美化电子书馆

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