Compare commits

...
12 Commits
Author SHA1 Message Date
XingfenD 7727baa25b fix(web): theme menu items fire on keyboard select (onSelect) 2026-09-08 19:19:45 +08:00
XingfenD dcaf502de6 feat(ui): admin pages on shadcn table/card/alert-dialog; drop TopBar, trim icons/ui.ts to reader-compat surface 2026-09-08 19:14:41 +08:00
XingfenD 273f35a42f fix(web): shelf search debounce merges against live searchParams (no lost sort/group clicks) 2026-09-08 19:08:05 +08:00
XingfenD b1c0db6c03 feat(ui): shelf rebuilt — sticky toolbar (search/sort/group in URL), rail, menu cards, alert-dialog delete 2026-09-08 18:58:16 +08:00
XingfenD d9e9e95ece feat(ui): login rebuilt on shadcn card/label/input, theme toggle available pre-auth 2026-09-08 17:47:17 +08:00
XingfenD 52e95a48d8 fix(ui): gate app sidebar to md+ and use onSelect for logout (keyboard path) 2026-09-08 17:41:29 +08:00
XingfenD 7957e5cee5 feat(ui): AppShell layout route — desktop rail, tablet icon rail, mobile tabs, theme toggle 2026-09-08 17:35:14 +08:00
XingfenD 913f3286bb feat(lib): shelf sort/section pure helpers + formatSize extraction, table tests 2026-09-08 17:29:07 +08:00
XingfenD 03bc537308 feat(ui): semantic light/dark tokens, ThemeProvider with FOUC guard, token-restyle Toaster/Cover 2026-09-08 17:17:44 +08:00
XingfenD 87b7545a94 feat(ui): shadcn/radix toolchain — deps, @ alias, cn, generated primitives 2026-09-08 17:07:28 +08:00
XingfenD ee676c2d2e docs(plan): modern redesign phase-1 implementation plan — 8 tasks from toolchain to acceptance walkthrough 2026-09-08 17:02:02 +08:00
XingfenD b8a5b3deb6 docs(design): phase-1 modern redesign spec — shadcn/radix foundation, dual theme, app shell, shelf/admin rebuild; readers deferred to phase 2 2026-09-08 16:49:46 +08:00
42 changed files with 5396 additions and 739 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
# 全站现代化重构(shadcn/radix 设计系统 + 公共表面)设计 / Modern site-wide redesign (shadcn/radix) design — phase 1
日期 2026-09-08。分支 `feat/ui-redesign`。状态:已获用户批准(五节设计逐节确认)。
## 目标 / Goal
以 shadcn/radix 体系从零重做全部产品表面的布局与交互(不沿袭现有布局/交互,仅阅读器的翻页/翻章语义保留),
建立 light/dark 双主题设计系统;功能面与现状等价;移动端达到真正可用。
本期(phase 1)= 设计底座 + 登录/应用壳/书架/admin 两页;阅读器 chrome 迁移为 phase 2(另立 spec)。
## 决策记录 / Decisions
- 用户选定:方案 C(shadcn/radix),视觉基调=极简书架风(Notion/Linear/豆瓣读书),主题=双主题开关(默认 system)。
- 功能等价重做;不做功能增减。明确否决/推迟:视图密度开关(=新功能,不做)、阅读器本期不动(phase 2)、
命令面板/键盘优先导航(不适合媒体浏览)。
- 否决方案:仅换皮保结构(四项痛点都不回答)、手写 token 层不引框架(交互原语 a11y 自理成本高)。
- `components/icons.tsx`(自研 SVG)删除,由 `lucide-react` 取代。
- `components/ui.ts` 不整体删(phase-1 的 `/book/:id` 各表面仍引用):**收缩为 `btn`/`btnGhost`/`formatBadge`
三个 deprecated 导出**供阅读器侧继续使用,phase 2 随阅读器迁移一并删除;其余导出(`input`/`card`/`pill`/
`pillActive`/`btnPrimary`)随公共表面重做删除;`formatSize` 属数据格式化,迁 `lib/format.ts`。
- `/book/:id`(`pages/Reader.tsx` + 四 reader 组件 + `Bookmarks` 面板)本期整体不重构、不套壳,维持现状。
例外边界:`Cover`/`Toaster` 是跨期共享组件,phase 1 改写时**保持 props 兼容**(EpubReader/App 等旧调用零改动),
仅视觉入新 token。
- 原生 `confirm()`(Shelf 删书、Users 删用户)→ radix AlertDialog。
- API/路由/数据流零改动;后端不在本期范围。
## 技术底座 / Foundation
- React 19.2 + Tailwind v4.3(`@tailwindcss/vite` 已在)+ react-router 7。新依赖(容器内安装,提交 lockfile):
`radix-ui`(统一新包,按需 import 子模块)、`class-variance-authority`、`clsx`、`tailwind-merge`、
`lucide-react`、`tw-animate-css`。
- `components.json` 放 `frontend/`,alias: `@/* → src/*`(与 tsconfig paths 同步)。
- 工具:`src/lib/utils.ts` 提供 `cn = twMerge(clsx(...))`。
- Token:shadcn 标准语义变量(`--background --foreground --card --card-foreground --popover --primary
--secondary --muted --accent --destructive --border --input --ring --chart-*` + `--radius`),
`@theme inline` 映射进 Tailwind;`:root` = light(纸白中性灰,`--primary` 暖墨黑,唯一彩色点缀朱橙),
`.dark` = 对应暗色套。全站禁止裸 `stone-*`/`amber-*` 直用(阅读器现有类在 phase 2 清理)。
- 主题:`src/components/theme.tsx` ThemeProvider — `"system" | "light" | "dark"` 持久化于
`localStorage("ui.theme")`(默认 system);`index.html` 内联脚本按同 key 预算 class 防 FOUC;
入口=应用壳内 DropdownMenu(lucide Sun/Moon/Monitor,当前值打勾)。
## 应用壳 / AppShell
- 新 `src/components/app-shell.tsx` 包裹 `/` 与 `/admin/*`(登录页不套壳,居中 Card 独立布局)。
- 断点约定(Tailwind 默认值):
- `lg(≥1024)`:固定 `w-60` 左侧栏——品牌字标、导航(书架;admin 另加 库管理/用户管理)、
库筛选列表(全部+各库,来自 `GET /libraries`)、底部 主题切换 + 用户/退出。
- `sm–lg`:图标窄栏(`w-14`,Tooltip 标注)。
- `<sm`:顶部 Header(品牌+搜索入口)+ 底部 Tab 栏(书架 / 管理(admin 可见) / 账户);
账户页=弹出 DropdownMenu(主题、库管理入口、退出)。
- 现 `TopBar.tsx` 删除,职责并入壳。
## 书架页 / Shelf
- 粘性工具条(主列顶部):搜索 Input(沿用 300ms 防抖与 `q` 参数)、排序 DropdownMenu
(新加时间/标题/阅读进度,前端排序,默认新加时间)、分组 SegmentedControl(平铺|按目录,默认平铺)。
- 「继续阅读」:等价保留(progress 前 12 条),桌面横滑大卡轨,移动端降级为单行紧凑卡横滑。
- 网格:`grid-cols-[repeat(auto-fill,minmax(...))]`,卡=封面 3:4 + 标题 + 格式徽章 + 进度细线;
整卡链接进阅读器;操作 DropdownMenu(打开;admin 追加 删除)叠于封面右上,hover/focus 出现。
- 删除确认 = AlertDialog,文案语义与现状一致(提示磁盘文件连带删除、进度保留)。
- 空态/错误重试/骨架屏按新语言重绘;损坏书红徽章保留。
- 数据源不变:`useQuery(["books", lib, q])` + `["progress"]` + 全量 books 缓存复用。
## admin 页 / Admin
- 库管理:库=Card 列表(名称、`#id`、root 路径、扫描按钮、上传 input);上传入口升级=dropzone
(Label+Input 包装,拖拽与点击同效,仍串行 `uploadBook`,toast 用快照计数——保留 2721df8 修复语义)。
- 用户管理:Table(用户名/角色/创建时间/操作)+ 顶部新建行(Input+Select 角色+提交);删除=AlertDialog。
- 权限与错误处理逻辑不变(非 admin 由 AuthContext 路由守卫拦截)。
## 错误处理 / Errors
- 网络错误路径不变(`apiFetch` 抛错→toast);radix 组件不新增失败面。
- `localStorage` 不可用(隐私模式):主题回落 system、渲染不崩(读写包 try/catch)。
## 测试与验收 / Verification
- `npm run -s check`(tsc+vitest+build)绿;既有 33 用例保持绿。
- 纯逻辑新增件(排序比较器、分组视图函数迁自 `lib/group.ts`)补 vitest。
- playwright 走查并截图:375/768/1280 三宽 × light/dark ×(登录→书架→筛选/搜索/排序→
删除确认→admin 两页),核对布局无溢出、焦点环可见、AlertDialog 焦点收拢。
- a11y 基线:导航 landmark、图标按钮 aria-label、`aria-current`、主题切换 `aria-pressed`。
## 文档 / Docs
- `docs/CHANGELOG_web.md` 新增 Changed 条目(英中相邻行):全站 UI 重构为 shadcn/radix 设计系统 +
双主题 + 新应用壳;阅读器注明「交互不变,样式后续」。
## Phase 2(另立 spec 的边界)
- 四阅读器 chrome(工具条/目录/书签面板/主题卡)迁入同一 token + radix 原语(Sheet/Popover);
翻页、翻屏、CBZ 章锁/连读/预读、书签 seek、进度上报逻辑与 localStorage 键原样保留;
「纸/米/夜」重映射为语义变量并与全局主题定联动规则;清理 `rd-*`/裸色类。
## 不做 / Non-goals
- 视图密度开关、虚拟化长列表(当前量级不需要)、i18n、SSR、后端 API 改动、收藏/标签等新功能。
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": { "config": "", "css": "src/index.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" },
"iconLibrary": "lucide",
"aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }
}
+10 -1
View File
@@ -3,8 +3,17 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0c0a09" /> <meta name="theme-color" content="#fafaf9" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" /> <link rel="icon" href="/icon.svg" type="image/svg+xml" />
<script>
(() => {
try {
const t = localStorage.getItem("ui.theme") ?? "system";
if (t === "dark" || (t === "system" && matchMedia("(prefers-color-scheme: dark)").matches))
document.documentElement.classList.add("dark");
} catch {}
})();
</script>
<title>BookLib — 个人书库</title> <title>BookLib — 个人书库</title>
</head> </head>
<body> <body>
+1815 -115
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -17,13 +17,19 @@
"private": "true", "private": "true",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.102.8", "@tanstack/react-query": "^5.102.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.4.15", "dompurify": "^3.4.15",
"epubjs": "^0.3.93", "epubjs": "^0.3.93",
"lucide-react": "^1.42.0",
"marked": "^18.0.11", "marked": "^18.0.11",
"pdfjs-dist": "^6.3.289", "pdfjs-dist": "^6.3.289",
"radix-ui": "^1.6.7",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-router-dom": "^7.18.3" "react-router-dom": "^7.18.3",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
+30 -43
View File
@@ -1,7 +1,9 @@
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider, RequireAdmin, RequireAuth } from "./auth/AuthContext"; import { AuthProvider, RequireAdmin, RequireAuth } from "./auth/AuthContext";
import { AppShell } from "./components/app-shell";
import { ErrorBoundary } from "./components/ErrorBoundary"; import { ErrorBoundary } from "./components/ErrorBoundary";
import { Toaster } from "./components/Toaster"; import { Toaster } from "./components/Toaster";
import { ThemeProvider } from "./components/theme";
import Login from "./pages/Login"; import Login from "./pages/Login";
import Reader from "./pages/Reader"; import Reader from "./pages/Reader";
import Shelf from "./pages/Shelf"; import Shelf from "./pages/Shelf";
@@ -11,49 +13,34 @@ import AdminUsers from "./pages/admin/Users";
export default function App() { export default function App() {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<AuthProvider> <ThemeProvider>
<Routes> <AuthProvider>
<Route path="/login" element={<Login />} /> <Routes>
<Route <Route path="/login" element={<Login />} />
path="/" <Route
element={ element={
<RequireAuth> <RequireAuth>
<Shelf /> <AppShell />
</RequireAuth> </RequireAuth>
} }
/> >
<Route <Route path="/" element={<Shelf />} />
path="/book/:id" <Route path="/admin/users" element={<RequireAdmin><AdminUsers /></RequireAdmin>} />
element={ <Route path="/admin/libraries" element={<RequireAdmin><AdminLibraries /></RequireAdmin>} />
<RequireAuth> </Route>
<Reader /> <Route
</RequireAuth> path="/book/:id"
} element={
/> <RequireAuth>
<Route <Reader />
path="/admin/users" </RequireAuth>
element={ }
<RequireAuth> />
<RequireAdmin> <Route path="*" element={<Navigate to="/" replace />} />
<AdminUsers /> </Routes>
</RequireAdmin> <Toaster />
</RequireAuth> </AuthProvider>
} </ThemeProvider>
/>
<Route
path="/admin/libraries"
element={
<RequireAuth>
<RequireAdmin>
<AdminLibraries />
</RequireAdmin>
</RequireAuth>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Toaster />
</AuthProvider>
</ErrorBoundary> </ErrorBoundary>
); );
} }
+6 -6
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import type { Book } from "../api/types"; import type { Book } from "../api/types";
import { fetchObjectUrl } from "../lib/authImage"; import { fetchObjectUrl } from "../lib/authImage";
import { btn, formatBadge } from "./ui"; import { btn, formatBadge } from "./ui";
import { IconAlert } from "./icons"; import { TriangleAlert } from "lucide-react";
export function useAuthedImage(url: string | undefined): { src: string; failed: boolean; retry: () => void } { 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 }); const [state, setState] = useState<{ key: string; src: string; failed: boolean }>({ key: "", src: "", failed: false });
@@ -31,26 +31,26 @@ export function Cover({ book, className }: { book: Book; className?: string }) {
if (!book.cover_url || failed) { if (!book.cover_url || failed) {
return ( return (
<div <div
className={`relative grid place-items-center overflow-hidden bg-linear-to-br from-stone-800 to-stone-900 ${className ?? ""}`} className={`relative grid place-items-center overflow-hidden bg-muted ${className ?? ""}`}
> >
<span className="select-none font-serif text-4xl text-stone-500">{Array.from(book.title)[0] ?? "?"}</span> <span className="select-none font-serif text-4xl text-muted-foreground">{Array.from(book.title)[0] ?? "?"}</span>
<span className={formatBadge(book.format, "absolute bottom-1.5 right-1.5")}>{book.format}</span> <span className={formatBadge(book.format, "absolute bottom-1.5 right-1.5")}>{book.format}</span>
{failed && ( {failed && (
<button className={btn + " absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"} onClick={retry}> <button className={btn + " absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"} onClick={retry}>
<IconAlert className="size-4" /> <TriangleAlert className="size-4" />
封面重试 封面重试
</button> </button>
)} )}
</div> </div>
); );
} }
if (!src) return <div className={`animate-pulse bg-stone-800/80 ${className ?? ""}`} role="presentation" />; if (!src) return <div className={`animate-pulse bg-muted ${className ?? ""}`} role="presentation" />;
return ( return (
<img <img
src={src} src={src}
alt={`《${book.title}》封面`} alt={`《${book.title}》封面`}
loading="lazy" loading="lazy"
className={`bg-stone-900 object-cover ${className ?? ""}`} className={`bg-muted object-cover ${className ?? ""}`}
/> />
); );
} }
+4 -6
View File
@@ -1,5 +1,5 @@
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { IconAlert, IconCheck } from "./icons"; import { CheckCircle2, XCircle } from "lucide-react";
type Toast = { id: number; kind: "ok" | "err"; text: ReactNode }; type Toast = { id: number; kind: "ok" | "err"; text: ReactNode };
@@ -32,13 +32,11 @@ export function Toaster() {
<div <div
key={t.id} key={t.id}
role="status" role="status"
className={`fx-rise flex max-w-sm items-center gap-2 rounded-xl px-4 py-2.5 text-sm shadow-xl ring-1 backdrop-blur-md ${ className={`fx-rise flex max-w-sm items-center gap-2 rounded-lg border bg-card px-4 py-2.5 text-sm text-card-foreground shadow-lg ${
t.kind === "ok" t.kind === "ok" ? "border-border" : "border-destructive/40"
? "bg-emerald-950/90 text-emerald-100 ring-emerald-500/25"
: "bg-red-950/90 text-red-100 ring-red-500/30"
}`} }`}
> >
{t.kind === "ok" ? <IconCheck className="size-4 shrink-0 text-emerald-400" /> : <IconAlert className="size-4 shrink-0 text-red-400" />} {t.kind === "ok" ? <CheckCircle2 className="size-4 shrink-0 text-accent" /> : <XCircle className="size-4 shrink-0 text-destructive" />}
{t.text} {t.text}
</div> </div>
))} ))}
-64
View File
@@ -1,64 +0,0 @@
import type { ReactNode } from "react";
import { Link, NavLink } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { IconBooks, IconFolder, IconLogout, IconUser } from "./icons";
import { btnGhost } from "./ui";
export function TopBar({ right }: { right?: ReactNode }) {
const { user, isAdmin, logout } = useAuth();
return (
<header className="sticky top-0 z-30 flex shrink-0 items-center gap-1 border-b border-white/[0.06] bg-stone-950/80 px-4 py-2.5 backdrop-blur-md sm:px-6">
<Link
to="/"
className="flex shrink-0 items-center gap-2 rounded-lg pr-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500"
>
<span className="grid size-7 place-items-center rounded-lg bg-linear-to-br from-amber-500 to-orange-600 text-stone-950 shadow-md shadow-amber-500/25">
<IconBooks className="size-4.5" />
</span>
<span className="font-serif text-lg font-semibold tracking-wide">
Book<span className="text-amber-400">Lib</span>
</span>
</Link>
{isAdmin && (
<nav aria-label="管理" className="ml-2 hidden items-center gap-1 sm:flex">
<TabLink to="/admin/users">
<IconUser className="size-4" />
用户
</TabLink>
<TabLink to="/admin/libraries">
<IconFolder className="size-4" />
库管理
</TabLink>
</nav>
)}
<div className="ml-auto flex items-center gap-2 sm:gap-3">
{right}
<span className="flex items-center gap-2 rounded-full bg-white/[0.05] py-1 pl-1 pr-3 text-sm text-stone-300 ring-1 ring-white/[0.07]">
<span className="grid size-6 shrink-0 place-items-center rounded-full bg-amber-500/20 text-[11px] font-semibold text-amber-300 ring-1 ring-amber-500/30">
{Array.from(user?.username ?? "?")[0]?.toUpperCase()}
</span>
<span className="hidden max-w-28 truncate sm:inline">{user?.username}</span>
{isAdmin && <span className="hidden rounded bg-amber-500/15 px-1 text-[10px] font-semibold text-amber-300 md:inline">admin</span>}
</span>
<button className={btnGhost} onClick={logout} aria-label="退出登录" title="退出登录">
<IconLogout className="size-4.5" />
</button>
</div>
</header>
);
}
function TabLink({ to, children }: { to: string; children: ReactNode }) {
return (
<NavLink
to={to}
className={({ isActive }) =>
`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}
</NavLink>
);
}
+129
View File
@@ -0,0 +1,129 @@
import { useQuery } from "@tanstack/react-query";
import { BookMarked, BookOpen, FolderCog, LogOut, Users } from "lucide-react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import { api } from "@/api/client";
import { useAuth } from "@/auth/AuthContext";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import { ThemeToggle } from "./theme-toggle";
import { cn } from "@/lib/utils";
import type { ReactNode } from "react";
function Brand({ compact }: { compact?: boolean }) {
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">
<span className="grid size-6 place-items-center rounded bg-accent text-accent-foreground">
<BookMarked className="size-4" />
</span>
{!compact && <span className="text-[15px] font-semibold tracking-tight">BookLib</span>}
</NavLink>
);
}
function NavItem({ to, icon, label, search }: { to: string; icon: ReactNode; label: string; search?: string }) {
return (
<NavLink
to={to + (search ?? "")}
className={({ isActive }) =>
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",
isActive && "bg-secondary font-medium text-foreground",
)
}
>
{icon}
<span className="hidden lg:inline">{label}</span>
</NavLink>
);
}
export function AppShell() {
const { user, isAdmin, logout } = useAuth();
const loc = useLocation();
const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries });
const libs = libsQ.data ?? [];
const account = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="gap-2 px-2">
<span className="grid size-7 place-items-center rounded-full bg-accent/15 text-xs font-semibold text-accent">
{Array.from(user?.username ?? "?")[0]?.toUpperCase()}
</span>
<span className="hidden max-w-28 truncate text-sm lg:inline">{user?.username}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{user?.username}{isAdmin ? " · admin" : ""}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={logout}>
<LogOut /> 退出登录
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
return (
<div className="flex h-full">
{/* 桌面/平板侧栏;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">
<Brand compact />
<nav aria-label="主导航" className="flex flex-col gap-1">
<NavItem to="/" icon={<BookOpen className="size-4 shrink-0" />} label="书架" />
{isAdmin && (
<>
<NavItem to="/admin/libraries" icon={<FolderCog className="size-4 shrink-0" />} label="库管理" />
<NavItem to="/admin/users" icon={<Users className="size-4 shrink-0" />} label="用户管理" />
</>
)}
</nav>
{libs.length > 0 && (
<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>
<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) => (
<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} />
))}
</div>
</div>
)}
<Separator className="my-2 hidden lg:block" />
<div className="mt-auto hidden items-center justify-between px-1 lg:flex">
<ThemeToggle />
{account}
</div>
</aside>
<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">
<Brand />
<div className="flex items-center gap-1">
<ThemeToggle />
{account}
</div>
</header>
<main key={loc.pathname} className="min-h-0 flex-1 overflow-y-auto">
<Outlet />
</main>
{/* 移动端底 Tab */}
<nav aria-label="底部导航" className="flex h-14 shrink-0 items-stretch border-t bg-card/90 backdrop-blur md:hidden">
<TabItem to="/" icon={<BookOpen className="size-5" />} label="书架" search="?lib=0" />
{isAdmin && <TabItem to="/admin/libraries" icon={<FolderCog className="size-5" />} label="库管理" />}
{isAdmin && <TabItem to="/admin/users" icon={<Users className="size-5" />} label="用户" />}
</nav>
</div>
</div>
);
}
function TabItem({ to, icon, label, search }: { to: string; icon: ReactNode; label: string; search?: string }) {
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")}>
{icon}
{label}
</NavLink>
);
}
+2 -103
View File
@@ -1,116 +1,15 @@
import type { SVGProps } from "react"; import type { SVGProps } from "react";
// 统一 1.7px 描边的线性图标(Phosphor 风格),尺寸由外部 className 控制
function Svg({ children, ...p }: SVGProps<SVGSVGElement>) { function Svg({ children, ...p }: SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...p}>
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.7}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...p}
>
{children} {children}
</svg> </svg>
); );
} }
type P = SVGProps<SVGSVGElement>; export const IconArrowLeft = (p: SVGProps<SVGSVGElement>) => (
export const IconBooks = (p: P) => (
<Svg {...p}>
<path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H10a1.5 1.5 0 0 1 1.5 1.5V18a2 2 0 0 0-2-2H5.5A1.5 1.5 0 0 1 4 14.5v-9Z" />
<path d="M13 8h4.5A1.5 1.5 0 0 1 19 9.5v8A1.5 1.5 0 0 1 17.5 19H13" />
<path d="M7 4v12M16 12.5v4" />
</Svg>
);
export const IconSearch = (p: P) => (
<Svg {...p}>
<circle cx="11" cy="11" r="6.5" />
<path d="m16 16 4 4" />
</Svg>
);
export const IconRefresh = (p: P) => (
<Svg {...p}>
<path d="M20 12a8 8 0 1 1-2.34-5.66" />
<path d="M20 4v4.5h-4.5" />
</Svg>
);
export const IconScan = (p: P) => (
<Svg {...p}>
<path d="M4 8V5.5A1.5 1.5 0 0 1 5.5 4H8M16 4h2.5A1.5 1.5 0 0 1 20 5.5V8M20 16v2.5a1.5 1.5 0 0 1-1.5 1.5H16M8 20H5.5A1.5 1.5 0 0 1 4 18.5V16" />
<path d="M7 12h10" />
</Svg>
);
export const IconX = (p: P) => (
<Svg {...p}>
<path d="m6 6 12 12M18 6 6 18" />
</Svg>
);
export const IconLogout = (p: P) => (
<Svg {...p}>
<path d="M14 4h4.5A1.5 1.5 0 0 1 20 5.5v13a1.5 1.5 0 0 1-1.5 1.5H14" />
<path d="M10 8 6 12l4 4M6 12h10" />
</Svg>
);
export const IconArrowLeft = (p: P) => (
<Svg {...p}> <Svg {...p}>
<path d="M20 12H4m0 0 6-6m-6 6 6 6" /> <path d="M20 12H4m0 0 6-6m-6 6 6 6" />
</Svg> </Svg>
); );
export const IconBookOpen = (p: P) => (
<Svg {...p}>
<path d="M12 6.5C10.5 5 8.5 4.5 4 4.5v13c4.5 0 6.5.5 8 2 1.5-1.5 3.5-2 8-2v-13c-4.5 0-6.5.5-8 2Z" />
<path d="M12 6.5v13" />
</Svg>
);
export const IconUser = (p: P) => (
<Svg {...p}>
<circle cx="12" cy="8" r="3.5" />
<path d="M5 19.5c1.2-3 3.7-4.5 7-4.5s5.8 1.5 7 4.5" />
</Svg>
);
export const IconLock = (p: P) => (
<Svg {...p}>
<rect x="5.5" y="10.5" width="13" height="9" rx="1.5" />
<path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5M12 14v2.5" />
</Svg>
);
export const IconCheck = (p: P) => (
<Svg {...p}>
<path d="m5 12.5 4.5 4.5L19 7" />
</Svg>
);
export const IconAlert = (p: P) => (
<Svg {...p}>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 7.5V13m0 3.5v.01" />
</Svg>
);
export const IconClock = (p: P) => (
<Svg {...p}>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 7.5V12l3 2" />
</Svg>
);
export const IconFolder = (p: P) => (
<Svg {...p}>
<path d="M4 6.5A1.5 1.5 0 0 1 5.5 5h3.2a1.5 1.5 0 0 1 1.2.6l.9 1.2a1.5 1.5 0 0 0 1.2.6h6.5A1.5 1.5 0 0 1 20 9.5v8a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 17.5v-11Z" />
</Svg>
);
+32
View File
@@ -0,0 +1,32 @@
import { Check, Monitor, Moon, Sun } from "lucide-react";
import { useTheme, type Theme } from "./theme";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
const ITEMS: { v: Theme; label: string; icon: typeof Sun }[] = [
{ v: "light", label: "浅色", icon: Sun },
{ v: "dark", label: "深色", icon: Moon },
{ v: "system", label: "跟随系统", icon: Monitor },
];
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="切换主题">
{theme === "dark" ? <Moon className="size-4" /> : theme === "light" ? <Sun className="size-4" /> : <Monitor className="size-4" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{ITEMS.map(({ v, label, icon: Icon }) => (
<DropdownMenuItem key={v} onSelect={() => setTheme(v)}>
<Icon />
{label}
{theme === v && <Check className="ml-auto size-4" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
export type Theme = "system" | "light" | "dark";
const KEY = "ui.theme";
const SYS = "(prefers-color-scheme: dark)";
function read(): Theme {
try {
const v = localStorage.getItem(KEY);
return v === "light" || v === "dark" || v === "system" ? v : "system";
} catch {
return "system";
}
}
function apply(t: Theme) {
const dark = t === "dark" || (t === "system" && matchMedia(SYS).matches);
document.documentElement.classList.toggle("dark", dark);
}
const Ctx = createContext<{ theme: Theme; setTheme: (t: Theme) => void } | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, set] = useState<Theme>(read);
useEffect(() => {
apply(theme);
try {
localStorage.setItem(KEY, theme);
} catch {
/* 隐私模式:仅本次会话生效 */
}
if (theme !== "system") return;
const mq = matchMedia(SYS);
const on = () => apply("system");
mq.addEventListener("change", on);
return () => mq.removeEventListener("change", on);
}, [theme]);
const setTheme = useCallback((t: Theme) => set(t), []);
const value = useMemo(() => ({ theme, setTheme }), [theme, setTheme]);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useTheme() {
const v = useContext(Ctx);
if (!v) throw new Error("useTheme outside ThemeProvider");
return v;
}
+2 -19
View File
@@ -1,19 +1,11 @@
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 btnPrimary =
"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 = 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"; "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 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<Format, string> = { export const FORMAT_BADGE: Record<Format, string> = {
@@ -27,12 +19,3 @@ export const FORMAT_BADGE: Record<Format, string> = {
export function formatBadge(fmt: Format, extra = ""): string { 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}`; 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]}`;
}
+196
View File
@@ -0,0 +1,196 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
+47
View File
@@ -0,0 +1,47 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Slot } from "radix-ui"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+63
View File
@@ -0,0 +1,63 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Slot } from "radix-ui"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+91
View File
@@ -0,0 +1,91 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+156
View File
@@ -0,0 +1,156 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
@@ -0,0 +1,256 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Label as LabelPrimitive } from "radix-ui"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+187
View File
@@ -0,0 +1,187 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+27
View File
@@ -0,0 +1,27 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Separator as SeparatorPrimitive } from "radix-ui"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-accent", className)}
{...props}
/>
)
}
export { Skeleton }
+115
View File
@@ -0,0 +1,115 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+54
View File
@@ -0,0 +1,54 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Tooltip as TooltipPrimitive } from "radix-ui"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+87 -14
View File
@@ -1,4 +1,74 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.65rem;
--background: oklch(0.985 0.001 80);
--foreground: oklch(0.22 0.01 70);
--card: oklch(0.995 0 0);
--card-foreground: oklch(0.22 0.01 70);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.22 0.01 70);
--primary: oklch(0.25 0.012 70);
--primary-foreground: oklch(0.98 0.002 80);
--secondary: oklch(0.95 0.003 80);
--secondary-foreground: oklch(0.32 0.01 70);
--muted: oklch(0.95 0.003 80);
--muted-foreground: oklch(0.55 0.008 70);
--accent: oklch(0.68 0.16 46);
--accent-foreground: oklch(0.985 0.002 80);
--destructive: oklch(0.58 0.21 27);
--border: oklch(0.91 0.004 80);
--input: oklch(0.91 0.004 80);
--ring: oklch(0.62 0.01 70);
}
.dark {
--background: oklch(0.19 0.006 80);
--foreground: oklch(0.95 0.003 80);
--card: oklch(0.23 0.006 80);
--card-foreground: oklch(0.95 0.003 80);
--popover: oklch(0.23 0.006 80);
--popover-foreground: oklch(0.95 0.003 80);
--primary: oklch(0.95 0.003 80);
--primary-foreground: oklch(0.22 0.01 70);
--secondary: oklch(0.28 0.006 80);
--secondary-foreground: oklch(0.95 0.003 80);
--muted: oklch(0.28 0.006 80);
--muted-foreground: oklch(0.68 0.006 80);
--accent: oklch(0.7 0.16 46);
--accent-foreground: oklch(0.19 0.006 80);
--destructive: oklch(0.65 0.19 25);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 14%);
--ring: oklch(0.6 0.01 70);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
}
html, html,
body, body,
@@ -6,10 +76,23 @@ body,
height: 100%; height: 100%;
} }
body { body {
@apply bg-stone-950 text-stone-100 antialiased; @apply bg-background text-foreground antialiased;
/* 顶部一抹极淡的琥珀氛围光,避免大面积纯黑的"朴素感" */ color-scheme: light;
background-image: radial-gradient(60rem 28rem at 50% -8rem, rgb(217 119 6 / 0.07), transparent 70%); }
background-repeat: no-repeat; .dark body {
color-scheme: dark;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
@apply rounded-full border-2 border-transparent bg-clip-padding;
background-color: color-mix(in oklab, var(--foreground) 22%, transparent);
}
::-webkit-scrollbar-thumb:hover {
background-color: color-mix(in oklab, var(--foreground) 34%, transparent);
} }
/* 入场:卡片/区块轻微上浮淡入(列表 stagger 用 style.animationDelay) */ /* 入场:卡片/区块轻微上浮淡入(列表 stagger 用 style.animationDelay) */
@@ -18,16 +101,6 @@ body {
to { opacity: 1; transform: none; } to { opacity: 1; transform: none; }
} }
.fx-rise { animation: fx-rise 0.35s cubic-bezier(0.22, 1, 0.36, 1) both; } .fx-rise { animation: fx-rise 0.35s cubic-bezier(0.22, 1, 0.36, 1) both; }
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
@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 */ /* 阅读页主题:纸/米/夜,文本类阅读器共用 —— 见 lib/readerPrefs.ts */
[data-rd="paper"] { --rd-bg: #faf8f4; --rd-fg: #292524; --rd-link: #9a3412; } [data-rd="paper"] { --rd-bg: #faf8f4; --rd-fg: #292524; --rd-link: #9a3412; }
+8
View File
@@ -0,0 +1,8 @@
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]}`;
}
+19
View File
@@ -0,0 +1,19 @@
import type { Book } from "../api/types";
import { groupByDir } from "./group";
export type SortKey = "added" | "title" | "progress";
export const SORT_LABEL: Record<SortKey, string> = { added: "最近入库", title: "标题", progress: "阅读进度" };
export type GroupMode = "flat" | "dir";
export function sortBooks(books: Book[], key: SortKey): Book[] {
const a = [...books];
if (key === "title") return a.sort((x, y) => x.title.localeCompare(y.title, "zh"));
if (key === "progress") return a.sort((x, y) => y.percent - x.percent || (x.added_at < y.added_at ? 1 : x.added_at > y.added_at ? -1 : 0));
return a.sort((x, y) => (x.added_at < y.added_at ? 1 : x.added_at > y.added_at ? -1 : 0));
}
/** 页面分组视图:flat 一段无标题;dir 沿用「根目录置顶、目录名 locale 排序」。 */
export function sections(books: Book[], key: SortKey, mode: GroupMode): { label: string; books: Book[] }[] {
if (mode === "flat") return [{ label: "", books: sortBooks(books, key) }];
return groupByDir(sortBooks(books, key)).map((g) => ({ label: g.dir, books: g.books }));
}
+8
View File
@@ -0,0 +1,8 @@
import type { GroupMode, SortKey } from "./shelf";
export const SORTS: SortKey[] = ["added", "title", "progress"];
export const GROUPS: { v: GroupMode; label: string }[] = [
{ v: "flat", label: "平铺" },
{ v: "dir", label: "按目录" },
];
export const LIB0 = 0;
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+36 -52
View File
@@ -1,20 +1,13 @@
import { useState, type FormEvent, type ReactNode } from "react"; import { useState, type FormEvent } from "react";
import { Navigate, useLocation } from "react-router-dom"; import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "../auth/AuthContext"; import { BookMarked } from "lucide-react";
import { IconBooks, IconLock, IconUser } from "../components/icons"; import { useAuth } from "@/auth/AuthContext";
import { toast } from "../components/Toaster"; import { Button } from "@/components/ui/button";
import { btnPrimary, card, input } from "../components/ui"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
function Field({ icon, children }: { icon: ReactNode; children: ReactNode }) { import { Label } from "@/components/ui/label";
return ( import { ThemeToggle } from "@/components/theme-toggle";
<label className="block"> import { toast } from "@/components/Toaster";
<span className="relative block">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-stone-500">{icon}</span>
{children}
</span>
</label>
);
}
export default function Login() { export default function Login() {
const { login, user } = useAuth(); const { login, user } = useAuth();
@@ -38,42 +31,33 @@ export default function Login() {
} }
return ( return (
<div className="grid h-full place-items-center p-4"> <div className="relative grid h-full place-items-center p-4">
<form onSubmit={submit} className={`${card} fx-rise w-full max-w-sm space-y-4 p-8 shadow-2xl shadow-black/40`}> <div className="absolute right-3 top-3">
<div className="flex flex-col items-center pb-2 text-center"> <ThemeToggle />
<span className="grid size-12 place-items-center rounded-2xl bg-linear-to-br from-amber-500 to-orange-600 text-stone-950 shadow-lg shadow-amber-500/25"> </div>
<IconBooks className="size-7" /> <form onSubmit={submit} className="fx-rise w-full max-w-sm">
</span> <Card>
<h1 className="mt-4 font-serif text-2xl font-semibold tracking-wide"> <CardHeader className="items-center text-center">
Book<span className="text-amber-400">Lib</span> <span className="mb-2 grid size-10 place-items-center rounded-lg bg-accent text-accent-foreground">
</h1> <BookMarked className="size-5" />
<p className="mt-1 text-sm text-stone-500">你的漫画与书籍藏书馆</p> </span>
</div> <CardTitle className="text-xl tracking-tight">BookLib</CardTitle>
<Field icon={<IconUser className="size-4" />}> <CardDescription>你的漫画与书籍藏书馆</CardDescription>
<input </CardHeader>
className={input + " w-full pl-9"} <CardContent className="space-y-4">
value={u} <div className="space-y-1.5">
onChange={(e) => setU(e.target.value)} <Label htmlFor="login-user">用户名</Label>
aria-label="用户名" <Input id="login-user" value={u} onChange={(e) => setU(e.target.value)} autoFocus autoComplete="username" />
placeholder="用户名" </div>
autoFocus <div className="space-y-1.5">
autoComplete="username" <Label htmlFor="login-pass">密码</Label>
/> <Input id="login-pass" type="password" value={p} onChange={(e) => setP(e.target.value)} autoComplete="current-password" />
</Field> </div>
<Field icon={<IconLock className="size-4" />}> <Button type="submit" className="w-full" disabled={busy || !u || !p}>
<input {busy ? "登录中…" : "登录"}
className={input + " w-full pl-9"} </Button>
type="password" </CardContent>
value={p} </Card>
onChange={(e) => setP(e.target.value)}
aria-label="密码"
placeholder="密码"
autoComplete="current-password"
/>
</Field>
<button className={btnPrimary + " w-full justify-center py-2"} disabled={busy || !u || !p}>
{busy ? "登录中…" : "登录"}
</button>
</form> </form>
</div> </div>
); );
+227 -210
View File
@@ -1,88 +1,84 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { api } from "../api/client"; import { ArrowDownUp, Ellipsis, FolderOpen, LibraryBig, RefreshCw, ScanLine, Search, TriangleAlert } from "lucide-react";
import type { Book } from "../api/types"; import { api } from "@/api/client";
import { useAuth } from "../auth/AuthContext"; import type { Book } from "@/api/types";
import { Cover } from "../components/Cover"; import { useAuth } from "@/auth/AuthContext";
import { IconAlert, IconBookOpen, IconClock, IconFolder, IconRefresh, IconScan, IconSearch, IconX } from "../components/icons"; import { Cover } from "@/components/Cover";
import { TopBar } from "../components/TopBar"; import { toast } from "@/components/Toaster";
import { toast } from "../components/Toaster"; import { formatBadge } from "@/components/ui";
import { btn, card, formatBadge, formatSize, input, pill, pillActive } from "../components/ui"; import {
import { groupByDir } from "../lib/group"; AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { formatSize } from "@/lib/format";
import { sections, SORT_LABEL, type GroupMode, type SortKey } from "@/lib/shelf";
import { GROUPS, LIB0, SORTS } from "@/lib/shelfMeta";
function BookCard({ 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(9.5rem,1fr))]";
book,
index, function BookCard({ book, index, onOpen, onDelete }: { book: Book; index: number; onOpen: () => void; onDelete: () => void }) {
onOpen,
onDelete,
}: {
book: Book;
index: number;
onOpen: () => void;
onDelete: () => void;
}) {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const pct = Math.round(book.percent * 100); const pct = Math.round(book.percent * 100);
return ( return (
<div <div className="group fx-rise relative flex flex-col" style={{ animationDelay: `${Math.min(index, 23) * 25}ms` }}>
className="group fx-rise relative flex flex-col" <button onClick={onOpen} className="block w-full p-0 text-left focus-visible:outline-none">
style={{ animationDelay: `${Math.min(index, 23) * 25}ms` }} <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">
>
<button onClick={onOpen} className="block w-full text-left focus-visible:outline-none">
<div className="relative aspect-[3/4] w-full overflow-hidden rounded-lg shadow-md ring-1 ring-white/10 transition duration-200 will-change-transform group-hover:-translate-y-1 group-hover:shadow-xl group-hover:shadow-black/40 group-hover:ring-amber-500/40 group-focus-visible:-translate-y-1 group-focus-visible:ring-amber-500">
<Cover book={book} className="size-full" /> <Cover book={book} className="size-full" />
<span className={formatBadge(book.format, "absolute left-1.5 top-1.5 backdrop-blur-sm")}>{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 <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}>
className="absolute right-1.5 top-1.5 rounded bg-red-950/90 px-1.5 py-0.5 text-[10px] font-semibold text-red-200 ring-1 ring-red-500/40" <TriangleAlert className="size-3" /> 损坏
title={book.error}
>
损坏
</span> </span>
)} )}
<span className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-1 bg-linear-to-t from-black/80 via-black/30 to-transparent p-1.5 pt-6 text-[10px] tabular-nums text-stone-200 opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-visible:opacity-100">
<span>{book.pages > 0 ? `${book.pages} 页` : ""}</span>
<span>{formatSize(book.size)}</span>
</span>
{pct > 0 && ( {pct > 0 && (
<span className="absolute inset-x-0 bottom-0 h-1 bg-black/50" role="presentation"> <span className="absolute inset-x-0 bottom-0 h-1 bg-black/30" role="presentation">
<span className="block h-full bg-amber-500" style={{ width: `${pct}%` }} /> <span className="block h-full bg-accent" style={{ width: `${pct}%` }} />
</span> </span>
)} )}
</div> </span>
<p className="mt-2 truncate text-xs font-medium text-stone-200 transition-colors group-hover:text-amber-300 group-focus-visible:text-amber-300"> <span className="mt-2 block truncate text-xs font-medium">{book.title}</span>
{book.title} <span className="block truncate text-[11px] text-muted-foreground">
</p> {book.path.includes("/") ? book.path.slice(0, book.path.lastIndexOf("/")) : `${book.pages > 0 ? book.pages + " 页 · " : ""}${formatSize(book.size)}`}
<p className="truncate text-[10px] text-stone-500">{book.path.includes("/") ? book.path.slice(0, book.path.lastIndexOf("/")) : "根目录"}</p> </span>
</button> </button>
{isAdmin && ( <div className="absolute right-1 top-1 z-10 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
<button <DropdownMenu>
className="absolute right-1.5 top-1.5 z-10 rounded-md bg-stone-950/80 p-1 text-stone-300 opacity-0 ring-1 ring-white/10 backdrop-blur-sm transition hover:bg-red-950 hover:text-red-300 focus-visible:opacity-100 group-hover:opacity-100" <DropdownMenuTrigger asChild>
title="删除(连磁盘文件)" <Button variant="secondary" size="icon" className="size-7 bg-background/80 backdrop-blur" aria-label={`《${book.title}》操作`}>
aria-label={`删除《${book.title}》`} <Ellipsis className="size-4" />
onClick={() => { </Button>
if (confirm(`删除《${book.title}》?磁盘文件一并删除,阅读进度保留`)) onDelete(); </DropdownMenuTrigger>
}} <DropdownMenuContent align="end">
> <DropdownMenuItem onClick={onOpen}>打开</DropdownMenuItem>
<IconX className="size-3.5" /> {isAdmin && (
</button> <>
)} <DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={onDelete}>
删除
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div> </div>
); );
} }
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 SectionHead({ label, count }: { label: string; count: number }) {
if (!label) return null;
function Section({ title, icon, children }: { title: string; icon?: React.ReactNode; children: React.ReactNode }) {
return ( return (
<section className="mb-10"> <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="mb-3 flex items-center gap-1.5 text-sm font-semibold tracking-wide text-stone-300"> <FolderOpen className="size-4 text-muted-foreground" />
{icon} {label}
{title} <span className="text-xs tabular-nums text-muted-foreground">{count}</span>
</h2> </h2>
{children}
</section>
); );
} }
@@ -90,23 +86,32 @@ export default function Shelf() {
const qc = useQueryClient(); const qc = useQueryClient();
const nav = useNavigate(); const nav = useNavigate();
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const [lib, setLib] = useState(0); // 0 = 全部库 const [sp, setSp] = useSearchParams();
const [qRaw, setQRaw] = useState(""); const lib = Number(sp.get("lib")) || LIB0;
const [q, setQ] = useState(""); const sort = (SORTS.includes(sp.get("sort") as SortKey) ? (sp.get("sort") as SortKey) : "added") satisfies SortKey;
const group: GroupMode = sp.get("group") === "dir" ? "dir" : "flat";
const serverQ = (sp.get("q") ?? "").trim();
const [qRaw, setQRaw] = useState(serverQ);
const [confirmDel, setConfirmDel] = useState<Book | null>(null);
useEffect(() => setQRaw(serverQ), [serverQ]);
useEffect(() => { useEffect(() => {
const t = setTimeout(() => setQ(qRaw.trim()), 300); // 搜索防抖 if (qRaw.trim() === serverQ) return;
const t = setTimeout(() => {
setSp((prev) => {
const next = new URLSearchParams(prev);
if (qRaw.trim()) next.set("q", qRaw.trim());
else next.delete("q");
return next;
}, { replace: true });
}, 300);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [qRaw]); }, [qRaw, serverQ, sp, setSp]);
const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries }); const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries });
const booksQ = useQuery({ const booksQ = useQuery({ queryKey: ["books", lib, serverQ], queryFn: () => api.listBooks({ library: lib || undefined, q: serverQ }) });
queryKey: ["books", lib, q],
queryFn: () => api.listBooks({ library: lib || undefined, q }),
});
const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress }); const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress });
// 继续阅读行 → book id 解析用(键与 booksQ 默认态一致,天然共享缓存) const allQ = useQuery({ queryKey: ["books", LIB0, ""], queryFn: () => api.listBooks() });
const allQ = useQuery({ queryKey: ["books", 0, ""], queryFn: () => api.listBooks() });
const del = useMutation({ const del = useMutation({
mutationFn: (id: number) => api.deleteBook(id), mutationFn: (id: number) => api.deleteBook(id),
@@ -120,162 +125,174 @@ export default function Shelf() {
mutationFn: (id: number) => api.scanLibrary(id), mutationFn: (id: number) => api.scanLibrary(id),
onSuccess: () => { onSuccess: () => {
toast("ok", "扫描已触发"); toast("ok", "扫描已触发");
setTimeout(() => qc.invalidateQueries({ queryKey: ["books"] }), 3000); // 给周期扫描留出时间 setTimeout(() => qc.invalidateQueries({ queryKey: ["books"] }), 3000);
}, },
onError: (e) => toast("err", e instanceof Error ? e.message : "扫描失败"), onError: (e) => toast("err", e instanceof Error ? e.message : "扫描失败"),
}); });
const groups = useMemo(() => groupByDir(booksQ.data ?? []), [booksQ.data]);
const booksByKey = useMemo(() => { const booksByKey = useMemo(() => {
const m = new Map<string, Book>(); const m = new Map<string, Book>();
for (const b of allQ.data ?? []) m.set(`${b.library_id}/${b.path}`, b); for (const b of allQ.data ?? []) m.set(`${b.library_id}/${b.path}`, b);
return m; return m;
}, [allQ.data]); }, [allQ.data]);
const secs = useMemo(() => sections(booksQ.data ?? [], sort, group), [booksQ.data, sort, group]);
const searching = serverQ.length > 0;
const continueRows = (progQ.data ?? []).slice(0, 12); const continueRows = (progQ.data ?? []).slice(0, 12);
const libs = libsQ.data ?? []; const setParam = (k: string, v: string) => {
const searching = q.length > 0; const next = new URLSearchParams(sp);
if (v) next.set(k, v);
else next.delete(k);
setSp(next, { replace: true });
};
const libName = lib === LIB0 ? "全部" : libsQ.data?.find((l) => l.id === lib)?.name ?? "书库";
return ( return (
<div className="flex h-full flex-col"> <div className="mx-auto flex w-full max-w-6xl flex-col px-4 pb-20 sm:px-6 md:pb-6">
<TopBar <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">
right={ <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" />
<div className="relative"> <Input className="pl-8" type="search" aria-label="搜索标题" placeholder="搜索标题…" value={qRaw} onChange={(e) => setQRaw(e.target.value)} />
<IconSearch className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-stone-500" /> </div>
<input <DropdownMenu>
className={input + " w-36 pl-8 sm:w-52"} <DropdownMenuTrigger asChild>
type="search" <Button variant="outline" size="sm" className="gap-1.5">
aria-label="搜索标题" <ArrowDownUp className="size-4" />
placeholder="搜索标题…" {SORT_LABEL[sort]}
value={qRaw} </Button>
onChange={(e) => setQRaw(e.target.value)} </DropdownMenuTrigger>
/> <DropdownMenuContent align="end">
</div> <DropdownMenuLabel>排序</DropdownMenuLabel>
{isAdmin && lib > 0 && ( {SORTS.map((k) => (
<button className={btn} disabled={scan.isPending} onClick={() => scan.mutate(lib)}> <DropdownMenuItem key={k} onClick={() => setParam("sort", k === "added" ? "" : k)} className={k === sort ? "font-medium text-accent" : ""}>
<IconScan className="size-4" /> {SORT_LABEL[k]}
{scan.isPending ? "扫描中…" : "扫描此库"} </DropdownMenuItem>
</button>
)}
<button className={btn} onClick={() => qc.invalidateQueries({ queryKey: ["books"] })} aria-label="刷新书目">
<IconRefresh className="size-4" />
<span className="hidden sm:inline">刷新</span>
</button>
</>
}
/>
<div className="flex-1 overflow-y-auto px-4 py-5 sm:px-6">
{libs.length === 0 && !libsQ.isPending && (
<p className="mb-6 flex items-center gap-2 rounded-lg bg-amber-500/10 px-3 py-2 text-sm text-amber-200 ring-1 ring-amber-500/25">
<IconAlert className="size-4 shrink-0" />
还没有书库。admin 请到「库管理」建库并扫描。
</p>
)}
{(libs.length > 0 || libsQ.isPending) && (
<nav aria-label="按库筛选" className="mb-8 flex w-fit flex-wrap gap-1 rounded-full bg-white/[0.04] p-1 ring-1 ring-white/[0.07]">
<button className={`${pill} ${lib === 0 ? pillActive : ""}`} aria-pressed={lib === 0} onClick={() => setLib(0)}>
全部
</button>
{libs.map((l) => (
<button
key={l.id}
className={`${pill} ${lib === l.id ? pillActive : ""}`}
aria-pressed={lib === l.id}
onClick={() => setLib(l.id)}
>
{l.name}
</button>
))} ))}
</nav> </DropdownMenuContent>
</DropdownMenu>
<div role="group" aria-label="分组视图" className="flex rounded-md border p-0.5">
{GROUPS.map((g) => (
<Button key={g.v} size="sm" variant={group === g.v ? "secondary" : "ghost"} aria-pressed={group === g.v} className="px-2.5" onClick={() => setParam("group", g.v === "flat" ? "" : g.v)}>
{g.label}
</Button>
))}
</div>
{isAdmin && lib > LIB0 && (
<Button variant="outline" size="sm" disabled={scan.isPending} onClick={() => scan.mutate(lib)} className="gap-1.5">
<ScanLine className="size-4" />
{scan.isPending ? "扫描中…" : "扫描此库"}
</Button>
)} )}
<Button variant="ghost" size="icon" aria-label="刷新书目" onClick={() => qc.invalidateQueries({ queryKey: ["books"] })}>
<RefreshCw className="size-4" />
</Button>
</div>
{continueRows.length > 0 && !searching && ( {lib === LIB0 && <p className="sr-only" aria-live="polite">{`当前筛选:${libName}`}</p>}
<Section title="继续阅读" icon={<IconClock className="size-4 text-amber-500/80" />}>
<ul className="flex gap-3 overflow-x-auto pb-2" role="list"> {(libsQ.data ?? []).length === 0 && !libsQ.isPending && !searching && (
{continueRows.map((r, i) => { <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">
const b = booksByKey.get(`${r.library_id}/${r.path}`); <LibraryBig className="size-4 shrink-0" />
if (!b) 还没有书库。admin 请到「库管理」建库并扫描。
return ( </p>
<li key={`${r.library_id}/${r.path}`} className={`fx-rise w-44 shrink-0 ${card} p-3`} style={{ animationDelay: `${i * 40}ms` }}> )}
<p className="truncate text-xs text-stone-400">{r.title || "已删除的书"}</p>
<p className="mt-1 text-[10px] text-stone-600">#{r.library_id}/{r.path} · 进度已保留</p> {continueRows.length > 0 && !searching && (
</li> <section className="mb-8">
); <h2 className="mb-3 text-sm font-medium text-muted-foreground">继续阅读</h2>
const pct = Math.round(r.percent * 100); <ul className="flex gap-3 overflow-x-auto pb-2" role="list">
{continueRows.map((r, i) => {
const b = booksByKey.get(`${r.library_id}/${r.path}`);
if (!b)
return ( return (
<li key={`${r.library_id}/${r.path}`} className="fx-rise shrink-0" style={{ animationDelay: `${i * 40}ms` }}> <li key={`${r.library_id}/${r.path}`} className="fx-rise w-40 shrink-0 rounded-md border bg-card p-3 text-xs text-muted-foreground" style={{ animationDelay: `${i * 40}ms` }}>
<button <p className="truncate">{r.title || "已删除的书"}</p>
className="group flex w-64 items-center gap-3 rounded-xl p-2 text-left ring-1 ring-white/[0.07] transition-colors hover:bg-white/[0.05] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500" <p className="mt-1 text-[10px]">进度已保留</p>
onClick={() => nav(`/book/${b.id}`)}
>
<Cover book={b} className="aspect-[3/4] w-14 shrink-0 rounded-md shadow ring-1 ring-white/10" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-stone-100 group-hover:text-amber-300">{b.title}</span>
<span className="mt-0.5 flex items-center gap-1.5 text-[11px] text-stone-500">
{r.library}
<span className="tabular-nums text-stone-400">{pct}%</span>
</span>
<span className="mt-1.5 block h-1 overflow-hidden rounded-full bg-white/10">
<span className="block h-full rounded-full bg-amber-500" style={{ width: `${pct}%` }} />
</span>
</span>
<IconBookOpen className="size-4 shrink-0 text-stone-600 transition-colors group-hover:text-amber-400" />
</button>
</li> </li>
); );
})} const pct = Math.round(r.percent * 100);
</ul> return (
</Section> <li key={`${r.library_id}/${r.path}`} className="fx-rise shrink-0" style={{ animationDelay: `${i * 40}ms` }}>
)} <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" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium group-hover:text-accent">{b.title}</span>
<span className="mt-0.5 flex justify-between text-[11px] text-muted-foreground">
<span>{r.library}</span>
<span className="tabular-nums">{pct}%</span>
</span>
<span className="mt-1.5 block h-0.5 overflow-hidden rounded-full bg-muted">
<span className="block h-full rounded-full bg-accent" style={{ width: `${pct}%` }} />
</span>
</span>
</button>
</li>
);
})}
</ul>
</section>
)}
{booksQ.isError && ( {booksQ.isError && (
<div className={`fx-rise mb-8 flex flex-wrap items-center gap-3 rounded-xl bg-red-950/40 px-4 py-3 text-sm text-red-200 ring-1 ring-red-500/25`}> <div className="fx-rise mb-8 flex flex-wrap items-center gap-3 rounded-md border border-destructive/40 px-4 py-3 text-sm">
<IconAlert className="size-4 shrink-0" /> <TriangleAlert className="size-4 shrink-0 text-destructive" />
<span>加载失败:{booksQ.error instanceof Error ? booksQ.error.message : "未知错误"}</span> <span>加载失败:{booksQ.error instanceof Error ? booksQ.error.message : "未知错误"}</span>
<button className={btn} onClick={() => booksQ.refetch()}> <Button variant="outline" size="sm" onClick={() => booksQ.refetch()}>
<IconRefresh className="size-4" /> <RefreshCw className="size-4" /> 重试
重试 </Button>
</button> </div>
</div> )}
)}
{booksQ.isPending && !booksQ.isError && ( {booksQ.isPending && !booksQ.isError && (
<div className={GRID} aria-busy="true" aria-label="加载书目中"> <div className={GRID} aria-busy="true" aria-label="加载书目中">
{Array.from({ length: 12 }).map((_, i) => ( {Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="space-y-2"> <div key={i} className="space-y-2">
<div className="aspect-[3/4] animate-pulse rounded-lg bg-stone-800/70" /> <Skeleton className="aspect-[3/4] w-full rounded-md" />
<div className="h-3 w-3/4 animate-pulse rounded bg-stone-800/70" /> <Skeleton className="h-3 w-3/4" />
<div className="h-2 w-1/2 animate-pulse rounded bg-stone-800/50" /> <Skeleton className="h-2 w-1/2" />
</div> </div>
))}
</div>
)}
{!booksQ.isPending && secs.every((s) => s.books.length === 0) && !booksQ.isError && (
<div className="fx-rise flex flex-col items-center py-20 text-center">
<FolderOpen className="size-10 text-muted-foreground/60" />
<h3 className="mt-3 text-sm font-medium">{searching ? `没有匹配「${serverQ}」的书` : `${libName}暂无书目`}</h3>
<p className="mt-1 text-sm text-muted-foreground">{searching ? "换个关键词试试。" : "放入文件后等待扫描,或用侧栏「库管理」上传。"}</p>
</div>
)}
{secs.map((s) => (
<section key={s.label || "//"} className="mb-10">
<SectionHead label={s.label} count={s.books.length} />
<div className={GRID}>
{s.books.map((b, i) => (
<BookCard key={b.id} book={b} index={i} onOpen={() => nav(`/book/${b.id}`)} onDelete={() => setConfirmDel(b)} />
))} ))}
</div> </div>
)} </section>
))}
{!booksQ.isPending && groups.length === 0 && !booksQ.isError && ( <AlertDialog open={confirmDel !== null} onOpenChange={(o) => !o && setConfirmDel(null)}>
<div className="fx-rise flex flex-col items-center py-16 text-center"> <AlertDialogContent>
<IconFolder className="size-10 text-stone-700" /> <AlertDialogHeader>
<h3 className="mt-3 text-sm font-medium text-stone-300">{searching ? `没有匹配「${q}」的书` : "此库暂无书目"}</h3> <AlertDialogTitle>删除《{confirmDel?.title}》?</AlertDialogTitle>
<p className="mt-1 text-sm text-stone-500"> <AlertDialogDescription>磁盘文件一并删除,阅读进度保留。</AlertDialogDescription>
{searching ? "换个关键词试试。" : "放入文件后等待扫描,或点上方「扫描此库」。"} </AlertDialogHeader>
</p> <AlertDialogFooter>
</div> <AlertDialogCancel>取消</AlertDialogCancel>
)} <AlertDialogAction
className="bg-destructive text-primary-foreground hover:bg-destructive/90"
{groups.map((g) => ( onClick={() => {
<Section if (confirmDel) del.mutate(confirmDel.id);
key={g.dir || "//"} setConfirmDel(null);
title={g.dir || "根目录"} }}
icon={<IconFolder className="size-4 text-stone-500" />} >
> 删除
<div className={GRID}> </AlertDialogAction>
{g.books.map((b, i) => ( </AlertDialogFooter>
<BookCard key={b.id} book={b} index={i} onOpen={() => nav(`/book/${b.id}`)} onDelete={() => del.mutate(b.id)} /> </AlertDialogContent>
))} </AlertDialog>
</div>
</Section>
))}
</div>
</div> </div>
); );
} }
+62 -54
View File
@@ -1,9 +1,12 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useRef, useState, type FormEvent } from "react"; import { useRef, useState, type DragEvent, type FormEvent } from "react";
import { RefreshCw, Upload } from "lucide-react";
import { api } from "../../api/client"; import { api } from "../../api/client";
import { TopBar } from "../../components/TopBar";
import { toast } from "../../components/Toaster"; import { toast } from "../../components/Toaster";
import { btn, btnPrimary, card, input } from "../../components/ui"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
export default function AdminLibraries() { export default function AdminLibraries() {
const qc = useQueryClient(); const qc = useQueryClient();
@@ -30,10 +33,10 @@ export default function AdminLibraries() {
}); });
async function onFiles(libId: number, list: FileList | null) { async function onFiles(libId: number, list: FileList | null) {
const files = Array.from(list ?? []); // FileList 是活引用,onChange 里 input.value="" 会清空它,必须先快照 const files = Array.from(list ?? []); // FileList 活引用,先快照
if (!files.length) return; if (!files.length) return;
try { try {
for (const f of files) await api.uploadBook(libId, f); // 串行,单个失败即止并报错 for (const f of files) await api.uploadBook(libId, f);
toast("ok", `已上传 ${files.length} 个文件,等待 scanner 收编`); toast("ok", `已上传 ${files.length} 个文件,等待 scanner 收编`);
void qc.invalidateQueries({ queryKey: ["books"] }); void qc.invalidateQueries({ queryKey: ["books"] });
setTimeout(() => void qc.invalidateQueries({ queryKey: ["books"] }), 5000); setTimeout(() => void qc.invalidateQueries({ queryKey: ["books"] }), 5000);
@@ -42,63 +45,68 @@ export default function AdminLibraries() {
} }
} }
function onDrop(e: DragEvent, libId: number) {
e.preventDefault();
void onFiles(libId, e.dataTransfer.files);
}
function submit(e: FormEvent) { function submit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
create.mutate(); create.mutate();
} }
const libs = libsQ.data ?? [];
return ( return (
<div className="flex h-full flex-col"> <div className="mx-auto w-full max-w-3xl px-4 py-6 sm:px-6">
<TopBar /> <h1 className="mb-4 text-lg font-semibold tracking-tight">库管理</h1>
<div className="flex-1 overflow-y-auto p-4"> <form onSubmit={submit} className="mb-6 flex flex-wrap items-center gap-3">
<h1 className="mb-4 text-lg font-semibold">库管理</h1> <Input className="max-w-64 flex-1" placeholder="库名(服务端目录自动落在 BOOKS_DIR/<库名>)" value={name} onChange={(e) => setName(e.target.value)} />
<form onSubmit={submit} className={`${card} mb-6 flex max-w-2xl flex-wrap items-center gap-3 p-4`}> <Button type="submit" disabled={create.isPending || !name.trim()}>
<input {create.isPending ? "创建中…" : "建库"}
className={input + " flex-1"} </Button>
placeholder="库名(如 comics,服务端目录自动落在 BOOKS_DIR/<库名> 下)" </form>
value={name} {libsQ.isPending && (
onChange={(e) => setName(e.target.value)} <div className="space-y-3">
/> {Array.from({ length: 3 }).map((_, i) => (
<button className={btnPrimary} disabled={create.isPending || !name.trim()}> <Skeleton key={i} className="h-28 w-full" />
{create.isPending ? "创建中…" : "建库"}
</button>
</form>
<div className="flex max-w-2xl flex-col gap-4">
{(libsQ.data ?? []).map((l) => (
<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-stone-500">#{l.id}</span>
</div>
<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 ? "扫描中…" : "扫描"}
</button>
<input
ref={(el) => {
fileRefs.current[l.id] = el;
}}
type="file"
multiple
accept=".cbz,.zip,.pdf,.epub,.txt,.md"
className="hidden"
onChange={(e) => {
void onFiles(l.id, e.target.files);
e.target.value = "";
}}
/>
<button className={btn} onClick={() => fileRefs.current[l.id]?.click()}>
上传文件
</button>
<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-stone-500">还没有库,用上方表单创建。</p>
)}
</div> </div>
)}
<div className="flex flex-col gap-4">
{libs.map((l) => (
<Card key={l.id} onDragOver={(e) => e.preventDefault()} onDrop={(e) => onDrop(e, l.id)}>
<CardHeader className="flex-row items-baseline justify-between gap-3 space-y-0 py-3">
<CardTitle className="flex items-baseline gap-2 text-base">
{l.name}
<span className="text-xs font-normal text-muted-foreground">#{l.id}</span>
</CardTitle>
<p className="text-xs text-muted-foreground">{l.root_path}</p>
</CardHeader>
<CardContent className="flex flex-wrap items-center gap-2 pt-0">
<Button variant="outline" size="sm" disabled={scan.isPending} onClick={() => scan.mutate(l.id)}>
<RefreshCw /> {scan.isPending ? "扫描中…" : "扫描"}
</Button>
<input
ref={(el) => {
fileRefs.current[l.id] = el;
}}
type="file"
multiple
accept=".cbz,.zip,.pdf,.epub,.txt,.md"
className="hidden"
onChange={(e) => {
void onFiles(l.id, e.target.files);
e.target.value = "";
}}
/>
<Button size="sm" onClick={() => fileRefs.current[l.id]?.click()}>
<Upload /> 上传文件
</Button>
<span className="text-xs text-muted-foreground">白名单:cbz/zip/pdf/epub/txt/md · 也可直接拖文件进卡片</span>
</CardContent>
</Card>
))}
{libs.length === 0 && !libsQ.isPending && <p className="text-sm text-muted-foreground">还没有库,用上方表单创建。</p>}
</div> </div>
</div> </div>
); );
+69 -49
View File
@@ -2,9 +2,16 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { api } from "../../api/client"; import { api } from "../../api/client";
import { useAuth } from "../../auth/AuthContext"; import { useAuth } from "../../auth/AuthContext";
import { TopBar } from "../../components/TopBar";
import { toast } from "../../components/Toaster"; import { toast } from "../../components/Toaster";
import { btn, btnPrimary, card, input } from "../../components/ui"; import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
export default function AdminUsers() { export default function AdminUsers() {
const qc = useQueryClient(); const qc = useQueryClient();
@@ -39,55 +46,68 @@ export default function AdminUsers() {
} }
return ( return (
<div className="flex h-full flex-col"> <div className="mx-auto w-full max-w-3xl px-4 py-6 sm:px-6">
<TopBar /> <h1 className="mb-4 text-lg font-semibold tracking-tight">用户管理</h1>
<div className="flex-1 overflow-y-auto p-4"> <form onSubmit={submit} className="mb-6 flex flex-wrap items-center gap-3">
<h1 className="mb-4 text-lg font-semibold">用户管理</h1> <Input className="max-w-40" placeholder="用户名" value={u} onChange={(e) => setU(e.target.value)} />
<form onSubmit={submit} className={`${card} mb-6 flex max-w-2xl flex-wrap items-center gap-3 p-4`}> <Input className="max-w-48" type="password" placeholder="密码(≥8 位)" value={p} onChange={(e) => setP(e.target.value)} />
<input className={input} placeholder="用户名" value={u} onChange={(e) => setU(e.target.value)} /> <Select value={role} onValueChange={(v) => setRole(v as "member" | "admin")}>
<input className={input} type="password" placeholder="密码(≥8 位)" value={p} onChange={(e) => setP(e.target.value)} /> <SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
<select className={input} value={role} onChange={(e) => setRole(e.target.value as "member" | "admin")}> <SelectContent>
<option value="member">member</option> <SelectItem value="member">member</SelectItem>
<option value="admin">admin</option> <SelectItem value="admin">admin</SelectItem>
</select> </SelectContent>
<button className={btnPrimary} disabled={create.isPending || !u.trim() || p.length < 8}> </Select>
{create.isPending ? "创建中…" : "创建"} <Button type="submit" disabled={create.isPending || !u.trim() || p.length < 8}>
</button> {create.isPending ? "创建中…" : "创建"}
</form> </Button>
<table className="w-full max-w-2xl text-sm"> </form>
<thead className="text-left text-stone-500"> {usersQ.isPending ? (
<tr> <Skeleton className="h-40 w-full" />
<th className="py-1">ID</th> ) : (
<th>用户名</th> <Table>
<th>角色</th> <TableHeader>
<th>创建时间</th> <TableRow>
<th /> <TableHead className="w-16">ID</TableHead>
</tr> <TableHead>用户名</TableHead>
</thead> <TableHead>角色</TableHead>
<tbody> <TableHead>创建时间</TableHead>
<TableHead className="w-16" />
</TableRow>
</TableHeader>
<TableBody>
{(usersQ.data ?? []).map((x) => ( {(usersQ.data ?? []).map((x) => (
<tr key={x.id} className="border-t border-stone-800"> <TableRow key={x.id}>
<td className="py-1 text-stone-500">{x.id}</td> <TableCell className="text-muted-foreground">{x.id}</TableCell>
<td>{x.username}</td> <TableCell>{x.username}</TableCell>
<td>{x.role}</td> <TableCell>{x.role}</TableCell>
<td className="text-stone-500">{x.created_at.slice(0, 19).replace("T", " ")}</td> <TableCell className="text-muted-foreground">{x.created_at.slice(0, 19).replace("T", " ")}</TableCell>
<td className="py-1 text-right"> <TableCell className="text-right">
<button <AlertDialog>
className={btn + " text-red-300"} <AlertDialogTrigger asChild>
disabled={x.id === me?.id} <Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" disabled={x.id === me?.id} title={x.id === me?.id ? "不能删除自己" : undefined}>
title={x.id === me?.id ? "不能删除自己" : undefined} 删除
onClick={() => { </Button>
if (confirm(`删除用户 ${x.username}?`)) del.mutate(x.id); </AlertDialogTrigger>
}} <AlertDialogContent>
> <AlertDialogHeader>
删除 <AlertDialogTitle>删除用户 {x.username}?</AlertDialogTitle>
</button> <AlertDialogDescription>该用户的阅读进度与书签一并删除。</AlertDialogDescription>
</td> </AlertDialogHeader>
</tr> <AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction className="bg-destructive text-primary-foreground hover:bg-destructive/90" onClick={() => del.mutate(x.id)}>
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))} ))}
</tbody> </TableBody>
</table> </Table>
</div> )}
</div> </div>
); );
} }
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { formatSize } from "../src/lib/format";
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");
});
it("returns empty for non-positive and keeps one decimal below 10", () => {
expect(formatSize(-1)).toBe("");
expect(formatSize(1024)).toBe("1.0 KB");
expect(formatSize(10 * 1024)).toBe("10 KB");
});
});
+25
View File
@@ -0,0 +1,25 @@
import { expect, it } from "vitest";
import type { Book } from "../src/api/types";
import { sections, sortBooks } from "../src/lib/shelf";
const b = (over: Partial<Book>): Book =>
({ id: 1, path: "p", title: "t", format: "cbz", size: 1, pages: 1, percent: 0, added_at: "2026-01-01T00:00:00Z", library_id: 1, state: "ready", cover_url: "", page_url_fmt: "", pages_url: "", error: "", ...over }) as Book;
it("sortBooks: added 新→旧 / title zh 本地化 / progress 高→低且并列按 added", () => {
const old = b({ id: 1, added_at: "2026-01-01T00:00:00Z", title: "b" });
const nw = b({ id: 2, added_at: "2026-02-01T00:00:00Z", title: "a" });
expect(sortBooks([old, nw], "added").map((x) => x.id)).toEqual([2, 1]);
expect(sortBooks([old, nw], "title").map((x) => x.id)).toEqual([2, 1]);
expect(sortBooks([b({ id: 3, percent: 0.2 }), b({ id: 4, percent: 0.9 })], "progress").map((x) => x.id)).toEqual([4, 3]);
const tie = [b({ id: 5, percent: 0.5, added_at: "2026-01-01T00:00:00Z" }), b({ id: 6, percent: 0.5, added_at: "2026-03-01T00:00:00Z" })];
expect(sortBooks(tie, "progress").map((x) => x.id)).toEqual([6, 5]);
});
it("sections: flat 单段无标题;dir 按目录分组且组内排序、根目录置顶", () => {
const books = [b({ id: 1, path: "x/a.cbz" }), b({ id: 2, path: "b.cbz", title: "B" }), b({ id: 3, path: "x/c.cbz", title: "A" })];
// 容器 Intl zh 实测序 A < B < t (localeCompare("zh")),故 title 升序为 id3,id2,id1
expect(sections(books, "title", "flat")).toEqual([{ label: "", books: [books[2], books[1], books[0]] }]);
const g = sections(books, "title", "dir");
expect(g.map((s) => s.label)).toEqual(["", "x"]);
expect(g[1].books.map((x) => x.title)).toEqual(["A", "t"]); // x 组内按 title 排序
});
+1
View File
@@ -12,6 +12,7 @@
"isolatedModules": true, "isolatedModules": true,
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true, "noEmit": true,
"paths": { "@/*": ["./src/*"] },
"types": ["vite/client", "vite-plugin-pwa/client"] "types": ["vite/client", "vite-plugin-pwa/client"]
}, },
"include": ["src", "test"] "include": ["src", "test"]
+3 -2
View File
@@ -4,6 +4,7 @@ import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
export default defineConfig({ export default defineConfig({
resolve: { alias: { "@": "/src" } },
plugins: [ plugins: [
react(), react(),
tailwindcss(), tailwindcss(),
@@ -14,8 +15,8 @@ export default defineConfig({
name: "BookLib 个人书库", name: "BookLib 个人书库",
short_name: "BookLib", short_name: "BookLib",
description: "个人书库/漫画库在线阅读", description: "个人书库/漫画库在线阅读",
theme_color: "#0c0a09", theme_color: "#fafaf9",
background_color: "#0c0a09", background_color: "#fafaf9",
display: "standalone", display: "standalone",
icons: [{ src: "icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" }], icons: [{ src: "icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" }],
}, },
+1
View File
@@ -1,5 +1,6 @@
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
export default defineConfig({ export default defineConfig({
resolve: { alias: { "@": new URL("./src", import.meta.url).pathname } },
test: { include: ["test/**/*.test.ts", "test/**/*.test.tsx"], passWithNoTests: true }, test: { include: ["test/**/*.test.ts", "test/**/*.test.tsx"], passWithNoTests: true },
}); });