漫画阅读历史右键菜单
This commit is contained in:
@@ -3,10 +3,10 @@
|
||||
import { History } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { getAllMangaReadRecords } from '@/lib/db.client';
|
||||
import { MangaReadRecord } from '@/lib/manga.types';
|
||||
import { deleteMangaReadRecord, deleteMangaShelf, getAllMangaReadRecords, getAllMangaShelf, saveMangaShelf, subscribeToDataUpdates } from '@/lib/db.client';
|
||||
import { MangaReadRecord, MangaShelfItem } from '@/lib/manga.types';
|
||||
|
||||
import MangaCard from '@/components/MangaCard';
|
||||
import MangaHistoryCard from '@/components/manga/MangaHistoryCard';
|
||||
|
||||
function MangaHistorySkeleton() {
|
||||
return (
|
||||
@@ -27,12 +27,24 @@ function MangaHistorySkeleton() {
|
||||
export default function MangaHistoryPage() {
|
||||
const [history, setHistory] = useState<Record<string, MangaReadRecord>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [shelf, setShelf] = useState<Record<string, MangaShelfItem>>({});
|
||||
|
||||
useEffect(() => {
|
||||
getAllMangaReadRecords()
|
||||
.then(setHistory)
|
||||
Promise.all([getAllMangaReadRecords(), getAllMangaShelf()])
|
||||
.then(([historyData, shelfData]) => {
|
||||
setHistory(historyData);
|
||||
setShelf(shelfData);
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
const unsubscribeHistory = subscribeToDataUpdates<Record<string, MangaReadRecord>>('mangaHistoryUpdated', setHistory);
|
||||
const unsubscribeShelf = subscribeToDataUpdates<Record<string, MangaShelfItem>>('mangaShelfUpdated', setShelf);
|
||||
|
||||
return () => {
|
||||
unsubscribeHistory();
|
||||
unsubscribeShelf();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const historyList = useMemo(
|
||||
@@ -40,6 +52,44 @@ export default function MangaHistoryPage() {
|
||||
[history]
|
||||
);
|
||||
|
||||
|
||||
const toggleShelf = async (item: MangaReadRecord) => {
|
||||
const key = `${item.sourceId}+${item.mangaId}`;
|
||||
if (shelf[key]) {
|
||||
await deleteMangaShelf(item.sourceId, item.mangaId);
|
||||
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.mangaId,
|
||||
saveTime: Date.now(),
|
||||
lastChapterId: item.chapterId,
|
||||
lastChapterName: item.chapterName,
|
||||
};
|
||||
|
||||
await saveMangaShelf(item.sourceId, item.mangaId, shelfItem);
|
||||
setShelf((prev) => ({ ...prev, [key]: shelfItem }));
|
||||
};
|
||||
|
||||
const deleteHistory = async (item: MangaReadRecord) => {
|
||||
const key = `${item.sourceId}+${item.mangaId}`;
|
||||
await deleteMangaReadRecord(item.sourceId, item.mangaId);
|
||||
setHistory((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'>
|
||||
@@ -54,11 +104,12 @@ export default function MangaHistoryPage() {
|
||||
) : (
|
||||
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
||||
{historyList.map(([key, item]) => (
|
||||
<MangaCard
|
||||
<MangaHistoryCard
|
||||
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} 页`}
|
||||
inShelf={!!shelf[`${item.sourceId}+${item.mangaId}`]}
|
||||
onToggleShelf={toggleShelf}
|
||||
onDelete={deleteHistory}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import { BookOpen, CircleMinus, CirclePlus, Info, Trash2 } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useLongPress } from '@/hooks/useLongPress';
|
||||
import { MangaReadRecord } from '@/lib/manga.types';
|
||||
import { processImageUrl } from '@/lib/utils';
|
||||
|
||||
import ImageViewer from '@/components/ImageViewer';
|
||||
import MobileActionSheet from '@/components/MobileActionSheet';
|
||||
import ProxyImage from '@/components/ProxyImage';
|
||||
|
||||
interface MangaHistoryCardProps {
|
||||
item: MangaReadRecord;
|
||||
inShelf: boolean;
|
||||
onToggleShelf: (item: MangaReadRecord) => void | Promise<void>;
|
||||
onDelete: (item: MangaReadRecord) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export default function MangaHistoryCard({ item, inShelf, onToggleShelf, onDelete }: MangaHistoryCardProps) {
|
||||
const router = useRouter();
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
const [showImageViewer, setShowImageViewer] = useState(false);
|
||||
|
||||
const readHref = useMemo(
|
||||
() => `/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)}&returnTo=${encodeURIComponent('/manga/history')}`,
|
||||
[item]
|
||||
);
|
||||
|
||||
const detailHref = useMemo(
|
||||
() => `/manga/detail?mangaId=${item.mangaId}&sourceId=${item.sourceId}&title=${encodeURIComponent(item.title)}&cover=${encodeURIComponent(item.cover)}&sourceName=${encodeURIComponent(item.sourceName)}&returnTo=${encodeURIComponent('/manga/history')}`,
|
||||
[item]
|
||||
);
|
||||
|
||||
const subtitle = useMemo(
|
||||
() => `${item.chapterName} · 第 ${item.pageIndex + 1}/${item.pageCount} 页`,
|
||||
[item]
|
||||
);
|
||||
|
||||
const openActions = () => setShowActions(true);
|
||||
const goRead = () => router.push(readHref);
|
||||
|
||||
const longPressProps = useLongPress({
|
||||
onLongPress: openActions,
|
||||
onClick: goRead,
|
||||
longPressDelay: 500,
|
||||
});
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'continue-reading',
|
||||
label: '继续阅读',
|
||||
icon: <BookOpen size={20} />,
|
||||
onClick: goRead,
|
||||
color: 'primary' as const,
|
||||
},
|
||||
{
|
||||
id: 'detail',
|
||||
label: '详情',
|
||||
icon: <Info size={20} />,
|
||||
onClick: () => router.push(detailHref),
|
||||
},
|
||||
{
|
||||
id: 'toggle-shelf',
|
||||
label: inShelf ? '移除书架' : '加入书架',
|
||||
icon: inShelf ? <CircleMinus size={20} /> : <CirclePlus size={20} />,
|
||||
onClick: () => onToggleShelf(item),
|
||||
color: inShelf ? ('danger' as const) : ('default' as const),
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
label: '删除',
|
||||
icon: <Trash2 size={20} />,
|
||||
onClick: () => onDelete(item),
|
||||
color: 'danger' as const,
|
||||
},
|
||||
],
|
||||
[detailHref, goRead, inShelf, item, onDelete, onToggleShelf, router]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='group overflow-hidden rounded-2xl border border-gray-200/70 bg-white/90 shadow-sm transition hover:-translate-y-1 hover:shadow-xl dark:border-gray-700 dark:bg-gray-900/80 cursor-pointer'
|
||||
onClick={goRead}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openActions();
|
||||
}}
|
||||
onDragStart={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
{...longPressProps}
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
touchAction: 'manipulation',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='relative aspect-[3/4] overflow-hidden bg-gray-100 dark:bg-gray-800'
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{
|
||||
WebkitTouchCallout: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{item.cover ? (
|
||||
<ProxyImage
|
||||
originalSrc={item.cover}
|
||||
alt={item.title}
|
||||
className='h-full w-full object-cover transition duration-300 group-hover:scale-105 pointer-events-none'
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-full items-center justify-center text-sm text-gray-400'>暂无封面</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='space-y-1 p-3'>
|
||||
<div className='line-clamp-2 min-h-[2.75rem] text-sm font-semibold text-gray-900 dark:text-gray-100'>
|
||||
{item.title}
|
||||
</div>
|
||||
<div className='text-xs text-gray-500 dark:text-gray-400'>{item.sourceName}</div>
|
||||
<div className='line-clamp-2 text-xs text-sky-600 dark:text-sky-400'>{subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MobileActionSheet
|
||||
isOpen={showActions}
|
||||
onClose={() => setShowActions(false)}
|
||||
title={item.title}
|
||||
poster={processImageUrl(item.cover)}
|
||||
sourceName={item.sourceName}
|
||||
actions={actions}
|
||||
onPosterClick={() => setShowImageViewer(true)}
|
||||
/>
|
||||
|
||||
{showImageViewer && (
|
||||
<ImageViewer
|
||||
isOpen={showImageViewer}
|
||||
onClose={() => setShowImageViewer(false)}
|
||||
imageUrl={item.cover}
|
||||
alt={item.title}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user