Keep/keep-notes/components/note-card.tsx
sepehr 640fcb26f7 fix: improve note interactions and markdown LaTeX support
## Bug Fixes

### Note Card Actions
- Fix broken size change functionality (missing state declaration)
- Implement React 19 useOptimistic for instant UI feedback
- Add startTransition for non-blocking updates
- Ensure smooth animations without page refresh
- All note actions now work: pin, archive, color, size, checklist

### Markdown LaTeX Rendering
- Add remark-math and rehype-katex plugins
- Support inline equations with dollar sign syntax
- Support block equations with double dollar sign syntax
- Import KaTeX CSS for proper styling
- Equations now render correctly instead of showing raw LaTeX

## Technical Details

- Replace undefined currentNote references with optimistic state
- Add optimistic updates before server actions for instant feedback
- Use router.refresh() in transitions for smart cache invalidation
- Install remark-math, rehype-katex, and katex packages

## Testing

- Build passes successfully with no TypeScript errors
- Dev server hot-reloads changes correctly
2026-01-09 22:13:49 +01:00

321 lines
11 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 { Pin, Bell, GripVertical, X } from 'lucide-react'
import { useState, useEffect, useTransition, useOptimistic } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, getNoteAllUsers, leaveSharedNote } from '@/app/actions/notes'
import { cn } from '@/lib/utils'
import { formatDistanceToNow } from 'date-fns'
import { fr } 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 { useLabels } from '@/context/LabelContext'
interface NoteCardProps {
note: Note
onEdit?: (note: Note, readOnly?: boolean) => void
isDragging?: boolean
isDragOver?: boolean
}
export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps) {
const router = useRouter()
const { refreshLabels } = useLabels()
const { data: session } = useSession()
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 colorClasses = NOTE_COLORS[note.color as NoteColor] || NOTE_COLORS.default
// Optimistic UI state for instant feedback
const [optimisticNote, addOptimisticNote] = useOptimistic(
note,
(state, newProps: Partial<Note>) => ({ ...state, ...newProps })
)
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('Are you sure you want to delete this note?')) {
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()
})
}
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 () => {
addOptimisticNote({ size })
await updateNote(note.id, { size })
router.refresh()
})
}
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('Are you sure you want to leave this shared note?')) {
try {
await leaveSharedNote(note.id)
setIsDeleting(true) // Hide the note from view
} catch (error) {
console.error('Failed to leave share:', error)
}
}
}
if (isDeleting) return null
return (
<Card
data-testid="note-card"
className={cn(
'note-card-main group relative p-4 transition-all duration-200 border cursor-move',
'hover:shadow-md',
colorClasses.bg,
colorClasses.card,
colorClasses.hover,
isDragging && 'opacity-30',
isDragOver && 'ring-2 ring-blue-500'
)}
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('.drag-handle')) {
// For shared notes, pass readOnly flag
onEdit?.(note, !!isSharedNote) // Pass second parameter as readOnly flag (convert to boolean)
}
}}
>
{/* Drag Handle - Visible only on mobile/touch devices */}
<div className="absolute top-2 left-2 z-20 md:hidden cursor-grab active:cursor-grabbing drag-handle touch-none">
<GripVertical className="h-4 w-4 text-gray-400 dark:text-gray-500" />
</div>
{/* Pin Button - Visible on hover or if pinned, always accessible */}
<Button
variant="ghost"
size="sm"
className={cn(
"absolute top-2 right-2 z-20 h-8 w-8 p-0 rounded-full transition-opacity",
optimisticNote.isPinned ? "opacity-100" : "opacity-0 group-hover:opacity-100",
"md:flex", // On desktop follow hover logic
"flex" // Ensure it's a flex container for the icon
)}
onClick={(e) => {
e.stopPropagation();
handleTogglePin();
}}
>
<Pin
className={cn("h-4 w-4", optimisticNote.isPinned ? "fill-current text-blue-600" : "text-gray-400")}
/>
</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-blue-600 dark:text-blue-400"
/>
)}
{/* Title */}
{optimisticNote.title && (
<h3 className="text-base font-medium mb-2 pr-10 text-gray-900 dark:text-gray-100">
{optimisticNote.title}
</h3>
)}
{/* Shared badge */}
{isSharedNote && owner && (
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium">
Shared by {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" />
Leave
</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-blue-500 mt-1 block">
{new URL(link.url).hostname}
</span>
</div>
</a>
))}
</div>
)}
{/* Content */}
{optimisticNote.type === 'text' ? (
<div className="text-sm text-gray-700 dark:text-gray-300 line-clamp-10">
<MarkdownContent content={optimisticNote.content} />
</div>
) : (
<NoteChecklist
items={optimisticNote.checkItems || []}
onToggleItem={handleCheckItem}
/>
)}
{/* Labels */}
{optimisticNote.labels && optimisticNote.labels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{optimisticNote.labels.map((label) => (
<LabelBadge key={label} label={label} />
))}
</div>
)}
{/* Collaborators */}
{optimisticNote.userId && collaborators.length > 0 && (
<CollaboratorAvatars
collaborators={collaborators}
ownerId={optimisticNote.userId}
/>
)}
{/* Creation Date */}
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: fr })}
</div>
{/* Action Bar Component - Only for owner */}
{isOwner && (
<NoteActions
isPinned={optimisticNote.isPinned}
isArchived={optimisticNote.isArchived}
currentColor={optimisticNote.color}
currentSize={optimisticNote.size}
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>
)}
</Card>
)
}