'use client' import { Note, NOTE_COLORS, NoteColor } 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, } 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, LogOut, Trash2 } from 'lucide-react' import { useState, useEffect, useTransition, useOptimistic, memo } from 'react' import { useSession } from 'next-auth/react' import { useRouter, useSearchParams } from 'next/navigation' import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, updateSize, getNoteAllUsers, leaveSharedNote, removeFusedBadge, restoreNote, permanentDeleteNote, createNote } 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 { MarkdownContent } from './markdown-content' import { LabelBadge } from './label-badge' import { NoteImages } from './note-images' import { NoteChecklist } from './note-checklist' import { NoteActions } from './note-actions' import { CollaboratorDialog } from './collaborator-dialog' import { useCardSizeMode } from '@/hooks/use-card-size-mode' import { CollaboratorAvatars } from './collaborator-avatars' import { ConnectionsBadge } from './connections-badge' import { ConnectionsOverlay } from './connections-overlay' import { ComparisonModal } from './comparison-modal' import { FusionModal } from './fusion-modal' import { useConnectionsCompare } from '@/hooks/use-connections-compare' import { useLabels } from '@/context/LabelContext' import { useNoteRefresh } from '@/context/NoteRefreshContext' import { useLanguage } from '@/lib/i18n' import { useNotebooks } from '@/context/notebooks-context' import { toast } from 'sonner' import { getNotebookIcon } from '@/lib/notebook-icon' // 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 } 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 } // 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-500', 'bg-green-500', 'bg-orange-500', 'bg-pink-500', 'bg-teal-500', 'bg-red-500', 'bg-indigo-500', ] 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 }: NoteCardProps) { const router = useRouter() const searchParams = useSearchParams() const { refreshLabels } = useLabels() const { triggerRefresh } = useNoteRefresh() const { data: session } = useSession() const { t, language } = useLanguage() const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks() const [, startTransition] = useTransition() const [isDeleting, setIsDeleting] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [showPermanentDeleteDialog, setShowPermanentDeleteDialog] = 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) // Move note to a notebook const handleMoveToNotebook = async (notebookId: string | null) => { await moveNoteToNotebookOptimistic(note.id, notebookId) setShowNotebookMenu(false) // No need for router.refresh() - triggerRefresh() 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) 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 // Card size mode from settings const cardSizeMode = useCardSizeMode() const isUniformMode = cardSizeMode === 'uniform' // 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 } 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) { console.error('Failed to load collaborators:', error) if (isMounted) { setCollaborators([]) } } } } loadCollaborators() return () => { isMounted = false } }, [note.id, note.userId, isSharedNote, currentUserId, session?.user]) const handleDelete = async () => { setIsDeleting(true) try { await deleteNote(note.id) await refreshLabels() } catch (error) { console.error('Failed to delete note:', error) setIsDeleting(false) } } const handleTogglePin = async () => { startTransition(async () => { addOptimisticNote({ isPinned: !note.isPinned }) await togglePin(note.id, !note.isPinned) if (!note.isPinned) { toast.success(t('notes.pinned') || 'Note pinned') } else { toast.info(t('notes.unpinned') || 'Note unpinned') } }) } const handleToggleArchive = async () => { startTransition(async () => { addOptimisticNote({ isArchived: !note.isArchived }) await toggleArchive(note.id, !note.isArchived) }) } const handleColorChange = async (color: string) => { setLocalColor(color) // instant visual update, survives transition startTransition(async () => { addOptimisticNote({ color }) await updateNote(note.id, { color }, { skipRevalidation: false }) }) } const handleSizeChange = (size: 'small' | 'medium' | 'large') => { // Notifier le parent immédiatement (hors transition) — c'est lui // qui détient la source de vérité via localNotes onSizeChange?.(size) onResize?.() // Persister en arrière-plan updateSize(note.id, size).catch(err => console.error('Failed to update note size:', err) ) } const handleCheckItem = async (checkItemId: string) => { if (note.type === 'checklist' && Array.isArray(note.checkItems)) { const updatedItems = note.checkItems.map(item => item.id === checkItemId ? { ...item, checked: !item.checked } : item ) startTransition(async () => { addOptimisticNote({ checkItems: updatedItems }) await updateNote(note.id, { checkItems: updatedItems }) // No router.refresh() — optimistic update is sufficient and avoids grid rebuild }) } } 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 handleRestore = async () => { try { await restoreNote(note.id) setIsDeleting(true) // Hide the note from trash view toast.success(t('trash.noteRestored')) } catch (error) { console.error('Failed to restore note:', error) } } const handlePermanentDelete = async () => { setIsDeleting(true) try { await permanentDeleteNote(note.id) toast.success(t('trash.notePermanentlyDeleted')) } catch (error) { console.error('Failed to permanently delete note:', error) setIsDeleting(false) } } 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 } } return ( { if (isTrashView) 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-2xl overflow-hidden p-5 border shadow-sm', 'transition-all duration-200 ease-out', 'hover:shadow-xl hover:-translate-y-1', colorClasses.bg, colorClasses.card, colorClasses.hover, colorClasses.hover, isDragging && 'shadow-2xl' // Removed opacity, scale, and rotation for clean drag )} onClick={(e) => { // 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, not in trash */} {!isTrashView && (
)} {/* Move to Notebook Dropdown Menu - Hidden in trash */} {!isTrashView && (
e.stopPropagation()} className="absolute top-2 right-2 z-20">
{t('notebookSuggestion.moveToNotebook')}
handleMoveToNotebook(null)}> {t('notebookSuggestion.generalNotes')} {notebooks.map((notebook: any) => { const NotebookIcon = getNotebookIcon(notebook.icon || 'folder') return ( handleMoveToNotebook(notebook.id)} > {notebook.name} ) })}
)} {/* Pin Button - Visible on hover or if pinned, hidden in trash */} {!isTrashView && ( )} {/* Reminder Icon - Move slightly if pin button is there */} {note.reminder && new Date(note.reminder) > new Date() && ( )} {/* Memory Echo Badges - Fusion + Connections (BEFORE Title) */}
{/* Fusion Badge with remove button */} {note.autoGenerated && (
{t('memoryEcho.fused')}
)} {/* Connections Badge */} { // Only open overlay if note is NOT open in editor // (to avoid having 2 Dialogs with 2 close buttons) if (!isNoteOpenInEditor) { setShowConnectionsOverlay(true) } }} />
{/* Title */} {optimisticNote.title && (

{optimisticNote.title}

)} {/* Search Match Type Badge */} {optimisticNote.matchType && ( {t(`semanticSearch.${optimisticNote.matchType === 'exact' ? 'exactMatch' : 'related'}`)} )} {/* Shared badge */} {isSharedNote && owner && (
{t('notes.sharedBy')} {owner.name || owner.email}
)} {/* Images Component */} {/* Link Previews */} {Array.isArray(optimisticNote.links) && optimisticNote.links.length > 0 && (
{optimisticNote.links.map((link, idx) => ( e.stopPropagation()} > {link.imageUrl && ( )} {/* Content */} {optimisticNote.type === 'text' ? (
) : ( )} {/* Labels - using shared LabelBadge component */} {optimisticNote.notebookId && Array.isArray(optimisticNote.labels) && optimisticNote.labels.length > 0 && (
{optimisticNote.labels.map((label) => ( ))}
)} {/* 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 - hide destructive actions for shared notes */} {!isSharedNote && ( setShowDeleteDialog(true)} onShareCollaborators={() => setShowCollaboratorDialog(true)} isTrashView={isTrashView} onRestore={handleRestore} onPermanentDelete={() => setShowPermanentDeleteDialog(true)} className="absolute bottom-0 left-0 right-0 p-2 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity" /> )} {/* Collaborator Dialog */} {currentUserId && note.userId && (
e.stopPropagation()}>
)} {/* Connections Overlay */}
e.stopPropagation()}> setShowConnectionsOverlay(false)} noteId={note.id} onOpenNote={(connNoteId) => { 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} onOpenNote={(noteId) => { const foundNote = comparisonNotesData.find(n => n.id === noteId) if (foundNote) { onEdit?.(foundNote, false) } }} />
)} {/* Fusion Modal */} {fusionNotes && fusionNotes.length > 0 && (
e.stopPropagation()}> setFusionNotes([])} notes={fusionNotes} onConfirmFusion={async ({ title, content }, options) => { 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, notebookId: fusionNotes[0].notebookId ?? undefined }) if (options.archiveOriginals) { for (const n of fusionNotes) { if (n.id) await updateNote(n.id, { isArchived: true }) } } toast.success(t('toast.notesFusionSuccess')) triggerRefresh() }} />
)} {/* Delete Confirmation Dialog */} {t('notes.confirmDeleteTitle') || t('notes.delete')} {t('notes.confirmDelete') || 'Are you sure you want to delete this note?'} {t('common.cancel') || 'Cancel'} {t('notes.delete') || 'Delete'} {/* Permanent Delete Confirmation Dialog (Trash view only) */} {t('trash.permanentDelete')} {t('trash.permanentDeleteConfirm')} {t('common.cancel') || 'Cancel'} {t('trash.permanentDelete')} ) })