legado初步支持
This commit is contained in:
@@ -6,7 +6,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { buildBookReadPath, cacheBookDetail, getBookRouteCache } from '@/lib/book-route-cache.client';
|
||||
import { deleteBookShelf, getAllBookShelf, saveBookShelf } from '@/lib/book.db.client';
|
||||
import { BookDetail, BookShelfItem } from '@/lib/book.types';
|
||||
import { BookChapter, BookDetail, BookShelfItem } from '@/lib/book.types';
|
||||
|
||||
function DetailSkeleton() {
|
||||
return (
|
||||
@@ -47,7 +47,7 @@ function sanitizeFilename(name: string) {
|
||||
return name.replace(/[\/:*?"<>|]/g, '_').trim();
|
||||
}
|
||||
|
||||
async function openBookFile(sourceId: string, bookId: string, format?: 'epub' | 'pdf', download = false, href?: string, title?: string) {
|
||||
async function openBookFile(sourceId: string, bookId: string, format?: 'epub' | 'pdf' | 'chapters', download = false, href?: string, title?: string) {
|
||||
const response = await fetch('/api/books/file', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -86,6 +86,9 @@ export default function BookDetailPage() {
|
||||
const bookId = searchParams.get('bookId') || '';
|
||||
const [detail, setDetail] = useState<BookDetail | null>(null);
|
||||
const [shelf, setShelf] = useState<Record<string, BookShelfItem>>({});
|
||||
const [chapters, setChapters] = useState<BookChapter[]>([]);
|
||||
const [chaptersLoading, setChaptersLoading] = useState(false);
|
||||
const [chaptersError, setChaptersError] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [fileBusy, setFileBusy] = useState<'open' | 'download' | ''>('');
|
||||
|
||||
@@ -122,8 +125,50 @@ export default function BookDetailPage() {
|
||||
.catch((err) => setError(err.message || '获取详情失败'));
|
||||
}, [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 readable = detail?.acquisitionLinks.find((item) => {
|
||||
const type = item.type.toLowerCase();
|
||||
return type.includes('epub') || type.includes('pdf') || type.includes('legado-chapters') || item.rel === 'legado:chapters';
|
||||
});
|
||||
const readableFormat = readable?.type.toLowerCase().includes('pdf')
|
||||
? 'pdf'
|
||||
: readable?.type.toLowerCase().includes('legado-chapters') || readable?.rel === 'legado:chapters'
|
||||
? 'chapters'
|
||||
: 'epub';
|
||||
|
||||
useEffect(() => {
|
||||
if (!detail || !readable || readableFormat !== 'chapters') {
|
||||
setChapters([]);
|
||||
setChaptersError('');
|
||||
setChaptersLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setChapters([]);
|
||||
setChaptersLoading(true);
|
||||
setChaptersError('');
|
||||
const params = new URLSearchParams({
|
||||
sourceId: detail.sourceId,
|
||||
bookId: detail.id,
|
||||
});
|
||||
fetch(`/api/books/read/chapters?${params.toString()}`, { cache: 'no-store' })
|
||||
.then(async (res) => {
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || '获取章节失败');
|
||||
if (cancelled) return;
|
||||
setChapters((json.chapters || []) as BookChapter[]);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setChapters([]);
|
||||
setChaptersError(err.message || '获取章节失败');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setChaptersLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [detail, readable, readableFormat]);
|
||||
|
||||
const toggleShelf = async () => {
|
||||
if (!detail) return;
|
||||
@@ -176,7 +221,7 @@ export default function BookDetailPage() {
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
{readable ? <Link href={buildBookReadPath(detail.sourceId, detail.id)} onClick={() => cacheBookDetail(detail)} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'>在线阅读</Link> : null}
|
||||
<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>
|
||||
{readable ? <button onClick={async () => { try { setFileBusy('download'); await openBookFile(detail.sourceId, detail.id, readableFormat, true, readable?.href, detail.title); } catch (err) { setError((err as Error).message || '下载文件失败'); } finally { setFileBusy(''); } }} disabled={fileBusy !== ''} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>{fileBusy === 'download' ? '下载中...' : '下载文件'}</button> : null}
|
||||
{readable && readableFormat !== 'chapters' ? <button onClick={async () => { try { setFileBusy('download'); await openBookFile(detail.sourceId, detail.id, readableFormat, true, readable?.href, detail.title); } catch (err) { setError((err as Error).message || '下载文件失败'); } finally { setFileBusy(''); } }} disabled={fileBusy !== ''} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>{fileBusy === 'download' ? '下载中...' : '下载文件'}</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -184,7 +229,8 @@ export default function BookDetailPage() {
|
||||
<h2 className='text-lg font-semibold'>可用格式</h2>
|
||||
<div className='mt-4 space-y-3'>
|
||||
{detail.acquisitionLinks.map((item) => {
|
||||
const format = item.type.toLowerCase().includes('pdf') ? 'pdf' : item.type.toLowerCase().includes('epub') ? 'epub' : undefined;
|
||||
const type = item.type.toLowerCase();
|
||||
const format = type.includes('pdf') ? 'pdf' : type.includes('epub') ? 'epub' : type.includes('legado-chapters') || item.rel === 'legado:chapters' ? 'chapters' : undefined;
|
||||
return (
|
||||
<div key={`${item.href}-${item.type}`} className='flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3 text-sm dark:bg-gray-900'>
|
||||
<div>
|
||||
@@ -193,7 +239,7 @@ export default function BookDetailPage() {
|
||||
</div>
|
||||
<button disabled={!format || fileBusy !== ''} onClick={async () => {
|
||||
if (!format) return;
|
||||
if (format === 'epub') {
|
||||
if (format === 'epub' || format === 'chapters') {
|
||||
cacheBookDetail(detail);
|
||||
window.location.href = buildBookReadPath(detail.sourceId, detail.id);
|
||||
return;
|
||||
@@ -212,6 +258,36 @@ export default function BookDetailPage() {
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
{readableFormat === 'chapters' ? (
|
||||
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<h2 className='text-lg font-semibold'>章节目录</h2>
|
||||
<div className='text-sm text-gray-500'>{chaptersLoading ? '加载中...' : `${chapters.length} 章`}</div>
|
||||
</div>
|
||||
{chaptersError ? <div className='mt-4 text-sm text-red-500'>{chaptersError}</div> : null}
|
||||
{!chaptersLoading && !chaptersError && chapters.length === 0 ? (
|
||||
<div className='mt-4 rounded-2xl bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:bg-amber-900/20 dark:text-amber-200'>
|
||||
源站当前没有返回章节,这不是 EPUB 文件缺失;请换有章节的搜索结果。
|
||||
</div>
|
||||
) : null}
|
||||
{chapters.length > 0 ? (
|
||||
<div className='mt-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-3'>
|
||||
{chapters.slice(0, 60).map((chapter) => (
|
||||
<Link
|
||||
key={`${chapter.href}-${chapter.order}`}
|
||||
href={buildBookReadPath(detail.sourceId, detail.id, chapter.href)}
|
||||
onClick={() => cacheBookDetail(detail)}
|
||||
className='truncate rounded-2xl bg-gray-50 px-4 py-3 text-sm hover:bg-sky-50 hover:text-sky-600 dark:bg-gray-900 dark:hover:bg-sky-950/40'
|
||||
title={chapter.title}
|
||||
>
|
||||
{chapter.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{chapters.length > 60 ? <div className='mt-3 text-xs text-gray-500'>仅预览前 60 章,完整目录请进入阅读页侧边栏查看。</div> : null}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,8 +45,8 @@ export default function BooksHomePage() {
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<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'>OPDS 电子书源</h1>
|
||||
<p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>支持分类浏览、搜索、书架与 EPUB 在线阅读。</p>
|
||||
<h1 className='text-lg font-semibold'>电子书源</h1>
|
||||
<p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>支持 OPDS 与 Legado 书源,提供搜索、书架与在线阅读。</p>
|
||||
</section>
|
||||
|
||||
{loading ? <BooksHomeSkeleton /> : null}
|
||||
@@ -56,6 +56,7 @@ export default function BooksHomePage() {
|
||||
{sources.map((source) => (
|
||||
<div key={source.id} className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
||||
<div className='text-base font-semibold'>{source.name}</div>
|
||||
<div className='mt-1 text-xs text-gray-400'>{source.type === 'legado' ? 'Legado' : 'OPDS'}</div>
|
||||
<div className='mt-2 flex flex-wrap gap-2 text-xs'>
|
||||
<span className={`rounded-full px-2 py-1 ${source.capabilities?.catalogSupported ? 'bg-sky-100 text-sky-700 dark:bg-sky-950/50 dark:text-sky-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-900 dark:text-gray-400'}`}>分类{source.capabilities?.catalogSupported ? '可用' : '不可用'}</span>
|
||||
<span className={`rounded-full px-2 py-1 ${source.capabilities?.searchSupported ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-900 dark:text-gray-400'}`}>搜索{source.capabilities?.searchSupported ? '可用' : '不可用'}</span>
|
||||
|
||||
+153
-2
@@ -6,7 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { saveBookReadRecord } from '@/lib/book.db.client';
|
||||
import { BookReadManifest, BookReadRecord, BookTtsProgress, BookTtsVoice } from '@/lib/book.types';
|
||||
import { BookChapter, BookChapterContent, BookReadManifest, BookReadRecord, BookTtsProgress, BookTtsVoice } from '@/lib/book.types';
|
||||
import {
|
||||
buildBookCacheKey,
|
||||
enforceBookCacheLimit,
|
||||
@@ -370,6 +370,153 @@ async function downloadBookWithProgress(
|
||||
return new Blob(chunks, { type: response.headers.get('content-type') || 'application/epub+zip' });
|
||||
}
|
||||
|
||||
function ChapterReader({ manifest }: { manifest: BookReadManifest }) {
|
||||
const searchParams = useSearchParams();
|
||||
const initialChapterHref = searchParams.get('chapterHref') || '';
|
||||
const [chapters, setChapters] = useState<BookChapter[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [chapter, setChapter] = useState<BookChapterContent | null>(null);
|
||||
const [tocOpen, setTocOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const handleToggleChapters = () => setTocOpen((prev) => !prev);
|
||||
window.addEventListener('books-read-toggle-chapters', handleToggleChapters);
|
||||
return () => window.removeEventListener('books-read-toggle-chapters', handleToggleChapters);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!manifest.chaptersUrl && !manifest.acquisitionHref) return;
|
||||
let cancelled = false;
|
||||
setChapters([]);
|
||||
setChapter(null);
|
||||
setCurrentIndex(0);
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const url = manifest.chaptersUrl || `/api/books/read/chapters?sourceId=${encodeURIComponent(manifest.book.sourceId)}&bookId=${encodeURIComponent(manifest.book.id)}`;
|
||||
fetch(url, { cache: 'no-store' })
|
||||
.then(async (res) => {
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || '获取目录失败');
|
||||
if (cancelled) return;
|
||||
const list = (json.chapters || []) as BookChapter[];
|
||||
setChapters(list);
|
||||
const savedHref = initialChapterHref || manifest.lastRecord?.chapterHref || manifest.lastRecord?.locator?.href || manifest.lastRecord?.locator?.value || '';
|
||||
const savedIndex = list.findIndex((item) => item.href === savedHref);
|
||||
setCurrentIndex(savedIndex >= 0 ? savedIndex : 0);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err.message || '获取目录失败');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [initialChapterHref, manifest]);
|
||||
|
||||
useEffect(() => {
|
||||
const item = chapters[currentIndex];
|
||||
if (!item) {
|
||||
if (chapters.length === 0) setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const params = new URLSearchParams({
|
||||
sourceId: manifest.book.sourceId,
|
||||
href: item.href,
|
||||
});
|
||||
if (manifest.acquisitionHref) params.set('tocHref', manifest.acquisitionHref);
|
||||
fetch(`/api/books/read/chapter?${params.toString()}`, { cache: 'no-store' })
|
||||
.then(async (res) => {
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || '获取章节失败');
|
||||
setChapter({ ...(json as BookChapterContent), title: (json as BookChapterContent).title || item.title });
|
||||
const progressPercent = chapters.length > 0 ? Math.round(((currentIndex + 1) / chapters.length) * 100) : 0;
|
||||
const record: BookReadRecord = {
|
||||
sourceId: manifest.book.sourceId,
|
||||
sourceName: manifest.book.sourceName,
|
||||
bookId: manifest.book.id,
|
||||
title: manifest.book.title,
|
||||
author: manifest.book.author,
|
||||
cover: manifest.book.cover,
|
||||
detailHref: manifest.book.detailHref,
|
||||
acquisitionHref: manifest.acquisitionHref,
|
||||
format: 'chapters',
|
||||
locator: { type: 'chapter', value: item.href, href: item.href, chapterTitle: item.title },
|
||||
chapterTitle: item.title,
|
||||
chapterHref: item.href,
|
||||
progressPercent,
|
||||
saveTime: Date.now(),
|
||||
};
|
||||
void saveBookReadRecord(record.sourceId, record.bookId, record);
|
||||
})
|
||||
.catch((err) => setError(err.message || '获取章节失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [chapters, currentIndex, manifest]);
|
||||
|
||||
if (error) return <div className='p-4 text-sm text-red-500'>{error}</div>;
|
||||
if (loading && !chapter) return <div className='p-4 text-sm text-gray-500'>章节加载中...</div>;
|
||||
if (!chapters.length) {
|
||||
return (
|
||||
<div className='mx-auto max-w-2xl p-4'>
|
||||
<div className='rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-200'>
|
||||
暂无章节。该 Legado 源返回的是章节/图片接口,不是 EPUB 文件;如果详情接口显示章节数为 0,说明源站当前还没放出可读章节,请换一本有章节的结果再试。
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='min-h-[calc(100vh-3.5rem)] bg-gray-50 dark:bg-black'>
|
||||
{tocOpen && typeof document !== 'undefined' ? createPortal(
|
||||
<div className='fixed inset-0 z-40 bg-black/30' onClick={() => setTocOpen(false)}>
|
||||
<div
|
||||
className='absolute right-0 top-0 h-screen w-[22rem] max-w-[88vw] overflow-y-auto border-l border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-950'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className='sticky top-0 border-b border-gray-200 bg-white/95 p-4 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95'>
|
||||
<div className='text-base font-semibold'>章节目录</div>
|
||||
<div className='mt-1 text-xs text-gray-500'>{manifest.book.title} · {chapters.length} 章</div>
|
||||
</div>
|
||||
<div className='space-y-2 p-4'>
|
||||
{chapters.map((item, index) => {
|
||||
const active = index === currentIndex;
|
||||
return (
|
||||
<button
|
||||
key={`${item.href}-${item.order}-${index}`}
|
||||
onClick={() => {
|
||||
setCurrentIndex(index);
|
||||
setTocOpen(false);
|
||||
}}
|
||||
className={`block w-full rounded-2xl px-4 py-3 text-left text-sm transition ${active ? 'bg-sky-600 text-white' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900'}`}
|
||||
title={item.title}
|
||||
>
|
||||
<span className='block truncate'>{item.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
) : null}
|
||||
|
||||
<div className='mx-auto max-w-3xl px-4 py-6'>
|
||||
<article className='text-lg leading-9 text-gray-800 dark:text-gray-100'>
|
||||
{loading ? '加载中...' : chapter?.content?.includes('<img')
|
||||
? <div className='space-y-2' dangerouslySetInnerHTML={{ __html: chapter.content }} />
|
||||
: <div className='whitespace-pre-wrap'>{chapter?.content || '本章暂无内容'}</div>}
|
||||
</article>
|
||||
<div className='mt-5 flex justify-between gap-3'>
|
||||
<button disabled={currentIndex <= 0} onClick={() => setCurrentIndex((prev) => Math.max(0, prev - 1))} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm disabled:text-gray-400 dark:border-gray-700'>上一章</button>
|
||||
<button disabled={currentIndex >= chapters.length - 1} onClick={() => setCurrentIndex((prev) => Math.min(chapters.length - 1, prev + 1))} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white disabled:bg-gray-300'>下一章</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function normalizeHrefForMatch(href?: string) {
|
||||
if (!href) return '';
|
||||
@@ -1174,7 +1321,7 @@ export default function BookReadPage() {
|
||||
sourceId: manifest.book.sourceId,
|
||||
bookId: manifest.book.id,
|
||||
title: manifest.book.title,
|
||||
format: manifest.format,
|
||||
format: 'epub',
|
||||
acquisitionHref: manifest.acquisitionHref || `${manifest.book.sourceId}:${manifest.book.id}:${manifest.format}`,
|
||||
blob,
|
||||
size: blob.size,
|
||||
@@ -1655,6 +1802,10 @@ export default function BookReadPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (manifest.format === 'chapters') {
|
||||
return <ChapterReader manifest={manifest} />;
|
||||
}
|
||||
|
||||
if (manifest.format === 'pdf') {
|
||||
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} />;
|
||||
|
||||
Reference in New Issue
Block a user