diff --git a/src/app/api/books/detail/route.ts b/src/app/api/books/detail/route.ts
index 8de0caa..7314d8b 100644
--- a/src/app/api/books/detail/route.ts
+++ b/src/app/api/books/detail/route.ts
@@ -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 });
}
diff --git a/src/app/api/books/file/route.ts b/src/app/api/books/file/route.ts
index 4bf36f2..23850a1 100644
--- a/src/app/api/books/file/route.ts
+++ b/src/app/api/books/file/route.ts
@@ -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 });
}
}
diff --git a/src/app/api/books/read/manifest/route.ts b/src/app/api/books/read/manifest/route.ts
index beffc80..d1c89f7 100644
--- a/src/app/api/books/read/manifest/route.ts
+++ b/src/app/api/books/read/manifest/route.ts
@@ -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 });
+ }
+}
diff --git a/src/app/books/catalog/page.tsx b/src/app/books/catalog/page.tsx
index 3a5d6c5..d8093f4 100644
--- a/src/app/books/catalog/page.tsx
+++ b/src/app/books/catalog/page.tsx
@@ -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 (
+
+ {Array.from({ length: 6 }).map((_, index) => (
+
+ ))}
+
+ );
+}
+
+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([]);
const [data, setData] = useState(null);
+ const [entries, setEntries] = useState([]);
+ const [nextHref, setNextHref] = useState(undefined);
const [error, setError] = useState('');
+ const [loadingMore, setLoadingMore] = useState(false);
+ const loaderRef = useRef(null);
+ const navScrollerRef = useRef(null);
+ const loadedPageHrefsRef = useRef>(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) => {
+ 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) => {
+ 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) => {
+ 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) => {
+ 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();
+ 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 (
@@ -91,22 +227,30 @@ export default function BooksCatalogPage() {
{data.title}
{data.subtitle ? {data.subtitle}
: null}
-
- {data.previousHref ? 上一页 : null}
- {data.nextHref ? 下一页 : null}
-
- {data.navigation.length > 0 ? (
+ {navigationItems.length > 0 ? (
目录
-
- {data.navigation.map((item, index) => (
+
+ {navigationItems.map((item, index) => (
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'
>
-
{item.title}
+
{item.title.trim()}
点击进入子目录
))}
@@ -114,8 +258,10 @@ export default function BooksCatalogPage() {
) : null}
- {data.entries.map((item) => )}
+ {entries.map((item) => cacheBookListItem(item)} />)}
+ {loadingMore ?
: null}
+ {!loadingMore && nextHref ?
: null}
>
) : !error ?
: null}
diff --git a/src/app/books/detail/page.tsx b/src/app/books/detail/page.tsx
index fc173d7..4481f6f 100644
--- a/src/app/books/detail/page.tsx
+++ b/src/app/books/detail/page.tsx
@@ -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
(null);
const [shelf, setShelf] = useState>({});
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 {error}
;
if (!detail) return ;
- 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 (
@@ -103,24 +153,27 @@ export default function BookDetailPage() {
{(detail.categories || detail.tags || []).map((tag) => {tag})}
- {readable ?
在线阅读 : null}
+ {readable ?
cacheBookDetail(detail)} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'>在线阅读 : null}
- {detail.acquisitionLinks[0] ?
下载文件 : null}
+ {readable ?
: null}
可用格式
- {detail.acquisitionLinks.map((item) => (
-
-
-
{item.title || item.type}
-
{item.rel}
+ {detail.acquisitionLinks.map((item) => {
+ const format = item.type.toLowerCase().includes('pdf') ? 'pdf' : item.type.toLowerCase().includes('epub') ? 'epub' : undefined;
+ return (
+
+
+
{item.title || item.type}
+
{item.rel}
+
+
-
打开
-
- ))}
+ );
+ })}
diff --git a/src/app/books/history/page.tsx b/src/app/books/history/page.tsx
index ce45132..d865b9b 100644
--- a/src/app/books/history/page.tsx
+++ b/src/app/books/history/page.tsx
@@ -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() {
{item.sourceId ? (
{ 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'
>
继续阅读
diff --git a/src/app/books/read/page.tsx b/src/app/books/read/page.tsx
index 10fd0e6..13dbf9a 100644
--- a/src/app/books/read/page.tsx
+++ b/src/app/books/read/page.tsx
@@ -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
,
onProgress: (received: number, total: number | null) => void
): Promise {
- 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(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(null);
+ const tocScrollRef = useRef(null);
+ const tocItemRefs = useRef>({});
const bookRef = useRef(null);
const renditionRef = useRef(null);
const saveTimerRef = useRef(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 {error}
;
if (!manifest) return 准备阅读器中...
;
if (manifest.format === 'pdf') {
- return ;
+ if (!pdfBlobUrl) return PDF 加载中... {progressLabel}
;
+ return ;
}
return (
-
-
-
-
{manifest.book.title}
-
- {currentChapter || manifest.book.author || 'EPUB 阅读'} · {Math.round(progressPercent)}%
-
-
-
-
-
-
-
-
+
{restoredMessage ? (
-
+
{restoredMessage}
) : null}
{!ready ? (
-
+
@@ -556,7 +667,7 @@ export default function BookReadPage() {
{tocOpen && (
setTocOpen(false)}>
event.stopPropagation()}
>
@@ -564,7 +675,7 @@ export default function BookReadPage() {
目录
-
+
{flatToc.length === 0 ? (
当前 EPUB 未提供目录
) : (
@@ -573,11 +684,14 @@ export default function BookReadPage() {
return (
@@ -646,15 +760,16 @@ export default function BookReadPage() {
)}
+
{ready && !tocOpen && !settingsOpen ? (
-
+
) : null}
-
+
);
}
diff --git a/src/app/books/search/page.tsx b/src/app/books/search/page.tsx
index 4d30360..cd621a1 100644
--- a/src/app/books/search/page.tsx
+++ b/src/app/books/search/page.tsx
@@ -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 ?
: null}
{result.failedSources.length > 0 ?
{result.failedSources.map((item) => `${item.sourceName}: ${item.error}`).join(';')}
: null}
- {result.results.map((item) => )}
+ {result.results.map((item) => cacheBookListItem(item)} />)}
{!loading && result.results.length === 0 ?
暂无结果
: null}
diff --git a/src/app/books/shelf/page.tsx b/src/app/books/shelf/page.tsx
index f773f03..cf218eb 100644
--- a/src/app/books/shelf/page.tsx
+++ b/src/app/books/shelf/page.tsx
@@ -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() {
{item.author || item.sourceName}
进度 {Math.round(item.progressPercent || 0)}%
- 详情
+ cacheBookShelfItem(item)} className='rounded-2xl bg-sky-600 px-3 py-2 text-xs text-white'>详情
{ 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'>移除
diff --git a/src/components/books/BookCard.tsx b/src/components/books/BookCard.tsx
index 237baae..a5050ad 100644
--- a/src/components/books/BookCard.tsx
+++ b/src/components/books/BookCard.tsx
@@ -4,10 +4,10 @@ import Link from 'next/link';
import { BookListItem } from '@/lib/book.types';
-export default function BookCard({ item, href, extra }: { item: BookListItem; href: string; extra?: React.ReactNode }) {
+export default function BookCard({ item, href, extra, onNavigate }: { item: BookListItem; href: string; extra?: React.ReactNode; onNavigate?: () => void }) {
return (
-
+
{item.cover ? (
// eslint-disable-next-line @next/next/no-img-element
@@ -18,7 +18,7 @@ export default function BookCard({ item, href, extra }: { item: BookListItem; hr
-
{item.title}
+
{item.title}
{item.author || item.sourceName}
{extra}
diff --git a/src/components/books/BooksLayout.tsx b/src/components/books/BooksLayout.tsx
index c431986..de97ae6 100644
--- a/src/components/books/BooksLayout.tsx
+++ b/src/components/books/BooksLayout.tsx
@@ -1,8 +1,9 @@
'use client';
-import { BookOpen, ChevronLeft, History, Library, Search } from 'lucide-react';
+import { BookOpen, ChevronLeft, History, Library, List, Search, Settings2 } from 'lucide-react';
import Link from 'next/link';
-import { usePathname } from 'next/navigation';
+import { usePathname, useSearchParams } from 'next/navigation';
+import { useEffect, useMemo, useState } from 'react';
const tabs = [
{ href: '/books', label: '发现', icon: Library },
@@ -11,26 +12,102 @@ const tabs = [
{ href: '/books/history', label: '历史', icon: History },
];
+type ReadHeaderPayload = {
+ title?: string;
+ subtitle?: string;
+ backHref?: string;
+};
+
+function getStaticMeta(pathname: string) {
+ if (pathname === '/books/shelf') return { title: '电子书书架', subtitle: '集中管理收藏的电子书' };
+ if (pathname === '/books/history') return { title: '阅读历史', subtitle: '从上次阅读的位置继续' };
+ if (pathname === '/books/search') return { title: '电子书搜索', subtitle: '按书名与作者搜索' };
+ if (pathname === '/books/detail') return { title: '电子书详情', subtitle: '查看书籍信息与可用格式' };
+ if (pathname === '/books/read') return { title: '电子书阅读', subtitle: '分页阅读', backHref: '/books' };
+ return { title: '电子书馆', subtitle: 'OPDS 目录、搜索、阅读与书架' };
+}
+
export default function BooksLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
+ const searchParams = useSearchParams();
const isRead = pathname === '/books/read';
+ const [readHeader, setReadHeader] = useState
(null);
+
+ useEffect(() => {
+ if (!isRead) return;
+ const handleUpdate = (event: Event) => {
+ const custom = event as CustomEvent;
+ setReadHeader(custom.detail || null);
+ };
+ window.addEventListener('books-read-update-header', handleUpdate as EventListener);
+ return () => {
+ window.removeEventListener('books-read-update-header', handleUpdate as EventListener);
+ };
+ }, [isRead]);
+
+ useEffect(() => {
+ if (!isRead) setReadHeader(null);
+ }, [isRead, pathname]);
+
+ const meta = useMemo(() => {
+ const base = getStaticMeta(pathname);
+ if (pathname === '/books/detail') {
+ return {
+ title: searchParams.get('title') || base.title,
+ subtitle: searchParams.get('author') || base.subtitle,
+ backHref: '/books',
+ };
+ }
+ if (isRead) {
+ return {
+ title: readHeader?.title || base.title,
+ subtitle: readHeader?.subtitle || base.subtitle,
+ backHref: readHeader?.backHref || `/books/detail?sourceId=${encodeURIComponent(searchParams.get('sourceId') || '')}&bookId=${encodeURIComponent(searchParams.get('bookId') || '')}`,
+ };
+ }
+ return base;
+ }, [pathname, searchParams, isRead, readHeader]);
return (
-
{children}
+
{children}
{!isRead && (