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:
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user