'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, Bot, Inbox, FlaskConical, ArrowUpDown, Archive, Trash2, User, LogOut, Shield, GripVertical, Users, Bell, Pencil, Clock, Moon, Sun, Pin, PinOff, } from 'lucide-react' 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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { useNotebooks } from '@/context/notebooks-context' import { Notebook, Note } from '@/lib/types' 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 { signOut } from 'next-auth/react' import { useNoteRefresh } from '@/context/NoteRefreshContext' type NavigationView = 'notebooks' | 'agents' | 'reminders' type SortOrder = 'newest' | 'oldest' | 'alpha' function NoteLink({ title, isActive, onClick, }: { title: string isActive: boolean onClick: () => void }) { return (
{title} ) } function SidebarCarnetItem({ carnet, isActive, notes, activeNoteId, onCarnetClick, onNoteClick, onAddSubNotebook, onRename, onDelete, onTogglePin, isPinned, children, isDragging, dragHandleProps, level, isExpanded, toggleExpand, }: { carnet: { id: string; name: string; initial: string; isPrivate?: boolean } isActive: boolean notes: { id: string; title: string }[] 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 }) { const { t } = useLanguage() const hasChildren = React.Children.count(children) > 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 && ( )}
{carnet.initial}
{carnet.name} {carnet.isPrivate && }
{isPinned && ( )} {notes.length > 0 && ( {notes.length} )}
{/* Right-click context menu */} {contextMenu && ( e.stopPropagation()} >
)} {isExpanded && (
{children} {isActive && notes.map(note => ( onNoteClick(note.id, carnet.id)} /> ))} {isActive && notes.length === 0 && !hasChildren && (

{t('common.noResults') || 'No notes found'}

)}
)}
) } export function Sidebar({ className, user }: { className?: string; user?: any }) { const pathname = usePathname() const searchParams = useSearchParams() const router = useRouter() const { t } = useLanguage() const { notebooks, trashNotebook, updateNotebookOrderOptimistic } = useNotebooks() const { refreshKey } = useNoteRefresh() const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) const [createParentId, setCreateParentId] = useState(null) const [renamingNotebook, setRenamingNotebook] = useState(null) const [renameValue, setRenameValue] = useState('') const [isDark, setIsDark] = useState(false) 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 [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 currentNotebookId = searchParams.get('notebook') const currentNoteId = searchParams.get('openNote') const isInboxActive = pathname === '/' && !searchParams.get('notebook') && !searchParams.get('labels') && !searchParams.get('archived') && !searchParams.get('trashed') useEffect(() => { setActiveView( pathname.startsWith('/agents') || pathname.startsWith('/lab') ? 'agents' : '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 === '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 } }, [refreshKey]) 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')), })) return [nb.id, mapped] as const }) ) if (cancelled) return setNotebookNotes(Object.fromEntries(mappedEntries)) } load() return () => { cancelled = true } // refreshKey: reload note titles whenever any note is saved/created/deleted }, [notebookIdsKey, refreshKey, t]) const handleCarnetClick = (notebookId: string) => { const params = new URLSearchParams() params.set('notebook', notebookId) params.set('forceList', '1') router.push(`/?${params.toString()}`) } const handleInboxClick = () => { router.push('/?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(`/?${params.toString()}`) } // ── Drag handlers ── const handleDragStart = (e: React.DragEvent, notebookId: string) => { setDraggedId(notebookId) e.dataTransfer.effectAllowed = 'move' } const handleDragOver = (e: React.DragEvent, notebookId: string) => { e.preventDefault() e.dataTransfer.dropEffect = 'move' if (dragOverId.current === notebookId) return dragOverId.current = notebookId if (!draggedId || draggedId === notebookId) return setOrderedNotebooks(prev => { const fromIdx = prev.findIndex(n => n.id === draggedId) const toIdx = prev.findIndex(n => n.id === notebookId) if (fromIdx === -1 || toIdx === -1) return prev const next = [...prev] const [item] = next.splice(fromIdx, 1) next.splice(toIdx, 0, item) return next }) } const handleDrop = async (e: React.DragEvent) => { e.preventDefault() if (!draggedId) return const savedOrder = [...orderedNotebooks] setDraggedId(null) dragOverId.current = null // Block the sync effect so the server reload doesn't overwrite local order isSavingRef.current = true try { await updateNotebookOrderOptimistic(savedOrder.map(n => n.id)) // Keep local order — server will return them in the right order next load setOrderedNotebooks(savedOrder) } catch { // On failure, revert to original server order isSavingRef.current = false setOrderedNotebooks(sortedNotebooks) } finally { // Allow sync again after 2 s (time for the server reload to settle) setTimeout(() => { isSavingRef.current = false }, 2000) } } const handleDragEnd = () => { if (draggedId) { // Drag cancelled without drop — restore setDraggedId(null) dragOverId.current = null isSavingRef.current = false setOrderedNotebooks(sortedNotebooks) } } const sortLabels: Record = { newest: t('sidebar.sortNewest'), oldest: t('sidebar.sortOldest'), alpha: t('sidebar.sortAlpha'), } 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('') router.refresh() } catch (err) { console.error('Rename failed:', err) } finally { setIsRenaming(false) } }, [renamingNotebook, renameValue, router]) 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('/') } } 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) || []) 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 hasActiveDescendant = children.some(c => currentNotebookId === c.id || (childNotebooks.get(c.id) || []).some(gc => currentNotebookId === gc.id) ) // A notebook stays expanded if: manually expanded, has active descendant, OR is pinned const isExpanded = expandedIds.has(notebook.id) || hasActiveDescendant || pinnedIds.has(notebook.id) return (
handleDragStart(e, notebook.id) : undefined} onDragOver={level === 0 ? (e) => handleDragOver(e, notebook.id) : undefined} onDragEnd={level === 0 ? handleDragEnd : undefined} > { if (currentNotebookId === notebook.id) { toggleExpand(notebook.id) } else { handleCarnetClick(notebook.id) if (!expandedIds.has(notebook.id)) toggleExpand(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)} > {renderCarnetTree(notebook.id, level + 1)}
) }) }, [rootNotebooks, childNotebooks, currentNotebookId, currentNoteId, notebookNotes, draggedId, expandedIds, toggleExpand, handleCarnetClick, handleNoteClick, handleDragStart, handleDragOver, handleDragEnd, handleStartRename]) return ( <> {/* Rename Dialog */} {renamingNotebook && ( !isRenaming && setRenamingNotebook(null)} > e.stopPropagation()} >

{t('notebook.rename') || 'Rename notebook'}

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') || 'Notebook name'} />
)}
{/* Delete Confirmation */} {deletingNotebook && ( !isDeleting && setDeletingNotebook(null)} > e.stopPropagation()} >

{t('notebook.trashTitle') || 'Move to trash'}

{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.`}

)}
)}
) }