Compare commits
3 Commits
dev
...
d3e1cd9092
| Author | SHA1 | Date | |
|---|---|---|---|
| d3e1cd9092 | |||
| 3ac17f66f2 | |||
| af259d4691 |
@@ -6,34 +6,3 @@ README.md
|
|||||||
.next
|
.next
|
||||||
.git
|
.git
|
||||||
certificates
|
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
|
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
kind: pipeline
|
kind: pipeline
|
||||||
type: docker
|
type: docker
|
||||||
name: learn-languages
|
name: learn-languages
|
||||||
concurrency:
|
|
||||||
limit: 1
|
|
||||||
|
|
||||||
platform:
|
platform:
|
||||||
os: linux
|
os: linux
|
||||||
|
|||||||
11
.env.example
11
.env.example
@@ -10,14 +10,3 @@ GITHUB_CLIENT_SECRET=
|
|||||||
|
|
||||||
// Database
|
// Database
|
||||||
DATABASE_URL=
|
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
3
.gitignore
vendored
@@ -46,9 +46,6 @@ next-env.d.ts
|
|||||||
build.sh
|
build.sh
|
||||||
|
|
||||||
test.ts
|
test.ts
|
||||||
test.js
|
|
||||||
/generated/prisma
|
/generated/prisma
|
||||||
|
|
||||||
certificates
|
certificates
|
||||||
|
|
||||||
.opencode
|
|
||||||
|
|||||||
9
.vscode/settings.json
vendored
9
.vscode/settings.json
vendored
@@ -6,12 +6,5 @@
|
|||||||
},
|
},
|
||||||
"[typescriptreact]": {
|
"[typescriptreact]": {
|
||||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||||
},
|
}
|
||||||
"[css]": {
|
|
||||||
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
|
|
||||||
},
|
|
||||||
"tailwindCSS.classFunctions": [
|
|
||||||
"cva",
|
|
||||||
"cx"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
222
AGENTS.md
222
AGENTS.md
@@ -1,222 +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 });
|
|
||||||
```
|
|
||||||
|
|
||||||
### i18n 翻译检查
|
|
||||||
**注意:翻译缺失不会被 build 检测出来。**
|
|
||||||
|
|
||||||
**系统性检查翻译缺失的方法(改进版):**
|
|
||||||
|
|
||||||
#### 步骤 1: 使用 AST-grep 搜索所有翻译模式
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 搜索所有 useTranslations 和 getTranslations 声明
|
|
||||||
ast-grep --pattern 'useTranslations($ARG)' --lang tsx --paths src/
|
|
||||||
|
|
||||||
# 搜索所有带插值的 t() 调用
|
|
||||||
ast-grep --pattern 't($ARG, $OPTS)' --lang tsx --paths src/
|
|
||||||
|
|
||||||
# 搜索所有简单 t() 调用
|
|
||||||
ast-grep --pattern 't($ARG)' --lang tsx --paths src/
|
|
||||||
```
|
|
||||||
|
|
||||||
**AST-grep 能捕获 31 种不同的翻译键模式, 而 grep 只能捕获 1 种模式。**
|
|
||||||
|
|
||||||
#### 步骤 2: 按文件提取所有翻译键
|
|
||||||
|
|
||||||
逐个 `.tsx` 文件检查使用的翻译键:
|
|
||||||
1. 找到该文件使用的 namespace(`useTranslations("namespace")` 或 `getTranslations("namespace")`)
|
|
||||||
2. 提取该文件中所有 `t("...")` 调用
|
|
||||||
3. 注意动态键模式:
|
|
||||||
- 模板字面量: `t(\`prefix.${variable}\`)`
|
|
||||||
- 条件键: `t(condition ? "a" : "b")`
|
|
||||||
- 变量键: `t(variable)`
|
|
||||||
4. 对比 `messages/en-US.json`,找出缺失的键
|
|
||||||
|
|
||||||
5. 先补全 `en-US.json`(作为基准语言)
|
|
||||||
6. 再根据 `en-US.json` 补全其他 7 种语言
|
|
||||||
|
|
||||||
#### 步骤 3: 验证 JSON 文件结构
|
|
||||||
**注意:JSON 语法错误会导致 build 失败,常见错误:**
|
|
||||||
- 重复的键(同一对象中出现两次相同的键名)
|
|
||||||
- 缺少逗号或多余的逗号
|
|
||||||
- 缺少闭合括号 `}`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 验证 JSON 格式
|
|
||||||
node -e "console.log(JSON.parse(require('fs').readFileSync('messages/en-US.json', 'utf8')))"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 步骤 4: 对比验证
|
|
||||||
```bash
|
|
||||||
# 列出代码中使用的所有 namespace
|
|
||||||
ast-grep --pattern 'useTranslations($ARG)' --lang tsx --paths src/ | grep -o 'useTranslations\|getTranslations' | sort | uniq
|
|
||||||
|
|
||||||
# 对比 messages/en-US.json 中的 namespace 列表
|
|
||||||
node -e "console.log(Object.keys(JSON.parse(require('fs').readFileSync('messages/en-US.json', 'utf8'))).join('\n'))"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 反模式 (本项目)
|
|
||||||
|
|
||||||
- ❌ `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
446
README.md
@@ -1,372 +1,162 @@
|
|||||||
# 🌍 多语言学习平台
|
# 多语言学习平台
|
||||||
|
|
||||||
<div align="center">
|
一个基于 Next.js 构建的全功能多语言学习平台,提供翻译、发音、字幕播放、字母学习等多种语言学习工具,帮助用户更高效地掌握新语言。
|
||||||
|
|
||||||
[](https://nextjs.org/)
|
## ✨ 主要功能
|
||||||
[](https://reactjs.org/)
|
|
||||||
[](https://www.typescriptlang.org/)
|
|
||||||
[](https://www.postgresql.org/)
|
|
||||||
[](./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 音标标注
|
- **ESLint** - 代码质量检查
|
||||||
- **词典查询** - 详细的单词释义、词性分析、例句展示
|
- **pnpm** - 高效的包管理器
|
||||||
- **语音合成** - 阿里云千问 TTS 提供自然的语音输出
|
|
||||||
- **个人学习空间** - 文件夹管理、学习资料组织
|
|
||||||
|
|
||||||
### 🔐 用户系统
|
## 📁 项目结构
|
||||||
- **多方式认证** - 邮箱/用户名登录、GitHub OAuth
|
|
||||||
- **个人资料** - 用户主页、学习进度追踪
|
|
||||||
- **数据安全** - better-auth 提供企业级安全保障
|
|
||||||
|
|
||||||
### 🌐 国际化
|
```
|
||||||
- **8 种语言** - en-US, zh-CN, ja-JP, ko-KR, de-DE, fr-FR, it-IT, ug-CN
|
src/
|
||||||
- **完整本地化** - 所有界面文本支持多语言
|
├── app/ # Next.js App Router 路由
|
||||||
|
│ ├── (features)/ # 功能模块路由
|
||||||
### 🏗️ 技术亮点
|
│ ├── api/ # API 路由
|
||||||
- **App Router** - 采用 Next.js 16 最新路由系统
|
│ └── auth/ # 认证相关页面
|
||||||
- **Server Components** - 优先服务端渲染,优化性能
|
├── components/ # React 组件
|
||||||
- **Action-Service-Repository** - 清晰的三层架构设计
|
│ ├── buttons/ # 按钮组件
|
||||||
- **类型安全** - TypeScript 严格模式 + Zod 验证
|
│ ├── cards/ # 卡片组件
|
||||||
|
│ └── ...
|
||||||
---
|
├── lib/ # 工具函数和库
|
||||||
|
│ ├── actions/ # Server Actions
|
||||||
|
│ ├── browser/ # 浏览器端工具
|
||||||
|
│ └── server/ # 服务器端工具
|
||||||
|
├── hooks/ # 自定义 React Hooks
|
||||||
|
├── i18n/ # 国际化配置
|
||||||
|
└── config/ # 应用配置
|
||||||
|
```
|
||||||
|
|
||||||
## 🚀 快速开始
|
## 🚀 快速开始
|
||||||
|
|
||||||
### 前置要求
|
### 环境要求
|
||||||
|
|
||||||
- Node.js 24+
|
- Node.js 24
|
||||||
- PostgreSQL 14+
|
- PostgreSQL 数据库
|
||||||
- pnpm 8+ (推荐) 或 npm/yarn
|
- pnpm (推荐) 或 npm
|
||||||
|
|
||||||
### 安装步骤
|
### 本地开发
|
||||||
|
|
||||||
|
1. 克隆项目
|
||||||
```bash
|
```bash
|
||||||
# 1. 克隆项目
|
|
||||||
git clone <repository-url>
|
git clone <repository-url>
|
||||||
cd learn-languages
|
cd learn-languages
|
||||||
|
```
|
||||||
|
|
||||||
# 2. 安装依赖
|
2. 安装依赖
|
||||||
|
```bash
|
||||||
pnpm install
|
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
|
```bash
|
||||||
# 开发
|
cp .env.example .env.local
|
||||||
pnpm dev # 启动开发服务器 (HTTPS)
|
|
||||||
pnpm build # 构建生产版本
|
|
||||||
pnpm start # 启动生产服务器
|
|
||||||
pnpm lint # 代码检查
|
|
||||||
|
|
||||||
# 数据库
|
|
||||||
pnpm prisma studio # 打开数据库 GUI
|
|
||||||
pnpm prisma db push # 同步 Schema
|
|
||||||
pnpm prisma migrate # 创建迁移
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 代码规范
|
然后编辑 `.env.local` 文件,配置所有必要的环境变量:
|
||||||
|
|
||||||
- ✅ TypeScript 严格模式
|
```env
|
||||||
- ✅ ESLint + TypeScript Plugin
|
// LLM
|
||||||
- ✅ 优先使用 Server Components
|
ZHIPU_API_KEY=your-zhipu-api-key
|
||||||
- ✅ 新功能遵循 Action-Service-Repository
|
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/` - 业务模块,每个模块包含:
|
// Database
|
||||||
- `*-action.ts` - Server Actions
|
DATABASE_URL=postgresql://username:password@localhost:5432/database_name
|
||||||
- `*-service.ts` - 业务逻辑
|
```
|
||||||
- `*-repository.ts` - 数据访问
|
|
||||||
- `*-dto.ts` - 数据传输对象
|
|
||||||
- `components/` - 业务相关组件
|
|
||||||
- `design-system/` - 可复用基础组件
|
|
||||||
- `lib/` - 工具函数和库
|
|
||||||
|
|
||||||
---
|
注意:所有带 `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`)
|
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`)
|
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||||
5. 开启 Pull Request
|
5. 打开 Pull Request
|
||||||
|
|
||||||
### 代码提交规范
|
|
||||||
|
|
||||||
```
|
|
||||||
feat: 新功能
|
|
||||||
fix: 修复问题
|
|
||||||
docs: 文档变更
|
|
||||||
style: 代码格式
|
|
||||||
refactor: 重构
|
|
||||||
test: 测试相关
|
|
||||||
chore: 构建/工具
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📄 许可证
|
## 📄 许可证
|
||||||
|
|
||||||
本项目采用 [AGPL-3.0](./LICENSE) 许可证。
|
本项目采用 AGPL-3.0 许可证 - 查看 [LICENSE](./LICENSE) 文件了解详情。
|
||||||
|
|
||||||
|
## 📞 支持
|
||||||
|
|
||||||
|
如果您遇到问题或有建议,请通过以下方式联系:
|
||||||
|
|
||||||
|
- 提交 [Issue](../../issues)
|
||||||
|
- 发送邮件至 [goddonebianu@outlook.com]
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📞 联系方式
|
**Happy Learning!** 🌟
|
||||||
|
|
||||||
- **问题反馈**:[GitHub Issues](../../issues)
|
|
||||||
- **邮箱**:goddonebianu@outlook.com
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
**如果这个项目对你有帮助,请给一个 ⭐️ Star!**
|
|
||||||
|
|
||||||
Made with ❤️ by the community
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,622 +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"
|
|
||||||
},
|
|
||||||
"decks": {
|
|
||||||
"title": "Decks",
|
|
||||||
"noDecks": "Noch keine Decks",
|
|
||||||
"deckName": "Deckname",
|
|
||||||
"totalCards": "Gesamtkarten",
|
|
||||||
"createdAt": "Erstellt am",
|
|
||||||
"actions": "Aktionen",
|
|
||||||
"view": "Anzeigen",
|
|
||||||
"subtitle": "Lern-Decks verwalten",
|
|
||||||
"newDeck": "Neues Deck",
|
|
||||||
"noDecksYet": "Noch keine Decks",
|
|
||||||
"loading": "Laden...",
|
|
||||||
"deckInfo": "ID: {id} · {totalCards} Karten",
|
|
||||||
"enterDeckName": "Deck-Name eingeben:",
|
|
||||||
"enterNewName": "Neuen Namen eingeben:",
|
|
||||||
"confirmDelete": "\"{name}\" eingeben zum Löschen:",
|
|
||||||
"public": "Öffentlich",
|
|
||||||
"private": "Privat",
|
|
||||||
"setPublic": "Öffentlich machen",
|
|
||||||
"setPrivate": "Privat machen",
|
|
||||||
"importApkg": "APKG importieren",
|
|
||||||
"exportApkg": "APKG exportieren",
|
|
||||||
"clickToUpload": "Klicken zum Hochladen",
|
|
||||||
"apkgFilesOnly": "Nur .apkg Dateien",
|
|
||||||
"parsing": "Analysieren...",
|
|
||||||
"foundDecks": "{count} Decks gefunden",
|
|
||||||
"back": "Zurück",
|
|
||||||
"import": "Importieren",
|
|
||||||
"importing": "Importieren...",
|
|
||||||
"exportSuccess": "Export erfolgreich",
|
|
||||||
"goToDecks": "Zu Decks"
|
|
||||||
},
|
|
||||||
"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.",
|
|
||||||
"verifyYourEmail": "E-Mail bestätigen",
|
|
||||||
"verificationEmailSent": "Bestätigungs-E-Mail gesendet",
|
|
||||||
"verificationEmailSentHint": "Wir haben eine Bestätigungs-E-Mail an {email} gesendet. Bitte klicken Sie auf den Link in der E-Mail, um Ihr Konto zu bestätigen.",
|
|
||||||
"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.",
|
|
||||||
"emailNotVerified": "Bitte verifizieren Sie Ihre E-Mail-Adresse",
|
|
||||||
"emailNotVerifiedHint": "Ihre E-Mail-Adresse wurde nicht verifiziert. Bitte überprüfen Sie Ihren Posteingang oder fordern Sie eine neue Verifizierungs-E-Mail an.",
|
|
||||||
"resendVerification": "Verifizierungs-E-Mail erneut senden",
|
|
||||||
"resendSuccess": "Verifizierungs-E-Mail gesendet! Bitte überprüfen Sie Ihren Posteingang.",
|
|
||||||
"resendFailed": "Verifizierungs-E-Mail konnte nicht gesendet werden"
|
|
||||||
},
|
|
||||||
"memorize": {
|
|
||||||
"deck_selector": {
|
|
||||||
"selectDeck": "Deck wählen",
|
|
||||||
"noDecks": "Keine Decks",
|
|
||||||
"goToDecks": "Zu Decks",
|
|
||||||
"noCards": "Keine Karten",
|
|
||||||
"new": "Neu",
|
|
||||||
"learning": "Lernen",
|
|
||||||
"review": "Wiederholen",
|
|
||||||
"due": "Fällig"
|
|
||||||
},
|
|
||||||
"review": {
|
|
||||||
"loading": "Laden...",
|
|
||||||
"backToDecks": "Zurück zu Decks",
|
|
||||||
"allDone": "Alles erledigt!",
|
|
||||||
"allDoneDesc": "Lernen für heute abgeschlossen!",
|
|
||||||
"reviewedCount": "{count} Karten wiederholt",
|
|
||||||
"progress": "{current} / {total}",
|
|
||||||
"nextReview": "Nächste Wiederholung",
|
|
||||||
"interval": "Intervall",
|
|
||||||
"ease": "Schwierigkeit",
|
|
||||||
"lapses": "Fehler",
|
|
||||||
"showAnswer": "Antwort zeigen",
|
|
||||||
"nextCard": "Weiter",
|
|
||||||
"again": "Nochmal",
|
|
||||||
"restart": "Neustart",
|
|
||||||
"orderLimited": "Reihenfolge begrenzt",
|
|
||||||
"orderInfinite": "Reihenfolge unbegrenzt",
|
|
||||||
"randomLimited": "Zufällig begrenzt",
|
|
||||||
"randomInfinite": "Zufällig unbegrenzt",
|
|
||||||
"noIpa": "Kein IPA verfügbar"
|
|
||||||
},
|
|
||||||
"page": {
|
|
||||||
"unauthorized": "Nicht autorisiert"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"navbar": {
|
|
||||||
"title": "learn-languages",
|
|
||||||
"sourceCode": "GitHub",
|
|
||||||
"sign_in": "Anmelden",
|
|
||||||
"profile": "Profil",
|
|
||||||
"folders": "Decks",
|
|
||||||
"explore": "Erkunden",
|
|
||||||
"favorites": "Favoriten",
|
|
||||||
"settings": "Einstellungen"
|
|
||||||
},
|
|
||||||
"ocr": {
|
|
||||||
"title": "OCR-Erkennung",
|
|
||||||
"description": "Text aus Bildern extrahieren",
|
|
||||||
"uploadImage": "Bild hochladen",
|
|
||||||
"dragDropHint": "Ziehen und ablegen",
|
|
||||||
"supportedFormats": "Unterstützt: JPG, PNG, WEBP",
|
|
||||||
"selectDeck": "Deck wählen",
|
|
||||||
"chooseDeck": "Deck wählen",
|
|
||||||
"noDecks": "Keine Decks verfügbar",
|
|
||||||
"languageHints": "Sprachhinweise",
|
|
||||||
"sourceLanguageHint": "Quellsprache",
|
|
||||||
"targetLanguageHint": "Zielsprache",
|
|
||||||
"process": "Verarbeiten",
|
|
||||||
"processing": "Verarbeiten...",
|
|
||||||
"preview": "Vorschau",
|
|
||||||
"extractedPairs": "Extrahierte Paare",
|
|
||||||
"word": "Wort",
|
|
||||||
"definition": "Definition",
|
|
||||||
"pairsCount": "{count} Paare",
|
|
||||||
"savePairs": "Speichern",
|
|
||||||
"saving": "Speichern...",
|
|
||||||
"saved": "Gespeichert",
|
|
||||||
"saveFailed": "Speichern fehlgeschlagen",
|
|
||||||
"noImage": "Bitte Bild hochladen",
|
|
||||||
"noDeck": "Bitte Deck wählen",
|
|
||||||
"processingFailed": "Verarbeitung fehlgeschlagen",
|
|
||||||
"tryAgain": "Erneut versuchen",
|
|
||||||
"detectedLanguages": "Erkannte Sprachen",
|
|
||||||
"invalidFileType": "Ungültiger Dateityp",
|
|
||||||
"ocrFailed": "OCR fehlgeschlagen",
|
|
||||||
"uploadSection": "Bild hochladen",
|
|
||||||
"dropOrClick": "Ablegen oder klicken",
|
|
||||||
"changeImage": "Bild ändern",
|
|
||||||
"deckSelection": "Deck wählen",
|
|
||||||
"sourceLanguagePlaceholder": "z.B. Englisch",
|
|
||||||
"targetLanguagePlaceholder": "z.B. Deutsch",
|
|
||||||
"processButton": "Erkennung starten",
|
|
||||||
"resultsPreview": "Ergebnisvorschau",
|
|
||||||
"saveButton": "In Deck speichern",
|
|
||||||
"ocrSuccess": "OCR erfolgreich",
|
|
||||||
"savedToDeck": "In Deck gespeichert",
|
|
||||||
"noResultsToSave": "Keine Ergebnisse",
|
|
||||||
"detectedSourceLanguage": "Erkannte Quellsprache",
|
|
||||||
"detectedTargetLanguage": "Erkannte Zielsprache"
|
|
||||||
},
|
|
||||||
"profile": {
|
|
||||||
"myProfile": "Mein Profil",
|
|
||||||
"email": "E-Mail: {email}",
|
|
||||||
"logout": "Abmelden"
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"title": "Einstellungen",
|
|
||||||
"themeColor": "Designfarbe",
|
|
||||||
"themeColorDescription": "Wählen Sie Ihre bevorzugte Designfarbe"
|
|
||||||
},
|
|
||||||
"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",
|
|
||||||
"settings": "Einstellungen",
|
|
||||||
"shortcuts": "Tastenkürzel",
|
|
||||||
"keyboardShortcuts": "Tastaturkürzel",
|
|
||||||
"playPause": "Wiedergabe/Pause",
|
|
||||||
"autoPauseToggle": "Auto-Pause",
|
|
||||||
"subtitleSettings": "Untertiteleinstellungen",
|
|
||||||
"fontSize": "Schriftgröße",
|
|
||||||
"textColor": "Textfarbe",
|
|
||||||
"backgroundColor": "Hintergrundfarbe",
|
|
||||||
"position": "Position",
|
|
||||||
"opacity": "Deckkraft",
|
|
||||||
"top": "Oben",
|
|
||||||
"center": "Mitte",
|
|
||||||
"bottom": "Unten"
|
|
||||||
},
|
|
||||||
"text_speaker": {
|
|
||||||
"generateIPA": "IPA generieren",
|
|
||||||
"viewSavedItems": "Gespeicherte Einträge anzeigen",
|
|
||||||
"confirmDeleteAll": "Sind Sie sicher, dass Sie alles löschen möchten? (J/N)",
|
|
||||||
"saved": "Gespeichert",
|
|
||||||
"clearAll": "Alles löschen",
|
|
||||||
"language": "Sprache",
|
|
||||||
"customLanguage": "oder Sprache eingeben...",
|
|
||||||
"languages": {
|
|
||||||
"auto": "Automatisch",
|
|
||||||
"chinese": "Chinesisch",
|
|
||||||
"english": "Englisch",
|
|
||||||
"japanese": "Japanisch",
|
|
||||||
"korean": "Koreanisch",
|
|
||||||
"french": "Französisch",
|
|
||||||
"german": "Deutsch",
|
|
||||||
"italian": "Italienisch",
|
|
||||||
"spanish": "Spanisch",
|
|
||||||
"portuguese": "Portugiesisch",
|
|
||||||
"russian": "Russisch"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"translator": {
|
|
||||||
"detectLanguage": "Sprache erkennen",
|
|
||||||
"sourceLanguage": "Quellsprache",
|
|
||||||
"auto": "Automatisch",
|
|
||||||
"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",
|
|
||||||
"customLanguage": "oder Sprache eingeben...",
|
|
||||||
"pleaseLogin": "Bitte anmelden um Karten zu speichern",
|
|
||||||
"pleaseCreateDeck": "Bitte erst zuerst ein Deck",
|
|
||||||
"noTranslationToSave": "Keine Übersetzung zum Speichern",
|
|
||||||
"noDeckSelected": "Kein Deck ausgewählt",
|
|
||||||
"saveAsCard": "Als Karte speichern",
|
|
||||||
"selectDeck": "Deck wählen",
|
|
||||||
"front": "Vorderseite",
|
|
||||||
"back": "Rückseite",
|
|
||||||
"cancel": "Abbrechen",
|
|
||||||
"save": "Speichern",
|
|
||||||
"savedToDeck": "Karte in {deckName} gespeichert",
|
|
||||||
"saveFailed": "Karte speichern fehlgeschlagen"
|
|
||||||
},
|
|
||||||
"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",
|
|
||||||
"noDecks": "Keine öffentlichen Decks",
|
|
||||||
"deckInfo": "{userName} · {totalCards} Karten"
|
|
||||||
},
|
|
||||||
"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",
|
|
||||||
"totalCards": "{count} Karten"
|
|
||||||
},
|
|
||||||
"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",
|
|
||||||
"deleteAccount": {
|
|
||||||
"button": "Konto löschen",
|
|
||||||
"title": "Konto löschen",
|
|
||||||
"warning": "Diese Aktion ist unwiderruflich. Alle Ihre Daten werden dauerhaft gelöscht.",
|
|
||||||
"warningDecks": "Alle Ihre Decks und Karten",
|
|
||||||
"warningCards": "All Ihr Lernfortschritt",
|
|
||||||
"warningHistory": "All Ihr Übersetzungs- und Wörterbuchverlauf",
|
|
||||||
"warningPermanent": "Diese Aktion kann nicht rückgängig gemacht werden",
|
|
||||||
"confirmLabel": "Geben Sie Ihren Benutzernamen zur Bestätigung ein:",
|
|
||||||
"usernameMismatch": "Benutzername stimmt nicht überein",
|
|
||||||
"cancel": "Abbrechen",
|
|
||||||
"confirm": "Mein Konto löschen",
|
|
||||||
"success": "Konto erfolgreich gelöscht",
|
|
||||||
"failed": "Konto konnte nicht gelöscht werden"
|
|
||||||
},
|
|
||||||
"folders": {
|
|
||||||
"title": "Decks",
|
|
||||||
"noFolders": "Noch keine Decks",
|
|
||||||
"folderName": "Deckname",
|
|
||||||
"totalPairs": "Gesamtkarten",
|
|
||||||
"createdAt": "Erstellt am",
|
|
||||||
"actions": "Aktionen",
|
|
||||||
"view": "Anzeigen"
|
|
||||||
},
|
|
||||||
"joined": "Beigetreten",
|
|
||||||
"decks": {
|
|
||||||
"title": "Meine Decks",
|
|
||||||
"noDecks": "Keine Decks",
|
|
||||||
"deckName": "Deck-Name",
|
|
||||||
"totalCards": "Gesamtkarten",
|
|
||||||
"createdAt": "Erstellt am",
|
|
||||||
"actions": "Aktionen",
|
|
||||||
"view": "Ansehen"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"follow": {
|
|
||||||
"follow": "Folgen",
|
|
||||||
"following": "Folge ich",
|
|
||||||
"followers": "Follower",
|
|
||||||
"followersOf": "{username}s Follower",
|
|
||||||
"followingOf": "{username} folgt",
|
|
||||||
"noFollowers": "Noch keine Follower",
|
|
||||||
"noFollowing": "Folgt noch niemandem"
|
|
||||||
},
|
|
||||||
"deck_id": {
|
|
||||||
"unauthorized": "Sie sind nicht der Besitzer dieses Decks",
|
|
||||||
"back": "Zurück",
|
|
||||||
"cards": "Karten",
|
|
||||||
"itemsCount": "{count} Elemente",
|
|
||||||
"memorize": "Auswendig lernen",
|
|
||||||
"loadingCards": "Karten werden geladen...",
|
|
||||||
"noCards": "Keine Karten in diesem Deck",
|
|
||||||
"card": "Karte",
|
|
||||||
"addNewCard": "Neue Karte hinzufügen",
|
|
||||||
"add": "Hinzufügen",
|
|
||||||
"adding": "Wird hinzugefügt...",
|
|
||||||
"updateCard": "Karte aktualisieren",
|
|
||||||
"update": "Aktualisieren",
|
|
||||||
"updating": "Wird aktualisiert...",
|
|
||||||
"word": "Wort",
|
|
||||||
"definition": "Definition",
|
|
||||||
"ipa": "IPA",
|
|
||||||
"example": "Beispiel",
|
|
||||||
"wordAndDefinitionRequired": "Wort und Definition sind erforderlich",
|
|
||||||
"edit": "Bearbeiten",
|
|
||||||
"delete": "Löschen",
|
|
||||||
"permissionDenied": "Sie haben keine Berechtigung für diese Aktion",
|
|
||||||
"resetProgress": "Fortschritt zurücksetzen",
|
|
||||||
"resetProgressTitle": "Lernfortschritt zurücksetzen",
|
|
||||||
"resetProgressConfirm": "Fortschritt wirklich zurücksetzen?",
|
|
||||||
"resetSuccess": "Fortschritt zurückgesetzt",
|
|
||||||
"resetting": "Zurücksetzen...",
|
|
||||||
"cancel": "Abbrechen",
|
|
||||||
"settings": "Einstellungen",
|
|
||||||
"settingsTitle": "Deck-Einstellungen",
|
|
||||||
"newPerDay": "Neue pro Tag",
|
|
||||||
"newPerDayHint": "Neue Karten pro Tag",
|
|
||||||
"revPerDay": "Wiederholungen pro Tag",
|
|
||||||
"revPerDayHint": "Wiederholungen pro Tag",
|
|
||||||
"save": "Speichern",
|
|
||||||
"saving": "Speichern...",
|
|
||||||
"settingsSaved": "Einstellungen gespeichert",
|
|
||||||
"todayNew": "Heute neu",
|
|
||||||
"todayReview": "Heute wiederholen",
|
|
||||||
"todayLearning": "Lernen",
|
|
||||||
"error": {
|
|
||||||
"update": "Keine Berechtigung zum Aktualisieren",
|
|
||||||
"delete": "Keine Berechtigung zum Löschen",
|
|
||||||
"add": "Keine Berechtigung zum Hinzufügen"
|
|
||||||
},
|
|
||||||
"ipaPlaceholder": "IPA eingeben",
|
|
||||||
"examplePlaceholder": "Beispiel eingeben",
|
|
||||||
"wordRequired": "Bitte Wort eingeben",
|
|
||||||
"definitionRequired": "Bitte Definition eingeben",
|
|
||||||
"cardAdded": "Karte hinzugefügt",
|
|
||||||
"cardType": "Kartentyp",
|
|
||||||
"wordCard": "Wortkarte",
|
|
||||||
"phraseCard": "Phrasenkarte",
|
|
||||||
"sentenceCard": "Satzkarte",
|
|
||||||
"sentence": "Satz",
|
|
||||||
"sentencePlaceholder": "Satz eingeben",
|
|
||||||
"wordPlaceholder": "Wort eingeben",
|
|
||||||
"queryLang": "Abfragesprache",
|
|
||||||
"enterLanguageName": "Bitte Sprachnamen eingeben",
|
|
||||||
"english": "Englisch",
|
|
||||||
"chinese": "Chinesisch",
|
|
||||||
"japanese": "Japanisch",
|
|
||||||
"korean": "Koreanisch",
|
|
||||||
"meanings": "Bedeutungen",
|
|
||||||
"addMeaning": "Bedeutung hinzufügen",
|
|
||||||
"partOfSpeech": "Wortart",
|
|
||||||
"deleteConfirm": "Karte wirklich löschen?",
|
|
||||||
"cardDeleted": "Karte gelöscht",
|
|
||||||
"cardUpdated": "Karte aktualisiert"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"alphabet": {
|
"alphabet": {
|
||||||
"chooseCharacters": "Please select the characters you want to learn",
|
"chooseCharacters": "Please select the characters you want to learn",
|
||||||
"chooseAlphabetHint": "Select an alphabet to start learning",
|
|
||||||
"japanese": "Japanese Kana",
|
"japanese": "Japanese Kana",
|
||||||
"english": "English Alphabet",
|
"english": "English Alphabet",
|
||||||
"uyghur": "Uyghur Alphabet",
|
"uyghur": "Uyghur Alphabet",
|
||||||
@@ -15,11 +14,7 @@
|
|||||||
"roman": "Romanization",
|
"roman": "Romanization",
|
||||||
"letter": "Letter",
|
"letter": "Letter",
|
||||||
"random": "Random Mode",
|
"random": "Random Mode",
|
||||||
"randomNext": "Random Next",
|
"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"
|
|
||||||
},
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
"title": "Folders",
|
"title": "Folders",
|
||||||
@@ -27,24 +22,13 @@
|
|||||||
"newFolder": "New Folder",
|
"newFolder": "New Folder",
|
||||||
"creating": "Creating...",
|
"creating": "Creating...",
|
||||||
"noFoldersYet": "No folders yet",
|
"noFoldersYet": "No folders yet",
|
||||||
"folderInfo": "ID: {id} • {totalPairs} pairs",
|
"folderInfo": "{id}. {name} ({totalPairs})",
|
||||||
"enterFolderName": "Enter folder name:",
|
"enterFolderName": "Enter folder name:",
|
||||||
"confirmDelete": "Type \"{name}\" to delete:",
|
"confirmDelete": "Type \"{name}\" to delete:",
|
||||||
"myFolders": "My Folders",
|
"createFolderSuccess": "Folder created successfully",
|
||||||
"publicFolders": "Public Folders",
|
"deleteFolderSuccess": "Folder deleted successfully",
|
||||||
"public": "Public",
|
"createFolderError": "Failed to create folder",
|
||||||
"private": "Private",
|
"deleteFolderError": "Failed to delete folder"
|
||||||
"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"
|
|
||||||
},
|
},
|
||||||
"folder_id": {
|
"folder_id": {
|
||||||
"unauthorized": "You are not the owner of this folder",
|
"unauthorized": "You are not the owner of this folder",
|
||||||
@@ -60,90 +44,10 @@
|
|||||||
"update": "Update",
|
"update": "Update",
|
||||||
"text1": "Text 1",
|
"text1": "Text 1",
|
||||||
"text2": "Text 2",
|
"text2": "Text 2",
|
||||||
"language1": "Locale 1",
|
"locale1": "Locale 1",
|
||||||
"language2": "Locale 2",
|
"locale2": "Locale 2",
|
||||||
"enterLanguageName": "Please enter language name",
|
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete",
|
"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."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"deck_id": {
|
|
||||||
"unauthorized": "You are not the owner of this deck",
|
|
||||||
"back": "Back",
|
|
||||||
"cards": "Cards",
|
|
||||||
"itemsCount": "{count} items",
|
|
||||||
"memorize": "Memorize",
|
|
||||||
"loadingCards": "Loading cards...",
|
|
||||||
"noCards": "No cards in this deck",
|
|
||||||
"card": "Card",
|
|
||||||
"addNewCard": "Add New Card",
|
|
||||||
"add": "Add",
|
|
||||||
"adding": "Adding...",
|
|
||||||
"updateCard": "Update Card",
|
|
||||||
"update": "Update",
|
|
||||||
"updating": "Updating...",
|
|
||||||
"word": "Word",
|
|
||||||
"definition": "Definition",
|
|
||||||
"ipa": "IPA",
|
|
||||||
"ipaPlaceholder": "Enter IPA pronunciation",
|
|
||||||
"example": "Example",
|
|
||||||
"examplePlaceholder": "Enter an example sentence",
|
|
||||||
"wordAndDefinitionRequired": "Word and definition are required",
|
|
||||||
"wordRequired": "Word is required",
|
|
||||||
"definitionRequired": "At least one definition is required",
|
|
||||||
"cardAdded": "Card added successfully",
|
|
||||||
"cardType": "Card Type",
|
|
||||||
"wordCard": "Word",
|
|
||||||
"phraseCard": "Phrase",
|
|
||||||
"sentenceCard": "Sentence",
|
|
||||||
"sentence": "Sentence",
|
|
||||||
"sentencePlaceholder": "Enter a sentence",
|
|
||||||
"wordPlaceholder": "Enter a word",
|
|
||||||
"queryLang": "Language",
|
|
||||||
"enterLanguageName": "Please enter language name",
|
|
||||||
"english": "English",
|
|
||||||
"chinese": "Chinese",
|
|
||||||
"japanese": "Japanese",
|
|
||||||
"korean": "Korean",
|
|
||||||
"meanings": "Meanings",
|
|
||||||
"addMeaning": "Add Meaning",
|
|
||||||
"partOfSpeech": "Part of Speech",
|
|
||||||
"edit": "Edit",
|
|
||||||
"delete": "Delete",
|
|
||||||
"deleteConfirm": "Are you sure you want to delete this card?",
|
|
||||||
"cardDeleted": "Card deleted",
|
|
||||||
"permissionDenied": "You do not have permission to perform this action",
|
|
||||||
"resetProgress": "Reset",
|
|
||||||
"resetProgressTitle": "Reset Deck Progress",
|
|
||||||
"resetProgressConfirm": "This will reset all cards in this deck to new state. Your learning progress will be lost. Are you sure?",
|
|
||||||
"resetSuccess": "Successfully reset {count} cards",
|
|
||||||
"resetting": "Resetting...",
|
|
||||||
"cancel": "Cancel",
|
|
||||||
"settings": "Settings",
|
|
||||||
"settingsTitle": "Deck Settings",
|
|
||||||
"newPerDay": "New Cards Per Day",
|
|
||||||
"newPerDayHint": "Maximum new cards to learn each day",
|
|
||||||
"revPerDay": "Review Cards Per Day",
|
|
||||||
"revPerDayHint": "Maximum review cards each day",
|
|
||||||
"save": "Save",
|
|
||||||
"saving": "Saving...",
|
|
||||||
"settingsSaved": "Settings saved",
|
|
||||||
"todayNew": "New",
|
|
||||||
"todayReview": "Review",
|
|
||||||
"todayLearning": "Learning",
|
|
||||||
"cardUpdated": "Card updated",
|
|
||||||
"error": {
|
|
||||||
"update": "You do not have permission to update this card.",
|
|
||||||
"delete": "You do not have permission to delete this card.",
|
|
||||||
"add": "You do not have permission to add cards to this deck."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "Learn Languages",
|
"title": "Learn Languages",
|
||||||
@@ -173,26 +77,23 @@
|
|||||||
"name": "Memorize",
|
"name": "Memorize",
|
||||||
"description": "Language A to Language B, Language B to Language A, supports dictation"
|
"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": {
|
"moreFeatures": {
|
||||||
"name": "More Features",
|
"name": "More Features",
|
||||||
"description": "Under development, stay tuned"
|
"description": "Under development, stay tuned"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"login": {
|
||||||
|
"loading": "Loading...",
|
||||||
|
"githubLogin": "GitHub Login"
|
||||||
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "Sign In",
|
"title": "Authentication",
|
||||||
"signUpTitle": "Sign Up",
|
|
||||||
"signIn": "Sign In",
|
"signIn": "Sign In",
|
||||||
"signUp": "Sign Up",
|
"signUp": "Sign Up",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"confirmPassword": "Confirm Password",
|
"confirmPassword": "Confirm Password",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"username": "Username",
|
|
||||||
"emailOrUsername": "Email or Username",
|
|
||||||
"signInButton": "Sign In",
|
"signInButton": "Sign In",
|
||||||
"signUpButton": "Sign Up",
|
"signUpButton": "Sign Up",
|
||||||
"noAccount": "Don't have an account?",
|
"noAccount": "Don't have an account?",
|
||||||
@@ -202,110 +103,31 @@
|
|||||||
"invalidEmail": "Please enter a valid email address",
|
"invalidEmail": "Please enter a valid email address",
|
||||||
"passwordTooShort": "Password must be at least 8 characters",
|
"passwordTooShort": "Password must be at least 8 characters",
|
||||||
"passwordsNotMatch": "Passwords do not match",
|
"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",
|
"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",
|
"emailRequired": "Please enter your email",
|
||||||
"identifierRequired": "Please enter your email or username",
|
|
||||||
"passwordRequired": "Please enter your password",
|
"passwordRequired": "Please enter your password",
|
||||||
"confirmPasswordRequired": "Please confirm your password",
|
"confirmPasswordRequired": "Please confirm your password",
|
||||||
"loading": "Loading...",
|
"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.",
|
|
||||||
"verifyYourEmail": "Verify Your Email",
|
|
||||||
"verificationEmailSent": "Verification email sent",
|
|
||||||
"verificationEmailSentHint": "We've sent a verification email to {email}. Please click the link in the email to verify your account.",
|
|
||||||
"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.",
|
|
||||||
"emailNotVerified": "Please verify your email address",
|
|
||||||
"emailNotVerifiedHint": "Your email has not been verified. Please check your inbox or request a new verification email.",
|
|
||||||
"resendVerification": "Resend Verification Email",
|
|
||||||
"resendSuccess": "Verification email sent! Please check your inbox.",
|
|
||||||
"resendFailed": "Failed to send verification email"
|
|
||||||
},
|
},
|
||||||
"memorize": {
|
"memorize": {
|
||||||
"deck_selector": {
|
"folder_selector": {
|
||||||
"selectDeck": "Select a deck",
|
"selectFolder": "Select a folder",
|
||||||
"noDecks": "No decks found",
|
"noFolders": "No folders found",
|
||||||
"goToDecks": "Go to Decks",
|
"folderInfo": "{id}. {name} ({count})"
|
||||||
"noCards": "No cards",
|
|
||||||
"new": "New",
|
|
||||||
"learning": "Learning",
|
|
||||||
"review": "Review",
|
|
||||||
"due": "Due"
|
|
||||||
},
|
},
|
||||||
"review": {
|
"memorize": {
|
||||||
"loading": "Loading cards...",
|
"answer": "Answer",
|
||||||
"backToDecks": "Back to Decks",
|
"next": "Next",
|
||||||
"allDone": "All Done!",
|
|
||||||
"allDoneDesc": "You've reviewed all due cards.",
|
|
||||||
"reviewedCount": "Reviewed {count} cards",
|
|
||||||
"progress": "{current} / {total}",
|
|
||||||
"nextReview": "Next review",
|
|
||||||
"interval": "Interval",
|
|
||||||
"ease": "Ease",
|
|
||||||
"lapses": "Lapses",
|
|
||||||
"showAnswer": "Show Answer",
|
|
||||||
"nextCard": "Next",
|
|
||||||
"again": "Again",
|
|
||||||
"hard": "Hard",
|
|
||||||
"good": "Good",
|
|
||||||
"easy": "Easy",
|
|
||||||
"now": "now",
|
|
||||||
"lessThanMinute": "<1 min",
|
|
||||||
"inMinutes": "{count} min",
|
|
||||||
"inHours": "{count}h",
|
|
||||||
"inDays": "{count}d",
|
|
||||||
"inMonths": "{count}mo",
|
|
||||||
"minutes": "<1 min",
|
|
||||||
"days": "{count}d",
|
|
||||||
"months": "{count}mo",
|
|
||||||
"minAbbr": "m",
|
|
||||||
"dayAbbr": "d",
|
|
||||||
"cardTypeNew": "New",
|
|
||||||
"cardTypeLearning": "Learning",
|
|
||||||
"cardTypeReview": "Review",
|
|
||||||
"cardTypeRelearning": "Relearning",
|
|
||||||
"reverse": "Reverse",
|
"reverse": "Reverse",
|
||||||
"dictation": "Dictation",
|
"dictation": "Dictation",
|
||||||
"clickToPlay": "Click to play audio",
|
"noTextPairs": "No text pairs available",
|
||||||
"restart": "Restart",
|
"disorder": "Disorder",
|
||||||
"yourAnswer": "Your answer",
|
"previous": "Previous"
|
||||||
"typeWhatYouHear": "Type what you hear...",
|
|
||||||
"correct": "Correct",
|
|
||||||
"incorrect": "Incorrect",
|
|
||||||
"orderLimited": "Order",
|
|
||||||
"orderInfinite": "Loop",
|
|
||||||
"randomLimited": "Random",
|
|
||||||
"randomInfinite": "Random Loop",
|
|
||||||
"noIpa": "No IPA available"
|
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "You are not authorized to access this deck"
|
"unauthorized": "You are not authorized to access this folder"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navbar": {
|
"navbar": {
|
||||||
@@ -313,66 +135,13 @@
|
|||||||
"sourceCode": "GitHub",
|
"sourceCode": "GitHub",
|
||||||
"sign_in": "Sign In",
|
"sign_in": "Sign In",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"folders": "Decks",
|
"folders": "Folders"
|
||||||
"explore": "Explore",
|
|
||||||
"favorites": "Favorites",
|
|
||||||
"settings": "Settings"
|
|
||||||
},
|
|
||||||
"ocr": {
|
|
||||||
"title": "OCR Vocabulary Extractor",
|
|
||||||
"description": "Upload vocabulary table screenshots from textbooks to extract word-definition pairs",
|
|
||||||
"uploadSection": "Upload Image",
|
|
||||||
"uploadImage": "Upload Image",
|
|
||||||
"dragDropHint": "Drag and drop an image here, or click to select",
|
|
||||||
"dropOrClick": "Drag and drop an image here, or click to select",
|
|
||||||
"changeImage": "Click to change image",
|
|
||||||
"supportedFormats": "Supports: JPG, PNG, WebP",
|
|
||||||
"invalidFileType": "Invalid file type. Please upload an image file (JPG, PNG, or WebP).",
|
|
||||||
"deckSelection": "Select Deck",
|
|
||||||
"selectDeck": "Select a deck",
|
|
||||||
"chooseDeck": "Choose a deck to save extracted pairs",
|
|
||||||
"noDecks": "No decks available. Please create a deck first.",
|
|
||||||
"languageHints": "Language Hints (Optional)",
|
|
||||||
"sourceLanguageHint": "Source language (e.g., English)",
|
|
||||||
"targetLanguageHint": "Target/Translation language (e.g., Chinese)",
|
|
||||||
"sourceLanguagePlaceholder": "Source language (e.g., English)",
|
|
||||||
"targetLanguagePlaceholder": "Target/Translation language (e.g., Chinese)",
|
|
||||||
"process": "Process Image",
|
|
||||||
"processButton": "Process Image",
|
|
||||||
"processing": "Processing...",
|
|
||||||
"preview": "Preview",
|
|
||||||
"resultsPreview": "Results Preview",
|
|
||||||
"extractedPairs": "Extracted {count} pairs",
|
|
||||||
"word": "Word",
|
|
||||||
"definition": "Definition",
|
|
||||||
"pairsCount": "{count} pairs extracted",
|
|
||||||
"savePairs": "Save to Deck",
|
|
||||||
"saveButton": "Save",
|
|
||||||
"saving": "Saving...",
|
|
||||||
"saved": "Successfully saved {count} pairs to {deck}",
|
|
||||||
"ocrSuccess": "Successfully extracted {count} pairs to {deck}",
|
|
||||||
"ocrFailed": "OCR processing failed. Please try again.",
|
|
||||||
"savedToDeck": "Saved to {deckName}",
|
|
||||||
"saveFailed": "Failed to save pairs",
|
|
||||||
"noImage": "Please upload an image first",
|
|
||||||
"noDeck": "Please select a deck",
|
|
||||||
"noResultsToSave": "No results to save",
|
|
||||||
"processingFailed": "OCR processing failed",
|
|
||||||
"tryAgain": "Please try again with a clearer image",
|
|
||||||
"detectedLanguages": "Detected: {source} → {target}",
|
|
||||||
"detectedSourceLanguage": "Detected source language",
|
|
||||||
"detectedTargetLanguage": "Detected target language"
|
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"myProfile": "My Profile",
|
"myProfile": "My Profile",
|
||||||
"email": "Email: {email}",
|
"email": "Email: {email}",
|
||||||
"logout": "Logout"
|
"logout": "Logout"
|
||||||
},
|
},
|
||||||
"settings": {
|
|
||||||
"title": "Settings",
|
|
||||||
"themeColor": "Theme Color",
|
|
||||||
"themeColorDescription": "Choose your preferred theme color"
|
|
||||||
},
|
|
||||||
"srt_player": {
|
"srt_player": {
|
||||||
"uploadVideo": "Upload Video",
|
"uploadVideo": "Upload Video",
|
||||||
"uploadSubtitle": "Upload Subtitle",
|
"uploadSubtitle": "Upload Subtitle",
|
||||||
@@ -382,6 +151,18 @@
|
|||||||
"next": "Next",
|
"next": "Next",
|
||||||
"restart": "Restart",
|
"restart": "Restart",
|
||||||
"autoPause": "Auto Pause ({enabled})",
|
"autoPause": "Auto Pause ({enabled})",
|
||||||
|
"playbackSpeed": "Playback Speed",
|
||||||
|
"subtitleSettings": "Subtitle Settings",
|
||||||
|
"fontSize": "Font Size",
|
||||||
|
"backgroundColor": "Background Color",
|
||||||
|
"textColor": "Text Color",
|
||||||
|
"fontFamily": "Font Family",
|
||||||
|
"opacity": "Opacity",
|
||||||
|
"position": "Position",
|
||||||
|
"top": "Top",
|
||||||
|
"center": "Center",
|
||||||
|
"bottom": "Bottom",
|
||||||
|
"keyboardShortcuts": "Keyboard Shortcuts",
|
||||||
"uploadVideoAndSubtitle": "Please upload video and subtitle files",
|
"uploadVideoAndSubtitle": "Please upload video and subtitle files",
|
||||||
"uploadVideoFile": "Please upload video file",
|
"uploadVideoFile": "Please upload video file",
|
||||||
"uploadSubtitleFile": "Please upload subtitle file",
|
"uploadSubtitleFile": "Please upload subtitle file",
|
||||||
@@ -392,71 +173,33 @@
|
|||||||
"uploaded": "Uploaded",
|
"uploaded": "Uploaded",
|
||||||
"notUploaded": "Not Uploaded",
|
"notUploaded": "Not Uploaded",
|
||||||
"upload": "Upload",
|
"upload": "Upload",
|
||||||
"uploadVideoButton": "Upload Video",
|
|
||||||
"uploadSubtitleButton": "Upload Subtitle",
|
|
||||||
"subtitleUploaded": "Subtitle Uploaded ({count} entries)",
|
|
||||||
"subtitleNotUploaded": "Subtitle Not Uploaded",
|
|
||||||
"autoPauseStatus": "Auto Pause: {enabled}",
|
"autoPauseStatus": "Auto Pause: {enabled}",
|
||||||
"on": "On",
|
"on": "On",
|
||||||
"off": "Off",
|
"off": "Off",
|
||||||
"videoUploadFailed": "Video upload failed",
|
"videoUploadFailed": "Video upload failed",
|
||||||
"subtitleUploadFailed": "Subtitle upload failed",
|
"subtitleUploadFailed": "Subtitle upload failed",
|
||||||
"subtitleLoadSuccess": "Subtitle loaded successfully",
|
"subtitleLoadSuccess": "Subtitle file loaded successfully",
|
||||||
"subtitleLoadFailed": "Subtitle load failed",
|
"subtitleLoadFailed": "Subtitle file loading failed",
|
||||||
"settings": "Settings",
|
"shortcuts": {
|
||||||
"shortcuts": "Shortcuts",
|
"playPause": "Play/Pause",
|
||||||
"keyboardShortcuts": "Keyboard Shortcuts",
|
"next": "Next",
|
||||||
"playPause": "Play/Pause",
|
"previous": "Previous",
|
||||||
"autoPauseToggle": "Toggle Auto Pause",
|
"restart": "Restart",
|
||||||
"subtitleSettings": "Subtitle Settings",
|
"autoPause": "Toggle Auto Pause"
|
||||||
"fontSize": "Font Size",
|
}
|
||||||
"textColor": "Text Color",
|
|
||||||
"backgroundColor": "Background Color",
|
|
||||||
"position": "Position",
|
|
||||||
"opacity": "Opacity",
|
|
||||||
"top": "Top",
|
|
||||||
"center": "Center",
|
|
||||||
"bottom": "Bottom"
|
|
||||||
},
|
},
|
||||||
"text_speaker": {
|
"text_speaker": {
|
||||||
"generateIPA": "Generate IPA",
|
"generateIPA": "Generate IPA",
|
||||||
"viewSavedItems": "View Saved Items",
|
"viewSavedItems": "View Saved Items",
|
||||||
"confirmDeleteAll": "Are you sure you want to delete everything? (Y/N)",
|
"confirmDeleteAll": "Are you sure you want to delete everything? (Y/N)"
|
||||||
"saved": "Saved",
|
|
||||||
"clearAll": "Clear All",
|
|
||||||
"language": "Language",
|
|
||||||
"customLanguage": "or type language...",
|
|
||||||
"languages": {
|
|
||||||
"auto": "Auto",
|
|
||||||
"chinese": "Chinese",
|
|
||||||
"english": "English",
|
|
||||||
"japanese": "Japanese",
|
|
||||||
"korean": "Korean",
|
|
||||||
"french": "French",
|
|
||||||
"german": "German",
|
|
||||||
"italian": "Italian",
|
|
||||||
"spanish": "Spanish",
|
|
||||||
"portuguese": "Portuguese",
|
|
||||||
"russian": "Russian"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"detectLanguage": "detect language",
|
"detectLanguage": "detect language",
|
||||||
"sourceLanguage": "source language",
|
|
||||||
"auto": "Auto",
|
|
||||||
"generateIPA": "generate ipa",
|
"generateIPA": "generate ipa",
|
||||||
"translateInto": "translate into",
|
"translateInto": "translate into",
|
||||||
"customLanguage": "or type language...",
|
|
||||||
"chinese": "Chinese",
|
"chinese": "Chinese",
|
||||||
"english": "English",
|
"english": "English",
|
||||||
"french": "French",
|
|
||||||
"german": "German",
|
|
||||||
"italian": "Italian",
|
"italian": "Italian",
|
||||||
"japanese": "Japanese",
|
|
||||||
"korean": "Korean",
|
|
||||||
"portuguese": "Portuguese",
|
|
||||||
"russian": "Russian",
|
|
||||||
"spanish": "Spanish",
|
|
||||||
"other": "Other",
|
"other": "Other",
|
||||||
"translating": "translating...",
|
"translating": "translating...",
|
||||||
"translate": "translate",
|
"translate": "translate",
|
||||||
@@ -472,159 +215,6 @@
|
|||||||
"success": "Text pair added to folder",
|
"success": "Text pair added to folder",
|
||||||
"error": "Failed to add text pair to folder"
|
"error": "Failed to add text pair to folder"
|
||||||
},
|
},
|
||||||
"autoSave": "Auto Save",
|
"autoSave": "Auto Save"
|
||||||
"pleaseLogin": "Please login to save cards",
|
|
||||||
"pleaseCreateDeck": "Please create a deck first",
|
|
||||||
"noTranslationToSave": "No translation to save",
|
|
||||||
"noDeckSelected": "No deck selected",
|
|
||||||
"saveAsCard": "Save as Card",
|
|
||||||
"selectDeck": "Select Deck",
|
|
||||||
"front": "Front",
|
|
||||||
"back": "Back",
|
|
||||||
"cancel": "Cancel",
|
|
||||||
"save": "Save",
|
|
||||||
"savedToDeck": "Card saved to {deckName}",
|
|
||||||
"saveFailed": "Failed to save card"
|
|
||||||
},
|
|
||||||
"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 decks",
|
|
||||||
"searchPlaceholder": "Search public decks...",
|
|
||||||
"loading": "Loading...",
|
|
||||||
"noDecks": "No public decks found",
|
|
||||||
"deckInfo": "{userName} • {cardCount} cards",
|
|
||||||
"unknownUser": "Unknown User",
|
|
||||||
"favorite": "Favorite",
|
|
||||||
"unfavorite": "Unfavorite",
|
|
||||||
"pleaseLogin": "Please login first",
|
|
||||||
"sortByFavorites": "Sort by favorites",
|
|
||||||
"sortByFavoritesActive": "Undo sort by favorites"
|
|
||||||
},
|
|
||||||
"exploreDetail": {
|
|
||||||
"title": "Deck Details",
|
|
||||||
"createdBy": "Created by: {name}",
|
|
||||||
"unknownUser": "Unknown User",
|
|
||||||
"totalCards": "Total Cards",
|
|
||||||
"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",
|
|
||||||
"joined": "Joined",
|
|
||||||
"logout": "Logout",
|
|
||||||
"deleteAccount": {
|
|
||||||
"button": "Delete Account",
|
|
||||||
"title": "Delete Account",
|
|
||||||
"warning": "This action is irreversible. All your data will be permanently deleted.",
|
|
||||||
"warningDecks": "All your decks and cards",
|
|
||||||
"warningCards": "All your learning progress",
|
|
||||||
"warningHistory": "All your translation and dictionary history",
|
|
||||||
"warningPermanent": "This action cannot be undone",
|
|
||||||
"confirmLabel": "Type your username to confirm:",
|
|
||||||
"usernameMismatch": "Username does not match",
|
|
||||||
"cancel": "Cancel",
|
|
||||||
"confirm": "Delete My Account",
|
|
||||||
"success": "Account deleted successfully",
|
|
||||||
"failed": "Failed to delete account"
|
|
||||||
},
|
|
||||||
"decks": {
|
|
||||||
"title": "Decks",
|
|
||||||
"noDecks": "No decks yet",
|
|
||||||
"deckName": "Deck Name",
|
|
||||||
"totalCards": "Total Cards",
|
|
||||||
"createdAt": "Created At",
|
|
||||||
"actions": "Actions",
|
|
||||||
"view": "View"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"decks": {
|
|
||||||
"title": "Decks",
|
|
||||||
"subtitle": "Manage your flashcard decks",
|
|
||||||
"newDeck": "New Deck",
|
|
||||||
"noDecksYet": "No decks yet",
|
|
||||||
"loading": "Loading...",
|
|
||||||
"deckInfo": "ID: {id} • {totalCards} cards",
|
|
||||||
"enterDeckName": "Enter deck name:",
|
|
||||||
"enterNewName": "Enter new name:",
|
|
||||||
"confirmDelete": "Type \"{name}\" to delete:",
|
|
||||||
"public": "Public",
|
|
||||||
"private": "Private",
|
|
||||||
"setPublic": "Set Public",
|
|
||||||
"setPrivate": "Set Private",
|
|
||||||
"importApkg": "Import APKG",
|
|
||||||
"exportApkg": "Export APKG",
|
|
||||||
"clickToUpload": "Click to upload an APKG file",
|
|
||||||
"apkgFilesOnly": "Only .apkg files are supported",
|
|
||||||
"parsing": "Parsing...",
|
|
||||||
"foundDecks": "Found {count} deck(s)",
|
|
||||||
"deckName": "Deck Name",
|
|
||||||
"back": "Back",
|
|
||||||
"import": "Import",
|
|
||||||
"importing": "Importing...",
|
|
||||||
"exportSuccess": "Deck exported successfully",
|
|
||||||
"goToDecks": "Go to Decks"
|
|
||||||
},
|
|
||||||
"follow": {
|
|
||||||
"follow": "Follow",
|
|
||||||
"following": "Following",
|
|
||||||
"followers": "Followers",
|
|
||||||
"followersOf": "{username}'s Followers",
|
|
||||||
"followingOf": "{username}'s Following",
|
|
||||||
"noFollowers": "No followers yet",
|
|
||||||
"noFollowing": "Not following anyone yet"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,613 +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"
|
|
||||||
},
|
|
||||||
"decks": {
|
|
||||||
"title": "Decks",
|
|
||||||
"noDecks": "Pas encore de decks",
|
|
||||||
"deckName": "Nom du deck",
|
|
||||||
"totalCards": "Total des cartes",
|
|
||||||
"createdAt": "Créé le",
|
|
||||||
"actions": "Actions",
|
|
||||||
"view": "Voir",
|
|
||||||
"subtitle": "Gérer vos decks d'apprentissage",
|
|
||||||
"newDeck": "Nouveau deck",
|
|
||||||
"noDecksYet": "Pas encore de decks",
|
|
||||||
"loading": "Chargement...",
|
|
||||||
"deckInfo": "ID: {id} · {totalCards} cartes",
|
|
||||||
"enterDeckName": "Nom du deck:",
|
|
||||||
"enterNewName": "Nouveau nom:",
|
|
||||||
"confirmDelete": "Tapez \"{name}\" pour supprimer:",
|
|
||||||
"public": "Public",
|
|
||||||
"private": "Privé",
|
|
||||||
"setPublic": "Rendre public",
|
|
||||||
"setPrivate": "Rendre privé",
|
|
||||||
"importApkg": "Importer APKG",
|
|
||||||
"exportApkg": "Exporter APKG",
|
|
||||||
"clickToUpload": "Cliquez pour télécharger",
|
|
||||||
"apkgFilesOnly": "Fichiers .apkg uniquement",
|
|
||||||
"parsing": "Analyse...",
|
|
||||||
"foundDecks": "{count} decks trouvés",
|
|
||||||
"back": "Retour",
|
|
||||||
"import": "Importer",
|
|
||||||
"importing": "Import...",
|
|
||||||
"exportSuccess": "Export réussi",
|
|
||||||
"goToDecks": "Aller aux decks"
|
|
||||||
},
|
|
||||||
"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."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"deck_id": {
|
|
||||||
"unauthorized": "Vous n'êtes pas le propriétaire de ce deck",
|
|
||||||
"back": "Retour",
|
|
||||||
"cards": "Cartes",
|
|
||||||
"itemsCount": "{count} éléments",
|
|
||||||
"memorize": "Mémoriser",
|
|
||||||
"loadingCards": "Chargement des cartes...",
|
|
||||||
"noCards": "Aucune carte dans ce deck",
|
|
||||||
"card": "Carte",
|
|
||||||
"addNewCard": "Ajouter une nouvelle carte",
|
|
||||||
"add": "Ajouter",
|
|
||||||
"adding": "Ajout en cours...",
|
|
||||||
"updateCard": "Mettre à jour la carte",
|
|
||||||
"update": "Mettre à jour",
|
|
||||||
"updating": "Mise à jour en cours...",
|
|
||||||
"word": "Mot",
|
|
||||||
"definition": "Définition",
|
|
||||||
"ipa": "IPA",
|
|
||||||
"example": "Exemple",
|
|
||||||
"wordAndDefinitionRequired": "Le mot et la définition sont requis",
|
|
||||||
"edit": "Modifier",
|
|
||||||
"delete": "Supprimer",
|
|
||||||
"permissionDenied": "Vous n'avez pas la permission d'effectuer cette action",
|
|
||||||
"resetProgress": "Réinitialiser progression",
|
|
||||||
"resetProgressTitle": "Réinitialiser la progression",
|
|
||||||
"resetProgressConfirm": "Réinitialiser la progression?",
|
|
||||||
"resetSuccess": "Progression réinitialisée",
|
|
||||||
"resetting": "Réinitialisation...",
|
|
||||||
"cancel": "Annuler",
|
|
||||||
"settings": "Paramètres",
|
|
||||||
"settingsTitle": "Paramètres du deck",
|
|
||||||
"newPerDay": "Nouvelles par jour",
|
|
||||||
"newPerDayHint": "Nouvelles cartes par jour",
|
|
||||||
"revPerDay": "Révisions par jour",
|
|
||||||
"revPerDayHint": "Révisions par jour",
|
|
||||||
"save": "Enregistrer",
|
|
||||||
"saving": "Enregistrement...",
|
|
||||||
"settingsSaved": "Paramètres enregistrés",
|
|
||||||
"todayNew": "Nouvelles aujourd'hui",
|
|
||||||
"todayReview": "Révisions aujourd'hui",
|
|
||||||
"todayLearning": "En apprentissage",
|
|
||||||
"error": {
|
|
||||||
"update": "Pas autorisé à modifier",
|
|
||||||
"delete": "Pas autorisé à supprimer",
|
|
||||||
"add": "Pas autorisé à ajouter"
|
|
||||||
},
|
|
||||||
"ipaPlaceholder": "Entrer IPA",
|
|
||||||
"examplePlaceholder": "Entrer exemple",
|
|
||||||
"wordRequired": "Veuillez entrer un mot",
|
|
||||||
"definitionRequired": "Veuillez entrer une définition",
|
|
||||||
"cardAdded": "Carte ajoutée",
|
|
||||||
"cardType": "Type de carte",
|
|
||||||
"wordCard": "Carte mot",
|
|
||||||
"phraseCard": "Carte phrase",
|
|
||||||
"sentenceCard": "Carte phrase",
|
|
||||||
"sentence": "Phrase",
|
|
||||||
"sentencePlaceholder": "Entrer phrase",
|
|
||||||
"wordPlaceholder": "Entrer mot",
|
|
||||||
"queryLang": "Langue de requête",
|
|
||||||
"enterLanguageName": "Veuillez entrer le nom de la langue",
|
|
||||||
"english": "Anglais",
|
|
||||||
"chinese": "Chinois",
|
|
||||||
"japanese": "Japonais",
|
|
||||||
"korean": "Coréen",
|
|
||||||
"meanings": "Significations",
|
|
||||||
"addMeaning": "Ajouter signification",
|
|
||||||
"partOfSpeech": "Partie du discours",
|
|
||||||
"deleteConfirm": "Supprimer cette carte?",
|
|
||||||
"cardDeleted": "Carte supprimée",
|
|
||||||
"cardUpdated": "Carte mise à jour"
|
|
||||||
},
|
|
||||||
"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.",
|
|
||||||
"verifyYourEmail": "Vérifier votre e-mail",
|
|
||||||
"verificationEmailSent": "E-mail de vérification envoyé",
|
|
||||||
"verificationEmailSentHint": "Nous avons envoyé un e-mail de vérification à {email}. Veuillez cliquer sur le lien dans l'e-mail pour vérifier votre compte.",
|
|
||||||
"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.",
|
|
||||||
"emailNotVerified": "Veuillez vérifier votre adresse e-mail",
|
|
||||||
"emailNotVerifiedHint": "Votre adresse e-mail n'a pas été vérifiée. Veuillez vérifier votre boîte de réception ou demander un nouvel e-mail de vérification.",
|
|
||||||
"resendVerification": "Renvoyer l'e-mail de vérification",
|
|
||||||
"resendSuccess": "E-mail de vérification envoyé ! Veuillez vérifier votre boîte de réception.",
|
|
||||||
"resendFailed": "Échec de l'envoi de l'e-mail de vérification"
|
|
||||||
},
|
|
||||||
"memorize": {
|
|
||||||
"deck_selector": {
|
|
||||||
"selectDeck": "Choisir deck",
|
|
||||||
"noDecks": "Pas de decks",
|
|
||||||
"goToDecks": "Aller aux decks",
|
|
||||||
"noCards": "Pas de cartes",
|
|
||||||
"new": "Nouveau",
|
|
||||||
"learning": "Apprentissage",
|
|
||||||
"review": "Révision",
|
|
||||||
"due": "À faire"
|
|
||||||
},
|
|
||||||
"review": {
|
|
||||||
"loading": "Chargement...",
|
|
||||||
"backToDecks": "Retour aux decks",
|
|
||||||
"allDone": "Tout terminé!",
|
|
||||||
"allDoneDesc": "Apprentissage terminé pour aujourd'hui!",
|
|
||||||
"reviewedCount": "{count} cartes révisées",
|
|
||||||
"progress": "{current} / {total}",
|
|
||||||
"nextReview": "Prochaine révision",
|
|
||||||
"interval": "Intervalle",
|
|
||||||
"ease": "Facilité",
|
|
||||||
"lapses": "Erreurs",
|
|
||||||
"showAnswer": "Montrer réponse",
|
|
||||||
"nextCard": "Suivant",
|
|
||||||
"again": "Encore",
|
|
||||||
"restart": "Recommencer",
|
|
||||||
"orderLimited": "Ordre limité",
|
|
||||||
"orderInfinite": "Ordre infini",
|
|
||||||
"randomLimited": "Aléatoire limité",
|
|
||||||
"randomInfinite": "Aléatoire infini",
|
|
||||||
"noIpa": "Pas d'IPA disponible"
|
|
||||||
},
|
|
||||||
"page": {
|
|
||||||
"unauthorized": "Non autorisé"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"navbar": {
|
|
||||||
"title": "learn-languages",
|
|
||||||
"sourceCode": "GitHub",
|
|
||||||
"sign_in": "Connexion",
|
|
||||||
"profile": "Profil",
|
|
||||||
"folders": "Decks",
|
|
||||||
"explore": "Explorer",
|
|
||||||
"favorites": "Favoris",
|
|
||||||
"settings": "Paramètres"
|
|
||||||
},
|
|
||||||
"ocr": {
|
|
||||||
"title": "Reconnaissance OCR",
|
|
||||||
"description": "Extraire le texte des images",
|
|
||||||
"uploadImage": "Télécharger image",
|
|
||||||
"dragDropHint": "Glisser-déposer",
|
|
||||||
"supportedFormats": "Formats: JPG, PNG, WEBP",
|
|
||||||
"selectDeck": "Choisir deck",
|
|
||||||
"chooseDeck": "Choisir un deck",
|
|
||||||
"noDecks": "Pas de decks disponibles",
|
|
||||||
"languageHints": "Indications de langue",
|
|
||||||
"sourceLanguageHint": "Langue source",
|
|
||||||
"targetLanguageHint": "Langue cible",
|
|
||||||
"process": "Traiter",
|
|
||||||
"processing": "Traitement...",
|
|
||||||
"preview": "Aperçu",
|
|
||||||
"extractedPairs": "Paires extraites",
|
|
||||||
"word": "Mot",
|
|
||||||
"definition": "Définition",
|
|
||||||
"pairsCount": "{count} paires",
|
|
||||||
"savePairs": "Enregistrer",
|
|
||||||
"saving": "Enregistrement...",
|
|
||||||
"saved": "Enregistré",
|
|
||||||
"saveFailed": "Échec de l'enregistrement",
|
|
||||||
"noImage": "Veuillez télécharger une image",
|
|
||||||
"noDeck": "Veuillez choisir un deck",
|
|
||||||
"processingFailed": "Traitement échoué",
|
|
||||||
"tryAgain": "Réessayer",
|
|
||||||
"detectedLanguages": "Langues détectées",
|
|
||||||
"uploadSection": "Télécharger image",
|
|
||||||
"dropOrClick": "Déposer ou cliquer",
|
|
||||||
"changeImage": "Changer image",
|
|
||||||
"invalidFileType": "Type de fichier invalide",
|
|
||||||
"deckSelection": "Choisir deck",
|
|
||||||
"sourceLanguagePlaceholder": "ex: Anglais",
|
|
||||||
"targetLanguagePlaceholder": "ex: Français",
|
|
||||||
"processButton": "Démarrer reconnaissance",
|
|
||||||
"resultsPreview": "Aperçu des résultats",
|
|
||||||
"saveButton": "Enregistrer dans le deck",
|
|
||||||
"ocrSuccess": "OCR réussi",
|
|
||||||
"ocrFailed": "OCR échoué",
|
|
||||||
"savedToDeck": "Enregistré dans le deck",
|
|
||||||
"noResultsToSave": "Pas de résultats",
|
|
||||||
"detectedSourceLanguage": "Langue source détectée",
|
|
||||||
"detectedTargetLanguage": "Langue cible détectée"
|
|
||||||
},
|
|
||||||
"profile": {
|
|
||||||
"myProfile": "Mon profil",
|
|
||||||
"email": "E-mail : {email}",
|
|
||||||
"logout": "Déconnexion"
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"title": "Paramètres",
|
|
||||||
"themeColor": "Couleur du thème",
|
|
||||||
"themeColorDescription": "Choisissez votre couleur de thème préférée"
|
|
||||||
},
|
|
||||||
"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",
|
|
||||||
"settings": "Paramètres",
|
|
||||||
"shortcuts": "Raccourcis",
|
|
||||||
"keyboardShortcuts": "Raccourcis clavier",
|
|
||||||
"playPause": "Lecture/Pause",
|
|
||||||
"autoPauseToggle": "Pause auto",
|
|
||||||
"subtitleSettings": "Paramètres sous-titres",
|
|
||||||
"fontSize": "Taille police",
|
|
||||||
"textColor": "Couleur texte",
|
|
||||||
"backgroundColor": "Couleur fond",
|
|
||||||
"position": "Position",
|
|
||||||
"opacity": "Opacité",
|
|
||||||
"top": "Haut",
|
|
||||||
"center": "Centre",
|
|
||||||
"bottom": "Bas"
|
|
||||||
},
|
|
||||||
"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)",
|
|
||||||
"saved": "Enregistré",
|
|
||||||
"clearAll": "Tout effacer",
|
|
||||||
"language": "Langue",
|
|
||||||
"customLanguage": "ou entrer une langue...",
|
|
||||||
"languages": {
|
|
||||||
"auto": "Auto",
|
|
||||||
"chinese": "Chinois",
|
|
||||||
"english": "Anglais",
|
|
||||||
"japanese": "Japonais",
|
|
||||||
"korean": "Coréen",
|
|
||||||
"french": "Français",
|
|
||||||
"german": "Allemand",
|
|
||||||
"italian": "Italien",
|
|
||||||
"spanish": "Espagnol",
|
|
||||||
"portuguese": "Portugais",
|
|
||||||
"russian": "Russe"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"translator": {
|
|
||||||
"detectLanguage": "détecter la langue",
|
|
||||||
"sourceLanguage": "langue source",
|
|
||||||
"auto": "Auto",
|
|
||||||
"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",
|
|
||||||
"customLanguage": "ou tapez la langue...",
|
|
||||||
"pleaseLogin": "Connectez-vous pour sauvegarder",
|
|
||||||
"pleaseCreateDeck": "Créez d'abord un deck",
|
|
||||||
"noTranslationToSave": "Pas de traduction à sauvegarder",
|
|
||||||
"noDeckSelected": "Aucun deck sélectionné",
|
|
||||||
"saveAsCard": "Sauvegarder comme carte",
|
|
||||||
"selectDeck": "Sélectionner deck",
|
|
||||||
"front": "Recto",
|
|
||||||
"back": "Verso",
|
|
||||||
"cancel": "Annuler",
|
|
||||||
"save": "Sauvegarder",
|
|
||||||
"savedToDeck": "Carte sauvegardée dans {deckName}",
|
|
||||||
"saveFailed": "Échec de la sauvegarde"
|
|
||||||
},
|
|
||||||
"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",
|
|
||||||
"noDecks": "Pas de decks publics",
|
|
||||||
"deckInfo": "{userName} · {totalCards} cartes"
|
|
||||||
},
|
|
||||||
"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",
|
|
||||||
"totalCards": "{count} cartes"
|
|
||||||
},
|
|
||||||
"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",
|
|
||||||
"deleteAccount": {
|
|
||||||
"button": "Supprimer le compte",
|
|
||||||
"title": "Supprimer le compte",
|
|
||||||
"warning": "Cette action est irréversible. Toutes vos données seront définitivement supprimées.",
|
|
||||||
"warningDecks": "Tous vos decks et cartes",
|
|
||||||
"warningCards": "Tout votre progression d'apprentissage",
|
|
||||||
"warningHistory": "Tout votre historique de traduction et de dictionnaire",
|
|
||||||
"warningPermanent": "Cette action ne peut pas être annulée",
|
|
||||||
"confirmLabel": "Tapez votre nom d'utilisateur pour confirmer :",
|
|
||||||
"usernameMismatch": "Le nom d'utilisateur ne correspond pas",
|
|
||||||
"cancel": "Annuler",
|
|
||||||
"confirm": "Supprimer mon compte",
|
|
||||||
"success": "Compte supprimé avec succès",
|
|
||||||
"failed": "Échec de la suppression du compte"
|
|
||||||
},
|
|
||||||
"decks": {
|
|
||||||
"title": "Decks",
|
|
||||||
"noDecks": "Pas encore de decks",
|
|
||||||
"deckName": "Nom du deck",
|
|
||||||
"totalCards": "Total des cartes",
|
|
||||||
"createdAt": "Créé le",
|
|
||||||
"actions": "Actions",
|
|
||||||
"view": "Voir"
|
|
||||||
},
|
|
||||||
"joined": "Inscrit le"
|
|
||||||
},
|
|
||||||
"follow": {
|
|
||||||
"follow": "Suivre",
|
|
||||||
"following": "Abonné",
|
|
||||||
"followers": "Abonnés",
|
|
||||||
"followersOf": "Abonnés de {username}",
|
|
||||||
"followingOf": "Abonnements de {username}",
|
|
||||||
"noFollowers": "Pas encore d'abonnés",
|
|
||||||
"noFollowing": "Ne suit personne"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,638 +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"
|
|
||||||
},
|
|
||||||
"decks": {
|
|
||||||
"title": "Mazzi",
|
|
||||||
"noDecks": "Nessun mazzo ancora",
|
|
||||||
"deckName": "Nome del mazzo",
|
|
||||||
"totalCards": "Totale carte",
|
|
||||||
"createdAt": "Creato il",
|
|
||||||
"actions": "Azioni",
|
|
||||||
"view": "Visualizza",
|
|
||||||
"subtitle": "Gestisci i tuoi deck",
|
|
||||||
"newDeck": "Nuovo deck",
|
|
||||||
"noDecksYet": "Nessun deck ancora",
|
|
||||||
"loading": "Caricamento...",
|
|
||||||
"deckInfo": "ID: {id} · {totalCards} carte",
|
|
||||||
"enterDeckName": "Nome deck:",
|
|
||||||
"enterNewName": "Nuovo nome:",
|
|
||||||
"confirmDelete": "Digita \"{name}\" per eliminare:",
|
|
||||||
"public": "Pubblico",
|
|
||||||
"private": "Privato",
|
|
||||||
"setPublic": "Rendi pubblico",
|
|
||||||
"setPrivate": "Rendi privato",
|
|
||||||
"importApkg": "Importa APKG",
|
|
||||||
"exportApkg": "Esporta APKG",
|
|
||||||
"clickToUpload": "Clicca per caricare",
|
|
||||||
"apkgFilesOnly": "Solo file .apkg",
|
|
||||||
"parsing": "Analisi...",
|
|
||||||
"foundDecks": "{count} deck trovati",
|
|
||||||
"back": "Indietro",
|
|
||||||
"import": "Importa",
|
|
||||||
"importing": "Importazione...",
|
|
||||||
"exportSuccess": "Esportazione riuscita",
|
|
||||||
"goToDecks": "Vai ai deck"
|
|
||||||
},
|
|
||||||
"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."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"deck_id": {
|
|
||||||
"unauthorized": "Non sei il proprietario di questo deck",
|
|
||||||
"back": "Indietro",
|
|
||||||
"cards": "Schede",
|
|
||||||
"itemsCount": "{count} elementi",
|
|
||||||
"memorize": "Memorizza",
|
|
||||||
"loadingCards": "Caricamento schede...",
|
|
||||||
"noCards": "Nessuna scheda in questo deck",
|
|
||||||
"card": "Scheda",
|
|
||||||
"addNewCard": "Aggiungi nuova scheda",
|
|
||||||
"add": "Aggiungi",
|
|
||||||
"adding": "Aggiunta in corso...",
|
|
||||||
"updateCard": "Aggiorna scheda",
|
|
||||||
"update": "Aggiorna",
|
|
||||||
"updating": "Aggiornamento in corso...",
|
|
||||||
"word": "Parola",
|
|
||||||
"definition": "Definizione",
|
|
||||||
"ipa": "IPA",
|
|
||||||
"example": "Esempio",
|
|
||||||
"wordAndDefinitionRequired": "Parola e definizione sono obbligatori",
|
|
||||||
"edit": "Modifica",
|
|
||||||
"delete": "Elimina",
|
|
||||||
"permissionDenied": "Non hai il permesso per questa accion",
|
|
||||||
"resetProgress": "Reimposta progresso",
|
|
||||||
"resetProgressTitle": "Reimposta progresso di apprendimento",
|
|
||||||
"resetProgressConfirm": "Reimpostare il progresso?",
|
|
||||||
"resetSuccess": "Progresso reimpostato",
|
|
||||||
"resetting": "Reimpostazione...",
|
|
||||||
"cancel": "Annulla",
|
|
||||||
"settings": "Impostazioni",
|
|
||||||
"settingsTitle": "Impostazioni deck",
|
|
||||||
"newPerDay": "Nuove al giorno",
|
|
||||||
"newPerDayHint": "Nuove carte al giorno",
|
|
||||||
"revPerDay": "Ripassate al giorno",
|
|
||||||
"revPerDayHint": "Ripassi al giorno",
|
|
||||||
"save": "Salva",
|
|
||||||
"saving": "Salvataggio...",
|
|
||||||
"settingsSaved": "Impostazioni salvate",
|
|
||||||
"todayNew": "Oggi nuove",
|
|
||||||
"todayReview": "Oggi ripasso",
|
|
||||||
"todayLearning": "In apprendimento",
|
|
||||||
"error": {
|
|
||||||
"update": "Nessun permesso di aggiornare",
|
|
||||||
"delete": "Nessun permesso di eliminare",
|
|
||||||
"add": "Nessun permesso di aggiungere"
|
|
||||||
},
|
|
||||||
"ipaPlaceholder": "Inserisci IPA",
|
|
||||||
"examplePlaceholder": "Inserisci esempio",
|
|
||||||
"wordRequired": "Inserisci una parola",
|
|
||||||
"definitionRequired": "Inserisci una definizione",
|
|
||||||
"cardAdded": "Carta aggiunta",
|
|
||||||
"cardType": "Tipo di carta",
|
|
||||||
"wordCard": "Carta parola",
|
|
||||||
"phraseCard": "Carta frase",
|
|
||||||
"sentenceCard": "Carta frase",
|
|
||||||
"sentence": "Frase",
|
|
||||||
"sentencePlaceholder": "Inserisci frase",
|
|
||||||
"wordPlaceholder": "Inserisci parola",
|
|
||||||
"queryLang": "Lingua di query",
|
|
||||||
"enterLanguageName": "Inserisci il nome della lingua",
|
|
||||||
"english": "Inglese",
|
|
||||||
"chinese": "Cinese",
|
|
||||||
"japanese": "Giapponese",
|
|
||||||
"korean": "Coreano",
|
|
||||||
"meanings": "Significati",
|
|
||||||
"addMeaning": "Aggiungi significato",
|
|
||||||
"partOfSpeech": "Parte del discorso",
|
|
||||||
"deleteConfirm": "Eliminare questa carta?",
|
|
||||||
"cardDeleted": "Carta eliminata",
|
|
||||||
"cardUpdated": "Carta aggiornata"
|
|
||||||
},
|
|
||||||
"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.",
|
|
||||||
"verifyYourEmail": "Verifica la tua Email",
|
|
||||||
"verificationEmailSent": "Email di verifica inviata",
|
|
||||||
"verificationEmailSentHint": "Abbiamo inviato un'email di verifica a {email}. Clicca sul link nell'email per verificare il tuo account.",
|
|
||||||
"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.",
|
|
||||||
"emailNotVerified": "Verifica il tuo indirizzo email",
|
|
||||||
"emailNotVerifiedHint": "Il tuo indirizzo email non è stato verificato. Controlla la tua casella di posta o richiedi una nuova email di verifica.",
|
|
||||||
"resendVerification": "Invia di nuovo email di verifica",
|
|
||||||
"resendSuccess": "Email di verifica inviata! Controlla la tua casella di posta.",
|
|
||||||
"resendFailed": "Impossibile inviare l'email di verifica"
|
|
||||||
},
|
|
||||||
"memorize": {
|
|
||||||
"deck_selector": {
|
|
||||||
"selectDeck": "Seleziona deck",
|
|
||||||
"noDecks": "Nessun deck",
|
|
||||||
"goToDecks": "Vai ai deck",
|
|
||||||
"noCards": "Nessuna carta",
|
|
||||||
"new": "Nuovo",
|
|
||||||
"learning": "Apprendimento",
|
|
||||||
"review": "Ripasso",
|
|
||||||
"due": "In scadenza"
|
|
||||||
},
|
|
||||||
"review": {
|
|
||||||
"loading": "Caricamento...",
|
|
||||||
"backToDecks": "Torna ai deck",
|
|
||||||
"allDone": "Tutto fatto!",
|
|
||||||
"allDoneDesc": "Apprendimento di oggi completato!",
|
|
||||||
"reviewedCount": "{count} carte ripassate",
|
|
||||||
"progress": "{current} / {total}",
|
|
||||||
"nextReview": "Prossimo ripasso",
|
|
||||||
"interval": "Intervallo",
|
|
||||||
"ease": "Difficoltà",
|
|
||||||
"lapses": "Errori",
|
|
||||||
"showAnswer": "Mostra risposta",
|
|
||||||
"nextCard": "Prossima",
|
|
||||||
"again": "Ancora",
|
|
||||||
"restart": "Ricomincia",
|
|
||||||
"hard": "Difficile",
|
|
||||||
"good": "Buono",
|
|
||||||
"easy": "Facile",
|
|
||||||
"now": "Ora",
|
|
||||||
"lessThanMinute": "meno di 1 minuto",
|
|
||||||
"inMinutes": "tra {n} minuti",
|
|
||||||
"inHours": "tra {n} ore",
|
|
||||||
"inDays": "tra {n} giorni",
|
|
||||||
"inMonths": "tra {n} mesi",
|
|
||||||
"minutes": "minuti",
|
|
||||||
"days": "giorni",
|
|
||||||
"months": "mesi",
|
|
||||||
"minAbbr": "min",
|
|
||||||
"dayAbbr": "g",
|
|
||||||
"cardTypeNew": "Nuovo",
|
|
||||||
"cardTypeLearning": "Apprendimento",
|
|
||||||
"cardTypeReview": "Ripasso",
|
|
||||||
"cardTypeRelearning": "Riapprendimento",
|
|
||||||
"reverse": "Inverti",
|
|
||||||
"dictation": "Dettato",
|
|
||||||
"clickToPlay": "Clicca per riprodurre",
|
|
||||||
"yourAnswer": "La tua risposta",
|
|
||||||
"typeWhatYouHear": "Scrivi cosa senti",
|
|
||||||
"correct": "Corretto!",
|
|
||||||
"incorrect": "Errato",
|
|
||||||
"orderLimited": "Ordine limitato",
|
|
||||||
"orderInfinite": "Ordine infinito",
|
|
||||||
"randomLimited": "Casuale limitato",
|
|
||||||
"randomInfinite": "Casuale infinito",
|
|
||||||
"noIpa": "Nessun IPA disponibile"
|
|
||||||
},
|
|
||||||
"page": {
|
|
||||||
"unauthorized": "Non autorizzato"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"navbar": {
|
|
||||||
"title": "learn-languages",
|
|
||||||
"sourceCode": "GitHub",
|
|
||||||
"sign_in": "Accedi",
|
|
||||||
"profile": "Profilo",
|
|
||||||
"folders": "Mazzi",
|
|
||||||
"explore": "Esplora",
|
|
||||||
"favorites": "Preferiti",
|
|
||||||
"settings": "Impostazioni"
|
|
||||||
},
|
|
||||||
"ocr": {
|
|
||||||
"title": "Riconoscimento OCR",
|
|
||||||
"description": "Estrai testo dalle immagini",
|
|
||||||
"uploadImage": "Carica immagine",
|
|
||||||
"dragDropHint": "Trascina e rilascia",
|
|
||||||
"supportedFormats": "Supportati: JPG, PNG, WEBP",
|
|
||||||
"selectDeck": "Seleziona deck",
|
|
||||||
"chooseDeck": "Scegli un deck",
|
|
||||||
"noDecks": "Nessun deck disponibile",
|
|
||||||
"languageHints": "Suggerimenti lingua",
|
|
||||||
"sourceLanguageHint": "Lingua sorgente",
|
|
||||||
"targetLanguageHint": "Lingua target",
|
|
||||||
"process": "Elabora",
|
|
||||||
"processing": "Elaborazione...",
|
|
||||||
"preview": "Anteprima",
|
|
||||||
"extractedPairs": "Coppie estratte",
|
|
||||||
"word": "Parola",
|
|
||||||
"definition": "Definizione",
|
|
||||||
"pairsCount": "{count} coppie",
|
|
||||||
"savePairs": "Salva",
|
|
||||||
"saving": "Salvataggio...",
|
|
||||||
"saved": "Salvato",
|
|
||||||
"saveFailed": "Salvataggio fallito",
|
|
||||||
"noImage": "Carica un'immagine",
|
|
||||||
"noDeck": "Seleziona un deck",
|
|
||||||
"processingFailed": "Elaborazione fallita",
|
|
||||||
"tryAgain": "Riprova",
|
|
||||||
"detectedLanguages": "Lingue rilevate",
|
|
||||||
"uploadSection": "Carica immagine",
|
|
||||||
"dropOrClick": "Rilascia o clicca",
|
|
||||||
"changeImage": "Cambia immagine",
|
|
||||||
"invalidFileType": "Tipo di file non valido",
|
|
||||||
"deckSelection": "Seleziona deck",
|
|
||||||
"sourceLanguagePlaceholder": "es: Inglese",
|
|
||||||
"targetLanguagePlaceholder": "es: Italiano",
|
|
||||||
"processButton": "Avvia riconoscimento",
|
|
||||||
"resultsPreview": "Anteprima risultati",
|
|
||||||
"saveButton": "Salva nel deck",
|
|
||||||
"ocrSuccess": "OCR riuscito",
|
|
||||||
"ocrFailed": "OCR fallito",
|
|
||||||
"savedToDeck": "Salvato nel deck",
|
|
||||||
"noResultsToSave": "Nessun risultato",
|
|
||||||
"detectedSourceLanguage": "Lingua sorgente rilevata",
|
|
||||||
"detectedTargetLanguage": "Lingua target rilevata"
|
|
||||||
},
|
|
||||||
"profile": {
|
|
||||||
"myProfile": "Il Mio Profilo",
|
|
||||||
"email": "Email: {email}",
|
|
||||||
"logout": "Esci"
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"title": "Impostazioni",
|
|
||||||
"themeColor": "Colore del tema",
|
|
||||||
"themeColorDescription": "Scegli il tuo colore del tema preferito"
|
|
||||||
},
|
|
||||||
"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",
|
|
||||||
"settings": "Impostazioni",
|
|
||||||
"shortcuts": "Scorciatoie",
|
|
||||||
"keyboardShortcuts": "Scorciatoie tastiera",
|
|
||||||
"playPause": "Riproduci/Pausa",
|
|
||||||
"autoPauseToggle": "Auto-pausa",
|
|
||||||
"subtitleSettings": "Impostazioni sottotitoli",
|
|
||||||
"fontSize": "Dimensione carattere",
|
|
||||||
"textColor": "Colore testo",
|
|
||||||
"backgroundColor": "Colore sfondo",
|
|
||||||
"position": "Posizione",
|
|
||||||
"opacity": "Opacità",
|
|
||||||
"top": "Alto",
|
|
||||||
"center": "Centro",
|
|
||||||
"bottom": "Basso"
|
|
||||||
},
|
|
||||||
"text_speaker": {
|
|
||||||
"generateIPA": "Genera IPA",
|
|
||||||
"viewSavedItems": "Visualizza Elementi Salvati",
|
|
||||||
"confirmDeleteAll": "Sei sicuro di voler eliminare tutto? (S/N)",
|
|
||||||
"saved": "Salvato",
|
|
||||||
"clearAll": "Cancella tutto",
|
|
||||||
"language": "Lingua",
|
|
||||||
"customLanguage": "o inserisci lingua...",
|
|
||||||
"languages": {
|
|
||||||
"auto": "Auto",
|
|
||||||
"chinese": "Cinese",
|
|
||||||
"english": "Inglese",
|
|
||||||
"japanese": "Giapponese",
|
|
||||||
"korean": "Coreano",
|
|
||||||
"french": "Francese",
|
|
||||||
"german": "Tedesco",
|
|
||||||
"italian": "Italiano",
|
|
||||||
"spanish": "Spagnolo",
|
|
||||||
"portuguese": "Portoghese",
|
|
||||||
"russian": "Russo"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"translator": {
|
|
||||||
"detectLanguage": "rileva lingua",
|
|
||||||
"sourceLanguage": "lingua di origine",
|
|
||||||
"auto": "Auto",
|
|
||||||
"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",
|
|
||||||
"customLanguage": "o digita lingua...",
|
|
||||||
"pleaseLogin": "Accedi per salvare le carte",
|
|
||||||
"pleaseCreateDeck": "Crea prima un deck",
|
|
||||||
"noTranslationToSave": "Nessuna traduzione da salvare",
|
|
||||||
"noDeckSelected": "Nessun deck selezionato",
|
|
||||||
"saveAsCard": "Salva come carta",
|
|
||||||
"selectDeck": "Seleziona deck",
|
|
||||||
"front": "Fronte",
|
|
||||||
"back": "Retro",
|
|
||||||
"cancel": "Annulla",
|
|
||||||
"save": "Salva",
|
|
||||||
"savedToDeck": "Carta salvata in {deckName}",
|
|
||||||
"saveFailed": "Salvataggio fallito"
|
|
||||||
},
|
|
||||||
"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",
|
|
||||||
"noDecks": "Nessun deck pubblico",
|
|
||||||
"deckInfo": "{userName} · {totalCards} carte"
|
|
||||||
},
|
|
||||||
"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",
|
|
||||||
"totalCards": "{count} carte"
|
|
||||||
},
|
|
||||||
"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",
|
|
||||||
"deleteAccount": {
|
|
||||||
"button": "Elimina Account",
|
|
||||||
"title": "Elimina Account",
|
|
||||||
"warning": "Questa azione è irreversibile. Tutti i tuoi dati saranno eliminati definitivamente.",
|
|
||||||
"warningDecks": "Tutti i tuoi mazzi e le tue carte",
|
|
||||||
"warningCards": "Tutto il tuo progresso di apprendimento",
|
|
||||||
"warningHistory": "Tutto il tuo cronologia di traduzione e dizionario",
|
|
||||||
"warningPermanent": "Questa azione non può essere annullata",
|
|
||||||
"confirmLabel": "Digita il tuo nome utente per confermare:",
|
|
||||||
"usernameMismatch": "Il nome utente non corrisponde",
|
|
||||||
"cancel": "Annulla",
|
|
||||||
"confirm": "Elimina il mio account",
|
|
||||||
"success": "Account eliminato con successo",
|
|
||||||
"failed": "Impossibile eliminare l'account"
|
|
||||||
},
|
|
||||||
"decks": {
|
|
||||||
"title": "Mazzi",
|
|
||||||
"noDecks": "Nessun mazzo ancora",
|
|
||||||
"deckName": "Nome del mazzo",
|
|
||||||
"totalCards": "Totale carte",
|
|
||||||
"createdAt": "Creata Il",
|
|
||||||
"actions": "Azioni",
|
|
||||||
"view": "Visualizza"
|
|
||||||
},
|
|
||||||
"joined": "Iscritto il"
|
|
||||||
},
|
|
||||||
"follow": {
|
|
||||||
"follow": "Segui",
|
|
||||||
"following": "Stai seguendo",
|
|
||||||
"followers": "Seguaci",
|
|
||||||
"followersOf": "Seguaci di {username}",
|
|
||||||
"followingOf": "Seguiti da {username}",
|
|
||||||
"noFollowers": "Nessun seguace ancora",
|
|
||||||
"noFollowing": "Non segui ancora nessuno"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,633 +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": "このフォルダーを削除する権限がありません。"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"deck_id": {
|
|
||||||
"unauthorized": "このデッキの所有者ではありません",
|
|
||||||
"back": "戻る",
|
|
||||||
"cards": "カード",
|
|
||||||
"itemsCount": "{count}件",
|
|
||||||
"memorize": "暗記",
|
|
||||||
"loadingCards": "カードを読み込み中...",
|
|
||||||
"noCards": "このデッキにはカードがありません",
|
|
||||||
"card": "カード",
|
|
||||||
"addNewCard": "新しいカードを追加",
|
|
||||||
"add": "追加",
|
|
||||||
"adding": "追加中...",
|
|
||||||
"updateCard": "カードを更新",
|
|
||||||
"update": "更新",
|
|
||||||
"updating": "更新中...",
|
|
||||||
"word": "単語",
|
|
||||||
"definition": "定義",
|
|
||||||
"ipa": "発音記号",
|
|
||||||
"example": "例文",
|
|
||||||
"wordAndDefinitionRequired": "単語と定義は必須です",
|
|
||||||
"edit": "編集",
|
|
||||||
"delete": "削除",
|
|
||||||
"permissionDenied": "この操作を実行する権限がありません",
|
|
||||||
"resetProgress": "進捗をリセット",
|
|
||||||
"resetProgressTitle": "学習進捗をリセット",
|
|
||||||
"resetProgressConfirm": "このデッキの学習進捗をリセットしますか?",
|
|
||||||
"resetSuccess": "リセットしました",
|
|
||||||
"resetting": "リセット中...",
|
|
||||||
"cancel": "キャンセル",
|
|
||||||
"settings": "設定",
|
|
||||||
"settingsTitle": "デッキ設定",
|
|
||||||
"newPerDay": "1日の新規カード",
|
|
||||||
"newPerDayHint": "毎日の新規カード数",
|
|
||||||
"revPerDay": "1日の復習",
|
|
||||||
"revPerDayHint": "毎日の復習数",
|
|
||||||
"save": "保存",
|
|
||||||
"saving": "保存中...",
|
|
||||||
"settingsSaved": "設定を保存しました",
|
|
||||||
"todayNew": "今日の新規",
|
|
||||||
"todayReview": "今日の復習",
|
|
||||||
"todayLearning": "学習中",
|
|
||||||
"error": {
|
|
||||||
"update": "更新する権限がありません",
|
|
||||||
"delete": "削除する権限がありません",
|
|
||||||
"add": "追加する権限がありません"
|
|
||||||
},
|
|
||||||
"ipaPlaceholder": "IPAを入力",
|
|
||||||
"examplePlaceholder": "例文を入力",
|
|
||||||
"wordRequired": "単語を入力してください",
|
|
||||||
"definitionRequired": "定義を入力してください",
|
|
||||||
"cardAdded": "カードを追加しました",
|
|
||||||
"cardType": "カードタイプ",
|
|
||||||
"wordCard": "単語カード",
|
|
||||||
"phraseCard": "フレーズカード",
|
|
||||||
"sentenceCard": "文章カード",
|
|
||||||
"sentence": "文章",
|
|
||||||
"sentencePlaceholder": "文章を入力",
|
|
||||||
"wordPlaceholder": "単語を入力",
|
|
||||||
"queryLang": "検索言語",
|
|
||||||
"enterLanguageName": "言語名を入力してください",
|
|
||||||
"english": "英語",
|
|
||||||
"chinese": "中国語",
|
|
||||||
"japanese": "日本語",
|
|
||||||
"korean": "韓国語",
|
|
||||||
"meanings": "意味",
|
|
||||||
"addMeaning": "意味を追加",
|
|
||||||
"partOfSpeech": "品詞",
|
|
||||||
"deleteConfirm": "このカードを削除しますか?",
|
|
||||||
"cardDeleted": "カードを削除しました",
|
|
||||||
"cardUpdated": "カードを更新しました"
|
|
||||||
},
|
|
||||||
"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": "パスワードリセット用のリンクをメールでお送りしました。受信トレイをご確認ください。",
|
|
||||||
"verifyYourEmail": "メールアドレスを確認",
|
|
||||||
"verificationEmailSent": "確認メールを送信しました",
|
|
||||||
"verificationEmailSentHint": "{email} に確認メールを送信しました。メール内のリンクをクリックしてアカウントを確認してください。",
|
|
||||||
"checkYourEmail": "メールをご確認ください",
|
|
||||||
"backToLogin": "ログインに戻る",
|
|
||||||
"resetPassword": "パスワードをリセット",
|
|
||||||
"newPassword": "新しいパスワード",
|
|
||||||
"invalidToken": "無効または期限切れのリンク",
|
|
||||||
"invalidTokenHint": "このパスワードリセットリンクは無効または期限切れです。新しいものをリクエストしてください。",
|
|
||||||
"requestNewToken": "新しいリセットリンクをリクエスト",
|
|
||||||
"resetPasswordSuccess": "パスワードのリセットに成功しました",
|
|
||||||
"resetPasswordSuccessTitle": "パスワードリセット完了",
|
|
||||||
"resetPasswordSuccessHint": "パスワードが正常にリセットされました。新しいパスワードでログインできます。",
|
|
||||||
"emailNotVerified": "メールアドレスを確認してください",
|
|
||||||
"emailNotVerifiedHint": "メールアドレスが確認されていません。受信トレイをご確認いただくか、新しい確認メールをリクエストしてください。",
|
|
||||||
"resendVerification": "確認メールを再送信",
|
|
||||||
"resendSuccess": "確認メールを送信しました!受信トレイをご確認ください。",
|
|
||||||
"resendFailed": "確認メールの送信に失敗しました"
|
|
||||||
},
|
|
||||||
"memorize": {
|
|
||||||
"deck_selector": {
|
|
||||||
"selectDeck": "デッキを選択",
|
|
||||||
"noDecks": "デッキが見つかりません",
|
|
||||||
"goToDecks": "デッキへ移動",
|
|
||||||
"noCards": "カードなし",
|
|
||||||
"new": "新規",
|
|
||||||
"learning": "学習中",
|
|
||||||
"review": "復習",
|
|
||||||
"due": "予定"
|
|
||||||
},
|
|
||||||
"review": {
|
|
||||||
"loading": "読み込み中...",
|
|
||||||
"backToDecks": "デッキに戻る",
|
|
||||||
"allDone": "完了!",
|
|
||||||
"allDoneDesc": "すべての復習カードが完了しました。",
|
|
||||||
"reviewedCount": "{count} 枚のカードを復習",
|
|
||||||
"progress": "{current} / {total}",
|
|
||||||
"nextReview": "次の復習",
|
|
||||||
"interval": "間隔",
|
|
||||||
"ease": "易しさ",
|
|
||||||
"lapses": "忘回数",
|
|
||||||
"showAnswer": "答えを表示",
|
|
||||||
"nextCard": "次へ",
|
|
||||||
"again": "もう一度",
|
|
||||||
"hard": "難しい",
|
|
||||||
"good": "普通",
|
|
||||||
"easy": "簡単",
|
|
||||||
"now": "今",
|
|
||||||
"lessThanMinute": "<1分",
|
|
||||||
"inMinutes": "{count}分",
|
|
||||||
"inHours": "{count}時間",
|
|
||||||
"inDays": "{count}日",
|
|
||||||
"inMonths": "{count}ヶ月",
|
|
||||||
"minutes": "<1分",
|
|
||||||
"days": "{count}日",
|
|
||||||
"months": "{count}ヶ月",
|
|
||||||
"minAbbr": "分",
|
|
||||||
"dayAbbr": "日",
|
|
||||||
"cardTypeNew": "新規",
|
|
||||||
"cardTypeLearning": "学習中",
|
|
||||||
"cardTypeReview": "復習",
|
|
||||||
"cardTypeRelearning": "再学習",
|
|
||||||
"reverse": "反転",
|
|
||||||
"dictation": "聴き取り",
|
|
||||||
"clickToPlay": "クリックして再生",
|
|
||||||
"yourAnswer": "あなたの答え",
|
|
||||||
"typeWhatYouHear": "聞こえた内容を入力",
|
|
||||||
"correct": "正解",
|
|
||||||
"incorrect": "不正解",
|
|
||||||
"restart": "最初から",
|
|
||||||
"orderLimited": "順序制限",
|
|
||||||
"orderInfinite": "順序無限",
|
|
||||||
"randomLimited": "ランダム制限",
|
|
||||||
"randomInfinite": "ランダム無限",
|
|
||||||
"noIpa": "IPAなし"
|
|
||||||
},
|
|
||||||
"page": {
|
|
||||||
"unauthorized": "このデッキにアクセスする権限がありません"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"navbar": {
|
|
||||||
"title": "learn-languages",
|
|
||||||
"sourceCode": "GitHub",
|
|
||||||
"sign_in": "サインイン",
|
|
||||||
"profile": "プロフィール",
|
|
||||||
"folders": "デッキ",
|
|
||||||
"explore": "探索",
|
|
||||||
"favorites": "お気に入り",
|
|
||||||
"settings": "設定"
|
|
||||||
},
|
|
||||||
"ocr": {
|
|
||||||
"title": "OCR認識",
|
|
||||||
"description": "画像からテキストを抽出",
|
|
||||||
"uploadImage": "画像をアップロード",
|
|
||||||
"dragDropHint": "ドラッグ&ドロップ",
|
|
||||||
"supportedFormats": "対応形式:JPG, PNG, WEBP",
|
|
||||||
"selectDeck": "デッキを選択",
|
|
||||||
"chooseDeck": "デッキを選択",
|
|
||||||
"noDecks": "デッキがありません",
|
|
||||||
"languageHints": "言語ヒント",
|
|
||||||
"sourceLanguageHint": "ソース言語ヒント",
|
|
||||||
"targetLanguageHint": "ターゲット言語ヒント",
|
|
||||||
"process": "処理",
|
|
||||||
"processing": "処理中...",
|
|
||||||
"preview": "プレビュー",
|
|
||||||
"extractedPairs": "抽出ペア",
|
|
||||||
"word": "単語",
|
|
||||||
"definition": "定義",
|
|
||||||
"pairsCount": "{count}ペア",
|
|
||||||
"savePairs": "保存",
|
|
||||||
"saving": "保存中...",
|
|
||||||
"saved": "保存済み",
|
|
||||||
"saveFailed": "保存失敗",
|
|
||||||
"noImage": "画像をアップロードしてください",
|
|
||||||
"noDeck": "デッキを選択してください",
|
|
||||||
"processingFailed": "処理失敗",
|
|
||||||
"tryAgain": "再試行",
|
|
||||||
"detectedLanguages": "検出言語",
|
|
||||||
"invalidFileType": "無効なファイル形式",
|
|
||||||
"ocrFailed": "OCR失敗",
|
|
||||||
"uploadSection": "画像をアップロード",
|
|
||||||
"dropOrClick": "ドロップまたはクリック",
|
|
||||||
"changeImage": "画像を変更",
|
|
||||||
"deckSelection": "デッキを選択",
|
|
||||||
"sourceLanguagePlaceholder": "例:英語",
|
|
||||||
"targetLanguagePlaceholder": "例:日本語",
|
|
||||||
"processButton": "認識開始",
|
|
||||||
"resultsPreview": "結果プレビュー",
|
|
||||||
"saveButton": "デッキに保存",
|
|
||||||
"ocrSuccess": "OCR成功",
|
|
||||||
"savedToDeck": "デッキに保存しました",
|
|
||||||
"noResultsToSave": "結果がありません",
|
|
||||||
"detectedSourceLanguage": "検出ソース言語",
|
|
||||||
"detectedTargetLanguage": "検出ターゲット言語"
|
|
||||||
},
|
|
||||||
"profile": {
|
|
||||||
"myProfile": "マイプロフィール",
|
|
||||||
"email": "メール: {email}",
|
|
||||||
"logout": "ログアウト"
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"title": "設定",
|
|
||||||
"themeColor": "テーマカラー",
|
|
||||||
"themeColorDescription": "お好みのテーマカラーを選択してください"
|
|
||||||
},
|
|
||||||
"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": "字幕の読み込みに失敗しました",
|
|
||||||
"settings": "設定",
|
|
||||||
"shortcuts": "ショートカット",
|
|
||||||
"keyboardShortcuts": "キーボードショートカット",
|
|
||||||
"playPause": "再生/一時停止",
|
|
||||||
"autoPauseToggle": "自動一時停止",
|
|
||||||
"subtitleSettings": "字幕設定",
|
|
||||||
"fontSize": "フォントサイズ",
|
|
||||||
"textColor": "文字色",
|
|
||||||
"backgroundColor": "背景色",
|
|
||||||
"position": "位置",
|
|
||||||
"opacity": "不透明度",
|
|
||||||
"top": "上",
|
|
||||||
"center": "中央",
|
|
||||||
"bottom": "下"
|
|
||||||
},
|
|
||||||
"text_speaker": {
|
|
||||||
"generateIPA": "IPAを生成",
|
|
||||||
"viewSavedItems": "保存済み項目を表示",
|
|
||||||
"confirmDeleteAll": "すべて削除してもよろしいですか? (Y/N)",
|
|
||||||
"saved": "保存済み",
|
|
||||||
"clearAll": "すべてクリア",
|
|
||||||
"language": "言語",
|
|
||||||
"customLanguage": "または言語を入力...",
|
|
||||||
"languages": {
|
|
||||||
"auto": "自動",
|
|
||||||
"chinese": "中国語",
|
|
||||||
"english": "英語",
|
|
||||||
"japanese": "日本語",
|
|
||||||
"korean": "韓国語",
|
|
||||||
"french": "フランス語",
|
|
||||||
"german": "ドイツ語",
|
|
||||||
"italian": "イタリア語",
|
|
||||||
"spanish": "スペイン語",
|
|
||||||
"portuguese": "ポルトガル語",
|
|
||||||
"russian": "ロシア語"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"translator": {
|
|
||||||
"detectLanguage": "言語を検出",
|
|
||||||
"sourceLanguage": "ソース言語",
|
|
||||||
"auto": "自動",
|
|
||||||
"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": "自動保存",
|
|
||||||
"customLanguage": "または言語を入力...",
|
|
||||||
"pleaseLogin": "ログインしてカードを保存",
|
|
||||||
"pleaseCreateDeck": "先にデッキを作成",
|
|
||||||
"noTranslationToSave": "保存する翻訳なし",
|
|
||||||
"noDeckSelected": "デッキ未選択",
|
|
||||||
"saveAsCard": "カードとして保存",
|
|
||||||
"selectDeck": "デッキ選択",
|
|
||||||
"front": "表面",
|
|
||||||
"back": "裏面",
|
|
||||||
"cancel": "キャンセル",
|
|
||||||
"save": "保存",
|
|
||||||
"savedToDeck": "{deckName}に保存",
|
|
||||||
"saveFailed": "保存失敗"
|
|
||||||
},
|
|
||||||
"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": "お気に入り順の並べ替えを解除",
|
|
||||||
"noDecks": "公開デッキなし",
|
|
||||||
"deckInfo": "{userName} · {totalCards}枚"
|
|
||||||
},
|
|
||||||
"exploreDetail": {
|
|
||||||
"title": "フォルダー詳細",
|
|
||||||
"createdBy": "作成者: {name}",
|
|
||||||
"unknownUser": "不明なユーザー",
|
|
||||||
"totalPairs": "合計ペア数",
|
|
||||||
"favorites": "お気に入り",
|
|
||||||
"createdAt": "作成日",
|
|
||||||
"viewContent": "コンテンツを表示",
|
|
||||||
"favorite": "お気に入り",
|
|
||||||
"unfavorite": "お気に入り解除",
|
|
||||||
"favorited": "お気に入りに追加しました",
|
|
||||||
"unfavorited": "お気に入りから削除しました",
|
|
||||||
"pleaseLogin": "まずログインしてください",
|
|
||||||
"totalCards": "{count}枚"
|
|
||||||
},
|
|
||||||
"favorites": {
|
|
||||||
"title": "マイお気に入り",
|
|
||||||
"subtitle": "お気に入りに追加したフォルダー",
|
|
||||||
"loading": "読み込み中...",
|
|
||||||
"noFavorites": "まだお気に入りがありません",
|
|
||||||
"folderInfo": "{userName} • {totalPairs} ペア",
|
|
||||||
"unknownUser": "不明なユーザー"
|
|
||||||
},
|
|
||||||
"user_profile": {
|
|
||||||
"anonymous": "匿名",
|
|
||||||
"email": "メールアドレス",
|
|
||||||
"verified": "認証済み",
|
|
||||||
"unverified": "未認証",
|
|
||||||
"accountInfo": "アカウント情報",
|
|
||||||
"userId": "ユーザーID",
|
|
||||||
"username": "ユーザー名",
|
|
||||||
"displayName": "表示名",
|
|
||||||
"notSet": "未設定",
|
|
||||||
"memberSince": "登録日",
|
|
||||||
"logout": "ログアウト",
|
|
||||||
"deleteAccount": {
|
|
||||||
"button": "アカウント削除",
|
|
||||||
"title": "アカウント削除",
|
|
||||||
"warning": "この操作は取り消せません。すべてのデータが完全に削除されます。",
|
|
||||||
"warningDecks": "すべてのデッキとカード",
|
|
||||||
"warningCards": "すべての学習履歴",
|
|
||||||
"warningHistory": "すべての翻訳と辞書の履歴",
|
|
||||||
"warningPermanent": "この操作は取り消せません",
|
|
||||||
"confirmLabel": "確認のためユーザー名を入力してください:",
|
|
||||||
"usernameMismatch": "ユーザー名が一致しません",
|
|
||||||
"cancel": "キャンセル",
|
|
||||||
"confirm": "アカウントを削除する",
|
|
||||||
"success": "アカウントが正常に削除されました",
|
|
||||||
"failed": "アカウントの削除に失敗しました"
|
|
||||||
},
|
|
||||||
"decks": {
|
|
||||||
"title": "デッキ",
|
|
||||||
"noDecks": "まだデッキがありません",
|
|
||||||
"deckName": "デッキ名",
|
|
||||||
"totalCards": "合計カード数",
|
|
||||||
"createdAt": "作成日",
|
|
||||||
"actions": "アクション",
|
|
||||||
"view": "表示"
|
|
||||||
},
|
|
||||||
"joined": "登録日"
|
|
||||||
},
|
|
||||||
"decks": {
|
|
||||||
"title": "デッキ",
|
|
||||||
"subtitle": "学習デッキを管理",
|
|
||||||
"newDeck": "新規デッキ",
|
|
||||||
"noDecksYet": "デッキなし",
|
|
||||||
"loading": "読込中...",
|
|
||||||
"deckInfo": "ID: {id} · {totalCards}枚",
|
|
||||||
"enterDeckName": "デッキ名:",
|
|
||||||
"enterNewName": "新しい名前:",
|
|
||||||
"confirmDelete": "削除確認:「{name}」を入力",
|
|
||||||
"public": "公開",
|
|
||||||
"private": "非公開",
|
|
||||||
"setPublic": "公開に設定",
|
|
||||||
"setPrivate": "非公開に設定",
|
|
||||||
"importApkg": "APKGインポート",
|
|
||||||
"exportApkg": "APKGエクスポート",
|
|
||||||
"clickToUpload": "クリックでアップロード",
|
|
||||||
"apkgFilesOnly": ".apkgのみ",
|
|
||||||
"parsing": "解析中...",
|
|
||||||
"foundDecks": "{count}デッキ発見",
|
|
||||||
"deckName": "デッキ名",
|
|
||||||
"back": "戻る",
|
|
||||||
"import": "インポート",
|
|
||||||
"importing": "インポート中...",
|
|
||||||
"exportSuccess": "エクスポート成功",
|
|
||||||
"goToDecks": "デッキへ"
|
|
||||||
},
|
|
||||||
"follow": {
|
|
||||||
"follow": "フォロー",
|
|
||||||
"following": "フォロー中",
|
|
||||||
"followers": "フォロワー",
|
|
||||||
"followersOf": "{username}のフォロワー",
|
|
||||||
"followingOf": "{username}のフォロー中",
|
|
||||||
"noFollowers": "まだフォロワーがいません",
|
|
||||||
"noFollowing": "まだ誰もフォローしていません"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,622 +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": "먼저 로그인해주세요"
|
|
||||||
},
|
|
||||||
"decks": {
|
|
||||||
"title": "덱",
|
|
||||||
"noDecks": "덱이 없습니다",
|
|
||||||
"deckName": "덱 이름",
|
|
||||||
"totalCards": "총 카드",
|
|
||||||
"createdAt": "생성일",
|
|
||||||
"actions": "작업",
|
|
||||||
"view": "보기",
|
|
||||||
"subtitle": "학습 덱 관리",
|
|
||||||
"newDeck": "새 덱",
|
|
||||||
"noDecksYet": "덱이 없습니다",
|
|
||||||
"loading": "로딩 중...",
|
|
||||||
"deckInfo": "ID: {id} · {totalCards}장",
|
|
||||||
"enterDeckName": "덱 이름 입력:",
|
|
||||||
"enterNewName": "새 이름 입력:",
|
|
||||||
"confirmDelete": "삭제하려면 \"{name}\" 입력:",
|
|
||||||
"public": "공개",
|
|
||||||
"private": "비공개",
|
|
||||||
"setPublic": "공개로 설정",
|
|
||||||
"setPrivate": "비공개로 설정",
|
|
||||||
"importApkg": "APKG 가져오기",
|
|
||||||
"exportApkg": "APKG 내보내기",
|
|
||||||
"clickToUpload": "클릭하여 업로드",
|
|
||||||
"apkgFilesOnly": ".apkg 파일만",
|
|
||||||
"parsing": "파싱 중...",
|
|
||||||
"foundDecks": "{count}개 덱 발견",
|
|
||||||
"back": "뒤로",
|
|
||||||
"import": "가져오기",
|
|
||||||
"importing": "가져오는 중...",
|
|
||||||
"exportSuccess": "내보내기 성공",
|
|
||||||
"goToDecks": "덱으로"
|
|
||||||
},
|
|
||||||
"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": "이 폴더를 삭제할 권한이 없습니다."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"deck_id": {
|
|
||||||
"unauthorized": "이 덱의 소유자가 아닙니다",
|
|
||||||
"back": "뒤로",
|
|
||||||
"cards": "카드",
|
|
||||||
"itemsCount": "{count}개",
|
|
||||||
"memorize": "암기",
|
|
||||||
"loadingCards": "카드 불러오는 중...",
|
|
||||||
"noCards": "이 덱에 카드가 없습니다",
|
|
||||||
"card": "카드",
|
|
||||||
"addNewCard": "새 카드 추가",
|
|
||||||
"add": "추가",
|
|
||||||
"adding": "추가 중...",
|
|
||||||
"updateCard": "카드 업데이트",
|
|
||||||
"update": "업데이트",
|
|
||||||
"updating": "업데이트 중...",
|
|
||||||
"word": "단어",
|
|
||||||
"definition": "정의",
|
|
||||||
"ipa": "IPA",
|
|
||||||
"example": "예문",
|
|
||||||
"wordAndDefinitionRequired": "단어와 정의는 필수입니다",
|
|
||||||
"edit": "편집",
|
|
||||||
"delete": "삭제",
|
|
||||||
"permissionDenied": "이 작업을 수행할 권한이 없습니다",
|
|
||||||
"resetProgress": "진행 초기화",
|
|
||||||
"resetProgressTitle": "학습 진행 초기화",
|
|
||||||
"resetProgressConfirm": "이 덱의 학습 진행을 초기화하시겠습니까?",
|
|
||||||
"resetSuccess": "초기화됨",
|
|
||||||
"resetting": "초기화 중...",
|
|
||||||
"cancel": "취소",
|
|
||||||
"settings": "설정",
|
|
||||||
"settingsTitle": "덱 설정",
|
|
||||||
"newPerDay": "일일 새 카드",
|
|
||||||
"newPerDayHint": "매일 학습할 새 카드 수",
|
|
||||||
"revPerDay": "일일 복습",
|
|
||||||
"revPerDayHint": "매일 복습할 카드 수",
|
|
||||||
"save": "저장",
|
|
||||||
"saving": "저장 중...",
|
|
||||||
"settingsSaved": "설정 저장됨",
|
|
||||||
"todayNew": "오늘 새 카드",
|
|
||||||
"todayReview": "오늘 복습",
|
|
||||||
"todayLearning": "학습 중",
|
|
||||||
"error": {
|
|
||||||
"update": "업데이트 권한이 없습니다",
|
|
||||||
"delete": "삭제 권한이 없습니다",
|
|
||||||
"add": "추가 권한이 없습니다"
|
|
||||||
},
|
|
||||||
"ipaPlaceholder": "IPA 입력",
|
|
||||||
"examplePlaceholder": "예문 입력",
|
|
||||||
"wordRequired": "단어를 입력하세요",
|
|
||||||
"definitionRequired": "정의를 입력하세요",
|
|
||||||
"cardAdded": "카드 추가됨",
|
|
||||||
"cardType": "카드 유형",
|
|
||||||
"wordCard": "단어 카드",
|
|
||||||
"phraseCard": "구문 카드",
|
|
||||||
"sentenceCard": "문장 카드",
|
|
||||||
"sentence": "문장",
|
|
||||||
"sentencePlaceholder": "문장 입력",
|
|
||||||
"wordPlaceholder": "단어 입력",
|
|
||||||
"queryLang": "검색 언어",
|
|
||||||
"enterLanguageName": "언어 이름을 입력하세요",
|
|
||||||
"english": "영어",
|
|
||||||
"chinese": "중국어",
|
|
||||||
"japanese": "일본어",
|
|
||||||
"korean": "한국어",
|
|
||||||
"meanings": "의미",
|
|
||||||
"addMeaning": "의미 추가",
|
|
||||||
"partOfSpeech": "품사",
|
|
||||||
"deleteConfirm": "이 카드를 삭제하시겠습니까?",
|
|
||||||
"cardDeleted": "카드 삭제됨",
|
|
||||||
"cardUpdated": "카드 업데이트됨"
|
|
||||||
},
|
|
||||||
"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": "비밀번호 재설정 링크를 이메일로 보냈습니다. 받은 편지함을 확인해주세요.",
|
|
||||||
"verifyYourEmail": "이메일 인증",
|
|
||||||
"verificationEmailSent": "인증 이메일이 전송되었습니다",
|
|
||||||
"verificationEmailSentHint": "{email}로 인증 이메일을 보냈습니다. 이메일의 링크를 클릭하여 계정을 인증해주세요.",
|
|
||||||
"checkYourEmail": "이메일을 확인하세요",
|
|
||||||
"backToLogin": "로그인으로 돌아가기",
|
|
||||||
"resetPassword": "비밀번호 재설정",
|
|
||||||
"newPassword": "새 비밀번호",
|
|
||||||
"invalidToken": "유효하지 않거나 만료된 링크",
|
|
||||||
"invalidTokenHint": "이 비밀번호 재설정 링크는 유효하지 않거나 만료되었습니다. 새로 요청해 주세요.",
|
|
||||||
"requestNewToken": "새 재설정 링크 요청",
|
|
||||||
"resetPasswordSuccess": "비밀번호 재설정 성공",
|
|
||||||
"resetPasswordSuccessTitle": "비밀번호 재설정 완료",
|
|
||||||
"resetPasswordSuccessHint": "비밀번호가 성공적으로 재설정되었습니다. 새 비밀번호로 로그인할 수 있습니다.",
|
|
||||||
"emailNotVerified": "이메일 주소를 인증해 주세요",
|
|
||||||
"emailNotVerifiedHint": "이메일이 인증되지 않았습니다. 받은 편지함을 확인하거나 새 인증 이메일을 요청해 주세요.",
|
|
||||||
"resendVerification": "인증 이메일 다시 보내기",
|
|
||||||
"resendSuccess": "인증 이메일이 발송되었습니다! 받은 편지함을 확인해 주세요.",
|
|
||||||
"resendFailed": "인증 이메일 발송에 실패했습니다"
|
|
||||||
},
|
|
||||||
"memorize": {
|
|
||||||
"deck_selector": {
|
|
||||||
"selectDeck": "덱 선택",
|
|
||||||
"noDecks": "덱이 없습니다",
|
|
||||||
"goToDecks": "덱으로 이동",
|
|
||||||
"noCards": "카드가 없습니다",
|
|
||||||
"new": "새로",
|
|
||||||
"learning": "학습 중",
|
|
||||||
"review": "복습",
|
|
||||||
"due": "예정"
|
|
||||||
},
|
|
||||||
"review": {
|
|
||||||
"loading": "로딩 중...",
|
|
||||||
"backToDecks": "덱으로 돌아가기",
|
|
||||||
"allDone": "모두 완료!",
|
|
||||||
"allDoneDesc": "오늘의 학습을 완료했습니다!",
|
|
||||||
"reviewedCount": "{count}장 복습 완료",
|
|
||||||
"progress": "{current} / {total}",
|
|
||||||
"nextReview": "다음 복습",
|
|
||||||
"interval": "간격",
|
|
||||||
"ease": "난이도",
|
|
||||||
"lapses": "실패 횟수",
|
|
||||||
"showAnswer": "정답 보기",
|
|
||||||
"nextCard": "다음",
|
|
||||||
"again": "다시",
|
|
||||||
"restart": "다시 시작",
|
|
||||||
"orderLimited": "순서 제한",
|
|
||||||
"orderInfinite": "순서 무제한",
|
|
||||||
"randomLimited": "무작위 제한",
|
|
||||||
"randomInfinite": "무작위 무제한",
|
|
||||||
"noIpa": "IPA 없음"
|
|
||||||
},
|
|
||||||
"page": {
|
|
||||||
"unauthorized": "권한이 없습니다"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"navbar": {
|
|
||||||
"title": "learn-languages",
|
|
||||||
"sourceCode": "GitHub",
|
|
||||||
"sign_in": "로그인",
|
|
||||||
"profile": "프로필",
|
|
||||||
"folders": "덱",
|
|
||||||
"explore": "탐색",
|
|
||||||
"favorites": "즐겨찾기",
|
|
||||||
"settings": "설정"
|
|
||||||
},
|
|
||||||
"ocr": {
|
|
||||||
"title": "OCR 인식",
|
|
||||||
"description": "이미지에서 텍스트 추출",
|
|
||||||
"uploadImage": "이미지 업로드",
|
|
||||||
"dragDropHint": "드래그 앤 드롭",
|
|
||||||
"supportedFormats": "지원 형식: JPG, PNG, WEBP",
|
|
||||||
"selectDeck": "덱 선택",
|
|
||||||
"chooseDeck": "덱 선택",
|
|
||||||
"noDecks": "덱이 없습니다",
|
|
||||||
"languageHints": "언어 힌트",
|
|
||||||
"sourceLanguageHint": "원본 언어 힌트",
|
|
||||||
"targetLanguageHint": "대상 언어 힌트",
|
|
||||||
"process": "처리",
|
|
||||||
"processing": "처리 중...",
|
|
||||||
"preview": "미리보기",
|
|
||||||
"extractedPairs": "추출된 쌍",
|
|
||||||
"word": "단어",
|
|
||||||
"definition": "정의",
|
|
||||||
"pairsCount": "{count}쌍",
|
|
||||||
"savePairs": "저장",
|
|
||||||
"saving": "저장 중...",
|
|
||||||
"saved": "저장됨",
|
|
||||||
"saveFailed": "저장 실패",
|
|
||||||
"noImage": "이미지를 업로드하세요",
|
|
||||||
"noDeck": "덱을 선택하세요",
|
|
||||||
"processingFailed": "처리 실패",
|
|
||||||
"tryAgain": "재시도",
|
|
||||||
"detectedLanguages": "감지된 언어",
|
|
||||||
"uploadSection": "이미지 업로드",
|
|
||||||
"dropOrClick": "드롭 또는 클릭",
|
|
||||||
"changeImage": "이미지 변경",
|
|
||||||
"invalidFileType": "잘못된 파일 형식",
|
|
||||||
"deckSelection": "덱 선택",
|
|
||||||
"sourceLanguagePlaceholder": "예: 영어",
|
|
||||||
"targetLanguagePlaceholder": "예: 한국어",
|
|
||||||
"processButton": "인식 시작",
|
|
||||||
"resultsPreview": "결과 미리보기",
|
|
||||||
"saveButton": "덱에 저장",
|
|
||||||
"ocrSuccess": "OCR 성공",
|
|
||||||
"ocrFailed": "OCR 실패",
|
|
||||||
"savedToDeck": "덱에 저장됨",
|
|
||||||
"noResultsToSave": "저장할 결과 없음",
|
|
||||||
"detectedSourceLanguage": "감지된 원본 언어",
|
|
||||||
"detectedTargetLanguage": "감지된 대상 언어"
|
|
||||||
},
|
|
||||||
"profile": {
|
|
||||||
"myProfile": "내 프로필",
|
|
||||||
"email": "이메일: {email}",
|
|
||||||
"logout": "로그아웃"
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"title": "설정",
|
|
||||||
"themeColor": "테마 색상",
|
|
||||||
"themeColorDescription": "원하는 테마 색상을 선택하세요"
|
|
||||||
},
|
|
||||||
"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": "자막 로드 실패",
|
|
||||||
"settings": "설정",
|
|
||||||
"shortcuts": "단축키",
|
|
||||||
"keyboardShortcuts": "키보드 단축키",
|
|
||||||
"playPause": "재생/일시정지",
|
|
||||||
"autoPauseToggle": "자동 일시정지",
|
|
||||||
"subtitleSettings": "자막 설정",
|
|
||||||
"fontSize": "글꼴 크기",
|
|
||||||
"textColor": "글자 색",
|
|
||||||
"backgroundColor": "배경색",
|
|
||||||
"position": "위치",
|
|
||||||
"opacity": "불투명도",
|
|
||||||
"top": "위",
|
|
||||||
"center": "중앙",
|
|
||||||
"bottom": "아래"
|
|
||||||
},
|
|
||||||
"text_speaker": {
|
|
||||||
"generateIPA": "IPA 생성",
|
|
||||||
"viewSavedItems": "저장된 항목 보기",
|
|
||||||
"confirmDeleteAll": "모든 것을 삭제하시겠습니까? (Y/N)",
|
|
||||||
"saved": "저장됨",
|
|
||||||
"clearAll": "모두 지우기",
|
|
||||||
"language": "언어",
|
|
||||||
"customLanguage": "또는 언어 입력...",
|
|
||||||
"languages": {
|
|
||||||
"auto": "자동",
|
|
||||||
"chinese": "중국어",
|
|
||||||
"english": "영어",
|
|
||||||
"japanese": "일본어",
|
|
||||||
"korean": "한국어",
|
|
||||||
"french": "프랑스어",
|
|
||||||
"german": "독일어",
|
|
||||||
"italian": "이탈리아어",
|
|
||||||
"spanish": "스페인어",
|
|
||||||
"portuguese": "포르투갈어",
|
|
||||||
"russian": "러시아어"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"translator": {
|
|
||||||
"detectLanguage": "언어 감지",
|
|
||||||
"sourceLanguage": "원본 언어",
|
|
||||||
"auto": "자동",
|
|
||||||
"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": "자동 저장",
|
|
||||||
"customLanguage": "또는 언어 입력...",
|
|
||||||
"pleaseLogin": "카드를 저장하려면 로그인하세요",
|
|
||||||
"pleaseCreateDeck": "먼저 덱을 만드세요",
|
|
||||||
"noTranslationToSave": "저장할 번역이 없습니다",
|
|
||||||
"noDeckSelected": "덱이 선택되지 않았습니다",
|
|
||||||
"saveAsCard": "카드로 저장",
|
|
||||||
"selectDeck": "덱 선택",
|
|
||||||
"front": "앞면",
|
|
||||||
"back": "뒷면",
|
|
||||||
"cancel": "취소",
|
|
||||||
"save": "저장",
|
|
||||||
"savedToDeck": "{deckName}에 카드 저장됨",
|
|
||||||
"saveFailed": "카드 저장 실패"
|
|
||||||
},
|
|
||||||
"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": "즐겨찾기순 정렬 해제",
|
|
||||||
"noDecks": "공개 덱 없음",
|
|
||||||
"deckInfo": "{userName} · {totalCards}장"
|
|
||||||
},
|
|
||||||
"exploreDetail": {
|
|
||||||
"title": "폴더 상세",
|
|
||||||
"createdBy": "생성자: {name}",
|
|
||||||
"unknownUser": "알 수 없는 사용자",
|
|
||||||
"totalPairs": "총 쌍",
|
|
||||||
"favorites": "즐겨찾기",
|
|
||||||
"createdAt": "생성일",
|
|
||||||
"viewContent": "내용 보기",
|
|
||||||
"favorite": "즐겨찾기",
|
|
||||||
"unfavorite": "즐겨찾기 해제",
|
|
||||||
"favorited": "즐겨찾기됨",
|
|
||||||
"unfavorited": "즐겨찾기 해제됨",
|
|
||||||
"pleaseLogin": "먼저 로그인해주세요",
|
|
||||||
"totalCards": "총 {count}장"
|
|
||||||
},
|
|
||||||
"favorites": {
|
|
||||||
"title": "내 즐겨찾기",
|
|
||||||
"subtitle": "즐겨찾기한 폴더",
|
|
||||||
"loading": "로딩 중...",
|
|
||||||
"noFavorites": "아직 즐겨찾기가 없습니다",
|
|
||||||
"folderInfo": "{userName} • {totalPairs} 쌍",
|
|
||||||
"unknownUser": "알 수 없는 사용자"
|
|
||||||
},
|
|
||||||
"user_profile": {
|
|
||||||
"anonymous": "익명",
|
|
||||||
"email": "이메일",
|
|
||||||
"verified": "인증됨",
|
|
||||||
"unverified": "미인증",
|
|
||||||
"accountInfo": "계정 정보",
|
|
||||||
"userId": "사용자 ID",
|
|
||||||
"username": "사용자명",
|
|
||||||
"displayName": "표시 이름",
|
|
||||||
"notSet": "설정되지 않음",
|
|
||||||
"memberSince": "가입일",
|
|
||||||
"logout": "로그아웃",
|
|
||||||
"deleteAccount": {
|
|
||||||
"button": "계정 삭제",
|
|
||||||
"title": "계정 삭제",
|
|
||||||
"warning": "이 작업은 되돌릴 수 없습니다. 모든 데이터가 영구적으로 삭제됩니다.",
|
|
||||||
"warningDecks": "모든 덱과 카드",
|
|
||||||
"warningCards": "모든 학습 진행 상황",
|
|
||||||
"warningHistory": "모든 번역 및 사전 기록",
|
|
||||||
"warningPermanent": "이 작업은 취소할 수 없습니다",
|
|
||||||
"confirmLabel": "확인을 위해 사용자명을 입력하세요:",
|
|
||||||
"usernameMismatch": "사용자명이 일치하지 않습니다",
|
|
||||||
"cancel": "취소",
|
|
||||||
"confirm": "내 계정 삭제",
|
|
||||||
"success": "계정이 성공적으로 삭제되었습니다",
|
|
||||||
"failed": "계정 삭제에 실패했습니다"
|
|
||||||
},
|
|
||||||
"folders": {
|
|
||||||
"title": "덱",
|
|
||||||
"noFolders": "아직 덱이 없습니다",
|
|
||||||
"folderName": "덱 이름",
|
|
||||||
"totalPairs": "총 카드 수",
|
|
||||||
"createdAt": "생성일",
|
|
||||||
"actions": "작업",
|
|
||||||
"view": "보기"
|
|
||||||
},
|
|
||||||
"joined": "가입일",
|
|
||||||
"decks": {
|
|
||||||
"title": "내 덱",
|
|
||||||
"noDecks": "덱이 없습니다",
|
|
||||||
"deckName": "덱 이름",
|
|
||||||
"totalCards": "총 카드",
|
|
||||||
"createdAt": "생성일",
|
|
||||||
"actions": "작업",
|
|
||||||
"view": "보기"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"follow": {
|
|
||||||
"follow": "팔로우",
|
|
||||||
"following": "팔로잉",
|
|
||||||
"followers": "팔로워",
|
|
||||||
"followersOf": "{username}의 팔로워",
|
|
||||||
"followingOf": "{username}의 팔로잉",
|
|
||||||
"noFollowers": "아직 팔로워가 없습니다",
|
|
||||||
"noFollowing": "아직 팔로우하는 사람이 없습니다"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,638 +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": "ئاۋۋال تىزىمغا كىرىڭ"
|
|
||||||
},
|
|
||||||
"decks": {
|
|
||||||
"title": "دېكلار",
|
|
||||||
"noDecks": "تېخى دېك يوق",
|
|
||||||
"deckName": "دېك ئاتى",
|
|
||||||
"totalCards": "جەمئىي كارتا",
|
|
||||||
"createdAt": "قۇرۇلغان ۋاقتى",
|
|
||||||
"actions": "مەشغۇلاتلار",
|
|
||||||
"view": "كۆرۈش",
|
|
||||||
"subtitle": "دېكلەرنى باشقۇرۇڭ",
|
|
||||||
"newDeck": "يېڭى دېك",
|
|
||||||
"noDecksYet": "دېك يوق",
|
|
||||||
"loading": "يۈكلىنىۋاتىدۇ...",
|
|
||||||
"deckInfo": "ID: {id} · {totalCards} كارتا",
|
|
||||||
"enterDeckName": "دېك ئاتى:",
|
|
||||||
"enterNewName": "يېڭى ئات:",
|
|
||||||
"confirmDelete": "ئۆچۈرۈش: \"{name}\"",
|
|
||||||
"public": "ئاممىۋىي",
|
|
||||||
"private": "شەخسىي",
|
|
||||||
"setPublic": "ئاممىۋىي قىلىش",
|
|
||||||
"setPrivate": "شەخسىي قىلىش",
|
|
||||||
"importApkg": "APKG ئەكىرىش",
|
|
||||||
"exportApkg": "APKG چىقىرىش",
|
|
||||||
"clickToUpload": "چېكىپ يۈكلەش",
|
|
||||||
"apkgFilesOnly": ".apkg ھۆججىتىلا",
|
|
||||||
"parsing": "تەھلىل قىلىنىۋاتىدۇ...",
|
|
||||||
"foundDecks": "{count} دېك تېپىلدى",
|
|
||||||
"back": "قايتىش",
|
|
||||||
"import": "ئەكىرىش",
|
|
||||||
"importing": "ئەكىرىلىۋاتىدۇ...",
|
|
||||||
"exportSuccess": "چىقىرىش مۇۋەپپەقىيەتلىك",
|
|
||||||
"goToDecks": "دېكلەرگە بېرىش"
|
|
||||||
},
|
|
||||||
"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": "بۇ قىسقۇچنى ئۆچۈرۈش ھوقۇقىڭىز يوق."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"deck_id": {
|
|
||||||
"unauthorized": "بۇ دېكنىڭ ئىگىسى ئەمەس",
|
|
||||||
"back": "قايتىش",
|
|
||||||
"cards": "كارتلار",
|
|
||||||
"itemsCount": "{count} تۈر",
|
|
||||||
"memorize": "يادلاش",
|
|
||||||
"loadingCards": "كارتلار يۈكلىنىۋاتىدۇ...",
|
|
||||||
"noCards": "بۇ دېكتا كارت يوق",
|
|
||||||
"card": "كارتا",
|
|
||||||
"addNewCard": "يېڭى كارتا قوشۇش",
|
|
||||||
"add": "قوشۇش",
|
|
||||||
"adding": "قوشۇلىۋاتىدۇ...",
|
|
||||||
"updateCard": "كارتىنى يېڭىلاش",
|
|
||||||
"update": "يېڭىلاش",
|
|
||||||
"updating": "يېڭىلىنىۋاتىدۇ...",
|
|
||||||
"word": "سۆز",
|
|
||||||
"definition": "ئېنىقلىما",
|
|
||||||
"ipa": "IPA",
|
|
||||||
"example": "مىسال",
|
|
||||||
"wordAndDefinitionRequired": "سۆز ۋە ئېنىقلىما زۆرۈر",
|
|
||||||
"edit": "تەھرىرلەش",
|
|
||||||
"delete": "ئۆچۈرۈش",
|
|
||||||
"permissionDenied": "بۇ مەشغۇلاتنى ئېلىپ بېرىش ھوقۇقىڭىز يوق",
|
|
||||||
"resetProgress": "ئىلگىرىلەشنى ئەسلىگە قايتۇرۇش",
|
|
||||||
"resetProgressTitle": "ئۆگىنىش ئىلگىرىلەشىنى ئەسلىگە قايتۇرۇش",
|
|
||||||
"resetProgressConfirm": "ئىلگىرىلەشنى ئەسلىگە قايتۇرامسىز؟",
|
|
||||||
"resetSuccess": "ئەسلىگە قايتۇرۇلدى",
|
|
||||||
"resetting": "ئەسلىگە قايتۇرۇۋاتىدۇ...",
|
|
||||||
"cancel": "بىكار قىلىش",
|
|
||||||
"settings": "تەڭشەكلەر",
|
|
||||||
"settingsTitle": "دېك تەڭشەكلىرى",
|
|
||||||
"newPerDay": "كۈندىلىك يېڭى",
|
|
||||||
"newPerDayHint": "كۈندە يېڭى كارتا سانى",
|
|
||||||
"revPerDay": "كۈندىلىك تەكرار",
|
|
||||||
"revPerDayHint": "كۈندە تەكرار سانى",
|
|
||||||
"save": "ساقلاش",
|
|
||||||
"saving": "ساقلاۋاتىدۇ...",
|
|
||||||
"settingsSaved": "تەڭشەكلەر ساقلاندى",
|
|
||||||
"todayNew": "بۈگۈنكى يېڭى",
|
|
||||||
"todayReview": "بۈگۈنكى تەكرار",
|
|
||||||
"todayLearning": "ئۆگىنىۋاتىدۇ",
|
|
||||||
"error": {
|
|
||||||
"update": "يېڭىلاش ھوقۇقى يوق",
|
|
||||||
"delete": "ئۆچۈرۈش ھوقۇقى يوق",
|
|
||||||
"add": "قوشۇش ھوقۇقى يوق"
|
|
||||||
},
|
|
||||||
"ipaPlaceholder": "IPA كىرگۈزۈڭ",
|
|
||||||
"examplePlaceholder": "مىسال كىرگۈزۈڭ",
|
|
||||||
"wordRequired": "سۆز كىرگۈزۈڭ",
|
|
||||||
"definitionRequired": "ئېنىقلىما كىرگۈزۈڭ",
|
|
||||||
"cardAdded": "كارتا قوشۇلدى",
|
|
||||||
"cardType": "كارتا تىپى",
|
|
||||||
"wordCard": "سۆز كارتىسى",
|
|
||||||
"phraseCard": "جۈملە كارتىسى",
|
|
||||||
"sentenceCard": "جۈملە كارتىسى",
|
|
||||||
"sentence": "جۈملە",
|
|
||||||
"sentencePlaceholder": "جۈملە كىرگۈزۈڭ",
|
|
||||||
"wordPlaceholder": "سۆز كىرگۈزۈڭ",
|
|
||||||
"queryLang": "سۈرۈشتۈرۈش تىلى",
|
|
||||||
"enterLanguageName": "تىل ئاتىنى كىرگۈزۈڭ",
|
|
||||||
"english": "ئىنگىلىزچە",
|
|
||||||
"chinese": "خەنزۇچە",
|
|
||||||
"japanese": "ياپونچە",
|
|
||||||
"korean": "كورىيەچە",
|
|
||||||
"meanings": "مەنىلىرى",
|
|
||||||
"addMeaning": "مەنا قوشۇش",
|
|
||||||
"partOfSpeech": "سۆز بۆلىكى",
|
|
||||||
"deleteConfirm": "بۇ كارتىنى ئۆچۈرەمسىز؟",
|
|
||||||
"cardDeleted": "كارتا ئۆچۈرۈلدى",
|
|
||||||
"cardUpdated": "كارتا يېڭىلاندى"
|
|
||||||
},
|
|
||||||
"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": "پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسىنى ئېلخەت ئادرېسىڭىزغا ئەۋەتتۇق. ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ.",
|
|
||||||
"verifyYourEmail": "ئېلخەتنى دەلىللەش",
|
|
||||||
"verificationEmailSent": "دەلىللەش ئېلخېتى ئەۋەتىلدى",
|
|
||||||
"verificationEmailSentHint": "{email} غا دەلىللەش ئېلخېتى ئەۋەتتۇق. ئېلخەتتىكى ئۇلانمىنى چېكىپ ھېساباتىڭىزنى دەلىللەڭ.",
|
|
||||||
"checkYourEmail": "ئېلخېتىڭىزنى تەكشۈرۈڭ",
|
|
||||||
"backToLogin": "كىرىشكە قايتىش",
|
|
||||||
"resetPassword": "پارولنى ئەسلىگە قايتۇرۇش",
|
|
||||||
"newPassword": "يېڭى پارول",
|
|
||||||
"invalidToken": "ئۇلانما ئىناۋەتسىز ياكى ۋاقتى ئۆتكەن",
|
|
||||||
"invalidTokenHint": "بۇ پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسى ئىناۋەتسىز ياكى ۋاقتى ئۆتكەن. يېڭىدىن سوراڭ.",
|
|
||||||
"requestNewToken": "يېڭى ئەسلىگە قايتۇرۇش ئۇلانمىسى سوراش",
|
|
||||||
"resetPasswordSuccess": "پارول مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى",
|
|
||||||
"resetPasswordSuccessTitle": "پارول ئەسلىگە قايتۇرۇش تاماملاندى",
|
|
||||||
"resetPasswordSuccessHint": "پارولىڭىز مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى. يېڭى پارول بىلەن كىرسىڭىز بولىدۇ.",
|
|
||||||
"emailNotVerified": "ئېلخەت ئادرېسىڭىزنى دەلىللەڭ",
|
|
||||||
"emailNotVerifiedHint": "ئېلخەت ئادرېسىڭىز دەلىللەنمىگەن. ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ ياكى يېڭى دەلىللەش ئېلخېتى سوراڭ.",
|
|
||||||
"resendVerification": "دەلىللەش ئېلخېتىنى قايتا ئەۋەتىش",
|
|
||||||
"resendSuccess": "دەلىللەش ئېلخېتى ئەۋەتىلدى! ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ.",
|
|
||||||
"resendFailed": "دەلىللەش ئېلخېتى ئەۋەتىش مەغلۇپ بولدى"
|
|
||||||
},
|
|
||||||
"memorize": {
|
|
||||||
"deck_selector": {
|
|
||||||
"selectDeck": "دېك تاللاش",
|
|
||||||
"noDecks": "دېك يوق",
|
|
||||||
"goToDecks": "دېكلەرگە بار",
|
|
||||||
"noCards": "كارتا يوق",
|
|
||||||
"new": "يېڭى",
|
|
||||||
"learning": "ئۆگىنىش",
|
|
||||||
"review": "تەكرار",
|
|
||||||
"due": "ۋاقتى كەلدى"
|
|
||||||
},
|
|
||||||
"review": {
|
|
||||||
"loading": "يۈكلىنىۋاتىدۇ...",
|
|
||||||
"backToDecks": "دېكلەرگە قايتىش",
|
|
||||||
"allDone": "ھەممىسى تامام!",
|
|
||||||
"allDoneDesc": "بۈگۈنكى ئۆگىنىش تامام!",
|
|
||||||
"reviewedCount": "{count} كارتا تەكرارلاندى",
|
|
||||||
"progress": "{current} / {total}",
|
|
||||||
"nextReview": "كېيىنكى تەكرار",
|
|
||||||
"interval": "ئارىلىق",
|
|
||||||
"ease": "قىيىنلىق",
|
|
||||||
"lapses": "خاتالىق",
|
|
||||||
"showAnswer": "جاۋابنى كۆرسەت",
|
|
||||||
"nextCard": "كېيىنكى",
|
|
||||||
"again": "يەنە",
|
|
||||||
"hard": "قىيىن",
|
|
||||||
"good": "ياخشى",
|
|
||||||
"easy": "ئاسان",
|
|
||||||
"now": "ھازىر",
|
|
||||||
"lessThanMinute": "1 مىنۇتتىن ئاز",
|
|
||||||
"inMinutes": "{n} مىنۇتتىن كېيىن",
|
|
||||||
"inHours": "{n} سائەتتىن كېيىن",
|
|
||||||
"inDays": "{n} كۈندىن كېيىن",
|
|
||||||
"inMonths": "{n} ئايدىن كېيىن",
|
|
||||||
"minutes": "مىنۇت",
|
|
||||||
"days": "كۈن",
|
|
||||||
"months": "ئاي",
|
|
||||||
"minAbbr": "مىن",
|
|
||||||
"dayAbbr": "كۈن",
|
|
||||||
"cardTypeNew": "يېڭى",
|
|
||||||
"cardTypeLearning": "ئۆگىنىش",
|
|
||||||
"cardTypeReview": "تەكرار",
|
|
||||||
"cardTypeRelearning": "قايتا ئۆگىنىش",
|
|
||||||
"reverse": "ئەكسىچە",
|
|
||||||
"dictation": "ئىملا",
|
|
||||||
"clickToPlay": "چېكىپ قويۇش",
|
|
||||||
"yourAnswer": "جاۋابىڭىز",
|
|
||||||
"typeWhatYouHear": "ئاڭلىغىنىڭىزنى يېزىڭ",
|
|
||||||
"correct": "توغرا!",
|
|
||||||
"incorrect": "خاتا",
|
|
||||||
"restart": "قايتا باشلا",
|
|
||||||
"orderLimited": "تەرتىپلى چەكلەنگەن",
|
|
||||||
"orderInfinite": "تەرتىپلى چەكسىز",
|
|
||||||
"randomLimited": "ئىختىيارى چەكلەنگەن",
|
|
||||||
"randomInfinite": "ئىختىيارى چەكسىز",
|
|
||||||
"noIpa": "IPA يوق"
|
|
||||||
},
|
|
||||||
"page": {
|
|
||||||
"unauthorized": "ھوقۇقسىز"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"navbar": {
|
|
||||||
"title": "learn-languages",
|
|
||||||
"sourceCode": "GitHub",
|
|
||||||
"sign_in": "كىرىش",
|
|
||||||
"profile": "شەخسىي ئۇچۇر",
|
|
||||||
"folders": "دېكلار",
|
|
||||||
"explore": "ئىزدىنىش",
|
|
||||||
"favorites": "يىغىپ ساقلاش",
|
|
||||||
"settings": "تەڭشەكلەر"
|
|
||||||
},
|
|
||||||
"ocr": {
|
|
||||||
"title": "OCR تونۇش",
|
|
||||||
"description": "رەسىمدىن تېكىست ئېلىش",
|
|
||||||
"uploadImage": "رەسىم يۈكلەش",
|
|
||||||
"dragDropHint": "سۆرەپ تاشلاش",
|
|
||||||
"supportedFormats": "قوللايدىغان فورمات: JPG, PNG, WEBP",
|
|
||||||
"selectDeck": "دېك تاللاش",
|
|
||||||
"chooseDeck": "دېك تاللاڭ",
|
|
||||||
"noDecks": "دېك يوق",
|
|
||||||
"languageHints": "تىل بېشارىتى",
|
|
||||||
"sourceLanguageHint": "مەنبە تىلى",
|
|
||||||
"targetLanguageHint": "نىشان تىلى",
|
|
||||||
"process": "بىر تەرەپ قىلىش",
|
|
||||||
"processing": "بىر تەرەپ قىلىنىۋاتىدۇ...",
|
|
||||||
"preview": "ئالدىن كۆرۈش",
|
|
||||||
"extractedPairs": "ئېلىنغان جۈپلەر",
|
|
||||||
"word": "سۆز",
|
|
||||||
"definition": "ئېنىقلىما",
|
|
||||||
"pairsCount": "{count} جۈپ",
|
|
||||||
"savePairs": "ساقلاش",
|
|
||||||
"saving": "ساقلاۋاتىدۇ...",
|
|
||||||
"saved": "ساقلاندى",
|
|
||||||
"saveFailed": "ساقلاش مەغلۇپ بولدى",
|
|
||||||
"noImage": "رەسىم يۈكلەڭ",
|
|
||||||
"noDeck": "دېك تاللاڭ",
|
|
||||||
"processingFailed": "بىر تەرەپ قىلىش مەغلۇپ بولدى",
|
|
||||||
"tryAgain": "قايتا سىناڭ",
|
|
||||||
"detectedLanguages": "تونۇلغان تىللار",
|
|
||||||
"uploadSection": "رەسىم يۈكلەش",
|
|
||||||
"dropOrClick": "تاشلاش ياكى چېكىش",
|
|
||||||
"changeImage": "رەسىم ئالماشتۇرۇش",
|
|
||||||
"invalidFileType": "ئىناۋەتسىز فايىل تىپى",
|
|
||||||
"deckSelection": "دېك تاللاش",
|
|
||||||
"sourceLanguagePlaceholder": "مەسىلەن: ئىنگلىزچە",
|
|
||||||
"targetLanguagePlaceholder": "مەسىلەن: ئۇيغۇرچە",
|
|
||||||
"processButton": "تونۇشنى باشلاش",
|
|
||||||
"resultsPreview": "نەتىجە ئالدىن كۆرۈش",
|
|
||||||
"saveButton": "دېككە ساقلاش",
|
|
||||||
"ocrSuccess": "OCR مۇۋەپپەقىيەتلىك",
|
|
||||||
"ocrFailed": "OCR مەغلۇپ بولدى",
|
|
||||||
"savedToDeck": "دېككە ساقلاندى",
|
|
||||||
"noResultsToSave": "نەتىجە يوق",
|
|
||||||
"detectedSourceLanguage": "تونۇلغان مەنبە تىلى",
|
|
||||||
"detectedTargetLanguage": "تونۇلغان نىشان تىلى"
|
|
||||||
},
|
|
||||||
"profile": {
|
|
||||||
"myProfile": "شەخسىي ئۇچۇرۇم",
|
|
||||||
"email": "ئېلخەت: {email}",
|
|
||||||
"logout": "چىكىنىش"
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"title": "تەڭشەكلەر",
|
|
||||||
"themeColor": "تېما رەڭگى",
|
|
||||||
"themeColorDescription": "ياقتۇرىدىغان تېما رەڭگىڭىزنى تاللاڭ"
|
|
||||||
},
|
|
||||||
"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": "تر پودكاست يۈكلەش مەغلۇپ بولدى",
|
|
||||||
"settings": "تەڭشەكلەر",
|
|
||||||
"shortcuts": "تېزلەتمەلەر",
|
|
||||||
"keyboardShortcuts": "كۇنۇپكا تاختىسى تېزلەتمەلىرى",
|
|
||||||
"playPause": "قويۇش/توختىتىش",
|
|
||||||
"autoPauseToggle": "ئاپتوماتىك توختىتىش",
|
|
||||||
"subtitleSettings": "ئاستى سىزىق تەڭشەكلىرى",
|
|
||||||
"fontSize": "خەت چوڭلۇقى",
|
|
||||||
"textColor": "خەت رەڭگى",
|
|
||||||
"backgroundColor": "تەگلىك رەڭگى",
|
|
||||||
"position": "ئورنى",
|
|
||||||
"opacity": "سۈزۈكلۈك",
|
|
||||||
"top": "ئۈستى",
|
|
||||||
"center": "ئوتتۇرا",
|
|
||||||
"bottom": "ئاستى"
|
|
||||||
},
|
|
||||||
"text_speaker": {
|
|
||||||
"generateIPA": "IPA ھاسىل قىلىش",
|
|
||||||
"viewSavedItems": "ساقلانغان تۈرلەرنى كۆرۈش",
|
|
||||||
"confirmDeleteAll": "ھەممىنى ئۆچۈرەمسىز؟ (Y/N)",
|
|
||||||
"saved": "ساقلاندى",
|
|
||||||
"clearAll": "ھەممىنى تازىلاش",
|
|
||||||
"language": "تىل",
|
|
||||||
"customLanguage": "ياكى تىل كىرگۈزۈڭ...",
|
|
||||||
"languages": {
|
|
||||||
"auto": "ئاپتوماتىك",
|
|
||||||
"chinese": "خەنزۇچە",
|
|
||||||
"english": "ئىنگلىزچە",
|
|
||||||
"japanese": "ياپونچە",
|
|
||||||
"korean": "كورېيەچە",
|
|
||||||
"french": "فرانسۇزچە",
|
|
||||||
"german": "گېرمانچە",
|
|
||||||
"italian": "ئىتاليانچە",
|
|
||||||
"spanish": "ئىسپانچە",
|
|
||||||
"portuguese": "پورتۇگالچە",
|
|
||||||
"russian": "رۇسچە"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"translator": {
|
|
||||||
"detectLanguage": "تىلنى تونۇش",
|
|
||||||
"sourceLanguage": "مەنبە تىلى",
|
|
||||||
"auto": "ئاپتوماتىك",
|
|
||||||
"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": "ئاپتوماتىك ساقلاش",
|
|
||||||
"customLanguage": "ياكى تىل تىل كىرۇڭ...",
|
|
||||||
"pleaseLogin": "كارتا ساقلاش ئۈچۈن كىرىڭ",
|
|
||||||
"pleaseCreateDeck": "ئاۋۋال دېك قۇرۇڭ",
|
|
||||||
"noTranslationToSave": "ساقلايدىغان تەرجىمە يوق",
|
|
||||||
"noDeckSelected": "دېك تاللانمىدى",
|
|
||||||
"saveAsCard": "كارتا ساقلاش",
|
|
||||||
"selectDeck": "دېك تاللاش",
|
|
||||||
"front": "ئالدى",
|
|
||||||
"back": "كەينى",
|
|
||||||
"cancel": "بىكار قىلىش",
|
|
||||||
"save": "ساقلاش",
|
|
||||||
"savedToDeck": "{deckName} غا ساقلاندى",
|
|
||||||
"saveFailed": "ساقلاش مەغلۇپ"
|
|
||||||
},
|
|
||||||
"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": "يىغىپ ساقلاش بويىچە تەرتىپلەشنى بىكار قىلىش",
|
|
||||||
"noDecks": "ئاممىۋىي دېك يوق",
|
|
||||||
"deckInfo": "{userName} · {totalCards} كارتا"
|
|
||||||
},
|
|
||||||
"exploreDetail": {
|
|
||||||
"title": "قىسقۇچ تەپسىلاتلىرى",
|
|
||||||
"createdBy": "قۇرغۇچى: {name}",
|
|
||||||
"unknownUser": "نامەلۇم ئىشلەتكۈچى",
|
|
||||||
"totalPairs": "جەمئىي جۈپ",
|
|
||||||
"favorites": "يىغىپ ساقلانغانلار",
|
|
||||||
"createdAt": "قۇرۇلغان ۋاقتى",
|
|
||||||
"viewContent": "مەزمۇننى كۆرۈش",
|
|
||||||
"favorite": "يىغىپ ساقلا",
|
|
||||||
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
|
|
||||||
"favorited": "يىغىپ ساقلاندى",
|
|
||||||
"unfavorited": "يىغىپ ساقلاش بىكار قىلىندى",
|
|
||||||
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ",
|
|
||||||
"totalCards": "{count} كارتا"
|
|
||||||
},
|
|
||||||
"favorites": {
|
|
||||||
"title": "يىغىپ ساقلىغانلىرىم",
|
|
||||||
"subtitle": "يىغىپ ساقلىغان قىسقۇچلىرىڭىز",
|
|
||||||
"loading": "يۈكلىنىۋاتىدۇ...",
|
|
||||||
"noFavorites": "تېخى يىغىپ ساقلانمىغان",
|
|
||||||
"folderInfo": "{userName} • {totalPairs} جۈپ",
|
|
||||||
"unknownUser": "نامەلۇم ئىشلەتكۈچى"
|
|
||||||
},
|
|
||||||
"user_profile": {
|
|
||||||
"anonymous": "نامسىز",
|
|
||||||
"email": "ئېلخەت",
|
|
||||||
"verified": "دەلىللەنگەن",
|
|
||||||
"unverified": "دەلىللەنمىگەن",
|
|
||||||
"accountInfo": "ھېسابات ئۇچۇرلىرى",
|
|
||||||
"userId": "ئىشلەتكۈچى كىملىكى",
|
|
||||||
"username": "ئىشلەتكۈچى ئاتى",
|
|
||||||
"displayName": "كۆرسىتىش ئاتى",
|
|
||||||
"notSet": "تەڭشەلمىگەن",
|
|
||||||
"memberSince": "ئەزا بولغاندىن بېرى",
|
|
||||||
"logout": "چىكىنىش",
|
|
||||||
"deleteAccount": {
|
|
||||||
"button": "ھېساباتنى ئۆچۈرۈش",
|
|
||||||
"title": "ھېساباتنى ئۆچۈرۈش",
|
|
||||||
"warning": "بۇ مەشغۇلاتنى ئەسلىگە قايتۇرغىلى بولمايدۇ. بارلىق سانلىق مەلۇماتلىرىڭىز مەڭگۈلۈك ئۆچۈرۈلىدۇ.",
|
|
||||||
"warningDecks": "بارلىق دېك ۋە كارتلىرىڭىز",
|
|
||||||
"warningCards": "بارلىق ئۆگىنىش ئىلگىرىلەشلىرىڭىز",
|
|
||||||
"warningHistory": "بارلىق تەرجىمە ۋە لۇغەت تارىخىڭىز",
|
|
||||||
"warningPermanent": "بۇ مەشغۇلاتنى بىكار قىلغىلى بولمايدۇ",
|
|
||||||
"confirmLabel": "جەزىملەش ئۈچۈن ئىشلەتكۈچى ئاتىڭىزنى كىرگۈزۈڭ:",
|
|
||||||
"usernameMismatch": "ئىشلەتكۈچى ئاتى ماس كەلمەيدۇ",
|
|
||||||
"cancel": "بىكار قىلىش",
|
|
||||||
"confirm": "ھېساباتىمنى ئۆچۈرۈش",
|
|
||||||
"success": "ھېسابات مۇۋەپپەقىيەتلىك ئۆچۈرۈلدى",
|
|
||||||
"failed": "ھېساباتنى ئۆچۈرۈش مەغلۇپ بولدى"
|
|
||||||
},
|
|
||||||
"decks": {
|
|
||||||
"title": "دېكلار",
|
|
||||||
"noDecks": "تېخى دېك يوق",
|
|
||||||
"deckName": "دېك ئاتى",
|
|
||||||
"totalCards": "جەمئىي كارتا",
|
|
||||||
"createdAt": "قۇرۇلغان ۋاقتى",
|
|
||||||
"actions": "مەشغۇلاتلار",
|
|
||||||
"view": "كۆرۈش"
|
|
||||||
},
|
|
||||||
"joined": "قوشۇلدى"
|
|
||||||
},
|
|
||||||
"follow": {
|
|
||||||
"follow": "ئەگىشىش",
|
|
||||||
"following": "ئەگىشىۋاتىدۇ",
|
|
||||||
"followers": "ئەگەشكۈچىلەر",
|
|
||||||
"followersOf": "{username} نىڭ ئەگەشكۈچىلىرى",
|
|
||||||
"followingOf": "{username} نىڭ ئەگىشىدىغانلىرى",
|
|
||||||
"noFollowers": "تېخى ئەگەشكۈچى يوق",
|
|
||||||
"noFollowing": "تېخى ئەگىشىدىغان ئادەم يوق"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"alphabet": {
|
"alphabet": {
|
||||||
"chooseCharacters": "请选择您想学习的字符",
|
"chooseCharacters": "请选择您想学习的字符",
|
||||||
"chooseAlphabetHint": "选择一种语言的字母表开始学习",
|
|
||||||
"japanese": "日语假名",
|
"japanese": "日语假名",
|
||||||
"english": "英文字母",
|
"english": "英文字母",
|
||||||
"uyghur": "维吾尔字母",
|
"uyghur": "维吾尔字母",
|
||||||
@@ -15,11 +14,7 @@
|
|||||||
"roman": "罗马音",
|
"roman": "罗马音",
|
||||||
"letter": "字母",
|
"letter": "字母",
|
||||||
"random": "随机模式",
|
"random": "随机模式",
|
||||||
"randomNext": "随机下一个",
|
"randomNext": "随机下一个"
|
||||||
"previousLetter": "上一个字母",
|
|
||||||
"nextLetter": "下一个字母",
|
|
||||||
"keyboardHint": "使用左右箭头键或空格键随机切换,ESC键返回",
|
|
||||||
"swipeHint": "使用左右箭头键或滑动切换字母"
|
|
||||||
},
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
"title": "文件夹",
|
"title": "文件夹",
|
||||||
@@ -27,24 +22,13 @@
|
|||||||
"newFolder": "新建文件夹",
|
"newFolder": "新建文件夹",
|
||||||
"creating": "创建中...",
|
"creating": "创建中...",
|
||||||
"noFoldersYet": "还没有文件夹",
|
"noFoldersYet": "还没有文件夹",
|
||||||
"folderInfo": "ID: {id} • {totalPairs} 个文本对",
|
"folderInfo": "{id}. {name} ({totalPairs})",
|
||||||
"enterFolderName": "输入文件夹名称:",
|
"enterFolderName": "输入文件夹名称:",
|
||||||
"confirmDelete": "输入 \"{name}\" 以删除:",
|
"confirmDelete": "输入 \"{name}\" 以删除:",
|
||||||
"myFolders": "我的文件夹",
|
"createFolderSuccess": "文件夹创建成功",
|
||||||
"publicFolders": "公开文件夹",
|
"deleteFolderSuccess": "文件夹删除成功",
|
||||||
"public": "公开",
|
"createFolderError": "创建文件夹失败",
|
||||||
"private": "私有",
|
"deleteFolderError": "删除文件夹失败"
|
||||||
"setPublic": "设为公开",
|
|
||||||
"setPrivate": "设为私有",
|
|
||||||
"publicFolderInfo": "{userName} • {totalPairs} 个文本对",
|
|
||||||
"searchPlaceholder": "搜索公开文件夹...",
|
|
||||||
"loading": "加载中...",
|
|
||||||
"noPublicFolders": "没有找到公开文件夹",
|
|
||||||
"unknownUser": "未知用户",
|
|
||||||
"enterNewName": "输入新名称:",
|
|
||||||
"favorite": "收藏",
|
|
||||||
"unfavorite": "取消收藏",
|
|
||||||
"pleaseLogin": "请先登录"
|
|
||||||
},
|
},
|
||||||
"folder_id": {
|
"folder_id": {
|
||||||
"unauthorized": "您不是此文件夹的所有者",
|
"unauthorized": "您不是此文件夹的所有者",
|
||||||
@@ -60,90 +44,10 @@
|
|||||||
"update": "更新",
|
"update": "更新",
|
||||||
"text1": "文本1",
|
"text1": "文本1",
|
||||||
"text2": "文本2",
|
"text2": "文本2",
|
||||||
"language1": "语言1",
|
"locale1": "语言1",
|
||||||
"language2": "语言2",
|
"locale2": "语言2",
|
||||||
"enterLanguageName": "请输入语言名称",
|
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"delete": "删除",
|
"delete": "删除"
|
||||||
"permissionDenied": "您没有权限执行此操作",
|
|
||||||
"error": {
|
|
||||||
"update": "您没有权限更新此项目",
|
|
||||||
"delete": "您没有权限删除此项目",
|
|
||||||
"add": "您没有权限向此文件夹添加项目",
|
|
||||||
"rename": "您没有权限重命名此文件夹",
|
|
||||||
"deleteFolder": "您没有权限删除此文件夹"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"deck_id": {
|
|
||||||
"unauthorized": "您不是此牌组的所有者",
|
|
||||||
"back": "返回",
|
|
||||||
"cards": "卡片",
|
|
||||||
"itemsCount": "{count} 个",
|
|
||||||
"memorize": "记忆",
|
|
||||||
"loadingCards": "加载卡片中...",
|
|
||||||
"noCards": "此牌组中没有卡片",
|
|
||||||
"card": "卡片",
|
|
||||||
"addNewCard": "添加新卡片",
|
|
||||||
"add": "添加",
|
|
||||||
"adding": "添加中...",
|
|
||||||
"updateCard": "更新卡片",
|
|
||||||
"update": "更新",
|
|
||||||
"updating": "更新中...",
|
|
||||||
"word": "单词",
|
|
||||||
"definition": "释义",
|
|
||||||
"ipa": "音标",
|
|
||||||
"example": "例句",
|
|
||||||
"wordAndDefinitionRequired": "单词和释义都是必需的",
|
|
||||||
"edit": "编辑",
|
|
||||||
"delete": "删除",
|
|
||||||
"permissionDenied": "您没有权限执行此操作",
|
|
||||||
"resetProgress": "重置进度",
|
|
||||||
"resetProgressTitle": "重置学习进度",
|
|
||||||
"resetProgressConfirm": "确定要重置这个卡组的学习进度吗?",
|
|
||||||
"resetSuccess": "进度已重置",
|
|
||||||
"resetting": "重置中...",
|
|
||||||
"cancel": "取消",
|
|
||||||
"settings": "设置",
|
|
||||||
"settingsTitle": "卡组设置",
|
|
||||||
"newPerDay": "每日新卡",
|
|
||||||
"newPerDayHint": "每天学习的新卡片数量",
|
|
||||||
"revPerDay": "每日复习",
|
|
||||||
"revPerDayHint": "每天复习的卡片数量",
|
|
||||||
"save": "保存",
|
|
||||||
"saving": "保存中...",
|
|
||||||
"settingsSaved": "设置已保存",
|
|
||||||
"todayNew": "今日新卡",
|
|
||||||
"todayReview": "今日复习",
|
|
||||||
"todayLearning": "学习中",
|
|
||||||
"error": {
|
|
||||||
"update": "您没有权限更新此卡片",
|
|
||||||
"delete": "您没有权限删除此卡片",
|
|
||||||
"add": "您没有权限向此牌组添加卡片"
|
|
||||||
},
|
|
||||||
"ipaPlaceholder": "输入IPA音标",
|
|
||||||
"examplePlaceholder": "输入例句",
|
|
||||||
"wordRequired": "请输入单词",
|
|
||||||
"definitionRequired": "请输入至少一个释义",
|
|
||||||
"cardAdded": "卡片已添加",
|
|
||||||
"cardType": "卡片类型",
|
|
||||||
"wordCard": "单词卡",
|
|
||||||
"phraseCard": "短语卡",
|
|
||||||
"sentenceCard": "句子卡",
|
|
||||||
"sentence": "句子",
|
|
||||||
"sentencePlaceholder": "输入句子",
|
|
||||||
"wordPlaceholder": "输入单词",
|
|
||||||
"queryLang": "查询语言",
|
|
||||||
"enterLanguageName": "请输入语言名称",
|
|
||||||
"english": "英语",
|
|
||||||
"chinese": "中文",
|
|
||||||
"japanese": "日语",
|
|
||||||
"korean": "韩语",
|
|
||||||
"meanings": "释义",
|
|
||||||
"addMeaning": "添加释义",
|
|
||||||
"partOfSpeech": "词性",
|
|
||||||
"deleteConfirm": "确定删除这张卡片吗?",
|
|
||||||
"cardDeleted": "卡片已删除",
|
|
||||||
"cardUpdated": "卡片已更新"
|
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "学语言",
|
"title": "学语言",
|
||||||
@@ -173,139 +77,60 @@
|
|||||||
"name": "记忆",
|
"name": "记忆",
|
||||||
"description": "语言A到语言B,语言B到语言A,支持听写"
|
"description": "语言A到语言B,语言B到语言A,支持听写"
|
||||||
},
|
},
|
||||||
"dictionary": {
|
|
||||||
"name": "词典",
|
|
||||||
"description": "查询单词和短语,提供详细的释义和例句"
|
|
||||||
},
|
|
||||||
"moreFeatures": {
|
"moreFeatures": {
|
||||||
"name": "更多功能",
|
"name": "更多功能",
|
||||||
"description": "开发中,敬请期待"
|
"description": "开发中,敬请期待"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"login": {
|
||||||
|
"loading": "加载中...",
|
||||||
|
"githubLogin": "GitHub登录"
|
||||||
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "登录",
|
"title": "登录",
|
||||||
"signUpTitle": "注册",
|
|
||||||
"signIn": "登录",
|
"signIn": "登录",
|
||||||
"signUp": "注册",
|
"signUp": "注册",
|
||||||
"email": "邮箱",
|
"email": "邮箱",
|
||||||
"password": "密码",
|
"password": "密码",
|
||||||
"confirmPassword": "确认密码",
|
"confirmPassword": "确认密码",
|
||||||
"name": "用户名",
|
"name": "用户名",
|
||||||
"username": "用户名",
|
|
||||||
"emailOrUsername": "邮箱或用户名",
|
|
||||||
"signInButton": "登录",
|
"signInButton": "登录",
|
||||||
"signUpButton": "注册",
|
"signUpButton": "注册",
|
||||||
"noAccount": "还没有账户?",
|
"noAccount": "还没有账户?",
|
||||||
"hasAccount": "已有账户?",
|
"hasAccount": "已有账户?",
|
||||||
"signInWithGitHub": "使用 GitHub 登录",
|
"signInWithGitHub": "使用GitHub登录",
|
||||||
"signUpWithGitHub": "使用 GitHub 注册",
|
"signUpWithGitHub": "使用GitHub注册",
|
||||||
"invalidEmail": "请输入有效的邮箱地址",
|
"invalidEmail": "请输入有效的邮箱地址",
|
||||||
"passwordTooShort": "密码至少需要8个字符",
|
"passwordTooShort": "密码至少需要8个字符",
|
||||||
"passwordsNotMatch": "两次输入的密码不匹配",
|
"passwordsNotMatch": "两次输入的密码不匹配",
|
||||||
|
"signInFailed": "登录失败,请检查您的邮箱和密码",
|
||||||
|
"signUpFailed": "注册失败,请稍后再试",
|
||||||
"nameRequired": "请输入用户名",
|
"nameRequired": "请输入用户名",
|
||||||
"usernameRequired": "请输入用户名",
|
|
||||||
"usernameTooShort": "用户名至少需要3个字符",
|
|
||||||
"usernameInvalid": "用户名只能包含字母、数字和下划线",
|
|
||||||
"emailRequired": "请输入邮箱",
|
"emailRequired": "请输入邮箱",
|
||||||
"identifierRequired": "请输入邮箱或用户名",
|
|
||||||
"passwordRequired": "请输入密码",
|
"passwordRequired": "请输入密码",
|
||||||
"confirmPasswordRequired": "请确认密码",
|
"confirmPasswordRequired": "请确认密码"
|
||||||
"loading": "加载中...",
|
|
||||||
"confirm": "确认",
|
|
||||||
"noAccountLink": "没有账号?去注册",
|
|
||||||
"hasAccountLink": "已有账号?去登录",
|
|
||||||
"usernamePlaceholder": "用户名",
|
|
||||||
"emailPlaceholder": "邮箱地址",
|
|
||||||
"passwordPlaceholder": "密码",
|
|
||||||
"usernameOrEmailPlaceholder": "用户名或邮箱地址",
|
|
||||||
"loginFailed": "登录失败",
|
|
||||||
"signUpFailed": "注册失败",
|
|
||||||
"fillAllFields": "请填写所有字段",
|
|
||||||
"enterCredentials": "请输入用户名和密码",
|
|
||||||
"forgotPassword": "忘记密码",
|
|
||||||
"forgotPasswordHint": "输入您的邮箱地址,我们将向您发送重置密码的链接。",
|
|
||||||
"sendResetEmail": "发送重置邮件",
|
|
||||||
"resetPasswordFailed": "发送重置邮件失败",
|
|
||||||
"resetPasswordEmailSent": "重置邮件已发送",
|
|
||||||
"resetPasswordEmailSentHint": "我们已向您的邮箱发送了密码重置链接,请查收。",
|
|
||||||
"verifyYourEmail": "验证您的邮箱",
|
|
||||||
"verificationEmailSent": "验证邮件已发送",
|
|
||||||
"verificationEmailSentHint": "我们已向 {email} 发送了验证邮件,请点击邮件中的链接完成验证。",
|
|
||||||
"checkYourEmail": "请查收邮件",
|
|
||||||
"backToLogin": "返回登录",
|
|
||||||
"resetPassword": "重置密码",
|
|
||||||
"newPassword": "新密码",
|
|
||||||
"invalidToken": "链接无效或已过期",
|
|
||||||
"invalidTokenHint": "此密码重置链接无效或已过期,请重新申请。",
|
|
||||||
"requestNewToken": "重新申请重置链接",
|
|
||||||
"resetPasswordSuccess": "密码重置成功",
|
|
||||||
"resetPasswordSuccessTitle": "密码重置完成",
|
|
||||||
"resetPasswordSuccessHint": "您的密码已成功重置,现在可以使用新密码登录了。",
|
|
||||||
"emailNotVerified": "请验证您的邮箱地址",
|
|
||||||
"emailNotVerifiedHint": "您的邮箱尚未验证。请检查收件箱或重新发送验证邮件。",
|
|
||||||
"resendVerification": "重新发送验证邮件",
|
|
||||||
"resendSuccess": "验证邮件已发送!请检查您的收件箱。",
|
|
||||||
"resendFailed": "发送验证邮件失败"
|
|
||||||
},
|
},
|
||||||
"memorize": {
|
"memorize": {
|
||||||
"deck_selector": {
|
"choose": {
|
||||||
"selectDeck": "选择牌组",
|
"back": "返回",
|
||||||
"noDecks": "未找到牌组",
|
"choose": "选择"
|
||||||
"goToDecks": "前往牌组",
|
|
||||||
"noCards": "无卡片",
|
|
||||||
"new": "新卡片",
|
|
||||||
"learning": "学习中",
|
|
||||||
"review": "复习",
|
|
||||||
"due": "待复习"
|
|
||||||
},
|
},
|
||||||
"review": {
|
"folder_selector": {
|
||||||
"loading": "加载中...",
|
"selectFolder": "选择文件夹",
|
||||||
"backToDecks": "返回牌组",
|
"noFolders": "未找到文件夹",
|
||||||
"allDone": "全部完成!",
|
"folderInfo": "{id}. {name} ({count})"
|
||||||
"allDoneDesc": "您已完成所有待复习卡片。",
|
},
|
||||||
"reviewedCount": "已复习 {count} 张卡片",
|
"memorize": {
|
||||||
"progress": "{current} / {total}",
|
"answer": "答案",
|
||||||
"nextReview": "下次复习",
|
"next": "下一个",
|
||||||
"interval": "间隔",
|
|
||||||
"ease": "难度系数",
|
|
||||||
"lapses": "遗忘次数",
|
|
||||||
"showAnswer": "显示答案",
|
|
||||||
"nextCard": "下一张",
|
|
||||||
"again": "重来",
|
|
||||||
"hard": "困难",
|
|
||||||
"good": "良好",
|
|
||||||
"easy": "简单",
|
|
||||||
"now": "现在",
|
|
||||||
"lessThanMinute": "<1 分钟",
|
|
||||||
"inMinutes": "{count} 分钟",
|
|
||||||
"inHours": "{count} 小时",
|
|
||||||
"inDays": "{count} 天",
|
|
||||||
"inMonths": "{count} 个月",
|
|
||||||
"minutes": "<1 分钟",
|
|
||||||
"days": "{count} 天",
|
|
||||||
"months": "{count} 个月",
|
|
||||||
"minAbbr": "分",
|
|
||||||
"dayAbbr": "天",
|
|
||||||
"cardTypeNew": "新卡片",
|
|
||||||
"cardTypeLearning": "学习中",
|
|
||||||
"cardTypeReview": "复习中",
|
|
||||||
"cardTypeRelearning": "重学中",
|
|
||||||
"reverse": "反向",
|
"reverse": "反向",
|
||||||
"dictation": "听写",
|
"dictation": "听写",
|
||||||
"clickToPlay": "点击播放",
|
"noTextPairs": "没有可用的文本对",
|
||||||
"yourAnswer": "你的答案",
|
"disorder": "乱序",
|
||||||
"typeWhatYouHear": "输入你听到的内容",
|
"previous": "上一个"
|
||||||
"correct": "正确",
|
|
||||||
"incorrect": "错误",
|
|
||||||
"restart": "重新开始",
|
|
||||||
"orderLimited": "顺序有限",
|
|
||||||
"orderInfinite": "顺序无限",
|
|
||||||
"randomLimited": "随机有限",
|
|
||||||
"randomInfinite": "随机无限",
|
|
||||||
"noIpa": "无音标"
|
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "您无权访问该牌组"
|
"unauthorized": "您无权访问该文件夹"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navbar": {
|
"navbar": {
|
||||||
@@ -313,66 +138,13 @@
|
|||||||
"sourceCode": "源码",
|
"sourceCode": "源码",
|
||||||
"sign_in": "登录",
|
"sign_in": "登录",
|
||||||
"profile": "个人资料",
|
"profile": "个人资料",
|
||||||
"folders": "牌组",
|
"folders": "文件夹"
|
||||||
"explore": "探索",
|
|
||||||
"favorites": "收藏",
|
|
||||||
"settings": "设置"
|
|
||||||
},
|
|
||||||
"ocr": {
|
|
||||||
"title": "OCR文字识别",
|
|
||||||
"description": "从图片中提取文字创建学习卡片",
|
|
||||||
"uploadSection": "上传图片",
|
|
||||||
"uploadImage": "上传图片",
|
|
||||||
"dragDropHint": "拖放或点击上传",
|
|
||||||
"dropOrClick": "拖放或点击",
|
|
||||||
"changeImage": "更换图片",
|
|
||||||
"supportedFormats": "支持格式:JPG, PNG, WEBP",
|
|
||||||
"invalidFileType": "无效的文件类型",
|
|
||||||
"deckSelection": "选择卡组",
|
|
||||||
"selectDeck": "选择卡组",
|
|
||||||
"chooseDeck": "选择卡组保存",
|
|
||||||
"noDecks": "没有可用的卡组",
|
|
||||||
"languageHints": "语言提示",
|
|
||||||
"sourceLanguageHint": "源语言提示",
|
|
||||||
"targetLanguageHint": "目标语言提示",
|
|
||||||
"sourceLanguagePlaceholder": "如:英语",
|
|
||||||
"targetLanguagePlaceholder": "如:中文",
|
|
||||||
"process": "处理",
|
|
||||||
"processButton": "开始识别",
|
|
||||||
"processing": "处理中...",
|
|
||||||
"preview": "预览",
|
|
||||||
"resultsPreview": "结果预览",
|
|
||||||
"extractedPairs": "提取的语言对",
|
|
||||||
"word": "单词",
|
|
||||||
"definition": "释义",
|
|
||||||
"pairsCount": "{count}对",
|
|
||||||
"savePairs": "保存",
|
|
||||||
"saveButton": "保存到卡组",
|
|
||||||
"saving": "保存中...",
|
|
||||||
"saved": "已保存",
|
|
||||||
"ocrSuccess": "OCR识别成功",
|
|
||||||
"savedToDeck": "已保存到卡组",
|
|
||||||
"saveFailed": "保存失败",
|
|
||||||
"noImage": "请上传图片",
|
|
||||||
"noDeck": "请选择卡组",
|
|
||||||
"noResultsToSave": "无结果可保存",
|
|
||||||
"processingFailed": "处理失败",
|
|
||||||
"tryAgain": "重试",
|
|
||||||
"detectedLanguages": "检测到的语言",
|
|
||||||
"detectedSourceLanguage": "检测到的源语言",
|
|
||||||
"detectedTargetLanguage": "检测到的目标语言",
|
|
||||||
"ocrFailed": "OCR识别失败"
|
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"myProfile": "我的个人资料",
|
"myProfile": "我的个人资料",
|
||||||
"email": "邮箱:{email}",
|
"email": "邮箱:{email}",
|
||||||
"logout": "退出登录"
|
"logout": "退出登录"
|
||||||
},
|
},
|
||||||
"settings": {
|
|
||||||
"title": "设置",
|
|
||||||
"themeColor": "主题色",
|
|
||||||
"themeColorDescription": "选择您喜欢的主题色"
|
|
||||||
},
|
|
||||||
"srt_player": {
|
"srt_player": {
|
||||||
"upload": "上传",
|
"upload": "上传",
|
||||||
"uploadVideo": "上传视频",
|
"uploadVideo": "上传视频",
|
||||||
@@ -383,6 +155,18 @@
|
|||||||
"next": "下句",
|
"next": "下句",
|
||||||
"restart": "句首",
|
"restart": "句首",
|
||||||
"autoPause": "自动暂停({enabled})",
|
"autoPause": "自动暂停({enabled})",
|
||||||
|
"playbackSpeed": "播放速度",
|
||||||
|
"subtitleSettings": "字幕设置",
|
||||||
|
"fontSize": "字体大小",
|
||||||
|
"backgroundColor": "背景颜色",
|
||||||
|
"textColor": "文字颜色",
|
||||||
|
"fontFamily": "字体",
|
||||||
|
"opacity": "透明度",
|
||||||
|
"position": "位置",
|
||||||
|
"top": "顶部",
|
||||||
|
"center": "居中",
|
||||||
|
"bottom": "底部",
|
||||||
|
"keyboardShortcuts": "键盘快捷键",
|
||||||
"uploadVideoAndSubtitle": "请上传视频和字幕文件",
|
"uploadVideoAndSubtitle": "请上传视频和字幕文件",
|
||||||
"uploadVideoFile": "请上传视频文件",
|
"uploadVideoFile": "请上传视频文件",
|
||||||
"uploadSubtitleFile": "请上传字幕文件",
|
"uploadSubtitleFile": "请上传字幕文件",
|
||||||
@@ -392,70 +176,33 @@
|
|||||||
"subtitleFile": "字幕文件",
|
"subtitleFile": "字幕文件",
|
||||||
"uploaded": "已上传",
|
"uploaded": "已上传",
|
||||||
"notUploaded": "未上传",
|
"notUploaded": "未上传",
|
||||||
"uploadVideoButton": "上传视频",
|
|
||||||
"uploadSubtitleButton": "上传字幕",
|
|
||||||
"subtitleUploaded": "字幕已上传 ({count} 条)",
|
|
||||||
"subtitleNotUploaded": "字幕未上传",
|
|
||||||
"autoPauseStatus": "自动暂停: {enabled}",
|
"autoPauseStatus": "自动暂停: {enabled}",
|
||||||
"on": "开",
|
"on": "开",
|
||||||
"off": "关",
|
"off": "关",
|
||||||
"videoUploadFailed": "视频上传失败",
|
"videoUploadFailed": "视频上传失败",
|
||||||
"subtitleUploadFailed": "字幕上传失败",
|
"subtitleUploadFailed": "字幕上传失败",
|
||||||
"subtitleLoadSuccess": "字幕加载成功",
|
"subtitleLoadSuccess": "字幕文件加载成功",
|
||||||
"subtitleLoadFailed": "字幕加载失败",
|
"subtitleLoadFailed": "字幕文件加载失败",
|
||||||
"settings": "设置",
|
"shortcuts": {
|
||||||
"shortcuts": "快捷键",
|
"playPause": "播放/暂停",
|
||||||
"keyboardShortcuts": "键盘快捷键",
|
"next": "下一句",
|
||||||
"playPause": "播放/暂停",
|
"previous": "上一句",
|
||||||
"autoPauseToggle": "自动暂停开关",
|
"restart": "句首",
|
||||||
"subtitleSettings": "字幕设置",
|
"autoPause": "切换自动暂停"
|
||||||
"fontSize": "字体大小",
|
}
|
||||||
"textColor": "文字颜色",
|
|
||||||
"backgroundColor": "背景颜色",
|
|
||||||
"position": "位置",
|
|
||||||
"opacity": "透明度",
|
|
||||||
"top": "顶部",
|
|
||||||
"center": "居中",
|
|
||||||
"bottom": "底部"
|
|
||||||
},
|
},
|
||||||
"text_speaker": {
|
"text_speaker": {
|
||||||
"generateIPA": "生成IPA",
|
"generateIPA": "生成IPA",
|
||||||
"viewSavedItems": "查看保存项",
|
"viewSavedItems": "查看保存项",
|
||||||
"confirmDeleteAll": "确定删光吗?(Y/N)",
|
"confirmDeleteAll": "确定删光吗?(Y/N)"
|
||||||
"saved": "已保存",
|
|
||||||
"clearAll": "清空全部",
|
|
||||||
"language": "语言",
|
|
||||||
"customLanguage": "或输入语言...",
|
|
||||||
"languages": {
|
|
||||||
"auto": "自动",
|
|
||||||
"chinese": "中文",
|
|
||||||
"english": "英语",
|
|
||||||
"japanese": "日语",
|
|
||||||
"korean": "韩语",
|
|
||||||
"french": "法语",
|
|
||||||
"german": "德语",
|
|
||||||
"italian": "意大利语",
|
|
||||||
"spanish": "西班牙语",
|
|
||||||
"portuguese": "葡萄牙语",
|
|
||||||
"russian": "俄语"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"detectLanguage": "检测语言",
|
"detectLanguage": "检测语言",
|
||||||
"sourceLanguage": "源语言",
|
|
||||||
"auto": "自动",
|
|
||||||
"generateIPA": "生成国际音标",
|
"generateIPA": "生成国际音标",
|
||||||
"translateInto": "翻译为",
|
"translateInto": "翻译为",
|
||||||
"chinese": "中文",
|
"chinese": "中文",
|
||||||
"english": "英文",
|
"english": "英文",
|
||||||
"french": "法语",
|
|
||||||
"german": "德语",
|
|
||||||
"italian": "意大利语",
|
"italian": "意大利语",
|
||||||
"japanese": "日语",
|
|
||||||
"korean": "韩语",
|
|
||||||
"portuguese": "葡萄牙语",
|
|
||||||
"russian": "俄语",
|
|
||||||
"spanish": "西班牙语",
|
|
||||||
"other": "其他",
|
"other": "其他",
|
||||||
"translating": "翻译中...",
|
"translating": "翻译中...",
|
||||||
"translate": "翻译",
|
"translate": "翻译",
|
||||||
@@ -471,160 +218,6 @@
|
|||||||
"success": "文本对已添加到文件夹",
|
"success": "文本对已添加到文件夹",
|
||||||
"error": "添加文本对到文件夹失败"
|
"error": "添加文本对到文件夹失败"
|
||||||
},
|
},
|
||||||
"autoSave": "自动保存",
|
"autoSave": "自动保存"
|
||||||
"customLanguage": "或输入语言...",
|
|
||||||
"pleaseLogin": "请登录后保存卡片",
|
|
||||||
"pleaseCreateDeck": "请先创建卡组",
|
|
||||||
"noTranslationToSave": "没有可保存的翻译",
|
|
||||||
"noDeckSelected": "未选择卡组",
|
|
||||||
"saveAsCard": "保存为卡片",
|
|
||||||
"selectDeck": "选择卡组",
|
|
||||||
"front": "正面",
|
|
||||||
"back": "背面",
|
|
||||||
"cancel": "取消",
|
|
||||||
"save": "保存",
|
|
||||||
"savedToDeck": "已保存到 {deckName}",
|
|
||||||
"saveFailed": "保存失败"
|
|
||||||
},
|
|
||||||
"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": "加载中...",
|
|
||||||
"noDecks": "暂无公开卡组",
|
|
||||||
"deckInfo": "{userName} · {totalCards} 张",
|
|
||||||
"unknownUser": "未知用户",
|
|
||||||
"favorite": "收藏",
|
|
||||||
"unfavorite": "取消收藏",
|
|
||||||
"pleaseLogin": "请先登录",
|
|
||||||
"sortByFavorites": "按收藏数排序",
|
|
||||||
"sortByFavoritesActive": "取消按收藏数排序"
|
|
||||||
},
|
|
||||||
"exploreDetail": {
|
|
||||||
"title": "牌组详情",
|
|
||||||
"createdBy": "创建者:{name}",
|
|
||||||
"unknownUser": "未知用户",
|
|
||||||
"totalCards": "共 {count} 张",
|
|
||||||
"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": "注册时间",
|
|
||||||
"joined": "注册于",
|
|
||||||
"logout": "登出",
|
|
||||||
"deleteAccount": {
|
|
||||||
"button": "注销账号",
|
|
||||||
"title": "注销账号",
|
|
||||||
"warning": "此操作不可逆,您的所有数据将被永久删除。",
|
|
||||||
"warningDecks": "您的所有牌组和卡片",
|
|
||||||
"warningCards": "您的所有学习进度",
|
|
||||||
"warningHistory": "您的所有翻译和词典历史",
|
|
||||||
"warningPermanent": "此操作无法撤销",
|
|
||||||
"confirmLabel": "输入您的用户名以确认:",
|
|
||||||
"usernameMismatch": "用户名不匹配",
|
|
||||||
"cancel": "取消",
|
|
||||||
"confirm": "注销我的账号",
|
|
||||||
"success": "账号已成功注销",
|
|
||||||
"failed": "注销账号失败"
|
|
||||||
},
|
|
||||||
"decks": {
|
|
||||||
"title": "牌组",
|
|
||||||
"noDecks": "还没有牌组",
|
|
||||||
"deckName": "牌组名称",
|
|
||||||
"totalCards": "卡片数量",
|
|
||||||
"createdAt": "创建时间",
|
|
||||||
"actions": "操作",
|
|
||||||
"view": "查看"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"decks": {
|
|
||||||
"title": "牌组",
|
|
||||||
"subtitle": "管理你的学习卡组",
|
|
||||||
"newDeck": "新建卡组",
|
|
||||||
"noDecksYet": "暂无卡组",
|
|
||||||
"loading": "加载中...",
|
|
||||||
"deckInfo": "ID: {id} · {totalCards} 张",
|
|
||||||
"enterDeckName": "输入卡组名称:",
|
|
||||||
"enterNewName": "输入新名称:",
|
|
||||||
"confirmDelete": "输入 \"{name}\" 确认删除:",
|
|
||||||
"public": "公开",
|
|
||||||
"private": "私有",
|
|
||||||
"setPublic": "设为公开",
|
|
||||||
"setPrivate": "设为私有",
|
|
||||||
"importApkg": "导入 APKG",
|
|
||||||
"exportApkg": "导出 APKG",
|
|
||||||
"clickToUpload": "点击上传",
|
|
||||||
"apkgFilesOnly": "仅支持 .apkg 文件",
|
|
||||||
"parsing": "解析中...",
|
|
||||||
"foundDecks": "发现 {count} 个卡组",
|
|
||||||
"deckName": "卡组名称",
|
|
||||||
"back": "返回",
|
|
||||||
"import": "导入",
|
|
||||||
"importing": "导入中...",
|
|
||||||
"exportSuccess": "导出成功",
|
|
||||||
"goToDecks": "前往卡组"
|
|
||||||
},
|
|
||||||
"follow": {
|
|
||||||
"follow": "关注",
|
|
||||||
"following": "已关注",
|
|
||||||
"followers": "粉丝",
|
|
||||||
"followersOf": "{username} 的粉丝",
|
|
||||||
"followingOf": "{username} 的关注",
|
|
||||||
"noFollowers": "还没有粉丝",
|
|
||||||
"noFollowing": "还没有关注任何人"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
42
package.json
42
package.json
@@ -2,7 +2,7 @@
|
|||||||
"name": "learn-languages",
|
"name": "learn-languages",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --experimental-https",
|
"dev": "next dev --experimental-https",
|
||||||
@@ -11,46 +11,36 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.4.2",
|
"@prisma/adapter-pg": "^7.1.0",
|
||||||
"@prisma/client": "7.4.2",
|
"@prisma/client": "^7.1.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-auth": "^1.4.10",
|
"better-auth": "^1.4.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"jszip": "^3.10.1",
|
"edge-tts-universal": "^1.3.3",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.561.0",
|
||||||
"next": "16.1.1",
|
"next": "16.0.10",
|
||||||
"next-intl": "^4.7.0",
|
"next-intl": "^4.5.8",
|
||||||
"nodemailer": "^8.0.2",
|
|
||||||
"openai": "^6.27.0",
|
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"sql.js": "^1.14.1",
|
|
||||||
"tailwind-merge": "^3.4.0",
|
|
||||||
"unstorage": "^1.17.3",
|
"unstorage": "^1.17.3",
|
||||||
"winston": "^3.19.0",
|
"zod": "^4.1.13"
|
||||||
"zod": "^4.3.5",
|
|
||||||
"zustand": "^5.0.11"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@better-auth/cli": "^1.4.10",
|
"@better-auth/cli": "^1.4.6",
|
||||||
"@eslint/eslintrc": "^3.3.3",
|
"@eslint/eslintrc": "^3.3.3",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/node": "^25.0.3",
|
"@types/node": "^25.0.1",
|
||||||
"@types/nodemailer": "^7.0.11",
|
|
||||||
"@types/react": "19.2.7",
|
"@types/react": "19.2.7",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@types/sql.js": "^1.4.9",
|
"@typescript-eslint/eslint-plugin": "^8.49.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.51.0",
|
"@typescript-eslint/parser": "^8.49.0",
|
||||||
"@typescript-eslint/parser": "^8.51.0",
|
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.1",
|
||||||
"eslint-config-next": "16.1.1",
|
"eslint-config-next": "16.0.10",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"prisma": "^7.4.2",
|
"prisma": "^7.1.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
|
|||||||
1616
pnpm-lock.yaml
generated
1616
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
120
prisma/migrations/20251210105812_init/migration.sql
Normal file
120
prisma/migrations/20251210105812_init/migration.sql
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "pairs" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"locale1" VARCHAR(10) NOT NULL,
|
||||||
|
"locale2" VARCHAR(10) NOT NULL,
|
||||||
|
"text1" TEXT NOT NULL,
|
||||||
|
"text2" TEXT NOT NULL,
|
||||||
|
"ipa1" TEXT,
|
||||||
|
"ipa2" TEXT,
|
||||||
|
"folder_id" INTEGER NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "pairs_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "folders" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "folders_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "user" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"image" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "session" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"ipAddress" TEXT,
|
||||||
|
"userAgent" TEXT,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "account" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"providerId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"accessToken" TEXT,
|
||||||
|
"refreshToken" TEXT,
|
||||||
|
"idToken" TEXT,
|
||||||
|
"accessTokenExpiresAt" TIMESTAMP(3),
|
||||||
|
"refreshTokenExpiresAt" TIMESTAMP(3),
|
||||||
|
"scope" TEXT,
|
||||||
|
"password" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "verification" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"identifier" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "pairs_folder_id_idx" ON "pairs"("folder_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "pairs_folder_id_locale1_locale2_text1_key" ON "pairs"("folder_id", "locale1", "locale2", "text1");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "folders_user_id_idx" ON "folders"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "session_userId_idx" ON "session"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "account_userId_idx" ON "account"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "verification_identifier_idx" ON "verification"("identifier");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "pairs" ADD CONSTRAINT "pairs_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "folders" ADD CONSTRAINT "folders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -1,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;
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "user" ADD COLUMN "bio" TEXT;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "follows" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"follower_id" TEXT NOT NULL,
|
|
||||||
"following_id" TEXT NOT NULL,
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "follows_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "follows_follower_id_idx" ON "follows"("follower_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "follows_following_id_idx" ON "follows"("following_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "follows_follower_id_following_id_key" ON "follows"("follower_id", "following_id");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "follows" ADD CONSTRAINT "follows_follower_id_fkey" FOREIGN KEY ("follower_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "follows" ADD CONSTRAINT "follows_following_id_fkey" FOREIGN KEY ("following_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- You are about to drop the `folder_favorites` table. If the table is not empty, all the data it contains will be lost.
|
|
||||||
- You are about to drop the `folders` table. If the table is not empty, all the data it contains will be lost.
|
|
||||||
- You are about to drop the `pairs` table. If the table is not empty, all the data it contains will be lost.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- CreateEnum
|
|
||||||
CREATE TYPE "CardType" AS ENUM ('NEW', 'LEARNING', 'REVIEW', 'RELEARNING');
|
|
||||||
|
|
||||||
-- CreateEnum
|
|
||||||
CREATE TYPE "CardQueue" AS ENUM ('USER_BURIED', 'SCHED_BURIED', 'SUSPENDED', 'NEW', 'LEARNING', 'REVIEW', 'IN_LEARNING', 'PREVIEW');
|
|
||||||
|
|
||||||
-- CreateEnum
|
|
||||||
CREATE TYPE "NoteKind" AS ENUM ('STANDARD', 'CLOZE');
|
|
||||||
|
|
||||||
-- DropForeignKey
|
|
||||||
ALTER TABLE "folder_favorites" DROP CONSTRAINT "folder_favorites_folder_id_fkey";
|
|
||||||
|
|
||||||
-- DropForeignKey
|
|
||||||
ALTER TABLE "folder_favorites" DROP CONSTRAINT "folder_favorites_user_id_fkey";
|
|
||||||
|
|
||||||
-- DropForeignKey
|
|
||||||
ALTER TABLE "folders" DROP CONSTRAINT "folders_user_id_fkey";
|
|
||||||
|
|
||||||
-- DropForeignKey
|
|
||||||
ALTER TABLE "pairs" DROP CONSTRAINT "pairs_folder_id_fkey";
|
|
||||||
|
|
||||||
-- DropTable
|
|
||||||
DROP TABLE "folder_favorites";
|
|
||||||
|
|
||||||
-- DropTable
|
|
||||||
DROP TABLE "folders";
|
|
||||||
|
|
||||||
-- DropTable
|
|
||||||
DROP TABLE "pairs";
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "note_types" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"kind" "NoteKind" NOT NULL DEFAULT 'STANDARD',
|
|
||||||
"css" TEXT NOT NULL DEFAULT '',
|
|
||||||
"fields" JSONB NOT NULL DEFAULT '[]',
|
|
||||||
"templates" JSONB NOT NULL DEFAULT '[]',
|
|
||||||
"user_id" TEXT NOT NULL,
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "note_types_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "decks" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"desc" TEXT NOT NULL DEFAULT '',
|
|
||||||
"user_id" TEXT NOT NULL,
|
|
||||||
"visibility" "Visibility" NOT NULL DEFAULT 'PRIVATE',
|
|
||||||
"collapsed" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"conf" JSONB NOT NULL DEFAULT '{}',
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "decks_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "deck_favorites" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"user_id" TEXT NOT NULL,
|
|
||||||
"deck_id" INTEGER NOT NULL,
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "deck_favorites_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "notes" (
|
|
||||||
"id" BIGINT NOT NULL,
|
|
||||||
"guid" TEXT NOT NULL,
|
|
||||||
"note_type_id" INTEGER NOT NULL,
|
|
||||||
"mod" INTEGER NOT NULL,
|
|
||||||
"usn" INTEGER NOT NULL DEFAULT -1,
|
|
||||||
"tags" TEXT NOT NULL DEFAULT ' ',
|
|
||||||
"flds" TEXT NOT NULL,
|
|
||||||
"sfld" TEXT NOT NULL,
|
|
||||||
"csum" INTEGER NOT NULL,
|
|
||||||
"flags" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"data" TEXT NOT NULL DEFAULT '',
|
|
||||||
"user_id" TEXT NOT NULL,
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "notes_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "cards" (
|
|
||||||
"id" BIGINT NOT NULL,
|
|
||||||
"note_id" BIGINT NOT NULL,
|
|
||||||
"deck_id" INTEGER NOT NULL,
|
|
||||||
"ord" INTEGER NOT NULL,
|
|
||||||
"mod" INTEGER NOT NULL,
|
|
||||||
"usn" INTEGER NOT NULL DEFAULT -1,
|
|
||||||
"type" "CardType" NOT NULL DEFAULT 'NEW',
|
|
||||||
"queue" "CardQueue" NOT NULL DEFAULT 'NEW',
|
|
||||||
"due" INTEGER NOT NULL,
|
|
||||||
"ivl" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"factor" INTEGER NOT NULL DEFAULT 2500,
|
|
||||||
"reps" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"lapses" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"left" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"odue" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"odid" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"flags" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"data" TEXT NOT NULL DEFAULT '',
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "cards_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "revlogs" (
|
|
||||||
"id" BIGINT NOT NULL,
|
|
||||||
"card_id" BIGINT NOT NULL,
|
|
||||||
"usn" INTEGER NOT NULL DEFAULT -1,
|
|
||||||
"ease" INTEGER NOT NULL,
|
|
||||||
"ivl" INTEGER NOT NULL,
|
|
||||||
"lastIvl" INTEGER NOT NULL,
|
|
||||||
"factor" INTEGER NOT NULL,
|
|
||||||
"time" INTEGER NOT NULL,
|
|
||||||
"type" INTEGER NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "revlogs_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "note_types_user_id_idx" ON "note_types"("user_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "decks_user_id_idx" ON "decks"("user_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "decks_visibility_idx" ON "decks"("visibility");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "deck_favorites_user_id_idx" ON "deck_favorites"("user_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "deck_favorites_deck_id_idx" ON "deck_favorites"("deck_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "deck_favorites_user_id_deck_id_key" ON "deck_favorites"("user_id", "deck_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "notes_guid_key" ON "notes"("guid");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "notes_user_id_idx" ON "notes"("user_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "notes_note_type_id_idx" ON "notes"("note_type_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "notes_csum_idx" ON "notes"("csum");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "cards_note_id_idx" ON "cards"("note_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "cards_deck_id_idx" ON "cards"("deck_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "cards_deck_id_queue_due_idx" ON "cards"("deck_id", "queue", "due");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "revlogs_card_id_idx" ON "revlogs"("card_id");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "note_types" ADD CONSTRAINT "note_types_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "decks" ADD CONSTRAINT "decks_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "deck_favorites" ADD CONSTRAINT "deck_favorites_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "deck_favorites" ADD CONSTRAINT "deck_favorites_deck_id_fkey" FOREIGN KEY ("deck_id") REFERENCES "decks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "notes" ADD CONSTRAINT "notes_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "notes" ADD CONSTRAINT "notes_note_type_id_fkey" FOREIGN KEY ("note_type_id") REFERENCES "note_types"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "cards" ADD CONSTRAINT "cards_note_id_fkey" FOREIGN KEY ("note_id") REFERENCES "notes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "cards" ADD CONSTRAINT "cards_deck_id_fkey" FOREIGN KEY ("deck_id") REFERENCES "decks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "revlogs" ADD CONSTRAINT "revlogs_card_id_fkey" FOREIGN KEY ("card_id") REFERENCES "cards"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "decks" ADD COLUMN "new_per_day" INTEGER NOT NULL DEFAULT 20,
|
|
||||||
ADD COLUMN "rev_per_day" INTEGER NOT NULL DEFAULT 200;
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client"
|
provider = "prisma-client"
|
||||||
output = "../generated/prisma"
|
output = "../generated/prisma"
|
||||||
@@ -7,35 +8,59 @@ datasource db {
|
|||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
model Pair {
|
||||||
// User & Auth
|
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 {
|
model User {
|
||||||
id String @id
|
id String @id
|
||||||
name String
|
name String
|
||||||
email String @unique
|
email String
|
||||||
emailVerified Boolean @default(false)
|
emailVerified Boolean @default(false)
|
||||||
image String?
|
image String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
displayUsername String?
|
sessions Session[]
|
||||||
username String @unique
|
accounts Account[]
|
||||||
bio String?
|
folders Folder[]
|
||||||
accounts Account[]
|
|
||||||
decks Deck[]
|
|
||||||
deckFavorites DeckFavorite[]
|
|
||||||
sessions Session[]
|
|
||||||
followers Follow[] @relation("UserFollowers")
|
|
||||||
following Follow[] @relation("UserFollowing")
|
|
||||||
|
|
||||||
|
@@unique([email])
|
||||||
@@map("user")
|
@@map("user")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
id String @id
|
id String @id
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
token String @unique
|
token String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
ipAddress String?
|
ipAddress String?
|
||||||
@@ -43,6 +68,7 @@ model Session {
|
|||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([token])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@map("session")
|
@@map("session")
|
||||||
}
|
}
|
||||||
@@ -52,6 +78,7 @@ model Account {
|
|||||||
accountId String
|
accountId String
|
||||||
providerId String
|
providerId String
|
||||||
userId String
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
accessToken String?
|
accessToken String?
|
||||||
refreshToken String?
|
refreshToken String?
|
||||||
idToken String?
|
idToken String?
|
||||||
@@ -61,7 +88,6 @@ model Account {
|
|||||||
password String?
|
password String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@map("account")
|
@@map("account")
|
||||||
@@ -78,99 +104,3 @@ model Verification {
|
|||||||
@@index([identifier])
|
@@index([identifier])
|
||||||
@@map("verification")
|
@@map("verification")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Deck & Card
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
enum Visibility {
|
|
||||||
PUBLIC
|
|
||||||
PRIVATE
|
|
||||||
}
|
|
||||||
|
|
||||||
enum CardType {
|
|
||||||
WORD
|
|
||||||
PHRASE
|
|
||||||
SENTENCE
|
|
||||||
}
|
|
||||||
|
|
||||||
model Deck {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
name String
|
|
||||||
desc String @db.Text @default("")
|
|
||||||
userId String
|
|
||||||
visibility Visibility @default(PRIVATE)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
cards Card[]
|
|
||||||
favorites DeckFavorite[]
|
|
||||||
|
|
||||||
@@index([userId])
|
|
||||||
@@index([visibility])
|
|
||||||
@@map("decks")
|
|
||||||
}
|
|
||||||
|
|
||||||
model Card {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
deckId Int
|
|
||||||
word String
|
|
||||||
ipa String?
|
|
||||||
queryLang String
|
|
||||||
cardType CardType @default(WORD)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
|
|
||||||
meanings CardMeaning[]
|
|
||||||
|
|
||||||
@@index([deckId])
|
|
||||||
@@index([word])
|
|
||||||
@@map("cards")
|
|
||||||
}
|
|
||||||
|
|
||||||
model CardMeaning {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
cardId Int
|
|
||||||
partOfSpeech String?
|
|
||||||
definition String
|
|
||||||
example String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([cardId])
|
|
||||||
@@map("card_meanings")
|
|
||||||
}
|
|
||||||
|
|
||||||
model DeckFavorite {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
userId String
|
|
||||||
deckId Int
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([userId, deckId])
|
|
||||||
@@index([userId])
|
|
||||||
@@index([deckId])
|
|
||||||
@@map("deck_favorites")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Social
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
model Follow {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
followerId String @map("follower_id")
|
|
||||||
followingId String @map("following_id")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
|
|
||||||
follower User @relation("UserFollowers", fields: [followerId], references: [id], onDelete: Cascade)
|
|
||||||
following User @relation("UserFollowing", fields: [followingId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([followerId, followingId])
|
|
||||||
@@index([followerId])
|
|
||||||
@@index([followingId])
|
|
||||||
@@map("follows")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
/**
|
|
||||||
* 查找缺失的翻译键
|
|
||||||
* 用法: npx tsx scripts/find-missing-translations.ts [locale]
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as fs from "fs";
|
|
||||||
import * as path from "path";
|
|
||||||
|
|
||||||
const SRC_DIR = "./src";
|
|
||||||
const MESSAGES_DIR = "./messages";
|
|
||||||
const ALL_LOCALES = ["en-US", "zh-CN", "ja-JP", "ko-KR", "de-DE", "fr-FR", "it-IT", "ug-CN"];
|
|
||||||
|
|
||||||
function parseString(s: string): string | null {
|
|
||||||
s = s.trim();
|
|
||||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
||||||
return s.slice(1, -1);
|
|
||||||
}
|
|
||||||
if (s.startsWith("`") && s.endsWith("`") && !s.includes("${")) {
|
|
||||||
return s.slice(1, -1);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBindings(content: string): Map<string, string> {
|
|
||||||
const bindings = new Map<string, string>();
|
|
||||||
const pattern = /(?:const|let|var)\s+(\w+)\s*=\s*(?:await\s+)?(?:useTranslations|getTranslations)\s*\(\s*([^)]*)\s*\)/g;
|
|
||||||
|
|
||||||
let match;
|
|
||||||
while ((match = pattern.exec(content)) !== null) {
|
|
||||||
const varName = match[1];
|
|
||||||
const arg = match[2].trim();
|
|
||||||
bindings.set(varName, arg ? parseString(arg) || "" : "__ROOT__");
|
|
||||||
}
|
|
||||||
|
|
||||||
return bindings;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUsages(content: string, file: string): { file: string; line: number; ns: string; key: string }[] {
|
|
||||||
const usages: { file: string; line: number; ns: string; key: string }[] = [];
|
|
||||||
const bindings = getBindings(content);
|
|
||||||
const lines = content.split("\n");
|
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const line = lines[i];
|
|
||||||
for (const [varName, ns] of bindings) {
|
|
||||||
const pattern = new RegExp(`\\b${varName}\\s*\\(\\s*("[^"]*"|'[^']*'|\`[^\`]*\`)(?:\\s*,|\\s*\\))`, "g");
|
|
||||||
let match;
|
|
||||||
while ((match = pattern.exec(line)) !== null) {
|
|
||||||
const key = parseString(match[1]);
|
|
||||||
if (key) usages.push({ file, line: i + 1, ns, key });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return usages;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFiles(dir: string): string[] {
|
|
||||||
const files: string[] = [];
|
|
||||||
if (!fs.existsSync(dir)) return files;
|
|
||||||
|
|
||||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
||||||
const p = path.join(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) files.push(...getFiles(p));
|
|
||||||
else if (entry.isFile() && /\.(tsx?|ts)$/.test(entry.name)) files.push(p);
|
|
||||||
}
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
function keyExists(key: string, ns: string, trans: Record<string, unknown>): boolean {
|
|
||||||
let obj: unknown;
|
|
||||||
|
|
||||||
if (ns === "__ROOT__") {
|
|
||||||
obj = trans;
|
|
||||||
} else {
|
|
||||||
obj = trans[ns];
|
|
||||||
if (typeof obj !== "object" || obj === null) {
|
|
||||||
obj = trans;
|
|
||||||
for (const part of ns.split(".")) {
|
|
||||||
if (typeof obj !== "object" || obj === null) return false;
|
|
||||||
obj = (obj as Record<string, unknown>)[part];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof obj !== "object" || obj === null) return false;
|
|
||||||
|
|
||||||
for (const part of key.split(".")) {
|
|
||||||
if (typeof obj !== "object" || obj === null) return false;
|
|
||||||
obj = (obj as Record<string, unknown>)[part];
|
|
||||||
}
|
|
||||||
|
|
||||||
return typeof obj === "string";
|
|
||||||
}
|
|
||||||
|
|
||||||
function main() {
|
|
||||||
const locales = process.argv[2] ? [process.argv[2]] : ALL_LOCALES;
|
|
||||||
|
|
||||||
const files = getFiles(SRC_DIR);
|
|
||||||
const usages: { file: string; line: number; ns: string; key: string }[] = [];
|
|
||||||
|
|
||||||
for (const f of files) {
|
|
||||||
usages.push(...getUsages(fs.readFileSync(f, "utf-8"), f));
|
|
||||||
}
|
|
||||||
|
|
||||||
const unique = new Map<string, { file: string; line: number; ns: string; key: string }>();
|
|
||||||
for (const u of usages) {
|
|
||||||
unique.set(`${u.file}:${u.line}:${u.ns}:${u.key}`, u);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Scanned ${files.length} files, ${unique.size} usages\n`);
|
|
||||||
|
|
||||||
for (const locale of locales) {
|
|
||||||
console.log(`\n${"=".repeat(50)}\nLocale: ${locale}\n${"=".repeat(50)}`);
|
|
||||||
|
|
||||||
const filePath = path.join(MESSAGES_DIR, `${locale}.json`);
|
|
||||||
if (!fs.existsSync(filePath)) {
|
|
||||||
console.log(`File not found: ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trans = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
||||||
const missing = Array.from(unique.values()).filter(u => !keyExists(u.key, u.ns, trans));
|
|
||||||
|
|
||||||
if (missing.length === 0) {
|
|
||||||
console.log("All translations exist!");
|
|
||||||
} else {
|
|
||||||
console.log(`\nMissing ${missing.length} translations:\n`);
|
|
||||||
const byFile = new Map<string, typeof missing>();
|
|
||||||
for (const u of missing) {
|
|
||||||
if (!byFile.has(u.file)) byFile.set(u.file, []);
|
|
||||||
byFile.get(u.file)!.push(u);
|
|
||||||
}
|
|
||||||
for (const [file, list] of byFile) {
|
|
||||||
console.log(file);
|
|
||||||
for (const u of list) {
|
|
||||||
console.log(` L${u.line} [${u.ns === "__ROOT__" ? "root" : u.ns}] ${u.key}`);
|
|
||||||
}
|
|
||||||
console.log();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("\nDone!");
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
/**
|
|
||||||
* 查找多余的翻译键
|
|
||||||
* 用法: npx tsx scripts/find-unused-translations.ts [locale]
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as fs from "fs";
|
|
||||||
import * as path from "path";
|
|
||||||
|
|
||||||
const SRC_DIR = "./src";
|
|
||||||
const MESSAGES_DIR = "./messages";
|
|
||||||
const ALL_LOCALES = ["en-US", "zh-CN", "ja-JP", "ko-KR", "de-DE", "fr-FR", "it-IT", "ug-CN"];
|
|
||||||
|
|
||||||
function parseString(s: string): string | null {
|
|
||||||
s = s.trim();
|
|
||||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
||||||
return s.slice(1, -1);
|
|
||||||
}
|
|
||||||
if (s.startsWith("`") && s.endsWith("`") && !s.includes("${")) {
|
|
||||||
return s.slice(1, -1);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBindings(content: string): Map<string, string> {
|
|
||||||
const bindings = new Map<string, string>();
|
|
||||||
const pattern = /(?:const|let|var)\s+(\w+)\s*=\s*(?:await\s+)?(?:useTranslations|getTranslations)\s*\(\s*([^)]*)\s*\)/g;
|
|
||||||
|
|
||||||
let match;
|
|
||||||
while ((match = pattern.exec(content)) !== null) {
|
|
||||||
const varName = match[1];
|
|
||||||
const arg = match[2].trim();
|
|
||||||
bindings.set(varName, arg ? parseString(arg) || "" : "__ROOT__");
|
|
||||||
}
|
|
||||||
|
|
||||||
return bindings;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUsedKeys(content: string): Map<string, Set<string>> {
|
|
||||||
const used = new Map<string, Set<string>>();
|
|
||||||
const bindings = getBindings(content);
|
|
||||||
|
|
||||||
for (const [varName, ns] of bindings) {
|
|
||||||
const pattern = new RegExp(`\\b${varName}\\s*\\(\\s*("[^"]*"|'[^']*'|\`[^\`]*\`)(?:\\s*,|\\s*\\))`, "g");
|
|
||||||
let match;
|
|
||||||
while ((match = pattern.exec(content)) !== null) {
|
|
||||||
const key = parseString(match[1]);
|
|
||||||
if (key) {
|
|
||||||
if (!used.has(ns)) used.set(ns, new Set());
|
|
||||||
used.get(ns)!.add(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return used;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFiles(dir: string): string[] {
|
|
||||||
const files: string[] = [];
|
|
||||||
if (!fs.existsSync(dir)) return files;
|
|
||||||
|
|
||||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
||||||
const p = path.join(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) files.push(...getFiles(p));
|
|
||||||
else if (entry.isFile() && /\.(tsx?|ts)$/.test(entry.name)) files.push(p);
|
|
||||||
}
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
function flattenKeys(obj: Record<string, unknown>, prefix = ""): string[] {
|
|
||||||
const keys: string[] = [];
|
|
||||||
for (const key of Object.keys(obj)) {
|
|
||||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
||||||
if (typeof obj[key] === "object" && obj[key] !== null) {
|
|
||||||
keys.push(...flattenKeys(obj[key] as Record<string, unknown>, fullKey));
|
|
||||||
} else if (typeof obj[key] === "string") {
|
|
||||||
keys.push(fullKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isUsed(fullKey: string, used: Map<string, Set<string>>): boolean {
|
|
||||||
const parts = fullKey.split(".");
|
|
||||||
|
|
||||||
for (let i = 1; i < parts.length; i++) {
|
|
||||||
const ns = parts.slice(0, i).join(".");
|
|
||||||
const key = parts.slice(i).join(".");
|
|
||||||
|
|
||||||
const nsKeys = used.get(ns);
|
|
||||||
if (nsKeys) {
|
|
||||||
if (nsKeys.has(key)) return true;
|
|
||||||
for (const k of nsKeys) {
|
|
||||||
if (key.startsWith(k + ".")) return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootKeys = used.get("__ROOT__");
|
|
||||||
return rootKeys?.has(fullKey) ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function main() {
|
|
||||||
const locales = process.argv[2] ? [process.argv[2]] : ALL_LOCALES;
|
|
||||||
|
|
||||||
const files = getFiles(SRC_DIR);
|
|
||||||
const allUsed = new Map<string, Set<string>>();
|
|
||||||
|
|
||||||
for (const f of files) {
|
|
||||||
const used = getUsedKeys(fs.readFileSync(f, "utf-8"));
|
|
||||||
for (const [ns, keys] of used) {
|
|
||||||
if (!allUsed.has(ns)) allUsed.set(ns, new Set());
|
|
||||||
for (const k of keys) allUsed.get(ns)!.add(k);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Scanned ${files.length} files, ${allUsed.size} namespaces\n`);
|
|
||||||
|
|
||||||
for (const locale of locales) {
|
|
||||||
console.log(`\n${"=".repeat(50)}\nLocale: ${locale}\n${"=".repeat(50)}`);
|
|
||||||
|
|
||||||
const filePath = path.join(MESSAGES_DIR, `${locale}.json`);
|
|
||||||
if (!fs.existsSync(filePath)) {
|
|
||||||
console.log(`File not found: ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trans = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
||||||
const allKeys = flattenKeys(trans);
|
|
||||||
const unused = allKeys.filter(k => !isUsed(k, allUsed));
|
|
||||||
|
|
||||||
console.log(`Total: ${allKeys.length} keys`);
|
|
||||||
|
|
||||||
if (unused.length === 0) {
|
|
||||||
console.log("No unused translations!");
|
|
||||||
} else {
|
|
||||||
console.log(`\n${unused.length} potentially unused:\n`);
|
|
||||||
const grouped = new Map<string, string[]>();
|
|
||||||
for (const k of unused) {
|
|
||||||
const [ns, ...rest] = k.split(".");
|
|
||||||
if (!grouped.has(ns)) grouped.set(ns, []);
|
|
||||||
grouped.get(ns)!.push(rest.join("."));
|
|
||||||
}
|
|
||||||
for (const [ns, keys] of grouped) {
|
|
||||||
console.log(`${ns}`);
|
|
||||||
for (const k of keys) console.log(` ${k}`);
|
|
||||||
console.log();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("\nDone!");
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
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";
|
|
||||||
import { actionRequestPasswordReset } from "@/modules/auth/forgot-password-action";
|
|
||||||
|
|
||||||
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 result = await actionRequestPasswordReset({ email });
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
toast.error(result.message);
|
|
||||||
} else {
|
|
||||||
setSent(true);
|
|
||||||
toast.success(result.message);
|
|
||||||
}
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,163 +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, LinkButton } 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 [resendLoading, setResendLoading] = useState(false);
|
|
||||||
const [showResendOption, setShowResendOption] = useState(false);
|
|
||||||
const [unverifiedEmail, setUnverifiedEmail] = useState("");
|
|
||||||
|
|
||||||
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("/decks");
|
|
||||||
}
|
|
||||||
}, [session, isPending, router, redirectTo]);
|
|
||||||
|
|
||||||
const handleResendVerification = async () => {
|
|
||||||
if (!unverifiedEmail) return;
|
|
||||||
|
|
||||||
setResendLoading(true);
|
|
||||||
try {
|
|
||||||
const { error } = await authClient.sendVerificationEmail({
|
|
||||||
email: unverifiedEmail,
|
|
||||||
callbackURL: "/login",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
toast.error(t("resendFailed"));
|
|
||||||
} else {
|
|
||||||
toast.success(t("resendSuccess"));
|
|
||||||
setShowResendOption(false);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setResendLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
if (!username || !password) {
|
|
||||||
toast.error(t("enterCredentials"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setShowResendOption(false);
|
|
||||||
try {
|
|
||||||
if (username.includes("@")) {
|
|
||||||
const { error } = await authClient.signIn.email({
|
|
||||||
email: username,
|
|
||||||
password: password,
|
|
||||||
});
|
|
||||||
if (error) {
|
|
||||||
if (error.status === 403) {
|
|
||||||
setUnverifiedEmail(username);
|
|
||||||
setShowResendOption(true);
|
|
||||||
toast.error(t("emailNotVerified"));
|
|
||||||
} else {
|
|
||||||
toast.error(error.message ?? t("loginFailed"));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const { error } = await authClient.signIn.username({
|
|
||||||
username: username,
|
|
||||||
password: password,
|
|
||||||
});
|
|
||||||
if (error) {
|
|
||||||
if (error.status === 403) {
|
|
||||||
toast.error(t("emailNotVerified"));
|
|
||||||
} else {
|
|
||||||
toast.error(error.message ?? t("loginFailed"));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
router.push(redirectTo ?? "/decks");
|
|
||||||
} 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>
|
|
||||||
|
|
||||||
{showResendOption && (
|
|
||||||
<div className="w-full p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg text-sm">
|
|
||||||
<p className="text-yellow-800 dark:text-yellow-200 mb-2">
|
|
||||||
{t("emailNotVerifiedHint")}
|
|
||||||
</p>
|
|
||||||
<LinkButton
|
|
||||||
onClick={handleResendVerification}
|
|
||||||
loading={resendLoading}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{t("resendVerification")}
|
|
||||||
</LinkButton>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (<></>);
|
|
||||||
}
|
|
||||||
@@ -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}` : "/decks");
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,133 +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 [verificationSent, setVerificationSent] = 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 && !verificationSent) {
|
|
||||||
router.push("/decks");
|
|
||||||
}
|
|
||||||
}, [session, isPending, router, redirectTo, verificationSent]);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
setVerificationSent(true);
|
|
||||||
toast.success(t("verificationEmailSent"));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (verificationSent) {
|
|
||||||
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("verifyYourEmail")}
|
|
||||||
</h1>
|
|
||||||
<p className="text-center text-gray-600">
|
|
||||||
{t("verificationEmailSentHint", { email })}
|
|
||||||
</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("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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Button } from "@/design-system/base/button";
|
|
||||||
import { Modal } from "@/design-system/overlay/modal";
|
|
||||||
import { actionDeleteAccount } from "@/modules/auth/auth-action";
|
|
||||||
|
|
||||||
interface DeleteAccountButtonProps {
|
|
||||||
username: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DeleteAccountButton({ username }: DeleteAccountButtonProps) {
|
|
||||||
const t = useTranslations("user_profile");
|
|
||||||
const router = useRouter();
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
|
||||||
const [confirmUsername, setConfirmUsername] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (confirmUsername !== username) {
|
|
||||||
toast.error(t("deleteAccount.usernameMismatch"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const result = await actionDeleteAccount();
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(t("deleteAccount.success"));
|
|
||||||
router.push("/");
|
|
||||||
} else {
|
|
||||||
toast.error(result.message || t("deleteAccount.failed"));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error(t("deleteAccount.failed"));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
setShowModal(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowModal(true)}
|
|
||||||
className="text-xs text-gray-400 hover:text-red-500 transition-colors"
|
|
||||||
>
|
|
||||||
{t("deleteAccount.button")}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Modal open={showModal} onClose={() => setShowModal(false)}>
|
|
||||||
<div className="p-6">
|
|
||||||
<h2 className="text-xl font-bold text-red-600 mb-4">
|
|
||||||
{t("deleteAccount.title")}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-gray-700">
|
|
||||||
{t("deleteAccount.warning")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ul className="list-disc list-inside text-gray-600 text-sm space-y-1">
|
|
||||||
<li>{t("deleteAccount.warningDecks")}</li>
|
|
||||||
<li>{t("deleteAccount.warningCards")}</li>
|
|
||||||
<li>{t("deleteAccount.warningHistory")}</li>
|
|
||||||
<li>{t("deleteAccount.warningPermanent")}</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
{t("deleteAccount.confirmLabel")} <span className="font-mono font-bold">{username}</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={confirmUsername}
|
|
||||||
onChange={(e) => setConfirmUsername(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500"
|
|
||||||
placeholder={username}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 mt-6">
|
|
||||||
<Button variant="secondary" onClick={() => setShowModal(false)}>
|
|
||||||
{t("deleteAccount.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="error"
|
|
||||||
onClick={handleDelete}
|
|
||||||
loading={loading}
|
|
||||||
disabled={confirmUsername !== username}
|
|
||||||
>
|
|
||||||
{t("deleteAccount.confirm")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { notFound } from "next/navigation";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import { PageLayout } from "@/components/ui/PageLayout";
|
|
||||||
import { UserList } from "@/components/follow/UserList";
|
|
||||||
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
|
|
||||||
import { actionGetFollowers } from "@/modules/follow/follow-action";
|
|
||||||
|
|
||||||
interface FollowersPageProps {
|
|
||||||
params: Promise<{ username: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function FollowersPage({ params }: FollowersPageProps) {
|
|
||||||
const { username } = await params;
|
|
||||||
const t = await getTranslations("follow");
|
|
||||||
|
|
||||||
const userResult = await actionGetUserProfileByUsername({ username });
|
|
||||||
|
|
||||||
if (!userResult.success || !userResult.data) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = userResult.data;
|
|
||||||
|
|
||||||
const followersResult = await actionGetFollowers({
|
|
||||||
userId: user.id,
|
|
||||||
page: 1,
|
|
||||||
limit: 50,
|
|
||||||
});
|
|
||||||
|
|
||||||
const followers = followersResult.success && followersResult.data
|
|
||||||
? followersResult.data.followers.map((f) => f.user)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
<div className="bg-white rounded-lg shadow-md p-6">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">
|
|
||||||
{t("followersOf", { username: user.displayUsername || user.username || "User" })}
|
|
||||||
</h1>
|
|
||||||
<UserList users={followers} emptyMessage={t("noFollowers")} />
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { notFound } from "next/navigation";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import { PageLayout } from "@/components/ui/PageLayout";
|
|
||||||
import { UserList } from "@/components/follow/UserList";
|
|
||||||
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
|
|
||||||
import { actionGetFollowing } from "@/modules/follow/follow-action";
|
|
||||||
|
|
||||||
interface FollowingPageProps {
|
|
||||||
params: Promise<{ username: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function FollowingPage({ params }: FollowingPageProps) {
|
|
||||||
const { username } = await params;
|
|
||||||
const t = await getTranslations("follow");
|
|
||||||
|
|
||||||
const userResult = await actionGetUserProfileByUsername({ username });
|
|
||||||
|
|
||||||
if (!userResult.success || !userResult.data) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = userResult.data;
|
|
||||||
|
|
||||||
const followingResult = await actionGetFollowing({
|
|
||||||
userId: user.id,
|
|
||||||
page: 1,
|
|
||||||
limit: 50,
|
|
||||||
});
|
|
||||||
|
|
||||||
const following = followingResult.success && followingResult.data
|
|
||||||
? followingResult.data.following.map((f) => f.user)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
<div className="bg-white rounded-lg shadow-md p-6">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">
|
|
||||||
{t("followingOf", { username: user.displayUsername || user.username || "User" })}
|
|
||||||
</h1>
|
|
||||||
<UserList users={following} emptyMessage={t("noFollowing")} />
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { PageLayout } from "@/components/ui/PageLayout";
|
|
||||||
import { LinkButton } from "@/design-system/base/button";
|
|
||||||
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
|
|
||||||
import { repoGetDecksByUserId } from "@/modules/deck/deck-repository";
|
|
||||||
import { actionGetFollowStatus } from "@/modules/follow/follow-action";
|
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { FollowStats } from "@/components/follow/FollowStats";
|
|
||||||
import { DeleteAccountButton } from "./DeleteAccountButton";
|
|
||||||
|
|
||||||
interface UserPageProps {
|
|
||||||
params: Promise<{ username: string; }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function UserPage({ params }: UserPageProps) {
|
|
||||||
const { username } = await params;
|
|
||||||
const t = await getTranslations("user_profile");
|
|
||||||
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
|
||||||
|
|
||||||
const result = await actionGetUserProfileByUsername({ username });
|
|
||||||
|
|
||||||
if (!result.success || !result.data) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = result.data;
|
|
||||||
|
|
||||||
const [decks, followStatus] = await Promise.all([
|
|
||||||
repoGetDecksByUserId({ userId: user.id }),
|
|
||||||
actionGetFollowStatus({ targetUserId: user.id }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const isOwnProfile = session?.user?.username === username || session?.user?.email === username;
|
|
||||||
|
|
||||||
const followersCount = followStatus.success && followStatus.data ? followStatus.data.followersCount : 0;
|
|
||||||
const followingCount = followStatus.success && followStatus.data ? followStatus.data.followingCount : 0;
|
|
||||||
const isFollowing = followStatus.success && followStatus.data ? followStatus.data.isFollowing : false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div></div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{isOwnProfile && (
|
|
||||||
<>
|
|
||||||
<LinkButton href="/logout">{t("logout")}</LinkButton>
|
|
||||||
<DeleteAccountButton username={username} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center space-y-4 sm:space-y-0 sm:space-x-6">
|
|
||||||
{user.image ? (
|
|
||||||
<div className="relative w-24 h-24 rounded-full border-4 border-primary-500 overflow-hidden flex-shrink-0">
|
|
||||||
<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 flex-shrink-0">
|
|
||||||
<span className="text-3xl font-bold text-white">
|
|
||||||
{(user.displayUsername || user.username || user.email)[0].toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{user.bio && (
|
|
||||||
<p className="text-gray-700 mt-2 mb-2">
|
|
||||||
{user.bio}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap items-center gap-4 text-sm mt-3">
|
|
||||||
<span className="text-gray-500">
|
|
||||||
{t("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>
|
|
||||||
{t("verified")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3">
|
|
||||||
<FollowStats
|
|
||||||
userId={user.id}
|
|
||||||
initialFollowersCount={followersCount}
|
|
||||||
initialFollowingCount={followingCount}
|
|
||||||
initialIsFollowing={isFollowing}
|
|
||||||
currentUserId={session?.user?.id}
|
|
||||||
isOwnProfile={isOwnProfile}
|
|
||||||
username={user.username || user.id}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-md p-6">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("decks.title")}</h2>
|
|
||||||
{decks.length === 0 ? (
|
|
||||||
<p className="text-gray-500 text-center py-8">{t("decks.noDecks")}</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("decks.deckName")}
|
|
||||||
</th>
|
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
{t("decks.totalCards")}
|
|
||||||
</th>
|
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
{t("decks.createdAt")}
|
|
||||||
</th>
|
|
||||||
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
{t("decks.actions")}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{decks.map((deck) => (
|
|
||||||
<tr key={deck.id} className="hover:bg-gray-50">
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm font-medium text-gray-900">{deck.name}</div>
|
|
||||||
<div className="text-sm text-gray-500">ID: {deck.id}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm text-gray-900">{deck.cardCount ?? 0}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
||||||
{new Date(deck.createdAt).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
||||||
<Link href={`/decks/${deck.id}`}>
|
|
||||||
<LinkButton>
|
|
||||||
{t("decks.view")}
|
|
||||||
</LinkButton>
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,11 +3,9 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
||||||
import { IconClick, CircleToggleButton, CircleButton, PrimaryButton } from "@/design-system/base/button";
|
import { IconClick } from "@/components/ui/buttons";
|
||||||
import { IMAGES } from "@/config/images";
|
import IMAGES from "@/config/images";
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { PageLayout } from "@/components/ui/PageLayout";
|
|
||||||
import { Card } from "@/design-system/base/card";
|
|
||||||
|
|
||||||
interface AlphabetCardProps {
|
interface AlphabetCardProps {
|
||||||
alphabet: Letter[];
|
alphabet: Letter[];
|
||||||
@@ -15,7 +13,7 @@ interface AlphabetCardProps {
|
|||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AlphabetCard({ alphabet, alphabetType, onBack }: AlphabetCardProps) {
|
export default function AlphabetCard({ alphabet, alphabetType, onBack }: AlphabetCardProps) {
|
||||||
const t = useTranslations("alphabet");
|
const t = useTranslations("alphabet");
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const [showIPA, setShowIPA] = useState(true);
|
const [showIPA, setShowIPA] = useState(true);
|
||||||
@@ -99,122 +97,167 @@ export function AlphabetCard({ alphabet, alphabetType, onBack }: AlphabetCardPro
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout className="relative">
|
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4 py-8">
|
||||||
{/* 右上角返回按钮 - outside the white card */}
|
<div className="w-full max-w-2xl">
|
||||||
<div className="flex justify-end mb-4">
|
{/* 右上角返回按钮 */}
|
||||||
<IconClick
|
<div className="flex justify-end mb-4">
|
||||||
size="lg"
|
<IconClick
|
||||||
alt="close"
|
size={32}
|
||||||
src={IMAGES.close}
|
alt="close"
|
||||||
onClick={onBack}
|
src={IMAGES.close}
|
||||||
className="bg-white rounded-full shadow-md"
|
onClick={onBack}
|
||||||
/>
|
className="bg-white rounded-full shadow-md"
|
||||||
</div>
|
/>
|
||||||
|
</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">
|
<div className="flex justify-between items-center mb-6">
|
||||||
{/* 当前字母进度 */}
|
{/* 当前字母进度 */}
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-sm text-gray-500">
|
||||||
{currentIndex + 1} / {alphabet.length}
|
{currentIndex + 1} / {alphabet.length}
|
||||||
</span>
|
</span>
|
||||||
{/* 显示选项切换按钮组 */}
|
{/* 显示选项切换按钮组 */}
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<CircleToggleButton
|
<button
|
||||||
selected={showLetter}
|
onClick={() => setShowLetter(!showLetter)}
|
||||||
onClick={() => setShowLetter(!showLetter)}
|
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||||
>
|
showLetter
|
||||||
{t("letter")}
|
? "bg-[#35786f] text-white"
|
||||||
</CircleToggleButton>
|
: "bg-gray-200 text-gray-600"
|
||||||
{/* IPA 音标显示切换 */}
|
}`}
|
||||||
<CircleToggleButton
|
|
||||||
selected={showIPA}
|
|
||||||
onClick={() => setShowIPA(!showIPA)}
|
|
||||||
>
|
|
||||||
IPA
|
|
||||||
</CircleToggleButton>
|
|
||||||
{/* 罗马音显示切换(仅日语显示) */}
|
|
||||||
{hasRomanization && (
|
|
||||||
<CircleToggleButton
|
|
||||||
selected={showRoman}
|
|
||||||
onClick={() => setShowRoman(!showRoman)}
|
|
||||||
>
|
>
|
||||||
{t("roman")}
|
{t("letter")}
|
||||||
</CircleToggleButton>
|
</button>
|
||||||
)}
|
{/* IPA 音标显示切换 */}
|
||||||
{/* 随机模式切换 */}
|
<button
|
||||||
<CircleToggleButton
|
onClick={() => setShowIPA(!showIPA)}
|
||||||
selected={isRandomMode}
|
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||||
onClick={() => setIsRandomMode(!isRandomMode)}
|
showIPA
|
||||||
>
|
? "bg-[#35786f] text-white"
|
||||||
{t("random")}
|
: "bg-gray-200 text-gray-600"
|
||||||
</CircleToggleButton>
|
}`}
|
||||||
|
>
|
||||||
|
IPA
|
||||||
|
</button>
|
||||||
|
{/* 罗马音显示切换(仅日语显示) */}
|
||||||
|
{hasRomanization && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRoman(!showRoman)}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||||
|
showRoman
|
||||||
|
? "bg-[#35786f] text-white"
|
||||||
|
: "bg-gray-200 text-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("roman")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* 随机模式切换 */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsRandomMode(!isRandomMode)}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
||||||
|
isRandomMode
|
||||||
|
? "bg-[#35786f] text-white"
|
||||||
|
: "bg-gray-200 text-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("random")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 字母主要内容显示区域 */}
|
{/* 字母主要内容显示区域 */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
{/* 字母本身(可隐藏) */}
|
{/* 字母本身(可隐藏) */}
|
||||||
{showLetter ? (
|
{showLetter ? (
|
||||||
<div className="text-6xl md:text-8xl font-bold text-gray-800 mb-4">
|
<div className="text-6xl md:text-8xl font-bold text-gray-800 mb-4">
|
||||||
{currentLetter.letter}
|
{currentLetter.letter}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-6xl md:text-8xl font-bold text-gray-300 mb-4 h-20 md:h-24 flex items-center justify-center">
|
<div className="text-6xl md:text-8xl font-bold text-gray-300 mb-4 h-20 md:h-24 flex items-center justify-center">
|
||||||
<span className="text-2xl md:text-3xl text-gray-400">?</span>
|
<span className="text-2xl md:text-3xl text-gray-400">?</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* IPA 音标显示 */}
|
{/* IPA 音标显示 */}
|
||||||
{showIPA && (
|
{showIPA && (
|
||||||
<div className="text-2xl md:text-3xl text-gray-600 mb-2">
|
<div className="text-2xl md:text-3xl text-gray-600 mb-2">
|
||||||
{currentLetter.letter_sound_ipa}
|
{currentLetter.letter_sound_ipa}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 罗马音显示(日语) */}
|
{/* 罗马音显示(日语) */}
|
||||||
{showRoman && hasRomanization && currentLetter.roman_letter && (
|
{showRoman && hasRomanization && currentLetter.roman_letter && (
|
||||||
<div className="text-lg md:text-xl text-gray-500">
|
<div className="text-lg md:text-xl text-gray-500">
|
||||||
{currentLetter.roman_letter}
|
{currentLetter.roman_letter}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 底部导航控制区域 */}
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
{/* 上一个按钮 */}
|
|
||||||
<CircleButton onClick={goToPrevious} aria-label="上一个字母">
|
|
||||||
<ChevronLeft size={20} />
|
|
||||||
</CircleButton>
|
|
||||||
|
|
||||||
{/* 中间区域:随机按钮 */}
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
{isRandomMode && (
|
|
||||||
<PrimaryButton
|
|
||||||
onClick={goToRandom}
|
|
||||||
className="rounded-full px-4 py-2 text-sm"
|
|
||||||
>
|
|
||||||
{t("randomNext")}
|
|
||||||
</PrimaryButton>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 下一个按钮 */}
|
{/* 底部导航控制区域 */}
|
||||||
<CircleButton onClick={goToNext} aria-label="下一个字母">
|
<div className="flex justify-between items-center">
|
||||||
<ChevronRight size={20} />
|
{/* 上一个按钮 */}
|
||||||
</CircleButton>
|
<button
|
||||||
</div>
|
onClick={goToPrevious}
|
||||||
</Card>
|
className="p-3 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
|
||||||
|
aria-label="上一个字母"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={24} />
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* 底部操作提示文字 */}
|
{/* 中间区域:随机按钮或进度条 */}
|
||||||
<div className="text-center mt-6 text-white text-sm">
|
<div className="flex gap-2 items-center">
|
||||||
<p>
|
{isRandomMode ? (
|
||||||
{isRandomMode
|
// 随机模式:显示随机切换按钮
|
||||||
? "使用左右箭头键或空格键随机切换字母,ESC键返回"
|
<button
|
||||||
: "使用左右箭头键或滑动切换字母,ESC键返回"
|
onClick={goToRandom}
|
||||||
}
|
className="px-4 py-2 rounded-full bg-[#35786f] text-white text-sm font-medium hover:bg-[#2d5f58] transition-colors"
|
||||||
</p>
|
>
|
||||||
|
{t("randomNext")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
// 顺序模式:显示进度点
|
||||||
|
<div className="flex gap-1 flex-wrap max-w-xs justify-center">
|
||||||
|
{alphabet.slice(0, 20).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`h-2 rounded-full transition-all ${
|
||||||
|
index === currentIndex
|
||||||
|
? "w-8 bg-[#35786f]"
|
||||||
|
: "w-2 bg-gray-300"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* 超过20个字母时显示省略号 */}
|
||||||
|
{alphabet.length > 20 && (
|
||||||
|
<div className="text-xs text-gray-500 flex items-center">...</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 下一个按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={goToNext}
|
||||||
|
className="p-3 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
|
||||||
|
aria-label="下一个字母"
|
||||||
|
>
|
||||||
|
<ChevronRight size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部操作提示文字 */}
|
||||||
|
<div className="text-center mt-6 text-white text-sm">
|
||||||
|
<p>
|
||||||
|
{isRandomMode
|
||||||
|
? "使用左右箭头键或空格键随机切换字母,ESC键返回"
|
||||||
|
: "使用左右箭头键或滑动切换字母,ESC键返回"
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 全屏触摸事件监听层(用于滑动切换) */}
|
{/* 全屏触摸事件监听层(用于滑动切换) */}
|
||||||
@@ -224,6 +267,6 @@ export function AlphabetCard({ alphabet, alphabetType, onBack }: AlphabetCardPro
|
|||||||
onTouchMove={onTouchMove}
|
onTouchMove={onTouchMove}
|
||||||
onTouchEnd={onTouchEnd}
|
onTouchEnd={onTouchEnd}
|
||||||
/>
|
/>
|
||||||
</PageLayout>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { LightButton } from "@/design-system/base/button";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import { IconClick } from "@/design-system/base/button";
|
import { IconClick } from "@/components/ui/buttons";
|
||||||
import { IMAGES } from "@/config/images";
|
import IMAGES from "@/config/images";
|
||||||
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
||||||
import {
|
import {
|
||||||
Dispatch,
|
Dispatch,
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
export function MemoryCard({
|
export default function MemoryCard({
|
||||||
alphabet,
|
alphabet,
|
||||||
setChosenAlphabet,
|
setChosenAlphabet,
|
||||||
}: {
|
}: {
|
||||||
@@ -45,10 +45,10 @@ export function MemoryCard({
|
|||||||
className="w-full flex justify-center items-center"
|
className="w-full flex justify-center items-center"
|
||||||
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()}
|
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">
|
<div className="w-full flex justify-end items-center">
|
||||||
<IconClick
|
<IconClick
|
||||||
size="lg"
|
size={32}
|
||||||
alt="close"
|
alt="close"
|
||||||
src={IMAGES.close}
|
src={IMAGES.close}
|
||||||
onClick={() => setChosenAlphabet(null)}
|
onClick={() => setChosenAlphabet(null)}
|
||||||
@@ -64,13 +64,13 @@ export function MemoryCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row mt-32 items-center justify-center gap-2">
|
<div className="flex flex-row mt-32 items-center justify-center gap-2">
|
||||||
<IconClick
|
<IconClick
|
||||||
size="lg"
|
size={48}
|
||||||
alt="refresh"
|
alt="refresh"
|
||||||
src={IMAGES.refresh}
|
src={IMAGES.refresh}
|
||||||
onClick={refresh}
|
onClick={refresh}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
<IconClick
|
<IconClick
|
||||||
size="lg"
|
size={48}
|
||||||
alt="more"
|
alt="more"
|
||||||
src={IMAGES.more_horiz}
|
src={IMAGES.more_horiz}
|
||||||
onClick={() => setMore(!more)}
|
onClick={() => setMore(!more)}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
||||||
import { PageLayout } from "@/components/ui/PageLayout";
|
import Container from "@/components/ui/Container";
|
||||||
import { LightButton } from "@/design-system/base/button";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import { AlphabetCard } from "./AlphabetCard";
|
import AlphabetCard from "./AlphabetCard";
|
||||||
|
|
||||||
export default function Alphabet() {
|
export default function Alphabet() {
|
||||||
const t = useTranslations("alphabet");
|
const t = useTranslations("alphabet");
|
||||||
@@ -48,81 +48,87 @@ export default function Alphabet() {
|
|||||||
// 语言选择界面
|
// 语言选择界面
|
||||||
if (!chosenAlphabet) {
|
if (!chosenAlphabet) {
|
||||||
return (
|
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 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
|
||||||
</h1>
|
{t("chooseCharacters")}
|
||||||
{/* 副标题说明 */}
|
</h1>
|
||||||
<p className="text-lg text-gray-600 text-center">
|
{/* 副标题说明 */}
|
||||||
{t("chooseAlphabetHint")}
|
<p className="text-gray-600 mb-8 text-lg">
|
||||||
</p>
|
选择一种语言的字母表开始学习
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* 语言选择按钮网格 */}
|
{/* 语言选择按钮网格 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{/* 日语假名选项 */}
|
{/* 日语假名选项 */}
|
||||||
<LightButton
|
<LightButton
|
||||||
onClick={() => setChosenAlphabet("japanese")}
|
onClick={() => setChosenAlphabet("japanese")}
|
||||||
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<span className="text-2xl mb-2">あいうえお</span>
|
<span className="text-2xl mb-2">あいうえお</span>
|
||||||
<span>{t("japanese")}</span>
|
<span>{t("japanese")}</span>
|
||||||
</div>
|
</div>
|
||||||
</LightButton>
|
</LightButton>
|
||||||
|
|
||||||
{/* 英语字母选项 */}
|
{/* 英语字母选项 */}
|
||||||
<LightButton
|
<LightButton
|
||||||
onClick={() => setChosenAlphabet("english")}
|
onClick={() => setChosenAlphabet("english")}
|
||||||
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<span className="text-2xl mb-2">ABC</span>
|
<span className="text-2xl mb-2">ABC</span>
|
||||||
<span>{t("english")}</span>
|
<span>{t("english")}</span>
|
||||||
</div>
|
</div>
|
||||||
</LightButton>
|
</LightButton>
|
||||||
|
|
||||||
{/* 维吾尔语字母选项 */}
|
{/* 维吾尔语字母选项 */}
|
||||||
<LightButton
|
<LightButton
|
||||||
onClick={() => setChosenAlphabet("uyghur")}
|
onClick={() => setChosenAlphabet("uyghur")}
|
||||||
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<span className="text-2xl mb-2">ئۇيغۇر</span>
|
<span className="text-2xl mb-2">ئۇيغۇر</span>
|
||||||
<span>{t("uyghur")}</span>
|
<span>{t("uyghur")}</span>
|
||||||
</div>
|
</div>
|
||||||
</LightButton>
|
</LightButton>
|
||||||
|
|
||||||
{/* 世界语字母选项 */}
|
{/* 世界语字母选项 */}
|
||||||
<LightButton
|
<LightButton
|
||||||
onClick={() => setChosenAlphabet("esperanto")}
|
onClick={() => setChosenAlphabet("esperanto")}
|
||||||
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
className="p-6 text-lg font-medium hover:scale-105 transition-transform"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<span className="text-2xl mb-2">ABCĜĤ</span>
|
<span className="text-2xl mb-2">ABCĜĤ</span>
|
||||||
<span>{t("esperanto")}</span>
|
<span>{t("esperanto")}</span>
|
||||||
</div>
|
</div>
|
||||||
</LightButton>
|
</LightButton>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</Container>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载状态
|
// 加载状态
|
||||||
if (loadingState === "loading") {
|
if (loadingState === "loading") {
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center">
|
||||||
<div className="text-2xl text-gray-600 text-center">{t("loading")}</div>
|
<Container className="p-8 text-center">
|
||||||
</PageLayout>
|
<div className="text-2xl text-gray-600">{t("loading")}</div>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 错误状态
|
// 错误状态
|
||||||
if (loadingState === "error") {
|
if (loadingState === "error") {
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center">
|
||||||
<div className="text-2xl text-red-600 text-center">{t("loadFailed")}</div>
|
<Container className="p-8 text-center">
|
||||||
</PageLayout>
|
<div className="text-2xl text-red-600">{t("loadFailed")}</div>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,280 +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 { Select } from "@/design-system/base/select";
|
|
||||||
import { Skeleton } from "@/design-system/feedback/skeleton";
|
|
||||||
import { HStack, VStack } from "@/design-system/layout/stack";
|
|
||||||
import { Plus, RefreshCw } from "lucide-react";
|
|
||||||
import { DictionaryEntry } from "./DictionaryEntry";
|
|
||||||
import { LanguageSelector } from "./LanguageSelector";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
|
|
||||||
import { actionCreateCard } from "@/modules/card/card-action";
|
|
||||||
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
|
||||||
import type { CardType } from "@/modules/card/card-action-dto";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { getNativeName } from "./stores/dictionaryStore";
|
|
||||||
|
|
||||||
interface DictionaryClientProps {
|
|
||||||
initialDecks: ActionOutputDeck[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DictionaryClient({ initialDecks }: 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 [decks, setDecks] = useState<ActionOutputDeck[]>(initialDecks);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
|
|
||||||
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) {
|
|
||||||
actionGetDecksByUserId(session.user.id).then((result) => {
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setDecks(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(t("pleaseLogin"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (decks.length === 0) {
|
|
||||||
toast.error(t("pleaseCreateFolder"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!searchResult?.entries?.length) {
|
|
||||||
toast.error("No dictionary item to save. Please search first.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deckSelect = document.getElementById("deck-select") as HTMLSelectElement;
|
|
||||||
const deckId = deckSelect?.value ? Number(deckSelect.value) : decks[0]?.id;
|
|
||||||
|
|
||||||
if (!deckId) {
|
|
||||||
toast.error("No deck selected");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSaving(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const hasIpa = searchResult.entries.some((e) => e.ipa);
|
|
||||||
const hasSpaces = searchResult.standardForm.includes(" ");
|
|
||||||
let cardType: CardType = "WORD";
|
|
||||||
if (!hasIpa) {
|
|
||||||
cardType = "SENTENCE";
|
|
||||||
} else if (hasSpaces) {
|
|
||||||
cardType = "PHRASE";
|
|
||||||
}
|
|
||||||
|
|
||||||
const ipa = searchResult.entries.find((e) => e.ipa)?.ipa || null;
|
|
||||||
const meanings = searchResult.entries.map((e) => ({
|
|
||||||
partOfSpeech: e.partOfSpeech || null,
|
|
||||||
definition: e.definition,
|
|
||||||
example: e.example || null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const cardResult = await actionCreateCard({
|
|
||||||
deckId,
|
|
||||||
word: searchResult.standardForm,
|
|
||||||
ipa,
|
|
||||||
queryLang: getNativeName(queryLang),
|
|
||||||
cardType,
|
|
||||||
meanings,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!cardResult.success) {
|
|
||||||
toast.error(cardResult.message || t("saveFailed"));
|
|
||||||
setIsSaving(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deckName = decks.find((d) => d.id === deckId)?.name || "Unknown";
|
|
||||||
toast.success(t("savedToFolder", { folderName: deckName }));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Save error:", error);
|
|
||||||
toast.error(t("saveFailed"));
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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 ? (
|
|
||||||
<VStack align="center" className="py-12">
|
|
||||||
<Skeleton variant="circular" className="w-8 h-8 mb-3" />
|
|
||||||
<p className="text-gray-600">{t("searching")}</p>
|
|
||||||
</VStack>
|
|
||||||
) : query && !searchResult ? (
|
|
||||||
<div className="text-center py-12 bg-white/20 rounded-lg">
|
|
||||||
<p className="text-gray-800 text-xl">{t("noResults")}</p>
|
|
||||||
<p className="text-gray-600 mt-2">{t("tryOtherWords")}</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>
|
|
||||||
<HStack align="center" gap={2} className="ml-4">
|
|
||||||
{session && decks.length > 0 && (
|
|
||||||
<Select
|
|
||||||
id="deck-select"
|
|
||||||
variant="bordered"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{decks.map((deck) => (
|
|
||||||
<option key={deck.id} value={deck.id}>
|
|
||||||
{deck.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
<LightButton
|
|
||||||
onClick={handleSave}
|
|
||||||
className="w-10 h-10 shrink-0"
|
|
||||||
title={t("saveToFolder")}
|
|
||||||
loading={isSaving}
|
|
||||||
disabled={isSaving}
|
|
||||||
>
|
|
||||||
<Plus />
|
|
||||||
</LightButton>
|
|
||||||
</HStack>
|
|
||||||
</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" />
|
|
||||||
{t("relookup")}
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { DictionaryClient } from "./DictionaryClient";
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
|
|
||||||
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
|
||||||
|
|
||||||
export default async function DictionaryPage() {
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
|
||||||
|
|
||||||
let decks: ActionOutputDeck[] = [];
|
|
||||||
|
|
||||||
if (session?.user?.id) {
|
|
||||||
const result = await actionGetDecksByUserId(session.user.id as string);
|
|
||||||
if (result.success && result.data) {
|
|
||||||
decks = result.data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return <DictionaryClient initialDecks={decks} />;
|
|
||||||
}
|
|
||||||
@@ -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' }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Layers,
|
|
||||||
Heart,
|
|
||||||
Search,
|
|
||||||
ArrowUpDown,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { CircleButton } from "@/design-system/base/button";
|
|
||||||
import { Input } from "@/design-system/base/input";
|
|
||||||
import { Skeleton } from "@/design-system/feedback/skeleton";
|
|
||||||
import { HStack } from "@/design-system/layout/stack";
|
|
||||||
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 {
|
|
||||||
actionSearchPublicDecks,
|
|
||||||
actionToggleDeckFavorite,
|
|
||||||
actionCheckDeckFavorite,
|
|
||||||
} from "@/modules/deck/deck-action";
|
|
||||||
import type { ActionOutputPublicDeck } from "@/modules/deck/deck-action-dto";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
|
|
||||||
interface PublicDeckCardProps {
|
|
||||||
deck: ActionOutputPublicDeck;
|
|
||||||
currentUserId?: string;
|
|
||||||
onUpdateFavorite: (deckId: number, isFavorited: boolean, favoriteCount: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PublicDeckCard = ({ deck, currentUserId, onUpdateFavorite }: PublicDeckCardProps) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const t = useTranslations("explore");
|
|
||||||
const [isFavorited, setIsFavorited] = useState(false);
|
|
||||||
const [favoriteCount, setFavoriteCount] = useState(deck.favoriteCount);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentUserId) {
|
|
||||||
actionCheckDeckFavorite({ deckId: deck.id }).then((result) => {
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setIsFavorited(result.data.isFavorited);
|
|
||||||
setFavoriteCount(result.data.favoriteCount);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [deck.id, currentUserId]);
|
|
||||||
|
|
||||||
const handleToggleFavorite = async (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (!currentUserId) {
|
|
||||||
toast.error(t("pleaseLogin"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await actionToggleDeckFavorite({ deckId: deck.id });
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setIsFavorited(result.data.isFavorited);
|
|
||||||
setFavoriteCount(result.data.favoriteCount);
|
|
||||||
onUpdateFavorite(deck.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/${deck.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">
|
|
||||||
<Layers size={18} className="sm:hidden" />
|
|
||||||
<Layers 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">{deck.name}</h3>
|
|
||||||
|
|
||||||
<p className="text-xs sm:text-sm text-gray-500 mb-2 sm:mb-3 line-clamp-2">
|
|
||||||
{t("deckInfo", {
|
|
||||||
userName: deck.userName ?? deck.userUsername ?? t("unknownUser"),
|
|
||||||
cardCount: deck.cardCount ?? 0,
|
|
||||||
})}
|
|
||||||
</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 {
|
|
||||||
initialPublicDecks: ActionOutputPublicDeck[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExploreClient({ initialPublicDecks }: ExploreClientProps) {
|
|
||||||
const t = useTranslations("explore");
|
|
||||||
const router = useRouter();
|
|
||||||
const [publicDecks, setPublicDecks] = useState<ActionOutputPublicDeck[]>(initialPublicDecks);
|
|
||||||
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()) {
|
|
||||||
setPublicDecks(initialPublicDecks);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
|
||||||
const result = await actionSearchPublicDecks({ query: searchQuery.trim() });
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setPublicDecks(result.data);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleSort = () => {
|
|
||||||
setSortByFavorites((prev) => !prev);
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortedDecks = sortByFavorites
|
|
||||||
? [...publicDecks].sort((a, b) => b.favoriteCount - a.favoriteCount)
|
|
||||||
: publicDecks;
|
|
||||||
|
|
||||||
const handleUpdateFavorite = (deckId: number, _isFavorited: boolean, favoriteCount: number) => {
|
|
||||||
setPublicDecks((prev) =>
|
|
||||||
prev.map((d) =>
|
|
||||||
d.id === deckId ? { ...d, favoriteCount } : d
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
|
||||||
|
|
||||||
<HStack align="center" gap={2} className="mb-6">
|
|
||||||
<Input
|
|
||||||
variant="bordered"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
|
||||||
placeholder={t("searchPlaceholder")}
|
|
||||||
leftIcon={<Search size={18} />}
|
|
||||||
containerClassName="flex-1"
|
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="p-8 text-center">
|
|
||||||
<Skeleton variant="circular" className="w-8 h-8 mx-auto mb-3" />
|
|
||||||
<p className="text-sm text-gray-500">{t("loading")}</p>
|
|
||||||
</div>
|
|
||||||
) : sortedDecks.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">
|
|
||||||
<Layers size={24} className="text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm">{t("noDecks")}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
||||||
{sortedDecks.map((deck) => (
|
|
||||||
<PublicDeckCard
|
|
||||||
key={deck.id}
|
|
||||||
deck={deck}
|
|
||||||
currentUserId={currentUserId}
|
|
||||||
onUpdateFavorite={handleUpdateFavorite}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Layers, 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 {
|
|
||||||
actionToggleDeckFavorite,
|
|
||||||
actionCheckDeckFavorite,
|
|
||||||
} from "@/modules/deck/deck-action";
|
|
||||||
import type { ActionOutputPublicDeck } from "@/modules/deck/deck-action-dto";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
|
|
||||||
interface ExploreDetailClientProps {
|
|
||||||
deck: ActionOutputPublicDeck;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExploreDetailClient({ deck }: ExploreDetailClientProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const t = useTranslations("exploreDetail");
|
|
||||||
const [isFavorited, setIsFavorited] = useState(false);
|
|
||||||
const [favoriteCount, setFavoriteCount] = useState(deck.favoriteCount);
|
|
||||||
|
|
||||||
const { data: session } = authClient.useSession();
|
|
||||||
const currentUserId = session?.user?.id;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentUserId) {
|
|
||||||
actionCheckDeckFavorite({ deckId: deck.id }).then((result) => {
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setIsFavorited(result.data.isFavorited);
|
|
||||||
setFavoriteCount(result.data.favoriteCount);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [deck.id, currentUserId]);
|
|
||||||
|
|
||||||
const handleToggleFavorite = async () => {
|
|
||||||
if (!currentUserId) {
|
|
||||||
toast.error(t("pleaseLogin"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await actionToggleDeckFavorite({ deckId: deck.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">
|
|
||||||
<Layers size={28} className="sm:w-8 sm:h-8" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900">
|
|
||||||
{deck.name}
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
|
||||||
{t("createdBy", {
|
|
||||||
name: deck.userName ?? deck.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>
|
|
||||||
|
|
||||||
{deck.desc && (
|
|
||||||
<p className="text-gray-600 mb-6 text-sm sm:text-base">
|
|
||||||
{deck.desc}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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">
|
|
||||||
{deck.cardCount ?? 0}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs sm:text-sm text-gray-500 mt-1">
|
|
||||||
{t("totalCards")}
|
|
||||||
</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(deck.createdAt)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs sm:text-sm text-gray-500 mt-1">
|
|
||||||
{t("createdAt")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href={`/decks/${deck.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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { ExploreDetailClient } from "./ExploreDetailClient";
|
|
||||||
import { actionGetPublicDeckById } from "@/modules/deck/deck-action";
|
|
||||||
|
|
||||||
export default async function ExploreDeckPage({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ id: string }>;
|
|
||||||
}) {
|
|
||||||
const { id } = await params;
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
redirect("/explore");
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await actionGetPublicDeckById({ deckId: Number(id) });
|
|
||||||
|
|
||||||
if (!result.success || !result.data) {
|
|
||||||
redirect("/explore");
|
|
||||||
}
|
|
||||||
|
|
||||||
return <ExploreDetailClient deck={result.data} />;
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { ExploreClient } from "./ExploreClient";
|
|
||||||
import { actionGetPublicDecks } from "@/modules/deck/deck-action";
|
|
||||||
|
|
||||||
export default async function ExplorePage() {
|
|
||||||
const publicDecksResult = await actionGetPublicDecks();
|
|
||||||
const publicDecks = publicDecksResult.success ? publicDecksResult.data ?? [] : [];
|
|
||||||
|
|
||||||
return <ExploreClient initialPublicDecks={publicDecks} />;
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
ChevronRight,
|
|
||||||
Layers as DeckIcon,
|
|
||||||
Heart,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { 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 { VStack } from "@/design-system/layout/stack";
|
|
||||||
import { Skeleton } from "@/design-system/feedback/skeleton";
|
|
||||||
import { actionGetUserFavoriteDecks, actionToggleDeckFavorite } from "@/modules/deck/deck-action";
|
|
||||||
import type { ActionOutputUserFavoriteDeck } from "@/modules/deck/deck-action-dto";
|
|
||||||
|
|
||||||
interface FavoriteCardProps {
|
|
||||||
favorite: ActionOutputUserFavoriteDeck;
|
|
||||||
onRemoveFavorite: (deckId: 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 actionToggleDeckFavorite({ deckId: favorite.id });
|
|
||||||
if (result.success) {
|
|
||||||
onRemoveFavorite(favorite.id);
|
|
||||||
} 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.id}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4 flex-1">
|
|
||||||
<div className="shrink-0 text-primary-500">
|
|
||||||
<DeckIcon size={24} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="font-semibold text-gray-900 truncate">{favorite.name}</h3>
|
|
||||||
<p className="text-sm text-gray-500 mt-0.5">
|
|
||||||
{t("folderInfo", {
|
|
||||||
userName: favorite.userName ?? favorite.userUsername ?? t("unknownUser"),
|
|
||||||
totalPairs: favorite.cardCount ?? 0,
|
|
||||||
})}
|
|
||||||
</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 {
|
|
||||||
initialFavorites: ActionOutputUserFavoriteDeck[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FavoritesClient({ initialFavorites }: FavoritesClientProps) {
|
|
||||||
const t = useTranslations("favorites");
|
|
||||||
const [favorites, setFavorites] = useState<ActionOutputUserFavoriteDeck[]>(initialFavorites);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const loadFavorites = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
const result = await actionGetUserFavoriteDecks();
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setFavorites(result.data);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveFavorite = (deckId: number) => {
|
|
||||||
setFavorites((prev) => prev.filter((f) => f.id !== deckId));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
|
||||||
|
|
||||||
<CardList>
|
|
||||||
{loading ? (
|
|
||||||
<VStack align="center" className="p-8">
|
|
||||||
<Skeleton variant="circular" className="w-8 h-8" />
|
|
||||||
<p className="text-sm text-gray-500">{t("loading")}</p>
|
|
||||||
</VStack>
|
|
||||||
) : 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { auth } from "@/auth";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { FavoritesClient } from "./FavoritesClient";
|
|
||||||
import { actionGetUserFavoriteDecks } from "@/modules/deck/deck-action";
|
|
||||||
import type { ActionOutputUserFavoriteDeck } from "@/modules/deck/deck-action-dto";
|
|
||||||
|
|
||||||
export default async function FavoritesPage() {
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
redirect("/login?redirect=/favorites");
|
|
||||||
}
|
|
||||||
|
|
||||||
let favorites: ActionOutputUserFavoriteDeck[] = [];
|
|
||||||
const result = await actionGetUserFavoriteDecks();
|
|
||||||
if (result.success && result.data) {
|
|
||||||
favorites = result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <FavoritesClient initialFavorites={favorites} />;
|
|
||||||
}
|
|
||||||
96
src/app/(features)/memorize/FolderSelector.tsx
Normal file
96
src/app/(features)/memorize/FolderSelector.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
interface FolderSelectorProps {
|
||||||
|
folders: (Folder & { total: number })[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
|
||||||
|
const t = useTranslations("memorize.folder_selector");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4 py-8">
|
||||||
|
<div className="w-full max-w-2xl">
|
||||||
|
<div className="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
|
||||||
|
className="inline-block px-6 py-2 bg-[#35786f] text-white rounded-full hover:bg-[#2d5f58] transition-colors"
|
||||||
|
href="/folders"
|
||||||
|
>
|
||||||
|
Go to Folders
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-6">
|
||||||
|
{t("selectFolder")}
|
||||||
|
</h1>
|
||||||
|
{/* 文件夹列表 */}
|
||||||
|
<div className="border border-gray-200 rounded-2xl max-h-96 overflow-y-auto">
|
||||||
|
{folders
|
||||||
|
.toSorted((a, b) => a.id - b.id)
|
||||||
|
.map((folder) => (
|
||||||
|
<div
|
||||||
|
key={folder.id}
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/memorize?folder_id=${folder.id}`)
|
||||||
|
}
|
||||||
|
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="flex-shrink-0">
|
||||||
|
<Fd className="text-gray-600" size={24} />
|
||||||
|
</div>
|
||||||
|
{/* 文件夹信息 */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-gray-900">
|
||||||
|
{folder.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{t("folderInfo", {
|
||||||
|
id: folder.id,
|
||||||
|
name: folder.name,
|
||||||
|
count: folder.total,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 右箭头 */}
|
||||||
|
<div className="text-gray-400">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FolderSelector;
|
||||||
203
src/app/(features)/memorize/Memorize.tsx
Normal file
203
src/app/(features)/memorize/Memorize.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||||
|
import { getTTSAudioUrl } from "@/lib/browser/tts";
|
||||||
|
import { VOICES } from "@/config/locales";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import localFont from "next/font/local";
|
||||||
|
import { isNonNegativeInteger, SeededRandom } from "@/lib/utils";
|
||||||
|
import { Pair } from "../../../../generated/prisma/browser";
|
||||||
|
|
||||||
|
const myFont = localFont({
|
||||||
|
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
|
||||||
|
});
|
||||||
|
|
||||||
|
interface MemorizeProps {
|
||||||
|
textPairs: Pair[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
||||||
|
const t = useTranslations("memorize.memorize");
|
||||||
|
const [reverse, setReverse] = useState(false);
|
||||||
|
const [dictation, setDictation] = useState(false);
|
||||||
|
const [disorder, setDisorder] = useState(false);
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const [show, setShow] = useState<"question" | "answer">("question");
|
||||||
|
const { load, play } = useAudioPlayer();
|
||||||
|
|
||||||
|
if (textPairs.length === 0) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rng = new SeededRandom(textPairs[0].folderId);
|
||||||
|
const disorderedTextPairs = textPairs.toSorted(() => rng.next() - 0.5);
|
||||||
|
|
||||||
|
textPairs.sort((a, b) => a.id - b.id);
|
||||||
|
|
||||||
|
const getTextPairs = () => disorder ? disorderedTextPairs : textPairs;
|
||||||
|
|
||||||
|
const handleIndexClick = () => {
|
||||||
|
const newIndex = prompt("Input a index number.")?.trim();
|
||||||
|
if (
|
||||||
|
newIndex &&
|
||||||
|
isNonNegativeInteger(newIndex) &&
|
||||||
|
parseInt(newIndex) <= textPairs.length &&
|
||||||
|
parseInt(newIndex) > 0
|
||||||
|
) {
|
||||||
|
setIndex(parseInt(newIndex) - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = async () => {
|
||||||
|
if (show === "answer") {
|
||||||
|
const newIndex = (index + 1) % getTextPairs().length;
|
||||||
|
setIndex(newIndex);
|
||||||
|
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");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevious = () => {
|
||||||
|
setIndex(
|
||||||
|
(index - 1 + getTextPairs().length) % getTextPairs().length,
|
||||||
|
);
|
||||||
|
setShow("question");
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleReverse = () => setReverse(!reverse);
|
||||||
|
const toggleDictation = () => setDictation(!dictation);
|
||||||
|
const toggleDisorder = () => setDisorder(!disorder);
|
||||||
|
|
||||||
|
const createText = (text: string) => {
|
||||||
|
return (
|
||||||
|
<div className="text-gray-900 text-xl md:text-2xl p-6 h-[20dvh] overflow-y-auto text-center">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [text1, text2] = reverse
|
||||||
|
? [getTextPairs()[index].text2, getTextPairs()[index].text1]
|
||||||
|
: [getTextPairs()[index].text1, getTextPairs()[index].text2];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4 py-8">
|
||||||
|
<div className="w-full max-w-2xl">
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl p-6 md:p-8">
|
||||||
|
{/* 进度指示器 */}
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<button
|
||||||
|
onClick={handleIndexClick}
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
{index + 1} / {getTextPairs().length}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 文本显示区域 */}
|
||||||
|
<div className={`h-[40dvh] ${myFont.className} mb-4`}>
|
||||||
|
{(() => {
|
||||||
|
if (dictation) {
|
||||||
|
if (show === "question") {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<div className="text-gray-400 text-4xl">?</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{createText(text1)}
|
||||||
|
<div className="border-t border-gray-200"></div>
|
||||||
|
{createText(text2)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (show === "question") {
|
||||||
|
return createText(text1);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{createText(text1)}
|
||||||
|
<div className="border-t border-gray-200"></div>
|
||||||
|
{createText(text2)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部按钮 */}
|
||||||
|
<div className="flex flex-row gap-2 items-center justify-center flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
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")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handlePrevious}
|
||||||
|
className="px-4 py-2 rounded-full bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
{t("previous")}
|
||||||
|
</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")}
|
||||||
|
</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")}
|
||||||
|
</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")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Memorize;
|
||||||
53
src/app/(features)/memorize/page.tsx
Normal file
53
src/app/(features)/memorize/page.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
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";
|
||||||
|
|
||||||
|
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
|
||||||
|
? isNonNegativeInteger(tParam)
|
||||||
|
? parseInt(tParam)
|
||||||
|
: null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!folder_id) {
|
||||||
|
return (
|
||||||
|
<FolderSelector
|
||||||
|
folders={await getFoldersWithTotalPairsByUserId(session.user.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = await getUserIdByFolderId(folder_id);
|
||||||
|
if (owner !== session.user.id) {
|
||||||
|
return <p>{t("unauthorized")}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Memorize textPairs={await getPairsByFolderId(folder_id)} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
216
src/app/(features)/srt-player/VideoPlayer/VideoPanel.tsx
Normal file
216
src/app/(features)/srt-player/VideoPlayer/VideoPanel.tsx
Normal 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;
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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';
|
|
||||||
45
src/app/(features)/srt-player/components/atoms/FileInput.tsx
Normal file
45
src/app/(features)/srt-player/components/atoms/FileInput.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useRef } from "react";
|
||||||
|
import { FileInputProps } from "../../types/controls";
|
||||||
|
|
||||||
|
interface FileInputComponentProps extends FileInputProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FileInput({ accept, onFileSelect, disabled, className, children }: FileInputComponentProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleClick = React.useCallback(() => {
|
||||||
|
if (!disabled && inputRef.current) {
|
||||||
|
inputRef.current.click();
|
||||||
|
}
|
||||||
|
}, [disabled]);
|
||||||
|
|
||||||
|
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
onFileSelect(file);
|
||||||
|
}
|
||||||
|
}, [onFileSelect]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={disabled}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`px-2 py-1 rounded shadow font-bold hover:cursor-pointer hover:bg-gray-200 text-gray-800 bg-white ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className || ''}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
|
import { PlayButtonProps } from "../../types/player";
|
||||||
|
|
||||||
|
export default function PlayButton({ isPlaying, onToggle, disabled, className }: PlayButtonProps) {
|
||||||
|
const t = useTranslations("srt_player");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LightButton
|
||||||
|
onClick={disabled ? undefined : onToggle}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`px-4 py-2 ${className || ''}`}
|
||||||
|
>
|
||||||
|
{isPlaying ? t("pause") : t("play")}
|
||||||
|
</LightButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/app/(features)/srt-player/components/atoms/SeekBar.tsx
Normal file
26
src/app/(features)/srt-player/components/atoms/SeekBar.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { SeekBarProps } from "../../types/player";
|
||||||
|
|
||||||
|
export default function SeekBar({ value, max, onChange, disabled, className }: SeekBarProps) {
|
||||||
|
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newValue = parseInt(event.target.value);
|
||||||
|
onChange(newValue);
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={max}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className || ''}`}
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to right, #374151 0%, #374151 ${(value / max) * 100}%, #e5e7eb ${(value / max) * 100}%, #e5e7eb 100%)`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
|
import { SpeedControlProps } from "../../types/player";
|
||||||
|
import { getPlaybackRateOptions, getPlaybackRateLabel } from "../../utils/timeUtils";
|
||||||
|
|
||||||
|
export default function SpeedControl({ playbackRate, onPlaybackRateChange, disabled, className }: SpeedControlProps) {
|
||||||
|
const speedOptions = getPlaybackRateOptions();
|
||||||
|
|
||||||
|
const handleSpeedChange = React.useCallback(() => {
|
||||||
|
const currentIndex = speedOptions.indexOf(playbackRate);
|
||||||
|
const nextIndex = (currentIndex + 1) % speedOptions.length;
|
||||||
|
onPlaybackRateChange(speedOptions[nextIndex]);
|
||||||
|
}, [playbackRate, onPlaybackRateChange, speedOptions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LightButton
|
||||||
|
onClick={disabled ? undefined : handleSpeedChange}
|
||||||
|
className={`${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className || ''}`}
|
||||||
|
>
|
||||||
|
{getPlaybackRateLabel(playbackRate)}
|
||||||
|
</LightButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { SubtitleTextProps } from "../../types/subtitle";
|
||||||
|
|
||||||
|
export default function SubtitleText({ text, onWordClick, style, className }: SubtitleTextProps) {
|
||||||
|
const handleWordClick = React.useCallback((word: string) => {
|
||||||
|
onWordClick?.(word);
|
||||||
|
}, [onWordClick]);
|
||||||
|
|
||||||
|
// 将文本分割成单词,保持标点符号
|
||||||
|
const renderTextWithClickableWords = () => {
|
||||||
|
if (!text) return null;
|
||||||
|
|
||||||
|
// 匹配单词和标点符号
|
||||||
|
const parts = text.match(/[\w']+|[^\w\s]+|\s+/g) || [];
|
||||||
|
|
||||||
|
return parts.map((part, index) => {
|
||||||
|
// 如果是单词(字母和撇号组成)
|
||||||
|
if (/^[\w']+$/.test(part)) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
onClick={() => handleWordClick(part)}
|
||||||
|
className="hover:bg-gray-700 hover:underline hover:cursor-pointer rounded px-1 transition-colors"
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 如果是空格或其他字符,直接渲染
|
||||||
|
return <span key={index}>{part}</span>;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`overflow-auto h-16 mt-2 wrap-break-words font-sans text-white text-center text-2xl ${className || ''}`}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{renderTextWithClickableWords()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { forwardRef } from "react";
|
||||||
|
import { VideoElementProps } from "../../types/player";
|
||||||
|
|
||||||
|
const VideoElement = forwardRef<HTMLVideoElement, VideoElementProps>(
|
||||||
|
({ src, onTimeUpdate, onLoadedMetadata, onPlay, onPause, onEnded, className }, ref) => {
|
||||||
|
const handleTimeUpdate = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||||
|
const video = event.currentTarget;
|
||||||
|
onTimeUpdate?.(video.currentTime);
|
||||||
|
}, [onTimeUpdate]);
|
||||||
|
|
||||||
|
const handleLoadedMetadata = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||||
|
const video = event.currentTarget;
|
||||||
|
onLoadedMetadata?.(video.duration);
|
||||||
|
}, [onLoadedMetadata]);
|
||||||
|
|
||||||
|
const handlePlay = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||||
|
onPlay?.();
|
||||||
|
}, [onPlay]);
|
||||||
|
|
||||||
|
const handlePause = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||||
|
onPause?.();
|
||||||
|
}, [onPause]);
|
||||||
|
|
||||||
|
const handleEnded = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||||
|
onEnded?.();
|
||||||
|
}, [onEnded]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
ref={ref}
|
||||||
|
src={src}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onLoadedMetadata={handleLoadedMetadata}
|
||||||
|
onPlay={handlePlay}
|
||||||
|
onPause={handlePause}
|
||||||
|
onEnded={handleEnded}
|
||||||
|
className={`bg-gray-200 w-full ${className || ""}`}
|
||||||
|
playsInline
|
||||||
|
controls={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
VideoElement.displayName = "VideoElement";
|
||||||
|
|
||||||
|
export default VideoElement;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { ChevronLeft, ChevronRight, RotateCcw, Pause } from "lucide-react";
|
||||||
|
import { 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { SubtitleDisplayProps } from "../../types/subtitle";
|
||||||
|
import SubtitleText from "../atoms/SubtitleText";
|
||||||
|
|
||||||
|
export default function SubtitleArea({ subtitle, onWordClick, settings, className }: SubtitleDisplayProps) {
|
||||||
|
const handleWordClick = React.useCallback((word: string) => {
|
||||||
|
// 打开有道词典页面查询单词
|
||||||
|
window.open(
|
||||||
|
`https://www.youdao.com/result?word=${encodeURIComponent(word)}&lang=en`,
|
||||||
|
"_blank"
|
||||||
|
);
|
||||||
|
onWordClick?.(word);
|
||||||
|
}, [onWordClick]);
|
||||||
|
|
||||||
|
const subtitleStyle = React.useMemo(() => {
|
||||||
|
if (!settings) return { backgroundColor: 'rgba(0, 0, 0, 0.5)' };
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: settings.backgroundColor,
|
||||||
|
color: settings.textColor,
|
||||||
|
fontSize: `${settings.fontSize}px`,
|
||||||
|
fontFamily: settings.fontFamily,
|
||||||
|
opacity: settings.opacity,
|
||||||
|
};
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SubtitleText
|
||||||
|
text={subtitle}
|
||||||
|
onWordClick={handleWordClick}
|
||||||
|
style={subtitleStyle}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Video, FileText } from "lucide-react";
|
||||||
|
import { 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -9,9 +9,10 @@ export function useFileUpload() {
|
|||||||
onError?: (error: Error) => void
|
onError?: (error: Error) => void
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const maxSize = 1000 * 1024 * 1024;
|
// 验证文件大小(限制为100MB)
|
||||||
|
const maxSize = 100 * 1024 * 1024; // 100MB
|
||||||
if (file.size > maxSize) {
|
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);
|
const url = URL.createObjectURL(file);
|
||||||
@@ -33,6 +34,7 @@ export function useFileUpload() {
|
|||||||
input.onchange = (e) => {
|
input.onchange = (e) => {
|
||||||
const file = (e.target as HTMLInputElement).files?.[0];
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
|
// 验证文件类型
|
||||||
if (!file.type.startsWith('video/')) {
|
if (!file.type.startsWith('video/')) {
|
||||||
onError?.(new Error('请选择有效的视频文件'));
|
onError?.(new Error('请选择有效的视频文件'));
|
||||||
return;
|
return;
|
||||||
@@ -59,6 +61,7 @@ export function useFileUpload() {
|
|||||||
input.onchange = (e) => {
|
input.onchange = (e) => {
|
||||||
const file = (e.target as HTMLInputElement).files?.[0];
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
|
// 验证文件扩展名
|
||||||
if (!file.name.toLowerCase().endsWith('.srt')) {
|
if (!file.name.toLowerCase().endsWith('.srt')) {
|
||||||
onError?.(new Error('请选择.srt格式的字幕文件'));
|
onError?.(new Error('请选择.srt格式的字幕文件'));
|
||||||
return;
|
return;
|
||||||
@@ -77,5 +80,6 @@ export function useFileUpload() {
|
|||||||
return {
|
return {
|
||||||
uploadVideo,
|
uploadVideo,
|
||||||
uploadSubtitle,
|
uploadSubtitle,
|
||||||
|
uploadFile,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,82 +1,68 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useSrtPlayerStore } from "../stores/srtPlayerStore";
|
import { KeyboardShortcut } from "../types/controls";
|
||||||
|
|
||||||
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]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useKeyboardShortcuts(
|
export function useKeyboardShortcuts(
|
||||||
shortcuts: Array<{ key: string; action: () => void }>,
|
shortcuts: KeyboardShortcut[],
|
||||||
isEnabled: boolean = true
|
enabled: boolean = true
|
||||||
) {
|
) {
|
||||||
|
const handleKeyDown = useCallback((event: globalThis.KeyboardEvent) => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
// 防止在输入框中触发快捷键
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortcut = shortcuts.find(s => s.key === event.key);
|
||||||
|
if (shortcut) {
|
||||||
|
event.preventDefault();
|
||||||
|
shortcut.action();
|
||||||
|
}
|
||||||
|
}, [shortcuts, enabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
|
||||||
if (!isEnabled) return;
|
|
||||||
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shortcut = shortcuts.find(s => s.key === event.key);
|
|
||||||
if (shortcut) {
|
|
||||||
event.preventDefault();
|
|
||||||
shortcut.action();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
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,
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
306
src/app/(features)/srt-player/hooks/useSrtPlayer.ts
Normal file
306
src/app/(features)/srt-player/hooks/useSrtPlayer.ts
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useReducer, useCallback, useRef, useEffect } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { VideoState, VideoControls } from "../types/player";
|
||||||
|
import { SubtitleState, SubtitleEntry } from "../types/subtitle";
|
||||||
|
import { ControlState, ControlActions } from "../types/controls";
|
||||||
|
|
||||||
|
export interface SrtPlayerState {
|
||||||
|
video: VideoState;
|
||||||
|
subtitle: SubtitleState;
|
||||||
|
controls: ControlState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SrtPlayerActions extends VideoControls, ControlActions {
|
||||||
|
setVideoUrl: (url: string | null) => void;
|
||||||
|
setSubtitleUrl: (url: string | null) => void;
|
||||||
|
nextSubtitle: () => void;
|
||||||
|
previousSubtitle: () => void;
|
||||||
|
restartSubtitle: () => void;
|
||||||
|
setSubtitleSettings: (settings: Partial<SubtitleState['settings']>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: SrtPlayerState = {
|
||||||
|
video: {
|
||||||
|
url: null,
|
||||||
|
isPlaying: false,
|
||||||
|
currentTime: 0,
|
||||||
|
duration: 0,
|
||||||
|
playbackRate: 1.0,
|
||||||
|
volume: 1.0,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
url: null,
|
||||||
|
data: [],
|
||||||
|
currentText: "",
|
||||||
|
currentIndex: null,
|
||||||
|
settings: {
|
||||||
|
fontSize: 24,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
textColor: "#ffffff",
|
||||||
|
position: "bottom",
|
||||||
|
fontFamily: "sans-serif",
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
autoPause: true,
|
||||||
|
showShortcuts: false,
|
||||||
|
showSettings: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type SrtPlayerAction =
|
||||||
|
| { type: "SET_VIDEO_URL"; payload: string | null }
|
||||||
|
| { type: "SET_PLAYING"; payload: boolean }
|
||||||
|
| { type: "SET_CURRENT_TIME"; payload: number }
|
||||||
|
| { type: "SET_DURATION"; payload: number }
|
||||||
|
| { type: "SET_PLAYBACK_RATE"; payload: number }
|
||||||
|
| { type: "SET_VOLUME"; payload: number }
|
||||||
|
| { type: "SET_SUBTITLE_URL"; payload: string | null }
|
||||||
|
| { type: "SET_SUBTITLE_DATA"; payload: SubtitleEntry[] }
|
||||||
|
| { type: "SET_CURRENT_SUBTITLE"; payload: { text: string; index: number | null } }
|
||||||
|
| { type: "SET_SUBTITLE_SETTINGS"; payload: Partial<SubtitleState['settings']> }
|
||||||
|
| { type: "TOGGLE_AUTO_PAUSE" }
|
||||||
|
| { type: "TOGGLE_SHORTCUTS" }
|
||||||
|
| { type: "TOGGLE_SETTINGS" };
|
||||||
|
|
||||||
|
function srtPlayerReducer(state: SrtPlayerState, action: SrtPlayerAction): SrtPlayerState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "SET_VIDEO_URL":
|
||||||
|
return { ...state, video: { ...state.video, url: action.payload } };
|
||||||
|
case "SET_PLAYING":
|
||||||
|
return { ...state, video: { ...state.video, isPlaying: action.payload } };
|
||||||
|
case "SET_CURRENT_TIME":
|
||||||
|
return { ...state, video: { ...state.video, currentTime: action.payload } };
|
||||||
|
case "SET_DURATION":
|
||||||
|
return { ...state, video: { ...state.video, duration: action.payload } };
|
||||||
|
case "SET_PLAYBACK_RATE":
|
||||||
|
return { ...state, video: { ...state.video, playbackRate: action.payload } };
|
||||||
|
case "SET_VOLUME":
|
||||||
|
return { ...state, video: { ...state.video, volume: action.payload } };
|
||||||
|
case "SET_SUBTITLE_URL":
|
||||||
|
return { ...state, subtitle: { ...state.subtitle, url: action.payload } };
|
||||||
|
case "SET_SUBTITLE_DATA":
|
||||||
|
return { ...state, subtitle: { ...state.subtitle, data: action.payload } };
|
||||||
|
case "SET_CURRENT_SUBTITLE":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
subtitle: {
|
||||||
|
...state.subtitle,
|
||||||
|
currentText: action.payload.text,
|
||||||
|
currentIndex: action.payload.index,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "SET_SUBTITLE_SETTINGS":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
subtitle: {
|
||||||
|
...state.subtitle,
|
||||||
|
settings: { ...state.subtitle.settings, ...action.payload },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "TOGGLE_AUTO_PAUSE":
|
||||||
|
return { ...state, controls: { ...state.controls, autoPause: !state.controls.autoPause } };
|
||||||
|
case "TOGGLE_SHORTCUTS":
|
||||||
|
return { ...state, controls: { ...state.controls, showShortcuts: !state.controls.showShortcuts } };
|
||||||
|
case "TOGGLE_SETTINGS":
|
||||||
|
return { ...state, controls: { ...state.controls, showSettings: !state.controls.showSettings } };
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSrtPlayer() {
|
||||||
|
const [state, dispatch] = useReducer(srtPlayerReducer, initialState);
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
|
// Video controls
|
||||||
|
const play = useCallback(() => {
|
||||||
|
// 检查是否同时有视频和字幕
|
||||||
|
if (!state.video.url || !state.subtitle.url || state.subtitle.data.length === 0) {
|
||||||
|
toast.error("请先上传视频和字幕文件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.play().catch(error => {
|
||||||
|
toast.error("视频播放失败: " + error.message);
|
||||||
|
});
|
||||||
|
dispatch({ type: "SET_PLAYING", payload: true });
|
||||||
|
}
|
||||||
|
}, [state.video.url, state.subtitle.url, state.subtitle.data.length, dispatch]);
|
||||||
|
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.pause();
|
||||||
|
dispatch({ type: "SET_PLAYING", payload: false });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const togglePlayPause = useCallback(() => {
|
||||||
|
if (state.video.isPlaying) {
|
||||||
|
pause();
|
||||||
|
} else {
|
||||||
|
play();
|
||||||
|
}
|
||||||
|
}, [state.video.isPlaying, play, pause]);
|
||||||
|
|
||||||
|
const seek = useCallback((time: number) => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.currentTime = time;
|
||||||
|
dispatch({ type: "SET_CURRENT_TIME", payload: time });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setPlaybackRate = useCallback((rate: number) => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.playbackRate = rate;
|
||||||
|
dispatch({ type: "SET_PLAYBACK_RATE", payload: rate });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setVolume = useCallback((volume: number) => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.volume = volume;
|
||||||
|
dispatch({ type: "SET_VOLUME", payload: volume });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const restart = useCallback(() => {
|
||||||
|
if (videoRef.current && state.subtitle.currentIndex !== null) {
|
||||||
|
const currentSubtitle = state.subtitle.data[state.subtitle.currentIndex];
|
||||||
|
if (currentSubtitle) {
|
||||||
|
seek(currentSubtitle.start);
|
||||||
|
play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
|
||||||
|
|
||||||
|
// URL setters
|
||||||
|
const setVideoUrl = useCallback((url: string | null) => {
|
||||||
|
dispatch({ type: "SET_VIDEO_URL", payload: url });
|
||||||
|
if (url && videoRef.current) {
|
||||||
|
videoRef.current.src = url;
|
||||||
|
videoRef.current.load();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setSubtitleUrl = useCallback((url: string | null) => {
|
||||||
|
dispatch({ type: "SET_SUBTITLE_URL", payload: url });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Subtitle controls
|
||||||
|
const nextSubtitle = useCallback(() => {
|
||||||
|
if (state.subtitle.currentIndex !== null &&
|
||||||
|
state.subtitle.currentIndex + 1 < state.subtitle.data.length) {
|
||||||
|
const nextIndex = state.subtitle.currentIndex + 1;
|
||||||
|
const nextSubtitle = state.subtitle.data[nextIndex];
|
||||||
|
seek(nextSubtitle.start);
|
||||||
|
play();
|
||||||
|
}
|
||||||
|
}, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
|
||||||
|
|
||||||
|
const previousSubtitle = useCallback(() => {
|
||||||
|
if (state.subtitle.currentIndex !== null && state.subtitle.currentIndex > 0) {
|
||||||
|
const prevIndex = state.subtitle.currentIndex - 1;
|
||||||
|
const prevSubtitle = state.subtitle.data[prevIndex];
|
||||||
|
seek(prevSubtitle.start);
|
||||||
|
play();
|
||||||
|
}
|
||||||
|
}, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
|
||||||
|
|
||||||
|
const restartSubtitle = useCallback(() => {
|
||||||
|
if (state.subtitle.currentIndex !== null) {
|
||||||
|
const currentSubtitle = state.subtitle.data[state.subtitle.currentIndex];
|
||||||
|
seek(currentSubtitle.start);
|
||||||
|
play();
|
||||||
|
}
|
||||||
|
}, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
|
||||||
|
|
||||||
|
const setSubtitleSettings = useCallback((settings: Partial<SubtitleState['settings']>) => {
|
||||||
|
dispatch({ type: "SET_SUBTITLE_SETTINGS", payload: settings });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Control actions
|
||||||
|
const toggleAutoPause = useCallback(() => {
|
||||||
|
dispatch({ type: "TOGGLE_AUTO_PAUSE" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleShortcuts = useCallback(() => {
|
||||||
|
dispatch({ type: "TOGGLE_SHORTCUTS" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleSettings = useCallback(() => {
|
||||||
|
dispatch({ type: "TOGGLE_SETTINGS" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Video event handlers
|
||||||
|
const handleTimeUpdate = useCallback(() => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
dispatch({ type: "SET_CURRENT_TIME", payload: videoRef.current.currentTime });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLoadedMetadata = useCallback(() => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
dispatch({ type: "SET_DURATION", payload: videoRef.current.duration });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePlay = useCallback(() => {
|
||||||
|
dispatch({ type: "SET_PLAYING", payload: true });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePause = useCallback(() => {
|
||||||
|
dispatch({ type: "SET_PLAYING", payload: false });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Set subtitle data
|
||||||
|
const setSubtitleData = useCallback((data: SubtitleEntry[]) => {
|
||||||
|
dispatch({ type: "SET_SUBTITLE_DATA", payload: data });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Set current subtitle
|
||||||
|
const setCurrentSubtitle = useCallback((text: string, index: number | null) => {
|
||||||
|
dispatch({ type: "SET_CURRENT_SUBTITLE", payload: { text, index } });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const actions: SrtPlayerActions = {
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
togglePlayPause,
|
||||||
|
seek,
|
||||||
|
setPlaybackRate,
|
||||||
|
setVolume,
|
||||||
|
restart,
|
||||||
|
setVideoUrl,
|
||||||
|
setSubtitleUrl,
|
||||||
|
nextSubtitle,
|
||||||
|
previousSubtitle,
|
||||||
|
restartSubtitle,
|
||||||
|
setSubtitleSettings,
|
||||||
|
toggleAutoPause,
|
||||||
|
toggleShortcuts,
|
||||||
|
toggleSettings,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
actions,
|
||||||
|
videoRef,
|
||||||
|
videoEventHandlers: {
|
||||||
|
onTimeUpdate: handleTimeUpdate,
|
||||||
|
onLoadedMetadata: handleLoadedMetadata,
|
||||||
|
onPlay: handlePlay,
|
||||||
|
onPause: handlePause,
|
||||||
|
},
|
||||||
|
subtitleActions: {
|
||||||
|
setSubtitleData,
|
||||||
|
setCurrentSubtitle,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseSrtPlayerReturn = ReturnType<typeof useSrtPlayer>;
|
||||||
@@ -1,101 +1,110 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useCallback } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { useSrtPlayerStore } from "../stores/srtPlayerStore";
|
import { SubtitleEntry } from "../types/subtitle";
|
||||||
|
|
||||||
export function useSubtitleSync() {
|
export function useSubtitleSync(
|
||||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
subtitles: SubtitleEntry[],
|
||||||
const lastIndexRef = useRef<number | null>(null);
|
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 getCurrentSubtitle = useCallback((time: number): SubtitleEntry | null => {
|
||||||
const autoPause = useSrtPlayerStore((state) => state.controls.autoPause);
|
return subtitles.find(subtitle => time >= subtitle.start && time <= subtitle.end) || null;
|
||||||
const playbackRate = useSrtPlayerStore((state) => state.video.playbackRate);
|
}, [subtitles]);
|
||||||
const currentTime = useSrtPlayerStore((state) => state.video.currentTime);
|
|
||||||
|
|
||||||
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 (time < subtitles[0].start) return null;
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
timeoutRef.current = null;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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];
|
while (left <= right) {
|
||||||
const timeUntilEnd = subtitle.end - currentTimeNow;
|
const mid = Math.floor((left + right) / 2);
|
||||||
|
const subtitle = subtitles[mid];
|
||||||
|
|
||||||
if (timeUntilEnd <= 0) {
|
if (time >= subtitle.start && time <= subtitle.end) {
|
||||||
return;
|
return mid;
|
||||||
}
|
} else if (time < subtitle.start) {
|
||||||
|
right = mid - 1;
|
||||||
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);
|
|
||||||
} else {
|
} else {
|
||||||
setCurrentSubtitle('', null);
|
left = mid + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [subtitleData, currentTime, setCurrentSubtitle]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
// 如果没有找到完全匹配的字幕,返回最近的字幕索引
|
||||||
scheduleAutoPause();
|
return right >= 0 ? right : null;
|
||||||
}, [isPlaying, autoPause]);
|
}, [subtitles]);
|
||||||
|
|
||||||
|
// 检查是否需要自动暂停
|
||||||
|
const shouldAutoPause = useCallback((subtitle: SubtitleEntry, time: number): boolean => {
|
||||||
|
return autoPause &&
|
||||||
|
time >= subtitle.end - 0.2 && // 增加时间窗口,确保自动暂停更可靠
|
||||||
|
time < subtitle.end;
|
||||||
|
}, [autoPause]);
|
||||||
|
|
||||||
|
// 启动/停止同步循环
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isPlaying && autoPause) {
|
const syncSubtitles = () => {
|
||||||
scheduleAutoPause();
|
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 () => {
|
return () => {
|
||||||
if (timeoutRef.current) {
|
if (rafIdRef.current) {
|
||||||
clearTimeout(timeoutRef.current);
|
cancelAnimationFrame(rafIdRef.current);
|
||||||
timeoutRef.current = null;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, [subtitles.length, currentTime, getCurrentSubtitle, onSubtitleChange, shouldAutoPause, onAutoPauseTrigger]);
|
||||||
|
|
||||||
|
// 重置最后字幕引用
|
||||||
|
useEffect(() => {
|
||||||
|
lastSubtitleRef.current = null;
|
||||||
|
}, [subtitles]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCurrentSubtitle,
|
||||||
|
getNearestIndex,
|
||||||
|
shouldAutoPause,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -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]);
|
|
||||||
}
|
|
||||||
@@ -1,179 +1,280 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useEffect } from "react";
|
import React from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { PageLayout } from "@/components/ui/PageLayout";
|
import { Video, FileText } from "lucide-react";
|
||||||
import { LightButton } from "@/design-system/base/button";
|
import { useSrtPlayer } from "./hooks/useSrtPlayer";
|
||||||
import { HStack } from "@/design-system/layout/stack";
|
|
||||||
import { Video, FileText, ChevronLeft, ChevronRight, RotateCcw, Pause, Play } from "lucide-react";
|
|
||||||
import { useVideoSync } from "./hooks/useVideoSync";
|
|
||||||
import { useSubtitleSync } from "./hooks/useSubtitleSync";
|
import { useSubtitleSync } from "./hooks/useSubtitleSync";
|
||||||
import { useSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
|
import { useKeyboardShortcuts, createSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||||
import { loadSubtitle } from "./utils/subtitleParser";
|
|
||||||
import { useSrtPlayerStore } from "./stores/srtPlayerStore";
|
|
||||||
import { useFileUpload } from "./hooks/useFileUpload";
|
import { useFileUpload } from "./hooks/useFileUpload";
|
||||||
import { setVideoRef } from "./stores/srtPlayerStore";
|
import { loadSubtitle } from "./utils/subtitleParser";
|
||||||
import Link from "next/link";
|
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() {
|
export default function SrtPlayerPage() {
|
||||||
const t = useTranslations("home");
|
const t = useTranslations("home");
|
||||||
const srtT = useTranslations("srt_player");
|
const srtT = useTranslations("srt_player");
|
||||||
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
|
||||||
const { uploadVideo, uploadSubtitle } = useFileUpload();
|
const { uploadVideo, uploadSubtitle } = useFileUpload();
|
||||||
|
const {
|
||||||
|
state,
|
||||||
|
actions,
|
||||||
|
videoRef,
|
||||||
|
videoEventHandlers,
|
||||||
|
subtitleActions
|
||||||
|
} = useSrtPlayer();
|
||||||
|
|
||||||
const subtitleUrl = useSrtPlayerStore((state) => state.subtitle.url);
|
// 字幕同步
|
||||||
const setSubtitleData = useSrtPlayerStore((state) => state.setSubtitleData);
|
useSubtitleSync(
|
||||||
const setSubtitleUrl = useSrtPlayerStore((state) => state.setSubtitleUrl);
|
state.subtitle.data,
|
||||||
const setVideoUrl = useSrtPlayerStore((state) => state.setVideoUrl);
|
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 shortcuts = React.useMemo(() =>
|
||||||
const currentIndex = useSrtPlayerStore((state) => state.subtitle.currentIndex);
|
createSrtPlayerShortcuts(
|
||||||
const isPlaying = useSrtPlayerStore((state) => state.video.isPlaying);
|
actions.togglePlayPause,
|
||||||
const playbackRate = useSrtPlayerStore((state) => state.video.playbackRate);
|
actions.nextSubtitle,
|
||||||
const autoPause = useSrtPlayerStore((state) => state.controls.autoPause);
|
actions.previousSubtitle,
|
||||||
|
actions.restartSubtitle,
|
||||||
|
actions.toggleAutoPause
|
||||||
|
), [
|
||||||
|
actions.togglePlayPause,
|
||||||
|
actions.nextSubtitle,
|
||||||
|
actions.previousSubtitle,
|
||||||
|
actions.restartSubtitle,
|
||||||
|
actions.toggleAutoPause
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
const togglePlayPause = useSrtPlayerStore((state) => state.togglePlayPause);
|
useKeyboardShortcuts(shortcuts);
|
||||||
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);
|
|
||||||
|
|
||||||
useVideoSync(videoRef);
|
// 处理字幕文件加载
|
||||||
useSubtitleSync();
|
React.useEffect(() => {
|
||||||
useSrtPlayerShortcuts();
|
if (state.subtitle.url) {
|
||||||
|
loadSubtitle(state.subtitle.url)
|
||||||
useEffect(() => {
|
.then(subtitleData => {
|
||||||
setVideoRef(videoRef);
|
subtitleActions.setSubtitleData(subtitleData);
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const canPlay = !!videoUrl && !!subtitleUrl && subtitleData.length > 0;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (subtitleUrl) {
|
|
||||||
loadSubtitle(subtitleUrl)
|
|
||||||
.then((subtitleData) => {
|
|
||||||
setSubtitleData(subtitleData);
|
|
||||||
toast.success(srtT("subtitleLoadSuccess"));
|
toast.success(srtT("subtitleLoadSuccess"));
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(error => {
|
||||||
toast.error(srtT("subtitleLoadFailed") + ": " + error.message);
|
toast.error(srtT("subtitleLoadFailed") + ": " + error.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [srtT, subtitleUrl, setSubtitleData]);
|
}, [srtT, state.subtitle.url, subtitleActions]);
|
||||||
|
|
||||||
const handleVideoUpload = () => {
|
// 处理进度条变化
|
||||||
uploadVideo((url) => {
|
const handleSeek = React.useCallback((index: number) => {
|
||||||
setVideoUrl(url);
|
if (state.subtitle.data[index]) {
|
||||||
}, (error) => {
|
actions.seek(state.subtitle.data[index].start);
|
||||||
toast.error(srtT('videoUploadFailed') + ': ' + error.message);
|
}
|
||||||
|
}, [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) => {
|
const handleSubtitleUpload = React.useCallback(() => {
|
||||||
setSubtitleUrl(url);
|
uploadSubtitle(actions.setSubtitleUrl, (error) => {
|
||||||
}, (error) => {
|
toast.error(srtT("subtitleUploadFailed") + ": " + error.message);
|
||||||
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 canPlay = state.video.url && state.subtitle.url && state.subtitle.data.length > 0;
|
||||||
const currentIndexRate = rates.indexOf(playbackRate);
|
|
||||||
const nextIndexRate = (currentIndexRate + 1) % rates.length;
|
|
||||||
setPlaybackRate(rates[nextIndexRate]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentSubtitle = currentIndex !== null ? subtitleData[currentIndex] : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="text-center mb-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<h1 className="text-4xl font-bold text-gray-800 mb-2">
|
<div className="max-w-6xl mx-auto">
|
||||||
{t("srtPlayer.name")}
|
{/* 标题区域 */}
|
||||||
</h1>
|
<div className="text-center mb-8">
|
||||||
<p className="text-lg text-gray-600">
|
<h1 className="text-4xl font-bold text-gray-800 mb-2">
|
||||||
{t("srtPlayer.description")}
|
{t("srtPlayer.name")}
|
||||||
</p>
|
</h1>
|
||||||
</div>
|
<p className="text-lg text-gray-600">
|
||||||
|
{t("srtPlayer.description")}
|
||||||
<video
|
</p>
|
||||||
ref={videoRef}
|
|
||||||
width="85%"
|
|
||||||
className="mx-auto"
|
|
||||||
playsInline
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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="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>
|
</div>
|
||||||
<LightButton onClick={handleVideoUpload} disabled={!!videoUrl}>
|
|
||||||
{videoUrl ? srtT("uploaded") : srtT("uploadVideoButton")}
|
{/* 主要内容区域 */}
|
||||||
</LightButton>
|
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||||
</div>
|
{/* 视频播放器区域 */}
|
||||||
<div className="border-gray-200 border-2 flex items p-2 justify-between items-center rounded gap-8">
|
<div className="aspect-video bg-black relative">
|
||||||
<div className="flex items-center flex-col">
|
{(!state.video.url || !state.subtitle.url || state.subtitle.data.length === 0) && (
|
||||||
<FileText size={16} />
|
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-75 z-10">
|
||||||
<span className="text-sm">
|
<div className="text-center text-white">
|
||||||
{subtitleData.length > 0 ? srtT("subtitleUploaded", { count: subtitleData.length }) : srtT("subtitleNotUploaded")}
|
<p className="text-lg mb-2">
|
||||||
</span>
|
{!state.video.url && !state.subtitle.url
|
||||||
|
? srtT("uploadVideoAndSubtitle")
|
||||||
|
: !state.video.url
|
||||||
|
? srtT("uploadVideoFile")
|
||||||
|
: !state.subtitle.url
|
||||||
|
? srtT("uploadSubtitleFile")
|
||||||
|
: srtT("processingSubtitle")
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
{(!state.video.url || !state.subtitle.url) && (
|
||||||
|
<p className="text-sm text-gray-300">
|
||||||
|
{srtT("needBothFiles")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.video.url && (
|
||||||
|
<VideoPlayer
|
||||||
|
ref={videoRef}
|
||||||
|
src={state.video.url}
|
||||||
|
{...videoEventHandlers}
|
||||||
|
className="w-full h-full"
|
||||||
|
>
|
||||||
|
{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="space-y-2">
|
||||||
|
<SeekBar
|
||||||
|
value={state.subtitle.currentIndex ?? 0}
|
||||||
|
max={Math.max(0, state.subtitle.data.length - 1)}
|
||||||
|
onChange={handleSeek}
|
||||||
|
disabled={!canPlay}
|
||||||
|
className="h-3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 字幕进度显示 */}
|
||||||
|
<div className="flex justify-between items-center text-sm text-gray-600 px-2">
|
||||||
|
<span>
|
||||||
|
{state.subtitle.currentIndex !== null ?
|
||||||
|
`${state.subtitle.currentIndex + 1}/${state.subtitle.data.length}` :
|
||||||
|
'0/0'
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* 播放速度显示 */}
|
||||||
|
<span className="bg-gray-200 px-2 py-1 rounded text-xs">
|
||||||
|
{state.video.playbackRate}x
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* 自动暂停状态 */}
|
||||||
|
<span className={`px-2 py-1 rounded text-xs ${state.controls.autoPause
|
||||||
|
? 'bg-gray-800 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{srtT("autoPauseStatus", { enabled: state.controls.autoPause ? srtT("on") : srtT("off") })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<LightButton onClick={handleSubtitleUpload} disabled={!!subtitleUrl}>
|
|
||||||
{subtitleUrl ? srtT("uploaded") : srtT("uploadSubtitleButton")}
|
|
||||||
</LightButton>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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' }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
74
src/app/(features)/srt-player/subtitle.ts
Normal file
74
src/app/(features)/srt-player/subtitle.ts
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
65
src/app/(features)/srt-player/types/controls.ts
Normal file
65
src/app/(features)/srt-player/types/controls.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
export interface ControlState {
|
||||||
|
autoPause: boolean;
|
||||||
|
showShortcuts: boolean;
|
||||||
|
showSettings: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControlActions {
|
||||||
|
toggleAutoPause: () => void;
|
||||||
|
toggleShortcuts: () => void;
|
||||||
|
toggleSettings: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControlBarProps {
|
||||||
|
isPlaying: boolean;
|
||||||
|
onPlayPause: () => void;
|
||||||
|
onPrevious: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onRestart: () => void;
|
||||||
|
playbackRate: number;
|
||||||
|
onPlaybackRateChange: (rate: number) => void;
|
||||||
|
autoPause: boolean;
|
||||||
|
onAutoPauseToggle: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavigationButtonProps {
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoPauseToggleProps {
|
||||||
|
enabled: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyboardShortcut {
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
action: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShortcutHintProps {
|
||||||
|
shortcuts: KeyboardShortcut[];
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileUploadProps {
|
||||||
|
onVideoUpload: (url: string) => void;
|
||||||
|
onSubtitleUpload: (url: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileInputProps {
|
||||||
|
accept: string;
|
||||||
|
onFileSelect: (file: File) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
57
src/app/(features)/srt-player/types/player.ts
Normal file
57
src/app/(features)/srt-player/types/player.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
export interface VideoState {
|
||||||
|
url: string | null;
|
||||||
|
isPlaying: boolean;
|
||||||
|
currentTime: number;
|
||||||
|
duration: number;
|
||||||
|
playbackRate: number;
|
||||||
|
volume: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoControls {
|
||||||
|
play: () => void;
|
||||||
|
pause: () => void;
|
||||||
|
togglePlayPause: () => void;
|
||||||
|
seek: (time: number) => void;
|
||||||
|
setPlaybackRate: (rate: number) => void;
|
||||||
|
setVolume: (volume: number) => void;
|
||||||
|
restart: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoElementProps {
|
||||||
|
src?: string;
|
||||||
|
onTimeUpdate?: (time: number) => void;
|
||||||
|
onLoadedMetadata?: (duration: number) => void;
|
||||||
|
onPlay?: () => void;
|
||||||
|
onPause?: () => void;
|
||||||
|
onEnded?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayButtonProps {
|
||||||
|
isPlaying: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeekBarProps {
|
||||||
|
value: number;
|
||||||
|
max: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeedControlProps {
|
||||||
|
playbackRate: number;
|
||||||
|
onPlaybackRateChange: (rate: number) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VolumeControlProps {
|
||||||
|
volume: number;
|
||||||
|
onVolumeChange: (volume: number) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
59
src/app/(features)/srt-player/types/subtitle.ts
Normal file
59
src/app/(features)/srt-player/types/subtitle.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
export interface SubtitleEntry {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
text: string;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleState {
|
||||||
|
url: string | null;
|
||||||
|
data: SubtitleEntry[];
|
||||||
|
currentText: string;
|
||||||
|
currentIndex: number | null;
|
||||||
|
settings: SubtitleSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleSettings {
|
||||||
|
fontSize: number;
|
||||||
|
backgroundColor: string;
|
||||||
|
textColor: string;
|
||||||
|
position: 'top' | 'center' | 'bottom';
|
||||||
|
fontFamily: string;
|
||||||
|
opacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleDisplayProps {
|
||||||
|
subtitle: string;
|
||||||
|
onWordClick?: (word: string) => void;
|
||||||
|
settings?: SubtitleSettings;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleTextProps {
|
||||||
|
text: string;
|
||||||
|
onWordClick?: (word: string) => void;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleSettingsProps {
|
||||||
|
settings: SubtitleSettings;
|
||||||
|
onSettingsChange: (settings: SubtitleSettings) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleControls {
|
||||||
|
next: () => void;
|
||||||
|
previous: () => void;
|
||||||
|
goToIndex: (index: number) => void;
|
||||||
|
toggleAutoPause: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleSyncProps {
|
||||||
|
subtitles: SubtitleEntry[];
|
||||||
|
currentTime: number;
|
||||||
|
isPlaying: boolean;
|
||||||
|
autoPause: boolean;
|
||||||
|
onSubtitleChange: (subtitle: SubtitleEntry | null) => void;
|
||||||
|
onAutoPauseTrigger?: (subtitle: SubtitleEntry) => void;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SubtitleEntry } from "../types";
|
import { SubtitleEntry } from "../types/subtitle";
|
||||||
|
|
||||||
export function parseSrt(data: string): SubtitleEntry[] {
|
export function parseSrt(data: string): SubtitleEntry[] {
|
||||||
const lines = data.split(/\r?\n/);
|
const lines = data.split(/\r?\n/);
|
||||||
@@ -62,12 +62,13 @@ export function getNearestIndex(
|
|||||||
): number | null {
|
): number | null {
|
||||||
for (let i = 0; i < subtitles.length; i++) {
|
for (let i = 0; i < subtitles.length; i++) {
|
||||||
const subtitle = subtitles[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 (!isBefore || !isAfter) return i - 1;
|
||||||
if (currentTime < subtitle.start) return i > 0 ? i - 1 : null;
|
if (isBefore && !isAfter) return i;
|
||||||
}
|
}
|
||||||
return subtitles.length > 0 ? subtitles.length - 1 : null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCurrentSubtitle(
|
export function getCurrentSubtitle(
|
||||||
@@ -92,7 +93,7 @@ export async function loadSubtitle(url: string): Promise<SubtitleEntry[]> {
|
|||||||
const data = await response.text();
|
const data = await response.text();
|
||||||
return parseSrt(data);
|
return parseSrt(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载字幕失败', error);
|
console.error('Failed to load subtitle:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
48
src/app/(features)/srt-player/utils/timeUtils.ts
Normal file
48
src/app/(features)/srt-player/utils/timeUtils.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
export function formatTime(seconds: number): string {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timeToSeconds(timeStr: string): number {
|
||||||
|
const parts = timeStr.split(':');
|
||||||
|
|
||||||
|
if (parts.length === 3) {
|
||||||
|
// HH:MM:SS format
|
||||||
|
const [h, m, s] = parts;
|
||||||
|
return parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s);
|
||||||
|
} else if (parts.length === 2) {
|
||||||
|
// MM:SS format
|
||||||
|
const [m, s] = parts;
|
||||||
|
return parseInt(m) * 60 + parseFloat(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function secondsToTime(seconds: number): string {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
const ms = Math.floor((seconds % 1) * 1000);
|
||||||
|
|
||||||
|
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')},${ms.toString().padStart(3, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampTime(time: number, min: number = 0, max: number = Infinity): number {
|
||||||
|
return Math.min(Math.max(time, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlaybackRateOptions(): number[] {
|
||||||
|
return [0.5, 0.7, 1.0, 1.2, 1.5, 2.0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlaybackRateLabel(rate: number): string {
|
||||||
|
return `${rate}x`;
|
||||||
|
}
|
||||||
@@ -6,8 +6,8 @@ import {
|
|||||||
TextSpeakerArraySchema,
|
TextSpeakerArraySchema,
|
||||||
TextSpeakerItemSchema,
|
TextSpeakerItemSchema,
|
||||||
} from "@/lib/interfaces";
|
} from "@/lib/interfaces";
|
||||||
import { IconClick } from "@/design-system/base/button";
|
import { IconClick } from "@/components/ui/buttons";
|
||||||
import { IMAGES } from "@/config/images";
|
import IMAGES from "@/config/images";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ function TextCard({ item, handleUse, handleDel }: TextCardProps) {
|
|||||||
handleDel(item);
|
handleDel(item);
|
||||||
};
|
};
|
||||||
return (
|
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="col-span-7" onClick={onUseClick}>
|
||||||
<div className="max-h-26 hover:cursor-pointer text-3xl overflow-y-auto">
|
<div className="max-h-26 hover:cursor-pointer text-3xl overflow-y-auto">
|
||||||
{item.text}
|
{item.text}
|
||||||
@@ -39,7 +39,7 @@ function TextCard({ item, handleUse, handleDel }: TextCardProps) {
|
|||||||
alt="delete"
|
alt="delete"
|
||||||
onClick={onDelClick}
|
onClick={onDelClick}
|
||||||
className="place-self-center"
|
className="place-self-center"
|
||||||
size="lg"
|
size={42}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,7 +50,7 @@ interface SaveListProps {
|
|||||||
show?: boolean;
|
show?: boolean;
|
||||||
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
|
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 t = useTranslations("text_speaker");
|
||||||
const { get: getFromLocalStorage, set: setIntoLocalStorage } =
|
const { get: getFromLocalStorage, set: setIntoLocalStorage } =
|
||||||
getLocalStorageOperator<typeof TextSpeakerArraySchema>(
|
getLocalStorageOperator<typeof TextSpeakerArraySchema>(
|
||||||
@@ -60,12 +60,11 @@ export function SaveList({ show = false, handleUse }: SaveListProps) {
|
|||||||
const [data, setData] = useState(getFromLocalStorage());
|
const [data, setData] = useState(getFromLocalStorage());
|
||||||
const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => {
|
const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => {
|
||||||
const current_data = getFromLocalStorage();
|
const current_data = getFromLocalStorage();
|
||||||
if (!current_data) return;
|
|
||||||
|
|
||||||
const index = current_data.findIndex((v) => v.text === item.text);
|
current_data.splice(
|
||||||
if (index === -1) return;
|
current_data.findIndex((v) => v.text === item.text),
|
||||||
|
1,
|
||||||
current_data.splice(index, 1);
|
);
|
||||||
setIntoLocalStorage(current_data);
|
setIntoLocalStorage(current_data);
|
||||||
refresh();
|
refresh();
|
||||||
};
|
};
|
||||||
@@ -79,25 +78,33 @@ export function SaveList({ show = false, handleUse }: SaveListProps) {
|
|||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (show && data)
|
if (show)
|
||||||
return (
|
return (
|
||||||
<div
|
<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">
|
<div className="flex flex-row justify-center gap-8 items-center">
|
||||||
<p className="text-sm text-gray-600">{t("saved")}</p>
|
<IconClick
|
||||||
<button
|
src={IMAGES.refresh}
|
||||||
|
alt="refresh"
|
||||||
|
onClick={refresh}
|
||||||
|
size={48}
|
||||||
|
className=""
|
||||||
|
></IconClick>
|
||||||
|
<IconClick
|
||||||
|
src={IMAGES.delete}
|
||||||
|
alt="delete"
|
||||||
onClick={handleDeleteAll}
|
onClick={handleDeleteAll}
|
||||||
className="text-xs text-gray-500 hover:text-gray-800"
|
size={48}
|
||||||
>
|
className=""
|
||||||
{t("clearAll")}
|
></IconClick>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<ul className="divide-y divide-gray-100">
|
<ul>
|
||||||
{data.map((item, i) => (
|
{data.map((v) => (
|
||||||
<TextCard
|
<TextCard
|
||||||
key={i}
|
item={v}
|
||||||
item={item}
|
key={crypto.randomUUID()}
|
||||||
handleUse={handleUse}
|
handleUse={handleUse}
|
||||||
handleDel={handleDel}
|
handleDel={handleDel}
|
||||||
></TextCard>
|
></TextCard>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LightButton, IconClick } from "@/design-system/base/button";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import { Input } from "@/design-system/base/input";
|
import { IconClick } from "@/components/ui/buttons";
|
||||||
import { Textarea } from "@/design-system/base/textarea";
|
import IMAGES from "@/config/images";
|
||||||
import { IMAGES } from "@/config/images";
|
|
||||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||||
import {
|
import {
|
||||||
TextSpeakerArraySchema,
|
TextSpeakerArraySchema,
|
||||||
@@ -11,45 +10,14 @@ import {
|
|||||||
} from "@/lib/interfaces";
|
} from "@/lib/interfaces";
|
||||||
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { SaveList } from "./SaveList";
|
import SaveList from "./SaveList";
|
||||||
|
|
||||||
|
import { VOICES } from "@/config/locales";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
||||||
import { genIPA, genLanguage } from "@/modules/translator/translator-action";
|
import { getTTSAudioUrl } from "@/lib/browser/tts";
|
||||||
import { PageLayout } from "@/components/ui/PageLayout";
|
import { genIPA, genLocale } from "@/lib/server/translatorActions";
|
||||||
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
import PageLayout from "@/components/ui/PageLayout";
|
||||||
|
|
||||||
const TTS_LANGUAGES = [
|
|
||||||
{ value: "Auto", label: "auto" },
|
|
||||||
{ value: "Chinese", label: "chinese" },
|
|
||||||
{ value: "English", label: "english" },
|
|
||||||
{ value: "Japanese", label: "japanese" },
|
|
||||||
{ value: "Korean", label: "korean" },
|
|
||||||
{ value: "French", label: "french" },
|
|
||||||
{ value: "German", label: "german" },
|
|
||||||
{ value: "Italian", label: "italian" },
|
|
||||||
{ value: "Spanish", label: "spanish" },
|
|
||||||
{ value: "Portuguese", label: "portuguese" },
|
|
||||||
{ value: "Russian", label: "russian" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type TTSLabel = typeof TTS_LANGUAGES[number]["label"];
|
|
||||||
|
|
||||||
function getLanguageLabel(t: (key: string) => string, label: TTSLabel): string {
|
|
||||||
switch (label) {
|
|
||||||
case "auto": return t("languages.auto");
|
|
||||||
case "chinese": return t("languages.chinese");
|
|
||||||
case "english": return t("languages.english");
|
|
||||||
case "japanese": return t("languages.japanese");
|
|
||||||
case "korean": return t("languages.korean");
|
|
||||||
case "french": return t("languages.french");
|
|
||||||
case "german": return t("languages.german");
|
|
||||||
case "italian": return t("languages.italian");
|
|
||||||
case "spanish": return t("languages.spanish");
|
|
||||||
case "portuguese": return t("languages.portuguese");
|
|
||||||
case "russian": return t("languages.russian");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TextSpeakerPage() {
|
export default function TextSpeakerPage() {
|
||||||
const t = useTranslations("text_speaker");
|
const t = useTranslations("text_speaker");
|
||||||
@@ -62,9 +30,7 @@ export default function TextSpeakerPage() {
|
|||||||
const [pause, setPause] = useState(true);
|
const [pause, setPause] = useState(true);
|
||||||
const [autopause, setAutopause] = useState(true);
|
const [autopause, setAutopause] = useState(true);
|
||||||
const textRef = useRef("");
|
const textRef = useRef("");
|
||||||
const [language, setLanguage] = useState<string | null>(null);
|
const [locale, setLocale] = useState<string | null>(null);
|
||||||
const [selectedLanguage, setSelectedLanguage] = useState<string>("Auto");
|
|
||||||
const [customLanguage, setCustomLanguage] = useState<string>("");
|
|
||||||
const [ipa, setIPA] = useState<string>("");
|
const [ipa, setIPA] = useState<string>("");
|
||||||
const objurlRef = useRef<string | null>(null);
|
const objurlRef = useRef<string | null>(null);
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
@@ -83,8 +49,8 @@ export default function TextSpeakerPage() {
|
|||||||
const handleEnded = () => {
|
const handleEnded = () => {
|
||||||
if (autopause) {
|
if (autopause) {
|
||||||
setPause(true);
|
setPause(true);
|
||||||
} else if (objurlRef.current) {
|
} else {
|
||||||
load(objurlRef.current);
|
load(objurlRef.current!);
|
||||||
play();
|
play();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -109,7 +75,7 @@ export default function TextSpeakerPage() {
|
|||||||
setIPA(data.ipa);
|
setIPA(data.ipa);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error("生成 IPA 失败", e);
|
console.error(e);
|
||||||
setIPA("");
|
setIPA("");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -128,41 +94,40 @@ export default function TextSpeakerPage() {
|
|||||||
} else {
|
} else {
|
||||||
// 第一次播放
|
// 第一次播放
|
||||||
try {
|
try {
|
||||||
let theLanguage: string;
|
let theLocale = locale;
|
||||||
|
if (!theLocale) {
|
||||||
if (customLanguage.trim()) {
|
console.log("downloading text info");
|
||||||
theLanguage = customLanguage.trim();
|
const tmp_locale = await genLocale(textRef.current.slice(0, 30));
|
||||||
} else if (selectedLanguage !== "Auto") {
|
setLocale(tmp_locale);
|
||||||
theLanguage = selectedLanguage;
|
theLocale = tmp_locale;
|
||||||
} else if (language) {
|
|
||||||
theLanguage = language;
|
|
||||||
} else {
|
|
||||||
const tmp_language = await genLanguage(textRef.current.slice(0, 30));
|
|
||||||
setLanguage(tmp_language);
|
|
||||||
theLanguage = tmp_language;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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.";
|
||||||
|
|
||||||
const supportedLanguages: TTS_SUPPORTED_LANGUAGES[] = [
|
objurlRef.current = await getTTSAudioUrl(
|
||||||
"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(
|
|
||||||
textRef.current,
|
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);
|
load(objurlRef.current);
|
||||||
play();
|
play();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("播放音频失败", e);
|
console.error(e);
|
||||||
|
|
||||||
setPause(true);
|
setPause(true);
|
||||||
setLanguage(null);
|
setLocale(null);
|
||||||
|
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,9 +143,7 @@ export default function TextSpeakerPage() {
|
|||||||
|
|
||||||
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
textRef.current = e.target.value.trim();
|
textRef.current = e.target.value.trim();
|
||||||
setLanguage(null);
|
setLocale(null);
|
||||||
setSelectedLanguage("Auto");
|
|
||||||
setCustomLanguage("");
|
|
||||||
setIPA("");
|
setIPA("");
|
||||||
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
||||||
objurlRef.current = null;
|
objurlRef.current = null;
|
||||||
@@ -201,7 +164,7 @@ export default function TextSpeakerPage() {
|
|||||||
const handleUseItem = (item: z.infer<typeof TextSpeakerItemSchema>) => {
|
const handleUseItem = (item: z.infer<typeof TextSpeakerItemSchema>) => {
|
||||||
if (textareaRef.current) textareaRef.current.value = item.text;
|
if (textareaRef.current) textareaRef.current.value = item.text;
|
||||||
textRef.current = item.text;
|
textRef.current = item.text;
|
||||||
setLanguage(item.language);
|
setLocale(item.locale);
|
||||||
setIPA(item.ipa || "");
|
setIPA(item.ipa || "");
|
||||||
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
||||||
objurlRef.current = null;
|
objurlRef.current = null;
|
||||||
@@ -216,11 +179,12 @@ export default function TextSpeakerPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let theLanguage = language;
|
let theLocale = locale;
|
||||||
if (!theLanguage) {
|
if (!theLocale) {
|
||||||
const tmp_language = await genLanguage(textRef.current.slice(0, 30));
|
console.log("downloading text info");
|
||||||
setLanguage(tmp_language);
|
const tmp_locale = await genLocale(textRef.current.slice(0, 30));
|
||||||
theLanguage = tmp_language;
|
setLocale(tmp_locale);
|
||||||
|
theLocale = tmp_locale;
|
||||||
}
|
}
|
||||||
|
|
||||||
let theIPA = ipa;
|
let theIPA = ipa;
|
||||||
@@ -230,7 +194,7 @@ export default function TextSpeakerPage() {
|
|||||||
theIPA = tmp_ipa;
|
theIPA = tmp_ipa;
|
||||||
}
|
}
|
||||||
|
|
||||||
const save = getFromLocalStorage() ?? [];
|
const save = getFromLocalStorage();
|
||||||
const oldIndex = save.findIndex((v) => v.text === textRef.current);
|
const oldIndex = save.findIndex((v) => v.text === textRef.current);
|
||||||
if (oldIndex !== -1) {
|
if (oldIndex !== -1) {
|
||||||
const oldItem = save[oldIndex];
|
const oldItem = save[oldIndex];
|
||||||
@@ -243,19 +207,19 @@ export default function TextSpeakerPage() {
|
|||||||
} else if (theIPA.length === 0) {
|
} else if (theIPA.length === 0) {
|
||||||
save.push({
|
save.push({
|
||||||
text: textRef.current,
|
text: textRef.current,
|
||||||
language: theLanguage as string,
|
locale: theLocale,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
save.push({
|
save.push({
|
||||||
text: textRef.current,
|
text: textRef.current,
|
||||||
language: theLanguage as string,
|
locale: theLocale,
|
||||||
ipa: theIPA,
|
ipa: theIPA,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setIntoLocalStorage(save);
|
setIntoLocalStorage(save);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("保存到本地存储失败", e);
|
console.error(e);
|
||||||
setLanguage(null);
|
setLocale(null);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -265,16 +229,15 @@ export default function TextSpeakerPage() {
|
|||||||
<PageLayout className="items-start py-4">
|
<PageLayout className="items-start py-4">
|
||||||
{/* 文本输入区域 */}
|
{/* 文本输入区域 */}
|
||||||
<div
|
<div
|
||||||
className="border border-gray-200 rounded-lg"
|
className="border border-gray-200 rounded-2xl"
|
||||||
style={{ fontFamily: "Times New Roman, serif" }}
|
style={{ fontFamily: "Times New Roman, serif" }}
|
||||||
>
|
>
|
||||||
{/* 文本输入框 */}
|
{/* 文本输入框 */}
|
||||||
<Textarea
|
<textarea
|
||||||
variant="bordered"
|
className="text-2xl resize-none focus:outline-0 min-h-64 w-full border-gray-200 border-b p-4"
|
||||||
className="text-2xl min-h-64"
|
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
/>
|
></textarea>
|
||||||
{/* IPA 显示区域 */}
|
{/* IPA 显示区域 */}
|
||||||
{(ipa.length !== 0 && (
|
{(ipa.length !== 0 && (
|
||||||
<div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b px-4">
|
<div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b px-4">
|
||||||
@@ -286,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">
|
<div className="p-4 relative w-full flex flex-row flex-wrap gap-2 justify-center items-center">
|
||||||
{/* 速度调节面板 */}
|
{/* 速度调节面板 */}
|
||||||
{showSpeedAdjust && (
|
{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
|
<IconClick
|
||||||
size="lg"
|
size={45}
|
||||||
onClick={letMeSetSpeed(0.5)}
|
onClick={letMeSetSpeed(0.5)}
|
||||||
src={IMAGES.speed_0_5x}
|
src={IMAGES.speed_0_5x}
|
||||||
alt="0.5x"
|
alt="0.5x"
|
||||||
className={speed === 0.5 ? "bg-gray-200" : ""}
|
className={speed === 0.5 ? "bg-gray-200" : ""}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
<IconClick
|
<IconClick
|
||||||
size="lg"
|
size={45}
|
||||||
onClick={letMeSetSpeed(0.7)}
|
onClick={letMeSetSpeed(0.7)}
|
||||||
src={IMAGES.speed_0_7x}
|
src={IMAGES.speed_0_7x}
|
||||||
alt="0.7x"
|
alt="0.7x"
|
||||||
className={speed === 0.7 ? "bg-gray-200" : ""}
|
className={speed === 0.7 ? "bg-gray-200" : ""}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
<IconClick
|
<IconClick
|
||||||
size="lg"
|
size={45}
|
||||||
onClick={letMeSetSpeed(1)}
|
onClick={letMeSetSpeed(1)}
|
||||||
src={IMAGES.speed_1x}
|
src={IMAGES.speed_1x}
|
||||||
alt="1x"
|
alt="1x"
|
||||||
className={speed === 1 ? "bg-gray-200" : ""}
|
className={speed === 1 ? "bg-gray-200" : ""}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
<IconClick
|
<IconClick
|
||||||
size="lg"
|
size={45}
|
||||||
onClick={letMeSetSpeed(1.2)}
|
onClick={letMeSetSpeed(1.2)}
|
||||||
src={IMAGES.speed_1_2_x}
|
src={IMAGES.speed_1_2_x}
|
||||||
alt="1.2x"
|
alt="1.2x"
|
||||||
className={speed === 1.2 ? "bg-gray-200" : ""}
|
className={speed === 1.2 ? "bg-gray-200" : ""}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
<IconClick
|
<IconClick
|
||||||
size="lg"
|
size={45}
|
||||||
onClick={letMeSetSpeed(1.5)}
|
onClick={letMeSetSpeed(1.5)}
|
||||||
src={IMAGES.speed_1_5x}
|
src={IMAGES.speed_1_5x}
|
||||||
alt="1.5x"
|
alt="1.5x"
|
||||||
@@ -326,7 +289,7 @@ export default function TextSpeakerPage() {
|
|||||||
)}
|
)}
|
||||||
{/* 播放/暂停按钮 */}
|
{/* 播放/暂停按钮 */}
|
||||||
<IconClick
|
<IconClick
|
||||||
size="lg"
|
size={45}
|
||||||
onClick={speak}
|
onClick={speak}
|
||||||
src={pause ? IMAGES.play_arrow : IMAGES.pause}
|
src={pause ? IMAGES.play_arrow : IMAGES.pause}
|
||||||
alt="playorpause"
|
alt="playorpause"
|
||||||
@@ -334,10 +297,10 @@ export default function TextSpeakerPage() {
|
|||||||
></IconClick>
|
></IconClick>
|
||||||
{/* 自动暂停按钮 */}
|
{/* 自动暂停按钮 */}
|
||||||
<IconClick
|
<IconClick
|
||||||
size="lg"
|
size={45}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAutopause(!autopause);
|
setAutopause(!autopause);
|
||||||
if (objurlRef.current) {
|
if (objurlRef) {
|
||||||
stop();
|
stop();
|
||||||
}
|
}
|
||||||
setPause(true);
|
setPause(true);
|
||||||
@@ -347,7 +310,7 @@ export default function TextSpeakerPage() {
|
|||||||
></IconClick>
|
></IconClick>
|
||||||
{/* 速度调节按钮 */}
|
{/* 速度调节按钮 */}
|
||||||
<IconClick
|
<IconClick
|
||||||
size="lg"
|
size={45}
|
||||||
onClick={() => setShowSpeedAdjust(!showSpeedAdjust)}
|
onClick={() => setShowSpeedAdjust(!showSpeedAdjust)}
|
||||||
src={IMAGES.speed}
|
src={IMAGES.speed}
|
||||||
alt="speed"
|
alt="speed"
|
||||||
@@ -355,46 +318,12 @@ export default function TextSpeakerPage() {
|
|||||||
></IconClick>
|
></IconClick>
|
||||||
{/* 保存按钮 */}
|
{/* 保存按钮 */}
|
||||||
<IconClick
|
<IconClick
|
||||||
size="lg"
|
size={45}
|
||||||
onClick={save}
|
onClick={save}
|
||||||
src={IMAGES.save}
|
src={IMAGES.save}
|
||||||
alt="save"
|
alt="save"
|
||||||
className={`${saving ? "bg-gray-200" : ""}`}
|
className={`${saving ? "bg-gray-200" : ""}`}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
{/* 语言选择器 */}
|
|
||||||
<div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center">
|
|
||||||
<span className="text-sm text-gray-600">{t("language")}</span>
|
|
||||||
{TTS_LANGUAGES.slice(0, 6).map((lang) => (
|
|
||||||
<LightButton
|
|
||||||
key={lang.value}
|
|
||||||
selected={!customLanguage && selectedLanguage === lang.value}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedLanguage(lang.value);
|
|
||||||
setCustomLanguage("");
|
|
||||||
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
|
||||||
objurlRef.current = null;
|
|
||||||
setPause(true);
|
|
||||||
}}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{getLanguageLabel(t, lang.label)}
|
|
||||||
</LightButton>
|
|
||||||
))}
|
|
||||||
<Input
|
|
||||||
variant="bordered"
|
|
||||||
size="sm"
|
|
||||||
value={customLanguage}
|
|
||||||
onChange={(e) => {
|
|
||||||
setCustomLanguage(e.target.value);
|
|
||||||
setSelectedLanguage("Auto");
|
|
||||||
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
|
||||||
objurlRef.current = null;
|
|
||||||
setPause(true);
|
|
||||||
}}
|
|
||||||
placeholder={t("customLanguage")}
|
|
||||||
className="w-auto min-w-[120px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* 功能开关按钮 */}
|
{/* 功能开关按钮 */}
|
||||||
<div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center">
|
<div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center">
|
||||||
<LightButton
|
<LightButton
|
||||||
@@ -416,7 +345,7 @@ export default function TextSpeakerPage() {
|
|||||||
</div>
|
</div>
|
||||||
{/* 保存列表 */}
|
{/* 保存列表 */}
|
||||||
{showSaveList && (
|
{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>
|
<SaveList show={showSaveList} handleUse={handleUseItem}></SaveList>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
88
src/app/(features)/translator/AddToFolder.tsx
Normal file
88
src/app/(features)/translator/AddToFolder.tsx
Normal 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;
|
||||||
57
src/app/(features)/translator/FolderSelector.tsx
Normal file
57
src/app/(features)/translator/FolderSelector.tsx
Normal 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;
|
||||||
@@ -1,279 +1,200 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LightButton, PrimaryButton, IconClick, CircleButton } from "@/design-system/base/button";
|
import { LightButton } from "@/components/ui/buttons";
|
||||||
import { Input } from "@/design-system/base/input";
|
import { IconClick } from "@/components/ui/buttons";
|
||||||
import { Textarea } from "@/design-system/base/textarea";
|
import IMAGES from "@/config/images";
|
||||||
import { Select } from "@/design-system/base/select";
|
import { VOICES } from "@/config/locales";
|
||||||
import { IMAGES } from "@/config/images";
|
|
||||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
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 { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { actionTranslateText } from "@/modules/translator/translator-action";
|
import z from "zod";
|
||||||
import { actionCreateCard } from "@/modules/card/card-action";
|
import AddToFolder from "./AddToFolder";
|
||||||
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
|
import {
|
||||||
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
genIPA,
|
||||||
import type { CardType } from "@/modules/card/card-action-dto";
|
genLocale,
|
||||||
|
genTranslation,
|
||||||
|
} from "@/lib/server/translatorActions";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
import FolderSelector from "./FolderSelector";
|
||||||
import { TSharedTranslationResult } from "@/shared/translator-type";
|
import { createPair } from "@/lib/server/services/pairService";
|
||||||
import { Plus } from "lucide-react";
|
import { shallowEqual } from "@/lib/utils";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
const SOURCE_LANGUAGES = [
|
|
||||||
{ value: "Auto", label: "auto" },
|
|
||||||
{ value: "Chinese", label: "chinese" },
|
|
||||||
{ value: "English", label: "english" },
|
|
||||||
{ value: "Japanese", label: "japanese" },
|
|
||||||
{ value: "Korean", label: "korean" },
|
|
||||||
{ value: "French", label: "french" },
|
|
||||||
{ value: "German", label: "german" },
|
|
||||||
{ value: "Italian", label: "italian" },
|
|
||||||
{ value: "Spanish", label: "spanish" },
|
|
||||||
{ value: "Portuguese", label: "portuguese" },
|
|
||||||
{ value: "Russian", label: "russian" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const TARGET_LANGUAGES = [
|
|
||||||
{ value: "Chinese", label: "chinese" },
|
|
||||||
{ value: "English", label: "english" },
|
|
||||||
{ value: "Japanese", label: "japanese" },
|
|
||||||
{ value: "Korean", label: "korean" },
|
|
||||||
{ value: "French", label: "french" },
|
|
||||||
{ value: "German", label: "german" },
|
|
||||||
{ value: "Italian", label: "italian" },
|
|
||||||
{ value: "Spanish", label: "spanish" },
|
|
||||||
{ value: "Portuguese", label: "portuguese" },
|
|
||||||
{ value: "Russian", label: "russian" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type LangLabel = typeof SOURCE_LANGUAGES[number]["label"];
|
|
||||||
|
|
||||||
function getLangLabel(t: (key: string) => string, label: LangLabel): string {
|
|
||||||
switch (label) {
|
|
||||||
case "auto": return t("auto");
|
|
||||||
case "chinese": return t("chinese");
|
|
||||||
case "english": return t("english");
|
|
||||||
case "japanese": return t("japanese");
|
|
||||||
case "korean": return t("korean");
|
|
||||||
case "french": return t("french");
|
|
||||||
case "german": return t("german");
|
|
||||||
case "italian": return t("italian");
|
|
||||||
case "spanish": return t("spanish");
|
|
||||||
case "portuguese": return t("portuguese");
|
|
||||||
case "russian": return t("russian");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Estimated button width in pixels (including gap)
|
|
||||||
const BUTTON_WIDTH = 80;
|
|
||||||
const LABEL_WIDTH = 100;
|
|
||||||
const INPUT_WIDTH = 140;
|
|
||||||
const IPA_BUTTON_WIDTH = 100;
|
|
||||||
|
|
||||||
export default function TranslatorPage() {
|
export default function TranslatorPage() {
|
||||||
const t = useTranslations("translator");
|
const t = useTranslations("translator");
|
||||||
|
|
||||||
const taref = useRef<HTMLTextAreaElement>(null);
|
const taref = useRef<HTMLTextAreaElement>(null);
|
||||||
const sourceContainerRef = useRef<HTMLDivElement>(null);
|
const [lang, setLang] = useState<string>("chinese");
|
||||||
const targetContainerRef = useRef<HTMLDivElement>(null);
|
const [tresult, setTresult] = useState<string>("");
|
||||||
const [sourceLanguage, setSourceLanguage] = useState<string>("Auto");
|
const [genIpa, setGenIpa] = useState(true);
|
||||||
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
|
const [ipaTexts, setIpaTexts] = useState(["", ""]);
|
||||||
const [customSourceLanguage, setCustomSourceLanguage] = useState<string>("");
|
|
||||||
const [customTargetLanguage, setCustomTargetLanguage] = useState<string>("");
|
|
||||||
const [translationResult, setTranslationResult] = useState<TSharedTranslationResult | null>(null);
|
|
||||||
const [needIpa, setNeedIpa] = useState(true);
|
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
const [lastTranslation, setLastTranslation] = useState<{
|
|
||||||
sourceText: string;
|
|
||||||
sourceLanguage: string;
|
|
||||||
targetLanguage: string;
|
|
||||||
} | null>(null);
|
|
||||||
const [sourceButtonCount, setSourceButtonCount] = useState(2);
|
|
||||||
const [targetButtonCount, setTargetButtonCount] = useState(2);
|
|
||||||
const { load, play } = useAudioPlayer();
|
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();
|
const { data: session } = authClient.useSession();
|
||||||
const [decks, setDecks] = useState<ActionOutputDeck[]>([]);
|
|
||||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session?.user?.id) {
|
setHistory(tlso.get());
|
||||||
actionGetDecksByUserId(session.user.id).then((result) => {
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setDecks(result.data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [session?.user?.id]);
|
|
||||||
|
|
||||||
// Calculate how many buttons to show based on container width
|
|
||||||
const calculateButtonCount = useCallback((containerWidth: number, hasIpa: boolean) => {
|
|
||||||
// Reserve space for label, input, and IPA button (for source)
|
|
||||||
const reservedWidth = LABEL_WIDTH + INPUT_WIDTH + (hasIpa ? IPA_BUTTON_WIDTH : 0);
|
|
||||||
const availableWidth = containerWidth - reservedWidth;
|
|
||||||
return Math.max(0, Math.floor(availableWidth / BUTTON_WIDTH));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const tts = async (text: string, locale: string) => {
|
||||||
const updateButtonCounts = () => {
|
if (lastTTS.current.text !== text) {
|
||||||
if (sourceContainerRef.current) {
|
const shortName = VOICES.find((v) => v.locale === locale)?.short_name;
|
||||||
const width = sourceContainerRef.current.offsetWidth;
|
if (!shortName) {
|
||||||
setSourceButtonCount(calculateButtonCount(width, true));
|
toast.error("Voice not found");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (targetContainerRef.current) {
|
try {
|
||||||
const width = targetContainerRef.current.offsetWidth;
|
const url = await getTTSAudioUrl(text, shortName);
|
||||||
setTargetButtonCount(calculateButtonCount(width, false));
|
await load(url);
|
||||||
|
lastTTS.current.text = text;
|
||||||
|
lastTTS.current.url = url;
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to generate audio");
|
||||||
|
console.error(error);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
updateButtonCounts();
|
|
||||||
window.addEventListener("resize", updateButtonCounts);
|
|
||||||
return () => window.removeEventListener("resize", updateButtonCounts);
|
|
||||||
}, [calculateButtonCount]);
|
|
||||||
|
|
||||||
const tts = useCallback(async (text: string, locale: string) => {
|
|
||||||
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 url = await getTTSUrl(text, theLanguage as TTS_SUPPORTED_LANGUAGES);
|
|
||||||
await load(url);
|
|
||||||
await play();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Failed to generate audio");
|
|
||||||
}
|
}
|
||||||
}, [load, play]);
|
await play();
|
||||||
|
};
|
||||||
|
|
||||||
const translate = async () => {
|
const translate = async () => {
|
||||||
if (!taref.current || processing) return;
|
if (!taref.current) return;
|
||||||
|
if (processing) return;
|
||||||
|
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
|
|
||||||
const sourceText = taref.current.value;
|
const text1 = taref.current.value;
|
||||||
const effectiveSourceLanguage = customSourceLanguage.trim() || sourceLanguage;
|
|
||||||
const effectiveTargetLanguage = customTargetLanguage.trim() || targetLanguage;
|
|
||||||
|
|
||||||
// 判断是否需要强制重新翻译
|
const llmres: {
|
||||||
const forceRetranslate =
|
text1: string | null;
|
||||||
lastTranslation?.sourceText === sourceText &&
|
text2: string | null;
|
||||||
lastTranslation?.sourceLanguage === effectiveSourceLanguage &&
|
locale1: string | null;
|
||||||
lastTranslation?.targetLanguage === effectiveTargetLanguage;
|
locale2: string | null;
|
||||||
|
ipa1: string | null;
|
||||||
|
ipa2: string | null;
|
||||||
|
} = {
|
||||||
|
text1: text1,
|
||||||
|
text2: null,
|
||||||
|
locale1: null,
|
||||||
|
locale2: null,
|
||||||
|
ipa1: null,
|
||||||
|
ipa2: null,
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
let historyUpdated = false;
|
||||||
const result = await actionTranslateText({
|
|
||||||
sourceText,
|
|
||||||
targetLanguage: effectiveTargetLanguage,
|
|
||||||
forceRetranslate,
|
|
||||||
needIpa,
|
|
||||||
sourceLanguage: effectiveSourceLanguage === "Auto" ? undefined : effectiveSourceLanguage,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
// 检查更新历史记录
|
||||||
setTranslationResult(result.data);
|
const checkUpdateLocalStorage = () => {
|
||||||
setLastTranslation({
|
if (historyUpdated) return;
|
||||||
sourceText,
|
if (llmres.text1 && llmres.text2 && llmres.locale1 && llmres.locale2) {
|
||||||
sourceLanguage: effectiveSourceLanguage,
|
setHistory(
|
||||||
targetLanguage: effectiveTargetLanguage,
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
} else {
|
genLocale(text2).then((locale) => {
|
||||||
toast.error(result.message || "翻译失败,请重试");
|
updateState("locale2", locale);
|
||||||
}
|
});
|
||||||
} catch (error) {
|
// 生成俩IPA
|
||||||
toast.error("翻译失败,请重试");
|
if (genIpa) {
|
||||||
console.error("翻译错误:", error);
|
genIPA(text1).then((ipa1) => {
|
||||||
} finally {
|
setIpaTexts((prev) => [ipa1, prev[1]]);
|
||||||
setProcessing(false);
|
updateState("ipa1", ipa1);
|
||||||
}
|
});
|
||||||
};
|
genIPA(text2).then((ipa2) => {
|
||||||
|
setIpaTexts((prev) => [prev[0], ipa2]);
|
||||||
const visibleSourceButtons = SOURCE_LANGUAGES.slice(0, sourceButtonCount);
|
updateState("ipa2", ipa2);
|
||||||
const visibleTargetButtons = TARGET_LANGUAGES.slice(0, targetButtonCount);
|
});
|
||||||
|
}
|
||||||
const handleSaveCard = async () => {
|
})
|
||||||
if (!session) {
|
.catch(() => {
|
||||||
toast.error(t("pleaseLogin"));
|
toast.error("Translation failed");
|
||||||
return;
|
})
|
||||||
}
|
.finally(() => {
|
||||||
if (decks.length === 0) {
|
setProcessing(false);
|
||||||
toast.error(t("pleaseCreateDeck"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!lastTranslation?.sourceText || !translationResult?.translatedText) {
|
|
||||||
toast.error(t("noTranslationToSave"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deckSelect = document.getElementById("deck-select-translator") as HTMLSelectElement;
|
|
||||||
const deckId = deckSelect?.value ? Number(deckSelect.value) : decks[0]?.id;
|
|
||||||
|
|
||||||
if (!deckId) {
|
|
||||||
toast.error(t("noDeckSelected"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSaving(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sourceText = lastTranslation.sourceText;
|
|
||||||
const hasSpaces = sourceText.includes(" ");
|
|
||||||
let cardType: CardType = "WORD";
|
|
||||||
if (!translationResult.sourceIpa) {
|
|
||||||
cardType = "SENTENCE";
|
|
||||||
} else if (hasSpaces) {
|
|
||||||
cardType = "PHRASE";
|
|
||||||
}
|
|
||||||
|
|
||||||
await actionCreateCard({
|
|
||||||
deckId,
|
|
||||||
word: sourceText,
|
|
||||||
ipa: translationResult.sourceIpa || null,
|
|
||||||
queryLang: lastTranslation.sourceLanguage,
|
|
||||||
cardType,
|
|
||||||
meanings: [{
|
|
||||||
partOfSpeech: null,
|
|
||||||
definition: translationResult.translatedText,
|
|
||||||
example: null,
|
|
||||||
}],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const deckName = decks.find((d) => d.id === deckId)?.name || "Unknown";
|
|
||||||
toast.success(t("savedToDeck", { deckName }));
|
|
||||||
setShowSaveModal(false);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(t("saveFailed"));
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[calc(100vh-64px)] bg-white">
|
<>
|
||||||
{/* TCard Component */}
|
{/* TCard Component */}
|
||||||
<div className="w-screen flex flex-col md:flex-row md:justify-between gap-2 p-2">
|
<div className="w-screen flex flex-col md:flex-row md:justify-between gap-2 p-2">
|
||||||
{/* Card Component - Left Side */}
|
{/* Card Component - Left Side */}
|
||||||
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
||||||
{/* ICard1 Component */}
|
{/* 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
|
<textarea
|
||||||
className="resize-none h-8/12 w-full"
|
className="resize-none h-8/12 w-full focus:outline-0"
|
||||||
ref={taref}
|
ref={taref}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.ctrlKey && e.key === "Enter") translate();
|
if (e.ctrlKey && e.key === "Enter") translate();
|
||||||
}}
|
}}
|
||||||
/>
|
></textarea>
|
||||||
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
|
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
|
||||||
{translationResult?.sourceIpa || ""}
|
{ipaTexts[0]}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2/12 w-full flex justify-end items-center">
|
<div className="h-2/12 w-full flex justify-end items-center">
|
||||||
<IconClick
|
<IconClick
|
||||||
@@ -289,41 +210,18 @@ export default function TranslatorPage() {
|
|||||||
src={IMAGES.play_arrow}
|
src={IMAGES.play_arrow}
|
||||||
alt="play"
|
alt="play"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const text = taref.current?.value;
|
const t = taref.current?.value;
|
||||||
if (!text) return;
|
if (!t) return;
|
||||||
tts(text, translationResult?.sourceLanguage || "");
|
tts(t, tlso.get().find((v) => v.text1 === t)?.locale1 || "");
|
||||||
}}
|
}}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref={sourceContainerRef} className="option1 w-full flex gap-1 items-center overflow-x-auto">
|
<div className="option1 w-full flex flex-row justify-between items-center">
|
||||||
<span className="shrink-0">{t("sourceLanguage")}</span>
|
<span>{t("detectLanguage")}</span>
|
||||||
{visibleSourceButtons.map((lang) => (
|
|
||||||
<LightButton
|
|
||||||
key={lang.value}
|
|
||||||
selected={!customSourceLanguage && sourceLanguage === lang.value}
|
|
||||||
onClick={() => {
|
|
||||||
setSourceLanguage(lang.value);
|
|
||||||
setCustomSourceLanguage("");
|
|
||||||
}}
|
|
||||||
className="shrink-0"
|
|
||||||
>
|
|
||||||
{getLangLabel(t, lang.label)}
|
|
||||||
</LightButton>
|
|
||||||
))}
|
|
||||||
<Input
|
|
||||||
variant="bordered"
|
|
||||||
size="sm"
|
|
||||||
value={customSourceLanguage}
|
|
||||||
onChange={(e) => setCustomSourceLanguage(e.target.value)}
|
|
||||||
placeholder={t("customLanguage")}
|
|
||||||
className="w-auto min-w-[120px] shrink-0"
|
|
||||||
/>
|
|
||||||
<div className="flex-1"></div>
|
|
||||||
<LightButton
|
<LightButton
|
||||||
selected={needIpa}
|
selected={genIpa}
|
||||||
onClick={() => setNeedIpa((prev) => !prev)}
|
onClick={() => setGenIpa((prev) => !prev)}
|
||||||
className="shrink-0"
|
|
||||||
>
|
>
|
||||||
{t("generateIPA")}
|
{t("generateIPA")}
|
||||||
</LightButton>
|
</LightButton>
|
||||||
@@ -333,112 +231,149 @@ export default function TranslatorPage() {
|
|||||||
{/* Card Component - Right Side */}
|
{/* Card Component - Right Side */}
|
||||||
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
||||||
{/* ICard2 Component */}
|
{/* ICard2 Component */}
|
||||||
<div className="bg-gray-100 rounded-lg w-full h-64 p-2">
|
<div className="bg-gray-100 rounded-2xl w-full h-64 p-2">
|
||||||
<div className="h-2/3 w-full overflow-y-auto">{translationResult?.translatedText || ""}</div>
|
<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">
|
<div className="ipa w-full h-1/6 overflow-y-auto text-gray-600">
|
||||||
{translationResult?.targetIpa || ""}
|
{ipaTexts[1]}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1/6 w-full flex justify-end items-center">
|
<div className="h-1/6 w-full flex justify-end items-center">
|
||||||
<IconClick
|
<IconClick
|
||||||
src={IMAGES.copy_all}
|
src={IMAGES.copy_all}
|
||||||
alt="copy"
|
alt="copy"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await navigator.clipboard.writeText(translationResult?.translatedText || "");
|
await navigator.clipboard.writeText(tresult);
|
||||||
}}
|
}}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
<IconClick
|
<IconClick
|
||||||
src={IMAGES.play_arrow}
|
src={IMAGES.play_arrow}
|
||||||
alt="play"
|
alt="play"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!translationResult) return;
|
|
||||||
tts(
|
tts(
|
||||||
translationResult.translatedText,
|
tresult,
|
||||||
translationResult.targetLanguage,
|
tlso.get().find((v) => v.text2 === tresult)?.locale2 || "",
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref={targetContainerRef} className="option2 w-full flex gap-1 items-center overflow-x-auto">
|
<div className="option2 w-full flex gap-1 items-center flex-wrap">
|
||||||
<span className="shrink-0">{t("translateInto")}</span>
|
<span>{t("translateInto")}</span>
|
||||||
{visibleTargetButtons.map((lang) => (
|
<LightButton
|
||||||
<LightButton
|
selected={lang === "chinese"}
|
||||||
key={lang.value}
|
onClick={() => setLang("chinese")}
|
||||||
selected={!customTargetLanguage && targetLanguage === lang.value}
|
>
|
||||||
onClick={() => {
|
{t("chinese")}
|
||||||
setTargetLanguage(lang.value);
|
</LightButton>
|
||||||
setCustomTargetLanguage("");
|
<LightButton
|
||||||
}}
|
selected={lang === "english"}
|
||||||
className="shrink-0"
|
onClick={() => setLang("english")}
|
||||||
>
|
>
|
||||||
{getLangLabel(t, lang.label)}
|
{t("english")}
|
||||||
</LightButton>
|
</LightButton>
|
||||||
))}
|
<LightButton
|
||||||
<Input
|
selected={lang === "italian"}
|
||||||
variant="bordered"
|
onClick={() => setLang("italian")}
|
||||||
size="sm"
|
>
|
||||||
value={customTargetLanguage}
|
{t("italian")}
|
||||||
onChange={(e) => setCustomTargetLanguage(e.target.value)}
|
</LightButton>
|
||||||
placeholder={t("customLanguage")}
|
<LightButton
|
||||||
className="w-auto min-w-[120px] shrink-0"
|
selected={!["chinese", "english", "italian"].includes(lang)}
|
||||||
/>
|
onClick={() => {
|
||||||
|
const newLang = prompt(t("enterLanguage"));
|
||||||
|
if (newLang) {
|
||||||
|
setLang(newLang);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("other")}
|
||||||
|
</LightButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TranslateButton Component */}
|
{/* TranslateButton Component */}
|
||||||
<div className="w-screen flex justify-center items-center gap-4">
|
<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}
|
onClick={translate}
|
||||||
disabled={processing}
|
|
||||||
size="lg"
|
|
||||||
className="text-xl"
|
|
||||||
>
|
>
|
||||||
{t("translate")}
|
{t("translate")}
|
||||||
</PrimaryButton>
|
</button>
|
||||||
{translationResult && session && decks.length > 0 && (
|
|
||||||
<CircleButton
|
|
||||||
onClick={() => setShowSaveModal(true)}
|
|
||||||
title={t("saveAsCard")}
|
|
||||||
>
|
|
||||||
<Plus size={20} />
|
|
||||||
</CircleButton>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showSaveModal && (
|
{/* AutoSave Component */}
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
<div className="w-screen flex justify-center items-center">
|
||||||
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
<label className="flex items-center">
|
||||||
<h2 className="text-xl font-semibold mb-4">{t("saveAsCard")}</h2>
|
<input
|
||||||
<div className="mb-4">
|
type="checkbox"
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
checked={autoSave}
|
||||||
{t("selectDeck")}
|
onChange={(e) => {
|
||||||
</label>
|
const checked = e.target.checked;
|
||||||
<Select id="deck-select-translator" className="w-full">
|
if (checked === true && !session) {
|
||||||
{decks.map((deck) => (
|
toast.warning("Please login to enable auto-save");
|
||||||
<option key={deck.id} value={deck.id}>
|
return;
|
||||||
{deck.name}
|
}
|
||||||
</option>
|
if (checked === false) setAutoSaveFolderId(null);
|
||||||
))}
|
setAutoSave(checked);
|
||||||
</Select>
|
}}
|
||||||
</div>
|
className="mr-2"
|
||||||
<div className="mb-4 p-3 bg-gray-50 rounded text-sm">
|
/>
|
||||||
<div className="font-medium mb-1">{t("front")}:</div>
|
{t("autoSave")}
|
||||||
<div className="text-gray-700 mb-2">{lastTranslation?.sourceText}</div>
|
{autoSaveFolderId ? ` (${autoSaveFolderId})` : ""}
|
||||||
<div className="font-medium mb-1">{t("back")}:</div>
|
</label>
|
||||||
<div className="text-gray-700">{translationResult?.translatedText}</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2">
|
{history.length > 0 && (
|
||||||
<LightButton onClick={() => setShowSaveModal(false)}>
|
<div className="m-6 flex flex-col items-center">
|
||||||
{t("cancel")}
|
<h1 className="text-2xl font-light">{t("history")}</h1>
|
||||||
</LightButton>
|
<div className="border border-gray-200 rounded-2xl m-4">
|
||||||
<PrimaryButton onClick={handleSaveCard} loading={isSaving}>
|
{history.toReversed().map((item, index) => (
|
||||||
{t("save")}
|
<div
|
||||||
</PrimaryButton>
|
key={index}
|
||||||
</div>
|
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>
|
</div>
|
||||||
|
{showAddToFolder && (
|
||||||
|
<AddToFolder setShow={setShowAddToFolder} item={addToFolderItem!} />
|
||||||
|
)}
|
||||||
|
{autoSave && !autoSaveFolderId && (
|
||||||
|
<FolderSelector
|
||||||
|
userId={session!.user.id as string}
|
||||||
|
cancel={() => setAutoSave(false)}
|
||||||
|
setSelectedFolderId={(id) => setAutoSaveFolderId(id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
259
src/app/auth/AuthForm.tsx
Normal file
259
src/app/auth/AuthForm.tsx
Normal 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
20
src/app/auth/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { auth } from "@/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import AuthForm from "./AuthForm";
|
||||||
|
|
||||||
|
export default async function AuthPage(
|
||||||
|
props: {
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] | undefined; }>
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const searchParams = await props.searchParams;
|
||||||
|
const redirectTo = searchParams.redirect as string | undefined;
|
||||||
|
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (session) {
|
||||||
|
redirect(redirectTo || '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AuthForm redirectTo={redirectTo} />;
|
||||||
|
}
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
ChevronRight,
|
|
||||||
Layers,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Globe,
|
|
||||||
Lock,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { CircleButton, LightButton } from "@/design-system/base/button";
|
|
||||||
import { Skeleton } from "@/design-system/feedback/skeleton";
|
|
||||||
import { VStack } from "@/design-system/layout/stack";
|
|
||||||
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 {
|
|
||||||
actionCreateDeck,
|
|
||||||
actionDeleteDeck,
|
|
||||||
actionGetDecksByUserId,
|
|
||||||
actionUpdateDeck,
|
|
||||||
actionGetDeckById,
|
|
||||||
} from "@/modules/deck/deck-action";
|
|
||||||
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
|
||||||
|
|
||||||
interface DeckCardProps {
|
|
||||||
deck: ActionOutputDeck;
|
|
||||||
onUpdateDeck: (deckId: number, updates: Partial<ActionOutputDeck>) => void;
|
|
||||||
onDeleteDeck: (deckId: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DeckCard = ({ deck, onUpdateDeck, onDeleteDeck }: DeckCardProps) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const t = useTranslations("decks");
|
|
||||||
|
|
||||||
const handleToggleVisibility = async (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const newVisibility = deck.visibility === "PUBLIC" ? "PRIVATE" : "PUBLIC";
|
|
||||||
const result = await actionUpdateDeck({
|
|
||||||
deckId: deck.id,
|
|
||||||
visibility: newVisibility,
|
|
||||||
});
|
|
||||||
if (result.success) {
|
|
||||||
onUpdateDeck(deck.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 actionUpdateDeck({
|
|
||||||
deckId: deck.id,
|
|
||||||
name: newName,
|
|
||||||
});
|
|
||||||
if (result.success) {
|
|
||||||
onUpdateDeck(deck.id, { name: newName });
|
|
||||||
} else {
|
|
||||||
toast.error(result.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const confirm = prompt(t("confirmDelete", { name: deck.name }));
|
|
||||||
if (confirm === deck.name) {
|
|
||||||
const result = await actionDeleteDeck({ deckId: deck.id });
|
|
||||||
if (result.success) {
|
|
||||||
onDeleteDeck(deck.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"
|
|
||||||
onClick={() => {
|
|
||||||
router.push(`/decks/${deck.id}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4 flex-1">
|
|
||||||
<div className="shrink-0 text-primary-500">
|
|
||||||
<Layers 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">{deck.name}</h3>
|
|
||||||
<span className="flex items-center gap-1 text-xs text-gray-400">
|
|
||||||
{deck.visibility === "PUBLIC" ? (
|
|
||||||
<Globe size={12} />
|
|
||||||
) : (
|
|
||||||
<Lock size={12} />
|
|
||||||
)}
|
|
||||||
{deck.visibility === "PUBLIC" ? t("public") : t("private")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-500 mt-0.5">
|
|
||||||
{t("deckInfo", {
|
|
||||||
id: deck.id,
|
|
||||||
name: deck.name,
|
|
||||||
totalCards: deck.cardCount ?? 0,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1 ml-4">
|
|
||||||
<CircleButton
|
|
||||||
onClick={handleToggleVisibility}
|
|
||||||
title={deck.visibility === "PUBLIC" ? t("setPrivate") : t("setPublic")}
|
|
||||||
>
|
|
||||||
{deck.visibility === "PUBLIC" ? (
|
|
||||||
<Lock size={18} />
|
|
||||||
) : (
|
|
||||||
<Globe size={18} />
|
|
||||||
)}
|
|
||||||
</CircleButton>
|
|
||||||
<CircleButton onClick={handleRename}>
|
|
||||||
<Pencil size={18} />
|
|
||||||
</CircleButton>
|
|
||||||
<CircleButton
|
|
||||||
onClick={handleDelete}
|
|
||||||
className="hover:text-red-500 hover:bg-red-50"
|
|
||||||
>
|
|
||||||
<Trash2 size={18} />
|
|
||||||
</CircleButton>
|
|
||||||
<ChevronRight size={20} className="text-gray-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface DecksClientProps {
|
|
||||||
userId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DecksClient({ userId }: DecksClientProps) {
|
|
||||||
const t = useTranslations("decks");
|
|
||||||
const router = useRouter();
|
|
||||||
const [decks, setDecks] = useState<ActionOutputDeck[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const loadDecks = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
const result = await actionGetDecksByUserId(userId);
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setDecks(result.data);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadDecks();
|
|
||||||
}, [userId]);
|
|
||||||
|
|
||||||
const handleUpdateDeck = (deckId: number, updates: Partial<ActionOutputDeck>) => {
|
|
||||||
setDecks((prev) =>
|
|
||||||
prev.map((d) => (d.id === deckId ? { ...d, ...updates } : d))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteDeck = (deckId: number) => {
|
|
||||||
setDecks((prev) => prev.filter((d) => d.id !== deckId));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateDeck = async () => {
|
|
||||||
const deckName = prompt(t("enterDeckName"));
|
|
||||||
if (!deckName?.trim()) return;
|
|
||||||
|
|
||||||
const result = await actionCreateDeck({ name: deckName.trim() });
|
|
||||||
if (result.success && result.deckId) {
|
|
||||||
const deckResult = await actionGetDeckById({ deckId: result.deckId });
|
|
||||||
if (deckResult.success && deckResult.data) {
|
|
||||||
setDecks((prev) => [...prev, deckResult.data!]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.error(result.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
|
||||||
|
|
||||||
<div className="mb-4 flex gap-2">
|
|
||||||
<LightButton onClick={handleCreateDeck}>
|
|
||||||
<Plus size={18} />
|
|
||||||
{t("newDeck")}
|
|
||||||
</LightButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardList>
|
|
||||||
{loading ? (
|
|
||||||
<VStack align="center" className="p-8">
|
|
||||||
<Skeleton variant="circular" className="w-8 h-8 mb-3" />
|
|
||||||
<p className="text-sm text-gray-500">{t("loading")}</p>
|
|
||||||
</VStack>
|
|
||||||
) : decks.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">
|
|
||||||
<Layers size={24} className="text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm">{t("noDecksYet")}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
decks.map((deck) => (
|
|
||||||
<DeckCard
|
|
||||||
key={deck.id}
|
|
||||||
deck={deck}
|
|
||||||
onUpdateDeck={handleUpdateDeck}
|
|
||||||
onDeleteDeck={handleDeleteDeck}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</CardList>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { LightButton, PrimaryButton } from "@/design-system/base/button";
|
|
||||||
import { Input } from "@/design-system/base/input";
|
|
||||||
import { Select } from "@/design-system/base/select";
|
|
||||||
import { Textarea } from "@/design-system/base/textarea";
|
|
||||||
import { Modal } from "@/design-system/overlay/modal";
|
|
||||||
import { VStack, HStack } from "@/design-system/layout/stack";
|
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { actionCreateCard } from "@/modules/card/card-action";
|
|
||||||
import type { CardType, CardMeaning } from "@/modules/card/card-action-dto";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
const QUERY_LANGUAGE_LABELS = {
|
|
||||||
english: "english",
|
|
||||||
chinese: "chinese",
|
|
||||||
japanese: "japanese",
|
|
||||||
korean: "korean",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const QUERY_LANGUAGES = [
|
|
||||||
{ value: "en", label: "english" as const },
|
|
||||||
{ value: "zh", label: "chinese" as const },
|
|
||||||
{ value: "ja", label: "japanese" as const },
|
|
||||||
{ value: "ko", label: "korean" as const },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
interface AddCardModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
deckId: number;
|
|
||||||
onAdded: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AddCardModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
deckId,
|
|
||||||
onAdded,
|
|
||||||
}: AddCardModalProps) {
|
|
||||||
const t = useTranslations("deck_id");
|
|
||||||
|
|
||||||
const [cardType, setCardType] = useState<CardType>("WORD");
|
|
||||||
const [word, setWord] = useState("");
|
|
||||||
const [ipa, setIpa] = useState("");
|
|
||||||
const [queryLang, setQueryLang] = useState("en");
|
|
||||||
const [customQueryLang, setCustomQueryLang] = useState("");
|
|
||||||
const [meanings, setMeanings] = useState<CardMeaning[]>([
|
|
||||||
{ partOfSpeech: null, definition: "", example: null }
|
|
||||||
]);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const showIpa = cardType === "WORD" || cardType === "PHRASE";
|
|
||||||
|
|
||||||
const addMeaning = () => {
|
|
||||||
setMeanings([...meanings, { partOfSpeech: null, definition: "", example: null }]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeMeaning = (index: number) => {
|
|
||||||
if (meanings.length > 1) {
|
|
||||||
setMeanings(meanings.filter((_, i) => i !== index));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateMeaning = (
|
|
||||||
index: number,
|
|
||||||
field: "partOfSpeech" | "definition" | "example",
|
|
||||||
value: string
|
|
||||||
) => {
|
|
||||||
const updated = [...meanings];
|
|
||||||
updated[index] = {
|
|
||||||
...updated[index],
|
|
||||||
[field]: value || null
|
|
||||||
};
|
|
||||||
setMeanings(updated);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setCardType("WORD");
|
|
||||||
setWord("");
|
|
||||||
setIpa("");
|
|
||||||
setQueryLang("en");
|
|
||||||
setCustomQueryLang("");
|
|
||||||
setMeanings([{ partOfSpeech: null, definition: "", example: null }]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAdd = async () => {
|
|
||||||
if (!word.trim()) {
|
|
||||||
toast.error(t("wordRequired"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const validMeanings = meanings.filter(m => m.definition?.trim());
|
|
||||||
if (validMeanings.length === 0) {
|
|
||||||
toast.error(t("definitionRequired"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
|
|
||||||
const effectiveQueryLang = customQueryLang.trim() || queryLang;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cardResult = await actionCreateCard({
|
|
||||||
deckId,
|
|
||||||
word: word.trim(),
|
|
||||||
ipa: showIpa && ipa.trim() ? ipa.trim() : null,
|
|
||||||
queryLang: effectiveQueryLang,
|
|
||||||
cardType,
|
|
||||||
meanings: validMeanings.map(m => ({
|
|
||||||
partOfSpeech: cardType === "SENTENCE" ? null : (m.partOfSpeech?.trim() || null),
|
|
||||||
definition: m.definition!.trim(),
|
|
||||||
example: m.example?.trim() || null,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!cardResult.success) {
|
|
||||||
throw new Error(cardResult.message || "Failed to create card");
|
|
||||||
}
|
|
||||||
|
|
||||||
resetForm();
|
|
||||||
onAdded();
|
|
||||||
onClose();
|
|
||||||
toast.success(t("cardAdded") || "Card added successfully");
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
resetForm();
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal open={isOpen} onClose={handleClose} size="md">
|
|
||||||
<Modal.Header>
|
|
||||||
<Modal.Title>{t("addNewCard")}</Modal.Title>
|
|
||||||
<Modal.CloseButton onClick={handleClose} />
|
|
||||||
</Modal.Header>
|
|
||||||
|
|
||||||
<Modal.Body className="space-y-4">
|
|
||||||
<HStack gap={3}>
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{t("cardType")}
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={cardType}
|
|
||||||
onChange={(e) => setCardType(e.target.value as CardType)}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<option value="WORD">{t("wordCard")}</option>
|
|
||||||
<option value="PHRASE">{t("phraseCard")}</option>
|
|
||||||
<option value="SENTENCE">{t("sentenceCard")}</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{t("queryLang")}
|
|
||||||
</label>
|
|
||||||
<HStack gap={2} className="flex-wrap">
|
|
||||||
{QUERY_LANGUAGES.map((lang) => (
|
|
||||||
<LightButton
|
|
||||||
key={lang.value}
|
|
||||||
selected={!customQueryLang && queryLang === lang.value}
|
|
||||||
onClick={() => {
|
|
||||||
setQueryLang(lang.value);
|
|
||||||
setCustomQueryLang("");
|
|
||||||
}}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{t(lang.label)}
|
|
||||||
</LightButton>
|
|
||||||
))}
|
|
||||||
<Input
|
|
||||||
value={customQueryLang}
|
|
||||||
onChange={(e) => setCustomQueryLang(e.target.value)}
|
|
||||||
placeholder={t("enterLanguageName")}
|
|
||||||
className="w-auto min-w-[100px] flex-1"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{cardType === "SENTENCE" ? t("sentence") : t("word")} *
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={word}
|
|
||||||
onChange={(e) => setWord(e.target.value)}
|
|
||||||
className="w-full"
|
|
||||||
placeholder={cardType === "SENTENCE" ? t("sentencePlaceholder") : t("wordPlaceholder")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showIpa && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{t("ipa")}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={ipa}
|
|
||||||
onChange={(e) => setIpa(e.target.value)}
|
|
||||||
className="w-full"
|
|
||||||
placeholder={t("ipaPlaceholder")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<HStack justify="between" className="mb-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
{t("meanings")} *
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
onClick={addMeaning}
|
|
||||||
className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<Plus size={14} />
|
|
||||||
{t("addMeaning")}
|
|
||||||
</button>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
<VStack gap={4}>
|
|
||||||
{meanings.map((meaning, index) => (
|
|
||||||
<div key={index} className="p-3 bg-gray-50 rounded-lg space-y-2">
|
|
||||||
<HStack gap={2}>
|
|
||||||
{cardType !== "SENTENCE" && (
|
|
||||||
<div className="w-28 shrink-0">
|
|
||||||
<Input
|
|
||||||
value={meaning.partOfSpeech || ""}
|
|
||||||
onChange={(e) => updateMeaning(index, "partOfSpeech", e.target.value)}
|
|
||||||
placeholder={t("partOfSpeech")}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1">
|
|
||||||
<Input
|
|
||||||
value={meaning.definition || ""}
|
|
||||||
onChange={(e) => updateMeaning(index, "definition", e.target.value)}
|
|
||||||
placeholder={t("definition")}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{meanings.length > 1 && (
|
|
||||||
<button
|
|
||||||
onClick={() => removeMeaning(index)}
|
|
||||||
className="p-2 text-gray-400 hover:text-red-500"
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
<Textarea
|
|
||||||
value={meaning.example || ""}
|
|
||||||
onChange={(e) => updateMeaning(index, "example", e.target.value)}
|
|
||||||
placeholder={t("examplePlaceholder")}
|
|
||||||
className="w-full min-h-[40px] text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</VStack>
|
|
||||||
</div>
|
|
||||||
</Modal.Body>
|
|
||||||
|
|
||||||
<Modal.Footer>
|
|
||||||
<LightButton onClick={handleClose}>
|
|
||||||
{t("cancel")}
|
|
||||||
</LightButton>
|
|
||||||
<PrimaryButton onClick={handleAdd} loading={isSubmitting}>
|
|
||||||
{isSubmitting ? t("adding") : t("add")}
|
|
||||||
</PrimaryButton>
|
|
||||||
</Modal.Footer>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import { Trash2, Pencil } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { CircleButton } from "@/design-system/base/button";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import type { ActionOutputCard, CardType } from "@/modules/card/card-action-dto";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { actionDeleteCard } from "@/modules/card/card-action";
|
|
||||||
import { EditCardModal } from "./EditCardModal";
|
|
||||||
|
|
||||||
interface CardItemProps {
|
|
||||||
card: ActionOutputCard;
|
|
||||||
isReadOnly: boolean;
|
|
||||||
onDel: () => void;
|
|
||||||
onUpdated: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CARD_TYPE_LABELS: Record<CardType, string> = {
|
|
||||||
WORD: "Word",
|
|
||||||
PHRASE: "Phrase",
|
|
||||||
SENTENCE: "Sentence",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CardItem({
|
|
||||||
card,
|
|
||||||
isReadOnly,
|
|
||||||
onDel,
|
|
||||||
onUpdated,
|
|
||||||
}: CardItemProps) {
|
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
|
||||||
const t = useTranslations("deck_id");
|
|
||||||
|
|
||||||
const frontText = card.word;
|
|
||||||
const backText = card.meanings.map((m) =>
|
|
||||||
m.partOfSpeech ? `${m.partOfSpeech}: ${m.definition}` : m.definition
|
|
||||||
).join("; ");
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
try {
|
|
||||||
const result = await actionDeleteCard({ cardId: card.id });
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(t("cardDeleted"));
|
|
||||||
onDel();
|
|
||||||
} else {
|
|
||||||
toast.error(result.message);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
|
||||||
}
|
|
||||||
setShowDeleteConfirm(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="group border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
|
||||||
<div className="p-4">
|
|
||||||
<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">
|
|
||||||
{t("card")}
|
|
||||||
</span>
|
|
||||||
<span className="px-2 py-1 bg-blue-50 text-blue-600 rounded-md">
|
|
||||||
{CARD_TYPE_LABELS[card.cardType]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1 opacity-50 group-hover:opacity-100 transition-opacity">
|
|
||||||
{!isReadOnly && (
|
|
||||||
<>
|
|
||||||
<CircleButton
|
|
||||||
onClick={() => setShowEditModal(true)}
|
|
||||||
title={t("edit")}
|
|
||||||
className="text-gray-400 hover:text-blue-500 hover:bg-blue-50"
|
|
||||||
>
|
|
||||||
<Pencil size={14} />
|
|
||||||
</CircleButton>
|
|
||||||
<CircleButton
|
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
|
||||||
title={t("delete")}
|
|
||||||
className="text-gray-400 hover:text-red-500 hover:bg-red-50"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</CircleButton>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-900 grid grid-cols-2 gap-4 w-3/4">
|
|
||||||
<div>
|
|
||||||
{frontText.length > 30
|
|
||||||
? frontText.substring(0, 30) + "..."
|
|
||||||
: frontText}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{backText.length > 30
|
|
||||||
? backText.substring(0, 30) + "..."
|
|
||||||
: backText}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showDeleteConfirm && (
|
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white rounded-lg p-4 max-w-sm mx-4">
|
|
||||||
<p className="text-gray-700 mb-4">{t("deleteConfirm")}</p>
|
|
||||||
<div className="flex gap-2 justify-end">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowDeleteConfirm(false)}
|
|
||||||
className="px-3 py-1 text-gray-600 hover:bg-gray-100 rounded"
|
|
||||||
>
|
|
||||||
{t("cancel")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleDelete}
|
|
||||||
className="px-3 py-1 text-red-600 hover:bg-red-50 rounded"
|
|
||||||
>
|
|
||||||
{t("delete")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<EditCardModal
|
|
||||||
isOpen={showEditModal}
|
|
||||||
onClose={() => setShowEditModal(false)}
|
|
||||||
card={card}
|
|
||||||
onUpdated={onUpdated}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { LightButton, PrimaryButton } from "@/design-system/base/button";
|
|
||||||
import { Input } from "@/design-system/base/input";
|
|
||||||
import { Textarea } from "@/design-system/base/textarea";
|
|
||||||
import { Modal } from "@/design-system/overlay/modal";
|
|
||||||
import { VStack, HStack } from "@/design-system/layout/stack";
|
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { actionUpdateCard } from "@/modules/card/card-action";
|
|
||||||
import type { ActionOutputCard, CardMeaning } from "@/modules/card/card-action-dto";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface EditCardModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
card: ActionOutputCard | null;
|
|
||||||
onUpdated: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EditCardModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
card,
|
|
||||||
onUpdated,
|
|
||||||
}: EditCardModalProps) {
|
|
||||||
const t = useTranslations("deck_id");
|
|
||||||
|
|
||||||
const [word, setWord] = useState("");
|
|
||||||
const [ipa, setIpa] = useState("");
|
|
||||||
const [meanings, setMeanings] = useState<CardMeaning[]>([
|
|
||||||
{ partOfSpeech: null, definition: "", example: null }
|
|
||||||
]);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const showIpa = card?.cardType === "WORD" || card?.cardType === "PHRASE";
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (card) {
|
|
||||||
setWord(card.word);
|
|
||||||
setIpa(card.ipa || "");
|
|
||||||
setMeanings(
|
|
||||||
card.meanings.length > 0
|
|
||||||
? card.meanings
|
|
||||||
: [{ partOfSpeech: null, definition: "", example: null }]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [card]);
|
|
||||||
|
|
||||||
const addMeaning = () => {
|
|
||||||
setMeanings([...meanings, { partOfSpeech: null, definition: "", example: null }]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeMeaning = (index: number) => {
|
|
||||||
if (meanings.length > 1) {
|
|
||||||
setMeanings(meanings.filter((_, i) => i !== index));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateMeaning = (index: number, field: keyof CardMeaning, value: string) => {
|
|
||||||
const updated = [...meanings];
|
|
||||||
updated[index] = { ...updated[index], [field]: value || null };
|
|
||||||
setMeanings(updated);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdate = async () => {
|
|
||||||
if (!card) return;
|
|
||||||
|
|
||||||
if (!word.trim()) {
|
|
||||||
toast.error(t("wordRequired"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const validMeanings = meanings.filter(m => m.definition?.trim());
|
|
||||||
if (validMeanings.length === 0) {
|
|
||||||
toast.error(t("definitionRequired"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await actionUpdateCard({
|
|
||||||
cardId: card.id,
|
|
||||||
word: word.trim(),
|
|
||||||
ipa: showIpa && ipa.trim() ? ipa.trim() : null,
|
|
||||||
meanings: validMeanings.map(m => ({
|
|
||||||
partOfSpeech: card.cardType === "SENTENCE" ? null : (m.partOfSpeech?.trim() || null),
|
|
||||||
definition: m.definition!.trim(),
|
|
||||||
example: m.example?.trim() || null,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.message || "Failed to update card");
|
|
||||||
}
|
|
||||||
|
|
||||||
onUpdated();
|
|
||||||
onClose();
|
|
||||||
toast.success(t("cardUpdated") || "Card updated successfully");
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!card) return null;
|
|
||||||
|
|
||||||
const cardTypeLabel = card.cardType === "WORD"
|
|
||||||
? t("wordCard")
|
|
||||||
: card.cardType === "PHRASE"
|
|
||||||
? t("phraseCard")
|
|
||||||
: t("sentenceCard");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal open={isOpen} onClose={onClose} size="md">
|
|
||||||
<Modal.Header>
|
|
||||||
<Modal.Title>{t("updateCard")}</Modal.Title>
|
|
||||||
<Modal.CloseButton onClick={onClose} />
|
|
||||||
</Modal.Header>
|
|
||||||
|
|
||||||
<Modal.Body className="space-y-4">
|
|
||||||
<HStack gap={2} className="text-sm text-gray-500">
|
|
||||||
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
|
||||||
{t("card")}
|
|
||||||
</span>
|
|
||||||
<span className="px-2 py-1 bg-blue-50 text-blue-600 rounded-md">
|
|
||||||
{cardTypeLabel}
|
|
||||||
</span>
|
|
||||||
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
|
||||||
{card.queryLang}
|
|
||||||
</span>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{card.cardType === "SENTENCE" ? t("sentence") : t("word")} *
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={word}
|
|
||||||
onChange={(e) => setWord(e.target.value)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showIpa && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{t("ipa")}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={ipa}
|
|
||||||
onChange={(e) => setIpa(e.target.value)}
|
|
||||||
className="w-full"
|
|
||||||
placeholder={t("ipaPlaceholder")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<HStack justify="between" className="mb-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
{t("meanings")} *
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
onClick={addMeaning}
|
|
||||||
className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<Plus size={14} />
|
|
||||||
{t("addMeaning")}
|
|
||||||
</button>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
<VStack gap={4}>
|
|
||||||
{meanings.map((meaning, index) => (
|
|
||||||
<div key={index} className="p-3 bg-gray-50 rounded-lg space-y-2">
|
|
||||||
<HStack gap={2}>
|
|
||||||
{card.cardType !== "SENTENCE" && (
|
|
||||||
<div className="w-28 shrink-0">
|
|
||||||
<Input
|
|
||||||
value={meaning.partOfSpeech || ""}
|
|
||||||
onChange={(e) => updateMeaning(index, "partOfSpeech", e.target.value)}
|
|
||||||
placeholder={t("partOfSpeech")}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1">
|
|
||||||
<Input
|
|
||||||
value={meaning.definition || ""}
|
|
||||||
onChange={(e) => updateMeaning(index, "definition", e.target.value)}
|
|
||||||
placeholder={t("definition")}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{meanings.length > 1 && (
|
|
||||||
<button
|
|
||||||
onClick={() => removeMeaning(index)}
|
|
||||||
className="p-2 text-gray-400 hover:text-red-500"
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
<Textarea
|
|
||||||
value={meaning.example || ""}
|
|
||||||
onChange={(e) => updateMeaning(index, "example", e.target.value)}
|
|
||||||
placeholder={t("examplePlaceholder")}
|
|
||||||
className="w-full min-h-[40px] text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</VStack>
|
|
||||||
</div>
|
|
||||||
</Modal.Body>
|
|
||||||
|
|
||||||
<Modal.Footer>
|
|
||||||
<LightButton onClick={onClose}>
|
|
||||||
{t("cancel")}
|
|
||||||
</LightButton>
|
|
||||||
<PrimaryButton onClick={handleUpdate} loading={isSubmitting}>
|
|
||||||
{isSubmitting ? t("updating") : t("update")}
|
|
||||||
</PrimaryButton>
|
|
||||||
</Modal.Footer>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ArrowLeft, Plus } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { CardItem } from "./CardItem";
|
|
||||||
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 { VStack } from "@/design-system/layout/stack";
|
|
||||||
import { Skeleton } from "@/design-system/feedback/skeleton";
|
|
||||||
import { actionGetCardsByDeckId, actionDeleteCard } from "@/modules/card/card-action";
|
|
||||||
import { actionGetDeckById } from "@/modules/deck/deck-action";
|
|
||||||
import type { ActionOutputCard } from "@/modules/card/card-action-dto";
|
|
||||||
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { AddCardModal } from "./AddCardModal";
|
|
||||||
|
|
||||||
export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boolean }) {
|
|
||||||
const [cards, setCards] = useState<ActionOutputCard[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [openAddModal, setAddModal] = useState(false);
|
|
||||||
const [deckInfo, setDeckInfo] = useState<ActionOutputDeck | null>(null);
|
|
||||||
const router = useRouter();
|
|
||||||
const t = useTranslations("deck_id");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchCards = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const [cardsResult, deckResult] = await Promise.all([
|
|
||||||
actionGetCardsByDeckId({ deckId }),
|
|
||||||
actionGetDeckById({ deckId }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!cardsResult.success || !cardsResult.data) {
|
|
||||||
throw new Error(cardsResult.message || "Failed to load cards");
|
|
||||||
}
|
|
||||||
setCards(cardsResult.data);
|
|
||||||
|
|
||||||
if (deckResult.success && deckResult.data) {
|
|
||||||
setDeckInfo(deckResult.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchCards();
|
|
||||||
}, [deckId]);
|
|
||||||
|
|
||||||
const refreshCards = async () => {
|
|
||||||
const result = await actionGetCardsByDeckId({ deckId });
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setCards(result.data);
|
|
||||||
} else {
|
|
||||||
toast.error(result.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteCard = async (cardId: number) => {
|
|
||||||
try {
|
|
||||||
const result = await actionDeleteCard({ cardId });
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(t("cardDeleted"));
|
|
||||||
await refreshCards();
|
|
||||||
} else {
|
|
||||||
toast.error(result.message);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
<div className="mb-6">
|
|
||||||
<LinkButton
|
|
||||||
onClick={router.back}
|
|
||||||
className="flex items-center gap-2 mb-4"
|
|
||||||
>
|
|
||||||
<ArrowLeft size={16} />
|
|
||||||
<span className="text-sm">{t("back")}</span>
|
|
||||||
</LinkButton>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-1">
|
|
||||||
{deckInfo?.name || t("cards")}
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
{t("itemsCount", { count: cards.length })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<PrimaryButton
|
|
||||||
onClick={() => {
|
|
||||||
router.push(`/decks/${deckId}/learn`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("memorize")}
|
|
||||||
</PrimaryButton>
|
|
||||||
{!isReadOnly && (
|
|
||||||
<CircleButton
|
|
||||||
onClick={() => {
|
|
||||||
setAddModal(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus size={18} className="text-gray-700" />
|
|
||||||
</CircleButton>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardList>
|
|
||||||
{loading ? (
|
|
||||||
<VStack align="center" className="p-8">
|
|
||||||
<Skeleton variant="circular" className="w-8 h-8" />
|
|
||||||
<p className="text-sm text-gray-500">{t("loadingCards")}</p>
|
|
||||||
</VStack>
|
|
||||||
) : cards.length === 0 ? (
|
|
||||||
<div className="p-12 text-center">
|
|
||||||
<p className="text-sm text-gray-500 mb-2">{t("noCards")}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y divide-gray-100">
|
|
||||||
{cards.map((card) => (
|
|
||||||
<CardItem
|
|
||||||
key={card.id}
|
|
||||||
card={card}
|
|
||||||
isReadOnly={isReadOnly}
|
|
||||||
onDel={() => handleDeleteCard(card.id)}
|
|
||||||
onUpdated={refreshCards}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardList>
|
|
||||||
|
|
||||||
<AddCardModal
|
|
||||||
isOpen={openAddModal}
|
|
||||||
onClose={() => setAddModal(false)}
|
|
||||||
deckId={deckId}
|
|
||||||
onAdded={refreshCards}
|
|
||||||
/>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,468 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect, useTransition, useCallback, useRef } from "react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import localFont from "next/font/local";
|
|
||||||
import { Layers, Check, RotateCcw, Volume2, Headphones, ChevronLeft, ChevronRight, Shuffle, List, Repeat, Infinity } from "lucide-react";
|
|
||||||
import { actionGetCardsByDeckId } from "@/modules/card/card-action";
|
|
||||||
import type { ActionOutputCard } from "@/modules/card/card-action-dto";
|
|
||||||
import { PageLayout } from "@/components/ui/PageLayout";
|
|
||||||
import { LightButton, CircleButton } from "@/design-system/base/button";
|
|
||||||
import { Progress } from "@/design-system/feedback/progress";
|
|
||||||
import { Skeleton } from "@/design-system/feedback/skeleton";
|
|
||||||
import { HStack, VStack } from "@/design-system/layout/stack";
|
|
||||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
|
||||||
import { getTTSUrl, type TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
|
||||||
|
|
||||||
const myFont = localFont({
|
|
||||||
src: "../../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
|
|
||||||
});
|
|
||||||
|
|
||||||
type StudyMode = "order-limited" | "order-infinite" | "random-limited" | "random-infinite";
|
|
||||||
|
|
||||||
interface MemorizeProps {
|
|
||||||
deckId: number;
|
|
||||||
deckName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
|
|
||||||
const t = useTranslations("memorize.review");
|
|
||||||
const router = useRouter();
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
|
|
||||||
const [originalCards, setOriginalCards] = useState<ActionOutputCard[]>([]);
|
|
||||||
const [cards, setCards] = useState<ActionOutputCard[]>([]);
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
|
||||||
const [showAnswer, setShowAnswer] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [isReversed, setIsReversed] = useState(false);
|
|
||||||
const [isDictation, setIsDictation] = useState(false);
|
|
||||||
const [studyMode, setStudyMode] = useState<StudyMode>("order-limited");
|
|
||||||
const { play, stop, load } = useAudioPlayer();
|
|
||||||
const audioUrlRef = useRef<string | null>(null);
|
|
||||||
const [isAudioLoading, setIsAudioLoading] = useState(false);
|
|
||||||
|
|
||||||
const shuffleCards = useCallback((cardArray: ActionOutputCard[]): ActionOutputCard[] => {
|
|
||||||
const shuffled = [...cardArray];
|
|
||||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
||||||
}
|
|
||||||
return shuffled;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let ignore = false;
|
|
||||||
|
|
||||||
const loadCards = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
startTransition(async () => {
|
|
||||||
const result = await actionGetCardsByDeckId({ deckId, limit: 100 });
|
|
||||||
if (!ignore) {
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setOriginalCards(result.data);
|
|
||||||
setCards(result.data);
|
|
||||||
setCurrentIndex(0);
|
|
||||||
setShowAnswer(false);
|
|
||||||
setIsReversed(false);
|
|
||||||
setIsDictation(false);
|
|
||||||
} else {
|
|
||||||
setError(result.message);
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
loadCards();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
ignore = true;
|
|
||||||
};
|
|
||||||
}, [deckId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (studyMode.startsWith("random")) {
|
|
||||||
setCards(shuffleCards(originalCards));
|
|
||||||
} else {
|
|
||||||
setCards(originalCards);
|
|
||||||
}
|
|
||||||
setCurrentIndex(0);
|
|
||||||
setShowAnswer(false);
|
|
||||||
}, [studyMode, originalCards, shuffleCards]);
|
|
||||||
|
|
||||||
const getCurrentCard = (): ActionOutputCard | null => {
|
|
||||||
return cards[currentIndex] ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFrontText = (card: ActionOutputCard): string => {
|
|
||||||
if (isReversed) {
|
|
||||||
return card.meanings.map((m) =>
|
|
||||||
m.partOfSpeech ? `${m.partOfSpeech}: ${m.definition}` : m.definition
|
|
||||||
).join("; ");
|
|
||||||
}
|
|
||||||
return card.word;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBackContent = (card: ActionOutputCard): React.ReactNode => {
|
|
||||||
if (isReversed) {
|
|
||||||
return <span className="text-gray-900 text-xl md:text-2xl text-center">{card.word}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack align="stretch" gap={2} className="w-full max-w-lg">
|
|
||||||
{card.meanings.map((m, idx) => (
|
|
||||||
<div key={idx} className="flex gap-3 text-left">
|
|
||||||
{m.partOfSpeech && (
|
|
||||||
<span className="text-primary-600 text-sm font-medium min-w-[60px] shrink-0">
|
|
||||||
{m.partOfSpeech}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-gray-800">{m.definition}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleShowAnswer = useCallback(() => {
|
|
||||||
setShowAnswer(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const isInfinite = studyMode.endsWith("infinite");
|
|
||||||
|
|
||||||
const handleNextCard = useCallback(() => {
|
|
||||||
if (isInfinite) {
|
|
||||||
if (currentIndex >= cards.length - 1) {
|
|
||||||
if (studyMode.startsWith("random")) {
|
|
||||||
setCards(shuffleCards(originalCards));
|
|
||||||
}
|
|
||||||
setCurrentIndex(0);
|
|
||||||
} else {
|
|
||||||
setCurrentIndex(currentIndex + 1);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (currentIndex < cards.length - 1) {
|
|
||||||
setCurrentIndex(currentIndex + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setShowAnswer(false);
|
|
||||||
setIsReversed(false);
|
|
||||||
setIsDictation(false);
|
|
||||||
cleanupAudio();
|
|
||||||
}, [currentIndex, cards.length, isInfinite, studyMode, originalCards, shuffleCards]);
|
|
||||||
|
|
||||||
const handlePrevCard = useCallback(() => {
|
|
||||||
if (isInfinite) {
|
|
||||||
if (currentIndex <= 0) {
|
|
||||||
setCurrentIndex(cards.length - 1);
|
|
||||||
} else {
|
|
||||||
setCurrentIndex(currentIndex - 1);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (currentIndex > 0) {
|
|
||||||
setCurrentIndex(currentIndex - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setShowAnswer(false);
|
|
||||||
setIsReversed(false);
|
|
||||||
setIsDictation(false);
|
|
||||||
cleanupAudio();
|
|
||||||
}, [currentIndex, cards.length, isInfinite]);
|
|
||||||
|
|
||||||
const cleanupAudio = useCallback(() => {
|
|
||||||
if (audioUrlRef.current) {
|
|
||||||
URL.revokeObjectURL(audioUrlRef.current);
|
|
||||||
audioUrlRef.current = null;
|
|
||||||
}
|
|
||||||
stop();
|
|
||||||
}, [stop]);
|
|
||||||
|
|
||||||
const playTTS = useCallback(async (text: string) => {
|
|
||||||
if (isAudioLoading) return;
|
|
||||||
|
|
||||||
setIsAudioLoading(true);
|
|
||||||
try {
|
|
||||||
const hasChinese = /[\u4e00-\u9fff]/.test(text);
|
|
||||||
const hasJapanese = /[\u3040-\u309f\u30a0-\u30ff]/.test(text);
|
|
||||||
const hasKorean = /[\uac00-\ud7af]/.test(text);
|
|
||||||
|
|
||||||
let lang: TTS_SUPPORTED_LANGUAGES = "Auto";
|
|
||||||
if (hasChinese) lang = "Chinese";
|
|
||||||
else if (hasJapanese) lang = "Japanese";
|
|
||||||
else if (hasKorean) lang = "Korean";
|
|
||||||
else if (/^[a-zA-Z\s]/.test(text)) lang = "English";
|
|
||||||
|
|
||||||
const audioUrl = await getTTSUrl(text, lang);
|
|
||||||
|
|
||||||
if (audioUrl && audioUrl !== "error") {
|
|
||||||
audioUrlRef.current = audioUrl;
|
|
||||||
await load(audioUrl);
|
|
||||||
play();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("TTS playback failed", e);
|
|
||||||
} finally {
|
|
||||||
setIsAudioLoading(false);
|
|
||||||
}
|
|
||||||
}, [isAudioLoading, load, play]);
|
|
||||||
|
|
||||||
const playCurrentCard = useCallback(() => {
|
|
||||||
const currentCard = getCurrentCard();
|
|
||||||
if (!currentCard) return;
|
|
||||||
|
|
||||||
const text = isReversed
|
|
||||||
? currentCard.meanings.map((m) => m.definition).join("; ")
|
|
||||||
: currentCard.word;
|
|
||||||
|
|
||||||
if (text) {
|
|
||||||
playTTS(text);
|
|
||||||
}
|
|
||||||
}, [isReversed, playTTS]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!showAnswer) {
|
|
||||||
if (e.key === " " || e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
handleShowAnswer();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (e.key === "ArrowRight" || e.key === " " || e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
handleNextCard();
|
|
||||||
} else if (e.key === "ArrowLeft") {
|
|
||||||
e.preventDefault();
|
|
||||||
handlePrevCard();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [showAnswer, handleShowAnswer, handleNextCard, handlePrevCard]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
<VStack align="center" className="py-12">
|
|
||||||
<Skeleton variant="circular" className="h-12 w-12 mb-4" />
|
|
||||||
<p className="text-gray-600">{t("loading")}</p>
|
|
||||||
</VStack>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
<VStack align="center" className="py-12">
|
|
||||||
<div className="text-red-600 mb-4 px-4 py-3 bg-red-50 border border-red-200 rounded-lg max-w-md">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
<LightButton onClick={() => router.push("/decks")} className="px-4 py-2">
|
|
||||||
{t("backToDecks")}
|
|
||||||
</LightButton>
|
|
||||||
</VStack>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cards.length === 0) {
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
<VStack align="center" className="py-12">
|
|
||||||
<div className="text-green-500 mb-4">
|
|
||||||
<Check className="w-16 h-16 mx-auto" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-800 mb-2">{t("allDone")}</h2>
|
|
||||||
<p className="text-gray-600 mb-6">{t("allDoneDesc")}</p>
|
|
||||||
<LightButton onClick={() => router.push("/decks")} className="px-4 py-2">
|
|
||||||
{t("backToDecks")}
|
|
||||||
</LightButton>
|
|
||||||
</VStack>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentCard = getCurrentCard()!;
|
|
||||||
const displayFront = getFrontText(currentCard);
|
|
||||||
const isFinished = !isInfinite && currentIndex === cards.length - 1 && showAnswer;
|
|
||||||
|
|
||||||
const studyModeOptions: { value: StudyMode; label: string; icon: React.ReactNode }[] = [
|
|
||||||
{ value: "order-limited", label: t("orderLimited"), icon: <List className="w-4 h-4" /> },
|
|
||||||
{ value: "order-infinite", label: t("orderInfinite"), icon: <Repeat className="w-4 h-4" /> },
|
|
||||||
{ value: "random-limited", label: t("randomLimited"), icon: <Shuffle className="w-4 h-4" /> },
|
|
||||||
{ value: "random-infinite", label: t("randomInfinite"), icon: <Infinity className="w-4 h-4" /> },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
<HStack justify="between" className="mb-4">
|
|
||||||
<HStack gap={2} className="text-gray-600">
|
|
||||||
<Layers className="w-5 h-5" />
|
|
||||||
<span className="font-medium">{deckName}</span>
|
|
||||||
</HStack>
|
|
||||||
{!isInfinite && (
|
|
||||||
<span className="text-sm text-gray-500">
|
|
||||||
{t("progress", { current: currentIndex + 1, total: cards.length })}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{!isInfinite && (
|
|
||||||
<Progress
|
|
||||||
value={((currentIndex + 1) / cards.length) * 100}
|
|
||||||
showLabel={false}
|
|
||||||
animated={false}
|
|
||||||
className="mb-6"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<VStack gap={2} className="mb-4">
|
|
||||||
<HStack justify="center" gap={1} className="flex-wrap">
|
|
||||||
{studyModeOptions.map((option) => (
|
|
||||||
<LightButton
|
|
||||||
key={option.value}
|
|
||||||
onClick={() => setStudyMode(option.value)}
|
|
||||||
selected={studyMode === option.value}
|
|
||||||
leftIcon={option.icon}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</LightButton>
|
|
||||||
))}
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
<HStack justify="center" gap={2}>
|
|
||||||
<LightButton
|
|
||||||
onClick={() => {
|
|
||||||
setIsReversed(!isReversed);
|
|
||||||
setShowAnswer(false);
|
|
||||||
}}
|
|
||||||
selected={isReversed}
|
|
||||||
leftIcon={<RotateCcw className="w-4 h-4" />}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{t("reverse")}
|
|
||||||
</LightButton>
|
|
||||||
<LightButton
|
|
||||||
onClick={() => {
|
|
||||||
setIsDictation(!isDictation);
|
|
||||||
}}
|
|
||||||
selected={isDictation}
|
|
||||||
leftIcon={<Headphones className="w-4 h-4" />}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{t("dictation")}
|
|
||||||
</LightButton>
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<div className={`bg-white border border-gray-200 rounded-xl shadow-sm mb-6 h-[50dvh] flex flex-col ${myFont.className}`}>
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{isDictation ? (
|
|
||||||
<>
|
|
||||||
<VStack align="center" justify="center" gap={4} className="p-8 min-h-[20dvh]">
|
|
||||||
{currentCard.ipa ? (
|
|
||||||
<div className="text-gray-700 text-2xl text-center font-mono">
|
|
||||||
{currentCard.ipa}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-gray-400 text-lg">
|
|
||||||
{t("noIpa")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
{showAnswer && (
|
|
||||||
<>
|
|
||||||
<div className="border-t border-gray-200" />
|
|
||||||
<VStack align="center" justify="center" className="p-8 min-h-[20dvh] bg-gray-50 rounded-b-xl">
|
|
||||||
<div className="text-gray-900 text-xl md:text-2xl text-center whitespace-pre-line">
|
|
||||||
{displayFront}
|
|
||||||
</div>
|
|
||||||
{getBackContent(currentCard)}
|
|
||||||
</VStack>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<HStack align="center" justify="center" className="p-8 min-h-[20dvh]">
|
|
||||||
<div className="text-gray-900 text-xl md:text-2xl text-center whitespace-pre-line">
|
|
||||||
{displayFront}
|
|
||||||
</div>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{showAnswer && (
|
|
||||||
<>
|
|
||||||
<div className="border-t border-gray-200" />
|
|
||||||
<VStack align="center" justify="center" className="p-8 min-h-[20dvh] bg-gray-50 rounded-b-xl">
|
|
||||||
{getBackContent(currentCard)}
|
|
||||||
</VStack>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<HStack justify="center">
|
|
||||||
{!showAnswer ? (
|
|
||||||
<LightButton
|
|
||||||
onClick={handleShowAnswer}
|
|
||||||
disabled={isPending}
|
|
||||||
className="px-8 py-3 text-lg rounded-full"
|
|
||||||
>
|
|
||||||
{t("showAnswer")}
|
|
||||||
<span className="ml-2 text-xs opacity-60">Space</span>
|
|
||||||
</LightButton>
|
|
||||||
) : isFinished ? (
|
|
||||||
<VStack align="center" gap={4}>
|
|
||||||
<div className="text-green-500">
|
|
||||||
<Check className="w-12 h-12" />
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-600">{t("allDoneDesc")}</p>
|
|
||||||
<HStack gap={2}>
|
|
||||||
<LightButton onClick={() => router.push("/decks")} className="px-4 py-2">
|
|
||||||
{t("backToDecks")}
|
|
||||||
</LightButton>
|
|
||||||
<LightButton onClick={() => setCurrentIndex(0)} className="px-4 py-2">
|
|
||||||
{t("restart")}
|
|
||||||
</LightButton>
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
) : (
|
|
||||||
<HStack gap={4}>
|
|
||||||
<LightButton
|
|
||||||
onClick={handlePrevCard}
|
|
||||||
className="px-4 py-2"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-5 h-5" />
|
|
||||||
</LightButton>
|
|
||||||
<span className="text-gray-500 text-sm">
|
|
||||||
{t("nextCard")}
|
|
||||||
<span className="ml-2 text-xs opacity-60">Space</span>
|
|
||||||
</span>
|
|
||||||
<LightButton
|
|
||||||
onClick={handleNextCard}
|
|
||||||
className="px-4 py-2"
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-5 h-5" />
|
|
||||||
</LightButton>
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { Memorize };
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { actionGetDeckById } from "@/modules/deck/deck-action";
|
|
||||||
import { Memorize } from "./Memorize";
|
|
||||||
|
|
||||||
export default async function LearnPage({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ deck_id: string }>;
|
|
||||||
}) {
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
|
||||||
const { deck_id } = await params;
|
|
||||||
const deckId = Number(deck_id);
|
|
||||||
|
|
||||||
if (!deckId) {
|
|
||||||
redirect("/decks");
|
|
||||||
}
|
|
||||||
|
|
||||||
const deckInfo = (await actionGetDeckById({ deckId })).data;
|
|
||||||
|
|
||||||
if (!deckInfo) {
|
|
||||||
redirect("/decks");
|
|
||||||
}
|
|
||||||
|
|
||||||
const isOwner = session?.user?.id === deckInfo.userId;
|
|
||||||
const isPublic = deckInfo.visibility === "PUBLIC";
|
|
||||||
|
|
||||||
if (!isOwner && !isPublic) {
|
|
||||||
redirect("/decks");
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Memorize deckId={deckId} deckName={deckInfo.name} />;
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import { InDeck } from "./InDeck";
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { actionGetDeckById } from "@/modules/deck/deck-action";
|
|
||||||
|
|
||||||
export default async function DecksPage({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ deck_id: number; }>;
|
|
||||||
}) {
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
|
||||||
const { deck_id } = await params;
|
|
||||||
const t = await getTranslations("deck_id");
|
|
||||||
|
|
||||||
if (!deck_id) {
|
|
||||||
redirect("/decks");
|
|
||||||
}
|
|
||||||
|
|
||||||
const deckInfo = (await actionGetDeckById({ deckId: Number(deck_id) })).data;
|
|
||||||
|
|
||||||
if (!deckInfo) {
|
|
||||||
redirect("/decks");
|
|
||||||
}
|
|
||||||
|
|
||||||
const isOwner = session?.user?.id === deckInfo.userId;
|
|
||||||
const isPublic = deckInfo.visibility === "PUBLIC";
|
|
||||||
|
|
||||||
if (!isOwner && !isPublic) {
|
|
||||||
redirect("/decks");
|
|
||||||
}
|
|
||||||
|
|
||||||
const isReadOnly = !isOwner;
|
|
||||||
|
|
||||||
return <InDeck deckId={Number(deck_id)} isReadOnly={isReadOnly} />;
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { auth } from "@/auth";
|
|
||||||
import { DecksClient } from "./DecksClient";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export default async function DecksPage() {
|
|
||||||
const session = await auth.api.getSession(
|
|
||||||
{ headers: await headers() }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
redirect("/login?redirect=/decks");
|
|
||||||
}
|
|
||||||
|
|
||||||
return <DecksClient userId={session.user.id} />;
|
|
||||||
}
|
|
||||||
174
src/app/folders/FoldersClient.tsx
Normal file
174
src/app/folders/FoldersClient.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
Folder as Fd,
|
||||||
|
FolderPen,
|
||||||
|
FolderPlus,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
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";
|
||||||
|
|
||||||
|
interface FolderProps {
|
||||||
|
folder: Folder & { total: number };
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FolderCard = ({ folder, refresh }: FolderProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const t = useTranslations("folders");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
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-3 flex-1">
|
||||||
|
<div className="shrink-0">
|
||||||
|
<Fd className="text-gray-600" size={24} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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,
|
||||||
|
totalPairs: folder.total,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<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={16} />
|
||||||
|
</button>
|
||||||
|
<ChevronRight size={18} className="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FoldersClient({ userId }: { userId: string }) {
|
||||||
|
const t = useTranslations("folders");
|
||||||
|
const [folders, setFolders] = useState<(Folder & { total: number })[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
getFoldersWithTotalPairsByUserId(userId)
|
||||||
|
.then((folders) => {
|
||||||
|
setFolders(folders);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("加载出错,请重试。");
|
||||||
|
});
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
const updateFolders = async () => {
|
||||||
|
try {
|
||||||
|
const updatedFolders = await getFoldersWithTotalPairsByUserId(userId);
|
||||||
|
setFolders(updatedFolders);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||||
|
|
||||||
|
{/* 新建文件夹按钮 */}
|
||||||
|
<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} />
|
||||||
|
<span>{loading ? t("creating") : t("newFolder")}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 文件夹列表 */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<CardList>
|
||||||
|
{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">
|
||||||
|
<FolderPlus size={24} className="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm">{t("noFoldersYet")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 文件夹卡片列表
|
||||||
|
<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}
|
||||||
|
refresh={updateFolders}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardList>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
src/app/folders/[folder_id]/AddTextPairModal.tsx
Normal file
146
src/app/folders/[folder_id]/AddTextPairModal.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
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;
|
||||||
|
onClose: () => void;
|
||||||
|
onAdd: (
|
||||||
|
text1: string,
|
||||||
|
text2: string,
|
||||||
|
locale1: string,
|
||||||
|
locale2: string,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}: AddTextPairModalProps) {
|
||||||
|
const t = useTranslations("folder_id");
|
||||||
|
const input1Ref = useRef<HTMLInputElement>(null);
|
||||||
|
const input2Ref = useRef<HTMLInputElement>(null);
|
||||||
|
const [locale1, setLocale1] = useState("en-US");
|
||||||
|
const [locale2, setLocale2] = useState("zh-CN");
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (
|
||||||
|
!input1Ref.current?.value ||
|
||||||
|
!input2Ref.current?.value ||
|
||||||
|
!locale1 ||
|
||||||
|
!locale2
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const text1 = input1Ref.current.value;
|
||||||
|
const text2 = input2Ref.current.value;
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof text1 === "string" &&
|
||||||
|
typeof text2 === "string" &&
|
||||||
|
typeof locale1 === "string" &&
|
||||||
|
typeof locale2 === "string" &&
|
||||||
|
text1.trim() !== "" &&
|
||||||
|
text2.trim() !== "" &&
|
||||||
|
locale1.trim() !== "" &&
|
||||||
|
locale2.trim() !== ""
|
||||||
|
) {
|
||||||
|
onAdd(text1, text2, locale1, locale2);
|
||||||
|
input1Ref.current.value = "";
|
||||||
|
input2Ref.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAdd();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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")}
|
||||||
|
</h2>
|
||||||
|
<X onClick={onClose} className="hover:cursor-pointer"></X>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
{t("text1")}
|
||||||
|
<Input ref={input1Ref} className="w-full"></Input>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t("text2")}
|
||||||
|
<Input ref={input2Ref} className="w-full"></Input>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t("locale1")}
|
||||||
|
<LocaleSelector value={locale1} onChange={setLocale1} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t("locale2")}
|
||||||
|
<LocaleSelector value={locale2} onChange={setLocale2} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LightButton onClick={handleAdd}>{t("add")}</LightButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
src/app/folders/[folder_id]/InFolder.tsx
Normal file
161
src/app/folders/[folder_id]/InFolder.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowLeft, Plus } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { redirect, useRouter } from "next/navigation";
|
||||||
|
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 { 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 default function InFolder({ folderId }: { folderId: number }) {
|
||||||
|
const [textPairs, setTextPairs] = useState<TextPair[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [openAddModal, setAddModal] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const t = useTranslations("folder_id");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTextPairs = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
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 () => {
|
||||||
|
try {
|
||||||
|
const data = await getPairsByFolderId(folderId);
|
||||||
|
setTextPairs(data as TextPair[]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch text pairs:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
{/* 顶部导航和标题栏 */}
|
||||||
|
<div className="mb-6">
|
||||||
|
{/* 返回按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={router.back}
|
||||||
|
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>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 页面标题和操作按钮 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{/* 标题区域 */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-1">
|
||||||
|
{t("textPairs")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{t("itemsCount", { count: textPairs.length })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮区域 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<GreenButton
|
||||||
|
onClick={() => {
|
||||||
|
redirect(`/memorize?folder_id=${folderId}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("memorize")}
|
||||||
|
</GreenButton>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => {
|
||||||
|
setAddModal(true);
|
||||||
|
}}
|
||||||
|
icon={<Plus size={18} className="text-gray-700" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 文本对列表 */}
|
||||||
|
<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("loadingTextPairs")}</p>
|
||||||
|
</div>
|
||||||
|
) : textPairs.length === 0 ? (
|
||||||
|
// 空状态
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<p className="text-sm text-gray-500 mb-2">{t("noTextPairs")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 文本对卡片列表
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{textPairs
|
||||||
|
.toSorted((a, b) => a.id - b.id)
|
||||||
|
.map((textPair) => (
|
||||||
|
<TextPairCard
|
||||||
|
key={textPair.id}
|
||||||
|
textPair={textPair}
|
||||||
|
onDel={() => {
|
||||||
|
deletePairById(textPair.id);
|
||||||
|
refreshTextPairs();
|
||||||
|
}}
|
||||||
|
refreshTextPairs={refreshTextPairs}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardList>
|
||||||
|
|
||||||
|
{/* 添加文本对模态框 */}
|
||||||
|
<AddTextPairModal
|
||||||
|
isOpen={openAddModal}
|
||||||
|
onClose={() => setAddModal(false)}
|
||||||
|
onAdd={async (
|
||||||
|
text1: string,
|
||||||
|
text2: string,
|
||||||
|
locale1: string,
|
||||||
|
locale2: string,
|
||||||
|
) => {
|
||||||
|
await createPair({
|
||||||
|
text1: text1,
|
||||||
|
text2: text2,
|
||||||
|
locale1: locale1,
|
||||||
|
locale2: locale2,
|
||||||
|
folder: {
|
||||||
|
connect: {
|
||||||
|
id: folderId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
refreshTextPairs();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user