diff --git a/.gitignore b/.gitignore index fd343e5..46229b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .env .superpowers/ tasks/ +.playwright*/ +frontend/tsconfig.tsbuildinfo diff --git a/docs/CHANGELOG_web.md b/docs/CHANGELOG_web.md index 335e890..6d7f398 100644 --- a/docs/CHANGELOG_web.md +++ b/docs/CHANGELOG_web.md @@ -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. diff --git a/docs/superpowers/plans/2026-09-08-cbz-chapter-continuous-reading.md b/docs/superpowers/plans/2026-09-08-cbz-chapter-continuous-reading.md new file mode 100644 index 0000000..3ea8e7f --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-cbz-chapter-continuous-reading.md @@ -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 ( +
+ 本页加载失败 + +
+ ); + if (!src) + return
; + return ( + { + 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(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(null); + const [width, setWidth] = useState(480); + const [vh, setVh] = useState(600); + const [top, setTop] = useState(0); + const [scrub, setScrub] = useState(null); // 页滑条拖拽中的临时值 + const [, bump] = useState(0); + const restored = useRef(false); + const scrollRaf = useRef(0); + const idle = useRef>(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(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(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) { + 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
页索引加载中…
; + if (countQ.isError || !book.page_url_fmt) + return ( +
+ 无法解析页索引:{countQ.error instanceof Error ? countQ.error.message : "缺 page_url_fmt"} +
+ ); + + 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 ( +
{ + if (e.key === "ArrowRight" || e.key === "PageDown") turnScreen(1); + else if (e.key === "ArrowLeft" || e.key === "PageUp") turnScreen(-1); + else return; + e.preventDefault(); + }} + > +
+
+ {Array.from({ length: Math.max(0, b - a) }, (_, k) => { + const i = a + k; + return ( +
+ measure(i, h)} /> +
+ ); + })} + {locked && ( +
+ {w.end >= count ? ( + — 全书完 — + ) : ( + + )} +
+ )} + {locked && w.mountEnd > w.end && ( + // 预读层:挂载+解码但不参与滚动几何(height:0 + overflow:hidden 裁掉全部子元素) + + )} +
+
+ {chrome.on ? ( +
+
+ { + 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="页码跳转" + /> + + {(scrub ?? cur) + 1}/{count} · {Math.round((((scrub ?? cur) + 1) / Math.max(1, count)) * 100)}% + +
+
+ + {locked && ( + + )} + {bm.btn} + {chapters && ( + <> + + + {ci + 1}/{chapters.length} + + + + + )} +
+
+ ) : ( +
+ + {cur + 1}/{count} + + {Math.round(((cur + 1) / Math.max(1, count)) * 100)}% +
+ )} + {toc && chapters && ( + <> + + ); +} +``` + +- [ ] **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 使用一致。 diff --git a/docs/superpowers/plans/2026-09-08-modern-redesign-phase1.md b/docs/superpowers/plans/2026-09-08-modern-redesign-phase1.md new file mode 100644 index 0000000..e2ffb3a --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-modern-redesign-phase1.md @@ -0,0 +1,1390 @@ +# 全站现代化重构 Phase 1(shadcn/radix + 双主题 + 应用壳 + 公共表面)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:** 用 shadcn/radix 从零建立 light/dark 双主题设计系统,并以此重做登录、应用壳、书架、admin 两页;`/book/:id`(Reader.tsx + 四 reader)与全部翻页/阅读逻辑本期零改动。 + +**Architecture:** 语义 CSS 变量(shadcn 标准集)+ `@theme inline` 喂给 Tailwind v4;shadcn CLI 生成基础原语进 `src/components/ui/`;`ThemeProvider`(system/light/dark,`localStorage("ui.theme")`,index.html 内联脚本防 FOUC);`AppShell` 布局路由(桌面侧栏/平板图标栏/手机底 Tab),书架筛选状态入 URLSearchParams;纯逻辑(排序/分组视图/formatSize)下沉 `lib/` 并配 vitest。 + +**Tech Stack:** React 19.2, Tailwind v4.3, react-router 7.18, radix-ui(统一包), cva/clsx/tailwind-merge, lucide-react, tw-animate-css, vitest。 + +Spec: `docs/superpowers/specs/2026-09-08-modern-redesign-shadcn-design.md` + +## Global Constraints + +- 分支 `feat/ui-redesign`;每个任务一个 commit。 +- 一切 npm/vitest/build 在容器内:`docker exec -w /app booklib_web_1 npm run -s check`(tsc+vitest+build);依赖安装也在容器内(`npm i`),提交 `package.json` + `package-lock.json`。 +- 公共表面(登录/壳/书架/admin/Toaster/Cover)**禁止裸 `stone-*`/`amber-*`**,一律语义 token 类(bg-background/text-muted-foreground 等)。例外:`components/ui.ts` 遗留三件套、`pages/Reader.tsx`、`readers/*`、`components/Bookmarks.tsx`、`index.css` 的 `rd-*`/`fx-*`/`md-body` 段、`FORMAT_BADGE` 彩色类(贴在深色封面上)。 +- `/book/:id` 路由与其内部一切行为、`cbz-mode/cbz-continuous/cbz-prefetch/rd-*` 等 localStorage 键**不动**。 +- API 契约(`backend/cmd/webui/api`)与数据流不改;react-query key 语义不变(`["books", lib, q]`、`["progress"]`、`["libraries"]`、`["users"]`)。 +- `ui.ts` 收缩后只留 `btn`/`btnGhost`/`formatBadge`(+`FORMAT_BADGE`)——phase 2 前 `Reader.tsx`/`Cover.tsx` 依赖它们。 +- toast API `toast(kind, text)` 保持不变(全仓 30+ 调用点)。 +- 用户可见变更进 `docs/CHANGELOG_web.md`(英中相邻行,不同条目空行分隔)。 + +--- + +### Task 1: 依赖、别名与 shadcn 原语生成 + +**Files:** +- Modify: `frontend/package.json` + `frontend/package-lock.json`(容器内 npm i) +- Create: `frontend/components.json`, `frontend/src/lib/utils.ts` +- Modify: `frontend/tsconfig.json`, `frontend/vite.config.ts`, `frontend/vitest.config.ts` +- Create: `frontend/src/components/ui/*`(CLI 生成,提交入库) + +**Interfaces:** +- Consumes: 无 +- Produces: `@/* → src/*` 别名;`cn(...)` from `@/lib/utils`;shadcn 组件 `button input label card badge skeleton separator tooltip dropdown-menu dialog alert-dialog select table`(from `@/components/ui/`)。Task 2+ 全部消费。 + +- [ ] **Step 1: 容器内安装依赖** + +```bash +docker exec -w /app booklib_web_1 npm i radix-ui class-variance-authority clsx tailwind-merge lucide-react tw-animate-css +``` +Expected: exit 0,lockfile 更新。 + +- [ ] **Step 2: 别名配置** + +`frontend/tsconfig.json` 的 `compilerOptions` 追加: + +```json + "baseUrl": ".", + "paths": { "@/*": ["src/*"] } +``` + +`frontend/vite.config.ts` 的 `defineConfig({...})` 顶层追加(与 `plugins` 平级): + +```ts + resolve: { alias: { "@": "/src" } }, +``` + +`frontend/vitest.config.ts` 整文件替换: + +```ts +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 }, +}); +``` + +- [ ] **Step 3: 写 `frontend/components.json` 与 `frontend/src/lib/utils.ts`** + +```json +{ + "$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" } +} +``` + +```ts +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} +``` + +- [ ] **Step 4: 生成 shadcn 原语并提交** + +```bash +docker exec -w /app booklib_web_1 npx --yes shadcn@2 add button input label card badge skeleton separator tooltip dropdown-menu dialog alert-dialog select table -y +``` +Expected: `src/components/ui/` 下出现 12 个组件文件;CLI 若报找不到 Tailwind 配置,确认 cwd=/app 且 components.json 存在后重试。 + +- [ ] **Step 5: 校验并 commit** + +```bash +docker exec -w /app booklib_web_1 npm run -s check # 此时 body 仍暗色、组件未用,必须绿 +git add frontend/package.json frontend/package-lock.json frontend/components.json frontend/tsconfig.json frontend/vite.config.ts frontend/vitest.config.ts frontend/src/lib/utils.ts frontend/src/components/ui +git commit -m "feat(ui): shadcn/radix toolchain — deps, @ alias, cn, generated primitives" +``` + +--- + +### Task 2: 设计 token + 主题系统 + Toaster/Cover 换肤 + +**Files:** +- Modify: `frontend/src/index.css`(头部重写,`rd-*`/`fx-*`/`md-body`/`@media` 段原样保留) +- Create: `frontend/src/components/theme.tsx` +- Modify: `frontend/index.html`, `frontend/src/App.tsx`, `frontend/vite.config.ts`(manifest 两色) +- Modify: `frontend/src/components/Toaster.tsx`, `frontend/src/components/Cover.tsx` + +**Interfaces:** +- Consumes: `@theme inline` 依赖 Task 1 的 tw-animate-css import 位与 `cn` +- Produces: `ThemeProvider` / `useTheme()`(`theme: "system"|"light"|"dark"` + `setTheme`)——Task 4 主题切换器消费;token 类名全集——后续所有 UI 消费。 + +- [ ] **Step 1: 替换 `index.css` 中从文件头到 `.fx-rise` 之前的内容 + 滚动条段**(保留 `@keyframes fx-rise` 起的全部阅读器/动画/markdown 段落不改) + +文件新头部: + +```css +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --radius: 0.65rem; + --background: oklch(0.985 0.001 80); + --foreground: oklch(0.22 0.01 70); + --card: oklch(0.995 0 0); + --card-foreground: oklch(0.22 0.01 70); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.22 0.01 70); + --primary: oklch(0.25 0.012 70); + --primary-foreground: oklch(0.98 0.002 80); + --secondary: oklch(0.95 0.003 80); + --secondary-foreground: oklch(0.32 0.01 70); + --muted: oklch(0.95 0.003 80); + --muted-foreground: oklch(0.55 0.008 70); + --accent: oklch(0.68 0.16 46); + --accent-foreground: oklch(0.985 0.002 80); + --destructive: oklch(0.58 0.21 27); + --border: oklch(0.91 0.004 80); + --input: oklch(0.91 0.004 80); + --ring: oklch(0.62 0.01 70); +} +.dark { + --background: oklch(0.19 0.006 80); + --foreground: oklch(0.95 0.003 80); + --card: oklch(0.23 0.006 80); + --card-foreground: oklch(0.95 0.003 80); + --popover: oklch(0.23 0.006 80); + --popover-foreground: oklch(0.95 0.003 80); + --primary: oklch(0.95 0.003 80); + --primary-foreground: oklch(0.22 0.01 70); + --secondary: oklch(0.28 0.006 80); + --secondary-foreground: oklch(0.95 0.003 80); + --muted: oklch(0.28 0.006 80); + --muted-foreground: oklch(0.68 0.006 80); + --accent: oklch(0.7 0.16 46); + --accent-foreground: oklch(0.19 0.006 80); + --destructive: oklch(0.65 0.19 25); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 14%); + --ring: oklch(0.6 0.01 70); +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); +} + +html, +body, +#root { + height: 100%; +} +body { + @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; + background-color: color-mix(in oklab, var(--foreground) 22%, transparent); +} +::-webkit-scrollbar-thumb:hover { + background-color: color-mix(in oklab, var(--foreground) 34%, transparent); +} +``` + +- [ ] **Step 2: 写 `frontend/src/components/theme.tsx`** + +```tsx +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 = useMemo(() => createContext<{ theme: Theme; setTheme: (t: Theme) => void } | null>(null), []); + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, set] = useState(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 {children}; +} + +export function useTheme() { + const v = useContext(Ctx); + if (!v) throw new Error("useTheme outside ThemeProvider"); + return v; +} +``` + +- [ ] **Step 3: `index.html` 防 FOUC + meta**:`` 内、`` 之前插入: + +```html + <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> +``` +并把 `meta name="theme-color" content="#0c0a09"` 改为 `content="#fafaf9"`。`vite.config.ts` 中 `theme_color`/`background_color` 同步改 `"#fafaf9"`。 + +- [ ] **Step 4: `App.tsx` 套 ThemeProvider**:`<AuthProvider>` 外包 `<ThemeProvider>`(import from `./components/theme`),其余不动。 + +- [ ] **Step 5: Toaster/Cover 换肤(props/exports 不变)** + +`Toaster.tsx`:import 改为 `import { CheckCircle2, XCircle } from "lucide-react";`,toast 容器类替换为: + +```tsx + 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" />}`。 + +`Cover.tsx`:删 `IconAlert` import,加 `import { TriangleAlert } from "lucide-react";`;类替换:占位底 `from-stone-800 to-stone-900` → `bg-muted`;字标 `text-stone-500` → `text-muted-foreground`;骨架 `bg-stone-800/80` → `animate-pulse bg-muted`;`img` 的 `bg-stone-900` → `bg-muted`;重试按钮内 `<IconAlert …/>` → `<TriangleAlert className="size-4" />`。`useAuthedImage`/`Cover` 签名不动。 + +- [ ] **Step 6: 校验 + commit** + +```bash +docker exec -w /app booklib_web_1 npm run -s check # 全绿(书架等仍是旧类名,允许) +git add -A frontend/src frontend/index.html frontend/vite.config.ts +git commit -m "feat(ui): semantic light/dark tokens, ThemeProvider with FOUC guard, token-restyle Toaster/Cover" +``` + +--- + +### Task 3: 纯逻辑下沉(lib/format + lib/shelf) + +**Files:** +- Create: `frontend/src/lib/format.ts`, `frontend/src/lib/shelf.ts` +- Modify: `frontend/src/components/ui.ts` +- Test: `frontend/test/format.test.ts`, `frontend/test/shelf.test.ts` + +**Interfaces:** +- Consumes: `Book` 类型(`../api/types`:字段 `id,path,title,format,size,pages,percent,added_at,library_id,state,cover_url,page_url_fmt,pages_url,error`)、`groupByDir`(`lib/group.ts` 不动) +- Produces: `formatSize(bytes): string`;`SortKey = "added" | "title" | "progress"`、`sortBooks(books, key): Book[]`、`GroupMode = "flat" | "dir"`、`sections(books, key, mode): { label: string; books: Book[] }[]`。Task 6/7 import。 + +- [ ] **Step 1: 写失败测试 `frontend/test/shelf.test.ts`** + +```ts +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" })]; + expect(sections(books, "title", "flat")).toEqual([{ label: "", books: [books[2], books[0], books[1]] }]); + const g = sections(books, "title", "dir"); + expect(g.map((s) => s.label)).toEqual(["", "x"]); + expect(g[1].books.map((x) => x.title)).toEqual(["A", "b"]); // 组内按 title 排序 +}); +``` + +(`title` 用例里 books[2] 是 id3 title"A"… 若断言顺序与实际 localeCompare 出入,以 `A<b<B` 的排序语义修正数组字面量。) + +- [ ] **Step 2: 跑测确认失败** + +Run: `docker exec -w /app booklib_web_1 npx vitest run test/shelf.test.ts` → FAIL(模块不存在)。 + +- [ ] **Step 3: 实现** + +`frontend/src/lib/format.ts`: + +```ts +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]}`; +} +``` + +`frontend/src/lib/shelf.ts`: + +```ts +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 })); +} +``` + +`components/ui.ts`:删除 `formatSize` 函数体,文件顶部加 `export { formatSize } from "../lib/format"; // deprecated: phase-2 随阅读器清除`;`pill/pillActive/card/input/btnPrimary` 保留(admin/Login 还引用,Task 5–7 迁移后于 Task 7 统一收缩)。 + +- [ ] **Step 4: 跑测通过 + commit** + +```bash +docker exec -w /app booklib_web_1 npx vitest run test/shelf.test.ts +git add frontend/src/lib/format.ts frontend/src/lib/shelf.ts frontend/src/components/ui.ts frontend/test/shelf.test.ts +git commit -m "feat(lib): shelf sort/section pure helpers + formatSize extraction, table tests" +``` + +--- + +### Task 4: AppShell + ThemeToggle + 路由嵌套 + +**Files:** +- Create: `frontend/src/components/app-shell.tsx`, `frontend/src/components/theme-toggle.tsx` +- Modify: `frontend/src/App.tsx`(路由改布局路由) + +**Interfaces:** +- Consumes: Task 2 `useTheme`;`useAuth`(`user,isAdmin,logout`);`api.listLibraries`(queryKey `["libraries"]` 与 Shelf 共享缓存);Task 1 ui 原语。 +- Produces: `<AppShell/>` 布局路由(含 `<Outlet/>`);URL 约定 `/?lib=<id>`(缺省=全部)——Task 6 Shelf 按此读取。 + +- [ ] **Step 1: 写 `theme-toggle.tsx`** + +```tsx +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="切换主题" aria-pressed="false"> + {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} onClick={() => setTheme(v)}> + <Icon /> + {label} + {theme === v && <Check className="ml-auto size-4" />} + </DropdownMenuItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + ); +} +``` + +- [ ] **Step 2: 写 `app-shell.tsx`** + +```tsx +import { useQuery } from "@tanstack/react-query"; +import { BookMarked, BookOpen, FolderCog, LogOut, Users } from "lucide-react"; +import { NavLink, Outlet, useLocation } from "react-router-dom"; +import { api } from "@/api/client"; +import { useAuth } from "@/auth/AuthContext"; +import { Button } from "@/components/ui/button"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { Separator } from "@/components/ui/separator"; +import { ThemeToggle } from "./theme-toggle"; +import { cn } from "@/lib/utils"; +import type { ReactNode } from "react"; + +function Brand({ compact }: { compact?: boolean }) { + return ( + <NavLink to="/" className="flex h-12 shrink-0 items-center gap-2 rounded-md px-3 focus-visible:outline-2 focus-visible:outline-offset-2"> + <span className="grid size-6 place-items-center rounded bg-accent text-accent-foreground"> + <BookMarked className="size-4" /> + </span> + {!compact && <span className="text-[15px] font-semibold tracking-tight">BookLib</span>} + </NavLink> + ); +} + +function NavItem({ to, icon, label, search }: { to: string; icon: ReactNode; label: string; search?: string }) { + return ( + <NavLink + to={to + (search ?? "")} + className={({ isActive }) => + cn( + "flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground", + isActive && "bg-secondary font-medium text-foreground", + ) + } + > + {icon} + <span className="hidden lg:inline group-rail:inline">{label}</span> + </NavLink> + ); +} + +export function AppShell() { + const { user, isAdmin, logout } = useAuth(); + const loc = useLocation(); + const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries }); + const libs = libsQ.data ?? []; + + const account = ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="gap-2 px-2"> + <span className="grid size-7 place-items-center rounded-full bg-accent/15 text-xs font-semibold text-accent"> + {Array.from(user?.username ?? "?")[0]?.toUpperCase()} + </span> + <span className="hidden max-w-28 truncate text-sm lg:inline">{user?.username}</span> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuLabel>{user?.username}{isAdmin ? " · admin" : ""}</DropdownMenuLabel> + <DropdownMenuSeparator /> + <DropdownMenuItem asChild> + <span className="cursor-pointer" onClick={logout}> + <LogOut /> 退出登录 + </span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ); + + return ( + <div className="flex h-full"> + {/* 桌面/平板侧栏;lg 全文标签,md 图标栏+悬浮标签 */} + <aside className="flex w-14 shrink-0 flex-col gap-1 border-r bg-card/60 p-2 md:group-rail lg:w-60"> + <Brand compact /> + <nav aria-label="主导航" className="flex flex-col gap-1"> + <NavItem to="/" icon={<BookOpen className="size-4 shrink-0" />} label="书架" /> + {isAdmin && ( + <> + <NavItem to="/admin/libraries" icon={<FolderCog className="size-4 shrink-0" />} label="库管理" /> + <NavItem to="/admin/users" icon={<Users className="size-4 shrink-0" />} label="用户管理" /> + </> + )} + </nav> + {libs.length > 0 && ( + <div className="mt-3 hidden min-h-0 flex-1 flex-col lg:flex"> + <p className="px-3 pb-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">书库</p> + <div className="min-h-0 flex-1 overflow-y-auto"> + <NavItem to="/" search="?lib=0" icon={<span className="size-1.5 shrink-0 rounded-full bg-muted-foreground/50" />} label="全部" /> + {libs.map((l) => ( + <NavItem key={l.id} to="/" search={`?lib=${l.id}`} icon={<span className="size-1.5 shrink-0 rounded-full bg-accent/60" />} label={l.name} /> + ))} + </div> + </div> + )} + <Separator className="my-2 hidden lg:block" /> + <div className="mt-auto hidden items-center justify-between px-1 lg:flex"> + <ThemeToggle /> + {account} + </div> + </aside> + + <div className="flex min-w-0 flex-1 flex-col"> + {/* 移动端顶栏 */} + <header className="flex h-12 shrink-0 items-center justify-between border-b bg-card/60 px-3 lg:hidden"> + <Brand /> + <div className="flex items-center gap-1"> + <ThemeToggle /> + {account} + </div> + </header> + <main key={loc.pathname} className="min-h-0 flex-1 overflow-y-auto"> + <Outlet /> + </main> + {/* 移动端底 Tab */} + <nav aria-label="底部导航" className="flex h-14 shrink-0 items-stretch border-t bg-card/90 backdrop-blur md:hidden"> + <TabItem to="/" icon={<BookOpen className="size-5" />} label="书架" search="?lib=0" /> + {isAdmin && <TabItem to="/admin/libraries" icon={<FolderCog className="size-5" />} label="库管理" />} + {isAdmin && <TabItem to="/admin/users" icon={<Users className="size-5" />} label="用户" />} + </nav> + </div> + </div> + ); +} + +function TabItem({ to, icon, label, search }: { to: string; icon: ReactNode; label: string; search?: string }) { + return ( + <NavLink to={to + (search ?? "")} className={({ isActive }) => cn("flex flex-col items-center justify-center gap-0.5 text-[11px] text-muted-foreground", isActive && "text-accent")}> + {icon} + {label} + </NavLink> + ); +} +``` + +- [ ] **Step 3: `App.tsx` 改布局路由**:`/`、`/admin/*` 包进壳: + +```tsx + <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> +``` +import `AppShell`。过渡态说明:旧 Shelf/admin 仍自带 TopBar,壳出现后暂时双头部,Task 5–7 逐个拆掉(预期内,不阻塞 check)。 + +- [ ] **Step 4: 校验 + commit** + +```bash +docker exec -w /app booklib_web_1 npm run -s check +git add frontend/src/components/app-shell.tsx frontend/src/components/theme-toggle.tsx frontend/src/App.tsx +git commit -m "feat(ui): AppShell layout route — desktop rail, tablet icon rail, mobile tabs, theme toggle" +``` + +--- + +### Task 5: 登录页重做 + +**Files:** +- Modify: `frontend/src/pages/Login.tsx`(整文件替换) + +**Interfaces:** +- Consumes: `useAuth`、`toast`、Task 1 `Card/Input/Label/Button`;Task 2 `ThemeToggle`。 +- Produces: 独立 `Login` 默认导出(不套壳)。 + +- [ ] **Step 1: 整文件替换 `frontend/src/pages/Login.tsx`** + +```tsx +import { useState, type FormEvent } from "react"; +import { Navigate, useLocation } from "react-router-dom"; +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(); + const loc = useLocation() as { state?: { from?: string } }; + const [u, setU] = useState(""); + const [p, setP] = useState(""); + const [busy, setBusy] = useState(false); + + if (user) return <Navigate to={loc.state?.from ?? "/"} replace />; + + async function submit(e: FormEvent) { + e.preventDefault(); + setBusy(true); + try { + await login(u, p); + } catch (err) { + toast("err", err instanceof Error ? err.message : "登录失败"); + } finally { + setBusy(false); + } + } + + return ( + <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> + ); +} +``` + +- [ ] **Step 2: 校验 + commit** + +```bash +docker exec -w /app booklib_web_1 npm run -s check +git add frontend/src/pages/Login.tsx +git commit -m "feat(ui): login rebuilt on shadcn card/label/input, theme toggle available pre-auth" +``` + +--- + +### Task 6: 书架页重做 + +**Files:** +- Modify: `frontend/src/pages/Shelf.tsx`(整文件替换) + +**Interfaces:** +- Consumes: Task 3 `sections/sortBooks/SORT_LABEL/SortKey/GroupMode`、`formatSize`;Task 4 URL 约定 `?lib=`;`Cover`、`api`、`useAuth`、`toast`;shadcn `Button/DropdownMenu/AlertDialog/Input/Skeleton/Badge`。 +- Produces: 无后续消费方。 + +- [ ] **Step 1: 整文件替换 `frontend/src/pages/Shelf.tsx`**(数据层与旧版等价:查询 key、防抖、del/scan mutation、继续读解析) + +```tsx +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { ArrowDownUp, Ellipsis, FolderOpen, LibraryBig, RefreshCw, ScanSearch, Search } 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 { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, + AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +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 { GROUPS, LIB0, type GroupMode, SORTS, type SortKey } from "@/lib/shelfMeta"; +import { formatSize } from "@/lib/format"; +import { sections, SORT_LABEL } from "@/lib/shelf"; +import { formatBadge } from "@/components/ui"; + +// 注:SORTS/GROUPS/LIB0 见 Step 2 的 lib/shelfMeta.ts(把 URL 常量集中一处,壳与页共用) +``` + +**为避免一次替换 400 行出错,Task 6 实际分两步提交:** + +- [ ] **Step 2: 先建 `frontend/src/lib/shelfMeta.ts`(URL/选项常量)** + +```ts +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; +``` + +- [ ] **Step 3: 再整文件替换 `Shelf.tsx`** + +```tsx +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { 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 { formatBadge } from "@/components/ui"; +import { toast } from "@/components/Toaster"; +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 SortKey } from "@/lib/shelf"; +import { GROUPS, LIB0, SORTS } from "@/lib/shelfMeta"; +import type { GroupMode } from "@/lib/shelf"; + +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 fx-rise relative flex flex-col" style={{ animationDelay: `${Math.min(index, 23) * 25}ms` }}> + <Button asChild variant="link" className="block h-auto w-full flex-col items-stretch p-0 text-left"> + <button onClick={onOpen} className="focus-visible:outline-none"> + <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-primary-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> + <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> + </Button> + <div className="absolute right-1 top-1 z-10 opacity-0 transition-opacity focus-within:opacity-100 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 onClick={onOpen}>打开</DropdownMenuItem> + {isAdmin && ( + <> + <DropdownMenuSeparator /> + <DropdownMenuItem className="text-destructive focus:text-destructive" onClick={onDelete}> + 删除 + </DropdownMenuItem> + </> + )} + </DropdownMenuContent> + </DropdownMenu> + </div> + </div> + ); +} +``` + +(组件其余部分——主组件 `Shelf()`、`ContinueRail`、`SectionHead`——在下面 Step 4 完整给出;BookCard 中 `Button asChild` 包裹原生 button 是误写,**Step 4 整合时删除外层 `<Button asChild>`,直接用 `<button onClick={onOpen}>` + `group` 类**。) + +- [ ] **Step 4: 完成 Shelf.tsx 余下部分(与 Step 3 合并为最终文件)** + +```tsx +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/90 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 [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(() => { + if (qRaw.trim() === serverQ) return; + const t = setTimeout(() => { + const next = new URLSearchParams(sp); + if (qRaw.trim()) next.set("q", qRaw.trim()); + else next.delete("q"); + setSp(next, { replace: true }); + }, 300); + return () => clearTimeout(t); + }, [qRaw]); // eslint-disable-line + + const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries }); + const booksQ = useQuery({ queryKey: ["books", lib, serverQ], queryFn: () => api.listBooks({ library: lib || undefined, q: serverQ }) }); + const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress }); + const allQ = useQuery({ queryKey: ["books", LIB0, ""], queryFn: () => api.listBooks() }); + + const del = useMutation({ + mutationFn: (id: number) => api.deleteBook(id), + onSuccess: () => { + toast("ok", "已删除"); + qc.invalidateQueries({ queryKey: ["books"] }); + }, + onError: (e) => toast("err", e instanceof Error ? e.message : "删除失败"), + }); + const scan = useMutation({ + mutationFn: (id: number) => api.scanLibrary(id), + onSuccess: () => { + toast("ok", "扫描已触发"); + setTimeout(() => qc.invalidateQueries({ queryKey: ["books"] }), 3000); + }, + onError: (e) => toast("err", e instanceof Error ? e.message : "扫描失败"), + }); + + 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 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="mx-auto flex w-full max-w-6xl flex-col px-4 pb-20 sm:px-6 md:pb-6"> + <div className="sticky top-0 z-20 -mx-4 mb-6 flex flex-wrap items-center gap-2 border-b bg-background/90 px-4 py-3 backdrop-blur sm:-mx-6 sm:px-6"> + <div className="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"> + <ArrowSort className="size-4" /> + {SORT_LABEL[sort]} + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuLabel>排序</DropdownMenuLabel> + {SORTS.map((k) => ( + <DropdownMenuItem key={k} onClick={() => setParam("sort", k === "added" ? "" : k)} className={k === sort ? "font-medium text-accent" : ""}> + {SORT_LABEL[k]} + </DropdownMenuItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + <div role="group" aria-label="分组视图" className="flex rounded-md border p-0.5"> + {GROUPS.map((g) => ( + <Button key={g.v} size="sm" variant={group === g.v ? "secondary" : "ghost"} aria-pressed={group === g.v} className="px-2.5" onClick={() => setParam("group", g.v === "flat" ? "" : g.v)}> + {g.label} + </Button> + ))} + </div> + {isAdmin && lib > LIB0 && ( + <Button variant="outline" size="sm" disabled={scan.isPending} onClick={() => scan.mutate(lib)} className="gap-1.5"> + <ScanLine className="size-4" /> + {scan.isPending ? "扫描中…" : "扫描此库"} + </Button> + )} + <Button variant="ghost" size="icon" aria-label="刷新书目" onClick={() => qc.invalidateQueries({ queryKey: ["books"] })}> + <RefreshCw className="size-4" /> + </Button> + </div> + + {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"> + <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">{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-primary-foreground hover:bg-destructive/90" + onClick={() => { + if (confirmDel) del.mutate(confirmDel.id); + setConfirmDel(null); + }} + > + 删除 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </div> + ); +} + +function ArrowSort({ className }: { className?: string }) { + return <ArrowDownUp className={className} />; +} +``` + +(整合要求:`ArrowSort` 垫片不需要,直接 import `ArrowDownUp as ArrowSort`;Step 3 的 BookCard 以「去掉 Button asChild 包裹」后的形态并入;最终文件必须通过 tsc `noUnusedLocals`——删掉未用 import 如 `Badge`。) + +- [ ] **Step 5: 校验 + commit** + +```bash +docker exec -w /app booklib_web_1 npm run -s check +git add frontend/src/lib/shelfMeta.ts frontend/src/pages/Shelf.tsx +git commit -m "feat(ui): shelf rebuilt — sticky toolbar (search/sort/group in URL), rail, menu cards, alert-dialog delete" +``` + +--- + +### Task 7: admin 两页重做 + 旧壳/图标/ui.ts 清理 + +**Files:** +- Modify: `frontend/src/pages/admin/Libraries.tsx`, `frontend/src/pages/admin/Users.tsx`(整文件替换) +- Delete: `frontend/src/components/TopBar.tsx` +- Modify: `frontend/src/components/icons.tsx`(收缩至 `Svg`+`IconArrowLeft`), `frontend/src/components/ui.ts`(收缩至 `btn/btnGhost/formatBadge/FORMAT_BADGE` + deprecated formatSize re-export) + +**Interfaces:** +- Consumes: AppShell(不再自绘头)、Task 1 原语、Task 3 `formatSize`、lucide。 +- Produces: 无后续消费方;`TopBar.tsx` 消亡。 + +- [ ] **Step 1: 替换 `Libraries.tsx`**(功能等价:建库表单、逐库 扫描/上传;新增仅交互层的拖拽 dropzone;上传沿用串行 + 快照计数) + +```tsx +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useRef, useState, type DragEvent, type FormEvent } from "react"; +import { RefreshCw, Upload } from "lucide-react"; +import { api } from "../../api/client"; +import { toast } from "../../components/Toaster"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import { formatSize } from "@/lib/format"; + +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()), + onSuccess: (lib) => { + toast("ok", `库「${lib.name}」已创建`); + setName(""); + void qc.invalidateQueries({ queryKey: ["libraries"] }); + }, + onError: (e) => toast("err", e instanceof Error ? e.message : "建库失败"), + }); + const scan = useMutation({ + mutationFn: (id: number) => api.scanLibrary(id), + onSuccess: () => { + toast("ok", "扫描已触发"); + setTimeout(() => void qc.invalidateQueries({ queryKey: ["books"] }), 3000); + }, + onError: (e) => toast("err", e instanceof Error ? e.message : "扫描失败"), + }); + + async function onFiles(libId: number, list: FileList | null) { + const files = Array.from(list ?? []); // FileList 活引用,先快照 + if (!files.length) return; + 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); + } catch (e) { + toast("err", e instanceof Error ? e.message : "上传失败"); + } + } + + function onDrop(e: DragEvent, libId: number) { + e.preventDefault(); + void onFiles(libId, e.dataTransfer.files); + } + + function submit(e: FormEvent) { + e.preventDefault(); + create.mutate(); + } + + const libs = libsQ.data ?? []; + return ( + <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 && <div className="space-y-3">{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-28 w-full" />)}</div>} + <div className="flex flex-col gap-4"> + {libs.map((l) => ( + <Card key={l.id} onDragOver={(e) => e.preventDefault()} onDrop={(e) => onDrop(e, l.id)}> + <CardHeader className="flex-row items-baseline justify-between gap-3 space-y-0 py-3"> + <CardTitle className="flex items-baseline gap-2 text-base"> + {l.name} + <span className="text-xs font-normal text-muted-foreground">#{l.id}</span> + </CardTitle> + <p className="text-xs text-muted-foreground">{l.root_path}</p> + </CardHeader> + <CardContent className="flex flex-wrap items-center gap-2 pt-0"> + <Button variant="outline" size="sm" disabled={scan.isPending} onClick={() => scan.mutate(l.id)}> + <RefreshCw /> {scan.isPending ? "扫描中…" : "扫描"} + </Button> + <Input ref={fileRefs.current[l.id] as never} type="file" multiple accept=".cbz,.zip,.pdf,.epub,.txt,.md" className="hidden" id={`files-${l.id}`} onChange={(e) => { void onFiles(l.id, e.target.files); e.target.value = ""; }} /> + <Button size="sm" onClick={() => fileRefs.current[l.id]?.click()}> + <Upload /> 上传文件 + </Button> + <span className="text-xs text-muted-foreground">白名单:cbz/zip/pdf/epub/txt/md · 也可直接拖文件进卡片</span> + </CardContent> + </Card> + ))} + {libs.length === 0 && !libsQ.isPending && <p className="text-sm text-muted-foreground">还没有库,用上方表单创建。</p>} + </div> + </div> + ); +} +``` + +(`Input ref` 那行是占位写法——实际用隐藏 `<input ref>` 原语:`<input ref={(el)=>{fileRefs.current[l.id]=el}} type="file" multiple accept=".cbz,.zip,.pdf,.epub,.txt,.md" className="hidden" onChange=…/>`,其余不变。) + +- [ ] **Step 2: 替换 `Users.tsx`**(Table + AlertDialog 删除;查询/创建/删除逻辑不变) + +```tsx +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 { toast } from "../../components/Toaster"; +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(); + const { user: me } = useAuth(); + const usersQ = useQuery({ queryKey: ["users"], queryFn: api.listUsers }); + const [u, setU] = useState(""); + const [p, setP] = useState(""); + const [role, setRole] = useState<"member" | "admin">("member"); + + const create = useMutation({ + mutationFn: () => api.createUser(u.trim(), p, role), + onSuccess: () => { + toast("ok", "用户已创建"); + setU(""); + setP(""); + void qc.invalidateQueries({ queryKey: ["users"] }); + }, + onError: (e) => toast("err", e instanceof Error ? e.message : "创建失败"), + }); + const del = useMutation({ + mutationFn: (id: number) => api.deleteUser(id), + onSuccess: () => { + toast("ok", "用户已删除(其阅读进度一并删除)"); + void qc.invalidateQueries({ queryKey: ["users"] }); + }, + onError: (e) => toast("err", e instanceof Error ? e.message : "删除失败"), + }); + + function submit(e: FormEvent) { + e.preventDefault(); + create.mutate(); + } + + return ( + <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) => ( + <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-primary-foreground hover:bg-destructive/90" onClick={() => del.mutate(x.id)}> + 删除 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + )} + </div> + ); +} +``` + +- [ ] **Step 3: 清理旧壳** + +```bash +git rm frontend/src/components/TopBar.tsx +``` + +`icons.tsx` 整文件替换为仅含 Reader 所需的: + +```tsx +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> +); +``` + +`ui.ts` 收缩为:`FORMAT_BADGE`、`formatBadge`、`btn`、`btnGhost`、`export { formatSize } from "../lib/format";`(删除 `btnPrimary/input/card/pill/pillActive/formatSize 本体`)。 + +- [ ] **Step 4: 全量校验 + commit** + +```bash +docker exec -w /app booklib_web_1 npm run -s check # 若报未用 import/缺符号,就地修至绿 +git add -A frontend/src +git commit -m "feat(ui): admin pages on shadcn table/card/alert-dialog; drop TopBar, trim icons/ui.ts to reader-compat surface" +``` + +--- + +### Task 8: 回归验收(check + playwright 全走查) + +**Files:** +- Modify: `docs/CHANGELOG_web.md` +- 发现缺陷就地修,补独立 commit。 + +- [ ] **Step 1: changelog**(`### Changed / 变更` 段顶部插入,英中相邻行) + +```markdown +- 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 两页同步重做。阅读器保持现状,改版随下一期到达。 +``` + +- [ ] **Step 2: `docker exec -w /app booklib_web_1 npm run -s check` 绿**(33+ 用例、tsc、build)。 +- [ ] **Step 3: playwright 走查**(容器栈在跑;qa 数据用 `deploy/api/storage/test/` 两包重导入):`375×750 / 768×1024 / 1280×800` × light/dark,路径:登录→书架(搜索 300ms 后 URL 带 `?q=`、排序/分组切换、侧栏库筛选、卡片菜单删除弹 AlertDialog、继续阅读横滑)→admin 两页(建库/扫描/上传含拖拽、建用户、删除确认、不能删自己)→`/book/2` 阅读器不回归(章锁/连读/预读抽查一条)→主题切换即时生效且刷新保持→console 零错误。截图归档 `.playwright-cli/`(不提交)。 +- [ ] **Step 4: 缺陷修复循环**:每处修复跑 `test/shelf.test.ts`/`test/format.test.ts` 覆盖的函数级回归 + 重截对应图。 +- [ ] **Step 5: commit + 清理测试数据**(DB 测试行、storage 导入产物,保留两个测试 zip 源文件;删除 `.playwright-cli/`) + +```bash +git add docs/CHANGELOG_web.md && git commit -m "docs(changelog): phase-1 modern redesign — shadcn tokens, dual theme, app shell, rebuilt public surfaces" +``` + +--- + +## Self-Review 记录 + +1. **Spec 覆盖**:token/双主题/FOUC✓(T2) 壳三档✓(T4) 书架 URL 状态/平铺默认/菜单操作/AlertDialog✓(T6) 登录✓(T5) admin 两页+dropzone✓(T7) ui.ts 收缩/TopBar 删除/icons 收缩✓(T7) Cover/Toaster props 兼容换肤✓(T2) formatSize 迁移+测试✓(T3) changelog✓(T8) 阅读器/翻页逻辑零触碰✓(全局约束+T8 抽查)。 +2. **占位符**:T6/T7 内两处"整合要求/占位写法"注记均已给出唯一正确形态与判定命令(tsc noUnusedLocals),非 TBD。 +3. **类型一致**:`SortKey/GroupMode` 定义于 T3 `lib/shelf.ts`,T6 `shelfMeta.ts` 引用 `SortKey` 与 `SORT_LABEL` 命名一致;`sections` 返回 `{label,books}` 与 Shelf 使用一致;`useTheme` 返回值与 T4 ThemeToggle 消费一致。 diff --git a/docs/superpowers/specs/2026-09-08-cbz-chapter-continuous-reading-design.md b/docs/superpowers/specs/2026-09-08-cbz-chapter-continuous-reading-design.md new file mode 100644 index 0000000..14c6d11 --- /dev/null +++ b/docs/superpowers/specs/2026-09-08-cbz-chapter-continuous-reading-design.md @@ -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 模式、 + 长按拖拽越章「窥视」下一章。 diff --git a/docs/superpowers/specs/2026-09-08-modern-redesign-shadcn-design.md b/docs/superpowers/specs/2026-09-08-modern-redesign-shadcn-design.md new file mode 100644 index 0000000..fd6d4aa --- /dev/null +++ b/docs/superpowers/specs/2026-09-08-modern-redesign-shadcn-design.md @@ -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 改动、收藏/标签等新功能。 diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev index e2252f1..42184ef 100644 --- a/frontend/Dockerfile.dev +++ b/frontend/Dockerfile.dev @@ -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"] diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod index c4a0a93..db00097 100644 --- a/frontend/Dockerfile.prod +++ b/frontend/Dockerfile.prod @@ -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 diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..f1447d2 --- /dev/null +++ b/frontend/components.json @@ -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" } +} diff --git a/frontend/index.html b/frontend/index.html index 7c3f092..0fdd40d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -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 — 个人书库 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 28c6f37..3a42806 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,13 +10,19 @@ "license": "ISC", "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", @@ -1589,6 +1595,44 @@ "node": ">=6.9.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", @@ -1777,9 +1821,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1800,9 +1841,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1823,9 +1861,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1846,9 +1881,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1869,9 +1901,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1933,9 +1962,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1955,6 +1981,1502 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.15.tgz", + "integrity": "sha512-WTQwcAvQf5sOcuUyi90lKPbhwcvQ+j55cjrSmeaN+L2vKU3DooOvlKw2MDeiJ5IkV5N905KW0/fGojKOBhD11A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.20.tgz", + "integrity": "sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collapsible": "1.1.20", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.23.tgz", + "integrity": "sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dialog": "1.1.23", + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.15.tgz", + "integrity": "sha512-fy+dyVR+90nelK8rqIznFlxzx7uPcGbhxH8Nfr2bHb4UfSe+e3hklOC0luK0hDwVwnRX7xTRySpsrQVeW+/oNQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.6.tgz", + "integrity": "sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.11.tgz", + "integrity": "sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.20.tgz", + "integrity": "sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.7.tgz", + "integrity": "sha512-CtXP35dxaB5T3zXSd+E3uHe/QpXcpYnZmxp6OaIbfthtfW4wyb77M23BG+bwIJDtsMwEP/YssdsmNyZu7jhWew==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.24.tgz", + "integrity": "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form": { + "version": "0.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.16.tgz", + "integrity": "sha512-Q4TLEn2A7TAypxwmd6R9EwrlXDvkfYSDMrq9/887AXAGh+G1rH+kYJKSTv+Si9Y0JPKTwKYv6PviAJosysNimA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-label": "2.1.15", + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.23.tgz", + "integrity": "sha512-H8qONfZd3ltrU3+jHCIgITbWo6e1iTKvP9DHdrvYbX48ooRM5FjEDTn16AMwdfuOGkWdZEhpl3PLL/Wk/AnHDQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.15.tgz", + "integrity": "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.24.tgz", + "integrity": "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.24.tgz", + "integrity": "sha512-eeVs0vf7cuqXaM0qLQCPcufImiJNVBXdJDLu7ZGYl2732UH23Qat/foNGrr6vYV3/DdTsBqASoggUFgH14OcZA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.22", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.22.tgz", + "integrity": "sha512-ou7iLEJ+yrhQndkkA4U21XIdS/CS45F4iXIkTZcb6/Ne9EMsOuDudVmCwmDnfFZZ+y1FZqXRNSIgBy+YMvZVZg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.16.tgz", + "integrity": "sha512-Tj9P6ntAJEw52oq/F0AGknXR4XncxEt7XU47O3xJQOiWfLzEy3d9gtgKfvjSzGxzHkfL+VzvxGu2KTFsloJqXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.11.tgz", + "integrity": "sha512-4gvFnmDXu3dgj21CqsufzIameRvlRd4SBqaWhcrlrNhRo0Y5i/49AmRJYe1fdAM3G2VNBbmin4b0D6cdQocwgw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-is-hydrated": "0.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.23.tgz", + "integrity": "sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.16.tgz", + "integrity": "sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.7.tgz", + "integrity": "sha512-cgYFEkntCxppHZgtSZ+7vh0wbZQ+IC7PPMw8DSnRG27B6kDd32/Zw0OJt7dGDigCoprMuWHjg2PvUn3PYvPFoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", + "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.18.tgz", + "integrity": "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.7.tgz", + "integrity": "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.15.tgz", + "integrity": "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.7.tgz", + "integrity": "sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-use-size": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.7.tgz", + "integrity": "sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.21.tgz", + "integrity": "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.23.tgz", + "integrity": "sha512-ofhyAsYaocRGOs/n0XWdUOSVzEAG6BfrMVM8z0c0kLEWY38w/0WuMFPTJP/HVaZPYkMvHZoKIIhNcjbTCBILPg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.18.tgz", + "integrity": "sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.19.tgz", + "integrity": "sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-toggle": "1.1.18", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.19.tgz", + "integrity": "sha512-Ph0IvtYw4VB12ZnZg+YtrGs8yJQsnizwo/zu0R4Y/nWugtJzA7Pg1eWeuDR9+LSqn+xjamss+UOSOJJJ4gx8jw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-separator": "1.1.15", + "@radix-ui/react-toggle-group": "1.1.19" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz", + "integrity": "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.5.tgz", + "integrity": "sha512-ge3ipobwSXTj4JyVtswQ7qZj0ZHdtbGuOno/LrgAAeSxtsJ6Vs4Gz5IkPH2bmqpjcLUFoqGhA/mueuIf63UXlA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", + "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", + "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", @@ -2065,9 +3587,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2085,9 +3604,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2105,9 +3621,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2125,9 +3638,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2145,9 +3655,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2165,9 +3672,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2454,9 +3958,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2471,9 +3972,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2488,9 +3986,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2505,9 +4000,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2522,9 +4014,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2539,9 +4028,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2556,9 +4042,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2573,9 +4056,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2590,9 +4070,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2607,9 +4084,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2624,9 +4098,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2641,9 +4112,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2658,9 +4126,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2884,9 +4349,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2904,9 +4366,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2924,9 +4383,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2944,9 +4400,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3116,7 +4569,7 @@ "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -3126,7 +4579,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -3604,6 +5057,18 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -3896,6 +5361,27 @@ "node": ">=18" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -3999,7 +5485,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/d": { @@ -4143,6 +5629,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/dompurify": { "version": "3.4.15", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", @@ -4755,6 +6247,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-own-enumerable-property-symbols": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", @@ -5683,9 +7184,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5707,9 +7205,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5731,9 +7226,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5755,9 +7247,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5854,6 +7343,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.42.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.42.0.tgz", + "integrity": "sha512-b3jprplnoLS8n5etw1z8xODe3hF/yjKATrTitsrKrnjUhCef5BdDct6Ppv3zVvzFwmtfWgLO6XNM3C9fAD93ug==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -6190,6 +7688,83 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/radix-ui": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.7.tgz", + "integrity": "sha512-QBdhh1arIEUvPC0dQ5+nwWAxt7+N+oP/9jPwjJkGFoSk/sqxg32gJtSXGtFh8frAIcS6oC9cx2Q+7KYCQLOAeA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-accessible-icon": "1.1.15", + "@radix-ui/react-accordion": "1.2.20", + "@radix-ui/react-alert-dialog": "1.1.23", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-aspect-ratio": "1.1.15", + "@radix-ui/react-avatar": "1.2.6", + "@radix-ui/react-checkbox": "1.3.11", + "@radix-ui/react-collapsible": "1.1.20", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-context-menu": "2.3.7", + "@radix-ui/react-dialog": "1.1.23", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-dropdown-menu": "2.1.24", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-form": "0.1.16", + "@radix-ui/react-hover-card": "1.1.23", + "@radix-ui/react-label": "2.1.15", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-menubar": "1.1.24", + "@radix-ui/react-navigation-menu": "1.2.22", + "@radix-ui/react-one-time-password-field": "0.1.16", + "@radix-ui/react-password-toggle-field": "0.1.11", + "@radix-ui/react-popover": "1.1.23", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-progress": "1.1.16", + "@radix-ui/react-radio-group": "1.4.7", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-scroll-area": "1.2.18", + "@radix-ui/react-select": "2.3.7", + "@radix-ui/react-separator": "1.1.15", + "@radix-ui/react-slider": "1.4.7", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-switch": "1.3.7", + "@radix-ui/react-tabs": "1.1.21", + "@radix-ui/react-toast": "1.2.23", + "@radix-ui/react-toggle": "1.1.18", + "@radix-ui/react-toggle-group": "1.1.19", + "@radix-ui/react-toolbar": "1.1.19", + "@radix-ui/react-tooltip": "1.2.16", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-escape-keydown": "1.1.5", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -6211,6 +7786,53 @@ "react": "^19.2.8" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-router": { "version": "7.18.3", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", @@ -6249,6 +7871,28 @@ "react-dom": ">=18" } }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -6973,6 +8617,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -7079,6 +8733,21 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, "node_modules/type": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", @@ -7339,6 +9008,49 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -7597,9 +9309,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7621,9 +9330,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7645,9 +9351,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7669,9 +9372,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/frontend/package.json b/frontend/package.json index a6211f3..b902195 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b7224f8..f319bcc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( - - - } /> - - - - } - /> - - - - } - /> - - - - - - } - /> - - - - - - } - /> - } /> - - - + + + + } /> + + + + } + > + } /> + } /> + } /> + + + + + } + /> + } /> + + + + ); } diff --git a/frontend/src/components/Bookmarks.tsx b/frontend/src/components/Bookmarks.tsx index 3b74b82..be99188 100644 --- a/frontend/src/components/Bookmarks.tsx +++ b/frontend/src/components/Bookmarks.tsx @@ -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; -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(null); const [editVal, setEditVal] = useState(""); @@ -48,107 +52,90 @@ export function useBookmarks(opts: { onError: fail, }); - const btn = ( - - ); - - const panel = open ? ( + return ( <> -