增加漫画展馆功能

This commit is contained in:
mtvpls
2026-04-16 16:01:47 +08:00
parent 4fe9314c57
commit cb225c3291
33 changed files with 3242 additions and 5 deletions
+189 -1
View File
@@ -24,6 +24,7 @@ import { CSS } from '@dnd-kit/utilities';
import {
AlertCircle,
AlertTriangle,
BookOpen,
Bot,
Cat,
Check,
@@ -10114,6 +10115,181 @@ const CustomAdFilterConfig = ({
};
// 小雅配置组件
const SuwayomiConfigComponent = ({
config,
refreshConfig,
}: {
config: AdminConfig | null;
refreshConfig: () => Promise<void>;
}) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [enabled, setEnabled] = useState(false);
const [serverURL, setServerURL] = useState('');
const [authToken, setAuthToken] = useState('');
const [defaultLang, setDefaultLang] = useState('zh');
const [sourceIds, setSourceIds] = useState('');
const [maxSources, setMaxSources] = useState(10);
useEffect(() => {
if (config?.SuwayomiConfig) {
setEnabled(config.SuwayomiConfig.Enabled || false);
setServerURL(config.SuwayomiConfig.ServerURL || '');
setAuthToken(config.SuwayomiConfig.AuthToken || '');
setDefaultLang(config.SuwayomiConfig.DefaultLang || 'zh');
setSourceIds((config.SuwayomiConfig.SourceIds || []).join(','));
setMaxSources(config.SuwayomiConfig.MaxSources || 10);
}
}, [config]);
const buildConfig = () => ({
Enabled: enabled,
ServerURL: serverURL,
AuthToken: authToken,
DefaultLang: defaultLang || 'zh',
SourceIds: sourceIds.split(',').map((item) => item.trim()).filter(Boolean),
MaxSources: Math.max(1, maxSources || 10),
});
const handleSave = async () => {
await withLoading('saveSuwayomi', 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,
SuwayomiConfig: buildConfig(),
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || '保存失败');
}
showSuccess('漫画后端配置已保存', showAlert);
await refreshConfig();
} catch (error) {
showError(error instanceof Error ? error.message : '保存失败', showAlert);
throw error;
}
});
};
return (
<div className='space-y-6'>
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
<h3 className='text-sm font-medium text-blue-900 dark:text-blue-100 mb-2'>
/ Suwayomi
</h3>
<div className='text-sm text-blue-800 dark:text-blue-200 space-y-1'>
<p> Suwayomi Server GraphQL </p>
<p> </p>
<p> 使</p>
</div>
</div>
<div className='space-y-4'>
<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'></p>
</div>
<button
onClick={() => setEnabled(!enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled ? 'bg-blue-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'>Suwayomi </label>
<input
type='text'
value={serverURL}
onChange={(e) => setServerURL(e.target.value)}
placeholder='http://127.0.0.1:4567'
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'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'> /api/graphql</p>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'> Token</label>
<input
type='password'
value={authToken}
onChange={(e) => setAuthToken(e.target.value)}
placeholder='可选,若 Suwayomi 开启认证请填写'
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-2'>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'></label>
<input
type='text'
value={defaultLang}
onChange={(e) => setDefaultLang(e.target.value)}
placeholder='zh'
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='number'
min='1'
value={maxSources}
onChange={(e) => setMaxSources(parseInt(e.target.value) || 10)}
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>
<textarea
value={sourceIds}
onChange={(e) => setSourceIds(e.target.value)}
rows={3}
placeholder='留空表示使用默认语言下全部源;填写时用英文逗号分隔 sourceId'
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='flex gap-3'>
<button
onClick={handleSave}
disabled={isLoading('saveSuwayomi')}
className={buttonStyles.success}
>
{isLoading('saveSuwayomi') ? '保存中...' : '保存配置'}
</button>
</div>
</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,
@@ -12822,6 +12998,7 @@ function AdminPageClient() {
netDiskConfig: false,
embyConfig: false,
xiaoyaConfig: false,
suwayomiConfig: false,
animeSubscription: false,
aiConfig: false,
liveSource: false,
@@ -13213,6 +13390,18 @@ function AdminPageClient() {
<MusicConfigComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
<CollapsibleTab
title='漫画配置'
icon={
<BookOpen size={20} className='text-gray-600 dark:text-gray-400' />
}
isExpanded={expandedTabs.suwayomiConfig}
onToggle={() => toggleTab('suwayomiConfig')}
>
<SuwayomiConfigComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* 电视直播源配置标签 */}
<CollapsibleTab
title='电视直播源配置'
@@ -13283,7 +13472,6 @@ function AdminPageClient() {
>
<XiaoyaConfigComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* 求片管理子标签 */}
<CollapsibleTab
title='求片管理'
+23
View File
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { db } from '@/lib/db';
export async function getAuthorizedUsername(request: NextRequest): Promise<string | NextResponse> {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (authInfo.username !== process.env.USERNAME) {
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
if (!userInfoV2) {
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
}
if (userInfoV2.banned) {
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
}
}
return authInfo.username;
}
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import { suwayomiClient } from '@/lib/suwayomi.client';
import { getAuthorizedUsername } from '../_utils';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const username = await getAuthorizedUsername(request);
if (username instanceof NextResponse) return username;
try {
const { searchParams } = new URL(request.url);
const mangaId = searchParams.get('mangaId')?.trim();
const sourceId = searchParams.get('sourceId')?.trim();
if (!mangaId || !sourceId) {
return NextResponse.json({ error: '缺少 mangaId 或 sourceId' }, { status: 400 });
}
const detail = await suwayomiClient.getMangaDetail({
mangaId,
sourceId,
title: searchParams.get('title') || undefined,
cover: searchParams.get('cover') || undefined,
sourceName: searchParams.get('sourceName') || undefined,
description: searchParams.get('description') || undefined,
author: searchParams.get('author') || undefined,
status: searchParams.get('status') || undefined,
});
return NextResponse.json(detail);
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
+90
View File
@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { MangaReadRecord } from '@/lib/manga.types';
import { getAuthorizedUsername } from '../_utils';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const username = await getAuthorizedUsername(request);
if (username instanceof NextResponse) return username;
try {
const key = new URL(request.url).searchParams.get('key');
if (key) {
const [sourceId, mangaId] = key.split('+');
if (!sourceId || !mangaId) {
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
}
const record = await db.getMangaReadRecord(username, sourceId, mangaId);
return NextResponse.json(record, { status: 200 });
}
const records = await db.getAllMangaReadRecords(username);
return NextResponse.json(records, { status: 200 });
} catch (error) {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
const username = await getAuthorizedUsername(request);
if (username instanceof NextResponse) return username;
try {
const { key, record }: { key: string; record: MangaReadRecord } = await request.json();
if (!key || !record?.chapterId) {
return NextResponse.json({ error: 'Missing key or record' }, { status: 400 });
}
const [sourceId, mangaId] = key.split('+');
if (!sourceId || !mangaId) {
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
}
await db.saveMangaReadRecord(username, sourceId, mangaId, {
...record,
saveTime: record.saveTime ?? Date.now(),
});
if ((db as any).storage.cleanupOldMangaReadRecords) {
(db as any).storage.cleanupOldMangaReadRecords(username).catch((err: Error) => {
console.error('异步清理漫画阅读历史失败:', err);
});
}
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
const username = await getAuthorizedUsername(request);
if (username instanceof NextResponse) return username;
try {
const key = new URL(request.url).searchParams.get('key');
if (key) {
const [sourceId, mangaId] = key.split('+');
if (!sourceId || !mangaId) {
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
}
await db.deleteMangaReadRecord(username, sourceId, mangaId);
} else {
const all = await db.getAllMangaReadRecords(username);
await Promise.all(Object.keys(all).map(async (itemKey) => {
const [sourceId, mangaId] = itemKey.split('+');
if (sourceId && mangaId) {
await db.deleteMangaReadRecord(username, sourceId, mangaId);
}
}));
}
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
+65
View File
@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthorizedUsername } from '../_utils';
import { getSuwayomiConfig } from '@/lib/suwayomi.client';
export const runtime = 'nodejs';
function resolveUpstreamUrl(serverBaseUrl: string, pathOrUrl: string): string {
if (/^https?:\/\//i.test(pathOrUrl)) {
const target = new URL(pathOrUrl);
const base = new URL(serverBaseUrl);
if (target.origin !== base.origin) {
throw new Error('不允许代理非当前 Suwayomi 服务的地址');
}
return target.toString();
}
if (!pathOrUrl.startsWith('/')) {
pathOrUrl = `/${pathOrUrl}`;
}
return `${serverBaseUrl}${pathOrUrl}`;
}
export async function GET(request: NextRequest) {
const username = await getAuthorizedUsername(request);
if (username instanceof NextResponse) return username;
try {
const pathOrUrl = new URL(request.url).searchParams.get('path')?.trim();
if (!pathOrUrl) {
return NextResponse.json({ error: '缺少 path 参数' }, { status: 400 });
}
const config = await getSuwayomiConfig();
const upstreamUrl = resolveUpstreamUrl(config.serverBaseUrl, pathOrUrl);
const response = await fetch(upstreamUrl, {
headers: config.token ? { Authorization: `Bearer ${config.token}` } : undefined,
cache: 'no-store',
});
if (!response.ok) {
return NextResponse.json(
{ error: `Suwayomi 图片请求失败: ${response.status}` },
{ status: response.status }
);
}
const headers = new Headers();
const contentType = response.headers.get('content-type');
const cacheControl = response.headers.get('cache-control');
if (contentType) headers.set('content-type', contentType);
headers.set('cache-control', cacheControl || 'public, max-age=300');
return new NextResponse(response.body, {
status: 200,
headers,
});
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : '图片代理失败' },
{ status: 500 }
);
}
}
+24
View File
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { suwayomiClient } from '@/lib/suwayomi.client';
import { getAuthorizedUsername } from '../_utils';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const username = await getAuthorizedUsername(request);
if (username instanceof NextResponse) return username;
try {
const chapterId = new URL(request.url).searchParams.get('chapterId')?.trim();
if (!chapterId) {
return NextResponse.json({ error: '缺少 chapterId' }, { status: 400 });
}
const pages = await suwayomiClient.getChapterPages(chapterId);
return NextResponse.json({ pages });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { suwayomiClient } from '@/lib/suwayomi.client';
import { getAuthorizedUsername } from '../_utils';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const username = await getAuthorizedUsername(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;
const page = Number(searchParams.get('page') || '1');
if (!q) {
return NextResponse.json({ results: [] });
}
const results = await suwayomiClient.searchManga(q, sourceId, page);
return NextResponse.json({ results });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
+83
View File
@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { MangaShelfItem } from '@/lib/manga.types';
import { getAuthorizedUsername } from '../_utils';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const username = await getAuthorizedUsername(request);
if (username instanceof NextResponse) return username;
try {
const key = new URL(request.url).searchParams.get('key');
if (key) {
const [sourceId, mangaId] = key.split('+');
if (!sourceId || !mangaId) {
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
}
const item = await db.getMangaShelf(username, sourceId, mangaId);
return NextResponse.json(item, { status: 200 });
}
const records = await db.getAllMangaShelf(username);
return NextResponse.json(records, { status: 200 });
} catch (error) {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
const username = await getAuthorizedUsername(request);
if (username instanceof NextResponse) return username;
try {
const { key, item }: { key: string; item: MangaShelfItem } = await request.json();
if (!key || !item?.title) {
return NextResponse.json({ error: 'Missing key or item' }, { status: 400 });
}
const [sourceId, mangaId] = key.split('+');
if (!sourceId || !mangaId) {
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
}
await db.saveMangaShelf(username, sourceId, mangaId, {
...item,
saveTime: item.saveTime ?? Date.now(),
});
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
const username = await getAuthorizedUsername(request);
if (username instanceof NextResponse) return username;
try {
const key = new URL(request.url).searchParams.get('key');
if (key) {
const [sourceId, mangaId] = key.split('+');
if (!sourceId || !mangaId) {
return NextResponse.json({ error: 'Invalid key format' }, { status: 400 });
}
await db.deleteMangaShelf(username, sourceId, mangaId);
} else {
const all = await db.getAllMangaShelf(username);
await Promise.all(Object.keys(all).map(async (itemKey) => {
const [sourceId, mangaId] = itemKey.split('+');
if (sourceId && mangaId) {
await db.deleteMangaShelf(username, sourceId, mangaId);
}
}));
}
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import { suwayomiClient } from '@/lib/suwayomi.client';
import { getAuthorizedUsername } from '../_utils';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const username = await getAuthorizedUsername(request);
if (username instanceof NextResponse) return username;
try {
const lang = new URL(request.url).searchParams.get('lang') || process.env.SUWAYOMI_DEFAULT_LANG || 'zh';
const sources = await suwayomiClient.getSources(lang);
return NextResponse.json({ sources });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
+7
View File
@@ -94,6 +94,7 @@ export default async function RootLayout({
let webLiveEnabled = false;
let customAdFilterVersion = 0;
let tuneHubEnabled = false;
let suwayomiEnabled = false;
let musicProxyEnabled = true;
let advancedRecommendationEnabled = false;
let customCategories = [] as {
@@ -153,6 +154,11 @@ export default async function RootLayout({
// 音乐功能配置
tuneHubEnabled = config.MusicConfig?.Enabled || false;
musicProxyEnabled = config.MusicConfig?.ProxyEnabled ?? true;
// 漫画功能配置
suwayomiEnabled = !!(
config.SuwayomiConfig?.Enabled &&
config.SuwayomiConfig?.ServerURL
);
// 高级推荐功能配置:存在已启用视频源脚本时显示
advancedRecommendationEnabled =
(await listEnabledSourceScripts()).length > 0;
@@ -229,6 +235,7 @@ export default async function RootLayout({
CUSTOM_AD_FILTER_VERSION: customAdFilterVersion,
MUSIC_ENABLED: tuneHubEnabled,
MUSIC_PROXY_ENABLED: musicProxyEnabled,
SUWAYOMI_ENABLED: suwayomiEnabled,
FESTIVE_EFFECT_ENABLED:
process.env.FESTIVE_EFFECT_ENABLED === 'true',
};
+180
View File
@@ -0,0 +1,180 @@
'use client';
import { ArrowDownWideNarrow, ArrowUpWideNarrow, BookOpen, Clock3 } from 'lucide-react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { deleteMangaShelf, getAllMangaReadRecords, getAllMangaShelf, saveMangaShelf } from '@/lib/db.client';
import { MangaChapter, MangaDetail, MangaReadRecord, MangaShelfItem } from '@/lib/manga.types';
import ProxyImage from '@/components/ProxyImage';
function formatChapterMeta(chapter: MangaChapter): string {
if (typeof chapter.pageCount === 'number' && chapter.pageCount > 0) {
return `${chapter.pageCount}`;
}
if (typeof chapter.uploadDate === 'number' && chapter.uploadDate > 0) {
const timestamp =
chapter.uploadDate > 1_000_000_000_000
? chapter.uploadDate
: chapter.uploadDate * 1000;
const date = new Date(timestamp);
if (!Number.isNaN(date.getTime())) {
return date.toLocaleDateString('zh-CN');
}
}
return '日期未知';
}
export default function MangaDetailPage() {
const searchParams = useSearchParams();
const mangaId = searchParams.get('mangaId') || '';
const sourceId = searchParams.get('sourceId') || '';
const [detail, setDetail] = useState<MangaDetail | null>(null);
const [history, setHistory] = useState<Record<string, MangaReadRecord>>({});
const [shelf, setShelf] = useState<Record<string, MangaShelfItem>>({});
const [descOrder, setDescOrder] = useState(true);
const key = `${sourceId}+${mangaId}`;
const currentRecord = history[key];
useEffect(() => {
if (!mangaId || !sourceId) return;
const params = new URLSearchParams({
mangaId,
sourceId,
title: searchParams.get('title') || '',
cover: searchParams.get('cover') || '',
sourceName: searchParams.get('sourceName') || '',
description: searchParams.get('description') || '',
author: searchParams.get('author') || '',
status: searchParams.get('status') || '',
});
fetch(`/api/manga/detail?${params.toString()}`)
.then((res) => res.json())
.then(setDetail)
.catch(() => undefined);
getAllMangaReadRecords().then(setHistory).catch(() => undefined);
getAllMangaShelf().then(setShelf).catch(() => undefined);
}, [mangaId, searchParams, sourceId]);
const chapters = useMemo(() => {
const list = detail?.chapters || [];
return [...list].sort((a, b) => {
const diff = (a.chapterNumber || 0) - (b.chapterNumber || 0);
return descOrder ? -diff : diff;
});
}, [detail?.chapters, descOrder]);
const toggleShelf = async () => {
if (!detail) return;
if (shelf[key]) {
await deleteMangaShelf(sourceId, mangaId);
setShelf((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
return;
}
const item: MangaShelfItem = {
title: detail.title,
cover: detail.cover,
sourceId: detail.sourceId,
sourceName: detail.sourceName,
mangaId: detail.id,
saveTime: Date.now(),
description: detail.description,
author: detail.author,
status: detail.status,
lastChapterId: currentRecord?.chapterId,
lastChapterName: currentRecord?.chapterName,
};
await saveMangaShelf(sourceId, mangaId, item);
setShelf((prev) => ({ ...prev, [key]: item }));
};
const chapterHref = (chapter: MangaChapter) =>
`/manga/read?mangaId=${mangaId}&sourceId=${sourceId}&chapterId=${chapter.id}&title=${encodeURIComponent(detail?.title || '')}&cover=${encodeURIComponent(detail?.cover || '')}&sourceName=${encodeURIComponent(detail?.sourceName || '')}&chapterName=${encodeURIComponent(chapter.name)}`;
if (!detail) return <div className='text-sm text-gray-500'>...</div>;
return (
<div className='space-y-6'>
<div className='grid gap-6 rounded-[28px] border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-950 md:grid-cols-[260px_1fr]'>
<div className='overflow-hidden rounded-3xl bg-gray-100 dark:bg-gray-800'>
{detail.cover ? (
<ProxyImage originalSrc={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-3xl font-bold'>{detail.title}</h1>
<div className='mt-3 flex flex-wrap gap-2 text-xs text-gray-500'>
<span className='rounded-full bg-sky-50 px-3 py-1 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300'>
{detail.sourceName}
</span>
{detail.author && <span className='rounded-full bg-gray-100 px-3 py-1 dark:bg-gray-800'>{detail.author}</span>}
{detail.status && <span className='rounded-full bg-gray-100 px-3 py-1 dark:bg-gray-800'>{detail.status}</span>}
</div>
</div>
{detail.description && <p className='text-sm leading-7 text-gray-600 dark:text-gray-300'>{detail.description}</p>}
<div className='flex flex-wrap gap-3'>
{chapters[0] && (
<Link href={chapterHref(chapters[0])} className='rounded-2xl bg-sky-600 px-5 py-3 text-sm font-medium text-white transition hover:bg-sky-700'>
<BookOpen className='mr-2 inline h-4 w-4' />
</Link>
)}
{currentRecord && (
<Link
href={`/manga/read?mangaId=${mangaId}&sourceId=${sourceId}&chapterId=${currentRecord.chapterId}&title=${encodeURIComponent(detail.title)}&cover=${encodeURIComponent(detail.cover)}&sourceName=${encodeURIComponent(detail.sourceName)}&chapterName=${encodeURIComponent(currentRecord.chapterName)}`}
className='rounded-2xl border border-sky-300 px-5 py-3 text-sm font-medium text-sky-700 transition hover:bg-sky-50 dark:text-sky-300 dark:hover:bg-sky-950/30'
>
<Clock3 className='mr-2 inline h-4 w-4' /> {currentRecord.pageIndex + 1}/{currentRecord.pageCount}
</Link>
)}
<button onClick={toggleShelf} className='rounded-2xl border border-gray-200 px-5 py-3 text-sm font-medium text-gray-700 transition hover:border-sky-300 hover:text-sky-600 dark:border-gray-700 dark:text-gray-200'>
{shelf[key] ? '移出书架' : '加入书架'}
</button>
</div>
</div>
</div>
<div className='rounded-[28px] border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
<div className='mb-4 flex items-center justify-between'>
<h2 className='text-lg font-semibold'></h2>
<button onClick={() => setDescOrder((prev) => !prev)} className='rounded-2xl border border-gray-200 px-4 py-2 text-sm dark:border-gray-700'>
{descOrder ? <ArrowDownWideNarrow className='inline h-4 w-4' /> : <ArrowUpWideNarrow className='inline h-4 w-4' />} {descOrder ? '倒序' : '正序'}
</button>
</div>
<div className='grid gap-3'>
{chapters.map((chapter) => {
const active = currentRecord?.chapterId === chapter.id;
return (
<Link
key={chapter.id}
href={chapterHref(chapter)}
className={`rounded-2xl border px-4 py-3 text-sm transition ${active ? 'border-sky-400 bg-sky-50 dark:bg-sky-950/30' : 'border-gray-200 hover:border-sky-300 dark:border-gray-700'}`}
>
<div className='font-medium text-gray-900 dark:text-gray-100'>{chapter.name}</div>
<div className='mt-1 text-xs text-gray-500'>
{formatChapterMeta(chapter)}
{active && currentRecord ? ` · 上次看到第 ${currentRecord.pageIndex + 1}` : ''}
</div>
</Link>
);
})}
</div>
</div>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
'use client';
import { History } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { getAllMangaReadRecords } from '@/lib/db.client';
import { MangaReadRecord } from '@/lib/manga.types';
import MangaCard from '@/components/MangaCard';
export default function MangaHistoryPage() {
const [history, setHistory] = useState<Record<string, MangaReadRecord>>({});
useEffect(() => {
getAllMangaReadRecords().then(setHistory).catch(() => undefined);
}, []);
const historyList = useMemo(
() => Object.entries(history).sort(([, a], [, b]) => b.saveTime - a.saveTime),
[history]
);
return (
<section className='mx-auto max-w-6xl'>
<div className='mb-4 flex items-center gap-2 text-sm text-gray-500'>
<History className='h-4 w-4 text-violet-500' /> {historyList.length}
</div>
{historyList.length === 0 ? (
<div className='rounded-2xl bg-gray-50 p-10 text-center text-sm text-gray-500 dark:bg-gray-900/50'>
</div>
) : (
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
{historyList.map(([key, item]) => (
<MangaCard
key={key}
item={item}
href={`/manga/read?mangaId=${item.mangaId}&sourceId=${item.sourceId}&chapterId=${item.chapterId}&title=${encodeURIComponent(item.title)}&cover=${encodeURIComponent(item.cover)}&sourceName=${encodeURIComponent(item.sourceName)}&chapterName=${encodeURIComponent(item.chapterName)}`}
subtitle={`${item.chapterName} · 第 ${item.pageIndex + 1}/${item.pageCount}`}
/>
))}
</div>
)}
</section>
);
}
+9
View File
@@ -0,0 +1,9 @@
import MangaLayout from '@/components/manga/MangaLayout';
export default function MangaAppLayout({
children,
}: {
children: React.ReactNode;
}) {
return <MangaLayout>{children}</MangaLayout>;
}
+143
View File
@@ -0,0 +1,143 @@
'use client';
import { Search } from 'lucide-react';
import { useEffect, useState } from 'react';
import { deleteMangaShelf, getAllMangaShelf, saveMangaShelf } from '@/lib/db.client';
import { MangaSearchItem, MangaShelfItem, MangaSource } from '@/lib/manga.types';
import MangaCard from '@/components/MangaCard';
export default function MangaPage() {
const [query, setQuery] = useState('');
const [sources, setSources] = useState<MangaSource[]>([]);
const [sourceId, setSourceId] = useState('');
const [results, setResults] = useState<MangaSearchItem[]>([]);
const [shelf, setShelf] = useState<Record<string, MangaShelfItem>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
fetch('/api/manga/sources')
.then((res) => res.json())
.then((data) => setSources(data.sources || []))
.catch(() => undefined);
getAllMangaShelf().then(setShelf).catch(() => undefined);
}, []);
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
if (!query.trim()) return;
setLoading(true);
setError('');
try {
const params = new URLSearchParams({ q: query.trim() });
if (sourceId) params.set('sourceId', sourceId);
const res = await fetch(`/api/manga/search?${params.toString()}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || '搜索失败');
setResults(data.results || []);
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
const toggleShelf = async (item: MangaSearchItem) => {
const key = `${item.sourceId}+${item.id}`;
if (shelf[key]) {
await deleteMangaShelf(item.sourceId, item.id);
setShelf((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
return;
}
const shelfItem: MangaShelfItem = {
title: item.title,
cover: item.cover,
sourceId: item.sourceId,
sourceName: item.sourceName,
mangaId: item.id,
saveTime: Date.now(),
description: item.description,
author: item.author,
status: item.status,
};
await saveMangaShelf(item.sourceId, item.id, shelfItem);
setShelf((prev) => ({ ...prev, [key]: shelfItem }));
};
return (
<div className='mx-auto max-w-6xl'>
<form
className='mx-auto mb-8 max-w-4xl'
onSubmit={handleSearch}
>
<div className='flex flex-col gap-3 lg:flex-row'>
<div className='flex-1'>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder='搜索漫画标题'
className='w-full rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm outline-none transition focus:border-sky-500 dark:border-gray-700 dark:bg-gray-900'
/>
</div>
<select
value={sourceId}
onChange={(e) => setSourceId(e.target.value)}
className='rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm dark:border-gray-700 dark:bg-gray-900 lg:w-56'
>
<option value=''></option>
{sources.map((source) => (
<option key={source.id} value={source.id}>
{source.displayName || source.name}
</option>
))}
</select>
<button className='inline-flex items-center justify-center gap-2 rounded-2xl bg-sky-600 px-6 py-3 text-sm font-medium text-white transition hover:bg-sky-700 lg:w-32'>
<Search className='h-4 w-4' />
</button>
</div>
</form>
<section>
<div className='mb-4 flex items-center justify-between'>
<h2 className='text-lg font-semibold'></h2>
{loading && <span className='text-sm text-gray-500'>...</span>}
</div>
{error && <div className='mb-4 text-sm text-red-500'>{error}</div>}
{results.length === 0 ? (
<div className='rounded-2xl bg-gray-50 p-10 text-center text-sm text-gray-500 dark:bg-gray-900/50'>
</div>
) : (
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
{results.map((item) => {
const key = `${item.sourceId}+${item.id}`;
return (
<div key={key} className='space-y-2'>
<MangaCard
item={item}
href={`/manga/detail?mangaId=${item.id}&sourceId=${item.sourceId}&title=${encodeURIComponent(item.title)}&cover=${encodeURIComponent(item.cover)}&sourceName=${encodeURIComponent(item.sourceName)}&description=${encodeURIComponent(item.description || '')}&author=${encodeURIComponent(item.author || '')}&status=${encodeURIComponent(item.status || '')}`}
subtitle={item.author || item.status || item.description}
/>
<button
onClick={() => toggleShelf(item)}
className='w-full rounded-2xl border border-gray-200 px-3 py-2 text-xs font-medium text-gray-700 transition hover:border-sky-500 hover:text-sky-600 dark:border-gray-700 dark:text-gray-200'
>
{shelf[key] ? '移出书架' : '加入书架'}
</button>
</div>
);
})}
</div>
)}
</section>
</div>
);
}
+423
View File
@@ -0,0 +1,423 @@
'use client';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { type MouseEvent, useEffect, useMemo, useRef, useState } from 'react';
import { saveMangaReadRecord } from '@/lib/db.client';
import ProxyImage from '@/components/ProxyImage';
type ReadMode = 'single' | 'double' | 'vertical' | 'horizontal';
type ScaleMode = 'fit' | 'original';
const READ_MODE_STORAGE_KEY = 'mangaReadMode';
const SCALE_MODE_STORAGE_KEY = 'mangaScaleMode';
const PAGE_GAP_STORAGE_KEY = 'mangaPageGap';
const READ_MODE_OPTIONS: Array<{ value: ReadMode; label: string }> = [
{ value: 'single', label: '单页' },
{ value: 'double', label: '双页' },
{ value: 'vertical', label: '垂直滚动' },
{ value: 'horizontal', label: '水平滚动' },
];
const SCALE_MODE_OPTIONS: Array<{ value: ScaleMode; label: string }> = [
{ value: 'fit', label: '适配屏幕' },
{ value: 'original', label: '原始大小' },
];
export default function MangaReadPage() {
const searchParams = useSearchParams();
const mangaId = searchParams.get('mangaId') || '';
const sourceId = searchParams.get('sourceId') || '';
const chapterId = searchParams.get('chapterId') || '';
const title = searchParams.get('title') || '漫画阅读';
const cover = searchParams.get('cover') || '';
const sourceName = searchParams.get('sourceName') || sourceId;
const chapterName = searchParams.get('chapterName') || '章节';
const [pages, setPages] = useState<string[]>([]);
const [activePage, setActivePage] = useState(0);
const [readMode, setReadMode] = useState<ReadMode>('vertical');
const [scaleMode, setScaleMode] = useState<ScaleMode>('fit');
const [pageGap, setPageGap] = useState(0);
const [controlsVisible, setControlsVisible] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const verticalPageRefs = useRef<Array<HTMLDivElement | null>>([]);
const horizontalContainerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (typeof window === 'undefined') return;
const savedMode = window.localStorage.getItem(READ_MODE_STORAGE_KEY) as ReadMode | null;
if (savedMode && READ_MODE_OPTIONS.some((item) => item.value === savedMode)) {
setReadMode(savedMode);
}
const savedScaleMode = window.localStorage.getItem(SCALE_MODE_STORAGE_KEY) as ScaleMode | null;
if (savedScaleMode && SCALE_MODE_OPTIONS.some((item) => item.value === savedScaleMode)) {
setScaleMode(savedScaleMode);
}
const savedGap = Number(window.localStorage.getItem(PAGE_GAP_STORAGE_KEY) || 0);
if (!Number.isNaN(savedGap)) {
setPageGap(Math.min(Math.max(savedGap, 0), 48));
}
}, []);
useEffect(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(READ_MODE_STORAGE_KEY, readMode);
}, [readMode]);
useEffect(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(SCALE_MODE_STORAGE_KEY, scaleMode);
}, [scaleMode]);
useEffect(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(PAGE_GAP_STORAGE_KEY, String(pageGap));
}, [pageGap]);
useEffect(() => {
const handleToggleSettings = () => {
setSettingsOpen((prev) => !prev);
setControlsVisible(false);
};
window.addEventListener('manga-read-toggle-settings', handleToggleSettings);
return () => {
window.removeEventListener('manga-read-toggle-settings', handleToggleSettings);
};
}, []);
useEffect(() => {
if (!chapterId) return;
fetch(`/api/manga/pages?chapterId=${encodeURIComponent(chapterId)}`)
.then((res) => res.json())
.then((data) => setPages(data.pages || []))
.catch(() => setPages([]));
}, [chapterId]);
useEffect(() => {
setActivePage(0);
}, [chapterId, readMode]);
useEffect(() => {
if (readMode !== 'vertical' || !pages.length || !mangaId || !sourceId || !chapterId) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
if (!visible) return;
const index = Number((visible.target as HTMLElement).dataset.index || 0);
setActivePage(index);
},
{ rootMargin: '-15% 0px -70% 0px', threshold: 0.2 }
);
verticalPageRefs.current.forEach((node) => node && observer.observe(node));
return () => observer.disconnect();
}, [readMode, pages, mangaId, sourceId, chapterId]);
useEffect(() => {
if (readMode !== 'horizontal') return;
const container = horizontalContainerRef.current;
if (!container) return;
const onScroll = () => {
const width = container.clientWidth || 1;
const nextPage = Math.round(container.scrollLeft / width);
setActivePage(Math.min(Math.max(nextPage, 0), Math.max(pages.length - 1, 0)));
};
container.addEventListener('scroll', onScroll, { passive: true });
return () => container.removeEventListener('scroll', onScroll);
}, [readMode, pages.length]);
useEffect(() => {
if (!pages.length || !mangaId || !sourceId || !chapterId) return;
const timer = window.setTimeout(() => {
saveMangaReadRecord(sourceId, mangaId, {
title,
cover,
sourceId,
sourceName,
mangaId,
chapterId,
chapterName,
pageIndex: activePage,
pageCount: pages.length,
saveTime: Date.now(),
}).catch(() => undefined);
}, 300);
return () => window.clearTimeout(timer);
}, [activePage, chapterId, chapterName, cover, mangaId, pages.length, sourceId, sourceName, title]);
const hideTransientUi = () => {
setControlsVisible(false);
setSettingsOpen(false);
};
const clampPage = (page: number) => {
if (!pages.length) return 0;
return Math.min(Math.max(page, 0), pages.length - 1);
};
const scrollHorizontalToPage = (page: number) => {
const container = horizontalContainerRef.current;
if (!container) return;
container.scrollTo({
left: container.clientWidth * page,
behavior: 'smooth',
});
};
const goPrev = () => {
if (!pages.length) return;
if (readMode === 'vertical') {
window.scrollBy({ top: -window.innerHeight * 0.85, behavior: 'smooth' });
hideTransientUi();
return;
}
if (readMode === 'horizontal') {
const nextPage = clampPage(activePage - 1);
setActivePage(nextPage);
scrollHorizontalToPage(nextPage);
hideTransientUi();
return;
}
setActivePage((prev) => clampPage(prev - (readMode === 'double' ? 2 : 1)));
hideTransientUi();
};
const goNext = () => {
if (!pages.length) return;
if (readMode === 'vertical') {
window.scrollBy({ top: window.innerHeight * 0.85, behavior: 'smooth' });
hideTransientUi();
return;
}
if (readMode === 'horizontal') {
const nextPage = clampPage(activePage + 1);
setActivePage(nextPage);
scrollHorizontalToPage(nextPage);
hideTransientUi();
return;
}
setActivePage((prev) => clampPage(prev + (readMode === 'double' ? 2 : 1)));
hideTransientUi();
};
const progress = useMemo(
() => (pages.length ? Math.round(((activePage + 1) / pages.length) * 100) : 0),
[activePage, pages.length]
);
const pagedItems = useMemo(() => {
if (readMode === 'single') {
return pages[activePage] ? [pages[activePage]] : [];
}
if (readMode === 'double') {
return pages.slice(activePage, activePage + 2);
}
return [];
}, [activePage, pages, readMode]);
const imageClassName = useMemo(() => {
if (scaleMode === 'original') {
return 'mx-auto h-auto w-auto max-w-none object-none';
}
return 'h-auto w-full object-contain';
}, [scaleMode]);
const handleReaderClick = (event: MouseEvent<HTMLDivElement>) => {
if (settingsOpen) return;
const { clientX } = event;
const width = window.innerWidth;
const leftBoundary = width / 3;
const rightBoundary = (width / 3) * 2;
if (clientX < leftBoundary) {
goPrev();
return;
}
if (clientX > rightBoundary) {
goNext();
return;
}
setControlsVisible((prev) => !prev);
setSettingsOpen(false);
};
return (
<div className='mx-auto max-w-6xl'>
{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={(e) => e.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'></div>
</div>
<div className='space-y-5'>
<div>
<div className='mb-2 text-sm font-medium text-gray-700 dark:text-gray-200'></div>
<div className='grid grid-cols-2 gap-2'>
{READ_MODE_OPTIONS.map((option) => (
<button
key={option.value}
type='button'
className={`rounded-2xl px-3 py-2 text-sm transition ${
readMode === option.value
? 'bg-sky-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800'
}`}
onClick={() => setReadMode(option.value)}
>
{option.label}
</button>
))}
</div>
</div>
<div>
<div className='mb-2 text-sm font-medium text-gray-700 dark:text-gray-200'></div>
<div className='grid grid-cols-2 gap-2'>
{SCALE_MODE_OPTIONS.map((option) => (
<button
key={option.value}
type='button'
className={`rounded-2xl px-3 py-2 text-sm transition ${
scaleMode === option.value
? 'bg-sky-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800'
}`}
onClick={() => setScaleMode(option.value)}
>
{option.label}
</button>
))}
</div>
</div>
<div>
<div className='mb-2 flex items-center justify-between text-sm font-medium text-gray-700 dark:text-gray-200'>
<span></span>
<span className='text-xs text-gray-500'>{pageGap}px</span>
</div>
<input
type='range'
min='0'
max='48'
step='2'
value={pageGap}
onChange={(e) => setPageGap(Number(e.target.value))}
className='w-full accent-sky-600'
/>
<div className='mt-1 text-xs text-gray-500'></div>
</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>
)}
<div
className='relative min-h-[calc(100vh-5rem)] select-none px-2 py-3 sm:px-3'
onClick={handleReaderClick}
>
<div
className={`fixed right-3 top-1/2 z-20 h-40 w-1 -translate-y-1/2 overflow-hidden rounded-full bg-gray-200/80 transition-all duration-200 dark:bg-gray-700/80 ${
controlsVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
}`}
>
<div
className='absolute bottom-0 left-0 w-full rounded-full bg-sky-500 transition-all'
style={{ height: `${progress}%` }}
/>
</div>
{pages.length === 0 ? (
<div className='rounded-[24px] bg-gray-50 p-10 text-center text-sm text-gray-500 dark:bg-gray-900/50'>
...
</div>
) : readMode === 'vertical' ? (
<div className='flex flex-col' style={{ gap: `${pageGap}px` }}>
{pages.map((page, index) => (
<div
key={`${page}-${index}`}
ref={(node) => {
verticalPageRefs.current[index] = node;
}}
data-index={index}
className='overflow-hidden rounded-[24px] bg-gray-100 shadow-sm dark:bg-gray-900'
>
<ProxyImage originalSrc={page} alt={`${chapterName}-${index + 1}`} className={imageClassName} />
</div>
))}
</div>
) : readMode === 'horizontal' ? (
<div
ref={horizontalContainerRef}
className='flex min-h-[calc(100vh-8rem)] snap-x snap-mandatory overflow-x-auto overflow-y-hidden scrollbar-hide'
style={{ gap: `${pageGap}px` }}
>
{pages.map((page, index) => (
<div key={`${page}-${index}`} className='flex min-w-full snap-center items-center justify-center px-1'>
<div className='w-full overflow-hidden rounded-[24px] bg-gray-100 shadow-sm dark:bg-gray-900'>
<ProxyImage originalSrc={page} alt={`${chapterName}-${index + 1}`} className={imageClassName} />
</div>
</div>
))}
</div>
) : (
<div className='flex min-h-[calc(100vh-8rem)] items-center justify-center'>
<div className={`grid w-full max-w-6xl ${readMode === 'double' ? 'md:grid-cols-2' : 'grid-cols-1'}`} style={{ gap: `${pageGap}px` }}>
{pagedItems.map((page, index) => (
<div
key={`${page}-${index}`}
className='overflow-hidden rounded-[24px] bg-gray-100 shadow-sm dark:bg-gray-900'
>
<ProxyImage
originalSrc={page}
alt={`${chapterName}-${activePage + index + 1}`}
className={imageClassName}
/>
</div>
))}
{readMode === 'double' && pagedItems.length === 1 && (
<div className='hidden rounded-[24px] bg-transparent md:block' />
)}
</div>
</div>
)}
</div>
<Link
href={`/manga/detail?mangaId=${mangaId}&sourceId=${sourceId}&title=${encodeURIComponent(title)}&cover=${encodeURIComponent(cover)}&sourceName=${encodeURIComponent(sourceName)}`}
className='sr-only'
>
</Link>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
'use client';
import { BookOpen } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { deleteMangaShelf, getAllMangaShelf } from '@/lib/db.client';
import { MangaShelfItem } from '@/lib/manga.types';
import MangaCard from '@/components/MangaCard';
export default function MangaShelfPage() {
const [shelf, setShelf] = useState<Record<string, MangaShelfItem>>({});
useEffect(() => {
getAllMangaShelf().then(setShelf).catch(() => undefined);
}, []);
const shelfList = useMemo(
() => Object.entries(shelf).sort(([, a], [, b]) => b.saveTime - a.saveTime),
[shelf]
);
const removeItem = async (sourceId: string, mangaId: string) => {
const key = `${sourceId}+${mangaId}`;
await deleteMangaShelf(sourceId, mangaId);
setShelf((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
};
return (
<section className='mx-auto max-w-6xl'>
<div className='mb-4 flex items-center gap-2 text-sm text-gray-500'>
<BookOpen className='h-4 w-4 text-emerald-500' /> {shelfList.length}
</div>
{shelfList.length === 0 ? (
<div className='rounded-2xl bg-gray-50 p-10 text-center text-sm text-gray-500 dark:bg-gray-900/50'>
</div>
) : (
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
{shelfList.map(([key, item]) => (
<div key={key} className='space-y-2'>
<MangaCard
item={item}
href={`/manga/detail?mangaId=${item.mangaId}&sourceId=${item.sourceId}&title=${encodeURIComponent(item.title)}&cover=${encodeURIComponent(item.cover)}&sourceName=${encodeURIComponent(item.sourceName)}`}
subtitle={item.lastChapterName || item.author || item.status}
/>
<button
onClick={() => removeItem(item.sourceId, item.mangaId)}
className='w-full rounded-2xl border border-gray-200 px-3 py-2 text-xs font-medium text-gray-700 transition hover:border-red-300 hover:text-red-600 dark:border-gray-700 dark:text-gray-200'
>
</button>
</div>
))}
</div>
)}
</section>
);
}
+21 -1
View File
@@ -2,7 +2,7 @@
'use client';
import { Bot, ChevronRight, Link as LinkIcon, ListVideo, Music } from 'lucide-react';
import { BookOpen, Bot, ChevronRight, Link as LinkIcon, ListVideo, Music } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
@@ -67,6 +67,7 @@ function HomeClient() {
const [aiDefaultMessageNoVideo, setAiDefaultMessageNoVideo] = useState('你好!我是MoonTVPlus的AI影视助手。想看什么电影或剧集?需要推荐吗?');
const [sourceSearchEnabled, setSourceSearchEnabled] = useState(true);
const [musicEnabled, setMusicEnabled] = useState(false);
const [mangaEnabled, setMangaEnabled] = useState(false);
const [showDirectPlayDialog, setShowDirectPlayDialog] = useState(false);
const [directPlayUrl, setDirectPlayUrl] = useState('');
@@ -157,6 +158,14 @@ function HomeClient() {
}
}, []);
// 检查漫画功能是否启用
useEffect(() => {
if (typeof window !== 'undefined') {
const enabled = !!(window as any).RUNTIME_CONFIG?.SUWAYOMI_ENABLED;
setMangaEnabled(enabled);
}
}, []);
// 检查公告弹窗状态
useEffect(() => {
if (typeof window !== 'undefined' && announcement) {
@@ -624,6 +633,17 @@ function HomeClient() {
</Link>
)}
{mangaEnabled && (
<Link href='/manga'>
<button
className='p-1.5 rounded-lg text-emerald-500 hover:text-emerald-600 transition-colors'
title='漫画展馆'
>
<BookOpen size={18} />
</button>
</Link>
)}
{/* 源站寻片入口 */}
{sourceSearchEnabled && (
<Link href='/source-search'>