- Add mounted state to prevent SSR/client mismatch - Permission badge and share button only render after mount - Resolves hydration error on list detail page
259 lines
7.4 KiB
TypeScript
259 lines
7.4 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useCallback, useMemo, useEffect } from 'react'
|
|
import { Share2 } from 'lucide-react'
|
|
import ItemCard from './ItemCard'
|
|
import SearchBar from './SearchBar'
|
|
import FilterPanel from './FilterPanel'
|
|
import SearchResults from './SearchResults'
|
|
import EmptyState from './EmptyState'
|
|
import ShareListModal from './ShareListModal'
|
|
import PermissionBadge from './PermissionBadge'
|
|
import { searchItems, getItemsByFilter } from '@/lib/actions'
|
|
import { useSearch, useFilters, useScrollPosition } from '@/hooks'
|
|
import { useRealtimeSync } from '@/hooks/useRealtimeSync'
|
|
|
|
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[]
|
|
permission?: 'owner' | 'editor' | 'viewer'
|
|
canShare?: boolean
|
|
listTitle?: string
|
|
ownerId?: string
|
|
ownerName?: string | null
|
|
ownerEmail?: string
|
|
collaborators?: Array<{
|
|
id: string
|
|
permission: 'editor' | 'viewer'
|
|
user: {
|
|
id: string
|
|
name: string | null
|
|
email: string
|
|
}
|
|
}>
|
|
}
|
|
|
|
export default function ListDetailView({
|
|
listId,
|
|
initialItems,
|
|
permission,
|
|
canShare = false,
|
|
listTitle = '',
|
|
ownerId = '',
|
|
ownerName = null,
|
|
ownerEmail = '',
|
|
collaborators = []
|
|
}: ListDetailViewProps) {
|
|
const [mounted, setMounted] = useState(false)
|
|
const [searchResults, setSearchResults] = useState<ItemWithList[]>([])
|
|
const [isSearching, setIsSearching] = useState(false)
|
|
const [filteredItems, setFilteredItems] = useState<Item[]>(initialItems)
|
|
const [isShareModalOpen, setIsShareModalOpen] = useState(false)
|
|
|
|
useEffect(() => {
|
|
setMounted(true)
|
|
}, [])
|
|
|
|
// Use scroll position preservation hook
|
|
useScrollPosition({ key: `list-${listId}` })
|
|
|
|
// Use real-time sync hook to poll for updates
|
|
useRealtimeSync({
|
|
listId,
|
|
enabled: true,
|
|
pollInterval: 1000, // Poll every 1 second for faster updates
|
|
})
|
|
|
|
// 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
|
|
})
|
|
// Extract items from pagination result
|
|
const items = 'items' in results ? results.items : results
|
|
setFilteredItems(items as Item[])
|
|
} catch (error) {
|
|
console.error('Filter error:', error)
|
|
setFilteredItems(initialItems)
|
|
}
|
|
}, [listId, initialItems])
|
|
|
|
const { filters, setFilters } = useFilters({
|
|
onFiltersChange: handleFiltersCallback
|
|
})
|
|
|
|
// Update filtered items when initialItems changes (after refresh)
|
|
// Re-apply filters to the new data
|
|
useEffect(() => {
|
|
handleFiltersCallback(filters)
|
|
}, [initialItems, handleFiltersCallback, filters])
|
|
|
|
// 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">
|
|
{/* Permission Badge and Share Button */}
|
|
{mounted && (permission || canShare) && (
|
|
<div className="flex items-center gap-2">
|
|
{permission && <PermissionBadge permission={permission} />}
|
|
{canShare && (
|
|
<button
|
|
onClick={() => setIsShareModalOpen(true)}
|
|
className="flex items-center gap-2 px-3 py-1.5 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 text-white rounded-lg transition-colors text-sm font-medium"
|
|
>
|
|
<Share2 size={16} />
|
|
<span className="hidden sm:inline">Share</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 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 className="animate-fade-in">
|
|
<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 className="animate-fade-in">
|
|
<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 && (
|
|
<EmptyState
|
|
icon={filters.priority || filters.completed !== null || filters.tags.length > 0 ? 'filter' : 'inbox'}
|
|
title="No tasks found"
|
|
description={
|
|
filters.priority || filters.completed !== null || filters.tags.length > 0
|
|
? 'Try adjusting your filters to see more tasks'
|
|
: 'Add your first task using the button above'
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Share Modal */}
|
|
<ShareListModal
|
|
isOpen={isShareModalOpen}
|
|
onClose={() => setIsShareModalOpen(false)}
|
|
listId={listId}
|
|
listTitle={listTitle}
|
|
isOwner={permission === 'owner'}
|
|
ownerId={ownerId}
|
|
ownerName={ownerName}
|
|
ownerEmail={ownerEmail}
|
|
initialCollaborators={collaborators}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|