新增电子书架

This commit is contained in:
mtvpls
2026-04-29 02:24:29 +08:00
parent 6d9afd8072
commit 0d99953e99
38 changed files with 3826 additions and 6 deletions
+307
View File
@@ -24,6 +24,7 @@ import { CSS } from '@dnd-kit/utilities';
import {
AlertCircle,
AlertTriangle,
BookMarked,
BookOpen,
Bot,
Cat,
@@ -44,12 +45,15 @@ import {
UserPlus,
Users,
Video,
Plus,
Trash2,
} from 'lucide-react';
import { GripVertical } from 'lucide-react';
import { memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { AdminConfig, AdminConfigResult } from '@/lib/admin.types';
import { BookSource } from '@/lib/book.types';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import {
ALL_FEATURE_PERMISSION_KEYS,
@@ -11231,6 +11235,297 @@ const SuwayomiConfigComponent = ({
);
};
const OPDSConfigComponent = ({
config,
refreshConfig,
}: {
config: AdminConfig | null;
refreshConfig: () => Promise<void>;
}) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [enabled, setEnabled] = useState(false);
const [cacheTTL, setCacheTTL] = useState(10 * 60 * 1000);
const [sources, setSources] = useState<BookSource[]>([]);
useEffect(() => {
if (config?.OPDSConfig) {
setEnabled(config.OPDSConfig.Enabled || false);
setCacheTTL(config.OPDSConfig.CacheTTL || 10 * 60 * 1000);
setSources(
(config.OPDSConfig.Sources || []).map((item, index) => ({
id: item.id || `source_${index + 1}`,
name: item.name || `书源 ${index + 1}`,
url: item.url || '',
enabled: item.enabled !== false,
authMode: item.authMode || 'none',
username: item.username || '',
password: item.password || '',
headerName: item.headerName || '',
headerValue: item.headerValue || '',
searchTemplate: item.searchTemplate || '',
preferFormat: item.preferFormat || ['epub', 'pdf'],
language: item.language || '',
}))
);
}
}, [config]);
const updateSource = (index: number, patch: Partial<BookSource>) => {
setSources((prev) => prev.map((item, idx) => (idx === index ? { ...item, ...patch } : item)));
};
const addSource = () => {
setSources((prev) => [
...prev,
{
id: `source_${prev.length + 1}`,
name: `书源 ${prev.length + 1}`,
url: '',
enabled: true,
authMode: 'none',
username: '',
password: '',
headerName: '',
headerValue: '',
searchTemplate: '',
preferFormat: ['epub', 'pdf'],
language: '',
},
]);
};
const removeSource = (index: number) => {
setSources((prev) => prev.filter((_, idx) => idx !== index));
};
const buildConfig = () => ({
Enabled: enabled,
CacheTTL: Math.max(60_000, cacheTTL || 10 * 60 * 1000),
Sources: sources
.map((source, index) => ({
id: source.id?.trim() || `source_${index + 1}`,
name: source.name?.trim() || `书源 ${index + 1}`,
url: source.url?.trim() || '',
enabled: source.enabled !== false,
authMode: source.authMode || 'none',
username: source.authMode === 'none' ? '' : source.username?.trim() || '',
password: source.authMode === 'none' ? '' : source.password || '',
headerName: source.authMode === 'header' ? source.headerName?.trim() || '' : '',
headerValue: source.authMode === 'header' ? source.headerValue || '' : '',
searchTemplate: source.searchTemplate?.trim() || '',
preferFormat: source.preferFormat?.length ? source.preferFormat : ['epub', 'pdf'],
language: source.language?.trim() || '',
}))
.filter((source) => !!source.url),
});
const handleSave = async () => {
await withLoading('saveOPDSConfig', async () => {
try {
if (!config) throw new Error('配置未加载');
const response = await fetch('/api/admin/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...config,
OPDSConfig: buildConfig(),
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || '保存失败');
}
showSuccess('电子书 OPDS 配置已保存', showAlert);
await refreshConfig();
} catch (error) {
showError(error instanceof Error ? error.message : '保存失败', showAlert);
throw error;
}
});
};
const handleTest = async () => {
await withLoading('testOPDSConfig', async () => {
try {
const response = await fetch('/api/admin/opds', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildConfig()),
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.message || data.error || '测试连接失败');
}
const summary = Array.isArray(data.results)
? data.results
.map((item: { name: string; capability: { catalogSupported: boolean; searchSupported: boolean; lastError?: string } }) =>
`${item.name}: 分类${item.capability.catalogSupported ? '√' : '×'} / 搜索${item.capability.searchSupported ? '√' : '×'}${item.capability.lastError ? ` (${item.capability.lastError})` : ''}`
)
.join('\n')
: '';
showSuccess(summary || data.message || '测试成功', showAlert);
} catch (error) {
showError(error instanceof Error ? error.message : '测试连接失败', showAlert);
throw error;
}
});
};
return (
<div className='space-y-6'>
<div className='bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4'>
<h3 className='text-sm font-medium text-amber-900 dark:text-amber-100 mb-2'> / OPDS</h3>
<div className='text-sm text-amber-800 dark:text-amber-200 space-y-1'>
<p> </p>
<p> </p>
<p> EPUB 线PDF </p>
</div>
</div>
<div className='flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-700'>
<div>
<h3 className='text-sm font-medium text-gray-900 dark:text-white'></h3>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'> OPDS </p>
</div>
<button
onClick={() => setEnabled(!enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled ? 'bg-amber-600' : 'bg-gray-200 dark:bg-gray-700'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>Feed </label>
<input
type='number'
min='60000'
value={cacheTTL}
onChange={(e) => setCacheTTL(parseInt(e.target.value) || 10 * 60 * 1000)}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
/>
</div>
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<h3 className='text-sm font-medium text-gray-900 dark:text-white'></h3>
<button type='button' onClick={addSource} className={buttonStyles.primary}>
<Plus size={16} className='inline mr-1' />
</button>
</div>
{sources.length === 0 && (
<div className='rounded-lg border border-dashed border-gray-300 dark:border-gray-600 p-4 text-sm text-gray-500 dark:text-gray-400'>
OPDS
</div>
)}
{sources.map((source, index) => (
<div key={`${source.id}-${index}`} className='rounded-xl border border-gray-200 dark:border-gray-700 p-4 space-y-4'>
<div className='flex items-center justify-between gap-3'>
<div className='text-sm font-medium text-gray-900 dark:text-white'> #{index + 1}</div>
<div className='flex items-center gap-2'>
<button
type='button'
onClick={() => updateSource(index, { enabled: source.enabled === false })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${source.enabled !== false ? 'bg-green-600' : 'bg-gray-200 dark:bg-gray-700'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${source.enabled !== false ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
<button type='button' onClick={() => removeSource(index)} className={buttonStyles.danger}>
<Trash2 size={16} />
</button>
</div>
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'> ID</label>
<input type='text' value={source.id} onChange={(e) => updateSource(index, { id: e.target.value })} className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' />
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'></label>
<input type='text' value={source.name} onChange={(e) => updateSource(index, { name: e.target.value })} className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' />
</div>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'></label>
<input type='text' value={source.url} onChange={(e) => updateSource(index, { url: e.target.value })} placeholder='https://example.com/opds' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' />
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'></label>
<select value={source.authMode || 'none'} onChange={(e) => updateSource(index, { authMode: e.target.value as BookSource['authMode'] })} className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'>
<option value='none'></option>
<option value='basic'>Basic Auth</option>
<option value='header'> Header</option>
</select>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'></label>
<input type='text' value={source.language || ''} onChange={(e) => updateSource(index, { language: e.target.value })} placeholder='zh / en' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' />
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'></label>
<input type='text' value={source.searchTemplate || ''} onChange={(e) => updateSource(index, { searchTemplate: e.target.value })} placeholder='https://...{searchTerms}' className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' />
</div>
</div>
{source.authMode === 'basic' && (
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'></label>
<input type='text' value={source.username || ''} onChange={(e) => updateSource(index, { username: e.target.value })} className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' />
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'></label>
<input type='password' value={source.password || ''} onChange={(e) => updateSource(index, { password: e.target.value })} className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' />
</div>
</div>
)}
{source.authMode === 'header' && (
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>Header </label>
<input type='text' value={source.headerName || ''} onChange={(e) => updateSource(index, { headerName: e.target.value })} className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' />
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>Header </label>
<input type='password' value={source.headerValue || ''} onChange={(e) => updateSource(index, { headerValue: e.target.value })} className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' />
</div>
</div>
)}
</div>
))}
</div>
<div className='flex gap-3'>
<button onClick={handleTest} disabled={isLoading('testOPDSConfig')} className={buttonStyles.primary}>
{isLoading('testOPDSConfig') ? '测试中...' : '测试书源'}
</button>
<button onClick={handleSave} disabled={isLoading('saveOPDSConfig')} className={buttonStyles.success}>
{isLoading('saveOPDSConfig') ? '保存中...' : '保存 OPDS 配置'}
</button>
</div>
<AlertModal
isOpen={alertModal.isOpen}
onClose={hideAlert}
type={alertModal.type}
title={alertModal.title}
message={alertModal.message}
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
/>
</div>
);
};
const XiaoyaConfigComponent = ({
config,
refreshConfig,
@@ -13908,6 +14203,7 @@ function AdminPageClient() {
embyConfig: false,
xiaoyaConfig: false,
suwayomiConfig: false,
opdsConfig: false,
animeSubscription: false,
aiConfig: false,
liveSource: false,
@@ -14311,6 +14607,17 @@ function AdminPageClient() {
<SuwayomiConfigComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
<CollapsibleTab
title='电子书配置'
icon={
<BookMarked size={20} className='text-gray-600 dark:text-gray-400' />
}
isExpanded={expandedTabs.opdsConfig}
onToggle={() => toggleTab('opdsConfig')}
>
<OPDSConfigComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* 电视直播源配置标签 */}
<CollapsibleTab
title='电视直播源配置'
+121
View File
@@ -0,0 +1,121 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { BookSource, BookSourceCapabilities } from '@/lib/book.types';
import { db } from '@/lib/db';
import { opdsClient } from '@/lib/opds.client';
export const runtime = 'nodejs';
interface TestSourceInput {
id?: string;
name?: string;
url?: string;
enabled?: boolean;
authMode?: 'none' | 'basic' | 'header';
username?: string;
password?: string;
headerName?: string;
headerValue?: string;
searchTemplate?: string;
preferFormat?: Array<'epub' | 'pdf'>;
language?: string;
}
async function ensureAdmin(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo?.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (authInfo.username !== process.env.USERNAME) {
const userInfo = await db.getUserInfoV2(authInfo.username);
if (!userInfo || (userInfo.role !== 'admin' && userInfo.role !== 'owner') || userInfo.banned) {
return NextResponse.json({ error: '权限不足' }, { status: 401 });
}
}
return authInfo.username;
}
async function detectCapabilitiesFromSource(source: BookSource): Promise<BookSourceCapabilities> {
try {
const result = await opdsClient.getCatalogFromSource(source);
return {
searchSupported: !!source.searchTemplate || result.searchHref !== undefined,
catalogSupported: result.navigation.length > 0 || result.entries.length > 0,
searchMode: result.searchHref ? 'opds' : source.searchTemplate ? 'template' : 'disabled',
catalogMode: result.navigation.length > 0 ? 'navigation' : result.entries.length > 0 ? 'flat' : 'disabled',
acquisitionTypes: Array.from(new Set(result.entries.flatMap((item) => item.acquisitionLinks.map((link) => link.type)))),
lastCheckedAt: Date.now(),
};
} catch (error) {
return {
searchSupported: !!source.searchTemplate,
catalogSupported: false,
searchMode: source.searchTemplate ? 'template' : 'disabled',
catalogMode: 'disabled',
acquisitionTypes: [],
lastCheckedAt: Date.now(),
lastError: error instanceof Error ? error.message : '测试失败',
};
}
}
export async function POST(request: NextRequest) {
const ensured = await ensureAdmin(request);
if (ensured instanceof NextResponse) return ensured;
try {
const body = await request.json();
const inputSources = (body?.Sources || []) as TestSourceInput[];
if (!Array.isArray(inputSources) || inputSources.length === 0) {
return NextResponse.json({ success: false, message: '请至少填写一个 OPDS 书源' }, { status: 400 });
}
const sources: BookSource[] = inputSources
.filter((item) => item?.url?.trim())
.map((item, index) => ({
id: item.id?.trim() || `source_${index + 1}`,
name: item.name?.trim() || `书源 ${index + 1}`,
url: (item.url || '').trim(),
enabled: item.enabled !== false,
authMode: item.authMode || 'none',
username: item.username?.trim() || '',
password: item.password || '',
headerName: item.headerName?.trim() || '',
headerValue: item.headerValue || '',
searchTemplate: item.searchTemplate?.trim() || '',
preferFormat: item.preferFormat || ['epub', 'pdf'],
language: item.language?.trim() || '',
}));
if (sources.length === 0) {
return NextResponse.json({ success: false, message: '没有可测试的有效书源地址' }, { status: 400 });
}
const results = await Promise.all(
sources.map(async (source) => ({
id: source.id,
name: source.name,
url: source.url,
capability: await detectCapabilitiesFromSource(source),
}))
);
const successCount = results.filter((item) => item.capability.catalogSupported || item.capability.searchSupported).length;
return NextResponse.json({
success: successCount > 0,
message: `测试完成,${successCount}/${results.length} 个书源可用`,
results,
});
} catch (error) {
return NextResponse.json(
{
success: false,
message: error instanceof Error ? error.message : '测试连接失败',
},
{ status: 400 }
);
}
}
+26
View File
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { db } from '@/lib/db';
import { hasFeaturePermission } from '@/lib/permissions';
export async function getAuthorizedBooksUsername(request: NextRequest): Promise<string | NextResponse> {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo?.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (authInfo.username !== process.env.USERNAME) {
const user = await db.getUserInfoV2(authInfo.username);
if (!user || user.banned) {
return NextResponse.json({ error: '用户不存在或已被封禁' }, { status: 401 });
}
}
const allowed = await hasFeaturePermission(authInfo.username, 'books');
if (!allowed) {
return NextResponse.json({ error: '无权限访问电子书功能' }, { status: 403 });
}
return authInfo.username;
}
+25
View File
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { opdsClient } from '@/lib/opds.client';
import { getAuthorizedBooksUsername } from '../_utils';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const { searchParams } = new URL(request.url);
const sourceId = searchParams.get('sourceId')?.trim();
const href = searchParams.get('href')?.trim() || undefined;
if (!sourceId) {
return NextResponse.json({ error: '缺少 sourceId' }, { status: 400 });
}
const result = await opdsClient.getCatalog(sourceId, href);
return NextResponse.json(result);
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { BookAcquisitionLink } from '@/lib/book.types';
import { opdsClient } from '@/lib/opds.client';
import { getAuthorizedBooksUsername } from '../_utils';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const { searchParams } = new URL(request.url);
const sourceId = searchParams.get('sourceId')?.trim();
const href = searchParams.get('href')?.trim() || '';
if (!sourceId) {
return NextResponse.json({ error: '缺少 sourceId' }, { status: 400 });
}
const acquisitionLinksRaw = searchParams.get('acquisitionLinks');
let acquisitionLinks: BookAcquisitionLink[] | undefined;
if (acquisitionLinksRaw) {
try {
acquisitionLinks = JSON.parse(acquisitionLinksRaw) as BookAcquisitionLink[];
} catch {
acquisitionLinks = undefined;
}
}
const detail = await opdsClient.getBookDetail(sourceId, href, {
id: searchParams.get('bookId') || undefined,
title: searchParams.get('title') || undefined,
author: searchParams.get('author') || undefined,
cover: searchParams.get('cover') || undefined,
summary: searchParams.get('summary') || undefined,
detailHref: href || undefined,
acquisitionLinks,
});
return NextResponse.json(detail);
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
+61
View File
@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server';
import { opdsClient } from '@/lib/opds.client';
import { getAuthorizedBooksUsername } from '../_utils';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const { searchParams } = new URL(request.url);
const sourceId = searchParams.get('sourceId')?.trim();
const href = searchParams.get('href')?.trim();
if (!sourceId || !href) {
return NextResponse.json({ error: '缺少 sourceId 或 href' }, { status: 400 });
}
const source = await opdsClient.getSourceById(sourceId);
const headers = new Headers();
if (source.authMode === 'basic' && source.username) {
headers.set('Authorization', `Basic ${Buffer.from(`${source.username}:${source.password || ''}`).toString('base64')}`);
} else if (source.authMode === 'header' && source.headerName && source.headerValue) {
headers.set(source.headerName, source.headerValue);
}
const range = request.headers.get('range');
if (range) headers.set('Range', range);
const response = await fetch(href, {
headers,
redirect: 'follow',
cache: 'no-store',
});
if (!response.ok) {
return NextResponse.json({ error: `文件代理失败: ${response.status}` }, { status: response.status });
}
const outHeaders = new Headers();
const contentType = response.headers.get('content-type');
const contentLength = response.headers.get('content-length');
const acceptRanges = response.headers.get('accept-ranges');
const contentRange = response.headers.get('content-range');
const disposition = response.headers.get('content-disposition');
if (contentType) outHeaders.set('Content-Type', contentType);
if (contentLength) outHeaders.set('Content-Length', contentLength);
if (acceptRanges) outHeaders.set('Accept-Ranges', acceptRanges);
if (contentRange) outHeaders.set('Content-Range', contentRange);
if (disposition) outHeaders.set('Content-Disposition', disposition);
outHeaders.set('Cache-Control', 'private, max-age=300');
return new NextResponse(response.body, {
status: response.status,
headers: outHeaders,
});
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
+102
View File
@@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from 'next/server';
import { BookReadRecord } from '@/lib/book.types';
import { db } from '@/lib/db';
import { getAuthorizedBooksUsername } from '../_utils';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const key = new URL(request.url).searchParams.get('key');
if (key) {
const [sourceId, bookId] = key.split('+');
if (!sourceId || !bookId) return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
const record = await db.getBookReadRecord(username, sourceId, bookId);
return NextResponse.json(record, { status: 200 });
}
const records = await db.getAllBookReadRecords(username);
return NextResponse.json(records, { status: 200 });
} catch {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const { key, record }: { key: string; record: BookReadRecord } = await request.json();
if (!key || !record?.locator?.value) {
return NextResponse.json({ error: 'Missing key or record' }, { status: 400 });
}
const [sourceId, bookId] = key.split('+');
if (!sourceId || !bookId) return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
const shelfItem = await db.getBookShelf(username, sourceId, bookId);
const existingRecord = await db.getBookReadRecord(username, sourceId, bookId);
const normalizedRecord: BookReadRecord = {
...record,
sourceId: record.sourceId || sourceId,
bookId: record.bookId || bookId,
sourceName: record.sourceName || shelfItem?.sourceName || existingRecord?.sourceName || '',
detailHref: record.detailHref || shelfItem?.detailHref || existingRecord?.detailHref,
acquisitionHref: record.acquisitionHref || shelfItem?.acquisitionHref || existingRecord?.acquisitionHref,
author: record.author || shelfItem?.author || existingRecord?.author,
cover: record.cover || shelfItem?.cover || existingRecord?.cover,
saveTime: record.saveTime ?? Date.now(),
};
await db.saveBookReadRecord(username, sourceId, bookId, normalizedRecord);
if (shelfItem) {
await db.saveBookShelf(username, sourceId, bookId, {
...shelfItem,
format: normalizedRecord.format,
progressPercent: normalizedRecord.progressPercent,
lastReadTime: normalizedRecord.saveTime,
lastLocatorType: normalizedRecord.locator.type,
lastLocatorValue: normalizedRecord.locator.value,
lastChapterTitle: normalizedRecord.chapterTitle || normalizedRecord.locator.chapterTitle,
});
}
if ((db as any).storage.cleanupOldBookReadRecords) {
(db as any).storage.cleanupOldBookReadRecords(username).catch((err: Error) => {
console.error('异步清理电子书阅读历史失败:', err);
});
}
return NextResponse.json({ success: true }, { status: 200 });
} catch {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const key = new URL(request.url).searchParams.get('key');
if (key) {
const [sourceId, bookId] = key.split('+');
if (!sourceId || !bookId) return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
await db.deleteBookReadRecord(username, sourceId, bookId);
} else {
const all = await db.getAllBookReadRecords(username);
await Promise.all(Object.keys(all).map(async (itemKey) => {
const [sourceId, bookId] = itemKey.split('+');
if (sourceId && bookId) await db.deleteBookReadRecord(username, sourceId, bookId);
}));
}
return NextResponse.json({ success: true }, { status: 200 });
} catch {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
+74
View File
@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server';
import { BookAcquisitionLink } from '@/lib/book.types';
import { opdsClient } from '@/lib/opds.client';
import { db } from '@/lib/db';
import { getAuthorizedBooksUsername } from '../../_utils';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const { searchParams } = new URL(request.url);
const sourceId = searchParams.get('sourceId')?.trim();
const href = searchParams.get('href')?.trim();
const acquisitionHref = searchParams.get('acquisitionHref')?.trim();
const format = searchParams.get('format')?.trim() as 'epub' | 'pdf' | null;
const bookId = searchParams.get('bookId')?.trim();
if (!sourceId) {
return NextResponse.json({ error: '缺少 sourceId' }, { status: 400 });
}
const existingRecord = bookId ? await db.getBookReadRecord(username, sourceId, bookId) : null;
const shelfItem = bookId ? await db.getBookShelf(username, sourceId, bookId) : null;
const resolvedHref = href || existingRecord?.detailHref || shelfItem?.detailHref || '';
const resolvedAcquisitionHref = acquisitionHref || existingRecord?.acquisitionHref || shelfItem?.acquisitionHref || '';
const resolvedFormat = format || existingRecord?.format || shelfItem?.format || 'epub';
if (!resolvedHref && !resolvedAcquisitionHref) {
return NextResponse.json({ error: '缺少 href / acquisitionHref,且历史记录中也没有可恢复的下载链接' }, { status: 400 });
}
const fallbackAcquisitionLinks: BookAcquisitionLink[] = resolvedAcquisitionHref
? [{
rel: 'http://opds-spec.org/acquisition',
type: resolvedFormat === 'pdf' ? 'application/pdf' : 'application/epub+zip',
href: resolvedAcquisitionHref,
}]
: [];
const detail = await opdsClient.getBookDetail(sourceId, resolvedHref || '', {
id: bookId || resolvedAcquisitionHref || undefined,
title: searchParams.get('title') || existingRecord?.title || shelfItem?.title || undefined,
author: searchParams.get('author') || existingRecord?.author || shelfItem?.author || undefined,
cover: searchParams.get('cover') || existingRecord?.cover || shelfItem?.cover || undefined,
summary: searchParams.get('summary') || undefined,
detailHref: resolvedHref || undefined,
acquisitionLinks: fallbackAcquisitionLinks,
});
const preferred = resolvedHref
? await opdsClient.getPreferredAcquisition(sourceId, resolvedHref)
: {
format: resolvedFormat === 'pdf' ? 'pdf' : 'epub',
href: resolvedAcquisitionHref || '',
};
const lastRecord = await db.getBookReadRecord(username, sourceId, detail.id);
return NextResponse.json({
book: detail,
format: preferred.format,
fileUrl: `/api/books/file?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(preferred.href)}`,
acquisitionHref: preferred.href,
cacheKey: `${sourceId}::${detail.id}::${preferred.href}`,
coverUrl: detail.cover,
lastRecord,
});
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
+25
View File
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { opdsClient } from '@/lib/opds.client';
import { getAuthorizedBooksUsername } from '../_utils';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const { searchParams } = new URL(request.url);
const q = searchParams.get('q')?.trim();
const sourceId = searchParams.get('sourceId')?.trim() || undefined;
if (!q) {
return NextResponse.json({ results: [], failedSources: [] });
}
const result = await opdsClient.searchBooks(q, sourceId);
return NextResponse.json(result);
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
+73
View File
@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server';
import { BookShelfItem } from '@/lib/book.types';
import { db } from '@/lib/db';
import { getAuthorizedBooksUsername } from '../_utils';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const key = new URL(request.url).searchParams.get('key');
if (key) {
const [sourceId, bookId] = key.split('+');
if (!sourceId || !bookId) return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
const item = await db.getBookShelf(username, sourceId, bookId);
return NextResponse.json(item, { status: 200 });
}
const records = await db.getAllBookShelf(username);
return NextResponse.json(records, { status: 200 });
} catch {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const { key, item }: { key: string; item: BookShelfItem } = await request.json();
if (!key || !item?.title) return NextResponse.json({ error: 'Missing key or item' }, { status: 400 });
const [sourceId, bookId] = key.split('+');
if (!sourceId || !bookId) return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
await db.saveBookShelf(username, sourceId, bookId, {
...item,
sourceId: item.sourceId || sourceId,
bookId: item.bookId || bookId,
saveTime: item.saveTime ?? Date.now(),
});
return NextResponse.json({ success: true }, { status: 200 });
} catch {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const key = new URL(request.url).searchParams.get('key');
if (key) {
const [sourceId, bookId] = key.split('+');
if (!sourceId || !bookId) return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
await db.deleteBookShelf(username, sourceId, bookId);
} else {
const all = await db.getAllBookShelf(username);
await Promise.all(Object.keys(all).map(async (itemKey) => {
const [sourceId, bookId] = itemKey.split('+');
if (sourceId && bookId) await db.deleteBookShelf(username, sourceId, bookId);
}));
}
return NextResponse.json({ success: true }, { status: 200 });
} catch {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
+19
View File
@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
import { opdsClient } from '@/lib/opds.client';
import { getAuthorizedBooksUsername } from '../_utils';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const username = await getAuthorizedBooksUsername(request);
if (username instanceof NextResponse) return username;
try {
const sources = await opdsClient.getSources();
return NextResponse.json({ sources });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
+123
View File
@@ -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>
);
}
+128
View File
@@ -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>
);
}
+79
View File
@@ -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>
);
}
+5
View File
@@ -0,0 +1,5 @@
import BooksLayout from '@/components/books/BooksLayout';
export default function Layout({ children }: { children: React.ReactNode }) {
return <BooksLayout>{children}</BooksLayout>;
}
+72
View File
@@ -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>
);
}
+660
View File
@@ -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>
);
}
+83
View File
@@ -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>
);
}
+42
View File
@@ -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>
);
}
+10
View File
@@ -101,6 +101,7 @@ export default async function RootLayout({
let customAdFilterVersion = 0;
let musicFeatureEnabled = false;
let suwayomiEnabled = false;
let booksEnabled = process.env.OPDS_ENABLED === 'true' && !!(process.env.OPDS_URL || process.env.NEXT_PUBLIC_OPDS_URL || process.env.OPDS_SOURCES_JSON);
let musicProxyEnabled = true;
let advancedRecommendationEnabled = false;
let userFeatureAccess =
@@ -175,6 +176,14 @@ export default async function RootLayout({
config.SuwayomiConfig?.Enabled &&
config.SuwayomiConfig?.ServerURL
);
// 电子书功能配置
const opdsConfig = config.OPDSConfig;
const rawOpdsSources = opdsConfig?.Sources;
const opdsSources = Array.isArray(rawOpdsSources) ? rawOpdsSources : [];
booksEnabled = !!(
opdsConfig?.Enabled &&
opdsSources.some((source) => source?.enabled !== false && !!source?.url)
);
// 高级推荐功能配置:存在已启用视频源脚本时显示
advancedRecommendationEnabled =
(await listEnabledSourceScripts()).length > 0;
@@ -257,6 +266,7 @@ export default async function RootLayout({
MUSIC_ENABLED: musicFeatureEnabled && userFeatureAccess.music,
MUSIC_PROXY_ENABLED: musicProxyEnabled,
SUWAYOMI_ENABLED: suwayomiEnabled && userFeatureAccess.manga,
BOOKS_ENABLED: booksEnabled && userFeatureAccess.books,
NETDISK_SEARCH_ENABLED: userFeatureAccess.netdisk_search,
MAGNET_SEARCH_ENABLED: userFeatureAccess.magnet_search,
MAGNET_SAVE_PRIVATE_LIBRARY_ENABLED:
+21 -1
View File
@@ -2,7 +2,7 @@
'use client';
import { BookOpen, Bot, ChevronRight, Link as LinkIcon, ListVideo, Music } from 'lucide-react';
import { BookMarked, BookOpen, Bot, ChevronRight, Link as LinkIcon, ListVideo, Music } from 'lucide-react';
import Link from 'next/link';
import { Suspense, useEffect, useState } from 'react';
@@ -66,6 +66,7 @@ function HomeClient() {
const [sourceSearchEnabled, setSourceSearchEnabled] = useState(true);
const [musicEnabled, setMusicEnabled] = useState(false);
const [mangaEnabled, setMangaEnabled] = useState(false);
const [booksEnabled, setBooksEnabled] = useState(false);
const [showDirectPlayDialog, setShowDirectPlayDialog] = useState(false);
const [directPlayUrl, setDirectPlayUrl] = useState('');
const [directPlaySubmitting, setDirectPlaySubmitting] = useState(false);
@@ -299,6 +300,14 @@ function HomeClient() {
}
}, []);
// 检查电子书功能是否启用
useEffect(() => {
if (typeof window !== 'undefined') {
const enabled = !!(window as any).RUNTIME_CONFIG?.BOOKS_ENABLED;
setBooksEnabled(enabled);
}
}, []);
// 检查公告弹窗状态
useEffect(() => {
if (typeof window !== 'undefined' && announcement) {
@@ -777,6 +786,17 @@ function HomeClient() {
</Link>
)}
{booksEnabled && (
<Link href='/books'>
<button
className='p-1.5 rounded-lg text-amber-500 hover:text-amber-600 transition-colors'
title='电子书馆'
>
<BookMarked size={18} />
</button>
</Link>
)}
{/* 源站寻片入口 */}
{sourceSearchEnabled && (
<Link href='/source-search'>