电子书架
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user