Ajoute la base organisable par carnet (schéma, champs partagés, valeurs par note) avec activation guidée, tableau éditable, kanban et suppression de colonnes. Corrige le multiselect en vue tableau et enrichit sidebar, grille et i18n FR/EN. Inclut aussi les améliorations flashcards SM-2, l'audit consentement IA et la robustesse du serveur MCP (config, validation, rate-limit, métriques). Co-authored-by: Cursor <cursoragent@cursor.com>
1438 lines
60 KiB
TypeScript
1438 lines
60 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,
|
|
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 (
|
|
<motion.button
|
|
initial={{ opacity: 0, x: slideX }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
onClick={onClick}
|
|
className={cn(
|
|
'w-full flex items-center gap-2 ps-6 pe-3 py-1.5 text-[11px] transition-all rounded-lg text-start',
|
|
isActive
|
|
? 'bg-white dark:bg-white/10 shadow-sm border border-border/50 text-foreground font-semibold'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-white/30 dark:hover:bg-white/5',
|
|
)}
|
|
>
|
|
<FileText
|
|
size={12}
|
|
className={cn('shrink-0', isActive ? 'text-brand-accent' : 'text-muted-foreground/70')}
|
|
/>
|
|
<span className="truncate flex-1">{title}</span>
|
|
{isPinned && <Pin size={10} className="text-amber-500 fill-amber-500 shrink-0" />}
|
|
</motion.button>
|
|
)
|
|
}
|
|
|
|
function SidebarBrainstorms() {
|
|
const { data: sessions, isLoading } = useBrainstormSessions()
|
|
const deleteBrainstorm = useDeleteBrainstorm()
|
|
const router = useRouter()
|
|
const { t } = useLanguage()
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="px-4 space-y-2">
|
|
{[1, 2, 3].map(i => (
|
|
<div key={i} className="h-12 rounded-xl bg-paper/50 animate-pulse" />
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!sessions || sessions.length === 0) {
|
|
return (
|
|
<div className="px-4 py-6 text-center">
|
|
<Sparkles size={20} className="mx-auto text-brand-accent/40 mb-2" />
|
|
<p className="text-[11px] text-concrete">{t('brainstorm.noSessions')}</p>
|
|
<button
|
|
onClick={() => router.push('/brainstorm')}
|
|
className="mt-2 text-[11px] text-brand-accent hover:text-brand-accent/80 font-medium"
|
|
>
|
|
{t('brainstorm.startOne')} →
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-0.5">
|
|
{sessions.slice(0, 10).map(s => (
|
|
<div key={s.id} className="relative group/item">
|
|
<button
|
|
onClick={() => 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' : ''
|
|
}`}
|
|
>
|
|
<div className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 ${
|
|
(s as any)._owned === false
|
|
? 'border border-brand-accent/20 dark:border-brand-accent/80 bg-brand-accent/5 dark:bg-brand-accent/20'
|
|
: 'border border-brand-accent/20 dark:border-brand-accent/40 bg-brand-accent/5 dark:bg-brand-accent/20'
|
|
}`}>
|
|
<Sparkles size={12} className="text-brand-accent" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-[12px] font-medium truncate">
|
|
{s.seedIdea}
|
|
{(s as any)._owned === false && (
|
|
<span className="text-[9px] ms-1.5 text-brand-accent/70 font-normal">{t('sidebar.sharedNotebookBadge')}</span>
|
|
)}
|
|
</p>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{s.activeIdeas} {t('brainstorm.ideas')} · {new Date(s.createdAt).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
{(s as any)._owned !== false && (
|
|
<button
|
|
onClick={(e) => {
|
|
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"
|
|
>
|
|
<Trash2 size={13} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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<HTMLDivElement>
|
|
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 (
|
|
<div className={cn('transition-opacity', isDragging && 'opacity-40')}>
|
|
<div
|
|
className="flex items-center group relative h-10"
|
|
style={{ paddingInlineStart: `${level * 24 + 8}px` }}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
setContextMenu({ x: e.clientX, y: e.clientY })
|
|
}}
|
|
>
|
|
{level > 0 && (
|
|
<div className="absolute start-[8px] top-[-10px] bottom-1/2 w-px bg-border/40" />
|
|
)}
|
|
{level > 0 && (
|
|
<div className="absolute start-[8px] top-1/2 w-[8px] h-px bg-border/40" />
|
|
)}
|
|
|
|
<div
|
|
{...dragHandleProps}
|
|
className="absolute start-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:opacity-100 transition-opacity z-10"
|
|
>
|
|
<GripVertical size={12} />
|
|
</div>
|
|
|
|
<div className="flex-1 flex items-center gap-1">
|
|
{hasChildren ? (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); toggleExpand() }}
|
|
className="p-1 hover:bg-foreground/5 rounded-md transition-colors text-muted-foreground"
|
|
>
|
|
<motion.div animate={{ rotate: isExpanded ? 90 : 0 }} transition={{ duration: 0.2 }}>
|
|
<ChevronRight size={14} className="rtl:scale-x-[-1]" />
|
|
</motion.div>
|
|
</button>
|
|
) : (
|
|
<div className="w-6" />
|
|
)}
|
|
|
|
<motion.div
|
|
whileHover={{ x: isRtl ? -2 : 2 }}
|
|
onClick={onCarnetClick}
|
|
onDoubleClick={(e) => { 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 && (
|
|
<motion.div
|
|
layoutId="active-indicator"
|
|
className="absolute -start-1 w-1 h-4 bg-brand-accent rounded-full"
|
|
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
|
/>
|
|
)}
|
|
<div className={cn(
|
|
'w-5 h-5 flex items-center justify-center shrink-0 transition-colors',
|
|
isActive ? 'text-brand-accent' : 'text-muted-foreground/80',
|
|
)}>
|
|
{isExpanded ? <FolderOpen size={13} /> : <Folder size={13} />}
|
|
</div>
|
|
|
|
<div className="flex-1 text-start flex items-center gap-2 min-w-0">
|
|
<span className={cn(
|
|
'text-[12px] font-medium transition-colors truncate',
|
|
isActive ? 'text-ink' : 'text-muted-ink group-hover/item:text-ink'
|
|
)}>
|
|
{carnet.name}
|
|
</span>
|
|
{carnet.isPrivate && <Lock size={10} className="text-concrete/60 shrink-0" />}
|
|
{!isActive && hasActiveDescendant && (
|
|
<span className="w-1.5 h-1.5 rounded-full bg-brand-accent/70 shrink-0" />
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 opacity-0 group-hover/item:opacity-100 transition-opacity shrink-0">
|
|
{isPinned && (
|
|
<span className="text-brand-accent" title={t('notebook.pinnedFrozenTooltip')}>
|
|
<Pin size={9} className="opacity-70" />
|
|
</span>
|
|
)}
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onAddSubNotebook() }}
|
|
className="p-1 hover:bg-ink/10 rounded-md transition-all text-concrete hover:text-ink"
|
|
title={t('notebook.createSubNotebook')}
|
|
>
|
|
<Plus size={10} />
|
|
</button>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onRename() }}
|
|
className="p-1 hover:bg-ink/10 rounded-md transition-all text-concrete hover:text-ink"
|
|
title={t('notebook.rename')}
|
|
>
|
|
<Pencil size={10} />
|
|
</button>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onDelete() }}
|
|
className="p-1 hover:bg-rose-50 rounded-md transition-all text-concrete hover:text-rose-500"
|
|
title={t('notebook.delete')}
|
|
>
|
|
<Trash2 size={10} />
|
|
</button>
|
|
|
|
{notes.length > 0 && (
|
|
<span className="text-[9px] font-bold text-concrete/40 px-1.5 border border-border/40 rounded-full group-hover/item:text-concrete transition-colors">
|
|
{notes.length}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right-click context menu */}
|
|
<AnimatePresence>
|
|
{contextMenu && (
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.95 }}
|
|
transition={{ duration: 0.1 }}
|
|
style={{ position: 'fixed', top: contextMenu.y, left: contextMenu.x, zIndex: 9999 }}
|
|
className="bg-card border border-border rounded-xl shadow-xl py-1.5 min-w-[180px] overflow-hidden"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<button
|
|
onClick={() => { 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
|
|
? <><PinOff size={13} className="text-brand-accent" /><span>{t('sidebar.unfreezePinnedNotebook')}</span></>
|
|
: <><Pin size={13} className="text-brand-accent" /><span>{t('sidebar.freezePinnedNotebook')}</span></>
|
|
}
|
|
</button>
|
|
<div className="mx-3 my-1 border-t border-border/50" />
|
|
<button
|
|
onClick={() => { 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"
|
|
>
|
|
<Plus size={13} className="text-concrete" />
|
|
<span>{t('sidebar.newSubNotebook')}</span>
|
|
</button>
|
|
<button
|
|
onClick={() => { 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"
|
|
>
|
|
<Pencil size={13} className="text-concrete" />
|
|
<span>{t('sidebar.renameNotebook')}</span>
|
|
</button>
|
|
<div className="mx-3 my-1 border-t border-border/50" />
|
|
<button
|
|
onClick={() => { 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"
|
|
>
|
|
<Trash2 size={13} />
|
|
<span>{t('common.delete')}</span>
|
|
</button>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<AnimatePresence initial={false}>
|
|
{isExpanded && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
|
className="overflow-hidden"
|
|
>
|
|
<div className="relative" style={{ marginInlineStart: `${(level + 1) * 16 + 10}px` }}>
|
|
<div className="absolute start-[-6px] top-0 bottom-4 w-px bg-border/30" />
|
|
|
|
<div className="space-y-0.5 py-1">
|
|
{children}
|
|
{isExpanded && notes.map(note => (
|
|
<NoteLink
|
|
key={note.id}
|
|
title={note.title}
|
|
isPinned={note.isPinned}
|
|
isActive={activeNoteId === note.id}
|
|
onClick={() => onNoteClick(note.id, carnet.id)}
|
|
/>
|
|
))}
|
|
{isExpanded && notes.length === 0 && !hasChildren && (
|
|
<p className="ps-6 py-1 text-[9px] italic text-muted-foreground/40 font-light">
|
|
{t('sidebar.notebookEmpty')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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<string | null>(null)
|
|
const [renamingNotebook, setRenamingNotebook] = useState<Notebook | null>(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<Notebook | null>(null)
|
|
const [isDeleting, setIsDeleting] = useState(false)
|
|
const [isRenaming, setIsRenaming] = useState(false)
|
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
|
const [pinnedIds, setPinnedIds] = useState<Set<string>>(new Set())
|
|
const [notebookNotes, setNotebookNotes] = useState<Record<string, { id: string; title: string; isPinned?: boolean }[]>>({})
|
|
const [activeView, setActiveView] = useState<NavigationView>('notebooks')
|
|
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
|
|
const [showSortMenu, setShowSortMenu] = useState(false)
|
|
const [notebookSearchQuery, setNotebookSearchQuery] = useState('')
|
|
const [trashCount, setTrashCount] = useState(0)
|
|
|
|
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 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<NoteChangeEvent>).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<string, { id: string; title: string; isPinned?: boolean }[]> = {}
|
|
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<string | null>(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<SortOrder, string> = {
|
|
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 (
|
|
<motion.div key={notebook.id}>
|
|
<div
|
|
draggable
|
|
onDragStart={(e) => {
|
|
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'
|
|
)}
|
|
>
|
|
<SidebarCarnetItem
|
|
carnet={{
|
|
id: notebook.id,
|
|
name: notebook.name,
|
|
initial: notebook.name.charAt(0).toUpperCase(),
|
|
isPrivate: notebook.isPrivate,
|
|
}}
|
|
isActive={isActive}
|
|
notes={notes}
|
|
activeNoteId={currentNoteId}
|
|
onCarnetClick={() => {
|
|
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}
|
|
/>
|
|
</div>
|
|
{isExpanded && renderCarnetTree(notebook.id, level + 1)}
|
|
</motion.div>
|
|
)
|
|
})
|
|
}, [rootNotebooks, childNotebooks, filteredNotebookIds, currentNotebookId, currentNoteId, notebookNotes, draggedId, dropTarget, dropAction, expandedIds, pinnedIds, toggleExpand, handleCarnetClick, handleNoteClick, handleDragStart, handleDragEnd, handleDropOnNotebook, handleStartRename])
|
|
|
|
return (
|
|
<>
|
|
{/* Overlay mobile */}
|
|
<AnimatePresence>
|
|
{isMobileOpen && (
|
|
<motion.div
|
|
key="sidebar-backdrop"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[60] md:hidden"
|
|
onClick={() => setIsMobileOpen(false)}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<aside
|
|
className={cn(
|
|
// Mobile: fixed overlay, slide in/out
|
|
'fixed inset-y-0 start-0 z-[70] md:relative md:z-auto',
|
|
'h-full min-h-0 w-72 lg:w-80 shrink-0 flex flex-row overflow-hidden',
|
|
'transition-transform duration-300 ease-in-out',
|
|
isMobileOpen ? 'translate-x-0 shadow-2xl' : '-translate-x-full md:translate-x-0',
|
|
'border-e border-border/40 bg-white/95 md:bg-white/30 backdrop-blur-md sidebar-shadow dark:border-white/6 dark:bg-[#151515] dark:backdrop-blur-none',
|
|
className
|
|
)}
|
|
>
|
|
{/* ── Column 1 : Rail d'icônes (54px) — inspiré du prototype ── */}
|
|
<div className="w-[54px] border-e border-border/40 bg-[#FAF9F5] dark:bg-[#0E0E0E] flex flex-col items-center justify-between py-4 shrink-0 select-none">
|
|
|
|
{/* Top : Logo + navigation */}
|
|
<div className="flex flex-col items-center gap-3 w-full">
|
|
{/* Logo avec dropdown profil */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<div className="w-9 h-9 bg-brand-accent hover:rotate-6 active:scale-95 flex items-center justify-center rounded-xl shadow-md transition-all cursor-pointer mb-1">
|
|
<span className="text-white font-serif font-bold text-sm">M</span>
|
|
</div>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="w-52 bg-popover border-border ms-2">
|
|
<DropdownMenuItem asChild>
|
|
<Link href="/settings/profile" className="flex items-center gap-2 cursor-pointer">
|
|
<User className="h-4 w-4" />
|
|
{t('sidebar.profile')}
|
|
</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')}
|
|
</a>
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
className="text-destructive focus:text-destructive"
|
|
onClick={() => performSignOut('/login')}
|
|
>
|
|
<LogOut className="h-4 w-4 me-2" />
|
|
{t('sidebar.signOut')}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* Boutons de navigation principaux */}
|
|
<div className="flex flex-col gap-1.5 w-full px-1.5">
|
|
{([
|
|
{ id: 'notebooks', icon: BookOpen, label: t('nav.notebooks'), onClick: () => { setActiveView('notebooks'); if (pathname !== '/home') router.push('/home') }, isActive: activeView === 'notebooks' && !pathname.startsWith('/settings') },
|
|
{ id: 'insights', icon: Sparkles, label: t('nav.insights'), onClick: () => router.push('/insights'), isActive: pathname === '/insights' },
|
|
{ id: 'revision', icon: GraduationCap, label: t('nav.revision'), onClick: () => router.push('/revision'), isActive: pathname === '/revision' },
|
|
{ id: 'agents', icon: Bot, label: t('agents.intelligenceOS') || 'Intelligence IA', onClick: () => { setActiveView('agents'); router.push('/agents') }, isActive: activeView === 'agents' || (pathname.startsWith('/agents') && activeView !== 'notebooks') },
|
|
{ id: 'reminders', icon: Bell, label: t('sidebar.reminders'), onClick: () => setActiveView('reminders'), isActive: activeView === 'reminders' },
|
|
] as { id: string; icon: React.FC<{ size?: number }>; label: string; onClick: () => void; isActive: boolean }[]).map(item => (
|
|
<button
|
|
key={item.id}
|
|
onClick={item.onClick}
|
|
className={cn(
|
|
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
|
|
item.isActive
|
|
? 'bg-brand-accent/10 text-brand-accent border border-brand-accent/25'
|
|
: 'text-concrete hover:text-ink dark:hover:text-white hover:bg-black/[0.04] dark:hover:bg-white/[0.04]'
|
|
)}
|
|
>
|
|
{item.isActive && (
|
|
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 bg-brand-accent rounded-r-full" />
|
|
)}
|
|
<item.icon size={16} />
|
|
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
|
{item.label}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom : utilitaires */}
|
|
<div className="flex flex-col gap-1.5 w-full px-1.5 items-center">
|
|
<NotificationPanel />
|
|
|
|
<Link
|
|
href="/trash"
|
|
className={cn(
|
|
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
|
|
pathname === '/trash'
|
|
? 'bg-rose-500/10 text-rose-500 border border-rose-500/25'
|
|
: 'text-concrete hover:text-rose-500 hover:bg-rose-500/5'
|
|
)}
|
|
>
|
|
{pathname === '/trash' && <div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 bg-rose-500 rounded-r-full" />}
|
|
<Trash2 size={16} />
|
|
{trashCount > 0 && (
|
|
<span className="absolute top-1.5 right-1.5 w-1.5 h-1.5 bg-rose-500 rounded-full border-2 border-[#FAF9F5] dark:border-[#0E0E0E]" />
|
|
)}
|
|
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
|
{t('sidebar.trash')}
|
|
</span>
|
|
</Link>
|
|
|
|
<Link
|
|
href="/home?shared=1&forceList=1"
|
|
className={cn(
|
|
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
|
|
searchParams.get('shared') === '1' && pathname === '/home'
|
|
? 'bg-sky-500/10 text-sky-500 border border-sky-500/25'
|
|
: 'text-concrete hover:text-sky-500 hover:bg-sky-500/5'
|
|
)}
|
|
>
|
|
<Users size={16} />
|
|
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
|
{t('sidebar.sharedWithMe')}
|
|
</span>
|
|
</Link>
|
|
|
|
<button
|
|
onClick={openSearch}
|
|
className="w-9 h-9 rounded-lg flex items-center justify-center text-concrete hover:text-ink dark:hover:text-white hover:bg-black/[0.04] dark:hover:bg-white/[0.04] transition-all relative group"
|
|
title="Ctrl+K"
|
|
>
|
|
<Search size={15} />
|
|
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
|
Recherche (Ctrl+K)
|
|
</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={toggleTheme}
|
|
className="w-9 h-9 rounded-lg flex items-center justify-center text-concrete hover:text-ink dark:hover:text-white hover:bg-black/[0.04] dark:hover:bg-white/[0.04] transition-all relative group"
|
|
>
|
|
{isDark ? <Sun size={15} /> : <Moon size={15} />}
|
|
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
|
{isDark ? 'Mode clair' : 'Mode sombre'}
|
|
</span>
|
|
</button>
|
|
|
|
<Link
|
|
href="/settings"
|
|
className={cn(
|
|
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
|
|
pathname.startsWith('/settings')
|
|
? 'bg-brand-accent/10 text-brand-accent border border-brand-accent/25'
|
|
: 'text-concrete hover:text-ink dark:hover:text-white hover:bg-black/[0.04] dark:hover:bg-white/[0.04]'
|
|
)}
|
|
>
|
|
{pathname.startsWith('/settings') && <div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 bg-brand-accent rounded-r-full" />}
|
|
<Settings size={15} />
|
|
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
|
{t('nav.settings')}
|
|
</span>
|
|
</Link>
|
|
|
|
<button
|
|
onClick={() => 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"
|
|
>
|
|
<LogOut size={14} />
|
|
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
|
{t('sidebar.signOut')}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Column 2 : Panneau de contenu dynamique ── */}
|
|
<div className="flex-1 h-full flex flex-col overflow-hidden bg-[#FCFCFA] dark:bg-[#111111]">
|
|
|
|
{/* ── Scrollable content ── */}
|
|
<div className="flex-1 overflow-y-auto space-y-6 -mx-0 custom-scrollbar pb-4">
|
|
|
|
<AnimatePresence mode="wait">
|
|
{activeView === 'notebooks' ? (
|
|
<motion.div
|
|
key="notebooks"
|
|
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="flex flex-col flex-1 min-h-0 overflow-hidden"
|
|
>
|
|
<div className="px-4 pt-4 shrink-0">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-1.5">
|
|
<BookMarked size={14} className="text-brand-accent" />
|
|
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
|
|
{t('sidebar.documents')}
|
|
</h3>
|
|
</div>
|
|
<div className="flex items-center gap-0.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setCreateParentId(null)
|
|
setIsCreateDialogOpen(true)
|
|
}}
|
|
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded transition-all text-concrete hover:text-ink"
|
|
title={t('notebook.create')}
|
|
>
|
|
<Plus size={15} />
|
|
</button>
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowSortMenu((s) => !s)}
|
|
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded transition-all text-concrete hover:text-ink"
|
|
title={t('sidebar.sortOrder')}
|
|
>
|
|
<ArrowUpDown size={13} />
|
|
</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 end-0 top-full mt-1 bg-card border border-border rounded-xl shadow-lg z-50 py-1 min-w-[140px]"
|
|
>
|
|
{(['newest', 'oldest', 'alpha', 'manual'] as SortOrder[]).map((order) => (
|
|
<button
|
|
key={order}
|
|
type="button"
|
|
onClick={() => {
|
|
setSortOrder(order)
|
|
setShowSortMenu(false)
|
|
}}
|
|
className={cn(
|
|
'w-full text-start 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>
|
|
</div>
|
|
|
|
<div className="relative mb-4">
|
|
<input
|
|
type="text"
|
|
value={notebookSearchQuery}
|
|
onChange={(e) => setNotebookSearchQuery(e.target.value)}
|
|
placeholder={t('sidebar.searchNotebooksPlaceholder')}
|
|
className="w-full text-[11px] ps-7 pe-8 py-1.5 rounded-lg border border-border/60 bg-white/70 dark:bg-zinc-800 placeholder-concrete/50 outline-none focus:border-brand-accent transition-colors text-ink dark:text-dark-ink"
|
|
/>
|
|
<Search
|
|
size={11}
|
|
className="absolute start-2.5 top-1/2 -translate-y-1/2 text-concrete opacity-60 pointer-events-none"
|
|
/>
|
|
{notebookSearchQuery && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setNotebookSearchQuery('')}
|
|
className="absolute end-2.5 top-1/2 -translate-y-1/2 text-[9px] uppercase font-bold text-concrete hover:text-ink"
|
|
aria-label={t('sidebar.clearSearch')}
|
|
>
|
|
X
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto custom-scrollbar min-h-0 px-4 pb-4">
|
|
<button
|
|
type="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-brand-accent text-white border-brand-accent'
|
|
: 'bg-paper dark:bg-white/5 text-muted-ink border-border group-hover:border-brand-accent/20',
|
|
)}
|
|
>
|
|
<Inbox size={14} />
|
|
</div>
|
|
<span
|
|
className={cn(
|
|
'text-[13px] font-medium truncate',
|
|
isInboxActive ? 'text-ink' : 'text-muted-ink',
|
|
)}
|
|
>
|
|
{t('sidebar.inbox')}
|
|
</span>
|
|
</button>
|
|
|
|
<div className="my-3 h-px bg-border/40" />
|
|
|
|
<div
|
|
className="space-y-0.5 min-h-[60px]"
|
|
onDrop={handleDropToRoot}
|
|
onDragOver={(e) => e.preventDefault()}
|
|
>
|
|
{renderCarnetTree(undefined, 0)}
|
|
{draggedId && (
|
|
<div className="h-10 rounded-lg border-2 border-dashed border-brand-accent/20 flex items-center justify-center text-[11px] text-brand-accent/50">
|
|
{t('sidebar.dropToRoot')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
) : activeView === 'reminders' ? (
|
|
<motion.div
|
|
key="reminders"
|
|
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<p className="text-[10px] font-bold text-muted-ink tracking-[0.2em] uppercase mb-4 px-4">
|
|
{t('sidebar.reminders')}
|
|
</p>
|
|
<div className="px-4 py-8 text-center border border-dashed border-border rounded-2xl bg-paper/30">
|
|
<Clock size={24} className="mx-auto text-concrete/40 mb-3" />
|
|
<p className="text-[11px] text-concrete italic">{t('sidebar.noReminders')}</p>
|
|
</div>
|
|
</motion.div>
|
|
) : activeView === 'agents' ? (
|
|
<motion.div
|
|
key="agents"
|
|
initial={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: isRtl ? 10 : -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 },
|
|
{ id: 'brainstorm', href: '/brainstorm', label: t('brainstorm.sessions'), icon: Sparkles },
|
|
].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-foreground/5 hover:text-foreground'
|
|
)}
|
|
>
|
|
<div className={cn(
|
|
'w-8 h-8 rounded-full flex items-center justify-center border transition-colors shrink-0',
|
|
isActive
|
|
? 'bg-brand-accent text-white border-brand-accent'
|
|
: 'bg-paper border-border group-hover:border-brand-accent/20'
|
|
)}>
|
|
<item.icon size={16} />
|
|
</div>
|
|
<span className="text-[13px] font-medium">{item.label}</span>
|
|
</Link>
|
|
)
|
|
})}
|
|
</div>
|
|
</motion.div>
|
|
) : (
|
|
<motion.div
|
|
key="brainstorms"
|
|
initial={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: isRtl ? 10 : -10 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<div className="flex items-center justify-between px-4 mb-3">
|
|
<p className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase">
|
|
{t('brainstorm.sessions')}
|
|
</p>
|
|
<button
|
|
onClick={() => router.push('/brainstorm')}
|
|
className="p-1 text-muted-foreground hover:text-brand-accent transition-colors rounded"
|
|
title={t('brainstorm.newBrainstorm')}
|
|
>
|
|
<Plus size={12} />
|
|
</button>
|
|
</div>
|
|
<SidebarBrainstorms />
|
|
</motion.div>
|
|
)}
|
|
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
{/* ── Usage meter en bas du panneau ── */}
|
|
<div className="border-t border-border/20 px-3 py-3 mt-auto">
|
|
<UsageMeter />
|
|
</div>
|
|
|
|
</div>{/* fin colonne 2 */}
|
|
</aside>
|
|
|
|
<CreateNotebookDialog
|
|
open={isCreateDialogOpen}
|
|
onOpenChange={setIsCreateDialogOpen}
|
|
parentNotebookId={createParentId}
|
|
/>
|
|
|
|
{/* Rename Dialog */}
|
|
<AnimatePresence>
|
|
{renamingNotebook && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
|
onClick={() => !isRenaming && setRenamingNotebook(null)}
|
|
>
|
|
<motion.div
|
|
initial={{ scale: 0.9, opacity: 0, y: 10 }}
|
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
|
exit={{ scale: 0.9, opacity: 0, y: 10 }}
|
|
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
|
|
className="bg-card border border-border rounded-2xl p-6 shadow-xl w-80"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<h3 className="text-sm font-semibold mb-4 font-memento-serif">
|
|
{t('notebook.rename')}
|
|
</h3>
|
|
<input
|
|
autoFocus
|
|
value={renameValue}
|
|
onChange={(e) => 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')}
|
|
/>
|
|
<div className="flex justify-end gap-2 mt-4">
|
|
<button
|
|
onClick={() => 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')}
|
|
</button>
|
|
<button
|
|
onClick={handleConfirmRename}
|
|
disabled={isRenaming || !renameValue.trim()}
|
|
className="px-4 py-1.5 text-xs font-medium bg-foreground text-background rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50"
|
|
>
|
|
{isRenaming ? '...' : t('notebook.confirmRename')}
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Delete Confirmation */}
|
|
<AnimatePresence>
|
|
{deletingNotebook && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
|
onClick={() => !isDeleting && setDeletingNotebook(null)}
|
|
>
|
|
<motion.div
|
|
initial={{ scale: 0.9, opacity: 0, y: 10 }}
|
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
|
exit={{ scale: 0.9, opacity: 0, y: 10 }}
|
|
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
|
|
className="bg-card border border-border rounded-2xl p-6 shadow-xl w-80"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="w-10 h-10 rounded-full bg-red-50 border border-red-100 flex items-center justify-center mb-3">
|
|
<Trash2 size={16} className="text-red-500" />
|
|
</div>
|
|
<h3 className="text-sm font-semibold mb-1 font-memento-serif">
|
|
{t('notebook.trashTitle')}
|
|
</h3>
|
|
<p className="text-xs text-muted-foreground mb-4">
|
|
{t('notebook.trashConfirm', { name: deletingNotebook.name }) || `Move "${deletingNotebook.name}" to trash? You can restore it within 30 days.`}
|
|
</p>
|
|
{getDescendantIds(deletingNotebook.id).length > 0 && (
|
|
<p className="text-xs text-amber-600 mb-4 bg-amber-50 px-3 py-2 rounded-lg border border-amber-100">
|
|
{t('notebook.trashCascadeWarning', { count: getDescendantIds(deletingNotebook.id).length }) || `${getDescendantIds(deletingNotebook.id).length} sub-notebook(s) will also be moved to trash.`}
|
|
</p>
|
|
)}
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
onClick={() => 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')}
|
|
</button>
|
|
<button
|
|
onClick={handleConfirmDelete}
|
|
disabled={isDeleting}
|
|
className="px-4 py-1.5 text-xs font-medium bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors disabled:opacity-50"
|
|
>
|
|
{isDeleting ? '...' : t('notebook.moveToTrash')}
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</>
|
|
)
|
|
}
|