'use client'
import { Note, NOTE_COLORS, NoteColor, NoteType } from '@/lib/types'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LucideIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, LogOut, Trash2, AlignLeft, FileCode2, PenLine, ListChecks, ChevronRight, Plus, History } from 'lucide-react'
import { useState, useEffect, useTransition, useOptimistic, memo, useMemo } from 'react'
import dynamic from 'next/dynamic'
import { useSession } from 'next-auth/react'
import { useRouter, useSearchParams } from 'next/navigation'
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, updateSize, getNoteAllUsers, leaveSharedNote, removeFusedBadge, createNote, restoreNote, permanentDeleteNote } from '@/app/actions/notes'
import { cn } from '@/lib/utils'
import { formatDistanceToNow, Locale } from 'date-fns'
import { enUS } from 'date-fns/locale/en-US'
import { fr } from 'date-fns/locale/fr'
import { es } from 'date-fns/locale/es'
import { de } from 'date-fns/locale/de'
import { faIR } from 'date-fns/locale/fa-IR'
import { it } from 'date-fns/locale/it'
import { pt } from 'date-fns/locale/pt'
import { ru } from 'date-fns/locale/ru'
import { zhCN } from 'date-fns/locale/zh-CN'
import { ja } from 'date-fns/locale/ja'
import { ko } from 'date-fns/locale/ko'
import { ar } from 'date-fns/locale/ar'
import { hi } from 'date-fns/locale/hi'
import { nl } from 'date-fns/locale/nl'
import { pl } from 'date-fns/locale/pl'
import { LabelBadge } from './label-badge'
import DOMPurify from 'isomorphic-dompurify'
import { NoteImages } from './note-images'
import { NoteChecklist } from './note-checklist'
import { NoteActions } from './note-actions'
import { CollaboratorAvatars } from './collaborator-avatars'
import { ConnectionsBadge } from './connections-badge'
const MarkdownContent = dynamic(() => import('./markdown-content').then(m => ({ default: m.MarkdownContent })), {
loading: () =>
…
,
})
const CollaboratorDialog = dynamic(() => import('./collaborator-dialog').then(m => ({ default: m.CollaboratorDialog })), { ssr: false })
const ConnectionsOverlay = dynamic(() => import('./connections-overlay').then(m => ({ default: m.ConnectionsOverlay })), { ssr: false })
const ComparisonModal = dynamic(() => import('./comparison-modal').then(m => ({ default: m.ComparisonModal })), { ssr: false })
const FusionModal = dynamic(() => import('./fusion-modal').then(m => ({ default: m.FusionModal })), { ssr: false })
import { useConnectionsCompare } from '@/hooks/use-connections-compare'
import { useNotebooks } from '@/context/notebooks-context'
import { emitNoteChange } from '@/lib/note-change-sync'
import { useLanguage } from '@/lib/i18n'
import { toast } from 'sonner'
// Mapping of supported languages to date-fns locales
const localeMap: Record = {
en: enUS,
fr: fr,
es: es,
de: de,
fa: faIR,
it: it,
pt: pt,
ru: ru,
zh: zhCN,
ja: ja,
ko: ko,
ar: ar,
hi: hi,
nl: nl,
pl: pl,
}
function getDateLocale(language: string): Locale {
return localeMap[language] || enUS
}
const NOTE_TYPE_ICONS: Record = {
text: AlignLeft,
markdown: FileCode2,
richtext: PenLine,
checklist: ListChecks,
}
// Map icon names to lucide-react components
const ICON_MAP: Record = {
'folder': Folder,
'briefcase': Briefcase,
'document': FileText,
'lightning': Zap,
'chart': BarChart3,
'globe': Globe,
'sparkle': Sparkles,
'book': Book,
'heart': Heart,
'crown': Crown,
'music': Music,
'building': Building2,
}
// Function to get icon component by name
function getNotebookIcon(iconName: string): LucideIcon {
const IconComponent = ICON_MAP[iconName] || Folder
return IconComponent
}
interface NoteCardProps {
note: Note
onEdit?: (note: Note, readOnly?: boolean) => void
isDragging?: boolean
isDragOver?: boolean
onDragStart?: (noteId: string) => void
onDragEnd?: () => void
onResize?: () => void
onSizeChange?: (newSize: 'small' | 'medium' | 'large') => void
isTrashView?: boolean
noteHistoryEnabled?: boolean
onOpenHistory?: (note: Note) => void
onCreateSubNotebook?: () => void
}
// Helper function to get initials from name
function getInitials(name: string): string {
if (!name) return '??'
const trimmedName = name.trim()
const parts = trimmedName.split(' ')
if (parts.length === 1) {
return trimmedName.substring(0, 2).toUpperCase()
}
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
// Helper function to get avatar color based on name hash
function getAvatarColor(name: string): string {
const colors = [
'bg-primary',
'bg-purple-600',
'bg-emerald-600',
'bg-amber-600',
'bg-pink-600',
'bg-teal-600',
'bg-stone-600',
'bg-indigo-600',
]
const hash = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
return colors[hash % colors.length]
}
export const NoteCard = memo(function NoteCard({
note,
onEdit,
onDragStart,
onDragEnd,
isDragging,
onResize,
onSizeChange,
isTrashView,
noteHistoryEnabled = false,
onOpenHistory,
onCreateSubNotebook,
}: NoteCardProps) {
const router = useRouter()
const searchParams = useSearchParams()
const { data: session } = useSession()
const { t, language } = useLanguage()
const { notebooks, moveNoteToNotebookOptimistic, refreshLabels } = useNotebooks()
const [, startTransition] = useTransition()
const [isDeleting, setIsDeleting] = useState(false)
const [isHidden, setIsHidden] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false)
const [collaborators, setCollaborators] = useState([])
const [owner, setOwner] = useState(null)
const [showConnectionsOverlay, setShowConnectionsOverlay] = useState(false)
const [comparisonNotes, setComparisonNotes] = useState(null)
const [fusionNotes, setFusionNotes] = useState>>([])
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
const [reminderDate, setReminderDate] = useState(note.reminder ? new Date(note.reminder) : null)
const sanitizedHtml = useMemo(() => {
if ((note.type !== 'richtext' && note.type !== 'daily') || !note.content) return ''
return DOMPurify.sanitize(note.content)
}, [note.type, note.content])
const handleUpdateReminder = async (noteId: string, reminder: Date | null) => {
startTransition(async () => {
try {
await updateNote(noteId, { reminder }, { skipRevalidation: true })
setReminderDate(reminder)
emitNoteChange({ type: 'updated', note: { ...note, reminder: reminder?.toISOString() ?? null } })
if (reminder) {
toast.success(t('notes.reminderSet', { datetime: reminder.toLocaleString() }))
} else {
toast.info(t('reminder.removeReminder'))
}
} catch {
toast.error(t('general.error'))
}
})
}
// Move note to a notebook
const handleMoveToNotebook = async (notebookId: string | null) => {
await moveNoteToNotebookOptimistic(note.id, notebookId)
setShowNotebookMenu(false)
// No need for router.refresh() - refreshNotes(note?.notebookId) is already called in moveNoteToNotebookOptimistic
}
// Optimistic UI state for instant feedback
const [optimisticNote, addOptimisticNote] = useOptimistic(
note,
(state, newProps: Partial) => ({ ...state, ...newProps })
)
// Local color state so color persists after transition ends
const [localColor, setLocalColor] = useState(note.color)
// Local checkItems state so checklist toggles persist after transition ends
const [localCheckItems, setLocalCheckItems] = useState(note.checkItems)
// Sync local state when parent data refreshes
useEffect(() => {
setLocalCheckItems(note.checkItems)
}, [note.checkItems])
const colorClasses = NOTE_COLORS[(localColor || optimisticNote.color) as NoteColor] || NOTE_COLORS.default
// Check if this note is currently open in the editor
const isNoteOpenInEditor = searchParams.get('note') === note.id
// Only fetch comparison notes when we have IDs to compare
const { notes: comparisonNotesData, isLoading: isLoadingComparison } = useConnectionsCompare(
comparisonNotes && comparisonNotes.length > 0 ? comparisonNotes : null
)
const currentUserId = session?.user?.id
const canManageCollaborators = currentUserId && note.userId && currentUserId === note.userId
const isSharedNote = currentUserId && note.userId && currentUserId !== note.userId
const isOwner = currentUserId && note.userId && currentUserId === note.userId
// Load collaborators only for shared notes (not owned by current user)
useEffect(() => {
// Skip API call for notes owned by current user — no need to fetch collaborators
if (!isSharedNote) {
// For own notes, set owner to current user
if (currentUserId && session?.user) {
setOwner({
id: currentUserId,
name: session.user.name,
email: session.user.email,
image: session.user.image,
})
}
return
}
if (!isSharedNote) return
let isMounted = true
const loadCollaborators = async () => {
if (note.userId && isMounted) {
try {
const users = await getNoteAllUsers(note.id)
if (isMounted) {
setCollaborators(users)
if (users.length > 0) {
setOwner(users[0])
}
}
} catch (error) {
if (isMounted) {
setCollaborators([])
}
}
}
}
loadCollaborators()
return () => {
isMounted = false
}
}, [note.id, note.userId, isSharedNote, currentUserId, session?.user])
const handleDelete = async () => {
setIsDeleting(true)
setIsHidden(true) // masquage immédiat
try {
await deleteNote(note.id, { skipRevalidation: true })
await refreshLabels()
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
} catch (error) {
console.error('Failed to delete note:', error)
setIsHidden(false)
setIsDeleting(false)
}
}
const handleRestore = async () => {
setIsDeleting(true)
setIsHidden(true)
try {
await restoreNote(note.id)
emitNoteChange({ type: 'updated', note: { ...note, trashedAt: null } })
toast.success(t('trash.noteRestored'))
} catch (error) {
console.error('Failed to restore note:', error)
setIsHidden(false)
setIsDeleting(false)
}
}
const handlePermanentDelete = async () => {
setIsDeleting(true)
setIsHidden(true)
try {
await permanentDeleteNote(note.id)
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
toast.success(t('trash.notePermanentlyDeleted'))
} catch (error) {
console.error('Failed to permanently delete note:', error)
setIsHidden(false)
setIsDeleting(false)
}
}
const handleTogglePin = async () => {
startTransition(async () => {
addOptimisticNote({ isPinned: !note.isPinned })
await togglePin(note.id, !note.isPinned, { skipRevalidation: true })
emitNoteChange({ type: 'updated', note: { ...note, isPinned: !note.isPinned } })
if (!note.isPinned) {
toast.success(t('notes.pinned'))
} else {
toast.info(t('notes.unpinned'))
}
})
}
const handleToggleArchive = async () => {
startTransition(async () => {
addOptimisticNote({ isArchived: !note.isArchived })
await toggleArchive(note.id, !note.isArchived, { skipRevalidation: true })
emitNoteChange({ type: 'updated', note: { ...note, isArchived: !note.isArchived } })
})
}
const handleColorChange = async (color: string) => {
setLocalColor(color) // instant visual update, survives transition
startTransition(async () => {
addOptimisticNote({ color })
await updateNote(note.id, { color }, { skipRevalidation: true })
})
}
const handleSizeChange = async (size: 'small' | 'medium' | 'large') => {
startTransition(async () => {
// Instant visual feedback for the card itself
addOptimisticNote({ size })
// Notify parent so it can update its local state
onSizeChange?.(size)
// Trigger layout refresh
onResize?.()
setTimeout(() => onResize?.(), 300)
// Update server in background
try {
await updateSize(note.id, size);
} catch (error) {
console.error('Failed to update note size:', error);
}
})
}
const handleCheckItem = async (checkItemId: string) => {
if (note.type === 'checklist') {
const currentItems = localCheckItems || note.checkItems || []
const updatedItems = currentItems.map(item =>
item.id === checkItemId ? { ...item, checked: !item.checked } : item
)
setLocalCheckItems(updatedItems) // instant visual update, survives transition
startTransition(async () => {
addOptimisticNote({ checkItems: updatedItems })
await updateNote(note.id, { checkItems: updatedItems }, { skipRevalidation: true })
})
}
}
const handleLeaveShare = async () => {
if (confirm(t('notes.confirmLeaveShare'))) {
try {
await leaveSharedNote(note.id)
setIsDeleting(true) // Hide the note from view
} catch (error) {
console.error('Failed to leave share:', error)
}
}
}
const handleRemoveFusedBadge = async (e: React.MouseEvent) => {
e.stopPropagation() // Prevent opening the note editor
startTransition(async () => {
addOptimisticNote({ autoGenerated: null })
await removeFusedBadge(note.id)
// No router.refresh() — optimistic update is sufficient and avoids grid rebuild
})
}
if (isDeleting) return null
const getMinHeight = (size?: string) => {
switch (size) {
case 'medium': return '350px'
case 'large': return '500px'
default: return '150px' // small
}
}
if (isHidden) return null
return (
{
e.dataTransfer.setData('text/plain', note.id)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/html', '') // Prevent ghost image in some browsers
onDragStart?.(note.id)
}}
onDragEnd={() => onDragEnd?.()}
className={cn(
'note-card group relative rounded-lg overflow-hidden p-6 border border-transparent shadow-[0_2px_4px_rgba(0,0,0,0.04),0_4px_12px_rgba(0,0,0,0.04)]',
'transition-all duration-200 ease-out',
!isTrashView && 'hover:shadow-[0_4px_8px_rgba(0,0,0,0.06),0_8px_24px_rgba(0,0,0,0.08)] hover:border-border/40 hover:-translate-y-0.5',
isTrashView && 'cursor-default',
colorClasses.bg,
colorClasses.card,
colorClasses.hover,
isDragging && 'shadow-lg'
)}
onClick={(e) => {
// Trashed notes are not editable
if (isTrashView) return
// Only trigger edit if not clicking on buttons
const target = e.target as HTMLElement
if (!target.closest('button') && !target.closest('[role="checkbox"]') && !target.closest('.muuri-drag-handle') && !target.closest('.drag-handle')) {
// For shared notes, pass readOnly flag
onEdit?.(note, !!isSharedNote) // Pass second parameter as readOnly flag (convert to boolean)
}
}}
>
{/* Drag Handle - Only visible on mobile/touch devices */}
{/* Move to Notebook Dropdown Menu — hidden in trash */}
{!isTrashView && e.stopPropagation()} className="absolute top-2 end-2 z-20">
{t('notebookSuggestion.moveToNotebook')}
handleMoveToNotebook(null)}>
{t('notebookSuggestion.generalNotes')}
{notebooks.filter(nb => !nb.parentId && !nb.trashedAt).map((notebook: any) => {
const NotebookIcon = getNotebookIcon(notebook.icon || 'folder')
const allDescendants = (parentId: string): any[] => {
const kids = notebooks.filter((c: any) => c.parentId === parentId && !c.trashedAt)
return kids.flatMap((k: any) => [k, ...allDescendants(k.id)])
}
const descendants = allDescendants(notebook.id)
if (descendants.length > 0) {
return (
{notebook.name}
handleMoveToNotebook(notebook.id)}>
{notebook.name}
{descendants.map((child: any) => {
const ChildIcon = getNotebookIcon(child.icon || 'folder')
const depth = (() => {
let d = 0
let current = child
while (current.parentId && current.parentId !== notebook.id) {
d++
current = notebooks.find((nb: any) => nb.id === current.parentId)
if (!current) break
}
return d
})()
return (
handleMoveToNotebook(child.id)}>
{child.name}
)
})}
)
}
return (
handleMoveToNotebook(notebook.id)}>
{notebook.name}
)
})}
onCreateSubNotebook?.()}>
{t('notebook.createSubNotebook')}
}
{/* Pin Button - hidden in trash */}
{!isTrashView && }
{/* Reminder Icon - Move slightly if pin button is there */}
{note.reminder && new Date(note.reminder) > new Date() && (
)}
{/* Versioning indicator */}
{optimisticNote.historyEnabled && (
)}
{/* Fusion Badge */}
{note.aiProvider === 'fusion' && optimisticNote.autoGenerated !== null && (
{t('memoryEcho.fused')}
)}
{/* Title */}
{note.title && (
{(() => {
const TypeIcon = NOTE_TYPE_ICONS[note.type] || AlignLeft
return
})()}
{note.title}
)}
{/* Search Match Type Badge */}
{note.matchType && (
{t(`semanticSearch.${note.matchType === 'exact' ? 'exactMatch' : 'related'}`)}
)}
{/* Shared badge */}
{isSharedNote && owner && (
{t('notes.sharedBy')} {owner.name || owner.email}
)}
{/* Images Component */}
{/* Link Previews */}
{Array.isArray(note.links) && note.links.length > 0 && (
)}
{/* Content */}
{note.type === 'checklist' ? (
) : (note.type === 'richtext' || note.type === 'daily') ? (
) : (
)}
{/* Labels - using shared LabelBadge component */}
{note.notebookId && Array.isArray(note.labels) && note.labels.length > 0 && (
{note.labels.map((label: string) => (
))}
)}
{/* Footer with Date only */}
{/* Creation Date */}
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: getDateLocale(language) })}
{/* Owner Avatar - Aligned with action buttons at bottom */}
{owner && (
{getInitials(owner.name || owner.email || '??')}
)}
{/* Action Bar Component - Always show for now to fix regression */}
{true && (
setShowDeleteDialog(true)}
onShareCollaborators={() => setShowCollaboratorDialog(true)}
isTrashView={isTrashView}
onRestore={handleRestore}
onPermanentDelete={handlePermanentDelete}
onOpenHistory={() => onOpenHistory?.(note)}
historyEnabled={!!note.historyEnabled}
noteId={note.id}
currentReminder={reminderDate}
onUpdateReminder={handleUpdateReminder}
className="absolute bottom-0 start-0 end-0 p-2 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity"
/>
)}
{/* Collaborator Dialog */}
{currentUserId && note.userId && (
e.stopPropagation()}>
)}
{/* Connections Badge - Bottom right (spec: amber, absolute) */}
{
if (!isNoteOpenInEditor) {
setShowConnectionsOverlay(true)
}
}}
/>
{/* Connections Overlay */}
e.stopPropagation()}>
setShowConnectionsOverlay(false)}
noteId={note.id}
onOpenNote={(connNoteId) => {
setShowConnectionsOverlay(false)
const params = new URLSearchParams(searchParams.toString())
params.set('note', connNoteId)
router.push(`?${params.toString()}`)
}}
onCompareNotes={(noteIds) => {
setComparisonNotes(noteIds)
}}
onMergeNotes={async (noteIds) => {
const fetchedNotes = await Promise.all(noteIds.map(async (id) => {
try {
const res = await fetch(`/api/notes/${id}`)
if (!res.ok) return null
const data = await res.json()
return data.success && data.data ? data.data : null
} catch { return null }
}))
setFusionNotes(fetchedNotes.filter((n: any) => n !== null) as Array>)
}}
/>
{/* Comparison Modal */}
{comparisonNotes && comparisonNotesData.length > 0 && (
e.stopPropagation()}>
setComparisonNotes(null)}
notes={comparisonNotesData}
/>
)}
{/* Fusion Modal */}
{fusionNotes.length > 0 && (
e.stopPropagation()}>
0}
onClose={() => setFusionNotes([])}
notes={fusionNotes}
onConfirmFusion={async ({ title, content }, options) => {
const created = await createNote({
title,
content,
labels: options.keepAllTags
? [...new Set(fusionNotes.flatMap(n => n.labels || []))]
: fusionNotes[0].labels || [],
color: fusionNotes[0].color,
type: 'text',
isMarkdown: true,
autoGenerated: true,
aiProvider: 'fusion',
notebookId: fusionNotes[0].notebookId ?? undefined
})
if (options.archiveOriginals) {
for (const n of fusionNotes) {
if (n.id) await updateNote(n.id, { isArchived: true }, { skipRevalidation: true })
}
}
toast.success(t('toast.notesFusionSuccess'))
setFusionNotes([])
if (created) emitNoteChange({ type: 'created', note: created })
}}
/>
)}
{/* Delete Confirmation Dialog */}
{t('notes.confirmDeleteTitle') || t('notes.delete')}
{t('notes.confirmDelete')}
{t('common.cancel')}
{t('notes.delete')}
)
})