Harmonise la liste des notes et l’éditeur (titres, dates, retour), rend la saisie rapide et les raccourcis de l’accueil utilisables sur téléphone, et conserve le panneau latéral repliable.
2258 lines
96 KiB
TypeScript
2258 lines
96 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,
|
|
LayoutGrid,
|
|
Palette,
|
|
CreditCard,
|
|
Database,
|
|
Globe,
|
|
Plug,
|
|
Key,
|
|
Info,
|
|
ChevronLeft,
|
|
} 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 { persistThemePreference } from '@/lib/apply-document-theme'
|
|
import { getAllNotes, getTrashCount, getNotesWithReminders, toggleReminderDone, getInboxCount } 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 { listAiCreatedNotebookIds, isRecentlyCreated } from '@/lib/ai-created-highlight'
|
|
import dynamic from 'next/dynamic'
|
|
|
|
const CreateNotebookDialog = dynamic(() => import('./create-notebook-dialog').then(m => ({ default: m.CreateNotebookDialog })), { ssr: false })
|
|
const AiNotebookWizard = dynamic(() => import('./wizard/ai-notebook-wizard').then(m => ({ default: m.AiNotebookWizard })), { ssr: false })
|
|
const NotificationPanel = dynamic(() => import('./notification-panel').then(m => ({ default: m.NotificationPanel })), { ssr: false })
|
|
const UsageMeter = dynamic(() => import('./usage-meter').then(m => ({ default: m.UsageMeter })), { ssr: false })
|
|
const StarterPackBadge = dynamic(() => import('./onboarding/starter-pack-badge').then(m => ({ default: m.StarterPackBadge })), { ssr: false })
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import { performSignOut } from '@/lib/auth-client'
|
|
import { isDashboardHomeRoute } from '@/lib/dashboard/home-route'
|
|
import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm'
|
|
|
|
type NavigationView = 'dashboard' | 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision' | 'insights' | 'settings'
|
|
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
|
|
|
|
const NOTEBOOKS_PANEL_HEIGHT_KEY = 'memento-sidebar-notebooks-height'
|
|
const NOTEBOOKS_PANEL_MIN_PX = 120
|
|
const SIDEBAR_WIDTH_KEY = 'memento-sidebar-width'
|
|
const SIDEBAR_WIDTH_MIN_PX = 280
|
|
const SIDEBAR_WIDTH_MAX_PX = 560
|
|
|
|
function NoteLink({
|
|
title,
|
|
isActive,
|
|
isPinned,
|
|
autoGenerated,
|
|
isRecent,
|
|
onClick,
|
|
}: {
|
|
title: string
|
|
isActive: boolean
|
|
isPinned?: boolean
|
|
/** Note créée par l'IA (agents, wizard, …) */
|
|
autoGenerated?: boolean
|
|
/** Créée récemment (< 48h) — point discret « nouveau » */
|
|
isRecent?: boolean
|
|
onClick: () => void
|
|
}) {
|
|
const { language, t } = 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'
|
|
: autoGenerated
|
|
? 'text-foreground/90 hover:text-foreground hover:bg-brand-accent/5 border border-transparent'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-white/30 dark:hover:bg-white/5',
|
|
autoGenerated && !isActive && 'bg-brand-accent/[0.04]',
|
|
)}
|
|
title={autoGenerated ? (t('sidebar.aiGeneratedNote') || 'Note générée par l\'IA') : undefined}
|
|
>
|
|
{autoGenerated ? (
|
|
<Sparkles
|
|
size={12}
|
|
className={cn('shrink-0', isActive ? 'text-brand-accent' : 'text-brand-accent/80')}
|
|
aria-hidden
|
|
/>
|
|
) : (
|
|
<FileText
|
|
size={12}
|
|
className={cn('shrink-0', isActive ? 'text-brand-accent' : 'text-muted-foreground/70')}
|
|
/>
|
|
)}
|
|
<span className="truncate flex-1">{title}</span>
|
|
{autoGenerated && (
|
|
<span className="shrink-0 text-[8px] font-bold uppercase tracking-wider text-brand-accent/90 bg-brand-accent/10 px-1.5 py-0.5 rounded">
|
|
{t('sidebar.aiBadge') || 'IA'}
|
|
</span>
|
|
)}
|
|
{isRecent && !autoGenerated && (
|
|
<span
|
|
className="shrink-0 w-1.5 h-1.5 rounded-full bg-brand-accent"
|
|
title={t('sidebar.recentNote') || 'Récemment créée'}
|
|
aria-label={t('sidebar.recentNote') || 'Récemment créée'}
|
|
/>
|
|
)}
|
|
{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 SidebarReminders({ onOpenNote }: { onOpenNote: (noteId: string, notebookId: string | null) => void }) {
|
|
const { t } = useLanguage()
|
|
const [reminders, setReminders] = useState<
|
|
{ id: string; title: string | null; reminder: Date | string | null; isReminderDone: boolean; notebookId: string | null }[]
|
|
>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [togglingId, setTogglingId] = useState<string | null>(null)
|
|
|
|
const reload = () => {
|
|
getNotesWithReminders().then((rows) => setReminders(rows as typeof reminders))
|
|
}
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
setLoading(true)
|
|
getNotesWithReminders()
|
|
.then((rows) => {
|
|
if (!cancelled) setReminders(rows as typeof reminders)
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false)
|
|
})
|
|
return () => { cancelled = true }
|
|
}, [])
|
|
|
|
const handleComplete = async (e: React.MouseEvent, noteId: string) => {
|
|
e.stopPropagation()
|
|
setTogglingId(noteId)
|
|
await toggleReminderDone(noteId, true)
|
|
setReminders(prev => prev.map(r => r.id === noteId ? { ...r, isReminderDone: true } : r))
|
|
setTogglingId(null)
|
|
}
|
|
|
|
const handleSnooze = async (e: React.MouseEvent, noteId: string) => {
|
|
e.stopPropagation()
|
|
// Snooze = +1h
|
|
const snoozeDate = new Date(Date.now() + 60 * 60 * 1000)
|
|
setTogglingId(noteId)
|
|
try {
|
|
await fetch(`/api/notes/${noteId}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ reminder: snoozeDate.toISOString(), isReminderDone: false }),
|
|
})
|
|
setReminders(prev => prev.map(r => r.id === noteId ? { ...r, reminder: snoozeDate, isReminderDone: false } : r))
|
|
} catch { reload() }
|
|
setTogglingId(null)
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="px-4 space-y-2">
|
|
{[1, 2, 3].map((i) => (
|
|
<div key={i} className="h-10 rounded-xl bg-paper/50 animate-pulse" />
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const now = new Date()
|
|
const active = reminders.filter((r) => !r.isReminderDone && r.reminder)
|
|
const overdue = active.filter((r) => new Date(r.reminder!) < now)
|
|
const upcoming = active.filter((r) => new Date(r.reminder!) >= now)
|
|
|
|
if (active.length === 0) {
|
|
return (
|
|
<div className="px-4 py-8 text-center border border-dashed border-border rounded-2xl bg-paper/30 mx-4">
|
|
<Clock size={24} className="mx-auto text-concrete/40 mb-3" />
|
|
<p className="text-[11px] text-concrete italic">{t('reminders.emptyDescription')}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const renderItem = (note: (typeof active)[0], overdueItem?: boolean) => (
|
|
<div
|
|
key={note.id}
|
|
className="group flex items-center gap-1 px-2 py-1.5 rounded-xl hover:bg-brand-accent/5 transition-colors"
|
|
>
|
|
{/* Bouton compléter */}
|
|
<button
|
|
type="button"
|
|
onClick={(e) => { void handleComplete(e, note.id) }}
|
|
disabled={togglingId === note.id}
|
|
className="flex-shrink-0 w-5 h-5 rounded-full border-2 border-concrete/30 hover:border-emerald-500 hover:bg-emerald-50 transition-all flex items-center justify-center"
|
|
title={t('reminders.markDone') || 'Marquer comme fait'}
|
|
>
|
|
{togglingId === note.id
|
|
? <div className="w-2 h-2 rounded-full bg-concrete animate-pulse" />
|
|
: null}
|
|
</button>
|
|
|
|
{/* Contenu cliquable */}
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpenNote(note.id, note.notebookId)}
|
|
className="flex-1 text-start min-w-0"
|
|
>
|
|
<p className="text-[12px] font-medium truncate group-hover:text-brand-accent transition-colors">
|
|
{note.title || t('notes.untitled')}
|
|
</p>
|
|
<p className={cn('text-[10px] mt-0.5', overdueItem ? 'text-red-500' : 'text-muted-foreground')}>
|
|
{note.reminder &&
|
|
new Date(note.reminder).toLocaleString(undefined, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})}
|
|
</p>
|
|
</button>
|
|
|
|
{/* Actions (visibles au hover) */}
|
|
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity flex gap-0.5">
|
|
<button
|
|
type="button"
|
|
onClick={(e) => { void handleSnooze(e, note.id) }}
|
|
disabled={togglingId === note.id}
|
|
className="p-1 rounded-md hover:bg-amber-100 text-amber-600 transition-colors"
|
|
title={t('reminders.snooze1h') || 'Reporter de 1h'}
|
|
>
|
|
<span className="text-[9px] font-bold">+1h</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<div className="space-y-4 pb-2">
|
|
{overdue.length > 0 && (
|
|
<div>
|
|
<p className="text-[9px] font-bold uppercase tracking-widest text-red-500 px-4 mb-1">
|
|
{t('reminders.overdue')}
|
|
</p>
|
|
<div className="space-y-0.5">{overdue.map((n) => renderItem(n, true))}</div>
|
|
</div>
|
|
)}
|
|
{upcoming.length > 0 && (
|
|
<div>
|
|
<p className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground px-4 mb-1">
|
|
{t('reminders.upcoming')}
|
|
</p>
|
|
<div className="space-y-0.5">{upcoming.map((n) => renderItem(n))}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SidebarCarnetItem({
|
|
carnet,
|
|
isActive,
|
|
notes,
|
|
activeNoteId,
|
|
onCarnetClick,
|
|
onNoteClick,
|
|
onAddSubNotebook,
|
|
onRename,
|
|
onDelete,
|
|
onTogglePin,
|
|
isPinned,
|
|
isAiCreated,
|
|
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; autoGenerated?: boolean; isRecent?: boolean }[]
|
|
activeNoteId: string | null
|
|
onCarnetClick: () => void
|
|
onNoteClick: (noteId: string, carnetId: string) => void
|
|
onAddSubNotebook: () => void
|
|
onRename: () => void
|
|
onDelete: () => void
|
|
onTogglePin: () => void
|
|
isPinned: boolean
|
|
/** Carnet créé via wizard IA (repère temporaire) */
|
|
isAiCreated?: 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 className="flex-1 flex items-center gap-0.5">
|
|
{/* Drag handle — visible au hover, dans son propre slot avant le chevron */}
|
|
<div
|
|
{...dragHandleProps}
|
|
className="shrink-0 w-5 h-5 flex items-center justify-center rounded text-muted-foreground/0 group-hover:text-muted-foreground/50 hover:!text-muted-foreground cursor-grab active:cursor-grabbing transition-colors"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<GripVertical size={11} />
|
|
</div>
|
|
|
|
{hasChildren ? (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); toggleExpand() }}
|
|
className="shrink-0 w-5 h-5 flex items-center justify-center hover:bg-foreground/5 rounded-md transition-colors text-muted-foreground"
|
|
>
|
|
<motion.div animate={{ rotate: isExpanded ? 90 : 0 }} transition={{ duration: 0.2 }}>
|
|
<ChevronRight size={13} className="rtl:scale-x-[-1]" />
|
|
</motion.div>
|
|
</button>
|
|
) : (
|
|
<div className="w-5" />
|
|
)}
|
|
|
|
<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 overflow-hidden',
|
|
isActive
|
|
? 'bg-white dark:bg-white/10 shadow-sm border border-border/40'
|
|
: isAiCreated
|
|
? 'hover:bg-brand-accent/10 border border-brand-accent/20 bg-brand-accent/[0.06]'
|
|
: 'hover:bg-white/40 dark:hover:bg-white/5',
|
|
)}
|
|
title={isAiCreated ? (t('sidebar.aiGeneratedNotebook') || 'Carnet créé avec l\'IA') : undefined}
|
|
>
|
|
{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 }}
|
|
/>
|
|
)}
|
|
{isAiCreated && !isActive && (
|
|
<span className="absolute -start-1 w-1 h-4 bg-brand-accent/70 rounded-full" aria-hidden />
|
|
)}
|
|
<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-[13px] font-medium transition-colors truncate',
|
|
isActive ? 'text-foreground' : 'text-foreground/80 group-hover/item:text-foreground'
|
|
)}>
|
|
{carnet.name}
|
|
</span>
|
|
{isAiCreated && (
|
|
<span className="shrink-0 inline-flex items-center gap-0.5 text-[8px] font-bold uppercase tracking-wider text-brand-accent bg-brand-accent/15 px-1.5 py-0.5 rounded">
|
|
<Sparkles size={9} aria-hidden />
|
|
{t('sidebar.aiBadge') || 'IA'}
|
|
</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>
|
|
|
|
{/* Compteur de notes (toujours visible, à droite du nom) */}
|
|
{notes.length > 0 && (
|
|
<span className="text-[11px] font-medium tabular-nums text-foreground/75 px-1.5 bg-brand-accent/5 border border-border/40 rounded-full transition-colors shrink-0 ms-auto">
|
|
{notes.length}
|
|
</span>
|
|
)}
|
|
|
|
{/* Boutons d'action en absolu, toujours à droite */}
|
|
<div className="absolute end-0 top-0 bottom-0 flex items-center gap-1 pe-1 ps-6 opacity-0 group-hover/item:opacity-100 focus-within:opacity-100 transition-opacity bg-gradient-to-l from-[var(--color-paper,white)] dark:from-[var(--color-paper,#1a1a1a)] from-60% to-transparent">
|
|
{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>
|
|
</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}
|
|
autoGenerated={note.autoGenerated}
|
|
isRecent={note.isRecent}
|
|
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 [showAiWizard, setShowAiWizard] = 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)
|
|
// Cookie + localStorage : le serveur doit connaître le dark, sinon flash clair
|
|
// à chaque changement de page (layout racine réécrit <html>).
|
|
persistThemePreference(next ? 'dark' : 'light')
|
|
}, [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; autoGenerated?: boolean; isRecent?: boolean }[]>>({})
|
|
const [aiNotebookIds, setAiNotebookIds] = useState<Set<string>>(() => new Set())
|
|
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 [inboxCount, setInboxCount] = useState(0)
|
|
|
|
const notebooksContainerRef = useRef<HTMLDivElement>(null)
|
|
const notebooksPanelRef = useRef<HTMLDivElement>(null)
|
|
const notebooksPanelHeightRef = useRef<number | null>(null)
|
|
const [notebooksPanelHeight, setNotebooksPanelHeight] = useState<number | null>(null)
|
|
const asideRef = useRef<HTMLElement>(null)
|
|
const sidebarWidthRef = useRef<number | null>(null)
|
|
const [sidebarWidth, setSidebarWidth] = useState<number | null>(null)
|
|
|
|
useEffect(() => {
|
|
try {
|
|
const stored = localStorage.getItem(NOTEBOOKS_PANEL_HEIGHT_KEY)
|
|
if (stored) {
|
|
const n = parseInt(stored, 10)
|
|
if (!Number.isNaN(n) && n >= NOTEBOOKS_PANEL_MIN_PX) {
|
|
setNotebooksPanelHeight(n)
|
|
notebooksPanelHeightRef.current = n
|
|
}
|
|
}
|
|
const storedW = localStorage.getItem(SIDEBAR_WIDTH_KEY)
|
|
if (storedW) {
|
|
const w = parseInt(storedW, 10)
|
|
if (!Number.isNaN(w) && w >= SIDEBAR_WIDTH_MIN_PX && w <= SIDEBAR_WIDTH_MAX_PX) {
|
|
setSidebarWidth(w)
|
|
sidebarWidthRef.current = w
|
|
}
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}, [])
|
|
|
|
const handleNotebooksResizeStart = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
const panel = notebooksPanelRef.current
|
|
const container = notebooksContainerRef.current
|
|
if (!panel || !container) return
|
|
|
|
const target = e.currentTarget
|
|
target.setPointerCapture(e.pointerId)
|
|
|
|
const startY = e.clientY
|
|
const startH = panel.getBoundingClientRect().height
|
|
const topBlock = container.querySelector('[data-sidebar-notebooks-top]') as HTMLElement | null
|
|
const handleH = 12
|
|
const containerH = container.getBoundingClientRect().height
|
|
const maxH = Math.max(
|
|
NOTEBOOKS_PANEL_MIN_PX,
|
|
containerH - (topBlock?.offsetHeight ?? 160) - handleH,
|
|
)
|
|
|
|
const onMove = (ev: PointerEvent) => {
|
|
const delta = ev.clientY - startY
|
|
const newH = Math.min(maxH, Math.max(NOTEBOOKS_PANEL_MIN_PX, startH + delta))
|
|
notebooksPanelHeightRef.current = newH
|
|
setNotebooksPanelHeight(newH)
|
|
}
|
|
const onUp = (ev: PointerEvent) => {
|
|
target.releasePointerCapture(ev.pointerId)
|
|
document.removeEventListener('pointermove', onMove)
|
|
document.removeEventListener('pointerup', onUp)
|
|
const h = notebooksPanelHeightRef.current
|
|
if (h !== null) {
|
|
try {
|
|
localStorage.setItem(NOTEBOOKS_PANEL_HEIGHT_KEY, String(h))
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
document.addEventListener('pointermove', onMove)
|
|
document.addEventListener('pointerup', onUp)
|
|
}, [])
|
|
|
|
const handleSidebarWidthResizeStart = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
const aside = asideRef.current
|
|
if (!aside) return
|
|
|
|
const target = e.currentTarget
|
|
target.setPointerCapture(e.pointerId)
|
|
document.body.style.cursor = 'col-resize'
|
|
document.body.style.userSelect = 'none'
|
|
|
|
const startX = e.clientX
|
|
const startW = aside.getBoundingClientRect().width
|
|
|
|
const onMove = (ev: PointerEvent) => {
|
|
const delta = isRtl ? startX - ev.clientX : ev.clientX - startX
|
|
const newW = Math.min(SIDEBAR_WIDTH_MAX_PX, Math.max(SIDEBAR_WIDTH_MIN_PX, startW + delta))
|
|
sidebarWidthRef.current = newW
|
|
setSidebarWidth(newW)
|
|
}
|
|
const onUp = (ev: PointerEvent) => {
|
|
target.releasePointerCapture(ev.pointerId)
|
|
document.body.style.cursor = ''
|
|
document.body.style.userSelect = ''
|
|
document.removeEventListener('pointermove', onMove)
|
|
document.removeEventListener('pointerup', onUp)
|
|
const w = sidebarWidthRef.current
|
|
if (w !== null) {
|
|
try {
|
|
localStorage.setItem(SIDEBAR_WIDTH_KEY, String(w))
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
document.addEventListener('pointermove', onMove)
|
|
document.addEventListener('pointerup', onUp)
|
|
}, [isRtl])
|
|
|
|
const [draggedId, setDraggedId] = useState<string | null>(null)
|
|
// Ref stable pour éviter les stale closures dans les handlers de drop
|
|
const draggedIdRef = useRef<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 isDashboardRoute = isDashboardHomeRoute(pathname, searchParams)
|
|
const panelView: NavigationView = pathname.startsWith('/settings') ? 'settings' : activeView
|
|
|
|
const isInboxActive =
|
|
pathname === '/home' &&
|
|
searchParams.get('forceList') === '1' &&
|
|
!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 if (pathname === '/insights') setActiveView('insights')
|
|
else if (pathname.startsWith('/revision')) setActiveView('revision')
|
|
else if (pathname.startsWith('/settings')) setActiveView('settings')
|
|
else if (searchParams.get('reminders') === '1' && pathname === '/home') setActiveView('reminders')
|
|
else if (isDashboardRoute) setActiveView('dashboard')
|
|
else if (pathname === '/home' || pathname.startsWith('/notes')) setActiveView('notebooks')
|
|
}, [pathname, searchParams, isDashboardRoute])
|
|
|
|
const isRemindersRoute = pathname === '/home' && searchParams.get('reminders') === '1'
|
|
const isSharedRoute = pathname === '/home' && searchParams.get('shared') === '1'
|
|
const isImmersiveRoute = pathname === '/insights'
|
|
const [dashboardPanelCollapsed, setDashboardPanelCollapsed] = useState(false)
|
|
useEffect(() => {
|
|
try {
|
|
setDashboardPanelCollapsed(localStorage.getItem('memento-dashboard-panel-collapsed') === 'true')
|
|
} catch {}
|
|
}, [])
|
|
const [insightsPanelCollapsed, setInsightsPanelCollapsed] = useState(false)
|
|
useEffect(() => {
|
|
try {
|
|
setInsightsPanelCollapsed(localStorage.getItem('insights-sidebar-collapsed') === 'true')
|
|
} catch {}
|
|
}, [])
|
|
const hasCollapsiblePanel = isDashboardRoute || isImmersiveRoute
|
|
const collapseDashboardPanel = (isDashboardRoute && dashboardPanelCollapsed) || (isImmersiveRoute && insightsPanelCollapsed)
|
|
const dashboardPanelLabel = isImmersiveRoute
|
|
? t('insightsView.toggleMenu')
|
|
: t(collapseDashboardPanel ? 'sidebar.showDashboardPanel' : 'sidebar.hideDashboardPanel')
|
|
const toggleDashboardPanel = () => {
|
|
if (isImmersiveRoute) {
|
|
const next = !insightsPanelCollapsed
|
|
setInsightsPanelCollapsed(next)
|
|
try {
|
|
localStorage.setItem('insights-sidebar-collapsed', String(next))
|
|
} catch {}
|
|
return
|
|
}
|
|
const next = !dashboardPanelCollapsed
|
|
setDashboardPanelCollapsed(next)
|
|
try {
|
|
localStorage.setItem('memento-dashboard-panel-collapsed', String(next))
|
|
} catch {}
|
|
}
|
|
|
|
const isNotebooksRoute =
|
|
(pathname === '/home' || pathname.startsWith('/notes')) &&
|
|
!pathname.startsWith('/settings') &&
|
|
!isRemindersRoute &&
|
|
!isSharedRoute &&
|
|
!isDashboardRoute
|
|
|
|
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) })
|
|
getInboxCount().then((count: number) => { if (!cancelled) setInboxCount(count) })
|
|
return () => { cancelled = true }
|
|
}, [])
|
|
|
|
const notebookIdsKey = useMemo(() => notebooks.map(nb => nb.id).sort().join(','), [notebooks])
|
|
|
|
// Repères carnets IA (localStorage TTL 7j)
|
|
useEffect(() => {
|
|
const refresh = () => setAiNotebookIds(listAiCreatedNotebookIds())
|
|
refresh()
|
|
window.addEventListener('memento-ai-created-changed', refresh)
|
|
window.addEventListener('storage', refresh)
|
|
return () => {
|
|
window.removeEventListener('memento-ai-created-changed', refresh)
|
|
window.removeEventListener('storage', refresh)
|
|
}
|
|
}, [])
|
|
|
|
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,
|
|
autoGenerated: Boolean(n.autoGenerated),
|
|
isRecent: isRecentlyCreated(n.createdAt, 48),
|
|
}))
|
|
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
|
|
getInboxCount().then((count: number) => setInboxCount(count))
|
|
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 handleRemindersClick = () => {
|
|
setActiveView('reminders')
|
|
router.push('/home?reminders=1&forceList=1')
|
|
}
|
|
|
|
const handleReminderNoteClick = (noteId: string, notebookId: string | null) => {
|
|
const params = new URLSearchParams()
|
|
params.set('reminders', '1')
|
|
if (notebookId) params.set('notebook', notebookId)
|
|
params.set('openNote', noteId)
|
|
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' | 'before' | 'after' | null>(null)
|
|
|
|
const handleDragStart = useCallback((e: React.DragEvent, notebookId: string) => {
|
|
// Stocker dans le ref ET dans le state
|
|
// Le ref est toujours frais dans les handlers async (pas de stale closure)
|
|
draggedIdRef.current = notebookId
|
|
setDraggedId(notebookId)
|
|
e.dataTransfer.effectAllowed = 'move'
|
|
e.dataTransfer.setData('text/plain', notebookId)
|
|
}, [])
|
|
|
|
const handleDragEnd = useCallback(() => {
|
|
draggedIdRef.current = null
|
|
setDraggedId(null)
|
|
dragOverId.current = null
|
|
isSavingRef.current = false
|
|
setDropTarget(null)
|
|
setDropAction(null)
|
|
setOrderedNotebooks(sortedNotebooks)
|
|
}, [sortedNotebooks])
|
|
|
|
const handleDropOnNotebook = useCallback(async (e: React.DragEvent, targetId: string, action: 'into' | 'before' | 'after') => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
const dragId = draggedIdRef.current || e.dataTransfer.getData('text/plain')
|
|
draggedIdRef.current = null
|
|
setDraggedId(null)
|
|
setDropTarget(null)
|
|
setDropAction(null)
|
|
dragOverId.current = null
|
|
if (!dragId || dragId === targetId) return
|
|
try {
|
|
if (action === 'into') {
|
|
await moveNotebookToParent(dragId, targetId)
|
|
} else {
|
|
// before/after : placer au même niveau que la cible et réordonner
|
|
const target = orderedNotebooks.find(nb => nb.id === targetId)
|
|
const newParentId = target?.parentId ?? null
|
|
|
|
// 1. Mettre à jour le parentId si nécessaire
|
|
const dragged = orderedNotebooks.find(nb => nb.id === dragId)
|
|
if (dragged?.parentId !== newParentId) {
|
|
await moveNotebookToParent(dragId, newParentId)
|
|
}
|
|
|
|
// 2. Recalculer l'ordre des siblings
|
|
const siblings = orderedNotebooks
|
|
.filter(nb => (nb.parentId ?? null) === newParentId && nb.id !== dragId)
|
|
const targetIdx = siblings.findIndex(nb => nb.id === targetId)
|
|
const insertAt = action === 'before' ? targetIdx : targetIdx + 1
|
|
siblings.splice(insertAt, 0, { id: dragId } as typeof siblings[0])
|
|
// Passer en tri manuel pour que l'ordre soit respecté visuellement
|
|
setSortOrder('manual')
|
|
await updateNotebookOrderOptimistic(siblings.map(nb => nb.id))
|
|
}
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : t('sidebar.moveFailed'))
|
|
setOrderedNotebooks(sortedNotebooks)
|
|
}
|
|
}, [moveNotebookToParent, updateNotebookOrderOptimistic, orderedNotebooks, sortedNotebooks, t])
|
|
|
|
const handleDropToRoot = useCallback(async (e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
// Lire depuis le ref (toujours à jour, pas de stale closure)
|
|
const dragId = draggedIdRef.current || e.dataTransfer.getData('text/plain')
|
|
// Nettoyer APRÈS avoir lu la valeur
|
|
draggedIdRef.current = null
|
|
setDraggedId(null)
|
|
setDropTarget(null)
|
|
setDropAction(null)
|
|
dragOverId.current = null
|
|
if (!dragId) return
|
|
try {
|
|
await moveNotebookToParent(dragId, null)
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : t('sidebar.moveFailed'))
|
|
setOrderedNotebooks(sortedNotebooks)
|
|
}
|
|
}, [moveNotebookToParent, sortedNotebooks, t])
|
|
|
|
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'
|
|
const rect = e.currentTarget.getBoundingClientRect()
|
|
const y = e.clientY - rect.top
|
|
const pct = y / rect.height
|
|
setDropTarget(notebook.id)
|
|
if (pct < 0.3) setDropAction('before')
|
|
else if (pct > 0.7) setDropAction('after')
|
|
else 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()
|
|
const action = (dropTarget === notebook.id ? dropAction : null) ?? 'into'
|
|
handleDropOnNotebook(e, notebook.id, action as 'into' | 'before' | 'after')
|
|
}}
|
|
className={cn(
|
|
'relative 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'
|
|
)}
|
|
>
|
|
{/* Indicateur "déposer avant" */}
|
|
{dropTarget === notebook.id && dropAction === 'before' && draggedId && draggedId !== notebook.id && (
|
|
<div className="absolute -top-0.5 start-0 end-0 h-0.5 bg-brand-accent rounded-full z-10 pointer-events-none" />
|
|
)}
|
|
{/* Indicateur "déposer après" */}
|
|
{dropTarget === notebook.id && dropAction === 'after' && draggedId && draggedId !== notebook.id && (
|
|
<div className="absolute -bottom-0.5 start-0 end-0 h-0.5 bg-brand-accent rounded-full z-10 pointer-events-none" />
|
|
)}
|
|
<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)}
|
|
isAiCreated={aiNotebookIds.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, aiNotebookIds, 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
|
|
ref={asideRef}
|
|
style={sidebarWidth !== null ? { width: sidebarWidth, '--sidebar-expanded-width': `${sidebarWidth}px` } as React.CSSProperties : undefined}
|
|
className={cn(
|
|
cn(
|
|
'fixed inset-y-0 start-0 z-[70] md:relative md:z-auto',
|
|
sidebarWidth === null && 'w-80 md:w-[26rem] 2xl:w-[30rem]',
|
|
isMobileOpen
|
|
? 'translate-x-0 shadow-2xl'
|
|
: cn(isRtl ? 'translate-x-full md:translate-x-0' : '-translate-x-full md:translate-x-0'),
|
|
),
|
|
'h-full min-h-0 shrink-0 flex flex-row self-stretch overflow-visible',
|
|
collapseDashboardPanel && 'md:!w-[72px]',
|
|
hasCollapsiblePanel && 'dashboard-sidebar md:z-40',
|
|
'transition-transform duration-300 ease-in-out',
|
|
'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 icône + libellé — destinations lisibles sans survol ── */}
|
|
<div className="w-[72px] border-e border-border/40 bg-[#FAF9F5] dark:bg-[#0E0E0E] flex flex-col items-center justify-between py-3 shrink-0 select-none overflow-y-auto overflow-x-hidden custom-scrollbar">
|
|
|
|
{/* Top : Logo + navigation */}
|
|
<div className="flex flex-col items-center gap-[18px] 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-2">
|
|
<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 w-full px-1">
|
|
{([
|
|
{
|
|
id: 'dashboard',
|
|
icon: LayoutGrid,
|
|
label: t('nav.home'),
|
|
onClick: () => {
|
|
setActiveView('dashboard')
|
|
router.replace('/home')
|
|
},
|
|
isActive: isDashboardRoute,
|
|
},
|
|
{
|
|
id: 'notebooks',
|
|
icon: BookOpen,
|
|
label: t('nav.notebooks'),
|
|
onClick: () => {
|
|
setActiveView('notebooks')
|
|
router.push('/home?forceList=1')
|
|
},
|
|
isActive: isNotebooksRoute,
|
|
},
|
|
{
|
|
id: 'insights',
|
|
icon: Sparkles,
|
|
label: t('nav.insights'),
|
|
onClick: () => {
|
|
setActiveView('insights')
|
|
router.push('/insights')
|
|
},
|
|
isActive: pathname === '/insights',
|
|
},
|
|
{
|
|
id: 'revision',
|
|
icon: GraduationCap,
|
|
label: t('nav.revision'),
|
|
onClick: () => {
|
|
setActiveView('revision')
|
|
router.push('/revision')
|
|
},
|
|
isActive: pathname.startsWith('/revision'),
|
|
},
|
|
{
|
|
id: 'agents',
|
|
icon: Bot,
|
|
label: t('nav.agents'),
|
|
onClick: () => {
|
|
setActiveView('agents')
|
|
router.push('/agents')
|
|
},
|
|
isActive: pathname.startsWith('/agents') || pathname.startsWith('/lab'),
|
|
},
|
|
{
|
|
id: 'reminders',
|
|
icon: Bell,
|
|
label: t('sidebar.reminders'),
|
|
onClick: handleRemindersClick,
|
|
isActive: isRemindersRoute,
|
|
},
|
|
] as { id: string; icon: React.FC<{ size?: number }>; label: string; onClick: () => void; isActive: boolean }[]).map(item => (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
aria-label={item.label}
|
|
aria-current={item.isActive ? 'page' : undefined}
|
|
onClick={item.onClick}
|
|
className={cn(
|
|
'w-full rounded-lg flex flex-col items-center justify-center gap-0.5 py-1.5 px-0.5 transition-all relative',
|
|
item.isActive
|
|
? 'bg-brand-accent/10 text-brand-accent border border-brand-accent/25'
|
|
: 'text-ink/70 hover:text-ink dark:text-white/70 dark:hover:text-white hover:bg-black/[0.04] dark:hover:bg-white/[0.04]'
|
|
)}
|
|
>
|
|
{item.isActive && (
|
|
<div className="absolute start-0 top-1/2 -translate-y-1/2 w-1 h-4 bg-brand-accent rounded-e-full" />
|
|
)}
|
|
<item.icon size={16} aria-hidden />
|
|
<span className="text-[11px] leading-tight font-semibold text-center max-w-full line-clamp-2">
|
|
{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"
|
|
aria-label={t('sidebar.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="/archive"
|
|
aria-label={t('sidebar.archive')}
|
|
className={cn(
|
|
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
|
|
pathname === '/archive'
|
|
? 'bg-amber-500/10 text-amber-500 border border-amber-500/25'
|
|
: 'text-concrete hover:text-amber-500 hover:bg-amber-500/5'
|
|
)}
|
|
>
|
|
{pathname === '/archive' && <div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 bg-amber-500 rounded-r-full" />}
|
|
<Archive 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.archive')}
|
|
</span>
|
|
</Link>
|
|
|
|
<Link
|
|
href="/home?shared=1&forceList=1"
|
|
aria-label={t('sidebar.sharedWithMe')}
|
|
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"
|
|
aria-label={t('sidebar.searchShortcut')}
|
|
title={t('sidebar.searchShortcut')}
|
|
>
|
|
<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">
|
|
{t('sidebar.searchShortcut')}
|
|
</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={toggleTheme}
|
|
aria-label={isDark ? t('sidebar.lightMode') : t('sidebar.darkMode')}
|
|
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 ? t('sidebar.lightMode') : t('sidebar.darkMode')}
|
|
</span>
|
|
</button>
|
|
|
|
<Link
|
|
href="/settings"
|
|
aria-label={t('nav.settings')}
|
|
onClick={() => setActiveView('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-1 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')}
|
|
aria-label={t('sidebar.signOut')}
|
|
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 id="sidebar-context-panel" data-collapsed={collapseDashboardPanel} className="flex-1 h-full flex flex-col overflow-hidden bg-[#FCFCFA] dark:bg-[#111111]">
|
|
|
|
{/* ── Scrollable content ── */}
|
|
<div
|
|
className={cn(
|
|
'flex-1 flex flex-col min-h-0 -mx-0 pb-4',
|
|
panelView === 'notebooks'
|
|
? 'overflow-hidden'
|
|
: 'overflow-y-auto custom-scrollbar space-y-6',
|
|
)}
|
|
>
|
|
|
|
<AnimatePresence mode="wait">
|
|
{panelView === 'dashboard' ? (
|
|
<motion.div
|
|
key="dashboard"
|
|
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="px-4 pt-4 space-y-4"
|
|
>
|
|
<div className="flex items-center gap-1.5">
|
|
<LayoutGrid size={14} className="text-brand-accent" />
|
|
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
|
|
{t('nav.dashboard')}
|
|
</h3>
|
|
</div>
|
|
<p className="text-[12px] leading-relaxed text-muted-foreground">
|
|
{t('sidebar.dashboardPanelBody')}
|
|
</p>
|
|
<div className="space-y-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setActiveView('notebooks')
|
|
router.push('/home?forceList=1')
|
|
}}
|
|
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
|
|
>
|
|
<BookOpen size={14} className="text-brand-accent shrink-0" />
|
|
{t('sidebar.backToNotebooks')}
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
) : panelView === 'settings' ? (
|
|
<motion.div
|
|
key="settings"
|
|
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="px-4 pt-4"
|
|
>
|
|
<div className="flex items-center gap-1.5 mb-6">
|
|
<Settings size={14} className="text-brand-accent" />
|
|
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
|
|
{t('settings.title')}
|
|
</h3>
|
|
</div>
|
|
<div className="space-y-1">
|
|
{[
|
|
{ id: 'general', href: '/settings/general', label: t('generalSettings.title'), icon: Settings },
|
|
{ id: 'ai', href: '/settings/ai', label: t('aiSettings.title'), icon: Sparkles },
|
|
{ id: 'billing', href: '/settings/billing', label: t('billing.title'), icon: CreditCard },
|
|
{ id: 'appearance', href: '/settings/appearance', label: t('appearance.title'), icon: Palette },
|
|
{ id: 'profile', href: '/settings/profile', label: t('profile.title'), icon: User },
|
|
{ id: 'data', href: '/settings/data', label: t('dataManagement.title'), icon: Database },
|
|
{ id: 'published', href: '/settings/published', label: t('settings.publishedTitle') || 'Mes pages', icon: Globe },
|
|
{ id: 'integrations', href: '/settings/integrations', label: t('integrations.title') || 'Intégrations', icon: Plug },
|
|
{ id: 'mcp', href: '/settings/mcp', label: t('mcpSettings.title'), icon: Key },
|
|
{ id: 'about', href: '/settings/about', label: t('about.title'), icon: Info },
|
|
].map((tab) => {
|
|
const isActive = pathname === tab.href || pathname.startsWith(`${tab.href}/`)
|
|
return (
|
|
<Link
|
|
key={tab.id}
|
|
href={tab.href}
|
|
className={cn(
|
|
'w-full text-start px-3 py-2 text-[11px] transition-all rounded-lg flex items-center gap-2.5',
|
|
isActive
|
|
? 'text-ink bg-brand-accent/10'
|
|
: 'text-muted-foreground hover:text-ink hover:bg-black/5 dark:hover:bg-white/5',
|
|
)}
|
|
>
|
|
<tab.icon size={12} className="text-concrete shrink-0" />
|
|
<span className="font-semibold">{tab.label}</span>
|
|
</Link>
|
|
)
|
|
})}
|
|
</div>
|
|
</motion.div>
|
|
) : panelView === 'notebooks' ? (
|
|
<motion.div
|
|
key="notebooks"
|
|
ref={notebooksContainerRef}
|
|
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 data-sidebar-notebooks-top className="shrink-0">
|
|
<div className="px-4 pt-4">
|
|
<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('nav.notebooks')}
|
|
</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>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowAiWizard(true)}
|
|
className="p-1 hover:bg-brand-accent/10 rounded transition-all text-concrete hover:text-brand-accent"
|
|
title={t('wizard.title')}
|
|
>
|
|
<Sparkles size={14} />
|
|
</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="px-4 pb-2">
|
|
<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 flex-1',
|
|
isInboxActive ? 'text-ink' : 'text-muted-ink',
|
|
)}
|
|
>
|
|
{t('sidebar.inbox')}
|
|
</span>
|
|
{inboxCount > 0 && (
|
|
<span className={cn(
|
|
'text-[10px] font-bold min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full transition-colors',
|
|
isInboxActive
|
|
? 'bg-brand-accent text-white'
|
|
: 'bg-brand-accent/10 text-brand-accent',
|
|
)}>
|
|
{inboxCount > 99 ? '99+' : inboxCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
role="separator"
|
|
aria-orientation="horizontal"
|
|
aria-label={t('sidebar.resizeNotebooksPanel')}
|
|
onPointerDown={handleNotebooksResizeStart}
|
|
className="flex shrink-0 h-3 mx-3 cursor-row-resize touch-none items-center justify-center group"
|
|
>
|
|
<div className="h-1 w-10 rounded-full bg-border/70 group-hover:bg-brand-accent/60 group-active:bg-brand-accent transition-colors" />
|
|
</div>
|
|
|
|
<div
|
|
ref={notebooksPanelRef}
|
|
className={cn(
|
|
'overflow-y-auto custom-scrollbar min-h-0 px-4 pb-4 shrink-0',
|
|
notebooksPanelHeight === null && 'flex-1',
|
|
)}
|
|
style={notebooksPanelHeight !== null ? { height: notebooksPanelHeight } : undefined}
|
|
>
|
|
<div className="my-3 h-px bg-border/40" />
|
|
|
|
{/* Zone de dépôt racine — toujours présente pour éviter tout décalage DOM */}
|
|
<div
|
|
className={`mb-2 h-10 rounded-lg border-2 border-dashed flex items-center justify-center gap-2 text-[11px] font-medium transition-all duration-150 ${
|
|
draggedId
|
|
? 'border-brand-accent/40 bg-brand-accent/5 text-brand-accent/70 opacity-100 cursor-copy pointer-events-auto'
|
|
: 'border-transparent bg-transparent text-transparent opacity-0 pointer-events-none'
|
|
}`}
|
|
onDrop={handleDropToRoot}
|
|
onDragOver={(e) => { e.preventDefault(); e.stopPropagation() }}
|
|
onDragEnter={(e) => { e.preventDefault() }}
|
|
>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
|
{t('sidebar.dropToRoot')}
|
|
</div>
|
|
|
|
<div
|
|
className="space-y-0.5 min-h-[60px]"
|
|
onDrop={handleDropToRoot}
|
|
onDragOver={(e) => e.preventDefault()}
|
|
>
|
|
{renderCarnetTree(undefined, 0)}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
) : panelView === 'insights' ? (
|
|
<motion.div
|
|
key="insights"
|
|
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="px-4 pt-4 space-y-4"
|
|
>
|
|
<div className="flex items-center gap-1.5">
|
|
<Sparkles size={14} className="text-brand-accent" />
|
|
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
|
|
{t('nav.insights')}
|
|
</h3>
|
|
</div>
|
|
<p className="text-[12px] leading-relaxed text-muted-foreground">
|
|
{t('sidebar.insightsPanelBody')}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => router.push('/home')}
|
|
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
|
|
>
|
|
<Home size={14} className="text-brand-accent shrink-0" />
|
|
{t('sidebar.backToHome')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => router.push('/home?forceList=1')}
|
|
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
|
|
>
|
|
<BookOpen size={14} className="text-brand-accent shrink-0" />
|
|
{t('sidebar.backToNotebooks')}
|
|
</button>
|
|
</motion.div>
|
|
) : panelView === 'revision' ? (
|
|
<motion.div
|
|
key="revision"
|
|
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="px-4 pt-4 space-y-4"
|
|
>
|
|
<div className="flex items-center gap-1.5">
|
|
<GraduationCap size={14} className="text-brand-accent" />
|
|
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
|
|
{t('nav.revision')}
|
|
</h3>
|
|
</div>
|
|
<p className="text-[12px] leading-relaxed text-muted-foreground">
|
|
{t('sidebar.revisionPanelBody')}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => router.push('/home')}
|
|
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
|
|
>
|
|
<Home size={14} className="text-brand-accent shrink-0" />
|
|
{t('sidebar.backToHome')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => router.push('/home?forceList=1')}
|
|
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
|
|
>
|
|
<BookOpen size={14} className="text-brand-accent shrink-0" />
|
|
{t('sidebar.backToNotebooks')}
|
|
</button>
|
|
</motion.div>
|
|
) : panelView === '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 }}
|
|
className="flex flex-col min-h-0"
|
|
>
|
|
<div className="flex items-center gap-1.5 px-4 pt-4 mb-3 shrink-0">
|
|
<Bell size={14} className="text-brand-accent" />
|
|
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
|
|
{t('sidebar.reminders')}
|
|
</h3>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto custom-scrollbar min-h-0">
|
|
<SidebarReminders onOpenNote={handleReminderNoteClick} />
|
|
</div>
|
|
</motion.div>
|
|
) : panelView === '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('nav.agents')}
|
|
</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 + starter badge en bas du panneau ── */}
|
|
<div className="border-t border-border/20 px-3 py-3 mt-auto space-y-2">
|
|
<StarterPackBadge />
|
|
<UsageMeter />
|
|
</div>
|
|
|
|
</div>{/* fin colonne 2 */}
|
|
|
|
{hasCollapsiblePanel && (
|
|
<button
|
|
type="button"
|
|
onClick={toggleDashboardPanel}
|
|
aria-label={dashboardPanelLabel}
|
|
title={dashboardPanelLabel}
|
|
aria-expanded={!collapseDashboardPanel}
|
|
aria-controls="sidebar-context-panel"
|
|
className="group absolute top-5 -end-[18px] z-40 hidden h-9 w-9 items-center justify-center rounded-full md:flex focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-accent"
|
|
>
|
|
<span className="flex h-7 w-7 items-center justify-center rounded-full border border-border bg-background text-foreground transition-colors duration-150 group-hover:border-brand-accent group-hover:text-brand-accent">
|
|
<ChevronLeft size={16} strokeWidth={1.75} className={cn('transition-transform duration-300 motion-reduce:transition-none', collapseDashboardPanel ? 'rotate-180 rtl:rotate-0' : 'rtl:rotate-180')} />
|
|
</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Poignée redimensionnement largeur sidebar (bord vers le contenu) */}
|
|
<div
|
|
role="separator"
|
|
aria-orientation="vertical"
|
|
aria-label={t('sidebar.resizeSidebar')}
|
|
onPointerDown={handleSidebarWidthResizeStart}
|
|
className={cn('hidden md:flex absolute inset-y-0 end-0 translate-x-1/2 w-4 cursor-col-resize touch-none z-30 items-center justify-center group', collapseDashboardPanel && 'md:hidden')}
|
|
>
|
|
<div className="h-10 w-1 rounded-full bg-border/80 group-hover:bg-brand-accent/70 group-active:bg-brand-accent transition-colors shadow-sm" />
|
|
</div>
|
|
</aside>
|
|
|
|
<CreateNotebookDialog
|
|
open={isCreateDialogOpen}
|
|
onOpenChange={setIsCreateDialogOpen}
|
|
parentNotebookId={createParentId}
|
|
/>
|
|
|
|
{showAiWizard && (
|
|
<AiNotebookWizard
|
|
onClose={() => setShowAiWizard(false)}
|
|
onComplete={async (notebookId: string) => {
|
|
// 1) Marquer IA + expand dans la sidebar
|
|
try {
|
|
const { markAiCreatedNotebook } = await import('@/lib/ai-created-highlight')
|
|
markAiCreatedNotebook(notebookId)
|
|
} catch { /* ignore */ }
|
|
setAiNotebookIds((prev) => new Set(prev).add(notebookId))
|
|
setExpandedIds((prev) => {
|
|
const next = new Set(prev)
|
|
next.add(notebookId)
|
|
return next
|
|
})
|
|
// 2) Recharger la liste des carnets (sinon le carnet n'apparaît qu'après F5)
|
|
await refreshNotebooks()
|
|
// 3) Forcer le rechargement des notes de ce carnet dans la sidebar
|
|
try {
|
|
const notes = await getAllNotes(false, notebookId)
|
|
const mapped = notes.map((n: Note) => ({
|
|
id: n.id,
|
|
title: getNoteDisplayTitle(n, t('notes.untitled')),
|
|
isPinned: n.isPinned,
|
|
autoGenerated: Boolean(n.autoGenerated),
|
|
isRecent: true,
|
|
}))
|
|
setNotebookNotes((prev) => ({ ...prev, [notebookId]: mapped }))
|
|
} catch { /* ignore */ }
|
|
setShowAiWizard(false)
|
|
// 4) Ouvrir le carnet
|
|
router.push(`/home?notebook=${notebookId}`)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* 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>
|
|
</>
|
|
)
|
|
}
|