Compare commits
13 Commits
467232457a
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| d8f0117359 | |||
| 2c84ab4370 | |||
| e17437a5ad | |||
| ff0954a413 | |||
| 573b1cb7e5 | |||
| 605c57f8bb | |||
| b69e168558 | |||
| 65aacc1582 | |||
| 572534a009 | |||
| 0d251a7e68 | |||
| e845c4abb7 | |||
| 881d9ca921 | |||
| db96b86e65 |
@@ -3,10 +3,10 @@ ZHIPU_API_KEY=
|
||||
ZHIPU_MODEL_NAME=
|
||||
|
||||
// Auth
|
||||
AUTH_SECRET=
|
||||
BETTER_AUTH_SECRET=
|
||||
BETTER_AUTH_URL=
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
NEXTAUTH_URL=
|
||||
|
||||
// Database
|
||||
DATABASE_URL=
|
||||
|
||||
10
.vscode/settings.json
vendored
Normal file
10
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"editor.defaultFormatter": null,
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||
}
|
||||
}
|
||||
170
README.md
170
README.md
@@ -1,36 +1,162 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# 多语言学习平台
|
||||
|
||||
## Getting Started
|
||||
一个基于 Next.js 构建的全功能多语言学习平台,提供翻译、发音、字幕播放、字母学习等多种语言学习工具,帮助用户更高效地掌握新语言。
|
||||
|
||||
First, run the development server:
|
||||
## ✨ 主要功能
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
- **智能翻译工具** - 支持多语言互译,包含国际音标(IPA)标注
|
||||
- **文本语音合成** - 将文本转换为自然语音,提高发音学习效果
|
||||
- **SRT字幕播放器** - 结合视频字幕学习,支持多种字幕格式
|
||||
- **字母学习模块** - 针对初学者的字母和发音基础学习
|
||||
- **记忆强化工具** - 通过科学记忆法巩固学习内容
|
||||
- **个人学习空间** - 用户可以创建、管理和组织自己的学习资料
|
||||
|
||||
## 🛠 技术栈
|
||||
|
||||
### 前端框架
|
||||
- **Next.js 16** - React 全栈框架,使用 App Router
|
||||
- **React 19** - 用户界面构建
|
||||
- **TypeScript** - 类型安全的 JavaScript
|
||||
- **Tailwind CSS** - 实用优先的 CSS 框架
|
||||
|
||||
### 数据与后端
|
||||
- **PostgreSQL** - 主数据库
|
||||
- **Prisma** - 现代数据库工具包和 ORM
|
||||
- **better-auth** - 安全的身份验证系统
|
||||
|
||||
### 国际化与辅助功能
|
||||
- **next-intl** - 国际化解决方案
|
||||
- **edge-tts-universal** - 跨平台文本转语音
|
||||
|
||||
### 开发工具
|
||||
- **ESLint** - 代码质量检查
|
||||
- **pnpm** - 高效的包管理器
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js App Router 路由
|
||||
│ ├── (features)/ # 功能模块路由
|
||||
│ ├── api/ # API 路由
|
||||
│ └── auth/ # 认证相关页面
|
||||
├── components/ # React 组件
|
||||
│ ├── buttons/ # 按钮组件
|
||||
│ ├── cards/ # 卡片组件
|
||||
│ └── ...
|
||||
├── lib/ # 工具函数和库
|
||||
│ ├── actions/ # Server Actions
|
||||
│ ├── browser/ # 浏览器端工具
|
||||
│ └── server/ # 服务器端工具
|
||||
├── hooks/ # 自定义 React Hooks
|
||||
├── i18n/ # 国际化配置
|
||||
└── config/ # 应用配置
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
## 🚀 快速开始
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
### 环境要求
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
- Node.js 24
|
||||
- PostgreSQL 数据库
|
||||
- pnpm (推荐) 或 npm
|
||||
|
||||
## Learn More
|
||||
### 本地开发
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
1. 克隆项目
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd learn-languages
|
||||
```
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
2. 安装依赖
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
3. 设置环境变量
|
||||
|
||||
## Deploy on Vercel
|
||||
从项目提供的示例文件复制环境变量模板:
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
然后编辑 `.env.local` 文件,配置所有必要的环境变量:
|
||||
|
||||
```env
|
||||
// LLM
|
||||
ZHIPU_API_KEY=your-zhipu-api-key
|
||||
ZHIPU_MODEL_NAME=your-zhipu-model-name
|
||||
|
||||
// Auth
|
||||
BETTER_AUTH_SECRET=your-better-auth-secret
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
GITHUB_CLIENT_ID=your-github-client-id
|
||||
GITHUB_CLIENT_SECRET=your-github-client-secret
|
||||
|
||||
// Database
|
||||
DATABASE_URL=postgresql://username:password@localhost:5432/database_name
|
||||
```
|
||||
|
||||
注意:所有带 `your-` 前缀的值需要替换为你的实际配置。
|
||||
|
||||
4. 初始化数据库
|
||||
```bash
|
||||
pnpm prisma generate
|
||||
pnpm prisma db push
|
||||
```
|
||||
|
||||
5. 启动开发服务器
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
访问 [http://localhost:3000](http://localhost:3000) 查看应用。
|
||||
|
||||
## 📚 API 文档
|
||||
|
||||
### 认证系统
|
||||
|
||||
应用使用 better-auth 提供安全的用户认证系统,支持邮箱/密码登录和第三方登录。
|
||||
|
||||
### 数据模型
|
||||
|
||||
核心数据模型包括:
|
||||
- **User** - 用户信息
|
||||
- **Folder** - 学习资料文件夹
|
||||
- **Pair** - 语言对(翻译对、词汇对等)
|
||||
|
||||
详细模型定义请参考 [prisma/schema.prisma](./prisma/schema.prisma)
|
||||
|
||||
## 🌍 国际化
|
||||
|
||||
应用支持多语言,当前语言文件位于 `messages/` 目录。添加新语言:
|
||||
|
||||
1. 在 `messages/` 目录创建对应语言的 JSON 文件
|
||||
2. 在 `src/i18n/config.ts` 中添加语言配置
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
我们欢迎各种形式的贡献!请遵循以下步骤:
|
||||
|
||||
1. Fork 项目
|
||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 打开 Pull Request
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 AGPL-3.0 许可证 - 查看 [LICENSE](./LICENSE) 文件了解详情。
|
||||
|
||||
## 📞 支持
|
||||
|
||||
如果您遇到问题或有建议,请通过以下方式联系:
|
||||
|
||||
- 提交 [Issue](../../issues)
|
||||
- 发送邮件至 [goddonebianu@outlook.com]
|
||||
|
||||
---
|
||||
|
||||
**Happy Learning!** 🌟
|
||||
|
||||
@@ -1,25 +1,16 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
import nextVitals from 'eslint-config-next/core-web-vitals'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
'.next/**',
|
||||
'out/**',
|
||||
'build/**',
|
||||
'next-env.d.ts',
|
||||
]),
|
||||
])
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/**",
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
export default eslintConfig
|
||||
@@ -10,7 +10,11 @@
|
||||
"hideLetter": "Hide Letter",
|
||||
"showLetter": "Show Letter",
|
||||
"hideIPA": "Hide IPA",
|
||||
"showIPA": "Show IPA"
|
||||
"showIPA": "Show IPA",
|
||||
"roman": "Romanization",
|
||||
"letter": "Letter",
|
||||
"random": "Random Mode",
|
||||
"randomNext": "Random Next"
|
||||
},
|
||||
"folders": {
|
||||
"title": "Folders",
|
||||
@@ -82,6 +86,31 @@
|
||||
"loading": "Loading...",
|
||||
"githubLogin": "GitHub Login"
|
||||
},
|
||||
"auth": {
|
||||
"title": "Authentication",
|
||||
"signIn": "Sign In",
|
||||
"signUp": "Sign Up",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"name": "Name",
|
||||
"signInButton": "Sign In",
|
||||
"signUpButton": "Sign Up",
|
||||
"noAccount": "Don't have an account?",
|
||||
"hasAccount": "Already have an account?",
|
||||
"signInWithGitHub": "Sign In with GitHub",
|
||||
"signUpWithGitHub": "Sign Up with GitHub",
|
||||
"invalidEmail": "Please enter a valid email address",
|
||||
"passwordTooShort": "Password must be at least 8 characters",
|
||||
"passwordsNotMatch": "Passwords do not match",
|
||||
"signInFailed": "Sign in failed, please check your email and password",
|
||||
"signUpFailed": "Sign up failed, please try again later",
|
||||
"nameRequired": "Please enter your name",
|
||||
"emailRequired": "Please enter your email",
|
||||
"passwordRequired": "Please enter your password",
|
||||
"confirmPasswordRequired": "Please confirm your password",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
"selectFolder": "Select a folder",
|
||||
@@ -103,9 +132,8 @@
|
||||
},
|
||||
"navbar": {
|
||||
"title": "learn-languages",
|
||||
"about": "About",
|
||||
"sourceCode": "GitHub",
|
||||
"login": "Login",
|
||||
"sign_in": "Sign In",
|
||||
"profile": "Profile",
|
||||
"folders": "Folders"
|
||||
},
|
||||
@@ -122,7 +150,43 @@
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"restart": "Restart",
|
||||
"autoPause": "Auto Pause ({enabled})"
|
||||
"autoPause": "Auto Pause ({enabled})",
|
||||
"playbackSpeed": "Playback Speed",
|
||||
"subtitleSettings": "Subtitle Settings",
|
||||
"fontSize": "Font Size",
|
||||
"backgroundColor": "Background Color",
|
||||
"textColor": "Text Color",
|
||||
"fontFamily": "Font Family",
|
||||
"opacity": "Opacity",
|
||||
"position": "Position",
|
||||
"top": "Top",
|
||||
"center": "Center",
|
||||
"bottom": "Bottom",
|
||||
"keyboardShortcuts": "Keyboard Shortcuts",
|
||||
"uploadVideoAndSubtitle": "Please upload video and subtitle files",
|
||||
"uploadVideoFile": "Please upload video file",
|
||||
"uploadSubtitleFile": "Please upload subtitle file",
|
||||
"processingSubtitle": "Processing subtitle file...",
|
||||
"needBothFiles": "Both video and subtitle files are required to start learning",
|
||||
"videoFile": "Video File",
|
||||
"subtitleFile": "Subtitle File",
|
||||
"uploaded": "Uploaded",
|
||||
"notUploaded": "Not Uploaded",
|
||||
"upload": "Upload",
|
||||
"autoPauseStatus": "Auto Pause: {enabled}",
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"videoUploadFailed": "Video upload failed",
|
||||
"subtitleUploadFailed": "Subtitle upload failed",
|
||||
"subtitleLoadSuccess": "Subtitle file loaded successfully",
|
||||
"subtitleLoadFailed": "Subtitle file loading failed",
|
||||
"shortcuts": {
|
||||
"playPause": "Play/Pause",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"restart": "Restart",
|
||||
"autoPause": "Toggle Auto Pause"
|
||||
}
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "Generate IPA",
|
||||
|
||||
@@ -10,7 +10,11 @@
|
||||
"hideLetter": "隐藏字母",
|
||||
"showLetter": "显示字母",
|
||||
"hideIPA": "隐藏IPA",
|
||||
"showIPA": "显示IPA"
|
||||
"showIPA": "显示IPA",
|
||||
"roman": "罗马音",
|
||||
"letter": "字母",
|
||||
"random": "随机模式",
|
||||
"randomNext": "随机下一个"
|
||||
},
|
||||
"folders": {
|
||||
"title": "文件夹",
|
||||
@@ -82,6 +86,30 @@
|
||||
"loading": "加载中...",
|
||||
"githubLogin": "GitHub登录"
|
||||
},
|
||||
"auth": {
|
||||
"title": "登录",
|
||||
"signIn": "登录",
|
||||
"signUp": "注册",
|
||||
"email": "邮箱",
|
||||
"password": "密码",
|
||||
"confirmPassword": "确认密码",
|
||||
"name": "用户名",
|
||||
"signInButton": "登录",
|
||||
"signUpButton": "注册",
|
||||
"noAccount": "还没有账户?",
|
||||
"hasAccount": "已有账户?",
|
||||
"signInWithGitHub": "使用GitHub登录",
|
||||
"signUpWithGitHub": "使用GitHub注册",
|
||||
"invalidEmail": "请输入有效的邮箱地址",
|
||||
"passwordTooShort": "密码至少需要8个字符",
|
||||
"passwordsNotMatch": "两次输入的密码不匹配",
|
||||
"signInFailed": "登录失败,请检查您的邮箱和密码",
|
||||
"signUpFailed": "注册失败,请稍后再试",
|
||||
"nameRequired": "请输入用户名",
|
||||
"emailRequired": "请输入邮箱",
|
||||
"passwordRequired": "请输入密码",
|
||||
"confirmPasswordRequired": "请确认密码"
|
||||
},
|
||||
"memorize": {
|
||||
"choose": {
|
||||
"back": "返回",
|
||||
@@ -107,9 +135,8 @@
|
||||
},
|
||||
"navbar": {
|
||||
"title": "学语言",
|
||||
"about": "关于",
|
||||
"sourceCode": "源码",
|
||||
"login": "登录",
|
||||
"sign_in": "登录",
|
||||
"profile": "个人资料",
|
||||
"folders": "文件夹"
|
||||
},
|
||||
@@ -119,6 +146,7 @@
|
||||
"logout": "退出登录"
|
||||
},
|
||||
"srt_player": {
|
||||
"upload": "上传",
|
||||
"uploadVideo": "上传视频",
|
||||
"uploadSubtitle": "上传字幕",
|
||||
"pause": "暂停",
|
||||
@@ -126,7 +154,42 @@
|
||||
"previous": "上句",
|
||||
"next": "下句",
|
||||
"restart": "句首",
|
||||
"autoPause": "自动暂停({enabled})"
|
||||
"autoPause": "自动暂停({enabled})",
|
||||
"playbackSpeed": "播放速度",
|
||||
"subtitleSettings": "字幕设置",
|
||||
"fontSize": "字体大小",
|
||||
"backgroundColor": "背景颜色",
|
||||
"textColor": "文字颜色",
|
||||
"fontFamily": "字体",
|
||||
"opacity": "透明度",
|
||||
"position": "位置",
|
||||
"top": "顶部",
|
||||
"center": "居中",
|
||||
"bottom": "底部",
|
||||
"keyboardShortcuts": "键盘快捷键",
|
||||
"uploadVideoAndSubtitle": "请上传视频和字幕文件",
|
||||
"uploadVideoFile": "请上传视频文件",
|
||||
"uploadSubtitleFile": "请上传字幕文件",
|
||||
"processingSubtitle": "字幕文件正在处理中...",
|
||||
"needBothFiles": "需要同时上传视频和字幕文件才能开始学习",
|
||||
"videoFile": "视频文件",
|
||||
"subtitleFile": "字幕文件",
|
||||
"uploaded": "已上传",
|
||||
"notUploaded": "未上传",
|
||||
"autoPauseStatus": "自动暂停: {enabled}",
|
||||
"on": "开",
|
||||
"off": "关",
|
||||
"videoUploadFailed": "视频上传失败",
|
||||
"subtitleUploadFailed": "字幕上传失败",
|
||||
"subtitleLoadSuccess": "字幕文件加载成功",
|
||||
"subtitleLoadFailed": "字幕文件加载失败",
|
||||
"shortcuts": {
|
||||
"playPause": "播放/暂停",
|
||||
"next": "下一句",
|
||||
"previous": "上一句",
|
||||
"restart": "句首",
|
||||
"autoPause": "切换自动暂停"
|
||||
}
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "生成IPA",
|
||||
|
||||
33
package.json
33
package.json
@@ -14,36 +14,43 @@
|
||||
"@prisma/adapter-pg": "^7.1.0",
|
||||
"@prisma/client": "^7.1.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-auth": "^1.4.6",
|
||||
"dotenv": "^17.2.3",
|
||||
"edge-tts-universal": "^1.3.3",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "16.0.7",
|
||||
"next-auth": "5.0.0-beta.30",
|
||||
"lucide-react": "^0.561.0",
|
||||
"next": "16.0.10",
|
||||
"next-intl": "^4.5.8",
|
||||
"pg": "^8.16.3",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"sonner": "^2.0.7",
|
||||
"unstorage": "^1.17.3",
|
||||
"zod": "^3.25.76"
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@better-auth/cli": "^1.4.6",
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^20.19.25",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^25.0.1",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.49.0",
|
||||
"@typescript-eslint/parser": "^8.49.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "16.0.7",
|
||||
"prisma": "^6.19.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"eslint-config-next": "16.0.10",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"prisma": "^7.1.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3"
|
||||
}
|
||||
},
|
||||
"ignoredBuiltDependencies": [
|
||||
"@prisma/client"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
2022
pnpm-lock.yaml
generated
2022
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,6 @@ export default defineConfig({
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
engine: "classic",
|
||||
datasource: {
|
||||
url: env("DATABASE_URL"),
|
||||
},
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `folder` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `text_pair` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "text_pair" DROP CONSTRAINT "fk_text_pairs_folder";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "folder";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "text_pair";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "pairs" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"locale1" VARCHAR(10) NOT NULL,
|
||||
"locale2" VARCHAR(10) NOT NULL,
|
||||
"text1" TEXT NOT NULL,
|
||||
"text2" TEXT NOT NULL,
|
||||
"ipa1" TEXT,
|
||||
"ipa2" TEXT,
|
||||
"folder_id" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "pairs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "folders" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"user_id" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "folders_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "pairs_folder_id_idx" ON "pairs"("folder_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "pairs_folder_id_locale1_locale2_text1_key" ON "pairs"("folder_id", "locale1", "locale2", "text1");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "folders_user_id_idx" ON "folders"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pairs" ADD CONSTRAINT "pairs_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "folders" ADD CONSTRAINT "folders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
120
prisma/migrations/20251210105812_init/migration.sql
Normal file
120
prisma/migrations/20251210105812_init/migration.sql
Normal file
@@ -0,0 +1,120 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "pairs" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"locale1" VARCHAR(10) NOT NULL,
|
||||
"locale2" VARCHAR(10) NOT NULL,
|
||||
"text1" TEXT NOT NULL,
|
||||
"text2" TEXT NOT NULL,
|
||||
"ipa1" TEXT,
|
||||
"ipa2" TEXT,
|
||||
"folder_id" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "pairs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "folders" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "folders_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||
"image" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "session" (
|
||||
"id" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"ipAddress" TEXT,
|
||||
"userAgent" TEXT,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "account" (
|
||||
"id" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"providerId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"accessToken" TEXT,
|
||||
"refreshToken" TEXT,
|
||||
"idToken" TEXT,
|
||||
"accessTokenExpiresAt" TIMESTAMP(3),
|
||||
"refreshTokenExpiresAt" TIMESTAMP(3),
|
||||
"scope" TEXT,
|
||||
"password" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "verification" (
|
||||
"id" TEXT NOT NULL,
|
||||
"identifier" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "pairs_folder_id_idx" ON "pairs"("folder_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "pairs_folder_id_locale1_locale2_text1_key" ON "pairs"("folder_id", "locale1", "locale2", "text1");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "folders_user_id_idx" ON "folders"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "session_userId_idx" ON "session"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "account_userId_idx" ON "account"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "verification_identifier_idx" ON "verification"("identifier");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pairs" ADD CONSTRAINT "pairs_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "folders" ADD CONSTRAINT "folders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../generated/prisma"
|
||||
@@ -29,7 +30,7 @@ model Pair {
|
||||
model Folder {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
userId Int @map("user_id")
|
||||
userId String @map("user_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -41,13 +42,65 @@ model Folder {
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
id String @id
|
||||
name String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
email String
|
||||
emailVerified Boolean @default(false)
|
||||
image String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
folders Folder[]
|
||||
|
||||
@@map("users")
|
||||
@@unique([email])
|
||||
@@map("user")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id
|
||||
expiresAt DateTime
|
||||
token String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([token])
|
||||
@@index([userId])
|
||||
@@map("session")
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id
|
||||
accountId String
|
||||
providerId String
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
accessToken String?
|
||||
refreshToken String?
|
||||
idToken String?
|
||||
accessTokenExpiresAt DateTime?
|
||||
refreshTokenExpiresAt DateTime?
|
||||
scope String?
|
||||
password String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
@@map("account")
|
||||
}
|
||||
|
||||
model Verification {
|
||||
id String @id
|
||||
identifier String
|
||||
value String
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([identifier])
|
||||
@@map("verification")
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
2025.11.10 重构了translator,将其改为并发请求多个数据,速度大大提升
|
||||
2025.10.31 添加国际化支持
|
||||
2025.10.30 添加背单词功能
|
||||
2025.10.12 添加朗读器本地保存功能
|
||||
2025.10.09 新增记忆字母表功能
|
||||
2025.10.08 加快了TTS的生成速度,将IPA生成设置为可选项
|
||||
2025.10.07 新增文本朗读器,优化了视频播放器UI
|
||||
2025.10.06 更新了主页面UI,移除IPA生成与文本朗读功能,新增全语言翻译器
|
||||
2025.10.05 新增IPA生成与文本朗读功能
|
||||
2025.09.25 优化了主界面UI
|
||||
2025.09.19 更新了单词板,单词不再会重叠
|
||||
258
src/app/(features)/alphabet/AlphabetCard.tsx
Normal file
258
src/app/(features)/alphabet/AlphabetCard.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
||||
import IconClick from "@/components/ui/buttons/IconClick";
|
||||
import IMAGES from "@/config/images";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
interface AlphabetCardProps {
|
||||
alphabet: Letter[];
|
||||
alphabetType: SupportedAlphabets;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export default function AlphabetCard({ alphabet, alphabetType, onBack }: AlphabetCardProps) {
|
||||
const t = useTranslations("alphabet");
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [showIPA, setShowIPA] = useState(true);
|
||||
const [showLetter, setShowLetter] = useState(true);
|
||||
const [showRoman, setShowRoman] = useState(false);
|
||||
const [isRandomMode, setIsRandomMode] = useState(false);
|
||||
|
||||
// 只有日语假名显示罗马音按钮
|
||||
const hasRomanization = alphabetType === "japanese";
|
||||
|
||||
const currentLetter = alphabet[currentIndex];
|
||||
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
if (isRandomMode) {
|
||||
setCurrentIndex(Math.floor(Math.random() * alphabet.length));
|
||||
} else {
|
||||
setCurrentIndex((prev) => (prev === alphabet.length - 1 ? 0 : prev + 1));
|
||||
}
|
||||
}, [alphabet.length, isRandomMode]);
|
||||
|
||||
const goToPrevious = useCallback(() => {
|
||||
if (isRandomMode) {
|
||||
setCurrentIndex(Math.floor(Math.random() * alphabet.length));
|
||||
} else {
|
||||
setCurrentIndex((prev) => (prev === 0 ? alphabet.length - 1 : prev - 1));
|
||||
}
|
||||
}, [alphabet.length, isRandomMode]);
|
||||
|
||||
const goToRandom = useCallback(() => {
|
||||
setCurrentIndex(Math.floor(Math.random() * alphabet.length));
|
||||
}, [alphabet.length]);
|
||||
|
||||
// 键盘快捷键支持
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: globalThis.KeyboardEvent) => {
|
||||
if (e.key === "ArrowLeft") {
|
||||
goToPrevious();
|
||||
} else if (e.key === "ArrowRight") {
|
||||
goToNext();
|
||||
} else if (e.key === " ") {
|
||||
e.preventDefault();
|
||||
goToRandom();
|
||||
} else if (e.key === "Escape") {
|
||||
onBack();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [goToPrevious, goToNext, goToRandom, onBack]);
|
||||
|
||||
// 触摸滑动支持
|
||||
const [touchStart, setTouchStart] = useState<number | null>(null);
|
||||
const [touchEnd, setTouchEnd] = useState<number | null>(null);
|
||||
|
||||
const minSwipeDistance = 50;
|
||||
|
||||
const onTouchStart = (e: React.TouchEvent) => {
|
||||
setTouchEnd(null);
|
||||
setTouchStart(e.targetTouches[0].clientX);
|
||||
};
|
||||
|
||||
const onTouchMove = (e: React.TouchEvent) => {
|
||||
setTouchEnd(e.targetTouches[0].clientX);
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
if (!touchStart || !touchEnd) return;
|
||||
|
||||
const distance = touchStart - touchEnd;
|
||||
const isLeftSwipe = distance > minSwipeDistance;
|
||||
const isRightSwipe = distance < -minSwipeDistance;
|
||||
|
||||
if (isLeftSwipe) {
|
||||
goToNext();
|
||||
}
|
||||
if (isRightSwipe) {
|
||||
goToPrevious();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4 py-8">
|
||||
<div className="w-full max-w-2xl">
|
||||
{/* 返回按钮 */}
|
||||
<div className="flex justify-end mb-4">
|
||||
<IconClick
|
||||
size={32}
|
||||
alt="close"
|
||||
src={IMAGES.close}
|
||||
onClick={onBack}
|
||||
className="bg-white rounded-full shadow-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 主卡片 */}
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 md:p-12">
|
||||
{/* 进度指示器 */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<span className="text-sm text-gray-500">
|
||||
{currentIndex + 1} / {alphabet.length}
|
||||
</span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setShowLetter(!showLetter)}
|
||||
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||
showLetter
|
||||
? "bg-[#35786f] text-white"
|
||||
: "bg-gray-200 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{t("letter")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowIPA(!showIPA)}
|
||||
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||
showIPA
|
||||
? "bg-[#35786f] text-white"
|
||||
: "bg-gray-200 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
IPA
|
||||
</button>
|
||||
{hasRomanization && (
|
||||
<button
|
||||
onClick={() => setShowRoman(!showRoman)}
|
||||
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||
showRoman
|
||||
? "bg-[#35786f] text-white"
|
||||
: "bg-gray-200 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{t("roman")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsRandomMode(!isRandomMode)}
|
||||
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||
isRandomMode
|
||||
? "bg-[#35786f] text-white"
|
||||
: "bg-gray-200 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{t("random")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 字母显示区域 */}
|
||||
<div className="text-center mb-8">
|
||||
{showLetter ? (
|
||||
<div className="text-6xl md:text-8xl font-bold text-gray-800 mb-4">
|
||||
{currentLetter.letter}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-6xl md:text-8xl font-bold text-gray-300 mb-4 h-20 md:h-24 flex items-center justify-center">
|
||||
<span className="text-2xl md:text-3xl text-gray-400">?</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showIPA && (
|
||||
<div className="text-2xl md:text-3xl text-gray-600 mb-2">
|
||||
{currentLetter.letter_sound_ipa}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showRoman && hasRomanization && currentLetter.roman_letter && (
|
||||
<div className="text-lg md:text-xl text-gray-500">
|
||||
{currentLetter.roman_letter}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 导航控制 */}
|
||||
<div className="flex justify-between items-center">
|
||||
<button
|
||||
onClick={goToPrevious}
|
||||
className="p-3 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
|
||||
aria-label="上一个字母"
|
||||
>
|
||||
<ChevronLeft size={24} />
|
||||
</button>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
{isRandomMode ? (
|
||||
<button
|
||||
onClick={goToRandom}
|
||||
className="px-4 py-2 rounded-full bg-[#35786f] text-white text-sm font-medium hover:bg-[#2d5f58] transition-colors"
|
||||
>
|
||||
{t("randomNext")}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-1 flex-wrap max-w-xs justify-center">
|
||||
{alphabet.slice(0, 20).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`h-2 rounded-full transition-all ${
|
||||
index === currentIndex
|
||||
? "w-8 bg-[#35786f]"
|
||||
: "w-2 bg-gray-300"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
{alphabet.length > 20 && (
|
||||
<div className="text-xs text-gray-500 flex items-center">...</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={goToNext}
|
||||
className="p-3 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
|
||||
aria-label="下一个字母"
|
||||
>
|
||||
<ChevronRight size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作提示 */}
|
||||
<div className="text-center mt-6 text-white text-sm">
|
||||
<p>
|
||||
{isRandomMode
|
||||
? "使用左右箭头键或空格键随机切换字母,ESC键返回"
|
||||
: "使用左右箭头键或滑动切换字母,ESC键返回"
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 触摸事件处理 */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import IconClick from "@/components/IconClick";
|
||||
import LightButton from "@/components/ui/buttons/LightButton";
|
||||
import IconClick from "@/components/ui/buttons/IconClick";
|
||||
import IMAGES from "@/config/images";
|
||||
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
||||
import {
|
||||
Dispatch,
|
||||
KeyboardEvent,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -19,25 +20,26 @@ export default function MemoryCard({
|
||||
setChosenAlphabet: Dispatch<SetStateAction<SupportedAlphabets | null>>;
|
||||
}) {
|
||||
const t = useTranslations("alphabet");
|
||||
const [index, setIndex] = useState(
|
||||
Math.floor(Math.random() * alphabet.length),
|
||||
);
|
||||
const [index, setIndex] = useState(() => alphabet.length > 0 ? Math.floor(Math.random() * alphabet.length) : 0);
|
||||
const [more, setMore] = useState(false);
|
||||
const [ipaDisplay, setIPADisplay] = useState(true);
|
||||
const [letterDisplay, setLetterDisplay] = useState(true);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (alphabet.length > 0) {
|
||||
setIndex(Math.floor(Math.random() * alphabet.length));
|
||||
}
|
||||
}, [alphabet.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeydown = (e: globalThis.KeyboardEvent) => {
|
||||
if (e.key === " ") refresh();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
return () => document.removeEventListener("keydown", handleKeydown);
|
||||
});
|
||||
}, [refresh]);
|
||||
|
||||
const letter = alphabet[index];
|
||||
const refresh = () => {
|
||||
setIndex(Math.floor(Math.random() * alphabet.length));
|
||||
};
|
||||
const letter = alphabet[index] || { letter: "", letter_name_ipa: "", letter_sound_ipa: "" };
|
||||
return (
|
||||
<div
|
||||
className="w-full flex justify-center items-center"
|
||||
|
||||
@@ -1,48 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
||||
import { useEffect, useState } from "react";
|
||||
import MemoryCard from "./MemoryCard";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
||||
import Container from "@/components/ui/Container";
|
||||
import LightButton from "@/components/ui/buttons/LightButton";
|
||||
import AlphabetCard from "./AlphabetCard";
|
||||
|
||||
export default function Alphabet() {
|
||||
const t = useTranslations("alphabet");
|
||||
const [chosenAlphabet, setChosenAlphabet] =
|
||||
useState<SupportedAlphabets | null>(null);
|
||||
const [alphabetData, setAlphabetData] = useState<
|
||||
Record<SupportedAlphabets, Letter[] | null>
|
||||
>({
|
||||
japanese: null,
|
||||
english: null,
|
||||
esperanto: null,
|
||||
uyghur: null,
|
||||
});
|
||||
const [loadingState, setLoadingState] = useState<
|
||||
"idle" | "loading" | "success" | "error"
|
||||
>("idle");
|
||||
const [chosenAlphabet, setChosenAlphabet] = useState<SupportedAlphabets | null>(null);
|
||||
const [alphabetData, setAlphabetData] = useState<Letter[] | null>(null);
|
||||
const [loadingState, setLoadingState] = useState<"idle" | "loading" | "success" | "error">("idle");
|
||||
|
||||
useEffect(() => {
|
||||
if (chosenAlphabet && !alphabetData[chosenAlphabet]) {
|
||||
const loadAlphabetData = async () => {
|
||||
if (chosenAlphabet && !alphabetData) {
|
||||
try {
|
||||
setLoadingState("loading");
|
||||
|
||||
fetch("/alphabets/" + chosenAlphabet + ".json")
|
||||
.then((res) => {
|
||||
const res = await fetch("/alphabets/" + chosenAlphabet + ".json");
|
||||
if (!res.ok) throw new Error("Network response was not ok");
|
||||
return res.json();
|
||||
})
|
||||
.then((obj) => {
|
||||
setAlphabetData((prev) => ({
|
||||
...prev,
|
||||
[chosenAlphabet]: obj as Letter[],
|
||||
}));
|
||||
|
||||
const obj = await res.json();
|
||||
setAlphabetData(obj as Letter[]);
|
||||
setLoadingState("success");
|
||||
})
|
||||
.catch(() => {
|
||||
} catch (error) {
|
||||
setLoadingState("error");
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadAlphabetData();
|
||||
}, [chosenAlphabet, alphabetData]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,48 +39,106 @@ export default function Alphabet() {
|
||||
const timer = setTimeout(() => {
|
||||
setLoadingState("idle");
|
||||
setChosenAlphabet(null);
|
||||
setAlphabetData(null);
|
||||
}, 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [loadingState]);
|
||||
|
||||
if (!chosenAlphabet)
|
||||
// 语言选择界面
|
||||
if (!chosenAlphabet) {
|
||||
return (
|
||||
<>
|
||||
<div className="border border-gray-200 m-4 mt-4 flex flex-col justify-center items-center p-4 rounded-2xl gap-2">
|
||||
<span className="text-2xl md:text-3xl">{t("chooseCharacters")}</span>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<LightButton onClick={() => setChosenAlphabet("japanese")}>
|
||||
{t("japanese")}
|
||||
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex flex-col items-center justify-center px-4">
|
||||
<Container className="p-8 max-w-2xl w-full text-center">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
|
||||
{t("chooseCharacters")}
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-8 text-lg">
|
||||
选择一种语言的字母表开始学习
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<LightButton
|
||||
onClick={() => setChosenAlphabet("japanese")}
|
||||
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl mb-2">あいうえお</span>
|
||||
<span>{t("japanese")}</span>
|
||||
</div>
|
||||
</LightButton>
|
||||
<LightButton onClick={() => setChosenAlphabet("english")}>
|
||||
{t("english")}
|
||||
|
||||
<LightButton
|
||||
onClick={() => setChosenAlphabet("english")}
|
||||
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl mb-2">ABC</span>
|
||||
<span>{t("english")}</span>
|
||||
</div>
|
||||
</LightButton>
|
||||
<LightButton onClick={() => setChosenAlphabet("uyghur")}>
|
||||
{t("uyghur")}
|
||||
|
||||
<LightButton
|
||||
onClick={() => setChosenAlphabet("uyghur")}
|
||||
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl mb-2">ئۇيغۇر</span>
|
||||
<span>{t("uyghur")}</span>
|
||||
</div>
|
||||
</LightButton>
|
||||
<LightButton onClick={() => setChosenAlphabet("esperanto")}>
|
||||
{t("esperanto")}
|
||||
|
||||
<LightButton
|
||||
onClick={() => setChosenAlphabet("esperanto")}
|
||||
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl mb-2">ABCĜĤ</span>
|
||||
<span>{t("esperanto")}</span>
|
||||
</div>
|
||||
</LightButton>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 加载状态
|
||||
if (loadingState === "loading") {
|
||||
return t("loading");
|
||||
}
|
||||
if (loadingState === "error") {
|
||||
return t("loadFailed");
|
||||
}
|
||||
if (loadingState === "success" && alphabetData[chosenAlphabet]) {
|
||||
return (
|
||||
<>
|
||||
<MemoryCard
|
||||
alphabet={alphabetData[chosenAlphabet]}
|
||||
setChosenAlphabet={setChosenAlphabet}
|
||||
></MemoryCard>
|
||||
</>
|
||||
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center">
|
||||
<Container className="p-8 text-center">
|
||||
<div className="text-2xl text-gray-600">{t("loading")}</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
if (loadingState === "error") {
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center">
|
||||
<Container className="p-8 text-center">
|
||||
<div className="text-2xl text-red-600">{t("loadFailed")}</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 字母卡片界面
|
||||
if (loadingState === "success" && alphabetData) {
|
||||
return (
|
||||
<AlphabetCard
|
||||
alphabet={alphabetData}
|
||||
alphabetType={chosenAlphabet}
|
||||
onBack={() => {
|
||||
setChosenAlphabet(null);
|
||||
setAlphabetData(null);
|
||||
setLoadingState("idle");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/cards/Container";
|
||||
import Container from "@/components/ui/Container";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Center } from "@/components/Center";
|
||||
import { Center } from "@/components/common/Center";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { Folder } from "../../../../generated/prisma/browser";
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import { useState } from "react";
|
||||
import LightButton from "@/components/ui/buttons/LightButton";
|
||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||
import { getTTSAudioUrl } from "@/lib/browser/tts";
|
||||
import { VOICES } from "@/config/locales";
|
||||
import { useTranslations } from "next-intl";
|
||||
import localFont from "next/font/local";
|
||||
import { isNonNegativeInteger } from "@/lib/utils";
|
||||
import { isNonNegativeInteger, SeededRandom } from "@/lib/utils";
|
||||
import { Pair } from "../../../../generated/prisma/browser";
|
||||
|
||||
const myFont = localFont({
|
||||
@@ -27,20 +27,16 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
||||
const [show, setShow] = useState<"question" | "answer">("question");
|
||||
const { load, play } = useAudioPlayer();
|
||||
|
||||
const [disorderedTextPairs, setDisorderedTextPairs] = useState<Pair[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setDisorderedTextPairs(textPairs.toSorted(() => Math.random() - 0.5));
|
||||
}, [textPairs]);
|
||||
|
||||
const getTextPairs = () => {
|
||||
if (disorder) {
|
||||
return disorderedTextPairs;
|
||||
if (textPairs.length === 0) {
|
||||
return <p>{t("noTextPairs")}</p>;
|
||||
}
|
||||
return textPairs.toSorted((a, b) => a.id - b.id);
|
||||
};
|
||||
|
||||
const rng = new SeededRandom(textPairs[0].folderId);
|
||||
const disorderedTextPairs = textPairs.toSorted(() => rng.next() - 0.5);
|
||||
|
||||
textPairs.sort((a, b) => a.id - b.id);
|
||||
|
||||
const getTextPairs = () => disorder ? disorderedTextPairs : textPairs;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
getFoldersWithTotalPairsByUserId,
|
||||
getUserIdByFolderId,
|
||||
} from "@/lib/actions/services/folderService";
|
||||
} from "@/lib/server/services/folderService";
|
||||
import { isNonNegativeInteger } from "@/lib/utils";
|
||||
import FolderSelector from "./FolderSelector";
|
||||
import Memorize from "./Memorize";
|
||||
import { getPairsByFolderId } from "@/lib/actions/services/pairService";
|
||||
import { getPairsByFolderId } from "@/lib/server/services/pairService";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export default async function MemorizePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ folder_id?: string }>;
|
||||
searchParams: Promise<{ folder_id?: string; }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
const userId = session?.user?.id;
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
const tParam = (await searchParams).folder_id;
|
||||
|
||||
if (!session) {
|
||||
redirect(
|
||||
`/auth?redirect=/memorize${(await searchParams).folder_id
|
||||
? `?folder_id=${tParam}`
|
||||
: ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
const t = await getTranslations("memorize.page");
|
||||
|
||||
const tParam = (await searchParams).folder_id;
|
||||
const folder_id = tParam
|
||||
? isNonNegativeInteger(tParam)
|
||||
? parseInt(tParam)
|
||||
: null
|
||||
: null;
|
||||
|
||||
if (!userId) {
|
||||
redirect(
|
||||
`/login?redirect=/memorize${folder_id ? `?folder_id=${folder_id}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
const uid = Number(userId);
|
||||
|
||||
if (!folder_id) {
|
||||
return (
|
||||
<FolderSelector
|
||||
folders={await getFoldersWithTotalPairsByUserId(uid)}
|
||||
folders={await getFoldersWithTotalPairsByUserId(session.user.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const owner = await getUserIdByFolderId(folder_id);
|
||||
if (owner !== uid) {
|
||||
if (owner !== session.user.id) {
|
||||
return <p>{t("unauthorized")}</p>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import { useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function UploadArea({
|
||||
setVideoUrl,
|
||||
setSrtUrl,
|
||||
}: {
|
||||
setVideoUrl: (url: string | null) => void;
|
||||
setSrtUrl: (url: string | null) => void;
|
||||
}) {
|
||||
const t = useTranslations("srt_player");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const uploadVideo = () => {
|
||||
const input = inputRef.current;
|
||||
if (input) {
|
||||
input.setAttribute("accept", "video/*");
|
||||
input.click();
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0];
|
||||
if (file) {
|
||||
setVideoUrl(URL.createObjectURL(file));
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
const uploadSRT = () => {
|
||||
const input = inputRef.current;
|
||||
if (input) {
|
||||
input.setAttribute("accept", ".srt");
|
||||
input.click();
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0];
|
||||
if (file) {
|
||||
setSrtUrl(URL.createObjectURL(file));
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-2 m-2">
|
||||
<LightButton onClick={uploadVideo}>{t("uploadVideo")}</LightButton>
|
||||
<LightButton onClick={uploadSRT}>{t("uploadSubtitle")}</LightButton>
|
||||
<input type="file" className="hidden" ref={inputRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, forwardRef, useEffect } from "react";
|
||||
import { useState, useRef, forwardRef, useEffect, useCallback } from "react";
|
||||
import SubtitleDisplay from "./SubtitleDisplay";
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import LightButton from "@/components/ui/buttons/LightButton";
|
||||
import { getIndex, parseSrt, getNearistIndex } from "../subtitle";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
@@ -20,7 +20,7 @@ const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
|
||||
const [spanText, setSpanText] = useState<string>("");
|
||||
const [subtitle, setSubtitle] = useState<string>("");
|
||||
const parsedSrtRef = useRef<
|
||||
{ start: number; end: number; text: string }[] | null
|
||||
{ start: number; end: number; text: string; }[] | null
|
||||
>(null);
|
||||
const rafldRef = useRef<number>(0);
|
||||
const ready = useRef({
|
||||
@@ -31,7 +31,7 @@ const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
|
||||
},
|
||||
});
|
||||
|
||||
const togglePlayPause = () => {
|
||||
const togglePlayPause = useCallback(() => {
|
||||
if (!videoUrl) return;
|
||||
|
||||
const video = videoRef.current;
|
||||
@@ -42,7 +42,7 @@ const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
|
||||
video.pause();
|
||||
}
|
||||
setIsPlaying(!video.paused);
|
||||
}
|
||||
}, [videoRef, videoUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDownEvent = (e: globalThis.KeyboardEvent) => {
|
||||
|
||||
45
src/app/(features)/srt-player/components/atoms/FileInput.tsx
Normal file
45
src/app/(features)/srt-player/components/atoms/FileInput.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef } from "react";
|
||||
import { FileInputProps } from "../../types/controls";
|
||||
|
||||
interface FileInputComponentProps extends FileInputProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function FileInput({ accept, onFileSelect, disabled, className, children }: FileInputComponentProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
if (!disabled && inputRef.current) {
|
||||
inputRef.current.click();
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
onFileSelect(file);
|
||||
}
|
||||
}, [onFileSelect]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
className={`px-2 py-1 rounded shadow font-bold hover:cursor-pointer hover:bg-gray-200 text-gray-800 bg-white ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className || ''}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import LightButton from "@/components/ui/buttons/LightButton";
|
||||
import { PlayButtonProps } from "../../types/player";
|
||||
|
||||
export default function PlayButton({ isPlaying, onToggle, disabled, className }: PlayButtonProps) {
|
||||
const t = useTranslations("srt_player");
|
||||
|
||||
return (
|
||||
<LightButton
|
||||
onClick={disabled ? undefined : onToggle}
|
||||
disabled={disabled}
|
||||
className={`px-4 py-2 ${className || ''}`}
|
||||
>
|
||||
{isPlaying ? t("pause") : t("play")}
|
||||
</LightButton>
|
||||
);
|
||||
}
|
||||
26
src/app/(features)/srt-player/components/atoms/SeekBar.tsx
Normal file
26
src/app/(features)/srt-player/components/atoms/SeekBar.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { SeekBarProps } from "../../types/player";
|
||||
|
||||
export default function SeekBar({ value, max, onChange, disabled, className }: SeekBarProps) {
|
||||
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = parseInt(event.target.value);
|
||||
onChange(newValue);
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className={`w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className || ''}`}
|
||||
style={{
|
||||
background: `linear-gradient(to right, #374151 0%, #374151 ${(value / max) * 100}%, #e5e7eb ${(value / max) * 100}%, #e5e7eb 100%)`
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import LightButton from "@/components/ui/buttons/LightButton";
|
||||
import { SpeedControlProps } from "../../types/player";
|
||||
import { getPlaybackRateOptions, getPlaybackRateLabel } from "../../utils/timeUtils";
|
||||
|
||||
export default function SpeedControl({ playbackRate, onPlaybackRateChange, disabled, className }: SpeedControlProps) {
|
||||
const speedOptions = getPlaybackRateOptions();
|
||||
|
||||
const handleSpeedChange = React.useCallback(() => {
|
||||
const currentIndex = speedOptions.indexOf(playbackRate);
|
||||
const nextIndex = (currentIndex + 1) % speedOptions.length;
|
||||
onPlaybackRateChange(speedOptions[nextIndex]);
|
||||
}, [playbackRate, onPlaybackRateChange, speedOptions]);
|
||||
|
||||
return (
|
||||
<LightButton
|
||||
onClick={disabled ? undefined : handleSpeedChange}
|
||||
className={`${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className || ''}`}
|
||||
>
|
||||
{getPlaybackRateLabel(playbackRate)}
|
||||
</LightButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { SubtitleTextProps } from "../../types/subtitle";
|
||||
|
||||
export default function SubtitleText({ text, onWordClick, style, className }: SubtitleTextProps) {
|
||||
const handleWordClick = React.useCallback((word: string) => {
|
||||
onWordClick?.(word);
|
||||
}, [onWordClick]);
|
||||
|
||||
// 将文本分割成单词,保持标点符号
|
||||
const renderTextWithClickableWords = () => {
|
||||
if (!text) return null;
|
||||
|
||||
// 匹配单词和标点符号
|
||||
const parts = text.match(/[\w']+|[^\w\s]+|\s+/g) || [];
|
||||
|
||||
return parts.map((part, index) => {
|
||||
// 如果是单词(字母和撇号组成)
|
||||
if (/^[\w']+$/.test(part)) {
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
onClick={() => handleWordClick(part)}
|
||||
className="hover:bg-gray-700 hover:underline hover:cursor-pointer rounded px-1 transition-colors"
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// 如果是空格或其他字符,直接渲染
|
||||
return <span key={index}>{part}</span>;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`overflow-auto h-16 mt-2 wrap-break-words font-sans text-white text-center text-2xl ${className || ''}`}
|
||||
style={style}
|
||||
>
|
||||
{renderTextWithClickableWords()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import React, { forwardRef } from "react";
|
||||
import { VideoElementProps } from "../../types/player";
|
||||
|
||||
const VideoElement = forwardRef<HTMLVideoElement, VideoElementProps>(
|
||||
({ src, onTimeUpdate, onLoadedMetadata, onPlay, onPause, onEnded, className }, ref) => {
|
||||
const handleTimeUpdate = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||
const video = event.currentTarget;
|
||||
onTimeUpdate?.(video.currentTime);
|
||||
}, [onTimeUpdate]);
|
||||
|
||||
const handleLoadedMetadata = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||
const video = event.currentTarget;
|
||||
onLoadedMetadata?.(video.duration);
|
||||
}, [onLoadedMetadata]);
|
||||
|
||||
const handlePlay = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||
onPlay?.();
|
||||
}, [onPlay]);
|
||||
|
||||
const handlePause = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||
onPause?.();
|
||||
}, [onPause]);
|
||||
|
||||
const handleEnded = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||
onEnded?.();
|
||||
}, [onEnded]);
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={ref}
|
||||
src={src}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onEnded={handleEnded}
|
||||
className={`bg-gray-200 w-full ${className || ""}`}
|
||||
playsInline
|
||||
controls={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
VideoElement.displayName = "VideoElement";
|
||||
|
||||
export default VideoElement;
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronLeft, ChevronRight, RotateCcw, Pause } from "lucide-react";
|
||||
import DarkButton from "@/components/ui/buttons/DarkButton";
|
||||
import { ControlBarProps } from "../../types/controls";
|
||||
import PlayButton from "../atoms/PlayButton";
|
||||
import SpeedControl from "../atoms/SpeedControl";
|
||||
|
||||
export default function ControlBar({
|
||||
isPlaying,
|
||||
onPlayPause,
|
||||
onPrevious,
|
||||
onNext,
|
||||
onRestart,
|
||||
playbackRate,
|
||||
onPlaybackRateChange,
|
||||
autoPause,
|
||||
onAutoPauseToggle,
|
||||
disabled,
|
||||
className
|
||||
}: ControlBarProps) {
|
||||
const t = useTranslations("srt_player");
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap gap-2 justify-center ${className || ''}`}>
|
||||
<PlayButton
|
||||
isPlaying={isPlaying}
|
||||
onToggle={onPlayPause}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<DarkButton
|
||||
onClick={disabled ? undefined : onPrevious}
|
||||
disabled={disabled}
|
||||
className="flex items-center px-3 py-2"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||
{t("previous")}
|
||||
</DarkButton>
|
||||
|
||||
<DarkButton
|
||||
onClick={disabled ? undefined : onNext}
|
||||
disabled={disabled}
|
||||
className="flex items-center px-3 py-2"
|
||||
>
|
||||
{t("next")}
|
||||
<ChevronRight className="w-4 h-4 ml-2" />
|
||||
</DarkButton>
|
||||
|
||||
<DarkButton
|
||||
onClick={disabled ? undefined : onRestart}
|
||||
disabled={disabled}
|
||||
className="flex items-center px-3 py-2"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
{t("restart")}
|
||||
</DarkButton>
|
||||
|
||||
<SpeedControl
|
||||
playbackRate={playbackRate}
|
||||
onPlaybackRateChange={onPlaybackRateChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<DarkButton
|
||||
onClick={disabled ? undefined : onAutoPauseToggle}
|
||||
disabled={disabled}
|
||||
className="flex items-center px-3 py-2"
|
||||
>
|
||||
<Pause className="w-4 h-4 mr-2" />
|
||||
{t("autoPause", { enabled: autoPause ? t("on") : t("off") })}
|
||||
</DarkButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { SubtitleDisplayProps } from "../../types/subtitle";
|
||||
import SubtitleText from "../atoms/SubtitleText";
|
||||
|
||||
export default function SubtitleArea({ subtitle, onWordClick, settings, className }: SubtitleDisplayProps) {
|
||||
const handleWordClick = React.useCallback((word: string) => {
|
||||
// 打开有道词典页面查询单词
|
||||
window.open(
|
||||
`https://www.youdao.com/result?word=${encodeURIComponent(word)}&lang=en`,
|
||||
"_blank"
|
||||
);
|
||||
onWordClick?.(word);
|
||||
}, [onWordClick]);
|
||||
|
||||
const subtitleStyle = React.useMemo(() => {
|
||||
if (!settings) return { backgroundColor: 'rgba(0, 0, 0, 0.5)' };
|
||||
|
||||
return {
|
||||
backgroundColor: settings.backgroundColor,
|
||||
color: settings.textColor,
|
||||
fontSize: `${settings.fontSize}px`,
|
||||
fontFamily: settings.fontFamily,
|
||||
opacity: settings.opacity,
|
||||
};
|
||||
}, [settings]);
|
||||
|
||||
return (
|
||||
<SubtitleText
|
||||
text={subtitle}
|
||||
onWordClick={handleWordClick}
|
||||
style={subtitleStyle}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { Video, FileText } from "lucide-react";
|
||||
import DarkButton from "@/components/ui/buttons/DarkButton";
|
||||
import { FileUploadProps } from "../../types/controls";
|
||||
import { useFileUpload } from "../../hooks/useFileUpload";
|
||||
|
||||
export default function UploadZone({ onVideoUpload, onSubtitleUpload, className }: FileUploadProps) {
|
||||
const t = useTranslations("srt_player");
|
||||
const { uploadVideo, uploadSubtitle } = useFileUpload();
|
||||
|
||||
const handleVideoUpload = React.useCallback(() => {
|
||||
uploadVideo(onVideoUpload, (error) => {
|
||||
toast.error(t("videoUploadFailed") + ": " + error.message);
|
||||
});
|
||||
}, [uploadVideo, onVideoUpload, t]);
|
||||
|
||||
const handleSubtitleUpload = React.useCallback(() => {
|
||||
uploadSubtitle(onSubtitleUpload, (error) => {
|
||||
toast.error(t("subtitleUploadFailed") + ": " + error.message);
|
||||
});
|
||||
}, [uploadSubtitle, onSubtitleUpload, t]);
|
||||
|
||||
return (
|
||||
<div className={`flex gap-3 ${className || ''}`}>
|
||||
<DarkButton
|
||||
onClick={handleVideoUpload}
|
||||
className="flex-1 py-2 px-3 text-sm"
|
||||
>
|
||||
<Video className="w-4 h-4 mr-2" />
|
||||
{t("uploadVideo")}
|
||||
</DarkButton>
|
||||
|
||||
<DarkButton
|
||||
onClick={handleSubtitleUpload}
|
||||
className="flex-1 py-2 px-3 text-sm"
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
{t("uploadSubtitle")}
|
||||
</DarkButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import React, { forwardRef } from "react";
|
||||
import { VideoElementProps } from "../../types/player";
|
||||
import VideoElement from "../atoms/VideoElement";
|
||||
|
||||
interface VideoPlayerComponentProps extends VideoElementProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerComponentProps>(
|
||||
({
|
||||
src,
|
||||
onTimeUpdate,
|
||||
onLoadedMetadata,
|
||||
onPlay,
|
||||
onPause,
|
||||
onEnded,
|
||||
className,
|
||||
children
|
||||
}, ref) => {
|
||||
return (
|
||||
<div className={`w-full flex flex-col ${className || ''}`}>
|
||||
<VideoElement
|
||||
ref={ref}
|
||||
src={src}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onLoadedMetadata={onLoadedMetadata}
|
||||
onPlay={onPlay}
|
||||
onPause={onPause}
|
||||
onEnded={onEnded}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
VideoPlayer.displayName = "VideoPlayer";
|
||||
|
||||
export default VideoPlayer;
|
||||
85
src/app/(features)/srt-player/hooks/useFileUpload.ts
Normal file
85
src/app/(features)/srt-player/hooks/useFileUpload.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
export function useFileUpload() {
|
||||
const uploadFile = useCallback((
|
||||
file: File,
|
||||
onSuccess: (url: string) => void,
|
||||
onError?: (error: Error) => void
|
||||
) => {
|
||||
try {
|
||||
// 验证文件大小(限制为100MB)
|
||||
const maxSize = 100 * 1024 * 1024; // 100MB
|
||||
if (file.size > maxSize) {
|
||||
throw new Error(`文件大小超过限制 (${(file.size / 1024 / 1024).toFixed(2)}MB > 100MB)`);
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
onSuccess(url);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '文件上传失败';
|
||||
onError?.(new Error(errorMessage));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const uploadVideo = useCallback((
|
||||
onVideoUpload: (url: string) => void,
|
||||
onError?: (error: Error) => void
|
||||
) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'video/*';
|
||||
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
// 验证文件类型
|
||||
if (!file.type.startsWith('video/')) {
|
||||
onError?.(new Error('请选择有效的视频文件'));
|
||||
return;
|
||||
}
|
||||
uploadFile(file, onVideoUpload, onError);
|
||||
}
|
||||
};
|
||||
|
||||
input.onerror = () => {
|
||||
onError?.(new Error('文件选择失败'));
|
||||
};
|
||||
|
||||
input.click();
|
||||
}, [uploadFile]);
|
||||
|
||||
const uploadSubtitle = useCallback((
|
||||
onSubtitleUpload: (url: string) => void,
|
||||
onError?: (error: Error) => void
|
||||
) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.srt';
|
||||
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
// 验证文件扩展名
|
||||
if (!file.name.toLowerCase().endsWith('.srt')) {
|
||||
onError?.(new Error('请选择.srt格式的字幕文件'));
|
||||
return;
|
||||
}
|
||||
uploadFile(file, onSubtitleUpload, onError);
|
||||
}
|
||||
};
|
||||
|
||||
input.onerror = () => {
|
||||
onError?.(new Error('文件选择失败'));
|
||||
};
|
||||
|
||||
input.click();
|
||||
}, [uploadFile]);
|
||||
|
||||
return {
|
||||
uploadVideo,
|
||||
uploadSubtitle,
|
||||
uploadFile,
|
||||
};
|
||||
}
|
||||
68
src/app/(features)/srt-player/hooks/useKeyboardShortcuts.ts
Normal file
68
src/app/(features)/srt-player/hooks/useKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { KeyboardShortcut } from "../types/controls";
|
||||
|
||||
export function useKeyboardShortcuts(
|
||||
shortcuts: KeyboardShortcut[],
|
||||
enabled: boolean = true
|
||||
) {
|
||||
const handleKeyDown = useCallback((event: globalThis.KeyboardEvent) => {
|
||||
if (!enabled) return;
|
||||
|
||||
// 防止在输入框中触发快捷键
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
|
||||
const shortcut = shortcuts.find(s => s.key === event.key);
|
||||
if (shortcut) {
|
||||
event.preventDefault();
|
||||
shortcut.action();
|
||||
}
|
||||
}, [shortcuts, enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
}
|
||||
|
||||
export function createSrtPlayerShortcuts(
|
||||
playPause: () => void,
|
||||
next: () => void,
|
||||
previous: () => void,
|
||||
restart: () => void,
|
||||
toggleAutoPause: () => void
|
||||
): KeyboardShortcut[] {
|
||||
return [
|
||||
{
|
||||
key: ' ',
|
||||
description: '播放/暂停',
|
||||
action: playPause,
|
||||
},
|
||||
{
|
||||
key: 'n',
|
||||
description: '下一句',
|
||||
action: next,
|
||||
},
|
||||
{
|
||||
key: 'p',
|
||||
description: '上一句',
|
||||
action: previous,
|
||||
},
|
||||
{
|
||||
key: 'r',
|
||||
description: '句首',
|
||||
action: restart,
|
||||
},
|
||||
{
|
||||
key: 'a',
|
||||
description: '切换自动暂停',
|
||||
action: toggleAutoPause,
|
||||
},
|
||||
];
|
||||
}
|
||||
306
src/app/(features)/srt-player/hooks/useSrtPlayer.ts
Normal file
306
src/app/(features)/srt-player/hooks/useSrtPlayer.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
"use client";
|
||||
|
||||
import { useReducer, useCallback, useRef, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { VideoState, VideoControls } from "../types/player";
|
||||
import { SubtitleState, SubtitleEntry } from "../types/subtitle";
|
||||
import { ControlState, ControlActions } from "../types/controls";
|
||||
|
||||
export interface SrtPlayerState {
|
||||
video: VideoState;
|
||||
subtitle: SubtitleState;
|
||||
controls: ControlState;
|
||||
}
|
||||
|
||||
export interface SrtPlayerActions extends VideoControls, ControlActions {
|
||||
setVideoUrl: (url: string | null) => void;
|
||||
setSubtitleUrl: (url: string | null) => void;
|
||||
nextSubtitle: () => void;
|
||||
previousSubtitle: () => void;
|
||||
restartSubtitle: () => void;
|
||||
setSubtitleSettings: (settings: Partial<SubtitleState['settings']>) => void;
|
||||
}
|
||||
|
||||
const initialState: SrtPlayerState = {
|
||||
video: {
|
||||
url: null,
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
playbackRate: 1.0,
|
||||
volume: 1.0,
|
||||
},
|
||||
subtitle: {
|
||||
url: null,
|
||||
data: [],
|
||||
currentText: "",
|
||||
currentIndex: null,
|
||||
settings: {
|
||||
fontSize: 24,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
textColor: "#ffffff",
|
||||
position: "bottom",
|
||||
fontFamily: "sans-serif",
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
controls: {
|
||||
autoPause: true,
|
||||
showShortcuts: false,
|
||||
showSettings: false,
|
||||
},
|
||||
};
|
||||
|
||||
type SrtPlayerAction =
|
||||
| { type: "SET_VIDEO_URL"; payload: string | null }
|
||||
| { type: "SET_PLAYING"; payload: boolean }
|
||||
| { type: "SET_CURRENT_TIME"; payload: number }
|
||||
| { type: "SET_DURATION"; payload: number }
|
||||
| { type: "SET_PLAYBACK_RATE"; payload: number }
|
||||
| { type: "SET_VOLUME"; payload: number }
|
||||
| { type: "SET_SUBTITLE_URL"; payload: string | null }
|
||||
| { type: "SET_SUBTITLE_DATA"; payload: SubtitleEntry[] }
|
||||
| { type: "SET_CURRENT_SUBTITLE"; payload: { text: string; index: number | null } }
|
||||
| { type: "SET_SUBTITLE_SETTINGS"; payload: Partial<SubtitleState['settings']> }
|
||||
| { type: "TOGGLE_AUTO_PAUSE" }
|
||||
| { type: "TOGGLE_SHORTCUTS" }
|
||||
| { type: "TOGGLE_SETTINGS" };
|
||||
|
||||
function srtPlayerReducer(state: SrtPlayerState, action: SrtPlayerAction): SrtPlayerState {
|
||||
switch (action.type) {
|
||||
case "SET_VIDEO_URL":
|
||||
return { ...state, video: { ...state.video, url: action.payload } };
|
||||
case "SET_PLAYING":
|
||||
return { ...state, video: { ...state.video, isPlaying: action.payload } };
|
||||
case "SET_CURRENT_TIME":
|
||||
return { ...state, video: { ...state.video, currentTime: action.payload } };
|
||||
case "SET_DURATION":
|
||||
return { ...state, video: { ...state.video, duration: action.payload } };
|
||||
case "SET_PLAYBACK_RATE":
|
||||
return { ...state, video: { ...state.video, playbackRate: action.payload } };
|
||||
case "SET_VOLUME":
|
||||
return { ...state, video: { ...state.video, volume: action.payload } };
|
||||
case "SET_SUBTITLE_URL":
|
||||
return { ...state, subtitle: { ...state.subtitle, url: action.payload } };
|
||||
case "SET_SUBTITLE_DATA":
|
||||
return { ...state, subtitle: { ...state.subtitle, data: action.payload } };
|
||||
case "SET_CURRENT_SUBTITLE":
|
||||
return {
|
||||
...state,
|
||||
subtitle: {
|
||||
...state.subtitle,
|
||||
currentText: action.payload.text,
|
||||
currentIndex: action.payload.index,
|
||||
},
|
||||
};
|
||||
case "SET_SUBTITLE_SETTINGS":
|
||||
return {
|
||||
...state,
|
||||
subtitle: {
|
||||
...state.subtitle,
|
||||
settings: { ...state.subtitle.settings, ...action.payload },
|
||||
},
|
||||
};
|
||||
case "TOGGLE_AUTO_PAUSE":
|
||||
return { ...state, controls: { ...state.controls, autoPause: !state.controls.autoPause } };
|
||||
case "TOGGLE_SHORTCUTS":
|
||||
return { ...state, controls: { ...state.controls, showShortcuts: !state.controls.showShortcuts } };
|
||||
case "TOGGLE_SETTINGS":
|
||||
return { ...state, controls: { ...state.controls, showSettings: !state.controls.showSettings } };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function useSrtPlayer() {
|
||||
const [state, dispatch] = useReducer(srtPlayerReducer, initialState);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// Video controls
|
||||
const play = useCallback(() => {
|
||||
// 检查是否同时有视频和字幕
|
||||
if (!state.video.url || !state.subtitle.url || state.subtitle.data.length === 0) {
|
||||
toast.error("请先上传视频和字幕文件");
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoRef.current) {
|
||||
videoRef.current.play().catch(error => {
|
||||
toast.error("视频播放失败: " + error.message);
|
||||
});
|
||||
dispatch({ type: "SET_PLAYING", payload: true });
|
||||
}
|
||||
}, [state.video.url, state.subtitle.url, state.subtitle.data.length, dispatch]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
dispatch({ type: "SET_PLAYING", payload: false });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const togglePlayPause = useCallback(() => {
|
||||
if (state.video.isPlaying) {
|
||||
pause();
|
||||
} else {
|
||||
play();
|
||||
}
|
||||
}, [state.video.isPlaying, play, pause]);
|
||||
|
||||
const seek = useCallback((time: number) => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = time;
|
||||
dispatch({ type: "SET_CURRENT_TIME", payload: time });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setPlaybackRate = useCallback((rate: number) => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.playbackRate = rate;
|
||||
dispatch({ type: "SET_PLAYBACK_RATE", payload: rate });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setVolume = useCallback((volume: number) => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.volume = volume;
|
||||
dispatch({ type: "SET_VOLUME", payload: volume });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const restart = useCallback(() => {
|
||||
if (videoRef.current && state.subtitle.currentIndex !== null) {
|
||||
const currentSubtitle = state.subtitle.data[state.subtitle.currentIndex];
|
||||
if (currentSubtitle) {
|
||||
seek(currentSubtitle.start);
|
||||
play();
|
||||
}
|
||||
}
|
||||
}, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
|
||||
|
||||
// URL setters
|
||||
const setVideoUrl = useCallback((url: string | null) => {
|
||||
dispatch({ type: "SET_VIDEO_URL", payload: url });
|
||||
if (url && videoRef.current) {
|
||||
videoRef.current.src = url;
|
||||
videoRef.current.load();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setSubtitleUrl = useCallback((url: string | null) => {
|
||||
dispatch({ type: "SET_SUBTITLE_URL", payload: url });
|
||||
}, []);
|
||||
|
||||
// Subtitle controls
|
||||
const nextSubtitle = useCallback(() => {
|
||||
if (state.subtitle.currentIndex !== null &&
|
||||
state.subtitle.currentIndex + 1 < state.subtitle.data.length) {
|
||||
const nextIndex = state.subtitle.currentIndex + 1;
|
||||
const nextSubtitle = state.subtitle.data[nextIndex];
|
||||
seek(nextSubtitle.start);
|
||||
play();
|
||||
}
|
||||
}, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
|
||||
|
||||
const previousSubtitle = useCallback(() => {
|
||||
if (state.subtitle.currentIndex !== null && state.subtitle.currentIndex > 0) {
|
||||
const prevIndex = state.subtitle.currentIndex - 1;
|
||||
const prevSubtitle = state.subtitle.data[prevIndex];
|
||||
seek(prevSubtitle.start);
|
||||
play();
|
||||
}
|
||||
}, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
|
||||
|
||||
const restartSubtitle = useCallback(() => {
|
||||
if (state.subtitle.currentIndex !== null) {
|
||||
const currentSubtitle = state.subtitle.data[state.subtitle.currentIndex];
|
||||
seek(currentSubtitle.start);
|
||||
play();
|
||||
}
|
||||
}, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
|
||||
|
||||
const setSubtitleSettings = useCallback((settings: Partial<SubtitleState['settings']>) => {
|
||||
dispatch({ type: "SET_SUBTITLE_SETTINGS", payload: settings });
|
||||
}, []);
|
||||
|
||||
// Control actions
|
||||
const toggleAutoPause = useCallback(() => {
|
||||
dispatch({ type: "TOGGLE_AUTO_PAUSE" });
|
||||
}, []);
|
||||
|
||||
const toggleShortcuts = useCallback(() => {
|
||||
dispatch({ type: "TOGGLE_SHORTCUTS" });
|
||||
}, []);
|
||||
|
||||
const toggleSettings = useCallback(() => {
|
||||
dispatch({ type: "TOGGLE_SETTINGS" });
|
||||
}, []);
|
||||
|
||||
// Video event handlers
|
||||
const handleTimeUpdate = useCallback(() => {
|
||||
if (videoRef.current) {
|
||||
dispatch({ type: "SET_CURRENT_TIME", payload: videoRef.current.currentTime });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLoadedMetadata = useCallback(() => {
|
||||
if (videoRef.current) {
|
||||
dispatch({ type: "SET_DURATION", payload: videoRef.current.duration });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePlay = useCallback(() => {
|
||||
dispatch({ type: "SET_PLAYING", payload: true });
|
||||
}, []);
|
||||
|
||||
const handlePause = useCallback(() => {
|
||||
dispatch({ type: "SET_PLAYING", payload: false });
|
||||
}, []);
|
||||
|
||||
// Set subtitle data
|
||||
const setSubtitleData = useCallback((data: SubtitleEntry[]) => {
|
||||
dispatch({ type: "SET_SUBTITLE_DATA", payload: data });
|
||||
}, []);
|
||||
|
||||
// Set current subtitle
|
||||
const setCurrentSubtitle = useCallback((text: string, index: number | null) => {
|
||||
dispatch({ type: "SET_CURRENT_SUBTITLE", payload: { text, index } });
|
||||
}, []);
|
||||
|
||||
const actions: SrtPlayerActions = {
|
||||
play,
|
||||
pause,
|
||||
togglePlayPause,
|
||||
seek,
|
||||
setPlaybackRate,
|
||||
setVolume,
|
||||
restart,
|
||||
setVideoUrl,
|
||||
setSubtitleUrl,
|
||||
nextSubtitle,
|
||||
previousSubtitle,
|
||||
restartSubtitle,
|
||||
setSubtitleSettings,
|
||||
toggleAutoPause,
|
||||
toggleShortcuts,
|
||||
toggleSettings,
|
||||
};
|
||||
|
||||
return {
|
||||
state,
|
||||
actions,
|
||||
videoRef,
|
||||
videoEventHandlers: {
|
||||
onTimeUpdate: handleTimeUpdate,
|
||||
onLoadedMetadata: handleLoadedMetadata,
|
||||
onPlay: handlePlay,
|
||||
onPause: handlePause,
|
||||
},
|
||||
subtitleActions: {
|
||||
setSubtitleData,
|
||||
setCurrentSubtitle,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type UseSrtPlayerReturn = ReturnType<typeof useSrtPlayer>;
|
||||
110
src/app/(features)/srt-player/hooks/useSubtitleSync.ts
Normal file
110
src/app/(features)/srt-player/hooks/useSubtitleSync.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { SubtitleEntry } from "../types/subtitle";
|
||||
|
||||
export function useSubtitleSync(
|
||||
subtitles: SubtitleEntry[],
|
||||
currentTime: number,
|
||||
isPlaying: boolean,
|
||||
autoPause: boolean,
|
||||
onSubtitleChange: (subtitle: SubtitleEntry | null) => void,
|
||||
onAutoPauseTrigger?: (subtitle: SubtitleEntry) => void
|
||||
) {
|
||||
const lastSubtitleRef = useRef<SubtitleEntry | null>(null);
|
||||
const rafIdRef = useRef<number>(0);
|
||||
|
||||
// 获取当前时间对应的字幕
|
||||
const getCurrentSubtitle = useCallback((time: number): SubtitleEntry | null => {
|
||||
return subtitles.find(subtitle => time >= subtitle.start && time <= subtitle.end) || null;
|
||||
}, [subtitles]);
|
||||
|
||||
// 获取最近的字幕索引
|
||||
const getNearestIndex = useCallback((time: number): number | null => {
|
||||
if (subtitles.length === 0) return null;
|
||||
|
||||
// 如果时间早于第一个字幕开始时间
|
||||
if (time < subtitles[0].start) return null;
|
||||
|
||||
// 如果时间晚于最后一个字幕结束时间
|
||||
if (time > subtitles[subtitles.length - 1].end) return subtitles.length - 1;
|
||||
|
||||
// 二分查找找到当前时间对应的字幕
|
||||
let left = 0;
|
||||
let right = subtitles.length - 1;
|
||||
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
const subtitle = subtitles[mid];
|
||||
|
||||
if (time >= subtitle.start && time <= subtitle.end) {
|
||||
return mid;
|
||||
} else if (time < subtitle.start) {
|
||||
right = mid - 1;
|
||||
} else {
|
||||
left = mid + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到完全匹配的字幕,返回最近的字幕索引
|
||||
return right >= 0 ? right : null;
|
||||
}, [subtitles]);
|
||||
|
||||
// 检查是否需要自动暂停
|
||||
const shouldAutoPause = useCallback((subtitle: SubtitleEntry, time: number): boolean => {
|
||||
return autoPause &&
|
||||
time >= subtitle.end - 0.2 && // 增加时间窗口,确保自动暂停更可靠
|
||||
time < subtitle.end;
|
||||
}, [autoPause]);
|
||||
|
||||
// 启动/停止同步循环
|
||||
useEffect(() => {
|
||||
const syncSubtitles = () => {
|
||||
const currentSubtitle = getCurrentSubtitle(currentTime);
|
||||
|
||||
// 检查字幕是否发生变化
|
||||
if (currentSubtitle !== lastSubtitleRef.current) {
|
||||
const previousSubtitle = lastSubtitleRef.current;
|
||||
lastSubtitleRef.current = currentSubtitle;
|
||||
|
||||
// 只有当有当前字幕时才调用onSubtitleChange
|
||||
// 在字幕间隙时保持之前的字幕索引,避免进度条跳到0
|
||||
if (currentSubtitle) {
|
||||
onSubtitleChange(currentSubtitle);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否需要自动暂停
|
||||
// 每次都检查,不只在字幕变化时检查
|
||||
if (currentSubtitle && shouldAutoPause(currentSubtitle, currentTime)) {
|
||||
onAutoPauseTrigger?.(currentSubtitle);
|
||||
} else if (!currentSubtitle && lastSubtitleRef.current && shouldAutoPause(lastSubtitleRef.current, currentTime)) {
|
||||
// 在字幕结束时,如果前一个字幕需要自动暂停,也要触发
|
||||
onAutoPauseTrigger?.(lastSubtitleRef.current);
|
||||
}
|
||||
|
||||
rafIdRef.current = requestAnimationFrame(syncSubtitles);
|
||||
};
|
||||
|
||||
if (subtitles.length > 0) {
|
||||
rafIdRef.current = requestAnimationFrame(syncSubtitles);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (rafIdRef.current) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
}
|
||||
};
|
||||
}, [subtitles.length, currentTime, getCurrentSubtitle, onSubtitleChange, shouldAutoPause, onAutoPauseTrigger]);
|
||||
|
||||
// 重置最后字幕引用
|
||||
useEffect(() => {
|
||||
lastSubtitleRef.current = null;
|
||||
}, [subtitles]);
|
||||
|
||||
return {
|
||||
getCurrentSubtitle,
|
||||
getNearestIndex,
|
||||
shouldAutoPause,
|
||||
};
|
||||
}
|
||||
@@ -1,25 +1,280 @@
|
||||
"use client";
|
||||
|
||||
import { KeyboardEvent, useRef, useState } from "react";
|
||||
import UploadArea from "./UploadArea";
|
||||
import VideoPanel from "./VideoPlayer/VideoPanel";
|
||||
import React from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { Video, FileText } from "lucide-react";
|
||||
import { useSrtPlayer } from "./hooks/useSrtPlayer";
|
||||
import { useSubtitleSync } from "./hooks/useSubtitleSync";
|
||||
import { useKeyboardShortcuts, createSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||
import { useFileUpload } from "./hooks/useFileUpload";
|
||||
import { loadSubtitle } from "./utils/subtitleParser";
|
||||
import VideoPlayer from "./components/compounds/VideoPlayer";
|
||||
import SubtitleArea from "./components/compounds/SubtitleArea";
|
||||
import ControlBar from "./components/compounds/ControlBar";
|
||||
import UploadZone from "./components/compounds/UploadZone";
|
||||
import SeekBar from "./components/atoms/SeekBar";
|
||||
import DarkButton from "@/components/ui/buttons/DarkButton";
|
||||
|
||||
export default function SrtPlayerPage() {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const t = useTranslations("home");
|
||||
const srtT = useTranslations("srt_player");
|
||||
const { uploadVideo, uploadSubtitle } = useFileUpload();
|
||||
const {
|
||||
state,
|
||||
actions,
|
||||
videoRef,
|
||||
videoEventHandlers,
|
||||
subtitleActions
|
||||
} = useSrtPlayer();
|
||||
|
||||
// 字幕同步
|
||||
useSubtitleSync(
|
||||
state.subtitle.data,
|
||||
state.video.currentTime,
|
||||
state.video.isPlaying,
|
||||
state.controls.autoPause,
|
||||
(subtitle) => {
|
||||
if (subtitle) {
|
||||
subtitleActions.setCurrentSubtitle(subtitle.text, subtitle.index);
|
||||
} else {
|
||||
subtitleActions.setCurrentSubtitle("", null);
|
||||
}
|
||||
},
|
||||
(subtitle) => {
|
||||
// 自动暂停逻辑
|
||||
actions.seek(subtitle.start);
|
||||
actions.pause();
|
||||
}
|
||||
);
|
||||
|
||||
// 键盘快捷键
|
||||
const shortcuts = React.useMemo(() =>
|
||||
createSrtPlayerShortcuts(
|
||||
actions.togglePlayPause,
|
||||
actions.nextSubtitle,
|
||||
actions.previousSubtitle,
|
||||
actions.restartSubtitle,
|
||||
actions.toggleAutoPause
|
||||
), [
|
||||
actions.togglePlayPause,
|
||||
actions.nextSubtitle,
|
||||
actions.previousSubtitle,
|
||||
actions.restartSubtitle,
|
||||
actions.toggleAutoPause
|
||||
]
|
||||
);
|
||||
|
||||
useKeyboardShortcuts(shortcuts);
|
||||
|
||||
// 处理字幕文件加载
|
||||
React.useEffect(() => {
|
||||
if (state.subtitle.url) {
|
||||
loadSubtitle(state.subtitle.url)
|
||||
.then(subtitleData => {
|
||||
subtitleActions.setSubtitleData(subtitleData);
|
||||
toast.success(srtT("subtitleLoadSuccess"));
|
||||
})
|
||||
.catch(error => {
|
||||
toast.error(srtT("subtitleLoadFailed") + ": " + error.message);
|
||||
});
|
||||
}
|
||||
}, [srtT, state.subtitle.url, subtitleActions]);
|
||||
|
||||
// 处理进度条变化
|
||||
const handleSeek = React.useCallback((index: number) => {
|
||||
if (state.subtitle.data[index]) {
|
||||
actions.seek(state.subtitle.data[index].start);
|
||||
}
|
||||
}, [state.subtitle.data, actions]);
|
||||
|
||||
// 处理视频上传
|
||||
const handleVideoUpload = React.useCallback(() => {
|
||||
uploadVideo(actions.setVideoUrl, (error) => {
|
||||
toast.error(srtT("videoUploadFailed") + ": " + error.message);
|
||||
});
|
||||
}, [uploadVideo, actions.setVideoUrl, srtT]);
|
||||
|
||||
// 处理字幕上传
|
||||
const handleSubtitleUpload = React.useCallback(() => {
|
||||
uploadSubtitle(actions.setSubtitleUrl, (error) => {
|
||||
toast.error(srtT("subtitleUploadFailed") + ": " + error.message);
|
||||
});
|
||||
}, [uploadSubtitle, actions.setSubtitleUrl, srtT]);
|
||||
|
||||
// 检查是否可以播放
|
||||
const canPlay = state.video.url && state.subtitle.url && state.subtitle.data.length > 0;
|
||||
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||
const [srtUrl, setSrtUrl] = useState<string | null>(null);
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex w-screen pt-8 items-center justify-center"
|
||||
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()}
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* 标题区域 */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-800 mb-2">
|
||||
{t("srtPlayer.name")}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600">
|
||||
{t("srtPlayer.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
{/* 视频播放器区域 */}
|
||||
<div className="aspect-video bg-black relative">
|
||||
{(!state.video.url || !state.subtitle.url || state.subtitle.data.length === 0) && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-75 z-10">
|
||||
<div className="text-center text-white">
|
||||
<p className="text-lg mb-2">
|
||||
{!state.video.url && !state.subtitle.url
|
||||
? srtT("uploadVideoAndSubtitle")
|
||||
: !state.video.url
|
||||
? srtT("uploadVideoFile")
|
||||
: !state.subtitle.url
|
||||
? srtT("uploadSubtitleFile")
|
||||
: srtT("processingSubtitle")
|
||||
}
|
||||
</p>
|
||||
{(!state.video.url || !state.subtitle.url) && (
|
||||
<p className="text-sm text-gray-300">
|
||||
{srtT("needBothFiles")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.video.url && (
|
||||
<VideoPlayer
|
||||
ref={videoRef}
|
||||
src={state.video.url}
|
||||
{...videoEventHandlers}
|
||||
className="w-full h-full"
|
||||
>
|
||||
<div className="w-[80vw] md:w-[45vw] flex items-center flex-col">
|
||||
<VideoPanel videoUrl={videoUrl} srtUrl={srtUrl} ref={videoRef} />
|
||||
<UploadArea setVideoUrl={setVideoUrl} setSrtUrl={setSrtUrl} />
|
||||
{state.subtitle.url && state.subtitle.data.length > 0 && (
|
||||
<SubtitleArea
|
||||
subtitle={state.subtitle.currentText}
|
||||
settings={state.subtitle.settings}
|
||||
className="absolute bottom-0 left-0 right-0 px-4 py-2"
|
||||
/>
|
||||
)}
|
||||
</VideoPlayer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 控制面板 */}
|
||||
<div className="p-3 bg-gray-50 border-t">
|
||||
{/* 上传区域和状态指示器 */}
|
||||
<div className="mb-3">
|
||||
<div className="flex gap-3">
|
||||
<div className={`flex-1 p-2 rounded-lg border-2 transition-all ${state.video.url
|
||||
? 'border-gray-800 bg-gray-100'
|
||||
: 'border-gray-300 bg-white'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Video className="w-5 h-5 text-gray-600" />
|
||||
<div className="text-left">
|
||||
<h3 className="font-semibold text-gray-800 text-sm">{srtT("videoFile")}</h3>
|
||||
<p className="text-xs text-gray-600">
|
||||
{state.video.url ? srtT("uploaded") : srtT("notUploaded")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DarkButton
|
||||
onClick={state.video.url ? undefined : handleVideoUpload}
|
||||
disabled={!!state.video.url}
|
||||
className="px-2 py-1 text-xs"
|
||||
>
|
||||
{state.video.url ? srtT("uploaded") : srtT("upload")}
|
||||
</DarkButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex-1 p-2 rounded-lg border-2 transition-all ${state.subtitle.url
|
||||
? 'border-gray-800 bg-gray-100'
|
||||
: 'border-gray-300 bg-white'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-gray-600" />
|
||||
<div className="text-left">
|
||||
<h3 className="font-semibold text-gray-800 text-sm">{srtT("subtitleFile")}</h3>
|
||||
<p className="text-xs text-gray-600">
|
||||
{state.subtitle.url ? srtT("uploaded") : srtT("notUploaded")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DarkButton
|
||||
onClick={state.subtitle.url ? undefined : handleSubtitleUpload}
|
||||
disabled={!!state.subtitle.url}
|
||||
className="px-2 py-1 text-xs"
|
||||
>
|
||||
{state.subtitle.url ? srtT("uploaded") : srtT("upload")}
|
||||
</DarkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 控制按钮和进度条 */}
|
||||
<div className={`space-y-4 ${canPlay ? '' : 'opacity-50 pointer-events-none'}`}>
|
||||
{/* 控制按钮 */}
|
||||
<ControlBar
|
||||
isPlaying={state.video.isPlaying}
|
||||
onPlayPause={actions.togglePlayPause}
|
||||
onPrevious={actions.previousSubtitle}
|
||||
onNext={actions.nextSubtitle}
|
||||
onRestart={actions.restartSubtitle}
|
||||
playbackRate={state.video.playbackRate}
|
||||
onPlaybackRateChange={actions.setPlaybackRate}
|
||||
autoPause={state.controls.autoPause}
|
||||
onAutoPauseToggle={actions.toggleAutoPause}
|
||||
disabled={!canPlay}
|
||||
className="justify-center"
|
||||
/>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="space-y-2">
|
||||
<SeekBar
|
||||
value={state.subtitle.currentIndex ?? 0}
|
||||
max={Math.max(0, state.subtitle.data.length - 1)}
|
||||
onChange={handleSeek}
|
||||
disabled={!canPlay}
|
||||
className="h-3"
|
||||
/>
|
||||
|
||||
{/* 字幕进度显示 */}
|
||||
<div className="flex justify-between items-center text-sm text-gray-600 px-2">
|
||||
<span>
|
||||
{state.subtitle.currentIndex !== null ?
|
||||
`${state.subtitle.currentIndex + 1}/${state.subtitle.data.length}` :
|
||||
'0/0'
|
||||
}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 播放速度显示 */}
|
||||
<span className="bg-gray-200 px-2 py-1 rounded text-xs">
|
||||
{state.video.playbackRate}x
|
||||
</span>
|
||||
|
||||
{/* 自动暂停状态 */}
|
||||
<span className={`px-2 py-1 rounded text-xs ${state.controls.autoPause
|
||||
? 'bg-gray-800 text-white'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{srtT("autoPauseStatus", { enabled: state.controls.autoPause ? srtT("on") : srtT("off") })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
65
src/app/(features)/srt-player/types/controls.ts
Normal file
65
src/app/(features)/srt-player/types/controls.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export interface ControlState {
|
||||
autoPause: boolean;
|
||||
showShortcuts: boolean;
|
||||
showSettings: boolean;
|
||||
}
|
||||
|
||||
export interface ControlActions {
|
||||
toggleAutoPause: () => void;
|
||||
toggleShortcuts: () => void;
|
||||
toggleSettings: () => void;
|
||||
}
|
||||
|
||||
export interface ControlBarProps {
|
||||
isPlaying: boolean;
|
||||
onPlayPause: () => void;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
onRestart: () => void;
|
||||
playbackRate: number;
|
||||
onPlaybackRateChange: (rate: number) => void;
|
||||
autoPause: boolean;
|
||||
onAutoPauseToggle: () => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface NavigationButtonProps {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface AutoPauseToggleProps {
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
key: string;
|
||||
description: string;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
export interface ShortcutHintProps {
|
||||
shortcuts: KeyboardShortcut[];
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface FileUploadProps {
|
||||
onVideoUpload: (url: string) => void;
|
||||
onSubtitleUpload: (url: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface FileInputProps {
|
||||
accept: string;
|
||||
onFileSelect: (file: File) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
57
src/app/(features)/srt-player/types/player.ts
Normal file
57
src/app/(features)/srt-player/types/player.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export interface VideoState {
|
||||
url: string | null;
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
playbackRate: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export interface VideoControls {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
togglePlayPause: () => void;
|
||||
seek: (time: number) => void;
|
||||
setPlaybackRate: (rate: number) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
restart: () => void;
|
||||
}
|
||||
|
||||
export interface VideoElementProps {
|
||||
src?: string;
|
||||
onTimeUpdate?: (time: number) => void;
|
||||
onLoadedMetadata?: (duration: number) => void;
|
||||
onPlay?: () => void;
|
||||
onPause?: () => void;
|
||||
onEnded?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface PlayButtonProps {
|
||||
isPlaying: boolean;
|
||||
onToggle: () => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface SeekBarProps {
|
||||
value: number;
|
||||
max: number;
|
||||
onChange: (value: number) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface SpeedControlProps {
|
||||
playbackRate: number;
|
||||
onPlaybackRateChange: (rate: number) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface VolumeControlProps {
|
||||
volume: number;
|
||||
onVolumeChange: (volume: number) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
59
src/app/(features)/srt-player/types/subtitle.ts
Normal file
59
src/app/(features)/srt-player/types/subtitle.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export interface SubtitleEntry {
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface SubtitleState {
|
||||
url: string | null;
|
||||
data: SubtitleEntry[];
|
||||
currentText: string;
|
||||
currentIndex: number | null;
|
||||
settings: SubtitleSettings;
|
||||
}
|
||||
|
||||
export interface SubtitleSettings {
|
||||
fontSize: number;
|
||||
backgroundColor: string;
|
||||
textColor: string;
|
||||
position: 'top' | 'center' | 'bottom';
|
||||
fontFamily: string;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export interface SubtitleDisplayProps {
|
||||
subtitle: string;
|
||||
onWordClick?: (word: string) => void;
|
||||
settings?: SubtitleSettings;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface SubtitleTextProps {
|
||||
text: string;
|
||||
onWordClick?: (word: string) => void;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface SubtitleSettingsProps {
|
||||
settings: SubtitleSettings;
|
||||
onSettingsChange: (settings: SubtitleSettings) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface SubtitleControls {
|
||||
next: () => void;
|
||||
previous: () => void;
|
||||
goToIndex: (index: number) => void;
|
||||
toggleAutoPause: () => void;
|
||||
}
|
||||
|
||||
export interface SubtitleSyncProps {
|
||||
subtitles: SubtitleEntry[];
|
||||
currentTime: number;
|
||||
isPlaying: boolean;
|
||||
autoPause: boolean;
|
||||
onSubtitleChange: (subtitle: SubtitleEntry | null) => void;
|
||||
onAutoPauseTrigger?: (subtitle: SubtitleEntry) => void;
|
||||
}
|
||||
99
src/app/(features)/srt-player/utils/subtitleParser.ts
Normal file
99
src/app/(features)/srt-player/utils/subtitleParser.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { SubtitleEntry } from "../types/subtitle";
|
||||
|
||||
export function parseSrt(data: string): SubtitleEntry[] {
|
||||
const lines = data.split(/\r?\n/);
|
||||
const result: SubtitleEntry[] = [];
|
||||
const re = new RegExp(
|
||||
"(\\d{2}:\\d{2}:\\d{2},\\d{3})\\s*-->\\s*(\\d{2}:\\d{2}:\\d{2},\\d{3})",
|
||||
);
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
if (!lines[i].trim()) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
if (i >= lines.length) break;
|
||||
|
||||
const timeMatch = lines[i].match(re);
|
||||
if (!timeMatch) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const start = toSeconds(timeMatch[1]);
|
||||
const end = toSeconds(timeMatch[2]);
|
||||
i++;
|
||||
|
||||
let text = "";
|
||||
while (i < lines.length && lines[i].trim()) {
|
||||
text += lines[i] + "\n";
|
||||
i++;
|
||||
}
|
||||
|
||||
result.push({
|
||||
start,
|
||||
end,
|
||||
text: text.trim(),
|
||||
index: result.length,
|
||||
});
|
||||
i++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getSubtitleIndex(
|
||||
subtitles: SubtitleEntry[],
|
||||
currentTime: number,
|
||||
): number | null {
|
||||
for (let i = 0; i < subtitles.length; i++) {
|
||||
if (currentTime >= subtitles[i].start && currentTime <= subtitles[i].end) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getNearestIndex(
|
||||
subtitles: SubtitleEntry[],
|
||||
currentTime: number,
|
||||
): number | null {
|
||||
for (let i = 0; i < subtitles.length; i++) {
|
||||
const subtitle = subtitles[i];
|
||||
const isBefore = currentTime - subtitle.start >= 0;
|
||||
const isAfter = currentTime - subtitle.end >= 0;
|
||||
|
||||
if (!isBefore || !isAfter) return i - 1;
|
||||
if (isBefore && !isAfter) return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getCurrentSubtitle(
|
||||
subtitles: SubtitleEntry[],
|
||||
currentTime: number,
|
||||
): SubtitleEntry | null {
|
||||
return subtitles.find((subtitle) =>
|
||||
currentTime >= subtitle.start && currentTime <= subtitle.end
|
||||
) || null;
|
||||
}
|
||||
|
||||
function toSeconds(timeStr: string): number {
|
||||
const [h, m, s] = timeStr.replace(",", ".").split(":");
|
||||
return parseFloat(
|
||||
(parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s)).toFixed(3),
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadSubtitle(url: string): Promise<SubtitleEntry[]> {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const data = await response.text();
|
||||
return parseSrt(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load subtitle:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
48
src/app/(features)/srt-player/utils/timeUtils.ts
Normal file
48
src/app/(features)/srt-player/utils/timeUtils.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export function formatTime(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function timeToSeconds(timeStr: string): number {
|
||||
const parts = timeStr.split(':');
|
||||
|
||||
if (parts.length === 3) {
|
||||
// HH:MM:SS format
|
||||
const [h, m, s] = parts;
|
||||
return parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s);
|
||||
} else if (parts.length === 2) {
|
||||
// MM:SS format
|
||||
const [m, s] = parts;
|
||||
return parseInt(m) * 60 + parseFloat(s);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function secondsToTime(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
const ms = Math.floor((seconds % 1) * 1000);
|
||||
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')},${ms.toString().padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
export function clampTime(time: number, min: number = 0, max: number = Infinity): number {
|
||||
return Math.min(Math.max(time, min), max);
|
||||
}
|
||||
|
||||
export function getPlaybackRateOptions(): number[] {
|
||||
return [0.5, 0.7, 1.0, 1.2, 1.5, 2.0];
|
||||
}
|
||||
|
||||
export function getPlaybackRateLabel(rate: number): string {
|
||||
return `${rate}x`;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
TextSpeakerArraySchema,
|
||||
TextSpeakerItemSchema,
|
||||
} from "@/lib/interfaces";
|
||||
import IconClick from "@/components/IconClick";
|
||||
import IconClick from "@/components/ui/buttons/IconClick";
|
||||
import IMAGES from "@/config/images";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import IconClick from "@/components/IconClick";
|
||||
import LightButton from "@/components/ui/buttons/LightButton";
|
||||
import IconClick from "@/components/ui/buttons/IconClick";
|
||||
import IMAGES from "@/config/images";
|
||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||
import {
|
||||
@@ -16,7 +16,7 @@ import { VOICES } from "@/config/locales";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
||||
import { getTTSAudioUrl } from "@/lib/browser/tts";
|
||||
import { genIPA, genLocale } from "@/lib/actions/translatorActions";
|
||||
import { genIPA, genLocale } from "@/lib/server/translatorActions";
|
||||
|
||||
export default function TextSpeakerPage() {
|
||||
const t = useTranslations("text_speaker");
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import Container from "@/components/cards/Container";
|
||||
"use client";
|
||||
|
||||
import LightButton from "@/components/ui/buttons/LightButton";
|
||||
import Container from "@/components/ui/Container";
|
||||
import { TranslationHistorySchema } from "@/lib/interfaces";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Dispatch, useEffect, useState } from "react";
|
||||
import z from "zod";
|
||||
import { Folder } from "../../../../generated/prisma/browser";
|
||||
import { getFoldersByUserId } from "@/lib/actions/services/folderService";
|
||||
import { getFoldersByUserId } from "@/lib/server/services/folderService";
|
||||
import { Folder as Fd } from "lucide-react";
|
||||
import { createPair } from "@/lib/actions/services/pairService";
|
||||
import { createPair } from "@/lib/server/services/pairService";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
interface AddToFolderProps {
|
||||
item: z.infer<typeof TranslationHistorySchema>;
|
||||
@@ -17,19 +19,21 @@ interface AddToFolderProps {
|
||||
}
|
||||
|
||||
const AddToFolder: React.FC<AddToFolderProps> = ({ item, setShow }) => {
|
||||
const session = useSession();
|
||||
const { data: session } = authClient.useSession();
|
||||
const [folders, setFolders] = useState<Folder[]>([]);
|
||||
const t = useTranslations("translator.add_to_folder");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const userId = Number(session.data!.user!.id);
|
||||
if (!session) return;
|
||||
const userId = session.user.id;
|
||||
getFoldersByUserId(userId)
|
||||
.then(setFolders)
|
||||
.then(() => setLoading(false));
|
||||
}, [session.data]);
|
||||
}, [session]);
|
||||
|
||||
if (session.status !== "authenticated") {
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="fixed left-0 top-0 z-50 w-screen h-screen bg-black/50 flex justify-center items-center">
|
||||
<Container className="p-6">
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import Container from "@/components/cards/Container";
|
||||
import Container from "@/components/ui/Container";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Folder } from "../../../../generated/prisma/browser";
|
||||
import { getFoldersByUserId } from "@/lib/actions/services/folderService";
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import { getFoldersByUserId } from "@/lib/server/services/folderService";
|
||||
import LightButton from "@/components/ui/buttons/LightButton";
|
||||
import { Folder as Fd } from "lucide-react";
|
||||
|
||||
interface FolderSelectorProps {
|
||||
setSelectedFolderId: (id: number) => void;
|
||||
userId: number;
|
||||
userId: string;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import IconClick from "@/components/IconClick";
|
||||
import LightButton from "@/components/ui/buttons/LightButton";
|
||||
import IconClick from "@/components/ui/buttons/IconClick";
|
||||
import IMAGES from "@/config/images";
|
||||
import { VOICES } from "@/config/locales";
|
||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||
@@ -10,25 +10,23 @@ import { tlsoPush, tlso } from "@/lib/browser/localStorageOperators";
|
||||
import { getTTSAudioUrl } from "@/lib/browser/tts";
|
||||
import { Plus, Trash } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import z from "zod";
|
||||
import AddToFolder from "./AddToFolder";
|
||||
import {
|
||||
genIPA,
|
||||
genLocale,
|
||||
genTranslation,
|
||||
} from "@/lib/actions/translatorActions";
|
||||
} from "@/lib/server/translatorActions";
|
||||
import { toast } from "sonner";
|
||||
import FolderSelector from "./FolderSelector";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { createPair } from "@/lib/actions/services/pairService";
|
||||
import { createPair } from "@/lib/server/services/pairService";
|
||||
import { shallowEqual } from "@/lib/utils";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export default function TranslatorPage() {
|
||||
const t = useTranslations("translator");
|
||||
|
||||
const session = useSession();
|
||||
|
||||
const taref = useRef<HTMLTextAreaElement>(null);
|
||||
const [lang, setLang] = useState<string>("chinese");
|
||||
const [tresult, setTresult] = useState<string>("");
|
||||
@@ -38,7 +36,7 @@ export default function TranslatorPage() {
|
||||
const { load, play } = useAudioPlayer();
|
||||
const [history, setHistory] = useState<
|
||||
z.infer<typeof TranslationHistorySchema>[]
|
||||
>(tlso.get());
|
||||
>([]);
|
||||
const [showAddToFolder, setShowAddToFolder] = useState(false);
|
||||
const [addToFolderItem, setAddToFolderItem] = useState<z.infer<
|
||||
typeof TranslationHistorySchema
|
||||
@@ -49,6 +47,11 @@ export default function TranslatorPage() {
|
||||
});
|
||||
const [autoSave, setAutoSave] = useState(false);
|
||||
const [autoSaveFolderId, setAutoSaveFolderId] = useState<number | null>(null);
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
useEffect(() => {
|
||||
setHistory(tlso.get());
|
||||
}, []);
|
||||
|
||||
const tts = async (text: string, locale: string) => {
|
||||
if (lastTTS.current.text !== text) {
|
||||
@@ -306,7 +309,7 @@ export default function TranslatorPage() {
|
||||
checked={autoSave}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
if (checked === true && !(session.status === "authenticated")) {
|
||||
if (checked === true && !session) {
|
||||
toast.warning("Please login to enable auto-save");
|
||||
return;
|
||||
}
|
||||
@@ -364,7 +367,7 @@ export default function TranslatorPage() {
|
||||
)}
|
||||
{autoSave && !autoSaveFolderId && (
|
||||
<FolderSelector
|
||||
userId={Number(session.data!.user!.id)}
|
||||
userId={session!.user.id as string}
|
||||
cancel={() => setAutoSave(false)}
|
||||
setSelectedFolderId={(id) => setAutoSaveFolderId(id)}
|
||||
/>
|
||||
|
||||
4
src/app/api/auth/[...all]/route.ts
Normal file
4
src/app/api/auth/[...all]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { auth } from "@/auth";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { POST, GET } = toNextJsHandler(auth);
|
||||
@@ -1,3 +0,0 @@
|
||||
import { handlers } from "../../../../auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
246
src/app/auth/AuthForm.tsx
Normal file
246
src/app/auth/AuthForm.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useActionState, startTransition } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { signInAction, signUpAction, SignUpState } from "@/lib/actions/auth";
|
||||
import Container from "@/components/ui/Container";
|
||||
import Input from "@/components/ui/Input";
|
||||
import LightButton from "@/components/ui/buttons/LightButton";
|
||||
import DarkButton from "@/components/ui/buttons/DarkButton";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
interface AuthFormProps {
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
export default function AuthForm({ redirectTo }: AuthFormProps) {
|
||||
const t = useTranslations("auth");
|
||||
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
|
||||
const [clearSignIn, setClearSignIn] = useState(false);
|
||||
const [clearSignUp, setClearSignUp] = useState(false);
|
||||
|
||||
const [signInState, signInActionForm, isSignInPending] = useActionState(
|
||||
async (prevState: SignUpState | undefined, formData: FormData) => {
|
||||
if (clearSignIn) {
|
||||
setClearSignIn(false);
|
||||
return undefined;
|
||||
}
|
||||
return signInAction(prevState || {}, formData);
|
||||
},
|
||||
undefined
|
||||
);
|
||||
const [signUpState, signUpActionForm, isSignUpPending] = useActionState(
|
||||
async (prevState: SignUpState | undefined, formData: FormData) => {
|
||||
if (clearSignUp) {
|
||||
setClearSignUp(false);
|
||||
return undefined;
|
||||
}
|
||||
return signUpAction(prevState || {}, formData);
|
||||
},
|
||||
undefined
|
||||
);
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const validateForm = (formData: FormData): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
const email = formData.get("email") as string;
|
||||
const password = formData.get("password") as string;
|
||||
const name = formData.get("name") as string;
|
||||
const confirmPassword = formData.get("confirmPassword") as string;
|
||||
|
||||
if (!email) {
|
||||
newErrors.email = t("emailRequired");
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
newErrors.email = t("invalidEmail");
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
newErrors.password = t("passwordRequired");
|
||||
} else if (password.length < 8) {
|
||||
newErrors.password = t("passwordTooShort");
|
||||
}
|
||||
|
||||
if (mode === 'signup') {
|
||||
if (!name) {
|
||||
newErrors.name = t("nameRequired");
|
||||
}
|
||||
|
||||
if (!confirmPassword) {
|
||||
newErrors.confirmPassword = t("confirmPasswordRequired");
|
||||
} else if (password !== confirmPassword) {
|
||||
newErrors.confirmPassword = t("passwordsNotMatch");
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
|
||||
// 基本客户端验证
|
||||
if (!validateForm(formData)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加 redirectTo 到 formData
|
||||
if (redirectTo) {
|
||||
formData.append("redirectTo", redirectTo);
|
||||
}
|
||||
|
||||
// 使用 startTransition 包装 action 调用
|
||||
startTransition(() => {
|
||||
// 根据模式调用相应的 action
|
||||
if (mode === 'signin') {
|
||||
signInActionForm(formData);
|
||||
} else {
|
||||
signUpActionForm(formData);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleGitHubSignIn = async () => {
|
||||
await authClient.signIn.social({
|
||||
provider: "github",
|
||||
callbackURL: redirectTo || "/"
|
||||
});
|
||||
};
|
||||
|
||||
const currentError = mode === 'signin' ? signInState : signUpState;
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4">
|
||||
<Container className="p-8 max-w-md w-full">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">{t(mode === 'signin' ? 'signIn' : 'signUp')}</h1>
|
||||
</div>
|
||||
|
||||
{currentError?.message && (
|
||||
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
|
||||
{currentError.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleFormSubmit} className="space-y-4">
|
||||
{mode === 'signup' && (
|
||||
<div>
|
||||
<Input
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder={t("name")}
|
||||
className="w-full px-3 py-2"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.name}</p>
|
||||
)}
|
||||
{currentError?.errors?.username && (
|
||||
<p className="text-red-500 text-sm mt-1">{currentError.errors.username[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder={t("email")}
|
||||
className="w-full px-3 py-2"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.email}</p>
|
||||
)}
|
||||
{currentError?.errors?.email && (
|
||||
<p className="text-red-500 text-sm mt-1">{currentError.errors.email[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder={t("password")}
|
||||
className="w-full px-3 py-2"
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.password}</p>
|
||||
)}
|
||||
{currentError?.errors?.password && (
|
||||
<p className="text-red-500 text-sm mt-1">{currentError.errors.password[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mode === 'signup' && (
|
||||
<div>
|
||||
<Input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
placeholder={t("confirmPassword")}
|
||||
className="w-full px-3 py-2"
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.confirmPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DarkButton
|
||||
type="submit"
|
||||
className={`w-full py-2 ${isSignInPending || isSignUpPending ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{isSignInPending || isSignUpPending
|
||||
? t("loading")
|
||||
: t(mode === 'signin' ? 'signInButton' : 'signUpButton')
|
||||
}
|
||||
</DarkButton>
|
||||
</form>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">或</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LightButton
|
||||
onClick={handleGitHubSignIn}
|
||||
className="w-full mt-4 py-2 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
{t(mode === 'signin' ? 'signInWithGitHub' : 'signUpWithGitHub')}
|
||||
</LightButton>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode(mode === 'signin' ? 'signup' : 'signin');
|
||||
setErrors({});
|
||||
// 清除服务器端错误状态
|
||||
if (mode === 'signin') {
|
||||
setClearSignIn(true);
|
||||
} else {
|
||||
setClearSignUp(true);
|
||||
}
|
||||
}}
|
||||
className="text-[#35786f] hover:underline"
|
||||
>
|
||||
{mode === 'signin'
|
||||
? `${t("noAccount")} ${t("signUp")}`
|
||||
: `${t("hasAccount")} ${t("signIn")}`
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
src/app/auth/page.tsx
Normal file
20
src/app/auth/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import AuthForm from "./AuthForm";
|
||||
|
||||
export default async function AuthPage(
|
||||
props: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined; }>
|
||||
}
|
||||
) {
|
||||
const searchParams = await props.searchParams;
|
||||
const redirectTo = searchParams.redirect as string | undefined;
|
||||
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (session) {
|
||||
redirect(redirectTo || '/');
|
||||
}
|
||||
|
||||
return <AuthForm redirectTo={redirectTo} />;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Center } from "@/components/Center";
|
||||
import { Center } from "@/components/common/Center";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Folder } from "../../../generated/prisma/browser";
|
||||
import {
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
deleteFolderById,
|
||||
getFoldersWithTotalPairsByUserId,
|
||||
renameFolderById,
|
||||
} from "@/lib/actions/services/folderService";
|
||||
} from "@/lib/server/services/folderService";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -85,7 +85,7 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default function FoldersClient({ userId }: { userId: number }) {
|
||||
export default function FoldersClient({ userId }: { userId: string }) {
|
||||
const t = useTranslations("folders");
|
||||
const [folders, setFolders] = useState<(Folder & { total: number })[]>(
|
||||
[],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import Input from "@/components/Input";
|
||||
import LightButton from "@/components/ui/buttons/LightButton";
|
||||
import Input from "@/components/ui/Input";
|
||||
import { X } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -83,13 +83,19 @@ export default function AddTextPairModal({
|
||||
</div>
|
||||
<div>
|
||||
{t("locale1")}
|
||||
<Input ref={input3Ref} className="w-full"
|
||||
placeholder="en-US"></Input>
|
||||
<Input
|
||||
ref={input3Ref}
|
||||
className="w-full"
|
||||
placeholder="en-US"
|
||||
></Input>
|
||||
</div>
|
||||
<div>
|
||||
{t("locale2")}
|
||||
<Input ref={input4Ref} className="w-full"
|
||||
placeholder="zh-CN"></Input>
|
||||
<Input
|
||||
ref={input4Ref}
|
||||
className="w-full"
|
||||
placeholder="zh-CN"
|
||||
></Input>
|
||||
</div>
|
||||
</div>
|
||||
<LightButton onClick={handleAdd}>{t("add")}</LightButton>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Plus } from "lucide-react";
|
||||
import { Center } from "@/components/Center";
|
||||
import { Center } from "@/components/common/Center";
|
||||
import { useEffect, useState } from "react";
|
||||
import { redirect, useRouter } from "next/navigation";
|
||||
import Container from "@/components/cards/Container";
|
||||
import Container from "@/components/ui/Container";
|
||||
import {
|
||||
createPair,
|
||||
deletePairById,
|
||||
getPairsByFolderId,
|
||||
} from "@/lib/actions/services/pairService";
|
||||
} from "@/lib/server/services/pairService";
|
||||
import AddTextPairModal from "./AddTextPairModal";
|
||||
import TextPairCard from "./TextPairCard";
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import LightButton from "@/components/ui/buttons/LightButton";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export interface TextPair {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Edit, Trash2 } from "lucide-react";
|
||||
import { TextPair } from "./InFolder";
|
||||
import { updatePairById } from "@/lib/actions/services/pairService";
|
||||
import { updatePairById } from "@/lib/server/services/pairService";
|
||||
import { useState } from "react";
|
||||
import UpdateTextPairModal from "./UpdateTextPairModal";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import Input from "@/components/Input";
|
||||
import LightButton from "@/components/ui/buttons/LightButton";
|
||||
import Input from "@/components/ui/Input";
|
||||
import { X } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
import { PairUpdateInput } from "../../../../generated/prisma/models";
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import InFolder from "./InFolder";
|
||||
import { getUserIdByFolderId } from "@/lib/actions/services/folderService";
|
||||
import { getUserIdByFolderId } from "@/lib/server/services/folderService";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
export default async function FoldersPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ folder_id: number }>;
|
||||
params: Promise<{ folder_id: number; }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
const { folder_id } = await params;
|
||||
const t = await getTranslations("folder_id");
|
||||
|
||||
if (!folder_id) {
|
||||
redirect("/folders");
|
||||
}
|
||||
if (!session?.user?.id) redirect(`/login?redirect=/folders/${folder_id}`);
|
||||
if ((await getUserIdByFolderId(Number(folder_id))) !== Number(session.user.id)) {
|
||||
if (!session) redirect(`/auth?redirect=/folders/${folder_id}`);
|
||||
if ((await getUserIdByFolderId(Number(folder_id))) !== session.user.id) {
|
||||
return <p>{t("unauthorized")}</p>;
|
||||
}
|
||||
return <InFolder folderId={Number(folder_id)} />;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { auth } from "@/auth";
|
||||
import FoldersClient from "./FoldersClient";
|
||||
import { redirect } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export default async function FoldersPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect(`/login?redirect=/folders`);
|
||||
return <FoldersClient userId={Number(session.user.id)} />;
|
||||
const session = await auth.api.getSession(
|
||||
{ headers: await headers() }
|
||||
);
|
||||
if (!session) redirect(`/auth?redirect=/folders`);
|
||||
return <FoldersClient userId={session.user.id} />;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import type { Viewport } from "next";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import SessionWrapper from "@/components/SessionWrapper";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
export const viewport: Viewport = {
|
||||
@@ -22,7 +21,6 @@ export default async function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<SessionWrapper>
|
||||
<html lang="en">
|
||||
<body className={`antialiased`}>
|
||||
<NextIntlClientProvider>
|
||||
@@ -32,6 +30,5 @@ export default async function RootLayout({
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
</SessionWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import { Center } from "@/components/Center";
|
||||
import IMAGES from "@/config/images";
|
||||
import { signIn, useSession } from "next-auth/react";
|
||||
import Image from "next/image";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function LoginPage() {
|
||||
const session = useSession();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const t = useTranslations("login");
|
||||
|
||||
useEffect(() => {
|
||||
if (session.status === "authenticated") {
|
||||
router.push(searchParams.get("redirect") || "/");
|
||||
}
|
||||
}, [session.status, router, searchParams]);
|
||||
|
||||
return (
|
||||
<Center>
|
||||
{session.status === "loading" ? (
|
||||
<div>{t("loading")}</div>
|
||||
) : (
|
||||
<LightButton
|
||||
className="flex flex-row p-2 gap-2"
|
||||
onClick={() => signIn("github")}
|
||||
>
|
||||
<Image
|
||||
src={IMAGES.github_mark}
|
||||
alt="GitHub Logo"
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
<span>{t("githubLogin")}</span>
|
||||
</LightButton>
|
||||
)}
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,14 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function HomePage() {
|
||||
const t = useTranslations("home");
|
||||
function TopArea() {
|
||||
return (
|
||||
<div className="bg-[#35786f] text-white w-full min-h-[75dvh] flex justify-center items-center">
|
||||
<div className="mb-16 mx-16 md:mx-0 md:max-w-[60dvw]">
|
||||
<h1 className="text-6xl md:text-9xl mb-8 font-extrabold">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-2xl md:text-5xl font-medium">{t("description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
interface LinkAreaProps {
|
||||
interface LinkAreaProps {
|
||||
href: string;
|
||||
name: string;
|
||||
description: string;
|
||||
color: string;
|
||||
}
|
||||
function LinkArea({ href, name, description, color }: LinkAreaProps) {
|
||||
}
|
||||
|
||||
function LinkArea({ href, name, description, color }: LinkAreaProps) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
@@ -34,11 +21,28 @@ export default function HomePage() {
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
function LinkGrid() {
|
||||
}
|
||||
|
||||
export default async function HomePage() {
|
||||
const t = await getTranslations("home");
|
||||
return (
|
||||
<div className="w-full grid grid-cols-1 grid-rows-6 md:grid-cols-3">
|
||||
<LinkArea
|
||||
<>
|
||||
<div className="bg-[#35786f] text-white w-full min-h-[75dvh] flex justify-center items-center">
|
||||
<div className="mb-16 mx-16 md:mx-0 md:max-w-[60dvw]">
|
||||
<h1 className="text-6xl md:text-9xl mb-8 font-extrabold">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-2xl md:text-5xl font-medium">{t("description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex justify-center font-serif items-center flex-col min-h-64 h-[25vdh]">
|
||||
<p className="text-3xl">{t("fortune.quote")}</p>
|
||||
<cite className="text-[#e9b353] text-xl">{t("fortune.author")}</cite>
|
||||
</div>
|
||||
<div className="bg-[#bbbbbb] w-full flex justify-center items-center flex-col h-32">
|
||||
<div className="w-0 h-0 border-l-40 border-r-40 border-t-30 border-l-transparent border-r-transparent border-t-white"></div>
|
||||
</div>
|
||||
<div className="w-full grid grid-cols-1 grid-rows-6 md:grid-cols-3"><LinkArea
|
||||
href="/translator"
|
||||
name={t("translator.name")}
|
||||
description={t("translator.description")}
|
||||
@@ -75,29 +79,6 @@ export default function HomePage() {
|
||||
color="#cab48a"
|
||||
></LinkArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Fortune() {
|
||||
return (
|
||||
<div className="w-full flex justify-center font-serif items-center flex-col min-h-64 h-[25vdh]">
|
||||
<p className="text-3xl">{t("fortune.quote")}</p>
|
||||
<cite className="text-[#e9b353] text-xl">{t("fortune.author")}</cite>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Explore() {
|
||||
return (
|
||||
<div className="bg-[#bbbbbb] w-full flex justify-center items-center flex-col h-32">
|
||||
<div className="w-0 h-0 border-l-40 border-r-40 border-t-30 border-l-transparent border-r-transparent border-t-white"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<TopArea></TopArea>
|
||||
<Fortune></Fortune>
|
||||
<Explore></Explore>
|
||||
<LinkGrid></LinkGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
20
src/app/profile/LogoutButton.tsx
Normal file
20
src/app/profile/LogoutButton.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import LightButton from "@/components/ui/buttons/LightButton";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function LogoutButton() {
|
||||
const t = useTranslations("profile");
|
||||
const router = useRouter();
|
||||
return <LightButton onClick={async () => {
|
||||
authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
router.push("/auth?redirect=/profile");
|
||||
}
|
||||
}
|
||||
});
|
||||
}}> {t("logout")}</LightButton >;
|
||||
}
|
||||
@@ -1,42 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { useEffect } from "react";
|
||||
import { Center } from "@/components/Center";
|
||||
import Container from "@/components/cards/Container";
|
||||
import LightButton from "@/components/buttons/LightButton";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Center } from "@/components/common/Center";
|
||||
import Container from "@/components/ui/Container";
|
||||
import { auth } from "@/auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
import LogoutButton from "./LogoutButton";
|
||||
|
||||
export default function MePage() {
|
||||
const session = useSession();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations("profile");
|
||||
export default async function ProfilePage() {
|
||||
const t = await getTranslations("profile");
|
||||
|
||||
useEffect(() => {
|
||||
if (session.status !== "authenticated") {
|
||||
router.push(`/login?redirect=${encodeURIComponent(pathname)}`);
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session) {
|
||||
redirect("/auth?redirect=/profile");
|
||||
}
|
||||
}, [session.status, router, pathname]);
|
||||
|
||||
console.log(JSON.stringify(session, null, 2));
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<Container className="p-6">
|
||||
<h1>{t("myProfile")}</h1>
|
||||
{(session.data?.user?.image as string) && (
|
||||
{session.user.image && (
|
||||
<Image
|
||||
width={64}
|
||||
height={64}
|
||||
alt="User Avatar"
|
||||
src={session.data?.user?.image as string}
|
||||
src={session.user.image as string}
|
||||
className="rounded-4xl"
|
||||
></Image>
|
||||
)}
|
||||
<p>{session.data?.user?.name}</p>
|
||||
<p>{t("email", { email: session.data!.user!.email as string })}</p>
|
||||
<LightButton onClick={signOut}>{t("logout")}</LightButton>
|
||||
<p>{session.user.name}</p>
|
||||
<p>{t("email", { email: session.user.email })}</p>
|
||||
<LogoutButton />
|
||||
</Container>
|
||||
</Center>
|
||||
);
|
||||
|
||||
38
src/auth.ts
38
src/auth.ts
@@ -1,30 +1,20 @@
|
||||
import NextAuth from "next-auth";
|
||||
import GitHub from "next-auth/providers/github";
|
||||
import { createUserIfNotExists, getUserIdByEmail } from "./lib/actions/services/userService";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||
import { nextCookies } from "better-auth/next-js";
|
||||
import prisma from "./lib/db";
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
providers: [
|
||||
GitHub({
|
||||
clientId: process.env.GITHUB_CLIENT_ID!,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||
export const auth = betterAuth({
|
||||
database: prismaAdapter(prisma, {
|
||||
provider: "postgresql"
|
||||
}),
|
||||
],
|
||||
|
||||
callbacks: {
|
||||
async signIn({ user }) {
|
||||
if (!user.email) return false;
|
||||
await createUserIfNotExists(user.email, user.name);
|
||||
return true
|
||||
emailAndPassword: {
|
||||
enabled: true
|
||||
},
|
||||
async session({ session }) {
|
||||
if (session.user?.email) {
|
||||
const userId = await getUserIdByEmail(session.user.email);
|
||||
|
||||
if (userId) {
|
||||
session.user.id = userId.toString();
|
||||
}
|
||||
}
|
||||
return session;
|
||||
socialProviders: {
|
||||
github: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID as string,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET as string
|
||||
},
|
||||
},
|
||||
plugins: [nextCookies()]
|
||||
});
|
||||
|
||||
46
src/components/LanguageSettings.tsx
Normal file
46
src/components/LanguageSettings.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import IMAGES from "@/config/images";
|
||||
import IconClick from "./ui/buttons/IconClick";
|
||||
import { useState } from "react";
|
||||
import GhostButton from "./ui/buttons/GhostButton";
|
||||
|
||||
export default function LanguageSettings() {
|
||||
const [showLanguageMenu, setShowLanguageMenu] = useState(false);
|
||||
const handleLanguageClick = () => {
|
||||
setShowLanguageMenu((prev) => !prev);
|
||||
};
|
||||
const setLocale = async (locale: string) => {
|
||||
document.cookie = `locale=${locale}`;
|
||||
window.location.reload();
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<IconClick
|
||||
src={IMAGES.language_white}
|
||||
alt="language"
|
||||
disableOnHoverBgChange={true}
|
||||
onClick={handleLanguageClick}
|
||||
></IconClick>
|
||||
<div className="relative">
|
||||
{showLanguageMenu && (
|
||||
<div>
|
||||
<div className="absolute top-10 right-0 rounded-md shadow-md flex flex-col gap-2">
|
||||
<GhostButton
|
||||
className="w-full bg-[#35786f]"
|
||||
onClick={() => setLocale("en-US")}
|
||||
>
|
||||
English
|
||||
</GhostButton>
|
||||
<GhostButton
|
||||
className="w-full bg-[#35786f]"
|
||||
onClick={() => setLocale("zh-CN")}
|
||||
>
|
||||
中文
|
||||
</GhostButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div></>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useTranslations } from "next-intl";
|
||||
import IconClick from "./IconClick";
|
||||
import IMAGES from "@/config/images";
|
||||
import { useState } from "react";
|
||||
import LightButton from "./buttons/LightButton";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Folder, Home, LoaderCircle } from "lucide-react";
|
||||
|
||||
export function Navbar() {
|
||||
const t = useTranslations("navbar");
|
||||
const [showLanguageMenu, setShowLanguageMenu] = useState(false);
|
||||
const handleLanguageClick = () => {
|
||||
setShowLanguageMenu((prev) => !prev);
|
||||
};
|
||||
const setLocale = async (locale: string) => {
|
||||
document.cookie = `locale=${locale}`;
|
||||
window.location.reload();
|
||||
};
|
||||
const session = useSession();
|
||||
return (
|
||||
<div className="flex justify-between items-center w-full h-16 px-8 bg-[#35786f] text-white">
|
||||
<Link href={"/"} className="text-xl border-b hidden md:block">
|
||||
{t("title")}
|
||||
</Link>
|
||||
<Link className="block md:hidden" href={"/"}>
|
||||
<Home />
|
||||
</Link>
|
||||
<div className="flex gap-4 text-xl justify-center items-center flex-wrap">
|
||||
<Link
|
||||
className="md:hidden block"
|
||||
href="https://github.com/GoddoNebianU/learn-languages"
|
||||
>
|
||||
<Image
|
||||
src={IMAGES.github_mark_white}
|
||||
alt="GitHub"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</Link>
|
||||
<div className="relative">
|
||||
{showLanguageMenu && (
|
||||
<div>
|
||||
<div className="absolute top-10 right-0 rounded-md shadow-md flex flex-col gap-2">
|
||||
<LightButton
|
||||
className="w-full"
|
||||
onClick={() => setLocale("en-US")}
|
||||
>
|
||||
English
|
||||
</LightButton>
|
||||
<LightButton
|
||||
className="w-full"
|
||||
onClick={() => setLocale("zh-CN")}
|
||||
>
|
||||
中文
|
||||
</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<IconClick
|
||||
src={IMAGES.language_white}
|
||||
alt="language"
|
||||
disableOnHoverBgChange={true}
|
||||
onClick={handleLanguageClick}
|
||||
></IconClick>
|
||||
</div>
|
||||
<Link href="/folders" className="md:block hidden">
|
||||
{t("folders")}
|
||||
</Link>
|
||||
<Link href="/folders" className="md:hidden block">
|
||||
<Folder />
|
||||
</Link>
|
||||
{session?.status === "authenticated" && (
|
||||
<div className="flex gap-2">
|
||||
<Link href="/profile">{t("profile")}</Link>
|
||||
</div>
|
||||
)}
|
||||
{session?.status === "unauthenticated" && (
|
||||
<Link href="/login">{t("login")}</Link>
|
||||
)}
|
||||
{session?.status === "loading" && <LoaderCircle />}
|
||||
<Link href="/changelog.txt">{t("about")}</Link>
|
||||
<Link
|
||||
className="hidden md:block"
|
||||
href="https://github.com/GoddoNebianU/learn-languages"
|
||||
>
|
||||
{t("sourceCode")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
export default function SessionWrapper({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
60
src/components/layout/Navbar.tsx
Normal file
60
src/components/layout/Navbar.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import Image from "next/image";
|
||||
import IMAGES from "@/config/images";
|
||||
import { Folder, Home } from "lucide-react";
|
||||
import LanguageSettings from "../LanguageSettings";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import GhostButton from "../ui/buttons/GhostButton";
|
||||
|
||||
export async function Navbar() {
|
||||
const t = await getTranslations("navbar");
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers()
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center w-full h-16 px-8 bg-[#35786f] text-white">
|
||||
<GhostButton href="/" className="text-xl border-b hidden md:block">
|
||||
{t("title")}
|
||||
</GhostButton>
|
||||
<GhostButton className="block md:hidden" href={"/"}>
|
||||
<Home />
|
||||
</GhostButton>
|
||||
<div className="flex text-xl gap-0.5 justify-center items-center flex-wrap">
|
||||
<GhostButton
|
||||
className="md:hidden block"
|
||||
href="https://github.com/GoddoNebianU/learn-languages"
|
||||
>
|
||||
<Image
|
||||
src={IMAGES.github_mark_white}
|
||||
alt="GitHub"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</GhostButton>
|
||||
<LanguageSettings />
|
||||
<GhostButton href="/folders" className="md:block hidden">
|
||||
{t("folders")}
|
||||
</GhostButton>
|
||||
<GhostButton href="/folders" className="md:hidden block">
|
||||
<Folder />
|
||||
</GhostButton>
|
||||
{
|
||||
(() => {
|
||||
return session &&
|
||||
<GhostButton href="/profile">{t("profile")}</GhostButton>
|
||||
|| <GhostButton href="/auth">{t("sign_in")}</GhostButton>;
|
||||
|
||||
})()
|
||||
}
|
||||
<GhostButton
|
||||
className="hidden md:block"
|
||||
href="https://github.com/GoddoNebianU/learn-languages"
|
||||
>
|
||||
{t("sourceCode")}
|
||||
</GhostButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
interface ContainerProps {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
@@ -6,18 +6,21 @@ export default function DarkButton({
|
||||
selected,
|
||||
children,
|
||||
type = "button",
|
||||
disabled
|
||||
}: {
|
||||
onClick?: () => void;
|
||||
onClick?: (() => void) | undefined;
|
||||
className?: string;
|
||||
selected?: boolean;
|
||||
children?: React.ReactNode;
|
||||
type?: ButtonType;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<PlainButton
|
||||
onClick={onClick}
|
||||
className={`hover:bg-gray-600 text-white ${selected ? "bg-gray-600" : "bg-gray-800"} ${className}`}
|
||||
className={`hover:bg-gray-100 text-black ${selected ? "bg-gray-100" : "bg-white"} ${className}`}
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</PlainButton>
|
||||
27
src/components/ui/buttons/GhostButton.tsx
Normal file
27
src/components/ui/buttons/GhostButton.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export type ButtonType = "button" | "submit" | "reset" | undefined;
|
||||
|
||||
export default function GhostButton({
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
type = "button",
|
||||
href
|
||||
}: {
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
type?: ButtonType;
|
||||
href?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`rounded hover:bg-black/30 p-2 ${className}`}
|
||||
type={type}
|
||||
>
|
||||
{href ? <Link href={href}>{children}</Link> : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -6,18 +6,21 @@ export default function LightButton({
|
||||
selected,
|
||||
children,
|
||||
type = "button",
|
||||
disabled
|
||||
}: {
|
||||
onClick?: () => void;
|
||||
onClick?: (() => void) | undefined;
|
||||
className?: string;
|
||||
selected?: boolean;
|
||||
children?: React.ReactNode;
|
||||
type?: ButtonType;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<PlainButton
|
||||
onClick={onClick}
|
||||
className={`hover:bg-gray-200 text-gray-800 ${selected ? "bg-gray-200" : "bg-white"} ${className}`}
|
||||
className={`hover:bg-gray-100 text-black ${selected ? "bg-gray-100" : "bg-white"} ${className}`}
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</PlainButton>
|
||||
@@ -5,17 +5,20 @@ export default function PlainButton({
|
||||
className,
|
||||
children,
|
||||
type = "button",
|
||||
disabled
|
||||
}: {
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
type?: ButtonType;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-2 py-1 rounded shadow font-bold hover:cursor-pointer ${className}`}
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
127
src/lib/actions/auth.ts
Normal file
127
src/lib/actions/auth.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export interface SignUpFormData {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface SignUpState {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
errors?: {
|
||||
username?: string[];
|
||||
email?: string[];
|
||||
password?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export async function signUpAction(prevState: SignUpState, formData: FormData) {
|
||||
const email = formData.get("email") as string;
|
||||
const name = formData.get("name") as string;
|
||||
const password = formData.get("password") as string;
|
||||
const redirectTo = formData.get("redirectTo") as string;
|
||||
|
||||
// 服务器端验证
|
||||
const errors: SignUpState['errors'] = {};
|
||||
|
||||
if (!email) {
|
||||
errors.email = ["邮箱是必填项"];
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
errors.email = ["请输入有效的邮箱地址"];
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
errors.username = ["姓名是必填项"];
|
||||
} else if (name.length < 2) {
|
||||
errors.username = ["姓名至少需要2个字符"];
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
errors.password = ["密码是必填项"];
|
||||
} else if (password.length < 8) {
|
||||
errors.password = ["密码至少需要8个字符"];
|
||||
}
|
||||
|
||||
// 如果有验证错误,返回错误状态
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: "请修正表单中的错误",
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await auth.api.signUpEmail({
|
||||
body: {
|
||||
email,
|
||||
password,
|
||||
name
|
||||
}
|
||||
});
|
||||
|
||||
redirect(redirectTo || "/");
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: "注册失败,请稍后再试"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function signInAction(prevState: SignUpState, formData: FormData) {
|
||||
const email = formData.get("email") as string;
|
||||
const password = formData.get("password") as string;
|
||||
const redirectTo = formData.get("redirectTo") as string;
|
||||
|
||||
// 服务器端验证
|
||||
const errors: SignUpState['errors'] = {};
|
||||
|
||||
if (!email) {
|
||||
errors.email = ["邮箱是必填项"];
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
errors.email = ["请输入有效的邮箱地址"];
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
errors.password = ["密码是必填项"];
|
||||
}
|
||||
|
||||
// 如果有验证错误,返回错误状态
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: "请修正表单中的错误",
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await auth.api.signInEmail({
|
||||
body: {
|
||||
email,
|
||||
password,
|
||||
}
|
||||
});
|
||||
|
||||
redirect(redirectTo || "/");
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: "登录失败,请检查您的邮箱和密码"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function signOutAction() {
|
||||
await auth.api.signOut({
|
||||
headers: await headers()
|
||||
});
|
||||
|
||||
redirect("/auth");
|
||||
}
|
||||
5
src/lib/auth-client.ts
Normal file
5
src/lib/auth-client.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.BETTER_AUTH_URL as string
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
TranslationHistoryArraySchema,
|
||||
TranslationHistorySchema,
|
||||
@@ -14,7 +16,7 @@ export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
|
||||
try {
|
||||
const item = globalThis.localStorage.getItem(key);
|
||||
|
||||
if (!item) return [];
|
||||
if (!item) return [] as z.infer<T>;
|
||||
|
||||
const rawData = JSON.parse(item) as z.infer<T>;
|
||||
const result = schema.safeParse(rawData);
|
||||
@@ -26,11 +28,11 @@ export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
|
||||
"Invalid data structure in localStorage:",
|
||||
result.error,
|
||||
);
|
||||
return [];
|
||||
return [] as z.infer<T>;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse ${key} data:`, e);
|
||||
return [];
|
||||
return [] as z.infer<T>;
|
||||
}
|
||||
},
|
||||
set: (data: z.infer<T>) => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { FolderCreateInput, FolderUpdateInput } from "../../../../generated/prisma/models";
|
||||
import prisma from "../../db";
|
||||
|
||||
export async function getFoldersByUserId(userId: number) {
|
||||
export async function getFoldersByUserId(userId: string) {
|
||||
const folders = await prisma.folder.findMany({
|
||||
where: {
|
||||
userId: userId,
|
||||
@@ -23,7 +23,7 @@ export async function renameFolderById(id: number, newName: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getFoldersWithTotalPairsByUserId(userId: number) {
|
||||
export async function getFoldersWithTotalPairsByUserId(userId: string) {
|
||||
const folders = await prisma.folder.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
127
src/lib/utils.ts
127
src/lib/utils.ts
@@ -20,3 +20,130 @@ export function shallowEqual<T extends object>(obj1: T, obj2: T): boolean {
|
||||
|
||||
return true;
|
||||
}
|
||||
export class SeededRandom {
|
||||
private seed: number;
|
||||
private readonly m: number = 0x80000000; // 2^31
|
||||
private readonly a: number = 1103515245;
|
||||
private readonly c: number = 12345;
|
||||
|
||||
constructor(seed?: number) {
|
||||
this.seed = seed || Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成0-1之间的随机数
|
||||
* @returns 0到1之间的随机浮点数
|
||||
*/
|
||||
next(): number {
|
||||
this.seed = (this.a * this.seed + this.c) % this.m;
|
||||
return this.seed / (this.m - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成指定范围的随机整数
|
||||
* @param min 最小值(包含)
|
||||
* @param max 最大值(包含)
|
||||
* @returns [min, max] 范围内的随机整数
|
||||
*/
|
||||
nextInt(min: number, max: number): number {
|
||||
if (min > max) {
|
||||
throw new Error('min must be less than or equal to max');
|
||||
}
|
||||
return Math.floor(this.next() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成指定范围的随机浮点数
|
||||
* @param min 最小值(包含)
|
||||
* @param max 最大值(不包含)
|
||||
* @returns [min, max) 范围内的随机浮点数
|
||||
*/
|
||||
nextFloat(min: number, max: number): number {
|
||||
if (min >= max) {
|
||||
throw new Error('min must be less than max');
|
||||
}
|
||||
return this.next() * (max - min) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成固定长度的随机数序列
|
||||
* @param length 序列长度
|
||||
* @param min 最小值
|
||||
* @param max 最大值
|
||||
* @param type 生成类型:'integer' 或 'float'
|
||||
* @returns 随机数数组
|
||||
*/
|
||||
generateSequence(
|
||||
length: number,
|
||||
min: number = 0,
|
||||
max: number = 1,
|
||||
type: 'integer' | 'float' = 'integer'
|
||||
): number[] {
|
||||
const sequence: number[] = [];
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (type === 'integer') {
|
||||
sequence.push(this.nextInt(min, max));
|
||||
} else {
|
||||
sequence.push(this.nextFloat(min, max));
|
||||
}
|
||||
}
|
||||
|
||||
return sequence;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置种子
|
||||
* @param newSeed 新的种子值
|
||||
*/
|
||||
reset(newSeed?: number): void {
|
||||
this.seed = newSeed || Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前种子值
|
||||
* @returns 当前种子
|
||||
*/
|
||||
getSeed(): number {
|
||||
return this.seed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机布尔值
|
||||
* @param probability 为 true 的概率,默认 0.5
|
||||
* @returns 随机布尔值
|
||||
*/
|
||||
nextBoolean(probability: number = 0.5): boolean {
|
||||
if (probability < 0 || probability > 1) {
|
||||
throw new Error('probability must be between 0 and 1');
|
||||
}
|
||||
return this.next() < probability;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数组中随机选择元素
|
||||
* @param array 源数组
|
||||
* @returns 随机选择的元素
|
||||
*/
|
||||
choice<T>(array: T[]): T {
|
||||
if (array.length === 0) {
|
||||
throw new Error('array cannot be empty');
|
||||
}
|
||||
const index = this.nextInt(0, array.length - 1);
|
||||
return array[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* 打乱数组(Fisher-Yates 洗牌算法)
|
||||
* @param array 要打乱的数组
|
||||
* @returns 打乱后的新数组
|
||||
*/
|
||||
shuffle<T>(array: T[]): T[] {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = this.nextInt(0, i);
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user