优化漫画界面
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';
|
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 {
|
function formatChapterMeta(chapter: MangaChapter): string {
|
||||||
if (typeof chapter.pageCount === 'number' && chapter.pageCount > 0) {
|
if (typeof chapter.pageCount === 'number' && chapter.pageCount > 0) {
|
||||||
return `${chapter.pageCount} 页`;
|
return `${chapter.pageCount} 页`;
|
||||||
@@ -104,7 +142,8 @@ export default function MangaDetailPage() {
|
|||||||
const chapterHref = (chapter: MangaChapter) =>
|
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)}`;
|
`/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 (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
|
|||||||
@@ -8,11 +8,31 @@ import { MangaReadRecord } from '@/lib/manga.types';
|
|||||||
|
|
||||||
import MangaCard from '@/components/MangaCard';
|
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() {
|
export default function MangaHistoryPage() {
|
||||||
const [history, setHistory] = useState<Record<string, MangaReadRecord>>({});
|
const [history, setHistory] = useState<Record<string, MangaReadRecord>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getAllMangaReadRecords().then(setHistory).catch(() => undefined);
|
getAllMangaReadRecords()
|
||||||
|
.then(setHistory)
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const historyList = useMemo(
|
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'>
|
<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} 条阅读记录
|
<History className='h-4 w-4 text-violet-500' /> 共 {historyList.length} 条阅读记录
|
||||||
</div>
|
</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 className='rounded-2xl bg-gray-50 p-10 text-center text-sm text-gray-500 dark:bg-gray-900/50'>
|
||||||
暂无阅读历史
|
暂无阅读历史
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+22
-2
@@ -8,6 +8,21 @@ import { MangaSearchItem, MangaShelfItem, MangaSource } from '@/lib/manga.types'
|
|||||||
|
|
||||||
import MangaCard from '@/components/MangaCard';
|
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() {
|
export default function MangaPage() {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [sources, setSources] = useState<MangaSource[]>([]);
|
const [sources, setSources] = useState<MangaSource[]>([]);
|
||||||
@@ -108,10 +123,15 @@ export default function MangaPage() {
|
|||||||
<section>
|
<section>
|
||||||
<div className='mb-4 flex items-center justify-between'>
|
<div className='mb-4 flex items-center justify-between'>
|
||||||
<h2 className='text-lg font-semibold'>搜索结果</h2>
|
<h2 className='text-lg font-semibold'>搜索结果</h2>
|
||||||
{loading && <span className='text-sm text-gray-500'>搜索中...</span>}
|
|
||||||
</div>
|
</div>
|
||||||
{error && <div className='mb-4 text-sm text-red-500'>{error}</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 className='rounded-2xl bg-gray-50 p-10 text-center text-sm text-gray-500 dark:bg-gray-900/50'>
|
||||||
请输入关键词开始搜索漫画
|
请输入关键词开始搜索漫画
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+482
-40
@@ -3,8 +3,10 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { type MouseEvent, useEffect, useMemo, useRef, useState } from 'react';
|
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';
|
import ProxyImage from '@/components/ProxyImage';
|
||||||
|
|
||||||
@@ -14,6 +16,7 @@ type ScaleMode = 'fit' | 'original';
|
|||||||
const READ_MODE_STORAGE_KEY = 'mangaReadMode';
|
const READ_MODE_STORAGE_KEY = 'mangaReadMode';
|
||||||
const SCALE_MODE_STORAGE_KEY = 'mangaScaleMode';
|
const SCALE_MODE_STORAGE_KEY = 'mangaScaleMode';
|
||||||
const PAGE_GAP_STORAGE_KEY = 'mangaPageGap';
|
const PAGE_GAP_STORAGE_KEY = 'mangaPageGap';
|
||||||
|
const SAVE_INTERVAL_MS = 10000;
|
||||||
|
|
||||||
const READ_MODE_OPTIONS: Array<{ value: ReadMode; label: string }> = [
|
const READ_MODE_OPTIONS: Array<{ value: ReadMode; label: string }> = [
|
||||||
{ value: 'single', label: '单页' },
|
{ value: 'single', label: '单页' },
|
||||||
@@ -27,6 +30,43 @@ const SCALE_MODE_OPTIONS: Array<{ value: ScaleMode; label: string }> = [
|
|||||||
{ value: 'original', label: '原始大小' },
|
{ 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() {
|
export default function MangaReadPage() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const mangaId = searchParams.get('mangaId') || '';
|
const mangaId = searchParams.get('mangaId') || '';
|
||||||
@@ -44,9 +84,42 @@ export default function MangaReadPage() {
|
|||||||
const [pageGap, setPageGap] = useState(0);
|
const [pageGap, setPageGap] = useState(0);
|
||||||
const [controlsVisible, setControlsVisible] = useState(false);
|
const [controlsVisible, setControlsVisible] = useState(false);
|
||||||
const [settingsOpen, setSettingsOpen] = 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 verticalPageRefs = useRef<Array<HTMLDivElement | null>>([]);
|
||||||
const horizontalContainerRef = useRef<HTMLDivElement | null>(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(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
@@ -83,6 +156,7 @@ export default function MangaReadPage() {
|
|||||||
const handleToggleSettings = () => {
|
const handleToggleSettings = () => {
|
||||||
setSettingsOpen((prev) => !prev);
|
setSettingsOpen((prev) => !prev);
|
||||||
setControlsVisible(false);
|
setControlsVisible(false);
|
||||||
|
setChapterListOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('manga-read-toggle-settings', handleToggleSettings);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!chapterId) return;
|
if (!chapterId) return;
|
||||||
fetch(`/api/manga/pages?chapterId=${encodeURIComponent(chapterId)}`)
|
fetch(`/api/manga/pages?chapterId=${encodeURIComponent(chapterId)}`)
|
||||||
@@ -99,27 +186,145 @@ export default function MangaReadPage() {
|
|||||||
.catch(() => setPages([]));
|
.catch(() => setPages([]));
|
||||||
}, [chapterId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
setActivePage(0);
|
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(() => {
|
useEffect(() => {
|
||||||
if (readMode !== 'vertical' || !pages.length || !mangaId || !sourceId || !chapterId) return;
|
if (readMode !== 'vertical' || !pages.length || !mangaId || !sourceId || !chapterId) return;
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
let ticking = false;
|
||||||
(entries) => {
|
let rafId = 0;
|
||||||
const visible = entries
|
let lastScrollTop = -1;
|
||||||
.filter((entry) => entry.isIntersecting)
|
let lastInnerHeight = -1;
|
||||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
|
const scrollingElement = document.scrollingElement || document.documentElement;
|
||||||
if (!visible) return;
|
|
||||||
const index = Number((visible.target as HTMLElement).dataset.index || 0);
|
|
||||||
setActivePage(index);
|
|
||||||
},
|
|
||||||
{ rootMargin: '-15% 0px -70% 0px', threshold: 0.2 }
|
|
||||||
);
|
|
||||||
|
|
||||||
verticalPageRefs.current.forEach((node) => node && observer.observe(node));
|
const updateActivePageFromViewport = () => {
|
||||||
return () => observer.disconnect();
|
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]);
|
}, [readMode, pages, mangaId, sourceId, chapterId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -138,28 +343,137 @@ export default function MangaReadPage() {
|
|||||||
}, [readMode, pages.length]);
|
}, [readMode, pages.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pages.length || !mangaId || !sourceId || !chapterId) return;
|
if (!pages.length) {
|
||||||
const timer = window.setTimeout(() => {
|
previousReadModeRef.current = readMode;
|
||||||
saveMangaReadRecord(sourceId, mangaId, {
|
return;
|
||||||
title,
|
}
|
||||||
cover,
|
|
||||||
sourceId,
|
|
||||||
sourceName,
|
|
||||||
mangaId,
|
|
||||||
chapterId,
|
|
||||||
chapterName,
|
|
||||||
pageIndex: activePage,
|
|
||||||
pageCount: pages.length,
|
|
||||||
saveTime: Date.now(),
|
|
||||||
}).catch(() => undefined);
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
return () => window.clearTimeout(timer);
|
const previousReadMode = previousReadModeRef.current;
|
||||||
}, [activePage, chapterId, chapterName, cover, mangaId, pages.length, sourceId, sourceName, title]);
|
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 = () => {
|
const hideTransientUi = () => {
|
||||||
setControlsVisible(false);
|
setControlsVisible(false);
|
||||||
setSettingsOpen(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) => {
|
const clampPage = (page: number) => {
|
||||||
@@ -197,17 +511,20 @@ export default function MangaReadPage() {
|
|||||||
const goNext = () => {
|
const goNext = () => {
|
||||||
if (!pages.length) return;
|
if (!pages.length) return;
|
||||||
if (readMode === 'vertical') {
|
if (readMode === 'vertical') {
|
||||||
|
if (openChapterComplete()) return;
|
||||||
window.scrollBy({ top: window.innerHeight * 0.85, behavior: 'smooth' });
|
window.scrollBy({ top: window.innerHeight * 0.85, behavior: 'smooth' });
|
||||||
hideTransientUi();
|
hideTransientUi();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (readMode === 'horizontal') {
|
if (readMode === 'horizontal') {
|
||||||
|
if (openChapterComplete()) return;
|
||||||
const nextPage = clampPage(activePage + 1);
|
const nextPage = clampPage(activePage + 1);
|
||||||
setActivePage(nextPage);
|
setActivePage(nextPage);
|
||||||
scrollHorizontalToPage(nextPage);
|
scrollHorizontalToPage(nextPage);
|
||||||
hideTransientUi();
|
hideTransientUi();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (openChapterComplete()) return;
|
||||||
setActivePage((prev) => clampPage(prev + (readMode === 'double' ? 2 : 1)));
|
setActivePage((prev) => clampPage(prev + (readMode === 'double' ? 2 : 1)));
|
||||||
hideTransientUi();
|
hideTransientUi();
|
||||||
};
|
};
|
||||||
@@ -217,6 +534,36 @@ export default function MangaReadPage() {
|
|||||||
[activePage, pages.length]
|
[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(() => {
|
const pagedItems = useMemo(() => {
|
||||||
if (readMode === 'single') {
|
if (readMode === 'single') {
|
||||||
return pages[activePage] ? [pages[activePage]] : [];
|
return pages[activePage] ? [pages[activePage]] : [];
|
||||||
@@ -231,11 +578,20 @@ export default function MangaReadPage() {
|
|||||||
if (scaleMode === 'original') {
|
if (scaleMode === 'original') {
|
||||||
return 'mx-auto h-auto w-auto max-w-none object-none';
|
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';
|
return 'h-auto w-full object-contain';
|
||||||
}, [scaleMode]);
|
}, [readMode, scaleMode]);
|
||||||
|
|
||||||
const handleReaderClick = (event: MouseEvent<HTMLDivElement>) => {
|
const handleReaderClick = (event: MouseEvent<HTMLDivElement>) => {
|
||||||
if (settingsOpen) return;
|
if (settingsOpen) return;
|
||||||
|
if (readMode === 'vertical') {
|
||||||
|
setControlsVisible((prev) => !prev);
|
||||||
|
setShowChapterComplete(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { clientX } = event;
|
const { clientX } = event;
|
||||||
const width = window.innerWidth;
|
const width = window.innerWidth;
|
||||||
const leftBoundary = width / 3;
|
const leftBoundary = width / 3;
|
||||||
@@ -341,10 +697,85 @@ export default function MangaReadPage() {
|
|||||||
</div>
|
</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
|
<div
|
||||||
className='relative min-h-[calc(100vh-5rem)] select-none px-2 py-3 sm:px-3'
|
className='relative min-h-[calc(100vh-5rem)] select-none px-2 py-3 sm:px-3'
|
||||||
onClick={handleReaderClick}
|
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
|
<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 ${
|
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'
|
controlsVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||||
@@ -356,10 +787,16 @@ export default function MangaReadPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pages.length === 0 ? (
|
{pages.length > 0 && (
|
||||||
<div className='rounded-[24px] bg-gray-50 p-10 text-center text-sm text-gray-500 dark:bg-gray-900/50'>
|
<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>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pages.length === 0 && <MangaReadSkeleton readMode={readMode} pageGap={pageGap} />}
|
||||||
|
|
||||||
|
{pages.length === 0 ? (
|
||||||
|
null
|
||||||
) : readMode === 'vertical' ? (
|
) : readMode === 'vertical' ? (
|
||||||
<div className='flex flex-col' style={{ gap: `${pageGap}px` }}>
|
<div className='flex flex-col' style={{ gap: `${pageGap}px` }}>
|
||||||
{pages.map((page, index) => (
|
{pages.map((page, index) => (
|
||||||
@@ -369,9 +806,14 @@ export default function MangaReadPage() {
|
|||||||
verticalPageRefs.current[index] = node;
|
verticalPageRefs.current[index] = node;
|
||||||
}}
|
}}
|
||||||
data-index={index}
|
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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -383,7 +825,7 @@ export default function MangaReadPage() {
|
|||||||
>
|
>
|
||||||
{pages.map((page, index) => (
|
{pages.map((page, index) => (
|
||||||
<div key={`${page}-${index}`} className='flex min-w-full snap-center items-center justify-center px-1'>
|
<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} />
|
<ProxyImage originalSrc={page} alt={`${chapterName}-${index + 1}`} className={imageClassName} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -395,7 +837,7 @@ export default function MangaReadPage() {
|
|||||||
{pagedItems.map((page, index) => (
|
{pagedItems.map((page, index) => (
|
||||||
<div
|
<div
|
||||||
key={`${page}-${index}`}
|
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
|
<ProxyImage
|
||||||
originalSrc={page}
|
originalSrc={page}
|
||||||
|
|||||||
@@ -8,11 +8,34 @@ import { MangaShelfItem } from '@/lib/manga.types';
|
|||||||
|
|
||||||
import MangaCard from '@/components/MangaCard';
|
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() {
|
export default function MangaShelfPage() {
|
||||||
const [shelf, setShelf] = useState<Record<string, MangaShelfItem>>({});
|
const [shelf, setShelf] = useState<Record<string, MangaShelfItem>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getAllMangaShelf().then(setShelf).catch(() => undefined);
|
getAllMangaShelf()
|
||||||
|
.then(setShelf)
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const shelfList = useMemo(
|
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'>
|
<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} 本漫画
|
<BookOpen className='h-4 w-4 text-emerald-500' /> 共 {shelfList.length} 本漫画
|
||||||
</div>
|
</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 className='rounded-2xl bg-gray-50 p-10 text-center text-sm text-gray-500 dark:bg-gray-900/50'>
|
||||||
暂无书架内容
|
暂无书架内容
|
||||||
</div>
|
</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 Link from 'next/link';
|
||||||
import { usePathname, useSearchParams } from 'next/navigation';
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
@@ -62,66 +62,83 @@ export default function MangaLayout({ children }: MangaLayoutProps) {
|
|||||||
return (
|
return (
|
||||||
<div className='min-h-screen bg-gray-50 text-gray-900 dark:bg-black dark:text-gray-100'>
|
<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'>
|
<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='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'>
|
<div className='flex min-w-0 items-center gap-2'>
|
||||||
{meta.backHref ? (
|
{meta.backHref ? (
|
||||||
<Link
|
<Link
|
||||||
href={meta.backHref}
|
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'
|
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' />
|
<ChevronLeft className='h-5 w-5' />
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
href='/'
|
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'
|
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}
|
{siteName}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<div className='min-w-0'>
|
<div className='min-w-0'>
|
||||||
|
<div className='group relative'>
|
||||||
<div className='truncate text-sm font-semibold sm:text-base'>{meta.title}</div>
|
<div className='truncate text-sm font-semibold sm:text-base'>{meta.title}</div>
|
||||||
{meta.subtitle && (
|
<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='truncate text-xs text-gray-500 dark:text-gray-400'>
|
<div className='text-sm'>{meta.title}</div>
|
||||||
{meta.subtitle}
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{meta.subtitle && (
|
||||||
|
<div className='truncate text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
{meta.subtitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<nav className='ml-auto hidden items-center gap-2 lg:flex'>
|
<nav className='ml-auto hidden items-center gap-2 lg:flex'>
|
||||||
{sectionTabs.map((tab) => {
|
{sectionTabs.map((tab) => {
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
const active = isActive(tab.href);
|
const active = isActive(tab.href);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={tab.href}
|
key={tab.href}
|
||||||
href={tab.href}
|
href={tab.href}
|
||||||
className={`inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm transition ${
|
className={`inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm transition ${
|
||||||
active
|
active
|
||||||
? 'bg-sky-600 text-white shadow-sm'
|
? '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'
|
: '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' />
|
<Icon className='h-4 w-4' />
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className={`${isReadingPage ? 'flex' : 'hidden md:flex'} items-center gap-2`}>
|
<div className={`${isReadingPage ? 'flex' : 'hidden md:flex'} items-center gap-2`}>
|
||||||
{isReadingPage ? (
|
{isReadingPage ? (
|
||||||
<button
|
<>
|
||||||
type='button'
|
<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'
|
type='button'
|
||||||
onClick={() => {
|
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'
|
||||||
window.dispatchEvent(new CustomEvent('manga-read-toggle-settings'));
|
onClick={() => {
|
||||||
}}
|
window.dispatchEvent(new CustomEvent('manga-read-toggle-chapters'));
|
||||||
aria-label='阅读设置'
|
}}
|
||||||
>
|
aria-label='章节列表'
|
||||||
<Settings2 className='h-5 w-5' />
|
>
|
||||||
</button>
|
<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 />
|
<ThemeToggle />
|
||||||
@@ -134,15 +151,16 @@ export default function MangaLayout({ children }: MangaLayoutProps) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main
|
<main
|
||||||
className='mx-auto max-w-7xl px-3 pb-24 pt-20 sm:px-6 sm:pb-28 sm:pt-24'
|
className={`mx-auto max-w-7xl px-3 pt-20 sm:px-6 sm:pt-24 ${
|
||||||
style={{ paddingBottom: isReadingPage ? undefined : 'calc(5rem + env(safe-area-inset-bottom))' }}
|
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}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{!isReadingPage && (
|
{!isReadingPage && (
|
||||||
<nav
|
<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)' }}
|
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||||
>
|
>
|
||||||
<div className='mx-auto grid max-w-3xl grid-cols-4'>
|
<div className='mx-auto grid max-w-3xl grid-cols-4'>
|
||||||
@@ -157,16 +175,12 @@ export default function MangaLayout({ children }: MangaLayoutProps) {
|
|||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
className={`h-5 w-5 ${
|
className={`h-5 w-5 ${
|
||||||
active
|
active ? 'text-sky-600 dark:text-sky-400' : 'text-gray-500 dark:text-gray-400'
|
||||||
? 'text-sky-600 dark:text-sky-400'
|
|
||||||
: 'text-gray-500 dark:text-gray-400'
|
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
active
|
active ? 'text-sky-600 dark:text-sky-400' : 'text-gray-600 dark:text-gray-300'
|
||||||
? 'text-sky-600 dark:text-sky-400'
|
|
||||||
: 'text-gray-600 dark:text-gray-300'
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
|
|||||||
Reference in New Issue
Block a user