电子书架
This commit is contained in:
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
<div className='overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
||||
<Link href={href}>
|
||||
<Link href={href} onClick={onNavigate}>
|
||||
<div className='aspect-[3/4] bg-gray-100 dark:bg-gray-900'>
|
||||
{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
|
||||
</div>
|
||||
</Link>
|
||||
<div className='space-y-2 p-3'>
|
||||
<Link href={href} className='line-clamp-2 text-sm font-medium hover:text-sky-600'>{item.title}</Link>
|
||||
<Link href={href} onClick={onNavigate} className='line-clamp-2 text-sm font-medium hover:text-sky-600'>{item.title}</Link>
|
||||
<div className='line-clamp-1 text-xs text-gray-500 dark:text-gray-400'>{item.author || item.sourceName}</div>
|
||||
{extra}
|
||||
</div>
|
||||
|
||||
@@ -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<ReadHeaderPayload | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRead) return;
|
||||
const handleUpdate = (event: Event) => {
|
||||
const custom = event as CustomEvent<ReadHeaderPayload>;
|
||||
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 (
|
||||
<div className='min-h-screen bg-gray-50 text-gray-900 dark:bg-black dark:text-gray-100'>
|
||||
<header className='fixed inset-x-0 top-0 z-40 border-b border-gray-200/70 bg-white/90 backdrop-blur dark:border-gray-800 dark:bg-gray-950/90'>
|
||||
<div className='mx-auto flex h-14 max-w-6xl items-center gap-3 px-4'>
|
||||
{isRead ? (
|
||||
<Link href='/books' className='inline-flex h-10 w-10 items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800'>
|
||||
{isRead || pathname === '/books/detail' ? (
|
||||
<Link href={meta.backHref || '/books'} className='inline-flex h-10 w-10 items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800'>
|
||||
<ChevronLeft className='h-5 w-5' />
|
||||
</Link>
|
||||
) : (
|
||||
<Link href='/' className='text-sm font-semibold text-sky-600'>MoonTV+</Link>
|
||||
)}
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='truncate text-sm font-semibold sm:text-base'>{isRead ? '电子书阅读' : '电子书馆'}</div>
|
||||
<div className='truncate text-xs text-gray-500 dark:text-gray-400'>OPDS 目录、搜索、阅读与书架</div>
|
||||
<div className='group relative'>
|
||||
<div className='truncate text-sm font-semibold sm:text-base'>{meta.title}</div>
|
||||
<div className='absolute left-1/2 top-full z-[100] mt-2 w-max max-w-[85vw] -translate-x-1/2 rounded-lg bg-gray-800 px-3 py-2 text-center text-sm text-white opacity-0 invisible shadow-xl transition-all duration-200 ease-out pointer-events-none group-hover:visible group-hover:opacity-100 dark:bg-gray-900 sm:max-w-none sm:whitespace-nowrap'>
|
||||
<div className='break-words whitespace-normal sm:whitespace-nowrap'>{meta.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='truncate text-xs text-gray-500 dark:text-gray-400'>{meta.subtitle}</div>
|
||||
</div>
|
||||
{!isRead && (
|
||||
{isRead ? (
|
||||
<div className='flex items-center gap-2'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('books-read-toggle-chapters'))}
|
||||
className='inline-flex h-10 w-10 items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
aria-label='目录'
|
||||
>
|
||||
<List className='h-5 w-5' />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('books-read-toggle-settings'))}
|
||||
className='inline-flex h-10 w-10 items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
aria-label='设置'
|
||||
>
|
||||
<Settings2 className='h-5 w-5' />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<nav className='hidden items-center gap-2 md:flex'>
|
||||
{tabs.map((tab) => {
|
||||
const active = pathname === tab.href;
|
||||
@@ -46,7 +123,7 @@ export default function BooksLayout({ children }: { children: React.ReactNode })
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<main className={`mx-auto max-w-6xl ${isRead ? 'pt-16' : 'px-4 pb-24 pt-20'}`}>{children}</main>
|
||||
<main className={`mx-auto max-w-6xl ${isRead ? 'pt-14' : 'px-4 pb-24 pt-20'}`}>{children}</main>
|
||||
{!isRead && (
|
||||
<nav className='fixed inset-x-0 bottom-0 z-40 grid grid-cols-4 border-t border-gray-200/70 bg-white/95 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95 md:hidden'>
|
||||
{tabs.map((tab) => {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import { BookAcquisitionLink, BookDetail, BookListItem, BookReadRecord, BookShelfItem } from './book.types';
|
||||
|
||||
const BOOK_ROUTE_CACHE_KEY = 'moontv_books_route_cache_v1';
|
||||
const MAX_CACHE_ITEMS = 300;
|
||||
|
||||
export interface BookRouteCacheItem {
|
||||
sourceId: string;
|
||||
bookId: string;
|
||||
sourceName?: string;
|
||||
title?: string;
|
||||
author?: string;
|
||||
cover?: string;
|
||||
summary?: string;
|
||||
detailHref?: string;
|
||||
acquisitionHref?: string;
|
||||
acquisitionLinks?: BookAcquisitionLink[];
|
||||
format?: 'epub' | 'pdf';
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
function buildKey(sourceId: string, bookId: string) {
|
||||
return `${sourceId}+${bookId}`;
|
||||
}
|
||||
|
||||
function readCache(): Record<string, BookRouteCacheItem> {
|
||||
if (typeof window === 'undefined') return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(BOOK_ROUTE_CACHE_KEY);
|
||||
return raw ? (JSON.parse(raw) as Record<string, BookRouteCacheItem>) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(cache: Record<string, BookRouteCacheItem>) {
|
||||
if (typeof window === 'undefined') return;
|
||||
const entries = Object.entries(cache)
|
||||
.sort(([, a], [, b]) => (b.updatedAt || 0) - (a.updatedAt || 0))
|
||||
.slice(0, MAX_CACHE_ITEMS);
|
||||
localStorage.setItem(BOOK_ROUTE_CACHE_KEY, JSON.stringify(Object.fromEntries(entries)));
|
||||
}
|
||||
|
||||
export function getBookRouteCache(sourceId: string, bookId: string): BookRouteCacheItem | null {
|
||||
const item = readCache()[buildKey(sourceId, bookId)];
|
||||
return item || null;
|
||||
}
|
||||
|
||||
export function saveBookRouteCache(item: Omit<BookRouteCacheItem, 'updatedAt'> & { updatedAt?: number }) {
|
||||
if (typeof window === 'undefined' || !item.sourceId || !item.bookId) return;
|
||||
const cache = readCache();
|
||||
const key = buildKey(item.sourceId, item.bookId);
|
||||
const prev = cache[key];
|
||||
cache[key] = {
|
||||
...prev,
|
||||
...item,
|
||||
acquisitionLinks: item.acquisitionLinks || prev?.acquisitionLinks || [],
|
||||
updatedAt: item.updatedAt || Date.now(),
|
||||
};
|
||||
writeCache(cache);
|
||||
}
|
||||
|
||||
export function cacheBookListItem(item: BookListItem) {
|
||||
saveBookRouteCache({
|
||||
sourceId: item.sourceId,
|
||||
bookId: item.id,
|
||||
sourceName: item.sourceName,
|
||||
title: item.title,
|
||||
author: item.author,
|
||||
cover: item.cover,
|
||||
summary: item.summary,
|
||||
detailHref: item.detailHref,
|
||||
acquisitionLinks: item.acquisitionLinks,
|
||||
});
|
||||
}
|
||||
|
||||
export function cacheBookDetail(detail: BookDetail) {
|
||||
const readable = detail.acquisitionLinks.find((item) => item.type.toLowerCase().includes('epub') || item.type.toLowerCase().includes('pdf'));
|
||||
saveBookRouteCache({
|
||||
sourceId: detail.sourceId,
|
||||
bookId: detail.id,
|
||||
sourceName: detail.sourceName,
|
||||
title: detail.title,
|
||||
author: detail.author,
|
||||
cover: detail.cover,
|
||||
summary: detail.summary,
|
||||
detailHref: detail.detailHref,
|
||||
acquisitionHref: readable?.href,
|
||||
format: readable?.type.toLowerCase().includes('pdf') ? 'pdf' : readable ? 'epub' : undefined,
|
||||
acquisitionLinks: detail.acquisitionLinks,
|
||||
});
|
||||
}
|
||||
|
||||
export function cacheBookShelfItem(item: BookShelfItem) {
|
||||
saveBookRouteCache({
|
||||
sourceId: item.sourceId,
|
||||
bookId: item.bookId,
|
||||
sourceName: item.sourceName,
|
||||
title: item.title,
|
||||
author: item.author,
|
||||
cover: item.cover,
|
||||
detailHref: item.detailHref,
|
||||
acquisitionHref: item.acquisitionHref,
|
||||
format: item.format,
|
||||
});
|
||||
}
|
||||
|
||||
export function cacheBookReadRecord(item: BookReadRecord) {
|
||||
saveBookRouteCache({
|
||||
sourceId: item.sourceId,
|
||||
bookId: item.bookId,
|
||||
sourceName: item.sourceName,
|
||||
title: item.title,
|
||||
author: item.author,
|
||||
cover: item.cover,
|
||||
detailHref: item.detailHref,
|
||||
acquisitionHref: item.acquisitionHref,
|
||||
format: item.format,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildBookDetailPath(sourceId: string, bookId: string) {
|
||||
return `/books/detail?sourceId=${encodeURIComponent(sourceId)}&bookId=${encodeURIComponent(bookId)}`;
|
||||
}
|
||||
|
||||
export function buildBookReadPath(sourceId: string, bookId: string) {
|
||||
return `/books/read?sourceId=${encodeURIComponent(sourceId)}&bookId=${encodeURIComponent(bookId)}`;
|
||||
}
|
||||
+10
-8
@@ -363,20 +363,22 @@ export class OPDSClient {
|
||||
href: normalizeUrl(source.url, href || source.url),
|
||||
entries: bookEntries.map((entry) => mapEntryToItem(source, entry)),
|
||||
navigation: [
|
||||
...feed.links.filter((link) => isNavigationLink(link)).map((link) => ({
|
||||
title: link.title || '目录',
|
||||
href: link.href,
|
||||
rel: link.rel,
|
||||
type: link.type,
|
||||
})),
|
||||
...feed.links
|
||||
.filter((link) => isNavigationLink(link) && link.rel !== 'next' && link.rel !== 'previous' && !!(link.title || '').trim())
|
||||
.map((link) => ({
|
||||
title: (link.title || '').trim(),
|
||||
href: link.href,
|
||||
rel: link.rel,
|
||||
type: link.type,
|
||||
})),
|
||||
...navigationEntries
|
||||
.map((entry) => ({
|
||||
title: entry.title,
|
||||
title: (entry.title || '').trim(),
|
||||
href: pickDetailHref(entry.links) || entry.links.find((link) => isNavigationLink(link))?.href || '',
|
||||
rel: entry.links.find((link) => isNavigationLink(link))?.rel,
|
||||
type: entry.links.find((link) => isNavigationLink(link))?.type,
|
||||
}))
|
||||
.filter((item) => !!item.href),
|
||||
.filter((item) => !!item.href && !!item.title && item.title !== '目录'),
|
||||
],
|
||||
nextHref: feed.links.find((link) => link.rel === 'next')?.href,
|
||||
previousHref: feed.links.find((link) => link.rel === 'previous')?.href,
|
||||
|
||||
Reference in New Issue
Block a user