mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 22:05:50 +01:00
Hardcover enhancements, refactor and cleanup, PUID/PGID additions (#365)
This commit is contained in:
@@ -85,6 +85,11 @@ function App() {
|
||||
handleSortChange,
|
||||
searchFieldValues,
|
||||
updateSearchFieldValue,
|
||||
// Pagination (universal mode)
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
loadMore,
|
||||
totalFound,
|
||||
} = useSearch({
|
||||
showToast,
|
||||
setIsAuthenticated,
|
||||
@@ -591,6 +596,10 @@ function App() {
|
||||
sortValue={advancedFilters.sort}
|
||||
onSortChange={(value) => handleSortChange(value, config)}
|
||||
metadataSortOptions={config?.metadata_sort_options}
|
||||
hasMore={hasMore}
|
||||
isLoadingMore={isLoadingMore}
|
||||
onLoadMore={() => loadMore(config)}
|
||||
totalFound={totalFound}
|
||||
/>
|
||||
|
||||
{selectedBook && (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Language } from '../types';
|
||||
import {
|
||||
formatDefaultLanguageLabel,
|
||||
LANGUAGE_OPTION_ALL,
|
||||
LANGUAGE_OPTION_DEFAULT,
|
||||
normalizeLanguageSelection,
|
||||
@@ -24,28 +23,40 @@ export const LanguageMultiSelect = ({
|
||||
label,
|
||||
placeholder,
|
||||
}: LanguageMultiSelectProps) => {
|
||||
const defaultLabel = formatDefaultLanguageLabel(defaultLanguageCodes, options);
|
||||
const defaultCodeSet = new Set(defaultLanguageCodes);
|
||||
const nonDefaultLanguages = options.filter(lang => !defaultCodeSet.has(lang.code));
|
||||
const selectableValues = [LANGUAGE_OPTION_DEFAULT, ...nonDefaultLanguages.map(lang => lang.code)];
|
||||
|
||||
// Get default languages with their full info
|
||||
const defaultLanguages = options.filter(lang => defaultCodeSet.has(lang.code));
|
||||
const nonDefaultLanguages = options.filter(lang => !defaultCodeSet.has(lang.code));
|
||||
|
||||
// All selectable values (individual language codes, not LANGUAGE_OPTION_DEFAULT)
|
||||
const selectableValues = [...defaultLanguageCodes, ...nonDefaultLanguages.map(lang => lang.code)];
|
||||
|
||||
// Build option list: All, then defaults (marked), then others
|
||||
const optionList: DropdownListOption[] = [
|
||||
{
|
||||
value: LANGUAGE_OPTION_ALL,
|
||||
label: 'All languages',
|
||||
},
|
||||
{
|
||||
value: LANGUAGE_OPTION_DEFAULT,
|
||||
label: defaultLabel,
|
||||
},
|
||||
// Each default language as a separate option
|
||||
...defaultLanguages.map(lang => ({
|
||||
value: lang.code,
|
||||
label: `${lang.language} (default)`,
|
||||
})),
|
||||
// Non-default languages
|
||||
...nonDefaultLanguages.map(lang => ({
|
||||
value: lang.code,
|
||||
label: lang.language,
|
||||
})),
|
||||
];
|
||||
|
||||
// Expand LANGUAGE_OPTION_DEFAULT to individual default codes for comparison
|
||||
const expandedValue = value.flatMap(v =>
|
||||
v === LANGUAGE_OPTION_DEFAULT ? defaultLanguageCodes : [v]
|
||||
);
|
||||
|
||||
const includesAllSelection = value.includes(LANGUAGE_OPTION_ALL);
|
||||
const effectiveValue = includesAllSelection ? selectableValues : value;
|
||||
const effectiveValue = includesAllSelection ? selectableValues : expandedValue;
|
||||
const selectedSet = new Set(effectiveValue);
|
||||
const isAllSelected = selectableValues.every(code => selectedSet.has(code));
|
||||
const displayedValue = isAllSelected ? [LANGUAGE_OPTION_ALL, ...effectiveValue] : effectiveValue;
|
||||
@@ -57,11 +68,8 @@ export const LanguageMultiSelect = ({
|
||||
|
||||
const labels: string[] = [];
|
||||
|
||||
if (selectedSet.has(LANGUAGE_OPTION_DEFAULT)) {
|
||||
labels.push(defaultLabel);
|
||||
}
|
||||
|
||||
nonDefaultLanguages.forEach(lang => {
|
||||
// Check each language
|
||||
options.forEach(lang => {
|
||||
if (selectedSet.has(lang.code)) {
|
||||
labels.push(lang.language);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ColumnSchema, ColumnColorHint, Release } from '../types';
|
||||
import { getFormatColor, getLanguageColor, getDownloadTypeColor, ColorStyle } from '../utils/colorMaps';
|
||||
import { ColumnSchema, Release } from '../types';
|
||||
import { getColorStyleFromHint } from '../utils/colorMaps';
|
||||
import { getNestedValue } from '../utils/objectHelpers';
|
||||
|
||||
interface ReleaseCellProps {
|
||||
column: ColumnSchema;
|
||||
@@ -8,48 +9,6 @@ interface ReleaseCellProps {
|
||||
onlineServers?: string[]; // For IRC: list of online server nicks to show status indicator
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a nested value from an object using dot-notation path.
|
||||
* e.g., getNestedValue(obj, "extra.language") returns obj.extra.language
|
||||
*/
|
||||
const getNestedValue = (obj: Record<string, unknown>, path: string): unknown => {
|
||||
return path.split('.').reduce((current, key) => {
|
||||
if (current && typeof current === 'object') {
|
||||
return (current as Record<string, unknown>)[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 <span>{displayValue}</span>;
|
||||
}
|
||||
const colorStyle = getColorStyle(value, column.color_hint);
|
||||
const colorStyle = getColorStyleFromHint(value, column.color_hint);
|
||||
return (
|
||||
<div className={`flex items-center ${alignClass}`}>
|
||||
{value !== column.fallback ? (
|
||||
|
||||
@@ -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<string, ReleasesResponse>();
|
||||
|
||||
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<string, number>();
|
||||
|
||||
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<string, (value: string) => 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<string, unknown>, path: string): unknown {
|
||||
return path.split('.').reduce((current, key) => {
|
||||
if (current && typeof current === 'object') {
|
||||
return (current as Record<string, unknown>)[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
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// 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<string, unknown>, 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 }) => (
|
||||
<div
|
||||
className={`rounded bg-gray-200 dark:bg-gray-800 relative overflow-hidden ${className}`}
|
||||
>
|
||||
function ShimmerBlock({ className }: { className: string }) {
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 dark:opacity-50"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.4) 50%, transparent 100%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'wave 2s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
className={`rounded bg-gray-200 dark:bg-gray-800 relative overflow-hidden ${className}`}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 dark:opacity-50"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.4) 50%, transparent 100%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'wave 2s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading skeleton for releases - matches ReleaseRow layout
|
||||
const ReleaseSkeleton = () => (
|
||||
<div className="divide-y divide-gray-200/60 dark:divide-gray-800/60">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="px-5 py-2"
|
||||
style={{
|
||||
opacity: 1 - (i - 1) * 0.15, // Fade out lower rows
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-[auto_1fr_auto] sm:grid-cols-[auto_minmax(0,2fr)_60px_80px_80px_auto] items-center gap-2 sm:gap-3">
|
||||
{/* Thumbnail skeleton */}
|
||||
<ShimmerBlock className="w-7 h-10 sm:w-10 sm:h-14" />
|
||||
function ReleaseSkeleton() {
|
||||
return (
|
||||
<div className="divide-y divide-gray-200/60 dark:divide-gray-800/60">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="px-5 py-2"
|
||||
style={{
|
||||
opacity: 1 - (i - 1) * 0.15, // Fade out lower rows
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-[auto_1fr_auto] sm:grid-cols-[auto_minmax(0,2fr)_60px_80px_80px_auto] items-center gap-2 sm:gap-3">
|
||||
{/* Thumbnail skeleton */}
|
||||
<ShimmerBlock className="w-7 h-10 sm:w-10 sm:h-14" />
|
||||
|
||||
{/* Title and author skeleton */}
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<ShimmerBlock className="h-4 w-3/4" />
|
||||
<ShimmerBlock className="h-3 w-1/2" />
|
||||
</div>
|
||||
|
||||
{/* Language badge skeleton - desktop */}
|
||||
<div className="hidden sm:flex justify-center">
|
||||
<ShimmerBlock className="w-8 h-5" />
|
||||
</div>
|
||||
|
||||
{/* Format badge skeleton - desktop */}
|
||||
<div className="hidden sm:flex justify-center">
|
||||
<ShimmerBlock className="w-12 h-5" />
|
||||
</div>
|
||||
|
||||
{/* Size skeleton - desktop */}
|
||||
<div className="hidden sm:flex justify-center">
|
||||
<ShimmerBlock className="w-14 h-4" />
|
||||
</div>
|
||||
|
||||
{/* Mobile info + action skeleton */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Mobile: format + size inline */}
|
||||
<div className="flex sm:hidden flex-col items-end gap-1">
|
||||
<ShimmerBlock className="w-10 h-3" />
|
||||
<ShimmerBlock className="w-14 h-3" />
|
||||
{/* Title and author skeleton */}
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<ShimmerBlock className="h-4 w-3/4" />
|
||||
<ShimmerBlock className="h-3 w-1/2" />
|
||||
</div>
|
||||
|
||||
{/* Action button skeleton */}
|
||||
<ShimmerBlock className="w-8 h-8 !rounded-full" />
|
||||
{/* Language badge skeleton - desktop */}
|
||||
<div className="hidden sm:flex justify-center">
|
||||
<ShimmerBlock className="w-8 h-5" />
|
||||
</div>
|
||||
|
||||
{/* Format badge skeleton - desktop */}
|
||||
<div className="hidden sm:flex justify-center">
|
||||
<ShimmerBlock className="w-12 h-5" />
|
||||
</div>
|
||||
|
||||
{/* Size skeleton - desktop */}
|
||||
<div className="hidden sm:flex justify-center">
|
||||
<ShimmerBlock className="w-14 h-4" />
|
||||
</div>
|
||||
|
||||
{/* Mobile info + action skeleton */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Mobile: format + size inline */}
|
||||
<div className="flex sm:hidden flex-col items-end gap-1">
|
||||
<ShimmerBlock className="w-10 h-3" />
|
||||
<ShimmerBlock className="w-14 h-3" />
|
||||
</div>
|
||||
|
||||
{/* Action button skeleton */}
|
||||
<ShimmerBlock className="w-8 h-8 !rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state component
|
||||
const EmptyState = ({ message }: { message: string }) => (
|
||||
<div className="text-center py-12 px-4">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-gray-100 dark:bg-gray-800 mb-4">
|
||||
<svg
|
||||
className="w-7 h-7 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
|
||||
/>
|
||||
</svg>
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="text-center py-12 px-4">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-gray-100 dark:bg-gray-800 mb-4">
|
||||
<svg
|
||||
className="w-7 h-7 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{message}</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{message}</p>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
// Configure source CTA
|
||||
const ConfigureSourceCTA = ({ sourceName }: { sourceName: string }) => (
|
||||
<div className="text-center py-12 px-4">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-amber-100 dark:bg-amber-900/30 mb-4">
|
||||
<svg
|
||||
className="w-7 h-7 text-amber-600 dark:text-amber-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
function ConfigureSourceCTA({ sourceName }: { sourceName: string }) {
|
||||
return (
|
||||
<div className="text-center py-12 px-4">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-amber-100 dark:bg-amber-900/30 mb-4">
|
||||
<svg
|
||||
className="w-7 h-7 text-amber-600 dark:text-amber-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
{sourceName} Not Configured
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-xs mx-auto">
|
||||
Configure {sourceName} in Settings to enable searching this source.
|
||||
</p>
|
||||
</div>
|
||||
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
{sourceName} Not Configured
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-xs mx-auto">
|
||||
Configure {sourceName} in Settings to enable searching this source.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Error state component
|
||||
const ErrorState = ({ message }: { message: string }) => (
|
||||
<div className="text-center py-12 px-4">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-red-100 dark:bg-red-900/30 mb-4">
|
||||
<svg
|
||||
className="w-7 h-7 text-red-600 dark:text-red-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"
|
||||
/>
|
||||
</svg>
|
||||
function ErrorState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="text-center py-12 px-4">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-red-100 dark:bg-red-900/30 mb-4">
|
||||
<svg
|
||||
className="w-7 h-7 text-red-600 dark:text-red-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Error Loading Releases
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-xs mx-auto">{message}</p>
|
||||
</div>
|
||||
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Error Loading Releases
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-xs mx-auto">{message}</p>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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 (
|
||||
<div
|
||||
@@ -1109,29 +1113,25 @@ export const ReleaseModal = ({
|
||||
{/* Metadata row */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{book.year && <span>{book.year}</span>}
|
||||
{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 (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<StarRating rating={parseFloat(starField.value || '0')} />
|
||||
<span>{starField.value}</span>
|
||||
{ratingsField && (
|
||||
<span className="text-gray-400 dark:text-gray-500">({ratingsField.value})</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{book.display_fields?.find(f => f.icon === 'users') && (
|
||||
{displayFields?.starField && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<StarRating rating={parseFloat(displayFields.starField.value || '0')} />
|
||||
<span>{displayFields.starField.value}</span>
|
||||
{displayFields.ratingsField && (
|
||||
<span className="text-gray-400 dark:text-gray-500">({displayFields.ratingsField.value})</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{displayFields?.usersField && (
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="h-3.5 w-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
|
||||
</svg>
|
||||
{book.display_fields.find(f => f.icon === 'users')?.value} readers
|
||||
{displayFields.usersField.value} readers
|
||||
</span>
|
||||
)}
|
||||
{book.display_fields?.find(f => f.icon === 'book') && (
|
||||
<span>{book.display_fields.find(f => f.icon === 'book')?.value} pages</span>
|
||||
{displayFields?.pagesField && (
|
||||
<span>{displayFields.pagesField.value} pages</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1366,26 +1366,14 @@ export const ReleaseModal = ({
|
||||
|
||||
{/* Release list content */}
|
||||
<div className="min-h-[200px]">
|
||||
{!currentTabEnabled ? (
|
||||
{sourcesLoading ? (
|
||||
<ReleaseSkeleton />
|
||||
) : !currentTabEnabled ? (
|
||||
<ConfigureSourceCTA
|
||||
sourceName={allTabs.find((t) => t.name === activeTab)?.displayName || activeTab}
|
||||
/>
|
||||
) : currentTabLoading && filteredReleases.length === 0 ? (
|
||||
// Initial loading - show full skeleton
|
||||
<div className="relative min-h-[200px]">
|
||||
<ReleaseSkeleton />
|
||||
{/* Search status - bottom center */}
|
||||
{searchStatus && searchStatus.source === activeTab && (
|
||||
<div className="absolute inset-x-0 bottom-4 z-10 flex items-center justify-center pointer-events-none">
|
||||
<div className="flex items-center gap-2.5 px-4 py-2 rounded-xl bg-[var(--bg-soft)] border border-[var(--border-muted)] text-gray-500 dark:text-gray-400 text-sm shadow-sm">
|
||||
{searchStatus.phase !== 'complete' && searchStatus.phase !== 'error' && (
|
||||
<div className="w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
{searchStatus.message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : isInitialLoading && filteredReleases.length === 0 ? (
|
||||
<ReleaseSkeleton />
|
||||
) : currentTabError ? (
|
||||
<ErrorState message={currentTabError} />
|
||||
) : filteredReleases.length === 0 && !currentTabLoading ? (
|
||||
@@ -1413,8 +1401,11 @@ export const ReleaseModal = ({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* 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 && (
|
||||
<div
|
||||
className="py-3 text-center animate-slide-up will-change-transform"
|
||||
style={{
|
||||
@@ -1438,6 +1429,18 @@ export const ReleaseModal = ({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sticky search status indicator - stays at bottom of visible scroll area */}
|
||||
{searchStatus && searchStatus.source === activeTab && currentTabLoading && (
|
||||
<div className="sticky bottom-0 z-10 flex items-center justify-center pointer-events-none pb-4 pt-2">
|
||||
<div className="flex items-center gap-2.5 px-4 py-2 rounded-xl bg-[var(--bg-soft)] border border-[var(--border-muted)] text-gray-500 dark:text-gray-400 text-sm shadow-lg pointer-events-auto">
|
||||
{searchStatus.phase !== 'complete' && searchStatus.phase !== 'error' && (
|
||||
<div className="w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
{searchStatus.message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 && (
|
||||
<div className="mt-4 text-sm opacity-80">No results found.</div>
|
||||
)}
|
||||
|
||||
{/* Load More button (universal mode pagination) */}
|
||||
{searchMode === 'universal' && hasMore && onLoadMore && (
|
||||
<div className="mt-12 flex flex-col items-center gap-2">
|
||||
<button
|
||||
onClick={onLoadMore}
|
||||
disabled={isLoadingMore}
|
||||
className={`px-3 py-2 rounded-full text-sm font-medium transition-all duration-200 ${
|
||||
isLoadingMore
|
||||
? 'bg-gray-300 dark:bg-gray-600 text-gray-500 dark:text-gray-400 cursor-wait'
|
||||
: 'bg-emerald-600 hover:bg-emerald-700 text-white'
|
||||
}`}
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Loading...
|
||||
</span>
|
||||
) : (
|
||||
'Load More'
|
||||
)}
|
||||
</button>
|
||||
{totalFound !== undefined && totalFound > 0 && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Showing {books.length} of {totalFound} results
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -60,7 +60,13 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
|
||||
<div className="relative w-full sm:w-full max-sm:w-[120px] max-sm:h-full max-sm:flex-shrink-0 group" style={{ aspectRatio: '2/3' }}>
|
||||
{/* Series position badge */}
|
||||
{showSeriesPosition && book.series_position != null && (
|
||||
<div className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md shadow-lg">
|
||||
<div
|
||||
className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md border border-emerald-700"
|
||||
style={{
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.3)',
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
#{book.series_position}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -61,7 +61,13 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
|
||||
<div className="relative w-[120px] h-full flex-shrink-0">
|
||||
{/* Series position badge */}
|
||||
{showSeriesPosition && book.series_position != null && (
|
||||
<div className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md shadow-lg">
|
||||
<div
|
||||
className="absolute top-2 left-2 z-10 px-2 py-1 text-xs font-bold text-white bg-emerald-600 rounded-md border border-emerald-700"
|
||||
style={{
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.3)',
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
#{book.series_position}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -123,7 +123,13 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
|
||||
<div className="min-w-0 flex flex-col justify-center sm:pl-3">
|
||||
<h3 className="font-semibold text-xs min-[400px]:text-sm sm:text-base leading-tight line-clamp-1 sm:line-clamp-2 flex items-center gap-2" title={book.title || 'Untitled'}>
|
||||
{showSeriesPosition && book.series_position != null && (
|
||||
<span className="inline-flex mr-1.5 px-1.5 py-0.5 text-[10px] sm:text-xs font-bold text-white bg-emerald-600 rounded flex-shrink-0">
|
||||
<span
|
||||
className="inline-flex mr-1.5 px-1.5 py-0.5 text-[10px] sm:text-xs font-bold text-white bg-emerald-600 rounded border border-emerald-700 flex-shrink-0"
|
||||
style={{
|
||||
boxShadow: '0 1px 4px rgba(0, 0, 0, 0.3)',
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
#{book.series_position}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -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<void>;
|
||||
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<SearchFieldValues>({});
|
||||
|
||||
// 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<AdvancedFilterState>) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, string | number | boolean> = {}
|
||||
): Promise<Book[]> => {
|
||||
fields: Record<string, string | number | boolean> = {},
|
||||
page: number = 1
|
||||
): Promise<MetadataSearchResult> => {
|
||||
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<MetadataSearchResponse>(`${API.metadataSearch}?${params.toString()}`);
|
||||
|
||||
const response = await fetchJSON<MetadataSearchResponse>(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<Book> => {
|
||||
|
||||
@@ -248,6 +248,11 @@ export interface Release {
|
||||
extra?: Record<string, unknown>; // 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<string, SourceSearchInfo>; // Per-source search metadata
|
||||
}
|
||||
|
||||
// Search status update from WebSocket (for ReleaseModal loading state)
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<string, unknown>, path: string): unknown {
|
||||
return path.split('.').reduce((current, key) => {
|
||||
if (current && typeof current === 'object') {
|
||||
return (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return undefined;
|
||||
}, obj as unknown);
|
||||
}
|
||||
Reference in New Issue
Block a user