feat(sidebar): drag-and-drop notebook reordering

- Add GripVertical handle (visible on hover) as reorder affordance
- Rows are now draggable; uses 'application/x-notebook' dataTransfer type
  to coexist with existing note-to-notebook drag ('text/plain')
- Drop indicator: thin primary-colored line above the insertion target
- Dragged item fades to 40% opacity during drag
- On drop: calls updateNotebookOrderOptimistic → POST /api/notebooks/reorder → persisted in DB
- draggingNbRef (useRef) used for stale-closure-safe detection in dragover handler
This commit is contained in:
Antigravity
2026-05-03 21:06:34 +00:00
parent 0ebf10344d
commit 21fb56de3f

View File

@@ -4,7 +4,7 @@ import { useState, useCallback, useRef } from 'react'
import Link from 'next/link'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { cn } from '@/lib/utils'
import { StickyNote, Plus, Tag, Folder, ChevronDown, ChevronRight } from 'lucide-react'
import { StickyNote, Plus, Tag, Folder, ChevronDown, ChevronRight, GripVertical } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useNotebooks } from '@/context/notebooks-context'
import { useNotebookDrag } from '@/context/notebook-drag-context'
@@ -35,7 +35,7 @@ export function NotebooksList() {
const searchParams = useSearchParams()
const router = useRouter()
const { t, language } = useLanguage()
const { notebooks, currentNotebook, deleteNotebook, moveNoteToNotebookOptimistic, isLoading } = useNotebooks()
const { notebooks, currentNotebook, deleteNotebook, moveNoteToNotebookOptimistic, updateNotebookOrderOptimistic, isLoading } = useNotebooks()
const { draggedNoteId, dragOverNotebookId, dragOver } = useNotebookDrag()
const { labels } = useLabels()
@@ -46,29 +46,77 @@ export function NotebooksList() {
const [expandedNotebook, setExpandedNotebook] = useState<string | null>(null)
const [labelsDialogOpen, setLabelsDialogOpen] = useState(false)
// ── Notebook reorder drag state ──
const draggingNbRef = useRef<string | null>(null)
const [draggingNbId, setDraggingNbId] = useState<string | null>(null)
const [overNbId, setOverNbId] = useState<string | null>(null)
const currentNotebookId = searchParams.get('notebook')
// Handle drop on a notebook
// Handle drop on a notebook (note-to-notebook OR notebook reorder)
const handleDrop = useCallback(async (e: React.DragEvent, notebookId: string | null) => {
e.preventDefault()
e.stopPropagation()
const sourceNbId = e.dataTransfer.getData('application/x-notebook')
const noteId = e.dataTransfer.getData('text/plain')
if (noteId) {
if (sourceNbId && notebookId && sourceNbId !== notebookId) {
// ── Reorder notebooks ──
const currentIds = notebooks.map((nb: Notebook) => nb.id)
const fromIdx = currentIds.indexOf(sourceNbId)
const toIdx = currentIds.indexOf(notebookId)
if (fromIdx !== -1 && toIdx !== -1) {
const newIds = [...currentIds]
newIds.splice(fromIdx, 1)
newIds.splice(toIdx, 0, sourceNbId)
await updateNotebookOrderOptimistic(newIds)
}
} else if (noteId) {
// ── Move note to notebook ──
await moveNoteToNotebookOptimistic(noteId, notebookId)
}
dragOver(null)
}, [moveNoteToNotebookOptimistic, dragOver])
draggingNbRef.current = null
setDraggingNbId(null)
setOverNbId(null)
}, [notebooks, moveNoteToNotebookOptimistic, updateNotebookOrderOptimistic, dragOver])
// Handle drag over a notebook
const handleDragOver = useCallback((e: React.DragEvent, notebookId: string | null) => {
e.preventDefault()
dragOver(notebookId)
if (draggingNbRef.current) {
// Notebook reorder mode — just track the insertion target
setOverNbId(notebookId)
} else {
// Note-to-notebook mode
dragOver(notebookId)
}
}, [dragOver])
// Handle drag leave
const handleDragLeave = useCallback(() => {
if (draggingNbRef.current) {
setOverNbId(null)
} else {
dragOver(null)
}
}, [dragOver])
// ── Notebook reorder handlers ──
const handleNotebookDragStart = useCallback((e: React.DragEvent, notebookId: string) => {
e.dataTransfer.setData('application/x-notebook', notebookId)
e.dataTransfer.effectAllowed = 'move'
draggingNbRef.current = notebookId
// Slight delay so the drag ghost renders before opacity change
setTimeout(() => setDraggingNbId(notebookId), 0)
}, [])
const handleNotebookDragEnd = useCallback(() => {
draggingNbRef.current = null
setDraggingNbId(null)
setOverNbId(null)
dragOver(null)
}, [dragOver])
@@ -142,7 +190,20 @@ export function NotebooksList() {
const NotebookIcon = getNotebookIcon(notebook.icon || 'folder')
return (
<div key={notebook.id} className="group flex flex-col">
<div
key={notebook.id}
className={cn(
"group flex flex-col transition-opacity duration-150",
draggingNbId === notebook.id && "opacity-40"
)}
draggable
onDragStart={(e) => handleNotebookDragStart(e, notebook.id)}
onDragEnd={handleNotebookDragEnd}
>
{/* Insertion indicator above this notebook when reordering */}
{overNbId === notebook.id && draggingNbId !== notebook.id && (
<div className="h-0.5 bg-primary/70 mx-4 rounded-full mb-0.5 transition-all" />
)}
{isActive ? (
// Active notebook with expanded labels
<div
@@ -252,10 +313,11 @@ export function NotebooksList() {
<button
onClick={() => handleSelectNotebook(notebook.id)}
className={cn(
"pointer-events-auto flex items-center gap-4 px-6 py-3 rounded-e-full me-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800/50 transition-colors w-full pe-14",
"pointer-events-auto flex items-center gap-3 px-4 py-3 rounded-e-full me-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800/50 transition-colors w-full pe-14",
isDragOver && "opacity-50"
)}
>
<GripVertical className="w-3.5 h-3.5 flex-shrink-0 text-gray-300 dark:text-gray-600 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing" />
<NotebookIcon className="w-5 h-5 flex-shrink-0" />
<span className="text-[15px] font-medium tracking-wide text-start truncate min-w-0 flex-1">
{notebook.name}