Compare commits
35
Commits
02ac3e5f7f
...
90b39d4f97
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90b39d4f97 | ||
|
|
fd227c1f00 | ||
|
|
3178c2802f | ||
|
|
472e58bec9 | ||
|
|
d2c852d6f0 | ||
|
|
9c2b7e2074 | ||
|
|
65e07a0890 | ||
|
|
48161ea1ff | ||
|
|
4f4c7e5434 | ||
|
|
6fbe3b0794 | ||
|
|
7e0847815d | ||
|
|
7727baa25b | ||
|
|
dcaf502de6 | ||
|
|
273f35a42f | ||
|
|
b1c0db6c03 | ||
|
|
d9e9e95ece | ||
|
|
52e95a48d8 | ||
|
|
7957e5cee5 | ||
|
|
913f3286bb | ||
|
|
03bc537308 | ||
|
|
87b7545a94 | ||
|
|
ee676c2d2e | ||
|
|
b8a5b3deb6 | ||
|
|
2721df8ea9 | ||
|
|
e5dacc0a77 | ||
|
|
60c4ccf26f | ||
|
|
f63b639f64 | ||
|
|
cd251b940c | ||
|
|
bba82e5529 | ||
|
|
497dc16604 | ||
|
|
37ddff89a5 | ||
|
|
a39ddf7b57 | ||
|
|
ec3f2c587e | ||
|
|
a43b73dcef | ||
|
|
32b9d3a136 |
@@ -1,3 +1,5 @@
|
||||
.env
|
||||
.superpowers/
|
||||
tasks/
|
||||
.playwright*/
|
||||
frontend/tsconfig.tsbuildinfo
|
||||
|
||||
+33
-4
@@ -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,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,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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": { "config": "", "css": "src/index.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" },
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }
|
||||
}
|
||||
+10
-1
@@ -3,8 +3,17 @@
|
||||
<head>
|
||||
<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>
|
||||
|
||||
Generated
+1815
-115
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
+30
-43
@@ -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,49 +13,34 @@ import AdminUsers from "./pages/admin/Users";
|
||||
export default function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Shelf />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/book/:id"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Reader />
|
||||
</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>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
element={
|
||||
<RequireAuth>
|
||||
<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={
|
||||
<RequireAuth>
|
||||
<Reader />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<Toaster />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,107 +52,90 @@ 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"
|
||||
placeholder="备注(可空)"
|
||||
maxLength={500}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !add.isPending) add.mutate();
|
||||
}}
|
||||
/>
|
||||
<button className="rd-btn shrink-0" disabled={add.isPending} onClick={() => add.mutate()}>
|
||||
加
|
||||
</button>
|
||||
</div>
|
||||
<ul className="min-h-0 flex-1 space-y-0.5 overflow-y-auto">
|
||||
{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>
|
||||
)}
|
||||
{items.map((b) => (
|
||||
<li key={b.id}>
|
||||
{editId === b.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<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"
|
||||
maxLength={500}
|
||||
value={editVal}
|
||||
onChange={(e) => setEditVal(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !patch.isPending) patch.mutate({ id: b.id, note: editVal.trim() });
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="rd-btn shrink-0"
|
||||
disabled={patch.isPending}
|
||||
onClick={() => patch.mutate({ id: b.id, note: editVal.trim() })}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
<button className="rd-btn shrink-0" onClick={() => setEditId(null)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<button
|
||||
className="rd-row min-w-0 flex-1 truncate text-left"
|
||||
title={b.note || undefined}
|
||||
onClick={() => {
|
||||
seek(b.locator);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<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>}
|
||||
</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={() => {
|
||||
setEditId(b.id);
|
||||
setEditVal(b.note);
|
||||
}}
|
||||
>
|
||||
✎
|
||||
</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)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<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"
|
||||
placeholder="备注(可空)"
|
||||
maxLength={500}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !add.isPending) 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="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>
|
||||
)}
|
||||
{items.map((b) => (
|
||||
<li key={b.id}>
|
||||
{editId === b.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<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"
|
||||
maxLength={500}
|
||||
value={editVal}
|
||||
onChange={(e) => setEditVal(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !patch.isPending) patch.mutate({ id: b.id, note: editVal.trim() });
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
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 inline-flex shrink-0 items-center" aria-label="取消编辑" onClick={() => setEditId(null)}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<button
|
||||
className="rd-row min-w-0 flex-1 truncate text-left"
|
||||
title={b.note || undefined}
|
||||
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>}
|
||||
</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={() => {
|
||||
setEditId(b.id);
|
||||
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>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
return { btn, panel };
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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="封面加载失败,重试">
|
||||
重试
|
||||
</button>
|
||||
<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 ?? ""}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Check, Monitor, Moon, Sun } from "lucide-react";
|
||||
import { useTheme, type Theme } from "./theme";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const ITEMS: { v: Theme; label: string; icon: typeof Sun }[] = [
|
||||
{ v: "light", label: "浅色", icon: Sun },
|
||||
{ v: "dark", label: "深色", icon: Moon },
|
||||
{ v: "system", label: "跟随系统", icon: Monitor },
|
||||
];
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label="切换主题">
|
||||
{theme === "dark" ? <Moon className="size-4" /> : theme === "light" ? <Sun className="size-4" /> : <Monitor className="size-4" />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{ITEMS.map(({ v, label, icon: Icon }) => (
|
||||
<DropdownMenuItem key={v} onSelect={() => setTheme(v)}>
|
||||
<Icon />
|
||||
{label}
|
||||
{theme === v && <Check className="ml-auto size-4" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
export type Theme = "system" | "light" | "dark";
|
||||
const KEY = "ui.theme";
|
||||
const SYS = "(prefers-color-scheme: dark)";
|
||||
|
||||
function read(): Theme {
|
||||
try {
|
||||
const v = localStorage.getItem(KEY);
|
||||
return v === "light" || v === "dark" || v === "system" ? v : "system";
|
||||
} catch {
|
||||
return "system";
|
||||
}
|
||||
}
|
||||
|
||||
function apply(t: Theme) {
|
||||
const dark = t === "dark" || (t === "system" && matchMedia(SYS).matches);
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
}
|
||||
|
||||
const Ctx = createContext<{ theme: Theme; setTheme: (t: Theme) => void } | null>(null);
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, set] = useState<Theme>(read);
|
||||
useEffect(() => {
|
||||
apply(theme);
|
||||
try {
|
||||
localStorage.setItem(KEY, theme);
|
||||
} catch {
|
||||
/* 隐私模式:仅本次会话生效 */
|
||||
}
|
||||
if (theme !== "system") return;
|
||||
const mq = matchMedia(SYS);
|
||||
const on = () => apply("system");
|
||||
mq.addEventListener("change", on);
|
||||
return () => mq.removeEventListener("change", on);
|
||||
}, [theme]);
|
||||
const setTheme = useCallback((t: Theme) => set(t), []);
|
||||
const value = useMemo(() => ({ theme, setTheme }), [theme, setTheme]);
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const v = useContext(Ctx);
|
||||
if (!v) throw new Error("useTheme outside ThemeProvider");
|
||||
return v;
|
||||
}
|
||||
@@ -1,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}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
||||
size?: "default" | "sm"
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn(
|
||||
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Action
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Cancel
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,91 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,187 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-accent", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -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
@@ -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 作用域) */
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes <= 0) return "";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let i = 0;
|
||||
let n = bytes;
|
||||
for (; n >= 1024 && i < units.length - 1; i++) n /= 1024;
|
||||
return `${n >= 10 || i === 0 ? Math.round(n) : n.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Book } from "../api/types";
|
||||
import { groupByDir } from "./group";
|
||||
|
||||
export type SortKey = "added" | "title" | "progress";
|
||||
export const SORT_LABEL: Record<SortKey, string> = { added: "最近入库", title: "标题", progress: "阅读进度" };
|
||||
export type GroupMode = "flat" | "dir";
|
||||
|
||||
export function sortBooks(books: Book[], key: SortKey): Book[] {
|
||||
const a = [...books];
|
||||
if (key === "title") return a.sort((x, y) => x.title.localeCompare(y.title, "zh"));
|
||||
if (key === "progress") return a.sort((x, y) => y.percent - x.percent || (x.added_at < y.added_at ? 1 : x.added_at > y.added_at ? -1 : 0));
|
||||
return a.sort((x, y) => (x.added_at < y.added_at ? 1 : x.added_at > y.added_at ? -1 : 0));
|
||||
}
|
||||
|
||||
/** 页面分组视图:flat 一段无标题;dir 沿用「根目录置顶、目录名 locale 排序」。 */
|
||||
export function sections(books: Book[], key: SortKey, mode: GroupMode): { label: string; books: Book[] }[] {
|
||||
if (mode === "flat") return [{ label: "", books: sortBooks(books, key) }];
|
||||
return groupByDir(sortBooks(books, key)).map((g) => ({ label: g.dir, books: g.books }));
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { GroupMode, SortKey } from "./shelf";
|
||||
|
||||
export const SORTS: SortKey[] = ["added", "title", "progress"];
|
||||
export const GROUPS: { v: GroupMode; label: string }[] = [
|
||||
{ v: "flat", label: "平铺" },
|
||||
{ v: "dir", label: "按目录" },
|
||||
];
|
||||
export const LIB0 = 0;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
{busy ? "登录中…" : "登录"}
|
||||
</button>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
+250
-149
@@ -1,88 +1,118 @@
|
||||
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>
|
||||
{book.state === "error" && (
|
||||
<span
|
||||
className="absolute left-1 top-1 rounded bg-red-900 px-1 text-[10px] text-red-100"
|
||||
title={book.error}
|
||||
>
|
||||
损坏
|
||||
<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 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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
{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();
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{continueRows.length > 0 && (
|
||||
<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) => {
|
||||
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}`}>
|
||||
<p className="truncate">{r.title || "已删除的书"}</p>
|
||||
<p>{Math.round(r.percent * 100)}%</p>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<BookCard
|
||||
key={`${r.library_id}/${r.path}`}
|
||||
book={b}
|
||||
onOpen={() => nav(`/book/${b.id}`)}
|
||||
onDelete={() => del.mutate(b.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
{!booksQ.isPending && groups.length === 0 && !booksQ.isError && (
|
||||
<p className="text-sm text-stone-500">此库暂无书目(放入文件后等待扫描或点「扫描此库」)。</p>
|
||||
)}
|
||||
{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)} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
<Button variant="ghost" size="icon" aria-label="刷新书目" onClick={() => qc.invalidateQueries({ queryKey: ["books"] })}>
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{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-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 (
|
||||
<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 className="mt-1 text-[10px]">进度已保留</p>
|
||||
</li>
|
||||
);
|
||||
const pct = Math.round(r.percent * 100);
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{booksQ.isError && (
|
||||
<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 && !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>
|
||||
)}
|
||||
|
||||
{!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>
|
||||
))}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()}>
|
||||
{create.isPending ? "创建中…" : "建库"}
|
||||
</button>
|
||||
</form>
|
||||
<div className="flex max-w-2xl flex-col gap-4">
|
||||
{(libsQ.data ?? []).map((l) => (
|
||||
<div key={l.id} className={`${card} p-4`}>
|
||||
<div className="mb-1 flex items-baseline gap-3">
|
||||
<span className="font-medium">{l.name}</span>
|
||||
<span className="text-xs text-stone-500">#{l.id}</span>
|
||||
</div>
|
||||
<p className="mb-3 text-xs break-all text-stone-500">{l.root_path}</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button className={btn} disabled={scan.isPending} onClick={() => scan.mutate(l.id)}>
|
||||
{scan.isPending ? "扫描中…" : "扫描"}
|
||||
</button>
|
||||
<input
|
||||
ref={(el) => {
|
||||
fileRefs.current[l.id] = el;
|
||||
}}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".cbz,.zip,.pdf,.epub,.txt,.md"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
void onFiles(l.id, e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<button className={btn} onClick={() => fileRefs.current[l.id]?.click()}>
|
||||
上传文件
|
||||
</button>
|
||||
<span className="text-xs text-stone-600">白名单:cbz/zip/pdf/epub/txt/md</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(libsQ.data ?? []).length === 0 && !libsQ.isPending && (
|
||||
<p className="text-sm text-stone-500">还没有库,用上方表单创建。</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="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>
|
||||
</form>
|
||||
{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>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<p className="mt-3 text-xs text-muted-foreground">上传白名单:cbz/zip/pdf/epub/txt/md · 也可直接拖文件进行</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
{create.isPending ? "创建中…" : "创建"}
|
||||
</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>
|
||||
<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>
|
||||
</form>
|
||||
{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);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
+238
-134
@@ -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();
|
||||
|
||||
function measure(i: number, h: number) {
|
||||
const el = boxRef.current;
|
||||
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);
|
||||
// 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="漫画阅读器,点按左右两侧翻页,点按中间显示工具栏"
|
||||
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);
|
||||
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={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) => {
|
||||
<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="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; // 适高模式定高一页一屏,无需量高
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
<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,34 +419,43 @@ 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"
|
||||
>
|
||||
<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={() => {
|
||||
jump(c.start);
|
||||
setToc(false);
|
||||
}}
|
||||
>
|
||||
{i + 1}. {c.title}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</>
|
||||
{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}>
|
||||
<button
|
||||
ref={i === ci ? activeRow : undefined}
|
||||
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
|
||||
onClick={() => {
|
||||
goPage(c.start);
|
||||
setNav(null);
|
||||
}}
|
||||
>
|
||||
{i + 1}. {c.title}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{bm.panel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,59 +310,75 @@ 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"
|
||||
>
|
||||
<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={() => {
|
||||
goChapter(i);
|
||||
setToc(false);
|
||||
}}
|
||||
>
|
||||
{i + 1}. {c.title}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</>
|
||||
{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}>
|
||||
<button
|
||||
ref={i === ci ? activeRow : undefined}
|
||||
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
|
||||
onClick={() => {
|
||||
goChapter(i);
|
||||
setNav(null);
|
||||
}}
|
||||
>
|
||||
{i + 1}. {c.title}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{bm.panel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatSize } from "../src/lib/format";
|
||||
|
||||
describe("formatSize", () => {
|
||||
it("renders human sizes across units", () => {
|
||||
expect(formatSize(0)).toBe("");
|
||||
expect(formatSize(512)).toBe("512 B");
|
||||
expect(formatSize(2048)).toBe("2.0 KB");
|
||||
expect(formatSize(15 * 1024 * 1024)).toBe("15 MB");
|
||||
expect(formatSize(3.2 * 1024 ** 3)).toBe("3.2 GB");
|
||||
});
|
||||
|
||||
it("returns empty for non-positive and keeps one decimal below 10", () => {
|
||||
expect(formatSize(-1)).toBe("");
|
||||
expect(formatSize(1024)).toBe("1.0 KB");
|
||||
expect(formatSize(10 * 1024)).toBe("10 KB");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { expect, it } from "vitest";
|
||||
import type { Book } from "../src/api/types";
|
||||
import { sections, sortBooks } from "../src/lib/shelf";
|
||||
|
||||
const b = (over: Partial<Book>): Book =>
|
||||
({ id: 1, path: "p", title: "t", format: "cbz", size: 1, pages: 1, percent: 0, added_at: "2026-01-01T00:00:00Z", library_id: 1, state: "ready", cover_url: "", page_url_fmt: "", pages_url: "", error: "", ...over }) as Book;
|
||||
|
||||
it("sortBooks: added 新→旧 / title zh 本地化 / progress 高→低且并列按 added", () => {
|
||||
const old = b({ id: 1, added_at: "2026-01-01T00:00:00Z", title: "b" });
|
||||
const nw = b({ id: 2, added_at: "2026-02-01T00:00:00Z", title: "a" });
|
||||
expect(sortBooks([old, nw], "added").map((x) => x.id)).toEqual([2, 1]);
|
||||
expect(sortBooks([old, nw], "title").map((x) => x.id)).toEqual([2, 1]);
|
||||
expect(sortBooks([b({ id: 3, percent: 0.2 }), b({ id: 4, percent: 0.9 })], "progress").map((x) => x.id)).toEqual([4, 3]);
|
||||
const tie = [b({ id: 5, percent: 0.5, added_at: "2026-01-01T00:00:00Z" }), b({ id: 6, percent: 0.5, added_at: "2026-03-01T00:00:00Z" })];
|
||||
expect(sortBooks(tie, "progress").map((x) => x.id)).toEqual([6, 5]);
|
||||
});
|
||||
|
||||
it("sections: flat 单段无标题;dir 按目录分组且组内排序、根目录置顶", () => {
|
||||
const books = [b({ id: 1, path: "x/a.cbz" }), b({ id: 2, path: "b.cbz", title: "B" }), b({ id: 3, path: "x/c.cbz", title: "A" })];
|
||||
// 容器 Intl zh 实测序 A < B < t (localeCompare("zh")),故 title 升序为 id3,id2,id1
|
||||
expect(sections(books, "title", "flat")).toEqual([{ label: "", books: [books[2], books[1], books[0]] }]);
|
||||
const g = sections(books, "title", "dir");
|
||||
expect(g.map((s) => s.label)).toEqual(["", "x"]);
|
||||
expect(g[1].books.map((x) => x.title)).toEqual(["A", "t"]); // x 组内按 title 排序
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"paths": { "@/*": ["./src/*"] },
|
||||
"types": ["vite/client", "vite-plugin-pwa/client"]
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
|
||||
@@ -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,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 },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user