Compare commits
2 Commits
477e378d08
...
e2e3581d06
| Author | SHA1 | Date | |
|---|---|---|---|
| e2e3581d06 | |||
| 0143097e69 |
@@ -120,7 +120,7 @@
|
||||
- **Property 14: Overdue task identification**
|
||||
- **Validates: Requirements 5.5**
|
||||
|
||||
- [ ] 8. Add search and filter UI components
|
||||
- [x] 8. Add search and filter UI components
|
||||
- Create SearchBar component with debounced input
|
||||
- Build FilterPanel component with controls for priority, completion, tags, and due date
|
||||
- Add sort controls (field and direction)
|
||||
@@ -136,7 +136,7 @@
|
||||
- **Property 23: Search clear restoration**
|
||||
- **Validates: Requirements 8.3**
|
||||
|
||||
- [ ] 9. Implement client-side state management
|
||||
- [x] 9. Implement client-side state management
|
||||
- Create useSearch hook for search state and debouncing
|
||||
- Create useFilters hook for filter state management
|
||||
- Create useOptimisticUpdate hook for optimistic UI updates
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
@tailwind base;
|
||||
@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 { notFound } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, Plus } from 'lucide-react'
|
||||
import ItemCard from '@/components/ItemCard'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import InlineItemCreate from '@/components/InlineItemCreate'
|
||||
import ListDetailView from '@/components/ListDetailView'
|
||||
|
||||
export default async function ListDetailPage({ params }: { params: { id: string } }) {
|
||||
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 incompleteItems = list.items.filter(item => !item.completed)
|
||||
|
||||
return (
|
||||
<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} />
|
||||
</div>
|
||||
|
||||
{/* Items List */}
|
||||
<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={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>
|
||||
{/* Search, Filter, and Items List */}
|
||||
<ListDetailView listId={list.id} initialItems={list.items} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getLists } from '@/lib/actions'
|
||||
import ListCard from '@/components/ListCard'
|
||||
import NewListButton from '@/components/NewListButton'
|
||||
import GlobalSearch from '@/components/GlobalSearch'
|
||||
|
||||
export default async function Home() {
|
||||
const lists = await getLists()
|
||||
@@ -12,6 +13,11 @@ export default async function Home() {
|
||||
<NewListButton />
|
||||
</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">
|
||||
{lists.map((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>
|
||||
)
|
||||
}
|
||||
73
components/GlobalSearch.tsx
Normal file
73
components/GlobalSearch.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import SearchBar from './SearchBar'
|
||||
import SearchResults from './SearchResults'
|
||||
import { searchItems } from '@/lib/actions'
|
||||
import { useSearch } from '@/hooks'
|
||||
|
||||
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 [searchResults, setSearchResults] = useState<ItemWithList[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
|
||||
const handleSearch = useCallback(async (query: string) => {
|
||||
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 { debouncedQuery, clearSearch } = useSearch({
|
||||
onSearch: handleSearch
|
||||
})
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
clearSearch()
|
||||
setSearchResults([])
|
||||
}, [clearSearch])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SearchBar
|
||||
onSearch={handleSearch}
|
||||
placeholder="Search all tasks..."
|
||||
/>
|
||||
|
||||
{debouncedQuery.trim() && (
|
||||
<SearchResults
|
||||
results={searchResults}
|
||||
query={debouncedQuery}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
182
components/ListDetailView.tsx
Normal file
182
components/ListDetailView.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react'
|
||||
import ItemCard from './ItemCard'
|
||||
import SearchBar from './SearchBar'
|
||||
import FilterPanel from './FilterPanel'
|
||||
import SearchResults from './SearchResults'
|
||||
import { searchItems, getItemsByFilter } from '@/lib/actions'
|
||||
import { useSearch, useFilters, useScrollPosition } from '@/hooks'
|
||||
|
||||
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 [searchResults, setSearchResults] = useState<ItemWithList[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [filteredItems, setFilteredItems] = useState<Item[]>(initialItems)
|
||||
|
||||
// Use scroll position preservation hook
|
||||
useScrollPosition({ key: `list-${listId}` })
|
||||
|
||||
// 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])
|
||||
|
||||
// Use search hook
|
||||
const handleSearchCallback = useCallback(async (query: string) => {
|
||||
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([])
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}, [listId])
|
||||
|
||||
const { debouncedQuery } = useSearch({
|
||||
onSearch: handleSearchCallback
|
||||
})
|
||||
|
||||
// Use filters hook
|
||||
const handleFiltersCallback = useCallback(async (newFilters: any) => {
|
||||
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])
|
||||
|
||||
const { filters, setFilters } = useFilters({
|
||||
onFiltersChange: handleFiltersCallback
|
||||
})
|
||||
|
||||
// Apply initial filters on mount
|
||||
useEffect(() => {
|
||||
handleFiltersCallback(filters)
|
||||
}, []) // Only run on mount
|
||||
|
||||
// Determine which items to display
|
||||
const displayItems = debouncedQuery.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={handleSearchCallback}
|
||||
placeholder="Search tasks in this list..."
|
||||
/>
|
||||
|
||||
{/* Filter Panel */}
|
||||
<FilterPanel
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
availableTags={availableTags}
|
||||
/>
|
||||
|
||||
{/* Results */}
|
||||
{debouncedQuery.trim() ? (
|
||||
<SearchResults
|
||||
results={searchResults}
|
||||
query={debouncedQuery}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
55
components/SearchBar.tsx
Normal file
55
components/SearchBar.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { useSearch } from '@/hooks'
|
||||
|
||||
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, clearSearch, isDebouncing } = useSearch({
|
||||
debounceMs,
|
||||
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={clearSearch}
|
||||
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>
|
||||
)}
|
||||
{isDebouncing && (
|
||||
<div className="absolute right-10 top-1/2 -translate-y-1/2">
|
||||
<div className="w-4 h-4 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
232
hooks/README.md
Normal file
232
hooks/README.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# Custom Hooks Documentation
|
||||
|
||||
This directory contains custom React hooks for client-side state management in the Ultimate Lists App.
|
||||
|
||||
## Hooks Overview
|
||||
|
||||
### `useSearch`
|
||||
|
||||
Manages search state with debouncing functionality.
|
||||
|
||||
**Features:**
|
||||
- Automatic debouncing of search queries
|
||||
- Immediate clear functionality
|
||||
- Tracks debouncing state
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
import { useSearch } from '@/hooks'
|
||||
|
||||
function MyComponent() {
|
||||
const { query, setQuery, debouncedQuery, isDebouncing, clearSearch } = useSearch({
|
||||
debounceMs: 300,
|
||||
onSearch: async (query) => {
|
||||
// Perform search with debounced query
|
||||
const results = await searchItems(query)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### `useFilters`
|
||||
|
||||
Manages filter state for sorting and filtering items.
|
||||
|
||||
**Features:**
|
||||
- Centralized filter state management
|
||||
- Detects active filters
|
||||
- Easy filter clearing
|
||||
- Individual filter updates
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
import { useFilters } from '@/hooks'
|
||||
|
||||
function MyComponent() {
|
||||
const { filters, setFilters, updateFilter, clearFilters, hasActiveFilters } = useFilters({
|
||||
initialFilters: { sortBy: 'createdAt' },
|
||||
onFiltersChange: async (filters) => {
|
||||
// Apply filters
|
||||
const results = await getItemsByFilter(listId, filters)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => updateFilter('priority', 'high')}>
|
||||
High Priority
|
||||
</button>
|
||||
{hasActiveFilters && (
|
||||
<button onClick={clearFilters}>Clear Filters</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### `useOptimisticUpdate`
|
||||
|
||||
Provides optimistic UI updates for better perceived performance.
|
||||
|
||||
**Features:**
|
||||
- Immediate UI feedback
|
||||
- Automatic rollback on error
|
||||
- Pending state tracking
|
||||
- Error handling
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
import { useOptimisticUpdate } from '@/hooks'
|
||||
|
||||
function MyComponent() {
|
||||
const { optimisticData, isPending, error, performUpdate } = useOptimisticUpdate({
|
||||
onUpdate: async (data) => {
|
||||
await updateItem(data.id, data)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Update failed:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const handleToggle = async () => {
|
||||
await performUpdate({ id: '123', completed: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={handleToggle} disabled={isPending}>
|
||||
{isPending ? 'Updating...' : 'Toggle'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### `useOptimisticList`
|
||||
|
||||
Specialized hook for managing lists with optimistic updates.
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
import { useOptimisticList } from '@/hooks'
|
||||
|
||||
function MyComponent({ initialItems }) {
|
||||
const { optimisticList, isPending, addItem, updateItem, removeItem } = useOptimisticList(
|
||||
initialItems,
|
||||
async (updatedList) => {
|
||||
// Sync with server
|
||||
await syncList(updatedList)
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{optimisticList.map(item => (
|
||||
<div key={item.id}>
|
||||
{item.title}
|
||||
<button onClick={() => removeItem(item.id)}>Delete</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### `useScrollPosition`
|
||||
|
||||
Preserves scroll position across navigation for better UX.
|
||||
|
||||
**Features:**
|
||||
- Automatic scroll position saving
|
||||
- Restoration on component mount
|
||||
- Debounced save operations
|
||||
- SessionStorage-based persistence
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
import { useScrollPosition } from '@/hooks'
|
||||
|
||||
function MyComponent({ listId }) {
|
||||
// Automatically saves and restores scroll position
|
||||
useScrollPosition({
|
||||
key: `list-${listId}`,
|
||||
enabled: true,
|
||||
debounceMs: 100
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Your scrollable content */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### `useContainerScrollPosition`
|
||||
|
||||
Similar to `useScrollPosition` but for specific container elements.
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
import { useContainerScrollPosition } from '@/hooks'
|
||||
|
||||
function MyComponent({ listId }) {
|
||||
const { containerRef } = useContainerScrollPosition({
|
||||
key: `container-${listId}`
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ height: '400px', overflow: 'auto' }}>
|
||||
{/* Your scrollable content */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements Validation
|
||||
|
||||
These hooks implement the following requirements from the specification:
|
||||
|
||||
- **Requirement 6.4**: Filter state management and clearing
|
||||
- **Requirement 7.3**: Visual feedback for user actions (optimistic updates)
|
||||
- **Requirement 7.4**: Scroll position preservation
|
||||
- **Requirement 8.3**: Search clearing and restoration
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Search Debouncing**: Use appropriate debounce times (300ms is a good default)
|
||||
2. **Filter State**: Keep filter state in sync with URL params for shareable links
|
||||
3. **Optimistic Updates**: Always provide rollback on error
|
||||
4. **Scroll Position**: Use unique keys for different views to avoid conflicts
|
||||
5. **Error Handling**: Always handle errors in async callbacks
|
||||
|
||||
## Testing
|
||||
|
||||
These hooks are designed to be testable with React Testing Library:
|
||||
|
||||
```typescript
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useSearch } from '@/hooks'
|
||||
|
||||
test('useSearch debounces queries', async () => {
|
||||
const onSearch = jest.fn()
|
||||
const { result } = renderHook(() => useSearch({ onSearch, debounceMs: 100 }))
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery('test')
|
||||
})
|
||||
|
||||
expect(onSearch).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
})
|
||||
|
||||
expect(onSearch).toHaveBeenCalledWith('test')
|
||||
})
|
||||
```
|
||||
10
hooks/index.ts
Normal file
10
hooks/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Custom hooks for client-side state management
|
||||
* Implements task 9 from the Ultimate Lists App specification
|
||||
*/
|
||||
|
||||
export { useSearch } from './useSearch'
|
||||
export { useFilters } from './useFilters'
|
||||
export type { FilterState } from './useFilters'
|
||||
export { useOptimisticUpdate, useOptimisticList } from './useOptimisticUpdate'
|
||||
export { useScrollPosition, useContainerScrollPosition } from './useScrollPosition'
|
||||
97
hooks/useFilters.ts
Normal file
97
hooks/useFilters.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useMemo } from '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 UseFiltersOptions {
|
||||
initialFilters?: Partial<FilterState>
|
||||
onFiltersChange?: (filters: FilterState) => void | Promise<void>
|
||||
}
|
||||
|
||||
interface UseFiltersReturn {
|
||||
filters: FilterState
|
||||
setFilters: (filters: FilterState) => void
|
||||
updateFilter: <K extends keyof FilterState>(key: K, value: FilterState[K]) => void
|
||||
clearFilters: () => void
|
||||
hasActiveFilters: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_FILTERS: FilterState = {
|
||||
priority: null,
|
||||
completed: null,
|
||||
tags: [],
|
||||
dueDateRange: { start: null, end: null },
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'desc'
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for managing filter state
|
||||
* Validates: Requirements 6.4
|
||||
*/
|
||||
export function useFilters(options: UseFiltersOptions = {}): UseFiltersReturn {
|
||||
const { initialFilters = {}, onFiltersChange } = options
|
||||
|
||||
const [filters, setFiltersState] = useState<FilterState>({
|
||||
...DEFAULT_FILTERS,
|
||||
...initialFilters
|
||||
})
|
||||
|
||||
// Check if any filters are active (excluding sort options)
|
||||
const hasActiveFilters = useMemo(() => {
|
||||
return (
|
||||
filters.priority !== null ||
|
||||
filters.completed !== null ||
|
||||
filters.tags.length > 0 ||
|
||||
filters.dueDateRange.start !== null ||
|
||||
filters.dueDateRange.end !== null
|
||||
)
|
||||
}, [filters])
|
||||
|
||||
// Set filters with callback
|
||||
const setFilters = useCallback((newFilters: FilterState) => {
|
||||
setFiltersState(newFilters)
|
||||
if (onFiltersChange) {
|
||||
onFiltersChange(newFilters)
|
||||
}
|
||||
}, [onFiltersChange])
|
||||
|
||||
// Update a single filter property
|
||||
const updateFilter = useCallback(<K extends keyof FilterState>(
|
||||
key: K,
|
||||
value: FilterState[K]
|
||||
) => {
|
||||
setFiltersState(prev => {
|
||||
const newFilters = { ...prev, [key]: value }
|
||||
if (onFiltersChange) {
|
||||
onFiltersChange(newFilters)
|
||||
}
|
||||
return newFilters
|
||||
})
|
||||
}, [onFiltersChange])
|
||||
|
||||
// Clear all filters (reset to defaults)
|
||||
const clearFilters = useCallback(() => {
|
||||
const clearedFilters = { ...DEFAULT_FILTERS }
|
||||
setFiltersState(clearedFilters)
|
||||
if (onFiltersChange) {
|
||||
onFiltersChange(clearedFilters)
|
||||
}
|
||||
}, [onFiltersChange])
|
||||
|
||||
return {
|
||||
filters,
|
||||
setFilters,
|
||||
updateFilter,
|
||||
clearFilters,
|
||||
hasActiveFilters
|
||||
}
|
||||
}
|
||||
138
hooks/useOptimisticUpdate.ts
Normal file
138
hooks/useOptimisticUpdate.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useTransition } from 'react'
|
||||
|
||||
interface UseOptimisticUpdateOptions<T> {
|
||||
onUpdate: (data: T) => Promise<void>
|
||||
onError?: (error: Error) => void
|
||||
}
|
||||
|
||||
interface UseOptimisticUpdateReturn<T> {
|
||||
optimisticData: T | null
|
||||
isPending: boolean
|
||||
error: Error | null
|
||||
performUpdate: (data: T) => Promise<void>
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for optimistic UI updates
|
||||
* Provides immediate UI feedback while server action is processing
|
||||
* Validates: Requirements 7.3
|
||||
*/
|
||||
export function useOptimisticUpdate<T>(
|
||||
options: UseOptimisticUpdateOptions<T>
|
||||
): UseOptimisticUpdateReturn<T> {
|
||||
const { onUpdate, onError } = options
|
||||
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [optimisticData, setOptimisticData] = useState<T | null>(null)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
const performUpdate = useCallback(async (data: T) => {
|
||||
// Clear any previous errors
|
||||
setError(null)
|
||||
|
||||
// Set optimistic data immediately for instant UI feedback
|
||||
setOptimisticData(data)
|
||||
|
||||
// Perform the actual update in a transition
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await onUpdate(data)
|
||||
// Clear optimistic data after successful update
|
||||
setOptimisticData(null)
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error('Update failed')
|
||||
setError(error)
|
||||
// Clear optimistic data on error
|
||||
setOptimisticData(null)
|
||||
|
||||
if (onError) {
|
||||
onError(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [onUpdate, onError])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setOptimisticData(null)
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
optimisticData,
|
||||
isPending,
|
||||
error,
|
||||
performUpdate,
|
||||
reset
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified hook for optimistic list updates
|
||||
* Useful for managing lists of items with optimistic updates
|
||||
*/
|
||||
export function useOptimisticList<T extends { id: string }>(
|
||||
initialData: T[],
|
||||
onUpdate: (data: T[]) => Promise<void>
|
||||
) {
|
||||
const [optimisticList, setOptimisticList] = useState<T[]>(initialData)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const addItem = useCallback((item: T) => {
|
||||
setOptimisticList(prev => [...prev, item])
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await onUpdate([...optimisticList, item])
|
||||
} catch (error) {
|
||||
// Rollback on error
|
||||
setOptimisticList(initialData)
|
||||
console.error('Failed to add item:', error)
|
||||
}
|
||||
})
|
||||
}, [optimisticList, initialData, onUpdate])
|
||||
|
||||
const updateItem = useCallback((id: string, updates: Partial<T>) => {
|
||||
setOptimisticList(prev =>
|
||||
prev.map(item => (item.id === id ? { ...item, ...updates } : item))
|
||||
)
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const updatedList = optimisticList.map(item =>
|
||||
item.id === id ? { ...item, ...updates } : item
|
||||
)
|
||||
await onUpdate(updatedList)
|
||||
} catch (error) {
|
||||
// Rollback on error
|
||||
setOptimisticList(initialData)
|
||||
console.error('Failed to update item:', error)
|
||||
}
|
||||
})
|
||||
}, [optimisticList, initialData, onUpdate])
|
||||
|
||||
const removeItem = useCallback((id: string) => {
|
||||
setOptimisticList(prev => prev.filter(item => item.id !== id))
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const updatedList = optimisticList.filter(item => item.id !== id)
|
||||
await onUpdate(updatedList)
|
||||
} catch (error) {
|
||||
// Rollback on error
|
||||
setOptimisticList(initialData)
|
||||
console.error('Failed to remove item:', error)
|
||||
}
|
||||
})
|
||||
}, [optimisticList, initialData, onUpdate])
|
||||
|
||||
return {
|
||||
optimisticList,
|
||||
isPending,
|
||||
addItem,
|
||||
updateItem,
|
||||
removeItem
|
||||
}
|
||||
}
|
||||
208
hooks/useScrollPosition.ts
Normal file
208
hooks/useScrollPosition.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
interface UseScrollPositionOptions {
|
||||
key: string
|
||||
enabled?: boolean
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for preserving scroll position across navigation
|
||||
* Validates: Requirements 7.4
|
||||
*/
|
||||
export function useScrollPosition(options: UseScrollPositionOptions) {
|
||||
const { key, enabled = true, debounceMs = 100 } = options
|
||||
|
||||
const scrollPositionRef = useRef<number>(0)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const isRestoringRef = useRef(false)
|
||||
|
||||
// Save scroll position to sessionStorage
|
||||
const saveScrollPosition = useCallback((position: number) => {
|
||||
if (!enabled) return
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(`scroll-${key}`, position.toString())
|
||||
scrollPositionRef.current = position
|
||||
} catch (error) {
|
||||
console.warn('Failed to save scroll position:', error)
|
||||
}
|
||||
}, [key, enabled])
|
||||
|
||||
// Restore scroll position from sessionStorage
|
||||
const restoreScrollPosition = useCallback(() => {
|
||||
if (!enabled) return
|
||||
|
||||
try {
|
||||
const savedPosition = sessionStorage.getItem(`scroll-${key}`)
|
||||
if (savedPosition !== null) {
|
||||
const position = parseInt(savedPosition, 10)
|
||||
if (!isNaN(position)) {
|
||||
isRestoringRef.current = true
|
||||
window.scrollTo(0, position)
|
||||
scrollPositionRef.current = position
|
||||
|
||||
// Reset flag after a short delay
|
||||
setTimeout(() => {
|
||||
isRestoringRef.current = false
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to restore scroll position:', error)
|
||||
}
|
||||
}, [key, enabled])
|
||||
|
||||
// Clear saved scroll position
|
||||
const clearScrollPosition = useCallback(() => {
|
||||
try {
|
||||
sessionStorage.removeItem(`scroll-${key}`)
|
||||
scrollPositionRef.current = 0
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear scroll position:', error)
|
||||
}
|
||||
}, [key])
|
||||
|
||||
// Handle scroll events with debouncing
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
const handleScroll = () => {
|
||||
// Don't save position if we're currently restoring
|
||||
if (isRestoringRef.current) return
|
||||
|
||||
// Clear existing timeout
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
|
||||
// Debounce the save operation
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
const position = window.scrollY || window.pageYOffset
|
||||
saveScrollPosition(position)
|
||||
}, debounceMs)
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [enabled, debounceMs, saveScrollPosition])
|
||||
|
||||
// Restore scroll position on mount
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
// Use requestAnimationFrame to ensure DOM is ready
|
||||
requestAnimationFrame(() => {
|
||||
restoreScrollPosition()
|
||||
})
|
||||
}
|
||||
}, [enabled, restoreScrollPosition])
|
||||
|
||||
return {
|
||||
saveScrollPosition,
|
||||
restoreScrollPosition,
|
||||
clearScrollPosition,
|
||||
currentPosition: scrollPositionRef.current
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for preserving scroll position within a specific container element
|
||||
*/
|
||||
export function useContainerScrollPosition(options: UseScrollPositionOptions) {
|
||||
const { key, enabled = true, debounceMs = 100 } = options
|
||||
|
||||
const containerRef = useRef<HTMLElement | null>(null)
|
||||
const scrollPositionRef = useRef<number>(0)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const isRestoringRef = useRef(false)
|
||||
|
||||
// Save scroll position
|
||||
const saveScrollPosition = useCallback((position: number) => {
|
||||
if (!enabled) return
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(`container-scroll-${key}`, position.toString())
|
||||
scrollPositionRef.current = position
|
||||
} catch (error) {
|
||||
console.warn('Failed to save container scroll position:', error)
|
||||
}
|
||||
}, [key, enabled])
|
||||
|
||||
// Restore scroll position
|
||||
const restoreScrollPosition = useCallback(() => {
|
||||
if (!enabled || !containerRef.current) return
|
||||
|
||||
try {
|
||||
const savedPosition = sessionStorage.getItem(`container-scroll-${key}`)
|
||||
if (savedPosition !== null) {
|
||||
const position = parseInt(savedPosition, 10)
|
||||
if (!isNaN(position)) {
|
||||
isRestoringRef.current = true
|
||||
containerRef.current.scrollTop = position
|
||||
scrollPositionRef.current = position
|
||||
|
||||
setTimeout(() => {
|
||||
isRestoringRef.current = false
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to restore container scroll position:', error)
|
||||
}
|
||||
}, [key, enabled])
|
||||
|
||||
// Clear saved scroll position
|
||||
const clearScrollPosition = useCallback(() => {
|
||||
try {
|
||||
sessionStorage.removeItem(`container-scroll-${key}`)
|
||||
scrollPositionRef.current = 0
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear container scroll position:', error)
|
||||
}
|
||||
}, [key])
|
||||
|
||||
// Set up scroll listener on container
|
||||
useEffect(() => {
|
||||
if (!enabled || !containerRef.current) return
|
||||
|
||||
const container = containerRef.current
|
||||
|
||||
const handleScroll = () => {
|
||||
if (isRestoringRef.current) return
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
const position = container.scrollTop
|
||||
saveScrollPosition(position)
|
||||
}, debounceMs)
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScroll)
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [enabled, debounceMs, saveScrollPosition])
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
saveScrollPosition,
|
||||
restoreScrollPosition,
|
||||
clearScrollPosition,
|
||||
currentPosition: scrollPositionRef.current
|
||||
}
|
||||
}
|
||||
84
hooks/useSearch.ts
Normal file
84
hooks/useSearch.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
|
||||
interface UseSearchOptions {
|
||||
debounceMs?: number
|
||||
onSearch?: (query: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
interface UseSearchReturn {
|
||||
query: string
|
||||
setQuery: (query: string) => void
|
||||
debouncedQuery: string
|
||||
isDebouncing: boolean
|
||||
clearSearch: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for managing search state with debouncing
|
||||
* Validates: Requirements 6.4, 8.3
|
||||
*/
|
||||
export function useSearch(options: UseSearchOptions = {}): UseSearchReturn {
|
||||
const { debounceMs = 300, onSearch } = options
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('')
|
||||
const [isDebouncing, setIsDebouncing] = useState(false)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Debounce the query
|
||||
useEffect(() => {
|
||||
// If query is empty, update immediately
|
||||
if (!query.trim()) {
|
||||
setDebouncedQuery('')
|
||||
setIsDebouncing(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsDebouncing(true)
|
||||
|
||||
// Clear existing timeout
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
|
||||
// Set new timeout
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setDebouncedQuery(query)
|
||||
setIsDebouncing(false)
|
||||
}, debounceMs)
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [query, debounceMs])
|
||||
|
||||
// Call onSearch callback when debounced query changes
|
||||
useEffect(() => {
|
||||
if (onSearch) {
|
||||
onSearch(debouncedQuery)
|
||||
}
|
||||
}, [debouncedQuery, onSearch])
|
||||
|
||||
// Clear search function
|
||||
const clearSearch = useCallback(() => {
|
||||
setQuery('')
|
||||
setDebouncedQuery('')
|
||||
setIsDebouncing(false)
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
query,
|
||||
setQuery,
|
||||
debouncedQuery,
|
||||
isDebouncing,
|
||||
clearSearch
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user