Files
Momento/memento-note/components/sidebar.tsx
Antigravity 3bdbec575a feat(ui): panneau repliable, carnets plus lisibles et accueil plus clair
Harmonise la liste des notes et l’éditeur (titres, dates, retour),
rend la saisie rapide et les raccourcis de l’accueil utilisables
sur téléphone, et conserve le panneau latéral repliable.
2026-09-05 20:53:48 +00:00

2258 lines
96 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,
LayoutGrid,
Palette,
CreditCard,
Database,
Globe,
Plug,
Key,
Info,
ChevronLeft,
} 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 { persistThemePreference } from '@/lib/apply-document-theme'
import { getAllNotes, getTrashCount, getNotesWithReminders, toggleReminderDone, getInboxCount } 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 { listAiCreatedNotebookIds, isRecentlyCreated } from '@/lib/ai-created-highlight'
import dynamic from 'next/dynamic'
const CreateNotebookDialog = dynamic(() => import('./create-notebook-dialog').then(m => ({ default: m.CreateNotebookDialog })), { ssr: false })
const AiNotebookWizard = dynamic(() => import('./wizard/ai-notebook-wizard').then(m => ({ default: m.AiNotebookWizard })), { ssr: false })
const NotificationPanel = dynamic(() => import('./notification-panel').then(m => ({ default: m.NotificationPanel })), { ssr: false })
const UsageMeter = dynamic(() => import('./usage-meter').then(m => ({ default: m.UsageMeter })), { ssr: false })
const StarterPackBadge = dynamic(() => import('./onboarding/starter-pack-badge').then(m => ({ default: m.StarterPackBadge })), { ssr: false })
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { performSignOut } from '@/lib/auth-client'
import { isDashboardHomeRoute } from '@/lib/dashboard/home-route'
import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm'
type NavigationView = 'dashboard' | 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision' | 'insights' | 'settings'
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
const NOTEBOOKS_PANEL_HEIGHT_KEY = 'memento-sidebar-notebooks-height'
const NOTEBOOKS_PANEL_MIN_PX = 120
const SIDEBAR_WIDTH_KEY = 'memento-sidebar-width'
const SIDEBAR_WIDTH_MIN_PX = 280
const SIDEBAR_WIDTH_MAX_PX = 560
function NoteLink({
title,
isActive,
isPinned,
autoGenerated,
isRecent,
onClick,
}: {
title: string
isActive: boolean
isPinned?: boolean
/** Note créée par l'IA (agents, wizard, …) */
autoGenerated?: boolean
/** Créée récemment (< 48h) — point discret « nouveau » */
isRecent?: boolean
onClick: () => void
}) {
const { language, t } = 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'
: autoGenerated
? 'text-foreground/90 hover:text-foreground hover:bg-brand-accent/5 border border-transparent'
: 'text-muted-foreground hover:text-foreground hover:bg-white/30 dark:hover:bg-white/5',
autoGenerated && !isActive && 'bg-brand-accent/[0.04]',
)}
title={autoGenerated ? (t('sidebar.aiGeneratedNote') || 'Note générée par l\'IA') : undefined}
>
{autoGenerated ? (
<Sparkles
size={12}
className={cn('shrink-0', isActive ? 'text-brand-accent' : 'text-brand-accent/80')}
aria-hidden
/>
) : (
<FileText
size={12}
className={cn('shrink-0', isActive ? 'text-brand-accent' : 'text-muted-foreground/70')}
/>
)}
<span className="truncate flex-1">{title}</span>
{autoGenerated && (
<span className="shrink-0 text-[8px] font-bold uppercase tracking-wider text-brand-accent/90 bg-brand-accent/10 px-1.5 py-0.5 rounded">
{t('sidebar.aiBadge') || 'IA'}
</span>
)}
{isRecent && !autoGenerated && (
<span
className="shrink-0 w-1.5 h-1.5 rounded-full bg-brand-accent"
title={t('sidebar.recentNote') || 'Récemment créée'}
aria-label={t('sidebar.recentNote') || 'Récemment créée'}
/>
)}
{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 SidebarReminders({ onOpenNote }: { onOpenNote: (noteId: string, notebookId: string | null) => void }) {
const { t } = useLanguage()
const [reminders, setReminders] = useState<
{ id: string; title: string | null; reminder: Date | string | null; isReminderDone: boolean; notebookId: string | null }[]
>([])
const [loading, setLoading] = useState(true)
const [togglingId, setTogglingId] = useState<string | null>(null)
const reload = () => {
getNotesWithReminders().then((rows) => setReminders(rows as typeof reminders))
}
useEffect(() => {
let cancelled = false
setLoading(true)
getNotesWithReminders()
.then((rows) => {
if (!cancelled) setReminders(rows as typeof reminders)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => { cancelled = true }
}, [])
const handleComplete = async (e: React.MouseEvent, noteId: string) => {
e.stopPropagation()
setTogglingId(noteId)
await toggleReminderDone(noteId, true)
setReminders(prev => prev.map(r => r.id === noteId ? { ...r, isReminderDone: true } : r))
setTogglingId(null)
}
const handleSnooze = async (e: React.MouseEvent, noteId: string) => {
e.stopPropagation()
// Snooze = +1h
const snoozeDate = new Date(Date.now() + 60 * 60 * 1000)
setTogglingId(noteId)
try {
await fetch(`/api/notes/${noteId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reminder: snoozeDate.toISOString(), isReminderDone: false }),
})
setReminders(prev => prev.map(r => r.id === noteId ? { ...r, reminder: snoozeDate, isReminderDone: false } : r))
} catch { reload() }
setTogglingId(null)
}
if (loading) {
return (
<div className="px-4 space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-10 rounded-xl bg-paper/50 animate-pulse" />
))}
</div>
)
}
const now = new Date()
const active = reminders.filter((r) => !r.isReminderDone && r.reminder)
const overdue = active.filter((r) => new Date(r.reminder!) < now)
const upcoming = active.filter((r) => new Date(r.reminder!) >= now)
if (active.length === 0) {
return (
<div className="px-4 py-8 text-center border border-dashed border-border rounded-2xl bg-paper/30 mx-4">
<Clock size={24} className="mx-auto text-concrete/40 mb-3" />
<p className="text-[11px] text-concrete italic">{t('reminders.emptyDescription')}</p>
</div>
)
}
const renderItem = (note: (typeof active)[0], overdueItem?: boolean) => (
<div
key={note.id}
className="group flex items-center gap-1 px-2 py-1.5 rounded-xl hover:bg-brand-accent/5 transition-colors"
>
{/* Bouton compléter */}
<button
type="button"
onClick={(e) => { void handleComplete(e, note.id) }}
disabled={togglingId === note.id}
className="flex-shrink-0 w-5 h-5 rounded-full border-2 border-concrete/30 hover:border-emerald-500 hover:bg-emerald-50 transition-all flex items-center justify-center"
title={t('reminders.markDone') || 'Marquer comme fait'}
>
{togglingId === note.id
? <div className="w-2 h-2 rounded-full bg-concrete animate-pulse" />
: null}
</button>
{/* Contenu cliquable */}
<button
type="button"
onClick={() => onOpenNote(note.id, note.notebookId)}
className="flex-1 text-start min-w-0"
>
<p className="text-[12px] font-medium truncate group-hover:text-brand-accent transition-colors">
{note.title || t('notes.untitled')}
</p>
<p className={cn('text-[10px] mt-0.5', overdueItem ? 'text-red-500' : 'text-muted-foreground')}>
{note.reminder &&
new Date(note.reminder).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</button>
{/* Actions (visibles au hover) */}
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity flex gap-0.5">
<button
type="button"
onClick={(e) => { void handleSnooze(e, note.id) }}
disabled={togglingId === note.id}
className="p-1 rounded-md hover:bg-amber-100 text-amber-600 transition-colors"
title={t('reminders.snooze1h') || 'Reporter de 1h'}
>
<span className="text-[9px] font-bold">+1h</span>
</button>
</div>
</div>
)
return (
<div className="space-y-4 pb-2">
{overdue.length > 0 && (
<div>
<p className="text-[9px] font-bold uppercase tracking-widest text-red-500 px-4 mb-1">
{t('reminders.overdue')}
</p>
<div className="space-y-0.5">{overdue.map((n) => renderItem(n, true))}</div>
</div>
)}
{upcoming.length > 0 && (
<div>
<p className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground px-4 mb-1">
{t('reminders.upcoming')}
</p>
<div className="space-y-0.5">{upcoming.map((n) => renderItem(n))}</div>
</div>
)}
</div>
)
}
function SidebarCarnetItem({
carnet,
isActive,
notes,
activeNoteId,
onCarnetClick,
onNoteClick,
onAddSubNotebook,
onRename,
onDelete,
onTogglePin,
isPinned,
isAiCreated,
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; autoGenerated?: boolean; isRecent?: boolean }[]
activeNoteId: string | null
onCarnetClick: () => void
onNoteClick: (noteId: string, carnetId: string) => void
onAddSubNotebook: () => void
onRename: () => void
onDelete: () => void
onTogglePin: () => void
isPinned: boolean
/** Carnet créé via wizard IA (repère temporaire) */
isAiCreated?: 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 className="flex-1 flex items-center gap-0.5">
{/* Drag handle — visible au hover, dans son propre slot avant le chevron */}
<div
{...dragHandleProps}
className="shrink-0 w-5 h-5 flex items-center justify-center rounded text-muted-foreground/0 group-hover:text-muted-foreground/50 hover:!text-muted-foreground cursor-grab active:cursor-grabbing transition-colors"
onClick={(e) => e.stopPropagation()}
>
<GripVertical size={11} />
</div>
{hasChildren ? (
<button
onClick={(e) => { e.stopPropagation(); toggleExpand() }}
className="shrink-0 w-5 h-5 flex items-center justify-center hover:bg-foreground/5 rounded-md transition-colors text-muted-foreground"
>
<motion.div animate={{ rotate: isExpanded ? 90 : 0 }} transition={{ duration: 0.2 }}>
<ChevronRight size={13} className="rtl:scale-x-[-1]" />
</motion.div>
</button>
) : (
<div className="w-5" />
)}
<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 overflow-hidden',
isActive
? 'bg-white dark:bg-white/10 shadow-sm border border-border/40'
: isAiCreated
? 'hover:bg-brand-accent/10 border border-brand-accent/20 bg-brand-accent/[0.06]'
: 'hover:bg-white/40 dark:hover:bg-white/5',
)}
title={isAiCreated ? (t('sidebar.aiGeneratedNotebook') || 'Carnet créé avec l\'IA') : undefined}
>
{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 }}
/>
)}
{isAiCreated && !isActive && (
<span className="absolute -start-1 w-1 h-4 bg-brand-accent/70 rounded-full" aria-hidden />
)}
<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-[13px] font-medium transition-colors truncate',
isActive ? 'text-foreground' : 'text-foreground/80 group-hover/item:text-foreground'
)}>
{carnet.name}
</span>
{isAiCreated && (
<span className="shrink-0 inline-flex items-center gap-0.5 text-[8px] font-bold uppercase tracking-wider text-brand-accent bg-brand-accent/15 px-1.5 py-0.5 rounded">
<Sparkles size={9} aria-hidden />
{t('sidebar.aiBadge') || 'IA'}
</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>
{/* Compteur de notes (toujours visible, à droite du nom) */}
{notes.length > 0 && (
<span className="text-[11px] font-medium tabular-nums text-foreground/75 px-1.5 bg-brand-accent/5 border border-border/40 rounded-full transition-colors shrink-0 ms-auto">
{notes.length}
</span>
)}
{/* Boutons d'action en absolu, toujours à droite */}
<div className="absolute end-0 top-0 bottom-0 flex items-center gap-1 pe-1 ps-6 opacity-0 group-hover/item:opacity-100 focus-within:opacity-100 transition-opacity bg-gradient-to-l from-[var(--color-paper,white)] dark:from-[var(--color-paper,#1a1a1a)] from-60% to-transparent">
{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>
</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}
autoGenerated={note.autoGenerated}
isRecent={note.isRecent}
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 [showAiWizard, setShowAiWizard] = 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)
// Cookie + localStorage : le serveur doit connaître le dark, sinon flash clair
// à chaque changement de page (layout racine réécrit <html>).
persistThemePreference(next ? 'dark' : 'light')
}, [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; autoGenerated?: boolean; isRecent?: boolean }[]>>({})
const [aiNotebookIds, setAiNotebookIds] = useState<Set<string>>(() => new Set())
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 [inboxCount, setInboxCount] = useState(0)
const notebooksContainerRef = useRef<HTMLDivElement>(null)
const notebooksPanelRef = useRef<HTMLDivElement>(null)
const notebooksPanelHeightRef = useRef<number | null>(null)
const [notebooksPanelHeight, setNotebooksPanelHeight] = useState<number | null>(null)
const asideRef = useRef<HTMLElement>(null)
const sidebarWidthRef = useRef<number | null>(null)
const [sidebarWidth, setSidebarWidth] = useState<number | null>(null)
useEffect(() => {
try {
const stored = localStorage.getItem(NOTEBOOKS_PANEL_HEIGHT_KEY)
if (stored) {
const n = parseInt(stored, 10)
if (!Number.isNaN(n) && n >= NOTEBOOKS_PANEL_MIN_PX) {
setNotebooksPanelHeight(n)
notebooksPanelHeightRef.current = n
}
}
const storedW = localStorage.getItem(SIDEBAR_WIDTH_KEY)
if (storedW) {
const w = parseInt(storedW, 10)
if (!Number.isNaN(w) && w >= SIDEBAR_WIDTH_MIN_PX && w <= SIDEBAR_WIDTH_MAX_PX) {
setSidebarWidth(w)
sidebarWidthRef.current = w
}
}
} catch {
/* ignore */
}
}, [])
const handleNotebooksResizeStart = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
const panel = notebooksPanelRef.current
const container = notebooksContainerRef.current
if (!panel || !container) return
const target = e.currentTarget
target.setPointerCapture(e.pointerId)
const startY = e.clientY
const startH = panel.getBoundingClientRect().height
const topBlock = container.querySelector('[data-sidebar-notebooks-top]') as HTMLElement | null
const handleH = 12
const containerH = container.getBoundingClientRect().height
const maxH = Math.max(
NOTEBOOKS_PANEL_MIN_PX,
containerH - (topBlock?.offsetHeight ?? 160) - handleH,
)
const onMove = (ev: PointerEvent) => {
const delta = ev.clientY - startY
const newH = Math.min(maxH, Math.max(NOTEBOOKS_PANEL_MIN_PX, startH + delta))
notebooksPanelHeightRef.current = newH
setNotebooksPanelHeight(newH)
}
const onUp = (ev: PointerEvent) => {
target.releasePointerCapture(ev.pointerId)
document.removeEventListener('pointermove', onMove)
document.removeEventListener('pointerup', onUp)
const h = notebooksPanelHeightRef.current
if (h !== null) {
try {
localStorage.setItem(NOTEBOOKS_PANEL_HEIGHT_KEY, String(h))
} catch {
/* ignore */
}
}
}
document.addEventListener('pointermove', onMove)
document.addEventListener('pointerup', onUp)
}, [])
const handleSidebarWidthResizeStart = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
const aside = asideRef.current
if (!aside) return
const target = e.currentTarget
target.setPointerCapture(e.pointerId)
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
const startX = e.clientX
const startW = aside.getBoundingClientRect().width
const onMove = (ev: PointerEvent) => {
const delta = isRtl ? startX - ev.clientX : ev.clientX - startX
const newW = Math.min(SIDEBAR_WIDTH_MAX_PX, Math.max(SIDEBAR_WIDTH_MIN_PX, startW + delta))
sidebarWidthRef.current = newW
setSidebarWidth(newW)
}
const onUp = (ev: PointerEvent) => {
target.releasePointerCapture(ev.pointerId)
document.body.style.cursor = ''
document.body.style.userSelect = ''
document.removeEventListener('pointermove', onMove)
document.removeEventListener('pointerup', onUp)
const w = sidebarWidthRef.current
if (w !== null) {
try {
localStorage.setItem(SIDEBAR_WIDTH_KEY, String(w))
} catch {
/* ignore */
}
}
}
document.addEventListener('pointermove', onMove)
document.addEventListener('pointerup', onUp)
}, [isRtl])
const [draggedId, setDraggedId] = useState<string | null>(null)
// Ref stable pour éviter les stale closures dans les handlers de drop
const draggedIdRef = useRef<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 isDashboardRoute = isDashboardHomeRoute(pathname, searchParams)
const panelView: NavigationView = pathname.startsWith('/settings') ? 'settings' : activeView
const isInboxActive =
pathname === '/home' &&
searchParams.get('forceList') === '1' &&
!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 if (pathname === '/insights') setActiveView('insights')
else if (pathname.startsWith('/revision')) setActiveView('revision')
else if (pathname.startsWith('/settings')) setActiveView('settings')
else if (searchParams.get('reminders') === '1' && pathname === '/home') setActiveView('reminders')
else if (isDashboardRoute) setActiveView('dashboard')
else if (pathname === '/home' || pathname.startsWith('/notes')) setActiveView('notebooks')
}, [pathname, searchParams, isDashboardRoute])
const isRemindersRoute = pathname === '/home' && searchParams.get('reminders') === '1'
const isSharedRoute = pathname === '/home' && searchParams.get('shared') === '1'
const isImmersiveRoute = pathname === '/insights'
const [dashboardPanelCollapsed, setDashboardPanelCollapsed] = useState(false)
useEffect(() => {
try {
setDashboardPanelCollapsed(localStorage.getItem('memento-dashboard-panel-collapsed') === 'true')
} catch {}
}, [])
const [insightsPanelCollapsed, setInsightsPanelCollapsed] = useState(false)
useEffect(() => {
try {
setInsightsPanelCollapsed(localStorage.getItem('insights-sidebar-collapsed') === 'true')
} catch {}
}, [])
const hasCollapsiblePanel = isDashboardRoute || isImmersiveRoute
const collapseDashboardPanel = (isDashboardRoute && dashboardPanelCollapsed) || (isImmersiveRoute && insightsPanelCollapsed)
const dashboardPanelLabel = isImmersiveRoute
? t('insightsView.toggleMenu')
: t(collapseDashboardPanel ? 'sidebar.showDashboardPanel' : 'sidebar.hideDashboardPanel')
const toggleDashboardPanel = () => {
if (isImmersiveRoute) {
const next = !insightsPanelCollapsed
setInsightsPanelCollapsed(next)
try {
localStorage.setItem('insights-sidebar-collapsed', String(next))
} catch {}
return
}
const next = !dashboardPanelCollapsed
setDashboardPanelCollapsed(next)
try {
localStorage.setItem('memento-dashboard-panel-collapsed', String(next))
} catch {}
}
const isNotebooksRoute =
(pathname === '/home' || pathname.startsWith('/notes')) &&
!pathname.startsWith('/settings') &&
!isRemindersRoute &&
!isSharedRoute &&
!isDashboardRoute
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) })
getInboxCount().then((count: number) => { if (!cancelled) setInboxCount(count) })
return () => { cancelled = true }
}, [])
const notebookIdsKey = useMemo(() => notebooks.map(nb => nb.id).sort().join(','), [notebooks])
// Repères carnets IA (localStorage TTL 7j)
useEffect(() => {
const refresh = () => setAiNotebookIds(listAiCreatedNotebookIds())
refresh()
window.addEventListener('memento-ai-created-changed', refresh)
window.addEventListener('storage', refresh)
return () => {
window.removeEventListener('memento-ai-created-changed', refresh)
window.removeEventListener('storage', refresh)
}
}, [])
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,
autoGenerated: Boolean(n.autoGenerated),
isRecent: isRecentlyCreated(n.createdAt, 48),
}))
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
getInboxCount().then((count: number) => setInboxCount(count))
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 handleRemindersClick = () => {
setActiveView('reminders')
router.push('/home?reminders=1&forceList=1')
}
const handleReminderNoteClick = (noteId: string, notebookId: string | null) => {
const params = new URLSearchParams()
params.set('reminders', '1')
if (notebookId) params.set('notebook', notebookId)
params.set('openNote', noteId)
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' | 'before' | 'after' | null>(null)
const handleDragStart = useCallback((e: React.DragEvent, notebookId: string) => {
// Stocker dans le ref ET dans le state
// Le ref est toujours frais dans les handlers async (pas de stale closure)
draggedIdRef.current = notebookId
setDraggedId(notebookId)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', notebookId)
}, [])
const handleDragEnd = useCallback(() => {
draggedIdRef.current = null
setDraggedId(null)
dragOverId.current = null
isSavingRef.current = false
setDropTarget(null)
setDropAction(null)
setOrderedNotebooks(sortedNotebooks)
}, [sortedNotebooks])
const handleDropOnNotebook = useCallback(async (e: React.DragEvent, targetId: string, action: 'into' | 'before' | 'after') => {
e.preventDefault()
e.stopPropagation()
const dragId = draggedIdRef.current || e.dataTransfer.getData('text/plain')
draggedIdRef.current = null
setDraggedId(null)
setDropTarget(null)
setDropAction(null)
dragOverId.current = null
if (!dragId || dragId === targetId) return
try {
if (action === 'into') {
await moveNotebookToParent(dragId, targetId)
} else {
// before/after : placer au même niveau que la cible et réordonner
const target = orderedNotebooks.find(nb => nb.id === targetId)
const newParentId = target?.parentId ?? null
// 1. Mettre à jour le parentId si nécessaire
const dragged = orderedNotebooks.find(nb => nb.id === dragId)
if (dragged?.parentId !== newParentId) {
await moveNotebookToParent(dragId, newParentId)
}
// 2. Recalculer l'ordre des siblings
const siblings = orderedNotebooks
.filter(nb => (nb.parentId ?? null) === newParentId && nb.id !== dragId)
const targetIdx = siblings.findIndex(nb => nb.id === targetId)
const insertAt = action === 'before' ? targetIdx : targetIdx + 1
siblings.splice(insertAt, 0, { id: dragId } as typeof siblings[0])
// Passer en tri manuel pour que l'ordre soit respecté visuellement
setSortOrder('manual')
await updateNotebookOrderOptimistic(siblings.map(nb => nb.id))
}
} catch (err) {
toast.error(err instanceof Error ? err.message : t('sidebar.moveFailed'))
setOrderedNotebooks(sortedNotebooks)
}
}, [moveNotebookToParent, updateNotebookOrderOptimistic, orderedNotebooks, sortedNotebooks, t])
const handleDropToRoot = useCallback(async (e: React.DragEvent) => {
e.preventDefault()
// Lire depuis le ref (toujours à jour, pas de stale closure)
const dragId = draggedIdRef.current || e.dataTransfer.getData('text/plain')
// Nettoyer APRÈS avoir lu la valeur
draggedIdRef.current = null
setDraggedId(null)
setDropTarget(null)
setDropAction(null)
dragOverId.current = null
if (!dragId) return
try {
await moveNotebookToParent(dragId, null)
} catch (err) {
toast.error(err instanceof Error ? err.message : t('sidebar.moveFailed'))
setOrderedNotebooks(sortedNotebooks)
}
}, [moveNotebookToParent, sortedNotebooks, t])
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'
const rect = e.currentTarget.getBoundingClientRect()
const y = e.clientY - rect.top
const pct = y / rect.height
setDropTarget(notebook.id)
if (pct < 0.3) setDropAction('before')
else if (pct > 0.7) setDropAction('after')
else 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()
const action = (dropTarget === notebook.id ? dropAction : null) ?? 'into'
handleDropOnNotebook(e, notebook.id, action as 'into' | 'before' | 'after')
}}
className={cn(
'relative 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'
)}
>
{/* Indicateur "déposer avant" */}
{dropTarget === notebook.id && dropAction === 'before' && draggedId && draggedId !== notebook.id && (
<div className="absolute -top-0.5 start-0 end-0 h-0.5 bg-brand-accent rounded-full z-10 pointer-events-none" />
)}
{/* Indicateur "déposer après" */}
{dropTarget === notebook.id && dropAction === 'after' && draggedId && draggedId !== notebook.id && (
<div className="absolute -bottom-0.5 start-0 end-0 h-0.5 bg-brand-accent rounded-full z-10 pointer-events-none" />
)}
<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)}
isAiCreated={aiNotebookIds.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, aiNotebookIds, 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
ref={asideRef}
style={sidebarWidth !== null ? { width: sidebarWidth, '--sidebar-expanded-width': `${sidebarWidth}px` } as React.CSSProperties : undefined}
className={cn(
cn(
'fixed inset-y-0 start-0 z-[70] md:relative md:z-auto',
sidebarWidth === null && 'w-80 md:w-[26rem] 2xl:w-[30rem]',
isMobileOpen
? 'translate-x-0 shadow-2xl'
: cn(isRtl ? 'translate-x-full md:translate-x-0' : '-translate-x-full md:translate-x-0'),
),
'h-full min-h-0 shrink-0 flex flex-row self-stretch overflow-visible',
collapseDashboardPanel && 'md:!w-[72px]',
hasCollapsiblePanel && 'dashboard-sidebar md:z-40',
'transition-transform duration-300 ease-in-out',
'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 icône + libellé — destinations lisibles sans survol ── */}
<div className="w-[72px] border-e border-border/40 bg-[#FAF9F5] dark:bg-[#0E0E0E] flex flex-col items-center justify-between py-3 shrink-0 select-none overflow-y-auto overflow-x-hidden custom-scrollbar">
{/* Top : Logo + navigation */}
<div className="flex flex-col items-center gap-[18px] 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-2">
<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 w-full px-1">
{([
{
id: 'dashboard',
icon: LayoutGrid,
label: t('nav.home'),
onClick: () => {
setActiveView('dashboard')
router.replace('/home')
},
isActive: isDashboardRoute,
},
{
id: 'notebooks',
icon: BookOpen,
label: t('nav.notebooks'),
onClick: () => {
setActiveView('notebooks')
router.push('/home?forceList=1')
},
isActive: isNotebooksRoute,
},
{
id: 'insights',
icon: Sparkles,
label: t('nav.insights'),
onClick: () => {
setActiveView('insights')
router.push('/insights')
},
isActive: pathname === '/insights',
},
{
id: 'revision',
icon: GraduationCap,
label: t('nav.revision'),
onClick: () => {
setActiveView('revision')
router.push('/revision')
},
isActive: pathname.startsWith('/revision'),
},
{
id: 'agents',
icon: Bot,
label: t('nav.agents'),
onClick: () => {
setActiveView('agents')
router.push('/agents')
},
isActive: pathname.startsWith('/agents') || pathname.startsWith('/lab'),
},
{
id: 'reminders',
icon: Bell,
label: t('sidebar.reminders'),
onClick: handleRemindersClick,
isActive: isRemindersRoute,
},
] as { id: string; icon: React.FC<{ size?: number }>; label: string; onClick: () => void; isActive: boolean }[]).map(item => (
<button
key={item.id}
type="button"
aria-label={item.label}
aria-current={item.isActive ? 'page' : undefined}
onClick={item.onClick}
className={cn(
'w-full rounded-lg flex flex-col items-center justify-center gap-0.5 py-1.5 px-0.5 transition-all relative',
item.isActive
? 'bg-brand-accent/10 text-brand-accent border border-brand-accent/25'
: 'text-ink/70 hover:text-ink dark:text-white/70 dark:hover:text-white hover:bg-black/[0.04] dark:hover:bg-white/[0.04]'
)}
>
{item.isActive && (
<div className="absolute start-0 top-1/2 -translate-y-1/2 w-1 h-4 bg-brand-accent rounded-e-full" />
)}
<item.icon size={16} aria-hidden />
<span className="text-[11px] leading-tight font-semibold text-center max-w-full line-clamp-2">
{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"
aria-label={t('sidebar.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="/archive"
aria-label={t('sidebar.archive')}
className={cn(
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
pathname === '/archive'
? 'bg-amber-500/10 text-amber-500 border border-amber-500/25'
: 'text-concrete hover:text-amber-500 hover:bg-amber-500/5'
)}
>
{pathname === '/archive' && <div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 bg-amber-500 rounded-r-full" />}
<Archive 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.archive')}
</span>
</Link>
<Link
href="/home?shared=1&forceList=1"
aria-label={t('sidebar.sharedWithMe')}
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"
aria-label={t('sidebar.searchShortcut')}
title={t('sidebar.searchShortcut')}
>
<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">
{t('sidebar.searchShortcut')}
</span>
</button>
<button
onClick={toggleTheme}
aria-label={isDark ? t('sidebar.lightMode') : t('sidebar.darkMode')}
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 ? t('sidebar.lightMode') : t('sidebar.darkMode')}
</span>
</button>
<Link
href="/settings"
aria-label={t('nav.settings')}
onClick={() => setActiveView('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-1 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')}
aria-label={t('sidebar.signOut')}
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 id="sidebar-context-panel" data-collapsed={collapseDashboardPanel} className="flex-1 h-full flex flex-col overflow-hidden bg-[#FCFCFA] dark:bg-[#111111]">
{/* ── Scrollable content ── */}
<div
className={cn(
'flex-1 flex flex-col min-h-0 -mx-0 pb-4',
panelView === 'notebooks'
? 'overflow-hidden'
: 'overflow-y-auto custom-scrollbar space-y-6',
)}
>
<AnimatePresence mode="wait">
{panelView === 'dashboard' ? (
<motion.div
key="dashboard"
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
transition={{ duration: 0.2 }}
className="px-4 pt-4 space-y-4"
>
<div className="flex items-center gap-1.5">
<LayoutGrid size={14} className="text-brand-accent" />
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
{t('nav.dashboard')}
</h3>
</div>
<p className="text-[12px] leading-relaxed text-muted-foreground">
{t('sidebar.dashboardPanelBody')}
</p>
<div className="space-y-1.5">
<button
type="button"
onClick={() => {
setActiveView('notebooks')
router.push('/home?forceList=1')
}}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
>
<BookOpen size={14} className="text-brand-accent shrink-0" />
{t('sidebar.backToNotebooks')}
</button>
</div>
</motion.div>
) : panelView === 'settings' ? (
<motion.div
key="settings"
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
transition={{ duration: 0.2 }}
className="px-4 pt-4"
>
<div className="flex items-center gap-1.5 mb-6">
<Settings size={14} className="text-brand-accent" />
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
{t('settings.title')}
</h3>
</div>
<div className="space-y-1">
{[
{ id: 'general', href: '/settings/general', label: t('generalSettings.title'), icon: Settings },
{ id: 'ai', href: '/settings/ai', label: t('aiSettings.title'), icon: Sparkles },
{ id: 'billing', href: '/settings/billing', label: t('billing.title'), icon: CreditCard },
{ id: 'appearance', href: '/settings/appearance', label: t('appearance.title'), icon: Palette },
{ id: 'profile', href: '/settings/profile', label: t('profile.title'), icon: User },
{ id: 'data', href: '/settings/data', label: t('dataManagement.title'), icon: Database },
{ id: 'published', href: '/settings/published', label: t('settings.publishedTitle') || 'Mes pages', icon: Globe },
{ id: 'integrations', href: '/settings/integrations', label: t('integrations.title') || 'Intégrations', icon: Plug },
{ id: 'mcp', href: '/settings/mcp', label: t('mcpSettings.title'), icon: Key },
{ id: 'about', href: '/settings/about', label: t('about.title'), icon: Info },
].map((tab) => {
const isActive = pathname === tab.href || pathname.startsWith(`${tab.href}/`)
return (
<Link
key={tab.id}
href={tab.href}
className={cn(
'w-full text-start px-3 py-2 text-[11px] transition-all rounded-lg flex items-center gap-2.5',
isActive
? 'text-ink bg-brand-accent/10'
: 'text-muted-foreground hover:text-ink hover:bg-black/5 dark:hover:bg-white/5',
)}
>
<tab.icon size={12} className="text-concrete shrink-0" />
<span className="font-semibold">{tab.label}</span>
</Link>
)
})}
</div>
</motion.div>
) : panelView === 'notebooks' ? (
<motion.div
key="notebooks"
ref={notebooksContainerRef}
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 data-sidebar-notebooks-top className="shrink-0">
<div className="px-4 pt-4">
<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('nav.notebooks')}
</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>
<button
type="button"
onClick={() => setShowAiWizard(true)}
className="p-1 hover:bg-brand-accent/10 rounded transition-all text-concrete hover:text-brand-accent"
title={t('wizard.title')}
>
<Sparkles size={14} />
</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="px-4 pb-2">
<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 flex-1',
isInboxActive ? 'text-ink' : 'text-muted-ink',
)}
>
{t('sidebar.inbox')}
</span>
{inboxCount > 0 && (
<span className={cn(
'text-[10px] font-bold min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full transition-colors',
isInboxActive
? 'bg-brand-accent text-white'
: 'bg-brand-accent/10 text-brand-accent',
)}>
{inboxCount > 99 ? '99+' : inboxCount}
</span>
)}
</button>
</div>
</div>
<div
role="separator"
aria-orientation="horizontal"
aria-label={t('sidebar.resizeNotebooksPanel')}
onPointerDown={handleNotebooksResizeStart}
className="flex shrink-0 h-3 mx-3 cursor-row-resize touch-none items-center justify-center group"
>
<div className="h-1 w-10 rounded-full bg-border/70 group-hover:bg-brand-accent/60 group-active:bg-brand-accent transition-colors" />
</div>
<div
ref={notebooksPanelRef}
className={cn(
'overflow-y-auto custom-scrollbar min-h-0 px-4 pb-4 shrink-0',
notebooksPanelHeight === null && 'flex-1',
)}
style={notebooksPanelHeight !== null ? { height: notebooksPanelHeight } : undefined}
>
<div className="my-3 h-px bg-border/40" />
{/* Zone de dépôt racine — toujours présente pour éviter tout décalage DOM */}
<div
className={`mb-2 h-10 rounded-lg border-2 border-dashed flex items-center justify-center gap-2 text-[11px] font-medium transition-all duration-150 ${
draggedId
? 'border-brand-accent/40 bg-brand-accent/5 text-brand-accent/70 opacity-100 cursor-copy pointer-events-auto'
: 'border-transparent bg-transparent text-transparent opacity-0 pointer-events-none'
}`}
onDrop={handleDropToRoot}
onDragOver={(e) => { e.preventDefault(); e.stopPropagation() }}
onDragEnter={(e) => { e.preventDefault() }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
{t('sidebar.dropToRoot')}
</div>
<div
className="space-y-0.5 min-h-[60px]"
onDrop={handleDropToRoot}
onDragOver={(e) => e.preventDefault()}
>
{renderCarnetTree(undefined, 0)}
</div>
</div>
</motion.div>
) : panelView === 'insights' ? (
<motion.div
key="insights"
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
transition={{ duration: 0.2 }}
className="px-4 pt-4 space-y-4"
>
<div className="flex items-center gap-1.5">
<Sparkles size={14} className="text-brand-accent" />
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
{t('nav.insights')}
</h3>
</div>
<p className="text-[12px] leading-relaxed text-muted-foreground">
{t('sidebar.insightsPanelBody')}
</p>
<button
type="button"
onClick={() => router.push('/home')}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
>
<Home size={14} className="text-brand-accent shrink-0" />
{t('sidebar.backToHome')}
</button>
<button
type="button"
onClick={() => router.push('/home?forceList=1')}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
>
<BookOpen size={14} className="text-brand-accent shrink-0" />
{t('sidebar.backToNotebooks')}
</button>
</motion.div>
) : panelView === 'revision' ? (
<motion.div
key="revision"
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
transition={{ duration: 0.2 }}
className="px-4 pt-4 space-y-4"
>
<div className="flex items-center gap-1.5">
<GraduationCap size={14} className="text-brand-accent" />
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
{t('nav.revision')}
</h3>
</div>
<p className="text-[12px] leading-relaxed text-muted-foreground">
{t('sidebar.revisionPanelBody')}
</p>
<button
type="button"
onClick={() => router.push('/home')}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
>
<Home size={14} className="text-brand-accent shrink-0" />
{t('sidebar.backToHome')}
</button>
<button
type="button"
onClick={() => router.push('/home?forceList=1')}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
>
<BookOpen size={14} className="text-brand-accent shrink-0" />
{t('sidebar.backToNotebooks')}
</button>
</motion.div>
) : panelView === '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 }}
className="flex flex-col min-h-0"
>
<div className="flex items-center gap-1.5 px-4 pt-4 mb-3 shrink-0">
<Bell size={14} className="text-brand-accent" />
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
{t('sidebar.reminders')}
</h3>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar min-h-0">
<SidebarReminders onOpenNote={handleReminderNoteClick} />
</div>
</motion.div>
) : panelView === '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('nav.agents')}
</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 + starter badge en bas du panneau ── */}
<div className="border-t border-border/20 px-3 py-3 mt-auto space-y-2">
<StarterPackBadge />
<UsageMeter />
</div>
</div>{/* fin colonne 2 */}
{hasCollapsiblePanel && (
<button
type="button"
onClick={toggleDashboardPanel}
aria-label={dashboardPanelLabel}
title={dashboardPanelLabel}
aria-expanded={!collapseDashboardPanel}
aria-controls="sidebar-context-panel"
className="group absolute top-5 -end-[18px] z-40 hidden h-9 w-9 items-center justify-center rounded-full md:flex focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-accent"
>
<span className="flex h-7 w-7 items-center justify-center rounded-full border border-border bg-background text-foreground transition-colors duration-150 group-hover:border-brand-accent group-hover:text-brand-accent">
<ChevronLeft size={16} strokeWidth={1.75} className={cn('transition-transform duration-300 motion-reduce:transition-none', collapseDashboardPanel ? 'rotate-180 rtl:rotate-0' : 'rtl:rotate-180')} />
</span>
</button>
)}
{/* Poignée redimensionnement largeur sidebar (bord vers le contenu) */}
<div
role="separator"
aria-orientation="vertical"
aria-label={t('sidebar.resizeSidebar')}
onPointerDown={handleSidebarWidthResizeStart}
className={cn('hidden md:flex absolute inset-y-0 end-0 translate-x-1/2 w-4 cursor-col-resize touch-none z-30 items-center justify-center group', collapseDashboardPanel && 'md:hidden')}
>
<div className="h-10 w-1 rounded-full bg-border/80 group-hover:bg-brand-accent/70 group-active:bg-brand-accent transition-colors shadow-sm" />
</div>
</aside>
<CreateNotebookDialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
parentNotebookId={createParentId}
/>
{showAiWizard && (
<AiNotebookWizard
onClose={() => setShowAiWizard(false)}
onComplete={async (notebookId: string) => {
// 1) Marquer IA + expand dans la sidebar
try {
const { markAiCreatedNotebook } = await import('@/lib/ai-created-highlight')
markAiCreatedNotebook(notebookId)
} catch { /* ignore */ }
setAiNotebookIds((prev) => new Set(prev).add(notebookId))
setExpandedIds((prev) => {
const next = new Set(prev)
next.add(notebookId)
return next
})
// 2) Recharger la liste des carnets (sinon le carnet n'apparaît qu'après F5)
await refreshNotebooks()
// 3) Forcer le rechargement des notes de ce carnet dans la sidebar
try {
const notes = await getAllNotes(false, notebookId)
const mapped = notes.map((n: Note) => ({
id: n.id,
title: getNoteDisplayTitle(n, t('notes.untitled')),
isPinned: n.isPinned,
autoGenerated: Boolean(n.autoGenerated),
isRecent: true,
}))
setNotebookNotes((prev) => ({ ...prev, [notebookId]: mapped }))
} catch { /* ignore */ }
setShowAiWizard(false)
// 4) Ouvrir le carnet
router.push(`/home?notebook=${notebookId}`)
}}
/>
)}
{/* 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>
</>
)
}