fix: Add debounced Undo/Redo system to avoid character-by-character history
- Add debounced state updates for title and content (500ms delay) - Immediate UI updates with delayed history saving - Prevent one-letter-per-undo issue - Add cleanup for debounce timers on unmount
This commit is contained in:
292
keep-notes/components/note-card.tsx
Normal file
292
keep-notes/components/note-card.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
'use client'
|
||||
|
||||
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
MoreVertical,
|
||||
Palette,
|
||||
Pin,
|
||||
Tag,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote } from '@/app/actions/notes'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface NoteCardProps {
|
||||
note: Note
|
||||
onEdit?: (note: Note) => void
|
||||
onDragStart?: (note: Note) => void
|
||||
onDragEnd?: () => void
|
||||
onDragOver?: (note: Note) => void
|
||||
isDragging?: boolean
|
||||
}
|
||||
|
||||
export function NoteCard({ note, onEdit, onDragStart, onDragEnd, onDragOver, isDragging }: NoteCardProps) {
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const colorClasses = NOTE_COLORS[note.color as NoteColor] || NOTE_COLORS.default
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (confirm('Are you sure you want to delete this note?')) {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await deleteNote(note.id)
|
||||
} catch (error) {
|
||||
console.error('Failed to delete note:', error)
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTogglePin = async () => {
|
||||
await togglePin(note.id, !note.isPinned)
|
||||
}
|
||||
|
||||
const handleToggleArchive = async () => {
|
||||
await toggleArchive(note.id, !note.isArchived)
|
||||
}
|
||||
|
||||
const handleColorChange = async (color: string) => {
|
||||
await updateColor(note.id, color)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
await updateNote(note.id, { checkItems: updatedItems })
|
||||
}
|
||||
}
|
||||
|
||||
if (isDeleting) return null
|
||||
|
||||
return (
|
||||
<Card
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation()
|
||||
onDragStart?.(note)
|
||||
}}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onDragOver?.(note)
|
||||
}}
|
||||
className={cn(
|
||||
'group relative p-4 transition-all duration-200 border',
|
||||
'cursor-move hover:shadow-md',
|
||||
colorClasses.card,
|
||||
isDragging && 'opacity-30 scale-95'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
// Only trigger edit if not clicking on buttons
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('button') && !target.closest('[role="checkbox"]')) {
|
||||
onEdit?.(note)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Pin Icon */}
|
||||
{note.isPinned && (
|
||||
<Pin className="absolute top-3 right-3 h-4 w-4 text-gray-600 dark:text-gray-400" fill="currentColor" />
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
{note.title && (
|
||||
<h3 className="text-base font-medium mb-2 pr-6 text-gray-900 dark:text-gray-100">
|
||||
{note.title}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{/* Images */}
|
||||
{note.images && note.images.length > 0 && (
|
||||
<div className={cn(
|
||||
"mb-3 -mx-4",
|
||||
!note.title && "-mt-4"
|
||||
)}>
|
||||
{note.images.length === 1 ? (
|
||||
<img
|
||||
src={note.images[0]}
|
||||
alt=""
|
||||
className="w-full h-auto rounded-lg"
|
||||
/>
|
||||
) : note.images.length === 2 ? (
|
||||
<div className="grid grid-cols-2 gap-2 px-4">
|
||||
{note.images.map((img, idx) => (
|
||||
<img
|
||||
key={idx}
|
||||
src={img}
|
||||
alt=""
|
||||
className="w-full h-auto rounded-lg"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : note.images.length === 3 ? (
|
||||
<div className="grid grid-cols-2 gap-2 px-4">
|
||||
<img
|
||||
src={note.images[0]}
|
||||
alt=""
|
||||
className="col-span-2 w-full h-auto rounded-lg"
|
||||
/>
|
||||
{note.images.slice(1).map((img, idx) => (
|
||||
<img
|
||||
key={idx}
|
||||
src={img}
|
||||
alt=""
|
||||
className="w-full h-auto rounded-lg"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2 px-4">
|
||||
{note.images.slice(0, 4).map((img, idx) => (
|
||||
<img
|
||||
key={idx}
|
||||
src={img}
|
||||
alt=""
|
||||
className="w-full h-auto rounded-lg"
|
||||
/>
|
||||
))}
|
||||
{note.images.length > 4 && (
|
||||
<div className="absolute bottom-2 right-2 bg-black/70 text-white px-2 py-1 rounded text-xs">
|
||||
+{note.images.length - 4}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{note.type === 'text' ? (
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap line-clamp-10">
|
||||
{note.content}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{note.checkItems?.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-start gap-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCheckItem(item.id)
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={item.checked}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm',
|
||||
item.checked
|
||||
? 'line-through text-gray-500 dark:text-gray-500'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
)}
|
||||
>
|
||||
{item.text}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Labels */}
|
||||
{note.labels && note.labels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{note.labels.map((label) => (
|
||||
<Badge key={label} variant="secondary" className="text-xs">
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Bar - Shows on Hover */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 p-2 flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Pin Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handleTogglePin}
|
||||
title={note.isPinned ? 'Unpin' : 'Pin'}
|
||||
>
|
||||
<Pin className={cn('h-4 w-4', note.isPinned && 'fill-current')} />
|
||||
</Button>
|
||||
|
||||
{/* Color Palette */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" title="Change color">
|
||||
<Palette className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<div className="grid grid-cols-5 gap-2 p-2">
|
||||
{Object.entries(NOTE_COLORS).map(([colorName, classes]) => (
|
||||
<button
|
||||
key={colorName}
|
||||
className={cn(
|
||||
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
|
||||
classes.bg,
|
||||
note.color === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700'
|
||||
)}
|
||||
onClick={() => handleColorChange(colorName)}
|
||||
title={colorName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* More Options */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleToggleArchive}>
|
||||
{note.isArchived ? (
|
||||
<>
|
||||
<ArchiveRestore className="h-4 w-4 mr-2" />
|
||||
Unarchive
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive className="h-4 w-4 mr-2" />
|
||||
Archive
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleDelete} className="text-red-600 dark:text-red-400">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user