美化电子书馆

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
+228 -66
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,37 +161,64 @@ 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 seen = new Set(
prev.map(
(item) =>
`${item.sourceId}::${item.id}::${
item.detailHref || item.acquisitionLinks[0]?.href || ''
}`
)
);
const merged = [...prev]; const merged = [...prev];
for (const item of next) { for (const item of next) {
const key = `${item.sourceId}::${item.id}::${item.detailHref || item.acquisitionLinks[0]?.href || ''}`; const key = `${item.sourceId}::${item.id}::${
item.detailHref || item.acquisitionLinks[0]?.href || ''
}`;
if (!seen.has(key)) { if (!seen.has(key)) {
seen.add(key); seen.add(key);
merged.push(item); merged.push(item);
} }
} }
return merged; return merged;
}, []); },
[]
);
const loadCatalog = useCallback(async (targetHref?: string, append = false) => { const loadCatalog = useCallback(
async (targetHref?: string, append = false) => {
if (!sourceId) return; if (!sourceId) return;
const normalizedHref = targetHref || ''; const normalizedHref = targetHref || '';
if (append) { if (append) {
if (!normalizedHref || loadedPageHrefsRef.current.has(normalizedHref) || failedPageHrefsRef.current.has(normalizedHref)) return; if (
!normalizedHref ||
loadedPageHrefsRef.current.has(normalizedHref) ||
failedPageHrefsRef.current.has(normalizedHref)
)
return;
setLoadingMore(true); setLoadingMore(true);
} else { } else {
setError(''); setError('');
@@ -163,7 +226,9 @@ export default function BooksCatalogPage() {
if (!normalizedHref) setData(null); if (!normalizedHref) setData(null);
setEntries([]); setEntries([]);
setNextHref(undefined); setNextHref(undefined);
loadedPageHrefsRef.current = new Set(normalizedHref ? [normalizedHref] : ['__root__']); loadedPageHrefsRef.current = new Set(
normalizedHref ? [normalizedHref] : ['__root__']
);
failedPageHrefsRef.current = new Set(); failedPageHrefsRef.current = new Set();
} }
@@ -179,7 +244,13 @@ export default function BooksCatalogPage() {
setEntries((prev) => mergeEntries(prev, nextData.entries || [])); setEntries((prev) => mergeEntries(prev, nextData.entries || []));
} else { } else {
setData(nextData); setData(nextData);
setCatalogNavigation((prev) => normalizedHref ? (prev.length > 0 ? prev : nextData.navigation || []) : nextData.navigation || []); setCatalogNavigation((prev) =>
normalizedHref
? prev.length > 0
? prev
: nextData.navigation || []
: nextData.navigation || []
);
setEntries(nextData.entries || []); setEntries(nextData.entries || []);
} }
setNextHref(nextData.nextHref || undefined); setNextHref(nextData.nextHref || undefined);
@@ -194,7 +265,9 @@ export default function BooksCatalogPage() {
if (!append) setLoadingCatalog(false); if (!append) setLoadingCatalog(false);
setLoadingMore(false); setLoadingMore(false);
} }
}, [mergeEntries, sourceId]); },
[mergeEntries, sourceId]
);
useEffect(() => { useEffect(() => {
if (!sourceId) return; if (!sourceId) return;
@@ -205,18 +278,22 @@ 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(
(entries) => {
const entry = entries[0]; const entry = entries[0];
if (entry?.isIntersecting && nextHref && !loadingMore) { if (entry?.isIntersecting && nextHref && !loadingMore) {
void loadCatalog(nextHref, true); 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(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType === 'mouse' && event.button !== 0) return; if (event.pointerType === 'mouse' && event.button !== 0) return;
const node = sourceScrollerRef.current; const node = sourceScrollerRef.current;
if (!node) return; if (!node) return;
@@ -231,12 +308,16 @@ export default function BooksCatalogPage() {
if (event.pointerType !== 'mouse') { if (event.pointerType !== 'mouse') {
node.setPointerCapture?.(event.pointerId); node.setPointerCapture?.(event.pointerId);
} }
}, []); },
[]
);
const handleSourcePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { const handleSourcePointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
const node = sourceScrollerRef.current; const node = sourceScrollerRef.current;
const dragState = sourceDragStateRef.current; const dragState = sourceDragStateRef.current;
if (!node || !dragState || dragState.pointerId !== event.pointerId) return; if (!node || !dragState || dragState.pointerId !== event.pointerId)
return;
const deltaX = event.clientX - dragState.startX; const deltaX = event.clientX - dragState.startX;
const moveThreshold = dragState.pointerType === 'mouse' ? 8 : 4; const moveThreshold = dragState.pointerType === 'mouse' ? 8 : 4;
if (Math.abs(deltaX) > moveThreshold) { if (Math.abs(deltaX) > moveThreshold) {
@@ -244,9 +325,12 @@ export default function BooksCatalogPage() {
suppressSourceClickRef.current = true; suppressSourceClickRef.current = true;
} }
node.scrollLeft = dragState.startScrollLeft - deltaX; node.scrollLeft = dragState.startScrollLeft - deltaX;
}, []); },
[]
);
const handleSourcePointerUp = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { const handleSourcePointerUp = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
const node = sourceScrollerRef.current; const node = sourceScrollerRef.current;
const dragState = sourceDragStateRef.current; const dragState = sourceDragStateRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) return; if (!dragState || dragState.pointerId !== event.pointerId) return;
@@ -260,22 +344,34 @@ export default function BooksCatalogPage() {
if (dragState.pointerType !== 'mouse') { if (dragState.pointerType !== 'mouse') {
node?.releasePointerCapture?.(event.pointerId); node?.releasePointerCapture?.(event.pointerId);
} }
}, []); },
[]
);
const handleSourcePointerLeave = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { const handleSourcePointerLeave = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType === 'mouse') return; if (event.pointerType === 'mouse') return;
handleSourcePointerUp(event); handleSourcePointerUp(event);
}, [handleSourcePointerUp]); },
[handleSourcePointerUp]
);
const handleSourceWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => { const handleSourceWheel = useCallback(
(event: ReactWheelEvent<HTMLDivElement>) => {
const node = sourceScrollerRef.current; const node = sourceScrollerRef.current;
if (!node) return; if (!node) return;
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; const delta =
Math.abs(event.deltaX) > Math.abs(event.deltaY)
? event.deltaX
: event.deltaY;
if (!delta) return; if (!delta) return;
node.scrollLeft += delta; node.scrollLeft += delta;
}, []); },
[]
);
const handleNavPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { const handleNavPointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType === 'mouse' && event.button !== 0) return; if (event.pointerType === 'mouse' && event.button !== 0) return;
const node = navScrollerRef.current; const node = navScrollerRef.current;
if (!node) return; if (!node) return;
@@ -290,12 +386,16 @@ export default function BooksCatalogPage() {
if (event.pointerType !== 'mouse') { if (event.pointerType !== 'mouse') {
node.setPointerCapture?.(event.pointerId); node.setPointerCapture?.(event.pointerId);
} }
}, []); },
[]
);
const handleNavPointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { const handleNavPointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
const node = navScrollerRef.current; const node = navScrollerRef.current;
const dragState = navDragStateRef.current; const dragState = navDragStateRef.current;
if (!node || !dragState || dragState.pointerId !== event.pointerId) return; if (!node || !dragState || dragState.pointerId !== event.pointerId)
return;
const deltaX = event.clientX - dragState.startX; const deltaX = event.clientX - dragState.startX;
const moveThreshold = dragState.pointerType === 'mouse' ? 8 : 4; const moveThreshold = dragState.pointerType === 'mouse' ? 8 : 4;
if (Math.abs(deltaX) > moveThreshold) { if (Math.abs(deltaX) > moveThreshold) {
@@ -303,9 +403,12 @@ export default function BooksCatalogPage() {
suppressNavClickRef.current = true; suppressNavClickRef.current = true;
} }
node.scrollLeft = dragState.startScrollLeft - deltaX; node.scrollLeft = dragState.startScrollLeft - deltaX;
}, []); },
[]
);
const handleNavPointerUp = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { const handleNavPointerUp = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
const node = navScrollerRef.current; const node = navScrollerRef.current;
const dragState = navDragStateRef.current; const dragState = navDragStateRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) return; if (!dragState || dragState.pointerId !== event.pointerId) return;
@@ -319,20 +422,31 @@ export default function BooksCatalogPage() {
if (dragState.pointerType !== 'mouse') { if (dragState.pointerType !== 'mouse') {
node?.releasePointerCapture?.(event.pointerId); node?.releasePointerCapture?.(event.pointerId);
} }
}, []); },
[]
);
const handleNavPointerLeave = useCallback((event: ReactPointerEvent<HTMLDivElement>) => { const handleNavPointerLeave = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType === 'mouse') return; if (event.pointerType === 'mouse') return;
handleNavPointerUp(event); handleNavPointerUp(event);
}, [handleNavPointerUp]); },
[handleNavPointerUp]
);
const handleNavWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => { const handleNavWheel = useCallback(
(event: ReactWheelEvent<HTMLDivElement>) => {
const node = navScrollerRef.current; const node = navScrollerRef.current;
if (!node) return; if (!node) return;
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; const delta =
Math.abs(event.deltaX) > Math.abs(event.deltaY)
? event.deltaX
: event.deltaY;
if (!delta) return; if (!delta) return;
node.scrollLeft += delta; 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>
); );
} }
+230 -55
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()
.then((items) => {
setShelf(items); setShelf(items);
}).catch(() => undefined); })
.catch(() => undefined);
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -127,11 +160,17 @@ 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') ||
readable?.rel === 'legado:chapters'
? 'chapters' ? 'chapters'
: 'epub'; : 'epub';
@@ -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 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'>
{detail.cover ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={detail.cover}
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 className='space-y-4'> )}
</div>
<div className='flex min-w-0 flex-col justify-between gap-5'>
<div> <div>
<h1 className='text-2xl font-semibold'>{detail.title}</h1> <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 className='mt-2 text-sm text-gray-500 dark:text-gray-400'>{detail.author || '未知作者'}</div> <BookOpen className='h-3.5 w-3.5' />
<div className='mt-1 text-xs text-gray-400 dark:text-gray-500'>{detail.sourceName}</div> {detail.sourceName}
</div>
<h1 className='mt-4 text-3xl font-black tracking-tight text-emerald-950 dark:text-emerald-50 sm:text-4xl'>
{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>
{detail.summary ? <div className='text-sm leading-7 text-gray-700 dark:text-gray-300'>{detail.summary}</div> : null}
<div className='flex flex-wrap gap-2'>
{(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> </div>
<div className='flex flex-wrap gap-3'> <div className='flex flex-wrap gap-3'>
{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} {readable ? (
<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> <Link
{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} 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>
<button disabled={!format || fileBusy !== ''} onClick={async () => { <div className='mt-1 truncate text-xs text-slate-500 dark:text-slate-400'>
{item.rel}
</div>
</div>
<button
type='button'
disabled={!format || fileBusy !== ''}
onClick={async () => {
if (!format) return; if (!format) return;
if (format === 'epub' || format === 'chapters') { if (format === 'epub' || format === 'chapters') {
cacheBookDetail(detail); cacheBookDetail(detail);
window.location.href = buildBookReadPath(detail.sourceId, detail.id); window.location.href = buildBookReadPath(
detail.sourceId,
detail.id
);
return; return;
} }
try { try {
setFileBusy('open'); setFileBusy('open');
await openBookFile(detail.sourceId, detail.id, format, false, item.href); await openBookFile(
detail.sourceId,
detail.id,
format,
false,
item.href
);
} catch (err) { } catch (err) {
setError((err as Error).message || '打开文件失败'); setError((err as Error).message || '打开文件失败');
} finally { } finally {
setFileBusy(''); setFileBusy('');
} }
}} className='text-sky-600 disabled:text-gray-400'></button> }}
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>
{chaptersError ? <div className='mt-4 text-sm text-red-500'>{chaptersError}</div> : null} </div>
{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>
+283 -63
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,7 +143,9 @@ export default function BookHistoryPage() {
void loadCacheItems(); void loadCacheItems();
}, [cacheModalOpen]); }, [cacheModalOpen]);
const items = useMemo(() => Object.entries(records) const items = useMemo(
() =>
Object.entries(records)
.map(([key, item]) => { .map(([key, item]) => {
const [fallbackSourceId = '', fallbackBookId = ''] = key.split('+'); const [fallbackSourceId = '', fallbackBookId = ''] = key.split('+');
const shelfItem = shelf[key]; const shelfItem = shelf[key];
@@ -122,93 +162,251 @@ export default function BookHistoryPage() {
format: item.format || shelfItem?.format || 'epub', 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' />
<div className='relative flex items-center justify-between gap-4'>
<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-semibold text-emerald-700 shadow-sm dark:border-emerald-500/20 dark:bg-gray-950/50 dark:text-emerald-200'>
<Clock3 className='h-3.5 w-3.5' />
Reading Timeline
</div>
<h1 className='mt-3 text-3xl font-black tracking-tight text-emerald-950 dark:text-emerald-50'>
</h1>
<div className='mt-2 text-sm text-slate-500 dark:text-slate-400'>
{items.length}
</div>
</div>
<button <button
type='button' type='button'
onClick={() => setCacheModalOpen(true)} onClick={() => setCacheModalOpen(true)}
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-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='缓存管理' aria-label='缓存管理'
title='缓存管理' title='缓存管理'
> >
<FolderCog className='h-4 w-4' /> <FolderCog className='h-5 w-5' />
</button> </button>
</div> </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
</div> 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>
</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
className='fixed inset-0 z-50 bg-black/45 backdrop-blur-sm'
onClick={() => setCacheModalOpen(false)}
>
<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%)]'
onClick={(event) => event.stopPropagation()}
>
<div className='space-y-5 p-5'>
<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 className='flex items-start justify-between gap-4'> <div className='flex items-start justify-between gap-4'>
<div> <div>
<div className='text-base font-semibold'></div> <div className='flex items-center gap-2 text-base font-semibold text-slate-950 dark:text-white'>
<div className='mt-1 text-xs text-gray-500'> {cacheItems.length} · {formatBytes(cacheTotalSize)}</div> <Database className='h-4 w-4 text-emerald-600 dark:text-emerald-300' />
</div>
<div className='mt-1 text-xs text-slate-500 dark:text-slate-400'>
{cacheItems.length} ·{' '}
{formatBytes(cacheTotalSize)}
</div>
</div> </div>
<div className='flex gap-2'> <div className='flex gap-2'>
<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
<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> type='button'
<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> onClick={() => void loadCacheItems()}
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='刷新缓存'
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' />
</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='text-sm text-gray-500'></div> : null} {cacheLoading ? (
{!cacheLoading && cacheItems.length === 0 ? <div className='text-sm text-gray-500'></div> : null} <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'> <div className='space-y-3'>
{cacheItems.map((item) => ( {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
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='flex items-start justify-between gap-3'>
<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-xs text-gray-500'> {item.format.toUpperCase()} · {formatBytes(item.size)}</div> {item.title}
<div className='mt-1 text-xs text-gray-500'> {new Date(item.lastOpenTime).toLocaleString()}</div> </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> </div>
<button <button
type='button' type='button'
onClick={() => setConfirmAction({ type: 'delete-one', key: item.key, title: item.title })} onClick={() =>
className='inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 dark:border-gray-700' 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='删除缓存' aria-label='删除缓存'
title='删除缓存' title='删除缓存'
> >
@@ -224,33 +422,55 @@ export default function BookHistoryPage() {
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>
<div className='mt-2 text-sm text-gray-500 dark:text-gray-400'> <div className='mt-2 text-sm text-gray-500 dark:text-gray-400'>
{confirmAction.type === 'clear-all' {confirmAction.type === 'clear-all'
? '确认清空当前浏览器中的全部电子书缓存吗?此操作不可撤销。' ? '确认清空当前浏览器中的全部电子书缓存吗?此操作不可撤销。'
: `确认删除《${confirmAction.title || '该书'}》的本地缓存吗?`} : `确认删除《${
confirmAction.title || '该书'
}》的本地缓存吗?`}
</div> </div>
<div className='mt-5 flex justify-end gap-3'> <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={() => 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 <button
type='button' type='button'
onClick={async () => { onClick={async () => {
if (confirmAction.type === 'clear-all') { if (confirmAction.type === 'clear-all') {
await Promise.all(cacheItems.map((item) => deleteCachedBookFile(item.key))); await Promise.all(
cacheItems.map((item) => deleteCachedBookFile(item.key))
);
setCacheItems([]); setCacheItems([]);
} else if (confirmAction.key) { } else if (confirmAction.key) {
await deleteCachedBookFile(confirmAction.key); await deleteCachedBookFile(confirmAction.key);
setCacheItems((prev) => prev.filter((item) => item.key !== confirmAction.key)); setCacheItems((prev) =>
prev.filter((item) => item.key !== confirmAction.key)
);
} }
setConfirmAction(null); setConfirmAction(null);
}} }}
className='rounded-2xl bg-red-600 px-4 py-2 text-sm text-white' 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> </button>
+182 -28
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' />
</section> <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'>
{loading ? <BooksHomeSkeleton /> : null} <div>
{error ? <div className='text-sm text-red-500'>{error}</div> : null} <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' />
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'> MoonTVPlus Reading Library
{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'>
<div className='text-base font-semibold'>{source.name}</div>
<div className='mt-1 text-xs text-gray-400'>{source.type === 'legado' ? 'Legado' : 'OPDS'}</div>
<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>
<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> </div>
<div className='mt-4 flex flex-wrap gap-2'> <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'>
{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>}
{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>} </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>
))} ))}
</div> </div>
</div> </div>
</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}
{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'>
{sources.map((source) => (
<article
key={source.id}
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='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' />
<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 className='relative mt-4 flex flex-wrap gap-2'>
<CapabilityPill enabled={source.capabilities?.catalogSupported}>
{source.capabilities?.catalogSupported ? '可用' : '不可用'}
</CapabilityPill>
<CapabilityPill enabled={source.capabilities?.searchSupported}>
{source.capabilities?.searchSupported ? '可用' : '不可用'}
</CapabilityPill>
</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>
{!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>
); );
} }
+1496 -322
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,19 +728,29 @@ 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(
(item: BookChapter, index: number): BookReadRecord => {
const node = scrollRef.current; const node = scrollRef.current;
const scrollTop = node?.scrollTop || 0; const scrollTop = node?.scrollTop || 0;
const scrollHeight = node?.scrollHeight || 0; const scrollHeight = node?.scrollHeight || 0;
const clientHeight = node?.clientHeight || 0; const clientHeight = node?.clientHeight || 0;
const chapterCount = Math.max(1, chapters.length); const chapterCount = Math.max(1, chapters.length);
const maxScrollTop = Math.max(0, scrollHeight - clientHeight); const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
const chapterRatio = maxScrollTop > 0 ? Math.max(0, Math.min(1, scrollTop / maxScrollTop)) : 0; const chapterRatio =
const progressPercent = chapters.length > 0 maxScrollTop > 0
? Math.max(0, Math.min(100, ((index + chapterRatio) / chapterCount) * 100)) ? 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; : 0;
return { return {
@@ -594,7 +765,12 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
format: 'chapters', format: 'chapters',
locator: { locator: {
type: 'chapter', type: 'chapter',
value: encodeChapterScrollLocator(item.href, scrollTop, scrollHeight, clientHeight), value: encodeChapterScrollLocator(
item.href,
scrollTop,
scrollHeight,
clientHeight
),
href: item.href, href: item.href,
chapterTitle: item.title, chapterTitle: item.title,
}, },
@@ -603,17 +779,23 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
progressPercent, progressPercent,
saveTime: Date.now(), saveTime: Date.now(),
}; };
}, [chapters.length, manifest]); },
[chapters.length, manifest]
);
const persistChapterProgress = useCallback((index = currentIndexRef.current) => { const persistChapterProgress = useCallback(
(index = currentIndexRef.current) => {
const item = chapters[index]; const item = chapters[index];
if (!item) return; if (!item) return;
const record = buildChapterReadRecord(item, index); const record = buildChapterReadRecord(item, index);
if (record.locator.value === lastChapterSavedLocatorValueRef.current) return; if (record.locator.value === lastChapterSavedLocatorValueRef.current)
return;
lastChapterSavedLocatorValueRef.current = record.locator.value; lastChapterSavedLocatorValueRef.current = record.locator.value;
lastChapterSavedAtRef.current = Date.now(); lastChapterSavedAtRef.current = Date.now();
void saveBookReadRecord(record.sourceId, record.bookId, record); void saveBookReadRecord(record.sourceId, record.bookId, record);
}, [buildChapterReadRecord, chapters]); },
[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(
new CustomEvent('books-read-update-header', {
detail: { detail: {
title: manifest.book.title, title: manifest.book.title,
subtitle: currentChapterTitle || manifest.book.author || (settings.mode === 'scrolled' ? '滚动阅读' : '翻页阅读'), subtitle:
backHref: `/books/detail?sourceId=${encodeURIComponent(manifest.book.sourceId)}&bookId=${encodeURIComponent(manifest.book.id)}`, 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,7 +925,8 @@ 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(
(direction: 1 | -1) => {
const node = scrollRef.current; const node = scrollRef.current;
if (!node) return; if (!node) return;
if (settings.mode === 'scrolled') return; if (settings.mode === 'scrolled') return;
@@ -713,19 +942,23 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
return; return;
} }
node.scrollTo({ top: nextTop, behavior: 'smooth' }); node.scrollTo({ top: nextTop, behavior: 'smooth' });
}, [chapters.length, currentIndex, goNextChapter, goPrevChapter, settings.mode]); },
[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(
async (chunk: TtsChunk, chapterHref: string) => {
if (!manifest) throw new Error('书籍信息未准备好'); if (!manifest) throw new Error('书籍信息未准备好');
const response = await fetch('/api/books/tts/synthesize', { const response = await fetch('/api/books/tts/synthesize', {
method: 'POST', method: 'POST',
@@ -743,10 +976,15 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
}); });
const json = await response.json(); const json = await response.json();
if (!response.ok) throw new Error(json.error || '朗读音频生成失败'); if (!response.ok) throw new Error(json.error || '朗读音频生成失败');
return URL.createObjectURL(decodeBase64Audio(json.audioBase64 || '', json.mimeType || 'audio/mpeg')); return URL.createObjectURL(
}, [manifest]); decodeBase64Audio(json.audioBase64 || '', json.mimeType || 'audio/mpeg')
);
},
[manifest]
);
const playTtsChunk = useCallback(async (index: number) => { const playTtsChunk = useCallback(
async (index: number) => {
const chunks = ttsChunksRef.current; const chunks = ttsChunksRef.current;
const chunk = chunks[index]; const chunk = chunks[index];
if (!chunk || !currentChapterHref) return; if (!chunk || !currentChapterHref) return;
@@ -767,7 +1005,9 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
setTtsLoadingChunkIndex(null); setTtsLoadingChunkIndex(null);
setTtsError((err as Error).message || '朗读失败'); 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()}> >
{tocOpen && typeof document !== 'undefined'
? createPortal(
<div
className='fixed inset-0 z-40 bg-black/30'
onClick={() => setTocOpen(false)}
>
<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'> <div className='space-y-2 p-4'>
{chapters.map((item, index) => { {chapters.map((item, index) => {
const active = index === currentIndex; 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-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>; 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> </div>
</div>, document.body </div>,
) : null} document.body
)
: null}
{settingsOpen && typeof document !== 'undefined' ? createPortal( {settingsOpen && typeof document !== 'undefined'
<div className='fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4' onClick={() => setSettingsOpen(false)}> ? createPortal(
<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
<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> 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 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 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 className='mb-2 font-medium'></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 className='grid grid-cols-2 gap-2'>
<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-sky-600 px-4 py-2 text-sm font-medium text-white' onClick={() => setSettingsOpen(false)}></button></div> [
{
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>
</div>, document.body <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}
{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} <div
ref={scrollRef}
<div ref={scrollRef} className='h-full overflow-y-auto px-4 py-6' style={{ scrollSnapType: settings.mode === 'paginated' ? 'y mandatory' : undefined }}> className='h-full overflow-y-auto px-4 py-6'
<article className='mx-auto max-w-3xl text-gray-800 dark:text-gray-100' style={{ fontSize: `${settings.fontSize}%`, lineHeight: settings.lineHeight, color: palette.bodyColor }}> style={{
{loading ? '加载中...' : chapter?.content?.includes('<img') scrollSnapType:
? <div className='space-y-2 [&_img]:mx-auto [&_img]:block [&_img]:max-w-full' dangerouslySetInnerHTML={{ __html: chapter.content }} /> 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 ? (
<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>
) : chapter?.content?.includes('<img') ? (
<div
className='space-y-2 [&_img]:mx-auto [&_img]:block [&_img]:max-w-full'
dangerouslySetInnerHTML={{ __html: chapter.content }}
/>
) : chapter?.content ? (
<div className='whitespace-pre-wrap'>{chapter.content}</div>
) : (
<div className='flex min-h-[45vh] items-center justify-center px-4'>
<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'>
<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='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='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-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='px-2 pt-2'>
<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> <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>
</div> </div>
{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} <button
</> : null} 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>
{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-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,7 +2136,12 @@ export default function BookReadPage() {
}; };
}, [manifest]); }, [manifest]);
const buildReadRecord = useCallback((location: EpubLocation, nextProgress = 0, chapterTitle?: string): BookReadRecord | null => { const buildReadRecord = useCallback(
(
location: EpubLocation,
nextProgress = 0,
chapterTitle?: string
): BookReadRecord | null => {
if (!manifest) return null; if (!manifest) return null;
const locatorValue = location?.start?.cfi || location?.end?.cfi || ''; const locatorValue = location?.start?.cfi || location?.end?.cfi || '';
if (!locatorValue) return null; if (!locatorValue) return null;
@@ -1380,18 +2166,24 @@ export default function BookReadPage() {
progressPercent: nextProgress, progressPercent: nextProgress,
saveTime: Date.now(), saveTime: Date.now(),
}; };
}, [manifest]); },
[manifest]
);
const queueReadRecord = useCallback((location: EpubLocation, nextProgress = 0, chapterTitle?: string) => { const queueReadRecord = useCallback(
(location: EpubLocation, nextProgress = 0, chapterTitle?: string) => {
const record = buildReadRecord(location, nextProgress, chapterTitle); const record = buildReadRecord(location, nextProgress, chapterTitle);
if (!record) return; if (!record) return;
pendingRecordRef.current = record; pendingRecordRef.current = record;
pendingRecordDirtyRef.current = true; pendingRecordDirtyRef.current = true;
}, [buildReadRecord]); },
[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,11 +2200,15 @@ 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 =
fallbackHref ||
currentHrefRef.current ||
lastLocationRef.current?.start?.href ||
'';
if (!metrics || !href) return; if (!metrics || !href) return;
saveScrolledPosition(manifest.book.sourceId, manifest.book.id, { saveScrolledPosition(manifest.book.sourceId, manifest.book.id, {
href, href,
@@ -1421,8 +2217,9 @@ export default function BookReadPage() {
clientHeight: metrics.clientHeight, clientHeight: metrics.clientHeight,
updatedAt: Date.now(), updatedAt: Date.now(),
}); });
}, [manifest]); },
[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,7 +2285,8 @@ export default function BookReadPage() {
await renditionRef.current.display(target); await renditionRef.current.display(target);
}, []); }, []);
const handleReaderTap = useCallback((zone: 'left' | 'center' | 'right') => { const handleReaderTap = useCallback(
(zone: 'left' | 'center' | 'right') => {
if (!ready) return; if (!ready) return;
if (settings.mode === 'paginated' && zone === 'left') { if (settings.mode === 'paginated' && zone === 'left') {
renditionRef.current?.prev?.(); renditionRef.current?.prev?.();
@@ -1495,15 +2298,20 @@ export default function BookReadPage() {
} }
setTocOpen(false); setTocOpen(false);
setSettingsOpen(false); setSettingsOpen(false);
}, [ready, settings.mode]); },
[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(
(clearQueue = false) => {
const audio = audioRef.current; const audio = audioRef.current;
if (audio) { if (audio) {
audio.pause(); audio.pause();
@@ -1528,14 +2336,22 @@ export default function BookReadPage() {
ttsCurrentChapterTitleRef.current = ''; ttsCurrentChapterTitleRef.current = '';
cleanupTtsAudioUrls(); cleanupTtsAudioUrls();
} }
}, [cleanupTtsAudioUrls]); },
[cleanupTtsAudioUrls]
);
const persistTtsProgress = useCallback((chunkIndex?: number) => { const persistTtsProgress = useCallback(
(chunkIndex?: number) => {
if (!manifest) return; if (!manifest) return;
const chunks = ttsChunksRef.current; const chunks = ttsChunksRef.current;
const currentIndex = chunkIndex ?? ttsCurrentChunkIndexRef.current; const currentIndex = chunkIndex ?? ttsCurrentChunkIndexRef.current;
const chunk = chunks[currentIndex]; const chunk = chunks[currentIndex];
if (!chunk || !ttsCurrentChapterHrefRef.current || !ttsSettingsRef.current.voice) return; if (
!chunk ||
!ttsCurrentChapterHrefRef.current ||
!ttsSettingsRef.current.voice
)
return;
const progress: BookTtsProgress = { const progress: BookTtsProgress = {
sourceId: manifest.book.sourceId, sourceId: manifest.book.sourceId,
bookId: manifest.book.id, bookId: manifest.book.id,
@@ -1551,16 +2367,20 @@ export default function BookReadPage() {
saveTime: Date.now(), saveTime: Date.now(),
}; };
saveBookTtsProgress(progress); saveBookTtsProgress(progress);
}, [manifest, currentChapter]); },
[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(
async (chunk: TtsChunk, chapterHref: string) => {
const cached = ttsChunkBlobCacheRef.current[chunk.index]; const cached = ttsChunkBlobCacheRef.current[chunk.index];
if (cached?.text === chunk.text) return cached.url; if (cached?.text === chunk.text) return cached.url;
if (!manifest) throw new Error('书籍信息未准备好'); if (!manifest) throw new Error('书籍信息未准备好');
@@ -1601,7 +2421,10 @@ export default function BookReadPage() {
}); });
const json = await response.json(); const json = await response.json();
if (!response.ok) throw new Error(json.error || '朗读音频生成失败'); if (!response.ok) throw new Error(json.error || '朗读音频生成失败');
const blob = decodeBase64Audio(json.audioBase64 || '', json.mimeType || 'audio/mpeg'); const blob = decodeBase64Audio(
json.audioBase64 || '',
json.mimeType || 'audio/mpeg'
);
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
if (cached?.url) URL.revokeObjectURL(cached.url); if (cached?.url) URL.revokeObjectURL(cached.url);
ttsChunkBlobCacheRef.current[chunk.index] = { url, text: chunk.text }; ttsChunkBlobCacheRef.current[chunk.index] = { url, text: chunk.text };
@@ -1627,9 +2450,12 @@ export default function BookReadPage() {
.then(() => enforceBookTtsCacheLimit()) .then(() => enforceBookTtsCacheLimit())
.catch(() => undefined); .catch(() => undefined);
return url; return url;
}, [manifest]); },
[manifest]
);
const prefetchTtsChunks = useCallback((fromIndex: number) => { const prefetchTtsChunks = useCallback(
(fromIndex: number) => {
const chunks = ttsChunksRef.current; const chunks = ttsChunksRef.current;
const chapterHref = ttsCurrentChapterHrefRef.current; const chapterHref = ttsCurrentChapterHrefRef.current;
if (!ttsSettingsRef.current.autoPlayNext || !chapterHref) return; if (!ttsSettingsRef.current.autoPlayNext || !chapterHref) return;
@@ -1637,14 +2463,19 @@ export default function BookReadPage() {
const nextIndex = fromIndex + 1; const nextIndex = fromIndex + 1;
if (nextIndex >= chunks.length) return; if (nextIndex >= chunks.length) return;
ttsPrefetchedFromChunkRef.current = fromIndex; ttsPrefetchedFromChunkRef.current = fromIndex;
void fetchTtsChunkAudioUrl(chunks[nextIndex], chapterHref).catch(() => undefined); void fetchTtsChunkAudioUrl(chunks[nextIndex], chapterHref).catch(
}, [fetchTtsChunkAudioUrl]); () => undefined
);
},
[fetchTtsChunkAudioUrl]
);
useEffect(() => { useEffect(() => {
ttsPrefetchFnRef.current = prefetchTtsChunks; ttsPrefetchFnRef.current = prefetchTtsChunks;
}, [prefetchTtsChunks]); }, [prefetchTtsChunks]);
const playTtsChunk = useCallback(async (index: number) => { const playTtsChunk = useCallback(
async (index: number) => {
const chunks = ttsChunksRef.current; const chunks = ttsChunksRef.current;
const chunk = chunks[index]; const chunk = chunks[index];
const chapterHref = ttsCurrentChapterHrefRef.current; const chapterHref = ttsCurrentChapterHrefRef.current;
@@ -1659,7 +2490,10 @@ export default function BookReadPage() {
} }
audioRef.current.src = url; audioRef.current.src = url;
ttsResumeTimeRef.current = 0; ttsResumeTimeRef.current = 0;
const saved = getBookTtsProgress(manifest.book.sourceId, manifest.book.id); const saved = getBookTtsProgress(
manifest.book.sourceId,
manifest.book.id
);
if (saved?.chapterHref === chapterHref && saved.chunkIndex === index) { if (saved?.chapterHref === chapterHref && saved.chunkIndex === index) {
ttsResumeTimeRef.current = saved.currentTimeSec || 0; ttsResumeTimeRef.current = saved.currentTimeSec || 0;
} }
@@ -1674,12 +2508,18 @@ export default function BookReadPage() {
setTtsLoadingChunkIndex(null); setTtsLoadingChunkIndex(null);
setTtsError((error as Error).message || '朗读失败'); setTtsError((error as Error).message || '朗读失败');
} }
}, [fetchTtsChunkAudioUrl, manifest, persistTtsProgress]); },
[fetchTtsChunkAudioUrl, manifest, persistTtsProgress]
);
const bootstrapTtsForCurrentChapter = useCallback(async (resume = true) => { const bootstrapTtsForCurrentChapter = useCallback(
async (resume = true) => {
if (!manifest || manifest.format !== 'epub') return; if (!manifest || manifest.format !== 'epub') return;
const chapterHref = currentHref || manifest.lastRecord?.chapterHref || ''; const chapterHref = currentHref || manifest.lastRecord?.chapterHref || '';
const chapterTitle = findTocLabelByHref(tocItemsRef.current, chapterHref) || currentChapter || manifest.book.title; const chapterTitle =
findTocLabelByHref(tocItemsRef.current, chapterHref) ||
currentChapter ||
manifest.book.title;
if (!chapterHref) { if (!chapterHref) {
setTtsError('当前章节尚未定位,稍后再试'); setTtsError('当前章节尚未定位,稍后再试');
setTtsStatus('error'); setTtsStatus('error');
@@ -1698,8 +2538,13 @@ export default function BookReadPage() {
setTtsStatus('error'); setTtsStatus('error');
return; return;
} }
const saved = resume ? getBookTtsProgress(manifest.book.sourceId, manifest.book.id) : null; const saved = resume
const startIndex = saved?.chapterHref === chapterHref ? Math.min(saved.chunkIndex, chunks.length - 1) : 0; ? getBookTtsProgress(manifest.book.sourceId, manifest.book.id)
: null;
const startIndex =
saved?.chapterHref === chapterHref
? Math.min(saved.chunkIndex, chunks.length - 1)
: 0;
setTtsChunks(chunks); setTtsChunks(chunks);
ttsChunksRef.current = chunks; ttsChunksRef.current = chunks;
setTtsCurrentChunkIndex(startIndex); setTtsCurrentChunkIndex(startIndex);
@@ -1709,7 +2554,16 @@ export default function BookReadPage() {
setTtsCurrentChapterTitle(chapterTitle); setTtsCurrentChapterTitle(chapterTitle);
ttsCurrentChapterTitleRef.current = chapterTitle; ttsCurrentChapterTitleRef.current = chapterTitle;
await playTtsChunk(startIndex); await playTtsChunk(startIndex);
}, [cleanupTtsAudioUrls, currentChapter, currentHref, getCurrentSpineDocumentText, manifest, playTtsChunk]); },
[
cleanupTtsAudioUrls,
currentChapter,
currentHref,
getCurrentSpineDocumentText,
manifest,
playTtsChunk,
]
);
const toggleTtsPlayback = useCallback(async () => { const toggleTtsPlayback = useCallback(async () => {
if (!ttsAvailable) return; if (!ttsAvailable) return;
@@ -1730,45 +2584,69 @@ 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.cacheKey ||
buildBookCacheKey(
manifest.book.sourceId, manifest.book.sourceId,
manifest.book.id, manifest.book.id,
manifest.acquisitionHref || `${manifest.book.sourceId}:${manifest.book.id}:${manifest.format}` manifest.acquisitionHref ||
`${manifest.book.sourceId}:${manifest.book.id}:${manifest.format}`
); );
let fileBuffer: ArrayBuffer; let fileBuffer: ArrayBuffer;
@@ -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(
manifest,
(received, total) => {
if (!destroyed) { if (!destroyed) {
setDownloadedBytes(received); setDownloadedBytes(received);
setTotalBytes(total); 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
? Math.max(
0,
Math.min(
100,
(book.locations?.percentageFromCfi?.(cfi) || 0) * 100
)
)
: null; : null;
const normalizedProgress = computedProgress ?? lastProgressRef.current ?? manifest.lastRecord?.progressPercent ?? 0; 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(
new CustomEvent('books-read-update-header', {
detail: { detail: {
title: manifest.book.title, title: manifest.book.title,
subtitle: currentTocLabel || currentChapter || manifest.book.author || (settings.mode === 'scrolled' ? '滚动阅读' : '分页阅读'), subtitle:
backHref: `/books/detail?sourceId=${encodeURIComponent(manifest.book.sourceId)}&bookId=${encodeURIComponent(manifest.book.id)}`, 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,16 +3174,20 @@ 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(
(items: TocItem[], depth = 0) =>
items.map((item) => {
const active = tocItemIsActive(item, currentHref); const active = tocItemIsActive(item, currentHref);
const clickable = !!item.href; const clickable = !!item.href;
return ( return (
<div key={`${item.href || item.label}-${depth}`} className='space-y-2'> <div
key={`${item.href || item.label}-${depth}`}
className='space-y-2'
>
<button <button
ref={(node) => { ref={(node) => {
if (item.href) tocItemRefs.current[item.href] = node; if (item.href) tocItemRefs.current[item.href] = node;
@@ -2235,7 +3207,11 @@ export default function BookReadPage() {
setTocOpen(false); setTocOpen(false);
}} }}
disabled={!clickable} 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' : ''}`} 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` }} style={{ paddingLeft: `${16 + depth * 14}px` }}
> >
<span className='block truncate'>{item.label}</span> <span className='block truncate'>{item.label}</span>
@@ -2243,18 +3219,33 @@ export default function BookReadPage() {
<div className='text-sm'>{item.label}</div> <div className='text-sm'>{item.label}</div>
</div> </div>
</button> </button>
{item.subitems?.length ? renderTocItems(item.subitems, depth + 1) : null} {item.subitems?.length
? renderTocItems(item.subitems, depth + 1)
: null}
</div> </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}
@@ -2307,12 +3311,25 @@ export default function BookReadPage() {
</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,8 +3357,12 @@ 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
className='fixed inset-0 z-40 bg-black/30'
onClick={() => setTocOpen(false)}
>
<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='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()} onClick={(event) => event.stopPropagation()}
@@ -2347,7 +3370,9 @@ export default function BookReadPage() {
<div className='p-4'> <div className='p-4'>
<div className='space-y-2' ref={tocScrollRef}> <div className='space-y-2' ref={tocScrollRef}>
{tocItems.length === 0 ? ( {tocItems.length === 0 ? (
<div className='p-3 text-sm text-gray-500'> EPUB </div> <div className='p-3 text-sm text-gray-500'>
EPUB
</div>
) : ( ) : (
renderTocItems(tocItems) renderTocItems(tocItems)
)} )}
@@ -2356,33 +3381,60 @@ export default function BookReadPage() {
</div> </div>
</div>, </div>,
document.body document.body
) : null} )
: null}
{settingsOpen && typeof document !== 'undefined' ? createPortal( {settingsOpen && typeof document !== 'undefined'
<div className='fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4' onClick={() => setSettingsOpen(false)}> ? createPortal(
<div
className='fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4'
onClick={() => setSettingsOpen(false)}
>
<div <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' 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()} onClick={(event) => event.stopPropagation()}
> >
<div className='mb-4'> <div className='mb-4'>
<div className='text-base font-semibold text-gray-900 dark:text-gray-100'></div> <div className='text-base font-semibold text-gray-900 dark:text-gray-100'>
<div className='mt-1 text-xs text-gray-500'></div>
</div>
<div className='mt-1 text-xs text-gray-500'>
</div>
</div> </div>
<div className='space-y-6 p-1 text-sm'> <div className='space-y-6 p-1 text-sm'>
<div> <div>
<div className='mb-2 font-medium'></div> <div className='mb-2 font-medium'></div>
<div className='grid grid-cols-2 gap-2'> <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) => ( key: 'paginated',
label: '翻页模式',
desc: '左右点击翻页',
},
{
key: 'scrolled',
label: '滚动模式',
desc: '上下连续滚动',
},
] as { key: ReaderMode; label: string; desc: string }[]
).map((mode) => (
<button <button
key={mode.key} key={mode.key}
onClick={() => setSettings((prev) => ({ ...prev, mode: mode.key }))} onClick={() =>
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'}`} 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='font-medium'>{mode.label}</div>
<div className='mt-1 text-xs opacity-70'>{mode.desc}</div> <div className='mt-1 text-xs opacity-70'>
{mode.desc}
</div>
</button> </button>
))} ))}
</div> </div>
@@ -2391,38 +3443,87 @@ export default function BookReadPage() {
<div> <div>
<div className='mb-2 font-medium'></div> <div className='mb-2 font-medium'></div>
<div className='grid grid-cols-3 gap-2'> <div className='grid grid-cols-3 gap-2'>
{(['light', 'sepia', 'dark'] as ReaderTheme[]).map((theme) => ( {(['light', 'sepia', 'dark'] as ReaderTheme[]).map(
(theme) => (
<button <button
key={theme} key={theme}
onClick={() => setSettings((prev) => ({ ...prev, theme }))} onClick={() =>
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'}`} 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> <div className='mb-1 flex justify-center'>
{theme === 'light' ? '浅色' : theme === 'sepia' ? '护眼' : '深色'} {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 className='mb-2 flex items-center justify-between font-medium'> <span>{settings.fontSize}%</span></div> <div className='mb-2 flex items-center justify-between font-medium'>
<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' /> <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> <div>
<div className='mb-2 flex items-center justify-between font-medium'> <span>{settings.lineHeight.toFixed(1)}</span></div> <div className='mb-2 flex items-center justify-between font-medium'>
<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' /> <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>
<div className='rounded-2xl bg-gray-50 p-4 text-xs text-gray-500 dark:bg-gray-900 dark:text-gray-400'> <div className='rounded-2xl bg-gray-50 p-4 text-xs text-gray-500 dark:bg-gray-900 dark:text-gray-400'>
{cacheHit ? '已命中本地缓存' : '本次为网络加载'}
{cacheHit ? '已命中本地缓存' : '本次为网络加载'}
</div> </div>
<div className='flex justify-end'> <div className='flex justify-end'>
<button <button
type='button' type='button'
className='rounded-2xl bg-sky-600 px-4 py-2 text-sm font-medium text-white' className='rounded-2xl bg-emerald-600 px-4 py-2 text-sm font-medium text-white'
onClick={() => setSettingsOpen(false)} onClick={() => setSettingsOpen(false)}
> >
@@ -2432,7 +3533,8 @@ export default function BookReadPage() {
</div> </div>
</div>, </div>,
document.body document.body
) : null} )
: 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,13 +3594,22 @@ 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'>
@@ -2506,7 +3623,11 @@ export default function BookReadPage() {
? '已暂停' ? '已暂停'
: '待播放'} : '待播放'}
</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>
+390 -69
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(
(keyword: string, selectedSourceId: string) => {
if (typeof window === 'undefined' || !keyword.trim()) return null; if (typeof window === 'undefined' || !keyword.trim()) return null;
try { try {
const raw = sessionStorage.getItem(getCacheKey(keyword, selectedSourceId)); const raw = sessionStorage.getItem(
getCacheKey(keyword, selectedSourceId)
);
return raw ? (JSON.parse(raw) as BookSearchResult) : null; return raw ? (JSON.parse(raw) as BookSearchResult) : null;
} catch { } catch {
return null; return null;
} }
}, [getCacheKey]); },
[getCacheKey]
);
const setCachedResult = useCallback((keyword: string, selectedSourceId: string, nextResult: BookSearchResult) => { const setCachedResult = useCallback(
(
keyword: string,
selectedSourceId: string,
nextResult: BookSearchResult
) => {
if (typeof window === 'undefined' || !keyword.trim()) return; if (typeof window === 'undefined' || !keyword.trim()) return;
try { try {
sessionStorage.setItem(getCacheKey(keyword, selectedSourceId), JSON.stringify(nextResult)); sessionStorage.setItem(
} catch {} getCacheKey(keyword, selectedSourceId),
}, [getCacheKey]); 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,31 +156,53 @@ 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(
(nextState: { q: string; sourceId: string; result: BookSearchResult }) => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
try { try {
sessionStorage.setItem(BOOK_SEARCH_STATE_KEY, JSON.stringify(nextState)); sessionStorage.setItem(
} catch {} 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(
async (
keyword: string,
selectedSourceId: string,
options?: { forceRefresh?: boolean }
) => {
const trimmed = keyword.trim(); const trimmed = keyword.trim();
if (!trimmed) return; if (!trimmed) return;
const normalizedSourceId = selectedSourceId || ''; const normalizedSourceId = selectedSourceId || '';
@@ -145,10 +218,16 @@ export default function BooksSearchPage() {
setTotalSources(0); setTotalSources(0);
setCompletedSources(0); setCompletedSources(0);
const cached = forceRefresh ? null : getCachedResult(trimmed, normalizedSourceId); const cached = forceRefresh
? null
: getCachedResult(trimmed, normalizedSourceId);
if (cached) { if (cached) {
setResult(cached); setResult(cached);
saveSearchState({ q: trimmed, sourceId: normalizedSourceId, result: cached }); saveSearchState({
q: trimmed,
sourceId: normalizedSourceId,
result: cached,
});
setLoading(false); setLoading(false);
setTotalSources(1); setTotalSources(1);
setCompletedSources(1); setCompletedSources(1);
@@ -158,7 +237,9 @@ export default function BooksSearchPage() {
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);
@@ -177,23 +258,32 @@ export default function BooksSearchPage() {
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)
);
if (
Array.isArray(payload.results) &&
payload.results.length > 0
) {
appendBufferedResults(payload.results as BookListItem[]); appendBufferedResults(payload.results as BookListItem[]);
} }
break; break;
case 'source_error': { case 'source_error':
setCompletedSources((prev) => Math.max(prev + 1, payload.completedSources || 0)); setCompletedSources((prev) =>
Math.max(prev + 1, payload.completedSources || 0)
);
break; break;
}
case 'error': case 'error':
setError(payload.error || '搜索失败'); setError(payload.error || '搜索失败');
setLoading(false); setLoading(false);
closeEventSource(); closeEventSource();
break; break;
case 'complete': { case 'complete': {
const finalFailedSources: BookSearchResult['failedSources'] = []; const finalFailedSources: BookSearchResult['failedSources'] =
setCompletedSources(payload.completedSources || payload.totalSources || 0); [];
setCompletedSources(
payload.completedSources || payload.totalSources || 0
);
if (pendingResultsRef.current.length > 0) { if (pendingResultsRef.current.length > 0) {
const toAppend = pendingResultsRef.current; const toAppend = pendingResultsRef.current;
pendingResultsRef.current = []; pendingResultsRef.current = [];
@@ -203,17 +293,31 @@ export default function BooksSearchPage() {
} }
startTransition(() => { startTransition(() => {
setResult((prev) => { setResult((prev) => {
const nextResult = { results: prev.results.concat(toAppend), failedSources: finalFailedSources }; const nextResult = {
results: prev.results.concat(toAppend),
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 { } else {
setResult((prev) => { setResult((prev) => {
const nextResult = { results: prev.results, 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;
}); });
} }
@@ -222,7 +326,9 @@ export default function BooksSearchPage() {
break; break;
} }
} }
} catch {} } catch {
// Ignore malformed streaming payloads.
}
}; };
es.onerror = () => { es.onerror = () => {
@@ -235,7 +341,10 @@ export default function BooksSearchPage() {
flushTimerRef.current = null; flushTimerRef.current = null;
} }
startTransition(() => { startTransition(() => {
setResult((prev) => ({ ...prev, results: prev.results.concat(toAppend) })); setResult((prev) => ({
...prev,
results: prev.results.concat(toAppend),
}));
}); });
} }
setLoading(false); setLoading(false);
@@ -249,12 +358,19 @@ export default function BooksSearchPage() {
const json = await res.json(); const json = await res.json();
if (currentSearchKeyRef.current !== searchKey) return; if (currentSearchKeyRef.current !== searchKey) return;
if (!res.ok) throw new Error(json.error || '搜索失败'); if (!res.ok) throw new Error(json.error || '搜索失败');
const nextResult: BookSearchResult = { results: json.results || [], failedSources: [] }; const nextResult: BookSearchResult = {
results: json.results || [],
failedSources: [],
};
setResult(nextResult); setResult(nextResult);
setTotalSources(1); setTotalSources(1);
setCompletedSources(1); setCompletedSources(1);
setCachedResult(trimmed, normalizedSourceId, nextResult); setCachedResult(trimmed, normalizedSourceId, nextResult);
saveSearchState({ q: trimmed, sourceId: normalizedSourceId, result: nextResult }); saveSearchState({
q: trimmed,
sourceId: normalizedSourceId,
result: nextResult,
});
} catch (err) { } catch (err) {
if (currentSearchKeyRef.current !== searchKey) return; if (currentSearchKeyRef.current !== searchKey) return;
setError((err as Error).message || '搜索失败'); setError((err as Error).message || '搜索失败');
@@ -264,11 +380,24 @@ export default function BooksSearchPage() {
setLoading(false); setLoading(false);
} }
} }
}, [appendBufferedResults, clearPendingResults, closeEventSource, getCachedResult, readFluidSearchSetting, saveSearchState, setCachedResult]); },
[
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() {
} }
}; };
return ( const selectedSourceName = useMemo(() => {
<div className='space-y-6'> if (!sourceId) return '全部书源';
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'> return sources.find((source) => source.id === sourceId)?.name || '当前书源';
<form onSubmit={handleSubmit} className='space-y-3'> }, [sourceId, sources]);
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder='搜索书名 / 作者' className='w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none dark:border-gray-700 dark:bg-gray-900' />
<select value={sourceId} onChange={(e) => setSourceId(e.target.value)} className='w-full rounded-2xl border border-gray-200 px-4 py-3 dark:border-gray-700 dark:bg-gray-900'>
<option value=''></option>
{sources.map((source) => <option key={source.id} value={source.id}>{source.name}</option>)}
</select>
<button className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'></button>
</form>
</section>
<div className='flex items-center justify-between gap-3'> const searchProgress =
<h2 className='text-lg font-semibold'>{result.results.length > 0 ? `${result.results.length}` : ''}</h2> totalSources > 0
{loading && useFluidSearch && totalSources > 0 ? ( ? Math.min(100, Math.round((completedSources / totalSources) * 100))
<span className='text-xs text-gray-500 dark:text-gray-400'> {completedSources}/{totalSources}</span> : 0;
) : null}
const submitSearch = useCallback(
(keyword: string) => {
const trimmed = keyword.trim();
if (!trimmed) return;
const params = new URLSearchParams();
params.set('q', trimmed);
if (sourceId) params.set('sourceId', sourceId);
forceNextUrlSearchRef.current = true;
router.replace(`/books/search?${params.toString()}`);
},
[router, sourceId]
);
return (
<div className='space-y-7'>
<section className='relative overflow-hidden rounded-[2.25rem] border border-emerald-100 bg-gradient-to-br from-emerald-50 via-white to-amber-50 p-5 shadow-sm shadow-emerald-950/5 dark:border-emerald-500/10 dark:from-emerald-950/30 dark:via-gray-950 dark:to-amber-950/20 sm:p-7'>
<div className='absolute -right-20 -top-24 h-64 w-64 rounded-full bg-emerald-300/25 blur-3xl dark:bg-emerald-500/10' />
<div className='absolute -bottom-28 left-1/4 h-64 w-64 rounded-full bg-amber-300/20 blur-3xl dark:bg-amber-500/10' />
<div className='relative'>
<div className='inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-white/75 px-3 py-1 text-xs font-semibold text-emerald-700 shadow-sm backdrop-blur dark:border-emerald-500/20 dark:bg-gray-950/50 dark:text-emerald-200'>
<Sparkles className='h-3.5 w-3.5' />
Search First · Reduce Friction
</div> </div>
{loading && result.results.length === 0 ? <SearchSkeleton /> : null} <div className='mt-5 grid gap-6 lg:grid-cols-[0.95fr_1.05fr] lg:items-end'>
{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} <div>
<section className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'> <h1 className='text-4xl font-black tracking-[-0.06em] text-emerald-950 dark:text-emerald-50 sm:text-6xl lg:text-7xl'>
{result.results.map((item) => <BookCard key={`${item.sourceId}-${item.id}`} item={item} href={detailHref(item)} onNavigate={() => cacheBookListItem(item)} />)}
</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>
{!loading && hasSearched && !error && result.results.length === 0 ? <div className='text-sm text-gray-500'></div> : null}
<section className='rounded-[2rem] border border-emerald-100/80 bg-white/75 p-4 shadow-sm shadow-emerald-950/5 backdrop-blur dark:border-emerald-500/10 dark:bg-gray-950/60 sm:p-5'>
<div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='min-w-0'>
<div className='flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-emerald-600 dark:text-emerald-300'>
<BookMarked className='h-4 w-4' />
Results
</div>
<h2 className='mt-1 text-2xl font-black tracking-tight text-slate-950 dark:text-white'>
{hasSearched
? `搜索结果${
result.results.length > 0
? `${result.results.length}`
: ''
}`
: '等待搜索'}
</h2>
<p className='mt-1 text-sm text-slate-500 dark:text-slate-400'>
{selectedSourceName}
</p>
</div>
<div className='flex flex-wrap items-center gap-2'>
<span className='inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-3 py-1.5 text-xs font-medium text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200'>
<Layers3 className='h-3.5 w-3.5' />
{sources.length || 0}
</span>
{loading && useFluidSearch && totalSources > 0 ? (
<span className='inline-flex items-center gap-1.5 rounded-full bg-amber-50 px-3 py-1.5 text-xs font-medium text-amber-700 dark:bg-amber-500/10 dark:text-amber-200'>
<Loader2 className='h-3.5 w-3.5 animate-spin' />
{completedSources}/{totalSources}
</span>
) : null}
</div>
</div>
{loading && useFluidSearch && totalSources > 0 ? (
<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}
</section>
{loading && result.results.length === 0 ? <SearchSkeleton /> : null}
{error ? (
<div className='rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-500/20 dark:bg-red-950/20 dark:text-red-300'>
{error}
</div>
) : null}
<section className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
{result.results.map((item) => (
<BookCard
key={`${item.sourceId}-${item.id}`}
item={item}
href={detailHref(item)}
onNavigate={() => cacheBookListItem(item)}
/>
))}
</section>
{!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)}
</div> 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>
</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>
); );
})} })}