美化电子书馆

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
+358 -196
View File
@@ -1,12 +1,26 @@
'use client'; 'use client';
import { AlertCircle } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { PointerEvent as ReactPointerEvent, WheelEvent as ReactWheelEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import {
PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
WheelEvent as ReactWheelEvent,
} from 'react';
import { BookCatalogResult, BookListItem, BookSource } from '@/lib/book.types';
import {
buildBookDetailPath,
cacheBookListItem,
} from '@/lib/book-route-cache.client';
import BookCard from '@/components/books/BookCard'; import BookCard from '@/components/books/BookCard';
import { buildBookDetailPath, cacheBookListItem } from '@/lib/book-route-cache.client';
import { BookCatalogResult, BookListItem, BookSource } from '@/lib/book.types';
function makeHref(sourceId: string, item: BookListItem) { function makeHref(sourceId: string, item: BookListItem) {
return buildBookDetailPath(sourceId, item.id); return buildBookDetailPath(sourceId, item.id);
@@ -17,12 +31,18 @@ function CatalogSkeleton() {
<div className='space-y-6 animate-pulse'> <div className='space-y-6 animate-pulse'>
<div className='flex gap-2 overflow-x-auto pb-1'> <div className='flex gap-2 overflow-x-auto pb-1'>
{Array.from({ length: 4 }).map((_, index) => ( {Array.from({ length: 4 }).map((_, index) => (
<div key={index} className='h-10 w-24 rounded-full bg-gray-200 dark:bg-gray-800' /> <div
key={index}
className='h-10 w-24 rounded-full bg-gray-200 dark:bg-gray-800'
/>
))} ))}
</div> </div>
<div className='flex gap-2 overflow-x-auto pb-1'> <div className='flex gap-2 overflow-x-auto pb-1'>
{Array.from({ length: 5 }).map((_, index) => ( {Array.from({ length: 5 }).map((_, index) => (
<div key={index} className='h-10 w-28 shrink-0 rounded-full bg-gray-200 dark:bg-gray-800' /> <div
key={index}
className='h-10 w-28 shrink-0 rounded-full bg-gray-200 dark:bg-gray-800'
/>
))} ))}
</div> </div>
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'> <div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
@@ -66,7 +86,9 @@ export default function BooksCatalogPage() {
const [selectedSourceId, setSelectedSourceId] = useState(sourceId); const [selectedSourceId, setSelectedSourceId] = useState(sourceId);
const [selectedHref, setSelectedHref] = useState(href); const [selectedHref, setSelectedHref] = useState(href);
const [data, setData] = useState<BookCatalogResult | null>(null); const [data, setData] = useState<BookCatalogResult | null>(null);
const [catalogNavigation, setCatalogNavigation] = useState<BookCatalogResult['navigation']>([]); const [catalogNavigation, setCatalogNavigation] = useState<
BookCatalogResult['navigation']
>([]);
const [entries, setEntries] = useState<BookListItem[]>([]); const [entries, setEntries] = useState<BookListItem[]>([]);
const [nextHref, setNextHref] = useState<string | undefined>(undefined); const [nextHref, setNextHref] = useState<string | undefined>(undefined);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -79,9 +101,21 @@ export default function BooksCatalogPage() {
const activeNavItemRef = useRef<HTMLAnchorElement | null>(null); const activeNavItemRef = useRef<HTMLAnchorElement | null>(null);
const loadedPageHrefsRef = useRef<Set<string>>(new Set()); const loadedPageHrefsRef = useRef<Set<string>>(new Set());
const failedPageHrefsRef = useRef<Set<string>>(new Set()); const failedPageHrefsRef = useRef<Set<string>>(new Set());
const sourceDragStateRef = useRef<{ pointerId: number; startX: number; startScrollLeft: number; moved: boolean; pointerType: string } | null>(null); const sourceDragStateRef = useRef<{
pointerId: number;
startX: number;
startScrollLeft: number;
moved: boolean;
pointerType: string;
} | null>(null);
const suppressSourceClickRef = useRef(false); const suppressSourceClickRef = useRef(false);
const navDragStateRef = useRef<{ pointerId: number; startX: number; startScrollLeft: number; moved: boolean; pointerType: string } | null>(null); const navDragStateRef = useRef<{
pointerId: number;
startX: number;
startScrollLeft: number;
moved: boolean;
pointerType: string;
} | null>(null);
const suppressNavClickRef = useRef(false); const suppressNavClickRef = useRef(false);
const showImmediateContentLoading = useCallback(() => { const showImmediateContentLoading = useCallback(() => {
@@ -92,12 +126,13 @@ export default function BooksCatalogPage() {
}, []); }, []);
useEffect(() => { useEffect(() => {
fetch('/api/books/sources').then((res) => res.json()).then((json) => setSources(json.sources || [])); fetch('/api/books/sources')
.then((res) => res.json())
.then((json) => setSources(json.sources || []));
}, []); }, []);
useEffect(() => { useEffect(() => {
setSelectedSourceId(sourceId); setSelectedSourceId(sourceId);
setSelectedHref(href);
setCatalogNavigation([]); setCatalogNavigation([]);
}, [sourceId]); }, [sourceId]);
@@ -106,7 +141,7 @@ export default function BooksCatalogPage() {
}, [href]); }, [href]);
useEffect(() => { useEffect(() => {
if (!sourceId || !href) return; if (!sourceId || !href || catalogNavigation.length > 0) return;
let cancelled = false; let cancelled = false;
const loadRootNavigation = async () => { const loadRootNavigation = async () => {
@@ -115,7 +150,8 @@ export default function BooksCatalogPage() {
const res = await fetch(`/api/books/catalog?${params.toString()}`); const res = await fetch(`/api/books/catalog?${params.toString()}`);
const json = await res.json(); const json = await res.json();
if (!res.ok) return; if (!res.ok) return;
if (!cancelled) setCatalogNavigation((json as BookCatalogResult).navigation || []); if (!cancelled)
setCatalogNavigation((json as BookCatalogResult).navigation || []);
} catch { } catch {
// 当前分类内容仍可正常展示,根目录分类加载失败时忽略。 // 当前分类内容仍可正常展示,根目录分类加载失败时忽略。
} }
@@ -125,76 +161,113 @@ export default function BooksCatalogPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [sourceId, href]); }, [sourceId, href, catalogNavigation.length]);
useEffect(() => { useEffect(() => {
if (!sourceId || href || catalogNavigation.length === 0) return; if (!sourceId || href || catalogNavigation.length === 0) return;
const firstNavigationItem = catalogNavigation.find((item) => { const firstNavigationItem = catalogNavigation.find((item) => {
const rel = (item.rel || '').toLowerCase(); const rel = (item.rel || '').toLowerCase();
return item.href && rel !== 'next' && rel !== 'previous' && isMeaningfulNavTitle(item.title); return (
item.href &&
rel !== 'next' &&
rel !== 'previous' &&
isMeaningfulNavTitle(item.title)
);
}); });
if (!firstNavigationItem?.href) return; if (!firstNavigationItem?.href) return;
setSelectedHref(firstNavigationItem.href); setSelectedHref(firstNavigationItem.href);
router.replace(`/books/catalog?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(firstNavigationItem.href)}`); router.replace(
`/books/catalog?sourceId=${encodeURIComponent(
sourceId
)}&href=${encodeURIComponent(firstNavigationItem.href)}`
);
}, [catalogNavigation, href, router, sourceId]); }, [catalogNavigation, href, router, sourceId]);
const mergeEntries = useCallback((prev: BookListItem[], next: BookListItem[]) => { const mergeEntries = useCallback(
const seen = new Set(prev.map((item) => `${item.sourceId}::${item.id}::${item.detailHref || item.acquisitionLinks[0]?.href || ''}`)); (prev: BookListItem[], next: BookListItem[]) => {
const merged = [...prev]; const seen = new Set(
for (const item of next) { prev.map(
const key = `${item.sourceId}::${item.id}::${item.detailHref || item.acquisitionLinks[0]?.href || ''}`; (item) =>
if (!seen.has(key)) { `${item.sourceId}::${item.id}::${
seen.add(key); item.detailHref || item.acquisitionLinks[0]?.href || ''
merged.push(item); }`
)
);
const merged = [...prev];
for (const item of next) {
const key = `${item.sourceId}::${item.id}::${
item.detailHref || item.acquisitionLinks[0]?.href || ''
}`;
if (!seen.has(key)) {
seen.add(key);
merged.push(item);
}
} }
} return merged;
return merged; },
}, []); []
);
const loadCatalog = useCallback(async (targetHref?: string, append = false) => { const loadCatalog = useCallback(
if (!sourceId) return; async (targetHref?: string, append = false) => {
const normalizedHref = targetHref || ''; if (!sourceId) return;
if (append) { const normalizedHref = targetHref || '';
if (!normalizedHref || loadedPageHrefsRef.current.has(normalizedHref) || failedPageHrefsRef.current.has(normalizedHref)) return;
setLoadingMore(true);
} else {
setError('');
setLoadingCatalog(true);
if (!normalizedHref) setData(null);
setEntries([]);
setNextHref(undefined);
loadedPageHrefsRef.current = new Set(normalizedHref ? [normalizedHref] : ['__root__']);
failedPageHrefsRef.current = new Set();
}
try {
const params = new URLSearchParams({ sourceId });
if (normalizedHref) params.set('href', normalizedHref);
const res = await fetch(`/api/books/catalog?${params.toString()}`);
const json = await res.json();
if (!res.ok) throw new Error(json.error || '获取目录失败');
const nextData = json as BookCatalogResult;
if (append) { if (append) {
loadedPageHrefsRef.current.add(normalizedHref); if (
setEntries((prev) => mergeEntries(prev, nextData.entries || [])); !normalizedHref ||
loadedPageHrefsRef.current.has(normalizedHref) ||
failedPageHrefsRef.current.has(normalizedHref)
)
return;
setLoadingMore(true);
} else { } else {
setData(nextData); setError('');
setCatalogNavigation((prev) => normalizedHref ? (prev.length > 0 ? prev : nextData.navigation || []) : nextData.navigation || []); setLoadingCatalog(true);
setEntries(nextData.entries || []); if (!normalizedHref) setData(null);
} setEntries([]);
setNextHref(nextData.nextHref || undefined);
if (!append) setData(nextData);
} catch (err) {
if (append && normalizedHref) {
failedPageHrefsRef.current.add(normalizedHref);
setNextHref(undefined); setNextHref(undefined);
loadedPageHrefsRef.current = new Set(
normalizedHref ? [normalizedHref] : ['__root__']
);
failedPageHrefsRef.current = new Set();
} }
setError(err instanceof Error ? err.message : '获取目录失败');
} finally { try {
if (!append) setLoadingCatalog(false); const params = new URLSearchParams({ sourceId });
setLoadingMore(false); if (normalizedHref) params.set('href', normalizedHref);
} const res = await fetch(`/api/books/catalog?${params.toString()}`);
}, [mergeEntries, sourceId]); const json = await res.json();
if (!res.ok) throw new Error(json.error || '获取目录失败');
const nextData = json as BookCatalogResult;
if (append) {
loadedPageHrefsRef.current.add(normalizedHref);
setEntries((prev) => mergeEntries(prev, nextData.entries || []));
} else {
setData(nextData);
setCatalogNavigation((prev) =>
normalizedHref
? prev.length > 0
? prev
: nextData.navigation || []
: nextData.navigation || []
);
setEntries(nextData.entries || []);
}
setNextHref(nextData.nextHref || undefined);
if (!append) setData(nextData);
} catch (err) {
if (append && normalizedHref) {
failedPageHrefsRef.current.add(normalizedHref);
setNextHref(undefined);
}
setError(err instanceof Error ? err.message : '获取目录失败');
} finally {
if (!append) setLoadingCatalog(false);
setLoadingMore(false);
}
},
[mergeEntries, sourceId]
);
useEffect(() => { useEffect(() => {
if (!sourceId) return; if (!sourceId) return;
@@ -205,134 +278,175 @@ export default function BooksCatalogPage() {
const node = loaderRef.current; const node = loaderRef.current;
if (!node || !nextHref || loadingMore || !data) return; if (!node || !nextHref || loadingMore || !data) return;
const observer = new IntersectionObserver((entries) => { const observer = new IntersectionObserver(
const entry = entries[0]; (entries) => {
if (entry?.isIntersecting && nextHref && !loadingMore) { const entry = entries[0];
void loadCatalog(nextHref, true); if (entry?.isIntersecting && nextHref && !loadingMore) {
} void loadCatalog(nextHref, true);
}, { rootMargin: '800px 0px' }); }
},
{ rootMargin: '800px 0px' }
);
observer.observe(node); observer.observe(node);
return () => observer.disconnect(); return () => observer.disconnect();
}, [data, nextHref, loadingMore, loadCatalog]); }, [data, nextHref, loadingMore, loadCatalog]);
const handleSourcePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { const handleSourcePointerDown = useCallback(
if (event.pointerType === 'mouse' && event.button !== 0) return; (event: ReactPointerEvent<HTMLDivElement>) => {
const node = sourceScrollerRef.current; if (event.pointerType === 'mouse' && event.button !== 0) return;
if (!node) return; const node = sourceScrollerRef.current;
sourceDragStateRef.current = { if (!node) return;
pointerId: event.pointerId, sourceDragStateRef.current = {
startX: event.clientX, pointerId: event.pointerId,
startScrollLeft: node.scrollLeft, startX: event.clientX,
moved: false, startScrollLeft: node.scrollLeft,
pointerType: event.pointerType, moved: false,
}; pointerType: event.pointerType,
suppressSourceClickRef.current = false; };
if (event.pointerType !== 'mouse') { suppressSourceClickRef.current = false;
node.setPointerCapture?.(event.pointerId); if (event.pointerType !== 'mouse') {
} node.setPointerCapture?.(event.pointerId);
}, []); }
},
[]
);
const handleSourcePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { const handleSourcePointerMove = useCallback(
const node = sourceScrollerRef.current; (event: ReactPointerEvent<HTMLDivElement>) => {
const dragState = sourceDragStateRef.current; const node = sourceScrollerRef.current;
if (!node || !dragState || dragState.pointerId !== event.pointerId) return; const dragState = sourceDragStateRef.current;
const deltaX = event.clientX - dragState.startX; if (!node || !dragState || dragState.pointerId !== event.pointerId)
const moveThreshold = dragState.pointerType === 'mouse' ? 8 : 4; return;
if (Math.abs(deltaX) > moveThreshold) { const deltaX = event.clientX - dragState.startX;
dragState.moved = true; const moveThreshold = dragState.pointerType === 'mouse' ? 8 : 4;
suppressSourceClickRef.current = true; if (Math.abs(deltaX) > moveThreshold) {
} dragState.moved = true;
node.scrollLeft = dragState.startScrollLeft - deltaX; suppressSourceClickRef.current = true;
}, []); }
node.scrollLeft = dragState.startScrollLeft - deltaX;
},
[]
);
const handleSourcePointerUp = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { const handleSourcePointerUp = useCallback(
const node = sourceScrollerRef.current; (event: ReactPointerEvent<HTMLDivElement>) => {
const dragState = sourceDragStateRef.current; const node = sourceScrollerRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) return; const dragState = sourceDragStateRef.current;
if (dragState.moved) { if (!dragState || dragState.pointerId !== event.pointerId) return;
event.preventDefault(); if (dragState.moved) {
window.setTimeout(() => { event.preventDefault();
suppressSourceClickRef.current = false; window.setTimeout(() => {
}, 0); suppressSourceClickRef.current = false;
} }, 0);
sourceDragStateRef.current = null; }
if (dragState.pointerType !== 'mouse') { sourceDragStateRef.current = null;
node?.releasePointerCapture?.(event.pointerId); if (dragState.pointerType !== 'mouse') {
} node?.releasePointerCapture?.(event.pointerId);
}, []); }
},
[]
);
const handleSourcePointerLeave = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { const handleSourcePointerLeave = useCallback(
if (event.pointerType === 'mouse') return; (event: ReactPointerEvent<HTMLDivElement>) => {
handleSourcePointerUp(event); if (event.pointerType === 'mouse') return;
}, [handleSourcePointerUp]); handleSourcePointerUp(event);
},
[handleSourcePointerUp]
);
const handleSourceWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => { const handleSourceWheel = useCallback(
const node = sourceScrollerRef.current; (event: ReactWheelEvent<HTMLDivElement>) => {
if (!node) return; const node = sourceScrollerRef.current;
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; if (!node) return;
if (!delta) return; const delta =
node.scrollLeft += delta; Math.abs(event.deltaX) > Math.abs(event.deltaY)
}, []); ? event.deltaX
: event.deltaY;
if (!delta) return;
node.scrollLeft += delta;
},
[]
);
const handleNavPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { const handleNavPointerDown = useCallback(
if (event.pointerType === 'mouse' && event.button !== 0) return; (event: ReactPointerEvent<HTMLDivElement>) => {
const node = navScrollerRef.current; if (event.pointerType === 'mouse' && event.button !== 0) return;
if (!node) return; const node = navScrollerRef.current;
navDragStateRef.current = { if (!node) return;
pointerId: event.pointerId, navDragStateRef.current = {
startX: event.clientX, pointerId: event.pointerId,
startScrollLeft: node.scrollLeft, startX: event.clientX,
moved: false, startScrollLeft: node.scrollLeft,
pointerType: event.pointerType, moved: false,
}; pointerType: event.pointerType,
suppressNavClickRef.current = false; };
if (event.pointerType !== 'mouse') { suppressNavClickRef.current = false;
node.setPointerCapture?.(event.pointerId); if (event.pointerType !== 'mouse') {
} node.setPointerCapture?.(event.pointerId);
}, []); }
},
[]
);
const handleNavPointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { const handleNavPointerMove = useCallback(
const node = navScrollerRef.current; (event: ReactPointerEvent<HTMLDivElement>) => {
const dragState = navDragStateRef.current; const node = navScrollerRef.current;
if (!node || !dragState || dragState.pointerId !== event.pointerId) return; const dragState = navDragStateRef.current;
const deltaX = event.clientX - dragState.startX; if (!node || !dragState || dragState.pointerId !== event.pointerId)
const moveThreshold = dragState.pointerType === 'mouse' ? 8 : 4; return;
if (Math.abs(deltaX) > moveThreshold) { const deltaX = event.clientX - dragState.startX;
dragState.moved = true; const moveThreshold = dragState.pointerType === 'mouse' ? 8 : 4;
suppressNavClickRef.current = true; if (Math.abs(deltaX) > moveThreshold) {
} dragState.moved = true;
node.scrollLeft = dragState.startScrollLeft - deltaX; suppressNavClickRef.current = true;
}, []); }
node.scrollLeft = dragState.startScrollLeft - deltaX;
},
[]
);
const handleNavPointerUp = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { const handleNavPointerUp = useCallback(
const node = navScrollerRef.current; (event: ReactPointerEvent<HTMLDivElement>) => {
const dragState = navDragStateRef.current; const node = navScrollerRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) return; const dragState = navDragStateRef.current;
if (dragState.moved) { if (!dragState || dragState.pointerId !== event.pointerId) return;
event.preventDefault(); if (dragState.moved) {
window.setTimeout(() => { event.preventDefault();
suppressNavClickRef.current = false; window.setTimeout(() => {
}, 0); suppressNavClickRef.current = false;
} }, 0);
navDragStateRef.current = null; }
if (dragState.pointerType !== 'mouse') { navDragStateRef.current = null;
node?.releasePointerCapture?.(event.pointerId); if (dragState.pointerType !== 'mouse') {
} node?.releasePointerCapture?.(event.pointerId);
}, []); }
},
[]
);
const handleNavPointerLeave = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { const handleNavPointerLeave = useCallback(
if (event.pointerType === 'mouse') return; (event: ReactPointerEvent<HTMLDivElement>) => {
handleNavPointerUp(event); if (event.pointerType === 'mouse') return;
}, [handleNavPointerUp]); handleNavPointerUp(event);
},
[handleNavPointerUp]
);
const handleNavWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => { const handleNavWheel = useCallback(
const node = navScrollerRef.current; (event: ReactWheelEvent<HTMLDivElement>) => {
if (!node) return; const node = navScrollerRef.current;
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; if (!node) return;
if (!delta) return; const delta =
node.scrollLeft += delta; Math.abs(event.deltaX) > Math.abs(event.deltaY)
}, []); ? event.deltaX
: event.deltaY;
if (!delta) return;
node.scrollLeft += delta;
},
[]
);
const navigationItems = useMemo(() => { const navigationItems = useMemo(() => {
const items = (catalogNavigation || []).filter((item) => { const items = (catalogNavigation || []).filter((item) => {
@@ -360,7 +474,11 @@ export default function BooksCatalogPage() {
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
const activeRect = activeItem.getBoundingClientRect(); const activeRect = activeItem.getBoundingClientRect();
const targetLeft = container.scrollLeft + activeRect.left - containerRect.left - (container.clientWidth - activeItem.clientWidth) / 2; const targetLeft =
container.scrollLeft +
activeRect.left -
containerRect.left -
(container.clientWidth - activeItem.clientWidth) / 2;
container.scrollTo({ left: Math.max(0, targetLeft), behavior: 'smooth' }); container.scrollTo({ left: Math.max(0, targetLeft), behavior: 'smooth' });
}); });
@@ -377,7 +495,11 @@ export default function BooksCatalogPage() {
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
const activeRect = activeItem.getBoundingClientRect(); const activeRect = activeItem.getBoundingClientRect();
const targetLeft = container.scrollLeft + activeRect.left - containerRect.left - (container.clientWidth - activeItem.clientWidth) / 2; const targetLeft =
container.scrollLeft +
activeRect.left -
containerRect.left -
(container.clientWidth - activeItem.clientWidth) / 2;
container.scrollTo({ left: Math.max(0, targetLeft), behavior: 'smooth' }); container.scrollTo({ left: Math.max(0, targetLeft), behavior: 'smooth' });
}); });
@@ -385,10 +507,10 @@ export default function BooksCatalogPage() {
}, [selectedSourceId, sources.length]); }, [selectedSourceId, sources.length]);
return ( return (
<div className='space-y-6'> <div className='space-y-4'>
<div <div
ref={sourceScrollerRef} ref={sourceScrollerRef}
className='flex flex-nowrap gap-2 overflow-x-auto pb-1 cursor-grab select-none touch-pan-x active:cursor-grabbing' className='flex flex-nowrap gap-2 overflow-x-auto px-1 pb-1.5 pt-2 cursor-grab select-none touch-pan-x active:cursor-grabbing'
onPointerDown={handleSourcePointerDown} onPointerDown={handleSourcePointerDown}
onPointerMove={handleSourcePointerMove} onPointerMove={handleSourcePointerMove}
onPointerUp={handleSourcePointerUp} onPointerUp={handleSourcePointerUp}
@@ -399,7 +521,9 @@ export default function BooksCatalogPage() {
{sources.map((source) => ( {sources.map((source) => (
<Link <Link
key={source.id} key={source.id}
ref={source.id === selectedSourceId ? activeSourceItemRef : undefined} ref={
source.id === selectedSourceId ? activeSourceItemRef : undefined
}
href={`/books/catalog?sourceId=${encodeURIComponent(source.id)}`} href={`/books/catalog?sourceId=${encodeURIComponent(source.id)}`}
draggable={false} draggable={false}
onDragStart={(event) => event.preventDefault()} onDragStart={(event) => event.preventDefault()}
@@ -413,19 +537,22 @@ export default function BooksCatalogPage() {
setSelectedHref(''); setSelectedHref('');
showImmediateContentLoading(); showImmediateContentLoading();
}} }}
className={`shrink-0 whitespace-nowrap rounded-full px-4 py-2 text-sm ${source.id === selectedSourceId ? 'bg-sky-600 text-white' : 'border border-gray-200 dark:border-gray-700'}`} className={`shrink-0 cursor-pointer whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-emerald-500 ${
source.id === selectedSourceId
? 'bg-emerald-600 text-white shadow-sm shadow-emerald-600/20'
: 'border border-emerald-100 bg-white/70 text-slate-600 hover:bg-emerald-50 hover:text-emerald-700 dark:border-emerald-500/10 dark:bg-gray-950/50 dark:text-gray-300 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
}`}
> >
{source.name} {source.name}
</Link> </Link>
))} ))}
</div> </div>
{error ? <div className='text-sm text-red-500'>{error}</div> : null} {data || navigationItems.length > 0 || error ? (
{data || navigationItems.length > 0 ? (
<> <>
{navigationItems.length > 0 ? ( {navigationItems.length > 0 ? (
<div <div
ref={navScrollerRef} ref={navScrollerRef}
className='flex flex-nowrap gap-2 overflow-x-auto pb-1 cursor-grab select-none touch-pan-x active:cursor-grabbing' className='flex flex-nowrap gap-2 overflow-x-auto px-1 pb-1.5 pt-2 cursor-grab select-none touch-pan-x active:cursor-grabbing'
onPointerDown={handleNavPointerDown} onPointerDown={handleNavPointerDown}
onPointerMove={handleNavPointerMove} onPointerMove={handleNavPointerMove}
onPointerUp={handleNavPointerUp} onPointerUp={handleNavPointerUp}
@@ -436,8 +563,12 @@ export default function BooksCatalogPage() {
{navigationItems.map((item, index) => ( {navigationItems.map((item, index) => (
<Link <Link
key={`${item.href}-${index}`} key={`${item.href}-${index}`}
ref={item.href === selectedHref ? activeNavItemRef : undefined} ref={
href={`/books/catalog?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(item.href)}`} item.href === selectedHref ? activeNavItemRef : undefined
}
href={`/books/catalog?sourceId=${encodeURIComponent(
sourceId
)}&href=${encodeURIComponent(item.href)}`}
draggable={false} draggable={false}
onDragStart={(event) => event.preventDefault()} onDragStart={(event) => event.preventDefault()}
onClick={(event) => { onClick={(event) => {
@@ -449,24 +580,55 @@ export default function BooksCatalogPage() {
setSelectedHref(item.href); setSelectedHref(item.href);
showImmediateContentLoading(); showImmediateContentLoading();
}} }}
className={`shrink-0 whitespace-nowrap rounded-full px-4 py-2 text-sm ${item.href === selectedHref ? 'bg-sky-600 text-white' : 'border border-gray-200 dark:border-gray-700'}`} className={`shrink-0 cursor-pointer whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-emerald-500 ${
item.href === selectedHref
? 'bg-emerald-600 text-white shadow-sm shadow-emerald-600/20'
: 'border border-emerald-100 bg-white/70 text-slate-600 hover:bg-emerald-50 hover:text-emerald-700 dark:border-emerald-500/10 dark:bg-gray-950/50 dark:text-gray-300 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
}`}
> >
{item.title.trim()} {item.title.trim()}
</Link> </Link>
))} ))}
</div> </div>
) : null} ) : null}
{loadingCatalog ? ( {error ? (
<div className='flex min-h-[45vh] items-center justify-center px-4'>
<div className='w-full max-w-md rounded-[2rem] border border-red-200 bg-white/85 p-6 text-center shadow-xl shadow-red-950/10 backdrop-blur dark:border-red-500/20 dark:bg-gray-950/75'>
<div className='mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-red-50 text-red-600 dark:bg-red-500/10 dark:text-red-300'>
<AlertCircle className='h-6 w-6' />
</div>
<h2 className='mt-4 text-lg font-bold text-slate-950 dark:text-white'>
</h2>
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400'>
{error}
</p>
</div>
</div>
) : loadingCatalog ? (
<LoadingMoreSkeleton /> <LoadingMoreSkeleton />
) : ( ) : (
<section className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'> <section className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
{entries.map((item) => <BookCard key={`${item.sourceId}-${item.id}-${item.detailHref || item.acquisitionLinks[0]?.href || ''}`} item={item} href={makeHref(sourceId, item)} onNavigate={() => cacheBookListItem(item)} />)} {entries.map((item) => (
<BookCard
key={`${item.sourceId}-${item.id}-${
item.detailHref || item.acquisitionLinks[0]?.href || ''
}`}
item={item}
href={makeHref(sourceId, item)}
onNavigate={() => cacheBookListItem(item)}
/>
))}
</section> </section>
)} )}
{loadingMore ? <LoadingMoreSkeleton /> : null} {loadingMore ? <LoadingMoreSkeleton /> : null}
{!loadingMore && nextHref ? <div ref={loaderRef} className='h-8 w-full' /> : null} {!loadingMore && nextHref ? (
<div ref={loaderRef} className='h-8 w-full' />
) : null}
</> </>
) : !error ? <CatalogSkeleton /> : null} ) : !error ? (
<CatalogSkeleton />
) : null}
</div> </div>
); );
} }
+249 -74
View File
@@ -1,25 +1,34 @@
'use client'; 'use client';
import { BookmarkPlus, BookOpen, Download, FileText, Tags } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { buildBookReadPath, cacheBookDetail, getBookRouteCache } from '@/lib/book-route-cache.client'; import {
import { deleteBookShelf, getAllBookShelf, saveBookShelf } from '@/lib/book.db.client'; deleteBookShelf,
getAllBookShelf,
saveBookShelf,
} from '@/lib/book.db.client';
import { BookChapter, BookDetail, BookShelfItem } from '@/lib/book.types'; import { BookChapter, BookDetail, BookShelfItem } from '@/lib/book.types';
import {
buildBookReadPath,
cacheBookDetail,
getBookRouteCache,
} from '@/lib/book-route-cache.client';
function DetailSkeleton() { function DetailSkeleton() {
return ( return (
<div className='space-y-6 animate-pulse'> <div className='space-y-6 animate-pulse'>
<section className='grid gap-6 rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950 md:grid-cols-[220px_1fr]'> <section className='grid gap-6 rounded-[2rem] border border-emerald-100/80 bg-white/85 p-5 shadow-sm shadow-emerald-950/5 dark:border-emerald-500/10 dark:bg-gray-950/70 md:grid-cols-[220px_1fr]'>
<div className='aspect-[3/4] rounded-3xl bg-gray-200 dark:bg-gray-800' /> <div className='aspect-[3/4] rounded-3xl bg-emerald-100 dark:bg-gray-800' />
<div className='space-y-4'> <div className='space-y-4'>
<div className='h-8 w-2/3 rounded bg-gray-200 dark:bg-gray-800' /> <div className='h-8 w-2/3 rounded bg-emerald-100 dark:bg-gray-800' />
<div className='h-4 w-1/3 rounded bg-gray-200 dark:bg-gray-800' /> <div className='h-4 w-1/3 rounded bg-emerald-100 dark:bg-gray-800' />
<div className='space-y-2'> <div className='space-y-2'>
<div className='h-4 w-full rounded bg-gray-200 dark:bg-gray-800' /> <div className='h-4 w-full rounded bg-emerald-100 dark:bg-gray-800' />
<div className='h-4 w-11/12 rounded bg-gray-200 dark:bg-gray-800' /> <div className='h-4 w-11/12 rounded bg-emerald-100 dark:bg-gray-800' />
<div className='h-4 w-10/12 rounded bg-gray-200 dark:bg-gray-800' /> <div className='h-4 w-10/12 rounded bg-emerald-100 dark:bg-gray-800' />
</div> </div>
<div className='flex gap-3'> <div className='flex gap-3'>
<div className='h-10 w-24 rounded-2xl bg-gray-200 dark:bg-gray-800' /> <div className='h-10 w-24 rounded-2xl bg-gray-200 dark:bg-gray-800' />
@@ -37,35 +46,54 @@ function parseDownloadFilename(disposition: string | null) {
if (utf8Match?.[1]) { if (utf8Match?.[1]) {
try { try {
return decodeURIComponent(utf8Match[1]); return decodeURIComponent(utf8Match[1]);
} catch {} } catch {
return '';
}
} }
const plainMatch = disposition.match(/filename="?([^";]+)"?/i); const plainMatch = disposition.match(/filename="?([^";]+)"?/i);
return plainMatch?.[1] || ''; return plainMatch?.[1] || '';
} }
function sanitizeFilename(name: string) { function sanitizeFilename(name: string) {
return name.replace(/[\/:*?"<>|]/g, '_').trim(); return name.replace(/[/:*?"<>|]/g, '_').trim();
} }
async function openBookFile(sourceId: string, bookId: string, format?: 'epub' | 'pdf' | 'chapters', download = false, href?: string, title?: string) { async function openBookFile(
sourceId: string,
bookId: string,
format?: 'epub' | 'pdf' | 'chapters',
download = false,
href?: string,
title?: string
) {
const response = await fetch('/api/books/file', { const response = await fetch('/api/books/file', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sourceId, bookId, format: format || null, href: href || undefined }), body: JSON.stringify({
sourceId,
bookId,
format: format || null,
href: href || undefined,
}),
}); });
if (!response.ok) { if (!response.ok) {
let message = '打开文件失败'; let message = '打开文件失败';
try { try {
const json = await response.json(); const json = await response.json();
message = json.error || message; message = json.error || message;
} catch {} } catch {
// Keep fallback error message.
}
throw new Error(message); throw new Error(message);
} }
const blob = await response.blob(); const blob = await response.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
if (download) { if (download) {
const headerFilename = parseDownloadFilename(response.headers.get('content-disposition')); const headerFilename = parseDownloadFilename(
const fallbackBaseName = sanitizeFilename(title || bookId || 'book') || 'book'; response.headers.get('content-disposition')
);
const fallbackBaseName =
sanitizeFilename(title || bookId || 'book') || 'book';
const extension = format === 'pdf' ? 'pdf' : 'epub'; const extension = format === 'pdf' ? 'pdf' : 'epub';
const finalFilename = headerFilename || `${fallbackBaseName}.${extension}`; const finalFilename = headerFilename || `${fallbackBaseName}.${extension}`;
const link = document.createElement('a'); const link = document.createElement('a');
@@ -92,12 +120,17 @@ export default function BookDetailPage() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [fileBusy, setFileBusy] = useState<'open' | 'download' | ''>(''); const [fileBusy, setFileBusy] = useState<'open' | 'download' | ''>('');
const cached = useMemo(() => (sourceId && bookId ? getBookRouteCache(sourceId, bookId) : null), [sourceId, bookId]); const cached = useMemo(
() => (sourceId && bookId ? getBookRouteCache(sourceId, bookId) : null),
[sourceId, bookId]
);
useEffect(() => { useEffect(() => {
getAllBookShelf().then((items) => { getAllBookShelf()
setShelf(items); .then((items) => {
}).catch(() => undefined); setShelf(items);
})
.catch(() => undefined);
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -127,13 +160,19 @@ export default function BookDetailPage() {
const readable = detail?.acquisitionLinks.find((item) => { const readable = detail?.acquisitionLinks.find((item) => {
const type = item.type.toLowerCase(); const type = item.type.toLowerCase();
return type.includes('epub') || type.includes('pdf') || type.includes('legado-chapters') || item.rel === 'legado:chapters'; return (
type.includes('epub') ||
type.includes('pdf') ||
type.includes('legado-chapters') ||
item.rel === 'legado:chapters'
);
}); });
const readableFormat = readable?.type.toLowerCase().includes('pdf') const readableFormat = readable?.type.toLowerCase().includes('pdf')
? 'pdf' ? 'pdf'
: readable?.type.toLowerCase().includes('legado-chapters') || readable?.rel === 'legado:chapters' : readable?.type.toLowerCase().includes('legado-chapters') ||
? 'chapters' readable?.rel === 'legado:chapters'
: 'epub'; ? 'chapters'
: 'epub';
useEffect(() => { useEffect(() => {
if (!detail || !readable || readableFormat !== 'chapters') { if (!detail || !readable || readableFormat !== 'chapters') {
@@ -150,7 +189,9 @@ export default function BookDetailPage() {
sourceId: detail.sourceId, sourceId: detail.sourceId,
bookId: detail.id, bookId: detail.id,
}); });
fetch(`/api/books/read/chapters?${params.toString()}`, { cache: 'no-store' }) fetch(`/api/books/read/chapters?${params.toString()}`, {
cache: 'no-store',
})
.then(async (res) => { .then(async (res) => {
const json = await res.json(); const json = await res.json();
if (!res.ok) throw new Error(json.error || '获取章节失败'); if (!res.ok) throw new Error(json.error || '获取章节失败');
@@ -199,74 +240,200 @@ export default function BookDetailPage() {
cacheBookDetail(detail); cacheBookDetail(detail);
}; };
if (error) return <div className='text-sm text-red-500'>{error}</div>; if (error)
return (
<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>
);
if (!detail) return <DetailSkeleton />; if (!detail) return <DetailSkeleton />;
return ( return (
<div className='space-y-6'> <div className='space-y-6'>
<section className='grid gap-6 rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950 md:grid-cols-[220px_1fr]'> <section className='relative overflow-hidden rounded-[2.25rem] border border-emerald-100 bg-gradient-to-br from-emerald-50 via-white to-lime-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-lime-950/20'>
<div className='overflow-hidden rounded-3xl bg-gray-100 dark:bg-gray-900'> <div className='absolute -right-20 -top-24 h-64 w-64 rounded-full bg-emerald-300/20 blur-3xl dark:bg-emerald-500/10' />
{detail.cover ? <img src={detail.cover} alt={detail.title} className='h-full w-full object-cover' /> : <div className='flex aspect-[3/4] items-center justify-center text-sm text-gray-400'></div>} <div className='relative grid gap-6 md:grid-cols-[220px_1fr]'>
</div> <div className='overflow-hidden rounded-[2rem] bg-gradient-to-br from-emerald-50 to-lime-50 shadow-xl shadow-emerald-950/10 ring-1 ring-emerald-100 dark:from-gray-900 dark:to-emerald-950/20 dark:ring-emerald-500/10'>
<div className='space-y-4'> {detail.cover ? (
<div> // eslint-disable-next-line @next/next/no-img-element
<h1 className='text-2xl font-semibold'>{detail.title}</h1> <img
<div className='mt-2 text-sm text-gray-500 dark:text-gray-400'>{detail.author || '未知作者'}</div> src={detail.cover}
<div className='mt-1 text-xs text-gray-400 dark:text-gray-500'>{detail.sourceName}</div> alt={detail.title}
className='h-full w-full object-cover'
/>
) : (
<div className='flex aspect-[3/4] flex-col items-center justify-center gap-2 text-sm text-emerald-500 dark:text-emerald-300'>
<BookOpen className='h-9 w-9' />
</div>
)}
</div> </div>
{detail.summary ? <div className='text-sm leading-7 text-gray-700 dark:text-gray-300'>{detail.summary}</div> : null} <div className='flex min-w-0 flex-col justify-between gap-5'>
<div className='flex flex-wrap gap-2'> <div>
{(detail.categories || detail.tags || []).map((tag) => <span key={tag} className='rounded-full bg-gray-100 px-3 py-1 text-xs dark:bg-gray-900'>{tag}</span>)} <div className='inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-white/70 px-3 py-1 text-xs font-semibold text-emerald-700 shadow-sm dark:border-emerald-500/20 dark:bg-gray-950/50 dark:text-emerald-200'>
</div> <BookOpen className='h-3.5 w-3.5' />
<div className='flex flex-wrap gap-3'> {detail.sourceName}
{readable ? <Link href={buildBookReadPath(detail.sourceId, detail.id)} onClick={() => cacheBookDetail(detail)} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'>线</Link> : null} </div>
<button onClick={toggleShelf} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>{shelf[`${detail.sourceId}+${detail.id}`] ? '移出书架' : '加入书架'}</button> <h1 className='mt-4 text-3xl font-black tracking-tight text-emerald-950 dark:text-emerald-50 sm:text-4xl'>
{readable && readableFormat !== 'chapters' ? <button onClick={async () => { try { setFileBusy('download'); await openBookFile(detail.sourceId, detail.id, readableFormat, true, readable?.href, detail.title); } catch (err) { setError((err as Error).message || '下载文件失败'); } finally { setFileBusy(''); } }} disabled={fileBusy !== ''} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>{fileBusy === 'download' ? '下载中...' : '下载文件'}</button> : null} {detail.title}
</h1>
<div className='mt-2 text-sm font-medium text-slate-500 dark:text-slate-400'>
{detail.author || '未知作者'}
</div>
{detail.summary ? (
<div className='mt-4 line-clamp-5 text-sm leading-7 text-slate-600 dark:text-slate-300'>
{detail.summary}
</div>
) : null}
<div className='mt-4 flex flex-wrap gap-2'>
{(detail.categories || detail.tags || []).map((tag) => (
<span
key={tag}
className='inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700 ring-1 ring-emerald-100 dark:bg-emerald-500/10 dark:text-emerald-200 dark:ring-emerald-500/20'
>
<Tags className='h-3 w-3' />
{tag}
</span>
))}
</div>
</div>
<div className='flex flex-wrap gap-3'>
{readable ? (
<Link
href={buildBookReadPath(detail.sourceId, detail.id)}
onClick={() => cacheBookDetail(detail)}
className='inline-flex cursor-pointer items-center gap-2 rounded-2xl bg-emerald-600 px-5 py-2.5 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 dark:focus:ring-offset-gray-950'
>
<BookOpen className='h-4 w-4' />
线
</Link>
) : null}
<button
type='button'
onClick={toggleShelf}
className='inline-flex cursor-pointer items-center gap-2 rounded-2xl border border-emerald-200 bg-white/70 px-5 py-2.5 text-sm font-semibold 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'
>
<BookmarkPlus className='h-4 w-4' />
{shelf[`${detail.sourceId}+${detail.id}`]
? '移出书架'
: '加入书架'}
</button>
{readable && readableFormat !== 'chapters' ? (
<button
type='button'
onClick={async () => {
try {
setFileBusy('download');
await openBookFile(
detail.sourceId,
detail.id,
readableFormat,
true,
readable?.href,
detail.title
);
} catch (err) {
setError((err as Error).message || '下载文件失败');
} finally {
setFileBusy('');
}
}}
disabled={fileBusy !== ''}
className='inline-flex cursor-pointer items-center gap-2 rounded-2xl border border-emerald-200 bg-white/70 px-5 py-2.5 text-sm font-semibold text-emerald-800 transition-colors duration-200 hover:bg-emerald-50 focus:outline-none focus:ring-2 focus:ring-emerald-500 disabled:cursor-not-allowed disabled:opacity-60 dark:border-emerald-500/20 dark:bg-gray-950/50 dark:text-emerald-100 dark:hover:bg-emerald-500/10'
>
<Download className='h-4 w-4' />
{fileBusy === 'download' ? '下载中...' : '下载文件'}
</button>
) : null}
</div>
</div> </div>
</div> </div>
</section> </section>
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
<h2 className='text-lg font-semibold'></h2> <section className='rounded-[2rem] border border-emerald-100/80 bg-white/85 p-5 shadow-sm shadow-emerald-950/5 dark:border-emerald-500/10 dark:bg-gray-950/70'>
<div className='flex items-center gap-2'>
<FileText className='h-5 w-5 text-emerald-600 dark:text-emerald-300' />
<h2 className='text-lg font-bold text-slate-950 dark:text-white'>
</h2>
</div>
<div className='mt-4 space-y-3'> <div className='mt-4 space-y-3'>
{detail.acquisitionLinks.map((item) => { {detail.acquisitionLinks.map((item) => {
const type = item.type.toLowerCase(); const type = item.type.toLowerCase();
const format = type.includes('pdf') ? 'pdf' : type.includes('epub') ? 'epub' : type.includes('legado-chapters') || item.rel === 'legado:chapters' ? 'chapters' : undefined; const format = type.includes('pdf')
? 'pdf'
: type.includes('epub')
? 'epub'
: type.includes('legado-chapters') ||
item.rel === 'legado:chapters'
? 'chapters'
: undefined;
return ( return (
<div key={`${item.href}-${item.type}`} className='flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3 text-sm dark:bg-gray-900'> <div
<div> key={`${item.href}-${item.type}`}
<div>{item.title || item.type}</div> className='flex items-center justify-between gap-4 rounded-2xl bg-emerald-50/70 px-4 py-3 text-sm ring-1 ring-emerald-100 dark:bg-emerald-500/5 dark:ring-emerald-500/10'
<div className='text-xs text-gray-500'>{item.rel}</div> >
<div className='min-w-0'>
<div className='truncate font-medium text-slate-900 dark:text-white'>
{item.title || item.type}
</div>
<div className='mt-1 truncate text-xs text-slate-500 dark:text-slate-400'>
{item.rel}
</div>
</div> </div>
<button disabled={!format || fileBusy !== ''} onClick={async () => { <button
if (!format) return; type='button'
if (format === 'epub' || format === 'chapters') { disabled={!format || fileBusy !== ''}
cacheBookDetail(detail); onClick={async () => {
window.location.href = buildBookReadPath(detail.sourceId, detail.id); if (!format) return;
return; if (format === 'epub' || format === 'chapters') {
} cacheBookDetail(detail);
try { window.location.href = buildBookReadPath(
setFileBusy('open'); detail.sourceId,
await openBookFile(detail.sourceId, detail.id, format, false, item.href); detail.id
} catch (err) { );
setError((err as Error).message || '打开文件失败'); return;
} finally { }
setFileBusy(''); try {
} setFileBusy('open');
}} className='text-sky-600 disabled:text-gray-400'></button> await openBookFile(
detail.sourceId,
detail.id,
format,
false,
item.href
);
} catch (err) {
setError((err as Error).message || '打开文件失败');
} finally {
setFileBusy('');
}
}}
className='cursor-pointer rounded-full px-3 py-1.5 text-xs font-semibold text-emerald-700 transition-colors duration-200 hover:bg-white disabled:cursor-not-allowed disabled:text-gray-400 dark:text-emerald-200 dark:hover:bg-emerald-500/10'
>
</button>
</div> </div>
); );
})} })}
</div> </div>
</section> </section>
{readableFormat === 'chapters' ? ( {readableFormat === 'chapters' ? (
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'> <section className='rounded-[2rem] border border-emerald-100/80 bg-white/85 p-5 shadow-sm shadow-emerald-950/5 dark:border-emerald-500/10 dark:bg-gray-950/70'>
<div className='flex items-center justify-between gap-3'> <div className='flex items-center justify-between gap-3'>
<h2 className='text-lg font-semibold'></h2> <h2 className='text-lg font-bold text-slate-950 dark:text-white'>
<div className='text-sm text-gray-500'>{chaptersLoading ? '加载中...' : `${chapters.length}`}</div>
</h2>
<div className='rounded-full bg-emerald-50 px-3 py-1 text-sm font-medium text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200'>
{chaptersLoading ? '加载中...' : `${chapters.length}`}
</div>
</div> </div>
{chaptersError ? <div className='mt-4 text-sm text-red-500'>{chaptersError}</div> : null} {chaptersError ? (
<div className='mt-4 text-sm text-red-500'>{chaptersError}</div>
) : null}
{!chaptersLoading && !chaptersError && chapters.length === 0 ? ( {!chaptersLoading && !chaptersError && chapters.length === 0 ? (
<div className='mt-4 rounded-2xl bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:bg-amber-900/20 dark:text-amber-200'> <div className='mt-4 rounded-2xl bg-lime-50 px-4 py-3 text-sm text-lime-800 dark:bg-lime-900/20 dark:text-lime-200'>
EPUB EPUB
</div> </div>
) : null} ) : null}
@@ -275,9 +442,13 @@ export default function BookDetailPage() {
{chapters.slice(0, 60).map((chapter) => ( {chapters.slice(0, 60).map((chapter) => (
<Link <Link
key={`${chapter.href}-${chapter.order}`} key={`${chapter.href}-${chapter.order}`}
href={buildBookReadPath(detail.sourceId, detail.id, chapter.href)} href={buildBookReadPath(
detail.sourceId,
detail.id,
chapter.href
)}
onClick={() => cacheBookDetail(detail)} onClick={() => cacheBookDetail(detail)}
className='truncate rounded-2xl bg-gray-50 px-4 py-3 text-sm hover:bg-sky-50 hover:text-sky-600 dark:bg-gray-900 dark:hover:bg-sky-950/40' className='truncate rounded-2xl bg-emerald-50/70 px-4 py-3 text-sm text-slate-700 ring-1 ring-emerald-100 transition-colors duration-200 hover:bg-white hover:text-emerald-700 dark:bg-emerald-500/5 dark:text-slate-200 dark:ring-emerald-500/10 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
title={chapter.title} title={chapter.title}
> >
{chapter.title} {chapter.title}
@@ -285,7 +456,11 @@ export default function BookDetailPage() {
))} ))}
</div> </div>
) : null} ) : null}
{chapters.length > 60 ? <div className='mt-3 text-xs text-gray-500'> 60 </div> : null} {chapters.length > 60 ? (
<div className='mt-3 text-xs text-slate-500 dark:text-slate-400'>
60
</div>
) : null}
</section> </section>
) : null} ) : null}
</div> </div>
+349 -129
View File
@@ -1,20 +1,44 @@
'use client'; 'use client';
import { FolderCog, RefreshCw, Trash2, X } from 'lucide-react'; import {
BookOpen,
Clock3,
Database,
FolderCog,
RefreshCw,
Trash2,
X,
} from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { deleteCachedBookFile, listCachedBookFiles, type CachedBookFile } from '@/lib/book-cache.client'; import {
import { buildBookReadPath, cacheBookReadRecord, cacheBookShelfItem } from '@/lib/book-route-cache.client'; deleteBookReadRecord,
import { deleteBookReadRecord, getAllBookReadRecords, getAllBookShelf, getCachedBookReadRecordsSnapshot } from '@/lib/book.db.client'; getAllBookReadRecords,
getAllBookShelf,
getCachedBookReadRecordsSnapshot,
} from '@/lib/book.db.client';
import { BookReadRecord, BookShelfItem } from '@/lib/book.types'; import { BookReadRecord, BookShelfItem } from '@/lib/book.types';
import {
type CachedBookFile,
deleteCachedBookFile,
listCachedBookFiles,
} from '@/lib/book-cache.client';
import {
buildBookReadPath,
cacheBookReadRecord,
cacheBookShelfItem,
} from '@/lib/book-route-cache.client';
import { subscribeToDataUpdates } from '@/lib/db.client'; import { subscribeToDataUpdates } from '@/lib/db.client';
function looksLikeInternalHref(value?: string) { function looksLikeInternalHref(value?: string) {
if (!value) return false; if (!value) return false;
const normalized = value.trim().toLowerCase(); const normalized = value.trim().toLowerCase();
return /\.(xhtml|html|htm|xml)(#.*)?$/.test(normalized) || /^nav\b/.test(normalized); return (
/\.(xhtml|html|htm|xml)(#.*)?$/.test(normalized) ||
/^nav\b/.test(normalized)
);
} }
function getReadableChapterLabel(item: BookReadRecord) { function getReadableChapterLabel(item: BookReadRecord) {
@@ -36,16 +60,19 @@ function BookHistorySkeleton() {
return ( return (
<div className='space-y-4'> <div className='space-y-4'>
{Array.from({ length: 6 }).map((_, index) => ( {Array.from({ length: 6 }).map((_, index) => (
<div key={index} className='rounded-3xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-950'> <div
key={index}
className='rounded-[2rem] border border-emerald-100/80 bg-white/85 p-4 shadow-sm shadow-emerald-950/5 dark:border-emerald-500/10 dark:bg-gray-950/70'
>
<div className='flex gap-4'> <div className='flex gap-4'>
<div className='h-28 w-20 animate-pulse overflow-hidden rounded-2xl bg-gray-200 dark:bg-gray-800' /> <div className='h-28 w-20 animate-pulse overflow-hidden rounded-2xl bg-emerald-100 dark:bg-gray-800' />
<div className='min-w-0 flex-1 space-y-3'> <div className='min-w-0 flex-1 space-y-3'>
<div className='h-5 w-2/3 animate-pulse rounded bg-gray-200 dark:bg-gray-800' /> <div className='h-5 w-2/3 animate-pulse rounded bg-emerald-100 dark:bg-gray-800' />
<div className='h-4 w-1/3 animate-pulse rounded bg-gray-200 dark:bg-gray-800' /> <div className='h-4 w-1/3 animate-pulse rounded bg-emerald-100 dark:bg-gray-800' />
<div className='h-4 w-1/2 animate-pulse rounded bg-gray-200 dark:bg-gray-800' /> <div className='h-4 w-1/2 animate-pulse rounded bg-emerald-100 dark:bg-gray-800' />
<div className='flex gap-2 pt-1'> <div className='flex gap-2 pt-1'>
<div className='h-9 w-20 animate-pulse rounded-2xl bg-gray-200 dark:bg-gray-800' /> <div className='h-9 w-20 animate-pulse rounded-2xl bg-emerald-100 dark:bg-gray-800' />
<div className='h-9 w-16 animate-pulse rounded-2xl bg-gray-200 dark:bg-gray-800' /> <div className='h-9 w-16 animate-pulse rounded-2xl bg-emerald-100 dark:bg-gray-800' />
</div> </div>
</div> </div>
</div> </div>
@@ -63,7 +90,11 @@ export default function BookHistoryPage() {
const [cacheItems, setCacheItems] = useState<CachedBookFile[]>([]); const [cacheItems, setCacheItems] = useState<CachedBookFile[]>([]);
const [cacheLoading, setCacheLoading] = useState(false); const [cacheLoading, setCacheLoading] = useState(false);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [confirmAction, setConfirmAction] = useState<{ type: 'delete-one' | 'clear-all'; key?: string; title?: string } | null>(null); const [confirmAction, setConfirmAction] = useState<{
type: 'delete-one' | 'clear-all';
key?: string;
title?: string;
} | null>(null);
const [displayAll, setDisplayAll] = useState(false); const [displayAll, setDisplayAll] = useState(false);
const updateRecords = (nextRecords: Record<string, BookReadRecord>) => { const updateRecords = (nextRecords: Record<string, BookReadRecord>) => {
@@ -83,10 +114,17 @@ export default function BookHistoryPage() {
setLoading(false); setLoading(false);
} }
getAllBookReadRecords().then(updateRecords).catch(() => undefined).finally(() => setLoading(false)); getAllBookReadRecords()
getAllBookShelf().then(setShelf).catch(() => undefined); .then(updateRecords)
.catch(() => undefined)
.finally(() => setLoading(false));
getAllBookShelf()
.then(setShelf)
.catch(() => undefined);
const unsubscribeHistory = subscribeToDataUpdates<Record<string, BookReadRecord>>('bookHistoryUpdated', updateRecords); const unsubscribeHistory = subscribeToDataUpdates<
Record<string, BookReadRecord>
>('bookHistoryUpdated', updateRecords);
return unsubscribeHistory; return unsubscribeHistory;
}, []); }, []);
@@ -105,160 +143,342 @@ export default function BookHistoryPage() {
void loadCacheItems(); void loadCacheItems();
}, [cacheModalOpen]); }, [cacheModalOpen]);
const items = useMemo(() => Object.entries(records) const items = useMemo(
.map(([key, item]) => { () =>
const [fallbackSourceId = '', fallbackBookId = ''] = key.split('+'); Object.entries(records)
const shelfItem = shelf[key]; .map(([key, item]) => {
return { const [fallbackSourceId = '', fallbackBookId = ''] = key.split('+');
...item, const shelfItem = shelf[key];
storageKey: key, return {
sourceId: item.sourceId || shelfItem?.sourceId || fallbackSourceId, ...item,
bookId: item.bookId || shelfItem?.bookId || fallbackBookId, storageKey: key,
sourceName: item.sourceName || shelfItem?.sourceName || '', sourceId: item.sourceId || shelfItem?.sourceId || fallbackSourceId,
detailHref: item.detailHref || shelfItem?.detailHref, bookId: item.bookId || shelfItem?.bookId || fallbackBookId,
acquisitionHref: item.acquisitionHref || shelfItem?.acquisitionHref, sourceName: item.sourceName || shelfItem?.sourceName || '',
cover: item.cover || shelfItem?.cover, detailHref: item.detailHref || shelfItem?.detailHref,
author: item.author || shelfItem?.author, acquisitionHref: item.acquisitionHref || shelfItem?.acquisitionHref,
format: item.format || shelfItem?.format || 'epub', cover: item.cover || shelfItem?.cover,
}; author: item.author || shelfItem?.author,
}) format: item.format || shelfItem?.format || 'epub',
.sort((a, b) => b.saveTime - a.saveTime), [records, shelf]); };
})
.sort((a, b) => b.saveTime - a.saveTime),
[records, shelf]
);
const visibleItems = useMemo( const visibleItems = useMemo(
() => (displayAll ? items : items.slice(0, 10)), () => (displayAll ? items : items.slice(0, 10)),
[displayAll, items] [displayAll, items]
); );
const cacheTotalSize = useMemo(() => cacheItems.reduce((sum, item) => sum + item.size, 0), [cacheItems]); const cacheTotalSize = useMemo(
() => cacheItems.reduce((sum, item) => sum + item.size, 0),
[cacheItems]
);
return ( return (
<div className='space-y-4'> <div className='space-y-5'>
<div className='flex items-center justify-between'> <section className='relative overflow-hidden rounded-[2rem] border border-emerald-100/80 bg-gradient-to-br from-emerald-50 via-white to-lime-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-lime-950/20'>
<div className='text-sm text-gray-500'> {items.length} </div> <div className='absolute -right-16 -top-20 h-48 w-48 rounded-full bg-emerald-300/20 blur-3xl dark:bg-emerald-500/10' />
<button <div className='relative flex items-center justify-between gap-4'>
type='button' <div>
onClick={() => setCacheModalOpen(true)} <div className='inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-white/70 px-3 py-1 text-xs font-semibold text-emerald-700 shadow-sm dark:border-emerald-500/20 dark:bg-gray-950/50 dark:text-emerald-200'>
className='inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 dark:border-gray-700' <Clock3 className='h-3.5 w-3.5' />
aria-label='缓存管理' Reading Timeline
title='缓存管理' </div>
> <h1 className='mt-3 text-3xl font-black tracking-tight text-emerald-950 dark:text-emerald-50'>
<FolderCog className='h-4 w-4' />
</button> </h1>
</div> <div className='mt-2 text-sm text-slate-500 dark:text-slate-400'>
{items.length}
</div>
</div>
<button
type='button'
onClick={() => setCacheModalOpen(true)}
className='inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-2xl border border-emerald-200 bg-white/80 text-emerald-700 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/60 dark:text-emerald-200 dark:hover:bg-emerald-500/10'
aria-label='缓存管理'
title='缓存管理'
>
<FolderCog className='h-5 w-5' />
</button>
</div>
</section>
{loading ? ( {loading ? (
<BookHistorySkeleton /> <BookHistorySkeleton />
) : ( ) : (
visibleItems.map((item) => ( visibleItems.map((item) => (
<div key={item.storageKey} className='rounded-3xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-950'> <article
key={item.storageKey}
className='rounded-[2rem] border border-emerald-100/80 bg-white/85 p-4 shadow-sm shadow-emerald-950/5 transition-colors duration-200 hover:border-emerald-200 hover:bg-white dark:border-emerald-500/10 dark:bg-gray-950/70 dark:hover:border-emerald-500/30'
>
<div className='flex gap-4'> <div className='flex gap-4'>
<div className='h-28 w-20 overflow-hidden rounded-2xl bg-gray-100 dark:bg-gray-900'>{item.cover ? <img src={item.cover} alt={item.title} className='h-full w-full object-cover' /> : null}</div> <div className='h-28 w-20 shrink-0 overflow-hidden rounded-2xl bg-gradient-to-br from-emerald-50 to-lime-50 ring-1 ring-emerald-100 dark:from-gray-900 dark:to-emerald-950/20 dark:ring-emerald-500/10'>
{item.cover ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.cover}
alt={item.title}
className='h-full w-full object-cover'
/>
) : (
<div className='flex h-full items-center justify-center text-emerald-400'>
<BookOpen className='h-7 w-7' />
</div>
)}
</div>
<div className='min-w-0 flex-1'> <div className='min-w-0 flex-1'>
<div className='truncate font-medium'>{item.title}</div> <div className='truncate font-semibold text-slate-950 dark:text-white'>
<div className='mt-1 text-sm text-gray-500'>{item.author || item.sourceName}</div> {item.title}
<div className='mt-1 text-xs text-gray-500'> {Math.round(item.progressPercent || 0)}% · {getReadableChapterLabel(item)}</div> </div>
<div className='mt-1 truncate text-sm text-slate-500 dark:text-slate-400'>
{item.author || item.sourceName}
</div>
<div className='mt-3 h-2 overflow-hidden rounded-full bg-emerald-50 dark:bg-gray-900'>
<div
className='h-full rounded-full bg-emerald-600'
style={{
width: `${Math.max(
0,
Math.min(100, Math.round(item.progressPercent || 0))
)}%`,
}}
/>
</div>
<div className='mt-1 text-xs text-slate-500 dark:text-slate-400'>
{Math.round(item.progressPercent || 0)}% ·{' '}
{getReadableChapterLabel(item)}
</div>
<div className='mt-3 flex flex-wrap gap-2'> <div className='mt-3 flex flex-wrap gap-2'>
{item.sourceId ? ( {item.sourceId ? (
<Link <Link
href={buildBookReadPath(item.sourceId, item.bookId)} href={buildBookReadPath(item.sourceId, item.bookId)}
onClick={() => { cacheBookReadRecord(item); if (item.sourceId && item.bookId) { cacheBookShelfItem({ sourceId: item.sourceId, sourceName: item.sourceName, bookId: item.bookId, title: item.title, author: item.author, cover: item.cover, format: item.format, detailHref: item.detailHref, acquisitionHref: item.acquisitionHref, saveTime: item.saveTime }); } }} onClick={() => {
className='rounded-2xl bg-sky-600 px-3 py-2 text-xs text-white' cacheBookReadRecord(item);
if (item.sourceId && item.bookId) {
cacheBookShelfItem({
sourceId: item.sourceId,
sourceName: item.sourceName,
bookId: item.bookId,
title: item.title,
author: item.author,
cover: item.cover,
format: item.format,
detailHref: item.detailHref,
acquisitionHref: item.acquisitionHref,
saveTime: item.saveTime,
});
}
}}
className='inline-flex cursor-pointer items-center gap-1.5 rounded-2xl bg-emerald-600 px-3 py-2 text-xs font-semibold text-white transition-colors duration-200 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500'
> >
</Link> </Link>
) : ( ) : (
<span className='rounded-2xl bg-gray-200 px-3 py-2 text-xs text-gray-500 dark:bg-gray-800'></span> <span className='rounded-2xl bg-gray-200 px-3 py-2 text-xs text-gray-500 dark:bg-gray-800'>
</span>
)} )}
<button onClick={async () => { const [deleteSourceId = item.sourceId, deleteBookId = item.bookId] = item.storageKey.split('+'); await deleteBookReadRecord(deleteSourceId, deleteBookId); updateRecords((() => { const next = { ...records }; delete next[item.storageKey]; return next; })()); }} className='rounded-2xl border border-gray-200 px-3 py-2 text-xs dark:border-gray-700'></button> <button
onClick={async () => {
const [
deleteSourceId = item.sourceId,
deleteBookId = item.bookId,
] = item.storageKey.split('+');
await deleteBookReadRecord(deleteSourceId, deleteBookId);
updateRecords(
(() => {
const next = { ...records };
delete next[item.storageKey];
return next;
})()
);
}}
className='cursor-pointer rounded-2xl border border-emerald-100 px-3 py-2 text-xs font-semibold text-slate-600 transition-colors duration-200 hover:bg-emerald-50 hover:text-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:border-emerald-500/10 dark:text-slate-300 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
>
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </article>
)) ))
)} )}
{!loading && items.length === 0 ? <div className='text-sm text-gray-500'></div> : null} {!loading && items.length === 0 ? (
<div className='rounded-3xl border border-dashed border-emerald-200 bg-white/70 p-8 text-center text-sm text-slate-500 dark:border-emerald-500/20 dark:bg-gray-950/50 dark:text-slate-400'>
</div>
) : null}
{cacheModalOpen && mounted && createPortal( {cacheModalOpen &&
<div className='fixed inset-0 z-50 bg-black/40' onClick={() => setCacheModalOpen(false)}> mounted &&
<div className='absolute right-0 top-0 h-screen w-full max-w-lg overflow-y-auto bg-white shadow-2xl dark:bg-gray-950' onClick={(event) => event.stopPropagation()}> createPortal(
<div className='space-y-4 p-5'> <div
<div className='flex items-start justify-between gap-4'> className='fixed inset-0 z-50 bg-black/45 backdrop-blur-sm'
<div> onClick={() => setCacheModalOpen(false)}
<div className='text-base font-semibold'></div> >
<div className='mt-1 text-xs text-gray-500'> {cacheItems.length} · {formatBytes(cacheTotalSize)}</div> <div
</div> className='absolute right-0 top-0 h-screen w-full max-w-lg overflow-y-auto border-l border-emerald-100 bg-[radial-gradient(circle_at_top_right,#dcfce7_0,transparent_20rem),linear-gradient(180deg,#ffffff_0%,#f8fafc_100%)] shadow-2xl dark:border-emerald-500/10 dark:bg-[radial-gradient(circle_at_top_right,rgba(6,95,70,0.24)_0,transparent_20rem),linear-gradient(180deg,#030712_0%,#09090b_100%)]'
<div className='flex gap-2'> onClick={(event) => event.stopPropagation()}
<button type='button' onClick={() => void loadCacheItems()} className='inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 dark:border-gray-700' aria-label='刷新缓存' title='刷新缓存'><RefreshCw className='h-4 w-4' /></button> >
<button type='button' onClick={() => setConfirmAction({ type: 'clear-all' })} className='inline-flex h-9 w-9 items-center justify-center rounded-full border border-red-200 text-red-600 dark:border-red-900/60 dark:text-red-400' aria-label='清空全部缓存' title='清空全部缓存'><Trash2 className='h-4 w-4' /></button> <div className='space-y-5 p-5'>
<button type='button' onClick={() => setCacheModalOpen(false)} className='inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 dark:border-gray-700' aria-label='关闭' title='关闭'><X className='h-4 w-4' /></button> <div className='rounded-[2rem] border border-emerald-100/80 bg-white/80 p-4 shadow-sm shadow-emerald-950/5 backdrop-blur dark:border-emerald-500/10 dark:bg-gray-950/70'>
</div> <div className='flex items-start justify-between gap-4'>
</div> <div>
<div className='flex items-center gap-2 text-base font-semibold text-slate-950 dark:text-white'>
{cacheLoading ? <div className='text-sm text-gray-500'></div> : null} <Database className='h-4 w-4 text-emerald-600 dark:text-emerald-300' />
{!cacheLoading && cacheItems.length === 0 ? <div className='text-sm text-gray-500'></div> : null}
<div className='space-y-3'>
{cacheItems.map((item) => (
<div key={item.key} className='rounded-3xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900'>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0 flex-1'>
<div className='truncate font-medium'>{item.title}</div>
<div className='mt-1 text-xs text-gray-500'> {item.format.toUpperCase()} · {formatBytes(item.size)}</div>
<div className='mt-1 text-xs text-gray-500'> {new Date(item.lastOpenTime).toLocaleString()}</div>
</div> </div>
<div className='mt-1 text-xs text-slate-500 dark:text-slate-400'>
{cacheItems.length} ·{' '}
{formatBytes(cacheTotalSize)}
</div>
</div>
<div className='flex gap-2'>
<button <button
type='button' type='button'
onClick={() => setConfirmAction({ type: 'delete-one', key: item.key, title: item.title })} onClick={() => void loadCacheItems()}
className='inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 dark:border-gray-700' className='inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-emerald-200 bg-white/80 text-emerald-700 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/60 dark:text-emerald-200 dark:hover:bg-emerald-500/10'
aria-label='删除缓存' aria-label='刷新缓存'
title='删除缓存' title='刷新缓存'
>
<RefreshCw className='h-4 w-4' />
</button>
<button
type='button'
onClick={() => setConfirmAction({ type: 'clear-all' })}
className='inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-red-200 bg-white/80 text-red-600 transition-colors duration-200 hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 dark:border-red-500/20 dark:bg-gray-950/60 dark:text-red-300 dark:hover:bg-red-500/10'
aria-label='清空全部缓存'
title='清空全部缓存'
> >
<Trash2 className='h-4 w-4' /> <Trash2 className='h-4 w-4' />
</button> </button>
<button
type='button'
onClick={() => setCacheModalOpen(false)}
className='inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-emerald-200 bg-white/80 text-slate-600 transition-colors duration-200 hover:bg-emerald-50 hover:text-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:border-emerald-500/20 dark:bg-gray-950/60 dark:text-slate-300 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
aria-label='关闭'
title='关闭'
>
<X className='h-4 w-4' />
</button>
</div> </div>
</div> </div>
))} </div>
{cacheLoading ? (
<div className='rounded-3xl border border-emerald-100 bg-white/75 p-5 text-center text-sm text-slate-500 shadow-sm dark:border-emerald-500/10 dark:bg-gray-950/60 dark:text-slate-400'>
</div>
) : null}
{!cacheLoading && cacheItems.length === 0 ? (
<div className='rounded-3xl border border-dashed border-emerald-200 bg-white/75 p-8 text-center text-sm text-slate-500 shadow-sm dark:border-emerald-500/20 dark:bg-gray-950/60 dark:text-slate-400'>
</div>
) : null}
<div className='space-y-3'>
{cacheItems.map((item) => (
<div
key={item.key}
className='rounded-[2rem] border border-emerald-100/80 bg-white/85 p-4 shadow-sm shadow-emerald-950/5 transition-colors duration-200 hover:border-emerald-200 hover:bg-white dark:border-emerald-500/10 dark:bg-gray-950/70 dark:hover:border-emerald-500/30'
>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0 flex-1'>
<div className='truncate font-semibold text-slate-950 dark:text-white'>
{item.title}
</div>
<div className='mt-1 text-xs text-slate-500 dark:text-slate-400'>
{item.format.toUpperCase()} · {' '}
{formatBytes(item.size)}
</div>
<div className='mt-1 text-xs text-slate-500 dark:text-slate-400'>
{' '}
{new Date(item.lastOpenTime).toLocaleString()}
</div>
</div>
<button
type='button'
onClick={() =>
setConfirmAction({
type: 'delete-one',
key: item.key,
title: item.title,
})
}
className='inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-red-100 bg-white/80 text-red-600 transition-colors duration-200 hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 dark:border-red-500/20 dark:bg-gray-950/60 dark:text-red-300 dark:hover:bg-red-500/10'
aria-label='删除缓存'
title='删除缓存'
>
<Trash2 className='h-4 w-4' />
</button>
</div>
</div>
))}
</div>
</div> </div>
</div> </div>
</div> </div>,
</div>, document.body
document.body )}
)}
{confirmAction &&
{confirmAction && mounted && createPortal( mounted &&
<div className='fixed inset-0 z-[60] flex items-center justify-center bg-black/50 px-4' onClick={() => setConfirmAction(null)}> createPortal(
<div className='w-full max-w-sm rounded-3xl border border-gray-200 bg-white p-5 shadow-2xl dark:border-gray-700 dark:bg-gray-950' onClick={(event) => event.stopPropagation()}> <div
<div className='text-base font-semibold text-gray-900 dark:text-gray-100'> className='fixed inset-0 z-[60] flex items-center justify-center bg-black/55 px-4 backdrop-blur-sm'
{confirmAction.type === 'clear-all' ? '清空全部缓存' : '删除缓存'} onClick={() => setConfirmAction(null)}
>
<div
className='w-full max-w-sm rounded-[2rem] border border-emerald-100 bg-white/95 p-5 shadow-2xl shadow-emerald-950/10 dark:border-emerald-500/10 dark:bg-gray-950/95'
onClick={(event) => event.stopPropagation()}
>
<div className='flex items-center gap-2 text-base font-bold text-slate-950 dark:text-white'>
<Trash2 className='h-4 w-4 text-red-600 dark:text-red-300' />
{confirmAction.type === 'clear-all'
? '清空全部缓存'
: '删除缓存'}
</div>
<div className='mt-2 text-sm text-gray-500 dark:text-gray-400'>
{confirmAction.type === 'clear-all'
? '确认清空当前浏览器中的全部电子书缓存吗?此操作不可撤销。'
: `确认删除《${
confirmAction.title || '该书'
}》的本地缓存吗?`}
</div>
<div className='mt-5 flex justify-end gap-3'>
<button
type='button'
onClick={() => setConfirmAction(null)}
className='cursor-pointer rounded-2xl border border-emerald-200 px-4 py-2 text-sm font-semibold text-slate-600 transition-colors duration-200 hover:bg-emerald-50 hover:text-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:border-emerald-500/20 dark:text-slate-300 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
>
</button>
<button
type='button'
onClick={async () => {
if (confirmAction.type === 'clear-all') {
await Promise.all(
cacheItems.map((item) => deleteCachedBookFile(item.key))
);
setCacheItems([]);
} else if (confirmAction.key) {
await deleteCachedBookFile(confirmAction.key);
setCacheItems((prev) =>
prev.filter((item) => item.key !== confirmAction.key)
);
}
setConfirmAction(null);
}}
className='cursor-pointer rounded-2xl bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-red-600/20 transition-colors duration-200 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500'
>
</button>
</div>
</div> </div>
<div className='mt-2 text-sm text-gray-500 dark:text-gray-400'> </div>,
{confirmAction.type === 'clear-all' document.body
? '确认清空当前浏览器中的全部电子书缓存吗?此操作不可撤销。' )}
: `确认删除《${confirmAction.title || '该书'}》的本地缓存吗?`}
</div>
<div className='mt-5 flex justify-end gap-3'>
<button type='button' onClick={() => setConfirmAction(null)} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'></button>
<button
type='button'
onClick={async () => {
if (confirmAction.type === 'clear-all') {
await Promise.all(cacheItems.map((item) => deleteCachedBookFile(item.key)));
setCacheItems([]);
} else if (confirmAction.key) {
await deleteCachedBookFile(confirmAction.key);
setCacheItems((prev) => prev.filter((item) => item.key !== confirmAction.key));
}
setConfirmAction(null);
}}
className='rounded-2xl bg-red-600 px-4 py-2 text-sm text-white'
>
</button>
</div>
</div>
</div>,
document.body
)}
</div> </div>
); );
} }
+177 -23
View File
@@ -1,7 +1,16 @@
'use client'; 'use client';
import {
BookOpen,
CheckCircle2,
Compass,
Library,
Search,
Sparkles,
XCircle,
} from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { BookSource } from '@/lib/book.types'; import { BookSource } from '@/lib/book.types';
@@ -9,15 +18,18 @@ function BooksHomeSkeleton() {
return ( return (
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3 animate-pulse'> <div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3 animate-pulse'>
{Array.from({ length: 6 }).map((_, index) => ( {Array.from({ length: 6 }).map((_, index) => (
<div key={index} className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'> <div
<div className='h-5 w-32 rounded bg-gray-200 dark:bg-gray-800' /> key={index}
className='rounded-[2rem] border border-emerald-100/80 bg-white/80 p-5 shadow-sm dark:border-emerald-500/10 dark:bg-gray-950/70'
>
<div className='h-5 w-32 rounded bg-emerald-100 dark:bg-gray-800' />
<div className='mt-3 flex gap-2'> <div className='mt-3 flex gap-2'>
<div className='h-6 w-16 rounded-full bg-gray-200 dark:bg-gray-800' /> <div className='h-6 w-16 rounded-full bg-emerald-100 dark:bg-gray-800' />
<div className='h-6 w-16 rounded-full bg-gray-200 dark:bg-gray-800' /> <div className='h-6 w-16 rounded-full bg-emerald-100 dark:bg-gray-800' />
</div> </div>
<div className='mt-4 flex gap-2'> <div className='mt-5 flex gap-2'>
<div className='h-10 w-24 rounded-2xl bg-gray-200 dark:bg-gray-800' /> <div className='h-10 w-24 rounded-2xl bg-emerald-100 dark:bg-gray-800' />
<div className='h-10 w-24 rounded-2xl bg-gray-200 dark:bg-gray-800' /> <div className='h-10 w-24 rounded-2xl bg-emerald-100 dark:bg-gray-800' />
</div> </div>
</div> </div>
))} ))}
@@ -25,13 +37,39 @@ function BooksHomeSkeleton() {
); );
} }
function CapabilityPill({
enabled,
children,
}: {
enabled?: boolean;
children: React.ReactNode;
}) {
const Icon = enabled ? CheckCircle2 : XCircle;
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${
enabled
? 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/20'
: 'bg-gray-100 text-gray-500 ring-1 ring-gray-200 dark:bg-gray-900 dark:text-gray-400 dark:ring-gray-800'
}`}
>
<Icon className='h-3.5 w-3.5' />
{children}
</span>
);
}
export default function BooksHomePage() { export default function BooksHomePage() {
const [sources, setSources] = useState<BookSource[]>([]); const [sources, setSources] = useState<BookSource[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined' && !(window as Window & { RUNTIME_CONFIG?: { BOOKS_ENABLED?: boolean } }).RUNTIME_CONFIG?.BOOKS_ENABLED) { if (
typeof window !== 'undefined' &&
!(window as Window & { RUNTIME_CONFIG?: { BOOKS_ENABLED?: boolean } })
.RUNTIME_CONFIG?.BOOKS_ENABLED
) {
window.location.href = '/'; window.location.href = '/';
return; return;
} }
@@ -42,31 +80,147 @@ export default function BooksHomePage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
const stats = useMemo(() => {
const catalogCount = sources.filter(
(source) => source.capabilities?.catalogSupported
).length;
const searchCount = sources.filter(
(source) => source.capabilities?.searchSupported
).length;
return [
{ label: '可用书源', value: sources.length },
{ label: '支持目录', value: catalogCount },
{ label: '支持搜索', value: searchCount },
];
}, [sources]);
return ( return (
<div className='space-y-6'> <div className='space-y-7'>
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'> <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-6 shadow-sm dark:border-emerald-500/10 dark:from-emerald-950/30 dark:via-gray-950 dark:to-amber-950/20 sm:p-8'>
<h1 className='text-lg font-semibold'></h1> <div className='absolute -right-16 -top-20 h-56 w-56 rounded-full bg-emerald-300/20 blur-3xl dark:bg-emerald-500/10' />
<div className='absolute -bottom-24 left-1/3 h-56 w-56 rounded-full bg-amber-300/20 blur-3xl dark:bg-amber-500/10' />
<div className='relative grid gap-8 lg:grid-cols-[1.25fr_0.75fr] lg:items-end'>
<div>
<div className='inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-white/70 px-3 py-1 text-xs font-medium 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' />
MoonTVPlus Reading Library
</div>
<h1 className='mt-5 max-w-3xl text-4xl font-black tracking-[-0.06em] text-emerald-950 dark:text-emerald-50 sm:text-6xl lg:text-7xl'>
</h1>
<div className='mt-6 flex flex-wrap gap-3'>
<Link
href='/books/search'
className='inline-flex cursor-pointer items-center gap-2 rounded-2xl bg-emerald-600 px-5 py-3 text-sm font-semibold 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 dark:focus:ring-offset-gray-950'
>
<Search className='h-4 w-4' />
</Link>
<Link
href='/books/shelf'
className='inline-flex cursor-pointer items-center gap-2 rounded-2xl border border-emerald-200 bg-white/70 px-5 py-3 text-sm font-semibold text-emerald-900 transition-colors duration-200 hover:bg-emerald-50 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:border-emerald-500/20 dark:bg-gray-950/50 dark:text-emerald-100 dark:hover:bg-emerald-950/30 dark:focus:ring-offset-gray-950'
>
<BookOpen className='h-4 w-4' />
</Link>
</div>
</div>
<div className='grid grid-cols-3 gap-3'>
{stats.map((stat) => (
<div
key={stat.label}
className='rounded-3xl border border-white/80 bg-white/75 p-4 text-center shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5'
>
<div className='text-2xl font-black text-emerald-700 dark:text-emerald-200'>
{stat.value}
</div>
<div className='mt-1 text-xs text-slate-500 dark:text-slate-400'>
{stat.label}
</div>
</div>
))}
</div>
</div>
</section> </section>
<div className='flex items-end justify-between gap-3'>
<div>
<h2 className='text-xl font-bold tracking-tight text-slate-950 dark:text-white'>
</h2>
<p className='mt-1 text-sm text-slate-500 dark:text-slate-400'>
</p>
</div>
<Library className='hidden h-6 w-6 text-emerald-500 sm:block' />
</div>
{loading ? <BooksHomeSkeleton /> : null} {loading ? <BooksHomeSkeleton /> : null}
{error ? <div className='text-sm text-red-500'>{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}
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'> <div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
{sources.map((source) => ( {sources.map((source) => (
<div key={source.id} className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'> <article
<div className='text-base font-semibold'>{source.name}</div> key={source.id}
<div className='mt-1 text-xs text-gray-400'>{source.type === 'legado' ? 'Legado' : 'OPDS'}</div> className='group relative overflow-hidden rounded-[2rem] border border-emerald-100/80 bg-white/85 p-5 shadow-sm transition-colors duration-200 hover:border-emerald-200 hover:bg-white dark:border-emerald-500/10 dark:bg-gray-950/70 dark:hover:border-emerald-500/30'
<div className='mt-2 flex flex-wrap gap-2 text-xs'> >
<span className={`rounded-full px-2 py-1 ${source.capabilities?.catalogSupported ? 'bg-sky-100 text-sky-700 dark:bg-sky-950/50 dark:text-sky-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-900 dark:text-gray-400'}`}>{source.capabilities?.catalogSupported ? '可用' : '不可用'}</span> <div className='absolute -right-10 -top-12 h-28 w-28 rounded-full bg-emerald-200/40 blur-2xl transition-opacity duration-200 group-hover:opacity-80 dark:bg-emerald-500/10' />
<span className={`rounded-full px-2 py-1 ${source.capabilities?.searchSupported ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-900 dark:text-gray-400'}`}>{source.capabilities?.searchSupported ? '可用' : '不可用'}</span> <div className='relative flex items-start justify-between gap-4'>
<div className='min-w-0'>
<div className='truncate text-base font-bold text-slate-950 dark:text-white'>
{source.name}
</div>
<div className='mt-1 text-xs font-medium uppercase tracking-[0.2em] text-emerald-500'>
{source.type === 'legado' ? 'Legado' : 'OPDS'}
</div>
</div>
<div className='flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-emerald-50 text-emerald-600 ring-1 ring-emerald-100 dark:bg-emerald-500/10 dark:text-emerald-200 dark:ring-emerald-500/20'>
<Compass className='h-5 w-5' />
</div>
</div> </div>
<div className='mt-4 flex flex-wrap gap-2'> <div className='relative mt-4 flex flex-wrap gap-2'>
{source.capabilities?.catalogSupported && <Link href={`/books/catalog?sourceId=${encodeURIComponent(source.id)}`} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'></Link>} <CapabilityPill enabled={source.capabilities?.catalogSupported}>
{source.capabilities?.searchSupported && <Link href={`/books/search?sourceId=${encodeURIComponent(source.id)}`} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'></Link>} {source.capabilities?.catalogSupported ? '可用' : '不可用'}
</CapabilityPill>
<CapabilityPill enabled={source.capabilities?.searchSupported}>
{source.capabilities?.searchSupported ? '可用' : '不可用'}
</CapabilityPill>
</div> </div>
</div> <div className='relative mt-5 flex flex-wrap gap-2'>
{source.capabilities?.catalogSupported && (
<Link
href={`/books/catalog?sourceId=${encodeURIComponent(
source.id
)}`}
className='inline-flex cursor-pointer items-center gap-2 rounded-2xl bg-emerald-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors duration-200 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-gray-950'
>
</Link>
)}
{source.capabilities?.searchSupported && (
<Link
href={`/books/search?sourceId=${encodeURIComponent(
source.id
)}`}
className='inline-flex cursor-pointer items-center gap-2 rounded-2xl border border-emerald-200 px-4 py-2.5 text-sm font-semibold text-emerald-800 transition-colors duration-200 hover:bg-emerald-50 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:border-emerald-500/20 dark:text-emerald-100 dark:hover:bg-emerald-950/30 dark:focus:ring-offset-gray-950'
>
</Link>
)}
</div>
</article>
))} ))}
</div> </div>
{!loading && !error && sources.length === 0 ? (
<div className='rounded-3xl border border-dashed border-emerald-200 bg-white/70 p-8 text-center text-sm text-slate-500 dark:border-emerald-500/20 dark:bg-gray-950/50 dark:text-slate-400'>
</div>
) : null}
</div> </div>
); );
} }
+1915 -741
View File
@@ -1,12 +1,36 @@
'use client'; 'use client';
import { BookOpen, ChevronRight, ChevronUp, Gauge, Headphones, Loader2, Moon, Pause, Play, SkipBack, SkipForward, Square, Sun, Volume2, Waves, X } from 'lucide-react'; import {
BookOpen,
ChevronRight,
ChevronUp,
Gauge,
Headphones,
Loader2,
Moon,
Pause,
Play,
SkipBack,
SkipForward,
Square,
Sun,
Volume2,
Waves,
X,
} from 'lucide-react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { saveBookReadRecord } from '@/lib/book.db.client'; import { saveBookReadRecord } from '@/lib/book.db.client';
import { BookChapter, BookChapterContent, BookReadManifest, BookReadRecord, BookTtsProgress, BookTtsVoice } from '@/lib/book.types'; import {
BookChapter,
BookChapterContent,
BookReadManifest,
BookReadRecord,
BookTtsProgress,
BookTtsVoice,
} from '@/lib/book.types';
import { import {
buildBookCacheKey, buildBookCacheKey,
enforceBookCacheLimit, enforceBookCacheLimit,
@@ -14,7 +38,10 @@ import {
putCachedBookFile, putCachedBookFile,
touchCachedBookFile, touchCachedBookFile,
} from '@/lib/book-cache.client'; } from '@/lib/book-cache.client';
import { cacheBookDetail, getBookRouteCache } from '@/lib/book-route-cache.client'; import {
cacheBookDetail,
getBookRouteCache,
} from '@/lib/book-route-cache.client';
import { import {
buildBookTtsCacheKey, buildBookTtsCacheKey,
enforceBookTtsCacheLimit, enforceBookTtsCacheLimit,
@@ -22,7 +49,10 @@ import {
putCachedBookTtsChunk, putCachedBookTtsChunk,
touchCachedBookTtsChunk, touchCachedBookTtsChunk,
} from '@/lib/book-tts-cache.client'; } from '@/lib/book-tts-cache.client';
import { getBookTtsProgress, saveBookTtsProgress } from '@/lib/book-tts-progress.client'; import {
getBookTtsProgress,
saveBookTtsProgress,
} from '@/lib/book-tts-progress.client';
declare global { declare global {
interface Window { interface Window {
@@ -54,7 +84,10 @@ interface EpubThemes {
} }
interface EpubBookInstance { interface EpubBookInstance {
renderTo: (element: HTMLElement, options: Record<string, string | boolean>) => EpubRendition; renderTo: (
element: HTMLElement,
options: Record<string, string | boolean>
) => EpubRendition;
locations?: { locations?: {
percentageFromCfi?: (cfi: string) => number; percentageFromCfi?: (cfi: string) => number;
generate?: (chars?: number) => Promise<void>; generate?: (chars?: number) => Promise<void>;
@@ -78,7 +111,12 @@ interface EpubRendition {
type ReaderTheme = 'light' | 'sepia' | 'dark'; type ReaderTheme = 'light' | 'sepia' | 'dark';
type ReaderMode = 'paginated' | 'scrolled'; type ReaderMode = 'paginated' | 'scrolled';
type FileLoadState = 'preparing' | 'checking-cache' | 'downloading' | 'opening' | 'ready'; type FileLoadState =
| 'preparing'
| 'checking-cache'
| 'downloading'
| 'opening'
| 'ready';
interface ReaderSettings { interface ReaderSettings {
fontSize: number; fontSize: number;
@@ -141,7 +179,10 @@ const TTS_RATE_STEPS = [-20, -10, 0, 10, 20, 35];
const TTS_PITCH_STEPS = [-10, 0, 10, 20]; const TTS_PITCH_STEPS = [-10, 0, 10, 20];
const TTS_VOLUME_STEPS = [-10, 0, 10, 20]; const TTS_VOLUME_STEPS = [-10, 0, 10, 20];
const THEME_STYLES: Record<ReaderTheme, { bodyBg: string; bodyColor: string; panelBg: string }> = { const THEME_STYLES: Record<
ReaderTheme,
{ bodyBg: string; bodyColor: string; panelBg: string }
> = {
light: { bodyBg: '#ffffff', bodyColor: '#111827', panelBg: '#ffffff' }, light: { bodyBg: '#ffffff', bodyColor: '#111827', panelBg: '#ffffff' },
sepia: { bodyBg: '#f6efe3', bodyColor: '#5b4636', panelBg: '#f7f1e7' }, sepia: { bodyBg: '#f6efe3', bodyColor: '#5b4636', panelBg: '#f7f1e7' },
dark: { bodyBg: '#111827', bodyColor: '#e5e7eb', panelBg: '#030712' }, dark: { bodyBg: '#111827', bodyColor: '#e5e7eb', panelBg: '#030712' },
@@ -152,7 +193,10 @@ function loadTtsSettings(): TtsSettings {
try { try {
const raw = localStorage.getItem(TTS_SETTINGS_STORAGE_KEY); const raw = localStorage.getItem(TTS_SETTINGS_STORAGE_KEY);
if (!raw) return DEFAULT_TTS_SETTINGS; if (!raw) return DEFAULT_TTS_SETTINGS;
return { ...DEFAULT_TTS_SETTINGS, ...(JSON.parse(raw) as Partial<TtsSettings>) }; return {
...DEFAULT_TTS_SETTINGS,
...(JSON.parse(raw) as Partial<TtsSettings>),
};
} catch { } catch {
return DEFAULT_TTS_SETTINGS; return DEFAULT_TTS_SETTINGS;
} }
@@ -177,10 +221,16 @@ function loadCachedTtsVoices(): TtsVoicesCache | null {
function saveCachedTtsVoices(cache: Omit<TtsVoicesCache, 'savedAt'>) { function saveCachedTtsVoices(cache: Omit<TtsVoicesCache, 'savedAt'>) {
if (typeof window === 'undefined' || cache.voices.length === 0) return; if (typeof window === 'undefined' || cache.voices.length === 0) return;
localStorage.setItem(TTS_VOICES_STORAGE_KEY, JSON.stringify({ ...cache, savedAt: Date.now() })); localStorage.setItem(
TTS_VOICES_STORAGE_KEY,
JSON.stringify({ ...cache, savedAt: Date.now() })
);
} }
function applyTtsDefaults(settings: TtsSettings, defaults?: Partial<TtsSettings>): TtsSettings { function applyTtsDefaults(
settings: TtsSettings,
defaults?: Partial<TtsSettings>
): TtsSettings {
return { return {
...settings, ...settings,
voice: settings.voice || defaults?.voice || '', voice: settings.voice || defaults?.voice || '',
@@ -202,14 +252,20 @@ function formatSignedValue(value: number, suffix: '%' | 'Hz') {
function loadScriptOnce(selector: string, src: string, errorMessage: string) { function loadScriptOnce(selector: string, src: string, errorMessage: string) {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const existing = document.querySelector(selector) as HTMLScriptElement | null; const existing = document.querySelector(
selector
) as HTMLScriptElement | null;
if (existing) { if (existing) {
if (existing.dataset.loaded === 'true') { if (existing.dataset.loaded === 'true') {
resolve(); resolve();
return; return;
} }
existing.addEventListener('load', () => resolve(), { once: true }); existing.addEventListener('load', () => resolve(), { once: true });
existing.addEventListener('error', () => reject(new Error(errorMessage)), { once: true }); existing.addEventListener(
'error',
() => reject(new Error(errorMessage)),
{ once: true }
);
return; return;
} }
@@ -230,10 +286,18 @@ function loadScriptOnce(selector: string, src: string, errorMessage: string) {
async function loadEpubScript() { async function loadEpubScript() {
if (window.ePub && window.JSZip) return; if (window.ePub && window.JSZip) return;
if (!window.JSZip) { if (!window.JSZip) {
await loadScriptOnce('script[data-jszip]', 'https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js', 'JSZip 加载失败'); await loadScriptOnce(
'script[data-jszip]',
'https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js',
'JSZip 加载失败'
);
} }
if (!window.ePub) { if (!window.ePub) {
await loadScriptOnce('script[data-epubjs]', 'https://cdn.jsdelivr.net/npm/epubjs/dist/epub.min.js', 'epub.js 加载失败'); await loadScriptOnce(
'script[data-epubjs]',
'https://cdn.jsdelivr.net/npm/epubjs/dist/epub.min.js',
'epub.js 加载失败'
);
} }
} }
@@ -242,14 +306,20 @@ function loadReaderSettings(): ReaderSettings {
try { try {
const raw = localStorage.getItem(SETTINGS_STORAGE_KEY); const raw = localStorage.getItem(SETTINGS_STORAGE_KEY);
if (!raw) return DEFAULT_SETTINGS; if (!raw) return DEFAULT_SETTINGS;
return { ...DEFAULT_SETTINGS, ...(JSON.parse(raw) as Partial<ReaderSettings>) }; return {
...DEFAULT_SETTINGS,
...(JSON.parse(raw) as Partial<ReaderSettings>),
};
} catch { } catch {
return DEFAULT_SETTINGS; return DEFAULT_SETTINGS;
} }
} }
function buildScrolledPositionKey(
function buildScrolledPositionKey(sourceId: string, bookId: string, href?: string) { sourceId: string,
bookId: string,
href?: string
) {
return `${sourceId}::${bookId}::${normalizeHrefForMatch(href)}`; return `${sourceId}::${bookId}::${normalizeHrefForMatch(href)}`;
} }
@@ -257,20 +327,30 @@ function loadScrolledPositions(): Record<string, ScrolledReadingPosition> {
if (typeof window === 'undefined') return {}; if (typeof window === 'undefined') return {};
try { try {
const raw = localStorage.getItem(SCROLLED_POSITION_STORAGE_KEY); const raw = localStorage.getItem(SCROLLED_POSITION_STORAGE_KEY);
return raw ? (JSON.parse(raw) as Record<string, ScrolledReadingPosition>) : {}; return raw
? (JSON.parse(raw) as Record<string, ScrolledReadingPosition>)
: {};
} catch { } catch {
return {}; return {};
} }
} }
function saveScrolledPosition(sourceId: string, bookId: string, position: ScrolledReadingPosition) { function saveScrolledPosition(
sourceId: string,
bookId: string,
position: ScrolledReadingPosition
) {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const all = loadScrolledPositions(); const all = loadScrolledPositions();
all[buildScrolledPositionKey(sourceId, bookId, position.href)] = position; all[buildScrolledPositionKey(sourceId, bookId, position.href)] = position;
localStorage.setItem(SCROLLED_POSITION_STORAGE_KEY, JSON.stringify(all)); localStorage.setItem(SCROLLED_POSITION_STORAGE_KEY, JSON.stringify(all));
} }
function getScrolledPosition(sourceId: string, bookId: string, href?: string): ScrolledReadingPosition | null { function getScrolledPosition(
sourceId: string,
bookId: string,
href?: string
): ScrolledReadingPosition | null {
const all = loadScrolledPositions(); const all = loadScrolledPositions();
return all[buildScrolledPositionKey(sourceId, bookId, href)] || null; return all[buildScrolledPositionKey(sourceId, bookId, href)] || null;
} }
@@ -298,15 +378,25 @@ function getIframeScrollMetrics(viewer: HTMLDivElement | null) {
} }
} }
const root = doc ? (doc.scrollingElement || doc.documentElement || doc.body) : null; const root = doc
const rootOverflow = root ? Math.max((root.scrollHeight || 0) - (root.clientHeight || win?.innerHeight || 0), 0) : 0; ? doc.scrollingElement || doc.documentElement || doc.body
: null;
const rootOverflow = root
? Math.max(
(root.scrollHeight || 0) - (root.clientHeight || win?.innerHeight || 0),
0
)
: 0;
if (root && rootOverflow >= bestOverflow) { if (root && rootOverflow >= bestOverflow) {
return { return {
iframe, iframe,
root, root,
scrollTop: Math.max(0, win?.scrollY || root.scrollTop || 0), scrollTop: Math.max(0, win?.scrollY || root.scrollTop || 0),
scrollHeight: Math.max(root.scrollHeight || 0, doc?.body?.scrollHeight || 0), scrollHeight: Math.max(
root.scrollHeight || 0,
doc?.body?.scrollHeight || 0
),
clientHeight: root.clientHeight || win?.innerHeight || 0, clientHeight: root.clientHeight || win?.innerHeight || 0,
setScrollTop: (value: number) => { setScrollTop: (value: number) => {
if (typeof root.scrollTo === 'function') { if (typeof root.scrollTo === 'function') {
@@ -315,8 +405,10 @@ function getIframeScrollMetrics(viewer: HTMLDivElement | null) {
root.scrollTop = value; root.scrollTop = value;
} }
}, },
addScrollListener: (listener: () => void) => win?.addEventListener('scroll', listener, { passive: true }), addScrollListener: (listener: () => void) =>
removeScrollListener: (listener: () => void) => win?.removeEventListener('scroll', listener), win?.addEventListener('scroll', listener, { passive: true }),
removeScrollListener: (listener: () => void) =>
win?.removeEventListener('scroll', listener),
interactionTarget: root, interactionTarget: root,
}; };
} }
@@ -332,8 +424,10 @@ function getIframeScrollMetrics(viewer: HTMLDivElement | null) {
setScrollTop: (value: number) => { setScrollTop: (value: number) => {
scrollElement.scrollTo({ top: value, behavior: 'auto' }); scrollElement.scrollTo({ top: value, behavior: 'auto' });
}, },
addScrollListener: (listener: () => void) => scrollElement.addEventListener('scroll', listener, { passive: true }), addScrollListener: (listener: () => void) =>
removeScrollListener: (listener: () => void) => scrollElement.removeEventListener('scroll', listener), scrollElement.addEventListener('scroll', listener, { passive: true }),
removeScrollListener: (listener: () => void) =>
scrollElement.removeEventListener('scroll', listener),
interactionTarget: scrollElement, interactionTarget: scrollElement,
}; };
} }
@@ -341,7 +435,11 @@ function getIframeScrollMetrics(viewer: HTMLDivElement | null) {
return null; return null;
} }
function computeScrolledTargetScrollTop(position: ScrolledReadingPosition, currentScrollHeight: number, currentClientHeight: number) { function computeScrolledTargetScrollTop(
position: ScrolledReadingPosition,
currentScrollHeight: number,
currentClientHeight: number
) {
const maxSaved = Math.max(0, position.scrollHeight - position.clientHeight); const maxSaved = Math.max(0, position.scrollHeight - position.clientHeight);
const maxCurrent = Math.max(0, currentScrollHeight - currentClientHeight); const maxCurrent = Math.max(0, currentScrollHeight - currentClientHeight);
if (maxCurrent <= 0) return 0; if (maxCurrent <= 0) return 0;
@@ -350,9 +448,15 @@ function computeScrolledTargetScrollTop(position: ScrolledReadingPosition, curre
return ratio * maxCurrent; return ratio * maxCurrent;
} }
function encodeChapterScrollLocator(href: string, scrollTop: number, scrollHeight: number, clientHeight: number) { function encodeChapterScrollLocator(
href: string,
scrollTop: number,
scrollHeight: number,
clientHeight: number
) {
const maxScrollTop = Math.max(0, scrollHeight - clientHeight); const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
const ratio = maxScrollTop > 0 ? Math.max(0, Math.min(1, scrollTop / maxScrollTop)) : 0; const ratio =
maxScrollTop > 0 ? Math.max(0, Math.min(1, scrollTop / maxScrollTop)) : 0;
return `${href}#scroll=${ratio.toFixed(6)}`; return `${href}#scroll=${ratio.toFixed(6)}`;
} }
@@ -370,7 +474,12 @@ function flattenToc(items: TocItem[]): TocItem[] {
} }
function tocItemIsActive(item: TocItem, currentHref: string): boolean { function tocItemIsActive(item: TocItem, currentHref: string): boolean {
return isSameTocTarget(currentHref, item.href) || (item.subitems || []).some((subitem) => tocItemIsActive(subitem, currentHref)); return (
isSameTocTarget(currentHref, item.href) ||
(item.subitems || []).some((subitem) =>
tocItemIsActive(subitem, currentHref)
)
);
} }
function findTocLabelByHref(items: TocItem[], currentHref: string): string { function findTocLabelByHref(items: TocItem[], currentHref: string): string {
@@ -382,7 +491,11 @@ function findTocLabelByHref(items: TocItem[], currentHref: string): string {
return ''; return '';
} }
async function fetchJsonWithRetry<T>(url: string, init?: RequestInit, retries = 2): Promise<T> { async function fetchJsonWithRetry<T>(
url: string,
init?: RequestInit,
retries = 2
): Promise<T> {
let lastError: unknown; let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt += 1) { for (let attempt = 0; attempt <= retries; attempt += 1) {
try { try {
@@ -393,7 +506,9 @@ async function fetchJsonWithRetry<T>(url: string, init?: RequestInit, retries =
} catch (error) { } catch (error) {
lastError = error; lastError = error;
if (attempt < retries) { if (attempt < retries) {
await new Promise((resolve) => setTimeout(resolve, 300 * (attempt + 1))); await new Promise((resolve) =>
setTimeout(resolve, 300 * (attempt + 1))
);
} }
} }
} }
@@ -440,7 +555,9 @@ async function downloadBookWithProgress(
} }
} }
return new Blob(chunks, { type: response.headers.get('content-type') || 'application/epub+zip' }); return new Blob(chunks, {
type: response.headers.get('content-type') || 'application/epub+zip',
});
} }
function ChapterReader({ manifest }: { manifest: BookReadManifest }) { function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
@@ -457,12 +574,16 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [ttsVoices, setTtsVoices] = useState<BookTtsVoice[]>([]); const [ttsVoices, setTtsVoices] = useState<BookTtsVoice[]>([]);
const [ttsAvailable, setTtsAvailable] = useState(false); const [ttsAvailable, setTtsAvailable] = useState(false);
const [ttsSettings, setTtsSettings] = useState<TtsSettings>(() => loadTtsSettings()); const [ttsSettings, setTtsSettings] = useState<TtsSettings>(() =>
loadTtsSettings()
);
const [ttsStatus, setTtsStatus] = useState<TtsStatus>('idle'); const [ttsStatus, setTtsStatus] = useState<TtsStatus>('idle');
const [ttsError, setTtsError] = useState(''); const [ttsError, setTtsError] = useState('');
const [ttsChunks, setTtsChunks] = useState<TtsChunk[]>([]); const [ttsChunks, setTtsChunks] = useState<TtsChunk[]>([]);
const [ttsCurrentChunkIndex, setTtsCurrentChunkIndex] = useState(0); const [ttsCurrentChunkIndex, setTtsCurrentChunkIndex] = useState(0);
const [ttsLoadingChunkIndex, setTtsLoadingChunkIndex] = useState<number | null>(null); const [ttsLoadingChunkIndex, setTtsLoadingChunkIndex] = useState<
number | null
>(null);
const [ttsBarVisible, setTtsBarVisible] = useState(false); const [ttsBarVisible, setTtsBarVisible] = useState(false);
const [ttsPanelOpen, setTtsPanelOpen] = useState(false); const [ttsPanelOpen, setTtsPanelOpen] = useState(false);
const [ttsCurrentTime, setTtsCurrentTime] = useState(0); const [ttsCurrentTime, setTtsCurrentTime] = useState(0);
@@ -475,7 +596,9 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
const pendingChapterRestoreRatioRef = useRef<number | null>(null); const pendingChapterRestoreRatioRef = useRef<number | null>(null);
const currentIndexRef = useRef(0); const currentIndexRef = useRef(0);
const lastChapterSavedAtRef = useRef(0); const lastChapterSavedAtRef = useRef(0);
const lastChapterSavedLocatorValueRef = useRef(manifest.lastRecord?.locator?.value || ''); const lastChapterSavedLocatorValueRef = useRef(
manifest.lastRecord?.locator?.value || ''
);
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
const ttsChunksRef = useRef<TtsChunk[]>([]); const ttsChunksRef = useRef<TtsChunk[]>([]);
const ttsCurrentChunkIndexRef = useRef(0); const ttsCurrentChunkIndexRef = useRef(0);
@@ -484,27 +607,44 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
const ttsSeekingRef = useRef(false); const ttsSeekingRef = useRef(false);
const ttsResumeTimeRef = useRef(0); const ttsResumeTimeRef = useRef(0);
const currentChapterHref = chapters[currentIndex]?.href || ''; const currentChapterHref = chapters[currentIndex]?.href || '';
const currentChapterTitle = chapters[currentIndex]?.title || chapter?.title || ''; const currentChapterTitle =
chapters[currentIndex]?.title || chapter?.title || '';
useEffect(() => { currentIndexRef.current = currentIndex; }, [currentIndex]); useEffect(() => {
currentIndexRef.current = currentIndex;
}, [currentIndex]);
useEffect(() => { useEffect(() => {
setSettings(loadReaderSettings()); setSettings(loadReaderSettings());
}, []); }, []);
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined') localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings)); if (typeof window !== 'undefined')
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
}, [settings]); }, [settings]);
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined') localStorage.setItem(TTS_SETTINGS_STORAGE_KEY, JSON.stringify(ttsSettings)); if (typeof window !== 'undefined')
localStorage.setItem(
TTS_SETTINGS_STORAGE_KEY,
JSON.stringify(ttsSettings)
);
ttsSettingsRef.current = ttsSettings; ttsSettingsRef.current = ttsSettings;
}, [ttsSettings]); }, [ttsSettings]);
useEffect(() => { ttsChunksRef.current = ttsChunks; }, [ttsChunks]); useEffect(() => {
useEffect(() => { ttsCurrentChunkIndexRef.current = ttsCurrentChunkIndex; }, [ttsCurrentChunkIndex]); ttsChunksRef.current = ttsChunks;
useEffect(() => { ttsStatusRef.current = ttsStatus; }, [ttsStatus]); }, [ttsChunks]);
useEffect(() => { ttsSeekingRef.current = ttsSeeking; if (!ttsSeeking) setTtsSeekValue(ttsCurrentTime); }, [ttsCurrentTime, ttsSeeking]); useEffect(() => {
ttsCurrentChunkIndexRef.current = ttsCurrentChunkIndex;
}, [ttsCurrentChunkIndex]);
useEffect(() => {
ttsStatusRef.current = ttsStatus;
}, [ttsStatus]);
useEffect(() => {
ttsSeekingRef.current = ttsSeeking;
if (!ttsSeeking) setTtsSeekValue(ttsCurrentTime);
}, [ttsCurrentTime, ttsSeeking]);
const stopTts = useCallback((clearQueue = false) => { const stopTts = useCallback((clearQueue = false) => {
const audio = audioRef.current; const audio = audioRef.current;
@@ -528,15 +668,33 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
}, []); }, []);
useEffect(() => { useEffect(() => {
const handleToggleChapters = () => { setTocOpen((prev) => !prev); setSettingsOpen(false); setTtsPanelOpen(false); }; const handleToggleChapters = () => {
const handleToggleSettings = () => { setSettingsOpen((prev) => !prev); setTocOpen(false); setTtsPanelOpen(false); }; setTocOpen((prev) => !prev);
const handleToggleTts = () => { setTtsBarVisible((prev) => !prev); setTocOpen(false); setSettingsOpen(false); }; setSettingsOpen(false);
setTtsPanelOpen(false);
};
const handleToggleSettings = () => {
setSettingsOpen((prev) => !prev);
setTocOpen(false);
setTtsPanelOpen(false);
};
const handleToggleTts = () => {
setTtsBarVisible((prev) => !prev);
setTocOpen(false);
setSettingsOpen(false);
};
window.addEventListener('books-read-toggle-chapters', handleToggleChapters); window.addEventListener('books-read-toggle-chapters', handleToggleChapters);
window.addEventListener('books-read-toggle-settings', handleToggleSettings); window.addEventListener('books-read-toggle-settings', handleToggleSettings);
window.addEventListener('books-read-toggle-tts', handleToggleTts); window.addEventListener('books-read-toggle-tts', handleToggleTts);
return () => { return () => {
window.removeEventListener('books-read-toggle-chapters', handleToggleChapters); window.removeEventListener(
window.removeEventListener('books-read-toggle-settings', handleToggleSettings); 'books-read-toggle-chapters',
handleToggleChapters
);
window.removeEventListener(
'books-read-toggle-settings',
handleToggleSettings
);
window.removeEventListener('books-read-toggle-tts', handleToggleTts); window.removeEventListener('books-read-toggle-tts', handleToggleTts);
}; };
}, []); }, []);
@@ -558,7 +716,10 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
setTtsAvailable(true); setTtsAvailable(true);
setTtsVoices(json.voices || []); setTtsVoices(json.voices || []);
setTtsSettings((prev) => applyTtsDefaults(prev, json.defaults)); setTtsSettings((prev) => applyTtsDefaults(prev, json.defaults));
saveCachedTtsVoices({ voices: json.voices || [], defaults: json.defaults || {} }); saveCachedTtsVoices({
voices: json.voices || [],
defaults: json.defaults || {},
});
}) })
.catch((err) => { .catch((err) => {
if (!cancelled) { if (!cancelled) {
@@ -567,53 +728,74 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
setTtsError(err.message || '朗读能力不可用'); setTtsError(err.message || '朗读能力不可用');
} }
}); });
return () => { cancelled = true; }; return () => {
cancelled = true;
};
}, []); }, []);
const buildChapterReadRecord = useCallback((item: BookChapter, index: number): BookReadRecord => { const buildChapterReadRecord = useCallback(
const node = scrollRef.current; (item: BookChapter, index: number): BookReadRecord => {
const scrollTop = node?.scrollTop || 0; const node = scrollRef.current;
const scrollHeight = node?.scrollHeight || 0; const scrollTop = node?.scrollTop || 0;
const clientHeight = node?.clientHeight || 0; const scrollHeight = node?.scrollHeight || 0;
const chapterCount = Math.max(1, chapters.length); const clientHeight = node?.clientHeight || 0;
const maxScrollTop = Math.max(0, scrollHeight - clientHeight); const chapterCount = Math.max(1, chapters.length);
const chapterRatio = maxScrollTop > 0 ? Math.max(0, Math.min(1, scrollTop / maxScrollTop)) : 0; const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
const progressPercent = chapters.length > 0 const chapterRatio =
? Math.max(0, Math.min(100, ((index + chapterRatio) / chapterCount) * 100)) maxScrollTop > 0
: 0; ? Math.max(0, Math.min(1, scrollTop / maxScrollTop))
: 0;
const progressPercent =
chapters.length > 0
? Math.max(
0,
Math.min(100, ((index + chapterRatio) / chapterCount) * 100)
)
: 0;
return { return {
sourceId: manifest.book.sourceId, sourceId: manifest.book.sourceId,
sourceName: manifest.book.sourceName, sourceName: manifest.book.sourceName,
bookId: manifest.book.id, bookId: manifest.book.id,
title: manifest.book.title, title: manifest.book.title,
author: manifest.book.author, author: manifest.book.author,
cover: manifest.book.cover, cover: manifest.book.cover,
detailHref: manifest.book.detailHref, detailHref: manifest.book.detailHref,
acquisitionHref: manifest.acquisitionHref, acquisitionHref: manifest.acquisitionHref,
format: 'chapters', format: 'chapters',
locator: { locator: {
type: 'chapter', type: 'chapter',
value: encodeChapterScrollLocator(item.href, scrollTop, scrollHeight, clientHeight), value: encodeChapterScrollLocator(
href: item.href, item.href,
scrollTop,
scrollHeight,
clientHeight
),
href: item.href,
chapterTitle: item.title,
},
chapterTitle: item.title, chapterTitle: item.title,
}, chapterHref: item.href,
chapterTitle: item.title, progressPercent,
chapterHref: item.href, saveTime: Date.now(),
progressPercent, };
saveTime: Date.now(), },
}; [chapters.length, manifest]
}, [chapters.length, manifest]); );
const persistChapterProgress = useCallback((index = currentIndexRef.current) => { const persistChapterProgress = useCallback(
const item = chapters[index]; (index = currentIndexRef.current) => {
if (!item) return; const item = chapters[index];
const record = buildChapterReadRecord(item, index); if (!item) return;
if (record.locator.value === lastChapterSavedLocatorValueRef.current) return; const record = buildChapterReadRecord(item, index);
lastChapterSavedLocatorValueRef.current = record.locator.value; if (record.locator.value === lastChapterSavedLocatorValueRef.current)
lastChapterSavedAtRef.current = Date.now(); return;
void saveBookReadRecord(record.sourceId, record.bookId, record); lastChapterSavedLocatorValueRef.current = record.locator.value;
}, [buildChapterReadRecord, chapters]); lastChapterSavedAtRef.current = Date.now();
void saveBookReadRecord(record.sourceId, record.bookId, record);
},
[buildChapterReadRecord, chapters]
);
const scheduleChapterProgressSave = useCallback(() => { const scheduleChapterProgressSave = useCallback(() => {
if (chapterSaveTimerRef.current) return; if (chapterSaveTimerRef.current) return;
@@ -635,22 +817,39 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
restoredChapterPositionRef.current = false; restoredChapterPositionRef.current = false;
pendingChapterRestoreRatioRef.current = null; pendingChapterRestoreRatioRef.current = null;
lastChapterSavedAtRef.current = 0; lastChapterSavedAtRef.current = 0;
lastChapterSavedLocatorValueRef.current = manifest.lastRecord?.locator?.value || ''; lastChapterSavedLocatorValueRef.current =
manifest.lastRecord?.locator?.value || '';
setLoading(true); setLoading(true);
setError(''); setError('');
const url = manifest.chaptersUrl || `/api/books/read/chapters?sourceId=${encodeURIComponent(manifest.book.sourceId)}&bookId=${encodeURIComponent(manifest.book.id)}`; const url =
manifest.chaptersUrl ||
`/api/books/read/chapters?sourceId=${encodeURIComponent(
manifest.book.sourceId
)}&bookId=${encodeURIComponent(manifest.book.id)}`;
fetchJsonWithRetry<{ chapters?: BookChapter[] }>(url, { cache: 'no-store' }) fetchJsonWithRetry<{ chapters?: BookChapter[] }>(url, { cache: 'no-store' })
.then((json) => { .then((json) => {
if (cancelled) return; if (cancelled) return;
const list = (json.chapters || []) as BookChapter[]; const list = (json.chapters || []) as BookChapter[];
setChapters(list); setChapters(list);
setChaptersLoaded(true); setChaptersLoaded(true);
const savedHref = initialChapterHref || manifest.lastRecord?.chapterHref || manifest.lastRecord?.locator?.href || manifest.lastRecord?.locator?.value?.split('#scroll=')[0] || ''; const savedHref =
initialChapterHref ||
manifest.lastRecord?.chapterHref ||
manifest.lastRecord?.locator?.href ||
manifest.lastRecord?.locator?.value?.split('#scroll=')[0] ||
'';
const savedIndex = list.findIndex((item) => item.href === savedHref); const savedIndex = list.findIndex((item) => item.href === savedHref);
setCurrentIndex(savedIndex >= 0 ? savedIndex : 0); setCurrentIndex(savedIndex >= 0 ? savedIndex : 0);
}) })
.catch((err) => { if (!cancelled) { setError(err.message || '获取目录失败'); setChaptersLoaded(true); } }); .catch((err) => {
return () => { cancelled = true; }; if (!cancelled) {
setError(err.message || '获取目录失败');
setChaptersLoaded(true);
}
});
return () => {
cancelled = true;
};
}, [initialChapterHref, manifest]); }, [initialChapterHref, manifest]);
useEffect(() => { useEffect(() => {
@@ -664,28 +863,57 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
setError(''); setError('');
scrollRef.current?.scrollTo({ top: 0, behavior: 'auto' }); scrollRef.current?.scrollTo({ top: 0, behavior: 'auto' });
pendingChapterRestoreRatioRef.current = null; pendingChapterRestoreRatioRef.current = null;
const params = new URLSearchParams({ sourceId: manifest.book.sourceId, href: item.href }); const params = new URLSearchParams({
if (manifest.acquisitionHref) params.set('tocHref', manifest.acquisitionHref); sourceId: manifest.book.sourceId,
fetchJsonWithRetry<BookChapterContent>(`/api/books/read/chapter?${params.toString()}`, { cache: 'no-store' }) href: item.href,
});
if (manifest.acquisitionHref)
params.set('tocHref', manifest.acquisitionHref);
fetchJsonWithRetry<BookChapterContent>(
`/api/books/read/chapter?${params.toString()}`,
{ cache: 'no-store' }
)
.then((json) => { .then((json) => {
const shouldRestore = !restoredChapterPositionRef.current const shouldRestore =
&& !initialChapterHref !restoredChapterPositionRef.current &&
&& (manifest.lastRecord?.chapterHref === item.href || manifest.lastRecord?.locator?.href === item.href); !initialChapterHref &&
pendingChapterRestoreRatioRef.current = shouldRestore ? parseChapterScrollLocator(manifest.lastRecord?.locator?.value) : null; (manifest.lastRecord?.chapterHref === item.href ||
setChapter({ ...(json as BookChapterContent), title: (json as BookChapterContent).title || item.title }); manifest.lastRecord?.locator?.href === item.href);
pendingChapterRestoreRatioRef.current = shouldRestore
? parseChapterScrollLocator(manifest.lastRecord?.locator?.value)
: null;
setChapter({
...(json as BookChapterContent),
title: (json as BookChapterContent).title || item.title,
});
}) })
.catch((err) => setError(err.message || '获取章节失败')) .catch((err) => setError(err.message || '获取章节失败'))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [chapters, chaptersLoaded, currentIndex, initialChapterHref, manifest, persistChapterProgress, stopTts]); }, [
chapters,
chaptersLoaded,
currentIndex,
initialChapterHref,
manifest,
persistChapterProgress,
stopTts,
]);
useEffect(() => { useEffect(() => {
window.dispatchEvent(new CustomEvent('books-read-update-header', { window.dispatchEvent(
detail: { new CustomEvent('books-read-update-header', {
title: manifest.book.title, detail: {
subtitle: currentChapterTitle || manifest.book.author || (settings.mode === 'scrolled' ? '滚动阅读' : '翻页阅读'), title: manifest.book.title,
backHref: `/books/detail?sourceId=${encodeURIComponent(manifest.book.sourceId)}&bookId=${encodeURIComponent(manifest.book.id)}`, subtitle:
}, currentChapterTitle ||
})); manifest.book.author ||
(settings.mode === 'scrolled' ? '滚动阅读' : '翻页阅读'),
backHref: `/books/detail?sourceId=${encodeURIComponent(
manifest.book.sourceId
)}&bookId=${encodeURIComponent(manifest.book.id)}`,
},
})
);
}, [manifest, currentChapterTitle, settings.mode]); }, [manifest, currentChapterTitle, settings.mode]);
const goPrevChapter = useCallback(() => { const goPrevChapter = useCallback(() => {
@@ -697,77 +925,89 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
setCurrentIndex((prev) => Math.min(chapters.length - 1, prev + 1)); setCurrentIndex((prev) => Math.min(chapters.length - 1, prev + 1));
}, [chapters.length, persistChapterProgress]); }, [chapters.length, persistChapterProgress]);
const turnPage = useCallback((direction: 1 | -1) => { const turnPage = useCallback(
const node = scrollRef.current; (direction: 1 | -1) => {
if (!node) return; const node = scrollRef.current;
if (settings.mode === 'scrolled') return; if (!node) return;
const delta = Math.max(240, node.clientHeight * 0.88) * direction; if (settings.mode === 'scrolled') return;
const maxTop = Math.max(0, node.scrollHeight - node.clientHeight); const delta = Math.max(240, node.clientHeight * 0.88) * direction;
const nextTop = Math.max(0, Math.min(maxTop, node.scrollTop + delta)); const maxTop = Math.max(0, node.scrollHeight - node.clientHeight);
if (direction > 0 && node.scrollTop >= maxTop - 8) { const nextTop = Math.max(0, Math.min(maxTop, node.scrollTop + delta));
if (currentIndex < chapters.length - 1) goNextChapter(); if (direction > 0 && node.scrollTop >= maxTop - 8) {
return; if (currentIndex < chapters.length - 1) goNextChapter();
} return;
if (direction < 0 && node.scrollTop <= 8) { }
if (currentIndex > 0) goPrevChapter(); if (direction < 0 && node.scrollTop <= 8) {
return; if (currentIndex > 0) goPrevChapter();
} return;
node.scrollTo({ top: nextTop, behavior: 'smooth' }); }
}, [chapters.length, currentIndex, goNextChapter, goPrevChapter, settings.mode]); node.scrollTo({ top: nextTop, behavior: 'smooth' });
},
[chapters.length, currentIndex, goNextChapter, goPrevChapter, settings.mode]
);
const getChapterPlainText = useCallback(() => { const getChapterPlainText = useCallback(() => {
const html = chapter?.content || ''; const html = chapter?.content || '';
if (!html) return ''; if (!html) return '';
if (typeof document === 'undefined') return sanitizeTtsText(html.replace(/<[^>]*>/g, ' ')); if (typeof document === 'undefined')
return sanitizeTtsText(html.replace(/<[^>]*>/g, ' '));
const div = document.createElement('div'); const div = document.createElement('div');
div.innerHTML = html; div.innerHTML = html;
div.querySelectorAll('script,style,img').forEach((node) => node.remove()); div.querySelectorAll('script,style,img').forEach((node) => node.remove());
return sanitizeTtsText(div.innerText || div.textContent || ''); return sanitizeTtsText(div.innerText || div.textContent || '');
}, [chapter]); }, [chapter]);
const fetchTtsChunkAudioUrl = useCallback(async (chunk: TtsChunk, chapterHref: string) => { const fetchTtsChunkAudioUrl = useCallback(
if (!manifest) throw new Error('书籍信息未准备好'); async (chunk: TtsChunk, chapterHref: string) => {
const response = await fetch('/api/books/tts/synthesize', { if (!manifest) throw new Error('书籍信息未准备好');
method: 'POST', const response = await fetch('/api/books/tts/synthesize', {
headers: { 'Content-Type': 'application/json' }, method: 'POST',
body: JSON.stringify({ headers: { 'Content-Type': 'application/json' },
sourceId: manifest.book.sourceId, body: JSON.stringify({
bookId: manifest.book.id, sourceId: manifest.book.sourceId,
chapterHref, bookId: manifest.book.id,
text: chunk.text, chapterHref,
voice: ttsSettingsRef.current.voice, text: chunk.text,
rate: ttsSettingsRef.current.rate, voice: ttsSettingsRef.current.voice,
pitch: ttsSettingsRef.current.pitch, rate: ttsSettingsRef.current.rate,
volume: ttsSettingsRef.current.volume, pitch: ttsSettingsRef.current.pitch,
}), volume: ttsSettingsRef.current.volume,
}); }),
const json = await response.json(); });
if (!response.ok) throw new Error(json.error || '朗读音频生成失败'); const json = await response.json();
return URL.createObjectURL(decodeBase64Audio(json.audioBase64 || '', json.mimeType || 'audio/mpeg')); if (!response.ok) throw new Error(json.error || '朗读音频生成失败');
}, [manifest]); return URL.createObjectURL(
decodeBase64Audio(json.audioBase64 || '', json.mimeType || 'audio/mpeg')
);
},
[manifest]
);
const playTtsChunk = useCallback(async (index: number) => { const playTtsChunk = useCallback(
const chunks = ttsChunksRef.current; async (index: number) => {
const chunk = chunks[index]; const chunks = ttsChunksRef.current;
if (!chunk || !currentChapterHref) return; const chunk = chunks[index];
try { if (!chunk || !currentChapterHref) return;
setTtsError(''); try {
setTtsLoadingChunkIndex(index); setTtsError('');
setTtsStatus('loading'); setTtsLoadingChunkIndex(index);
const url = await fetchTtsChunkAudioUrl(chunk, currentChapterHref); setTtsStatus('loading');
if (!audioRef.current) audioRef.current = new Audio(); const url = await fetchTtsChunkAudioUrl(chunk, currentChapterHref);
audioRef.current.src = url; if (!audioRef.current) audioRef.current = new Audio();
await audioRef.current.play(); audioRef.current.src = url;
ttsCurrentChunkIndexRef.current = index; await audioRef.current.play();
setTtsCurrentChunkIndex(index); ttsCurrentChunkIndexRef.current = index;
setTtsStatus('playing'); setTtsCurrentChunkIndex(index);
setTtsLoadingChunkIndex(null); setTtsStatus('playing');
} catch (err) { setTtsLoadingChunkIndex(null);
setTtsStatus('error'); } catch (err) {
setTtsLoadingChunkIndex(null); setTtsStatus('error');
setTtsError((err as Error).message || '朗读失败'); setTtsLoadingChunkIndex(null);
} setTtsError((err as Error).message || '朗读失败');
}, [currentChapterHref, fetchTtsChunkAudioUrl]); }
},
[currentChapterHref, fetchTtsChunkAudioUrl]
);
const bootstrapTts = useCallback(async () => { const bootstrapTts = useCallback(async () => {
if (!ttsAvailable) return; if (!ttsAvailable) return;
@@ -842,7 +1082,9 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
useEffect(() => { useEffect(() => {
const node = scrollRef.current; const node = scrollRef.current;
if (!node) return; if (!node) return;
node.addEventListener('scroll', scheduleChapterProgressSave, { passive: true }); node.addEventListener('scroll', scheduleChapterProgressSave, {
passive: true,
});
return () => { return () => {
node.removeEventListener('scroll', scheduleChapterProgressSave); node.removeEventListener('scroll', scheduleChapterProgressSave);
if (chapterSaveTimerRef.current) { if (chapterSaveTimerRef.current) {
@@ -851,7 +1093,13 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
} }
persistChapterProgress(); persistChapterProgress();
}; };
}, [chapter, currentChapterHref, loading, persistChapterProgress, scheduleChapterProgressSave]); }, [
chapter,
currentChapterHref,
loading,
persistChapterProgress,
scheduleChapterProgressSave,
]);
useEffect(() => { useEffect(() => {
const flush = () => persistChapterProgress(); const flush = () => persistChapterProgress();
@@ -882,9 +1130,16 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
setTtsDuration(audio.duration || 0); setTtsDuration(audio.duration || 0);
if (!ttsSeekingRef.current) setTtsSeekValue(audio.currentTime || 0); if (!ttsSeekingRef.current) setTtsSeekValue(audio.currentTime || 0);
}; };
const handlePause = () => { if (!audio.ended && ttsStatusRef.current === 'playing') setTtsStatus('paused'); }; const handlePause = () => {
if (!audio.ended && ttsStatusRef.current === 'playing')
setTtsStatus('paused');
};
const handleLoadedMetadata = () => { const handleLoadedMetadata = () => {
if (ttsResumeTimeRef.current > 0 && audio.duration > 0) audio.currentTime = Math.min(ttsResumeTimeRef.current, Math.max(0, audio.duration - 0.25)); if (ttsResumeTimeRef.current > 0 && audio.duration > 0)
audio.currentTime = Math.min(
ttsResumeTimeRef.current,
Math.max(0, audio.duration - 0.25)
);
ttsResumeTimeRef.current = 0; ttsResumeTimeRef.current = 0;
setTtsDuration(audio.duration || 0); setTtsDuration(audio.duration || 0);
}; };
@@ -901,13 +1156,18 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
}; };
}, [playTtsChunk, stopTts]); }, [playTtsChunk, stopTts]);
const selectedVoice = ttsVoices.find((item) => item.shortName === ttsSettings.voice); const selectedVoice = ttsVoices.find(
(item) => item.shortName === ttsSettings.voice
);
const currentChunk = ttsChunks[ttsCurrentChunkIndex]; const currentChunk = ttsChunks[ttsCurrentChunkIndex];
const ttsRateValue = parseSignedNumber(ttsSettings.rate, '%'); const ttsRateValue = parseSignedNumber(ttsSettings.rate, '%');
const ttsPitchValue = parseSignedNumber(ttsSettings.pitch, 'Hz'); const ttsPitchValue = parseSignedNumber(ttsSettings.pitch, 'Hz');
const ttsVolumeValue = parseSignedNumber(ttsSettings.volume, '%'); const ttsVolumeValue = parseSignedNumber(ttsSettings.volume, '%');
const displayedTtsTime = ttsSeeking ? ttsSeekValue : ttsCurrentTime; const displayedTtsTime = ttsSeeking ? ttsSeekValue : ttsCurrentTime;
const ttsChunkPercent = ttsChunks.length > 0 ? ((ttsCurrentChunkIndex + 1) / ttsChunks.length) * 100 : 0; const ttsChunkPercent =
ttsChunks.length > 0
? ((ttsCurrentChunkIndex + 1) / ttsChunks.length) * 100
: 0;
const palette = THEME_STYLES[settings.theme]; const palette = THEME_STYLES[settings.theme];
if (error) return <div className='p-4 text-sm text-red-500'>{error}</div>; if (error) return <div className='p-4 text-sm text-red-500'>{error}</div>;
@@ -918,7 +1178,9 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
<div className='reader-book-loader'> <div className='reader-book-loader'>
<BookOpen className='h-10 w-10' strokeWidth={1.75} /> <BookOpen className='h-10 w-10' strokeWidth={1.75} />
</div> </div>
<div className='text-sm text-gray-500 dark:text-gray-400'>...</div> <div className='text-sm text-gray-500 dark:text-gray-400'>
...
</div>
</div> </div>
</div> </div>
); );
@@ -927,62 +1189,525 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
return ( return (
<div className='mx-auto max-w-2xl p-4'> <div className='mx-auto max-w-2xl p-4'>
<div className='rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-200'> <div className='rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-200'>
Legado / EPUB 0 Legado / EPUB
0
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className='relative h-[calc(100vh-3.5rem)] overflow-hidden' style={{ backgroundColor: palette.panelBg, color: palette.bodyColor }}> <div
{tocOpen && typeof document !== 'undefined' ? createPortal( className='relative h-[calc(100vh-3.5rem)] overflow-hidden'
<div className='fixed inset-0 z-40 bg-black/30' onClick={() => setTocOpen(false)}> style={{ backgroundColor: palette.panelBg, color: palette.bodyColor }}
<div className='absolute right-0 top-0 h-screen w-[22rem] max-w-[88vw] overflow-y-auto border-l border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-950' onClick={(event) => event.stopPropagation()}> >
<div className='space-y-2 p-4'> {tocOpen && typeof document !== 'undefined'
{chapters.map((item, index) => { ? createPortal(
const active = index === currentIndex; <div
return <button key={`${item.href}-${item.order}-${index}`} onClick={() => { persistChapterProgress(); setCurrentIndex(index); setTocOpen(false); }} className={`block w-full rounded-2xl px-4 py-3 text-left text-sm transition ${active ? 'bg-sky-600 text-white' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900'}`} title={item.title}>{item.title}</button>; className='fixed inset-0 z-40 bg-black/30'
})} onClick={() => setTocOpen(false)}
</div> >
</div> <div
</div>, document.body className='absolute right-0 top-0 h-screen w-[22rem] max-w-[88vw] overflow-y-auto border-l border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-950'
onClick={(event) => event.stopPropagation()}
>
<div className='space-y-2 p-4'>
{chapters.map((item, index) => {
const active = index === currentIndex;
return (
<button
key={`${item.href}-${item.order}-${index}`}
onClick={() => {
persistChapterProgress();
setCurrentIndex(index);
setTocOpen(false);
}}
className={`block w-full rounded-2xl px-4 py-3 text-left text-sm transition ${
active
? 'bg-emerald-600 text-white'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900'
}`}
title={item.title}
>
{item.title}
</button>
);
})}
</div>
</div>
</div>,
document.body
)
: null}
{settingsOpen && typeof document !== 'undefined'
? createPortal(
<div
className='fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4'
onClick={() => setSettingsOpen(false)}
>
<div
className='w-full max-w-sm rounded-3xl border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-700 dark:bg-gray-950'
onClick={(event) => event.stopPropagation()}
>
<div className='mb-4'>
<div className='text-base font-semibold text-gray-900 dark:text-gray-100'>
</div>
<div className='mt-1 text-xs text-gray-500'>
Legado
</div>
</div>
<div className='space-y-6 p-1 text-sm'>
<div>
<div className='mb-2 font-medium'></div>
<div className='grid grid-cols-2 gap-2'>
{(
[
{
key: 'paginated',
label: '翻页模式',
desc: '左右点击翻页/章节',
},
{
key: 'scrolled',
label: '滚动模式',
desc: '上下连续滚动',
},
] as { key: ReaderMode; label: string; desc: string }[]
).map((mode) => (
<button
key={mode.key}
onClick={() =>
setSettings((prev) => ({ ...prev, mode: mode.key }))
}
className={`rounded-2xl border px-3 py-3 text-left ${
settings.mode === mode.key
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'
: 'border-gray-200 dark:border-gray-700'
}`}
>
<div className='font-medium'>{mode.label}</div>
<div className='mt-1 text-xs opacity-70'>
{mode.desc}
</div>
</button>
))}
</div>
</div>
<div>
<div className='mb-2 font-medium'></div>
<div className='grid grid-cols-3 gap-2'>
{(['light', 'sepia', 'dark'] as ReaderTheme[]).map(
(theme) => (
<button
key={theme}
onClick={() =>
setSettings((prev) => ({ ...prev, theme }))
}
className={`rounded-2xl border px-3 py-2 ${
settings.theme === theme
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'
: 'border-gray-200 dark:border-gray-700'
}`}
>
{theme === 'light'
? '浅色'
: theme === 'sepia'
? '护眼'
: '深色'}
</button>
)
)}
</div>
</div>
<div>
<div className='mb-2 flex items-center justify-between font-medium'>
<span>{settings.fontSize}%</span>
</div>
<input
type='range'
min='85'
max='140'
step='5'
value={settings.fontSize}
onChange={(e) =>
setSettings((prev) => ({
...prev,
fontSize: Number(e.target.value),
}))
}
className='w-full'
/>
</div>
<div>
<div className='mb-2 flex items-center justify-between font-medium'>
<span>{settings.lineHeight.toFixed(1)}</span>
</div>
<input
type='range'
min='1.4'
max='2.2'
step='0.1'
value={settings.lineHeight}
onChange={(e) =>
setSettings((prev) => ({
...prev,
lineHeight: Number(e.target.value),
}))
}
className='w-full'
/>
</div>
<div className='flex justify-end'>
<button
type='button'
className='rounded-2xl bg-emerald-600 px-4 py-2 text-sm font-medium text-white'
onClick={() => setSettingsOpen(false)}
>
</button>
</div>
</div>
</div>
</div>,
document.body
)
: null}
{settings.mode === 'paginated' && !tocOpen && !settingsOpen ? (
<>
<button
aria-label='上一页'
className='absolute inset-y-0 left-0 z-10 w-[28%] cursor-pointer bg-transparent'
onClick={() => turnPage(-1)}
/>
<button
aria-label='下一页'
className='absolute inset-y-0 right-0 z-10 w-[28%] cursor-pointer bg-transparent'
onClick={() => turnPage(1)}
/>
</>
) : null} ) : null}
{settingsOpen && typeof document !== 'undefined' ? createPortal( <div
<div className='fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4' onClick={() => setSettingsOpen(false)}> ref={scrollRef}
<div className='w-full max-w-sm rounded-3xl border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-700 dark:bg-gray-950' onClick={(event) => event.stopPropagation()}> className='h-full overflow-y-auto px-4 py-6'
<div className='mb-4'><div className='text-base font-semibold text-gray-900 dark:text-gray-100'></div><div className='mt-1 text-xs text-gray-500'>Legado </div></div> style={{
<div className='space-y-6 p-1 text-sm'> scrollSnapType:
<div><div className='mb-2 font-medium'></div><div className='grid grid-cols-2 gap-2'>{([{ key: 'paginated', label: '翻页模式', desc: '左右点击翻页/章节' }, { key: 'scrolled', label: '滚动模式', desc: '上下连续滚动' }] as { key: ReaderMode; label: string; desc: string }[]).map((mode) => <button key={mode.key} onClick={() => setSettings((prev) => ({ ...prev, mode: mode.key }))} className={`rounded-2xl border px-3 py-3 text-left ${settings.mode === mode.key ? 'border-sky-500 bg-sky-50 text-sky-700 dark:bg-sky-950/30 dark:text-sky-300' : 'border-gray-200 dark:border-gray-700'}`}><div className='font-medium'>{mode.label}</div><div className='mt-1 text-xs opacity-70'>{mode.desc}</div></button>)}</div></div> settings.mode === 'paginated' ? 'y mandatory' : undefined,
<div><div className='mb-2 font-medium'></div><div className='grid grid-cols-3 gap-2'>{(['light', 'sepia', 'dark'] as ReaderTheme[]).map((theme) => <button key={theme} onClick={() => setSettings((prev) => ({ ...prev, theme }))} className={`rounded-2xl border px-3 py-2 ${settings.theme === theme ? 'border-sky-500 bg-sky-50 text-sky-700 dark:bg-sky-950/30 dark:text-sky-300' : 'border-gray-200 dark:border-gray-700'}`}>{theme === 'light' ? '浅色' : theme === 'sepia' ? '护眼' : '深色'}</button>)}</div></div> }}
<div><div className='mb-2 flex items-center justify-between font-medium'> <span>{settings.fontSize}%</span></div><input type='range' min='85' max='140' step='5' value={settings.fontSize} onChange={(e) => setSettings((prev) => ({ ...prev, fontSize: Number(e.target.value) }))} className='w-full' /></div> >
<div><div className='mb-2 flex items-center justify-between font-medium'> <span>{settings.lineHeight.toFixed(1)}</span></div><input type='range' min='1.4' max='2.2' step='0.1' value={settings.lineHeight} onChange={(e) => setSettings((prev) => ({ ...prev, lineHeight: Number(e.target.value) }))} className='w-full' /></div> <article
<div className='flex justify-end'><button type='button' className='rounded-2xl bg-sky-600 px-4 py-2 text-sm font-medium text-white' onClick={() => setSettingsOpen(false)}></button></div> className='mx-auto max-w-3xl text-gray-800 dark:text-gray-100'
style={{
fontSize: `${settings.fontSize}%`,
lineHeight: settings.lineHeight,
color: palette.bodyColor,
}}
>
{loading ? (
<div className='flex min-h-[45vh] items-center justify-center px-4'>
<div className='rounded-[2rem] border border-emerald-100/80 bg-white/80 px-6 py-5 text-center shadow-sm shadow-emerald-950/5 backdrop-blur dark:border-emerald-500/10 dark:bg-gray-950/70'>
<Loader2 className='mx-auto h-6 w-6 animate-spin text-emerald-600 dark:text-emerald-300' />
<div className='mt-3 text-sm font-medium text-slate-600 dark:text-slate-300'>
...
</div>
</div>
</div> </div>
</div> ) : chapter?.content?.includes('<img') ? (
</div>, document.body <div
) : null} className='space-y-2 [&_img]:mx-auto [&_img]:block [&_img]:max-w-full'
dangerouslySetInnerHTML={{ __html: chapter.content }}
{settings.mode === 'paginated' && !tocOpen && !settingsOpen ? <><button aria-label='上一页' className='absolute inset-y-0 left-0 z-10 w-[28%] cursor-pointer bg-transparent' onClick={() => turnPage(-1)} /><button aria-label='下一页' className='absolute inset-y-0 right-0 z-10 w-[28%] cursor-pointer bg-transparent' onClick={() => turnPage(1)} /></> : null} />
) : chapter?.content ? (
<div ref={scrollRef} className='h-full overflow-y-auto px-4 py-6' style={{ scrollSnapType: settings.mode === 'paginated' ? 'y mandatory' : undefined }}> <div className='whitespace-pre-wrap'>{chapter.content}</div>
<article className='mx-auto max-w-3xl text-gray-800 dark:text-gray-100' style={{ fontSize: `${settings.fontSize}%`, lineHeight: settings.lineHeight, color: palette.bodyColor }}> ) : (
{loading ? '加载中...' : chapter?.content?.includes('<img') <div className='flex min-h-[45vh] items-center justify-center px-4'>
? <div className='space-y-2 [&_img]:mx-auto [&_img]:block [&_img]:max-w-full' dangerouslySetInnerHTML={{ __html: chapter.content }} /> <div className='rounded-[2rem] border border-dashed border-emerald-200 bg-white/80 px-6 py-5 text-center shadow-sm shadow-emerald-950/5 backdrop-blur dark:border-emerald-500/20 dark:bg-gray-950/70'>
: <div className='whitespace-pre-wrap'>{chapter?.content || '本章暂无内容'}</div>} <BookOpen className='mx-auto h-7 w-7 text-emerald-600 dark:text-emerald-300' />
<div className='mt-3 text-sm font-medium text-slate-600 dark:text-slate-300'>
</div>
</div>
</div>
)}
</article> </article>
{settings.mode === 'scrolled' ? <div className='mx-auto mt-5 flex max-w-3xl justify-between gap-3 pb-8'><button disabled={currentIndex <= 0} onClick={goPrevChapter} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm disabled:text-gray-400 dark:border-gray-700'></button><button disabled={currentIndex >= chapters.length - 1} onClick={goNextChapter} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white disabled:bg-gray-300'></button></div> : null} {settings.mode === 'scrolled' ? (
<div className='mx-auto mt-5 flex max-w-3xl justify-between gap-3 pb-8'>
<button
disabled={currentIndex <= 0}
onClick={goPrevChapter}
className='rounded-2xl border border-gray-200 px-4 py-2 text-sm disabled:text-gray-400 dark:border-gray-700'
>
</button>
<button
disabled={currentIndex >= chapters.length - 1}
onClick={goNextChapter}
className='rounded-2xl bg-emerald-600 px-4 py-2 text-sm text-white disabled:bg-gray-300'
>
</button>
</div>
) : null}
</div> </div>
{ttsBarVisible ? <> {ttsBarVisible ? (
<div className='absolute inset-x-0 bottom-3 z-20 mx-auto w-[min(94vw,34rem)]'> <>
<div className='overflow-hidden rounded-3xl border border-gray-200 bg-white/95 shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-950/95'> <div className='absolute inset-x-0 bottom-3 z-20 mx-auto w-[min(94vw,34rem)]'>
<div className='px-2 pt-2'><input type='range' min={0} max={Math.max(ttsDuration, 0)} step={0.1} value={Math.min(ttsSeekValue, Math.max(ttsDuration, 0))} disabled={!ttsAvailable || ttsDuration <= 0} onPointerDown={() => setTtsSeeking(true)} onChange={(e) => setTtsSeekValue(Number(e.target.value))} onPointerUp={(e) => { const next = Number((e.target as HTMLInputElement).value); if (audioRef.current && Number.isFinite(next)) audioRef.current.currentTime = next; setTtsCurrentTime(next); setTtsSeeking(false); }} className='w-full accent-sky-500' /></div> <div className='overflow-hidden rounded-3xl border border-gray-200 bg-white/95 shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-950/95'>
<div className='px-3 py-2.5'><div className='flex items-center gap-2'><button type='button' onClick={() => void toggleTtsPlayback()} disabled={!ttsAvailable || ttsLoadingChunkIndex !== null} className='flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-600 text-white disabled:opacity-50'>{ttsLoadingChunkIndex !== null ? <Loader2 className='h-4 w-4 animate-spin' /> : ttsStatus === 'playing' ? <Pause className='h-4 w-4' /> : <Play className='h-4 w-4' />}</button><div className='min-w-0 flex-1'><div className='truncate text-sm font-medium text-gray-900 dark:text-gray-100'>{currentChapterTitle || '语音朗读'}</div><div className='mt-0.5 flex items-center gap-2 text-[11px] text-gray-500 dark:text-gray-400'><span>{!ttsAvailable ? '服务异常' : ttsStatus === 'playing' ? '正在播放' : ttsStatus === 'paused' ? '已暂停' : ttsLoadingChunkIndex !== null ? '生成语音中...' : '待播放'}</span>{ttsChunks.length > 0 ? <span>{ttsCurrentChunkIndex + 1}/{ttsChunks.length}</span> : null}</div></div><button type='button' onClick={() => setTtsPanelOpen((prev) => !prev)} className='flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-200'><ChevronUp className={`h-4 w-4 transition-transform ${ttsPanelOpen ? 'rotate-180' : ''}`} /></button></div><div className='mt-2 flex items-center justify-between text-[11px] text-gray-400'><span>{selectedVoice?.displayName || '默认音色'}</span><span>{formatDurationTime(displayedTtsTime)} / {formatDurationTime(ttsDuration || 0)}</span></div></div> <div className='px-2 pt-2'>
<input
type='range'
min={0}
max={Math.max(ttsDuration, 0)}
step={0.1}
value={Math.min(ttsSeekValue, Math.max(ttsDuration, 0))}
disabled={!ttsAvailable || ttsDuration <= 0}
onPointerDown={() => setTtsSeeking(true)}
onChange={(e) => setTtsSeekValue(Number(e.target.value))}
onPointerUp={(e) => {
const next = Number((e.target as HTMLInputElement).value);
if (audioRef.current && Number.isFinite(next))
audioRef.current.currentTime = next;
setTtsCurrentTime(next);
setTtsSeeking(false);
}}
className='w-full accent-emerald-500'
/>
</div>
<div className='px-3 py-2.5'>
<div className='flex items-center gap-2'>
<button
type='button'
onClick={() => void toggleTtsPlayback()}
disabled={!ttsAvailable || ttsLoadingChunkIndex !== null}
className='flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-600 text-white disabled:opacity-50'
>
{ttsLoadingChunkIndex !== null ? (
<Loader2 className='h-4 w-4 animate-spin' />
) : ttsStatus === 'playing' ? (
<Pause className='h-4 w-4' />
) : (
<Play className='h-4 w-4' />
)}
</button>
<div className='min-w-0 flex-1'>
<div className='truncate text-sm font-medium text-gray-900 dark:text-gray-100'>
{currentChapterTitle || '语音朗读'}
</div>
<div className='mt-0.5 flex items-center gap-2 text-[11px] text-gray-500 dark:text-gray-400'>
<span>
{!ttsAvailable
? '服务异常'
: ttsStatus === 'playing'
? '正在播放'
: ttsStatus === 'paused'
? '已暂停'
: ttsLoadingChunkIndex !== null
? '生成语音中...'
: '待播放'}
</span>
{ttsChunks.length > 0 ? (
<span>
{ttsCurrentChunkIndex + 1}/{ttsChunks.length}
</span>
) : null}
</div>
</div>
<button
type='button'
onClick={() => setTtsPanelOpen((prev) => !prev)}
className='flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-200'
>
<ChevronUp
className={`h-4 w-4 transition-transform ${
ttsPanelOpen ? 'rotate-180' : ''
}`}
/>
</button>
</div>
<div className='mt-2 flex items-center justify-between text-[11px] text-gray-400'>
<span>{selectedVoice?.displayName || '默认音色'}</span>
<span>
{formatDurationTime(displayedTtsTime)} /{' '}
{formatDurationTime(ttsDuration || 0)}
</span>
</div>
</div>
</div>
</div> </div>
</div> {ttsPanelOpen ? (
{ttsPanelOpen ? <div className='absolute inset-x-0 bottom-20 z-30 mx-auto w-[min(94vw,34rem)]'><div className='rounded-[2rem] border border-gray-200 bg-white/98 p-4 shadow-2xl backdrop-blur dark:border-gray-800 dark:bg-gray-950/98'><div className='mb-3 flex items-center justify-between'><div className='flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-gray-100'><Headphones className='h-4 w-4 text-sky-500' /></div><button type='button' onClick={() => setTtsPanelOpen(false)} className='flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 text-gray-600 dark:bg-gray-900 dark:text-gray-300'><X className='h-4 w-4' /></button></div><div className='mb-4 flex items-center justify-between rounded-2xl bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-gray-900 dark:text-gray-300'><span className='truncate'>{currentChunk?.text.slice(0, 28) || '当前章节可开始朗读'}</span><span className='ml-2 shrink-0'>{Math.round(ttsChunkPercent)}%</span></div><div className='mb-4 flex items-center justify-center gap-3'><button type='button' aria-label='上一段' onClick={() => { const next = Math.max(0, ttsCurrentChunkIndex - 1); if (ttsChunks[next]) void playTtsChunk(next); }} disabled={ttsCurrentChunkIndex <= 0 || ttsChunks.length === 0} className='flex h-11 w-11 items-center justify-center rounded-full bg-gray-100 text-gray-700 disabled:opacity-40 dark:bg-gray-900 dark:text-gray-200'><SkipBack className='h-5 w-5' /></button><button type='button' onClick={() => void toggleTtsPlayback()} disabled={!ttsAvailable || ttsLoadingChunkIndex !== null} className='flex h-14 w-14 items-center justify-center rounded-full bg-sky-600 text-white shadow-lg disabled:opacity-50'>{ttsLoadingChunkIndex !== null ? <Loader2 className='h-5 w-5 animate-spin' /> : ttsStatus === 'playing' ? <Pause className='h-5 w-5' /> : <Play className='h-5 w-5' />}</button><button type='button' aria-label='下一段' onClick={() => { const next = ttsCurrentChunkIndex + 1; if (ttsChunks[next]) void playTtsChunk(next); }} disabled={ttsCurrentChunkIndex >= ttsChunks.length - 1 || ttsChunks.length === 0} className='flex h-11 w-11 items-center justify-center rounded-full bg-gray-100 text-gray-700 disabled:opacity-40 dark:bg-gray-900 dark:text-gray-200'><SkipForward className='h-5 w-5' /></button><button type='button' aria-label='停止' onClick={() => stopTts(true)} disabled={ttsStatus === 'idle' && ttsChunks.length === 0} className='flex h-11 w-11 items-center justify-center rounded-full bg-gray-100 text-gray-700 disabled:opacity-40 dark:bg-gray-900 dark:text-gray-200'><Square className='h-4 w-4' /></button></div><select value={ttsSettings.voice} onChange={(e) => { stopTts(true); setTtsSettings((prev) => ({ ...prev, voice: e.target.value })); }} className='mb-4 w-full rounded-2xl border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100'>{ttsVoices.map((voice) => <option key={voice.shortName} value={voice.shortName}>{voice.displayName || voice.shortName}</option>)}</select><label className='mb-3 block text-xs text-gray-500'> {ttsSettings.rate}<input type='range' min={0} max={TTS_RATE_STEPS.length - 1} step={1} value={Math.max(0, TTS_RATE_STEPS.indexOf(ttsRateValue))} onChange={(e) => { stopTts(true); setTtsSettings((prev) => ({ ...prev, rate: formatSignedValue(TTS_RATE_STEPS[Number(e.target.value)] ?? 0, '%') })); }} className='w-full' /></label><label className='mb-3 block text-xs text-gray-500'> {ttsSettings.pitch}<input type='range' min={0} max={TTS_PITCH_STEPS.length - 1} step={1} value={Math.max(0, TTS_PITCH_STEPS.indexOf(ttsPitchValue))} onChange={(e) => { stopTts(true); setTtsSettings((prev) => ({ ...prev, pitch: formatSignedValue(TTS_PITCH_STEPS[Number(e.target.value)] ?? 0, 'Hz') })); }} className='w-full' /></label><label className='block text-xs text-gray-500'> {ttsSettings.volume}<input type='range' min={0} max={TTS_VOLUME_STEPS.length - 1} step={1} value={Math.max(0, TTS_VOLUME_STEPS.indexOf(ttsVolumeValue))} onChange={(e) => { stopTts(true); setTtsSettings((prev) => ({ ...prev, volume: formatSignedValue(TTS_VOLUME_STEPS[Number(e.target.value)] ?? 0, '%') })); }} className='w-full' /></label>{ttsError ? <div className='mt-3 text-xs text-red-500'>{ttsError}</div> : null}</div></div> : null} <div className='absolute inset-x-0 bottom-20 z-30 mx-auto w-[min(94vw,34rem)]'>
</> : null} <div className='rounded-[2rem] border border-gray-200 bg-white/98 p-4 shadow-2xl backdrop-blur dark:border-gray-800 dark:bg-gray-950/98'>
<div className='mb-3 flex items-center justify-between'>
<div className='flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-gray-100'>
<Headphones className='h-4 w-4 text-emerald-500' />
</div>
<button
type='button'
onClick={() => setTtsPanelOpen(false)}
className='flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 text-gray-600 dark:bg-gray-900 dark:text-gray-300'
>
<X className='h-4 w-4' />
</button>
</div>
<div className='mb-4 flex items-center justify-between rounded-2xl bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-gray-900 dark:text-gray-300'>
<span className='truncate'>
{currentChunk?.text.slice(0, 28) || '当前章节可开始朗读'}
</span>
<span className='ml-2 shrink-0'>
{Math.round(ttsChunkPercent)}%
</span>
</div>
<div className='mb-4 flex items-center justify-center gap-3'>
<button
type='button'
aria-label='上一段'
onClick={() => {
const next = Math.max(0, ttsCurrentChunkIndex - 1);
if (ttsChunks[next]) void playTtsChunk(next);
}}
disabled={
ttsCurrentChunkIndex <= 0 || ttsChunks.length === 0
}
className='flex h-11 w-11 items-center justify-center rounded-full bg-gray-100 text-gray-700 disabled:opacity-40 dark:bg-gray-900 dark:text-gray-200'
>
<SkipBack className='h-5 w-5' />
</button>
<button
type='button'
onClick={() => void toggleTtsPlayback()}
disabled={!ttsAvailable || ttsLoadingChunkIndex !== null}
className='flex h-14 w-14 items-center justify-center rounded-full bg-emerald-600 text-white shadow-lg disabled:opacity-50'
>
{ttsLoadingChunkIndex !== null ? (
<Loader2 className='h-5 w-5 animate-spin' />
) : ttsStatus === 'playing' ? (
<Pause className='h-5 w-5' />
) : (
<Play className='h-5 w-5' />
)}
</button>
<button
type='button'
aria-label='下一段'
onClick={() => {
const next = ttsCurrentChunkIndex + 1;
if (ttsChunks[next]) void playTtsChunk(next);
}}
disabled={
ttsCurrentChunkIndex >= ttsChunks.length - 1 ||
ttsChunks.length === 0
}
className='flex h-11 w-11 items-center justify-center rounded-full bg-gray-100 text-gray-700 disabled:opacity-40 dark:bg-gray-900 dark:text-gray-200'
>
<SkipForward className='h-5 w-5' />
</button>
<button
type='button'
aria-label='停止'
onClick={() => stopTts(true)}
disabled={ttsStatus === 'idle' && ttsChunks.length === 0}
className='flex h-11 w-11 items-center justify-center rounded-full bg-gray-100 text-gray-700 disabled:opacity-40 dark:bg-gray-900 dark:text-gray-200'
>
<Square className='h-4 w-4' />
</button>
</div>
<select
value={ttsSettings.voice}
onChange={(e) => {
stopTts(true);
setTtsSettings((prev) => ({
...prev,
voice: e.target.value,
}));
}}
className='mb-4 w-full rounded-2xl border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100'
>
{ttsVoices.map((voice) => (
<option key={voice.shortName} value={voice.shortName}>
{voice.displayName || voice.shortName}
</option>
))}
</select>
<label className='mb-3 block text-xs text-gray-500'>
{ttsSettings.rate}
<input
type='range'
min={0}
max={TTS_RATE_STEPS.length - 1}
step={1}
value={Math.max(0, TTS_RATE_STEPS.indexOf(ttsRateValue))}
onChange={(e) => {
stopTts(true);
setTtsSettings((prev) => ({
...prev,
rate: formatSignedValue(
TTS_RATE_STEPS[Number(e.target.value)] ?? 0,
'%'
),
}));
}}
className='w-full'
/>
</label>
<label className='mb-3 block text-xs text-gray-500'>
{ttsSettings.pitch}
<input
type='range'
min={0}
max={TTS_PITCH_STEPS.length - 1}
step={1}
value={Math.max(0, TTS_PITCH_STEPS.indexOf(ttsPitchValue))}
onChange={(e) => {
stopTts(true);
setTtsSettings((prev) => ({
...prev,
pitch: formatSignedValue(
TTS_PITCH_STEPS[Number(e.target.value)] ?? 0,
'Hz'
),
}));
}}
className='w-full'
/>
</label>
<label className='block text-xs text-gray-500'>
{ttsSettings.volume}
<input
type='range'
min={0}
max={TTS_VOLUME_STEPS.length - 1}
step={1}
value={Math.max(
0,
TTS_VOLUME_STEPS.indexOf(ttsVolumeValue)
)}
onChange={(e) => {
stopTts(true);
setTtsSettings((prev) => ({
...prev,
volume: formatSignedValue(
TTS_VOLUME_STEPS[Number(e.target.value)] ?? 0,
'%'
),
}));
}}
className='w-full'
/>
</label>
{ttsError ? (
<div className='mt-3 text-xs text-red-500'>{ttsError}</div>
) : null}
</div>
</div>
) : null}
</>
) : null}
</div> </div>
); );
} }
@@ -991,9 +1716,18 @@ function normalizeHrefForMatch(href?: string) {
if (!href) return ''; if (!href) return '';
try { try {
const normalized = decodeURIComponent(href).replace(/\\/g, '/').trim(); const normalized = decodeURIComponent(href).replace(/\\/g, '/').trim();
return normalized.split('#')[0].split('?')[0].replace(/^\.\//, '').replace(/^\//, ''); return normalized
.split('#')[0]
.split('?')[0]
.replace(/^\.\//, '')
.replace(/^\//, '');
} catch { } catch {
return href.split('#')[0].split('?')[0].replace(/^\.\//, '').replace(/^\//, '').trim(); return href
.split('#')[0]
.split('?')[0]
.replace(/^\.\//, '')
.replace(/^\//, '')
.trim();
} }
} }
@@ -1001,7 +1735,9 @@ function isSameTocTarget(currentHref?: string, tocHref?: string) {
const current = normalizeHrefForMatch(currentHref); const current = normalizeHrefForMatch(currentHref);
const target = normalizeHrefForMatch(tocHref); const target = normalizeHrefForMatch(tocHref);
if (!current || !target) return false; if (!current || !target) return false;
return current === target || current.endsWith(target) || target.endsWith(current); return (
current === target || current.endsWith(target) || target.endsWith(current)
);
} }
function formatBytes(size: number): string { function formatBytes(size: number): string {
@@ -1014,7 +1750,10 @@ function formatDurationTime(value: number) {
const totalSeconds = Math.max(0, Math.floor(value || 0)); const totalSeconds = Math.max(0, Math.floor(value || 0));
const minutes = Math.floor(totalSeconds / 60); const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60; const seconds = totalSeconds % 60;
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(
2,
'0'
)}`;
} }
function sanitizeTtsText(text: string): string { function sanitizeTtsText(text: string): string {
@@ -1031,7 +1770,10 @@ function chunkTtsText(text: string, maxChars: number): TtsChunk[] {
const normalized = sanitizeTtsText(text); const normalized = sanitizeTtsText(text);
if (!normalized) return []; if (!normalized) return [];
const paragraphs = normalized.split(/\n{2,}/).map((item) => item.trim()).filter(Boolean); const paragraphs = normalized
.split(/\n{2,}/)
.map((item) => item.trim())
.filter(Boolean);
const chunks: TtsChunk[] = []; const chunks: TtsChunk[] = [];
let buffer = ''; let buffer = '';
let start = 0; let start = 0;
@@ -1070,7 +1812,10 @@ function chunkTtsText(text: string, maxChars: number): TtsChunk[] {
return; return;
} }
const sentences = trimmed.split(/(?<=[。!?!?;])/).map((item) => item.trim()).filter(Boolean); const sentences = trimmed
.split(/(?<=[。!?!?;])/)
.map((item) => item.trim())
.filter(Boolean);
let local = ''; let local = '';
let localStart = cursor; let localStart = cursor;
for (const sentence of sentences) { for (const sentence of sentences) {
@@ -1081,7 +1826,12 @@ function chunkTtsText(text: string, maxChars: number): TtsChunk[] {
cursor += sentence.length; cursor += sentence.length;
} else { } else {
if (local) { if (local) {
chunks.push({ index: chunks.length, text: local, start: localStart, end: localStart + local.length }); chunks.push({
index: chunks.length,
text: local,
start: localStart,
end: localStart + local.length,
});
local = ''; local = '';
} }
if (sentence.length <= maxChars) { if (sentence.length <= maxChars) {
@@ -1091,14 +1841,24 @@ function chunkTtsText(text: string, maxChars: number): TtsChunk[] {
} else { } else {
for (let i = 0; i < sentence.length; i += maxChars) { for (let i = 0; i < sentence.length; i += maxChars) {
const part = sentence.slice(i, i + maxChars); const part = sentence.slice(i, i + maxChars);
chunks.push({ index: chunks.length, text: part, start: cursor, end: cursor + part.length }); chunks.push({
index: chunks.length,
text: part,
start: cursor,
end: cursor + part.length,
});
cursor += part.length; cursor += part.length;
} }
} }
} }
} }
if (local) { if (local) {
chunks.push({ index: chunks.length, text: local, start: localStart, end: localStart + local.length }); chunks.push({
index: chunks.length,
text: local,
start: localStart,
end: localStart + local.length,
});
} }
}; };
@@ -1109,7 +1869,6 @@ function chunkTtsText(text: string, maxChars: number): TtsChunk[] {
return chunks; return chunks;
} }
function getRenditionOptions(mode: ReaderMode) { function getRenditionOptions(mode: ReaderMode) {
return mode === 'scrolled' return mode === 'scrolled'
? { ? {
@@ -1141,18 +1900,24 @@ export default function BookReadPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const sourceId = searchParams.get('sourceId') || ''; const sourceId = searchParams.get('sourceId') || '';
const bookId = searchParams.get('bookId') || ''; const bookId = searchParams.get('bookId') || '';
const cached = useMemo(() => (sourceId && bookId ? getBookRouteCache(sourceId, bookId) : null), [sourceId, bookId]); const cached = useMemo(
() => (sourceId && bookId ? getBookRouteCache(sourceId, bookId) : null),
[sourceId, bookId]
);
const [manifest, setManifest] = useState<BookReadManifest | null>(null); const [manifest, setManifest] = useState<BookReadManifest | null>(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [fileLoadState, setFileLoadState] = useState<FileLoadState>('preparing'); const [fileLoadState, setFileLoadState] =
useState<FileLoadState>('preparing');
const [downloadedBytes, setDownloadedBytes] = useState(0); const [downloadedBytes, setDownloadedBytes] = useState(0);
const [totalBytes, setTotalBytes] = useState<number | null>(null); const [totalBytes, setTotalBytes] = useState<number | null>(null);
const [cacheHit, setCacheHit] = useState(false); const [cacheHit, setCacheHit] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [tocOpen, setTocOpen] = useState(false); const [tocOpen, setTocOpen] = useState(false);
const [settings, setSettings] = useState<ReaderSettings>(DEFAULT_SETTINGS); const [settings, setSettings] = useState<ReaderSettings>(DEFAULT_SETTINGS);
const [ttsSettings, setTtsSettings] = useState<TtsSettings>(() => loadTtsSettings()); const [ttsSettings, setTtsSettings] = useState<TtsSettings>(() =>
loadTtsSettings()
);
const [tocItems, setTocItems] = useState<TocItem[]>([]); const [tocItems, setTocItems] = useState<TocItem[]>([]);
const [currentHref, setCurrentHref] = useState(''); const [currentHref, setCurrentHref] = useState('');
const [currentChapter, setCurrentChapter] = useState(''); const [currentChapter, setCurrentChapter] = useState('');
@@ -1165,7 +1930,9 @@ export default function BookReadPage() {
const [ttsError, setTtsError] = useState(''); const [ttsError, setTtsError] = useState('');
const [ttsChunks, setTtsChunks] = useState<TtsChunk[]>([]); const [ttsChunks, setTtsChunks] = useState<TtsChunk[]>([]);
const [ttsCurrentChunkIndex, setTtsCurrentChunkIndex] = useState(0); const [ttsCurrentChunkIndex, setTtsCurrentChunkIndex] = useState(0);
const [ttsLoadingChunkIndex, setTtsLoadingChunkIndex] = useState<number | null>(null); const [ttsLoadingChunkIndex, setTtsLoadingChunkIndex] = useState<
number | null
>(null);
const [ttsCurrentChapterHref, setTtsCurrentChapterHref] = useState(''); const [ttsCurrentChapterHref, setTtsCurrentChapterHref] = useState('');
const [ttsCurrentChapterTitle, setTtsCurrentChapterTitle] = useState(''); const [ttsCurrentChapterTitle, setTtsCurrentChapterTitle] = useState('');
const [ttsBarVisible, setTtsBarVisible] = useState(false); const [ttsBarVisible, setTtsBarVisible] = useState(false);
@@ -1176,7 +1943,9 @@ export default function BookReadPage() {
const [ttsSeeking, setTtsSeeking] = useState(false); const [ttsSeeking, setTtsSeeking] = useState(false);
const [scrolledBottomReached, setScrolledBottomReached] = useState(false); const [scrolledBottomReached, setScrolledBottomReached] = useState(false);
const viewerRef = useRef<HTMLDivElement | null>(null); const viewerRef = useRef<HTMLDivElement | null>(null);
const pendingScrolledRestoreRef = useRef<ScrolledReadingPosition | null>(null); const pendingScrolledRestoreRef = useRef<ScrolledReadingPosition | null>(
null
);
const restoreTargetRef = useRef<string | undefined>(undefined); const restoreTargetRef = useRef<string | undefined>(undefined);
const scrollListenerCleanupRef = useRef<(() => void) | null>(null); const scrollListenerCleanupRef = useRef<(() => void) | null>(null);
const scrolledAutoAdvanceLockRef = useRef(false); const scrolledAutoAdvanceLockRef = useRef(false);
@@ -1201,7 +1970,9 @@ export default function BookReadPage() {
const currentHrefRef = useRef(''); const currentHrefRef = useRef('');
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
const ttsChunkAudioUrlRef = useRef<Record<number, string>>({}); const ttsChunkAudioUrlRef = useRef<Record<number, string>>({});
const ttsChunkBlobCacheRef = useRef<Record<number, { url: string; text: string }>>({}); const ttsChunkBlobCacheRef = useRef<
Record<number, { url: string; text: string }>
>({});
const ttsChunksRef = useRef<TtsChunk[]>([]); const ttsChunksRef = useRef<TtsChunk[]>([]);
const ttsSettingsRef = useRef<TtsSettings>(DEFAULT_TTS_SETTINGS); const ttsSettingsRef = useRef<TtsSettings>(DEFAULT_TTS_SETTINGS);
const ttsCurrentChunkIndexRef = useRef(0); const ttsCurrentChunkIndexRef = useRef(0);
@@ -1226,7 +1997,10 @@ export default function BookReadPage() {
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
localStorage.setItem(TTS_SETTINGS_STORAGE_KEY, JSON.stringify(ttsSettings)); localStorage.setItem(
TTS_SETTINGS_STORAGE_KEY,
JSON.stringify(ttsSettings)
);
} }
ttsSettingsRef.current = ttsSettings; ttsSettingsRef.current = ttsSettings;
}, [ttsSettings]); }, [ttsSettings]);
@@ -1250,7 +2024,6 @@ export default function BookReadPage() {
scrolledBottomReachedRef.current = scrolledBottomReached; scrolledBottomReachedRef.current = scrolledBottomReached;
}, [scrolledBottomReached]); }, [scrolledBottomReached]);
useEffect(() => { useEffect(() => {
const handleToggleSettings = () => { const handleToggleSettings = () => {
if (manifest?.format === 'chapters') return; if (manifest?.format === 'chapters') return;
@@ -1260,7 +2033,10 @@ export default function BookReadPage() {
window.addEventListener('books-read-toggle-settings', handleToggleSettings); window.addEventListener('books-read-toggle-settings', handleToggleSettings);
return () => { return () => {
window.removeEventListener('books-read-toggle-settings', handleToggleSettings); window.removeEventListener(
'books-read-toggle-settings',
handleToggleSettings
);
}; };
}, [manifest?.format]); }, [manifest?.format]);
@@ -1273,7 +2049,10 @@ export default function BookReadPage() {
window.addEventListener('books-read-toggle-chapters', handleToggleChapters); window.addEventListener('books-read-toggle-chapters', handleToggleChapters);
return () => { return () => {
window.removeEventListener('books-read-toggle-chapters', handleToggleChapters); window.removeEventListener(
'books-read-toggle-chapters',
handleToggleChapters
);
}; };
}, [manifest?.format]); }, [manifest?.format]);
@@ -1297,7 +2076,6 @@ export default function BookReadPage() {
}; };
}, [manifest?.format]); }, [manifest?.format]);
useEffect(() => { useEffect(() => {
if (!sourceId || !bookId) return; if (!sourceId || !bookId) return;
fetch('/api/books/read/manifest', { fetch('/api/books/read/manifest', {
@@ -1342,7 +2120,10 @@ export default function BookReadPage() {
setTtsAvailable(true); setTtsAvailable(true);
setTtsVoices(json.voices || []); setTtsVoices(json.voices || []);
setTtsSettings((prev) => applyTtsDefaults(prev, json.defaults)); setTtsSettings((prev) => applyTtsDefaults(prev, json.defaults));
saveCachedTtsVoices({ voices: json.voices || [], defaults: json.defaults || {} }); saveCachedTtsVoices({
voices: json.voices || [],
defaults: json.defaults || {},
});
}) })
.catch((err) => { .catch((err) => {
if (cancelled) return; if (cancelled) return;
@@ -1355,43 +2136,54 @@ export default function BookReadPage() {
}; };
}, [manifest]); }, [manifest]);
const buildReadRecord = useCallback((location: EpubLocation, nextProgress = 0, chapterTitle?: string): BookReadRecord | null => { const buildReadRecord = useCallback(
if (!manifest) return null; (
const locatorValue = location?.start?.cfi || location?.end?.cfi || ''; location: EpubLocation,
if (!locatorValue) return null; nextProgress = 0,
return { chapterTitle?: string
sourceId: manifest.book.sourceId, ): BookReadRecord | null => {
sourceName: manifest.book.sourceName, if (!manifest) return null;
bookId: manifest.book.id, const locatorValue = location?.start?.cfi || location?.end?.cfi || '';
title: manifest.book.title, if (!locatorValue) return null;
author: manifest.book.author, return {
cover: manifest.book.cover, sourceId: manifest.book.sourceId,
detailHref: manifest.book.detailHref, sourceName: manifest.book.sourceName,
acquisitionHref: manifest.acquisitionHref, bookId: manifest.book.id,
format: manifest.format, title: manifest.book.title,
locator: { author: manifest.book.author,
type: 'epub-cfi', cover: manifest.book.cover,
value: locatorValue, detailHref: manifest.book.detailHref,
href: location?.start?.href, acquisitionHref: manifest.acquisitionHref,
format: manifest.format,
locator: {
type: 'epub-cfi',
value: locatorValue,
href: location?.start?.href,
chapterTitle,
},
chapterTitle, chapterTitle,
}, chapterHref: location?.start?.href,
chapterTitle, progressPercent: nextProgress,
chapterHref: location?.start?.href, saveTime: Date.now(),
progressPercent: nextProgress, };
saveTime: Date.now(), },
}; [manifest]
}, [manifest]); );
const queueReadRecord = useCallback((location: EpubLocation, nextProgress = 0, chapterTitle?: string) => { const queueReadRecord = useCallback(
const record = buildReadRecord(location, nextProgress, chapterTitle); (location: EpubLocation, nextProgress = 0, chapterTitle?: string) => {
if (!record) return; const record = buildReadRecord(location, nextProgress, chapterTitle);
pendingRecordRef.current = record; if (!record) return;
pendingRecordDirtyRef.current = true; pendingRecordRef.current = record;
}, [buildReadRecord]); pendingRecordDirtyRef.current = true;
},
[buildReadRecord]
);
const flushPendingReadRecord = useCallback(async () => { const flushPendingReadRecord = useCallback(async () => {
const record = pendingRecordRef.current; const record = pendingRecordRef.current;
if (!record || !pendingRecordDirtyRef.current || saveInFlightRef.current) return; if (!record || !pendingRecordDirtyRef.current || saveInFlightRef.current)
return;
saveInFlightRef.current = true; saveInFlightRef.current = true;
try { try {
@@ -1408,21 +2200,26 @@ export default function BookReadPage() {
} }
}, []); }, []);
const persistScrolledPosition = useCallback(
const persistScrolledPosition = useCallback((fallbackHref?: string) => { (fallbackHref?: string) => {
if (!manifest || settingsRef.current.mode !== 'scrolled') return; if (!manifest || settingsRef.current.mode !== 'scrolled') return;
const metrics = getIframeScrollMetrics(viewerRef.current); const metrics = getIframeScrollMetrics(viewerRef.current);
const href = fallbackHref || currentHrefRef.current || lastLocationRef.current?.start?.href || ''; const href =
if (!metrics || !href) return; fallbackHref ||
saveScrolledPosition(manifest.book.sourceId, manifest.book.id, { currentHrefRef.current ||
href, lastLocationRef.current?.start?.href ||
scrollTop: metrics.scrollTop, '';
scrollHeight: metrics.scrollHeight, if (!metrics || !href) return;
clientHeight: metrics.clientHeight, saveScrolledPosition(manifest.book.sourceId, manifest.book.id, {
updatedAt: Date.now(), href,
}); scrollTop: metrics.scrollTop,
}, [manifest]); scrollHeight: metrics.scrollHeight,
clientHeight: metrics.clientHeight,
updatedAt: Date.now(),
});
},
[manifest]
);
const applyPendingScrolledRestore = useCallback(() => { const applyPendingScrolledRestore = useCallback(() => {
if (settingsRef.current.mode !== 'scrolled') return; if (settingsRef.current.mode !== 'scrolled') return;
@@ -1430,25 +2227,30 @@ export default function BookReadPage() {
if (!pending) return; if (!pending) return;
const metrics = getIframeScrollMetrics(viewerRef.current); const metrics = getIframeScrollMetrics(viewerRef.current);
if (!metrics) return; if (!metrics) return;
const currentHrefValue = lastLocationRef.current?.start?.href || currentHrefRef.current; const currentHrefValue =
if (!currentHrefValue || !isSameTocTarget(currentHrefValue, pending.href)) return; lastLocationRef.current?.start?.href || currentHrefRef.current;
const targetScrollTop = computeScrolledTargetScrollTop(pending, metrics.scrollHeight, metrics.clientHeight); if (!currentHrefValue || !isSameTocTarget(currentHrefValue, pending.href))
return;
const targetScrollTop = computeScrolledTargetScrollTop(
pending,
metrics.scrollHeight,
metrics.clientHeight
);
metrics.setScrollTop(targetScrollTop); metrics.setScrollTop(targetScrollTop);
pendingScrolledRestoreRef.current = null; pendingScrolledRestoreRef.current = null;
}, []); }, []);
useEffect(() => { useEffect(() => {
applyPendingScrolledRestoreRef.current = applyPendingScrolledRestore; applyPendingScrolledRestoreRef.current = applyPendingScrolledRestore;
}, [applyPendingScrolledRestore]); }, [applyPendingScrolledRestore]);
const persistCurrentProgress = useCallback(() => { const persistCurrentProgress = useCallback(() => {
if (lastLocationRef.current) { if (lastLocationRef.current) {
queueReadRecord(lastLocationRef.current, lastProgressRef.current, lastChapterRef.current); queueReadRecord(
lastLocationRef.current,
lastProgressRef.current,
lastChapterRef.current
);
} }
persistScrolledPosition(); persistScrolledPosition();
void flushPendingReadRecord(); void flushPendingReadRecord();
@@ -1483,233 +2285,285 @@ export default function BookReadPage() {
await renditionRef.current.display(target); await renditionRef.current.display(target);
}, []); }, []);
const handleReaderTap = useCallback((zone: 'left' | 'center' | 'right') => { const handleReaderTap = useCallback(
if (!ready) return; (zone: 'left' | 'center' | 'right') => {
if (settings.mode === 'paginated' && zone === 'left') { if (!ready) return;
renditionRef.current?.prev?.(); if (settings.mode === 'paginated' && zone === 'left') {
return; renditionRef.current?.prev?.();
} return;
if (settings.mode === 'paginated' && zone === 'right') { }
renditionRef.current?.next?.(); if (settings.mode === 'paginated' && zone === 'right') {
return; renditionRef.current?.next?.();
} return;
setTocOpen(false); }
setSettingsOpen(false); setTocOpen(false);
}, [ready, settings.mode]); setSettingsOpen(false);
},
[ready, settings.mode]
);
const cleanupTtsAudioUrls = useCallback(() => { const cleanupTtsAudioUrls = useCallback(() => {
Object.values(ttsChunkBlobCacheRef.current).forEach((item) => URL.revokeObjectURL(item.url)); Object.values(ttsChunkBlobCacheRef.current).forEach((item) =>
URL.revokeObjectURL(item.url)
);
ttsChunkBlobCacheRef.current = {}; ttsChunkBlobCacheRef.current = {};
ttsChunkAudioUrlRef.current = {}; ttsChunkAudioUrlRef.current = {};
}, []); }, []);
const stopTts = useCallback((clearQueue = false) => { const stopTts = useCallback(
const audio = audioRef.current; (clearQueue = false) => {
if (audio) { const audio = audioRef.current;
audio.pause(); if (audio) {
audio.removeAttribute('src'); audio.pause();
audio.load(); audio.removeAttribute('src');
} audio.load();
setTtsCurrentTime(0); }
setTtsDuration(0); setTtsCurrentTime(0);
setTtsSeekValue(0); setTtsDuration(0);
setTtsSeeking(false); setTtsSeekValue(0);
ttsPrefetchedFromChunkRef.current = null; setTtsSeeking(false);
setTtsLoadingChunkIndex(null); ttsPrefetchedFromChunkRef.current = null;
setTtsStatus('idle'); setTtsLoadingChunkIndex(null);
if (clearQueue) { setTtsStatus('idle');
setTtsChunks([]); if (clearQueue) {
ttsChunksRef.current = []; setTtsChunks([]);
setTtsCurrentChunkIndex(0); ttsChunksRef.current = [];
ttsCurrentChunkIndexRef.current = 0; setTtsCurrentChunkIndex(0);
setTtsCurrentChapterHref(''); ttsCurrentChunkIndexRef.current = 0;
ttsCurrentChapterHrefRef.current = ''; setTtsCurrentChapterHref('');
setTtsCurrentChapterTitle(''); ttsCurrentChapterHrefRef.current = '';
ttsCurrentChapterTitleRef.current = ''; setTtsCurrentChapterTitle('');
cleanupTtsAudioUrls(); ttsCurrentChapterTitleRef.current = '';
} cleanupTtsAudioUrls();
}, [cleanupTtsAudioUrls]); }
},
[cleanupTtsAudioUrls]
);
const persistTtsProgress = useCallback((chunkIndex?: number) => { const persistTtsProgress = useCallback(
if (!manifest) return; (chunkIndex?: number) => {
const chunks = ttsChunksRef.current; if (!manifest) return;
const currentIndex = chunkIndex ?? ttsCurrentChunkIndexRef.current; const chunks = ttsChunksRef.current;
const chunk = chunks[currentIndex]; const currentIndex = chunkIndex ?? ttsCurrentChunkIndexRef.current;
if (!chunk || !ttsCurrentChapterHrefRef.current || !ttsSettingsRef.current.voice) return; const chunk = chunks[currentIndex];
const progress: BookTtsProgress = { if (
sourceId: manifest.book.sourceId, !chunk ||
bookId: manifest.book.id, !ttsCurrentChapterHrefRef.current ||
chapterHref: ttsCurrentChapterHrefRef.current, !ttsSettingsRef.current.voice
chapterTitle: ttsCurrentChapterTitleRef.current || currentChapter, )
chunkIndex: currentIndex, return;
charOffset: chunk.start, const progress: BookTtsProgress = {
currentTimeSec: audioRef.current?.currentTime || 0, sourceId: manifest.book.sourceId,
voice: ttsSettingsRef.current.voice, bookId: manifest.book.id,
rate: ttsSettingsRef.current.rate, chapterHref: ttsCurrentChapterHrefRef.current,
pitch: ttsSettingsRef.current.pitch, chapterTitle: ttsCurrentChapterTitleRef.current || currentChapter,
volume: ttsSettingsRef.current.volume, chunkIndex: currentIndex,
saveTime: Date.now(), charOffset: chunk.start,
}; currentTimeSec: audioRef.current?.currentTime || 0,
saveBookTtsProgress(progress); voice: ttsSettingsRef.current.voice,
}, [manifest, currentChapter]); rate: ttsSettingsRef.current.rate,
pitch: ttsSettingsRef.current.pitch,
volume: ttsSettingsRef.current.volume,
saveTime: Date.now(),
};
saveBookTtsProgress(progress);
},
[manifest, currentChapter]
);
const getCurrentSpineDocumentText = useCallback(() => { const getCurrentSpineDocumentText = useCallback(() => {
const iframe = viewerRef.current?.querySelector('iframe'); const iframe = viewerRef.current?.querySelector('iframe');
const doc = iframe?.contentDocument; const doc = iframe?.contentDocument;
const text = doc?.body?.innerText || doc?.documentElement?.textContent || ''; const text =
doc?.body?.innerText || doc?.documentElement?.textContent || '';
return sanitizeTtsText(text); return sanitizeTtsText(text);
}, []); }, []);
const fetchTtsChunkAudioUrl = useCallback(async (chunk: TtsChunk, chapterHref: string) => { const fetchTtsChunkAudioUrl = useCallback(
const cached = ttsChunkBlobCacheRef.current[chunk.index]; async (chunk: TtsChunk, chapterHref: string) => {
if (cached?.text === chunk.text) return cached.url; const cached = ttsChunkBlobCacheRef.current[chunk.index];
if (!manifest) throw new Error('书籍信息未准备好'); if (cached?.text === chunk.text) return cached.url;
const { cacheKey, textHash } = await buildBookTtsCacheKey({ if (!manifest) throw new Error('书籍信息未准备好');
sourceId: manifest.book.sourceId, const { cacheKey, textHash } = await buildBookTtsCacheKey({
bookId: manifest.book.id,
chapterHref,
chunkIndex: chunk.index,
text: chunk.text,
voice: ttsSettingsRef.current.voice,
rate: ttsSettingsRef.current.rate,
pitch: ttsSettingsRef.current.pitch,
volume: ttsSettingsRef.current.volume,
});
const persisted = await getCachedBookTtsChunk(cacheKey).catch(() => null);
if (persisted?.audioBlob) {
const url = URL.createObjectURL(persisted.audioBlob);
ttsChunkBlobCacheRef.current[chunk.index] = { url, text: chunk.text };
ttsChunkAudioUrlRef.current[chunk.index] = url;
void touchCachedBookTtsChunk(cacheKey).catch(() => undefined);
return url;
}
const response = await fetch('/api/books/tts/synthesize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceId: manifest.book.sourceId, sourceId: manifest.book.sourceId,
bookId: manifest.book.id, bookId: manifest.book.id,
chapterHref, chapterHref,
chunkIndex: chunk.index,
text: chunk.text, text: chunk.text,
voice: ttsSettingsRef.current.voice, voice: ttsSettingsRef.current.voice,
rate: ttsSettingsRef.current.rate, rate: ttsSettingsRef.current.rate,
pitch: ttsSettingsRef.current.pitch, pitch: ttsSettingsRef.current.pitch,
volume: ttsSettingsRef.current.volume, volume: ttsSettingsRef.current.volume,
}), });
});
const json = await response.json();
if (!response.ok) throw new Error(json.error || '朗读音频生成失败');
const blob = decodeBase64Audio(json.audioBase64 || '', json.mimeType || 'audio/mpeg');
const url = URL.createObjectURL(blob);
if (cached?.url) URL.revokeObjectURL(cached.url);
ttsChunkBlobCacheRef.current[chunk.index] = { url, text: chunk.text };
ttsChunkAudioUrlRef.current[chunk.index] = url;
void putCachedBookTtsChunk({
cacheKey,
sourceId: manifest.book.sourceId,
bookId: manifest.book.id,
chapterHref,
chunkIndex: chunk.index,
textHash,
voice: ttsSettingsRef.current.voice,
rate: ttsSettingsRef.current.rate,
pitch: ttsSettingsRef.current.pitch,
volume: ttsSettingsRef.current.volume,
textPreview: chunk.text.slice(0, 80),
mimeType: json.mimeType || 'audio/mpeg',
audioBlob: blob,
size: blob.size,
createdAt: Date.now(),
lastAccessAt: Date.now(),
})
.then(() => enforceBookTtsCacheLimit())
.catch(() => undefined);
return url;
}, [manifest]);
const prefetchTtsChunks = useCallback((fromIndex: number) => { const persisted = await getCachedBookTtsChunk(cacheKey).catch(() => null);
const chunks = ttsChunksRef.current; if (persisted?.audioBlob) {
const chapterHref = ttsCurrentChapterHrefRef.current; const url = URL.createObjectURL(persisted.audioBlob);
if (!ttsSettingsRef.current.autoPlayNext || !chapterHref) return; ttsChunkBlobCacheRef.current[chunk.index] = { url, text: chunk.text };
if (ttsPrefetchedFromChunkRef.current === fromIndex) return; ttsChunkAudioUrlRef.current[chunk.index] = url;
const nextIndex = fromIndex + 1; void touchCachedBookTtsChunk(cacheKey).catch(() => undefined);
if (nextIndex >= chunks.length) return; return url;
ttsPrefetchedFromChunkRef.current = fromIndex; }
void fetchTtsChunkAudioUrl(chunks[nextIndex], chapterHref).catch(() => undefined);
}, [fetchTtsChunkAudioUrl]); const response = await fetch('/api/books/tts/synthesize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceId: manifest.book.sourceId,
bookId: manifest.book.id,
chapterHref,
text: chunk.text,
voice: ttsSettingsRef.current.voice,
rate: ttsSettingsRef.current.rate,
pitch: ttsSettingsRef.current.pitch,
volume: ttsSettingsRef.current.volume,
}),
});
const json = await response.json();
if (!response.ok) throw new Error(json.error || '朗读音频生成失败');
const blob = decodeBase64Audio(
json.audioBase64 || '',
json.mimeType || 'audio/mpeg'
);
const url = URL.createObjectURL(blob);
if (cached?.url) URL.revokeObjectURL(cached.url);
ttsChunkBlobCacheRef.current[chunk.index] = { url, text: chunk.text };
ttsChunkAudioUrlRef.current[chunk.index] = url;
void putCachedBookTtsChunk({
cacheKey,
sourceId: manifest.book.sourceId,
bookId: manifest.book.id,
chapterHref,
chunkIndex: chunk.index,
textHash,
voice: ttsSettingsRef.current.voice,
rate: ttsSettingsRef.current.rate,
pitch: ttsSettingsRef.current.pitch,
volume: ttsSettingsRef.current.volume,
textPreview: chunk.text.slice(0, 80),
mimeType: json.mimeType || 'audio/mpeg',
audioBlob: blob,
size: blob.size,
createdAt: Date.now(),
lastAccessAt: Date.now(),
})
.then(() => enforceBookTtsCacheLimit())
.catch(() => undefined);
return url;
},
[manifest]
);
const prefetchTtsChunks = useCallback(
(fromIndex: number) => {
const chunks = ttsChunksRef.current;
const chapterHref = ttsCurrentChapterHrefRef.current;
if (!ttsSettingsRef.current.autoPlayNext || !chapterHref) return;
if (ttsPrefetchedFromChunkRef.current === fromIndex) return;
const nextIndex = fromIndex + 1;
if (nextIndex >= chunks.length) return;
ttsPrefetchedFromChunkRef.current = fromIndex;
void fetchTtsChunkAudioUrl(chunks[nextIndex], chapterHref).catch(
() => undefined
);
},
[fetchTtsChunkAudioUrl]
);
useEffect(() => { useEffect(() => {
ttsPrefetchFnRef.current = prefetchTtsChunks; ttsPrefetchFnRef.current = prefetchTtsChunks;
}, [prefetchTtsChunks]); }, [prefetchTtsChunks]);
const playTtsChunk = useCallback(async (index: number) => { const playTtsChunk = useCallback(
const chunks = ttsChunksRef.current; async (index: number) => {
const chunk = chunks[index]; const chunks = ttsChunksRef.current;
const chapterHref = ttsCurrentChapterHrefRef.current; const chunk = chunks[index];
if (!chunk || !chapterHref || !manifest) return; const chapterHref = ttsCurrentChapterHrefRef.current;
try { if (!chunk || !chapterHref || !manifest) return;
setTtsError(''); try {
setTtsLoadingChunkIndex(index); setTtsError('');
setTtsStatus('loading'); setTtsLoadingChunkIndex(index);
const url = await fetchTtsChunkAudioUrl(chunk, chapterHref); setTtsStatus('loading');
if (!audioRef.current) { const url = await fetchTtsChunkAudioUrl(chunk, chapterHref);
audioRef.current = new Audio(); if (!audioRef.current) {
audioRef.current = new Audio();
}
audioRef.current.src = url;
ttsResumeTimeRef.current = 0;
const saved = getBookTtsProgress(
manifest.book.sourceId,
manifest.book.id
);
if (saved?.chapterHref === chapterHref && saved.chunkIndex === index) {
ttsResumeTimeRef.current = saved.currentTimeSec || 0;
}
await audioRef.current.play();
ttsCurrentChunkIndexRef.current = index;
setTtsCurrentChunkIndex(index);
setTtsStatus('playing');
setTtsLoadingChunkIndex(null);
persistTtsProgress(index);
} catch (error) {
setTtsStatus('error');
setTtsLoadingChunkIndex(null);
setTtsError((error as Error).message || '朗读失败');
} }
audioRef.current.src = url; },
ttsResumeTimeRef.current = 0; [fetchTtsChunkAudioUrl, manifest, persistTtsProgress]
const saved = getBookTtsProgress(manifest.book.sourceId, manifest.book.id); );
if (saved?.chapterHref === chapterHref && saved.chunkIndex === index) {
ttsResumeTimeRef.current = saved.currentTimeSec || 0;
}
await audioRef.current.play();
ttsCurrentChunkIndexRef.current = index;
setTtsCurrentChunkIndex(index);
setTtsStatus('playing');
setTtsLoadingChunkIndex(null);
persistTtsProgress(index);
} catch (error) {
setTtsStatus('error');
setTtsLoadingChunkIndex(null);
setTtsError((error as Error).message || '朗读失败');
}
}, [fetchTtsChunkAudioUrl, manifest, persistTtsProgress]);
const bootstrapTtsForCurrentChapter = useCallback(async (resume = true) => { const bootstrapTtsForCurrentChapter = useCallback(
if (!manifest || manifest.format !== 'epub') return; async (resume = true) => {
const chapterHref = currentHref || manifest.lastRecord?.chapterHref || ''; if (!manifest || manifest.format !== 'epub') return;
const chapterTitle = findTocLabelByHref(tocItemsRef.current, chapterHref) || currentChapter || manifest.book.title; const chapterHref = currentHref || manifest.lastRecord?.chapterHref || '';
if (!chapterHref) { const chapterTitle =
setTtsError('当前章节尚未定位,稍后再试'); findTocLabelByHref(tocItemsRef.current, chapterHref) ||
setTtsStatus('error'); currentChapter ||
return; manifest.book.title;
} if (!chapterHref) {
const text = getCurrentSpineDocumentText(); setTtsError('当前章节尚未定位,稍后再试');
if (!text) { setTtsStatus('error');
setTtsError('当前章节暂未提取到可朗读文本'); return;
setTtsStatus('error'); }
return; const text = getCurrentSpineDocumentText();
} if (!text) {
cleanupTtsAudioUrls(); setTtsError('当前章节暂未提取到可朗读文本');
const chunks = chunkTtsText(text, 1200); setTtsStatus('error');
if (chunks.length === 0) { return;
setTtsError('当前章节没有可朗读内容'); }
setTtsStatus('error'); cleanupTtsAudioUrls();
return; const chunks = chunkTtsText(text, 1200);
} if (chunks.length === 0) {
const saved = resume ? getBookTtsProgress(manifest.book.sourceId, manifest.book.id) : null; setTtsError('当前章节没有可朗读内容');
const startIndex = saved?.chapterHref === chapterHref ? Math.min(saved.chunkIndex, chunks.length - 1) : 0; setTtsStatus('error');
setTtsChunks(chunks); return;
ttsChunksRef.current = chunks; }
setTtsCurrentChunkIndex(startIndex); const saved = resume
ttsCurrentChunkIndexRef.current = startIndex; ? getBookTtsProgress(manifest.book.sourceId, manifest.book.id)
setTtsCurrentChapterHref(chapterHref); : null;
ttsCurrentChapterHrefRef.current = chapterHref; const startIndex =
setTtsCurrentChapterTitle(chapterTitle); saved?.chapterHref === chapterHref
ttsCurrentChapterTitleRef.current = chapterTitle; ? Math.min(saved.chunkIndex, chunks.length - 1)
await playTtsChunk(startIndex); : 0;
}, [cleanupTtsAudioUrls, currentChapter, currentHref, getCurrentSpineDocumentText, manifest, playTtsChunk]); setTtsChunks(chunks);
ttsChunksRef.current = chunks;
setTtsCurrentChunkIndex(startIndex);
ttsCurrentChunkIndexRef.current = startIndex;
setTtsCurrentChapterHref(chapterHref);
ttsCurrentChapterHrefRef.current = chapterHref;
setTtsCurrentChapterTitle(chapterTitle);
ttsCurrentChapterTitleRef.current = chapterTitle;
await playTtsChunk(startIndex);
},
[
cleanupTtsAudioUrls,
currentChapter,
currentHref,
getCurrentSpineDocumentText,
manifest,
playTtsChunk,
]
);
const toggleTtsPlayback = useCallback(async () => { const toggleTtsPlayback = useCallback(async () => {
if (!ttsAvailable) return; if (!ttsAvailable) return;
@@ -1730,46 +2584,70 @@ export default function BookReadPage() {
return; return;
} }
await bootstrapTtsForCurrentChapter(true); await bootstrapTtsForCurrentChapter(true);
}, [bootstrapTtsForCurrentChapter, persistTtsProgress, ttsAvailable, ttsStatus]); }, [
bootstrapTtsForCurrentChapter,
persistTtsProgress,
ttsAvailable,
ttsStatus,
]);
useEffect(() => { useEffect(() => {
if (!manifest || manifest.format !== 'epub' || !viewerRef.current) return; if (!manifest || manifest.format !== 'epub' || !viewerRef.current) return;
let destroyed = false; let destroyed = false;
const currentSessionCfi = lastLocationRef.current?.start?.cfi || undefined; const currentSessionCfi = lastLocationRef.current?.start?.cfi || undefined;
const currentSessionHref = currentHrefRef.current || lastLocationRef.current?.start?.href || undefined; const currentSessionHref =
currentHrefRef.current ||
lastLocationRef.current?.start?.href ||
undefined;
setReady(false); setReady(false);
setRestoredMessage(''); setRestoredMessage('');
locationsReadyRef.current = false; locationsReadyRef.current = false;
lastLocationRef.current = null; lastLocationRef.current = null;
setProgressPercent(manifest.lastRecord?.progressPercent || 0); setProgressPercent(manifest.lastRecord?.progressPercent || 0);
setCurrentChapter(manifest.lastRecord?.chapterTitle || manifest.lastRecord?.locator?.chapterTitle || ''); setCurrentChapter(
manifest.lastRecord?.chapterTitle ||
manifest.lastRecord?.locator?.chapterTitle ||
''
);
setFileLoadState('checking-cache'); setFileLoadState('checking-cache');
setDownloadedBytes(0); setDownloadedBytes(0);
setTotalBytes(null); setTotalBytes(null);
setCacheHit(false); setCacheHit(false);
const initialScrolledHref = currentSessionHref || manifest.lastRecord?.chapterHref || manifest.lastRecord?.locator?.href || undefined; const initialScrolledHref =
const cachedScrolledPosition = initialScrolledHref ? getScrolledPosition(manifest.book.sourceId, manifest.book.id, initialScrolledHref) : null; currentSessionHref ||
pendingScrolledRestoreRef.current = settings.mode === 'scrolled' && !currentSessionHref ? cachedScrolledPosition : null; manifest.lastRecord?.chapterHref ||
restoreTargetRef.current = settings.mode === 'scrolled' manifest.lastRecord?.locator?.href ||
? (initialScrolledHref || cachedScrolledPosition?.href || undefined) undefined;
: (currentSessionCfi || manifest.lastRecord?.locator?.value || undefined); const cachedScrolledPosition = initialScrolledHref
? getScrolledPosition(
manifest.book.sourceId,
manifest.book.id,
initialScrolledHref
)
: null;
pendingScrolledRestoreRef.current =
settings.mode === 'scrolled' && !currentSessionHref
? cachedScrolledPosition
: null;
restoreTargetRef.current =
settings.mode === 'scrolled'
? initialScrolledHref || cachedScrolledPosition?.href || undefined
: currentSessionCfi || manifest.lastRecord?.locator?.value || undefined;
loadEpubScript() loadEpubScript()
.then(async () => { .then(async () => {
if (!window.ePub || destroyed || !viewerRef.current) return; if (!window.ePub || destroyed || !viewerRef.current) return;
const cacheKey = manifest.cacheKey || buildBookCacheKey( const cacheKey =
manifest.book.sourceId, manifest.cacheKey ||
manifest.book.id, buildBookCacheKey(
manifest.acquisitionHref || `${manifest.book.sourceId}:${manifest.book.id}:${manifest.format}` manifest.book.sourceId,
); manifest.book.id,
manifest.acquisitionHref ||
`${manifest.book.sourceId}:${manifest.book.id}:${manifest.format}`
);
let fileBuffer: ArrayBuffer; let fileBuffer: ArrayBuffer;
const cached = await getCachedBookFile(cacheKey).catch(() => null); const cached = await getCachedBookFile(cacheKey).catch(() => null);
@@ -1782,12 +2660,15 @@ export default function BookReadPage() {
fileBuffer = await cached.blob.arrayBuffer(); fileBuffer = await cached.blob.arrayBuffer();
} else { } else {
setFileLoadState('downloading'); setFileLoadState('downloading');
const blob = await downloadBookWithProgress(manifest, (received, total) => { const blob = await downloadBookWithProgress(
if (!destroyed) { manifest,
setDownloadedBytes(received); (received, total) => {
setTotalBytes(total); if (!destroyed) {
setDownloadedBytes(received);
setTotalBytes(total);
}
} }
}); );
fileBuffer = await blob.arrayBuffer(); fileBuffer = await blob.arrayBuffer();
await putCachedBookFile({ await putCachedBookFile({
key: cacheKey, key: cacheKey,
@@ -1795,7 +2676,9 @@ export default function BookReadPage() {
bookId: manifest.book.id, bookId: manifest.book.id,
title: manifest.book.title, title: manifest.book.title,
format: 'epub', format: 'epub',
acquisitionHref: manifest.acquisitionHref || `${manifest.book.sourceId}:${manifest.book.id}:${manifest.format}`, acquisitionHref:
manifest.acquisitionHref ||
`${manifest.book.sourceId}:${manifest.book.id}:${manifest.format}`,
blob, blob,
size: blob.size, size: blob.size,
mimeType: blob.type || 'application/epub+zip', mimeType: blob.type || 'application/epub+zip',
@@ -1816,7 +2699,10 @@ export default function BookReadPage() {
} }
}, 4000); }, 4000);
const rendition = book.renderTo(viewerRef.current, getRenditionOptions(settings.mode)); const rendition = book.renderTo(
viewerRef.current,
getRenditionOptions(settings.mode)
);
bookRef.current = book; bookRef.current = book;
renditionRef.current = rendition; renditionRef.current = rendition;
applyReaderTheme(settingsRef.current); applyReaderTheme(settingsRef.current);
@@ -1832,7 +2718,11 @@ export default function BookReadPage() {
} }
if (restoreTarget && !restoreMessageShown) { if (restoreTarget && !restoreMessageShown) {
restoreMessageShown = true; restoreMessageShown = true;
setRestoredMessage(`已恢复到上次阅读位置(约 ${Math.round(manifest.lastRecord?.progressPercent || 0)}%`); setRestoredMessage(
`已恢复到上次阅读位置(约 ${Math.round(
manifest.lastRecord?.progressPercent || 0
)}%`
);
window.setTimeout(() => setRestoredMessage(''), 3000); window.setTimeout(() => setRestoredMessage(''), 3000);
} }
lastLocationRef.current = location; lastLocationRef.current = location;
@@ -1842,13 +2732,30 @@ export default function BookReadPage() {
bindScrolledIframeListenerRef.current(); bindScrolledIframeListenerRef.current();
applyPendingScrolledRestoreRef.current(); applyPendingScrolledRestoreRef.current();
}); });
const hrefLabel = location?.start?.href ? findTocLabelByHref(tocItemsRef.current, location.start.href) : ''; const hrefLabel = location?.start?.href
const chapterTitle = hrefLabel || location?.start?.displayed?.chapter || location?.start?.href || manifest.book.title; ? findTocLabelByHref(tocItemsRef.current, location.start.href)
: '';
const chapterTitle =
hrefLabel ||
location?.start?.displayed?.chapter ||
location?.start?.href ||
manifest.book.title;
const cfi = location?.start?.cfi || ''; const cfi = location?.start?.cfi || '';
const computedProgress = locationsReadyRef.current && cfi const computedProgress =
? Math.max(0, Math.min(100, (book.locations?.percentageFromCfi?.(cfi) || 0) * 100)) locationsReadyRef.current && cfi
: null; ? Math.max(
const normalizedProgress = computedProgress ?? lastProgressRef.current ?? manifest.lastRecord?.progressPercent ?? 0; 0,
Math.min(
100,
(book.locations?.percentageFromCfi?.(cfi) || 0) * 100
)
)
: null;
const normalizedProgress =
computedProgress ??
lastProgressRef.current ??
manifest.lastRecord?.progressPercent ??
0;
setProgressPercent(normalizedProgress); setProgressPercent(normalizedProgress);
setCurrentChapter(chapterTitle); setCurrentChapter(chapterTitle);
setCurrentHref(location?.start?.href || ''); setCurrentHref(location?.start?.href || '');
@@ -1866,7 +2773,8 @@ export default function BookReadPage() {
void (async () => { void (async () => {
try { try {
const navigation = (await book.loaded?.navigation) || book.navigation; const navigation =
(await book.loaded?.navigation) || book.navigation;
if (!destroyed) setTocItems(navigation?.toc || []); if (!destroyed) setTocItems(navigation?.toc || []);
} catch { } catch {
if (!destroyed) setTocItems(book.navigation?.toc || []); if (!destroyed) setTocItems(book.navigation?.toc || []);
@@ -1879,7 +2787,10 @@ export default function BookReadPage() {
await book.locations?.generate?.(480); await book.locations?.generate?.(480);
locationsReadyRef.current = true; locationsReadyRef.current = true;
if (lastLocationRef.current?.start?.cfi) { if (lastLocationRef.current?.start?.cfi) {
const recomputed = book.locations?.percentageFromCfi?.(lastLocationRef.current.start.cfi) || 0; const recomputed =
book.locations?.percentageFromCfi?.(
lastLocationRef.current.start.cfi
) || 0;
const nextProgress = Math.max(0, Math.min(100, recomputed * 100)); const nextProgress = Math.max(0, Math.min(100, recomputed * 100));
setProgressPercent(nextProgress); setProgressPercent(nextProgress);
lastProgressRef.current = nextProgress; lastProgressRef.current = nextProgress;
@@ -1902,7 +2813,14 @@ export default function BookReadPage() {
renditionRef.current?.destroy?.(); renditionRef.current?.destroy?.();
bookRef.current?.destroy?.(); bookRef.current?.destroy?.();
}; };
}, [manifest, settings.mode, applyReaderTheme, persistCurrentProgress, queueReadRecord, navigateToTarget]); }, [
manifest,
settings.mode,
applyReaderTheme,
persistCurrentProgress,
queueReadRecord,
navigateToTarget,
]);
useEffect(() => { useEffect(() => {
const flushPendingReadRecordOnLeave = () => { const flushPendingReadRecordOnLeave = () => {
@@ -1965,7 +2883,10 @@ export default function BookReadPage() {
const handleLoadedMetadata = () => { const handleLoadedMetadata = () => {
const nextDuration = audio.duration || 0; const nextDuration = audio.duration || 0;
if (ttsResumeTimeRef.current > 0 && nextDuration > 0) { if (ttsResumeTimeRef.current > 0 && nextDuration > 0) {
audio.currentTime = Math.min(ttsResumeTimeRef.current, Math.max(0, nextDuration - 0.25)); audio.currentTime = Math.min(
ttsResumeTimeRef.current,
Math.max(0, nextDuration - 0.25)
);
ttsResumeTimeRef.current = 0; ttsResumeTimeRef.current = 0;
} }
setTtsDuration(nextDuration); setTtsDuration(nextDuration);
@@ -2010,8 +2931,6 @@ export default function BookReadPage() {
} }
}, [currentHref, stopTts, ttsCurrentChapterHref]); }, [currentHref, stopTts, ttsCurrentChapterHref]);
useEffect(() => { useEffect(() => {
if (!manifest || manifest.format !== 'pdf') return; if (!manifest || manifest.format !== 'pdf') return;
let revokedUrl = ''; let revokedUrl = '';
@@ -2044,28 +2963,40 @@ export default function BookReadPage() {
}; };
}, [manifest]); }, [manifest]);
useEffect(() => { useEffect(() => {
tocItemsRef.current = tocItems; tocItemsRef.current = tocItems;
}, [tocItems]); }, [tocItems]);
const flatToc = useMemo(() => flattenToc(tocItems), [tocItems]); const flatToc = useMemo(() => flattenToc(tocItems), [tocItems]);
const activeTocHref = useMemo( const activeTocHref = useMemo(
() => flatToc.find((item) => isSameTocTarget(currentHref, item.href))?.href || '', () =>
flatToc.find((item) => isSameTocTarget(currentHref, item.href))?.href ||
'',
[flatToc, currentHref] [flatToc, currentHref]
); );
const currentTocLabel = useMemo(() => findTocLabelByHref(tocItems, currentHref), [tocItems, currentHref]); const currentTocLabel = useMemo(
() => findTocLabelByHref(tocItems, currentHref),
[tocItems, currentHref]
);
useEffect(() => { useEffect(() => {
if (!manifest) return; if (!manifest) return;
window.dispatchEvent(new CustomEvent('books-read-update-header', { window.dispatchEvent(
detail: { new CustomEvent('books-read-update-header', {
title: manifest.book.title, detail: {
subtitle: currentTocLabel || currentChapter || manifest.book.author || (settings.mode === 'scrolled' ? '滚动阅读' : '分页阅读'), title: manifest.book.title,
backHref: `/books/detail?sourceId=${encodeURIComponent(manifest.book.sourceId)}&bookId=${encodeURIComponent(manifest.book.id)}`, subtitle:
}, currentTocLabel ||
})); currentChapter ||
manifest.book.author ||
(settings.mode === 'scrolled' ? '滚动阅读' : '分页阅读'),
backHref: `/books/detail?sourceId=${encodeURIComponent(
manifest.book.sourceId
)}&bookId=${encodeURIComponent(manifest.book.id)}`,
},
})
);
}, [manifest, currentChapter, currentTocLabel, settings.mode]); }, [manifest, currentChapter, currentTocLabel, settings.mode]);
useEffect(() => { useEffect(() => {
@@ -2075,7 +3006,9 @@ export default function BookReadPage() {
activeNode.scrollIntoView({ block: 'center', behavior: 'smooth' }); activeNode.scrollIntoView({ block: 'center', behavior: 'smooth' });
}, [tocOpen, activeTocHref]); }, [tocOpen, activeTocHref]);
const nextChapterHref = useMemo(() => { const nextChapterHref = useMemo(() => {
const index = flatToc.findIndex((item) => isSameTocTarget(currentHref, item.href)); const index = flatToc.findIndex((item) =>
isSameTocTarget(currentHref, item.href)
);
if (index < 0) return flatToc[0]?.href || ''; if (index < 0) return flatToc[0]?.href || '';
return flatToc[index + 1]?.href || ''; return flatToc[index + 1]?.href || '';
}, [flatToc, currentHref]); }, [flatToc, currentHref]);
@@ -2117,7 +3050,10 @@ export default function BookReadPage() {
const isAtBottom = () => { const isAtBottom = () => {
const latestMetrics = getIframeScrollMetrics(viewerRef.current); const latestMetrics = getIframeScrollMetrics(viewerRef.current);
if (!latestMetrics) return false; if (!latestMetrics) return false;
const distanceToBottom = latestMetrics.scrollHeight - latestMetrics.clientHeight - latestMetrics.scrollTop; const distanceToBottom =
latestMetrics.scrollHeight -
latestMetrics.clientHeight -
latestMetrics.scrollTop;
return distanceToBottom <= 36; return distanceToBottom <= 36;
}; };
@@ -2144,7 +3080,10 @@ export default function BookReadPage() {
const latestMetrics = getIframeScrollMetrics(viewerRef.current); const latestMetrics = getIframeScrollMetrics(viewerRef.current);
if (!latestMetrics) return; if (!latestMetrics) return;
persistScrolledPosition(); persistScrolledPosition();
const distanceToBottom = latestMetrics.scrollHeight - latestMetrics.clientHeight - latestMetrics.scrollTop; const distanceToBottom =
latestMetrics.scrollHeight -
latestMetrics.clientHeight -
latestMetrics.scrollTop;
if (distanceToBottom <= 36) { if (distanceToBottom <= 36) {
setBottomReached(true); setBottomReached(true);
scrolledAutoAdvanceLockRef.current = false; scrolledAutoAdvanceLockRef.current = false;
@@ -2179,23 +3118,52 @@ export default function BookReadPage() {
}; };
metrics.addScrollListener(handleScroll); metrics.addScrollListener(handleScroll);
metrics.interactionTarget?.addEventListener('wheel', handleWheel, { passive: true }); metrics.interactionTarget?.addEventListener('wheel', handleWheel, {
metrics.interactionTarget?.addEventListener('touchstart', handleTouchStart, { passive: true }); passive: true,
metrics.interactionTarget?.addEventListener('touchmove', handleTouchMove, { passive: true }); });
metrics.interactionTarget?.addEventListener('touchend', handleTouchEnd, { passive: true }); metrics.interactionTarget?.addEventListener(
viewerRef.current?.addEventListener('wheel', handleWheel, { passive: true }); 'touchstart',
viewerRef.current?.addEventListener('touchstart', handleTouchStart, { passive: true }); handleTouchStart,
viewerRef.current?.addEventListener('touchmove', handleTouchMove, { passive: true }); { passive: true }
viewerRef.current?.addEventListener('touchend', handleTouchEnd, { passive: true }); );
metrics.interactionTarget?.addEventListener(
'touchmove',
handleTouchMove,
{ passive: true }
);
metrics.interactionTarget?.addEventListener('touchend', handleTouchEnd, {
passive: true,
});
viewerRef.current?.addEventListener('wheel', handleWheel, {
passive: true,
});
viewerRef.current?.addEventListener('touchstart', handleTouchStart, {
passive: true,
});
viewerRef.current?.addEventListener('touchmove', handleTouchMove, {
passive: true,
});
viewerRef.current?.addEventListener('touchend', handleTouchEnd, {
passive: true,
});
handleScroll(); handleScroll();
scrollListenerCleanupRef.current = () => { scrollListenerCleanupRef.current = () => {
if (retryTimer) window.clearTimeout(retryTimer); if (retryTimer) window.clearTimeout(retryTimer);
if (rafId) window.cancelAnimationFrame(rafId); if (rafId) window.cancelAnimationFrame(rafId);
metrics.removeScrollListener(handleScroll); metrics.removeScrollListener(handleScroll);
metrics.interactionTarget?.removeEventListener('wheel', handleWheel); metrics.interactionTarget?.removeEventListener('wheel', handleWheel);
metrics.interactionTarget?.removeEventListener('touchstart', handleTouchStart); metrics.interactionTarget?.removeEventListener(
metrics.interactionTarget?.removeEventListener('touchmove', handleTouchMove); 'touchstart',
metrics.interactionTarget?.removeEventListener('touchend', handleTouchEnd); handleTouchStart
);
metrics.interactionTarget?.removeEventListener(
'touchmove',
handleTouchMove
);
metrics.interactionTarget?.removeEventListener(
'touchend',
handleTouchEnd
);
viewerRef.current?.removeEventListener('wheel', handleWheel); viewerRef.current?.removeEventListener('wheel', handleWheel);
viewerRef.current?.removeEventListener('touchstart', handleTouchStart); viewerRef.current?.removeEventListener('touchstart', handleTouchStart);
viewerRef.current?.removeEventListener('touchmove', handleTouchMove); viewerRef.current?.removeEventListener('touchmove', handleTouchMove);
@@ -2206,55 +3174,78 @@ export default function BookReadPage() {
attach(); attach();
}, [goToNextChapter, persistScrolledPosition]); }, [goToNextChapter, persistScrolledPosition]);
useEffect(() => { useEffect(() => {
bindScrolledIframeListenerRef.current = bindScrolledIframeListener; bindScrolledIframeListenerRef.current = bindScrolledIframeListener;
}, [bindScrolledIframeListener]); }, [bindScrolledIframeListener]);
const renderTocItems = useCallback((items: TocItem[], depth = 0) => items.map((item) => { const renderTocItems = useCallback(
const active = tocItemIsActive(item, currentHref); (items: TocItem[], depth = 0) =>
const clickable = !!item.href; items.map((item) => {
return ( const active = tocItemIsActive(item, currentHref);
<div key={`${item.href || item.label}-${depth}`} className='space-y-2'> const clickable = !!item.href;
<button return (
ref={(node) => { <div
if (item.href) tocItemRefs.current[item.href] = node; key={`${item.href || item.label}-${depth}`}
}} className='space-y-2'
onClick={() => { >
if (!clickable) return; <button
persistScrolledPosition(); ref={(node) => {
pendingScrolledRestoreRef.current = { if (item.href) tocItemRefs.current[item.href] = node;
href: item.href, }}
scrollTop: 0, onClick={() => {
scrollHeight: 1, if (!clickable) return;
clientHeight: 1, persistScrolledPosition();
updatedAt: Date.now(), pendingScrolledRestoreRef.current = {
}; href: item.href,
restoreTargetRef.current = item.href; scrollTop: 0,
void navigateToTarget(item.href); scrollHeight: 1,
setTocOpen(false); clientHeight: 1,
}} updatedAt: Date.now(),
disabled={!clickable} };
className={`group relative block w-full rounded-2xl px-4 py-3 text-left text-sm transition ${active ? 'bg-sky-600 text-white' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900'} ${!clickable ? 'cursor-default opacity-80' : ''}`} restoreTargetRef.current = item.href;
style={{ paddingLeft: `${16 + depth * 14}px` }} void navigateToTarget(item.href);
> setTocOpen(false);
<span className='block truncate'>{item.label}</span> }}
<div className='pointer-events-none absolute bottom-full left-1/2 z-[100] mb-2 -translate-x-1/2 rounded-lg bg-gray-800 px-3 py-2 text-sm text-white opacity-0 invisible shadow-xl transition-all duration-200 ease-out group-hover:visible group-hover:opacity-100 dark:bg-gray-900 whitespace-nowrap'> disabled={!clickable}
<div className='text-sm'>{item.label}</div> className={`group relative block w-full rounded-2xl px-4 py-3 text-left text-sm transition ${
active
? 'bg-emerald-600 text-white'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900'
} ${!clickable ? 'cursor-default opacity-80' : ''}`}
style={{ paddingLeft: `${16 + depth * 14}px` }}
>
<span className='block truncate'>{item.label}</span>
<div className='pointer-events-none absolute bottom-full left-1/2 z-[100] mb-2 -translate-x-1/2 rounded-lg bg-gray-800 px-3 py-2 text-sm text-white opacity-0 invisible shadow-xl transition-all duration-200 ease-out group-hover:visible group-hover:opacity-100 dark:bg-gray-900 whitespace-nowrap'>
<div className='text-sm'>{item.label}</div>
</div>
</button>
{item.subitems?.length
? renderTocItems(item.subitems, depth + 1)
: null}
</div> </div>
</button> );
{item.subitems?.length ? renderTocItems(item.subitems, depth + 1) : null} }),
</div> [currentHref, navigateToTarget, persistScrolledPosition]
); );
}), [currentHref, navigateToTarget, persistScrolledPosition]);
const showScrolledNextChapter =
ready &&
settings.mode === 'scrolled' &&
!tocOpen &&
!settingsOpen &&
scrolledBottomReached &&
!!nextChapterHref;
const progressLabel = totalBytes
const showScrolledNextChapter = ready && settings.mode === 'scrolled' && !tocOpen && !settingsOpen && scrolledBottomReached && !!nextChapterHref; ? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}`
: formatBytes(downloadedBytes);
const progressLabel = totalBytes ? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` : formatBytes(downloadedBytes); const ttsChunkPercent =
const ttsChunkPercent = ttsChunks.length > 0 ? ((ttsCurrentChunkIndex + 1) / ttsChunks.length) * 100 : 0; ttsChunks.length > 0
const selectedVoice = ttsVoices.find((item) => item.shortName === ttsSettings.voice); ? ((ttsCurrentChunkIndex + 1) / ttsChunks.length) * 100
: 0;
const selectedVoice = ttsVoices.find(
(item) => item.shortName === ttsSettings.voice
);
const currentChunk = ttsChunks[ttsCurrentChunkIndex]; const currentChunk = ttsChunks[ttsCurrentChunkIndex];
const ttsRateValue = parseSignedNumber(ttsSettings.rate, '%'); const ttsRateValue = parseSignedNumber(ttsSettings.rate, '%');
const ttsPitchValue = parseSignedNumber(ttsSettings.pitch, 'Hz'); const ttsPitchValue = parseSignedNumber(ttsSettings.pitch, 'Hz');
@@ -2269,7 +3260,9 @@ export default function BookReadPage() {
<div className='reader-book-loader'> <div className='reader-book-loader'>
<BookOpen className='h-10 w-10' strokeWidth={1.75} /> <BookOpen className='h-10 w-10' strokeWidth={1.75} />
</div> </div>
<div className='text-sm text-gray-500 dark:text-gray-400'>...</div> <div className='text-sm text-gray-500 dark:text-gray-400'>
...
</div>
</div> </div>
</div> </div>
); );
@@ -2280,14 +3273,25 @@ export default function BookReadPage() {
} }
if (manifest.format === 'pdf') { if (manifest.format === 'pdf') {
if (!pdfBlobUrl) return <div className='p-4 text-sm text-gray-500'>PDF ... {progressLabel}</div>; if (!pdfBlobUrl)
return <iframe src={pdfBlobUrl} className='h-[calc(100vh-4rem)] w-full bg-white' title={manifest.book.title} />; return (
<div className='p-4 text-sm text-gray-500'>
PDF ... {progressLabel}
</div>
);
return (
<iframe
src={pdfBlobUrl}
className='h-[calc(100vh-4rem)] w-full bg-white'
title={manifest.book.title}
/>
);
} }
return ( return (
<div className='flex h-[calc(100vh-3.5rem)] flex-col bg-white dark:bg-gray-950'> <div className='flex h-[calc(100vh-3.5rem)] flex-col bg-white dark:bg-gray-950'>
{restoredMessage ? ( {restoredMessage ? (
<div className='absolute left-1/2 top-[4.5rem] z-30 -translate-x-1/2 rounded-full bg-sky-600 px-4 py-2 text-xs text-white shadow-lg'> <div className='absolute left-1/2 top-[4.5rem] z-30 -translate-x-1/2 rounded-full bg-emerald-600 px-4 py-2 text-xs text-white shadow-lg'>
{restoredMessage} {restoredMessage}
</div> </div>
) : null} ) : null}
@@ -2300,19 +3304,32 @@ export default function BookReadPage() {
{fileLoadState === 'checking-cache' {fileLoadState === 'checking-cache'
? '检查本地缓存' ? '检查本地缓存'
: fileLoadState === 'downloading' : fileLoadState === 'downloading'
? '下载电子书' ? '下载电子书'
: fileLoadState === 'opening' : fileLoadState === 'opening'
? '正在打开电子书' ? '正在打开电子书'
: '准备阅读器'} : '准备阅读器'}
</div> </div>
<div className='h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-800'> <div className='h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-800'>
<div <div
className='h-full rounded-full bg-sky-600 transition-all' className='h-full rounded-full bg-emerald-600 transition-all'
style={{ width: totalBytes ? `${Math.min(100, (downloadedBytes / totalBytes) * 100)}%` : fileLoadState === 'opening' ? '92%' : fileLoadState === 'checking-cache' ? '20%' : '45%' }} style={{
width: totalBytes
? `${Math.min(
100,
(downloadedBytes / totalBytes) * 100
)}%`
: fileLoadState === 'opening'
? '92%'
: fileLoadState === 'checking-cache'
? '20%'
: '45%',
}}
/> />
</div> </div>
<div className='flex items-center justify-between text-xs text-gray-500 dark:text-gray-400'> <div className='flex items-center justify-between text-xs text-gray-500 dark:text-gray-400'>
<span>{cacheHit ? '已命中本地缓存' : '首次打开将缓存到当前浏览器'}</span> <span>
{cacheHit ? '已命中本地缓存' : '首次打开将缓存到当前浏览器'}
</span>
<span>{progressLabel}</span> <span>{progressLabel}</span>
</div> </div>
</div> </div>
@@ -2322,7 +3339,9 @@ export default function BookReadPage() {
<div className='reader-book-loader'> <div className='reader-book-loader'>
<BookOpen className='h-10 w-10' strokeWidth={1.75} /> <BookOpen className='h-10 w-10' strokeWidth={1.75} />
</div> </div>
<div className='text-sm text-gray-500 dark:text-gray-400'>...</div> <div className='text-sm text-gray-500 dark:text-gray-400'>
...
</div>
</div> </div>
) : ( ) : (
<div className='space-y-3 animate-pulse'> <div className='space-y-3 animate-pulse'>
@@ -2338,101 +3357,184 @@ export default function BookReadPage() {
</div> </div>
) : null} ) : null}
{tocOpen && typeof document !== 'undefined' ? createPortal( {tocOpen && typeof document !== 'undefined'
<div className='fixed inset-0 z-40 bg-black/30' onClick={() => setTocOpen(false)}> ? createPortal(
<div <div
className='absolute right-0 top-0 h-screen w-[22rem] max-w-[88vw] overflow-y-auto border-l border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-950' className='fixed inset-0 z-40 bg-black/30'
onClick={(event) => event.stopPropagation()} onClick={() => setTocOpen(false)}
> >
<div className='p-4'> <div
<div className='space-y-2' ref={tocScrollRef}> className='absolute right-0 top-0 h-screen w-[22rem] max-w-[88vw] overflow-y-auto border-l border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-950'
{tocItems.length === 0 ? ( onClick={(event) => event.stopPropagation()}
<div className='p-3 text-sm text-gray-500'> EPUB </div> >
) : ( <div className='p-4'>
renderTocItems(tocItems) <div className='space-y-2' ref={tocScrollRef}>
)} {tocItems.length === 0 ? (
</div> <div className='p-3 text-sm text-gray-500'>
</div> EPUB
</div> </div>
</div>, ) : (
document.body renderTocItems(tocItems)
) : null} )}
</div>
{settingsOpen && typeof document !== 'undefined' ? createPortal(
<div className='fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4' onClick={() => setSettingsOpen(false)}>
<div
className='w-full max-w-sm rounded-3xl border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-700 dark:bg-gray-950'
onClick={(event) => event.stopPropagation()}
>
<div className='mb-4'>
<div className='text-base font-semibold text-gray-900 dark:text-gray-100'></div>
<div className='mt-1 text-xs text-gray-500'></div>
</div>
<div className='space-y-6 p-1 text-sm'>
<div>
<div className='mb-2 font-medium'></div>
<div className='grid grid-cols-2 gap-2'>
{([
{ key: 'paginated', label: '翻页模式', desc: '左右点击翻页' },
{ key: 'scrolled', label: '滚动模式', desc: '上下连续滚动' },
] as { key: ReaderMode; label: string; desc: string }[]).map((mode) => (
<button
key={mode.key}
onClick={() => setSettings((prev) => ({ ...prev, mode: mode.key }))}
className={`rounded-2xl border px-3 py-3 text-left ${settings.mode === mode.key ? 'border-sky-500 bg-sky-50 text-sky-700 dark:bg-sky-950/30 dark:text-sky-300' : 'border-gray-200 dark:border-gray-700'}`}
>
<div className='font-medium'>{mode.label}</div>
<div className='mt-1 text-xs opacity-70'>{mode.desc}</div>
</button>
))}
</div> </div>
</div> </div>
</div>,
document.body
)
: null}
<div> {settingsOpen && typeof document !== 'undefined'
<div className='mb-2 font-medium'></div> ? createPortal(
<div className='grid grid-cols-3 gap-2'> <div
{(['light', 'sepia', 'dark'] as ReaderTheme[]).map((theme) => ( className='fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4'
onClick={() => setSettingsOpen(false)}
>
<div
className='w-full max-w-sm rounded-3xl border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-700 dark:bg-gray-950'
onClick={(event) => event.stopPropagation()}
>
<div className='mb-4'>
<div className='text-base font-semibold text-gray-900 dark:text-gray-100'>
</div>
<div className='mt-1 text-xs text-gray-500'>
</div>
</div>
<div className='space-y-6 p-1 text-sm'>
<div>
<div className='mb-2 font-medium'></div>
<div className='grid grid-cols-2 gap-2'>
{(
[
{
key: 'paginated',
label: '翻页模式',
desc: '左右点击翻页',
},
{
key: 'scrolled',
label: '滚动模式',
desc: '上下连续滚动',
},
] as { key: ReaderMode; label: string; desc: string }[]
).map((mode) => (
<button
key={mode.key}
onClick={() =>
setSettings((prev) => ({ ...prev, mode: mode.key }))
}
className={`rounded-2xl border px-3 py-3 text-left ${
settings.mode === mode.key
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'
: 'border-gray-200 dark:border-gray-700'
}`}
>
<div className='font-medium'>{mode.label}</div>
<div className='mt-1 text-xs opacity-70'>
{mode.desc}
</div>
</button>
))}
</div>
</div>
<div>
<div className='mb-2 font-medium'></div>
<div className='grid grid-cols-3 gap-2'>
{(['light', 'sepia', 'dark'] as ReaderTheme[]).map(
(theme) => (
<button
key={theme}
onClick={() =>
setSettings((prev) => ({ ...prev, theme }))
}
className={`rounded-2xl border px-3 py-2 ${
settings.theme === theme
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'
: 'border-gray-200 dark:border-gray-700'
}`}
>
<div className='mb-1 flex justify-center'>
{theme === 'dark' ? (
<Moon className='h-4 w-4' />
) : (
<Sun className='h-4 w-4' />
)}
</div>
{theme === 'light'
? '浅色'
: theme === 'sepia'
? '护眼'
: '深色'}
</button>
)
)}
</div>
</div>
<div>
<div className='mb-2 flex items-center justify-between font-medium'>
<span>{settings.fontSize}%</span>
</div>
<input
type='range'
min='85'
max='140'
step='5'
value={settings.fontSize}
onChange={(e) =>
setSettings((prev) => ({
...prev,
fontSize: Number(e.target.value),
}))
}
className='w-full'
/>
</div>
<div>
<div className='mb-2 flex items-center justify-between font-medium'>
<span>{settings.lineHeight.toFixed(1)}</span>
</div>
<input
type='range'
min='1.4'
max='2.2'
step='0.1'
value={settings.lineHeight}
onChange={(e) =>
setSettings((prev) => ({
...prev,
lineHeight: Number(e.target.value),
}))
}
className='w-full'
/>
</div>
<div className='rounded-2xl bg-gray-50 p-4 text-xs text-gray-500 dark:bg-gray-900 dark:text-gray-400'>
{cacheHit ? '已命中本地缓存' : '本次为网络加载'}
</div>
<div className='flex justify-end'>
<button <button
key={theme} type='button'
onClick={() => setSettings((prev) => ({ ...prev, theme }))} className='rounded-2xl bg-emerald-600 px-4 py-2 text-sm font-medium text-white'
className={`rounded-2xl border px-3 py-2 ${settings.theme === theme ? 'border-sky-500 bg-sky-50 text-sky-700 dark:bg-sky-950/30 dark:text-sky-300' : 'border-gray-200 dark:border-gray-700'}`} onClick={() => setSettingsOpen(false)}
> >
<div className='mb-1 flex justify-center'>{theme === 'dark' ? <Moon className='h-4 w-4' /> : <Sun className='h-4 w-4' />}</div>
{theme === 'light' ? '浅色' : theme === 'sepia' ? '护眼' : '深色'}
</button> </button>
))} </div>
</div> </div>
</div> </div>
</div>,
<div> document.body
<div className='mb-2 flex items-center justify-between font-medium'> <span>{settings.fontSize}%</span></div> )
<input type='range' min='85' max='140' step='5' value={settings.fontSize} onChange={(e) => setSettings((prev) => ({ ...prev, fontSize: Number(e.target.value) }))} className='w-full' /> : null}
</div>
<div>
<div className='mb-2 flex items-center justify-between font-medium'> <span>{settings.lineHeight.toFixed(1)}</span></div>
<input type='range' min='1.4' max='2.2' step='0.1' value={settings.lineHeight} onChange={(e) => setSettings((prev) => ({ ...prev, lineHeight: Number(e.target.value) }))} className='w-full' />
</div>
<div className='rounded-2xl bg-gray-50 p-4 text-xs text-gray-500 dark:bg-gray-900 dark:text-gray-400'>
{cacheHit ? '已命中本地缓存' : '本次为网络加载'}
</div>
<div className='flex justify-end'>
<button
type='button'
className='rounded-2xl bg-sky-600 px-4 py-2 text-sm font-medium text-white'
onClick={() => setSettingsOpen(false)}
>
</button>
</div>
</div>
</div>
</div>,
document.body
) : null}
{manifest.format === 'epub' && ttsBarVisible ? ( {manifest.format === 'epub' && ttsBarVisible ? (
<> <>
@@ -2451,7 +3553,9 @@ export default function BookReadPage() {
onTouchStart={() => setTtsSeeking(true)} onTouchStart={() => setTtsSeeking(true)}
onChange={(e) => setTtsSeekValue(Number(e.target.value))} onChange={(e) => setTtsSeekValue(Number(e.target.value))}
onPointerUp={(e) => { onPointerUp={(e) => {
const nextTime = Number((e.target as HTMLInputElement).value); const nextTime = Number(
(e.target as HTMLInputElement).value
);
if (audioRef.current && Number.isFinite(nextTime)) { if (audioRef.current && Number.isFinite(nextTime)) {
audioRef.current.currentTime = nextTime; audioRef.current.currentTime = nextTime;
} }
@@ -2460,7 +3564,9 @@ export default function BookReadPage() {
setTtsSeeking(false); setTtsSeeking(false);
}} }}
onMouseUp={(e) => { onMouseUp={(e) => {
const nextTime = Number((e.target as HTMLInputElement).value); const nextTime = Number(
(e.target as HTMLInputElement).value
);
if (audioRef.current && Number.isFinite(nextTime)) { if (audioRef.current && Number.isFinite(nextTime)) {
audioRef.current.currentTime = nextTime; audioRef.current.currentTime = nextTime;
} }
@@ -2469,7 +3575,9 @@ export default function BookReadPage() {
setTtsSeeking(false); setTtsSeeking(false);
}} }}
onTouchEnd={(e) => { onTouchEnd={(e) => {
const nextTime = Number((e.target as HTMLInputElement).value); const nextTime = Number(
(e.target as HTMLInputElement).value
);
if (audioRef.current && Number.isFinite(nextTime)) { if (audioRef.current && Number.isFinite(nextTime)) {
audioRef.current.currentTime = nextTime; audioRef.current.currentTime = nextTime;
} }
@@ -2477,7 +3585,7 @@ export default function BookReadPage() {
setTtsSeekValue(nextTime); setTtsSeekValue(nextTime);
setTtsSeeking(false); setTtsSeeking(false);
}} }}
className='w-full accent-sky-500' className='w-full accent-emerald-500'
/> />
</div> </div>
<div className='px-3 py-2.5'> <div className='px-3 py-2.5'>
@@ -2486,27 +3594,40 @@ export default function BookReadPage() {
type='button' type='button'
onClick={() => void toggleTtsPlayback()} onClick={() => void toggleTtsPlayback()}
disabled={!ttsAvailable || ttsLoadingChunkIndex !== null} disabled={!ttsAvailable || ttsLoadingChunkIndex !== null}
className='flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-600 text-white disabled:opacity-50' className='flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-600 text-white disabled:opacity-50'
> >
{ttsLoadingChunkIndex !== null ? <Loader2 className='h-4 w-4 animate-spin' /> : ttsStatus === 'playing' ? <Pause className='h-4 w-4' /> : <Play className='h-4 w-4' />} {ttsLoadingChunkIndex !== null ? (
<Loader2 className='h-4 w-4 animate-spin' />
) : ttsStatus === 'playing' ? (
<Pause className='h-4 w-4' />
) : (
<Play className='h-4 w-4' />
)}
</button> </button>
<div className='min-w-0 flex-1'> <div className='min-w-0 flex-1'>
<div className='truncate text-sm font-medium text-gray-900 dark:text-gray-100'> <div className='truncate text-sm font-medium text-gray-900 dark:text-gray-100'>
{ttsCurrentChapterTitle || currentTocLabel || currentChapter || '语音朗读'} {ttsCurrentChapterTitle ||
currentTocLabel ||
currentChapter ||
'语音朗读'}
</div> </div>
<div className='mt-0.5 flex items-center gap-2 text-[11px] text-gray-500 dark:text-gray-400'> <div className='mt-0.5 flex items-center gap-2 text-[11px] text-gray-500 dark:text-gray-400'>
<span className='truncate'> <span className='truncate'>
{!ttsAvailable {!ttsAvailable
? '服务异常' ? '服务异常'
: ttsLoadingChunkIndex !== null : ttsLoadingChunkIndex !== null
? '生成语音中...' ? '生成语音中...'
: ttsStatus === 'playing' : ttsStatus === 'playing'
? '正在播放' ? '正在播放'
: ttsStatus === 'paused' : ttsStatus === 'paused'
? '已暂停' ? '已暂停'
: '待播放'} : '待播放'}
</span> </span>
{ttsChunks.length > 0 ? <span>{ttsCurrentChunkIndex + 1}/{ttsChunks.length}</span> : null} {ttsChunks.length > 0 ? (
<span>
{ttsCurrentChunkIndex + 1}/{ttsChunks.length}
</span>
) : null}
</div> </div>
</div> </div>
<button <button
@@ -2514,12 +3635,19 @@ export default function BookReadPage() {
onClick={() => setTtsPanelOpen((prev) => !prev)} onClick={() => setTtsPanelOpen((prev) => !prev)}
className='flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-200' className='flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-200'
> >
<ChevronUp className={`h-4 w-4 transition-transform ${ttsPanelOpen ? 'rotate-180' : ''}`} /> <ChevronUp
className={`h-4 w-4 transition-transform ${
ttsPanelOpen ? 'rotate-180' : ''
}`}
/>
</button> </button>
</div> </div>
<div className='mt-2 flex items-center justify-between text-[11px] text-gray-400'> <div className='mt-2 flex items-center justify-between text-[11px] text-gray-400'>
<span>{selectedVoice?.displayName || '默认音色'}</span> <span>{selectedVoice?.displayName || '默认音色'}</span>
<span>{formatDurationTime(displayedTtsTime)} / {formatDurationTime(ttsDuration || 0)}</span> <span>
{formatDurationTime(displayedTtsTime)} /{' '}
{formatDurationTime(ttsDuration || 0)}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -2530,7 +3658,7 @@ export default function BookReadPage() {
<div className='rounded-[2rem] border border-gray-200 bg-white/98 p-4 shadow-2xl backdrop-blur dark:border-gray-800 dark:bg-gray-950/98'> <div className='rounded-[2rem] border border-gray-200 bg-white/98 p-4 shadow-2xl backdrop-blur dark:border-gray-800 dark:bg-gray-950/98'>
<div className='mb-3 flex items-center justify-between'> <div className='mb-3 flex items-center justify-between'>
<div className='flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-gray-100'> <div className='flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-gray-100'>
<Headphones className='h-4 w-4 text-sky-500' /> <Headphones className='h-4 w-4 text-emerald-500' />
</div> </div>
<button <button
@@ -2543,8 +3671,12 @@ export default function BookReadPage() {
</div> </div>
<div className='mb-4 flex items-center justify-between rounded-2xl bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-gray-900 dark:text-gray-300'> <div className='mb-4 flex items-center justify-between rounded-2xl bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-gray-900 dark:text-gray-300'>
<span className='truncate'>{currentChunk?.text.slice(0, 28) || '当前章节可开始朗读'}</span> <span className='truncate'>
<span className='ml-2 shrink-0'>{Math.round(ttsChunkPercent)}%</span> {currentChunk?.text.slice(0, 28) || '当前章节可开始朗读'}
</span>
<span className='ml-2 shrink-0'>
{Math.round(ttsChunkPercent)}%
</span>
</div> </div>
<div className='mb-4 flex items-center justify-center gap-3'> <div className='mb-4 flex items-center justify-center gap-3'>
@@ -2554,7 +3686,9 @@ export default function BookReadPage() {
const next = Math.max(0, ttsCurrentChunkIndex - 1); const next = Math.max(0, ttsCurrentChunkIndex - 1);
if (ttsChunks[next]) void playTtsChunk(next); if (ttsChunks[next]) void playTtsChunk(next);
}} }}
disabled={ttsCurrentChunkIndex <= 0 || ttsChunks.length === 0} disabled={
ttsCurrentChunkIndex <= 0 || ttsChunks.length === 0
}
className='flex h-11 w-11 items-center justify-center rounded-full bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-200 disabled:opacity-40' className='flex h-11 w-11 items-center justify-center rounded-full bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-200 disabled:opacity-40'
> >
<SkipBack className='h-5 w-5' /> <SkipBack className='h-5 w-5' />
@@ -2563,9 +3697,15 @@ export default function BookReadPage() {
type='button' type='button'
onClick={() => void toggleTtsPlayback()} onClick={() => void toggleTtsPlayback()}
disabled={!ttsAvailable || ttsLoadingChunkIndex !== null} disabled={!ttsAvailable || ttsLoadingChunkIndex !== null}
className='flex h-14 w-14 items-center justify-center rounded-full bg-sky-600 text-white shadow-lg disabled:opacity-50' className='flex h-14 w-14 items-center justify-center rounded-full bg-emerald-600 text-white shadow-lg disabled:opacity-50'
> >
{ttsLoadingChunkIndex !== null ? <Loader2 className='h-5 w-5 animate-spin' /> : ttsStatus === 'playing' ? <Pause className='h-5 w-5' /> : <Play className='h-5 w-5' />} {ttsLoadingChunkIndex !== null ? (
<Loader2 className='h-5 w-5 animate-spin' />
) : ttsStatus === 'playing' ? (
<Pause className='h-5 w-5' />
) : (
<Play className='h-5 w-5' />
)}
</button> </button>
<button <button
type='button' type='button'
@@ -2573,7 +3713,10 @@ export default function BookReadPage() {
const next = ttsCurrentChunkIndex + 1; const next = ttsCurrentChunkIndex + 1;
if (ttsChunks[next]) void playTtsChunk(next); if (ttsChunks[next]) void playTtsChunk(next);
}} }}
disabled={ttsCurrentChunkIndex >= ttsChunks.length - 1 || ttsChunks.length === 0} disabled={
ttsCurrentChunkIndex >= ttsChunks.length - 1 ||
ttsChunks.length === 0
}
className='flex h-11 w-11 items-center justify-center rounded-full bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-200 disabled:opacity-40' className='flex h-11 w-11 items-center justify-center rounded-full bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-200 disabled:opacity-40'
> >
<SkipForward className='h-5 w-5' /> <SkipForward className='h-5 w-5' />
@@ -2598,7 +3741,10 @@ export default function BookReadPage() {
value={ttsSettings.voice} value={ttsSettings.voice}
onChange={(e) => { onChange={(e) => {
stopTts(true); stopTts(true);
setTtsSettings((prev) => ({ ...prev, voice: e.target.value })); setTtsSettings((prev) => ({
...prev,
voice: e.target.value,
}));
}} }}
className='w-full rounded-2xl border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100' className='w-full rounded-2xl border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100'
> >
@@ -2612,7 +3758,10 @@ export default function BookReadPage() {
<label className='block'> <label className='block'>
<div className='mb-2 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400'> <div className='mb-2 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400'>
<span className='flex items-center gap-2'><Gauge className='h-3.5 w-3.5' /></span> <span className='flex items-center gap-2'>
<Gauge className='h-3.5 w-3.5' />
</span>
<span>{ttsSettings.rate}</span> <span>{ttsSettings.rate}</span>
</div> </div>
<input <input
@@ -2623,8 +3772,12 @@ export default function BookReadPage() {
value={Math.max(0, TTS_RATE_STEPS.indexOf(ttsRateValue))} value={Math.max(0, TTS_RATE_STEPS.indexOf(ttsRateValue))}
onChange={(e) => { onChange={(e) => {
stopTts(true); stopTts(true);
const nextValue = TTS_RATE_STEPS[Number(e.target.value)] ?? 0; const nextValue =
setTtsSettings((prev) => ({ ...prev, rate: formatSignedValue(nextValue, '%') })); TTS_RATE_STEPS[Number(e.target.value)] ?? 0;
setTtsSettings((prev) => ({
...prev,
rate: formatSignedValue(nextValue, '%'),
}));
}} }}
className='w-full' className='w-full'
/> />
@@ -2632,7 +3785,10 @@ export default function BookReadPage() {
<label className='block'> <label className='block'>
<div className='mb-2 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400'> <div className='mb-2 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400'>
<span className='flex items-center gap-2'><Waves className='h-3.5 w-3.5' /></span> <span className='flex items-center gap-2'>
<Waves className='h-3.5 w-3.5' />
</span>
<span>{ttsSettings.pitch}</span> <span>{ttsSettings.pitch}</span>
</div> </div>
<input <input
@@ -2640,11 +3796,18 @@ export default function BookReadPage() {
min={0} min={0}
max={TTS_PITCH_STEPS.length - 1} max={TTS_PITCH_STEPS.length - 1}
step={1} step={1}
value={Math.max(0, TTS_PITCH_STEPS.indexOf(ttsPitchValue))} value={Math.max(
0,
TTS_PITCH_STEPS.indexOf(ttsPitchValue)
)}
onChange={(e) => { onChange={(e) => {
stopTts(true); stopTts(true);
const nextValue = TTS_PITCH_STEPS[Number(e.target.value)] ?? 0; const nextValue =
setTtsSettings((prev) => ({ ...prev, pitch: formatSignedValue(nextValue, 'Hz') })); TTS_PITCH_STEPS[Number(e.target.value)] ?? 0;
setTtsSettings((prev) => ({
...prev,
pitch: formatSignedValue(nextValue, 'Hz'),
}));
}} }}
className='w-full' className='w-full'
/> />
@@ -2652,7 +3815,10 @@ export default function BookReadPage() {
<label className='block'> <label className='block'>
<div className='mb-2 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400'> <div className='mb-2 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400'>
<span className='flex items-center gap-2'><Volume2 className='h-3.5 w-3.5' /></span> <span className='flex items-center gap-2'>
<Volume2 className='h-3.5 w-3.5' />
</span>
<span>{ttsSettings.volume}</span> <span>{ttsSettings.volume}</span>
</div> </div>
<input <input
@@ -2660,25 +3826,33 @@ export default function BookReadPage() {
min={0} min={0}
max={TTS_VOLUME_STEPS.length - 1} max={TTS_VOLUME_STEPS.length - 1}
step={1} step={1}
value={Math.max(0, TTS_VOLUME_STEPS.indexOf(ttsVolumeValue))} value={Math.max(
0,
TTS_VOLUME_STEPS.indexOf(ttsVolumeValue)
)}
onChange={(e) => { onChange={(e) => {
stopTts(true); stopTts(true);
const nextValue = TTS_VOLUME_STEPS[Number(e.target.value)] ?? 0; const nextValue =
setTtsSettings((prev) => ({ ...prev, volume: formatSignedValue(nextValue, '%') })); TTS_VOLUME_STEPS[Number(e.target.value)] ?? 0;
setTtsSettings((prev) => ({
...prev,
volume: formatSignedValue(nextValue, '%'),
}));
}} }}
className='w-full' className='w-full'
/> />
</label> </label>
</div> </div>
{ttsError ? <div className='mt-3 text-xs text-red-500'>{ttsError}</div> : null} {ttsError ? (
<div className='mt-3 text-xs text-red-500'>{ttsError}</div>
) : null}
</div> </div>
</div> </div>
) : null} ) : null}
</> </>
) : null} ) : null}
{ready && !tocOpen && !settingsOpen && settings.mode === 'paginated' ? ( {ready && !tocOpen && !settingsOpen && settings.mode === 'paginated' ? (
<> <>
<button <button
@@ -2707,7 +3881,7 @@ export default function BookReadPage() {
type='button' type='button'
onClick={goToNextChapter} onClick={goToNextChapter}
aria-label='下一章' aria-label='下一章'
className='pointer-events-auto flex h-11 w-11 items-center justify-center rounded-full bg-sky-600/20 text-white shadow-lg' className='pointer-events-auto flex h-11 w-11 items-center justify-center rounded-full bg-emerald-600/20 text-white shadow-lg'
> >
<ChevronRight className='h-5 w-5' /> <ChevronRight className='h-5 w-5' />
</button> </button>
+493 -172
View File
@@ -1,11 +1,32 @@
'use client'; 'use client';
import {
BookMarked,
Layers3,
Loader2,
Search,
Sparkles,
X,
} from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation'; 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 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) { function detailHref(item: BookListItem) {
return buildBookDetailPath(item.sourceId, item.id); return buildBookDetailPath(item.sourceId, item.id);
@@ -15,10 +36,13 @@ function SearchSkeleton() {
return ( return (
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6 animate-pulse'> <div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6 animate-pulse'>
{Array.from({ length: 12 }).map((_, index) => ( {Array.from({ length: 12 }).map((_, index) => (
<div key={index} className='space-y-3'> <div
<div className='aspect-[3/4] rounded-2xl bg-gray-200 dark:bg-gray-800' /> key={index}
<div className='h-4 w-3/4 rounded bg-gray-200 dark:bg-gray-800' /> 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='h-3 w-1/2 rounded bg-gray-200 dark:bg-gray-800' /> >
<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>
))} ))}
</div> </div>
@@ -27,6 +51,7 @@ function SearchSkeleton() {
const BOOK_SEARCH_STATE_KEY = 'book_search_state'; const BOOK_SEARCH_STATE_KEY = 'book_search_state';
const EMPTY_RESULT: BookSearchResult = { results: [], failedSources: [] }; const EMPTY_RESULT: BookSearchResult = { results: [], failedSources: [] };
const QUICK_SEARCHES = ['三体', '刘慈欣', '东野圭吾', '哈利波特'];
export default function BooksSearchPage() { export default function BooksSearchPage() {
const router = useRouter(); const router = useRouter();
@@ -52,39 +77,65 @@ export default function BooksSearchPage() {
const pendingResultsRef = useRef<BookListItem[]>([]); const pendingResultsRef = useRef<BookListItem[]>([]);
const flushTimerRef = useRef<number | null>(null); 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) => { const getCachedResult = useCallback(
if (typeof window === 'undefined' || !keyword.trim()) return null; (keyword: string, selectedSourceId: string) => {
try { if (typeof window === 'undefined' || !keyword.trim()) return null;
const raw = sessionStorage.getItem(getCacheKey(keyword, selectedSourceId)); try {
return raw ? (JSON.parse(raw) as BookSearchResult) : null; const raw = sessionStorage.getItem(
} catch { getCacheKey(keyword, selectedSourceId)
return null; );
} return raw ? (JSON.parse(raw) as BookSearchResult) : null;
}, [getCacheKey]); } catch {
return null;
}
},
[getCacheKey]
);
const setCachedResult = useCallback((keyword: string, selectedSourceId: string, nextResult: BookSearchResult) => { const setCachedResult = useCallback(
if (typeof window === 'undefined' || !keyword.trim()) return; (
try { keyword: string,
sessionStorage.setItem(getCacheKey(keyword, selectedSourceId), JSON.stringify(nextResult)); selectedSourceId: string,
} catch {} nextResult: BookSearchResult
}, [getCacheKey]); ) => {
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(() => { const readFluidSearchSetting = useCallback(() => {
if (typeof window === 'undefined') return true; if (typeof window === 'undefined') return true;
try { try {
const savedFluidSearch = localStorage.getItem('fluidSearch'); const savedFluidSearch = localStorage.getItem('fluidSearch');
if (savedFluidSearch !== null) return JSON.parse(savedFluidSearch) !== false; if (savedFluidSearch !== null)
} catch {} return JSON.parse(savedFluidSearch) !== false;
return (window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false; } catch {
// Ignore storage/browser cleanup failures.
}
return (window as RuntimeWindow).RUNTIME_CONFIG?.FLUID_SEARCH !== false;
}, []); }, []);
const closeEventSource = useCallback(() => { const closeEventSource = useCallback(() => {
if (eventSourceRef.current) { if (eventSourceRef.current) {
try { try {
eventSourceRef.current.close(); eventSourceRef.current.close();
} catch {} } catch {
// Ignore cleanup failures.
}
eventSourceRef.current = null; eventSourceRef.current = null;
} }
}, []); }, []);
@@ -105,170 +156,248 @@ export default function BooksSearchPage() {
const toAppend = pendingResultsRef.current; const toAppend = pendingResultsRef.current;
pendingResultsRef.current = []; pendingResultsRef.current = [];
startTransition(() => { startTransition(() => {
setResult((prev) => ({ ...prev, results: prev.results.concat(toAppend) })); setResult((prev) => ({
...prev,
results: prev.results.concat(toAppend),
}));
}); });
flushTimerRef.current = null; flushTimerRef.current = null;
}, 80); }, 80);
} }
}, []); }, []);
const saveSearchState = useCallback((nextState: { q: string; sourceId: string; result: BookSearchResult }) => { const saveSearchState = useCallback(
if (typeof window === 'undefined') return; (nextState: { q: string; sourceId: string; result: BookSearchResult }) => {
try { if (typeof window === 'undefined') return;
sessionStorage.setItem(BOOK_SEARCH_STATE_KEY, JSON.stringify(nextState)); try {
} catch {} sessionStorage.setItem(
}, []); BOOK_SEARCH_STATE_KEY,
JSON.stringify(nextState)
);
} catch {
// Ignore storage failures.
}
},
[]
);
const restoreSearchState = useCallback(() => { const restoreSearchState = useCallback(() => {
if (typeof window === 'undefined') return null; if (typeof window === 'undefined') return null;
try { try {
const raw = sessionStorage.getItem(BOOK_SEARCH_STATE_KEY); 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 { } catch {
return null; return null;
} }
}, []); }, []);
const performSearch = useCallback(async (keyword: string, selectedSourceId: string, options?: { forceRefresh?: boolean }) => { const performSearch = useCallback(
const trimmed = keyword.trim(); async (
if (!trimmed) return; keyword: string,
const normalizedSourceId = selectedSourceId || ''; selectedSourceId: string,
const searchKey = `${normalizedSourceId}::${trimmed}`; options?: { forceRefresh?: boolean }
const forceRefresh = options?.forceRefresh === true; ) => {
const trimmed = keyword.trim();
if (!trimmed) return;
const normalizedSourceId = selectedSourceId || '';
const searchKey = `${normalizedSourceId}::${trimmed}`;
const forceRefresh = options?.forceRefresh === true;
closeEventSource(); closeEventSource();
clearPendingResults(); clearPendingResults();
currentSearchKeyRef.current = searchKey; currentSearchKeyRef.current = searchKey;
setLoading(true); setLoading(true);
setError(''); setError('');
setHasSearched(true); setHasSearched(true);
setTotalSources(0); setTotalSources(0);
setCompletedSources(0); setCompletedSources(0);
const cached = forceRefresh ? null : getCachedResult(trimmed, normalizedSourceId); const cached = forceRefresh
if (cached) { ? null
setResult(cached); : getCachedResult(trimmed, normalizedSourceId);
saveSearchState({ q: trimmed, sourceId: normalizedSourceId, result: cached }); if (cached) {
setLoading(false); setResult(cached);
setTotalSources(1); saveSearchState({
setCompletedSources(1); q: trimmed,
return; sourceId: normalizedSourceId,
} result: cached,
});
setLoading(false);
setTotalSources(1);
setCompletedSources(1);
return;
}
setResult(EMPTY_RESULT); setResult(EMPTY_RESULT);
const currentFluidSearch = readFluidSearchSetting(); const currentFluidSearch = readFluidSearchSetting();
setUseFluidSearch((prev) => (prev === currentFluidSearch ? prev : currentFluidSearch)); setUseFluidSearch((prev) =>
prev === currentFluidSearch ? prev : currentFluidSearch
);
const params = new URLSearchParams({ q: trimmed }); const params = new URLSearchParams({ q: trimmed });
if (normalizedSourceId) params.set('sourceId', normalizedSourceId); if (normalizedSourceId) params.set('sourceId', normalizedSourceId);
if (currentFluidSearch) { if (currentFluidSearch) {
const es = new EventSource(`/api/books/search/ws?${params.toString()}`); const es = new EventSource(`/api/books/search/ws?${params.toString()}`);
eventSourceRef.current = es; eventSourceRef.current = es;
es.onmessage = (event) => { es.onmessage = (event) => {
if (!event.data || currentSearchKeyRef.current !== searchKey) return; if (!event.data || currentSearchKeyRef.current !== searchKey) return;
try { try {
const payload = JSON.parse(event.data); const payload = JSON.parse(event.data);
switch (payload.type) { switch (payload.type) {
case 'start': case 'start':
setTotalSources(payload.totalSources || 0); setTotalSources(payload.totalSources || 0);
setCompletedSources(0); setCompletedSources(0);
break; break;
case 'source_result': case 'source_result':
setCompletedSources((prev) => Math.max(prev + 1, payload.completedSources || 0)); setCompletedSources((prev) =>
if (Array.isArray(payload.results) && payload.results.length > 0) { Math.max(prev + 1, payload.completedSources || 0)
appendBufferedResults(payload.results as BookListItem[]); );
} if (
break; Array.isArray(payload.results) &&
case 'source_error': { payload.results.length > 0
setCompletedSources((prev) => Math.max(prev + 1, payload.completedSources || 0)); ) {
break; appendBufferedResults(payload.results as BookListItem[]);
}
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(() => { 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) => { setResult((prev) => {
const nextResult = { results: prev.results.concat(toAppend), failedSources: finalFailedSources }; const nextResult = {
results: prev.results,
failedSources: finalFailedSources,
};
setCachedResult(trimmed, normalizedSourceId, nextResult); setCachedResult(trimmed, normalizedSourceId, nextResult);
saveSearchState({ q: trimmed, sourceId: normalizedSourceId, result: nextResult }); saveSearchState({
q: trimmed,
sourceId: normalizedSourceId,
result: nextResult,
});
return nextResult; return nextResult;
}); });
}); }
} else { setLoading(false);
setResult((prev) => { closeEventSource();
const nextResult = { results: prev.results, failedSources: finalFailedSources }; break;
setCachedResult(trimmed, normalizedSourceId, nextResult);
saveSearchState({ q: trimmed, sourceId: normalizedSourceId, result: nextResult });
return nextResult;
});
} }
setLoading(false);
closeEventSource();
break;
} }
} catch {
// Ignore malformed streaming payloads.
} }
} catch {} };
};
es.onerror = () => { es.onerror = () => {
if (currentSearchKeyRef.current !== searchKey) return; if (currentSearchKeyRef.current !== searchKey) return;
if (pendingResultsRef.current.length > 0) { if (pendingResultsRef.current.length > 0) {
const toAppend = pendingResultsRef.current; const toAppend = pendingResultsRef.current;
pendingResultsRef.current = []; pendingResultsRef.current = [];
if (flushTimerRef.current) { if (flushTimerRef.current) {
window.clearTimeout(flushTimerRef.current); window.clearTimeout(flushTimerRef.current);
flushTimerRef.current = null; flushTimerRef.current = null;
}
startTransition(() => {
setResult((prev) => ({
...prev,
results: prev.results.concat(toAppend),
}));
});
} }
startTransition(() => { setLoading(false);
setResult((prev) => ({ ...prev, results: prev.results.concat(toAppend) })); closeEventSource();
}); };
} return;
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);
} }
}
}, [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(() => { useEffect(() => {
setUseFluidSearch(readFluidSearchSetting()); 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 () => { return () => {
closeEventSource(); closeEventSource();
clearPendingResults(); clearPendingResults();
@@ -310,7 +439,14 @@ export default function BooksSearchPage() {
const forceRefresh = forceNextUrlSearchRef.current; const forceRefresh = forceNextUrlSearchRef.current;
forceNextUrlSearchRef.current = false; forceNextUrlSearchRef.current = false;
void performSearch(keyword, source, { forceRefresh }); void performSearch(keyword, source, { forceRefresh });
}, [clearPendingResults, closeEventSource, performSearch, restoreSearchState, urlQuery, urlSourceId]); }, [
clearPendingResults,
closeEventSource,
performSearch,
restoreSearchState,
urlQuery,
urlSourceId,
]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); 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 ( return (
<div className='space-y-6'> <div className='space-y-7'>
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'> <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'>
<form onSubmit={handleSubmit} className='space-y-3'> <div className='absolute -right-20 -top-24 h-64 w-64 rounded-full bg-emerald-300/25 blur-3xl dark:bg-emerald-500/10' />
<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' /> <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' />
<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'> <div className='relative'>
<option value=''></option> <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'>
{sources.map((source) => <option key={source.id} value={source.id}>{source.name}</option>)} <Sparkles className='h-3.5 w-3.5' />
</select> Search First · Reduce Friction
<button className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'></button> </div>
</form>
<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> </section>
<div className='flex items-center justify-between gap-3'> <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'>
<h2 className='text-lg font-semibold'>{result.results.length > 0 ? `${result.results.length}` : ''}</h2> <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 ? ( {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} ) : null}
</div> </section>
{loading && result.results.length === 0 ? <SearchSkeleton /> : null} {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'> <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> </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> </div>
); );
} }
+99 -14
View File
@@ -1,43 +1,128 @@
'use client'; 'use client';
import { BookmarkCheck, BookOpen, Trash2 } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { buildBookDetailPath, cacheBookShelfItem } from '@/lib/book-route-cache.client';
import { deleteBookShelf, getAllBookShelf } from '@/lib/book.db.client'; import { deleteBookShelf, getAllBookShelf } from '@/lib/book.db.client';
import { BookShelfItem } from '@/lib/book.types'; import { BookShelfItem } from '@/lib/book.types';
import {
buildBookDetailPath,
cacheBookShelfItem,
} from '@/lib/book-route-cache.client';
export default function BookShelfPage() { export default function BookShelfPage() {
const [shelf, setShelf] = useState<Record<string, BookShelfItem>>({}); const [shelf, setShelf] = useState<Record<string, BookShelfItem>>({});
useEffect(() => { useEffect(() => {
getAllBookShelf().then(setShelf).catch(() => undefined); getAllBookShelf()
.then(setShelf)
.catch(() => undefined);
}, []); }, []);
const items = useMemo(() => Object.values(shelf).sort((a, b) => (b.lastReadTime || b.saveTime) - (a.lastReadTime || a.saveTime)), [shelf]); const items = useMemo(
() =>
Object.values(shelf).sort(
(a, b) =>
(b.lastReadTime || b.saveTime) - (a.lastReadTime || a.saveTime)
),
[shelf]
);
return ( return (
<div className='space-y-4'> <div className='space-y-5'>
<div className='text-sm text-gray-500'> {items.length} </div> <section className='rounded-[2rem] border border-emerald-100/80 bg-white/85 p-5 shadow-sm shadow-emerald-950/5 dark:border-emerald-500/10 dark:bg-gray-950/70'>
<div className='flex items-center justify-between gap-4'>
<div>
<div className='text-sm font-medium text-emerald-600 dark:text-emerald-300'>
</div>
<div className='mt-1 text-2xl font-black tracking-tight text-slate-950 dark:text-white'>
{items.length}
</div>
</div>
<div className='flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50 text-emerald-600 ring-1 ring-emerald-100 dark:bg-emerald-500/10 dark:text-emerald-200 dark:ring-emerald-500/20'>
<BookmarkCheck className='h-6 w-6' />
</div>
</div>
</section>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'> <div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
{items.map((item) => ( {items.map((item) => (
<div key={`${item.sourceId}-${item.bookId}`} className='rounded-3xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-950'> <article
key={`${item.sourceId}-${item.bookId}`}
className='rounded-[2rem] border border-emerald-100/80 bg-white/85 p-4 shadow-sm shadow-emerald-950/5 transition-colors duration-200 hover:border-emerald-200 hover:bg-white dark:border-emerald-500/10 dark:bg-gray-950/70 dark:hover:border-emerald-500/30'
>
<div className='flex gap-4'> <div className='flex gap-4'>
<div className='h-28 w-20 overflow-hidden rounded-2xl bg-gray-100 dark:bg-gray-900'>{item.cover ? <img src={item.cover} alt={item.title} className='h-full w-full object-cover' /> : null}</div> <div className='h-28 w-20 shrink-0 overflow-hidden rounded-2xl bg-gradient-to-br from-emerald-50 to-amber-50 ring-1 ring-emerald-100 dark:from-gray-900 dark:to-emerald-950/20 dark:ring-emerald-500/10'>
{item.cover ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.cover}
alt={item.title}
className='h-full w-full object-cover'
/>
) : (
<div className='flex h-full items-center justify-center text-slate-400'>
<BookOpen className='h-7 w-7' />
</div>
)}
</div>
<div className='min-w-0 flex-1'> <div className='min-w-0 flex-1'>
<div className='truncate font-medium'>{item.title}</div> <div className='truncate font-semibold text-slate-950 dark:text-white'>
<div className='mt-1 text-sm text-gray-500'>{item.author || item.sourceName}</div> {item.title}
<div className='mt-2 text-xs text-gray-500'> {Math.round(item.progressPercent || 0)}%</div> </div>
<div className='mt-1 truncate text-sm text-slate-500 dark:text-slate-400'>
{item.author || item.sourceName}
</div>
<div className='mt-3 h-2 overflow-hidden rounded-full bg-emerald-50 dark:bg-gray-900'>
<div
className='h-full rounded-full bg-emerald-600'
style={{
width: `${Math.max(
0,
Math.min(100, Math.round(item.progressPercent || 0))
)}%`,
}}
/>
</div>
<div className='mt-1 text-xs text-slate-500 dark:text-slate-400'>
{Math.round(item.progressPercent || 0)}%
</div>
<div className='mt-3 flex flex-wrap gap-2'> <div className='mt-3 flex flex-wrap gap-2'>
<Link href={buildBookDetailPath(item.sourceId, item.bookId)} onClick={() => cacheBookShelfItem(item)} className='rounded-2xl bg-sky-600 px-3 py-2 text-xs text-white'></Link> <Link
<button onClick={async () => { await deleteBookShelf(item.sourceId, item.bookId); setShelf((prev) => { const next = { ...prev }; delete next[`${item.sourceId}+${item.bookId}`]; return next; }); }} className='rounded-2xl border border-gray-200 px-3 py-2 text-xs dark:border-gray-700'></button> href={buildBookDetailPath(item.sourceId, item.bookId)}
onClick={() => cacheBookShelfItem(item)}
className='inline-flex cursor-pointer items-center gap-1.5 rounded-2xl bg-emerald-600 px-3 py-2 text-xs font-semibold text-white transition-colors duration-200 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500'
>
</Link>
<button
type='button'
onClick={async () => {
await deleteBookShelf(item.sourceId, item.bookId);
setShelf((prev) => {
const next = { ...prev };
delete next[`${item.sourceId}+${item.bookId}`];
return next;
});
}}
className='inline-flex cursor-pointer items-center gap-1.5 rounded-2xl border border-emerald-100 px-3 py-2 text-xs font-semibold text-slate-600 transition-colors duration-200 hover:bg-emerald-50 hover:text-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:border-emerald-500/10 dark:text-slate-300 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
>
<Trash2 className='h-3.5 w-3.5' />
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </article>
))} ))}
</div> </div>
{items.length === 0 ? <div className='text-sm text-gray-500'></div> : null} {items.length === 0 ? (
<div className='rounded-3xl border border-dashed border-emerald-200 bg-white/70 p-8 text-center text-sm text-slate-500 dark:border-emerald-500/20 dark:bg-gray-950/50 dark:text-slate-400'>
</div>
) : null}
</div> </div>
); );
} }
+44 -12
View File
@@ -1,30 +1,62 @@
'use client'; 'use client';
import { BookOpen, Library } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { BookListItem } from '@/lib/book.types'; import { BookListItem } from '@/lib/book.types';
export default function BookCard({ item, href, extra, onNavigate }: { item: BookListItem; href: string; extra?: React.ReactNode; onNavigate?: () => void }) { export default function BookCard({
item,
href,
extra,
onNavigate,
}: {
item: BookListItem;
href: string;
extra?: React.ReactNode;
onNavigate?: () => void;
}) {
return ( return (
<div className='overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-950'> <article className='group overflow-hidden rounded-[1.75rem] border border-emerald-100/80 bg-white/85 shadow-sm shadow-emerald-950/5 transition-colors duration-200 hover:border-emerald-200 hover:bg-white dark:border-emerald-500/10 dark:bg-gray-950/70 dark:hover:border-emerald-500/30'>
<Link href={href} onClick={onNavigate}> <Link
<div className='relative aspect-[3/4] bg-gray-100 dark:bg-gray-900'> href={href}
onClick={onNavigate}
className='block cursor-pointer focus:outline-none focus:ring-2 focus:ring-inset focus:ring-emerald-500'
>
<div className='relative aspect-[3/4] overflow-hidden bg-gradient-to-br from-emerald-50 to-amber-50 dark:from-gray-900 dark:to-emerald-950/20'>
{item.cover ? ( {item.cover ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={item.cover} alt={item.title} className='h-full w-full object-cover' /> <img
src={item.cover}
alt={item.title}
className='h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]'
/>
) : ( ) : (
<div className='flex h-full items-center justify-center text-sm text-gray-400'></div> <div className='flex h-full flex-col items-center justify-center gap-2 text-sm text-slate-400 dark:text-slate-500'>
<BookOpen className='h-8 w-8' />
</div>
)} )}
<div className='absolute right-2 top-2 max-w-[70%] truncate rounded-full bg-black/70 px-2 py-1 text-[11px] text-white'> <div className='absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/55 to-transparent opacity-80' />
{item.sourceName} <div className='absolute right-2 top-2 inline-flex max-w-[74%] items-center gap-1.5 truncate rounded-full bg-black/65 px-2.5 py-1 text-[11px] font-medium text-white shadow-lg backdrop-blur'>
<Library className='h-3 w-3 shrink-0' />
<span className='truncate'>{item.sourceName}</span>
</div> </div>
</div> </div>
</Link> </Link>
<div className='space-y-2 p-3'> <div className='space-y-2 p-3.5'>
<Link href={href} onClick={onNavigate} className='line-clamp-2 text-sm font-medium hover:text-sky-600'>{item.title}</Link> <Link
<div className='line-clamp-1 text-xs text-gray-500 dark:text-gray-400'>{item.author || '未知作者'}</div> href={href}
onClick={onNavigate}
className='line-clamp-2 cursor-pointer text-sm font-semibold leading-5 text-slate-950 transition-colors duration-200 hover:text-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:text-white dark:hover:text-emerald-200'
>
{item.title}
</Link>
<div className='line-clamp-1 text-xs text-slate-500 dark:text-slate-400'>
{item.author || '未知作者'}
</div>
{extra} {extra}
</div> </div>
</div> </article>
); );
} }
+124 -33
View File
@@ -1,11 +1,23 @@
'use client'; 'use client';
import { BookOpen, ChevronLeft, Headphones, History, Library, List, MoreVertical, Search, Settings2 } from 'lucide-react'; import {
BookOpen,
ChevronLeft,
Headphones,
History,
Library,
List,
MoreVertical,
Search,
Settings2,
Sparkles,
} from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation'; import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useSite } from '@/components/SiteProvider'; import { useSite } from '@/components/SiteProvider';
import { ThemeToggle } from '@/components/ThemeToggle';
const tabs = [ const tabs = [
{ href: '/books', label: '发现', icon: Library }, { href: '/books', label: '发现', icon: Library },
@@ -21,15 +33,24 @@ type ReadHeaderPayload = {
}; };
function getStaticMeta(pathname: string) { function getStaticMeta(pathname: string) {
if (pathname === '/books/shelf') return { title: '电子书书架', subtitle: '集中管理收藏的电子书' }; if (pathname === '/books/shelf')
if (pathname === '/books/history') return { title: '阅读历史', subtitle: '从上次阅读的位置继续' }; return { title: '电子书书架', subtitle: '集中管理收藏的电子书' };
if (pathname === '/books/search') return { title: '电子书搜索', subtitle: '按书名与作者搜索' }; if (pathname === '/books/history')
if (pathname === '/books/detail') return { title: '电子书详情', subtitle: '查看书籍信息与可用格式' }; return { title: '阅读历史', subtitle: '从上次阅读的位置继续' };
if (pathname === '/books/read') return { title: '电子书阅读', subtitle: '分页阅读', backHref: '/books' }; if (pathname === '/books/search')
return { title: '电子书搜索', subtitle: '按书名与作者搜索' };
if (pathname === '/books/detail')
return { title: '电子书详情', subtitle: '查看书籍信息与可用格式' };
if (pathname === '/books/read')
return { title: '电子书阅读', subtitle: '分页阅读', backHref: '/books' };
return { title: '电子书馆' }; return { title: '电子书馆' };
} }
export default function BooksLayout({ children }: { children: React.ReactNode }) { export default function BooksLayout({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { siteName } = useSite(); const { siteName } = useSite();
@@ -44,9 +65,15 @@ export default function BooksLayout({ children }: { children: React.ReactNode })
const custom = event as CustomEvent<ReadHeaderPayload>; const custom = event as CustomEvent<ReadHeaderPayload>;
setReadHeader(custom.detail || null); setReadHeader(custom.detail || null);
}; };
window.addEventListener('books-read-update-header', handleUpdate as EventListener); window.addEventListener(
'books-read-update-header',
handleUpdate as EventListener
);
return () => { return () => {
window.removeEventListener('books-read-update-header', handleUpdate as EventListener); window.removeEventListener(
'books-read-update-header',
handleUpdate as EventListener
);
}; };
}, [isRead]); }, [isRead]);
@@ -80,39 +107,69 @@ export default function BooksLayout({ children }: { children: React.ReactNode })
return { return {
title: readHeader?.title || base.title, title: readHeader?.title || base.title,
subtitle: readHeader?.subtitle || base.subtitle, subtitle: readHeader?.subtitle || base.subtitle,
backHref: readHeader?.backHref || `/books/detail?sourceId=${encodeURIComponent(searchParams.get('sourceId') || '')}&bookId=${encodeURIComponent(searchParams.get('bookId') || '')}`, backHref:
readHeader?.backHref ||
`/books/detail?sourceId=${encodeURIComponent(
searchParams.get('sourceId') || ''
)}&bookId=${encodeURIComponent(searchParams.get('bookId') || '')}`,
}; };
} }
return base; return base;
}, [pathname, searchParams, isRead, readHeader]); }, [pathname, searchParams, isRead, readHeader]);
return ( return (
<div className='min-h-screen bg-gray-50 text-gray-900 dark:bg-black dark:text-gray-100'> <div className='min-h-screen bg-[radial-gradient(circle_at_top_left,#fce7f3_0,transparent_34rem),linear-gradient(180deg,#fff7fb_0%,#f8fafc_44%,#f8fafc_100%)] text-slate-900 dark:bg-[radial-gradient(circle_at_top_left,rgba(6,95,70,0.26)_0,transparent_32rem),linear-gradient(180deg,#050505_0%,#09090b_100%)] dark:text-gray-100'>
<header className='fixed inset-x-0 top-0 z-40 border-b border-gray-200/70 bg-white/90 backdrop-blur dark:border-gray-800 dark:bg-gray-950/90'> <header className='fixed inset-x-0 top-0 z-40 border-b border-emerald-100/80 bg-white/85 shadow-sm shadow-emerald-950/5 backdrop-blur-xl dark:border-emerald-500/10 dark:bg-gray-950/85 dark:shadow-black/20'>
<div className='mx-auto flex h-14 max-w-6xl items-center gap-3 px-4'> <div className='mx-auto flex h-16 max-w-6xl items-center gap-3 px-4'>
{isRead || pathname === '/books/detail' ? ( {isRead || pathname === '/books/detail' ? (
<Link href={meta.backHref || '/books'} className='inline-flex h-10 w-10 items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800'> <Link
href={meta.backHref || '/books'}
className='inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full text-slate-700 transition-colors duration-200 hover:bg-emerald-50 hover:text-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:text-slate-200 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
>
<ChevronLeft className='h-5 w-5' /> <ChevronLeft className='h-5 w-5' />
</Link> </Link>
) : ( ) : (
<Link href='/' className='text-sm font-semibold text-sky-600'>{siteName}</Link> <Link
href='/'
className='inline-flex cursor-pointer items-center gap-2 rounded-full bg-emerald-50 px-3 py-2 text-sm font-bold text-emerald-700 transition-colors duration-200 hover:bg-emerald-100 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:bg-emerald-500/10 dark:text-emerald-200 dark:hover:bg-emerald-500/20'
>
<Sparkles className='h-4 w-4' />
{siteName}
</Link>
)} )}
<div className='min-w-0 flex-1'> <div className='min-w-0 flex-1'>
<div className='group relative'> <div className='group relative'>
<div className='truncate text-sm font-semibold sm:text-base'>{meta.title}</div> <div className='truncate text-sm font-bold tracking-tight text-slate-950 dark:text-white sm:text-base'>
{meta.title}
</div>
<div className='absolute left-1/2 top-full z-[100] mt-2 w-max max-w-[85vw] -translate-x-1/2 rounded-lg bg-gray-800 px-3 py-2 text-center text-sm text-white opacity-0 invisible shadow-xl transition-all duration-200 ease-out pointer-events-none group-hover:visible group-hover:opacity-100 dark:bg-gray-900'> <div className='absolute left-1/2 top-full z-[100] mt-2 w-max max-w-[85vw] -translate-x-1/2 rounded-lg bg-gray-800 px-3 py-2 text-center text-sm text-white opacity-0 invisible shadow-xl transition-all duration-200 ease-out pointer-events-none group-hover:visible group-hover:opacity-100 dark:bg-gray-900'>
<div className='max-w-[85vw] break-words whitespace-normal sm:max-w-none sm:whitespace-nowrap'>{meta.title}</div> <div className='max-w-[85vw] break-words whitespace-normal sm:max-w-none sm:whitespace-nowrap'>
{meta.subtitle ? <div className='mt-1 max-w-[85vw] break-words whitespace-normal text-xs text-gray-300 sm:max-w-none sm:whitespace-nowrap'>{meta.subtitle}</div> : null} {meta.title}
</div>
{meta.subtitle ? (
<div className='mt-1 max-w-[85vw] break-words whitespace-normal text-xs text-gray-300 sm:max-w-none sm:whitespace-nowrap'>
{meta.subtitle}
</div>
) : null}
</div> </div>
</div> </div>
<div className='truncate text-xs text-gray-500 dark:text-gray-400'>{meta.subtitle}</div> <div className='truncate text-xs text-slate-500 dark:text-slate-400'>
{meta.subtitle}
</div>
</div>
<div className='hidden md:block'>
<ThemeToggle />
</div> </div>
{isRead ? ( {isRead ? (
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<button <button
type='button' type='button'
onClick={() => window.dispatchEvent(new CustomEvent('books-read-toggle-chapters'))} onClick={() =>
className='inline-flex h-10 w-10 items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800' window.dispatchEvent(
new CustomEvent('books-read-toggle-chapters')
)
}
className='inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full 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='目录' aria-label='目录'
> >
<List className='h-5 w-5' /> <List className='h-5 w-5' />
@@ -121,20 +178,22 @@ export default function BooksLayout({ children }: { children: React.ReactNode })
<button <button
type='button' type='button'
onClick={() => setReadMenuOpen((prev) => !prev)} onClick={() => setReadMenuOpen((prev) => !prev)}
className='inline-flex h-10 w-10 items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800' className='inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full 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='更多' aria-label='更多'
> >
<MoreVertical className='h-5 w-5' /> <MoreVertical className='h-5 w-5' />
</button> </button>
{readMenuOpen ? ( {readMenuOpen ? (
<div className='absolute right-0 top-12 z-50 min-w-[9rem] overflow-hidden rounded-2xl border border-gray-200 bg-white py-1 shadow-xl dark:border-gray-800 dark:bg-gray-950'> <div className='absolute right-0 top-12 z-50 min-w-[9rem] overflow-hidden rounded-2xl border border-emerald-100 bg-white py-1 shadow-xl shadow-emerald-950/10 dark:border-emerald-500/10 dark:bg-gray-950'>
<button <button
type='button' type='button'
onClick={() => { onClick={() => {
setReadMenuOpen(false); setReadMenuOpen(false);
window.dispatchEvent(new CustomEvent('books-read-toggle-settings')); window.dispatchEvent(
new CustomEvent('books-read-toggle-settings')
);
}} }}
className='flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900' className='flex w-full cursor-pointer items-center gap-2 px-4 py-2.5 text-left text-sm text-slate-700 transition-colors duration-200 hover:bg-emerald-50 hover:text-emerald-700 dark:text-gray-200 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
> >
<Settings2 className='h-4 w-4' /> <Settings2 className='h-4 w-4' />
@@ -143,9 +202,11 @@ export default function BooksLayout({ children }: { children: React.ReactNode })
type='button' type='button'
onClick={() => { onClick={() => {
setReadMenuOpen(false); setReadMenuOpen(false);
window.dispatchEvent(new CustomEvent('books-read-toggle-tts')); window.dispatchEvent(
new CustomEvent('books-read-toggle-tts')
);
}} }}
className='flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900' className='flex w-full cursor-pointer items-center gap-2 px-4 py-2.5 text-left text-sm text-slate-700 transition-colors duration-200 hover:bg-emerald-50 hover:text-emerald-700 dark:text-gray-200 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
> >
<Headphones className='h-4 w-4' /> <Headphones className='h-4 w-4' />
@@ -160,7 +221,15 @@ export default function BooksLayout({ children }: { children: React.ReactNode })
const active = pathname === tab.href; const active = pathname === tab.href;
const Icon = tab.icon; const Icon = tab.icon;
return ( return (
<Link key={tab.href} href={tab.href} className={`inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm ${active ? 'bg-sky-600 text-white' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'}`}> <Link
key={tab.href}
href={tab.href}
className={`inline-flex cursor-pointer items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-emerald-500 ${
active
? 'bg-emerald-600 text-white shadow-sm shadow-emerald-600/20'
: 'text-slate-600 hover:bg-emerald-50 hover:text-emerald-700 dark:text-gray-300 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
}`}
>
<Icon className='h-4 w-4' /> <Icon className='h-4 w-4' />
{tab.label} {tab.label}
</Link> </Link>
@@ -170,16 +239,38 @@ export default function BooksLayout({ children }: { children: React.ReactNode })
)} )}
</div> </div>
</header> </header>
<main className={`mx-auto max-w-6xl ${isRead ? 'pt-14' : 'px-4 pb-24 pt-20'}`}>{children}</main> <main
className={`mx-auto max-w-6xl ${isRead ? 'pt-16' : 'px-4 pb-24 pt-24'}`}
>
{children}
</main>
{!isRead && ( {!isRead && (
<nav className='fixed inset-x-0 bottom-0 z-40 grid grid-cols-4 border-t border-gray-200/70 bg-white/95 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95 md:hidden'> <nav className='fixed inset-x-0 bottom-0 z-40 grid grid-cols-4 border-t border-emerald-100/80 bg-white/95 shadow-[0_-12px_32px_rgba(6,95,70,0.08)] backdrop-blur-xl dark:border-emerald-500/10 dark:bg-gray-950/95 md:hidden'>
{tabs.map((tab) => { {tabs.map((tab) => {
const active = pathname === tab.href; const active = pathname === tab.href;
const Icon = tab.icon; const Icon = tab.icon;
return ( return (
<Link key={tab.href} href={tab.href} className='flex min-h-16 flex-col items-center justify-center gap-1 text-xs'> <Link
<Icon className={`h-5 w-5 ${active ? 'text-sky-600' : 'text-gray-500'}`} /> key={tab.href}
<span className={active ? 'text-sky-600' : 'text-gray-600 dark:text-gray-300'}>{tab.label}</span> href={tab.href}
className='flex min-h-16 cursor-pointer flex-col items-center justify-center gap-1 text-xs transition-colors duration-200 hover:bg-emerald-50 dark:hover:bg-emerald-500/10'
>
<Icon
className={`h-5 w-5 ${
active
? 'text-emerald-600 dark:text-emerald-300'
: 'text-gray-500'
}`}
/>
<span
className={
active
? 'font-semibold text-emerald-600 dark:text-emerald-300'
: 'text-gray-600 dark:text-gray-300'
}
>
{tab.label}
</span>
</Link> </Link>
); );
})} })}