feat: 引入图片组件
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
# TorBox API Key,仅服务端使用,不会下发到浏览器
|
||||
# 不配置时以演示模式启动:资源库显示内置模拟数据(src/server/mock-library.ts),不可播放
|
||||
# 不配置时以演示模式启动:资源库显示内置模拟数据(src/server/mock-library.ts)
|
||||
TORBOX_API_KEY=
|
||||
# 自定义访问密钥:网页登录、播放链接、播放列表都用它鉴权
|
||||
# 演示模式下可不配置,此时访问密钥为 mock;配置了 TORBOX_API_KEY 时必须配置,否则拒绝启动
|
||||
|
||||
@@ -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` 共用同一套深色值,`<html>` 常驻 `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` |
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Generated
+25
@@ -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
|
||||
|
||||
@@ -16,10 +16,11 @@ export const AppHeader = ({ token, mock }: AppHeaderProps) => (
|
||||
<header className="shrink-0 border-b">
|
||||
<div className="mx-auto flex h-14 w-full max-w-7xl items-center gap-3 px-4 md:px-6">
|
||||
<BrandMark />
|
||||
{/* 手机宽度下放不下,演示模式只在登录页提示 */}
|
||||
{mock && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-muted-foreground"
|
||||
className="hidden text-muted-foreground sm:inline-flex"
|
||||
title="未配置 TORBOX_API_KEY,资源库显示的是内置模拟数据,无法播放"
|
||||
>
|
||||
演示数据
|
||||
|
||||
@@ -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 */}
|
||||
<img
|
||||
@@ -192,7 +187,7 @@ export const FileCard = ({ file, token, status }: FileCardProps) => {
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => setPreviewOpen(true)}
|
||||
onClick={onPreview}
|
||||
{...CARD_FOCUS_PROPS}
|
||||
>
|
||||
<EyeIcon data-icon="inline-start" />
|
||||
@@ -246,24 +241,6 @@ export const FileCard = ({ file, token, status }: FileCardProps) => {
|
||||
<TooltipContent>复制直链</TooltipContent>
|
||||
</Tooltip>
|
||||
</CardFooter>
|
||||
|
||||
{available && file.kind === "image" && (
|
||||
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
|
||||
<DialogContent className="sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="truncate pr-8">
|
||||
{file.displayName}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={streamUrl}
|
||||
alt={file.displayName}
|
||||
className="max-h-[75dvh] w-full rounded-lg bg-muted object-contain"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<LightboxImage[]>(
|
||||
() =>
|
||||
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 (
|
||||
<ImageLightbox
|
||||
images={images}
|
||||
index={Math.max(index, 0)}
|
||||
open={index >= 0}
|
||||
onClose={() => onTargetChange(null)}
|
||||
onIndexChange={(next) => {
|
||||
// 打开时也会回调一次当前序号,同一张不重复更新
|
||||
const file = files[next];
|
||||
if (itemKey !== undefined && file && file.id !== target?.fileId)
|
||||
onTargetChange({ itemKey, fileId: file.id });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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<LibraryRow, { type: "header" }>;
|
||||
|
||||
type RowViewProps = {
|
||||
@@ -131,8 +142,10 @@ type RowViewProps = {
|
||||
index: number;
|
||||
columns: number;
|
||||
token: string;
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
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<HTMLDivElement>(
|
||||
scrollRef,
|
||||
getStickyTop(row),
|
||||
row.type === "header"
|
||||
);
|
||||
const ref = useCallback(
|
||||
(element: HTMLDivElement | null) => {
|
||||
stuckRef.current = element;
|
||||
measure(element);
|
||||
},
|
||||
[stuckRef, measure]
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-index={index} ref={measure} className={rowClassName(row)}>
|
||||
<div data-index={index} ref={ref} className={rowClassName(row, stuck)}>
|
||||
{row.type === "notice" && (
|
||||
<Alert variant="destructive">
|
||||
<TriangleAlertIcon />
|
||||
@@ -167,7 +195,14 @@ const RowView = memo(function RowView({
|
||||
<FileGrid columns={columns} animated>
|
||||
{row.files.map((file) => (
|
||||
<FileGridItem key={file.id} {...CARD_PROPS}>
|
||||
<FileCard file={file} token={token} status={row.status} />
|
||||
<FileCard
|
||||
file={file}
|
||||
token={token}
|
||||
status={row.status}
|
||||
onPreview={() =>
|
||||
onPreview({ itemKey: row.itemKey, fileId: file.id })
|
||||
}
|
||||
/>
|
||||
</FileGridItem>
|
||||
))}
|
||||
</FileGrid>
|
||||
@@ -330,6 +365,9 @@ const VirtualRows = ({
|
||||
[virtualizer, toggleItem]
|
||||
);
|
||||
|
||||
// 图片预览:灯箱只渲染一个,按分组取图片
|
||||
const [preview, setPreview] = useState<ImagePreviewTarget | null>(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,8 +435,9 @@ const VirtualRows = ({
|
||||
);
|
||||
|
||||
return (
|
||||
// 单格 grid:列表与准星叠放在同一格,准星通过 transform 移动;
|
||||
// grid-cols-1 即 minmax(0, 1fr),内容再宽也不会把列撑出容器
|
||||
<>
|
||||
{/* 单格 grid:列表与准星叠放在同一格,准星通过 transform 移动;
|
||||
grid-cols-1 即 minmax(0, 1fr),内容再宽也不会把列撑出容器 */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="grid grid-cols-1 *:col-start-1 *:row-start-1"
|
||||
@@ -413,5 +454,12 @@ const VirtualRows = ({
|
||||
</div>
|
||||
<Reticle ref={highlight.ref} style={highlight.style} />
|
||||
</div>
|
||||
<LibraryImagePreview
|
||||
items={items}
|
||||
token={token}
|
||||
target={preview}
|
||||
onTargetChange={setPreview}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -32,7 +32,8 @@ export const PlaylistMenu = ({ token }: { token: string }) => {
|
||||
render={<Button variant="ghost" disabled={!playlistUrl} />}
|
||||
>
|
||||
<ListVideoIcon data-icon="inline-start" />
|
||||
播放列表
|
||||
{/* 手机宽度下只显示图标,文字留给读屏 */}
|
||||
<span className="sr-only sm:not-sr-only">播放列表</span>
|
||||
<ChevronDownIcon data-icon="inline-end" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// 播放流代理接口
|
||||
import type { NextRequest } from "next/server";
|
||||
import { guard, jsonError } from "@/server/auth";
|
||||
import { isMockMode } from "@/server/env";
|
||||
import { getMockImage } from "@/server/mock-library";
|
||||
import { isSourceType, requestDownloadUrl } from "@/server/torbox";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -30,6 +32,18 @@ const proxy = async (
|
||||
return jsonError(404, "unknown source");
|
||||
}
|
||||
|
||||
// 演示模式:图片返回生成的占位图,其他文件仍提示不可播放
|
||||
const mockImage = isMockMode()
|
||||
? getMockImage(type, Number(id), Number(fileId))
|
||||
: null;
|
||||
if (mockImage)
|
||||
return new Response(method === "HEAD" ? null : mockImage, {
|
||||
headers: {
|
||||
"Content-Type": "image/svg+xml; charset=utf-8",
|
||||
"Cache-Control": "no-store"
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const url = await requestDownloadUrl(type, id, fileId);
|
||||
const range = req.headers.get("range");
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// 图片预览灯箱:基于 yet-another-react-lightbox,支持缩放与组内循环切换
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ImageOffIcon,
|
||||
XIcon,
|
||||
ZoomInIcon,
|
||||
ZoomOutIcon
|
||||
} from "lucide-react";
|
||||
import Lightbox, {
|
||||
type Labels,
|
||||
type Render,
|
||||
type Slide,
|
||||
type SlotStyles
|
||||
} from "yet-another-react-lightbox";
|
||||
import Captions from "yet-another-react-lightbox/plugins/captions";
|
||||
import Counter from "yet-another-react-lightbox/plugins/counter";
|
||||
import Zoom from "yet-another-react-lightbox/plugins/zoom";
|
||||
import "yet-another-react-lightbox/styles.css";
|
||||
import "yet-another-react-lightbox/plugins/captions.css";
|
||||
import "yet-another-react-lightbox/plugins/counter.css";
|
||||
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { LIGHTBOX } from "@/constants/motion";
|
||||
|
||||
export type LightboxImage = {
|
||||
src: string;
|
||||
/** 图片名称:显示在左上角,同时作为 alt */
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type ImageLightboxProps = {
|
||||
/** 同一组图片:只在这一组内切换,首尾循环 */
|
||||
images: readonly LightboxImage[];
|
||||
/** 当前图片在 images 中的序号 */
|
||||
index: number;
|
||||
open: boolean;
|
||||
/** 关闭动画结束后调用 */
|
||||
onClose: () => void;
|
||||
/** 切换图片后调用;父组件据此更新 index,重新渲染时仍停在当前图片 */
|
||||
onIndexChange?: (index: number) => void;
|
||||
};
|
||||
|
||||
const LABELS: Labels = {
|
||||
Lightbox: "图片预览",
|
||||
"Photo gallery": "图片",
|
||||
Carousel: "图片列表",
|
||||
Slide: "图片",
|
||||
"{index} of {total}": "第 {index} 张,共 {total} 张",
|
||||
Previous: "上一张",
|
||||
Next: "下一张",
|
||||
Close: "关闭",
|
||||
"Zoom in": "放大",
|
||||
"Zoom out": "缩小"
|
||||
};
|
||||
|
||||
/** 图标统一用 lucide;加载超过一小段时间才淡入转圈 */
|
||||
const RENDER: Render = {
|
||||
iconPrev: () => <ChevronLeftIcon className="size-8" strokeWidth={1.5} />,
|
||||
iconNext: () => <ChevronRightIcon className="size-8" strokeWidth={1.5} />,
|
||||
iconClose: () => <XIcon className="size-6" />,
|
||||
iconZoomIn: () => <ZoomInIcon className="size-6" />,
|
||||
iconZoomOut: () => <ZoomOutIcon className="size-6" />,
|
||||
iconLoading: () => (
|
||||
<motion.span
|
||||
className="flex"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ ...LIGHTBOX.loadingFade, delay: LIGHTBOX.loadingDelay }}
|
||||
>
|
||||
<Spinner className="size-8 text-muted-foreground" aria-label="加载中" />
|
||||
</motion.span>
|
||||
),
|
||||
iconError: () => (
|
||||
// 占位层的 line-height 为 0,这里恢复正常行高
|
||||
<span className="flex flex-col items-center gap-2 text-sm leading-normal text-muted-foreground">
|
||||
<ImageOffIcon className="size-10" strokeWidth={1.5} aria-hidden="true" />
|
||||
图片加载失败
|
||||
</span>
|
||||
)
|
||||
};
|
||||
|
||||
/** 只有一张时不循环,也不显示左右切换按钮 */
|
||||
const SINGLE_RENDER: Render = {
|
||||
...RENDER,
|
||||
buttonPrev: () => null,
|
||||
buttonNext: () => null
|
||||
};
|
||||
|
||||
/** 灯箱配色映射到现有主题变量,不新增颜色 */
|
||||
const STYLES: SlotStyles = {
|
||||
root: {
|
||||
"--yarl__color_backdrop": "var(--background)",
|
||||
"--yarl__color_button": "var(--muted-foreground)",
|
||||
"--yarl__color_button_active": "var(--foreground)",
|
||||
"--yarl__color_button_disabled":
|
||||
"color-mix(in oklab, var(--muted-foreground) 35%, transparent)",
|
||||
"--yarl__slide_captions_container_background":
|
||||
"linear-gradient(var(--background), transparent)",
|
||||
"--yarl__slide_title_color": "var(--foreground)",
|
||||
"--yarl__slide_title_font_size": "0.875rem",
|
||||
"--yarl__slide_title_font_weight": 500,
|
||||
// 计数移到底部,左上角留给图片名称;自定义属性写 unset 等于未设置,会退回默认的 0,所以用 auto
|
||||
"--yarl__counter_top": "auto",
|
||||
"--yarl__counter_bottom": 0,
|
||||
"--yarl__counter_right": 0,
|
||||
"--yarl__counter_filter": "none"
|
||||
}
|
||||
};
|
||||
|
||||
const ZOOM = {
|
||||
/** 最大放大到图片原始像素的 2 倍,小图也能放大查看 */
|
||||
maxZoomPixelRatio: 2,
|
||||
/** 鼠标滚轮直接缩放 */
|
||||
scrollToZoom: true
|
||||
};
|
||||
|
||||
const CONTROLLER = { closeOnBackdropClick: true, closeOnPullDown: true };
|
||||
|
||||
/**
|
||||
* 通用图片预览:传入一组图片和当前序号,只在这一组内左右切换(首尾循环),
|
||||
* 支持滚轮、双击、双指与工具栏按钮缩放。受控组件,由父组件管理开关与当前序号。
|
||||
*/
|
||||
export const ImageLightbox = ({
|
||||
images,
|
||||
index,
|
||||
open,
|
||||
onClose,
|
||||
onIndexChange
|
||||
}: ImageLightboxProps) => {
|
||||
// 灯箱在 slides 引用变化时会按 index 重置,这里保证 images 不变时引用不变
|
||||
const slides = useMemo<Slide[]>(
|
||||
() => images.map(({ src, title }) => ({ src, alt: title, title })),
|
||||
[images]
|
||||
);
|
||||
const single = slides.length <= 1;
|
||||
|
||||
return (
|
||||
<Lightbox
|
||||
open={open && slides.length > 0}
|
||||
close={onClose}
|
||||
index={index}
|
||||
slides={slides}
|
||||
plugins={single ? [Captions, Zoom] : [Captions, Counter, Zoom]}
|
||||
carousel={{ finite: single }}
|
||||
render={single ? SINGLE_RENDER : RENDER}
|
||||
labels={LABELS}
|
||||
styles={STYLES}
|
||||
zoom={ZOOM}
|
||||
controller={CONTROLLER}
|
||||
counter={{
|
||||
container: {
|
||||
className:
|
||||
"pointer-events-none text-center font-mono text-sm tabular-nums"
|
||||
}
|
||||
}}
|
||||
on={{ view: ({ index: next }) => onIndexChange?.(next) }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -68,6 +68,13 @@ export const TYPEWRITER = {
|
||||
cursorBlink: { duration: 0.55, repeat: Infinity, repeatType: "reverse" }
|
||||
} as const;
|
||||
|
||||
/** 图片预览灯箱 */
|
||||
export const LIGHTBOX = {
|
||||
/** 加载超过这个时间(秒)才显示转圈,缓存命中时不闪一下 */
|
||||
loadingDelay: 0.4,
|
||||
loadingFade: { duration: 0.2 }
|
||||
} as const;
|
||||
|
||||
/** 登录卡片在密钥错误时的抖动 */
|
||||
export const SHAKE = {
|
||||
x: [0, -10, 10, -6, 6, -2, 0],
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// 判断 sticky 元素是否已吸附在滚动容器顶部的 hook
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
|
||||
/**
|
||||
* 元素是否已吸附在滚动容器顶部;ref 挂到 sticky 元素上。
|
||||
* stickyTop 是该元素 sticky 的 top 值(px,可为负)。观察边界比吸附位置低 1px,
|
||||
* 吸附后元素始终被裁掉 1px,IntersectionObserver 只在跨过这条线时回调,滚动时不跑 JS。
|
||||
*/
|
||||
export const useStuck = <T extends HTMLElement>(
|
||||
scrollRef: RefObject<HTMLElement | null>,
|
||||
stickyTop: number,
|
||||
enabled = true
|
||||
) => {
|
||||
const ref = useRef<T | null>(null);
|
||||
const [stuck, setStuck] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const node = ref.current;
|
||||
const root = scrollRef.current;
|
||||
if (!enabled || !node || !root) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) =>
|
||||
setStuck(entry.boundingClientRect.top < (entry.rootBounds?.top ?? 0)),
|
||||
{ root, rootMargin: `${-(stickyTop + 1)}px 0px 0px 0px`, threshold: 1 }
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [scrollRef, stickyTop, enabled]);
|
||||
|
||||
return [ref, stuck] as const;
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
LibraryResponse,
|
||||
SourceType
|
||||
} from "@/types/torbox";
|
||||
import { getBaseName, getMediaKind } from "@/utils/media";
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const MB = 1024 ** 2;
|
||||
@@ -263,3 +264,47 @@ export const getMockLibrary = (now = Date.now()): LibraryResponse => ({
|
||||
items: MOCK_ITEMS.map((mock, index) => toLibraryItem(mock, index, now)),
|
||||
errors: {}
|
||||
});
|
||||
|
||||
const escapeXml = (text: string) =>
|
||||
text.replace(
|
||||
/[&<>"]/g,
|
||||
(char) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[char]!
|
||||
);
|
||||
|
||||
/** 演示图片:渐变底 + 点阵 + 文件名;每 4 张有 1 张竖图,用来检查不同比例的显示与缩放 */
|
||||
const renderPlaceholderSvg = (title: string, seed: number) => {
|
||||
const hue = (seed * 47) % 360;
|
||||
const [width, height] = seed % 4 === 0 ? [1080, 1440] : [1600, 1000];
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="hsl(${hue} 55% 34%)"/>
|
||||
<stop offset="1" stop-color="hsl(${(hue + 60) % 360} 60% 12%)"/>
|
||||
</linearGradient>
|
||||
<pattern id="dots" width="14" height="14" patternUnits="userSpaceOnUse">
|
||||
<circle cx="7" cy="7" r="1.2" fill="rgba(255,255,255,.18)"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#bg)"/>
|
||||
<rect width="100%" height="100%" fill="url(#dots)"/>
|
||||
<text x="50%" y="50%" text-anchor="middle" font-family="monospace" font-size="${width / 18}" fill="rgba(255,255,255,.92)">${escapeXml(title)}</text>
|
||||
<text x="50%" y="${height / 2 + width / 16}" text-anchor="middle" font-family="monospace" font-size="${width / 56}" fill="rgba(255,255,255,.6)">${width} × ${height} · 演示图片</text>
|
||||
</svg>`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 演示模式下图片文件的内容:生成一张 SVG 占位图,缩略图与图片预览都能正常显示。
|
||||
* 不是图片、资源未就绪或找不到文件时返回 null,按演示模式不可播放处理。
|
||||
*/
|
||||
export const getMockImage = (
|
||||
source: SourceType,
|
||||
id: number,
|
||||
fileId: number
|
||||
) => {
|
||||
const mock = MOCK_ITEMS[id - 1000];
|
||||
if (!mock || mock.source !== source || (mock.status ?? "ready") !== "ready")
|
||||
return null;
|
||||
const file = mock.files[fileId - 1];
|
||||
if (!file || getMediaKind(file.name, file.mimetype) !== "image") return null;
|
||||
return renderPlaceholderSvg(getBaseName(file.name), id * 31 + fileId * 7);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user