Files
Momento/memento-note/components/notebooks-list.tsx
Antigravity 91b1201112 refactor: split NoteEditor into focused components + consolidate contexts
Phase 1: NoteEditor Split (64KB → 9 focused components)
- components/note-editor/: types.ts, context, toolbar, title-block,
  content-area, metadata-section, full-page, dialog compositions
- Maintains backwards compatibility via re-export from note-editor.tsx

Phase 2: Context Consolidation (5 → 3 contexts)
- NotebooksContext absorbs LabelContext (labels CRUD)
- EditorUIContext merges HomeViewContext + NotebookDragContext
- Removed: LabelContext, home-view-context, notebook-drag-context

Phase 3: React Query Infrastructure
- Added QueryProvider with @tanstack/react-query
- lib/query-keys.ts: centralized query key definitions
- lib/query-hooks.ts: useNotes, useNotebooksQuery, useLabelsQuery
- lib/use-refresh.ts: hybrid invalidateQueries + triggerRefresh helper
- NotebooksContext: invalidateQueries on mutations (with triggerRefresh fallback)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:31:08 +00:00

399 lines
17 KiB
TypeScript

'use client'
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, GripVertical } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useNotebooks } from '@/context/notebooks-context'
import { useEditorUI } from '@/context/editor-ui-context'
import { Button } from '@/components/ui/button'
import { CreateNotebookDialog } from './create-notebook-dialog'
import { NotebookActions } from './notebook-actions'
import { DeleteNotebookDialog } from './delete-notebook-dialog'
import { EditNotebookDialog } from './edit-notebook-dialog'
import { NotebookSummaryDialog } from './notebook-summary-dialog'
import { useLanguage } from '@/lib/i18n'
import { LabelManagementDialog } from '@/components/label-management-dialog'
import { Notebook } from '@/lib/types'
import { getNotebookIcon } from '@/lib/notebook-icon'
function NotebookName({ children }: { name: string; children: React.ReactNode }) {
return (
<span className="relative truncate min-w-0 group-hover:overflow-visible group-hover:text-nowrap">
<span className="group-hover:font-bold group-hover:relative group-hover:z-20 group-hover:inline-block group-hover:bg-white dark:group-hover:bg-[#1e2128] group-hover:pr-4 group-hover:shadow-[4px_0_12px_8px] group-hover:shadow-white dark:group-hover:shadow-[#1e2128]">
{children}
</span>
</span>
)
}
export function NotebooksList() {
const pathname = usePathname()
const searchParams = useSearchParams()
const router = useRouter()
const { t, language } = useLanguage()
const { notebooks, currentNotebook, deleteNotebook, moveNoteToNotebookOptimistic, updateNotebookOrderOptimistic, isLoading, labels } = useNotebooks()
const { draggedNoteId, dragOverNotebookId, dragOver } = useEditorUI()
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [editingNotebook, setEditingNotebook] = useState<Notebook | null>(null)
const [deletingNotebook, setDeletingNotebook] = useState<Notebook | null>(null)
const [summaryNotebook, setSummaryNotebook] = useState<Notebook | null>(null)
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 (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 (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)
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()
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])
const handleSelectNotebook = (notebookId: string | null) => {
const params = new URLSearchParams(searchParams)
if (notebookId) {
params.set('notebook', notebookId)
} else {
params.delete('notebook')
}
// Clear other filters
params.delete('labels')
params.delete('search')
router.push(`/?${params.toString()}`)
}
const handleToggleExpand = (notebookId: string) => {
setExpandedNotebook((prev) => (prev === notebookId ? null : notebookId))
}
const handleLabelFilter = (labelName: string, notebookId: string) => {
const params = new URLSearchParams(searchParams)
const currentLabels = params.get('labels')?.split(',').filter(Boolean) || []
if (currentLabels.includes(labelName)) {
params.set('labels', currentLabels.filter((l: string) => l !== labelName).join(','))
} else {
params.set('labels', [...currentLabels, labelName].join(','))
}
params.set('notebook', notebookId)
router.push(`/?${params.toString()}`)
}
if (isLoading) {
return (
<div className="my-2">
<div className="px-4 py-2">
<div className="text-xs text-gray-500">{t('common.loading')}</div>
</div>
</div>
)
}
return (
<>
<LabelManagementDialog open={labelsDialogOpen} onOpenChange={setLabelsDialogOpen} />
<div className="flex flex-col pt-1">
{/* Header with Add Button */}
<div className="group mt-1 flex cursor-pointer items-center justify-between px-3 py-2 text-muted-foreground hover:text-foreground">
<span className="text-xs font-semibold uppercase tracking-wider">{t('nav.notebooks') || 'NOTEBOOKS'}</span>
<button
onClick={() => setIsCreateDialogOpen(true)}
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-full transition-colors"
title={t('notebooks.create') || 'Create notebook'}
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Notebooks Loop */}
{notebooks.map((notebook: Notebook) => {
const isActive = currentNotebookId === notebook.id
const isExpanded = expandedNotebook === notebook.id
const isDragOver = dragOverNotebookId === notebook.id
// Get icon component
const NotebookIcon = getNotebookIcon(notebook.icon || 'folder')
return (
<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
onDrop={(e) => handleDrop(e, notebook.id)}
onDragOver={(e) => handleDragOver(e, notebook.id)}
onDragLeave={handleDragLeave}
className={cn(
"flex flex-col me-2 rounded-e-full transition-all relative",
!notebook.color && "bg-primary/10 dark:bg-primary/20",
isDragOver && "ring-2 ring-primary ring-dashed"
)}
style={notebook.color ? { backgroundColor: `${notebook.color}20` } : undefined}
>
{/* Header */}
<div className="pointer-events-auto flex items-center justify-between px-3 py-3">
<div className="flex items-center gap-4 min-w-0 flex-1">
<NotebookIcon
className={cn("w-5 h-5 flex-shrink-0 fill-current", !notebook.color && "text-primary dark:text-primary-foreground")}
style={notebook.color ? { color: notebook.color } : undefined}
/>
<NotebookName name={notebook.name}>
<span
className={cn("text-[15px] font-medium tracking-wide", !notebook.color && "text-primary dark:text-primary-foreground")}
style={notebook.color ? { color: notebook.color } : undefined}
>
{notebook.name}
</span>
</NotebookName>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{/* Actions menu for active notebook */}
<NotebookActions
notebook={notebook}
onEdit={() => setEditingNotebook(notebook)}
onDelete={() => setDeletingNotebook(notebook)}
onSummary={() => setSummaryNotebook(notebook)}
/>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleToggleExpand(notebook.id)
}}
className={cn(
"shrink-0 rounded-full p-1 transition-colors",
!notebook.color &&
"text-primary hover:text-primary/80 dark:text-primary-foreground dark:hover:text-primary-foreground/80"
)}
style={notebook.color ? { color: notebook.color } : undefined}
aria-expanded={isExpanded}
>
<ChevronDown className={cn("h-4 w-4 transition-transform", isExpanded && "rotate-180")} />
</button>
</div>
</div>
{/* Contextual Labels Tree */}
{isExpanded && (
<div className="flex flex-col pb-2">
{labels.length === 0 ? (
<p className="pointer-events-none ps-12 pe-4 py-2 text-xs text-muted-foreground">
{t('sidebar.noLabelsInNotebook')}
</p>
) : (
labels.map((label: any) => (
<button
key={label.id}
type="button"
onClick={() => handleLabelFilter(label.name, notebook.id)}
className={cn(
'pointer-events-auto flex items-center gap-4 ps-12 pe-4 py-2 rounded-e-full me-2 transition-colors',
'hover:bg-accent/60 text-muted-foreground hover:text-foreground',
searchParams.get('labels')?.includes(label.name) &&
'font-semibold text-foreground'
)}
>
<Tag className="h-4 w-4 shrink-0" />
<span className="text-xs font-medium truncate">{label.name}</span>
</button>
))
)}
<button
type="button"
onClick={() => setLabelsDialogOpen(true)}
className="pointer-events-auto flex items-center gap-2 ps-12 pe-4 py-2 mt-1 rounded-e-full me-2 transition-colors text-muted-foreground hover:text-foreground hover:bg-accent/60 group/label"
>
<Plus className="h-3 w-3 shrink-0 group-hover/label:scale-110 transition-transform" />
<span className="text-xs font-medium">{t('sidebar.editLabels')}</span>
</button>
</div>
)}
</div>
) : (
// Inactive notebook
<div
onDrop={(e) => handleDrop(e, notebook.id)}
onDragOver={(e) => handleDragOver(e, notebook.id)}
onDragLeave={handleDragLeave}
className={cn(
"flex items-center relative",
isDragOver && "ring-2 ring-primary ring-dashed rounded-e-full me-2"
)}
>
<TooltipProvider delayDuration={600}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => handleSelectNotebook(notebook.id)}
className={cn(
"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}
</span>
{(notebook as any).notesCount > 0 && (
<span className="text-xs text-gray-400 ms-auto flex-shrink-0">({new Intl.NumberFormat(language).format((notebook as any).notesCount)})</span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="right" className="text-sm font-medium">
{notebook.name}
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Actions + expand on the right — always rendered, visible on hover */}
<div className="absolute end-3 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<NotebookActions
notebook={notebook}
onEdit={() => setEditingNotebook(notebook)}
onDelete={() => setDeletingNotebook(notebook)}
onSummary={() => setSummaryNotebook(notebook)}
/>
<button
onClick={(e) => { e.stopPropagation(); handleToggleExpand(notebook.id); }}
className={cn(
"p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors rounded-full hover:bg-gray-200 dark:hover:bg-gray-700",
expandedNotebook === notebook.id && "rotate-180"
)}
>
<ChevronDown className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
)
})}
</div>
{/* Create Notebook Dialog */}
<CreateNotebookDialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
/>
{/* Edit Notebook Dialog */}
{editingNotebook && (
<EditNotebookDialog
notebook={editingNotebook}
open={!!editingNotebook}
onOpenChange={(open) => {
if (!open) setEditingNotebook(null)
}}
/>
)}
{/* Delete Confirmation Dialog */}
{deletingNotebook && (
<DeleteNotebookDialog
notebook={deletingNotebook}
open={!!deletingNotebook}
onOpenChange={(open) => {
if (!open) setDeletingNotebook(null)
}}
/>
)}
{/* Notebook Summary Dialog */}
<NotebookSummaryDialog
open={!!summaryNotebook}
onOpenChange={(open) => {
if (!open) setSummaryNotebook(null)
}}
notebookId={summaryNotebook?.id ?? null}
notebookName={summaryNotebook?.name}
/>
</>
)
}