'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 && (
{note.links.map((link: any, idx: number) => ( e.stopPropagation()} > {link.imageUrl && ( )} {/* 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')} ) })