优化漫画界面
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# 强制所有文本文件在仓库中用 LF
|
||||
* text=auto eol=lf
|
||||
Vendored
-10
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"Region CSS": {
|
||||
"prefix": "regc",
|
||||
"body": [
|
||||
"/* #region /**=========== ${1} =========== */",
|
||||
"$0",
|
||||
"/* #endregion /**======== ${1} =========== */"
|
||||
]
|
||||
}
|
||||
}
|
||||
Vendored
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
// Tailwind CSS Intellisense
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"aaron-bond.better-comments"
|
||||
]
|
||||
}
|
||||
Vendored
-17
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
-17
@@ -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
@@ -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}')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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'>
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user