- Added multi-provider AI infrastructure (OpenAI/Ollama) - Implemented real-time tag suggestions with debounced analysis - Created AI diagnostics and database maintenance tools in Settings - Added automated garbage collection for orphan labels - Refined UX with deterministic color hashing and interactive ghost tags
183 lines
5.9 KiB
TypeScript
183 lines
5.9 KiB
TypeScript
'use client'
|
|
|
|
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types'
|
|
import { Card } from '@/components/ui/card'
|
|
import { Pin, Bell } from 'lucide-react'
|
|
import { useState } from 'react'
|
|
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote } 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 { useLabels } from '@/context/LabelContext'
|
|
|
|
interface NoteCardProps {
|
|
note: Note
|
|
onEdit?: (note: Note) => void
|
|
isDragging?: boolean
|
|
isDragOver?: boolean
|
|
}
|
|
|
|
export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps) {
|
|
const { refreshLabels } = useLabels()
|
|
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)
|
|
// Refresh global labels to reflect garbage collection
|
|
await refreshLabels()
|
|
} 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
|
|
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"]')) {
|
|
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" />
|
|
)}
|
|
|
|
{/* Reminder Icon */}
|
|
{note.reminder && new Date(note.reminder) > new Date() && (
|
|
<Bell
|
|
className={cn(
|
|
"absolute h-4 w-4 text-blue-600 dark:text-blue-400",
|
|
note.isPinned ? "top-3 right-9" : "top-3 right-3"
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{/* Title */}
|
|
{note.title && (
|
|
<h3 className="text-base font-medium mb-2 pr-6 text-gray-900 dark:text-gray-100">
|
|
{note.title}
|
|
</h3>
|
|
)}
|
|
|
|
{/* Images Component */}
|
|
<NoteImages images={note.images || []} title={note.title} />
|
|
|
|
{/* Link Previews */}
|
|
{note.links && note.links.length > 0 && (
|
|
<div className="flex flex-col gap-2 mb-2">
|
|
{note.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 */}
|
|
{note.type === 'text' ? (
|
|
note.isMarkdown ? (
|
|
<div className="text-sm line-clamp-10">
|
|
<MarkdownContent content={note.content} />
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap line-clamp-10">
|
|
{note.content}
|
|
</p>
|
|
)
|
|
) : (
|
|
<NoteChecklist
|
|
items={note.checkItems || []}
|
|
onToggleItem={handleCheckItem}
|
|
/>
|
|
)}
|
|
|
|
{/* Labels */}
|
|
{note.labels && note.labels.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-3">
|
|
{note.labels.map((label) => (
|
|
<LabelBadge key={label} label={label} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 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 */}
|
|
<NoteActions
|
|
isPinned={note.isPinned}
|
|
isArchived={note.isArchived}
|
|
currentColor={note.color}
|
|
onTogglePin={handleTogglePin}
|
|
onToggleArchive={handleToggleArchive}
|
|
onColorChange={handleColorChange}
|
|
onDelete={handleDelete}
|
|
className="absolute bottom-0 left-0 right-0 p-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
/>
|
|
</Card>
|
|
)
|
|
} |