From 32b9d3a136f749cf7e916cf066bb9b374a1f7dfa Mon Sep 17 00:00:00 2001 From: Fendy Date: Tue, 8 Sep 2026 13:05:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20content-first=20shelf=20redesign=20?= =?UTF-8?q?=E2=80=94=20responsive=20cover=20grid,=20hover=20overlays,=20fo?= =?UTF-8?q?rmat=20badges,=20skeletons,=20sticky=20blurred=20top=20bar,=20b?= =?UTF-8?q?rand=20login,=20SVG=20icon=20set,=20animated=20toasts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CHANGELOG_web.md | 3 + frontend/src/components/Cover.tsx | 32 +++- frontend/src/components/Toaster.tsx | 8 +- frontend/src/components/TopBar.tsx | 73 ++++++--- frontend/src/components/icons.tsx | 122 +++++++++++++++ frontend/src/components/ui.ts | 39 ++++- frontend/src/index.css | 11 ++ frontend/src/pages/Login.tsx | 54 ++++++- frontend/src/pages/Reader.tsx | 14 +- frontend/src/pages/Shelf.tsx | 228 +++++++++++++++++++--------- frontend/test/ui.test.ts | 12 ++ 11 files changed, 468 insertions(+), 128 deletions(-) create mode 100644 frontend/src/components/icons.tsx create mode 100644 frontend/test/ui.test.ts diff --git a/docs/CHANGELOG_web.md b/docs/CHANGELOG_web.md index 335e890..6b6a5cc 100644 --- a/docs/CHANGELOG_web.md +++ b/docs/CHANGELOG_web.md @@ -28,6 +28,9 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab ### Changed / 变更 +- Shelf rebuilt into a content-first library UI: responsive cover grid (auto-fill 3:4 posters) with hover overlays for pages/size, color-coded format badges, in-cover progress bars, staggered entrance animations, skeleton loading, and dedicated empty/error/searching states; 继续阅读 is now a horizontal resume strip (cover + progress + library); library filter became a segmented pill bar with an icon search field in a sticky blurred top bar (brand mark, admin nav pills, user chip, icon logout); login got a brand card with icon-adorned inputs; toasts animate with icons; missing covers render a serif-initial placeholder instead of an endless pulse. +- 书架重构为「内容优先」的现代书库界面:响应式封面网格(自适应列宽、3:4 海报),悬停显示页数/大小浮层、格式彩色徽章、封面内嵌进度条、卡片错峰入场动画、骨架屏加载与空/错/搜索无结果专属状态;「继续阅读」改为横向续读卡片条(封面+进度+所属库);库筛选变成分段 pill,搜索框带图标并移入毛玻璃吸顶顶栏(品牌标、管理导航 pill、用户胶囊、图标退出);登录页升级为主视觉卡片与带图标输入框;Toast 带动画与图标;无封面书籍以衬线首字占位,不再无限脉冲。 + - .txt chapter splitting no longer recognizes volumes: 「第X卷/部」 lines stay plain body text and volume-only books fall back to pseudo-sections as before; chapter switching moves from the dropdown to a 目录 sidebar overlay that highlights the current chapter, scrolls it into view, and closes on click-outside or after picking a chapter. - .txt 章节切分不再感知卷:「第X卷/部」行只作为正文文本,只有卷的书和以前一样退回伪章节切分;换章从下拉框改为「目录」侧边栏浮层——当前章高亮并自动居中可见,点击空白处或选定章节即收起。 - Whole UI restyled to a warm library palette (stone surfaces + amber accents, replacing cool zinc + emerald): warmer dark chrome, amber primary buttons/links, hover/press feedback on shelf cards (cover lift + glow), reader header with format badge and blur, pill-style CBZ page indicator, EPUB spread centered like a book page. diff --git a/frontend/src/components/Cover.tsx b/frontend/src/components/Cover.tsx index aa5a76b..81a5cc1 100644 --- a/frontend/src/components/Cover.tsx +++ b/frontend/src/components/Cover.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from "react"; import type { Book } from "../api/types"; import { fetchObjectUrl } from "../lib/authImage"; -import { btn } from "./ui"; +import { btn, formatBadge } from "./ui"; +import { IconAlert } from "./icons"; export function useAuthedImage(url: string | undefined): { src: string; failed: boolean; retry: () => void } { const [state, setState] = useState<{ key: string; src: string; failed: boolean }>({ key: "", src: "", failed: false }); @@ -27,14 +28,29 @@ export function useAuthedImage(url: string | undefined): { src: string; failed: export function Cover({ book, className }: { book: Book; className?: string }) { const { src, failed, retry } = useAuthedImage(book.cover_url); - if (failed) + if (!book.cover_url || failed) { return ( -
- +
+ {Array.from(book.title)[0] ?? "?"} + {book.format} + {failed && ( + + )}
); - if (!src) return
; - return ; + } + if (!src) return
; + return ( + {`《${book.title}》封面`} + ); } diff --git a/frontend/src/components/Toaster.tsx b/frontend/src/components/Toaster.tsx index a569d6d..d5e29af 100644 --- a/frontend/src/components/Toaster.tsx +++ b/frontend/src/components/Toaster.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, type ReactNode } from "react"; +import { IconAlert, IconCheck } from "./icons"; type Toast = { id: number; kind: "ok" | "err"; text: ReactNode }; @@ -31,10 +32,13 @@ export function Toaster() {
+ {t.kind === "ok" ? : } {t.text}
))} diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index bd5687f..9f8dea3 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -1,41 +1,64 @@ import type { ReactNode } from "react"; -import { Link } from "react-router-dom"; +import { Link, NavLink } from "react-router-dom"; import { useAuth } from "../auth/AuthContext"; -import { btn } from "./ui"; +import { IconBooks, IconFolder, IconLogout, IconUser } from "./icons"; +import { btnGhost } from "./ui"; export function TopBar({ right }: { right?: ReactNode }) { const { user, isAdmin, logout } = useAuth(); return ( -
- - BookLib +
+ + + + + + BookLib + {isAdmin && ( - - 用户 - + )} - {isAdmin && ( - - 库管理 - - )} -
+
{right} - - {user?.username} - {isAdmin ? "(admin)" : ""} + + + {Array.from(user?.username ?? "?")[0]?.toUpperCase()} + + {user?.username} + {isAdmin && admin} -
); } + +function TabLink({ to, children }: { to: string; children: ReactNode }) { + return ( + + `flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 ${ + isActive ? "bg-white/[0.09] text-stone-100 ring-1 ring-white/10" : "text-stone-400 hover:bg-white/[0.06] hover:text-stone-100" + }` + } + > + {children} + + ); +} diff --git a/frontend/src/components/icons.tsx b/frontend/src/components/icons.tsx new file mode 100644 index 0000000..8a0782c --- /dev/null +++ b/frontend/src/components/icons.tsx @@ -0,0 +1,122 @@ +import type { SVGProps } from "react"; + +// 统一 1.7px 描边的线性图标(Phosphor 风格),尺寸由外部 className 控制 +function Svg({ children, ...p }: SVGProps) { + return ( + + ); +} + +type P = SVGProps; + +export const IconBooks = (p: P) => ( + + + + + +); + +export const IconSearch = (p: P) => ( + + + + +); + +export const IconRefresh = (p: P) => ( + + + + +); + +export const IconScan = (p: P) => ( + + + + +); + +export const IconX = (p: P) => ( + + + +); + +export const IconTrash = (p: P) => ( + + + +); + +export const IconLogout = (p: P) => ( + + + + +); + +export const IconArrowLeft = (p: P) => ( + + + +); + +export const IconBookOpen = (p: P) => ( + + + + +); + +export const IconUser = (p: P) => ( + + + + +); + +export const IconLock = (p: P) => ( + + + + +); + +export const IconCheck = (p: P) => ( + + + +); + +export const IconAlert = (p: P) => ( + + + + +); + +export const IconClock = (p: P) => ( + + + + +); + +export const IconFolder = (p: P) => ( + + + +); diff --git a/frontend/src/components/ui.ts b/frontend/src/components/ui.ts index e9a37aa..dbbad4f 100644 --- a/frontend/src/components/ui.ts +++ b/frontend/src/components/ui.ts @@ -1,7 +1,38 @@ +import type { Format } from "../api/types"; + export const btn = - "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"; + "inline-flex items-center gap-1.5 rounded-lg bg-white/[0.06] px-3 py-1.5 text-sm ring-1 ring-white/10 transition-colors hover:bg-white/[0.11] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 active:bg-white/[0.15] disabled:cursor-not-allowed disabled:opacity-50"; export const btnPrimary = - "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"; + "inline-flex items-center gap-1.5 rounded-lg bg-amber-500 px-3 py-1.5 text-sm font-medium text-stone-950 shadow-md shadow-amber-500/20 transition-colors hover:bg-amber-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 active:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"; +export const btnGhost = + "inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-stone-400 transition-colors hover:bg-white/[0.07] hover:text-stone-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500"; export const input = - "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"; + "rounded-lg bg-white/[0.04] px-3 py-1.5 text-sm ring-1 ring-white/10 outline-none transition placeholder:text-stone-500 focus:bg-white/[0.06] focus:ring-2 focus:ring-amber-500/60"; +export const card = "rounded-xl bg-stone-900/70 ring-1 ring-white/[0.07]"; + +// 库/筛选分段 pill +export const pill = + "rounded-full px-3.5 py-1.5 text-sm text-stone-400 ring-1 ring-transparent transition-colors hover:bg-white/[0.07] hover:text-stone-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500"; +export const pillActive = "bg-amber-500/15 text-amber-300 ring-amber-500/30 hover:bg-amber-500/20 hover:text-amber-200"; + +// 格式徽章:按类型着色,色彩之外始终伴随文字,不依赖颜色单独传义 +export const FORMAT_BADGE: Record = { + cbz: "bg-orange-500/15 text-orange-300 ring-orange-500/25", + pdf: "bg-rose-500/15 text-rose-300 ring-rose-500/25", + epub: "bg-sky-500/15 text-sky-300 ring-sky-500/25", + txt: "bg-emerald-500/15 text-emerald-300 ring-emerald-500/25", + md: "bg-teal-500/15 text-teal-300 ring-teal-500/25", +}; + +export function formatBadge(fmt: Format, extra = ""): string { + return `rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ring-1 ${FORMAT_BADGE[fmt]} ${extra}`; +} + +export function formatSize(bytes: number): string { + if (bytes <= 0) return ""; + const units = ["B", "KB", "MB", "GB"]; + let i = 0; + let n = bytes; + for (; n >= 1024 && i < units.length - 1; i++) n /= 1024; + return `${n >= 10 || i === 0 ? Math.round(n) : n.toFixed(1)} ${units[i]}`; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 277608f..d5fc3b1 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -7,7 +7,17 @@ body, } body { @apply bg-stone-950 text-stone-100 antialiased; + /* 顶部一抹极淡的琥珀氛围光,避免大面积纯黑的"朴素感" */ + background-image: radial-gradient(60rem 28rem at 50% -8rem, rgb(217 119 6 / 0.07), transparent 70%); + background-repeat: no-repeat; } + +/* 入场:卡片/区块轻微上浮淡入(列表 stagger 用 style.animationDelay) */ +@keyframes fx-rise { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: none; } +} +.fx-rise { animation: fx-rise 0.35s cubic-bezier(0.22, 1, 0.36, 1) both; } ::-webkit-scrollbar { width: 10px; height: 10px; @@ -57,6 +67,7 @@ body { @media (prefers-reduced-motion: reduce) { .rd-sheet { animation: none; } .rd-rise-anim { transition: none !important; } + .fx-rise { animation: none; } } /* markdown 正文(仅 .md-body 作用域) */ diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index ed47ef0..5396009 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,9 +1,21 @@ -import { useState, type FormEvent } from "react"; +import { useState, type FormEvent, type ReactNode } from "react"; import { Navigate, useLocation } from "react-router-dom"; import { useAuth } from "../auth/AuthContext"; +import { IconBooks, IconLock, IconUser } from "../components/icons"; import { toast } from "../components/Toaster"; import { btnPrimary, card, input } from "../components/ui"; +function Field({ icon, children }: { icon: ReactNode; children: ReactNode }) { + return ( + + ); +} + export default function Login() { const { login, user } = useAuth(); const loc = useLocation() as { state?: { from?: string } }; @@ -27,13 +39,39 @@ export default function Login() { return (
-
-

BookLib 登录

- setU(e.target.value)} - aria-label="用户名" placeholder="用户名" autoFocus autoComplete="username" /> - setP(e.target.value)} - aria-label="密码" placeholder="密码" autoComplete="current-password" /> -
diff --git a/frontend/src/pages/Reader.tsx b/frontend/src/pages/Reader.tsx index ef2635a..d4854c2 100644 --- a/frontend/src/pages/Reader.tsx +++ b/frontend/src/pages/Reader.tsx @@ -4,8 +4,9 @@ import { Link, useNavigate, useParams } from "react-router-dom"; import { api } from "../api/client"; import type { Format } from "../api/types"; import { useAuth } from "../auth/AuthContext"; +import { IconArrowLeft, IconTrash } from "../components/icons"; import { toast } from "../components/Toaster"; -import { btn } from "../components/ui"; +import { btn, btnGhost, formatBadge } from "../components/ui"; import type { ReaderProps } from "../lib/useProgress"; const READERS: Partial>>> = { @@ -68,17 +69,15 @@ export default function Reader() { (chromeOn ? "h-11 py-2 opacity-100" : "h-0 border-b-0 py-0 opacity-0") } > - - ← 书架 + + + 书架 {book?.title ?? "…"} {book && ( {book.library && {book.library}} - {book.format} + {book.format} {Math.round(book.percent * 100)}% )} @@ -89,6 +88,7 @@ export default function Reader() { if (confirm(`删除《${book.title}》?磁盘文件一并删除,阅读进度保留`)) del.mutate(); }} > + 删除 )} diff --git a/frontend/src/pages/Shelf.tsx b/frontend/src/pages/Shelf.tsx index 954a144..c28fd21 100644 --- a/frontend/src/pages/Shelf.tsx +++ b/frontend/src/pages/Shelf.tsx @@ -5,63 +5,87 @@ import { api } from "../api/client"; import type { Book } from "../api/types"; import { useAuth } from "../auth/AuthContext"; import { Cover } from "../components/Cover"; +import { IconAlert, IconBookOpen, IconClock, IconFolder, IconRefresh, IconScan, IconSearch, IconX } from "../components/icons"; import { TopBar } from "../components/TopBar"; import { toast } from "../components/Toaster"; -import { btn, btnPrimary, card, input } from "../components/ui"; +import { btn, card, formatBadge, formatSize, input, pill, pillActive } from "../components/ui"; import { groupByDir } from "../lib/group"; function BookCard({ book, + index, onOpen, onDelete, }: { book: Book; + index: number; onOpen: () => void; onDelete: () => void; }) { const { isAdmin } = useAuth(); + const pct = Math.round(book.percent * 100); return ( -
- - {book.state === "error" && ( - - 损坏 - - )} - {book.percent > 0 && ( -
-
-
- )} {isAdmin && ( )}
); } +const GRID = "grid grid-cols-[repeat(auto-fill,minmax(7.5rem,1fr))] gap-x-4 gap-y-6 sm:grid-cols-[repeat(auto-fill,minmax(9rem,1fr))]"; + +function Section({ title, icon, children }: { title: string; icon?: React.ReactNode; children: React.ReactNode }) { + return ( +
+

+ {icon} + {title} +

+ {children} +
+ ); +} + export default function Shelf() { const qc = useQueryClient(); const nav = useNavigate(); @@ -110,90 +134,146 @@ export default function Shelf() { const continueRows = (progQ.data ?? []).slice(0, 12); const libs = libsQ.data ?? []; + const searching = q.length > 0; return (
- +
+ + setQRaw(e.target.value)} + /> +
{isAdmin && lib > 0 && ( )} - setQRaw(e.target.value)} - /> + } /> -
- {libs.length === 0 && ( -

还没有书库。admin 请到「库管理」建库并扫描。

+
+ {libs.length === 0 && !libsQ.isPending && ( +

+ + 还没有书库。admin 请到「库管理」建库并扫描。 +

)} -
- - {libs.map((l) => ( - - ))} -
+ {libs.map((l) => ( + + ))} + + )} - {continueRows.length > 0 && ( -
-

继续阅读

-
- {continueRows.map((r) => { + {continueRows.length > 0 && !searching && ( +
}> +
    + {continueRows.map((r, i) => { const b = booksByKey.get(`${r.library_id}/${r.path}`); if (!b) return ( -
    -

    {r.title || "已删除的书"}

    -

    {Math.round(r.percent * 100)}%

    -
    +
  • +

    {r.title || "已删除的书"}

    +

    #{r.library_id}/{r.path} · 进度已保留

    +
  • ); + const pct = Math.round(r.percent * 100); return ( - nav(`/book/${b.id}`)} - onDelete={() => del.mutate(b.id)} - /> +
  • + +
  • ); })} -
-
+ + )} {booksQ.isError && ( -

- 加载失败:{booksQ.error instanceof Error ? booksQ.error.message : ""}{" "} +

+ + 加载失败:{booksQ.error instanceof Error ? booksQ.error.message : "未知错误"} -

+
)} + + {booksQ.isPending && !booksQ.isError && ( +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+
+
+
+
+ ))} +
+ )} + {!booksQ.isPending && groups.length === 0 && !booksQ.isError && ( -

此库暂无书目(放入文件后等待扫描或点「扫描此库」)。

+
+ +

{searching ? `没有匹配「${q}」的书` : "此库暂无书目"}

+

+ {searching ? "换个关键词试试。" : "放入文件后等待扫描,或点上方「扫描此库」。"} +

+
)} + {groups.map((g) => ( -
-

{g.dir || "根目录"}

-
- {g.books.map((b) => ( - nav(`/book/${b.id}`)} onDelete={() => del.mutate(b.id)} /> +
} + > +
+ {g.books.map((b, i) => ( + nav(`/book/${b.id}`)} onDelete={() => del.mutate(b.id)} /> ))}
-
+
))}
diff --git a/frontend/test/ui.test.ts b/frontend/test/ui.test.ts new file mode 100644 index 0000000..3c6dde2 --- /dev/null +++ b/frontend/test/ui.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { formatSize } from "../src/components/ui"; + +describe("formatSize", () => { + it("renders human sizes across units", () => { + expect(formatSize(0)).toBe(""); + expect(formatSize(512)).toBe("512 B"); + expect(formatSize(2048)).toBe("2.0 KB"); + expect(formatSize(15 * 1024 * 1024)).toBe("15 MB"); + expect(formatSize(3.2 * 1024 ** 3)).toBe("3.2 GB"); + }); +});