feat(ui): warm library palette (zinc+emerald -> stone+amber) with hover/focus feedback; text reader gains paper/sepia/night themes + persisted font-size (lib/readerPrefs.ts), serif justified measure; PDF drops emoji icons for 首页/末页, CBZ pill page counter, EPUB centered spread, blur reader header with format badge, aria labels on login/search/chapter select

This commit is contained in:
2026-09-07 22:37:47 +08:00
parent 2478da059b
commit 7fb01ce335
20 changed files with 281 additions and 126 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#09090b" />
<meta name="theme-color" content="#0c0a09" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<title>BookLib — 个人书库</title>
</head>
+1 -1
View File
@@ -77,7 +77,7 @@ export function RequireAuth({ children }: { children: ReactNode }) {
const { user, ready } = useAuth();
const loc = useLocation();
if (!getToken()) return <Navigate to="/login" replace state={{ from: loc.pathname }} />;
if (!ready || !user) return <div className="grid h-full place-items-center text-zinc-500">加载中…</div>;
if (!ready || !user) return <div className="grid h-full place-items-center text-stone-500">加载中…</div>;
return <>{children}</>;
}
+3 -3
View File
@@ -29,12 +29,12 @@ export function Cover({ book, className }: { book: Book; className?: string }) {
const { src, failed, retry } = useAuthedImage(book.cover_url);
if (failed)
return (
<div className={`grid place-items-center bg-zinc-900 ${className ?? ""}`}>
<div className={`grid place-items-center bg-stone-900 ${className ?? ""}`}>
<button className={btn} onClick={retry} aria-label="封面加载失败,重试">
重试
</button>
</div>
);
if (!src) return <div className={`animate-pulse bg-zinc-800 ${className ?? ""}`} />;
return <img src={src} alt="" loading="lazy" className={`bg-zinc-900 object-cover ${className ?? ""}`} />;
if (!src) return <div className={`animate-pulse bg-stone-800 ${className ?? ""}`} />;
return <img src={src} alt="" loading="lazy" className={`bg-stone-900 object-cover ${className ?? ""}`} />;
}
+2 -2
View File
@@ -13,8 +13,8 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, { err: Err
<div className="grid h-full place-items-center p-8 text-center">
<div>
<p className="mb-2 text-lg text-red-400">页面出错了</p>
<p className="mb-4 max-w-md text-sm break-all text-zinc-500">{String(this.state.err)}</p>
<button className="rounded-md bg-zinc-800 px-3 py-1.5 text-sm" onClick={() => this.setState({ err: null })}>
<p className="mb-4 max-w-md text-sm break-all text-stone-500">{String(this.state.err)}</p>
<button className="rounded-md bg-stone-800 px-3 py-1.5 text-sm" onClick={() => this.setState({ err: null })}>
重试
</button>
</div>
+1 -1
View File
@@ -31,7 +31,7 @@ export function Toaster() {
<div
key={t.id}
role="status"
className={`max-w-sm rounded-lg px-4 py-2 text-sm shadow-lg ${
className={`max-w-sm rounded-lg px-4 py-2 text-sm shadow-lg ring-1 ring-white/10 ${
t.kind === "ok" ? "bg-emerald-900 text-emerald-100" : "bg-red-900 text-red-100"
}`}
>
+12 -6
View File
@@ -6,23 +6,29 @@ import { btn } from "./ui";
export function TopBar({ right }: { right?: ReactNode }) {
const { user, isAdmin, logout } = useAuth();
return (
<header className="flex shrink-0 items-center gap-4 border-b border-zinc-800 px-4 py-3">
<Link to="/" className="font-semibold">
BookLib
<header className="flex shrink-0 items-center gap-2 border-b border-stone-800/80 px-4 py-3">
<Link to="/" className="font-serif text-lg font-semibold tracking-wide">
Book<span className="text-amber-500">Lib</span>
</Link>
{isAdmin && (
<Link className="text-sm text-zinc-400 hover:text-zinc-100" to="/admin/users">
<Link
className="rounded-md px-2 py-1 text-sm text-stone-400 transition-colors hover:bg-stone-800 hover:text-stone-100"
to="/admin/users"
>
用户
</Link>
)}
{isAdmin && (
<Link className="text-sm text-zinc-400 hover:text-zinc-100" to="/admin/libraries">
<Link
className="rounded-md px-2 py-1 text-sm text-stone-400 transition-colors hover:bg-stone-800 hover:text-stone-100"
to="/admin/libraries"
>
库管理
</Link>
)}
<div className="ml-auto flex items-center gap-3">
{right}
<span className="text-sm text-zinc-400">
<span className="text-sm text-stone-400">
{user?.username}
{isAdmin ? "(admin)" : ""}
</span>
+4 -4
View File
@@ -1,7 +1,7 @@
export const btn =
"rounded-md bg-zinc-800 px-3 py-1.5 text-sm hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-50";
"rounded-lg bg-stone-800 px-3 py-1.5 text-sm transition-colors hover:bg-stone-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 active:bg-stone-600 disabled:cursor-not-allowed disabled:opacity-50";
export const btnPrimary =
"rounded-md bg-emerald-700 px-3 py-1.5 text-sm hover:bg-emerald-600 disabled:cursor-not-allowed disabled:opacity-50";
"rounded-lg bg-amber-600 px-3 py-1.5 text-sm text-stone-950 transition-colors hover:bg-amber-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 active:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-50";
export const input =
"rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm outline-none focus:border-zinc-500";
export const card = "rounded-xl border border-zinc-800 bg-zinc-900";
"rounded-lg border border-stone-700 bg-stone-900 px-3 py-1.5 text-sm outline-none transition-colors placeholder:text-stone-500 focus:border-amber-500/70";
export const card = "rounded-xl border border-stone-800 bg-stone-900";
+37 -8
View File
@@ -6,15 +6,44 @@ body,
height: 100%;
}
body {
@apply bg-zinc-950 text-zinc-100 antialiased;
@apply bg-stone-950 text-stone-100 antialiased;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
@apply rounded bg-zinc-700;
@apply rounded-full border-2 border-transparent bg-clip-padding bg-stone-700/70;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-stone-600;
}
/* 阅读页主题:纸/米/夜,文本类阅读器共用 —— 见 lib/readerPrefs.ts */
[data-rd="paper"] { --rd-bg: #faf8f4; --rd-fg: #292524; --rd-link: #9a3412; }
[data-rd="sepia"] { --rd-bg: #f0e6d2; --rd-fg: #433a29; --rd-link: #92400e; }
[data-rd="night"] { --rd-bg: #171512; --rd-fg: #c6c0b6; --rd-link: #e0a458; }
.rd-surface {
background: var(--rd-bg);
color: var(--rd-fg);
font-family: ui-serif, "Songti SC", "Noto Serif CJK SC", "Source Han Serif SC", SimSun, Georgia, serif;
}
.rd-surface ::selection {
background: color-mix(in oklab, var(--rd-link) 30%, transparent);
}
/* 主题内的次级控件(章节条/翻页键),跟随当前阅读主题 */
.rd-btn {
@apply rounded-md px-2.5 py-1 text-sm transition-colors;
background: color-mix(in oklab, currentColor 8%, transparent);
}
.rd-btn:hover { background: color-mix(in oklab, currentColor 16%, transparent); }
.rd-btn:disabled { @apply cursor-not-allowed opacity-40; }
.rd-select {
@apply rounded-md px-2 py-1 text-sm outline-none;
background: color-mix(in oklab, currentColor 8%, transparent);
color: inherit;
}
.rd-divider { border-color: color-mix(in oklab, currentColor 14%, transparent); }
/* markdown 正文(仅 .md-body 作用域) */
.md-body h1 { @apply mt-8 mb-3 text-2xl font-semibold; }
@@ -23,12 +52,12 @@ body {
.md-body p { @apply my-3; }
.md-body ul { @apply my-3 list-disc space-y-1 pl-6; }
.md-body ol { @apply my-3 list-decimal space-y-1 pl-6; }
.md-body blockquote { @apply my-3 border-l-2 border-zinc-600 pl-4 text-zinc-400; }
.md-body code { @apply rounded bg-zinc-800 px-1 py-0.5 text-[13px]; }
.md-body pre { @apply my-3 overflow-x-auto rounded-lg bg-zinc-900 p-3; }
.md-body blockquote { @apply my-3 border-l-2 pl-4; border-color: color-mix(in oklab, currentColor 30%, transparent); opacity: 0.75; }
.md-body code { @apply rounded px-1 py-0.5 text-[0.85em]; background: color-mix(in oklab, currentColor 10%, transparent); }
.md-body pre { @apply my-3 overflow-x-auto rounded-lg p-3; background: color-mix(in oklab, currentColor 6%, transparent); }
.md-body pre code { @apply bg-transparent p-0; }
.md-body a { @apply text-emerald-400 underline; }
.md-body a { color: var(--rd-link, #f59e0b); @apply underline underline-offset-2; }
.md-body img { @apply my-3 max-w-full rounded; }
.md-body hr { @apply my-6 border-zinc-800; }
.md-body hr { @apply my-6; border-color: color-mix(in oklab, currentColor 15%, transparent); }
.md-body table { @apply my-3 w-full border-collapse text-sm; }
.md-body th, .md-body td { @apply border border-zinc-700 px-2 py-1 text-left; }
.md-body th, .md-body td { @apply border px-2 py-1 text-left; border-color: color-mix(in oklab, currentColor 22%, transparent); }
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useState } from "react";
export const RD_THEMES = ["paper", "sepia", "night"] as const;
export type RdTheme = (typeof RD_THEMES)[number];
export const RD_THEME_LABEL: Record<RdTheme, string> = { paper: "纸", sepia: "米", night: "夜" };
export const FONT_SIZES = [16, 18, 20, 22, 25, 28];
const KEY = "reader-prefs";
export interface ReaderPrefs {
theme: RdTheme;
sizeIdx: number;
}
export function useReaderPrefs() {
const [prefs, setPrefs] = useState<ReaderPrefs>(() => {
try {
const p = JSON.parse(localStorage.getItem(KEY) ?? "") as ReaderPrefs;
if (RD_THEMES.includes(p.theme) && Number.isInteger(p.sizeIdx) && p.sizeIdx >= 0 && p.sizeIdx < FONT_SIZES.length)
return p;
} catch {
/* 首次使用或存了坏值 */
}
return { theme: "sepia", sizeIdx: 2 };
});
useEffect(() => localStorage.setItem(KEY, JSON.stringify(prefs)), [prefs]);
return {
prefs,
fontSize: FONT_SIZES[prefs.sizeIdx],
cycleTheme: () =>
setPrefs((p) => ({ ...p, theme: RD_THEMES[(RD_THEMES.indexOf(p.theme) + 1) % RD_THEMES.length] })),
bump: (d: number) =>
setPrefs((p) => ({ ...p, sizeIdx: Math.min(FONT_SIZES.length - 1, Math.max(0, p.sizeIdx + d)) })),
};
}
+2 -2
View File
@@ -30,9 +30,9 @@ export default function Login() {
<form onSubmit={submit} className={`${card} w-full max-w-sm space-y-4 p-6`}>
<h1 className="text-lg font-semibold">BookLib 登录</h1>
<input className={input + " w-full"} value={u} onChange={(e) => setU(e.target.value)}
placeholder="用户名" autoFocus autoComplete="username" />
aria-label="用户名" placeholder="用户名" autoFocus autoComplete="username" />
<input className={input + " w-full"} type="password" value={p} onChange={(e) => setP(e.target.value)}
placeholder="密码" autoComplete="current-password" />
aria-label="密码" placeholder="密码" autoComplete="current-password" />
<button className={btnPrimary + " w-full"} disabled={busy || !u || !p}>
{busy ? "登录中…" : "登录"}
</button>
+13 -8
View File
@@ -57,16 +57,21 @@ export default function Reader() {
return (
<div className="flex h-full flex-col">
<header className="flex shrink-0 items-center gap-3 border-b border-zinc-800 px-4 py-2 text-sm">
<Link to="/" className="text-zinc-400 hover:text-zinc-100">
<header className="flex shrink-0 items-center gap-3 border-b border-stone-800/80 bg-stone-950/80 px-4 py-2 text-sm backdrop-blur">
<Link
to="/"
className="shrink-0 rounded-md px-2 py-1 text-stone-400 transition-colors hover:bg-stone-800 hover:text-stone-100"
>
← 书架
</Link>
<span className="truncate font-medium">{book?.title ?? "…"}</span>
<span className="text-zinc-500">
{book?.library ? book.library + " · " : ""}
{book?.format}
{book ? ` · ${Math.round(book.percent * 100)}%` : ""}
</span>
{book && (
<span className="hidden shrink-0 items-center gap-2 text-xs text-stone-500 sm:flex">
{book.library && <span>{book.library}</span>}
<span className="rounded bg-stone-800/80 px-1.5 py-0.5 uppercase">{book.format}</span>
<span className="tabular-nums">{Math.round(book.percent * 100)}%</span>
</span>
)}
{isAdmin && book && (
<button
className={btn + " ml-auto text-red-300"}
@@ -87,7 +92,7 @@ export default function Reader() {
function Msg({ text, retry }: { text: string; retry?: () => void }) {
return (
<div className="grid h-full place-items-center p-8 text-center text-zinc-500">
<div className="grid h-full place-items-center p-8 text-center text-stone-500">
<div>
<p className="mb-3">{text}</p>
{retry && (
+17 -12
View File
@@ -23,9 +23,14 @@ function BookCard({
return (
<div className="group relative w-32 shrink-0">
<button onClick={onOpen} className="block w-full text-left">
<Cover book={book} className="aspect-[3/4] w-32 rounded-lg" />
<p className="mt-1 truncate text-xs text-zinc-300">{book.title}</p>
<p className="truncate text-[10px] text-zinc-500">
<Cover
book={book}
className="aspect-[3/4] w-32 rounded-lg shadow-md ring-1 ring-stone-800 transition duration-200 group-hover:-translate-y-1 group-hover:shadow-xl group-hover:ring-stone-600"
/>
<p className="mt-1.5 truncate text-xs text-stone-200 transition-colors group-hover:text-amber-300">
{book.title}
</p>
<p className="truncate text-[10px] text-stone-500">
{book.format} · {book.path}
</p>
</button>
@@ -38,13 +43,13 @@ function BookCard({
</span>
)}
{book.percent > 0 && (
<div className="mt-1 h-1 w-32 overflow-hidden rounded bg-zinc-800">
<div className="h-full bg-emerald-600" style={{ width: `${Math.round(book.percent * 100)}%` }} />
<div className="mt-1 h-1 w-32 overflow-hidden rounded-full bg-stone-800">
<div className="h-full rounded-full bg-amber-500" style={{ width: `${Math.round(book.percent * 100)}%` }} />
</div>
)}
{isAdmin && (
<button
className="absolute right-1 top-1 hidden rounded bg-zinc-900/80 px-1.5 text-xs text-red-300 group-hover:block"
className="absolute right-1 top-1 hidden rounded bg-stone-900/80 px-1.5 text-xs text-red-300 group-hover:block"
title="删除(连磁盘文件)"
onClick={() => {
if (confirm(`删除《${book.title}》?磁盘文件一并删除,阅读进度保留`)) onDelete();
@@ -121,7 +126,7 @@ export default function Shelf() {
)}
<input
className={input + " w-44"}
placeholder="搜索标题…"
aria-label="搜索标题" placeholder="搜索标题…"
value={qRaw}
onChange={(e) => setQRaw(e.target.value)}
/>
@@ -130,7 +135,7 @@ export default function Shelf() {
/>
<div className="flex-1 overflow-y-auto p-4">
{libs.length === 0 && (
<p className="text-sm text-zinc-500">还没有书库。admin 请到「库管理」建库并扫描。</p>
<p className="text-sm text-stone-500">还没有书库。admin 请到「库管理」建库并扫描。</p>
)}
<div className="mb-4 flex flex-wrap gap-2">
<button className={lib === 0 ? btnPrimary : btn} onClick={() => setLib(0)}>
@@ -145,13 +150,13 @@ export default function Shelf() {
{continueRows.length > 0 && (
<section className="mb-8">
<h2 className="mb-2 text-sm font-semibold text-zinc-400">继续阅读</h2>
<h2 className="mb-2 text-sm font-semibold text-stone-400">继续阅读</h2>
<div className="flex gap-4 overflow-x-auto pb-2">
{continueRows.map((r) => {
const b = booksByKey.get(`${r.library_id}/${r.path}`);
if (!b)
return (
<div key={`${r.library_id}/${r.path}`} className={`w-28 shrink-0 p-2 text-xs text-zinc-600 ${card}`}>
<div key={`${r.library_id}/${r.path}`} className={`w-28 shrink-0 p-2 text-xs text-stone-600 ${card}`}>
<p className="truncate">{r.title || "已删除的书"}</p>
<p>{Math.round(r.percent * 100)}%</p>
</div>
@@ -178,11 +183,11 @@ export default function Shelf() {
</p>
)}
{!booksQ.isPending && groups.length === 0 && !booksQ.isError && (
<p className="text-sm text-zinc-500">此库暂无书目(放入文件后等待扫描或点「扫描此库」)。</p>
<p className="text-sm text-stone-500">此库暂无书目(放入文件后等待扫描或点「扫描此库」)。</p>
)}
{groups.map((g) => (
<section key={g.dir || "//"} className="mb-8">
<h2 className="mb-2 text-sm font-semibold text-zinc-400">{g.dir || "根目录"}</h2>
<h2 className="mb-2 text-sm font-semibold text-stone-400">{g.dir || "根目录"}</h2>
<div className="flex flex-wrap gap-4">
{g.books.map((b) => (
<BookCard key={b.id} book={b} onOpen={() => nav(`/book/${b.id}`)} onDelete={() => del.mutate(b.id)} />
+4 -4
View File
@@ -67,9 +67,9 @@ export default function AdminLibraries() {
<div key={l.id} className={`${card} p-4`}>
<div className="mb-1 flex items-baseline gap-3">
<span className="font-medium">{l.name}</span>
<span className="text-xs text-zinc-500">#{l.id}</span>
<span className="text-xs text-stone-500">#{l.id}</span>
</div>
<p className="mb-3 text-xs break-all text-zinc-500">{l.root_path}</p>
<p className="mb-3 text-xs break-all text-stone-500">{l.root_path}</p>
<div className="flex flex-wrap items-center gap-3">
<button className={btn} disabled={scan.isPending} onClick={() => scan.mutate(l.id)}>
{scan.isPending ? "扫描中…" : "扫描"}
@@ -90,12 +90,12 @@ export default function AdminLibraries() {
<button className={btn} onClick={() => fileRefs.current[l.id]?.click()}>
上传文件
</button>
<span className="text-xs text-zinc-600">白名单:cbz/zip/pdf/epub/txt/md</span>
<span className="text-xs text-stone-600">白名单:cbz/zip/pdf/epub/txt/md</span>
</div>
</div>
))}
{(libsQ.data ?? []).length === 0 && !libsQ.isPending && (
<p className="text-sm text-zinc-500">还没有库,用上方表单创建。</p>
<p className="text-sm text-stone-500">还没有库,用上方表单创建。</p>
)}
</div>
</div>
+4 -4
View File
@@ -55,7 +55,7 @@ export default function AdminUsers() {
</button>
</form>
<table className="w-full max-w-2xl text-sm">
<thead className="text-left text-zinc-500">
<thead className="text-left text-stone-500">
<tr>
<th className="py-1">ID</th>
<th>用户名</th>
@@ -66,11 +66,11 @@ export default function AdminUsers() {
</thead>
<tbody>
{(usersQ.data ?? []).map((x) => (
<tr key={x.id} className="border-t border-zinc-800">
<td className="py-1 text-zinc-500">{x.id}</td>
<tr key={x.id} className="border-t border-stone-800">
<td className="py-1 text-stone-500">{x.id}</td>
<td>{x.username}</td>
<td>{x.role}</td>
<td className="text-zinc-500">{x.created_at.slice(0, 19).replace("T", " ")}</td>
<td className="text-stone-500">{x.created_at.slice(0, 19).replace("T", " ")}</td>
<td className="py-1 text-right">
<button
className={btn + " text-red-300"}
+9 -6
View File
@@ -26,7 +26,7 @@ function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoade
}, [url, nonce]);
if (failed)
return (
<div className="grid place-items-center gap-2 bg-zinc-900 py-8 text-sm text-zinc-500" style={{ width }}>
<div className="grid place-items-center gap-2 bg-stone-900 py-8 text-sm text-stone-500" style={{ width }}>
本页加载失败
<button className={btn} onClick={() => setNonce((n) => n + 1)}>
重试
@@ -34,7 +34,7 @@ function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoade
</div>
);
if (!src)
return <div className="animate-pulse bg-zinc-800" style={{ width, height: width * EST_RATIO }} />;
return <div className="animate-pulse bg-stone-800" style={{ width, height: width * EST_RATIO }} />;
return (
<img
src={src}
@@ -102,10 +102,10 @@ export default function CbzReader({ book, initialLocator }: ReaderProps) {
// pages_url 缺失时 query 被 disabled 永久 pending → 只有真正发起了请求才显示加载态
if (countQ.isPending && !!book.pages_url)
return <div className="grid h-full place-items-center text-zinc-500">页索引加载中…</div>;
return <div className="grid h-full place-items-center text-stone-500">页索引加载中…</div>;
if (countQ.isError || !book.page_url_fmt)
return (
<div className="grid h-full place-items-center p-8 text-center text-zinc-500">
<div className="grid h-full place-items-center p-8 text-center text-stone-500">
无法解析页索引:{countQ.error instanceof Error ? countQ.error.message : "缺 page_url_fmt"}
</div>
);
@@ -138,8 +138,11 @@ export default function CbzReader({ book, initialLocator }: ReaderProps) {
})}
</div>
</div>
<div className="pointer-events-none absolute bottom-3 right-3 rounded bg-zinc-900/80 px-2 py-1 text-xs text-zinc-300">
{cur + 1}/{count}
<div className="pointer-events-none absolute bottom-3 right-3 flex items-center gap-2 rounded-full bg-stone-900/85 px-3 py-1.5 text-xs tabular-nums text-stone-200 shadow-lg ring-1 ring-stone-700/60 backdrop-blur">
<span>
{cur + 1}/{count}
</span>
<span className="opacity-60">{Math.round(((cur + 1) / count) * 100)}%</span>
</div>
</div>
);
+3 -3
View File
@@ -91,11 +91,11 @@ export default function EpubReader({ book, initialLocator }: ReaderProps) {
return (
<div className="grid h-full grid-rows-[1fr_auto]">
<div className="relative min-h-0 bg-white">
<div className="relative mx-auto min-h-0 w-full max-w-4xl bg-white shadow-2xl ring-1 ring-stone-800">
<div ref={hostRef} className="absolute inset-0" />
{!ready && !err && <div className="grid h-full place-items-center bg-zinc-950 text-zinc-500">EPUB 加载中…</div>}
{!ready && !err && <div className="grid h-full place-items-center bg-stone-950 text-stone-500">EPUB 加载中…</div>}
</div>
<div className="flex items-center justify-center gap-3 border-t border-zinc-800 px-4 py-2 text-sm">
<div className="flex items-center justify-center gap-3 border-t border-stone-800 px-4 py-2 text-sm">
<button className={btn} onClick={() => rendRef.current?.prev()}>
上一页
</button>
+17 -12
View File
@@ -123,28 +123,33 @@ export default function PdfReader({ book, initialLocator }: ReaderProps) {
</div>
</div>
);
if (num === 0) return <div className="grid h-full place-items-center text-zinc-500">PDF 加载中…</div>;
if (num === 0) return <div className="grid h-full place-items-center text-stone-500">PDF 加载中…</div>;
return (
<div tabIndex={0} onKeyDown={onKey} className="grid h-full grid-rows-[1fr_auto] focus:outline-none">
<div ref={wrapRef} className="flex min-h-0 items-start justify-center overflow-y-auto bg-zinc-900 p-4">
<canvas ref={canvasRef} className="rounded shadow-lg" />
<div
tabIndex={0}
onKeyDown={onKey}
aria-label="PDF 阅读器,可用左右方向键翻页"
className="grid h-full grid-rows-[1fr_auto] focus:outline-none"
>
<div ref={wrapRef} className="flex min-h-0 items-start justify-center overflow-y-auto bg-stone-900/70 p-4">
<canvas ref={canvasRef} className="max-w-3xl rounded-md shadow-2xl ring-1 ring-stone-700/50" />
</div>
<div className="flex items-center justify-center gap-3 border-t border-zinc-800 px-4 py-2 text-sm">
<button className={btn} disabled={page === 0} onClick={() => setPage(0)}>
⏮
<div className="flex items-center justify-center gap-2 border-t border-stone-800/80 px-4 py-2 text-sm">
<button className={btn} disabled={page === 0} onClick={() => setPage(0)} aria-label="第一页">
首页
</button>
<button className={btn} disabled={page === 0} onClick={() => setPage((p) => Math.max(0, p - 1))}>
上一页
← 上一页
</button>
<span className="text-zinc-400">
<span className="min-w-16 text-center tabular-nums text-stone-400">
{page + 1}/{num}
</span>
<button className={btn} disabled={page >= num - 1} onClick={() => setPage((p) => Math.min(num - 1, p + 1))}>
下一页
下一页 →
</button>
<button className={btn} disabled={page >= num - 1} onClick={() => setPage(num - 1)}>
⏭
<button className={btn} disabled={page >= num - 1} onClick={() => setPage(num - 1)} aria-label="最后一页">
末页
</button>
</div>
</div>
+102 -46
View File
@@ -1,12 +1,47 @@
import DOMPurify from "dompurify";
import { marked } from "marked";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { apiRaw } from "../api/client";
import { btn, input } from "../components/ui";
import { btn } from "../components/ui";
import { chapterText, splitChapters } from "../lib/chapters";
import { decodeText } from "../lib/encoding";
import { FONT_SIZES, RD_THEME_LABEL, useReaderPrefs } from "../lib/readerPrefs";
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
type Prefs = ReturnType<typeof useReaderPrefs>;
function PrefsBar({ pr }: { pr: Prefs }) {
return (
<div className="flex items-center gap-1.5 text-sm" role="group" aria-label="阅读偏好">
<button className="rd-btn text-xs" onClick={pr.cycleTheme} aria-label="切换阅读主题">
{RD_THEME_LABEL[pr.prefs.theme]}·主题
</button>
<button className="rd-btn" onClick={() => pr.bump(-1)} disabled={pr.prefs.sizeIdx === 0} aria-label="减小字号">
A−
</button>
<button
className="rd-btn"
onClick={() => pr.bump(1)}
disabled={pr.prefs.sizeIdx === FONT_SIZES.length - 1}
aria-label="加大字号"
>
A+
</button>
</div>
);
}
function ReaderToolbar({ pr, children }: { pr: Prefs; children?: ReactNode }) {
return (
<div className="rd-divider flex shrink-0 items-center gap-2 border-b px-4 py-1.5">
{children}
<div className="ml-auto">
<PrefsBar pr={pr} />
</div>
</div>
);
}
function useFileText(url: string) {
const [text, setText] = useState<string | null>(null);
const [err, setErr] = useState("");
@@ -31,7 +66,7 @@ function LoadState({ text, onRetry }: { text: string; onRetry?: () => void }) {
<div className="grid h-full place-items-center p-8 text-center">
<div>
{onRetry && <p className="mb-3 text-red-400">{text}</p>}
{!onRetry && <p className="text-zinc-500">{text}</p>}
{!onRetry && <p className="text-stone-500">{text}</p>}
{onRetry && (
<button className={btn} onClick={onRetry}>
重试
@@ -50,6 +85,7 @@ function MdView({ book, initialLocator }: ReaderProps) {
const boxRef = useRef<HTMLDivElement>(null);
const saver = useProgressSaver(book.id);
const restored = useRef(false);
const pr = useReaderPrefs();
const { text, err, retry } = useFileText(book.file_url ?? "");
const html = useMemo(() => (text == null ? null : DOMPurify.sanitize(marked.parse(text, { async: false }))), [text]);
@@ -75,8 +111,15 @@ function MdView({ book, initialLocator }: ReaderProps) {
if (err) return <LoadState text={err} onRetry={retry} />;
if (text == null) return <LoadState text="正文加载中…" />;
return (
<div ref={boxRef} onScroll={onScroll} className="h-full overflow-y-auto p-6">
<article className="md-body mx-auto max-w-2xl text-[15px] leading-7" dangerouslySetInnerHTML={{ __html: html ?? "" }} />
<div className="rd-surface flex h-full flex-col" data-rd={pr.prefs.theme}>
<ReaderToolbar pr={pr} />
<div ref={boxRef} onScroll={onScroll} className="min-h-0 flex-1 overflow-y-auto px-6 py-6">
<article
className="md-body mx-auto max-w-[46rem] leading-[1.85]"
style={{ fontSize: pr.fontSize }}
dangerouslySetInnerHTML={{ __html: html ?? "" }}
/>
</div>
</div>
);
}
@@ -89,6 +132,7 @@ function TxtView({ book, initialLocator }: ReaderProps) {
const restoreFrac = useRef<number | null>(null);
const targetCi = useRef(0);
const [ci, setCi] = useState(0);
const pr = useReaderPrefs();
const { text, err, retry } = useFileText(book.file_url ?? "");
const chapters = useMemo(() => (text == null ? null : splitChapters(text)), [text]);
@@ -141,53 +185,65 @@ function TxtView({ book, initialLocator }: ReaderProps) {
if (err) return <LoadState text={err} onRetry={retry} />;
if (text == null || !chapters) return <LoadState text="正文加载中…" />;
const chapterOptions = (() => {
const groups: { vol: string | undefined; items: number[] }[] = [];
chapters.forEach((c, i) => {
const last = groups[groups.length - 1];
if (!last || last.vol !== c.vol) groups.push({ vol: c.vol, items: [i] });
else last.items.push(i);
});
const opt = (i: number) => (
<option key={i} value={i}>
{i + 1}. {chapters[i].title}
</option>
);
return groups.length === 1 && !groups[0].vol
? chapters.map((_, i) => opt(i))
: groups.map((g, gi) =>
g.vol ? (
<optgroup key={gi} label={g.vol}>
{g.items.map(opt)}
</optgroup>
) : (
g.items.map(opt)
),
);
})();
return (
<div className="flex h-full flex-col">
{chapters.length > 1 && (
<div className="flex shrink-0 items-center gap-2 border-b border-zinc-800 px-4 py-2 text-sm">
<select className={input + " max-w-64"} value={ci} onChange={(e) => goChapter(Number(e.target.value))}>
{(() => {
const groups: { vol: string | undefined; items: number[] }[] = [];
chapters.forEach((c, i) => {
const last = groups[groups.length - 1];
if (!last || last.vol !== c.vol) groups.push({ vol: c.vol, items: [i] });
else last.items.push(i);
});
const opt = (i: number) => (
<option key={i} value={i}>
{i + 1}. {chapters[i].title}
</option>
);
return groups.length === 1 && !groups[0].vol
? chapters.map((_, i) => opt(i))
: groups.map((g, gi) =>
g.vol ? (
<optgroup key={gi} label={g.vol}>
{g.items.map(opt)}
</optgroup>
) : (
g.items.map(opt)
),
);
})()}
</select>
<span className="ml-auto text-zinc-500">
{ci + 1}/{chapters.length}
</span>
</div>
)}
<div ref={boxRef} onScroll={onScroll} className="min-h-0 flex-1 overflow-y-auto p-6">
<pre className="mx-auto max-w-2xl font-sans text-[15px] leading-7 break-words whitespace-pre-wrap">
<div className="rd-surface flex h-full flex-col" data-rd={pr.prefs.theme}>
<ReaderToolbar pr={pr}>
{chapters.length > 1 && (
<>
<select
className="rd-select max-w-72 truncate"
value={ci}
aria-label="选择章节"
onChange={(e) => goChapter(Number(e.target.value))}
>
{chapterOptions}
</select>
<span className="text-xs opacity-60 tabular-nums">
{ci + 1}/{chapters.length}
</span>
</>
)}
</ReaderToolbar>
<div ref={boxRef} onScroll={onScroll} className="min-h-0 flex-1 overflow-y-auto px-5 py-6 sm:px-6">
<pre
className="mx-auto max-w-[34em] break-words whitespace-pre-wrap"
style={{ fontSize: pr.fontSize, lineHeight: 1.9, textAlign: "justify" }}
>
{chapterText(text, chapters, ci)}
</pre>
</div>
{chapters.length > 1 && (
<div className="flex shrink-0 items-center justify-center gap-3 border-t border-zinc-800 px-4 py-2 text-sm">
<button className={btn} disabled={ci === 0} onClick={() => goChapter(ci - 1)}>
上一章
<div className="rd-divider flex shrink-0 items-center justify-center gap-3 border-t px-4 py-2 text-sm">
<button className="rd-btn" disabled={ci === 0} onClick={() => goChapter(ci - 1)}>
← 上一章
</button>
<button className={btn} disabled={ci === chapters.length - 1} onClick={() => goChapter(ci + 1)}>
下一章
<button className="rd-btn" disabled={ci === chapters.length - 1} onClick={() => goChapter(ci + 1)}>
下一章 →
</button>
</div>
)}
+2 -2
View File
@@ -14,8 +14,8 @@ export default defineConfig({
name: "BookLib 个人书库",
short_name: "BookLib",
description: "个人书库/漫画库在线阅读",
theme_color: "#09090b",
background_color: "#09090b",
theme_color: "#0c0a09",
background_color: "#0c0a09",
display: "standalone",
icons: [{ src: "icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" }],
},