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

This commit is contained in:
Antigravity
2026-05-10 07:30:50 +00:00
parent 43a18c0123
commit 539c72cf6d
2 changed files with 119 additions and 93 deletions

View File

@@ -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>
) )
} }

View File

@@ -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>
) : ( ) : (