Initial commit

This commit is contained in:
zhongyidian committed 2026-09-08 14:19:44 +08:00
commit 6b1e803320
158 files changed
+16461

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
**/*.css
**/*.module.css
+50
View File
@@ -0,0 +1,50 @@
# Commit Message 规则
所有 commit message 必须使用中文编写。
Commit message 格式:
- 类型: 简短描述(50字以内)
- 空行
- 详细描述(可选,72字以内换行)
类型包括:
- feat: 新功能
- fix: 修复 bug
- docs: 文档变更
- style: 代码格式(不影响代码运行的变动)
- refactor: 重构
- perf: 性能优化
- test: 增加测试
- chore: 构建过程或辅助工具的变动
- ci: 持续集成相关
- build: 构建系统或外部依赖的变更
示例:
```
fix: 修复 TypeScript 配置文件中的 lint 错误
- 添加 incremental 选项以支持 tsBuildInfoFile
- 移除不存在的 noUncheckedSideEffectImports 选项
```
React 项目工程化结构
```
src/
├── assets/ # 静态资源 (images, fonts, global svgs)
├── api/ # API 请求定义 (推荐用 Axios 或 React Query 封装)
├── components/ # 复用 UI 组件
│ ├── common/ # 原子级基础组件 (Button, Input, Modal)
│ ├── business/ # 业务逻辑组件 (UserCard, OrderList)
│ └── layout/ # 页面框架组件 (Header, Sidebar)
├── constants/ # 常量定义 (Enums, Configs)
├── hooks/ # 自定义全局 Hooks (useAuth, useLocalStorage)
├── pages/ # 页面级组件 (路由对应)
│ └── Home/
│ ├── index.tsx # 页面主入口
│ ├── components/ # 页面私有组件 (不对外复用)
│ └── hooks.ts # 页面私有逻辑
├── store/ # 状态管理 (Zustand, Redux, 或 Context)
├── styles/ # 全局样式 (Variables, Theme, Reset)
├── types/ # 全局 TypeScript 类型定义 (.d.ts)
└── utils/ # 工具函数 (date, format, validator)
```
+28
View File
@@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Playwright
test-results
playwright-report
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+16
View File
@@ -0,0 +1,16 @@
# <类型>: <简短描述>
# <详细描述(可选)>
# <类型> 可以是以下之一:
# feat: 新功能
# fix: 修复 bug
# docs: 文档变更
# style: 代码格式(不影响代码运行的变动)
# refactor: 重构(既不是新增功能,也不是修复 bug)
# perf: 性能优化
# test: 增加测试
# chore: 构建过程或辅助工具的变动
# ci: 持续集成相关
# build: 构建系统或外部依赖的变更
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
bunx lint-staged
+27
View File
@@ -0,0 +1,27 @@
# Dependencies
node_modules
bun.lock
package-lock.json
yarn.lock
pnpm-lock.yaml
# Build outputs
dist
dist-ssr
*.local
# Logs
logs
*.log
# OS
.DS_Store
# IDE
.vscode/*
!.vscode/extensions.json
.idea
# Temp
*.tmp
*.temp
+11
View File
@@ -0,0 +1,11 @@
# 锁定包管理器为 bun
package-manager=bun
# 严格模式:不允许使用其他包管理器
engine-strict=true
# 自动安装 peers
auto-install-peers=true
# 使用国内镜像(可选)
registry=https://registry.npmmirror.com
+187
View File
@@ -0,0 +1,187 @@
# CLAUDE.md
本文件记录 Claude Code 在本仓库中开发、创建文件和修改代码时必须遵守的项目规范。规范以当前仓库的代码、配置和 README 为准;如配置发生变化,优先同步更新本文件。
## 项目概览
- 技术栈:React 19、TypeScript、Vite 8、React Router 7、Tailwind CSS 4、Zustand。
- 包管理与脚本:使用 Bun。
- 代码检查与格式化:使用 Biome。
- 提交前检查:Husky + lint-staged。
- 运行环境:易班 H5 课程模板,包含 videoajax.js 封装、1920 x 1080 整体缩放、横屏访问提示和移动端 vconsole 调试。
## 常用命令
以 [package.json](package.json) 为准:
```bash
bun dev # 启动开发服务器
bun run build # 类型检查并构建生产版本
bun run preview # 预览生产构建
bun run lint # 代码检查
bun run lint:fix # 自动修复代码问题
```
修改代码后,至少运行 `bun run lint`。如果改动影响路由、构建配置、页面结构、类型系统或模板能力,还应运行 `bun run build`。
## 代码风格
以 [biome.json](biome.json) 为准:
- 使用 Biome 进行 JS/TS/JSON 等文件的格式化与检查。
- 使用 tab 缩进。
- JavaScript/TypeScript 字符串使用双引号。
- 导入整理交给 Biome 的 organizeImports。
- 当前 CSS linter/formatter 关闭,不要擅自引入新的 CSS 格式化标准。
- 写代码时匹配周围代码的命名、注释密度和组织方式,不为了“统一”改写无关文件。
## 文件创建与目录归属
新增文件前先判断职责归属,优先沿用现有目录结构:
- `src/components/ui/`:无课程业务和全局状态依赖的基础 UI copied source。
- `src/components/common/`:模板通用组件和适配器;其中 `GuideDialog`、`GuideSpotlight` 负责连接 Zustand、CourseCanvas 与交互内核。新增通用组件后,按需在 `src/components/common/index.ts` 维护导出。
- `src/components/business/`:业务组合组件,不放通用基础能力。
- `src/course/canvas/`:设计稿画布、缩放、viewport 和课程 Portal 根节点。
- `src/course/orientation/`:横竖屏访问控制。
- `src/course/interactions/`:由 CLI 复制的受控课程交互内核,不依赖具体页面、Store 或 common 适配器。
- `src/pages/<PageName>/`:页面组件。页面目录入口使用 `index.tsx`,页面组件使用默认导出。
- 模块内私有组件:仅本模块使用、其他模块用不到的,放到本模块目录的 `components/` 子目录(没有就创建);被多个模块或组件复用才下沉到 `src/components/common/`(通用基础能力)或 `business/`(业务组合),按组件核心类型区分。该规则约束新代码,存量平铺在页面目录下的模块内组件不强制迁移。
- `src/router/index.tsx`:集中维护路由配置。新增页面优先复用 `lazyRoute` 和 `RouteFallback`。
- `src/stores/`:Zustand 状态管理。
- `src/utils/`:通用工具、hooks 或平台封装。
- `src/types/`:只放跨多个模块复用的全局共享类型。
- `src/styles/`:全局样式和重置样式。
### 类型放置规则
组件、页面、store、API 的对外 `props` 与跨文件共享的 `interface`、`type` 放在 `.types.ts`;文件内私有的 helper 类型留在实现文件即可:
- 组件类型放在同组件目录的 `组件名.types.ts`,例如 `Button/Button.types.ts`。
- 页面类型如确实需要,放在页面目录的 `PageName.types.ts`。
- store 类型统一放在 `src/stores/types/*.types.ts`,例如 `src/stores/types/user.types.ts`。
- API 模块类型放在对应模块的 `*.types.ts`,例如 `src/api/client.types.ts`。
- 只有跨多个页面、组件、store、utils 复用的全局共享类型,才放入 `src/types/`。
- 不要把组件 props、store slice state 或组件内部布局类型放入 `src/types/`,避免全局类型目录变成杂物目录。
## Tailwind、cn 与样式提取规则
项目已有 [src/lib/cn.ts](src/lib/cn.ts) 中的 `cn`,用于合并 Tailwind class 并处理冲突([src/utils/tailwind.ts](src/utils/tailwind.ts) 只是 re-export)。使用时遵守以下边界。
### 默认策略
- 短的静态 Tailwind class 可以直接写在 JSX 的 `className` 中。
- 不为了“看起来统一”强行把所有 class 都包进 `cn`。
- 不为了少量 class 新增多层封装、过度抽象或大量碎片常量。
### 什么时候使用 cn
在以下场景使用 `cn`:
- 需要合并外部传入的 `className`。
- 条件 class 需要和其他 class 合并。
- 需要通过 `tailwind-merge` 解决 Tailwind 冲突合并。
- 需要组合 CSS Module、固定 class、条件 class 或外部 `className`。
示例:
```tsx
<div className={cn(styles.panel, active && "opacity-100", className)} />
```
### 什么时候不使用 cn
以下场景不要使用 `cn`:
```tsx
<div className="flex items-center justify-center" />
<div className={active ? "h-2 w-8 bg-current" : "h-2 w-2 bg-current/20"} />
```
单个固定字符串,或在两个完整字符串之间二选一的三元且不需要再合并其他 class 时,直接写 `className`。
### 什么时候提取样式
只有在以下情况才提取 class:
- Tailwind 字符串过长,导致 JSX 难读。
- 同一组 class 在同一文件或多个相近组件中重复出现。
- 条件组合已经让 `cn(...)` 变得很长。
- 提取后能更清楚表达布局或业务意图。
- Tailwind 直接实现过于麻烦、复杂或冗余,此时优先考虑 CSS Module、已有组件 API 或更简单的结构。
提取位置:
- 组件/页面私有样式常量优先放在当前文件内。
- 同目录多个文件共享时,可放同目录局部文件。
- 跨组件复用的样式能力,再考虑沉淀为通用组件、工具或 CSS Module。
### 组件与 CSS 类的边界
「何时提取 class」见上一节;本节只解决复用一段 UI 时该抽组件还是抽 CSS 类。
组件/页面文件超过 500 行时,先审视是否该拆分(这是触发思考的信号,不是强制拆分线);拆出的子组件按「文件创建与目录归属」放置。
重复出现时优先抽组件,CSS 类是兜底,但避免「为整洁而抽」。抽组件必须同时满足:
- 同一组「标记 + 样式 + 行为」在 2 处以上真实重复(第一次出现不抽)。
- 组件有清晰可命名的语义,能一句话说清它是什么。
- 复用的是整体而非几个 class;props 少且稳定(一般 ≤3~4 个)。
以下情况改用 CSS 类或局部常量,而不是抽组件:
- 只复用样式、不复用标记/行为,内容由数据或富文本决定。
- 仅同一文件内的小重复。
- 给第三方/框架等无法插入组件的位置加样式。
- Tailwind 表达不了或 class 串过长,但元素本身不够独立语义。
反模式(不要抽):只被用一次仅为缩短 JSX;组件 props 塞满 className/style 覆盖;名字说不清职责(`CardTopSection`、`Wrapper` 之类)。
## 动画使用规则
项目使用 CSS/Tailwind 和 GSAP 时遵守以下边界:
- 普通状态过渡、hover、active、简单淡入淡出及单元素入场默认使用 CSS/Tailwind。
- 只有动画需要时间轴编排、暂停/继续/倒放、运行时动态计算、音视频同步或复杂路径时,才使用 GSAP。
- 不因项目已经安装 GSAP 而重写现有 CSS 动画,也不为简单动画建立 GSAP preset 或通用封装。
- CSS 与 GSAP 不要在同一交互周期内控制同一元素的同一属性,尤其是 `transform` 和 `opacity`。
- GSAP 动画通过 `src/utils/motion.ts` 使用 `useGSAP` 管理生命周期,并处理 `prefers-reduced-motion` 降级。
- 不使用 GSAP 修改 `CourseCanvas`、画布 portal 根节点或 dnd-kit 正在控制的 `transform`。
## 导入与导出
- `@` 指向 `src`,新增代码优先使用 `@` 避免过长相对路径。
- 同目录内局部文件可以继续使用相对路径。
- 保持现有 barrel export 风格:通用组件、utils、stores 等需要对外复用时,再维护对应 `index.ts`。
- 不要为了导出方便把不该公开的内部实现暴露出去。
## 路由与页面
- 路由集中在 [src/router/index.tsx](src/router/index.tsx)。
- 新页面优先使用目录入口懒加载,例如 `lazyRoute(() => import("../pages/About"))`。
- 页面组件使用默认导出。
- 新增路由时同步考虑 `HydrateFallback: RouteFallback`。
- 不要绕开现有 hash router 和懒加载模式,除非用户明确要求调整路由架构。
## 模板能力约束
开发时要尊重模板已有能力:
- 页面按 `1920 x 1080` 设计稿开发,整体缩放由 `src/course/canvas` 的 `CourseCanvas` 组件承担。
- 横屏访问提示由 `src/course/orientation` 的 `OrientationGuard` 组件承担。
- 易班能力通过 `src/utils/yiban.ts` 和 `public/videoajax/` 相关脚本封装。
- 移动端调试使用 `src/utils/vconsole.ts`。
- 图片按钮由 `src/components/common/ImageButton` 提供;视频播放与视频弹层由 `src/components/common/Video` 提供。
- 拖拽交互由 `src/course/interactions/drag-drop` 提供(基于 dnd-kit)。
- 音频能力由 `src/utils/audio` 提供(`GameAudioProvider` / `useGameAudio` / `AudioManager`)。
- GSAP 复杂动画通过 `src/utils/motion.ts` 统一引入,边界见「动画使用规则」。
- 新增通用组件时,同步考虑是否需要在 [src/pages/Components/index.tsx](src/pages/Components/index.tsx) 增加示例。
## Claude Code 工作准则
- 编辑前先阅读相关文件,查找现有模式和可复用实现。
- 优先复用已有组件、工具、store 和样式方案。
- 不随意替换技术栈、引入新依赖或新增全局抽象。
- 新建文件时先判断目录归属、类型放置位置、是否需要导出、是否需要示例页。
- 修改配置、路由、构建、全局样式等影响范围较大的内容前,应确认需求明确。
- 不绕过 lint/build 结果;如果校验失败,必须如实报告失败命令、错误摘要和影响范围。
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+743
View File
@@ -0,0 +1,743 @@
# 易班 H5 开发模板
一个基于 React 19、TypeScript、Vite、React Router、Tailwind CSS 和 Zustand 的易班平台 H5 开发模板。
## 特性
- React 19 + TypeScript
- Vite 8 开发与构建
- React Router 7 路由管理(支持懒加载)
- Tailwind CSS 4 原子化样式
- Zustand 轻量状态管理
- Biome 代码格式化与检查
- Husky + lint-staged 提交前检查
- 1920x1080 整体缩放适配
- 横屏访问提示
- vconsole 移动端调试工具(仅开发环境)
- 易班 videoajax.js 封装
- 易班用户信息状态管理
- 图片按钮、视频播放器与视频弹层基础组件
- 音频管理(配音 / BGM / 音效)
- GSAP 动画能力(复杂时间轴编排)
- `@` 指向 `src` 的路径别名
- 通过 `@yuxiang/cli` 安装并提交可直接修改的课程交互源码
- 组件分层:Registry copied source、模板适配器、业务组件、页面组件
## 使用模板
### 通过 GitHub 页面创建
1. 打开模板仓库页面
2. 点击 **Use this template**
3. 创建自己的新仓库
4. 克隆新仓库并安装依赖
```bash
git clone https://github.com/your-name/your-app.git
cd your-app
bun install
bun dev
```
### 通过 GitHub CLI 创建
```bash
gh repo create my-react-app --template your-name/yx-react-template --public
cd my-react-app
bun install
bun dev
```
创建项目后,建议修改 [package.json](package.json) 中的 `name` 字段,以及 [src/app.config.ts](src/app.config.ts) 中的页面标题、描述和图标配置。
## 应用配置
页面标签页标题、描述、图标、课程标识、存储前缀和 viewport 配置统一维护在 [src/app.config.ts](src/app.config.ts):
```ts
export const appConfig = {
title: "React Vite App",
description: "易班 H5 课程模板",
favicon: "./vite.svg",
course: {
id: import.meta.env.VITE_COURSE_ID ?? "course-demo",
name: import.meta.env.VITE_COURSE_NAME ?? "标准课程页示例",
},
storage: {
prefix: "yiban-course-template",
},
viewport: {
width: "device-width",
initialScale: 1.0,
minimumScale: 1.0,
maximumScale: 1.0,
},
};
```
课程画布尺寸和最大缩放比例由 `CourseCanvas` props 控制,不属于 `appConfig`。修改应用配置后需要重启开发服务。
## 开发命令
```bash
bun dev # 启动开发服务器
bun run build # 类型检查并构建生产版本
bun run preview # 预览生产构建
bun run lint # 代码检查
bun run lint:fix # 自动修复代码问题
```
## 开发规范
完整的 Claude Code/自动化开发规范见 [CLAUDE.md](CLAUDE.md)。日常开发遵守以下约定:
- 使用 Bun 执行项目脚本,提交前确保 `bun run lint` 通过;涉及路由、构建配置、页面结构或类型系统的改动建议同时运行 `bun run build`。
- 代码风格以 [biome.json](biome.json) 为准:tab 缩进、双引号、Biome 检查和导入整理。
- 组件、页面、store、API 的对外 Props 和跨文件共享类型放入 `.types.ts`;文件内私有 helper 类型留在实现文件。store 类型放 [src/stores/types/](src/stores/types/),只有跨多个模块复用的全局类型才放入 [src/types/](src/types/)。
- 短的静态 Tailwind class 可直接写在 JSX 中;只有需要条件合并、外部 `className`、冲突合并或组合 CSS Module 时才使用 `cn`。
- Tailwind class 过长、重复、条件组合复杂,或直接实现过于冗余时,再提取为局部常量、同目录文件、CSS Module 或通用组件。
- 新增页面沿用 [src/router/index.tsx](src/router/index.tsx) 中的 `lazyRoute` 和 `RouteFallback` 模式,导入优先使用 `@` 指向 `src` 的路径别名。
- 新增通用组件时,按需维护 [src/components/common/index.ts](src/components/common/index.ts),并考虑在 [src/pages/Components/index.tsx](src/pages/Components/index.tsx) 补充示例。
## 课程交互源码
模板中的基础组件和课程交互不是外部 React 运行时包,而是由 `@yuxiang/cli` 复制并提交到当前项目:
```text
src/components/ui/ # Button、Portal、Overlay、Toast
src/course/interactions/ # GuideDialog、GuideSpotlight、DragDrop 受控内核
src/course/canvas/ # 固定课程画布、缩放与 Portal 根节点
src/course/orientation/ # 横竖屏访问控制
src/lib/cn.ts # 本地 class 合并工具
```
项目根目录的 [components.json](components.json) 配置 copied source 的目录与 alias。需要新增能力时运行:
```bash
bunx @yuxiang/cli add <item>
```
CLI 默认不会覆盖不同内容的本地文件。安装后请把源码作为模板代码维护,不要重新添加 `@yuxiang/react` 或 `@yuxiang/core` 依赖。
## 真实课程案例
本地打开以下路由可体验连贯的课程流程:
```text
http://localhost:5173/#/course-demo
```
案例包含课程导入与分支、页面 Spotlight、课程资源分类拖拽、错误/正确 Toast、完成 Overlay,以及易班或 session fallback 的进度保存与刷新恢复。
## 组件开发 Smoke 页
本地开发时可以打开组件示例页查看和测试基础组件:
```text
http://localhost:5173/#/components
```
该路由仅用于开发时检查 copied source 的基础状态,不作为产品级组件展示墙。真实组合用法以 `#/course-demo` 为准。
当前 smoke 页包含:
- `Button`: variant 和 size 示例
- `Toast`: info / success / warning / error 轻提示示例
- `Portal`: 挂载到 `document.body` 或自定义容器的浮层示例
- `Overlay`: 默认背景、自定义 Tailwind 背景、背景图片、禁止点击背景关闭等交互示例
- `GuideSpotlight`: 课程引导和高亮目标区域示例
- `Drag and Drop`: 基于 dnd-kit 的拖拽到目标区域示例
后续新增通用组件时,可以在 [src/pages/Components/index.tsx](src/pages/Components/index.tsx) 中补充对应示例。
## 目录结构
```text
src/
├── api/ # API 请求相关代码
├── assets/ # 项目资源
├── components/
│ ├── business/ # 业务组合组件
│ ├── common/ # 模板通用组件和 Store/Canvas 适配器
│ └── ui/ # Button、Portal、Overlay、Toast 等基础 UI
├── course/
│ ├── canvas/ # 1920 x 1080 画布、viewport 与 Portal 根节点
│ ├── interactions/ # Guide、DragDrop 等课程交互内核
│ └── orientation/ # 横竖屏访问控制
├── lib/ # 无业务依赖的底层工具
├── pages/ # 页面组件
├── router/ # 路由配置
├── stores/
│ ├── modules/ # Zustand store 实现
│ └── types/ # Store 类型
├── styles/ # 全局样式
├── types/ # 跨多个模块共享的全局类型
└── utils/
├── audio/ # 音频管理
├── motion.ts # GSAP 动画统一入口
├── vconsole.ts # 移动端调试
└── yiban.ts # 易班 API 封装
```
`src/course/interactions` 保持无具体课程 Store 依赖;`src/components/common/GuideDialog` 和 `GuideSpotlight` 负责连接 Zustand、CourseCanvas 和 Portal。
## 易班开发说明
模板面向易班 H5 / 静态资源相对路径部署,Vite `base` 固定为 `"./"`,确保构建产物在未知子路径或压缩包上传场景下仍能引用资源。除非部署环境明确要求根路径资源,否则不要轻易改成 `"/"`。
### videoajax.js 使用
模板已集成易班 videoajax.js,提供以下功能:
- [public/videoajax/jquery-3.7.1.min.js](public/videoajax/jquery-3.7.1.min.js)
- [public/videoajax/videoajax.js](public/videoajax/videoajax.js)
这些文件需要和构建后的页面同源、同相对路径部署。若易班平台提供了新的 `videoajax.js`,请同步确认来源、版本和分发权限后再替换,并重新验证用户数据、课程进度、断点和答题记录相关能力。
### 易班环境判断
模板启动时会从 URL 中读取:
- `courseId` / `courseid`
- `uid`
生产环境中,只要存在以上任意参数,就会认为当前处于易班环境。
本地开发环境中,即使 URL 带有 `courseId` / `uid`,也默认使用 `sessionStorage`,避免误请求易班接口:
```text
http://localhost:5173/?courseId=123&uid=456
```
如果本地确实需要强制走易班接口,可追加:
```text
http://localhost:5173/?courseId=123&uid=456&yiban=1
```
模板已集成以下易班能力:
#### 1. 存取用户数据
```ts
import { setUserStorage, getUserStorage } from '@/utils/yiban';
// 保存数据(自动根据易班连接状态选择存储方式)
await setUserStorage(lessonId, key, value);
// 读取数据
const result = await getUserStorage(lessonId, key);
if (result.status === 'success') {
console.log(result.content);
}
// 强制指定存储方式
await setUserStorage(lessonId, key, value, { backend: 'session' });
```
#### 2. 课程进度管理
```ts
import { reportCourseProgress, getCourseProgress } from '@/utils/yiban';
// 上报课程进度(自动读取 store 中的 courseId 和连接状态)
await reportCourseProgress(order);
// 获取课程进度
const progress = await getCourseProgress(order);
if (progress) {
// videoajax.js 返回平台原始响应,使用前请按平台响应结构进行类型收窄
console.log("当前进度响应:", progress);
}
```
#### 3. 播放断点管理
```ts
import { saveBreakPoint, getBreakPoint } from '@/utils/yiban';
// 保存播放断点
await saveBreakPoint({
userId: '123456',
courseId: '789',
lessonId: '001',
breakPoint: 120, // 秒
});
// 获取播放断点
const breakpoint = await getBreakPoint({
userId: '123456',
courseId: '789',
lessonId: '001',
});
```
#### 4. 用户答题记录
```ts
import { saveUserAnswer } from '@/utils/yiban';
// 保存答题记录
await saveUserAnswer({
userId: '123456',
lessonId: '001',
courseId: '789',
questionOrder: 1,
answer: 'A',
});
```
#### 5. 用户状态管理
```ts
import { useUserStore } from '@/stores';
function MyComponent() {
const { userId, userName, setUserInfo, setYibanConnectionStatus } = useUserStore();
// 设置易班连接状态
setYibanConnectionStatus('connected');
// 设置用户信息
setUserInfo({
userId: '123456',
userName: '张三',
userAvatar: 'https://...'
});
}
```
### 移动端调试
开发环境自动启用 vconsole,可在移动端查看:
- Console 日志
- Network 请求
- Element 结构
- Storage 存储
### 课程画布适配
模板按 `1920 x 1080` 设计稿开发,由 `src/course/canvas` 的 `CourseCanvas` 组件承担整体缩放。`CourseCanvas` 默认 `width=1920`、`height=1080`、`maxScale=1`,如需其他设计稿尺寸或放大比例,显式传入对应 props 即可。
当前默认缩放策略:
- PC 端:按可视区域等比缩放并居中显示
- 大屏:`maxScale` 默认为 `1`,不会超过设计稿原始尺寸,周围留白
- 移动端:优先横屏访问,竖屏时由 `OrientationGuard` 显示横屏提示
- 视口:缩放逻辑优先读取 `visualViewport`,移动端地址栏显示/隐藏时更稳定
- 页面内容:按 `1920 x 1080` 设计稿进行开发,不使用 rem 适配
本地运行 `bun dev` 后,可以访问标准课程页示例:
```text
http://localhost:5173/#/course-demo
```
`CourseDemo` 用于展示标准课程页面结构、易班 session fallback、Toast、Overlay 和 GuideSpotlight 的基础接入方式。
## 背景、图片按钮与视频
### BackgroundContainer
`BackgroundContainer` 用于给页面区域设置背景图片。`src` 可以传入相对 `src/assets/images/` 的静态资源 key,也可以传入 public 相对路径或外部 URL:
```tsx
import { BackgroundContainer } from "@/components/common";
import { getPublicPath } from "@/utils/assets";
<BackgroundContainer
src={getPublicPath("images/course-bg.jpg")}
fit="cover"
position="center"
className="h-full w-full"
>
<section className="p-10">课程内容</section>
</BackgroundContainer>;
```
调用方传入的 `style` 优先级最高,可以覆盖 `backgroundImage`、`backgroundSize` 等默认值。
### ImageButton
`ImageButton` 提供图片按钮、hover/按下缩放、禁用状态和键盘焦点样式:
```tsx
import { ImageButton } from "@/components/common";
<ImageButton
src="start-button.png"
alt="开始学习"
width={280}
height={96}
onClick={startCourse}
/>;
```
静态资源 key 相对 `src/assets/images/`;外部 `http(s)`、`data:` 和 `blob:` URL 会直接使用。图片按钮应提供可访问名称,例如 `alt` 或 `aria-label`。
### VideoPlayer 与 VideoOverlay
```tsx
import { VideoOverlay, VideoPlayer } from "@/components/common";
<VideoPlayer
src="./videos/lesson.mp4"
poster="./images/video-poster.jpg"
mode="window"
onEnded={handleEnded}
/>;
<VideoOverlay
open={videoOpen}
src="./videos/intro.mp4"
skipLabel="跳过"
onSkip={() => setVideoOpen(false)}
onClose={() => setVideoOpen(false)}
onEnded={() => setVideoOpen(false)}
/>;
```
`VideoOverlay` 默认挂载到课程画布 Portal 根节点。`dismissible=false` 时不会通过背景点击或 Esc 自动关闭,播放结束、跳过和流程推进仍由调用方决定。
## 弹层基础组件
模板内置 `Portal` 和 `Overlay`,用于课程页面组合自定义弹窗、提示层、章节面板、全屏 loading 等内容。
`Overlay` 内置 `Portal`。不传 `container` 时默认挂载到 `document.body`;如需跟随 `1920 x 1080` 课程画布一起缩放,应传入 `CourseCanvas` 提供的 `portalContainer`:
```tsx
import { Overlay } from "@/components/common";
import { useCourseCanvas } from "@/course/canvas";
function CoursePanel() {
const { portalContainer } = useCourseCanvas();
return (
<Overlay
open={open}
container={portalContainer}
onClose={() => setOpen(false)}
backgroundImage="url(./images/dialog-bg.png)"
backgroundClassName="bg-cover bg-center"
contentClassName="flex items-center justify-center"
>
<img src="./images/dialog.png" alt="课程提示" />
</Overlay>
);
}
```
说明:
- `backgroundImage`:原始 CSS `background-image` 值,组件不参与路径处理;引用 public 资源时可用 `getPublicPath`。
- `backgroundClassName`:控制背景层样式。
- `contentClassName`:控制内容层布局。
- `closeOnBackgroundClick`:点击背景关闭,默认开启。
- `closeOnEscape`:按 Esc 关闭,默认开启。
- `lockScroll`:打开时锁定页面滚动,默认开启。
- `variant`:提供 `dim` / `light` / `transparent` 三种基础背景。
- `fixed`:默认在无 `container` 时使用 fixed,传入 `container` 时使用 absolute;可以显式覆盖。
如果需要挂载到业务自己的容器,也可以传入 DOM 元素、React ref 或返回元素的函数:
```tsx
import { useRef } from "react";
import { Overlay } from "@/components/common";
function LocalPanel() {
const rootRef = useRef<HTMLDivElement>(null);
return (
<div ref={rootRef} className="relative">
<Overlay
container={rootRef}
open={open}
onClose={() => setOpen(false)}
>
业务弹层
</Overlay>
</div>
);
}
```
`Overlay` 当前没有 `placement` prop;渲染位置只由 `container` 和 `disabledPortal` 决定。
## Toast 轻提示
模板内置 `ToastProvider`、`ToastViewport` 和 `useToast`,用于保存成功、提交失败、网络异常等轻量反馈。
`App` 已默认接入 `ToastProvider` 和 `ToastViewport`,页面组件中可以直接使用:
```tsx
import { useToast } from "@/components/common";
const toast = useToast();
toast.success("保存成功");
toast.error("网络异常,请稍后重试");
```
支持:
- `toast.info(message)`
- `toast.success(message)`
- `toast.warning(message)`
- `toast.error(message)`
- `toast.show({ message, type, title, duration, className, data })`
如果课程视觉风格不同,可以自定义 `ToastViewport`:
```tsx
<ToastViewport
classNames={{
item: "rounded-full border-0 bg-black/80 px-8 py-4 text-white",
title: "hidden",
message: "text-white",
close: "text-white/70",
}}
/>
```
也可以完全接管单条 Toast 渲染:
```tsx
<ToastViewport
renderToast={(toast, { remove }) => (
<div className="pointer-events-auto rounded-full bg-black/80 px-8 py-4 text-white">
<span>{toast.message}</span>
<button onClick={() => remove(toast.id)}>关闭</button>
</div>
)}
/>
```
本地运行 `bun dev` 后,可以访问 `/#/components` 查看 Toast 示例。
## 拖拽交互
模板引入 dnd-kit 作为拖拽基础能力,适合网络课程中常见的拖物品到目标区、分类、匹配、排序等交互。
已封装基础组件:
- `DragDropProvider`: 拖拽上下文,默认启用 Pointer / Touch / Keyboard sensors。
- `Draggable`: 可拖拽元素。
- `Droppable`: 可投放区域。
基础示例:
```tsx
import { DragDropProvider, Draggable, Droppable } from "@/course/interactions/drag-drop";
import { useCourseCanvas } from "@/course/canvas";
const { scale } = useCourseCanvas();
<DragDropProvider
scale={scale}
onDragEnd={(event) => console.log(event.active.id, event.over?.id)}
>
<Draggable id="apple">苹果</Draggable>
<Droppable id="fruit-zone">水果区域</Droppable>
</DragDropProvider>;
```
由于模板整体使用 `transform: scale(...)` 缩放课程画布,拖拽内容在画布内时建议给 `DragDropProvider` 传入当前 `scale`,避免拖拽元素和鼠标/手指移动不同步。
`DragDropProvider` 默认会根据 `scale` 修正拖拽启动距离和拖拽位移,并使用 `pointerWithin` 优先、`closestCenter` 兜底的碰撞检测策略,让“拖到目标区域”类课程交互更符合手指/鼠标位置直觉。
本地运行 `bun dev` 后,可以访问 `/#/components` 查看拖拽示例。
> 当前基础封装优先覆盖多端拖拽和投放区域判断。排序、分类、拼图等更上层课程交互可以继续基于这些组件封装。
## 引导聚光灯
模板提供 `GuideSpotlight` 和 `defineSpotlight`,适合课程首次学习时突出关键操作区域。页面已经位于 `CourseCanvas` 内时,组件默认挂载到课程画布 Portal 根节点并自动使用画布 viewport。
### 基础使用
先定义引导。未显式提供 `target` 时,`defineSpotlight` 默认使用和步骤 `id` 相同的 `data-guide`:
```tsx
import { defineSpotlight } from "@/components/common";
export const lessonSpotlight = defineSpotlight({
id: "lesson-intro",
steps: [
{
id: "start-button",
title: "开始学习",
description: "点击这里开始本节课程。",
placement: "bottom",
},
],
});
```
在页面中启动并渲染:
```tsx
import { useEffect } from "react";
import { Button, GuideSpotlight } from "@/components/common";
import { lessonSpotlight } from "./lessonSpotlight";
export default function LessonPage() {
useEffect(() => {
lessonSpotlight.start();
}, []);
return (
<>
<Button data-guide="start-button">开始学习</Button>
<GuideSpotlight />
</>
);
}
```
### 持久化说明
引导状态通过 Zustand persist 只保存已完成记录:
```ts
completedSpotlightMap: {
"course-demo:lesson-intro": true,
}
```
`showOnce` 默认为 `true`。点击完成或跳过都会调用 `finishSpotlight` 并标记为已完成;`closeSpotlight` 只关闭当前引导,不会写入完成记录。
重新允许展示:
```ts
lessonSpotlight.reset();
```
### Step 配置
支持:
- `target`:CSS selector 或 `{ dataGuide: "xxx" }`;省略时由 `defineSpotlight` 使用步骤 `id`。
- `placement`:`top` / `bottom` / `left` / `right` / `auto`。
- `allowTargetClick`:是否允许点击高亮目标。
- `padding` / `radius`:调整高亮区域。
- `panelClassName`:单步提示框样式。
- `nextLabel` / `prevLabel` / `doneLabel` / `skipLabel`:按钮文案。
建议 `data-guide` 只使用字母、数字、横线和下划线,例如 `start-button`、`question_1`。
### 高级交互
`allowTargetClick` 只让高亮区域下方的真实元素可点击,不会自动切换步骤。业务操作完成后可显式推进:
```tsx
import { useSpotlightStore } from "@/stores";
const nextStep = useSpotlightStore((state) => state.nextStep);
<Button
data-guide="play-button"
onClick={() => {
playVideo();
nextStep();
}}
>
播放
</Button>;
```
可通过 `theme.zIndex` 调整层级:
```tsx
<GuideSpotlight theme={{ zIndex: 1200 }} />
```
目标不存在时,`skipIfTargetMissing` 默认为 `true`,组件会前进到下一步或结束引导。开发排查时可以显式传入 `skipIfTargetMissing={false}` 显示缺失目标面板。
组件内置遮罩淡入、高亮呼吸和提示框入场动画,并兼容系统的 `prefers-reduced-motion` 设置。
## 音频管理
模板内置 `GameAudioProvider` 和 `useGameAudio`,按 `voice`(配音)/ `bgm`(背景音乐)/ `sfx`(音效)三个通道管理播放,底层基于 howler。`App` 已默认挂载 `GameAudioProvider`,页面内直接使用:
```tsx
import { useEffect } from "react";
import { useGameAudio } from "@/utils/audio";
export default function LessonPage() {
const audio = useGameAudio();
useEffect(() => {
// 必须先 enterPage,播放才会生效;离开页面时 dispose 会停止本页所有音频
const scope = audio.enterPage("lesson-1");
audio.bgm.play("./audio/bgm.mp3");
return () => scope.dispose();
}, [audio]);
return (
<button onClick={() => audio.sfx.play("./audio/click.mp3")}>点击</button>
);
}
```
说明:
- 同一通道同时只播放一个音频,再次 `play` 会自动停止上一个。
- `play` 返回 `{ started, stop }`,`started` 解析为 `playing` / `cancelled` / `blocked`(自动播放被浏览器拦截)/ `load-error` / `play-error`,可据此提示用户点击后重试。
- `audio.preload(source)` 可提前加载本页音频;页面切到后台时配音和音效会停止,BGM 暂停并在回到前台后恢复。
## GSAP 动画
GSAP 仅用于时间轴编排、暂停/继续/倒放、运行时动态计算、音视频同步或复杂路径。简单 hover、状态过渡和单元素入场优先使用 CSS/Tailwind。
```tsx
import { useRef } from "react";
import { EASE_OUT, gsap, useGSAP, useReducedMotion } from "@/utils/motion";
function TimelineExample() {
const rootRef = useRef<HTMLDivElement>(null);
const reducedMotion = useReducedMotion();
useGSAP(() => {
if (reducedMotion) return;
gsap.from("[data-animate]", { y: 24, opacity: 0, ease: EASE_OUT });
}, { scope: rootRef, dependencies: [reducedMotion] });
return <div ref={rootRef} data-animate>课程内容</div>;
}
```
请从 `@/utils/motion` 直接导入,避免不使用动画的入口无意加载 GSAP;不要让 GSAP 控制 `CourseCanvas`、画布 Portal 根节点或 dnd-kit 正在控制的 transform。
## 路由懒加载
模板内置了 `lazyRoute` 工具函数,新页面可以这样配置:
```tsx
{
path: "about",
lazy: lazyRoute(() => import("../pages/About")),
}
```
页面组件使用默认导出:
```tsx
export default function About() {
return <div>About</div>;
}
```
## 发布为 GitHub 模板仓库
如果你要把这个项目作为模板发布:
1. 推送到 GitHub
2. 进入仓库 **Settings**
3. 勾选 **Template repository**
之后其他人就可以通过 **Use this template** 使用它。
## License
MIT
+50
View File
@@ -0,0 +1,50 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": ["**", "!public/videoajax", "!dist"]
},
"css": {
"parser": {
"cssModules": true,
"tailwindDirectives": true
},
"linter": {
"enabled": false
},
"formatter": {
"enabled": false
}
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noUnknownAtRules": "off"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
+403
View File
@@ -0,0 +1,403 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "eco-steps",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@gsap/react": "^2.1.2",
"clsx": "^2.1.1",
"gsap": "^3.15.0",
"howler": "^2.2.4",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.12.0",
"tailwind-merge": "^3.5.0",
"vconsole": "^3.15.1",
"zustand": "^5.0.14",
},
"devDependencies": {
"@biomejs/biome": "2.3.11",
"@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.1.18",
"@types/howler": "^2.2.13",
"@types/node": "^25.0.10",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^6.0.3",
"autoprefixer": "^10.4.23",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "~5.7.2",
"vite": "^8.1.2",
},
},
},
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@babel/runtime": ["@babel/runtime@7.29.7", "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@biomejs/biome": ["@biomejs/biome@2.3.11", "https://registry.npmmirror.com/@biomejs/biome/-/biome-2.3.11.tgz", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.11", "@biomejs/cli-darwin-x64": "2.3.11", "@biomejs/cli-linux-arm64": "2.3.11", "@biomejs/cli-linux-arm64-musl": "2.3.11", "@biomejs/cli-linux-x64": "2.3.11", "@biomejs/cli-linux-x64-musl": "2.3.11", "@biomejs/cli-win32-arm64": "2.3.11", "@biomejs/cli-win32-x64": "2.3.11" }, "bin": { "biome": "bin/biome" } }, "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.11", "https://registry.npmmirror.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.11.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.11", "https://registry.npmmirror.com/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.11.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.11", "https://registry.npmmirror.com/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.11.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.11", "https://registry.npmmirror.com/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.11.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.11", "https://registry.npmmirror.com/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.11.tgz", { "os": "linux", "cpu": "x64" }, "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.11", "https://registry.npmmirror.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.11.tgz", { "os": "linux", "cpu": "x64" }, "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.11", "https://registry.npmmirror.com/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.11.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.11", "https://registry.npmmirror.com/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.11.tgz", { "os": "win32", "cpu": "x64" }, "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg=="],
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
"@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "https://registry.npmmirror.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
"@emnapi/core": ["@emnapi/core@1.11.1", "https://registry.npmmirror.com/@emnapi/core/-/core-1.11.1.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
"@emnapi/runtime": ["@emnapi/runtime@1.11.1", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@gsap/react": ["@gsap/react@2.1.2", "https://registry.npmmirror.com/@gsap/react/-/react-2.1.2.tgz", { "peerDependencies": { "gsap": "^3.12.5", "react": ">=17" } }, "sha512-JqliybO1837UcgH2hVOM4VO+38APk3ECNrsuSM4MuXp+rbf+/2IG2K1YJiqfTcXQHH7XlA0m3ykniFYstfq0Iw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
"@oxc-project/types": ["@oxc-project/types@0.137.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.137.0.tgz", {}, "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA=="],
"@playwright/test": ["@playwright/test@1.61.1", "https://registry.npmmirror.com/@playwright/test/-/test-1.61.1.tgz", { "dependencies": { "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" } }, "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.3", "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", { "os": "android", "cpu": "arm64" }, "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.3", "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.3", "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.3", "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.3", "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", { "os": "linux", "cpu": "arm" }, "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.3", "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.3", "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.3", "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.3", "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.3", "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", { "os": "linux", "cpu": "x64" }, "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.3", "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", { "os": "linux", "cpu": "x64" }, "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.3", "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", { "os": "none", "cpu": "arm64" }, "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.3", "https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.3", "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.3", "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", { "os": "win32", "cpu": "x64" }, "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.1.18.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.1.18.tgz", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "https://registry.npmmirror.com/@tailwindcss/postcss/-/postcss-4.1.18.tgz", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@types/howler": ["@types/howler@2.2.13", "https://registry.npmmirror.com/@types/howler/-/howler-2.2.13.tgz", {}, "sha512-40+EBjqIHHrC4VShlz/7i0lBUsE3QkgzZinQQji74Hd8sBkJZUBaT7LWFLK6rcabsDOOQpoMbEJvtaFQwxOu/g=="],
"@types/node": ["@types/node@25.0.10", "https://registry.npmmirror.com/@types/node/-/node-25.0.10.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="],
"@types/react": ["@types/react@19.2.9", "https://registry.npmmirror.com/@types/react/-/react-19.2.9.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.3", "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg=="],
"ansi-escapes": ["ansi-escapes@7.2.0", "https://registry.npmmirror.com/ansi-escapes/-/ansi-escapes-7.2.0.tgz", { "dependencies": { "environment": "^1.0.0" } }, "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw=="],
"ansi-regex": ["ansi-regex@6.2.2", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"autoprefixer": ["autoprefixer@10.4.23", "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.23.tgz", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.17", "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.17.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ=="],
"braces": ["braces@3.0.3", "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.1", "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.1.tgz", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001765", "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz", {}, "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ=="],
"cli-cursor": ["cli-cursor@5.0.0", "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-5.0.0.tgz", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
"cli-truncate": ["cli-truncate@5.1.1", "https://registry.npmmirror.com/cli-truncate/-/cli-truncate-5.1.1.tgz", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="],
"clsx": ["clsx@2.1.1", "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"colorette": ["colorette@2.0.20", "https://registry.npmmirror.com/colorette/-/colorette-2.0.20.tgz", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
"commander": ["commander@14.0.2", "https://registry.npmmirror.com/commander/-/commander-14.0.2.tgz", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
"cookie": ["cookie@1.1.1", "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"copy-text-to-clipboard": ["copy-text-to-clipboard@3.2.2", "https://registry.npmmirror.com/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.2.tgz", {}, "sha512-T6SqyLd1iLuqPA90J5N4cTalrtovCySh58iiZDGJ6FGznbclKh4UI+FGacQSgFzwKG77W7XT5gwbVEbd9cIH1A=="],
"core-js": ["core-js@3.49.0", "https://registry.npmmirror.com/core-js/-/core-js-3.49.0.tgz", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="],
"csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.277", "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.277.tgz", {}, "sha512-wKXFZw4erWmmOz5N/grBoJ2XrNJGDFMu2+W5ACHza5rHtvsqrK4gb6rnLC7XxKB9WlJ+RmyQatuEXmtm86xbnw=="],
"emoji-regex": ["emoji-regex@10.6.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"enhanced-resolve": ["enhanced-resolve@5.18.4", "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
"environment": ["environment@1.1.0", "https://registry.npmmirror.com/environment/-/environment-1.1.0.tgz", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
"escalade": ["escalade@3.2.0", "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"eventemitter3": ["eventemitter3@5.0.4", "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fill-range": ["fill-range@7.1.1", "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"fraction.js": ["fraction.js@5.3.4", "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"get-east-asian-width": ["get-east-asian-width@1.4.0", "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
"graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"gsap": ["gsap@3.15.0", "https://registry.npmmirror.com/gsap/-/gsap-3.15.0.tgz", {}, "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A=="],
"howler": ["howler@2.2.4", "https://registry.npmmirror.com/howler/-/howler-2.2.4.tgz", {}, "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="],
"husky": ["husky@9.1.7", "https://registry.npmmirror.com/husky/-/husky-9.1.7.tgz", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
"is-number": ["is-number@7.0.0", "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"jiti": ["jiti@2.6.1", "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"lightningcss": ["lightningcss@1.32.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lint-staged": ["lint-staged@16.2.7", "https://registry.npmmirror.com/lint-staged/-/lint-staged-16.2.7.tgz", { "dependencies": { "commander": "^14.0.2", "listr2": "^9.0.5", "micromatch": "^4.0.8", "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow=="],
"listr2": ["listr2@9.0.5", "https://registry.npmmirror.com/listr2/-/listr2-9.0.5.tgz", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="],
"log-update": ["log-update@6.1.0", "https://registry.npmmirror.com/log-update/-/log-update-6.1.0.tgz", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
"magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"micromatch": ["micromatch@4.0.8", "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mimic-function": ["mimic-function@5.0.1", "https://registry.npmmirror.com/mimic-function/-/mimic-function-5.0.1.tgz", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"mutation-observer": ["mutation-observer@1.0.3", "https://registry.npmmirror.com/mutation-observer/-/mutation-observer-1.0.3.tgz", {}, "sha512-M/O/4rF2h776hV7qGMZUH3utZLO/jK7p8rnNgGkjKUw8zCGjRQPxB8z6+5l8+VjRUQ3dNYu4vjqXYLr+U8ZVNA=="],
"nano-spawn": ["nano-spawn@2.0.0", "https://registry.npmmirror.com/nano-spawn/-/nano-spawn-2.0.0.tgz", {}, "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw=="],
"nanoid": ["nanoid@3.3.11", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-releases": ["node-releases@2.0.27", "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"onetime": ["onetime@7.0.0", "https://registry.npmmirror.com/onetime/-/onetime-7.0.0.tgz", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"pidtree": ["pidtree@0.6.0", "https://registry.npmmirror.com/pidtree/-/pidtree-0.6.0.tgz", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="],
"playwright": ["playwright@1.61.1", "https://registry.npmmirror.com/playwright/-/playwright-1.61.1.tgz", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="],
"playwright-core": ["playwright-core@1.61.1", "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.61.1.tgz", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="],
"postcss": ["postcss@8.5.6", "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"react": ["react@19.2.7", "https://registry.npmmirror.com/react/-/react-19.2.7.tgz", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
"react-dom": ["react-dom@19.2.7", "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.7.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
"react-router": ["react-router@7.12.0", "https://registry.npmmirror.com/react-router/-/react-router-7.12.0.tgz", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw=="],
"react-router-dom": ["react-router-dom@7.12.0", "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-7.12.0.tgz", { "dependencies": { "react-router": "7.12.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA=="],
"restore-cursor": ["restore-cursor@5.1.0", "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-5.1.0.tgz", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"rfdc": ["rfdc@1.4.1", "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"rolldown": ["rolldown@1.1.3", "https://registry.npmmirror.com/rolldown/-/rolldown-1.1.3.tgz", { "dependencies": { "@oxc-project/types": "=0.137.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.3", "@rolldown/binding-darwin-arm64": "1.1.3", "@rolldown/binding-darwin-x64": "1.1.3", "@rolldown/binding-freebsd-x64": "1.1.3", "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", "@rolldown/binding-linux-arm64-gnu": "1.1.3", "@rolldown/binding-linux-arm64-musl": "1.1.3", "@rolldown/binding-linux-ppc64-gnu": "1.1.3", "@rolldown/binding-linux-s390x-gnu": "1.1.3", "@rolldown/binding-linux-x64-gnu": "1.1.3", "@rolldown/binding-linux-x64-musl": "1.1.3", "@rolldown/binding-openharmony-arm64": "1.1.3", "@rolldown/binding-wasm32-wasi": "1.1.3", "@rolldown/binding-win32-arm64-msvc": "1.1.3", "@rolldown/binding-win32-x64-msvc": "1.1.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g=="],
"scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"signal-exit": ["signal-exit@4.1.0", "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"slice-ansi": ["slice-ansi@7.1.2", "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-7.1.2.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
"source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"string-argv": ["string-argv@0.3.2", "https://registry.npmmirror.com/string-argv/-/string-argv-0.3.2.tgz", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
"string-width": ["string-width@8.1.0", "https://registry.npmmirror.com/string-width/-/string-width-8.1.0.tgz", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="],
"strip-ansi": ["strip-ansi@7.1.2", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.2.tgz", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"tailwind-merge": ["tailwind-merge@3.6.0", "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-3.6.0.tgz", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
"tailwindcss": ["tailwindcss@4.1.18", "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.1.18.tgz", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"tapable": ["tapable@2.3.0", "https://registry.npmmirror.com/tapable/-/tapable-2.3.0.tgz", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tinyglobby": ["tinyglobby@0.2.17", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"to-regex-range": ["to-regex-range@5.0.1", "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.7.3", "https://registry.npmmirror.com/typescript/-/typescript-5.7.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="],
"undici-types": ["undici-types@7.16.0", "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"vconsole": ["vconsole@3.15.1", "https://registry.npmmirror.com/vconsole/-/vconsole-3.15.1.tgz", { "dependencies": { "@babel/runtime": "^7.17.2", "copy-text-to-clipboard": "^3.0.1", "core-js": "^3.11.0", "mutation-observer": "^1.0.3" } }, "sha512-KH8XLdrq9T5YHJO/ixrjivHfmF2PC2CdVoK6RWZB4yftMykYIaXY1mxZYAic70vADM54kpMQF+dYmvl5NRNy1g=="],
"vite": ["vite@8.1.2", "https://registry.npmmirror.com/vite/-/vite-8.1.2.tgz", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.16", "rolldown": "~1.1.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ=="],
"wrap-ansi": ["wrap-ansi@9.0.2", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"yaml": ["yaml@2.8.2", "https://registry.npmmirror.com/yaml/-/yaml-2.8.2.tgz", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
"zustand": ["zustand@5.0.14", "https://registry.npmmirror.com/zustand/-/zustand-5.0.14.tgz", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.30.2", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.30.2.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "https://registry.npmmirror.com/@emnapi/core/-/core-1.8.1.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"micromatch/picomatch": ["picomatch@2.3.1", "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"playwright/fsevents": ["fsevents@2.3.2", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"vite/postcss": ["postcss@8.5.16", "https://registry.npmmirror.com/postcss/-/postcss-8.5.16.tgz", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="],
"wrap-ansi/string-width": ["string-width@7.2.0", "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
"@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
"@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
"@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
"vite/postcss/nanoid": ["nanoid@3.3.15", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.15.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="],
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://registry.yuxiang.example/components.schema.json",
"version": 1,
"framework": "react",
"typescript": true,
"tailwind": {
"version": 4,
"css": "src/styles/index.css"
},
"directories": {
"ui": "src/components/ui",
"interactions": "src/course/interactions",
"runtime": "src/course",
"lib": "src/lib"
},
"aliases": {
"ui": "@/components/ui",
"interactions": "@/course/interactions",
"runtime": "@/course",
"lib": "@/lib"
}
}
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="%APP_FAVICON%" />
<meta
name="viewport"
content="%APP_VIEWPORT%"
/>
<meta name="description" content="%APP_DESCRIPTION%" />
<title>%APP_TITLE%</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
{
"name": "yx-react-template",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"preview": "vite preview",
"prepare": "husky"
},
"lint-staged": {
"*.{js,jsx,ts,tsx,json}": "bunx biome check --write",
"*.{css,module.css}": "echo 'Skipping CSS files'"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@gsap/react": "^2.1.2",
"clsx": "^2.1.1",
"gsap": "^3.15.0",
"howler": "^2.2.4",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.12.0",
"tailwind-merge": "^3.5.0",
"vconsole": "^3.15.1",
"zustand": "^5.0.14"
},
"devDependencies": {
"@biomejs/biome": "2.3.11",
"@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.1.18",
"@types/howler": "^2.2.13",
"@types/node": "^25.0.10",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^6.0.3",
"autoprefixer": "^10.4.23",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "~5.7.2",
"vite": "^8.1.2"
}
}
+39
View File
@@ -0,0 +1,39 @@
import { defineConfig, devices } from "@playwright/test";
const PORT = 4173;
const BASE_URL = `http://127.0.0.1:${PORT}`;
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 0,
workers: 1,
reporter: [["list"]],
use: {
baseURL: BASE_URL,
trace: "retain-on-failure",
},
projects: [
{
name: "chromium-desktop",
use: {
...devices["Desktop Chrome"],
viewport: { width: 1280, height: 720 },
},
},
{
name: "chromium-full",
use: {
...devices["Desktop Chrome"],
viewport: { width: 1920, height: 1080 },
},
},
],
webServer: {
command: "bun run build && bun run preview -- --host 127.0.0.1 --port 4173",
url: BASE_URL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
File diff suppressed because it is too large. Load diff
+457
View File
@@ -0,0 +1,457 @@
/*
* $Id: base64.js,v 2.15 2014/04/05 12:58:57 dankogai Exp dankogai $
*
* Licensed under the MIT license.
* http://opensource.org/licenses/mit-license
*
* References:
* http://en.wikipedia.org/wiki/Base64
*/
;(function (global) {
'use strict'
// existing version for noConflict()
var _Base64 = global.Base64
var version = '2.1.5'
// if node.js, we use Buffer
var buffer
if (typeof module !== 'undefined' && module.exports) {
buffer = require('buffer').Buffer
}
// constants
var b64chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
var b64tab = (function (bin) {
var t = {}
for (var i = 0, l = bin.length; i < l; i++) t[bin.charAt(i)] = i
return t
})(b64chars)
var fromCharCode = String.fromCharCode
// encoder stuff
var cb_utob = function (c) {
if (c.length < 2) {
var cc = c.charCodeAt(0)
return cc < 0x80
? c
: cc < 0x800
? fromCharCode(0xc0 | (cc >>> 6)) + fromCharCode(0x80 | (cc & 0x3f))
: fromCharCode(0xe0 | ((cc >>> 12) & 0x0f)) +
fromCharCode(0x80 | ((cc >>> 6) & 0x3f)) +
fromCharCode(0x80 | (cc & 0x3f))
} else {
var cc = 0x10000 + (c.charCodeAt(0) - 0xd800) * 0x400 + (c.charCodeAt(1) - 0xdc00)
return (
fromCharCode(0xf0 | ((cc >>> 18) & 0x07)) +
fromCharCode(0x80 | ((cc >>> 12) & 0x3f)) +
fromCharCode(0x80 | ((cc >>> 6) & 0x3f)) +
fromCharCode(0x80 | (cc & 0x3f))
)
}
}
var re_utob = /[\uD800-\uDBFF][\uDC00-\uDFFFF]|[^\x00-\x7F]/g
var utob = function (u) {
return u.replace(re_utob, cb_utob)
}
var cb_encode = function (ccc) {
var padlen = [0, 2, 1][ccc.length % 3],
ord =
(ccc.charCodeAt(0) << 16) |
((ccc.length > 1 ? ccc.charCodeAt(1) : 0) << 8) |
(ccc.length > 2 ? ccc.charCodeAt(2) : 0),
chars = [
b64chars.charAt(ord >>> 18),
b64chars.charAt((ord >>> 12) & 63),
padlen >= 2 ? '=' : b64chars.charAt((ord >>> 6) & 63),
padlen >= 1 ? '=' : b64chars.charAt(ord & 63),
]
return chars.join('')
}
var btoa = global.btoa
? function (b) {
return global.btoa(b)
}
: function (b) {
return b.replace(/[\s\S]{1,3}/g, cb_encode)
}
var _encode = buffer
? function (u) {
return new buffer(u).toString('base64')
}
: function (u) {
return btoa(utob(u))
}
var encode = function (u, urisafe) {
return !urisafe
? _encode(u)
: _encode(u)
.replace(/[+\/]/g, function (m0) {
return m0 == '+' ? '-' : '_'
})
.replace(/=/g, '')
}
var encodeURI = function (u) {
return encode(u, true)
}
// decoder stuff
var re_btou = new RegExp(
['[\xC0-\xDF][\x80-\xBF]', '[\xE0-\xEF][\x80-\xBF]{2}', '[\xF0-\xF7][\x80-\xBF]{3}'].join('|'),
'g'
)
var cb_btou = function (cccc) {
switch (cccc.length) {
case 4:
var cp =
((0x07 & cccc.charCodeAt(0)) << 18) |
((0x3f & cccc.charCodeAt(1)) << 12) |
((0x3f & cccc.charCodeAt(2)) << 6) |
(0x3f & cccc.charCodeAt(3)),
offset = cp - 0x10000
return fromCharCode((offset >>> 10) + 0xd800) + fromCharCode((offset & 0x3ff) + 0xdc00)
case 3:
return fromCharCode(
((0x0f & cccc.charCodeAt(0)) << 12) |
((0x3f & cccc.charCodeAt(1)) << 6) |
(0x3f & cccc.charCodeAt(2))
)
default:
return fromCharCode(((0x1f & cccc.charCodeAt(0)) << 6) | (0x3f & cccc.charCodeAt(1)))
}
}
var btou = function (b) {
return b.replace(re_btou, cb_btou)
}
var cb_decode = function (cccc) {
var len = cccc.length,
padlen = len % 4,
n =
(len > 0 ? b64tab[cccc.charAt(0)] << 18 : 0) |
(len > 1 ? b64tab[cccc.charAt(1)] << 12 : 0) |
(len > 2 ? b64tab[cccc.charAt(2)] << 6 : 0) |
(len > 3 ? b64tab[cccc.charAt(3)] : 0),
chars = [fromCharCode(n >>> 16), fromCharCode((n >>> 8) & 0xff), fromCharCode(n & 0xff)]
chars.length -= [0, 0, 2, 1][padlen]
return chars.join('')
}
var atob = global.atob
? function (a) {
return global.atob(a)
}
: function (a) {
return a.replace(/[\s\S]{1,4}/g, cb_decode)
}
var _decode = buffer
? function (a) {
return new buffer(a, 'base64').toString()
}
: function (a) {
return btou(atob(a))
}
var decode = function (a) {
return _decode(
a
.replace(/[-_]/g, function (m0) {
return m0 == '-' ? '+' : '/'
})
.replace(/[^A-Za-z0-9\+\/]/g, '')
)
}
var noConflict = function () {
var Base64 = global.Base64
global.Base64 = _Base64
return Base64
}
// export Base64
global.Base64 = {
VERSION: version,
atob: atob,
btoa: btoa,
fromBase64: decode,
toBase64: encode,
utob: utob,
encode: encode,
encodeURI: encodeURI,
btou: btou,
decode: decode,
noConflict: noConflict,
}
// if ES5 is available, make Base64.extendString() available
if (typeof Object.defineProperty === 'function') {
var noEnum = function (v) {
return { value: v, enumerable: false, writable: true, configurable: true }
}
global.Base64.extendString = function () {
Object.defineProperty(
String.prototype,
'fromBase64',
noEnum(function () {
return decode(this)
})
)
Object.defineProperty(
String.prototype,
'toBase64',
noEnum(function (urisafe) {
return encode(this, urisafe)
})
)
Object.defineProperty(
String.prototype,
'toBase64URI',
noEnum(function () {
return encode(this, true)
})
)
}
}
// that's it!
})(this)
if (this['Meteor']) {
Base64 = global.Base64 // for normal export in Meteor.js
}
;(function (root) {
// body...
var videoajax = function (argument) {
// body...
var host = '/'
this._setHost = function (hoststr) {
host = hoststr
}
this._getHost = function () {
return host
}
}
$.extend(videoajax.prototype, {
/**
* 设置主域名
* @param hosts {String} default: '/' 主域名字符串
*/
setHost: function (hosts) {
this._setHost(hosts)
},
//user_id,course_id,lesson_id,break_point
/**
* 播放退出时间点
* @param data {dataObject} 向后台传递的数据对象,应该包含
* user_id {Number} 用户ID
* course_id {Number} 课程ID
* lesson_id {Number} 课时ID
* break_point {Number} 断点
*@param callback {Function} 回调方法
*/
quitBreakPoint: function (data, callback) {
var paramfiled = ['user_id', 'course_id', 'lesson_id', 'break_point']
var dataStr = verfiyData(paramfiled, data)
getDate(
this._getHost() + 'index.php?m=zzplatform&c=userview&a=addBreakPoint',
{
userViewPoint: dataStr,
},
function (data) {
isFunction(callback) ? callback(data.code == 200, data) : ''
}
)
},
/**
* user_id:123,course_id:23,lesson_id:3
*播放历史记录
* @param data {dataObject} 向后台传递的数据对象,应该包含
* user_id {Number} 用户ID
* course_id {Number} 课程ID
* lesson_id {Number} 课时ID
*@param callback {Function} 回调方法
*/
getBreakPoint: function (data, callback) {
var paramfiled = ['user_id', 'course_id', 'lesson_id']
var dataStr = verfiyData(paramfiled, data)
getDate(
this._getHost() + 'index.php?m=zzplatform&c=userview&a=userbreakpoint',
{
userCourseInfo: dataStr,
},
function (data) {
isFunction(callback) ? callback(data.code == 200, data) : ''
// isFunction(callback) ? callback(data) : '';
}
)
},
/**
*用户答题答案
* @param data {dataObject} 向后台传递的数据对象,应该包含
* user_id {Number} 用户ID
* course_id {Number} 课程ID
* lesson_id {Number} 课时ID
* question_order {Number} 问题序号
* answer {String} 问题
*@param callback {Function} 回调方法
*/
getUserAnswer: function (data, callback) {
var paramfiled = ['user_id', 'lesson_id', 'course_id', 'question_order', 'answer']
var dataStr = verfiyData(paramfiled, data)
getDate(
this._getHost() + 'index.php?m=zzplatform&c=userview&a=addUserAnswer',
{
userAnswerInfo: dataStr,
},
function (data) {
isFunction(callback) ? callback(data.code == 200, data) : ''
}
)
},
/**
*播放结束
* @param data {dataObject} 向后台传递的数据对象,应该包含
* order {Number} 排序ID
* co {Number} 课时ID
*@param callback {Function} 回调方法
*/
finishCourse: function (data, callback) {
var paramfiled = ['order', 'co', 'process']
var dataStr = verfiyData(paramfiled, data)
getDate(
this._getHost() + 'index.php?m=user&c=learnrecord&a=playcount',
data,
function (data) {
isFunction(callback) ? callback(data.code == 200, data) : ''
// isFunction(callback) ? callback(data) : '';
}
)
},
/**
*课程进度获取
* @param data {dataObject} 向后台传递的数据对象,应该包含
* order {Number} 排序ID
* co {Number} 课时ID
*@param callback {Function} 回调方法
* 注回调使用的参数,值类似为: {"status":true,"data":{"process":"91"}}
* 或: {"status":false,"data":'参数丢失'}
* 其中status为true为调用正常, 如果有值则data返回json形式的进度, 没有值则返回[]
* status为false时, data保存的是错误提示
*/
getfinishCourse: function (data, callback) {
var paramfiled = ['order', 'co']
var dataStr = verfiyData(paramfiled, data)
getDate(
this._getHost() + 'index.php?m=user&c=learnrecord&a=getuserlessionprocess',
data,
function (data) {
isFunction(callback) ? callback(data) : ''
// isFunction(callback) ? callback(data) : '';
}
)
},
/**
*解码加密的breakpoint
* @pparam basecode {String} 要解码的字符串
*/
fromBaseCode: function (basecode) {
return getBreakPoint(basecode)
},
/**
*通过key值和课时值获取自定的数据
* @param data {dataObject} 向后台传递的数据对象,应该包含
* lessonId {Number} 课时ID
* order {Number} 排序ID
*@param callback {Function} 回调方法
*/
getUserDefineInfo: function (data, callback) {
var paramfiled = ['lessonId', 'order']
var dataStr = verfiyData(paramfiled, data)
getDate(
this._getHost() + 'index.php?m=zzplatform&c=userview&a=getUserDefineInfo',
{
defineDate: dataStr,
},
function (data) {
isFunction(callback) ? callback(data) : ''
}
)
},
/**
*通过key值和课时值及用户ID获取自定的数据
* @param data {dataObject} 向后台传递的数据对象,应该包含
* lessonId {Number} 课时ID
* order {Number} 排序ID
*@param callback {Function} 回调方法
*/
getUserDefineInfoByUser: function (data, callback) {
var paramfiled = ['lessonId', 'order']
var dataStr = verfiyData(paramfiled, data)
getDate(
this._getHost() + 'index.php?m=zzplatform&c=userview&a=getUserDefineInfoByUser',
{
defineUserDate: dataStr,
},
function (data) {
isFunction(callback) ? callback(data) : ''
}
)
},
/**
*增加自定数据
* @param data {dataObject} 向后台传递的数据对象,应该包含
* lessonId {Number} 课时ID
* key {string} 键值ID
* content {string} 字内容
*@param callback {Function} 回调方法
*/
addUserDefine: function (data, callback) {
var paramfiled = ['lessonId', 'key', 'content']
var dataStr = verfiyData(paramfiled, data)
getDate(
this._getHost() + 'index.php?m=zzplatform&c=userview&a=addUserDefine',
{
addDefineDate: dataStr,
},
function (data) {
isFunction(callback) ? callback(data) : ''
}
)
},
})
var getDate = function (url, data, callback) {
$.get(
url,
data,
function (data) {
callback(data)
},
'json'
)
}
var isFunction = function (fn) {
if (typeof fn === 'function') return true
return false
}
var verfiyData = function (field, data) {
var arr = []
console.log(field)
console.log('<br/>')
console.log(data)
for (var i in field) {
if (!data[field[i]]) {
throw new Error('data is error')
}
arr.push(field[i] + ':' + data[field[i]])
}
if (root.JSON) {
return JSON.stringify(data)
} else {
return '{' + arr.join(',') + '}'
}
}
var getBreakPoint = function (base64point) {
var newbc = base64point.split('').reverse().join('')
return Base64.fromBase64(newbc)
}
root.videoAjax = function () {
return new videoajax()
}
})(this)
+1
View File
@@ -0,0 +1 @@
(function(global){'use strict';var _Base64=global.Base64;var version="2.1.5";var buffer;if(typeof module!=='undefined'&&module.exports){buffer=require('buffer').Buffer}var b64chars='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';var b64tab=function(bin){var t={};for(var i=0,l=bin.length;i<l;i++)t[bin.charAt(i)]=i;return t}(b64chars);var fromCharCode=String.fromCharCode;var cb_utob=function(c){if(c.length<2){var cc=c.charCodeAt(0);return cc<0x80?c:cc<0x800?(fromCharCode(0xc0|(cc>>>6))+fromCharCode(0x80|(cc&0x3f))):(fromCharCode(0xe0|((cc>>>12)&0x0f))+fromCharCode(0x80|((cc>>>6)&0x3f))+fromCharCode(0x80|(cc&0x3f)))}else{var cc=0x10000+(c.charCodeAt(0)-0xD800)*0x400+(c.charCodeAt(1)-0xDC00);return(fromCharCode(0xf0|((cc>>>18)&0x07))+fromCharCode(0x80|((cc>>>12)&0x3f))+fromCharCode(0x80|((cc>>>6)&0x3f))+fromCharCode(0x80|(cc&0x3f)))}};var re_utob=/[\uD800-\uDBFF][\uDC00-\uDFFFF]|[^\x00-\x7F]/g;var utob=function(u){return u.replace(re_utob,cb_utob)};var cb_encode=function(ccc){var padlen=[0,2,1][ccc.length%3],ord=ccc.charCodeAt(0)<<16|((ccc.length>1?ccc.charCodeAt(1):0)<<8)|((ccc.length>2?ccc.charCodeAt(2):0)),chars=[b64chars.charAt(ord>>>18),b64chars.charAt((ord>>>12)&63),padlen>=2?'=':b64chars.charAt((ord>>>6)&63),padlen>=1?'=':b64chars.charAt(ord&63)];return chars.join('')};var btoa=global.btoa?function(b){return global.btoa(b)}:function(b){return b.replace(/[\s\S]{1,3}/g,cb_encode)};var _encode=buffer?function(u){return(new buffer(u)).toString('base64')}:function(u){return btoa(utob(u))};var encode=function(u,urisafe){return!urisafe?_encode(u):_encode(u).replace(/[+\/]/g,function(m0){return m0=='+'?'-':'_'}).replace(/=/g,'')};var encodeURI=function(u){return encode(u,true)};var re_btou=new RegExp(['[\xC0-\xDF][\x80-\xBF]','[\xE0-\xEF][\x80-\xBF]{2}','[\xF0-\xF7][\x80-\xBF]{3}'].join('|'),'g');var cb_btou=function(cccc){switch(cccc.length){case 4:var cp=((0x07&cccc.charCodeAt(0))<<18)|((0x3f&cccc.charCodeAt(1))<<12)|((0x3f&cccc.charCodeAt(2))<<6)|(0x3f&cccc.charCodeAt(3)),offset=cp-0x10000;return(fromCharCode((offset>>>10)+0xD800)+fromCharCode((offset&0x3FF)+0xDC00));case 3:return fromCharCode(((0x0f&cccc.charCodeAt(0))<<12)|((0x3f&cccc.charCodeAt(1))<<6)|(0x3f&cccc.charCodeAt(2)));default:return fromCharCode(((0x1f&cccc.charCodeAt(0))<<6)|(0x3f&cccc.charCodeAt(1)))}};var btou=function(b){return b.replace(re_btou,cb_btou)};var cb_decode=function(cccc){var len=cccc.length,padlen=len%4,n=(len>0?b64tab[cccc.charAt(0)]<<18:0)|(len>1?b64tab[cccc.charAt(1)]<<12:0)|(len>2?b64tab[cccc.charAt(2)]<<6:0)|(len>3?b64tab[cccc.charAt(3)]:0),chars=[fromCharCode(n>>>16),fromCharCode((n>>>8)&0xff),fromCharCode(n&0xff)];chars.length-=[0,0,2,1][padlen];return chars.join('')};var atob=global.atob?function(a){return global.atob(a)}:function(a){return a.replace(/[\s\S]{1,4}/g,cb_decode)};var _decode=buffer?function(a){return(new buffer(a,'base64')).toString()}:function(a){return btou(atob(a))};var decode=function(a){return _decode(a.replace(/[-_]/g,function(m0){return m0=='-'?'+':'/'}).replace(/[^A-Za-z0-9\+\/]/g,''))};var noConflict=function(){var Base64=global.Base64;global.Base64=_Base64;return Base64};global.Base64={VERSION:version,atob:atob,btoa:btoa,fromBase64:decode,toBase64:encode,utob:utob,encode:encode,encodeURI:encodeURI,btou:btou,decode:decode,noConflict:noConflict};if(typeof Object.defineProperty==='function'){var noEnum=function(v){return{value:v,enumerable:false,writable:true,configurable:true}};global.Base64.extendString=function(){Object.defineProperty(String.prototype,'fromBase64',noEnum(function(){return decode(this)}));Object.defineProperty(String.prototype,'toBase64',noEnum(function(urisafe){return encode(this,urisafe)}));Object.defineProperty(String.prototype,'toBase64URI',noEnum(function(){return encode(this,true)}))}}})(this);if(this['Meteor']){Base64=global.Base64};(function(root){var videoajax=function(argument){var host='/';this._setHost=function(hoststr){host=hoststr};this._getHost=function(){return host}};$.extend(videoajax.prototype,{setHost:function(hosts){this._setHost(hosts)},quitBreakPoint:function(data,callback){var paramfiled=["user_id","course_id","lesson_id","break_point"];var dataStr=verfiyData(paramfiled,data);getDate(this._getHost()+'index.php?m=zzplatform&c=userview&a=addBreakPoint',{userViewPoint:dataStr},function(data){isFunction(callback)?callback(data.code==200,data):''})},getBreakPoint:function(data,callback){var paramfiled=["user_id","course_id","lesson_id"];var dataStr=verfiyData(paramfiled,data);getDate(this._getHost()+'index.php?m=zzplatform&c=userview&a=userbreakpoint',{userCourseInfo:dataStr},function(data){isFunction(callback)?callback(data.code==200,data):''})},getUserAnswer:function(data,callback){var paramfiled=["user_id","lesson_id","course_id","question_order","answer"];var dataStr=verfiyData(paramfiled,data);getDate(this._getHost()+'index.php?m=zzplatform&c=userview&a=addUserAnswer',{userAnswerInfo:dataStr},function(data){isFunction(callback)?callbacLine truncated
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+45
View File
@@ -0,0 +1,45 @@
import { useEffect } from "react";
import { Outlet } from "react-router-dom";
import { ToastProvider, ToastViewport } from "@/components/ui/toast";
import {
CourseCanvas,
registerAppPortalRoot,
useCourseCanvas,
} from "@/course/canvas";
import { OrientationGuard } from "@/course/orientation";
import { GameAudioProvider } from "@/utils/audio";
import { Navigation } from "./components/business";
function CourseShell() {
const { portalContainer } = useCourseCanvas();
useEffect(() => registerAppPortalRoot(portalContainer), [portalContainer]);
return (
<GameAudioProvider>
<ToastProvider>
<Navigation />
<main className="flex-1 overflow-auto p-8">
<Outlet />
</main>
<ToastViewport container={portalContainer} />
</ToastProvider>
</GameAudioProvider>
);
}
function App() {
return (
<>
<OrientationGuard />
<CourseCanvas
className="bg-gray-900"
canvasClassName="flex flex-col overflow-hidden bg-white"
>
<CourseShell />
</CourseCanvas>
</>
);
}
export default App;
+84
View File
@@ -0,0 +1,84 @@
/**
* API 客户端配置
* 可以根据项目需求使用 axios、fetch 或其他 HTTP 客户端
*/
import type { ApiError, ApiResponse } from "./client.types";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "/api";
/**
* 基础请求函数
*/
export async function request<T>(
endpoint: string,
options?: RequestInit,
): Promise<ApiResponse<T>> {
const url = `${API_BASE_URL}${endpoint}`;
try {
const response = await fetch(url, {
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = (await response.json()) as ApiResponse<T>;
return data;
} catch (error) {
throw {
message: error instanceof Error ? error.message : "Unknown error",
code: 500,
} as ApiError;
}
}
/**
* GET 请求
*/
export function get<T>(endpoint: string, options?: RequestInit) {
return request<T>(endpoint, { ...options, method: "GET" });
}
/**
* POST 请求
*/
export function post<T>(
endpoint: string,
data?: unknown,
options?: RequestInit,
) {
return request<T>(endpoint, {
...options,
method: "POST",
body: data ? JSON.stringify(data) : undefined,
});
}
/**
* PUT 请求
*/
export function put<T>(
endpoint: string,
data?: unknown,
options?: RequestInit,
) {
return request<T>(endpoint, {
...options,
method: "PUT",
body: data ? JSON.stringify(data) : undefined,
});
}
/**
* DELETE 请求
*/
export function del<T>(endpoint: string, options?: RequestInit) {
return request<T>(endpoint, { ...options, method: "DELETE" });
}
+10
View File
@@ -0,0 +1,10 @@
export interface ApiResponse<T> {
data: T;
message?: string;
code?: number;
}
export interface ApiError {
message: string;
code?: number;
}
+2
View File
@@ -0,0 +1,2 @@
export { del, get, post, put, request } from "./client.ts";
export type { ApiError, ApiResponse } from "./client.types";
+35
View File
@@ -0,0 +1,35 @@
/**
* 应用配置
* 修改此文件后需要重启开发服务
*/
const env = (import.meta as ImportMeta & { env?: Record<string, string> }).env;
export const appConfig = {
/** 应用标题 */
title: "React Vite App",
/** 网站描述 */
description: "易班 H5 课程模板",
/** Favicon 路径(相对于 public 目录) */
favicon: "./vite.svg",
/** 课程配置 */
course: {
id: env?.VITE_COURSE_ID ?? "course-demo",
name: env?.VITE_COURSE_NAME ?? "标准课程页示例",
},
/** 本地存储配置 */
storage: {
prefix: "yiban-course-template",
},
/** 视口配置 */
viewport: {
width: "device-width",
initialScale: 1.0,
minimumScale: 1.0,
maximumScale: 1.0,
},
};
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,15 @@
@reference "tailwindcss";
.navigation {
@apply flex gap-4 p-4 bg-gray-100 border-b border-gray-300;
}
.link {
@apply text-blue-600 no-underline px-4 py-2 rounded transition-colors;
@apply hover:bg-gray-200;
}
.active {
@apply bg-blue-100 text-blue-800 font-medium;
}
@@ -0,0 +1,35 @@
import { Link, useLocation } from "react-router-dom";
import { cn } from "@/lib/cn";
import styles from "./Navigation.module.css";
import type { NavItem, NavigationProps } from "./Navigation.types";
const defaultItems: NavItem[] = [
{ label: "首页", path: "/" },
{ label: "关于", path: "/about" },
{ label: "组件示例", path: "/components" },
{ label: "课程示例", path: "/course-demo" },
];
export function Navigation({
items = defaultItems,
className,
}: NavigationProps) {
const location = useLocation();
return (
<nav className={cn(styles.navigation, className)}>
{items.map((item) => {
const isActive = location.pathname === item.path;
return (
<Link
key={item.path}
to={item.path}
className={cn(styles.link, isActive && styles.active)}
>
{item.label}
</Link>
);
})}
</nav>
);
}
@@ -0,0 +1,9 @@
export interface NavItem {
label: string;
path: string;
}
export interface NavigationProps {
items?: NavItem[];
className?: string;
}
@@ -0,0 +1,2 @@
export { Navigation } from "./Navigation.tsx";
export type { NavItem, NavigationProps } from "./Navigation.types";
+2
View File
@@ -0,0 +1,2 @@
export type { NavItem, NavigationProps } from "./Navigation";
export { Navigation } from "./Navigation";
@@ -0,0 +1,9 @@
@reference "tailwindcss";
.container {
@apply relative;
}
.content {
@apply relative h-full z-10;
}
@@ -0,0 +1,41 @@
import { cn } from "@/lib/cn";
import { getStaticImage } from "@/utils/assets";
import styles from "./BackgroundContainer.module.css";
import type { BackgroundContainerProps } from "./BackgroundContainer.types";
const DIRECT_URL_PATTERN = /^(?:[a-z][a-z\d+.-]*:|\/|\.\.?\/)/i;
function resolveBackgroundImage(src: string): string {
return DIRECT_URL_PATTERN.test(src) ? src : getStaticImage(src) || src;
}
export function BackgroundContainer({
src,
fit = "cover",
position = "center",
repeat = "no-repeat",
as: Component = "div",
className,
contentClassName,
children,
style,
...props
}: BackgroundContainerProps) {
const imageSrc = resolveBackgroundImage(src);
return (
<Component
className={cn(styles.container, className)}
style={{
backgroundImage: `url(${JSON.stringify(imageSrc)})`,
backgroundSize: fit,
backgroundPosition: position,
backgroundRepeat: repeat,
...style,
}}
{...props}
>
<div className={cn(styles.content, contentClassName)}>{children}</div>
</Component>
);
}
@@ -0,0 +1,21 @@
import type { HTMLAttributes, ReactNode } from "react";
export type BackgroundContainerElement = "div" | "section" | "main" | "article";
export type BackgroundContainerFit = "cover" | "contain";
export type BackgroundContainerRepeat =
| "no-repeat"
| "repeat"
| "repeat-x"
| "repeat-y";
export interface BackgroundContainerProps
extends Omit<HTMLAttributes<HTMLElement>, "children"> {
/** 静态资源 key(相对 src/assets/images/)或可直接使用的图片 URL */
src: string;
fit?: BackgroundContainerFit;
position?: string;
repeat?: BackgroundContainerRepeat;
as?: BackgroundContainerElement;
contentClassName?: string;
children?: ReactNode;
}
@@ -0,0 +1,7 @@
export { BackgroundContainer } from "./BackgroundContainer";
export type {
BackgroundContainerElement,
BackgroundContainerFit,
BackgroundContainerProps,
BackgroundContainerRepeat,
} from "./BackgroundContainer.types";
@@ -0,0 +1,44 @@
import { appConfig } from "@/app.config";
import { useDialogStore } from "@/stores";
import type {
GuideDialogStep,
StartDialogOptions,
} from "@/stores/types/dialog.types";
export interface DefineDialogOptions {
id: string;
steps: GuideDialogStep[];
showOnce?: boolean;
initialStep?: number;
}
export interface GuideDialogController {
id: string;
steps: GuideDialogStep[];
start: (options?: Pick<StartDialogOptions, "initialStep">) => void;
reset: () => void;
}
export function defineDialog({
id,
steps,
showOnce = true,
initialStep = 0,
}: DefineDialogOptions): GuideDialogController {
return {
id,
steps,
start: (options) => {
useDialogStore.getState().startDialog({
courseId: appConfig.course.id,
dialogId: id,
showOnce,
steps,
initialStep: options?.initialStep ?? initialStep,
});
},
reset: () => {
useDialogStore.getState().resetDialog(appConfig.course.id, id);
},
};
}
@@ -0,0 +1,56 @@
import { useCallback } from "react";
import { getAppPortalRoot, useCourseCanvas } from "@/course/canvas";
import { GuideDialog as RegistryGuideDialog } from "@/course/interactions/guide-dialog";
import { useDialogStore } from "@/stores";
import type { GuideDialogProps } from "./GuideDialog.types";
export function GuideDialog({
children,
onFinish,
onSkip,
...props
}: GuideDialogProps) {
const {
open,
steps,
current,
nextStep,
prevStep,
selectOption,
finishDialog,
skipDialog,
} = useDialogStore();
const { viewport } = useCourseCanvas();
const finish = useCallback(() => {
finishDialog();
onFinish?.();
}, [finishDialog, onFinish]);
const skip = useCallback(() => {
skipDialog();
onSkip?.();
}, [onSkip, skipDialog]);
return (
<RegistryGuideDialog
state={{
open,
steps,
current,
}}
actions={{
next: nextStep,
prev: prevStep,
skip,
finish,
selectOption,
}}
container={getAppPortalRoot}
viewport={viewport}
{...props}
>
{children}
</RegistryGuideDialog>
);
}
@@ -0,0 +1,12 @@
import type { GuideDialogProps as RegistryGuideDialogProps } from "@/course/interactions/guide-dialog";
export interface GuideDialogProps
extends Omit<RegistryGuideDialogProps, "actions" | "state" | "viewport"> {
onFinish?: () => void;
onSkip?: () => void;
}
export type {
GuideDialogRenderState,
GuideDialogTheme,
} from "@/course/interactions/guide-dialog";
@@ -0,0 +1,11 @@
export { GuideDialog } from "./GuideDialog";
export type {
DefineDialogOptions,
GuideDialogController,
} from "./GuideDialog.helpers";
export { defineDialog } from "./GuideDialog.helpers";
export type {
GuideDialogProps,
GuideDialogRenderState,
GuideDialogTheme,
} from "./GuideDialog.types";
@@ -0,0 +1,57 @@
import { appConfig } from "@/app.config";
import { useSpotlightStore } from "@/stores";
import type {
SpotlightStep,
StartSpotlightOptions,
} from "@/stores/types/spotlight.types";
type DefineSpotlightStep = Omit<SpotlightStep, "target"> & {
target?: SpotlightStep["target"];
};
export interface DefineSpotlightOptions {
id: string;
steps: DefineSpotlightStep[];
showOnce?: boolean;
initialStep?: number;
}
export interface SpotlightController {
id: string;
steps: SpotlightStep[];
start: (options?: Pick<StartSpotlightOptions, "initialStep">) => void;
reset: () => void;
}
function normalizeSteps(steps: DefineSpotlightStep[]): SpotlightStep[] {
return steps.map((step) => ({
...step,
target: step.target ?? { dataGuide: step.id },
}));
}
export function defineSpotlight({
id,
steps,
showOnce = true,
initialStep = 0,
}: DefineSpotlightOptions): SpotlightController {
const normalizedSteps = normalizeSteps(steps);
return {
id,
steps: normalizedSteps,
start: (options) => {
useSpotlightStore.getState().startSpotlight({
courseId: appConfig.course.id,
spotlightId: id,
showOnce,
steps: normalizedSteps,
initialStep: options?.initialStep ?? initialStep,
});
},
reset: () => {
useSpotlightStore.getState().resetSpotlight(appConfig.course.id, id);
},
};
}
@@ -0,0 +1,56 @@
import { useCallback } from "react";
import { getAppPortalRoot, useCourseCanvas } from "@/course/canvas";
import { GuideSpotlight as RegistryGuideSpotlight } from "@/course/interactions/guide-spotlight";
import { useSpotlightStore } from "@/stores";
import type { GuideSpotlightProps } from "./GuideSpotlight.types";
export function GuideSpotlight({
children,
onFinish,
onSkip,
container = getAppPortalRoot,
viewport,
...props
}: GuideSpotlightProps) {
const {
open,
steps,
current,
nextStep,
prevStep,
finishSpotlight,
skipSpotlight,
} = useSpotlightStore();
const { viewport: canvasViewport } = useCourseCanvas();
const finish = useCallback(() => {
finishSpotlight();
onFinish?.();
}, [finishSpotlight, onFinish]);
const skip = useCallback(() => {
skipSpotlight();
onSkip?.();
}, [onSkip, skipSpotlight]);
return (
<RegistryGuideSpotlight
state={{
open,
steps,
current,
}}
actions={{
next: nextStep,
prev: prevStep,
skip,
finish,
}}
container={container}
viewport={viewport ?? canvasViewport}
{...props}
>
{children}
</RegistryGuideSpotlight>
);
}
@@ -0,0 +1,14 @@
import type { GuideSpotlightProps as RegistryGuideSpotlightProps } from "@/course/interactions/guide-spotlight";
export interface GuideSpotlightProps
extends Omit<RegistryGuideSpotlightProps, "actions" | "state"> {
onFinish?: () => void;
onSkip?: () => void;
}
export type {
GuideSpotlightRenderState,
GuideSpotlightTheme,
Rect,
SpotlightLayout,
} from "@/course/interactions/guide-spotlight";
@@ -0,0 +1,11 @@
export { GuideSpotlight } from "./GuideSpotlight";
export type {
DefineSpotlightOptions,
SpotlightController,
} from "./GuideSpotlight.helpers";
export { defineSpotlight } from "./GuideSpotlight.helpers";
export type {
GuideSpotlightProps,
GuideSpotlightRenderState,
GuideSpotlightTheme,
} from "./GuideSpotlight.types";
@@ -0,0 +1,34 @@
@reference "tailwindcss";
/* 不设 position:组件无绝对定位子元素,留空以便调用方用 className 自行定位 */
.button {
/* hover/按下缩放倍率由组件通过内联 --hover-scale / --press-scale 变量传入 */
--hover-scale: 1.05;
--press-scale: 0.95;
transition: transform 0.18s ease-out;
@apply m-0 box-border cursor-pointer select-none appearance-none overflow-hidden rounded-md border-0 bg-transparent p-0 align-middle hover:brightness-110;
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500;
@apply disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50;
}
.button:hover {
transform: scale(var(--hover-scale));
}
.button:active {
transform: scale(var(--press-scale));
}
.image {
@apply block h-full w-full object-cover;
}
@media (prefers-reduced-motion: reduce) {
.button,
.button:hover,
.button:active {
transform: none;
transition: none;
}
}
@@ -0,0 +1,53 @@
import { type CSSProperties, forwardRef } from "react";
import { cn } from "@/lib/cn";
import { getStaticImage } from "@/utils/assets";
import styles from "./ImageButton.module.css";
import type { ImageButtonProps } from "./ImageButton.types";
// 静态资源 key vs 外部 URL 的解析;未命中时回退原样交给 <img>
function resolveImage(src: string): string {
return /^(https?:|data:|blob:)/.test(src) ? src : getStaticImage(src) || src;
}
export const ImageButton = forwardRef<HTMLButtonElement, ImageButtonProps>(
(
{
src,
alt = "",
width,
height,
hoverScale = 1.05,
pressScale = 0.95,
className,
style,
...rest
},
ref,
) => {
const imageSrc = resolveImage(src);
// hover/按下缩放由 CSS 过渡实现,倍率通过内联变量传入(见 ImageButton.module.css)
const scaleVars = {
"--hover-scale": hoverScale,
"--press-scale": pressScale,
} as CSSProperties;
return (
<button
ref={ref}
type="button"
className={cn(styles.button, className)}
style={{ ...scaleVars, width, height, ...style }}
{...rest}
>
<img
src={imageSrc}
alt={alt}
className={styles.image}
draggable={false}
/>
</button>
);
},
);
ImageButton.displayName = "ImageButton";
@@ -0,0 +1,15 @@
import type { ButtonHTMLAttributes } from "react";
export interface ImageButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement> {
/** 图片:静态资源 key(相对 src/assets/images/)或外部 http(s)/data/blob URL */
src: string;
alt?: string;
/** 显示尺寸(数字按 px,字符串原样透传)。图片按钮通常应有确定尺寸 */
width?: number | string;
height?: number | string;
/** hover 放大倍率,默认 1.05 */
hoverScale?: number;
/** 按下缩小倍率,默认 0.95 */
pressScale?: number;
}
@@ -0,0 +1,2 @@
export { ImageButton } from "./ImageButton";
export type { ImageButtonProps } from "./ImageButton.types";
@@ -0,0 +1,39 @@
import { isRouteErrorResponse, Link, useRouteError } from "react-router-dom";
export function RouteError() {
const error = useRouteError();
const message = isRouteErrorResponse(error)
? `${error.status} ${error.statusText}`
: error instanceof Error
? error.message
: "页面加载失败";
return (
<div className="flex min-h-full items-center justify-center p-8">
<div className="w-full max-w-xl rounded-3xl border border-rose-200 bg-white p-8 text-center shadow-xl">
<p className="text-sm font-semibold text-rose-600">页面运行异常</p>
<h1 className="mt-3 text-2xl font-bold text-gray-950">
当前示例无法加载
</h1>
<p className="mt-3 break-words text-sm leading-6 text-gray-600">
{message}
</p>
<div className="mt-6 flex justify-center gap-3">
<Link
to="/"
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white"
>
返回首页
</Link>
<button
type="button"
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700"
onClick={() => window.location.reload()}
>
重新加载
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,7 @@
export function RouteFallback() {
return (
<div className="flex min-h-[200px] items-center justify-center text-gray-500">
加载中...
</div>
);
}
@@ -0,0 +1,2 @@
export { RouteError } from "./RouteError";
export { RouteFallback } from "./RouteFallback";
@@ -0,0 +1,67 @@
import { Overlay } from "@/components/ui/overlay";
import { getAppPortalRoot } from "@/course/canvas";
import type { VideoOverlayProps } from "./VideoOverlay.types";
import { VideoPlayer } from "./VideoPlayer";
/** 视频弹层:支持全屏过场与窗口播放,由调用方决定关闭和流程推进策略 */
export function VideoOverlay({
open,
src,
poster,
autoPlay = true,
mode = "fullscreen",
windowWidth = 1200,
windowHeight = 675,
dismissible = false,
lockScroll = true,
skipLabel,
onClose,
onEnded,
onSkip,
}: VideoOverlayProps) {
return (
<Overlay
open={open}
onClose={onClose}
container={getAppPortalRoot}
variant="dim"
closeOnEscape={dismissible}
closeOnBackgroundClick={dismissible}
lockScroll={lockScroll}
className="z-50"
contentClassName="flex items-center justify-center"
>
<div
style={
mode === "window"
? { width: windowWidth, height: windowHeight }
: undefined
}
className={
mode === "window"
? "relative overflow-hidden rounded-lg bg-black shadow-2xl"
: "absolute inset-0 h-full w-full bg-black"
}
>
<VideoPlayer
src={src}
poster={poster}
autoPlay={autoPlay}
mode={mode}
allowFullscreenToggle={false}
onEnded={onEnded}
/>
{skipLabel && (
<button
type="button"
className="absolute right-14 top-10 z-10 rounded-full bg-black/55 px-10 py-4 text-[30px] text-white/90 hover:bg-black/75"
onClick={onSkip}
>
{skipLabel}
</button>
)}
</div>
</Overlay>
);
}
@@ -0,0 +1,28 @@
export interface VideoOverlayProps {
/** 是否显示 */
open: boolean;
/** 可直接交给 video 元素的视频 URL */
src: string;
/** 封面图 */
poster?: string;
/** 自动播放 */
autoPlay?: boolean;
/** 播放模式 */
mode?: "fullscreen" | "window";
/** 窗口模式下的宽度(px) */
windowWidth?: number;
/** 窗口模式下的高度(px) */
windowHeight?: number;
/** 是否允许点击背景或按 Escape 关闭 */
dismissible?: boolean;
/** 弹层打开时是否锁定页面滚动 */
lockScroll?: boolean;
/** 跳过按钮文案,不传则不显示 */
skipLabel?: string;
/** 关闭回调 */
onClose?: () => void;
/** 播放结束回调 */
onEnded?: () => void;
/** 点击跳过回调 */
onSkip?: () => void;
}
@@ -0,0 +1,134 @@
@reference "tailwindcss";
/* 播放器容器 */
.player {
@apply relative flex items-center justify-center overflow-hidden bg-black;
}
.playerFullscreen {
@apply absolute inset-0 h-full w-full;
}
.playerWindow {
@apply relative mx-auto;
}
/* 视频元素 */
.video {
@apply h-full w-full object-contain;
}
/* 中央播放按钮(暂停时显示) */
.centerPlayButton {
@apply absolute flex h-20 w-20 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-opacity duration-300;
}
.centerPlayButton:hover {
@apply bg-black/70;
}
/* 控制条容器 */
.controls {
@apply absolute bottom-0 left-0 right-0 flex flex-col bg-gradient-to-t from-black/80 to-transparent px-4 pb-3 pt-12 transition-opacity duration-300;
}
.controlsHidden {
@apply pointer-events-none opacity-0;
}
/* 小进度条(控件隐藏时显示) */
.miniProgress {
@apply pointer-events-none absolute bottom-0 left-0 right-0 h-1 bg-white/20 opacity-100 transition-opacity duration-300;
}
.miniProgressHidden {
@apply opacity-0;
}
.miniProgressBar {
@apply h-full w-full origin-left bg-red-500;
transform: scaleX(0);
will-change: transform;
}
/* 进度条 */
.progressContainer {
@apply relative mb-2 h-1.5 cursor-pointer rounded-full bg-white/30;
touch-action: none;
}
.progressBar,
.progressBuffered {
@apply absolute left-0 top-0 h-full w-full origin-left rounded-full;
transform: scaleX(0);
will-change: transform;
}
.progressBar {
@apply bg-red-500;
}
.progressBuffered {
@apply bg-white/30;
}
.progressThumb {
@apply absolute left-0 top-1/2 h-3.5 w-3.5 rounded-full bg-red-500 opacity-0 shadow-md transition-opacity duration-200;
transform: translate3d(0, -50%, 0);
will-change: transform;
}
.progressContainer:hover .progressThumb,
.progressDragging .progressThumb {
@apply opacity-100;
}
.progressDragging {
@apply cursor-grabbing;
}
/* 时间显示 */
.timeDisplay {
@apply text-xs text-white/90 tabular-nums;
}
/* 按钮 */
.controlButton {
@apply flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-white/90 transition-colors hover:text-white;
}
.controlButton:disabled {
@apply cursor-not-allowed opacity-50;
}
/* 倍速选择器 */
.rateSelector {
@apply flex cursor-pointer items-center rounded px-2 py-0.5 text-xs text-white/90 transition-colors hover:bg-white/20 hover:text-white;
}
/* 音量滑块 */
.volumeSlider {
@apply h-1 w-16 cursor-pointer appearance-none rounded-full bg-white/30;
}
.volumeSlider::-webkit-slider-thumb {
@apply h-3 w-3 appearance-none rounded-full bg-white shadow-md;
}
.volumeSlider::-moz-range-thumb {
@apply h-3 w-3 rounded-full border-0 bg-white shadow-md;
}
/* 底部控制行 */
.controlRow {
@apply flex items-center gap-2;
}
/* 加载状态 */
.loading {
@apply absolute inset-0 flex items-center justify-center bg-black/30;
}
.spinner {
@apply h-10 w-10 animate-spin rounded-full border-4 border-white/30 border-t-white;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,50 @@
export interface VideoPlayerRef {
play: () => void;
pause: () => void;
seek: (time: number) => void;
setVolume: (volume: number) => void;
setPlaybackRate: (rate: number) => void;
getCurrentTime: () => number;
getDuration: () => number;
}
export interface VideoPlayerProps {
/** 视频源 URL */
src: string;
/** 封面图 */
poster?: string;
/** 自动播放 */
autoPlay?: boolean;
/** 循环播放 */
loop?: boolean;
/** 默认静音 */
muted?: boolean;
/** 预加载策略 */
preload?: "none" | "metadata" | "auto";
/** 初始播放速度 */
playbackRate?: number;
/** 显示模式:全屏覆盖或窗口 */
mode?: "fullscreen" | "window";
/** 是否允许在全屏覆盖与窗口模式之间切换 */
allowFullscreenToggle?: boolean;
/** 自定义 className */
className?: string;
// 事件回调
/** 播放开始时触发 */
onPlay?: () => void;
/** 暂停时触发 */
onPause?: () => void;
/** 播放结束时触发 */
onEnded?: () => void;
/** 时间更新时触发 */
onTimeUpdate?: (currentTime: number) => void;
/** 进度加载时触发 */
onProgress?: (buffered: TimeRanges | null) => void;
/** 音量变化时触发 */
onVolumeChange?: (volume: number, muted: boolean) => void;
/** 播放速度变化时触发 */
onPlaybackRateChange?: (rate: number) => void;
/** 全屏状态变化时触发 */
onFullscreenChange?: (isFullscreen: boolean) => void;
}
+4
View File
@@ -0,0 +1,4 @@
export { VideoOverlay } from "./VideoOverlay";
export type { VideoOverlayProps } from "./VideoOverlay.types";
export { VideoPlayer } from "./VideoPlayer";
export type { VideoPlayerProps, VideoPlayerRef } from "./VideoPlayer.types";
+49
View File
@@ -0,0 +1,49 @@
export type { ButtonProps } from "@/components/ui/button";
export { Button } from "@/components/ui/button";
export type { OverlayProps } from "@/components/ui/overlay";
export { Overlay } from "@/components/ui/overlay";
export type { PortalContainer, PortalProps } from "@/components/ui/portal";
export { Portal } from "@/components/ui/portal";
export type {
ToastContextValue,
ToastItem as ToastItemData,
ToastOptions,
ToastProviderProps,
ToastRenderApi,
ToastType,
ToastViewportClassNames,
ToastViewportProps,
} from "@/components/ui/toast";
export { ToastProvider, ToastViewport, useToast } from "@/components/ui/toast";
export type {
BackgroundContainerElement,
BackgroundContainerFit,
BackgroundContainerProps,
BackgroundContainerRepeat,
} from "./BackgroundContainer";
export { BackgroundContainer } from "./BackgroundContainer";
export type {
DefineDialogOptions,
GuideDialogController,
GuideDialogProps,
GuideDialogRenderState,
GuideDialogTheme,
} from "./GuideDialog";
export { defineDialog, GuideDialog } from "./GuideDialog";
export type {
DefineSpotlightOptions,
GuideSpotlightProps,
GuideSpotlightRenderState,
GuideSpotlightTheme,
SpotlightController,
} from "./GuideSpotlight";
export { defineSpotlight, GuideSpotlight } from "./GuideSpotlight";
export type { ImageButtonProps } from "./ImageButton";
export { ImageButton } from "./ImageButton";
export { RouteError, RouteFallback } from "./RouteFallback";
export type {
VideoOverlayProps,
VideoPlayerProps,
VideoPlayerRef,
} from "./Video";
export { VideoOverlay, VideoPlayer } from "./Video";
@@ -0,0 +1,48 @@
@reference "tailwindcss";
.button {
@apply relative m-0 box-border inline-flex cursor-pointer select-none appearance-none items-center justify-center overflow-hidden rounded-md border border-transparent bg-transparent p-0 align-middle font-medium leading-none transition-colors;
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset;
@apply disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50;
}
.primary {
@apply bg-blue-600 text-white;
}
.primary::before {
@apply pointer-events-none absolute inset-0 rounded-[inherit] bg-black/0 transition-colors;
content: "";
}
.primary:hover::before {
@apply bg-black/10;
}
.content {
@apply relative z-10 inline-flex items-center justify-center;
}
.secondary {
@apply bg-gray-200 text-gray-900 hover:bg-gray-300;
}
.outline {
@apply border-gray-300 bg-transparent hover:bg-gray-100;
}
.ghost {
@apply bg-transparent hover:bg-gray-100;
}
.sm {
@apply h-8 px-3 text-sm;
}
.md {
@apply h-10 px-4 text-base;
}
.lg {
@apply h-12 px-6 text-lg;
}
+23
View File
@@ -0,0 +1,23 @@
import { forwardRef } from "react";
import { cn } from "@/lib/cn";
import styles from "./Button.module.css";
import type { ButtonProps } from "./Button.types";
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{ children, className, variant = "primary", size = "md", ...props },
ref,
) => {
return (
<button
ref={ref}
className={cn(styles.button, styles[variant], styles[size], className)}
{...props}
>
<span className={styles.content}>{children}</span>
</button>
);
},
);
Button.displayName = "Button";
+6
View File
@@ -0,0 +1,6 @@
import type { ButtonHTMLAttributes } from "react";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "outline" | "ghost";
size?: "sm" | "md" | "lg";
}
+2
View File
@@ -0,0 +1,2 @@
export { Button } from "./Button";
export type { ButtonProps } from "./Button.types";
@@ -0,0 +1,41 @@
@reference "tailwindcss";
.overlay {
@apply pointer-events-auto inset-0 z-50 overscroll-contain;
}
.fixed {
@apply fixed;
}
.absolute {
@apply absolute;
}
.background {
@apply absolute inset-0 cursor-default appearance-none border-0 p-0;
}
.background:disabled {
@apply cursor-default;
}
.dim {
@apply bg-black/60;
}
.light {
@apply bg-white/60;
}
.transparent {
@apply bg-transparent;
}
.content {
@apply pointer-events-none absolute inset-0;
}
.contentInner {
@apply pointer-events-auto;
}
+98
View File
@@ -0,0 +1,98 @@
import type { CSSProperties, MouseEvent } from "react";
import { useEffect } from "react";
import { Portal } from "@/components/ui/portal";
import { cn } from "@/lib/cn";
import styles from "./Overlay.module.css";
import type { OverlayProps } from "./Overlay.types";
export function Overlay({
open,
children,
variant = "dim",
backgroundImage,
backgroundClassName,
closeOnBackgroundClick = true,
closeOnEscape = true,
lockScroll = true,
onClose,
container,
disabledPortal = false,
className,
contentClassName,
fixed,
...props
}: OverlayProps) {
const isFixed = fixed ?? container == null;
useEffect(() => {
if (!open || !closeOnEscape) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose?.();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [closeOnEscape, onClose, open]);
useEffect(() => {
if (!open || !lockScroll || typeof document === "undefined") {
return;
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, [lockScroll, open]);
if (!open) {
return null;
}
const backgroundImageStyle: CSSProperties | undefined = backgroundImage
? { backgroundImage }
: undefined;
const handleBackgroundClick = (event: MouseEvent<HTMLButtonElement>) => {
event.currentTarget.blur();
onClose?.();
};
return (
<Portal container={container} disabled={disabledPortal}>
<div
className={cn(
styles.overlay,
isFixed ? styles.fixed : styles.absolute,
className,
)}
{...props}
>
<button
type="button"
aria-label="关闭弹层背景"
className={cn(
styles.background,
styles[variant],
backgroundClassName,
)}
disabled={!closeOnBackgroundClick}
style={backgroundImageStyle}
onClick={handleBackgroundClick}
/>
<div className={cn(styles.content, contentClassName)}>
<div className={styles.contentInner}>{children}</div>
</div>
</div>
</Portal>
);
}
@@ -0,0 +1,19 @@
import type { HTMLAttributes, ReactNode } from "react";
import type { PortalContainer } from "@/components/ui/portal";
export interface OverlayProps
extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
open: boolean;
children?: ReactNode;
variant?: "dim" | "light" | "transparent";
backgroundImage?: string;
backgroundClassName?: string;
closeOnBackgroundClick?: boolean;
closeOnEscape?: boolean;
lockScroll?: boolean;
onClose?: () => void;
container?: PortalContainer;
disabledPortal?: boolean;
contentClassName?: string;
fixed?: boolean;
}
+2
View File
@@ -0,0 +1,2 @@
export { Overlay } from "./Overlay";
export type { OverlayProps } from "./Overlay.types";
+26
View File
@@ -0,0 +1,26 @@
import { createPortal } from "react-dom";
import type { PortalContainer, PortalProps } from "./Portal.types";
function resolveContainer(container?: PortalContainer) {
if (!container) {
return null;
}
if (typeof container === "function") {
return container();
}
if ("current" in container) {
return container.current;
}
return container;
}
export function Portal({ children, container, disabled = false }: PortalProps) {
if (disabled || typeof document === "undefined") {
return children;
}
return createPortal(children, resolveContainer(container) ?? document.body);
}
+13
View File
@@ -0,0 +1,13 @@
import type { ReactNode, RefObject } from "react";
export type PortalContainer =
| Element
| null
| RefObject<Element | null>
| (() => Element | null);
export interface PortalProps {
children: ReactNode;
container?: PortalContainer;
disabled?: boolean;
}
+2
View File
@@ -0,0 +1,2 @@
export { Portal } from "./Portal";
export type { PortalContainer, PortalProps } from "./Portal.types";
+36
View File
@@ -0,0 +1,36 @@
@reference "tailwindcss";
.container {
@apply pointer-events-none absolute top-1/2 left-1/2 z-60 flex w-auto max-w-[calc(100%-80px)] -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-3;
}
.customToast {
@apply pointer-events-auto;
}
.toast {
@apply pointer-events-auto inline-flex max-w-[520px] items-center justify-center rounded-lg bg-black/75 px-6 py-3 text-center text-white shadow-[0_8px_24px_rgba(0,0,0,0.18)] backdrop-blur-sm;
}
.content {
@apply min-w-0;
}
.title {
@apply hidden;
}
.message {
@apply text-sm font-medium leading-6 text-current;
}
.close {
@apply hidden;
}
.info,
.success,
.warning,
.error {
@apply text-white;
}
+72
View File
@@ -0,0 +1,72 @@
import type { ReactNode } from "react";
import type { PortalContainer } from "@/components/ui/portal";
export type ToastType = "info" | "success" | "warning" | "error";
export interface ToastOptions {
title?: string;
message: string;
type?: ToastType;
duration?: number;
className?: string;
data?: unknown;
}
export interface ToastItem
extends Required<Omit<ToastOptions, "className" | "data">> {
id: string;
className?: string;
data?: unknown;
}
export interface ToastProviderProps {
children: ReactNode;
}
export interface ToastContextValue {
toasts: ToastItem[];
show: (options: ToastOptions | string) => string;
success: (
message: string,
options?: Omit<ToastOptions, "message" | "type">,
) => string;
error: (
message: string,
options?: Omit<ToastOptions, "message" | "type">,
) => string;
warning: (
message: string,
options?: Omit<ToastOptions, "message" | "type">,
) => string;
info: (
message: string,
options?: Omit<ToastOptions, "message" | "type">,
) => string;
remove: (id: string) => void;
clear: () => void;
}
export interface ToastRenderApi {
remove: (id: string) => void;
}
export interface ToastViewportClassNames {
container?: string;
item?: string;
title?: string;
message?: string;
close?: string;
}
export interface ToastViewportProps {
container?: PortalContainer;
className?: string;
classNames?: ToastViewportClassNames;
renderToast?: (toast: ToastItem, api: ToastRenderApi) => ReactNode;
}
export interface ToastItemProps {
toast: ToastItem;
classNames?: ToastViewportClassNames;
onRemove: (id: string) => void;
}
+30
View File
@@ -0,0 +1,30 @@
import { cn } from "@/lib/cn";
import styles from "./Toast.module.css";
import type { ToastItemProps } from "./Toast.types";
export function ToastItem({ toast, classNames, onRemove }: ToastItemProps) {
return (
<div
className={cn(
styles.toast,
styles[toast.type],
classNames?.item,
toast.className,
)}
>
<div className={styles.content}>
<div className={cn(styles.title, classNames?.title)}>{toast.title}</div>
<div className={cn(styles.message, classNames?.message)}>
{toast.message}
</div>
</div>
<button
type="button"
className={cn(styles.close, classNames?.close)}
onClick={() => onRemove(toast.id)}
>
×
</button>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type {
ToastContextValue,
ToastItem,
ToastOptions,
ToastProviderProps,
ToastType,
} from "./Toast.types";
const DEFAULT_DURATION = 2400;
const ToastContext = createContext<ToastContextValue | null>(null);
function createToastId() {
return `toast-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function getDefaultTitle(type: ToastType) {
const titleMap: Record<ToastType, string> = {
info: "提示",
success: "成功",
warning: "注意",
error: "错误",
};
return titleMap[type];
}
export function ToastProvider({ children }: ToastProviderProps) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const timersRef = useRef<Map<string, number>>(new Map());
const remove = useCallback((id: string) => {
const timer = timersRef.current.get(id);
if (timer) {
window.clearTimeout(timer);
timersRef.current.delete(id);
}
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);
const clear = useCallback(() => {
timersRef.current.forEach((timer) => {
window.clearTimeout(timer);
});
timersRef.current.clear();
setToasts([]);
}, []);
useEffect(() => {
return () => {
timersRef.current.forEach((timer) => {
window.clearTimeout(timer);
});
timersRef.current.clear();
};
}, []);
const show = useCallback(
(options: ToastOptions | string) => {
const normalizedOptions: ToastOptions =
typeof options === "string" ? { message: options } : options;
const type = normalizedOptions.type ?? "info";
const id = createToastId();
const toast: ToastItem = {
id,
type,
title: normalizedOptions.title ?? getDefaultTitle(type),
message: normalizedOptions.message,
duration: normalizedOptions.duration ?? DEFAULT_DURATION,
className: normalizedOptions.className,
data: normalizedOptions.data,
};
setToasts((current) => [...current, toast]);
if (toast.duration > 0) {
const timer = window.setTimeout(() => remove(id), toast.duration);
timersRef.current.set(id, timer);
}
return id;
},
[remove],
);
const contextValue = useMemo<ToastContextValue>(
() => ({
toasts,
show,
success: (message, options) =>
show({ ...options, message, type: "success" }),
error: (message, options) => show({ ...options, message, type: "error" }),
warning: (message, options) =>
show({ ...options, message, type: "warning" }),
info: (message, options) => show({ ...options, message, type: "info" }),
remove,
clear,
}),
[clear, remove, show, toasts],
);
return (
<ToastContext.Provider value={contextValue}>
{children}
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error("useToast must be used within ToastProvider");
}
return context;
}
+40
View File
@@ -0,0 +1,40 @@
import { Portal } from "@/components/ui/portal";
import { cn } from "@/lib/cn";
import styles from "./Toast.module.css";
import type { ToastViewportProps } from "./Toast.types";
import { ToastItem } from "./ToastItem";
import { useToast } from "./ToastProvider";
export function ToastViewport({
container,
className,
classNames,
renderToast,
}: ToastViewportProps) {
const { toasts, remove } = useToast();
if (toasts.length === 0) {
return null;
}
return (
<Portal container={container}>
<div className={cn(styles.container, classNames?.container, className)}>
{toasts.map((toast) =>
renderToast ? (
<div key={toast.id} className={styles.customToast}>
{renderToast(toast, { remove })}
</div>
) : (
<ToastItem
key={toast.id}
toast={toast}
classNames={classNames}
onRemove={remove}
/>
),
)}
</div>
</Portal>
);
}
+12
View File
@@ -0,0 +1,12 @@
export type {
ToastContextValue,
ToastItem,
ToastOptions,
ToastProviderProps,
ToastRenderApi,
ToastType,
ToastViewportClassNames,
ToastViewportProps,
} from "./Toast.types";
export { ToastProvider, useToast } from "./ToastProvider";
export { ToastViewport } from "./ToastViewport";
+14
View File
@@ -0,0 +1,14 @@
import { createContext, useContext } from "react";
import type { CourseCanvasContextValue } from "./CourseCanvas.types";
export const CanvasContext = createContext<CourseCanvasContextValue | null>(
null,
);
export function useCourseCanvas(): CourseCanvasContextValue {
const context = useContext(CanvasContext);
if (!context) {
throw new Error("useCourseCanvas must be used within CourseCanvas");
}
return context;
}
+134
View File
@@ -0,0 +1,134 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CanvasContext } from "./CanvasContext";
import type {
CourseCanvasContextValue,
CourseCanvasProps,
CourseCanvasViewport,
} from "./CourseCanvas.types";
import { useCanvasScale } from "./useCanvasScale";
const DEFAULT_WIDTH = 1920;
const DEFAULT_HEIGHT = 1080;
export function CourseCanvas({
children,
width = DEFAULT_WIDTH,
height = DEFAULT_HEIGHT,
maxScale = 1,
className,
canvasClassName,
portalClassName,
}: CourseCanvasProps) {
const scale = useCanvasScale({ width, height, maxScale });
const frameRef = useRef<HTMLDivElement>(null);
const portalContainer = useRef<HTMLDivElement>(null);
const [viewport, setViewport] = useState<CourseCanvasViewport>({
width,
height,
scale,
originLeft: 0,
originTop: 0,
});
const measure = useCallback(() => {
const frame = frameRef.current;
if (!frame) return;
const rect = frame.getBoundingClientRect();
const committedScale = rect.width > 0 ? rect.width / width : scale;
const next = {
width,
height,
scale: committedScale,
originLeft: rect.left,
originTop: rect.top,
};
setViewport((current) =>
current.width === next.width &&
current.height === next.height &&
current.scale === next.scale &&
current.originLeft === next.originLeft &&
current.originTop === next.originTop
? current
: next,
);
}, [height, scale, width]);
useEffect(() => {
const frame = frameRef.current;
let animationFrame = requestAnimationFrame(measure);
const scheduleMeasure = () => {
cancelAnimationFrame(animationFrame);
animationFrame = requestAnimationFrame(measure);
};
const observer =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(scheduleMeasure);
if (frame) observer?.observe(frame);
observer?.observe(document.documentElement);
window.addEventListener("resize", scheduleMeasure);
window.visualViewport?.addEventListener("resize", scheduleMeasure);
return () => {
cancelAnimationFrame(animationFrame);
observer?.disconnect();
window.removeEventListener("resize", scheduleMeasure);
window.visualViewport?.removeEventListener("resize", scheduleMeasure);
};
}, [measure]);
const value = useMemo<CourseCanvasContextValue>(
() => ({ width, height, scale, portalContainer, viewport }),
[height, scale, viewport, width],
);
return (
<CanvasContext.Provider value={value}>
<div
className={className}
style={{
display: "flex",
width: "100%",
height: "100%",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
}}
>
<div
ref={frameRef}
style={{
position: "relative",
width: width * scale,
height: height * scale,
flex: "none",
}}
>
<div
className={canvasClassName}
style={{
position: "relative",
width,
height,
transform: `scale(${scale})`,
transformOrigin: "left top",
}}
>
{children}
<div
ref={portalContainer}
data-course-portal-root=""
className={portalClassName}
style={{
position: "absolute",
inset: 0,
pointerEvents: "none",
}}
/>
</div>
</div>
</div>
</CanvasContext.Provider>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { ReactNode, RefObject } from "react";
export interface CourseCanvasViewport {
width: number;
height: number;
scale: number;
originLeft: number;
originTop: number;
}
export interface CourseCanvasContextValue {
width: number;
height: number;
scale: number;
portalContainer: RefObject<HTMLDivElement | null>;
viewport: CourseCanvasViewport;
}
export interface CourseCanvasProps {
children: ReactNode;
width?: number;
height?: number;
maxScale?: number;
className?: string;
canvasClassName?: string;
portalClassName?: string;
}
+16
View File
@@ -0,0 +1,16 @@
import type { RefObject } from "react";
let portalRoot: RefObject<HTMLDivElement | null> | null = null;
export function registerAppPortalRoot(
container: RefObject<HTMLDivElement | null>,
): () => void {
portalRoot = container;
return () => {
if (portalRoot === container) portalRoot = null;
};
}
export function getAppPortalRoot(): HTMLDivElement | null {
return portalRoot?.current ?? null;
}
+8
View File
@@ -0,0 +1,8 @@
export { CanvasContext, useCourseCanvas } from "./CanvasContext";
export { CourseCanvas } from "./CourseCanvas";
export type {
CourseCanvasContextValue,
CourseCanvasProps,
CourseCanvasViewport,
} from "./CourseCanvas.types";
export { getAppPortalRoot, registerAppPortalRoot } from "./canvasPortal";
+57
View File
@@ -0,0 +1,57 @@
import { useEffect, useState } from "react";
interface CanvasScaleOptions {
width: number;
height: number;
maxScale: number;
}
function getViewportSize() {
if (typeof window === "undefined") {
return null;
}
return {
width: window.visualViewport?.width ?? window.innerWidth,
height: window.visualViewport?.height ?? window.innerHeight,
};
}
function calculateScale({
width,
height,
maxScale,
}: CanvasScaleOptions): number {
if (typeof window === "undefined") return maxScale;
const viewport = getViewportSize();
if (!viewport) return maxScale;
return Math.min(viewport.width / width, viewport.height / height, maxScale);
}
export function useCanvasScale({
width,
height,
maxScale,
}: CanvasScaleOptions): number {
const [scale, setScale] = useState(() =>
calculateScale({ width, height, maxScale }),
);
useEffect(() => {
const updateScale = () =>
setScale(calculateScale({ width, height, maxScale }));
updateScale();
window.addEventListener("resize", updateScale);
window.addEventListener("orientationchange", updateScale);
window.visualViewport?.addEventListener("resize", updateScale);
window.visualViewport?.addEventListener("scroll", updateScale);
return () => {
window.removeEventListener("resize", updateScale);
window.removeEventListener("orientationchange", updateScale);
window.visualViewport?.removeEventListener("resize", updateScale);
window.visualViewport?.removeEventListener("scroll", updateScale);
};
}, [height, maxScale, width]);
return scale;
}
@@ -0,0 +1,21 @@
@reference "tailwindcss";
.draggable {
@apply touch-none select-none;
}
.dragging {
@apply z-10 cursor-grabbing;
}
.droppable {
@apply transition-colors;
}
.over {
@apply bg-blue-50;
}
.disabled {
@apply pointer-events-none opacity-50;
}
@@ -0,0 +1,79 @@
import type {
CollisionDetection,
DndContextProps,
DragCancelEvent,
DragEndEvent,
DragOverEvent,
DragStartEvent,
UniqueIdentifier,
} from "@dnd-kit/core";
import type { CSSProperties, HTMLAttributes, ReactNode } from "react";
export type DragId = UniqueIdentifier;
export interface DragOverlayRenderState {
activeId: DragId;
activeData?: Record<string, unknown>;
}
type DragDropClassName<State> = string | ((state: State) => string | undefined);
export interface DragDropProviderProps {
children: ReactNode;
scale?: number;
renderOverlay?: (state: DragOverlayRenderState) => ReactNode;
collisionDetection?: CollisionDetection;
onDragStart?: (event: DragStartEvent) => void;
onDragOver?: (event: DragOverEvent) => void;
onDragEnd?: (event: DragEndEvent) => void;
onDragCancel?: (event: DragCancelEvent) => void;
options?: Omit<
DndContextProps,
| "children"
| "collisionDetection"
| "sensors"
| "onDragStart"
| "onDragOver"
| "onDragEnd"
| "onDragCancel"
>;
}
export interface DraggableState {
isDragging: boolean;
disabled: boolean;
}
export interface DraggableProps
extends Omit<
HTMLAttributes<HTMLDivElement>,
"children" | "className" | "id"
> {
id: DragId;
children: ReactNode | ((state: DraggableState) => ReactNode);
disabled?: boolean;
data?: Record<string, unknown>;
className?: DragDropClassName<DraggableState>;
style?: CSSProperties;
}
export interface DroppableState {
isOver: boolean;
disabled: boolean;
activeId: DragId | null;
canDrop: boolean;
}
export interface DroppableProps
extends Omit<
HTMLAttributes<HTMLDivElement>,
"children" | "className" | "id"
> {
id: DragId;
children: ReactNode | ((state: DroppableState) => ReactNode);
disabled?: boolean;
data?: Record<string, unknown>;
activeClassName?: string;
canDrop?: (activeId: DragId) => boolean;
className?: DragDropClassName<DroppableState>;
}
@@ -0,0 +1,22 @@
import type { Transform } from "@dnd-kit/utilities";
import { createContext, useContext } from "react";
interface DragDropContextValue {
scale: number;
inverseScale: number;
hasOverlay: boolean;
getScaledTransform: (transform: Transform | null) => Transform | null;
}
const DragDropContext = createContext<DragDropContextValue>({
scale: 1,
inverseScale: 1,
hasOverlay: false,
getScaledTransform: (transform) => transform,
});
export const DragDropContextProvider = DragDropContext.Provider;
export function useDragDropContext() {
return useContext(DragDropContext);
}
@@ -0,0 +1,123 @@
import type {
DragCancelEvent,
DragEndEvent,
DragStartEvent,
} from "@dnd-kit/core";
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import type { Transform } from "@dnd-kit/utilities";
import { useMemo, useState } from "react";
import { Portal } from "@/components/ui/portal";
import { pointerWithinWithFallback } from "./collision";
import type {
DragDropProviderProps,
DragOverlayRenderState,
} from "./DragDrop.types";
import { DragDropContextProvider } from "./DragDropContext";
export function DragDropProvider({
children,
scale = 1,
renderOverlay,
collisionDetection = pointerWithinWithFallback,
onDragStart,
onDragOver,
onDragEnd,
onDragCancel,
options,
}: DragDropProviderProps) {
const safeScale = scale || 1;
const inverseScale = 1 / safeScale;
const hasOverlay = renderOverlay != null;
const [overlayState, setOverlayState] =
useState<DragOverlayRenderState | null>(null);
const activationDistance = Math.max(2, 6 / safeScale);
const touchTolerance = Math.max(4, 8 / safeScale);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: activationDistance,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 120,
tolerance: touchTolerance,
},
}),
useSensor(KeyboardSensor),
);
const contextValue = useMemo(
() => ({
scale: safeScale,
inverseScale,
hasOverlay,
getScaledTransform: (transform: Transform | null) =>
transform
? {
...transform,
x: transform.x * inverseScale,
y: transform.y * inverseScale,
}
: null,
}),
[hasOverlay, inverseScale, safeScale],
);
const handleDragStart = (event: DragStartEvent) => {
if (renderOverlay) {
setOverlayState({
activeId: event.active.id,
activeData: event.active.data.current,
});
}
onDragStart?.(event);
};
const handleDragEnd = (event: DragEndEvent) => {
setOverlayState(null);
onDragEnd?.(event);
};
const handleDragCancel = (event: DragCancelEvent) => {
setOverlayState(null);
onDragCancel?.(event);
};
return (
<DragDropContextProvider value={contextValue}>
<DndContext
collisionDetection={collisionDetection}
sensors={sensors}
onDragStart={handleDragStart}
onDragOver={onDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
{...options}
>
{children}
{renderOverlay && typeof document !== "undefined" && (
<Portal container={document.body}>
<DragOverlay adjustScale={false} dropAnimation={null}>
<div
style={{
transform: `scale(${safeScale})`,
transformOrigin: "top left",
}}
>
{overlayState ? renderOverlay(overlayState) : null}
</div>
</DragOverlay>
</Portal>
)}
</DndContext>
</DragDropContextProvider>
);
}
@@ -0,0 +1,56 @@
import { useDraggable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/cn";
import styles from "./DragDrop.module.css";
import type { DraggableProps } from "./DragDrop.types";
import { useDragDropContext } from "./DragDropContext";
export function Draggable({
id,
children,
disabled = false,
data,
className,
style,
...props
}: DraggableProps) {
const { getScaledTransform, hasOverlay } = useDragDropContext();
const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({
id,
data,
disabled,
});
const scaledTransform = getScaledTransform(transform);
const state = {
isDragging,
disabled,
};
const resolvedClassName =
typeof className === "function" ? className(state) : className;
const resolvedChildren =
typeof children === "function" ? children(state) : children;
return (
<div
ref={setNodeRef}
className={cn(
styles.draggable,
isDragging && styles.dragging,
disabled && styles.disabled,
resolvedClassName,
)}
style={{
...style,
transform: hasOverlay
? style?.transform
: CSS.Translate.toString(scaledTransform),
}}
{...attributes}
{...listeners}
{...props}
>
{resolvedChildren}
</div>
);
}
@@ -0,0 +1,49 @@
import { useDndContext, useDroppable } from "@dnd-kit/core";
import { cn } from "@/lib/cn";
import styles from "./DragDrop.module.css";
import type { DroppableProps, DroppableState } from "./DragDrop.types";
export function Droppable({
id,
children,
disabled = false,
data,
className,
activeClassName,
canDrop,
...props
}: DroppableProps) {
const { active } = useDndContext();
const { isOver, setNodeRef } = useDroppable({
id,
data,
disabled,
});
const activeId = active?.id ?? null;
const state: DroppableState = {
isOver,
disabled,
activeId,
canDrop: activeId != null ? (canDrop?.(activeId) ?? true) : false,
};
const resolvedClassName =
typeof className === "function" ? className(state) : className;
const resolvedChildren =
typeof children === "function" ? children(state) : children;
return (
<div
ref={setNodeRef}
className={cn(
styles.droppable,
isOver && className == null && !activeClassName && styles.over,
isOver && activeClassName,
disabled && styles.disabled,
resolvedClassName,
)}
{...props}
>
{resolvedChildren}
</div>
);
}
@@ -0,0 +1,15 @@
import {
type CollisionDetection,
closestCenter,
pointerWithin,
} from "@dnd-kit/core";
export const pointerWithinWithFallback: CollisionDetection = (args) => {
const pointerCollisions = pointerWithin(args);
if (args.pointerCoordinates) {
return pointerCollisions;
}
return pointerCollisions.length > 0 ? pointerCollisions : closestCenter(args);
};
@@ -0,0 +1,13 @@
export { pointerWithinWithFallback } from "./collision";
export type {
DragDropProviderProps,
DraggableProps,
DraggableState,
DragId,
DragOverlayRenderState,
DroppableProps,
DroppableState,
} from "./DragDrop.types";
export { DragDropProvider } from "./DragDropProvider";
export { Draggable } from "./Draggable";
export { Droppable } from "./Droppable";
@@ -0,0 +1,72 @@
import { Button } from "@/components/ui/button";
import styles from "./GuideDialog.module.css";
import type { GuideDialogRenderState } from "./GuideDialog.types";
export interface DefaultGuideDialogPanelProps {
dialog: GuideDialogRenderState;
}
export function DefaultGuideDialogPanel({
dialog,
}: DefaultGuideDialogPanelProps) {
const { character, options, progress, status, step, actions, labels } =
dialog;
return (
<>
{character && (
<div className={styles.character}>
<div className={styles.avatar}>{character.avatar ?? "🙂"}</div>
<div>
{character.name && (
<div className={styles.name}>{character.name}</div>
)}
{character.mood && (
<div className="text-xs text-gray-400">{character.mood}</div>
)}
</div>
</div>
)}
{step.title && <h2 className={styles.title}>{step.title}</h2>}
{step.description && (
<div className={styles.description}>{step.description}</div>
)}
{options.length > 0 && (
<div className={styles.options}>
{options.map((option) => (
<Button
key={option.id}
variant="outline"
onClick={() => actions.selectOption(option.id)}
>
{option.label}
</Button>
))}
</div>
)}
<div className={styles.footer}>
<span className={styles.counter}>
{progress.current} / {progress.total}
</span>
<div className={styles.actions}>
<Button variant="ghost" size="sm" onClick={actions.skip}>
{labels.skip}
</Button>
{!status.isFirst && (
<Button variant="secondary" size="sm" onClick={actions.prev}>
{labels.prev}
</Button>
)}
{!status.hasOptions && (
<Button size="sm" onClick={actions.primary}>
{labels.primary}
</Button>
)}
</div>
</div>
</>
);
}
@@ -0,0 +1,101 @@
@reference "tailwindcss";
.mask {
@apply pointer-events-auto absolute inset-0 block h-full w-full border-0 p-0;
animation: dialog-fade-in 220ms ease-out both;
}
.panelContainer {
@apply pointer-events-auto absolute;
animation: dialog-panel-in 320ms cubic-bezier(0.16, 1, 0.3, 1) both;
transition:
left 260ms cubic-bezier(0.16, 1, 0.3, 1),
top 260ms cubic-bezier(0.16, 1, 0.3, 1),
transform 260ms cubic-bezier(0.16, 1, 0.3, 1),
opacity 180ms ease;
will-change: left, top, transform, opacity;
}
.panel {
@apply pointer-events-auto absolute w-[520px] rounded-3xl bg-white p-6 text-gray-900 shadow-2xl;
animation: dialog-panel-in 320ms cubic-bezier(0.16, 1, 0.3, 1) both;
transition:
left 260ms cubic-bezier(0.16, 1, 0.3, 1),
top 260ms cubic-bezier(0.16, 1, 0.3, 1),
transform 260ms cubic-bezier(0.16, 1, 0.3, 1),
opacity 180ms ease;
will-change: left, top, transform, opacity;
}
.measurePanel {
left: -9999px;
top: -9999px;
visibility: hidden;
animation: none;
pointer-events: none;
}
.character {
@apply mb-4 flex items-center gap-3;
}
.avatar {
@apply flex h-14 w-14 items-center justify-center rounded-full bg-blue-100 text-3xl;
}
.name {
@apply text-sm font-semibold text-blue-600;
}
.title {
@apply text-2xl font-bold;
}
.description {
@apply mt-3 text-base leading-7 text-gray-600;
}
.options {
@apply mt-5 grid gap-2;
}
.footer {
@apply mt-6 flex items-center justify-between gap-3;
}
.counter {
@apply text-xs text-gray-400;
}
.actions {
@apply flex items-center gap-2;
}
@keyframes dialog-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialog-panel-in {
from {
opacity: 0;
transform: translate3d(0, 12px, 0) scale(0.98);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.mask,
.panel,
.panelContainer {
animation: none;
transition: none;
}
}
@@ -0,0 +1,300 @@
import {
type MouseEvent,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { Portal } from "@/components/ui/portal";
import { cn } from "@/lib/cn";
import { DefaultGuideDialogPanel } from "./DefaultGuideDialogPanel";
import styles from "./GuideDialog.module.css";
import type { GuideDialogLayout, GuideDialogProps } from "./GuideDialog.types";
const DEFAULT_PANEL_MAX_WIDTH = 560;
const VIEWPORT_MARGIN = 32;
const BOTTOM_OFFSET = 72;
const TOP_OFFSET = 72;
interface CoordinateContext {
portalTarget: HTMLElement;
scale: number;
width: number;
height: number;
}
function resolveContainer(container?: GuideDialogProps["container"]) {
if (!container) {
return null;
}
if (typeof container === "function") {
return container();
}
return container;
}
function getCoordinateContext({
container,
viewport,
}: Pick<GuideDialogProps, "container" | "viewport">): CoordinateContext {
const portalTarget = resolveContainer(container) ?? document.body;
if (viewport) {
return {
portalTarget,
scale: viewport.scale ?? 1,
width: viewport.width,
height: viewport.height,
};
}
const rect = portalTarget.getBoundingClientRect();
return {
portalTarget,
scale: 1,
width: rect.width || window.innerWidth,
height: rect.height || window.innerHeight,
};
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function getPanelPosition(
placement: "top" | "center" | "bottom",
panel: { width: number; height: number },
context: CoordinateContext,
) {
const left = context.width / 2 - panel.width / 2;
const topMap = {
top: TOP_OFFSET,
center: context.height / 2 - panel.height / 2,
bottom: context.height - panel.height - BOTTOM_OFFSET,
};
const maxLeft = Math.max(
context.width - panel.width - VIEWPORT_MARGIN,
VIEWPORT_MARGIN,
);
const maxTop = Math.max(
context.height - panel.height - VIEWPORT_MARGIN,
VIEWPORT_MARGIN,
);
return {
left: clamp(left, VIEWPORT_MARGIN, maxLeft),
top: clamp(topMap[placement], VIEWPORT_MARGIN, maxTop),
};
}
export function GuideDialog({
state,
actions,
children,
container,
viewport,
theme,
closeOnOverlayClick = false,
closeOnEsc = true,
onStepChange,
}: GuideDialogProps) {
const { open, steps, current } = state;
const panelRef = useRef<HTMLDivElement>(null);
const [layout, setLayout] = useState<GuideDialogLayout | null>(null);
const step = steps[current];
const stepKey = step ? `${current}:${step.id}` : "";
const zIndex = theme?.zIndex ?? 1000;
const panelMaxWidth = theme?.panelMaxWidth ?? DEFAULT_PANEL_MAX_WIDTH;
const isLayoutReady = !!layout && layout.stepKey === stepKey;
const activeLayout = isLayoutReady ? layout : null;
const closeAsFinish = useCallback(() => {
actions.finish();
}, [actions]);
const closeAsSkip = useCallback(() => {
actions.skip();
}, [actions]);
const measure = useCallback(() => {
if (!open || !step || typeof document === "undefined") {
setLayout(null);
return;
}
const context = getCoordinateContext({ container, viewport });
const panelElement = panelRef.current;
const panelRect = panelElement?.getBoundingClientRect();
const panelSize = {
width: panelElement
? Math.max(
panelRect?.width ?? 0,
panelElement.scrollWidth * context.scale,
) / context.scale
: panelMaxWidth,
height: panelElement
? Math.max(
panelRect?.height ?? 0,
panelElement.scrollHeight * context.scale,
) / context.scale
: 220,
};
const position = getPanelPosition(
step.placement ?? "bottom",
panelSize,
context,
);
setLayout({
stepKey,
portalTarget: context.portalTarget,
width: context.width,
height: context.height,
panelStyle: {
left: `${position.left}px`,
top: `${position.top}px`,
maxWidth: `${panelMaxWidth}px`,
},
});
}, [container, open, panelMaxWidth, step, stepKey, viewport]);
useLayoutEffect(() => {
measure();
}, [measure]);
useEffect(() => {
if (!open || !step) return;
onStepChange?.(current, step);
}, [current, open, onStepChange, step]);
useEffect(() => {
if (!open) return;
const handleResize = () => measure();
const handleKeyDown = (event: KeyboardEvent) => {
if (closeOnEsc && event.key === "Escape") {
closeAsSkip();
}
};
const resizeObserver =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(() => measure());
window.addEventListener("resize", handleResize);
window.addEventListener("keydown", handleKeyDown);
if (panelRef.current) resizeObserver?.observe(panelRef.current);
return () => {
window.removeEventListener("resize", handleResize);
window.removeEventListener("keydown", handleKeyDown);
resizeObserver?.disconnect();
};
}, [closeAsSkip, closeOnEsc, measure, open]);
if (!open || !step || typeof document === "undefined") return null;
const isFirst = current === 0;
const isLast = current === steps.length - 1;
const options = step.options ?? [];
const hasOptions = options.length > 0;
const primaryAction = isLast ? closeAsFinish : actions.next;
const dialog = {
step,
character: step.character,
options,
progress: {
index: current,
current: current + 1,
total: steps.length,
},
status: {
isFirst,
isLast,
hasOptions,
},
actions: {
prev: actions.prev,
next: actions.next,
primary: primaryAction,
skip: closeAsSkip,
finish: closeAsFinish,
selectOption: actions.selectOption,
},
labels: {
prev: step.prevLabel ?? "上一步",
next: step.nextLabel ?? "继续",
skip: step.skipLabel ?? "跳过",
done: step.doneLabel ?? "完成",
primary: isLast ? (step.doneLabel ?? "完成") : (step.nextLabel ?? "继续"),
},
};
const panelContent = children ? (
children(dialog)
) : (
<DefaultGuideDialogPanel dialog={dialog} />
);
const panelClassName = children ? styles.panelContainer : styles.panel;
if (!activeLayout) {
return (
<Portal
container={getCoordinateContext({ container, viewport }).portalTarget}
>
<div
ref={panelRef}
aria-hidden="true"
className={cn(
panelClassName,
styles.measurePanel,
theme?.panelClassName,
)}
style={{
maxWidth: `${panelMaxWidth}px`,
}}
>
{panelContent}
</div>
</Portal>
);
}
const overlayClick = (event: MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
if (closeOnOverlayClick) {
closeAsSkip();
}
};
return (
<Portal container={activeLayout.portalTarget}>
<div
aria-hidden="true"
className={cn(styles.mask, theme?.overlayClassName)}
onClick={overlayClick}
style={{
zIndex,
background: theme?.overlayColor ?? "rgb(15 23 42 / 0.58)",
}}
/>
<div
key={`dialog-${stepKey}`}
ref={panelRef}
aria-modal="true"
className={cn(panelClassName, theme?.panelClassName)}
role="dialog"
style={{
zIndex: zIndex + 1,
...activeLayout.panelStyle,
}}
>
{panelContent}
</div>
</Portal>
);
}
@@ -0,0 +1,108 @@
import type { CSSProperties, ReactNode } from "react";
export type GuideDialogPlacement = "top" | "center" | "bottom";
export interface GuideDialogCharacter {
name?: ReactNode;
avatar?: ReactNode;
mood?: string;
}
export interface GuideDialogOption {
id: string;
label: ReactNode;
nextId?: string;
finish?: boolean;
data?: unknown;
}
export interface GuideDialogStep {
id: string;
character?: GuideDialogCharacter;
title?: ReactNode;
description?: ReactNode;
placement?: GuideDialogPlacement;
nextLabel?: string;
prevLabel?: string;
skipLabel?: string;
doneLabel?: string;
options?: GuideDialogOption[];
data?: unknown;
}
export interface GuideDialogTheme {
overlayClassName?: string;
panelClassName?: string;
zIndex?: number;
panelMaxWidth?: number;
overlayColor?: string;
}
export interface GuideDialogState {
open: boolean;
steps: GuideDialogStep[];
current: number;
}
export interface GuideDialogActions {
next: () => void;
prev: () => void;
skip: () => void;
finish: () => void;
selectOption: (optionId: string) => void;
}
export interface GuideDialogRenderState {
step: GuideDialogStep;
character?: GuideDialogCharacter;
options: GuideDialogOption[];
progress: {
index: number;
current: number;
total: number;
};
status: {
isFirst: boolean;
isLast: boolean;
hasOptions: boolean;
};
actions: {
prev: () => void;
next: () => void;
primary: () => void;
skip: () => void;
finish: () => void;
selectOption: (optionId: string) => void;
};
labels: {
prev: string;
next: string;
skip: string;
done: string;
primary: string;
};
}
export interface GuideDialogProps {
state: GuideDialogState;
actions: GuideDialogActions;
children?: (dialog: GuideDialogRenderState) => ReactNode;
container?: HTMLElement | null | (() => HTMLElement | null);
theme?: GuideDialogTheme;
viewport?: {
width: number;
height: number;
scale?: number;
};
closeOnOverlayClick?: boolean;
closeOnEsc?: boolean;
onStepChange?: (index: number, step: GuideDialogStep) => void;
}
export interface GuideDialogLayout {
stepKey: string;
portalTarget: HTMLElement;
width: number;
height: number;
panelStyle: CSSProperties;
}
@@ -0,0 +1,15 @@
export type { DefaultGuideDialogPanelProps } from "./DefaultGuideDialogPanel";
export { DefaultGuideDialogPanel } from "./DefaultGuideDialogPanel";
export { GuideDialog } from "./GuideDialog";
export type {
GuideDialogActions,
GuideDialogCharacter,
GuideDialogLayout,
GuideDialogOption,
GuideDialogPlacement,
GuideDialogProps,
GuideDialogRenderState,
GuideDialogState,
GuideDialogStep,
GuideDialogTheme,
} from "./GuideDialog.types";
@@ -0,0 +1,41 @@
import { Button } from "@/components/ui/button";
import styles from "./GuideSpotlight.module.css";
import type { GuideSpotlightRenderState } from "./GuideSpotlight.types";
export interface DefaultGuideSpotlightPanelProps {
spotlight: GuideSpotlightRenderState;
}
export function DefaultGuideSpotlightPanel({
spotlight,
}: DefaultGuideSpotlightPanelProps) {
const { step, progress, status, actions, labels } = spotlight;
return (
<>
{step.title && <h2 className={styles.title}>{step.title}</h2>}
{step.description && (
<div className={styles.description}>{step.description}</div>
)}
<div className={styles.footer}>
<span className={styles.counter}>
{progress.current} / {progress.total}
</span>
<div className={styles.actions}>
<Button variant="ghost" size="sm" onClick={actions.skip}>
{labels.skip}
</Button>
{!status.isFirst && (
<Button variant="secondary" size="sm" onClick={actions.prev}>
{labels.prev}
</Button>
)}
<Button size="sm" onClick={actions.primary}>
{labels.primary}
</Button>
</div>
</div>
</>
);
}
@@ -0,0 +1,158 @@
@reference "tailwindcss";
.mask {
@apply pointer-events-auto absolute inset-0 block h-full w-full border-0 p-0;
animation: spotlight-fade-in 220ms ease-out both;
will-change: opacity;
}
.highlight {
@apply absolute border-2 border-white shadow-[0_0_0_1px_rgba(255,255,255,0.35),0_0_28px_rgba(255,255,255,0.5),0_0_56px_rgba(59,130,246,0.22)];
pointer-events: none;
animation: spotlight-highlight-in 320ms cubic-bezier(0.16, 1, 0.3, 1) both,
spotlight-pulse 2400ms ease-in-out 360ms infinite;
transition:
top 260ms cubic-bezier(0.16, 1, 0.3, 1),
left 260ms cubic-bezier(0.16, 1, 0.3, 1),
width 260ms cubic-bezier(0.16, 1, 0.3, 1),
height 260ms cubic-bezier(0.16, 1, 0.3, 1),
border-radius 260ms ease;
will-change: top, left, width, height, transform, opacity;
}
.blocker {
@apply pointer-events-auto absolute bg-transparent;
}
.panelContainer {
@apply pointer-events-auto absolute;
animation: spotlight-panel-in 320ms cubic-bezier(0.16, 1, 0.3, 1) both;
transition:
left 260ms cubic-bezier(0.16, 1, 0.3, 1),
top 260ms cubic-bezier(0.16, 1, 0.3, 1),
transform 260ms cubic-bezier(0.16, 1, 0.3, 1),
opacity 180ms ease;
will-change: left, top, transform, opacity;
}
.panel {
@apply pointer-events-auto absolute w-80 rounded-xl bg-white p-5 text-gray-900 shadow-2xl;
animation: spotlight-panel-in 320ms cubic-bezier(0.16, 1, 0.3, 1) both;
transition:
left 260ms cubic-bezier(0.16, 1, 0.3, 1),
top 260ms cubic-bezier(0.16, 1, 0.3, 1),
transform 260ms cubic-bezier(0.16, 1, 0.3, 1),
opacity 180ms ease;
will-change: left, top, transform, opacity;
}
.measurePanel {
left: -9999px;
top: -9999px;
visibility: hidden;
animation: none;
pointer-events: none;
}
.missingPanel {
@apply pointer-events-auto absolute left-1/2 top-1/2 w-96 max-w-[calc(100%-32px)] -translate-x-1/2 -translate-y-1/2 rounded-xl bg-white p-5 text-gray-900 shadow-2xl;
animation: spotlight-missing-panel-in 320ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.missingBadge {
@apply mb-3 inline-flex rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-700;
}
.missingSelector {
@apply mt-3 block rounded-lg bg-gray-100 px-3 py-2 text-sm text-gray-700;
word-break: break-all;
}
.title {
@apply text-lg font-semibold;
}
.description {
@apply mt-2 text-sm leading-6 text-gray-600;
}
.footer {
@apply mt-5 flex items-center justify-between gap-3;
}
.counter {
@apply text-xs text-gray-400;
}
.actions {
@apply flex items-center gap-2;
}
@keyframes spotlight-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes spotlight-highlight-in {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes spotlight-pulse {
0%,
100% {
box-shadow:
0 0 0 1px rgb(255 255 255 / 35%),
0 0 28px rgb(255 255 255 / 50%),
0 0 56px rgb(59 130 246 / 22%);
}
50% {
box-shadow:
0 0 0 1px rgb(255 255 255 / 48%),
0 0 36px rgb(255 255 255 / 68%),
0 0 72px rgb(59 130 246 / 32%);
}
}
@keyframes spotlight-panel-in {
from {
opacity: 0;
transform: translate3d(0, 10px, 0) scale(0.98);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
}
@keyframes spotlight-missing-panel-in {
from {
opacity: 0;
transform: translate3d(-50%, calc(-50% + 10px), 0) scale(0.98);
}
to {
opacity: 1;
transform: translate3d(-50%, -50%, 0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.mask,
.highlight,
.panelContainer,
.panel,
.missingPanel {
animation: none;
transition: none;
}
}
@@ -0,0 +1,636 @@
import {
type MouseEvent,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { Button } from "@/components/ui/button";
import { Portal } from "@/components/ui/portal";
import { cn } from "@/lib/cn";
import { DefaultGuideSpotlightPanel } from "./DefaultGuideSpotlightPanel";
import styles from "./GuideSpotlight.module.css";
import type {
GuideSpotlightProps,
Rect,
SpotlightLayout,
SpotlightStep,
} from "./GuideSpotlight.types";
const DEFAULT_OFFSET = 16;
const DEFAULT_PADDING = 8;
const DEFAULT_RADIUS = 12;
const VIEWPORT_MARGIN = 16;
interface CoordinateContext {
portalTarget: HTMLElement;
originLeft: number;
originTop: number;
scale: number;
width: number;
height: number;
}
function resolveContainer(container?: GuideSpotlightProps["container"]) {
if (!container) {
return null;
}
if (typeof container === "function") {
return container();
}
return container;
}
function getCoordinateContext({
container,
viewport,
}: Pick<GuideSpotlightProps, "container" | "viewport">): CoordinateContext {
const portalTarget = resolveContainer(container) ?? document.body;
if (viewport) {
return {
portalTarget,
originLeft: viewport.originLeft ?? 0,
originTop: viewport.originTop ?? 0,
scale: viewport.scale ?? 1,
width: viewport.width,
height: viewport.height,
};
}
const rect = portalTarget.getBoundingClientRect();
return {
portalTarget,
originLeft: rect.left,
originTop: rect.top,
scale: 1,
width: rect.width || window.innerWidth,
height: rect.height || window.innerHeight,
};
}
function toCanvasRect(rect: DOMRect, context: CoordinateContext): Rect {
const top = (rect.top - context.originTop) / context.scale;
const left = (rect.left - context.originLeft) / context.scale;
const width = rect.width / context.scale;
const height = rect.height / context.scale;
return {
top,
left,
width,
height,
right: left + width,
bottom: top + height,
};
}
function getTargetSelector(step: SpotlightStep) {
if (typeof step.target === "string") {
return step.target;
}
if (step.target.dataGuide) {
return `[data-guide="${step.target.dataGuide}"]`;
}
return step.target.selector ?? "";
}
function getTargetElement(step: SpotlightStep) {
const selector = getTargetSelector(step);
if (!selector) return null;
return document.querySelector<HTMLElement>(selector);
}
function getHighlightRect(
element: HTMLElement,
padding: number,
context: CoordinateContext,
): Rect {
const rect = toCanvasRect(element.getBoundingClientRect(), context);
const top = Math.floor(Math.max(rect.top - padding, 0));
const left = Math.floor(Math.max(rect.left - padding, 0));
const right = Math.ceil(Math.min(rect.right + padding, context.width));
const bottom = Math.ceil(Math.min(rect.bottom + padding, context.height));
return {
top,
left,
width: right - left,
height: bottom - top,
right,
bottom,
};
}
function getOverflowScore(
left: number,
top: number,
width: number,
height: number,
context: CoordinateContext,
) {
const overflowLeft = Math.max(VIEWPORT_MARGIN - left, 0);
const overflowTop = Math.max(VIEWPORT_MARGIN - top, 0);
const overflowRight = Math.max(
left + width + VIEWPORT_MARGIN - context.width,
0,
);
const overflowBottom = Math.max(
top + height + VIEWPORT_MARGIN - context.height,
0,
);
return overflowLeft + overflowTop + overflowRight + overflowBottom;
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function getRoundedRectPath(rect: Rect, radius: number) {
const right = rect.left + rect.width;
const bottom = rect.top + rect.height;
const safeRadius = Math.min(radius, rect.width / 2, rect.height / 2);
if (safeRadius <= 0) {
return `M ${rect.left} ${rect.top} H ${right} V ${bottom} H ${rect.left} Z`;
}
return [
`M ${rect.left + safeRadius} ${rect.top}`,
`H ${right - safeRadius}`,
`Q ${right} ${rect.top} ${right} ${rect.top + safeRadius}`,
`V ${bottom - safeRadius}`,
`Q ${right} ${bottom} ${right - safeRadius} ${bottom}`,
`H ${rect.left + safeRadius}`,
`Q ${rect.left} ${bottom} ${rect.left} ${bottom - safeRadius}`,
`V ${rect.top + safeRadius}`,
`Q ${rect.left} ${rect.top} ${rect.left + safeRadius} ${rect.top}`,
"Z",
].join(" ");
}
function getPanelPosition(
target: Rect,
panel: { width: number; height: number },
placement: SpotlightStep["placement"],
offset: number,
context: CoordinateContext,
) {
const positions = {
top: {
left: target.left + target.width / 2 - panel.width / 2,
top: target.top - panel.height - offset,
},
bottom: {
left: target.left + target.width / 2 - panel.width / 2,
top: target.bottom + offset,
},
left: {
left: target.left - panel.width - offset,
top: target.top + target.height / 2 - panel.height / 2,
},
right: {
left: target.right + offset,
top: target.top + target.height / 2 - panel.height / 2,
},
};
const preferred = placement && placement !== "auto" ? placement : "bottom";
const ordered = [
preferred,
...(["bottom", "top", "right", "left"] as const).filter(
(item) => item !== preferred,
),
];
const best = ordered
.map((item) => ({
placement: item,
...positions[item],
score: getOverflowScore(
positions[item].left,
positions[item].top,
panel.width,
panel.height,
context,
),
}))
.sort((a, b) => a.score - b.score)[0];
const maxLeft = Math.max(
context.width - panel.width - VIEWPORT_MARGIN,
VIEWPORT_MARGIN,
);
const maxTop = Math.max(
context.height - panel.height - VIEWPORT_MARGIN,
VIEWPORT_MARGIN,
);
return {
left: clamp(best.left, VIEWPORT_MARGIN, maxLeft),
top: clamp(best.top, VIEWPORT_MARGIN, maxTop),
};
}
export function GuideSpotlight({
state,
actions,
children,
container,
viewport,
theme,
closeOnOverlayClick = false,
closeOnEsc = true,
skipIfTargetMissing = true,
onStepChange,
}: GuideSpotlightProps) {
const { open, steps, current } = state;
const panelRef = useRef<HTMLDivElement>(null);
const [layout, setLayout] = useState<SpotlightLayout | null>(null);
const step = steps[current];
const stepKey = step ? `${current}:${step.id}` : "";
const offset = theme?.offset ?? DEFAULT_OFFSET;
const radius = step?.radius ?? theme?.radius ?? DEFAULT_RADIUS;
const padding = step?.padding ?? DEFAULT_PADDING;
const zIndex = theme?.zIndex ?? 1000;
const panelMaxWidth = theme?.panelMaxWidth ?? 320;
const isLayoutReady = !!layout && layout.stepKey === stepKey;
const highlightRect = isLayoutReady ? layout.highlightRect : null;
const targetMissingSelector = isLayoutReady
? layout.targetMissingSelector
: null;
const closeAsFinish = useCallback(() => {
actions.finish();
}, [actions]);
const closeAsSkip = useCallback(() => {
actions.skip();
}, [actions]);
const measure = useCallback(() => {
if (!open || !step || typeof document === "undefined") {
setLayout(null);
return;
}
const selector = getTargetSelector(step);
const context = getCoordinateContext({ container, viewport });
const target = getTargetElement(step);
if (!target) {
console.warn(`[GuideSpotlight] target not found: ${selector}`);
if (skipIfTargetMissing) {
if (current >= steps.length - 1) {
closeAsFinish();
} else {
actions.next();
}
return;
}
setLayout({
stepKey,
portalTarget: context.portalTarget,
width: context.width,
height: context.height,
highlightRect: null,
panelStyle: {
maxWidth: `${panelMaxWidth}px`,
},
targetMissingSelector: selector,
});
return;
}
const nextHighlightRect = getHighlightRect(target, padding, context);
const panelElement = panelRef.current;
const panelRect = panelElement?.getBoundingClientRect();
const panelSize = {
width: panelElement
? Math.max(
panelRect?.width ?? 0,
panelElement.scrollWidth * context.scale,
) / context.scale
: panelMaxWidth,
height: panelElement
? Math.max(
panelRect?.height ?? 0,
panelElement.scrollHeight * context.scale,
) / context.scale
: 180,
};
const position = getPanelPosition(
nextHighlightRect,
panelSize,
step.placement ?? "auto",
offset,
context,
);
setLayout({
stepKey,
portalTarget: context.portalTarget,
width: context.width,
height: context.height,
highlightRect: nextHighlightRect,
panelStyle: {
left: `${position.left}px`,
top: `${position.top}px`,
maxWidth: `${panelMaxWidth}px`,
},
targetMissingSelector: null,
});
}, [
actions,
closeAsFinish,
container,
current,
offset,
open,
padding,
panelMaxWidth,
skipIfTargetMissing,
step,
stepKey,
steps.length,
viewport,
]);
useLayoutEffect(() => {
measure();
}, [measure]);
useEffect(() => {
if (!open || !step) return;
onStepChange?.(current, step);
}, [current, open, onStepChange, step]);
useEffect(() => {
if (!open) return;
const scrollKeys = new Set([
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"End",
"Home",
"PageDown",
"PageUp",
" ",
]);
const previousOverflow = document.body.style.overflow;
const previousOverscrollBehavior = document.body.style.overscrollBehavior;
const preventScroll = (event: Event) => event.preventDefault();
const preventKeyboardScroll = (event: KeyboardEvent) => {
if (scrollKeys.has(event.key)) {
event.preventDefault();
}
};
document.body.style.overflow = "hidden";
document.body.style.overscrollBehavior = "none";
window.addEventListener("wheel", preventScroll, {
capture: true,
passive: false,
});
window.addEventListener("touchmove", preventScroll, {
capture: true,
passive: false,
});
window.addEventListener("keydown", preventKeyboardScroll, true);
return () => {
document.body.style.overflow = previousOverflow;
document.body.style.overscrollBehavior = previousOverscrollBehavior;
window.removeEventListener("wheel", preventScroll, true);
window.removeEventListener("touchmove", preventScroll, true);
window.removeEventListener("keydown", preventKeyboardScroll, true);
};
}, [open]);
useEffect(() => {
if (!open) return;
const handleResize = () => measure();
const handleScroll = () => measure();
const handleKeyDown = (event: KeyboardEvent) => {
if (closeOnEsc && event.key === "Escape") {
closeAsSkip();
}
};
window.addEventListener("resize", handleResize);
window.addEventListener("scroll", handleScroll, true);
window.addEventListener("keydown", handleKeyDown);
const target = step ? getTargetElement(step) : null;
const resizeObserver =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(() => measure());
if (target) resizeObserver?.observe(target);
if (panelRef.current) resizeObserver?.observe(panelRef.current);
return () => {
window.removeEventListener("resize", handleResize);
window.removeEventListener("scroll", handleScroll, true);
window.removeEventListener("keydown", handleKeyDown);
resizeObserver?.disconnect();
};
}, [closeAsSkip, closeOnEsc, measure, open, step]);
if (!open || !step || typeof document === "undefined") return null;
const maskStyle = {
zIndex,
};
const isFirst = current === 0;
const isLast = current === steps.length - 1;
const overlayClick = (event: MouseEvent<SVGPathElement>) => {
event.stopPropagation();
if (closeOnOverlayClick) {
closeAsSkip();
}
};
const primaryAction = isLast ? closeAsFinish : actions.next;
const spotlight = {
step,
progress: {
index: current,
current: current + 1,
total: steps.length,
},
status: {
isFirst,
isLast,
},
actions: {
prev: actions.prev,
next: actions.next,
primary: primaryAction,
skip: closeAsSkip,
finish: closeAsFinish,
},
labels: {
prev: step.prevLabel ?? "上一步",
next: step.nextLabel ?? "下一步",
skip: step.skipLabel ?? "跳过",
done: step.doneLabel ?? "完成",
primary: isLast
? (step.doneLabel ?? "完成")
: (step.nextLabel ?? "下一步"),
},
highlight: {
rect: highlightRect,
targetMissingSelector,
},
};
const panelContent = children ? (
children(spotlight)
) : (
<DefaultGuideSpotlightPanel spotlight={spotlight} />
);
const panelClassName = children ? styles.panelContainer : styles.panel;
if (!highlightRect && !targetMissingSelector) {
return (
<Portal
container={getCoordinateContext({ container, viewport }).portalTarget}
>
<div
ref={panelRef}
aria-hidden="true"
className={cn(
panelClassName,
styles.measurePanel,
theme?.panelClassName,
step.panelClassName,
)}
style={{
maxWidth: `${panelMaxWidth}px`,
}}
>
{panelContent}
</div>
</Portal>
);
}
const activeLayout = layout;
if (!activeLayout) return null;
if (targetMissingSelector) {
return (
<Portal container={activeLayout.portalTarget}>
<div
aria-modal="true"
className={styles.missingPanel}
role="dialog"
style={{ zIndex: zIndex + 3 }}
>
<div className={styles.missingBadge}>DEV</div>
<h2 className={styles.title}>引导目标未找到</h2>
<p className={styles.description}>
请检查当前步骤的 data-guide 或 selector 是否和页面元素一致。
</p>
<code className={styles.missingSelector}>
{targetMissingSelector}
</code>
<div className={styles.footer}>
<span className={styles.counter}>
{current + 1} / {steps.length}
</span>
<div className={styles.actions}>
<Button variant="ghost" size="sm" onClick={closeAsSkip}>
跳过
</Button>
<Button size="sm" onClick={primaryAction}>
{isLast ? "完成" : "下一步"}
</Button>
</div>
</div>
</div>
</Portal>
);
}
if (!highlightRect) return null;
const activeRect = highlightRect;
return (
<Portal container={activeLayout.portalTarget}>
<svg
aria-hidden="true"
className={cn(styles.mask, theme?.overlayClassName)}
style={maskStyle}
viewBox={`0 0 ${activeLayout.width} ${activeLayout.height}`}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: click area must follow the SVG cutout path so the target hole stays interactive. */}
<path
d={`M 0 0 H ${activeLayout.width} V ${activeLayout.height} H 0 Z ${getRoundedRectPath(activeRect, radius)}`}
fill={theme?.overlayColor ?? "rgb(0 0 0 / 0.6)"}
fillRule="evenodd"
onClick={overlayClick}
/>
</svg>
{!step.allowTargetClick && (
<div
className={styles.blocker}
style={{
zIndex: zIndex + 1,
top: `${activeRect.top}px`,
left: `${activeRect.left}px`,
width: `${activeRect.width}px`,
height: `${activeRect.height}px`,
}}
/>
)}
<div
key={`highlight-${stepKey}`}
className={cn(styles.highlight, theme?.highlightClassName)}
style={{
zIndex: zIndex + 2,
top: `${activeRect.top}px`,
left: `${activeRect.left}px`,
width: `${activeRect.width}px`,
height: `${activeRect.height}px`,
borderRadius: `${radius}px`,
}}
/>
<div
key={`panel-${stepKey}`}
ref={panelRef}
aria-modal="true"
className={cn(
panelClassName,
theme?.panelClassName,
step.panelClassName,
)}
role="dialog"
style={{
zIndex: zIndex + 3,
...activeLayout.panelStyle,
}}
>
{panelContent}
</div>
</Portal>
);
}
@@ -0,0 +1,118 @@
import type { CSSProperties, ReactNode } from "react";
export type SpotlightPlacement = "top" | "bottom" | "left" | "right" | "auto";
export interface SpotlightTarget {
selector?: string;
dataGuide?: string;
}
export interface SpotlightStep {
id: string;
target: string | SpotlightTarget;
title?: ReactNode;
description?: ReactNode;
placement?: SpotlightPlacement;
allowTargetClick?: boolean;
padding?: number;
radius?: number;
panelClassName?: string;
nextLabel?: string;
prevLabel?: string;
doneLabel?: string;
skipLabel?: string;
data?: unknown;
}
export interface Rect {
top: number;
left: number;
width: number;
height: number;
right: number;
bottom: number;
}
export interface SpotlightLayout {
stepKey: string;
portalTarget: HTMLElement;
width: number;
height: number;
highlightRect: Rect | null;
panelStyle: CSSProperties;
targetMissingSelector: string | null;
}
export interface GuideSpotlightTheme {
overlayClassName?: string;
panelClassName?: string;
highlightClassName?: string;
zIndex?: number;
panelMaxWidth?: number;
offset?: number;
radius?: number;
overlayColor?: string;
}
export interface GuideSpotlightState {
open: boolean;
steps: SpotlightStep[];
current: number;
}
export interface GuideSpotlightActions {
next: () => void;
prev: () => void;
skip: () => void;
finish: () => void;
}
export interface GuideSpotlightRenderState {
step: SpotlightStep;
progress: {
index: number;
current: number;
total: number;
};
status: {
isFirst: boolean;
isLast: boolean;
};
actions: {
prev: () => void;
next: () => void;
primary: () => void;
skip: () => void;
finish: () => void;
};
labels: {
prev: string;
next: string;
skip: string;
done: string;
primary: string;
};
highlight: {
rect: Rect | null;
targetMissingSelector: string | null;
};
}
export interface GuideSpotlightProps {
state: GuideSpotlightState;
actions: GuideSpotlightActions;
children?: (spotlight: GuideSpotlightRenderState) => ReactNode;
container?: HTMLElement | null | (() => HTMLElement | null);
viewport?: {
width: number;
height: number;
scale?: number;
originLeft?: number;
originTop?: number;
};
theme?: GuideSpotlightTheme;
closeOnOverlayClick?: boolean;
closeOnEsc?: boolean;
skipIfTargetMissing?: boolean;
onStepChange?: (index: number, step: SpotlightStep) => void;
}
@@ -0,0 +1,15 @@
export type { DefaultGuideSpotlightPanelProps } from "./DefaultGuideSpotlightPanel";
export { DefaultGuideSpotlightPanel } from "./DefaultGuideSpotlightPanel";
export { GuideSpotlight } from "./GuideSpotlight";
export type {
GuideSpotlightActions,
GuideSpotlightProps,
GuideSpotlightRenderState,
GuideSpotlightState,
GuideSpotlightTheme,
Rect,
SpotlightLayout,
SpotlightPlacement,
SpotlightStep,
SpotlightTarget,
} from "./GuideSpotlight.types";
@@ -0,0 +1,37 @@
import type { OrientationGuardProps } from "./OrientationGuard.types";
import { useOrientation } from "./useOrientation";
function DefaultLandscapePrompt() {
return (
<div className="fixed inset-0 z-2000 flex items-center justify-center bg-gray-950/80 px-8 text-white">
<div className="text-center">
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center">
<svg
aria-hidden="true"
className="h-full w-full text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<rect x="2" y="5" width="20" height="14" rx="2" />
<circle cx="12" cy="12" r="1" fill="currentColor" />
</svg>
</div>
<h1 className="text-2xl font-semibold">请旋转屏幕</h1>
<p className="mt-3 text-base text-gray-400">
为获得最佳体验,请横屏浏览
</p>
</div>
</div>
);
}
export function OrientationGuard({ renderPrompt }: OrientationGuardProps) {
const isPortrait = useOrientation();
if (!isPortrait) return null;
if (renderPrompt) return <>{renderPrompt()}</>;
return <DefaultLandscapePrompt />;
}
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff. Show more