- Add database indexes for frequently queried fields (listId, completed, priority, dueDate, createdAt) - Add compound indexes for common query patterns (listId+completed, listId+priority) - Implement pagination support in getItemsByFilter with configurable page size (default 50) - Optimize components with React.memo (ItemCard, ListCard, FilterPanel, SearchBar, SearchResults, EmptyState) - Configure Next.js for better bundle optimization: - Enable optimizePackageImports for lucide-react - Enable SWC minification - Remove console logs in production (except errors/warnings) - Optimize images with AVIF/WebP formats - Enable compression - Disable production source maps for smaller bundles - Loading states already implemented to prevent layout shift Task 18 complete - all performance optimizations implemented
161 lines
5.2 KiB
TypeScript
161 lines
5.2 KiB
TypeScript
'use client'
|
|
|
|
import { memo } from 'react'
|
|
import { highlightText } from '@/lib/utils'
|
|
import Link from 'next/link'
|
|
import { Calendar, Tag, AlertCircle } from 'lucide-react'
|
|
import EmptyState from './EmptyState'
|
|
|
|
interface SearchResultsProps {
|
|
results: Array<{
|
|
id: string
|
|
title: string
|
|
description: string | null
|
|
completed: boolean
|
|
priority: string
|
|
dueDate: Date | null
|
|
tags: any
|
|
notes: string | null
|
|
list: {
|
|
id: string
|
|
title: string
|
|
color: string
|
|
}
|
|
}>
|
|
query: string
|
|
onClose?: () => void
|
|
}
|
|
|
|
function SearchResults({ results, query, onClose }: SearchResultsProps) {
|
|
if (!query.trim()) {
|
|
return null
|
|
}
|
|
|
|
if (results.length === 0) {
|
|
return (
|
|
<EmptyState
|
|
icon="search"
|
|
title={`No tasks found matching "${query}"`}
|
|
description="Try a different search term or check your spelling"
|
|
/>
|
|
)
|
|
}
|
|
|
|
const priorityColors = {
|
|
low: 'bg-green-100 text-green-700 border-green-200',
|
|
medium: 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
|
high: 'bg-red-100 text-red-700 border-red-200',
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2 animate-fade-in">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<p className="text-sm text-gray-600">
|
|
Found {results.length} {results.length === 1 ? 'task' : 'tasks'}
|
|
</p>
|
|
</div>
|
|
|
|
{results.map((item) => {
|
|
const tags = Array.isArray(item.tags) ? item.tags : []
|
|
const isOverdue = item.dueDate && new Date(item.dueDate) < new Date() && !item.completed
|
|
const priorityColor = priorityColors[item.priority as keyof typeof priorityColors] || priorityColors.medium
|
|
|
|
return (
|
|
<Link
|
|
key={item.id}
|
|
href={`/list/${item.list.id}/item/${item.id}`}
|
|
onClick={onClose}
|
|
className="block bg-white rounded-lg border border-gray-200 p-4 hover:shadow-md transition-shadow"
|
|
>
|
|
{/* List Badge */}
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<div
|
|
className="w-3 h-3 rounded-full"
|
|
style={{ backgroundColor: item.list.color }}
|
|
/>
|
|
<span className="text-xs text-gray-500">{item.list.title}</span>
|
|
</div>
|
|
|
|
{/* Title with highlighting */}
|
|
<h3
|
|
className={`font-medium mb-1 ${
|
|
item.completed ? 'text-gray-500 line-through' : 'text-gray-900'
|
|
}`}
|
|
dangerouslySetInnerHTML={{
|
|
__html: highlightText(item.title, query)
|
|
}}
|
|
/>
|
|
|
|
{/* Description with highlighting */}
|
|
{item.description && (
|
|
<p
|
|
className={`text-sm mb-2 line-clamp-2 ${
|
|
item.completed ? 'text-gray-400' : 'text-gray-600'
|
|
}`}
|
|
dangerouslySetInnerHTML={{
|
|
__html: highlightText(item.description, query)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Notes with highlighting (if matches) */}
|
|
{item.notes && item.notes.toLowerCase().includes(query.toLowerCase()) && (
|
|
<p
|
|
className="text-sm text-gray-500 mb-2 line-clamp-1"
|
|
dangerouslySetInnerHTML={{
|
|
__html: 'Notes: ' + highlightText(item.notes, query)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Metadata */}
|
|
<div className="flex flex-wrap items-center gap-2 mt-2">
|
|
{/* Priority Badge */}
|
|
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium border ${priorityColor}`}>
|
|
<AlertCircle size={12} />
|
|
{item.priority.charAt(0).toUpperCase() + item.priority.slice(1)}
|
|
</span>
|
|
|
|
{/* Due Date */}
|
|
{item.dueDate && (
|
|
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs ${
|
|
isOverdue
|
|
? 'bg-red-100 text-red-700 border border-red-200 font-semibold'
|
|
: 'bg-gray-100 text-gray-700'
|
|
}`}>
|
|
<Calendar size={12} />
|
|
{new Date(item.dueDate).toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
})}
|
|
{isOverdue && ' (Overdue)'}
|
|
</span>
|
|
)}
|
|
|
|
{/* Tags with highlighting */}
|
|
{tags.length > 0 && (
|
|
<div className="inline-flex items-center gap-1">
|
|
<Tag size={12} className="text-gray-400" />
|
|
<span
|
|
className="text-xs text-gray-600"
|
|
dangerouslySetInnerHTML={{
|
|
__html: highlightText(tags.slice(0, 2).join(', '), query)
|
|
}}
|
|
/>
|
|
{tags.length > 2 && (
|
|
<span className="text-xs text-gray-600">
|
|
{` +${tags.length - 2}`}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Link>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default memo(SearchResults)
|