美化电子书馆

This commit is contained in:
mtvpls
2026-05-28 09:52:41 +08:00
parent c942c8c9ea
commit dc31a788fc
9 changed files with 3808 additions and 1394 deletions
+493 -172
View File
@@ -1,11 +1,32 @@
'use client';
import {
BookMarked,
Layers3,
Loader2,
Search,
Sparkles,
X,
} from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { startTransition, useCallback, useEffect, useRef, useState } from 'react';
import {
startTransition,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { BookListItem, BookSearchResult, BookSource } from '@/lib/book.types';
import {
buildBookDetailPath,
cacheBookListItem,
} from '@/lib/book-route-cache.client';
import BookCard from '@/components/books/BookCard';
import { buildBookDetailPath, cacheBookListItem } from '@/lib/book-route-cache.client';
import { BookListItem, BookSearchResult, BookSource } from '@/lib/book.types';
type RuntimeWindow = Window & { RUNTIME_CONFIG?: { FLUID_SEARCH?: boolean } };
function detailHref(item: BookListItem) {
return buildBookDetailPath(item.sourceId, item.id);
@@ -15,10 +36,13 @@ function SearchSkeleton() {
return (
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6 animate-pulse'>
{Array.from({ length: 12 }).map((_, index) => (
<div key={index} className='space-y-3'>
<div className='aspect-[3/4] rounded-2xl bg-gray-200 dark:bg-gray-800' />
<div className='h-4 w-3/4 rounded bg-gray-200 dark:bg-gray-800' />
<div className='h-3 w-1/2 rounded bg-gray-200 dark:bg-gray-800' />
<div
key={index}
className='overflow-hidden rounded-[1.75rem] border border-emerald-100/70 bg-white/70 p-3 shadow-sm dark:border-emerald-500/10 dark:bg-gray-950/50'
>
<div className='aspect-[3/4] rounded-2xl bg-gradient-to-br from-emerald-100 to-amber-100 dark:from-gray-800 dark:to-emerald-950/30' />
<div className='mt-3 h-4 w-3/4 rounded bg-emerald-100 dark:bg-gray-800' />
<div className='mt-2 h-3 w-1/2 rounded bg-emerald-100/80 dark:bg-gray-800' />
</div>
))}
</div>
@@ -27,6 +51,7 @@ function SearchSkeleton() {
const BOOK_SEARCH_STATE_KEY = 'book_search_state';
const EMPTY_RESULT: BookSearchResult = { results: [], failedSources: [] };
const QUICK_SEARCHES = ['三体', '刘慈欣', '东野圭吾', '哈利波特'];
export default function BooksSearchPage() {
const router = useRouter();
@@ -52,39 +77,65 @@ export default function BooksSearchPage() {
const pendingResultsRef = useRef<BookListItem[]>([]);
const flushTimerRef = useRef<number | null>(null);
const getCacheKey = useCallback((keyword: string, selectedSourceId: string) => `book_search_cache_${selectedSourceId || 'all'}_${keyword.trim()}`, []);
const getCacheKey = useCallback(
(keyword: string, selectedSourceId: string) =>
`book_search_cache_${selectedSourceId || 'all'}_${keyword.trim()}`,
[]
);
const getCachedResult = useCallback((keyword: string, selectedSourceId: string) => {
if (typeof window === 'undefined' || !keyword.trim()) return null;
try {
const raw = sessionStorage.getItem(getCacheKey(keyword, selectedSourceId));
return raw ? (JSON.parse(raw) as BookSearchResult) : null;
} catch {
return null;
}
}, [getCacheKey]);
const getCachedResult = useCallback(
(keyword: string, selectedSourceId: string) => {
if (typeof window === 'undefined' || !keyword.trim()) return null;
try {
const raw = sessionStorage.getItem(
getCacheKey(keyword, selectedSourceId)
);
return raw ? (JSON.parse(raw) as BookSearchResult) : null;
} catch {
return null;
}
},
[getCacheKey]
);
const setCachedResult = useCallback((keyword: string, selectedSourceId: string, nextResult: BookSearchResult) => {
if (typeof window === 'undefined' || !keyword.trim()) return;
try {
sessionStorage.setItem(getCacheKey(keyword, selectedSourceId), JSON.stringify(nextResult));
} catch {}
}, [getCacheKey]);
const setCachedResult = useCallback(
(
keyword: string,
selectedSourceId: string,
nextResult: BookSearchResult
) => {
if (typeof window === 'undefined' || !keyword.trim()) return;
try {
sessionStorage.setItem(
getCacheKey(keyword, selectedSourceId),
JSON.stringify(nextResult)
);
} catch {
// Ignore storage/browser cleanup failures.
}
},
[getCacheKey]
);
const readFluidSearchSetting = useCallback(() => {
if (typeof window === 'undefined') return true;
try {
const savedFluidSearch = localStorage.getItem('fluidSearch');
if (savedFluidSearch !== null) return JSON.parse(savedFluidSearch) !== false;
} catch {}
return (window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false;
if (savedFluidSearch !== null)
return JSON.parse(savedFluidSearch) !== false;
} catch {
// Ignore storage/browser cleanup failures.
}
return (window as RuntimeWindow).RUNTIME_CONFIG?.FLUID_SEARCH !== false;
}, []);
const closeEventSource = useCallback(() => {
if (eventSourceRef.current) {
try {
eventSourceRef.current.close();
} catch {}
} catch {
// Ignore cleanup failures.
}
eventSourceRef.current = null;
}
}, []);
@@ -105,170 +156,248 @@ export default function BooksSearchPage() {
const toAppend = pendingResultsRef.current;
pendingResultsRef.current = [];
startTransition(() => {
setResult((prev) => ({ ...prev, results: prev.results.concat(toAppend) }));
setResult((prev) => ({
...prev,
results: prev.results.concat(toAppend),
}));
});
flushTimerRef.current = null;
}, 80);
}
}, []);
const saveSearchState = useCallback((nextState: { q: string; sourceId: string; result: BookSearchResult }) => {
if (typeof window === 'undefined') return;
try {
sessionStorage.setItem(BOOK_SEARCH_STATE_KEY, JSON.stringify(nextState));
} catch {}
}, []);
const saveSearchState = useCallback(
(nextState: { q: string; sourceId: string; result: BookSearchResult }) => {
if (typeof window === 'undefined') return;
try {
sessionStorage.setItem(
BOOK_SEARCH_STATE_KEY,
JSON.stringify(nextState)
);
} catch {
// Ignore storage failures.
}
},
[]
);
const restoreSearchState = useCallback(() => {
if (typeof window === 'undefined') return null;
try {
const raw = sessionStorage.getItem(BOOK_SEARCH_STATE_KEY);
return raw ? (JSON.parse(raw) as { q: string; sourceId: string; result: BookSearchResult }) : null;
return raw
? (JSON.parse(raw) as {
q: string;
sourceId: string;
result: BookSearchResult;
})
: null;
} catch {
return null;
}
}, []);
const performSearch = useCallback(async (keyword: string, selectedSourceId: string, options?: { forceRefresh?: boolean }) => {
const trimmed = keyword.trim();
if (!trimmed) return;
const normalizedSourceId = selectedSourceId || '';
const searchKey = `${normalizedSourceId}::${trimmed}`;
const forceRefresh = options?.forceRefresh === true;
const performSearch = useCallback(
async (
keyword: string,
selectedSourceId: string,
options?: { forceRefresh?: boolean }
) => {
const trimmed = keyword.trim();
if (!trimmed) return;
const normalizedSourceId = selectedSourceId || '';
const searchKey = `${normalizedSourceId}::${trimmed}`;
const forceRefresh = options?.forceRefresh === true;
closeEventSource();
clearPendingResults();
currentSearchKeyRef.current = searchKey;
setLoading(true);
setError('');
setHasSearched(true);
setTotalSources(0);
setCompletedSources(0);
closeEventSource();
clearPendingResults();
currentSearchKeyRef.current = searchKey;
setLoading(true);
setError('');
setHasSearched(true);
setTotalSources(0);
setCompletedSources(0);
const cached = forceRefresh ? null : getCachedResult(trimmed, normalizedSourceId);
if (cached) {
setResult(cached);
saveSearchState({ q: trimmed, sourceId: normalizedSourceId, result: cached });
setLoading(false);
setTotalSources(1);
setCompletedSources(1);
return;
}
const cached = forceRefresh
? null
: getCachedResult(trimmed, normalizedSourceId);
if (cached) {
setResult(cached);
saveSearchState({
q: trimmed,
sourceId: normalizedSourceId,
result: cached,
});
setLoading(false);
setTotalSources(1);
setCompletedSources(1);
return;
}
setResult(EMPTY_RESULT);
setResult(EMPTY_RESULT);
const currentFluidSearch = readFluidSearchSetting();
setUseFluidSearch((prev) => (prev === currentFluidSearch ? prev : currentFluidSearch));
const currentFluidSearch = readFluidSearchSetting();
setUseFluidSearch((prev) =>
prev === currentFluidSearch ? prev : currentFluidSearch
);
const params = new URLSearchParams({ q: trimmed });
if (normalizedSourceId) params.set('sourceId', normalizedSourceId);
const params = new URLSearchParams({ q: trimmed });
if (normalizedSourceId) params.set('sourceId', normalizedSourceId);
if (currentFluidSearch) {
const es = new EventSource(`/api/books/search/ws?${params.toString()}`);
eventSourceRef.current = es;
if (currentFluidSearch) {
const es = new EventSource(`/api/books/search/ws?${params.toString()}`);
eventSourceRef.current = es;
es.onmessage = (event) => {
if (!event.data || currentSearchKeyRef.current !== searchKey) return;
try {
const payload = JSON.parse(event.data);
switch (payload.type) {
case 'start':
setTotalSources(payload.totalSources || 0);
setCompletedSources(0);
break;
case 'source_result':
setCompletedSources((prev) => Math.max(prev + 1, payload.completedSources || 0));
if (Array.isArray(payload.results) && payload.results.length > 0) {
appendBufferedResults(payload.results as BookListItem[]);
}
break;
case 'source_error': {
setCompletedSources((prev) => Math.max(prev + 1, payload.completedSources || 0));
break;
}
case 'error':
setError(payload.error || '搜索失败');
setLoading(false);
closeEventSource();
break;
case 'complete': {
const finalFailedSources: BookSearchResult['failedSources'] = [];
setCompletedSources(payload.completedSources || payload.totalSources || 0);
if (pendingResultsRef.current.length > 0) {
const toAppend = pendingResultsRef.current;
pendingResultsRef.current = [];
if (flushTimerRef.current) {
window.clearTimeout(flushTimerRef.current);
flushTimerRef.current = null;
es.onmessage = (event) => {
if (!event.data || currentSearchKeyRef.current !== searchKey) return;
try {
const payload = JSON.parse(event.data);
switch (payload.type) {
case 'start':
setTotalSources(payload.totalSources || 0);
setCompletedSources(0);
break;
case 'source_result':
setCompletedSources((prev) =>
Math.max(prev + 1, payload.completedSources || 0)
);
if (
Array.isArray(payload.results) &&
payload.results.length > 0
) {
appendBufferedResults(payload.results as BookListItem[]);
}
startTransition(() => {
break;
case 'source_error':
setCompletedSources((prev) =>
Math.max(prev + 1, payload.completedSources || 0)
);
break;
case 'error':
setError(payload.error || '搜索失败');
setLoading(false);
closeEventSource();
break;
case 'complete': {
const finalFailedSources: BookSearchResult['failedSources'] =
[];
setCompletedSources(
payload.completedSources || payload.totalSources || 0
);
if (pendingResultsRef.current.length > 0) {
const toAppend = pendingResultsRef.current;
pendingResultsRef.current = [];
if (flushTimerRef.current) {
window.clearTimeout(flushTimerRef.current);
flushTimerRef.current = null;
}
startTransition(() => {
setResult((prev) => {
const nextResult = {
results: prev.results.concat(toAppend),
failedSources: finalFailedSources,
};
setCachedResult(trimmed, normalizedSourceId, nextResult);
saveSearchState({
q: trimmed,
sourceId: normalizedSourceId,
result: nextResult,
});
return nextResult;
});
});
} else {
setResult((prev) => {
const nextResult = { results: prev.results.concat(toAppend), failedSources: finalFailedSources };
const nextResult = {
results: prev.results,
failedSources: finalFailedSources,
};
setCachedResult(trimmed, normalizedSourceId, nextResult);
saveSearchState({ q: trimmed, sourceId: normalizedSourceId, result: nextResult });
saveSearchState({
q: trimmed,
sourceId: normalizedSourceId,
result: nextResult,
});
return nextResult;
});
});
} else {
setResult((prev) => {
const nextResult = { results: prev.results, failedSources: finalFailedSources };
setCachedResult(trimmed, normalizedSourceId, nextResult);
saveSearchState({ q: trimmed, sourceId: normalizedSourceId, result: nextResult });
return nextResult;
});
}
setLoading(false);
closeEventSource();
break;
}
setLoading(false);
closeEventSource();
break;
}
} catch {
// Ignore malformed streaming payloads.
}
} catch {}
};
};
es.onerror = () => {
if (currentSearchKeyRef.current !== searchKey) return;
if (pendingResultsRef.current.length > 0) {
const toAppend = pendingResultsRef.current;
pendingResultsRef.current = [];
if (flushTimerRef.current) {
window.clearTimeout(flushTimerRef.current);
flushTimerRef.current = null;
es.onerror = () => {
if (currentSearchKeyRef.current !== searchKey) return;
if (pendingResultsRef.current.length > 0) {
const toAppend = pendingResultsRef.current;
pendingResultsRef.current = [];
if (flushTimerRef.current) {
window.clearTimeout(flushTimerRef.current);
flushTimerRef.current = null;
}
startTransition(() => {
setResult((prev) => ({
...prev,
results: prev.results.concat(toAppend),
}));
});
}
startTransition(() => {
setResult((prev) => ({ ...prev, results: prev.results.concat(toAppend) }));
});
}
setLoading(false);
closeEventSource();
};
return;
}
try {
const res = await fetch(`/api/books/search?${params.toString()}`);
const json = await res.json();
if (currentSearchKeyRef.current !== searchKey) return;
if (!res.ok) throw new Error(json.error || '搜索失败');
const nextResult: BookSearchResult = { results: json.results || [], failedSources: [] };
setResult(nextResult);
setTotalSources(1);
setCompletedSources(1);
setCachedResult(trimmed, normalizedSourceId, nextResult);
saveSearchState({ q: trimmed, sourceId: normalizedSourceId, result: nextResult });
} catch (err) {
if (currentSearchKeyRef.current !== searchKey) return;
setError((err as Error).message || '搜索失败');
setResult(EMPTY_RESULT);
} finally {
if (currentSearchKeyRef.current === searchKey) {
setLoading(false);
setLoading(false);
closeEventSource();
};
return;
}
}
}, [appendBufferedResults, clearPendingResults, closeEventSource, getCachedResult, readFluidSearchSetting, saveSearchState, setCachedResult]);
try {
const res = await fetch(`/api/books/search?${params.toString()}`);
const json = await res.json();
if (currentSearchKeyRef.current !== searchKey) return;
if (!res.ok) throw new Error(json.error || '搜索失败');
const nextResult: BookSearchResult = {
results: json.results || [],
failedSources: [],
};
setResult(nextResult);
setTotalSources(1);
setCompletedSources(1);
setCachedResult(trimmed, normalizedSourceId, nextResult);
saveSearchState({
q: trimmed,
sourceId: normalizedSourceId,
result: nextResult,
});
} catch (err) {
if (currentSearchKeyRef.current !== searchKey) return;
setError((err as Error).message || '搜索失败');
setResult(EMPTY_RESULT);
} finally {
if (currentSearchKeyRef.current === searchKey) {
setLoading(false);
}
}
},
[
appendBufferedResults,
clearPendingResults,
closeEventSource,
getCachedResult,
readFluidSearchSetting,
saveSearchState,
setCachedResult,
]
);
useEffect(() => {
setUseFluidSearch(readFluidSearchSetting());
fetch('/api/books/sources').then((res) => res.json()).then((json) => setSources(json.sources || [])).catch(() => undefined);
fetch('/api/books/sources')
.then((res) => res.json())
.then((json) => setSources(json.sources || []))
.catch(() => undefined);
return () => {
closeEventSource();
clearPendingResults();
@@ -310,7 +439,14 @@ export default function BooksSearchPage() {
const forceRefresh = forceNextUrlSearchRef.current;
forceNextUrlSearchRef.current = false;
void performSearch(keyword, source, { forceRefresh });
}, [clearPendingResults, closeEventSource, performSearch, restoreSearchState, urlQuery, urlSourceId]);
}, [
clearPendingResults,
closeEventSource,
performSearch,
restoreSearchState,
urlQuery,
urlSourceId,
]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -328,32 +464,217 @@ export default function BooksSearchPage() {
}
};
const selectedSourceName = useMemo(() => {
if (!sourceId) return '全部书源';
return sources.find((source) => source.id === sourceId)?.name || '当前书源';
}, [sourceId, sources]);
const searchProgress =
totalSources > 0
? Math.min(100, Math.round((completedSources / totalSources) * 100))
: 0;
const submitSearch = useCallback(
(keyword: string) => {
const trimmed = keyword.trim();
if (!trimmed) return;
const params = new URLSearchParams();
params.set('q', trimmed);
if (sourceId) params.set('sourceId', sourceId);
forceNextUrlSearchRef.current = true;
router.replace(`/books/search?${params.toString()}`);
},
[router, sourceId]
);
return (
<div className='space-y-6'>
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
<form onSubmit={handleSubmit} className='space-y-3'>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder='搜索书名 / 作者' className='w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none dark:border-gray-700 dark:bg-gray-900' />
<select value={sourceId} onChange={(e) => setSourceId(e.target.value)} className='w-full rounded-2xl border border-gray-200 px-4 py-3 dark:border-gray-700 dark:bg-gray-900'>
<option value=''></option>
{sources.map((source) => <option key={source.id} value={source.id}>{source.name}</option>)}
</select>
<button className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'></button>
</form>
<div className='space-y-7'>
<section className='relative overflow-hidden rounded-[2.25rem] border border-emerald-100 bg-gradient-to-br from-emerald-50 via-white to-amber-50 p-5 shadow-sm shadow-emerald-950/5 dark:border-emerald-500/10 dark:from-emerald-950/30 dark:via-gray-950 dark:to-amber-950/20 sm:p-7'>
<div className='absolute -right-20 -top-24 h-64 w-64 rounded-full bg-emerald-300/25 blur-3xl dark:bg-emerald-500/10' />
<div className='absolute -bottom-28 left-1/4 h-64 w-64 rounded-full bg-amber-300/20 blur-3xl dark:bg-amber-500/10' />
<div className='relative'>
<div className='inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-white/75 px-3 py-1 text-xs font-semibold text-emerald-700 shadow-sm backdrop-blur dark:border-emerald-500/20 dark:bg-gray-950/50 dark:text-emerald-200'>
<Sparkles className='h-3.5 w-3.5' />
Search First · Reduce Friction
</div>
<div className='mt-5 grid gap-6 lg:grid-cols-[0.95fr_1.05fr] lg:items-end'>
<div>
<h1 className='text-4xl font-black tracking-[-0.06em] text-emerald-950 dark:text-emerald-50 sm:text-6xl lg:text-7xl'>
</h1>
<div className='mt-5 flex flex-wrap gap-2'>
{QUICK_SEARCHES.map((keyword) => (
<button
key={keyword}
type='button'
onClick={() => {
setQ(keyword);
submitSearch(keyword);
}}
className='inline-flex cursor-pointer items-center gap-1.5 rounded-full border border-emerald-200 bg-white/70 px-3 py-1.5 text-xs font-medium text-emerald-800 transition-colors duration-200 hover:bg-emerald-50 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:border-emerald-500/20 dark:bg-gray-950/50 dark:text-emerald-100 dark:hover:bg-emerald-500/10'
>
<Search className='h-3.5 w-3.5' />
{keyword}
</button>
))}
</div>
</div>
<form
onSubmit={handleSubmit}
className='rounded-[2rem] border border-white/80 bg-white/85 p-3 shadow-xl shadow-emerald-950/10 backdrop-blur dark:border-white/10 dark:bg-gray-950/70'
>
<div className='grid gap-3 lg:grid-cols-[1fr_13rem_auto]'>
<label className='relative block'>
<span className='mb-2 block px-1 text-xs font-semibold uppercase tracking-[0.18em] text-emerald-700 dark:text-emerald-200'>
</span>
<Search className='pointer-events-none absolute bottom-3.5 left-4 h-5 w-5 text-emerald-400' />
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder='搜索书名 / 作者'
className='h-12 w-full rounded-2xl border border-emerald-100 bg-white pl-11 pr-11 text-base font-medium text-slate-900 outline-none transition-colors duration-200 placeholder:text-slate-400 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-500/20 dark:border-emerald-500/10 dark:bg-gray-900 dark:text-white'
/>
{q ? (
<button
type='button'
onClick={() => setQ('')}
className='absolute bottom-2.5 right-2.5 inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-full text-slate-400 transition-colors duration-200 hover:bg-emerald-50 hover:text-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
aria-label='清空搜索关键词'
>
<X className='h-4 w-4' />
</button>
) : null}
</label>
<label className='block'>
<span className='mb-2 block px-1 text-xs font-semibold uppercase tracking-[0.18em] text-emerald-700 dark:text-emerald-200'>
</span>
<select
value={sourceId}
onChange={(e) => setSourceId(e.target.value)}
className='h-12 w-full cursor-pointer rounded-2xl border border-emerald-100 bg-white px-4 text-sm font-medium text-slate-900 outline-none transition-colors duration-200 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-500/20 dark:border-emerald-500/10 dark:bg-gray-900 dark:text-white'
>
<option value=''></option>
{sources.map((source) => (
<option key={source.id} value={source.id}>
{source.name}
</option>
))}
</select>
</label>
<div className='flex items-end'>
<button
type='submit'
disabled={loading}
className='inline-flex h-12 w-full cursor-pointer items-center justify-center gap-2 rounded-2xl bg-emerald-600 px-6 text-sm font-bold text-white shadow-lg shadow-emerald-600/20 transition-colors duration-200 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-70 dark:focus:ring-offset-gray-950 lg:w-auto'
>
{loading ? (
<Loader2 className='h-4 w-4 animate-spin' />
) : (
<Search className='h-4 w-4' />
)}
{loading ? '搜索中' : '搜索'}
</button>
</div>
</div>
</form>
</div>
</div>
</section>
<div className='flex items-center justify-between gap-3'>
<h2 className='text-lg font-semibold'>{result.results.length > 0 ? `${result.results.length}` : ''}</h2>
<section className='rounded-[2rem] border border-emerald-100/80 bg-white/75 p-4 shadow-sm shadow-emerald-950/5 backdrop-blur dark:border-emerald-500/10 dark:bg-gray-950/60 sm:p-5'>
<div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='min-w-0'>
<div className='flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-emerald-600 dark:text-emerald-300'>
<BookMarked className='h-4 w-4' />
Results
</div>
<h2 className='mt-1 text-2xl font-black tracking-tight text-slate-950 dark:text-white'>
{hasSearched
? `搜索结果${
result.results.length > 0
? `${result.results.length}`
: ''
}`
: '等待搜索'}
</h2>
<p className='mt-1 text-sm text-slate-500 dark:text-slate-400'>
{selectedSourceName}
</p>
</div>
<div className='flex flex-wrap items-center gap-2'>
<span className='inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-3 py-1.5 text-xs font-medium text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200'>
<Layers3 className='h-3.5 w-3.5' />
{sources.length || 0}
</span>
{loading && useFluidSearch && totalSources > 0 ? (
<span className='inline-flex items-center gap-1.5 rounded-full bg-amber-50 px-3 py-1.5 text-xs font-medium text-amber-700 dark:bg-amber-500/10 dark:text-amber-200'>
<Loader2 className='h-3.5 w-3.5 animate-spin' />
{completedSources}/{totalSources}
</span>
) : null}
</div>
</div>
{loading && useFluidSearch && totalSources > 0 ? (
<span className='text-xs text-gray-500 dark:text-gray-400'> {completedSources}/{totalSources}</span>
<div className='mt-4 h-2 overflow-hidden rounded-full bg-emerald-50 dark:bg-gray-900'>
<div
className='h-full rounded-full bg-emerald-600 transition-all duration-300'
style={{ width: `${searchProgress}%` }}
/>
</div>
) : null}
</div>
</section>
{loading && result.results.length === 0 ? <SearchSkeleton /> : null}
{error ? <div className='rounded-2xl bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/20 dark:text-red-300'>{error}</div> : null}
{error ? (
<div className='rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-500/20 dark:bg-red-950/20 dark:text-red-300'>
{error}
</div>
) : null}
<section className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
{result.results.map((item) => <BookCard key={`${item.sourceId}-${item.id}`} item={item} href={detailHref(item)} onNavigate={() => cacheBookListItem(item)} />)}
{result.results.map((item) => (
<BookCard
key={`${item.sourceId}-${item.id}`}
item={item}
href={detailHref(item)}
onNavigate={() => cacheBookListItem(item)}
/>
))}
</section>
{!loading && hasSearched && !error && result.results.length === 0 ? <div className='text-sm text-gray-500'></div> : null}
{!loading && hasSearched && !error && result.results.length === 0 ? (
<div className='rounded-[2rem] border border-dashed border-emerald-200 bg-white/75 p-8 text-center shadow-sm dark:border-emerald-500/20 dark:bg-gray-950/50'>
<div className='mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-200'>
<Search className='h-6 w-6' />
</div>
<h3 className='mt-4 text-lg font-bold text-slate-950 dark:text-white'>
</h3>
<p className='mx-auto mt-2 max-w-md text-sm leading-6 text-slate-500 dark:text-slate-400'>
</p>
<div className='mt-5 flex flex-wrap justify-center gap-2'>
{QUICK_SEARCHES.map((keyword) => (
<button
key={keyword}
type='button'
onClick={() => {
setQ(keyword);
submitSearch(keyword);
}}
className='cursor-pointer rounded-full border border-emerald-200 bg-white px-3 py-1.5 text-xs font-medium text-emerald-800 transition-colors duration-200 hover:bg-emerald-50 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:border-emerald-500/20 dark:bg-gray-950 dark:text-emerald-100 dark:hover:bg-emerald-500/10'
>
{keyword}
</button>
))}
</div>
</div>
) : null}
</div>
);
}