漫画阅读历史右键菜单
This commit is contained in:
@@ -3,10 +3,10 @@
|
|||||||
import { History } from 'lucide-react';
|
import { History } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { getAllMangaReadRecords } from '@/lib/db.client';
|
import { deleteMangaReadRecord, deleteMangaShelf, getAllMangaReadRecords, getAllMangaShelf, saveMangaShelf, subscribeToDataUpdates } from '@/lib/db.client';
|
||||||
import { MangaReadRecord } from '@/lib/manga.types';
|
import { MangaReadRecord, MangaShelfItem } from '@/lib/manga.types';
|
||||||
|
|
||||||
import MangaCard from '@/components/MangaCard';
|
import MangaHistoryCard from '@/components/manga/MangaHistoryCard';
|
||||||
|
|
||||||
function MangaHistorySkeleton() {
|
function MangaHistorySkeleton() {
|
||||||
return (
|
return (
|
||||||
@@ -27,12 +27,24 @@ function MangaHistorySkeleton() {
|
|||||||
export default function MangaHistoryPage() {
|
export default function MangaHistoryPage() {
|
||||||
const [history, setHistory] = useState<Record<string, MangaReadRecord>>({});
|
const [history, setHistory] = useState<Record<string, MangaReadRecord>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [shelf, setShelf] = useState<Record<string, MangaShelfItem>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getAllMangaReadRecords()
|
Promise.all([getAllMangaReadRecords(), getAllMangaShelf()])
|
||||||
.then(setHistory)
|
.then(([historyData, shelfData]) => {
|
||||||
|
setHistory(historyData);
|
||||||
|
setShelf(shelfData);
|
||||||
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
.finally(() => setLoading(false));
|
.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(
|
const historyList = useMemo(
|
||||||
@@ -40,6 +52,44 @@ export default function MangaHistoryPage() {
|
|||||||
[history]
|
[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 (
|
return (
|
||||||
<section className='mx-auto max-w-6xl'>
|
<section className='mx-auto max-w-6xl'>
|
||||||
<div className='mb-4 flex items-center gap-2 text-sm text-gray-500'>
|
<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'>
|
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
||||||
{historyList.map(([key, item]) => (
|
{historyList.map(([key, item]) => (
|
||||||
<MangaCard
|
<MangaHistoryCard
|
||||||
key={key}
|
key={key}
|
||||||
item={item}
|
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)}`}
|
inShelf={!!shelf[`${item.sourceId}+${item.mangaId}`]}
|
||||||
subtitle={`${item.chapterName} · 第 ${item.pageIndex + 1}/${item.pageCount} 页`}
|
onToggleShelf={toggleShelf}
|
||||||
|
onDelete={deleteHistory}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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