Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7727baa25b | ||
|
|
dcaf502de6 | ||
|
|
273f35a42f | ||
|
|
b1c0db6c03 | ||
|
|
d9e9e95ece | ||
|
|
52e95a48d8 | ||
|
|
7957e5cee5 | ||
|
|
913f3286bb | ||
|
|
03bc537308 | ||
|
|
87b7545a94 | ||
|
|
ee676c2d2e | ||
|
|
b8a5b3deb6 |
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 改动、收藏/标签等新功能。
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
Generated
+1815
-115
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?? ""}`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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]}`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
@@ -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; }
|
||||||
|
|||||||
@@ -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]}`;
|
||||||
|
}
|
||||||
@@ -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 }));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 排序
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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,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 },
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user