'use client'; import { useState, useEffect } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { searchApi, storyApi, tagApi } from '../../lib/api'; import { Story, Tag, FacetCount } from '../../types/api'; import AppLayout from '../../components/layout/AppLayout'; import { Input } from '../../components/ui/Input'; import Button from '../../components/ui/Button'; import StoryMultiSelect from '../../components/stories/StoryMultiSelect'; import TagFilter from '../../components/stories/TagFilter'; import LoadingSpinner from '../../components/ui/LoadingSpinner'; import SidebarLayout from '../../components/library/SidebarLayout'; import ToolbarLayout from '../../components/library/ToolbarLayout'; import MinimalLayout from '../../components/library/MinimalLayout'; import { useLibraryLayout } from '../../hooks/useLibraryLayout'; type ViewMode = 'grid' | 'list'; type SortOption = 'createdAt' | 'title' | 'authorName' | 'rating' | 'wordCount' | 'lastRead'; export default function LibraryPage() { const router = useRouter(); const searchParams = useSearchParams(); const { layout } = useLibraryLayout(); const [stories, setStories] = useState([]); const [tags, setTags] = useState([]); const [loading, setLoading] = useState(false); const [searchLoading, setSearchLoading] = useState(false); const [randomLoading, setRandomLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [selectedTags, setSelectedTags] = useState([]); const [viewMode, setViewMode] = useState('list'); const [sortOption, setSortOption] = useState('lastRead'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); const [page, setPage] = useState(0); const [totalPages, setTotalPages] = useState(1); const [totalElements, setTotalElements] = useState(0); const [refreshTrigger, setRefreshTrigger] = useState(0); const [urlParamsProcessed, setUrlParamsProcessed] = useState(false); // Initialize filters from URL parameters useEffect(() => { const tagsParam = searchParams.get('tags'); if (tagsParam) { console.log('URL tag filter detected:', tagsParam); // Use functional updates to ensure all state changes happen together setSelectedTags([tagsParam]); setPage(0); // Reset to first page when applying URL filter } setUrlParamsProcessed(true); }, [searchParams]); // Convert facet counts to Tag objects for the UI, enriched with full tag data const [fullTags, setFullTags] = useState([]); // Fetch full tag data for enrichment useEffect(() => { const fetchFullTags = async () => { try { const result = await tagApi.getTags({ size: 1000 }); // Get all tags setFullTags(result.content || []); } catch (error) { console.error('Failed to fetch full tag data:', error); setFullTags([]); } }; fetchFullTags(); }, []); const convertFacetsToTags = (facets?: Record): Tag[] => { if (!facets || !facets.tagNames) { return []; } return facets.tagNames.map(facet => { // Find the full tag data by name const fullTag = fullTags.find(tag => tag.name.toLowerCase() === facet.value.toLowerCase()); return { id: fullTag?.id || facet.value, // Use actual ID if available, fallback to name name: facet.value, storyCount: facet.count, // Include color and other metadata from the full tag data color: fullTag?.color, description: fullTag?.description, aliasCount: fullTag?.aliasCount, createdAt: fullTag?.createdAt, aliases: fullTag?.aliases }; }); }; // Enrich existing tags when fullTags are loaded useEffect(() => { if (fullTags.length > 0 && tags.length > 0) { // Check if tags already have color data to avoid infinite loops const hasColors = tags.some(tag => tag.color); if (!hasColors) { // Re-enrich existing tags with color data const enrichedTags = tags.map(tag => { const fullTag = fullTags.find(ft => ft.name.toLowerCase() === tag.name.toLowerCase()); return { ...tag, color: fullTag?.color, description: fullTag?.description, aliasCount: fullTag?.aliasCount, createdAt: fullTag?.createdAt, aliases: fullTag?.aliases, id: fullTag?.id || tag.id }; }); setTags(enrichedTags); } } }, [fullTags, tags]); // Run when fullTags or tags change // Debounce search to avoid too many API calls useEffect(() => { // Don't run search until URL parameters have been processed if (!urlParamsProcessed) return; const debounceTimer = setTimeout(() => { const performSearch = async () => { try { // Use searchLoading for background search, loading only for initial load const isInitialLoad = stories.length === 0 && !searchQuery; if (isInitialLoad) { setLoading(true); } else { setSearchLoading(true); } // Always use search API for consistency - use '*' for match-all when no query const apiParams = { query: searchQuery.trim() || '*', page: page, // Use 0-based pagination consistently size: 20, tags: selectedTags.length > 0 ? selectedTags : undefined, sortBy: sortOption, sortDir: sortDirection, facetBy: ['tagNames'], // Request tag facets for the filter UI }; console.log('Performing search with params:', apiParams); const result = await searchApi.search(apiParams); const currentStories = result?.results || []; setStories(currentStories); setTotalPages(Math.ceil((result?.totalHits || 0) / 20)); setTotalElements(result?.totalHits || 0); // Update tags from facets - these represent all matching stories, not just current page const resultTags = convertFacetsToTags(result?.facets); setTags(resultTags); } catch (error) { console.error('Failed to load stories:', error); setStories([]); setTags([]); } finally { setLoading(false); setSearchLoading(false); } }; performSearch(); }, searchQuery ? 500 : 0); // Debounce search queries, but load immediately for filters/pagination return () => clearTimeout(debounceTimer); }, [searchQuery, selectedTags, sortOption, sortDirection, page, refreshTrigger, urlParamsProcessed]); const handleSearchChange = (e: React.ChangeEvent) => { setSearchQuery(e.target.value); setPage(0); }; const handleStoryUpdate = () => { setRefreshTrigger(prev => prev + 1); }; const handleRandomStory = async () => { if (totalElements === 0) return; try { setRandomLoading(true); const randomStory = await storyApi.getRandomStory({ searchQuery: searchQuery || undefined, tags: selectedTags.length > 0 ? selectedTags : undefined }); if (randomStory) { router.push(`/stories/${randomStory.id}`); } else { alert('No stories available. Please add some stories first.'); } } catch (error) { console.error('Failed to get random story:', error); alert('Failed to get a random story. Please try again.'); } finally { setRandomLoading(false); } }; const clearFilters = () => { setSearchQuery(''); setSelectedTags([]); setPage(0); setRefreshTrigger(prev => prev + 1); }; const handleTagToggle = (tagName: string) => { setSelectedTags(prev => prev.includes(tagName) ? prev.filter(t => t !== tagName) : [...prev, tagName] ); setPage(0); setRefreshTrigger(prev => prev + 1); }; const handleSortDirectionToggle = () => { setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc'); }; if (loading) { return (
); } const handleSortChange = (option: string) => { setSortOption(option as SortOption); }; const layoutProps = { stories, tags, totalElements, searchQuery, selectedTags, viewMode, sortOption, sortDirection, onSearchChange: handleSearchChange, onTagToggle: handleTagToggle, onViewModeChange: setViewMode, onSortChange: handleSortChange, onSortDirectionToggle: handleSortDirectionToggle, onRandomStory: handleRandomStory, onClearFilters: clearFilters, }; const renderContent = () => { if (stories.length === 0 && !loading) { return (

{searchQuery || selectedTags.length > 0 ? 'No stories match your search criteria.' : 'Your library is empty.' }

{searchQuery || selectedTags.length > 0 ? ( ) : ( )}
); } return ( <> {/* Pagination */} {totalPages > 1 && (
Page {page + 1} of {totalPages}
)} ); }; const LayoutComponent = layout === 'sidebar' ? SidebarLayout : layout === 'toolbar' ? ToolbarLayout : MinimalLayout; return ( {renderContent()} ); }