Compare commits

...

13 Commits

Author SHA1 Message Date
d8f0117359 update react
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-12 17:11:21 +08:00
2c84ab4370 update README.md
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-12 16:47:57 +08:00
e17437a5ad 修复登录问题
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-12 16:45:50 +08:00
ff0954a413 ...
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-12 16:31:48 +08:00
573b1cb7e5 ...
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-12 13:41:00 +08:00
605c57f8bb 重构逐句视频播放器
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-12 13:37:00 +08:00
b69e168558 ...
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-11 21:36:45 +08:00
65aacc1582 update README.md
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-11 20:21:11 +08:00
572534a009 clean code
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-11 17:14:37 +08:00
0d251a7e68 优化navbar链接样式
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-11 17:13:35 +08:00
e845c4abb7 ...
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-10 18:56:53 +08:00
881d9ca921 将next-auth替换为better-auth 2025-12-10 17:54:14 +08:00
db96b86e65 ... 2025-12-05 14:03:08 +08:00
86 changed files with 5097 additions and 970 deletions

View File

@@ -3,10 +3,10 @@ ZHIPU_API_KEY=
ZHIPU_MODEL_NAME= ZHIPU_MODEL_NAME=
// Auth // Auth
AUTH_SECRET= BETTER_AUTH_SECRET=
BETTER_AUTH_URL=
GITHUB_CLIENT_ID= GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET= GITHUB_CLIENT_SECRET=
NEXTAUTH_URL=
// Database // Database
DATABASE_URL= DATABASE_URL=

10
.vscode/settings.json vendored Normal file
View 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
View File

@@ -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 - **智能翻译工具** - 支持多语言互译,包含国际音标(IPA)标注
npm run dev - **文本语音合成** - 将文本转换为自然语音,提高发音学习效果
# or - **SRT字幕播放器** - 结合视频字幕学习,支持多种字幕格式
yarn dev - **字母学习模块** - 针对初学者的字母和发音基础学习
# or - **记忆强化工具** - 通过科学记忆法巩固学习内容
pnpm dev - **个人学习空间** - 用户可以创建、管理和组织自己的学习资料
# or
bun dev ## 🛠 技术栈
### 前端框架
- **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. 2. 安装依赖
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. ```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!** 🌟

View File

@@ -1,25 +1,16 @@
import { dirname } from "path"; import { defineConfig, globalIgnores } from 'eslint/config'
import { fileURLToPath } from "url"; import nextVitals from 'eslint-config-next/core-web-vitals'
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url); const eslintConfig = defineConfig([
const __dirname = dirname(__filename); ...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({ export default eslintConfig
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;

View File

@@ -10,7 +10,11 @@
"hideLetter": "Hide Letter", "hideLetter": "Hide Letter",
"showLetter": "Show Letter", "showLetter": "Show Letter",
"hideIPA": "Hide IPA", "hideIPA": "Hide IPA",
"showIPA": "Show IPA" "showIPA": "Show IPA",
"roman": "Romanization",
"letter": "Letter",
"random": "Random Mode",
"randomNext": "Random Next"
}, },
"folders": { "folders": {
"title": "Folders", "title": "Folders",
@@ -82,6 +86,31 @@
"loading": "Loading...", "loading": "Loading...",
"githubLogin": "GitHub Login" "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": { "memorize": {
"folder_selector": { "folder_selector": {
"selectFolder": "Select a folder", "selectFolder": "Select a folder",
@@ -103,9 +132,8 @@
}, },
"navbar": { "navbar": {
"title": "learn-languages", "title": "learn-languages",
"about": "About",
"sourceCode": "GitHub", "sourceCode": "GitHub",
"login": "Login", "sign_in": "Sign In",
"profile": "Profile", "profile": "Profile",
"folders": "Folders" "folders": "Folders"
}, },
@@ -122,7 +150,43 @@
"previous": "Previous", "previous": "Previous",
"next": "Next", "next": "Next",
"restart": "Restart", "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": { "text_speaker": {
"generateIPA": "Generate IPA", "generateIPA": "Generate IPA",

View File

@@ -10,7 +10,11 @@
"hideLetter": "隐藏字母", "hideLetter": "隐藏字母",
"showLetter": "显示字母", "showLetter": "显示字母",
"hideIPA": "隐藏IPA", "hideIPA": "隐藏IPA",
"showIPA": "显示IPA" "showIPA": "显示IPA",
"roman": "罗马音",
"letter": "字母",
"random": "随机模式",
"randomNext": "随机下一个"
}, },
"folders": { "folders": {
"title": "文件夹", "title": "文件夹",
@@ -82,6 +86,30 @@
"loading": "加载中...", "loading": "加载中...",
"githubLogin": "GitHub登录" "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": { "memorize": {
"choose": { "choose": {
"back": "返回", "back": "返回",
@@ -107,9 +135,8 @@
}, },
"navbar": { "navbar": {
"title": "学语言", "title": "学语言",
"about": "关于",
"sourceCode": "源码", "sourceCode": "源码",
"login": "登录", "sign_in": "登录",
"profile": "个人资料", "profile": "个人资料",
"folders": "文件夹" "folders": "文件夹"
}, },
@@ -119,6 +146,7 @@
"logout": "退出登录" "logout": "退出登录"
}, },
"srt_player": { "srt_player": {
"upload": "上传",
"uploadVideo": "上传视频", "uploadVideo": "上传视频",
"uploadSubtitle": "上传字幕", "uploadSubtitle": "上传字幕",
"pause": "暂停", "pause": "暂停",
@@ -126,7 +154,42 @@
"previous": "上句", "previous": "上句",
"next": "下句", "next": "下句",
"restart": "句首", "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": { "text_speaker": {
"generateIPA": "生成IPA", "generateIPA": "生成IPA",

View File

@@ -14,36 +14,43 @@
"@prisma/adapter-pg": "^7.1.0", "@prisma/adapter-pg": "^7.1.0",
"@prisma/client": "^7.1.0", "@prisma/client": "^7.1.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-auth": "^1.4.6",
"dotenv": "^17.2.3",
"edge-tts-universal": "^1.3.3", "edge-tts-universal": "^1.3.3",
"lucide-react": "^0.553.0", "lucide-react": "^0.561.0",
"next": "16.0.7", "next": "16.0.10",
"next-auth": "5.0.0-beta.30",
"next-intl": "^4.5.8", "next-intl": "^4.5.8",
"pg": "^8.16.3", "pg": "^8.16.3",
"react": "19.2.1", "react": "19.2.3",
"react-dom": "19.2.1", "react-dom": "19.2.3",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"unstorage": "^1.17.3", "unstorage": "^1.17.3",
"zod": "^3.25.76" "zod": "^4.1.13"
}, },
"devDependencies": { "devDependencies": {
"@better-auth/cli": "^1.4.6",
"@eslint/eslintrc": "^3.3.3", "@eslint/eslintrc": "^3.3.3",
"@tailwindcss/postcss": "^4.1.17", "@tailwindcss/postcss": "^4.1.18",
"@types/bcryptjs": "^2.4.6", "@types/node": "^25.0.1",
"@types/node": "^20.19.25",
"@types/react": "19.2.7", "@types/react": "19.2.7",
"@types/react-dom": "19.2.3", "@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", "babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-config-next": "16.0.7", "eslint-config-next": "16.0.10",
"prisma": "^6.19.0", "eslint-plugin-react": "^7.37.5",
"tailwindcss": "^4.1.17", "prisma": "^7.1.0",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
"pnpm": { "pnpm": {
"overrides": { "overrides": {
"@types/react": "19.2.7", "@types/react": "19.2.7",
"@types/react-dom": "19.2.3" "@types/react-dom": "19.2.3"
} },
"ignoredBuiltDependencies": [
"@prisma/client"
]
} }
} }

2022
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,6 @@ export default defineConfig({
migrations: { migrations: {
path: "prisma/migrations", path: "prisma/migrations",
}, },
engine: "classic",
datasource: { datasource: {
url: env("DATABASE_URL"), url: env("DATABASE_URL"),
}, },

View File

@@ -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;

View 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;

View File

@@ -1,3 +1,4 @@
generator client { generator client {
provider = "prisma-client" provider = "prisma-client"
output = "../generated/prisma" output = "../generated/prisma"
@@ -29,7 +30,7 @@ model Pair {
model Folder { model Folder {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
userId Int @map("user_id") userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@@ -41,13 +42,65 @@ model Folder {
} }
model User { model User {
id Int @id @default(autoincrement()) id String @id
email String @unique
name String name String
createdAt DateTime @default(now()) @map("created_at") email String
updatedAt DateTime @updatedAt @map("updated_at") emailVerified Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
accounts Account[]
folders Folder[] 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")
} }

View File

@@ -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 更新了单词板,单词不再会重叠

View 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>
);
}

View File

@@ -1,11 +1,12 @@
import LightButton from "@/components/buttons/LightButton"; import LightButton from "@/components/ui/buttons/LightButton";
import IconClick from "@/components/IconClick"; import IconClick from "@/components/ui/buttons/IconClick";
import IMAGES from "@/config/images"; import IMAGES from "@/config/images";
import { Letter, SupportedAlphabets } from "@/lib/interfaces"; import { Letter, SupportedAlphabets } from "@/lib/interfaces";
import { import {
Dispatch, Dispatch,
KeyboardEvent, KeyboardEvent,
SetStateAction, SetStateAction,
useCallback,
useEffect, useEffect,
useState, useState,
} from "react"; } from "react";
@@ -19,25 +20,26 @@ export default function MemoryCard({
setChosenAlphabet: Dispatch<SetStateAction<SupportedAlphabets | null>>; setChosenAlphabet: Dispatch<SetStateAction<SupportedAlphabets | null>>;
}) { }) {
const t = useTranslations("alphabet"); const t = useTranslations("alphabet");
const [index, setIndex] = useState( const [index, setIndex] = useState(() => alphabet.length > 0 ? Math.floor(Math.random() * alphabet.length) : 0);
Math.floor(Math.random() * alphabet.length),
);
const [more, setMore] = useState(false); const [more, setMore] = useState(false);
const [ipaDisplay, setIPADisplay] = useState(true); const [ipaDisplay, setIPADisplay] = useState(true);
const [letterDisplay, setLetterDisplay] = useState(true); const [letterDisplay, setLetterDisplay] = useState(true);
const refresh = useCallback(() => {
if (alphabet.length > 0) {
setIndex(Math.floor(Math.random() * alphabet.length));
}
}, [alphabet.length]);
useEffect(() => { useEffect(() => {
const handleKeydown = (e: globalThis.KeyboardEvent) => { const handleKeydown = (e: globalThis.KeyboardEvent) => {
if (e.key === " ") refresh(); if (e.key === " ") refresh();
}; };
document.addEventListener("keydown", handleKeydown); document.addEventListener("keydown", handleKeydown);
return () => document.removeEventListener("keydown", handleKeydown); return () => document.removeEventListener("keydown", handleKeydown);
}); }, [refresh]);
const letter = alphabet[index]; const letter = alphabet[index] || { letter: "", letter_name_ipa: "", letter_sound_ipa: "" };
const refresh = () => {
setIndex(Math.floor(Math.random() * alphabet.length));
};
return ( return (
<div <div
className="w-full flex justify-center items-center" className="w-full flex justify-center items-center"

View File

@@ -1,48 +1,37 @@
"use client"; "use client";
import LightButton from "@/components/buttons/LightButton"; import { useState, useEffect } from "react";
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
import { useEffect, useState } from "react";
import MemoryCard from "./MemoryCard";
import { useTranslations } from "next-intl"; 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() { export default function Alphabet() {
const t = useTranslations("alphabet"); const t = useTranslations("alphabet");
const [chosenAlphabet, setChosenAlphabet] = const [chosenAlphabet, setChosenAlphabet] = useState<SupportedAlphabets | null>(null);
useState<SupportedAlphabets | null>(null); const [alphabetData, setAlphabetData] = useState<Letter[] | null>(null);
const [alphabetData, setAlphabetData] = useState< const [loadingState, setLoadingState] = useState<"idle" | "loading" | "success" | "error">("idle");
Record<SupportedAlphabets, Letter[] | null>
>({
japanese: null,
english: null,
esperanto: null,
uyghur: null,
});
const [loadingState, setLoadingState] = useState<
"idle" | "loading" | "success" | "error"
>("idle");
useEffect(() => { useEffect(() => {
if (chosenAlphabet && !alphabetData[chosenAlphabet]) { const loadAlphabetData = async () => {
if (chosenAlphabet && !alphabetData) {
try {
setLoadingState("loading"); setLoadingState("loading");
fetch("/alphabets/" + chosenAlphabet + ".json") const res = await fetch("/alphabets/" + chosenAlphabet + ".json");
.then((res) => {
if (!res.ok) throw new Error("Network response was not ok"); if (!res.ok) throw new Error("Network response was not ok");
return res.json();
}) const obj = await res.json();
.then((obj) => { setAlphabetData(obj as Letter[]);
setAlphabetData((prev) => ({
...prev,
[chosenAlphabet]: obj as Letter[],
}));
setLoadingState("success"); setLoadingState("success");
}) } catch (error) {
.catch(() => {
setLoadingState("error"); setLoadingState("error");
});
} }
}
};
loadAlphabetData();
}, [chosenAlphabet, alphabetData]); }, [chosenAlphabet, alphabetData]);
useEffect(() => { useEffect(() => {
@@ -50,48 +39,106 @@ export default function Alphabet() {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setLoadingState("idle"); setLoadingState("idle");
setChosenAlphabet(null); setChosenAlphabet(null);
setAlphabetData(null);
}, 2000); }, 2000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [loadingState]); }, [loadingState]);
if (!chosenAlphabet) // 语言选择界面
if (!chosenAlphabet) {
return ( return (
<> <div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex flex-col items-center justify-center px-4">
<div className="border border-gray-200 m-4 mt-4 flex flex-col justify-center items-center p-4 rounded-2xl gap-2"> <Container className="p-8 max-w-2xl w-full text-center">
<span className="text-2xl md:text-3xl">{t("chooseCharacters")}</span> <h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
<div className="flex gap-1 flex-wrap"> {t("chooseCharacters")}
<LightButton onClick={() => setChosenAlphabet("japanese")}> </h1>
{t("japanese")} <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>
<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>
<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>
<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> </LightButton>
</div> </div>
</Container>
</div> </div>
</>
); );
}
// 加载状态
if (loadingState === "loading") { if (loadingState === "loading") {
return t("loading");
}
if (loadingState === "error") {
return t("loadFailed");
}
if (loadingState === "success" && alphabetData[chosenAlphabet]) {
return ( return (
<> <div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center">
<MemoryCard <Container className="p-8 text-center">
alphabet={alphabetData[chosenAlphabet]} <div className="text-2xl text-gray-600">{t("loading")}</div>
setChosenAlphabet={setChosenAlphabet} </Container>
></MemoryCard> </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; return null;
} }

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import Container from "@/components/cards/Container"; import Container from "@/components/ui/Container";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Center } from "@/components/Center"; import { Center } from "@/components/common/Center";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { Folder } from "../../../../generated/prisma/browser"; import { Folder } from "../../../../generated/prisma/browser";

View File

@@ -1,13 +1,13 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useState } from "react";
import LightButton from "@/components/buttons/LightButton"; import LightButton from "@/components/ui/buttons/LightButton";
import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { getTTSAudioUrl } from "@/lib/browser/tts"; import { getTTSAudioUrl } from "@/lib/browser/tts";
import { VOICES } from "@/config/locales"; import { VOICES } from "@/config/locales";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import localFont from "next/font/local"; import localFont from "next/font/local";
import { isNonNegativeInteger } from "@/lib/utils"; import { isNonNegativeInteger, SeededRandom } from "@/lib/utils";
import { Pair } from "../../../../generated/prisma/browser"; import { Pair } from "../../../../generated/prisma/browser";
const myFont = localFont({ const myFont = localFont({
@@ -27,20 +27,16 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
const [show, setShow] = useState<"question" | "answer">("question"); const [show, setShow] = useState<"question" | "answer">("question");
const { load, play } = useAudioPlayer(); const { load, play } = useAudioPlayer();
const [disorderedTextPairs, setDisorderedTextPairs] = useState<Pair[]>( if (textPairs.length === 0) {
[], return <p>{t("noTextPairs")}</p>;
);
useEffect(() => {
setDisorderedTextPairs(textPairs.toSorted(() => Math.random() - 0.5));
}, [textPairs]);
const getTextPairs = () => {
if (disorder) {
return disorderedTextPairs;
} }
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 ( return (
<> <>

View File

@@ -1,51 +1,51 @@
"use server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { import {
getFoldersWithTotalPairsByUserId, getFoldersWithTotalPairsByUserId,
getUserIdByFolderId, getUserIdByFolderId,
} from "@/lib/actions/services/folderService"; } from "@/lib/server/services/folderService";
import { isNonNegativeInteger } from "@/lib/utils"; import { isNonNegativeInteger } from "@/lib/utils";
import FolderSelector from "./FolderSelector"; import FolderSelector from "./FolderSelector";
import Memorize from "./Memorize"; import Memorize from "./Memorize";
import { getPairsByFolderId } from "@/lib/actions/services/pairService"; import { getPairsByFolderId } from "@/lib/server/services/pairService";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers";
export default async function MemorizePage({ export default async function MemorizePage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ folder_id?: string }>; searchParams: Promise<{ folder_id?: string; }>;
}) { }) {
const session = await auth(); const session = await auth.api.getSession({ headers: await headers() });
const userId = session?.user?.id; 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 t = await getTranslations("memorize.page");
const tParam = (await searchParams).folder_id;
const folder_id = tParam const folder_id = tParam
? isNonNegativeInteger(tParam) ? isNonNegativeInteger(tParam)
? parseInt(tParam) ? parseInt(tParam)
: null : null
: null; : null;
if (!userId) {
redirect(
`/login?redirect=/memorize${folder_id ? `?folder_id=${folder_id}` : ""}`,
);
}
const uid = Number(userId);
if (!folder_id) { if (!folder_id) {
return ( return (
<FolderSelector <FolderSelector
folders={await getFoldersWithTotalPairsByUserId(uid)} folders={await getFoldersWithTotalPairsByUserId(session.user.id)}
/> />
); );
} }
const owner = await getUserIdByFolderId(folder_id); const owner = await getUserIdByFolderId(folder_id);
if (owner !== uid) { if (owner !== session.user.id) {
return <p>{t("unauthorized")}</p>; return <p>{t("unauthorized")}</p>;
} }

View File

@@ -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>
);
}

View File

@@ -1,6 +1,6 @@
import { useState, useRef, forwardRef, useEffect } from "react"; import { useState, useRef, forwardRef, useEffect, useCallback } from "react";
import SubtitleDisplay from "./SubtitleDisplay"; import SubtitleDisplay from "./SubtitleDisplay";
import LightButton from "@/components/buttons/LightButton"; import LightButton from "@/components/ui/buttons/LightButton";
import { getIndex, parseSrt, getNearistIndex } from "../subtitle"; import { getIndex, parseSrt, getNearistIndex } from "../subtitle";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -20,7 +20,7 @@ const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
const [spanText, setSpanText] = useState<string>(""); const [spanText, setSpanText] = useState<string>("");
const [subtitle, setSubtitle] = useState<string>(""); const [subtitle, setSubtitle] = useState<string>("");
const parsedSrtRef = useRef< const parsedSrtRef = useRef<
{ start: number; end: number; text: string }[] | null { start: number; end: number; text: string; }[] | null
>(null); >(null);
const rafldRef = useRef<number>(0); const rafldRef = useRef<number>(0);
const ready = useRef({ const ready = useRef({
@@ -31,7 +31,7 @@ const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
}, },
}); });
const togglePlayPause = () => { const togglePlayPause = useCallback(() => {
if (!videoUrl) return; if (!videoUrl) return;
const video = videoRef.current; const video = videoRef.current;
@@ -42,7 +42,7 @@ const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
video.pause(); video.pause();
} }
setIsPlaying(!video.paused); setIsPlaying(!video.paused);
} }, [videoRef, videoUrl]);
useEffect(() => { useEffect(() => {
const handleKeyDownEvent = (e: globalThis.KeyboardEvent) => { const handleKeyDownEvent = (e: globalThis.KeyboardEvent) => {

View 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>
</>
);
}

View File

@@ -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>
);
}

View 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%)`
}}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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;

View 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,
};
}

View 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,
},
];
}

View 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>;

View 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,
};
}

View File

@@ -1,25 +1,280 @@
"use client"; "use client";
import { KeyboardEvent, useRef, useState } from "react"; import React from "react";
import UploadArea from "./UploadArea"; import { useTranslations } from "next-intl";
import VideoPanel from "./VideoPlayer/VideoPanel"; 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() { 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 ( return (
<> <div className="min-h-screen bg-gray-50">
<div <div className="container mx-auto px-4 py-8">
className="flex w-screen pt-8 items-center justify-center" <div className="max-w-6xl mx-auto">
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()} {/* 标题区域 */}
<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"> {state.subtitle.url && state.subtitle.data.length > 0 && (
<VideoPanel videoUrl={videoUrl} srtUrl={srtUrl} ref={videoRef} /> <SubtitleArea
<UploadArea setVideoUrl={setVideoUrl} setSrtUrl={setSrtUrl} /> 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>
</div> </div>
</>
); );
} }

View 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;
}

View 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;
}

View 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;
}

View 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 [];
}
}

View 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`;
}

View File

@@ -6,7 +6,7 @@ import {
TextSpeakerArraySchema, TextSpeakerArraySchema,
TextSpeakerItemSchema, TextSpeakerItemSchema,
} from "@/lib/interfaces"; } from "@/lib/interfaces";
import IconClick from "@/components/IconClick"; import IconClick from "@/components/ui/buttons/IconClick";
import IMAGES from "@/config/images"; import IMAGES from "@/config/images";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators"; import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import LightButton from "@/components/buttons/LightButton"; import LightButton from "@/components/ui/buttons/LightButton";
import IconClick from "@/components/IconClick"; import IconClick from "@/components/ui/buttons/IconClick";
import IMAGES from "@/config/images"; import IMAGES from "@/config/images";
import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { import {
@@ -16,7 +16,7 @@ import { VOICES } from "@/config/locales";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators"; import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
import { getTTSAudioUrl } from "@/lib/browser/tts"; import { getTTSAudioUrl } from "@/lib/browser/tts";
import { genIPA, genLocale } from "@/lib/actions/translatorActions"; import { genIPA, genLocale } from "@/lib/server/translatorActions";
export default function TextSpeakerPage() { export default function TextSpeakerPage() {
const t = useTranslations("text_speaker"); const t = useTranslations("text_speaker");

View File

@@ -1,15 +1,17 @@
import LightButton from "@/components/buttons/LightButton"; "use client";
import Container from "@/components/cards/Container";
import LightButton from "@/components/ui/buttons/LightButton";
import Container from "@/components/ui/Container";
import { TranslationHistorySchema } from "@/lib/interfaces"; import { TranslationHistorySchema } from "@/lib/interfaces";
import { useSession } from "next-auth/react";
import { Dispatch, useEffect, useState } from "react"; import { Dispatch, useEffect, useState } from "react";
import z from "zod"; import z from "zod";
import { Folder } from "../../../../generated/prisma/browser"; 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 { 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 { toast } from "sonner";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { authClient } from "@/lib/auth-client";
interface AddToFolderProps { interface AddToFolderProps {
item: z.infer<typeof TranslationHistorySchema>; item: z.infer<typeof TranslationHistorySchema>;
@@ -17,19 +19,21 @@ interface AddToFolderProps {
} }
const AddToFolder: React.FC<AddToFolderProps> = ({ item, setShow }) => { const AddToFolder: React.FC<AddToFolderProps> = ({ item, setShow }) => {
const session = useSession(); const { data: session } = authClient.useSession();
const [folders, setFolders] = useState<Folder[]>([]); const [folders, setFolders] = useState<Folder[]>([]);
const t = useTranslations("translator.add_to_folder"); const t = useTranslations("translator.add_to_folder");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const userId = Number(session.data!.user!.id); if (!session) return;
const userId = session.user.id;
getFoldersByUserId(userId) getFoldersByUserId(userId)
.then(setFolders) .then(setFolders)
.then(() => setLoading(false)); .then(() => setLoading(false));
}, [session.data]); }, [session]);
if (session.status !== "authenticated") {
if (!session) {
return ( return (
<div className="fixed left-0 top-0 z-50 w-screen h-screen bg-black/50 flex justify-center items-center"> <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"> <Container className="p-6">

View File

@@ -1,13 +1,13 @@
import Container from "@/components/cards/Container"; import Container from "@/components/ui/Container";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Folder } from "../../../../generated/prisma/browser"; import { Folder } from "../../../../generated/prisma/browser";
import { getFoldersByUserId } from "@/lib/actions/services/folderService"; import { getFoldersByUserId } from "@/lib/server/services/folderService";
import LightButton from "@/components/buttons/LightButton"; import LightButton from "@/components/ui/buttons/LightButton";
import { Folder as Fd } from "lucide-react"; import { Folder as Fd } from "lucide-react";
interface FolderSelectorProps { interface FolderSelectorProps {
setSelectedFolderId: (id: number) => void; setSelectedFolderId: (id: number) => void;
userId: number; userId: string;
cancel: () => void; cancel: () => void;
} }

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import LightButton from "@/components/buttons/LightButton"; import LightButton from "@/components/ui/buttons/LightButton";
import IconClick from "@/components/IconClick"; import IconClick from "@/components/ui/buttons/IconClick";
import IMAGES from "@/config/images"; import IMAGES from "@/config/images";
import { VOICES } from "@/config/locales"; import { VOICES } from "@/config/locales";
import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { useAudioPlayer } from "@/hooks/useAudioPlayer";
@@ -10,25 +10,23 @@ import { tlsoPush, tlso } from "@/lib/browser/localStorageOperators";
import { getTTSAudioUrl } from "@/lib/browser/tts"; import { getTTSAudioUrl } from "@/lib/browser/tts";
import { Plus, Trash } from "lucide-react"; import { Plus, Trash } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import z from "zod"; import z from "zod";
import AddToFolder from "./AddToFolder"; import AddToFolder from "./AddToFolder";
import { import {
genIPA, genIPA,
genLocale, genLocale,
genTranslation, genTranslation,
} from "@/lib/actions/translatorActions"; } from "@/lib/server/translatorActions";
import { toast } from "sonner"; import { toast } from "sonner";
import FolderSelector from "./FolderSelector"; import FolderSelector from "./FolderSelector";
import { useSession } from "next-auth/react"; import { createPair } from "@/lib/server/services/pairService";
import { createPair } from "@/lib/actions/services/pairService";
import { shallowEqual } from "@/lib/utils"; import { shallowEqual } from "@/lib/utils";
import { authClient } from "@/lib/auth-client";
export default function TranslatorPage() { export default function TranslatorPage() {
const t = useTranslations("translator"); const t = useTranslations("translator");
const session = useSession();
const taref = useRef<HTMLTextAreaElement>(null); const taref = useRef<HTMLTextAreaElement>(null);
const [lang, setLang] = useState<string>("chinese"); const [lang, setLang] = useState<string>("chinese");
const [tresult, setTresult] = useState<string>(""); const [tresult, setTresult] = useState<string>("");
@@ -38,7 +36,7 @@ export default function TranslatorPage() {
const { load, play } = useAudioPlayer(); const { load, play } = useAudioPlayer();
const [history, setHistory] = useState< const [history, setHistory] = useState<
z.infer<typeof TranslationHistorySchema>[] z.infer<typeof TranslationHistorySchema>[]
>(tlso.get()); >([]);
const [showAddToFolder, setShowAddToFolder] = useState(false); const [showAddToFolder, setShowAddToFolder] = useState(false);
const [addToFolderItem, setAddToFolderItem] = useState<z.infer< const [addToFolderItem, setAddToFolderItem] = useState<z.infer<
typeof TranslationHistorySchema typeof TranslationHistorySchema
@@ -49,6 +47,11 @@ export default function TranslatorPage() {
}); });
const [autoSave, setAutoSave] = useState(false); const [autoSave, setAutoSave] = useState(false);
const [autoSaveFolderId, setAutoSaveFolderId] = useState<number | null>(null); const [autoSaveFolderId, setAutoSaveFolderId] = useState<number | null>(null);
const { data: session } = authClient.useSession();
useEffect(() => {
setHistory(tlso.get());
}, []);
const tts = async (text: string, locale: string) => { const tts = async (text: string, locale: string) => {
if (lastTTS.current.text !== text) { if (lastTTS.current.text !== text) {
@@ -306,7 +309,7 @@ export default function TranslatorPage() {
checked={autoSave} checked={autoSave}
onChange={(e) => { onChange={(e) => {
const checked = e.target.checked; const checked = e.target.checked;
if (checked === true && !(session.status === "authenticated")) { if (checked === true && !session) {
toast.warning("Please login to enable auto-save"); toast.warning("Please login to enable auto-save");
return; return;
} }
@@ -364,7 +367,7 @@ export default function TranslatorPage() {
)} )}
{autoSave && !autoSaveFolderId && ( {autoSave && !autoSaveFolderId && (
<FolderSelector <FolderSelector
userId={Number(session.data!.user!.id)} userId={session!.user.id as string}
cancel={() => setAutoSave(false)} cancel={() => setAutoSave(false)}
setSelectedFolderId={(id) => setAutoSaveFolderId(id)} setSelectedFolderId={(id) => setAutoSaveFolderId(id)}
/> />

View File

@@ -0,0 +1,4 @@
import { auth } from "@/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { POST, GET } = toNextJsHandler(auth);

View File

@@ -1,3 +0,0 @@
import { handlers } from "../../../../auth";
export const { GET, POST } = handlers;

246
src/app/auth/AuthForm.tsx Normal file
View 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
View 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} />;
}

View File

@@ -8,7 +8,7 @@ import {
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Center } from "@/components/Center"; import { Center } from "@/components/common/Center";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Folder } from "../../../generated/prisma/browser"; import { Folder } from "../../../generated/prisma/browser";
import { import {
@@ -16,7 +16,7 @@ import {
deleteFolderById, deleteFolderById,
getFoldersWithTotalPairsByUserId, getFoldersWithTotalPairsByUserId,
renameFolderById, renameFolderById,
} from "@/lib/actions/services/folderService"; } from "@/lib/server/services/folderService";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { toast } from "sonner"; 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 t = useTranslations("folders");
const [folders, setFolders] = useState<(Folder & { total: number })[]>( const [folders, setFolders] = useState<(Folder & { total: number })[]>(
[], [],

View File

@@ -1,5 +1,5 @@
import LightButton from "@/components/buttons/LightButton"; import LightButton from "@/components/ui/buttons/LightButton";
import Input from "@/components/Input"; import Input from "@/components/ui/Input";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useRef } from "react"; import { useRef } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -83,13 +83,19 @@ export default function AddTextPairModal({
</div> </div>
<div> <div>
{t("locale1")} {t("locale1")}
<Input ref={input3Ref} className="w-full" <Input
placeholder="en-US"></Input> ref={input3Ref}
className="w-full"
placeholder="en-US"
></Input>
</div> </div>
<div> <div>
{t("locale2")} {t("locale2")}
<Input ref={input4Ref} className="w-full" <Input
placeholder="zh-CN"></Input> ref={input4Ref}
className="w-full"
placeholder="zh-CN"
></Input>
</div> </div>
</div> </div>
<LightButton onClick={handleAdd}>{t("add")}</LightButton> <LightButton onClick={handleAdd}>{t("add")}</LightButton>

View File

@@ -1,18 +1,18 @@
"use client"; "use client";
import { ArrowLeft, Plus } from "lucide-react"; import { ArrowLeft, Plus } from "lucide-react";
import { Center } from "@/components/Center"; import { Center } from "@/components/common/Center";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { redirect, useRouter } from "next/navigation"; import { redirect, useRouter } from "next/navigation";
import Container from "@/components/cards/Container"; import Container from "@/components/ui/Container";
import { import {
createPair, createPair,
deletePairById, deletePairById,
getPairsByFolderId, getPairsByFolderId,
} from "@/lib/actions/services/pairService"; } from "@/lib/server/services/pairService";
import AddTextPairModal from "./AddTextPairModal"; import AddTextPairModal from "./AddTextPairModal";
import TextPairCard from "./TextPairCard"; import TextPairCard from "./TextPairCard";
import LightButton from "@/components/buttons/LightButton"; import LightButton from "@/components/ui/buttons/LightButton";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
export interface TextPair { export interface TextPair {

View File

@@ -1,6 +1,6 @@
import { Edit, Trash2 } from "lucide-react"; import { Edit, Trash2 } from "lucide-react";
import { TextPair } from "./InFolder"; import { TextPair } from "./InFolder";
import { updatePairById } from "@/lib/actions/services/pairService"; import { updatePairById } from "@/lib/server/services/pairService";
import { useState } from "react"; import { useState } from "react";
import UpdateTextPairModal from "./UpdateTextPairModal"; import UpdateTextPairModal from "./UpdateTextPairModal";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";

View File

@@ -1,5 +1,5 @@
import LightButton from "@/components/buttons/LightButton"; import LightButton from "@/components/ui/buttons/LightButton";
import Input from "@/components/Input"; import Input from "@/components/ui/Input";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useRef } from "react"; import { useRef } from "react";
import { PairUpdateInput } from "../../../../generated/prisma/models"; import { PairUpdateInput } from "../../../../generated/prisma/models";

View File

@@ -1,22 +1,23 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import InFolder from "./InFolder"; import InFolder from "./InFolder";
import { getUserIdByFolderId } from "@/lib/actions/services/folderService"; import { getUserIdByFolderId } from "@/lib/server/services/folderService";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers";
export default async function FoldersPage({ export default async function FoldersPage({
params, 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 { folder_id } = await params;
const t = await getTranslations("folder_id"); const t = await getTranslations("folder_id");
if (!folder_id) { if (!folder_id) {
redirect("/folders"); redirect("/folders");
} }
if (!session?.user?.id) redirect(`/login?redirect=/folders/${folder_id}`); if (!session) redirect(`/auth?redirect=/folders/${folder_id}`);
if ((await getUserIdByFolderId(Number(folder_id))) !== Number(session.user.id)) { if ((await getUserIdByFolderId(Number(folder_id))) !== session.user.id) {
return <p>{t("unauthorized")}</p>; return <p>{t("unauthorized")}</p>;
} }
return <InFolder folderId={Number(folder_id)} />; return <InFolder folderId={Number(folder_id)} />;

View File

@@ -1,8 +1,12 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import FoldersClient from "./FoldersClient"; import FoldersClient from "./FoldersClient";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { headers } from "next/headers";
export default async function FoldersPage() { export default async function FoldersPage() {
const session = await auth(); const session = await auth.api.getSession(
if (!session?.user?.id) redirect(`/login?redirect=/folders`); { headers: await headers() }
return <FoldersClient userId={Number(session.user.id)} />; );
if (!session) redirect(`/auth?redirect=/folders`);
return <FoldersClient userId={session.user.id} />;
} }

View File

@@ -2,8 +2,7 @@ import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import type { Viewport } from "next"; import type { Viewport } from "next";
import { NextIntlClientProvider } from "next-intl"; import { NextIntlClientProvider } from "next-intl";
import SessionWrapper from "@/components/SessionWrapper"; import { Navbar } from "@/components/layout/Navbar";
import { Navbar } from "@/components/Navbar";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
export const viewport: Viewport = { export const viewport: Viewport = {
@@ -22,7 +21,6 @@ export default async function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<SessionWrapper>
<html lang="en"> <html lang="en">
<body className={`antialiased`}> <body className={`antialiased`}>
<NextIntlClientProvider> <NextIntlClientProvider>
@@ -32,6 +30,5 @@ export default async function RootLayout({
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>
</html> </html>
</SessionWrapper>
); );
} }

View File

@@ -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>
);
}

View File

@@ -1,27 +1,14 @@
import { useTranslations } from "next-intl"; import { getTranslations } from "next-intl/server";
import Link from "next/link"; import Link from "next/link";
export default function HomePage() { interface LinkAreaProps {
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 {
href: string; href: string;
name: string; name: string;
description: string; description: string;
color: string; color: string;
} }
function LinkArea({ href, name, description, color }: LinkAreaProps) {
function LinkArea({ href, name, description, color }: LinkAreaProps) {
return ( return (
<Link <Link
href={href} href={href}
@@ -34,11 +21,28 @@ export default function HomePage() {
</div> </div>
</Link> </Link>
); );
} }
function LinkGrid() {
export default async function HomePage() {
const t = await getTranslations("home");
return ( 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" href="/translator"
name={t("translator.name")} name={t("translator.name")}
description={t("translator.description")} description={t("translator.description")}
@@ -75,29 +79,6 @@ export default function HomePage() {
color="#cab48a" color="#cab48a"
></LinkArea> ></LinkArea>
</div> </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>
</> </>
); );
} }

View 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 >;
}

View File

@@ -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 Image from "next/image";
import { useEffect } from "react"; import { Center } from "@/components/common/Center";
import { Center } from "@/components/Center"; import Container from "@/components/ui/Container";
import Container from "@/components/cards/Container"; import { auth } from "@/auth";
import LightButton from "@/components/buttons/LightButton"; import { getTranslations } from "next-intl/server";
import { useTranslations } from "next-intl"; import { redirect } from "next/navigation";
import { headers } from "next/headers";
import LogoutButton from "./LogoutButton";
export default function MePage() { export default async function ProfilePage() {
const session = useSession(); const t = await getTranslations("profile");
const router = useRouter();
const pathname = usePathname();
const t = useTranslations("profile");
useEffect(() => { const session = await auth.api.getSession({ headers: await headers() });
if (session.status !== "authenticated") {
router.push(`/login?redirect=${encodeURIComponent(pathname)}`); if (!session) {
redirect("/auth?redirect=/profile");
} }
}, [session.status, router, pathname]);
console.log(JSON.stringify(session, null, 2));
return ( return (
<Center> <Center>
<Container className="p-6"> <Container className="p-6">
<h1>{t("myProfile")}</h1> <h1>{t("myProfile")}</h1>
{(session.data?.user?.image as string) && ( {session.user.image && (
<Image <Image
width={64} width={64}
height={64} height={64}
alt="User Avatar" alt="User Avatar"
src={session.data?.user?.image as string} src={session.user.image as string}
className="rounded-4xl" className="rounded-4xl"
></Image> ></Image>
)} )}
<p>{session.data?.user?.name}</p> <p>{session.user.name}</p>
<p>{t("email", { email: session.data!.user!.email as string })}</p> <p>{t("email", { email: session.user.email })}</p>
<LightButton onClick={signOut}>{t("logout")}</LightButton> <LogoutButton />
</Container> </Container>
</Center> </Center>
); );

View File

@@ -1,30 +1,20 @@
import NextAuth from "next-auth"; import { betterAuth } from "better-auth";
import GitHub from "next-auth/providers/github"; import { prismaAdapter } from "better-auth/adapters/prisma";
import { createUserIfNotExists, getUserIdByEmail } from "./lib/actions/services/userService"; import { nextCookies } from "better-auth/next-js";
import prisma from "./lib/db";
export const { handlers, auth, signIn, signOut } = NextAuth({ export const auth = betterAuth({
providers: [ database: prismaAdapter(prisma, {
GitHub({ provider: "postgresql"
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}), }),
], emailAndPassword: {
enabled: true
callbacks: {
async signIn({ user }) {
if (!user.email) return false;
await createUserIfNotExists(user.email, user.name);
return true
}, },
async session({ session }) { socialProviders: {
if (session.user?.email) { github: {
const userId = await getUserIdByEmail(session.user.email); clientId: process.env.GITHUB_CLIENT_ID as string,
clientSecret: process.env.GITHUB_CLIENT_SECRET as string
if (userId) {
session.user.id = userId.toString();
}
}
return session;
}, },
}, },
plugins: [nextCookies()]
}); });

View 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></>
);
}

View File

@@ -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>
);
}

View File

@@ -1,11 +0,0 @@
"use client";
import { SessionProvider } from "next-auth/react";
export default function SessionWrapper({
children,
}: {
children: React.ReactNode;
}) {
return <SessionProvider>{children}</SessionProvider>;
}

View 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>
);
}

View File

@@ -1,5 +1,3 @@
"use client";
interface ContainerProps { interface ContainerProps {
children?: React.ReactNode; children?: React.ReactNode;
className?: string; className?: string;

View File

@@ -6,18 +6,21 @@ export default function DarkButton({
selected, selected,
children, children,
type = "button", type = "button",
disabled
}: { }: {
onClick?: () => void; onClick?: (() => void) | undefined;
className?: string; className?: string;
selected?: boolean; selected?: boolean;
children?: React.ReactNode; children?: React.ReactNode;
type?: ButtonType; type?: ButtonType;
disabled?: boolean;
}) { }) {
return ( return (
<PlainButton <PlainButton
onClick={onClick} 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} type={type}
disabled={disabled}
> >
{children} {children}
</PlainButton> </PlainButton>

View 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>
);
}

View File

@@ -6,18 +6,21 @@ export default function LightButton({
selected, selected,
children, children,
type = "button", type = "button",
disabled
}: { }: {
onClick?: () => void; onClick?: (() => void) | undefined;
className?: string; className?: string;
selected?: boolean; selected?: boolean;
children?: React.ReactNode; children?: React.ReactNode;
type?: ButtonType; type?: ButtonType;
disabled?: boolean;
}) { }) {
return ( return (
<PlainButton <PlainButton
onClick={onClick} 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} type={type}
disabled={disabled}
> >
{children} {children}
</PlainButton> </PlainButton>

View File

@@ -5,17 +5,20 @@ export default function PlainButton({
className, className,
children, children,
type = "button", type = "button",
disabled
}: { }: {
onClick?: () => void; onClick?: () => void;
className?: string; className?: string;
children?: React.ReactNode; children?: React.ReactNode;
type?: ButtonType; type?: ButtonType;
disabled?: boolean;
}) { }) {
return ( return (
<button <button
onClick={onClick} onClick={onClick}
className={`px-2 py-1 rounded shadow font-bold hover:cursor-pointer ${className}`} className={`px-2 py-1 rounded shadow font-bold hover:cursor-pointer ${className}`}
type={type} type={type}
disabled={disabled}
> >
{children} {children}
</button> </button>

127
src/lib/actions/auth.ts Normal file
View 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
View File

@@ -0,0 +1,5 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: process.env.BETTER_AUTH_URL as string
});

View File

@@ -1,3 +1,5 @@
"use client";
import { import {
TranslationHistoryArraySchema, TranslationHistoryArraySchema,
TranslationHistorySchema, TranslationHistorySchema,
@@ -14,7 +16,7 @@ export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
try { try {
const item = globalThis.localStorage.getItem(key); 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 rawData = JSON.parse(item) as z.infer<T>;
const result = schema.safeParse(rawData); const result = schema.safeParse(rawData);
@@ -26,11 +28,11 @@ export const getLocalStorageOperator = <T extends z.ZodTypeAny>(
"Invalid data structure in localStorage:", "Invalid data structure in localStorage:",
result.error, result.error,
); );
return []; return [] as z.infer<T>;
} }
} catch (e) { } catch (e) {
console.error(`Failed to parse ${key} data:`, e); console.error(`Failed to parse ${key} data:`, e);
return []; return [] as z.infer<T>;
} }
}, },
set: (data: z.infer<T>) => { set: (data: z.infer<T>) => {

View File

@@ -3,7 +3,7 @@
import { FolderCreateInput, FolderUpdateInput } from "../../../../generated/prisma/models"; import { FolderCreateInput, FolderUpdateInput } from "../../../../generated/prisma/models";
import prisma from "../../db"; import prisma from "../../db";
export async function getFoldersByUserId(userId: number) { export async function getFoldersByUserId(userId: string) {
const folders = await prisma.folder.findMany({ const folders = await prisma.folder.findMany({
where: { where: {
userId: userId, 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({ const folders = await prisma.folder.findMany({
where: { userId }, where: { userId },
include: { include: {

View File

@@ -20,3 +20,130 @@ export function shallowEqual<T extends object>(obj1: T, obj2: T): boolean {
return true; 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;
}
}