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:
Antigravity
2026-05-09 07:28:03 +00:00
parent 574c8b3166
commit 97b08e5d0b
65 changed files with 2991 additions and 2296 deletions

View File

@@ -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"