优化漫画界面

This commit is contained in:
mtvpls
2026-04-16 23:28:19 +08:00
parent 68274900b4
commit d3b267380f
14 changed files with 1593 additions and 1275 deletions
+2
View File
@@ -0,0 +1,2 @@
# 强制所有文本文件在仓库中用 LF
* text=auto eol=lf
-10
View File
@@ -1,10 +0,0 @@
{
"Region CSS": {
"prefix": "regc",
"body": [
"/* #region /**=========== ${1} =========== */",
"$0",
"/* #endregion /**======== ${1} =========== */"
]
}
}
-9
View File
@@ -1,9 +0,0 @@
{
"recommendations": [
// Tailwind CSS Intellisense
"bradlc.vscode-tailwindcss",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"aaron-bond.better-comments"
]
}
-17
View File
@@ -1,17 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"skipFiles": [
"<node_internals>/**"
],
"program": "${workspaceFolder}/start"
}
]
}
-17
View File
@@ -1,17 +0,0 @@
{
"css.validate": false,
"editor.formatOnSave": true,
"editor.tabSize": 2,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
},
"[css]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
// Tailwind CSS Autocomplete, add more if used in projects
"tailwindCSS.classAttributes": [
"class",
"className",
"classNames",
"containerClassName"
],
"typescript.preferences.importModuleSpecifier": "non-relative"
}
-193
View File
@@ -1,193 +0,0 @@
{
//#region //*=========== React ===========
"import React": {
"prefix": "ir",
"body": ["import * as React from 'react';"]
},
"React.useState": {
"prefix": "us",
"body": [
"const [${1}, set${1/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}] = React.useState<$3>(${2:initial${1/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}})$0"
]
},
"React.useEffect": {
"prefix": "uf",
"body": ["React.useEffect(() => {", " $0", "}, []);"]
},
"React.useReducer": {
"prefix": "ur",
"body": [
"const [state, dispatch] = React.useReducer(${0:someReducer}, {",
" ",
"})"
]
},
"React.useRef": {
"prefix": "urf",
"body": ["const ${1:someRef} = React.useRef($0)"]
},
"React Functional Component": {
"prefix": "rc",
"body": [
"import * as React from 'react';\n",
"export default function ${1:${TM_FILENAME_BASE}}() {",
" return (",
" <div>",
" $0",
" </div>",
" )",
"}"
]
},
"React Functional Component with Props": {
"prefix": "rcp",
"body": [
"import * as React from 'react';\n",
"import clsxm from '@/lib/clsxm';\n",
"type ${1:${TM_FILENAME_BASE}}Props= {\n",
"} & React.ComponentPropsWithoutRef<'div'>\n",
"export default function ${1:${TM_FILENAME_BASE}}({className, ...rest}: ${1:${TM_FILENAME_BASE}}Props) {",
" return (",
" <div className={clsxm(['', className])} {...rest}>",
" $0",
" </div>",
" )",
"}"
]
},
//#endregion //*======== React ===========
//#region //*=========== Commons ===========
"Region": {
"prefix": "reg",
"scope": "javascript, typescript, javascriptreact, typescriptreact",
"body": [
"//#region //*=========== ${1} ===========",
"${TM_SELECTED_TEXT}$0",
"//#endregion //*======== ${1} ==========="
]
},
"Region CSS": {
"prefix": "regc",
"scope": "css, scss",
"body": [
"/* #region /**=========== ${1} =========== */",
"${TM_SELECTED_TEXT}$0",
"/* #endregion /**======== ${1} =========== */"
]
},
//#endregion //*======== Commons ===========
//#region //*=========== Next.js ===========
"Next Pages": {
"prefix": "np",
"body": [
"import * as React from 'react';\n",
"import Layout from '@/components/layout/Layout';",
"import Seo from '@/components/Seo';\n",
"export default function ${1:${TM_FILENAME_BASE/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}}Page() {",
" return (",
" <Layout>",
" <Seo templateTitle='${1:${TM_FILENAME_BASE/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}}' />\n",
" <main>\n",
" <section className=''>",
" <div className='layout py-20 min-h-screen'>",
" $0",
" </div>",
" </section>",
" </main>",
" </Layout>",
" )",
"}"
]
},
"Next API": {
"prefix": "napi",
"body": [
"import { NextApiRequest, NextApiResponse } from 'next';\n",
"export default async function handler(req: NextApiRequest, res: NextApiResponse) {",
" if (req.method === 'GET') {",
" res.status(200).json({ name: 'Bambang' });",
" } else {",
" res.status(405).json({ message: 'Method Not Allowed' });",
" }",
"}"
]
},
"Get Static Props": {
"prefix": "gsp",
"body": [
"export const getStaticProps = async (context: GetStaticPropsContext) => {",
" return {",
" props: {}",
" };",
"}"
]
},
"Get Static Paths": {
"prefix": "gspa",
"body": [
"export const getStaticPaths: GetStaticPaths = async () => {",
" return {",
" paths: [",
" { params: { $1 }}",
" ],",
" fallback: ",
" };",
"}"
]
},
"Get Server Side Props": {
"prefix": "gssp",
"body": [
"export const getServerSideProps = async (context: GetServerSidePropsContext) => {",
" return {",
" props: {}",
" };",
"}"
]
},
"Infer Get Static Props": {
"prefix": "igsp",
"body": "InferGetStaticPropsType<typeof getStaticProps>"
},
"Infer Get Server Side Props": {
"prefix": "igssp",
"body": "InferGetServerSidePropsType<typeof getServerSideProps>"
},
"Import useRouter": {
"prefix": "imust",
"body": ["import { useRouter } from 'next/router';"]
},
"Import Next Image": {
"prefix": "imimg",
"body": ["import Image from 'next/image';"]
},
"Import Next Link": {
"prefix": "iml",
"body": ["import Link from 'next/link';"]
},
//#endregion //*======== Next.js ===========
//#region //*=========== Snippet Wrap ===========
"Wrap with Fragment": {
"prefix": "ff",
"body": ["<>", "\t${TM_SELECTED_TEXT}", "</>"]
},
"Wrap with clsx": {
"prefix": "cx",
"body": ["{clsx([${TM_SELECTED_TEXT}$0])}"]
},
"Wrap with clsxm": {
"prefix": "cxm",
"body": ["{clsxm([${TM_SELECTED_TEXT}$0, className])}"]
},
//#endregion //*======== Snippet Wrap ===========
"Logger": {
"prefix": "lg",
"body": [
"logger({ ${1:${CLIPBOARD}} }, '${TM_FILENAME} line ${TM_LINE_NUMBER}')"
]
}
}
+40 -1
View File
@@ -10,6 +10,44 @@ import { MangaChapter, MangaDetail, MangaReadRecord, MangaShelfItem } from '@/li
import ProxyImage from '@/components/ProxyImage';
function MangaDetailSkeleton() {
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='aspect-[3/4] animate-pulse rounded-3xl bg-gray-200 dark:bg-gray-800' />
<div className='space-y-4'>
<div className='h-8 w-2/3 animate-pulse rounded bg-gray-200 dark:bg-gray-800' />
<div className='flex gap-2'>
<div className='h-7 w-24 animate-pulse rounded-full bg-gray-200 dark:bg-gray-800' />
<div className='h-7 w-20 animate-pulse rounded-full bg-gray-200 dark:bg-gray-800' />
</div>
<div className='space-y-3'>
<div className='h-4 w-full animate-pulse rounded bg-gray-200 dark:bg-gray-800' />
<div className='h-4 w-11/12 animate-pulse rounded bg-gray-200 dark:bg-gray-800' />
<div className='h-4 w-4/5 animate-pulse rounded bg-gray-200 dark:bg-gray-800' />
</div>
<div className='flex flex-wrap gap-3'>
<div className='h-12 w-32 animate-pulse rounded-2xl bg-gray-200 dark:bg-gray-800' />
<div className='h-12 w-40 animate-pulse rounded-2xl bg-gray-200 dark:bg-gray-800' />
<div className='h-12 w-32 animate-pulse rounded-2xl bg-gray-200 dark:bg-gray-800' />
</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 h-6 w-32 animate-pulse rounded bg-gray-200 dark:bg-gray-800' />
<div className='space-y-3'>
{Array.from({ length: 8 }).map((_, index) => (
<div key={index} className='rounded-2xl border border-gray-200 px-4 py-3 dark:border-gray-700'>
<div className='h-4 w-1/3 animate-pulse rounded bg-gray-200 dark:bg-gray-800' />
<div className='mt-2 h-3 w-1/4 animate-pulse rounded bg-gray-200 dark:bg-gray-800' />
</div>
))}
</div>
</div>
</div>
);
}
function formatChapterMeta(chapter: MangaChapter): string {
if (typeof chapter.pageCount === 'number' && chapter.pageCount > 0) {
return `${chapter.pageCount}`;
@@ -104,7 +142,8 @@ export default function MangaDetailPage() {
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>;
if (!detail) return <MangaDetailSkeleton />;
return (
<div className='space-y-6'>
+24 -2
View File
@@ -8,11 +8,31 @@ import { MangaReadRecord } from '@/lib/manga.types';
import MangaCard from '@/components/MangaCard';
function MangaHistorySkeleton() {
return (
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
{Array.from({ length: 12 }).map((_, index) => (
<div key={index} className='overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-950'>
<div className='aspect-[3/4] w-full animate-pulse bg-gray-200 dark:bg-gray-800' />
<div className='space-y-3 p-3'>
<div className='h-4 w-3/4 animate-pulse rounded bg-gray-200 dark:bg-gray-800' />
<div className='h-3 w-2/3 animate-pulse rounded bg-gray-200 dark:bg-gray-800' />
</div>
</div>
))}
</div>
);
}
export default function MangaHistoryPage() {
const [history, setHistory] = useState<Record<string, MangaReadRecord>>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
getAllMangaReadRecords().then(setHistory).catch(() => undefined);
getAllMangaReadRecords()
.then(setHistory)
.catch(() => undefined)
.finally(() => setLoading(false));
}, []);
const historyList = useMemo(
@@ -25,7 +45,9 @@ export default function MangaHistoryPage() {
<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 ? (
{loading ? (
<MangaHistorySkeleton />
) : 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>
+22 -2
View File
@@ -8,6 +8,21 @@ import { MangaSearchItem, MangaShelfItem, MangaSource } from '@/lib/manga.types'
import MangaCard from '@/components/MangaCard';
function MangaCardSkeleton({ withButton = false }: { withButton?: boolean }) {
return (
<div className='space-y-2'>
<div className='overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-950'>
<div className='aspect-[3/4] w-full animate-pulse bg-gray-200 dark:bg-gray-800' />
<div className='space-y-3 p-3'>
<div className='h-4 w-3/4 animate-pulse rounded bg-gray-200 dark:bg-gray-800' />
<div className='h-3 w-1/2 animate-pulse rounded bg-gray-200 dark:bg-gray-800' />
</div>
</div>
{withButton && <div className='h-9 w-full animate-pulse rounded-2xl bg-gray-200 dark:bg-gray-800' />}
</div>
);
}
export default function MangaPage() {
const [query, setQuery] = useState('');
const [sources, setSources] = useState<MangaSource[]>([]);
@@ -108,10 +123,15 @@ export default function MangaPage() {
<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 ? (
{loading ? (
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
{Array.from({ length: 12 }).map((_, index) => (
<MangaCardSkeleton key={index} withButton />
))}
</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>
+482 -40
View File
@@ -3,8 +3,10 @@
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { type MouseEvent, useEffect, useMemo, useRef, useState } from 'react';
import { ArrowDownWideNarrow, ArrowUpWideNarrow } from 'lucide-react';
import { saveMangaReadRecord } from '@/lib/db.client';
import { getAllMangaReadRecords, saveMangaReadRecord } from '@/lib/db.client';
import type { MangaChapter, MangaDetail, MangaReadRecord } from '@/lib/manga.types';
import ProxyImage from '@/components/ProxyImage';
@@ -14,6 +16,7 @@ type ScaleMode = 'fit' | 'original';
const READ_MODE_STORAGE_KEY = 'mangaReadMode';
const SCALE_MODE_STORAGE_KEY = 'mangaScaleMode';
const PAGE_GAP_STORAGE_KEY = 'mangaPageGap';
const SAVE_INTERVAL_MS = 10000;
const READ_MODE_OPTIONS: Array<{ value: ReadMode; label: string }> = [
{ value: 'single', label: '单页' },
@@ -27,6 +30,43 @@ const SCALE_MODE_OPTIONS: Array<{ value: ScaleMode; label: string }> = [
{ value: 'original', label: '原始大小' },
];
function MangaReadSkeleton({ readMode, pageGap }: { readMode: ReadMode; pageGap: number }) {
if (readMode === 'horizontal') {
return (
<div className='flex min-h-[calc(100vh-8rem)] overflow-hidden' style={{ gap: `${pageGap}px` }}>
{Array.from({ length: 2 }).map((_, index) => (
<div key={index} className='flex min-w-full items-center justify-center px-1'>
<div className='h-full min-h-[calc(100vh-8rem)] w-full animate-pulse bg-gray-100 dark:bg-gray-900' />
</div>
))}
</div>
);
}
if (readMode === 'single' || readMode === 'double') {
return (
<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` }}
>
{Array.from({ length: readMode === 'double' ? 2 : 1 }).map((_, index) => (
<div key={index} className='min-h-[calc(100vh-8rem)] animate-pulse bg-gray-100 dark:bg-gray-900' />
))}
</div>
</div>
);
}
return (
<div className='flex flex-col' style={{ gap: `${pageGap}px` }}>
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className='aspect-[3/4] animate-pulse bg-gray-100 dark:bg-gray-900' />
))}
</div>
);
}
export default function MangaReadPage() {
const searchParams = useSearchParams();
const mangaId = searchParams.get('mangaId') || '';
@@ -44,9 +84,42 @@ export default function MangaReadPage() {
const [pageGap, setPageGap] = useState(0);
const [controlsVisible, setControlsVisible] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [chapterListOpen, setChapterListOpen] = useState(false);
const [chapterListDesc, setChapterListDesc] = useState(false);
const [mangaDetail, setMangaDetail] = useState<MangaDetail | null>(null);
const [showChapterComplete, setShowChapterComplete] = useState(false);
const verticalPageRefs = useRef<Array<HTMLDivElement | null>>([]);
const horizontalContainerRef = useRef<HTMLDivElement | null>(null);
const pendingRecordRef = useRef<MangaReadRecord | null>(null);
const pendingRecordDirtyRef = useRef(false);
const saveInFlightRef = useRef(false);
const restoredChapterKeyRef = useRef<string | null>(null);
const previousReadModeRef = useRef<ReadMode>('vertical');
const requestVerticalPageSyncRef = useRef<(() => void) | null>(null);
const currentVerticalPageIndexRef = useRef(0);
const getCurrentVerticalPageIndex = () => {
if (!verticalPageRefs.current.length) return 0;
const topAnchor = 80;
let currentIndex = 0;
for (let index = 0; index < verticalPageRefs.current.length; index += 1) {
const node = verticalPageRefs.current[index];
if (!node) continue;
const rect = node.getBoundingClientRect();
if (rect.top <= topAnchor) {
currentIndex = index;
}
}
return currentIndex;
};
const handleVerticalImageLoad = () => {
requestVerticalPageSyncRef.current?.();
};
useEffect(() => {
if (typeof window === 'undefined') return;
@@ -83,6 +156,7 @@ export default function MangaReadPage() {
const handleToggleSettings = () => {
setSettingsOpen((prev) => !prev);
setControlsVisible(false);
setChapterListOpen(false);
};
window.addEventListener('manga-read-toggle-settings', handleToggleSettings);
@@ -91,6 +165,19 @@ export default function MangaReadPage() {
};
}, []);
useEffect(() => {
const handleToggleChapters = () => {
setChapterListOpen((prev) => !prev);
setControlsVisible(false);
setSettingsOpen(false);
};
window.addEventListener('manga-read-toggle-chapters', handleToggleChapters);
return () => {
window.removeEventListener('manga-read-toggle-chapters', handleToggleChapters);
};
}, []);
useEffect(() => {
if (!chapterId) return;
fetch(`/api/manga/pages?chapterId=${encodeURIComponent(chapterId)}`)
@@ -99,27 +186,145 @@ export default function MangaReadPage() {
.catch(() => setPages([]));
}, [chapterId]);
useEffect(() => {
if (!mangaId || !sourceId) return;
const params = new URLSearchParams({
mangaId,
sourceId,
title,
cover,
sourceName,
});
fetch(`/api/manga/detail?${params.toString()}`)
.then((res) => res.json())
.then((data) => setMangaDetail(data))
.catch(() => setMangaDetail(null));
}, [cover, mangaId, sourceId, sourceName, title]);
useEffect(() => {
setActivePage(0);
}, [chapterId, readMode]);
restoredChapterKeyRef.current = null;
}, [chapterId]);
useEffect(() => {
if (!pages.length || !mangaId || !sourceId || !chapterId) return;
const chapterKey = `${sourceId}+${mangaId}+${chapterId}`;
if (restoredChapterKeyRef.current === chapterKey) return;
let cancelled = false;
getAllMangaReadRecords()
.then((records) => {
if (cancelled) return;
const record = records[`${sourceId}+${mangaId}`];
if (!record || record.chapterId !== chapterId) {
restoredChapterKeyRef.current = chapterKey;
return;
}
const nextPage = Math.min(Math.max(record.pageIndex || 0, 0), Math.max(pages.length - 1, 0));
setActivePage(nextPage);
restoredChapterKeyRef.current = chapterKey;
if (readMode === 'vertical') {
window.setTimeout(() => {
const node = verticalPageRefs.current[nextPage];
node?.scrollIntoView({ block: 'start' });
}, 0);
return;
}
if (readMode === 'horizontal') {
window.setTimeout(() => {
const container = horizontalContainerRef.current;
if (!container) return;
container.scrollTo({
left: container.clientWidth * nextPage,
behavior: 'auto',
});
}, 0);
}
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [chapterId, mangaId, pages.length, readMode, sourceId]);
useEffect(() => {
setShowChapterComplete(false);
}, [activePage, 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 }
);
let ticking = false;
let rafId = 0;
let lastScrollTop = -1;
let lastInnerHeight = -1;
const scrollingElement = document.scrollingElement || document.documentElement;
verticalPageRefs.current.forEach((node) => node && observer.observe(node));
return () => observer.disconnect();
const updateActivePageFromViewport = () => {
ticking = false;
const nextIndex = getCurrentVerticalPageIndex();
currentVerticalPageIndexRef.current = nextIndex;
setActivePage((prev) => (prev === nextIndex ? prev : nextIndex));
};
const requestUpdate = () => {
if (ticking) return;
ticking = true;
window.requestAnimationFrame(updateActivePageFromViewport);
};
requestVerticalPageSyncRef.current = requestUpdate;
const watchScrollPosition = () => {
const nextScrollTop = scrollingElement.scrollTop;
const nextInnerHeight = window.innerHeight;
if (nextScrollTop !== lastScrollTop || nextInnerHeight !== lastInnerHeight) {
lastScrollTop = nextScrollTop;
lastInnerHeight = nextInnerHeight;
requestUpdate();
}
rafId = window.requestAnimationFrame(watchScrollPosition);
};
requestUpdate();
rafId = window.requestAnimationFrame(watchScrollPosition);
window.addEventListener('scroll', requestUpdate, { passive: true });
document.addEventListener('scroll', requestUpdate, { passive: true, capture: true });
window.addEventListener('resize', requestUpdate);
let resizeObserver: ResizeObserver | null = null;
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => {
requestUpdate();
});
verticalPageRefs.current.forEach((node) => {
if (node) {
resizeObserver?.observe(node);
}
});
}
return () => {
requestVerticalPageSyncRef.current = null;
window.cancelAnimationFrame(rafId);
window.removeEventListener('scroll', requestUpdate);
document.removeEventListener('scroll', requestUpdate, true);
window.removeEventListener('resize', requestUpdate);
resizeObserver?.disconnect();
};
}, [readMode, pages, mangaId, sourceId, chapterId]);
useEffect(() => {
@@ -138,28 +343,137 @@ export default function MangaReadPage() {
}, [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);
if (!pages.length) {
previousReadModeRef.current = readMode;
return;
}
return () => window.clearTimeout(timer);
}, [activePage, chapterId, chapterName, cover, mangaId, pages.length, sourceId, sourceName, title]);
const previousReadMode = previousReadModeRef.current;
previousReadModeRef.current = readMode;
if (previousReadMode === readMode) return;
const targetPage = Math.min(Math.max(activePage, 0), Math.max(pages.length - 1, 0));
if (readMode === 'vertical') {
window.setTimeout(() => {
const node = verticalPageRefs.current[targetPage];
node?.scrollIntoView({ block: 'start' });
}, 0);
return;
}
if (readMode === 'horizontal') {
window.setTimeout(() => {
const container = horizontalContainerRef.current;
if (!container) return;
container.scrollTo({
left: container.clientWidth * targetPage,
behavior: 'auto',
});
}, 0);
}
}, [activePage, pages.length, readMode]);
useEffect(() => {
if (!pages.length || !mangaId || !sourceId || !chapterId) return;
const currentPageIndex = readMode === 'vertical' ? currentVerticalPageIndexRef.current : activePage;
pendingRecordRef.current = {
title,
cover,
sourceId,
sourceName,
mangaId,
chapterId,
chapterName,
pageIndex: currentPageIndex,
pageCount: pages.length,
saveTime: Date.now(),
};
pendingRecordDirtyRef.current = true;
}, [activePage, chapterId, chapterName, cover, mangaId, pages.length, readMode, sourceId, sourceName, title]);
useEffect(() => {
if (!mangaId || !sourceId || !chapterId) return;
const flushPendingRecord = () => {
const record = pendingRecordRef.current;
if (!record || !pendingRecordDirtyRef.current || saveInFlightRef.current) return;
const recordToSave =
readMode === 'vertical'
? {
...record,
pageIndex: getCurrentVerticalPageIndex(),
}
: record;
saveInFlightRef.current = true;
saveMangaReadRecord(sourceId, mangaId, {
...recordToSave,
saveTime: Date.now(),
})
.then(() => {
pendingRecordRef.current = recordToSave;
pendingRecordDirtyRef.current = false;
})
.catch(() => undefined)
.finally(() => {
saveInFlightRef.current = false;
});
};
const flushPendingRecordOnLeave = () => {
if (!pendingRecordDirtyRef.current || saveInFlightRef.current) return;
flushPendingRecord();
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
flushPendingRecordOnLeave();
}
};
const intervalId = window.setInterval(flushPendingRecord, SAVE_INTERVAL_MS);
window.addEventListener('pagehide', flushPendingRecordOnLeave);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.clearInterval(intervalId);
window.removeEventListener('pagehide', flushPendingRecordOnLeave);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [chapterId, mangaId, readMode, sourceId]);
const hideTransientUi = () => {
setControlsVisible(false);
setSettingsOpen(false);
setChapterListOpen(false);
setShowChapterComplete(false);
};
const isAtChapterEnd = () => {
if (!pages.length) return false;
if (readMode === 'double') {
return activePage >= Math.max(pages.length - 2, 0);
}
if (readMode === 'vertical') {
const scrollBottom = window.scrollY + window.innerHeight;
const pageBottom = document.documentElement.scrollHeight;
return activePage >= pages.length - 1 && scrollBottom >= pageBottom - 24;
}
return activePage >= pages.length - 1;
};
const openChapterComplete = () => {
if (!isAtChapterEnd()) return false;
setShowChapterComplete(true);
setControlsVisible(false);
setSettingsOpen(false);
return true;
};
const clampPage = (page: number) => {
@@ -197,17 +511,20 @@ export default function MangaReadPage() {
const goNext = () => {
if (!pages.length) return;
if (readMode === 'vertical') {
if (openChapterComplete()) return;
window.scrollBy({ top: window.innerHeight * 0.85, behavior: 'smooth' });
hideTransientUi();
return;
}
if (readMode === 'horizontal') {
if (openChapterComplete()) return;
const nextPage = clampPage(activePage + 1);
setActivePage(nextPage);
scrollHorizontalToPage(nextPage);
hideTransientUi();
return;
}
if (openChapterComplete()) return;
setActivePage((prev) => clampPage(prev + (readMode === 'double' ? 2 : 1)));
hideTransientUi();
};
@@ -217,6 +534,36 @@ export default function MangaReadPage() {
[activePage, pages.length]
);
const nextChapter = useMemo<MangaChapter | null>(() => {
const chapters = mangaDetail?.chapters || [];
if (!chapters.length) return null;
const orderedChapters = [...chapters].sort((a, b) => {
const chapterDiff = (a.chapterNumber ?? 0) - (b.chapterNumber ?? 0);
if (chapterDiff !== 0) return chapterDiff;
return (a.uploadDate ?? 0) - (b.uploadDate ?? 0);
});
const currentIndex = orderedChapters.findIndex((item) => item.id === chapterId);
if (currentIndex === -1 || currentIndex >= orderedChapters.length - 1) return null;
return orderedChapters[currentIndex + 1];
}, [chapterId, mangaDetail?.chapters]);
const chapterList = useMemo(() => {
const chapters = mangaDetail?.chapters || [];
return [...chapters].sort((a, b) => {
const chapterDiff = (a.chapterNumber ?? 0) - (b.chapterNumber ?? 0);
if (chapterDiff !== 0) return chapterDiff;
return (a.uploadDate ?? 0) - (b.uploadDate ?? 0);
});
}, [mangaDetail?.chapters]);
const orderedChapterList = useMemo(
() => (chapterListDesc ? [...chapterList].reverse() : chapterList),
[chapterList, chapterListDesc]
);
const pagedItems = useMemo(() => {
if (readMode === 'single') {
return pages[activePage] ? [pages[activePage]] : [];
@@ -231,11 +578,20 @@ export default function MangaReadPage() {
if (scaleMode === 'original') {
return 'mx-auto h-auto w-auto max-w-none object-none';
}
if (readMode === 'single' || readMode === 'double') {
return 'mx-auto max-h-[calc(100vh-8rem)] w-auto max-w-full object-contain';
}
return 'h-auto w-full object-contain';
}, [scaleMode]);
}, [readMode, scaleMode]);
const handleReaderClick = (event: MouseEvent<HTMLDivElement>) => {
if (settingsOpen) return;
if (readMode === 'vertical') {
setControlsVisible((prev) => !prev);
setShowChapterComplete(false);
return;
}
const { clientX } = event;
const width = window.innerWidth;
const leftBoundary = width / 3;
@@ -341,10 +697,85 @@ export default function MangaReadPage() {
</div>
)}
{chapterListOpen && (
<div className='fixed inset-0 z-40 bg-black/30' onClick={() => setChapterListOpen(false)}>
<div
className='absolute right-0 top-14 h-[calc(100vh-3.5rem)] w-full max-w-sm overflow-y-auto border-l border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-950 sm:top-16 sm:h-[calc(100vh-4rem)]'
onClick={(event) => event.stopPropagation()}
>
<div className='p-4'>
<div className='mb-3 flex items-center justify-end'>
<button
type='button'
className='inline-flex items-center gap-2 rounded-2xl border border-gray-200 px-3 py-2 text-sm text-gray-700 transition hover:bg-gray-100 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900'
onClick={() => setChapterListDesc((prev) => !prev)}
>
{chapterListDesc ? <ArrowDownWideNarrow className='h-4 w-4' /> : <ArrowUpWideNarrow className='h-4 w-4' />}
{chapterListDesc ? '倒序' : '正序'}
</button>
</div>
<div className='space-y-2'>
{orderedChapterList.map((chapter) => {
const active = chapter.id === chapterId;
return (
<Link
key={chapter.id}
href={`/manga/read?mangaId=${mangaId}&sourceId=${sourceId}&chapterId=${chapter.id}&title=${encodeURIComponent(title)}&cover=${encodeURIComponent(cover)}&sourceName=${encodeURIComponent(sourceName)}&chapterName=${encodeURIComponent(chapter.name)}`}
className={`group relative block rounded-2xl px-4 py-3 text-sm transition ${
active
? 'bg-sky-600 text-white'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900'
}`}
onClick={() => setChapterListOpen(false)}
>
<span className='block truncate'>{chapter.name}</span>
<div className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 bg-gray-800 dark:bg-gray-900 text-white text-sm rounded-lg shadow-xl opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 ease-out whitespace-nowrap z-[100] pointer-events-none'>
<div className='text-sm'>{chapter.name}</div>
</div>
</Link>
);
})}
</div>
</div>
</div>
</div>
)}
<div
className='relative min-h-[calc(100vh-5rem)] select-none px-2 py-3 sm:px-3'
onClick={handleReaderClick}
>
{showChapterComplete && (
<div className='fixed inset-0 z-30 flex items-center justify-center bg-black/60 px-4' onClick={() => setShowChapterComplete(false)}>
<div
className='w-full max-w-sm rounded-3xl border border-gray-200 bg-white p-6 text-center shadow-xl dark:border-gray-700 dark:bg-gray-950'
onClick={(event) => event.stopPropagation()}
>
<div className='text-lg font-semibold text-gray-900 dark:text-gray-100'>{chapterName} </div>
<div className='mt-2 text-sm text-gray-500 dark:text-gray-400'>
{nextChapter ? '当前章节已读完,可继续阅读下一话' : '当前章节已读完'}
</div>
<div className='mt-6 flex flex-col gap-3'>
{nextChapter ? (
<Link
href={`/manga/read?mangaId=${mangaId}&sourceId=${sourceId}&chapterId=${nextChapter.id}&title=${encodeURIComponent(title)}&cover=${encodeURIComponent(cover)}&sourceName=${encodeURIComponent(sourceName)}&chapterName=${encodeURIComponent(nextChapter.name)}`}
className='rounded-2xl bg-sky-600 px-4 py-3 text-sm font-medium text-white transition hover:bg-sky-700'
>
{nextChapter.name}
</Link>
) : null}
<button
type='button'
className='rounded-2xl border border-gray-200 px-4 py-3 text-sm font-medium text-gray-700 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900'
onClick={() => setShowChapterComplete(false)}
>
</button>
</div>
</div>
</div>
)}
<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'
@@ -356,10 +787,16 @@ export default function MangaReadPage() {
/>
</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'>
...
{pages.length > 0 && (
<div className='pointer-events-none fixed bottom-4 left-1/2 z-20 -translate-x-1/2 rounded-full bg-black/15 px-2 py-0.5 text-sm font-medium text-white/90 backdrop-blur-sm dark:bg-white/10 dark:text-white/85'>
{Math.min(activePage + 1, pages.length)}/{pages.length}
</div>
)}
{pages.length === 0 && <MangaReadSkeleton readMode={readMode} pageGap={pageGap} />}
{pages.length === 0 ? (
null
) : readMode === 'vertical' ? (
<div className='flex flex-col' style={{ gap: `${pageGap}px` }}>
{pages.map((page, index) => (
@@ -369,9 +806,14 @@ export default function MangaReadPage() {
verticalPageRefs.current[index] = node;
}}
data-index={index}
className='overflow-hidden rounded-[24px] bg-gray-100 shadow-sm dark:bg-gray-900'
className='overflow-hidden bg-gray-100 shadow-sm dark:bg-gray-900'
>
<ProxyImage originalSrc={page} alt={`${chapterName}-${index + 1}`} className={imageClassName} />
<ProxyImage
originalSrc={page}
alt={`${chapterName}-${index + 1}`}
className={imageClassName}
onLoad={handleVerticalImageLoad}
/>
</div>
))}
</div>
@@ -383,7 +825,7 @@ export default function MangaReadPage() {
>
{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'>
<div className='w-full overflow-hidden bg-gray-100 shadow-sm dark:bg-gray-900'>
<ProxyImage originalSrc={page} alt={`${chapterName}-${index + 1}`} className={imageClassName} />
</div>
</div>
@@ -395,7 +837,7 @@ export default function MangaReadPage() {
{pagedItems.map((page, index) => (
<div
key={`${page}-${index}`}
className='overflow-hidden rounded-[24px] bg-gray-100 shadow-sm dark:bg-gray-900'
className='overflow-hidden bg-gray-100 shadow-sm dark:bg-gray-900'
>
<ProxyImage
originalSrc={page}
+27 -2
View File
@@ -8,11 +8,34 @@ import { MangaShelfItem } from '@/lib/manga.types';
import MangaCard from '@/components/MangaCard';
function MangaShelfSkeleton() {
return (
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
{Array.from({ length: 12 }).map((_, index) => (
<div key={index} className='space-y-2'>
<div className='overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-950'>
<div className='aspect-[3/4] w-full animate-pulse bg-gray-200 dark:bg-gray-800' />
<div className='space-y-3 p-3'>
<div className='h-4 w-3/4 animate-pulse rounded bg-gray-200 dark:bg-gray-800' />
<div className='h-3 w-1/2 animate-pulse rounded bg-gray-200 dark:bg-gray-800' />
</div>
</div>
<div className='h-9 w-full animate-pulse rounded-2xl bg-gray-200 dark:bg-gray-800' />
</div>
))}
</div>
);
}
export default function MangaShelfPage() {
const [shelf, setShelf] = useState<Record<string, MangaShelfItem>>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
getAllMangaShelf().then(setShelf).catch(() => undefined);
getAllMangaShelf()
.then(setShelf)
.catch(() => undefined)
.finally(() => setLoading(false));
}, []);
const shelfList = useMemo(
@@ -35,7 +58,9 @@ export default function MangaShelfPage() {
<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 ? (
{loading ? (
<MangaShelfSkeleton />
) : 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>
+78 -64
View File
@@ -1,6 +1,6 @@
'use client';
'use client';
import { BookOpen, ChevronLeft, History, Home, Search, Settings2 } from 'lucide-react';
import { BookOpen, ChevronLeft, History, Home, List, Search, Settings2 } from 'lucide-react';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
@@ -62,66 +62,83 @@ export default function MangaLayout({ children }: MangaLayoutProps) {
return (
<div className='min-h-screen bg-gray-50 text-gray-900 dark:bg-black dark:text-gray-100'>
<header className='fixed inset-x-0 top-0 z-[999] border-b border-gray-200/70 bg-white/85 backdrop-blur-xl shadow-sm dark:border-gray-800/80 dark:bg-gray-950/85'>
<div className='mx-auto flex h-14 max-w-7xl items-center gap-3 px-3 sm:h-16 sm:px-6'>
<div className='flex min-w-0 items-center gap-2'>
{meta.backHref ? (
<Link
href={meta.backHref}
className='flex h-10 w-10 items-center justify-center rounded-full text-gray-600 transition hover:bg-gray-100 hover:text-sky-600 dark:text-gray-300 dark:hover:bg-gray-800'
>
<ChevronLeft className='h-5 w-5' />
</Link>
) : (
<Link
href='/'
className='flex h-10 items-center rounded-full px-3 text-sm font-semibold text-sky-600 transition hover:bg-sky-50 dark:hover:bg-sky-950/40'
>
{siteName}
</Link>
)}
<div className='min-w-0'>
<div className='mx-auto flex h-14 max-w-7xl items-center gap-3 px-3 sm:h-16 sm:px-6'>
<div className='flex min-w-0 items-center gap-2'>
{meta.backHref ? (
<Link
href={meta.backHref}
className='flex h-10 w-10 items-center justify-center rounded-full text-gray-600 transition hover:bg-gray-100 hover:text-sky-600 dark:text-gray-300 dark:hover:bg-gray-800'
>
<ChevronLeft className='h-5 w-5' />
</Link>
) : (
<Link
href='/'
className='flex h-10 items-center rounded-full px-3 text-sm font-semibold text-sky-600 transition hover:bg-sky-50 dark:hover:bg-sky-950/40'
>
{siteName}
</Link>
)}
<div className='min-w-0'>
<div className='group relative'>
<div className='truncate text-sm font-semibold sm:text-base'>{meta.title}</div>
{meta.subtitle && (
<div className='truncate text-xs text-gray-500 dark:text-gray-400'>
{meta.subtitle}
</div>
)}
<div className='absolute top-full left-1/2 transform -translate-x-1/2 mt-2 px-3 py-2 bg-gray-800 dark:bg-gray-900 text-white text-sm rounded-lg shadow-xl opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 ease-out z-[100] pointer-events-none w-max max-w-[85vw] whitespace-normal break-words text-center sm:max-w-none sm:whitespace-nowrap'>
<div className='text-sm'>{meta.title}</div>
</div>
</div>
{meta.subtitle && (
<div className='truncate text-xs text-gray-500 dark:text-gray-400'>
{meta.subtitle}
</div>
)}
</div>
</div>
<nav className='ml-auto hidden items-center gap-2 lg:flex'>
{sectionTabs.map((tab) => {
const Icon = tab.icon;
const active = isActive(tab.href);
return (
<Link
key={tab.href}
href={tab.href}
className={`inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm transition ${
active
? 'bg-sky-600 text-white shadow-sm'
: 'text-gray-600 hover:bg-gray-100 hover:text-sky-600 dark:text-gray-300 dark:hover:bg-gray-800'
}`}
>
<Icon className='h-4 w-4' />
{tab.label}
</Link>
);
})}
</nav>
<nav className='ml-auto hidden items-center gap-2 lg:flex'>
{sectionTabs.map((tab) => {
const Icon = tab.icon;
const active = isActive(tab.href);
return (
<Link
key={tab.href}
href={tab.href}
className={`inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm transition ${
active
? 'bg-sky-600 text-white shadow-sm'
: 'text-gray-600 hover:bg-gray-100 hover:text-sky-600 dark:text-gray-300 dark:hover:bg-gray-800'
}`}
>
<Icon className='h-4 w-4' />
{tab.label}
</Link>
);
})}
</nav>
<div className={`${isReadingPage ? 'flex' : 'hidden md:flex'} items-center gap-2`}>
{isReadingPage ? (
<button
type='button'
className='inline-flex h-10 w-10 items-center justify-center rounded-full text-gray-600 transition hover:bg-gray-100 hover:text-sky-600 dark:text-gray-300 dark:hover:bg-gray-800'
onClick={() => {
window.dispatchEvent(new CustomEvent('manga-read-toggle-settings'));
}}
aria-label='阅读设置'
>
<Settings2 className='h-5 w-5' />
</button>
<>
<button
type='button'
className='inline-flex h-10 w-10 items-center justify-center rounded-full text-gray-600 transition hover:bg-gray-100 hover:text-sky-600 dark:text-gray-300 dark:hover:bg-gray-800'
onClick={() => {
window.dispatchEvent(new CustomEvent('manga-read-toggle-chapters'));
}}
aria-label='章节列表'
>
<List className='h-5 w-5' />
</button>
<button
type='button'
className='inline-flex h-10 w-10 items-center justify-center rounded-full text-gray-600 transition hover:bg-gray-100 hover:text-sky-600 dark:text-gray-300 dark:hover:bg-gray-800'
onClick={() => {
window.dispatchEvent(new CustomEvent('manga-read-toggle-settings'));
}}
aria-label='阅读设置'
>
<Settings2 className='h-5 w-5' />
</button>
</>
) : (
<>
<ThemeToggle />
@@ -134,15 +151,16 @@ export default function MangaLayout({ children }: MangaLayoutProps) {
</header>
<main
className='mx-auto max-w-7xl px-3 pb-24 pt-20 sm:px-6 sm:pb-28 sm:pt-24'
style={{ paddingBottom: isReadingPage ? undefined : 'calc(5rem + env(safe-area-inset-bottom))' }}
className={`mx-auto max-w-7xl px-3 pt-20 sm:px-6 sm:pt-24 ${
isReadingPage ? 'pb-24 sm:pb-28 lg:pb-10' : 'pb-[calc(5rem+env(safe-area-inset-bottom))] sm:pb-[calc(5rem+env(safe-area-inset-bottom))] lg:pb-10'
}`}
>
{children}
</main>
{!isReadingPage && (
<nav
className='fixed inset-x-0 bottom-0 z-[998] border-t border-gray-200/70 bg-white/92 backdrop-blur-xl dark:border-gray-800/80 dark:bg-gray-950/92'
className='fixed inset-x-0 bottom-0 z-[998] border-t border-gray-200/70 bg-white/92 backdrop-blur-xl dark:border-gray-800/80 dark:bg-gray-950/92 lg:hidden'
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
>
<div className='mx-auto grid max-w-3xl grid-cols-4'>
@@ -157,16 +175,12 @@ export default function MangaLayout({ children }: MangaLayoutProps) {
>
<Icon
className={`h-5 w-5 ${
active
? 'text-sky-600 dark:text-sky-400'
: 'text-gray-500 dark:text-gray-400'
active ? 'text-sky-600 dark:text-sky-400' : 'text-gray-500 dark:text-gray-400'
}`}
/>
<span
className={
active
? 'text-sky-600 dark:text-sky-400'
: 'text-gray-600 dark:text-gray-300'
active ? 'text-sky-600 dark:text-sky-400' : 'text-gray-600 dark:text-gray-300'
}
>
{tab.label}
Executable → Regular
View File