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:
2025-12-01 19:25:09 +00:00
parent 477e378d08
commit 0143097e69
9 changed files with 781 additions and 43 deletions

295
components/FilterPanel.tsx Normal file
View File

@@ -0,0 +1,295 @@
'use client'
import { useState } from 'react'
import { Filter, X, ChevronDown, ChevronUp } from 'lucide-react'
export interface FilterState {
priority: 'low' | 'medium' | 'high' | null
completed: boolean | null
tags: string[]
dueDateRange: { start: Date | null; end: Date | null }
sortBy: 'createdAt' | 'dueDate' | 'priority' | 'title'
sortOrder: 'asc' | 'desc'
}
interface FilterPanelProps {
filters: FilterState
onFiltersChange: (filters: FilterState) => void
availableTags?: string[]
showListScope?: boolean
}
export default function FilterPanel({
filters,
onFiltersChange,
availableTags = [],
showListScope = false
}: FilterPanelProps) {
const [isExpanded, setIsExpanded] = useState(false)
const hasActiveFilters =
filters.priority !== null ||
filters.completed !== null ||
filters.tags.length > 0 ||
filters.dueDateRange.start !== null ||
filters.dueDateRange.end !== null
const handlePriorityChange = (priority: 'low' | 'medium' | 'high' | null) => {
onFiltersChange({ ...filters, priority })
}
const handleCompletedChange = (completed: boolean | null) => {
onFiltersChange({ ...filters, completed })
}
const handleTagToggle = (tag: string) => {
const newTags = filters.tags.includes(tag)
? filters.tags.filter(t => t !== tag)
: [...filters.tags, tag]
onFiltersChange({ ...filters, tags: newTags })
}
const handleDueDateStartChange = (date: string) => {
const start = date ? new Date(date) : null
onFiltersChange({
...filters,
dueDateRange: { ...filters.dueDateRange, start }
})
}
const handleDueDateEndChange = (date: string) => {
const end = date ? new Date(date) : null
onFiltersChange({
...filters,
dueDateRange: { ...filters.dueDateRange, end }
})
}
const handleSortByChange = (sortBy: FilterState['sortBy']) => {
onFiltersChange({ ...filters, sortBy })
}
const handleSortOrderToggle = () => {
onFiltersChange({
...filters,
sortOrder: filters.sortOrder === 'asc' ? 'desc' : 'asc'
})
}
const handleClearFilters = () => {
onFiltersChange({
priority: null,
completed: null,
tags: [],
dueDateRange: { start: null, end: null },
sortBy: 'createdAt',
sortOrder: 'desc'
})
}
return (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
{/* Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
>
<div className="flex items-center gap-2">
<Filter size={20} className="text-gray-600" />
<span className="font-medium text-gray-900">Filters & Sort</span>
{hasActiveFilters && (
<span className="bg-blue-100 text-blue-700 text-xs font-medium px-2 py-1 rounded">
Active
</span>
)}
</div>
{isExpanded ? (
<ChevronUp size={20} className="text-gray-400" />
) : (
<ChevronDown size={20} className="text-gray-400" />
)}
</button>
{/* Filter Controls */}
{isExpanded && (
<div className="p-4 border-t border-gray-200 space-y-4">
{/* Sort Controls */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Sort By
</label>
<div className="flex gap-2">
<select
value={filters.sortBy}
onChange={(e) => handleSortByChange(e.target.value as FilterState['sortBy'])}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="createdAt">Created Date</option>
<option value="dueDate">Due Date</option>
<option value="priority">Priority</option>
<option value="title">Title</option>
</select>
<button
onClick={handleSortOrderToggle}
className="px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
title={filters.sortOrder === 'asc' ? 'Ascending' : 'Descending'}
>
{filters.sortOrder === 'asc' ? '↑' : '↓'}
</button>
</div>
</div>
{/* Priority Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Priority
</label>
<div className="flex gap-2">
<button
onClick={() => handlePriorityChange(null)}
className={`flex-1 px-3 py-2 rounded-lg border transition-colors ${
filters.priority === null
? 'bg-blue-50 border-blue-500 text-blue-700'
: 'border-gray-300 hover:bg-gray-50'
}`}
>
All
</button>
<button
onClick={() => handlePriorityChange('low')}
className={`flex-1 px-3 py-2 rounded-lg border transition-colors ${
filters.priority === 'low'
? 'bg-green-50 border-green-500 text-green-700'
: 'border-gray-300 hover:bg-gray-50'
}`}
>
Low
</button>
<button
onClick={() => handlePriorityChange('medium')}
className={`flex-1 px-3 py-2 rounded-lg border transition-colors ${
filters.priority === 'medium'
? 'bg-yellow-50 border-yellow-500 text-yellow-700'
: 'border-gray-300 hover:bg-gray-50'
}`}
>
Medium
</button>
<button
onClick={() => handlePriorityChange('high')}
className={`flex-1 px-3 py-2 rounded-lg border transition-colors ${
filters.priority === 'high'
? 'bg-red-50 border-red-500 text-red-700'
: 'border-gray-300 hover:bg-gray-50'
}`}
>
High
</button>
</div>
</div>
{/* Completion Status Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Status
</label>
<div className="flex gap-2">
<button
onClick={() => handleCompletedChange(null)}
className={`flex-1 px-3 py-2 rounded-lg border transition-colors ${
filters.completed === null
? 'bg-blue-50 border-blue-500 text-blue-700'
: 'border-gray-300 hover:bg-gray-50'
}`}
>
All
</button>
<button
onClick={() => handleCompletedChange(false)}
className={`flex-1 px-3 py-2 rounded-lg border transition-colors ${
filters.completed === false
? 'bg-blue-50 border-blue-500 text-blue-700'
: 'border-gray-300 hover:bg-gray-50'
}`}
>
Incomplete
</button>
<button
onClick={() => handleCompletedChange(true)}
className={`flex-1 px-3 py-2 rounded-lg border transition-colors ${
filters.completed === true
? 'bg-blue-50 border-blue-500 text-blue-700'
: 'border-gray-300 hover:bg-gray-50'
}`}
>
Completed
</button>
</div>
</div>
{/* Tags Filter */}
{availableTags.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Tags
</label>
<div className="flex flex-wrap gap-2">
{availableTags.map((tag) => (
<button
key={tag}
onClick={() => handleTagToggle(tag)}
className={`px-3 py-1 rounded-full text-sm border transition-colors ${
filters.tags.includes(tag)
? 'bg-blue-50 border-blue-500 text-blue-700'
: 'border-gray-300 hover:bg-gray-50'
}`}
>
{tag}
</button>
))}
</div>
</div>
)}
{/* Due Date Range Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Due Date Range
</label>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs text-gray-500 mb-1">From</label>
<input
type="date"
value={filters.dueDateRange.start ? filters.dueDateRange.start.toISOString().split('T')[0] : ''}
onChange={(e) => handleDueDateStartChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">To</label>
<input
type="date"
value={filters.dueDateRange.end ? filters.dueDateRange.end.toISOString().split('T')[0] : ''}
onChange={(e) => handleDueDateEndChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</div>
{/* Clear Filters Button */}
{hasActiveFilters && (
<button
onClick={handleClearFilters}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
>
<X size={16} />
Clear All Filters
</button>
)}
</div>
)}
</div>
)
}