美化电子书馆

This commit is contained in:
mtvpls
2026-05-28 09:52:41 +08:00
parent c942c8c9ea
commit dc31a788fc
9 changed files with 3808 additions and 1394 deletions
+44 -12
View File
@@ -1,30 +1,62 @@
'use client';
import { BookOpen, Library } from 'lucide-react';
import Link from 'next/link';
import { BookListItem } from '@/lib/book.types';
export default function BookCard({ item, href, extra, onNavigate }: { item: BookListItem; href: string; extra?: React.ReactNode; onNavigate?: () => void }) {
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} onClick={onNavigate}>
<div className='relative aspect-[3/4] bg-gray-100 dark:bg-gray-900'>
<article className='group overflow-hidden rounded-[1.75rem] border border-emerald-100/80 bg-white/85 shadow-sm shadow-emerald-950/5 transition-colors duration-200 hover:border-emerald-200 hover:bg-white dark:border-emerald-500/10 dark:bg-gray-950/70 dark:hover:border-emerald-500/30'>
<Link
href={href}
onClick={onNavigate}
className='block cursor-pointer focus:outline-none focus:ring-2 focus:ring-inset focus:ring-emerald-500'
>
<div className='relative aspect-[3/4] overflow-hidden bg-gradient-to-br from-emerald-50 to-amber-50 dark:from-gray-900 dark:to-emerald-950/20'>
{item.cover ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={item.cover} alt={item.title} className='h-full w-full object-cover' />
<img
src={item.cover}
alt={item.title}
className='h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]'
/>
) : (
<div className='flex h-full items-center justify-center text-sm text-gray-400'></div>
<div className='flex h-full flex-col items-center justify-center gap-2 text-sm text-slate-400 dark:text-slate-500'>
<BookOpen className='h-8 w-8' />
</div>
)}
<div className='absolute right-2 top-2 max-w-[70%] truncate rounded-full bg-black/70 px-2 py-1 text-[11px] text-white'>
{item.sourceName}
<div className='absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/55 to-transparent opacity-80' />
<div className='absolute right-2 top-2 inline-flex max-w-[74%] items-center gap-1.5 truncate rounded-full bg-black/65 px-2.5 py-1 text-[11px] font-medium text-white shadow-lg backdrop-blur'>
<Library className='h-3 w-3 shrink-0' />
<span className='truncate'>{item.sourceName}</span>
</div>
</div>
</Link>
<div className='space-y-2 p-3'>
<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 || '未知作者'}</div>
<div className='space-y-2 p-3.5'>
<Link
href={href}
onClick={onNavigate}
className='line-clamp-2 cursor-pointer text-sm font-semibold leading-5 text-slate-950 transition-colors duration-200 hover:text-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:text-white dark:hover:text-emerald-200'
>
{item.title}
</Link>
<div className='line-clamp-1 text-xs text-slate-500 dark:text-slate-400'>
{item.author || '未知作者'}
</div>
{extra}
</div>
</div>
</article>
);
}
+124 -33
View File
@@ -1,11 +1,23 @@
'use client';
import { BookOpen, ChevronLeft, Headphones, History, Library, List, MoreVertical, Search, Settings2 } from 'lucide-react';
import {
BookOpen,
ChevronLeft,
Headphones,
History,
Library,
List,
MoreVertical,
Search,
Settings2,
Sparkles,
} from 'lucide-react';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSite } from '@/components/SiteProvider';
import { ThemeToggle } from '@/components/ThemeToggle';
const tabs = [
{ href: '/books', label: '发现', icon: Library },
@@ -21,15 +33,24 @@ type ReadHeaderPayload = {
};
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' };
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: '电子书馆' };
}
export default function BooksLayout({ children }: { children: React.ReactNode }) {
export default function BooksLayout({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
const searchParams = useSearchParams();
const { siteName } = useSite();
@@ -44,9 +65,15 @@ export default function BooksLayout({ children }: { children: React.ReactNode })
const custom = event as CustomEvent<ReadHeaderPayload>;
setReadHeader(custom.detail || null);
};
window.addEventListener('books-read-update-header', handleUpdate as EventListener);
window.addEventListener(
'books-read-update-header',
handleUpdate as EventListener
);
return () => {
window.removeEventListener('books-read-update-header', handleUpdate as EventListener);
window.removeEventListener(
'books-read-update-header',
handleUpdate as EventListener
);
};
}, [isRead]);
@@ -80,39 +107,69 @@ export default function BooksLayout({ children }: { children: React.ReactNode })
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') || '')}`,
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'>
<div className='min-h-screen bg-[radial-gradient(circle_at_top_left,#fce7f3_0,transparent_34rem),linear-gradient(180deg,#fff7fb_0%,#f8fafc_44%,#f8fafc_100%)] text-slate-900 dark:bg-[radial-gradient(circle_at_top_left,rgba(6,95,70,0.26)_0,transparent_32rem),linear-gradient(180deg,#050505_0%,#09090b_100%)] dark:text-gray-100'>
<header className='fixed inset-x-0 top-0 z-40 border-b border-emerald-100/80 bg-white/85 shadow-sm shadow-emerald-950/5 backdrop-blur-xl dark:border-emerald-500/10 dark:bg-gray-950/85 dark:shadow-black/20'>
<div className='mx-auto flex h-16 max-w-6xl items-center gap-3 px-4'>
{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'>
<Link
href={meta.backHref || '/books'}
className='inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full text-slate-700 transition-colors duration-200 hover:bg-emerald-50 hover:text-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:text-slate-200 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
>
<ChevronLeft className='h-5 w-5' />
</Link>
) : (
<Link href='/' className='text-sm font-semibold text-sky-600'>{siteName}</Link>
<Link
href='/'
className='inline-flex cursor-pointer items-center gap-2 rounded-full bg-emerald-50 px-3 py-2 text-sm font-bold text-emerald-700 transition-colors duration-200 hover:bg-emerald-100 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:bg-emerald-500/10 dark:text-emerald-200 dark:hover:bg-emerald-500/20'
>
<Sparkles className='h-4 w-4' />
{siteName}
</Link>
)}
<div className='min-w-0 flex-1'>
<div className='group relative'>
<div className='truncate text-sm font-semibold sm:text-base'>{meta.title}</div>
<div className='truncate text-sm font-bold tracking-tight text-slate-950 dark:text-white 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'>
<div className='max-w-[85vw] break-words whitespace-normal sm:max-w-none sm:whitespace-nowrap'>{meta.title}</div>
{meta.subtitle ? <div className='mt-1 max-w-[85vw] break-words whitespace-normal text-xs text-gray-300 sm:max-w-none sm:whitespace-nowrap'>{meta.subtitle}</div> : null}
<div className='max-w-[85vw] break-words whitespace-normal sm:max-w-none sm:whitespace-nowrap'>
{meta.title}
</div>
{meta.subtitle ? (
<div className='mt-1 max-w-[85vw] break-words whitespace-normal text-xs text-gray-300 sm:max-w-none sm:whitespace-nowrap'>
{meta.subtitle}
</div>
) : null}
</div>
</div>
<div className='truncate text-xs text-gray-500 dark:text-gray-400'>{meta.subtitle}</div>
<div className='truncate text-xs text-slate-500 dark:text-slate-400'>
{meta.subtitle}
</div>
</div>
<div className='hidden md:block'>
<ThemeToggle />
</div>
{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'
onClick={() =>
window.dispatchEvent(
new CustomEvent('books-read-toggle-chapters')
)
}
className='inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full transition-colors duration-200 hover:bg-emerald-50 hover:text-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
aria-label='目录'
>
<List className='h-5 w-5' />
@@ -121,20 +178,22 @@ export default function BooksLayout({ children }: { children: React.ReactNode })
<button
type='button'
onClick={() => setReadMenuOpen((prev) => !prev)}
className='inline-flex h-10 w-10 items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800'
className='inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full transition-colors duration-200 hover:bg-emerald-50 hover:text-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
aria-label='更多'
>
<MoreVertical className='h-5 w-5' />
</button>
{readMenuOpen ? (
<div className='absolute right-0 top-12 z-50 min-w-[9rem] overflow-hidden rounded-2xl border border-gray-200 bg-white py-1 shadow-xl dark:border-gray-800 dark:bg-gray-950'>
<div className='absolute right-0 top-12 z-50 min-w-[9rem] overflow-hidden rounded-2xl border border-emerald-100 bg-white py-1 shadow-xl shadow-emerald-950/10 dark:border-emerald-500/10 dark:bg-gray-950'>
<button
type='button'
onClick={() => {
setReadMenuOpen(false);
window.dispatchEvent(new CustomEvent('books-read-toggle-settings'));
window.dispatchEvent(
new CustomEvent('books-read-toggle-settings')
);
}}
className='flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900'
className='flex w-full cursor-pointer items-center gap-2 px-4 py-2.5 text-left text-sm text-slate-700 transition-colors duration-200 hover:bg-emerald-50 hover:text-emerald-700 dark:text-gray-200 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
>
<Settings2 className='h-4 w-4' />
@@ -143,9 +202,11 @@ export default function BooksLayout({ children }: { children: React.ReactNode })
type='button'
onClick={() => {
setReadMenuOpen(false);
window.dispatchEvent(new CustomEvent('books-read-toggle-tts'));
window.dispatchEvent(
new CustomEvent('books-read-toggle-tts')
);
}}
className='flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-900'
className='flex w-full cursor-pointer items-center gap-2 px-4 py-2.5 text-left text-sm text-slate-700 transition-colors duration-200 hover:bg-emerald-50 hover:text-emerald-700 dark:text-gray-200 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
>
<Headphones className='h-4 w-4' />
@@ -160,7 +221,15 @@ export default function BooksLayout({ children }: { children: React.ReactNode })
const active = pathname === tab.href;
const Icon = tab.icon;
return (
<Link key={tab.href} href={tab.href} className={`inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm ${active ? 'bg-sky-600 text-white' : 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'}`}>
<Link
key={tab.href}
href={tab.href}
className={`inline-flex cursor-pointer items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-emerald-500 ${
active
? 'bg-emerald-600 text-white shadow-sm shadow-emerald-600/20'
: 'text-slate-600 hover:bg-emerald-50 hover:text-emerald-700 dark:text-gray-300 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-200'
}`}
>
<Icon className='h-4 w-4' />
{tab.label}
</Link>
@@ -170,16 +239,38 @@ export default function BooksLayout({ children }: { children: React.ReactNode })
)}
</div>
</header>
<main className={`mx-auto max-w-6xl ${isRead ? 'pt-14' : 'px-4 pb-24 pt-20'}`}>{children}</main>
<main
className={`mx-auto max-w-6xl ${isRead ? 'pt-16' : 'px-4 pb-24 pt-24'}`}
>
{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'>
<nav className='fixed inset-x-0 bottom-0 z-40 grid grid-cols-4 border-t border-emerald-100/80 bg-white/95 shadow-[0_-12px_32px_rgba(6,95,70,0.08)] backdrop-blur-xl dark:border-emerald-500/10 dark:bg-gray-950/95 md:hidden'>
{tabs.map((tab) => {
const active = pathname === tab.href;
const Icon = tab.icon;
return (
<Link key={tab.href} href={tab.href} className='flex min-h-16 flex-col items-center justify-center gap-1 text-xs'>
<Icon className={`h-5 w-5 ${active ? 'text-sky-600' : 'text-gray-500'}`} />
<span className={active ? 'text-sky-600' : 'text-gray-600 dark:text-gray-300'}>{tab.label}</span>
<Link
key={tab.href}
href={tab.href}
className='flex min-h-16 cursor-pointer flex-col items-center justify-center gap-1 text-xs transition-colors duration-200 hover:bg-emerald-50 dark:hover:bg-emerald-500/10'
>
<Icon
className={`h-5 w-5 ${
active
? 'text-emerald-600 dark:text-emerald-300'
: 'text-gray-500'
}`}
/>
<span
className={
active
? 'font-semibold text-emerald-600 dark:text-emerald-300'
: 'text-gray-600 dark:text-gray-300'
}
>
{tab.label}
</span>
</Link>
);
})}