feat: icon-only toolbar, versioning fixes, history modal, PanelRight repositioning
- Toolbar: remove text labels from all icon buttons (AI, Save, Preview, Convert) all buttons now icon-only with title tooltip for accessibility - Toolbar: reposition PanelRight (info panel toggle) to far right after three-dot menu - Versioning: decouple getNoteHistory/restoreNoteVersion from global userAISettings.noteHistory now checks note.historyEnabled directly — unblocks manual per-note history - Versioning: add 'Sauvegarder cette version' button in Versions tab of info panel calls commitNoteHistory with visual feedback (spinner → success state) - note-document-info-panel: import commitNoteHistory, add isSavingVersion state - notes.ts: fix double guard that silently blocked all history operations
This commit is contained in:
@@ -20,10 +20,10 @@ import {
|
||||
User,
|
||||
LogOut,
|
||||
Shield,
|
||||
GripVertical,
|
||||
} from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useNotebooksQuery } from '@/lib/query-hooks'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { getAllNotes } from '@/app/actions/notes'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||
|
||||
type NavigationView = 'notebooks' | 'agents'
|
||||
type SortOrder = 'newest' | 'oldest' | 'alpha'
|
||||
@@ -79,51 +80,65 @@ function SidebarCarnetItem({
|
||||
activeNoteId,
|
||||
onCarnetClick,
|
||||
onNoteClick,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
carnet: { id: string; name: string; initial: string; isPrivate?: boolean }
|
||||
isActive: boolean
|
||||
/** Notes for this carnet — always passed (like architectural-grid ref); visibility toggled by isActive */
|
||||
notes: { id: string; title: string }[]
|
||||
activeNoteId: string | null
|
||||
onCarnetClick: () => void
|
||||
onNoteClick: (noteId: string, carnetId: string) => void
|
||||
isDragging?: boolean
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<motion.button
|
||||
whileHover={{ x: 4 }}
|
||||
onClick={onCarnetClick}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group',
|
||||
isActive ? 'memento-active-nav' : 'hover:bg-white/40'
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: isActive ? 90 : 0 }}
|
||||
className="text-muted-foreground"
|
||||
<div className={cn('space-y-1 transition-opacity', isDragging && 'opacity-40')}>
|
||||
<div className="relative group/carnet">
|
||||
{/* Drag handle — visible on hover */}
|
||||
<div
|
||||
{...dragHandleProps}
|
||||
className="absolute left-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/carnet:opacity-100 transition-opacity z-10"
|
||||
title="Déplacer"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</motion.div>
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
|
||||
isActive
|
||||
? 'bg-foreground text-background border-foreground'
|
||||
: 'bg-white/60 text-foreground border-border'
|
||||
)}>
|
||||
{carnet.initial}
|
||||
<GripVertical size={12} />
|
||||
</div>
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
'text-[13px] font-medium transition-colors truncate',
|
||||
isActive ? 'text-foreground' : 'text-muted-foreground'
|
||||
)}>
|
||||
{carnet.name}
|
||||
</span>
|
||||
{carnet.isPrivate && <Lock size={10} className="text-muted-foreground shrink-0" />}
|
||||
|
||||
<motion.button
|
||||
whileHover={{ x: 4 }}
|
||||
onClick={onCarnetClick}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group',
|
||||
isActive ? 'memento-active-nav' : 'hover:bg-white/40'
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: isActive ? 90 : 0 }}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</motion.div>
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
|
||||
isActive
|
||||
? 'bg-foreground text-background border-foreground'
|
||||
: 'bg-white/60 text-foreground border-border'
|
||||
)}>
|
||||
{carnet.initial}
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
'text-[13px] font-medium transition-colors truncate',
|
||||
isActive ? 'text-foreground' : 'text-muted-foreground'
|
||||
)}>
|
||||
{carnet.name}
|
||||
</span>
|
||||
{carnet.isPrivate && <Lock size={10} className="text-muted-foreground shrink-0" />}
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isActive && (
|
||||
@@ -157,17 +172,24 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
const { notebooks } = useNotebooks()
|
||||
const { notebooks, updateNotebookOrderOptimistic } = useNotebooks()
|
||||
const { refreshKey } = useNoteRefresh()
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
const [notebookNotes, setNotebookNotes] = useState<Record<string, { id: string; title: string }[]>>({})
|
||||
const [activeView, setActiveView] = useState<NavigationView>('notebooks')
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
|
||||
const [showSortMenu, setShowSortMenu] = useState(false)
|
||||
|
||||
// ── Drag state ──
|
||||
const [draggedId, setDraggedId] = useState<string | null>(null)
|
||||
const [orderedNotebooks, setOrderedNotebooks] = useState<Notebook[]>([])
|
||||
const dragOverId = useRef<string | null>(null)
|
||||
// Prevents the sync effect from overwriting a just-saved drag order
|
||||
const isSavingRef = useRef(false)
|
||||
|
||||
const currentNotebookId = searchParams.get('notebook')
|
||||
const currentNoteId = searchParams.get('openNote')
|
||||
|
||||
// Determine if inbox is active (no notebook filter, on home page)
|
||||
const isInboxActive =
|
||||
pathname === '/' &&
|
||||
!searchParams.get('notebook') &&
|
||||
@@ -175,7 +197,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
!searchParams.get('archived') &&
|
||||
!searchParams.get('trashed')
|
||||
|
||||
// Sync toggle with route (fixes staying on "Agents" tab after navigating home)
|
||||
useEffect(() => {
|
||||
setActiveView(
|
||||
pathname.startsWith('/agents') || pathname.startsWith('/lab') ? 'agents' : 'notebooks'
|
||||
@@ -185,12 +206,23 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
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 === '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])
|
||||
|
||||
const notebookIdsKey = useMemo(() => notebooks.map(nb => nb.id).sort().join(','), [notebooks])
|
||||
|
||||
/** Load note titles for every notebook (like ref: filter per carnet).
|
||||
* Refetch when notebooks list changes (added/removed/reordered).
|
||||
* Note: individual note changes (create/edit/delete) don't need to trigger this
|
||||
* because React Query cache handles invalidation separately. */
|
||||
useEffect(() => {
|
||||
if (!notebookIdsKey) return
|
||||
let cancelled = false
|
||||
@@ -209,16 +241,13 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
setNotebookNotes(Object.fromEntries(mappedEntries))
|
||||
}
|
||||
load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [notebookIdsKey, notebooks, t])
|
||||
return () => { cancelled = true }
|
||||
// refreshKey: reload note titles whenever any note is saved/created/deleted
|
||||
}, [notebookIdsKey, refreshKey, t])
|
||||
|
||||
// BUG FIX: clicking a carnet always forces list (editorial) view
|
||||
const handleCarnetClick = (notebookId: string) => {
|
||||
const params = new URLSearchParams()
|
||||
params.set('notebook', notebookId)
|
||||
// forceList resets to editorial view in home-client
|
||||
params.set('forceList', '1')
|
||||
router.push(`/?${params.toString()}`)
|
||||
}
|
||||
@@ -235,13 +264,63 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
router.push(`/?${params.toString()}`)
|
||||
}
|
||||
|
||||
// Sort notebooks
|
||||
const sortedNotebooks = [...notebooks].sort((a: Notebook, b: Notebook) => {
|
||||
if (sortOrder === 'alpha') return a.name.localeCompare(b.name)
|
||||
if (sortOrder === 'newest') return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
if (sortOrder === 'oldest') return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
return 0
|
||||
})
|
||||
// ── Drag handlers ──
|
||||
const handleDragStart = (e: React.DragEvent, notebookId: string) => {
|
||||
setDraggedId(notebookId)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, notebookId: string) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
if (dragOverId.current === notebookId) return
|
||||
dragOverId.current = notebookId
|
||||
|
||||
if (!draggedId || draggedId === notebookId) return
|
||||
setOrderedNotebooks(prev => {
|
||||
const fromIdx = prev.findIndex(n => n.id === draggedId)
|
||||
const toIdx = prev.findIndex(n => n.id === notebookId)
|
||||
if (fromIdx === -1 || toIdx === -1) return prev
|
||||
const next = [...prev]
|
||||
const [item] = next.splice(fromIdx, 1)
|
||||
next.splice(toIdx, 0, item)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
if (!draggedId) return
|
||||
const savedOrder = [...orderedNotebooks]
|
||||
setDraggedId(null)
|
||||
dragOverId.current = null
|
||||
// Block the sync effect so the server reload doesn't overwrite local order
|
||||
isSavingRef.current = true
|
||||
try {
|
||||
await updateNotebookOrderOptimistic(savedOrder.map(n => n.id))
|
||||
// Keep local order — server will return them in the right order next load
|
||||
setOrderedNotebooks(savedOrder)
|
||||
} catch {
|
||||
// On failure, revert to original server order
|
||||
isSavingRef.current = false
|
||||
setOrderedNotebooks(sortedNotebooks)
|
||||
} finally {
|
||||
// Allow sync again after 2 s (time for the server reload to settle)
|
||||
setTimeout(() => {
|
||||
isSavingRef.current = false
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
if (draggedId) {
|
||||
// Drag cancelled without drop — restore
|
||||
setDraggedId(null)
|
||||
dragOverId.current = null
|
||||
isSavingRef.current = false
|
||||
setOrderedNotebooks(sortedNotebooks)
|
||||
}
|
||||
}
|
||||
|
||||
const sortLabels: Record<SortOrder, string> = {
|
||||
newest: t('sidebar.sortNewest') || 'Newest first',
|
||||
@@ -254,7 +333,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
<aside
|
||||
className={cn(
|
||||
'hidden h-full min-h-0 w-72 shrink-0 flex-col lg:w-80 md:flex',
|
||||
'border-e border-border/40 bg-white/30 backdrop-blur-md sidebar-shadow dark:border-border/30 dark:bg-sidebar/90',
|
||||
'border-e border-border/40 bg-white/30 backdrop-blur-md sidebar-shadow dark:border-white/6 dark:bg-[#252525] dark:backdrop-blur-none',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -311,22 +390,25 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Notebooks / Agents toggle */}
|
||||
<div className="sidebar-view-toggle">
|
||||
<button
|
||||
onClick={() => { setActiveView('notebooks'); if (pathname !== '/') router.push('/') }}
|
||||
className={cn('sidebar-view-toggle-btn', activeView === 'notebooks' && 'active')}
|
||||
title={t('nav.notebooks') || 'Notebooks'}
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveView('agents'); router.push('/agents') }}
|
||||
className={cn('sidebar-view-toggle-btn', activeView === 'agents' && 'active')}
|
||||
title={t('nav.agents') || 'Agents'}
|
||||
>
|
||||
<Bot size={14} />
|
||||
</button>
|
||||
{/* Notification bell + Notebooks / Agents toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationPanel />
|
||||
<div className="flex bg-white/50 p-1 rounded-full border border-border transition-all">
|
||||
<button
|
||||
onClick={() => { setActiveView('notebooks'); if (pathname !== '/') router.push('/') }}
|
||||
className={cn('p-1.5 rounded-full transition-all', activeView === 'notebooks' ? 'bg-foreground text-background' : 'text-muted-foreground hover:text-foreground')}
|
||||
title={t('nav.notebooks') || 'Notebooks'}
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveView('agents'); router.push('/agents') }}
|
||||
className={cn('p-1.5 rounded-full transition-all', activeView === 'agents' ? 'bg-foreground text-background' : 'text-muted-foreground hover:text-foreground')}
|
||||
title={t('nav.agents') || 'Agents'}
|
||||
>
|
||||
<Bot size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -407,25 +489,38 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
{/* Divider */}
|
||||
<div className="mx-4 my-3 h-px bg-border/40" />
|
||||
|
||||
{/* Notebooks list */}
|
||||
<div className="space-y-1">
|
||||
{sortedNotebooks.map((notebook: Notebook) => {
|
||||
{/* Notebooks list — draggable */}
|
||||
<div
|
||||
className="space-y-1"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
>
|
||||
{orderedNotebooks.map((notebook: Notebook) => {
|
||||
const isActive = currentNotebookId === notebook.id
|
||||
const notes = notebookNotes[notebook.id] || []
|
||||
const isDragging = draggedId === notebook.id
|
||||
return (
|
||||
<SidebarCarnetItem
|
||||
<div
|
||||
key={notebook.id}
|
||||
carnet={{
|
||||
id: notebook.id,
|
||||
name: notebook.name,
|
||||
initial: notebook.name.charAt(0).toUpperCase(),
|
||||
}}
|
||||
isActive={isActive}
|
||||
notes={notes}
|
||||
activeNoteId={currentNoteId}
|
||||
onCarnetClick={() => handleCarnetClick(notebook.id)}
|
||||
onNoteClick={handleNoteClick}
|
||||
/>
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, notebook.id)}
|
||||
onDragOver={(e) => handleDragOver(e, notebook.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SidebarCarnetItem
|
||||
carnet={{
|
||||
id: notebook.id,
|
||||
name: notebook.name,
|
||||
initial: notebook.name.charAt(0).toUpperCase(),
|
||||
}}
|
||||
isActive={isActive}
|
||||
notes={notes}
|
||||
activeNoteId={currentNoteId}
|
||||
onCarnetClick={() => handleCarnetClick(notebook.id)}
|
||||
onNoteClick={handleNoteClick}
|
||||
isDragging={isDragging}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -496,14 +591,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div className="pt-4 p-5 border-t border-border space-y-1">
|
||||
{/* Notifications */}
|
||||
<Link
|
||||
href="/notifications"
|
||||
className="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/30"
|
||||
>
|
||||
<NotificationPanel />
|
||||
<span>{t('notification.notifications') || 'Notifications'}</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/archive"
|
||||
className="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/30"
|
||||
|
||||
Reference in New Issue
Block a user