feat: sidebar tree with visual guides, hover '+' for sub-notebook, collapse/expand, notebook view 'New Sub-Carnet' button
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m32s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m32s
This commit is contained in:
@@ -11,7 +11,7 @@ import { NotesEditorialView } from '@/components/notes-editorial-view'
|
|||||||
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
|
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
|
||||||
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Plus, ArrowUpDown, Search, Sparkles, FileText } from 'lucide-react'
|
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen } from 'lucide-react'
|
||||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||||
import { useRefresh } from '@/lib/use-refresh'
|
import { useRefresh } from '@/lib/use-refresh'
|
||||||
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
||||||
@@ -21,6 +21,7 @@ import { cn } from '@/lib/utils'
|
|||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { useEditorUI } from '@/context/editor-ui-context'
|
import { useEditorUI } from '@/context/editor-ui-context'
|
||||||
import { NoteHistoryModal } from '@/components/note-history-modal'
|
import { NoteHistoryModal } from '@/components/note-history-modal'
|
||||||
|
import { CreateNotebookDialog } from '@/components/create-notebook-dialog'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { AnimatePresence, motion } from 'motion/react'
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion()
|
const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion()
|
||||||
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
|
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
|
||||||
const [summaryDialogOpen, setSummaryDialogOpen] = useState(false)
|
const [summaryDialogOpen, setSummaryDialogOpen] = useState(false)
|
||||||
|
const [createSubNotebookOpen, setCreateSubNotebookOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (shouldSuggestLabels && suggestNotebookId) {
|
if (shouldSuggestLabels && suggestNotebookId) {
|
||||||
@@ -424,6 +426,16 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
<span>{t('notes.newNote') || 'Add Note'}</span>
|
<span>{t('notes.newNote') || 'Add Note'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{currentNotebook && (
|
||||||
|
<button
|
||||||
|
onClick={() => setCreateSubNotebookOpen(true)}
|
||||||
|
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
|
||||||
|
>
|
||||||
|
<FolderOpen size={16} />
|
||||||
|
<span>{t('notebook.createSubNotebook') || 'Nouveau sous-carnet'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Inline search — toggles an input within the toolbar */}
|
{/* Inline search — toggles an input within the toolbar */}
|
||||||
{showInlineSearch ? (
|
{showInlineSearch ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -638,6 +650,13 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
notebookName={currentNotebook?.name}
|
notebookName={currentNotebook?.name}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{searchParams.get('notebook') && (
|
||||||
|
<CreateNotebookDialog
|
||||||
|
open={createSubNotebookOpen}
|
||||||
|
onOpenChange={setCreateSubNotebookOpen}
|
||||||
|
parentNotebookId={searchParams.get('notebook')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,23 +82,30 @@ function SidebarCarnetItem({
|
|||||||
activeNoteId,
|
activeNoteId,
|
||||||
onCarnetClick,
|
onCarnetClick,
|
||||||
onNoteClick,
|
onNoteClick,
|
||||||
|
onAddSubNotebook,
|
||||||
children,
|
children,
|
||||||
isDragging,
|
isDragging,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
|
depth = 0,
|
||||||
}: {
|
}: {
|
||||||
carnet: { id: string; name: string; initial: string; isPrivate?: boolean }
|
carnet: { id: string; name: string; initial: string; isPrivate?: boolean; hasChildren?: boolean }
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
notes: { id: string; title: string }[]
|
notes: { id: string; title: string }[]
|
||||||
activeNoteId: string | null
|
activeNoteId: string | null
|
||||||
onCarnetClick: () => void
|
onCarnetClick: () => void
|
||||||
onNoteClick: (noteId: string, carnetId: string) => void
|
onNoteClick: (noteId: string, carnetId: string) => void
|
||||||
|
onAddSubNotebook?: () => void
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
isDragging?: boolean
|
isDragging?: boolean
|
||||||
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>
|
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>
|
||||||
|
depth?: number
|
||||||
}) {
|
}) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const showContent = isActive || expanded
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('space-y-1 transition-opacity', isDragging && 'opacity-40')}>
|
<div className={cn('transition-opacity', isDragging && 'opacity-40')}>
|
||||||
<div className="relative group/carnet">
|
<div className="relative group/carnet">
|
||||||
<div
|
<div
|
||||||
{...dragHandleProps}
|
{...dragHandleProps}
|
||||||
@@ -108,63 +115,78 @@ function SidebarCarnetItem({
|
|||||||
<GripVertical size={12} />
|
<GripVertical size={12} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.button
|
<div
|
||||||
whileHover={{ x: 4 }}
|
|
||||||
onClick={onCarnetClick}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group',
|
'w-full flex items-center gap-3 px-4 py-2.5 rounded-xl transition-all duration-200 group cursor-pointer',
|
||||||
isActive ? 'memento-active-nav' : 'hover:bg-white/40'
|
isActive ? 'memento-active-nav' : 'hover:bg-white/40'
|
||||||
)}
|
)}
|
||||||
|
onClick={onCarnetClick}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ rotate: isActive ? 90 : 0 }}
|
animate={{ rotate: showContent ? 90 : 0 }}
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground shrink-0"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setExpanded(v => !v) }}
|
||||||
>
|
>
|
||||||
<ChevronRight size={14} />
|
<ChevronRight size={14} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{depth > 0 && (
|
||||||
|
<div className="w-px h-4 bg-border/50 shrink-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
|
'w-7 h-7 rounded-full flex items-center justify-center text-xs font-medium border shrink-0',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-foreground text-background border-foreground'
|
? 'bg-foreground text-background border-foreground'
|
||||||
: 'bg-white/60 text-foreground border-border'
|
: 'bg-white/60 text-foreground border-border'
|
||||||
)}>
|
)}>
|
||||||
{carnet.initial}
|
{carnet.initial}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 text-left min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
<span className={cn(
|
||||||
<span className={cn(
|
'text-[13px] font-medium transition-colors truncate flex-1',
|
||||||
'text-[13px] font-medium transition-colors truncate',
|
isActive ? 'text-foreground' : 'text-muted-foreground'
|
||||||
isActive ? 'text-foreground' : 'text-muted-foreground'
|
)}>
|
||||||
)}>
|
{carnet.name}
|
||||||
{carnet.name}
|
</span>
|
||||||
</span>
|
|
||||||
{carnet.isPrivate && <Lock size={10} className="text-muted-foreground shrink-0" />}
|
{carnet.isPrivate && <Lock size={10} className="text-muted-foreground shrink-0" />}
|
||||||
</div>
|
|
||||||
</div>
|
{onAddSubNotebook && (
|
||||||
</motion.button>
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onAddSubNotebook() }}
|
||||||
|
className="p-1 rounded-md text-muted-foreground/30 hover:text-foreground hover:bg-white/60 opacity-0 group-hover:opacity-100 transition-all shrink-0"
|
||||||
|
title={t('notebook.createSubNotebook') || 'Nouveau sous-carnet'}
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isActive && (
|
{showContent && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ height: 0, opacity: 0 }}
|
initial={{ height: 0, opacity: 0 }}
|
||||||
animate={{ height: 'auto', opacity: 1 }}
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
exit={{ height: 0, opacity: 0 }}
|
exit={{ height: 0, opacity: 0 }}
|
||||||
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}
|
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||||
className="overflow-hidden space-y-0.5"
|
className="overflow-hidden"
|
||||||
>
|
>
|
||||||
{children}
|
<div className={cn(depth > 0 && 'ml-4 border-l border-border/30 pl-2')}>
|
||||||
{notes.map(note => (
|
{children}
|
||||||
<NoteLink
|
{notes.map(note => (
|
||||||
key={note.id}
|
<NoteLink
|
||||||
title={note.title}
|
key={note.id}
|
||||||
isActive={activeNoteId === note.id}
|
title={note.title}
|
||||||
onClick={() => onNoteClick(note.id, carnet.id)}
|
isActive={activeNoteId === note.id}
|
||||||
/>
|
onClick={() => onNoteClick(note.id, carnet.id)}
|
||||||
))}
|
/>
|
||||||
{notes.length === 0 && !children && (
|
))}
|
||||||
<p className="pl-12 text-[11px] text-muted-foreground/50 py-2 italic font-light">{t('common.noResults')}</p>
|
{notes.length === 0 && !children && (
|
||||||
)}
|
<p className="pl-10 text-[11px] text-muted-foreground/50 py-2 italic font-light">{t('common.noResults')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -446,14 +468,22 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
<p className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase">
|
<p className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase">
|
||||||
{t('nav.notebooks')}
|
{t('nav.notebooks')}
|
||||||
</p>
|
</p>
|
||||||
<div className="relative">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSortMenu(s => !s)}
|
onClick={() => { setCreateParentId(null); setIsCreateDialogOpen(true) }}
|
||||||
className="p-1 text-muted-foreground hover:text-foreground transition-colors rounded"
|
className="p-1 text-muted-foreground hover:text-foreground hover:bg-white/40 transition-all rounded"
|
||||||
title={t('sidebar.sortOrder')}
|
title={t('notebook.create') || 'Nouveau carnet'}
|
||||||
>
|
>
|
||||||
<ArrowUpDown size={12} />
|
<Plus size={12} />
|
||||||
</button>
|
</button>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSortMenu(s => !s)}
|
||||||
|
className="p-1 text-muted-foreground hover:text-foreground transition-colors rounded"
|
||||||
|
title={t('sidebar.sortOrder')}
|
||||||
|
>
|
||||||
|
<ArrowUpDown size={12} />
|
||||||
|
</button>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showSortMenu && (
|
{showSortMenu && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -479,6 +509,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -558,7 +589,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
|
|
||||||
{/* Notebooks list — draggable */}
|
{/* Notebooks list — draggable */}
|
||||||
<div
|
<div
|
||||||
className="space-y-1"
|
className="space-y-0.5"
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
@@ -573,12 +604,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
<motion.div
|
<motion.div
|
||||||
key={notebook.id}
|
key={notebook.id}
|
||||||
layout
|
layout
|
||||||
transition={{
|
transition={{ type: 'spring', stiffness: 300, damping: 30, mass: 0.8 }}
|
||||||
type: 'spring',
|
|
||||||
stiffness: 300,
|
|
||||||
damping: 30,
|
|
||||||
mass: 0.8
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
draggable
|
draggable
|
||||||
@@ -591,64 +617,45 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
id: notebook.id,
|
id: notebook.id,
|
||||||
name: notebook.name,
|
name: notebook.name,
|
||||||
initial: notebook.name.charAt(0).toUpperCase(),
|
initial: notebook.name.charAt(0).toUpperCase(),
|
||||||
|
hasChildren: children.length > 0,
|
||||||
}}
|
}}
|
||||||
isActive={isExpanded}
|
isActive={isExpanded}
|
||||||
notes={notes}
|
notes={notes}
|
||||||
activeNoteId={currentNoteId}
|
activeNoteId={currentNoteId}
|
||||||
onCarnetClick={() => handleCarnetClick(notebook.id)}
|
onCarnetClick={() => handleCarnetClick(notebook.id)}
|
||||||
onNoteClick={handleNoteClick}
|
onNoteClick={handleNoteClick}
|
||||||
|
onAddSubNotebook={() => {
|
||||||
|
setCreateParentId(notebook.id)
|
||||||
|
setIsCreateDialogOpen(true)
|
||||||
|
}}
|
||||||
isDragging={isDragging}
|
isDragging={isDragging}
|
||||||
|
depth={0}
|
||||||
>
|
>
|
||||||
{children.length > 0 && (
|
{children.map(child => {
|
||||||
<div className="pl-4 space-y-1 mt-1">
|
const childActive = currentNotebookId === child.id
|
||||||
{children.map(child => {
|
const childNotes = notebookNotes[child.id] || []
|
||||||
const childActive = currentNotebookId === child.id
|
return (
|
||||||
const childNotes = notebookNotes[child.id] || []
|
<SidebarCarnetItem
|
||||||
return (
|
key={child.id}
|
||||||
<div key={child.id}>
|
carnet={{
|
||||||
<SidebarCarnetItem
|
id: child.id,
|
||||||
carnet={{
|
name: child.name,
|
||||||
id: child.id,
|
initial: child.name.charAt(0).toUpperCase(),
|
||||||
name: child.name,
|
|
||||||
initial: child.name.charAt(0).toUpperCase(),
|
|
||||||
}}
|
|
||||||
isActive={childActive}
|
|
||||||
notes={childNotes}
|
|
||||||
activeNoteId={currentNoteId}
|
|
||||||
onCarnetClick={() => handleCarnetClick(child.id)}
|
|
||||||
onNoteClick={handleNoteClick}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setCreateParentId(notebook.id)
|
|
||||||
setIsCreateDialogOpen(true)
|
|
||||||
}}
|
}}
|
||||||
className="w-full flex items-center gap-2 pl-12 pr-4 py-2 text-[11px] text-muted-foreground/50 hover:text-muted-foreground transition-colors"
|
isActive={childActive}
|
||||||
>
|
notes={childNotes}
|
||||||
<Plus size={12} />
|
activeNoteId={currentNoteId}
|
||||||
<span>{t('notebook.createSubNotebook') || 'Sous-carnet'}</span>
|
onCarnetClick={() => handleCarnetClick(child.id)}
|
||||||
</button>
|
onNoteClick={handleNoteClick}
|
||||||
</div>
|
depth={1}
|
||||||
)}
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</SidebarCarnetItem>
|
</SidebarCarnetItem>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setCreateParentId(null)
|
|
||||||
setIsCreateDialogOpen(true)
|
|
||||||
}}
|
|
||||||
className="w-full mt-4 flex items-center gap-3 px-4 py-2 text-[13px] text-muted-foreground hover:text-foreground transition-colors font-medium rounded-lg hover:bg-white/40"
|
|
||||||
>
|
|
||||||
<Plus size={16} />
|
|
||||||
<span>{t('notebook.create')}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user