Files
Momento/memento-note/components/sidebar.tsx

462 lines
18 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,
} from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { useNoteRefreshOptional } from '@/context/NoteRefreshContext'
import { useEffect, 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'
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',
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="truncate">{title}</span>
</motion.button>
)
}
function SidebarCarnetItem({
carnet,
isActive,
notes,
activeNoteId,
onCarnetClick,
onNoteClick,
}: {
carnet: { id: string; name: string; initial: string; isPrivate?: boolean }
isActive: boolean
notes: { id: string; title: string }[]
activeNoteId: string | null
onCarnetClick: () => void
onNoteClick: (noteId: string) => void
}) {
return (
<div className="space-y-1">
<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>
<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"
>
{notes.map(note => (
<NoteLink
key={note.id}
title={note.title}
isActive={activeNoteId === note.id}
onClick={() => onNoteClick(note.id)}
/>
))}
{notes.length === 0 && (
<p className="pl-12 text-[11px] text-muted-foreground/50 py-2 italic font-light">No notes yet</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 { refreshKey } = useNoteRefreshOptional()
const { notebooks } = useNotebooks()
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
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 currentNotebookId = searchParams.get('notebook')
const currentNoteId = searchParams.get('openNote')
// Determine if inbox is active (no notebook filter, on home page)
const isInboxActive =
pathname === '/' &&
!searchParams.get('notebook') &&
!searchParams.get('label') &&
!searchParams.get('archived') &&
!searchParams.get('trashed')
// Sync activeView with current route
useEffect(() => {
if (pathname.startsWith('/agents') || pathname.startsWith('/lab')) {
setActiveView('agents')
}
}, [pathname])
const displayName = user?.name || user?.email || ''
const initial = displayName ? displayName.charAt(0).toUpperCase() : '?'
useEffect(() => {
if (!currentNotebookId) return
if (notebookNotes[currentNotebookId]) return
getAllNotes(false, currentNotebookId).then(notes => {
const mapped = notes.map((n: Note) => ({
id: n.id,
title: getNoteDisplayTitle(n, t('notes.untitled') || 'Untitled'),
}))
setNotebookNotes(prev => ({ ...prev, [currentNotebookId!]: mapped }))
})
}, [currentNotebookId, refreshKey])
// BUG FIX: clicking a carnet always forces list (editorial) view
const handleCarnetClick = (notebookId: string) => {
const params = new URLSearchParams()
params.set('notebook', notebookId)
// forceList resets to editorial view in home-client
params.set('forceList', '1')
router.push(`/?${params.toString()}`)
}
const handleInboxClick = () => {
router.push('/?forceList=1')
}
const handleNoteClick = (noteId: string) => {
const params = new URLSearchParams(searchParams.toString())
params.set('openNote', noteId)
params.delete('forceList')
router.push(`/?${params.toString()}`)
}
// Sort notebooks
const sortedNotebooks = [...notebooks].sort((a: Notebook, b: Notebook) => {
if (sortOrder === 'alpha') return a.name.localeCompare(b.name)
if (sortOrder === 'newest') return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
if (sortOrder === 'oldest') return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
return 0
})
const sortLabels: Record<SortOrder, string> = {
newest: t('sidebar.sortNewest') || 'Newest first',
oldest: t('sidebar.sortOldest') || 'Oldest first',
alpha: t('sidebar.sortAlpha') || 'A → Z',
}
return (
<>
<aside
className={cn(
'hidden h-full min-h-0 w-80 shrink-0 flex-col md:flex',
'border-e border-border/40 bg-white/30 backdrop-blur-md sidebar-shadow dark:border-border/30 dark:bg-sidebar/90',
className
)}
>
{/* ── Top: Avatar + View Toggle ── */}
<div className="p-6 flex items-center justify-between mb-4">
{/* Avatar → profile */}
<Link href="/settings/profile" className="shrink-0">
<div className="w-10 h-10 rounded-full bg-slate-200 border border-border flex items-center justify-center text-foreground font-memento-serif text-lg shadow-sm hover:ring-2 hover:ring-primary/30 transition-all">
{user?.image ? (
<Avatar className="size-10 ring-1 ring-border/60">
<AvatarImage src={user.image} alt="" />
<AvatarFallback className="bg-primary/10 text-sm font-semibold text-primary">{initial}</AvatarFallback>
</Avatar>
) : (
<span>{initial}</span>
)}
</div>
</Link>
{/* Notebooks / Agents toggle */}
<div className="sidebar-view-toggle">
<button
onClick={() => { setActiveView('notebooks'); if (pathname !== '/') router.push('/') }}
className={cn('sidebar-view-toggle-btn', activeView === 'notebooks' && 'active')}
title={t('nav.notebooks') || 'Notebooks'}
>
<BookOpen size={14} />
</button>
<button
onClick={() => { setActiveView('agents'); router.push('/agents') }}
className={cn('sidebar-view-toggle-btn', activeView === 'agents' && 'active')}
title={t('nav.agents') || 'Agents'}
>
<Bot size={14} />
</button>
</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') || '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') || 'Sort order'}
>
<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') || 'Inbox'}
</span>
</button>
{/* Divider */}
<div className="mx-4 my-3 h-px bg-border/40" />
{/* Notebooks list */}
<div className="space-y-1">
{sortedNotebooks.map((notebook: Notebook) => {
const isActive = currentNotebookId === notebook.id
const notes = notebookNotes[notebook.id] || []
return (
<SidebarCarnetItem
key={notebook.id}
carnet={{
id: notebook.id,
name: notebook.name,
initial: notebook.name.charAt(0).toUpperCase(),
}}
isActive={isActive}
notes={isActive ? notes : []}
activeNoteId={currentNoteId}
onCarnetClick={() => handleCarnetClick(notebook.id)}
onNoteClick={handleNoteClick}
/>
)
})}
<button
onClick={() => 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('notebooks.create') || 'New Carnet'}</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') || 'Intelligence OS'}
</p>
<div className="space-y-1">
{[
{ id: 'agents', href: '/agents', label: t('agents.myAgents') || 'Mes Agents', icon: Bot },
{ id: 'lab', href: '/lab', label: t('nav.lab') || 'Le Lab AI', icon: FlaskConical },
{ id: 'chat', href: '/chat', label: t('nav.chat') || 'Conversations', icon: MessageSquare },
].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>
)
})}
{/* General Chat button (opens floating panel) */}
<button
onClick={() => window.dispatchEvent(new Event('toggle-ai-chat'))}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group text-muted-foreground hover:bg-white/40 hover:text-foreground"
>
<div className="w-8 h-8 rounded-full flex items-center justify-center border transition-colors shrink-0 bg-white/60 border-border group-hover:border-foreground/20">
<Sparkles size={16} />
</div>
<span className="text-[13px] font-medium">{t('ai.openAssistant') || 'Assistant IA'}</span>
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* ── Footer ── */}
<div className="pt-4 p-5 border-t border-border space-y-1">
{/* Notification bell */}
<div className="flex items-center gap-3 px-4 py-2">
<NotificationPanel />
<span className="text-[13px] text-muted-foreground font-medium">{t('notification.notifications') || 'Notifications'}</span>
</div>
<Link
href="/archive"
className="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/30"
>
<Archive size={16} />
<span>{t('sidebar.archive') || 'Archive'}</span>
</Link>
<Link
href="/settings"
className="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/30"
>
<Settings size={16} />
<span>{t('nav.settings') || 'Settings'}</span>
</Link>
</div>
</aside>
<CreateNotebookDialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
/>
</>
)
}