All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m3s
- Add parentId to Notebook model (tree structure) - Update sidebar to render parent/child notebooks with expand/collapse - Add sub-notebook creation from parent notebook - Remove 'list' from NotesViewMode type everywhere - Delete 22 unused components, hooks, and UI files - Wrap revalidatePath in try-catch to prevent save 500 - Update notebook API to support parentId in creation
732 lines
29 KiB
TypeScript
732 lines
29 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,
|
|
Bot,
|
|
Inbox,
|
|
FlaskConical,
|
|
ArrowUpDown,
|
|
Archive,
|
|
MessageSquare,
|
|
Sparkles,
|
|
Trash2,
|
|
User,
|
|
LogOut,
|
|
Shield,
|
|
GripVertical,
|
|
Users,
|
|
Bell,
|
|
} from 'lucide-react'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { getAllNotes } 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'
|
|
type SortOrder = 'newest' | 'oldest' | 'alpha'
|
|
|
|
function NoteLink({
|
|
title,
|
|
isActive,
|
|
onClick,
|
|
}: {
|
|
title: string
|
|
isActive: boolean
|
|
onClick: () => void
|
|
}) {
|
|
return (
|
|
<motion.button
|
|
initial={{ opacity: 0, x: -10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
onClick={onClick}
|
|
className={cn(
|
|
'w-full flex items-center gap-2 pl-12 pr-4 py-2 text-[12px] transition-colors rounded-lg text-left',
|
|
isActive ? 'bg-white/50 text-foreground font-medium' : 'text-muted-foreground hover:text-foreground hover:bg-white/30'
|
|
)}
|
|
>
|
|
<div className={cn(
|
|
'w-1.5 h-1.5 rounded-full shrink-0',
|
|
isActive ? 'bg-foreground' : 'bg-transparent border border-muted-foreground/30'
|
|
)} />
|
|
<span className="break-words line-clamp-2 leading-tight">{title}</span>
|
|
</motion.button>
|
|
)
|
|
}
|
|
|
|
function SidebarCarnetItem({
|
|
carnet,
|
|
isActive,
|
|
notes,
|
|
activeNoteId,
|
|
onCarnetClick,
|
|
onNoteClick,
|
|
children,
|
|
isDragging,
|
|
dragHandleProps,
|
|
}: {
|
|
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
|
|
children?: React.ReactNode
|
|
isDragging?: boolean
|
|
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>
|
|
}) {
|
|
const { t } = useLanguage()
|
|
return (
|
|
<div className={cn('space-y-1 transition-opacity', isDragging && 'opacity-40')}>
|
|
<div className="relative group/carnet">
|
|
<div
|
|
{...dragHandleProps}
|
|
className="absolute left-1 top-1/2 -translate-y-1/2 p-1 rounded text-muted-foreground/30 hover:text-muted-foreground cursor-grab active:cursor-grabbing opacity-0 group-hover/carnet:opacity-100 transition-opacity z-10"
|
|
title="Déplacer"
|
|
>
|
|
<GripVertical size={12} />
|
|
</div>
|
|
|
|
<motion.button
|
|
whileHover={{ x: 4 }}
|
|
onClick={onCarnetClick}
|
|
className={cn(
|
|
'w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group',
|
|
isActive ? 'memento-active-nav' : 'hover:bg-white/40'
|
|
)}
|
|
>
|
|
<motion.div
|
|
animate={{ rotate: isActive ? 90 : 0 }}
|
|
className="text-muted-foreground"
|
|
>
|
|
<ChevronRight size={14} />
|
|
</motion.div>
|
|
<div className={cn(
|
|
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
|
|
isActive
|
|
? 'bg-foreground text-background border-foreground'
|
|
: 'bg-white/60 text-foreground border-border'
|
|
)}>
|
|
{carnet.initial}
|
|
</div>
|
|
<div className="flex-1 text-left min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className={cn(
|
|
'text-[13px] font-medium transition-colors truncate',
|
|
isActive ? 'text-foreground' : 'text-muted-foreground'
|
|
)}>
|
|
{carnet.name}
|
|
</span>
|
|
{carnet.isPrivate && <Lock size={10} className="text-muted-foreground shrink-0" />}
|
|
</div>
|
|
</div>
|
|
</motion.button>
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{isActive && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}
|
|
className="overflow-hidden space-y-0.5"
|
|
>
|
|
{children}
|
|
{notes.map(note => (
|
|
<NoteLink
|
|
key={note.id}
|
|
title={note.title}
|
|
isActive={activeNoteId === note.id}
|
|
onClick={() => onNoteClick(note.id, carnet.id)}
|
|
/>
|
|
))}
|
|
{notes.length === 0 && !children && (
|
|
<p className="pl-12 text-[11px] text-muted-foreground/50 py-2 italic font-light">{t('common.noResults')}</p>
|
|
)}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function Sidebar({ className, user }: { className?: string; user?: any }) {
|
|
const pathname = usePathname()
|
|
const searchParams = useSearchParams()
|
|
const router = useRouter()
|
|
const { t } = useLanguage()
|
|
const { notebooks, updateNotebookOrderOptimistic } = useNotebooks()
|
|
const { refreshKey } = useNoteRefresh()
|
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
|
const [createParentId, setCreateParentId] = useState<string | null>(null)
|
|
const [notebookNotes, setNotebookNotes] = useState<Record<string, { id: string; title: string }[]>>({})
|
|
const [activeView, setActiveView] = useState<NavigationView>('notebooks')
|
|
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
|
|
const [showSortMenu, setShowSortMenu] = useState(false)
|
|
|
|
const [draggedId, setDraggedId] = useState<string | null>(null)
|
|
const [orderedNotebooks, setOrderedNotebooks] = useState<Notebook[]>([])
|
|
const dragOverId = useRef<string | null>(null)
|
|
const isSavingRef = useRef(false)
|
|
|
|
const rootNotebooks = useMemo(() => orderedNotebooks.filter(nb => !nb.parentId), [orderedNotebooks])
|
|
const childNotebooks = useMemo(() => {
|
|
const map = new Map<string, Notebook[]>()
|
|
for (const nb of orderedNotebooks) {
|
|
if (nb.parentId) {
|
|
const children = map.get(nb.parentId) || []
|
|
children.push(nb)
|
|
map.set(nb.parentId, children)
|
|
}
|
|
}
|
|
return map
|
|
}, [orderedNotebooks])
|
|
|
|
const 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])
|
|
|
|
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<SortOrder, string> = {
|
|
newest: t('sidebar.sortNewest'),
|
|
oldest: t('sidebar.sortOldest'),
|
|
alpha: t('sidebar.sortAlpha'),
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<aside
|
|
className={cn(
|
|
'hidden h-full min-h-0 w-72 shrink-0 flex-col lg:w-80 md:flex',
|
|
'border-e border-border/40 bg-memento-sidebar backdrop-blur-md sidebar-shadow dark:border-white/6 dark:bg-[#252525] dark:backdrop-blur-none',
|
|
className
|
|
)}
|
|
>
|
|
{/* ── Top: Avatar + View Toggle ── */}
|
|
<div className="p-6 flex items-center justify-between mb-4">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="shrink-0 rounded-full outline-none ring-offset-background transition-shadow hover:ring-2 hover:ring-primary/30 focus-visible:ring-2 focus-visible:ring-ring"
|
|
aria-label={t('sidebar.accountMenu')}
|
|
>
|
|
<div className="w-10 h-10 rounded-full bg-secondary border border-black/10 flex items-center justify-center text-foreground font-memento-serif text-lg shadow-sm">
|
|
{user?.image ? (
|
|
<Avatar className="size-10 ring-1 ring-border/60">
|
|
<AvatarImage src={user.image} alt="" />
|
|
<AvatarFallback className="bg-secondary text-sm font-semibold text-[#1C1C1C]/60">{initial}</AvatarFallback>
|
|
</Avatar>
|
|
) : (
|
|
<span>{initial}</span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="w-52 bg-popover border-border">
|
|
<DropdownMenuItem asChild>
|
|
<Link href="/settings/profile" className="flex items-center gap-2 cursor-pointer">
|
|
<User className="h-4 w-4" />
|
|
{t('sidebar.profile') || 'Profil'}
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem asChild>
|
|
<Link href="/settings" className="flex items-center gap-2 cursor-pointer">
|
|
<Settings className="h-4 w-4" />
|
|
{t('nav.settings') || 'Paramètres'}
|
|
</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') || 'Administration'}
|
|
</a>
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
className="text-destructive focus:text-destructive"
|
|
onClick={() => signOut({ callbackUrl: '/login' })}
|
|
>
|
|
<LogOut className="h-4 w-4 mr-2" />
|
|
{t('sidebar.signOut') || 'Se déconnecter'}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* Notification bell + Notebooks / Agents toggle */}
|
|
<div className="flex items-center gap-2">
|
|
<NotificationPanel />
|
|
<div className="flex bg-white/50 p-1 rounded-full border border-border transition-all">
|
|
<button
|
|
onClick={() => { setActiveView('notebooks'); if (pathname !== '/') router.push('/') }}
|
|
className={cn('p-1.5 rounded-full transition-all', activeView === 'notebooks' ? 'bg-foreground text-background' : 'text-muted-foreground hover:text-foreground')}
|
|
title={t('nav.notebooks')}
|
|
>
|
|
<BookOpen size={14} />
|
|
</button>
|
|
<button
|
|
onClick={() => { setActiveView('agents'); router.push('/agents') }}
|
|
className={cn('p-1.5 rounded-full transition-all', activeView === 'agents' ? 'bg-foreground text-background' : 'text-muted-foreground hover:text-foreground')}
|
|
title={t('nav.agents')}
|
|
>
|
|
<Bot size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Scrollable content ── */}
|
|
<div className="flex-1 overflow-y-auto space-y-6 -mx-2 px-2 custom-scrollbar pb-4">
|
|
|
|
<AnimatePresence mode="wait">
|
|
{activeView === 'notebooks' ? (
|
|
<motion.div
|
|
key="notebooks"
|
|
initial={{ opacity: 0, x: -10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: 10 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
{/* Section header with sort button */}
|
|
<div className="flex items-center justify-between px-4 mb-3">
|
|
<p className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase">
|
|
{t('nav.notebooks')}
|
|
</p>
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setShowSortMenu(s => !s)}
|
|
className="p-1 text-muted-foreground hover:text-foreground transition-colors rounded"
|
|
title={t('sidebar.sortOrder')}
|
|
>
|
|
<ArrowUpDown size={12} />
|
|
</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 right-0 top-full mt-1 bg-card border border-border rounded-xl shadow-lg z-50 py-1 min-w-[140px]"
|
|
>
|
|
{(['newest', 'oldest', 'alpha'] as SortOrder[]).map(order => (
|
|
<button
|
|
key={order}
|
|
onClick={() => { setSortOrder(order); setShowSortMenu(false) }}
|
|
className={cn(
|
|
'w-full text-left 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>
|
|
|
|
{/* Inbox — Notes without notebook */}
|
|
<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-foreground text-background border-foreground'
|
|
: 'bg-white/60 text-foreground border-border'
|
|
)}>
|
|
<Inbox size={14} />
|
|
</div>
|
|
<span className={cn(
|
|
'text-[13px] font-medium truncate',
|
|
isInboxActive ? 'text-foreground' : 'text-muted-foreground'
|
|
)}>
|
|
{t('sidebar.inbox')}
|
|
</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => {
|
|
const params = new URLSearchParams()
|
|
params.set('shared', '1')
|
|
params.set('forceList', '1')
|
|
router.push(`/?${params.toString()}`)
|
|
}}
|
|
className={cn('sidebar-inbox-item', searchParams.get('shared') === '1' && pathname === '/' && 'active')}
|
|
>
|
|
<div className={cn(
|
|
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
|
|
searchParams.get('shared') === '1' && pathname === '/'
|
|
? 'bg-foreground text-background border-foreground'
|
|
: 'bg-white/60 text-foreground border-border'
|
|
)}>
|
|
<Users size={14} />
|
|
</div>
|
|
<span className={cn(
|
|
'text-[13px] font-medium truncate',
|
|
searchParams.get('shared') === '1' && pathname === '/' ? 'text-foreground' : 'text-muted-foreground'
|
|
)}>
|
|
{t('sidebar.sharedWithMe')}
|
|
</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => {
|
|
const params = new URLSearchParams()
|
|
params.set('reminders', '1')
|
|
params.set('forceList', '1')
|
|
router.push(`/?${params.toString()}`)
|
|
}}
|
|
className={cn('sidebar-inbox-item', searchParams.get('reminders') === '1' && pathname === '/' && 'active')}
|
|
>
|
|
<div className={cn(
|
|
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
|
|
searchParams.get('reminders') === '1' && pathname === '/'
|
|
? 'bg-foreground text-background border-foreground'
|
|
: 'bg-white/60 text-foreground border-border'
|
|
)}>
|
|
<Bell size={14} />
|
|
</div>
|
|
<span className={cn(
|
|
'text-[13px] font-medium truncate',
|
|
searchParams.get('reminders') === '1' && pathname === '/' ? 'text-foreground' : 'text-muted-foreground'
|
|
)}>
|
|
{t('sidebar.reminders')}
|
|
</span>
|
|
</button>
|
|
|
|
{/* Divider */}
|
|
<div className="mx-4 my-3 h-px bg-border/40" />
|
|
|
|
{/* Notebooks list — draggable */}
|
|
<div
|
|
className="space-y-1"
|
|
onDrop={handleDrop}
|
|
onDragOver={(e) => e.preventDefault()}
|
|
>
|
|
{rootNotebooks.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 isChildActive = children.some(c => currentNotebookId === c.id)
|
|
const isExpanded = isActive || isChildActive
|
|
return (
|
|
<motion.div
|
|
key={notebook.id}
|
|
layout
|
|
transition={{
|
|
type: 'spring',
|
|
stiffness: 300,
|
|
damping: 30,
|
|
mass: 0.8
|
|
}}
|
|
>
|
|
<div
|
|
draggable
|
|
onDragStart={(e) => handleDragStart(e, notebook.id)}
|
|
onDragOver={(e) => handleDragOver(e, notebook.id)}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SidebarCarnetItem
|
|
carnet={{
|
|
id: notebook.id,
|
|
name: notebook.name,
|
|
initial: notebook.name.charAt(0).toUpperCase(),
|
|
}}
|
|
isActive={isExpanded}
|
|
notes={notes}
|
|
activeNoteId={currentNoteId}
|
|
onCarnetClick={() => handleCarnetClick(notebook.id)}
|
|
onNoteClick={handleNoteClick}
|
|
isDragging={isDragging}
|
|
>
|
|
{children.length > 0 && (
|
|
<div className="pl-4 space-y-1 mt-1">
|
|
{children.map(child => {
|
|
const childActive = currentNotebookId === child.id
|
|
const childNotes = notebookNotes[child.id] || []
|
|
return (
|
|
<div key={child.id}>
|
|
<SidebarCarnetItem
|
|
carnet={{
|
|
id: child.id,
|
|
name: child.name,
|
|
initial: child.name.charAt(0).toUpperCase(),
|
|
}}
|
|
isActive={childActive}
|
|
notes={childNotes}
|
|
activeNoteId={currentNoteId}
|
|
onCarnetClick={() => handleCarnetClick(child.id)}
|
|
onNoteClick={handleNoteClick}
|
|
/>
|
|
</div>
|
|
)
|
|
})}
|
|
<button
|
|
onClick={() => {
|
|
setCreateParentId(notebook.id)
|
|
setIsCreateDialogOpen(true)
|
|
}}
|
|
className="w-full flex items-center gap-2 pl-12 pr-4 py-2 text-[11px] text-muted-foreground/50 hover:text-muted-foreground transition-colors"
|
|
>
|
|
<Plus size={12} />
|
|
<span>{t('notebook.createSubNotebook') || 'Sous-carnet'}</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</SidebarCarnetItem>
|
|
</div>
|
|
</motion.div>
|
|
)
|
|
})}
|
|
|
|
<button
|
|
onClick={() => {
|
|
setCreateParentId(null)
|
|
setIsCreateDialogOpen(true)
|
|
}}
|
|
className="w-full mt-4 flex items-center gap-3 px-4 py-2 text-[13px] text-muted-foreground hover:text-foreground transition-colors font-medium rounded-lg hover:bg-white/40"
|
|
>
|
|
<Plus size={16} />
|
|
<span>{t('notebook.create')}</span>
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
) : (
|
|
<motion.div
|
|
key="agents"
|
|
initial={{ opacity: 0, x: 10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: -10 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<p className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-4 px-4">
|
|
{t('agents.intelligenceOS')}
|
|
</p>
|
|
<div className="space-y-1">
|
|
{[
|
|
{ id: 'agents', href: '/agents', label: t('agents.myAgents'), icon: Bot },
|
|
{ id: 'lab', href: '/lab', label: t('nav.lab'), icon: FlaskConical },
|
|
].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-white/40 hover:text-foreground'
|
|
)}
|
|
>
|
|
<div className={cn(
|
|
'w-8 h-8 rounded-full flex items-center justify-center border transition-colors shrink-0',
|
|
isActive
|
|
? 'bg-foreground text-background border-foreground'
|
|
: 'bg-white/60 border-border group-hover:border-foreground/20'
|
|
)}>
|
|
<item.icon size={16} />
|
|
</div>
|
|
<span className="text-[13px] font-medium">{item.label}</span>
|
|
</Link>
|
|
)
|
|
})}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
{/* ── Footer ── */}
|
|
<div className="pt-4 p-5 border-t border-border space-y-1">
|
|
<Link
|
|
href="/archive"
|
|
className="flex items-center gap-3 px-4 py-2 text-[13px] text-[#1C1C1C]/60 hover:text-[#1C1C1C] transition-colors font-medium rounded-lg hover:bg-white/30"
|
|
>
|
|
<Archive size={16} />
|
|
<span>{t('sidebar.archive')}</span>
|
|
</Link>
|
|
<Link
|
|
href="/trash"
|
|
className="flex items-center gap-3 px-4 py-2 text-[13px] text-[#1C1C1C]/60 hover:text-[#1C1C1C] transition-colors font-medium rounded-lg hover:bg-white/30"
|
|
>
|
|
<Trash2 size={16} />
|
|
<span>{t('sidebar.trash')}</span>
|
|
</Link>
|
|
<Link
|
|
href="/settings"
|
|
className="flex items-center gap-3 px-4 py-2 text-[13px] text-[#1C1C1C]/60 hover:text-[#1C1C1C] transition-colors font-medium rounded-lg hover:bg-white/30"
|
|
>
|
|
<Settings size={16} />
|
|
<span>{t('nav.settings')}</span>
|
|
</Link>
|
|
</div>
|
|
</aside>
|
|
|
|
<CreateNotebookDialog
|
|
open={isCreateDialogOpen}
|
|
onOpenChange={setIsCreateDialogOpen}
|
|
parentNotebookId={createParentId}
|
|
/>
|
|
</>
|
|
)
|
|
}
|