Files
Momento/architectural-grid1/src/components/NotebookInfoSidebar.tsx
Antigravity f46654f574 feat: editor improvements and architectural grid prototype
Multiple feature additions and improvements across the application:

- NextGen Editor: drag handles, smart paste, block actions
- Structured views: Kanban and table layouts for notes
- Architectural Grid: new brainstorming/agent interface prototype
- Flashcards: SM-2 revision algorithm with AI generation
- MCP server: robustness improvements
- Graph/PDF chat: fix click propagation and copy behavior
- Various UI/UX enhancements and bug fixes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:45:15 +00:00

770 lines
43 KiB
TypeScript

import React from 'react';
import {
X,
Clock,
Folder,
Calendar,
FileText,
Hash,
Network,
CheckCircle2,
AlertCircle,
Copy,
Check,
History,
Info,
ChevronRight
} from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { Note, Carnet } from '../types';
interface NotebookInfoSidebarProps {
isOpen: boolean;
onClose: () => void;
activeNote: Note | undefined;
notes: Note[];
carnets: Carnet[];
onOpenNote: (id: string) => void;
onUpdateNote?: (note: Note) => void;
}
export const NotebookInfoSidebar: React.FC<NotebookInfoSidebarProps> = ({
isOpen,
onClose,
activeNote,
notes = [],
carnets,
onOpenNote,
onUpdateNote
}) => {
const [activeTab, setActiveTab] = React.useState<'infos' | 'versions' | 'relations'>('infos');
const [copiedId, setCopiedId] = React.useState(false);
const [hoveredOrbitNode, setHoveredOrbitNode] = React.useState<any | null>(null);
// For ID copy action
const handleCopyId = (id: string) => {
navigator.clipboard.writeText(id).then(() => {
setCopiedId(true);
setTimeout(() => setCopiedId(false), 2000);
});
};
// Explicit links for Network
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';
// Network calculation values
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 <span>{content.substring(0, 80)}...</span>;
}
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 (
<span>
{start > 0 && "..."}
{before}
<mark className="bg-ochre/20 dark:bg-ochre/40 text-ochre px-1 py-0.5 rounded font-bold">{match}</mark>
{after}
{end < content.length && "..."}
</span>
);
};
// Safe time calculation helper (mocked cleanly to match image's 'il y a 12 jours' or standard dynamic calculations)
const getRelativeCreatedStr = (dateStr: string) => {
if (dateStr.includes('12 mai 2026')) return 'il y a 12 jours';
if (dateStr.includes('Oct 26')) return 'il y a 2h';
if (dateStr.includes('Oct 27')) return 'il y a 1j';
if (dateStr.includes('Oct 24')) return 'il y a 3j';
if (dateStr.includes('Oct 25')) return 'il y a 2j';
if (dateStr.includes('Oct 22')) return 'il y a 5j';
if (dateStr.includes('Oct 23')) return 'il y a 4j';
if (dateStr.includes('Oct 28')) return 'il y a 10 min';
return 'il y a quelques jours';
};
return (
<AnimatePresence>
{isOpen && (
<motion.aside
initial={{ x: 380, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 380, opacity: 0 }}
transition={{ type: 'spring', damping: 26, stiffness: 210 }}
className="w-[380px] border-l border-border bg-[#F5F4F0] dark:bg-[#121212] shadow-xl flex flex-col z-50 shrink-0 relative h-full select-none"
>
{/* Header tabs row matching image style */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50 bg-[#F5F4F0]/85 dark:bg-[#121212]/85 backdrop-blur-md">
<div className="flex gap-2.5">
{/* Infos tab */}
<button
onClick={() => setActiveTab('infos')}
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-full text-xs font-bold tracking-wide transition-all duration-200 cursor-pointer
${activeTab === 'infos'
? 'bg-ink text-paper dark:bg-white dark:text-ink shadow-sm'
: 'text-concrete hover:text-ink hover:bg-black/[0.03] dark:hover:bg-white/5'}`}
>
<CheckCircle2 size={13} className={activeTab === 'infos' ? 'opacity-100' : 'opacity-70'} />
<span>Infos</span>
</button>
{/* Versions tab */}
<button
onClick={() => setActiveTab('versions')}
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-full text-xs font-bold tracking-wide transition-all duration-200 cursor-pointer
${activeTab === 'versions'
? 'bg-ink text-paper dark:bg-white dark:text-ink shadow-sm'
: 'text-concrete hover:text-ink hover:bg-black/[0.03] dark:hover:bg-white/5'}`}
>
<Clock size={13} className={activeTab === 'versions' ? 'opacity-100' : 'opacity-70'} />
<span>Versions</span>
</button>
{/* Network / Relations tab */}
<button
onClick={() => setActiveTab('relations')}
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-full text-xs font-bold tracking-wide transition-all duration-200 cursor-pointer
${activeTab === 'relations'
? 'bg-ink text-paper dark:bg-white dark:text-ink shadow-sm'
: 'text-concrete hover:text-ink hover:bg-black/[0.03] dark:hover:bg-white/5'}`}
>
<Network size={13} className={activeTab === 'relations' ? 'opacity-100' : 'opacity-70'} />
<span>Réseau</span>
</button>
</div>
<button
onClick={onClose}
className="p-1 px-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-full text-concrete hover:text-ink transition-all cursor-pointer"
>
<X size={18} />
</button>
</div>
{/* Core scrollable content area */}
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar space-y-6">
<AnimatePresence mode="wait">
{/* TABS - INFOS */}
{activeTab === 'infos' && (
<motion.div
key="infos"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="space-y-6 text-left"
>
{activeNote ? (
<div className="space-y-6 font-sans">
{/* Calculated Stats */}
{(() => {
const wordCount = activeNote.content.trim() ? activeNote.content.trim().split(/\s+/).filter(Boolean).length : 0;
const charCount = activeNote.content.length;
const lineCount = activeNote.content.trim() ? activeNote.content.split('\n').length : 0;
// Count math equations
const matchesBigMath = (activeNote.content.match(/\$\$[\s\S]*?\$\$/g) || []).length;
const matchesInlineMath = (activeNote.content.match(/\$[^\$\n]+?\$/g) || []).length;
const equationCount = matchesBigMath + matchesInlineMath;
// Count graph relations or internal visual blocks
const matchesLivingBlocks = (activeNote.content.match(/\[\[living-block:.*?\]\]/g) || []).length;
const graphCount = orbitNodes.length + matchesLivingBlocks;
// Count images
const matchesMarkdownImages = (activeNote.content.match(/!\[.*?\]\(.*?\)/g) || []).length;
const matchesHtmlImages = (activeNote.content.match(/<img\s+/g) || []).length;
const imageCount = matchesMarkdownImages + matchesHtmlImages;
return (
<>
{/* Grid Stats */}
<div className="grid grid-cols-2 gap-4">
<div className="bg-white/95 dark:bg-black/40 border border-border/50 rounded-2xl p-5 text-center flex flex-col justify-between min-h-[105px] shadow-sm hover:shadow-md transition-all duration-300">
<span className="block text-4xl font-medium font-serif text-ink dark:text-white tracking-tight leading-none">
{wordCount}
</span>
<span className="text-[9.5px] font-bold uppercase tracking-[0.25em] text-muted-ink block mt-2">Mots</span>
</div>
<div className="bg-white/95 dark:bg-black/40 border border-border/50 rounded-2xl p-5 text-center flex flex-col justify-between min-h-[105px] shadow-sm hover:shadow-md transition-all duration-300">
<span className="block text-4xl font-medium font-serif text-ink dark:text-white tracking-tight leading-none">
{charCount}
</span>
<span className="text-[9.5px] font-bold uppercase tracking-[0.25em] text-muted-ink block mt-2">Caractères</span>
</div>
</div>
{/* Secondary Detailed Counts Widget */}
<div className="grid grid-cols-4 gap-1.5 bg-white/70 dark:bg-black/30 border border-border/50 rounded-2xl p-4 text-center shadow-xs">
<div className="space-y-1">
<span className="block text-base font-serif font-bold text-ink dark:text-white">{lineCount}</span>
<span className="block text-[8px] font-extrabold uppercase tracking-widest text-concrete">Lignes</span>
</div>
<div className="space-y-1 border-l border-border/40">
<span className="block text-base font-serif font-bold text-ink dark:text-white">{equationCount}</span>
<span className="block text-[8px] font-extrabold uppercase tracking-widest text-concrete">Équations</span>
</div>
<div className="space-y-1 border-l border-border/40">
<span className="block text-base font-serif font-bold text-ink dark:text-white">{graphCount}</span>
<span className="block text-[8px] font-extrabold uppercase tracking-widest text-concrete">Graphes</span>
</div>
<div className="space-y-1 border-l border-border/40">
<span className="block text-base font-serif font-bold text-ink dark:text-white">{imageCount}</span>
<span className="block text-[8px] font-extrabold uppercase tracking-widest text-concrete">Images</span>
</div>
</div>
</>
);
})()}
{/* Attribute Detail rows styled to 100% exact layout matching the attached image */}
<div className="space-y-5 bg-white/40 dark:bg-zinc-950/20 border border-border/50 rounded-2xl p-5 text-left select-text">
{/* Carnet attribute */}
<div className="flex items-start gap-4 pb-4 border-b border-border/30">
<div className="p-2.5 bg-white dark:bg-neutral-900 border border-border/60 rounded-xl text-concrete shrink-0">
<Folder size={15} />
</div>
<div className="space-y-0.5">
<span className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink block">Carnet</span>
<span className="text-sm font-semibold text-ink dark:text-white">
{carnets.find(c => c.id === activeNote.carnetId)?.name || "Général"}
</span>
</div>
</div>
{/* Type attribute */}
<div className="flex items-start gap-4 pb-4 border-b border-border/30">
<div className="p-2.5 bg-white dark:bg-neutral-900 border border-border/60 rounded-xl text-concrete shrink-0">
<FileText size={15} />
</div>
<div className="space-y-0.5">
<span className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink block">Type</span>
<span className="text-sm font-semibold text-ink dark:text-white">
{activeNote.isClipped ? 'Source Web' : 'Texte enrichi'}
</span>
</div>
</div>
{/* Créé le attribute */}
<div className="flex items-start gap-4 pb-4 border-b border-border/30">
<div className="p-2.5 bg-white dark:bg-neutral-900 border border-border/60 rounded-xl text-concrete shrink-0">
<Calendar size={15} />
</div>
<div className="space-y-0.5">
<span className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink block">Créée le</span>
<span className="text-sm font-semibold text-ink dark:text-white block">
{activeNote.date || "12 mai 2026"}
</span>
<span className="text-[10.5px] text-muted-ink block">
{getRelativeCreatedStr(activeNote.date || "12 mai 2026")}
</span>
</div>
</div>
{/* Modifiée attribute */}
<div className="flex items-start gap-4 pb-4 border-b border-border/30">
<div className="p-2.5 bg-white dark:bg-neutral-900 border border-border/60 rounded-xl text-concrete shrink-0">
<Clock size={15} />
</div>
<div className="space-y-0.5">
<span className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink block">Modifiée</span>
<span className="text-sm font-semibold text-ink dark:text-white block">
{activeNote.date || "12 mai 2026"} 15:58
</span>
<span className="text-[10.5px] text-muted-ink block">
{getRelativeCreatedStr(activeNote.date || "12 mai 2026")}
</span>
</div>
</div>
{/* ID attribute */}
<div className="flex items-start gap-4">
<div className="p-2.5 bg-white dark:bg-neutral-900 border border-border/60 rounded-xl text-concrete shrink-0">
<Hash size={15} />
</div>
<div className="space-y-0.5 min-w-0 flex-1">
<span className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink block">ID</span>
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-[11px] font-mono text-muted-ink truncate block select-all" title={activeNote.id}>
{activeNote.id}
</span>
<button
onClick={() => handleCopyId(activeNote.id)}
className="p-1 hover:bg-slate-100 dark:hover:bg-neutral-800 rounded text-concrete shrink-0 transition-all cursor-pointer"
title="Copier l'ID de la note"
>
{copiedId ? <Check size={11} className="text-emerald-500" /> : <Copy size={11} />}
</button>
</div>
</div>
</div>
</div>
{/* Snapshots Toggle */}
<div className="bg-white/50 dark:bg-neutral-900/40 border border-border/50 rounded-2xl p-5 flex items-center justify-between group hover:shadow-sm transition-all duration-300">
<div className="flex items-center gap-3.5 text-left">
<div className="p-2.5 bg-paper dark:bg-neutral-850 rounded-xl text-ochre border border-ochre/10">
<History size={16} />
</div>
<div>
<h4 className="text-xs font-bold text-ink dark:text-white">Snapshots Actifs</h4>
<p className="text-[10px] text-muted-ink leading-relaxed">Suivi d'historique automatique</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer shrink-0">
<input
type="checkbox"
className="sr-only peer"
checked={activeNote.isVersioningEnabled !== false}
onChange={() => {
onUpdateNote?.({
...activeNote,
isVersioningEnabled: activeNote.isVersioningEnabled === false ? true : false
});
}}
/>
<div className="w-10 h-5.5 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[18px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-3.5 after:w-3.5 after:transition-all duration-300 ease-in-out peer-checked:bg-ink dark:peer-checked:bg-white"></div>
</label>
</div>
</div>
) : (
<div className="text-center py-16 text-muted-ink/40">
<Folder size={36} className="mx-auto mb-3 opacity-30 text-concrete" />
<p className="text-xs font-serif italic">Veuillez sélectionner une note pour inspecter ses informations.</p>
</div>
)}
</motion.div>
)}
{/* TABS - VERSIONS */}
{activeTab === 'versions' && (
<motion.div
key="versions"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="space-y-6 text-left"
>
<div className="flex items-center justify-between pl-1">
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-muted-ink">Snapshots &amp; Versions</h4>
<span className="text-[9px] font-mono text-muted-ink bg-black/5 dark:bg-white/5 px-2 py-0.5 rounded-full">
{(activeNote?.versionHistory || []).length} Snapshots
</span>
</div>
{activeNote ? (
<div className="space-y-5">
{activeNote.isVersioningEnabled !== false ? (
<>
{/* Banner to snap manual version */}
<div className="p-4 bg-ochre/5 dark:bg-neutral-900 border border-ochre/20 rounded-xl space-y-3">
<div className="text-left space-y-0.5">
<span className="text-[10px] text-ochre uppercase font-bold tracking-widest block">Garnir l'historique</span>
<p className="text-[10px] text-muted-ink leading-relaxed">Figer manuellement l'état actuel de la note.</p>
</div>
<button
onClick={() => {
const newSnapshot = {
id: 'v-' + Date.now(),
title: activeNote.title,
content: activeNote.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: activeNote.content.length
};
onUpdateNote?.({
...activeNote,
versionHistory: [newSnapshot, ...(activeNote.versionHistory || [])]
});
}}
className="w-full text-center py-2 bg-ink dark:bg-white hover:opacity-90 text-paper dark:text-ink text-[10px] uppercase tracking-widest font-bold rounded-lg transition-all shadow-sm cursor-pointer"
>
Figer un instant
</button>
</div>
{/* Snapshot list */}
<div className="space-y-3">
{(activeNote.versionHistory || []).length > 0 ? (
<div className="space-y-3 max-h-[440px] overflow-y-auto custom-scrollbar pr-1">
{(activeNote.versionHistory || []).map((v) => (
<div
key={v.id}
className="p-4 bg-white dark:bg-zinc-950 border border-border hover:border-accent/40 rounded-xl space-y-2.5 transition-all shadow-xs"
>
<div className="flex items-start justify-between gap-2">
<div className="space-y-0.5 text-left">
<span className="text-xs uppercase tracking-wide font-bold text-ink dark:text-white block truncate max-w-[190px]">
{v.title}
</span>
<span className="text-[9.5px] text-muted-ink block">{v.timestamp}</span>
</div>
<span className="text-[9px] font-mono text-muted-ink bg-slate-100 dark:bg-neutral-850 px-1.5 py-0.5 rounded">
{v.size >= 1024 ? (v.size / 1024).toFixed(1) + ' KB' : v.size + ' B'}
</span>
</div>
<div className="flex items-center justify-end gap-3.5 pt-2 border-t border-black/[0.03] dark:border-white/[0.02] text-[10px]">
<button
onClick={() => {
alert(`Aperçu de la version "${v.title}" :\n\n${v.content || "Note vide"}`);
}}
className="text-muted-ink hover:text-ink transition-colors font-semibold"
>
Aperçu
</button>
<button
onClick={() => {
if (window.confirm("Êtes-vous sûr de vouloir restaurer cette version ? Le contenu actuel sera archivé comme nouvelle version.")) {
const backupSnapshot = {
id: 'v-' + Date.now(),
title: activeNote.title,
content: activeNote.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: activeNote.content.length
};
onUpdateNote?.({
...activeNote,
title: v.title,
content: v.content,
versionHistory: [backupSnapshot, ...(activeNote.versionHistory || []).filter(h => h.id !== v.id)]
});
}
}}
className="text-ochre dark:text-ochre font-bold hover:underline"
>
Restaurer
</button>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-16 px-6 border border-dashed border-border/80 bg-white/45 rounded-xl text-muted-ink/50">
<Clock size={24} className="mx-auto mb-2 opacity-30 text-concrete" />
<p className="text-[11px] font-medium leading-relaxed">Aucun snapshot enregistré pour le moment. Modifiez la note pour démarrer le suivi ou figez-en un manuellement.</p>
</div>
)}
</div>
</>
) : (
<div className="text-center py-12 px-6 border-2 border-dashed border-border/60 rounded-2xl bg-amber-500/5 border-amber-500/10 text-amber-600 space-y-3">
<AlertCircle size={28} className="mx-auto opacity-70" />
<h5 className="font-bold text-xs uppercase tracking-wider">Suivi d'historique inactif</h5>
<p className="text-[10px] leading-relaxed text-concrete">L'historique des versions est actuellement désactivé pour cette note spécifique. Pour l'activer, cochez l'option dans l'onglet "Infos".</p>
</div>
)}
</div>
) : (
<div className="text-center py-16 text-muted-ink/40">
<Clock size={36} className="mx-auto mb-3 opacity-30 text-concrete" />
<p className="text-xs font-serif italic">Veuillez sélectionner une note pour voir son historique de versions.</p>
</div>
)}
</motion.div>
)}
{/* TABS - RELATIONS (RESEAU) */}
{activeTab === 'relations' && (
<motion.div
key="relations"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="space-y-6 text-left"
>
<div className="flex items-center gap-2 mb-1">
<div className="h-px flex-1 bg-border/40" />
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-muted-ink whitespace-nowrap">Vue Graphe Locale</h4>
<div className="h-px flex-1 bg-border/40" />
</div>
{activeNote ? (
<>
{/* Interactive Local Graph representation */}
<div className="relative p-2 bg-white/80 dark:bg-black/30 border border-border/60 rounded-xl overflow-hidden shadow-inner flex flex-col items-center">
<svg width="100%" height="220" viewBox="0 0 320 220" className="select-none font-sans">
<defs>
<filter id="glow-panel-sidebar-three" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="4" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
{/* Dotted boundary */}
<circle cx="160" cy="110" r="70" fill="none" stroke="#E2E8F0" strokeWidth="1" strokeDasharray="3,6" className="dark:stroke-neutral-800" />
{/* Links */}
{orbitNodes.map((node, i) => {
const angle = i * (orbitNodes.length > 0 ? (2 * Math.PI) / orbitNodes.length : 0);
const nx = 160 + 70 * Math.cos(angle);
const ny = 110 + 62 * Math.sin(angle);
return (
<g key={node.id}>
<line
x1="160"
y1="110"
x2={nx}
y2={ny}
stroke={node.relationship === 'mention' ? '#94A3B8' : '#A47148'}
strokeWidth={node.relationship === 'mention' ? 1.2 : 2}
strokeDasharray={node.relationship === 'mention' ? '3,3' : 'none'}
className="opacity-50 transition-all hover:opacity-100"
/>
{node.relationship === 'outbound' && (
<polygon
points={`${160 + (nx - 160) * 0.75},${110 + (ny - 110) * 0.75} ${160 + (nx - 160) * 0.75 - 4},${110 + (ny - 110) * 0.75 - 4} ${160 + (nx - 160) * 0.75 - 4},${110 + (ny - 110) * 0.75 + 4}`}
transform={`rotate(${(angle * 180) / Math.PI}, ${160 + (nx - 160) * 0.75}, ${110 + (ny - 110) * 0.75})`}
fill="#A47148"
className="opacity-70"
/>
)}
{node.relationship === 'backlink' && (
<polygon
points={`${160 + (nx - 160) * 0.3},${110 + (ny - 110) * 0.3} ${160 + (nx - 160) * 0.3 - 4},${110 + (ny - 110) * 0.3 - 4} ${160 + (nx - 160) * 0.3 - 4},${110 + (ny - 110) * 0.3 + 4}`}
transform={`rotate(${((angle + Math.PI) * 180) / Math.PI}, ${160 + (nx - 160) * 0.3}, ${110 + (ny - 110) * 0.3})`}
fill="#A47148"
className="opacity-70"
/>
)}
</g>
);
})}
{/* Center Node: Active Note */}
<g>
<circle
cx="160"
cy="110"
r="15"
fill="#A47148"
className="stroke-white dark:stroke-black stroke-[3px] shadow transition-transform duration-300 hover:scale-110 active:scale-95 cursor-pointer"
/>
<circle cx="160" cy="110" r="5" fill="#FFFFFF" />
</g>
{/* Orbit nodes */}
{orbitNodes.map((node, i) => {
const angle = i * (orbitNodes.length > 0 ? (2 * Math.PI) / orbitNodes.length : 0);
const nx = 160 + 70 * Math.cos(angle);
const ny = 110 + 62 * Math.sin(angle);
const isHovered = hoveredOrbitNode?.id === node.id;
return (
<g
key={node.id}
className="cursor-pointer group"
onClick={() => onOpenNote(node.id)}
onMouseEnter={() => setHoveredOrbitNode(node)}
onMouseLeave={() => setHoveredOrbitNode(null)}
>
<circle
cx={nx}
cy={ny}
r={isHovered ? 11 : 8}
fill={node.color}
stroke={isHovered ? '#000000' : '#FFFFFF'}
strokeWidth={1.5}
className="transition-all duration-200 group-hover:shadow"
/>
<text
x={nx}
y={ny + 15}
textAnchor="middle"
fontSize="8"
className="fill-concrete bg-white font-medium select-none pointer-events-none opacity-40 hover:opacity-100 transition-opacity"
>
{node.title.substring(0, 10)}
</text>
</g>
);
})}
</svg>
<div className="absolute bottom-2 left-2 right-2 p-2 bg-white/90 dark:bg-black/95 rounded-lg border border-border/40 text-left min-h-[46px] select-text">
{hoveredOrbitNode ? (
<div className="animate-fadeIn">
<div className="flex justify-between items-center text-[8px] text-muted-ink uppercase tracking-wider">
<span>{hoveredOrbitNode.carnetName}</span>
<span className="font-bold">
{hoveredOrbitNode.relationship === 'backlink' ? 'Lien Entrant' : hoveredOrbitNode.relationship === 'outbound' ? 'Lien Sortant' : 'Mention Simple'}
</span>
</div>
<p className="font-bold text-ink dark:text-white truncate text-xs">{hoveredOrbitNode.title}</p>
<p className="text-[9px] text-muted-ink italic">Cliquez pour ouvrir la note</p>
</div>
) : (
<div className="text-center py-1 text-muted-ink/60 text-[10px] font-medium leading-normal flex items-center justify-center gap-1.5">
<Network size={12} className="text-muted-ink/40" />
Survolez un nœud, cliquez pour ouvrir
</div>
)}
</div>
</div>
{/* Explicit links listings with highlighting */}
<div className="space-y-4 pt-2 font-sans text-left">
{/* 1. Backlinks */}
<div className="space-y-1.5">
<h5 className="text-[10px] uppercase tracking-[0.18em] font-bold text-muted-ink">
Liens Entrants ({backlinks.length})
</h5>
{backlinks.length > 0 ? (
<div className="space-y-2 max-h-[160px] overflow-y-auto custom-scrollbar pr-1">
{backlinks.map(n => (
<div
key={n.id}
onClick={() => onOpenNote(n.id)}
className="p-3 bg-white dark:bg-zinc-950 border border-border hover:border-accent/40 rounded-xl cursor-pointer transition-all space-y-1.5 hover:shadow-sm"
>
<div className="flex items-center justify-between text-muted-ink font-sans">
<span className="text-[9px] font-bold uppercase tracking-wider text-ink dark:text-white truncate max-w-[180px]">{n.title}</span>
<span className="text-[8px] bg-accent/5 text-accent/80 px-1.5 py-0.5 rounded-full font-bold uppercase tracking-tight">Réf</span>
</div>
<p className="text-[11px] text-ink/70 dark:text-white/70 italic leading-snug select-text">
{getSnippetWithHighlight(n.content, activeNote.title)}
</p>
</div>
))}
</div>
) : (
<p className="text-[10px] text-muted-ink leading-normal italic bg-white/45 p-3 rounded-xl border border-border/40">Aucun lien entrant de type wiki [[lien]] pointant vers cette note.</p>
)}
</div>
{/* 2. Outbound Links */}
<div className="space-y-1.5">
<h5 className="text-[10px] uppercase tracking-[0.18em] font-bold text-muted-ink">
Liens Sortants ({outboundLinks.length})
</h5>
{outboundLinks.length > 0 ? (
<div className="space-y-2 max-h-[160px] overflow-y-auto custom-scrollbar pr-1">
{outboundLinks.map(n => (
<div
key={n.id}
onClick={() => onOpenNote(n.id)}
className="p-3 bg-white dark:bg-zinc-950 border border-border hover:border-accent/40 rounded-xl cursor-pointer transition-all space-y-1.5 hover:shadow-sm"
>
<div className="flex items-center justify-between text-muted-ink font-sans">
<span className="text-[9px] font-bold uppercase tracking-wider text-ink dark:text-white truncate max-w-[180px]">{n.title}</span>
<span className="text-[8px] bg-ochre/5 text-ochre/80 px-1.5 py-0.5 rounded-full font-bold uppercase tracking-tight">Vers</span>
</div>
<p className="text-[11px] text-ink/70 dark:text-white/70 italic leading-snug select-text">
{getSnippetWithHighlight(activeNote.content, n.title)}
</p>
</div>
))}
</div>
) : (
<p className="text-[10px] text-muted-ink leading-normal italic bg-white/45 p-3 rounded-xl border border-border/40">Cette note ne pointe vers aucune autre note de type [[lien]].</p>
)}
</div>
</div>
</>
) : (
<div className="text-center py-16 text-muted-ink/40">
<Network size={36} className="mx-auto mb-3 opacity-30 text-concrete" />
<p className="text-xs font-serif italic">Sélectionnez une note pour analyser son graphe local.</p>
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</motion.aside>
)}
</AnimatePresence>
);
};