Compare commits
3 Commits
eb6cda9d24
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d38a99586b | ||
|
|
6e13b6d207 | ||
|
|
4d95234e32 |
@@ -619,6 +619,11 @@ export function HomeClient({
|
||||
if (detail.type === 'updated') {
|
||||
patchNoteInList(detail.note.id, detail.note)
|
||||
setPinnedNotes(prev => prev.map(n => n.id === detail.note.id ? { ...n, ...detail.note } : n))
|
||||
} else if (detail.type === 'deleted') {
|
||||
removeNoteFromList(detail.noteId)
|
||||
setPinnedNotes(prev => prev.filter(n => n.id !== detail.noteId))
|
||||
} else if (detail.type === 'created' && detail.note) {
|
||||
setNotes(prev => [detail.note, ...prev])
|
||||
}
|
||||
}
|
||||
window.addEventListener(NOTE_CHANGE_EVENT, onNoteChange)
|
||||
|
||||
348
memento-note/components/sidebar-panels.tsx
Normal file
348
memento-note/components/sidebar-panels.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { getAllNotes, getNotesWithReminders, toggleReminderDone } from '@/app/actions/notes'
|
||||
import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm'
|
||||
import {
|
||||
ChevronRight, Lock, Plus, Pencil, Trash2, Sparkles, Clock,
|
||||
Pin, PinOff, GripVertical, FileText, Folder, FolderOpen,
|
||||
} from 'lucide-react'
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
|
||||
export 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()
|
||||
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 (e) { console.error('[silent-catch]', e); 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">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
export 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)
|
||||
|
||||
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">
|
||||
<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' : '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>
|
||||
{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 shrink-0 ms-auto">
|
||||
{notes.length}
|
||||
</span>
|
||||
)}
|
||||
<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 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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
BookMarked,
|
||||
Bot,
|
||||
Inbox,
|
||||
CalendarDays,
|
||||
FlaskConical,
|
||||
ArrowUpDown,
|
||||
Archive,
|
||||
@@ -68,6 +67,12 @@ import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainsto
|
||||
type NavigationView = 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision' | 'insights'
|
||||
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,
|
||||
@@ -635,6 +640,119 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
const [notebookSearchQuery, setNotebookSearchQuery] = useState('')
|
||||
const [trashCount, setTrashCount] = 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)
|
||||
@@ -855,21 +973,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
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)
|
||||
@@ -1180,11 +1283,14 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
</AnimatePresence>
|
||||
|
||||
<aside
|
||||
ref={asideRef}
|
||||
style={sidebarWidth !== null ? { width: sidebarWidth } : undefined}
|
||||
className={cn(
|
||||
isImmersiveRoute && userCollapsed
|
||||
? 'fixed inset-y-0 start-0 z-[70]'
|
||||
: 'fixed inset-y-0 start-0 z-[70] md:relative md:z-auto',
|
||||
'h-full min-h-0 w-80 md:w-[26rem] 2xl:w-[30rem] shrink-0 flex flex-row overflow-hidden self-stretch',
|
||||
sidebarWidth === null && 'w-80 md:w-[26rem] 2xl:w-[30rem]',
|
||||
'h-full min-h-0 shrink-0 flex flex-row self-stretch relative overflow-visible',
|
||||
'transition-transform duration-300 ease-in-out',
|
||||
isImmersiveRoute && userCollapsed
|
||||
? '-translate-x-full'
|
||||
@@ -1194,7 +1300,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
)}
|
||||
>
|
||||
{/* ── 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-5 shrink-0 select-none">
|
||||
<div className="w-[54px] border-e border-border/40 bg-[#FAF9F5] dark:bg-[#0E0E0E] flex flex-col items-center justify-between py-5 shrink-0 select-none overflow-hidden">
|
||||
|
||||
{/* Top : Logo + navigation */}
|
||||
<div className="flex flex-col items-center gap-[18px] w-full">
|
||||
@@ -1401,20 +1507,29 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
<div className="flex-1 h-full flex flex-col overflow-hidden bg-[#FCFCFA] dark:bg-[#111111]">
|
||||
|
||||
{/* ── Scrollable content ── */}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto space-y-6 -mx-0 custom-scrollbar pb-4">
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 flex flex-col min-h-0 -mx-0 pb-4',
|
||||
activeView === 'notebooks'
|
||||
? 'overflow-hidden'
|
||||
: 'overflow-y-auto custom-scrollbar space-y-6',
|
||||
)}
|
||||
>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{activeView === '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 className="px-4 pt-4 shrink-0">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<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">
|
||||
@@ -1506,48 +1621,54 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
</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',
|
||||
isInboxActive ? 'text-ink' : 'text-muted-ink',
|
||||
)}
|
||||
>
|
||||
{t('sidebar.inbox')}
|
||||
</span>
|
||||
</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"
|
||||
title={t('sidebar.dailyNoteTooltip') || "Ouvrir ou créer la note personnelle pour le journal d'aujourd'hui"}
|
||||
>
|
||||
<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
|
||||
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 */}
|
||||
@@ -1722,6 +1843,17 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
</div>
|
||||
|
||||
</div>{/* fin colonne 2 */}
|
||||
|
||||
{/* Poignée redimensionnement largeur sidebar (bord vers le contenu) */}
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={t('sidebar.resizeSidebar')}
|
||||
onPointerDown={handleSidebarWidthResizeStart}
|
||||
className="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"
|
||||
>
|
||||
<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
|
||||
|
||||
@@ -78,6 +78,8 @@
|
||||
"insightsPanelBody": "Semantic map of your notes: thematic clusters, bridge notes, and connection suggestions.",
|
||||
"revisionPanelBody": "Review flashcards with the SM-2 algorithm. Decks are generated from your notes.",
|
||||
"backToNotebooks": "Back to notebooks",
|
||||
"resizeNotebooksPanel": "Resize notebooks panel",
|
||||
"resizeSidebar": "Resize sidebar width",
|
||||
"dailyNote": "Daily Note",
|
||||
"dailyNoteError": "Could not open today's note",
|
||||
"dailyNoteTooltip": "Open or create your personal daily journal note for today"
|
||||
|
||||
@@ -78,6 +78,8 @@
|
||||
"insightsPanelBody": "Cartographie sémantique de vos notes : clusters thématiques, notes-ponts et suggestions de connexion.",
|
||||
"revisionPanelBody": "Révisez vos flashcards avec l'algorithme SM-2. Les decks sont générés depuis vos notes.",
|
||||
"backToNotebooks": "Retour aux carnets",
|
||||
"resizeNotebooksPanel": "Redimensionner la zone des carnets",
|
||||
"resizeSidebar": "Redimensionner la largeur de la barre latérale",
|
||||
"dailyNote": "Note du jour",
|
||||
"dailyNoteError": "Impossible d'ouvrir la note du jour",
|
||||
"dailyNoteTooltip": "Ouvrir ou créer la note personnelle pour le journal d'aujourd'hui"
|
||||
|
||||
Reference in New Issue
Block a user