电子书架

This commit is contained in:
mtvpls
2026-04-29 11:30:45 +08:00
parent 0d99953e99
commit 3acb4e82cd
13 changed files with 891 additions and 255 deletions
+3 -3
View File
@@ -4,10 +4,10 @@ import Link from 'next/link';
import { BookListItem } from '@/lib/book.types';
export default function BookCard({ item, href, extra }: { item: BookListItem; href: string; extra?: React.ReactNode }) {
export default function BookCard({ item, href, extra, onNavigate }: { item: BookListItem; href: string; extra?: React.ReactNode; onNavigate?: () => void }) {
return (
<div className='overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-950'>
<Link href={href}>
<Link href={href} onClick={onNavigate}>
<div className='aspect-[3/4] bg-gray-100 dark:bg-gray-900'>
{item.cover ? (
// eslint-disable-next-line @next/next/no-img-element
@@ -18,7 +18,7 @@ export default function BookCard({ item, href, extra }: { item: BookListItem; hr
</div>
</Link>
<div className='space-y-2 p-3'>
<Link href={href} className='line-clamp-2 text-sm font-medium hover:text-sky-600'>{item.title}</Link>
<Link href={href} onClick={onNavigate} className='line-clamp-2 text-sm font-medium hover:text-sky-600'>{item.title}</Link>
<div className='line-clamp-1 text-xs text-gray-500 dark:text-gray-400'>{item.author || item.sourceName}</div>
{extra}
</div>
+85 -8
View File
@@ -1,8 +1,9 @@
'use client';
import { BookOpen, ChevronLeft, History, Library, Search } from 'lucide-react';
import { BookOpen, ChevronLeft, History, Library, List, Search, Settings2 } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
const tabs = [
{ href: '/books', label: '发现', icon: Library },
@@ -11,26 +12,102 @@ const tabs = [
{ href: '/books/history', label: '历史', icon: History },
];
type ReadHeaderPayload = {
title?: string;
subtitle?: string;
backHref?: string;
};
function getStaticMeta(pathname: string) {
if (pathname === '/books/shelf') return { title: '电子书书架', subtitle: '集中管理收藏的电子书' };
if (pathname === '/books/history') return { title: '阅读历史', subtitle: '从上次阅读的位置继续' };
if (pathname === '/books/search') return { title: '电子书搜索', subtitle: '按书名与作者搜索' };
if (pathname === '/books/detail') return { title: '电子书详情', subtitle: '查看书籍信息与可用格式' };
if (pathname === '/books/read') return { title: '电子书阅读', subtitle: '分页阅读', backHref: '/books' };
return { title: '电子书馆', subtitle: 'OPDS 目录、搜索、阅读与书架' };
}
export default function BooksLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const isRead = pathname === '/books/read';
const [readHeader, setReadHeader] = useState<ReadHeaderPayload | null>(null);
useEffect(() => {
if (!isRead) return;
const handleUpdate = (event: Event) => {
const custom = event as CustomEvent<ReadHeaderPayload>;
setReadHeader(custom.detail || null);
};
window.addEventListener('books-read-update-header', handleUpdate as EventListener);
return () => {
window.removeEventListener('books-read-update-header', handleUpdate as EventListener);
};
}, [isRead]);
useEffect(() => {
if (!isRead) setReadHeader(null);
}, [isRead, pathname]);
const meta = useMemo(() => {
const base = getStaticMeta(pathname);
if (pathname === '/books/detail') {
return {
title: searchParams.get('title') || base.title,
subtitle: searchParams.get('author') || base.subtitle,
backHref: '/books',
};
}
if (isRead) {
return {
title: readHeader?.title || base.title,
subtitle: readHeader?.subtitle || base.subtitle,
backHref: readHeader?.backHref || `/books/detail?sourceId=${encodeURIComponent(searchParams.get('sourceId') || '')}&bookId=${encodeURIComponent(searchParams.get('bookId') || '')}`,
};
}
return base;
}, [pathname, searchParams, isRead, readHeader]);
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-40 border-b border-gray-200/70 bg-white/90 backdrop-blur dark:border-gray-800 dark:bg-gray-950/90'>
<div className='mx-auto flex h-14 max-w-6xl items-center gap-3 px-4'>
{isRead ? (
<Link href='/books' className='inline-flex h-10 w-10 items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800'>
{isRead || pathname === '/books/detail' ? (
<Link href={meta.backHref || '/books'} className='inline-flex h-10 w-10 items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800'>
<ChevronLeft className='h-5 w-5' />
</Link>
) : (
<Link href='/' className='text-sm font-semibold text-sky-600'>MoonTV+</Link>
)}
<div className='min-w-0 flex-1'>
<div className='truncate text-sm font-semibold sm:text-base'>{isRead ? '电子书阅读' : '电子书馆'}</div>
<div className='truncate text-xs text-gray-500 dark:text-gray-400'>OPDS </div>
<div className='group relative'>
<div className='truncate text-sm font-semibold sm:text-base'>{meta.title}</div>
<div className='absolute left-1/2 top-full z-[100] mt-2 w-max max-w-[85vw] -translate-x-1/2 rounded-lg bg-gray-800 px-3 py-2 text-center text-sm text-white opacity-0 invisible shadow-xl transition-all duration-200 ease-out pointer-events-none group-hover:visible group-hover:opacity-100 dark:bg-gray-900 sm:max-w-none sm:whitespace-nowrap'>
<div className='break-words whitespace-normal sm:whitespace-nowrap'>{meta.title}</div>
</div>
</div>
<div className='truncate text-xs text-gray-500 dark:text-gray-400'>{meta.subtitle}</div>
</div>
{!isRead && (
{isRead ? (
<div className='flex items-center gap-2'>
<button
type='button'
onClick={() => window.dispatchEvent(new CustomEvent('books-read-toggle-chapters'))}
className='inline-flex h-10 w-10 items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800'
aria-label='目录'
>
<List className='h-5 w-5' />
</button>
<button
type='button'
onClick={() => window.dispatchEvent(new CustomEvent('books-read-toggle-settings'))}
className='inline-flex h-10 w-10 items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800'
aria-label='设置'
>
<Settings2 className='h-5 w-5' />
</button>
</div>
) : (
<nav className='hidden items-center gap-2 md:flex'>
{tabs.map((tab) => {
const active = pathname === tab.href;
@@ -46,7 +123,7 @@ export default function BooksLayout({ children }: { children: React.ReactNode })
)}
</div>
</header>
<main className={`mx-auto max-w-6xl ${isRead ? 'pt-16' : 'px-4 pb-24 pt-20'}`}>{children}</main>
<main className={`mx-auto max-w-6xl ${isRead ? 'pt-14' : 'px-4 pb-24 pt-20'}`}>{children}</main>
{!isRead && (
<nav className='fixed inset-x-0 bottom-0 z-40 grid grid-cols-4 border-t border-gray-200/70 bg-white/95 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95 md:hidden'>
{tabs.map((tab) => {