)[key];
- }
- return undefined;
- }, obj as unknown);
-};
-
-const DEFAULT_COLOR_STYLE: ColorStyle = { bg: 'bg-gray-500/20', text: 'text-gray-700 dark:text-gray-300' };
-
-/**
- * Get the color style for a value based on the color hint.
- */
-const getColorStyle = (value: string, colorHint?: ColumnColorHint | null): ColorStyle => {
- if (!colorHint) return DEFAULT_COLOR_STYLE;
-
- if (colorHint.type === 'static') {
- // For static hints, assume it's a bg class and pair with default text
- return { bg: colorHint.value, text: 'text-gray-700 dark:text-gray-300' };
- }
-
- if (colorHint.type === 'map') {
- switch (colorHint.value) {
- case 'format':
- return getFormatColor(value);
- case 'language':
- return getLanguageColor(value);
- case 'download_type':
- return getDownloadTypeColor(value);
- default:
- return DEFAULT_COLOR_STYLE;
- }
- }
-
- return DEFAULT_COLOR_STYLE;
-};
-
/**
* Generic cell renderer for release list columns.
* Renders different column types (text, badge, size, number, seeders) based on schema.
@@ -77,7 +36,7 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
if (compact) {
return {displayValue};
}
- const colorStyle = getColorStyle(value, column.color_hint);
+ const colorStyle = getColorStyleFromHint(value, column.color_hint);
return (
{value !== column.fallback ? (
diff --git a/src/frontend/src/components/ReleaseModal.tsx b/src/frontend/src/components/ReleaseModal.tsx
index 4e496e09..f7eef2d3 100644
--- a/src/frontend/src/components/ReleaseModal.tsx
+++ b/src/frontend/src/components/ReleaseModal.tsx
@@ -1,12 +1,13 @@
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
-import { Book, Release, ReleaseSource, ReleasesResponse, Language, StatusData, ButtonStateInfo, ColumnSchema, ReleaseColumnConfig, LeadingCellConfig, ColumnColorHint, SearchStatusData } from '../types';
+import { Book, Release, ReleaseSource, ReleasesResponse, Language, StatusData, ButtonStateInfo, ColumnSchema, ReleaseColumnConfig, LeadingCellConfig, SearchStatusData } from '../types';
import { getReleases, getReleaseSources } from '../services/api';
import { useSocket } from '../contexts/SocketContext';
import { Dropdown } from './Dropdown';
import { DropdownList } from './DropdownList';
import { BookDownloadButton } from './BookDownloadButton';
import { ReleaseCell } from './ReleaseCell';
-import { getFormatColor, getLanguageColor, getDownloadTypeColor, ColorStyle } from '../utils/colorMaps';
+import { getColorStyleFromHint } from '../utils/colorMaps';
+import { getNestedValue } from '../utils/objectHelpers';
import { LanguageMultiSelect } from './LanguageMultiSelect';
import { LANGUAGE_OPTION_ALL, LANGUAGE_OPTION_DEFAULT, getLanguageFilterValues } from '../utils/languageFilters';
@@ -15,14 +16,15 @@ import { LANGUAGE_OPTION_ALL, LANGUAGE_OPTION_DEFAULT, getLanguageFilterValues }
// This persists across modal open/close cycles
const releaseCache = new Map();
-const getCacheKey = (provider: string, providerId: string, source: string): string =>
- `${provider}:${providerId}:${source}`;
+function getCacheKey(provider: string, providerId: string, source: string): string {
+ return `${provider}:${providerId}:${source}`;
+}
// Default cache TTL (5 minutes) - sources can override via column_config.cache_ttl_seconds
const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
const cacheTimestamps = new Map();
-const getCachedReleases = (provider: string, providerId: string, source: string): ReleasesResponse | null => {
+function getCachedReleases(provider: string, providerId: string, source: string): ReleasesResponse | null {
const key = getCacheKey(provider, providerId, source);
const timestamp = cacheTimestamps.get(key);
const cached = releaseCache.get(key);
@@ -45,13 +47,13 @@ const getCachedReleases = (provider: string, providerId: string, source: string)
cacheTimestamps.delete(key);
return null;
-};
+}
-const setCachedReleases = (provider: string, providerId: string, source: string, data: ReleasesResponse): void => {
+function setCachedReleases(provider: string, providerId: string, source: string, data: ReleasesResponse): void {
const key = getCacheKey(provider, providerId, source);
releaseCache.set(key, data);
cacheTimestamps.set(key, Date.now());
-};
+}
// Default column configuration (fallback when backend doesn't provide one)
const DEFAULT_COLUMN_CONFIG: ReleaseColumnConfig = {
@@ -104,40 +106,9 @@ interface ReleaseModalProps {
onSearchSeries?: (seriesName: string) => void; // Callback to search for series
}
-// Color map lookup for dynamic color hints
-const COLOR_MAP_HANDLERS: Record ColorStyle> = {
- format: getFormatColor,
- language: getLanguageColor,
- download_type: getDownloadTypeColor,
-};
-
-const DEFAULT_COLOR_STYLE: ColorStyle = { bg: 'bg-gray-500/20', text: 'text-gray-700 dark:text-gray-300' };
-
-// Helper to get color style based on color hint
-function getLeadingCellColor(value: string, colorHint?: ColumnColorHint): ColorStyle {
- if (!colorHint) return DEFAULT_COLOR_STYLE;
-
- if (colorHint.type === 'map') {
- const handler = COLOR_MAP_HANDLERS[colorHint.value];
- return handler ? handler(value) : DEFAULT_COLOR_STYLE;
- }
-
- // Static color hint - assume it's a bg class
- return { bg: colorHint.value, text: 'text-gray-700 dark:text-gray-300' };
-}
-
-// Helper to get nested value from object using dot notation path
-function getNestedValue(obj: Record, path: string): unknown {
- return path.split('.').reduce((current, key) => {
- if (current && typeof current === 'object') {
- return (current as Record)[key];
- }
- return undefined;
- }, obj as unknown);
-}
// 5-star rating display with partial fill support
-const StarRating = ({ rating, maxRating = 5 }: { rating: number; maxRating?: number }) => {
+function StarRating({ rating, maxRating = 5 }: { rating: number; maxRating?: number }) {
// Normalize rating to 0-5 scale if needed
const normalizedRating = Math.min(Math.max(rating, 0), maxRating);
@@ -170,7 +141,7 @@ const StarRating = ({ rating, maxRating = 5 }: { rating: number; maxRating?: num
})}
);
-};
+}
// Thumbnail component for release rows
const ReleaseThumbnail = ({ preview, title }: { preview?: string; title?: string }) => {
@@ -231,7 +202,7 @@ const LeadingCell = ({
if (cellType === 'badge' && config?.key) {
const value = getNestedValue(release as unknown as Record, config.key);
const displayValue = value ? String(value) : '';
- const colorStyle = getLeadingCellColor(displayValue, config.color_hint);
+ const colorStyle = getColorStyleFromHint(displayValue, config.color_hint);
const text = config.uppercase ? displayValue.toUpperCase() : displayValue;
return (
@@ -396,149 +367,159 @@ const ReleaseRow = ({
};
// Shimmer block with wave animation - same as DownloadsSidebar
-const ShimmerBlock = ({ className }: { className: string }) => (
-
+function ShimmerBlock({ className }: { className: string }) {
+ return (
-
-);
+ className={`rounded bg-gray-200 dark:bg-gray-800 relative overflow-hidden ${className}`}
+ >
+
+
+ );
+}
// Loading skeleton for releases - matches ReleaseRow layout
-const ReleaseSkeleton = () => (
-
- {[1, 2, 3, 4, 5].map((i) => (
-
-
- {/* Thumbnail skeleton */}
-
+function ReleaseSkeleton() {
+ return (
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+
+ {/* Thumbnail skeleton */}
+
- {/* Title and author skeleton */}
-
-
-
-
-
- {/* Language badge skeleton - desktop */}
-
-
-
-
- {/* Format badge skeleton - desktop */}
-
-
-
-
- {/* Size skeleton - desktop */}
-
-
-
-
- {/* Mobile info + action skeleton */}
-
- {/* Mobile: format + size inline */}
-
-
-
+ {/* Title and author skeleton */}
+
+
+
- {/* Action button skeleton */}
-
+ {/* Language badge skeleton - desktop */}
+
+
+
+
+ {/* Format badge skeleton - desktop */}
+
+
+
+
+ {/* Size skeleton - desktop */}
+
+
+
+
+ {/* Mobile info + action skeleton */}
+
+ {/* Mobile: format + size inline */}
+
+
+
+
+
+ {/* Action button skeleton */}
+
+
-
- ))}
-
-);
+ ))}
+
+ );
+}
// Empty state component
-const EmptyState = ({ message }: { message: string }) => (
-
-
-
+function EmptyState({ message }: { message: string }) {
+ return (
+
-
{message}
-
-);
+ );
+}
// Configure source CTA
-const ConfigureSourceCTA = ({ sourceName }: { sourceName: string }) => (
-
-
-
+function ConfigureSourceCTA({ sourceName }: { sourceName: string }) {
+ return (
+
+
+
+ {sourceName} Not Configured
+
+
+ Configure {sourceName} in Settings to enable searching this source.
+
-
- {sourceName} Not Configured
-
-
- Configure {sourceName} in Settings to enable searching this source.
-
-
-);
+ );
+}
// Error state component
-const ErrorState = ({ message }: { message: string }) => (
-
-
-
+function ErrorState({ message }: { message: string }) {
+ return (
+
+
+
+ Error Loading Releases
+
+
{message}
-
- Error Loading Releases
-
-
{message}
-
-);
+ );
+}
export const ReleaseModal = ({
@@ -736,11 +717,15 @@ export const ReleaseModal = ({
if (sources.length > 0) {
const enabledSources = sources.filter(s => s.enabled);
const defaultIsEnabled = defaultReleaseSource && enabledSources.some(s => s.name === defaultReleaseSource);
- const defaultSource = defaultIsEnabled
- ? defaultReleaseSource
- : enabledSources.length > 0
- ? enabledSources[0].name
- : sources[0].name; // Fallback to first source if none enabled
+
+ let defaultSource: string;
+ if (defaultIsEnabled) {
+ defaultSource = defaultReleaseSource;
+ } else if (enabledSources.length > 0) {
+ defaultSource = enabledSources[0].name;
+ } else {
+ defaultSource = sources[0].name; // Fallback to first source if none enabled
+ }
setActiveTab(defaultSource);
}
} catch (err) {
@@ -807,9 +792,15 @@ export const ReleaseModal = ({
setExpandedBySource((prev) => ({ ...prev, [activeTab]: true }));
try {
+ // Resolve language codes for the API call (same logic as Apply button)
+ const langCodes = getLanguageFilterValues(languageFilter, bookLanguages, defaultLanguages);
+ const languagesParam = (langCodes === null || langCodes?.includes(LANGUAGE_OPTION_ALL))
+ ? undefined
+ : langCodes;
+
// Fetch with expand_search=true (title+author search)
const expandedResponse = await getReleases(
- provider, bookId, activeTab, book.title, book.author, true
+ provider, bookId, activeTab, book.title, book.author, true, languagesParam
);
// Merge with existing results, deduplicating by source_id
@@ -836,7 +827,7 @@ export const ReleaseModal = ({
} finally {
setLoadingBySource((prev) => ({ ...prev, [activeTab]: false }));
}
- }, [activeTab, book]);
+ }, [activeTab, book, languageFilter, bookLanguages, defaultLanguages]);
// Build list of tabs to show
// All sources come from backend with their enabled status
@@ -961,6 +952,18 @@ export const ReleaseModal = ({
return DEFAULT_COLUMN_CONFIG;
}, [releasesBySource, activeTab]);
+ // Pre-compute display field lookups to avoid repeated .find() calls in JSX
+ const displayFields = useMemo(() => {
+ if (!book?.display_fields) return null;
+
+ const starField = book.display_fields.find(f => f.icon === 'star');
+ const ratingsField = book.display_fields.find(f => f.icon === 'ratings');
+ const usersField = book.display_fields.find(f => f.icon === 'users');
+ const pagesField = book.display_fields.find(f => f.icon === 'book');
+
+ return { starField, ratingsField, usersField, pagesField };
+ }, [book?.display_fields]);
+
// Get button state for a release based on its source_id
const getButtonState = useCallback(
(releaseId: string): ButtonStateInfo => {
@@ -1019,6 +1022,7 @@ export const ReleaseModal = ({
const currentTabLoading = loadingBySource[activeTab] ?? false;
const currentTabError = errorBySource[activeTab] ?? null;
const currentTabEnabled = allTabs.find((t) => t.name === activeTab)?.enabled ?? false;
+ const isInitialLoading = currentTabLoading || (releasesBySource[activeTab] === undefined && !currentTabError);
return (
{book.year &&
{book.year}}
- {book.display_fields?.find(f => f.icon === 'star') && (() => {
- const starField = book.display_fields!.find(f => f.icon === 'star')!;
- const ratingsField = book.display_fields?.find(f => f.icon === 'ratings');
- return (
-
-
- {starField.value}
- {ratingsField && (
- ({ratingsField.value})
- )}
-
- );
- })()}
- {book.display_fields?.find(f => f.icon === 'users') && (
+ {displayFields?.starField && (
+
+
+ {displayFields.starField.value}
+ {displayFields.ratingsField && (
+ ({displayFields.ratingsField.value})
+ )}
+
+ )}
+ {displayFields?.usersField && (
- {book.display_fields.find(f => f.icon === 'users')?.value} readers
+ {displayFields.usersField.value} readers
)}
- {book.display_fields?.find(f => f.icon === 'book') && (
-
{book.display_fields.find(f => f.icon === 'book')?.value} pages
+ {displayFields?.pagesField && (
+
{displayFields.pagesField.value} pages
)}
@@ -1366,26 +1366,14 @@ export const ReleaseModal = ({
{/* Release list content */}
- {!currentTabEnabled ? (
+ {sourcesLoading ? (
+
+ ) : !currentTabEnabled ? (
t.name === activeTab)?.displayName || activeTab}
/>
- ) : currentTabLoading && filteredReleases.length === 0 ? (
- // Initial loading - show full skeleton
-
-
- {/* Search status - bottom center */}
- {searchStatus && searchStatus.source === activeTab && (
-
-
- {searchStatus.phase !== 'complete' && searchStatus.phase !== 'error' && (
-
- )}
- {searchStatus.message}
-
-
- )}
-
+ ) : isInitialLoading && filteredReleases.length === 0 ? (
+
) : currentTabError ? (
) : filteredReleases.length === 0 && !currentTabLoading ? (
@@ -1413,8 +1401,11 @@ export const ReleaseModal = ({
/>
))}
- {/* Expand search button or loading indicator */}
- {activeTab === 'direct_download' && !expandedBySource[activeTab] && !currentTabLoading && (
+ {/* Expand search button - only show if ISBN search was used (otherwise we already did title+author) */}
+ {activeTab === 'direct_download' &&
+ releasesBySource[activeTab]?.search_info?.direct_download?.search_type === 'isbn' &&
+ !expandedBySource[activeTab] &&
+ !currentTabLoading && (
)}
+
+ {/* Sticky search status indicator - stays at bottom of visible scroll area */}
+ {searchStatus && searchStatus.source === activeTab && currentTabLoading && (
+
+
+ {searchStatus.phase !== 'complete' && searchStatus.phase !== 'error' && (
+
+ )}
+ {searchStatus.message}
+
+
+ )}
diff --git a/src/frontend/src/components/ResultsSection.tsx b/src/frontend/src/components/ResultsSection.tsx
index 337de96a..9c47a4b0 100644
--- a/src/frontend/src/components/ResultsSection.tsx
+++ b/src/frontend/src/components/ResultsSection.tsx
@@ -25,6 +25,11 @@ interface ResultsSectionProps {
sortValue: string;
onSortChange: (value: string) => void;
metadataSortOptions?: SortOption[];
+ // Pagination (universal mode)
+ hasMore?: boolean;
+ isLoadingMore?: boolean;
+ onLoadMore?: () => void;
+ totalFound?: number;
}
export const ResultsSection = ({
@@ -38,6 +43,10 @@ export const ResultsSection = ({
sortValue,
onSortChange,
metadataSortOptions,
+ hasMore,
+ isLoadingMore,
+ onLoadMore,
+ totalFound,
}: ResultsSectionProps) => {
const { searchMode } = useSearchMode();
const [viewMode, setViewMode] = useState<'card' | 'compact' | 'list'>(() => {
@@ -210,6 +219,38 @@ export const ResultsSection = ({
{books.length === 0 && (
No results found.
)}
+
+ {/* Load More button (universal mode pagination) */}
+ {searchMode === 'universal' && hasMore && onLoadMore && (
+
+
+ {totalFound !== undefined && totalFound > 0 && (
+
+ Showing {books.length} of {totalFound} results
+
+ )}
+
+ )}
);
};
diff --git a/src/frontend/src/components/resultsViews/CardView.tsx b/src/frontend/src/components/resultsViews/CardView.tsx
index 164b45b2..4374b7ca 100644
--- a/src/frontend/src/components/resultsViews/CardView.tsx
+++ b/src/frontend/src/components/resultsViews/CardView.tsx
@@ -60,7 +60,13 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
{/* Series position badge */}
{showSeriesPosition && book.series_position != null && (
-
+
#{book.series_position}
)}
diff --git a/src/frontend/src/components/resultsViews/CompactView.tsx b/src/frontend/src/components/resultsViews/CompactView.tsx
index 8015c633..efd830a8 100644
--- a/src/frontend/src/components/resultsViews/CompactView.tsx
+++ b/src/frontend/src/components/resultsViews/CompactView.tsx
@@ -61,7 +61,13 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
{/* Series position badge */}
{showSeriesPosition && book.series_position != null && (
-
+
#{book.series_position}
)}
diff --git a/src/frontend/src/components/resultsViews/ListView.tsx b/src/frontend/src/components/resultsViews/ListView.tsx
index 9386738a..d9f1af16 100644
--- a/src/frontend/src/components/resultsViews/ListView.tsx
+++ b/src/frontend/src/components/resultsViews/ListView.tsx
@@ -123,7 +123,13 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
{showSeriesPosition && book.series_position != null && (
-
+
#{book.series_position}
)}
diff --git a/src/frontend/src/hooks/useSearch.ts b/src/frontend/src/hooks/useSearch.ts
index 713563a1..2b0692d3 100644
--- a/src/frontend/src/hooks/useSearch.ts
+++ b/src/frontend/src/hooks/useSearch.ts
@@ -1,4 +1,4 @@
-import { useState, useCallback } from 'react';
+import { useState, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Book, AppConfig, AdvancedFilterState } from '../types';
import { searchBooks, searchMetadata, AuthenticationError } from '../services/api';
@@ -36,6 +36,11 @@ interface UseSearchReturn {
// Universal mode search field values
searchFieldValues: SearchFieldValues;
updateSearchFieldValue: (key: string, value: string | number | boolean) => void;
+ // Pagination (universal mode only)
+ hasMore: boolean;
+ isLoadingMore: boolean;
+ loadMore: (config: AppConfig | null) => Promise;
+ totalFound: number;
}
export function useSearch(options: UseSearchOptions): UseSearchReturn {
@@ -60,23 +65,46 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
// Universal mode: provider-specific search field values
const [searchFieldValues, setSearchFieldValues] = useState({});
+ // Pagination state (universal mode only)
+ const [currentPage, setCurrentPage] = useState(1);
+ const [hasMore, setHasMore] = useState(false);
+ const [isLoadingMore, setIsLoadingMore] = useState(false);
+ const [totalFound, setTotalFound] = useState(0);
+
+ // Store last search params for loadMore
+ const lastSearchParamsRef = useRef<{
+ query: string;
+ sort: string;
+ fieldValues: SearchFieldValues;
+ } | null>(null);
+
const updateAdvancedFilters = useCallback((updates: Partial) => {
setAdvancedFilters(prev => ({ ...prev, ...updates }));
}, []);
const updateSearchFieldValue = useCallback((key: string, value: string | number | boolean) => {
- console.log('[useSearch] updateSearchFieldValue:', key, '=', value);
- setSearchFieldValues(prev => {
- const next = { ...prev, [key]: value };
- console.log('[useSearch] searchFieldValues updated:', next);
- return next;
- });
+ setSearchFieldValues(prev => ({ ...prev, [key]: value }));
}, []);
const resetSortFilter = useCallback(() => {
setAdvancedFilters(prev => ({ ...prev, sort: '' }));
}, []);
+ // Helper to handle authentication and other errors consistently
+ const handleSearchError = useCallback((error: unknown, context: string) => {
+ if (error instanceof AuthenticationError) {
+ setIsAuthenticated(false);
+ if (authRequired) {
+ navigate('/login', { replace: true });
+ }
+ return;
+ }
+
+ console.error(`${context}:`, error);
+ const message = error instanceof Error ? error.message : context;
+ showToast(message, 'error');
+ }, [setIsAuthenticated, authRequired, navigate, showToast]);
+
const handleSearch = useCallback(async (
query: string,
config: AppConfig | null,
@@ -97,21 +125,13 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
const hasSeriesSearch = typeof seriesValue === 'string' && seriesValue.trim() !== '';
const sort = hasSeriesSearch ? 'series_order' : (params.get('sort') || 'relevance');
- // Debug logging
- console.log('[useSearch] Universal mode search:', {
- query,
- searchQuery,
- sort,
- searchFieldValues,
- fieldValues,
- effectiveFieldValues,
- hasFieldValues,
- });
-
if (!searchQuery && !hasFieldValues) {
- console.log('[useSearch] Early return: no query and no field values');
setBooks([]);
setLastSearchQuery('');
+ setHasMore(false);
+ setTotalFound(0);
+ setCurrentPage(1);
+ lastSearchParamsRef.current = null;
return;
}
@@ -122,26 +142,27 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
setIsSearching(true);
setLastSearchQuery(query);
+ // Reset pagination for new search
+ setCurrentPage(1);
+ setHasMore(false);
+ setTotalFound(0);
try {
- const results = await searchMetadata(searchQuery, 20, sort, effectiveFieldValues);
- if (results.length > 0) {
- setBooks(results);
+ const result = await searchMetadata(searchQuery, 40, sort, effectiveFieldValues, 1);
+ if (result.books.length > 0) {
+ setBooks(result.books);
+ setHasMore(result.hasMore);
+ setTotalFound(result.totalFound);
+ // Store params for loadMore
+ lastSearchParamsRef.current = { query: searchQuery, sort, fieldValues: effectiveFieldValues };
} else {
+ setBooks([]);
+ setHasMore(false);
+ setTotalFound(0);
showToast('No results found', 'error');
}
} catch (error) {
- if (error instanceof AuthenticationError) {
- setIsAuthenticated(false);
- if (authRequired) {
- navigate('/login', { replace: true });
- }
- } else {
- console.error('Search failed:', error);
- // API now returns user-friendly error messages directly
- const message = error instanceof Error ? error.message : 'Search failed';
- showToast(message, 'error');
- }
+ handleSearchError(error, 'Search failed');
} finally {
setIsSearching(false);
}
@@ -167,10 +188,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
}
} catch (error) {
if (error instanceof AuthenticationError) {
- setIsAuthenticated(false);
- if (authRequired) {
- navigate('/login', { replace: true });
- }
+ handleSearchError(error, 'Search failed');
} else {
console.error('Search failed:', error);
const message = error instanceof Error ? error.message : 'Search failed';
@@ -182,7 +200,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
} finally {
setIsSearching(false);
}
- }, [showToast, setIsAuthenticated, authRequired, navigate, searchFieldValues]);
+ }, [showToast, setIsAuthenticated, authRequired, navigate, searchFieldValues, handleSearchError]);
const handleResetSearch = useCallback((config: AppConfig | null) => {
setBooks([]);
@@ -204,8 +222,42 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
// Reset universal mode search field values
setSearchFieldValues({});
+
+ // Reset pagination
+ setCurrentPage(1);
+ setHasMore(false);
+ setTotalFound(0);
+ lastSearchParamsRef.current = null;
}, [onSearchReset]);
+ // Load more results (universal mode pagination)
+ const loadMore = useCallback(async (config: AppConfig | null) => {
+ const searchMode = config?.search_mode || 'direct';
+ if (searchMode !== 'universal') return;
+ if (!lastSearchParamsRef.current) return;
+ if (isLoadingMore || !hasMore) return;
+
+ const { query, sort, fieldValues } = lastSearchParamsRef.current;
+ const nextPage = currentPage + 1;
+
+ setIsLoadingMore(true);
+
+ try {
+ const result = await searchMetadata(query, 40, sort, fieldValues, nextPage);
+ if (result.books.length > 0) {
+ setBooks(prev => [...prev, ...result.books]);
+ setHasMore(result.hasMore);
+ setCurrentPage(nextPage);
+ } else {
+ setHasMore(false);
+ }
+ } catch (error) {
+ handleSearchError(error, 'Failed to load more results');
+ } finally {
+ setIsLoadingMore(false);
+ }
+ }, [currentPage, hasMore, isLoadingMore, handleSearchError]);
+
const handleSortChange = useCallback((value: string, config: AppConfig | null) => {
updateAdvancedFilters({ sort: value });
if (!lastSearchQuery) return;
@@ -241,5 +293,10 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
// Universal mode search field values
searchFieldValues,
updateSearchFieldValue,
+ // Pagination (universal mode only)
+ hasMore,
+ isLoadingMore,
+ loadMore,
+ totalFound,
};
}
diff --git a/src/frontend/src/services/api.ts b/src/frontend/src/services/api.ts
index d6ac6c1f..0a5266ce 100644
--- a/src/frontend/src/services/api.ts
+++ b/src/frontend/src/services/api.ts
@@ -78,23 +78,31 @@ interface MetadataSearchResponse {
books: MetadataBookData[];
provider: string;
query: string;
+ page?: number;
+ total_found?: number;
+ has_more?: boolean;
+}
+
+// Metadata search result with pagination info
+export interface MetadataSearchResult {
+ books: Book[];
+ page: number;
+ totalFound: number;
+ hasMore: boolean;
}
// Search metadata providers and normalize to Book format
export const searchMetadata = async (
query: string,
- limit: number = 20,
+ limit: number = 40,
sort: string = 'relevance',
- fields: Record = {}
-): Promise => {
+ fields: Record = {},
+ page: number = 1
+): Promise => {
const hasFields = Object.values(fields).some(v => v !== '' && v !== false);
- // Debug logging
- console.log('[api.searchMetadata] Called with:', { query, limit, sort, fields, hasFields });
-
if (!query && !hasFields) {
- console.log('[api.searchMetadata] Early return: no query and no fields');
- return [];
+ return { books: [], page: 1, totalFound: 0, hasMore: false };
}
const params = new URLSearchParams();
@@ -103,6 +111,7 @@ export const searchMetadata = async (
}
params.set('limit', String(limit));
params.set('sort', sort);
+ params.set('page', String(page));
// Add custom search field values
Object.entries(fields).forEach(([key, value]) => {
@@ -111,13 +120,14 @@ export const searchMetadata = async (
}
});
- const requestUrl = `${API.metadataSearch}?${params.toString()}`;
- console.log('[api.searchMetadata] Request URL:', requestUrl);
+ const response = await fetchJSON(`${API.metadataSearch}?${params.toString()}`);
- const response = await fetchJSON(requestUrl);
- console.log('[api.searchMetadata] Response:', response);
-
- return response.books.map(transformMetadataToBook);
+ return {
+ books: response.books.map(transformMetadataToBook),
+ page: response.page || page,
+ totalFound: response.total_found || 0,
+ hasMore: response.has_more || false,
+ };
};
export const getBookInfo = async (id: string): Promise => {
diff --git a/src/frontend/src/types/index.ts b/src/frontend/src/types/index.ts
index c41a0691..66ead740 100644
--- a/src/frontend/src/types/index.ts
+++ b/src/frontend/src/types/index.ts
@@ -248,6 +248,11 @@ export interface Release {
extra?: Record; // Source-specific metadata
}
+// Search info returned by release sources
+export interface SourceSearchInfo {
+ search_type: 'isbn' | 'title_author';
+}
+
// Response from /api/releases endpoint
export interface ReleasesResponse {
releases: Release[];
@@ -265,6 +270,7 @@ export interface ReleasesResponse {
sources_searched: string[];
errors?: string[];
column_config?: ReleaseColumnConfig | null; // Plugin-driven column configuration
+ search_info?: Record; // Per-source search metadata
}
// Search status update from WebSocket (for ReleaseModal loading state)
diff --git a/src/frontend/src/utils/colorMaps.ts b/src/frontend/src/utils/colorMaps.ts
index 08d7cd61..24a1d4fa 100644
--- a/src/frontend/src/utils/colorMaps.ts
+++ b/src/frontend/src/utils/colorMaps.ts
@@ -65,4 +65,39 @@ export function getDownloadTypeColor(downloadType?: string): ColorStyle {
return DOWNLOAD_TYPE_COLORS[downloadType.toLowerCase()] || DEFAULT_DOWNLOAD_TYPE_COLOR;
}
+/**
+ * Color hint type for dynamic column coloring.
+ */
+interface ColumnColorHint {
+ type: 'static' | 'map';
+ value: string;
+}
+
+/**
+ * Get the color style for a value based on a color hint.
+ * Supports both static color classes and dynamic map lookups.
+ */
+export function getColorStyleFromHint(value: string, colorHint?: ColumnColorHint | null): ColorStyle {
+ if (!colorHint) return FALLBACK_COLOR;
+
+ if (colorHint.type === 'static') {
+ return { bg: colorHint.value, text: 'text-gray-700 dark:text-gray-300' };
+ }
+
+ if (colorHint.type === 'map') {
+ switch (colorHint.value) {
+ case 'format':
+ return getFormatColor(value);
+ case 'language':
+ return getLanguageColor(value);
+ case 'download_type':
+ return getDownloadTypeColor(value);
+ default:
+ return FALLBACK_COLOR;
+ }
+ }
+
+ return FALLBACK_COLOR;
+}
+
export type { ColorStyle };
diff --git a/src/frontend/src/utils/objectHelpers.ts b/src/frontend/src/utils/objectHelpers.ts
new file mode 100644
index 00000000..5cf0ffb3
--- /dev/null
+++ b/src/frontend/src/utils/objectHelpers.ts
@@ -0,0 +1,12 @@
+/**
+ * Get a nested value from an object using dot-notation path.
+ * e.g., getNestedValue(obj, "extra.language") returns obj.extra.language
+ */
+export function getNestedValue(obj: Record, path: string): unknown {
+ return path.split('.').reduce((current, key) => {
+ if (current && typeof current === 'object') {
+ return (current as Record)[key];
+ }
+ return undefined;
+ }, obj as unknown);
+}