'use client' import Link from 'next/link' import { usePathname, useSearchParams, useRouter } from 'next/navigation' import { cn } from '@/lib/utils' import { Settings, Plus, ChevronRight, Lock, BookOpen, BookMarked, Bot, Inbox, FlaskConical, ArrowUpDown, Archive, Trash2, User, LogOut, Shield, GripVertical, Users, Bell, Pencil, Clock, Moon, Sun, Pin, PinOff, Sparkles, Home, Search, GraduationCap, FileText, Folder, FolderOpen, } from 'lucide-react' import { useSearchModal } from '@/context/search-modal-context' import { useLanguage } from '@/lib/i18n' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { applyDocumentTheme } from '@/lib/apply-document-theme' import { getAllNotes, getTrashCount } from '@/app/actions/notes' import { NOTE_CHANGE_EVENT, type NoteChangeEvent } from '@/lib/note-change-sync' import { useNotebooks } from '@/context/notebooks-context' import { Notebook, Note } from '@/lib/types' import { toast } from 'sonner' import { motion, AnimatePresence } from 'motion/react' import { getNoteDisplayTitle } from '@/lib/note-preview' import { CreateNotebookDialog } from './create-notebook-dialog' import { NotificationPanel } from './notification-panel' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { performSignOut } from '@/lib/auth-client' import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm' import { UsageMeter } from './usage-meter' type NavigationView = 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision' type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual' function NoteLink({ title, isActive, isPinned, onClick, }: { title: string isActive: boolean isPinned?: boolean onClick: () => void }) { const { language } = useLanguage() const slideX = language === 'fa' || language === 'ar' ? 10 : -10 return ( {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')}

) } return (
{sessions.slice(0, 10).map(s => (
{(s as any)._owned !== false && ( )}
))}
) } 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 && (
)}
{hasChildren ? ( ) : (
)} { e.stopPropagation(); onRename() }} className={cn( 'flex-1 flex items-center gap-2.5 px-2 py-1.5 rounded-lg transition-all duration-300 group/item cursor-pointer relative', isActive ? 'bg-white dark:bg-white/10 shadow-sm border border-border/40' : 'hover:bg-white/40 dark:hover:bg-white/5' )} > {isActive && ( )}
{isExpanded ? : }
{carnet.name} {carnet.isPrivate && } {!isActive && hasActiveDescendant && ( )}
{isPinned && ( )} {notes.length > 0 && ( {notes.length} )}
{/* Right-click context menu */} {contextMenu && ( e.stopPropagation()} >
)} {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 [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) 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 setActiveView('notebooks') }, [pathname]) const displayName = user?.name || user?.email || '' const initial = displayName ? displayName.charAt(0).toUpperCase() : '?' // Sorted list for the sort dropdown (not used directly when dragging) const sortedNotebooks = useMemo(() => { const arr = [...notebooks] if (sortOrder === 'manual') return arr.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) if (sortOrder === 'alpha') return arr.sort((a, b) => a.name.localeCompare(b.name)) if (sortOrder === 'newest') return arr.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) if (sortOrder === 'oldest') return arr.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) return arr }, [notebooks, sortOrder]) // Sync orderedNotebooks from server ONLY when not in the middle of a drag save useEffect(() => { if (isSavingRef.current) return setOrderedNotebooks(sortedNotebooks) }, [sortedNotebooks]) useEffect(() => { let cancelled = false getTrashCount().then(count => { if (!cancelled) setTrashCount(count) }) return () => { cancelled = true } }, []) const notebookIdsKey = useMemo(() => notebooks.map(nb => nb.id).sort().join(','), [notebooks]) useEffect(() => { if (!notebookIdsKey) return let cancelled = false const load = async () => { const mappedEntries = await Promise.all( notebooks.map(async (nb: Notebook) => { const notes = await getAllNotes(false, nb.id) const mapped = notes.map((n: Note) => ({ id: n.id, title: getNoteDisplayTitle(n, t('notes.untitled')), isPinned: n.isPinned, })) return [nb.id, mapped] as const }) ) if (cancelled) return setNotebookNotes(Object.fromEntries(mappedEntries)) } load() return () => { cancelled = true } }, [notebookIdsKey, t]) useEffect(() => { const onNoteChange = (e: Event) => { const detail = (e as CustomEvent).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 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(null) const [dropAction, setDropAction] = useState<'into' | null>(null) const handleDragStart = (e: React.DragEvent, notebookId: string) => { setDraggedId(notebookId) e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', notebookId) } const handleDragEnd = () => { setDraggedId(null) dragOverId.current = null isSavingRef.current = false setDropTarget(null) setDropAction(null) setOrderedNotebooks(sortedNotebooks) } const handleDropOnNotebook = async (e: React.DragEvent, targetId: string) => { e.preventDefault() e.stopPropagation() setDropTarget(null) setDropAction(null) const dragId = draggedId || e.dataTransfer.getData('text/plain') if (!dragId || dragId === targetId) { setDraggedId(null) return } setDraggedId(null) dragOverId.current = null try { await moveNotebookToParent(dragId, targetId) } catch (err) { toast.error(err instanceof Error ? err.message : t('sidebar.moveFailed')) setOrderedNotebooks(sortedNotebooks) } } const handleDropToRoot = async (e: React.DragEvent) => { e.preventDefault() setDropTarget(null) setDropAction(null) const dragId = draggedId || e.dataTransfer.getData('text/plain') if (!dragId) { setDraggedId(null) return } setDraggedId(null) dragOverId.current = null try { await moveNotebookToParent(dragId, null) } catch (err) { toast.error(err instanceof Error ? err.message : t('sidebar.moveFailed')) setOrderedNotebooks(sortedNotebooks) } } const sortLabels: Record = { 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' setDropTarget(notebook.id) setDropAction('into') }} onDragLeave={(e) => { if (e.currentTarget.contains(e.relatedTarget as Node)) return if (dropTarget === notebook.id) { setDropTarget(null) setDropAction(null) } }} onDrop={(e) => { e.preventDefault() e.stopPropagation() handleDropOnNotebook(e, notebook.id) }} className={cn( 'rounded-lg transition-colors', dropTarget === notebook.id && dropAction === 'into' && draggedId && draggedId !== notebook.id && 'bg-brand-accent/10 ring-1 ring-brand-accent/30', isDragging && 'opacity-50' )} > { 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)} /> )} {/* 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')} />
)}
{/* 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.`}

)}
)}
) }