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:
875
architectural-grid/src/components/Sidebar.tsx
Normal file
875
architectural-grid/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,875 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Archive,
|
||||
Settings,
|
||||
ChevronRight,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Microscope,
|
||||
Activity,
|
||||
Pin,
|
||||
Moon,
|
||||
Sun,
|
||||
Bell,
|
||||
Lock,
|
||||
Edit3,
|
||||
Trash2,
|
||||
Users,
|
||||
Clock,
|
||||
GripVertical,
|
||||
Wind,
|
||||
Network,
|
||||
Home,
|
||||
Sparkles,
|
||||
LogOut,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FileText,
|
||||
Search,
|
||||
BookMarked,
|
||||
User,
|
||||
ExternalLink,
|
||||
ChevronUp,
|
||||
HelpCircle,
|
||||
EyeOff,
|
||||
Layers,
|
||||
Scissors,
|
||||
Chrome,
|
||||
Crown,
|
||||
ArrowRight,
|
||||
GraduationCap
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { NavigationView, Carnet, Note, SettingsTab, Flashcard } from '../types';
|
||||
|
||||
interface NoteLinkProps {
|
||||
note: Note;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const NoteLink: React.FC<NoteLinkProps> = ({ note, isActive, onClick }) => (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
onClick={onClick}
|
||||
className={`w-full flex items-center gap-2 pl-6 pr-3 py-1.5 text-[11px] transition-all rounded-lg text-left
|
||||
${isActive ? 'bg-white shadow-sm border border-border/50 dark:bg-white/10 text-ink font-semibold' : 'text-muted-ink hover:text-ink hover:bg-white/30'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 truncate">
|
||||
<FileText size={12} className={isActive ? "text-accent shrink-0" : "text-concrete opacity-70 shrink-0"} />
|
||||
<span className="truncate">{note.title || "Note sans titre"}</span>
|
||||
</div>
|
||||
{note.isPinned && <Pin size={10} className="text-amber-500 shrink-0" />}
|
||||
</motion.button>
|
||||
);
|
||||
|
||||
interface SidebarItemProps {
|
||||
carnet: Carnet;
|
||||
isActive: boolean;
|
||||
notes: Note[];
|
||||
activeNoteId: string | null;
|
||||
onCarnetClick: () => void;
|
||||
onNoteClick: (noteId: string) => void;
|
||||
onAddSubCarnet: () => void;
|
||||
onRename: () => void;
|
||||
onDelete: () => void;
|
||||
children?: React.ReactNode;
|
||||
level: number;
|
||||
isExpanded: boolean;
|
||||
toggleExpand: () => void;
|
||||
onMove?: (draggedId: string, targetId?: string) => void;
|
||||
}
|
||||
|
||||
const SidebarItem: React.FC<SidebarItemProps> = ({
|
||||
carnet,
|
||||
isActive,
|
||||
notes,
|
||||
activeNoteId,
|
||||
onCarnetClick,
|
||||
onNoteClick,
|
||||
onAddSubCarnet,
|
||||
onRename,
|
||||
onDelete,
|
||||
children,
|
||||
level,
|
||||
isExpanded,
|
||||
toggleExpand,
|
||||
onMove
|
||||
}) => {
|
||||
const hasChildren = React.Children.count(children) > 0 || notes.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div
|
||||
className="flex items-center group relative h-8 select-none"
|
||||
style={{ paddingLeft: `${level * 10}px` }}
|
||||
>
|
||||
{/* Subtle Drag Handle */}
|
||||
<div className="absolute left-[-2px] opacity-0 group-hover:opacity-40 cursor-grab active:cursor-grabbing text-concrete transition-opacity z-10">
|
||||
<GripVertical size={10} />
|
||||
</div>
|
||||
|
||||
{/* Hierarchy Guide Line */}
|
||||
{level > 0 && (
|
||||
<div className="absolute left-[4px] top-[-10px] bottom-1/2 w-px bg-border/20" />
|
||||
)}
|
||||
{level > 0 && (
|
||||
<div className="absolute left-[4px] top-1/2 w-[8px] h-px bg-border/20" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex items-center gap-1.5">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpand();
|
||||
}}
|
||||
className="p-0.5 hover:bg-ink/5 dark:hover:bg-white/5 rounded transition-colors text-concrete"
|
||||
>
|
||||
<motion.div animate={{ rotate: isExpanded ? 90 : 0 }} transition={{ duration: 0.15 }}>
|
||||
<ChevronRight size={12} />
|
||||
</motion.div>
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-4" /> // Spacer for alignment
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
whileHover={{ x: 1 }}
|
||||
className={`flex-1 flex items-center gap-2 px-2 py-1 rounded-lg transition-all duration-200 group/item cursor-pointer relative
|
||||
${isActive ? 'bg-white shadow-sm border border-border/50 dark:bg-white/10' : 'hover:bg-white/40 dark:hover:bg-white/5'}`}
|
||||
onClick={onCarnetClick}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
e.currentTarget.classList.add('bg-accent/5', 'ring-1', 'ring-accent/20');
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.currentTarget.classList.remove('bg-accent/5', 'ring-1', 'ring-accent/20');
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove('bg-accent/5', 'ring-1', 'ring-accent/20');
|
||||
const draggedId = e.dataTransfer.getData('carnetId');
|
||||
if (draggedId && draggedId !== carnet.id) {
|
||||
onMove?.(draggedId, carnet.id);
|
||||
}
|
||||
}}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('carnetId', carnet.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
const ghost = e.currentTarget.cloneNode(true) as HTMLElement;
|
||||
ghost.style.position = 'absolute';
|
||||
ghost.style.top = '-1000px';
|
||||
ghost.style.opacity = '0.5';
|
||||
document.body.appendChild(ghost);
|
||||
e.dataTransfer.setDragImage(ghost, 0, 0);
|
||||
setTimeout(() => document.body.removeChild(ghost), 0);
|
||||
}}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="active-indicator"
|
||||
className="absolute -left-1 w-1 h-3.5 bg-accent rounded-full"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="w-5 h-5 flex items-center justify-center text-concrete shrink-0">
|
||||
{isExpanded ? (
|
||||
<FolderOpen size={13} className={isActive ? "text-accent" : "text-concrete opacity-80"} />
|
||||
) : (
|
||||
<Folder size={13} className={isActive ? "text-accent" : "text-concrete opacity-80"} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 text-left flex items-center gap-2 min-w-0">
|
||||
<span className={`text-[12px] font-medium transition-colors truncate ${isActive ? 'text-ink font-semibold' : 'text-muted-ink group-hover:text-ink'}`}>
|
||||
{carnet.name}
|
||||
</span>
|
||||
{carnet.isPrivate && <Lock size={10} className="text-concrete/60 shrink-0" />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover/item:opacity-100 transition-opacity shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddSubCarnet();
|
||||
}}
|
||||
className="p-0.5 hover:bg-ink/10 dark:hover:bg-white/10 rounded transition-all text-concrete hover:text-ink"
|
||||
title="Ajouter un sous-carnet"
|
||||
>
|
||||
<Plus size={10} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRename();
|
||||
}}
|
||||
className="p-0.5 hover:bg-ink/10 dark:hover:bg-white/10 rounded transition-all text-concrete hover:text-ink"
|
||||
title="Renommer"
|
||||
>
|
||||
<Edit3 size={10} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="p-0.5 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-all text-concrete hover:text-red-500"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
|
||||
{notes.length > 0 && (
|
||||
<span className="text-[9px] font-bold text-concrete/40 px-1 border border-border/40 rounded-full group-hover:text-concrete transition-colors">
|
||||
{notes.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="relative" style={{ marginLeft: `${(level + 1) * 10 - 2}px` }}>
|
||||
{/* Vertical line for nested content path */}
|
||||
<div className="absolute left-[2px] top-0 bottom-3 w-px bg-black/[0.06] dark:bg-white/[0.06]" />
|
||||
|
||||
<div className="space-y-0.5 py-0.5 pl-2.5">
|
||||
{children}
|
||||
{notes.map(note => (
|
||||
<NoteLink
|
||||
key={note.id}
|
||||
note={note}
|
||||
isActive={activeNoteId === note.id}
|
||||
onClick={() => onNoteClick(note.id)}
|
||||
/>
|
||||
))}
|
||||
{notes.length === 0 && !React.Children.count(children) && (
|
||||
<p className="pl-6 py-1 text-[9px] italic text-concrete/40 font-light">
|
||||
Vide
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SidebarProps {
|
||||
activeView: NavigationView;
|
||||
isSidebarOpen: boolean;
|
||||
setIsSidebarOpen: (val: boolean) => void;
|
||||
isDarkMode: boolean;
|
||||
setIsDarkMode: (val: boolean) => void;
|
||||
setActiveView: (view: NavigationView) => void;
|
||||
setActiveSettingsTab?: (tab: SettingsTab) => void;
|
||||
carnets: Carnet[];
|
||||
notes: Note[];
|
||||
activeCarnetId: string;
|
||||
activeNoteId: string | null;
|
||||
setActiveCarnetId: (id: string) => void;
|
||||
setActiveNoteId: (id: string | null) => void;
|
||||
setShowNewCarnetModal: (show: boolean, parentId?: string, isRenaming?: boolean, carnetId?: string) => void;
|
||||
onDeleteCarnet: (id: string) => void;
|
||||
onMoveCarnet: (draggedId: string, targetId?: string) => void;
|
||||
onGoHome: () => void;
|
||||
onLogout: () => void;
|
||||
flashcards?: Flashcard[];
|
||||
onSelectReviewDeck?: (noteId: string) => void;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({
|
||||
activeView,
|
||||
isSidebarOpen,
|
||||
setIsSidebarOpen,
|
||||
isDarkMode,
|
||||
setIsDarkMode,
|
||||
setActiveView,
|
||||
setActiveSettingsTab,
|
||||
carnets,
|
||||
notes,
|
||||
activeCarnetId,
|
||||
activeNoteId,
|
||||
setActiveCarnetId,
|
||||
setActiveNoteId,
|
||||
setShowNewCarnetModal,
|
||||
onDeleteCarnet,
|
||||
onMoveCarnet,
|
||||
onGoHome,
|
||||
onLogout,
|
||||
flashcards,
|
||||
onSelectReviewDeck
|
||||
}) => {
|
||||
const [expandedIds, setExpandedIds] = React.useState<Set<string>>(new Set(['4', '1', '2', '3'])); // Default expand key guides
|
||||
const [collapsedSections, setCollapsedSections] = React.useState<Set<string>>(new Set());
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
|
||||
const toggleSection = (id: string) => {
|
||||
const newSet = new Set(collapsedSections);
|
||||
if (newSet.has(id)) newSet.delete(id);
|
||||
else newSet.add(id);
|
||||
setCollapsedSections(newSet);
|
||||
};
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
const newSet = new Set(expandedIds);
|
||||
if (newSet.has(id)) newSet.delete(id);
|
||||
else newSet.add(id);
|
||||
setExpandedIds(newSet);
|
||||
};
|
||||
|
||||
// Safe filtration based on searches
|
||||
const filteredCarnets = React.useMemo(() => {
|
||||
if (!searchQuery) return carnets;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return carnets.filter(c =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
notes.some(n => n.carnetId === c.id && n.title.toLowerCase().includes(q))
|
||||
);
|
||||
}, [carnets, searchQuery, notes]);
|
||||
|
||||
const activeNote = React.useMemo(() => {
|
||||
if (!activeNoteId) return null;
|
||||
return notes.find(n => n.id === activeNoteId);
|
||||
}, [notes, activeNoteId]);
|
||||
|
||||
// Extract outline markdown headings dynamically for the currently active note
|
||||
const headings = React.useMemo(() => {
|
||||
if (!activeNote || !activeNote.content) return [];
|
||||
const lines = activeNote.content.split('\n');
|
||||
const hs: { text: string; level: number }[] = [];
|
||||
lines.forEach(line => {
|
||||
const trimmed = line.trim();
|
||||
const hMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (hMatch) {
|
||||
hs.push({
|
||||
level: hMatch[1].length,
|
||||
text: hMatch[2].replace(/\[.*?\]\(.*?\)/g, '').replace(/[*_`]/g, '').trim()
|
||||
});
|
||||
}
|
||||
});
|
||||
return hs.slice(0, 10);
|
||||
}, [activeNote]);
|
||||
|
||||
const renderCarnetTree = (parentId: string | undefined = undefined, level: number = 0) => {
|
||||
return filteredCarnets
|
||||
.filter(c => c.parentId === parentId && !c.isDeleted)
|
||||
.map(carnet => {
|
||||
const carnetNotes = notes.filter(n => n.carnetId === carnet.id && !n.isDeleted);
|
||||
return (
|
||||
<SidebarItem
|
||||
key={carnet.id}
|
||||
carnet={carnet}
|
||||
isActive={activeCarnetId === carnet.id}
|
||||
notes={carnetNotes}
|
||||
activeNoteId={activeNoteId}
|
||||
level={level}
|
||||
isExpanded={expandedIds.has(carnet.id)}
|
||||
toggleExpand={() => toggleExpand(carnet.id)}
|
||||
onAddSubCarnet={() => {
|
||||
if (!expandedIds.has(carnet.id)) toggleExpand(carnet.id);
|
||||
setShowNewCarnetModal(true, carnet.id);
|
||||
}}
|
||||
onRename={() => {
|
||||
setShowNewCarnetModal(true, undefined, true, carnet.id);
|
||||
}}
|
||||
onDelete={() => {
|
||||
onDeleteCarnet(carnet.id);
|
||||
}}
|
||||
onCarnetClick={() => {
|
||||
setActiveCarnetId(carnet.id);
|
||||
setActiveNoteId(null);
|
||||
// Auto expand when clicking
|
||||
if (!expandedIds.has(carnet.id)) toggleExpand(carnet.id);
|
||||
}}
|
||||
onNoteClick={(id) => {
|
||||
setActiveCarnetId(carnet.id);
|
||||
setActiveNoteId(id);
|
||||
}}
|
||||
onMove={onMoveCarnet}
|
||||
>
|
||||
{renderCarnetTree(carnet.id, level + 1)}
|
||||
</SidebarItem>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{isSidebarOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[60] lg:hidden"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<aside className={`
|
||||
fixed inset-y-0 left-0 lg:relative z-[70] lg:z-20
|
||||
w-80 h-screen bg-white dark:bg-[#0D0D0D] border-r border-border/85 flex shrink-0
|
||||
transition-all duration-300 ease-in-out font-sans overflow-hidden
|
||||
${isSidebarOpen ? 'translate-x-0 shadow-2xl' : '-translate-x-full lg:translate-x-0'}
|
||||
`}>
|
||||
|
||||
{/* Column 1: Ultra Narrow Left Utility Active-Rail Bar -> Identical to Ribbon in SiYuan */}
|
||||
<div className="w-[54px] border-r border-border/40 bg-[#FAF9F5] dark:bg-[#0E0E0E] flex flex-col items-center justify-between py-5 shrink-0 select-none">
|
||||
{/* Top Stack: Logo & View Shortcuts */}
|
||||
<div className="flex flex-col items-center gap-4.5 w-full">
|
||||
{/* Visual SiYuan branding card */}
|
||||
<div
|
||||
onClick={() => { onGoHome(); setIsSidebarOpen(false); }}
|
||||
className="w-9 h-9 bg-accent hover:rotate-6 active:scale-95 flex items-center justify-center rounded-xl shadow-md transition-all cursor-pointer mb-2"
|
||||
title="Aller à la page d'accueil"
|
||||
>
|
||||
<span className="text-white font-serif font-black text-xs tracking-tight">M</span>
|
||||
</div>
|
||||
|
||||
{/* Tab items list */}
|
||||
<div className="flex flex-col gap-2 w-full px-1.5">
|
||||
{[
|
||||
{ id: 'notebooks', label: 'Feuilles / Docs', icon: <BookOpen size={16} /> },
|
||||
{ id: 'graph', label: 'Knowledge Map', icon: <Network size={16} /> },
|
||||
{ id: 'revision', label: 'Révisions / Decks', icon: <GraduationCap size={16} /> },
|
||||
{ id: 'agents', label: 'Agents IA Lab', icon: <Bot size={16} /> },
|
||||
{ id: 'reminders', label: 'Rappels & Alertes', icon: <Bell size={16} /> },
|
||||
].map(item => {
|
||||
const isSel = activeView === item.id || (item.id === 'agents' && ['brainstorm', 'insights', 'temporal'].includes(activeView));
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
setActiveView(item.id as any);
|
||||
}}
|
||||
className={`w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group
|
||||
${isSel
|
||||
? 'bg-accent/10 text-accent border border-accent/25'
|
||||
: 'text-concrete hover:text-ink dark:hover:text-dark-ink hover:bg-black/[0.03] dark:hover:bg-white/[0.03]'}`}
|
||||
>
|
||||
{/* Visual status pin */}
|
||||
{isSel && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-4 bg-accent rounded-r-full" />
|
||||
)}
|
||||
{item.icon}
|
||||
|
||||
{/* Tooltip */}
|
||||
<span className="absolute left-[58px] top-1/2 -translate-y-1/2 bg-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Stack: Trash, Light Mode, Settings, Logout */}
|
||||
<div className="flex flex-col gap-2 w-full px-1.5 items-center">
|
||||
{/* TRASH DISCIPLINE: Promoted directly on the sidebar utility ribbon for quick accessible storage management */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveView('trash');
|
||||
}}
|
||||
className={`w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group
|
||||
${activeView === 'trash'
|
||||
? 'bg-rose-500/10 text-rose-500 border border-rose-500/25'
|
||||
: 'text-concrete hover:text-rose-500 hover:bg-rose-500/5'}`}
|
||||
>
|
||||
{activeView === 'trash' && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-4 bg-rose-500 rounded-r-full" />
|
||||
)}
|
||||
<Trash2 size={16} />
|
||||
{notes.some(n => n.isDeleted) && (
|
||||
<span className="absolute top-1.5 right-1.5 w-1.5 h-1.5 bg-rose-500 rounded-full border border-[#FAF9F5] dark:border-[#0E0E0E]" />
|
||||
)}
|
||||
<span className="absolute left-[58px] top-1/2 -translate-y-1/2 bg-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
||||
Corbeille / Corbeille vide
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Shared */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveView('shared');
|
||||
}}
|
||||
className={`w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group
|
||||
${activeView === 'shared'
|
||||
? 'bg-[#E3EBFB] text-sky-600 dark:bg-white/10 dark:text-sky-400'
|
||||
: 'text-concrete hover:text-sky-500 hover:bg-sky-500/5'}`}
|
||||
>
|
||||
{activeView === 'shared' && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-4 bg-sky-500 rounded-r-full" />
|
||||
)}
|
||||
<Users size={16} />
|
||||
<span className="absolute left-[58px] top-1/2 -translate-y-1/2 bg-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
||||
Partagé
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Web Clipper Simulator Trigger */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent('toggle-clipper-simulator'));
|
||||
}}
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center text-concrete hover:text-cyan-500 hover:bg-cyan-500/5 transition-all relative group"
|
||||
>
|
||||
<Scissors size={15} className="-rotate-90 text-cyan-500" />
|
||||
<span className="absolute left-[58px] top-1/2 -translate-y-1/2 bg-[#0E0E0E] text-white text-[9.5px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-lg uppercase tracking-wider font-sans">
|
||||
Clipper Simulé
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Appearance Theme Switcher */}
|
||||
<button
|
||||
onClick={() => setIsDarkMode(!isDarkMode)}
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center text-concrete hover:text-ink dark:hover:text-dark-ink hover:bg-black/[0.03] dark:hover:bg-white/[0.03] transition-all relative group"
|
||||
>
|
||||
{isDarkMode ? <Sun size={15} /> : <Moon size={15} />}
|
||||
<span className="absolute left-[58px] top-1/2 -translate-y-1/2 bg-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
||||
{isDarkMode ? "Mode clair" : "Mode sombre"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Settings Panel */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveView('settings');
|
||||
setActiveSettingsTab?.('general');
|
||||
}}
|
||||
className={`w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group
|
||||
${activeView === 'settings'
|
||||
? 'bg-accent/10 text-accent border border-accent/25'
|
||||
: 'text-concrete hover:text-ink dark:text-concrete hover:bg-black/[0.03] dark:hover:bg-white/[0.03]'}`}
|
||||
>
|
||||
<Settings size={15} />
|
||||
<span className="absolute left-[58px] top-1/2 -translate-y-1/2 bg-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
||||
Paramètres
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Logout button */}
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center text-concrete hover:text-red-500 hover:bg-rose-500/5 transition-all relative group"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
<span className="absolute left-[58px] top-1/2 -translate-y-1/2 bg-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
||||
Déconnexion
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column 2: Large details zone (266px width) for list details - Dynamic depending on Ribbon view */}
|
||||
<div className="flex-1 h-full bg-[#FCFCFA] dark:bg-[#111111] flex flex-col overflow-hidden">
|
||||
|
||||
{/* Render notebook list detail content */}
|
||||
{activeView === 'notebooks' && (
|
||||
<div className="flex-1 flex flex-col p-4 overflow-hidden h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<BookMarked size={14} className="text-accent" />
|
||||
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">Documents</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowNewCarnetModal(true)}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded transition-all text-concrete hover:text-ink"
|
||||
title="Nouveau carnet parent"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Simple search bar as seen in standard file trees */}
|
||||
<div className="relative mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Rechercher doc..."
|
||||
className="w-full text-[11px] pl-7 pr-3 py-1.5 rounded-lg border border-border/60 bg-white/70 dark:bg-zinc-800 placeholder-concrete/50 outline-none focus:border-accent transition-colors text-ink dark:text-dark-ink"
|
||||
/>
|
||||
<Search size={11} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-concrete opacity-60" />
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[9px] uppercase font-bold text-concrete hover:text-ink"
|
||||
>
|
||||
X
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hierarchical list of documents */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar pr-1 mb-4">
|
||||
<div className="space-y-0.5">
|
||||
{renderCarnetTree()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* IA Usage & Upgrade Section */}
|
||||
<div className="border-t border-[#E3EBFB]/60 dark:border-border/40 pt-4 mt-auto select-none">
|
||||
<div className="p-3 bg-slate-50/70 dark:bg-zinc-900 border border-border/40 rounded-xl space-y-2.5 shadow-[0_1px_2px_rgba(0,0,0,0.02)]">
|
||||
<div className="flex items-center justify-between text-[10px]">
|
||||
<span className="font-bold text-ink/75 dark:text-dark-ink/80 flex items-center gap-1">
|
||||
<Sparkles size={11} className="text-accent" />
|
||||
Utilisation de l'IA
|
||||
</span>
|
||||
<span className="font-semibold text-concrete">49 / 50 restants</span>
|
||||
</div>
|
||||
|
||||
<div className="h-1 w-full bg-slate-100 dark:bg-white/5 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-accent hover:opacity-90 transition-all rounded-full" style={{ width: '98%' }} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveView('settings');
|
||||
setActiveSettingsTab?.('billing');
|
||||
}}
|
||||
className="w-full h-[28px] mt-1 flex items-center justify-between px-2.5 bg-accent/5 hover:bg-accent/10 hover:text-accent border border-accent/10 hover:border-accent/20 rounded-lg text-[10px] font-bold text-accent transition-all group cursor-pointer"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Crown size={10} className="fill-accent/10" />
|
||||
Passer au Plan Pro
|
||||
</span>
|
||||
<ArrowRight size={10} className="group-hover:translate-x-0.5 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render intelligence modules */}
|
||||
{activeView === 'agents' && (
|
||||
<div className="flex-1 flex flex-col p-4 overflow-y-auto custom-scrollbar justify-between">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Sparkles size={14} className="text-ochre" />
|
||||
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">Intelligence OS</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
{ id: 'brainstorm', label: 'Brainstorm Wave', desc: 'Génération d ideas rhizomatique', icon: <Wind size={15} /> },
|
||||
{ id: 'insights', label: 'Réseau Sémantique', desc: 'Cartographie de clusters DBSCAN', icon: <Network size={15} /> },
|
||||
{ id: 'temporal', label: 'Temporal Forecast', desc: 'Chronologie et prévisions', icon: <Clock size={15} /> },
|
||||
].map(sub => (
|
||||
<button
|
||||
key={sub.id}
|
||||
onClick={() => setActiveView(sub.id as any)}
|
||||
className="w-full text-left p-3 rounded-xl border border-border/30 hover:border-accent/30 bg-white dark:bg-zinc-800/50 hover:shadow-xs transition-all flex items-start gap-3 group"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-black/[0.03] dark:bg-white/[0.03] text-concrete group-hover:text-accent group-hover:bg-accent/5 transition-all shrink-0">
|
||||
{sub.icon}
|
||||
</div>
|
||||
<div className="truncate">
|
||||
<p className="text-[12px] font-bold text-ink dark:text-dark-ink group-hover:text-accent transition-colors">{sub.label}</p>
|
||||
<p className="text-[9px] text-concrete truncate mt-0.5">{sub.desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pack quota discovery */}
|
||||
<div className="p-3.5 bg-white dark:bg-zinc-800 border border-border/40 rounded-xl space-y-2 mt-auto">
|
||||
<div className="flex items-center justify-between text-[10px]">
|
||||
<span className="font-bold text-ink/70">Pack Découverte IA</span>
|
||||
<span className="font-semibold text-concrete">49 restants</span>
|
||||
</div>
|
||||
<div className="h-1 w-full bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-accent" style={{ width: '49%' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reminders section list view */}
|
||||
{activeView === 'reminders' && (
|
||||
<div className="flex-1 flex flex-col p-4 overflow-y-auto custom-scrollbar">
|
||||
<div className="flex items-center gap-1.5 mb-6">
|
||||
<Clock size={14} className="text-indigo-500" />
|
||||
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">Rappels Actifs</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-4 border border-dashed border-border/50 rounded-2xl bg-paper/20">
|
||||
<Bell size={20} className="text-concrete/40 mb-2.5" />
|
||||
<p className="text-[11px] text-concrete italic">Aucun rappel pour le moment.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Flashcards / Révisions panel view inside Column 2 of Sidebar */}
|
||||
{activeView === 'revision' && (
|
||||
<div className="flex-1 flex flex-col p-4 overflow-y-auto custom-scrollbar">
|
||||
<div className="flex items-center gap-1.5 mb-6">
|
||||
<GraduationCap size={14} className="text-accent" />
|
||||
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">Decks Révision</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{(() => {
|
||||
const deckNotesList: { noteId: string; title: string; count: number; mastery: number }[] = [];
|
||||
const cardGroups: Record<string, Flashcard[]> = {};
|
||||
(flashcards || []).forEach(card => {
|
||||
if (!cardGroups[card.noteId]) cardGroups[card.noteId] = [];
|
||||
cardGroups[card.noteId].push(card);
|
||||
});
|
||||
|
||||
Object.keys(cardGroups).forEach(noteId => {
|
||||
const noteItem = notes.find(n => n.id === noteId);
|
||||
if (!noteItem || noteItem.isDeleted) return;
|
||||
const cList = cardGroups[noteId];
|
||||
const mastered = cList.filter(c => c.mastered).length;
|
||||
deckNotesList.push({
|
||||
noteId,
|
||||
title: noteItem.title || 'Note sans titre',
|
||||
count: cList.length,
|
||||
mastery: cList.length > 0 ? mastered / cList.length : 0
|
||||
});
|
||||
});
|
||||
|
||||
return deckNotesList.map(deck => (
|
||||
<button
|
||||
key={deck.noteId}
|
||||
onClick={() => {
|
||||
onSelectReviewDeck?.(deck.noteId);
|
||||
}}
|
||||
className="w-full text-left p-2.5 rounded-xl border border-border/40 hover:border-accent/30 bg-white dark:bg-zinc-800/40 hover:shadow-2xs transition-all flex items-start gap-2.5 group cursor-pointer"
|
||||
>
|
||||
<div className="w-7 h-7 bg-accent/5 text-accent rounded-lg flex items-center justify-center shrink-0 group-hover:bg-accent group-hover:text-white transition-all">
|
||||
<GraduationCap size={13} />
|
||||
</div>
|
||||
<div className="truncate flex-1 min-w-0">
|
||||
<p className="text-[11px] font-bold text-ink dark:text-dark-ink truncate group-hover:text-accent transition-colors">
|
||||
{deck.title}
|
||||
</p>
|
||||
<p className="text-[8.5px] text-concrete truncate mt-0.5">
|
||||
{deck.count} cartes · {Math.round(deck.mastery * 100)}% acquis
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
));
|
||||
})()}
|
||||
|
||||
{(!flashcards || flashcards.length === 0) && (
|
||||
<div className="flex flex-col items-center justify-center text-center p-4 border border-dashed border-border/55 rounded-2xl bg-paper/20">
|
||||
<GraduationCap size={18} className="text-concrete/40 mb-2" />
|
||||
<p className="text-[10px] text-concrete italic">Aucun deck créé.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shared panel view */}
|
||||
{activeView === 'shared' && (
|
||||
<div className="flex-1 flex flex-col p-4 overflow-y-auto custom-scrollbar">
|
||||
<div className="flex items-center gap-1.5 mb-6">
|
||||
<Users size={14} className="text-sky-500" />
|
||||
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">Partagé avec moi</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-4 border border-dashed border-border/50 rounded-2xl bg-paper/20">
|
||||
<Users size={20} className="text-concrete/40 mb-2.5" />
|
||||
<p className="text-[11px] text-concrete italic">Aucun document partagé pour le moment.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trash bin panel view */}
|
||||
{activeView === 'trash' && (
|
||||
<div className="flex-1 flex flex-col p-4 h-full overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 mb-4">
|
||||
<Trash2 size={14} className="text-rose-500" />
|
||||
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">Fichiers Supprimés</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar pr-1 space-y-2 mb-4">
|
||||
{notes.filter(n => n.isDeleted).map(note => (
|
||||
<div
|
||||
key={note.id}
|
||||
className="p-2.5 rounded-lg border border-border/40 bg-white dark:bg-zinc-800/40 text-left flex items-center justify-between gap-3 group"
|
||||
>
|
||||
<div className="truncate flex-1">
|
||||
<p className="text-[11px] font-bold text-ink dark:text-dark-ink truncate">{note.title || "Note sans titre"}</p>
|
||||
<p className="text-[8.5px] text-concrete">Supprimé le {new Date(note.deletedAt || note.date).toLocaleDateString('fr-FR')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveCarnetId(note.carnetId);
|
||||
setActiveNoteId(note.id);
|
||||
}}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-[9px] font-bold uppercase tracking-wider text-accent shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
Inspecter
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{notes.filter(n => n.isDeleted).length === 0 && (
|
||||
<div className="flex-1 h-32 flex flex-col items-center justify-center text-center p-4 border border-dashed border-border/55 bg-black/[0.01] rounded-2xl">
|
||||
<Trash2 size={16} className="text-concrete/30 mb-2" />
|
||||
<p className="text-[10px] text-concrete italic">Corbeille vide</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings panel category switcher list */}
|
||||
{activeView === 'settings' && (
|
||||
<div className="flex-1 flex flex-col p-4 overflow-y-auto custom-scrollbar">
|
||||
<div className="flex items-center gap-1.5 mb-6">
|
||||
<Settings size={14} className="text-accent" />
|
||||
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">Paramètres</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
{ id: 'general', label: 'Général', icon: <Archive size={12} /> },
|
||||
{ id: 'ai', label: 'Intelligence IA', icon: <Bot size={12} /> },
|
||||
{ id: 'billing', label: 'Tarifs & Abonnements', icon: <Sparkles size={12} /> },
|
||||
{ id: 'appearance', label: 'Thème & Stylisme', icon: <Sun size={12} /> },
|
||||
{ id: 'profile', label: 'Profil Utilisateur', icon: <User size={12} /> },
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveSettingsTab?.(tab.id as any)}
|
||||
className="w-full text-left px-3 py-2 text-[11px] transition-all rounded-lg flex items-center gap-2.5 text-muted-ink hover:text-ink hover:bg-black/5"
|
||||
>
|
||||
<span className="text-concrete">{tab.icon}</span>
|
||||
<span className="font-semibold">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user