Keep/keep-notes/components/notebooks-list.tsx

311 lines
13 KiB
TypeScript

'use client'
import { useState, useCallback } from 'react'
import Link from 'next/link'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { cn } from '@/lib/utils'
import { StickyNote, Plus, Tag, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, LucideIcon, Plane, ChevronDown, ChevronRight } from 'lucide-react'
import { useNotebooks } from '@/context/notebooks-context'
import { useNotebookDrag } from '@/context/notebook-drag-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 { useLabels } from '@/context/LabelContext'
// Map icon names to lucide-react components
const ICON_MAP: Record<string, LucideIcon> = {
'folder': Folder,
'briefcase': Briefcase,
'document': FileText,
'lightning': Zap,
'chart': BarChart3,
'globe': Globe,
'sparkle': Sparkles,
'book': Book,
'heart': Heart,
'crown': Crown,
'music': Music,
'building': Building2,
'flight_takeoff': Plane,
}
// Function to get icon component by name
const getNotebookIcon = (iconName: string) => {
const IconComponent = ICON_MAP[iconName] || Folder
return IconComponent
}
export function NotebooksList() {
const pathname = usePathname()
const searchParams = useSearchParams()
const router = useRouter()
const { t } = useLanguage()
const { notebooks, currentNotebook, deleteNotebook, moveNoteToNotebookOptimistic, isLoading } = useNotebooks()
const { draggedNoteId, dragOverNotebookId, dragOver } = useNotebookDrag()
const { labels } = useLabels()
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [editingNotebook, setEditingNotebook] = useState<any>(null)
const [deletingNotebook, setDeletingNotebook] = useState<any>(null)
const [summaryNotebook, setSummaryNotebook] = useState<any>(null)
const [expandedNotebook, setExpandedNotebook] = useState<string | null>(null)
const currentNotebookId = searchParams.get('notebook')
// Handle drop on a notebook
const handleDrop = useCallback(async (e: React.DragEvent, notebookId: string | null) => {
e.preventDefault()
e.stopPropagation()
const noteId = e.dataTransfer.getData('text/plain')
if (noteId) {
await moveNoteToNotebookOptimistic(noteId, notebookId)
}
dragOver(null)
}, [moveNoteToNotebookOptimistic, dragOver])
// Handle drag over a notebook
const handleDragOver = useCallback((e: React.DragEvent, notebookId: string | null) => {
e.preventDefault()
dragOver(notebookId)
}, [dragOver])
// Handle drag leave
const handleDragLeave = useCallback(() => {
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(expandedNotebook === 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 (
<>
<div className="flex flex-col pt-1">
{/* Header with Add Button */}
<div className="flex items-center justify-between px-6 py-2 mt-2 group cursor-pointer text-gray-500 hover:text-gray-800 dark:hover:text-gray-300">
<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: any) => {
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="group flex flex-col">
{isActive ? (
// Active notebook with expanded labels - STYLE MATCH Sidebar
<div
onDrop={(e) => handleDrop(e, notebook.id)}
onDragOver={(e) => handleDragOver(e, notebook.id)}
onDragLeave={handleDragLeave}
className={cn(
"flex flex-col mr-2 rounded-r-full overflow-hidden transition-all",
!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 - allow pointer events for expand button */}
<div className="pointer-events-auto flex items-center justify-between px-6 py-3">
<div className="flex items-center gap-4 min-w-0">
<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}
/>
<span
className={cn("text-sm font-medium tracking-wide truncate max-w-[120px]", !notebook.color && "text-primary dark:text-primary-foreground")}
style={notebook.color ? { color: notebook.color } : undefined}
>
{notebook.name}
</span>
</div>
<button
onClick={() => handleToggleExpand(notebook.id)}
className={cn("transition-colors p-1 flex-shrink-0", !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}
>
<ChevronDown className={cn("w-4 h-4 transition-transform", isExpanded && "rotate-180")} />
</button>
</div>
{/* Contextual Labels Tree */}
{isExpanded && labels.length > 0 && (
<div className="flex flex-col pb-2">
{labels.map((label: any) => (
<button
key={label.id}
onClick={() => handleLabelFilter(label.name, notebook.id)}
className={cn(
"pointer-events-auto flex items-center gap-4 pl-12 pr-4 py-2 hover:bg-black/5 dark:hover:bg-white/10 transition-colors rounded-r-full mr-2",
searchParams.get('labels')?.includes(label.name) && "font-bold text-gray-900 dark:text-white"
)}
>
<Tag className="w-4 h-4 text-gray-500" />
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">
{label.name}
</span>
</button>
))}
<button
onClick={() => router.push('/settings/labels')}
className="pointer-events-auto flex items-center gap-2 pl-12 pr-4 py-2 mt-1 text-gray-500 hover:text-gray-800 hover:bg-black/5 dark:hover:bg-white/10 rounded-r-full mr-2 transition-colors group/label"
>
<Plus className="w-3 h-3 group-hover/label:scale-110 transition-transform" />
<span className="text-xs font-medium opacity-80">{t('sidebar.editLabels') || 'Edit Labels'}</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 group relative",
isDragOver && "ring-2 ring-blue-500 ring-dashed rounded-r-full mr-2"
)}
>
<div className="w-full flex">
<button
onClick={() => handleSelectNotebook(notebook.id)}
className={cn(
"pointer-events-auto flex items-center gap-4 px-6 py-3 rounded-r-full mr-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800/50 transition-colors w-full pr-20",
isDragOver && "opacity-50"
)}
>
<NotebookIcon className="w-5 h-5 flex-shrink-0" />
<span className="text-sm font-medium tracking-wide truncate flex-1 text-left">{notebook.name}</span>
{notebook.notesCount > 0 && (
<span className="text-xs text-gray-400 ml-2 flex-shrink-0">({notebook.notesCount})</span>
)}
</button>
{/* Expand button separate from main click */}
<button
onClick={(e) => { e.stopPropagation(); handleToggleExpand(notebook.id); }}
className={cn(
"absolute right-4 top-1/2 -translate-y-1/2 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 opacity-0 group-hover:opacity-100",
expandedNotebook === notebook.id && "opacity-100 rotate-180"
)}
>
<ChevronDown className="w-4 h-4" />
</button>
</div>
{/* Actions (visible on hover) */}
<div className="absolute right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10" style={{ right: '40px' }}>
<NotebookActions
notebook={notebook}
onEdit={() => setEditingNotebook(notebook)}
onDelete={() => setDeletingNotebook(notebook)}
onSummary={() => setSummaryNotebook(notebook)}
/>
</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}
notebookName={summaryNotebook?.name}
/>
</>
)
}