From 0203de116815765f610c0e82eb873c5d6197529d Mon Sep 17 00:00:00 2001 From: Marcus <1922576605@qq.com> Date: Mon, 5 Oct 2026 20:24:49 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BC=95=E5=85=A5=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 +- docs/design-system.md | 202 ++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 25 +++ src/app/(app)/components/app-header.tsx | 3 +- src/app/(app)/components/file-card.tsx | 33 +-- .../components/library-image-preview.tsx | 73 +++++++ src/app/(app)/components/library-list.tsx | 94 ++++++-- src/app/(app)/components/library-rows.ts | 3 + src/app/(app)/components/playlist-menu.tsx | 3 +- .../[type]/[id]/[fileId]/[[...name]]/route.ts | 14 ++ src/components/image-lightbox.tsx | 164 ++++++++++++++ src/constants/motion.ts | 7 + src/hooks/use-stuck.ts | 32 +++ src/server/mock-library.ts | 45 ++++ 15 files changed, 647 insertions(+), 54 deletions(-) create mode 100644 docs/design-system.md create mode 100644 src/app/(app)/components/library-image-preview.tsx create mode 100644 src/components/image-lightbox.tsx create mode 100644 src/hooks/use-stuck.ts diff --git a/.env.example b/.env.example index 2460503..a771231 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # TorBox API Key,仅服务端使用,不会下发到浏览器 -# 不配置时以演示模式启动:资源库显示内置模拟数据(src/server/mock-library.ts),不可播放 +# 不配置时以演示模式启动:资源库显示内置模拟数据(src/server/mock-library.ts) TORBOX_API_KEY= # 自定义访问密钥:网页登录、播放链接、播放列表都用它鉴权 # 演示模式下可不配置,此时访问密钥为 mock;配置了 TORBOX_API_KEY 时必须配置,否则拒绝启动 diff --git a/docs/design-system.md b/docs/design-system.md new file mode 100644 index 0000000..bbc8dfa --- /dev/null +++ b/docs/design-system.md @@ -0,0 +1,202 @@ +# TorBox Admin UI 风格规范 + +## 1. 定位 + +TorBox 是云端 debrid / seedbox 服务:代为下载种子、Usenet 与网页资源并存入云端,再以直链或串流取回。官网自称 _Private Infrastructure Provider_,视觉为墨黑底、翠绿强调色、点阵标题字体、6px 圆角。 + +本项目是自托管的 TorBox 面板,核心任务是**浏览资源并一键交给 PotPlayer 串流**。风格定为 **「私有云控制台」**: + +- 墨黑底 + 翠绿**唯一**强调色,克制、不花哨 +- 点阵字标呼应 TorBox 品牌,等宽字体承载一切数据 +- 高信息密度,操作直达(播放、复制直链、预览) +- **仅深色主题**,不提供浅色模式 + +## 2. 颜色 + +只使用 shadcn 语义变量(`bg-primary`、`text-muted-foreground` 等),不写原始色值,不新增另一套 token。变量定义在 `src/app/globals.css`,`:root` 与 `.dark` 共用同一套深色值,`` 常驻 `dark` class。 + +| 变量 | 值 | 用途 | +| -------------------------------- | --------------------------- | -------------------------------- | +| `background` | `oklch(0.192 0.015 272.5)` | 页面底色(= TorBox `#12141B`) | +| `card` / `popover` | `oklch(0.223 0.018 270.5)` | 卡片、弹层,比底色抬升一级 | +| `muted` / `secondary` / `accent` | `oklch(0.257 0.02 268)` | 悬停、选中、占位封面、次要按钮 | +| `foreground` | `oklch(0.97 0.005 265)` | 正文 | +| `muted-foreground` | `oklch(0.714 0.019 261)` | 次要文字(对比度 ≈ 7.2:1) | +| `primary` | `oklch(0.713 0.15 164.6)` | 主操作、品牌色(= `#04BF8A`) | +| `primary-foreground` | `oklch(0.159 0.011 268)` | 主按钮文字,深墨色 | +| `border` / `input` | 白色 8% / 12% | 分隔线、输入框描边 | +| `ring` | 同 `primary` | 焦点环 | +| `destructive` | `oklch(0.704 0.191 22.216)` | 错误、失败 | +| `chart-1` / `chart-2` / `chart-3` | 翠绿 / 天蓝 / 琥珀 | 来源分类色:Torrent / Usenet / Web DL | + +要点: + +- 主按钮用**深墨字**而非白字:白字在翠绿上对比度仅 2.4:1,深墨为 8.2:1。 +- 翠绿只给"最重要的一个动作"(播放、登录、品牌标识),同一视图里不要铺满绿色。 +- 来源分类只以小圆点(`SourceDot`)出现,不给大面积色块。 + +## 3. 字体 + +通过 `next/font/google` 加载,在 `globals.css` 的 `@theme inline` 中映射: + +| 工具类 | 字体 | 用于 | +| -------------- | ---------------------------------------- | -------------------------------------- | +| `font-sans` | Geist + PingFang SC / Microsoft YaHei | 正文、标题、按钮 | +| `font-mono` | Geist Mono | 文件大小、扩展名、计数、汇总等**所有数据**,配合 `tabular-nums` | +| `font-display` | Doto(点阵) | **仅** `BrandMark` 中的 "TORBOX" 字标 | + +- 中文由系统字体回落,不另加载中文 webfont。 +- 不要把 `font-display` 用在中文或正文上(Doto 只有拉丁字符)。 +- 品牌小字(如登录页底部的 `PRIVATE · SELF-HOSTED`)使用 `font-mono` + 大字距 + `text-muted-foreground`。 + +## 4. 圆角、间距、尺寸 + +- `--radius: 0.375rem`(6px,与官网一致),组件按 shadcn 派生规则取 `rounded-lg` / `rounded-xl`。 +- 间距只用 `gap-*`,不用 `space-x/y-*`;常用节奏:控件间 `gap-2`,区块内 `gap-3`,区块间 `gap-8`。 +- 内容最大宽度 `max-w-7xl`,水平内边距 `px-4 md:px-6`;header、工具栏的内层容器与内容区对齐。 +- 等宽等高用 `size-*`,单行截断用 `truncate`,多行截断用 `line-clamp-*`。 + +## 5. 布局规则 + +**宏观骨架一律 flex,禁止用 `fixed` / `sticky` / `absolute` 做布局。** 固定顶部的效果靠"外层不滚、内层滚"实现: + +``` +div.flex.h-dvh.flex-col ← (app)/layout.tsx +├─ header.shrink-0 ← AppHeader,永远在顶部 +└─ main.flex.min-h-0.flex-1.flex-col + └─ div.flex.min-h-0.flex-1.flex-col ← 页面 + ├─ 工具栏.shrink-0 ← 也不随内容滚动 + └─ div.min-h-0.flex-1.overflow-y-auto ← 唯一的滚动容器 +``` + +- 需要撑满的子项加 `flex-1 min-h-0`(`min-h-0` 不能省,否则内容会把容器撑破)。 +- 推到右侧用 `ml-auto`,居中用 `items-center justify-center`。 +- **例外一**:等宽卡片网格使用 grid(见 `FileGrid` 组件),列数随宽度 `grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5`,**一行最多 5 张卡片**,卡片宽度保持在约 190–240px。flex-wrap 做不到"等宽 + 末行不拉伸",grid 也不属于定位。 +- **例外二·叠层**(装饰层与内容叠在一起,如登录页点阵画布、卡片准星、封面光斑)用单格 grid:父元素 `grid`,子元素 `col-start-1 row-start-1`,后写的在上层;装饰层加 `pointer-events-none`。需要移动时用 Motion 的 `x/y`(transform),transform 不属于定位。 +- **例外三·分组标题吸顶**:资源库的分组标题行用原生 `sticky`(全站唯一),由浏览器合成,滚动时不跑 JS。虚拟列表把每个分组的可见行包进一个与整组等高的容器(未渲染部分用占位块撑出),标题以它为边界,到分组末尾时被下一组自然推出;`rangeExtractor` 保证当前分组的标题始终渲染。吸附时的底边线用 `stuck-divider`(scroll-state 容器查询);容器查询改不了容器自身,吸附时标题行两侧各外扩 6px 由 `useStuck`(IntersectionObserver,观察边界比吸附位置低 1px)驱动。 +- 单格 grid 里放 canvas 时,轨道必须是 `grid-cols-1 grid-rows-1`(`minmax(0, 1fr)`),否则画布的像素尺寸会反过来撑大布局。 +- shadcn 自带浮层(Dialog、Toast、Tooltip、DropdownMenu)与图片预览灯箱内部的定位不受此限。 + +### 长列表(虚拟滚动) + +资源库使用 TanStack Virtual(`src/app/(app)/components/library-list.tsx`),同样不使用定位: + +- 滚动容器由页面持有(上面那个唯一的 `overflow-y-auto`),虚拟器只计算该渲染哪些行。 +- 分组数据拍平成「提示 / 标题 / 一行卡片 / 空提示」四种行;可见行处于正常文档流,上下各一个占位 `div` 用 `height` 撑出未渲染部分。 +- 行的留白写在行自身(`pb-3`、分组标题 `pt-5`),由 `measureElement` 一并测量,虚拟器 `gap` 为 0。 +- 行 key 只由资源与分块序号组成,不含列数;列数按容器宽度计算(最多 5 列)。 +- 刷新时顶部插入新资源:停在顶部则直接展示新资源,向下浏览时锚定当前行不跳动。 +- 默认保持 TorBox 返回的顺序:来源按 torrents → usenet → webdl 合并,资源内文件不重排。工具栏「排序」菜单(`DropdownMenuRadioGroup`)可选最新加入、最早加入、名称、体积最大、即将过期,比较函数集中在 `use-library.ts` 的 `LIBRARY_SORTERS`,缺字段的排在最后。 +- 筛选或排序变化回到顶部;滚动快照(锚点行 + 偏移 + 已测量尺寸)存于 sessionStorage,刷新页面后按锚点恢复。 +- 展开状态默认全部展开,**不持久化**,刷新后恢复全部展开。store 记录「基准状态 + 单独切换过的资源」,工具栏图标按钮一键全部收起 / 展开;全部切换或在吸顶条上收起后,把当前分组的标题放回视口顶部。 +- 分组标题吸顶见第 5 节例外三;行组件 `memo`,滚动时只渲染新进入范围的行。键盘移动时虚拟器 `scrollPaddingStart`、Tab 聚焦时滚动容器 `scroll-pt-14` 都会留出吸顶标题的高度。 + +## 6. 组件选型 + +优先复用 `src/components/ui` 中的 shadcn 组件,`className` 只调布局,不覆盖颜色与字号。 + +| 需求 | 使用 | +| -------------- | -------------------------------------------- | +| 表单 | `FieldGroup` + `Field` + `FieldLabel` + `InputGroup` | +| 2–5 个选项切换 | `ToggleGroup`(`variant="outline" size="sm" spacing={0}`) | +| 资源分组 | `ItemHeader`(`aria-expanded` 按钮,展开状态在 `library-view-store`);窄屏只显示来源色点、过期时间省略「后过期」,给标题留空间 | +| 文件卡片 | `Card size="sm"` + `CardContent` + `CardFooter` | +| 标签 | `Badge`(来源:`variant="outline"` + `SourceDot`) | +| 下拉操作 | `DropdownMenu` | +| 图标按钮说明 | `Tooltip` | +| 图片预览 | `ImageLightbox`(yet-another-react-lightbox + Zoom):传入一组图片与当前序号;资源库以一个资源分组内的图片为一组,组内首尾循环,不跨组;列表只渲染一个灯箱 | +| 提示消息 | `src/lib/message.ts`(不直接调用 `toast`) | +| 错误提示 | `Alert variant="destructive"` | +| 空状态 | `Empty` | +| 加载占位 | `Skeleton` | +| 快捷键提示 | `Kbd` | +| 动效 | Motion(`motion/react`),见第 10 节 | + +按钮层级: + +- `default`(翠绿)——每张卡片/每个视图最多一个主操作,如「PotPlayer」「进入」。 +- `secondary`——次要但常用,如「预览」「下载」。 +- `outline` / `ghost`——工具栏与 header 中的辅助操作、图标按钮。 + +## 7. 状态 + +| 状态 | 表现 | +| ------------ | ------------------------------------------------------------ | +| 加载中 | `Skeleton` 复刻真实布局(分组标题 + 卡片网格) | +| 刷新中 | 刷新按钮内 `Spinner` 并禁用,列表保持原内容;手动刷新带 `?fresh=1`,服务端以 `bypass_cache` 绕过 TorBox 列表缓存(torrent 默认 600 秒才更新),自动重新获取仍走缓存 | +| 资源下载中 | 标题行显示 `Progress` + 百分比 + 状态名(`DOWNLOAD_STATE_LABELS`);卡片封面为沙漏占位,主操作禁用但可聚焦(`focusableWhenDisabled`),复制直链禁用;不进入 M3U 播放列表 | +| 资源已失效 | 下载完成但文件不在服务器上:标题行 `destructive` 色「文件不可用」,卡片同上禁用 | +| 资源快过期 | 距 `expires_at` 不足 3 天:标题行 `ClockAlert` +「N 天后过期」(`muted-foreground`);不足 24 小时或已过期转为 `destructive`;悬停显示具体时间。阈值见 `EXPIRING_WINDOW_MS / EXPIRING_URGENT_MS`;文件已不可用时不重复提示 | +| 演示模式 | 未配置 `TORBOX_API_KEY` 时资源库返回 `server/mock-library.ts` 的模拟数据;header 字标旁 `Badge variant="outline"`「演示数据」(窄屏隐藏);图片文件返回生成的 SVG 占位图,可预览、缩放,其他文件不可播放;未配置 `ACCESS_TOKEN` 时登录框下方 `FieldDescription` 提示演示密钥 | +| 整体失败 | `Alert variant="destructive"` + 「重试」 | +| 部分来源失败 | 每个来源一条 `Alert`,成组排列(`gap-2`),其余数据照常展示 | +| 无数据 | `Empty` + 引导文案 | +| 筛选无结果 | `Empty` +「清除筛选」 | +| 表单错误 | `Field data-invalid` + 控件 `aria-invalid` + `FieldError` | + +## 8. 图标 + +统一 `lucide-react`。按钮内图标加 `data-icon="inline-start|inline-end"`,不加尺寸类。 + +| 含义 | 图标 | +| ---------- | ---------------- | +| 品牌 | `Box` | +| 播放 | `Play` | +| 复制直链 | `Copy` | +| 图片预览 | `Eye` | +| 下载 | `Download` | +| 加载失败图 | `ImageOff` | +| 其他文件 | `File` | +| 播放列表 | `ListVideo` | +| 刷新 | `RefreshCw` | +| 退出 | `LogOut` | + +## 9. 文案 + +- 中文、短句、不加句号;按钮用动词(「进入」「预览」「复制直链」)。 +- 数据使用等宽字体与千分位:`128 资源 · 1,024 文件 · 3.2 TB`,分隔符为 ` · `。 +- 来源名保留英文:Torrent / Usenet / Web DL;扩展名大写显示(MKV、JPG)。 +- 文件大小按 1024 进制格式化(`formatBytes`)。 + +## 10. 动效与交互 + +### 原则 + +- **Motion 是唯一的动效库**(`motion/react`、原生侧 `motion`),不手写补间、缓动与帧循环,不新增 CSS keyframes。 +- 所有参数(弹簧、时长、延迟、阶梯间隔、涟漪预设)集中在 `src/constants/motion.ts`,调手感只改这一个文件。 +- 翠绿只用于「焦点反馈」:准星、光斑、点阵余辉、NEW 呼吸点;错误反馈用 `destructive`。 +- 尊重系统「减弱动态效果」:根布局挂 `MotionConfig reducedMotion="user"`,位移类动画自动关闭;自写逻辑用 `useReducedMotion()` 走 `jump` / 不启动。 +- 高频更新只写 MotionValue(作为 `style` 或 `motion.*` 的子节点),不 `setState`,不触发 React 渲染。 + +### 分层(解耦) + +| 层 | 内容 | 规则 | +| --- | --- | --- | +| `src/utils/` | `scramble`、`grid-nav`、`layout`、`date`、`sort` | 纯函数,无 React、无业务 | +| `src/lib/dot-field.ts` | 画布点阵场引擎 | 不依赖 React;渲染循环用 Motion `frame`,涟漪时序用 `animate` | +| `src/hooks/` | `use-follow-highlight`、`use-spotlight`、`use-grid-keyboard-nav`、`use-hotkey` | 通用交互,无业务语义;只产出 MotionValue / 几何 | +| `src/components/` | `Reticle`、`DotField`、`ScrambleText`、`AnimatedNumber`、`TypeText`、`ImageLightbox` | 通用展示组件,外观在这里 | +| 页面组件 | `library-list`、`file-card`、`login-stage` 等 | 只做接线:传选择器、参数与回调 | + +- 容器级交互 hook 统一签名 `useXxx(containerRef, options)`,在容器上做事件委托,彼此独立;删掉一行调用即可拆掉一个交互。 +- 目标靠 data 属性识别(`src/app/(app)/components/library-dom.ts`):`data-file-card` 网格单元、`data-card-focus` 卡片主操作、虚拟行 `data-index`。 +- 表单等业务组件只通过回调对外通知(如 `LoginForm` 的 `onTyping / onReject / onAccept`),不知道外部如何呈现。 + +### 交互清单 + +| 位置 | 交互 | 实现 | +| --- | --- | --- | +| 登录页背景 | 光标划过点亮附近点阵并留下余辉,空闲时停止帧循环 | `DotField` + `lib/dot-field`,静态点由 `bg-dot-grid` 提供、网格对齐 | +| 登录表单 | 键入时从输入框荡开涟漪;密钥错误时卡片抖动并泛起红波;正确时翠绿波扫过全屏后再跳转 | `LoginStage` 把表单回调接到 `DotField.ripple`;`useLogin({ beforeNavigate })` | +| 字标 | TORBOX 乱码后逐字定格;登录页挂载时播放,header 悬停时重播 | `ScrambleText` + `utils/scramble`(Doto 为等宽字体,不抖动) | +| 登录页入场 | 点阵场淡入 → 卡片上浮 → 标语逐字打出 | variants `delayChildren / staggerChildren`、`TypeText` | +| 资源卡片 | 角标准星:悬停出现,移到下一张时滑过去(120ms 内穿过间隙不隐藏),离开消失;键盘聚焦同样跟随;虚拟列表重新测量后自动贴回 | `useFollowHighlight` + `Reticle`(`bg-corner-brackets`) | +| 视频封面 | 光标附近的点阵亮成翠绿 | `useSpotlight`(`useMotionTemplate` 径向遮罩)+ `bg-dot-grid` 的 `--dot-color / --dot-size` | +| 卡片行 | 行挂载时(首屏、筛选、展开)按列依次浮现 | `FileGrid animated` + `FileGridItem` | +| 工具栏数字 | 资源数、文件数、总大小、来源计数滚动到新值 | `AnimatedNumber` | +| 分组标题 | 24 小时内加入的资源显示呼吸的 NEW | `NewBadge`,以数据拉取时间为「现在」 | +| 分组吸顶 | 滚过分组标题后标题吸附在顶部,下一分组到达时被推出;吸附时可直接收起该分组并回到其标题处 | 原生 `sticky` + 分组容器(`layoutVirtualRows`),无滚动监听 | +| 全部展开 / 收起 | 工具栏图标按钮,有展开的资源时收起全部,否则展开全部;当前分组保持在顶部 | `setAllExpanded` + 列表订阅 `bulkToggleCount` | +| 图片预览 | 点击图片封面或「预览」打开灯箱:左右键 / 按钮 / 滑动在同一分组内循环切换;滚轮、双击、双指或工具栏按钮缩放;Esc、点击空白处或下拉关闭,焦点回到打开它的按钮 | `ImageLightbox`;配色通过 `--yarl__*` 变量映射到主题变量,图标换成 lucide,加载超过 0.4s 才淡入转圈;减弱动态效果由灯箱自行处理 | +| 键盘 | `/` 聚焦搜索,搜索框内 Esc 先清空再失焦;方向键在卡片间移动(跨分组、自动滚动),Enter 执行主操作 | `useHotkey`、`useGridKeyboardNav` + `utils/grid-nav` | + diff --git a/package.json b/package.json index 0fd277c..0238593 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "react": "19.2.8", "react-dom": "19.2.8", "tw-animate-css": "^1.4.0", + "yet-another-react-lightbox": "^3.32.2", "zustand": "^5.0.15" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd55c49..531e0c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + yet-another-react-lightbox: + specifier: ^3.32.2 + version: 3.32.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) zustand: specifier: ^5.0.15 version: 5.0.15(@types/react@19.3.0)(react@19.2.8)(use-sync-external-store@1.7.0(react@19.2.8)) @@ -3159,6 +3162,20 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yet-another-react-lightbox@3.32.2: + resolution: {integrity: sha512-F4HtHQfUNpvkj+AmECgWM4XRdCqMY5gXpKgOUx39+T+FyxLe8II4SK/pwMyYj2X54KH9lFSQeHYY1/GfYf3SdA==} + engines: {node: '>=14'} + peerDependencies: + '@types/react': ^16 || ^17 || ^18 || ^19 + '@types/react-dom': ^16 || ^17 || ^18 || ^19 + react: ^16.8.0 || ^17 || ^18 || ^19 + react-dom: ^16.8.0 || ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + yocto-spinner@1.2.2: resolution: {integrity: sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng==} engines: {node: '>=18.19'} @@ -6575,6 +6592,14 @@ snapshots: yallist@3.1.1: {} + yet-another-react-lightbox@3.32.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + yocto-spinner@1.2.2: dependencies: yoctocolors: 2.2.0 diff --git a/src/app/(app)/components/app-header.tsx b/src/app/(app)/components/app-header.tsx index f9715a6..32d4fda 100644 --- a/src/app/(app)/components/app-header.tsx +++ b/src/app/(app)/components/app-header.tsx @@ -16,10 +16,11 @@ export const AppHeader = ({ token, mock }: AppHeaderProps) => (
+ {/* 手机宽度下放不下,演示模式只在登录页提示 */} {mock && ( 演示数据 diff --git a/src/app/(app)/components/file-card.tsx b/src/app/(app)/components/file-card.tsx index 843a3bc..d03c2ee 100644 --- a/src/app/(app)/components/file-card.tsx +++ b/src/app/(app)/components/file-card.tsx @@ -18,12 +18,6 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardFooter } from "@/components/ui/card"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle -} from "@/components/ui/dialog"; import { Tooltip, TooltipContent, @@ -105,12 +99,13 @@ type FileCardProps = { token: string; /** 所属资源的下载状态,非 ready 时不可播放、预览、下载或复制直链 */ status: DownloadStatus; + /** 打开图片预览(仅图片文件使用),灯箱由列表统一渲染 */ + onPreview: () => void; }; -export const FileCard = ({ file, token, status }: FileCardProps) => { +export const FileCard = ({ file, token, status, onPreview }: FileCardProps) => { const origin = useOrigin(); const copy = useCopy(); - const [previewOpen, setPreviewOpen] = useState(false); const [imageFailed, setImageFailed] = useState(false); if (!origin) return null; @@ -175,7 +170,7 @@ export const FileCard = ({ file, token, status }: FileCardProps) => { type="button" title="预览图片" className="flex aspect-video overflow-hidden bg-muted" - onClick={() => setPreviewOpen(true)} + onClick={onPreview} > {/* eslint-disable-next-line @next/next/no-img-element */} { size="sm" variant="secondary" className="flex-1" - onClick={() => setPreviewOpen(true)} + onClick={onPreview} {...CARD_FOCUS_PROPS} > @@ -246,24 +241,6 @@ export const FileCard = ({ file, token, status }: FileCardProps) => { 复制直链 - - {available && file.kind === "image" && ( - - - - - {file.displayName} - - - {/* eslint-disable-next-line @next/next/no-img-element */} - {file.displayName} - - - )} ); }; diff --git a/src/app/(app)/components/library-image-preview.tsx b/src/app/(app)/components/library-image-preview.tsx new file mode 100644 index 0000000..a9c849e --- /dev/null +++ b/src/app/(app)/components/library-image-preview.tsx @@ -0,0 +1,73 @@ +// 资源库图片预览:一个资源分组(抽屉)内的图片为一组,只在组内切换 +"use client"; + +import { useMemo } from "react"; + +import { ImageLightbox, type LightboxImage } from "@/components/image-lightbox"; +import type { LibraryItemView } from "@/hooks/use-library"; +import { useOrigin } from "@/hooks/use-origin"; +import { buildStreamUrl } from "@/utils/media"; + +import { getItemKey } from "./library-rows"; + +/** 正在预览的图片:所属资源分组与文件 id */ +export type ImagePreviewTarget = { itemKey: string; fileId: number }; + +type LibraryImagePreviewProps = { + /** 列表当前展示的资源(已筛选、排序),组内图片顺序与抽屉中一致 */ + items: LibraryItemView[]; + token: string; + target: ImagePreviewTarget | null; + onTargetChange: (target: ImagePreviewTarget | null) => void; +}; + +export const LibraryImagePreview = ({ + items, + token, + target, + onTargetChange +}: LibraryImagePreviewProps) => { + const origin = useOrigin(); + const itemKey = target?.itemKey; + + // 资源未就绪时卡片不提供预览,这里同样视为没有图片 + const files = useMemo(() => { + const item = + itemKey === undefined + ? undefined + : items.find((entry) => getItemKey(entry) === itemKey); + if (item?.status !== "ready") return []; + return item.files.filter((file) => file.kind === "image"); + }, [items, itemKey]); + + const images = useMemo( + () => + origin + ? files.map((file) => ({ + src: buildStreamUrl(origin, file.playUrl, file.displayName, token), + title: file.displayName + })) + : [], + [files, origin, token] + ); + + // 按文件 id 定位,刷新列表后仍停在同一张;这张图已不存在时关闭 + const index = target + ? files.findIndex((file) => file.id === target.fileId) + : -1; + + return ( + = 0} + onClose={() => onTargetChange(null)} + onIndexChange={(next) => { + // 打开时也会回调一次当前序号,同一张不重复更新 + const file = files[next]; + if (itemKey !== undefined && file && file.id !== target?.fileId) + onTargetChange({ itemKey, fileId: file.id }); + }} + /> + ); +}; diff --git a/src/app/(app)/components/library-list.tsx b/src/app/(app)/components/library-list.tsx index f6984e0..8945697 100644 --- a/src/app/(app)/components/library-list.tsx +++ b/src/app/(app)/components/library-list.tsx @@ -28,6 +28,7 @@ import { useElementWidth } from "@/hooks/use-element-width"; import { useFollowHighlight } from "@/hooks/use-follow-highlight"; import { useGridKeyboardNav } from "@/hooks/use-grid-keyboard-nav"; import type { LibraryItemView } from "@/hooks/use-library"; +import { useStuck } from "@/hooks/use-stuck"; import { cn } from "@/lib/utils"; import { useLibraryFilterStore } from "@/stores/library-filter-store"; import { @@ -40,6 +41,10 @@ import type { SourceType } from "@/types/torbox"; import { FileCard } from "./file-card"; import { FileGrid, FileGridItem } from "./file-grid"; import { ItemHeader } from "./item-header"; +import { + LibraryImagePreview, + type ImagePreviewTarget +} from "./library-image-preview"; import { CARD_FOCUS_SELECTOR, CARD_PROPS, @@ -114,16 +119,22 @@ const getRestoreOffset = ( * 分组标题用原生 sticky 吸顶(由浏览器合成,滚动时不跑 JS):非首个标题有 20px 上留白, * 用 -top-3 藏掉 12px,吸附时与首个标题一样露出 8px 上留白。 */ -const rowClassName = (row: LibraryRow) => { +const rowClassName = (row: LibraryRow, stuck: boolean) => { if (row.type === "notice") return "pb-2"; if (row.type === "header") return cn( - "stuck-divider sticky z-10 bg-background", - row.first ? "top-0 pt-2" : "-top-3 pt-5" + "stuck-divider sticky bg-background", + row.first ? "top-0 pt-2" : "-top-3 pt-5", + // 吸附后两侧各外扩 6px,内边距补回,内容位置不变 + stuck && "-mx-1.5 px-1.5 z-10" ); return "pb-3"; }; +/** 标题行 sticky 的 top(px),与 rowClassName 中的 top-0 / -top-3 对应 */ +const getStickyTop = (row: LibraryRow) => + row.type === "header" && !row.first ? -12 : 0; + type HeaderRow = Extract; type RowViewProps = { @@ -131,8 +142,10 @@ type RowViewProps = { index: number; columns: number; token: string; + scrollRef: React.RefObject; measure: (element: HTMLDivElement | null) => void; onToggle: (row: HeaderRow, index: number) => void; + onPreview: (target: ImagePreviewTarget) => void; }; /** 单行;memo 后滚动只渲染新进入范围的行,已渲染的行不重复渲染 */ @@ -141,11 +154,26 @@ const RowView = memo(function RowView({ index, columns, token, + scrollRef, measure, - onToggle + onToggle, + onPreview }: RowViewProps) { + const [stuckRef, stuck] = useStuck( + scrollRef, + getStickyTop(row), + row.type === "header" + ); + const ref = useCallback( + (element: HTMLDivElement | null) => { + stuckRef.current = element; + measure(element); + }, + [stuckRef, measure] + ); + return ( -
+
{row.type === "notice" && ( @@ -167,7 +195,14 @@ const RowView = memo(function RowView({ {row.files.map((file) => ( - + + onPreview({ itemKey: row.itemKey, fileId: file.id }) + } + /> ))} @@ -330,6 +365,9 @@ const VirtualRows = ({ [virtualizer, toggleItem] ); + // 图片预览:灯箱只渲染一个,按分组取图片 + const [preview, setPreview] = useState(null); + // 卸载时(如列表被空状态替换)补存一次快照 useEffect( () => () => { @@ -379,8 +417,10 @@ const VirtualRows = ({ index={item.index} columns={columns} token={token} + scrollRef={scrollRef} measure={virtualizer.measureElement} onToggle={onToggleHeader} + onPreview={setPreview} /> ); const renderPiece = (piece: RowPiece) => @@ -395,23 +435,31 @@ const VirtualRows = ({ ); return ( - // 单格 grid:列表与准星叠放在同一格,准星通过 transform 移动; - // grid-cols-1 即 minmax(0, 1fr),内容再宽也不会把列撑出容器 -
- {/* 可见行处于正常文档流,未渲染部分用占位块撑出;每个资源分组包一层容器,作为标题吸顶的边界 */} -
- {blocks.map((block) => - block.type === "group" ? ( -
{block.pieces.map(renderPiece)}
- ) : ( - renderPiece(block) - ) - )} + <> + {/* 单格 grid:列表与准星叠放在同一格,准星通过 transform 移动; + grid-cols-1 即 minmax(0, 1fr),内容再宽也不会把列撑出容器 */} +
+ {/* 可见行处于正常文档流,未渲染部分用占位块撑出;每个资源分组包一层容器,作为标题吸顶的边界 */} +
+ {blocks.map((block) => + block.type === "group" ? ( +
{block.pieces.map(renderPiece)}
+ ) : ( + renderPiece(block) + ) + )} +
+
- -
+ + ); }; diff --git a/src/app/(app)/components/library-rows.ts b/src/app/(app)/components/library-rows.ts index 2a845f6..d8380a6 100644 --- a/src/app/(app)/components/library-rows.ts +++ b/src/app/(app)/components/library-rows.ts @@ -33,6 +33,8 @@ export type LibraryRow = | { type: "cards"; key: string; + /** 所属资源分组,图片预览只在这一组内切换 */ + itemKey: string; files: LibraryFileView[]; status: DownloadStatus; } @@ -79,6 +81,7 @@ export const buildLibraryRows = ( rows.push({ type: "cards", key: `cards:${itemKey}:${start / columns}`, + itemKey, files: item.files.slice(start, start + columns), status: item.status }); diff --git a/src/app/(app)/components/playlist-menu.tsx b/src/app/(app)/components/playlist-menu.tsx index b0cf6e3..f8727c7 100644 --- a/src/app/(app)/components/playlist-menu.tsx +++ b/src/app/(app)/components/playlist-menu.tsx @@ -32,7 +32,8 @@ export const PlaylistMenu = ({ token }: { token: string }) => { render={