315 lines
13 KiB
TypeScript
315 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, 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'
|
|
import { LabelManagementDialog } from '@/components/label-management-dialog'
|
|
import { Notebook } from '@/lib/types'
|
|
import { getNotebookIcon } from '@/lib/notebook-icon'
|
|
|
|
export function NotebooksList() {
|
|
const pathname = usePathname()
|
|
const searchParams = useSearchParams()
|
|
const router = useRouter()
|
|
const { t, language } = 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<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)
|
|
|
|
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((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="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: 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="group flex flex-col">
|
|
{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-6 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}
|
|
/>
|
|
<span
|
|
className={cn("text-sm font-medium tracking-wide truncate min-w-0", !notebook.color && "text-primary dark:text-primary-foreground")}
|
|
style={notebook.color ? { color: notebook.color } : undefined}
|
|
>
|
|
{notebook.name}
|
|
</span>
|
|
</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-blue-500 ring-dashed rounded-e-full me-2"
|
|
)}
|
|
>
|
|
<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-24",
|
|
isDragOver && "opacity-50"
|
|
)}
|
|
>
|
|
<NotebookIcon className="w-5 h-5 flex-shrink-0" />
|
|
<span className="text-sm font-medium tracking-wide truncate min-w-0 text-start">{notebook.name}</span>
|
|
{(notebook as any).notesCount > 0 && (
|
|
<span className="text-xs text-gray-400 ms-2 flex-shrink-0">({new Intl.NumberFormat(language).format((notebook as any).notesCount)})</span>
|
|
)}
|
|
</button>
|
|
|
|
{/* 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}
|
|
/>
|
|
</>
|
|
)
|
|
}
|