fix(web): active lib highlight, rail a11y names, card focus ring, AA accent + destructive tokens, touch-reachable menu, brand wordmark, chrome unify
This commit is contained in:
@@ -34,8 +34,8 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
|
|||||||
- The web UI was rebuilt on a shadcn/Radix design system: semantic light/dark theme tokens with a system/light/dark switcher (remembered), a persistent app shell (desktop sidebar, tablet icon rail, mobile bottom tabs), a shelf driven by URL-state search/sort/group controls with a flat-first grid and menu-based card actions (confirm dialogs replace native popups), and rebuilt login/admin pages. Readers keep their current chrome and every reading interaction this round; their restyle lands next.
|
- The web UI was rebuilt on a shadcn/Radix design system: semantic light/dark theme tokens with a system/light/dark switcher (remembered), a persistent app shell (desktop sidebar, tablet icon rail, mobile bottom tabs), a shelf driven by URL-state search/sort/group controls with a flat-first grid and menu-based card actions (confirm dialogs replace native popups), and rebuilt login/admin pages. Readers keep their current chrome and every reading interaction this round; their restyle lands next.
|
||||||
- 网页 UI 以 shadcn/Radix 设计系统重做:语义化明暗主题 token + 系统/浅色/深色切换(记忆);常驻应用壳(桌面侧栏、平板图标栏、手机底部标签);书架改为 URL 状态驱动的搜索/排序/分组工具条,默认平铺网格,卡片操作收进菜单(删除改为对话框确认);登录页与 admin 两页同步重做。阅读器保持现状,改版随下一期到达。
|
- 网页 UI 以 shadcn/Radix 设计系统重做:语义化明暗主题 token + 系统/浅色/深色切换(记忆);常驻应用壳(桌面侧栏、平板图标栏、手机底部标签);书架改为 URL 状态驱动的搜索/排序/分组工具条,默认平铺网格,卡片操作收进菜单(删除改为对话框确认);登录页与 admin 两页同步重做。阅读器保持现状,改版随下一期到达。
|
||||||
|
|
||||||
- Shelf rebuilt into a content-first library UI: responsive cover grid (auto-fill 3:4 posters) with a pages/size caption, 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); 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.
|
- Shelf rebuilt into a content-first library UI: responsive cover grid (auto-fill 3:4 posters) with a pages/size caption, 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); login got a clean brand card; toasts animate with icons; missing covers render a serif-initial placeholder instead of an endless pulse.
|
||||||
- 书架重构为「内容优先」的现代书库界面:响应式封面网格(自适应列宽、3:4 海报),封面下方显示页数/大小、格式彩色徽章、封面内嵌进度条、卡片错峰入场动画、骨架屏加载与空/错/搜索无结果专属状态;「继续阅读」改为横向续读卡片条(封面+进度+所属库);登录页升级为主视觉卡片与带图标输入框;Toast 带动画与图标;无封面书籍以衬线首字占位,不再无限脉冲。
|
- 书架重构为「内容优先」的现代书库界面:响应式封面网格(自适应列宽、3:4 海报),封面下方显示页数/大小、格式彩色徽章、封面内嵌进度条、卡片错峰入场动画、骨架屏加载与空/错/搜索无结果专属状态;「继续阅读」改为横向续读卡片条(封面+进度+所属库);登录页升级为主视觉卡片;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 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卷/部」行只作为正文文本,只有卷的书和以前一样退回伪章节切分;换章从下拉框改为「目录」侧边栏浮层——当前章高亮并自动居中可见,点击空白处或选定章节即收起。
|
- .txt 章节切分不再感知卷:「第X卷/部」行只作为正文文本,只有卷的书和以前一样退回伪章节切分;换章从下拉框改为「目录」侧边栏浮层——当前章高亮并自动居中可见,点击空白处或选定章节即收起。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { BookMarked, BookOpen, FolderCog, LogOut, Users } from "lucide-react";
|
import { BookMarked, BookOpen, FolderCog, LogOut, Users } from "lucide-react";
|
||||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
|
||||||
import { api } from "@/api/client";
|
import { api } from "@/api/client";
|
||||||
import { useAuth } from "@/auth/AuthContext";
|
import { useAuth } from "@/auth/AuthContext";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -10,31 +10,32 @@ import { ThemeToggle } from "./theme-toggle";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
function Brand({ compact }: { compact?: boolean }) {
|
function Brand() {
|
||||||
return (
|
return (
|
||||||
<NavLink to="/" className="flex h-12 shrink-0 items-center gap-2 rounded-md px-3 focus-visible:outline-2 focus-visible:outline-offset-2">
|
<Link to="/" aria-label="BookLib 首页" className="flex h-12 shrink-0 items-center gap-2 rounded-md px-3 focus-visible:outline-2 focus-visible:outline-offset-2">
|
||||||
<span className="grid size-6 place-items-center rounded bg-accent text-accent-foreground">
|
<span className="grid size-6 place-items-center rounded bg-accent text-accent-foreground">
|
||||||
<BookMarked className="size-4" />
|
<BookMarked className="size-4" />
|
||||||
</span>
|
</span>
|
||||||
{!compact && <span className="text-[15px] font-semibold tracking-tight">BookLib</span>}
|
<span className="hidden text-[15px] font-semibold tracking-tight lg:inline">BookLib</span>
|
||||||
</NavLink>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavItem({ to, icon, label, search }: { to: string; icon: ReactNode; label: string; search?: string }) {
|
/* NavLink 不可用:其 isActive/aria-current 前缀匹配 "/" 会无视 search,导致 ?lib=N 全部同时高亮 */
|
||||||
|
function NavItem({ to, icon, label, active }: { to: string; icon: ReactNode; label: string; active: boolean }) {
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<Link
|
||||||
to={to + (search ?? "")}
|
to={to}
|
||||||
className={({ isActive }) =>
|
aria-label={label}
|
||||||
cn(
|
aria-current={active ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground",
|
"flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground",
|
||||||
isActive && "bg-secondary font-medium text-foreground",
|
active && "bg-secondary font-medium text-foreground",
|
||||||
)
|
)}
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<span className="hidden lg:inline">{label}</span>
|
<span className="hidden lg:inline">{label}</span>
|
||||||
</NavLink>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,12 +44,13 @@ export function AppShell() {
|
|||||||
const loc = useLocation();
|
const loc = useLocation();
|
||||||
const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries });
|
const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries });
|
||||||
const libs = libsQ.data ?? [];
|
const libs = libsQ.data ?? [];
|
||||||
|
const libParam = new URLSearchParams(loc.search).get("lib");
|
||||||
|
|
||||||
const account = (
|
const account = (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" className="gap-2 px-2">
|
<Button variant="ghost" className="gap-2 px-2" aria-label={`账户 ${user?.username ?? ""}`}>
|
||||||
<span className="grid size-7 place-items-center rounded-full bg-accent/15 text-xs font-semibold text-accent">
|
<span className="grid size-7 place-items-center rounded-full bg-accent/15 text-xs font-semibold text-accent-strong">
|
||||||
{Array.from(user?.username ?? "?")[0]?.toUpperCase()}
|
{Array.from(user?.username ?? "?")[0]?.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
<span className="hidden max-w-28 truncate text-sm lg:inline">{user?.username}</span>
|
<span className="hidden max-w-28 truncate text-sm lg:inline">{user?.username}</span>
|
||||||
@@ -67,14 +69,14 @@ export function AppShell() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
{/* 桌面/平板侧栏;lg 全文标签,md 图标栏 */}
|
{/* 桌面/平板侧栏;lg 全文标签,md 图标栏 */}
|
||||||
<aside className="hidden w-14 shrink-0 flex-col gap-1 border-r bg-card/60 p-2 md:flex lg:w-60">
|
<aside className="hidden w-14 shrink-0 flex-col gap-1 border-r bg-background/85 p-2 backdrop-blur md:flex lg:w-60">
|
||||||
<Brand compact />
|
<Brand />
|
||||||
<nav aria-label="主导航" className="flex flex-col gap-1">
|
<nav aria-label="主导航" className="flex flex-col gap-1">
|
||||||
<NavItem to="/" icon={<BookOpen className="size-4 shrink-0" />} label="书架" />
|
<NavItem to="/" icon={<BookOpen className="size-4 shrink-0" />} label="书架" active={loc.pathname === "/" && !libParam} />
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<NavItem to="/admin/libraries" icon={<FolderCog className="size-4 shrink-0" />} label="库管理" />
|
<NavItem to="/admin/libraries" icon={<FolderCog className="size-4 shrink-0" />} label="库管理" active={loc.pathname.startsWith("/admin/libraries")} />
|
||||||
<NavItem to="/admin/users" icon={<Users className="size-4 shrink-0" />} label="用户管理" />
|
<NavItem to="/admin/users" icon={<Users className="size-4 shrink-0" />} label="用户管理" active={loc.pathname.startsWith("/admin/users")} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -82,9 +84,8 @@ export function AppShell() {
|
|||||||
<div className="mt-3 hidden min-h-0 flex-1 flex-col lg:flex">
|
<div className="mt-3 hidden min-h-0 flex-1 flex-col lg:flex">
|
||||||
<p className="px-3 pb-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">书库</p>
|
<p className="px-3 pb-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">书库</p>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<NavItem to="/" search="?lib=0" icon={<span className="size-1.5 shrink-0 rounded-full bg-muted-foreground/50" />} label="全部" />
|
|
||||||
{libs.map((l) => (
|
{libs.map((l) => (
|
||||||
<NavItem key={l.id} to="/" search={`?lib=${l.id}`} icon={<span className="size-1.5 shrink-0 rounded-full bg-accent/60" />} label={l.name} />
|
<NavItem key={l.id} to={`/?lib=${l.id}`} icon={<span className="size-1.5 shrink-0 rounded-full bg-accent/60" />} label={l.name} active={loc.pathname === "/" && libParam === String(l.id)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,7 +99,7 @@ export function AppShell() {
|
|||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
{/* 移动端顶栏 */}
|
{/* 移动端顶栏 */}
|
||||||
<header className="flex h-12 shrink-0 items-center justify-between border-b bg-card/60 px-3 lg:hidden">
|
<header className="flex h-12 shrink-0 items-center justify-between border-b bg-background/85 px-3 backdrop-blur lg:hidden">
|
||||||
<Brand />
|
<Brand />
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
@@ -109,8 +110,8 @@ export function AppShell() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
{/* 移动端底 Tab */}
|
{/* 移动端底 Tab */}
|
||||||
<nav aria-label="底部导航" className="flex h-14 shrink-0 items-stretch border-t bg-card/90 backdrop-blur md:hidden">
|
<nav aria-label="底部导航" className="flex h-14 shrink-0 items-stretch border-t bg-background/85 backdrop-blur md:hidden">
|
||||||
<TabItem to="/" icon={<BookOpen className="size-5" />} label="书架" search="?lib=0" />
|
<TabItem to="/" icon={<BookOpen className="size-5" />} label="书架" />
|
||||||
{isAdmin && <TabItem to="/admin/libraries" icon={<FolderCog className="size-5" />} label="库管理" />}
|
{isAdmin && <TabItem to="/admin/libraries" icon={<FolderCog className="size-5" />} label="库管理" />}
|
||||||
{isAdmin && <TabItem to="/admin/users" icon={<Users className="size-5" />} label="用户" />}
|
{isAdmin && <TabItem to="/admin/users" icon={<Users className="size-5" />} label="用户" />}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -119,9 +120,9 @@ export function AppShell() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabItem({ to, icon, label, search }: { to: string; icon: ReactNode; label: string; search?: string }) {
|
function TabItem({ to, icon, label }: { to: string; icon: ReactNode; label: string }) {
|
||||||
return (
|
return (
|
||||||
<NavLink to={to + (search ?? "")} className={({ isActive }) => cn("flex flex-col items-center justify-center gap-0.5 text-[11px] text-muted-foreground", isActive && "text-accent")}>
|
<NavLink to={to} end={to === "/"} className={({ isActive }) => cn("flex flex-col items-center justify-center gap-0.5 text-[11px] text-muted-foreground", isActive && "text-accent-strong")}>
|
||||||
{icon}
|
{icon}
|
||||||
{label}
|
{label}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { Format } from "../api/types";
|
import type { Format } from "../api/types";
|
||||||
|
|
||||||
export { formatSize } from "../lib/format"; // deprecated: phase-2 随阅读器清除
|
|
||||||
|
|
||||||
export const btn =
|
export const btn =
|
||||||
"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";
|
"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 btnGhost =
|
export const btnGhost =
|
||||||
|
|||||||
@@ -18,8 +18,10 @@
|
|||||||
--muted: oklch(0.95 0.003 80);
|
--muted: oklch(0.95 0.003 80);
|
||||||
--muted-foreground: oklch(0.55 0.008 70);
|
--muted-foreground: oklch(0.55 0.008 70);
|
||||||
--accent: oklch(0.68 0.16 46);
|
--accent: oklch(0.68 0.16 46);
|
||||||
|
--accent-strong: oklch(0.55 0.17 46);
|
||||||
--accent-foreground: oklch(0.985 0.002 80);
|
--accent-foreground: oklch(0.985 0.002 80);
|
||||||
--destructive: oklch(0.58 0.21 27);
|
--destructive: oklch(0.58 0.21 27);
|
||||||
|
--destructive-foreground: oklch(0.98 0.002 80);
|
||||||
--border: oklch(0.91 0.004 80);
|
--border: oklch(0.91 0.004 80);
|
||||||
--input: oklch(0.91 0.004 80);
|
--input: oklch(0.91 0.004 80);
|
||||||
--ring: oklch(0.62 0.01 70);
|
--ring: oklch(0.62 0.01 70);
|
||||||
@@ -38,8 +40,10 @@
|
|||||||
--muted: oklch(0.28 0.006 80);
|
--muted: oklch(0.28 0.006 80);
|
||||||
--muted-foreground: oklch(0.68 0.006 80);
|
--muted-foreground: oklch(0.68 0.006 80);
|
||||||
--accent: oklch(0.7 0.16 46);
|
--accent: oklch(0.7 0.16 46);
|
||||||
|
--accent-strong: oklch(0.72 0.16 46);
|
||||||
--accent-foreground: oklch(0.19 0.006 80);
|
--accent-foreground: oklch(0.19 0.006 80);
|
||||||
--destructive: oklch(0.65 0.19 25);
|
--destructive: oklch(0.65 0.19 25);
|
||||||
|
--destructive-foreground: oklch(0.19 0.006 80);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 14%);
|
--input: oklch(1 0 0 / 14%);
|
||||||
--ring: oklch(0.6 0.01 70);
|
--ring: oklch(0.6 0.01 70);
|
||||||
@@ -63,8 +67,10 @@
|
|||||||
--color-muted: var(--muted);
|
--color-muted: var(--muted);
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-strong: var(--accent-strong);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
--color-destructive: var(--destructive);
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ function BookCard({ book, index, onOpen, onDelete }: { book: Book; index: number
|
|||||||
const pct = Math.round(book.percent * 100);
|
const pct = Math.round(book.percent * 100);
|
||||||
return (
|
return (
|
||||||
<div className="group fx-rise relative flex flex-col" style={{ animationDelay: `${Math.min(index, 23) * 25}ms` }}>
|
<div className="group fx-rise relative flex flex-col" style={{ animationDelay: `${Math.min(index, 23) * 25}ms` }}>
|
||||||
<button onClick={onOpen} className="block w-full p-0 text-left focus-visible:outline-none">
|
<button onClick={onOpen} className="block w-full rounded-md p-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
|
||||||
<span className="relative block aspect-[3/4] overflow-hidden rounded-md border bg-muted shadow-sm transition group-hover:shadow-md group-hover:ring-2 group-hover:ring-accent/50">
|
<span className="relative block aspect-[3/4] overflow-hidden rounded-md border bg-muted shadow-sm transition group-hover:shadow-md group-hover:ring-2 group-hover:ring-accent/50">
|
||||||
<Cover book={book} className="size-full" />
|
<Cover book={book} className="size-full" />
|
||||||
<span className={formatBadge(book.format, "absolute left-1.5 top-1.5")}>{book.format}</span>
|
<span className={formatBadge(book.format, "absolute left-1.5 top-1.5")}>{book.format}</span>
|
||||||
{book.state === "error" && (
|
{book.state === "error" && (
|
||||||
<span className="absolute right-1.5 top-1.5 flex items-center gap-1 rounded bg-destructive px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground" title={book.error}>
|
<span className="absolute right-1.5 top-1.5 flex items-center gap-1 rounded bg-destructive px-1.5 py-0.5 text-[10px] font-semibold text-destructive-foreground" title={book.error}>
|
||||||
<TriangleAlert className="size-3" /> 损坏
|
<TriangleAlert className="size-3" /> 损坏
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -47,7 +47,7 @@ function BookCard({ book, index, onOpen, onDelete }: { book: Book; index: number
|
|||||||
{book.path.includes("/") ? book.path.slice(0, book.path.lastIndexOf("/")) : `${book.pages > 0 ? book.pages + " 页 · " : ""}${formatSize(book.size)}`}
|
{book.path.includes("/") ? book.path.slice(0, book.path.lastIndexOf("/")) : `${book.pages > 0 ? book.pages + " 页 · " : ""}${formatSize(book.size)}`}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="absolute right-1 top-1 z-10 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
|
<div className="absolute right-1 top-1 z-10 opacity-100 transition-opacity md:opacity-0 md:focus-within:opacity-100 md:group-hover:opacity-100">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="secondary" size="icon" className="size-7 bg-background/80 backdrop-blur" aria-label={`《${book.title}》操作`}>
|
<Button variant="secondary" size="icon" className="size-7 bg-background/80 backdrop-blur" aria-label={`《${book.title}》操作`}>
|
||||||
@@ -55,11 +55,11 @@ function BookCard({ book, index, onOpen, onDelete }: { book: Book; index: number
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={onOpen}>打开</DropdownMenuItem>
|
<DropdownMenuItem onSelect={onOpen}>打开</DropdownMenuItem>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={onDelete}>
|
<DropdownMenuItem variant="destructive" onSelect={onDelete}>
|
||||||
删除
|
删除
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</>
|
</>
|
||||||
@@ -74,7 +74,7 @@ function BookCard({ book, index, onOpen, onDelete }: { book: Book; index: number
|
|||||||
function SectionHead({ label, count }: { label: string; count: number }) {
|
function SectionHead({ label, count }: { label: string; count: number }) {
|
||||||
if (!label) return null;
|
if (!label) return null;
|
||||||
return (
|
return (
|
||||||
<h2 className="sticky top-0 z-10 mb-3 flex items-center gap-2 bg-background/90 py-1.5 text-sm font-medium backdrop-blur">
|
<h2 className="sticky top-0 z-10 mb-3 flex items-center gap-2 bg-background/85 py-1.5 text-sm font-medium backdrop-blur">
|
||||||
<FolderOpen className="size-4 text-muted-foreground" />
|
<FolderOpen className="size-4 text-muted-foreground" />
|
||||||
{label}
|
{label}
|
||||||
<span className="text-xs tabular-nums text-muted-foreground">{count}</span>
|
<span className="text-xs tabular-nums text-muted-foreground">{count}</span>
|
||||||
@@ -148,7 +148,7 @@ export default function Shelf() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-6xl flex-col px-4 pb-20 sm:px-6 md:pb-6">
|
<div className="mx-auto flex w-full max-w-6xl flex-col px-4 pb-20 sm:px-6 md:pb-6">
|
||||||
<div className="sticky top-0 z-20 -mx-4 mb-6 flex flex-wrap items-center gap-2 border-b bg-background/90 px-4 py-3 backdrop-blur sm:-mx-6 sm:px-6">
|
<div className="sticky top-0 z-20 -mx-4 mb-6 flex flex-wrap items-center gap-2 border-b bg-background/85 px-4 py-3 backdrop-blur sm:-mx-6 sm:px-6">
|
||||||
<div className="relative min-w-0 flex-1 sm:max-w-64">
|
<div className="relative min-w-0 flex-1 sm:max-w-64">
|
||||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input className="pl-8" type="search" aria-label="搜索标题" placeholder="搜索标题…" value={qRaw} onChange={(e) => setQRaw(e.target.value)} />
|
<Input className="pl-8" type="search" aria-label="搜索标题" placeholder="搜索标题…" value={qRaw} onChange={(e) => setQRaw(e.target.value)} />
|
||||||
@@ -163,7 +163,7 @@ export default function Shelf() {
|
|||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuLabel>排序</DropdownMenuLabel>
|
<DropdownMenuLabel>排序</DropdownMenuLabel>
|
||||||
{SORTS.map((k) => (
|
{SORTS.map((k) => (
|
||||||
<DropdownMenuItem key={k} onClick={() => setParam("sort", k === "added" ? "" : k)} className={k === sort ? "font-medium text-accent" : ""}>
|
<DropdownMenuItem key={k} onSelect={() => setParam("sort", k === "added" ? "" : k)} className={k === sort ? "font-medium text-accent-strong" : ""}>
|
||||||
{SORT_LABEL[k]}
|
{SORT_LABEL[k]}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
@@ -190,7 +190,7 @@ export default function Shelf() {
|
|||||||
{lib === LIB0 && <p className="sr-only" aria-live="polite">{`当前筛选:${libName}`}</p>}
|
{lib === LIB0 && <p className="sr-only" aria-live="polite">{`当前筛选:${libName}`}</p>}
|
||||||
|
|
||||||
{(libsQ.data ?? []).length === 0 && !libsQ.isPending && !searching && (
|
{(libsQ.data ?? []).length === 0 && !libsQ.isPending && !searching && (
|
||||||
<p className="mb-6 flex items-center gap-2 rounded-md border border-accent/30 bg-accent/10 px-3 py-2 text-sm text-accent">
|
<p className="mb-6 flex items-center gap-2 rounded-md border border-accent/30 bg-accent/10 px-3 py-2 text-sm text-accent-strong">
|
||||||
<LibraryBig className="size-4 shrink-0" />
|
<LibraryBig className="size-4 shrink-0" />
|
||||||
还没有书库。admin 请到「库管理」建库并扫描。
|
还没有书库。admin 请到「库管理」建库并扫描。
|
||||||
</p>
|
</p>
|
||||||
@@ -215,7 +215,7 @@ export default function Shelf() {
|
|||||||
<button onClick={() => nav(`/book/${b.id}`)} className="group flex w-56 items-center gap-3 rounded-md border bg-card p-2 text-left transition-colors hover:bg-secondary focus-visible:outline-2 focus-visible:outline-accent">
|
<button onClick={() => nav(`/book/${b.id}`)} className="group flex w-56 items-center gap-3 rounded-md border bg-card p-2 text-left transition-colors hover:bg-secondary focus-visible:outline-2 focus-visible:outline-accent">
|
||||||
<Cover book={b} className="aspect-[3/4] w-11 shrink-0 rounded" />
|
<Cover book={b} className="aspect-[3/4] w-11 shrink-0 rounded" />
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate text-sm font-medium group-hover:text-accent">{b.title}</span>
|
<span className="block truncate text-sm font-medium group-hover:text-accent-strong">{b.title}</span>
|
||||||
<span className="mt-0.5 flex justify-between text-[11px] text-muted-foreground">
|
<span className="mt-0.5 flex justify-between text-[11px] text-muted-foreground">
|
||||||
<span>{r.library}</span>
|
<span>{r.library}</span>
|
||||||
<span className="tabular-nums">{pct}%</span>
|
<span className="tabular-nums">{pct}%</span>
|
||||||
@@ -282,7 +282,7 @@ export default function Shelf() {
|
|||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
className="bg-destructive text-primary-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirmDel) del.mutate(confirmDel.id);
|
if (confirmDel) del.mutate(confirmDel.id);
|
||||||
setConfirmDel(null);
|
setConfirmDel(null);
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export default function AdminUsers() {
|
|||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
<AlertDialogAction className="bg-destructive text-primary-foreground hover:bg-destructive/90" onClick={() => del.mutate(x.id)}>
|
<AlertDialogAction className="bg-destructive text-destructive-foreground hover:bg-destructive/90" onClick={() => del.mutate(x.id)}>
|
||||||
删除
|
删除
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user