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:
295
components/FilterPanel.tsx
Normal file
295
components/FilterPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user