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
+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"}