增加漫画展馆功能
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import MangaLayout from '@/components/manga/MangaLayout';
|
||||
|
||||
export default function MangaAppLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <MangaLayout>{children}</MangaLayout>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user