658 lines
23 KiB
TypeScript
658 lines
23 KiB
TypeScript
'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 { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LucideIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, Tag } from 'lucide-react'
|
|
import { useState, useEffect, useTransition, useOptimistic } from 'react'
|
|
import { useSession } from 'next-auth/react'
|
|
import { useRouter, useSearchParams } from 'next/navigation'
|
|
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, updateSize, getNoteAllUsers, leaveSharedNote, removeFusedBadge } from '@/app/actions/notes'
|
|
import { cn } from '@/lib/utils'
|
|
import { formatDistanceToNow, Locale } from 'date-fns'
|
|
import * as dateFnsLocales from 'date-fns/locale'
|
|
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 { CollaboratorAvatars } from './collaborator-avatars'
|
|
import { ConnectionsBadge } from './connections-badge'
|
|
import { ConnectionsOverlay } from './connections-overlay'
|
|
import { ComparisonModal } from './comparison-modal'
|
|
import { useConnectionsCompare } from '@/hooks/use-connections-compare'
|
|
import { useLabels } from '@/context/LabelContext'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { useNotebooks } from '@/context/notebooks-context'
|
|
import { toast } from 'sonner'
|
|
|
|
// Mapping of supported languages to date-fns locales
|
|
const localeMap: Record<string, Locale> = {
|
|
en: dateFnsLocales.enUS,
|
|
fr: dateFnsLocales.fr,
|
|
es: dateFnsLocales.es,
|
|
de: dateFnsLocales.de,
|
|
fa: dateFnsLocales.faIR,
|
|
it: dateFnsLocales.it,
|
|
pt: dateFnsLocales.pt,
|
|
ru: dateFnsLocales.ru,
|
|
zh: dateFnsLocales.zhCN,
|
|
ja: dateFnsLocales.ja,
|
|
ko: dateFnsLocales.ko,
|
|
ar: dateFnsLocales.ar,
|
|
hi: dateFnsLocales.hi,
|
|
nl: dateFnsLocales.nl,
|
|
pl: dateFnsLocales.pl,
|
|
}
|
|
|
|
function getDateLocale(language: string): Locale {
|
|
return localeMap[language] || dateFnsLocales.enUS
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 function NoteCard({
|
|
note,
|
|
onEdit,
|
|
onDragStart,
|
|
onDragEnd,
|
|
isDragging,
|
|
onResize,
|
|
onSizeChange
|
|
}: NoteCardProps) {
|
|
const router = useRouter()
|
|
const searchParams = useSearchParams()
|
|
const { refreshLabels } = useLabels()
|
|
const { data: session } = useSession()
|
|
const { t, language } = useLanguage()
|
|
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
|
|
const [isPending, startTransition] = useTransition()
|
|
const [isDeleting, setIsDeleting] = useState(false)
|
|
const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false)
|
|
const [collaborators, setCollaborators] = useState<any[]>([])
|
|
const [owner, setOwner] = useState<any>(null)
|
|
const [showConnectionsOverlay, setShowConnectionsOverlay] = useState(false)
|
|
const [comparisonNotes, setComparisonNotes] = useState<string[] | null>(null)
|
|
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<Note>) => ({ ...state, ...newProps })
|
|
)
|
|
|
|
const colorClasses = NOTE_COLORS[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 when note changes
|
|
useEffect(() => {
|
|
const loadCollaborators = async () => {
|
|
if (note.userId) {
|
|
try {
|
|
const users = await getNoteAllUsers(note.id)
|
|
setCollaborators(users)
|
|
// Owner is always first in the list
|
|
if (users.length > 0) {
|
|
setOwner(users[0])
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load collaborators:', error)
|
|
setCollaborators([])
|
|
}
|
|
}
|
|
}
|
|
|
|
loadCollaborators()
|
|
}, [note.id, note.userId])
|
|
|
|
const handleDelete = async () => {
|
|
if (confirm(t('notes.confirmDelete'))) {
|
|
setIsDeleting(true)
|
|
try {
|
|
await deleteNote(note.id)
|
|
// Refresh global labels to reflect garbage collection
|
|
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)
|
|
router.refresh()
|
|
|
|
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)
|
|
router.refresh()
|
|
})
|
|
}
|
|
|
|
const handleColorChange = async (color: string) => {
|
|
startTransition(async () => {
|
|
addOptimisticNote({ color })
|
|
await updateColor(note.id, color)
|
|
router.refresh()
|
|
})
|
|
}
|
|
|
|
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' && 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 })
|
|
router.refresh()
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
router.refresh()
|
|
})
|
|
}
|
|
|
|
if (isDeleting) return null
|
|
|
|
const getMinHeight = (size?: string) => {
|
|
switch (size) {
|
|
case 'medium': return '350px'
|
|
case 'large': return '500px'
|
|
default: return '150px' // small
|
|
}
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
<Card
|
|
data-testid="note-card"
|
|
data-draggable="true"
|
|
data-note-id={note.id}
|
|
data-size={optimisticNote.size}
|
|
style={{ minHeight: getMinHeight(optimisticNote.size) }}
|
|
draggable={true}
|
|
onDragStart={(e) => {
|
|
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 */}
|
|
<div
|
|
className="muuri-drag-handle absolute top-2 left-2 z-20 cursor-grab active:cursor-grabbing p-2 md:hidden"
|
|
aria-label={t('notes.dragToReorder') || 'Drag to reorder'}
|
|
title={t('notes.dragToReorder') || 'Drag to reorder'}
|
|
>
|
|
<GripVertical className="h-5 w-5 text-muted-foreground" />
|
|
</div>
|
|
|
|
{/* Move to Notebook Dropdown Menu */}
|
|
<div onClick={(e) => e.stopPropagation()} className="absolute top-2 right-2 z-20">
|
|
<DropdownMenu open={showNotebookMenu} onOpenChange={setShowNotebookMenu}>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 p-0 bg-primary/10 dark:bg-primary/20 hover:bg-primary/20 dark:hover:bg-primary/30 text-primary dark:text-primary-foreground"
|
|
title={t('notebookSuggestion.moveToNotebook')}
|
|
>
|
|
<FolderOpen className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-56">
|
|
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
|
{t('notebookSuggestion.moveToNotebook')}
|
|
</div>
|
|
<DropdownMenuItem onClick={() => handleMoveToNotebook(null)}>
|
|
<StickyNote className="h-4 w-4 mr-2" />
|
|
{t('notebookSuggestion.generalNotes')}
|
|
</DropdownMenuItem>
|
|
{notebooks.map((notebook: any) => {
|
|
const NotebookIcon = getNotebookIcon(notebook.icon || 'folder')
|
|
return (
|
|
<DropdownMenuItem
|
|
key={notebook.id}
|
|
onClick={() => handleMoveToNotebook(notebook.id)}
|
|
>
|
|
<NotebookIcon className="h-4 w-4 mr-2" />
|
|
{notebook.name}
|
|
</DropdownMenuItem>
|
|
)
|
|
})}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
|
|
{/* Pin Button - Visible on hover or if pinned */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
data-testid="pin-button"
|
|
className={cn(
|
|
"absolute top-2 right-12 z-20 min-h-[44px] min-w-[44px] h-8 w-8 p-0 rounded-md transition-opacity",
|
|
optimisticNote.isPinned ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
|
)}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
handleTogglePin()
|
|
}}
|
|
>
|
|
<Pin
|
|
className={cn("h-4 w-4", optimisticNote.isPinned ? "fill-current text-primary" : "text-muted-foreground")}
|
|
/>
|
|
</Button>
|
|
|
|
|
|
|
|
{/* Reminder Icon - Move slightly if pin button is there */}
|
|
{note.reminder && new Date(note.reminder) > new Date() && (
|
|
<Bell
|
|
className="absolute top-3 right-10 h-4 w-4 text-primary"
|
|
/>
|
|
)}
|
|
|
|
{/* Memory Echo Badges - Fusion + Connections (BEFORE Title) */}
|
|
<div className="flex flex-wrap gap-1 mb-2">
|
|
{/* Fusion Badge with remove button */}
|
|
{note.autoGenerated && (
|
|
<div className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 border border-purple-200 dark:border-purple-800 flex items-center gap-1 group/badge relative">
|
|
<Link2 className="h-2.5 w-2.5" />
|
|
{t('memoryEcho.fused')}
|
|
<button
|
|
onClick={handleRemoveFusedBadge}
|
|
className="ml-1 opacity-0 group-hover/badge:opacity-100 hover:opacity-100 transition-opacity"
|
|
title={t('notes.remove') || 'Remove'}
|
|
>
|
|
<X className="h-2.5 w-2.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Connections Badge */}
|
|
<ConnectionsBadge
|
|
noteId={note.id}
|
|
onClick={() => {
|
|
// Only open overlay if note is NOT open in editor
|
|
// (to avoid having 2 Dialogs with 2 close buttons)
|
|
if (!isNoteOpenInEditor) {
|
|
setShowConnectionsOverlay(true)
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Title */}
|
|
{optimisticNote.title && (
|
|
<h3 className="text-base font-medium mb-2 pr-10 text-foreground">
|
|
{optimisticNote.title}
|
|
</h3>
|
|
)}
|
|
|
|
{/* Search Match Type Badge */}
|
|
{optimisticNote.matchType && (
|
|
<Badge
|
|
variant={optimisticNote.matchType === 'exact' ? 'default' : 'secondary'}
|
|
className={cn(
|
|
'mb-2 text-xs',
|
|
optimisticNote.matchType === 'exact'
|
|
? 'bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-300 dark:border-green-800'
|
|
: 'bg-primary/10 text-primary border-primary/20 dark:bg-primary/20 dark:text-primary-foreground'
|
|
)}
|
|
>
|
|
{t(`semanticSearch.${optimisticNote.matchType === 'exact' ? 'exactMatch' : 'related'}`)}
|
|
</Badge>
|
|
)}
|
|
|
|
{/* Shared badge */}
|
|
{isSharedNote && owner && (
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-xs text-primary dark:text-primary-foreground font-medium">
|
|
{t('notes.sharedBy')} {owner.name || owner.email}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 px-2 text-xs text-gray-500 hover:text-red-600 dark:hover:text-red-400"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
handleLeaveShare()
|
|
}}
|
|
>
|
|
<X className="h-3 w-3 mr-1" />
|
|
{t('notes.leaveShare')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Images Component */}
|
|
<NoteImages images={optimisticNote.images || []} title={optimisticNote.title} />
|
|
|
|
{/* Link Previews */}
|
|
{optimisticNote.links && optimisticNote.links.length > 0 && (
|
|
<div className="flex flex-col gap-2 mb-2">
|
|
{optimisticNote.links.map((link, idx) => (
|
|
<a
|
|
key={idx}
|
|
href={link.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="block border rounded-md overflow-hidden bg-white/50 dark:bg-black/20 hover:bg-white/80 dark:hover:bg-black/40 transition-colors"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{link.imageUrl && (
|
|
<div className="h-24 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
|
|
)}
|
|
<div className="p-2">
|
|
<h4 className="font-medium text-xs truncate text-gray-900 dark:text-gray-100">{link.title || link.url}</h4>
|
|
{link.description && <p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 mt-0.5">{link.description}</p>}
|
|
<span className="text-[10px] text-primary mt-1 block">
|
|
{new URL(link.url).hostname}
|
|
</span>
|
|
</div>
|
|
</a>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Content */}
|
|
{optimisticNote.type === 'text' ? (
|
|
<div className="text-sm text-foreground line-clamp-10">
|
|
<MarkdownContent content={optimisticNote.content} />
|
|
</div>
|
|
) : (
|
|
<NoteChecklist
|
|
items={optimisticNote.checkItems || []}
|
|
onToggleItem={handleCheckItem}
|
|
/>
|
|
)}
|
|
|
|
{/* Labels - ONLY show if note belongs to a notebook (labels are contextual per PRD) */}
|
|
{optimisticNote.notebookId && optimisticNote.labels && optimisticNote.labels.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-3">
|
|
{optimisticNote.labels.map((label) => {
|
|
// Map label names to Keep style colors
|
|
const getLabelColor = (labelName: string) => {
|
|
if (labelName.includes('hôtels') || labelName.includes('réservations')) {
|
|
return 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300'
|
|
} else if (labelName.includes('vols') || labelName.includes('flight')) {
|
|
return 'bg-sky-50 dark:bg-sky-900/30 text-sky-700 dark:text-sky-300'
|
|
} else if (labelName.includes('restos') || labelName.includes('restaurant')) {
|
|
return 'bg-orange-50 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300'
|
|
} else {
|
|
return 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
|
|
}
|
|
}
|
|
|
|
// Map label names to Keep style icons
|
|
const getLabelIcon = (labelName: string) => {
|
|
if (labelName.includes('hôtels')) return 'label'
|
|
else if (labelName.includes('vols')) return 'flight'
|
|
else if (labelName.includes('restos')) return 'restaurant'
|
|
else return 'label'
|
|
}
|
|
|
|
const icon = getLabelIcon(label)
|
|
const colorClass = getLabelColor(label)
|
|
|
|
return (
|
|
<span
|
|
key={label}
|
|
className={cn(
|
|
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold",
|
|
colorClass
|
|
)}
|
|
>
|
|
<Tag className="w-3 h-3" />
|
|
{label}
|
|
</span>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Footer with Date only */}
|
|
<div className="mt-3 flex items-center justify-end">
|
|
{/* Creation Date */}
|
|
<div className="text-xs text-muted-foreground">
|
|
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: getDateLocale(language) })}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Owner Avatar - Aligned with action buttons at bottom */}
|
|
{owner && (
|
|
<div
|
|
className={cn(
|
|
"absolute bottom-2 left-2 z-20",
|
|
"w-6 h-6 rounded-full text-white text-[10px] font-semibold flex items-center justify-center",
|
|
getAvatarColor(owner.name || owner.email || 'Unknown')
|
|
)}
|
|
title={owner.name || owner.email || 'Unknown'}
|
|
>
|
|
{getInitials(owner.name || owner.email || '??')}
|
|
</div>
|
|
)}
|
|
|
|
{/* Action Bar Component - Always show for now to fix regression */}
|
|
{true && (
|
|
<NoteActions
|
|
isPinned={optimisticNote.isPinned}
|
|
isArchived={optimisticNote.isArchived}
|
|
currentColor={optimisticNote.color}
|
|
currentSize={optimisticNote.size as 'small' | 'medium' | 'large'}
|
|
onTogglePin={handleTogglePin}
|
|
onToggleArchive={handleToggleArchive}
|
|
onColorChange={handleColorChange}
|
|
onSizeChange={handleSizeChange}
|
|
onDelete={handleDelete}
|
|
onShareCollaborators={() => setShowCollaboratorDialog(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 && (
|
|
<div onClick={(e) => e.stopPropagation()}>
|
|
<CollaboratorDialog
|
|
open={showCollaboratorDialog}
|
|
onOpenChange={setShowCollaboratorDialog}
|
|
noteId={note.id}
|
|
noteOwnerId={note.userId}
|
|
currentUserId={currentUserId}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Connections Overlay */}
|
|
<div onClick={(e) => e.stopPropagation()}>
|
|
<ConnectionsOverlay
|
|
isOpen={showConnectionsOverlay}
|
|
onClose={() => setShowConnectionsOverlay(false)}
|
|
noteId={note.id}
|
|
onOpenNote={(noteId) => {
|
|
// Find the note and open it
|
|
onEdit?.(note, false)
|
|
}}
|
|
onCompareNotes={(noteIds) => {
|
|
setComparisonNotes(noteIds)
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Comparison Modal */}
|
|
{comparisonNotes && comparisonNotesData.length > 0 && (
|
|
<div onClick={(e) => e.stopPropagation()}>
|
|
<ComparisonModal
|
|
isOpen={!!comparisonNotes}
|
|
onClose={() => setComparisonNotes(null)}
|
|
notes={comparisonNotesData}
|
|
onOpenNote={(noteId) => {
|
|
const foundNote = comparisonNotesData.find(n => n.id === noteId)
|
|
if (foundNote) {
|
|
onEdit?.(foundNote, false)
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
)
|
|
} |