Files
Momento/memento-note/components/sidebar.tsx
Antigravity 0784c94242
Some checks failed
CI / Lint, Test & Build (push) Failing after 57s
CI / Deploy production (on server) (push) Has been skipped
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>
2026-05-24 23:03:16 +00:00

1438 lines
60 KiB
TypeScript

'use client'
import Link from 'next/link'
import { usePathname, useSearchParams, useRouter } from 'next/navigation'
import { cn } from '@/lib/utils'
import {
Settings,
Plus,
ChevronRight,
Lock,
BookOpen,
BookMarked,
Bot,
Inbox,
FlaskConical,
ArrowUpDown,
Archive,
Trash2,
User,
LogOut,
Shield,
GripVertical,
Users,
Bell,
Pencil,
Clock,
Moon,
Sun,
Pin,
PinOff,
Sparkles,
Home,
Search,
GraduationCap,
FileText,
Folder,
FolderOpen,
} from 'lucide-react'
import { useSearchModal } from '@/context/search-modal-context'
import { useLanguage } from '@/lib/i18n'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { applyDocumentTheme } from '@/lib/apply-document-theme'
import { getAllNotes, getTrashCount } from '@/app/actions/notes'
import { NOTE_CHANGE_EVENT, type NoteChangeEvent } from '@/lib/note-change-sync'
import { useNotebooks } from '@/context/notebooks-context'
import { Notebook, Note } from '@/lib/types'
import { toast } from 'sonner'
import { motion, AnimatePresence } from 'motion/react'
import { getNoteDisplayTitle } from '@/lib/note-preview'
import { CreateNotebookDialog } from './create-notebook-dialog'
import { NotificationPanel } from './notification-panel'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { performSignOut } from '@/lib/auth-client'
import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm'
import { UsageMeter } from './usage-meter'
type NavigationView = 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision'
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
function NoteLink({
title,
isActive,
isPinned,
onClick,
}: {
title: string
isActive: boolean
isPinned?: boolean
onClick: () => void
}) {
const { language } = useLanguage()
const slideX = language === 'fa' || language === 'ar' ? 10 : -10
return (
<motion.button
initial={{ opacity: 0, x: slideX }}
animate={{ opacity: 1, x: 0 }}
onClick={onClick}
className={cn(
'w-full flex items-center gap-2 ps-6 pe-3 py-1.5 text-[11px] transition-all rounded-lg text-start',
isActive
? 'bg-white dark:bg-white/10 shadow-sm border border-border/50 text-foreground font-semibold'
: 'text-muted-foreground hover:text-foreground hover:bg-white/30 dark:hover:bg-white/5',
)}
>
<FileText
size={12}
className={cn('shrink-0', isActive ? 'text-brand-accent' : 'text-muted-foreground/70')}
/>
<span className="truncate flex-1">{title}</span>
{isPinned && <Pin size={10} className="text-amber-500 fill-amber-500 shrink-0" />}
</motion.button>
)
}
function SidebarBrainstorms() {
const { data: sessions, isLoading } = useBrainstormSessions()
const deleteBrainstorm = useDeleteBrainstorm()
const router = useRouter()
const { t } = useLanguage()
if (isLoading) {
return (
<div className="px-4 space-y-2">
{[1, 2, 3].map(i => (
<div key={i} className="h-12 rounded-xl bg-paper/50 animate-pulse" />
))}
</div>
)
}
if (!sessions || sessions.length === 0) {
return (
<div className="px-4 py-6 text-center">
<Sparkles size={20} className="mx-auto text-brand-accent/40 mb-2" />
<p className="text-[11px] text-concrete">{t('brainstorm.noSessions')}</p>
<button
onClick={() => router.push('/brainstorm')}
className="mt-2 text-[11px] text-brand-accent hover:text-brand-accent/80 font-medium"
>
{t('brainstorm.startOne')}
</button>
</div>
)
}
return (
<div className="space-y-0.5">
{sessions.slice(0, 10).map(s => (
<div key={s.id} className="relative group/item">
<button
onClick={() => router.replace(`/brainstorm?session=${s.id}`)}
className={`w-full flex items-center gap-3 px-4 py-2.5 rounded-xl transition-all duration-200 text-start hover:bg-brand-accent/5 group ${
(s as any)._owned === false ? 'border-s-2 border-brand-accent/30 dark:border-brand-accent/70' : ''
}`}
>
<div className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 ${
(s as any)._owned === false
? 'border border-brand-accent/20 dark:border-brand-accent/80 bg-brand-accent/5 dark:bg-brand-accent/20'
: 'border border-brand-accent/20 dark:border-brand-accent/40 bg-brand-accent/5 dark:bg-brand-accent/20'
}`}>
<Sparkles size={12} className="text-brand-accent" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[12px] font-medium truncate">
{s.seedIdea}
{(s as any)._owned === false && (
<span className="text-[9px] ms-1.5 text-brand-accent/70 font-normal">{t('sidebar.sharedNotebookBadge')}</span>
)}
</p>
<p className="text-[10px] text-muted-foreground">
{s.activeIdeas} {t('brainstorm.ideas')} · {new Date(s.createdAt).toLocaleDateString()}
</p>
</div>
</button>
{(s as any)._owned !== false && (
<button
onClick={(e) => {
e.stopPropagation()
deleteBrainstorm.mutate(s.id)
}}
className="absolute end-2 top-1/2 -translate-y-1/2 p-1.5 rounded-lg opacity-0 group-hover/item:opacity-100 hover:bg-rose-500/10 text-muted-foreground hover:text-rose-500 transition-all"
>
<Trash2 size={13} />
</button>
)}
</div>
))}
</div>
)
}
function SidebarCarnetItem({
carnet,
isActive,
notes,
activeNoteId,
onCarnetClick,
onNoteClick,
onAddSubNotebook,
onRename,
onDelete,
onTogglePin,
isPinned,
children,
isDragging,
dragHandleProps,
level,
isExpanded,
toggleExpand,
hasChildNotebooks,
hasActiveDescendant,
}: {
carnet: { id: string; name: string; initial: string; isPrivate?: boolean }
isActive: boolean
notes: { id: string; title: string; isPinned?: boolean }[]
activeNoteId: string | null
onCarnetClick: () => void
onNoteClick: (noteId: string, carnetId: string) => void
onAddSubNotebook: () => void
onRename: () => void
onDelete: () => void
onTogglePin: () => void
isPinned: boolean
children?: React.ReactNode
isDragging?: boolean
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>
level: number
isExpanded: boolean
toggleExpand: () => void
hasChildNotebooks?: boolean
hasActiveDescendant?: boolean
}) {
const { t, language } = useLanguage()
const isRtl = language === 'fa' || language === 'ar'
const hasChildren = hasChildNotebooks || React.Children.count(children) > 0 || notes.length > 0
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
// Close context menu on outside click
useEffect(() => {
if (!contextMenu) return
const handler = () => setContextMenu(null)
window.addEventListener('click', handler)
return () => window.removeEventListener('click', handler)
}, [contextMenu])
return (
<div className={cn('transition-opacity', isDragging && 'opacity-40')}>
<div
className="flex items-center group relative h-10"
style={{ paddingInlineStart: `${level * 24 + 8}px` }}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
setContextMenu({ x: e.clientX, y: e.clientY })
}}
>
{level > 0 && (
<div className="absolute start-[8px] top-[-10px] bottom-1/2 w-px bg-border/40" />
)}
{level > 0 && (
<div className="absolute start-[8px] top-1/2 w-[8px] h-px bg-border/40" />
)}
<div
{...dragHandleProps}
className="absolute start-1 top-1/2 -translate-y-1/2 p-1 rounded text-muted-foreground/30 hover:text-muted-foreground cursor-grab active:cursor-grabbing opacity-0 group-hover:opacity-100 transition-opacity z-10"
>
<GripVertical size={12} />
</div>
<div className="flex-1 flex items-center gap-1">
{hasChildren ? (
<button
onClick={(e) => { e.stopPropagation(); toggleExpand() }}
className="p-1 hover:bg-foreground/5 rounded-md transition-colors text-muted-foreground"
>
<motion.div animate={{ rotate: isExpanded ? 90 : 0 }} transition={{ duration: 0.2 }}>
<ChevronRight size={14} className="rtl:scale-x-[-1]" />
</motion.div>
</button>
) : (
<div className="w-6" />
)}
<motion.div
whileHover={{ x: isRtl ? -2 : 2 }}
onClick={onCarnetClick}
onDoubleClick={(e) => { e.stopPropagation(); onRename() }}
className={cn(
'flex-1 flex items-center gap-2.5 px-2 py-1.5 rounded-lg transition-all duration-300 group/item cursor-pointer relative',
isActive ? 'bg-white dark:bg-white/10 shadow-sm border border-border/40' : 'hover:bg-white/40 dark:hover:bg-white/5'
)}
>
{isActive && (
<motion.div
layoutId="active-indicator"
className="absolute -start-1 w-1 h-4 bg-brand-accent rounded-full"
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/>
)}
<div className={cn(
'w-5 h-5 flex items-center justify-center shrink-0 transition-colors',
isActive ? 'text-brand-accent' : 'text-muted-foreground/80',
)}>
{isExpanded ? <FolderOpen size={13} /> : <Folder size={13} />}
</div>
<div className="flex-1 text-start flex items-center gap-2 min-w-0">
<span className={cn(
'text-[12px] font-medium transition-colors truncate',
isActive ? 'text-ink' : 'text-muted-ink group-hover/item:text-ink'
)}>
{carnet.name}
</span>
{carnet.isPrivate && <Lock size={10} className="text-concrete/60 shrink-0" />}
{!isActive && hasActiveDescendant && (
<span className="w-1.5 h-1.5 rounded-full bg-brand-accent/70 shrink-0" />
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover/item:opacity-100 transition-opacity shrink-0">
{isPinned && (
<span className="text-brand-accent" title={t('notebook.pinnedFrozenTooltip')}>
<Pin size={9} className="opacity-70" />
</span>
)}
<button
onClick={(e) => { e.stopPropagation(); onAddSubNotebook() }}
className="p-1 hover:bg-ink/10 rounded-md transition-all text-concrete hover:text-ink"
title={t('notebook.createSubNotebook')}
>
<Plus size={10} />
</button>
<button
onClick={(e) => { e.stopPropagation(); onRename() }}
className="p-1 hover:bg-ink/10 rounded-md transition-all text-concrete hover:text-ink"
title={t('notebook.rename')}
>
<Pencil size={10} />
</button>
<button
onClick={(e) => { e.stopPropagation(); onDelete() }}
className="p-1 hover:bg-rose-50 rounded-md transition-all text-concrete hover:text-rose-500"
title={t('notebook.delete')}
>
<Trash2 size={10} />
</button>
{notes.length > 0 && (
<span className="text-[9px] font-bold text-concrete/40 px-1.5 border border-border/40 rounded-full group-hover/item:text-concrete transition-colors">
{notes.length}
</span>
)}
</div>
</motion.div>
</div>
</div>
{/* Right-click context menu */}
<AnimatePresence>
{contextMenu && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.1 }}
style={{ position: 'fixed', top: contextMenu.y, left: contextMenu.x, zIndex: 9999 }}
className="bg-card border border-border rounded-xl shadow-xl py-1.5 min-w-[180px] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => { onTogglePin(); setContextMenu(null) }}
className="w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-ink hover:bg-foreground/5 transition-colors"
>
{isPinned
? <><PinOff size={13} className="text-brand-accent" /><span>{t('sidebar.unfreezePinnedNotebook')}</span></>
: <><Pin size={13} className="text-brand-accent" /><span>{t('sidebar.freezePinnedNotebook')}</span></>
}
</button>
<div className="mx-3 my-1 border-t border-border/50" />
<button
onClick={() => { onAddSubNotebook(); setContextMenu(null) }}
className="w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-ink hover:bg-foreground/5 transition-colors"
>
<Plus size={13} className="text-concrete" />
<span>{t('sidebar.newSubNotebook')}</span>
</button>
<button
onClick={() => { onRename(); setContextMenu(null) }}
className="w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-ink hover:bg-foreground/5 transition-colors"
>
<Pencil size={13} className="text-concrete" />
<span>{t('sidebar.renameNotebook')}</span>
</button>
<div className="mx-3 my-1 border-t border-border/50" />
<button
onClick={() => { onDelete(); setContextMenu(null) }}
className="w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/30 transition-colors"
>
<Trash2 size={13} />
<span>{t('common.delete')}</span>
</button>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
className="overflow-hidden"
>
<div className="relative" style={{ marginInlineStart: `${(level + 1) * 16 + 10}px` }}>
<div className="absolute start-[-6px] top-0 bottom-4 w-px bg-border/30" />
<div className="space-y-0.5 py-1">
{children}
{isExpanded && notes.map(note => (
<NoteLink
key={note.id}
title={note.title}
isPinned={note.isPinned}
isActive={activeNoteId === note.id}
onClick={() => onNoteClick(note.id, carnet.id)}
/>
))}
{isExpanded && notes.length === 0 && !hasChildren && (
<p className="ps-6 py-1 text-[9px] italic text-muted-foreground/40 font-light">
{t('sidebar.notebookEmpty')}
</p>
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
export function Sidebar({ className, user }: { className?: string; user?: any }) {
const pathname = usePathname()
const searchParams = useSearchParams()
const router = useRouter()
const { t, language } = useLanguage()
const isRtl = language === 'fa' || language === 'ar'
const { notebooks, trashNotebook, updateNotebookOrderOptimistic, moveNotebookToParent, refreshNotebooks } = useNotebooks()
const { open: openSearch } = useSearchModal()
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [createParentId, setCreateParentId] = useState<string | null>(null)
const [renamingNotebook, setRenamingNotebook] = useState<Notebook | null>(null)
const [renameValue, setRenameValue] = useState('')
const [isDark, setIsDark] = useState(false)
const [isMobileOpen, setIsMobileOpen] = useState(false)
// Écoute l'événement d'ouverture du menu mobile
useEffect(() => {
const handler = () => setIsMobileOpen(true)
window.addEventListener('open-mobile-sidebar', handler)
return () => window.removeEventListener('open-mobile-sidebar', handler)
}, [])
// Ferme la sidebar mobile lors d'une navigation
useEffect(() => {
setIsMobileOpen(false)
}, [pathname, searchParams])
useEffect(() => {
setIsDark(document.documentElement.classList.contains('dark'))
}, [])
const toggleTheme = useCallback(() => {
const next = !isDark
setIsDark(next)
const theme = next ? 'dark' : 'light'
localStorage.setItem('theme-preference', theme)
applyDocumentTheme(theme)
}, [isDark])
const [deletingNotebook, setDeletingNotebook] = useState<Notebook | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const [isRenaming, setIsRenaming] = useState(false)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const [pinnedIds, setPinnedIds] = useState<Set<string>>(new Set())
const [notebookNotes, setNotebookNotes] = useState<Record<string, { id: string; title: string; isPinned?: boolean }[]>>({})
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)
const [orderedNotebooks, setOrderedNotebooks] = useState<Notebook[]>([])
const dragOverId = useRef<string | null>(null)
const isSavingRef = useRef(false)
const rootNotebooks = useMemo(() => orderedNotebooks.filter(nb => !nb.parentId), [orderedNotebooks])
const childNotebooks = useMemo(() => {
const map = new Map<string, Notebook[]>()
for (const nb of orderedNotebooks) {
if (nb.parentId) {
const children = map.get(nb.parentId) || []
children.push(nb)
map.set(nb.parentId, children)
}
}
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')
useEffect(() => {
if (!currentNotebookId) return
setExpandedIds(prev => {
const next = new Set(prev)
let id: string | null | undefined = currentNotebookId
while (id) {
next.add(id)
const nb = notebooks.find(n => n.id === id)
id = nb?.parentId ?? null
}
return next
})
}, [currentNotebookId, notebooks])
const isInboxActive =
pathname === '/home' &&
!searchParams.get('notebook') &&
!searchParams.get('labels') &&
!searchParams.get('archived') &&
!searchParams.get('trashed')
useEffect(() => {
if (pathname.startsWith('/brainstorm')) setActiveView('brainstorms')
else if (pathname.startsWith('/agents') || pathname.startsWith('/lab')) setActiveView('agents')
else setActiveView('notebooks')
}, [pathname])
const displayName = user?.name || user?.email || ''
const initial = displayName ? displayName.charAt(0).toUpperCase() : '?'
// Sorted list for the sort dropdown (not used directly when dragging)
const sortedNotebooks = useMemo(() => {
const arr = [...notebooks]
if (sortOrder === 'manual') return arr.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
if (sortOrder === 'alpha') return arr.sort((a, b) => a.name.localeCompare(b.name))
if (sortOrder === 'newest') return arr.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
if (sortOrder === 'oldest') return arr.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
return arr
}, [notebooks, sortOrder])
// Sync orderedNotebooks from server ONLY when not in the middle of a drag save
useEffect(() => {
if (isSavingRef.current) return
setOrderedNotebooks(sortedNotebooks)
}, [sortedNotebooks])
useEffect(() => {
let cancelled = false
getTrashCount().then(count => { if (!cancelled) setTrashCount(count) })
return () => { cancelled = true }
}, [])
const notebookIdsKey = useMemo(() => notebooks.map(nb => nb.id).sort().join(','), [notebooks])
useEffect(() => {
if (!notebookIdsKey) return
let cancelled = false
const load = async () => {
const mappedEntries = await Promise.all(
notebooks.map(async (nb: Notebook) => {
const notes = await getAllNotes(false, nb.id)
const mapped = notes.map((n: Note) => ({
id: n.id,
title: getNoteDisplayTitle(n, t('notes.untitled')),
isPinned: n.isPinned,
}))
return [nb.id, mapped] as const
})
)
if (cancelled) return
setNotebookNotes(Object.fromEntries(mappedEntries))
}
load()
return () => { cancelled = true }
}, [notebookIdsKey, t])
useEffect(() => {
const onNoteChange = (e: Event) => {
const detail = (e as CustomEvent<NoteChangeEvent>).detail
if (detail.type === 'deleted') {
setNotebookNotes((prev) => {
const next = { ...prev }
for (const key of Object.keys(next)) {
next[key] = next[key].filter((n) => n.id !== detail.noteId)
}
return next
})
setTrashCount((count) => count + 1)
return
}
if (detail.type === 'created' && detail.note.notebookId) {
const nbId = detail.note.notebookId
const title = getNoteDisplayTitle(detail.note, t('notes.untitled'))
setNotebookNotes((prev) => {
const list = prev[nbId] || []
if (list.some((n) => n.id === detail.note.id)) return prev
return {
...prev,
[nbId]: [{ id: detail.note.id, title, isPinned: detail.note.isPinned }, ...list],
}
})
return
}
if (detail.type === 'updated') {
const note = detail.note
const title = getNoteDisplayTitle(note, t('notes.untitled'))
setNotebookNotes((prev) => {
const next: Record<string, { id: string; title: string; isPinned?: boolean }[]> = {}
for (const [key, list] of Object.entries(prev)) {
const filtered = list.filter((n) => n.id !== note.id)
if (filtered.length > 0) next[key] = filtered
}
if (note.notebookId) {
next[note.notebookId] = [
{ id: note.id, title, isPinned: note.isPinned },
...(next[note.notebookId] || []),
]
}
return next
})
}
}
window.addEventListener(NOTE_CHANGE_EVENT, onNoteChange)
return () => window.removeEventListener(NOTE_CHANGE_EVENT, onNoteChange)
}, [t])
const handleCarnetClick = (notebookId: string) => {
const params = new URLSearchParams()
params.set('notebook', notebookId)
params.set('forceList', '1')
router.push(`/home?${params.toString()}`)
}
const handleInboxClick = () => {
router.push('/home?forceList=1')
}
const handleNoteClick = (noteId: string, notebookId: string) => {
const params = new URLSearchParams(searchParams.toString())
params.set('notebook', notebookId)
params.set('openNote', noteId)
params.delete('forceList')
router.push(`/home?${params.toString()}`)
}
// ── Drag state ──
const [dropTarget, setDropTarget] = useState<string | null>(null)
const [dropAction, setDropAction] = useState<'into' | null>(null)
const handleDragStart = (e: React.DragEvent, notebookId: string) => {
setDraggedId(notebookId)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', notebookId)
}
const handleDragEnd = () => {
setDraggedId(null)
dragOverId.current = null
isSavingRef.current = false
setDropTarget(null)
setDropAction(null)
setOrderedNotebooks(sortedNotebooks)
}
const handleDropOnNotebook = async (e: React.DragEvent, targetId: string) => {
e.preventDefault()
e.stopPropagation()
setDropTarget(null)
setDropAction(null)
const dragId = draggedId || e.dataTransfer.getData('text/plain')
if (!dragId || dragId === targetId) {
setDraggedId(null)
return
}
setDraggedId(null)
dragOverId.current = null
try {
await moveNotebookToParent(dragId, targetId)
} catch (err) {
toast.error(err instanceof Error ? err.message : t('sidebar.moveFailed'))
setOrderedNotebooks(sortedNotebooks)
}
}
const handleDropToRoot = async (e: React.DragEvent) => {
e.preventDefault()
setDropTarget(null)
setDropAction(null)
const dragId = draggedId || e.dataTransfer.getData('text/plain')
if (!dragId) {
setDraggedId(null)
return
}
setDraggedId(null)
dragOverId.current = null
try {
await moveNotebookToParent(dragId, null)
} catch (err) {
toast.error(err instanceof Error ? err.message : t('sidebar.moveFailed'))
setOrderedNotebooks(sortedNotebooks)
}
}
const sortLabels: Record<SortOrder, string> = {
newest: t('sidebar.sortNewest'),
oldest: t('sidebar.sortOldest'),
alpha: t('sidebar.sortAlpha'),
manual: t('sidebar.sortManual'),
}
const toggleExpand = useCallback((id: string) => {
setExpandedIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
// Load pinned notebooks from localStorage on mount
useEffect(() => {
try {
const stored = localStorage.getItem('momento-pinned-notebooks')
if (stored) setPinnedIds(new Set(JSON.parse(stored)))
} catch {}
}, [])
const togglePin = useCallback((id: string) => {
setPinnedIds(prev => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
// Also collapse the notebook when unpinning
setExpandedIds(e => { const ne = new Set(e); ne.delete(id); return ne })
} else {
next.add(id)
// Ensure it's also expanded when pinned
setExpandedIds(e => { const ne = new Set(e); ne.add(id); return ne })
}
try { localStorage.setItem('momento-pinned-notebooks', JSON.stringify([...next])) } catch {}
return next
})
}, [])
const handleStartRename = useCallback((notebook: Notebook) => {
setRenamingNotebook(notebook)
setRenameValue(notebook.name)
}, [])
const handleConfirmRename = useCallback(async () => {
if (!renamingNotebook || !renameValue.trim()) return
setIsRenaming(true)
try {
const res = await fetch(`/api/notebooks/${renamingNotebook.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: renameValue.trim() }),
})
if (!res.ok) throw new Error('Rename failed')
setRenamingNotebook(null)
setRenameValue('')
await refreshNotebooks()
} catch (err) {
console.error('Rename failed:', err)
} finally {
setIsRenaming(false)
}
}, [renamingNotebook, renameValue, refreshNotebooks])
const getDescendantIds = useCallback((notebookId: string): string[] => {
const ids: string[] = []
const children = childNotebooks.get(notebookId) || []
for (const child of children) {
ids.push(child.id)
ids.push(...getDescendantIds(child.id))
}
return ids
}, [childNotebooks])
const handleConfirmDelete = useCallback(async () => {
if (!deletingNotebook) return
setIsDeleting(true)
try {
await trashNotebook(deletingNotebook.id)
setDeletingNotebook(null)
if (currentNotebookId === deletingNotebook.id) {
router.push('/home')
}
} catch (err) {
console.error('Trash failed:', err)
} finally {
setIsDeleting(false)
}
}, [deletingNotebook, trashNotebook, currentNotebookId, router])
const renderCarnetTree = useCallback((parentId: string | undefined, level: number): React.ReactNode => {
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
const notes = notebookNotes[notebook.id] || []
const isDragging = draggedId === notebook.id
const children = childNotebooks.get(notebook.id) || []
const hasDescendant = (nid: string): boolean => {
const desc = childNotebooks.get(nid) || []
return desc.some(c => currentNotebookId === c.id || hasDescendant(c.id))
}
const hasActiveDescendant = hasDescendant(notebook.id)
// A notebook stays expanded if: manually expanded OR is pinned
const isExpanded = expandedIds.has(notebook.id) || pinnedIds.has(notebook.id)
return (
<motion.div key={notebook.id}>
<div
draggable
onDragStart={(e) => {
e.stopPropagation()
handleDragStart(e, notebook.id)
}}
onDragEnd={handleDragEnd}
onDragOver={(e) => {
e.preventDefault()
e.stopPropagation()
if (!draggedId || draggedId === notebook.id) return
e.dataTransfer.dropEffect = 'move'
setDropTarget(notebook.id)
setDropAction('into')
}}
onDragLeave={(e) => {
if (e.currentTarget.contains(e.relatedTarget as Node)) return
if (dropTarget === notebook.id) {
setDropTarget(null)
setDropAction(null)
}
}}
onDrop={(e) => {
e.preventDefault()
e.stopPropagation()
handleDropOnNotebook(e, notebook.id)
}}
className={cn(
'rounded-lg transition-colors',
dropTarget === notebook.id && dropAction === 'into' && draggedId && draggedId !== notebook.id
&& 'bg-brand-accent/10 ring-1 ring-brand-accent/30',
isDragging && 'opacity-50'
)}
>
<SidebarCarnetItem
carnet={{
id: notebook.id,
name: notebook.name,
initial: notebook.name.charAt(0).toUpperCase(),
isPrivate: notebook.isPrivate,
}}
isActive={isActive}
notes={notes}
activeNoteId={currentNoteId}
onCarnetClick={() => {
if (currentNotebookId === notebook.id) {
toggleExpand(notebook.id)
} else {
handleCarnetClick(notebook.id)
}
}}
onNoteClick={handleNoteClick}
onAddSubNotebook={() => {
setCreateParentId(notebook.id)
setIsCreateDialogOpen(true)
if (!expandedIds.has(notebook.id)) toggleExpand(notebook.id)
}}
onRename={() => handleStartRename(notebook)}
onDelete={() => setDeletingNotebook(notebook)}
onTogglePin={() => togglePin(notebook.id)}
isPinned={pinnedIds.has(notebook.id)}
isDragging={isDragging}
level={level}
isExpanded={isExpanded}
toggleExpand={() => toggleExpand(notebook.id)}
hasChildNotebooks={children.length > 0}
hasActiveDescendant={hasActiveDescendant}
/>
</div>
{isExpanded && renderCarnetTree(notebook.id, level + 1)}
</motion.div>
)
})
}, [rootNotebooks, childNotebooks, filteredNotebookIds, currentNotebookId, currentNoteId, notebookNotes, draggedId, dropTarget, dropAction, expandedIds, pinnedIds, toggleExpand, handleCarnetClick, handleNoteClick, handleDragStart, handleDragEnd, handleDropOnNotebook, handleStartRename])
return (
<>
{/* Overlay mobile */}
<AnimatePresence>
{isMobileOpen && (
<motion.div
key="sidebar-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[60] md:hidden"
onClick={() => setIsMobileOpen(false)}
/>
)}
</AnimatePresence>
<aside
className={cn(
// Mobile: fixed overlay, slide in/out
'fixed inset-y-0 start-0 z-[70] md:relative md:z-auto',
'h-full min-h-0 w-72 lg:w-80 shrink-0 flex flex-row overflow-hidden',
'transition-transform duration-300 ease-in-out',
isMobileOpen ? 'translate-x-0 shadow-2xl' : '-translate-x-full md:translate-x-0',
'border-e border-border/40 bg-white/95 md:bg-white/30 backdrop-blur-md sidebar-shadow dark:border-white/6 dark:bg-[#151515] dark:backdrop-blur-none',
className
)}
>
{/* ── Column 1 : Rail d'icônes (54px) — inspiré du prototype ── */}
<div className="w-[54px] border-e border-border/40 bg-[#FAF9F5] dark:bg-[#0E0E0E] flex flex-col items-center justify-between py-4 shrink-0 select-none">
{/* Top : Logo + navigation */}
<div className="flex flex-col items-center gap-3 w-full">
{/* Logo avec dropdown profil */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="w-9 h-9 bg-brand-accent hover:rotate-6 active:scale-95 flex items-center justify-center rounded-xl shadow-md transition-all cursor-pointer mb-1">
<span className="text-white font-serif font-bold text-sm">M</span>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52 bg-popover border-border ms-2">
<DropdownMenuItem asChild>
<Link href="/settings/profile" className="flex items-center gap-2 cursor-pointer">
<User className="h-4 w-4" />
{t('sidebar.profile')}
</Link>
</DropdownMenuItem>
{(user as { role?: string } | undefined)?.role === 'ADMIN' && (
<DropdownMenuItem asChild>
<a href="/admin" className="flex items-center gap-2 cursor-pointer">
<Shield className="h-4 w-4" />
{t('nav.adminDashboard')}
</a>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => performSignOut('/login')}
>
<LogOut className="h-4 w-4 me-2" />
{t('sidebar.signOut')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Boutons de navigation principaux */}
<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: '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') },
{ id: 'reminders', icon: Bell, label: t('sidebar.reminders'), onClick: () => setActiveView('reminders'), isActive: activeView === 'reminders' },
] as { id: string; icon: React.FC<{ size?: number }>; label: string; onClick: () => void; isActive: boolean }[]).map(item => (
<button
key={item.id}
onClick={item.onClick}
className={cn(
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
item.isActive
? 'bg-brand-accent/10 text-brand-accent border border-brand-accent/25'
: 'text-concrete hover:text-ink dark:hover:text-white hover:bg-black/[0.04] dark:hover:bg-white/[0.04]'
)}
>
{item.isActive && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 bg-brand-accent rounded-r-full" />
)}
<item.icon size={16} />
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-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 : utilitaires */}
<div className="flex flex-col gap-1.5 w-full px-1.5 items-center">
<NotificationPanel />
<Link
href="/trash"
className={cn(
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
pathname === '/trash'
? 'bg-rose-500/10 text-rose-500 border border-rose-500/25'
: 'text-concrete hover:text-rose-500 hover:bg-rose-500/5'
)}
>
{pathname === '/trash' && <div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 bg-rose-500 rounded-r-full" />}
<Trash2 size={16} />
{trashCount > 0 && (
<span className="absolute top-1.5 right-1.5 w-1.5 h-1.5 bg-rose-500 rounded-full border-2 border-[#FAF9F5] dark:border-[#0E0E0E]" />
)}
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-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">
{t('sidebar.trash')}
</span>
</Link>
<Link
href="/home?shared=1&forceList=1"
className={cn(
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
searchParams.get('shared') === '1' && pathname === '/home'
? 'bg-sky-500/10 text-sky-500 border border-sky-500/25'
: 'text-concrete hover:text-sky-500 hover:bg-sky-500/5'
)}
>
<Users size={16} />
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-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">
{t('sidebar.sharedWithMe')}
</span>
</Link>
<button
onClick={openSearch}
className="w-9 h-9 rounded-lg flex items-center justify-center text-concrete hover:text-ink dark:hover:text-white hover:bg-black/[0.04] dark:hover:bg-white/[0.04] transition-all relative group"
title="Ctrl+K"
>
<Search size={15} />
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-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">
Recherche (Ctrl+K)
</span>
</button>
<button
onClick={toggleTheme}
className="w-9 h-9 rounded-lg flex items-center justify-center text-concrete hover:text-ink dark:hover:text-white hover:bg-black/[0.04] dark:hover:bg-white/[0.04] transition-all relative group"
>
{isDark ? <Sun size={15} /> : <Moon size={15} />}
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-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">
{isDark ? 'Mode clair' : 'Mode sombre'}
</span>
</button>
<Link
href="/settings"
className={cn(
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
pathname.startsWith('/settings')
? 'bg-brand-accent/10 text-brand-accent border border-brand-accent/25'
: 'text-concrete hover:text-ink dark:hover:text-white hover:bg-black/[0.04] dark:hover:bg-white/[0.04]'
)}
>
{pathname.startsWith('/settings') && <div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 bg-brand-accent rounded-r-full" />}
<Settings size={15} />
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-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">
{t('nav.settings')}
</span>
</Link>
<button
onClick={() => performSignOut('/login')}
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-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-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">
{t('sidebar.signOut')}
</span>
</button>
</div>
</div>
{/* ── Column 2 : Panneau de contenu dynamique ── */}
<div className="flex-1 h-full flex flex-col overflow-hidden bg-[#FCFCFA] dark:bg-[#111111]">
{/* ── Scrollable content ── */}
<div className="flex-1 overflow-y-auto space-y-6 -mx-0 custom-scrollbar pb-4">
<AnimatePresence mode="wait">
{activeView === 'notebooks' ? (
<motion.div
key="notebooks"
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
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"
>
<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>
<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={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>
<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' ? (
<motion.div
key="reminders"
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
transition={{ duration: 0.2 }}
>
<p className="text-[10px] font-bold text-muted-ink tracking-[0.2em] uppercase mb-4 px-4">
{t('sidebar.reminders')}
</p>
<div className="px-4 py-8 text-center border border-dashed border-border rounded-2xl bg-paper/30">
<Clock size={24} className="mx-auto text-concrete/40 mb-3" />
<p className="text-[11px] text-concrete italic">{t('sidebar.noReminders')}</p>
</div>
</motion.div>
) : activeView === 'agents' ? (
<motion.div
key="agents"
initial={{ opacity: 0, x: isRtl ? -10 : 10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: isRtl ? 10 : -10 }}
transition={{ duration: 0.2 }}
>
<p className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-4 px-4">
{t('agents.intelligenceOS')}
</p>
<div className="space-y-1">
{[
{ id: 'agents', href: '/agents', label: t('agents.myAgents'), icon: Bot },
{ id: 'lab', href: '/lab', label: t('nav.lab'), icon: FlaskConical },
{ id: 'brainstorm', href: '/brainstorm', label: t('brainstorm.sessions'), icon: Sparkles },
].map(item => {
const isActive = pathname.startsWith(item.href)
return (
<Link
key={item.id}
href={item.href}
className={cn(
'w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group',
isActive ? 'memento-active-nav' : 'text-muted-foreground hover:bg-foreground/5 hover:text-foreground'
)}
>
<div className={cn(
'w-8 h-8 rounded-full flex items-center justify-center border transition-colors shrink-0',
isActive
? 'bg-brand-accent text-white border-brand-accent'
: 'bg-paper border-border group-hover:border-brand-accent/20'
)}>
<item.icon size={16} />
</div>
<span className="text-[13px] font-medium">{item.label}</span>
</Link>
)
})}
</div>
</motion.div>
) : (
<motion.div
key="brainstorms"
initial={{ opacity: 0, x: isRtl ? -10 : 10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: isRtl ? 10 : -10 }}
transition={{ duration: 0.2 }}
>
<div className="flex items-center justify-between px-4 mb-3">
<p className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase">
{t('brainstorm.sessions')}
</p>
<button
onClick={() => router.push('/brainstorm')}
className="p-1 text-muted-foreground hover:text-brand-accent transition-colors rounded"
title={t('brainstorm.newBrainstorm')}
>
<Plus size={12} />
</button>
</div>
<SidebarBrainstorms />
</motion.div>
)}
</AnimatePresence>
</div>
{/* ── Usage meter en bas du panneau ── */}
<div className="border-t border-border/20 px-3 py-3 mt-auto">
<UsageMeter />
</div>
</div>{/* fin colonne 2 */}
</aside>
<CreateNotebookDialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
parentNotebookId={createParentId}
/>
{/* Rename Dialog */}
<AnimatePresence>
{renamingNotebook && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
onClick={() => !isRenaming && setRenamingNotebook(null)}
>
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 10 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0, y: 10 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
className="bg-card border border-border rounded-2xl p-6 shadow-xl w-80"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-sm font-semibold mb-4 font-memento-serif">
{t('notebook.rename')}
</h3>
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleConfirmRename(); if (e.key === 'Escape') setRenamingNotebook(null) }}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-transparent focus:outline-none focus:ring-2 focus:ring-foreground/20"
placeholder={t('notebook.namePlaceholder')}
/>
<div className="flex justify-end gap-2 mt-4">
<button
onClick={() => setRenamingNotebook(null)}
disabled={isRenaming}
className="px-4 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors rounded-lg"
>
{t('common.cancel')}
</button>
<button
onClick={handleConfirmRename}
disabled={isRenaming || !renameValue.trim()}
className="px-4 py-1.5 text-xs font-medium bg-foreground text-background rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50"
>
{isRenaming ? '...' : t('notebook.confirmRename')}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Delete Confirmation */}
<AnimatePresence>
{deletingNotebook && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
onClick={() => !isDeleting && setDeletingNotebook(null)}
>
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 10 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0, y: 10 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
className="bg-card border border-border rounded-2xl p-6 shadow-xl w-80"
onClick={(e) => e.stopPropagation()}
>
<div className="w-10 h-10 rounded-full bg-red-50 border border-red-100 flex items-center justify-center mb-3">
<Trash2 size={16} className="text-red-500" />
</div>
<h3 className="text-sm font-semibold mb-1 font-memento-serif">
{t('notebook.trashTitle')}
</h3>
<p className="text-xs text-muted-foreground mb-4">
{t('notebook.trashConfirm', { name: deletingNotebook.name }) || `Move "${deletingNotebook.name}" to trash? You can restore it within 30 days.`}
</p>
{getDescendantIds(deletingNotebook.id).length > 0 && (
<p className="text-xs text-amber-600 mb-4 bg-amber-50 px-3 py-2 rounded-lg border border-amber-100">
{t('notebook.trashCascadeWarning', { count: getDescendantIds(deletingNotebook.id).length }) || `${getDescendantIds(deletingNotebook.id).length} sub-notebook(s) will also be moved to trash.`}
</p>
)}
<div className="flex justify-end gap-2">
<button
onClick={() => setDeletingNotebook(null)}
disabled={isDeleting}
className="px-4 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors rounded-lg"
>
{t('common.cancel')}
</button>
<button
onClick={handleConfirmDelete}
disabled={isDeleting}
className="px-4 py-1.5 text-xs font-medium bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors disabled:opacity-50"
>
{isDeleting ? '...' : t('notebook.moveToTrash')}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
)
}