feat(notes): vues structurées tableau/kanban, flashcards et MCP robuste
Ajoute la base organisable par carnet (schéma, champs partagés, valeurs par note) avec activation guidée, tableau éditable, kanban et suppression de colonnes. Corrige le multiselect en vue tableau et enrichit sidebar, grille et i18n FR/EN. Inclut aussi les améliorations flashcards SM-2, l'audit consentement IA et la robustesse du serveur MCP (config, validation, rate-limit, métriques). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
ChevronRight,
|
||||
Lock,
|
||||
BookOpen,
|
||||
BookMarked,
|
||||
Bot,
|
||||
Inbox,
|
||||
FlaskConical,
|
||||
@@ -29,7 +30,6 @@ import {
|
||||
PinOff,
|
||||
Sparkles,
|
||||
Home,
|
||||
Network,
|
||||
Search,
|
||||
GraduationCap,
|
||||
FileText,
|
||||
@@ -474,6 +474,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
const [activeView, setActiveView] = useState<NavigationView>('notebooks')
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
|
||||
const [showSortMenu, setShowSortMenu] = useState(false)
|
||||
const [notebookSearchQuery, setNotebookSearchQuery] = useState('')
|
||||
const [trashCount, setTrashCount] = useState(0)
|
||||
|
||||
const [draggedId, setDraggedId] = useState<string | null>(null)
|
||||
@@ -494,6 +495,20 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
return map
|
||||
}, [orderedNotebooks])
|
||||
|
||||
const filteredNotebookIds = useMemo(() => {
|
||||
const q = notebookSearchQuery.trim().toLowerCase()
|
||||
if (!q) return null
|
||||
return new Set(
|
||||
notebooks
|
||||
.filter(
|
||||
(nb) =>
|
||||
nb.name.toLowerCase().includes(q) ||
|
||||
(notebookNotes[nb.id] || []).some((n) => n.title.toLowerCase().includes(q)),
|
||||
)
|
||||
.map((nb) => nb.id),
|
||||
)
|
||||
}, [notebooks, notebookNotes, notebookSearchQuery])
|
||||
|
||||
const currentNotebookId = searchParams.get('notebook')
|
||||
const currentNoteId = searchParams.get('openNote')
|
||||
|
||||
@@ -793,9 +808,10 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
}, [deletingNotebook, trashNotebook, currentNotebookId, router])
|
||||
|
||||
const renderCarnetTree = useCallback((parentId: string | undefined, level: number): React.ReactNode => {
|
||||
const items = parentId === undefined
|
||||
const items = (parentId === undefined
|
||||
? rootNotebooks
|
||||
: (childNotebooks.get(parentId) || [])
|
||||
).filter((notebook) => !filteredNotebookIds || filteredNotebookIds.has(notebook.id))
|
||||
|
||||
return items.map((notebook: Notebook) => {
|
||||
const isActive = currentNotebookId === notebook.id
|
||||
@@ -885,7 +901,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
</motion.div>
|
||||
)
|
||||
})
|
||||
}, [rootNotebooks, childNotebooks, currentNotebookId, currentNoteId, notebookNotes, draggedId, dropTarget, dropAction, expandedIds, toggleExpand, handleCarnetClick, handleNoteClick, handleDragStart, handleDragEnd, handleDropOnNotebook, handleStartRename])
|
||||
}, [rootNotebooks, childNotebooks, filteredNotebookIds, currentNotebookId, currentNoteId, notebookNotes, draggedId, dropTarget, dropAction, expandedIds, pinnedIds, toggleExpand, handleCarnetClick, handleNoteClick, handleDragStart, handleDragEnd, handleDropOnNotebook, handleStartRename])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -957,7 +973,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
<div className="flex flex-col gap-1.5 w-full px-1.5">
|
||||
{([
|
||||
{ id: 'notebooks', icon: BookOpen, label: t('nav.notebooks'), onClick: () => { setActiveView('notebooks'); if (pathname !== '/home') router.push('/home') }, isActive: activeView === 'notebooks' && !pathname.startsWith('/settings') },
|
||||
{ id: 'graph', icon: Network, label: t('nav.graphView'), onClick: () => router.push('/graph'), isActive: pathname === '/graph' },
|
||||
{ id: 'insights', icon: Sparkles, label: t('nav.insights'), onClick: () => router.push('/insights'), isActive: pathname === '/insights' },
|
||||
{ id: 'revision', icon: GraduationCap, label: t('nav.revision'), onClick: () => router.push('/revision'), isActive: pathname === '/revision' },
|
||||
{ id: 'agents', icon: Bot, label: t('agents.intelligenceOS') || 'Intelligence IA', onClick: () => { setActiveView('agents'); router.push('/agents') }, isActive: activeView === 'agents' || (pathname.startsWith('/agents') && activeView !== 'notebooks') },
|
||||
@@ -1086,95 +1101,135 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="flex flex-col flex-1 min-h-0 overflow-hidden"
|
||||
>
|
||||
{/* Section header with sort button */}
|
||||
<div className="flex items-center justify-between px-4 mb-3">
|
||||
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase">
|
||||
{t('nav.notebooks')}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => { setCreateParentId(null); setIsCreateDialogOpen(true) }}
|
||||
className="p-1 text-muted-foreground hover:text-foreground hover:bg-white/40 transition-all rounded"
|
||||
title={t('notebook.create')}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowSortMenu(s => !s)}
|
||||
className="p-1 text-muted-foreground hover:text-foreground transition-colors rounded"
|
||||
title={t('sidebar.sortOrder')}
|
||||
>
|
||||
<ArrowUpDown size={12} />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showSortMenu && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: -4 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: -4 }}
|
||||
className="absolute end-0 top-full mt-1 bg-card border border-border rounded-xl shadow-lg z-50 py-1 min-w-[140px]"
|
||||
>
|
||||
{(['newest', 'oldest', 'alpha', 'manual'] as SortOrder[]).map(order => (
|
||||
<button
|
||||
key={order}
|
||||
onClick={() => { setSortOrder(order); setShowSortMenu(false) }}
|
||||
className={cn(
|
||||
'w-full text-start px-4 py-2 text-[12px] transition-colors',
|
||||
sortOrder === order
|
||||
? 'font-bold text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
||||
)}
|
||||
>
|
||||
{sortLabels[order]}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div className="px-4 pt-4 shrink-0">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<BookMarked size={14} className="text-brand-accent" />
|
||||
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
|
||||
{t('sidebar.documents')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCreateParentId(null)
|
||||
setIsCreateDialogOpen(true)
|
||||
}}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded transition-all text-concrete hover:text-ink"
|
||||
title={t('notebook.create')}
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSortMenu((s) => !s)}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded transition-all text-concrete hover:text-ink"
|
||||
title={t('sidebar.sortOrder')}
|
||||
>
|
||||
<ArrowUpDown size={13} />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showSortMenu && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: -4 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: -4 }}
|
||||
className="absolute end-0 top-full mt-1 bg-card border border-border rounded-xl shadow-lg z-50 py-1 min-w-[140px]"
|
||||
>
|
||||
{(['newest', 'oldest', 'alpha', 'manual'] as SortOrder[]).map((order) => (
|
||||
<button
|
||||
key={order}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSortOrder(order)
|
||||
setShowSortMenu(false)
|
||||
}}
|
||||
className={cn(
|
||||
'w-full text-start px-4 py-2 text-[12px] transition-colors',
|
||||
sortOrder === order
|
||||
? 'font-bold text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40',
|
||||
)}
|
||||
>
|
||||
{sortLabels[order]}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={notebookSearchQuery}
|
||||
onChange={(e) => setNotebookSearchQuery(e.target.value)}
|
||||
placeholder={t('sidebar.searchNotebooksPlaceholder')}
|
||||
className="w-full text-[11px] ps-7 pe-8 py-1.5 rounded-lg border border-border/60 bg-white/70 dark:bg-zinc-800 placeholder-concrete/50 outline-none focus:border-brand-accent transition-colors text-ink dark:text-dark-ink"
|
||||
/>
|
||||
<Search
|
||||
size={11}
|
||||
className="absolute start-2.5 top-1/2 -translate-y-1/2 text-concrete opacity-60 pointer-events-none"
|
||||
/>
|
||||
{notebookSearchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNotebookSearchQuery('')}
|
||||
className="absolute end-2.5 top-1/2 -translate-y-1/2 text-[9px] uppercase font-bold text-concrete hover:text-ink"
|
||||
aria-label={t('sidebar.clearSearch')}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inbox — Notes without notebook */}
|
||||
<button
|
||||
onClick={handleInboxClick}
|
||||
className={cn('sidebar-inbox-item', isInboxActive && 'active')}
|
||||
>
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
|
||||
isInboxActive
|
||||
? 'bg-brand-accent text-white border-brand-accent'
|
||||
: 'bg-paper dark:bg-white/5 text-muted-ink border-border group-hover:border-brand-accent/20'
|
||||
)}>
|
||||
<Inbox size={14} />
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-[13px] font-medium truncate',
|
||||
isInboxActive ? 'text-ink' : 'text-muted-ink'
|
||||
)}>
|
||||
{t('sidebar.inbox')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mx-4 my-3 h-px bg-border/40" />
|
||||
|
||||
{/* Notebooks list — draggable */}
|
||||
<div
|
||||
className="space-y-0.5 min-h-[60px]"
|
||||
onDrop={handleDropToRoot}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
>
|
||||
{renderCarnetTree(undefined, 0)}
|
||||
{draggedId && (
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar min-h-0 px-4 pb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInboxClick}
|
||||
className={cn('sidebar-inbox-item', isInboxActive && 'active')}
|
||||
>
|
||||
<div
|
||||
className="h-10 rounded-lg border-2 border-dashed border-brand-accent/20 flex items-center justify-center text-[11px] text-brand-accent/50"
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
|
||||
isInboxActive
|
||||
? 'bg-brand-accent text-white border-brand-accent'
|
||||
: 'bg-paper dark:bg-white/5 text-muted-ink border-border group-hover:border-brand-accent/20',
|
||||
)}
|
||||
>
|
||||
{t('sidebar.dropToRoot')}
|
||||
<Inbox size={14} />
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'text-[13px] font-medium truncate',
|
||||
isInboxActive ? 'text-ink' : 'text-muted-ink',
|
||||
)}
|
||||
>
|
||||
{t('sidebar.inbox')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="my-3 h-px bg-border/40" />
|
||||
|
||||
<div
|
||||
className="space-y-0.5 min-h-[60px]"
|
||||
onDrop={handleDropToRoot}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
>
|
||||
{renderCarnetTree(undefined, 0)}
|
||||
{draggedId && (
|
||||
<div className="h-10 rounded-lg border-2 border-dashed border-brand-accent/20 flex items-center justify-center text-[11px] text-brand-accent/50">
|
||||
{t('sidebar.dropToRoot')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : activeView === 'reminders' ? (
|
||||
|
||||
Reference in New Issue
Block a user