电子书架
This commit is contained in:
@@ -1,45 +1,89 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { BookAcquisitionLink } from '@/lib/book.types';
|
import { BookAcquisitionLink } from '@/lib/book.types';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
import { opdsClient } from '@/lib/opds.client';
|
import { opdsClient } from '@/lib/opds.client';
|
||||||
|
|
||||||
import { getAuthorizedBooksUsername } from '../_utils';
|
import { getAuthorizedBooksUsername } from '../_utils';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
const username = await getAuthorizedBooksUsername(request);
|
const username = await getAuthorizedBooksUsername(request);
|
||||||
if (username instanceof NextResponse) return username;
|
if (username instanceof NextResponse) return username;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const sourceId = searchParams.get('sourceId')?.trim();
|
return await resolveDetail(username, {
|
||||||
const href = searchParams.get('href')?.trim() || '';
|
sourceId: searchParams.get('sourceId') || undefined,
|
||||||
|
bookId: searchParams.get('bookId') || undefined,
|
||||||
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 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) {
|
} catch (error) {
|
||||||
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
|
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
+100
-43
@@ -1,61 +1,118 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
import { opdsClient } from '@/lib/opds.client';
|
import { opdsClient } from '@/lib/opds.client';
|
||||||
|
|
||||||
import { getAuthorizedBooksUsername } from '../_utils';
|
import { getAuthorizedBooksUsername } from '../_utils';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
const username = await getAuthorizedBooksUsername(request);
|
const username = await getAuthorizedBooksUsername(request);
|
||||||
if (username instanceof NextResponse) return username;
|
if (username instanceof NextResponse) return username;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const sourceId = searchParams.get('sourceId')?.trim();
|
const resolved = await resolveFileHref(username, {
|
||||||
const href = searchParams.get('href')?.trim();
|
sourceId: searchParams.get('sourceId') || undefined,
|
||||||
if (!sourceId || !href) {
|
bookId: searchParams.get('bookId') || undefined,
|
||||||
return NextResponse.json({ error: '缺少 sourceId 或 href' }, { status: 400 });
|
href: searchParams.get('href') || undefined,
|
||||||
}
|
format: (searchParams.get('format')?.trim() as 'epub' | 'pdf' | null) || null,
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
|
return await proxyFile(request, resolved.sourceId, resolved.href);
|
||||||
} catch (error) {
|
} 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 { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { BookAcquisitionLink } from '@/lib/book.types';
|
import { BookAcquisitionLink } from '@/lib/book.types';
|
||||||
import { opdsClient } from '@/lib/opds.client';
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { opdsClient } from '@/lib/opds.client';
|
||||||
|
|
||||||
import { getAuthorizedBooksUsername } from '../../_utils';
|
import { getAuthorizedBooksUsername } from '../../_utils';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
type ManifestPayload = {
|
||||||
const username = await getAuthorizedBooksUsername(request);
|
sourceId?: string;
|
||||||
if (username instanceof NextResponse) return username;
|
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 {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const sourceId = payload.sourceId?.trim();
|
||||||
const sourceId = searchParams.get('sourceId')?.trim();
|
const href = payload.href?.trim();
|
||||||
const href = searchParams.get('href')?.trim();
|
const acquisitionHref = payload.acquisitionHref?.trim();
|
||||||
const acquisitionHref = searchParams.get('acquisitionHref')?.trim();
|
const format = payload.format;
|
||||||
const format = searchParams.get('format')?.trim() as 'epub' | 'pdf' | null;
|
const bookId = payload.bookId?.trim();
|
||||||
const bookId = searchParams.get('bookId')?.trim();
|
|
||||||
|
|
||||||
if (!sourceId) {
|
if (!sourceId) {
|
||||||
return NextResponse.json({ error: '缺少 sourceId' }, { status: 400 });
|
return NextResponse.json({ error: '缺少 sourceId' }, { status: 400 });
|
||||||
@@ -44,10 +52,10 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const detail = await opdsClient.getBookDetail(sourceId, resolvedHref || '', {
|
const detail = await opdsClient.getBookDetail(sourceId, resolvedHref || '', {
|
||||||
id: bookId || resolvedAcquisitionHref || undefined,
|
id: bookId || resolvedAcquisitionHref || undefined,
|
||||||
title: searchParams.get('title') || existingRecord?.title || shelfItem?.title || undefined,
|
title: payload.title || existingRecord?.title || shelfItem?.title || undefined,
|
||||||
author: searchParams.get('author') || existingRecord?.author || shelfItem?.author || undefined,
|
author: payload.author || existingRecord?.author || shelfItem?.author || undefined,
|
||||||
cover: searchParams.get('cover') || existingRecord?.cover || shelfItem?.cover || undefined,
|
cover: payload.cover || existingRecord?.cover || shelfItem?.cover || undefined,
|
||||||
summary: searchParams.get('summary') || undefined,
|
summary: payload.summary || undefined,
|
||||||
detailHref: resolvedHref || undefined,
|
detailHref: resolvedHref || undefined,
|
||||||
acquisitionLinks: fallbackAcquisitionLinks,
|
acquisitionLinks: fallbackAcquisitionLinks,
|
||||||
});
|
});
|
||||||
@@ -62,9 +70,9 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
book: detail,
|
book: detail,
|
||||||
format: preferred.format,
|
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,
|
acquisitionHref: preferred.href,
|
||||||
cacheKey: `${sourceId}::${detail.id}::${preferred.href}`,
|
cacheKey: `${sourceId}::${detail.id}::${preferred.format}`,
|
||||||
coverUrl: detail.cover,
|
coverUrl: detail.cover,
|
||||||
lastRecord,
|
lastRecord,
|
||||||
});
|
});
|
||||||
@@ -72,3 +80,26 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
|
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 Link from 'next/link';
|
||||||
import { useSearchParams } from 'next/navigation';
|
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 BookCard from '@/components/books/BookCard';
|
||||||
|
import { buildBookDetailPath, cacheBookListItem } from '@/lib/book-route-cache.client';
|
||||||
import { BookCatalogResult, BookListItem, BookSource } from '@/lib/book.types';
|
import { BookCatalogResult, BookListItem, BookSource } from '@/lib/book.types';
|
||||||
|
|
||||||
function makeHref(sourceId: string, item: BookListItem) {
|
function makeHref(sourceId: string, item: BookListItem) {
|
||||||
const params = new URLSearchParams({
|
return buildBookDetailPath(sourceId, item.id);
|
||||||
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()}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CatalogSkeleton() {
|
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() {
|
export default function BooksCatalogPage() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const sourceId = searchParams.get('sourceId') || '';
|
const sourceId = searchParams.get('sourceId') || '';
|
||||||
const href = searchParams.get('href') || '';
|
const href = searchParams.get('href') || '';
|
||||||
const [sources, setSources] = useState<BookSource[]>([]);
|
const [sources, setSources] = useState<BookSource[]>([]);
|
||||||
const [data, setData] = useState<BookCatalogResult | null>(null);
|
const [data, setData] = useState<BookCatalogResult | null>(null);
|
||||||
|
const [entries, setEntries] = useState<BookListItem[]>([]);
|
||||||
|
const [nextHref, setNextHref] = useState<string | undefined>(undefined);
|
||||||
const [error, setError] = useState('');
|
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(() => {
|
useEffect(() => {
|
||||||
fetch('/api/books/sources').then((res) => res.json()).then((json) => setSources(json.sources || []));
|
fetch('/api/books/sources').then((res) => res.json()).then((json) => setSources(json.sources || []));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (!sourceId) return;
|
if (!sourceId) return;
|
||||||
const params = new URLSearchParams({ sourceId });
|
void loadCatalog(href, false);
|
||||||
if (href) params.set('href', href);
|
}, [sourceId, href, loadCatalog]);
|
||||||
fetch(`/api/books/catalog?${params.toString()}`)
|
|
||||||
.then(async (res) => {
|
useEffect(() => {
|
||||||
const json = await res.json();
|
const node = loaderRef.current;
|
||||||
if (!res.ok) throw new Error(json.error || '获取目录失败');
|
if (!node || !nextHref || loadingMore || !data) return;
|
||||||
setData(json);
|
|
||||||
})
|
const observer = new IntersectionObserver((entries) => {
|
||||||
.catch((err) => setError(err.message || '获取目录失败'));
|
const entry = entries[0];
|
||||||
}, [sourceId, href]);
|
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 (
|
return (
|
||||||
<div className='space-y-6'>
|
<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'>
|
<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>
|
<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}
|
{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>
|
</section>
|
||||||
{data.navigation.length > 0 ? (
|
{navigationItems.length > 0 ? (
|
||||||
<section className='space-y-3'>
|
<section className='space-y-3'>
|
||||||
<div className='text-sm font-medium text-gray-700 dark:text-gray-300'>目录</div>
|
<div className='text-sm font-medium text-gray-700 dark:text-gray-300'>目录</div>
|
||||||
<div className='flex gap-3 overflow-x-auto pb-2'>
|
<div
|
||||||
{data.navigation.map((item, index) => (
|
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
|
<Link
|
||||||
key={`${item.href}-${index}`}
|
key={`${item.href}-${index}`}
|
||||||
href={`/books/catalog?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(item.href)}`}
|
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'
|
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>
|
<div className='mt-2 text-xs text-gray-500 dark:text-gray-400'>点击进入子目录</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
@@ -114,8 +258,10 @@ export default function BooksCatalogPage() {
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
<section className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
<section className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
||||||
{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>
|
</section>
|
||||||
|
{loadingMore ? <LoadingMoreSkeleton /> : null}
|
||||||
|
{!loadingMore && nextHref ? <div ref={loaderRef} className='h-8 w-full' /> : null}
|
||||||
</>
|
</>
|
||||||
) : !error ? <CatalogSkeleton /> : null}
|
) : !error ? <CatalogSkeleton /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSearchParams } from 'next/navigation';
|
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 { deleteBookShelf, getAllBookShelf, saveBookShelf } from '@/lib/book.db.client';
|
||||||
|
import { BookDetail, BookShelfItem } from '@/lib/book.types';
|
||||||
|
|
||||||
function DetailSkeleton() {
|
function DetailSkeleton() {
|
||||||
return (
|
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() {
|
export default function BookDetailPage() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const sourceId = searchParams.get('sourceId') || '';
|
const sourceId = searchParams.get('sourceId') || '';
|
||||||
const href = searchParams.get('href') || '';
|
const bookId = searchParams.get('bookId') || '';
|
||||||
const [detail, setDetail] = useState<BookDetail | null>(null);
|
const [detail, setDetail] = useState<BookDetail | null>(null);
|
||||||
const [shelf, setShelf] = useState<Record<string, BookShelfItem>>({});
|
const [shelf, setShelf] = useState<Record<string, BookShelfItem>>({});
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [fileBusy, setFileBusy] = useState<'open' | 'download' | ''>('');
|
||||||
|
|
||||||
|
const cached = useMemo(() => (sourceId && bookId ? getBookRouteCache(sourceId, bookId) : null), [sourceId, bookId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getAllBookShelf().then(setShelf).catch(() => undefined);
|
getAllBookShelf().then((items) => {
|
||||||
|
setShelf(items);
|
||||||
|
}).catch(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
if (!sourceId || !bookId) return;
|
||||||
fetch(`/api/books/detail?${params.toString()}`)
|
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) => {
|
.then(async (res) => {
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (!res.ok) throw new Error(json.error || '获取详情失败');
|
if (!res.ok) throw new Error(json.error || '获取详情失败');
|
||||||
setDetail(json);
|
setDetail(json);
|
||||||
|
cacheBookDetail(json);
|
||||||
})
|
})
|
||||||
.catch((err) => setError(err.message || '获取详情失败'));
|
.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 () => {
|
const toggleShelf = async () => {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
@@ -73,20 +124,19 @@ export default function BookDetailPage() {
|
|||||||
title: detail.title,
|
title: detail.title,
|
||||||
author: detail.author,
|
author: detail.author,
|
||||||
cover: detail.cover,
|
cover: detail.cover,
|
||||||
|
format: readableFormat,
|
||||||
detailHref: detail.detailHref,
|
detailHref: detail.detailHref,
|
||||||
acquisitionHref: readable?.href,
|
acquisitionHref: readable?.href,
|
||||||
saveTime: Date.now(),
|
saveTime: Date.now(),
|
||||||
};
|
};
|
||||||
await saveBookShelf(detail.sourceId, detail.id, item);
|
await saveBookShelf(detail.sourceId, detail.id, item);
|
||||||
setShelf((prev) => ({ ...prev, [bookKey]: item }));
|
setShelf((prev) => ({ ...prev, [bookKey]: item }));
|
||||||
|
cacheBookDetail(detail);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (error) return <div className='text-sm text-red-500'>{error}</div>;
|
if (error) return <div className='text-sm text-red-500'>{error}</div>;
|
||||||
if (!detail) return <DetailSkeleton />;
|
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 (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
<section className='grid gap-6 rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950 md:grid-cols-[220px_1fr]'>
|
<section className='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>)}
|
{(detail.categories || detail.tags || []).map((tag) => <span key={tag} className='rounded-full bg-gray-100 px-3 py-1 text-xs dark:bg-gray-900'>{tag}</span>)}
|
||||||
</div>
|
</div>
|
||||||
<div className='flex flex-wrap gap-3'>
|
<div className='flex flex-wrap gap-3'>
|
||||||
{readable ? <Link href={`/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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
<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>
|
<h2 className='text-lg font-semibold'>可用格式</h2>
|
||||||
<div className='mt-4 space-y-3'>
|
<div className='mt-4 space-y-3'>
|
||||||
{detail.acquisitionLinks.map((item) => (
|
{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'>
|
const format = item.type.toLowerCase().includes('pdf') ? 'pdf' : item.type.toLowerCase().includes('epub') ? 'epub' : undefined;
|
||||||
<div>
|
return (
|
||||||
<div>{item.title || item.type}</div>
|
<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 className='text-xs text-gray-500'>{item.rel}</div>
|
<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>
|
</div>
|
||||||
<a href={`/api/books/file?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(item.href)}`} target='_blank' rel='noreferrer' className='text-sky-600'>打开</a>
|
);
|
||||||
</div>
|
})}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
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 { deleteBookReadRecord, getAllBookReadRecords, getAllBookShelf } from '@/lib/book.db.client';
|
||||||
import { BookReadRecord, BookShelfItem } from '@/lib/book.types';
|
import { BookReadRecord, BookShelfItem } from '@/lib/book.types';
|
||||||
|
|
||||||
@@ -47,19 +48,8 @@ export default function BookHistoryPage() {
|
|||||||
<div className='mt-3 flex flex-wrap gap-2'>
|
<div className='mt-3 flex flex-wrap gap-2'>
|
||||||
{item.sourceId ? (
|
{item.sourceId ? (
|
||||||
<Link
|
<Link
|
||||||
href={{
|
href={buildBookReadPath(item.sourceId, item.bookId)}
|
||||||
pathname: '/books/read',
|
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 }); } }}
|
||||||
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 || '',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
className='rounded-2xl bg-sky-600 px-3 py-2 text-xs text-white'
|
className='rounded-2xl bg-sky-600 px-3 py-2 text-xs text-white'
|
||||||
>
|
>
|
||||||
继续阅读
|
继续阅读
|
||||||
|
|||||||
+189
-74
@@ -11,6 +11,7 @@ import {
|
|||||||
putCachedBookFile,
|
putCachedBookFile,
|
||||||
touchCachedBookFile,
|
touchCachedBookFile,
|
||||||
} from '@/lib/book-cache.client';
|
} from '@/lib/book-cache.client';
|
||||||
|
import { cacheBookDetail, getBookRouteCache } from '@/lib/book-route-cache.client';
|
||||||
import { saveBookReadRecord } from '@/lib/book.db.client';
|
import { saveBookReadRecord } from '@/lib/book.db.client';
|
||||||
import { BookReadManifest } from '@/lib/book.types';
|
import { BookReadManifest } from '@/lib/book.types';
|
||||||
|
|
||||||
@@ -141,10 +142,20 @@ function flattenToc(items: TocItem[]): TocItem[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function downloadBookWithProgress(
|
async function downloadBookWithProgress(
|
||||||
url: string,
|
manifest: Pick<BookReadManifest, 'book' | 'format' | 'acquisitionHref'>,
|
||||||
onProgress: (received: number, total: number | null) => void
|
onProgress: (received: number, total: number | null) => void
|
||||||
): Promise<Blob> {
|
): 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}`);
|
if (!response.ok) throw new Error(`下载电子书失败: ${response.status}`);
|
||||||
const total = Number(response.headers.get('content-length') || '') || null;
|
const total = Number(response.headers.get('content-length') || '') || null;
|
||||||
if (!response.body) {
|
if (!response.body) {
|
||||||
@@ -173,6 +184,24 @@ async function downloadBookWithProgress(
|
|||||||
return new Blob(chunks, { type: response.headers.get('content-type') || 'application/epub+zip' });
|
return new Blob(chunks, { type: response.headers.get('content-type') || 'application/epub+zip' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function 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 {
|
function formatBytes(size: number): string {
|
||||||
if (size < 1024) return `${size} B`;
|
if (size < 1024) return `${size} B`;
|
||||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||||
@@ -182,7 +211,8 @@ function formatBytes(size: number): string {
|
|||||||
export default function BookReadPage() {
|
export default function BookReadPage() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const sourceId = searchParams.get('sourceId') || '';
|
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 [manifest, setManifest] = useState<BookReadManifest | null>(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
@@ -199,7 +229,10 @@ export default function BookReadPage() {
|
|||||||
const [progressPercent, setProgressPercent] = useState(0);
|
const [progressPercent, setProgressPercent] = useState(0);
|
||||||
const [restoredMessage, setRestoredMessage] = useState('');
|
const [restoredMessage, setRestoredMessage] = useState('');
|
||||||
const [controlsVisible, setControlsVisible] = useState(true);
|
const [controlsVisible, setControlsVisible] = useState(true);
|
||||||
|
const [pdfBlobUrl, setPdfBlobUrl] = useState('');
|
||||||
const viewerRef = useRef<HTMLDivElement | null>(null);
|
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 bookRef = useRef<EpubBookInstance | null>(null);
|
||||||
const renditionRef = useRef<EpubRendition | null>(null);
|
const renditionRef = useRef<EpubRendition | null>(null);
|
||||||
const saveTimerRef = useRef<number | null>(null);
|
const saveTimerRef = useRef<number | null>(null);
|
||||||
@@ -218,25 +251,57 @@ export default function BookReadPage() {
|
|||||||
}
|
}
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams({
|
const handleToggleSettings = () => {
|
||||||
sourceId,
|
setSettingsOpen((prev) => !prev);
|
||||||
href,
|
setTocOpen(false);
|
||||||
acquisitionHref: searchParams.get('acquisitionHref') || '',
|
};
|
||||||
format: searchParams.get('format') || '',
|
|
||||||
bookId: searchParams.get('bookId') || '',
|
window.addEventListener('books-read-toggle-settings', handleToggleSettings);
|
||||||
title: searchParams.get('title') || '',
|
return () => {
|
||||||
author: searchParams.get('author') || '',
|
window.removeEventListener('books-read-toggle-settings', handleToggleSettings);
|
||||||
cover: searchParams.get('cover') || '',
|
};
|
||||||
});
|
}, []);
|
||||||
fetch(`/api/books/read/manifest?${params.toString()}`)
|
|
||||||
|
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) => {
|
.then(async (res) => {
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (!res.ok) throw new Error(json.error || '获取阅读信息失败');
|
if (!res.ok) throw new Error(json.error || '获取阅读信息失败');
|
||||||
setManifest(json);
|
setManifest(json);
|
||||||
|
cacheBookDetail(json.book);
|
||||||
})
|
})
|
||||||
.catch((err) => setError(err.message || '获取阅读信息失败'));
|
.catch((err) => setError(err.message || '获取阅读信息失败'));
|
||||||
}, [sourceId, href, searchParams]);
|
}, [sourceId, bookId, cached]);
|
||||||
|
|
||||||
const saveProgress = useMemo(() => {
|
const saveProgress = useMemo(() => {
|
||||||
return async (location: EpubLocation, nextProgress = 0, chapterTitle?: string) => {
|
return async (location: EpubLocation, nextProgress = 0, chapterTitle?: string) => {
|
||||||
@@ -312,11 +377,12 @@ export default function BookReadPage() {
|
|||||||
renditionRef.current?.next?.();
|
renditionRef.current?.next?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setControlsVisible((prev) => !prev);
|
|
||||||
setTocOpen(false);
|
setTocOpen(false);
|
||||||
setSettingsOpen(false);
|
setSettingsOpen(false);
|
||||||
}, [ready]);
|
}, [ready]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!manifest || manifest.format !== 'epub' || !viewerRef.current) return;
|
if (!manifest || manifest.format !== 'epub' || !viewerRef.current) return;
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
@@ -337,7 +403,7 @@ export default function BookReadPage() {
|
|||||||
const cacheKey = manifest.cacheKey || buildBookCacheKey(
|
const cacheKey = manifest.cacheKey || buildBookCacheKey(
|
||||||
manifest.book.sourceId,
|
manifest.book.sourceId,
|
||||||
manifest.book.id,
|
manifest.book.id,
|
||||||
manifest.acquisitionHref || manifest.fileUrl
|
manifest.acquisitionHref || `${manifest.book.sourceId}:${manifest.book.id}:${manifest.format}`
|
||||||
);
|
);
|
||||||
|
|
||||||
let fileBuffer: ArrayBuffer;
|
let fileBuffer: ArrayBuffer;
|
||||||
@@ -351,7 +417,7 @@ export default function BookReadPage() {
|
|||||||
fileBuffer = await cached.blob.arrayBuffer();
|
fileBuffer = await cached.blob.arrayBuffer();
|
||||||
} else {
|
} else {
|
||||||
setFileLoadState('downloading');
|
setFileLoadState('downloading');
|
||||||
const blob = await downloadBookWithProgress(manifest.fileUrl, (received, total) => {
|
const blob = await downloadBookWithProgress(manifest, (received, total) => {
|
||||||
if (!destroyed) {
|
if (!destroyed) {
|
||||||
setDownloadedBytes(received);
|
setDownloadedBytes(received);
|
||||||
setTotalBytes(total);
|
setTotalBytes(total);
|
||||||
@@ -364,7 +430,7 @@ export default function BookReadPage() {
|
|||||||
bookId: manifest.book.id,
|
bookId: manifest.book.id,
|
||||||
title: manifest.book.title,
|
title: manifest.book.title,
|
||||||
format: manifest.format,
|
format: manifest.format,
|
||||||
acquisitionHref: manifest.acquisitionHref || manifest.fileUrl,
|
acquisitionHref: manifest.acquisitionHref || `${manifest.book.sourceId}:${manifest.book.id}:${manifest.format}`,
|
||||||
blob,
|
blob,
|
||||||
size: blob.size,
|
size: blob.size,
|
||||||
mimeType: blob.type || 'application/epub+zip',
|
mimeType: blob.type || 'application/epub+zip',
|
||||||
@@ -397,16 +463,43 @@ export default function BookReadPage() {
|
|||||||
applyReaderTheme(settings);
|
applyReaderTheme(settings);
|
||||||
|
|
||||||
const restoreTarget = manifest.lastRecord?.locator?.value || undefined;
|
const restoreTarget = manifest.lastRecord?.locator?.value || undefined;
|
||||||
await navigateToTarget(restoreTarget);
|
let restoreMessageShown = false;
|
||||||
window.clearTimeout(readyFallbackTimer);
|
|
||||||
if (destroyed) return;
|
|
||||||
setReady(true);
|
|
||||||
setFileLoadState('ready');
|
|
||||||
|
|
||||||
if (restoreTarget) {
|
rendition.on('relocated', (location: EpubLocation) => {
|
||||||
setRestoredMessage(`已恢复到上次阅读位置(约 ${Math.round(manifest.lastRecord?.progressPercent || 0)}%)`);
|
if (!destroyed) {
|
||||||
window.setTimeout(() => setRestoredMessage(''), 3000);
|
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 () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
@@ -432,25 +525,6 @@ export default function BookReadPage() {
|
|||||||
// ignore
|
// 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) => {
|
.catch((err) => {
|
||||||
setReady(false);
|
setReady(false);
|
||||||
@@ -479,47 +553,84 @@ export default function BookReadPage() {
|
|||||||
};
|
};
|
||||||
}, [persistCurrentProgress]);
|
}, [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 flatToc = useMemo(() => flattenToc(tocItems), [tocItems]);
|
||||||
const activeTocHref = useMemo(
|
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]
|
[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);
|
const progressLabel = totalBytes ? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` : formatBytes(downloadedBytes);
|
||||||
|
|
||||||
if (error) return <div className='p-4 text-sm text-red-500'>{error}</div>;
|
if (error) return <div className='p-4 text-sm text-red-500'>{error}</div>;
|
||||||
if (!manifest) return <div className='p-4 text-sm text-gray-500'>准备阅读器中...</div>;
|
if (!manifest) return <div className='p-4 text-sm text-gray-500'>准备阅读器中...</div>;
|
||||||
|
|
||||||
if (manifest.format === 'pdf') {
|
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 (
|
return (
|
||||||
<div className='relative h-[calc(100vh-4rem)] overflow-hidden bg-white dark:bg-gray-950'>
|
<div className='relative h-[calc(100vh-3.5rem)] 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>
|
|
||||||
|
|
||||||
{restoredMessage ? (
|
{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}
|
{restoredMessage}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!ready ? (
|
{!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='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='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'>
|
<div className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
@@ -556,7 +667,7 @@ export default function BookReadPage() {
|
|||||||
{tocOpen && (
|
{tocOpen && (
|
||||||
<div className='fixed inset-0 z-40 bg-black/30' onClick={() => setTocOpen(false)}>
|
<div className='fixed inset-0 z-40 bg-black/30' onClick={() => setTocOpen(false)}>
|
||||||
<div
|
<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()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className='p-4'>
|
<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>
|
<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>
|
<button onClick={() => setTocOpen(false)} className='text-xs text-gray-500'>关闭</button>
|
||||||
</div>
|
</div>
|
||||||
<div className='space-y-2'>
|
<div ref={tocScrollRef} className='space-y-2'>
|
||||||
{flatToc.length === 0 ? (
|
{flatToc.length === 0 ? (
|
||||||
<div className='p-3 text-sm text-gray-500'>当前 EPUB 未提供目录</div>
|
<div className='p-3 text-sm text-gray-500'>当前 EPUB 未提供目录</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -573,11 +684,14 @@ export default function BookReadPage() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`${item.href}-${item.label}`}
|
key={`${item.href}-${item.label}`}
|
||||||
|
ref={(node) => {
|
||||||
|
tocItemRefs.current[item.href] = node;
|
||||||
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void navigateToTarget(item.href);
|
void navigateToTarget(item.href);
|
||||||
setTocOpen(false);
|
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}
|
{item.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -646,15 +760,16 @@ export default function BookReadPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{ready && !tocOpen && !settingsOpen ? (
|
{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('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('center')} />
|
||||||
<button aria-label='下一页' className='h-full w-full cursor-pointer bg-transparent' onClick={() => handleReaderTap('right')} />
|
<button aria-label='下一页' className='h-full w-full cursor-pointer bg-transparent' onClick={() => handleReaderTap('right')} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,20 +4,11 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import BookCard from '@/components/books/BookCard';
|
import BookCard from '@/components/books/BookCard';
|
||||||
|
import { buildBookDetailPath, cacheBookListItem } from '@/lib/book-route-cache.client';
|
||||||
import { BookListItem, BookSearchResult, BookSource } from '@/lib/book.types';
|
import { BookListItem, BookSearchResult, BookSource } from '@/lib/book.types';
|
||||||
|
|
||||||
function detailHref(item: BookListItem) {
|
function detailHref(item: BookListItem) {
|
||||||
const params = new URLSearchParams({
|
return buildBookDetailPath(item.sourceId, item.id);
|
||||||
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()}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchSkeleton() {
|
function SearchSkeleton() {
|
||||||
@@ -75,7 +66,7 @@ export default function BooksSearchPage() {
|
|||||||
{loading ? <SearchSkeleton /> : null}
|
{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}
|
{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'>
|
<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>
|
</section>
|
||||||
{!loading && result.results.length === 0 ? <div className='text-sm text-gray-500'>暂无结果</div> : null}
|
{!loading && result.results.length === 0 ? <div className='text-sm text-gray-500'>暂无结果</div> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { buildBookDetailPath, cacheBookShelfItem } from '@/lib/book-route-cache.client';
|
||||||
import { deleteBookShelf, getAllBookShelf } from '@/lib/book.db.client';
|
import { deleteBookShelf, getAllBookShelf } from '@/lib/book.db.client';
|
||||||
import { BookShelfItem } from '@/lib/book.types';
|
import { BookShelfItem } from '@/lib/book.types';
|
||||||
|
|
||||||
@@ -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-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-2 text-xs text-gray-500'>进度 {Math.round(item.progressPercent || 0)}%</div>
|
||||||
<div className='mt-3 flex flex-wrap gap-2'>
|
<div className='mt-3 flex flex-wrap gap-2'>
|
||||||
<Link href={`/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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import Link from 'next/link';
|
|||||||
|
|
||||||
import { BookListItem } from '@/lib/book.types';
|
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 (
|
return (
|
||||||
<div className='overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
<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'>
|
<div className='aspect-[3/4] bg-gray-100 dark:bg-gray-900'>
|
||||||
{item.cover ? (
|
{item.cover ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
@@ -18,7 +18,7 @@ export default function BookCard({ item, href, extra }: { item: BookListItem; hr
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<div className='space-y-2 p-3'>
|
<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>
|
<div className='line-clamp-1 text-xs text-gray-500 dark:text-gray-400'>{item.author || item.sourceName}</div>
|
||||||
{extra}
|
{extra}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client';
|
'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 Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ href: '/books', label: '发现', icon: Library },
|
{ href: '/books', label: '发现', icon: Library },
|
||||||
@@ -11,26 +12,102 @@ const tabs = [
|
|||||||
{ href: '/books/history', label: '历史', icon: History },
|
{ 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 }) {
|
export default function BooksLayout({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const isRead = pathname === '/books/read';
|
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 (
|
return (
|
||||||
<div className='min-h-screen bg-gray-50 text-gray-900 dark:bg-black dark:text-gray-100'>
|
<div className='min-h-screen bg-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'>
|
<header className='fixed inset-x-0 top-0 z-40 border-b border-gray-200/70 bg-white/90 backdrop-blur dark:border-gray-800 dark:bg-gray-950/90'>
|
||||||
<div className='mx-auto flex h-14 max-w-6xl items-center gap-3 px-4'>
|
<div className='mx-auto flex h-14 max-w-6xl items-center gap-3 px-4'>
|
||||||
{isRead ? (
|
{isRead || pathname === '/books/detail' ? (
|
||||||
<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'>
|
<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' />
|
<ChevronLeft className='h-5 w-5' />
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<Link href='/' className='text-sm font-semibold text-sky-600'>MoonTV+</Link>
|
<Link href='/' className='text-sm font-semibold text-sky-600'>MoonTV+</Link>
|
||||||
)}
|
)}
|
||||||
<div className='min-w-0 flex-1'>
|
<div className='min-w-0 flex-1'>
|
||||||
<div className='truncate text-sm font-semibold sm:text-base'>{isRead ? '电子书阅读' : '电子书馆'}</div>
|
<div className='group relative'>
|
||||||
<div className='truncate text-xs text-gray-500 dark:text-gray-400'>OPDS 目录、搜索、阅读与书架</div>
|
<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>
|
</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'>
|
<nav className='hidden items-center gap-2 md:flex'>
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const active = pathname === tab.href;
|
const active = pathname === tab.href;
|
||||||
@@ -46,7 +123,7 @@ export default function BooksLayout({ children }: { children: React.ReactNode })
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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 && (
|
{!isRead && (
|
||||||
<nav className='fixed inset-x-0 bottom-0 z-40 grid grid-cols-4 border-t border-gray-200/70 bg-white/95 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95 md:hidden'>
|
<nav className='fixed inset-x-0 bottom-0 z-40 grid grid-cols-4 border-t border-gray-200/70 bg-white/95 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95 md:hidden'>
|
||||||
{tabs.map((tab) => {
|
{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),
|
href: normalizeUrl(source.url, href || source.url),
|
||||||
entries: bookEntries.map((entry) => mapEntryToItem(source, entry)),
|
entries: bookEntries.map((entry) => mapEntryToItem(source, entry)),
|
||||||
navigation: [
|
navigation: [
|
||||||
...feed.links.filter((link) => isNavigationLink(link)).map((link) => ({
|
...feed.links
|
||||||
title: link.title || '目录',
|
.filter((link) => isNavigationLink(link) && link.rel !== 'next' && link.rel !== 'previous' && !!(link.title || '').trim())
|
||||||
href: link.href,
|
.map((link) => ({
|
||||||
rel: link.rel,
|
title: (link.title || '').trim(),
|
||||||
type: link.type,
|
href: link.href,
|
||||||
})),
|
rel: link.rel,
|
||||||
|
type: link.type,
|
||||||
|
})),
|
||||||
...navigationEntries
|
...navigationEntries
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
title: entry.title,
|
title: (entry.title || '').trim(),
|
||||||
href: pickDetailHref(entry.links) || entry.links.find((link) => isNavigationLink(link))?.href || '',
|
href: pickDetailHref(entry.links) || entry.links.find((link) => isNavigationLink(link))?.href || '',
|
||||||
rel: entry.links.find((link) => isNavigationLink(link))?.rel,
|
rel: entry.links.find((link) => isNavigationLink(link))?.rel,
|
||||||
type: entry.links.find((link) => isNavigationLink(link))?.type,
|
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,
|
nextHref: feed.links.find((link) => link.rel === 'next')?.href,
|
||||||
previousHref: feed.links.find((link) => link.rel === 'previous')?.href,
|
previousHref: feed.links.find((link) => link.rel === 'previous')?.href,
|
||||||
|
|||||||
Reference in New Issue
Block a user