All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 4s
Covers architecture, configuration steps, user flows, API routes, webhooks, pricing, testing with Stripe CLI, production checklist, and troubleshooting.
889 lines
32 KiB
TypeScript
889 lines
32 KiB
TypeScript
'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 } 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 { 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: () => <div className="text-sm text-muted-foreground animate-pulse">…</div>,
|
|
})
|
|
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 { useRefresh } from '@/lib/use-refresh'
|
|
import { useLanguage } from '@/lib/i18n'
|
|
import { toast } from 'sonner'
|
|
|
|
// Mapping of supported languages to date-fns locales
|
|
const localeMap: Record<string, Locale> = {
|
|
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<NoteType, LucideIcon> = {
|
|
text: AlignLeft,
|
|
markdown: FileCode2,
|
|
richtext: PenLine,
|
|
checklist: ListChecks,
|
|
}
|
|
|
|
// 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
|
|
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 { refreshNotes } = useRefresh()
|
|
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<any[]>([])
|
|
const [owner, setOwner] = useState<any>(null)
|
|
const [showConnectionsOverlay, setShowConnectionsOverlay] = useState(false)
|
|
const [comparisonNotes, setComparisonNotes] = useState<string[] | null>(null)
|
|
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
|
|
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
|
|
const [reminderDate, setReminderDate] = useState<Date | null>(note.reminder ? new Date(note.reminder) : null)
|
|
|
|
const sanitizedHtml = useMemo(() => {
|
|
if (note.type !== 'richtext' || !note.content) return ''
|
|
if (typeof window !== 'undefined') {
|
|
return require('isomorphic-dompurify').sanitize(note.content)
|
|
}
|
|
return note.content
|
|
}, [note.type, note.content])
|
|
|
|
const handleUpdateReminder = async (noteId: string, reminder: Date | null) => {
|
|
startTransition(async () => {
|
|
try {
|
|
await updateNote(noteId, { reminder })
|
|
setReminderDate(reminder)
|
|
refreshNotes(note?.notebookId)
|
|
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<Note>) => ({ ...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)
|
|
await refreshLabels()
|
|
refreshNotes(note?.notebookId) // met à jour la liste et le compteur du carnet
|
|
} 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)
|
|
refreshNotes(note?.notebookId)
|
|
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)
|
|
refreshNotes(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)
|
|
refreshNotes(note?.notebookId)
|
|
|
|
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)
|
|
refreshNotes(note?.notebookId)
|
|
})
|
|
}
|
|
|
|
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 = 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 })
|
|
})
|
|
}
|
|
}
|
|
|
|
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 (
|
|
<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-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 */}
|
|
<div
|
|
className="muuri-drag-handle absolute top-2 start-2 z-20 cursor-grab active:cursor-grabbing p-2 md:hidden"
|
|
aria-label={t('notes.dragToReorder')}
|
|
title={t('notes.dragToReorder')}
|
|
>
|
|
<GripVertical className="h-5 w-5 text-muted-foreground" />
|
|
</div>
|
|
|
|
{/* Move to Notebook Dropdown Menu — hidden in trash */}
|
|
{!isTrashView && <div onClick={(e) => e.stopPropagation()} className="absolute top-2 end-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 me-2" />
|
|
{t('notebookSuggestion.generalNotes')}
|
|
</DropdownMenuItem>
|
|
{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 (
|
|
<DropdownMenuSub key={notebook.id}>
|
|
<DropdownMenuSubTrigger className="gap-2">
|
|
<NotebookIcon className="h-4 w-4" />
|
|
{notebook.name}
|
|
<ChevronRight className="h-3 w-3 ms-auto opacity-50 rtl:rotate-180" />
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent>
|
|
<DropdownMenuItem onClick={() => handleMoveToNotebook(notebook.id)}>
|
|
<NotebookIcon className="h-4 w-4 me-2" />
|
|
{notebook.name}
|
|
</DropdownMenuItem>
|
|
{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 (
|
|
<DropdownMenuItem key={child.id} onClick={() => handleMoveToNotebook(child.id)}>
|
|
<NotebookIcon className="h-4 w-4 me-2" />
|
|
<span style={{ marginInlineStart: `${depth * 0.5}rem` }}>{child.name}</span>
|
|
</DropdownMenuItem>
|
|
)
|
|
})}
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
)
|
|
}
|
|
return (
|
|
<DropdownMenuItem key={notebook.id} onClick={() => handleMoveToNotebook(notebook.id)}>
|
|
<NotebookIcon className="h-4 w-4 me-2" />
|
|
{notebook.name}
|
|
</DropdownMenuItem>
|
|
)
|
|
})}
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem className="text-muted-foreground" onSelect={() => onCreateSubNotebook?.()}>
|
|
<Plus className="h-4 w-4 me-2" />
|
|
{t('notebook.createSubNotebook')}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>}
|
|
|
|
{/* Pin Button - hidden in trash */}
|
|
{!isTrashView && <Button
|
|
variant="ghost"
|
|
size="sm"
|
|
data-testid="pin-button"
|
|
className={cn(
|
|
"absolute top-2 end-12 z-20 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 end-10 h-4 w-4 text-primary"
|
|
/>
|
|
)}
|
|
|
|
{/* Fusion Badge */}
|
|
{note.aiProvider === 'fusion' && optimisticNote.autoGenerated !== null && (
|
|
<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 mb-2 w-fit">
|
|
<Link2 className="h-2.5 w-2.5" />
|
|
{t('memoryEcho.fused')}
|
|
<button
|
|
onClick={handleRemoveFusedBadge}
|
|
className="ms-1 opacity-0 group-hover/badge:opacity-100 hover:opacity-100 transition-opacity"
|
|
title={t('notes.remove')}
|
|
>
|
|
<Trash2 className="h-2.5 w-2.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Title */}
|
|
{note.title && (
|
|
<h3 dir="auto" className="text-lg font-heading font-semibold mb-2 pe-20 text-foreground leading-tight tracking-tight flex items-center gap-2">
|
|
{(() => {
|
|
const TypeIcon = NOTE_TYPE_ICONS[note.type] || AlignLeft
|
|
return <TypeIcon className="h-4 w-4 shrink-0 text-muted-foreground/50" />
|
|
})()}
|
|
<span className="min-w-0 truncate">{note.title}</span>
|
|
</h3>
|
|
)}
|
|
|
|
{/* Search Match Type Badge */}
|
|
{note.matchType && (
|
|
<Badge
|
|
variant={note.matchType === 'exact' ? 'default' : 'secondary'}
|
|
className={cn(
|
|
'mb-2 text-xs',
|
|
note.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.${note.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()
|
|
}}
|
|
>
|
|
<LogOut className="h-3 w-3 me-1" />
|
|
{t('notes.leaveShare')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Images Component */}
|
|
<NoteImages images={note.images || []} title={note.title} />
|
|
|
|
{/* Link Previews */}
|
|
{Array.isArray(note.links) && note.links.length > 0 && (
|
|
<div className="flex flex-col gap-2 mb-2">
|
|
{note.links.map((link: any, idx: number) => (
|
|
<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 */}
|
|
{note.type === 'checklist' ? (
|
|
<NoteChecklist
|
|
items={localCheckItems || optimisticNote.checkItems || note.checkItems || []}
|
|
onToggleItem={handleCheckItem}
|
|
/>
|
|
) : note.type === 'richtext' ? (
|
|
<div className="text-sm text-foreground line-clamp-10 rt-preview" dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />
|
|
) : (
|
|
<div className="text-sm text-foreground line-clamp-10">
|
|
<MarkdownContent
|
|
content={note.content}
|
|
className="prose-h1:text-xl prose-h1:font-semibold prose-h1:leading-snug prose-h1:mt-1 prose-h1:mb-2 prose-h2:text-lg prose-h2:font-medium prose-h3:text-base prose-p:text-sm prose-p:leading-relaxed"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Labels - using shared LabelBadge component */}
|
|
{note.notebookId && Array.isArray(note.labels) && note.labels.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-3">
|
|
{note.labels.map((label: string) => (
|
|
<LabelBadge key={label} label={label} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Footer with Date only */}
|
|
<div className="mt-3 flex items-center justify-end">
|
|
{/* Creation Date */}
|
|
<div className="text-xs text-muted-foreground" suppressHydrationWarning>
|
|
{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 start-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={() => 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 && (
|
|
<div onClick={(e) => e.stopPropagation()}>
|
|
<CollaboratorDialog
|
|
open={showCollaboratorDialog}
|
|
onOpenChange={setShowCollaboratorDialog}
|
|
noteId={note.id}
|
|
noteOwnerId={note.userId}
|
|
currentUserId={currentUserId}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Connections Badge - Bottom right (spec: amber, absolute) */}
|
|
<div className="absolute bottom-2 end-2 z-10">
|
|
<ConnectionsBadge
|
|
noteId={note.id}
|
|
onClick={() => {
|
|
if (!isNoteOpenInEditor) {
|
|
setShowConnectionsOverlay(true)
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Connections Overlay */}
|
|
<div onClick={(e) => e.stopPropagation()}>
|
|
<ConnectionsOverlay
|
|
isOpen={showConnectionsOverlay}
|
|
onClose={() => 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<Partial<Note>>)
|
|
}}
|
|
/>
|
|
</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>
|
|
)}
|
|
|
|
{/* Fusion Modal */}
|
|
{fusionNotes.length > 0 && (
|
|
<div onClick={(e) => e.stopPropagation()}>
|
|
<FusionModal
|
|
isOpen={fusionNotes.length > 0}
|
|
onClose={() => 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,
|
|
aiProvider: 'fusion',
|
|
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'))
|
|
setFusionNotes([])
|
|
refreshNotes(note?.notebookId)
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t('notes.confirmDeleteTitle') || t('notes.delete')}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t('notes.confirmDelete')}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
|
<AlertDialogAction variant="destructive" onClick={handleDelete}>
|
|
{t('notes.delete')}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</Card>
|
|
)
|
|
}) |