Compare commits

..

3 Commits

Author SHA1 Message Date
d3e1cd9092 ... 2025-12-29 11:18:36 +08:00
3ac17f66f2 ... 2025-12-29 10:40:59 +08:00
af259d4691 ... 2025-12-29 10:06:16 +08:00
195 changed files with 6387 additions and 16203 deletions

View File

@@ -6,34 +6,3 @@ README.md
.next
.git
certificates
# testing
/coverage
test.ts
test.js
# build outputs
/out/
/build
*.tsbuildinfo
next-env.d.ts
# debug logs
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*
!.env.example
# misc
.DS_Store
*.pem
.vercel
build.sh
# prisma
/generated/prisma
.claude

View File

@@ -2,8 +2,6 @@
kind: pipeline
type: docker
name: learn-languages
concurrency:
limit: 1
platform:
os: linux

View File

@@ -10,14 +10,3 @@ GITHUB_CLIENT_SECRET=
// Database
DATABASE_URL=
// DashScore
DASHSCORE_API_KEY=
// SMTP Email - Resend (https://resend.com)
SMTP_HOST=smtp.resend.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=resend
SMTP_PASS=re_your_resend_api_key
SMTP_FROM=onboarding@resend.dev

3
.gitignore vendored
View File

@@ -46,9 +46,6 @@ next-env.d.ts
build.sh
test.ts
test.js
/generated/prisma
certificates
.opencode

View File

@@ -6,12 +6,5 @@
},
"[typescriptreact]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[css]": {
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
},
"tailwindCSS.classFunctions": [
"cva",
"cx"
]
}
}

168
AGENTS.md
View File

@@ -1,168 +0,0 @@
# LEARN-LANGUAGES 知识库
**生成时间:** 2026-03-08
**提交:** 6ba5ae9
**分支:** dev
## 概述
全栈语言学习平台,集成 AI 翻译、词典和 TTS。Next.js 16 App Router + PostgreSQL + better-auth + next-intl。
## 结构
```
src/
├── app/ # Next.js 路由 (Server Components)
│ ├── (auth)/ # 认证页面: 登录、注册、个人资料
│ ├── (features)/ # 功能页面: 翻译、词典、字幕播放器
│ ├── folders/ # 文件夹管理
│ └── api/auth/ # better-auth catch-all
├── modules/ # 业务逻辑 (action-service-repository)
│ ├── auth/ # 认证 actions, services, repositories
│ ├── translator/ # 翻译模块
│ ├── dictionary/ # 词典模块
│ └── folder/ # 文件夹管理模块
├── design-system/ # 可复用 UI 基础组件 (CVA)
├── components/ # 业务组件
├── lib/ # 集成层 (db, auth, bigmodel AI)
├── hooks/ # 自定义 hooks (useAudioPlayer, useFileUpload)
├── utils/ # 纯工具函数 (cn, validate, json)
└── shared/ # 类型和常量
```
## 查找位置
| 任务 | 位置 | 备注 |
|------|------|------|
| 添加功能页面 | `src/app/(features)/` | 路由组,无 URL 前缀 |
| 添加认证页面 | `src/app/(auth)/` | 登录、注册、个人资料 |
| 添加业务逻辑 | `src/modules/{name}/` | 遵循 action-service-repository |
| 添加 AI 管道 | `src/lib/bigmodel/{name}/` | 多阶段 orchestrator |
| 添加 UI 组件 | `src/design-system/{category}/` | base, feedback, layout, overlay |
| 添加工具函数 | `src/utils/` | 纯函数 |
| 添加类型定义 | `src/shared/` | 业务类型 |
| 数据库查询 | `src/modules/*/` | Repository 层 |
| i18n 翻译 | `messages/*.json` | 8 种语言 |
## 约定
### 架构: Action-Service-Repository
每个模块 6 个文件:
```
{name}-action.ts # Server Actions, "use server"
{name}-action-dto.ts # Zod schemas, ActionInput*/ActionOutput*
{name}-service.ts # 业务逻辑, 跨模块调用
{name}-service-dto.ts # ServiceInput*/ServiceOutput*
{name}-repository.ts # Prisma 操作
{name}-repository-dto.ts # RepoInput*/RepoOutput*
```
### 命名
- 类型: `{Layer}{Input|Output}{Feature}``ActionInputSignUp`
- 函数: `{layer}{Feature}``actionSignUp`, `serviceSignUp`
- 文件: `kebab-case` 带角色后缀
### Server/Client 划分
- **默认**: Server Components (无 "use client")
- **Client**: 仅在需要时 (useState, useEffect, 浏览器 API)
- **Actions**: 必须有 `"use server"`
### 导入风格
- 显式路径: `@/design-system/base/button` (无 barrel exports)
- 不创建 `index.ts` 文件
### 验证
- Zod schemas 放在 `*-dto.ts`
- 使用 `validate()` from `@/utils/validate`
### 认证
```typescript
// 服务端
import { auth } from "@/auth";
const session = await auth.api.getSession({ headers: await headers() });
// 客户端
import { authClient } from "@/lib/auth-client";
const { data } = authClient.useSession();
```
### 受保护操作
```typescript
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) return { success: false, message: "未授权" };
// 变更前检查所有权
```
### 日志
```typescript
import { createLogger } from "@/lib/logger";
const log = createLogger("folder-repository");
log.debug("Fetching public folders");
log.info("Fetched folders", { count: folders.length });
log.error("Failed to fetch folders", { error });
```
## 反模式 (本项目)
-`index.ts` barrel exports
-`as any`, `@ts-ignore`, `@ts-expect-error`
- ❌ 用 API routes 做数据操作 (使用 Server Actions)
- ❌ Server Component 可行时用 Client Component
- ❌ npm 或 yarn (使用 pnpm)
- ❌ 生产代码中使用 `console.log` (使用 winston logger)
- ❌ 擅自运行 `pnpm dev` (不需要,用 `pnpm build` 验证即可)
## 独特风格
### 设计系统分类
- `base/` — 原子组件: button, input, card, checkbox, radio, switch, select, textarea, range
- `feedback/` — 反馈: alert, progress, skeleton, toast
- `layout/` — 布局: container, grid, stack (VStack, HStack)
- `overlay/` — 覆盖层: modal
- `navigation/` — 导航: tabs
### AI 管道模式
`src/lib/bigmodel/` 中的多阶段 orchestrator:
```
{name}/
├── orchestrator.ts # 协调各阶段
├── types.ts # 共享接口
└── stage{n}-{name}.ts # 各阶段实现
```
### 废弃函数
`translator-action.ts` 中的 `genIPA()``genLanguage()` — 保留用于 text-speaker 兼容
## 命令
```bash
pnpm dev # 开发服务器 (HTTPS)
pnpm build # 生产构建 (验证代码)
pnpm lint # ESLint
pnpm prisma studio # 数据库 GUI
```
### 数据库迁移
**必须使用 `prisma migrate dev`,禁止使用 `db push`**
```bash
# 修改 schema 后创建迁移
DATABASE_URL=your_db_url pnpm prisma migrate dev --name your_migration_name
# 生成 Prisma Client
DATABASE_URL=your_db_url pnpm prisma generate
```
`db push` 会绕过迁移历史,导致生产环境无法正确迁移。
## 备注
- Tailwind CSS v4 (无 tailwind.config.ts)
- React Compiler 已启用
- i18n: 8 种语言 (en-US, zh-CN, ja-JP, ko-KR, de-DE, fr-FR, it-IT, ug-CN)
- TTS: 阿里云千问 (qwen3-tts-flash)
- 数据库: PostgreSQL via Prisma (生成在 `generated/prisma/`)
- 未配置测试基础设施

446
README.md
View File

@@ -1,372 +1,162 @@
# 🌍 多语言学习平台
# 多语言学习平台
<div align="center">
一个基于 Next.js 构建的全功能多语言学习平台,提供翻译、发音、字幕播放、字母学习等多种语言学习工具,帮助用户更高效地掌握新语言。
[![Next.js](https://img.shields.io/badge/Next.js-16.1.1-black?logo=next.js)](https://nextjs.org/)
[![React](https://img.shields.io/badge/React-19.2.3-61DAFB?logo=react)](https://reactjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.9.3-3178C6?logo=typescript)](https://www.typescriptlang.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-Latest-336791?logo=postgresql)](https://www.postgresql.org/)
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue)](./LICENSE)
## ✨ 主要功能
**一个现代化的全栈多语言学习平台,集成 AI 驱动的翻译、发音、词典和学习管理功能**
- **智能翻译工具** - 支持多语言互译,包含国际音标(IPA)标注
- **文本语音合成** - 将文本转换为自然语音,提高发音学习效果
- **SRT字幕播放器** - 结合视频字幕学习,支持多种字幕格式
- **字母学习模块** - 针对初学者的字母和发音基础学习
- **记忆强化工具** - 通过科学记忆法巩固学习内容
- **个人学习空间** - 用户可以创建、管理和组织自己的学习资料
[在线演示](#) · [报告问题](../../issues) · [功能建议](../../issues)
## 🛠 技术栈
</div>
### 前端框架
- **Next.js 16** - React 全栈框架,使用 App Router
- **React 19** - 用户界面构建
- **TypeScript** - 类型安全的 JavaScript
- **Tailwind CSS** - 实用优先的 CSS 框架
---
### 数据与后端
- **PostgreSQL** - 主数据库
- **Prisma** - 现代数据库工具包和 ORM
- **better-auth** - 安全的身份验证系统
## ✨ 核心特性
### 国际化与辅助功能
- **next-intl** - 国际化解决方案
- **edge-tts-universal** - 跨平台文本转语音
### 🎯 学习工具
- **智能翻译** - 基于 AI 的多语言互译,支持 IPA 音标标注
- **词典查询** - 详细的单词释义、词性分析、例句展示
- **语音合成** - 阿里云千问 TTS 提供自然的语音输出
- **个人学习空间** - 文件夹管理、学习资料组织
### 开发工具
- **ESLint** - 代码质量检查
- **pnpm** - 高效的包管理器
### 🔐 用户系统
- **多方式认证** - 邮箱/用户名登录、GitHub OAuth
- **个人资料** - 用户主页、学习进度追踪
- **数据安全** - better-auth 提供企业级安全保障
## 📁 项目结构
### 🌐 国际化
- **8 种语言** - en-US, zh-CN, ja-JP, ko-KR, de-DE, fr-FR, it-IT, ug-CN
- **完整本地化** - 所有界面文本支持多语言
### 🏗️ 技术亮点
- **App Router** - 采用 Next.js 16 最新路由系统
- **Server Components** - 优先服务端渲染,优化性能
- **Action-Service-Repository** - 清晰的三层架构设计
- **类型安全** - TypeScript 严格模式 + Zod 验证
---
```
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/ # 应用配置
```
## 🚀 快速开始
### 前置要求
### 环境要求
- Node.js 24+
- PostgreSQL 14+
- pnpm 8+ (推荐) 或 npm/yarn
- Node.js 24
- PostgreSQL 数据库
- pnpm (推荐) 或 npm
### 安装步骤
### 本地开发
1. 克隆项目
```bash
# 1. 克隆项目
git clone <repository-url>
cd learn-languages
```
# 2. 安装依赖
2. 安装依赖
```bash
pnpm install
# 3. 配置环境变量
cp .env.example .env.local
# 编辑 .env.local 填写必要配置
# 4. 初始化数据库
pnpm prisma generate
pnpm prisma db push
# 5. 启动开发服务器
pnpm dev
```
访问 **http://localhost:3000** 开始使用!
3. 设置环境变量
### 环境变量配置
```env
# 🤖 AI 服务(必需)
ZHIPU_API_KEY=your-api-key # 智谱 AI - 翻译和词典
ZHIPU_MODEL_NAME=your-model-name # 模型名称
DASHSCORE_API_KEY=your-api-key # 阿里云 TTS
# 🔐 认证配置(必需)
BETTER_AUTH_SECRET=your-secret # 随机字符串
BETTER_AUTH_URL=http://localhost:3000
# 🐙 GitHub OAuth可选
GITHUB_CLIENT_ID=your-client-id
GITHUB_CLIENT_SECRET=your-client-secret
# 💾 数据库(必需)
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
```
---
## 🛠️ 技术栈
<table>
<tr>
<td width="50%">
### 前端
- **Next.js 16** - App Router
- **React 19** - UI 框架
- **TypeScript 5.9** - 类型安全
- **Tailwind CSS 4** - 样式方案
- **Zustand** - 状态管理
- **next-intl** - 国际化
</td>
<td width="50%">
### 后端
- **PostgreSQL** - 关系数据库
- **Prisma 7** - ORM
- **better-auth** - 认证系统
- **智谱 AI** - LLM 服务
- **阿里云 TTS** - 语音合成
</td>
</tr>
</table>
---
## 📁 项目架构
```
learn-languages/
├── 📂 src/
│ ├── 📂 app/ # Next.js App Router
│ │ ├── 📂 (auth)/ # 认证相关页面
│ │ ├── 📂 folders/ # 文件夹管理
│ │ ├── 📂 users/[username]/ # 用户资料
│ │ └── 📂 api/ # API 路由
│ │
│ ├── 📂 modules/ # 业务模块(三层架构)
│ │ ├── 📂 auth/ # 认证模块
│ │ ├── 📂 folder/ # 文件夹模块
│ │ ├── 📂 dictionary/ # 词典模块
│ │ └── 📂 translator/ # 翻译模块
│ │
│ ├── 📂 components/ # React 组件
│ │ ├── 📂 ui/ # 通用 UI 组件
│ │ └── 📂 layout/ # 布局组件
│ │
│ ├── 📂 design-system/ # 设计系统
│ │ ├── 📂 base/ # 基础组件
│ │ ├── 📂 layout/ # 布局组件
│ │ └── 📂 feedback/ # 反馈组件
│ │
│ ├── 📂 lib/ # 工具库
│ │ ├── 📂 bigmodel/ # AI 集成
│ │ ├── 📂 browser/ # 浏览器工具
│ │ └── 📂 server/ # 服务端工具
│ │
│ ├── 📂 hooks/ # 自定义 Hooks
│ ├── 📂 i18n/ # 国际化配置
│ ├── 📂 shared/ # 共享类型和常量
│ └── 📂 config/ # 应用配置
├── 📂 prisma/ # 数据库 Schema
├── 📂 messages/ # 多语言文件
└── 📂 public/ # 静态资源
```
### 架构设计Action-Service-Repository
```
┌─────────────────────────────────────────┐
│ Presentation Layer │
│ (Server Components / Client Components)│
└────────────────┬────────────────────────┘
┌────────────────▼────────────────────────┐
│ Action Layer │
│ • Server Actions │
│ • Form Validation (Zod) │
│ • Redirect & Error Handling │
└────────────────┬────────────────────────┘
┌────────────────▼────────────────────────┐
│ Service Layer │
│ • Business Logic │
│ • better-auth Integration │
│ • Cross-module Coordination │
└────────────────┬────────────────────────┘
┌────────────────▼────────────────────────┐
│ Repository Layer │
│ • Prisma Database Operations │
│ • Data Access Abstraction │
│ • Query Optimization │
└─────────────────────────────────────────┘
```
---
## 📚 核心模块
### 认证系统 (auth)
```typescript
// 支持多种登录方式
- 邮箱/密码登录
- 用户名登录
- GitHub OAuth
- 邮箱验证
```
### 翻译模块 (translator)
```typescript
// AI 驱动的智能翻译
- 多语言互译
- IPA 音标标注
- 翻译历史记录
- 上下文理解
```
### 词典模块 (dictionary)
```typescript
// 智能词典查询
- 单词释义
- 词性分析
- 例句展示
- 词频统计
```
### 文件夹模块 (folder)
```typescript
// 学习资料管理
- 创建/删除文件夹
- 添加语言对
- IPA 标注
- 批量管理
```
---
## 🗄️ 数据模型
核心数据模型关系:
```
User (用户)
├─ Account (账户)
├─ Session (会话)
├─ Folder (文件夹)
│ └─ Pair (语言对)
├─ DictionaryLookUp (查询记录)
│ └─ DictionaryItem (词典项)
│ └─ DictionaryEntry (词条)
└─ TranslationHistory (翻译历史)
```
详细模型定义:[prisma/schema.prisma](./prisma/schema.prisma)
---
## 🌍 国际化支持
当前支持的语言:
| 语言 | 代码 | 区域 |
|------|------|------|
| English | en-US | 美国 |
| 中文 | zh-CN | 中国 |
| 日本語 | ja-JP | 日本 |
| 한국어 | ko-KR | 韩国 |
| Deutsch | de-DE | 德国 |
| Français | fr-FR | 法国 |
| Italiano | it-IT | 意大利 |
| ئۇيغۇرچە | ug-CN | 新疆 |
添加新语言:
1.`messages/` 创建语言文件
2.`src/i18n/config.ts` 添加配置
3. 更新语言选择器组件
---
## 🔧 开发指南
### 可用脚本
从项目提供的示例文件复制环境变量模板:
```bash
# 开发
pnpm dev # 启动开发服务器 (HTTPS)
pnpm build # 构建生产版本
pnpm start # 启动生产服务器
pnpm lint # 代码检查
# 数据库
pnpm prisma studio # 打开数据库 GUI
pnpm prisma db push # 同步 Schema
pnpm prisma migrate # 创建迁移
cp .env.example .env.local
```
### 代码规范
然后编辑 `.env.local` 文件,配置所有必要的环境变量:
- ✅ TypeScript 严格模式
- ✅ ESLint + TypeScript Plugin
- ✅ 优先使用 Server Components
- ✅ 新功能遵循 Action-Service-Repository
- ✅ 所有用户文本需要国际化
- ✅ 组件复用设计系统和业务组件
```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
- `modules/` - 业务模块,每个模块包含:
- `*-action.ts` - Server Actions
- `*-service.ts` - 业务逻辑
- `*-repository.ts` - 数据访问
- `*-dto.ts` - 数据传输对象
- `components/` - 业务相关组件
- `design-system/` - 可复用基础组件
- `lib/` - 工具函数和库
// 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 本仓库
1. Fork 项目
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add: AmazingFeature'`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. Pull Request
### 代码提交规范
```
feat: 新功能
fix: 修复问题
docs: 文档变更
style: 代码格式
refactor: 重构
test: 测试相关
chore: 构建/工具
```
---
5. 开 Pull Request
## 📄 许可证
本项目采用 [AGPL-3.0](./LICENSE) 许可证
本项目采用 AGPL-3.0 许可证 - 查看 [LICENSE](./LICENSE) 文件了解详情
## 📞 支持
如果您遇到问题或有建议,请通过以下方式联系:
- 提交 [Issue](../../issues)
- 发送邮件至 [goddonebianu@outlook.com]
---
## 📞 联系方式
- **问题反馈**[GitHub Issues](../../issues)
- **邮箱**goddonebianu@outlook.com
---
<div align="center">
**如果这个项目对你有帮助,请给一个 ⭐️ Star**
Made with ❤️ by the community
</div>
**Happy Learning!** 🌟

View File

@@ -1,360 +0,0 @@
{
"alphabet": {
"chooseCharacters": "Bitte wählen Sie die Zeichen aus, die Sie lernen möchten",
"chooseAlphabetHint": "Wählen Sie ein Alphabet, um mit dem Lernen zu beginnen",
"japanese": "Japanische Kana",
"english": "Englisches Alphabet",
"uyghur": "Uigurisches Alphabet",
"esperanto": "Esperanto-Alphabet",
"loading": "Wird geladen...",
"loadFailed": "Laden fehlgeschlagen, bitte versuchen Sie es erneut",
"hideLetter": "Buchstabe ausblenden",
"showLetter": "Buchstabe anzeigen",
"hideIPA": "IPA ausblenden",
"showIPA": "IPA anzeigen",
"roman": "Romanisierung",
"letter": "Buchstabe",
"random": "Zufallsmodus",
"randomNext": "Zufällig weiter",
"previousLetter": "Vorheriger Buchstabe",
"nextLetter": "Nächster Buchstabe",
"keyboardHint": "Verwenden Sie die Pfeiltasten links/rechts oder Leertaste für Zufall, ESC zum Zurückgehen",
"swipeHint": "Verwenden Sie die Pfeiltasten links/rechts oder wischen Sie zum Navigieren, ESC zum Zurückgehen"
},
"folders": {
"title": "Ordner",
"subtitle": "Verwalten Sie Ihre Sammlungen",
"newFolder": "Neuer Ordner",
"creating": "Wird erstellt...",
"noFoldersYet": "Noch keine Ordner vorhanden",
"folderInfo": "ID: {id} • {totalPairs} Paare",
"enterFolderName": "Ordnernamen eingeben:",
"confirmDelete": "Geben Sie \"{name}\" zum Löschen ein:",
"myFolders": "Meine Ordner",
"publicFolders": "Öffentliche Ordner",
"public": "Öffentlich",
"private": "Privat",
"setPublic": "Öffentlich machen",
"setPrivate": "Privat machen",
"publicFolderInfo": "{userName} • {totalPairs} Paare",
"searchPlaceholder": "Öffentliche Ordner durchsuchen...",
"loading": "Wird geladen...",
"noPublicFolders": "Keine öffentlichen Ordner gefunden",
"unknownUser": "Unbekannter Benutzer",
"enterNewName": "Neuen Namen eingeben:",
"favorite": "Favorisieren",
"unfavorite": "Aus Favoriten entfernen",
"pleaseLogin": "Bitte melden Sie sich zuerst an"
},
"folder_id": {
"unauthorized": "Sie sind nicht der Besitzer dieses Ordners",
"back": "Zurück",
"textPairs": "Textpaare",
"itemsCount": "{count} Einträge",
"memorize": "Auswendig lernen",
"loadingTextPairs": "Textpaare werden geladen...",
"noTextPairs": "Keine Textpaare in diesem Ordner",
"addNewTextPair": "Neues Textpaar hinzufügen",
"add": "Hinzufügen",
"updateTextPair": "Textpaar aktualisieren",
"update": "Aktualisieren",
"text1": "Text 1",
"text2": "Text 2",
"language1": "Sprache 1",
"language2": "Sprache 2",
"enterLanguageName": "Bitte Sprachnamen eingeben",
"edit": "Bearbeiten",
"delete": "Löschen",
"permissionDenied": "Sie haben keine Berechtigung für diese Aktion",
"error": {
"update": "Sie haben keine Berechtigung, diesen Eintrag zu aktualisieren.",
"delete": "Sie haben keine Berechtigung, diesen Eintrag zu löschen.",
"add": "Sie haben keine Berechtigung, Einträge zu diesem Ordner hinzuzufügen.",
"rename": "Sie haben keine Berechtigung, diesen Ordner umzubenennen.",
"deleteFolder": "Sie haben keine Berechtigung, diesen Ordner zu löschen."
}
},
"home": {
"title": "Sprachen lernen",
"description": "Hier ist eine sehr nützliche Website, die Ihnen hilft, fast jede Sprache der Welt zu lernen, einschließlich konstruierter Sprachen.",
"explore": "Entdecken",
"fortune": {
"quote": "Stay hungry, stay foolish.",
"author": "— Steve Jobs"
},
"translator": {
"name": "Übersetzer",
"description": "In jede Sprache übersetzen und mit dem Internationalen Phonetischen Alphabet (IPA) annotieren"
},
"textSpeaker": {
"name": "Textvorleser",
"description": "Text erkennen und vorlesen, unterstützt Schleifenwiedergabe und Geschwindigkeitsanpassung"
},
"srtPlayer": {
"name": "SRT-Videoplayer",
"description": "Videos Satz für Satz basierend auf SRT-Untertiteldateien abspielen, um die Aussprache von Muttersprachlern nachzuahmen"
},
"alphabet": {
"name": "Alphabet",
"description": "Beginnen Sie mit dem Lernen einer neuen Sprache vom Alphabet aus"
},
"memorize": {
"name": "Auswendig lernen",
"description": "Sprache A zu Sprache B, Sprache B zu Sprache A, unterstützt Diktat"
},
"dictionary": {
"name": "Wörterbuch",
"description": "Wörter und Ausdrücke mit detaillierten Definitionen und Beispielen nachschlagen"
},
"moreFeatures": {
"name": "Weitere Funktionen",
"description": "In Entwicklung, bleiben Sie gespannt"
}
},
"auth": {
"title": "Anmelden",
"signUpTitle": "Registrieren",
"signIn": "Anmelden",
"signUp": "Registrieren",
"email": "E-Mail",
"password": "Passwort",
"confirmPassword": "Passwort bestätigen",
"name": "Name",
"username": "Benutzername",
"emailOrUsername": "E-Mail oder Benutzername",
"signInButton": "Anmelden",
"signUpButton": "Registrieren",
"noAccount": "Haben Sie kein Konto?",
"hasAccount": "Haben Sie bereits ein Konto?",
"signInWithGitHub": "Mit GitHub anmelden",
"signUpWithGitHub": "Mit GitHub registrieren",
"invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"passwordTooShort": "Das Passwort muss mindestens 8 Zeichen lang sein",
"passwordsNotMatch": "Die Passwörter stimmen nicht überein",
"nameRequired": "Bitte geben Sie Ihren Namen ein",
"usernameRequired": "Bitte geben Sie einen Benutzernamen ein",
"usernameTooShort": "Der Benutzername muss mindestens 3 Zeichen lang sein",
"usernameInvalid": "Der Benutzername darf nur Buchstaben, Zahlen und Unterstriche enthalten",
"emailRequired": "Bitte geben Sie Ihre E-Mail ein",
"identifierRequired": "Bitte geben Sie Ihre E-Mail oder Ihren Benutzernamen ein",
"passwordRequired": "Bitte geben Sie Ihr Passwort ein",
"confirmPasswordRequired": "Bitte bestätigen Sie Ihr Passwort",
"loading": "Wird geladen...",
"confirm": "Bestätigen",
"noAccountLink": "Haben Sie kein Konto? Registrieren Sie sich",
"hasAccountLink": "Haben Sie bereits ein Konto? Anmelden",
"usernamePlaceholder": "Benutzername",
"emailPlaceholder": "E-Mail-Adresse",
"passwordPlaceholder": "Passwort",
"usernameOrEmailPlaceholder": "Benutzername oder E-Mail",
"loginFailed": "Anmeldung fehlgeschlagen",
"signUpFailed": "Registrierung fehlgeschlagen",
"fillAllFields": "Bitte füllen Sie alle Felder aus",
"enterCredentials": "Bitte geben Sie Benutzername und Passwort ein",
"forgotPassword": "Passwort vergessen",
"forgotPasswordHint": "Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen einen Link zum Zurücksetzen Ihres Passworts.",
"sendResetEmail": "Reset-E-Mail senden",
"resetPasswordFailed": "Reset-E-Mail konnte nicht gesendet werden",
"resetPasswordEmailSent": "Reset-E-Mail erfolgreich gesendet",
"resetPasswordEmailSentHint": "Wir haben einen Link zum Zurücksetzen Ihres Passworts an Ihre E-Mail-Adresse gesendet. Bitte überprüfen Sie Ihren Posteingang.",
"checkYourEmail": "Überprüfen Sie Ihre E-Mail",
"backToLogin": "Zurück zur Anmeldung",
"resetPassword": "Passwort zurücksetzen",
"newPassword": "Neues Passwort",
"invalidToken": "Ungültiger oder abgelaufener Link",
"invalidTokenHint": "Dieser Link zum Zurücksetzen des Passworts ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen an.",
"requestNewToken": "Neuen Reset-Link anfordern",
"resetPasswordSuccess": "Passwort erfolgreich zurückgesetzt",
"resetPasswordSuccessTitle": "Passwort-Zurücksetzung abgeschlossen",
"resetPasswordSuccessHint": "Ihr Passwort wurde erfolgreich zurückgesetzt. Sie können sich jetzt mit Ihrem neuen Passwort anmelden."
},
"memorize": {
"folder_selector": {
"selectFolder": "Wählen Sie einen Ordner",
"noFolders": "Keine Ordner gefunden",
"folderInfo": "{id}. {name} ({count})"
},
"memorize": {
"answer": "Antwort",
"next": "Weiter",
"reverse": "Umkehren",
"dictation": "Diktat",
"noTextPairs": "Keine Textpaare verfügbar",
"disorder": "Mischen",
"previous": "Zurück"
},
"page": {
"unauthorized": "Sie sind nicht berechtigt, auf diesen Ordner zuzugreifen"
}
},
"navbar": {
"title": "learn-languages",
"sourceCode": "GitHub",
"sign_in": "Anmelden",
"profile": "Profil",
"folders": "Ordner",
"explore": "Entdecken",
"favorites": "Favoriten"
},
"profile": {
"myProfile": "Mein Profil",
"email": "E-Mail: {email}",
"logout": "Abmelden"
},
"srt_player": {
"uploadVideo": "Video hochladen",
"uploadSubtitle": "Untertitel hochladen",
"pause": "Pause",
"play": "Abspielen",
"previous": "Zurück",
"next": "Weiter",
"restart": "Neustart",
"autoPause": "Auto-Pause ({enabled})",
"uploadVideoAndSubtitle": "Bitte laden Sie Video- und Untertiteldateien hoch",
"uploadVideoFile": "Bitte laden Sie eine Videodatei hoch",
"uploadSubtitleFile": "Bitte laden Sie eine Untertiteldatei hoch",
"processingSubtitle": "Untertiteldatei wird verarbeitet...",
"needBothFiles": "Sowohl Video- als auch Untertiteldateien sind erforderlich, um mit dem Lernen zu beginnen",
"videoFile": "Videodatei",
"subtitleFile": "Untertiteldatei",
"uploaded": "Hochgeladen",
"notUploaded": "Nicht hochgeladen",
"upload": "Hochladen",
"uploadVideoButton": "Video hochladen",
"uploadSubtitleButton": "Untertitel hochladen",
"subtitleUploaded": "Untertitel hochgeladen ({count} Einträge)",
"subtitleNotUploaded": "Untertitel nicht hochgeladen",
"autoPauseStatus": "Auto-Pause: {enabled}",
"on": "Ein",
"off": "Aus",
"videoUploadFailed": "Video-Upload fehlgeschlagen",
"subtitleUploadFailed": "Untertitel-Upload fehlgeschlagen",
"subtitleLoadSuccess": "Untertitel erfolgreich geladen",
"subtitleLoadFailed": "Laden der Untertitel fehlgeschlagen"
},
"text_speaker": {
"generateIPA": "IPA generieren",
"viewSavedItems": "Gespeicherte Einträge anzeigen",
"confirmDeleteAll": "Sind Sie sicher, dass Sie alles löschen möchten? (J/N)"
},
"translator": {
"detectLanguage": "Sprache erkennen",
"generateIPA": "IPA generieren",
"translateInto": "übersetzen in",
"chinese": "Chinesisch",
"english": "Englisch",
"french": "Französisch",
"german": "Deutsch",
"italian": "Italienisch",
"japanese": "Japanisch",
"korean": "Koreanisch",
"portuguese": "Portugiesisch",
"russian": "Russisch",
"spanish": "Spanisch",
"other": "Andere",
"translating": "wird übersetzt...",
"translate": "übersetzen",
"inputLanguage": "Geben Sie eine Sprache ein.",
"history": "Verlauf",
"enterLanguage": "Sprache eingeben",
"add_to_folder": {
"notAuthenticated": "Sie sind nicht authentifiziert",
"chooseFolder": "Wählen Sie einen Ordner zum Hinzufügen",
"noFolders": "Keine Ordner gefunden",
"folderInfo": "{id}. {name}",
"close": "Schließen",
"success": "Textpaar zum Ordner hinzugefügt",
"error": "Fehler beim Hinzufügen des Textpaars zum Ordner"
},
"autoSave": "Autom. Speichern"
},
"dictionary": {
"title": "Wörterbuch",
"description": "Wörter und Ausdrücke mit detaillierten Definitionen und Beispielen nachschlagen",
"searchPlaceholder": "Geben Sie ein Wort oder einen Ausdruck zum Nachschlagen ein...",
"searching": "Suche läuft...",
"search": "Suchen",
"languageSettings": "Spracheinstellungen",
"queryLanguage": "Abfragesprache",
"queryLanguageHint": "In welcher Sprache ist das Wort/der Ausdruck, den Sie nachschlagen möchten",
"definitionLanguage": "Definitionssprache",
"definitionLanguageHint": "In welcher Sprache möchten Sie die Definitionen",
"otherLanguagePlaceholder": "Oder geben Sie eine andere Sprache ein...",
"other": "Andere",
"currentSettings": "Aktuelle Einstellungen: Abfrage {queryLang}, Definition {definitionLang}",
"relookup": "Erneut suchen",
"saveToFolder": "In Ordner speichern",
"loading": "Wird geladen...",
"noResults": "Keine Ergebnisse gefunden",
"tryOtherWords": "Versuchen Sie andere Wörter oder Ausdrücke",
"welcomeTitle": "Willkommen im Wörterbuch",
"welcomeHint": "Geben Sie oben in das Suchfeld ein Wort oder einen Ausdruck ein, um mit dem Nachschlagen zu beginnen",
"lookupFailed": "Suche fehlgeschlagen, bitte versuchen Sie es später erneut",
"relookupSuccess": "Erneute Suche erfolgreich",
"relookupFailed": "Erneute Wörterbuchsuche fehlgeschlagen",
"pleaseLogin": "Bitte melden Sie sich zuerst an",
"pleaseCreateFolder": "Bitte erstellen Sie zuerst einen Ordner",
"savedToFolder": "In Ordner gespeichert: {folderName}",
"saveFailed": "Speichern fehlgeschlagen, bitte versuchen Sie es später erneut",
"definition": "Definition",
"example": "Beispiel"
},
"explore": {
"title": "Entdecken",
"subtitle": "Öffentliche Ordner entdecken",
"searchPlaceholder": "Öffentliche Ordner durchsuchen...",
"loading": "Wird geladen...",
"noFolders": "Keine öffentlichen Ordner gefunden",
"folderInfo": "{userName} • {totalPairs} Paare",
"unknownUser": "Unbekannter Benutzer",
"favorite": "Favorisieren",
"unfavorite": "Aus Favoriten entfernen",
"pleaseLogin": "Bitte melden Sie sich zuerst an",
"sortByFavorites": "Nach Favoriten sortieren",
"sortByFavoritesActive": "Sortierung nach Favoriten aufheben"
},
"exploreDetail": {
"title": "Ordnerdetails",
"createdBy": "Erstellt von: {name}",
"unknownUser": "Unbekannter Benutzer",
"totalPairs": "Gesamtpaare",
"favorites": "Favoriten",
"createdAt": "Erstellt am",
"viewContent": "Inhalt anzeigen",
"favorite": "Favorisieren",
"unfavorite": "Aus Favoriten entfernen",
"favorited": "Favorisiert",
"unfavorited": "Aus Favoriten entfernt",
"pleaseLogin": "Bitte melden Sie sich zuerst an"
},
"favorites": {
"title": "Meine Favoriten",
"subtitle": "Ordner, die Sie favorisiert haben",
"loading": "Wird geladen...",
"noFavorites": "Noch keine Favoriten",
"folderInfo": "{userName} • {totalPairs} Paare",
"unknownUser": "Unbekannter Benutzer"
},
"user_profile": {
"anonymous": "Anonym",
"email": "E-Mail",
"verified": "Verifiziert",
"unverified": "Nicht verifiziert",
"accountInfo": "Kontoinformationen",
"userId": "Benutzer-ID",
"username": "Benutzername",
"displayName": "Anzeigename",
"notSet": "Nicht festgelegt",
"memberSince": "Mitglied seit",
"logout": "Abmelden",
"folders": {
"title": "Ordner",
"noFolders": "Noch keine Ordner",
"folderName": "Ordnername",
"totalPairs": "Gesamtpaare",
"createdAt": "Erstellt am",
"actions": "Aktionen",
"view": "Anzeigen"
}
}
}

View File

@@ -1,7 +1,6 @@
{
"alphabet": {
"chooseCharacters": "Please select the characters you want to learn",
"chooseAlphabetHint": "Select an alphabet to start learning",
"japanese": "Japanese Kana",
"english": "English Alphabet",
"uyghur": "Uyghur Alphabet",
@@ -15,11 +14,7 @@
"roman": "Romanization",
"letter": "Letter",
"random": "Random Mode",
"randomNext": "Random Next",
"previousLetter": "Previous letter",
"nextLetter": "Next letter",
"keyboardHint": "Use left/right arrow keys or space for random, ESC to go back",
"swipeHint": "Use left/right arrow keys or swipe to navigate, ESC to go back"
"randomNext": "Random Next"
},
"folders": {
"title": "Folders",
@@ -27,24 +22,13 @@
"newFolder": "New Folder",
"creating": "Creating...",
"noFoldersYet": "No folders yet",
"folderInfo": "ID: {id} • {totalPairs} pairs",
"folderInfo": "{id}. {name} ({totalPairs})",
"enterFolderName": "Enter folder name:",
"confirmDelete": "Type \"{name}\" to delete:",
"myFolders": "My Folders",
"publicFolders": "Public Folders",
"public": "Public",
"private": "Private",
"setPublic": "Set Public",
"setPrivate": "Set Private",
"publicFolderInfo": "{userName} • {totalPairs} pairs",
"searchPlaceholder": "Search public folders...",
"loading": "Loading...",
"noPublicFolders": "No public folders found",
"unknownUser": "Unknown User",
"enterNewName": "Enter new name:",
"favorite": "Favorite",
"unfavorite": "Unfavorite",
"pleaseLogin": "Please login first"
"createFolderSuccess": "Folder created successfully",
"deleteFolderSuccess": "Folder deleted successfully",
"createFolderError": "Failed to create folder",
"deleteFolderError": "Failed to delete folder"
},
"folder_id": {
"unauthorized": "You are not the owner of this folder",
@@ -60,19 +44,10 @@
"update": "Update",
"text1": "Text 1",
"text2": "Text 2",
"language1": "Locale 1",
"language2": "Locale 2",
"enterLanguageName": "Please enter language name",
"locale1": "Locale 1",
"locale2": "Locale 2",
"edit": "Edit",
"delete": "Delete",
"permissionDenied": "You do not have permission to perform this action",
"error": {
"update": "You do not have permission to update this item.",
"delete": "You do not have permission to delete this item.",
"add": "You do not have permission to add items to this folder.",
"rename": "You do not have permission to rename this folder.",
"deleteFolder": "You do not have permission to delete this folder."
}
"delete": "Delete"
},
"home": {
"title": "Learn Languages",
@@ -102,26 +77,23 @@
"name": "Memorize",
"description": "Language A to Language B, Language B to Language A, supports dictation"
},
"dictionary": {
"name": "Dictionary",
"description": "Look up words and phrases with detailed definitions and examples"
},
"moreFeatures": {
"name": "More Features",
"description": "Under development, stay tuned"
}
},
"login": {
"loading": "Loading...",
"githubLogin": "GitHub Login"
},
"auth": {
"title": "Sign In",
"signUpTitle": "Sign Up",
"title": "Authentication",
"signIn": "Sign In",
"signUp": "Sign Up",
"email": "Email",
"password": "Password",
"confirmPassword": "Confirm Password",
"name": "Name",
"username": "Username",
"emailOrUsername": "Email or Username",
"signInButton": "Sign In",
"signUpButton": "Sign Up",
"noAccount": "Don't have an account?",
@@ -131,42 +103,13 @@
"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",
"usernameRequired": "Please enter a username",
"usernameTooShort": "Username must be at least 3 characters",
"usernameInvalid": "Username can only contain letters, numbers, and underscores",
"emailRequired": "Please enter your email",
"identifierRequired": "Please enter your email or username",
"passwordRequired": "Please enter your password",
"confirmPasswordRequired": "Please confirm your password",
"loading": "Loading...",
"confirm": "Confirm",
"noAccountLink": "Don't have an account? Sign up",
"hasAccountLink": "Already have an account? Sign in",
"usernamePlaceholder": "Username",
"emailPlaceholder": "Email address",
"passwordPlaceholder": "Password",
"usernameOrEmailPlaceholder": "Username or email",
"loginFailed": "Login failed",
"signUpFailed": "Sign up failed",
"fillAllFields": "Please fill in all fields",
"enterCredentials": "Please enter username and password",
"forgotPassword": "Forgot Password",
"forgotPasswordHint": "Enter your email address and we'll send you a link to reset your password.",
"sendResetEmail": "Send Reset Email",
"resetPasswordFailed": "Failed to send reset email",
"resetPasswordEmailSent": "Reset email sent successfully",
"resetPasswordEmailSentHint": "We've sent a password reset link to your email address. Please check your inbox.",
"checkYourEmail": "Check Your Email",
"backToLogin": "Back to Login",
"resetPassword": "Reset Password",
"newPassword": "New Password",
"invalidToken": "Invalid or Expired Link",
"invalidTokenHint": "This password reset link is invalid or has expired. Please request a new one.",
"requestNewToken": "Request New Reset Link",
"resetPasswordSuccess": "Password reset successfully",
"resetPasswordSuccessTitle": "Password Reset Complete",
"resetPasswordSuccessHint": "Your password has been reset successfully. You can now log in with your new password."
"loading": "Loading..."
},
"memorize": {
"folder_selector": {
@@ -192,9 +135,7 @@
"sourceCode": "GitHub",
"sign_in": "Sign In",
"profile": "Profile",
"folders": "Folders",
"explore": "Explore",
"favorites": "Favorites"
"folders": "Folders"
},
"profile": {
"myProfile": "My Profile",
@@ -210,6 +151,18 @@
"next": "Next",
"restart": "Restart",
"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",
@@ -220,17 +173,20 @@
"uploaded": "Uploaded",
"notUploaded": "Not Uploaded",
"upload": "Upload",
"uploadVideoButton": "Upload Video",
"uploadSubtitleButton": "Upload Subtitle",
"subtitleUploaded": "Subtitle Uploaded ({count} entries)",
"subtitleNotUploaded": "Subtitle Not Uploaded",
"autoPauseStatus": "Auto Pause: {enabled}",
"on": "On",
"off": "Off",
"videoUploadFailed": "Video upload failed",
"subtitleUploadFailed": "Subtitle upload failed",
"subtitleLoadSuccess": "Subtitle loaded successfully",
"subtitleLoadFailed": "Subtitle load failed"
"subtitleLoadSuccess": "Subtitle file loaded successfully",
"subtitleLoadFailed": "Subtitle file loading failed",
"shortcuts": {
"playPause": "Play/Pause",
"next": "Next",
"previous": "Previous",
"restart": "Restart",
"autoPause": "Toggle Auto Pause"
}
},
"text_speaker": {
"generateIPA": "Generate IPA",
@@ -243,14 +199,7 @@
"translateInto": "translate into",
"chinese": "Chinese",
"english": "English",
"french": "French",
"german": "German",
"italian": "Italian",
"japanese": "Japanese",
"korean": "Korean",
"portuguese": "Portuguese",
"russian": "Russian",
"spanish": "Spanish",
"other": "Other",
"translating": "translating...",
"translate": "translate",
@@ -267,94 +216,5 @@
"error": "Failed to add text pair to folder"
},
"autoSave": "Auto Save"
},
"dictionary": {
"title": "Dictionary",
"description": "Look up words and phrases with detailed definitions and examples",
"searchPlaceholder": "Enter a word or phrase to look up...",
"searching": "Searching...",
"search": "Search",
"languageSettings": "Language Settings",
"queryLanguage": "Query Language",
"queryLanguageHint": "What language is the word/phrase you want to look up",
"definitionLanguage": "Definition Language",
"definitionLanguageHint": "What language do you want the definitions in",
"otherLanguagePlaceholder": "Or enter another language...",
"other": "Other",
"currentSettings": "Current settings: Query {queryLang}, Definition {definitionLang}",
"relookup": "Re-search",
"saveToFolder": "Save to folder",
"loading": "Loading...",
"noResults": "No results found",
"tryOtherWords": "Try other words or phrases",
"welcomeTitle": "Welcome to Dictionary",
"welcomeHint": "Enter a word or phrase in the search box above to start looking up",
"lookupFailed": "Search failed, please try again later",
"relookupSuccess": "Re-searched successfully",
"relookupFailed": "Dictionary re-search failed",
"pleaseLogin": "Please log in first",
"pleaseCreateFolder": "Please create a folder first",
"savedToFolder": "Saved to folder: {folderName}",
"saveFailed": "Save failed, please try again later",
"definition": "Definition",
"example": "Example"
},
"explore": {
"title": "Explore",
"subtitle": "Discover public folders",
"searchPlaceholder": "Search public folders...",
"loading": "Loading...",
"noFolders": "No public folders found",
"folderInfo": "{userName} • {totalPairs} pairs",
"unknownUser": "Unknown User",
"favorite": "Favorite",
"unfavorite": "Unfavorite",
"pleaseLogin": "Please login first",
"sortByFavorites": "Sort by favorites",
"sortByFavoritesActive": "Undo sort by favorites"
},
"exploreDetail": {
"title": "Folder Details",
"createdBy": "Created by: {name}",
"unknownUser": "Unknown User",
"totalPairs": "Total Pairs",
"favorites": "Favorites",
"createdAt": "Created At",
"viewContent": "View Content",
"favorite": "Favorite",
"unfavorite": "Unfavorite",
"favorited": "Favorited",
"unfavorited": "Unfavorited",
"pleaseLogin": "Please login first"
},
"favorites": {
"title": "My Favorites",
"subtitle": "Folders you've favorited",
"loading": "Loading...",
"noFavorites": "No favorites yet",
"folderInfo": "{userName} • {totalPairs} pairs",
"unknownUser": "Unknown User"
},
"user_profile": {
"anonymous": "Anonymous",
"email": "Email",
"verified": "Verified",
"unverified": "Unverified",
"accountInfo": "Account Information",
"userId": "User ID",
"username": "Username",
"displayName": "Display Name",
"notSet": "Not Set",
"memberSince": "Member Since",
"logout": "Logout",
"folders": {
"title": "Folders",
"noFolders": "No folders yet",
"folderName": "Folder Name",
"totalPairs": "Total Pairs",
"createdAt": "Created At",
"actions": "Actions",
"view": "View"
}
}
}

View File

@@ -1,360 +0,0 @@
{
"alphabet": {
"chooseCharacters": "Veuillez sélectionner les caractères que vous souhaitez apprendre",
"chooseAlphabetHint": "Sélectionnez un alphabet pour commencer à apprendre",
"japanese": "Kana japonais",
"english": "Alphabet anglais",
"uyghur": "Alphabet ouïghour",
"esperanto": "Alphabet espéranto",
"loading": "Chargement...",
"loadFailed": "Échec du chargement, veuillez réessayer",
"hideLetter": "Masquer la lettre",
"showLetter": "Afficher la lettre",
"hideIPA": "Masquer l'API",
"showIPA": "Afficher l'API",
"roman": "Romanisation",
"letter": "Lettre",
"random": "Mode aléatoire",
"randomNext": "Suivant aléatoire",
"previousLetter": "Lettre précédente",
"nextLetter": "Lettre suivante",
"keyboardHint": "Utilisez les touches fléchées gauche/droite ou espace pour aléatoire, ÉCHAP pour revenir",
"swipeHint": "Utilisez les touches fléchées gauche/droite ou balayez pour naviguer, ÉCHAP pour revenir"
},
"folders": {
"title": "Dossiers",
"subtitle": "Gérez vos collections",
"newFolder": "Nouveau dossier",
"creating": "Création...",
"noFoldersYet": "Pas encore de dossiers",
"folderInfo": "ID : {id} • {totalPairs} paires",
"enterFolderName": "Entrez le nom du dossier :",
"confirmDelete": "Tapez \"{name}\" pour supprimer :",
"myFolders": "Mes dossiers",
"publicFolders": "Dossiers publics",
"public": "Public",
"private": "Privé",
"setPublic": "Définir comme public",
"setPrivate": "Définir comme privé",
"publicFolderInfo": "{userName} • {totalPairs} paires",
"searchPlaceholder": "Rechercher des dossiers publics...",
"loading": "Chargement...",
"noPublicFolders": "Aucun dossier public trouvé",
"unknownUser": "Utilisateur inconnu",
"enterNewName": "Entrez le nouveau nom :",
"favorite": "Favori",
"unfavorite": "Retirer des favoris",
"pleaseLogin": "Veuillez vous connecter d'abord"
},
"folder_id": {
"unauthorized": "Vous n'êtes pas le propriétaire de ce dossier",
"back": "Retour",
"textPairs": "Paires de texte",
"itemsCount": "{count} éléments",
"memorize": "Mémoriser",
"loadingTextPairs": "Chargement des paires de texte...",
"noTextPairs": "Aucune paire de texte dans ce dossier",
"addNewTextPair": "Ajouter une nouvelle paire de texte",
"add": "Ajouter",
"updateTextPair": "Mettre à jour la paire de texte",
"update": "Mettre à jour",
"text1": "Texte 1",
"text2": "Texte 2",
"language1": "Langue 1",
"language2": "Langue 2",
"enterLanguageName": "Veuillez entrer le nom de la langue",
"edit": "Modifier",
"delete": "Supprimer",
"permissionDenied": "Vous n'avez pas la permission d'effectuer cette action",
"error": {
"update": "Vous n'avez pas la permission de mettre à jour cet élément.",
"delete": "Vous n'avez pas la permission de supprimer cet élément.",
"add": "Vous n'avez pas la permission d'ajouter des éléments à ce dossier.",
"rename": "Vous n'avez pas la permission de renommer ce dossier.",
"deleteFolder": "Vous n'avez pas la permission de supprimer ce dossier."
}
},
"home": {
"title": "Apprendre les langues",
"description": "Voici un site Web très utile pour vous aider à apprendre presque toutes les langues du monde, y compris les langues construites.",
"explore": "Explorer",
"fortune": {
"quote": "Restez affamés, restez fous.",
"author": "— Steve Jobs"
},
"translator": {
"name": "Traducteur",
"description": "Traduire vers n'importe quelle langue et annoter avec l'Alphabet Phonétique International (API)"
},
"textSpeaker": {
"name": "Lecteur de texte",
"description": "Reconnaître et lire le texte à haute voix, prend en charge la lecture en boucle et le réglage de la vitesse"
},
"srtPlayer": {
"name": "Lecteur vidéo SRT",
"description": "Lire des vidéos phrase par phrase basées sur des fichiers de sous-titres SRT pour imiter la prononciation des locuteurs natifs"
},
"alphabet": {
"name": "Alphabet",
"description": "Commencez à apprendre une nouvelle langue à partir de l'alphabet"
},
"memorize": {
"name": "Mémoriser",
"description": "Langue A vers Langue B, Langue B vers Langue A, prend en charge la dictée"
},
"dictionary": {
"name": "Dictionnaire",
"description": "Rechercher des mots et des expressions avec des définitions détaillées et des exemples"
},
"moreFeatures": {
"name": "Plus de fonctionnalités",
"description": "En développement, restez à l'écoute"
}
},
"auth": {
"title": "Se connecter",
"signUpTitle": "S'inscrire",
"signIn": "Se connecter",
"signUp": "S'inscrire",
"email": "E-mail",
"password": "Mot de passe",
"confirmPassword": "Confirmer le mot de passe",
"name": "Nom",
"username": "Nom d'utilisateur",
"emailOrUsername": "E-mail ou nom d'utilisateur",
"signInButton": "Se connecter",
"signUpButton": "S'inscrire",
"noAccount": "Vous n'avez pas de compte ?",
"hasAccount": "Vous avez déjà un compte ?",
"signInWithGitHub": "Se connecter avec GitHub",
"signUpWithGitHub": "S'inscrire avec GitHub",
"invalidEmail": "Veuillez entrer une adresse e-mail valide",
"passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères",
"passwordsNotMatch": "Les mots de passe ne correspondent pas",
"nameRequired": "Veuillez entrer votre nom",
"usernameRequired": "Veuillez entrer un nom d'utilisateur",
"usernameTooShort": "Le nom d'utilisateur doit contenir au moins 3 caractères",
"usernameInvalid": "Le nom d'utilisateur ne peut contenir que des lettres, des chiffres et des underscores",
"emailRequired": "Veuillez entrer votre e-mail",
"identifierRequired": "Veuillez entrer votre e-mail ou nom d'utilisateur",
"passwordRequired": "Veuillez entrer votre mot de passe",
"confirmPasswordRequired": "Veuillez confirmer votre mot de passe",
"loading": "Chargement...",
"confirm": "Confirmer",
"noAccountLink": "Vous n'avez pas de compte ? Inscrivez-vous",
"hasAccountLink": "Vous avez déjà un compte ? Connectez-vous",
"usernamePlaceholder": "Nom d'utilisateur",
"emailPlaceholder": "Adresse e-mail",
"passwordPlaceholder": "Mot de passe",
"usernameOrEmailPlaceholder": "Nom d'utilisateur ou e-mail",
"loginFailed": "Échec de la connexion",
"signUpFailed": "Échec de l'inscription",
"fillAllFields": "Veuillez remplir tous les champs",
"enterCredentials": "Veuillez entrer le nom d'utilisateur et le mot de passe",
"forgotPassword": "Mot de passe oublié",
"forgotPasswordHint": "Entrez votre adresse e-mail et nous vous enverrons un lien pour réinitialiser votre mot de passe.",
"sendResetEmail": "Envoyer l'e-mail de réinitialisation",
"resetPasswordFailed": "Échec de l'envoi de l'e-mail de réinitialisation",
"resetPasswordEmailSent": "E-mail de réinitialisation envoyé avec succès",
"resetPasswordEmailSentHint": "Nous avons envoyé un lien de réinitialisation de mot de passe à votre adresse e-mail. Veuillez vérifier votre boîte de réception.",
"checkYourEmail": "Vérifiez votre e-mail",
"backToLogin": "Retour à la connexion",
"resetPassword": "Réinitialiser le mot de passe",
"newPassword": "Nouveau mot de passe",
"invalidToken": "Lien invalide ou expiré",
"invalidTokenHint": "Ce lien de réinitialisation de mot de passe est invalide ou a expiré. Veuillez en demander un nouveau.",
"requestNewToken": "Demander un nouveau lien de réinitialisation",
"resetPasswordSuccess": "Mot de passe réinitialisé avec succès",
"resetPasswordSuccessTitle": "Réinitialisation du mot de passe terminée",
"resetPasswordSuccessHint": "Votre mot de passe a été réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe."
},
"memorize": {
"folder_selector": {
"selectFolder": "Sélectionner un dossier",
"noFolders": "Aucun dossier trouvé",
"folderInfo": "{id}. {name} ({count})"
},
"memorize": {
"answer": "Réponse",
"next": "Suivant",
"reverse": "Inverser",
"dictation": "Dictée",
"noTextPairs": "Aucune paire de texte disponible",
"disorder": "Désordre",
"previous": "Précédent"
},
"page": {
"unauthorized": "Vous n'êtes pas autorisé à accéder à ce dossier"
}
},
"navbar": {
"title": "apprendre-langues",
"sourceCode": "GitHub",
"sign_in": "Se connecter",
"profile": "Profil",
"folders": "Dossiers",
"explore": "Explorer",
"favorites": "Favoris"
},
"profile": {
"myProfile": "Mon profil",
"email": "E-mail : {email}",
"logout": "Déconnexion"
},
"srt_player": {
"uploadVideo": "Télécharger la vidéo",
"uploadSubtitle": "Télécharger les sous-titres",
"pause": "Pause",
"play": "Lecture",
"previous": "Précédent",
"next": "Suivant",
"restart": "Recommencer",
"autoPause": "Pause automatique ({enabled})",
"uploadVideoAndSubtitle": "Veuillez télécharger les fichiers vidéo et sous-titres",
"uploadVideoFile": "Veuillez télécharger le fichier vidéo",
"uploadSubtitleFile": "Veuillez télécharger le fichier de sous-titres",
"processingSubtitle": "Traitement du fichier de sous-titres...",
"needBothFiles": "Les fichiers vidéo et sous-titres sont tous deux requis pour commencer l'apprentissage",
"videoFile": "Fichier vidéo",
"subtitleFile": "Fichier de sous-titres",
"uploaded": "Téléchargé",
"notUploaded": "Non téléchargé",
"upload": "Télécharger",
"uploadVideoButton": "Télécharger la vidéo",
"uploadSubtitleButton": "Télécharger les sous-titres",
"subtitleUploaded": "Sous-titres téléchargés ({count} entrées)",
"subtitleNotUploaded": "Sous-titres non téléchargés",
"autoPauseStatus": "Pause automatique : {enabled}",
"on": "Activé",
"off": "Désactivé",
"videoUploadFailed": "Échec du téléchargement de la vidéo",
"subtitleUploadFailed": "Échec du téléchargement des sous-titres",
"subtitleLoadSuccess": "Sous-titres chargés avec succès",
"subtitleLoadFailed": "Échec du chargement des sous-titres"
},
"text_speaker": {
"generateIPA": "Générer l'API",
"viewSavedItems": "Voir les éléments enregistrés",
"confirmDeleteAll": "Êtes-vous sûr de vouloir tout supprimer ? (O/N)"
},
"translator": {
"detectLanguage": "détecter la langue",
"generateIPA": "générer l'api",
"translateInto": "traduire en",
"chinese": "Chinois",
"english": "Anglais",
"french": "Français",
"german": "Allemand",
"italian": "Italien",
"japanese": "Japonais",
"korean": "Coréen",
"portuguese": "Portugais",
"russian": "Russe",
"spanish": "Espagnol",
"other": "Autre",
"translating": "traduction...",
"translate": "traduire",
"inputLanguage": "Entrez une langue.",
"history": "Historique",
"enterLanguage": "Entrez la langue",
"add_to_folder": {
"notAuthenticated": "Vous n'êtes pas authentifié",
"chooseFolder": "Choisissez un dossier à ajouter",
"noFolders": "Aucun dossier trouvé",
"folderInfo": "{id}. {name}",
"close": "Fermer",
"success": "Paire de texte ajoutée au dossier",
"error": "Échec de l'ajout de la paire de texte au dossier"
},
"autoSave": "Sauvegarde automatique"
},
"dictionary": {
"title": "Dictionnaire",
"description": "Rechercher des mots et des expressions avec des définitions détaillées et des exemples",
"searchPlaceholder": "Entrez un mot ou une expression à rechercher...",
"searching": "Recherche...",
"search": "Rechercher",
"languageSettings": "Paramètres de langue",
"queryLanguage": "Langue de requête",
"queryLanguageHint": "Dans quelle langue est le mot/l'expression que vous voulez rechercher",
"definitionLanguage": "Langue de définition",
"definitionLanguageHint": "Dans quelle langue voulez-vous les définitions",
"otherLanguagePlaceholder": "Ou entrez une autre langue...",
"other": "Autre",
"currentSettings": "Paramètres actuels : Requête {queryLang}, Définition {definitionLang}",
"relookup": "Rechercher à nouveau",
"saveToFolder": "Enregistrer dans le dossier",
"loading": "Chargement...",
"noResults": "Aucun résultat trouvé",
"tryOtherWords": "Essayez d'autres mots ou expressions",
"welcomeTitle": "Bienvenue dans le dictionnaire",
"welcomeHint": "Entrez un mot ou une expression dans la zone de recherche ci-dessus pour commencer la recherche",
"lookupFailed": "La recherche a échoué, veuillez réessayer plus tard",
"relookupSuccess": "Recherche effectuée avec succès",
"relookupFailed": "La nouvelle recherche dans le dictionnaire a échoué",
"pleaseLogin": "Veuillez vous connecter d'abord",
"pleaseCreateFolder": "Veuillez créer un dossier d'abord",
"savedToFolder": "Enregistré dans le dossier : {folderName}",
"saveFailed": "Échec de l'enregistrement, veuillez réessayer plus tard",
"definition": "Définition",
"example": "Exemple"
},
"explore": {
"title": "Explorer",
"subtitle": "Découvrir les dossiers publics",
"searchPlaceholder": "Rechercher des dossiers publics...",
"loading": "Chargement...",
"noFolders": "Aucun dossier public trouvé",
"folderInfo": "{userName} • {totalPairs} paires",
"unknownUser": "Utilisateur inconnu",
"favorite": "Favori",
"unfavorite": "Retirer des favoris",
"pleaseLogin": "Veuillez vous connecter d'abord",
"sortByFavorites": "Trier par favoris",
"sortByFavoritesActive": "Annuler le tri par favoris"
},
"exploreDetail": {
"title": "Détails du dossier",
"createdBy": "Créé par : {name}",
"unknownUser": "Utilisateur inconnu",
"totalPairs": "Total des paires",
"favorites": "Favoris",
"createdAt": "Créé le",
"viewContent": "Voir le contenu",
"favorite": "Favori",
"unfavorite": "Retirer des favoris",
"favorited": "Ajouté aux favoris",
"unfavorited": "Retiré des favoris",
"pleaseLogin": "Veuillez vous connecter d'abord"
},
"favorites": {
"title": "Mes favoris",
"subtitle": "Les dossiers que vous avez mis en favoris",
"loading": "Chargement...",
"noFavorites": "Pas encore de favoris",
"folderInfo": "{userName} • {totalPairs} paires",
"unknownUser": "Utilisateur inconnu"
},
"user_profile": {
"anonymous": "Anonyme",
"email": "E-mail",
"verified": "Vérifié",
"unverified": "Non vérifié",
"accountInfo": "Informations du compte",
"userId": "ID utilisateur",
"username": "Nom d'utilisateur",
"displayName": "Nom d'affichage",
"notSet": "Non défini",
"memberSince": "Membre depuis",
"logout": "Déconnexion",
"folders": {
"title": "Dossiers",
"noFolders": "Pas encore de dossiers",
"folderName": "Nom du dossier",
"totalPairs": "Total des paires",
"createdAt": "Créé le",
"actions": "Actions",
"view": "Voir"
}
}
}

View File

@@ -1,360 +0,0 @@
{
"alphabet": {
"chooseCharacters": "Seleziona i caratteri che vuoi imparare",
"chooseAlphabetHint": "Seleziona un alfabeto per iniziare a imparare",
"japanese": "Kana Giapponese",
"english": "Alfabeto Inglese",
"uyghur": "Alfabeto Uiguro",
"esperanto": "Alfabeto Esperanto",
"loading": "Caricamento...",
"loadFailed": "Caricamento fallito, riprova",
"hideLetter": "Nascondi Lettera",
"showLetter": "Mostra Lettera",
"hideIPA": "Nascondi IPA",
"showIPA": "Mostra IPA",
"roman": "Romanizzazione",
"letter": "Lettera",
"random": "Modalità Casuale",
"randomNext": "Prossimo Casuale",
"previousLetter": "Lettera precedente",
"nextLetter": "Lettera successiva",
"keyboardHint": "Usa le frecce sinistra/destra o spazio per casuale, ESC per tornare indietro",
"swipeHint": "Usa le frecce sinistra/destra o scorri per navigare, ESC per tornare indietro"
},
"folders": {
"title": "Cartelle",
"subtitle": "Gestisci le tue collezioni",
"newFolder": "Nuova Cartella",
"creating": "Creazione...",
"noFoldersYet": "Nessuna cartella ancora",
"folderInfo": "ID: {id} • {totalPairs} coppie",
"enterFolderName": "Inserisci il nome della cartella:",
"confirmDelete": "Digita \"{name}\" per eliminare:",
"myFolders": "Le Mie Cartelle",
"publicFolders": "Cartelle Pubbliche",
"public": "Pubblica",
"private": "Privata",
"setPublic": "Imposta Pubblica",
"setPrivate": "Imposta Privata",
"publicFolderInfo": "{userName} • {totalPairs} coppie",
"searchPlaceholder": "Cerca cartelle pubbliche...",
"loading": "Caricamento...",
"noPublicFolders": "Nessuna cartella pubblica trovata",
"unknownUser": "Utente Sconosciuto",
"enterNewName": "Inserisci nuovo nome:",
"favorite": "Preferito",
"unfavorite": "Rimuovi dai preferiti",
"pleaseLogin": "Per favore accedi prima"
},
"folder_id": {
"unauthorized": "Non sei il proprietario di questa cartella",
"back": "Indietro",
"textPairs": "Coppie di Testo",
"itemsCount": "{count} elementi",
"memorize": "Memorizza",
"loadingTextPairs": "Caricamento coppie di testo...",
"noTextPairs": "Nessuna coppia di testo in questa cartella",
"addNewTextPair": "Aggiungi Nuova Coppia di Testo",
"add": "Aggiungi",
"updateTextPair": "Aggiorna Coppia di Testo",
"update": "Aggiorna",
"text1": "Testo 1",
"text2": "Testo 2",
"language1": "Locale 1",
"language2": "Locale 2",
"enterLanguageName": "Per favore inserisci il nome della lingua",
"edit": "Modifica",
"delete": "Elimina",
"permissionDenied": "Non hai il permesso di eseguire questa azione",
"error": {
"update": "Non hai il permesso di aggiornare questo elemento.",
"delete": "Non hai il permesso di eliminare questo elemento.",
"add": "Non hai il permesso di aggiungere elementi a questa cartella.",
"rename": "Non hai il permesso di rinominare questa cartella.",
"deleteFolder": "Non hai il permesso di eliminare questa cartella."
}
},
"home": {
"title": "Impara le Lingue",
"description": "Ecco un sito molto utile per aiutarti a imparare quasi tutte le lingue del mondo, incluse quelle costruite.",
"explore": "Esplora",
"fortune": {
"quote": "Stay hungry, stay foolish.",
"author": "— Steve Jobs"
},
"translator": {
"name": "Traduttore",
"description": "Traduci in qualsiasi lingua e annota con l'Alfabeto Fonetico Internazionale (IPA)"
},
"textSpeaker": {
"name": "Lettore Testo",
"description": "Riconosci e leggi il testo ad alta voce, supporta riproduzione in loop e regolazione della velocità"
},
"srtPlayer": {
"name": "Lettore Video SRT",
"description": "Riproduci video frase per frase basandoti sui file di sottotitoli SRT per imitare la pronuncia dei madrelingua"
},
"alphabet": {
"name": "Alfabeto",
"description": "Inizia a imparare una nuova lingua dall'alfabeto"
},
"memorize": {
"name": "Memorizza",
"description": "Lingua A a Lingua B, Lingua B a Lingua A, supporta dettatura"
},
"dictionary": {
"name": "Dizionario",
"description": "Cerca parole e frasi con definizioni dettagliate ed esempi"
},
"moreFeatures": {
"name": "Altre Funzionalità",
"description": "In sviluppo, resta sintonizzato"
}
},
"auth": {
"title": "Accedi",
"signUpTitle": "Registrati",
"signIn": "Accedi",
"signUp": "Registrati",
"email": "Email",
"password": "Password",
"confirmPassword": "Conferma Password",
"name": "Nome",
"username": "Nome Utente",
"emailOrUsername": "Email o Nome Utente",
"signInButton": "Accedi",
"signUpButton": "Registrati",
"noAccount": "Non hai un account?",
"hasAccount": "Hai già un account?",
"signInWithGitHub": "Accedi con GitHub",
"signUpWithGitHub": "Registrati con GitHub",
"invalidEmail": "Per favore inserisci un indirizzo email valido",
"passwordTooShort": "La password deve essere di almeno 8 caratteri",
"passwordsNotMatch": "Le password non corrispondono",
"nameRequired": "Per favore inserisci il tuo nome",
"usernameRequired": "Per favore inserisci un nome utente",
"usernameTooShort": "Il nome utente deve essere di almeno 3 caratteri",
"usernameInvalid": "Il nome utente può contenere solo lettere, numeri e trattini bassi",
"emailRequired": "Per favore inserisci la tua email",
"identifierRequired": "Per favore inserisci la tua email o nome utente",
"passwordRequired": "Per favore inserisci la tua password",
"confirmPasswordRequired": "Per favore conferma la tua password",
"loading": "Caricamento...",
"confirm": "Conferma",
"noAccountLink": "Non hai un account? Registrati",
"hasAccountLink": "Hai già un account? Accedi",
"usernamePlaceholder": "Nome utente",
"emailPlaceholder": "Indirizzo email",
"passwordPlaceholder": "Password",
"usernameOrEmailPlaceholder": "Nome utente o email",
"loginFailed": "Accesso fallito",
"signUpFailed": "Registrazione fallita",
"fillAllFields": "Per favore compila tutti i campi",
"enterCredentials": "Per favore inserisci nome utente e password",
"forgotPassword": "Password Dimenticata",
"forgotPasswordHint": "Inserisci il tuo indirizzo email e ti invieremo un link per reimpostare la password.",
"sendResetEmail": "Invia Email di Reset",
"resetPasswordFailed": "Impossibile inviare email di reset",
"resetPasswordEmailSent": "Email di reset inviata con successo",
"resetPasswordEmailSentHint": "Abbiamo inviato un link per reimpostare la password al tuo indirizzo email. Controlla la tua casella di posta.",
"checkYourEmail": "Controlla la tua Email",
"backToLogin": "Torna al Login",
"resetPassword": "Reimposta Password",
"newPassword": "Nuova Password",
"invalidToken": "Link Non Valido o Scaduto",
"invalidTokenHint": "Questo link per reimpostare la password non è valido o è scaduto. Richiedine uno nuovo.",
"requestNewToken": "Richiedi Nuovo Link di Reset",
"resetPasswordSuccess": "Password reimpostata con successo",
"resetPasswordSuccessTitle": "Reimpostazione Password Completata",
"resetPasswordSuccessHint": "La tua password è stata reimpostata con successo. Ora puoi accedere con la tua nuova password."
},
"memorize": {
"folder_selector": {
"selectFolder": "Seleziona una cartella",
"noFolders": "Nessuna cartella trovata",
"folderInfo": "{id}. {name} ({count})"
},
"memorize": {
"answer": "Risposta",
"next": "Successivo",
"reverse": "Inverti",
"dictation": "Dettatura",
"noTextPairs": "Nessuna coppia di testo disponibile",
"disorder": "Disordina",
"previous": "Precedente"
},
"page": {
"unauthorized": "Non sei autorizzato ad accedere a questa cartella"
}
},
"navbar": {
"title": "impara-lingue",
"sourceCode": "GitHub",
"sign_in": "Accedi",
"profile": "Profilo",
"folders": "Cartelle",
"explore": "Esplora",
"favorites": "Preferiti"
},
"profile": {
"myProfile": "Il Mio Profilo",
"email": "Email: {email}",
"logout": "Esci"
},
"srt_player": {
"uploadVideo": "Carica Video",
"uploadSubtitle": "Carica Sottotitoli",
"pause": "Pausa",
"play": "Riproduci",
"previous": "Precedente",
"next": "Successivo",
"restart": "Riavvia",
"autoPause": "Pausa Automatica ({enabled})",
"uploadVideoAndSubtitle": "Per favore carica file video e sottotitoli",
"uploadVideoFile": "Per favore carica il file video",
"uploadSubtitleFile": "Per favore carica il file sottotitoli",
"processingSubtitle": "Elaborazione file sottotitoli...",
"needBothFiles": "Sono richiesti sia il file video che quello dei sottotitoli per iniziare a imparare",
"videoFile": "File Video",
"subtitleFile": "File Sottotitoli",
"uploaded": "Caricato",
"notUploaded": "Non Caricato",
"upload": "Carica",
"uploadVideoButton": "Carica Video",
"uploadSubtitleButton": "Carica Sottotitoli",
"subtitleUploaded": "Sottotitoli Caricati ({count} voci)",
"subtitleNotUploaded": "Sottotitoli Non Caricati",
"autoPauseStatus": "Pausa Automatica: {enabled}",
"on": "Attivo",
"off": "Disattivo",
"videoUploadFailed": "Caricamento video fallito",
"subtitleUploadFailed": "Caricamento sottotitoli fallito",
"subtitleLoadSuccess": "Sottotitoli caricati con successo",
"subtitleLoadFailed": "Caricamento sottotitoli fallito"
},
"text_speaker": {
"generateIPA": "Genera IPA",
"viewSavedItems": "Visualizza Elementi Salvati",
"confirmDeleteAll": "Sei sicuro di voler eliminare tutto? (S/N)"
},
"translator": {
"detectLanguage": "rileva lingua",
"generateIPA": "genera ipa",
"translateInto": "traduci in",
"chinese": "Cinese",
"english": "Inglese",
"french": "Francese",
"german": "Tedesco",
"italian": "Italiano",
"japanese": "Giapponese",
"korean": "Coreano",
"portuguese": "Portoghese",
"russian": "Russo",
"spanish": "Spagnolo",
"other": "Altro",
"translating": "traduzione...",
"translate": "traduci",
"inputLanguage": "Inserisci una lingua.",
"history": "Cronologia",
"enterLanguage": "Inserisci lingua",
"add_to_folder": {
"notAuthenticated": "Non sei autenticato",
"chooseFolder": "Scegli una Cartella a cui Aggiungere",
"noFolders": "Nessuna cartella trovata",
"folderInfo": "{id}. {name}",
"close": "Chiudi",
"success": "Coppia di testo aggiunta alla cartella",
"error": "Impossibile aggiungere coppia di testo alla cartella"
},
"autoSave": "Salvataggio Automatico"
},
"dictionary": {
"title": "Dizionario",
"description": "Cerca parole e frasi con definizioni dettagliate ed esempi",
"searchPlaceholder": "Inserisci una parola o frase da cercare...",
"searching": "Ricerca...",
"search": "Cerca",
"languageSettings": "Impostazioni Lingua",
"queryLanguage": "Lingua di Query",
"queryLanguageHint": "In che lingua è la parola/frase che vuoi cercare",
"definitionLanguage": "Lingua delle Definizioni",
"definitionLanguageHint": "In che lingua vuoi le definizioni",
"otherLanguagePlaceholder": "Oppure inserisci un'altra lingua...",
"other": "Altro",
"currentSettings": "Impostazioni attuali: Query {queryLang}, Definizione {definitionLang}",
"relookup": "Ricerca di nuovo",
"saveToFolder": "Salva nella cartella",
"loading": "Caricamento...",
"noResults": "Nessun risultato trovato",
"tryOtherWords": "Prova altre parole o frasi",
"welcomeTitle": "Benvenuto nel Dizionario",
"welcomeHint": "Inserisci una parola o frase nella casella di ricerca sopra per iniziare a cercare",
"lookupFailed": "Ricerca fallita, riprova più tardi",
"relookupSuccess": "Ricerca effettuata con successo",
"relookupFailed": "Ricerca dizionario fallita",
"pleaseLogin": "Per favore accedi prima",
"pleaseCreateFolder": "Per favore crea prima una cartella",
"savedToFolder": "Salvato nella cartella: {folderName}",
"saveFailed": "Salvataggio fallito, riprova più tardi",
"definition": "Definizione",
"example": "Esempio"
},
"explore": {
"title": "Esplora",
"subtitle": "Scopri cartelle pubbliche",
"searchPlaceholder": "Cerca cartelle pubbliche...",
"loading": "Caricamento...",
"noFolders": "Nessuna cartella pubblica trovata",
"folderInfo": "{userName} • {totalPairs} coppie",
"unknownUser": "Utente Sconosciuto",
"favorite": "Preferito",
"unfavorite": "Rimuovi dai preferiti",
"pleaseLogin": "Per favore accedi prima",
"sortByFavorites": "Ordina per preferiti",
"sortByFavoritesActive": "Annulla ordinamento per preferiti"
},
"exploreDetail": {
"title": "Dettagli Cartella",
"createdBy": "Creata da: {name}",
"unknownUser": "Utente Sconosciuto",
"totalPairs": "Coppie Totali",
"favorites": "Preferiti",
"createdAt": "Creata Il",
"viewContent": "Visualizza Contenuto",
"favorite": "Preferito",
"unfavorite": "Rimuovi dai preferiti",
"favorited": "Aggiunto ai preferiti",
"unfavorited": "Rimosso dai preferiti",
"pleaseLogin": "Per favore accedi prima"
},
"favorites": {
"title": "I Miei Preferiti",
"subtitle": "Cartelle che hai aggiunto ai preferiti",
"loading": "Caricamento...",
"noFavorites": "Nessun preferito ancora",
"folderInfo": "{userName} • {totalPairs} coppie",
"unknownUser": "Utente Sconosciuto"
},
"user_profile": {
"anonymous": "Anonimo",
"email": "Email",
"verified": "Verificato",
"unverified": "Non Verificato",
"accountInfo": "Informazioni Account",
"userId": "ID Utente",
"username": "Nome Utente",
"displayName": "Nome Visualizzato",
"notSet": "Non Impostato",
"memberSince": "Membro Dal",
"logout": "Esci",
"folders": {
"title": "Cartelle",
"noFolders": "Nessuna cartella ancora",
"folderName": "Nome Cartella",
"totalPairs": "Coppie Totali",
"createdAt": "Creata Il",
"actions": "Azioni",
"view": "Visualizza"
}
}
}

View File

@@ -1,360 +0,0 @@
{
"alphabet": {
"chooseCharacters": "学習したい文字を選択してください",
"chooseAlphabetHint": "学習を始めるアルファベットを選択してください",
"japanese": "日本語仮名",
"english": "英語アルファベット",
"uyghur": "ウイグル語アルファベット",
"esperanto": "エスペラント語アルファベット",
"loading": "読み込み中...",
"loadFailed": "読み込みに失敗しました。もう一度お試しください",
"hideLetter": "文字を非表示",
"showLetter": "文字を表示",
"hideIPA": "IPAを非表示",
"showIPA": "IPAを表示",
"roman": "ローマ字",
"letter": "文字",
"random": "ランダムモード",
"randomNext": "ランダム次へ",
"previousLetter": "前の文字",
"nextLetter": "次の文字",
"keyboardHint": "左右の矢印キーまたはスペースキーでランダム移動、ESCで戻る",
"swipeHint": "左右の矢印キーまたはスワイプで移動、ESCで戻る"
},
"folders": {
"title": "フォルダー",
"subtitle": "コレクションを管理",
"newFolder": "新規フォルダー",
"creating": "作成中...",
"noFoldersYet": "まだフォルダーがありません",
"folderInfo": "ID: {id} • {totalPairs} ペア",
"enterFolderName": "フォルダー名を入力:",
"confirmDelete": "削除するには「{name}」と入力してください:",
"myFolders": "マイフォルダー",
"publicFolders": "公開フォルダー",
"public": "公開",
"private": "非公開",
"setPublic": "公開に設定",
"setPrivate": "非公開に設定",
"publicFolderInfo": "{userName} • {totalPairs} ペア",
"searchPlaceholder": "公開フォルダーを検索...",
"loading": "読み込み中...",
"noPublicFolders": "公開フォルダーが見つかりません",
"unknownUser": "不明なユーザー",
"enterNewName": "新しい名前を入力:",
"favorite": "お気に入り",
"unfavorite": "お気に入り解除",
"pleaseLogin": "まずログインしてください"
},
"folder_id": {
"unauthorized": "このフォルダーの所有者ではありません",
"back": "戻る",
"textPairs": "テキストペア",
"itemsCount": "{count} 項目",
"memorize": "暗記",
"loadingTextPairs": "テキストペアを読み込み中...",
"noTextPairs": "このフォルダーにはテキストペアがありません",
"addNewTextPair": "新しいテキストペアを追加",
"add": "追加",
"updateTextPair": "テキストペアを更新",
"update": "更新",
"text1": "テキスト1",
"text2": "テキスト2",
"language1": "言語1",
"language2": "言語2",
"enterLanguageName": "言語名を入力してください",
"edit": "編集",
"delete": "削除",
"permissionDenied": "このアクションを実行する権限がありません",
"error": {
"update": "この項目を更新する権限がありません。",
"delete": "この項目を削除する権限がありません。",
"add": "このフォルダーに項目を追加する権限がありません。",
"rename": "このフォルダーの名前を変更する権限がありません。",
"deleteFolder": "このフォルダーを削除する権限がありません。"
}
},
"home": {
"title": "言語を学ぶ",
"description": "ここは世界のほぼすべての言語(人工言語を含む)を学ぶのに役立つ非常に便利なウェブサイトです。",
"explore": "探索",
"fortune": {
"quote": "Stay hungry, stay foolish.",
"author": "— Steve Jobs"
},
"translator": {
"name": "翻訳者",
"description": "あらゆる言語に翻訳し、国際音声記号IPAで注釈を付けます"
},
"textSpeaker": {
"name": "テキストスピーカー",
"description": "テキストを認識して読み上げ、ループ再生と速度調整をサポート"
},
"srtPlayer": {
"name": "SRTビデオプレーヤー",
"description": "SRT字幕ファイルに基づいて文ごとにビデオを再生し、ネイティブスピーカーの発音を模倣"
},
"alphabet": {
"name": "アルファベット",
"description": "アルファベットから新しい言語の学習を始めましょう"
},
"memorize": {
"name": "暗記",
"description": "言語Aから言語B、言語Bから言語A、書き取りをサポート"
},
"dictionary": {
"name": "辞書",
"description": "詳細な定義と例文で単語やフレーズを検索"
},
"moreFeatures": {
"name": "その他の機能",
"description": "開発中、お楽しみに"
}
},
"auth": {
"title": "サインイン",
"signUpTitle": "新規登録",
"signIn": "サインイン",
"signUp": "新規登録",
"email": "メールアドレス",
"password": "パスワード",
"confirmPassword": "パスワード確認",
"name": "名前",
"username": "ユーザー名",
"emailOrUsername": "メールアドレスまたはユーザー名",
"signInButton": "サインイン",
"signUpButton": "新規登録",
"noAccount": "アカウントをお持ちでないですか?",
"hasAccount": "すでにアカウントをお持ちですか?",
"signInWithGitHub": "GitHubでサインイン",
"signUpWithGitHub": "GitHubで新規登録",
"invalidEmail": "有効なメールアドレスを入力してください",
"passwordTooShort": "パスワードは8文字以上である必要があります",
"passwordsNotMatch": "パスワードが一致しません",
"nameRequired": "名前を入力してください",
"usernameRequired": "ユーザー名を入力してください",
"usernameTooShort": "ユーザー名は3文字以上である必要があります",
"usernameInvalid": "ユーザー名には文字、数字、アンダースコアのみ使用できます",
"emailRequired": "メールアドレスを入力してください",
"identifierRequired": "メールアドレスまたはユーザー名を入力してください",
"passwordRequired": "パスワードを入力してください",
"confirmPasswordRequired": "パスワードを確認してください",
"loading": "読み込み中...",
"confirm": "確認",
"noAccountLink": "アカウントをお持ちでないですか? 新規登録",
"hasAccountLink": "すでにアカウントをお持ちですか? サインイン",
"usernamePlaceholder": "ユーザー名",
"emailPlaceholder": "メールアドレス",
"passwordPlaceholder": "パスワード",
"usernameOrEmailPlaceholder": "ユーザー名またはメールアドレス",
"loginFailed": "ログインに失敗しました",
"signUpFailed": "新規登録に失敗しました",
"fillAllFields": "すべてのフィールドに入力してください",
"enterCredentials": "ユーザー名とパスワードを入力してください",
"forgotPassword": "パスワードをお忘れですか",
"forgotPasswordHint": "メールアドレスを入力してください。パスワードリセット用のリンクをお送りします。",
"sendResetEmail": "リセットメールを送信",
"resetPasswordFailed": "リセットメールの送信に失敗しました",
"resetPasswordEmailSent": "リセットメールを送信しました",
"resetPasswordEmailSentHint": "パスワードリセット用のリンクをメールでお送りしました。受信トレイをご確認ください。",
"checkYourEmail": "メールをご確認ください",
"backToLogin": "ログインに戻る",
"resetPassword": "パスワードをリセット",
"newPassword": "新しいパスワード",
"invalidToken": "無効または期限切れのリンク",
"invalidTokenHint": "このパスワードリセットリンクは無効または期限切れです。新しいものをリクエストしてください。",
"requestNewToken": "新しいリセットリンクをリクエスト",
"resetPasswordSuccess": "パスワードのリセットに成功しました",
"resetPasswordSuccessTitle": "パスワードリセット完了",
"resetPasswordSuccessHint": "パスワードが正常にリセットされました。新しいパスワードでログインできます。"
},
"memorize": {
"folder_selector": {
"selectFolder": "フォルダーを選択",
"noFolders": "フォルダーが見つかりません",
"folderInfo": "{id}. {name} ({count})"
},
"memorize": {
"answer": "答え",
"next": "次へ",
"reverse": "逆順",
"dictation": "書き取り",
"noTextPairs": "利用可能なテキストペアがありません",
"disorder": "シャッフル",
"previous": "前へ"
},
"page": {
"unauthorized": "このフォルダーにアクセスする権限がありません"
}
},
"navbar": {
"title": "learn-languages",
"sourceCode": "GitHub",
"sign_in": "サインイン",
"profile": "プロフィール",
"folders": "フォルダー",
"explore": "探索",
"favorites": "お気に入り"
},
"profile": {
"myProfile": "マイプロフィール",
"email": "メール: {email}",
"logout": "ログアウト"
},
"srt_player": {
"uploadVideo": "ビデオをアップロード",
"uploadSubtitle": "字幕をアップロード",
"pause": "一時停止",
"play": "再生",
"previous": "前へ",
"next": "次へ",
"restart": "最初から",
"autoPause": "自動一時停止 ({enabled})",
"uploadVideoAndSubtitle": "ビデオと字幕ファイルをアップロードしてください",
"uploadVideoFile": "ビデオファイルをアップロードしてください",
"uploadSubtitleFile": "字幕ファイルをアップロードしてください",
"processingSubtitle": "字幕ファイルを処理中...",
"needBothFiles": "学習を開始するにはビデオと字幕ファイルの両方が必要です",
"videoFile": "ビデオファイル",
"subtitleFile": "字幕ファイル",
"uploaded": "アップロード済み",
"notUploaded": "未アップロード",
"upload": "アップロード",
"uploadVideoButton": "ビデオをアップロード",
"uploadSubtitleButton": "字幕をアップロード",
"subtitleUploaded": "字幕をアップロード済み ({count} エントリ)",
"subtitleNotUploaded": "字幕がアップロードされていません",
"autoPauseStatus": "自動一時停止: {enabled}",
"on": "オン",
"off": "オフ",
"videoUploadFailed": "ビデオのアップロードに失敗しました",
"subtitleUploadFailed": "字幕のアップロードに失敗しました",
"subtitleLoadSuccess": "字幕の読み込みに成功しました",
"subtitleLoadFailed": "字幕の読み込みに失敗しました"
},
"text_speaker": {
"generateIPA": "IPAを生成",
"viewSavedItems": "保存済み項目を表示",
"confirmDeleteAll": "すべて削除してもよろしいですか? (Y/N)"
},
"translator": {
"detectLanguage": "言語を検出",
"generateIPA": "ipaを生成",
"translateInto": "翻訳先",
"chinese": "中国語",
"english": "英語",
"french": "フランス語",
"german": "ドイツ語",
"italian": "イタリア語",
"japanese": "日本語",
"korean": "韓国語",
"portuguese": "ポルトガル語",
"russian": "ロシア語",
"spanish": "スペイン語",
"other": "その他",
"translating": "翻訳中...",
"translate": "翻訳",
"inputLanguage": "言語を入力してください。",
"history": "履歴",
"enterLanguage": "言語を入力",
"add_to_folder": {
"notAuthenticated": "認証されていません",
"chooseFolder": "追加するフォルダーを選択",
"noFolders": "フォルダーが見つかりません",
"folderInfo": "{id}. {name}",
"close": "閉じる",
"success": "テキストペアがフォルダーに追加されました",
"error": "テキストペアをフォルダーに追加できませんでした"
},
"autoSave": "自動保存"
},
"dictionary": {
"title": "辞書",
"description": "詳細な定義と例文で単語やフレーズを検索",
"searchPlaceholder": "検索する単語やフレーズを入力...",
"searching": "検索中...",
"search": "検索",
"languageSettings": "言語設定",
"queryLanguage": "クエリ言語",
"queryLanguageHint": "検索したい単語/フレーズの言語",
"definitionLanguage": "定義言語",
"definitionLanguageHint": "定義を表示する言語",
"otherLanguagePlaceholder": "または別の言語を入力...",
"other": "その他",
"currentSettings": "現在の設定: クエリ {queryLang}, 定義 {definitionLang}",
"relookup": "再検索",
"saveToFolder": "フォルダーに保存",
"loading": "読み込み中...",
"noResults": "結果が見つかりません",
"tryOtherWords": "別の単語やフレーズを試してください",
"welcomeTitle": "辞書へようこそ",
"welcomeHint": "上の検索ボックスに単語やフレーズを入力して検索を始めましょう",
"lookupFailed": "検索に失敗しました。後でもう一度お試しください",
"relookupSuccess": "再検索に成功しました",
"relookupFailed": "辞書の再検索に失敗しました",
"pleaseLogin": "まずログインしてください",
"pleaseCreateFolder": "まずフォルダーを作成してください",
"savedToFolder": "フォルダーに保存しました: {folderName}",
"saveFailed": "保存に失敗しました。後でもう一度お試しください",
"definition": "定義",
"example": "例文"
},
"explore": {
"title": "探索",
"subtitle": "公開フォルダーを発見",
"searchPlaceholder": "公開フォルダーを検索...",
"loading": "読み込み中...",
"noFolders": "公開フォルダーが見つかりません",
"folderInfo": "{userName} • {totalPairs} ペア",
"unknownUser": "不明なユーザー",
"favorite": "お気に入り",
"unfavorite": "お気に入り解除",
"pleaseLogin": "まずログインしてください",
"sortByFavorites": "お気に入り順に並べ替え",
"sortByFavoritesActive": "お気に入り順の並べ替えを解除"
},
"exploreDetail": {
"title": "フォルダー詳細",
"createdBy": "作成者: {name}",
"unknownUser": "不明なユーザー",
"totalPairs": "合計ペア数",
"favorites": "お気に入り",
"createdAt": "作成日",
"viewContent": "コンテンツを表示",
"favorite": "お気に入り",
"unfavorite": "お気に入り解除",
"favorited": "お気に入りに追加しました",
"unfavorited": "お気に入りから削除しました",
"pleaseLogin": "まずログインしてください"
},
"favorites": {
"title": "マイお気に入り",
"subtitle": "お気に入りに追加したフォルダー",
"loading": "読み込み中...",
"noFavorites": "まだお気に入りがありません",
"folderInfo": "{userName} • {totalPairs} ペア",
"unknownUser": "不明なユーザー"
},
"user_profile": {
"anonymous": "匿名",
"email": "メールアドレス",
"verified": "認証済み",
"unverified": "未認証",
"accountInfo": "アカウント情報",
"userId": "ユーザーID",
"username": "ユーザー名",
"displayName": "表示名",
"notSet": "未設定",
"memberSince": "登録日",
"logout": "ログアウト",
"folders": {
"title": "フォルダー",
"noFolders": "まだフォルダーがありません",
"folderName": "フォルダー名",
"totalPairs": "合計ペア数",
"createdAt": "作成日",
"actions": "アクション",
"view": "表示"
}
}
}

View File

@@ -1,360 +0,0 @@
{
"alphabet": {
"chooseCharacters": "배우고 싶은 문자를 선택하세요",
"chooseAlphabetHint": "학습을 시작할 알파벳을 선택하세요",
"japanese": "일본어 가나",
"english": "영어 알파벳",
"uyghur": "위구르어 알파벳",
"esperanto": "에스페란토 알파벳",
"loading": "로딩 중...",
"loadFailed": "로딩 실패, 다시 시도해주세요",
"hideLetter": "문자 숨기기",
"showLetter": "문자 표시",
"hideIPA": "IPA 숨기기",
"showIPA": "IPA 표시",
"roman": "로마자 표기",
"letter": "문자",
"random": "무작위 모드",
"randomNext": "무작위 다음",
"previousLetter": "이전 문자",
"nextLetter": "다음 문자",
"keyboardHint": "왼쪽/오른쪽 화살표 키 또는 스페이스바로 무작위, ESC로 뒤로가기",
"swipeHint": "왼쪽/오른쪽 화살표 키 또는 스와이프로 탐색, ESC로 뒤로가기"
},
"folders": {
"title": "폴더",
"subtitle": "컬렉션 관리",
"newFolder": "새 폴더",
"creating": "생성 중...",
"noFoldersYet": "아직 폴더가 없습니다",
"folderInfo": "ID: {id} • {totalPairs} 쌍",
"enterFolderName": "폴더 이름 입력:",
"confirmDelete": "삭제하려면 \"{name}\"을(를) 입력하세요:",
"myFolders": "내 폴더",
"publicFolders": "공개 폴더",
"public": "공개",
"private": "비공개",
"setPublic": "공개로 설정",
"setPrivate": "비공개로 설정",
"publicFolderInfo": "{userName} • {totalPairs} 쌍",
"searchPlaceholder": "공개 폴더 검색...",
"loading": "로딩 중...",
"noPublicFolders": "공개 폴더를 찾을 수 없습니다",
"unknownUser": "알 수 없는 사용자",
"enterNewName": "새 이름 입력:",
"favorite": "즐겨찾기",
"unfavorite": "즐겨찾기 해제",
"pleaseLogin": "먼저 로그인해주세요"
},
"folder_id": {
"unauthorized": "이 폴더의 소유자가 아닙니다",
"back": "뒤로",
"textPairs": "텍스트 쌍",
"itemsCount": "{count}개 항목",
"memorize": "암기",
"loadingTextPairs": "텍스트 쌍 로딩 중...",
"noTextPairs": "이 폴더에 텍스트 쌍이 없습니다",
"addNewTextPair": "새 텍스트 쌍 추가",
"add": "추가",
"updateTextPair": "텍스트 쌍 수정",
"update": "수정",
"text1": "텍스트 1",
"text2": "텍스트 2",
"language1": "로캘 1",
"language2": "로캘 2",
"enterLanguageName": "언어 이름을 입력하세요",
"edit": "편집",
"delete": "삭제",
"permissionDenied": "이 작업을 수행할 권한이 없습니다",
"error": {
"update": "이 항목을 수정할 권한이 없습니다.",
"delete": "이 항목을 삭제할 권한이 없습니다.",
"add": "이 폴더에 항목을 추가할 권한이 없습니다.",
"rename": "이 폴더의 이름을 변경할 권한이 없습니다.",
"deleteFolder": "이 폴더를 삭제할 권한이 없습니다."
}
},
"home": {
"title": "언어 배우기",
"description": "세계의 거의 모든 언어(인공어 포함)를 배우는 데 도움이 되는 매우 유용한 웹사이트입니다.",
"explore": "탐색",
"fortune": {
"quote": "Stay hungry, stay foolish.",
"author": "— Steve Jobs"
},
"translator": {
"name": "번역기",
"description": "모든 언어로 번역하고 국제 음성 기호(IPA)로 주석 달기"
},
"textSpeaker": {
"name": "텍스트 스피커",
"description": "텍스트 인식 및 낭독, 반복 재생 및 속도 조절 지원"
},
"srtPlayer": {
"name": "SRT 비디오 플레이어",
"description": "SRT 자막 파일을 기반으로 문장별로 비디오를 재생하여 원어민 발음 모방"
},
"alphabet": {
"name": "알파벳",
"description": "알파벳부터 새로운 언어 학습 시작"
},
"memorize": {
"name": "암기",
"description": "언어 A에서 언어 B로, 언어 B에서 언어 A로, 받아쓰기 지원"
},
"dictionary": {
"name": "사전",
"description": "상세한 정의와 예문으로 단어 및 구문 검색"
},
"moreFeatures": {
"name": "더 많은 기능",
"description": "개발 중, 기대해주세요"
}
},
"auth": {
"title": "로그인",
"signUpTitle": "회원가입",
"signIn": "로그인",
"signUp": "회원가입",
"email": "이메일",
"password": "비밀번호",
"confirmPassword": "비밀번호 확인",
"name": "이름",
"username": "사용자명",
"emailOrUsername": "이메일 또는 사용자명",
"signInButton": "로그인",
"signUpButton": "회원가입",
"noAccount": "계정이 없으신가요?",
"hasAccount": "이미 계정이 있으신가요?",
"signInWithGitHub": "GitHub로 로그인",
"signUpWithGitHub": "GitHub로 회원가입",
"invalidEmail": "유효한 이메일 주소를 입력하세요",
"passwordTooShort": "비밀번호는 최소 8자 이상이어야 합니다",
"passwordsNotMatch": "비밀번호가 일치하지 않습니다",
"nameRequired": "이름을 입력하세요",
"usernameRequired": "사용자명을 입력하세요",
"usernameTooShort": "사용자명은 최소 3자 이상이어야 합니다",
"usernameInvalid": "사용자명은 문자, 숫자, 밑줄만 포함할 수 있습니다",
"emailRequired": "이메일을 입력하세요",
"identifierRequired": "이메일 또는 사용자명을 입력하세요",
"passwordRequired": "비밀번호를 입력하세요",
"confirmPasswordRequired": "비밀번호를 확인하세요",
"loading": "로딩 중...",
"confirm": "확인",
"noAccountLink": "계정이 없으신가요? 회원가입",
"hasAccountLink": "이미 계정이 있으신가요? 로그인",
"usernamePlaceholder": "사용자명",
"emailPlaceholder": "이메일 주소",
"passwordPlaceholder": "비밀번호",
"usernameOrEmailPlaceholder": "사용자명 또는 이메일",
"loginFailed": "로그인 실패",
"signUpFailed": "회원가입 실패",
"fillAllFields": "모든 필드를 입력하세요",
"enterCredentials": "사용자명과 비밀번호를 입력하세요",
"forgotPassword": "비밀번호 찾기",
"forgotPasswordHint": "이메일 주소를 입력하시면 비밀번호 재설정 링크를 보내드립니다.",
"sendResetEmail": "재설정 이메일 보내기",
"resetPasswordFailed": "재설정 이메일 전송 실패",
"resetPasswordEmailSent": "재설정 이메일이 전송되었습니다",
"resetPasswordEmailSentHint": "비밀번호 재설정 링크를 이메일로 보냈습니다. 받은 편지함을 확인해주세요.",
"checkYourEmail": "이메일을 확인하세요",
"backToLogin": "로그인으로 돌아가기",
"resetPassword": "비밀번호 재설정",
"newPassword": "새 비밀번호",
"invalidToken": "유효하지 않거나 만료된 링크",
"invalidTokenHint": "이 비밀번호 재설정 링크는 유효하지 않거나 만료되었습니다. 새로 요청해 주세요.",
"requestNewToken": "새 재설정 링크 요청",
"resetPasswordSuccess": "비밀번호 재설정 성공",
"resetPasswordSuccessTitle": "비밀번호 재설정 완료",
"resetPasswordSuccessHint": "비밀번호가 성공적으로 재설정되었습니다. 새 비밀번호로 로그인할 수 있습니다."
},
"memorize": {
"folder_selector": {
"selectFolder": "폴더 선택",
"noFolders": "폴더를 찾을 수 없습니다",
"folderInfo": "{id}. {name} ({count})"
},
"memorize": {
"answer": "정답",
"next": "다음",
"reverse": "반대",
"dictation": "받아쓰기",
"noTextPairs": "사용 가능한 텍스트 쌍이 없습니다",
"disorder": "무작위",
"previous": "이전"
},
"page": {
"unauthorized": "이 폴더에 접근할 권한이 없습니다"
}
},
"navbar": {
"title": "learn-languages",
"sourceCode": "GitHub",
"sign_in": "로그인",
"profile": "프로필",
"folders": "폴더",
"explore": "탐색",
"favorites": "즐겨찾기"
},
"profile": {
"myProfile": "내 프로필",
"email": "이메일: {email}",
"logout": "로그아웃"
},
"srt_player": {
"uploadVideo": "비디오 업로드",
"uploadSubtitle": "자막 업로드",
"pause": "일시정지",
"play": "재생",
"previous": "이전",
"next": "다음",
"restart": "다시 시작",
"autoPause": "자동 일시정지 ({enabled})",
"uploadVideoAndSubtitle": "비디오와 자막 파일을 업로드하세요",
"uploadVideoFile": "비디오 파일을 업로드하세요",
"uploadSubtitleFile": "자막 파일을 업로드하세요",
"processingSubtitle": "자막 파일 처리 중...",
"needBothFiles": "학습을 시작하려면 비디오와 자막 파일이 모두 필요합니다",
"videoFile": "비디오 파일",
"subtitleFile": "자막 파일",
"uploaded": "업로드됨",
"notUploaded": "업로드되지 않음",
"upload": "업로드",
"uploadVideoButton": "비디오 업로드",
"uploadSubtitleButton": "자막 업로드",
"subtitleUploaded": "자막 업로드됨 ({count}개 항목)",
"subtitleNotUploaded": "자막 업로드되지 않음",
"autoPauseStatus": "자동 일시정지: {enabled}",
"on": "켜기",
"off": "끄기",
"videoUploadFailed": "비디오 업로드 실패",
"subtitleUploadFailed": "자막 업로드 실패",
"subtitleLoadSuccess": "자막 로드 성공",
"subtitleLoadFailed": "자막 로드 실패"
},
"text_speaker": {
"generateIPA": "IPA 생성",
"viewSavedItems": "저장된 항목 보기",
"confirmDeleteAll": "모든 것을 삭제하시겠습니까? (Y/N)"
},
"translator": {
"detectLanguage": "언어 감지",
"generateIPA": "IPA 생성",
"translateInto": "번역할 언어",
"chinese": "중국어",
"english": "영어",
"french": "프랑스어",
"german": "독일어",
"italian": "이탈리아어",
"japanese": "일본어",
"korean": "한국어",
"portuguese": "포르투갈어",
"russian": "러시아어",
"spanish": "스페인어",
"other": "기타",
"translating": "번역 중...",
"translate": "번역",
"inputLanguage": "언어를 입력하세요.",
"history": "기록",
"enterLanguage": "언어 입력",
"add_to_folder": {
"notAuthenticated": "인증되지 않았습니다",
"chooseFolder": "추가할 폴더 선택",
"noFolders": "폴더를 찾을 수 없습니다",
"folderInfo": "{id}. {name}",
"close": "닫기",
"success": "텍스트 쌍이 폴더에 추가됨",
"error": "폴더에 텍스트 쌍 추가 실패"
},
"autoSave": "자동 저장"
},
"dictionary": {
"title": "사전",
"description": "상세한 정의와 예문으로 단어 및 구문 검색",
"searchPlaceholder": "검색할 단어나 구문을 입력하세요...",
"searching": "검색 중...",
"search": "검색",
"languageSettings": "언어 설정",
"queryLanguage": "질의 언어",
"queryLanguageHint": "검색할 단어/구문의 언어",
"definitionLanguage": "정의 언어",
"definitionLanguageHint": "정의를 표시할 언어",
"otherLanguagePlaceholder": "또는 다른 언어 입력...",
"other": "기타",
"currentSettings": "현재 설정: 질의 {queryLang}, 정의 {definitionLang}",
"relookup": "다시 검색",
"saveToFolder": "폴더에 저장",
"loading": "로딩 중...",
"noResults": "검색 결과 없음",
"tryOtherWords": "다른 단어나 구문을 시도하세요",
"welcomeTitle": "사전에 오신 것을 환영합니다",
"welcomeHint": "위의 검색 상자에 단어나 구문을 입력하여 검색을 시작하세요",
"lookupFailed": "검색 실패, 나중에 다시 시도하세요",
"relookupSuccess": "다시 검색 성공",
"relookupFailed": "사전 다시 검색 실패",
"pleaseLogin": "먼저 로그인하세요",
"pleaseCreateFolder": "먼저 폴더를 생성하세요",
"savedToFolder": "폴더에 저장됨: {folderName}",
"saveFailed": "저장 실패, 나중에 다시 시도하세요",
"definition": "정의",
"example": "예문"
},
"explore": {
"title": "탐색",
"subtitle": "공개 폴더 발견",
"searchPlaceholder": "공개 폴더 검색...",
"loading": "로딩 중...",
"noFolders": "공개 폴더를 찾을 수 없습니다",
"folderInfo": "{userName} • {totalPairs} 쌍",
"unknownUser": "알 수 없는 사용자",
"favorite": "즐겨찾기",
"unfavorite": "즐겨찾기 해제",
"pleaseLogin": "먼저 로그인해주세요",
"sortByFavorites": "즐겨찾기순 정렬",
"sortByFavoritesActive": "즐겨찾기순 정렬 해제"
},
"exploreDetail": {
"title": "폴더 상세",
"createdBy": "생성자: {name}",
"unknownUser": "알 수 없는 사용자",
"totalPairs": "총 쌍",
"favorites": "즐겨찾기",
"createdAt": "생성일",
"viewContent": "내용 보기",
"favorite": "즐겨찾기",
"unfavorite": "즐겨찾기 해제",
"favorited": "즐겨찾기됨",
"unfavorited": "즐겨찾기 해제됨",
"pleaseLogin": "먼저 로그인해주세요"
},
"favorites": {
"title": "내 즐겨찾기",
"subtitle": "즐겨찾기한 폴더",
"loading": "로딩 중...",
"noFavorites": "아직 즐겨찾기가 없습니다",
"folderInfo": "{userName} • {totalPairs} 쌍",
"unknownUser": "알 수 없는 사용자"
},
"user_profile": {
"anonymous": "익명",
"email": "이메일",
"verified": "인증됨",
"unverified": "미인증",
"accountInfo": "계정 정보",
"userId": "사용자 ID",
"username": "사용자명",
"displayName": "표시 이름",
"notSet": "설정되지 않음",
"memberSince": "가입일",
"logout": "로그아웃",
"folders": {
"title": "폴더",
"noFolders": "아직 폴더가 없습니다",
"folderName": "폴더 이름",
"totalPairs": "총 쌍",
"createdAt": "생성일",
"actions": "작업",
"view": "보기"
}
}
}

View File

@@ -1,360 +0,0 @@
{
"alphabet": {
"chooseCharacters": "ئۆگەنمەكچى بولغان ھەرپلەرنى تاللاڭ",
"chooseAlphabetHint": "ئۆگىنىشنى باشلاش ئۈچۈن بىر ئېلىپبە تاللاڭ",
"japanese": "ياپون يېزىقى",
"english": "ئىنگلىز ئېلىپبەسى",
"uyghur": "ئۇيغۇر ئېلىپبەسى",
"esperanto": "ئېسپېرانتو ئېلىپبەسى",
"loading": "يۈكلىنىۋاتىدۇ...",
"loadFailed": "يۈكلەش مەغلۇپ بولدى، قايتا سىناڭ",
"hideLetter": "ھەرپنى يوشۇر",
"showLetter": "ھەرپنى كۆرسەت",
"hideIPA": "IPA نى يوشۇر",
"showIPA": "IPA نى كۆرسەت",
"roman": "لاتىن يېزىقى",
"letter": "ھەرپ",
"random": "ئىختىيارىي ھالەت",
"randomNext": "ئىختىيارىي كېيىنكى",
"previousLetter": "ئالدىنقى ھەرپ",
"nextLetter": "كېيىنكى ھەرپ",
"keyboardHint": "سول/ئوڭ يا ئوق كۇنۇپكىلىرىنى ياكى بوشلۇق كۇنۇپكىسىنى ئىختىيارىي ئالماشتۇرۇش ئۈچۈن ئىشلىتىڭ، ESC قايتىش ئۈچۈن",
"swipeHint": "سول/ئوڭ يا ئوق كۇنۇپكىلىرىنى ياكى سىيرىشنى ئىشلىتىپ يۆنىلىڭ، ESC قايتىش ئۈچۈن"
},
"folders": {
"title": "قىسقۇچلار",
"subtitle": "يىغىپ ساقلاشلىرىڭىزنى باشقۇرۇڭ",
"newFolder": "يېڭى قىسقۇچ",
"creating": "قۇرۇۋاتىدۇ...",
"noFoldersYet": "تېخى قىسقۇچ يوق",
"folderInfo": "كىملىك: {id} • {totalPairs} جۈپ",
"enterFolderName": "قىسقۇچ ئاتىنى كىرگۈزۈڭ:",
"confirmDelete": "ئۆچۈرۈش ئۈچۈن \"{name}\" نى كىرگۈزۈڭ:",
"myFolders": "قىسقۇچلىرىم",
"publicFolders": "ئاممىۋى قىسقۇچلار",
"public": "ئاممىۋى",
"private": "شەخسىي",
"setPublic": "ئاممىۋى قىلىپ تەڭشە",
"setPrivate": "شەخسىي قىلىپ تەڭشە",
"publicFolderInfo": "{userName} • {totalPairs} جۈپ",
"searchPlaceholder": "ئاممىۋى قىسقۇچلارنى ئىزدەڭ...",
"loading": "يۈكلىنىۋاتىدۇ...",
"noPublicFolders": "ئاممىۋى قىسقۇچ تېپىلمىدى",
"unknownUser": "نامەلۇم ئىشلەتكۈچى",
"enterNewName": "يېڭى ئات كىرگۈزۈڭ:",
"favorite": "يىغىپ ساقلا",
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ"
},
"folder_id": {
"unauthorized": "بۇ قىسقۇچنىڭ ئىگىسى ئەمەسسىز",
"back": "قايتىش",
"textPairs": "تېكىست جۈپلىرى",
"itemsCount": "{count} تۈر",
"memorize": "يادلاش",
"loadingTextPairs": "تېكىست جۈپلىرى يۈكلىنىۋاتىدۇ...",
"noTextPairs": "بۇ قىسقۇچتا تېكىست جۈپى يوق",
"addNewTextPair": "يېڭى تېكىست جۈپى قوشۇش",
"add": "قوشۇش",
"updateTextPair": "تېكىست جۈپىنى يېڭىلاش",
"update": "يېڭىلاش",
"text1": "تېكىست 1",
"text2": "تېكىست 2",
"language1": "تىل 1",
"language2": "تىل 2",
"enterLanguageName": "تىل ئاتىنى كىرگۈزۈڭ",
"edit": "تەھرىرلەش",
"delete": "ئۆچۈرۈش",
"permissionDenied": "بۇ مەشغۇلاتنى ئېلىپ بېرىش ھوقۇقىڭىز يوق",
"error": {
"update": "بۇ تۈرنى يېڭىلاش ھوقۇقىڭىز يوق.",
"delete": "بۇ تۈرنى ئۆچۈرۈش ھوقۇقىڭىز يوق.",
"add": "بۇ قىسقۇچقا تۈر قوشۇش ھوقۇقىڭىز يوق.",
"rename": "بۇ قىسقۇچنىڭ ئاتىنى ئۆزگەرتىش ھوقۇقىڭىز يوق.",
"deleteFolder": "بۇ قىسقۇچنى ئۆچۈرۈش ھوقۇقىڭىز يوق."
}
},
"home": {
"title": "تىل ئۆگىنىش",
"description": "بۇ دۇنيادىكى almost ھەر بىر تىلنى، جۈملىدىن سۈنئىي تىللارنى ئۆگىنىشىڭىزگە ياردەم بېرىدىغان ئىنتايىن قوللىنىشلىق تور بېكەت.",
"explore": "ئىزدىنىش",
"fortune": {
"quote": "ئاچ قورساق، ئەخمەق بولۇپ تۇرۇڭ.",
"author": "— Steve Jobs"
},
"translator": {
"name": "تەرجىمان",
"description": "ھەر قانداق تىلغا تەرجىمە قىلىڭ ۋە خەلقئارالىق فونېتىكىلىق ئېلىپبە (IPA) بىلەن ئىزاھلاڭ"
},
"textSpeaker": {
"name": "تېكىست ئوقۇغۇچى",
"description": "تېكىستنى تونۇپ ۋە ئۈنلۈك ئوقۇپ بېرىدۇ، دەۋرىي قويۇش ۋە سۈرئەت تەڭشەشنى قوللايدۇ"
},
"srtPlayer": {
"name": "SRT ۋىدېئو قويغۇچ",
"description": "SRT تر پودكاست ھۆججەتلىرىگە ئاساسەن ۋىدېئولارنى جۈمە بويىچە قويۇپ، ئانا تىللىقلارنىڭ تەلەپپۇزىنى دوراڭ"
},
"alphabet": {
"name": "ئېلىپبە",
"description": "يېڭى بىر تىلنى ئېلىپبەدىن باشلاپ ئۆگىنىڭ"
},
"memorize": {
"name": "يادلاش",
"description": "تىل A دىن تىل B گە، تىل B دىن تىل A غا، دىكتات قىلىشنى قوللايدۇ"
},
"dictionary": {
"name": "لۇغەت",
"description": "سۆزلەر ۋە ئىبارىلەرنى تەپسىلىي ئېنىقلىما ۋە مىساللار بىلەن ئىزدەڭ"
},
"moreFeatures": {
"name": "تېخىمۇ كۆپ ئىقتىدارلار",
"description": "تەرەققىيات ئاستىدا، دىققەت قىلىپ تۇرۇڭ"
}
},
"auth": {
"title": "كىرىش",
"signUpTitle": "تىزىملىتىش",
"signIn": "كىرىش",
"signUp": "تىزىملىتىش",
"email": "ئېلخەت",
"password": "پارول",
"confirmPassword": "پارولنى جەزىملەڭ",
"name": "ئىسىم",
"username": "ئىشلەتكۈچى ئاتى",
"emailOrUsername": "ئېلخەت ياكى ئىشلەتكۈچى ئاتى",
"signInButton": "كىرىش",
"signUpButton": "تىزىملىتىش",
"noAccount": "ھېساباتىڭىز يوقمۇ؟",
"hasAccount": "ھېساباتىڭىز بارمۇ؟",
"signInWithGitHub": "GitHub بىلەن كىرىش",
"signUpWithGitHub": "GitHub بىلەن تىزىملىتىش",
"invalidEmail": "ئۈنۈملۈك ئېلخەت ئادرېسى كىرگۈزۈڭ",
"passwordTooShort": "پارول ئەڭ ئاز 8 ھەرپ بولۇشى كېرەك",
"passwordsNotMatch": "پاروللار ماس كەلمەيدۇ",
"nameRequired": "ئىسىمىڭىزنى كىرگۈزۈڭ",
"usernameRequired": "ئىشلەتكۈچى ئاتىنى كىرگۈزۈڭ",
"usernameTooShort": "ئىشلەتكۈچى ئاتى ئەڭ ئاز 3 ھەرپ بولۇشى كېرەك",
"usernameInvalid": "ئىشلەتكۈچى ئاتى پەقەت ھەرپ، سان ۋە ئاستى سىزىقنى ئۆز ئىچىگە ئالىدۇ",
"emailRequired": "ئېلخەت كىرگۈزۈڭ",
"identifierRequired": "ئېلخەت ياكى ئىشلەتكۈچى ئاتىنى كىرگۈزۈڭ",
"passwordRequired": "پارول كىرگۈزۈڭ",
"confirmPasswordRequired": "پارولنى جەزىملەڭ",
"loading": "يۈكلىنىۋاتىدۇ...",
"confirm": "جەزىملەش",
"noAccountLink": "ھېساباتىڭىز يوقمۇ؟ تىزىملىتىڭ",
"hasAccountLink": "ھېساباتىڭىز بارمۇ؟ كىرىڭ",
"usernamePlaceholder": "ئىشلەتكۈچى ئاتى",
"emailPlaceholder": "ئېلخەت ئادرېسى",
"passwordPlaceholder": "پارول",
"usernameOrEmailPlaceholder": "ئىشلەتكۈچى ئاتى ياكى ئېلخەت",
"loginFailed": "كىرىش مەغلۇپ بولدى",
"signUpFailed": "تىزىملىتىش مەغلۇپ بولدى",
"fillAllFields": "ھەممە بۆلەكلەرنى تولدۇرۇڭ",
"enterCredentials": "ئىشلەتكۈچى ئاتى ۋە پارول كىرگۈزۈڭ",
"forgotPassword": "پارولنى ئۇنتۇپ قالدىڭىزمۇ",
"forgotPasswordHint": "ئېلخەت ئادرېسىڭىزنى كىرگۈزۈڭ، بىز سىزگە پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسى ئەۋەتىمىز.",
"sendResetEmail": "ئەسلىگە قايتۇرۇش ئېلخېتى ئەۋەتىش",
"resetPasswordFailed": "ئەسلىگە قايتۇرۇش ئېلخېتى ئەۋەتىش مەغلۇپ بولدى",
"resetPasswordEmailSent": "ئەسلىگە قايتۇرۇش ئېلخېتى مۇۋەپپەقىيەتلىك ئەۋەتىلدى",
"resetPasswordEmailSentHint": "پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسىنى ئېلخەت ئادرېسىڭىزغا ئەۋەتتۇق. ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ.",
"checkYourEmail": "ئېلخېتىڭىزنى تەكشۈرۈڭ",
"backToLogin": "كىرىشكە قايتىش",
"resetPassword": "پارولنى ئەسلىگە قايتۇرۇش",
"newPassword": "يېڭى پارول",
"invalidToken": "ئۇلانما ئىناۋەتسىز ياكى ۋاقتى ئۆتكەن",
"invalidTokenHint": "بۇ پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسى ئىناۋەتسىز ياكى ۋاقتى ئۆتكەن. يېڭىدىن سوراڭ.",
"requestNewToken": "يېڭى ئەسلىگە قايتۇرۇش ئۇلانمىسى سوراش",
"resetPasswordSuccess": "پارول مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى",
"resetPasswordSuccessTitle": "پارول ئەسلىگە قايتۇرۇش تاماملاندى",
"resetPasswordSuccessHint": "پارولىڭىز مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى. يېڭى پارول بىلەن كىرسىڭىز بولىدۇ."
},
"memorize": {
"folder_selector": {
"selectFolder": "بىر قىسقۇچ تاللاڭ",
"noFolders": "قىسقۇچ تېپىلمىدى",
"folderInfo": "{id}. {name} ({count})"
},
"memorize": {
"answer": "جاۋاب",
"next": "كېيىنكى",
"reverse": "تەتۈر",
"dictation": "دىكتات",
"noTextPairs": "تېكىست جۈپى يوق",
"disorder": "قالايمىقانلاشتۇرۇش",
"previous": "ئالدىنقى"
},
"page": {
"unauthorized": "بۇ قىسقۇچنى زىيارەت قىلىش ھوقۇقىڭىز يوق"
}
},
"navbar": {
"title": "تىل-ئۆگىنىش",
"sourceCode": "GitHub",
"sign_in": "كىرىش",
"profile": "شەخسىي ئۇچۇر",
"folders": "قىسقۇچلار",
"explore": "ئىزدىنىش",
"favorites": "يىغىپ ساقلانغانلار"
},
"profile": {
"myProfile": "شەخسىي ئۇچۇرۇم",
"email": "ئېلخەت: {email}",
"logout": "چىكىنىش"
},
"srt_player": {
"uploadVideo": "ۋىدېئو يۈكلەش",
"uploadSubtitle": "تر پودكاست يۈكلەش",
"pause": "ۋاقىتلىق توختىتىش",
"play": "قويۇش",
"previous": "ئالدىنقى",
"next": "كېيىنكى",
"restart": "قايتا باشلاش",
"autoPause": "ئاپتوماتىك توختىتىش ({enabled})",
"uploadVideoAndSubtitle": "ۋىدېئو ۋە تر پودكاست ھۆججەتلىرىنى يۈكلەڭ",
"uploadVideoFile": "ۋىدېئو ھۆججىتى يۈكلەڭ",
"uploadSubtitleFile": "تر پودكاست ھۆججىتى يۈكلەڭ",
"processingSubtitle": "تر پودكاست ھۆججىتى بىر تەرەپ قىلىنىۋاتىدۇ...",
"needBothFiles": "ئۆگىنىشنى باشلاش ئۈچۈن ۋىدېئو ۋە تر پودكاست ھۆججەتلىرى كېرەك",
"videoFile": "ۋىدېئو ھۆججىتى",
"subtitleFile": "تر پودكاست ھۆججىتى",
"uploaded": "يۈكلەندى",
"notUploaded": "يۈكلەنمىدى",
"upload": "يۈكلەش",
"uploadVideoButton": "ۋىدېئو يۈكلەش",
"uploadSubtitleButton": "تر پودكاست يۈكلەش",
"subtitleUploaded": "تر پودكاست يۈكلەندى ({count} تۈر)",
"subtitleNotUploaded": "تر پودكاست يۈكلەنمىدى",
"autoPauseStatus": "ئاپتوماتىك توختىتىش: {enabled}",
"on": "ئوچۇق",
"off": "تاقاق",
"videoUploadFailed": "ۋىدېئو يۈكلەش مەغلۇپ بولدى",
"subtitleUploadFailed": "تر پودكاست يۈكلەش مەغلۇپ بولدى",
"subtitleLoadSuccess": "تر پودكاست مۇۋەپپەقىيەتلىك يۈكلەندى",
"subtitleLoadFailed": "تر پودكاست يۈكلەش مەغلۇپ بولدى"
},
"text_speaker": {
"generateIPA": "IPA ھاسىل قىلىش",
"viewSavedItems": "ساقلانغان تۈرلەرنى كۆرۈش",
"confirmDeleteAll": "ھەممىنى ئۆچۈرەمسىز؟ (Y/N)"
},
"translator": {
"detectLanguage": "تىلنى تونۇش",
"generateIPA": "ipa ھاسىل قىلىش",
"translateInto": "تەرجىمە قىلىش",
"chinese": "خەنزۇچە",
"english": "ئىنگلىزچە",
"french": "فىرانسۇزچە",
"german": "گېرمانچە",
"italian": "ئىتاليانچە",
"japanese": "ياپونچە",
"korean": "كورېيەچە",
"portuguese": "پورتۇگالچە",
"russian": "رۇسچە",
"spanish": "ئىسپانچە",
"other": "باشقا",
"translating": "تەرجىمە قىلىنىۋاتىدۇ...",
"translate": "تەرجىمە قىلىش",
"inputLanguage": "بىر تىل كىرگۈزۈڭ.",
"history": "تارىخ",
"enterLanguage": "تىل كىرگۈزۈڭ",
"add_to_folder": {
"notAuthenticated": "تىزىملىتىلمىدىڭىز",
"chooseFolder": "قوشۇش ئۈچۈن قىسقۇچ تاللاڭ",
"noFolders": "قىسقۇچ تېپىلمىدى",
"folderInfo": "{id}. {name}",
"close": "تاقاش",
"success": "تېكىست جۈپى قىسقۇچقا قوشۇلدى",
"error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇپ بولدى"
},
"autoSave": "ئاپتوماتىك ساقلاش"
},
"dictionary": {
"title": "لۇغەت",
"description": "سۆزلەر ۋە ئىبارىلەرنى تەپسىلىي ئېنىقلىما ۋە مىساللار بىلەن ئىزدەڭ",
"searchPlaceholder": "ئىزدەش ئۈچۈن سۆز ياكى ئىبارە كىرگۈزۈڭ...",
"searching": "ئىزدەۋاتىدۇ...",
"search": "ئىزدەش",
"languageSettings": "تىل تەڭشەكلىرى",
"queryLanguage": "سۈرۈشتۈرۈش تىلى",
"queryLanguageHint": "ئىزدىمەكچى بولغان سۆز/ئىبارە قايسى تىلدا",
"definitionLanguage": "ئېنىقلىما تىلى",
"definitionLanguageHint": "ئېنىقلىمىلارنى قايسى تىلدا كۆرمەكچى",
"otherLanguagePlaceholder": "ياكى باشقا تىل كىرگۈزۈڭ...",
"other": "باشقا",
"currentSettings": "نۆۋەتتىكى تەڭشەكلەر: سۈرۈشتۈرۈش {queryLang}، ئېنىقلىما {definitionLang}",
"relookup": "قايتا ئىزدەش",
"saveToFolder": "قىسقۇچقا ساقلاش",
"loading": "يۈكلىنىۋاتىدۇ...",
"noResults": "نەتىجە تېپىلمىدى",
"tryOtherWords": "باشقا سۆز ياكى ئىبارىلەرنى سىناڭ",
"welcomeTitle": "لۇغەتكە خۇش كەلدىڭىز",
"welcomeHint": "ئىزدەشنى باشلاش ئۈچۈن يۇقىرىدىكى ئىزدەش رامكىسىغا سۆز ياكى ئىبارە كىرگۈزۈڭ",
"lookupFailed": "ئىزدەش مەغلۇپ بولدى، كېيىن قايتا سىناڭ",
"relookupSuccess": "مۇۋەپپەقىيەتلىك قايتا ئىزدەلدى",
"relookupFailed": "لۇغەت قايتا ئىزدەش مەغلۇپ بولدى",
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ",
"pleaseCreateFolder": "ئاۋۋال بىر قىسقۇچ قۇرۇڭ",
"savedToFolder": "قىسقۇچقا ساقلاندى: {folderName}",
"saveFailed": "ساقلاش مەغلۇپ بولدى، كېيىن قايتا سىناڭ",
"definition": "ئېنىقلىما",
"example": "مىسال"
},
"explore": {
"title": "ئىزدىنىش",
"subtitle": "ئاممىۋى قىسقۇچلارنى بايقاڭ",
"searchPlaceholder": "ئاممىۋى قىسقۇچلارنى ئىزدەڭ...",
"loading": "يۈكلىنىۋاتىدۇ...",
"noFolders": "ئاممىۋى قىسقۇچ تېپىلمىدى",
"folderInfo": "{userName} • {totalPairs} جۈپ",
"unknownUser": "نامەلۇم ئىشلەتكۈچى",
"favorite": "يىغىپ ساقلا",
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ",
"sortByFavorites": "يىغىپ ساقلاش بويىچە تەرتىپلەش",
"sortByFavoritesActive": "يىغىپ ساقلاش بويىچە تەرتىپلەشنى بىكار قىلىش"
},
"exploreDetail": {
"title": "قىسقۇچ تەپسىلاتلىرى",
"createdBy": "قۇرغۇچى: {name}",
"unknownUser": "نامەلۇم ئىشلەتكۈچى",
"totalPairs": "جەمئىي جۈپ",
"favorites": "يىغىپ ساقلانغانلار",
"createdAt": "قۇرۇلغان ۋاقتى",
"viewContent": "مەزمۇننى كۆرۈش",
"favorite": "يىغىپ ساقلا",
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
"favorited": "يىغىپ ساقلاندى",
"unfavorited": "يىغىپ ساقلاش بىكار قىلىندى",
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ"
},
"favorites": {
"title": "يىغىپ ساقلىغانلىرىم",
"subtitle": "يىغىپ ساقلىغان قىسقۇچلىرىڭىز",
"loading": "يۈكلىنىۋاتىدۇ...",
"noFavorites": "تېخى يىغىپ ساقلانمىغان",
"folderInfo": "{userName} • {totalPairs} جۈپ",
"unknownUser": "نامەلۇم ئىشلەتكۈچى"
},
"user_profile": {
"anonymous": "نامسىز",
"email": "ئېلخەت",
"verified": "دەلىللەنگەن",
"unverified": "دەلىللەنمىگەن",
"accountInfo": "ھېسابات ئۇچۇرلىرى",
"userId": "ئىشلەتكۈچى كىملىكى",
"username": "ئىشلەتكۈچى ئاتى",
"displayName": "كۆرسىتىش ئاتى",
"notSet": "تەڭشەلمىگەن",
"memberSince": "ئەزا بولغاندىن بېرى",
"logout": "چىكىنىش",
"folders": {
"title": "قىسقۇچلار",
"noFolders": "تېخى قىسقۇچ يوق",
"folderName": "قىسقۇچ ئاتى",
"totalPairs": "جەمئىي جۈپ",
"createdAt": "قۇرۇلغان ۋاقتى",
"actions": "مەشغۇلاتلار",
"view": "كۆرۈش"
}
}
}

View File

@@ -1,7 +1,6 @@
{
"alphabet": {
"chooseCharacters": "请选择您想学习的字符",
"chooseAlphabetHint": "选择一种语言的字母表开始学习",
"japanese": "日语假名",
"english": "英文字母",
"uyghur": "维吾尔字母",
@@ -15,11 +14,7 @@
"roman": "罗马音",
"letter": "字母",
"random": "随机模式",
"randomNext": "随机下一个",
"previousLetter": "上一个字母",
"nextLetter": "下一个字母",
"keyboardHint": "使用左右箭头键或空格键随机切换ESC键返回",
"swipeHint": "使用左右箭头键或滑动切换字母"
"randomNext": "随机下一个"
},
"folders": {
"title": "文件夹",
@@ -27,24 +22,13 @@
"newFolder": "新建文件夹",
"creating": "创建中...",
"noFoldersYet": "还没有文件夹",
"folderInfo": "ID: {id} • {totalPairs} 个文本对",
"folderInfo": "{id}. {name} ({totalPairs})",
"enterFolderName": "输入文件夹名称:",
"confirmDelete": "输入 \"{name}\" 以删除:",
"myFolders": "我的文件夹",
"publicFolders": "公开文件夹",
"public": "公开",
"private": "私有",
"setPublic": "设为公开",
"setPrivate": "设为私有",
"publicFolderInfo": "{userName} • {totalPairs} 个文本对",
"searchPlaceholder": "搜索公开文件夹...",
"loading": "加载中...",
"noPublicFolders": "没有找到公开文件夹",
"unknownUser": "未知用户",
"enterNewName": "输入新名称:",
"favorite": "收藏",
"unfavorite": "取消收藏",
"pleaseLogin": "请先登录"
"createFolderSuccess": "文件夹创建成功",
"deleteFolderSuccess": "文件夹删除成功",
"createFolderError": "创建文件夹失败",
"deleteFolderError": "删除文件夹失败"
},
"folder_id": {
"unauthorized": "您不是此文件夹的所有者",
@@ -60,19 +44,10 @@
"update": "更新",
"text1": "文本1",
"text2": "文本2",
"language1": "语言1",
"language2": "语言2",
"enterLanguageName": "请输入语言名称",
"locale1": "语言1",
"locale2": "语言2",
"edit": "编辑",
"delete": "删除",
"permissionDenied": "您没有权限执行此操作",
"error": {
"update": "您没有权限更新此项目",
"delete": "您没有权限删除此项目",
"add": "您没有权限向此文件夹添加项目",
"rename": "您没有权限重命名此文件夹",
"deleteFolder": "您没有权限删除此文件夹"
}
"delete": "删除"
},
"home": {
"title": "学语言",
@@ -102,73 +77,44 @@
"name": "记忆",
"description": "语言A到语言B语言B到语言A支持听写"
},
"dictionary": {
"name": "词典",
"description": "查询单词和短语,提供详细的释义和例句"
},
"moreFeatures": {
"name": "更多功能",
"description": "开发中,敬请期待"
}
},
"login": {
"loading": "加载中...",
"githubLogin": "GitHub登录"
},
"auth": {
"title": "登录",
"signUpTitle": "注册",
"signIn": "登录",
"signUp": "注册",
"email": "邮箱",
"password": "密码",
"confirmPassword": "确认密码",
"name": "用户名",
"username": "用户名",
"emailOrUsername": "邮箱或用户名",
"signInButton": "登录",
"signUpButton": "注册",
"noAccount": "还没有账户?",
"hasAccount": "已有账户?",
"signInWithGitHub": "使用 GitHub 登录",
"signUpWithGitHub": "使用 GitHub 注册",
"signInWithGitHub": "使用GitHub登录",
"signUpWithGitHub": "使用GitHub注册",
"invalidEmail": "请输入有效的邮箱地址",
"passwordTooShort": "密码至少需要8个字符",
"passwordsNotMatch": "两次输入的密码不匹配",
"signInFailed": "登录失败,请检查您的邮箱和密码",
"signUpFailed": "注册失败,请稍后再试",
"nameRequired": "请输入用户名",
"usernameRequired": "请输入用户名",
"usernameTooShort": "用户名至少需要3个字符",
"usernameInvalid": "用户名只能包含字母、数字和下划线",
"emailRequired": "请输入邮箱",
"identifierRequired": "请输入邮箱或用户名",
"passwordRequired": "请输入密码",
"confirmPasswordRequired": "请确认密码",
"loading": "加载中...",
"confirm": "确认",
"noAccountLink": "没有账号?去注册",
"hasAccountLink": "已有账号?去登录",
"usernamePlaceholder": "用户名",
"emailPlaceholder": "邮箱地址",
"passwordPlaceholder": "密码",
"usernameOrEmailPlaceholder": "用户名或邮箱地址",
"loginFailed": "登录失败",
"signUpFailed": "注册失败",
"fillAllFields": "请填写所有字段",
"enterCredentials": "请输入用户名和密码",
"forgotPassword": "忘记密码",
"forgotPasswordHint": "输入您的邮箱地址,我们将向您发送重置密码的链接。",
"sendResetEmail": "发送重置邮件",
"resetPasswordFailed": "发送重置邮件失败",
"resetPasswordEmailSent": "重置邮件已发送",
"resetPasswordEmailSentHint": "我们已向您的邮箱发送了密码重置链接,请查收。",
"checkYourEmail": "请查收邮件",
"backToLogin": "返回登录",
"resetPassword": "重置密码",
"newPassword": "新密码",
"invalidToken": "链接无效或已过期",
"invalidTokenHint": "此密码重置链接无效或已过期,请重新申请。",
"requestNewToken": "重新申请重置链接",
"resetPasswordSuccess": "密码重置成功",
"resetPasswordSuccessTitle": "密码重置完成",
"resetPasswordSuccessHint": "您的密码已成功重置,现在可以使用新密码登录了。"
"confirmPasswordRequired": "请确认密码"
},
"memorize": {
"choose": {
"back": "返回",
"choose": "选择"
},
"folder_selector": {
"selectFolder": "选择文件夹",
"noFolders": "未找到文件夹",
@@ -192,9 +138,7 @@
"sourceCode": "源码",
"sign_in": "登录",
"profile": "个人资料",
"folders": "文件夹",
"explore": "探索",
"favorites": "收藏"
"folders": "文件夹"
},
"profile": {
"myProfile": "我的个人资料",
@@ -211,6 +155,18 @@
"next": "下句",
"restart": "句首",
"autoPause": "自动暂停({enabled})",
"playbackSpeed": "播放速度",
"subtitleSettings": "字幕设置",
"fontSize": "字体大小",
"backgroundColor": "背景颜色",
"textColor": "文字颜色",
"fontFamily": "字体",
"opacity": "透明度",
"position": "位置",
"top": "顶部",
"center": "居中",
"bottom": "底部",
"keyboardShortcuts": "键盘快捷键",
"uploadVideoAndSubtitle": "请上传视频和字幕文件",
"uploadVideoFile": "请上传视频文件",
"uploadSubtitleFile": "请上传字幕文件",
@@ -220,17 +176,20 @@
"subtitleFile": "字幕文件",
"uploaded": "已上传",
"notUploaded": "未上传",
"uploadVideoButton": "上传视频",
"uploadSubtitleButton": "上传字幕",
"subtitleUploaded": "字幕已上传 ({count} 条)",
"subtitleNotUploaded": "字幕未上传",
"autoPauseStatus": "自动暂停: {enabled}",
"on": "开",
"off": "关",
"videoUploadFailed": "视频上传失败",
"subtitleUploadFailed": "字幕上传失败",
"subtitleLoadSuccess": "字幕加载成功",
"subtitleLoadFailed": "字幕加载失败"
"subtitleLoadSuccess": "字幕文件加载成功",
"subtitleLoadFailed": "字幕文件加载失败",
"shortcuts": {
"playPause": "播放/暂停",
"next": "下一句",
"previous": "上一句",
"restart": "句首",
"autoPause": "切换自动暂停"
}
},
"text_speaker": {
"generateIPA": "生成IPA",
@@ -243,14 +202,7 @@
"translateInto": "翻译为",
"chinese": "中文",
"english": "英文",
"french": "法语",
"german": "德语",
"italian": "意大利语",
"japanese": "日语",
"korean": "韩语",
"portuguese": "葡萄牙语",
"russian": "俄语",
"spanish": "西班牙语",
"other": "其他",
"translating": "翻译中...",
"translate": "翻译",
@@ -267,94 +219,5 @@
"error": "添加文本对到文件夹失败"
},
"autoSave": "自动保存"
},
"dictionary": {
"title": "词典",
"description": "查询单词和短语,提供详细的释义和例句",
"searchPlaceholder": "输入要查询的单词或短语...",
"searching": "查询中...",
"search": "查询",
"languageSettings": "语言设置",
"queryLanguage": "查询语言",
"queryLanguageHint": "你要查询的单词/短语是什么语言",
"definitionLanguage": "释义语言",
"definitionLanguageHint": "你希望用什么语言查看释义",
"otherLanguagePlaceholder": "或输入其他语言...",
"other": "其他",
"currentSettings": "当前设置:查询 {queryLang},释义 {definitionLang}",
"relookup": "重新查询",
"saveToFolder": "保存到文件夹",
"loading": "加载中...",
"noResults": "未找到结果",
"tryOtherWords": "尝试其他单词或短语",
"welcomeTitle": "欢迎使用词典",
"welcomeHint": "在上方搜索框中输入单词或短语开始查询",
"lookupFailed": "查询失败,请稍后重试",
"relookupSuccess": "已重新查询",
"relookupFailed": "词典重新查询失败",
"pleaseLogin": "请先登录",
"pleaseCreateFolder": "请先创建文件夹",
"savedToFolder": "已保存到文件夹:{folderName}",
"saveFailed": "保存失败,请稍后重试",
"definition": "释义",
"example": "例句"
},
"explore": {
"title": "探索",
"subtitle": "发现公开文件夹",
"searchPlaceholder": "搜索公开文件夹...",
"loading": "加载中...",
"noFolders": "没有找到公开文件夹",
"folderInfo": "{userName} • {totalPairs} 个文本对",
"unknownUser": "未知用户",
"favorite": "收藏",
"unfavorite": "取消收藏",
"pleaseLogin": "请先登录",
"sortByFavorites": "按收藏数排序",
"sortByFavoritesActive": "取消按收藏数排序"
},
"exploreDetail": {
"title": "文件夹详情",
"createdBy": "创建者:{name}",
"unknownUser": "未知用户",
"totalPairs": "词对数量",
"favorites": "收藏数",
"createdAt": "创建时间",
"viewContent": "查看内容",
"favorite": "收藏",
"unfavorite": "取消收藏",
"favorited": "已收藏",
"unfavorited": "已取消收藏",
"pleaseLogin": "请先登录"
},
"favorites": {
"title": "我的收藏",
"subtitle": "收藏的公开文件夹",
"loading": "加载中...",
"noFavorites": "还没有收藏任何文件夹",
"folderInfo": "{userName} • {totalPairs} 个文本对",
"unknownUser": "未知用户"
},
"user_profile": {
"anonymous": "匿名",
"email": "邮箱",
"verified": "已验证",
"unverified": "未验证",
"accountInfo": "账户信息",
"userId": "用户ID",
"username": "用户名",
"displayName": "显示名称",
"notSet": "未设置",
"memberSince": "注册时间",
"logout": "登出",
"folders": {
"title": "文件夹",
"noFolders": "还没有文件夹",
"folderName": "文件夹名称",
"totalPairs": "文本对数量",
"createdAt": "创建时间",
"actions": "操作",
"view": "查看"
}
}
}

View File

@@ -2,7 +2,7 @@
"name": "learn-languages",
"version": "0.1.0",
"private": true,
"license": "AGPL-3.0-only",
"license": "GPL-3.0-only",
"type": "module",
"scripts": {
"dev": "next dev --experimental-https",
@@ -11,43 +11,36 @@
"lint": "eslint"
},
"dependencies": {
"@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "7.4.2",
"@prisma/adapter-pg": "^7.1.0",
"@prisma/client": "^7.1.0",
"bcryptjs": "^3.0.3",
"better-auth": "^1.4.10",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"better-auth": "^1.4.6",
"dotenv": "^17.2.3",
"lucide-react": "^0.562.0",
"next": "16.1.1",
"next-intl": "^4.7.0",
"nodemailer": "^8.0.2",
"openai": "^6.27.0",
"edge-tts-universal": "^1.3.3",
"lucide-react": "^0.561.0",
"next": "16.0.10",
"next-intl": "^4.5.8",
"pg": "^8.16.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"unstorage": "^1.17.3",
"winston": "^3.19.0",
"zod": "^4.3.5",
"zustand": "^5.0.11"
"zod": "^4.1.13"
},
"devDependencies": {
"@better-auth/cli": "^1.4.10",
"@better-auth/cli": "^1.4.6",
"@eslint/eslintrc": "^3.3.3",
"@tailwindcss/postcss": "^4.1.18",
"@types/node": "^25.0.3",
"@types/nodemailer": "^7.0.11",
"@types/node": "^25.0.1",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@typescript-eslint/eslint-plugin": "^8.51.0",
"@typescript-eslint/parser": "^8.51.0",
"@typescript-eslint/eslint-plugin": "^8.49.0",
"@typescript-eslint/parser": "^8.49.0",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.39.2",
"eslint-config-next": "16.1.1",
"eslint": "^9.39.1",
"eslint-config-next": "16.0.10",
"eslint-plugin-react": "^7.37.5",
"prisma": "^7.4.2",
"prisma": "^7.1.0",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3"
},

1519
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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,262 +0,0 @@
-- CreateEnum
CREATE TYPE "Visibility" AS ENUM ('PRIVATE', 'PUBLIC');
-- 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,
"displayUsername" TEXT,
"username" TEXT 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")
);
-- CreateTable
CREATE TABLE "pairs" (
"id" SERIAL NOT NULL,
"language1" TEXT NOT NULL,
"language2" TEXT 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,
"visibility" "Visibility" NOT NULL DEFAULT 'PRIVATE',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "folders_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "folder_favorites" (
"id" SERIAL NOT NULL,
"user_id" TEXT NOT NULL,
"folder_id" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "folder_favorites_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dictionary_lookups" (
"id" SERIAL NOT NULL,
"user_id" TEXT,
"text" TEXT NOT NULL,
"query_lang" TEXT NOT NULL,
"definition_lang" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"dictionary_item_id" INTEGER,
"normalized_text" TEXT NOT NULL DEFAULT '',
CONSTRAINT "dictionary_lookups_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dictionary_items" (
"id" SERIAL NOT NULL,
"frequency" INTEGER NOT NULL DEFAULT 1,
"standard_form" TEXT NOT NULL,
"query_lang" TEXT NOT NULL,
"definition_lang" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "dictionary_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dictionary_entries" (
"id" SERIAL NOT NULL,
"item_id" INTEGER NOT NULL,
"ipa" TEXT,
"definition" TEXT NOT NULL,
"part_of_speech" TEXT,
"example" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "dictionary_entries_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "translation_history" (
"id" SERIAL NOT NULL,
"user_id" TEXT,
"source_text" TEXT NOT NULL,
"source_language" TEXT NOT NULL,
"target_language" TEXT NOT NULL,
"translated_text" TEXT NOT NULL,
"source_ipa" TEXT,
"target_ipa" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "translation_history_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
-- CreateIndex
CREATE UNIQUE INDEX "user_username_key" ON "user"("username");
-- CreateIndex
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
-- CreateIndex
CREATE INDEX "session_userId_idx" ON "session"("userId");
-- CreateIndex
CREATE INDEX "account_userId_idx" ON "account"("userId");
-- CreateIndex
CREATE INDEX "verification_identifier_idx" ON "verification"("identifier");
-- CreateIndex
CREATE INDEX "pairs_folder_id_idx" ON "pairs"("folder_id");
-- CreateIndex
CREATE UNIQUE INDEX "pairs_folder_id_language1_language2_text1_text2_key" ON "pairs"("folder_id", "language1", "language2", "text1", "text2");
-- CreateIndex
CREATE INDEX "folders_user_id_idx" ON "folders"("user_id");
-- CreateIndex
CREATE INDEX "folders_visibility_idx" ON "folders"("visibility");
-- CreateIndex
CREATE INDEX "folder_favorites_user_id_idx" ON "folder_favorites"("user_id");
-- CreateIndex
CREATE INDEX "folder_favorites_folder_id_idx" ON "folder_favorites"("folder_id");
-- CreateIndex
CREATE UNIQUE INDEX "folder_favorites_user_id_folder_id_key" ON "folder_favorites"("user_id", "folder_id");
-- CreateIndex
CREATE INDEX "dictionary_lookups_user_id_idx" ON "dictionary_lookups"("user_id");
-- CreateIndex
CREATE INDEX "dictionary_lookups_created_at_idx" ON "dictionary_lookups"("created_at");
-- CreateIndex
CREATE INDEX "dictionary_lookups_normalized_text_idx" ON "dictionary_lookups"("normalized_text");
-- CreateIndex
CREATE INDEX "dictionary_items_standard_form_idx" ON "dictionary_items"("standard_form");
-- CreateIndex
CREATE INDEX "dictionary_items_query_lang_definition_lang_idx" ON "dictionary_items"("query_lang", "definition_lang");
-- CreateIndex
CREATE UNIQUE INDEX "dictionary_items_standard_form_query_lang_definition_lang_key" ON "dictionary_items"("standard_form", "query_lang", "definition_lang");
-- CreateIndex
CREATE INDEX "dictionary_entries_item_id_idx" ON "dictionary_entries"("item_id");
-- CreateIndex
CREATE INDEX "dictionary_entries_created_at_idx" ON "dictionary_entries"("created_at");
-- CreateIndex
CREATE INDEX "translation_history_user_id_idx" ON "translation_history"("user_id");
-- CreateIndex
CREATE INDEX "translation_history_created_at_idx" ON "translation_history"("created_at");
-- CreateIndex
CREATE INDEX "translation_history_source_text_target_language_idx" ON "translation_history"("source_text", "target_language");
-- CreateIndex
CREATE INDEX "translation_history_translated_text_source_language_target__idx" ON "translation_history"("translated_text", "source_language", "target_language");
-- 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;
-- 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 "folder_favorites" ADD CONSTRAINT "folder_favorites_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "folder_favorites" ADD CONSTRAINT "folder_favorites_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_item_id_fkey" FOREIGN KEY ("dictionary_item_id") REFERENCES "dictionary_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dictionary_entries" ADD CONSTRAINT "dictionary_entries_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "dictionary_items"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "translation_history" ADD CONSTRAINT "translation_history_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -1,3 +1,4 @@
generator client {
provider = "prisma-client"
output = "../generated/prisma"
@@ -7,30 +8,59 @@ datasource db {
provider = "postgresql"
}
model Pair {
id Int @id @default(autoincrement())
locale1 String @db.VarChar(10)
locale2 String @db.VarChar(10)
text1 String
text2 String
ipa1 String?
ipa2 String?
folderId Int @map("folder_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
@@unique([folderId, locale1, locale2, text1])
@@index([folderId])
@@map("pairs")
}
model Folder {
id Int @id @default(autoincrement())
name String
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
pairs Pair[]
@@index([userId])
@@map("folders")
}
model User {
id String @id
name String
email String @unique
email String
emailVerified Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
displayUsername String?
username String @unique
accounts Account[]
dictionaryLookUps DictionaryLookUp[]
folders Folder[]
folderFavorites FolderFavorite[]
sessions Session[]
translationHistories TranslationHistory[]
accounts Account[]
folders Folder[]
@@unique([email])
@@map("user")
}
model Session {
id String @id
expiresAt DateTime
token String @unique
token String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ipAddress String?
@@ -38,6 +68,7 @@ model Session {
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([token])
@@index([userId])
@@map("session")
}
@@ -47,6 +78,7 @@ model Account {
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
@@ -56,7 +88,6 @@ model Account {
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("account")
@@ -73,128 +104,3 @@ model Verification {
@@index([identifier])
@@map("verification")
}
model Pair {
id Int @id @default(autoincrement())
language1 String
language2 String
text1 String
text2 String
ipa1 String?
ipa2 String?
folderId Int @map("folder_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
@@unique([folderId, language1, language2, text1, text2])
@@index([folderId])
@@map("pairs")
}
enum Visibility {
PRIVATE
PUBLIC
}
model Folder {
id Int @id @default(autoincrement())
name String
userId String @map("user_id")
visibility Visibility @default(PRIVATE)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
pairs Pair[]
favorites FolderFavorite[]
@@index([userId])
@@index([visibility])
@@map("folders")
}
model FolderFavorite {
id Int @id @default(autoincrement())
userId String @map("user_id")
folderId Int @map("folder_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
@@unique([userId, folderId])
@@index([userId])
@@index([folderId])
@@map("folder_favorites")
}
model DictionaryLookUp {
id Int @id @default(autoincrement())
userId String? @map("user_id")
text String
queryLang String @map("query_lang")
definitionLang String @map("definition_lang")
createdAt DateTime @default(now()) @map("created_at")
dictionaryItemId Int? @map("dictionary_item_id")
normalizedText String @default("") @map("normalized_text")
dictionaryItem DictionaryItem? @relation(fields: [dictionaryItemId], references: [id])
user User? @relation(fields: [userId], references: [id])
@@index([userId])
@@index([createdAt])
@@index([normalizedText])
@@map("dictionary_lookups")
}
model DictionaryItem {
id Int @id @default(autoincrement())
frequency Int @default(1)
standardForm String @map("standard_form")
queryLang String @map("query_lang")
definitionLang String @map("definition_lang")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
entries DictionaryEntry[]
lookups DictionaryLookUp[]
@@unique([standardForm, queryLang, definitionLang])
@@index([standardForm])
@@index([queryLang, definitionLang])
@@map("dictionary_items")
}
model DictionaryEntry {
id Int @id @default(autoincrement())
itemId Int @map("item_id")
ipa String?
definition String
partOfSpeech String? @map("part_of_speech")
example String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
item DictionaryItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
@@index([itemId])
@@index([createdAt])
@@map("dictionary_entries")
}
model TranslationHistory {
id Int @id @default(autoincrement())
userId String? @map("user_id")
sourceText String @map("source_text")
sourceLanguage String @map("source_language")
targetLanguage String @map("target_language")
translatedText String @map("translated_text")
sourceIpa String? @map("source_ipa")
targetIpa String? @map("target_ipa")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User? @relation(fields: [userId], references: [id])
@@index([userId])
@@index([createdAt])
@@index([sourceText, targetLanguage])
@@index([translatedText, sourceLanguage, targetLanguage])
@@map("translation_history")
}

View File

@@ -1,102 +0,0 @@
"use client";
import { useState } from "react";
import { authClient } from "@/lib/auth-client";
import Link from "next/link";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Card, CardBody } from "@/design-system/base/card";
import { Input } from "@/design-system/base/input";
import { PrimaryButton } from "@/design-system/base/button";
import { VStack } from "@/design-system/layout/stack";
export default function ForgotPasswordPage() {
const t = useTranslations("auth");
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
const handleResetRequest = async () => {
if (!email) {
toast.error(t("emailRequired"));
return;
}
setLoading(true);
const { error } = await authClient.requestPasswordReset({
email,
redirectTo: "/reset-password",
});
if (error) {
toast.error(error.message ?? t("resetPasswordFailed"));
} else {
setSent(true);
toast.success(t("resetPasswordEmailSent"));
}
setLoading(false);
};
if (sent) {
return (
<div className="flex justify-center items-center min-h-screen">
<Card className="w-96">
<CardBody>
<VStack gap={4} align="center" justify="center">
<h1 className="text-2xl font-bold text-center w-full">
{t("checkYourEmail")}
</h1>
<p className="text-center text-gray-600">
{t("resetPasswordEmailSentHint")}
</p>
<Link
href="/login"
className="text-primary-500 hover:underline"
>
{t("backToLogin")}
</Link>
</VStack>
</CardBody>
</Card>
</div>
);
}
return (
<div className="flex justify-center items-center min-h-screen">
<Card className="w-96">
<CardBody>
<VStack gap={4} align="center" justify="center">
<h1 className="text-3xl font-bold text-center w-full">
{t("forgotPassword")}
</h1>
<p className="text-center text-gray-600 text-sm">
{t("forgotPasswordHint")}
</p>
<VStack gap={0} align="center" justify="center" className="w-full">
<Input
type="email"
placeholder={t("emailPlaceholder")}
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</VStack>
<PrimaryButton
onClick={handleResetRequest}
loading={loading}
fullWidth
>
{t("sendResetEmail")}
</PrimaryButton>
<Link
href="/login"
className="text-center text-primary-500 hover:underline"
>
{t("backToLogin")}
</Link>
</VStack>
</CardBody>
</Card>
</div>
);
}

View File

@@ -1,113 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { authClient } from "@/lib/auth-client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Card, CardBody } from "@/design-system/base/card";
import { Input } from "@/design-system/base/input";
import { PrimaryButton } from "@/design-system/base/button";
import { VStack } from "@/design-system/layout/stack";
export default function LoginPage() {
const t = useTranslations("auth");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const searchParams = useSearchParams();
const redirectTo = searchParams.get("redirect");
const { data: session, isPending } = authClient.useSession();
const router = useRouter();
useEffect(() => {
if (!isPending && session?.user?.username && !redirectTo) {
router.push("/folders");
}
}, [session, isPending, router, redirectTo]);
const handleLogin = async () => {
if (!username || !password) {
toast.error(t("enterCredentials"));
return;
}
setLoading(true);
try {
if (username.includes("@")) {
const { error } = await authClient.signIn.email({
email: username,
password: password,
});
if (error) {
toast.error(error.message ?? t("loginFailed"));
return;
}
} else {
const { error } = await authClient.signIn.username({
username: username,
password: password,
});
if (error) {
toast.error(error.message ?? t("loginFailed"));
return;
}
}
router.push(redirectTo ?? "/folders");
} finally {
setLoading(false);
}
};
return (
<div className="flex justify-center items-center min-h-screen">
<Card className="w-96">
<CardBody>
<VStack gap={4} align="center" justify="center">
<h1 className="text-3xl font-bold text-center w-full">{t("title")}</h1>
<VStack gap={0} align="center" justify="center" className="w-full">
<Input
placeholder={t("usernameOrEmailPlaceholder")}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<Input
type="password"
placeholder={t("passwordPlaceholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</VStack>
<Link
href="/forgot-password"
className="text-sm text-gray-500 hover:text-primary-500 self-end"
>
{t("forgotPassword")}
</Link>
<PrimaryButton
onClick={handleLogin}
loading={loading}
fullWidth
>
{t("confirm")}
</PrimaryButton>
<Link
href={"/signup" + (redirectTo ? `?redirect=${redirectTo}` : "")}
className="text-center text-primary-500 hover:underline"
>
{t("noAccountLink")}
</Link>
</VStack>
</CardBody>
</Card>
</div>
);
}

View File

@@ -1,25 +0,0 @@
import { auth } from "@/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export default async function LogoutPage(
props: {
searchParams: Promise<{ [key: string]: string | undefined; }>;
}
) {
const searchParams = await props.searchParams;
const redirectTo = searchParams.redirect ?? null;
const session = await auth.api.getSession({
headers: await headers()
});
if (session) {
await auth.api.signOut({
headers: await headers()
});
redirect("/login" + (redirectTo ? `?redirect=${redirectTo}` : ""));
} else {
redirect("/profile");
}
return (<></>);
}

View File

@@ -1,13 +0,0 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
export default async function ProfilePage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
redirect("/login?redirect=/profile");
}
redirect(session.user.username ? `/users/${session.user.username}` : "/folders");
}

View File

@@ -1,154 +0,0 @@
"use client";
import { useState } from "react";
import { authClient } from "@/lib/auth-client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Card, CardBody } from "@/design-system/base/card";
import { Input } from "@/design-system/base/input";
import { PrimaryButton } from "@/design-system/base/button";
import { VStack } from "@/design-system/layout/stack";
export default function ResetPasswordPage() {
const t = useTranslations("auth");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token");
const handleResetPassword = async () => {
if (!password || !confirmPassword) {
toast.error(t("fillAllFields"));
return;
}
if (password !== confirmPassword) {
toast.error(t("passwordsNotMatch"));
return;
}
if (password.length < 8) {
toast.error(t("passwordTooShort"));
return;
}
if (!token) {
toast.error(t("invalidToken"));
return;
}
setLoading(true);
const { error } = await authClient.resetPassword({
newPassword: password,
token,
});
if (error) {
toast.error(error.message ?? t("resetPasswordFailed"));
} else {
setSuccess(true);
toast.success(t("resetPasswordSuccess"));
setTimeout(() => {
router.push("/login");
}, 2000);
}
setLoading(false);
};
if (success) {
return (
<div className="flex justify-center items-center min-h-screen">
<Card className="w-96">
<CardBody>
<VStack gap={4} align="center" justify="center">
<h1 className="text-2xl font-bold text-center w-full">
{t("resetPasswordSuccessTitle")}
</h1>
<p className="text-center text-gray-600">
{t("resetPasswordSuccessHint")}
</p>
<Link
href="/login"
className="text-primary-500 hover:underline"
>
{t("backToLogin")}
</Link>
</VStack>
</CardBody>
</Card>
</div>
);
}
if (!token) {
return (
<div className="flex justify-center items-center min-h-screen">
<Card className="w-96">
<CardBody>
<VStack gap={4} align="center" justify="center">
<h1 className="text-2xl font-bold text-center w-full">
{t("invalidToken")}
</h1>
<p className="text-center text-gray-600">
{t("invalidTokenHint")}
</p>
<Link
href="/forgot-password"
className="text-primary-500 hover:underline"
>
{t("requestNewToken")}
</Link>
</VStack>
</CardBody>
</Card>
</div>
);
}
return (
<div className="flex justify-center items-center min-h-screen">
<Card className="w-96">
<CardBody>
<VStack gap={4} align="center" justify="center">
<h1 className="text-3xl font-bold text-center w-full">
{t("resetPassword")}
</h1>
<VStack gap={0} align="center" justify="center" className="w-full">
<Input
type="password"
placeholder={t("newPassword")}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Input
type="password"
placeholder={t("confirmPassword")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</VStack>
<PrimaryButton
onClick={handleResetPassword}
loading={loading}
fullWidth
>
{t("resetPassword")}
</PrimaryButton>
<Link
href="/login"
className="text-center text-primary-500 hover:underline"
>
{t("backToLogin")}
</Link>
</VStack>
</CardBody>
</Card>
</div>
);
}

View File

@@ -1,106 +0,0 @@
"use client";
import { useState } from "react";
import { authClient } from "@/lib/auth-client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Card, CardBody } from "@/design-system/base/card";
import { Input } from "@/design-system/base/input";
import { PrimaryButton } from "@/design-system/base/button";
import { VStack } from "@/design-system/layout/stack";
export default function SignUpPage() {
const t = useTranslations("auth");
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const searchParams = useSearchParams();
const redirectTo = searchParams.get("redirect");
const { data: session, isPending } = authClient.useSession();
const router = useRouter();
useEffect(() => {
if (!isPending && session?.user?.username && !redirectTo) {
router.push("/folders");
}
}, [session, isPending, router, redirectTo]);
const handleSignUp = async () => {
if (!username || !email || !password) {
toast.error(t("fillAllFields"));
return;
}
setLoading(true);
try {
const { error } = await authClient.signUp.email({
email: email,
name: username,
username: username,
password: password,
});
if (error) {
toast.error(error.message ?? t("signUpFailed"));
return;
}
router.push(redirectTo ?? "/folders");
} finally {
setLoading(false);
}
};
return (
<div className="flex justify-center items-center min-h-screen">
<Card className="w-96">
<CardBody>
<VStack gap={4} align="center" justify="center">
<h1 className="text-3xl font-bold text-center w-full">{t("signUpTitle")}</h1>
<VStack gap={0} align="center" justify="center" className="w-full">
<Input
placeholder={t("usernamePlaceholder")}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<Input
type="email"
placeholder={t("emailPlaceholder")}
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Input
type="password"
placeholder={t("passwordPlaceholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</VStack>
<PrimaryButton
onClick={handleSignUp}
loading={loading}
fullWidth
>
{t("confirm")}
</PrimaryButton>
<Link
href={"/login" + (redirectTo ? `?redirect=${redirectTo}` : "")}
className="text-center text-primary-500 hover:underline"
>
{t("hasAccountLink")}
</Link>
</VStack>
</CardBody>
</Card>
</div>
);
}

View File

@@ -1,179 +0,0 @@
import Image from "next/image";
import Link from "next/link";
import { PageLayout } from "@/components/ui/PageLayout";
import { LightButton, LinkButton } from "@/design-system/base/button";
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
import { repoGetFoldersWithTotalPairsByUserId } from "@/modules/folder/folder-repository";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { auth } from "@/auth";
import { headers } from "next/headers";
// import { LogoutButton } from "./LogoutButton";
interface UserPageProps {
params: Promise<{ username: string; }>;
}
export default async function UserPage({ params }: UserPageProps) {
const { username } = await params;
const t = await getTranslations("user_profile");
// Get current session
const session = await auth.api.getSession({ headers: await headers() });
// Get user profile
const result = await actionGetUserProfileByUsername({ username });
if (!result.success || !result.data) {
notFound();
}
const user = result.data;
// Get user's folders
const folders = await repoGetFoldersWithTotalPairsByUserId(user.id);
// Check if viewing own profile
const isOwnProfile = session?.user?.username === username || session?.user?.email === username;
return (
<PageLayout>
{/* Header */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<div></div>
{isOwnProfile && <LinkButton href="/logout">{t("logout")}</LinkButton>}
</div>
<div className="flex items-center space-x-6">
{/* Avatar */}
{user.image ? (
<div className="relative w-24 h-24 rounded-full border-4 border-primary-500 overflow-hidden">
<Image
src={user.image}
alt={user.displayUsername || user.username || user.email}
fill
className="object-cover"
unoptimized
/>
</div>
) : (
<div className="w-24 h-24 rounded-full bg-primary-500 border-4 border-primary-500 flex items-center justify-center">
<span className="text-3xl font-bold text-white">
{(user.displayUsername || user.username || user.email)[0].toUpperCase()}
</span>
</div>
)}
{/* User Info */}
<div className="flex-1">
<h1 className="text-3xl font-bold text-gray-800 mb-2">
{user.displayUsername || user.username || t("anonymous")}
</h1>
{user.username && (
<p className="text-gray-600 text-sm mb-1">
@{user.username}
</p>
)}
<p className="text-gray-600 text-sm mb-1">
{user.email}
</p>
<div className="flex items-center space-x-4 text-sm">
<span className="text-gray-500">
Joined: {new Date(user.createdAt).toLocaleDateString()}
</span>
{user.emailVerified && (
<span className="flex items-center text-green-600">
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.293 12.293a1 1 0 101.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
Verified
</span>
)}
</div>
</div>
</div>
</div>
{/* Account Info */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("accountInfo")}</h2>
<dl className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-gray-500">{t("userId")}</dt>
<dd className="mt-1 text-sm text-gray-900 font-mono break-all">{user.id}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">{t("username")}</dt>
<dd className="mt-1 text-sm text-gray-900">
{user.username || <span className="text-gray-400">{t("notSet")}</span>}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">{t("displayName")}</dt>
<dd className="mt-1 text-sm text-gray-900">
{user.displayUsername || <span className="text-gray-400">{t("notSet")}</span>}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">{t("memberSince")}</dt>
<dd className="mt-1 text-sm text-gray-900">
{new Date(user.createdAt).toLocaleDateString()}
</dd>
</div>
</dl>
</div>
{/* Folders Section */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("folders.title")}</h2>
{folders.length === 0 ? (
<p className="text-gray-500 text-center py-8">{t("folders.noFolders")}</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t("folders.folderName")}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t("folders.totalPairs")}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t("folders.createdAt")}
</th>
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{t("folders.actions")}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{folders.map((folder) => (
<tr key={folder.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{folder.name}</div>
<div className="text-sm text-gray-500">ID: {folder.id}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{folder.total}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(folder.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Link href={`/folders/${folder.id}`}>
<LinkButton>
{t("folders.view")}
</LinkButton>
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</PageLayout>
);
}

View File

@@ -3,11 +3,9 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
import { IconClick, CircleToggleButton, CircleButton, PrimaryButton } from "@/design-system/base/button";
import { IMAGES } from "@/config/images";
import { IconClick } from "@/components/ui/buttons";
import IMAGES from "@/config/images";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { PageLayout } from "@/components/ui/PageLayout";
import { Card } from "@/design-system/base/card";
interface AlphabetCardProps {
alphabet: Letter[];
@@ -15,7 +13,7 @@ interface AlphabetCardProps {
onBack: () => void;
}
export function AlphabetCard({ alphabet, alphabetType, onBack }: AlphabetCardProps) {
export default function AlphabetCard({ alphabet, alphabetType, onBack }: AlphabetCardProps) {
const t = useTranslations("alphabet");
const [currentIndex, setCurrentIndex] = useState(0);
const [showIPA, setShowIPA] = useState(true);
@@ -99,11 +97,12 @@ export function AlphabetCard({ alphabet, alphabetType, onBack }: AlphabetCardPro
};
return (
<PageLayout className="relative">
{/* 右上角返回按钮 - outside the white card */}
<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="lg"
size={32}
alt="close"
src={IMAGES.close}
onClick={onBack}
@@ -112,7 +111,7 @@ export function AlphabetCard({ alphabet, alphabetType, onBack }: AlphabetCardPro
</div>
{/* 白色主卡片容器 */}
<Card padding="xl">
<div className="bg-white rounded-2xl shadow-xl p-8 md:p-12">
{/* 顶部进度指示器和显示选项按钮 */}
<div className="flex justify-between items-center mb-6">
{/* 当前字母进度 */}
@@ -121,35 +120,51 @@ export function AlphabetCard({ alphabet, alphabetType, onBack }: AlphabetCardPro
</span>
{/* 显示选项切换按钮组 */}
<div className="flex gap-2 flex-wrap">
<CircleToggleButton
selected={showLetter}
<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")}
</CircleToggleButton>
</button>
{/* IPA 音标显示切换 */}
<CircleToggleButton
selected={showIPA}
<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
</CircleToggleButton>
</button>
{/* 罗马音显示切换(仅日语显示) */}
{hasRomanization && (
<CircleToggleButton
selected={showRoman}
<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")}
</CircleToggleButton>
</button>
)}
{/* 随机模式切换 */}
<CircleToggleButton
selected={isRandomMode}
<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")}
</CircleToggleButton>
</button>
</div>
</div>
@@ -184,28 +199,55 @@ export function AlphabetCard({ alphabet, alphabetType, onBack }: AlphabetCardPro
{/* 底部导航控制区域 */}
<div className="flex justify-between items-center">
{/* 上一个按钮 */}
<CircleButton onClick={goToPrevious} aria-label="上一个字母">
<ChevronLeft size={20} />
</CircleButton>
<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 && (
<PrimaryButton
{isRandomMode ? (
// 随机模式:显示随机切换按钮
<button
onClick={goToRandom}
className="rounded-full px-4 py-2 text-sm"
className="px-4 py-2 rounded-full bg-[#35786f] text-white text-sm font-medium hover:bg-[#2d5f58] transition-colors"
>
{t("randomNext")}
</PrimaryButton>
</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"
}`}
/>
))}
{/* 超过20个字母时显示省略号 */}
{alphabet.length > 20 && (
<div className="text-xs text-gray-500 flex items-center">...</div>
)}
</div>
)}
</div>
{/* 下一个按钮 */}
<CircleButton onClick={goToNext} aria-label="下一个字母">
<ChevronRight size={20} />
</CircleButton>
<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>
</Card>
{/* 底部操作提示文字 */}
<div className="text-center mt-6 text-white text-sm">
@@ -216,6 +258,7 @@ export function AlphabetCard({ alphabet, alphabetType, onBack }: AlphabetCardPro
}
</p>
</div>
</div>
{/* 全屏触摸事件监听层(用于滑动切换) */}
<div
@@ -224,6 +267,6 @@ export function AlphabetCard({ alphabet, alphabetType, onBack }: AlphabetCardPro
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
/>
</PageLayout>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { LightButton } from "@/design-system/base/button";
import { IconClick } from "@/design-system/base/button";
import { IMAGES } from "@/config/images";
import { LightButton } from "@/components/ui/buttons";
import { IconClick } from "@/components/ui/buttons";
import IMAGES from "@/config/images";
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
import {
Dispatch,
@@ -12,7 +12,7 @@ import {
} from "react";
import { useTranslations } from "next-intl";
export function MemoryCard({
export default function MemoryCard({
alphabet,
setChosenAlphabet,
}: {
@@ -45,10 +45,10 @@ export function MemoryCard({
className="w-full flex justify-center items-center"
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()}
>
<div className="m-4 p-4 w-full md:w-[60dvw] flex-col rounded-lg shadow border-gray-200 border flex justify-center items-center">
<div className="m-4 p-4 w-full md:w-[60dvw] flex-col rounded-2xl shadow border-gray-200 border flex justify-center items-center">
<div className="w-full flex justify-end items-center">
<IconClick
size="lg"
size={32}
alt="close"
src={IMAGES.close}
onClick={() => setChosenAlphabet(null)}
@@ -64,13 +64,13 @@ export function MemoryCard({
</div>
<div className="flex flex-row mt-32 items-center justify-center gap-2">
<IconClick
size="lg"
size={48}
alt="refresh"
src={IMAGES.refresh}
onClick={refresh}
></IconClick>
<IconClick
size="lg"
size={48}
alt="more"
src={IMAGES.more_horiz}
onClick={() => setMore(!more)}

View File

@@ -3,9 +3,9 @@
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
import { PageLayout } from "@/components/ui/PageLayout";
import { LightButton } from "@/design-system/base/button";
import { AlphabetCard } from "./AlphabetCard";
import Container from "@/components/ui/Container";
import { LightButton } from "@/components/ui/buttons";
import AlphabetCard from "./AlphabetCard";
export default function Alphabet() {
const t = useTranslations("alphabet");
@@ -48,14 +48,15 @@ export default function Alphabet() {
// 语言选择界面
if (!chosenAlphabet) {
return (
<PageLayout>
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex flex-col items-center justify-center px-4">
<Container className="p-8 max-w-2xl w-full text-center">
{/* 页面标题 */}
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
{t("chooseCharacters")}
</h1>
{/* 副标题说明 */}
<p className="text-lg text-gray-600 text-center">
{t("chooseAlphabetHint")}
<p className="text-gray-600 mb-8 text-lg">
</p>
{/* 语言选择按钮网格 */}
@@ -104,25 +105,30 @@ export default function Alphabet() {
</div>
</LightButton>
</div>
</PageLayout>
</Container>
</div>
);
}
// 加载状态
if (loadingState === "loading") {
return (
<PageLayout>
<div className="text-2xl text-gray-600 text-center">{t("loading")}</div>
</PageLayout>
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center">
<Container className="p-8 text-center">
<div className="text-2xl text-gray-600">{t("loading")}</div>
</Container>
</div>
);
}
// 错误状态
if (loadingState === "error") {
return (
<PageLayout>
<div className="text-2xl text-red-600 text-center">{t("loadFailed")}</div>
</PageLayout>
<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>
);
}

View File

@@ -1,240 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { useRouter, useSearchParams } from "next/navigation";
import { useDictionaryStore } from "./stores/dictionaryStore";
import { PageLayout } from "@/components/ui/PageLayout";
import { LightButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { Plus, RefreshCw } from "lucide-react";
import { DictionaryEntry } from "./DictionaryEntry";
import { LanguageSelector } from "./LanguageSelector";
import { authClient } from "@/lib/auth-client";
import { actionGetFoldersByUserId, actionCreatePair } from "@/modules/folder/folder-action";
import { TSharedFolder } from "@/shared/folder-type";
import { toast } from "sonner";
interface DictionaryClientProps {
initialFolders: TSharedFolder[];
}
export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
const t = useTranslations("dictionary");
const router = useRouter();
const searchParams = useSearchParams();
const {
query,
queryLang,
definitionLang,
searchResult,
isSearching,
setQuery,
setQueryLang,
setDefinitionLang,
search,
relookup,
syncFromUrl,
} = useDictionaryStore();
const { data: session } = authClient.useSession();
const [folders, setFolders] = useState<TSharedFolder[]>(initialFolders);
useEffect(() => {
const q = searchParams.get("q") || undefined;
const ql = searchParams.get("ql") || undefined;
const dl = searchParams.get("dl") || undefined;
syncFromUrl({ q, ql, dl });
if (q) {
search();
}
}, [searchParams, syncFromUrl, search]);
useEffect(() => {
if (session?.user?.id) {
actionGetFoldersByUserId(session.user.id).then((result) => {
if (result.success && result.data) {
setFolders(result.data);
}
});
}
}, [session?.user?.id]);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!query.trim()) return;
const params = new URLSearchParams({
q: query,
ql: queryLang,
dl: definitionLang,
});
router.push(`/dictionary?${params.toString()}`);
};
const handleSave = async () => {
if (!session) {
toast.error("Please login first");
return;
}
if (folders.length === 0) {
toast.error("Please create a folder first");
return;
}
const folderSelect = document.getElementById("folder-select") as HTMLSelectElement;
const folderId = folderSelect?.value ? Number(folderSelect.value) : folders[0]?.id;
if (!searchResult?.entries?.length) return;
const definition = searchResult.entries
.map((e) => e.definition)
.join(" | ");
try {
await actionCreatePair({
text1: searchResult.standardForm,
text2: definition,
language1: queryLang,
language2: definitionLang,
ipa1: searchResult.entries[0]?.ipa,
folderId: folderId,
});
const folderName = folders.find((f) => f.id === folderId)?.name || "Unknown";
toast.success(`Saved to ${folderName}`);
} catch (error) {
toast.error("Save failed");
}
};
return (
<PageLayout>
<div className="text-center mb-8">
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
{t("title")}
</h1>
<p className="text-gray-700 text-lg">
{t("description")}
</p>
</div>
<form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-2">
<Input
type="text"
name="searchQuery"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("searchPlaceholder")}
variant="search"
required
containerClassName="flex-1"
/>
<LightButton
type="submit"
className="h-10 px-6 rounded-full whitespace-nowrap"
loading={isSearching}
>
{t("search")}
</LightButton>
</form>
<div className="mt-4 bg-white/20 rounded-lg p-4">
<div className="mb-3">
<span className="text-gray-800 font-semibold">{t("languageSettings")}</span>
</div>
<div className="space-y-4">
<LanguageSelector
label={t("queryLanguage")}
hint={t("queryLanguageHint")}
value={queryLang}
onChange={setQueryLang}
/>
<LanguageSelector
label={t("definitionLanguage")}
hint={t("definitionLanguageHint")}
value={definitionLang}
onChange={setDefinitionLang}
/>
</div>
</div>
<div className="mt-8">
{isSearching ? (
<div className="text-center py-12">
<div className="w-8 h-8 border-2 border-gray-200 border-t-primary-500 rounded-full animate-spin mx-auto mb-3"></div>
<p className="text-gray-600">{t("searching")}</p>
</div>
) : query && !searchResult ? (
<div className="text-center py-12 bg-white/20 rounded-lg">
<p className="text-gray-800 text-xl">No results found</p>
<p className="text-gray-600 mt-2">Try other words</p>
</div>
) : searchResult ? (
<div className="bg-white rounded-lg p-6 shadow-lg">
<div className="flex items-start justify-between mb-6">
<div className="flex-1">
<h2 className="text-3xl font-bold text-gray-800 mb-2">
{searchResult.standardForm}
</h2>
</div>
<div className="flex items-center gap-2 ml-4">
{session && folders.length > 0 && (
<select
id="folder-select"
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[#35786f]"
>
{folders.map((folder) => (
<option key={folder.id} value={folder.id}>
{folder.name}
</option>
))}
</select>
)}
<LightButton
onClick={handleSave}
className="w-10 h-10 shrink-0"
title="Save to folder"
>
<Plus />
</LightButton>
</div>
</div>
<div className="space-y-6">
{searchResult.entries.map((entry, index) => (
<div key={index} className="border-t border-gray-200 pt-4">
<DictionaryEntry entry={entry} />
</div>
))}
</div>
<div className="border-t border-gray-200 pt-4 mt-4">
<LightButton
onClick={relookup}
className="flex items-center gap-2 px-4 py-2 text-sm"
loading={isSearching}
>
<RefreshCw className="w-4 h-4" />
Re-lookup
</LightButton>
</div>
</div>
) : (
<div className="text-center py-12">
<div className="text-6xl mb-4">📚</div>
<p className="text-gray-800 text-xl mb-2">{t("welcomeTitle")}</p>
<p className="text-gray-600">{t("welcomeHint")}</p>
</div>
)}
</div>
</PageLayout>
);
}

View File

@@ -1,45 +0,0 @@
import { TSharedEntry } from "@/shared/dictionary-type";
import { useTranslations } from "next-intl";
interface DictionaryEntryProps {
entry: TSharedEntry;
}
export function DictionaryEntry({ entry }: DictionaryEntryProps) {
const t = useTranslations("dictionary");
return (
<div>
<div className="flex items-center gap-3 mb-3">
{entry.ipa && (
<span className="text-gray-600 text-lg">
[{entry.ipa}]
</span>
)}
{entry.partOfSpeech && (
<span className="px-3 py-1 bg-[#35786f] text-white text-sm rounded-full">
{entry.partOfSpeech}
</span>
)}
</div>
<div className="mb-3">
<h3 className="text-sm font-semibold text-gray-700 mb-1">
{t("definition")}
</h3>
<p className="text-gray-800">{entry.definition}</p>
</div>
{entry.example && (
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-1">
{t("example")}
</h3>
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
{entry.example}
</p>
</div>
)}
</div>
);
}

View File

@@ -1,80 +0,0 @@
"use client";
import { useState } from "react";
import { LightButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { POPULAR_LANGUAGES } from "./constants";
import { useTranslations } from "next-intl";
interface LanguageSelectorProps {
label: string;
hint: string;
value: string;
onChange: (value: string) => void;
}
export function LanguageSelector({ label, hint, value, onChange }: LanguageSelectorProps) {
const t = useTranslations("dictionary");
const [showCustomInput, setShowCustomInput] = useState(false);
const [customLang, setCustomLang] = useState("");
const isPresetLanguage = POPULAR_LANGUAGES.some((lang) => lang.code === value);
const handlePresetSelect = (code: string) => {
onChange(code);
setShowCustomInput(false);
setCustomLang("");
};
const handleCustomToggle = () => {
setShowCustomInput(!showCustomInput);
if (!showCustomInput && customLang.trim()) {
onChange(customLang.trim());
}
};
const handleCustomChange = (newValue: string) => {
setCustomLang(newValue);
if (newValue.trim()) {
onChange(newValue.trim());
}
};
return (
<div>
<label className="block text-gray-700 text-sm mb-2">
{label} ({hint})
</label>
<div className="flex flex-wrap gap-2 mb-2">
{POPULAR_LANGUAGES.map((lang) => (
<LightButton
key={lang.code}
type="button"
selected={isPresetLanguage && value === lang.code}
onClick={() => handlePresetSelect(lang.code)}
className="text-sm px-3 py-1"
>
{lang.nativeName}
</LightButton>
))}
<LightButton
type="button"
selected={!isPresetLanguage && !!value}
onClick={handleCustomToggle}
className="text-sm px-3 py-1"
>
{t("other")}
</LightButton>
</div>
{(showCustomInput || (!isPresetLanguage && value)) && (
<Input
type="text"
value={isPresetLanguage ? customLang : value}
onChange={(e) => handleCustomChange(e.target.value)}
placeholder={t("otherLanguagePlaceholder")}
className="text-sm"
/>
)}
</div>
);
}

View File

@@ -1,8 +0,0 @@
export const POPULAR_LANGUAGES = [
{ code: "english", name: "英语", nativeName: "English" },
{ code: "chinese", name: "中文", nativeName: "中文" },
{ code: "japanese", name: "日语", nativeName: "日本語" },
{ code: "korean", name: "韩语", nativeName: "한국어" },
{ code: "italian", name: "意大利语", nativeName: "Italiano" },
{ code: "uyghur", name: "维吾尔语", nativeName: "ئۇيغۇرچە" },
] as const;

View File

@@ -1,20 +0,0 @@
import { DictionaryClient } from "./DictionaryClient";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { actionGetFoldersByUserId } from "@/modules/folder/folder-action";
import { TSharedFolder } from "@/shared/folder-type";
export default async function DictionaryPage() {
const session = await auth.api.getSession({ headers: await headers() });
let folders: TSharedFolder[] = [];
if (session?.user?.id) {
const result = await actionGetFoldersByUserId(session.user.id as string);
if (result.success && result.data) {
folders = result.data;
}
}
return <DictionaryClient initialFolders={folders} />;
}

View File

@@ -1,148 +0,0 @@
"use client";
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { TSharedItem } from "@/shared/dictionary-type";
import { actionLookUpDictionary } from "@/modules/dictionary/dictionary-action";
import { toast } from "sonner";
const POPULAR_LANGUAGES_MAP: Record<string, string> = {
english: "English",
chinese: "中文",
japanese: "日本語",
korean: "한국어",
italian: "Italiano",
uyghur: "ئۇيغۇرچە",
};
export function getNativeName(code: string): string {
return POPULAR_LANGUAGES_MAP[code] || code;
}
export interface DictionaryState {
query: string;
queryLang: string;
definitionLang: string;
searchResult: TSharedItem | null;
isSearching: boolean;
}
export interface DictionaryActions {
setQuery: (query: string) => void;
setQueryLang: (lang: string) => void;
setDefinitionLang: (lang: string) => void;
setSearchResult: (result: TSharedItem | null) => void;
search: () => Promise<void>;
relookup: () => Promise<void>;
syncFromUrl: (params: { q?: string; ql?: string; dl?: string }) => void;
}
export type DictionaryStore = DictionaryState & DictionaryActions;
const initialState: DictionaryState = {
query: "",
queryLang: "english",
definitionLang: "chinese",
searchResult: null,
isSearching: false,
};
export const useDictionaryStore = create<DictionaryStore>()(
devtools(
(set, get) => ({
...initialState,
setQuery: (query) => set({ query }),
setQueryLang: (queryLang) => set({ queryLang }),
setDefinitionLang: (definitionLang) => set({ definitionLang }),
setSearchResult: (searchResult) => set({ searchResult }),
search: async () => {
const { query, queryLang, definitionLang } = get();
if (!query.trim()) {
return;
}
set({ isSearching: true });
try {
const result = await actionLookUpDictionary({
text: query,
queryLang: getNativeName(queryLang),
definitionLang: getNativeName(definitionLang),
forceRelook: false,
});
if (result.success && result.data) {
set({ searchResult: result.data });
} else {
set({ searchResult: null });
if (result.message) {
toast.error(result.message);
}
}
} catch (error) {
set({ searchResult: null });
toast.error("Search failed");
} finally {
set({ isSearching: false });
}
},
relookup: async () => {
const { query, queryLang, definitionLang } = get();
if (!query.trim()) {
return;
}
set({ isSearching: true });
try {
const result = await actionLookUpDictionary({
text: query,
queryLang: getNativeName(queryLang),
definitionLang: getNativeName(definitionLang),
forceRelook: true,
});
if (result.success && result.data) {
set({ searchResult: result.data });
toast.success("Re-lookup successful");
} else {
if (result.message) {
toast.error(result.message);
}
}
} catch (error) {
toast.error("Re-lookup failed");
} finally {
set({ isSearching: false });
}
},
syncFromUrl: (params) => {
const updates: Partial<DictionaryState> = {};
if (params.q !== undefined) {
updates.query = params.q;
}
if (params.ql !== undefined) {
updates.queryLang = params.ql;
}
if (params.dl !== undefined) {
updates.definitionLang = params.dl;
}
if (Object.keys(updates).length > 0) {
set(updates);
}
},
}),
{ name: 'dictionary-store' }
)
);

View File

@@ -1,201 +0,0 @@
"use client";
import {
Folder as Fd,
Heart,
Search,
ArrowUpDown,
} from "lucide-react";
import { CircleButton } from "@/design-system/base/button";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { PageLayout } from "@/components/ui/PageLayout";
import { PageHeader } from "@/components/ui/PageHeader";
import {
actionSearchPublicFolders,
actionToggleFavorite,
actionCheckFavorite,
} from "@/modules/folder/folder-action";
import { TPublicFolder } from "@/shared/folder-type";
import { authClient } from "@/lib/auth-client";
interface PublicFolderCardProps {
folder: TPublicFolder;
currentUserId?: string;
onUpdateFavorite: (folderId: number, isFavorited: boolean, favoriteCount: number) => void;
}
const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFolderCardProps) => {
const router = useRouter();
const t = useTranslations("explore");
const [isFavorited, setIsFavorited] = useState(false);
const [favoriteCount, setFavoriteCount] = useState(folder.favoriteCount);
useEffect(() => {
if (currentUserId) {
actionCheckFavorite(folder.id).then((result) => {
if (result.success && result.data) {
setIsFavorited(result.data.isFavorited);
setFavoriteCount(result.data.favoriteCount);
}
});
}
}, [folder.id, currentUserId]);
const handleToggleFavorite = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!currentUserId) {
toast.error(t("pleaseLogin"));
return;
}
const result = await actionToggleFavorite(folder.id);
if (result.success && result.data) {
setIsFavorited(result.data.isFavorited);
setFavoriteCount(result.data.favoriteCount);
onUpdateFavorite(folder.id, result.data.isFavorited, result.data.favoriteCount);
} else {
toast.error(result.message);
}
};
return (
<div
className="group bg-white border border-gray-200 sm:border-2 rounded-lg p-3 sm:p-5 hover:border-primary-300 hover:shadow-md cursor-pointer transition-all overflow-hidden"
onClick={() => {
router.push(`/explore/${folder.id}`);
}}
>
<div className="flex items-start justify-between mb-2 sm:mb-3">
<div className="shrink-0 w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-primary-50 flex items-center justify-center text-primary-500">
<Fd size={18} className="sm:hidden" />
<Fd size={22} className="hidden sm:block" />
</div>
<CircleButton
onClick={handleToggleFavorite}
title={isFavorited ? t("unfavorite") : t("favorite")}
>
<Heart
size={16}
className={`sm:w-[18px] sm:h-[18px] sm:text-[18px] ${isFavorited ? "fill-red-500 text-red-500" : ""}`}
/>
</CircleButton>
</div>
<h3 className="font-semibold text-gray-900 truncate text-sm sm:text-base mb-1 sm:mb-2">{folder.name}</h3>
<p className="text-xs sm:text-sm text-gray-500 mb-2 sm:mb-3 line-clamp-2">
{t("folderInfo", {
userName: folder.userName ?? folder.userUsername ?? t("unknownUser"),
totalPairs: folder.totalPairs,
})}
</p>
<div className="flex items-center gap-1 text-xs sm:text-sm text-gray-400">
<Heart size={12} className="sm:w-3.5 sm:h-3.5" />
<span>{favoriteCount}</span>
</div>
</div>
);
};
interface ExploreClientProps {
initialPublicFolders: TPublicFolder[];
}
export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
const t = useTranslations("explore");
const router = useRouter();
const [publicFolders, setPublicFolders] = useState<TPublicFolder[]>(initialPublicFolders);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [sortByFavorites, setSortByFavorites] = useState(false);
const { data: session } = authClient.useSession();
const currentUserId = session?.user?.id;
const handleSearch = async () => {
if (!searchQuery.trim()) {
setPublicFolders(initialPublicFolders);
return;
}
setLoading(true);
const result = await actionSearchPublicFolders(searchQuery.trim());
if (result.success && result.data) {
setPublicFolders(result.data);
}
setLoading(false);
};
const handleToggleSort = () => {
setSortByFavorites((prev) => !prev);
};
const sortedFolders = sortByFavorites
? [...publicFolders].sort((a, b) => b.favoriteCount - a.favoriteCount)
: publicFolders;
const handleUpdateFavorite = (folderId: number, _isFavorited: boolean, favoriteCount: number) => {
setPublicFolders((prev) =>
prev.map((f) =>
f.id === folderId ? { ...f, favoriteCount } : f
)
);
};
return (
<PageLayout>
<PageHeader title={t("title")} subtitle={t("subtitle")} />
<div className="flex items-center gap-2 mb-6">
<div className="relative flex-1">
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder={t("searchPlaceholder")}
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<CircleButton
onClick={handleToggleSort}
title={sortByFavorites ? t("sortByFavoritesActive") : t("sortByFavorites")}
className={sortByFavorites ? "bg-primary-100 text-primary-600 hover:bg-primary-200" : ""}
>
<ArrowUpDown size={18} />
</CircleButton>
<CircleButton onClick={handleSearch}>
<Search size={18} />
</CircleButton>
</div>
{loading ? (
<div className="p-8 text-center">
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
<p className="text-sm text-gray-500">{t("loading")}</p>
</div>
) : sortedFolders.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
<Fd size={24} className="text-gray-400" />
</div>
<p className="text-sm">{t("noFolders")}</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{sortedFolders.map((folder) => (
<PublicFolderCard
key={folder.id}
folder={folder}
currentUserId={currentUserId}
onUpdateFavorite={handleUpdateFavorite}
/>
))}
</div>
)}
</PageLayout>
);
}

View File

@@ -1,146 +0,0 @@
"use client";
import { Folder as Fd, Heart, ExternalLink, ArrowLeft } from "lucide-react";
import { CircleButton } from "@/design-system/base/button";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import Link from "next/link";
import {
actionToggleFavorite,
actionCheckFavorite,
} from "@/modules/folder/folder-action";
import { ActionOutputPublicFolder } from "@/modules/folder/folder-action-dto";
import { authClient } from "@/lib/auth-client";
interface ExploreDetailClientProps {
folder: ActionOutputPublicFolder;
}
export function ExploreDetailClient({ folder }: ExploreDetailClientProps) {
const router = useRouter();
const t = useTranslations("exploreDetail");
const [isFavorited, setIsFavorited] = useState(false);
const [favoriteCount, setFavoriteCount] = useState(folder.favoriteCount);
const { data: session } = authClient.useSession();
const currentUserId = session?.user?.id;
useEffect(() => {
if (currentUserId) {
actionCheckFavorite(folder.id).then((result) => {
if (result.success && result.data) {
setIsFavorited(result.data.isFavorited);
setFavoriteCount(result.data.favoriteCount);
}
});
}
}, [folder.id, currentUserId]);
const handleToggleFavorite = async () => {
if (!currentUserId) {
toast.error(t("pleaseLogin"));
return;
}
const result = await actionToggleFavorite(folder.id);
if (result.success && result.data) {
setIsFavorited(result.data.isFavorited);
setFavoriteCount(result.data.favoriteCount);
toast.success(
result.data.isFavorited ? t("favorited") : t("unfavorited")
);
} else {
toast.error(result.message);
}
};
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(date));
};
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-3xl mx-auto px-4 py-6 sm:py-8">
<div className="flex items-center gap-3 mb-6">
<CircleButton onClick={() => router.push("/explore")}>
<ArrowLeft size={18} />
</CircleButton>
<h1 className="text-lg sm:text-xl font-semibold text-gray-900">
{t("title")}
</h1>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-5 sm:p-8 shadow-sm">
<div className="flex items-start justify-between mb-6">
<div className="flex items-center gap-4">
<div className="w-14 h-14 sm:w-16 sm:h-16 rounded-xl bg-primary-50 flex items-center justify-center text-primary-500">
<Fd size={28} className="sm:w-8 sm:h-8" />
</div>
<div>
<h2 className="text-xl sm:text-2xl font-bold text-gray-900">
{folder.name}
</h2>
<p className="text-sm text-gray-500 mt-1">
{t("createdBy", {
name: folder.userName ?? folder.userUsername ?? t("unknownUser"),
})}
</p>
</div>
</div>
<CircleButton
onClick={handleToggleFavorite}
title={isFavorited ? t("unfavorite") : t("favorite")}
className="shrink-0"
>
<Heart
size={20}
className={isFavorited ? "fill-red-500 text-red-500" : ""}
/>
</CircleButton>
</div>
<div className="grid grid-cols-3 gap-4 mb-6 py-4 border-y border-gray-100">
<div className="text-center">
<div className="text-2xl sm:text-3xl font-bold text-primary-600">
{folder.totalPairs}
</div>
<div className="text-xs sm:text-sm text-gray-500 mt-1">
{t("totalPairs")}
</div>
</div>
<div className="text-center border-x border-gray-100">
<div className="text-2xl sm:text-3xl font-bold text-red-500 flex items-center justify-center gap-1">
<Heart size={18} className={isFavorited ? "fill-red-500" : ""} />
{favoriteCount}
</div>
<div className="text-xs sm:text-sm text-gray-500 mt-1">
{t("favorites")}
</div>
</div>
<div className="text-center">
<div className="text-lg sm:text-xl font-semibold text-gray-700">
{formatDate(folder.createdAt)}
</div>
<div className="text-xs sm:text-sm text-gray-500 mt-1">
{t("createdAt")}
</div>
</div>
</div>
<Link
href={`/folders/${folder.id}`}
className="flex items-center justify-center gap-2 w-full py-3 px-4 bg-primary-500 hover:bg-primary-600 text-white rounded-lg font-medium transition-colors"
>
<ExternalLink size={18} />
{t("viewContent")}
</Link>
</div>
</div>
</div>
);
}

View File

@@ -1,23 +0,0 @@
import { redirect } from "next/navigation";
import { ExploreDetailClient } from "./ExploreDetailClient";
import { actionGetPublicFolderById } from "@/modules/folder/folder-action";
export default async function ExploreFolderPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
if (!id) {
redirect("/explore");
}
const result = await actionGetPublicFolderById(Number(id));
if (!result.success || !result.data) {
redirect("/explore");
}
return <ExploreDetailClient folder={result.data} />;
}

View File

@@ -1,9 +0,0 @@
import { ExploreClient } from "./ExploreClient";
import { actionGetPublicFolders } from "@/modules/folder/folder-action";
export default async function ExplorePage() {
const publicFoldersResult = await actionGetPublicFolders();
const publicFolders = publicFoldersResult.success ? publicFoldersResult.data ?? [] : [];
return <ExploreClient initialPublicFolders={publicFolders} />;
}

View File

@@ -1,143 +0,0 @@
"use client";
import {
ChevronRight,
Folder as Fd,
Heart,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { PageLayout } from "@/components/ui/PageLayout";
import { PageHeader } from "@/components/ui/PageHeader";
import { CardList } from "@/components/ui/CardList";
import { actionGetUserFavorites, actionToggleFavorite } from "@/modules/folder/folder-action";
type UserFavorite = {
id: number;
folderId: number;
folderName: string;
folderCreatedAt: Date;
folderTotalPairs: number;
folderOwnerId: string;
folderOwnerName: string | null;
folderOwnerUsername: string | null;
favoritedAt: Date;
};
interface FavoriteCardProps {
favorite: UserFavorite;
onRemoveFavorite: (folderId: number) => void;
}
const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
const router = useRouter();
const t = useTranslations("favorites");
const [isRemoving, setIsRemoving] = useState(false);
const handleRemoveFavorite = async (e: React.MouseEvent) => {
e.stopPropagation();
if (isRemoving) return;
setIsRemoving(true);
const result = await actionToggleFavorite(favorite.folderId);
if (result.success) {
onRemoveFavorite(favorite.folderId);
} else {
toast.error(result.message);
}
setIsRemoving(false);
};
return (
<div
className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() => {
router.push(`/explore/${favorite.folderId}`);
}}
>
<div className="flex items-center gap-4 flex-1">
<div className="shrink-0 text-primary-500">
<Fd size={24} />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{favorite.folderName}</h3>
<p className="text-sm text-gray-500 mt-0.5">
{t("folderInfo", {
userName: favorite.folderOwnerName ?? favorite.folderOwnerUsername ?? t("unknownUser"),
totalPairs: favorite.folderTotalPairs,
})}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Heart
size={18}
className="fill-red-500 text-red-500 cursor-pointer hover:scale-110 transition-transform"
onClick={handleRemoveFavorite}
/>
<ChevronRight size={20} className="text-gray-400" />
</div>
</div>
);
};
interface FavoritesClientProps {
userId: string;
}
export function FavoritesClient({ userId }: FavoritesClientProps) {
const t = useTranslations("favorites");
const [favorites, setFavorites] = useState<UserFavorite[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadFavorites();
}, [userId]);
const loadFavorites = async () => {
setLoading(true);
const result = await actionGetUserFavorites();
if (result.success && result.data) {
setFavorites(result.data);
}
setLoading(false);
};
const handleRemoveFavorite = (folderId: number) => {
setFavorites((prev) => prev.filter((f) => f.folderId !== folderId));
};
return (
<PageLayout>
<PageHeader title={t("title")} subtitle={t("subtitle")} />
<CardList>
{loading ? (
<div className="p-8 text-center">
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
<p className="text-sm text-gray-500">{t("loading")}</p>
</div>
) : favorites.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
<Heart size={24} className="text-gray-400" />
</div>
<p className="text-sm">{t("noFavorites")}</p>
</div>
) : (
favorites.map((favorite) => (
<FavoriteCard
key={favorite.id}
favorite={favorite}
onRemoveFavorite={handleRemoveFavorite}
/>
))
)}
</CardList>
</PageLayout>
);
}

View File

@@ -1,14 +0,0 @@
import { auth } from "@/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { FavoritesClient } from "./FavoritesClient";
export default async function FavoritesPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
redirect("/login?redirect=/favorites");
}
return <FavoritesClient userId={session.user.id} />;
}

View File

@@ -3,13 +3,11 @@
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { Folder } from "../../../../generated/prisma/browser";
import { Folder as Fd } from "lucide-react";
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
import { PageLayout } from "@/components/ui/PageLayout";
import { PrimaryButton } from "@/design-system/base/button";
interface FolderSelectorProps {
folders: TSharedFolderWithTotalPairs[];
folders: (Folder & { total: number })[];
}
const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
@@ -17,17 +15,20 @@ const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
const router = useRouter();
return (
<PageLayout>
<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="bg-white rounded-2xl shadow-xl p-6 md:p-8">
{folders.length === 0 ? (
// 空状态 - 显示提示和跳转按钮
<div className="text-center">
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-4">
{t("noFolders")}
</h1>
<Link href="/folders">
<PrimaryButton className="px-6 py-2">
<Link
className="inline-block px-6 py-2 bg-[#35786f] text-white rounded-full hover:bg-[#2d5f58] transition-colors"
href="/folders"
>
Go to Folders
</PrimaryButton>
</Link>
</div>
) : (
@@ -37,7 +38,7 @@ const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
{t("selectFolder")}
</h1>
{/* 文件夹列表 */}
<div className="border border-gray-200 rounded-lg max-h-96 overflow-y-auto">
<div className="border border-gray-200 rounded-2xl max-h-96 overflow-y-auto">
{folders
.toSorted((a, b) => a.id - b.id)
.map((folder) => (
@@ -49,8 +50,8 @@ const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
className="flex flex-row items-center p-4 gap-3 hover:cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0"
>
{/* 文件夹图标 */}
<div className="shrink-0">
<Fd className="text-gray-600" size="md" />
<div className="flex-shrink-0">
<Fd className="text-gray-600" size={24} />
</div>
{/* 文件夹信息 */}
<div className="flex-1">
@@ -86,8 +87,10 @@ const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
</div>
</>
)}
</PageLayout>
</div>
</div>
</div>
);
};
export { FolderSelector };
export default FolderSelector;

View File

@@ -1,21 +1,20 @@
"use client";
import { useState } from "react";
import { LinkButton, CircleToggleButton, LightButton } from "@/design-system/base/button";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
import { getTTSAudioUrl } from "@/lib/browser/tts";
import { VOICES } from "@/config/locales";
import { useTranslations } from "next-intl";
import localFont from "next/font/local";
import { isNonNegativeInteger, SeededRandom } from "@/utils/random";
import { TSharedPair } from "@/shared/folder-type";
import { PageLayout } from "@/components/ui/PageLayout";
import { isNonNegativeInteger, SeededRandom } from "@/lib/utils";
import { Pair } from "../../../../generated/prisma/browser";
const myFont = localFont({
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
});
interface MemorizeProps {
textPairs: TSharedPair[];
textPairs: Pair[];
}
const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
@@ -29,9 +28,11 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
if (textPairs.length === 0) {
return (
<PageLayout>
<p className="text-gray-700 text-center">{t("noTextPairs")}</p>
</PageLayout>
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
<p className="text-gray-700">{t("noTextPairs")}</p>
</div>
</div>
);
}
@@ -58,33 +59,21 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
if (show === "answer") {
const newIndex = (index + 1) % getTextPairs().length;
setIndex(newIndex);
if (dictation) {
const textPair = getTextPairs()[newIndex];
const language = textPair[reverse ? "language2" : "language1"];
const text = textPair[reverse ? "text2" : "text1"];
// 映射语言到 TTS 支持的格式
const languageMap: Record<string, TTS_SUPPORTED_LANGUAGES> = {
"chinese": "Chinese",
"english": "English",
"japanese": "Japanese",
"korean": "Korean",
"french": "French",
"german": "German",
"italian": "Italian",
"portuguese": "Portuguese",
"spanish": "Spanish",
"russian": "Russian",
};
const ttsLanguage = languageMap[language?.toLowerCase()] || "Auto";
getTTSUrl(text, ttsLanguage).then((url) => {
if (dictation)
getTTSAudioUrl(
getTextPairs()[newIndex][reverse ? "text2" : "text1"],
VOICES.find(
(v) =>
v.locale ===
getTextPairs()[newIndex][
reverse ? "locale2" : "locale1"
],
)!.short_name,
).then((url) => {
load(url);
play();
});
}
}
setShow(show === "question" ? "answer" : "question");
};
@@ -112,12 +101,17 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
: [getTextPairs()[index].text1, getTextPairs()[index].text2];
return (
<PageLayout>
<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="bg-white rounded-2xl shadow-xl p-6 md:p-8">
{/* 进度指示器 */}
<div className="flex justify-center mb-4">
<LinkButton onClick={handleIndexClick} className="text-sm">
<button
onClick={handleIndexClick}
className="text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
{index + 1} / {getTextPairs().length}
</LinkButton>
</button>
</div>
{/* 文本显示区域 */}
@@ -157,39 +151,53 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
{/* 底部按钮 */}
<div className="flex flex-row gap-2 items-center justify-center flex-wrap">
<LightButton
<button
onClick={handleNext}
className="px-4 py-2 rounded-full text-sm"
className="px-4 py-2 rounded-full bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors text-sm"
>
{show === "question" ? t("answer") : t("next")}
</LightButton>
<LightButton
</button>
<button
onClick={handlePrevious}
className="px-4 py-2 rounded-full text-sm"
className="px-4 py-2 rounded-full bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors text-sm"
>
{t("previous")}
</LightButton>
<CircleToggleButton
selected={reverse}
</button>
<button
onClick={toggleReverse}
className={`px-4 py-2 rounded-full transition-colors text-sm ${
reverse
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
{t("reverse")}
</CircleToggleButton>
<CircleToggleButton
selected={dictation}
</button>
<button
onClick={toggleDictation}
className={`px-4 py-2 rounded-full transition-colors text-sm ${
dictation
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
{t("dictation")}
</CircleToggleButton>
<CircleToggleButton
selected={disorder}
</button>
<button
onClick={toggleDisorder}
className={`px-4 py-2 rounded-full transition-colors text-sm ${
disorder
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
{t("disorder")}
</CircleToggleButton>
</button>
</div>
</div>
</div>
</div>
</PageLayout>
);
};
export { Memorize };
export default Memorize;

View File

@@ -1,19 +1,33 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { isNonNegativeInteger } from "@/utils/random";
import { FolderSelector } from "./FolderSelector";
import { Memorize } from "./Memorize";
import {
getFoldersWithTotalPairsByUserId,
getUserIdByFolderId,
} from "@/lib/server/services/folderService";
import { isNonNegativeInteger } from "@/lib/utils";
import FolderSelector from "./FolderSelector";
import Memorize from "./Memorize";
import { getPairsByFolderId } from "@/lib/server/services/pairService";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { actionGetFoldersWithTotalPairsByUserId, actionGetPairsByFolderId } from "@/modules/folder/folder-action";
export default async function MemorizePage({
searchParams,
}: {
searchParams: Promise<{ folder_id?: string; }>;
}) {
const session = await auth.api.getSession({ headers: await headers() });
const tParam = (await searchParams).folder_id;
if (!session) {
redirect(
`/auth?redirect=/memorize${(await searchParams).folder_id
? `?folder_id=${tParam}`
: ""
}`,
);
}
const t = await getTranslations("memorize.page");
const folder_id = tParam
@@ -23,15 +37,17 @@ export default async function MemorizePage({
: null;
if (!folder_id) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login?redirect=/memorize");
return (
<FolderSelector
folders={(await actionGetFoldersWithTotalPairsByUserId(session.user.id)).data!}
folders={await getFoldersWithTotalPairsByUserId(session.user.id)}
/>
);
}
return <Memorize textPairs={(await actionGetPairsByFolderId(folder_id)).data!} />;
const owner = await getUserIdByFolderId(folder_id);
if (owner !== session.user.id) {
return <p>{t("unauthorized")}</p>;
}
return <Memorize textPairs={await getPairsByFolderId(folder_id)} />;
}

View File

@@ -0,0 +1,22 @@
export default function SubtitleDisplay({ subtitle }: { subtitle: string }) {
const words = subtitle.match(/\b[\w']+(?:-[\w']+)*\b/g) || [];
let i = 0;
return (
<div className="w-full subtitle overflow-auto h-16 mt-2 wrap-break-word bg-black/50 font-sans text-white text-center text-2xl">
{words.map((v) => (
<span
onClick={() => {
window.open(
`https://www.youdao.com/result?word=${v}&lang=en`,
"_blank",
);
}}
key={i++}
className="hover:bg-gray-700 hover:underline hover:cursor-pointer"
>
{v + " "}
</span>
))}
</div>
);
}

View File

@@ -0,0 +1,216 @@
import { useState, useRef, forwardRef, useEffect, useCallback } from "react";
import SubtitleDisplay from "./SubtitleDisplay";
import { LightButton } from "@/components/ui/buttons";
import { getIndex, parseSrt, getNearistIndex } from "../subtitle";
import { useTranslations } from "next-intl";
type VideoPanelProps = {
videoUrl: string | null;
srtUrl: string | null;
};
const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
({ videoUrl, srtUrl }, videoRef) => {
const t = useTranslations("srt_player");
videoRef = videoRef as React.RefObject<HTMLVideoElement>;
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [srtLength, setSrtLength] = useState<number>(0);
const [progress, setProgress] = useState<number>(-1);
const [autoPause, setAutoPause] = useState<boolean>(true);
const [spanText, setSpanText] = useState<string>("");
const [subtitle, setSubtitle] = useState<string>("");
const parsedSrtRef = useRef<
{ start: number; end: number; text: string; }[] | null
>(null);
const rafldRef = useRef<number>(0);
const ready = useRef({
vid: false,
sub: false,
all: function () {
return this.vid && this.sub;
},
});
const togglePlayPause = useCallback(() => {
if (!videoUrl) return;
const video = videoRef.current;
if (!video) return;
if (video.paused || video.currentTime === 0) {
video.play();
} else {
video.pause();
}
setIsPlaying(!video.paused);
}, [videoRef, videoUrl]);
useEffect(() => {
const handleKeyDownEvent = (e: globalThis.KeyboardEvent) => {
if (e.key === "n") {
next();
} else if (e.key === "p") {
previous();
} else if (e.key === " ") {
togglePlayPause();
} else if (e.key === "r") {
restart();
} else if (e.key === "a") {
handleAutoPauseToggle();
}
};
document.addEventListener("keydown", handleKeyDownEvent);
return () => document.removeEventListener("keydown", handleKeyDownEvent);
});
useEffect(() => {
const cb = () => {
if (ready.current.all()) {
if (!parsedSrtRef.current) {
} else if (isPlaying) {
// 这里负责显示当前时间的字幕与自动暂停
const srt = parsedSrtRef.current;
const ct = videoRef.current?.currentTime as number;
const index = getIndex(srt, ct);
if (index !== null) {
setSubtitle(srt[index].text);
if (
autoPause &&
ct >= srt[index].end - 0.05 &&
ct < srt[index].end
) {
videoRef.current!.currentTime = srt[index].start;
togglePlayPause();
}
} else {
setSubtitle("");
}
} else {
}
}
rafldRef.current = requestAnimationFrame(cb);
};
rafldRef.current = requestAnimationFrame(cb);
return () => {
cancelAnimationFrame(rafldRef.current);
};
}, [autoPause, isPlaying, togglePlayPause, videoRef]);
useEffect(() => {
if (videoUrl && videoRef.current) {
videoRef.current.src = videoUrl;
videoRef.current.load();
setIsPlaying(false);
ready.current["vid"] = true;
}
}, [videoRef, videoUrl]);
useEffect(() => {
if (srtUrl) {
fetch(srtUrl)
.then((response) => response.text())
.then((data) => {
parsedSrtRef.current = parseSrt(data);
setSrtLength(parsedSrtRef.current.length);
ready.current["sub"] = true;
});
}
}, [srtUrl]);
const timeUpdate = () => {
if (!parsedSrtRef.current || !videoRef.current) return;
const index = getIndex(
parsedSrtRef.current,
videoRef.current.currentTime,
);
if (!index) return;
setSpanText(`${index + 1}/${parsedSrtRef.current.length}`);
};
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
if (videoRef.current && parsedSrtRef.current) {
const newProgress = parseInt(e.target.value);
videoRef.current.currentTime =
parsedSrtRef.current[newProgress]?.start || 0;
setProgress(newProgress);
}
};
const handleAutoPauseToggle = () => {
setAutoPause(!autoPause);
};
const next = () => {
if (!parsedSrtRef.current || !videoRef.current) return;
const i = getNearistIndex(
parsedSrtRef.current,
videoRef.current.currentTime,
);
if (i != null && i + 1 < parsedSrtRef.current.length) {
videoRef.current.currentTime = parsedSrtRef.current[i + 1].start;
videoRef.current.play();
setIsPlaying(true);
}
};
const previous = () => {
if (!parsedSrtRef.current || !videoRef.current) return;
const i = getNearistIndex(
parsedSrtRef.current,
videoRef.current.currentTime,
);
if (i != null && i - 1 >= 0) {
videoRef.current.currentTime = parsedSrtRef.current[i - 1].start;
videoRef.current.play();
setIsPlaying(true);
}
};
const restart = () => {
if (!parsedSrtRef.current || !videoRef.current) return;
const i = getNearistIndex(
parsedSrtRef.current,
videoRef.current.currentTime,
);
if (i != null && i >= 0) {
videoRef.current.currentTime = parsedSrtRef.current[i].start;
videoRef.current.play();
setIsPlaying(true);
}
};
return (
<div className="w-full flex flex-col">
<video
className="bg-gray-200"
ref={videoRef}
onTimeUpdate={timeUpdate}
></video>
<SubtitleDisplay subtitle={subtitle}></SubtitleDisplay>
<div className="buttons flex mt-2 gap-2 flex-wrap">
<LightButton onClick={togglePlayPause}>
{isPlaying ? t("pause") : t("play")}
</LightButton>
<LightButton onClick={previous}>{t("previous")}</LightButton>
<LightButton onClick={next}>{t("next")}</LightButton>
<LightButton onClick={restart}>{t("restart")}</LightButton>
<LightButton onClick={handleAutoPauseToggle}>
{t("autoPause", { enabled: autoPause ? "Yes" : "No" })}
</LightButton>
</div>
<input
className="seekbar"
type="range"
min={0}
max={srtLength}
onChange={handleSeek}
step={1}
value={progress}
></input>
<span>{spanText}</span>
</div>
);
},
);
VideoPanel.displayName = "VideoPanel";
export default VideoPanel;

View File

@@ -1,310 +0,0 @@
"use client";
import { useCallback, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Video, FileText, ChevronLeft, ChevronRight, RotateCcw, Pause, Play, Settings, Keyboard } from 'lucide-react';
import { Button, LightButton } from '@/design-system/base/button';
import { Range } from '@/design-system/base/range';
import { HStack, VStack } from '@/design-system/layout/stack';
import { useSrtPlayerStore } from '../stores/srtPlayerStore';
import { useFileUpload } from '../hooks/useFileUpload';
import { toast } from 'sonner';
export function ControlPanel() {
const t = useTranslations('srt_player');
const { uploadVideo, uploadSubtitle } = useFileUpload();
const videoUrl = useSrtPlayerStore((state) => state.video.url);
const subtitleUrl = useSrtPlayerStore((state) => state.subtitle.url);
const subtitleData = useSrtPlayerStore((state) => state.subtitle.data);
const currentIndex = useSrtPlayerStore((state) => state.subtitle.currentIndex);
const isPlaying = useSrtPlayerStore((state) => state.video.isPlaying);
const playbackRate = useSrtPlayerStore((state) => state.video.playbackRate);
const autoPause = useSrtPlayerStore((state) => state.controls.autoPause);
const showSettings = useSrtPlayerStore((state) => state.controls.showSettings);
const showShortcuts = useSrtPlayerStore((state) => state.controls.showShortcuts);
const settings = useSrtPlayerStore((state) => state.subtitle.settings);
const togglePlayPause = useSrtPlayerStore((state) => state.togglePlayPause);
const nextSubtitle = useSrtPlayerStore((state) => state.nextSubtitle);
const previousSubtitle = useSrtPlayerStore((state) => state.previousSubtitle);
const restartSubtitle = useSrtPlayerStore((state) => state.restartSubtitle);
const setPlaybackRate = useSrtPlayerStore((state) => state.setPlaybackRate);
const toggleAutoPause = useSrtPlayerStore((state) => state.toggleAutoPause);
const setVideoUrl = useSrtPlayerStore((state) => state.setVideoUrl);
const setSubtitleUrl = useSrtPlayerStore((state) => state.setSubtitleUrl);
const seek = useSrtPlayerStore((state) => state.seek);
const toggleSettings = useSrtPlayerStore((state) => state.toggleSettings);
const toggleShortcuts = useSrtPlayerStore((state) => state.toggleShortcuts);
const updateSettings = useSrtPlayerStore((state) => state.updateSettings);
const canPlay = useMemo(() => !!videoUrl && !!subtitleUrl && subtitleData.length > 0, [videoUrl, subtitleUrl, subtitleData]);
const currentProgress = currentIndex ?? 0;
const totalProgress = Math.max(0, subtitleData.length - 1);
const handleVideoUpload = useCallback(() => {
uploadVideo(setVideoUrl, (error) => {
toast.error(t('videoUploadFailed') + ': ' + error.message);
});
}, [uploadVideo, setVideoUrl, t]);
const handleSubtitleUpload = useCallback(() => {
uploadSubtitle((url) => {
setSubtitleUrl(url);
}, (error) => {
toast.error(t('subtitleUploadFailed') + ': ' + error.message);
});
}, [uploadSubtitle, setSubtitleUrl, t]);
const handleSeek = useCallback((index: number) => {
if (subtitleData[index]) {
seek(subtitleData[index].start);
}
}, [subtitleData, seek]);
const handlePlaybackRateChange = useCallback(() => {
const rates = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
const currentIndexRate = rates.indexOf(playbackRate);
const nextIndexRate = (currentIndexRate + 1) % rates.length;
setPlaybackRate(rates[nextIndexRate]);
}, [playbackRate, setPlaybackRate]);
return (
<div className="p-3 bg-gray-50 border-t rounded-b-xl">
<VStack gap={3}>
<HStack gap={3}>
<div
className={`flex-1 p-2 rounded-lg border-2 transition-all ${
videoUrl ? 'border-gray-800 bg-gray-100' : 'border-gray-300 bg-white'
}`}
>
<HStack gap={2} justify="between">
<HStack gap={2}>
<Video className="w-5 h-5 text-gray-600" />
<VStack gap={0}>
<h3 className="font-semibold text-gray-800 text-sm">{t('videoFile')}</h3>
<p className="text-xs text-gray-600">{videoUrl ? t('uploaded') : t('notUploaded')}</p>
</VStack>
</HStack>
<LightButton
onClick={videoUrl ? undefined : handleVideoUpload}
disabled={!!videoUrl}
size="sm"
>
{videoUrl ? t('uploaded') : t('upload')}
</LightButton>
</HStack>
</div>
<div
className={`flex-1 p-2 rounded-lg border-2 transition-all ${
subtitleUrl ? 'border-gray-800 bg-gray-100' : 'border-gray-300 bg-white'
}`}
>
<HStack gap={2} justify="between">
<HStack gap={2}>
<FileText className="w-5 h-5 text-gray-600" />
<VStack gap={0}>
<h3 className="font-semibold text-gray-800 text-sm">{t('subtitleFile')}</h3>
<p className="text-xs text-gray-600">{subtitleUrl ? t('uploaded') : t('notUploaded')}</p>
</VStack>
</HStack>
<LightButton
onClick={subtitleUrl ? undefined : handleSubtitleUpload}
disabled={!!subtitleUrl}
size="sm"
>
{subtitleUrl ? t('uploaded') : t('upload')}
</LightButton>
</HStack>
</div>
</HStack>
<VStack
gap={4}
className={!canPlay ? 'opacity-50 pointer-events-none' : ''}
>
<HStack gap={2} justify="center" wrap>
<Button
onClick={togglePlayPause}
disabled={!canPlay}
leftIcon={isPlaying ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
>
{isPlaying ? t('pause') : t('play')}
</Button>
<Button
onClick={previousSubtitle}
disabled={!canPlay}
leftIcon={<ChevronLeft className="w-4 h-4" />}
>
{t('previous')}
</Button>
<Button
onClick={nextSubtitle}
disabled={!canPlay}
rightIcon={<ChevronRight className="w-4 h-4" />}
>
{t('next')}
</Button>
<Button
onClick={restartSubtitle}
disabled={!canPlay}
leftIcon={<RotateCcw className="w-4 h-4" />}
>
{t('restart')}
</Button>
<Button
onClick={handlePlaybackRateChange}
disabled={!canPlay}
>
{playbackRate}x
</Button>
<Button
onClick={toggleAutoPause}
disabled={!canPlay}
leftIcon={<Pause className="w-4 h-4" />}
variant={autoPause ? 'primary' : 'secondary'}
>
{t('autoPause', { enabled: autoPause ? t('on') : t('off') })}
</Button>
<LightButton
onClick={toggleSettings}
leftIcon={<Settings className="w-4 h-4" />}
>
{t('settings')}
</LightButton>
<LightButton
onClick={toggleShortcuts}
leftIcon={<Keyboard className="w-4 h-4" />}
>
{t('shortcuts')}
</LightButton>
</HStack>
<VStack gap={2}>
<Range
value={currentProgress}
min={0}
max={totalProgress}
onChange={handleSeek}
disabled={!canPlay}
/>
<HStack gap={4} justify="between" className="text-sm text-gray-600 px-2">
<span>
{currentIndex !== null ? `${currentIndex + 1}/${subtitleData.length}` : '0/0'}
</span>
<HStack gap={4}>
<span className="bg-gray-200 px-2 py-1 rounded text-xs">
{playbackRate}x
</span>
<span
className={`px-2 py-1 rounded text-xs ${
autoPause ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-600'
}`}
>
{t('autoPauseStatus', { enabled: autoPause ? t('on') : t('off') })}
</span>
</HStack>
</HStack>
</VStack>
</VStack>
{showSettings && (
<div className="p-3 bg-white rounded-lg border border-gray-200">
<h3 className="font-semibold text-gray-800 mb-3">{t('subtitleSettings')}</h3>
<VStack gap={3}>
<HStack gap={2} className="w-full">
<span className="text-sm text-gray-600 w-20">{t('fontSize')}</span>
<Range
value={settings.fontSize}
min={12}
max={48}
onChange={(value) => updateSettings({ fontSize: value })}
/>
<span className="text-sm text-gray-600 w-12">{settings.fontSize}px</span>
</HStack>
<HStack gap={2} className="w-full">
<span className="text-sm text-gray-600 w-20">{t('textColor')}</span>
<input
type="color"
value={settings.textColor}
onChange={(e) => updateSettings({ textColor: e.target.value })}
className="w-8 h-8 rounded cursor-pointer"
/>
</HStack>
<HStack gap={2} className="w-full">
<span className="text-sm text-gray-600 w-20">{t('backgroundColor')}</span>
<input
type="color"
value={settings.backgroundColor.replace(/rgba?\([^)]+\)/, '#000000')}
onChange={(e) => updateSettings({ backgroundColor: e.target.value })}
className="w-8 h-8 rounded cursor-pointer"
/>
</HStack>
<HStack gap={2} className="w-full">
<span className="text-sm text-gray-600 w-20">{t('position')}</span>
<HStack gap={2}>
{(['top', 'center', 'bottom'] as const).map((pos) => (
<Button
key={pos}
size="sm"
variant={settings.position === pos ? 'primary' : 'secondary'}
onClick={() => updateSettings({ position: pos })}
>
{t(pos)}
</Button>
))}
</HStack>
</HStack>
<HStack gap={2} className="w-full">
<span className="text-sm text-gray-600 w-20">{t('opacity')}</span>
<Range
value={settings.opacity}
min={0.1}
max={1}
step={0.1}
onChange={(value) => updateSettings({ opacity: value })}
/>
<span className="text-sm text-gray-600 w-12">{Math.round(settings.opacity * 100)}%</span>
</HStack>
</VStack>
</div>
)}
{showShortcuts && (
<div className="p-3 bg-white rounded-lg border border-gray-200">
<h3 className="font-semibold text-gray-800 mb-3">{t('keyboardShortcuts')}</h3>
<VStack gap={2}>
{[
{ key: 'Space', desc: t('playPause') },
{ key: 'N', desc: t('next') },
{ key: 'P', desc: t('previous') },
{ key: 'R', desc: t('restart') },
{ key: 'A', desc: t('autoPauseToggle') },
].map((shortcut) => (
<HStack key={shortcut.key} gap={2} justify="between" className="w-full">
<kbd className="px-2 py-1 bg-gray-100 rounded text-sm font-mono">{shortcut.key}</kbd>
<span className="text-sm text-gray-600">{shortcut.desc}</span>
</HStack>
))}
</VStack>
</div>
)}
</VStack>
</div>
);
}

View File

@@ -1,72 +0,0 @@
"use client";
import { useRef, useEffect, forwardRef } from 'react';
import { useSrtPlayerStore } from '../stores/srtPlayerStore';
import { setVideoRef } from '../stores/srtPlayerStore';
export const VideoPlayerPanel = forwardRef<HTMLVideoElement>((_, ref) => {
const localVideoRef = useRef<HTMLVideoElement>(null);
const videoRef = (ref as React.RefObject<HTMLVideoElement>) || localVideoRef;
const videoUrl = useSrtPlayerStore((state) => state.video.url);
const subtitleUrl = useSrtPlayerStore((state) => state.subtitle.url);
const subtitleData = useSrtPlayerStore((state) => state.subtitle.data);
const currentText = useSrtPlayerStore((state) => state.subtitle.currentText);
const settings = useSrtPlayerStore((state) => state.subtitle.settings);
useEffect(() => {
setVideoRef(videoRef);
}, [videoRef]);
return (
<div className="aspect-video bg-black relative rounded-md overflow-hidden">
{(!videoUrl || !subtitleUrl || subtitleData.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">
{!videoUrl && !subtitleUrl
? '请上传视频和字幕文件'
: !videoUrl
? '请上传视频文件'
: !subtitleUrl
? '请上传字幕文件'
: '正在处理字幕...'}
</p>
{(!videoUrl || !subtitleUrl) && (
<p className="text-sm text-gray-300"></p>
)}
</div>
</div>
)}
{videoUrl && (
<video
ref={videoRef}
src={videoUrl}
className="w-full h-full"
playsInline
/>
)}
{subtitleUrl && subtitleData.length > 0 && currentText && (
<div
className="absolute px-4 py-2 text-center w-full"
style={{
bottom: settings.position === 'top' ? 'auto' : settings.position === 'center' ? '50%' : '0',
top: settings.position === 'top' ? '0' : 'auto',
transform: settings.position === 'center' ? 'translateY(-50%)' : 'none',
backgroundColor: settings.backgroundColor,
color: settings.textColor,
fontSize: `${settings.fontSize}px`,
fontFamily: settings.fontFamily,
opacity: settings.opacity,
}}
>
{currentText}
</div>
)}
</div>
);
});
VideoPlayerPanel.displayName = 'VideoPlayerPanel';

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";
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";
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 { LightButton } from "@/components/ui/buttons";
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}
/>
<LightButton
onClick={disabled ? undefined : onPrevious}
disabled={disabled}
className="flex items-center px-3 py-2"
>
<ChevronLeft className="w-4 h-4 mr-2" />
{t("previous")}
</LightButton>
<LightButton
onClick={disabled ? undefined : onNext}
disabled={disabled}
className="flex items-center px-3 py-2"
>
{t("next")}
<ChevronRight className="w-4 h-4 ml-2" />
</LightButton>
<LightButton
onClick={disabled ? undefined : onRestart}
disabled={disabled}
className="flex items-center px-3 py-2"
>
<RotateCcw className="w-4 h-4 mr-2" />
{t("restart")}
</LightButton>
<SpeedControl
playbackRate={playbackRate}
onPlaybackRateChange={onPlaybackRateChange}
disabled={disabled}
/>
<LightButton
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") })}
</LightButton>
</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 { LightButton } from "@/components/ui/buttons";
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 || ''}`}>
<LightButton
onClick={handleVideoUpload}
className="flex-1 py-2 px-3 text-sm"
>
<Video className="w-4 h-4 mr-2" />
{t("uploadVideo")}
</LightButton>
<LightButton
onClick={handleSubtitleUpload}
className="flex-1 py-2 px-3 text-sm"
>
<FileText className="w-4 h-4 mr-2" />
{t("uploadSubtitle")}
</LightButton>
</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

@@ -9,9 +9,10 @@ export function useFileUpload() {
onError?: (error: Error) => void
) => {
try {
const maxSize = 1000 * 1024 * 1024;
// 验证文件大小限制为100MB
const maxSize = 100 * 1024 * 1024; // 100MB
if (file.size > maxSize) {
throw new Error(`文件大小超过限制 (${(file.size / 1024 / 1024).toFixed(2)}MB > 1000MB)`);
throw new Error(`文件大小超过限制 (${(file.size / 1024 / 1024).toFixed(2)}MB > 100MB)`);
}
const url = URL.createObjectURL(file);
@@ -33,6 +34,7 @@ export function useFileUpload() {
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
// 验证文件类型
if (!file.type.startsWith('video/')) {
onError?.(new Error('请选择有效的视频文件'));
return;
@@ -59,6 +61,7 @@ export function useFileUpload() {
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
// 验证文件扩展名
if (!file.name.toLowerCase().endsWith('.srt')) {
onError?.(new Error('请选择.srt格式的字幕文件'));
return;
@@ -77,5 +80,6 @@ export function useFileUpload() {
return {
uploadVideo,
uploadSubtitle,
uploadFile,
};
}

View File

@@ -1,67 +1,16 @@
"use client";
import { useEffect } from "react";
import { useSrtPlayerStore } from "../stores/srtPlayerStore";
export function useSrtPlayerShortcuts(enabled: boolean = true) {
const togglePlayPause = useSrtPlayerStore((state) => state.togglePlayPause);
const nextSubtitle = useSrtPlayerStore((state) => state.nextSubtitle);
const previousSubtitle = useSrtPlayerStore((state) => state.previousSubtitle);
const restartSubtitle = useSrtPlayerStore((state) => state.restartSubtitle);
const toggleAutoPause = useSrtPlayerStore((state) => state.toggleAutoPause);
useEffect(() => {
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (!enabled) return;
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
return;
}
switch (event.key) {
case ' ':
event.preventDefault();
togglePlayPause();
break;
case 'n':
case 'N':
event.preventDefault();
nextSubtitle();
break;
case 'p':
case 'P':
event.preventDefault();
previousSubtitle();
break;
case 'r':
case 'R':
event.preventDefault();
restartSubtitle();
break;
case 'a':
case 'A':
event.preventDefault();
toggleAutoPause();
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [enabled, togglePlayPause, nextSubtitle, previousSubtitle, restartSubtitle, toggleAutoPause]);
}
import { useCallback, useEffect } from "react";
import { KeyboardShortcut } from "../types/controls";
export function useKeyboardShortcuts(
shortcuts: Array<{ key: string; action: () => void }>,
isEnabled: boolean = true
shortcuts: KeyboardShortcut[],
enabled: boolean = true
) {
useEffect(() => {
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (!isEnabled) return;
const handleKeyDown = useCallback((event: globalThis.KeyboardEvent) => {
if (!enabled) return;
// 防止在输入框中触发快捷键
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
return;
@@ -72,11 +21,48 @@ export function useKeyboardShortcuts(
event.preventDefault();
shortcut.action();
}
};
}, [shortcuts, enabled]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [shortcuts, isEnabled]);
}, [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

@@ -1,101 +1,110 @@
"use client";
import { useEffect, useRef, useCallback } from "react";
import { useSrtPlayerStore } from "../stores/srtPlayerStore";
import { useCallback, useEffect, useRef } from "react";
import { SubtitleEntry } from "../types/subtitle";
export function useSubtitleSync() {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastIndexRef = useRef<number | null>(null);
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 subtitleData = useSrtPlayerStore((state) => state.subtitle.data);
const isPlaying = useSrtPlayerStore((state) => state.video.isPlaying);
const autoPause = useSrtPlayerStore((state) => state.controls.autoPause);
const playbackRate = useSrtPlayerStore((state) => state.video.playbackRate);
const currentTime = useSrtPlayerStore((state) => state.video.currentTime);
// 获取当前时间对应的字幕
const getCurrentSubtitle = useCallback((time: number): SubtitleEntry | null => {
return subtitles.find(subtitle => time >= subtitle.start && time <= subtitle.end) || null;
}, [subtitles]);
const setCurrentSubtitle = useSrtPlayerStore((state) => state.setCurrentSubtitle);
const pause = useSrtPlayerStore((state) => state.pause);
// 获取最近的字幕索引
const getNearestIndex = useCallback((time: number): number | null => {
if (subtitles.length === 0) return null;
const scheduleAutoPause = useCallback(() => {
if (!autoPause || !isPlaying) {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
return;
}
// 如果时间早于第一个字幕开始时间
if (time < subtitles[0].start) return null;
const currentTimeNow = useSrtPlayerStore.getState().video.currentTime;
const currentIndexNow = useSrtPlayerStore.getState().subtitle.currentIndex;
// 如果时间晚于最后一个字幕结束时间
if (time > subtitles[subtitles.length - 1].end) return subtitles.length - 1;
if (currentIndexNow === null || !subtitleData[currentIndexNow]) {
return;
}
// 二分查找找到当前时间对应的字幕
let left = 0;
let right = subtitles.length - 1;
const subtitle = subtitleData[currentIndexNow];
const timeUntilEnd = subtitle.end - currentTimeNow;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const subtitle = subtitles[mid];
if (timeUntilEnd <= 0) {
return;
}
const advanceTime = 0.15;
const realTimeUntilPause = (timeUntilEnd - advanceTime) / playbackRate;
if (realTimeUntilPause > 0) {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
pause();
}, realTimeUntilPause * 1000);
}
}, [autoPause, isPlaying, subtitleData, playbackRate, pause]);
useEffect(() => {
if (!subtitleData || subtitleData.length === 0) {
setCurrentSubtitle('', null);
lastIndexRef.current = null;
return;
}
let newIndex: number | null = null;
for (let i = 0; i < subtitleData.length; i++) {
const subtitle = subtitleData[i];
if (currentTime >= subtitle.start && currentTime <= subtitle.end) {
newIndex = i;
break;
}
}
if (newIndex !== lastIndexRef.current) {
lastIndexRef.current = newIndex;
if (newIndex !== null) {
setCurrentSubtitle(subtitleData[newIndex].text, newIndex);
if (time >= subtitle.start && time <= subtitle.end) {
return mid;
} else if (time < subtitle.start) {
right = mid - 1;
} else {
setCurrentSubtitle('', null);
left = mid + 1;
}
}
}, [subtitleData, currentTime, setCurrentSubtitle]);
useEffect(() => {
scheduleAutoPause();
}, [isPlaying, autoPause]);
// 如果没有找到完全匹配的字幕,返回最近的字幕索引
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(() => {
if (isPlaying && autoPause) {
scheduleAutoPause();
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);
}
}, [playbackRate, currentTime]);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
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,44 +0,0 @@
"use client";
import { useEffect, type RefObject } from 'react';
import { useSrtPlayerStore } from '../stores/srtPlayerStore';
export function useVideoSync(videoRef: RefObject<HTMLVideoElement | null>) {
const setCurrentTime = useSrtPlayerStore((state) => state.setCurrentTime);
const setDuration = useSrtPlayerStore((state) => state.setDuration);
const play = useSrtPlayerStore((state) => state.play);
const pause = useSrtPlayerStore((state) => state.pause);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleTimeUpdate = () => {
setCurrentTime(video.currentTime);
};
const handleLoadedMetadata = () => {
setDuration(video.duration);
};
const handlePlay = () => {
play();
};
const handlePause = () => {
pause();
};
video.addEventListener('timeupdate', handleTimeUpdate);
video.addEventListener('loadedmetadata', handleLoadedMetadata);
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
return () => {
video.removeEventListener('timeupdate', handleTimeUpdate);
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
};
}, [videoRef, setCurrentTime, setDuration, play, pause]);
}

View File

@@ -1,98 +1,115 @@
"use client";
import { useRef, useEffect } from "react";
import React from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { PageLayout } from "@/components/ui/PageLayout";
import { LightButton } from "@/design-system/base/button";
import { HStack } from "@/design-system/layout/stack";
import { Video, FileText, ChevronLeft, ChevronRight, RotateCcw, Pause, Play } from "lucide-react";
import { useVideoSync } from "./hooks/useVideoSync";
import { Video, FileText } from "lucide-react";
import { useSrtPlayer } from "./hooks/useSrtPlayer";
import { useSubtitleSync } from "./hooks/useSubtitleSync";
import { useSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
import { loadSubtitle } from "./utils/subtitleParser";
import { useSrtPlayerStore } from "./stores/srtPlayerStore";
import { useKeyboardShortcuts, createSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
import { useFileUpload } from "./hooks/useFileUpload";
import { setVideoRef } from "./stores/srtPlayerStore";
import Link from "next/link";
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 { LightButton } from "@/components/ui/buttons";
export default function SrtPlayerPage() {
const t = useTranslations("home");
const srtT = useTranslations("srt_player");
const videoRef = useRef<HTMLVideoElement>(null);
const { uploadVideo, uploadSubtitle } = useFileUpload();
const {
state,
actions,
videoRef,
videoEventHandlers,
subtitleActions
} = useSrtPlayer();
const subtitleUrl = useSrtPlayerStore((state) => state.subtitle.url);
const setSubtitleData = useSrtPlayerStore((state) => state.setSubtitleData);
const setSubtitleUrl = useSrtPlayerStore((state) => state.setSubtitleUrl);
const setVideoUrl = useSrtPlayerStore((state) => state.setVideoUrl);
// 字幕同步
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 videoUrl = useSrtPlayerStore((state) => state.video.url);
const subtitleData = useSrtPlayerStore((state) => state.subtitle.data);
const currentIndex = useSrtPlayerStore((state) => state.subtitle.currentIndex);
const isPlaying = useSrtPlayerStore((state) => state.video.isPlaying);
const playbackRate = useSrtPlayerStore((state) => state.video.playbackRate);
const autoPause = useSrtPlayerStore((state) => state.controls.autoPause);
// 键盘快捷键
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
]
);
const togglePlayPause = useSrtPlayerStore((state) => state.togglePlayPause);
const nextSubtitle = useSrtPlayerStore((state) => state.nextSubtitle);
const previousSubtitle = useSrtPlayerStore((state) => state.previousSubtitle);
const restartSubtitle = useSrtPlayerStore((state) => state.restartSubtitle);
const setPlaybackRate = useSrtPlayerStore((state) => state.setPlaybackRate);
const toggleAutoPause = useSrtPlayerStore((state) => state.toggleAutoPause);
const seek = useSrtPlayerStore((state) => state.seek);
useKeyboardShortcuts(shortcuts);
useVideoSync(videoRef);
useSubtitleSync();
useSrtPlayerShortcuts();
useEffect(() => {
setVideoRef(videoRef);
}, [videoRef]);
const canPlay = !!videoUrl && !!subtitleUrl && subtitleData.length > 0;
useEffect(() => {
if (subtitleUrl) {
loadSubtitle(subtitleUrl)
.then((subtitleData) => {
setSubtitleData(subtitleData);
// 处理字幕文件加载
React.useEffect(() => {
if (state.subtitle.url) {
loadSubtitle(state.subtitle.url)
.then(subtitleData => {
subtitleActions.setSubtitleData(subtitleData);
toast.success(srtT("subtitleLoadSuccess"));
})
.catch((error) => {
.catch(error => {
toast.error(srtT("subtitleLoadFailed") + ": " + error.message);
});
}
}, [srtT, subtitleUrl, setSubtitleData]);
}, [srtT, state.subtitle.url, subtitleActions]);
const handleVideoUpload = () => {
uploadVideo((url) => {
setVideoUrl(url);
}, (error) => {
toast.error(t('videoUploadFailed') + ': ' + error.message);
// 处理进度条变化
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 = () => {
uploadSubtitle((url) => {
setSubtitleUrl(url);
}, (error) => {
toast.error(t('subtitleUploadFailed') + ': ' + error.message);
// 处理字幕上传
const handleSubtitleUpload = React.useCallback(() => {
uploadSubtitle(actions.setSubtitleUrl, (error) => {
toast.error(srtT("subtitleUploadFailed") + ": " + error.message);
});
};
}, [uploadSubtitle, actions.setSubtitleUrl, srtT]);
const handlePlaybackRateChange = () => {
const rates = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
const currentIndexRate = rates.indexOf(playbackRate);
const nextIndexRate = (currentIndexRate + 1) % rates.length;
setPlaybackRate(rates[nextIndexRate]);
};
const currentSubtitle = currentIndex !== null ? subtitleData[currentIndex] : null;
// 检查是否可以播放
const canPlay = state.video.url && state.subtitle.url && state.subtitle.data.length > 0;
return (
<PageLayout>
<div className="min-h-screen bg-gray-50">
<div className="container mx-auto px-4 py-8">
<div className="max-w-6xl mx-auto">
{/* 标题区域 */}
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-800 mb-2">
{t("srtPlayer.name")}
@@ -102,78 +119,162 @@ export default function SrtPlayerPage() {
</p>
</div>
<video
{/* 主要内容区域 */}
<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}
width="85%"
className="mx-auto"
playsInline
src={state.video.url}
{...videoEventHandlers}
className="w-full h-full"
>
{state.subtitle.url && state.subtitle.data.length > 0 && (
<SubtitleArea
subtitle={state.subtitle.currentText}
settings={state.subtitle.settings}
className="absolute bottom-0 left-0 right-0 px-4 py-2"
/>
)}
</VideoPlayer>
)}
</div>
{/* 控制面板 */}
<div className="p-3 bg-gray-50 border-t">
{/* 上传区域和状态指示器 */}
<div className="mb-3">
<div className="flex gap-3">
<div className={`flex-1 p-2 rounded-lg border-2 transition-all ${state.video.url
? 'border-gray-800 bg-gray-100'
: 'border-gray-300 bg-white'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Video className="w-5 h-5 text-gray-600" />
<div className="text-left">
<h3 className="font-semibold text-gray-800 text-sm">{srtT("videoFile")}</h3>
<p className="text-xs text-gray-600">
{state.video.url ? srtT("uploaded") : srtT("notUploaded")}
</p>
</div>
</div>
<LightButton
onClick={state.video.url ? undefined : handleVideoUpload}
disabled={!!state.video.url}
className="px-2 py-1 text-xs"
>
{state.video.url ? srtT("uploaded") : srtT("upload")}
</LightButton>
</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>
<LightButton
onClick={state.subtitle.url ? undefined : handleSubtitleUpload}
disabled={!!state.subtitle.url}
className="px-2 py-1 text-xs"
>
{state.subtitle.url ? srtT("uploaded") : srtT("upload")}
</LightButton>
</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="shadow rounded h-20 w-[85%] mx-auto flex-wrap flex items-begin justify-center">
{currentSubtitle && currentSubtitle.text.split(" ").map((s, i) => (
<Link
key={i}
href={`/dictionary?q=${s}`}
className="px-1 h-fit hover:bg-gray-200 hover:cursor-pointer"
target="_blank"
rel="noopener noreferrer"
>
{s}
</Link>
))}
</div>
{/* 进度条 */}
<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="mx-auto mt-4 flex items-center justify-center flex-wrap gap-2 w-[85%]">
<div className="border-gray-200 border-2 flex items p-2 justify-between items-center rounded gap-8">
<div className="flex items-center flex-col">
<Video size={16} />
<span className="text-sm">{srtT("videoFile")}</span>
</div>
<LightButton onClick={handleVideoUpload} disabled={!!videoUrl}>
{videoUrl ? srtT("uploaded") : srtT("uploadVideoButton")}
</LightButton>
</div>
<div className="border-gray-200 border-2 flex items p-2 justify-between items-center rounded gap-8">
<div className="flex items-center flex-col">
<FileText size={16} />
<span className="text-sm">
{subtitleData.length > 0 ? srtT("subtitleUploaded", { count: subtitleData.length }) : srtT("subtitleNotUploaded")}
{/* 字幕进度显示 */}
<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>
<LightButton onClick={handleSubtitleUpload} disabled={!!subtitleUrl}>
{subtitleUrl ? srtT("uploaded") : srtT("uploadSubtitleButton")}
</LightButton>
</div>
</div>
{canPlay && (
<HStack gap={2} className="mx-auto mt-4 w-[85%]" justify={"center"} wrap>
{isPlaying ? (
<LightButton onClick={togglePlayPause} leftIcon={<Pause className="w-4 h-4" />}>
{srtT('pause')}
</LightButton>
) : (
<LightButton onClick={togglePlayPause} leftIcon={<Play className="w-4 h-4" />}>
{srtT('play')}
</LightButton>
)}
<LightButton onClick={previousSubtitle} leftIcon={<ChevronLeft className="w-4 h-4" />}>
{srtT('previous')}
</LightButton>
<LightButton onClick={nextSubtitle} rightIcon={<ChevronRight className="w-4 h-4" />}>
{srtT('next')}
</LightButton>
<LightButton onClick={restartSubtitle} leftIcon={<RotateCcw className="w-4 h-4" />}>
{srtT('restart')}
</LightButton>
<LightButton onClick={handlePlaybackRateChange}>
{playbackRate}x
</LightButton>
<LightButton onClick={toggleAutoPause}>
{srtT('autoPause', { enabled: autoPause ? srtT('on') : srtT('off') })}
</LightButton>
</HStack>
)}
</PageLayout>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,217 +0,0 @@
"use client";
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { toast } from 'sonner';
import type {
SrtPlayerStore,
VideoState,
SubtitleState,
ControlState,
SubtitleSettings,
SubtitleEntry,
} from '../types';
import type { RefObject } from 'react';
let videoRef: RefObject<HTMLVideoElement | null> | null;
export function setVideoRef(ref: RefObject<HTMLVideoElement | null> | null) {
videoRef = ref;
}
const initialVideoState: VideoState = {
url: null,
isPlaying: false,
currentTime: 0,
duration: 0,
playbackRate: 1.0,
volume: 1.0,
};
const initialSubtitleSettings: SubtitleSettings = {
fontSize: 24,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
textColor: '#ffffff',
position: 'bottom',
fontFamily: 'sans-serif',
opacity: 1,
};
const initialSubtitleState: SubtitleState = {
url: null,
data: [],
currentText: '',
currentIndex: null,
settings: initialSubtitleSettings,
};
const initialControlState: ControlState = {
autoPause: true,
showShortcuts: false,
showSettings: false,
};
export const useSrtPlayerStore = create<SrtPlayerStore>()(
devtools(
(set, get) => ({
video: initialVideoState,
subtitle: initialSubtitleState,
controls: initialControlState,
setVideoUrl: (url) =>
set((state) => {
if (videoRef?.current) {
videoRef.current.src = url || '';
videoRef.current.load();
}
return { video: { ...state.video, url } };
}),
setPlaying: (playing) =>
set((state) => ({ video: { ...state.video, isPlaying: playing } })),
setCurrentTime: (time) =>
set((state) => ({ video: { ...state.video, currentTime: time } })),
setDuration: (duration) =>
set((state) => ({ video: { ...state.video, duration } })),
setPlaybackRate: (rate) =>
set((state) => {
if (videoRef?.current) {
videoRef.current.playbackRate = rate;
}
return { video: { ...state.video, playbackRate: rate } };
}),
setVolume: (volume) =>
set((state) => {
if (videoRef?.current) {
videoRef.current.volume = volume;
}
return { video: { ...state.video, volume } };
}),
play: () => {
const state = get();
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);
});
set({ video: { ...state.video, isPlaying: true } });
}
},
pause: () => {
if (videoRef?.current) {
if (!videoRef.current.paused) {
videoRef.current.pause();
}
set((state) => ({ video: { ...state.video, isPlaying: false } }));
}
},
togglePlayPause: () => {
const state = get();
if (state.video.isPlaying) {
get().pause();
} else {
get().play();
}
},
seek: (time) => {
if (videoRef?.current) {
videoRef.current.currentTime = time;
set((state) => ({ video: { ...state.video, currentTime: time } }));
}
},
restart: () => {
const state = get();
if (state.subtitle.currentIndex !== null) {
const currentSubtitle = state.subtitle.data[state.subtitle.currentIndex];
if (currentSubtitle) {
get().seek(currentSubtitle.start);
get().play();
}
}
},
setSubtitleUrl: (url) =>
set((state) => ({ subtitle: { ...state.subtitle, url } })),
setSubtitleData: (data) =>
set((state) => ({ subtitle: { ...state.subtitle, data } })),
setCurrentSubtitle: (text, index) =>
set((state) => ({
subtitle: {
...state.subtitle,
currentText: text,
currentIndex: index,
},
})),
updateSettings: (settings) =>
set((state) => ({
subtitle: {
...state.subtitle,
settings: { ...state.subtitle.settings, ...settings },
},
})),
nextSubtitle: () => {
const state = get();
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];
get().seek(nextSubtitle.start);
get().play();
}
},
previousSubtitle: () => {
const state = get();
if (state.subtitle.currentIndex !== null && state.subtitle.currentIndex > 0) {
const prevIndex = state.subtitle.currentIndex - 1;
const prevSubtitle = state.subtitle.data[prevIndex];
get().seek(prevSubtitle.start);
get().play();
}
},
restartSubtitle: () => {
const state = get();
if (state.subtitle.currentIndex !== null) {
const currentSubtitle = state.subtitle.data[state.subtitle.currentIndex];
get().seek(currentSubtitle.start);
get().play();
}
},
toggleAutoPause: () =>
set((state) => ({
controls: { ...state.controls, autoPause: !state.controls.autoPause },
})),
toggleShortcuts: () =>
set((state) => ({
controls: { ...state.controls, showShortcuts: !state.controls.showShortcuts },
})),
toggleSettings: () =>
set((state) => ({
controls: { ...state.controls, showSettings: !state.controls.showSettings },
})),
}),
{ name: 'srt-player-store' }
)
);

View File

@@ -0,0 +1,74 @@
export function parseSrt(data: string) {
const lines = data.split(/\r?\n/);
const result = [];
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() });
i++;
}
return result;
}
export function getNearistIndex(
srt: { start: number; end: number; text: string }[],
ct: number,
) {
for (let i = 0; i < srt.length; i++) {
const s = srt[i];
const l = ct - s.start >= 0;
const r = ct - s.end >= 0;
if (!(l || r)) return i - 1;
if (l && !r) return i;
}
}
export function getIndex(
srt: { start: number; end: number; text: string }[],
ct: number,
) {
for (let i = 0; i < srt.length; i++) {
if (ct >= srt[i].start && ct <= srt[i].end) {
return i;
}
}
return null;
}
export function getSubtitle(
srt: { start: number; end: number; text: string }[],
currentTime: number,
) {
return (
srt.find((sub) => currentTime >= sub.start && currentTime <= sub.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),
);
}

View File

@@ -1,132 +0,0 @@
// ==================== Video Types ====================
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;
}
// ==================== Subtitle Types ====================
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 SubtitleControls {
next: () => void;
previous: () => void;
goToIndex: (index: number) => void;
toggleAutoPause: () => void;
}
// ==================== Controls Types ====================
export interface ControlState {
autoPause: boolean;
showShortcuts: boolean;
showSettings: boolean;
}
export interface ControlActions {
toggleAutoPause: () => void;
toggleShortcuts: () => void;
toggleSettings: () => void;
}
export interface KeyboardShortcut {
key: string;
description: string;
action: () => void;
}
// ==================== Store Types ====================
export interface SrtPlayerStore {
// Video state
video: VideoState;
// Subtitle state
subtitle: SubtitleState;
// Controls state
controls: ControlState;
// Video actions
setVideoUrl: (url: string | null) => void;
setPlaying: (playing: boolean) => void;
setCurrentTime: (time: number) => void;
setDuration: (duration: number) => void;
setPlaybackRate: (rate: number) => void;
setVolume: (volume: number) => void;
play: () => void;
pause: () => void;
togglePlayPause: () => void;
seek: (time: number) => void;
restart: () => void;
// Subtitle actions
setSubtitleUrl: (url: string | null) => void;
setSubtitleData: (data: SubtitleEntry[]) => void;
setCurrentSubtitle: (text: string, index: number | null) => void;
updateSettings: (settings: Partial<SubtitleSettings>) => void;
nextSubtitle: () => void;
previousSubtitle: () => void;
restartSubtitle: () => void;
// Controls actions
toggleAutoPause: () => void;
toggleShortcuts: () => void;
toggleSettings: () => void;
}
// ==================== Selectors ====================
export const selectors = {
canPlay: (state: SrtPlayerStore) =>
!!state.video.url &&
!!state.subtitle.url &&
state.subtitle.data.length > 0,
currentSubtitle: (state: SrtPlayerStore) =>
state.subtitle.currentIndex !== null
? state.subtitle.data[state.subtitle.currentIndex]
: null,
progress: (state: SrtPlayerStore) => ({
current: state.subtitle.currentIndex ?? 0,
total: state.subtitle.data.length,
}),
};

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

@@ -1,4 +1,4 @@
import { SubtitleEntry } from "../types";
import { SubtitleEntry } from "../types/subtitle";
export function parseSrt(data: string): SubtitleEntry[] {
const lines = data.split(/\r?\n/);
@@ -62,12 +62,13 @@ export function getNearestIndex(
): number | null {
for (let i = 0; i < subtitles.length; i++) {
const subtitle = subtitles[i];
const isWithin = currentTime >= subtitle.start && currentTime <= subtitle.end;
const isBefore = currentTime - subtitle.start >= 0;
const isAfter = currentTime - subtitle.end >= 0;
if (isWithin) return i;
if (currentTime < subtitle.start) return i > 0 ? i - 1 : null;
if (!isBefore || !isAfter) return i - 1;
if (isBefore && !isAfter) return i;
}
return subtitles.length > 0 ? subtitles.length - 1 : null;
return null;
}
export function getCurrentSubtitle(
@@ -92,7 +93,7 @@ export async function loadSubtitle(url: string): Promise<SubtitleEntry[]> {
const data = await response.text();
return parseSrt(data);
} catch (error) {
console.error('加载字幕失败', 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,8 +6,8 @@ import {
TextSpeakerArraySchema,
TextSpeakerItemSchema,
} from "@/lib/interfaces";
import { IconClick } from "@/design-system/base/button";
import { IMAGES } from "@/config/images";
import { IconClick } from "@/components/ui/buttons";
import IMAGES from "@/config/images";
import { useTranslations } from "next-intl";
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
@@ -24,7 +24,7 @@ function TextCard({ item, handleUse, handleDel }: TextCardProps) {
handleDel(item);
};
return (
<div className="p-2 border-b border-gray-200 rounded-lg bg-gray-100 m-2 grid grid-cols-8">
<div className="p-2 border-b border-gray-200 rounded-2xl bg-gray-100 m-2 grid grid-cols-8">
<div className="col-span-7" onClick={onUseClick}>
<div className="max-h-26 hover:cursor-pointer text-3xl overflow-y-auto">
{item.text}
@@ -39,7 +39,7 @@ function TextCard({ item, handleUse, handleDel }: TextCardProps) {
alt="delete"
onClick={onDelClick}
className="place-self-center"
size="lg"
size={42}
></IconClick>
</div>
</div>
@@ -50,7 +50,7 @@ interface SaveListProps {
show?: boolean;
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
}
export function SaveList({ show = false, handleUse }: SaveListProps) {
export default function SaveList({ show = false, handleUse }: SaveListProps) {
const t = useTranslations("text_speaker");
const { get: getFromLocalStorage, set: setIntoLocalStorage } =
getLocalStorageOperator<typeof TextSpeakerArraySchema>(
@@ -60,12 +60,11 @@ export function SaveList({ show = false, handleUse }: SaveListProps) {
const [data, setData] = useState(getFromLocalStorage());
const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => {
const current_data = getFromLocalStorage();
if (!current_data) return;
const index = current_data.findIndex((v) => v.text === item.text);
if (index === -1) return;
current_data.splice(index, 1);
current_data.splice(
current_data.findIndex((v) => v.text === item.text),
1,
);
setIntoLocalStorage(current_data);
refresh();
};
@@ -79,25 +78,33 @@ export function SaveList({ show = false, handleUse }: SaveListProps) {
refresh();
}
};
if (show && data)
if (show)
return (
<div
className="my-4 p-2 mx-4 md:mx-32 border border-gray-200 rounded-lg"
className="my-4 p-2 mx-4 md:mx-32 border border-gray-200 rounded-2xl"
style={{ fontFamily: "Times New Roman, serif" }}
>
<div className="flex justify-between items-center mb-2">
<p className="text-sm text-gray-600">{t("saved")}</p>
<button
<div className="flex flex-row justify-center gap-8 items-center">
<IconClick
src={IMAGES.refresh}
alt="refresh"
onClick={refresh}
size={48}
className=""
></IconClick>
<IconClick
src={IMAGES.delete}
alt="delete"
onClick={handleDeleteAll}
className="text-xs text-gray-500 hover:text-gray-800"
>
{t("clearAll")}
</button>
size={48}
className=""
></IconClick>
</div>
<ul className="divide-y divide-gray-100">
{data.map((item, i) => (
<ul>
{data.map((v) => (
<TextCard
key={i}
item={item}
item={v}
key={crypto.randomUUID()}
handleUse={handleUse}
handleDel={handleDel}
></TextCard>

View File

@@ -1,8 +1,8 @@
"use client";
import { LightButton } from "@/design-system/base/button";
import { IconClick } from "@/design-system/base/button";
import { IMAGES } from "@/config/images";
import { LightButton } from "@/components/ui/buttons";
import { IconClick } from "@/components/ui/buttons";
import IMAGES from "@/config/images";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import {
TextSpeakerArraySchema,
@@ -10,13 +10,14 @@ import {
} from "@/lib/interfaces";
import { ChangeEvent, useEffect, useRef, useState } from "react";
import z from "zod";
import { SaveList } from "./SaveList";
import SaveList from "./SaveList";
import { VOICES } from "@/config/locales";
import { useTranslations } from "next-intl";
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
import { genIPA, genLanguage } from "@/modules/translator/translator-action";
import { PageLayout } from "@/components/ui/PageLayout";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
import { getTTSAudioUrl } from "@/lib/browser/tts";
import { genIPA, genLocale } from "@/lib/server/translatorActions";
import PageLayout from "@/components/ui/PageLayout";
export default function TextSpeakerPage() {
const t = useTranslations("text_speaker");
@@ -29,7 +30,7 @@ export default function TextSpeakerPage() {
const [pause, setPause] = useState(true);
const [autopause, setAutopause] = useState(true);
const textRef = useRef("");
const [language, setLanguage] = useState<string | null>(null);
const [locale, setLocale] = useState<string | null>(null);
const [ipa, setIPA] = useState<string>("");
const objurlRef = useRef<string | null>(null);
const [processing, setProcessing] = useState(false);
@@ -48,8 +49,8 @@ export default function TextSpeakerPage() {
const handleEnded = () => {
if (autopause) {
setPause(true);
} else if (objurlRef.current) {
load(objurlRef.current);
} else {
load(objurlRef.current!);
play();
}
};
@@ -74,7 +75,7 @@ export default function TextSpeakerPage() {
setIPA(data.ipa);
})
.catch((e) => {
console.error("生成 IPA 失败", e);
console.error(e);
setIPA("");
});
}
@@ -93,35 +94,40 @@ export default function TextSpeakerPage() {
} else {
// 第一次播放
try {
let theLanguage = language;
if (!theLanguage) {
const tmp_language = await genLanguage(textRef.current.slice(0, 30));
setLanguage(tmp_language);
theLanguage = tmp_language;
let theLocale = locale;
if (!theLocale) {
console.log("downloading text info");
const tmp_locale = await genLocale(textRef.current.slice(0, 30));
setLocale(tmp_locale);
theLocale = tmp_locale;
}
theLanguage = theLanguage.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
const voice = VOICES.find((v) => v.locale.startsWith(theLocale));
if (!voice) throw "Voice not found.";
// 检查语言是否在 TTS 支持列表中
const supportedLanguages: TTS_SUPPORTED_LANGUAGES[] = [
"Auto", "Chinese", "English", "German", "Italian", "Portuguese",
"Spanish", "Japanese", "Korean", "French", "Russian"
];
if (!supportedLanguages.includes(theLanguage as TTS_SUPPORTED_LANGUAGES)) {
theLanguage = "Auto";
}
objurlRef.current = await getTTSUrl(
objurlRef.current = await getTTSAudioUrl(
textRef.current,
theLanguage as TTS_SUPPORTED_LANGUAGES
voice.short_name,
(() => {
if (speed === 1) return {};
else if (speed < 1)
return {
rate: `-${100 - speed * 100}%`,
};
else
return {
rate: `+${speed * 100 - 100}%`,
};
})(),
);
load(objurlRef.current);
play();
} catch (e) {
console.error("播放音频失败", e);
console.error(e);
setPause(true);
setLanguage(null);
setLocale(null);
setProcessing(false);
}
}
@@ -137,7 +143,7 @@ export default function TextSpeakerPage() {
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
textRef.current = e.target.value.trim();
setLanguage(null);
setLocale(null);
setIPA("");
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
objurlRef.current = null;
@@ -158,7 +164,7 @@ export default function TextSpeakerPage() {
const handleUseItem = (item: z.infer<typeof TextSpeakerItemSchema>) => {
if (textareaRef.current) textareaRef.current.value = item.text;
textRef.current = item.text;
setLanguage(item.language);
setLocale(item.locale);
setIPA(item.ipa || "");
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
objurlRef.current = null;
@@ -173,11 +179,12 @@ export default function TextSpeakerPage() {
setSaving(true);
try {
let theLanguage = language;
if (!theLanguage) {
const tmp_language = await genLanguage(textRef.current.slice(0, 30));
setLanguage(tmp_language);
theLanguage = tmp_language;
let theLocale = locale;
if (!theLocale) {
console.log("downloading text info");
const tmp_locale = await genLocale(textRef.current.slice(0, 30));
setLocale(tmp_locale);
theLocale = tmp_locale;
}
let theIPA = ipa;
@@ -187,7 +194,7 @@ export default function TextSpeakerPage() {
theIPA = tmp_ipa;
}
const save = getFromLocalStorage() ?? [];
const save = getFromLocalStorage();
const oldIndex = save.findIndex((v) => v.text === textRef.current);
if (oldIndex !== -1) {
const oldItem = save[oldIndex];
@@ -200,19 +207,19 @@ export default function TextSpeakerPage() {
} else if (theIPA.length === 0) {
save.push({
text: textRef.current,
language: theLanguage as string,
locale: theLocale,
});
} else {
save.push({
text: textRef.current,
language: theLanguage as string,
locale: theLocale,
ipa: theIPA,
});
}
setIntoLocalStorage(save);
} catch (e) {
console.error("保存到本地存储失败", e);
setLanguage(null);
console.error(e);
setLocale(null);
} finally {
setSaving(false);
}
@@ -222,7 +229,7 @@ export default function TextSpeakerPage() {
<PageLayout className="items-start py-4">
{/* 文本输入区域 */}
<div
className="border border-gray-200 rounded-lg"
className="border border-gray-200 rounded-2xl"
style={{ fontFamily: "Times New Roman, serif" }}
>
{/* 文本输入框 */}
@@ -242,37 +249,37 @@ export default function TextSpeakerPage() {
<div className="p-4 relative w-full flex flex-row flex-wrap gap-2 justify-center items-center">
{/* 速度调节面板 */}
{showSpeedAdjust && (
<div className="bg-white p-6 rounded-lg border-gray-200 border-2 shadow-2xl absolute left-1/2 -translate-x-1/2 -translate-y-full -top-4 flex flex-row flex-wrap gap-2 justify-center items-center z-10">
<div className="bg-white p-6 rounded-2xl border-gray-200 border-2 shadow-2xl absolute left-1/2 -translate-x-1/2 -translate-y-full -top-4 flex flex-row flex-wrap gap-2 justify-center items-center z-10">
<IconClick
size="lg"
size={45}
onClick={letMeSetSpeed(0.5)}
src={IMAGES.speed_0_5x}
alt="0.5x"
className={speed === 0.5 ? "bg-gray-200" : ""}
></IconClick>
<IconClick
size="lg"
size={45}
onClick={letMeSetSpeed(0.7)}
src={IMAGES.speed_0_7x}
alt="0.7x"
className={speed === 0.7 ? "bg-gray-200" : ""}
></IconClick>
<IconClick
size="lg"
size={45}
onClick={letMeSetSpeed(1)}
src={IMAGES.speed_1x}
alt="1x"
className={speed === 1 ? "bg-gray-200" : ""}
></IconClick>
<IconClick
size="lg"
size={45}
onClick={letMeSetSpeed(1.2)}
src={IMAGES.speed_1_2_x}
alt="1.2x"
className={speed === 1.2 ? "bg-gray-200" : ""}
></IconClick>
<IconClick
size="lg"
size={45}
onClick={letMeSetSpeed(1.5)}
src={IMAGES.speed_1_5x}
alt="1.5x"
@@ -282,7 +289,7 @@ export default function TextSpeakerPage() {
)}
{/* 播放/暂停按钮 */}
<IconClick
size="lg"
size={45}
onClick={speak}
src={pause ? IMAGES.play_arrow : IMAGES.pause}
alt="playorpause"
@@ -290,10 +297,10 @@ export default function TextSpeakerPage() {
></IconClick>
{/* 自动暂停按钮 */}
<IconClick
size="lg"
size={45}
onClick={() => {
setAutopause(!autopause);
if (objurlRef.current) {
if (objurlRef) {
stop();
}
setPause(true);
@@ -303,7 +310,7 @@ export default function TextSpeakerPage() {
></IconClick>
{/* 速度调节按钮 */}
<IconClick
size="lg"
size={45}
onClick={() => setShowSpeedAdjust(!showSpeedAdjust)}
src={IMAGES.speed}
alt="speed"
@@ -311,7 +318,7 @@ export default function TextSpeakerPage() {
></IconClick>
{/* 保存按钮 */}
<IconClick
size="lg"
size={45}
onClick={save}
src={IMAGES.save}
alt="save"
@@ -338,7 +345,7 @@ export default function TextSpeakerPage() {
</div>
{/* 保存列表 */}
{showSaveList && (
<div className="mt-4 border border-gray-200 rounded-lg overflow-hidden">
<div className="mt-4 border border-gray-200 rounded-2xl overflow-hidden">
<SaveList show={showSaveList} handleUse={handleUseItem}></SaveList>
</div>
)}

View File

@@ -0,0 +1,88 @@
"use client";
import { LightButton } from "@/components/ui/buttons";
import Container from "@/components/ui/Container";
import { TranslationHistorySchema } from "@/lib/interfaces";
import { Dispatch, useEffect, useState } from "react";
import z from "zod";
import { Folder } from "../../../../generated/prisma/browser";
import { getFoldersByUserId } from "@/lib/server/services/folderService";
import { Folder as Fd } from "lucide-react";
import { createPair } from "@/lib/server/services/pairService";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { authClient } from "@/lib/auth-client";
interface AddToFolderProps {
item: z.infer<typeof TranslationHistorySchema>;
setShow: Dispatch<React.SetStateAction<boolean>>;
}
const AddToFolder: React.FC<AddToFolderProps> = ({ item, setShow }) => {
const { data: session } = authClient.useSession();
const [folders, setFolders] = useState<Folder[]>([]);
const t = useTranslations("translator.add_to_folder");
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!session) return;
const userId = session.user.id;
getFoldersByUserId(userId)
.then(setFolders)
.then(() => setLoading(false));
}, [session]);
if (!session) {
return (
<div className="fixed left-0 top-0 z-50 w-screen h-screen bg-black/50 flex justify-center items-center">
<Container className="p-6">
<div>{t("notAuthenticated")}</div>
</Container>
</div>
);
}
return (
<div className="fixed left-0 top-0 z-50 w-screen h-screen bg-black/50 flex justify-center items-center">
<Container className="p-6">
<h1>{t("chooseFolder")}</h1>
<div className="border border-gray-200 rounded-2xl">
{(loading && <span>...</span>) ||
(folders.length > 0 &&
folders.map((folder) => (
<button
key={folder.id}
className="p-2 flex items-center justify-start hover:bg-gray-50 gap-2 hover:cursor-pointer w-full border-b border-gray-200"
onClick={() => {
createPair({
text1: item.text1,
text2: item.text2,
locale1: item.locale1,
locale2: item.locale2,
folder: {
connect: {
id: folder.id,
},
},
})
.then(() => {
toast.success(t("success"));
setShow(false);
})
.catch(() => {
toast.error(t("error"));
});
}}
>
<Fd />
{t("folderInfo", { id: folder.id, name: folder.name })}
</button>
))) || <div>{t("noFolders")}</div>}
</div>
<LightButton onClick={() => setShow(false)}>{t("close")}</LightButton>
</Container>
</div>
);
};
export default AddToFolder;

View File

@@ -0,0 +1,57 @@
import Container from "@/components/ui/Container";
import { useEffect, useState } from "react";
import { Folder } from "../../../../generated/prisma/browser";
import { getFoldersByUserId } from "@/lib/server/services/folderService";
import { LightButton } from "@/components/ui/buttons";
import { Folder as Fd } from "lucide-react";
interface FolderSelectorProps {
setSelectedFolderId: (id: number) => void;
userId: string;
cancel: () => void;
}
const FolderSelector: React.FC<FolderSelectorProps> = ({
setSelectedFolderId,
userId,
cancel,
}) => {
const [loading, setLoading] = useState(false);
const [folders, setFolders] = useState<Folder[]>([]);
useEffect(() => {
getFoldersByUserId(userId)
.then(setFolders)
.then(() => setLoading(false));
}, [userId]);
return (
<div
className={`bg-black/50 fixed inset-0 z-50 flex justify-center items-center`}
>
<Container className="p-6">
{(loading && <p>Loading...</p>) ||
(folders.length > 0 && (
<>
<h1>Select a Folder</h1>
<div className="m-2 border-gray-200 border rounded-2xl max-h-96 overflow-y-auto">
{folders.map((folder) => (
<button
className="p-2 w-full flex hover:bg-gray-50 gap-2"
key={folder.id}
onClick={() => setSelectedFolderId(folder.id)}
>
<Fd />
{folder.id}. {folder.name}
</button>
))}
</div>
</>
)) || <p>No folders found</p>}
<LightButton onClick={cancel}>Cancel</LightButton>
</Container>
</div>
);
};
export default FolderSelector;

View File

@@ -1,106 +1,191 @@
"use client";
import { LightButton, PrimaryButton, IconClick } from "@/design-system/base/button";
import { IMAGES } from "@/config/images";
import { LightButton } from "@/components/ui/buttons";
import { IconClick } from "@/components/ui/buttons";
import IMAGES from "@/config/images";
import { VOICES } from "@/config/locales";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { TranslationHistorySchema } from "@/lib/interfaces";
import { tlsoPush, tlso } from "@/lib/browser/localStorageOperators";
import { getTTSAudioUrl } from "@/lib/browser/tts";
import { Plus, Trash } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRef, useState } from "react";
import { actionTranslateText } from "@/modules/translator/translator-action";
import { useEffect, useRef, useState } from "react";
import z from "zod";
import AddToFolder from "./AddToFolder";
import {
genIPA,
genLocale,
genTranslation,
} from "@/lib/server/translatorActions";
import { toast } from "sonner";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
import { TSharedTranslationResult } from "@/shared/translator-type";
import FolderSelector from "./FolderSelector";
import { createPair } from "@/lib/server/services/pairService";
import { shallowEqual } from "@/lib/utils";
import { authClient } from "@/lib/auth-client";
export default function TranslatorPage() {
const t = useTranslations("translator");
const taref = useRef<HTMLTextAreaElement>(null);
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
const [translationResult, setTranslationResult] = useState<TSharedTranslationResult | null>(null);
const [needIpa, setNeedIpa] = useState(true);
const [lang, setLang] = useState<string>("chinese");
const [tresult, setTresult] = useState<string>("");
const [genIpa, setGenIpa] = useState(true);
const [ipaTexts, setIpaTexts] = useState(["", ""]);
const [processing, setProcessing] = useState(false);
const [lastTranslation, setLastTranslation] = useState<{
sourceText: string;
targetLanguage: string;
} | null>(null);
const { load, play } = useAudioPlayer();
const [history, setHistory] = useState<
z.infer<typeof TranslationHistorySchema>[]
>([]);
const [showAddToFolder, setShowAddToFolder] = useState(false);
const [addToFolderItem, setAddToFolderItem] = useState<z.infer<
typeof TranslationHistorySchema
> | null>(null);
const lastTTS = useRef({
text: "",
url: "",
});
const [autoSave, setAutoSave] = useState(false);
const [autoSaveFolderId, setAutoSaveFolderId] = useState<number | null>(null);
const { data: session } = authClient.useSession();
useEffect(() => {
setHistory(tlso.get());
}, []);
const tts = async (text: string, locale: string) => {
if (lastTTS.current.text !== text) {
try {
// Map language name to TTS format
let theLanguage = locale.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
// Check if language is in TTS supported list
const supportedLanguages: TTS_SUPPORTED_LANGUAGES[] = [
"Auto", "Chinese", "English", "German", "Italian", "Portuguese",
"Spanish", "Japanese", "Korean", "French", "Russian"
];
if (!supportedLanguages.includes(theLanguage as TTS_SUPPORTED_LANGUAGES)) {
theLanguage = "Auto";
const shortName = VOICES.find((v) => v.locale === locale)?.short_name;
if (!shortName) {
toast.error("Voice not found");
return;
}
const url = await getTTSUrl(text, theLanguage as TTS_SUPPORTED_LANGUAGES);
try {
const url = await getTTSAudioUrl(text, shortName);
await load(url);
await play();
lastTTS.current.text = text;
lastTTS.current.url = url;
} catch (error) {
toast.error("Failed to generate audio");
console.error(error);
}
}
await play();
};
const translate = async () => {
if (!taref.current || processing) return;
if (!taref.current) return;
if (processing) return;
setProcessing(true);
const sourceText = taref.current.value;
const text1 = taref.current.value;
// 判断是否需要强制重新翻译
// 只有当源文本和目标语言都与上次相同时,才强制重新翻译
const forceRetranslate =
lastTranslation?.sourceText === sourceText &&
lastTranslation?.targetLanguage === targetLanguage;
const llmres: {
text1: string | null;
text2: string | null;
locale1: string | null;
locale2: string | null;
ipa1: string | null;
ipa2: string | null;
} = {
text1: text1,
text2: null,
locale1: null,
locale2: null,
ipa1: null,
ipa2: null,
};
try {
const result = await actionTranslateText({
sourceText,
targetLanguage,
forceRetranslate,
needIpa,
let historyUpdated = false;
// 检查更新历史记录
const checkUpdateLocalStorage = () => {
if (historyUpdated) return;
if (llmres.text1 && llmres.text2 && llmres.locale1 && llmres.locale2) {
setHistory(
tlsoPush({
text1: llmres.text1,
text2: llmres.text2,
locale1: llmres.locale1,
locale2: llmres.locale2,
}),
);
if (autoSave && autoSaveFolderId) {
createPair({
text1: llmres.text1,
text2: llmres.text2,
locale1: llmres.locale1,
locale2: llmres.locale2,
folder: {
connect: {
id: autoSaveFolderId,
},
},
})
.then(() => {
toast.success(
llmres.text1 + "保存到文件夹" + autoSaveFolderId + "成功",
);
})
.catch((error) => {
toast.error(
llmres.text1 +
"保存到文件夹" +
autoSaveFolderId +
"失败:" +
error.message,
);
});
if (result.success && result.data) {
setTranslationResult(result.data);
setLastTranslation({
sourceText,
targetLanguage,
});
} else {
toast.error(result.message || "翻译失败,请重试");
}
} catch (error) {
toast.error("翻译失败,请重试");
console.error("翻译错误:", error);
} finally {
historyUpdated = true;
}
};
// 更新局部翻译状态
const updateState = (stateName: keyof typeof llmres, value: string) => {
llmres[stateName] = value;
checkUpdateLocalStorage();
};
genTranslation(text1, lang)
.then(async (text2) => {
updateState("text2", text2);
setTresult(text2);
// 生成两个locale
genLocale(text1).then((locale) => {
updateState("locale1", locale);
});
genLocale(text2).then((locale) => {
updateState("locale2", locale);
});
// 生成俩IPA
if (genIpa) {
genIPA(text1).then((ipa1) => {
setIpaTexts((prev) => [ipa1, prev[1]]);
updateState("ipa1", ipa1);
});
genIPA(text2).then((ipa2) => {
setIpaTexts((prev) => [prev[0], ipa2]);
updateState("ipa2", ipa2);
});
}
})
.catch(() => {
toast.error("Translation failed");
})
.finally(() => {
setProcessing(false);
}
});
};
return (
<div className="min-h-[calc(100vh-64px)] bg-white">
<>
{/* TCard Component */}
<div className="w-screen flex flex-col md:flex-row md:justify-between gap-2 p-2">
{/* Card Component - Left Side */}
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
{/* ICard1 Component */}
<div className="border border-gray-200 rounded-lg w-full h-64 p-2">
<div className="border border-gray-200 rounded-2xl w-full h-64 p-2">
<textarea
className="resize-none h-8/12 w-full focus:outline-0"
ref={taref}
@@ -109,7 +194,7 @@ export default function TranslatorPage() {
}}
></textarea>
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
{translationResult?.sourceIpa || ""}
{ipaTexts[0]}
</div>
<div className="h-2/12 w-full flex justify-end items-center">
<IconClick
@@ -127,7 +212,7 @@ export default function TranslatorPage() {
onClick={() => {
const t = taref.current?.value;
if (!t) return;
tts(t, translationResult?.sourceLanguage || "");
tts(t, tlso.get().find((v) => v.text1 === t)?.locale1 || "");
}}
></IconClick>
</div>
@@ -135,8 +220,8 @@ export default function TranslatorPage() {
<div className="option1 w-full flex flex-row justify-between items-center">
<span>{t("detectLanguage")}</span>
<LightButton
selected={needIpa}
onClick={() => setNeedIpa((prev) => !prev)}
selected={genIpa}
onClick={() => setGenIpa((prev) => !prev)}
>
{t("generateIPA")}
</LightButton>
@@ -146,27 +231,26 @@ export default function TranslatorPage() {
{/* Card Component - Right Side */}
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
{/* ICard2 Component */}
<div className="bg-gray-100 rounded-lg w-full h-64 p-2">
<div className="h-2/3 w-full overflow-y-auto">{translationResult?.translatedText || ""}</div>
<div className="bg-gray-100 rounded-2xl w-full h-64 p-2">
<div className="h-2/3 w-full overflow-y-auto">{tresult}</div>
<div className="ipa w-full h-1/6 overflow-y-auto text-gray-600">
{translationResult?.targetIpa || ""}
{ipaTexts[1]}
</div>
<div className="h-1/6 w-full flex justify-end items-center">
<IconClick
src={IMAGES.copy_all}
alt="copy"
onClick={async () => {
await navigator.clipboard.writeText(translationResult?.translatedText || "");
await navigator.clipboard.writeText(tresult);
}}
></IconClick>
<IconClick
src={IMAGES.play_arrow}
alt="play"
onClick={() => {
if (!translationResult) return;
tts(
translationResult.translatedText,
translationResult.targetLanguage,
tresult,
tlso.get().find((v) => v.text2 === tresult)?.locale2 || "",
);
}}
></IconClick>
@@ -175,29 +259,29 @@ export default function TranslatorPage() {
<div className="option2 w-full flex gap-1 items-center flex-wrap">
<span>{t("translateInto")}</span>
<LightButton
selected={targetLanguage === "Chinese"}
onClick={() => setTargetLanguage("Chinese")}
selected={lang === "chinese"}
onClick={() => setLang("chinese")}
>
{t("chinese")}
</LightButton>
<LightButton
selected={targetLanguage === "English"}
onClick={() => setTargetLanguage("English")}
selected={lang === "english"}
onClick={() => setLang("english")}
>
{t("english")}
</LightButton>
<LightButton
selected={targetLanguage === "Italian"}
onClick={() => setTargetLanguage("Italian")}
selected={lang === "italian"}
onClick={() => setLang("italian")}
>
{t("italian")}
</LightButton>
<LightButton
selected={!["Chinese", "English", "Italian"].includes(targetLanguage)}
selected={!["chinese", "english", "italian"].includes(lang)}
onClick={() => {
const newLang = prompt(t("enterLanguage"));
if (newLang) {
setTargetLanguage(newLang);
setLang(newLang);
}
}}
>
@@ -209,15 +293,87 @@ export default function TranslatorPage() {
{/* TranslateButton Component */}
<div className="w-screen flex justify-center items-center">
<PrimaryButton
<button
className={`duration-150 ease-in text-xl font-extrabold border rounded-4xl p-3 border-gray-200 h-16 ${processing ? "bg-gray-200" : "bg-white hover:bg-gray-200 hover:cursor-pointer"}`}
onClick={translate}
disabled={processing}
size="lg"
className="text-xl"
>
{t("translate")}
</PrimaryButton>
</button>
</div>
{/* AutoSave Component */}
<div className="w-screen flex justify-center items-center">
<label className="flex items-center">
<input
type="checkbox"
checked={autoSave}
onChange={(e) => {
const checked = e.target.checked;
if (checked === true && !session) {
toast.warning("Please login to enable auto-save");
return;
}
if (checked === false) setAutoSaveFolderId(null);
setAutoSave(checked);
}}
className="mr-2"
/>
{t("autoSave")}
{autoSaveFolderId ? ` (${autoSaveFolderId})` : ""}
</label>
</div>
{history.length > 0 && (
<div className="m-6 flex flex-col items-center">
<h1 className="text-2xl font-light">{t("history")}</h1>
<div className="border border-gray-200 rounded-2xl m-4">
{history.toReversed().map((item, index) => (
<div
key={index}
className="border-b border-gray-200 p-2 group hover:bg-gray-50 flex gap-2 flex-row justify-between items-start"
>
<div className="flex-1 flex flex-col">
<p className="text-sm font-light">{item.text1}</p>
<p className="text-sm font-light">{item.text2}</p>
</div>
<div className="flex gap-2">
<button
onClick={() => {
setShowAddToFolder(true);
setAddToFolderItem(item);
}}
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-8 h-8 flex justify-center items-center"
>
<Plus />
</button>
<button
onClick={() => {
setHistory(
tlso.set(
tlso.get().filter((v) => !shallowEqual(v, item)),
) || [],
);
}}
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-8 h-8 flex justify-center items-center"
>
<Trash />
</button>
</div>
</div>
))}
</div>
{showAddToFolder && (
<AddToFolder setShow={setShowAddToFolder} item={addToFolderItem!} />
)}
{autoSave && !autoSaveFolderId && (
<FolderSelector
userId={session!.user.id as string}
cancel={() => setAutoSave(false)}
setSelectedFolderId={(id) => setAutoSaveFolderId(id)}
/>
)}
</div>
)}
</>
);
}

259
src/app/auth/AuthForm.tsx Normal file
View File

@@ -0,0 +1,259 @@
"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";
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>
)}
{/* 提交按钮 */}
<LightButton
type="submit"
className={`w-full py-2 ${isSignInPending || isSignUpPending ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{isSignInPending || isSignUpPending
? t("loading")
: t(mode === 'signin' ? 'signInButton' : 'signUpButton')
}
</LightButton>
</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>
{/* GitHub 登录按钮 */}
<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

@@ -5,99 +5,47 @@ import {
Folder as Fd,
FolderPen,
FolderPlus,
Globe,
Lock,
Trash2,
} from "lucide-react";
import { CircleButton, LightButton } from "@/design-system/base/button";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Folder } from "../../../generated/prisma/browser";
import {
createFolder,
deleteFolderById,
getFoldersWithTotalPairsByUserId,
renameFolderById,
} from "@/lib/server/services/folderService";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { PageLayout } from "@/components/ui/PageLayout";
import { PageHeader } from "@/components/ui/PageHeader";
import { CardList } from "@/components/ui/CardList";
import {
actionCreateFolder,
actionDeleteFolderById,
actionGetFoldersWithTotalPairsByUserId,
actionRenameFolderById,
actionSetFolderVisibility,
} from "@/modules/folder/folder-action";
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
import PageLayout from "@/components/ui/PageLayout";
import PageHeader from "@/components/ui/PageHeader";
import CardList from "@/components/ui/CardList";
interface FolderCardProps {
folder: TSharedFolderWithTotalPairs;
onUpdateFolder: (folderId: number, updates: Partial<TSharedFolderWithTotalPairs>) => void;
onDeleteFolder: (folderId: number) => void;
interface FolderProps {
folder: Folder & { total: number };
refresh: () => void;
}
const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps) => {
const FolderCard = ({ folder, refresh }: FolderProps) => {
const router = useRouter();
const t = useTranslations("folders");
const handleToggleVisibility = async (e: React.MouseEvent) => {
e.stopPropagation();
const newVisibility = folder.visibility === "PUBLIC" ? "PRIVATE" : "PUBLIC";
const result = await actionSetFolderVisibility(folder.id, newVisibility);
if (result.success) {
onUpdateFolder(folder.id, { visibility: newVisibility });
} else {
toast.error(result.message);
}
};
const handleRename = async (e: React.MouseEvent) => {
e.stopPropagation();
const newName = prompt(t("enterNewName"))?.trim();
if (newName && newName.length > 0) {
const result = await actionRenameFolderById(folder.id, newName);
if (result.success) {
onUpdateFolder(folder.id, { name: newName });
} else {
toast.error(result.message);
}
}
};
const handleDelete = async (e: React.MouseEvent) => {
e.stopPropagation();
const confirm = prompt(t("confirmDelete", { name: folder.name }));
if (confirm === folder.name) {
const result = await actionDeleteFolderById(folder.id);
if (result.success) {
onDeleteFolder(folder.id);
} else {
toast.error(result.message);
}
}
};
return (
<div
className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors"
className="flex justify-between items-center group p-4 border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() => {
router.push(`/folders/${folder.id}`);
}}
>
<div className="flex items-center gap-4 flex-1">
<div className="shrink-0 text-primary-500">
<Fd size={24} />
<div className="flex items-center gap-3 flex-1">
<div className="shrink-0">
<Fd className="text-gray-600" size={24} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900 truncate">{folder.name}</h3>
<span className="flex items-center gap-1 text-xs text-gray-400">
{folder.visibility === "PUBLIC" ? (
<Globe size={12} />
) : (
<Lock size={12} />
)}
{folder.visibility === "PUBLIC" ? t("public") : t("private")}
</span>
</div>
<p className="text-sm text-gray-500 mt-0.5">
<div className="flex-1">
<h3 className="font-medium text-gray-900">{folder.name}</h3>
<p className="text-sm text-gray-500">
{t("folderInfo", {
id: folder.id,
name: folder.name,
@@ -107,74 +55,63 @@ const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps)
</div>
</div>
<div className="flex items-center gap-1 ml-4">
<CircleButton
onClick={handleToggleVisibility}
title={folder.visibility === "PUBLIC" ? t("setPrivate") : t("setPublic")}
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
const newName = prompt("Input a new name.")?.trim();
if (newName && newName.length > 0) {
renameFolderById(folder.id, newName).then(refresh);
}
}}
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
{folder.visibility === "PUBLIC" ? (
<Lock size={18} />
) : (
<Globe size={18} />
)}
</CircleButton>
<CircleButton onClick={handleRename}>
<FolderPen size={18} />
</CircleButton>
<CircleButton
onClick={handleDelete}
className="hover:text-red-500 hover:bg-red-50"
<FolderPen size={16} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
const confirm = prompt(t("confirmDelete", { name: folder.name }));
if (confirm === folder.name) {
deleteFolderById(folder.id).then(refresh);
}
}}
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 size={18} />
</CircleButton>
<ChevronRight size={20} className="text-gray-400" />
<Trash2 size={16} />
</button>
<ChevronRight size={18} className="text-gray-400" />
</div>
</div>
);
};
interface FoldersClientProps {
userId: string;
}
export function FoldersClient({ userId }: FoldersClientProps) {
export default function FoldersClient({ userId }: { userId: string }) {
const t = useTranslations("folders");
const router = useRouter();
const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>([]);
const [loading, setLoading] = useState(true);
const loadFolders = async () => {
setLoading(true);
const result = await actionGetFoldersWithTotalPairsByUserId(userId);
if (result.success && result.data) {
setFolders(result.data);
}
setLoading(false);
};
const [folders, setFolders] = useState<(Folder & { total: number })[]>(
[],
);
const [loading, setLoading] = useState(false);
useEffect(() => {
loadFolders();
setLoading(true);
getFoldersWithTotalPairsByUserId(userId)
.then((folders) => {
setFolders(folders);
setLoading(false);
})
.catch((error) => {
console.error(error);
toast.error("加载出错,请重试。");
});
}, [userId]);
const handleUpdateFolder = (folderId: number, updates: Partial<TSharedFolderWithTotalPairs>) => {
setFolders((prev) =>
prev.map((f) => (f.id === folderId ? { ...f, ...updates } : f))
);
};
const handleDeleteFolder = (folderId: number) => {
setFolders((prev) => prev.filter((f) => f.id !== folderId));
};
const handleCreateFolder = async () => {
const folderName = prompt(t("enterFolderName"));
if (!folderName?.trim()) return;
const result = await actionCreateFolder(userId, folderName.trim());
if (result.success) {
loadFolders();
} else {
toast.error(result.message);
const updateFolders = async () => {
try {
const updatedFolders = await getFoldersWithTotalPairsByUserId(userId);
setFolders(updatedFolders);
} catch (error) {
console.error(error);
}
};
@@ -182,37 +119,56 @@ export function FoldersClient({ userId }: FoldersClientProps) {
<PageLayout>
<PageHeader title={t("title")} subtitle={t("subtitle")} />
<div className="mb-4">
<LightButton onClick={handleCreateFolder}>
{/* 新建文件夹按钮 */}
<button
onClick={async () => {
const folderName = prompt(t("enterFolderName"));
if (!folderName) return;
setLoading(true);
try {
await createFolder({
name: folderName,
user: { connect: { id: userId } },
});
await updateFolders();
} finally {
setLoading(false);
}
}}
disabled={loading}
className="w-full p-3 border-2 border-dashed border-gray-300 rounded-xl text-gray-500 hover:border-gray-400 hover:text-gray-600 transition-colors flex items-center justify-center gap-2"
>
<FolderPlus size={18} />
{t("newFolder")}
</LightButton>
</div>
<span>{loading ? t("creating") : t("newFolder")}</span>
</button>
{/* 文件夹列表 */}
<div className="mt-4">
<CardList>
{loading ? (
<div className="p-8 text-center">
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
<p className="text-sm text-gray-500">{t("loading")}</p>
</div>
) : folders.length === 0 ? (
{folders.length === 0 ? (
// 空状态
<div className="text-center py-12 text-gray-400">
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
<Fd size={24} className="text-gray-400" />
<FolderPlus size={24} className="text-gray-400" />
</div>
<p className="text-sm">{t("noFoldersYet")}</p>
</div>
) : (
folders.map((folder) => (
// 文件夹卡片列表
<div className="rounded-xl border border-gray-200 overflow-hidden">
{folders
.toSorted((a, b) => a.id - b.id)
.map((folder) => (
<FolderCard
key={folder.id}
folder={folder}
onUpdateFolder={handleUpdateFolder}
onDeleteFolder={handleDeleteFolder}
refresh={updateFolders}
/>
))
))}
</div>
)}
</CardList>
</div>
</PageLayout>
);
}

View File

@@ -1,9 +1,9 @@
import { LightButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { LocaleSelector } from "@/components/ui/LocaleSelector";
import { LightButton } from "@/components/ui/buttons";
import Input from "@/components/ui/Input";
import { X } from "lucide-react";
import { useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { LOCALES } from "@/config/locales";
interface AddTextPairModalProps {
isOpen: boolean;
@@ -11,12 +11,59 @@ interface AddTextPairModalProps {
onAdd: (
text1: string,
text2: string,
language1: string,
language2: string,
locale1: string,
locale2: string,
) => void;
}
export function AddTextPairModal({
const COMMON_LOCALES = [
{ label: "中文", value: "zh-CN" },
{ label: "英文", value: "en-US" },
{ label: "意大利语", value: "it-IT" },
{ label: "日语", value: "ja-JP" },
{ label: "其他", value: "other" },
];
interface LocaleSelectorProps {
value: string;
onChange: (val: string) => void;
}
function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
const isCommonLocale = COMMON_LOCALES.some((l) => l.value === value && l.value !== "other");
const showFullList = value === "other" || !isCommonLocale;
return (
<div>
<select
value={isCommonLocale ? value : "other"}
onChange={(e) => onChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-[#35786f]"
>
{COMMON_LOCALES.map((locale) => (
<option key={locale.value} value={locale.value}>
{locale.label}
</option>
))}
</select>
{showFullList && (
<select
value={value === "other" ? LOCALES[0] : value}
onChange={(e) => onChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-[#35786f] mt-2"
>
{LOCALES.map((locale) => (
<option key={locale} value={locale}>
{locale}
</option>
))}
</select>
)}
</div>
);
}
export default function AddTextPairModal({
isOpen,
onClose,
onAdd,
@@ -24,8 +71,8 @@ export function AddTextPairModal({
const t = useTranslations("folder_id");
const input1Ref = useRef<HTMLInputElement>(null);
const input2Ref = useRef<HTMLInputElement>(null);
const [language1, setLanguage1] = useState("english");
const [language2, setLanguage2] = useState("chinese");
const [locale1, setLocale1] = useState("en-US");
const [locale2, setLocale2] = useState("zh-CN");
if (!isOpen) return null;
@@ -33,8 +80,8 @@ export function AddTextPairModal({
if (
!input1Ref.current?.value ||
!input2Ref.current?.value ||
!language1 ||
!language2
!locale1 ||
!locale2
)
return;
@@ -44,14 +91,14 @@ export function AddTextPairModal({
if (
typeof text1 === "string" &&
typeof text2 === "string" &&
typeof language1 === "string" &&
typeof language2 === "string" &&
typeof locale1 === "string" &&
typeof locale2 === "string" &&
text1.trim() !== "" &&
text2.trim() !== "" &&
language1.trim() !== "" &&
language2.trim() !== ""
locale1.trim() !== "" &&
locale2.trim() !== ""
) {
onAdd(text1, text2, language1, language2);
onAdd(text1, text2, locale1, locale2);
input1Ref.current.value = "";
input2Ref.current.value = "";
}
@@ -67,7 +114,7 @@ export function AddTextPairModal({
}
}}
>
<div className="bg-white rounded-md p-6 w-full max-w-md mx-4">
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
<div className="flex">
<h2 className="flex-1 text-xl font-light mb-4 text-center">
{t("addNewTextPair")}
@@ -84,12 +131,12 @@ export function AddTextPairModal({
<Input ref={input2Ref} className="w-full"></Input>
</div>
<div>
{t("language1")}
<LocaleSelector value={language1} onChange={setLanguage1} />
{t("locale1")}
<LocaleSelector value={locale1} onChange={setLocale1} />
</div>
<div>
{t("language2")}
<LocaleSelector value={language2} onChange={setLanguage2} />
{t("locale2")}
<LocaleSelector value={locale2} onChange={setLocale2} />
</div>
</div>
<LightButton onClick={handleAdd}>{t("add")}</LightButton>

View File

@@ -3,19 +3,29 @@
import { ArrowLeft, Plus } from "lucide-react";
import { useEffect, useState } from "react";
import { redirect, useRouter } from "next/navigation";
import { AddTextPairModal } from "./AddTextPairModal";
import { TextPairCard } from "./TextPairCard";
import {
createPair,
deletePairById,
getPairsByFolderId,
} from "@/lib/server/services/pairService";
import AddTextPairModal from "./AddTextPairModal";
import TextPairCard from "./TextPairCard";
import { useTranslations } from "next-intl";
import { PageLayout } from "@/components/ui/PageLayout";
import { PrimaryButton, CircleButton, LinkButton } from "@/design-system/base/button";
import { CardList } from "@/components/ui/CardList";
import { actionCreatePair, actionDeletePairById, actionGetPairsByFolderId } from "@/modules/folder/folder-action";
import { TSharedPair } from "@/shared/folder-type";
import { toast } from "sonner";
import PageLayout from "@/components/ui/PageLayout";
import { GreenButton } from "@/components/ui/buttons";
import { IconButton } from "@/components/ui/buttons";
import CardList from "@/components/ui/CardList";
export interface TextPair {
id: number;
text1: string;
text2: string;
locale1: string;
locale2: string;
}
export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnly: boolean; }) {
const [textPairs, setTextPairs] = useState<TSharedPair[]>([]);
export default function InFolder({ folderId }: { folderId: number }) {
const [textPairs, setTextPairs] = useState<TextPair[]>([]);
const [loading, setLoading] = useState(true);
const [openAddModal, setAddModal] = useState(false);
const router = useRouter();
@@ -24,34 +34,25 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
useEffect(() => {
const fetchTextPairs = async () => {
setLoading(true);
await actionGetPairsByFolderId(folderId)
.then(result => {
if (!result.success || !result.data) {
throw new Error(result.message || "Failed to load text pairs");
}
return result.data;
}).then(setTextPairs)
.catch((error) => {
toast.error(error instanceof Error ? error.message : "Unknown error");
})
.finally(() => {
try {
const data = await getPairsByFolderId(folderId);
setTextPairs(data as TextPair[]);
} catch (error) {
console.error("Failed to fetch text pairs:", error);
} finally {
setLoading(false);
});
}
};
fetchTextPairs();
}, [folderId]);
const refreshTextPairs = async () => {
await actionGetPairsByFolderId(folderId)
.then(result => {
if (!result.success || !result.data) {
throw new Error(result.message || "Failed to refresh text pairs");
try {
const data = await getPairsByFolderId(folderId);
setTextPairs(data as TextPair[]);
} catch (error) {
console.error("Failed to fetch text pairs:", error);
}
return result.data;
}).then(setTextPairs)
.catch((error) => {
toast.error(error instanceof Error ? error.message : "Unknown error");
});
};
return (
@@ -59,13 +60,13 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
{/* 顶部导航和标题栏 */}
<div className="mb-6">
{/* 返回按钮 */}
<LinkButton
<button
onClick={router.back}
className="flex items-center gap-2 mb-4"
className="flex items-center gap-2 text-gray-500 hover:text-gray-700 transition-colors mb-4"
>
<ArrowLeft size={16} />
<span className="text-sm">{t("back")}</span>
</LinkButton>
</button>
{/* 页面标题和操作按钮 */}
<div className="flex items-center justify-between">
@@ -81,22 +82,19 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
{/* 操作按钮区域 */}
<div className="flex items-center gap-2">
<PrimaryButton
<GreenButton
onClick={() => {
redirect(`/memorize?folder_id=${folderId}`);
}}
>
{t("memorize")}
</PrimaryButton>
{!isReadOnly && (
<CircleButton
</GreenButton>
<IconButton
onClick={() => {
setAddModal(true);
}}
>
<Plus size={18} className="text-gray-700" />
</CircleButton>
)}
icon={<Plus size={18} className="text-gray-700" />}
/>
</div>
</div>
</div>
@@ -123,15 +121,9 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
<TextPairCard
key={textPair.id}
textPair={textPair}
isReadOnly={isReadOnly}
onDel={() => {
actionDeletePairById(textPair.id)
.then(result => {
if (!result.success) throw new Error(result.message || "Delete failed");
}).then(refreshTextPairs)
.catch((error) => {
toast.error(error instanceof Error ? error.message : "Unknown error");
});
deletePairById(textPair.id);
refreshTextPairs();
}}
refreshTextPairs={refreshTextPairs}
/>
@@ -147,19 +139,23 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
onAdd={async (
text1: string,
text2: string,
language1: string,
language2: string,
locale1: string,
locale2: string,
) => {
await actionCreatePair({
await createPair({
text1: text1,
text2: text2,
language1: language1,
language2: language2,
folderId: folderId,
locale1: locale1,
locale2: locale2,
folder: {
connect: {
id: folderId,
},
},
});
refreshTextPairs();
}}
/>
</PageLayout>
);
};
}

View File

@@ -1,23 +1,19 @@
import { Edit, Trash2 } from "lucide-react";
import { TextPair } from "./InFolder";
import { updatePairById } from "@/lib/server/services/pairService";
import { useState } from "react";
import { CircleButton } from "@/design-system/base/button";
import { UpdateTextPairModal } from "./UpdateTextPairModal";
import UpdateTextPairModal from "./UpdateTextPairModal";
import { useTranslations } from "next-intl";
import { TSharedPair } from "@/shared/folder-type";
import { actionUpdatePairById } from "@/modules/folder/folder-action";
import { ActionInputUpdatePairById } from "@/modules/folder/folder-action-dto";
import { toast } from "sonner";
import { PairUpdateInput } from "../../../../generated/prisma/models";
interface TextPairCardProps {
textPair: TSharedPair;
isReadOnly: boolean;
textPair: TextPair;
onDel: () => void;
refreshTextPairs: () => void;
}
export function TextPairCard({
export default function TextPairCard({
textPair,
isReadOnly,
onDel,
refreshTextPairs,
}: TextPairCardProps) {
@@ -29,33 +25,29 @@ export function TextPairCard({
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className="px-2 py-1 bg-gray-100 rounded-md">
{textPair.language1.toUpperCase()}
{textPair.locale1.toUpperCase()}
</span>
<span></span>
<span className="px-2 py-1 bg-gray-100 rounded-md">
{textPair.language2.toUpperCase()}
{textPair.locale2.toUpperCase()}
</span>
</div>
<div className="flex items-center gap-1 opacity-50 group-hover:opacity-100 transition-opacity">
{!isReadOnly && (
<>
<CircleButton
<button
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-md transition-colors"
onClick={() => setOpenUpdateModal(true)}
title={t("edit")}
className="text-gray-400 hover:text-gray-600"
>
<Edit size={14} />
</CircleButton>
<CircleButton
</button>
<button
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-colors"
onClick={onDel}
title={t("delete")}
className="text-gray-400 hover:text-red-500 hover:bg-red-50"
>
<Trash2 size={14} />
</CircleButton>
</>
)}
</button>
</div>
</div>
<div className="text-gray-900 grid grid-cols-2 gap-4 w-3/4">
@@ -74,8 +66,8 @@ export function TextPairCard({
<UpdateTextPairModal
isOpen={openUpdateModal}
onClose={() => setOpenUpdateModal(false)}
onUpdate={async (id: number, data: ActionInputUpdatePairById) => {
await actionUpdatePairById(id, data).then(result => result.success ? toast.success(result.message) : toast.error(result.message));
onUpdate={async (id: number, data: PairUpdateInput) => {
await updatePairById(id, data);
setOpenUpdateModal(false);
refreshTextPairs();
}}

View File

@@ -1,20 +1,19 @@
import { LightButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { LocaleSelector } from "@/components/ui/LocaleSelector";
import { LightButton } from "@/components/ui/buttons";
import Input from "@/components/ui/Input";
import { X } from "lucide-react";
import { useRef, useState } from "react";
import { useRef } from "react";
import { PairUpdateInput } from "../../../../generated/prisma/models";
import { TextPair } from "./InFolder";
import { useTranslations } from "next-intl";
import { TSharedPair } from "@/shared/folder-type";
import { ActionInputUpdatePairById } from "@/modules/folder/folder-action-dto";
interface UpdateTextPairModalProps {
isOpen: boolean;
onClose: () => void;
textPair: TSharedPair;
onUpdate: (id: number, tp: ActionInputUpdatePairById) => void;
textPair: TextPair;
onUpdate: (id: number, tp: PairUpdateInput) => void;
}
export function UpdateTextPairModal({
export default function UpdateTextPairModal({
isOpen,
onClose,
onUpdate,
@@ -23,34 +22,36 @@ export function UpdateTextPairModal({
const t = useTranslations("folder_id");
const input1Ref = useRef<HTMLInputElement>(null);
const input2Ref = useRef<HTMLInputElement>(null);
const [language1, setLanguage1] = useState(textPair.language1);
const [language2, setLanguage2] = useState(textPair.language2);
const input3Ref = useRef<HTMLInputElement>(null);
const input4Ref = useRef<HTMLInputElement>(null);
if (!isOpen) return null;
const handleUpdate = () => {
if (
!input1Ref.current?.value ||
!input2Ref.current?.value ||
!language1 ||
!language2
!input3Ref.current?.value ||
!input4Ref.current?.value
)
return;
const text1 = input1Ref.current.value;
const text2 = input2Ref.current.value;
const locale1 = input3Ref.current.value;
const locale2 = input4Ref.current.value;
if (
typeof text1 === "string" &&
typeof text2 === "string" &&
typeof language1 === "string" &&
typeof language2 === "string" &&
typeof locale1 === "string" &&
typeof locale2 === "string" &&
text1.trim() !== "" &&
text2.trim() !== "" &&
language1.trim() !== "" &&
language2.trim() !== ""
locale1.trim() !== "" &&
locale2.trim() !== ""
) {
onUpdate(textPair.id, { text1, text2, language1, language2 });
onUpdate(textPair.id, { text1, text2, locale1, locale2 });
input1Ref.current.value = "";
input2Ref.current.value = "";
}
};
return (
@@ -63,7 +64,7 @@ export function UpdateTextPairModal({
}
}}
>
<div className="bg-white rounded-md p-6 w-full max-w-md mx-4">
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
<div className="flex">
<h2 className="flex-1 text-xl font-light mb-4 text-center">
{t("updateTextPair")}
@@ -88,12 +89,20 @@ export function UpdateTextPairModal({
></Input>
</div>
<div>
{t("language1")}
<LocaleSelector value={language1} onChange={setLanguage1} />
{t("locale1")}
<Input
defaultValue={textPair.locale1}
ref={input3Ref}
className="w-full"
></Input>
</div>
<div>
{t("language2")}
<LocaleSelector value={language2} onChange={setLanguage2} />
{t("locale2")}
<Input
defaultValue={textPair.locale2}
ref={input4Ref}
className="w-full"
></Input>
</div>
</div>
<LightButton onClick={handleUpdate}>{t("update")}</LightButton>

View File

@@ -1,10 +1,9 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { InFolder } from "./InFolder";
import InFolder from "./InFolder";
import { getUserIdByFolderId } from "@/lib/server/services/folderService";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { actionGetFolderVisibility } from "@/modules/folder/folder-action";
export default async function FoldersPage({
params,
}: {
@@ -17,21 +16,9 @@ export default async function FoldersPage({
if (!folder_id) {
redirect("/folders");
}
const folderInfo = (await actionGetFolderVisibility(Number(folder_id))).data;
if (!folderInfo) {
redirect("/folders");
if (!session) redirect(`/auth?redirect=/folders/${folder_id}`);
if ((await getUserIdByFolderId(Number(folder_id))) !== session.user.id) {
return <p>{t("unauthorized")}</p>;
}
const isOwner = session?.user?.id === folderInfo.userId;
const isPublic = folderInfo.visibility === "PUBLIC";
if (!isOwner && !isPublic) {
redirect("/folders");
}
const isReadOnly = !isOwner;
return <InFolder folderId={Number(folder_id)} isReadOnly={isReadOnly} />;
return <InFolder folderId={Number(folder_id)} />;
}

View File

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

View File

@@ -1,230 +1,30 @@
@import "tailwindcss";
/**
* Tailwind CSS v4 主题配置
* 使用 @theme 指令定义主题变量
*/
@theme {
/* 主色 - Teal */
--color-primary-50: #f0f9f8;
--color-primary-100: #e0f2f0;
--color-primary-200: #bce6e1;
--color-primary-300: #8dd4cc;
--color-primary-400: #5ec2b7;
--color-primary-500: #35786f;
--color-primary-600: #2a605b;
--color-primary-700: #1f4844;
--color-primary-800: #183835;
--color-primary-900: #122826;
--color-primary-950: #0a1413;
/* 中性色 */
--color-gray-50: #f9fafb;
--color-gray-100: #f3f4f6;
--color-gray-200: #e5e7eb;
--color-gray-300: #d1d5db;
--color-gray-400: #9ca3af;
--color-gray-500: #6b7280;
--color-gray-600: #4b5563;
--color-gray-700: #374151;
--color-gray-800: #1f2937;
--color-gray-900: #111827;
--color-gray-950: #030712;
/* 语义色 - Success */
--color-success-50: #f0fdf4;
--color-success-100: #dcfce7;
--color-success-200: #bbf7d0;
--color-success-300: #86efac;
--color-success-400: #4ade80;
--color-success-500: #22c55e;
--color-success-600: #16a34a;
--color-success-700: #15803d;
--color-success-800: #166534;
--color-success-900: #14532d;
--color-success-950: #052e16;
/* 语义色 - Warning */
--color-warning-50: #fffbeb;
--color-warning-100: #fef3c7;
--color-warning-200: #fde68a;
--color-warning-300: #fcd34d;
--color-warning-400: #fbbf24;
--color-warning-500: #f59e0b;
--color-warning-600: #d97706;
--color-warning-700: #b45309;
--color-warning-800: #92400e;
--color-warning-900: #78350f;
--color-warning-950: #451a03;
/* 语义色 - Error */
--color-error-50: #fef2f2;
--color-error-100: #fee2e2;
--color-error-200: #fecaca;
--color-error-300: #fca5a5;
--color-error-400: #f87171;
--color-error-500: #ef4444;
--color-error-600: #dc2626;
--color-error-700: #b91c1c;
--color-error-800: #991b1b;
--color-error-900: #7f1d1d;
--color-error-950: #450a0a;
/* 语义色 - Info */
--color-info-50: #eff6ff;
--color-info-100: #dbeafe;
--color-info-200: #bfdbfe;
--color-info-300: #93c5fd;
--color-info-400: #60a5fa;
--color-info-500: #3b82f6;
--color-info-600: #2563eb;
--color-info-700: #1d4ed8;
--color-info-800: #1e40af;
--color-info-900: #1e3a8a;
--color-info-950: #172554;
/* 圆角 - 更小的圆角 */
--radius-xs: 0.125rem;
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.625rem;
--radius-2xl: 0.75rem;
--radius-3xl: 1rem;
--radius-full: 9999px;
}
/**
* Design System CSS 变量
*
* 定义全局 CSS 变量用于主题切换和动态样式
*/
:root {
/* 基础颜色 */
--background: #ffffff;
--foreground: #111827;
--foreground-secondary: #4b5563;
--foreground-tertiary: #6b7280;
--foreground-disabled: #9ca3af;
/* 背景 */
--background-secondary: #f3f4f6;
--background-tertiary: #e5e7eb;
/* 边框 */
--border: #d1d5db;
--border-secondary: #e5e7eb;
--border-focus: #35786f;
/* 圆角 - 更小的圆角 */
--radius-xs: 0.125rem;
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.625rem;
--radius-2xl: 0.75rem;
--radius-3xl: 1rem;
--radius-full: 9999px;
/* 阴影 */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
--shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
--shadow-primary: 0 4px 14px 0 rgba(53, 120, 111, 0.39);
/* 间距 */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
/* 过渡 */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 350ms cubic-bezier(0.4, 0, 0.2, 1);
--foreground: #171717;
}
/**
* 全局基础样式
*/
* {
box-sizing: border-box;
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
html {
height: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* @media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
} */
body {
height: 100%;
margin: 0;
padding: 0;
background: var(--background);
color: var(--foreground);
font-family: var(--font-geist-sans), -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
font-size: 1rem;
line-height: 1.5;
text-rendering: optimizeLegibility;
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
}
/**
* 代码块字体
*/
.code-block,
code,
kbd,
pre,
samp {
font-family: var(--font-geist-mono), ui-monospace, SFMono-Regular, Monaco, Consolas, monospace;
}
/**
* 导航栏按钮样式
*/
.navbar-btn {
@apply border-0 bg-transparent hover:bg-black/30 shadow-none;
transition: background-color var(--transition-fast);
}
/**
* 焦点可见性优化
*/
:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
/**
* 选择文本样式
*/
::selection {
background-color: var(--color-primary-200);
color: var(--color-primary-900);
}
/**
* 滚动条样式
*/
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--background-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--foreground-tertiary);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: var(--foreground-secondary);
.code-block {
font-family: var(--font-geist-mono), monospace;
}

View File

@@ -4,7 +4,6 @@ import type { Viewport } from "next";
import { NextIntlClientProvider } from "next-intl";
import { Navbar } from "@/components/layout/Navbar";
import { Toaster } from "sonner";
import { StrictMode } from "react";
export const viewport: Viewport = {
width: "device-width",
@@ -24,13 +23,11 @@ export default async function RootLayout({
return (
<html lang="en">
<body className={`antialiased`}>
<StrictMode>
<NextIntlClientProvider>
<Navbar></Navbar>
{children}
<Toaster />
</NextIntlClientProvider>
</StrictMode>
</body>
</html>
);

View File

@@ -13,7 +13,7 @@ function LinkArea({ href, name, description, color }: LinkAreaProps) {
<Link
href={href}
style={{ backgroundColor: color }}
className={`hover:scale-105 transition-transform duration-200 h-32 md:h-64 flex md:justify-center items-center`}
className={`h-32 md:h-64 flex md:justify-center items-center`}
>
<div className="text-white m-8">
<h1 className="md:text-4xl text-3xl">{name}</h1>
@@ -27,7 +27,7 @@ export default async function HomePage() {
const t = await getTranslations("home");
return (
<>
<div className="bg-primary-500 text-white w-full min-h-[75dvh] flex justify-center items-center">
<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")}
@@ -60,12 +60,6 @@ export default async function HomePage() {
description={t("srtPlayer.description")}
color="#3c988d"
></LinkArea>
<LinkArea
href="/dictionary"
name={t("dictionary.name")}
description={t("dictionary.description")}
color="#6a9c89"
></LinkArea>
<LinkArea
href="/alphabet"
name={t("alphabet.name")}

View File

@@ -0,0 +1,20 @@
"use client";
import { LightButton } from "@/components/ui/buttons";
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 >;
}

49
src/app/profile/page.tsx Normal file
View File

@@ -0,0 +1,49 @@
import Image from "next/image";
import PageLayout from "@/components/ui/PageLayout";
import PageHeader from "@/components/ui/PageHeader";
import { auth } from "@/auth";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import LogoutButton from "./LogoutButton";
export default async function ProfilePage() {
const t = await getTranslations("profile");
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
redirect("/auth?redirect=/profile");
}
return (
<PageLayout>
<PageHeader title={t("myProfile")} />
{/* 用户信息区域 */}
<div className="flex flex-col items-center gap-4">
{/* 用户头像 */}
{session.user.image && (
<Image
width={80}
height={80}
alt="User Avatar"
src={session.user.image as string}
className="rounded-full"
/>
)}
{/* 用户名和邮箱 */}
<div className="text-center">
<h2 className="text-xl font-semibold text-gray-800">
{session.user.name}
</h2>
<p className="text-gray-600">{t("email", { email: session.user.email })}</p>
</div>
{/* 登出按钮 */}
<LogoutButton />
</div>
</PageLayout>
);
}

View File

@@ -1,57 +1,20 @@
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { nextCookies } from "better-auth/next-js";
import { username } from "better-auth/plugins";
import { createAuthMiddleware, APIError } from "better-auth/api";
import { prisma } from "./lib/db";
import {
sendEmail,
generateVerificationEmailHtml,
generateResetPasswordEmailHtml,
} from "./lib/email";
import prisma from "./lib/db";
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "postgresql",
provider: "postgresql"
}),
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
sendResetPassword: async ({ user, url }) => {
void sendEmail({
to: user.email,
subject: "重置您的密码 - Learn Languages",
html: generateResetPasswordEmailHtml(url, user.name || "用户"),
});
},
},
emailVerification: {
sendOnSignUp: true,
sendVerificationEmail: async ({ user, url }) => {
void sendEmail({
to: user.email,
subject: "验证您的邮箱 - Learn Languages",
html: generateVerificationEmailHtml(url, user.name || "用户"),
});
},
enabled: true
},
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID as string,
clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
clientSecret: process.env.GITHUB_CLIENT_SECRET as string
},
},
plugins: [nextCookies(), username()],
hooks: {
before: createAuthMiddleware(async (ctx) => {
if (ctx.path !== "/sign-up/email" && ctx.path !== "/update-user") return;
const body = ctx.body as { username?: string };
if (!body.username || body.username.trim() === "") {
throw new APIError("BAD_REQUEST", {
message: "Username is required",
});
}
}),
},
plugins: [nextCookies()]
});

View File

@@ -0,0 +1,46 @@
"use client";
import IMAGES from "@/config/images";
import { IconClick, GhostButton } from "./ui/buttons";
import { useState } from "react";
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}
size={40}
></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,81 +0,0 @@
"use client";
import { GhostLightButton } from "@/design-system/base/button";
import { useState } from "react";
import { Languages } from "lucide-react";
export 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 (
<>
<GhostLightButton
size="md"
onClick={handleLanguageClick}
>
<Languages size={20} />
</GhostLightButton>
<div className="relative">
{showLanguageMenu && (
<div>
<div className="absolute top-10 right-0 rounded-md shadow-md flex flex-col gap-2">
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("en-US")}
>
English
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("zh-CN")}
>
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("ja-JP")}
>
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("ko-KR")}
>
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("de-DE")}
>
Deutsch
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("fr-FR")}
>
Français
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("it-IT")}
>
Italiano
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("ug-CN")}
>
ئۇيغۇرچە
</GhostLightButton>
</div>
</div>
)}
</div></>
);
}

View File

@@ -1,11 +1,11 @@
import Image from "next/image";
import { IMAGES } from "@/config/images";
import { Compass, Folder, Heart, Home, User } from "lucide-react";
import { LanguageSettings } from "./LanguageSettings";
import IMAGES from "@/config/images";
import { Folder, Home, User } from "lucide-react";
import LanguageSettings from "../LanguageSettings";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { getTranslations } from "next-intl/server";
import { GhostLightButton } from "@/design-system/base/button";
import { GhostButton } from "../ui/buttons";
export async function Navbar() {
const t = await getTranslations("navbar");
@@ -14,18 +14,17 @@ export async function Navbar() {
});
return (
<div className="flex justify-between items-center w-full h-16 px-4 md:px-8 bg-primary-500 text-white">
<GhostLightButton href="/" className="border-b hidden! md:block!" size="md">
<div className="flex justify-between items-center w-full h-16 px-4 md:px-8 bg-[#35786f] text-white">
<GhostButton href="/" className="text-lg md:text-xl border-b hidden! md:block!">
{t("title")}
</GhostLightButton>
<GhostLightButton className="block! md:hidden!" size="md" href={"/"}>
</GhostButton>
<GhostButton className="block! md:hidden!" href={"/"}>
<Home size={20} />
</GhostLightButton>
<div className="flex gap-0.5 justify-center items-center">
</GhostButton>
<div className="flex text-base md:text-xl gap-0.5 justify-center items-center flex-wrap">
<LanguageSettings />
<GhostLightButton
className="md:hidden! block!"
size="md"
<GhostButton
className="md:hidden! block! border-0 bg-transparent hover:bg-black/30 shadow-none p-2"
href="https://github.com/GoddoNebianU/learn-languages"
>
<Image
@@ -34,51 +33,35 @@ export async function Navbar() {
width={20}
height={20}
/>
</GhostLightButton>
<GhostLightButton href="/folders" className="md:block! hidden!" size="md">
</GhostButton>
<GhostButton href="/folders" className="md:block! hidden! border-0 bg-transparent hover:bg-black/30 shadow-none">
{t("folders")}
</GhostLightButton>
<GhostLightButton href="/folders" className="md:hidden! block!" size="md">
</GhostButton>
<GhostButton href="/folders" className="md:hidden! block! border-0 bg-transparent hover:bg-black/30 shadow-none p-2">
<Folder size={20} />
</GhostLightButton>
<GhostLightButton href="/explore" className="md:block! hidden!" size="md">
{t("explore")}
</GhostLightButton>
<GhostLightButton href="/explore" className="md:hidden! block!" size="md">
<Compass size={20} />
</GhostLightButton>
{session && (
<>
<GhostLightButton href="/favorites" className="md:block! hidden!" size="md">
{t("favorites")}
</GhostLightButton>
<GhostLightButton href="/favorites" className="md:hidden! block!" size="md">
<Heart size={20} />
</GhostLightButton>
</>
)}
<GhostLightButton
className="hidden! md:block!"
size="md"
</GhostButton>
<GhostButton
className="hidden! md:block! border-0 bg-transparent hover:bg-black/30 shadow-none"
href="https://github.com/GoddoNebianU/learn-languages"
>
{t("sourceCode")}
</GhostLightButton>
</GhostButton>
{
(() => {
return session &&
<>
<GhostLightButton href="/profile" className="hidden! md:block!" size="md">{t("profile")}</GhostLightButton>
<GhostLightButton href="/profile" className="md:hidden! block!" size="md">
<GhostButton href="/profile" className="hidden! md:block! text-sm md:text-base border-0 bg-transparent hover:bg-black/30 shadow-none px-2 py-1">{t("profile")}</GhostButton>
<GhostButton href="/profile" className="md:hidden! block! border-0 bg-transparent hover:bg-black/30 shadow-none p-2">
<User size={20} />
</GhostLightButton>
</GhostButton>
</>
|| <>
<GhostLightButton href="/login" className="hidden! md:block!" size="md">{t("sign_in")}</GhostLightButton>
<GhostLightButton href="/login" className="md:hidden! block!" size="md">
<GhostButton href="/auth" className="hidden! md:block! text-sm md:text-base border-0 bg-transparent hover:bg-black/30 shadow-none px-2 py-1">{t("sign_in")}</GhostButton>
<GhostButton href="/auth" className="md:hidden! block! border-0 bg-transparent hover:bg-black/30 shadow-none p-2">
<User size={20} />
</GhostLightButton>
</GhostButton>
</>;
})()
}
</div>

View File

@@ -0,0 +1,163 @@
"use client";
import React from "react";
import Link from "next/link";
import Image from "next/image";
import { COLORS } from "@/lib/theme/colors";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "icon";
export type ButtonSize = "sm" | "md" | "lg";
export interface ButtonProps {
// Content
children?: React.ReactNode;
// Behavior
onClick?: () => void;
disabled?: boolean;
type?: "button" | "submit" | "reset";
// Styling
variant?: ButtonVariant;
size?: ButtonSize;
className?: string;
selected?: boolean;
style?: React.CSSProperties;
// Icons
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
iconSrc?: string; // For Next.js Image icons
iconAlt?: string;
// Navigation
href?: string;
}
export default function Button({
variant = "secondary",
size = "md",
selected = false,
href,
iconSrc,
iconAlt,
leftIcon,
rightIcon,
children,
className = "",
style,
type = "button",
disabled = false,
...props
}: ButtonProps) {
// Base classes
const baseClasses = "inline-flex items-center justify-center gap-2 rounded font-bold shadow hover:cursor-pointer transition-colors";
// Variant-specific classes
const variantStyles: Record<ButtonVariant, string> = {
primary: `
text-white
hover:opacity-90
`,
secondary: `
text-black
hover:bg-gray-100
`,
ghost: `
hover:bg-black/30
p-2
`,
icon: `
p-2 bg-gray-200 rounded-full
hover:bg-gray-300
`
};
// Size-specific classes
const sizeStyles: Record<ButtonSize, string> = {
sm: "px-3 py-1 text-sm",
md: "px-4 py-2",
lg: "px-6 py-3 text-lg"
};
const variantClass = variantStyles[variant];
const sizeClass = sizeStyles[size];
// Selected state for secondary variant
const selectedClass = variant === "secondary" && selected ? "bg-gray-100" : "";
// Background color for primary variant
const backgroundColor = variant === "primary" ? COLORS.primary : undefined;
// Combine all classes
const combinedClasses = `
${baseClasses}
${variantClass}
${sizeClass}
${selectedClass}
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
${className}
`.trim().replace(/\s+/g, " ");
// Icon rendering helper for SVG icons
const renderSvgIcon = (icon: React.ReactNode, position: "left" | "right") => {
if (!icon) return null;
return (
<span className={`flex items-center ${position === "left" ? "-ml-1 mr-2" : "-mr-1 ml-2"}`}>
{icon}
</span>
);
};
// Image icon rendering for Next.js Image
const renderImageIcon = () => {
if (!iconSrc) return null;
const sizeMap = { sm: 16, md: 20, lg: 24 };
const imgSize = sizeMap[size] || 20;
return (
<Image
src={iconSrc}
width={imgSize}
height={imgSize}
alt={iconAlt || "icon"}
/>
);
};
// Content assembly
const content = (
<>
{renderImageIcon()}
{renderSvgIcon(leftIcon, "left")}
{children}
{renderSvgIcon(rightIcon, "right")}
</>
);
// If href is provided, render as Link
if (href) {
return (
<Link
href={href}
className={combinedClasses}
style={{ ...style, backgroundColor }}
>
{content}
</Link>
);
}
// Otherwise render as button
return (
<button
type={type}
disabled={disabled}
className={combinedClasses}
style={{ ...style, backgroundColor }}
{...props}
>
{content}
</button>
);
}

View File

@@ -1,21 +1,30 @@
/**
* CardList - 可滚动的卡片列表容器
*
* 使用 Design System 重写的卡片列表组件
* 用于显示可滚动的列表内容,如文件夹列表、文本对列表等
* - 最大高度 96 (24rem)
* - 垂直滚动
* - 圆角边框
*
* @example
* ```tsx
* <CardList>
* {items.map(item => (
* <div key={item.id}>{item.name}</div>
* ))}
* </CardList>
* ```
*/
import { VStack } from "@/design-system/layout/stack";
interface CardListProps {
children: React.ReactNode;
/** 额外的 CSS 类名 */
className?: string;
}
export function CardList({ children, className = "" }: CardListProps) {
export default function CardList({ children, className = "" }: CardListProps) {
return (
<div className={`max-h-96 overflow-y-auto rounded-lg border-2 border-gray-200 ${className}`}>
<VStack gap={0} align="stretch">
<div className={`max-h-96 overflow-y-auto rounded-xl border border-gray-200 overflow-hidden ${className}`}>
{children}
</VStack>
</div>
);
}

View File

@@ -1,22 +1,14 @@
/**
* Container - 容器组件
*
* 使用 Design System 重写的容器组件
*/
import { Container as DSContainer } from "@/design-system/layout/container";
import { Card } from "@/design-system/base/card";
interface ContainerProps {
children?: React.ReactNode;
className?: string;
}
export function Container({ children, className = "" }: ContainerProps) {
export default function Container({ children, className }: ContainerProps) {
return (
<DSContainer size="2xl" className={`mx-auto ${className}`}>
<Card variant="bordered" padding="md">
<div
className={`w-full max-w-2xl mx-auto bg-white border border-gray-200 rounded-2xl ${className}`}
>
{children}
</Card>
</DSContainer>
</div>
);
}

View File

@@ -0,0 +1,28 @@
interface Props {
ref?: React.Ref<HTMLInputElement>;
placeholder?: string;
type?: string;
className?: string;
name?: string;
defaultValue?: string;
}
export default function Input({
ref,
placeholder = "",
type = "text",
className = "",
name = "",
defaultValue = "",
}: Props) {
return (
<input
ref={ref}
placeholder={placeholder}
type={type}
className={`block focus:outline-none border-b-2 border-gray-600 ${className}`}
name={name}
defaultValue={defaultValue}
/>
);
}

Some files were not shown because too many files have changed in this diff Show More