/** * Component: BookDate Book Picker Modal * Documentation: documentation/features/bookdate.md */ 'use client'; import { useState, useEffect, useRef, useCallback } from 'react'; interface BookPickerModalProps { isOpen: boolean; onClose: () => void; selectedIds: string[]; onConfirm: (selectedIds: string[]) => void; maxSelection: number; } interface LibraryBook { id: string; title: string; author: string; coverUrl?: string | null; } export function BookPickerModal({ isOpen, onClose, selectedIds, onConfirm, maxSelection, }: BookPickerModalProps) { const [books, setBooks] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [localSelectedIds, setLocalSelectedIds] = useState(selectedIds); // Infinite scroll state const [displayedCount, setDisplayedCount] = useState(100); // Start with 100 books const observerTarget = useRef(null); // Load library books when modal opens useEffect(() => { if (isOpen) { loadLibraryBooks(); setLocalSelectedIds(selectedIds); // Reset to initial selection when reopening setDisplayedCount(100); // Reset displayed count setSearchQuery(''); // Reset search } }, [isOpen]); const loadLibraryBooks = async () => { setLoading(true); setError(null); try { const accessToken = localStorage.getItem('accessToken'); const response = await fetch('/api/bookdate/library', { headers: { 'Authorization': `Bearer ${accessToken}`, }, }); if (!response.ok) { throw new Error('Failed to load library books'); } const data = await response.json(); setBooks(data.books || []); } catch (error: any) { console.error('Load library books error:', error); setError(error.message || 'Failed to load library books'); } finally { setLoading(false); } }; const toggleBook = (bookId: string) => { setLocalSelectedIds(prev => { if (prev.includes(bookId)) { // Deselect return prev.filter(id => id !== bookId); } else { // Select (only if under max) if (prev.length < maxSelection) { return [...prev, bookId]; } return prev; // Already at max } }); }; // Reset displayed count when search query changes const handleSearchChange = (query: string) => { setSearchQuery(query); setDisplayedCount(100); // Reset to show first 100 results }; const handleConfirm = () => { onConfirm(localSelectedIds); onClose(); }; const handleCancel = () => { setLocalSelectedIds(selectedIds); // Reset to original onClose(); }; // Filter books by search query const filteredBooks = books.filter(book => { const query = searchQuery.toLowerCase(); return ( book.title.toLowerCase().includes(query) || book.author.toLowerCase().includes(query) ); }); // Only display a subset for performance (infinite scroll) const displayedBooks = filteredBooks.slice(0, displayedCount); const hasMore = displayedCount < filteredBooks.length; const isMaxReached = localSelectedIds.length >= maxSelection; // Infinite scroll observer useEffect(() => { const currentFilteredLength = filteredBooks.length; const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && !loading) { // Load more books when bottom sentinel is visible setDisplayedCount(prev => Math.min(prev + 100, currentFilteredLength)); } }, { threshold: 0.1 } ); const currentTarget = observerTarget.current; if (currentTarget) { observer.observe(currentTarget); } return () => { if (currentTarget) { observer.unobserve(currentTarget); } }; }, [loading, filteredBooks.length]); // Re-run when loading state or filtered length changes if (!isOpen) return null; return ( <> {/* Backdrop */}
{/* Modal */}
{/* Header */}

Select Your Favorite Books

Choose up to {maxSelection} books that represent your favorites

{/* Selection Counter */}
{localSelectedIds.length} / {maxSelection} selected {isMaxReached && ( (Maximum reached) )}
{localSelectedIds.length > 0 && ( )}
{/* Search Bar */} handleSearchChange(e.target.value)} placeholder="Search books..." className="w-64 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 text-sm" />
{/* Books Grid */}
{loading ? (
) : error ? (
{error}
) : filteredBooks.length === 0 ? (
{searchQuery ? 'No books match your search' : 'No books in your library'}
) : (
{displayedBooks.map((book, index) => { const isSelected = localSelectedIds.includes(book.id); const isDisabled = !isSelected && isMaxReached; return ( ); })}
)} {/* Infinite scroll sentinel */} {!loading && !error && hasMore && (
)} {/* Show count info */} {!loading && !error && filteredBooks.length > 0 && (
Showing {displayedBooks.length} of {filteredBooks.length} books {filteredBooks.length !== books.length && ` (filtered from ${books.length} total)`}
)}
{/* Footer */}
{/* Fade-in animation */} ); }