电子书架

This commit is contained in:
mtvpls
2026-04-29 11:30:45 +08:00
parent 0d99953e99
commit 3acb4e82cd
13 changed files with 891 additions and 255 deletions
+70 -26
View File
@@ -1,45 +1,89 @@
import { NextRequest, NextResponse } from 'next/server';
import { BookAcquisitionLink } from '@/lib/book.types';
import { db } from '@/lib/db';
import { opdsClient } from '@/lib/opds.client';
import { getAuthorizedBooksUsername } from '../_utils';
export const runtime = 'nodejs';
type DetailPayload = {
sourceId?: string;
bookId?: string;
href?: string;
title?: string;
author?: string;
cover?: string;
summary?: string;
acquisitionLinks?: BookAcquisitionLink[];
};
async function resolveDetail(username: string, payload: DetailPayload) {
const sourceId = payload.sourceId?.trim();
if (!sourceId) {
return NextResponse.json({ error: '缺少 sourceId' }, { status: 400 });
}
const bookId = payload.bookId?.trim();
if (!bookId) {
return NextResponse.json({ error: '缺少 bookId' }, { status: 400 });
}
const shelfItem = await db.getBookShelf(username, sourceId, bookId);
const readRecord = await db.getBookReadRecord(username, sourceId, bookId);
const href = payload.href?.trim() || shelfItem?.detailHref || readRecord?.detailHref || '';
const acquisitionLinks = (payload.acquisitionLinks && payload.acquisitionLinks.length > 0)
? payload.acquisitionLinks
: shelfItem?.acquisitionHref
? [{
rel: 'http://opds-spec.org/acquisition',
type: shelfItem.format === 'pdf' ? 'application/pdf' : 'application/epub+zip',
href: shelfItem.acquisitionHref,
}]
: readRecord?.acquisitionHref
? [{
rel: 'http://opds-spec.org/acquisition',
type: readRecord.format === 'pdf' ? 'application/pdf' : 'application/epub+zip',
href: readRecord.acquisitionHref,
}]
: undefined;
const detail = await opdsClient.getBookDetail(sourceId, href, {
id: bookId,
title: payload.title || shelfItem?.title || readRecord?.title || undefined,
author: payload.author || shelfItem?.author || readRecord?.author || undefined,
cover: payload.cover || shelfItem?.cover || readRecord?.cover || undefined,
summary: payload.summary || undefined,
detailHref: href || undefined,
acquisitionLinks,
});
return NextResponse.json(detail);
}
export async function GET(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const { searchParams } = new URL(request.url);
const sourceId = searchParams.get('sourceId')?.trim();
const href = searchParams.get('href')?.trim() || '';
if (!sourceId) {
return NextResponse.json({ error: '缺少 sourceId' }, { status: 400 });
}
const acquisitionLinksRaw = searchParams.get('acquisitionLinks');
let acquisitionLinks: BookAcquisitionLink[] | undefined;
if (acquisitionLinksRaw) {
try {
acquisitionLinks = JSON.parse(acquisitionLinksRaw) as BookAcquisitionLink[];
} catch {
acquisitionLinks = undefined;
}
}
const detail = await opdsClient.getBookDetail(sourceId, href, {
id: searchParams.get('bookId') || undefined,
title: searchParams.get('title') || undefined,
author: searchParams.get('author') || undefined,
cover: searchParams.get('cover') || undefined,
summary: searchParams.get('summary') || undefined,
detailHref: href || undefined,
acquisitionLinks,
return await resolveDetail(username, {
sourceId: searchParams.get('sourceId') || undefined,
bookId: searchParams.get('bookId') || undefined,
});
return NextResponse.json(detail);
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const payload = await request.json() as DetailPayload;
return await resolveDetail(username, payload);
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
+100 -43
View File
@@ -1,61 +1,118 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { opdsClient } from '@/lib/opds.client';
import { getAuthorizedBooksUsername } from '../_utils';
export const runtime = 'nodejs';
type FilePayload = {
sourceId?: string;
bookId?: string;
href?: string;
format?: 'epub' | 'pdf' | null;
};
async function resolveFileHref(username: string, payload: FilePayload): Promise<{ sourceId: string; href: string }> {
const sourceId = payload.sourceId?.trim();
if (!sourceId) throw new Error('缺少 sourceId');
if (payload.href?.trim()) {
return { sourceId, href: payload.href.trim() };
}
const bookId = payload.bookId?.trim();
if (!bookId) throw new Error('缺少 bookId 或 href');
const shelfItem = await db.getBookShelf(username, sourceId, bookId);
const readRecord = await db.getBookReadRecord(username, sourceId, bookId);
const directHref = shelfItem?.acquisitionHref || readRecord?.acquisitionHref;
if (directHref) {
return { sourceId, href: directHref };
}
const detailHref = shelfItem?.detailHref || readRecord?.detailHref;
if (!detailHref) throw new Error('找不到可下载文件');
const preferred = await opdsClient.getPreferredAcquisition(sourceId, detailHref);
if (payload.format && preferred.format !== payload.format) {
const detail = await opdsClient.getBookDetail(sourceId, detailHref);
const matched = detail.acquisitionLinks.find((item) => (payload.format === 'pdf' ? item.type.toLowerCase().includes('pdf') : item.type.toLowerCase().includes('epub')));
if (!matched?.href) throw new Error('找不到对应格式文件');
return { sourceId, href: matched.href };
}
return { sourceId, href: preferred.href };
}
async function proxyFile(request: NextRequest, sourceId: string, href: string) {
const source = await opdsClient.getSourceById(sourceId);
const headers = new Headers();
if (source.authMode === 'basic' && source.username) {
headers.set('Authorization', `Basic ${Buffer.from(`${source.username}:${source.password || ''}`).toString('base64')}`);
} else if (source.authMode === 'header' && source.headerName && source.headerValue) {
headers.set(source.headerName, source.headerValue);
}
const range = request.headers.get('range');
if (range) headers.set('Range', range);
const response = await fetch(href, {
headers,
redirect: 'follow',
cache: 'no-store',
});
if (!response.ok) {
return NextResponse.json({ error: `文件代理失败: ${response.status}` }, { status: response.status });
}
const outHeaders = new Headers();
const contentType = response.headers.get('content-type');
const contentLength = response.headers.get('content-length');
const acceptRanges = response.headers.get('accept-ranges');
const contentRange = response.headers.get('content-range');
const disposition = response.headers.get('content-disposition');
if (contentType) outHeaders.set('Content-Type', contentType);
if (contentLength) outHeaders.set('Content-Length', contentLength);
if (acceptRanges) outHeaders.set('Accept-Ranges', acceptRanges);
if (contentRange) outHeaders.set('Content-Range', contentRange);
if (disposition) outHeaders.set('Content-Disposition', disposition);
outHeaders.set('Cache-Control', 'private, max-age=300');
return new NextResponse(response.body, {
status: response.status,
headers: outHeaders,
});
}
export async function GET(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const { searchParams } = new URL(request.url);
const sourceId = searchParams.get('sourceId')?.trim();
const href = searchParams.get('href')?.trim();
if (!sourceId || !href) {
return NextResponse.json({ error: '缺少 sourceId 或 href' }, { status: 400 });
}
const source = await opdsClient.getSourceById(sourceId);
const headers = new Headers();
if (source.authMode === 'basic' && source.username) {
headers.set('Authorization', `Basic ${Buffer.from(`${source.username}:${source.password || ''}`).toString('base64')}`);
} else if (source.authMode === 'header' && source.headerName && source.headerValue) {
headers.set(source.headerName, source.headerValue);
}
const range = request.headers.get('range');
if (range) headers.set('Range', range);
const response = await fetch(href, {
headers,
redirect: 'follow',
cache: 'no-store',
});
if (!response.ok) {
return NextResponse.json({ error: `文件代理失败: ${response.status}` }, { status: response.status });
}
const outHeaders = new Headers();
const contentType = response.headers.get('content-type');
const contentLength = response.headers.get('content-length');
const acceptRanges = response.headers.get('accept-ranges');
const contentRange = response.headers.get('content-range');
const disposition = response.headers.get('content-disposition');
if (contentType) outHeaders.set('Content-Type', contentType);
if (contentLength) outHeaders.set('Content-Length', contentLength);
if (acceptRanges) outHeaders.set('Accept-Ranges', acceptRanges);
if (contentRange) outHeaders.set('Content-Range', contentRange);
if (disposition) outHeaders.set('Content-Disposition', disposition);
outHeaders.set('Cache-Control', 'private, max-age=300');
return new NextResponse(response.body, {
status: response.status,
headers: outHeaders,
const resolved = await resolveFileHref(username, {
sourceId: searchParams.get('sourceId') || undefined,
bookId: searchParams.get('bookId') || undefined,
href: searchParams.get('href') || undefined,
format: (searchParams.get('format')?.trim() as 'epub' | 'pdf' | null) || null,
});
return await proxyFile(request, resolved.sourceId, resolved.href);
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
}
}
export async function POST(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const payload = await request.json() as FilePayload;
const resolved = await resolveFileHref(username, payload);
return await proxyFile(request, resolved.sourceId, resolved.href);
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
}
}
+47 -16
View File
@@ -1,24 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import { BookAcquisitionLink } from '@/lib/book.types';
import { opdsClient } from '@/lib/opds.client';
import { db } from '@/lib/db';
import { opdsClient } from '@/lib/opds.client';
import { getAuthorizedBooksUsername } from '../../_utils';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
type ManifestPayload = {
sourceId?: string;
bookId?: string;
href?: string;
acquisitionHref?: string;
format?: 'epub' | 'pdf' | null;
title?: string;
author?: string;
cover?: string;
summary?: string;
};
async function resolveManifest(username: string, payload: ManifestPayload) {
try {
const { searchParams } = new URL(request.url);
const sourceId = searchParams.get('sourceId')?.trim();
const href = searchParams.get('href')?.trim();
const acquisitionHref = searchParams.get('acquisitionHref')?.trim();
const format = searchParams.get('format')?.trim() as 'epub' | 'pdf' | null;
const bookId = searchParams.get('bookId')?.trim();
const sourceId = payload.sourceId?.trim();
const href = payload.href?.trim();
const acquisitionHref = payload.acquisitionHref?.trim();
const format = payload.format;
const bookId = payload.bookId?.trim();
if (!sourceId) {
return NextResponse.json({ error: '缺少 sourceId' }, { status: 400 });
@@ -44,10 +52,10 @@ export async function GET(request: NextRequest) {
const detail = await opdsClient.getBookDetail(sourceId, resolvedHref || '', {
id: bookId || resolvedAcquisitionHref || undefined,
title: searchParams.get('title') || existingRecord?.title || shelfItem?.title || undefined,
author: searchParams.get('author') || existingRecord?.author || shelfItem?.author || undefined,
cover: searchParams.get('cover') || existingRecord?.cover || shelfItem?.cover || undefined,
summary: searchParams.get('summary') || undefined,
title: payload.title || existingRecord?.title || shelfItem?.title || undefined,
author: payload.author || existingRecord?.author || shelfItem?.author || undefined,
cover: payload.cover || existingRecord?.cover || shelfItem?.cover || undefined,
summary: payload.summary || undefined,
detailHref: resolvedHref || undefined,
acquisitionLinks: fallbackAcquisitionLinks,
});
@@ -62,9 +70,9 @@ export async function GET(request: NextRequest) {
return NextResponse.json({
book: detail,
format: preferred.format,
fileUrl: `/api/books/file?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(preferred.href)}`,
fileUrl: `/api/books/file?sourceId=${encodeURIComponent(sourceId)}&bookId=${encodeURIComponent(detail.id)}&format=${encodeURIComponent(preferred.format)}`,
acquisitionHref: preferred.href,
cacheKey: `${sourceId}::${detail.id}::${preferred.href}`,
cacheKey: `${sourceId}::${detail.id}::${preferred.format}`,
coverUrl: detail.cover,
lastRecord,
});
@@ -72,3 +80,26 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
export async function GET(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
const { searchParams } = new URL(request.url);
return resolveManifest(username, {
sourceId: searchParams.get('sourceId') || undefined,
bookId: searchParams.get('bookId') || undefined,
});
}
export async function POST(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const payload = await request.json() as ManifestPayload;
return await resolveManifest(username, payload);
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
+177 -31
View File
@@ -2,23 +2,14 @@
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { PointerEvent as ReactPointerEvent, WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
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) {
const params = new URLSearchParams({
sourceId,
href: item.detailHref || '',
bookId: item.id,
title: item.title,
author: item.author || '',
cover: item.cover || '',
summary: item.summary || '',
acquisitionLinks: JSON.stringify(item.acquisitionLinks || []),
});
return `/books/detail?${params.toString()}`;
return buildBookDetailPath(sourceId, item.id);
}
function CatalogSkeleton() {
@@ -51,30 +42,175 @@ function CatalogSkeleton() {
);
}
function LoadingMoreSkeleton() {
return (
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className='space-y-3 animate-pulse'>
<div className='aspect-[3/4] rounded-2xl bg-gray-200 dark:bg-gray-800' />
<div className='h-4 w-3/4 rounded bg-gray-200 dark:bg-gray-800' />
<div className='h-3 w-1/2 rounded bg-gray-200 dark:bg-gray-800' />
</div>
))}
</div>
);
}
function isMeaningfulNavTitle(title?: string) {
const text = (title || '').trim();
return !!text && text !== '目录';
}
export default function BooksCatalogPage() {
const searchParams = useSearchParams();
const sourceId = searchParams.get('sourceId') || '';
const href = searchParams.get('href') || '';
const [sources, setSources] = useState<BookSource[]>([]);
const [data, setData] = useState<BookCatalogResult | null>(null);
const [entries, setEntries] = useState<BookListItem[]>([]);
const [nextHref, setNextHref] = useState<string | undefined>(undefined);
const [error, setError] = useState('');
const [loadingMore, setLoadingMore] = useState(false);
const loaderRef = useRef<HTMLDivElement | null>(null);
const navScrollerRef = useRef<HTMLDivElement | null>(null);
const loadedPageHrefsRef = useRef<Set<string>>(new Set());
const navDragStateRef = useRef<{ pointerId: number; startX: number; startScrollLeft: number; moved: boolean } | null>(null);
useEffect(() => {
fetch('/api/books/sources').then((res) => res.json()).then((json) => setSources(json.sources || []));
}, []);
const mergeEntries = useCallback((prev: BookListItem[], next: BookListItem[]) => {
const seen = new Set(prev.map((item) => `${item.sourceId}::${item.id}::${item.detailHref || item.acquisitionLinks[0]?.href || ''}`));
const merged = [...prev];
for (const item of next) {
const key = `${item.sourceId}::${item.id}::${item.detailHref || item.acquisitionLinks[0]?.href || ''}`;
if (!seen.has(key)) {
seen.add(key);
merged.push(item);
}
}
return merged;
}, []);
const loadCatalog = useCallback(async (targetHref?: string, append = false) => {
if (!sourceId) return;
const normalizedHref = targetHref || '';
if (append) {
if (!normalizedHref || loadedPageHrefsRef.current.has(normalizedHref)) return;
setLoadingMore(true);
} else {
setError('');
setData(null);
setEntries([]);
setNextHref(undefined);
loadedPageHrefsRef.current = new Set(normalizedHref ? [normalizedHref] : ['__root__']);
}
try {
const params = new URLSearchParams({ sourceId });
if (normalizedHref) params.set('href', normalizedHref);
const res = await fetch(`/api/books/catalog?${params.toString()}`);
const json = await res.json();
if (!res.ok) throw new Error(json.error || '获取目录失败');
const nextData = json as BookCatalogResult;
if (append) {
loadedPageHrefsRef.current.add(normalizedHref);
setEntries((prev) => mergeEntries(prev, nextData.entries || []));
} else {
setData(nextData);
setEntries(nextData.entries || []);
}
setNextHref(nextData.nextHref || undefined);
if (!append) setData(nextData);
} catch (err) {
setError(err instanceof Error ? err.message : '获取目录失败');
} finally {
setLoadingMore(false);
}
}, [mergeEntries, sourceId]);
useEffect(() => {
if (!sourceId) return;
const params = new URLSearchParams({ sourceId });
if (href) params.set('href', href);
fetch(`/api/books/catalog?${params.toString()}`)
.then(async (res) => {
const json = await res.json();
if (!res.ok) throw new Error(json.error || '获取目录失败');
setData(json);
})
.catch((err) => setError(err.message || '获取目录失败'));
}, [sourceId, href]);
void loadCatalog(href, false);
}, [sourceId, href, loadCatalog]);
useEffect(() => {
const node = loaderRef.current;
if (!node || !nextHref || loadingMore || !data) return;
const observer = new IntersectionObserver((entries) => {
const entry = entries[0];
if (entry?.isIntersecting && nextHref && !loadingMore) {
void loadCatalog(nextHref, true);
}
}, { rootMargin: '800px 0px' });
observer.observe(node);
return () => observer.disconnect();
}, [data, nextHref, loadingMore, loadCatalog]);
const handleNavPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
const node = navScrollerRef.current;
if (!node) return;
navDragStateRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startScrollLeft: node.scrollLeft,
moved: false,
};
node.setPointerCapture?.(event.pointerId);
}, []);
const handleNavPointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
const node = navScrollerRef.current;
const dragState = navDragStateRef.current;
if (!node || !dragState || dragState.pointerId !== event.pointerId) return;
const deltaX = event.clientX - dragState.startX;
if (Math.abs(deltaX) > 4) dragState.moved = true;
node.scrollLeft = dragState.startScrollLeft - deltaX;
}, []);
const handleNavPointerUp = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
const node = navScrollerRef.current;
const dragState = navDragStateRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) return;
if (dragState.moved) {
event.preventDefault();
window.setTimeout(() => {
navDragStateRef.current = null;
}, 0);
} else {
navDragStateRef.current = null;
}
node?.releasePointerCapture?.(event.pointerId);
}, []);
const handleNavWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {
const node = navScrollerRef.current;
if (!node) return;
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
if (!delta) return;
node.scrollLeft += delta;
event.preventDefault();
}, []);
const navigationItems = useMemo(() => {
const items = (data?.navigation || []).filter((item) => {
const rel = (item.rel || '').toLowerCase();
if (rel === 'next' || rel === 'previous') return false;
return isMeaningfulNavTitle(item.title);
});
const seen = new Set<string>();
return items.filter((item) => {
const key = `${item.href}::${(item.title || '').trim()}`;
if (!item.href || seen.has(key)) return false;
seen.add(key);
return true;
});
}, [data]);
return (
<div className='space-y-6'>
@@ -91,22 +227,30 @@ export default function BooksCatalogPage() {
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
<h1 className='text-lg font-semibold'>{data.title}</h1>
{data.subtitle ? <p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>{data.subtitle}</p> : null}
<div className='mt-4 flex flex-wrap gap-2'>
{data.previousHref ? <Link href={`/books/catalog?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(data.previousHref)}`} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'></Link> : null}
{data.nextHref ? <Link href={`/books/catalog?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(data.nextHref)}`} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'></Link> : null}
</div>
</section>
{data.navigation.length > 0 ? (
{navigationItems.length > 0 ? (
<section className='space-y-3'>
<div className='text-sm font-medium text-gray-700 dark:text-gray-300'></div>
<div className='flex gap-3 overflow-x-auto pb-2'>
{data.navigation.map((item, index) => (
<div
ref={navScrollerRef}
className='flex gap-3 overflow-x-auto pb-2 cursor-grab select-none touch-pan-x active:cursor-grabbing'
onPointerDown={handleNavPointerDown}
onPointerMove={handleNavPointerMove}
onPointerUp={handleNavPointerUp}
onPointerCancel={handleNavPointerUp}
onPointerLeave={handleNavPointerUp}
onWheel={handleNavWheel}
>
{navigationItems.map((item, index) => (
<Link
key={`${item.href}-${index}`}
href={`/books/catalog?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(item.href)}`}
draggable={false}
onDragStart={(event) => event.preventDefault()}
onClick={(event) => { if (navDragStateRef.current?.moved) event.preventDefault(); }}
className='min-w-[180px] rounded-2xl border border-gray-200 bg-white p-4 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-950'
>
<div className='line-clamp-2 font-medium'>{item.title}</div>
<div className='line-clamp-2 font-medium'>{item.title.trim()}</div>
<div className='mt-2 text-xs text-gray-500 dark:text-gray-400'></div>
</Link>
))}
@@ -114,8 +258,10 @@ export default function BooksCatalogPage() {
</section>
) : null}
<section className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
{data.entries.map((item) => <BookCard key={`${item.sourceId}-${item.id}`} item={item} href={makeHref(sourceId, item)} />)}
{entries.map((item) => <BookCard key={`${item.sourceId}-${item.id}-${item.detailHref || item.acquisitionLinks[0]?.href || ''}`} item={item} href={makeHref(sourceId, item)} onNavigate={() => cacheBookListItem(item)} />)}
</section>
{loadingMore ? <LoadingMoreSkeleton /> : null}
{!loadingMore && nextHref ? <div ref={loaderRef} className='h-8 w-full' /> : null}
</>
) : !error ? <CatalogSkeleton /> : null}
</div>
+73 -20
View File
@@ -2,10 +2,11 @@
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { BookDetail, BookShelfItem } from '@/lib/book.types';
import { buildBookReadPath, cacheBookDetail, getBookRouteCache } from '@/lib/book-route-cache.client';
import { deleteBookShelf, getAllBookShelf, saveBookShelf } from '@/lib/book.db.client';
import { BookDetail, BookShelfItem } from '@/lib/book.types';
function DetailSkeleton() {
return (
@@ -30,29 +31,79 @@ function DetailSkeleton() {
);
}
async function openBookFile(sourceId: string, bookId: string, format?: 'epub' | 'pdf', download = false, href?: string) {
const response = await fetch('/api/books/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sourceId, bookId, format: format || null, href: href || undefined }),
});
if (!response.ok) {
let message = '打开文件失败';
try {
const json = await response.json();
message = json.error || message;
} catch {}
throw new Error(message);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
if (download) {
const link = document.createElement('a');
link.href = url;
link.download = '';
document.body.appendChild(link);
link.click();
link.remove();
} else {
window.open(url, '_blank', 'noopener,noreferrer');
}
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
export default function BookDetailPage() {
const searchParams = useSearchParams();
const sourceId = searchParams.get('sourceId') || '';
const href = searchParams.get('href') || '';
const bookId = searchParams.get('bookId') || '';
const [detail, setDetail] = useState<BookDetail | null>(null);
const [shelf, setShelf] = useState<Record<string, BookShelfItem>>({});
const [error, setError] = useState('');
const [fileBusy, setFileBusy] = useState<'open' | 'download' | ''>('');
const cached = useMemo(() => (sourceId && bookId ? getBookRouteCache(sourceId, bookId) : null), [sourceId, bookId]);
useEffect(() => {
getAllBookShelf().then(setShelf).catch(() => undefined);
getAllBookShelf().then((items) => {
setShelf(items);
}).catch(() => undefined);
}, []);
useEffect(() => {
const params = new URLSearchParams(searchParams.toString());
fetch(`/api/books/detail?${params.toString()}`)
if (!sourceId || !bookId) return;
fetch('/api/books/detail', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceId,
bookId,
href: cached?.detailHref,
title: cached?.title,
author: cached?.author,
cover: cached?.cover,
summary: cached?.summary,
acquisitionLinks: cached?.acquisitionLinks || [],
}),
})
.then(async (res) => {
const json = await res.json();
if (!res.ok) throw new Error(json.error || '获取详情失败');
setDetail(json);
cacheBookDetail(json);
})
.catch((err) => setError(err.message || '获取详情失败'));
}, [searchParams]);
}, [sourceId, bookId, cached]);
const readable = detail?.acquisitionLinks.find((item) => item.type.toLowerCase().includes('epub') || item.type.toLowerCase().includes('pdf'));
const readableFormat = readable?.type.toLowerCase().includes('pdf') ? 'pdf' : 'epub';
const toggleShelf = async () => {
if (!detail) return;
@@ -73,20 +124,19 @@ export default function BookDetailPage() {
title: detail.title,
author: detail.author,
cover: detail.cover,
format: readableFormat,
detailHref: detail.detailHref,
acquisitionHref: readable?.href,
saveTime: Date.now(),
};
await saveBookShelf(detail.sourceId, detail.id, item);
setShelf((prev) => ({ ...prev, [bookKey]: item }));
cacheBookDetail(detail);
};
if (error) return <div className='text-sm text-red-500'>{error}</div>;
if (!detail) return <DetailSkeleton />;
const readable = detail.acquisitionLinks.find((item) => item.type.toLowerCase().includes('epub') || item.type.toLowerCase().includes('pdf'));
const readableFormat = readable?.type.toLowerCase().includes('pdf') ? 'pdf' : 'epub';
return (
<div className='space-y-6'>
<section className='grid gap-6 rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950 md:grid-cols-[220px_1fr]'>
@@ -103,24 +153,27 @@ export default function BookDetailPage() {
{(detail.categories || detail.tags || []).map((tag) => <span key={tag} className='rounded-full bg-gray-100 px-3 py-1 text-xs dark:bg-gray-900'>{tag}</span>)}
</div>
<div className='flex flex-wrap gap-3'>
{readable ? <Link href={`/books/read?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(href || detail.detailHref || '')}&acquisitionHref=${encodeURIComponent(readable.href)}&format=${encodeURIComponent(readableFormat)}&bookId=${encodeURIComponent(detail.id)}&title=${encodeURIComponent(detail.title)}&author=${encodeURIComponent(detail.author || '')}&cover=${encodeURIComponent(detail.cover || '')}`} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'>线</Link> : null}
{readable ? <Link href={buildBookReadPath(detail.sourceId, detail.id)} onClick={() => cacheBookDetail(detail)} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'>线</Link> : null}
<button onClick={toggleShelf} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>{shelf[`${detail.sourceId}+${detail.id}`] ? '移出书架' : '加入书架'}</button>
{detail.acquisitionLinks[0] ? <a href={`/api/books/file?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(detail.acquisitionLinks[0].href)}`} target='_blank' rel='noreferrer' className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'></a> : null}
{readable ? <button onClick={async () => { try { setFileBusy('download'); await openBookFile(detail.sourceId, detail.id, readableFormat, true, readable?.href); } catch (err) { setError((err as Error).message || '下载文件失败'); } finally { setFileBusy(''); } }} disabled={fileBusy !== ''} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>{fileBusy === 'download' ? '下载中...' : '下载文件'}</button> : null}
</div>
</div>
</section>
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
<h2 className='text-lg font-semibold'></h2>
<div className='mt-4 space-y-3'>
{detail.acquisitionLinks.map((item) => (
<div key={`${item.href}-${item.type}`} className='flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3 text-sm dark:bg-gray-900'>
<div>
<div>{item.title || item.type}</div>
<div className='text-xs text-gray-500'>{item.rel}</div>
{detail.acquisitionLinks.map((item) => {
const format = item.type.toLowerCase().includes('pdf') ? 'pdf' : item.type.toLowerCase().includes('epub') ? 'epub' : undefined;
return (
<div key={`${item.href}-${item.type}`} className='flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3 text-sm dark:bg-gray-900'>
<div>
<div>{item.title || item.type}</div>
<div className='text-xs text-gray-500'>{item.rel}</div>
</div>
<button disabled={!format || fileBusy !== ''} onClick={async () => { if (!format) return; try { setFileBusy('open'); await openBookFile(detail.sourceId, detail.id, format, false, item.href); } catch (err) { setError((err as Error).message || '打开文件失败'); } finally { setFileBusy(''); } }} className='text-sky-600 disabled:text-gray-400'></button>
</div>
<a href={`/api/books/file?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(item.href)}`} target='_blank' rel='noreferrer' className='text-sky-600'></a>
</div>
))}
);
})}
</div>
</section>
</div>
+3 -13
View File
@@ -3,6 +3,7 @@
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { buildBookReadPath, cacheBookReadRecord, cacheBookShelfItem } from '@/lib/book-route-cache.client';
import { deleteBookReadRecord, getAllBookReadRecords, getAllBookShelf } from '@/lib/book.db.client';
import { BookReadRecord, BookShelfItem } from '@/lib/book.types';
@@ -47,19 +48,8 @@ export default function BookHistoryPage() {
<div className='mt-3 flex flex-wrap gap-2'>
{item.sourceId ? (
<Link
href={{
pathname: '/books/read',
query: {
sourceId: item.sourceId,
href: item.detailHref || '',
acquisitionHref: item.acquisitionHref || '',
format: item.format,
bookId: item.bookId,
title: item.title,
author: item.author || '',
cover: item.cover || '',
},
}}
href={buildBookReadPath(item.sourceId, item.bookId)}
onClick={() => { cacheBookReadRecord(item); if (item.sourceId && item.bookId) { cacheBookShelfItem({ sourceId: item.sourceId, sourceName: item.sourceName, bookId: item.bookId, title: item.title, author: item.author, cover: item.cover, format: item.format, detailHref: item.detailHref, acquisitionHref: item.acquisitionHref, saveTime: item.saveTime }); } }}
className='rounded-2xl bg-sky-600 px-3 py-2 text-xs text-white'
>
+189 -74
View File
@@ -11,6 +11,7 @@ import {
putCachedBookFile,
touchCachedBookFile,
} from '@/lib/book-cache.client';
import { cacheBookDetail, getBookRouteCache } from '@/lib/book-route-cache.client';
import { saveBookReadRecord } from '@/lib/book.db.client';
import { BookReadManifest } from '@/lib/book.types';
@@ -141,10 +142,20 @@ function flattenToc(items: TocItem[]): TocItem[] {
}
async function downloadBookWithProgress(
url: string,
manifest: Pick<BookReadManifest, 'book' | 'format' | 'acquisitionHref'>,
onProgress: (received: number, total: number | null) => void
): Promise<Blob> {
const response = await fetch(url, { cache: 'force-cache' });
const response = await fetch('/api/books/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceId: manifest.book.sourceId,
bookId: manifest.book.id,
format: manifest.format,
href: manifest.acquisitionHref || undefined,
}),
cache: 'force-cache',
});
if (!response.ok) throw new Error(`下载电子书失败: ${response.status}`);
const total = Number(response.headers.get('content-length') || '') || null;
if (!response.body) {
@@ -173,6 +184,24 @@ async function downloadBookWithProgress(
return new Blob(chunks, { type: response.headers.get('content-type') || 'application/epub+zip' });
}
function normalizeHrefForMatch(href?: string) {
if (!href) return '';
try {
const normalized = decodeURIComponent(href).replace(/\\/g, '/').trim();
return normalized.split('#')[0].split('?')[0].replace(/^\.\//, '').replace(/^\//, '');
} catch {
return href.split('#')[0].split('?')[0].replace(/^\.\//, '').replace(/^\//, '').trim();
}
}
function isSameTocTarget(currentHref?: string, tocHref?: string) {
const current = normalizeHrefForMatch(currentHref);
const target = normalizeHrefForMatch(tocHref);
if (!current || !target) return false;
return current === target || current.endsWith(target) || target.endsWith(current);
}
function formatBytes(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
@@ -182,7 +211,8 @@ function formatBytes(size: number): string {
export default function BookReadPage() {
const searchParams = useSearchParams();
const sourceId = searchParams.get('sourceId') || '';
const href = searchParams.get('href') || '';
const bookId = searchParams.get('bookId') || '';
const cached = useMemo(() => (sourceId && bookId ? getBookRouteCache(sourceId, bookId) : null), [sourceId, bookId]);
const [manifest, setManifest] = useState<BookReadManifest | null>(null);
const [error, setError] = useState('');
const [ready, setReady] = useState(false);
@@ -199,7 +229,10 @@ export default function BookReadPage() {
const [progressPercent, setProgressPercent] = useState(0);
const [restoredMessage, setRestoredMessage] = useState('');
const [controlsVisible, setControlsVisible] = useState(true);
const [pdfBlobUrl, setPdfBlobUrl] = useState('');
const viewerRef = useRef<HTMLDivElement | null>(null);
const tocScrollRef = useRef<HTMLDivElement | null>(null);
const tocItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const bookRef = useRef<EpubBookInstance | null>(null);
const renditionRef = useRef<EpubRendition | null>(null);
const saveTimerRef = useRef<number | null>(null);
@@ -218,25 +251,57 @@ export default function BookReadPage() {
}
}, [settings]);
useEffect(() => {
const params = new URLSearchParams({
sourceId,
href,
acquisitionHref: searchParams.get('acquisitionHref') || '',
format: searchParams.get('format') || '',
bookId: searchParams.get('bookId') || '',
title: searchParams.get('title') || '',
author: searchParams.get('author') || '',
cover: searchParams.get('cover') || '',
});
fetch(`/api/books/read/manifest?${params.toString()}`)
const handleToggleSettings = () => {
setSettingsOpen((prev) => !prev);
setTocOpen(false);
};
window.addEventListener('books-read-toggle-settings', handleToggleSettings);
return () => {
window.removeEventListener('books-read-toggle-settings', handleToggleSettings);
};
}, []);
useEffect(() => {
const handleToggleChapters = () => {
setTocOpen((prev) => !prev);
setSettingsOpen(false);
};
window.addEventListener('books-read-toggle-chapters', handleToggleChapters);
return () => {
window.removeEventListener('books-read-toggle-chapters', handleToggleChapters);
};
}, []);
useEffect(() => {
if (!sourceId || !bookId) return;
fetch('/api/books/read/manifest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceId,
bookId,
href: cached?.detailHref,
acquisitionHref: cached?.acquisitionHref,
format: cached?.format || null,
title: cached?.title,
author: cached?.author,
cover: cached?.cover,
summary: cached?.summary,
}),
})
.then(async (res) => {
const json = await res.json();
if (!res.ok) throw new Error(json.error || '获取阅读信息失败');
setManifest(json);
cacheBookDetail(json.book);
})
.catch((err) => setError(err.message || '获取阅读信息失败'));
}, [sourceId, href, searchParams]);
}, [sourceId, bookId, cached]);
const saveProgress = useMemo(() => {
return async (location: EpubLocation, nextProgress = 0, chapterTitle?: string) => {
@@ -312,11 +377,12 @@ export default function BookReadPage() {
renditionRef.current?.next?.();
return;
}
setControlsVisible((prev) => !prev);
setTocOpen(false);
setSettingsOpen(false);
}, [ready]);
useEffect(() => {
if (!manifest || manifest.format !== 'epub' || !viewerRef.current) return;
let destroyed = false;
@@ -337,7 +403,7 @@ export default function BookReadPage() {
const cacheKey = manifest.cacheKey || buildBookCacheKey(
manifest.book.sourceId,
manifest.book.id,
manifest.acquisitionHref || manifest.fileUrl
manifest.acquisitionHref || `${manifest.book.sourceId}:${manifest.book.id}:${manifest.format}`
);
let fileBuffer: ArrayBuffer;
@@ -351,7 +417,7 @@ export default function BookReadPage() {
fileBuffer = await cached.blob.arrayBuffer();
} else {
setFileLoadState('downloading');
const blob = await downloadBookWithProgress(manifest.fileUrl, (received, total) => {
const blob = await downloadBookWithProgress(manifest, (received, total) => {
if (!destroyed) {
setDownloadedBytes(received);
setTotalBytes(total);
@@ -364,7 +430,7 @@ export default function BookReadPage() {
bookId: manifest.book.id,
title: manifest.book.title,
format: manifest.format,
acquisitionHref: manifest.acquisitionHref || manifest.fileUrl,
acquisitionHref: manifest.acquisitionHref || `${manifest.book.sourceId}:${manifest.book.id}:${manifest.format}`,
blob,
size: blob.size,
mimeType: blob.type || 'application/epub+zip',
@@ -397,16 +463,43 @@ export default function BookReadPage() {
applyReaderTheme(settings);
const restoreTarget = manifest.lastRecord?.locator?.value || undefined;
await navigateToTarget(restoreTarget);
window.clearTimeout(readyFallbackTimer);
if (destroyed) return;
setReady(true);
setFileLoadState('ready');
let restoreMessageShown = false;
if (restoreTarget) {
setRestoredMessage(`已恢复到上次阅读位置(约 ${Math.round(manifest.lastRecord?.progressPercent || 0)}%`);
window.setTimeout(() => setRestoredMessage(''), 3000);
}
rendition.on('relocated', (location: EpubLocation) => {
if (!destroyed) {
window.clearTimeout(readyFallbackTimer);
setReady(true);
setFileLoadState('ready');
}
if (restoreTarget && !restoreMessageShown) {
restoreMessageShown = true;
setRestoredMessage(`已恢复到上次阅读位置(约 ${Math.round(manifest.lastRecord?.progressPercent || 0)}%`);
window.setTimeout(() => setRestoredMessage(''), 3000);
}
lastLocationRef.current = location;
const chapterTitle = location?.start?.displayed?.chapter || location?.start?.href || manifest.book.title;
const cfi = location?.start?.cfi || '';
const computedProgress = locationsReadyRef.current && cfi
? Math.max(0, Math.min(100, (book.locations?.percentageFromCfi?.(cfi) || 0) * 100))
: null;
const normalizedProgress = computedProgress ?? lastProgressRef.current ?? manifest.lastRecord?.progressPercent ?? 0;
setProgressPercent(normalizedProgress);
setCurrentChapter(chapterTitle);
setCurrentHref(location?.start?.href || '');
lastProgressRef.current = normalizedProgress;
lastChapterRef.current = chapterTitle;
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = window.setTimeout(() => {
void saveProgress(location, normalizedProgress, chapterTitle);
}, locationsReadyRef.current ? 1500 : 3500);
});
void navigateToTarget(restoreTarget).catch(() => {
if (!destroyed) {
setReady(true);
setFileLoadState('ready');
}
});
void (async () => {
try {
@@ -432,25 +525,6 @@ export default function BookReadPage() {
// ignore
}
})();
rendition.on('relocated', (location: EpubLocation) => {
lastLocationRef.current = location;
const chapterTitle = location?.start?.displayed?.chapter || location?.start?.href || manifest.book.title;
const cfi = location?.start?.cfi || '';
const computedProgress = locationsReadyRef.current && cfi
? Math.max(0, Math.min(100, (book.locations?.percentageFromCfi?.(cfi) || 0) * 100))
: null;
const normalizedProgress = computedProgress ?? lastProgressRef.current ?? manifest.lastRecord?.progressPercent ?? 0;
setProgressPercent(normalizedProgress);
setCurrentChapter(chapterTitle);
setCurrentHref(location?.start?.href || '');
lastProgressRef.current = normalizedProgress;
lastChapterRef.current = chapterTitle;
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = window.setTimeout(() => {
void saveProgress(location, normalizedProgress, chapterTitle);
}, locationsReadyRef.current ? 1500 : 3500);
});
})
.catch((err) => {
setReady(false);
@@ -479,47 +553,84 @@ export default function BookReadPage() {
};
}, [persistCurrentProgress]);
useEffect(() => {
if (!manifest || manifest.format !== 'pdf') return;
let revokedUrl = '';
let cancelled = false;
setFileLoadState('downloading');
setDownloadedBytes(0);
setTotalBytes(null);
setPdfBlobUrl('');
downloadBookWithProgress(manifest, (received, total) => {
if (!cancelled) {
setDownloadedBytes(received);
setTotalBytes(total);
}
})
.then(async (blob) => {
if (cancelled) return;
const objectUrl = URL.createObjectURL(blob);
revokedUrl = objectUrl;
setPdfBlobUrl(objectUrl);
setFileLoadState('ready');
})
.catch((err) => {
if (!cancelled) setError(err.message || 'PDF 加载失败');
});
return () => {
cancelled = true;
if (revokedUrl) URL.revokeObjectURL(revokedUrl);
};
}, [manifest]);
useEffect(() => {
if (!manifest) return;
window.dispatchEvent(new CustomEvent('books-read-update-header', {
detail: {
title: manifest.book.title,
subtitle: currentChapter || manifest.book.author || '分页阅读',
backHref: `/books/detail?sourceId=${encodeURIComponent(manifest.book.sourceId)}&bookId=${encodeURIComponent(manifest.book.id)}`,
},
}));
}, [manifest, currentChapter]);
const flatToc = useMemo(() => flattenToc(tocItems), [tocItems]);
const activeTocHref = useMemo(
() => flatToc.find((item) => currentHref.includes(item.href) || item.href.includes(currentHref))?.href || '',
() => flatToc.find((item) => isSameTocTarget(currentHref, item.href))?.href || '',
[flatToc, currentHref]
);
useEffect(() => {
if (!tocOpen || !activeTocHref) return;
const activeNode = tocItemRefs.current[activeTocHref];
if (!activeNode) return;
activeNode.scrollIntoView({ block: 'center', behavior: 'smooth' });
}, [tocOpen, activeTocHref]);
const progressLabel = totalBytes ? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` : formatBytes(downloadedBytes);
if (error) return <div className='p-4 text-sm text-red-500'>{error}</div>;
if (!manifest) return <div className='p-4 text-sm text-gray-500'>...</div>;
if (manifest.format === 'pdf') {
return <iframe src={manifest.fileUrl} className='h-[calc(100vh-4rem)] w-full bg-white' title={manifest.book.title} />;
if (!pdfBlobUrl) return <div className='p-4 text-sm text-gray-500'>PDF ... {progressLabel}</div>;
return <iframe src={pdfBlobUrl} className='h-[calc(100vh-4rem)] w-full bg-white' title={manifest.book.title} />;
}
return (
<div className='relative h-[calc(100vh-4rem)] overflow-hidden bg-white dark:bg-gray-950'>
<div className={`flex h-14 items-center justify-between border-b border-gray-200 bg-white px-4 text-sm shadow-sm transition-all dark:border-gray-800 dark:bg-gray-950 ${controlsVisible ? 'translate-y-0 opacity-100' : '-translate-y-full opacity-0 pointer-events-none'}`}>
<div className='min-w-0'>
<div className='truncate font-medium'>{manifest.book.title}</div>
<div className='truncate text-xs text-gray-500 dark:text-gray-400'>
{currentChapter || manifest.book.author || 'EPUB 阅读'} · {Math.round(progressPercent)}%
</div>
</div>
<div className='flex items-center gap-2'>
<button onClick={() => setTocOpen((prev) => !prev)} className='inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 dark:border-gray-700'>
<List className='h-4 w-4' />
</button>
<button onClick={() => setSettingsOpen((prev) => !prev)} className='inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 dark:border-gray-700'>
<Settings2 className='h-4 w-4' />
</button>
</div>
</div>
<div className='relative h-[calc(100vh-3.5rem)] overflow-hidden bg-white dark:bg-gray-950'>
{restoredMessage ? (
<div className='absolute left-1/2 top-16 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 z-30 -translate-x-1/2 rounded-full bg-sky-600 px-4 py-2 text-xs text-white shadow-lg'>
{restoredMessage}
</div>
) : null}
{!ready ? (
<div className='absolute inset-x-0 top-14 z-10 p-4'>
<div className='absolute inset-x-0 top-0 z-10 p-4'>
<div className='mx-auto max-w-3xl space-y-4'>
<div className='space-y-2 rounded-3xl border border-gray-200 bg-white/90 p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950/90'>
<div className='text-sm font-medium text-gray-700 dark:text-gray-300'>
@@ -556,7 +667,7 @@ export default function BookReadPage() {
{tocOpen && (
<div className='fixed inset-0 z-40 bg-black/30' onClick={() => setTocOpen(false)}>
<div
className='absolute right-0 top-14 h-[calc(100vh-3.5rem)] w-full max-w-sm 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-[calc(100vh-3.5rem)] w-full max-w-sm overflow-y-auto border-l border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-950'
onClick={(event) => event.stopPropagation()}
>
<div className='p-4'>
@@ -564,7 +675,7 @@ export default function BookReadPage() {
<div className='flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-gray-100'><BookOpen className='h-4 w-4' /></div>
<button onClick={() => setTocOpen(false)} className='text-xs text-gray-500'></button>
</div>
<div className='space-y-2'>
<div ref={tocScrollRef} className='space-y-2'>
{flatToc.length === 0 ? (
<div className='p-3 text-sm text-gray-500'> EPUB </div>
) : (
@@ -573,11 +684,14 @@ export default function BookReadPage() {
return (
<button
key={`${item.href}-${item.label}`}
ref={(node) => {
tocItemRefs.current[item.href] = node;
}}
onClick={() => {
void navigateToTarget(item.href);
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'}`}
className={`block w-full rounded-2xl px-4 py-3 text-left text-sm transition ${active ? 'bg-sky-600 text-white shadow-sm' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900'}`}
>
{item.label}
</button>
@@ -646,15 +760,16 @@ export default function BookReadPage() {
</div>
)}
{ready && !tocOpen && !settingsOpen ? (
<div className='absolute inset-x-0 top-14 bottom-0 z-10 grid grid-cols-3'>
<div className='absolute inset-0 z-10 grid grid-cols-3'>
<button aria-label='上一页' className='h-full w-full cursor-pointer bg-transparent' onClick={() => handleReaderTap('left')} />
<button aria-label='切换工具栏' className='h-full w-full cursor-pointer bg-transparent' onClick={() => handleReaderTap('center')} />
<button aria-label='下一页' className='h-full w-full cursor-pointer bg-transparent' onClick={() => handleReaderTap('right')} />
</div>
) : null}
<div ref={viewerRef} className='h-[calc(100%-3.5rem)] w-full' style={{ backgroundColor: THEME_STYLES[settings.theme].panelBg }} />
<div ref={viewerRef} className='h-full w-full' style={{ backgroundColor: THEME_STYLES[settings.theme].panelBg }} />
</div>
);
}
+3 -12
View File
@@ -4,20 +4,11 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import BookCard from '@/components/books/BookCard';
import { buildBookDetailPath, cacheBookListItem } from '@/lib/book-route-cache.client';
import { BookListItem, BookSearchResult, BookSource } from '@/lib/book.types';
function detailHref(item: BookListItem) {
const params = new URLSearchParams({
sourceId: item.sourceId,
href: item.detailHref || '',
bookId: item.id,
title: item.title,
author: item.author || '',
cover: item.cover || '',
summary: item.summary || '',
acquisitionLinks: JSON.stringify(item.acquisitionLinks || []),
});
return `/books/detail?${params.toString()}`;
return buildBookDetailPath(item.sourceId, item.id);
}
function SearchSkeleton() {
@@ -75,7 +66,7 @@ export default function BooksSearchPage() {
{loading ? <SearchSkeleton /> : null}
{result.failedSources.length > 0 ? <div className='rounded-2xl bg-amber-50 p-4 text-sm text-amber-700 dark:bg-amber-950/20 dark:text-amber-300'>{result.failedSources.map((item) => `${item.sourceName}: ${item.error}`).join('')}</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)} />)}
{result.results.map((item) => <BookCard key={`${item.sourceId}-${item.id}`} item={item} href={detailHref(item)} onNavigate={() => cacheBookListItem(item)} />)}
</section>
{!loading && result.results.length === 0 ? <div className='text-sm text-gray-500'></div> : null}
</div>
+2 -1
View File
@@ -3,6 +3,7 @@
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { buildBookDetailPath, cacheBookShelfItem } from '@/lib/book-route-cache.client';
import { deleteBookShelf, getAllBookShelf } from '@/lib/book.db.client';
import { BookShelfItem } from '@/lib/book.types';
@@ -28,7 +29,7 @@ export default function BookShelfPage() {
<div className='mt-1 text-sm text-gray-500'>{item.author || item.sourceName}</div>
<div className='mt-2 text-xs text-gray-500'> {Math.round(item.progressPercent || 0)}%</div>
<div className='mt-3 flex flex-wrap gap-2'>
<Link href={`/books/detail?sourceId=${encodeURIComponent(item.sourceId)}&href=${encodeURIComponent(item.detailHref || '')}&bookId=${encodeURIComponent(item.bookId)}&title=${encodeURIComponent(item.title)}&author=${encodeURIComponent(item.author || '')}&cover=${encodeURIComponent(item.cover || '')}`} className='rounded-2xl bg-sky-600 px-3 py-2 text-xs text-white'></Link>
<Link href={buildBookDetailPath(item.sourceId, item.bookId)} onClick={() => cacheBookShelfItem(item)} className='rounded-2xl bg-sky-600 px-3 py-2 text-xs text-white'></Link>
<button onClick={async () => { await deleteBookShelf(item.sourceId, item.bookId); setShelf((prev) => { const next = { ...prev }; delete next[`${item.sourceId}+${item.bookId}`]; return next; }); }} className='rounded-2xl border border-gray-200 px-3 py-2 text-xs dark:border-gray-700'></button>
</div>
</div>