328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
'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<Story[]>([]);
|
|
const [tags, setTags] = useState<Tag[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [searchLoading, setSearchLoading] = useState(false);
|
|
const [randomLoading, setRandomLoading] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
|
const [sortOption, setSortOption] = useState<SortOption>('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<Tag[]>([]);
|
|
|
|
// 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<string, FacetCount[]>): 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<HTMLInputElement>) => {
|
|
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 (
|
|
<AppLayout>
|
|
<div className="flex items-center justify-center py-20">
|
|
<LoadingSpinner size="lg" />
|
|
</div>
|
|
</AppLayout>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="text-center py-12 theme-card theme-shadow rounded-lg">
|
|
<p className="theme-text text-lg mb-4">
|
|
{searchQuery || selectedTags.length > 0
|
|
? 'No stories match your search criteria.'
|
|
: 'Your library is empty.'
|
|
}
|
|
</p>
|
|
{searchQuery || selectedTags.length > 0 ? (
|
|
<Button variant="ghost" onClick={clearFilters}>
|
|
Clear Filters
|
|
</Button>
|
|
) : (
|
|
<Button href="/add-story">
|
|
Add Your First Story
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<StoryMultiSelect
|
|
stories={stories}
|
|
viewMode={viewMode}
|
|
onUpdate={handleStoryUpdate}
|
|
allowMultiSelect={true}
|
|
/>
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="flex justify-center gap-2 mt-8">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => setPage(page - 1)}
|
|
disabled={page === 0}
|
|
>
|
|
Previous
|
|
</Button>
|
|
|
|
<span className="flex items-center px-4 py-2 theme-text">
|
|
Page {page + 1} of {totalPages}
|
|
</span>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => setPage(page + 1)}
|
|
disabled={page >= totalPages - 1}
|
|
>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const LayoutComponent = layout === 'sidebar' ? SidebarLayout :
|
|
layout === 'toolbar' ? ToolbarLayout :
|
|
MinimalLayout;
|
|
|
|
return (
|
|
<AppLayout>
|
|
<LayoutComponent {...layoutProps}>
|
|
{renderContent()}
|
|
</LayoutComponent>
|
|
</AppLayout>
|
|
);
|
|
} |