Tier 1: - BASIC tier: chat (10/mo) + reformulate (10/mo) désormais accessibles - Nouveaux quotas: ai_flashcard + voice_transcribe dans tous les tiers - /api/notes/daily : note du jour auto-créée (find or create) - Bouton Note du Jour dans la sidebar (CalendarDays) - Voice-to-Text dans l'éditeur (Web Speech API, bouton Mic toolbar) - Flashcard generation → quota ai_flashcard (au lieu de reformulate) Tier 2: - Intégration Readwise: GET/POST/DELETE /api/integrations/readwise - Intégration Google Calendar: OAuth flow + today's events + meeting notes - /api/integrations/calendar + /callback - Page /settings/integrations avec cards Calendar + Readwise - SettingsNav: onglet Intégrations - AgentTemplates: catégories + 4 nouveaux templates (Digest/Recap/AutoTagger/Synthesis) Schema: - UserAISettings.integrationTokens Json? (migration 20260529160000) - prisma generate + migrate deploy appliqués Fix: - SpeechRecognition types (triple-slash @types/dom-speech-recognition) - Notebook.create: suppression champ 'description' inexistant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1688 lines
69 KiB
TypeScript
1688 lines
69 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,
|
|
CalendarDays,
|
|
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, getNotesWithReminders } 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'
|
|
import { StarterPackBadge } from './onboarding/starter-pack-badge'
|
|
|
|
type NavigationView = 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision' | 'insights'
|
|
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 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)
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
setLoading(true)
|
|
getNotesWithReminders()
|
|
.then((rows) => {
|
|
if (!cancelled) setReminders(rows as typeof reminders)
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false)
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
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) => (
|
|
<button
|
|
key={note.id}
|
|
type="button"
|
|
onClick={() => onOpenNote(note.id, note.notebookId)}
|
|
className="w-full text-start px-4 py-2.5 rounded-xl hover:bg-brand-accent/5 transition-colors group"
|
|
>
|
|
<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>
|
|
)
|
|
|
|
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,
|
|
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 if (pathname === '/insights') setActiveView('insights')
|
|
else if (pathname.startsWith('/revision')) setActiveView('revision')
|
|
else if (searchParams.get('reminders') === '1' && pathname === '/home') setActiveView('reminders')
|
|
else if (pathname === '/home' || pathname.startsWith('/notes')) setActiveView('notebooks')
|
|
}, [pathname, searchParams])
|
|
|
|
const isRemindersRoute = pathname === '/home' && searchParams.get('reminders') === '1'
|
|
const isSharedRoute = pathname === '/home' && searchParams.get('shared') === '1'
|
|
const isNotebooksRoute =
|
|
(pathname === '/home' || pathname.startsWith('/notes')) &&
|
|
!pathname.startsWith('/settings') &&
|
|
!isRemindersRoute &&
|
|
!isSharedRoute
|
|
|
|
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 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 handleDailyNoteClick = async () => {
|
|
try {
|
|
const res = await fetch('/api/notes/daily')
|
|
const data = await res.json()
|
|
if (data.success && data.note) {
|
|
const params = new URLSearchParams()
|
|
if (data.note.notebookId) params.set('notebook', data.note.notebookId)
|
|
params.set('openNote', data.note.id)
|
|
router.push(`/home?${params.toString()}`)
|
|
}
|
|
} catch {
|
|
toast.error(t('sidebar.dailyNoteError') || 'Impossible d\'ouvrir la note du jour')
|
|
}
|
|
}
|
|
|
|
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')
|
|
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('agents.intelligenceOS') || 'Intelligence IA',
|
|
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-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
|
|
aria-hidden="true"
|
|
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>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleDailyNoteClick}
|
|
className="sidebar-inbox-item"
|
|
>
|
|
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0 bg-paper dark:bg-white/5 text-muted-ink border-border">
|
|
<CalendarDays size={14} />
|
|
</div>
|
|
<span className="text-[13px] font-medium truncate text-muted-ink">
|
|
{t('sidebar.dailyNote') || 'Note du jour'}
|
|
</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 === '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"
|
|
>
|
|
<BookOpen size={14} className="text-brand-accent shrink-0" />
|
|
{t('sidebar.backToNotebooks')}
|
|
</button>
|
|
</motion.div>
|
|
) : activeView === '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"
|
|
>
|
|
<BookOpen size={14} className="text-brand-accent shrink-0" />
|
|
{t('sidebar.backToNotebooks')}
|
|
</button>
|
|
</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 }}
|
|
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>
|
|
) : 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 + 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 */}
|
|
</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>
|
|
</>
|
|
)
|
|
}
|