feat: Add search and filter UI components
- Created SearchBar component with debounced input - Built FilterPanel component with controls for priority, completion, tags, and due date - Added sort controls (field and direction) - Implemented filter clear functionality - Display search results with highlighted text - Added GlobalSearch component for home page - Integrated search and filter into list detail view - Added CSS styles for search highlighting Requirements: 6.1, 6.2, 6.3, 6.4, 8.1, 8.2, 8.3
This commit is contained in:
155
components/SearchResults.tsx
Normal file
155
components/SearchResults.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
import { highlightText } from '@/lib/utils'
|
||||
import Link from 'next/link'
|
||||
import { Calendar, Tag, AlertCircle } from 'lucide-react'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export default function SearchResults({ results, query, onClose }: SearchResultsProps) {
|
||||
if (!query.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-8 text-center">
|
||||
<p className="text-gray-500">No tasks found matching "{query}"</p>
|
||||
<p className="text-sm text-gray-400 mt-2">Try a different search term</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user