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:
@@ -4,7 +4,7 @@ import { useState, useCallback, useRef } from 'react'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { cn } from '@/lib/utils'
|
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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
import { useNotebookDrag } from '@/context/notebook-drag-context'
|
import { useNotebookDrag } from '@/context/notebook-drag-context'
|
||||||
@@ -35,7 +35,7 @@ export function NotebooksList() {
|
|||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { t, language } = useLanguage()
|
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 { draggedNoteId, dragOverNotebookId, dragOver } = useNotebookDrag()
|
||||||
const { labels } = useLabels()
|
const { labels } = useLabels()
|
||||||
|
|
||||||
@@ -46,29 +46,77 @@ export function NotebooksList() {
|
|||||||
const [expandedNotebook, setExpandedNotebook] = useState<string | null>(null)
|
const [expandedNotebook, setExpandedNotebook] = useState<string | null>(null)
|
||||||
const [labelsDialogOpen, setLabelsDialogOpen] = useState(false)
|
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')
|
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) => {
|
const handleDrop = useCallback(async (e: React.DragEvent, notebookId: string | null) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
|
const sourceNbId = e.dataTransfer.getData('application/x-notebook')
|
||||||
const noteId = e.dataTransfer.getData('text/plain')
|
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)
|
await moveNoteToNotebookOptimistic(noteId, notebookId)
|
||||||
}
|
}
|
||||||
|
|
||||||
dragOver(null)
|
dragOver(null)
|
||||||
}, [moveNoteToNotebookOptimistic, dragOver])
|
draggingNbRef.current = null
|
||||||
|
setDraggingNbId(null)
|
||||||
|
setOverNbId(null)
|
||||||
|
}, [notebooks, moveNoteToNotebookOptimistic, updateNotebookOrderOptimistic, dragOver])
|
||||||
|
|
||||||
// Handle drag over a notebook
|
// Handle drag over a notebook
|
||||||
const handleDragOver = useCallback((e: React.DragEvent, notebookId: string | null) => {
|
const handleDragOver = useCallback((e: React.DragEvent, notebookId: string | null) => {
|
||||||
e.preventDefault()
|
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])
|
}, [dragOver])
|
||||||
|
|
||||||
// Handle drag leave
|
// Handle drag leave
|
||||||
const handleDragLeave = useCallback(() => {
|
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(null)
|
||||||
}, [dragOver])
|
}, [dragOver])
|
||||||
|
|
||||||
@@ -142,7 +190,20 @@ export function NotebooksList() {
|
|||||||
const NotebookIcon = getNotebookIcon(notebook.icon || 'folder')
|
const NotebookIcon = getNotebookIcon(notebook.icon || 'folder')
|
||||||
|
|
||||||
return (
|
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 ? (
|
{isActive ? (
|
||||||
// Active notebook with expanded labels
|
// Active notebook with expanded labels
|
||||||
<div
|
<div
|
||||||
@@ -252,10 +313,11 @@ export function NotebooksList() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleSelectNotebook(notebook.id)}
|
onClick={() => handleSelectNotebook(notebook.id)}
|
||||||
className={cn(
|
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"
|
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" />
|
<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">
|
<span className="text-[15px] font-medium tracking-wide text-start truncate min-w-0 flex-1">
|
||||||
{notebook.name}
|
{notebook.name}
|
||||||
|
|||||||
Reference in New Issue
Block a user