- 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
299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import { memo, 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
|
|
}
|
|
|
|
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 sm:p-5 hover:bg-gray-50 active:bg-gray-100 transition-colors tap-target"
|
|
>
|
|
<div className="flex items-center gap-2 sm:gap-3">
|
|
<Filter size={20} className="text-gray-600 flex-shrink-0" />
|
|
<span className="font-medium text-gray-900 text-sm sm:text-base">Filters & Sort</span>
|
|
{hasActiveFilters && (
|
|
<span className="bg-blue-100 text-blue-700 text-xs font-medium px-2 py-1 rounded whitespace-nowrap">
|
|
Active
|
|
</span>
|
|
)}
|
|
</div>
|
|
{isExpanded ? (
|
|
<ChevronUp size={20} className="text-gray-400 flex-shrink-0" />
|
|
) : (
|
|
<ChevronDown size={20} className="text-gray-400 flex-shrink-0" />
|
|
)}
|
|
</button>
|
|
|
|
{/* Filter Controls */}
|
|
{isExpanded && (
|
|
<div className="p-4 sm:p-5 border-t border-gray-200 space-y-4 sm:space-y-5">
|
|
{/* 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 sm:px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base tap-target"
|
|
>
|
|
<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-4 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 active:bg-gray-100 transition-colors tap-target text-lg font-medium"
|
|
title={filters.sortOrder === 'asc' ? 'Ascending' : 'Descending'}
|
|
aria-label={filters.sortOrder === 'asc' ? 'Sort ascending' : 'Sort 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="grid grid-cols-4 gap-2">
|
|
<button
|
|
onClick={() => handlePriorityChange(null)}
|
|
className={`px-2 sm:px-3 py-2.5 sm:py-3 rounded-lg border transition-colors tap-target text-xs sm:text-sm font-medium ${
|
|
filters.priority === null
|
|
? 'bg-blue-50 border-blue-500 text-blue-700'
|
|
: 'border-gray-300 hover:bg-gray-50 active:bg-gray-100'
|
|
}`}
|
|
>
|
|
All
|
|
</button>
|
|
<button
|
|
onClick={() => handlePriorityChange('low')}
|
|
className={`px-2 sm:px-3 py-2.5 sm:py-3 rounded-lg border transition-colors tap-target text-xs sm:text-sm font-medium ${
|
|
filters.priority === 'low'
|
|
? 'bg-green-50 border-green-500 text-green-700'
|
|
: 'border-gray-300 hover:bg-gray-50 active:bg-gray-100'
|
|
}`}
|
|
>
|
|
Low
|
|
</button>
|
|
<button
|
|
onClick={() => handlePriorityChange('medium')}
|
|
className={`px-2 sm:px-3 py-2.5 sm:py-3 rounded-lg border transition-colors tap-target text-xs sm:text-sm font-medium ${
|
|
filters.priority === 'medium'
|
|
? 'bg-yellow-50 border-yellow-500 text-yellow-700'
|
|
: 'border-gray-300 hover:bg-gray-50 active:bg-gray-100'
|
|
}`}
|
|
>
|
|
Med
|
|
</button>
|
|
<button
|
|
onClick={() => handlePriorityChange('high')}
|
|
className={`px-2 sm:px-3 py-2.5 sm:py-3 rounded-lg border transition-colors tap-target text-xs sm:text-sm font-medium ${
|
|
filters.priority === 'high'
|
|
? 'bg-red-50 border-red-500 text-red-700'
|
|
: 'border-gray-300 hover:bg-gray-50 active:bg-gray-100'
|
|
}`}
|
|
>
|
|
High
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Completion Status Filter */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Status
|
|
</label>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<button
|
|
onClick={() => handleCompletedChange(null)}
|
|
className={`px-3 py-2.5 sm:py-3 rounded-lg border transition-colors tap-target text-xs sm:text-sm font-medium ${
|
|
filters.completed === null
|
|
? 'bg-blue-50 border-blue-500 text-blue-700'
|
|
: 'border-gray-300 hover:bg-gray-50 active:bg-gray-100'
|
|
}`}
|
|
>
|
|
All
|
|
</button>
|
|
<button
|
|
onClick={() => handleCompletedChange(false)}
|
|
className={`px-3 py-2.5 sm:py-3 rounded-lg border transition-colors tap-target text-xs sm:text-sm font-medium ${
|
|
filters.completed === false
|
|
? 'bg-blue-50 border-blue-500 text-blue-700'
|
|
: 'border-gray-300 hover:bg-gray-50 active:bg-gray-100'
|
|
}`}
|
|
>
|
|
Incomplete
|
|
</button>
|
|
<button
|
|
onClick={() => handleCompletedChange(true)}
|
|
className={`px-3 py-2.5 sm:py-3 rounded-lg border transition-colors tap-target text-xs sm:text-sm font-medium ${
|
|
filters.completed === true
|
|
? 'bg-blue-50 border-blue-500 text-blue-700'
|
|
: 'border-gray-300 hover:bg-gray-50 active:bg-gray-100'
|
|
}`}
|
|
>
|
|
Complete
|
|
</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-2 rounded-full text-xs sm:text-sm border transition-colors tap-target-sm font-medium ${
|
|
filters.tags.includes(tag)
|
|
? 'bg-blue-50 border-blue-500 text-blue-700'
|
|
: 'border-gray-300 hover:bg-gray-50 active:bg-gray-100'
|
|
}`}
|
|
>
|
|
{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-1 sm:grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs text-gray-500 mb-1.5">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 sm:px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base tap-target"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-500 mb-1.5">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 sm:px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base tap-target"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Clear Filters Button */}
|
|
{hasActiveFilters && (
|
|
<button
|
|
onClick={handleClearFilters}
|
|
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-gray-100 hover:bg-gray-200 active:bg-gray-300 text-gray-700 rounded-lg transition-colors tap-target text-sm sm:text-base font-medium"
|
|
>
|
|
<X size={16} className="flex-shrink-0" />
|
|
Clear All Filters
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default memo(FilterPanel)
|