新增电子书架
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import BookCard from '@/components/books/BookCard';
|
||||
import { BookCatalogResult, BookListItem, BookSource } from '@/lib/book.types';
|
||||
|
||||
function makeHref(sourceId: string, item: BookListItem) {
|
||||
const params = new URLSearchParams({
|
||||
sourceId,
|
||||
href: item.detailHref || '',
|
||||
bookId: item.id,
|
||||
title: item.title,
|
||||
author: item.author || '',
|
||||
cover: item.cover || '',
|
||||
summary: item.summary || '',
|
||||
acquisitionLinks: JSON.stringify(item.acquisitionLinks || []),
|
||||
});
|
||||
return `/books/detail?${params.toString()}`;
|
||||
}
|
||||
|
||||
function CatalogSkeleton() {
|
||||
return (
|
||||
<div className='space-y-6 animate-pulse'>
|
||||
<div className='flex gap-2 overflow-x-auto pb-1'>
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className='h-10 w-24 rounded-full bg-gray-200 dark:bg-gray-800' />
|
||||
))}
|
||||
</div>
|
||||
<div className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
||||
<div className='h-6 w-40 rounded bg-gray-200 dark:bg-gray-800' />
|
||||
<div className='mt-3 h-4 w-72 rounded bg-gray-200 dark:bg-gray-800' />
|
||||
</div>
|
||||
<div className='flex gap-3 overflow-x-auto pb-2'>
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} className='h-20 min-w-[180px] rounded-2xl bg-gray-200 dark:bg-gray-800' />
|
||||
))}
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
||||
{Array.from({ length: 12 }).map((_, index) => (
|
||||
<div key={index} className='space-y-3'>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BooksCatalogPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const sourceId = searchParams.get('sourceId') || '';
|
||||
const href = searchParams.get('href') || '';
|
||||
const [sources, setSources] = useState<BookSource[]>([]);
|
||||
const [data, setData] = useState<BookCatalogResult | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/books/sources').then((res) => res.json()).then((json) => setSources(json.sources || []));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourceId) return;
|
||||
const params = new URLSearchParams({ sourceId });
|
||||
if (href) params.set('href', href);
|
||||
fetch(`/api/books/catalog?${params.toString()}`)
|
||||
.then(async (res) => {
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || '获取目录失败');
|
||||
setData(json);
|
||||
})
|
||||
.catch((err) => setError(err.message || '获取目录失败'));
|
||||
}, [sourceId, href]);
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{sources.map((source) => (
|
||||
<Link key={source.id} href={`/books/catalog?sourceId=${encodeURIComponent(source.id)}`} className={`rounded-full px-4 py-2 text-sm ${source.id === sourceId ? 'bg-sky-600 text-white' : 'border border-gray-200 dark:border-gray-700'}`}>
|
||||
{source.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{error ? <div className='text-sm text-red-500'>{error}</div> : null}
|
||||
{data ? (
|
||||
<>
|
||||
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
||||
<h1 className='text-lg font-semibold'>{data.title}</h1>
|
||||
{data.subtitle ? <p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>{data.subtitle}</p> : null}
|
||||
<div className='mt-4 flex flex-wrap gap-2'>
|
||||
{data.previousHref ? <Link href={`/books/catalog?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(data.previousHref)}`} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>上一页</Link> : null}
|
||||
{data.nextHref ? <Link href={`/books/catalog?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(data.nextHref)}`} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>下一页</Link> : null}
|
||||
</div>
|
||||
</section>
|
||||
{data.navigation.length > 0 ? (
|
||||
<section className='space-y-3'>
|
||||
<div className='text-sm font-medium text-gray-700 dark:text-gray-300'>目录</div>
|
||||
<div className='flex gap-3 overflow-x-auto pb-2'>
|
||||
{data.navigation.map((item, index) => (
|
||||
<Link
|
||||
key={`${item.href}-${index}`}
|
||||
href={`/books/catalog?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(item.href)}`}
|
||||
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='mt-2 text-xs text-gray-500 dark:text-gray-400'>点击进入子目录</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<section className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
||||
{data.entries.map((item) => <BookCard key={`${item.sourceId}-${item.id}`} item={item} href={makeHref(sourceId, item)} />)}
|
||||
</section>
|
||||
</>
|
||||
) : !error ? <CatalogSkeleton /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { BookDetail, BookShelfItem } from '@/lib/book.types';
|
||||
import { deleteBookShelf, getAllBookShelf, saveBookShelf } from '@/lib/book.db.client';
|
||||
|
||||
function DetailSkeleton() {
|
||||
return (
|
||||
<div className='space-y-6 animate-pulse'>
|
||||
<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]'>
|
||||
<div className='aspect-[3/4] rounded-3xl bg-gray-200 dark:bg-gray-800' />
|
||||
<div className='space-y-4'>
|
||||
<div className='h-8 w-2/3 rounded bg-gray-200 dark:bg-gray-800' />
|
||||
<div className='h-4 w-1/3 rounded bg-gray-200 dark:bg-gray-800' />
|
||||
<div className='space-y-2'>
|
||||
<div className='h-4 w-full rounded bg-gray-200 dark:bg-gray-800' />
|
||||
<div className='h-4 w-11/12 rounded bg-gray-200 dark:bg-gray-800' />
|
||||
<div className='h-4 w-10/12 rounded bg-gray-200 dark:bg-gray-800' />
|
||||
</div>
|
||||
<div className='flex gap-3'>
|
||||
<div className='h-10 w-24 rounded-2xl bg-gray-200 dark:bg-gray-800' />
|
||||
<div className='h-10 w-24 rounded-2xl bg-gray-200 dark:bg-gray-800' />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BookDetailPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const sourceId = searchParams.get('sourceId') || '';
|
||||
const href = searchParams.get('href') || '';
|
||||
const [detail, setDetail] = useState<BookDetail | null>(null);
|
||||
const [shelf, setShelf] = useState<Record<string, BookShelfItem>>({});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
getAllBookShelf().then(setShelf).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
fetch(`/api/books/detail?${params.toString()}`)
|
||||
.then(async (res) => {
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || '获取详情失败');
|
||||
setDetail(json);
|
||||
})
|
||||
.catch((err) => setError(err.message || '获取详情失败'));
|
||||
}, [searchParams]);
|
||||
|
||||
const toggleShelf = async () => {
|
||||
if (!detail) return;
|
||||
const bookKey = `${detail.sourceId}+${detail.id}`;
|
||||
if (shelf[bookKey]) {
|
||||
await deleteBookShelf(detail.sourceId, detail.id);
|
||||
setShelf((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[bookKey];
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
const item: BookShelfItem = {
|
||||
sourceId: detail.sourceId,
|
||||
sourceName: detail.sourceName,
|
||||
bookId: detail.id,
|
||||
title: detail.title,
|
||||
author: detail.author,
|
||||
cover: detail.cover,
|
||||
detailHref: detail.detailHref,
|
||||
acquisitionHref: readable?.href,
|
||||
saveTime: Date.now(),
|
||||
};
|
||||
await saveBookShelf(detail.sourceId, detail.id, item);
|
||||
setShelf((prev) => ({ ...prev, [bookKey]: item }));
|
||||
};
|
||||
|
||||
if (error) return <div className='text-sm text-red-500'>{error}</div>;
|
||||
if (!detail) return <DetailSkeleton />;
|
||||
|
||||
const readable = detail.acquisitionLinks.find((item) => item.type.toLowerCase().includes('epub') || item.type.toLowerCase().includes('pdf'));
|
||||
const readableFormat = readable?.type.toLowerCase().includes('pdf') ? 'pdf' : 'epub';
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<section className='grid gap-6 rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950 md:grid-cols-[220px_1fr]'>
|
||||
<div className='overflow-hidden rounded-3xl bg-gray-100 dark:bg-gray-900'>
|
||||
{detail.cover ? <img src={detail.cover} alt={detail.title} className='h-full w-full object-cover' /> : <div className='flex aspect-[3/4] items-center justify-center text-sm text-gray-400'>无封面</div>}
|
||||
</div>
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<h1 className='text-2xl font-semibold'>{detail.title}</h1>
|
||||
<div className='mt-2 text-sm text-gray-500 dark:text-gray-400'>{detail.author || detail.sourceName}</div>
|
||||
</div>
|
||||
{detail.summary ? <div className='text-sm leading-7 text-gray-700 dark:text-gray-300'>{detail.summary}</div> : null}
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{(detail.categories || detail.tags || []).map((tag) => <span key={tag} className='rounded-full bg-gray-100 px-3 py-1 text-xs dark:bg-gray-900'>{tag}</span>)}
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
{readable ? <Link href={`/books/read?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(href || detail.detailHref || '')}&acquisitionHref=${encodeURIComponent(readable.href)}&format=${encodeURIComponent(readableFormat)}&bookId=${encodeURIComponent(detail.id)}&title=${encodeURIComponent(detail.title)}&author=${encodeURIComponent(detail.author || '')}&cover=${encodeURIComponent(detail.cover || '')}`} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'>在线阅读</Link> : null}
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
||||
<h2 className='text-lg font-semibold'>可用格式</h2>
|
||||
<div className='mt-4 space-y-3'>
|
||||
{detail.acquisitionLinks.map((item) => (
|
||||
<div key={`${item.href}-${item.type}`} className='flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3 text-sm dark:bg-gray-900'>
|
||||
<div>
|
||||
<div>{item.title || item.type}</div>
|
||||
<div className='text-xs text-gray-500'>{item.rel}</div>
|
||||
</div>
|
||||
<a href={`/api/books/file?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(item.href)}`} target='_blank' rel='noreferrer' className='text-sky-600'>打开</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { deleteBookReadRecord, getAllBookReadRecords, getAllBookShelf } from '@/lib/book.db.client';
|
||||
import { BookReadRecord, BookShelfItem } from '@/lib/book.types';
|
||||
|
||||
export default function BookHistoryPage() {
|
||||
const [records, setRecords] = useState<Record<string, BookReadRecord>>({});
|
||||
const [shelf, setShelf] = useState<Record<string, BookShelfItem>>({});
|
||||
|
||||
useEffect(() => {
|
||||
getAllBookReadRecords().then(setRecords).catch(() => undefined);
|
||||
getAllBookShelf().then(setShelf).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const items = useMemo(() => Object.entries(records)
|
||||
.map(([key, item]) => {
|
||||
const [fallbackSourceId = '', fallbackBookId = ''] = key.split('+');
|
||||
const shelfItem = shelf[key];
|
||||
return {
|
||||
...item,
|
||||
storageKey: key,
|
||||
sourceId: item.sourceId || shelfItem?.sourceId || fallbackSourceId,
|
||||
bookId: item.bookId || shelfItem?.bookId || fallbackBookId,
|
||||
sourceName: item.sourceName || shelfItem?.sourceName || '',
|
||||
detailHref: item.detailHref || shelfItem?.detailHref,
|
||||
acquisitionHref: item.acquisitionHref || shelfItem?.acquisitionHref,
|
||||
cover: item.cover || shelfItem?.cover,
|
||||
author: item.author || shelfItem?.author,
|
||||
format: item.format || shelfItem?.format || 'epub',
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.saveTime - a.saveTime), [records, shelf]);
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
{items.map((item) => (
|
||||
<div key={item.storageKey} className='rounded-3xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
||||
<div className='flex gap-4'>
|
||||
<div className='h-28 w-20 overflow-hidden rounded-2xl bg-gray-100 dark:bg-gray-900'>{item.cover ? <img src={item.cover} alt={item.title} className='h-full w-full object-cover' /> : null}</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='truncate font-medium'>{item.title}</div>
|
||||
<div className='mt-1 text-sm text-gray-500'>{item.author || item.sourceName}</div>
|
||||
<div className='mt-1 text-xs text-gray-500'>已读 {Math.round(item.progressPercent || 0)}% · {item.chapterTitle || item.locator.chapterTitle || '定位已保存'}</div>
|
||||
<div className='mt-3 flex flex-wrap gap-2'>
|
||||
{item.sourceId ? (
|
||||
<Link
|
||||
href={{
|
||||
pathname: '/books/read',
|
||||
query: {
|
||||
sourceId: item.sourceId,
|
||||
href: item.detailHref || '',
|
||||
acquisitionHref: item.acquisitionHref || '',
|
||||
format: item.format,
|
||||
bookId: item.bookId,
|
||||
title: item.title,
|
||||
author: item.author || '',
|
||||
cover: item.cover || '',
|
||||
},
|
||||
}}
|
||||
className='rounded-2xl bg-sky-600 px-3 py-2 text-xs text-white'
|
||||
>
|
||||
继续阅读
|
||||
</Link>
|
||||
) : (
|
||||
<span className='rounded-2xl bg-gray-200 px-3 py-2 text-xs text-gray-500 dark:bg-gray-800'>历史记录缺少书源信息</span>
|
||||
)}
|
||||
<button onClick={async () => { const [deleteSourceId = item.sourceId, deleteBookId = item.bookId] = item.storageKey.split('+'); await deleteBookReadRecord(deleteSourceId, deleteBookId); setRecords((prev) => { const next = { ...prev }; delete next[item.storageKey]; return next; }); }} className='rounded-2xl border border-gray-200 px-3 py-2 text-xs dark:border-gray-700'>删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 ? <div className='text-sm text-gray-500'>暂无阅读历史</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import BooksLayout from '@/components/books/BooksLayout';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <BooksLayout>{children}</BooksLayout>;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { BookSource } from '@/lib/book.types';
|
||||
|
||||
function BooksHomeSkeleton() {
|
||||
return (
|
||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3 animate-pulse'>
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
||||
<div className='h-5 w-32 rounded bg-gray-200 dark:bg-gray-800' />
|
||||
<div className='mt-3 flex gap-2'>
|
||||
<div className='h-6 w-16 rounded-full bg-gray-200 dark:bg-gray-800' />
|
||||
<div className='h-6 w-16 rounded-full bg-gray-200 dark:bg-gray-800' />
|
||||
</div>
|
||||
<div className='mt-4 flex gap-2'>
|
||||
<div className='h-10 w-24 rounded-2xl bg-gray-200 dark:bg-gray-800' />
|
||||
<div className='h-10 w-24 rounded-2xl bg-gray-200 dark:bg-gray-800' />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BooksHomePage() {
|
||||
const [sources, setSources] = useState<BookSource[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && !(window as Window & { RUNTIME_CONFIG?: { BOOKS_ENABLED?: boolean } }).RUNTIME_CONFIG?.BOOKS_ENABLED) {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
fetch('/api/books/sources')
|
||||
.then((res) => res.json())
|
||||
.then((data) => setSources(data.sources || []))
|
||||
.catch((err) => setError(err.message || '加载书源失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
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>
|
||||
</section>
|
||||
|
||||
{loading ? <BooksHomeSkeleton /> : null}
|
||||
{error ? <div className='text-sm text-red-500'>{error}</div> : null}
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
||||
{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-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>
|
||||
</div>
|
||||
<div className='mt-4 flex flex-wrap gap-2'>
|
||||
{source.capabilities?.catalogSupported && <Link href={`/books/catalog?sourceId=${encodeURIComponent(source.id)}`} className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'>浏览目录</Link>}
|
||||
{source.capabilities?.searchSupported && <Link href={`/books/search?sourceId=${encodeURIComponent(source.id)}`} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>搜索书籍</Link>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
'use client';
|
||||
|
||||
import { BookOpen, List, Moon, Settings2, Sun } from 'lucide-react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
buildBookCacheKey,
|
||||
enforceBookCacheLimit,
|
||||
getCachedBookFile,
|
||||
putCachedBookFile,
|
||||
touchCachedBookFile,
|
||||
} from '@/lib/book-cache.client';
|
||||
import { saveBookReadRecord } from '@/lib/book.db.client';
|
||||
import { BookReadManifest } from '@/lib/book.types';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
ePub?: (input: string | ArrayBuffer) => EpubBookInstance;
|
||||
JSZip?: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
interface EpubLocation {
|
||||
start?: { cfi?: string; href?: string; displayed?: { chapter?: string } };
|
||||
end?: { cfi?: string };
|
||||
}
|
||||
|
||||
interface TocItem {
|
||||
id?: string;
|
||||
label: string;
|
||||
href: string;
|
||||
subitems?: TocItem[];
|
||||
}
|
||||
|
||||
interface EpubNavigation {
|
||||
toc?: TocItem[];
|
||||
}
|
||||
|
||||
interface EpubThemes {
|
||||
fontSize?: (value: string) => void;
|
||||
default?: (styles: Record<string, Record<string, string>>) => void;
|
||||
override?: (name: string, value: string) => void;
|
||||
}
|
||||
|
||||
interface EpubBookInstance {
|
||||
renderTo: (element: HTMLElement, options: Record<string, string | boolean>) => EpubRendition;
|
||||
locations?: {
|
||||
percentageFromCfi?: (cfi: string) => number;
|
||||
generate?: (chars?: number) => Promise<void>;
|
||||
};
|
||||
loaded?: {
|
||||
navigation?: Promise<EpubNavigation>;
|
||||
};
|
||||
navigation?: EpubNavigation;
|
||||
ready?: Promise<unknown>;
|
||||
destroy?: () => void;
|
||||
}
|
||||
|
||||
interface EpubRendition {
|
||||
display: (target?: string) => Promise<void>;
|
||||
on: (event: 'relocated', callback: (location: EpubLocation) => void) => void;
|
||||
prev?: () => void;
|
||||
next?: () => void;
|
||||
destroy?: () => void;
|
||||
themes?: EpubThemes;
|
||||
}
|
||||
|
||||
type ReaderTheme = 'light' | 'sepia' | 'dark';
|
||||
type FileLoadState = 'preparing' | 'checking-cache' | 'downloading' | 'opening' | 'ready';
|
||||
|
||||
interface ReaderSettings {
|
||||
fontSize: number;
|
||||
lineHeight: number;
|
||||
theme: ReaderTheme;
|
||||
}
|
||||
|
||||
const SETTINGS_STORAGE_KEY = 'books_epub_reader_settings';
|
||||
const DEFAULT_SETTINGS: ReaderSettings = {
|
||||
fontSize: 100,
|
||||
lineHeight: 1.7,
|
||||
theme: 'light',
|
||||
};
|
||||
|
||||
const THEME_STYLES: Record<ReaderTheme, { bodyBg: string; bodyColor: string; panelBg: string }> = {
|
||||
light: { bodyBg: '#ffffff', bodyColor: '#111827', panelBg: '#ffffff' },
|
||||
sepia: { bodyBg: '#f6efe3', bodyColor: '#5b4636', panelBg: '#f7f1e7' },
|
||||
dark: { bodyBg: '#111827', bodyColor: '#e5e7eb', panelBg: '#030712' },
|
||||
};
|
||||
|
||||
function loadScriptOnce(selector: string, src: string, errorMessage: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const existing = document.querySelector(selector) as HTMLScriptElement | null;
|
||||
if (existing) {
|
||||
if (existing.dataset.loaded === 'true') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
existing.addEventListener('load', () => resolve(), { once: true });
|
||||
existing.addEventListener('error', () => reject(new Error(errorMessage)), { once: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
if (selector.includes('jszip')) script.dataset.jszip = 'true';
|
||||
if (selector.includes('epubjs')) script.dataset.epubjs = 'true';
|
||||
script.onload = () => {
|
||||
script.dataset.loaded = 'true';
|
||||
resolve();
|
||||
};
|
||||
script.onerror = () => reject(new Error(errorMessage));
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadEpubScript() {
|
||||
if (window.ePub && window.JSZip) return;
|
||||
if (!window.JSZip) {
|
||||
await loadScriptOnce('script[data-jszip]', 'https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js', 'JSZip 加载失败');
|
||||
}
|
||||
if (!window.ePub) {
|
||||
await loadScriptOnce('script[data-epubjs]', 'https://cdn.jsdelivr.net/npm/epubjs/dist/epub.min.js', 'epub.js 加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
function loadReaderSettings(): ReaderSettings {
|
||||
if (typeof window === 'undefined') return DEFAULT_SETTINGS;
|
||||
try {
|
||||
const raw = localStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||
if (!raw) return DEFAULT_SETTINGS;
|
||||
return { ...DEFAULT_SETTINGS, ...(JSON.parse(raw) as Partial<ReaderSettings>) };
|
||||
} catch {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
}
|
||||
|
||||
function flattenToc(items: TocItem[]): TocItem[] {
|
||||
return items.flatMap((item) => [item, ...flattenToc(item.subitems || [])]);
|
||||
}
|
||||
|
||||
async function downloadBookWithProgress(
|
||||
url: string,
|
||||
onProgress: (received: number, total: number | null) => void
|
||||
): Promise<Blob> {
|
||||
const response = await fetch(url, { cache: 'force-cache' });
|
||||
if (!response.ok) throw new Error(`下载电子书失败: ${response.status}`);
|
||||
const total = Number(response.headers.get('content-length') || '') || null;
|
||||
if (!response.body) {
|
||||
const blob = await response.blob();
|
||||
onProgress(blob.size, total);
|
||||
return blob;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let received = 0;
|
||||
|
||||
let done = false;
|
||||
while (!done) {
|
||||
const result = await reader.read();
|
||||
done = result.done;
|
||||
const value = result.value;
|
||||
if (done) break;
|
||||
if (value) {
|
||||
chunks.push(value);
|
||||
received += value.length;
|
||||
onProgress(received, total);
|
||||
}
|
||||
}
|
||||
|
||||
return new Blob(chunks, { type: response.headers.get('content-type') || 'application/epub+zip' });
|
||||
}
|
||||
|
||||
function formatBytes(size: number): string {
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export default function BookReadPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const sourceId = searchParams.get('sourceId') || '';
|
||||
const href = searchParams.get('href') || '';
|
||||
const [manifest, setManifest] = useState<BookReadManifest | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [ready, setReady] = useState(false);
|
||||
const [fileLoadState, setFileLoadState] = useState<FileLoadState>('preparing');
|
||||
const [downloadedBytes, setDownloadedBytes] = useState(0);
|
||||
const [totalBytes, setTotalBytes] = useState<number | null>(null);
|
||||
const [cacheHit, setCacheHit] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [tocOpen, setTocOpen] = useState(false);
|
||||
const [settings, setSettings] = useState<ReaderSettings>(DEFAULT_SETTINGS);
|
||||
const [tocItems, setTocItems] = useState<TocItem[]>([]);
|
||||
const [currentHref, setCurrentHref] = useState('');
|
||||
const [currentChapter, setCurrentChapter] = useState('');
|
||||
const [progressPercent, setProgressPercent] = useState(0);
|
||||
const [restoredMessage, setRestoredMessage] = useState('');
|
||||
const [controlsVisible, setControlsVisible] = useState(true);
|
||||
const viewerRef = useRef<HTMLDivElement | null>(null);
|
||||
const bookRef = useRef<EpubBookInstance | null>(null);
|
||||
const renditionRef = useRef<EpubRendition | null>(null);
|
||||
const saveTimerRef = useRef<number | null>(null);
|
||||
const lastLocationRef = useRef<EpubLocation | null>(null);
|
||||
const lastProgressRef = useRef(0);
|
||||
const lastChapterRef = useRef('');
|
||||
const locationsReadyRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSettings(loadReaderSettings());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams({
|
||||
sourceId,
|
||||
href,
|
||||
acquisitionHref: searchParams.get('acquisitionHref') || '',
|
||||
format: searchParams.get('format') || '',
|
||||
bookId: searchParams.get('bookId') || '',
|
||||
title: searchParams.get('title') || '',
|
||||
author: searchParams.get('author') || '',
|
||||
cover: searchParams.get('cover') || '',
|
||||
});
|
||||
fetch(`/api/books/read/manifest?${params.toString()}`)
|
||||
.then(async (res) => {
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || '获取阅读信息失败');
|
||||
setManifest(json);
|
||||
})
|
||||
.catch((err) => setError(err.message || '获取阅读信息失败'));
|
||||
}, [sourceId, href, searchParams]);
|
||||
|
||||
const saveProgress = useMemo(() => {
|
||||
return async (location: EpubLocation, nextProgress = 0, chapterTitle?: string) => {
|
||||
if (!manifest) return;
|
||||
const locatorValue = location?.start?.cfi || location?.end?.cfi || '';
|
||||
if (!locatorValue) return;
|
||||
await saveBookReadRecord(manifest.book.sourceId, manifest.book.id, {
|
||||
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: manifest.format,
|
||||
locator: {
|
||||
type: 'epub-cfi',
|
||||
value: locatorValue,
|
||||
href: location?.start?.href,
|
||||
chapterTitle,
|
||||
},
|
||||
chapterTitle,
|
||||
chapterHref: location?.start?.href,
|
||||
progressPercent: nextProgress,
|
||||
saveTime: Date.now(),
|
||||
});
|
||||
};
|
||||
}, [manifest]);
|
||||
|
||||
const persistCurrentProgress = useCallback(() => {
|
||||
if (lastLocationRef.current) {
|
||||
void saveProgress(lastLocationRef.current, lastProgressRef.current, lastChapterRef.current);
|
||||
}
|
||||
}, [saveProgress]);
|
||||
|
||||
const applyReaderTheme = useCallback((nextSettings: ReaderSettings) => {
|
||||
const rendition = renditionRef.current;
|
||||
if (!rendition?.themes) return;
|
||||
const palette = THEME_STYLES[nextSettings.theme];
|
||||
rendition.themes.default?.({
|
||||
body: {
|
||||
'background-color': palette.bodyBg,
|
||||
color: palette.bodyColor,
|
||||
'font-size': `${nextSettings.fontSize}%`,
|
||||
'line-height': String(nextSettings.lineHeight),
|
||||
'padding-left': '6px',
|
||||
'padding-right': '6px',
|
||||
},
|
||||
p: { color: palette.bodyColor },
|
||||
a: { color: nextSettings.theme === 'dark' ? '#93c5fd' : '#2563eb' },
|
||||
});
|
||||
rendition.themes.fontSize?.(`${nextSettings.fontSize}%`);
|
||||
rendition.themes.override?.('line-height', String(nextSettings.lineHeight));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
applyReaderTheme(settings);
|
||||
}, [settings, applyReaderTheme]);
|
||||
|
||||
const navigateToTarget = useCallback(async (target?: string) => {
|
||||
if (!renditionRef.current) return;
|
||||
await renditionRef.current.display(target);
|
||||
}, []);
|
||||
|
||||
const handleReaderTap = useCallback((zone: 'left' | 'center' | 'right') => {
|
||||
if (!ready) return;
|
||||
if (zone === 'left') {
|
||||
renditionRef.current?.prev?.();
|
||||
return;
|
||||
}
|
||||
if (zone === 'right') {
|
||||
renditionRef.current?.next?.();
|
||||
return;
|
||||
}
|
||||
setControlsVisible((prev) => !prev);
|
||||
setTocOpen(false);
|
||||
setSettingsOpen(false);
|
||||
}, [ready]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!manifest || manifest.format !== 'epub' || !viewerRef.current) return;
|
||||
let destroyed = false;
|
||||
setReady(false);
|
||||
setRestoredMessage('');
|
||||
locationsReadyRef.current = false;
|
||||
setProgressPercent(manifest.lastRecord?.progressPercent || 0);
|
||||
setCurrentChapter(manifest.lastRecord?.chapterTitle || manifest.lastRecord?.locator?.chapterTitle || '');
|
||||
setFileLoadState('checking-cache');
|
||||
setDownloadedBytes(0);
|
||||
setTotalBytes(null);
|
||||
setCacheHit(false);
|
||||
|
||||
loadEpubScript()
|
||||
.then(async () => {
|
||||
if (!window.ePub || destroyed || !viewerRef.current) return;
|
||||
|
||||
const cacheKey = manifest.cacheKey || buildBookCacheKey(
|
||||
manifest.book.sourceId,
|
||||
manifest.book.id,
|
||||
manifest.acquisitionHref || manifest.fileUrl
|
||||
);
|
||||
|
||||
let fileBuffer: ArrayBuffer;
|
||||
const cached = await getCachedBookFile(cacheKey).catch(() => null);
|
||||
if (cached) {
|
||||
setCacheHit(true);
|
||||
setFileLoadState('opening');
|
||||
setDownloadedBytes(cached.size);
|
||||
setTotalBytes(cached.size);
|
||||
await touchCachedBookFile(cacheKey).catch(() => undefined);
|
||||
fileBuffer = await cached.blob.arrayBuffer();
|
||||
} else {
|
||||
setFileLoadState('downloading');
|
||||
const blob = await downloadBookWithProgress(manifest.fileUrl, (received, total) => {
|
||||
if (!destroyed) {
|
||||
setDownloadedBytes(received);
|
||||
setTotalBytes(total);
|
||||
}
|
||||
});
|
||||
fileBuffer = await blob.arrayBuffer();
|
||||
await putCachedBookFile({
|
||||
key: cacheKey,
|
||||
sourceId: manifest.book.sourceId,
|
||||
bookId: manifest.book.id,
|
||||
title: manifest.book.title,
|
||||
format: manifest.format,
|
||||
acquisitionHref: manifest.acquisitionHref || manifest.fileUrl,
|
||||
blob,
|
||||
size: blob.size,
|
||||
mimeType: blob.type || 'application/epub+zip',
|
||||
updatedAt: Date.now(),
|
||||
lastOpenTime: Date.now(),
|
||||
}).catch(() => undefined);
|
||||
await enforceBookCacheLimit().catch(() => undefined);
|
||||
if (destroyed) return;
|
||||
setFileLoadState('opening');
|
||||
}
|
||||
|
||||
if (destroyed) return;
|
||||
const book = window.ePub(fileBuffer);
|
||||
const readyFallbackTimer = window.setTimeout(() => {
|
||||
if (!destroyed) {
|
||||
setReady(true);
|
||||
setFileLoadState('ready');
|
||||
}
|
||||
}, 4000);
|
||||
|
||||
const rendition = book.renderTo(viewerRef.current, {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
spread: 'none',
|
||||
manager: 'default',
|
||||
flow: 'paginated',
|
||||
});
|
||||
bookRef.current = book;
|
||||
renditionRef.current = rendition;
|
||||
applyReaderTheme(settings);
|
||||
|
||||
const restoreTarget = manifest.lastRecord?.locator?.value || undefined;
|
||||
await navigateToTarget(restoreTarget);
|
||||
window.clearTimeout(readyFallbackTimer);
|
||||
if (destroyed) return;
|
||||
setReady(true);
|
||||
setFileLoadState('ready');
|
||||
|
||||
if (restoreTarget) {
|
||||
setRestoredMessage(`已恢复到上次阅读位置(约 ${Math.round(manifest.lastRecord?.progressPercent || 0)}%)`);
|
||||
window.setTimeout(() => setRestoredMessage(''), 3000);
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const navigation = (await book.loaded?.navigation) || book.navigation;
|
||||
if (!destroyed) setTocItems(navigation?.toc || []);
|
||||
} catch {
|
||||
if (!destroyed) setTocItems(book.navigation?.toc || []);
|
||||
}
|
||||
})();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await book.ready;
|
||||
await book.locations?.generate?.(480);
|
||||
locationsReadyRef.current = true;
|
||||
if (lastLocationRef.current?.start?.cfi) {
|
||||
const recomputed = book.locations?.percentageFromCfi?.(lastLocationRef.current.start.cfi) || 0;
|
||||
const nextProgress = Math.max(0, Math.min(100, recomputed * 100));
|
||||
setProgressPercent(nextProgress);
|
||||
lastProgressRef.current = nextProgress;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
|
||||
rendition.on('relocated', (location: EpubLocation) => {
|
||||
lastLocationRef.current = location;
|
||||
const chapterTitle = location?.start?.displayed?.chapter || location?.start?.href || manifest.book.title;
|
||||
const cfi = location?.start?.cfi || '';
|
||||
const computedProgress = locationsReadyRef.current && cfi
|
||||
? Math.max(0, Math.min(100, (book.locations?.percentageFromCfi?.(cfi) || 0) * 100))
|
||||
: null;
|
||||
const normalizedProgress = computedProgress ?? lastProgressRef.current ?? manifest.lastRecord?.progressPercent ?? 0;
|
||||
setProgressPercent(normalizedProgress);
|
||||
setCurrentChapter(chapterTitle);
|
||||
setCurrentHref(location?.start?.href || '');
|
||||
lastProgressRef.current = normalizedProgress;
|
||||
lastChapterRef.current = chapterTitle;
|
||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = window.setTimeout(() => {
|
||||
void saveProgress(location, normalizedProgress, chapterTitle);
|
||||
}, locationsReadyRef.current ? 1500 : 3500);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
setReady(false);
|
||||
setError(err.message || '初始化 EPUB 阅读器失败');
|
||||
});
|
||||
|
||||
return () => {
|
||||
destroyed = true;
|
||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||
persistCurrentProgress();
|
||||
renditionRef.current?.destroy?.();
|
||||
bookRef.current?.destroy?.();
|
||||
};
|
||||
}, [manifest, settings, applyReaderTheme, persistCurrentProgress, saveProgress, navigateToTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState === 'hidden') persistCurrentProgress();
|
||||
};
|
||||
const handleUnload = () => persistCurrentProgress();
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
window.addEventListener('beforeunload', handleUnload);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibility);
|
||||
window.removeEventListener('beforeunload', handleUnload);
|
||||
};
|
||||
}, [persistCurrentProgress]);
|
||||
|
||||
const flatToc = useMemo(() => flattenToc(tocItems), [tocItems]);
|
||||
const activeTocHref = useMemo(
|
||||
() => flatToc.find((item) => currentHref.includes(item.href) || item.href.includes(currentHref))?.href || '',
|
||||
[flatToc, currentHref]
|
||||
);
|
||||
const progressLabel = totalBytes ? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` : formatBytes(downloadedBytes);
|
||||
|
||||
if (error) return <div className='p-4 text-sm text-red-500'>{error}</div>;
|
||||
if (!manifest) return <div className='p-4 text-sm text-gray-500'>准备阅读器中...</div>;
|
||||
|
||||
if (manifest.format === 'pdf') {
|
||||
return <iframe src={manifest.fileUrl} className='h-[calc(100vh-4rem)] w-full bg-white' title={manifest.book.title} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative h-[calc(100vh-4rem)] overflow-hidden bg-white dark:bg-gray-950'>
|
||||
<div className={`flex h-14 items-center justify-between border-b border-gray-200 bg-white px-4 text-sm shadow-sm transition-all dark:border-gray-800 dark:bg-gray-950 ${controlsVisible ? 'translate-y-0 opacity-100' : '-translate-y-full opacity-0 pointer-events-none'}`}>
|
||||
<div className='min-w-0'>
|
||||
<div className='truncate font-medium'>{manifest.book.title}</div>
|
||||
<div className='truncate text-xs text-gray-500 dark:text-gray-400'>
|
||||
{currentChapter || manifest.book.author || 'EPUB 阅读'} · {Math.round(progressPercent)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<button onClick={() => setTocOpen((prev) => !prev)} className='inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 dark:border-gray-700'>
|
||||
<List className='h-4 w-4' />
|
||||
</button>
|
||||
<button onClick={() => setSettingsOpen((prev) => !prev)} className='inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 dark:border-gray-700'>
|
||||
<Settings2 className='h-4 w-4' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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'>
|
||||
{restoredMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!ready ? (
|
||||
<div className='absolute inset-x-0 top-14 z-10 p-4'>
|
||||
<div className='mx-auto max-w-3xl space-y-4'>
|
||||
<div className='space-y-2 rounded-3xl border border-gray-200 bg-white/90 p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950/90'>
|
||||
<div className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
{fileLoadState === 'checking-cache'
|
||||
? '检查本地缓存'
|
||||
: fileLoadState === 'downloading'
|
||||
? '下载电子书'
|
||||
: fileLoadState === 'opening'
|
||||
? '正在打开电子书'
|
||||
: '准备阅读器'}
|
||||
</div>
|
||||
<div className='h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-800'>
|
||||
<div
|
||||
className='h-full rounded-full bg-sky-600 transition-all'
|
||||
style={{ width: totalBytes ? `${Math.min(100, (downloadedBytes / totalBytes) * 100)}%` : fileLoadState === 'opening' ? '92%' : fileLoadState === 'checking-cache' ? '20%' : '45%' }}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center justify-between text-xs text-gray-500 dark:text-gray-400'>
|
||||
<span>{cacheHit ? '已命中本地缓存' : '首次打开将缓存到当前浏览器'}</span>
|
||||
<span>{progressLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-3 rounded-3xl bg-gray-50 p-6 dark:bg-gray-900 animate-pulse'>
|
||||
<div className='h-4 w-full rounded bg-gray-200 dark:bg-gray-800' />
|
||||
<div className='h-4 w-11/12 rounded bg-gray-200 dark:bg-gray-800' />
|
||||
<div className='h-4 w-10/12 rounded bg-gray-200 dark:bg-gray-800' />
|
||||
<div className='h-4 w-full rounded bg-gray-200 dark:bg-gray-800' />
|
||||
<div className='h-4 w-9/12 rounded bg-gray-200 dark:bg-gray-800' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tocOpen && (
|
||||
<div className='fixed inset-0 z-40 bg-black/30' onClick={() => setTocOpen(false)}>
|
||||
<div
|
||||
className='absolute right-0 top-14 h-[calc(100vh-3.5rem)] w-full max-w-sm overflow-y-auto border-l border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-950'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className='p-4'>
|
||||
<div className='mb-3 flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-gray-100'><BookOpen className='h-4 w-4' />目录</div>
|
||||
<button onClick={() => setTocOpen(false)} className='text-xs text-gray-500'>关闭</button>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
{flatToc.length === 0 ? (
|
||||
<div className='p-3 text-sm text-gray-500'>当前 EPUB 未提供目录</div>
|
||||
) : (
|
||||
flatToc.map((item) => {
|
||||
const active = activeTocHref === item.href;
|
||||
return (
|
||||
<button
|
||||
key={`${item.href}-${item.label}`}
|
||||
onClick={() => {
|
||||
void navigateToTarget(item.href);
|
||||
setTocOpen(false);
|
||||
}}
|
||||
className={`block w-full rounded-2xl px-4 py-3 text-left text-sm transition ${active ? 'bg-sky-600 text-white' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900'}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settingsOpen && (
|
||||
<div className='fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4' onClick={() => setSettingsOpen(false)}>
|
||||
<div
|
||||
className='w-full max-w-sm rounded-3xl border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-700 dark:bg-gray-950'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className='mb-4'>
|
||||
<div className='text-base font-semibold text-gray-900 dark:text-gray-100'>阅读设置</div>
|
||||
<div className='mt-1 text-xs text-gray-500'>分页式 EPUB 阅读设置</div>
|
||||
</div>
|
||||
<div className='space-y-6 p-1 text-sm'>
|
||||
<div>
|
||||
<div className='mb-2 font-medium'>主题</div>
|
||||
<div className='grid grid-cols-3 gap-2'>
|
||||
{(['light', 'sepia', 'dark'] as ReaderTheme[]).map((theme) => (
|
||||
<button
|
||||
key={theme}
|
||||
onClick={() => setSettings((prev) => ({ ...prev, theme }))}
|
||||
className={`rounded-2xl border px-3 py-2 ${settings.theme === theme ? 'border-sky-500 bg-sky-50 text-sky-700 dark:bg-sky-950/30 dark:text-sky-300' : 'border-gray-200 dark:border-gray-700'}`}
|
||||
>
|
||||
<div className='mb-1 flex justify-center'>{theme === 'dark' ? <Moon className='h-4 w-4' /> : <Sun className='h-4 w-4' />}</div>
|
||||
{theme === 'light' ? '浅色' : theme === 'sepia' ? '护眼' : '深色'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='mb-2 flex items-center justify-between font-medium'>字号 <span>{settings.fontSize}%</span></div>
|
||||
<input type='range' min='85' max='140' step='5' value={settings.fontSize} onChange={(e) => setSettings((prev) => ({ ...prev, fontSize: Number(e.target.value) }))} className='w-full' />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='mb-2 flex items-center justify-between font-medium'>行距 <span>{settings.lineHeight.toFixed(1)}</span></div>
|
||||
<input type='range' min='1.4' max='2.2' step='0.1' value={settings.lineHeight} onChange={(e) => setSettings((prev) => ({ ...prev, lineHeight: Number(e.target.value) }))} className='w-full' />
|
||||
</div>
|
||||
|
||||
<div className='rounded-2xl bg-gray-50 p-4 text-xs text-gray-500 dark:bg-gray-900 dark:text-gray-400'>
|
||||
首次会缓存到当前浏览器,之后再次打开同一本书通常不需要重新整包下载。
|
||||
当前缓存状态:{cacheHit ? '已命中本地缓存' : '本次为网络加载'}。
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end'>
|
||||
<button
|
||||
type='button'
|
||||
className='rounded-2xl bg-sky-600 px-4 py-2 text-sm font-medium text-white'
|
||||
onClick={() => setSettingsOpen(false)}
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ready && !tocOpen && !settingsOpen ? (
|
||||
<div className='absolute inset-x-0 top-14 bottom-0 z-10 grid grid-cols-3'>
|
||||
<button aria-label='上一页' className='h-full w-full cursor-pointer bg-transparent' onClick={() => handleReaderTap('left')} />
|
||||
<button aria-label='切换工具栏' className='h-full w-full cursor-pointer bg-transparent' onClick={() => handleReaderTap('center')} />
|
||||
<button aria-label='下一页' className='h-full w-full cursor-pointer bg-transparent' onClick={() => handleReaderTap('right')} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div ref={viewerRef} className='h-[calc(100%-3.5rem)] w-full' style={{ backgroundColor: THEME_STYLES[settings.theme].panelBg }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import BookCard from '@/components/books/BookCard';
|
||||
import { BookListItem, BookSearchResult, BookSource } from '@/lib/book.types';
|
||||
|
||||
function detailHref(item: BookListItem) {
|
||||
const params = new URLSearchParams({
|
||||
sourceId: item.sourceId,
|
||||
href: item.detailHref || '',
|
||||
bookId: item.id,
|
||||
title: item.title,
|
||||
author: item.author || '',
|
||||
cover: item.cover || '',
|
||||
summary: item.summary || '',
|
||||
acquisitionLinks: JSON.stringify(item.acquisitionLinks || []),
|
||||
});
|
||||
return `/books/detail?${params.toString()}`;
|
||||
}
|
||||
|
||||
function SearchSkeleton() {
|
||||
return (
|
||||
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6 animate-pulse'>
|
||||
{Array.from({ length: 12 }).map((_, index) => (
|
||||
<div key={index} className='space-y-3'>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BooksSearchPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [q, setQ] = useState(searchParams.get('q') || '');
|
||||
const [sourceId, setSourceId] = useState(searchParams.get('sourceId') || '');
|
||||
const [sources, setSources] = useState<BookSource[]>([]);
|
||||
const [result, setResult] = useState<BookSearchResult>({ results: [], failedSources: [] });
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/books/sources').then((res) => res.json()).then((json) => setSources(json.sources || []));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const keyword = searchParams.get('q') || '';
|
||||
const source = searchParams.get('sourceId') || '';
|
||||
setQ(keyword);
|
||||
setSourceId(source);
|
||||
if (!keyword) return;
|
||||
setLoading(true);
|
||||
fetch(`/api/books/search?${new URLSearchParams({ q: keyword, ...(source ? { sourceId: source } : {}) }).toString()}`)
|
||||
.then((res) => res.json())
|
||||
.then((json) => setResult(json))
|
||||
.finally(() => setLoading(false));
|
||||
}, [searchParams]);
|
||||
|
||||
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'>
|
||||
<form onSubmit={(e) => { e.preventDefault(); const params = new URLSearchParams(); if (q.trim()) params.set('q', q.trim()); if (sourceId) params.set('sourceId', sourceId); router.push(`/books/search?${params.toString()}`); }} className='space-y-3'>
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder='搜索书名 / 作者' className='w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none dark:border-gray-700 dark:bg-gray-900' />
|
||||
<select value={sourceId} onChange={(e) => setSourceId(e.target.value)} className='w-full rounded-2xl border border-gray-200 px-4 py-3 dark:border-gray-700 dark:bg-gray-900'>
|
||||
<option value=''>全部书源</option>
|
||||
{sources.map((source) => <option key={source.id} value={source.id}>{source.name}</option>)}
|
||||
</select>
|
||||
<button className='rounded-2xl bg-sky-600 px-4 py-2 text-sm text-white'>搜索</button>
|
||||
</form>
|
||||
</section>
|
||||
{loading ? <SearchSkeleton /> : null}
|
||||
{result.failedSources.length > 0 ? <div className='rounded-2xl bg-amber-50 p-4 text-sm text-amber-700 dark:bg-amber-950/20 dark:text-amber-300'>{result.failedSources.map((item) => `${item.sourceName}: ${item.error}`).join(';')}</div> : null}
|
||||
<section className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
||||
{result.results.map((item) => <BookCard key={`${item.sourceId}-${item.id}`} item={item} href={detailHref(item)} />)}
|
||||
</section>
|
||||
{!loading && result.results.length === 0 ? <div className='text-sm text-gray-500'>暂无结果</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { deleteBookShelf, getAllBookShelf } from '@/lib/book.db.client';
|
||||
import { BookShelfItem } from '@/lib/book.types';
|
||||
|
||||
export default function BookShelfPage() {
|
||||
const [shelf, setShelf] = useState<Record<string, BookShelfItem>>({});
|
||||
|
||||
useEffect(() => {
|
||||
getAllBookShelf().then(setShelf).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const items = useMemo(() => Object.values(shelf).sort((a, b) => (b.lastReadTime || b.saveTime) - (a.lastReadTime || a.saveTime)), [shelf]);
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='text-sm text-gray-500'>共 {items.length} 本电子书</div>
|
||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
||||
{items.map((item) => (
|
||||
<div key={`${item.sourceId}-${item.bookId}`} className='rounded-3xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
||||
<div className='flex gap-4'>
|
||||
<div className='h-28 w-20 overflow-hidden rounded-2xl bg-gray-100 dark:bg-gray-900'>{item.cover ? <img src={item.cover} alt={item.title} className='h-full w-full object-cover' /> : null}</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='truncate font-medium'>{item.title}</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-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>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
{items.length === 0 ? <div className='text-sm text-gray-500'>书架还是空的</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user