美化电子书馆
This commit is contained in:
+228
-66
@@ -1,12 +1,26 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { PointerEvent as ReactPointerEvent, WheelEvent as ReactWheelEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import {
|
||||||
|
PointerEvent as ReactPointerEvent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
WheelEvent as ReactWheelEvent,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { BookCatalogResult, BookListItem, BookSource } from '@/lib/book.types';
|
||||||
|
import {
|
||||||
|
buildBookDetailPath,
|
||||||
|
cacheBookListItem,
|
||||||
|
} from '@/lib/book-route-cache.client';
|
||||||
|
|
||||||
import BookCard from '@/components/books/BookCard';
|
import BookCard from '@/components/books/BookCard';
|
||||||
import { buildBookDetailPath, cacheBookListItem } from '@/lib/book-route-cache.client';
|
|
||||||
import { BookCatalogResult, BookListItem, BookSource } from '@/lib/book.types';
|
|
||||||
|
|
||||||
function makeHref(sourceId: string, item: BookListItem) {
|
function makeHref(sourceId: string, item: BookListItem) {
|
||||||
return buildBookDetailPath(sourceId, item.id);
|
return buildBookDetailPath(sourceId, item.id);
|
||||||
@@ -17,12 +31,18 @@ function CatalogSkeleton() {
|
|||||||
<div className='space-y-6 animate-pulse'>
|
<div className='space-y-6 animate-pulse'>
|
||||||
<div className='flex gap-2 overflow-x-auto pb-1'>
|
<div className='flex gap-2 overflow-x-auto pb-1'>
|
||||||
{Array.from({ length: 4 }).map((_, index) => (
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
<div key={index} className='h-10 w-24 rounded-full bg-gray-200 dark:bg-gray-800' />
|
<div
|
||||||
|
key={index}
|
||||||
|
className='h-10 w-24 rounded-full bg-gray-200 dark:bg-gray-800'
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className='flex gap-2 overflow-x-auto pb-1'>
|
<div className='flex gap-2 overflow-x-auto pb-1'>
|
||||||
{Array.from({ length: 5 }).map((_, index) => (
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
<div key={index} className='h-10 w-28 shrink-0 rounded-full bg-gray-200 dark:bg-gray-800' />
|
<div
|
||||||
|
key={index}
|
||||||
|
className='h-10 w-28 shrink-0 rounded-full bg-gray-200 dark:bg-gray-800'
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
||||||
@@ -66,7 +86,9 @@ export default function BooksCatalogPage() {
|
|||||||
const [selectedSourceId, setSelectedSourceId] = useState(sourceId);
|
const [selectedSourceId, setSelectedSourceId] = useState(sourceId);
|
||||||
const [selectedHref, setSelectedHref] = useState(href);
|
const [selectedHref, setSelectedHref] = useState(href);
|
||||||
const [data, setData] = useState<BookCatalogResult | null>(null);
|
const [data, setData] = useState<BookCatalogResult | null>(null);
|
||||||
const [catalogNavigation, setCatalogNavigation] = useState<BookCatalogResult['navigation']>([]);
|
const [catalogNavigation, setCatalogNavigation] = useState<
|
||||||
|
BookCatalogResult['navigation']
|
||||||
|
>([]);
|
||||||
const [entries, setEntries] = useState<BookListItem[]>([]);
|
const [entries, setEntries] = useState<BookListItem[]>([]);
|
||||||
const [nextHref, setNextHref] = useState<string | undefined>(undefined);
|
const [nextHref, setNextHref] = useState<string | undefined>(undefined);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -79,9 +101,21 @@ export default function BooksCatalogPage() {
|
|||||||
const activeNavItemRef = useRef<HTMLAnchorElement | null>(null);
|
const activeNavItemRef = useRef<HTMLAnchorElement | null>(null);
|
||||||
const loadedPageHrefsRef = useRef<Set<string>>(new Set());
|
const loadedPageHrefsRef = useRef<Set<string>>(new Set());
|
||||||
const failedPageHrefsRef = useRef<Set<string>>(new Set());
|
const failedPageHrefsRef = useRef<Set<string>>(new Set());
|
||||||
const sourceDragStateRef = useRef<{ pointerId: number; startX: number; startScrollLeft: number; moved: boolean; pointerType: string } | null>(null);
|
const sourceDragStateRef = useRef<{
|
||||||
|
pointerId: number;
|
||||||
|
startX: number;
|
||||||
|
startScrollLeft: number;
|
||||||
|
moved: boolean;
|
||||||
|
pointerType: string;
|
||||||
|
} | null>(null);
|
||||||
const suppressSourceClickRef = useRef(false);
|
const suppressSourceClickRef = useRef(false);
|
||||||
const navDragStateRef = useRef<{ pointerId: number; startX: number; startScrollLeft: number; moved: boolean; pointerType: string } | null>(null);
|
const navDragStateRef = useRef<{
|
||||||
|
pointerId: number;
|
||||||
|
startX: number;
|
||||||
|
startScrollLeft: number;
|
||||||
|
moved: boolean;
|
||||||
|
pointerType: string;
|
||||||
|
} | null>(null);
|
||||||
const suppressNavClickRef = useRef(false);
|
const suppressNavClickRef = useRef(false);
|
||||||
|
|
||||||
const showImmediateContentLoading = useCallback(() => {
|
const showImmediateContentLoading = useCallback(() => {
|
||||||
@@ -92,12 +126,13 @@ export default function BooksCatalogPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/books/sources').then((res) => res.json()).then((json) => setSources(json.sources || []));
|
fetch('/api/books/sources')
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((json) => setSources(json.sources || []));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedSourceId(sourceId);
|
setSelectedSourceId(sourceId);
|
||||||
setSelectedHref(href);
|
|
||||||
setCatalogNavigation([]);
|
setCatalogNavigation([]);
|
||||||
}, [sourceId]);
|
}, [sourceId]);
|
||||||
|
|
||||||
@@ -106,7 +141,7 @@ export default function BooksCatalogPage() {
|
|||||||
}, [href]);
|
}, [href]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sourceId || !href) return;
|
if (!sourceId || !href || catalogNavigation.length > 0) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const loadRootNavigation = async () => {
|
const loadRootNavigation = async () => {
|
||||||
@@ -115,7 +150,8 @@ export default function BooksCatalogPage() {
|
|||||||
const res = await fetch(`/api/books/catalog?${params.toString()}`);
|
const res = await fetch(`/api/books/catalog?${params.toString()}`);
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
if (!cancelled) setCatalogNavigation((json as BookCatalogResult).navigation || []);
|
if (!cancelled)
|
||||||
|
setCatalogNavigation((json as BookCatalogResult).navigation || []);
|
||||||
} catch {
|
} catch {
|
||||||
// 当前分类内容仍可正常展示,根目录分类加载失败时忽略。
|
// 当前分类内容仍可正常展示,根目录分类加载失败时忽略。
|
||||||
}
|
}
|
||||||
@@ -125,37 +161,64 @@ export default function BooksCatalogPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [sourceId, href]);
|
}, [sourceId, href, catalogNavigation.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sourceId || href || catalogNavigation.length === 0) return;
|
if (!sourceId || href || catalogNavigation.length === 0) return;
|
||||||
const firstNavigationItem = catalogNavigation.find((item) => {
|
const firstNavigationItem = catalogNavigation.find((item) => {
|
||||||
const rel = (item.rel || '').toLowerCase();
|
const rel = (item.rel || '').toLowerCase();
|
||||||
return item.href && rel !== 'next' && rel !== 'previous' && isMeaningfulNavTitle(item.title);
|
return (
|
||||||
|
item.href &&
|
||||||
|
rel !== 'next' &&
|
||||||
|
rel !== 'previous' &&
|
||||||
|
isMeaningfulNavTitle(item.title)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
if (!firstNavigationItem?.href) return;
|
if (!firstNavigationItem?.href) return;
|
||||||
setSelectedHref(firstNavigationItem.href);
|
setSelectedHref(firstNavigationItem.href);
|
||||||
router.replace(`/books/catalog?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(firstNavigationItem.href)}`);
|
router.replace(
|
||||||
|
`/books/catalog?sourceId=${encodeURIComponent(
|
||||||
|
sourceId
|
||||||
|
)}&href=${encodeURIComponent(firstNavigationItem.href)}`
|
||||||
|
);
|
||||||
}, [catalogNavigation, href, router, sourceId]);
|
}, [catalogNavigation, href, router, sourceId]);
|
||||||
|
|
||||||
const mergeEntries = useCallback((prev: BookListItem[], next: BookListItem[]) => {
|
const mergeEntries = useCallback(
|
||||||
const seen = new Set(prev.map((item) => `${item.sourceId}::${item.id}::${item.detailHref || item.acquisitionLinks[0]?.href || ''}`));
|
(prev: BookListItem[], next: BookListItem[]) => {
|
||||||
|
const seen = new Set(
|
||||||
|
prev.map(
|
||||||
|
(item) =>
|
||||||
|
`${item.sourceId}::${item.id}::${
|
||||||
|
item.detailHref || item.acquisitionLinks[0]?.href || ''
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
);
|
||||||
const merged = [...prev];
|
const merged = [...prev];
|
||||||
for (const item of next) {
|
for (const item of next) {
|
||||||
const key = `${item.sourceId}::${item.id}::${item.detailHref || item.acquisitionLinks[0]?.href || ''}`;
|
const key = `${item.sourceId}::${item.id}::${
|
||||||
|
item.detailHref || item.acquisitionLinks[0]?.href || ''
|
||||||
|
}`;
|
||||||
if (!seen.has(key)) {
|
if (!seen.has(key)) {
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
merged.push(item);
|
merged.push(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return merged;
|
return merged;
|
||||||
}, []);
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const loadCatalog = useCallback(async (targetHref?: string, append = false) => {
|
const loadCatalog = useCallback(
|
||||||
|
async (targetHref?: string, append = false) => {
|
||||||
if (!sourceId) return;
|
if (!sourceId) return;
|
||||||
const normalizedHref = targetHref || '';
|
const normalizedHref = targetHref || '';
|
||||||
if (append) {
|
if (append) {
|
||||||
if (!normalizedHref || loadedPageHrefsRef.current.has(normalizedHref) || failedPageHrefsRef.current.has(normalizedHref)) return;
|
if (
|
||||||
|
!normalizedHref ||
|
||||||
|
loadedPageHrefsRef.current.has(normalizedHref) ||
|
||||||
|
failedPageHrefsRef.current.has(normalizedHref)
|
||||||
|
)
|
||||||
|
return;
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
} else {
|
} else {
|
||||||
setError('');
|
setError('');
|
||||||
@@ -163,7 +226,9 @@ export default function BooksCatalogPage() {
|
|||||||
if (!normalizedHref) setData(null);
|
if (!normalizedHref) setData(null);
|
||||||
setEntries([]);
|
setEntries([]);
|
||||||
setNextHref(undefined);
|
setNextHref(undefined);
|
||||||
loadedPageHrefsRef.current = new Set(normalizedHref ? [normalizedHref] : ['__root__']);
|
loadedPageHrefsRef.current = new Set(
|
||||||
|
normalizedHref ? [normalizedHref] : ['__root__']
|
||||||
|
);
|
||||||
failedPageHrefsRef.current = new Set();
|
failedPageHrefsRef.current = new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +244,13 @@ export default function BooksCatalogPage() {
|
|||||||
setEntries((prev) => mergeEntries(prev, nextData.entries || []));
|
setEntries((prev) => mergeEntries(prev, nextData.entries || []));
|
||||||
} else {
|
} else {
|
||||||
setData(nextData);
|
setData(nextData);
|
||||||
setCatalogNavigation((prev) => normalizedHref ? (prev.length > 0 ? prev : nextData.navigation || []) : nextData.navigation || []);
|
setCatalogNavigation((prev) =>
|
||||||
|
normalizedHref
|
||||||
|
? prev.length > 0
|
||||||
|
? prev
|
||||||
|
: nextData.navigation || []
|
||||||
|
: nextData.navigation || []
|
||||||
|
);
|
||||||
setEntries(nextData.entries || []);
|
setEntries(nextData.entries || []);
|
||||||
}
|
}
|
||||||
setNextHref(nextData.nextHref || undefined);
|
setNextHref(nextData.nextHref || undefined);
|
||||||
@@ -194,7 +265,9 @@ export default function BooksCatalogPage() {
|
|||||||
if (!append) setLoadingCatalog(false);
|
if (!append) setLoadingCatalog(false);
|
||||||
setLoadingMore(false);
|
setLoadingMore(false);
|
||||||
}
|
}
|
||||||
}, [mergeEntries, sourceId]);
|
},
|
||||||
|
[mergeEntries, sourceId]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sourceId) return;
|
if (!sourceId) return;
|
||||||
@@ -205,18 +278,22 @@ export default function BooksCatalogPage() {
|
|||||||
const node = loaderRef.current;
|
const node = loaderRef.current;
|
||||||
if (!node || !nextHref || loadingMore || !data) return;
|
if (!node || !nextHref || loadingMore || !data) return;
|
||||||
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
const entry = entries[0];
|
const entry = entries[0];
|
||||||
if (entry?.isIntersecting && nextHref && !loadingMore) {
|
if (entry?.isIntersecting && nextHref && !loadingMore) {
|
||||||
void loadCatalog(nextHref, true);
|
void loadCatalog(nextHref, true);
|
||||||
}
|
}
|
||||||
}, { rootMargin: '800px 0px' });
|
},
|
||||||
|
{ rootMargin: '800px 0px' }
|
||||||
|
);
|
||||||
|
|
||||||
observer.observe(node);
|
observer.observe(node);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [data, nextHref, loadingMore, loadCatalog]);
|
}, [data, nextHref, loadingMore, loadCatalog]);
|
||||||
|
|
||||||
const handleSourcePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
const handleSourcePointerDown = useCallback(
|
||||||
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||||
const node = sourceScrollerRef.current;
|
const node = sourceScrollerRef.current;
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
@@ -231,12 +308,16 @@ export default function BooksCatalogPage() {
|
|||||||
if (event.pointerType !== 'mouse') {
|
if (event.pointerType !== 'mouse') {
|
||||||
node.setPointerCapture?.(event.pointerId);
|
node.setPointerCapture?.(event.pointerId);
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const handleSourcePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
const handleSourcePointerMove = useCallback(
|
||||||
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
const node = sourceScrollerRef.current;
|
const node = sourceScrollerRef.current;
|
||||||
const dragState = sourceDragStateRef.current;
|
const dragState = sourceDragStateRef.current;
|
||||||
if (!node || !dragState || dragState.pointerId !== event.pointerId) return;
|
if (!node || !dragState || dragState.pointerId !== event.pointerId)
|
||||||
|
return;
|
||||||
const deltaX = event.clientX - dragState.startX;
|
const deltaX = event.clientX - dragState.startX;
|
||||||
const moveThreshold = dragState.pointerType === 'mouse' ? 8 : 4;
|
const moveThreshold = dragState.pointerType === 'mouse' ? 8 : 4;
|
||||||
if (Math.abs(deltaX) > moveThreshold) {
|
if (Math.abs(deltaX) > moveThreshold) {
|
||||||
@@ -244,9 +325,12 @@ export default function BooksCatalogPage() {
|
|||||||
suppressSourceClickRef.current = true;
|
suppressSourceClickRef.current = true;
|
||||||
}
|
}
|
||||||
node.scrollLeft = dragState.startScrollLeft - deltaX;
|
node.scrollLeft = dragState.startScrollLeft - deltaX;
|
||||||
}, []);
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const handleSourcePointerUp = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
const handleSourcePointerUp = useCallback(
|
||||||
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
const node = sourceScrollerRef.current;
|
const node = sourceScrollerRef.current;
|
||||||
const dragState = sourceDragStateRef.current;
|
const dragState = sourceDragStateRef.current;
|
||||||
if (!dragState || dragState.pointerId !== event.pointerId) return;
|
if (!dragState || dragState.pointerId !== event.pointerId) return;
|
||||||
@@ -260,22 +344,34 @@ export default function BooksCatalogPage() {
|
|||||||
if (dragState.pointerType !== 'mouse') {
|
if (dragState.pointerType !== 'mouse') {
|
||||||
node?.releasePointerCapture?.(event.pointerId);
|
node?.releasePointerCapture?.(event.pointerId);
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const handleSourcePointerLeave = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
const handleSourcePointerLeave = useCallback(
|
||||||
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
if (event.pointerType === 'mouse') return;
|
if (event.pointerType === 'mouse') return;
|
||||||
handleSourcePointerUp(event);
|
handleSourcePointerUp(event);
|
||||||
}, [handleSourcePointerUp]);
|
},
|
||||||
|
[handleSourcePointerUp]
|
||||||
|
);
|
||||||
|
|
||||||
const handleSourceWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {
|
const handleSourceWheel = useCallback(
|
||||||
|
(event: ReactWheelEvent<HTMLDivElement>) => {
|
||||||
const node = sourceScrollerRef.current;
|
const node = sourceScrollerRef.current;
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
|
const delta =
|
||||||
|
Math.abs(event.deltaX) > Math.abs(event.deltaY)
|
||||||
|
? event.deltaX
|
||||||
|
: event.deltaY;
|
||||||
if (!delta) return;
|
if (!delta) return;
|
||||||
node.scrollLeft += delta;
|
node.scrollLeft += delta;
|
||||||
}, []);
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const handleNavPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
const handleNavPointerDown = useCallback(
|
||||||
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||||
const node = navScrollerRef.current;
|
const node = navScrollerRef.current;
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
@@ -290,12 +386,16 @@ export default function BooksCatalogPage() {
|
|||||||
if (event.pointerType !== 'mouse') {
|
if (event.pointerType !== 'mouse') {
|
||||||
node.setPointerCapture?.(event.pointerId);
|
node.setPointerCapture?.(event.pointerId);
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const handleNavPointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
const handleNavPointerMove = useCallback(
|
||||||
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
const node = navScrollerRef.current;
|
const node = navScrollerRef.current;
|
||||||
const dragState = navDragStateRef.current;
|
const dragState = navDragStateRef.current;
|
||||||
if (!node || !dragState || dragState.pointerId !== event.pointerId) return;
|
if (!node || !dragState || dragState.pointerId !== event.pointerId)
|
||||||
|
return;
|
||||||
const deltaX = event.clientX - dragState.startX;
|
const deltaX = event.clientX - dragState.startX;
|
||||||
const moveThreshold = dragState.pointerType === 'mouse' ? 8 : 4;
|
const moveThreshold = dragState.pointerType === 'mouse' ? 8 : 4;
|
||||||
if (Math.abs(deltaX) > moveThreshold) {
|
if (Math.abs(deltaX) > moveThreshold) {
|
||||||
@@ -303,9 +403,12 @@ export default function BooksCatalogPage() {
|
|||||||
suppressNavClickRef.current = true;
|
suppressNavClickRef.current = true;
|
||||||
}
|
}
|
||||||
node.scrollLeft = dragState.startScrollLeft - deltaX;
|
node.scrollLeft = dragState.startScrollLeft - deltaX;
|
||||||
}, []);
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const handleNavPointerUp = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
const handleNavPointerUp = useCallback(
|
||||||
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
const node = navScrollerRef.current;
|
const node = navScrollerRef.current;
|
||||||
const dragState = navDragStateRef.current;
|
const dragState = navDragStateRef.current;
|
||||||
if (!dragState || dragState.pointerId !== event.pointerId) return;
|
if (!dragState || dragState.pointerId !== event.pointerId) return;
|
||||||
@@ -319,20 +422,31 @@ export default function BooksCatalogPage() {
|
|||||||
if (dragState.pointerType !== 'mouse') {
|
if (dragState.pointerType !== 'mouse') {
|
||||||
node?.releasePointerCapture?.(event.pointerId);
|
node?.releasePointerCapture?.(event.pointerId);
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const handleNavPointerLeave = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
const handleNavPointerLeave = useCallback(
|
||||||
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
if (event.pointerType === 'mouse') return;
|
if (event.pointerType === 'mouse') return;
|
||||||
handleNavPointerUp(event);
|
handleNavPointerUp(event);
|
||||||
}, [handleNavPointerUp]);
|
},
|
||||||
|
[handleNavPointerUp]
|
||||||
|
);
|
||||||
|
|
||||||
const handleNavWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {
|
const handleNavWheel = useCallback(
|
||||||
|
(event: ReactWheelEvent<HTMLDivElement>) => {
|
||||||
const node = navScrollerRef.current;
|
const node = navScrollerRef.current;
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
|
const delta =
|
||||||
|
Math.abs(event.deltaX) > Math.abs(event.deltaY)
|
||||||
|
? event.deltaX
|
||||||
|
: event.deltaY;
|
||||||
if (!delta) return;
|
if (!delta) return;
|
||||||
node.scrollLeft += delta;
|
node.scrollLeft += delta;
|
||||||
}, []);
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const navigationItems = useMemo(() => {
|
const navigationItems = useMemo(() => {
|
||||||
const items = (catalogNavigation || []).filter((item) => {
|
const items = (catalogNavigation || []).filter((item) => {
|
||||||
@@ -360,7 +474,11 @@ export default function BooksCatalogPage() {
|
|||||||
|
|
||||||
const containerRect = container.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
const activeRect = activeItem.getBoundingClientRect();
|
const activeRect = activeItem.getBoundingClientRect();
|
||||||
const targetLeft = container.scrollLeft + activeRect.left - containerRect.left - (container.clientWidth - activeItem.clientWidth) / 2;
|
const targetLeft =
|
||||||
|
container.scrollLeft +
|
||||||
|
activeRect.left -
|
||||||
|
containerRect.left -
|
||||||
|
(container.clientWidth - activeItem.clientWidth) / 2;
|
||||||
container.scrollTo({ left: Math.max(0, targetLeft), behavior: 'smooth' });
|
container.scrollTo({ left: Math.max(0, targetLeft), behavior: 'smooth' });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -377,7 +495,11 @@ export default function BooksCatalogPage() {
|
|||||||
|
|
||||||
const containerRect = container.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
const activeRect = activeItem.getBoundingClientRect();
|
const activeRect = activeItem.getBoundingClientRect();
|
||||||
const targetLeft = container.scrollLeft + activeRect.left - containerRect.left - (container.clientWidth - activeItem.clientWidth) / 2;
|
const targetLeft =
|
||||||
|
container.scrollLeft +
|
||||||
|
activeRect.left -
|
||||||
|
containerRect.left -
|
||||||
|
(container.clientWidth - activeItem.clientWidth) / 2;
|
||||||
container.scrollTo({ left: Math.max(0, targetLeft), behavior: 'smooth' });
|
container.scrollTo({ left: Math.max(0, targetLeft), behavior: 'smooth' });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -385,10 +507,10 @@ export default function BooksCatalogPage() {
|
|||||||
}, [selectedSourceId, sources.length]);
|
}, [selectedSourceId, sources.length]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-4'>
|
||||||
<div
|
<div
|
||||||
ref={sourceScrollerRef}
|
ref={sourceScrollerRef}
|
||||||
className='flex flex-nowrap gap-2 overflow-x-auto pb-1 cursor-grab select-none touch-pan-x active:cursor-grabbing'
|
className='flex flex-nowrap gap-2 overflow-x-auto px-1 pb-1.5 pt-2 cursor-grab select-none touch-pan-x active:cursor-grabbing'
|
||||||
onPointerDown={handleSourcePointerDown}
|
onPointerDown={handleSourcePointerDown}
|
||||||
onPointerMove={handleSourcePointerMove}
|
onPointerMove={handleSourcePointerMove}
|
||||||
onPointerUp={handleSourcePointerUp}
|
onPointerUp={handleSourcePointerUp}
|
||||||
@@ -399,7 +521,9 @@ export default function BooksCatalogPage() {
|
|||||||
{sources.map((source) => (
|
{sources.map((source) => (
|
||||||
<Link
|
<Link
|
||||||
key={source.id}
|
key={source.id}
|
||||||
ref={source.id === selectedSourceId ? activeSourceItemRef : undefined}
|
ref={
|
||||||
|
source.id === selectedSourceId ? activeSourceItemRef : undefined
|
||||||
|
}
|
||||||
href={`/books/catalog?sourceId=${encodeURIComponent(source.id)}`}
|
href={`/books/catalog?sourceId=${encodeURIComponent(source.id)}`}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
onDragStart={(event) => event.preventDefault()}
|
onDragStart={(event) => event.preventDefault()}
|
||||||
@@ -413,19 +537,22 @@ export default function BooksCatalogPage() {
|
|||||||
setSelectedHref('');
|
setSelectedHref('');
|
||||||
showImmediateContentLoading();
|
showImmediateContentLoading();
|
||||||
}}
|
}}
|
||||||
className={`shrink-0 whitespace-nowrap rounded-full px-4 py-2 text-sm ${source.id === selectedSourceId ? 'bg-sky-600 text-white' : 'border border-gray-200 dark:border-gray-700'}`}
|
className={`shrink-0 cursor-pointer whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-emerald-500 ${
|
||||||
|
source.id === selectedSourceId
|
||||||
|
? 'bg-emerald-600 text-white shadow-sm shadow-emerald-600/20'
|
||||||
|
: 'border border-emerald-100 bg-white/70 text-slate-600 hover:bg-emerald-50 hover:text-emerald-700 dark:border-emerald-500/10 dark:bg-gray-950/50 dark:text-gray-300 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{source.name}
|
{source.name}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{error ? <div className='text-sm text-red-500'>{error}</div> : null}
|
{data || navigationItems.length > 0 || error ? (
|
||||||
{data || navigationItems.length > 0 ? (
|
|
||||||
<>
|
<>
|
||||||
{navigationItems.length > 0 ? (
|
{navigationItems.length > 0 ? (
|
||||||
<div
|
<div
|
||||||
ref={navScrollerRef}
|
ref={navScrollerRef}
|
||||||
className='flex flex-nowrap gap-2 overflow-x-auto pb-1 cursor-grab select-none touch-pan-x active:cursor-grabbing'
|
className='flex flex-nowrap gap-2 overflow-x-auto px-1 pb-1.5 pt-2 cursor-grab select-none touch-pan-x active:cursor-grabbing'
|
||||||
onPointerDown={handleNavPointerDown}
|
onPointerDown={handleNavPointerDown}
|
||||||
onPointerMove={handleNavPointerMove}
|
onPointerMove={handleNavPointerMove}
|
||||||
onPointerUp={handleNavPointerUp}
|
onPointerUp={handleNavPointerUp}
|
||||||
@@ -436,8 +563,12 @@ export default function BooksCatalogPage() {
|
|||||||
{navigationItems.map((item, index) => (
|
{navigationItems.map((item, index) => (
|
||||||
<Link
|
<Link
|
||||||
key={`${item.href}-${index}`}
|
key={`${item.href}-${index}`}
|
||||||
ref={item.href === selectedHref ? activeNavItemRef : undefined}
|
ref={
|
||||||
href={`/books/catalog?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(item.href)}`}
|
item.href === selectedHref ? activeNavItemRef : undefined
|
||||||
|
}
|
||||||
|
href={`/books/catalog?sourceId=${encodeURIComponent(
|
||||||
|
sourceId
|
||||||
|
)}&href=${encodeURIComponent(item.href)}`}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
onDragStart={(event) => event.preventDefault()}
|
onDragStart={(event) => event.preventDefault()}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
@@ -449,24 +580,55 @@ export default function BooksCatalogPage() {
|
|||||||
setSelectedHref(item.href);
|
setSelectedHref(item.href);
|
||||||
showImmediateContentLoading();
|
showImmediateContentLoading();
|
||||||
}}
|
}}
|
||||||
className={`shrink-0 whitespace-nowrap rounded-full px-4 py-2 text-sm ${item.href === selectedHref ? 'bg-sky-600 text-white' : 'border border-gray-200 dark:border-gray-700'}`}
|
className={`shrink-0 cursor-pointer whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-emerald-500 ${
|
||||||
|
item.href === selectedHref
|
||||||
|
? 'bg-emerald-600 text-white shadow-sm shadow-emerald-600/20'
|
||||||
|
: 'border border-emerald-100 bg-white/70 text-slate-600 hover:bg-emerald-50 hover:text-emerald-700 dark:border-emerald-500/10 dark:bg-gray-950/50 dark:text-gray-300 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{item.title.trim()}
|
{item.title.trim()}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{loadingCatalog ? (
|
{error ? (
|
||||||
|
<div className='flex min-h-[45vh] items-center justify-center px-4'>
|
||||||
|
<div className='w-full max-w-md rounded-[2rem] border border-red-200 bg-white/85 p-6 text-center shadow-xl shadow-red-950/10 backdrop-blur dark:border-red-500/20 dark:bg-gray-950/75'>
|
||||||
|
<div className='mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-red-50 text-red-600 dark:bg-red-500/10 dark:text-red-300'>
|
||||||
|
<AlertCircle className='h-6 w-6' />
|
||||||
|
</div>
|
||||||
|
<h2 className='mt-4 text-lg font-bold text-slate-950 dark:text-white'>
|
||||||
|
目录加载失败
|
||||||
|
</h2>
|
||||||
|
<p className='mt-2 text-sm leading-6 text-slate-500 dark:text-slate-400'>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : loadingCatalog ? (
|
||||||
<LoadingMoreSkeleton />
|
<LoadingMoreSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<section className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
<section className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
||||||
{entries.map((item) => <BookCard key={`${item.sourceId}-${item.id}-${item.detailHref || item.acquisitionLinks[0]?.href || ''}`} item={item} href={makeHref(sourceId, item)} onNavigate={() => cacheBookListItem(item)} />)}
|
{entries.map((item) => (
|
||||||
|
<BookCard
|
||||||
|
key={`${item.sourceId}-${item.id}-${
|
||||||
|
item.detailHref || item.acquisitionLinks[0]?.href || ''
|
||||||
|
}`}
|
||||||
|
item={item}
|
||||||
|
href={makeHref(sourceId, item)}
|
||||||
|
onNavigate={() => cacheBookListItem(item)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{loadingMore ? <LoadingMoreSkeleton /> : null}
|
{loadingMore ? <LoadingMoreSkeleton /> : null}
|
||||||
{!loadingMore && nextHref ? <div ref={loaderRef} className='h-8 w-full' /> : null}
|
{!loadingMore && nextHref ? (
|
||||||
|
<div ref={loaderRef} className='h-8 w-full' />
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : !error ? <CatalogSkeleton /> : null}
|
) : !error ? (
|
||||||
|
<CatalogSkeleton />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+230
-55
@@ -1,25 +1,34 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { BookmarkPlus, BookOpen, Download, FileText, Tags } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { buildBookReadPath, cacheBookDetail, getBookRouteCache } from '@/lib/book-route-cache.client';
|
import {
|
||||||
import { deleteBookShelf, getAllBookShelf, saveBookShelf } from '@/lib/book.db.client';
|
deleteBookShelf,
|
||||||
|
getAllBookShelf,
|
||||||
|
saveBookShelf,
|
||||||
|
} from '@/lib/book.db.client';
|
||||||
import { BookChapter, BookDetail, BookShelfItem } from '@/lib/book.types';
|
import { BookChapter, BookDetail, BookShelfItem } from '@/lib/book.types';
|
||||||
|
import {
|
||||||
|
buildBookReadPath,
|
||||||
|
cacheBookDetail,
|
||||||
|
getBookRouteCache,
|
||||||
|
} from '@/lib/book-route-cache.client';
|
||||||
|
|
||||||
function DetailSkeleton() {
|
function DetailSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className='space-y-6 animate-pulse'>
|
<div className='space-y-6 animate-pulse'>
|
||||||
<section className='grid gap-6 rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950 md:grid-cols-[220px_1fr]'>
|
<section className='grid gap-6 rounded-[2rem] border border-emerald-100/80 bg-white/85 p-5 shadow-sm shadow-emerald-950/5 dark:border-emerald-500/10 dark:bg-gray-950/70 md:grid-cols-[220px_1fr]'>
|
||||||
<div className='aspect-[3/4] rounded-3xl bg-gray-200 dark:bg-gray-800' />
|
<div className='aspect-[3/4] rounded-3xl bg-emerald-100 dark:bg-gray-800' />
|
||||||
<div className='space-y-4'>
|
<div className='space-y-4'>
|
||||||
<div className='h-8 w-2/3 rounded bg-gray-200 dark:bg-gray-800' />
|
<div className='h-8 w-2/3 rounded bg-emerald-100 dark:bg-gray-800' />
|
||||||
<div className='h-4 w-1/3 rounded bg-gray-200 dark:bg-gray-800' />
|
<div className='h-4 w-1/3 rounded bg-emerald-100 dark:bg-gray-800' />
|
||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
<div className='h-4 w-full rounded bg-gray-200 dark:bg-gray-800' />
|
<div className='h-4 w-full rounded bg-emerald-100 dark:bg-gray-800' />
|
||||||
<div className='h-4 w-11/12 rounded bg-gray-200 dark:bg-gray-800' />
|
<div className='h-4 w-11/12 rounded bg-emerald-100 dark:bg-gray-800' />
|
||||||
<div className='h-4 w-10/12 rounded bg-gray-200 dark:bg-gray-800' />
|
<div className='h-4 w-10/12 rounded bg-emerald-100 dark:bg-gray-800' />
|
||||||
</div>
|
</div>
|
||||||
<div className='flex gap-3'>
|
<div className='flex gap-3'>
|
||||||
<div className='h-10 w-24 rounded-2xl bg-gray-200 dark:bg-gray-800' />
|
<div className='h-10 w-24 rounded-2xl bg-gray-200 dark:bg-gray-800' />
|
||||||
@@ -37,35 +46,54 @@ function parseDownloadFilename(disposition: string | null) {
|
|||||||
if (utf8Match?.[1]) {
|
if (utf8Match?.[1]) {
|
||||||
try {
|
try {
|
||||||
return decodeURIComponent(utf8Match[1]);
|
return decodeURIComponent(utf8Match[1]);
|
||||||
} catch {}
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const plainMatch = disposition.match(/filename="?([^";]+)"?/i);
|
const plainMatch = disposition.match(/filename="?([^";]+)"?/i);
|
||||||
return plainMatch?.[1] || '';
|
return plainMatch?.[1] || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeFilename(name: string) {
|
function sanitizeFilename(name: string) {
|
||||||
return name.replace(/[\/:*?"<>|]/g, '_').trim();
|
return name.replace(/[/:*?"<>|]/g, '_').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openBookFile(sourceId: string, bookId: string, format?: 'epub' | 'pdf' | 'chapters', download = false, href?: string, title?: string) {
|
async function openBookFile(
|
||||||
|
sourceId: string,
|
||||||
|
bookId: string,
|
||||||
|
format?: 'epub' | 'pdf' | 'chapters',
|
||||||
|
download = false,
|
||||||
|
href?: string,
|
||||||
|
title?: string
|
||||||
|
) {
|
||||||
const response = await fetch('/api/books/file', {
|
const response = await fetch('/api/books/file', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ sourceId, bookId, format: format || null, href: href || undefined }),
|
body: JSON.stringify({
|
||||||
|
sourceId,
|
||||||
|
bookId,
|
||||||
|
format: format || null,
|
||||||
|
href: href || undefined,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let message = '打开文件失败';
|
let message = '打开文件失败';
|
||||||
try {
|
try {
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
message = json.error || message;
|
message = json.error || message;
|
||||||
} catch {}
|
} catch {
|
||||||
|
// Keep fallback error message.
|
||||||
|
}
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
if (download) {
|
if (download) {
|
||||||
const headerFilename = parseDownloadFilename(response.headers.get('content-disposition'));
|
const headerFilename = parseDownloadFilename(
|
||||||
const fallbackBaseName = sanitizeFilename(title || bookId || 'book') || 'book';
|
response.headers.get('content-disposition')
|
||||||
|
);
|
||||||
|
const fallbackBaseName =
|
||||||
|
sanitizeFilename(title || bookId || 'book') || 'book';
|
||||||
const extension = format === 'pdf' ? 'pdf' : 'epub';
|
const extension = format === 'pdf' ? 'pdf' : 'epub';
|
||||||
const finalFilename = headerFilename || `${fallbackBaseName}.${extension}`;
|
const finalFilename = headerFilename || `${fallbackBaseName}.${extension}`;
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
@@ -92,12 +120,17 @@ export default function BookDetailPage() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [fileBusy, setFileBusy] = useState<'open' | 'download' | ''>('');
|
const [fileBusy, setFileBusy] = useState<'open' | 'download' | ''>('');
|
||||||
|
|
||||||
const cached = useMemo(() => (sourceId && bookId ? getBookRouteCache(sourceId, bookId) : null), [sourceId, bookId]);
|
const cached = useMemo(
|
||||||
|
() => (sourceId && bookId ? getBookRouteCache(sourceId, bookId) : null),
|
||||||
|
[sourceId, bookId]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getAllBookShelf().then((items) => {
|
getAllBookShelf()
|
||||||
|
.then((items) => {
|
||||||
setShelf(items);
|
setShelf(items);
|
||||||
}).catch(() => undefined);
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -127,11 +160,17 @@ export default function BookDetailPage() {
|
|||||||
|
|
||||||
const readable = detail?.acquisitionLinks.find((item) => {
|
const readable = detail?.acquisitionLinks.find((item) => {
|
||||||
const type = item.type.toLowerCase();
|
const type = item.type.toLowerCase();
|
||||||
return type.includes('epub') || type.includes('pdf') || type.includes('legado-chapters') || item.rel === 'legado:chapters';
|
return (
|
||||||
|
type.includes('epub') ||
|
||||||
|
type.includes('pdf') ||
|
||||||
|
type.includes('legado-chapters') ||
|
||||||
|
item.rel === 'legado:chapters'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
const readableFormat = readable?.type.toLowerCase().includes('pdf')
|
const readableFormat = readable?.type.toLowerCase().includes('pdf')
|
||||||
? 'pdf'
|
? 'pdf'
|
||||||
: readable?.type.toLowerCase().includes('legado-chapters') || readable?.rel === 'legado:chapters'
|
: readable?.type.toLowerCase().includes('legado-chapters') ||
|
||||||
|
readable?.rel === 'legado:chapters'
|
||||||
? 'chapters'
|
? 'chapters'
|
||||||
: 'epub';
|
: 'epub';
|
||||||
|
|
||||||
@@ -150,7 +189,9 @@ export default function BookDetailPage() {
|
|||||||
sourceId: detail.sourceId,
|
sourceId: detail.sourceId,
|
||||||
bookId: detail.id,
|
bookId: detail.id,
|
||||||
});
|
});
|
||||||
fetch(`/api/books/read/chapters?${params.toString()}`, { cache: 'no-store' })
|
fetch(`/api/books/read/chapters?${params.toString()}`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
})
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (!res.ok) throw new Error(json.error || '获取章节失败');
|
if (!res.ok) throw new Error(json.error || '获取章节失败');
|
||||||
@@ -199,74 +240,200 @@ export default function BookDetailPage() {
|
|||||||
cacheBookDetail(detail);
|
cacheBookDetail(detail);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (error) return <div className='text-sm text-red-500'>{error}</div>;
|
if (error)
|
||||||
|
return (
|
||||||
|
<div className='rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-500/20 dark:bg-red-950/20 dark:text-red-300'>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
if (!detail) return <DetailSkeleton />;
|
if (!detail) return <DetailSkeleton />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
<section className='grid gap-6 rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950 md:grid-cols-[220px_1fr]'>
|
<section className='relative overflow-hidden rounded-[2.25rem] border border-emerald-100 bg-gradient-to-br from-emerald-50 via-white to-lime-50 p-5 shadow-sm shadow-emerald-950/5 dark:border-emerald-500/10 dark:from-emerald-950/30 dark:via-gray-950 dark:to-lime-950/20'>
|
||||||
<div className='overflow-hidden rounded-3xl bg-gray-100 dark:bg-gray-900'>
|
<div className='absolute -right-20 -top-24 h-64 w-64 rounded-full bg-emerald-300/20 blur-3xl dark:bg-emerald-500/10' />
|
||||||
{detail.cover ? <img src={detail.cover} alt={detail.title} className='h-full w-full object-cover' /> : <div className='flex aspect-[3/4] items-center justify-center text-sm text-gray-400'>无封面</div>}
|
<div className='relative grid gap-6 md:grid-cols-[220px_1fr]'>
|
||||||
|
<div className='overflow-hidden rounded-[2rem] bg-gradient-to-br from-emerald-50 to-lime-50 shadow-xl shadow-emerald-950/10 ring-1 ring-emerald-100 dark:from-gray-900 dark:to-emerald-950/20 dark:ring-emerald-500/10'>
|
||||||
|
{detail.cover ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={detail.cover}
|
||||||
|
alt={detail.title}
|
||||||
|
className='h-full w-full object-cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='flex aspect-[3/4] flex-col items-center justify-center gap-2 text-sm text-emerald-500 dark:text-emerald-300'>
|
||||||
|
<BookOpen className='h-9 w-9' />
|
||||||
|
无封面
|
||||||
</div>
|
</div>
|
||||||
<div className='space-y-4'>
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='flex min-w-0 flex-col justify-between gap-5'>
|
||||||
<div>
|
<div>
|
||||||
<h1 className='text-2xl font-semibold'>{detail.title}</h1>
|
<div className='inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-white/70 px-3 py-1 text-xs font-semibold text-emerald-700 shadow-sm dark:border-emerald-500/20 dark:bg-gray-950/50 dark:text-emerald-200'>
|
||||||
<div className='mt-2 text-sm text-gray-500 dark:text-gray-400'>{detail.author || '未知作者'}</div>
|
<BookOpen className='h-3.5 w-3.5' />
|
||||||
<div className='mt-1 text-xs text-gray-400 dark:text-gray-500'>{detail.sourceName}</div>
|
{detail.sourceName}
|
||||||
|
</div>
|
||||||
|
<h1 className='mt-4 text-3xl font-black tracking-tight text-emerald-950 dark:text-emerald-50 sm:text-4xl'>
|
||||||
|
{detail.title}
|
||||||
|
</h1>
|
||||||
|
<div className='mt-2 text-sm font-medium text-slate-500 dark:text-slate-400'>
|
||||||
|
{detail.author || '未知作者'}
|
||||||
|
</div>
|
||||||
|
{detail.summary ? (
|
||||||
|
<div className='mt-4 line-clamp-5 text-sm leading-7 text-slate-600 dark:text-slate-300'>
|
||||||
|
{detail.summary}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className='mt-4 flex flex-wrap gap-2'>
|
||||||
|
{(detail.categories || detail.tags || []).map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className='inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700 ring-1 ring-emerald-100 dark:bg-emerald-500/10 dark:text-emerald-200 dark:ring-emerald-500/20'
|
||||||
|
>
|
||||||
|
<Tags className='h-3 w-3' />
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
{detail.summary ? <div className='text-sm leading-7 text-gray-700 dark:text-gray-300'>{detail.summary}</div> : null}
|
|
||||||
<div className='flex flex-wrap gap-2'>
|
|
||||||
{(detail.categories || detail.tags || []).map((tag) => <span key={tag} className='rounded-full bg-gray-100 px-3 py-1 text-xs dark:bg-gray-900'>{tag}</span>)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className='flex flex-wrap gap-3'>
|
<div className='flex flex-wrap gap-3'>
|
||||||
{readable ? <Link href={buildBookReadPath(detail.sourceId, detail.id)} onClick={() => cacheBookDetail(detail)} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'>在线阅读</Link> : null}
|
{readable ? (
|
||||||
<button onClick={toggleShelf} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>{shelf[`${detail.sourceId}+${detail.id}`] ? '移出书架' : '加入书架'}</button>
|
<Link
|
||||||
{readable && readableFormat !== 'chapters' ? <button onClick={async () => { try { setFileBusy('download'); await openBookFile(detail.sourceId, detail.id, readableFormat, true, readable?.href, detail.title); } catch (err) { setError((err as Error).message || '下载文件失败'); } finally { setFileBusy(''); } }} disabled={fileBusy !== ''} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>{fileBusy === 'download' ? '下载中...' : '下载文件'}</button> : null}
|
href={buildBookReadPath(detail.sourceId, detail.id)}
|
||||||
|
onClick={() => cacheBookDetail(detail)}
|
||||||
|
className='inline-flex cursor-pointer items-center gap-2 rounded-2xl bg-emerald-600 px-5 py-2.5 text-sm font-bold text-white shadow-lg shadow-emerald-600/20 transition-colors duration-200 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-gray-950'
|
||||||
|
>
|
||||||
|
<BookOpen className='h-4 w-4' />
|
||||||
|
在线阅读
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={toggleShelf}
|
||||||
|
className='inline-flex cursor-pointer items-center gap-2 rounded-2xl border border-emerald-200 bg-white/70 px-5 py-2.5 text-sm font-semibold text-emerald-800 transition-colors duration-200 hover:bg-emerald-50 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:border-emerald-500/20 dark:bg-gray-950/50 dark:text-emerald-100 dark:hover:bg-emerald-500/10'
|
||||||
|
>
|
||||||
|
<BookmarkPlus className='h-4 w-4' />
|
||||||
|
{shelf[`${detail.sourceId}+${detail.id}`]
|
||||||
|
? '移出书架'
|
||||||
|
: '加入书架'}
|
||||||
|
</button>
|
||||||
|
{readable && readableFormat !== 'chapters' ? (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
setFileBusy('download');
|
||||||
|
await openBookFile(
|
||||||
|
detail.sourceId,
|
||||||
|
detail.id,
|
||||||
|
readableFormat,
|
||||||
|
true,
|
||||||
|
readable?.href,
|
||||||
|
detail.title
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message || '下载文件失败');
|
||||||
|
} finally {
|
||||||
|
setFileBusy('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={fileBusy !== ''}
|
||||||
|
className='inline-flex cursor-pointer items-center gap-2 rounded-2xl border border-emerald-200 bg-white/70 px-5 py-2.5 text-sm font-semibold text-emerald-800 transition-colors duration-200 hover:bg-emerald-50 focus:outline-none focus:ring-2 focus:ring-emerald-500 disabled:cursor-not-allowed disabled:opacity-60 dark:border-emerald-500/20 dark:bg-gray-950/50 dark:text-emerald-100 dark:hover:bg-emerald-500/10'
|
||||||
|
>
|
||||||
|
<Download className='h-4 w-4' />
|
||||||
|
{fileBusy === 'download' ? '下载中...' : '下载文件'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
|
||||||
<h2 className='text-lg font-semibold'>可用格式</h2>
|
<section className='rounded-[2rem] border border-emerald-100/80 bg-white/85 p-5 shadow-sm shadow-emerald-950/5 dark:border-emerald-500/10 dark:bg-gray-950/70'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<FileText className='h-5 w-5 text-emerald-600 dark:text-emerald-300' />
|
||||||
|
<h2 className='text-lg font-bold text-slate-950 dark:text-white'>
|
||||||
|
可用格式
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
<div className='mt-4 space-y-3'>
|
<div className='mt-4 space-y-3'>
|
||||||
{detail.acquisitionLinks.map((item) => {
|
{detail.acquisitionLinks.map((item) => {
|
||||||
const type = item.type.toLowerCase();
|
const type = item.type.toLowerCase();
|
||||||
const format = type.includes('pdf') ? 'pdf' : type.includes('epub') ? 'epub' : type.includes('legado-chapters') || item.rel === 'legado:chapters' ? 'chapters' : undefined;
|
const format = type.includes('pdf')
|
||||||
|
? 'pdf'
|
||||||
|
: type.includes('epub')
|
||||||
|
? 'epub'
|
||||||
|
: type.includes('legado-chapters') ||
|
||||||
|
item.rel === 'legado:chapters'
|
||||||
|
? 'chapters'
|
||||||
|
: undefined;
|
||||||
return (
|
return (
|
||||||
<div key={`${item.href}-${item.type}`} className='flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3 text-sm dark:bg-gray-900'>
|
<div
|
||||||
<div>
|
key={`${item.href}-${item.type}`}
|
||||||
<div>{item.title || item.type}</div>
|
className='flex items-center justify-between gap-4 rounded-2xl bg-emerald-50/70 px-4 py-3 text-sm ring-1 ring-emerald-100 dark:bg-emerald-500/5 dark:ring-emerald-500/10'
|
||||||
<div className='text-xs text-gray-500'>{item.rel}</div>
|
>
|
||||||
|
<div className='min-w-0'>
|
||||||
|
<div className='truncate font-medium text-slate-900 dark:text-white'>
|
||||||
|
{item.title || item.type}
|
||||||
</div>
|
</div>
|
||||||
<button disabled={!format || fileBusy !== ''} onClick={async () => {
|
<div className='mt-1 truncate text-xs text-slate-500 dark:text-slate-400'>
|
||||||
|
{item.rel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
disabled={!format || fileBusy !== ''}
|
||||||
|
onClick={async () => {
|
||||||
if (!format) return;
|
if (!format) return;
|
||||||
if (format === 'epub' || format === 'chapters') {
|
if (format === 'epub' || format === 'chapters') {
|
||||||
cacheBookDetail(detail);
|
cacheBookDetail(detail);
|
||||||
window.location.href = buildBookReadPath(detail.sourceId, detail.id);
|
window.location.href = buildBookReadPath(
|
||||||
|
detail.sourceId,
|
||||||
|
detail.id
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setFileBusy('open');
|
setFileBusy('open');
|
||||||
await openBookFile(detail.sourceId, detail.id, format, false, item.href);
|
await openBookFile(
|
||||||
|
detail.sourceId,
|
||||||
|
detail.id,
|
||||||
|
format,
|
||||||
|
false,
|
||||||
|
item.href
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message || '打开文件失败');
|
setError((err as Error).message || '打开文件失败');
|
||||||
} finally {
|
} finally {
|
||||||
setFileBusy('');
|
setFileBusy('');
|
||||||
}
|
}
|
||||||
}} className='text-sky-600 disabled:text-gray-400'>打开</button>
|
}}
|
||||||
|
className='cursor-pointer rounded-full px-3 py-1.5 text-xs font-semibold text-emerald-700 transition-colors duration-200 hover:bg-white disabled:cursor-not-allowed disabled:text-gray-400 dark:text-emerald-200 dark:hover:bg-emerald-500/10'
|
||||||
|
>
|
||||||
|
打开
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{readableFormat === 'chapters' ? (
|
{readableFormat === 'chapters' ? (
|
||||||
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
<section className='rounded-[2rem] border border-emerald-100/80 bg-white/85 p-5 shadow-sm shadow-emerald-950/5 dark:border-emerald-500/10 dark:bg-gray-950/70'>
|
||||||
<div className='flex items-center justify-between gap-3'>
|
<div className='flex items-center justify-between gap-3'>
|
||||||
<h2 className='text-lg font-semibold'>章节目录</h2>
|
<h2 className='text-lg font-bold text-slate-950 dark:text-white'>
|
||||||
<div className='text-sm text-gray-500'>{chaptersLoading ? '加载中...' : `${chapters.length} 章`}</div>
|
章节目录
|
||||||
|
</h2>
|
||||||
|
<div className='rounded-full bg-emerald-50 px-3 py-1 text-sm font-medium text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200'>
|
||||||
|
{chaptersLoading ? '加载中...' : `${chapters.length} 章`}
|
||||||
</div>
|
</div>
|
||||||
{chaptersError ? <div className='mt-4 text-sm text-red-500'>{chaptersError}</div> : null}
|
</div>
|
||||||
|
{chaptersError ? (
|
||||||
|
<div className='mt-4 text-sm text-red-500'>{chaptersError}</div>
|
||||||
|
) : null}
|
||||||
{!chaptersLoading && !chaptersError && chapters.length === 0 ? (
|
{!chaptersLoading && !chaptersError && chapters.length === 0 ? (
|
||||||
<div className='mt-4 rounded-2xl bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:bg-amber-900/20 dark:text-amber-200'>
|
<div className='mt-4 rounded-2xl bg-lime-50 px-4 py-3 text-sm text-lime-800 dark:bg-lime-900/20 dark:text-lime-200'>
|
||||||
源站当前没有返回章节,这不是 EPUB 文件缺失;请换有章节的搜索结果。
|
源站当前没有返回章节,这不是 EPUB 文件缺失;请换有章节的搜索结果。
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -275,9 +442,13 @@ export default function BookDetailPage() {
|
|||||||
{chapters.slice(0, 60).map((chapter) => (
|
{chapters.slice(0, 60).map((chapter) => (
|
||||||
<Link
|
<Link
|
||||||
key={`${chapter.href}-${chapter.order}`}
|
key={`${chapter.href}-${chapter.order}`}
|
||||||
href={buildBookReadPath(detail.sourceId, detail.id, chapter.href)}
|
href={buildBookReadPath(
|
||||||
|
detail.sourceId,
|
||||||
|
detail.id,
|
||||||
|
chapter.href
|
||||||
|
)}
|
||||||
onClick={() => cacheBookDetail(detail)}
|
onClick={() => cacheBookDetail(detail)}
|
||||||
className='truncate rounded-2xl bg-gray-50 px-4 py-3 text-sm hover:bg-sky-50 hover:text-sky-600 dark:bg-gray-900 dark:hover:bg-sky-950/40'
|
className='truncate rounded-2xl bg-emerald-50/70 px-4 py-3 text-sm text-slate-700 ring-1 ring-emerald-100 transition-colors duration-200 hover:bg-white hover:text-emerald-700 dark:bg-emerald-500/5 dark:text-slate-200 dark:ring-emerald-500/10 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
|
||||||
title={chapter.title}
|
title={chapter.title}
|
||||||
>
|
>
|
||||||
{chapter.title}
|
{chapter.title}
|
||||||
@@ -285,7 +456,11 @@ export default function BookDetailPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{chapters.length > 60 ? <div className='mt-3 text-xs text-gray-500'>仅预览前 60 章,完整目录请进入阅读页侧边栏查看。</div> : null}
|
{chapters.length > 60 ? (
|
||||||
|
<div className='mt-3 text-xs text-slate-500 dark:text-slate-400'>
|
||||||
|
仅预览前 60 章,完整目录请进入阅读页侧边栏查看。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+283
-63
@@ -1,20 +1,44 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { FolderCog, RefreshCw, Trash2, X } from 'lucide-react';
|
import {
|
||||||
|
BookOpen,
|
||||||
|
Clock3,
|
||||||
|
Database,
|
||||||
|
FolderCog,
|
||||||
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { deleteCachedBookFile, listCachedBookFiles, type CachedBookFile } from '@/lib/book-cache.client';
|
import {
|
||||||
import { buildBookReadPath, cacheBookReadRecord, cacheBookShelfItem } from '@/lib/book-route-cache.client';
|
deleteBookReadRecord,
|
||||||
import { deleteBookReadRecord, getAllBookReadRecords, getAllBookShelf, getCachedBookReadRecordsSnapshot } from '@/lib/book.db.client';
|
getAllBookReadRecords,
|
||||||
|
getAllBookShelf,
|
||||||
|
getCachedBookReadRecordsSnapshot,
|
||||||
|
} from '@/lib/book.db.client';
|
||||||
import { BookReadRecord, BookShelfItem } from '@/lib/book.types';
|
import { BookReadRecord, BookShelfItem } from '@/lib/book.types';
|
||||||
|
import {
|
||||||
|
type CachedBookFile,
|
||||||
|
deleteCachedBookFile,
|
||||||
|
listCachedBookFiles,
|
||||||
|
} from '@/lib/book-cache.client';
|
||||||
|
import {
|
||||||
|
buildBookReadPath,
|
||||||
|
cacheBookReadRecord,
|
||||||
|
cacheBookShelfItem,
|
||||||
|
} from '@/lib/book-route-cache.client';
|
||||||
import { subscribeToDataUpdates } from '@/lib/db.client';
|
import { subscribeToDataUpdates } from '@/lib/db.client';
|
||||||
|
|
||||||
function looksLikeInternalHref(value?: string) {
|
function looksLikeInternalHref(value?: string) {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
const normalized = value.trim().toLowerCase();
|
const normalized = value.trim().toLowerCase();
|
||||||
return /\.(xhtml|html|htm|xml)(#.*)?$/.test(normalized) || /^nav\b/.test(normalized);
|
return (
|
||||||
|
/\.(xhtml|html|htm|xml)(#.*)?$/.test(normalized) ||
|
||||||
|
/^nav\b/.test(normalized)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getReadableChapterLabel(item: BookReadRecord) {
|
function getReadableChapterLabel(item: BookReadRecord) {
|
||||||
@@ -36,16 +60,19 @@ function BookHistorySkeleton() {
|
|||||||
return (
|
return (
|
||||||
<div className='space-y-4'>
|
<div className='space-y-4'>
|
||||||
{Array.from({ length: 6 }).map((_, index) => (
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
<div key={index} className='rounded-3xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
<div
|
||||||
|
key={index}
|
||||||
|
className='rounded-[2rem] border border-emerald-100/80 bg-white/85 p-4 shadow-sm shadow-emerald-950/5 dark:border-emerald-500/10 dark:bg-gray-950/70'
|
||||||
|
>
|
||||||
<div className='flex gap-4'>
|
<div className='flex gap-4'>
|
||||||
<div className='h-28 w-20 animate-pulse overflow-hidden rounded-2xl bg-gray-200 dark:bg-gray-800' />
|
<div className='h-28 w-20 animate-pulse overflow-hidden rounded-2xl bg-emerald-100 dark:bg-gray-800' />
|
||||||
<div className='min-w-0 flex-1 space-y-3'>
|
<div className='min-w-0 flex-1 space-y-3'>
|
||||||
<div className='h-5 w-2/3 animate-pulse rounded bg-gray-200 dark:bg-gray-800' />
|
<div className='h-5 w-2/3 animate-pulse rounded bg-emerald-100 dark:bg-gray-800' />
|
||||||
<div className='h-4 w-1/3 animate-pulse rounded bg-gray-200 dark:bg-gray-800' />
|
<div className='h-4 w-1/3 animate-pulse rounded bg-emerald-100 dark:bg-gray-800' />
|
||||||
<div className='h-4 w-1/2 animate-pulse rounded bg-gray-200 dark:bg-gray-800' />
|
<div className='h-4 w-1/2 animate-pulse rounded bg-emerald-100 dark:bg-gray-800' />
|
||||||
<div className='flex gap-2 pt-1'>
|
<div className='flex gap-2 pt-1'>
|
||||||
<div className='h-9 w-20 animate-pulse rounded-2xl bg-gray-200 dark:bg-gray-800' />
|
<div className='h-9 w-20 animate-pulse rounded-2xl bg-emerald-100 dark:bg-gray-800' />
|
||||||
<div className='h-9 w-16 animate-pulse rounded-2xl bg-gray-200 dark:bg-gray-800' />
|
<div className='h-9 w-16 animate-pulse rounded-2xl bg-emerald-100 dark:bg-gray-800' />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,7 +90,11 @@ export default function BookHistoryPage() {
|
|||||||
const [cacheItems, setCacheItems] = useState<CachedBookFile[]>([]);
|
const [cacheItems, setCacheItems] = useState<CachedBookFile[]>([]);
|
||||||
const [cacheLoading, setCacheLoading] = useState(false);
|
const [cacheLoading, setCacheLoading] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [confirmAction, setConfirmAction] = useState<{ type: 'delete-one' | 'clear-all'; key?: string; title?: string } | null>(null);
|
const [confirmAction, setConfirmAction] = useState<{
|
||||||
|
type: 'delete-one' | 'clear-all';
|
||||||
|
key?: string;
|
||||||
|
title?: string;
|
||||||
|
} | null>(null);
|
||||||
const [displayAll, setDisplayAll] = useState(false);
|
const [displayAll, setDisplayAll] = useState(false);
|
||||||
|
|
||||||
const updateRecords = (nextRecords: Record<string, BookReadRecord>) => {
|
const updateRecords = (nextRecords: Record<string, BookReadRecord>) => {
|
||||||
@@ -83,10 +114,17 @@ export default function BookHistoryPage() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
getAllBookReadRecords().then(updateRecords).catch(() => undefined).finally(() => setLoading(false));
|
getAllBookReadRecords()
|
||||||
getAllBookShelf().then(setShelf).catch(() => undefined);
|
.then(updateRecords)
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
getAllBookShelf()
|
||||||
|
.then(setShelf)
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
const unsubscribeHistory = subscribeToDataUpdates<Record<string, BookReadRecord>>('bookHistoryUpdated', updateRecords);
|
const unsubscribeHistory = subscribeToDataUpdates<
|
||||||
|
Record<string, BookReadRecord>
|
||||||
|
>('bookHistoryUpdated', updateRecords);
|
||||||
return unsubscribeHistory;
|
return unsubscribeHistory;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -105,7 +143,9 @@ export default function BookHistoryPage() {
|
|||||||
void loadCacheItems();
|
void loadCacheItems();
|
||||||
}, [cacheModalOpen]);
|
}, [cacheModalOpen]);
|
||||||
|
|
||||||
const items = useMemo(() => Object.entries(records)
|
const items = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.entries(records)
|
||||||
.map(([key, item]) => {
|
.map(([key, item]) => {
|
||||||
const [fallbackSourceId = '', fallbackBookId = ''] = key.split('+');
|
const [fallbackSourceId = '', fallbackBookId = ''] = key.split('+');
|
||||||
const shelfItem = shelf[key];
|
const shelfItem = shelf[key];
|
||||||
@@ -122,93 +162,251 @@ export default function BookHistoryPage() {
|
|||||||
format: item.format || shelfItem?.format || 'epub',
|
format: item.format || shelfItem?.format || 'epub',
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => b.saveTime - a.saveTime), [records, shelf]);
|
.sort((a, b) => b.saveTime - a.saveTime),
|
||||||
|
[records, shelf]
|
||||||
|
);
|
||||||
const visibleItems = useMemo(
|
const visibleItems = useMemo(
|
||||||
() => (displayAll ? items : items.slice(0, 10)),
|
() => (displayAll ? items : items.slice(0, 10)),
|
||||||
[displayAll, items]
|
[displayAll, items]
|
||||||
);
|
);
|
||||||
|
|
||||||
const cacheTotalSize = useMemo(() => cacheItems.reduce((sum, item) => sum + item.size, 0), [cacheItems]);
|
const cacheTotalSize = useMemo(
|
||||||
|
() => cacheItems.reduce((sum, item) => sum + item.size, 0),
|
||||||
|
[cacheItems]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-4'>
|
<div className='space-y-5'>
|
||||||
<div className='flex items-center justify-between'>
|
<section className='relative overflow-hidden rounded-[2rem] border border-emerald-100/80 bg-gradient-to-br from-emerald-50 via-white to-lime-50 p-5 shadow-sm shadow-emerald-950/5 dark:border-emerald-500/10 dark:from-emerald-950/30 dark:via-gray-950 dark:to-lime-950/20'>
|
||||||
<div className='text-sm text-gray-500'>共 {items.length} 条阅读历史</div>
|
<div className='absolute -right-16 -top-20 h-48 w-48 rounded-full bg-emerald-300/20 blur-3xl dark:bg-emerald-500/10' />
|
||||||
|
<div className='relative flex items-center justify-between gap-4'>
|
||||||
|
<div>
|
||||||
|
<div className='inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-white/70 px-3 py-1 text-xs font-semibold text-emerald-700 shadow-sm dark:border-emerald-500/20 dark:bg-gray-950/50 dark:text-emerald-200'>
|
||||||
|
<Clock3 className='h-3.5 w-3.5' />
|
||||||
|
Reading Timeline
|
||||||
|
</div>
|
||||||
|
<h1 className='mt-3 text-3xl font-black tracking-tight text-emerald-950 dark:text-emerald-50'>
|
||||||
|
阅读历史
|
||||||
|
</h1>
|
||||||
|
<div className='mt-2 text-sm text-slate-500 dark:text-slate-400'>
|
||||||
|
共 {items.length} 条记录
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
onClick={() => setCacheModalOpen(true)}
|
onClick={() => setCacheModalOpen(true)}
|
||||||
className='inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 dark:border-gray-700'
|
className='inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-2xl border border-emerald-200 bg-white/80 text-emerald-700 transition-colors duration-200 hover:bg-emerald-50 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:border-emerald-500/20 dark:bg-gray-950/60 dark:text-emerald-200 dark:hover:bg-emerald-500/10'
|
||||||
aria-label='缓存管理'
|
aria-label='缓存管理'
|
||||||
title='缓存管理'
|
title='缓存管理'
|
||||||
>
|
>
|
||||||
<FolderCog className='h-4 w-4' />
|
<FolderCog className='h-5 w-5' />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<BookHistorySkeleton />
|
<BookHistorySkeleton />
|
||||||
) : (
|
) : (
|
||||||
visibleItems.map((item) => (
|
visibleItems.map((item) => (
|
||||||
<div key={item.storageKey} className='rounded-3xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
<article
|
||||||
|
key={item.storageKey}
|
||||||
|
className='rounded-[2rem] border border-emerald-100/80 bg-white/85 p-4 shadow-sm shadow-emerald-950/5 transition-colors duration-200 hover:border-emerald-200 hover:bg-white dark:border-emerald-500/10 dark:bg-gray-950/70 dark:hover:border-emerald-500/30'
|
||||||
|
>
|
||||||
<div className='flex gap-4'>
|
<div className='flex gap-4'>
|
||||||
<div className='h-28 w-20 overflow-hidden rounded-2xl bg-gray-100 dark:bg-gray-900'>{item.cover ? <img src={item.cover} alt={item.title} className='h-full w-full object-cover' /> : null}</div>
|
<div className='h-28 w-20 shrink-0 overflow-hidden rounded-2xl bg-gradient-to-br from-emerald-50 to-lime-50 ring-1 ring-emerald-100 dark:from-gray-900 dark:to-emerald-950/20 dark:ring-emerald-500/10'>
|
||||||
|
{item.cover ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={item.cover}
|
||||||
|
alt={item.title}
|
||||||
|
className='h-full w-full object-cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='flex h-full items-center justify-center text-emerald-400'>
|
||||||
|
<BookOpen className='h-7 w-7' />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className='min-w-0 flex-1'>
|
<div className='min-w-0 flex-1'>
|
||||||
<div className='truncate font-medium'>{item.title}</div>
|
<div className='truncate font-semibold text-slate-950 dark:text-white'>
|
||||||
<div className='mt-1 text-sm text-gray-500'>{item.author || item.sourceName}</div>
|
{item.title}
|
||||||
<div className='mt-1 text-xs text-gray-500'>已读 {Math.round(item.progressPercent || 0)}% · {getReadableChapterLabel(item)}</div>
|
</div>
|
||||||
|
<div className='mt-1 truncate text-sm text-slate-500 dark:text-slate-400'>
|
||||||
|
{item.author || item.sourceName}
|
||||||
|
</div>
|
||||||
|
<div className='mt-3 h-2 overflow-hidden rounded-full bg-emerald-50 dark:bg-gray-900'>
|
||||||
|
<div
|
||||||
|
className='h-full rounded-full bg-emerald-600'
|
||||||
|
style={{
|
||||||
|
width: `${Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(100, Math.round(item.progressPercent || 0))
|
||||||
|
)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='mt-1 text-xs text-slate-500 dark:text-slate-400'>
|
||||||
|
已读 {Math.round(item.progressPercent || 0)}% ·{' '}
|
||||||
|
{getReadableChapterLabel(item)}
|
||||||
|
</div>
|
||||||
<div className='mt-3 flex flex-wrap gap-2'>
|
<div className='mt-3 flex flex-wrap gap-2'>
|
||||||
{item.sourceId ? (
|
{item.sourceId ? (
|
||||||
<Link
|
<Link
|
||||||
href={buildBookReadPath(item.sourceId, item.bookId)}
|
href={buildBookReadPath(item.sourceId, item.bookId)}
|
||||||
onClick={() => { cacheBookReadRecord(item); if (item.sourceId && item.bookId) { cacheBookShelfItem({ sourceId: item.sourceId, sourceName: item.sourceName, bookId: item.bookId, title: item.title, author: item.author, cover: item.cover, format: item.format, detailHref: item.detailHref, acquisitionHref: item.acquisitionHref, saveTime: item.saveTime }); } }}
|
onClick={() => {
|
||||||
className='rounded-2xl bg-sky-600 px-3 py-2 text-xs text-white'
|
cacheBookReadRecord(item);
|
||||||
|
if (item.sourceId && item.bookId) {
|
||||||
|
cacheBookShelfItem({
|
||||||
|
sourceId: item.sourceId,
|
||||||
|
sourceName: item.sourceName,
|
||||||
|
bookId: item.bookId,
|
||||||
|
title: item.title,
|
||||||
|
author: item.author,
|
||||||
|
cover: item.cover,
|
||||||
|
format: item.format,
|
||||||
|
detailHref: item.detailHref,
|
||||||
|
acquisitionHref: item.acquisitionHref,
|
||||||
|
saveTime: item.saveTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className='inline-flex cursor-pointer items-center gap-1.5 rounded-2xl bg-emerald-600 px-3 py-2 text-xs font-semibold text-white transition-colors duration-200 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500'
|
||||||
>
|
>
|
||||||
继续阅读
|
继续阅读
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span className='rounded-2xl bg-gray-200 px-3 py-2 text-xs text-gray-500 dark:bg-gray-800'>历史记录缺少书源信息</span>
|
<span className='rounded-2xl bg-gray-200 px-3 py-2 text-xs text-gray-500 dark:bg-gray-800'>
|
||||||
|
历史记录缺少书源信息
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<button onClick={async () => { const [deleteSourceId = item.sourceId, deleteBookId = item.bookId] = item.storageKey.split('+'); await deleteBookReadRecord(deleteSourceId, deleteBookId); updateRecords((() => { const next = { ...records }; delete next[item.storageKey]; return next; })()); }} className='rounded-2xl border border-gray-200 px-3 py-2 text-xs dark:border-gray-700'>删除</button>
|
<button
|
||||||
</div>
|
onClick={async () => {
|
||||||
|
const [
|
||||||
|
deleteSourceId = item.sourceId,
|
||||||
|
deleteBookId = item.bookId,
|
||||||
|
] = item.storageKey.split('+');
|
||||||
|
await deleteBookReadRecord(deleteSourceId, deleteBookId);
|
||||||
|
updateRecords(
|
||||||
|
(() => {
|
||||||
|
const next = { ...records };
|
||||||
|
delete next[item.storageKey];
|
||||||
|
return next;
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className='cursor-pointer rounded-2xl border border-emerald-100 px-3 py-2 text-xs font-semibold text-slate-600 transition-colors duration-200 hover:bg-emerald-50 hover:text-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:border-emerald-500/10 dark:text-slate-300 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</article>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
{!loading && items.length === 0 ? <div className='text-sm text-gray-500'>暂无阅读历史</div> : null}
|
{!loading && items.length === 0 ? (
|
||||||
|
<div className='rounded-3xl border border-dashed border-emerald-200 bg-white/70 p-8 text-center text-sm text-slate-500 dark:border-emerald-500/20 dark:bg-gray-950/50 dark:text-slate-400'>
|
||||||
|
暂无阅读历史
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{cacheModalOpen && mounted && createPortal(
|
{cacheModalOpen &&
|
||||||
<div className='fixed inset-0 z-50 bg-black/40' onClick={() => setCacheModalOpen(false)}>
|
mounted &&
|
||||||
<div className='absolute right-0 top-0 h-screen w-full max-w-lg overflow-y-auto bg-white shadow-2xl dark:bg-gray-950' onClick={(event) => event.stopPropagation()}>
|
createPortal(
|
||||||
<div className='space-y-4 p-5'>
|
<div
|
||||||
|
className='fixed inset-0 z-50 bg-black/45 backdrop-blur-sm'
|
||||||
|
onClick={() => setCacheModalOpen(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='absolute right-0 top-0 h-screen w-full max-w-lg overflow-y-auto border-l border-emerald-100 bg-[radial-gradient(circle_at_top_right,#dcfce7_0,transparent_20rem),linear-gradient(180deg,#ffffff_0%,#f8fafc_100%)] shadow-2xl dark:border-emerald-500/10 dark:bg-[radial-gradient(circle_at_top_right,rgba(6,95,70,0.24)_0,transparent_20rem),linear-gradient(180deg,#030712_0%,#09090b_100%)]'
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className='space-y-5 p-5'>
|
||||||
|
<div className='rounded-[2rem] border border-emerald-100/80 bg-white/80 p-4 shadow-sm shadow-emerald-950/5 backdrop-blur dark:border-emerald-500/10 dark:bg-gray-950/70'>
|
||||||
<div className='flex items-start justify-between gap-4'>
|
<div className='flex items-start justify-between gap-4'>
|
||||||
<div>
|
<div>
|
||||||
<div className='text-base font-semibold'>缓存管理</div>
|
<div className='flex items-center gap-2 text-base font-semibold text-slate-950 dark:text-white'>
|
||||||
<div className='mt-1 text-xs text-gray-500'>已缓存 {cacheItems.length} 本 · {formatBytes(cacheTotalSize)}</div>
|
<Database className='h-4 w-4 text-emerald-600 dark:text-emerald-300' />
|
||||||
|
缓存管理
|
||||||
|
</div>
|
||||||
|
<div className='mt-1 text-xs text-slate-500 dark:text-slate-400'>
|
||||||
|
已缓存 {cacheItems.length} 本 ·{' '}
|
||||||
|
{formatBytes(cacheTotalSize)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex gap-2'>
|
<div className='flex gap-2'>
|
||||||
<button type='button' onClick={() => void loadCacheItems()} className='inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 dark:border-gray-700' aria-label='刷新缓存' title='刷新缓存'><RefreshCw className='h-4 w-4' /></button>
|
<button
|
||||||
<button type='button' onClick={() => setConfirmAction({ type: 'clear-all' })} className='inline-flex h-9 w-9 items-center justify-center rounded-full border border-red-200 text-red-600 dark:border-red-900/60 dark:text-red-400' aria-label='清空全部缓存' title='清空全部缓存'><Trash2 className='h-4 w-4' /></button>
|
type='button'
|
||||||
<button type='button' onClick={() => setCacheModalOpen(false)} className='inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 dark:border-gray-700' aria-label='关闭' title='关闭'><X className='h-4 w-4' /></button>
|
onClick={() => void loadCacheItems()}
|
||||||
|
className='inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-emerald-200 bg-white/80 text-emerald-700 transition-colors duration-200 hover:bg-emerald-50 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:border-emerald-500/20 dark:bg-gray-950/60 dark:text-emerald-200 dark:hover:bg-emerald-500/10'
|
||||||
|
aria-label='刷新缓存'
|
||||||
|
title='刷新缓存'
|
||||||
|
>
|
||||||
|
<RefreshCw className='h-4 w-4' />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => setConfirmAction({ type: 'clear-all' })}
|
||||||
|
className='inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-red-200 bg-white/80 text-red-600 transition-colors duration-200 hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 dark:border-red-500/20 dark:bg-gray-950/60 dark:text-red-300 dark:hover:bg-red-500/10'
|
||||||
|
aria-label='清空全部缓存'
|
||||||
|
title='清空全部缓存'
|
||||||
|
>
|
||||||
|
<Trash2 className='h-4 w-4' />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => setCacheModalOpen(false)}
|
||||||
|
className='inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-emerald-200 bg-white/80 text-slate-600 transition-colors duration-200 hover:bg-emerald-50 hover:text-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:border-emerald-500/20 dark:bg-gray-950/60 dark:text-slate-300 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
|
||||||
|
aria-label='关闭'
|
||||||
|
title='关闭'
|
||||||
|
>
|
||||||
|
<X className='h-4 w-4' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cacheLoading ? <div className='text-sm text-gray-500'>正在读取缓存…</div> : null}
|
{cacheLoading ? (
|
||||||
{!cacheLoading && cacheItems.length === 0 ? <div className='text-sm text-gray-500'>当前还没有缓存书籍</div> : null}
|
<div className='rounded-3xl border border-emerald-100 bg-white/75 p-5 text-center text-sm text-slate-500 shadow-sm dark:border-emerald-500/10 dark:bg-gray-950/60 dark:text-slate-400'>
|
||||||
|
正在读取缓存…
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!cacheLoading && cacheItems.length === 0 ? (
|
||||||
|
<div className='rounded-3xl border border-dashed border-emerald-200 bg-white/75 p-8 text-center text-sm text-slate-500 shadow-sm dark:border-emerald-500/20 dark:bg-gray-950/60 dark:text-slate-400'>
|
||||||
|
当前还没有缓存书籍
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
{cacheItems.map((item) => (
|
{cacheItems.map((item) => (
|
||||||
<div key={item.key} className='rounded-3xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900'>
|
<div
|
||||||
|
key={item.key}
|
||||||
|
className='rounded-[2rem] border border-emerald-100/80 bg-white/85 p-4 shadow-sm shadow-emerald-950/5 transition-colors duration-200 hover:border-emerald-200 hover:bg-white dark:border-emerald-500/10 dark:bg-gray-950/70 dark:hover:border-emerald-500/30'
|
||||||
|
>
|
||||||
<div className='flex items-start justify-between gap-3'>
|
<div className='flex items-start justify-between gap-3'>
|
||||||
<div className='min-w-0 flex-1'>
|
<div className='min-w-0 flex-1'>
|
||||||
<div className='truncate font-medium'>{item.title}</div>
|
<div className='truncate font-semibold text-slate-950 dark:text-white'>
|
||||||
<div className='mt-1 text-xs text-gray-500'>格式 {item.format.toUpperCase()} · 大小 {formatBytes(item.size)}</div>
|
{item.title}
|
||||||
<div className='mt-1 text-xs text-gray-500'>最近打开 {new Date(item.lastOpenTime).toLocaleString()}</div>
|
</div>
|
||||||
|
<div className='mt-1 text-xs text-slate-500 dark:text-slate-400'>
|
||||||
|
格式 {item.format.toUpperCase()} · 大小{' '}
|
||||||
|
{formatBytes(item.size)}
|
||||||
|
</div>
|
||||||
|
<div className='mt-1 text-xs text-slate-500 dark:text-slate-400'>
|
||||||
|
最近打开{' '}
|
||||||
|
{new Date(item.lastOpenTime).toLocaleString()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
onClick={() => setConfirmAction({ type: 'delete-one', key: item.key, title: item.title })}
|
onClick={() =>
|
||||||
className='inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 dark:border-gray-700'
|
setConfirmAction({
|
||||||
|
type: 'delete-one',
|
||||||
|
key: item.key,
|
||||||
|
title: item.title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className='inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-red-100 bg-white/80 text-red-600 transition-colors duration-200 hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 dark:border-red-500/20 dark:bg-gray-950/60 dark:text-red-300 dark:hover:bg-red-500/10'
|
||||||
aria-label='删除缓存'
|
aria-label='删除缓存'
|
||||||
title='删除缓存'
|
title='删除缓存'
|
||||||
>
|
>
|
||||||
@@ -224,33 +422,55 @@ export default function BookHistoryPage() {
|
|||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{confirmAction &&
|
||||||
{confirmAction && mounted && createPortal(
|
mounted &&
|
||||||
<div className='fixed inset-0 z-[60] flex items-center justify-center bg-black/50 px-4' onClick={() => setConfirmAction(null)}>
|
createPortal(
|
||||||
<div className='w-full max-w-sm rounded-3xl border border-gray-200 bg-white p-5 shadow-2xl dark:border-gray-700 dark:bg-gray-950' onClick={(event) => event.stopPropagation()}>
|
<div
|
||||||
<div className='text-base font-semibold text-gray-900 dark:text-gray-100'>
|
className='fixed inset-0 z-[60] flex items-center justify-center bg-black/55 px-4 backdrop-blur-sm'
|
||||||
{confirmAction.type === 'clear-all' ? '清空全部缓存' : '删除缓存'}
|
onClick={() => setConfirmAction(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='w-full max-w-sm rounded-[2rem] border border-emerald-100 bg-white/95 p-5 shadow-2xl shadow-emerald-950/10 dark:border-emerald-500/10 dark:bg-gray-950/95'
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className='flex items-center gap-2 text-base font-bold text-slate-950 dark:text-white'>
|
||||||
|
<Trash2 className='h-4 w-4 text-red-600 dark:text-red-300' />
|
||||||
|
{confirmAction.type === 'clear-all'
|
||||||
|
? '清空全部缓存'
|
||||||
|
: '删除缓存'}
|
||||||
</div>
|
</div>
|
||||||
<div className='mt-2 text-sm text-gray-500 dark:text-gray-400'>
|
<div className='mt-2 text-sm text-gray-500 dark:text-gray-400'>
|
||||||
{confirmAction.type === 'clear-all'
|
{confirmAction.type === 'clear-all'
|
||||||
? '确认清空当前浏览器中的全部电子书缓存吗?此操作不可撤销。'
|
? '确认清空当前浏览器中的全部电子书缓存吗?此操作不可撤销。'
|
||||||
: `确认删除《${confirmAction.title || '该书'}》的本地缓存吗?`}
|
: `确认删除《${
|
||||||
|
confirmAction.title || '该书'
|
||||||
|
}》的本地缓存吗?`}
|
||||||
</div>
|
</div>
|
||||||
<div className='mt-5 flex justify-end gap-3'>
|
<div className='mt-5 flex justify-end gap-3'>
|
||||||
<button type='button' onClick={() => setConfirmAction(null)} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>取消</button>
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => setConfirmAction(null)}
|
||||||
|
className='cursor-pointer rounded-2xl border border-emerald-200 px-4 py-2 text-sm font-semibold text-slate-600 transition-colors duration-200 hover:bg-emerald-50 hover:text-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:border-emerald-500/20 dark:text-slate-300 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (confirmAction.type === 'clear-all') {
|
if (confirmAction.type === 'clear-all') {
|
||||||
await Promise.all(cacheItems.map((item) => deleteCachedBookFile(item.key)));
|
await Promise.all(
|
||||||
|
cacheItems.map((item) => deleteCachedBookFile(item.key))
|
||||||
|
);
|
||||||
setCacheItems([]);
|
setCacheItems([]);
|
||||||
} else if (confirmAction.key) {
|
} else if (confirmAction.key) {
|
||||||
await deleteCachedBookFile(confirmAction.key);
|
await deleteCachedBookFile(confirmAction.key);
|
||||||
setCacheItems((prev) => prev.filter((item) => item.key !== confirmAction.key));
|
setCacheItems((prev) =>
|
||||||
|
prev.filter((item) => item.key !== confirmAction.key)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setConfirmAction(null);
|
setConfirmAction(null);
|
||||||
}}
|
}}
|
||||||
className='rounded-2xl bg-red-600 px-4 py-2 text-sm text-white'
|
className='cursor-pointer rounded-2xl bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-red-600/20 transition-colors duration-200 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500'
|
||||||
>
|
>
|
||||||
确认
|
确认
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+182
-28
@@ -1,7 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BookOpen,
|
||||||
|
CheckCircle2,
|
||||||
|
Compass,
|
||||||
|
Library,
|
||||||
|
Search,
|
||||||
|
Sparkles,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { BookSource } from '@/lib/book.types';
|
import { BookSource } from '@/lib/book.types';
|
||||||
|
|
||||||
@@ -9,15 +18,18 @@ function BooksHomeSkeleton() {
|
|||||||
return (
|
return (
|
||||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3 animate-pulse'>
|
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3 animate-pulse'>
|
||||||
{Array.from({ length: 6 }).map((_, index) => (
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
<div key={index} className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
<div
|
||||||
<div className='h-5 w-32 rounded bg-gray-200 dark:bg-gray-800' />
|
key={index}
|
||||||
|
className='rounded-[2rem] border border-emerald-100/80 bg-white/80 p-5 shadow-sm dark:border-emerald-500/10 dark:bg-gray-950/70'
|
||||||
|
>
|
||||||
|
<div className='h-5 w-32 rounded bg-emerald-100 dark:bg-gray-800' />
|
||||||
<div className='mt-3 flex gap-2'>
|
<div className='mt-3 flex gap-2'>
|
||||||
<div className='h-6 w-16 rounded-full bg-gray-200 dark:bg-gray-800' />
|
<div className='h-6 w-16 rounded-full bg-emerald-100 dark:bg-gray-800' />
|
||||||
<div className='h-6 w-16 rounded-full bg-gray-200 dark:bg-gray-800' />
|
<div className='h-6 w-16 rounded-full bg-emerald-100 dark:bg-gray-800' />
|
||||||
</div>
|
</div>
|
||||||
<div className='mt-4 flex gap-2'>
|
<div className='mt-5 flex gap-2'>
|
||||||
<div className='h-10 w-24 rounded-2xl bg-gray-200 dark:bg-gray-800' />
|
<div className='h-10 w-24 rounded-2xl bg-emerald-100 dark:bg-gray-800' />
|
||||||
<div className='h-10 w-24 rounded-2xl bg-gray-200 dark:bg-gray-800' />
|
<div className='h-10 w-24 rounded-2xl bg-emerald-100 dark:bg-gray-800' />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -25,13 +37,39 @@ function BooksHomeSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CapabilityPill({
|
||||||
|
enabled,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
enabled?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const Icon = enabled ? CheckCircle2 : XCircle;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${
|
||||||
|
enabled
|
||||||
|
? 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/20'
|
||||||
|
: 'bg-gray-100 text-gray-500 ring-1 ring-gray-200 dark:bg-gray-900 dark:text-gray-400 dark:ring-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className='h-3.5 w-3.5' />
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function BooksHomePage() {
|
export default function BooksHomePage() {
|
||||||
const [sources, setSources] = useState<BookSource[]>([]);
|
const [sources, setSources] = useState<BookSource[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined' && !(window as Window & { RUNTIME_CONFIG?: { BOOKS_ENABLED?: boolean } }).RUNTIME_CONFIG?.BOOKS_ENABLED) {
|
if (
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
!(window as Window & { RUNTIME_CONFIG?: { BOOKS_ENABLED?: boolean } })
|
||||||
|
.RUNTIME_CONFIG?.BOOKS_ENABLED
|
||||||
|
) {
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -42,31 +80,147 @@ export default function BooksHomePage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const catalogCount = sources.filter(
|
||||||
|
(source) => source.capabilities?.catalogSupported
|
||||||
|
).length;
|
||||||
|
const searchCount = sources.filter(
|
||||||
|
(source) => source.capabilities?.searchSupported
|
||||||
|
).length;
|
||||||
|
return [
|
||||||
|
{ label: '可用书源', value: sources.length },
|
||||||
|
{ label: '支持目录', value: catalogCount },
|
||||||
|
{ label: '支持搜索', value: searchCount },
|
||||||
|
];
|
||||||
|
}, [sources]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-7'>
|
||||||
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
<section className='relative overflow-hidden rounded-[2.25rem] border border-emerald-100 bg-gradient-to-br from-emerald-50 via-white to-amber-50 p-6 shadow-sm dark:border-emerald-500/10 dark:from-emerald-950/30 dark:via-gray-950 dark:to-amber-950/20 sm:p-8'>
|
||||||
<h1 className='text-lg font-semibold'>电子书源</h1>
|
<div className='absolute -right-16 -top-20 h-56 w-56 rounded-full bg-emerald-300/20 blur-3xl dark:bg-emerald-500/10' />
|
||||||
</section>
|
<div className='absolute -bottom-24 left-1/3 h-56 w-56 rounded-full bg-amber-300/20 blur-3xl dark:bg-amber-500/10' />
|
||||||
|
<div className='relative grid gap-8 lg:grid-cols-[1.25fr_0.75fr] lg:items-end'>
|
||||||
{loading ? <BooksHomeSkeleton /> : null}
|
<div>
|
||||||
{error ? <div className='text-sm text-red-500'>{error}</div> : null}
|
<div className='inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-white/70 px-3 py-1 text-xs font-medium text-emerald-700 shadow-sm backdrop-blur dark:border-emerald-500/20 dark:bg-gray-950/50 dark:text-emerald-200'>
|
||||||
|
<Sparkles className='h-3.5 w-3.5' />
|
||||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
MoonTVPlus Reading Library
|
||||||
{sources.map((source) => (
|
|
||||||
<div key={source.id} className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
|
||||||
<div className='text-base font-semibold'>{source.name}</div>
|
|
||||||
<div className='mt-1 text-xs text-gray-400'>{source.type === 'legado' ? 'Legado' : 'OPDS'}</div>
|
|
||||||
<div className='mt-2 flex flex-wrap gap-2 text-xs'>
|
|
||||||
<span className={`rounded-full px-2 py-1 ${source.capabilities?.catalogSupported ? 'bg-sky-100 text-sky-700 dark:bg-sky-950/50 dark:text-sky-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-900 dark:text-gray-400'}`}>分类{source.capabilities?.catalogSupported ? '可用' : '不可用'}</span>
|
|
||||||
<span className={`rounded-full px-2 py-1 ${source.capabilities?.searchSupported ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-900 dark:text-gray-400'}`}>搜索{source.capabilities?.searchSupported ? '可用' : '不可用'}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className='mt-4 flex flex-wrap gap-2'>
|
<h1 className='mt-5 max-w-3xl text-4xl font-black tracking-[-0.06em] text-emerald-950 dark:text-emerald-50 sm:text-6xl lg:text-7xl'>
|
||||||
{source.capabilities?.catalogSupported && <Link href={`/books/catalog?sourceId=${encodeURIComponent(source.id)}`} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'>浏览目录</Link>}
|
电子书馆
|
||||||
{source.capabilities?.searchSupported && <Link href={`/books/search?sourceId=${encodeURIComponent(source.id)}`} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>搜索书籍</Link>}
|
</h1>
|
||||||
|
<div className='mt-6 flex flex-wrap gap-3'>
|
||||||
|
<Link
|
||||||
|
href='/books/search'
|
||||||
|
className='inline-flex cursor-pointer items-center gap-2 rounded-2xl bg-emerald-600 px-5 py-3 text-sm font-semibold text-white shadow-lg shadow-emerald-600/20 transition-colors duration-200 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-gray-950'
|
||||||
|
>
|
||||||
|
<Search className='h-4 w-4' />
|
||||||
|
搜索书籍
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href='/books/shelf'
|
||||||
|
className='inline-flex cursor-pointer items-center gap-2 rounded-2xl border border-emerald-200 bg-white/70 px-5 py-3 text-sm font-semibold text-emerald-900 transition-colors duration-200 hover:bg-emerald-50 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:border-emerald-500/20 dark:bg-gray-950/50 dark:text-emerald-100 dark:hover:bg-emerald-950/30 dark:focus:ring-offset-gray-950'
|
||||||
|
>
|
||||||
|
<BookOpen className='h-4 w-4' />
|
||||||
|
我的书架
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='grid grid-cols-3 gap-3'>
|
||||||
|
{stats.map((stat) => (
|
||||||
|
<div
|
||||||
|
key={stat.label}
|
||||||
|
className='rounded-3xl border border-white/80 bg-white/75 p-4 text-center shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5'
|
||||||
|
>
|
||||||
|
<div className='text-2xl font-black text-emerald-700 dark:text-emerald-200'>
|
||||||
|
{stat.value}
|
||||||
|
</div>
|
||||||
|
<div className='mt-1 text-xs text-slate-500 dark:text-slate-400'>
|
||||||
|
{stat.label}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className='flex items-end justify-between gap-3'>
|
||||||
|
<div>
|
||||||
|
<h2 className='text-xl font-bold tracking-tight text-slate-950 dark:text-white'>
|
||||||
|
书源入口
|
||||||
|
</h2>
|
||||||
|
<p className='mt-1 text-sm text-slate-500 dark:text-slate-400'>
|
||||||
|
选择一个书源开始浏览,或直接进入搜索。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Library className='hidden h-6 w-6 text-emerald-500 sm:block' />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? <BooksHomeSkeleton /> : null}
|
||||||
|
{error ? (
|
||||||
|
<div className='rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-500/20 dark:bg-red-950/20 dark:text-red-300'>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
||||||
|
{sources.map((source) => (
|
||||||
|
<article
|
||||||
|
key={source.id}
|
||||||
|
className='group relative overflow-hidden rounded-[2rem] border border-emerald-100/80 bg-white/85 p-5 shadow-sm transition-colors duration-200 hover:border-emerald-200 hover:bg-white dark:border-emerald-500/10 dark:bg-gray-950/70 dark:hover:border-emerald-500/30'
|
||||||
|
>
|
||||||
|
<div className='absolute -right-10 -top-12 h-28 w-28 rounded-full bg-emerald-200/40 blur-2xl transition-opacity duration-200 group-hover:opacity-80 dark:bg-emerald-500/10' />
|
||||||
|
<div className='relative flex items-start justify-between gap-4'>
|
||||||
|
<div className='min-w-0'>
|
||||||
|
<div className='truncate text-base font-bold text-slate-950 dark:text-white'>
|
||||||
|
{source.name}
|
||||||
|
</div>
|
||||||
|
<div className='mt-1 text-xs font-medium uppercase tracking-[0.2em] text-emerald-500'>
|
||||||
|
{source.type === 'legado' ? 'Legado' : 'OPDS'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-emerald-50 text-emerald-600 ring-1 ring-emerald-100 dark:bg-emerald-500/10 dark:text-emerald-200 dark:ring-emerald-500/20'>
|
||||||
|
<Compass className='h-5 w-5' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='relative mt-4 flex flex-wrap gap-2'>
|
||||||
|
<CapabilityPill enabled={source.capabilities?.catalogSupported}>
|
||||||
|
分类{source.capabilities?.catalogSupported ? '可用' : '不可用'}
|
||||||
|
</CapabilityPill>
|
||||||
|
<CapabilityPill enabled={source.capabilities?.searchSupported}>
|
||||||
|
搜索{source.capabilities?.searchSupported ? '可用' : '不可用'}
|
||||||
|
</CapabilityPill>
|
||||||
|
</div>
|
||||||
|
<div className='relative mt-5 flex flex-wrap gap-2'>
|
||||||
|
{source.capabilities?.catalogSupported && (
|
||||||
|
<Link
|
||||||
|
href={`/books/catalog?sourceId=${encodeURIComponent(
|
||||||
|
source.id
|
||||||
|
)}`}
|
||||||
|
className='inline-flex cursor-pointer items-center gap-2 rounded-2xl bg-emerald-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors duration-200 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-gray-950'
|
||||||
|
>
|
||||||
|
浏览目录
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{source.capabilities?.searchSupported && (
|
||||||
|
<Link
|
||||||
|
href={`/books/search?sourceId=${encodeURIComponent(
|
||||||
|
source.id
|
||||||
|
)}`}
|
||||||
|
className='inline-flex cursor-pointer items-center gap-2 rounded-2xl border border-emerald-200 px-4 py-2.5 text-sm font-semibold text-emerald-800 transition-colors duration-200 hover:bg-emerald-50 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:border-emerald-500/20 dark:text-emerald-100 dark:hover:bg-emerald-950/30 dark:focus:ring-offset-gray-950'
|
||||||
|
>
|
||||||
|
搜索书籍
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && !error && sources.length === 0 ? (
|
||||||
|
<div className='rounded-3xl border border-dashed border-emerald-200 bg-white/70 p-8 text-center text-sm text-slate-500 dark:border-emerald-500/20 dark:bg-gray-950/50 dark:text-slate-400'>
|
||||||
|
暂无可用书源
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+1496
-322
@@ -1,12 +1,36 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { BookOpen, ChevronRight, ChevronUp, Gauge, Headphones, Loader2, Moon, Pause, Play, SkipBack, SkipForward, Square, Sun, Volume2, Waves, X } from 'lucide-react';
|
import {
|
||||||
|
BookOpen,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronUp,
|
||||||
|
Gauge,
|
||||||
|
Headphones,
|
||||||
|
Loader2,
|
||||||
|
Moon,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
SkipBack,
|
||||||
|
SkipForward,
|
||||||
|
Square,
|
||||||
|
Sun,
|
||||||
|
Volume2,
|
||||||
|
Waves,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { saveBookReadRecord } from '@/lib/book.db.client';
|
import { saveBookReadRecord } from '@/lib/book.db.client';
|
||||||
import { BookChapter, BookChapterContent, BookReadManifest, BookReadRecord, BookTtsProgress, BookTtsVoice } from '@/lib/book.types';
|
import {
|
||||||
|
BookChapter,
|
||||||
|
BookChapterContent,
|
||||||
|
BookReadManifest,
|
||||||
|
BookReadRecord,
|
||||||
|
BookTtsProgress,
|
||||||
|
BookTtsVoice,
|
||||||
|
} from '@/lib/book.types';
|
||||||
import {
|
import {
|
||||||
buildBookCacheKey,
|
buildBookCacheKey,
|
||||||
enforceBookCacheLimit,
|
enforceBookCacheLimit,
|
||||||
@@ -14,7 +38,10 @@ import {
|
|||||||
putCachedBookFile,
|
putCachedBookFile,
|
||||||
touchCachedBookFile,
|
touchCachedBookFile,
|
||||||
} from '@/lib/book-cache.client';
|
} from '@/lib/book-cache.client';
|
||||||
import { cacheBookDetail, getBookRouteCache } from '@/lib/book-route-cache.client';
|
import {
|
||||||
|
cacheBookDetail,
|
||||||
|
getBookRouteCache,
|
||||||
|
} from '@/lib/book-route-cache.client';
|
||||||
import {
|
import {
|
||||||
buildBookTtsCacheKey,
|
buildBookTtsCacheKey,
|
||||||
enforceBookTtsCacheLimit,
|
enforceBookTtsCacheLimit,
|
||||||
@@ -22,7 +49,10 @@ import {
|
|||||||
putCachedBookTtsChunk,
|
putCachedBookTtsChunk,
|
||||||
touchCachedBookTtsChunk,
|
touchCachedBookTtsChunk,
|
||||||
} from '@/lib/book-tts-cache.client';
|
} from '@/lib/book-tts-cache.client';
|
||||||
import { getBookTtsProgress, saveBookTtsProgress } from '@/lib/book-tts-progress.client';
|
import {
|
||||||
|
getBookTtsProgress,
|
||||||
|
saveBookTtsProgress,
|
||||||
|
} from '@/lib/book-tts-progress.client';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -54,7 +84,10 @@ interface EpubThemes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface EpubBookInstance {
|
interface EpubBookInstance {
|
||||||
renderTo: (element: HTMLElement, options: Record<string, string | boolean>) => EpubRendition;
|
renderTo: (
|
||||||
|
element: HTMLElement,
|
||||||
|
options: Record<string, string | boolean>
|
||||||
|
) => EpubRendition;
|
||||||
locations?: {
|
locations?: {
|
||||||
percentageFromCfi?: (cfi: string) => number;
|
percentageFromCfi?: (cfi: string) => number;
|
||||||
generate?: (chars?: number) => Promise<void>;
|
generate?: (chars?: number) => Promise<void>;
|
||||||
@@ -78,7 +111,12 @@ interface EpubRendition {
|
|||||||
|
|
||||||
type ReaderTheme = 'light' | 'sepia' | 'dark';
|
type ReaderTheme = 'light' | 'sepia' | 'dark';
|
||||||
type ReaderMode = 'paginated' | 'scrolled';
|
type ReaderMode = 'paginated' | 'scrolled';
|
||||||
type FileLoadState = 'preparing' | 'checking-cache' | 'downloading' | 'opening' | 'ready';
|
type FileLoadState =
|
||||||
|
| 'preparing'
|
||||||
|
| 'checking-cache'
|
||||||
|
| 'downloading'
|
||||||
|
| 'opening'
|
||||||
|
| 'ready';
|
||||||
|
|
||||||
interface ReaderSettings {
|
interface ReaderSettings {
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
@@ -141,7 +179,10 @@ const TTS_RATE_STEPS = [-20, -10, 0, 10, 20, 35];
|
|||||||
const TTS_PITCH_STEPS = [-10, 0, 10, 20];
|
const TTS_PITCH_STEPS = [-10, 0, 10, 20];
|
||||||
const TTS_VOLUME_STEPS = [-10, 0, 10, 20];
|
const TTS_VOLUME_STEPS = [-10, 0, 10, 20];
|
||||||
|
|
||||||
const THEME_STYLES: Record<ReaderTheme, { bodyBg: string; bodyColor: string; panelBg: string }> = {
|
const THEME_STYLES: Record<
|
||||||
|
ReaderTheme,
|
||||||
|
{ bodyBg: string; bodyColor: string; panelBg: string }
|
||||||
|
> = {
|
||||||
light: { bodyBg: '#ffffff', bodyColor: '#111827', panelBg: '#ffffff' },
|
light: { bodyBg: '#ffffff', bodyColor: '#111827', panelBg: '#ffffff' },
|
||||||
sepia: { bodyBg: '#f6efe3', bodyColor: '#5b4636', panelBg: '#f7f1e7' },
|
sepia: { bodyBg: '#f6efe3', bodyColor: '#5b4636', panelBg: '#f7f1e7' },
|
||||||
dark: { bodyBg: '#111827', bodyColor: '#e5e7eb', panelBg: '#030712' },
|
dark: { bodyBg: '#111827', bodyColor: '#e5e7eb', panelBg: '#030712' },
|
||||||
@@ -152,7 +193,10 @@ function loadTtsSettings(): TtsSettings {
|
|||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(TTS_SETTINGS_STORAGE_KEY);
|
const raw = localStorage.getItem(TTS_SETTINGS_STORAGE_KEY);
|
||||||
if (!raw) return DEFAULT_TTS_SETTINGS;
|
if (!raw) return DEFAULT_TTS_SETTINGS;
|
||||||
return { ...DEFAULT_TTS_SETTINGS, ...(JSON.parse(raw) as Partial<TtsSettings>) };
|
return {
|
||||||
|
...DEFAULT_TTS_SETTINGS,
|
||||||
|
...(JSON.parse(raw) as Partial<TtsSettings>),
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return DEFAULT_TTS_SETTINGS;
|
return DEFAULT_TTS_SETTINGS;
|
||||||
}
|
}
|
||||||
@@ -177,10 +221,16 @@ function loadCachedTtsVoices(): TtsVoicesCache | null {
|
|||||||
|
|
||||||
function saveCachedTtsVoices(cache: Omit<TtsVoicesCache, 'savedAt'>) {
|
function saveCachedTtsVoices(cache: Omit<TtsVoicesCache, 'savedAt'>) {
|
||||||
if (typeof window === 'undefined' || cache.voices.length === 0) return;
|
if (typeof window === 'undefined' || cache.voices.length === 0) return;
|
||||||
localStorage.setItem(TTS_VOICES_STORAGE_KEY, JSON.stringify({ ...cache, savedAt: Date.now() }));
|
localStorage.setItem(
|
||||||
|
TTS_VOICES_STORAGE_KEY,
|
||||||
|
JSON.stringify({ ...cache, savedAt: Date.now() })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTtsDefaults(settings: TtsSettings, defaults?: Partial<TtsSettings>): TtsSettings {
|
function applyTtsDefaults(
|
||||||
|
settings: TtsSettings,
|
||||||
|
defaults?: Partial<TtsSettings>
|
||||||
|
): TtsSettings {
|
||||||
return {
|
return {
|
||||||
...settings,
|
...settings,
|
||||||
voice: settings.voice || defaults?.voice || '',
|
voice: settings.voice || defaults?.voice || '',
|
||||||
@@ -202,14 +252,20 @@ function formatSignedValue(value: number, suffix: '%' | 'Hz') {
|
|||||||
|
|
||||||
function loadScriptOnce(selector: string, src: string, errorMessage: string) {
|
function loadScriptOnce(selector: string, src: string, errorMessage: string) {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const existing = document.querySelector(selector) as HTMLScriptElement | null;
|
const existing = document.querySelector(
|
||||||
|
selector
|
||||||
|
) as HTMLScriptElement | null;
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.dataset.loaded === 'true') {
|
if (existing.dataset.loaded === 'true') {
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
existing.addEventListener('load', () => resolve(), { once: true });
|
existing.addEventListener('load', () => resolve(), { once: true });
|
||||||
existing.addEventListener('error', () => reject(new Error(errorMessage)), { once: true });
|
existing.addEventListener(
|
||||||
|
'error',
|
||||||
|
() => reject(new Error(errorMessage)),
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,10 +286,18 @@ function loadScriptOnce(selector: string, src: string, errorMessage: string) {
|
|||||||
async function loadEpubScript() {
|
async function loadEpubScript() {
|
||||||
if (window.ePub && window.JSZip) return;
|
if (window.ePub && window.JSZip) return;
|
||||||
if (!window.JSZip) {
|
if (!window.JSZip) {
|
||||||
await loadScriptOnce('script[data-jszip]', 'https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js', 'JSZip 加载失败');
|
await loadScriptOnce(
|
||||||
|
'script[data-jszip]',
|
||||||
|
'https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js',
|
||||||
|
'JSZip 加载失败'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!window.ePub) {
|
if (!window.ePub) {
|
||||||
await loadScriptOnce('script[data-epubjs]', 'https://cdn.jsdelivr.net/npm/epubjs/dist/epub.min.js', 'epub.js 加载失败');
|
await loadScriptOnce(
|
||||||
|
'script[data-epubjs]',
|
||||||
|
'https://cdn.jsdelivr.net/npm/epubjs/dist/epub.min.js',
|
||||||
|
'epub.js 加载失败'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,14 +306,20 @@ function loadReaderSettings(): ReaderSettings {
|
|||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(SETTINGS_STORAGE_KEY);
|
const raw = localStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||||
if (!raw) return DEFAULT_SETTINGS;
|
if (!raw) return DEFAULT_SETTINGS;
|
||||||
return { ...DEFAULT_SETTINGS, ...(JSON.parse(raw) as Partial<ReaderSettings>) };
|
return {
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
...(JSON.parse(raw) as Partial<ReaderSettings>),
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return DEFAULT_SETTINGS;
|
return DEFAULT_SETTINGS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildScrolledPositionKey(
|
||||||
function buildScrolledPositionKey(sourceId: string, bookId: string, href?: string) {
|
sourceId: string,
|
||||||
|
bookId: string,
|
||||||
|
href?: string
|
||||||
|
) {
|
||||||
return `${sourceId}::${bookId}::${normalizeHrefForMatch(href)}`;
|
return `${sourceId}::${bookId}::${normalizeHrefForMatch(href)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,20 +327,30 @@ function loadScrolledPositions(): Record<string, ScrolledReadingPosition> {
|
|||||||
if (typeof window === 'undefined') return {};
|
if (typeof window === 'undefined') return {};
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(SCROLLED_POSITION_STORAGE_KEY);
|
const raw = localStorage.getItem(SCROLLED_POSITION_STORAGE_KEY);
|
||||||
return raw ? (JSON.parse(raw) as Record<string, ScrolledReadingPosition>) : {};
|
return raw
|
||||||
|
? (JSON.parse(raw) as Record<string, ScrolledReadingPosition>)
|
||||||
|
: {};
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveScrolledPosition(sourceId: string, bookId: string, position: ScrolledReadingPosition) {
|
function saveScrolledPosition(
|
||||||
|
sourceId: string,
|
||||||
|
bookId: string,
|
||||||
|
position: ScrolledReadingPosition
|
||||||
|
) {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
const all = loadScrolledPositions();
|
const all = loadScrolledPositions();
|
||||||
all[buildScrolledPositionKey(sourceId, bookId, position.href)] = position;
|
all[buildScrolledPositionKey(sourceId, bookId, position.href)] = position;
|
||||||
localStorage.setItem(SCROLLED_POSITION_STORAGE_KEY, JSON.stringify(all));
|
localStorage.setItem(SCROLLED_POSITION_STORAGE_KEY, JSON.stringify(all));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getScrolledPosition(sourceId: string, bookId: string, href?: string): ScrolledReadingPosition | null {
|
function getScrolledPosition(
|
||||||
|
sourceId: string,
|
||||||
|
bookId: string,
|
||||||
|
href?: string
|
||||||
|
): ScrolledReadingPosition | null {
|
||||||
const all = loadScrolledPositions();
|
const all = loadScrolledPositions();
|
||||||
return all[buildScrolledPositionKey(sourceId, bookId, href)] || null;
|
return all[buildScrolledPositionKey(sourceId, bookId, href)] || null;
|
||||||
}
|
}
|
||||||
@@ -298,15 +378,25 @@ function getIframeScrollMetrics(viewer: HTMLDivElement | null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = doc ? (doc.scrollingElement || doc.documentElement || doc.body) : null;
|
const root = doc
|
||||||
const rootOverflow = root ? Math.max((root.scrollHeight || 0) - (root.clientHeight || win?.innerHeight || 0), 0) : 0;
|
? doc.scrollingElement || doc.documentElement || doc.body
|
||||||
|
: null;
|
||||||
|
const rootOverflow = root
|
||||||
|
? Math.max(
|
||||||
|
(root.scrollHeight || 0) - (root.clientHeight || win?.innerHeight || 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
if (root && rootOverflow >= bestOverflow) {
|
if (root && rootOverflow >= bestOverflow) {
|
||||||
return {
|
return {
|
||||||
iframe,
|
iframe,
|
||||||
root,
|
root,
|
||||||
scrollTop: Math.max(0, win?.scrollY || root.scrollTop || 0),
|
scrollTop: Math.max(0, win?.scrollY || root.scrollTop || 0),
|
||||||
scrollHeight: Math.max(root.scrollHeight || 0, doc?.body?.scrollHeight || 0),
|
scrollHeight: Math.max(
|
||||||
|
root.scrollHeight || 0,
|
||||||
|
doc?.body?.scrollHeight || 0
|
||||||
|
),
|
||||||
clientHeight: root.clientHeight || win?.innerHeight || 0,
|
clientHeight: root.clientHeight || win?.innerHeight || 0,
|
||||||
setScrollTop: (value: number) => {
|
setScrollTop: (value: number) => {
|
||||||
if (typeof root.scrollTo === 'function') {
|
if (typeof root.scrollTo === 'function') {
|
||||||
@@ -315,8 +405,10 @@ function getIframeScrollMetrics(viewer: HTMLDivElement | null) {
|
|||||||
root.scrollTop = value;
|
root.scrollTop = value;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
addScrollListener: (listener: () => void) => win?.addEventListener('scroll', listener, { passive: true }),
|
addScrollListener: (listener: () => void) =>
|
||||||
removeScrollListener: (listener: () => void) => win?.removeEventListener('scroll', listener),
|
win?.addEventListener('scroll', listener, { passive: true }),
|
||||||
|
removeScrollListener: (listener: () => void) =>
|
||||||
|
win?.removeEventListener('scroll', listener),
|
||||||
interactionTarget: root,
|
interactionTarget: root,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -332,8 +424,10 @@ function getIframeScrollMetrics(viewer: HTMLDivElement | null) {
|
|||||||
setScrollTop: (value: number) => {
|
setScrollTop: (value: number) => {
|
||||||
scrollElement.scrollTo({ top: value, behavior: 'auto' });
|
scrollElement.scrollTo({ top: value, behavior: 'auto' });
|
||||||
},
|
},
|
||||||
addScrollListener: (listener: () => void) => scrollElement.addEventListener('scroll', listener, { passive: true }),
|
addScrollListener: (listener: () => void) =>
|
||||||
removeScrollListener: (listener: () => void) => scrollElement.removeEventListener('scroll', listener),
|
scrollElement.addEventListener('scroll', listener, { passive: true }),
|
||||||
|
removeScrollListener: (listener: () => void) =>
|
||||||
|
scrollElement.removeEventListener('scroll', listener),
|
||||||
interactionTarget: scrollElement,
|
interactionTarget: scrollElement,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -341,7 +435,11 @@ function getIframeScrollMetrics(viewer: HTMLDivElement | null) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeScrolledTargetScrollTop(position: ScrolledReadingPosition, currentScrollHeight: number, currentClientHeight: number) {
|
function computeScrolledTargetScrollTop(
|
||||||
|
position: ScrolledReadingPosition,
|
||||||
|
currentScrollHeight: number,
|
||||||
|
currentClientHeight: number
|
||||||
|
) {
|
||||||
const maxSaved = Math.max(0, position.scrollHeight - position.clientHeight);
|
const maxSaved = Math.max(0, position.scrollHeight - position.clientHeight);
|
||||||
const maxCurrent = Math.max(0, currentScrollHeight - currentClientHeight);
|
const maxCurrent = Math.max(0, currentScrollHeight - currentClientHeight);
|
||||||
if (maxCurrent <= 0) return 0;
|
if (maxCurrent <= 0) return 0;
|
||||||
@@ -350,9 +448,15 @@ function computeScrolledTargetScrollTop(position: ScrolledReadingPosition, curre
|
|||||||
return ratio * maxCurrent;
|
return ratio * maxCurrent;
|
||||||
}
|
}
|
||||||
|
|
||||||
function encodeChapterScrollLocator(href: string, scrollTop: number, scrollHeight: number, clientHeight: number) {
|
function encodeChapterScrollLocator(
|
||||||
|
href: string,
|
||||||
|
scrollTop: number,
|
||||||
|
scrollHeight: number,
|
||||||
|
clientHeight: number
|
||||||
|
) {
|
||||||
const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
|
const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
|
||||||
const ratio = maxScrollTop > 0 ? Math.max(0, Math.min(1, scrollTop / maxScrollTop)) : 0;
|
const ratio =
|
||||||
|
maxScrollTop > 0 ? Math.max(0, Math.min(1, scrollTop / maxScrollTop)) : 0;
|
||||||
return `${href}#scroll=${ratio.toFixed(6)}`;
|
return `${href}#scroll=${ratio.toFixed(6)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,7 +474,12 @@ function flattenToc(items: TocItem[]): TocItem[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function tocItemIsActive(item: TocItem, currentHref: string): boolean {
|
function tocItemIsActive(item: TocItem, currentHref: string): boolean {
|
||||||
return isSameTocTarget(currentHref, item.href) || (item.subitems || []).some((subitem) => tocItemIsActive(subitem, currentHref));
|
return (
|
||||||
|
isSameTocTarget(currentHref, item.href) ||
|
||||||
|
(item.subitems || []).some((subitem) =>
|
||||||
|
tocItemIsActive(subitem, currentHref)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function findTocLabelByHref(items: TocItem[], currentHref: string): string {
|
function findTocLabelByHref(items: TocItem[], currentHref: string): string {
|
||||||
@@ -382,7 +491,11 @@ function findTocLabelByHref(items: TocItem[], currentHref: string): string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJsonWithRetry<T>(url: string, init?: RequestInit, retries = 2): Promise<T> {
|
async function fetchJsonWithRetry<T>(
|
||||||
|
url: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
retries = 2
|
||||||
|
): Promise<T> {
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
@@ -393,7 +506,9 @@ async function fetchJsonWithRetry<T>(url: string, init?: RequestInit, retries =
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
if (attempt < retries) {
|
if (attempt < retries) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300 * (attempt + 1)));
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, 300 * (attempt + 1))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -440,7 +555,9 @@ async function downloadBookWithProgress(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Blob(chunks, { type: response.headers.get('content-type') || 'application/epub+zip' });
|
return new Blob(chunks, {
|
||||||
|
type: response.headers.get('content-type') || 'application/epub+zip',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
|
function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
|
||||||
@@ -457,12 +574,16 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [ttsVoices, setTtsVoices] = useState<BookTtsVoice[]>([]);
|
const [ttsVoices, setTtsVoices] = useState<BookTtsVoice[]>([]);
|
||||||
const [ttsAvailable, setTtsAvailable] = useState(false);
|
const [ttsAvailable, setTtsAvailable] = useState(false);
|
||||||
const [ttsSettings, setTtsSettings] = useState<TtsSettings>(() => loadTtsSettings());
|
const [ttsSettings, setTtsSettings] = useState<TtsSettings>(() =>
|
||||||
|
loadTtsSettings()
|
||||||
|
);
|
||||||
const [ttsStatus, setTtsStatus] = useState<TtsStatus>('idle');
|
const [ttsStatus, setTtsStatus] = useState<TtsStatus>('idle');
|
||||||
const [ttsError, setTtsError] = useState('');
|
const [ttsError, setTtsError] = useState('');
|
||||||
const [ttsChunks, setTtsChunks] = useState<TtsChunk[]>([]);
|
const [ttsChunks, setTtsChunks] = useState<TtsChunk[]>([]);
|
||||||
const [ttsCurrentChunkIndex, setTtsCurrentChunkIndex] = useState(0);
|
const [ttsCurrentChunkIndex, setTtsCurrentChunkIndex] = useState(0);
|
||||||
const [ttsLoadingChunkIndex, setTtsLoadingChunkIndex] = useState<number | null>(null);
|
const [ttsLoadingChunkIndex, setTtsLoadingChunkIndex] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
const [ttsBarVisible, setTtsBarVisible] = useState(false);
|
const [ttsBarVisible, setTtsBarVisible] = useState(false);
|
||||||
const [ttsPanelOpen, setTtsPanelOpen] = useState(false);
|
const [ttsPanelOpen, setTtsPanelOpen] = useState(false);
|
||||||
const [ttsCurrentTime, setTtsCurrentTime] = useState(0);
|
const [ttsCurrentTime, setTtsCurrentTime] = useState(0);
|
||||||
@@ -475,7 +596,9 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
|
|||||||
const pendingChapterRestoreRatioRef = useRef<number | null>(null);
|
const pendingChapterRestoreRatioRef = useRef<number | null>(null);
|
||||||
const currentIndexRef = useRef(0);
|
const currentIndexRef = useRef(0);
|
||||||
const lastChapterSavedAtRef = useRef(0);
|
const lastChapterSavedAtRef = useRef(0);
|
||||||
const lastChapterSavedLocatorValueRef = useRef(manifest.lastRecord?.locator?.value || '');
|
const lastChapterSavedLocatorValueRef = useRef(
|
||||||
|
manifest.lastRecord?.locator?.value || ''
|
||||||
|
);
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const ttsChunksRef = useRef<TtsChunk[]>([]);
|
const ttsChunksRef = useRef<TtsChunk[]>([]);
|
||||||
const ttsCurrentChunkIndexRef = useRef(0);
|
const ttsCurrentChunkIndexRef = useRef(0);
|
||||||
@@ -484,27 +607,44 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
|
|||||||
const ttsSeekingRef = useRef(false);
|
const ttsSeekingRef = useRef(false);
|
||||||
const ttsResumeTimeRef = useRef(0);
|
const ttsResumeTimeRef = useRef(0);
|
||||||
const currentChapterHref = chapters[currentIndex]?.href || '';
|
const currentChapterHref = chapters[currentIndex]?.href || '';
|
||||||
const currentChapterTitle = chapters[currentIndex]?.title || chapter?.title || '';
|
const currentChapterTitle =
|
||||||
|
chapters[currentIndex]?.title || chapter?.title || '';
|
||||||
|
|
||||||
useEffect(() => { currentIndexRef.current = currentIndex; }, [currentIndex]);
|
useEffect(() => {
|
||||||
|
currentIndexRef.current = currentIndex;
|
||||||
|
}, [currentIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSettings(loadReaderSettings());
|
setSettings(loadReaderSettings());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined') localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
|
if (typeof window !== 'undefined')
|
||||||
|
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined') localStorage.setItem(TTS_SETTINGS_STORAGE_KEY, JSON.stringify(ttsSettings));
|
if (typeof window !== 'undefined')
|
||||||
|
localStorage.setItem(
|
||||||
|
TTS_SETTINGS_STORAGE_KEY,
|
||||||
|
JSON.stringify(ttsSettings)
|
||||||
|
);
|
||||||
ttsSettingsRef.current = ttsSettings;
|
ttsSettingsRef.current = ttsSettings;
|
||||||
}, [ttsSettings]);
|
}, [ttsSettings]);
|
||||||
|
|
||||||
useEffect(() => { ttsChunksRef.current = ttsChunks; }, [ttsChunks]);
|
useEffect(() => {
|
||||||
useEffect(() => { ttsCurrentChunkIndexRef.current = ttsCurrentChunkIndex; }, [ttsCurrentChunkIndex]);
|
ttsChunksRef.current = ttsChunks;
|
||||||
useEffect(() => { ttsStatusRef.current = ttsStatus; }, [ttsStatus]);
|
}, [ttsChunks]);
|
||||||
useEffect(() => { ttsSeekingRef.current = ttsSeeking; if (!ttsSeeking) setTtsSeekValue(ttsCurrentTime); }, [ttsCurrentTime, ttsSeeking]);
|
useEffect(() => {
|
||||||
|
ttsCurrentChunkIndexRef.current = ttsCurrentChunkIndex;
|
||||||
|
}, [ttsCurrentChunkIndex]);
|
||||||
|
useEffect(() => {
|
||||||
|
ttsStatusRef.current = ttsStatus;
|
||||||
|
}, [ttsStatus]);
|
||||||
|
useEffect(() => {
|
||||||
|
ttsSeekingRef.current = ttsSeeking;
|
||||||
|
if (!ttsSeeking) setTtsSeekValue(ttsCurrentTime);
|
||||||
|
}, [ttsCurrentTime, ttsSeeking]);
|
||||||
|
|
||||||
const stopTts = useCallback((clearQueue = false) => {
|
const stopTts = useCallback((clearQueue = false) => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
@@ -528,15 +668,33 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleToggleChapters = () => { setTocOpen((prev) => !prev); setSettingsOpen(false); setTtsPanelOpen(false); };
|
const handleToggleChapters = () => {
|
||||||
const handleToggleSettings = () => { setSettingsOpen((prev) => !prev); setTocOpen(false); setTtsPanelOpen(false); };
|
setTocOpen((prev) => !prev);
|
||||||
const handleToggleTts = () => { setTtsBarVisible((prev) => !prev); setTocOpen(false); setSettingsOpen(false); };
|
setSettingsOpen(false);
|
||||||
|
setTtsPanelOpen(false);
|
||||||
|
};
|
||||||
|
const handleToggleSettings = () => {
|
||||||
|
setSettingsOpen((prev) => !prev);
|
||||||
|
setTocOpen(false);
|
||||||
|
setTtsPanelOpen(false);
|
||||||
|
};
|
||||||
|
const handleToggleTts = () => {
|
||||||
|
setTtsBarVisible((prev) => !prev);
|
||||||
|
setTocOpen(false);
|
||||||
|
setSettingsOpen(false);
|
||||||
|
};
|
||||||
window.addEventListener('books-read-toggle-chapters', handleToggleChapters);
|
window.addEventListener('books-read-toggle-chapters', handleToggleChapters);
|
||||||
window.addEventListener('books-read-toggle-settings', handleToggleSettings);
|
window.addEventListener('books-read-toggle-settings', handleToggleSettings);
|
||||||
window.addEventListener('books-read-toggle-tts', handleToggleTts);
|
window.addEventListener('books-read-toggle-tts', handleToggleTts);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('books-read-toggle-chapters', handleToggleChapters);
|
window.removeEventListener(
|
||||||
window.removeEventListener('books-read-toggle-settings', handleToggleSettings);
|
'books-read-toggle-chapters',
|
||||||
|
handleToggleChapters
|
||||||
|
);
|
||||||
|
window.removeEventListener(
|
||||||
|
'books-read-toggle-settings',
|
||||||
|
handleToggleSettings
|
||||||
|
);
|
||||||
window.removeEventListener('books-read-toggle-tts', handleToggleTts);
|
window.removeEventListener('books-read-toggle-tts', handleToggleTts);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@@ -558,7 +716,10 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
|
|||||||
setTtsAvailable(true);
|
setTtsAvailable(true);
|
||||||
setTtsVoices(json.voices || []);
|
setTtsVoices(json.voices || []);
|
||||||
setTtsSettings((prev) => applyTtsDefaults(prev, json.defaults));
|
setTtsSettings((prev) => applyTtsDefaults(prev, json.defaults));
|
||||||
saveCachedTtsVoices({ voices: json.voices || [], defaults: json.defaults || {} });
|
saveCachedTtsVoices({
|
||||||
|
voices: json.voices || [],
|
||||||
|
defaults: json.defaults || {},
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -567,19 +728,29 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
|
|||||||
setTtsError(err.message || '朗读能力不可用');
|
setTtsError(err.message || '朗读能力不可用');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const buildChapterReadRecord = useCallback((item: BookChapter, index: number): BookReadRecord => {
|
const buildChapterReadRecord = useCallback(
|
||||||
|
(item: BookChapter, index: number): BookReadRecord => {
|
||||||
const node = scrollRef.current;
|
const node = scrollRef.current;
|
||||||
const scrollTop = node?.scrollTop || 0;
|
const scrollTop = node?.scrollTop || 0;
|
||||||
const scrollHeight = node?.scrollHeight || 0;
|
const scrollHeight = node?.scrollHeight || 0;
|
||||||
const clientHeight = node?.clientHeight || 0;
|
const clientHeight = node?.clientHeight || 0;
|
||||||
const chapterCount = Math.max(1, chapters.length);
|
const chapterCount = Math.max(1, chapters.length);
|
||||||
const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
|
const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
|
||||||
const chapterRatio = maxScrollTop > 0 ? Math.max(0, Math.min(1, scrollTop / maxScrollTop)) : 0;
|
const chapterRatio =
|
||||||
const progressPercent = chapters.length > 0
|
maxScrollTop > 0
|
||||||
? Math.max(0, Math.min(100, ((index + chapterRatio) / chapterCount) * 100))
|
? Math.max(0, Math.min(1, scrollTop / maxScrollTop))
|
||||||
|
: 0;
|
||||||
|
const progressPercent =
|
||||||
|
chapters.length > 0
|
||||||
|
? Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(100, ((index + chapterRatio) / chapterCount) * 100)
|
||||||
|
)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -594,7 +765,12 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
|
|||||||
format: 'chapters',
|
format: 'chapters',
|
||||||
locator: {
|
locator: {
|
||||||
type: 'chapter',
|
type: 'chapter',
|
||||||
value: encodeChapterScrollLocator(item.href, scrollTop, scrollHeight, clientHeight),
|
value: encodeChapterScrollLocator(
|
||||||
|
item.href,
|
||||||
|
scrollTop,
|
||||||
|
scrollHeight,
|
||||||
|
clientHeight
|
||||||
|
),
|
||||||
href: item.href,
|
href: item.href,
|
||||||
chapterTitle: item.title,
|
chapterTitle: item.title,
|
||||||
},
|
},
|
||||||
@@ -603,17 +779,23 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
|
|||||||
progressPercent,
|
progressPercent,
|
||||||
saveTime: Date.now(),
|
saveTime: Date.now(),
|
||||||
};
|
};
|
||||||
}, [chapters.length, manifest]);
|
},
|
||||||
|
[chapters.length, manifest]
|
||||||
|
);
|
||||||
|
|
||||||
const persistChapterProgress = useCallback((index = currentIndexRef.current) => {
|
const persistChapterProgress = useCallback(
|
||||||
|
(index = currentIndexRef.current) => {
|
||||||
const item = chapters[index];
|
const item = chapters[index];
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
const record = buildChapterReadRecord(item, index);
|
const record = buildChapterReadRecord(item, index);
|
||||||
if (record.locator.value === lastChapterSavedLocatorValueRef.current) return;
|
if (record.locator.value === lastChapterSavedLocatorValueRef.current)
|
||||||
|
return;
|
||||||
lastChapterSavedLocatorValueRef.current = record.locator.value;
|
lastChapterSavedLocatorValueRef.current = record.locator.value;
|
||||||
lastChapterSavedAtRef.current = Date.now();
|
lastChapterSavedAtRef.current = Date.now();
|
||||||
void saveBookReadRecord(record.sourceId, record.bookId, record);
|
void saveBookReadRecord(record.sourceId, record.bookId, record);
|
||||||
}, [buildChapterReadRecord, chapters]);
|
},
|
||||||
|
[buildChapterReadRecord, chapters]
|
||||||
|
);
|
||||||
|
|
||||||
const scheduleChapterProgressSave = useCallback(() => {
|
const scheduleChapterProgressSave = useCallback(() => {
|
||||||
if (chapterSaveTimerRef.current) return;
|
if (chapterSaveTimerRef.current) return;
|
||||||
@@ -635,22 +817,39 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
|
|||||||
restoredChapterPositionRef.current = false;
|
restoredChapterPositionRef.current = false;
|
||||||
pendingChapterRestoreRatioRef.current = null;
|
pendingChapterRestoreRatioRef.current = null;
|
||||||
lastChapterSavedAtRef.current = 0;
|
lastChapterSavedAtRef.current = 0;
|
||||||
lastChapterSavedLocatorValueRef.current = manifest.lastRecord?.locator?.value || '';
|
lastChapterSavedLocatorValueRef.current =
|
||||||
|
manifest.lastRecord?.locator?.value || '';
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
const url = manifest.chaptersUrl || `/api/books/read/chapters?sourceId=${encodeURIComponent(manifest.book.sourceId)}&bookId=${encodeURIComponent(manifest.book.id)}`;
|
const url =
|
||||||
|
manifest.chaptersUrl ||
|
||||||
|
`/api/books/read/chapters?sourceId=${encodeURIComponent(
|
||||||
|
manifest.book.sourceId
|
||||||
|
)}&bookId=${encodeURIComponent(manifest.book.id)}`;
|
||||||
fetchJsonWithRetry<{ chapters?: BookChapter[] }>(url, { cache: 'no-store' })
|
fetchJsonWithRetry<{ chapters?: BookChapter[] }>(url, { cache: 'no-store' })
|
||||||
.then((json) => {
|
.then((json) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const list = (json.chapters || []) as BookChapter[];
|
const list = (json.chapters || []) as BookChapter[];
|
||||||
setChapters(list);
|
setChapters(list);
|
||||||
setChaptersLoaded(true);
|
setChaptersLoaded(true);
|
||||||
const savedHref = initialChapterHref || manifest.lastRecord?.chapterHref || manifest.lastRecord?.locator?.href || manifest.lastRecord?.locator?.value?.split('#scroll=')[0] || '';
|
const savedHref =
|
||||||
|
initialChapterHref ||
|
||||||
|
manifest.lastRecord?.chapterHref ||
|
||||||
|
manifest.lastRecord?.locator?.href ||
|
||||||
|
manifest.lastRecord?.locator?.value?.split('#scroll=')[0] ||
|
||||||
|
'';
|
||||||
const savedIndex = list.findIndex((item) => item.href === savedHref);
|
const savedIndex = list.findIndex((item) => item.href === savedHref);
|
||||||
setCurrentIndex(savedIndex >= 0 ? savedIndex : 0);
|
setCurrentIndex(savedIndex >= 0 ? savedIndex : 0);
|
||||||
})
|
})
|
||||||
.catch((err) => { if (!cancelled) { setError(err.message || '获取目录失败'); setChaptersLoaded(true); } });
|
.catch((err) => {
|
||||||
return () => { cancelled = true; };
|
if (!cancelled) {
|
||||||
|
setError(err.message || '获取目录失败');
|
||||||
|
setChaptersLoaded(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [initialChapterHref, manifest]);
|
}, [initialChapterHref, manifest]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -664,28 +863,57 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
|
|||||||
setError('');
|
setError('');
|
||||||
scrollRef.current?.scrollTo({ top: 0, behavior: 'auto' });
|
scrollRef.current?.scrollTo({ top: 0, behavior: 'auto' });
|
||||||
pendingChapterRestoreRatioRef.current = null;
|
pendingChapterRestoreRatioRef.current = null;
|
||||||
const params = new URLSearchParams({ sourceId: manifest.book.sourceId, href: item.href });
|
const params = new URLSearchParams({
|
||||||
if (manifest.acquisitionHref) params.set('tocHref', manifest.acquisitionHref);
|
sourceId: manifest.book.sourceId,
|
||||||
fetchJsonWithRetry<BookChapterContent>(`/api/books/read/chapter?${params.toString()}`, { cache: 'no-store' })
|
href: item.href,
|
||||||
|
});
|
||||||
|
if (manifest.acquisitionHref)
|
||||||
|
params.set('tocHref', manifest.acquisitionHref);
|
||||||
|
fetchJsonWithRetry<BookChapterContent>(
|
||||||
|
`/api/books/read/chapter?${params.toString()}`,
|
||||||
|
{ cache: 'no-store' }
|
||||||
|
)
|
||||||
.then((json) => {
|
.then((json) => {
|
||||||
const shouldRestore = !restoredChapterPositionRef.current
|
const shouldRestore =
|
||||||
&& !initialChapterHref
|
!restoredChapterPositionRef.current &&
|
||||||
&& (manifest.lastRecord?.chapterHref === item.href || manifest.lastRecord?.locator?.href === item.href);
|
!initialChapterHref &&
|
||||||
pendingChapterRestoreRatioRef.current = shouldRestore ? parseChapterScrollLocator(manifest.lastRecord?.locator?.value) : null;
|
(manifest.lastRecord?.chapterHref === item.href ||
|
||||||
setChapter({ ...(json as BookChapterContent), title: (json as BookChapterContent).title || item.title });
|
manifest.lastRecord?.locator?.href === item.href);
|
||||||
|
pendingChapterRestoreRatioRef.current = shouldRestore
|
||||||
|
? parseChapterScrollLocator(manifest.lastRecord?.locator?.value)
|
||||||
|
: null;
|
||||||
|
setChapter({
|
||||||
|
...(json as BookChapterContent),
|
||||||
|
title: (json as BookChapterContent).title || item.title,
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch((err) => setError(err.message || '获取章节失败'))
|
.catch((err) => setError(err.message || '获取章节失败'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [chapters, chaptersLoaded, currentIndex, initialChapterHref, manifest, persistChapterProgress, stopTts]);
|
}, [
|
||||||
|
chapters,
|
||||||
|
chaptersLoaded,
|
||||||
|
currentIndex,
|
||||||
|
initialChapterHref,
|
||||||
|
manifest,
|
||||||
|
persistChapterProgress,
|
||||||
|
stopTts,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.dispatchEvent(new CustomEvent('books-read-update-header', {
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('books-read-update-header', {
|
||||||
detail: {
|
detail: {
|
||||||
title: manifest.book.title,
|
title: manifest.book.title,
|
||||||
subtitle: currentChapterTitle || manifest.book.author || (settings.mode === 'scrolled' ? '滚动阅读' : '翻页阅读'),
|
subtitle:
|
||||||
backHref: `/books/detail?sourceId=${encodeURIComponent(manifest.book.sourceId)}&bookId=${encodeURIComponent(manifest.book.id)}`,
|
currentChapterTitle ||
|
||||||
|
manifest.book.author ||
|
||||||
|
(settings.mode === 'scrolled' ? '滚动阅读' : '翻页阅读'),
|
||||||
|
backHref: `/books/detail?sourceId=${encodeURIComponent(
|
||||||
|
manifest.book.sourceId
|
||||||
|
)}&bookId=${encodeURIComponent(manifest.book.id)}`,
|
||||||
},
|
},
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
}, [manifest, currentChapterTitle, settings.mode]);
|
}, [manifest, currentChapterTitle, settings.mode]);
|
||||||
|
|
||||||
const goPrevChapter = useCallback(() => {
|
const goPrevChapter = useCallback(() => {
|
||||||
@@ -697,7 +925,8 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
|
|||||||
setCurrentIndex((prev) => Math.min(chapters.length - 1, prev + 1));
|
setCurrentIndex((prev) => Math.min(chapters.length - 1, prev + 1));
|
||||||
}, [chapters.length, persistChapterProgress]);
|
}, [chapters.length, persistChapterProgress]);
|
||||||
|
|
||||||
const turnPage = useCallback((direction: 1 | -1) => {
|
const turnPage = useCallback(
|
||||||
|
(direction: 1 | -1) => {
|
||||||
const node = scrollRef.current;
|
const node = scrollRef.current;
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
if (settings.mode === 'scrolled') return;
|
if (settings.mode === 'scrolled') return;
|
||||||
@@ -713,19 +942,23 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
node.scrollTo({ top: nextTop, behavior: 'smooth' });
|
node.scrollTo({ top: nextTop, behavior: 'smooth' });
|
||||||
}, [chapters.length, currentIndex, goNextChapter, goPrevChapter, settings.mode]);
|
},
|
||||||
|
[chapters.length, currentIndex, goNextChapter, goPrevChapter, settings.mode]
|
||||||
|
);
|
||||||
|
|
||||||
const getChapterPlainText = useCallback(() => {
|
const getChapterPlainText = useCallback(() => {
|
||||||
const html = chapter?.content || '';
|
const html = chapter?.content || '';
|
||||||
if (!html) return '';
|
if (!html) return '';
|
||||||
if (typeof document === 'undefined') return sanitizeTtsText(html.replace(/<[^>]*>/g, ' '));
|
if (typeof document === 'undefined')
|
||||||
|
return sanitizeTtsText(html.replace(/<[^>]*>/g, ' '));
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.innerHTML = html;
|
div.innerHTML = html;
|
||||||
div.querySelectorAll('script,style,img').forEach((node) => node.remove());
|
div.querySelectorAll('script,style,img').forEach((node) => node.remove());
|
||||||
return sanitizeTtsText(div.innerText || div.textContent || '');
|
return sanitizeTtsText(div.innerText || div.textContent || '');
|
||||||
}, [chapter]);
|
}, [chapter]);
|
||||||
|
|
||||||
const fetchTtsChunkAudioUrl = useCallback(async (chunk: TtsChunk, chapterHref: string) => {
|
const fetchTtsChunkAudioUrl = useCallback(
|
||||||
|
async (chunk: TtsChunk, chapterHref: string) => {
|
||||||
if (!manifest) throw new Error('书籍信息未准备好');
|
if (!manifest) throw new Error('书籍信息未准备好');
|
||||||
const response = await fetch('/api/books/tts/synthesize', {
|
const response = await fetch('/api/books/tts/synthesize', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -743,10 +976,15 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
|
|||||||
});
|
});
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
if (!response.ok) throw new Error(json.error || '朗读音频生成失败');
|
if (!response.ok) throw new Error(json.error || '朗读音频生成失败');
|
||||||
return URL.createObjectURL(decodeBase64Audio(json.audioBase64 || '', json.mimeType || 'audio/mpeg'));
|
return URL.createObjectURL(
|
||||||
}, [manifest]);
|
decodeBase64Audio(json.audioBase64 || '', json.mimeType || 'audio/mpeg')
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[manifest]
|
||||||
|
);
|
||||||
|
|
||||||
const playTtsChunk = useCallback(async (index: number) => {
|
const playTtsChunk = useCallback(
|
||||||
|
async (index: number) => {
|
||||||
const chunks = ttsChunksRef.current;
|
const chunks = ttsChunksRef.current;
|
||||||
const chunk = chunks[index];
|
const chunk = chunks[index];
|
||||||
if (!chunk || !currentChapterHref) return;
|
if (!chunk || !currentChapterHref) return;
|
||||||
@@ -767,7 +1005,9 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
|
|||||||
setTtsLoadingChunkIndex(null);
|
setTtsLoadingChunkIndex(null);
|
||||||
setTtsError((err as Error).message || '朗读失败');
|
setTtsError((err as Error).message || '朗读失败');
|
||||||
}
|
}
|
||||||
}, [currentChapterHref, fetchTtsChunkAudioUrl]);
|
},
|
||||||
|
[currentChapterHref, fetchTtsChunkAudioUrl]
|
||||||
|
);
|
||||||
|
|
||||||
const bootstrapTts = useCallback(async () => {
|
const bootstrapTts = useCallback(async () => {
|
||||||
if (!ttsAvailable) return;
|
if (!ttsAvailable) return;
|
||||||
@@ -842,7 +1082,9 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const node = scrollRef.current;
|
const node = scrollRef.current;
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
node.addEventListener('scroll', scheduleChapterProgressSave, { passive: true });
|
node.addEventListener('scroll', scheduleChapterProgressSave, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
node.removeEventListener('scroll', scheduleChapterProgressSave);
|
node.removeEventListener('scroll', scheduleChapterProgressSave);
|
||||||
if (chapterSaveTimerRef.current) {
|
if (chapterSaveTimerRef.current) {
|
||||||
@@ -851,7 +1093,13 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
|
|||||||
}
|
}
|
||||||
persistChapterProgress();
|
persistChapterProgress();
|
||||||
};
|
};
|
||||||
}, [chapter, currentChapterHref, loading, persistChapterProgress, scheduleChapterProgressSave]);
|
}, [
|
||||||
|
chapter,
|
||||||
|
currentChapterHref,
|
||||||
|
loading,
|
||||||
|
persistChapterProgress,
|
||||||
|
scheduleChapterProgressSave,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const flush = () => persistChapterProgress();
|
const flush = () => persistChapterProgress();
|
||||||
@@ -882,9 +1130,16 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
|
|||||||
setTtsDuration(audio.duration || 0);
|
setTtsDuration(audio.duration || 0);
|
||||||
if (!ttsSeekingRef.current) setTtsSeekValue(audio.currentTime || 0);
|
if (!ttsSeekingRef.current) setTtsSeekValue(audio.currentTime || 0);
|
||||||
};
|
};
|
||||||
const handlePause = () => { if (!audio.ended && ttsStatusRef.current === 'playing') setTtsStatus('paused'); };
|
const handlePause = () => {
|
||||||
|
if (!audio.ended && ttsStatusRef.current === 'playing')
|
||||||
|
setTtsStatus('paused');
|
||||||
|
};
|
||||||
const handleLoadedMetadata = () => {
|
const handleLoadedMetadata = () => {
|
||||||
if (ttsResumeTimeRef.current > 0 && audio.duration > 0) audio.currentTime = Math.min(ttsResumeTimeRef.current, Math.max(0, audio.duration - 0.25));
|
if (ttsResumeTimeRef.current > 0 && audio.duration > 0)
|
||||||
|
audio.currentTime = Math.min(
|
||||||
|
ttsResumeTimeRef.current,
|
||||||
|
Math.max(0, audio.duration - 0.25)
|
||||||
|
);
|
||||||
ttsResumeTimeRef.current = 0;
|
ttsResumeTimeRef.current = 0;
|
||||||
setTtsDuration(audio.duration || 0);
|
setTtsDuration(audio.duration || 0);
|
||||||
};
|
};
|
||||||
@@ -901,13 +1156,18 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
|
|||||||
};
|
};
|
||||||
}, [playTtsChunk, stopTts]);
|
}, [playTtsChunk, stopTts]);
|
||||||
|
|
||||||
const selectedVoice = ttsVoices.find((item) => item.shortName === ttsSettings.voice);
|
const selectedVoice = ttsVoices.find(
|
||||||
|
(item) => item.shortName === ttsSettings.voice
|
||||||
|
);
|
||||||
const currentChunk = ttsChunks[ttsCurrentChunkIndex];
|
const currentChunk = ttsChunks[ttsCurrentChunkIndex];
|
||||||
const ttsRateValue = parseSignedNumber(ttsSettings.rate, '%');
|
const ttsRateValue = parseSignedNumber(ttsSettings.rate, '%');
|
||||||
const ttsPitchValue = parseSignedNumber(ttsSettings.pitch, 'Hz');
|
const ttsPitchValue = parseSignedNumber(ttsSettings.pitch, 'Hz');
|
||||||
const ttsVolumeValue = parseSignedNumber(ttsSettings.volume, '%');
|
const ttsVolumeValue = parseSignedNumber(ttsSettings.volume, '%');
|
||||||
const displayedTtsTime = ttsSeeking ? ttsSeekValue : ttsCurrentTime;
|
const displayedTtsTime = ttsSeeking ? ttsSeekValue : ttsCurrentTime;
|
||||||
const ttsChunkPercent = ttsChunks.length > 0 ? ((ttsCurrentChunkIndex + 1) / ttsChunks.length) * 100 : 0;
|
const ttsChunkPercent =
|
||||||
|
ttsChunks.length > 0
|
||||||
|
? ((ttsCurrentChunkIndex + 1) / ttsChunks.length) * 100
|
||||||
|
: 0;
|
||||||
const palette = THEME_STYLES[settings.theme];
|
const palette = THEME_STYLES[settings.theme];
|
||||||
|
|
||||||
if (error) return <div className='p-4 text-sm text-red-500'>{error}</div>;
|
if (error) return <div className='p-4 text-sm text-red-500'>{error}</div>;
|
||||||
@@ -918,7 +1178,9 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
|
|||||||
<div className='reader-book-loader'>
|
<div className='reader-book-loader'>
|
||||||
<BookOpen className='h-10 w-10' strokeWidth={1.75} />
|
<BookOpen className='h-10 w-10' strokeWidth={1.75} />
|
||||||
</div>
|
</div>
|
||||||
<div className='text-sm text-gray-500 dark:text-gray-400'>章节加载中...</div>
|
<div className='text-sm text-gray-500 dark:text-gray-400'>
|
||||||
|
章节加载中...
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -927,62 +1189,525 @@ function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
|
|||||||
return (
|
return (
|
||||||
<div className='mx-auto max-w-2xl p-4'>
|
<div className='mx-auto max-w-2xl p-4'>
|
||||||
<div className='rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-200'>
|
<div className='rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-200'>
|
||||||
暂无章节。该 Legado 源返回的是章节/图片接口,不是 EPUB 文件;如果详情接口显示章节数为 0,说明源站当前还没放出可读章节,请换一本有章节的结果再试。
|
暂无章节。该 Legado 源返回的是章节/图片接口,不是 EPUB
|
||||||
|
文件;如果详情接口显示章节数为
|
||||||
|
0,说明源站当前还没放出可读章节,请换一本有章节的结果再试。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='relative h-[calc(100vh-3.5rem)] overflow-hidden' style={{ backgroundColor: palette.panelBg, color: palette.bodyColor }}>
|
<div
|
||||||
{tocOpen && typeof document !== 'undefined' ? createPortal(
|
className='relative h-[calc(100vh-3.5rem)] overflow-hidden'
|
||||||
<div className='fixed inset-0 z-40 bg-black/30' onClick={() => setTocOpen(false)}>
|
style={{ backgroundColor: palette.panelBg, color: palette.bodyColor }}
|
||||||
<div className='absolute right-0 top-0 h-screen w-[22rem] max-w-[88vw] overflow-y-auto border-l border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-950' onClick={(event) => event.stopPropagation()}>
|
>
|
||||||
|
{tocOpen && typeof document !== 'undefined'
|
||||||
|
? createPortal(
|
||||||
|
<div
|
||||||
|
className='fixed inset-0 z-40 bg-black/30'
|
||||||
|
onClick={() => setTocOpen(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='absolute right-0 top-0 h-screen w-[22rem] max-w-[88vw] overflow-y-auto border-l border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-950'
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
<div className='space-y-2 p-4'>
|
<div className='space-y-2 p-4'>
|
||||||
{chapters.map((item, index) => {
|
{chapters.map((item, index) => {
|
||||||
const active = index === currentIndex;
|
const active = index === currentIndex;
|
||||||
return <button key={`${item.href}-${item.order}-${index}`} onClick={() => { persistChapterProgress(); setCurrentIndex(index); setTocOpen(false); }} className={`block w-full rounded-2xl px-4 py-3 text-left text-sm transition ${active ? 'bg-sky-600 text-white' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900'}`} title={item.title}>{item.title}</button>;
|
return (
|
||||||
|
<button
|
||||||
|
key={`${item.href}-${item.order}-${index}`}
|
||||||
|
onClick={() => {
|
||||||
|
persistChapterProgress();
|
||||||
|
setCurrentIndex(index);
|
||||||
|
setTocOpen(false);
|
||||||
|
}}
|
||||||
|
className={`block w-full rounded-2xl px-4 py-3 text-left text-sm transition ${
|
||||||
|
active
|
||||||
|
? 'bg-emerald-600 text-white'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900'
|
||||||
|
}`}
|
||||||
|
title={item.title}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>, document.body
|
</div>,
|
||||||
) : null}
|
document.body
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
|
||||||
{settingsOpen && typeof document !== 'undefined' ? createPortal(
|
{settingsOpen && typeof document !== 'undefined'
|
||||||
<div className='fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4' onClick={() => setSettingsOpen(false)}>
|
? createPortal(
|
||||||
<div className='w-full max-w-sm rounded-3xl border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-700 dark:bg-gray-950' onClick={(event) => event.stopPropagation()}>
|
<div
|
||||||
<div className='mb-4'><div className='text-base font-semibold text-gray-900 dark:text-gray-100'>阅读设置</div><div className='mt-1 text-xs text-gray-500'>Legado 源支持翻页和滚动阅读</div></div>
|
className='fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4'
|
||||||
|
onClick={() => setSettingsOpen(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='w-full max-w-sm rounded-3xl border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-700 dark:bg-gray-950'
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className='mb-4'>
|
||||||
|
<div className='text-base font-semibold text-gray-900 dark:text-gray-100'>
|
||||||
|
阅读设置
|
||||||
|
</div>
|
||||||
|
<div className='mt-1 text-xs text-gray-500'>
|
||||||
|
Legado 源支持翻页和滚动阅读
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className='space-y-6 p-1 text-sm'>
|
<div className='space-y-6 p-1 text-sm'>
|
||||||
<div><div className='mb-2 font-medium'>阅读模式</div><div className='grid grid-cols-2 gap-2'>{([{ key: 'paginated', label: '翻页模式', desc: '左右点击翻页/章节' }, { key: 'scrolled', label: '滚动模式', desc: '上下连续滚动' }] as { key: ReaderMode; label: string; desc: string }[]).map((mode) => <button key={mode.key} onClick={() => setSettings((prev) => ({ ...prev, mode: mode.key }))} className={`rounded-2xl border px-3 py-3 text-left ${settings.mode === mode.key ? 'border-sky-500 bg-sky-50 text-sky-700 dark:bg-sky-950/30 dark:text-sky-300' : 'border-gray-200 dark:border-gray-700'}`}><div className='font-medium'>{mode.label}</div><div className='mt-1 text-xs opacity-70'>{mode.desc}</div></button>)}</div></div>
|
<div>
|
||||||
<div><div className='mb-2 font-medium'>主题</div><div className='grid grid-cols-3 gap-2'>{(['light', 'sepia', 'dark'] as ReaderTheme[]).map((theme) => <button key={theme} onClick={() => setSettings((prev) => ({ ...prev, theme }))} className={`rounded-2xl border px-3 py-2 ${settings.theme === theme ? 'border-sky-500 bg-sky-50 text-sky-700 dark:bg-sky-950/30 dark:text-sky-300' : 'border-gray-200 dark:border-gray-700'}`}>{theme === 'light' ? '浅色' : theme === 'sepia' ? '护眼' : '深色'}</button>)}</div></div>
|
<div className='mb-2 font-medium'>阅读模式</div>
|
||||||
<div><div className='mb-2 flex items-center justify-between font-medium'>字号 <span>{settings.fontSize}%</span></div><input type='range' min='85' max='140' step='5' value={settings.fontSize} onChange={(e) => setSettings((prev) => ({ ...prev, fontSize: Number(e.target.value) }))} className='w-full' /></div>
|
<div className='grid grid-cols-2 gap-2'>
|
||||||
<div><div className='mb-2 flex items-center justify-between font-medium'>行距 <span>{settings.lineHeight.toFixed(1)}</span></div><input type='range' min='1.4' max='2.2' step='0.1' value={settings.lineHeight} onChange={(e) => setSettings((prev) => ({ ...prev, lineHeight: Number(e.target.value) }))} className='w-full' /></div>
|
{(
|
||||||
<div className='flex justify-end'><button type='button' className='rounded-2xl bg-sky-600 px-4 py-2 text-sm font-medium text-white' onClick={() => setSettingsOpen(false)}>完成</button></div>
|
[
|
||||||
|
{
|
||||||
|
key: 'paginated',
|
||||||
|
label: '翻页模式',
|
||||||
|
desc: '左右点击翻页/章节',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'scrolled',
|
||||||
|
label: '滚动模式',
|
||||||
|
desc: '上下连续滚动',
|
||||||
|
},
|
||||||
|
] as { key: ReaderMode; label: string; desc: string }[]
|
||||||
|
).map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode.key}
|
||||||
|
onClick={() =>
|
||||||
|
setSettings((prev) => ({ ...prev, mode: mode.key }))
|
||||||
|
}
|
||||||
|
className={`rounded-2xl border px-3 py-3 text-left ${
|
||||||
|
settings.mode === mode.key
|
||||||
|
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'
|
||||||
|
: 'border-gray-200 dark:border-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className='font-medium'>{mode.label}</div>
|
||||||
|
<div className='mt-1 text-xs opacity-70'>
|
||||||
|
{mode.desc}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>, document.body
|
<div>
|
||||||
|
<div className='mb-2 font-medium'>主题</div>
|
||||||
|
<div className='grid grid-cols-3 gap-2'>
|
||||||
|
{(['light', 'sepia', 'dark'] as ReaderTheme[]).map(
|
||||||
|
(theme) => (
|
||||||
|
<button
|
||||||
|
key={theme}
|
||||||
|
onClick={() =>
|
||||||
|
setSettings((prev) => ({ ...prev, theme }))
|
||||||
|
}
|
||||||
|
className={`rounded-2xl border px-3 py-2 ${
|
||||||
|
settings.theme === theme
|
||||||
|
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'
|
||||||
|
: 'border-gray-200 dark:border-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{theme === 'light'
|
||||||
|
? '浅色'
|
||||||
|
: theme === 'sepia'
|
||||||
|
? '护眼'
|
||||||
|
: '深色'}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className='mb-2 flex items-center justify-between font-medium'>
|
||||||
|
字号 <span>{settings.fontSize}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type='range'
|
||||||
|
min='85'
|
||||||
|
max='140'
|
||||||
|
step='5'
|
||||||
|
value={settings.fontSize}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
fontSize: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className='w-full'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className='mb-2 flex items-center justify-between font-medium'>
|
||||||
|
行距 <span>{settings.lineHeight.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type='range'
|
||||||
|
min='1.4'
|
||||||
|
max='2.2'
|
||||||
|
step='0.1'
|
||||||
|
value={settings.lineHeight}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
lineHeight: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className='w-full'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='flex justify-end'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='rounded-2xl bg-emerald-600 px-4 py-2 text-sm font-medium text-white'
|
||||||
|
onClick={() => setSettingsOpen(false)}
|
||||||
|
>
|
||||||
|
完成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
|
||||||
|
{settings.mode === 'paginated' && !tocOpen && !settingsOpen ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
aria-label='上一页'
|
||||||
|
className='absolute inset-y-0 left-0 z-10 w-[28%] cursor-pointer bg-transparent'
|
||||||
|
onClick={() => turnPage(-1)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
aria-label='下一页'
|
||||||
|
className='absolute inset-y-0 right-0 z-10 w-[28%] cursor-pointer bg-transparent'
|
||||||
|
onClick={() => turnPage(1)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{settings.mode === 'paginated' && !tocOpen && !settingsOpen ? <><button aria-label='上一页' className='absolute inset-y-0 left-0 z-10 w-[28%] cursor-pointer bg-transparent' onClick={() => turnPage(-1)} /><button aria-label='下一页' className='absolute inset-y-0 right-0 z-10 w-[28%] cursor-pointer bg-transparent' onClick={() => turnPage(1)} /></> : null}
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
<div ref={scrollRef} className='h-full overflow-y-auto px-4 py-6' style={{ scrollSnapType: settings.mode === 'paginated' ? 'y mandatory' : undefined }}>
|
className='h-full overflow-y-auto px-4 py-6'
|
||||||
<article className='mx-auto max-w-3xl text-gray-800 dark:text-gray-100' style={{ fontSize: `${settings.fontSize}%`, lineHeight: settings.lineHeight, color: palette.bodyColor }}>
|
style={{
|
||||||
{loading ? '加载中...' : chapter?.content?.includes('<img')
|
scrollSnapType:
|
||||||
? <div className='space-y-2 [&_img]:mx-auto [&_img]:block [&_img]:max-w-full' dangerouslySetInnerHTML={{ __html: chapter.content }} />
|
settings.mode === 'paginated' ? 'y mandatory' : undefined,
|
||||||
: <div className='whitespace-pre-wrap'>{chapter?.content || '本章暂无内容'}</div>}
|
}}
|
||||||
|
>
|
||||||
|
<article
|
||||||
|
className='mx-auto max-w-3xl text-gray-800 dark:text-gray-100'
|
||||||
|
style={{
|
||||||
|
fontSize: `${settings.fontSize}%`,
|
||||||
|
lineHeight: settings.lineHeight,
|
||||||
|
color: palette.bodyColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className='flex min-h-[45vh] items-center justify-center px-4'>
|
||||||
|
<div className='rounded-[2rem] border border-emerald-100/80 bg-white/80 px-6 py-5 text-center shadow-sm shadow-emerald-950/5 backdrop-blur dark:border-emerald-500/10 dark:bg-gray-950/70'>
|
||||||
|
<Loader2 className='mx-auto h-6 w-6 animate-spin text-emerald-600 dark:text-emerald-300' />
|
||||||
|
<div className='mt-3 text-sm font-medium text-slate-600 dark:text-slate-300'>
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : chapter?.content?.includes('<img') ? (
|
||||||
|
<div
|
||||||
|
className='space-y-2 [&_img]:mx-auto [&_img]:block [&_img]:max-w-full'
|
||||||
|
dangerouslySetInnerHTML={{ __html: chapter.content }}
|
||||||
|
/>
|
||||||
|
) : chapter?.content ? (
|
||||||
|
<div className='whitespace-pre-wrap'>{chapter.content}</div>
|
||||||
|
) : (
|
||||||
|
<div className='flex min-h-[45vh] items-center justify-center px-4'>
|
||||||
|
<div className='rounded-[2rem] border border-dashed border-emerald-200 bg-white/80 px-6 py-5 text-center shadow-sm shadow-emerald-950/5 backdrop-blur dark:border-emerald-500/20 dark:bg-gray-950/70'>
|
||||||
|
<BookOpen className='mx-auto h-7 w-7 text-emerald-600 dark:text-emerald-300' />
|
||||||
|
<div className='mt-3 text-sm font-medium text-slate-600 dark:text-slate-300'>
|
||||||
|
本章暂无内容
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
{settings.mode === 'scrolled' ? <div className='mx-auto mt-5 flex max-w-3xl justify-between gap-3 pb-8'><button disabled={currentIndex <= 0} onClick={goPrevChapter} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm disabled:text-gray-400 dark:border-gray-700'>上一章</button><button disabled={currentIndex >= chapters.length - 1} onClick={goNextChapter} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white disabled:bg-gray-300'>下一章</button></div> : null}
|
{settings.mode === 'scrolled' ? (
|
||||||
|
<div className='mx-auto mt-5 flex max-w-3xl justify-between gap-3 pb-8'>
|
||||||
|
<button
|
||||||
|
disabled={currentIndex <= 0}
|
||||||
|
onClick={goPrevChapter}
|
||||||
|
className='rounded-2xl border border-gray-200 px-4 py-2 text-sm disabled:text-gray-400 dark:border-gray-700'
|
||||||
|
>
|
||||||
|
上一章
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={currentIndex >= chapters.length - 1}
|
||||||
|
onClick={goNextChapter}
|
||||||
|
className='rounded-2xl bg-emerald-600 px-4 py-2 text-sm text-white disabled:bg-gray-300'
|
||||||
|
>
|
||||||
|
下一章
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ttsBarVisible ? <>
|
{ttsBarVisible ? (
|
||||||
|
<>
|
||||||
<div className='absolute inset-x-0 bottom-3 z-20 mx-auto w-[min(94vw,34rem)]'>
|
<div className='absolute inset-x-0 bottom-3 z-20 mx-auto w-[min(94vw,34rem)]'>
|
||||||
<div className='overflow-hidden rounded-3xl border border-gray-200 bg-white/95 shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-950/95'>
|
<div className='overflow-hidden rounded-3xl border border-gray-200 bg-white/95 shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-950/95'>
|
||||||
<div className='px-2 pt-2'><input type='range' min={0} max={Math.max(ttsDuration, 0)} step={0.1} value={Math.min(ttsSeekValue, Math.max(ttsDuration, 0))} disabled={!ttsAvailable || ttsDuration <= 0} onPointerDown={() => setTtsSeeking(true)} onChange={(e) => setTtsSeekValue(Number(e.target.value))} onPointerUp={(e) => { const next = Number((e.target as HTMLInputElement).value); if (audioRef.current && Number.isFinite(next)) audioRef.current.currentTime = next; setTtsCurrentTime(next); setTtsSeeking(false); }} className='w-full accent-sky-500' /></div>
|
<div className='px-2 pt-2'>
|
||||||
<div className='px-3 py-2.5'><div className='flex items-center gap-2'><button type='button' onClick={() => void toggleTtsPlayback()} disabled={!ttsAvailable || ttsLoadingChunkIndex !== null} className='flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-600 text-white disabled:opacity-50'>{ttsLoadingChunkIndex !== null ? <Loader2 className='h-4 w-4 animate-spin' /> : ttsStatus === 'playing' ? <Pause className='h-4 w-4' /> : <Play className='h-4 w-4' />}</button><div className='min-w-0 flex-1'><div className='truncate text-sm font-medium text-gray-900 dark:text-gray-100'>{currentChapterTitle || '语音朗读'}</div><div className='mt-0.5 flex items-center gap-2 text-[11px] text-gray-500 dark:text-gray-400'><span>{!ttsAvailable ? '服务异常' : ttsStatus === 'playing' ? '正在播放' : ttsStatus === 'paused' ? '已暂停' : ttsLoadingChunkIndex !== null ? '生成语音中...' : '待播放'}</span>{ttsChunks.length > 0 ? <span>{ttsCurrentChunkIndex + 1}/{ttsChunks.length}</span> : null}</div></div><button type='button' onClick={() => setTtsPanelOpen((prev) => !prev)} className='flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-200'><ChevronUp className={`h-4 w-4 transition-transform ${ttsPanelOpen ? 'rotate-180' : ''}`} /></button></div><div className='mt-2 flex items-center justify-between text-[11px] text-gray-400'><span>{selectedVoice?.displayName || '默认音色'}</span><span>{formatDurationTime(displayedTtsTime)} / {formatDurationTime(ttsDuration || 0)}</span></div></div>
|
<input
|
||||||
|
type='range'
|
||||||
|
min={0}
|
||||||
|
max={Math.max(ttsDuration, 0)}
|
||||||
|
step={0.1}
|
||||||
|
value={Math.min(ttsSeekValue, Math.max(ttsDuration, 0))}
|
||||||
|
disabled={!ttsAvailable || ttsDuration <= 0}
|
||||||
|
onPointerDown={() => setTtsSeeking(true)}
|
||||||
|
onChange={(e) => setTtsSeekValue(Number(e.target.value))}
|
||||||
|
onPointerUp={(e) => {
|
||||||
|
const next = Number((e.target as HTMLInputElement).value);
|
||||||
|
if (audioRef.current && Number.isFinite(next))
|
||||||
|
audioRef.current.currentTime = next;
|
||||||
|
setTtsCurrentTime(next);
|
||||||
|
setTtsSeeking(false);
|
||||||
|
}}
|
||||||
|
className='w-full accent-emerald-500'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='px-3 py-2.5'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => void toggleTtsPlayback()}
|
||||||
|
disabled={!ttsAvailable || ttsLoadingChunkIndex !== null}
|
||||||
|
className='flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-600 text-white disabled:opacity-50'
|
||||||
|
>
|
||||||
|
{ttsLoadingChunkIndex !== null ? (
|
||||||
|
<Loader2 className='h-4 w-4 animate-spin' />
|
||||||
|
) : ttsStatus === 'playing' ? (
|
||||||
|
<Pause className='h-4 w-4' />
|
||||||
|
) : (
|
||||||
|
<Play className='h-4 w-4' />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className='min-w-0 flex-1'>
|
||||||
|
<div className='truncate text-sm font-medium text-gray-900 dark:text-gray-100'>
|
||||||
|
{currentChapterTitle || '语音朗读'}
|
||||||
|
</div>
|
||||||
|
<div className='mt-0.5 flex items-center gap-2 text-[11px] text-gray-500 dark:text-gray-400'>
|
||||||
|
<span>
|
||||||
|
{!ttsAvailable
|
||||||
|
? '服务异常'
|
||||||
|
: ttsStatus === 'playing'
|
||||||
|
? '正在播放'
|
||||||
|
: ttsStatus === 'paused'
|
||||||
|
? '已暂停'
|
||||||
|
: ttsLoadingChunkIndex !== null
|
||||||
|
? '生成语音中...'
|
||||||
|
: '待播放'}
|
||||||
|
</span>
|
||||||
|
{ttsChunks.length > 0 ? (
|
||||||
|
<span>
|
||||||
|
{ttsCurrentChunkIndex + 1}/{ttsChunks.length}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{ttsPanelOpen ? <div className='absolute inset-x-0 bottom-20 z-30 mx-auto w-[min(94vw,34rem)]'><div className='rounded-[2rem] border border-gray-200 bg-white/98 p-4 shadow-2xl backdrop-blur dark:border-gray-800 dark:bg-gray-950/98'><div className='mb-3 flex items-center justify-between'><div className='flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-gray-100'><Headphones className='h-4 w-4 text-sky-500' />听书控制</div><button type='button' onClick={() => setTtsPanelOpen(false)} className='flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 text-gray-600 dark:bg-gray-900 dark:text-gray-300'><X className='h-4 w-4' /></button></div><div className='mb-4 flex items-center justify-between rounded-2xl bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-gray-900 dark:text-gray-300'><span className='truncate'>{currentChunk?.text.slice(0, 28) || '当前章节可开始朗读'}</span><span className='ml-2 shrink-0'>{Math.round(ttsChunkPercent)}%</span></div><div className='mb-4 flex items-center justify-center gap-3'><button type='button' aria-label='上一段' onClick={() => { const next = Math.max(0, ttsCurrentChunkIndex - 1); if (ttsChunks[next]) void playTtsChunk(next); }} disabled={ttsCurrentChunkIndex <= 0 || ttsChunks.length === 0} className='flex h-11 w-11 items-center justify-center rounded-full bg-gray-100 text-gray-700 disabled:opacity-40 dark:bg-gray-900 dark:text-gray-200'><SkipBack className='h-5 w-5' /></button><button type='button' onClick={() => void toggleTtsPlayback()} disabled={!ttsAvailable || ttsLoadingChunkIndex !== null} className='flex h-14 w-14 items-center justify-center rounded-full bg-sky-600 text-white shadow-lg disabled:opacity-50'>{ttsLoadingChunkIndex !== null ? <Loader2 className='h-5 w-5 animate-spin' /> : ttsStatus === 'playing' ? <Pause className='h-5 w-5' /> : <Play className='h-5 w-5' />}</button><button type='button' aria-label='下一段' onClick={() => { const next = ttsCurrentChunkIndex + 1; if (ttsChunks[next]) void playTtsChunk(next); }} disabled={ttsCurrentChunkIndex >= ttsChunks.length - 1 || ttsChunks.length === 0} className='flex h-11 w-11 items-center justify-center rounded-full bg-gray-100 text-gray-700 disabled:opacity-40 dark:bg-gray-900 dark:text-gray-200'><SkipForward className='h-5 w-5' /></button><button type='button' aria-label='停止' onClick={() => stopTts(true)} disabled={ttsStatus === 'idle' && ttsChunks.length === 0} className='flex h-11 w-11 items-center justify-center rounded-full bg-gray-100 text-gray-700 disabled:opacity-40 dark:bg-gray-900 dark:text-gray-200'><Square className='h-4 w-4' /></button></div><select value={ttsSettings.voice} onChange={(e) => { stopTts(true); setTtsSettings((prev) => ({ ...prev, voice: e.target.value })); }} className='mb-4 w-full rounded-2xl border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100'>{ttsVoices.map((voice) => <option key={voice.shortName} value={voice.shortName}>{voice.displayName || voice.shortName}</option>)}</select><label className='mb-3 block text-xs text-gray-500'>语速 {ttsSettings.rate}<input type='range' min={0} max={TTS_RATE_STEPS.length - 1} step={1} value={Math.max(0, TTS_RATE_STEPS.indexOf(ttsRateValue))} onChange={(e) => { stopTts(true); setTtsSettings((prev) => ({ ...prev, rate: formatSignedValue(TTS_RATE_STEPS[Number(e.target.value)] ?? 0, '%') })); }} className='w-full' /></label><label className='mb-3 block text-xs text-gray-500'>音调 {ttsSettings.pitch}<input type='range' min={0} max={TTS_PITCH_STEPS.length - 1} step={1} value={Math.max(0, TTS_PITCH_STEPS.indexOf(ttsPitchValue))} onChange={(e) => { stopTts(true); setTtsSettings((prev) => ({ ...prev, pitch: formatSignedValue(TTS_PITCH_STEPS[Number(e.target.value)] ?? 0, 'Hz') })); }} className='w-full' /></label><label className='block text-xs text-gray-500'>音量 {ttsSettings.volume}<input type='range' min={0} max={TTS_VOLUME_STEPS.length - 1} step={1} value={Math.max(0, TTS_VOLUME_STEPS.indexOf(ttsVolumeValue))} onChange={(e) => { stopTts(true); setTtsSettings((prev) => ({ ...prev, volume: formatSignedValue(TTS_VOLUME_STEPS[Number(e.target.value)] ?? 0, '%') })); }} className='w-full' /></label>{ttsError ? <div className='mt-3 text-xs text-red-500'>{ttsError}</div> : null}</div></div> : null}
|
<button
|
||||||
</> : null}
|
type='button'
|
||||||
|
onClick={() => setTtsPanelOpen((prev) => !prev)}
|
||||||
|
className='flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-200'
|
||||||
|
>
|
||||||
|
<ChevronUp
|
||||||
|
className={`h-4 w-4 transition-transform ${
|
||||||
|
ttsPanelOpen ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className='mt-2 flex items-center justify-between text-[11px] text-gray-400'>
|
||||||
|
<span>{selectedVoice?.displayName || '默认音色'}</span>
|
||||||
|
<span>
|
||||||
|
{formatDurationTime(displayedTtsTime)} /{' '}
|
||||||
|
{formatDurationTime(ttsDuration || 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{ttsPanelOpen ? (
|
||||||
|
<div className='absolute inset-x-0 bottom-20 z-30 mx-auto w-[min(94vw,34rem)]'>
|
||||||
|
<div className='rounded-[2rem] border border-gray-200 bg-white/98 p-4 shadow-2xl backdrop-blur dark:border-gray-800 dark:bg-gray-950/98'>
|
||||||
|
<div className='mb-3 flex items-center justify-between'>
|
||||||
|
<div className='flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-gray-100'>
|
||||||
|
<Headphones className='h-4 w-4 text-emerald-500' />
|
||||||
|
听书控制
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => setTtsPanelOpen(false)}
|
||||||
|
className='flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 text-gray-600 dark:bg-gray-900 dark:text-gray-300'
|
||||||
|
>
|
||||||
|
<X className='h-4 w-4' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className='mb-4 flex items-center justify-between rounded-2xl bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-gray-900 dark:text-gray-300'>
|
||||||
|
<span className='truncate'>
|
||||||
|
{currentChunk?.text.slice(0, 28) || '当前章节可开始朗读'}
|
||||||
|
</span>
|
||||||
|
<span className='ml-2 shrink-0'>
|
||||||
|
{Math.round(ttsChunkPercent)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='mb-4 flex items-center justify-center gap-3'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
aria-label='上一段'
|
||||||
|
onClick={() => {
|
||||||
|
const next = Math.max(0, ttsCurrentChunkIndex - 1);
|
||||||
|
if (ttsChunks[next]) void playTtsChunk(next);
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
ttsCurrentChunkIndex <= 0 || ttsChunks.length === 0
|
||||||
|
}
|
||||||
|
className='flex h-11 w-11 items-center justify-center rounded-full bg-gray-100 text-gray-700 disabled:opacity-40 dark:bg-gray-900 dark:text-gray-200'
|
||||||
|
>
|
||||||
|
<SkipBack className='h-5 w-5' />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => void toggleTtsPlayback()}
|
||||||
|
disabled={!ttsAvailable || ttsLoadingChunkIndex !== null}
|
||||||
|
className='flex h-14 w-14 items-center justify-center rounded-full bg-emerald-600 text-white shadow-lg disabled:opacity-50'
|
||||||
|
>
|
||||||
|
{ttsLoadingChunkIndex !== null ? (
|
||||||
|
<Loader2 className='h-5 w-5 animate-spin' />
|
||||||
|
) : ttsStatus === 'playing' ? (
|
||||||
|
<Pause className='h-5 w-5' />
|
||||||
|
) : (
|
||||||
|
<Play className='h-5 w-5' />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
aria-label='下一段'
|
||||||
|
onClick={() => {
|
||||||
|
const next = ttsCurrentChunkIndex + 1;
|
||||||
|
if (ttsChunks[next]) void playTtsChunk(next);
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
ttsCurrentChunkIndex >= ttsChunks.length - 1 ||
|
||||||
|
ttsChunks.length === 0
|
||||||
|
}
|
||||||
|
className='flex h-11 w-11 items-center justify-center rounded-full bg-gray-100 text-gray-700 disabled:opacity-40 dark:bg-gray-900 dark:text-gray-200'
|
||||||
|
>
|
||||||
|
<SkipForward className='h-5 w-5' />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
aria-label='停止'
|
||||||
|
onClick={() => stopTts(true)}
|
||||||
|
disabled={ttsStatus === 'idle' && ttsChunks.length === 0}
|
||||||
|
className='flex h-11 w-11 items-center justify-center rounded-full bg-gray-100 text-gray-700 disabled:opacity-40 dark:bg-gray-900 dark:text-gray-200'
|
||||||
|
>
|
||||||
|
<Square className='h-4 w-4' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={ttsSettings.voice}
|
||||||
|
onChange={(e) => {
|
||||||
|
stopTts(true);
|
||||||
|
setTtsSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
voice: e.target.value,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className='mb-4 w-full rounded-2xl border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100'
|
||||||
|
>
|
||||||
|
{ttsVoices.map((voice) => (
|
||||||
|
<option key={voice.shortName} value={voice.shortName}>
|
||||||
|
{voice.displayName || voice.shortName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label className='mb-3 block text-xs text-gray-500'>
|
||||||
|
语速 {ttsSettings.rate}
|
||||||
|
<input
|
||||||
|
type='range'
|
||||||
|
min={0}
|
||||||
|
max={TTS_RATE_STEPS.length - 1}
|
||||||
|
step={1}
|
||||||
|
value={Math.max(0, TTS_RATE_STEPS.indexOf(ttsRateValue))}
|
||||||
|
onChange={(e) => {
|
||||||
|
stopTts(true);
|
||||||
|
setTtsSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
rate: formatSignedValue(
|
||||||
|
TTS_RATE_STEPS[Number(e.target.value)] ?? 0,
|
||||||
|
'%'
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className='w-full'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className='mb-3 block text-xs text-gray-500'>
|
||||||
|
音调 {ttsSettings.pitch}
|
||||||
|
<input
|
||||||
|
type='range'
|
||||||
|
min={0}
|
||||||
|
max={TTS_PITCH_STEPS.length - 1}
|
||||||
|
step={1}
|
||||||
|
value={Math.max(0, TTS_PITCH_STEPS.indexOf(ttsPitchValue))}
|
||||||
|
onChange={(e) => {
|
||||||
|
stopTts(true);
|
||||||
|
setTtsSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
pitch: formatSignedValue(
|
||||||
|
TTS_PITCH_STEPS[Number(e.target.value)] ?? 0,
|
||||||
|
'Hz'
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className='w-full'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className='block text-xs text-gray-500'>
|
||||||
|
音量 {ttsSettings.volume}
|
||||||
|
<input
|
||||||
|
type='range'
|
||||||
|
min={0}
|
||||||
|
max={TTS_VOLUME_STEPS.length - 1}
|
||||||
|
step={1}
|
||||||
|
value={Math.max(
|
||||||
|
0,
|
||||||
|
TTS_VOLUME_STEPS.indexOf(ttsVolumeValue)
|
||||||
|
)}
|
||||||
|
onChange={(e) => {
|
||||||
|
stopTts(true);
|
||||||
|
setTtsSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
volume: formatSignedValue(
|
||||||
|
TTS_VOLUME_STEPS[Number(e.target.value)] ?? 0,
|
||||||
|
'%'
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className='w-full'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{ttsError ? (
|
||||||
|
<div className='mt-3 text-xs text-red-500'>{ttsError}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -991,9 +1716,18 @@ function normalizeHrefForMatch(href?: string) {
|
|||||||
if (!href) return '';
|
if (!href) return '';
|
||||||
try {
|
try {
|
||||||
const normalized = decodeURIComponent(href).replace(/\\/g, '/').trim();
|
const normalized = decodeURIComponent(href).replace(/\\/g, '/').trim();
|
||||||
return normalized.split('#')[0].split('?')[0].replace(/^\.\//, '').replace(/^\//, '');
|
return normalized
|
||||||
|
.split('#')[0]
|
||||||
|
.split('?')[0]
|
||||||
|
.replace(/^\.\//, '')
|
||||||
|
.replace(/^\//, '');
|
||||||
} catch {
|
} catch {
|
||||||
return href.split('#')[0].split('?')[0].replace(/^\.\//, '').replace(/^\//, '').trim();
|
return href
|
||||||
|
.split('#')[0]
|
||||||
|
.split('?')[0]
|
||||||
|
.replace(/^\.\//, '')
|
||||||
|
.replace(/^\//, '')
|
||||||
|
.trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1001,7 +1735,9 @@ function isSameTocTarget(currentHref?: string, tocHref?: string) {
|
|||||||
const current = normalizeHrefForMatch(currentHref);
|
const current = normalizeHrefForMatch(currentHref);
|
||||||
const target = normalizeHrefForMatch(tocHref);
|
const target = normalizeHrefForMatch(tocHref);
|
||||||
if (!current || !target) return false;
|
if (!current || !target) return false;
|
||||||
return current === target || current.endsWith(target) || target.endsWith(current);
|
return (
|
||||||
|
current === target || current.endsWith(target) || target.endsWith(current)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(size: number): string {
|
function formatBytes(size: number): string {
|
||||||
@@ -1014,7 +1750,10 @@ function formatDurationTime(value: number) {
|
|||||||
const totalSeconds = Math.max(0, Math.floor(value || 0));
|
const totalSeconds = Math.max(0, Math.floor(value || 0));
|
||||||
const minutes = Math.floor(totalSeconds / 60);
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
const seconds = totalSeconds % 60;
|
const seconds = totalSeconds % 60;
|
||||||
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(
|
||||||
|
2,
|
||||||
|
'0'
|
||||||
|
)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeTtsText(text: string): string {
|
function sanitizeTtsText(text: string): string {
|
||||||
@@ -1031,7 +1770,10 @@ function chunkTtsText(text: string, maxChars: number): TtsChunk[] {
|
|||||||
const normalized = sanitizeTtsText(text);
|
const normalized = sanitizeTtsText(text);
|
||||||
if (!normalized) return [];
|
if (!normalized) return [];
|
||||||
|
|
||||||
const paragraphs = normalized.split(/\n{2,}/).map((item) => item.trim()).filter(Boolean);
|
const paragraphs = normalized
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
const chunks: TtsChunk[] = [];
|
const chunks: TtsChunk[] = [];
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
let start = 0;
|
let start = 0;
|
||||||
@@ -1070,7 +1812,10 @@ function chunkTtsText(text: string, maxChars: number): TtsChunk[] {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sentences = trimmed.split(/(?<=[。!?!?;;])/).map((item) => item.trim()).filter(Boolean);
|
const sentences = trimmed
|
||||||
|
.split(/(?<=[。!?!?;;])/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
let local = '';
|
let local = '';
|
||||||
let localStart = cursor;
|
let localStart = cursor;
|
||||||
for (const sentence of sentences) {
|
for (const sentence of sentences) {
|
||||||
@@ -1081,7 +1826,12 @@ function chunkTtsText(text: string, maxChars: number): TtsChunk[] {
|
|||||||
cursor += sentence.length;
|
cursor += sentence.length;
|
||||||
} else {
|
} else {
|
||||||
if (local) {
|
if (local) {
|
||||||
chunks.push({ index: chunks.length, text: local, start: localStart, end: localStart + local.length });
|
chunks.push({
|
||||||
|
index: chunks.length,
|
||||||
|
text: local,
|
||||||
|
start: localStart,
|
||||||
|
end: localStart + local.length,
|
||||||
|
});
|
||||||
local = '';
|
local = '';
|
||||||
}
|
}
|
||||||
if (sentence.length <= maxChars) {
|
if (sentence.length <= maxChars) {
|
||||||
@@ -1091,14 +1841,24 @@ function chunkTtsText(text: string, maxChars: number): TtsChunk[] {
|
|||||||
} else {
|
} else {
|
||||||
for (let i = 0; i < sentence.length; i += maxChars) {
|
for (let i = 0; i < sentence.length; i += maxChars) {
|
||||||
const part = sentence.slice(i, i + maxChars);
|
const part = sentence.slice(i, i + maxChars);
|
||||||
chunks.push({ index: chunks.length, text: part, start: cursor, end: cursor + part.length });
|
chunks.push({
|
||||||
|
index: chunks.length,
|
||||||
|
text: part,
|
||||||
|
start: cursor,
|
||||||
|
end: cursor + part.length,
|
||||||
|
});
|
||||||
cursor += part.length;
|
cursor += part.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (local) {
|
if (local) {
|
||||||
chunks.push({ index: chunks.length, text: local, start: localStart, end: localStart + local.length });
|
chunks.push({
|
||||||
|
index: chunks.length,
|
||||||
|
text: local,
|
||||||
|
start: localStart,
|
||||||
|
end: localStart + local.length,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1109,7 +1869,6 @@ function chunkTtsText(text: string, maxChars: number): TtsChunk[] {
|
|||||||
return chunks;
|
return chunks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function getRenditionOptions(mode: ReaderMode) {
|
function getRenditionOptions(mode: ReaderMode) {
|
||||||
return mode === 'scrolled'
|
return mode === 'scrolled'
|
||||||
? {
|
? {
|
||||||
@@ -1141,18 +1900,24 @@ export default function BookReadPage() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const sourceId = searchParams.get('sourceId') || '';
|
const sourceId = searchParams.get('sourceId') || '';
|
||||||
const bookId = searchParams.get('bookId') || '';
|
const bookId = searchParams.get('bookId') || '';
|
||||||
const cached = useMemo(() => (sourceId && bookId ? getBookRouteCache(sourceId, bookId) : null), [sourceId, bookId]);
|
const cached = useMemo(
|
||||||
|
() => (sourceId && bookId ? getBookRouteCache(sourceId, bookId) : null),
|
||||||
|
[sourceId, bookId]
|
||||||
|
);
|
||||||
const [manifest, setManifest] = useState<BookReadManifest | null>(null);
|
const [manifest, setManifest] = useState<BookReadManifest | null>(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [fileLoadState, setFileLoadState] = useState<FileLoadState>('preparing');
|
const [fileLoadState, setFileLoadState] =
|
||||||
|
useState<FileLoadState>('preparing');
|
||||||
const [downloadedBytes, setDownloadedBytes] = useState(0);
|
const [downloadedBytes, setDownloadedBytes] = useState(0);
|
||||||
const [totalBytes, setTotalBytes] = useState<number | null>(null);
|
const [totalBytes, setTotalBytes] = useState<number | null>(null);
|
||||||
const [cacheHit, setCacheHit] = useState(false);
|
const [cacheHit, setCacheHit] = useState(false);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [tocOpen, setTocOpen] = useState(false);
|
const [tocOpen, setTocOpen] = useState(false);
|
||||||
const [settings, setSettings] = useState<ReaderSettings>(DEFAULT_SETTINGS);
|
const [settings, setSettings] = useState<ReaderSettings>(DEFAULT_SETTINGS);
|
||||||
const [ttsSettings, setTtsSettings] = useState<TtsSettings>(() => loadTtsSettings());
|
const [ttsSettings, setTtsSettings] = useState<TtsSettings>(() =>
|
||||||
|
loadTtsSettings()
|
||||||
|
);
|
||||||
const [tocItems, setTocItems] = useState<TocItem[]>([]);
|
const [tocItems, setTocItems] = useState<TocItem[]>([]);
|
||||||
const [currentHref, setCurrentHref] = useState('');
|
const [currentHref, setCurrentHref] = useState('');
|
||||||
const [currentChapter, setCurrentChapter] = useState('');
|
const [currentChapter, setCurrentChapter] = useState('');
|
||||||
@@ -1165,7 +1930,9 @@ export default function BookReadPage() {
|
|||||||
const [ttsError, setTtsError] = useState('');
|
const [ttsError, setTtsError] = useState('');
|
||||||
const [ttsChunks, setTtsChunks] = useState<TtsChunk[]>([]);
|
const [ttsChunks, setTtsChunks] = useState<TtsChunk[]>([]);
|
||||||
const [ttsCurrentChunkIndex, setTtsCurrentChunkIndex] = useState(0);
|
const [ttsCurrentChunkIndex, setTtsCurrentChunkIndex] = useState(0);
|
||||||
const [ttsLoadingChunkIndex, setTtsLoadingChunkIndex] = useState<number | null>(null);
|
const [ttsLoadingChunkIndex, setTtsLoadingChunkIndex] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
const [ttsCurrentChapterHref, setTtsCurrentChapterHref] = useState('');
|
const [ttsCurrentChapterHref, setTtsCurrentChapterHref] = useState('');
|
||||||
const [ttsCurrentChapterTitle, setTtsCurrentChapterTitle] = useState('');
|
const [ttsCurrentChapterTitle, setTtsCurrentChapterTitle] = useState('');
|
||||||
const [ttsBarVisible, setTtsBarVisible] = useState(false);
|
const [ttsBarVisible, setTtsBarVisible] = useState(false);
|
||||||
@@ -1176,7 +1943,9 @@ export default function BookReadPage() {
|
|||||||
const [ttsSeeking, setTtsSeeking] = useState(false);
|
const [ttsSeeking, setTtsSeeking] = useState(false);
|
||||||
const [scrolledBottomReached, setScrolledBottomReached] = useState(false);
|
const [scrolledBottomReached, setScrolledBottomReached] = useState(false);
|
||||||
const viewerRef = useRef<HTMLDivElement | null>(null);
|
const viewerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const pendingScrolledRestoreRef = useRef<ScrolledReadingPosition | null>(null);
|
const pendingScrolledRestoreRef = useRef<ScrolledReadingPosition | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
const restoreTargetRef = useRef<string | undefined>(undefined);
|
const restoreTargetRef = useRef<string | undefined>(undefined);
|
||||||
const scrollListenerCleanupRef = useRef<(() => void) | null>(null);
|
const scrollListenerCleanupRef = useRef<(() => void) | null>(null);
|
||||||
const scrolledAutoAdvanceLockRef = useRef(false);
|
const scrolledAutoAdvanceLockRef = useRef(false);
|
||||||
@@ -1201,7 +1970,9 @@ export default function BookReadPage() {
|
|||||||
const currentHrefRef = useRef('');
|
const currentHrefRef = useRef('');
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const ttsChunkAudioUrlRef = useRef<Record<number, string>>({});
|
const ttsChunkAudioUrlRef = useRef<Record<number, string>>({});
|
||||||
const ttsChunkBlobCacheRef = useRef<Record<number, { url: string; text: string }>>({});
|
const ttsChunkBlobCacheRef = useRef<
|
||||||
|
Record<number, { url: string; text: string }>
|
||||||
|
>({});
|
||||||
const ttsChunksRef = useRef<TtsChunk[]>([]);
|
const ttsChunksRef = useRef<TtsChunk[]>([]);
|
||||||
const ttsSettingsRef = useRef<TtsSettings>(DEFAULT_TTS_SETTINGS);
|
const ttsSettingsRef = useRef<TtsSettings>(DEFAULT_TTS_SETTINGS);
|
||||||
const ttsCurrentChunkIndexRef = useRef(0);
|
const ttsCurrentChunkIndexRef = useRef(0);
|
||||||
@@ -1226,7 +1997,10 @@ export default function BookReadPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.setItem(TTS_SETTINGS_STORAGE_KEY, JSON.stringify(ttsSettings));
|
localStorage.setItem(
|
||||||
|
TTS_SETTINGS_STORAGE_KEY,
|
||||||
|
JSON.stringify(ttsSettings)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
ttsSettingsRef.current = ttsSettings;
|
ttsSettingsRef.current = ttsSettings;
|
||||||
}, [ttsSettings]);
|
}, [ttsSettings]);
|
||||||
@@ -1250,7 +2024,6 @@ export default function BookReadPage() {
|
|||||||
scrolledBottomReachedRef.current = scrolledBottomReached;
|
scrolledBottomReachedRef.current = scrolledBottomReached;
|
||||||
}, [scrolledBottomReached]);
|
}, [scrolledBottomReached]);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleToggleSettings = () => {
|
const handleToggleSettings = () => {
|
||||||
if (manifest?.format === 'chapters') return;
|
if (manifest?.format === 'chapters') return;
|
||||||
@@ -1260,7 +2033,10 @@ export default function BookReadPage() {
|
|||||||
|
|
||||||
window.addEventListener('books-read-toggle-settings', handleToggleSettings);
|
window.addEventListener('books-read-toggle-settings', handleToggleSettings);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('books-read-toggle-settings', handleToggleSettings);
|
window.removeEventListener(
|
||||||
|
'books-read-toggle-settings',
|
||||||
|
handleToggleSettings
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}, [manifest?.format]);
|
}, [manifest?.format]);
|
||||||
|
|
||||||
@@ -1273,7 +2049,10 @@ export default function BookReadPage() {
|
|||||||
|
|
||||||
window.addEventListener('books-read-toggle-chapters', handleToggleChapters);
|
window.addEventListener('books-read-toggle-chapters', handleToggleChapters);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('books-read-toggle-chapters', handleToggleChapters);
|
window.removeEventListener(
|
||||||
|
'books-read-toggle-chapters',
|
||||||
|
handleToggleChapters
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}, [manifest?.format]);
|
}, [manifest?.format]);
|
||||||
|
|
||||||
@@ -1297,7 +2076,6 @@ export default function BookReadPage() {
|
|||||||
};
|
};
|
||||||
}, [manifest?.format]);
|
}, [manifest?.format]);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sourceId || !bookId) return;
|
if (!sourceId || !bookId) return;
|
||||||
fetch('/api/books/read/manifest', {
|
fetch('/api/books/read/manifest', {
|
||||||
@@ -1342,7 +2120,10 @@ export default function BookReadPage() {
|
|||||||
setTtsAvailable(true);
|
setTtsAvailable(true);
|
||||||
setTtsVoices(json.voices || []);
|
setTtsVoices(json.voices || []);
|
||||||
setTtsSettings((prev) => applyTtsDefaults(prev, json.defaults));
|
setTtsSettings((prev) => applyTtsDefaults(prev, json.defaults));
|
||||||
saveCachedTtsVoices({ voices: json.voices || [], defaults: json.defaults || {} });
|
saveCachedTtsVoices({
|
||||||
|
voices: json.voices || [],
|
||||||
|
defaults: json.defaults || {},
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -1355,7 +2136,12 @@ export default function BookReadPage() {
|
|||||||
};
|
};
|
||||||
}, [manifest]);
|
}, [manifest]);
|
||||||
|
|
||||||
const buildReadRecord = useCallback((location: EpubLocation, nextProgress = 0, chapterTitle?: string): BookReadRecord | null => {
|
const buildReadRecord = useCallback(
|
||||||
|
(
|
||||||
|
location: EpubLocation,
|
||||||
|
nextProgress = 0,
|
||||||
|
chapterTitle?: string
|
||||||
|
): BookReadRecord | null => {
|
||||||
if (!manifest) return null;
|
if (!manifest) return null;
|
||||||
const locatorValue = location?.start?.cfi || location?.end?.cfi || '';
|
const locatorValue = location?.start?.cfi || location?.end?.cfi || '';
|
||||||
if (!locatorValue) return null;
|
if (!locatorValue) return null;
|
||||||
@@ -1380,18 +2166,24 @@ export default function BookReadPage() {
|
|||||||
progressPercent: nextProgress,
|
progressPercent: nextProgress,
|
||||||
saveTime: Date.now(),
|
saveTime: Date.now(),
|
||||||
};
|
};
|
||||||
}, [manifest]);
|
},
|
||||||
|
[manifest]
|
||||||
|
);
|
||||||
|
|
||||||
const queueReadRecord = useCallback((location: EpubLocation, nextProgress = 0, chapterTitle?: string) => {
|
const queueReadRecord = useCallback(
|
||||||
|
(location: EpubLocation, nextProgress = 0, chapterTitle?: string) => {
|
||||||
const record = buildReadRecord(location, nextProgress, chapterTitle);
|
const record = buildReadRecord(location, nextProgress, chapterTitle);
|
||||||
if (!record) return;
|
if (!record) return;
|
||||||
pendingRecordRef.current = record;
|
pendingRecordRef.current = record;
|
||||||
pendingRecordDirtyRef.current = true;
|
pendingRecordDirtyRef.current = true;
|
||||||
}, [buildReadRecord]);
|
},
|
||||||
|
[buildReadRecord]
|
||||||
|
);
|
||||||
|
|
||||||
const flushPendingReadRecord = useCallback(async () => {
|
const flushPendingReadRecord = useCallback(async () => {
|
||||||
const record = pendingRecordRef.current;
|
const record = pendingRecordRef.current;
|
||||||
if (!record || !pendingRecordDirtyRef.current || saveInFlightRef.current) return;
|
if (!record || !pendingRecordDirtyRef.current || saveInFlightRef.current)
|
||||||
|
return;
|
||||||
|
|
||||||
saveInFlightRef.current = true;
|
saveInFlightRef.current = true;
|
||||||
try {
|
try {
|
||||||
@@ -1408,11 +2200,15 @@ export default function BookReadPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const persistScrolledPosition = useCallback(
|
||||||
const persistScrolledPosition = useCallback((fallbackHref?: string) => {
|
(fallbackHref?: string) => {
|
||||||
if (!manifest || settingsRef.current.mode !== 'scrolled') return;
|
if (!manifest || settingsRef.current.mode !== 'scrolled') return;
|
||||||
const metrics = getIframeScrollMetrics(viewerRef.current);
|
const metrics = getIframeScrollMetrics(viewerRef.current);
|
||||||
const href = fallbackHref || currentHrefRef.current || lastLocationRef.current?.start?.href || '';
|
const href =
|
||||||
|
fallbackHref ||
|
||||||
|
currentHrefRef.current ||
|
||||||
|
lastLocationRef.current?.start?.href ||
|
||||||
|
'';
|
||||||
if (!metrics || !href) return;
|
if (!metrics || !href) return;
|
||||||
saveScrolledPosition(manifest.book.sourceId, manifest.book.id, {
|
saveScrolledPosition(manifest.book.sourceId, manifest.book.id, {
|
||||||
href,
|
href,
|
||||||
@@ -1421,8 +2217,9 @@ export default function BookReadPage() {
|
|||||||
clientHeight: metrics.clientHeight,
|
clientHeight: metrics.clientHeight,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
});
|
});
|
||||||
}, [manifest]);
|
},
|
||||||
|
[manifest]
|
||||||
|
);
|
||||||
|
|
||||||
const applyPendingScrolledRestore = useCallback(() => {
|
const applyPendingScrolledRestore = useCallback(() => {
|
||||||
if (settingsRef.current.mode !== 'scrolled') return;
|
if (settingsRef.current.mode !== 'scrolled') return;
|
||||||
@@ -1430,25 +2227,30 @@ export default function BookReadPage() {
|
|||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
const metrics = getIframeScrollMetrics(viewerRef.current);
|
const metrics = getIframeScrollMetrics(viewerRef.current);
|
||||||
if (!metrics) return;
|
if (!metrics) return;
|
||||||
const currentHrefValue = lastLocationRef.current?.start?.href || currentHrefRef.current;
|
const currentHrefValue =
|
||||||
if (!currentHrefValue || !isSameTocTarget(currentHrefValue, pending.href)) return;
|
lastLocationRef.current?.start?.href || currentHrefRef.current;
|
||||||
const targetScrollTop = computeScrolledTargetScrollTop(pending, metrics.scrollHeight, metrics.clientHeight);
|
if (!currentHrefValue || !isSameTocTarget(currentHrefValue, pending.href))
|
||||||
|
return;
|
||||||
|
const targetScrollTop = computeScrolledTargetScrollTop(
|
||||||
|
pending,
|
||||||
|
metrics.scrollHeight,
|
||||||
|
metrics.clientHeight
|
||||||
|
);
|
||||||
metrics.setScrollTop(targetScrollTop);
|
metrics.setScrollTop(targetScrollTop);
|
||||||
pendingScrolledRestoreRef.current = null;
|
pendingScrolledRestoreRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyPendingScrolledRestoreRef.current = applyPendingScrolledRestore;
|
applyPendingScrolledRestoreRef.current = applyPendingScrolledRestore;
|
||||||
}, [applyPendingScrolledRestore]);
|
}, [applyPendingScrolledRestore]);
|
||||||
|
|
||||||
const persistCurrentProgress = useCallback(() => {
|
const persistCurrentProgress = useCallback(() => {
|
||||||
if (lastLocationRef.current) {
|
if (lastLocationRef.current) {
|
||||||
queueReadRecord(lastLocationRef.current, lastProgressRef.current, lastChapterRef.current);
|
queueReadRecord(
|
||||||
|
lastLocationRef.current,
|
||||||
|
lastProgressRef.current,
|
||||||
|
lastChapterRef.current
|
||||||
|
);
|
||||||
}
|
}
|
||||||
persistScrolledPosition();
|
persistScrolledPosition();
|
||||||
void flushPendingReadRecord();
|
void flushPendingReadRecord();
|
||||||
@@ -1483,7 +2285,8 @@ export default function BookReadPage() {
|
|||||||
await renditionRef.current.display(target);
|
await renditionRef.current.display(target);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleReaderTap = useCallback((zone: 'left' | 'center' | 'right') => {
|
const handleReaderTap = useCallback(
|
||||||
|
(zone: 'left' | 'center' | 'right') => {
|
||||||
if (!ready) return;
|
if (!ready) return;
|
||||||
if (settings.mode === 'paginated' && zone === 'left') {
|
if (settings.mode === 'paginated' && zone === 'left') {
|
||||||
renditionRef.current?.prev?.();
|
renditionRef.current?.prev?.();
|
||||||
@@ -1495,15 +2298,20 @@ export default function BookReadPage() {
|
|||||||
}
|
}
|
||||||
setTocOpen(false);
|
setTocOpen(false);
|
||||||
setSettingsOpen(false);
|
setSettingsOpen(false);
|
||||||
}, [ready, settings.mode]);
|
},
|
||||||
|
[ready, settings.mode]
|
||||||
|
);
|
||||||
|
|
||||||
const cleanupTtsAudioUrls = useCallback(() => {
|
const cleanupTtsAudioUrls = useCallback(() => {
|
||||||
Object.values(ttsChunkBlobCacheRef.current).forEach((item) => URL.revokeObjectURL(item.url));
|
Object.values(ttsChunkBlobCacheRef.current).forEach((item) =>
|
||||||
|
URL.revokeObjectURL(item.url)
|
||||||
|
);
|
||||||
ttsChunkBlobCacheRef.current = {};
|
ttsChunkBlobCacheRef.current = {};
|
||||||
ttsChunkAudioUrlRef.current = {};
|
ttsChunkAudioUrlRef.current = {};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const stopTts = useCallback((clearQueue = false) => {
|
const stopTts = useCallback(
|
||||||
|
(clearQueue = false) => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
if (audio) {
|
if (audio) {
|
||||||
audio.pause();
|
audio.pause();
|
||||||
@@ -1528,14 +2336,22 @@ export default function BookReadPage() {
|
|||||||
ttsCurrentChapterTitleRef.current = '';
|
ttsCurrentChapterTitleRef.current = '';
|
||||||
cleanupTtsAudioUrls();
|
cleanupTtsAudioUrls();
|
||||||
}
|
}
|
||||||
}, [cleanupTtsAudioUrls]);
|
},
|
||||||
|
[cleanupTtsAudioUrls]
|
||||||
|
);
|
||||||
|
|
||||||
const persistTtsProgress = useCallback((chunkIndex?: number) => {
|
const persistTtsProgress = useCallback(
|
||||||
|
(chunkIndex?: number) => {
|
||||||
if (!manifest) return;
|
if (!manifest) return;
|
||||||
const chunks = ttsChunksRef.current;
|
const chunks = ttsChunksRef.current;
|
||||||
const currentIndex = chunkIndex ?? ttsCurrentChunkIndexRef.current;
|
const currentIndex = chunkIndex ?? ttsCurrentChunkIndexRef.current;
|
||||||
const chunk = chunks[currentIndex];
|
const chunk = chunks[currentIndex];
|
||||||
if (!chunk || !ttsCurrentChapterHrefRef.current || !ttsSettingsRef.current.voice) return;
|
if (
|
||||||
|
!chunk ||
|
||||||
|
!ttsCurrentChapterHrefRef.current ||
|
||||||
|
!ttsSettingsRef.current.voice
|
||||||
|
)
|
||||||
|
return;
|
||||||
const progress: BookTtsProgress = {
|
const progress: BookTtsProgress = {
|
||||||
sourceId: manifest.book.sourceId,
|
sourceId: manifest.book.sourceId,
|
||||||
bookId: manifest.book.id,
|
bookId: manifest.book.id,
|
||||||
@@ -1551,16 +2367,20 @@ export default function BookReadPage() {
|
|||||||
saveTime: Date.now(),
|
saveTime: Date.now(),
|
||||||
};
|
};
|
||||||
saveBookTtsProgress(progress);
|
saveBookTtsProgress(progress);
|
||||||
}, [manifest, currentChapter]);
|
},
|
||||||
|
[manifest, currentChapter]
|
||||||
|
);
|
||||||
|
|
||||||
const getCurrentSpineDocumentText = useCallback(() => {
|
const getCurrentSpineDocumentText = useCallback(() => {
|
||||||
const iframe = viewerRef.current?.querySelector('iframe');
|
const iframe = viewerRef.current?.querySelector('iframe');
|
||||||
const doc = iframe?.contentDocument;
|
const doc = iframe?.contentDocument;
|
||||||
const text = doc?.body?.innerText || doc?.documentElement?.textContent || '';
|
const text =
|
||||||
|
doc?.body?.innerText || doc?.documentElement?.textContent || '';
|
||||||
return sanitizeTtsText(text);
|
return sanitizeTtsText(text);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchTtsChunkAudioUrl = useCallback(async (chunk: TtsChunk, chapterHref: string) => {
|
const fetchTtsChunkAudioUrl = useCallback(
|
||||||
|
async (chunk: TtsChunk, chapterHref: string) => {
|
||||||
const cached = ttsChunkBlobCacheRef.current[chunk.index];
|
const cached = ttsChunkBlobCacheRef.current[chunk.index];
|
||||||
if (cached?.text === chunk.text) return cached.url;
|
if (cached?.text === chunk.text) return cached.url;
|
||||||
if (!manifest) throw new Error('书籍信息未准备好');
|
if (!manifest) throw new Error('书籍信息未准备好');
|
||||||
@@ -1601,7 +2421,10 @@ export default function BookReadPage() {
|
|||||||
});
|
});
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
if (!response.ok) throw new Error(json.error || '朗读音频生成失败');
|
if (!response.ok) throw new Error(json.error || '朗读音频生成失败');
|
||||||
const blob = decodeBase64Audio(json.audioBase64 || '', json.mimeType || 'audio/mpeg');
|
const blob = decodeBase64Audio(
|
||||||
|
json.audioBase64 || '',
|
||||||
|
json.mimeType || 'audio/mpeg'
|
||||||
|
);
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
if (cached?.url) URL.revokeObjectURL(cached.url);
|
if (cached?.url) URL.revokeObjectURL(cached.url);
|
||||||
ttsChunkBlobCacheRef.current[chunk.index] = { url, text: chunk.text };
|
ttsChunkBlobCacheRef.current[chunk.index] = { url, text: chunk.text };
|
||||||
@@ -1627,9 +2450,12 @@ export default function BookReadPage() {
|
|||||||
.then(() => enforceBookTtsCacheLimit())
|
.then(() => enforceBookTtsCacheLimit())
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
return url;
|
return url;
|
||||||
}, [manifest]);
|
},
|
||||||
|
[manifest]
|
||||||
|
);
|
||||||
|
|
||||||
const prefetchTtsChunks = useCallback((fromIndex: number) => {
|
const prefetchTtsChunks = useCallback(
|
||||||
|
(fromIndex: number) => {
|
||||||
const chunks = ttsChunksRef.current;
|
const chunks = ttsChunksRef.current;
|
||||||
const chapterHref = ttsCurrentChapterHrefRef.current;
|
const chapterHref = ttsCurrentChapterHrefRef.current;
|
||||||
if (!ttsSettingsRef.current.autoPlayNext || !chapterHref) return;
|
if (!ttsSettingsRef.current.autoPlayNext || !chapterHref) return;
|
||||||
@@ -1637,14 +2463,19 @@ export default function BookReadPage() {
|
|||||||
const nextIndex = fromIndex + 1;
|
const nextIndex = fromIndex + 1;
|
||||||
if (nextIndex >= chunks.length) return;
|
if (nextIndex >= chunks.length) return;
|
||||||
ttsPrefetchedFromChunkRef.current = fromIndex;
|
ttsPrefetchedFromChunkRef.current = fromIndex;
|
||||||
void fetchTtsChunkAudioUrl(chunks[nextIndex], chapterHref).catch(() => undefined);
|
void fetchTtsChunkAudioUrl(chunks[nextIndex], chapterHref).catch(
|
||||||
}, [fetchTtsChunkAudioUrl]);
|
() => undefined
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[fetchTtsChunkAudioUrl]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ttsPrefetchFnRef.current = prefetchTtsChunks;
|
ttsPrefetchFnRef.current = prefetchTtsChunks;
|
||||||
}, [prefetchTtsChunks]);
|
}, [prefetchTtsChunks]);
|
||||||
|
|
||||||
const playTtsChunk = useCallback(async (index: number) => {
|
const playTtsChunk = useCallback(
|
||||||
|
async (index: number) => {
|
||||||
const chunks = ttsChunksRef.current;
|
const chunks = ttsChunksRef.current;
|
||||||
const chunk = chunks[index];
|
const chunk = chunks[index];
|
||||||
const chapterHref = ttsCurrentChapterHrefRef.current;
|
const chapterHref = ttsCurrentChapterHrefRef.current;
|
||||||
@@ -1659,7 +2490,10 @@ export default function BookReadPage() {
|
|||||||
}
|
}
|
||||||
audioRef.current.src = url;
|
audioRef.current.src = url;
|
||||||
ttsResumeTimeRef.current = 0;
|
ttsResumeTimeRef.current = 0;
|
||||||
const saved = getBookTtsProgress(manifest.book.sourceId, manifest.book.id);
|
const saved = getBookTtsProgress(
|
||||||
|
manifest.book.sourceId,
|
||||||
|
manifest.book.id
|
||||||
|
);
|
||||||
if (saved?.chapterHref === chapterHref && saved.chunkIndex === index) {
|
if (saved?.chapterHref === chapterHref && saved.chunkIndex === index) {
|
||||||
ttsResumeTimeRef.current = saved.currentTimeSec || 0;
|
ttsResumeTimeRef.current = saved.currentTimeSec || 0;
|
||||||
}
|
}
|
||||||
@@ -1674,12 +2508,18 @@ export default function BookReadPage() {
|
|||||||
setTtsLoadingChunkIndex(null);
|
setTtsLoadingChunkIndex(null);
|
||||||
setTtsError((error as Error).message || '朗读失败');
|
setTtsError((error as Error).message || '朗读失败');
|
||||||
}
|
}
|
||||||
}, [fetchTtsChunkAudioUrl, manifest, persistTtsProgress]);
|
},
|
||||||
|
[fetchTtsChunkAudioUrl, manifest, persistTtsProgress]
|
||||||
|
);
|
||||||
|
|
||||||
const bootstrapTtsForCurrentChapter = useCallback(async (resume = true) => {
|
const bootstrapTtsForCurrentChapter = useCallback(
|
||||||
|
async (resume = true) => {
|
||||||
if (!manifest || manifest.format !== 'epub') return;
|
if (!manifest || manifest.format !== 'epub') return;
|
||||||
const chapterHref = currentHref || manifest.lastRecord?.chapterHref || '';
|
const chapterHref = currentHref || manifest.lastRecord?.chapterHref || '';
|
||||||
const chapterTitle = findTocLabelByHref(tocItemsRef.current, chapterHref) || currentChapter || manifest.book.title;
|
const chapterTitle =
|
||||||
|
findTocLabelByHref(tocItemsRef.current, chapterHref) ||
|
||||||
|
currentChapter ||
|
||||||
|
manifest.book.title;
|
||||||
if (!chapterHref) {
|
if (!chapterHref) {
|
||||||
setTtsError('当前章节尚未定位,稍后再试');
|
setTtsError('当前章节尚未定位,稍后再试');
|
||||||
setTtsStatus('error');
|
setTtsStatus('error');
|
||||||
@@ -1698,8 +2538,13 @@ export default function BookReadPage() {
|
|||||||
setTtsStatus('error');
|
setTtsStatus('error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const saved = resume ? getBookTtsProgress(manifest.book.sourceId, manifest.book.id) : null;
|
const saved = resume
|
||||||
const startIndex = saved?.chapterHref === chapterHref ? Math.min(saved.chunkIndex, chunks.length - 1) : 0;
|
? getBookTtsProgress(manifest.book.sourceId, manifest.book.id)
|
||||||
|
: null;
|
||||||
|
const startIndex =
|
||||||
|
saved?.chapterHref === chapterHref
|
||||||
|
? Math.min(saved.chunkIndex, chunks.length - 1)
|
||||||
|
: 0;
|
||||||
setTtsChunks(chunks);
|
setTtsChunks(chunks);
|
||||||
ttsChunksRef.current = chunks;
|
ttsChunksRef.current = chunks;
|
||||||
setTtsCurrentChunkIndex(startIndex);
|
setTtsCurrentChunkIndex(startIndex);
|
||||||
@@ -1709,7 +2554,16 @@ export default function BookReadPage() {
|
|||||||
setTtsCurrentChapterTitle(chapterTitle);
|
setTtsCurrentChapterTitle(chapterTitle);
|
||||||
ttsCurrentChapterTitleRef.current = chapterTitle;
|
ttsCurrentChapterTitleRef.current = chapterTitle;
|
||||||
await playTtsChunk(startIndex);
|
await playTtsChunk(startIndex);
|
||||||
}, [cleanupTtsAudioUrls, currentChapter, currentHref, getCurrentSpineDocumentText, manifest, playTtsChunk]);
|
},
|
||||||
|
[
|
||||||
|
cleanupTtsAudioUrls,
|
||||||
|
currentChapter,
|
||||||
|
currentHref,
|
||||||
|
getCurrentSpineDocumentText,
|
||||||
|
manifest,
|
||||||
|
playTtsChunk,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
const toggleTtsPlayback = useCallback(async () => {
|
const toggleTtsPlayback = useCallback(async () => {
|
||||||
if (!ttsAvailable) return;
|
if (!ttsAvailable) return;
|
||||||
@@ -1730,45 +2584,69 @@ export default function BookReadPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await bootstrapTtsForCurrentChapter(true);
|
await bootstrapTtsForCurrentChapter(true);
|
||||||
}, [bootstrapTtsForCurrentChapter, persistTtsProgress, ttsAvailable, ttsStatus]);
|
}, [
|
||||||
|
bootstrapTtsForCurrentChapter,
|
||||||
|
persistTtsProgress,
|
||||||
|
ttsAvailable,
|
||||||
|
ttsStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!manifest || manifest.format !== 'epub' || !viewerRef.current) return;
|
if (!manifest || manifest.format !== 'epub' || !viewerRef.current) return;
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
const currentSessionCfi = lastLocationRef.current?.start?.cfi || undefined;
|
const currentSessionCfi = lastLocationRef.current?.start?.cfi || undefined;
|
||||||
const currentSessionHref = currentHrefRef.current || lastLocationRef.current?.start?.href || undefined;
|
const currentSessionHref =
|
||||||
|
currentHrefRef.current ||
|
||||||
|
lastLocationRef.current?.start?.href ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
setReady(false);
|
setReady(false);
|
||||||
setRestoredMessage('');
|
setRestoredMessage('');
|
||||||
locationsReadyRef.current = false;
|
locationsReadyRef.current = false;
|
||||||
lastLocationRef.current = null;
|
lastLocationRef.current = null;
|
||||||
setProgressPercent(manifest.lastRecord?.progressPercent || 0);
|
setProgressPercent(manifest.lastRecord?.progressPercent || 0);
|
||||||
setCurrentChapter(manifest.lastRecord?.chapterTitle || manifest.lastRecord?.locator?.chapterTitle || '');
|
setCurrentChapter(
|
||||||
|
manifest.lastRecord?.chapterTitle ||
|
||||||
|
manifest.lastRecord?.locator?.chapterTitle ||
|
||||||
|
''
|
||||||
|
);
|
||||||
setFileLoadState('checking-cache');
|
setFileLoadState('checking-cache');
|
||||||
setDownloadedBytes(0);
|
setDownloadedBytes(0);
|
||||||
setTotalBytes(null);
|
setTotalBytes(null);
|
||||||
setCacheHit(false);
|
setCacheHit(false);
|
||||||
|
|
||||||
const initialScrolledHref = currentSessionHref || manifest.lastRecord?.chapterHref || manifest.lastRecord?.locator?.href || undefined;
|
const initialScrolledHref =
|
||||||
const cachedScrolledPosition = initialScrolledHref ? getScrolledPosition(manifest.book.sourceId, manifest.book.id, initialScrolledHref) : null;
|
currentSessionHref ||
|
||||||
pendingScrolledRestoreRef.current = settings.mode === 'scrolled' && !currentSessionHref ? cachedScrolledPosition : null;
|
manifest.lastRecord?.chapterHref ||
|
||||||
restoreTargetRef.current = settings.mode === 'scrolled'
|
manifest.lastRecord?.locator?.href ||
|
||||||
? (initialScrolledHref || cachedScrolledPosition?.href || undefined)
|
undefined;
|
||||||
: (currentSessionCfi || manifest.lastRecord?.locator?.value || undefined);
|
const cachedScrolledPosition = initialScrolledHref
|
||||||
|
? getScrolledPosition(
|
||||||
|
manifest.book.sourceId,
|
||||||
|
manifest.book.id,
|
||||||
|
initialScrolledHref
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
pendingScrolledRestoreRef.current =
|
||||||
|
settings.mode === 'scrolled' && !currentSessionHref
|
||||||
|
? cachedScrolledPosition
|
||||||
|
: null;
|
||||||
|
restoreTargetRef.current =
|
||||||
|
settings.mode === 'scrolled'
|
||||||
|
? initialScrolledHref || cachedScrolledPosition?.href || undefined
|
||||||
|
: currentSessionCfi || manifest.lastRecord?.locator?.value || undefined;
|
||||||
|
|
||||||
loadEpubScript()
|
loadEpubScript()
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
if (!window.ePub || destroyed || !viewerRef.current) return;
|
if (!window.ePub || destroyed || !viewerRef.current) return;
|
||||||
|
|
||||||
const cacheKey = manifest.cacheKey || buildBookCacheKey(
|
const cacheKey =
|
||||||
|
manifest.cacheKey ||
|
||||||
|
buildBookCacheKey(
|
||||||
manifest.book.sourceId,
|
manifest.book.sourceId,
|
||||||
manifest.book.id,
|
manifest.book.id,
|
||||||
manifest.acquisitionHref || `${manifest.book.sourceId}:${manifest.book.id}:${manifest.format}`
|
manifest.acquisitionHref ||
|
||||||
|
`${manifest.book.sourceId}:${manifest.book.id}:${manifest.format}`
|
||||||
);
|
);
|
||||||
|
|
||||||
let fileBuffer: ArrayBuffer;
|
let fileBuffer: ArrayBuffer;
|
||||||
@@ -1782,12 +2660,15 @@ export default function BookReadPage() {
|
|||||||
fileBuffer = await cached.blob.arrayBuffer();
|
fileBuffer = await cached.blob.arrayBuffer();
|
||||||
} else {
|
} else {
|
||||||
setFileLoadState('downloading');
|
setFileLoadState('downloading');
|
||||||
const blob = await downloadBookWithProgress(manifest, (received, total) => {
|
const blob = await downloadBookWithProgress(
|
||||||
|
manifest,
|
||||||
|
(received, total) => {
|
||||||
if (!destroyed) {
|
if (!destroyed) {
|
||||||
setDownloadedBytes(received);
|
setDownloadedBytes(received);
|
||||||
setTotalBytes(total);
|
setTotalBytes(total);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
);
|
||||||
fileBuffer = await blob.arrayBuffer();
|
fileBuffer = await blob.arrayBuffer();
|
||||||
await putCachedBookFile({
|
await putCachedBookFile({
|
||||||
key: cacheKey,
|
key: cacheKey,
|
||||||
@@ -1795,7 +2676,9 @@ export default function BookReadPage() {
|
|||||||
bookId: manifest.book.id,
|
bookId: manifest.book.id,
|
||||||
title: manifest.book.title,
|
title: manifest.book.title,
|
||||||
format: 'epub',
|
format: 'epub',
|
||||||
acquisitionHref: manifest.acquisitionHref || `${manifest.book.sourceId}:${manifest.book.id}:${manifest.format}`,
|
acquisitionHref:
|
||||||
|
manifest.acquisitionHref ||
|
||||||
|
`${manifest.book.sourceId}:${manifest.book.id}:${manifest.format}`,
|
||||||
blob,
|
blob,
|
||||||
size: blob.size,
|
size: blob.size,
|
||||||
mimeType: blob.type || 'application/epub+zip',
|
mimeType: blob.type || 'application/epub+zip',
|
||||||
@@ -1816,7 +2699,10 @@ export default function BookReadPage() {
|
|||||||
}
|
}
|
||||||
}, 4000);
|
}, 4000);
|
||||||
|
|
||||||
const rendition = book.renderTo(viewerRef.current, getRenditionOptions(settings.mode));
|
const rendition = book.renderTo(
|
||||||
|
viewerRef.current,
|
||||||
|
getRenditionOptions(settings.mode)
|
||||||
|
);
|
||||||
bookRef.current = book;
|
bookRef.current = book;
|
||||||
renditionRef.current = rendition;
|
renditionRef.current = rendition;
|
||||||
applyReaderTheme(settingsRef.current);
|
applyReaderTheme(settingsRef.current);
|
||||||
@@ -1832,7 +2718,11 @@ export default function BookReadPage() {
|
|||||||
}
|
}
|
||||||
if (restoreTarget && !restoreMessageShown) {
|
if (restoreTarget && !restoreMessageShown) {
|
||||||
restoreMessageShown = true;
|
restoreMessageShown = true;
|
||||||
setRestoredMessage(`已恢复到上次阅读位置(约 ${Math.round(manifest.lastRecord?.progressPercent || 0)}%)`);
|
setRestoredMessage(
|
||||||
|
`已恢复到上次阅读位置(约 ${Math.round(
|
||||||
|
manifest.lastRecord?.progressPercent || 0
|
||||||
|
)}%)`
|
||||||
|
);
|
||||||
window.setTimeout(() => setRestoredMessage(''), 3000);
|
window.setTimeout(() => setRestoredMessage(''), 3000);
|
||||||
}
|
}
|
||||||
lastLocationRef.current = location;
|
lastLocationRef.current = location;
|
||||||
@@ -1842,13 +2732,30 @@ export default function BookReadPage() {
|
|||||||
bindScrolledIframeListenerRef.current();
|
bindScrolledIframeListenerRef.current();
|
||||||
applyPendingScrolledRestoreRef.current();
|
applyPendingScrolledRestoreRef.current();
|
||||||
});
|
});
|
||||||
const hrefLabel = location?.start?.href ? findTocLabelByHref(tocItemsRef.current, location.start.href) : '';
|
const hrefLabel = location?.start?.href
|
||||||
const chapterTitle = hrefLabel || location?.start?.displayed?.chapter || location?.start?.href || manifest.book.title;
|
? findTocLabelByHref(tocItemsRef.current, location.start.href)
|
||||||
|
: '';
|
||||||
|
const chapterTitle =
|
||||||
|
hrefLabel ||
|
||||||
|
location?.start?.displayed?.chapter ||
|
||||||
|
location?.start?.href ||
|
||||||
|
manifest.book.title;
|
||||||
const cfi = location?.start?.cfi || '';
|
const cfi = location?.start?.cfi || '';
|
||||||
const computedProgress = locationsReadyRef.current && cfi
|
const computedProgress =
|
||||||
? Math.max(0, Math.min(100, (book.locations?.percentageFromCfi?.(cfi) || 0) * 100))
|
locationsReadyRef.current && cfi
|
||||||
|
? Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
100,
|
||||||
|
(book.locations?.percentageFromCfi?.(cfi) || 0) * 100
|
||||||
|
)
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
const normalizedProgress = computedProgress ?? lastProgressRef.current ?? manifest.lastRecord?.progressPercent ?? 0;
|
const normalizedProgress =
|
||||||
|
computedProgress ??
|
||||||
|
lastProgressRef.current ??
|
||||||
|
manifest.lastRecord?.progressPercent ??
|
||||||
|
0;
|
||||||
setProgressPercent(normalizedProgress);
|
setProgressPercent(normalizedProgress);
|
||||||
setCurrentChapter(chapterTitle);
|
setCurrentChapter(chapterTitle);
|
||||||
setCurrentHref(location?.start?.href || '');
|
setCurrentHref(location?.start?.href || '');
|
||||||
@@ -1866,7 +2773,8 @@ export default function BookReadPage() {
|
|||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const navigation = (await book.loaded?.navigation) || book.navigation;
|
const navigation =
|
||||||
|
(await book.loaded?.navigation) || book.navigation;
|
||||||
if (!destroyed) setTocItems(navigation?.toc || []);
|
if (!destroyed) setTocItems(navigation?.toc || []);
|
||||||
} catch {
|
} catch {
|
||||||
if (!destroyed) setTocItems(book.navigation?.toc || []);
|
if (!destroyed) setTocItems(book.navigation?.toc || []);
|
||||||
@@ -1879,7 +2787,10 @@ export default function BookReadPage() {
|
|||||||
await book.locations?.generate?.(480);
|
await book.locations?.generate?.(480);
|
||||||
locationsReadyRef.current = true;
|
locationsReadyRef.current = true;
|
||||||
if (lastLocationRef.current?.start?.cfi) {
|
if (lastLocationRef.current?.start?.cfi) {
|
||||||
const recomputed = book.locations?.percentageFromCfi?.(lastLocationRef.current.start.cfi) || 0;
|
const recomputed =
|
||||||
|
book.locations?.percentageFromCfi?.(
|
||||||
|
lastLocationRef.current.start.cfi
|
||||||
|
) || 0;
|
||||||
const nextProgress = Math.max(0, Math.min(100, recomputed * 100));
|
const nextProgress = Math.max(0, Math.min(100, recomputed * 100));
|
||||||
setProgressPercent(nextProgress);
|
setProgressPercent(nextProgress);
|
||||||
lastProgressRef.current = nextProgress;
|
lastProgressRef.current = nextProgress;
|
||||||
@@ -1902,7 +2813,14 @@ export default function BookReadPage() {
|
|||||||
renditionRef.current?.destroy?.();
|
renditionRef.current?.destroy?.();
|
||||||
bookRef.current?.destroy?.();
|
bookRef.current?.destroy?.();
|
||||||
};
|
};
|
||||||
}, [manifest, settings.mode, applyReaderTheme, persistCurrentProgress, queueReadRecord, navigateToTarget]);
|
}, [
|
||||||
|
manifest,
|
||||||
|
settings.mode,
|
||||||
|
applyReaderTheme,
|
||||||
|
persistCurrentProgress,
|
||||||
|
queueReadRecord,
|
||||||
|
navigateToTarget,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const flushPendingReadRecordOnLeave = () => {
|
const flushPendingReadRecordOnLeave = () => {
|
||||||
@@ -1965,7 +2883,10 @@ export default function BookReadPage() {
|
|||||||
const handleLoadedMetadata = () => {
|
const handleLoadedMetadata = () => {
|
||||||
const nextDuration = audio.duration || 0;
|
const nextDuration = audio.duration || 0;
|
||||||
if (ttsResumeTimeRef.current > 0 && nextDuration > 0) {
|
if (ttsResumeTimeRef.current > 0 && nextDuration > 0) {
|
||||||
audio.currentTime = Math.min(ttsResumeTimeRef.current, Math.max(0, nextDuration - 0.25));
|
audio.currentTime = Math.min(
|
||||||
|
ttsResumeTimeRef.current,
|
||||||
|
Math.max(0, nextDuration - 0.25)
|
||||||
|
);
|
||||||
ttsResumeTimeRef.current = 0;
|
ttsResumeTimeRef.current = 0;
|
||||||
}
|
}
|
||||||
setTtsDuration(nextDuration);
|
setTtsDuration(nextDuration);
|
||||||
@@ -2010,8 +2931,6 @@ export default function BookReadPage() {
|
|||||||
}
|
}
|
||||||
}, [currentHref, stopTts, ttsCurrentChapterHref]);
|
}, [currentHref, stopTts, ttsCurrentChapterHref]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!manifest || manifest.format !== 'pdf') return;
|
if (!manifest || manifest.format !== 'pdf') return;
|
||||||
let revokedUrl = '';
|
let revokedUrl = '';
|
||||||
@@ -2044,28 +2963,40 @@ export default function BookReadPage() {
|
|||||||
};
|
};
|
||||||
}, [manifest]);
|
}, [manifest]);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
tocItemsRef.current = tocItems;
|
tocItemsRef.current = tocItems;
|
||||||
}, [tocItems]);
|
}, [tocItems]);
|
||||||
|
|
||||||
const flatToc = useMemo(() => flattenToc(tocItems), [tocItems]);
|
const flatToc = useMemo(() => flattenToc(tocItems), [tocItems]);
|
||||||
const activeTocHref = useMemo(
|
const activeTocHref = useMemo(
|
||||||
() => flatToc.find((item) => isSameTocTarget(currentHref, item.href))?.href || '',
|
() =>
|
||||||
|
flatToc.find((item) => isSameTocTarget(currentHref, item.href))?.href ||
|
||||||
|
'',
|
||||||
[flatToc, currentHref]
|
[flatToc, currentHref]
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentTocLabel = useMemo(() => findTocLabelByHref(tocItems, currentHref), [tocItems, currentHref]);
|
const currentTocLabel = useMemo(
|
||||||
|
() => findTocLabelByHref(tocItems, currentHref),
|
||||||
|
[tocItems, currentHref]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!manifest) return;
|
if (!manifest) return;
|
||||||
window.dispatchEvent(new CustomEvent('books-read-update-header', {
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('books-read-update-header', {
|
||||||
detail: {
|
detail: {
|
||||||
title: manifest.book.title,
|
title: manifest.book.title,
|
||||||
subtitle: currentTocLabel || currentChapter || manifest.book.author || (settings.mode === 'scrolled' ? '滚动阅读' : '分页阅读'),
|
subtitle:
|
||||||
backHref: `/books/detail?sourceId=${encodeURIComponent(manifest.book.sourceId)}&bookId=${encodeURIComponent(manifest.book.id)}`,
|
currentTocLabel ||
|
||||||
|
currentChapter ||
|
||||||
|
manifest.book.author ||
|
||||||
|
(settings.mode === 'scrolled' ? '滚动阅读' : '分页阅读'),
|
||||||
|
backHref: `/books/detail?sourceId=${encodeURIComponent(
|
||||||
|
manifest.book.sourceId
|
||||||
|
)}&bookId=${encodeURIComponent(manifest.book.id)}`,
|
||||||
},
|
},
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
}, [manifest, currentChapter, currentTocLabel, settings.mode]);
|
}, [manifest, currentChapter, currentTocLabel, settings.mode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2075,7 +3006,9 @@ export default function BookReadPage() {
|
|||||||
activeNode.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
activeNode.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
}, [tocOpen, activeTocHref]);
|
}, [tocOpen, activeTocHref]);
|
||||||
const nextChapterHref = useMemo(() => {
|
const nextChapterHref = useMemo(() => {
|
||||||
const index = flatToc.findIndex((item) => isSameTocTarget(currentHref, item.href));
|
const index = flatToc.findIndex((item) =>
|
||||||
|
isSameTocTarget(currentHref, item.href)
|
||||||
|
);
|
||||||
if (index < 0) return flatToc[0]?.href || '';
|
if (index < 0) return flatToc[0]?.href || '';
|
||||||
return flatToc[index + 1]?.href || '';
|
return flatToc[index + 1]?.href || '';
|
||||||
}, [flatToc, currentHref]);
|
}, [flatToc, currentHref]);
|
||||||
@@ -2117,7 +3050,10 @@ export default function BookReadPage() {
|
|||||||
const isAtBottom = () => {
|
const isAtBottom = () => {
|
||||||
const latestMetrics = getIframeScrollMetrics(viewerRef.current);
|
const latestMetrics = getIframeScrollMetrics(viewerRef.current);
|
||||||
if (!latestMetrics) return false;
|
if (!latestMetrics) return false;
|
||||||
const distanceToBottom = latestMetrics.scrollHeight - latestMetrics.clientHeight - latestMetrics.scrollTop;
|
const distanceToBottom =
|
||||||
|
latestMetrics.scrollHeight -
|
||||||
|
latestMetrics.clientHeight -
|
||||||
|
latestMetrics.scrollTop;
|
||||||
return distanceToBottom <= 36;
|
return distanceToBottom <= 36;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2144,7 +3080,10 @@ export default function BookReadPage() {
|
|||||||
const latestMetrics = getIframeScrollMetrics(viewerRef.current);
|
const latestMetrics = getIframeScrollMetrics(viewerRef.current);
|
||||||
if (!latestMetrics) return;
|
if (!latestMetrics) return;
|
||||||
persistScrolledPosition();
|
persistScrolledPosition();
|
||||||
const distanceToBottom = latestMetrics.scrollHeight - latestMetrics.clientHeight - latestMetrics.scrollTop;
|
const distanceToBottom =
|
||||||
|
latestMetrics.scrollHeight -
|
||||||
|
latestMetrics.clientHeight -
|
||||||
|
latestMetrics.scrollTop;
|
||||||
if (distanceToBottom <= 36) {
|
if (distanceToBottom <= 36) {
|
||||||
setBottomReached(true);
|
setBottomReached(true);
|
||||||
scrolledAutoAdvanceLockRef.current = false;
|
scrolledAutoAdvanceLockRef.current = false;
|
||||||
@@ -2179,23 +3118,52 @@ export default function BookReadPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
metrics.addScrollListener(handleScroll);
|
metrics.addScrollListener(handleScroll);
|
||||||
metrics.interactionTarget?.addEventListener('wheel', handleWheel, { passive: true });
|
metrics.interactionTarget?.addEventListener('wheel', handleWheel, {
|
||||||
metrics.interactionTarget?.addEventListener('touchstart', handleTouchStart, { passive: true });
|
passive: true,
|
||||||
metrics.interactionTarget?.addEventListener('touchmove', handleTouchMove, { passive: true });
|
});
|
||||||
metrics.interactionTarget?.addEventListener('touchend', handleTouchEnd, { passive: true });
|
metrics.interactionTarget?.addEventListener(
|
||||||
viewerRef.current?.addEventListener('wheel', handleWheel, { passive: true });
|
'touchstart',
|
||||||
viewerRef.current?.addEventListener('touchstart', handleTouchStart, { passive: true });
|
handleTouchStart,
|
||||||
viewerRef.current?.addEventListener('touchmove', handleTouchMove, { passive: true });
|
{ passive: true }
|
||||||
viewerRef.current?.addEventListener('touchend', handleTouchEnd, { passive: true });
|
);
|
||||||
|
metrics.interactionTarget?.addEventListener(
|
||||||
|
'touchmove',
|
||||||
|
handleTouchMove,
|
||||||
|
{ passive: true }
|
||||||
|
);
|
||||||
|
metrics.interactionTarget?.addEventListener('touchend', handleTouchEnd, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
viewerRef.current?.addEventListener('wheel', handleWheel, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
viewerRef.current?.addEventListener('touchstart', handleTouchStart, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
viewerRef.current?.addEventListener('touchmove', handleTouchMove, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
viewerRef.current?.addEventListener('touchend', handleTouchEnd, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
handleScroll();
|
handleScroll();
|
||||||
scrollListenerCleanupRef.current = () => {
|
scrollListenerCleanupRef.current = () => {
|
||||||
if (retryTimer) window.clearTimeout(retryTimer);
|
if (retryTimer) window.clearTimeout(retryTimer);
|
||||||
if (rafId) window.cancelAnimationFrame(rafId);
|
if (rafId) window.cancelAnimationFrame(rafId);
|
||||||
metrics.removeScrollListener(handleScroll);
|
metrics.removeScrollListener(handleScroll);
|
||||||
metrics.interactionTarget?.removeEventListener('wheel', handleWheel);
|
metrics.interactionTarget?.removeEventListener('wheel', handleWheel);
|
||||||
metrics.interactionTarget?.removeEventListener('touchstart', handleTouchStart);
|
metrics.interactionTarget?.removeEventListener(
|
||||||
metrics.interactionTarget?.removeEventListener('touchmove', handleTouchMove);
|
'touchstart',
|
||||||
metrics.interactionTarget?.removeEventListener('touchend', handleTouchEnd);
|
handleTouchStart
|
||||||
|
);
|
||||||
|
metrics.interactionTarget?.removeEventListener(
|
||||||
|
'touchmove',
|
||||||
|
handleTouchMove
|
||||||
|
);
|
||||||
|
metrics.interactionTarget?.removeEventListener(
|
||||||
|
'touchend',
|
||||||
|
handleTouchEnd
|
||||||
|
);
|
||||||
viewerRef.current?.removeEventListener('wheel', handleWheel);
|
viewerRef.current?.removeEventListener('wheel', handleWheel);
|
||||||
viewerRef.current?.removeEventListener('touchstart', handleTouchStart);
|
viewerRef.current?.removeEventListener('touchstart', handleTouchStart);
|
||||||
viewerRef.current?.removeEventListener('touchmove', handleTouchMove);
|
viewerRef.current?.removeEventListener('touchmove', handleTouchMove);
|
||||||
@@ -2206,16 +3174,20 @@ export default function BookReadPage() {
|
|||||||
attach();
|
attach();
|
||||||
}, [goToNextChapter, persistScrolledPosition]);
|
}, [goToNextChapter, persistScrolledPosition]);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bindScrolledIframeListenerRef.current = bindScrolledIframeListener;
|
bindScrolledIframeListenerRef.current = bindScrolledIframeListener;
|
||||||
}, [bindScrolledIframeListener]);
|
}, [bindScrolledIframeListener]);
|
||||||
|
|
||||||
const renderTocItems = useCallback((items: TocItem[], depth = 0) => items.map((item) => {
|
const renderTocItems = useCallback(
|
||||||
|
(items: TocItem[], depth = 0) =>
|
||||||
|
items.map((item) => {
|
||||||
const active = tocItemIsActive(item, currentHref);
|
const active = tocItemIsActive(item, currentHref);
|
||||||
const clickable = !!item.href;
|
const clickable = !!item.href;
|
||||||
return (
|
return (
|
||||||
<div key={`${item.href || item.label}-${depth}`} className='space-y-2'>
|
<div
|
||||||
|
key={`${item.href || item.label}-${depth}`}
|
||||||
|
className='space-y-2'
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
ref={(node) => {
|
ref={(node) => {
|
||||||
if (item.href) tocItemRefs.current[item.href] = node;
|
if (item.href) tocItemRefs.current[item.href] = node;
|
||||||
@@ -2235,7 +3207,11 @@ export default function BookReadPage() {
|
|||||||
setTocOpen(false);
|
setTocOpen(false);
|
||||||
}}
|
}}
|
||||||
disabled={!clickable}
|
disabled={!clickable}
|
||||||
className={`group relative block w-full rounded-2xl px-4 py-3 text-left text-sm transition ${active ? 'bg-sky-600 text-white' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900'} ${!clickable ? 'cursor-default opacity-80' : ''}`}
|
className={`group relative block w-full rounded-2xl px-4 py-3 text-left text-sm transition ${
|
||||||
|
active
|
||||||
|
? 'bg-emerald-600 text-white'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900'
|
||||||
|
} ${!clickable ? 'cursor-default opacity-80' : ''}`}
|
||||||
style={{ paddingLeft: `${16 + depth * 14}px` }}
|
style={{ paddingLeft: `${16 + depth * 14}px` }}
|
||||||
>
|
>
|
||||||
<span className='block truncate'>{item.label}</span>
|
<span className='block truncate'>{item.label}</span>
|
||||||
@@ -2243,18 +3219,33 @@ export default function BookReadPage() {
|
|||||||
<div className='text-sm'>{item.label}</div>
|
<div className='text-sm'>{item.label}</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{item.subitems?.length ? renderTocItems(item.subitems, depth + 1) : null}
|
{item.subitems?.length
|
||||||
|
? renderTocItems(item.subitems, depth + 1)
|
||||||
|
: null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}), [currentHref, navigateToTarget, persistScrolledPosition]);
|
}),
|
||||||
|
[currentHref, navigateToTarget, persistScrolledPosition]
|
||||||
|
);
|
||||||
|
|
||||||
|
const showScrolledNextChapter =
|
||||||
|
ready &&
|
||||||
|
settings.mode === 'scrolled' &&
|
||||||
|
!tocOpen &&
|
||||||
|
!settingsOpen &&
|
||||||
|
scrolledBottomReached &&
|
||||||
|
!!nextChapterHref;
|
||||||
|
|
||||||
|
const progressLabel = totalBytes
|
||||||
const showScrolledNextChapter = ready && settings.mode === 'scrolled' && !tocOpen && !settingsOpen && scrolledBottomReached && !!nextChapterHref;
|
? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}`
|
||||||
|
: formatBytes(downloadedBytes);
|
||||||
const progressLabel = totalBytes ? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` : formatBytes(downloadedBytes);
|
const ttsChunkPercent =
|
||||||
const ttsChunkPercent = ttsChunks.length > 0 ? ((ttsCurrentChunkIndex + 1) / ttsChunks.length) * 100 : 0;
|
ttsChunks.length > 0
|
||||||
const selectedVoice = ttsVoices.find((item) => item.shortName === ttsSettings.voice);
|
? ((ttsCurrentChunkIndex + 1) / ttsChunks.length) * 100
|
||||||
|
: 0;
|
||||||
|
const selectedVoice = ttsVoices.find(
|
||||||
|
(item) => item.shortName === ttsSettings.voice
|
||||||
|
);
|
||||||
const currentChunk = ttsChunks[ttsCurrentChunkIndex];
|
const currentChunk = ttsChunks[ttsCurrentChunkIndex];
|
||||||
const ttsRateValue = parseSignedNumber(ttsSettings.rate, '%');
|
const ttsRateValue = parseSignedNumber(ttsSettings.rate, '%');
|
||||||
const ttsPitchValue = parseSignedNumber(ttsSettings.pitch, 'Hz');
|
const ttsPitchValue = parseSignedNumber(ttsSettings.pitch, 'Hz');
|
||||||
@@ -2269,7 +3260,9 @@ export default function BookReadPage() {
|
|||||||
<div className='reader-book-loader'>
|
<div className='reader-book-loader'>
|
||||||
<BookOpen className='h-10 w-10' strokeWidth={1.75} />
|
<BookOpen className='h-10 w-10' strokeWidth={1.75} />
|
||||||
</div>
|
</div>
|
||||||
<div className='text-sm text-gray-500 dark:text-gray-400'>准备阅读器中...</div>
|
<div className='text-sm text-gray-500 dark:text-gray-400'>
|
||||||
|
准备阅读器中...
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -2280,14 +3273,25 @@ export default function BookReadPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (manifest.format === 'pdf') {
|
if (manifest.format === 'pdf') {
|
||||||
if (!pdfBlobUrl) return <div className='p-4 text-sm text-gray-500'>PDF 加载中... {progressLabel}</div>;
|
if (!pdfBlobUrl)
|
||||||
return <iframe src={pdfBlobUrl} className='h-[calc(100vh-4rem)] w-full bg-white' title={manifest.book.title} />;
|
return (
|
||||||
|
<div className='p-4 text-sm text-gray-500'>
|
||||||
|
PDF 加载中... {progressLabel}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
src={pdfBlobUrl}
|
||||||
|
className='h-[calc(100vh-4rem)] w-full bg-white'
|
||||||
|
title={manifest.book.title}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex h-[calc(100vh-3.5rem)] flex-col bg-white dark:bg-gray-950'>
|
<div className='flex h-[calc(100vh-3.5rem)] flex-col bg-white dark:bg-gray-950'>
|
||||||
{restoredMessage ? (
|
{restoredMessage ? (
|
||||||
<div className='absolute left-1/2 top-[4.5rem] z-30 -translate-x-1/2 rounded-full bg-sky-600 px-4 py-2 text-xs text-white shadow-lg'>
|
<div className='absolute left-1/2 top-[4.5rem] z-30 -translate-x-1/2 rounded-full bg-emerald-600 px-4 py-2 text-xs text-white shadow-lg'>
|
||||||
{restoredMessage}
|
{restoredMessage}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -2307,12 +3311,25 @@ export default function BookReadPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className='h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-800'>
|
<div className='h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-800'>
|
||||||
<div
|
<div
|
||||||
className='h-full rounded-full bg-sky-600 transition-all'
|
className='h-full rounded-full bg-emerald-600 transition-all'
|
||||||
style={{ width: totalBytes ? `${Math.min(100, (downloadedBytes / totalBytes) * 100)}%` : fileLoadState === 'opening' ? '92%' : fileLoadState === 'checking-cache' ? '20%' : '45%' }}
|
style={{
|
||||||
|
width: totalBytes
|
||||||
|
? `${Math.min(
|
||||||
|
100,
|
||||||
|
(downloadedBytes / totalBytes) * 100
|
||||||
|
)}%`
|
||||||
|
: fileLoadState === 'opening'
|
||||||
|
? '92%'
|
||||||
|
: fileLoadState === 'checking-cache'
|
||||||
|
? '20%'
|
||||||
|
: '45%',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center justify-between text-xs text-gray-500 dark:text-gray-400'>
|
<div className='flex items-center justify-between text-xs text-gray-500 dark:text-gray-400'>
|
||||||
<span>{cacheHit ? '已命中本地缓存' : '首次打开将缓存到当前浏览器'}</span>
|
<span>
|
||||||
|
{cacheHit ? '已命中本地缓存' : '首次打开将缓存到当前浏览器'}
|
||||||
|
</span>
|
||||||
<span>{progressLabel}</span>
|
<span>{progressLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2322,7 +3339,9 @@ export default function BookReadPage() {
|
|||||||
<div className='reader-book-loader'>
|
<div className='reader-book-loader'>
|
||||||
<BookOpen className='h-10 w-10' strokeWidth={1.75} />
|
<BookOpen className='h-10 w-10' strokeWidth={1.75} />
|
||||||
</div>
|
</div>
|
||||||
<div className='text-sm text-gray-500 dark:text-gray-400'>正在打开电子书...</div>
|
<div className='text-sm text-gray-500 dark:text-gray-400'>
|
||||||
|
正在打开电子书...
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className='space-y-3 animate-pulse'>
|
<div className='space-y-3 animate-pulse'>
|
||||||
@@ -2338,8 +3357,12 @@ export default function BookReadPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{tocOpen && typeof document !== 'undefined' ? createPortal(
|
{tocOpen && typeof document !== 'undefined'
|
||||||
<div className='fixed inset-0 z-40 bg-black/30' onClick={() => setTocOpen(false)}>
|
? createPortal(
|
||||||
|
<div
|
||||||
|
className='fixed inset-0 z-40 bg-black/30'
|
||||||
|
onClick={() => setTocOpen(false)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className='absolute right-0 top-0 h-screen w-[22rem] max-w-[88vw] overflow-y-auto border-l border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-950'
|
className='absolute right-0 top-0 h-screen w-[22rem] max-w-[88vw] overflow-y-auto border-l border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-950'
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
@@ -2347,7 +3370,9 @@ export default function BookReadPage() {
|
|||||||
<div className='p-4'>
|
<div className='p-4'>
|
||||||
<div className='space-y-2' ref={tocScrollRef}>
|
<div className='space-y-2' ref={tocScrollRef}>
|
||||||
{tocItems.length === 0 ? (
|
{tocItems.length === 0 ? (
|
||||||
<div className='p-3 text-sm text-gray-500'>当前 EPUB 未提供目录</div>
|
<div className='p-3 text-sm text-gray-500'>
|
||||||
|
当前 EPUB 未提供目录
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
renderTocItems(tocItems)
|
renderTocItems(tocItems)
|
||||||
)}
|
)}
|
||||||
@@ -2356,33 +3381,60 @@ export default function BookReadPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
) : null}
|
)
|
||||||
|
: null}
|
||||||
|
|
||||||
{settingsOpen && typeof document !== 'undefined' ? createPortal(
|
{settingsOpen && typeof document !== 'undefined'
|
||||||
<div className='fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4' onClick={() => setSettingsOpen(false)}>
|
? createPortal(
|
||||||
|
<div
|
||||||
|
className='fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4'
|
||||||
|
onClick={() => setSettingsOpen(false)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className='w-full max-w-sm rounded-3xl border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-700 dark:bg-gray-950'
|
className='w-full max-w-sm rounded-3xl border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-700 dark:bg-gray-950'
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className='mb-4'>
|
<div className='mb-4'>
|
||||||
<div className='text-base font-semibold text-gray-900 dark:text-gray-100'>阅读设置</div>
|
<div className='text-base font-semibold text-gray-900 dark:text-gray-100'>
|
||||||
<div className='mt-1 text-xs text-gray-500'>可切换翻页或滚动阅读,默认翻页模式</div>
|
阅读设置
|
||||||
|
</div>
|
||||||
|
<div className='mt-1 text-xs text-gray-500'>
|
||||||
|
可切换翻页或滚动阅读,默认翻页模式
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='space-y-6 p-1 text-sm'>
|
<div className='space-y-6 p-1 text-sm'>
|
||||||
<div>
|
<div>
|
||||||
<div className='mb-2 font-medium'>阅读模式</div>
|
<div className='mb-2 font-medium'>阅读模式</div>
|
||||||
<div className='grid grid-cols-2 gap-2'>
|
<div className='grid grid-cols-2 gap-2'>
|
||||||
{([
|
{(
|
||||||
{ key: 'paginated', label: '翻页模式', desc: '左右点击翻页' },
|
[
|
||||||
{ key: 'scrolled', label: '滚动模式', desc: '上下连续滚动' },
|
{
|
||||||
] as { key: ReaderMode; label: string; desc: string }[]).map((mode) => (
|
key: 'paginated',
|
||||||
|
label: '翻页模式',
|
||||||
|
desc: '左右点击翻页',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'scrolled',
|
||||||
|
label: '滚动模式',
|
||||||
|
desc: '上下连续滚动',
|
||||||
|
},
|
||||||
|
] as { key: ReaderMode; label: string; desc: string }[]
|
||||||
|
).map((mode) => (
|
||||||
<button
|
<button
|
||||||
key={mode.key}
|
key={mode.key}
|
||||||
onClick={() => setSettings((prev) => ({ ...prev, mode: mode.key }))}
|
onClick={() =>
|
||||||
className={`rounded-2xl border px-3 py-3 text-left ${settings.mode === mode.key ? 'border-sky-500 bg-sky-50 text-sky-700 dark:bg-sky-950/30 dark:text-sky-300' : 'border-gray-200 dark:border-gray-700'}`}
|
setSettings((prev) => ({ ...prev, mode: mode.key }))
|
||||||
|
}
|
||||||
|
className={`rounded-2xl border px-3 py-3 text-left ${
|
||||||
|
settings.mode === mode.key
|
||||||
|
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'
|
||||||
|
: 'border-gray-200 dark:border-gray-700'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className='font-medium'>{mode.label}</div>
|
<div className='font-medium'>{mode.label}</div>
|
||||||
<div className='mt-1 text-xs opacity-70'>{mode.desc}</div>
|
<div className='mt-1 text-xs opacity-70'>
|
||||||
|
{mode.desc}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -2391,38 +3443,87 @@ export default function BookReadPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className='mb-2 font-medium'>主题</div>
|
<div className='mb-2 font-medium'>主题</div>
|
||||||
<div className='grid grid-cols-3 gap-2'>
|
<div className='grid grid-cols-3 gap-2'>
|
||||||
{(['light', 'sepia', 'dark'] as ReaderTheme[]).map((theme) => (
|
{(['light', 'sepia', 'dark'] as ReaderTheme[]).map(
|
||||||
|
(theme) => (
|
||||||
<button
|
<button
|
||||||
key={theme}
|
key={theme}
|
||||||
onClick={() => setSettings((prev) => ({ ...prev, theme }))}
|
onClick={() =>
|
||||||
className={`rounded-2xl border px-3 py-2 ${settings.theme === theme ? 'border-sky-500 bg-sky-50 text-sky-700 dark:bg-sky-950/30 dark:text-sky-300' : 'border-gray-200 dark:border-gray-700'}`}
|
setSettings((prev) => ({ ...prev, theme }))
|
||||||
|
}
|
||||||
|
className={`rounded-2xl border px-3 py-2 ${
|
||||||
|
settings.theme === theme
|
||||||
|
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'
|
||||||
|
: 'border-gray-200 dark:border-gray-700'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className='mb-1 flex justify-center'>{theme === 'dark' ? <Moon className='h-4 w-4' /> : <Sun className='h-4 w-4' />}</div>
|
<div className='mb-1 flex justify-center'>
|
||||||
{theme === 'light' ? '浅色' : theme === 'sepia' ? '护眼' : '深色'}
|
{theme === 'dark' ? (
|
||||||
|
<Moon className='h-4 w-4' />
|
||||||
|
) : (
|
||||||
|
<Sun className='h-4 w-4' />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{theme === 'light'
|
||||||
|
? '浅色'
|
||||||
|
: theme === 'sepia'
|
||||||
|
? '护眼'
|
||||||
|
: '深色'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className='mb-2 flex items-center justify-between font-medium'>字号 <span>{settings.fontSize}%</span></div>
|
<div className='mb-2 flex items-center justify-between font-medium'>
|
||||||
<input type='range' min='85' max='140' step='5' value={settings.fontSize} onChange={(e) => setSettings((prev) => ({ ...prev, fontSize: Number(e.target.value) }))} className='w-full' />
|
字号 <span>{settings.fontSize}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type='range'
|
||||||
|
min='85'
|
||||||
|
max='140'
|
||||||
|
step='5'
|
||||||
|
value={settings.fontSize}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
fontSize: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className='w-full'
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className='mb-2 flex items-center justify-between font-medium'>行距 <span>{settings.lineHeight.toFixed(1)}</span></div>
|
<div className='mb-2 flex items-center justify-between font-medium'>
|
||||||
<input type='range' min='1.4' max='2.2' step='0.1' value={settings.lineHeight} onChange={(e) => setSettings((prev) => ({ ...prev, lineHeight: Number(e.target.value) }))} className='w-full' />
|
行距 <span>{settings.lineHeight.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type='range'
|
||||||
|
min='1.4'
|
||||||
|
max='2.2'
|
||||||
|
step='0.1'
|
||||||
|
value={settings.lineHeight}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
lineHeight: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className='w-full'
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='rounded-2xl bg-gray-50 p-4 text-xs text-gray-500 dark:bg-gray-900 dark:text-gray-400'>
|
<div className='rounded-2xl bg-gray-50 p-4 text-xs text-gray-500 dark:bg-gray-900 dark:text-gray-400'>
|
||||||
首次会缓存到当前浏览器,之后再次打开同一本书通常不需要重新整包下载。
|
首次会缓存到当前浏览器,之后再次打开同一本书通常不需要重新整包下载。
|
||||||
当前缓存状态:{cacheHit ? '已命中本地缓存' : '本次为网络加载'}。
|
当前缓存状态:
|
||||||
|
{cacheHit ? '已命中本地缓存' : '本次为网络加载'}。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex justify-end'>
|
<div className='flex justify-end'>
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
className='rounded-2xl bg-sky-600 px-4 py-2 text-sm font-medium text-white'
|
className='rounded-2xl bg-emerald-600 px-4 py-2 text-sm font-medium text-white'
|
||||||
onClick={() => setSettingsOpen(false)}
|
onClick={() => setSettingsOpen(false)}
|
||||||
>
|
>
|
||||||
完成
|
完成
|
||||||
@@ -2432,7 +3533,8 @@ export default function BookReadPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
) : null}
|
)
|
||||||
|
: null}
|
||||||
|
|
||||||
{manifest.format === 'epub' && ttsBarVisible ? (
|
{manifest.format === 'epub' && ttsBarVisible ? (
|
||||||
<>
|
<>
|
||||||
@@ -2451,7 +3553,9 @@ export default function BookReadPage() {
|
|||||||
onTouchStart={() => setTtsSeeking(true)}
|
onTouchStart={() => setTtsSeeking(true)}
|
||||||
onChange={(e) => setTtsSeekValue(Number(e.target.value))}
|
onChange={(e) => setTtsSeekValue(Number(e.target.value))}
|
||||||
onPointerUp={(e) => {
|
onPointerUp={(e) => {
|
||||||
const nextTime = Number((e.target as HTMLInputElement).value);
|
const nextTime = Number(
|
||||||
|
(e.target as HTMLInputElement).value
|
||||||
|
);
|
||||||
if (audioRef.current && Number.isFinite(nextTime)) {
|
if (audioRef.current && Number.isFinite(nextTime)) {
|
||||||
audioRef.current.currentTime = nextTime;
|
audioRef.current.currentTime = nextTime;
|
||||||
}
|
}
|
||||||
@@ -2460,7 +3564,9 @@ export default function BookReadPage() {
|
|||||||
setTtsSeeking(false);
|
setTtsSeeking(false);
|
||||||
}}
|
}}
|
||||||
onMouseUp={(e) => {
|
onMouseUp={(e) => {
|
||||||
const nextTime = Number((e.target as HTMLInputElement).value);
|
const nextTime = Number(
|
||||||
|
(e.target as HTMLInputElement).value
|
||||||
|
);
|
||||||
if (audioRef.current && Number.isFinite(nextTime)) {
|
if (audioRef.current && Number.isFinite(nextTime)) {
|
||||||
audioRef.current.currentTime = nextTime;
|
audioRef.current.currentTime = nextTime;
|
||||||
}
|
}
|
||||||
@@ -2469,7 +3575,9 @@ export default function BookReadPage() {
|
|||||||
setTtsSeeking(false);
|
setTtsSeeking(false);
|
||||||
}}
|
}}
|
||||||
onTouchEnd={(e) => {
|
onTouchEnd={(e) => {
|
||||||
const nextTime = Number((e.target as HTMLInputElement).value);
|
const nextTime = Number(
|
||||||
|
(e.target as HTMLInputElement).value
|
||||||
|
);
|
||||||
if (audioRef.current && Number.isFinite(nextTime)) {
|
if (audioRef.current && Number.isFinite(nextTime)) {
|
||||||
audioRef.current.currentTime = nextTime;
|
audioRef.current.currentTime = nextTime;
|
||||||
}
|
}
|
||||||
@@ -2477,7 +3585,7 @@ export default function BookReadPage() {
|
|||||||
setTtsSeekValue(nextTime);
|
setTtsSeekValue(nextTime);
|
||||||
setTtsSeeking(false);
|
setTtsSeeking(false);
|
||||||
}}
|
}}
|
||||||
className='w-full accent-sky-500'
|
className='w-full accent-emerald-500'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='px-3 py-2.5'>
|
<div className='px-3 py-2.5'>
|
||||||
@@ -2486,13 +3594,22 @@ export default function BookReadPage() {
|
|||||||
type='button'
|
type='button'
|
||||||
onClick={() => void toggleTtsPlayback()}
|
onClick={() => void toggleTtsPlayback()}
|
||||||
disabled={!ttsAvailable || ttsLoadingChunkIndex !== null}
|
disabled={!ttsAvailable || ttsLoadingChunkIndex !== null}
|
||||||
className='flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-600 text-white disabled:opacity-50'
|
className='flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-600 text-white disabled:opacity-50'
|
||||||
>
|
>
|
||||||
{ttsLoadingChunkIndex !== null ? <Loader2 className='h-4 w-4 animate-spin' /> : ttsStatus === 'playing' ? <Pause className='h-4 w-4' /> : <Play className='h-4 w-4' />}
|
{ttsLoadingChunkIndex !== null ? (
|
||||||
|
<Loader2 className='h-4 w-4 animate-spin' />
|
||||||
|
) : ttsStatus === 'playing' ? (
|
||||||
|
<Pause className='h-4 w-4' />
|
||||||
|
) : (
|
||||||
|
<Play className='h-4 w-4' />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<div className='min-w-0 flex-1'>
|
<div className='min-w-0 flex-1'>
|
||||||
<div className='truncate text-sm font-medium text-gray-900 dark:text-gray-100'>
|
<div className='truncate text-sm font-medium text-gray-900 dark:text-gray-100'>
|
||||||
{ttsCurrentChapterTitle || currentTocLabel || currentChapter || '语音朗读'}
|
{ttsCurrentChapterTitle ||
|
||||||
|
currentTocLabel ||
|
||||||
|
currentChapter ||
|
||||||
|
'语音朗读'}
|
||||||
</div>
|
</div>
|
||||||
<div className='mt-0.5 flex items-center gap-2 text-[11px] text-gray-500 dark:text-gray-400'>
|
<div className='mt-0.5 flex items-center gap-2 text-[11px] text-gray-500 dark:text-gray-400'>
|
||||||
<span className='truncate'>
|
<span className='truncate'>
|
||||||
@@ -2506,7 +3623,11 @@ export default function BookReadPage() {
|
|||||||
? '已暂停'
|
? '已暂停'
|
||||||
: '待播放'}
|
: '待播放'}
|
||||||
</span>
|
</span>
|
||||||
{ttsChunks.length > 0 ? <span>{ttsCurrentChunkIndex + 1}/{ttsChunks.length}</span> : null}
|
{ttsChunks.length > 0 ? (
|
||||||
|
<span>
|
||||||
|
{ttsCurrentChunkIndex + 1}/{ttsChunks.length}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -2514,12 +3635,19 @@ export default function BookReadPage() {
|
|||||||
onClick={() => setTtsPanelOpen((prev) => !prev)}
|
onClick={() => setTtsPanelOpen((prev) => !prev)}
|
||||||
className='flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-200'
|
className='flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-200'
|
||||||
>
|
>
|
||||||
<ChevronUp className={`h-4 w-4 transition-transform ${ttsPanelOpen ? 'rotate-180' : ''}`} />
|
<ChevronUp
|
||||||
|
className={`h-4 w-4 transition-transform ${
|
||||||
|
ttsPanelOpen ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className='mt-2 flex items-center justify-between text-[11px] text-gray-400'>
|
<div className='mt-2 flex items-center justify-between text-[11px] text-gray-400'>
|
||||||
<span>{selectedVoice?.displayName || '默认音色'}</span>
|
<span>{selectedVoice?.displayName || '默认音色'}</span>
|
||||||
<span>{formatDurationTime(displayedTtsTime)} / {formatDurationTime(ttsDuration || 0)}</span>
|
<span>
|
||||||
|
{formatDurationTime(displayedTtsTime)} /{' '}
|
||||||
|
{formatDurationTime(ttsDuration || 0)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2530,7 +3658,7 @@ export default function BookReadPage() {
|
|||||||
<div className='rounded-[2rem] border border-gray-200 bg-white/98 p-4 shadow-2xl backdrop-blur dark:border-gray-800 dark:bg-gray-950/98'>
|
<div className='rounded-[2rem] border border-gray-200 bg-white/98 p-4 shadow-2xl backdrop-blur dark:border-gray-800 dark:bg-gray-950/98'>
|
||||||
<div className='mb-3 flex items-center justify-between'>
|
<div className='mb-3 flex items-center justify-between'>
|
||||||
<div className='flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-gray-100'>
|
<div className='flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-gray-100'>
|
||||||
<Headphones className='h-4 w-4 text-sky-500' />
|
<Headphones className='h-4 w-4 text-emerald-500' />
|
||||||
听书控制
|
听书控制
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -2543,8 +3671,12 @@ export default function BookReadPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mb-4 flex items-center justify-between rounded-2xl bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-gray-900 dark:text-gray-300'>
|
<div className='mb-4 flex items-center justify-between rounded-2xl bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-gray-900 dark:text-gray-300'>
|
||||||
<span className='truncate'>{currentChunk?.text.slice(0, 28) || '当前章节可开始朗读'}</span>
|
<span className='truncate'>
|
||||||
<span className='ml-2 shrink-0'>{Math.round(ttsChunkPercent)}%</span>
|
{currentChunk?.text.slice(0, 28) || '当前章节可开始朗读'}
|
||||||
|
</span>
|
||||||
|
<span className='ml-2 shrink-0'>
|
||||||
|
{Math.round(ttsChunkPercent)}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mb-4 flex items-center justify-center gap-3'>
|
<div className='mb-4 flex items-center justify-center gap-3'>
|
||||||
@@ -2554,7 +3686,9 @@ export default function BookReadPage() {
|
|||||||
const next = Math.max(0, ttsCurrentChunkIndex - 1);
|
const next = Math.max(0, ttsCurrentChunkIndex - 1);
|
||||||
if (ttsChunks[next]) void playTtsChunk(next);
|
if (ttsChunks[next]) void playTtsChunk(next);
|
||||||
}}
|
}}
|
||||||
disabled={ttsCurrentChunkIndex <= 0 || ttsChunks.length === 0}
|
disabled={
|
||||||
|
ttsCurrentChunkIndex <= 0 || ttsChunks.length === 0
|
||||||
|
}
|
||||||
className='flex h-11 w-11 items-center justify-center rounded-full bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-200 disabled:opacity-40'
|
className='flex h-11 w-11 items-center justify-center rounded-full bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-200 disabled:opacity-40'
|
||||||
>
|
>
|
||||||
<SkipBack className='h-5 w-5' />
|
<SkipBack className='h-5 w-5' />
|
||||||
@@ -2563,9 +3697,15 @@ export default function BookReadPage() {
|
|||||||
type='button'
|
type='button'
|
||||||
onClick={() => void toggleTtsPlayback()}
|
onClick={() => void toggleTtsPlayback()}
|
||||||
disabled={!ttsAvailable || ttsLoadingChunkIndex !== null}
|
disabled={!ttsAvailable || ttsLoadingChunkIndex !== null}
|
||||||
className='flex h-14 w-14 items-center justify-center rounded-full bg-sky-600 text-white shadow-lg disabled:opacity-50'
|
className='flex h-14 w-14 items-center justify-center rounded-full bg-emerald-600 text-white shadow-lg disabled:opacity-50'
|
||||||
>
|
>
|
||||||
{ttsLoadingChunkIndex !== null ? <Loader2 className='h-5 w-5 animate-spin' /> : ttsStatus === 'playing' ? <Pause className='h-5 w-5' /> : <Play className='h-5 w-5' />}
|
{ttsLoadingChunkIndex !== null ? (
|
||||||
|
<Loader2 className='h-5 w-5 animate-spin' />
|
||||||
|
) : ttsStatus === 'playing' ? (
|
||||||
|
<Pause className='h-5 w-5' />
|
||||||
|
) : (
|
||||||
|
<Play className='h-5 w-5' />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
@@ -2573,7 +3713,10 @@ export default function BookReadPage() {
|
|||||||
const next = ttsCurrentChunkIndex + 1;
|
const next = ttsCurrentChunkIndex + 1;
|
||||||
if (ttsChunks[next]) void playTtsChunk(next);
|
if (ttsChunks[next]) void playTtsChunk(next);
|
||||||
}}
|
}}
|
||||||
disabled={ttsCurrentChunkIndex >= ttsChunks.length - 1 || ttsChunks.length === 0}
|
disabled={
|
||||||
|
ttsCurrentChunkIndex >= ttsChunks.length - 1 ||
|
||||||
|
ttsChunks.length === 0
|
||||||
|
}
|
||||||
className='flex h-11 w-11 items-center justify-center rounded-full bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-200 disabled:opacity-40'
|
className='flex h-11 w-11 items-center justify-center rounded-full bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-200 disabled:opacity-40'
|
||||||
>
|
>
|
||||||
<SkipForward className='h-5 w-5' />
|
<SkipForward className='h-5 w-5' />
|
||||||
@@ -2598,7 +3741,10 @@ export default function BookReadPage() {
|
|||||||
value={ttsSettings.voice}
|
value={ttsSettings.voice}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
stopTts(true);
|
stopTts(true);
|
||||||
setTtsSettings((prev) => ({ ...prev, voice: e.target.value }));
|
setTtsSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
voice: e.target.value,
|
||||||
|
}));
|
||||||
}}
|
}}
|
||||||
className='w-full rounded-2xl border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100'
|
className='w-full rounded-2xl border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100'
|
||||||
>
|
>
|
||||||
@@ -2612,7 +3758,10 @@ export default function BookReadPage() {
|
|||||||
|
|
||||||
<label className='block'>
|
<label className='block'>
|
||||||
<div className='mb-2 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400'>
|
<div className='mb-2 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400'>
|
||||||
<span className='flex items-center gap-2'><Gauge className='h-3.5 w-3.5' />语速</span>
|
<span className='flex items-center gap-2'>
|
||||||
|
<Gauge className='h-3.5 w-3.5' />
|
||||||
|
语速
|
||||||
|
</span>
|
||||||
<span>{ttsSettings.rate}</span>
|
<span>{ttsSettings.rate}</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -2623,8 +3772,12 @@ export default function BookReadPage() {
|
|||||||
value={Math.max(0, TTS_RATE_STEPS.indexOf(ttsRateValue))}
|
value={Math.max(0, TTS_RATE_STEPS.indexOf(ttsRateValue))}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
stopTts(true);
|
stopTts(true);
|
||||||
const nextValue = TTS_RATE_STEPS[Number(e.target.value)] ?? 0;
|
const nextValue =
|
||||||
setTtsSettings((prev) => ({ ...prev, rate: formatSignedValue(nextValue, '%') }));
|
TTS_RATE_STEPS[Number(e.target.value)] ?? 0;
|
||||||
|
setTtsSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
rate: formatSignedValue(nextValue, '%'),
|
||||||
|
}));
|
||||||
}}
|
}}
|
||||||
className='w-full'
|
className='w-full'
|
||||||
/>
|
/>
|
||||||
@@ -2632,7 +3785,10 @@ export default function BookReadPage() {
|
|||||||
|
|
||||||
<label className='block'>
|
<label className='block'>
|
||||||
<div className='mb-2 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400'>
|
<div className='mb-2 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400'>
|
||||||
<span className='flex items-center gap-2'><Waves className='h-3.5 w-3.5' />音调</span>
|
<span className='flex items-center gap-2'>
|
||||||
|
<Waves className='h-3.5 w-3.5' />
|
||||||
|
音调
|
||||||
|
</span>
|
||||||
<span>{ttsSettings.pitch}</span>
|
<span>{ttsSettings.pitch}</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -2640,11 +3796,18 @@ export default function BookReadPage() {
|
|||||||
min={0}
|
min={0}
|
||||||
max={TTS_PITCH_STEPS.length - 1}
|
max={TTS_PITCH_STEPS.length - 1}
|
||||||
step={1}
|
step={1}
|
||||||
value={Math.max(0, TTS_PITCH_STEPS.indexOf(ttsPitchValue))}
|
value={Math.max(
|
||||||
|
0,
|
||||||
|
TTS_PITCH_STEPS.indexOf(ttsPitchValue)
|
||||||
|
)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
stopTts(true);
|
stopTts(true);
|
||||||
const nextValue = TTS_PITCH_STEPS[Number(e.target.value)] ?? 0;
|
const nextValue =
|
||||||
setTtsSettings((prev) => ({ ...prev, pitch: formatSignedValue(nextValue, 'Hz') }));
|
TTS_PITCH_STEPS[Number(e.target.value)] ?? 0;
|
||||||
|
setTtsSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
pitch: formatSignedValue(nextValue, 'Hz'),
|
||||||
|
}));
|
||||||
}}
|
}}
|
||||||
className='w-full'
|
className='w-full'
|
||||||
/>
|
/>
|
||||||
@@ -2652,7 +3815,10 @@ export default function BookReadPage() {
|
|||||||
|
|
||||||
<label className='block'>
|
<label className='block'>
|
||||||
<div className='mb-2 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400'>
|
<div className='mb-2 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400'>
|
||||||
<span className='flex items-center gap-2'><Volume2 className='h-3.5 w-3.5' />音量</span>
|
<span className='flex items-center gap-2'>
|
||||||
|
<Volume2 className='h-3.5 w-3.5' />
|
||||||
|
音量
|
||||||
|
</span>
|
||||||
<span>{ttsSettings.volume}</span>
|
<span>{ttsSettings.volume}</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -2660,25 +3826,33 @@ export default function BookReadPage() {
|
|||||||
min={0}
|
min={0}
|
||||||
max={TTS_VOLUME_STEPS.length - 1}
|
max={TTS_VOLUME_STEPS.length - 1}
|
||||||
step={1}
|
step={1}
|
||||||
value={Math.max(0, TTS_VOLUME_STEPS.indexOf(ttsVolumeValue))}
|
value={Math.max(
|
||||||
|
0,
|
||||||
|
TTS_VOLUME_STEPS.indexOf(ttsVolumeValue)
|
||||||
|
)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
stopTts(true);
|
stopTts(true);
|
||||||
const nextValue = TTS_VOLUME_STEPS[Number(e.target.value)] ?? 0;
|
const nextValue =
|
||||||
setTtsSettings((prev) => ({ ...prev, volume: formatSignedValue(nextValue, '%') }));
|
TTS_VOLUME_STEPS[Number(e.target.value)] ?? 0;
|
||||||
|
setTtsSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
volume: formatSignedValue(nextValue, '%'),
|
||||||
|
}));
|
||||||
}}
|
}}
|
||||||
className='w-full'
|
className='w-full'
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ttsError ? <div className='mt-3 text-xs text-red-500'>{ttsError}</div> : null}
|
{ttsError ? (
|
||||||
|
<div className='mt-3 text-xs text-red-500'>{ttsError}</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
||||||
{ready && !tocOpen && !settingsOpen && settings.mode === 'paginated' ? (
|
{ready && !tocOpen && !settingsOpen && settings.mode === 'paginated' ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -2707,7 +3881,7 @@ export default function BookReadPage() {
|
|||||||
type='button'
|
type='button'
|
||||||
onClick={goToNextChapter}
|
onClick={goToNextChapter}
|
||||||
aria-label='下一章'
|
aria-label='下一章'
|
||||||
className='pointer-events-auto flex h-11 w-11 items-center justify-center rounded-full bg-sky-600/20 text-white shadow-lg'
|
className='pointer-events-auto flex h-11 w-11 items-center justify-center rounded-full bg-emerald-600/20 text-white shadow-lg'
|
||||||
>
|
>
|
||||||
<ChevronRight className='h-5 w-5' />
|
<ChevronRight className='h-5 w-5' />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+390
-69
@@ -1,11 +1,32 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BookMarked,
|
||||||
|
Layers3,
|
||||||
|
Loader2,
|
||||||
|
Search,
|
||||||
|
Sparkles,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { startTransition, useCallback, useEffect, useRef, useState } from 'react';
|
import {
|
||||||
|
startTransition,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { BookListItem, BookSearchResult, BookSource } from '@/lib/book.types';
|
||||||
|
import {
|
||||||
|
buildBookDetailPath,
|
||||||
|
cacheBookListItem,
|
||||||
|
} from '@/lib/book-route-cache.client';
|
||||||
|
|
||||||
import BookCard from '@/components/books/BookCard';
|
import BookCard from '@/components/books/BookCard';
|
||||||
import { buildBookDetailPath, cacheBookListItem } from '@/lib/book-route-cache.client';
|
|
||||||
import { BookListItem, BookSearchResult, BookSource } from '@/lib/book.types';
|
type RuntimeWindow = Window & { RUNTIME_CONFIG?: { FLUID_SEARCH?: boolean } };
|
||||||
|
|
||||||
function detailHref(item: BookListItem) {
|
function detailHref(item: BookListItem) {
|
||||||
return buildBookDetailPath(item.sourceId, item.id);
|
return buildBookDetailPath(item.sourceId, item.id);
|
||||||
@@ -15,10 +36,13 @@ function SearchSkeleton() {
|
|||||||
return (
|
return (
|
||||||
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6 animate-pulse'>
|
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6 animate-pulse'>
|
||||||
{Array.from({ length: 12 }).map((_, index) => (
|
{Array.from({ length: 12 }).map((_, index) => (
|
||||||
<div key={index} className='space-y-3'>
|
<div
|
||||||
<div className='aspect-[3/4] rounded-2xl bg-gray-200 dark:bg-gray-800' />
|
key={index}
|
||||||
<div className='h-4 w-3/4 rounded bg-gray-200 dark:bg-gray-800' />
|
className='overflow-hidden rounded-[1.75rem] border border-emerald-100/70 bg-white/70 p-3 shadow-sm dark:border-emerald-500/10 dark:bg-gray-950/50'
|
||||||
<div className='h-3 w-1/2 rounded bg-gray-200 dark:bg-gray-800' />
|
>
|
||||||
|
<div className='aspect-[3/4] rounded-2xl bg-gradient-to-br from-emerald-100 to-amber-100 dark:from-gray-800 dark:to-emerald-950/30' />
|
||||||
|
<div className='mt-3 h-4 w-3/4 rounded bg-emerald-100 dark:bg-gray-800' />
|
||||||
|
<div className='mt-2 h-3 w-1/2 rounded bg-emerald-100/80 dark:bg-gray-800' />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -27,6 +51,7 @@ function SearchSkeleton() {
|
|||||||
|
|
||||||
const BOOK_SEARCH_STATE_KEY = 'book_search_state';
|
const BOOK_SEARCH_STATE_KEY = 'book_search_state';
|
||||||
const EMPTY_RESULT: BookSearchResult = { results: [], failedSources: [] };
|
const EMPTY_RESULT: BookSearchResult = { results: [], failedSources: [] };
|
||||||
|
const QUICK_SEARCHES = ['三体', '刘慈欣', '东野圭吾', '哈利波特'];
|
||||||
|
|
||||||
export default function BooksSearchPage() {
|
export default function BooksSearchPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -52,39 +77,65 @@ export default function BooksSearchPage() {
|
|||||||
const pendingResultsRef = useRef<BookListItem[]>([]);
|
const pendingResultsRef = useRef<BookListItem[]>([]);
|
||||||
const flushTimerRef = useRef<number | null>(null);
|
const flushTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const getCacheKey = useCallback((keyword: string, selectedSourceId: string) => `book_search_cache_${selectedSourceId || 'all'}_${keyword.trim()}`, []);
|
const getCacheKey = useCallback(
|
||||||
|
(keyword: string, selectedSourceId: string) =>
|
||||||
|
`book_search_cache_${selectedSourceId || 'all'}_${keyword.trim()}`,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const getCachedResult = useCallback((keyword: string, selectedSourceId: string) => {
|
const getCachedResult = useCallback(
|
||||||
|
(keyword: string, selectedSourceId: string) => {
|
||||||
if (typeof window === 'undefined' || !keyword.trim()) return null;
|
if (typeof window === 'undefined' || !keyword.trim()) return null;
|
||||||
try {
|
try {
|
||||||
const raw = sessionStorage.getItem(getCacheKey(keyword, selectedSourceId));
|
const raw = sessionStorage.getItem(
|
||||||
|
getCacheKey(keyword, selectedSourceId)
|
||||||
|
);
|
||||||
return raw ? (JSON.parse(raw) as BookSearchResult) : null;
|
return raw ? (JSON.parse(raw) as BookSearchResult) : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, [getCacheKey]);
|
},
|
||||||
|
[getCacheKey]
|
||||||
|
);
|
||||||
|
|
||||||
const setCachedResult = useCallback((keyword: string, selectedSourceId: string, nextResult: BookSearchResult) => {
|
const setCachedResult = useCallback(
|
||||||
|
(
|
||||||
|
keyword: string,
|
||||||
|
selectedSourceId: string,
|
||||||
|
nextResult: BookSearchResult
|
||||||
|
) => {
|
||||||
if (typeof window === 'undefined' || !keyword.trim()) return;
|
if (typeof window === 'undefined' || !keyword.trim()) return;
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem(getCacheKey(keyword, selectedSourceId), JSON.stringify(nextResult));
|
sessionStorage.setItem(
|
||||||
} catch {}
|
getCacheKey(keyword, selectedSourceId),
|
||||||
}, [getCacheKey]);
|
JSON.stringify(nextResult)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage/browser cleanup failures.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getCacheKey]
|
||||||
|
);
|
||||||
|
|
||||||
const readFluidSearchSetting = useCallback(() => {
|
const readFluidSearchSetting = useCallback(() => {
|
||||||
if (typeof window === 'undefined') return true;
|
if (typeof window === 'undefined') return true;
|
||||||
try {
|
try {
|
||||||
const savedFluidSearch = localStorage.getItem('fluidSearch');
|
const savedFluidSearch = localStorage.getItem('fluidSearch');
|
||||||
if (savedFluidSearch !== null) return JSON.parse(savedFluidSearch) !== false;
|
if (savedFluidSearch !== null)
|
||||||
} catch {}
|
return JSON.parse(savedFluidSearch) !== false;
|
||||||
return (window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false;
|
} catch {
|
||||||
|
// Ignore storage/browser cleanup failures.
|
||||||
|
}
|
||||||
|
return (window as RuntimeWindow).RUNTIME_CONFIG?.FLUID_SEARCH !== false;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeEventSource = useCallback(() => {
|
const closeEventSource = useCallback(() => {
|
||||||
if (eventSourceRef.current) {
|
if (eventSourceRef.current) {
|
||||||
try {
|
try {
|
||||||
eventSourceRef.current.close();
|
eventSourceRef.current.close();
|
||||||
} catch {}
|
} catch {
|
||||||
|
// Ignore cleanup failures.
|
||||||
|
}
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -105,31 +156,53 @@ export default function BooksSearchPage() {
|
|||||||
const toAppend = pendingResultsRef.current;
|
const toAppend = pendingResultsRef.current;
|
||||||
pendingResultsRef.current = [];
|
pendingResultsRef.current = [];
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setResult((prev) => ({ ...prev, results: prev.results.concat(toAppend) }));
|
setResult((prev) => ({
|
||||||
|
...prev,
|
||||||
|
results: prev.results.concat(toAppend),
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
flushTimerRef.current = null;
|
flushTimerRef.current = null;
|
||||||
}, 80);
|
}, 80);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const saveSearchState = useCallback((nextState: { q: string; sourceId: string; result: BookSearchResult }) => {
|
const saveSearchState = useCallback(
|
||||||
|
(nextState: { q: string; sourceId: string; result: BookSearchResult }) => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem(BOOK_SEARCH_STATE_KEY, JSON.stringify(nextState));
|
sessionStorage.setItem(
|
||||||
} catch {}
|
BOOK_SEARCH_STATE_KEY,
|
||||||
}, []);
|
JSON.stringify(nextState)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage failures.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const restoreSearchState = useCallback(() => {
|
const restoreSearchState = useCallback(() => {
|
||||||
if (typeof window === 'undefined') return null;
|
if (typeof window === 'undefined') return null;
|
||||||
try {
|
try {
|
||||||
const raw = sessionStorage.getItem(BOOK_SEARCH_STATE_KEY);
|
const raw = sessionStorage.getItem(BOOK_SEARCH_STATE_KEY);
|
||||||
return raw ? (JSON.parse(raw) as { q: string; sourceId: string; result: BookSearchResult }) : null;
|
return raw
|
||||||
|
? (JSON.parse(raw) as {
|
||||||
|
q: string;
|
||||||
|
sourceId: string;
|
||||||
|
result: BookSearchResult;
|
||||||
|
})
|
||||||
|
: null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const performSearch = useCallback(async (keyword: string, selectedSourceId: string, options?: { forceRefresh?: boolean }) => {
|
const performSearch = useCallback(
|
||||||
|
async (
|
||||||
|
keyword: string,
|
||||||
|
selectedSourceId: string,
|
||||||
|
options?: { forceRefresh?: boolean }
|
||||||
|
) => {
|
||||||
const trimmed = keyword.trim();
|
const trimmed = keyword.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
const normalizedSourceId = selectedSourceId || '';
|
const normalizedSourceId = selectedSourceId || '';
|
||||||
@@ -145,10 +218,16 @@ export default function BooksSearchPage() {
|
|||||||
setTotalSources(0);
|
setTotalSources(0);
|
||||||
setCompletedSources(0);
|
setCompletedSources(0);
|
||||||
|
|
||||||
const cached = forceRefresh ? null : getCachedResult(trimmed, normalizedSourceId);
|
const cached = forceRefresh
|
||||||
|
? null
|
||||||
|
: getCachedResult(trimmed, normalizedSourceId);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
setResult(cached);
|
setResult(cached);
|
||||||
saveSearchState({ q: trimmed, sourceId: normalizedSourceId, result: cached });
|
saveSearchState({
|
||||||
|
q: trimmed,
|
||||||
|
sourceId: normalizedSourceId,
|
||||||
|
result: cached,
|
||||||
|
});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setTotalSources(1);
|
setTotalSources(1);
|
||||||
setCompletedSources(1);
|
setCompletedSources(1);
|
||||||
@@ -158,7 +237,9 @@ export default function BooksSearchPage() {
|
|||||||
setResult(EMPTY_RESULT);
|
setResult(EMPTY_RESULT);
|
||||||
|
|
||||||
const currentFluidSearch = readFluidSearchSetting();
|
const currentFluidSearch = readFluidSearchSetting();
|
||||||
setUseFluidSearch((prev) => (prev === currentFluidSearch ? prev : currentFluidSearch));
|
setUseFluidSearch((prev) =>
|
||||||
|
prev === currentFluidSearch ? prev : currentFluidSearch
|
||||||
|
);
|
||||||
|
|
||||||
const params = new URLSearchParams({ q: trimmed });
|
const params = new URLSearchParams({ q: trimmed });
|
||||||
if (normalizedSourceId) params.set('sourceId', normalizedSourceId);
|
if (normalizedSourceId) params.set('sourceId', normalizedSourceId);
|
||||||
@@ -177,23 +258,32 @@ export default function BooksSearchPage() {
|
|||||||
setCompletedSources(0);
|
setCompletedSources(0);
|
||||||
break;
|
break;
|
||||||
case 'source_result':
|
case 'source_result':
|
||||||
setCompletedSources((prev) => Math.max(prev + 1, payload.completedSources || 0));
|
setCompletedSources((prev) =>
|
||||||
if (Array.isArray(payload.results) && payload.results.length > 0) {
|
Math.max(prev + 1, payload.completedSources || 0)
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
Array.isArray(payload.results) &&
|
||||||
|
payload.results.length > 0
|
||||||
|
) {
|
||||||
appendBufferedResults(payload.results as BookListItem[]);
|
appendBufferedResults(payload.results as BookListItem[]);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'source_error': {
|
case 'source_error':
|
||||||
setCompletedSources((prev) => Math.max(prev + 1, payload.completedSources || 0));
|
setCompletedSources((prev) =>
|
||||||
|
Math.max(prev + 1, payload.completedSources || 0)
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
case 'error':
|
case 'error':
|
||||||
setError(payload.error || '搜索失败');
|
setError(payload.error || '搜索失败');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
closeEventSource();
|
closeEventSource();
|
||||||
break;
|
break;
|
||||||
case 'complete': {
|
case 'complete': {
|
||||||
const finalFailedSources: BookSearchResult['failedSources'] = [];
|
const finalFailedSources: BookSearchResult['failedSources'] =
|
||||||
setCompletedSources(payload.completedSources || payload.totalSources || 0);
|
[];
|
||||||
|
setCompletedSources(
|
||||||
|
payload.completedSources || payload.totalSources || 0
|
||||||
|
);
|
||||||
if (pendingResultsRef.current.length > 0) {
|
if (pendingResultsRef.current.length > 0) {
|
||||||
const toAppend = pendingResultsRef.current;
|
const toAppend = pendingResultsRef.current;
|
||||||
pendingResultsRef.current = [];
|
pendingResultsRef.current = [];
|
||||||
@@ -203,17 +293,31 @@ export default function BooksSearchPage() {
|
|||||||
}
|
}
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setResult((prev) => {
|
setResult((prev) => {
|
||||||
const nextResult = { results: prev.results.concat(toAppend), failedSources: finalFailedSources };
|
const nextResult = {
|
||||||
|
results: prev.results.concat(toAppend),
|
||||||
|
failedSources: finalFailedSources,
|
||||||
|
};
|
||||||
setCachedResult(trimmed, normalizedSourceId, nextResult);
|
setCachedResult(trimmed, normalizedSourceId, nextResult);
|
||||||
saveSearchState({ q: trimmed, sourceId: normalizedSourceId, result: nextResult });
|
saveSearchState({
|
||||||
|
q: trimmed,
|
||||||
|
sourceId: normalizedSourceId,
|
||||||
|
result: nextResult,
|
||||||
|
});
|
||||||
return nextResult;
|
return nextResult;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setResult((prev) => {
|
setResult((prev) => {
|
||||||
const nextResult = { results: prev.results, failedSources: finalFailedSources };
|
const nextResult = {
|
||||||
|
results: prev.results,
|
||||||
|
failedSources: finalFailedSources,
|
||||||
|
};
|
||||||
setCachedResult(trimmed, normalizedSourceId, nextResult);
|
setCachedResult(trimmed, normalizedSourceId, nextResult);
|
||||||
saveSearchState({ q: trimmed, sourceId: normalizedSourceId, result: nextResult });
|
saveSearchState({
|
||||||
|
q: trimmed,
|
||||||
|
sourceId: normalizedSourceId,
|
||||||
|
result: nextResult,
|
||||||
|
});
|
||||||
return nextResult;
|
return nextResult;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -222,7 +326,9 @@ export default function BooksSearchPage() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {
|
||||||
|
// Ignore malformed streaming payloads.
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
es.onerror = () => {
|
es.onerror = () => {
|
||||||
@@ -235,7 +341,10 @@ export default function BooksSearchPage() {
|
|||||||
flushTimerRef.current = null;
|
flushTimerRef.current = null;
|
||||||
}
|
}
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setResult((prev) => ({ ...prev, results: prev.results.concat(toAppend) }));
|
setResult((prev) => ({
|
||||||
|
...prev,
|
||||||
|
results: prev.results.concat(toAppend),
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -249,12 +358,19 @@ export default function BooksSearchPage() {
|
|||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (currentSearchKeyRef.current !== searchKey) return;
|
if (currentSearchKeyRef.current !== searchKey) return;
|
||||||
if (!res.ok) throw new Error(json.error || '搜索失败');
|
if (!res.ok) throw new Error(json.error || '搜索失败');
|
||||||
const nextResult: BookSearchResult = { results: json.results || [], failedSources: [] };
|
const nextResult: BookSearchResult = {
|
||||||
|
results: json.results || [],
|
||||||
|
failedSources: [],
|
||||||
|
};
|
||||||
setResult(nextResult);
|
setResult(nextResult);
|
||||||
setTotalSources(1);
|
setTotalSources(1);
|
||||||
setCompletedSources(1);
|
setCompletedSources(1);
|
||||||
setCachedResult(trimmed, normalizedSourceId, nextResult);
|
setCachedResult(trimmed, normalizedSourceId, nextResult);
|
||||||
saveSearchState({ q: trimmed, sourceId: normalizedSourceId, result: nextResult });
|
saveSearchState({
|
||||||
|
q: trimmed,
|
||||||
|
sourceId: normalizedSourceId,
|
||||||
|
result: nextResult,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (currentSearchKeyRef.current !== searchKey) return;
|
if (currentSearchKeyRef.current !== searchKey) return;
|
||||||
setError((err as Error).message || '搜索失败');
|
setError((err as Error).message || '搜索失败');
|
||||||
@@ -264,11 +380,24 @@ export default function BooksSearchPage() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [appendBufferedResults, clearPendingResults, closeEventSource, getCachedResult, readFluidSearchSetting, saveSearchState, setCachedResult]);
|
},
|
||||||
|
[
|
||||||
|
appendBufferedResults,
|
||||||
|
clearPendingResults,
|
||||||
|
closeEventSource,
|
||||||
|
getCachedResult,
|
||||||
|
readFluidSearchSetting,
|
||||||
|
saveSearchState,
|
||||||
|
setCachedResult,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUseFluidSearch(readFluidSearchSetting());
|
setUseFluidSearch(readFluidSearchSetting());
|
||||||
fetch('/api/books/sources').then((res) => res.json()).then((json) => setSources(json.sources || [])).catch(() => undefined);
|
fetch('/api/books/sources')
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((json) => setSources(json.sources || []))
|
||||||
|
.catch(() => undefined);
|
||||||
return () => {
|
return () => {
|
||||||
closeEventSource();
|
closeEventSource();
|
||||||
clearPendingResults();
|
clearPendingResults();
|
||||||
@@ -310,7 +439,14 @@ export default function BooksSearchPage() {
|
|||||||
const forceRefresh = forceNextUrlSearchRef.current;
|
const forceRefresh = forceNextUrlSearchRef.current;
|
||||||
forceNextUrlSearchRef.current = false;
|
forceNextUrlSearchRef.current = false;
|
||||||
void performSearch(keyword, source, { forceRefresh });
|
void performSearch(keyword, source, { forceRefresh });
|
||||||
}, [clearPendingResults, closeEventSource, performSearch, restoreSearchState, urlQuery, urlSourceId]);
|
}, [
|
||||||
|
clearPendingResults,
|
||||||
|
closeEventSource,
|
||||||
|
performSearch,
|
||||||
|
restoreSearchState,
|
||||||
|
urlQuery,
|
||||||
|
urlSourceId,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -328,32 +464,217 @@ export default function BooksSearchPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const selectedSourceName = useMemo(() => {
|
||||||
<div className='space-y-6'>
|
if (!sourceId) return '全部书源';
|
||||||
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
return sources.find((source) => source.id === sourceId)?.name || '当前书源';
|
||||||
<form onSubmit={handleSubmit} className='space-y-3'>
|
}, [sourceId, sources]);
|
||||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder='搜索书名 / 作者' className='w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none dark:border-gray-700 dark:bg-gray-900' />
|
|
||||||
<select value={sourceId} onChange={(e) => setSourceId(e.target.value)} className='w-full rounded-2xl border border-gray-200 px-4 py-3 dark:border-gray-700 dark:bg-gray-900'>
|
|
||||||
<option value=''>全部书源</option>
|
|
||||||
{sources.map((source) => <option key={source.id} value={source.id}>{source.name}</option>)}
|
|
||||||
</select>
|
|
||||||
<button className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'>搜索</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className='flex items-center justify-between gap-3'>
|
const searchProgress =
|
||||||
<h2 className='text-lg font-semibold'>搜索结果{result.results.length > 0 ? `(${result.results.length})` : ''}</h2>
|
totalSources > 0
|
||||||
{loading && useFluidSearch && totalSources > 0 ? (
|
? Math.min(100, Math.round((completedSources / totalSources) * 100))
|
||||||
<span className='text-xs text-gray-500 dark:text-gray-400'>搜索中 {completedSources}/{totalSources}</span>
|
: 0;
|
||||||
) : null}
|
|
||||||
|
const submitSearch = useCallback(
|
||||||
|
(keyword: string) => {
|
||||||
|
const trimmed = keyword.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('q', trimmed);
|
||||||
|
if (sourceId) params.set('sourceId', sourceId);
|
||||||
|
forceNextUrlSearchRef.current = true;
|
||||||
|
router.replace(`/books/search?${params.toString()}`);
|
||||||
|
},
|
||||||
|
[router, sourceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-7'>
|
||||||
|
<section className='relative overflow-hidden rounded-[2.25rem] border border-emerald-100 bg-gradient-to-br from-emerald-50 via-white to-amber-50 p-5 shadow-sm shadow-emerald-950/5 dark:border-emerald-500/10 dark:from-emerald-950/30 dark:via-gray-950 dark:to-amber-950/20 sm:p-7'>
|
||||||
|
<div className='absolute -right-20 -top-24 h-64 w-64 rounded-full bg-emerald-300/25 blur-3xl dark:bg-emerald-500/10' />
|
||||||
|
<div className='absolute -bottom-28 left-1/4 h-64 w-64 rounded-full bg-amber-300/20 blur-3xl dark:bg-amber-500/10' />
|
||||||
|
<div className='relative'>
|
||||||
|
<div className='inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-white/75 px-3 py-1 text-xs font-semibold text-emerald-700 shadow-sm backdrop-blur dark:border-emerald-500/20 dark:bg-gray-950/50 dark:text-emerald-200'>
|
||||||
|
<Sparkles className='h-3.5 w-3.5' />
|
||||||
|
Search First · Reduce Friction
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && result.results.length === 0 ? <SearchSkeleton /> : null}
|
<div className='mt-5 grid gap-6 lg:grid-cols-[0.95fr_1.05fr] lg:items-end'>
|
||||||
{error ? <div className='rounded-2xl bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/20 dark:text-red-300'>{error}</div> : null}
|
<div>
|
||||||
<section className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
<h1 className='text-4xl font-black tracking-[-0.06em] text-emerald-950 dark:text-emerald-50 sm:text-6xl lg:text-7xl'>
|
||||||
{result.results.map((item) => <BookCard key={`${item.sourceId}-${item.id}`} item={item} href={detailHref(item)} onNavigate={() => cacheBookListItem(item)} />)}
|
找到下一本书
|
||||||
|
</h1>
|
||||||
|
<div className='mt-5 flex flex-wrap gap-2'>
|
||||||
|
{QUICK_SEARCHES.map((keyword) => (
|
||||||
|
<button
|
||||||
|
key={keyword}
|
||||||
|
type='button'
|
||||||
|
onClick={() => {
|
||||||
|
setQ(keyword);
|
||||||
|
submitSearch(keyword);
|
||||||
|
}}
|
||||||
|
className='inline-flex cursor-pointer items-center gap-1.5 rounded-full border border-emerald-200 bg-white/70 px-3 py-1.5 text-xs font-medium text-emerald-800 transition-colors duration-200 hover:bg-emerald-50 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:border-emerald-500/20 dark:bg-gray-950/50 dark:text-emerald-100 dark:hover:bg-emerald-500/10'
|
||||||
|
>
|
||||||
|
<Search className='h-3.5 w-3.5' />
|
||||||
|
{keyword}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className='rounded-[2rem] border border-white/80 bg-white/85 p-3 shadow-xl shadow-emerald-950/10 backdrop-blur dark:border-white/10 dark:bg-gray-950/70'
|
||||||
|
>
|
||||||
|
<div className='grid gap-3 lg:grid-cols-[1fr_13rem_auto]'>
|
||||||
|
<label className='relative block'>
|
||||||
|
<span className='mb-2 block px-1 text-xs font-semibold uppercase tracking-[0.18em] text-emerald-700 dark:text-emerald-200'>
|
||||||
|
关键词
|
||||||
|
</span>
|
||||||
|
<Search className='pointer-events-none absolute bottom-3.5 left-4 h-5 w-5 text-emerald-400' />
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder='搜索书名 / 作者'
|
||||||
|
className='h-12 w-full rounded-2xl border border-emerald-100 bg-white pl-11 pr-11 text-base font-medium text-slate-900 outline-none transition-colors duration-200 placeholder:text-slate-400 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-500/20 dark:border-emerald-500/10 dark:bg-gray-900 dark:text-white'
|
||||||
|
/>
|
||||||
|
{q ? (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => setQ('')}
|
||||||
|
className='absolute bottom-2.5 right-2.5 inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-full text-slate-400 transition-colors duration-200 hover:bg-emerald-50 hover:text-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
|
||||||
|
aria-label='清空搜索关键词'
|
||||||
|
>
|
||||||
|
<X className='h-4 w-4' />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className='block'>
|
||||||
|
<span className='mb-2 block px-1 text-xs font-semibold uppercase tracking-[0.18em] text-emerald-700 dark:text-emerald-200'>
|
||||||
|
书源
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={sourceId}
|
||||||
|
onChange={(e) => setSourceId(e.target.value)}
|
||||||
|
className='h-12 w-full cursor-pointer rounded-2xl border border-emerald-100 bg-white px-4 text-sm font-medium text-slate-900 outline-none transition-colors duration-200 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-500/20 dark:border-emerald-500/10 dark:bg-gray-900 dark:text-white'
|
||||||
|
>
|
||||||
|
<option value=''>全部书源</option>
|
||||||
|
{sources.map((source) => (
|
||||||
|
<option key={source.id} value={source.id}>
|
||||||
|
{source.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className='flex items-end'>
|
||||||
|
<button
|
||||||
|
type='submit'
|
||||||
|
disabled={loading}
|
||||||
|
className='inline-flex h-12 w-full cursor-pointer items-center justify-center gap-2 rounded-2xl bg-emerald-600 px-6 text-sm font-bold text-white shadow-lg shadow-emerald-600/20 transition-colors duration-200 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-70 dark:focus:ring-offset-gray-950 lg:w-auto'
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className='h-4 w-4 animate-spin' />
|
||||||
|
) : (
|
||||||
|
<Search className='h-4 w-4' />
|
||||||
|
)}
|
||||||
|
{loading ? '搜索中' : '搜索'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{!loading && hasSearched && !error && result.results.length === 0 ? <div className='text-sm text-gray-500'>暂无结果</div> : null}
|
|
||||||
|
<section className='rounded-[2rem] border border-emerald-100/80 bg-white/75 p-4 shadow-sm shadow-emerald-950/5 backdrop-blur dark:border-emerald-500/10 dark:bg-gray-950/60 sm:p-5'>
|
||||||
|
<div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between'>
|
||||||
|
<div className='min-w-0'>
|
||||||
|
<div className='flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-emerald-600 dark:text-emerald-300'>
|
||||||
|
<BookMarked className='h-4 w-4' />
|
||||||
|
Results
|
||||||
|
</div>
|
||||||
|
<h2 className='mt-1 text-2xl font-black tracking-tight text-slate-950 dark:text-white'>
|
||||||
|
{hasSearched
|
||||||
|
? `搜索结果${
|
||||||
|
result.results.length > 0
|
||||||
|
? `(${result.results.length})`
|
||||||
|
: ''
|
||||||
|
}`
|
||||||
|
: '等待搜索'}
|
||||||
|
</h2>
|
||||||
|
<p className='mt-1 text-sm text-slate-500 dark:text-slate-400'>
|
||||||
|
当前范围:{selectedSourceName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-wrap items-center gap-2'>
|
||||||
|
<span className='inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-3 py-1.5 text-xs font-medium text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200'>
|
||||||
|
<Layers3 className='h-3.5 w-3.5' />
|
||||||
|
{sources.length || 0} 个书源
|
||||||
|
</span>
|
||||||
|
{loading && useFluidSearch && totalSources > 0 ? (
|
||||||
|
<span className='inline-flex items-center gap-1.5 rounded-full bg-amber-50 px-3 py-1.5 text-xs font-medium text-amber-700 dark:bg-amber-500/10 dark:text-amber-200'>
|
||||||
|
<Loader2 className='h-3.5 w-3.5 animate-spin' />
|
||||||
|
搜索中 {completedSources}/{totalSources}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{loading && useFluidSearch && totalSources > 0 ? (
|
||||||
|
<div className='mt-4 h-2 overflow-hidden rounded-full bg-emerald-50 dark:bg-gray-900'>
|
||||||
|
<div
|
||||||
|
className='h-full rounded-full bg-emerald-600 transition-all duration-300'
|
||||||
|
style={{ width: `${searchProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{loading && result.results.length === 0 ? <SearchSkeleton /> : null}
|
||||||
|
{error ? (
|
||||||
|
<div className='rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-500/20 dark:bg-red-950/20 dark:text-red-300'>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<section className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
||||||
|
{result.results.map((item) => (
|
||||||
|
<BookCard
|
||||||
|
key={`${item.sourceId}-${item.id}`}
|
||||||
|
item={item}
|
||||||
|
href={detailHref(item)}
|
||||||
|
onNavigate={() => cacheBookListItem(item)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
{!loading && hasSearched && !error && result.results.length === 0 ? (
|
||||||
|
<div className='rounded-[2rem] border border-dashed border-emerald-200 bg-white/75 p-8 text-center shadow-sm dark:border-emerald-500/20 dark:bg-gray-950/50'>
|
||||||
|
<div className='mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-200'>
|
||||||
|
<Search className='h-6 w-6' />
|
||||||
|
</div>
|
||||||
|
<h3 className='mt-4 text-lg font-bold text-slate-950 dark:text-white'>
|
||||||
|
没有找到匹配书籍
|
||||||
|
</h3>
|
||||||
|
<p className='mx-auto mt-2 max-w-md text-sm leading-6 text-slate-500 dark:text-slate-400'>
|
||||||
|
试试更短的关键词、作者名,或切换到全部书源重新搜索。
|
||||||
|
</p>
|
||||||
|
<div className='mt-5 flex flex-wrap justify-center gap-2'>
|
||||||
|
{QUICK_SEARCHES.map((keyword) => (
|
||||||
|
<button
|
||||||
|
key={keyword}
|
||||||
|
type='button'
|
||||||
|
onClick={() => {
|
||||||
|
setQ(keyword);
|
||||||
|
submitSearch(keyword);
|
||||||
|
}}
|
||||||
|
className='cursor-pointer rounded-full border border-emerald-200 bg-white px-3 py-1.5 text-xs font-medium text-emerald-800 transition-colors duration-200 hover:bg-emerald-50 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:border-emerald-500/20 dark:bg-gray-950 dark:text-emerald-100 dark:hover:bg-emerald-500/10'
|
||||||
|
>
|
||||||
|
搜索 {keyword}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,128 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { BookmarkCheck, BookOpen, Trash2 } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { buildBookDetailPath, cacheBookShelfItem } from '@/lib/book-route-cache.client';
|
|
||||||
import { deleteBookShelf, getAllBookShelf } from '@/lib/book.db.client';
|
import { deleteBookShelf, getAllBookShelf } from '@/lib/book.db.client';
|
||||||
import { BookShelfItem } from '@/lib/book.types';
|
import { BookShelfItem } from '@/lib/book.types';
|
||||||
|
import {
|
||||||
|
buildBookDetailPath,
|
||||||
|
cacheBookShelfItem,
|
||||||
|
} from '@/lib/book-route-cache.client';
|
||||||
|
|
||||||
export default function BookShelfPage() {
|
export default function BookShelfPage() {
|
||||||
const [shelf, setShelf] = useState<Record<string, BookShelfItem>>({});
|
const [shelf, setShelf] = useState<Record<string, BookShelfItem>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getAllBookShelf().then(setShelf).catch(() => undefined);
|
getAllBookShelf()
|
||||||
|
.then(setShelf)
|
||||||
|
.catch(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const items = useMemo(() => Object.values(shelf).sort((a, b) => (b.lastReadTime || b.saveTime) - (a.lastReadTime || a.saveTime)), [shelf]);
|
const items = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.values(shelf).sort(
|
||||||
|
(a, b) =>
|
||||||
|
(b.lastReadTime || b.saveTime) - (a.lastReadTime || a.saveTime)
|
||||||
|
),
|
||||||
|
[shelf]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-4'>
|
<div className='space-y-5'>
|
||||||
<div className='text-sm text-gray-500'>共 {items.length} 本电子书</div>
|
<section className='rounded-[2rem] border border-emerald-100/80 bg-white/85 p-5 shadow-sm shadow-emerald-950/5 dark:border-emerald-500/10 dark:bg-gray-950/70'>
|
||||||
|
<div className='flex items-center justify-between gap-4'>
|
||||||
|
<div>
|
||||||
|
<div className='text-sm font-medium text-emerald-600 dark:text-emerald-300'>
|
||||||
|
我的书架
|
||||||
|
</div>
|
||||||
|
<div className='mt-1 text-2xl font-black tracking-tight text-slate-950 dark:text-white'>
|
||||||
|
共 {items.length} 本电子书
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50 text-emerald-600 ring-1 ring-emerald-100 dark:bg-emerald-500/10 dark:text-emerald-200 dark:ring-emerald-500/20'>
|
||||||
|
<BookmarkCheck className='h-6 w-6' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<div key={`${item.sourceId}-${item.bookId}`} className='rounded-3xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
<article
|
||||||
|
key={`${item.sourceId}-${item.bookId}`}
|
||||||
|
className='rounded-[2rem] border border-emerald-100/80 bg-white/85 p-4 shadow-sm shadow-emerald-950/5 transition-colors duration-200 hover:border-emerald-200 hover:bg-white dark:border-emerald-500/10 dark:bg-gray-950/70 dark:hover:border-emerald-500/30'
|
||||||
|
>
|
||||||
<div className='flex gap-4'>
|
<div className='flex gap-4'>
|
||||||
<div className='h-28 w-20 overflow-hidden rounded-2xl bg-gray-100 dark:bg-gray-900'>{item.cover ? <img src={item.cover} alt={item.title} className='h-full w-full object-cover' /> : null}</div>
|
<div className='h-28 w-20 shrink-0 overflow-hidden rounded-2xl bg-gradient-to-br from-emerald-50 to-amber-50 ring-1 ring-emerald-100 dark:from-gray-900 dark:to-emerald-950/20 dark:ring-emerald-500/10'>
|
||||||
|
{item.cover ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={item.cover}
|
||||||
|
alt={item.title}
|
||||||
|
className='h-full w-full object-cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='flex h-full items-center justify-center text-slate-400'>
|
||||||
|
<BookOpen className='h-7 w-7' />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className='min-w-0 flex-1'>
|
<div className='min-w-0 flex-1'>
|
||||||
<div className='truncate font-medium'>{item.title}</div>
|
<div className='truncate font-semibold text-slate-950 dark:text-white'>
|
||||||
<div className='mt-1 text-sm text-gray-500'>{item.author || item.sourceName}</div>
|
{item.title}
|
||||||
<div className='mt-2 text-xs text-gray-500'>进度 {Math.round(item.progressPercent || 0)}%</div>
|
</div>
|
||||||
|
<div className='mt-1 truncate text-sm text-slate-500 dark:text-slate-400'>
|
||||||
|
{item.author || item.sourceName}
|
||||||
|
</div>
|
||||||
|
<div className='mt-3 h-2 overflow-hidden rounded-full bg-emerald-50 dark:bg-gray-900'>
|
||||||
|
<div
|
||||||
|
className='h-full rounded-full bg-emerald-600'
|
||||||
|
style={{
|
||||||
|
width: `${Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(100, Math.round(item.progressPercent || 0))
|
||||||
|
)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='mt-1 text-xs text-slate-500 dark:text-slate-400'>
|
||||||
|
进度 {Math.round(item.progressPercent || 0)}%
|
||||||
|
</div>
|
||||||
<div className='mt-3 flex flex-wrap gap-2'>
|
<div className='mt-3 flex flex-wrap gap-2'>
|
||||||
<Link href={buildBookDetailPath(item.sourceId, item.bookId)} onClick={() => cacheBookShelfItem(item)} className='rounded-2xl bg-sky-600 px-3 py-2 text-xs text-white'>详情</Link>
|
<Link
|
||||||
<button onClick={async () => { await deleteBookShelf(item.sourceId, item.bookId); setShelf((prev) => { const next = { ...prev }; delete next[`${item.sourceId}+${item.bookId}`]; return next; }); }} className='rounded-2xl border border-gray-200 px-3 py-2 text-xs dark:border-gray-700'>移除</button>
|
href={buildBookDetailPath(item.sourceId, item.bookId)}
|
||||||
</div>
|
onClick={() => cacheBookShelfItem(item)}
|
||||||
|
className='inline-flex cursor-pointer items-center gap-1.5 rounded-2xl bg-emerald-600 px-3 py-2 text-xs font-semibold text-white transition-colors duration-200 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500'
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteBookShelf(item.sourceId, item.bookId);
|
||||||
|
setShelf((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[`${item.sourceId}+${item.bookId}`];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className='inline-flex cursor-pointer items-center gap-1.5 rounded-2xl border border-emerald-100 px-3 py-2 text-xs font-semibold text-slate-600 transition-colors duration-200 hover:bg-emerald-50 hover:text-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:border-emerald-500/10 dark:text-slate-300 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
|
||||||
|
>
|
||||||
|
<Trash2 className='h-3.5 w-3.5' />
|
||||||
|
移除
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{items.length === 0 ? <div className='text-sm text-gray-500'>书架还是空的</div> : null}
|
{items.length === 0 ? (
|
||||||
|
<div className='rounded-3xl border border-dashed border-emerald-200 bg-white/70 p-8 text-center text-sm text-slate-500 dark:border-emerald-500/20 dark:bg-gray-950/50 dark:text-slate-400'>
|
||||||
|
书架还是空的
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,62 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { BookOpen, Library } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { BookListItem } from '@/lib/book.types';
|
import { BookListItem } from '@/lib/book.types';
|
||||||
|
|
||||||
export default function BookCard({ item, href, extra, onNavigate }: { item: BookListItem; href: string; extra?: React.ReactNode; onNavigate?: () => void }) {
|
export default function BookCard({
|
||||||
|
item,
|
||||||
|
href,
|
||||||
|
extra,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
item: BookListItem;
|
||||||
|
href: string;
|
||||||
|
extra?: React.ReactNode;
|
||||||
|
onNavigate?: () => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className='overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
<article className='group overflow-hidden rounded-[1.75rem] border border-emerald-100/80 bg-white/85 shadow-sm shadow-emerald-950/5 transition-colors duration-200 hover:border-emerald-200 hover:bg-white dark:border-emerald-500/10 dark:bg-gray-950/70 dark:hover:border-emerald-500/30'>
|
||||||
<Link href={href} onClick={onNavigate}>
|
<Link
|
||||||
<div className='relative aspect-[3/4] bg-gray-100 dark:bg-gray-900'>
|
href={href}
|
||||||
|
onClick={onNavigate}
|
||||||
|
className='block cursor-pointer focus:outline-none focus:ring-2 focus:ring-inset focus:ring-emerald-500'
|
||||||
|
>
|
||||||
|
<div className='relative aspect-[3/4] overflow-hidden bg-gradient-to-br from-emerald-50 to-amber-50 dark:from-gray-900 dark:to-emerald-950/20'>
|
||||||
{item.cover ? (
|
{item.cover ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={item.cover} alt={item.title} className='h-full w-full object-cover' />
|
<img
|
||||||
|
src={item.cover}
|
||||||
|
alt={item.title}
|
||||||
|
className='h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]'
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className='flex h-full items-center justify-center text-sm text-gray-400'>无封面</div>
|
<div className='flex h-full flex-col items-center justify-center gap-2 text-sm text-slate-400 dark:text-slate-500'>
|
||||||
|
<BookOpen className='h-8 w-8' />
|
||||||
|
无封面
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className='absolute right-2 top-2 max-w-[70%] truncate rounded-full bg-black/70 px-2 py-1 text-[11px] text-white'>
|
<div className='absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/55 to-transparent opacity-80' />
|
||||||
{item.sourceName}
|
<div className='absolute right-2 top-2 inline-flex max-w-[74%] items-center gap-1.5 truncate rounded-full bg-black/65 px-2.5 py-1 text-[11px] font-medium text-white shadow-lg backdrop-blur'>
|
||||||
|
<Library className='h-3 w-3 shrink-0' />
|
||||||
|
<span className='truncate'>{item.sourceName}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<div className='space-y-2 p-3'>
|
<div className='space-y-2 p-3.5'>
|
||||||
<Link href={href} onClick={onNavigate} className='line-clamp-2 text-sm font-medium hover:text-sky-600'>{item.title}</Link>
|
<Link
|
||||||
<div className='line-clamp-1 text-xs text-gray-500 dark:text-gray-400'>{item.author || '未知作者'}</div>
|
href={href}
|
||||||
|
onClick={onNavigate}
|
||||||
|
className='line-clamp-2 cursor-pointer text-sm font-semibold leading-5 text-slate-950 transition-colors duration-200 hover:text-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:text-white dark:hover:text-emerald-200'
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
<div className='line-clamp-1 text-xs text-slate-500 dark:text-slate-400'>
|
||||||
|
{item.author || '未知作者'}
|
||||||
|
</div>
|
||||||
{extra}
|
{extra}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { BookOpen, ChevronLeft, Headphones, History, Library, List, MoreVertical, Search, Settings2 } from 'lucide-react';
|
import {
|
||||||
|
BookOpen,
|
||||||
|
ChevronLeft,
|
||||||
|
Headphones,
|
||||||
|
History,
|
||||||
|
Library,
|
||||||
|
List,
|
||||||
|
MoreVertical,
|
||||||
|
Search,
|
||||||
|
Settings2,
|
||||||
|
Sparkles,
|
||||||
|
} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname, useSearchParams } from 'next/navigation';
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { useSite } from '@/components/SiteProvider';
|
import { useSite } from '@/components/SiteProvider';
|
||||||
|
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ href: '/books', label: '发现', icon: Library },
|
{ href: '/books', label: '发现', icon: Library },
|
||||||
@@ -21,15 +33,24 @@ type ReadHeaderPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function getStaticMeta(pathname: string) {
|
function getStaticMeta(pathname: string) {
|
||||||
if (pathname === '/books/shelf') return { title: '电子书书架', subtitle: '集中管理收藏的电子书' };
|
if (pathname === '/books/shelf')
|
||||||
if (pathname === '/books/history') return { title: '阅读历史', subtitle: '从上次阅读的位置继续' };
|
return { title: '电子书书架', subtitle: '集中管理收藏的电子书' };
|
||||||
if (pathname === '/books/search') return { title: '电子书搜索', subtitle: '按书名与作者搜索' };
|
if (pathname === '/books/history')
|
||||||
if (pathname === '/books/detail') return { title: '电子书详情', subtitle: '查看书籍信息与可用格式' };
|
return { title: '阅读历史', subtitle: '从上次阅读的位置继续' };
|
||||||
if (pathname === '/books/read') return { title: '电子书阅读', subtitle: '分页阅读', backHref: '/books' };
|
if (pathname === '/books/search')
|
||||||
|
return { title: '电子书搜索', subtitle: '按书名与作者搜索' };
|
||||||
|
if (pathname === '/books/detail')
|
||||||
|
return { title: '电子书详情', subtitle: '查看书籍信息与可用格式' };
|
||||||
|
if (pathname === '/books/read')
|
||||||
|
return { title: '电子书阅读', subtitle: '分页阅读', backHref: '/books' };
|
||||||
return { title: '电子书馆' };
|
return { title: '电子书馆' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BooksLayout({ children }: { children: React.ReactNode }) {
|
export default function BooksLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { siteName } = useSite();
|
const { siteName } = useSite();
|
||||||
@@ -44,9 +65,15 @@ export default function BooksLayout({ children }: { children: React.ReactNode })
|
|||||||
const custom = event as CustomEvent<ReadHeaderPayload>;
|
const custom = event as CustomEvent<ReadHeaderPayload>;
|
||||||
setReadHeader(custom.detail || null);
|
setReadHeader(custom.detail || null);
|
||||||
};
|
};
|
||||||
window.addEventListener('books-read-update-header', handleUpdate as EventListener);
|
window.addEventListener(
|
||||||
|
'books-read-update-header',
|
||||||
|
handleUpdate as EventListener
|
||||||
|
);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('books-read-update-header', handleUpdate as EventListener);
|
window.removeEventListener(
|
||||||
|
'books-read-update-header',
|
||||||
|
handleUpdate as EventListener
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}, [isRead]);
|
}, [isRead]);
|
||||||
|
|
||||||
@@ -80,39 +107,69 @@ export default function BooksLayout({ children }: { children: React.ReactNode })
|
|||||||
return {
|
return {
|
||||||
title: readHeader?.title || base.title,
|
title: readHeader?.title || base.title,
|
||||||
subtitle: readHeader?.subtitle || base.subtitle,
|
subtitle: readHeader?.subtitle || base.subtitle,
|
||||||
backHref: readHeader?.backHref || `/books/detail?sourceId=${encodeURIComponent(searchParams.get('sourceId') || '')}&bookId=${encodeURIComponent(searchParams.get('bookId') || '')}`,
|
backHref:
|
||||||
|
readHeader?.backHref ||
|
||||||
|
`/books/detail?sourceId=${encodeURIComponent(
|
||||||
|
searchParams.get('sourceId') || ''
|
||||||
|
)}&bookId=${encodeURIComponent(searchParams.get('bookId') || '')}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return base;
|
return base;
|
||||||
}, [pathname, searchParams, isRead, readHeader]);
|
}, [pathname, searchParams, isRead, readHeader]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='min-h-screen bg-gray-50 text-gray-900 dark:bg-black dark:text-gray-100'>
|
<div className='min-h-screen bg-[radial-gradient(circle_at_top_left,#fce7f3_0,transparent_34rem),linear-gradient(180deg,#fff7fb_0%,#f8fafc_44%,#f8fafc_100%)] text-slate-900 dark:bg-[radial-gradient(circle_at_top_left,rgba(6,95,70,0.26)_0,transparent_32rem),linear-gradient(180deg,#050505_0%,#09090b_100%)] dark:text-gray-100'>
|
||||||
<header className='fixed inset-x-0 top-0 z-40 border-b border-gray-200/70 bg-white/90 backdrop-blur dark:border-gray-800 dark:bg-gray-950/90'>
|
<header className='fixed inset-x-0 top-0 z-40 border-b border-emerald-100/80 bg-white/85 shadow-sm shadow-emerald-950/5 backdrop-blur-xl dark:border-emerald-500/10 dark:bg-gray-950/85 dark:shadow-black/20'>
|
||||||
<div className='mx-auto flex h-14 max-w-6xl items-center gap-3 px-4'>
|
<div className='mx-auto flex h-16 max-w-6xl items-center gap-3 px-4'>
|
||||||
{isRead || pathname === '/books/detail' ? (
|
{isRead || pathname === '/books/detail' ? (
|
||||||
<Link href={meta.backHref || '/books'} className='inline-flex h-10 w-10 items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800'>
|
<Link
|
||||||
|
href={meta.backHref || '/books'}
|
||||||
|
className='inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full text-slate-700 transition-colors duration-200 hover:bg-emerald-50 hover:text-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:text-slate-200 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
|
||||||
|
>
|
||||||
<ChevronLeft className='h-5 w-5' />
|
<ChevronLeft className='h-5 w-5' />
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<Link href='/' className='text-sm font-semibold text-sky-600'>{siteName}</Link>
|
<Link
|
||||||
|
href='/'
|
||||||
|
className='inline-flex cursor-pointer items-center gap-2 rounded-full bg-emerald-50 px-3 py-2 text-sm font-bold text-emerald-700 transition-colors duration-200 hover:bg-emerald-100 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:bg-emerald-500/10 dark:text-emerald-200 dark:hover:bg-emerald-500/20'
|
||||||
|
>
|
||||||
|
<Sparkles className='h-4 w-4' />
|
||||||
|
{siteName}
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
<div className='min-w-0 flex-1'>
|
<div className='min-w-0 flex-1'>
|
||||||
<div className='group relative'>
|
<div className='group relative'>
|
||||||
<div className='truncate text-sm font-semibold sm:text-base'>{meta.title}</div>
|
<div className='truncate text-sm font-bold tracking-tight text-slate-950 dark:text-white sm:text-base'>
|
||||||
|
{meta.title}
|
||||||
|
</div>
|
||||||
<div className='absolute left-1/2 top-full z-[100] mt-2 w-max max-w-[85vw] -translate-x-1/2 rounded-lg bg-gray-800 px-3 py-2 text-center text-sm text-white opacity-0 invisible shadow-xl transition-all duration-200 ease-out pointer-events-none group-hover:visible group-hover:opacity-100 dark:bg-gray-900'>
|
<div className='absolute left-1/2 top-full z-[100] mt-2 w-max max-w-[85vw] -translate-x-1/2 rounded-lg bg-gray-800 px-3 py-2 text-center text-sm text-white opacity-0 invisible shadow-xl transition-all duration-200 ease-out pointer-events-none group-hover:visible group-hover:opacity-100 dark:bg-gray-900'>
|
||||||
<div className='max-w-[85vw] break-words whitespace-normal sm:max-w-none sm:whitespace-nowrap'>{meta.title}</div>
|
<div className='max-w-[85vw] break-words whitespace-normal sm:max-w-none sm:whitespace-nowrap'>
|
||||||
{meta.subtitle ? <div className='mt-1 max-w-[85vw] break-words whitespace-normal text-xs text-gray-300 sm:max-w-none sm:whitespace-nowrap'>{meta.subtitle}</div> : null}
|
{meta.title}
|
||||||
|
</div>
|
||||||
|
{meta.subtitle ? (
|
||||||
|
<div className='mt-1 max-w-[85vw] break-words whitespace-normal text-xs text-gray-300 sm:max-w-none sm:whitespace-nowrap'>
|
||||||
|
{meta.subtitle}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='truncate text-xs text-gray-500 dark:text-gray-400'>{meta.subtitle}</div>
|
<div className='truncate text-xs text-slate-500 dark:text-slate-400'>
|
||||||
|
{meta.subtitle}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='hidden md:block'>
|
||||||
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
{isRead ? (
|
{isRead ? (
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
onClick={() => window.dispatchEvent(new CustomEvent('books-read-toggle-chapters'))}
|
onClick={() =>
|
||||||
className='inline-flex h-10 w-10 items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800'
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('books-read-toggle-chapters')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className='inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full transition-colors duration-200 hover:bg-emerald-50 hover:text-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
|
||||||
aria-label='目录'
|
aria-label='目录'
|
||||||
>
|
>
|
||||||
<List className='h-5 w-5' />
|
<List className='h-5 w-5' />
|
||||||
@@ -121,20 +178,22 @@ export default function BooksLayout({ children }: { children: React.ReactNode })
|
|||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
onClick={() => setReadMenuOpen((prev) => !prev)}
|
onClick={() => setReadMenuOpen((prev) => !prev)}
|
||||||
className='inline-flex h-10 w-10 items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800'
|
className='inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full transition-colors duration-200 hover:bg-emerald-50 hover:text-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
|
||||||
aria-label='更多'
|
aria-label='更多'
|
||||||
>
|
>
|
||||||
<MoreVertical className='h-5 w-5' />
|
<MoreVertical className='h-5 w-5' />
|
||||||
</button>
|
</button>
|
||||||
{readMenuOpen ? (
|
{readMenuOpen ? (
|
||||||
<div className='absolute right-0 top-12 z-50 min-w-[9rem] overflow-hidden rounded-2xl border border-gray-200 bg-white py-1 shadow-xl dark:border-gray-800 dark:bg-gray-950'>
|
<div className='absolute right-0 top-12 z-50 min-w-[9rem] overflow-hidden rounded-2xl border border-emerald-100 bg-white py-1 shadow-xl shadow-emerald-950/10 dark:border-emerald-500/10 dark:bg-gray-950'>
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setReadMenuOpen(false);
|
setReadMenuOpen(false);
|
||||||
window.dispatchEvent(new CustomEvent('books-read-toggle-settings'));
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('books-read-toggle-settings')
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
className='flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900'
|
className='flex w-full cursor-pointer items-center gap-2 px-4 py-2.5 text-left text-sm text-slate-700 transition-colors duration-200 hover:bg-emerald-50 hover:text-emerald-700 dark:text-gray-200 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
|
||||||
>
|
>
|
||||||
<Settings2 className='h-4 w-4' />
|
<Settings2 className='h-4 w-4' />
|
||||||
设置
|
设置
|
||||||
@@ -143,9 +202,11 @@ export default function BooksLayout({ children }: { children: React.ReactNode })
|
|||||||
type='button'
|
type='button'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setReadMenuOpen(false);
|
setReadMenuOpen(false);
|
||||||
window.dispatchEvent(new CustomEvent('books-read-toggle-tts'));
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('books-read-toggle-tts')
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
className='flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900'
|
className='flex w-full cursor-pointer items-center gap-2 px-4 py-2.5 text-left text-sm text-slate-700 transition-colors duration-200 hover:bg-emerald-50 hover:text-emerald-700 dark:text-gray-200 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
|
||||||
>
|
>
|
||||||
<Headphones className='h-4 w-4' />
|
<Headphones className='h-4 w-4' />
|
||||||
听书
|
听书
|
||||||
@@ -160,7 +221,15 @@ export default function BooksLayout({ children }: { children: React.ReactNode })
|
|||||||
const active = pathname === tab.href;
|
const active = pathname === tab.href;
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
return (
|
return (
|
||||||
<Link key={tab.href} href={tab.href} className={`inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm ${active ? 'bg-sky-600 text-white' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'}`}>
|
<Link
|
||||||
|
key={tab.href}
|
||||||
|
href={tab.href}
|
||||||
|
className={`inline-flex cursor-pointer items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-emerald-500 ${
|
||||||
|
active
|
||||||
|
? 'bg-emerald-600 text-white shadow-sm shadow-emerald-600/20'
|
||||||
|
: 'text-slate-600 hover:bg-emerald-50 hover:text-emerald-700 dark:text-gray-300 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<Icon className='h-4 w-4' />
|
<Icon className='h-4 w-4' />
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -170,16 +239,38 @@ export default function BooksLayout({ children }: { children: React.ReactNode })
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className={`mx-auto max-w-6xl ${isRead ? 'pt-14' : 'px-4 pb-24 pt-20'}`}>{children}</main>
|
<main
|
||||||
|
className={`mx-auto max-w-6xl ${isRead ? 'pt-16' : 'px-4 pb-24 pt-24'}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
{!isRead && (
|
{!isRead && (
|
||||||
<nav className='fixed inset-x-0 bottom-0 z-40 grid grid-cols-4 border-t border-gray-200/70 bg-white/95 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95 md:hidden'>
|
<nav className='fixed inset-x-0 bottom-0 z-40 grid grid-cols-4 border-t border-emerald-100/80 bg-white/95 shadow-[0_-12px_32px_rgba(6,95,70,0.08)] backdrop-blur-xl dark:border-emerald-500/10 dark:bg-gray-950/95 md:hidden'>
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const active = pathname === tab.href;
|
const active = pathname === tab.href;
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
return (
|
return (
|
||||||
<Link key={tab.href} href={tab.href} className='flex min-h-16 flex-col items-center justify-center gap-1 text-xs'>
|
<Link
|
||||||
<Icon className={`h-5 w-5 ${active ? 'text-sky-600' : 'text-gray-500'}`} />
|
key={tab.href}
|
||||||
<span className={active ? 'text-sky-600' : 'text-gray-600 dark:text-gray-300'}>{tab.label}</span>
|
href={tab.href}
|
||||||
|
className='flex min-h-16 cursor-pointer flex-col items-center justify-center gap-1 text-xs transition-colors duration-200 hover:bg-emerald-50 dark:hover:bg-emerald-500/10'
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={`h-5 w-5 ${
|
||||||
|
active
|
||||||
|
? 'text-emerald-600 dark:text-emerald-300'
|
||||||
|
: 'text-gray-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
active
|
||||||
|
? 'font-semibold text-emerald-600 dark:text-emerald-300'
|
||||||
|
: 'text-gray-600 dark:text-gray-300'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user