Compare commits

...
35 Commits
Author SHA1 Message Date
XingfenD 90b39d4f97 Merge branch 'feat/ui-redesign' 2026-09-08 21:48:41 +08:00
XingfenD fd227c1f00 feat(web): table-style 库管理, admin entries under avatar 系统管理, shelf-view upload button 2026-09-08 21:45:03 +08:00
XingfenD 3178c2802f refactor(web): extract prefetch slider into reusable RdSlider component 2026-09-08 21:31:20 +08:00
XingfenD 472e58bec9 fix(web): unify 更多 menu text size, add 0-3 tick anchors under prefetch slider 2026-09-08 21:30:26 +08:00
XingfenD d2c852d6f0 feat(web): iconify reader chrome, move 连读/预读 into 更多 menu with slider 2026-09-08 21:29:01 +08:00
XingfenD 9c2b7e2074 feat(web): collapse reader toc/bookmark buttons into single 导航 toolbar button 2026-09-08 21:11:16 +08:00
XingfenD 65e07a0890 chore: ignore tsc buildinfo artifact 2026-09-08 21:08:52 +08:00
XingfenD 48161ea1ff feat(web): unify reader TOC and bookmarks into one left nav drawer with tabs 2026-09-08 21:08:42 +08:00
XingfenD 4f4c7e5434 chore: gitignore playwright artifacts 2026-09-08 20:48:38 +08:00
XingfenD 6fbe3b0794 fix(web): active lib highlight, rail a11y names, card focus ring, AA accent + destructive tokens, touch-reachable menu, brand wordmark, chrome unify 2026-09-08 20:37:20 +08:00
XingfenD 7e0847815d docs(changelog): phase-1 modern redesign — shadcn tokens, dual theme, app shell, rebuilt public surfaces 2026-09-08 19:37:38 +08:00
XingfenD 7727baa25b fix(web): theme menu items fire on keyboard select (onSelect) 2026-09-08 19:19:45 +08:00
XingfenD dcaf502de6 feat(ui): admin pages on shadcn table/card/alert-dialog; drop TopBar, trim icons/ui.ts to reader-compat surface 2026-09-08 19:14:41 +08:00
XingfenD 273f35a42f fix(web): shelf search debounce merges against live searchParams (no lost sort/group clicks) 2026-09-08 19:08:05 +08:00
XingfenD b1c0db6c03 feat(ui): shelf rebuilt — sticky toolbar (search/sort/group in URL), rail, menu cards, alert-dialog delete 2026-09-08 18:58:16 +08:00
XingfenD d9e9e95ece feat(ui): login rebuilt on shadcn card/label/input, theme toggle available pre-auth 2026-09-08 17:47:17 +08:00
XingfenD 52e95a48d8 fix(ui): gate app sidebar to md+ and use onSelect for logout (keyboard path) 2026-09-08 17:41:29 +08:00
XingfenD 7957e5cee5 feat(ui): AppShell layout route — desktop rail, tablet icon rail, mobile tabs, theme toggle 2026-09-08 17:35:14 +08:00
XingfenD 913f3286bb feat(lib): shelf sort/section pure helpers + formatSize extraction, table tests 2026-09-08 17:29:07 +08:00
XingfenD 03bc537308 feat(ui): semantic light/dark tokens, ThemeProvider with FOUC guard, token-restyle Toaster/Cover 2026-09-08 17:17:44 +08:00
XingfenD 87b7545a94 feat(ui): shadcn/radix toolchain — deps, @ alias, cn, generated primitives 2026-09-08 17:07:28 +08:00
XingfenD ee676c2d2e docs(plan): modern redesign phase-1 implementation plan — 8 tasks from toolchain to acceptance walkthrough 2026-09-08 17:02:02 +08:00
XingfenD b8a5b3deb6 docs(design): phase-1 modern redesign spec — shadcn/radix foundation, dual theme, app shell, shelf/admin rebuild; readers deferred to phase 2 2026-09-08 16:49:46 +08:00
XingfenD 2721df8ea9 fix(web): upload toast always showed 0 files (live FileList read after input reset); snapshot before upload 2026-09-08 16:22:12 +08:00
XingfenD e5dacc0a77 fix(cbz): clamp locked cur at chapter end, hide 连读 on flat packs, silent restore, tighten prefetch storage 2026-09-08 15:28:27 +08:00
XingfenD 60c4ccf26f fix(cbz): window-relative scroll geometry locks head boundary; default prefetch 2 when key absent 2026-09-08 15:15:42 +08:00
XingfenD f63b639f64 feat(cbz): chapter-scoped continuous reading — lock scroll at chapter end, screen paging, prefetch next N chapters; drop 连读/整页/适高 modes 2026-09-08 15:01:28 +08:00
XingfenD cd251b940c feat(cbz): pure chapter-window helpers (chapterIndexAt/chapterWin) with table tests 2026-09-08 14:56:46 +08:00
XingfenD bba82e5529 docs(plan): correct container names to podman compose underscores 2026-09-08 14:54:34 +08:00
XingfenD 497dc16604 fix(deploy): COPY paths in web Dockerfiles still referenced old web/ dir after web->frontend rename — dev image build failed 2026-09-08 14:53:29 +08:00
XingfenD 37ddff89a5 docs(cbz): chapter-scoped reading implementation plan — 3 tasks, virt helpers TDD first 2026-09-08 14:51:03 +08:00
XingfenD a39ddf7b57 docs: add cbz chapter-scoped reading design spec 2026-09-08 14:45:15 +08:00
XingfenD ec3f2c587e feat(reader): remove admin delete button from reader header; shelf card hover remains the only delete entry 2026-09-08 13:08:46 +08:00
XingfenD a43b73dcef fix(cbz): attach resize observer via callback ref + drop vh from scroll-mode geometry deps — fixes constant per-page gaps and refetch storm on mobile 2026-09-08 13:05:58 +08:00
XingfenD 32b9d3a136 feat(ui): content-first shelf redesign — responsive cover grid, hover overlays, format badges, skeletons, sticky blurred top bar, brand login, SVG icon set, animated toasts 2026-09-08 13:05:40 +08:00
59 changed files with 6905 additions and 867 deletions
+2
View File
@@ -1,3 +1,5 @@
.env
.superpowers/
tasks/
.playwright*/
frontend/tsconfig.tsbuildinfo
+33 -4
View File
@@ -10,11 +10,14 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
### Added / 新增
- Bookmarks in all four readers: a 书签 button in the bar captures the current position (page / chapter+offset / EPUB CFI) with an optional note; the right-side panel lists them — click jumps straight back to that spot, ✎ edits the note in place, ✕ deletes. Bookmarks are per-user and server-side, so they follow you across devices.
- 四类阅读器新增书签:工具条「书签」按钮把当前位置(页码/章节+章内偏移/EPUB CFI)连同可选备注存下;右侧书签面板点一下即跳回原位,✎ 行内改备注,✕ 删除。书签按用户存在服务端,换设备同账号即见。
- CBZ reader is now chapter-scoped: a chapter's images flow as one continuous strip and scrolling stops at an 本章完 · 下一章 card (only chapter buttons / TOC / slider / bookmarks cross the boundary); a 连读 toggle makes scrolling flow across chapters, and a configurable 0–3-chapter prefetch pre-mounts upcoming chapters for instant switching — the old 连读/整页/适高 mode cycle is gone.
- CBZ 阅读器改为以章为单位:一章图片是一段连续长卷,滚动止于「本章完 · 下一章」卡片(跨章只能靠章节按钮/目录/滑条/书签);「连读」开关让滑动贯穿章节,预读 0–3 章可配置、提前挂载解码实现切章零白屏;移除原 连读/整页/适高 三档循环。
- Folder-structured comics gain a 目录 sidebar in the CBZ reader: each archive folder (第N話…) becomes a chapter that jumps to its first page, with 上一章/下一章 buttons, a current-chapter counter, and the active row highlighted and scrolled into view — flat archives show no extra UI.
- 按目录组织的漫画在 CBZ 阅读器新增「目录」侧栏:压缩包内每个文件夹(第N話…)即一章,点击直达该话首页;工具条配上一章/下一章与当前章计数,目录内高亮当前章并自动居中;平铺无目录的包不显示多余控件。
- Bookmarks in all four readers: in the 书签 tab of the left navigation drawer, 加 captures the current position (page / chapter+offset / EPUB CFI) with an optional note; clicking a row jumps straight back to that spot, ✎ edits the note in place, ✕ deletes. Bookmarks are per-user and server-side, so they follow you across devices.
- 四类阅读器新增书签:在左侧导航抽屉的「书签」选项卡内点「加」把当前位置(页码/章节+章内偏移/EPUB CFI)连同可选备注存下;点条目即跳回原位,✎ 行内改备注,✕ 删除。书签按用户存在服务端,换设备同账号即见。
- Folder-structured comics gain a 目录 tab in the CBZ reader's navigation drawer: each archive folder (第N話…) becomes a chapter that jumps to its first page, with 上一章/下一章 buttons, a current-chapter counter, and the active row highlighted and scrolled into view — flat archives show no extra UI.
- 按目录组织的漫画在 CBZ 阅读器导航抽屉内新增「目录」选项卡:压缩包内每个文件夹(第N話…)即一章,点击直达该话首页;工具条配上一章/下一章与当前章计数,目录内高亮当前章并自动居中;平铺无目录的包不显示多余控件。
- Library uploads over 16MB now auto-switch to the resumable chunked protocol (8MB parts): failed parts retry in place up to 3 times and already-sent parts are skipped, so a flaky upload continues instead of starting over; small files keep the original single request.
- 书库上传超过 16MB 自动切换为可续传分片协议(每片 8MB):失败的分片原地重试至多 3 次,已传片自动跳过,中断后继续而不是从头再来;小文件仍走原单次上传。
@@ -28,13 +31,39 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
### Changed / 变更
- Admin polish: 库管理 is now a table styled like 用户管理 (scan/upload per row, drag-to-row upload kept); both admin pages moved out of the sidebar and bottom tabs into a 系统管理 group under the avatar menu; admins can upload files straight from a library's shelf view via a new 上传文件 button next to 扫描此库.
- 后台入口统一:库管理改为用户管理同款表格(行内扫描/上传,仍支持拖文件到行);两个管理页从侧边栏与移动端底 Tab 收进头像菜单的「系统管理」分组;书架进入某个库时,管理员可在「扫描此库」旁直接「上传文件」。
- Reader chrome is no longer text-only: lucide icons accompany 导航/上一章/下一章/翻页/抽屉选项卡/书签操作, while 连读 and 预读 (now a 0–3 slider) collapse into a 更多 menu in the CBZ bar.
- 阅读器工具条告别纯文字:导航/上一章/下一章/翻页/抽屉选项卡/书签操作均配 lucide 图标;连读与预读(改为 0–3 滑条)收进 CBZ 工具条的「更多」菜单。
- Reader navigation tightened into one left-side drawer opened by a single 导航 toolbar button: 目录 and 书签 are now tabs of a sidebar shared by all four readers, following the reader theme's colors; the button lands on 目录 when a TOC exists, otherwise straight on bookmarks (flat archives, EPUB, PDF show no tab bar); the separate right-side bookmark panel and the per-reader TOC popovers are gone.
- 阅读器导航收紧为统一左侧抽屉,工具条只留一个「导航」按钮:目录与书签成为四个阅读器共用侧边栏内的两个选项卡,配色跟随阅读器主题;有目录时默认落在「目录」,否则直达书签(平铺包、EPUB、PDF 不显示选项卡条);原右侧书签面板与各阅读器自绘目录浮层移除。
- The web UI was rebuilt on a shadcn/Radix design system: semantic light/dark theme tokens with a system/light/dark switcher (remembered), a persistent app shell (desktop sidebar, tablet icon rail, mobile bottom tabs), a shelf driven by URL-state search/sort/group controls with a flat-first grid and menu-based card actions (confirm dialogs replace native popups), and rebuilt login/admin pages. Readers keep their current chrome and every reading interaction this round; their restyle lands next.
- 网页 UI 以 shadcn/Radix 设计系统重做:语义化明暗主题 token + 系统/浅色/深色切换(记忆);常驻应用壳(桌面侧栏、平板图标栏、手机底部标签);书架改为 URL 状态驱动的搜索/排序/分组工具条,默认平铺网格,卡片操作收进菜单(删除改为对话框确认);登录页与 admin 两页同步重做。阅读器保持现状,改版随下一期到达。
- Shelf rebuilt into a content-first library UI: responsive cover grid (auto-fill 3:4 posters) with a pages/size caption, color-coded format badges, in-cover progress bars, staggered entrance animations, skeleton loading, and dedicated empty/error/searching states; 继续阅读 is now a horizontal resume strip (cover + progress + library); login got a clean brand card; toasts animate with icons; missing covers render a serif-initial placeholder instead of an endless pulse.
- 书架重构为「内容优先」的现代书库界面:响应式封面网格(自适应列宽、3:4 海报),封面下方显示页数/大小、格式彩色徽章、封面内嵌进度条、卡片错峰入场动画、骨架屏加载与空/错/搜索无结果专属状态;「继续阅读」改为横向续读卡片条(封面+进度+所属库);登录页升级为主视觉卡片;Toast 带动画与图标;无封面书籍以衬线首字占位,不再无限脉冲。
- .txt chapter splitting no longer recognizes volumes: 「第X卷/部」 lines stay plain body text and volume-only books fall back to pseudo-sections as before; chapter switching moves from the dropdown to a 目录 sidebar overlay that highlights the current chapter, scrolls it into view, and closes on click-outside or after picking a chapter.
- .txt 章节切分不再感知卷:「第X卷/部」行只作为正文文本,只有卷的书和以前一样退回伪章节切分;换章从下拉框改为「目录」侧边栏浮层——当前章高亮并自动居中可见,点击空白处或选定章节即收起。
- Whole UI restyled to a warm library palette (stone surfaces + amber accents, replacing cool zinc + emerald): warmer dark chrome, amber primary buttons/links, hover/press feedback on shelf cards (cover lift + glow), reader header with format badge and blur, pill-style CBZ page indicator, EPUB spread centered like a book page.
- 全站换为暖色「书房」配色(stone 深灰面 + amber 琥珀强调,替代冷色 zinc + emerald):书架封面悬停上浮高亮、阅读器顶栏带格式徽章与毛玻璃、CBZ 页码改为悬浮胶囊、EPUB 页面居中成书卷感,主按钮/链接统一琥珀色并补齐悬停/焦点反馈。
### Removed / 移除
- The admin 删除 button is gone from the reader header: deleting books now lives only in the shelf cover-card menu (with a confirm dialog), so a destructive tap can't happen one step away from the page-turn zone while reading.
- 阅读器顶栏右上角不再提供 admin「删除」按钮:删除入口仅保留在书架封面卡片的操作菜单中(带确认对话框),避免阅读时误触翻页区旁的破坏性删除。
### Fixed / 修复
- Library upload now reports the real file count: the success toast read the live `FileList` after the file input had been reset (always 0, e.g. "已上传 0 个文件"), making successful uploads look broken while waiting for the scanner; the list is snapshotted before uploading.
- 库管理上传完成提示不再恒为「已上传 0 个文件」:此前成功提示在清空 file input 之后读取其活引用 FileList 的长度(始终为 0),上传明明成功却像没生效;现在在上传开始前对文件列表做快照,计数与遍历都用快照。
- Comic continuous-scroll mode is contiguous again on phones: the size observer used to miss the scroll container (it was attached while the reader still showed "loading", so the box didn't exist), leaving page geometry computed from a stale 480px default width while pages rendered at the real (e.g. 390px) width — every page gained a constant dead gap. The observer now attaches when the box actually mounts; additionally, mobile address-bar show/hide no longer rebuilds the scroll geometry in continuous/whole-page modes, which used to wipe measured page heights and re-trigger a burst of image requests.
- 手机端漫画「连读」模式页面不再上下脱节:尺寸监听器此前在阅读器还在「加载中」时就尝试挂载(滚动容器尚不存在),导致监听从未生效,页高按过时的 480px 默认宽计算而实际按手机真实宽度(如 390px)渲染,每页之间出现恒定空白断层;现改为容器真正挂载时再接管。另外,手机地址栏收展在连读/整页模式下不再重建滚动几何——此前会清空全部实测页高并引发一波图片重复请求。
- Dragging the comic scrollbar (or the page slider) is no longer gritty: scroll handling is coalesced to one update per frame instead of per event, prefetching waits until scrolling settles instead of firing mid-drag, height corrections are batched per frame, the virtual list re-estimates unseen pages from the first measured page size (scrollbar geometry stops morphing under your finger), and slider seeks land instantly instead of restarting a smooth-scroll animation per notch.
- 漫画拖动滚动条(及页码滑条)不再发涩:滚动改为每帧合并处理而非逐事件响应,预取等滚动停稳再发(不再拖拽途中抢带宽),量高补偿按帧合并,虚拟列表用首个实测页高重估所有未加载页(滚动条几何不再拖拽中变形),滑条寻址即时落位(不再每格重启一段平滑动画)。
- .txt/.md files encoded in GBK/GB2312/GB18030 or UTF-16 (typical web-novel exports) no longer render as mojibake: the reader strictly tries UTF-8 first and falls back to GB18030, recognizing UTF-16 by BOM.
@@ -0,0 +1,594 @@
# CBZ 章节化连续阅读(锁章 + 预读)Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 漫画一章内所有图视为连续整章内容,默认滚动锁在章末(只能按钮切章),可开连读跨章,并预渲染其后 N 章(0–3,默认 2)使切章零白屏;移除 连读/整页/适高 三档模式。
**Architecture:** 纯前端改动。`lib/virt.ts` 新增两个纯函数(章节窗口计算)供单测全覆盖;`CbzReader.tsx` 重构为「章节窗口 + 屏翻页 + 隐藏预读层」,`PageHeights` 虚拟列表模型不动。无后端/API 改动。
**Tech Stack:** React 18 + TypeScript + Tailwind(rd-* 类)、vitest、vite。
Spec: `docs/superpowers/specs/2026-09-08-cbz-chapter-continuous-reading-design.md`
## Global Constraints
- 分支 `feat/ui-redesign`(当前已在此分支,勿切 master 提交)。
- 测试/构建在容器内跑:`docker exec -w /app booklib_web_1 npm run -s check`(= tsc --noEmit + vitest + vite build);单文件测试 `docker exec -w /app booklib_web_1 npx vitest run test/virt.test.ts`。
- localStorage 键:`cbz-continuous`("1"/"0",默认关)、`cbz-prefetch`(0–3 整数,非法回退 2);旧键 `cbz-mode` 废弃不再读写。
- 用户可见变更必须记 `docs/CHANGELOG_web.md`(英中各一行、相邻行、不同条目间空行),不重复进 `docs/CHANGELOG.md`。
- 全书页号语义不变:进度/书签 locator `{page}`、滑条、页码 HUD 均为全书页,不改后端。
- 容器宽上限 `MAX_W = 720` 保留;扁平包(`chapters` 为 null 或长度 < 2)= 整本一章。
---
### Task 1: `lib/virt.ts` 章节窗口纯函数(TDD)
**Files:**
- Modify: `frontend/src/lib/virt.ts`(文件末尾追加)
- Test: `frontend/test/virt.test.ts`(文件末尾追加)
**Interfaces:**
- Consumes: 无(新代码)。
- Produces:
- `chapterIndexAt(chs: { start: number }[] | null, page: number): number` — 页所在章下标;无章/单章恒 0;page 小于首章 start 归 0。
- `chapterWin(chs: { start: number }[] | null, count: number, ci: number, continuous: boolean, prefetch: number): ChapterWindow`,`interface ChapterWindow { start: number; end: number; mountEnd: number }`(start 含、end 不含;连读/扁平 → 全书;锁章 → 本章窗口,mountEnd 含 prefetch 章)。Task 2 按此签名消费。
- [ ] **Step 1: 写失败测试**(追加到 `frontend/test/virt.test.ts` 末尾)
```ts
import { chapterIndexAt, chapterWin } from "../src/lib/virt"; // 与文件顶部 import 合并
const CH4 = [{ start: 0 }, { start: 10 }, { start: 20 }, { start: 30 }];
it("chapterIndexAt:无章/单章恒 0;页号归章;首章 start 之前的散页归 0", () => {
expect(chapterIndexAt(null, 7)).toBe(0);
expect(chapterIndexAt([{ start: 0 }], 7)).toBe(0);
expect(chapterIndexAt(CH4, 0)).toBe(0);
expect(chapterIndexAt(CH4, 9)).toBe(0);
expect(chapterIndexAt(CH4, 10)).toBe(1);
expect(chapterIndexAt(CH4, 999)).toBe(3);
expect(chapterIndexAt([{ start: 5 }, { start: 10 }], 3)).toBe(0); // 根散页
});
it("chapterWin:扁平/连读 = 全书窗口", () => {
expect(chapterWin(null, 40, 0, false, 2)).toEqual({ start: 0, end: 40, mountEnd: 40 });
expect(chapterWin(CH4, 40, 2, true, 2)).toEqual({ start: 0, end: 40, mountEnd: 40 });
});
it("chapterWin:锁章窗口 = 本章;mountEnd 按预读章数延伸、夹到 count", () => {
expect(chapterWin(CH4, 40, 1, false, 0)).toEqual({ start: 10, end: 20, mountEnd: 20 });
expect(chapterWin(CH4, 40, 1, false, 1)).toEqual({ start: 10, end: 20, mountEnd: 30 });
expect(chapterWin(CH4, 40, 1, false, 2)).toEqual({ start: 10, end: 20, mountEnd: 40 });
expect(chapterWin(CH4, 40, 1, false, 3)).toEqual({ start: 10, end: 20, mountEnd: 40 }); // 越界夹住
expect(chapterWin(CH4, 40, 3, false, 2)).toEqual({ start: 30, end: 40, mountEnd: 40 }); // 末章
});
it("chapterWin:ci 越界夹边;首章窗口含首章 start 之前的散页;prefetch 负数按 0", () => {
expect(chapterWin(CH4, 40, 9, false, 1)).toEqual({ start: 30, end: 40, mountEnd: 40 });
expect(chapterWin(CH4, 40, -2, false, 1)).toEqual({ start: 0, end: 10, mountEnd: 20 });
expect(chapterWin([{ start: 5 }, { start: 10 }], 20, 0, false, 0)).toEqual({ start: 0, end: 10, mountEnd: 10 });
expect(chapterWin(CH4, 40, 1, false, -1)).toEqual({ start: 10, end: 20, mountEnd: 20 });
});
```
- [ ] **Step 2: 跑测试确认失败**
Run: `docker exec -w /app booklib_web_1 npx vitest run test/virt.test.ts`
Expected: FAIL —「chapterIndexAt is not exported」/ import 解析错误。
- [ ] **Step 3: 实现**(追加到 `frontend/src/lib/virt.ts` 末尾)
```ts
/** 页号 → 所在章下标;无章(扁平包或单组)恒 0;首章 start 之前的散页归首章。 */
export function chapterIndexAt(chs: { start: number }[] | null, page: number): number {
if (!chs || chs.length < 2) return 0;
return chs.reduce((acc, c, i) => (c.start <= page ? i : acc), 0);
}
export interface ChapterWindow {
start: number; // 窗口首页(含)——滚动/挂载下界
end: number; // 窗口末页(不含)——锁章时的滚动边界
mountEnd: number; // 挂载上界(含预读章),恒 ≤ count 且 ≥ end
}
/** 阅读窗口:连读或扁平包 = 全书;锁章 = 本章滚动、本章 + prefetch 章挂载。 */
export function chapterWin(
chs: { start: number }[] | null,
count: number,
ci: number,
continuous: boolean,
prefetch: number,
): ChapterWindow {
if (!chs || chs.length < 2 || continuous) return { start: 0, end: count, mountEnd: count };
const c = Math.max(0, Math.min(ci, chs.length - 1));
const end = chs[c + 1]?.start ?? count;
return {
start: c === 0 ? 0 : chs[c].start,
end,
mountEnd: chs[c + 1 + Math.max(0, prefetch)]?.start ?? count,
};
}
```
- [ ] **Step 4: 跑测试确认通过(含既有 virt 用例回归)**
Run: `docker exec -w /app booklib_web_1 npx vitest run test/virt.test.ts`
Expected: 全部 PASS(既有 4 条 + 新增 4 条)。
- [ ] **Step 5: Commit**
```bash
git add frontend/src/lib/virt.ts frontend/test/virt.test.ts
git commit -m "feat(cbz): pure chapter-window helpers (chapterIndexAt/chapterWin) with table tests"
```
---
### Task 2: CbzReader 重构为章节窗口模型
**Files:**
- Modify: `frontend/src/readers/CbzReader.tsx`(整文件替换,代码见 Step 1)
- Modify: `docs/CHANGELOG_web.md`(Step 3)
- Test: 无新增组件测试(既有约定:reader UI 走 `npm run check` + 真实浏览器点验)
**Interfaces:**
- Consumes: Task 1 的 `chapterIndexAt` / `chapterWin`;既有 `PageHeights`、`fetchObjectUrl`、`useProgressSaver`、`useBookmarks`、`api.pageCount`(`{count, chapters: {title,start}[] | null}`)、`chrome`(ReaderProps)。
- Produces: 重写后的默认导出 `CbzReader(props: ReaderProps)`;删除 `PageMode/MODES/MODE_LABEL/useStoredMode/fitH` 等全部旧模式符号(无外部引用者)。
- [ ] **Step 1: 用以下完整内容替换 `frontend/src/readers/CbzReader.tsx`**
```tsx
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { api, formatPageUrl } from "../api/client";
import { btn } from "../components/ui";
import { useBookmarks } from "../components/Bookmarks";
import { fetchObjectUrl } from "../lib/authImage";
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
import { PageHeights, chapterIndexAt, chapterWin } from "../lib/virt";
const MAX_W = 720;
const EST_RATIO = 1.4; // 估高:宽 × 1.4(漫画常见竖幅)
function useStoredBool(key: string, def: boolean) {
const [v, setV] = useState(() => {
const s = localStorage.getItem(key);
return s === null ? def : s === "1";
});
useEffect(() => localStorage.setItem(key, v ? "1" : "0"), [key, v]);
return [v, setV] as const;
}
function useStoredPrefetch() {
const [n, setN] = useState(() => {
const v = Number(localStorage.getItem("cbz-prefetch"));
return Number.isInteger(v) && v >= 0 && v <= 3 ? v : 2;
});
useEffect(() => localStorage.setItem("cbz-prefetch", String(n)), [n]);
return [n, setN] as const;
}
function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoaded: (h: number) => void }) {
const [src, setSrc] = useState("");
const [failed, setFailed] = useState(false);
const [nonce, setNonce] = useState(0);
useEffect(() => {
let dead = false;
setSrc("");
setFailed(false);
fetchObjectUrl(url)
.then((s) => !dead && setSrc(s))
.catch(() => !dead && setFailed(true));
return () => {
dead = true;
};
}, [url, nonce]);
if (failed)
return (
<div className="grid place-items-center gap-2 bg-stone-900 py-8 text-sm text-stone-500" style={{ width }}>
本页加载失败
<button className={btn} onClick={() => setNonce((n) => n + 1)}>
重试
</button>
</div>
);
if (!src)
return <div className="animate-pulse bg-stone-800" style={{ width, height: width * EST_RATIO }} />;
return (
<img
src={src}
alt=""
style={{ width }}
className="block"
onLoad={(e) => {
const el = e.currentTarget;
if (el.naturalWidth > 0) onLoaded((el.naturalHeight / el.naturalWidth) * width);
}}
/>
);
}
export default function CbzReader({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
const countQ = useQuery({
queryKey: ["pages", book.id],
queryFn: () => api.pageCount(book.pages_url ?? ""),
enabled: !!book.pages_url,
retry: 0,
});
const count = countQ.data?.count ?? 0;
const chapters = countQ.data?.chapters ?? null; // 包内目录结构(第N話/上中下卷);null/单组=扁平整本一章
const [toc, setToc] = useState(false);
const activeRow = useRef<HTMLButtonElement>(null);
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
const bm = useBookmarks({ book, capture: saver.capture, seek: (l) => goPage(Number(l.page) || 0) });
const boxRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(480);
const [vh, setVh] = useState(600);
const [top, setTop] = useState(0);
const [scrub, setScrub] = useState<number | null>(null); // 页滑条拖拽中的临时值
const [, bump] = useState(0);
const restored = useRef(false);
const scrollRaf = useRef(0);
const idle = useRef<undefined | ReturnType<typeof setTimeout>>(undefined);
const curRef = useRef(0);
const shift = useRef(0); // 量高补偿按帧合并,避免一次加载多页各改一次 scrollTop
const shiftRaf = useRef(0);
const ratio = useRef(EST_RATIO); // 学到的页高比(同开本漫画一页量准,全程几何稳定)
const [continuous, setContinuous] = useStoredBool("cbz-continuous", false); // 连读:滑动可跨章
const [prefetch, setPrefetch] = useStoredPrefetch(); // 锁章预读章数 0–3
const [ci, setCi] = useState(0); // 当前章窗口;锁章时只由显式跳页改,连读时随滚动同步
const pending = useRef<number | null>(null); // 跨章跳页:切窗后下一帧落位
const ph = useMemo(() => new PageHeights(count, width * ratio.current), [count, width]);
const w = useMemo(
() => chapterWin(chapters, count, ci, continuous, prefetch),
[chapters, count, ci, continuous, prefetch],
);
const locked = !!chapters && chapters.length >= 2 && !continuous;
// 容器尺寸自适应:回调 ref 在滚动盒真正挂载时才观测(等页数请求期间的早退渲染里没有这个节点)
const roRef = useRef<ResizeObserver | null>(null);
function attachBox(el: HTMLDivElement | null) {
boxRef.current = el;
roRef.current?.disconnect();
roRef.current = null;
if (!el) return;
const ro = new ResizeObserver(() => {
setWidth(Math.min(el.clientWidth, MAX_W));
setVh(el.clientHeight);
});
ro.observe(el);
roRef.current = ro;
}
// 首次拿到 count 后恢复进度页;目标页可能在他章 → goPage 走显式切章路径
useEffect(() => {
if (restored.current || !count || !boxRef.current) return;
restored.current = true;
const p = Number(initialLocator?.page);
if (Number.isInteger(p) && p > 0 && p < count) goPage(p, false);
}, [count, initialLocator, ph]);
// 跨章跳页:新窗口几何渲染后才能真正滚动(旧窗口 contentH 会把 scrollTop 夹断),故挂 pending 等 [ci] 生效
useEffect(() => {
if (pending.current === null) return;
const el = boxRef.current;
if (!el) return;
const t = pending.current;
pending.current = null;
el.scrollTop = ph.offset(t);
}, [ci, ph]);
// 滚动事件按帧合并;预取等滚动停稳再做——拖拽途中逐事件拉图=带宽/解码风暴
function onScroll() {
if (scrollRaf.current) return;
scrollRaf.current = requestAnimationFrame(() => {
scrollRaf.current = 0;
const el = boxRef.current;
if (!el) return;
const t = el.scrollTop;
setTop(t);
if (count) {
curRef.current = ph.pageAt(t + vh / 2);
if (!locked) setCi(chapterIndexAt(chapters, curRef.current)); // 连读/扁平:当前章随滚动走(目录计数高亮)
saver.report({ page: curRef.current }, (curRef.current + 1) / count);
}
clearTimeout(idle.current);
idle.current = setTimeout(() => {
for (let j = curRef.current + 1; j <= Math.min(count - 1, curRef.current + 5); j++)
fetchObjectUrl(formatPageUrl(book.page_url_fmt ?? "", j)).catch(() => {});
}, 250);
});
}
useEffect(
() => () => {
cancelAnimationFrame(scrollRaf.current);
cancelAnimationFrame(shiftRaf.current);
clearTimeout(idle.current);
},
[],
);
function applyShift(d: number) {
shift.current += d;
if (shiftRaf.current) return;
shiftRaf.current = requestAnimationFrame(() => {
shiftRaf.current = 0;
const el = boxRef.current;
if (el && shift.current) el.scrollTop += shift.current;
shift.current = 0;
});
}
const cur = ph.pageAt(top + vh / 2);
useEffect(() => {
if (toc) activeRow.current?.scrollIntoView({ block: "center" });
}, [toc]);
function scrollToPage(i: number, smooth: boolean) {
const el = boxRef.current;
if (!el) return;
el.scrollTo({ top: ph.offset(Math.max(w.start, Math.min(w.end - 1, i))), behavior: smooth ? "smooth" : "auto" });
}
// 统一定位入口:锁章下目标页在窗外 = 显式切章(滑条/书签/目录/章末卡片/恢复进度皆此一路径)
function goPage(i: number, smooth = true) {
const t = Math.max(0, Math.min(count - 1, i));
chrome.show();
if (locked && (t < w.start || t >= w.end)) {
setCi(chapterIndexAt(chapters, t));
pending.current = t;
return;
}
scrollToPage(t, smooth);
}
// 翻一屏;锁章时滚动高度天然止于章末卡片,滚不过去
function turnScreen(d: number) {
boxRef.current?.scrollBy({ top: d * vh, behavior: "smooth" });
}
// 点按两侧翻屏,点中间唤出工具栏(微信读书/Mihon 式手势区)
function onZoneClick(e: MouseEvent<HTMLDivElement>) {
if ((e.target as HTMLElement).closest("button,input,nav,a")) return;
if (window.getSelection()?.toString()) return;
const r = e.currentTarget.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width;
if (x < 0.28) turnScreen(-1);
else if (x > 0.72) turnScreen(1);
else chrome.toggle();
}
// pages_url 缺失时 query 被 disabled 永久 pending → 只有真正发起了请求才显示加载态
if (countQ.isPending && !!book.pages_url)
return <div className="grid h-full place-items-center text-stone-500">页索引加载中…</div>;
if (countQ.isError || !book.page_url_fmt)
return (
<div className="grid h-full place-items-center p-8 text-center text-stone-500">
无法解析页索引:{countQ.error instanceof Error ? countQ.error.message : "缺 page_url_fmt"}
</div>
);
const fmt: string = book.page_url_fmt;
const [a0, b0] = ph.range(top, vh, 2);
const a = Math.max(a0, w.start);
const b = Math.min(b0, w.end); // 可见页永不超过章末;窗外页在隐藏预读层
const contentH = locked ? ph.offset(w.end) + vh : ph.total();
function measure(i: number, h: number) {
const el = boxRef.current;
const topY = el ? el.scrollTop : 0;
const oldEst = ph.estHeight;
const topOfI = ph.offset(i);
const d = ph.set(i, h);
if (el && d !== 0 && topOfI < topY) applyShift(d); // 视口上方页高变化 → 补偿滚动
// 让未量高页的估高收敛到实测页高:同开本漫画量一页后 d≈0,拖拽滚动条不再逐帧打架
if (width > 0 && Math.abs(h - oldEst) / oldEst > 0.03) {
const n = ph.setEst(h, topY);
if (el && n) applyShift(n * (h - oldEst));
ratio.current = h / width;
}
bump((x) => x + 1);
}
return (
<div
tabIndex={0}
aria-label="漫画阅读器,点按左右两侧翻屏,点按中间显示工具栏"
className="relative h-full"
onKeyDown={(e) => {
if (e.key === "ArrowRight" || e.key === "PageDown") turnScreen(1);
else if (e.key === "ArrowLeft" || e.key === "PageUp") turnScreen(-1);
else return;
e.preventDefault();
}}
>
<div ref={attachBox} onScroll={onScroll} onClick={onZoneClick} className="h-full overflow-y-auto">
<div className="relative mx-auto" style={{ height: contentH, maxWidth: MAX_W }}>
{Array.from({ length: Math.max(0, b - a) }, (_, k) => {
const i = a + k;
return (
<div key={i} className="absolute left-0 w-full" style={{ top: ph.offset(i) }}>
<PageImg url={formatPageUrl(fmt, i)} width={width} onLoaded={(h) => measure(i, h)} />
</div>
);
})}
{locked && (
<div
className="absolute left-0 grid w-full place-items-center"
style={{ top: ph.offset(w.end), height: vh }}
>
{w.end >= count ? (
<span className="text-sm text-stone-500">— 全书完 —</span>
) : (
<button className={btn} onClick={() => goPage(chapters![ci + 1].start)}>
本章完 · 下一章 →
</button>
)}
</div>
)}
{locked && w.mountEnd > w.end && (
// 预读层:挂载+解码但不参与滚动几何(height:0 + overflow:hidden 裁掉全部子元素)
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 h-0 overflow-hidden">
{Array.from({ length: w.mountEnd - w.end }, (_, k) => {
const i = w.end + k;
return (
<div key={i} className="absolute left-0 w-full" style={{ top: ph.offset(i) }}>
<PageImg url={formatPageUrl(fmt, i)} width={width} onLoaded={(h) => measure(i, h)} />
</div>
);
})}
</div>
)}
</div>
</div>
{chrome.on ? (
<div className="rd-sheet absolute inset-x-0 bottom-0 z-20 border-t border-stone-700/60 bg-stone-900/85 px-4 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 text-stone-200 backdrop-blur">
<div className="flex items-center gap-3">
<input
type="range"
min={0}
max={Math.max(0, count - 1)}
value={scrub ?? cur}
onChange={(e) => {
const v = Number(e.target.value);
setScrub(v);
goPage(v, false); // 拖拽中瞬移:平滑动画会被下一格打断,手感像卡顿
}}
onPointerUp={() => setScrub(null)}
onPointerCancel={() => setScrub(null)}
onBlur={() => setScrub(null)}
className="rd-range"
aria-label="页码跳转"
/>
<span className="w-20 shrink-0 text-right text-xs tabular-nums opacity-70">
{(scrub ?? cur) + 1}/{count} · {Math.round((((scrub ?? cur) + 1) / Math.max(1, count)) * 100)}%
</span>
</div>
<div className="mt-1 flex items-center gap-2 pb-1 text-sm">
<button
className={"rd-btn" + (continuous ? " rd-btn-on" : "")}
aria-pressed={continuous}
onClick={() => setContinuous(!continuous)}
>
连读{continuous ? "·开" : "·关"}
</button>
{locked && (
<button
className="rd-btn"
aria-label="预读:锁定章节末尾时提前挂载并解码其后 N 章"
onClick={() => setPrefetch((p) => (p + 1) % 4)}
>
预读 {prefetch} 章
</button>
)}
{bm.btn}
{chapters && (
<>
<button className="rd-btn" aria-expanded={toc} aria-controls="cbz-toc" onClick={() => setToc((v) => !v)}>
目录
</button>
<span className="text-xs tabular-nums opacity-60">
{ci + 1}/{chapters.length}
</span>
<button className="rd-btn" disabled={ci === 0} onClick={() => goPage(chapters[ci - 1].start)}>
← 上一章
</button>
<button
className="rd-btn"
disabled={ci === chapters.length - 1}
onClick={() => goPage(chapters[ci + 1].start)}
>
下一章 →
</button>
</>
)}
</div>
</div>
) : (
<div className="pointer-events-none absolute bottom-3 right-3 flex items-center gap-2 rounded-full bg-stone-900/85 px-3 py-1.5 text-xs tabular-nums text-stone-200 shadow-lg ring-1 ring-stone-700/60 backdrop-blur">
<span>
{cur + 1}/{count}
</span>
<span className="opacity-60">{Math.round(((cur + 1) / Math.max(1, count)) * 100)}%</span>
</div>
)}
{toc && chapters && (
<>
<div className="fixed inset-0 z-10" aria-hidden="true" onClick={() => setToc(false)} />
<nav
id="cbz-toc"
aria-label="章节目录"
className="rd-divider absolute inset-y-0 left-0 z-20 w-64 overflow-y-auto border-r bg-[var(--rd-bg)] p-2 shadow-2xl"
>
<ul className="space-y-0.5">
{chapters.map((c, i) => (
<li key={i}>
<button
ref={i === ci ? activeRow : undefined}
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
onClick={() => {
goPage(c.start);
setToc(false);
}}
>
{i + 1}. {c.title}
</button>
</li>
))}
</ul>
</nav>
</>
)}
{bm.panel}
</div>
);
}
```
- [ ] **Step 2: 静态检查 + 单测全绿**
Run: `docker exec -w /app booklib_web_1 npm run -s check`
Expected: tsc 无错误、vitest 全绿、vite build 成功。若 tsc 报 `chapters!` 收窄或 unused import,就地修正后重跑。
- [ ] **Step 3: 追加 changelog**(`docs/CHANGELOG_web.md`,插入到 `### Added / 新增` 之后的**最上方**,与下一条之间留一个空行;英中文案见 spec《文档 / Docs》节,内容如下)
```markdown
- CBZ reader is now chapter-scoped: a chapter's images flow as one continuous strip and scrolling stops at an 本章完 · 下一章 card (only chapter buttons / TOC / slider / bookmarks cross the boundary); a 连读 toggle makes scrolling flow across chapters, and a configurable 0–3-chapter prefetch pre-mounts upcoming chapters for instant switching — the old 连读/整页/适高 mode cycle is gone.
- CBZ 阅读器改为以章为单位:一章图片是一段连续长卷,滚动止于「本章完 · 下一章」卡片(跨章只能靠章节按钮/目录/滑条/书签);「连读」开关让滑动贯穿章节,预读 0–3 章可配置、提前挂载解码实现切章零白屏;移除原 连读/整页/适高 三档循环。
```
- [ ] **Step 4: Commit**
```bash
git add frontend/src/readers/CbzReader.tsx docs/CHANGELOG_web.md
git commit -m "feat(cbz): chapter-scoped continuous reading — lock scroll at chapter end, screen paging, prefetch next N chapters; drop 连读/整页/适高 modes"
```
---
### Task 3: 浏览器验收(真实运行环境点验)
**Files:**
- 无新文件;发现问题就地回改 Task 1/2 的文件并补 commit。
**Interfaces:**
- Consumes: Task 2 产物;dev 栈(`deploy/docker-compose.dev.yml`,web 热更新无需重建)。
- [ ] **Step 1: 确认 dev 栈在跑**(`docker compose -f deploy/docker-compose.dev.yml ps`;未跑则 `up -d`),打开 `http://localhost:5173`(或 compose 映射端口),登录。
- [ ] **Step 2: 分章包锁章验收**——打开一本按目录分章的漫画:向下滚到本章底部出现「本章完 · 下一章」卡片且继续滚不进入下一章;点卡片/下一章秒开无白屏(预读 2 章生效);「预读」按钮循环 0–3 且刷新后记忆。
- [ ] **Step 3: 连读验收**——点「连读」变「连读·开」:滚动可直接滑过章界进下章,目录计数随滚动走;刷新页面开关状态保持。
- [ ] **Step 4: 定位类入口验收**——拖滑条到别章页 = 跳章成功;书签 seek 跳他章;重进书恢复到他章原位;点按左右缘/←→ 翻一屏(非翻一张图)。
- [ ] **Step 5: 扁平包回归**——无目录的漫画:工具栏无连读/预读按钮、正常滚动到底只有全书末尾(无章末卡片);图片加载失败重试可用。
- [ ] **Step 6: 移动端手感抽查**——窄屏(DevTools 手机模拟)滚到章底回弹正常、无逐帧跳动;console 无 React 报错。
- [ ] **Step 7: 全量回归收尾**——`docker exec -w /app booklib_web_1 npm run -s check` 绿;有修复则 `git add -A && git commit -m "fix(cbz): <具体现象>"`。
---
## Self-Review 记录
1. **Spec 覆盖**:模式移除✓(Task 2 删除三档)、锁章滚动边界✓(contentH=章末+卡片)、翻屏✓(turnScreen)、章末卡片✓、滑条/书签/目录跨章=显式跳章✓(goPage 统一入口)、预读隐藏层✓(mountEnd + overflow:hidden 层)、步进器/持久化✓、扁平包✓(chapterWin 全书 + 按钮隐藏)、恢复进度✓(goPage 路径)、量高学习保留✓(measure)、changelog✓。
2. **占位符**:无 TBD/similar-to;测试与实现代码完整。
3. **类型一致**:Task 1 导出 `chapterIndexAt(chs,page)` / `chapterWin(chs,count,ci,continuous,prefetch)` 与 Task 2 import 一致;`ChapterWindow` 字段名与 Task 2 使用一致。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,73 @@
# CBZ 章节化连续阅读(锁章 + 预读)设计 / CBZ chapter-scoped continuous reading design
日期 2026-09-08。分支 `feat/ui-redesign`。状态:已获用户批准(会话内确认)。
## 目标 / Goal
漫画(CBZ)阅读以「章」为单位:一章内无论多少图都视为一段连续的整章内容,
默认不能靠上下滑动越过章界进入下一章(只能点「下一章」),可开「连读」让滑动贯穿章节。
去掉现有 连读/整页/适高 三档概念。锁章时预渲染(挂载+解码)其后 N 章(用户可配置,默认 2),
切章零白屏。
CBZ reading is chapter-scoped: images within a chapter flow continuously; by default scrolling cannot cross a chapter boundary (only the 下一章 button can); an optional 连读 mode lets scrolling flow into following chapters. The three-mode cycle (连读/整页/适高) is removed. The next N chapters (user-configurable, default 2) are pre-rendered while locked so chapter switches are instant.
## 决策记录 / Decisions
- 章节内显示 = 按容器宽度(≤720px)连续滚动、无 snap、无适高一屏一页。用户选定(方案 A)。
- 翻页手势/按键 = 章内翻一屏(`scrollBy ±vh`),不再按图跳页。用户选定。
- 采用「按章节作用域渲染」(方案 A),否决:全书渲染+滚动钳制(手感生硬、浪费加载)、
一章一挂载(书签/进度/量高学习跨章迁移复杂、切章闪烁)。
- 预读实现为隐藏层挂载解码(不增加可滚动高度),不违反锁章语义。用户要求「可预渲染下两章、可配置」。
- 进度滑条/页码/书签/服务端进度均仍为全书页号,locator 形状 `{page}` 不变,无后端/API 改动。
- 扁平包(chapters 为 null)= 整本一章:章按钮与连读开关隐藏,锁章/预读无意义,行为即纯连续滚动。
## 前端 / Frontend(全部改动在 `frontend/src`,纯前端)
### 状态与持久化
- 删除 `PageMode`/`MODES`/`MODE_LABEL`/`useStoredMode` 及 `cbz-mode` 键;PageImg 去掉 `fitH` 分支;
滚动盒去掉 `snap-y snap-proximity`。
- `localStorage("cbz-continuous")`:连读开关,默认关(锁章)。
- `localStorage("cbz-prefetch")`:预读章数 0–3,默认 2。工具栏步进器按钮循环 0→1→2→3→0。
### 区间几何
- 当前章 `ci` 由滚动位置(现 `cur` 所在页 → 章)推出,章起点 `chapters[ci].start`。
- `PageHeights` 仍覆盖全书 `count` 页(复用现有量高/估高/`applyShift` 补偿逻辑)。
- 挂载上限 `mountEnd`:锁章 = 第 `ci+1+prefetch` 章起点(越界取 count);连读 = count。
- 滚动终点 `scrollEnd`:锁章 = `ph.offset(章末) + 章末卡片高`;连读 = 全书末尾。
- 超出 `scrollEnd` 的预读页渲染进一个 `height:0; overflow:hidden` 的绝对定位隐藏层
(子页仍按 `ph.offset(i)` 定位、`aria-hidden`):blob 经 `fetchObjectUrl` 缓存、`<img>` 挂载即解码,
但不参与滚动几何。切章后这些页落入可见区间即直接上屏。
- 纯计算抽为 `lib/virt.ts` 导出 helper(如 `chapterRange(chapters, count, ci, continuous, prefetch)`
→ `{mountStart, mountEnd, scrollEndPage}`),vitest 覆盖。
### 交互
- 点按左右侧区/←→/PageUp·Down:`scrollBy(±vh, smooth)`;锁章时滚动高度天然停在章末卡片。
- 点中间仍唤出工具栏(不变)。
- 章末卡片:锁章时本章末尾一屏「本章完 · 下一章 →」(最终章显示「全书完」),点卡片=切章。
- 切章入口(上一章/下一章按钮、目录行、书签 seek、章末卡片、滑条拖入他章区间):
设 `ci` 并 `jump(page)`;滑条跨章视为显式定位,不算「滑动越界」。
- 恢复进度:由 `initialLocator.page` 反推所在章后定位。
- 闲时「停滚 250ms 预取后 5 页」保留。
## 错误处理 / Errors
- 无新增失败面:页数接口、鉴权取图、失败重试(「本页加载失败」)均沿用。
- `cbz-prefetch` 值非法(手改 localStorage)→ 回退默认 2;`cbz-continuous` 非 `"1"` → 关。
## 测试 / Tests
- `lib/virt.ts` helper 表驱动单测:锁/连读 × 预读 0–3 × 首章/末章/单章扁平包 × 边界页;
断言 mountEnd/scrollEnd 不越过滚动锁、预读不超过 count。
- 现有 `virt` 用例保持绿;`npm run check` 绿。
- 真实浏览器点验:锁章滚不到下章、开连读可滑过、预读下切章无白屏、扁平包无章 UI。
## 文档 / Docs
- `docs/CHANGELOG_web.md` 新增条目(英中各一行),不重复进 `docs/CHANGELOG.md`。
## 不做 / Non-goals
- 后端章节结构改动、章内页号重映射、PDF/EPUB/TXT 阅读器改动、横翻 RTL 模式、
长按拖拽越章「窥视」下一章。
@@ -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 改动、收藏/标签等新功能。
+1 -1
View File
@@ -1,6 +1,6 @@
FROM node:22 AS dev
WORKDIR /app
COPY frontend/package.json web/package-lock.json ./
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
+1 -1
View File
@@ -1,6 +1,6 @@
FROM node:22-alpine AS build
WORKDIR /src
COPY frontend/package.json web/package-lock.json ./
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": { "config": "", "css": "src/index.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" },
"iconLibrary": "lucide",
"aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }
}
+10 -1
View File
@@ -3,8 +3,17 @@
<head>
<meta charset="UTF-8" />
<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" />
<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>
</head>
<body>
+1815 -115
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -17,13 +17,19 @@
"private": "true",
"dependencies": {
"@tanstack/react-query": "^5.102.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.4.15",
"epubjs": "^0.3.93",
"lucide-react": "^1.42.0",
"marked": "^18.0.11",
"pdfjs-dist": "^6.3.289",
"radix-ui": "^1.6.7",
"react": "^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": {
"@tailwindcss/vite": "^4.3.3",
+10 -23
View File
@@ -1,7 +1,9 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider, RequireAdmin, RequireAuth } from "./auth/AuthContext";
import { AppShell } from "./components/app-shell";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { Toaster } from "./components/Toaster";
import { ThemeProvider } from "./components/theme";
import Login from "./pages/Login";
import Reader from "./pages/Reader";
import Shelf from "./pages/Shelf";
@@ -11,17 +13,21 @@ import AdminUsers from "./pages/admin/Users";
export default function App() {
return (
<ErrorBoundary>
<ThemeProvider>
<AuthProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<RequireAuth>
<Shelf />
<AppShell />
</RequireAuth>
}
/>
>
<Route path="/" element={<Shelf />} />
<Route path="/admin/users" element={<RequireAdmin><AdminUsers /></RequireAdmin>} />
<Route path="/admin/libraries" element={<RequireAdmin><AdminLibraries /></RequireAdmin>} />
</Route>
<Route
path="/book/:id"
element={
@@ -30,30 +36,11 @@ export default function App() {
</RequireAuth>
}
/>
<Route
path="/admin/users"
element={
<RequireAuth>
<RequireAdmin>
<AdminUsers />
</RequireAdmin>
</RequireAuth>
}
/>
<Route
path="/admin/libraries"
element={
<RequireAuth>
<RequireAdmin>
<AdminLibraries />
</RequireAdmin>
</RequireAuth>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Toaster />
</AuthProvider>
</ThemeProvider>
</ErrorBoundary>
);
}
+27 -40
View File
@@ -1,19 +1,23 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
import { Check, Pencil, Plus, X } from "lucide-react";
import { useState } from "react";
import { api } from "../api/client";
import type { Book } from "../api/types";
import { toast } from "./Toaster";
type Locator = Record<string, unknown>;
export function useBookmarks(opts: {
/** 书签面板内容(供 ReaderNav 的「书签」选项卡承载);跳转后由调用方负责关抽屉. */
export function BookmarksTab({
book,
capture,
seek,
}: {
book: Book;
capture: () => { locator: Locator; percent: number };
seek: (locator: Locator) => void;
}): { btn: ReactNode; panel: ReactNode } {
const { book, capture, seek } = opts;
}) {
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [note, setNote] = useState("");
const [editId, setEditId] = useState<number | null>(null);
const [editVal, setEditVal] = useState("");
@@ -48,25 +52,8 @@ export function useBookmarks(opts: {
onError: fail,
});
const btn = (
<button
className="rd-btn"
aria-expanded={open}
aria-controls="bm-panel"
onClick={() => setOpen((v) => !v)}
>
书签{items.length ? ` ${items.length}` : ""}
</button>
);
const panel = open ? (
return (
<>
<div className="fixed inset-0 z-30" aria-hidden="true" onClick={() => setOpen(false)} />
<nav
id="bm-panel"
aria-label="书签"
className="rd-divider absolute inset-y-0 right-0 z-40 flex w-72 flex-col border-l bg-[var(--rd-bg)] p-2 shadow-2xl"
>
<div className="mb-2 flex items-center gap-1.5">
<input
className="min-w-0 flex-1 rounded-md border border-stone-700 bg-stone-900 px-2 py-1 text-sm text-stone-100 placeholder:text-stone-500"
@@ -78,11 +65,16 @@ export function useBookmarks(opts: {
if (e.key === "Enter" && !add.isPending) add.mutate();
}}
/>
<button className="rd-btn shrink-0" disabled={add.isPending} onClick={() => add.mutate()}>
加
<button
className="rd-btn inline-flex shrink-0 items-center gap-1"
aria-label="加书签"
disabled={add.isPending}
onClick={() => add.mutate()}
>
<Plus size={14} /> 加
</button>
</div>
<ul className="min-h-0 flex-1 space-y-0.5 overflow-y-auto">
<ul className="space-y-0.5">
{q.isPending && <li className="p-2 text-xs text-stone-500">加载中…</li>}
{!q.isPending && items.length === 0 && (
<li className="p-2 text-xs text-stone-500">还没有书签,读到想记的位置点「加」</li>
@@ -101,14 +93,15 @@ export function useBookmarks(opts: {
}}
/>
<button
className="rd-btn shrink-0"
className="rd-btn inline-flex shrink-0 items-center"
aria-label="保存备注"
disabled={patch.isPending}
onClick={() => patch.mutate({ id: b.id, note: editVal.trim() })}
>
✓
<Check size={14} />
</button>
<button className="rd-btn shrink-0" onClick={() => setEditId(null)}>
✕
<button className="rd-btn inline-flex shrink-0 items-center" aria-label="取消编辑" onClick={() => setEditId(null)}>
<X size={14} />
</button>
</div>
) : (
@@ -116,10 +109,7 @@ export function useBookmarks(opts: {
<button
className="rd-row min-w-0 flex-1 truncate text-left"
title={b.note || undefined}
onClick={() => {
seek(b.locator);
setOpen(false);
}}
onClick={() => seek(b.locator)}
>
<span className="tabular-nums opacity-70">{Math.round(b.percent * 100)}%</span>{" "}
{b.note || <span className="opacity-50">{b.created_at.slice(0, 10)}</span>}
@@ -132,23 +122,20 @@ export function useBookmarks(opts: {
setEditVal(b.note);
}}
>
✎
<Pencil size={14} />
</button>
<button
className="rd-btn m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
aria-label="删除书签"
onClick={() => del.mutate(b.id)}
>
✕
<X size={14} />
</button>
</div>
)}
</li>
))}
</ul>
</nav>
</>
) : null;
return { btn, panel };
);
}
+23 -7
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react";
import type { Book } from "../api/types";
import { fetchObjectUrl } from "../lib/authImage";
import { btn } from "./ui";
import { btn, formatBadge } from "./ui";
import { TriangleAlert } from "lucide-react";
export function useAuthedImage(url: string | undefined): { src: string; failed: boolean; retry: () => void } {
const [state, setState] = useState<{ key: string; src: string; failed: boolean }>({ key: "", src: "", failed: false });
@@ -27,14 +28,29 @@ export function useAuthedImage(url: string | undefined): { src: string; failed:
export function Cover({ book, className }: { book: Book; className?: string }) {
const { src, failed, retry } = useAuthedImage(book.cover_url);
if (failed)
if (!book.cover_url || failed) {
return (
<div className={`grid place-items-center bg-stone-900 ${className ?? ""}`}>
<button className={btn} onClick={retry} aria-label="封面加载失败,重试">
重试
<div
className={`relative grid place-items-center overflow-hidden bg-muted ${className ?? ""}`}
>
<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>
{failed && (
<button className={btn + " absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"} onClick={retry}>
<TriangleAlert className="size-4" />
封面重试
</button>
)}
</div>
);
if (!src) return <div className={`animate-pulse bg-stone-800 ${className ?? ""}`} />;
return <img src={src} alt="" loading="lazy" className={`bg-stone-900 object-cover ${className ?? ""}`} />;
}
if (!src) return <div className={`animate-pulse bg-muted ${className ?? ""}`} role="presentation" />;
return (
<img
src={src}
alt={`《${book.title}》封面`}
loading="lazy"
className={`bg-muted object-cover ${className ?? ""}`}
/>
);
}
+4 -2
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, type ReactNode } from "react";
import { CheckCircle2, XCircle } from "lucide-react";
type Toast = { id: number; kind: "ok" | "err"; text: ReactNode };
@@ -31,10 +32,11 @@ export function Toaster() {
<div
key={t.id}
role="status"
className={`max-w-sm rounded-lg px-4 py-2 text-sm shadow-lg ring-1 ring-white/10 ${
t.kind === "ok" ? "bg-emerald-900 text-emerald-100" : "bg-red-900 text-red-100"
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" ? "border-border" : "border-destructive/40"
}`}
>
{t.kind === "ok" ? <CheckCircle2 className="size-4 shrink-0 text-accent" /> : <XCircle className="size-4 shrink-0 text-destructive" />}
{t.text}
</div>
))}
-41
View File
@@ -1,41 +0,0 @@
import type { ReactNode } from "react";
import { Link } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { btn } from "./ui";
export function TopBar({ right }: { right?: ReactNode }) {
const { user, isAdmin, logout } = useAuth();
return (
<header className="flex shrink-0 items-center gap-2 border-b border-stone-800/80 px-4 py-3">
<Link to="/" className="font-serif text-lg font-semibold tracking-wide">
Book<span className="text-amber-500">Lib</span>
</Link>
{isAdmin && (
<Link
className="rounded-md px-2 py-1 text-sm text-stone-400 transition-colors hover:bg-stone-800 hover:text-stone-100"
to="/admin/users"
>
用户
</Link>
)}
{isAdmin && (
<Link
className="rounded-md px-2 py-1 text-sm text-stone-400 transition-colors hover:bg-stone-800 hover:text-stone-100"
to="/admin/libraries"
>
库管理
</Link>
)}
<div className="ml-auto flex items-center gap-3">
{right}
<span className="text-sm text-stone-400">
{user?.username}
{isAdmin ? "(admin)" : ""}
</span>
<button className={btn} onClick={logout}>
退出
</button>
</div>
</header>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { useQuery } from "@tanstack/react-query";
import { BookMarked, BookOpen, FolderCog, LogOut, Users } from "lucide-react";
import { Link, 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() {
return (
<Link to="/" aria-label="BookLib 首页" className="flex h-12 shrink-0 items-center gap-2 rounded-md px-3 focus-visible:outline-2 focus-visible:outline-offset-2">
<span className="grid size-6 place-items-center rounded bg-accent text-accent-foreground">
<BookMarked className="size-4" />
</span>
<span className="hidden text-[15px] font-semibold tracking-tight lg:inline">BookLib</span>
</Link>
);
}
/* NavLink 不可用:其 isActive/aria-current 前缀匹配 "/" 会无视 search,导致 ?lib=N 全部同时高亮 */
function NavItem({ to, icon, label, active }: { to: string; icon: ReactNode; label: string; active: boolean }) {
return (
<Link
to={to}
aria-label={label}
aria-current={active ? "page" : undefined}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground",
active && "bg-secondary font-medium text-foreground",
)}
>
{icon}
<span className="hidden lg:inline">{label}</span>
</Link>
);
}
export function AppShell() {
const { user, isAdmin, logout } = useAuth();
const loc = useLocation();
const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries });
const libs = libsQ.data ?? [];
const libParam = new URLSearchParams(loc.search).get("lib");
const account = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="gap-2 px-2" aria-label={`账户 ${user?.username ?? ""}`}>
<span className="grid size-7 place-items-center rounded-full bg-accent/15 text-xs font-semibold text-accent-strong">
{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>
{isAdmin && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>系统管理</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link to="/admin/libraries">
<FolderCog /> 库管理
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/admin/users">
<Users /> 用户管理
</Link>
</DropdownMenuItem>
</>
)}
<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-background/85 p-2 backdrop-blur md:flex lg:w-60">
<Brand />
<nav aria-label="主导航" className="flex flex-col gap-1">
<NavItem to="/" icon={<BookOpen className="size-4 shrink-0" />} label="书架" active={loc.pathname === "/" && !libParam} />
</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">
{libs.map((l) => (
<NavItem key={l.id} to={`/?lib=${l.id}`} icon={<span className="size-1.5 shrink-0 rounded-full bg-accent/60" />} label={l.name} active={loc.pathname === "/" && libParam === String(l.id)} />
))}
</div>
</div>
)}
<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-background/85 px-3 backdrop-blur 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-background/85 backdrop-blur md:hidden">
<TabItem to="/" icon={<BookOpen className="size-5" />} label="书架" />
</nav>
</div>
</div>
);
}
function TabItem({ to, icon, label }: { to: string; icon: ReactNode; label: string }) {
return (
<NavLink to={to} end={to === "/"} className={({ isActive }) => cn("flex flex-col items-center justify-center gap-0.5 text-[11px] text-muted-foreground", isActive && "text-accent-strong")}>
{icon}
{label}
</NavLink>
);
}
+15
View File
@@ -0,0 +1,15 @@
import type { SVGProps } from "react";
function Svg({ children, ...p }: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...p}>
{children}
</svg>
);
}
export const IconArrowLeft = (p: SVGProps<SVGSVGElement>) => (
<Svg {...p}>
<path d="M20 12H4m0 0 6-6m-6 6 6 6" />
</Svg>
);
+37
View File
@@ -0,0 +1,37 @@
/** 阅读器主题内的滑条配置项:说明 + 整数档位滑条 + 锚点刻度. */
export function RdSlider({
label,
ariaLabel,
min,
max,
value,
onChange,
}: {
label: string;
ariaLabel: string;
min: number;
max: number;
value: number;
onChange: (v: number) => void;
}) {
return (
<div className="px-2.5 pb-1.5 pt-2">
<div className="mb-1 text-sm">{label}</div>
<input
type="range"
min={min}
max={max}
step={1}
value={value}
aria-label={ariaLabel}
className="rd-range"
onChange={(e) => onChange(Number(e.target.value))}
/>
<div className="flex justify-between px-1 text-[10px] tabular-nums opacity-50" aria-hidden="true">
{Array.from({ length: max - min + 1 }, (_, i) => (
<span key={i}>{min + i}</span>
))}
</div>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { Bookmark, List, X } from "lucide-react";
import type { ReactNode } from "react";
export type NavTab = "toc" | "bm";
/** 阅读器左侧导航抽屉:目录/书签选项卡;不传 toc 则退化为纯书签面板. */
export function ReaderNav({
tab,
onTab,
onClose,
toc,
bookmarks,
}: {
tab: NavTab;
onTab?: (t: NavTab) => void;
onClose: () => void;
toc?: ReactNode;
bookmarks: ReactNode;
}) {
return (
<>
<div className="fixed inset-0 z-30" aria-hidden="true" onClick={onClose} />
<nav
id="reader-nav"
aria-label="导航"
className="rd-divider absolute inset-y-0 left-0 z-40 flex w-72 flex-col border-r bg-[var(--rd-bg,#171512)] shadow-2xl"
style={{ color: "var(--rd-fg,#c6c0b6)" }}
>
<div
role={toc != null ? "tablist" : undefined}
aria-label="导航选项卡"
className="rd-divider flex shrink-0 items-center gap-1 border-b p-2"
>
{toc != null ? (
<>
<button
role="tab"
aria-selected={tab === "toc"}
className={"rd-btn inline-flex items-center gap-1.5" + (tab === "toc" ? " rd-btn-on" : "")}
onClick={() => onTab?.("toc")}
>
<List size={14} /> 目录
</button>
<button
role="tab"
aria-selected={tab === "bm"}
className={"rd-btn inline-flex items-center gap-1.5" + (tab === "bm" ? " rd-btn-on" : "")}
onClick={() => onTab?.("bm")}
>
<Bookmark size={14} /> 书签
</button>
</>
) : (
<span className="rd-btn rd-btn-on inline-flex items-center gap-1.5">
<Bookmark size={14} /> 书签
</span>
)}
<button className="rd-btn ml-auto" aria-label="关闭导航" onClick={onClose}>
<X size={14} />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">{tab === "toc" ? toc : bookmarks}</div>
</nav>
</>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { Check, Monitor, Moon, Sun } from "lucide-react";
import { useTheme, type Theme } from "./theme";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
const ITEMS: { v: Theme; label: string; icon: typeof Sun }[] = [
{ v: "light", label: "浅色", icon: Sun },
{ v: "dark", label: "深色", icon: Moon },
{ v: "system", label: "跟随系统", icon: Monitor },
];
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="切换主题">
{theme === "dark" ? <Moon className="size-4" /> : theme === "light" ? <Sun className="size-4" /> : <Monitor className="size-4" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{ITEMS.map(({ v, label, icon: Icon }) => (
<DropdownMenuItem key={v} onSelect={() => setTheme(v)}>
<Icon />
{label}
{theme === v && <Check className="ml-auto size-4" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
export type Theme = "system" | "light" | "dark";
const KEY = "ui.theme";
const SYS = "(prefers-color-scheme: dark)";
function read(): Theme {
try {
const v = localStorage.getItem(KEY);
return v === "light" || v === "dark" || v === "system" ? v : "system";
} catch {
return "system";
}
}
function apply(t: Theme) {
const dark = t === "dark" || (t === "system" && matchMedia(SYS).matches);
document.documentElement.classList.toggle("dark", dark);
}
const Ctx = createContext<{ theme: Theme; setTheme: (t: Theme) => void } | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, set] = useState<Theme>(read);
useEffect(() => {
apply(theme);
try {
localStorage.setItem(KEY, theme);
} catch {
/* 隐私模式:仅本次会话生效 */
}
if (theme !== "system") return;
const mq = matchMedia(SYS);
const on = () => apply("system");
mq.addEventListener("change", on);
return () => mq.removeEventListener("change", on);
}, [theme]);
const setTheme = useCallback((t: Theme) => set(t), []);
const value = useMemo(() => ({ theme, setTheme }), [theme, setTheme]);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useTheme() {
const v = useContext(Ctx);
if (!v) throw new Error("useTheme outside ThemeProvider");
return v;
}
+18 -6
View File
@@ -1,7 +1,19 @@
import type { Format } from "../api/types";
export const btn =
"rounded-lg bg-stone-800 px-3 py-1.5 text-sm transition-colors hover:bg-stone-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 active:bg-stone-600 disabled:cursor-not-allowed disabled:opacity-50";
export const btnPrimary =
"rounded-lg bg-amber-600 px-3 py-1.5 text-sm text-stone-950 transition-colors hover:bg-amber-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 active:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-50";
export const input =
"rounded-lg border border-stone-700 bg-stone-900 px-3 py-1.5 text-sm outline-none transition-colors placeholder:text-stone-500 focus:border-amber-500/70";
export const card = "rounded-xl border border-stone-800 bg-stone-900";
"inline-flex items-center gap-1.5 rounded-lg bg-white/[0.06] px-3 py-1.5 text-sm ring-1 ring-white/10 transition-colors hover:bg-white/[0.11] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 active:bg-white/[0.15] disabled:cursor-not-allowed disabled:opacity-50";
export const btnGhost =
"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 FORMAT_BADGE: Record<Format, string> = {
cbz: "bg-orange-500/15 text-orange-300 ring-orange-500/25",
pdf: "bg-rose-500/15 text-rose-300 ring-rose-500/25",
epub: "bg-sky-500/15 text-sky-300 ring-sky-500/25",
txt: "bg-emerald-500/15 text-emerald-300 ring-emerald-500/25",
md: "bg-teal-500/15 text-teal-300 ring-teal-500/25",
};
export function formatBadge(fmt: Format, extra = ""): string {
return `rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ring-1 ${FORMAT_BADGE[fmt]} ${extra}`;
}
+196
View File
@@ -0,0 +1,196 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
+47
View File
@@ -0,0 +1,47 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Slot } from "radix-ui"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+63
View File
@@ -0,0 +1,63 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Slot } from "radix-ui"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+91
View File
@@ -0,0 +1,91 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+156
View File
@@ -0,0 +1,156 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
@@ -0,0 +1,256 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Label as LabelPrimitive } from "radix-ui"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+187
View File
@@ -0,0 +1,187 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+27
View File
@@ -0,0 +1,27 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Separator as SeparatorPrimitive } from "radix-ui"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-accent", className)}
{...props}
/>
)
}
export { Skeleton }
+115
View File
@@ -0,0 +1,115 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+54
View File
@@ -0,0 +1,54 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Tooltip as TooltipPrimitive } from "radix-ui"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+59
View File
@@ -0,0 +1,59 @@
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { useRef, useState, type ComponentProps } from "react";
import { Upload } from "lucide-react";
import { api } from "@/api/client";
import { Button } from "@/components/ui/button";
import { toast } from "./Toaster";
export async function uploadToLib(qc: QueryClient, libId: number, list: FileList | File[]): Promise<boolean> {
const files = Array.from(list); // FileList 活引用,先快照
if (!files.length) return false;
try {
for (const f of files) await api.uploadBook(libId, f);
toast("ok", `已上传 ${files.length} 个文件,等待 scanner 收编`);
void qc.invalidateQueries({ queryKey: ["books"] });
setTimeout(() => void qc.invalidateQueries({ queryKey: ["books"] }), 5000);
return true;
} catch (e) {
toast("err", e instanceof Error ? e.message : "上传失败");
return false;
}
}
/** 上传按钮(隐藏 file input),白名单:cbz/zip/pdf/epub/txt/md. */
export function UploadButton({
libId,
variant,
size = "sm",
label = "上传",
}: {
libId: number;
variant?: ComponentProps<typeof Button>["variant"];
size?: ComponentProps<typeof Button>["size"];
label?: string;
}) {
const qc = useQueryClient();
const ref = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
return (
<>
<input
ref={ref}
type="file"
multiple
accept=".cbz,.zip,.pdf,.epub,.txt,.md"
className="hidden"
onChange={async (e) => {
const files = Array.from(e.target.files ?? []);
e.target.value = "";
setBusy(true);
await uploadToLib(qc, libId, files);
setBusy(false);
}}
/>
<Button variant={variant} size={size} disabled={busy} onClick={() => ref.current?.click()}>
<Upload /> {busy ? "上传中…" : label}
</Button>
</>
);
}
+96 -3
View File
@@ -1,4 +1,80 @@
@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-strong: oklch(0.55 0.17 46);
--accent-foreground: oklch(0.985 0.002 80);
--destructive: oklch(0.58 0.21 27);
--destructive-foreground: oklch(0.98 0.002 80);
--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-strong: oklch(0.72 0.16 46);
--accent-foreground: oklch(0.19 0.006 80);
--destructive: oklch(0.65 0.19 25);
--destructive-foreground: oklch(0.19 0.006 80);
--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-strong: var(--accent-strong);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
}
html,
body,
@@ -6,19 +82,32 @@ body,
height: 100%;
}
body {
@apply bg-stone-950 text-stone-100 antialiased;
@apply bg-background text-foreground antialiased;
color-scheme: light;
}
.dark body {
color-scheme: dark;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
@apply rounded-full border-2 border-transparent bg-clip-padding bg-stone-700/70;
@apply rounded-full border-2 border-transparent bg-clip-padding;
background-color: color-mix(in oklab, var(--foreground) 22%, transparent);
}
::-webkit-scrollbar-thumb:hover {
@apply bg-stone-600;
background-color: color-mix(in oklab, var(--foreground) 34%, transparent);
}
/* 入场:卡片/区块轻微上浮淡入(列表 stagger 用 style.animationDelay) */
@keyframes fx-rise {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
.fx-rise { animation: fx-rise 0.35s cubic-bezier(0.22, 1, 0.36, 1) both; }
/* 阅读页主题:纸/米/夜,文本类阅读器共用 —— 见 lib/readerPrefs.ts */
[data-rd="paper"] { --rd-bg: #faf8f4; --rd-fg: #292524; --rd-link: #9a3412; }
[data-rd="sepia"] { --rd-bg: #f0e6d2; --rd-fg: #433a29; --rd-link: #92400e; }
@@ -42,6 +131,9 @@ body {
.rd-row { @apply block w-full truncate rounded-md px-2.5 py-1.5 text-left text-sm transition-colors; }
.rd-row:hover { background: color-mix(in oklab, currentColor 12%, transparent); }
.rd-btn-on { @apply font-semibold; background: color-mix(in oklab, currentColor 20%, transparent); }
/* Radix 菜单项高亮(指针/键盘) */
.rd-highlight { outline: none; }
.rd-highlight[data-highlighted] { background: color-mix(in oklab, currentColor 16%, transparent); }
.rd-divider { border-color: color-mix(in oklab, currentColor 14%, transparent); }
/* 进度/页码滑条:主题色轨道 + 强调色拇指,触摸友好 */
@@ -57,6 +149,7 @@ body {
@media (prefers-reduced-motion: reduce) {
.rd-sheet { animation: none; }
.rd-rise-anim { transition: none !important; }
.fx-rise { animation: none; }
}
/* markdown 正文(仅 .md-body 作用域) */
+8
View File
@@ -0,0 +1,8 @@
export function formatSize(bytes: number): string {
if (bytes <= 0) return "";
const units = ["B", "KB", "MB", "GB"];
let i = 0;
let n = bytes;
for (; n >= 1024 && i < units.length - 1; i++) n /= 1024;
return `${n >= 10 || i === 0 ? Math.round(n) : n.toFixed(1)} ${units[i]}`;
}
+19
View File
@@ -0,0 +1,19 @@
import type { Book } from "../api/types";
import { groupByDir } from "./group";
export type SortKey = "added" | "title" | "progress";
export const SORT_LABEL: Record<SortKey, string> = { added: "最近入库", title: "标题", progress: "阅读进度" };
export type GroupMode = "flat" | "dir";
export function sortBooks(books: Book[], key: SortKey): Book[] {
const a = [...books];
if (key === "title") return a.sort((x, y) => x.title.localeCompare(y.title, "zh"));
if (key === "progress") return a.sort((x, y) => y.percent - x.percent || (x.added_at < y.added_at ? 1 : x.added_at > y.added_at ? -1 : 0));
return a.sort((x, y) => (x.added_at < y.added_at ? 1 : x.added_at > y.added_at ? -1 : 0));
}
/** 页面分组视图:flat 一段无标题;dir 沿用「根目录置顶、目录名 locale 排序」。 */
export function sections(books: Book[], key: SortKey, mode: GroupMode): { label: string; books: Book[] }[] {
if (mode === "flat") return [{ label: "", books: sortBooks(books, key) }];
return groupByDir(sortBooks(books, key)).map((g) => ({ label: g.dir, books: g.books }));
}
+8
View File
@@ -0,0 +1,8 @@
import type { GroupMode, SortKey } from "./shelf";
export const SORTS: SortKey[] = ["added", "title", "progress"];
export const GROUPS: { v: GroupMode; label: string }[] = [
{ v: "flat", label: "平铺" },
{ v: "dir", label: "按目录" },
];
export const LIB0 = 0;
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+30
View File
@@ -76,3 +76,33 @@ export class PageHeights {
return [a, b];
}
}
/** 页号 → 所在章下标;无章(扁平包或单组)恒 0;首章 start 之前的散页归首章。 */
export function chapterIndexAt(chs: { start: number }[] | null, page: number): number {
if (!chs || chs.length < 2) return 0;
return chs.reduce((acc, c, i) => (c.start <= page ? i : acc), 0);
}
export interface ChapterWindow {
start: number; // 窗口首页(含)——滚动/挂载下界
end: number; // 窗口末页(不含)——锁章时的滚动边界
mountEnd: number; // 挂载上界(含预读章),恒 ≤ count 且 ≥ end
}
/** 阅读窗口:连读或扁平包 = 全书;锁章 = 本章滚动、本章 + prefetch 章挂载。 */
export function chapterWin(
chs: { start: number }[] | null,
count: number,
ci: number,
continuous: boolean,
prefetch: number,
): ChapterWindow {
if (!chs || chs.length < 2 || continuous) return { start: 0, end: count, mountEnd: count };
const c = Math.max(0, Math.min(ci, chs.length - 1));
const end = chs[c + 1]?.start ?? count;
return {
start: c === 0 ? 0 : chs[c].start,
end,
mountEnd: chs[c + 1 + Math.max(0, prefetch)]?.start ?? count,
};
}
+34 -12
View File
@@ -1,8 +1,13 @@
import { useState, type FormEvent } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { toast } from "../components/Toaster";
import { btnPrimary, card, input } from "../components/ui";
import { BookMarked } from "lucide-react";
import { useAuth } from "@/auth/AuthContext";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ThemeToggle } from "@/components/theme-toggle";
import { toast } from "@/components/Toaster";
export default function Login() {
const { login, user } = useAuth();
@@ -26,16 +31,33 @@ export default function Login() {
}
return (
<div className="grid h-full place-items-center p-4">
<form onSubmit={submit} className={`${card} w-full max-w-sm space-y-4 p-6`}>
<h1 className="text-lg font-semibold">BookLib 登录</h1>
<input className={input + " w-full"} value={u} onChange={(e) => setU(e.target.value)}
aria-label="用户名" placeholder="用户名" autoFocus autoComplete="username" />
<input className={input + " w-full"} type="password" value={p} onChange={(e) => setP(e.target.value)}
aria-label="密码" placeholder="密码" autoComplete="current-password" />
<button className={btnPrimary + " w-full"} disabled={busy || !u || !p}>
<div className="relative grid h-full place-items-center p-4">
<div className="absolute right-3 top-3">
<ThemeToggle />
</div>
<form onSubmit={submit} className="fx-rise w-full max-w-sm">
<Card>
<CardHeader className="items-center text-center">
<span className="mb-2 grid size-10 place-items-center rounded-lg bg-accent text-accent-foreground">
<BookMarked className="size-5" />
</span>
<CardTitle className="text-xl tracking-tight">BookLib</CardTitle>
<CardDescription>你的漫画与书籍藏书馆</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="login-user">用户名</Label>
<Input id="login-user" value={u} onChange={(e) => setU(e.target.value)} autoFocus autoComplete="username" />
</div>
<div className="space-y-1.5">
<Label htmlFor="login-pass">密码</Label>
<Input id="login-pass" type="password" value={p} onChange={(e) => setP(e.target.value)} autoComplete="current-password" />
</div>
<Button type="submit" className="w-full" disabled={busy || !u || !p}>
{busy ? "登录中…" : "登录"}
</button>
</Button>
</CardContent>
</Card>
</form>
</div>
);
+8 -33
View File
@@ -1,11 +1,10 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { Suspense, lazy, useCallback, useMemo, useState, type ComponentType, type LazyExoticComponent } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Link, useParams } from "react-router-dom";
import { api } from "../api/client";
import type { Format } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { toast } from "../components/Toaster";
import { btn } from "../components/ui";
import { IconArrowLeft } from "../components/icons";
import { btn, btnGhost, formatBadge } from "../components/ui";
import type { ReaderProps } from "../lib/useProgress";
const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = {
@@ -19,9 +18,6 @@ const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderPr
export default function Reader() {
const { id: idStr } = useParams();
const id = Number(idStr);
const qc = useQueryClient();
const nav = useNavigate();
const { isAdmin } = useAuth();
const [chromeOn, setChromeOn] = useState(true);
const toggle = useCallback(() => setChromeOn((v) => !v), []);
@@ -30,15 +26,6 @@ export default function Reader() {
const bookQ = useQuery({ queryKey: ["book", id], queryFn: () => api.getBook(id), enabled: Number.isFinite(id) });
const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress });
const del = useMutation({
mutationFn: () => api.deleteBook(id),
onSuccess: () => {
toast("ok", "已删除");
qc.invalidateQueries({ queryKey: ["books"] });
nav("/");
},
onError: (e) => toast("err", e instanceof Error ? e.message : "删除失败"),
});
const book = bookQ.data;
const body = (() => {
@@ -68,30 +55,18 @@ export default function Reader() {
(chromeOn ? "h-11 py-2 opacity-100" : "h-0 border-b-0 py-0 opacity-0")
}
>
<Link
to="/"
className="shrink-0 rounded-md px-2 py-1 text-stone-400 transition-colors hover:bg-stone-800 hover:text-stone-100"
>
← 书架
<Link to="/" className={btnGhost + " shrink-0"}>
<IconArrowLeft className="size-4" />
书架
</Link>
<span className="truncate font-medium">{book?.title ?? "…"}</span>
{book && (
<span className="hidden shrink-0 items-center gap-2 text-xs text-stone-500 sm:flex">
{book.library && <span>{book.library}</span>}
<span className="rounded bg-stone-800/80 px-1.5 py-0.5 uppercase">{book.format}</span>
<span className={formatBadge(book.format)}>{book.format}</span>
<span className="tabular-nums">{Math.round(book.percent * 100)}%</span>
</span>
)}
{isAdmin && book && (
<button
className={btn + " ml-auto text-red-300"}
onClick={() => {
if (confirm(`删除《${book.title}》?磁盘文件一并删除,阅读进度保留`)) del.mutate();
}}
>
删除
</button>
)}
</header>
<div className="min-h-0 flex-1">
<Suspense fallback={<Msg text="加载阅读器…" />}>{body}</Suspense>
+227 -126
View File
@@ -1,64 +1,85 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import type { Book } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { Cover } from "../components/Cover";
import { TopBar } from "../components/TopBar";
import { toast } from "../components/Toaster";
import { btn, btnPrimary, card, input } from "../components/ui";
import { groupByDir } from "../lib/group";
import { useNavigate, useSearchParams } from "react-router-dom";
import { ArrowDownUp, Ellipsis, FolderOpen, LibraryBig, RefreshCw, ScanLine, Search, TriangleAlert } from "lucide-react";
import { api } from "@/api/client";
import type { Book } from "@/api/types";
import { useAuth } from "@/auth/AuthContext";
import { Cover } from "@/components/Cover";
import { toast } from "@/components/Toaster";
import { UploadButton } from "@/components/upload-button";
import { formatBadge } from "@/components/ui";
import {
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({
book,
onOpen,
onDelete,
}: {
book: Book;
onOpen: () => void;
onDelete: () => void;
}) {
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))]";
function BookCard({ book, index, onOpen, onDelete }: { book: Book; index: number; onOpen: () => void; onDelete: () => void }) {
const { isAdmin } = useAuth();
const pct = Math.round(book.percent * 100);
return (
<div className="group relative w-32 shrink-0">
<button onClick={onOpen} className="block w-full text-left">
<Cover
book={book}
className="aspect-[3/4] w-32 rounded-lg shadow-md ring-1 ring-stone-800 transition duration-200 group-hover:-translate-y-1 group-hover:shadow-xl group-hover:ring-stone-600"
/>
<p className="mt-1.5 truncate text-xs text-stone-200 transition-colors group-hover:text-amber-300">
{book.title}
</p>
<p className="truncate text-[10px] text-stone-500">
{book.format} · {book.path}
</p>
</button>
<div className="group fx-rise relative flex flex-col" style={{ animationDelay: `${Math.min(index, 23) * 25}ms` }}>
<button onClick={onOpen} className="block w-full rounded-md p-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<span className="relative block aspect-[3/4] overflow-hidden rounded-md border bg-muted shadow-sm transition group-hover:shadow-md group-hover:ring-2 group-hover:ring-accent/50">
<Cover book={book} className="size-full" />
<span className={formatBadge(book.format, "absolute left-1.5 top-1.5")}>{book.format}</span>
{book.state === "error" && (
<span
className="absolute left-1 top-1 rounded bg-red-900 px-1 text-[10px] text-red-100"
title={book.error}
>
损坏
<span className="absolute right-1.5 top-1.5 flex items-center gap-1 rounded bg-destructive px-1.5 py-0.5 text-[10px] font-semibold text-destructive-foreground" title={book.error}>
<TriangleAlert className="size-3" /> 损坏
</span>
)}
{book.percent > 0 && (
<div className="mt-1 h-1 w-32 overflow-hidden rounded-full bg-stone-800">
<div className="h-full rounded-full bg-amber-500" style={{ width: `${Math.round(book.percent * 100)}%` }} />
</div>
{pct > 0 && (
<span className="absolute inset-x-0 bottom-0 h-1 bg-black/30" role="presentation">
<span className="block h-full bg-accent" style={{ width: `${pct}%` }} />
</span>
)}
{isAdmin && (
<button
className="absolute right-1 top-1 hidden rounded bg-stone-900/80 px-1.5 text-xs text-red-300 group-hover:block"
title="删除(连磁盘文件)"
onClick={() => {
if (confirm(`删除《${book.title}》?磁盘文件一并删除,阅读进度保留`)) onDelete();
}}
>
✕
</span>
<span className="mt-2 block truncate text-xs font-medium">{book.title}</span>
<span className="block truncate text-[11px] text-muted-foreground">
{book.path.includes("/") ? book.path.slice(0, book.path.lastIndexOf("/")) : `${book.pages > 0 ? book.pages + " 页 · " : ""}${formatSize(book.size)}`}
</span>
</button>
<div className="absolute right-1 top-1 z-10 opacity-100 transition-opacity md:opacity-0 md:focus-within:opacity-100 md:group-hover:opacity-100">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" className="size-7 bg-background/80 backdrop-blur" aria-label={`《${book.title}》操作`}>
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={onOpen}>打开</DropdownMenuItem>
{isAdmin && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={onDelete}>
删除
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
function SectionHead({ label, count }: { label: string; count: number }) {
if (!label) return null;
return (
<h2 className="sticky top-0 z-10 mb-3 flex items-center gap-2 bg-background/85 py-1.5 text-sm font-medium backdrop-blur">
<FolderOpen className="size-4 text-muted-foreground" />
{label}
<span className="text-xs tabular-nums text-muted-foreground">{count}</span>
</h2>
);
}
@@ -66,23 +87,32 @@ export default function Shelf() {
const qc = useQueryClient();
const nav = useNavigate();
const { isAdmin } = useAuth();
const [lib, setLib] = useState(0); // 0 = 全部库
const [qRaw, setQRaw] = useState("");
const [q, setQ] = useState("");
const [sp, setSp] = useSearchParams();
const lib = Number(sp.get("lib")) || LIB0;
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(() => {
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);
}, [qRaw]);
}, [qRaw, serverQ, sp, setSp]);
const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries });
const booksQ = useQuery({
queryKey: ["books", lib, q],
queryFn: () => api.listBooks({ library: lib || undefined, q }),
});
const booksQ = useQuery({ queryKey: ["books", lib, serverQ], queryFn: () => api.listBooks({ library: lib || undefined, q: serverQ }) });
const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress });
// 继续阅读行 → book id 解析用(键与 booksQ 默认态一致,天然共享缓存)
const allQ = useQuery({ queryKey: ["books", 0, ""], queryFn: () => api.listBooks() });
const allQ = useQuery({ queryKey: ["books", LIB0, ""], queryFn: () => api.listBooks() });
const del = useMutation({
mutationFn: (id: number) => api.deleteBook(id),
@@ -96,106 +126,177 @@ export default function Shelf() {
mutationFn: (id: number) => api.scanLibrary(id),
onSuccess: () => {
toast("ok", "扫描已触发");
setTimeout(() => qc.invalidateQueries({ queryKey: ["books"] }), 3000); // 给周期扫描留出时间
setTimeout(() => qc.invalidateQueries({ queryKey: ["books"] }), 3000);
},
onError: (e) => toast("err", e instanceof Error ? e.message : "扫描失败"),
});
const groups = useMemo(() => groupByDir(booksQ.data ?? []), [booksQ.data]);
const booksByKey = useMemo(() => {
const m = new Map<string, Book>();
for (const b of allQ.data ?? []) m.set(`${b.library_id}/${b.path}`, b);
return m;
}, [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 libs = libsQ.data ?? [];
const setParam = (k: string, v: string) => {
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 (
<div className="flex h-full flex-col">
<TopBar
right={
<>
<button className={btn} onClick={() => qc.invalidateQueries({ queryKey: ["books"] })}>
刷新
</button>
{isAdmin && lib > 0 && (
<button className={btn} disabled={scan.isPending} onClick={() => scan.mutate(lib)}>
{scan.isPending ? "扫描中…" : "扫描此库"}
</button>
)}
<input
className={input + " w-44"}
aria-label="搜索标题" placeholder="搜索标题…"
value={qRaw}
onChange={(e) => setQRaw(e.target.value)}
/>
</>
}
/>
<div className="flex-1 overflow-y-auto p-4">
{libs.length === 0 && (
<p className="text-sm text-stone-500">还没有书库。admin 请到「库管理」建库并扫描。</p>
)}
<div className="mb-4 flex flex-wrap gap-2">
<button className={lib === 0 ? btnPrimary : btn} onClick={() => setLib(0)}>
全部
</button>
{libs.map((l) => (
<button key={l.id} className={lib === l.id ? btnPrimary : btn} onClick={() => setLib(l.id)}>
{l.name}
</button>
<div className="mx-auto flex w-full max-w-6xl flex-col px-4 pb-20 sm:px-6 md:pb-6">
<div className="sticky top-0 z-20 -mx-4 mb-6 flex flex-wrap items-center gap-2 border-b bg-background/85 px-4 py-3 backdrop-blur sm:-mx-6 sm:px-6">
<div className="relative min-w-0 flex-1 sm:max-w-64">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input className="pl-8" type="search" aria-label="搜索标题" placeholder="搜索标题…" value={qRaw} onChange={(e) => setQRaw(e.target.value)} />
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5">
<ArrowDownUp className="size-4" />
{SORT_LABEL[sort]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>排序</DropdownMenuLabel>
{SORTS.map((k) => (
<DropdownMenuItem key={k} onSelect={() => setParam("sort", k === "added" ? "" : k)} className={k === sort ? "font-medium text-accent-strong" : ""}>
{SORT_LABEL[k]}
</DropdownMenuItem>
))}
</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 && (
<>
<UploadButton libId={lib} variant="outline" label="上传文件" />
<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 && (
{lib === LIB0 && <p className="sr-only" aria-live="polite">{`当前筛选:${libName}`}</p>}
{(libsQ.data ?? []).length === 0 && !libsQ.isPending && !searching && (
<p className="mb-6 flex items-center gap-2 rounded-md border border-accent/30 bg-accent/10 px-3 py-2 text-sm text-accent-strong">
<LibraryBig className="size-4 shrink-0" />
还没有书库。admin 请到「库管理」建库并扫描。
</p>
)}
{continueRows.length > 0 && !searching && (
<section className="mb-8">
<h2 className="mb-2 text-sm font-semibold text-stone-400">继续阅读</h2>
<div className="flex gap-4 overflow-x-auto pb-2">
{continueRows.map((r) => {
<h2 className="mb-3 text-sm font-medium text-muted-foreground">继续阅读</h2>
<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 (
<div key={`${r.library_id}/${r.path}`} className={`w-28 shrink-0 p-2 text-xs text-stone-600 ${card}`}>
<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` }}>
<p className="truncate">{r.title || "已删除的书"}</p>
<p>{Math.round(r.percent * 100)}%</p>
</div>
<p className="mt-1 text-[10px]">进度已保留</p>
</li>
);
const pct = Math.round(r.percent * 100);
return (
<BookCard
key={`${r.library_id}/${r.path}`}
book={b}
onOpen={() => nav(`/book/${b.id}`)}
onDelete={() => del.mutate(b.id)}
/>
<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-strong">{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>
);
})}
</div>
</ul>
</section>
)}
{booksQ.isError && (
<p className="mb-4 text-sm text-red-400">
加载失败:{booksQ.error instanceof Error ? booksQ.error.message : ""}{" "}
<button className={btn} onClick={() => booksQ.refetch()}>
重试
</button>
</p>
<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">
<TriangleAlert className="size-4 shrink-0 text-destructive" />
<span>加载失败:{booksQ.error instanceof Error ? booksQ.error.message : "未知错误"}</span>
<Button variant="outline" size="sm" onClick={() => booksQ.refetch()}>
<RefreshCw className="size-4" /> 重试
</Button>
</div>
)}
{!booksQ.isPending && groups.length === 0 && !booksQ.isError && (
<p className="text-sm text-stone-500">此库暂无书目(放入文件后等待扫描或点「扫描此库」)。</p>
{booksQ.isPending && !booksQ.isError && (
<div className={GRID} aria-busy="true" aria-label="加载书目中">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="aspect-[3/4] w-full rounded-md" />
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-2 w-1/2" />
</div>
))}
</div>
)}
{groups.map((g) => (
<section key={g.dir || "//"} className="mb-8">
<h2 className="mb-2 text-sm font-semibold text-stone-400">{g.dir || "根目录"}</h2>
<div className="flex flex-wrap gap-4">
{g.books.map((b) => (
<BookCard key={b.id} book={b} onOpen={() => nav(`/book/${b.id}`)} onDelete={() => del.mutate(b.id)} />
{!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>
</section>
))}
</div>
<AlertDialog open={confirmDel !== null} onOpenChange={(o) => !o && setConfirmDel(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除《{confirmDel?.title}》?</AlertDialogTitle>
<AlertDialogDescription>磁盘文件一并删除,阅读进度保留。</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
if (confirmDel) del.mutate(confirmDel.id);
setConfirmDel(null);
}}
>
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+48 -61
View File
@@ -1,15 +1,18 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useRef, useState, type FormEvent } from "react";
import { useState, type DragEvent, type FormEvent } from "react";
import { RefreshCw } from "lucide-react";
import { api } from "../../api/client";
import { TopBar } from "../../components/TopBar";
import { toast } from "../../components/Toaster";
import { btn, btnPrimary, card, input } from "../../components/ui";
import { UploadButton, uploadToLib } from "@/components/upload-button";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
export default function AdminLibraries() {
const qc = useQueryClient();
const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries });
const [name, setName] = useState("");
const fileRefs = useRef<Record<number, HTMLInputElement | null>>({});
const create = useMutation({
mutationFn: () => api.createLibrary(name.trim()),
@@ -29,16 +32,9 @@ export default function AdminLibraries() {
onError: (e) => toast("err", e instanceof Error ? e.message : "扫描失败"),
});
async function onFiles(libId: number, list: FileList | null) {
if (!list?.length) return;
try {
for (const f of Array.from(list)) await api.uploadBook(libId, f); // 串行,单个失败即止并报错
toast("ok", `已上传 ${list.length} 个文件,等待 scanner 收编`);
void qc.invalidateQueries({ queryKey: ["books"] });
setTimeout(() => void qc.invalidateQueries({ queryKey: ["books"] }), 5000);
} catch (e) {
toast("err", e instanceof Error ? e.message : "上传失败");
}
function onDrop(e: DragEvent, libId: number) {
e.preventDefault();
void uploadToLib(qc, libId, Array.from(e.dataTransfer.files));
}
function submit(e: FormEvent) {
@@ -46,59 +42,50 @@ export default function AdminLibraries() {
create.mutate();
}
const libs = libsQ.data ?? [];
return (
<div className="flex h-full flex-col">
<TopBar />
<div className="flex-1 overflow-y-auto p-4">
<h1 className="mb-4 text-lg font-semibold">库管理</h1>
<form onSubmit={submit} className={`${card} mb-6 flex max-w-2xl flex-wrap items-center gap-3 p-4`}>
<input
className={input + " flex-1"}
placeholder="库名(如 comics,服务端目录自动落在 BOOKS_DIR/<库名> 下)"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<button className={btnPrimary} disabled={create.isPending || !name.trim()}>
<div className="mx-auto w-full max-w-3xl px-4 py-6 sm:px-6">
<h1 className="mb-4 text-lg font-semibold tracking-tight">库管理</h1>
<form onSubmit={submit} className="mb-6 flex flex-wrap items-center gap-3">
<Input className="max-w-64 flex-1" placeholder="库名(服务端目录自动落在 BOOKS_DIR/<库名>)" value={name} onChange={(e) => setName(e.target.value)} />
<Button type="submit" disabled={create.isPending || !name.trim()}>
{create.isPending ? "创建中…" : "建库"}
</button>
</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>
{libsQ.isPending ? (
<Skeleton className="h-40 w-full" />
) : libs.length === 0 ? (
<p className="text-sm text-muted-foreground">还没有库,用上方表单创建。</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">ID</TableHead>
<TableHead>库名</TableHead>
<TableHead>服务端路径</TableHead>
<TableHead className="w-44" />
</TableRow>
</TableHeader>
<TableBody>
{libs.map((l) => (
<TableRow key={l.id} onDragOver={(e) => e.preventDefault()} onDrop={(e) => onDrop(e, l.id)}>
<TableCell className="text-muted-foreground">{l.id}</TableCell>
<TableCell className="font-medium">{l.name}</TableCell>
<TableCell className="text-muted-foreground">{l.root_path}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="sm" disabled={scan.isPending} onClick={() => scan.mutate(l.id)}>
<RefreshCw /> 扫描
</Button>
<UploadButton libId={l.id} variant="ghost" />
</div>
</TableCell>
</TableRow>
))}
{(libsQ.data ?? []).length === 0 && !libsQ.isPending && (
<p className="text-sm text-stone-500">还没有库,用上方表单创建。</p>
</TableBody>
</Table>
)}
</div>
</div>
<p className="mt-3 text-xs text-muted-foreground">上传白名单:cbz/zip/pdf/epub/txt/md · 也可直接拖文件进行</p>
</div>
);
}
+66 -46
View File
@@ -2,9 +2,16 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, type FormEvent } from "react";
import { api } from "../../api/client";
import { useAuth } from "../../auth/AuthContext";
import { TopBar } from "../../components/TopBar";
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() {
const qc = useQueryClient();
@@ -39,55 +46,68 @@ export default function AdminUsers() {
}
return (
<div className="flex h-full flex-col">
<TopBar />
<div className="flex-1 overflow-y-auto p-4">
<h1 className="mb-4 text-lg font-semibold">用户管理</h1>
<form onSubmit={submit} className={`${card} mb-6 flex max-w-2xl flex-wrap items-center gap-3 p-4`}>
<input className={input} placeholder="用户名" value={u} onChange={(e) => setU(e.target.value)} />
<input className={input} type="password" placeholder="密码(≥8 位)" value={p} onChange={(e) => setP(e.target.value)} />
<select className={input} value={role} onChange={(e) => setRole(e.target.value as "member" | "admin")}>
<option value="member">member</option>
<option value="admin">admin</option>
</select>
<button className={btnPrimary} disabled={create.isPending || !u.trim() || p.length < 8}>
<div className="mx-auto w-full max-w-3xl px-4 py-6 sm:px-6">
<h1 className="mb-4 text-lg font-semibold tracking-tight">用户管理</h1>
<form onSubmit={submit} className="mb-6 flex flex-wrap items-center gap-3">
<Input className="max-w-40" placeholder="用户名" value={u} onChange={(e) => setU(e.target.value)} />
<Input className="max-w-48" type="password" placeholder="密码(≥8 位)" value={p} onChange={(e) => setP(e.target.value)} />
<Select value={role} onValueChange={(v) => setRole(v as "member" | "admin")}>
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="member">member</SelectItem>
<SelectItem value="admin">admin</SelectItem>
</SelectContent>
</Select>
<Button type="submit" disabled={create.isPending || !u.trim() || p.length < 8}>
{create.isPending ? "创建中…" : "创建"}
</button>
</Button>
</form>
<table className="w-full max-w-2xl text-sm">
<thead className="text-left text-stone-500">
<tr>
<th className="py-1">ID</th>
<th>用户名</th>
<th>角色</th>
<th>创建时间</th>
<th />
</tr>
</thead>
<tbody>
{usersQ.isPending ? (
<Skeleton className="h-40 w-full" />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">ID</TableHead>
<TableHead>用户名</TableHead>
<TableHead>角色</TableHead>
<TableHead>创建时间</TableHead>
<TableHead className="w-16" />
</TableRow>
</TableHeader>
<TableBody>
{(usersQ.data ?? []).map((x) => (
<tr key={x.id} className="border-t border-stone-800">
<td className="py-1 text-stone-500">{x.id}</td>
<td>{x.username}</td>
<td>{x.role}</td>
<td className="text-stone-500">{x.created_at.slice(0, 19).replace("T", " ")}</td>
<td className="py-1 text-right">
<button
className={btn + " text-red-300"}
disabled={x.id === me?.id}
title={x.id === me?.id ? "不能删除自己" : undefined}
onClick={() => {
if (confirm(`删除用户 ${x.username}?`)) del.mutate(x.id);
}}
>
<TableRow key={x.id}>
<TableCell className="text-muted-foreground">{x.id}</TableCell>
<TableCell>{x.username}</TableCell>
<TableCell>{x.role}</TableCell>
<TableCell className="text-muted-foreground">{x.created_at.slice(0, 19).replace("T", " ")}</TableCell>
<TableCell className="text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" disabled={x.id === me?.id} title={x.id === me?.id ? "不能删除自己" : undefined}>
删除
</button>
</td>
</tr>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除用户 {x.username}?</AlertDialogTitle>
<AlertDialogDescription>该用户的阅读进度与书签一并删除。</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction className="bg-destructive text-destructive-foreground hover:bg-destructive/90" onClick={() => del.mutate(x.id)}>
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))}
</tbody>
</table>
</div>
</TableBody>
</Table>
)}
</div>
);
}
+223 -119
View File
@@ -1,39 +1,40 @@
import { useQuery } from "@tanstack/react-query";
import { BookOpenCheck, Check, ChevronLeft, ChevronRight, PanelLeft, Settings2 } from "lucide-react";
import { DropdownMenu } from "radix-ui";
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { api, formatPageUrl } from "../api/client";
import { btn } from "../components/ui";
import { useBookmarks } from "../components/Bookmarks";
import { BookmarksTab } from "../components/Bookmarks";
import { RdSlider } from "../components/rd-slider";
import { ReaderNav, type NavTab } from "../components/reader-nav";
import { fetchObjectUrl } from "../lib/authImage";
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
import { PageHeights } from "../lib/virt";
import { PageHeights, chapterIndexAt, chapterWin } from "../lib/virt";
const MAX_W = 720;
const EST_RATIO = 1.4; // 估高:宽 × 1.4(漫画常见竖幅)
type PageMode = "scroll" | "snap" | "fit"; // 连读 / 整页(适宽) / 适高(一页一屏)
const MODES: PageMode[] = ["scroll", "snap", "fit"];
const MODE_LABEL: Record<PageMode, string> = { scroll: "连读", snap: "整页", fit: "适高" };
function useStoredMode() {
const [mode, setMode] = useState<PageMode>(() => {
const v = localStorage.getItem("cbz-mode");
return MODES.includes(v as PageMode) ? (v as PageMode) : "scroll";
function useStoredBool(key: string, def: boolean) {
const [v, setV] = useState(() => {
const s = localStorage.getItem(key);
return s === null ? def : s === "1";
});
useEffect(() => localStorage.setItem("cbz-mode", mode), [mode]);
return [mode, setMode] as const;
useEffect(() => localStorage.setItem(key, v ? "1" : "0"), [key, v]);
return [v, setV] as const;
}
function PageImg({
url,
width,
fitH,
onLoaded,
}: {
url: string;
width: number;
fitH: boolean;
onLoaded: (h: number) => void;
}) {
function useStoredPrefetch() {
const [n, setN] = useState(() => {
const s = localStorage.getItem("cbz-prefetch");
if (s === null || s === "") return 2; // 键缺失/空串 ≠ 0:新用户默认 2
const v = Number(s);
return Number.isInteger(v) && v >= 0 && v <= 3 ? v : 2;
});
useEffect(() => localStorage.setItem("cbz-prefetch", String(n)), [n]);
return [n, setN] as const;
}
function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoaded: (h: number) => void }) {
const [src, setSrc] = useState("");
const [failed, setFailed] = useState(false);
const [nonce, setNonce] = useState(0);
@@ -58,11 +59,7 @@ function PageImg({
</div>
);
if (!src)
return (
<div className="animate-pulse bg-stone-800" style={{ width, height: fitH ? "100%" : width * EST_RATIO }} />
);
if (fitH)
return <img src={src} alt="" className="mx-auto block h-full w-full object-contain" loading="eager" />;
return <div className="animate-pulse bg-stone-800" style={{ width, height: width * EST_RATIO }} />;
return (
<img
src={src}
@@ -85,11 +82,10 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
retry: 0,
});
const count = countQ.data?.count ?? 0;
const chapters = countQ.data?.chapters ?? null; // 包内目录结构(第N話/上中下卷)
const [toc, setToc] = useState(false);
const chapters = countQ.data?.chapters ?? null; // 包内目录结构(第N話/上中下卷);null/单组=扁平整本一章
const [nav, setNav] = useState<null | NavTab>(null);
const activeRow = useRef<HTMLButtonElement>(null);
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
const bm = useBookmarks({ book, capture: saver.capture, seek: (l) => jump(Number(l.page) || 0) });
const boxRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(480);
const [vh, setVh] = useState(600);
@@ -103,34 +99,53 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
const shift = useRef(0); // 量高补偿按帧合并,避免一次加载多页各改一次 scrollTop
const shiftRaf = useRef(0);
const ratio = useRef(EST_RATIO); // 学到的页高比(同开本漫画一页量准,全程几何稳定)
const [mode, setMode] = useStoredMode();
const fitH = mode === "fit";
const [continuous, setContinuous] = useStoredBool("cbz-continuous", false); // 连读:滑动可跨章
const [prefetch, setPrefetch] = useStoredPrefetch(); // 锁章预读章数 0–3
const [ci, setCi] = useState(0); // 当前章窗口;锁章时只由显式跳页改,连读时随滚动同步
const pending = useRef<number | null>(null); // 跨章跳页:切窗后下一帧落位
const ph = useMemo(
() => new PageHeights(count, fitH ? vh : width * ratio.current),
[count, width, vh, fitH],
const ph = useMemo(() => new PageHeights(count, width * ratio.current), [count, width]);
const w = useMemo(
() => chapterWin(chapters, count, ci, continuous, prefetch),
[chapters, count, ci, continuous, prefetch],
);
const locked = !!chapters && chapters.length >= 2 && !continuous;
// 锁章下窗外前缀页不渲染 → scrollTop/页 top 一律窗口相对;读 ph(全局前缀和)前先加回 anchor
const anchor = locked ? ph.offset(w.start) : 0;
// 容器尺寸自适应(尺寸变化后重建 PageHeights,阅读位置以当前滚动值为准)
useEffect(() => {
const el = boxRef.current;
// 容器尺寸自适应:回调 ref 在滚动盒真正挂载时才观测(等页数请求期间的早退渲染里没有这个节点)
const roRef = useRef<ResizeObserver | null>(null);
function attachBox(el: HTMLDivElement | null) {
boxRef.current = el;
roRef.current?.disconnect();
roRef.current = null;
if (!el) return;
const ro = new ResizeObserver(() => {
setWidth(Math.min(el.clientWidth, MAX_W));
setVh(el.clientHeight);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
roRef.current = ro;
}
// 首次拿到 count 后恢复进度页
// 首次拿到 count 后恢复进度页;目标页可能在他章 → goPage 走显式切章路径
useEffect(() => {
if (restored.current || !count || !boxRef.current) return;
restored.current = true;
const p = Number(initialLocator?.page);
if (Number.isInteger(p) && p > 0 && p < count) boxRef.current.scrollTop = ph.offset(p);
if (Number.isInteger(p) && p > 0 && p < count) goPage(p, false, false);
}, [count, initialLocator, ph]);
// 跨章跳页:新窗口几何渲染后才能真正滚动(旧窗口 contentH 会把 scrollTop 夹断),故挂 pending 等 [ci] 生效
useEffect(() => {
if (pending.current === null) return;
const el = boxRef.current;
if (!el) return;
const t = pending.current;
pending.current = null;
el.scrollTop = ph.offset(t) - anchor;
}, [ci, ph]);
// 滚动事件按帧合并;预取等滚动停稳再做——拖拽途中逐事件拉图=带宽/解码风暴
function onScroll() {
if (scrollRaf.current) return;
@@ -141,7 +156,9 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
const t = el.scrollTop;
setTop(t);
if (count) {
curRef.current = ph.pageAt(t + vh / 2);
curRef.current = ph.pageAt(t + anchor + vh / 2);
if (locked) curRef.current = Math.min(curRef.current, w.end - 1); // 末屏中心落在章末卡片/他章 → 钉在本章末页
if (!locked) setCi(chapterIndexAt(chapters, curRef.current)); // 连读/扁平:当前章随滚动走(目录计数高亮)
saver.report({ page: curRef.current }, (curRef.current + 1) / count);
}
clearTimeout(idle.current);
@@ -172,27 +189,47 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
});
}
const cur = ph.pageAt(top + vh / 2);
const ci = chapters ? chapters.reduce((acc, c, i) => (c.start <= cur ? i : acc), 0) : 0;
useEffect(() => {
if (toc) activeRow.current?.scrollIntoView({ block: "center" });
}, [toc]);
const curRaw = ph.pageAt(top + anchor + vh / 2);
const cur = locked ? Math.min(curRaw, w.end - 1) : curRaw; // 锁章:视口落在章末卡片上时读数不越章(HUD/滑条/进度)
function jump(i: number, smooth = true) {
useEffect(() => {
if (nav === "toc") activeRow.current?.scrollIntoView({ block: "center" });
}, [nav]);
function scrollToPage(i: number, smooth: boolean) {
const el = boxRef.current;
if (!el) return;
el.scrollTo({ top: ph.offset(Math.max(0, Math.min(count - 1, i))), behavior: smooth ? "smooth" : "auto" });
chrome.show();
el.scrollTo({
top: ph.offset(Math.max(w.start, Math.min(w.end - 1, i))) - anchor,
behavior: smooth ? "smooth" : "auto",
});
}
// 点按两侧翻页/翻屏,点中间唤出工具栏(微信读书/Mihon 式手势区)
// 统一定位入口:锁章下目标页在窗外 = 显式切章(滑条/书签/目录/章末卡片皆此一路径);恢复进度走 show=false 静默落位
function goPage(i: number, smooth = true, show = true) {
const t = Math.max(0, Math.min(count - 1, i));
if (show) chrome.show();
if (locked && (t < w.start || t >= w.end)) {
setCi(chapterIndexAt(chapters, t));
pending.current = t;
return;
}
scrollToPage(t, smooth);
}
// 翻一屏;锁章时滚动高度天然止于章末卡片,滚不过去
function turnScreen(d: number) {
boxRef.current?.scrollBy({ top: d * vh, behavior: "smooth" });
}
// 点按两侧翻屏,点中间唤出工具栏(微信读书/Mihon 式手势区)
function onZoneClick(e: MouseEvent<HTMLDivElement>) {
if ((e.target as HTMLElement).closest("button,input,nav,a")) return;
if (window.getSelection()?.toString()) return;
const r = e.currentTarget.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width;
if (x < 0.28) jump(cur - 1);
else if (x > 0.72) jump(cur + 1);
if (x < 0.28) turnScreen(-1);
else if (x > 0.72) turnScreen(1);
else chrome.toggle();
}
@@ -207,59 +244,79 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
);
const fmt: string = book.page_url_fmt;
const [a, b] = ph.range(top, vh, 2);
const [a0, b0] = ph.range(top + anchor, vh, 2); // 视口位置换回全局空间再查页(锁章下 top 是窗口相对的)
const a = Math.max(a0, w.start);
const b = Math.min(b0, w.end); // 可见页永不超过章末;窗外页在隐藏预读层
const contentH = locked ? ph.offset(w.end) - anchor + vh : ph.total();
return (
<div
tabIndex={0}
aria-label="漫画阅读器,点按左右两侧翻页,点按中间显示工具栏"
className="relative h-full"
onKeyDown={(e) => {
if (e.key === "ArrowRight" || e.key === "PageDown") jump(cur + 1);
else if (e.key === "ArrowLeft" || e.key === "PageUp") jump(cur - 1);
else return;
e.preventDefault();
}}
>
<div
ref={boxRef}
onScroll={onScroll}
onClick={onZoneClick}
className={"h-full overflow-y-auto" + (mode !== "scroll" ? " snap-y snap-proximity" : "")}
>
<div className="relative mx-auto" style={{ height: ph.total(), maxWidth: fitH ? "none" : MAX_W }}>
{Array.from({ length: b - a }, (_, k) => {
const i = a + k;
return (
<div
key={i}
className="snap-start absolute left-0 w-full"
style={{ top: ph.offset(i), ...(fitH ? { height: vh } : null) }}
>
<PageImg
url={formatPageUrl(fmt, i)}
width={width}
fitH={fitH}
onLoaded={(h) => {
if (fitH) return; // 适高模式定高一页一屏,无需量高
function measure(i: number, h: number) {
const el = boxRef.current;
const topY = el ? el.scrollTop : 0;
const topY = el ? el.scrollTop + anchor : 0; // 与 ph.offset 同处全局空间比较
const oldEst = ph.estHeight;
const topOfI = ph.offset(i);
const d = ph.set(i, h);
if (el && d !== 0 && topOfI < topY) applyShift(d); // 视口上方页高变化 → 补偿滚动
// 让未量高页的估高收敛到实测页高:同开本漫画量一页后 d≈0,拖拽滚动条不再逐帧打架
if (width > 0 && Math.abs(h - oldEst) / oldEst > 0.03) {
const a0 = ph.offset(w.start); // 基线取当前实际 anchor:连续 measure 交错时闭包 anchor 可能过期,防前缀份额双扣
const n = ph.setEst(h, topY);
if (el && n) applyShift(n * (h - oldEst));
// n×Δest 是全局位移;锁章下窗外前缀页的那部分不影响窗口相对几何,须扣减
const corr = n * (h - oldEst) - (ph.offset(w.start) - a0);
if (el && corr) applyShift(corr);
ratio.current = h / width;
}
bump((x) => x + 1);
}
return (
<div
tabIndex={0}
aria-label="漫画阅读器,点按左右两侧翻屏,点按中间显示工具栏"
className="relative h-full"
onKeyDown={(e) => {
if (e.key === "ArrowRight" || e.key === "PageDown") turnScreen(1);
else if (e.key === "ArrowLeft" || e.key === "PageUp") turnScreen(-1);
else return;
e.preventDefault();
}}
/>
>
<div ref={attachBox} onScroll={onScroll} onClick={onZoneClick} className="h-full overflow-y-auto">
<div className="relative mx-auto" style={{ height: contentH, maxWidth: MAX_W }}>
{Array.from({ length: Math.max(0, b - a) }, (_, k) => {
const i = a + k;
return (
<div key={i} className="absolute left-0 w-full" style={{ top: ph.offset(i) - anchor }}>
<PageImg url={formatPageUrl(fmt, i)} width={width} onLoaded={(h) => measure(i, h)} />
</div>
);
})}
{locked && (
<div
className="absolute left-0 grid w-full place-items-center"
style={{ top: ph.offset(w.end) - anchor, height: vh }}
>
{w.end >= count ? (
<span className="text-sm text-stone-500">— 全书完 —</span>
) : (
<button className={btn} onClick={() => goPage(chapters![ci + 1].start)}>
本章完 · 下一章 <ChevronRight size={14} className="inline" />
</button>
)}
</div>
)}
{locked && w.mountEnd > w.end && (
// 预读层:挂载+解码但不参与滚动几何(height:0 + overflow:hidden 裁掉全部子元素)
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 h-0 overflow-hidden">
{Array.from({ length: w.mountEnd - w.end }, (_, k) => {
const i = w.end + k;
return (
<div key={i} className="absolute left-0 w-full" style={{ top: ph.offset(i) - anchor }}>
<PageImg url={formatPageUrl(fmt, i)} width={width} onLoaded={(h) => measure(i, h)} />
</div>
);
})}
</div>
)}
</div>
</div>
{chrome.on ? (
@@ -273,7 +330,7 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
onChange={(e) => {
const v = Number(e.target.value);
setScrub(v);
jump(v, false); // 拖拽中瞬移:平滑动画会被下一格打断,手感像卡顿
goPage(v, false); // 拖拽中瞬移:平滑动画会被下一格打断,手感像卡顿
}}
onPointerUp={() => setScrub(null)}
onPointerCancel={() => setScrub(null)}
@@ -287,33 +344,71 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
</div>
<div className="mt-1 flex items-center gap-2 pb-1 text-sm">
<button
className="rd-btn"
onClick={() => setMode(MODES[(MODES.indexOf(mode) + 1) % MODES.length])}
aria-label="切换阅读模式"
className="rd-btn inline-flex items-center gap-1.5"
aria-expanded={!!nav}
aria-controls="reader-nav"
onClick={() => setNav(nav ? null : chapters ? "toc" : "bm")}
>
{MODE_LABEL[mode]}
<PanelLeft size={14} /> 导航
</button>
{bm.btn}
{chapters && (
<>
<button className="rd-btn" aria-expanded={toc} aria-controls="cbz-toc" onClick={() => setToc((v) => !v)}>
目录
</button>
<span className="text-xs tabular-nums opacity-60">
{ci + 1}/{chapters.length}
</span>
<button className="rd-btn" disabled={ci === 0} onClick={() => jump(chapters[ci - 1].start)}>
← 上一章
<button
className="rd-btn inline-flex items-center gap-0.5"
disabled={ci === 0}
onClick={() => goPage(chapters[ci - 1].start)}
>
<ChevronLeft size={14} /> 上一章
</button>
<button
className="rd-btn"
className="rd-btn inline-flex items-center gap-0.5"
disabled={ci === chapters.length - 1}
onClick={() => jump(chapters[ci + 1].start)}
onClick={() => goPage(chapters[ci + 1].start)}
>
下一章 →
下一章 <ChevronRight size={14} />
</button>
</>
)}
{chapters && chapters.length >= 2 && (
<DropdownMenu.Root>
<DropdownMenu.Trigger className="rd-btn ml-auto inline-flex items-center gap-1.5" aria-label="更多阅读选项">
<Settings2 size={14} /> 更多
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
align="end"
side="top"
className="rd-divider z-50 min-w-44 rounded-md border bg-[var(--rd-bg,#171512)] p-1 shadow-2xl"
style={{ color: "var(--rd-fg,#c6c0b6)" }}
>
<DropdownMenu.CheckboxItem
className="rd-row rd-highlight cursor-pointer"
style={{ display: "flex", alignItems: "center", gap: 8 }}
checked={continuous}
onCheckedChange={(v) => setContinuous(!!v)}
>
<BookOpenCheck size={14} /> 连读(滑动跨章)
<DropdownMenu.ItemIndicator style={{ marginLeft: "auto" }}>
<Check size={14} />
</DropdownMenu.ItemIndicator>
</DropdownMenu.CheckboxItem>
{locked && (
<RdSlider
label="预读:锁章末尾时提前挂载其后 N 章"
ariaLabel="预读章数"
min={0}
max={3}
value={prefetch}
onChange={setPrefetch}
/>
)}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)}
</div>
</div>
) : (
@@ -324,14 +419,23 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
<span className="opacity-60">{Math.round(((cur + 1) / Math.max(1, count)) * 100)}%</span>
</div>
)}
{toc && chapters && (
<>
<div className="fixed inset-0 z-10" aria-hidden="true" onClick={() => setToc(false)} />
<nav
id="cbz-toc"
aria-label="章节目录"
className="rd-divider absolute inset-y-0 left-0 z-20 w-64 overflow-y-auto border-r bg-[var(--rd-bg)] p-2 shadow-2xl"
>
{nav && (
<ReaderNav
tab={chapters ? nav : "bm"}
onTab={setNav}
onClose={() => setNav(null)}
bookmarks={
<BookmarksTab
book={book}
capture={saver.capture}
seek={(l) => {
goPage(Number(l.page) || 0);
setNav(null);
}}
/>
}
toc={
chapters && (
<ul className="space-y-0.5">
{chapters.map((c, i) => (
<li key={i}>
@@ -339,8 +443,8 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
ref={i === ci ? activeRow : undefined}
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
onClick={() => {
jump(c.start);
setToc(false);
goPage(c.start);
setNav(null);
}}
>
{i + 1}. {c.title}
@@ -348,10 +452,10 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
</li>
))}
</ul>
</nav>
</>
)
}
/>
)}
{bm.panel}
</div>
);
}
+34 -16
View File
@@ -1,6 +1,8 @@
import { ChevronLeft, ChevronRight, PanelLeft } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { apiRaw } from "../api/client";
import { useBookmarks } from "../components/Bookmarks";
import { BookmarksTab } from "../components/Bookmarks";
import { ReaderNav } from "../components/reader-nav";
import { btn } from "../components/ui";
import { toast } from "../components/Toaster";
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
@@ -32,15 +34,7 @@ export default function EpubReader({ book, initialLocator, initialPercent }: Rea
const hostRef = useRef<HTMLDivElement>(null);
const rendRef = useRef<EpubRendition | null>(null);
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
const bm = useBookmarks({
book,
capture: saver.capture,
seek: (l) => {
const cfi = String(l.cfi ?? "");
if (!cfi) return;
Promise.resolve(rendRef.current?.display(cfi)).catch(() => toast("err", "书签定位失败"));
},
});
const [nav, setNav] = useState(false);
const [err, setErr] = useState("");
const [nonce, setNonce] = useState(0);
const [ready, setReady] = useState(false);
@@ -107,15 +101,39 @@ export default function EpubReader({ book, initialLocator, initialPercent }: Rea
{!ready && !err && <div className="grid h-full place-items-center bg-stone-950 text-stone-500">EPUB 加载中…</div>}
</div>
<div className="flex items-center justify-center gap-3 border-t border-stone-800 px-4 py-2 text-sm">
<button className={btn} onClick={() => rendRef.current?.prev()}>
上一页
<button className={btn + " inline-flex items-center gap-0.5"} onClick={() => rendRef.current?.prev()}>
<ChevronLeft size={14} /> 上一页
</button>
<button className={btn} onClick={() => rendRef.current?.next()}>
下一页
<button className={btn + " inline-flex items-center gap-0.5"} onClick={() => rendRef.current?.next()}>
下一页 <ChevronRight size={14} />
</button>
<button
className="rd-btn inline-flex items-center gap-1.5"
aria-expanded={nav}
aria-controls="reader-nav"
onClick={() => setNav((v) => !v)}
>
<PanelLeft size={14} /> 导航
</button>
{bm.btn}
</div>
{bm.panel}
{nav && (
<ReaderNav
tab="bm"
onClose={() => setNav(false)}
bookmarks={
<BookmarksTab
book={book}
capture={saver.capture}
seek={(l) => {
const cfi = String(l.cfi ?? "");
setNav(false);
if (!cfi) return;
Promise.resolve(rendRef.current?.display(cfi)).catch(() => toast("err", "书签定位失败"));
}}
/>
}
/>
)}
</div>
);
}
+37 -19
View File
@@ -1,7 +1,9 @@
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, PanelLeft } from "lucide-react";
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import * as pdfjs from "pdfjs-dist";
import { apiRaw } from "../api/client";
import { useBookmarks } from "../components/Bookmarks";
import { BookmarksTab } from "../components/Bookmarks";
import { ReaderNav } from "../components/reader-nav";
import { btn } from "../components/ui";
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
@@ -21,14 +23,7 @@ export default function PdfReader({ book, initialLocator, initialPercent }: Read
const taskRef = useRef<RenderTask | null>(null);
const restored = useRef(false);
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
const bm = useBookmarks({
book,
capture: saver.capture,
seek: (l) => {
const p = Number(l.page);
if (Number.isInteger(p) && p >= 0 && p < num) setPage(p);
},
});
const [nav, setNav] = useState(false);
const [num, setNum] = useState(0);
const [page, setPage] = useState(0); // 0 基
const [err, setErr] = useState("");
@@ -145,24 +140,47 @@ export default function PdfReader({ book, initialLocator, initialPercent }: Read
<canvas ref={canvasRef} className="max-w-3xl rounded-md shadow-2xl ring-1 ring-stone-700/50" />
</div>
<div className="flex items-center justify-center gap-2 border-t border-stone-800/80 px-4 py-2 text-sm">
<button className={btn} disabled={page === 0} onClick={() => setPage(0)} aria-label="第一页">
首页
<button className={btn + " inline-flex items-center gap-0.5"} disabled={page === 0} onClick={() => setPage(0)} aria-label="第一页">
<ChevronsLeft size={14} /> 首页
</button>
<button className={btn} disabled={page === 0} onClick={() => setPage((p) => Math.max(0, p - 1))}>
← 上一页
<button className={btn + " inline-flex items-center gap-0.5"} disabled={page === 0} onClick={() => setPage((p) => Math.max(0, p - 1))}>
<ChevronLeft size={14} /> 上一页
</button>
<span className="min-w-16 text-center tabular-nums text-stone-400">
{page + 1}/{num}
</span>
<button className={btn} disabled={page >= num - 1} onClick={() => setPage((p) => Math.min(num - 1, p + 1))}>
下一页 →
<button className={btn + " inline-flex items-center gap-0.5"} disabled={page >= num - 1} onClick={() => setPage((p) => Math.min(num - 1, p + 1))}>
下一页 <ChevronRight size={14} />
</button>
<button className={btn} disabled={page >= num - 1} onClick={() => setPage(num - 1)} aria-label="最后一页">
末页
<button className={btn + " inline-flex items-center gap-0.5"} disabled={page >= num - 1} onClick={() => setPage(num - 1)} aria-label="最后一页">
<ChevronsRight size={14} /> 末页
</button>
<button
className="rd-btn inline-flex items-center gap-1.5"
aria-expanded={nav}
aria-controls="reader-nav"
onClick={() => setNav((v) => !v)}
>
<PanelLeft size={14} /> 导航
</button>
{bm.btn}
</div>
{bm.panel}
{nav && (
<ReaderNav
tab="bm"
onClose={() => setNav(false)}
bookmarks={
<BookmarksTab
book={book}
capture={saver.capture}
seek={(l) => {
const p = Number(l.page);
setNav(false);
if (Number.isInteger(p) && p >= 0 && p < num) setPage(p);
}}
/>
}
/>
)}
</div>
);
}
+77 -38
View File
@@ -1,8 +1,10 @@
import DOMPurify from "dompurify";
import { marked } from "marked";
import { ChevronLeft, ChevronRight, PanelLeft } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react";
import { apiRaw } from "../api/client";
import { useBookmarks } from "../components/Bookmarks";
import { BookmarksTab } from "../components/Bookmarks";
import { ReaderNav, type NavTab } from "../components/reader-nav";
import { btn } from "../components/ui";
import { chapterText, seekChapter, splitChapters } from "../lib/chapters";
import { decodeText } from "../lib/encoding";
@@ -134,7 +136,7 @@ export default function TextReader(props: ReaderProps) {
function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
const boxRef = useRef<HTMLDivElement>(null);
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
const bm = useBookmarks({ book, capture: saver.capture, seek: (l) => seek(Number(l.scrollFraction) || 0) });
const [nav, setNav] = useState(false);
const restored = useRef(false);
const pr = useReaderPrefs();
const [frac, setFrac] = useState(0);
@@ -183,8 +185,34 @@ function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
dangerouslySetInnerHTML={{ __html: html ?? "" }}
/>
</div>
{chrome.on && <ReaderSheet pr={pr} frac={frac} onSeek={seek}>{bm.btn}</ReaderSheet>}
{bm.panel}
{chrome.on && (
<ReaderSheet pr={pr} frac={frac} onSeek={seek}>
<button
className="rd-btn inline-flex items-center gap-1.5"
aria-expanded={nav}
aria-controls="reader-nav"
onClick={() => setNav((v) => !v)}
>
<PanelLeft size={14} /> 导航
</button>
</ReaderSheet>
)}
{nav && (
<ReaderNav
tab="bm"
onClose={() => setNav(false)}
bookmarks={
<BookmarksTab
book={book}
capture={saver.capture}
seek={(l) => {
seek(Number(l.scrollFraction) || 0);
setNav(false);
}}
/>
}
/>
)}
</div>
);
}
@@ -199,14 +227,9 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
const activeRow = useRef<HTMLButtonElement>(null);
const [ci, setCi] = useState(0);
const [frac, setFrac] = useState(0);
const [toc, setToc] = useState(false);
const [nav, setNav] = useState<null | NavTab>(null);
const pr = useReaderPrefs();
const { text, err, retry } = useFileText(book.file_url ?? "");
const bm = useBookmarks({
book,
capture: saver.capture,
seek: (l) => goChapter(Number(l.ch) || 0, Number(l.scrollFraction) || 0),
});
const chapters = useMemo(() => (text == null ? null : splitChapters(text)), [text]);
@@ -238,8 +261,8 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
}, [ci, chapters]);
useEffect(() => {
if (toc) activeRow.current?.scrollIntoView({ block: "center" });
}, [toc]);
if (nav === "toc") activeRow.current?.scrollIntoView({ block: "center" });
}, [nav]);
function goChapter(i: number, intra = 0) {
if (!chapters) return;
@@ -287,39 +310,55 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
</div>
{chrome.on && (
<ReaderSheet pr={pr} frac={total} onSeek={seek}>
{bm.btn}
<button
className="rd-btn inline-flex items-center gap-1.5"
aria-expanded={!!nav}
aria-controls="reader-nav"
onClick={() => setNav(nav ? null : chapters.length > 1 ? "toc" : "bm")}
>
<PanelLeft size={14} /> 导航
</button>
{chapters.length > 1 && (
<>
<button
className="rd-btn"
aria-expanded={toc}
aria-controls="toc-panel"
onClick={() => setToc((v) => !v)}
>
目录
</button>
<span className="text-xs opacity-60 tabular-nums">
{ci + 1}/{chapters.length}
</span>
<button className="rd-btn" disabled={ci === 0} onClick={() => goChapter(ci - 1)}>
← 上一章
<button
className="rd-btn inline-flex items-center gap-0.5"
disabled={ci === 0}
onClick={() => goChapter(ci - 1)}
>
<ChevronLeft size={14} /> 上一章
</button>
<button className="rd-btn" disabled={ci === chapters.length - 1} onClick={() => goChapter(ci + 1)}>
下一章 →
<button
className="rd-btn inline-flex items-center gap-0.5"
disabled={ci === chapters.length - 1}
onClick={() => goChapter(ci + 1)}
>
下一章 <ChevronRight size={14} />
</button>
</>
)}
</ReaderSheet>
)}
{/* ponytail: 目录一次渲染全部章节,数千章级书若卡顿再上虚拟列表 */}
{toc && chapters.length > 1 && (
<>
<div className="fixed inset-0 z-10" aria-hidden="true" onClick={() => setToc(false)} />
<nav
id="toc-panel"
aria-label="章节目录"
className="rd-divider absolute inset-y-0 left-0 z-20 w-64 overflow-y-auto border-r bg-[var(--rd-bg)] p-2 shadow-2xl"
>
{nav && (
<ReaderNav
tab={chapters.length > 1 ? nav : "bm"}
onTab={setNav}
onClose={() => setNav(null)}
bookmarks={
<BookmarksTab
book={book}
capture={saver.capture}
seek={(l) => {
goChapter(Number(l.ch) || 0, Number(l.scrollFraction) || 0);
setNav(null);
}}
/>
}
toc={
/* ponytail: 目录一次渲染全部章节,数千章级书若卡顿再上虚拟列表 */
chapters.length > 1 && (
<ul className="space-y-0.5">
{chapters.map((c, i) => (
<li key={i}>
@@ -328,7 +367,7 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
onClick={() => {
goChapter(i);
setToc(false);
setNav(null);
}}
>
{i + 1}. {c.title}
@@ -336,10 +375,10 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
</li>
))}
</ul>
</nav>
</>
)
}
/>
)}
{bm.panel}
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { formatSize } from "../src/lib/format";
describe("formatSize", () => {
it("renders human sizes across units", () => {
expect(formatSize(0)).toBe("");
expect(formatSize(512)).toBe("512 B");
expect(formatSize(2048)).toBe("2.0 KB");
expect(formatSize(15 * 1024 * 1024)).toBe("15 MB");
expect(formatSize(3.2 * 1024 ** 3)).toBe("3.2 GB");
});
it("returns empty for non-positive and keeps one decimal below 10", () => {
expect(formatSize(-1)).toBe("");
expect(formatSize(1024)).toBe("1.0 KB");
expect(formatSize(10 * 1024)).toBe("10 KB");
});
});
+25
View File
@@ -0,0 +1,25 @@
import { expect, it } from "vitest";
import type { Book } from "../src/api/types";
import { sections, sortBooks } from "../src/lib/shelf";
const b = (over: Partial<Book>): Book =>
({ id: 1, path: "p", title: "t", format: "cbz", size: 1, pages: 1, percent: 0, added_at: "2026-01-01T00:00:00Z", library_id: 1, state: "ready", cover_url: "", page_url_fmt: "", pages_url: "", error: "", ...over }) as Book;
it("sortBooks: added 新→旧 / title zh 本地化 / progress 高→低且并列按 added", () => {
const old = b({ id: 1, added_at: "2026-01-01T00:00:00Z", title: "b" });
const nw = b({ id: 2, added_at: "2026-02-01T00:00:00Z", title: "a" });
expect(sortBooks([old, nw], "added").map((x) => x.id)).toEqual([2, 1]);
expect(sortBooks([old, nw], "title").map((x) => x.id)).toEqual([2, 1]);
expect(sortBooks([b({ id: 3, percent: 0.2 }), b({ id: 4, percent: 0.9 })], "progress").map((x) => x.id)).toEqual([4, 3]);
const tie = [b({ id: 5, percent: 0.5, added_at: "2026-01-01T00:00:00Z" }), b({ id: 6, percent: 0.5, added_at: "2026-03-01T00:00:00Z" })];
expect(sortBooks(tie, "progress").map((x) => x.id)).toEqual([6, 5]);
});
it("sections: flat 单段无标题;dir 按目录分组且组内排序、根目录置顶", () => {
const books = [b({ id: 1, path: "x/a.cbz" }), b({ id: 2, path: "b.cbz", title: "B" }), b({ id: 3, path: "x/c.cbz", title: "A" })];
// 容器 Intl zh 实测序 A < B < t (localeCompare("zh")),故 title 升序为 id3,id2,id1
expect(sections(books, "title", "flat")).toEqual([{ label: "", books: [books[2], books[1], books[0]] }]);
const g = sections(books, "title", "dir");
expect(g.map((s) => s.label)).toEqual(["", "x"]);
expect(g[1].books.map((x) => x.title)).toEqual(["A", "t"]); // x 组内按 title 排序
});
+33 -1
View File
@@ -1,5 +1,5 @@
import { expect, it } from "vitest";
import { PageHeights } from "../src/lib/virt";
import { chapterIndexAt, chapterWin, PageHeights } from "../src/lib/virt";
it("估算高度:offset/total/pageAt/range(带 overscan、夹边)", () => {
const ph = new PageHeights(10, 100);
@@ -37,3 +37,35 @@ it("n=0 时一切归空", () => {
expect(ph.range(0, 100, 2)).toEqual([0, 0]);
expect(ph.set(0, 50)).toBe(0);
});
const CH4 = [{ start: 0 }, { start: 10 }, { start: 20 }, { start: 30 }];
it("chapterIndexAt:无章/单章恒 0;页号归章;首章 start 之前的散页归 0", () => {
expect(chapterIndexAt(null, 7)).toBe(0);
expect(chapterIndexAt([{ start: 0 }], 7)).toBe(0);
expect(chapterIndexAt(CH4, 0)).toBe(0);
expect(chapterIndexAt(CH4, 9)).toBe(0);
expect(chapterIndexAt(CH4, 10)).toBe(1);
expect(chapterIndexAt(CH4, 999)).toBe(3);
expect(chapterIndexAt([{ start: 5 }, { start: 10 }], 3)).toBe(0); // 根散页
});
it("chapterWin:扁平/连读 = 全书窗口", () => {
expect(chapterWin(null, 40, 0, false, 2)).toEqual({ start: 0, end: 40, mountEnd: 40 });
expect(chapterWin(CH4, 40, 2, true, 2)).toEqual({ start: 0, end: 40, mountEnd: 40 });
});
it("chapterWin:锁章窗口 = 本章;mountEnd 按预读章数延伸、夹到 count", () => {
expect(chapterWin(CH4, 40, 1, false, 0)).toEqual({ start: 10, end: 20, mountEnd: 20 });
expect(chapterWin(CH4, 40, 1, false, 1)).toEqual({ start: 10, end: 20, mountEnd: 30 });
expect(chapterWin(CH4, 40, 1, false, 2)).toEqual({ start: 10, end: 20, mountEnd: 40 });
expect(chapterWin(CH4, 40, 1, false, 3)).toEqual({ start: 10, end: 20, mountEnd: 40 }); // 越界夹住
expect(chapterWin(CH4, 40, 3, false, 2)).toEqual({ start: 30, end: 40, mountEnd: 40 }); // 末章
});
it("chapterWin:ci 越界夹边;首章窗口含首章 start 之前的散页;prefetch 负数按 0", () => {
expect(chapterWin(CH4, 40, 9, false, 1)).toEqual({ start: 30, end: 40, mountEnd: 40 });
expect(chapterWin(CH4, 40, -2, false, 1)).toEqual({ start: 0, end: 10, mountEnd: 20 });
expect(chapterWin([{ start: 5 }, { start: 10 }], 20, 0, false, 0)).toEqual({ start: 0, end: 10, mountEnd: 10 });
expect(chapterWin(CH4, 40, 1, false, -1)).toEqual({ start: 10, end: 20, mountEnd: 20 });
});
+1
View File
@@ -12,6 +12,7 @@
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true,
"paths": { "@/*": ["./src/*"] },
"types": ["vite/client", "vite-plugin-pwa/client"]
},
"include": ["src", "test"]
+3 -2
View File
@@ -4,6 +4,7 @@ import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
export default defineConfig({
resolve: { alias: { "@": "/src" } },
plugins: [
react(),
tailwindcss(),
@@ -14,8 +15,8 @@ export default defineConfig({
name: "BookLib 个人书库",
short_name: "BookLib",
description: "个人书库/漫画库在线阅读",
theme_color: "#0c0a09",
background_color: "#0c0a09",
theme_color: "#fafaf9",
background_color: "#fafaf9",
display: "standalone",
icons: [{ src: "icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" }],
},
+1
View File
@@ -1,5 +1,6 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: { alias: { "@": new URL("./src", import.meta.url).pathname } },
test: { include: ["test/**/*.test.ts", "test/**/*.test.tsx"], passWithNoTests: true },
});