generated from zhongyidian/yx-react-template
Initial commit
This commit is contained in:
commit
6b1e803320
158 files changed
+16461
No files matched your search
@@ -0,0 +1,4 @@
|
||||
**/*.css
|
||||
|
||||
**/*.module.css
|
||||
|
||||
@@ -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
@@ -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
@@ -0,0 +1,16 @@
|
||||
# <类型>: <简短描述>
|
||||
|
||||
# <详细描述(可选)>
|
||||
|
||||
# <类型> 可以是以下之一:
|
||||
# feat: 新功能
|
||||
# fix: 修复 bug
|
||||
# docs: 文档变更
|
||||
# style: 代码格式(不影响代码运行的变动)
|
||||
# refactor: 重构(既不是新增功能,也不是修复 bug)
|
||||
# perf: 性能优化
|
||||
# test: 增加测试
|
||||
# chore: 构建过程或辅助工具的变动
|
||||
# ci: 持续集成相关
|
||||
# build: 构建系统或外部依赖的变更
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
bunx lint-staged
|
||||
+27
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
# 锁定包管理器为 bun
|
||||
package-manager=bun
|
||||
|
||||
# 严格模式:不允许使用其他包管理器
|
||||
engine-strict=true
|
||||
|
||||
# 自动安装 peers
|
||||
auto-install-peers=true
|
||||
|
||||
# 使用国内镜像(可选)
|
||||
registry=https://registry.npmmirror.com
|
||||
@@ -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 结果;如果校验失败,必须如实报告失败命令、错误摘要和影响范围。
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
Vendored
+4902
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
Vendored
+1
@@ -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
|
||||
@@ -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
@@ -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;
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
message?: string;
|
||||
code?: number;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
code?: number;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { del, get, post, put, request } from "./client.ts";
|
||||
export type { ApiError, ApiResponse } from "./client.types";
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "primary" | "secondary" | "outline" | "ghost";
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Overlay } from "./Overlay";
|
||||
export type { OverlayProps } from "./Overlay.types";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Portal } from "./Portal";
|
||||
export type { PortalContainer, PortalProps } from "./Portal.types";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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
Reference in new issue
Block a user