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:
@@ -120,7 +120,7 @@
|
|||||||
- **Property 14: Overdue task identification**
|
- **Property 14: Overdue task identification**
|
||||||
- **Validates: Requirements 5.5**
|
- **Validates: Requirements 5.5**
|
||||||
|
|
||||||
- [ ] 8. Add search and filter UI components
|
- [-] 8. Add search and filter UI components
|
||||||
- Create SearchBar component with debounced input
|
- Create SearchBar component with debounced input
|
||||||
- Build FilterPanel component with controls for priority, completion, tags, and due date
|
- Build FilterPanel component with controls for priority, completion, tags, and due date
|
||||||
- Add sort controls (field and direction)
|
- Add sort controls (field and direction)
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* Search highlight styles */
|
||||||
|
mark {
|
||||||
|
background-color: #fef08a;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { getListWithItems } from '@/lib/actions'
|
import { getListWithItems } from '@/lib/actions'
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { ArrowLeft, Plus } from 'lucide-react'
|
import { ArrowLeft } from 'lucide-react'
|
||||||
import ItemCard from '@/components/ItemCard'
|
|
||||||
import InlineItemCreate from '@/components/InlineItemCreate'
|
import InlineItemCreate from '@/components/InlineItemCreate'
|
||||||
|
import ListDetailView from '@/components/ListDetailView'
|
||||||
|
|
||||||
export default async function ListDetailPage({ params }: { params: { id: string } }) {
|
export default async function ListDetailPage({ params }: { params: { id: string } }) {
|
||||||
const list = await getListWithItems(params.id)
|
const list = await getListWithItems(params.id)
|
||||||
@@ -13,7 +13,6 @@ export default async function ListDetailPage({ params }: { params: { id: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
const completedItems = list.items.filter(item => item.completed)
|
const completedItems = list.items.filter(item => item.completed)
|
||||||
const incompleteItems = list.items.filter(item => !item.completed)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8 max-w-4xl">
|
<main className="container mx-auto px-4 py-8 max-w-4xl">
|
||||||
@@ -45,44 +44,8 @@ export default async function ListDetailPage({ params }: { params: { id: string
|
|||||||
<InlineItemCreate listId={list.id} />
|
<InlineItemCreate listId={list.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Items List */}
|
{/* Search, Filter, and Items List */}
|
||||||
<div className="space-y-6">
|
<ListDetailView listId={list.id} initialItems={list.items} />
|
||||||
{/* Incomplete Items */}
|
|
||||||
{incompleteItems.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-3">
|
|
||||||
To Do ({incompleteItems.length})
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{incompleteItems.map((item) => (
|
|
||||||
<ItemCard key={item.id} item={item} listId={list.id} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Completed Items */}
|
|
||||||
{completedItems.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-gray-500 mb-3">
|
|
||||||
Completed ({completedItems.length})
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{completedItems.map((item) => (
|
|
||||||
<ItemCard key={item.id} item={item} listId={list.id} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty State */}
|
|
||||||
{list.items.length === 0 && (
|
|
||||||
<div className="text-center py-12 bg-gray-50 rounded-lg">
|
|
||||||
<p className="text-gray-500 text-lg mb-2">No tasks yet</p>
|
|
||||||
<p className="text-gray-400">Add your first task to get started</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getLists } from '@/lib/actions'
|
import { getLists } from '@/lib/actions'
|
||||||
import ListCard from '@/components/ListCard'
|
import ListCard from '@/components/ListCard'
|
||||||
import NewListButton from '@/components/NewListButton'
|
import NewListButton from '@/components/NewListButton'
|
||||||
|
import GlobalSearch from '@/components/GlobalSearch'
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const lists = await getLists()
|
const lists = await getLists()
|
||||||
@@ -12,6 +13,11 @@ export default async function Home() {
|
|||||||
<NewListButton />
|
<NewListButton />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Global Search */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<GlobalSearch />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{lists.map((list) => (
|
{lists.map((list) => (
|
||||||
<ListCard key={list.id} list={list} />
|
<ListCard key={list.id} list={list} />
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
components/GlobalSearch.tsx
Normal file
71
components/GlobalSearch.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import SearchBar from './SearchBar'
|
||||||
|
import SearchResults from './SearchResults'
|
||||||
|
import { searchItems } from '@/lib/actions'
|
||||||
|
|
||||||
|
interface ItemWithList {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GlobalSearch() {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [searchResults, setSearchResults] = useState<ItemWithList[]>([])
|
||||||
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
|
|
||||||
|
const handleSearch = useCallback(async (query: string) => {
|
||||||
|
setSearchQuery(query)
|
||||||
|
|
||||||
|
if (!query.trim()) {
|
||||||
|
setSearchResults([])
|
||||||
|
setIsSearching(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearching(true)
|
||||||
|
try {
|
||||||
|
const results = await searchItems(query)
|
||||||
|
setSearchResults(results as ItemWithList[])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search error:', error)
|
||||||
|
setSearchResults([])
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setSearchQuery('')
|
||||||
|
setSearchResults([])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SearchBar
|
||||||
|
onSearch={handleSearch}
|
||||||
|
placeholder="Search all tasks..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{searchQuery.trim() && (
|
||||||
|
<SearchResults
|
||||||
|
results={searchResults}
|
||||||
|
query={searchQuery}
|
||||||
|
onClose={handleClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
178
components/ListDetailView.tsx
Normal file
178
components/ListDetailView.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useCallback, useMemo } from 'react'
|
||||||
|
import ItemCard from './ItemCard'
|
||||||
|
import SearchBar from './SearchBar'
|
||||||
|
import FilterPanel, { FilterState } from './FilterPanel'
|
||||||
|
import SearchResults from './SearchResults'
|
||||||
|
import { searchItems, getItemsByFilter } from '@/lib/actions'
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
completed: boolean
|
||||||
|
priority: string
|
||||||
|
dueDate: Date | null
|
||||||
|
tags: any
|
||||||
|
notes: string | null
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
listId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ItemWithList extends Item {
|
||||||
|
list: {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListDetailViewProps {
|
||||||
|
listId: string
|
||||||
|
initialItems: Item[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ListDetailView({ listId, initialItems }: ListDetailViewProps) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [searchResults, setSearchResults] = useState<ItemWithList[]>([])
|
||||||
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
|
|
||||||
|
const [filters, setFilters] = useState<FilterState>({
|
||||||
|
priority: null,
|
||||||
|
completed: null,
|
||||||
|
tags: [],
|
||||||
|
dueDateRange: { start: null, end: null },
|
||||||
|
sortBy: 'createdAt',
|
||||||
|
sortOrder: 'desc'
|
||||||
|
})
|
||||||
|
|
||||||
|
const [filteredItems, setFilteredItems] = useState<Item[]>(initialItems)
|
||||||
|
|
||||||
|
// Extract all unique tags from items
|
||||||
|
const availableTags = useMemo(() => {
|
||||||
|
const tagSet = new Set<string>()
|
||||||
|
initialItems.forEach(item => {
|
||||||
|
const tags = Array.isArray(item.tags) ? item.tags : []
|
||||||
|
tags.forEach(tag => {
|
||||||
|
if (typeof tag === 'string') {
|
||||||
|
tagSet.add(tag)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return Array.from(tagSet).sort()
|
||||||
|
}, [initialItems])
|
||||||
|
|
||||||
|
// Handle search
|
||||||
|
const handleSearch = useCallback(async (query: string) => {
|
||||||
|
setSearchQuery(query)
|
||||||
|
|
||||||
|
if (!query.trim()) {
|
||||||
|
setSearchResults([])
|
||||||
|
setIsSearching(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearching(true)
|
||||||
|
try {
|
||||||
|
const results = await searchItems(query, listId)
|
||||||
|
setSearchResults(results as ItemWithList[])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search error:', error)
|
||||||
|
setSearchResults([])
|
||||||
|
}
|
||||||
|
}, [listId])
|
||||||
|
|
||||||
|
// Handle filter changes
|
||||||
|
const handleFiltersChange = useCallback(async (newFilters: FilterState) => {
|
||||||
|
setFilters(newFilters)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await getItemsByFilter(listId, {
|
||||||
|
priority: newFilters.priority,
|
||||||
|
completed: newFilters.completed,
|
||||||
|
tags: newFilters.tags,
|
||||||
|
dueDateRange: newFilters.dueDateRange,
|
||||||
|
sortBy: newFilters.sortBy,
|
||||||
|
sortOrder: newFilters.sortOrder
|
||||||
|
})
|
||||||
|
setFilteredItems(results as Item[])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Filter error:', error)
|
||||||
|
setFilteredItems(initialItems)
|
||||||
|
}
|
||||||
|
}, [listId, initialItems])
|
||||||
|
|
||||||
|
// Determine which items to display
|
||||||
|
const displayItems = searchQuery.trim() ? searchResults : filteredItems
|
||||||
|
const completedItems = displayItems.filter(item => item.completed)
|
||||||
|
const incompleteItems = displayItems.filter(item => !item.completed)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Search Bar */}
|
||||||
|
<SearchBar
|
||||||
|
onSearch={handleSearch}
|
||||||
|
placeholder="Search tasks in this list..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filter Panel */}
|
||||||
|
<FilterPanel
|
||||||
|
filters={filters}
|
||||||
|
onFiltersChange={handleFiltersChange}
|
||||||
|
availableTags={availableTags}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{searchQuery.trim() ? (
|
||||||
|
<SearchResults
|
||||||
|
results={searchResults}
|
||||||
|
query={searchQuery}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Incomplete Items */}
|
||||||
|
{incompleteItems.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-3">
|
||||||
|
To Do ({incompleteItems.length})
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{incompleteItems.map((item) => (
|
||||||
|
<ItemCard key={item.id} item={item} listId={listId} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Completed Items */}
|
||||||
|
{completedItems.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-500 mb-3">
|
||||||
|
Completed ({completedItems.length})
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{completedItems.map((item) => (
|
||||||
|
<ItemCard key={item.id} item={item} listId={listId} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{displayItems.length === 0 && (
|
||||||
|
<div className="text-center py-12 bg-gray-50 rounded-lg">
|
||||||
|
<p className="text-gray-500 text-lg mb-2">No tasks found</p>
|
||||||
|
<p className="text-gray-400">
|
||||||
|
{filters.priority || filters.completed !== null || filters.tags.length > 0
|
||||||
|
? 'Try adjusting your filters'
|
||||||
|
: 'Add your first task to get started'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
components/SearchBar.tsx
Normal file
61
components/SearchBar.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { Search, X } from 'lucide-react'
|
||||||
|
|
||||||
|
interface SearchBarProps {
|
||||||
|
onSearch: (query: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
debounceMs?: number
|
||||||
|
initialValue?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchBar({
|
||||||
|
onSearch,
|
||||||
|
placeholder = 'Search tasks...',
|
||||||
|
debounceMs = 300,
|
||||||
|
initialValue = ''
|
||||||
|
}: SearchBarProps) {
|
||||||
|
const [query, setQuery] = useState(initialValue)
|
||||||
|
|
||||||
|
// Debounced search effect
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
onSearch(query)
|
||||||
|
}, debounceMs)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [query, debounceMs, onSearch])
|
||||||
|
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
setQuery('')
|
||||||
|
onSearch('')
|
||||||
|
}, [onSearch])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="relative">
|
||||||
|
<Search
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="w-full pl-10 pr-10 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
onClick={handleClear}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
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