漫画阅读历史右键菜单

This commit is contained in:
mtvpls
2026-04-18 09:48:04 +08:00
parent 897d475ad5
commit 6360b0fa18
2 changed files with 217 additions and 8 deletions
+59 -8
View File
@@ -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>
+158
View File
@@ -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}
/>
)}
</>
);
}