'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,
CalendarDays,
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, getNotesWithReminders, toggleReminderDone } 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 { AiNotebookWizard } from './wizard/ai-notebook-wizard'
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'
import { StarterPackBadge } from './onboarding/starter-pack-badge'
type NavigationView = 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision' | 'insights'
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 (
{title}
{isPinned && }
)
}
function SidebarBrainstorms() {
const { data: sessions, isLoading } = useBrainstormSessions()
const deleteBrainstorm = useDeleteBrainstorm()
const router = useRouter()
const { t } = useLanguage()
if (isLoading) {
return (
{[1, 2, 3].map(i => (
))}
)
}
if (!sessions || sessions.length === 0) {
return (
{t('brainstorm.noSessions')}
router.push('/brainstorm')}
className="mt-2 text-[11px] text-brand-accent hover:text-brand-accent/80 font-medium"
>
{t('brainstorm.startOne')} →
)
}
return (
{sessions.slice(0, 10).map(s => (
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' : ''
}`}
>
{s.seedIdea}
{(s as any)._owned === false && (
{t('sidebar.sharedNotebookBadge')}
)}
{s.activeIdeas} {t('brainstorm.ideas')} · {new Date(s.createdAt).toLocaleDateString()}
{(s as any)._owned !== false && (
{
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"
>
)}
))}
)
}
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(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 (
{[1, 2, 3].map((i) => (
))}
)
}
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 (
{t('reminders.emptyDescription')}
)
}
const renderItem = (note: (typeof active)[0], overdueItem?: boolean) => (
{/* Bouton compléter */}
{ 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
?
: null}
{/* Contenu cliquable */}
onOpenNote(note.id, note.notebookId)}
className="flex-1 text-start min-w-0"
>
{note.title || t('notes.untitled')}
{note.reminder &&
new Date(note.reminder).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
{/* Actions (visibles au hover) */}
{ 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'}
>
+1h
)
return (
{overdue.length > 0 && (
{t('reminders.overdue')}
{overdue.map((n) => renderItem(n, true))}
)}
{upcoming.length > 0 && (
{t('reminders.upcoming')}
{upcoming.map((n) => renderItem(n))}
)}
)
}
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
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 (
{
e.preventDefault()
e.stopPropagation()
setContextMenu({ x: e.clientX, y: e.clientY })
}}
>
{level > 0 && (
)}
{level > 0 && (
)}
{/* Drag handle — visible au hover, dans son propre slot avant le chevron */}
e.stopPropagation()}
>
{hasChildren ? (
{ 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"
>
) : (
)}
{ 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' : 'hover:bg-white/40 dark:hover:bg-white/5'
)}
>
{isActive && (
)}
{isExpanded ? : }
{carnet.name}
{carnet.isPrivate && }
{!isActive && hasActiveDescendant && (
)}
{/* Compteur de notes (toujours visible, à droite du nom) */}
{notes.length > 0 && (
{notes.length}
)}
{/* Boutons d'action en absolu, toujours à droite */}
{isPinned && (
)}
{ e.stopPropagation(); onAddSubNotebook() }}
className="p-1 hover:bg-ink/10 rounded-md transition-all text-concrete hover:text-ink"
title={t('notebook.createSubNotebook')}
>
{ e.stopPropagation(); onRename() }}
className="p-1 hover:bg-ink/10 rounded-md transition-all text-concrete hover:text-ink"
title={t('notebook.rename')}
>
{ e.stopPropagation(); onDelete() }}
className="p-1 hover:bg-rose-50 rounded-md transition-all text-concrete hover:text-rose-500"
title={t('notebook.delete')}
>
{/* Right-click context menu */}
{contextMenu && (
e.stopPropagation()}
>
{ 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
? <>{t('sidebar.unfreezePinnedNotebook')} >
: <>{t('sidebar.freezePinnedNotebook')} >
}
{ 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"
>
{t('sidebar.newSubNotebook')}
{ 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"
>
{t('sidebar.renameNotebook')}
{ 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"
>
{t('common.delete')}
)}
{isExpanded && (
{children}
{isExpanded && notes.map(note => (
onNoteClick(note.id, carnet.id)}
/>
))}
{isExpanded && notes.length === 0 && !hasChildren && (
{t('sidebar.notebookEmpty')}
)}
)}
)
}
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(null)
const [renamingNotebook, setRenamingNotebook] = useState(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(null)
const [isDeleting, setIsDeleting] = useState(false)
const [isRenaming, setIsRenaming] = useState(false)
const [expandedIds, setExpandedIds] = useState>(new Set())
const [pinnedIds, setPinnedIds] = useState>(new Set())
const [notebookNotes, setNotebookNotes] = useState>({})
const [activeView, setActiveView] = useState('notebooks')
const [sortOrder, setSortOrder] = useState('newest')
const [showSortMenu, setShowSortMenu] = useState(false)
const [notebookSearchQuery, setNotebookSearchQuery] = useState('')
const [trashCount, setTrashCount] = useState(0)
const [draggedId, setDraggedId] = useState(null)
// Ref stable pour éviter les stale closures dans les handlers de drop
const draggedIdRef = useRef(null)
const [orderedNotebooks, setOrderedNotebooks] = useState([])
const dragOverId = useRef(null)
const isSavingRef = useRef(false)
const rootNotebooks = useMemo(() => orderedNotebooks.filter(nb => !nb.parentId), [orderedNotebooks])
const childNotebooks = useMemo(() => {
const map = new Map()
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 if (pathname === '/insights') setActiveView('insights')
else if (pathname.startsWith('/revision')) setActiveView('revision')
else if (searchParams.get('reminders') === '1' && pathname === '/home') setActiveView('reminders')
else if (pathname === '/home' || pathname.startsWith('/notes')) setActiveView('notebooks')
}, [pathname, searchParams])
const isRemindersRoute = pathname === '/home' && searchParams.get('reminders') === '1'
const isSharedRoute = pathname === '/home' && searchParams.get('shared') === '1'
const isImmersiveRoute = pathname === '/insights'
// Sur /insights : sidebar visible par défaut, user peut le cacher via toggle
const [userCollapsed, setUserCollapsed] = useState(false)
useEffect(() => {
if (isImmersiveRoute) {
const stored = localStorage.getItem('insights-sidebar-collapsed')
setUserCollapsed(stored === 'true')
}
}, [isImmersiveRoute])
useEffect(() => {
const onToggle = () => {
setUserCollapsed(prev => {
const next = !prev
if (isImmersiveRoute) localStorage.setItem('insights-sidebar-collapsed', String(next))
return next
})
}
window.addEventListener('toggle-insights-sidebar', onToggle)
return () => window.removeEventListener('toggle-insights-sidebar', onToggle)
}, [isImmersiveRoute])
const isNotebooksRoute =
(pathname === '/home' || pathname.startsWith('/notes')) &&
!pathname.startsWith('/settings') &&
!isRemindersRoute &&
!isSharedRoute
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).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 = {}
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 handleDailyNoteClick = async () => {
try {
const res = await fetch('/api/notes/daily')
const data = await res.json()
if (data.success && data.note) {
const params = new URLSearchParams()
if (data.note.notebookId) params.set('notebook', data.note.notebookId)
params.set('openNote', data.note.id)
router.push(`/home?${params.toString()}`)
}
} catch {
toast.error(t('sidebar.dailyNoteError') || 'Impossible d\'ouvrir la note du jour')
}
}
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(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 = {
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 (
{
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 && (
)}
{/* Indicateur "déposer après" */}
{dropTarget === notebook.id && dropAction === 'after' && draggedId && draggedId !== notebook.id && (
)}
{
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}
/>
{isExpanded && renderCarnetTree(notebook.id, level + 1)}
)
})
}, [rootNotebooks, childNotebooks, filteredNotebookIds, currentNotebookId, currentNoteId, notebookNotes, draggedId, dropTarget, dropAction, expandedIds, pinnedIds, toggleExpand, handleCarnetClick, handleNoteClick, handleDragStart, handleDragEnd, handleDropOnNotebook, handleStartRename])
return (
<>
{/* Overlay mobile */}
{isMobileOpen && (
setIsMobileOpen(false)}
/>
)}
{/* ── Column 1 : Rail d'icônes (54px) — inspiré du prototype ── */}
{/* Top : Logo + navigation */}
{/* Logo avec dropdown profil */}
M
{t('sidebar.profile')}
{(user as { role?: string } | undefined)?.role === 'ADMIN' && (
{t('nav.adminDashboard')}
)}
performSignOut('/login')}
>
{t('sidebar.signOut')}
{/* Boutons de navigation principaux */}
{([
{
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('agents.intelligenceOS') || 'Intelligence IA',
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 => (
{item.isActive && (
)}
{item.label}
))}
{/* Bottom : utilitaires */}
{pathname === '/trash' &&
}
{trashCount > 0 && (
)}
{t('sidebar.trash')}
{t('sidebar.sharedWithMe')}
Recherche (Ctrl+K)
{isDark ? : }
{isDark ? 'Mode clair' : 'Mode sombre'}
{pathname.startsWith('/settings') &&
}
{t('nav.settings')}
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"
>
{t('sidebar.signOut')}
{/* ── Column 2 : Panneau de contenu dynamique ── */}
{/* ── Scrollable content ── */}
{activeView === 'notebooks' ? (
{t('sidebar.inbox')}
{t('sidebar.dailyNote') || 'Note du jour'}
{/* Zone de dépôt racine — toujours présente pour éviter tout décalage DOM */}
{ e.preventDefault(); e.stopPropagation() }}
onDragEnter={(e) => { e.preventDefault() }}
>
{t('sidebar.dropToRoot')}
e.preventDefault()}
>
{renderCarnetTree(undefined, 0)}
) : activeView === 'insights' ? (
{t('nav.insights')}
{t('sidebar.insightsPanelBody')}
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"
>
{t('sidebar.backToNotebooks')}
) : activeView === 'revision' ? (
{t('nav.revision')}
{t('sidebar.revisionPanelBody')}
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"
>
{t('sidebar.backToNotebooks')}
) : activeView === 'reminders' ? (
{t('sidebar.reminders')}
) : activeView === 'agents' ? (
{t('agents.intelligenceOS')}
{[
{ 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 (
{item.label}
)
})}
) : (
{t('brainstorm.sessions')}
router.push('/brainstorm')}
className="p-1 text-muted-foreground hover:text-brand-accent transition-colors rounded"
title={t('brainstorm.newBrainstorm')}
>
)}
{/* ── Usage meter + starter badge en bas du panneau ── */}
{/* fin colonne 2 */}
{showAiWizard && (
setShowAiWizard(false)}
onComplete={(notebookId: string) => {
setShowAiWizard(false)
router.push(`/home?notebook=${notebookId}`)
}}
/>
)}
{/* Rename Dialog */}
{renamingNotebook && (
!isRenaming && setRenamingNotebook(null)}
>
e.stopPropagation()}
>
{t('notebook.rename')}
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')}
/>
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')}
{isRenaming ? '...' : t('notebook.confirmRename')}
)}
{/* Delete Confirmation */}
{deletingNotebook && (
!isDeleting && setDeletingNotebook(null)}
>
e.stopPropagation()}
>
{t('notebook.trashTitle')}
{t('notebook.trashConfirm', { name: deletingNotebook.name }) || `Move "${deletingNotebook.name}" to trash? You can restore it within 30 days.`}
{getDescendantIds(deletingNotebook.id).length > 0 && (
{t('notebook.trashCascadeWarning', { count: getDescendantIds(deletingNotebook.id).length }) || `${getDescendantIds(deletingNotebook.id).length} sub-notebook(s) will also be moved to trash.`}
)}
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')}
{isDeleting ? '...' : t('notebook.moveToTrash')}
)}
>
)
}