美化电子书馆
This commit is contained in:
+358
-196
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user