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>
770 lines
43 KiB
TypeScript
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 & 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>
|
|
);
|
|
};
|