Compare commits
87 Commits
0149fde0bd
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| dda7d64dee | |||
| 911343ce0d | |||
| 130ab226ff | |||
| 59d22ccf4c | |||
| 06012c43f2 | |||
| c54376cbe6 | |||
| 3ed3478c66 | |||
| bc7608e049 | |||
| 1ef337801d | |||
| 286add7fff | |||
| de7c1321c2 | |||
| 95ce49378b | |||
| 2f5ec1c0f0 | |||
| f53fa5e2a1 | |||
| 1d5732abc8 | |||
| ada2f249ee | |||
| bc0b392875 | |||
| a68951f1d3 | |||
| c525bd4591 | |||
| 6213dd2338 | |||
| af684a15ce | |||
| 279eee2953 | |||
| 168f0c161e | |||
| 7c71ffcf31 | |||
| 4243cdc68b | |||
| cbb9326f84 | |||
| 49ad953add | |||
| f1eafa8015 | |||
| 12e502313b | |||
| 13e8f51ada | |||
| 7ba31a37bd | |||
| 4d4062985d | |||
| 804c28ada9 | |||
| e68e24a9fb | |||
| 8099320e00 | |||
| db0b0ff348 | |||
| 6f4b123a84 | |||
| 57ad1b8699 | |||
| 9b78fd5215 | |||
| 683a4104ec | |||
| abcae1b8d1 | |||
| 6b9fba254d | |||
| 0cb240791b | |||
| d9fd09c13d | |||
| 5406543cbe | |||
| d2a3d32376 | |||
| 436d58be52 | |||
| 11a265d52e | |||
| fb4346377a | |||
| c83aefabfa | |||
| 020744b353 | |||
| 719aef5a7f | |||
| 6c811a77db | |||
| 3652e350e6 | |||
| 6ba5ae993a | |||
| b643205f72 | |||
| c6878ed1e5 | |||
| e74cd80fac | |||
| c01c94abd0 | |||
| 0881846717 | |||
| d7149366e9 | |||
| b0fa1a4201 | |||
| b407783d61 | |||
| ca33d4353f | |||
| ff57f5e0a5 | |||
| 91c59c3ad9 | |||
| 1df184d1ad | |||
| f6e21aa2fe | |||
| 67ac0bf7b6 | |||
| dd1c6a7b52 | |||
| e2d8e17f62 | |||
| 63486757b9 | |||
| 45ffe5733b | |||
| 613df6824b | |||
| bf80e17514 | |||
| d71c79c87a | |||
| 559690dc56 | |||
| 884b30d7f6 | |||
| 01cd122d93 | |||
| 94840c1b0a | |||
| 72ced7866e | |||
| 690222ccb7 | |||
| 6dc933dc1e | |||
| 1be24065e0 | |||
| 757c27c94a | |||
| 6ea8b4d4b9 | |||
| 9e9ac373c6 |
@@ -2,6 +2,8 @@
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: learn-languages
|
||||
concurrency:
|
||||
limit: 1
|
||||
|
||||
platform:
|
||||
os: linux
|
||||
|
||||
@@ -13,3 +13,11 @@ 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
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -51,4 +51,4 @@ test.js
|
||||
|
||||
certificates
|
||||
|
||||
.claude
|
||||
.opencode
|
||||
|
||||
222
AGENTS.md
Normal file
222
AGENTS.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# 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/`)
|
||||
- 未配置测试基础设施
|
||||
128
CLAUDE.md
128
CLAUDE.md
@@ -1,128 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
本文件为 Claude Code (claude.ai/code) 在此代码库中工作时提供指导。
|
||||
|
||||
## 项目概述
|
||||
|
||||
这是一个基于 Next.js 16 构建的全栈语言学习平台,提供翻译工具、文本转语音、字幕播放、字母学习和记忆功能。平台支持 8 种语言,具有完整的国际化支持。
|
||||
|
||||
## 开发命令
|
||||
|
||||
```bash
|
||||
# 启动开发服务器(启用 HTTPS)
|
||||
pnpm run dev
|
||||
|
||||
# 构建生产版本(standalone 输出模式,用于 Docker)
|
||||
pnpm run build
|
||||
|
||||
# 启动生产服务器
|
||||
pnpm run start
|
||||
|
||||
# 代码检查
|
||||
pnpm run lint
|
||||
|
||||
# 数据库操作
|
||||
# 不要进行数据库操作,让用户操作数据库
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **Next.js 16** 使用 App Router 和 standalone 输出模式
|
||||
- **React 19** 启用 React Compiler 进行优化
|
||||
- **TypeScript** 严格模式和 ES2023 目标
|
||||
- **Tailwind CSS v4** 样式框架
|
||||
- **PostgreSQL** + **Prisma ORM**(自定义输出目录:`src/generated/prisma`)
|
||||
- **better-auth** 身份验证(邮箱/密码 + OAuth)
|
||||
- **next-intl** 国际化(支持:en-US, zh-CN, ja-JP, ko-KR, de-DE, fr-FR, it-IT, ug-CN)
|
||||
- **阿里云千问 TTS** (qwen3-tts-flash) 文本转语音
|
||||
- **pnpm** 包管理器
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 路由结构
|
||||
|
||||
应用使用 Next.js App Router 和基于功能的组织方式:
|
||||
|
||||
```
|
||||
src/app/
|
||||
├── (features)/ # 功能模块(translator, alphabet, memorize, dictionary, srt-player)
|
||||
│ └── [locale]/ # 国际化路由
|
||||
├── auth/ # 认证页面(sign-in, sign-up)
|
||||
├── folders/ # 用户学习文件夹管理
|
||||
├── users/[username]/# 用户资料页面(Server Component)
|
||||
├── profile/ # 重定向到当前用户的资料页面
|
||||
└── api/ # API 路由
|
||||
```
|
||||
|
||||
### 后端架构模式
|
||||
|
||||
项目使用 **Action-Service-Repository 三层架构**:
|
||||
|
||||
```
|
||||
src/modules/{module}/
|
||||
├── {module}-action.ts # Server Actions 层(表单处理、重定向)
|
||||
├── {module}-action-dto.ts # Action 层 DTO(Zod 验证)
|
||||
├── {module}-service.ts # Service 层(业务逻辑)
|
||||
├── {module}-service-dto.ts # Service 层 DTO
|
||||
├── {module}-repository.ts # Repository 层(数据库操作)
|
||||
└── {module}-repository-dto.ts # Repository 层 DTO
|
||||
```
|
||||
|
||||
各层职责:
|
||||
- **Action 层**:处理表单数据、验证输入、调用 service 层、处理重定向和错误响应
|
||||
- **Service 层**:实现业务逻辑、调用 better-auth API、协调多个 repository 操作
|
||||
- **Repository 层**:直接使用 Prisma 进行数据库查询和操作
|
||||
|
||||
现有模块:
|
||||
- `auth` - 认证和用户管理(支持用户名/邮箱登录)
|
||||
- `folder` - 学习文件夹管理
|
||||
- `dictionary` - 词典查询
|
||||
- `translator` - 翻译服务
|
||||
|
||||
### 数据库 Schema
|
||||
|
||||
核心模型(见 [prisma/schema.prisma](prisma/schema.prisma)):
|
||||
- **User**: 用户中心实体,包含认证信息
|
||||
- **Folder**: 用户拥有的学习资料容器(级联删除 pairs)
|
||||
- **Pair**: 语言对(翻译/词汇),支持 IPA,唯一约束为 (folderId, locale1, locale2, text1)
|
||||
- **Session/Account**: better-auth 追踪
|
||||
- **Verification**: 邮箱验证系统
|
||||
|
||||
### 核心模式
|
||||
|
||||
**Server Actions**: 数据库变更使用 `src/lib/actions/` 中的 Server Actions,配合类型安全的 Prisma 操作。
|
||||
|
||||
**基于功能的组件**: 每个功能在 `(features)/` 下有自己的路由组,带有 locale 前缀。
|
||||
|
||||
**国际化**: 所有面向用户的内容通过 next-intl 处理。消息文件在 `messages/` 目录。locale 自动检测并在路由中前缀。
|
||||
|
||||
**认证流程**: better-auth 使用客户端适配器 (`authClient`),通过 hooks 管理会话,受保护的路由使用条件渲染。
|
||||
|
||||
**LLM 集成**: 使用智谱 AI API 进行翻译和 IPA 生成。通过环境变量 `ZHIPU_API_KEY` 和 `ZHIPU_MODEL_NAME` 配置。
|
||||
|
||||
- **Standalone 输出**: 为 Docker 部署配置
|
||||
- **React Compiler**: 在 `next.config.ts` 中启用以自动优化
|
||||
- **HTTPS 开发**: 开发服务器使用 `--experimental-https` 标志
|
||||
- **图片优化**: 通过 remote patterns 允许 GitHub 头像
|
||||
|
||||
## 代码组织
|
||||
|
||||
- `src/modules/`: 业务模块(auth, folder, dictionary, translator)
|
||||
- `src/lib/actions/`: 数据库变更的 Server Actions(旧架构,正在迁移到 modules)
|
||||
- `src/lib/server/`: 服务端工具(AI 集成、认证、翻译器)
|
||||
- `src/lib/browser/`: 客户端工具
|
||||
- `src/hooks/`: 自定义 React hooks(认证 hooks、会话管理)
|
||||
- `src/i18n/`: 国际化配置
|
||||
- `messages/`: 各支持语言的翻译文件
|
||||
- `src/components/`: 可复用的 UI 组件(buttons, cards 等)
|
||||
- `src/shared/`: 共享常量和类型定义
|
||||
|
||||
## 开发注意事项
|
||||
|
||||
- 使用 pnpm,而不是 npm 或 yarn
|
||||
- 应用使用 TypeScript 严格模式 - 确保类型安全
|
||||
- 所有面向用户的文本都需要国际化
|
||||
- **优先使用 Server Components**,只在需要交互时使用 Client Components
|
||||
- **新功能应遵循 action-service-repository 架构**
|
||||
- Better-auth 处理会话管理 - 使用 authClient 适配器进行认证操作
|
||||
- 使用 better-auth username 插件支持用户名登录
|
||||
545
README.md
545
README.md
@@ -1,189 +1,372 @@
|
||||
# 多语言学习平台
|
||||
# 🌍 多语言学习平台
|
||||
|
||||
一个基于 Next.js 构建的全功能多语言学习平台,提供翻译、发音、字幕播放、字母学习等多种语言学习工具,帮助用户更高效地掌握新语言。
|
||||
<div align="center">
|
||||
|
||||
## ✨ 主要功能
|
||||
[](https://nextjs.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://www.postgresql.org/)
|
||||
[](./LICENSE)
|
||||
|
||||
- **智能翻译工具** - 支持多语言互译,包含国际音标(IPA)标注
|
||||
- **文本语音合成** - 将文本转换为自然语音,提高发音学习效果
|
||||
- **SRT字幕播放器** - 结合视频字幕学习,支持多种字幕格式
|
||||
- **字母学习模块** - 针对初学者的字母和发音基础学习
|
||||
- **记忆强化工具** - 通过科学记忆法巩固学习内容
|
||||
- **词典查询** - 查询单词和短语,提供详细释义和例句
|
||||
- **个人学习空间** - 用户可以创建、管理和组织自己的学习资料
|
||||
- **用户资料系统** - 支持用户名登录、个人资料页面展示
|
||||
**一个现代化的全栈多语言学习平台,集成 AI 驱动的翻译、发音、词典和学习管理功能**
|
||||
|
||||
## 🛠 技术栈
|
||||
[在线演示](#) · [报告问题](../../issues) · [功能建议](../../issues)
|
||||
|
||||
### 前端框架
|
||||
- **Next.js 16** - React 全栈框架,使用 App Router
|
||||
- **React 19** - 用户界面构建
|
||||
- **TypeScript** - 类型安全的 JavaScript
|
||||
- **Tailwind CSS** - 实用优先的 CSS 框架
|
||||
|
||||
### 数据与后端
|
||||
- **PostgreSQL** - 主数据库
|
||||
- **Prisma** - 现代数据库工具包和 ORM
|
||||
- **better-auth** - 安全的身份验证系统
|
||||
|
||||
### 国际化与辅助功能
|
||||
- **next-intl** - 国际化解决方案
|
||||
- **阿里云千问 TTS** - qwen3-tts-flash 语音合成
|
||||
|
||||
### 开发工具
|
||||
- **ESLint** - 代码质量检查
|
||||
- **pnpm** - 高效的包管理器
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js App Router 路由
|
||||
│ ├── (features)/ # 功能模块路由
|
||||
│ ├── auth/ # 认证相关页面
|
||||
│ ├── profile/ # 用户资料重定向
|
||||
│ ├── users/[username]/ # 用户资料页面
|
||||
│ ├── folders/ # 文件夹管理
|
||||
│ └── api/ # API 路由
|
||||
├── modules/ # 业务模块(action-service-repository 架构)
|
||||
│ ├── auth/ # 认证模块
|
||||
│ ├── folder/ # 文件夹模块
|
||||
│ ├── dictionary/ # 词典模块
|
||||
│ └── translator/ # 翻译模块
|
||||
├── components/ # React 组件
|
||||
│ ├── buttons/ # 按钮组件
|
||||
│ ├── cards/ # 卡片组件
|
||||
│ └── ...
|
||||
├── lib/ # 工具函数和库
|
||||
│ ├── actions/ # Server Actions
|
||||
│ ├── browser/ # 浏览器端工具
|
||||
│ └── server/ # 服务器端工具
|
||||
├── hooks/ # 自定义 React Hooks
|
||||
├── i18n/ # 国际化配置
|
||||
├── shared/ # 共享常量和类型
|
||||
└── config/ # 应用配置
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js 23
|
||||
- PostgreSQL 数据库
|
||||
- pnpm (推荐) 或 npm
|
||||
|
||||
### 本地开发
|
||||
|
||||
1. 克隆项目
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd learn-languages
|
||||
```
|
||||
|
||||
2. 安装依赖
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. 设置环境变量
|
||||
|
||||
从项目提供的示例文件复制环境变量模板:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
然后编辑 `.env.local` 文件,配置所有必要的环境变量:
|
||||
|
||||
```env
|
||||
# LLM 集成(智谱 AI 用于翻译和 IPA 生成)
|
||||
ZHIPU_API_KEY=your-zhipu-api-key
|
||||
ZHIPU_MODEL_NAME=your-zhipu-model-name
|
||||
|
||||
# 阿里云千问 TTS(文本转语音)
|
||||
DASHSCORE_API_KEY=your-dashscore-api-key
|
||||
|
||||
# 认证
|
||||
BETTER_AUTH_SECRET=your-better-auth-secret
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
GITHUB_CLIENT_ID=your-github-client-id
|
||||
GITHUB_CLIENT_SECRET=your-github-client-secret
|
||||
|
||||
# 数据库
|
||||
DATABASE_URL=postgresql://username:password@localhost:5432/database_name
|
||||
```
|
||||
|
||||
注意:所有带 `your-` 前缀的值需要替换为你的实际配置。
|
||||
|
||||
4. 初始化数据库
|
||||
```bash
|
||||
pnpm prisma generate
|
||||
pnpm prisma db push
|
||||
```
|
||||
|
||||
5. 启动开发服务器
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
访问 [http://localhost:3000](http://localhost:3000) 查看应用。
|
||||
|
||||
## 📚 API 文档
|
||||
|
||||
### 认证系统
|
||||
|
||||
应用使用 better-auth 提供安全的用户认证系统,支持:
|
||||
- 邮箱/密码登录和注册
|
||||
- **用户名登录**(可通过用户名或邮箱登录)
|
||||
- GitHub OAuth 第三方登录
|
||||
- 邮箱验证功能
|
||||
|
||||
### 后端架构
|
||||
|
||||
项目采用 **Action-Service-Repository 三层架构**:
|
||||
- **Action 层**:处理 Server Actions、表单验证、重定向
|
||||
- **Service 层**:业务逻辑、better-auth 集成
|
||||
- **Repository 层**:Prisma 数据库操作
|
||||
|
||||
### 数据模型
|
||||
|
||||
核心数据模型包括:
|
||||
- **User** - 用户信息(支持用户名、邮箱、头像)
|
||||
- **Folder** - 学习资料文件夹
|
||||
- **Pair** - 语言对(翻译对、词汇对等)
|
||||
- **Session/Account** - 认证会话追踪
|
||||
- **Verification** - 邮箱验证系统
|
||||
|
||||
详细模型定义请参考 [prisma/schema.prisma](./prisma/schema.prisma)
|
||||
|
||||
## 🌍 国际化
|
||||
|
||||
应用支持多语言,当前语言文件位于 `messages/` 目录。添加新语言:
|
||||
|
||||
1. 在 `messages/` 目录创建对应语言的 JSON 文件
|
||||
2. 在 `src/i18n/config.ts` 中添加语言配置
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
我们欢迎各种形式的贡献!请遵循以下步骤:
|
||||
|
||||
1. Fork 项目
|
||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 打开 Pull Request
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 AGPL-3.0 许可证 - 查看 [LICENSE](./LICENSE) 文件了解详情。
|
||||
|
||||
## 📞 支持
|
||||
|
||||
如果您遇到问题或有建议,请通过以下方式联系:
|
||||
|
||||
- 提交 [Issue](../../issues)
|
||||
- 发送邮件至 [goddonebianu@outlook.com]
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
**Happy Learning!** 🌟
|
||||
## ✨ 核心特性
|
||||
|
||||
### 🎯 学习工具
|
||||
- **智能翻译** - 基于 AI 的多语言互译,支持 IPA 音标标注
|
||||
- **词典查询** - 详细的单词释义、词性分析、例句展示
|
||||
- **语音合成** - 阿里云千问 TTS 提供自然的语音输出
|
||||
- **个人学习空间** - 文件夹管理、学习资料组织
|
||||
|
||||
### 🔐 用户系统
|
||||
- **多方式认证** - 邮箱/用户名登录、GitHub OAuth
|
||||
- **个人资料** - 用户主页、学习进度追踪
|
||||
- **数据安全** - better-auth 提供企业级安全保障
|
||||
|
||||
### 🌐 国际化
|
||||
- **8 种语言** - en-US, zh-CN, ja-JP, ko-KR, de-DE, fr-FR, it-IT, ug-CN
|
||||
- **完整本地化** - 所有界面文本支持多语言
|
||||
|
||||
### 🏗️ 技术亮点
|
||||
- **App Router** - 采用 Next.js 16 最新路由系统
|
||||
- **Server Components** - 优先服务端渲染,优化性能
|
||||
- **Action-Service-Repository** - 清晰的三层架构设计
|
||||
- **类型安全** - TypeScript 严格模式 + Zod 验证
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Node.js 24+
|
||||
- PostgreSQL 14+
|
||||
- pnpm 8+ (推荐) 或 npm/yarn
|
||||
|
||||
### 安装步骤
|
||||
|
||||
```bash
|
||||
# 1. 克隆项目
|
||||
git clone <repository-url>
|
||||
cd learn-languages
|
||||
|
||||
# 2. 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 3. 配置环境变量
|
||||
cp .env.example .env.local
|
||||
# 编辑 .env.local 填写必要配置
|
||||
|
||||
# 4. 初始化数据库
|
||||
pnpm prisma generate
|
||||
pnpm prisma db push
|
||||
|
||||
# 5. 启动开发服务器
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
访问 **http://localhost:3000** 开始使用!
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
```env
|
||||
# 🤖 AI 服务(必需)
|
||||
ZHIPU_API_KEY=your-api-key # 智谱 AI - 翻译和词典
|
||||
ZHIPU_MODEL_NAME=your-model-name # 模型名称
|
||||
DASHSCORE_API_KEY=your-api-key # 阿里云 TTS
|
||||
|
||||
# 🔐 认证配置(必需)
|
||||
BETTER_AUTH_SECRET=your-secret # 随机字符串
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
|
||||
# 🐙 GitHub OAuth(可选)
|
||||
GITHUB_CLIENT_ID=your-client-id
|
||||
GITHUB_CLIENT_SECRET=your-client-secret
|
||||
|
||||
# 💾 数据库(必需)
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
### 前端
|
||||
- **Next.js 16** - App Router
|
||||
- **React 19** - UI 框架
|
||||
- **TypeScript 5.9** - 类型安全
|
||||
- **Tailwind CSS 4** - 样式方案
|
||||
- **Zustand** - 状态管理
|
||||
- **next-intl** - 国际化
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
### 后端
|
||||
- **PostgreSQL** - 关系数据库
|
||||
- **Prisma 7** - ORM
|
||||
- **better-auth** - 认证系统
|
||||
- **智谱 AI** - LLM 服务
|
||||
- **阿里云 TTS** - 语音合成
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## 📁 项目架构
|
||||
|
||||
```
|
||||
learn-languages/
|
||||
├── 📂 src/
|
||||
│ ├── 📂 app/ # Next.js App Router
|
||||
│ │ ├── 📂 (auth)/ # 认证相关页面
|
||||
│ │ ├── 📂 folders/ # 文件夹管理
|
||||
│ │ ├── 📂 users/[username]/ # 用户资料
|
||||
│ │ └── 📂 api/ # API 路由
|
||||
│ │
|
||||
│ ├── 📂 modules/ # 业务模块(三层架构)
|
||||
│ │ ├── 📂 auth/ # 认证模块
|
||||
│ │ ├── 📂 folder/ # 文件夹模块
|
||||
│ │ ├── 📂 dictionary/ # 词典模块
|
||||
│ │ └── 📂 translator/ # 翻译模块
|
||||
│ │
|
||||
│ ├── 📂 components/ # React 组件
|
||||
│ │ ├── 📂 ui/ # 通用 UI 组件
|
||||
│ │ └── 📂 layout/ # 布局组件
|
||||
│ │
|
||||
│ ├── 📂 design-system/ # 设计系统
|
||||
│ │ ├── 📂 base/ # 基础组件
|
||||
│ │ ├── 📂 layout/ # 布局组件
|
||||
│ │ └── 📂 feedback/ # 反馈组件
|
||||
│ │
|
||||
│ ├── 📂 lib/ # 工具库
|
||||
│ │ ├── 📂 bigmodel/ # AI 集成
|
||||
│ │ ├── 📂 browser/ # 浏览器工具
|
||||
│ │ └── 📂 server/ # 服务端工具
|
||||
│ │
|
||||
│ ├── 📂 hooks/ # 自定义 Hooks
|
||||
│ ├── 📂 i18n/ # 国际化配置
|
||||
│ ├── 📂 shared/ # 共享类型和常量
|
||||
│ └── 📂 config/ # 应用配置
|
||||
│
|
||||
├── 📂 prisma/ # 数据库 Schema
|
||||
├── 📂 messages/ # 多语言文件
|
||||
└── 📂 public/ # 静态资源
|
||||
```
|
||||
|
||||
### 架构设计:Action-Service-Repository
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Presentation Layer │
|
||||
│ (Server Components / Client Components)│
|
||||
└────────────────┬────────────────────────┘
|
||||
│
|
||||
┌────────────────▼────────────────────────┐
|
||||
│ Action Layer │
|
||||
│ • Server Actions │
|
||||
│ • Form Validation (Zod) │
|
||||
│ • Redirect & Error Handling │
|
||||
└────────────────┬────────────────────────┘
|
||||
│
|
||||
┌────────────────▼────────────────────────┐
|
||||
│ Service Layer │
|
||||
│ • Business Logic │
|
||||
│ • better-auth Integration │
|
||||
│ • Cross-module Coordination │
|
||||
└────────────────┬────────────────────────┘
|
||||
│
|
||||
┌────────────────▼────────────────────────┐
|
||||
│ Repository Layer │
|
||||
│ • Prisma Database Operations │
|
||||
│ • Data Access Abstraction │
|
||||
│ • Query Optimization │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 核心模块
|
||||
|
||||
### 认证系统 (auth)
|
||||
|
||||
```typescript
|
||||
// 支持多种登录方式
|
||||
- 邮箱/密码登录
|
||||
- 用户名登录
|
||||
- GitHub OAuth
|
||||
- 邮箱验证
|
||||
```
|
||||
|
||||
### 翻译模块 (translator)
|
||||
|
||||
```typescript
|
||||
// AI 驱动的智能翻译
|
||||
- 多语言互译
|
||||
- IPA 音标标注
|
||||
- 翻译历史记录
|
||||
- 上下文理解
|
||||
```
|
||||
|
||||
### 词典模块 (dictionary)
|
||||
|
||||
```typescript
|
||||
// 智能词典查询
|
||||
- 单词释义
|
||||
- 词性分析
|
||||
- 例句展示
|
||||
- 词频统计
|
||||
```
|
||||
|
||||
### 文件夹模块 (folder)
|
||||
|
||||
```typescript
|
||||
// 学习资料管理
|
||||
- 创建/删除文件夹
|
||||
- 添加语言对
|
||||
- IPA 标注
|
||||
- 批量管理
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ 数据模型
|
||||
|
||||
核心数据模型关系:
|
||||
|
||||
```
|
||||
User (用户)
|
||||
├─ Account (账户)
|
||||
├─ Session (会话)
|
||||
├─ Folder (文件夹)
|
||||
│ └─ Pair (语言对)
|
||||
├─ DictionaryLookUp (查询记录)
|
||||
│ └─ DictionaryItem (词典项)
|
||||
│ └─ DictionaryEntry (词条)
|
||||
└─ TranslationHistory (翻译历史)
|
||||
```
|
||||
|
||||
详细模型定义:[prisma/schema.prisma](./prisma/schema.prisma)
|
||||
|
||||
---
|
||||
|
||||
## 🌍 国际化支持
|
||||
|
||||
当前支持的语言:
|
||||
|
||||
| 语言 | 代码 | 区域 |
|
||||
|------|------|------|
|
||||
| English | en-US | 美国 |
|
||||
| 中文 | zh-CN | 中国 |
|
||||
| 日本語 | ja-JP | 日本 |
|
||||
| 한국어 | ko-KR | 韩国 |
|
||||
| Deutsch | de-DE | 德国 |
|
||||
| Français | fr-FR | 法国 |
|
||||
| Italiano | it-IT | 意大利 |
|
||||
| ئۇيغۇرچە | ug-CN | 新疆 |
|
||||
|
||||
添加新语言:
|
||||
|
||||
1. 在 `messages/` 创建语言文件
|
||||
2. 在 `src/i18n/config.ts` 添加配置
|
||||
3. 更新语言选择器组件
|
||||
|
||||
---
|
||||
|
||||
## 🔧 开发指南
|
||||
|
||||
### 可用脚本
|
||||
|
||||
```bash
|
||||
# 开发
|
||||
pnpm dev # 启动开发服务器 (HTTPS)
|
||||
pnpm build # 构建生产版本
|
||||
pnpm start # 启动生产服务器
|
||||
pnpm lint # 代码检查
|
||||
|
||||
# 数据库
|
||||
pnpm prisma studio # 打开数据库 GUI
|
||||
pnpm prisma db push # 同步 Schema
|
||||
pnpm prisma migrate # 创建迁移
|
||||
```
|
||||
|
||||
### 代码规范
|
||||
|
||||
- ✅ TypeScript 严格模式
|
||||
- ✅ ESLint + TypeScript Plugin
|
||||
- ✅ 优先使用 Server Components
|
||||
- ✅ 新功能遵循 Action-Service-Repository
|
||||
- ✅ 所有用户文本需要国际化
|
||||
- ✅ 组件复用设计系统和业务组件
|
||||
|
||||
### 目录约定
|
||||
|
||||
- `modules/` - 业务模块,每个模块包含:
|
||||
- `*-action.ts` - Server Actions
|
||||
- `*-service.ts` - 业务逻辑
|
||||
- `*-repository.ts` - 数据访问
|
||||
- `*-dto.ts` - 数据传输对象
|
||||
- `components/` - 业务相关组件
|
||||
- `design-system/` - 可复用基础组件
|
||||
- `lib/` - 工具函数和库
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
我们欢迎各种贡献!
|
||||
|
||||
### 贡献流程
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add: AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 开启 Pull Request
|
||||
|
||||
### 代码提交规范
|
||||
|
||||
```
|
||||
feat: 新功能
|
||||
fix: 修复问题
|
||||
docs: 文档变更
|
||||
style: 代码格式
|
||||
refactor: 重构
|
||||
test: 测试相关
|
||||
chore: 构建/工具
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 [AGPL-3.0](./LICENSE) 许可证。
|
||||
|
||||
---
|
||||
|
||||
## 📞 联系方式
|
||||
|
||||
- **问题反馈**:[GitHub Issues](../../issues)
|
||||
- **邮箱**:goddonebianu@outlook.com
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**如果这个项目对你有帮助,请给一个 ⭐️ Star!**
|
||||
|
||||
Made with ❤️ by the community
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,37 +1,89 @@
|
||||
{
|
||||
"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": "Laden...",
|
||||
"loading": "Wird geladen...",
|
||||
"loadFailed": "Laden fehlgeschlagen, bitte versuchen Sie es erneut",
|
||||
"hideLetter": "Zeichen ausblenden",
|
||||
"showLetter": "Zeichen anzeigen",
|
||||
"hideLetter": "Buchstabe ausblenden",
|
||||
"showLetter": "Buchstabe anzeigen",
|
||||
"hideIPA": "IPA ausblenden",
|
||||
"showIPA": "IPA anzeigen",
|
||||
"roman": "Romanisierung",
|
||||
"letter": "Zeichen",
|
||||
"random": "Zufälliger Modus",
|
||||
"randomNext": "Zufällig weiter"
|
||||
"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": "Erstellen...",
|
||||
"noFoldersYet": "Noch keine Ordner",
|
||||
"creating": "Wird erstellt...",
|
||||
"noFoldersYet": "Noch keine Ordner vorhanden",
|
||||
"folderInfo": "ID: {id} • {totalPairs} Paare",
|
||||
"enterFolderName": "Ordnernamen eingeben:",
|
||||
"confirmDelete": "Geben Sie \"{name}\" ein, um zu löschen:"
|
||||
"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 Eigentümer dieses Ordners",
|
||||
"unauthorized": "Sie sind nicht der Besitzer dieses Ordners",
|
||||
"back": "Zurück",
|
||||
"textPairs": "Textpaare",
|
||||
"itemsCount": "{count} Elemente",
|
||||
"memorize": "Einprägen",
|
||||
"itemsCount": "{count} Einträge",
|
||||
"memorize": "Auswendig lernen",
|
||||
"loadingTextPairs": "Textpaare werden geladen...",
|
||||
"noTextPairs": "Keine Textpaare in diesem Ordner",
|
||||
"addNewTextPair": "Neues Textpaar hinzufügen",
|
||||
@@ -42,14 +94,14 @@
|
||||
"text2": "Text 2",
|
||||
"language1": "Sprache 1",
|
||||
"language2": "Sprache 2",
|
||||
"enterLanguageName": "Bitte geben Sie den Sprachennamen ein",
|
||||
"enterLanguageName": "Bitte Sprachnamen eingeben",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen",
|
||||
"permissionDenied": "Sie haben keine Berechtigung, diese Aktion auszuführen",
|
||||
"permissionDenied": "Sie haben keine Berechtigung für diese Aktion",
|
||||
"error": {
|
||||
"update": "Sie haben keine Berechtigung, dieses Element zu aktualisieren.",
|
||||
"delete": "Sie haben keine Berechtigung, dieses Element zu löschen.",
|
||||
"add": "Sie haben keine Berechtigung, Elemente zu diesem Ordner hinzuzufügen.",
|
||||
"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."
|
||||
}
|
||||
@@ -57,48 +109,51 @@
|
||||
"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": "Erkunden",
|
||||
"explore": "Entdecken",
|
||||
"fortune": {
|
||||
"quote": "Bleib hungrig, bleiv dumm.",
|
||||
"quote": "Stay hungry, stay foolish.",
|
||||
"author": "— Steve Jobs"
|
||||
},
|
||||
"translator": {
|
||||
"name": "Übersetzer",
|
||||
"description": "In jede Sprache übersetzen und mit Internationalem Phonetischem Alphabet (IPA) annotieren"
|
||||
"description": "In jede Sprache übersetzen und mit dem Internationalen Phonetischen Alphabet (IPA) annotieren"
|
||||
},
|
||||
"textSpeaker": {
|
||||
"name": "Text-Sprecher",
|
||||
"name": "Textvorleser",
|
||||
"description": "Text erkennen und vorlesen, unterstützt Schleifenwiedergabe und Geschwindigkeitsanpassung"
|
||||
},
|
||||
"srtPlayer": {
|
||||
"name": "SRT-Videoplayer",
|
||||
"description": "Videos basierend auf SRT-Untertiteldateien satzweise abspielen, um die Aussprache von Muttersprachlern zu imitieren"
|
||||
"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 Erlernen einer neuen Sprache mit dem Alphabet"
|
||||
"description": "Beginnen Sie mit dem Lernen einer neuen Sprache vom Alphabet aus"
|
||||
},
|
||||
"memorize": {
|
||||
"name": "Einprägen",
|
||||
"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 Redewendungen nachschlagen mit detaillierten Definitionen und Beispielen"
|
||||
"description": "Wörter und Ausdrücke mit detaillierten Definitionen und Beispielen nachschlagen"
|
||||
},
|
||||
"moreFeatures": {
|
||||
"name": "Weitere Funktionen",
|
||||
"description": "In Entwicklung, bleiben Sie dran"
|
||||
"description": "In Entwicklung, bleiben Sie gespannt"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "Authentifizierung",
|
||||
"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?",
|
||||
@@ -107,30 +162,86 @@
|
||||
"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": "Passwörter stimmen nicht überein",
|
||||
"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": "Laden..."
|
||||
"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": {
|
||||
"folder_selector": {
|
||||
"selectFolder": "Wählen Sie einen Ordner aus",
|
||||
"noFolders": "Keine Ordner gefunden",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
"deck_selector": {
|
||||
"selectDeck": "Deck wählen",
|
||||
"noDecks": "Keine Decks",
|
||||
"goToDecks": "Zu Decks",
|
||||
"noCards": "Keine Karten",
|
||||
"new": "Neu",
|
||||
"learning": "Lernen",
|
||||
"review": "Wiederholen",
|
||||
"due": "Fällig"
|
||||
},
|
||||
"memorize": {
|
||||
"answer": "Antwort",
|
||||
"next": "Weiter",
|
||||
"reverse": "Umkehren",
|
||||
"dictation": "Diktat",
|
||||
"noTextPairs": "Keine Textpaare verfügbar",
|
||||
"disorder": "Mischen",
|
||||
"previous": "Zurück"
|
||||
"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": "Sie sind nicht berechtigt, auf diesen Ordner zuzugreifen"
|
||||
"unauthorized": "Nicht autorisiert"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
@@ -138,13 +249,66 @@
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "Anmelden",
|
||||
"profile": "Profil",
|
||||
"folders": "Ordner"
|
||||
"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",
|
||||
@@ -164,21 +328,60 @@
|
||||
"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"
|
||||
"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 Elemente anzeigen",
|
||||
"confirmDeleteAll": "Sind Sie sicher, dass Sie alles löschen möchten? (J/N)"
|
||||
"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",
|
||||
"translateInto": "übersetzen in",
|
||||
"chinese": "Chinesisch",
|
||||
"english": "Englisch",
|
||||
"french": "Französisch",
|
||||
@@ -190,49 +393,104 @@
|
||||
"russian": "Russisch",
|
||||
"spanish": "Spanisch",
|
||||
"other": "Andere",
|
||||
"translating": "Übersetzung läuft...",
|
||||
"translate": "Übersetzen",
|
||||
"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 aus",
|
||||
"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": "Textpaar konnte nicht zum Ordner hinzugefügt werden"
|
||||
"error": "Fehler beim Hinzufügen des Textpaars zum Ordner"
|
||||
},
|
||||
"autoSave": "Automatisch speichern"
|
||||
"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": "Wort oder Ausdruck zum Nachschlagen eingeben...",
|
||||
"searching": "Suche...",
|
||||
"searchPlaceholder": "Geben Sie ein Wort oder einen Ausdruck zum Nachschlagen ein...",
|
||||
"searching": "Suche läuft...",
|
||||
"search": "Suchen",
|
||||
"languageSettings": "Spracheinstellungen",
|
||||
"queryLanguage": "Abfragesprache",
|
||||
"queryLanguageHint": "Welche Sprache hat das Wort/die Phrase, die Sie nachschlagen möchten",
|
||||
"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 sehen",
|
||||
"otherLanguagePlaceholder": "Oder eine andere Sprache eingeben...",
|
||||
"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": "Neu suchen",
|
||||
"relookup": "Erneut suchen",
|
||||
"saveToFolder": "In Ordner speichern",
|
||||
"loading": "Laden...",
|
||||
"loading": "Wird geladen...",
|
||||
"noResults": "Keine Ergebnisse gefunden",
|
||||
"tryOtherWords": "Versuchen Sie andere Wörter oder Ausdrücke",
|
||||
"welcomeTitle": "Willkommen beim Wörterbuch",
|
||||
"welcomeHint": "Geben Sie oben im Suchfeld ein Wort oder einen Ausdruck ein, um zu suchen",
|
||||
"lookupFailed": "Suche fehlgeschlagen, bitte später erneut versuchen",
|
||||
"relookupSuccess": "Erfolgreich neu gesucht",
|
||||
"relookupFailed": "Wörterbuch Neu-Suche fehlgeschlagen",
|
||||
"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": "Im Ordner gespeichert: {folderName}",
|
||||
"saveFailed": "Speichern fehlgeschlagen, bitte später erneut versuchen"
|
||||
"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",
|
||||
@@ -245,14 +503,120 @@
|
||||
"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": "Ordner",
|
||||
"noFolders": "Noch keine Ordner",
|
||||
"folderName": "Ordnername",
|
||||
"totalPairs": "Anzahl der Paare",
|
||||
"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,6 +1,7 @@
|
||||
{
|
||||
"alphabet": {
|
||||
"chooseCharacters": "Please select the characters you want to learn",
|
||||
"chooseAlphabetHint": "Select an alphabet to start learning",
|
||||
"japanese": "Japanese Kana",
|
||||
"english": "English Alphabet",
|
||||
"uyghur": "Uyghur Alphabet",
|
||||
@@ -14,7 +15,11 @@
|
||||
"roman": "Romanization",
|
||||
"letter": "Letter",
|
||||
"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": {
|
||||
"title": "Folders",
|
||||
@@ -24,7 +29,22 @@
|
||||
"noFoldersYet": "No folders yet",
|
||||
"folderInfo": "ID: {id} • {totalPairs} pairs",
|
||||
"enterFolderName": "Enter folder name:",
|
||||
"confirmDelete": "Type \"{name}\" to delete:"
|
||||
"confirmDelete": "Type \"{name}\" to delete:",
|
||||
"myFolders": "My Folders",
|
||||
"publicFolders": "Public Folders",
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"setPublic": "Set Public",
|
||||
"setPrivate": "Set Private",
|
||||
"publicFolderInfo": "{userName} • {totalPairs} pairs",
|
||||
"searchPlaceholder": "Search public folders...",
|
||||
"loading": "Loading...",
|
||||
"noPublicFolders": "No public folders found",
|
||||
"unknownUser": "Unknown User",
|
||||
"enterNewName": "Enter new name:",
|
||||
"favorite": "Favorite",
|
||||
"unfavorite": "Unfavorite",
|
||||
"pleaseLogin": "Please login first"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "You are not the owner of this folder",
|
||||
@@ -54,6 +74,77 @@
|
||||
"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": {
|
||||
"title": "Learn Languages",
|
||||
"description": "Here is a very useful website to help you learn almost every language in the world, including constructed ones.",
|
||||
@@ -92,7 +183,8 @@
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "Authentication",
|
||||
"title": "Sign In",
|
||||
"signUpTitle": "Sign Up",
|
||||
"signIn": "Sign In",
|
||||
"signUp": "Sign Up",
|
||||
"email": "Email",
|
||||
@@ -118,25 +210,102 @@
|
||||
"identifierRequired": "Please enter your email or username",
|
||||
"passwordRequired": "Please enter 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": {
|
||||
"folder_selector": {
|
||||
"selectFolder": "Select a folder",
|
||||
"noFolders": "No folders found",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
"deck_selector": {
|
||||
"selectDeck": "Select a deck",
|
||||
"noDecks": "No decks found",
|
||||
"goToDecks": "Go to Decks",
|
||||
"noCards": "No cards",
|
||||
"new": "New",
|
||||
"learning": "Learning",
|
||||
"review": "Review",
|
||||
"due": "Due"
|
||||
},
|
||||
"memorize": {
|
||||
"answer": "Answer",
|
||||
"next": "Next",
|
||||
"review": {
|
||||
"loading": "Loading cards...",
|
||||
"backToDecks": "Back to Decks",
|
||||
"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",
|
||||
"dictation": "Dictation",
|
||||
"noTextPairs": "No text pairs available",
|
||||
"disorder": "Disorder",
|
||||
"previous": "Previous"
|
||||
"clickToPlay": "Click to play audio",
|
||||
"restart": "Restart",
|
||||
"yourAnswer": "Your answer",
|
||||
"typeWhatYouHear": "Type what you hear...",
|
||||
"correct": "Correct",
|
||||
"incorrect": "Incorrect",
|
||||
"orderLimited": "Order",
|
||||
"orderInfinite": "Loop",
|
||||
"randomLimited": "Random",
|
||||
"randomInfinite": "Random Loop",
|
||||
"noIpa": "No IPA available"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "You are not authorized to access this folder"
|
||||
"unauthorized": "You are not authorized to access this deck"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
@@ -144,13 +313,66 @@
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "Sign In",
|
||||
"profile": "Profile",
|
||||
"folders": "Folders"
|
||||
"folders": "Decks",
|
||||
"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": {
|
||||
"myProfile": "My Profile",
|
||||
"email": "Email: {email}",
|
||||
"logout": "Logout"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"themeColor": "Theme Color",
|
||||
"themeColorDescription": "Choose your preferred theme color"
|
||||
},
|
||||
"srt_player": {
|
||||
"uploadVideo": "Upload Video",
|
||||
"uploadSubtitle": "Upload Subtitle",
|
||||
@@ -170,21 +392,61 @@
|
||||
"uploaded": "Uploaded",
|
||||
"notUploaded": "Not Uploaded",
|
||||
"upload": "Upload",
|
||||
"uploadVideoButton": "Upload Video",
|
||||
"uploadSubtitleButton": "Upload Subtitle",
|
||||
"subtitleUploaded": "Subtitle Uploaded ({count} entries)",
|
||||
"subtitleNotUploaded": "Subtitle Not Uploaded",
|
||||
"autoPauseStatus": "Auto Pause: {enabled}",
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"videoUploadFailed": "Video upload failed",
|
||||
"subtitleUploadFailed": "Subtitle upload failed"
|
||||
"subtitleUploadFailed": "Subtitle upload failed",
|
||||
"subtitleLoadSuccess": "Subtitle loaded successfully",
|
||||
"subtitleLoadFailed": "Subtitle load failed",
|
||||
"settings": "Settings",
|
||||
"shortcuts": "Shortcuts",
|
||||
"keyboardShortcuts": "Keyboard Shortcuts",
|
||||
"playPause": "Play/Pause",
|
||||
"autoPauseToggle": "Toggle Auto Pause",
|
||||
"subtitleSettings": "Subtitle Settings",
|
||||
"fontSize": "Font Size",
|
||||
"textColor": "Text Color",
|
||||
"backgroundColor": "Background Color",
|
||||
"position": "Position",
|
||||
"opacity": "Opacity",
|
||||
"top": "Top",
|
||||
"center": "Center",
|
||||
"bottom": "Bottom"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "Generate IPA",
|
||||
"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": {
|
||||
"detectLanguage": "detect language",
|
||||
"sourceLanguage": "source language",
|
||||
"auto": "Auto",
|
||||
"generateIPA": "generate ipa",
|
||||
"translateInto": "translate into",
|
||||
"customLanguage": "or type language...",
|
||||
"chinese": "Chinese",
|
||||
"english": "English",
|
||||
"french": "French",
|
||||
@@ -210,7 +472,19 @@
|
||||
"success": "Text pair added 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",
|
||||
@@ -224,6 +498,7 @@
|
||||
"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",
|
||||
@@ -238,7 +513,45 @@
|
||||
"pleaseLogin": "Please log in first",
|
||||
"pleaseCreateFolder": "Please create a folder first",
|
||||
"savedToFolder": "Saved to folder: {folderName}",
|
||||
"saveFailed": "Save failed, please try again later"
|
||||
"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",
|
||||
@@ -251,14 +564,67 @@
|
||||
"displayName": "Display Name",
|
||||
"notSet": "Not Set",
|
||||
"memberSince": "Member Since",
|
||||
"folders": {
|
||||
"title": "Folders",
|
||||
"noFolders": "No folders yet",
|
||||
"folderName": "Folder Name",
|
||||
"totalPairs": "Total Pairs",
|
||||
"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,6 +1,7 @@
|
||||
{
|
||||
"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",
|
||||
@@ -14,29 +15,80 @@
|
||||
"roman": "Romanisation",
|
||||
"letter": "Lettre",
|
||||
"random": "Mode aléatoire",
|
||||
"randomNext": "Suivant 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": "Aucun dossier pour le moment",
|
||||
"folderInfo": "ID: {id} • {totalPairs} paires",
|
||||
"enterFolderName": "Entrez le nom du dossier:",
|
||||
"confirmDelete": "Tapez \"{name}\" pour supprimer:"
|
||||
"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 textes",
|
||||
"textPairs": "Paires de texte",
|
||||
"itemsCount": "{count} éléments",
|
||||
"memorize": "Mémoriser",
|
||||
"loadingTextPairs": "Chargement des paires de textes...",
|
||||
"noTextPairs": "Aucune paire de textes dans ce dossier",
|
||||
"addNewTextPair": "Ajouter une nouvelle paire de textes",
|
||||
"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 textes",
|
||||
"updateTextPair": "Mettre à jour la paire de texte",
|
||||
"update": "Mettre à jour",
|
||||
"text1": "Texte 1",
|
||||
"text2": "Texte 2",
|
||||
@@ -54,17 +106,88 @@
|
||||
"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.",
|
||||
"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": "Stay hungry, stay foolish.",
|
||||
"quote": "Restez affamés, restez fous.",
|
||||
"author": "— Steve Jobs"
|
||||
},
|
||||
"translator": {
|
||||
"name": "Traducteur",
|
||||
"description": "Traduire dans n'importe quelle langue et annoter avec l'alphabet phonétique international (API)"
|
||||
"description": "Traduire vers n'importe quelle langue et annoter avec l'Alphabet Phonétique International (API)"
|
||||
},
|
||||
"textSpeaker": {
|
||||
"name": "Lecteur de texte",
|
||||
@@ -76,15 +199,15 @@
|
||||
},
|
||||
"alphabet": {
|
||||
"name": "Alphabet",
|
||||
"description": "Commencer à apprendre une nouvelle langue par l'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"
|
||||
"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 phrases avec des définitions détaillées et des exemples"
|
||||
"description": "Rechercher des mots et des expressions avec des définitions détaillées et des exemples"
|
||||
},
|
||||
"moreFeatures": {
|
||||
"name": "Plus de fonctionnalités",
|
||||
@@ -92,91 +215,242 @@
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "Authentification",
|
||||
"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?",
|
||||
"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..."
|
||||
"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": {
|
||||
"folder_selector": {
|
||||
"selectFolder": "Sélectionner un dossier",
|
||||
"noFolders": "Aucun dossier trouvé",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
"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"
|
||||
},
|
||||
"memorize": {
|
||||
"answer": "Réponse",
|
||||
"next": "Suivant",
|
||||
"reverse": "Inverser",
|
||||
"dictation": "Dictée",
|
||||
"noTextPairs": "Aucune paire de textes disponible",
|
||||
"disorder": "Désordre",
|
||||
"previous": "Précédent"
|
||||
"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": "Vous n'êtes pas autorisé à accéder à ce dossier"
|
||||
"unauthorized": "Non autorisé"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
"title": "learn-languages",
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "Se connecter",
|
||||
"sign_in": "Connexion",
|
||||
"profile": "Profil",
|
||||
"folders": "Dossiers"
|
||||
"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": "Se déconnecter"
|
||||
"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 une vidéo",
|
||||
"uploadSubtitle": "Télécharger des sous-titres",
|
||||
"uploadVideo": "Télécharger la vidéo",
|
||||
"uploadSubtitle": "Télécharger les sous-titres",
|
||||
"pause": "Pause",
|
||||
"play": "Lire",
|
||||
"play": "Lecture",
|
||||
"previous": "Précédent",
|
||||
"next": "Suivant",
|
||||
"restart": "Redémarrer",
|
||||
"restart": "Recommencer",
|
||||
"autoPause": "Pause automatique ({enabled})",
|
||||
"uploadVideoAndSubtitle": "Veuillez télécharger des fichiers vidéo et de sous-titres",
|
||||
"uploadVideoFile": "Veuillez télécharger un fichier vidéo",
|
||||
"uploadSubtitleFile": "Veuillez télécharger un fichier de sous-titres",
|
||||
"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 de sous-titres sont requis pour commencer l'apprentissage",
|
||||
"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",
|
||||
"autoPauseStatus": "Pause automatique: {enabled}",
|
||||
"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"
|
||||
"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)"
|
||||
"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",
|
||||
@@ -194,45 +468,100 @@
|
||||
"translate": "traduire",
|
||||
"inputLanguage": "Entrez une langue.",
|
||||
"history": "Historique",
|
||||
"enterLanguage": "Entrer la langue",
|
||||
"enterLanguage": "Entrez la langue",
|
||||
"add_to_folder": {
|
||||
"notAuthenticated": "Vous n'êtes pas authentifié",
|
||||
"chooseFolder": "Choisir un dossier à ajouter",
|
||||
"chooseFolder": "Choisissez un dossier à ajouter",
|
||||
"noFolders": "Aucun dossier trouvé",
|
||||
"folderInfo": "{id}. {name}",
|
||||
"close": "Fermer",
|
||||
"success": "Paire de textes ajoutée au dossier",
|
||||
"error": "Échec de l'ajout de la paire de textes au dossier"
|
||||
"success": "Paire de texte ajoutée au dossier",
|
||||
"error": "Échec de l'ajout de la paire de texte au dossier"
|
||||
},
|
||||
"autoSave": "Sauvegarde automatique"
|
||||
"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 phrases avec des définitions détaillées et des exemples",
|
||||
"searchPlaceholder": "Entrez un mot ou une phrase à rechercher...",
|
||||
"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 linguistiques",
|
||||
"languageSettings": "Paramètres de langue",
|
||||
"queryLanguage": "Langue de requête",
|
||||
"queryLanguageHint": "Quelle est la langue du mot/phrase que vous souhaitez rechercher",
|
||||
"queryLanguageHint": "Dans quelle langue est le mot/l'expression que vous voulez rechercher",
|
||||
"definitionLanguage": "Langue de définition",
|
||||
"definitionLanguageHint": "Dans quelle langue souhaitez-vous voir les définitions",
|
||||
"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 phrases",
|
||||
"tryOtherWords": "Essayez d'autres mots ou expressions",
|
||||
"welcomeTitle": "Bienvenue dans le dictionnaire",
|
||||
"welcomeHint": "Entrez un mot ou une phrase dans la zone de recherche ci-dessus pour commencer",
|
||||
"lookupFailed": "Recherche échouée, veuillez réessayer plus tard",
|
||||
"relookupSuccess": "Recherche répétée avec succès",
|
||||
"relookupFailed": "Nouvelle recherche de dictionnaire échouée",
|
||||
"pleaseLogin": "Veuillez d'abord vous connecter",
|
||||
"pleaseCreateFolder": "Veuillez d'abord créer un dossier",
|
||||
"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"
|
||||
"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",
|
||||
@@ -245,14 +574,40 @@
|
||||
"displayName": "Nom d'affichage",
|
||||
"notSet": "Non défini",
|
||||
"memberSince": "Membre depuis",
|
||||
"folders": {
|
||||
"title": "Dossiers",
|
||||
"noFolders": "Aucun dossier pour le moment",
|
||||
"folderName": "Nom du dossier",
|
||||
"totalPairs": "Nombre de paires",
|
||||
"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,48 +1,100 @@
|
||||
{
|
||||
"alphabet": {
|
||||
"chooseCharacters": "Seleziona i caratteri che desideri imparare",
|
||||
"japanese": "Kana giapponese",
|
||||
"english": "Alfabeto inglese",
|
||||
"uyghur": "Alfabeto uiguro",
|
||||
"esperanto": "Alfabeto esperanto",
|
||||
"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",
|
||||
"hideLetter": "Nascondi Lettera",
|
||||
"showLetter": "Mostra Lettera",
|
||||
"hideIPA": "Nascondi IPA",
|
||||
"showIPA": "Mostra IPA",
|
||||
"roman": "Romanizzazione",
|
||||
"letter": "Lettera",
|
||||
"random": "Modalità casuale",
|
||||
"randomNext": "Successivo casuale"
|
||||
"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",
|
||||
"newFolder": "Nuova Cartella",
|
||||
"creating": "Creazione...",
|
||||
"noFoldersYet": "Nessuna cartella ancora",
|
||||
"folderInfo": "ID: {id} • {totalPairs} coppie",
|
||||
"enterFolderName": "Inserisci nome cartella:",
|
||||
"confirmDelete": "Digita \"{name}\" per eliminare:"
|
||||
"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 testi",
|
||||
"textPairs": "Coppie di Testo",
|
||||
"itemsCount": "{count} elementi",
|
||||
"memorize": "Memorizza",
|
||||
"loadingTextPairs": "Caricamento coppie di testi...",
|
||||
"noTextPairs": "Nessuna coppia di testi in questa cartella",
|
||||
"addNewTextPair": "Aggiungi nuova coppia di testi",
|
||||
"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 testi",
|
||||
"updateTextPair": "Aggiorna Coppia di Testo",
|
||||
"update": "Aggiorna",
|
||||
"text1": "Testo 1",
|
||||
"text2": "Testo 2",
|
||||
"language1": "Lingua 1",
|
||||
"language2": "Lingua 2",
|
||||
"enterLanguageName": "Inserisci il nome della lingua",
|
||||
"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",
|
||||
@@ -54,9 +106,80 @@
|
||||
"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": "Questo è un sito web molto utile che ti aiuta a imparare quasi tutte le lingue del mondo, incluse quelle costruite.",
|
||||
"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.",
|
||||
@@ -64,15 +187,15 @@
|
||||
},
|
||||
"translator": {
|
||||
"name": "Traduttore",
|
||||
"description": "Traduci in qualsiasi lingua e annota con l'alfabeto fonetico internazionale (IPA)"
|
||||
"description": "Traduci in qualsiasi lingua e annota con l'Alfabeto Fonetico Internazionale (IPA)"
|
||||
},
|
||||
"textSpeaker": {
|
||||
"name": "Lettore di testo",
|
||||
"description": "Riconosce e legge il testo ad alta voce, supporta la riproduzione in loop e la regolazione della velocità"
|
||||
"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 su file di sottotitoli SRT per imitare la pronuncia dei madrelingua"
|
||||
"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",
|
||||
@@ -80,57 +203,141 @@
|
||||
},
|
||||
"memorize": {
|
||||
"name": "Memorizza",
|
||||
"description": "Lingua A verso lingua B, lingua B verso lingua A, supporta dettatura"
|
||||
"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, rimani sintonizzato"
|
||||
"name": "Altre Funzionalità",
|
||||
"description": "In sviluppo, resta sintonizzato"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "Autenticazione",
|
||||
"title": "Accedi",
|
||||
"signUpTitle": "Registrati",
|
||||
"signIn": "Accedi",
|
||||
"signUp": "Registrati",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Conferma 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": "Inserisci un indirizzo email valido",
|
||||
"invalidEmail": "Per favore inserisci un indirizzo email valido",
|
||||
"passwordTooShort": "La password deve essere di almeno 8 caratteri",
|
||||
"passwordsNotMatch": "Le password non corrispondono",
|
||||
"nameRequired": "Inserisci il tuo nome",
|
||||
"emailRequired": "Inserisci la tua email",
|
||||
"passwordRequired": "Inserisci la tua password",
|
||||
"confirmPasswordRequired": "Conferma la tua password",
|
||||
"loading": "Caricamento..."
|
||||
"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": {
|
||||
"folder_selector": {
|
||||
"selectFolder": "Seleziona una cartella",
|
||||
"noFolders": "Nessuna cartella trovata",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
"deck_selector": {
|
||||
"selectDeck": "Seleziona deck",
|
||||
"noDecks": "Nessun deck",
|
||||
"goToDecks": "Vai ai deck",
|
||||
"noCards": "Nessuna carta",
|
||||
"new": "Nuovo",
|
||||
"learning": "Apprendimento",
|
||||
"review": "Ripasso",
|
||||
"due": "In scadenza"
|
||||
},
|
||||
"memorize": {
|
||||
"answer": "Risposta",
|
||||
"next": "Successivo",
|
||||
"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": "Dettatura",
|
||||
"noTextPairs": "Nessuna coppia di testi disponibile",
|
||||
"disorder": "Disordine",
|
||||
"previous": "Precedente"
|
||||
"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 sei autorizzato ad accedere a questa cartella"
|
||||
"unauthorized": "Non autorizzato"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
@@ -138,45 +345,137 @@
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "Accedi",
|
||||
"profile": "Profilo",
|
||||
"folders": "Cartelle"
|
||||
"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",
|
||||
"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",
|
||||
"uploadVideo": "Carica Video",
|
||||
"uploadSubtitle": "Carica Sottotitoli",
|
||||
"pause": "Pausa",
|
||||
"play": "Riproduci",
|
||||
"previous": "Precedente",
|
||||
"next": "Successivo",
|
||||
"restart": "Riavvia",
|
||||
"autoPause": "Pausa automatica ({enabled})",
|
||||
"uploadVideoAndSubtitle": "Carica i file video e sottotitoli",
|
||||
"uploadVideoFile": "Carica un file video",
|
||||
"uploadSubtitleFile": "Carica un file di sottotitoli",
|
||||
"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 i file video che i sottotitoli per iniziare l'apprendimento",
|
||||
"videoFile": "File video",
|
||||
"subtitleFile": "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",
|
||||
"notUploaded": "Non Caricato",
|
||||
"upload": "Carica",
|
||||
"autoPauseStatus": "Pausa automatica: {enabled}",
|
||||
"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"
|
||||
"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)"
|
||||
"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",
|
||||
@@ -197,14 +496,27 @@
|
||||
"enterLanguage": "Inserisci lingua",
|
||||
"add_to_folder": {
|
||||
"notAuthenticated": "Non sei autenticato",
|
||||
"chooseFolder": "Scegli una cartella a cui aggiungere",
|
||||
"chooseFolder": "Scegli una Cartella a cui Aggiungere",
|
||||
"noFolders": "Nessuna cartella trovata",
|
||||
"folderInfo": "{id}. {name}",
|
||||
"close": "Chiudi",
|
||||
"success": "Coppia di testi aggiunta alla cartella",
|
||||
"error": "Impossibile aggiungere la coppia di testi alla cartella"
|
||||
"success": "Coppia di testo aggiunta alla cartella",
|
||||
"error": "Impossibile aggiungere coppia di testo alla cartella"
|
||||
},
|
||||
"autoSave": "Salvataggio automatico"
|
||||
"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",
|
||||
@@ -212,47 +524,115 @@
|
||||
"searchPlaceholder": "Inserisci una parola o frase da cercare...",
|
||||
"searching": "Ricerca...",
|
||||
"search": "Cerca",
|
||||
"languageSettings": "Impostazioni lingua",
|
||||
"queryLanguage": "Lingua di interrogazione",
|
||||
"queryLanguageHint": "Quale è la lingua della parola/frase che vuoi cercare",
|
||||
"definitionLanguage": "Lingua di definizione",
|
||||
"definitionLanguageHint": "In quale lingua vuoi vedere le definizioni",
|
||||
"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...",
|
||||
"currentSettings": "Impostazioni attuali: Interrogazione {queryLang}, Definizione {definitionLang}",
|
||||
"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",
|
||||
"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 ripetuta con successo",
|
||||
"relookupFailed": "Nuova ricerca del dizionario fallita",
|
||||
"pleaseLogin": "Accedi prima",
|
||||
"pleaseCreateFolder": "Crea prima una cartella",
|
||||
"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"
|
||||
"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",
|
||||
"folders": {
|
||||
"title": "Cartelle",
|
||||
"noFolders": "Nessuna cartella ancora",
|
||||
"folderName": "Nome cartella",
|
||||
"totalPairs": "Numero di coppie",
|
||||
"createdAt": "Creato il",
|
||||
"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,10 +1,11 @@
|
||||
{
|
||||
"alphabet": {
|
||||
"chooseCharacters": "学習したい文字を選択してください",
|
||||
"chooseAlphabetHint": "学習を始めるアルファベットを選択してください",
|
||||
"japanese": "日本語仮名",
|
||||
"english": "英語アルファベット",
|
||||
"uyghur": "ウイグル文字",
|
||||
"esperanto": "エスペラント文字",
|
||||
"uyghur": "ウイグル語アルファベット",
|
||||
"esperanto": "エスペラント語アルファベット",
|
||||
"loading": "読み込み中...",
|
||||
"loadFailed": "読み込みに失敗しました。もう一度お試しください",
|
||||
"hideLetter": "文字を非表示",
|
||||
@@ -14,23 +15,42 @@
|
||||
"roman": "ローマ字",
|
||||
"letter": "文字",
|
||||
"random": "ランダムモード",
|
||||
"randomNext": "ランダムで次へ"
|
||||
"randomNext": "ランダム次へ",
|
||||
"previousLetter": "前の文字",
|
||||
"nextLetter": "次の文字",
|
||||
"keyboardHint": "左右の矢印キーまたはスペースキーでランダム移動、ESCで戻る",
|
||||
"swipeHint": "左右の矢印キーまたはスワイプで移動、ESCで戻る"
|
||||
},
|
||||
"folders": {
|
||||
"title": "フォルダー",
|
||||
"subtitle": "コレクションを管理",
|
||||
"newFolder": "新規フォルダー",
|
||||
"creating": "作成中...",
|
||||
"noFoldersYet": "フォルダーがありません",
|
||||
"folderInfo": "ID: {id} • {totalPairs}組",
|
||||
"noFoldersYet": "まだフォルダーがありません",
|
||||
"folderInfo": "ID: {id} • {totalPairs} ペア",
|
||||
"enterFolderName": "フォルダー名を入力:",
|
||||
"confirmDelete": "削除するには「{name}」と入力してください:"
|
||||
"confirmDelete": "削除するには「{name}」と入力してください:",
|
||||
"myFolders": "マイフォルダー",
|
||||
"publicFolders": "公開フォルダー",
|
||||
"public": "公開",
|
||||
"private": "非公開",
|
||||
"setPublic": "公開に設定",
|
||||
"setPrivate": "非公開に設定",
|
||||
"publicFolderInfo": "{userName} • {totalPairs} ペア",
|
||||
"searchPlaceholder": "公開フォルダーを検索...",
|
||||
"loading": "読み込み中...",
|
||||
"noPublicFolders": "公開フォルダーが見つかりません",
|
||||
"unknownUser": "不明なユーザー",
|
||||
"enterNewName": "新しい名前を入力:",
|
||||
"favorite": "お気に入り",
|
||||
"unfavorite": "お気に入り解除",
|
||||
"pleaseLogin": "まずログインしてください"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "あなたはこのフォルダーの所有者ではありません",
|
||||
"unauthorized": "このフォルダーの所有者ではありません",
|
||||
"back": "戻る",
|
||||
"textPairs": "テキストペア",
|
||||
"itemsCount": "{count}項目",
|
||||
"itemsCount": "{count} 項目",
|
||||
"memorize": "暗記",
|
||||
"loadingTextPairs": "テキストペアを読み込み中...",
|
||||
"noTextPairs": "このフォルダーにはテキストペアがありません",
|
||||
@@ -45,34 +65,105 @@
|
||||
"enterLanguageName": "言語名を入力してください",
|
||||
"edit": "編集",
|
||||
"delete": "削除",
|
||||
"permissionDenied": "この操作を実行する権限がありません",
|
||||
"permissionDenied": "このアクションを実行する権限がありません",
|
||||
"error": {
|
||||
"update": "この項目を更新する権限がありません。",
|
||||
"delete": "この項目を削除する権限がありません。",
|
||||
"add": "このフォルダーに項目を追加する権限がありません。",
|
||||
"rename": "このフォルダー名を変更する権限がありません。",
|
||||
"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": "これは、人工言語を含む世界中のほぼすべての言語を学ぶのに役立つ非常に便利なウェブサイトです。",
|
||||
"description": "ここは世界のほぼすべての言語(人工言語を含む)を学ぶのに役立つ非常に便利なウェブサイトです。",
|
||||
"explore": "探索",
|
||||
"fortune": {
|
||||
"quote": "Stay hungry, stay foolish.",
|
||||
"author": "— スティーブ・ジョブズ"
|
||||
"author": "— Steve Jobs"
|
||||
},
|
||||
"translator": {
|
||||
"name": "翻訳",
|
||||
"description": "任意の言語に翻訳し、国際音声記号(IPA)で注釈を付けます"
|
||||
"name": "翻訳者",
|
||||
"description": "あらゆる言語に翻訳し、国際音声記号(IPA)で注釈を付けます"
|
||||
},
|
||||
"textSpeaker": {
|
||||
"name": "テキストスピーカー",
|
||||
"description": "テキストを認識して音読します。ループ再生と速度調整をサポート"
|
||||
"description": "テキストを認識して読み上げ、ループ再生と速度調整をサポート"
|
||||
},
|
||||
"srtPlayer": {
|
||||
"name": "SRTビデオプレーヤー",
|
||||
"description": "SRT字幕ファイルに基づいてビデオを文ごとに再生し、ネイティブスピーカーの発音を模倣します"
|
||||
"description": "SRT字幕ファイルに基づいて文ごとにビデオを再生し、ネイティブスピーカーの発音を模倣"
|
||||
},
|
||||
"alphabet": {
|
||||
"name": "アルファベット",
|
||||
@@ -80,71 +171,208 @@
|
||||
},
|
||||
"memorize": {
|
||||
"name": "暗記",
|
||||
"description": "言語Aから言語B、言語Bから言語A、ディクテーションをサポート"
|
||||
"description": "言語Aから言語B、言語Bから言語A、書き取りをサポート"
|
||||
},
|
||||
"dictionary": {
|
||||
"name": "辞書",
|
||||
"description": "単語やフレーズを調べ、詳細な定義と例を表示"
|
||||
"description": "詳細な定義と例文で単語やフレーズを検索"
|
||||
},
|
||||
"moreFeatures": {
|
||||
"name": "その他の機能",
|
||||
"description": "開発中です。お楽しみに"
|
||||
"description": "開発中、お楽しみに"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "認証",
|
||||
"signIn": "ログイン",
|
||||
"title": "サインイン",
|
||||
"signUpTitle": "新規登録",
|
||||
"signIn": "サインイン",
|
||||
"signUp": "新規登録",
|
||||
"email": "メールアドレス",
|
||||
"password": "パスワード",
|
||||
"confirmPassword": "パスワード(確認)",
|
||||
"confirmPassword": "パスワード確認",
|
||||
"name": "名前",
|
||||
"signInButton": "ログイン",
|
||||
"username": "ユーザー名",
|
||||
"emailOrUsername": "メールアドレスまたはユーザー名",
|
||||
"signInButton": "サインイン",
|
||||
"signUpButton": "新規登録",
|
||||
"noAccount": "アカウントをお持ちでないですか?",
|
||||
"hasAccount": "すでにアカウントをお持ちですか?",
|
||||
"signInWithGitHub": "GitHubでログイン",
|
||||
"signInWithGitHub": "GitHubでサインイン",
|
||||
"signUpWithGitHub": "GitHubで新規登録",
|
||||
"invalidEmail": "有効なメールアドレスを入力してください",
|
||||
"passwordTooShort": "パスワードは8文字以上である必要があります",
|
||||
"passwordsNotMatch": "パスワードが一致しません",
|
||||
"nameRequired": "名前を入力してください",
|
||||
"usernameRequired": "ユーザー名を入力してください",
|
||||
"usernameTooShort": "ユーザー名は3文字以上である必要があります",
|
||||
"usernameInvalid": "ユーザー名には文字、数字、アンダースコアのみ使用できます",
|
||||
"emailRequired": "メールアドレスを入力してください",
|
||||
"identifierRequired": "メールアドレスまたはユーザー名を入力してください",
|
||||
"passwordRequired": "パスワードを入力してください",
|
||||
"confirmPasswordRequired": "パスワード(確認)を入力してください",
|
||||
"loading": "読み込み中..."
|
||||
"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": {
|
||||
"folder_selector": {
|
||||
"selectFolder": "フォルダーを選択",
|
||||
"noFolders": "フォルダーが見つかりません",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
"deck_selector": {
|
||||
"selectDeck": "デッキを選択",
|
||||
"noDecks": "デッキが見つかりません",
|
||||
"goToDecks": "デッキへ移動",
|
||||
"noCards": "カードなし",
|
||||
"new": "新規",
|
||||
"learning": "学習中",
|
||||
"review": "復習",
|
||||
"due": "予定"
|
||||
},
|
||||
"memorize": {
|
||||
"answer": "回答",
|
||||
"next": "次へ",
|
||||
"reverse": "逆順",
|
||||
"dictation": "ディクテーション",
|
||||
"noTextPairs": "利用可能なテキストペアがありません",
|
||||
"disorder": "ランダム",
|
||||
"previous": "前へ"
|
||||
"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": "このフォルダーにアクセスする権限がありません"
|
||||
"unauthorized": "このデッキにアクセスする権限がありません"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
"title": "learn-languages",
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "ログイン",
|
||||
"sign_in": "サインイン",
|
||||
"profile": "プロフィール",
|
||||
"folders": "フォルダー"
|
||||
"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}",
|
||||
"email": "メール: {email}",
|
||||
"logout": "ログアウト"
|
||||
},
|
||||
"settings": {
|
||||
"title": "設定",
|
||||
"themeColor": "テーマカラー",
|
||||
"themeColorDescription": "お好みのテーマカラーを選択してください"
|
||||
},
|
||||
"srt_player": {
|
||||
"uploadVideo": "ビデオをアップロード",
|
||||
"uploadSubtitle": "字幕をアップロード",
|
||||
@@ -164,21 +392,60 @@
|
||||
"uploaded": "アップロード済み",
|
||||
"notUploaded": "未アップロード",
|
||||
"upload": "アップロード",
|
||||
"uploadVideoButton": "ビデオをアップロード",
|
||||
"uploadSubtitleButton": "字幕をアップロード",
|
||||
"subtitleUploaded": "字幕をアップロード済み ({count} エントリ)",
|
||||
"subtitleNotUploaded": "字幕がアップロードされていません",
|
||||
"autoPauseStatus": "自動一時停止: {enabled}",
|
||||
"on": "オン",
|
||||
"off": "オフ",
|
||||
"videoUploadFailed": "ビデオのアップロードに失敗しました",
|
||||
"subtitleUploadFailed": "字幕のアップロードに失敗しました"
|
||||
"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)"
|
||||
"viewSavedItems": "保存済み項目を表示",
|
||||
"confirmDeleteAll": "すべて削除してもよろしいですか? (Y/N)",
|
||||
"saved": "保存済み",
|
||||
"clearAll": "すべてクリア",
|
||||
"language": "言語",
|
||||
"customLanguage": "または言語を入力...",
|
||||
"languages": {
|
||||
"auto": "自動",
|
||||
"chinese": "中国語",
|
||||
"english": "英語",
|
||||
"japanese": "日本語",
|
||||
"korean": "韓国語",
|
||||
"french": "フランス語",
|
||||
"german": "ドイツ語",
|
||||
"italian": "イタリア語",
|
||||
"spanish": "スペイン語",
|
||||
"portuguese": "ポルトガル語",
|
||||
"russian": "ロシア語"
|
||||
}
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "言語を検出",
|
||||
"generateIPA": "IPAを生成",
|
||||
"translateInto": "翻訳",
|
||||
"sourceLanguage": "ソース言語",
|
||||
"auto": "自動",
|
||||
"generateIPA": "ipaを生成",
|
||||
"translateInto": "翻訳先",
|
||||
"chinese": "中国語",
|
||||
"english": "英語",
|
||||
"french": "フランス語",
|
||||
@@ -201,38 +468,93 @@
|
||||
"noFolders": "フォルダーが見つかりません",
|
||||
"folderInfo": "{id}. {name}",
|
||||
"close": "閉じる",
|
||||
"success": "テキストペアをフォルダーに追加しました",
|
||||
"error": "テキストペアの追加に失敗しました"
|
||||
"success": "テキストペアがフォルダーに追加されました",
|
||||
"error": "テキストペアをフォルダーに追加できませんでした"
|
||||
},
|
||||
"autoSave": "自動保存"
|
||||
"autoSave": "自動保存",
|
||||
"customLanguage": "または言語を入力...",
|
||||
"pleaseLogin": "ログインしてカードを保存",
|
||||
"pleaseCreateDeck": "先にデッキを作成",
|
||||
"noTranslationToSave": "保存する翻訳なし",
|
||||
"noDeckSelected": "デッキ未選択",
|
||||
"saveAsCard": "カードとして保存",
|
||||
"selectDeck": "デッキ選択",
|
||||
"front": "表面",
|
||||
"back": "裏面",
|
||||
"cancel": "キャンセル",
|
||||
"save": "保存",
|
||||
"savedToDeck": "{deckName}に保存",
|
||||
"saveFailed": "保存失敗"
|
||||
},
|
||||
"dictionary": {
|
||||
"title": "辞書",
|
||||
"description": "詳細な定義と例で単語やフレーズを検索",
|
||||
"description": "詳細な定義と例文で単語やフレーズを検索",
|
||||
"searchPlaceholder": "検索する単語やフレーズを入力...",
|
||||
"searching": "検索中...",
|
||||
"search": "検索",
|
||||
"languageSettings": "言語設定",
|
||||
"queryLanguage": "クエリ言語",
|
||||
"queryLanguageHint": "検索する単語/フレーズの言語",
|
||||
"queryLanguageHint": "検索したい単語/フレーズの言語",
|
||||
"definitionLanguage": "定義言語",
|
||||
"definitionLanguageHint": "定義を表示する言語",
|
||||
"otherLanguagePlaceholder": "または他の言語を入力...",
|
||||
"currentSettings": "現在の設定:クエリ {queryLang}、定義 {definitionLang}",
|
||||
"otherLanguagePlaceholder": "または別の言語を入力...",
|
||||
"other": "その他",
|
||||
"currentSettings": "現在の設定: クエリ {queryLang}, 定義 {definitionLang}",
|
||||
"relookup": "再検索",
|
||||
"saveToFolder": "フォルダに保存",
|
||||
"saveToFolder": "フォルダーに保存",
|
||||
"loading": "読み込み中...",
|
||||
"noResults": "結果が見つかりません",
|
||||
"tryOtherWords": "他の単語やフレーズを試してください",
|
||||
"tryOtherWords": "別の単語やフレーズを試してください",
|
||||
"welcomeTitle": "辞書へようこそ",
|
||||
"welcomeHint": "上の検索ボックスに単語やフレーズを入力して検索を開始",
|
||||
"welcomeHint": "上の検索ボックスに単語やフレーズを入力して検索を始めましょう",
|
||||
"lookupFailed": "検索に失敗しました。後でもう一度お試しください",
|
||||
"relookupSuccess": "再検索しました",
|
||||
"relookupSuccess": "再検索に成功しました",
|
||||
"relookupFailed": "辞書の再検索に失敗しました",
|
||||
"pleaseLogin": "まずログインしてください",
|
||||
"pleaseCreateFolder": "まずフォルダを作成してください",
|
||||
"savedToFolder": "フォルダに保存しました:{folderName}",
|
||||
"saveFailed": "保存に失敗しました。後でもう一度お試しください"
|
||||
"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": "匿名",
|
||||
@@ -245,14 +567,67 @@
|
||||
"displayName": "表示名",
|
||||
"notSet": "未設定",
|
||||
"memberSince": "登録日",
|
||||
"folders": {
|
||||
"title": "フォルダー",
|
||||
"noFolders": "フォルダーがありません",
|
||||
"folderName": "フォルダー名",
|
||||
"totalPairs": "テキストペア数",
|
||||
"logout": "ログアウト",
|
||||
"deleteAccount": {
|
||||
"button": "アカウント削除",
|
||||
"title": "アカウント削除",
|
||||
"warning": "この操作は取り消せません。すべてのデータが完全に削除されます。",
|
||||
"warningDecks": "すべてのデッキとカード",
|
||||
"warningCards": "すべての学習履歴",
|
||||
"warningHistory": "すべての翻訳と辞書の履歴",
|
||||
"warningPermanent": "この操作は取り消せません",
|
||||
"confirmLabel": "確認のためユーザー名を入力してください:",
|
||||
"usernameMismatch": "ユーザー名が一致しません",
|
||||
"cancel": "キャンセル",
|
||||
"confirm": "アカウントを削除する",
|
||||
"success": "アカウントが正常に削除されました",
|
||||
"failed": "アカウントの削除に失敗しました"
|
||||
},
|
||||
"decks": {
|
||||
"title": "デッキ",
|
||||
"noDecks": "まだデッキがありません",
|
||||
"deckName": "デッキ名",
|
||||
"totalCards": "合計カード数",
|
||||
"createdAt": "作成日",
|
||||
"actions": "操作",
|
||||
"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,12 +1,13 @@
|
||||
{
|
||||
"alphabet": {
|
||||
"chooseCharacters": "학습할 문자를 선택하세요",
|
||||
"chooseCharacters": "배우고 싶은 문자를 선택하세요",
|
||||
"chooseAlphabetHint": "학습을 시작할 알파벳을 선택하세요",
|
||||
"japanese": "일본어 가나",
|
||||
"english": "영문 알파벳",
|
||||
"uyghur": "위구르 문자",
|
||||
"esperanto": "에스페란토 문자",
|
||||
"english": "영어 알파벳",
|
||||
"uyghur": "위구르어 알파벳",
|
||||
"esperanto": "에스페란토 알파벳",
|
||||
"loading": "로딩 중...",
|
||||
"loadFailed": "로딩 실패, 다시 시도해 주세요",
|
||||
"loadFailed": "로딩 실패, 다시 시도해주세요",
|
||||
"hideLetter": "문자 숨기기",
|
||||
"showLetter": "문자 표시",
|
||||
"hideIPA": "IPA 숨기기",
|
||||
@@ -14,17 +15,68 @@
|
||||
"roman": "로마자 표기",
|
||||
"letter": "문자",
|
||||
"random": "무작위 모드",
|
||||
"randomNext": "무작위 다음"
|
||||
"randomNext": "무작위 다음",
|
||||
"previousLetter": "이전 문자",
|
||||
"nextLetter": "다음 문자",
|
||||
"keyboardHint": "왼쪽/오른쪽 화살표 키 또는 스페이스바로 무작위, ESC로 뒤로가기",
|
||||
"swipeHint": "왼쪽/오른쪽 화살표 키 또는 스와이프로 탐색, ESC로 뒤로가기"
|
||||
},
|
||||
"folders": {
|
||||
"title": "폴더",
|
||||
"subtitle": "컬렉션 관리",
|
||||
"newFolder": "새 폴더",
|
||||
"creating": "생성 중...",
|
||||
"noFoldersYet": "폴더가 없습니다",
|
||||
"folderInfo": "ID: {id} • {totalPairs}쌍",
|
||||
"noFoldersYet": "아직 폴더가 없습니다",
|
||||
"folderInfo": "ID: {id} • {totalPairs} 쌍",
|
||||
"enterFolderName": "폴더 이름 입력:",
|
||||
"confirmDelete": "삭제하려면 \"{name}\"을(를) 입력하세요:"
|
||||
"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": "이 폴더의 소유자가 아닙니다",
|
||||
@@ -36,39 +88,110 @@
|
||||
"noTextPairs": "이 폴더에 텍스트 쌍이 없습니다",
|
||||
"addNewTextPair": "새 텍스트 쌍 추가",
|
||||
"add": "추가",
|
||||
"updateTextPair": "텍스트 쌍 업데이트",
|
||||
"update": "업데이트",
|
||||
"updateTextPair": "텍스트 쌍 수정",
|
||||
"update": "수정",
|
||||
"text1": "텍스트 1",
|
||||
"text2": "텍스트 2",
|
||||
"language1": "언어 1",
|
||||
"language2": "언어 2",
|
||||
"language1": "로캘 1",
|
||||
"language2": "로캘 2",
|
||||
"enterLanguageName": "언어 이름을 입력하세요",
|
||||
"edit": "편집",
|
||||
"delete": "삭제",
|
||||
"permissionDenied": "이 작업을 수행할 권한이 없습니다",
|
||||
"error": {
|
||||
"update": "이 항목을 업데이트할 권한이 없습니다.",
|
||||
"update": "이 항목을 수정할 권한이 없습니다.",
|
||||
"delete": "이 항목을 삭제할 권한이 없습니다.",
|
||||
"add": "이 폴더에 항목을 추가할 권한이 없습니다.",
|
||||
"rename": "이 폴더 이름을 변경할 권한이 없습니다.",
|
||||
"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": "인공 언어를 포함하여 세상의 거의 모든 언어를 배우는 데 도움이 되는 매우 유용한 웹사이트입니다.",
|
||||
"title": "언어 배우기",
|
||||
"description": "세계의 거의 모든 언어(인공어 포함)를 배우는 데 도움이 되는 매우 유용한 웹사이트입니다.",
|
||||
"explore": "탐색",
|
||||
"fortune": {
|
||||
"quote": "Stay hungry, stay foolish.",
|
||||
"author": "— 스티브 잡스"
|
||||
"author": "— Steve Jobs"
|
||||
},
|
||||
"translator": {
|
||||
"name": "번역기",
|
||||
"description": "모든 언어로 번역하고 국제 음성 기호(IPA)로 주석 추가"
|
||||
"description": "모든 언어로 번역하고 국제 음성 기호(IPA)로 주석 달기"
|
||||
},
|
||||
"textSpeaker": {
|
||||
"name": "텍스트 스피커",
|
||||
"description": "텍스트를 인식하고 읽어줍니다. 반복 재생 및 속도 조정 지원"
|
||||
"description": "텍스트 인식 및 낭독, 반복 재생 및 속도 조절 지원"
|
||||
},
|
||||
"srtPlayer": {
|
||||
"name": "SRT 비디오 플레이어",
|
||||
@@ -84,21 +207,24 @@
|
||||
},
|
||||
"dictionary": {
|
||||
"name": "사전",
|
||||
"description": "단어와 구문을 조회하고 자세한 정의와 예제 제공"
|
||||
"description": "상세한 정의와 예문으로 단어 및 구문 검색"
|
||||
},
|
||||
"moreFeatures": {
|
||||
"name": "더 많은 기능",
|
||||
"description": "개발 중, 기대해 주세요"
|
||||
"description": "개발 중, 기대해주세요"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "인증",
|
||||
"title": "로그인",
|
||||
"signUpTitle": "회원가입",
|
||||
"signIn": "로그인",
|
||||
"signUp": "회원가입",
|
||||
"email": "이메일",
|
||||
"password": "비밀번호",
|
||||
"confirmPassword": "비밀번호 확인",
|
||||
"name": "이름",
|
||||
"username": "사용자명",
|
||||
"emailOrUsername": "이메일 또는 사용자명",
|
||||
"signInButton": "로그인",
|
||||
"signUpButton": "회원가입",
|
||||
"noAccount": "계정이 없으신가요?",
|
||||
@@ -109,28 +235,84 @@
|
||||
"passwordTooShort": "비밀번호는 최소 8자 이상이어야 합니다",
|
||||
"passwordsNotMatch": "비밀번호가 일치하지 않습니다",
|
||||
"nameRequired": "이름을 입력하세요",
|
||||
"usernameRequired": "사용자명을 입력하세요",
|
||||
"usernameTooShort": "사용자명은 최소 3자 이상이어야 합니다",
|
||||
"usernameInvalid": "사용자명은 문자, 숫자, 밑줄만 포함할 수 있습니다",
|
||||
"emailRequired": "이메일을 입력하세요",
|
||||
"identifierRequired": "이메일 또는 사용자명을 입력하세요",
|
||||
"passwordRequired": "비밀번호를 입력하세요",
|
||||
"confirmPasswordRequired": "비밀번호 확인을 입력하세요",
|
||||
"loading": "로딩 중..."
|
||||
"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": {
|
||||
"folder_selector": {
|
||||
"selectFolder": "폴더 선택",
|
||||
"noFolders": "폴더를 찾을 수 없습니다",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
"deck_selector": {
|
||||
"selectDeck": "덱 선택",
|
||||
"noDecks": "덱이 없습니다",
|
||||
"goToDecks": "덱으로 이동",
|
||||
"noCards": "카드가 없습니다",
|
||||
"new": "새로",
|
||||
"learning": "학습 중",
|
||||
"review": "복습",
|
||||
"due": "예정"
|
||||
},
|
||||
"memorize": {
|
||||
"answer": "정답",
|
||||
"next": "다음",
|
||||
"reverse": "반대",
|
||||
"dictation": "받아쓰기",
|
||||
"noTextPairs": "사용 가능한 텍스트 쌍이 없습니다",
|
||||
"disorder": "무작위",
|
||||
"previous": "이전"
|
||||
"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": "이 폴더에 액세스할 권한이 없습니다"
|
||||
"unauthorized": "권한이 없습니다"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
@@ -138,13 +320,66 @@
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "로그인",
|
||||
"profile": "프로필",
|
||||
"folders": "폴더"
|
||||
"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": "자막 업로드",
|
||||
@@ -152,7 +387,7 @@
|
||||
"play": "재생",
|
||||
"previous": "이전",
|
||||
"next": "다음",
|
||||
"restart": "처음부터",
|
||||
"restart": "다시 시작",
|
||||
"autoPause": "자동 일시정지 ({enabled})",
|
||||
"uploadVideoAndSubtitle": "비디오와 자막 파일을 업로드하세요",
|
||||
"uploadVideoFile": "비디오 파일을 업로드하세요",
|
||||
@@ -164,21 +399,60 @@
|
||||
"uploaded": "업로드됨",
|
||||
"notUploaded": "업로드되지 않음",
|
||||
"upload": "업로드",
|
||||
"uploadVideoButton": "비디오 업로드",
|
||||
"uploadSubtitleButton": "자막 업로드",
|
||||
"subtitleUploaded": "자막 업로드됨 ({count}개 항목)",
|
||||
"subtitleNotUploaded": "자막 업로드되지 않음",
|
||||
"autoPauseStatus": "자동 일시정지: {enabled}",
|
||||
"on": "켜기",
|
||||
"off": "끄기",
|
||||
"videoUploadFailed": "비디오 업로드 실패",
|
||||
"subtitleUploadFailed": "자막 업로드 실패"
|
||||
"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)"
|
||||
"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": "번역",
|
||||
"translateInto": "번역할 언어",
|
||||
"chinese": "중국어",
|
||||
"english": "영어",
|
||||
"french": "프랑스어",
|
||||
@@ -201,38 +475,93 @@
|
||||
"noFolders": "폴더를 찾을 수 없습니다",
|
||||
"folderInfo": "{id}. {name}",
|
||||
"close": "닫기",
|
||||
"success": "텍스트 쌍을 폴더에 추가했습니다",
|
||||
"error": "텍스트 쌍 추가 실패"
|
||||
"success": "텍스트 쌍이 폴더에 추가됨",
|
||||
"error": "폴더에 텍스트 쌍 추가 실패"
|
||||
},
|
||||
"autoSave": "자동 저장"
|
||||
"autoSave": "자동 저장",
|
||||
"customLanguage": "또는 언어 입력...",
|
||||
"pleaseLogin": "카드를 저장하려면 로그인하세요",
|
||||
"pleaseCreateDeck": "먼저 덱을 만드세요",
|
||||
"noTranslationToSave": "저장할 번역이 없습니다",
|
||||
"noDeckSelected": "덱이 선택되지 않았습니다",
|
||||
"saveAsCard": "카드로 저장",
|
||||
"selectDeck": "덱 선택",
|
||||
"front": "앞면",
|
||||
"back": "뒷면",
|
||||
"cancel": "취소",
|
||||
"save": "저장",
|
||||
"savedToDeck": "{deckName}에 카드 저장됨",
|
||||
"saveFailed": "카드 저장 실패"
|
||||
},
|
||||
"dictionary": {
|
||||
"title": "사전",
|
||||
"description": "상세한 정의와 예제로 단어 및 구문 검색",
|
||||
"description": "상세한 정의와 예문으로 단어 및 구문 검색",
|
||||
"searchPlaceholder": "검색할 단어나 구문을 입력하세요...",
|
||||
"searching": "검색 중...",
|
||||
"search": "검색",
|
||||
"languageSettings": "언어 설정",
|
||||
"queryLanguage": "쿼리 언어",
|
||||
"queryLanguageHint": "검색하려는 단어/구문의 언어",
|
||||
"queryLanguage": "질의 언어",
|
||||
"queryLanguageHint": "검색할 단어/구문의 언어",
|
||||
"definitionLanguage": "정의 언어",
|
||||
"definitionLanguageHint": "정의를 표시할 언어",
|
||||
"otherLanguagePlaceholder": "또는 다른 언어를 입력하세요...",
|
||||
"currentSettings": "현재 설정: 쿼리 {queryLang}, 정의 {definitionLang}",
|
||||
"relookup": "재검색",
|
||||
"otherLanguagePlaceholder": "또는 다른 언어 입력...",
|
||||
"other": "기타",
|
||||
"currentSettings": "현재 설정: 질의 {queryLang}, 정의 {definitionLang}",
|
||||
"relookup": "다시 검색",
|
||||
"saveToFolder": "폴더에 저장",
|
||||
"loading": "로드 중...",
|
||||
"noResults": "결과를 찾을 수 없습니다",
|
||||
"loading": "로딩 중...",
|
||||
"noResults": "검색 결과 없음",
|
||||
"tryOtherWords": "다른 단어나 구문을 시도하세요",
|
||||
"welcomeTitle": "사전에 오신 것을 환영합니다",
|
||||
"welcomeHint": "위 검색 상자에 단어나 구문을 입력하여 검색을 시작하세요",
|
||||
"welcomeHint": "위의 검색 상자에 단어나 구문을 입력하여 검색을 시작하세요",
|
||||
"lookupFailed": "검색 실패, 나중에 다시 시도하세요",
|
||||
"relookupSuccess": "재검색했습니다",
|
||||
"relookupFailed": "사전 재검색 실패",
|
||||
"relookupSuccess": "다시 검색 성공",
|
||||
"relookupFailed": "사전 다시 검색 실패",
|
||||
"pleaseLogin": "먼저 로그인하세요",
|
||||
"pleaseCreateFolder": "먼저 폴더를 만드세요",
|
||||
"pleaseCreateFolder": "먼저 폴더를 생성하세요",
|
||||
"savedToFolder": "폴더에 저장됨: {folderName}",
|
||||
"saveFailed": "저장 실패, 나중에 다시 시도하세요"
|
||||
"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": "익명",
|
||||
@@ -245,14 +574,49 @@
|
||||
"displayName": "표시 이름",
|
||||
"notSet": "설정되지 않음",
|
||||
"memberSince": "가입일",
|
||||
"logout": "로그아웃",
|
||||
"deleteAccount": {
|
||||
"button": "계정 삭제",
|
||||
"title": "계정 삭제",
|
||||
"warning": "이 작업은 되돌릴 수 없습니다. 모든 데이터가 영구적으로 삭제됩니다.",
|
||||
"warningDecks": "모든 덱과 카드",
|
||||
"warningCards": "모든 학습 진행 상황",
|
||||
"warningHistory": "모든 번역 및 사전 기록",
|
||||
"warningPermanent": "이 작업은 취소할 수 없습니다",
|
||||
"confirmLabel": "확인을 위해 사용자명을 입력하세요:",
|
||||
"usernameMismatch": "사용자명이 일치하지 않습니다",
|
||||
"cancel": "취소",
|
||||
"confirm": "내 계정 삭제",
|
||||
"success": "계정이 성공적으로 삭제되었습니다",
|
||||
"failed": "계정 삭제에 실패했습니다"
|
||||
},
|
||||
"folders": {
|
||||
"title": "폴더",
|
||||
"noFolders": "폴더가 없습니다",
|
||||
"folderName": "폴더 이름",
|
||||
"totalPairs": "텍스트 쌍 수",
|
||||
"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,187 +1,486 @@
|
||||
{
|
||||
"alphabet": {
|
||||
"chooseCharacters": "ئۆگىنەرلىك ھەرپلەرنى تاللاڭ",
|
||||
"japanese": "ياپونىيە كانا",
|
||||
"english": "ئىنگلىز ئېلىپبې",
|
||||
"uyghur": "ئۇيغۇر ئېلىپبېسى",
|
||||
"esperanto": "ئېسپېرانتو ئېلىپبېسى",
|
||||
"loading": "چىقىرىۋېتىلىۋاتىدۇ...",
|
||||
"loadFailed": "چىقىرىش مەغلۇب بولدى، قايتا سىناڭ",
|
||||
"hideLetter": "ھەرپنى يوشۇرۇش",
|
||||
"showLetter": "ھەرپنى كۆرسىتىش",
|
||||
"hideIPA": "IPA نى يوشۇرۇش",
|
||||
"showIPA": "IPA نى كۆرسىتىش",
|
||||
"roman": "روماللاشتۇرۇش",
|
||||
"chooseCharacters": "ئۆگەنمەكچى بولغان ھەرپلەرنى تاللاڭ",
|
||||
"chooseAlphabetHint": "ئۆگىنىشنى باشلاش ئۈچۈن بىر ئېلىپبە تاللاڭ",
|
||||
"japanese": "ياپون يېزىقى",
|
||||
"english": "ئىنگلىز ئېلىپبەسى",
|
||||
"uyghur": "ئۇيغۇر ئېلىپبەسى",
|
||||
"esperanto": "ئېسپېرانتو ئېلىپبەسى",
|
||||
"loading": "يۈكلىنىۋاتىدۇ...",
|
||||
"loadFailed": "يۈكلەش مەغلۇپ بولدى، قايتا سىناڭ",
|
||||
"hideLetter": "ھەرپنى يوشۇر",
|
||||
"showLetter": "ھەرپنى كۆرسەت",
|
||||
"hideIPA": "IPA نى يوشۇر",
|
||||
"showIPA": "IPA نى كۆرسەت",
|
||||
"roman": "لاتىن يېزىقى",
|
||||
"letter": "ھەرپ",
|
||||
"random": "ئىختىيارىي ھالەت",
|
||||
"randomNext": "ئىختىيارىي كېيىنكى"
|
||||
"randomNext": "ئىختىيارىي كېيىنكى",
|
||||
"previousLetter": "ئالدىنقى ھەرپ",
|
||||
"nextLetter": "كېيىنكى ھەرپ",
|
||||
"keyboardHint": "سول/ئوڭ يا ئوق كۇنۇپكىلىرىنى ياكى بوشلۇق كۇنۇپكىسىنى ئىختىيارىي ئالماشتۇرۇش ئۈچۈن ئىشلىتىڭ، ESC قايتىش ئۈچۈن",
|
||||
"swipeHint": "سول/ئوڭ يا ئوق كۇنۇپكىلىرىنى ياكى سىيرىشنى ئىشلىتىپ يۆنىلىڭ، ESC قايتىش ئۈچۈن"
|
||||
},
|
||||
"folders": {
|
||||
"title": "قىسقۇچلار",
|
||||
"subtitle": "توپلىمىڭىزنى باشقۇرۇڭ",
|
||||
"subtitle": "يىغىپ ساقلاشلىرىڭىزنى باشقۇرۇڭ",
|
||||
"newFolder": "يېڭى قىسقۇچ",
|
||||
"creating": "قۇرۇۋاتىدۇ...",
|
||||
"noFoldersYet": "قىسقۇچ يوق",
|
||||
"folderInfo": "كود: {id} • {totalPairs} جۈپ",
|
||||
"enterFolderName": "قىسقۇچ نامىنى كىرگۈزۈڭ:",
|
||||
"confirmDelete": "ئۆچۈرۈش ئۈچۈن «{name}» نى كىرگۈزۈڭ:"
|
||||
"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": "كەينىگە",
|
||||
"unauthorized": "بۇ قىسقۇچنىڭ ئىگىسى ئەمەسسىز",
|
||||
"back": "قايتىش",
|
||||
"textPairs": "تېكىست جۈپلىرى",
|
||||
"itemsCount": "{count} تۈر",
|
||||
"memorize": "ئەستە ساقلاش",
|
||||
"loadingTextPairs": "تېكىست جۈپلىرى چىقىرىۋېتىلىۋاتىدۇ...",
|
||||
"memorize": "يادلاش",
|
||||
"loadingTextPairs": "تېكىست جۈپلىرى يۈكلىنىۋاتىدۇ...",
|
||||
"noTextPairs": "بۇ قىسقۇچتا تېكىست جۈپى يوق",
|
||||
"addNewTextPair": "يېڭى تېكىست جۈپى قوشۇڭ",
|
||||
"addNewTextPair": "يېڭى تېكىست جۈپى قوشۇش",
|
||||
"add": "قوشۇش",
|
||||
"updateTextPair": "تېكىست جۈپىنى يېڭىلاڭ",
|
||||
"updateTextPair": "تېكىست جۈپىنى يېڭىلاش",
|
||||
"update": "يېڭىلاش",
|
||||
"text1": "تېكىست 1",
|
||||
"text2": "تېكىست 2",
|
||||
"language1": "تىل 1",
|
||||
"language2": "تىل 2",
|
||||
"enterLanguageName": "تىل نامىنى كىرگۈزۈڭ",
|
||||
"enterLanguageName": "تىل ئاتىنى كىرگۈزۈڭ",
|
||||
"edit": "تەھرىرلەش",
|
||||
"delete": "ئۆچۈرۈش",
|
||||
"permissionDenied": "بۇ مەشغۇلاتنى ئىجرا قىلىش ھوقۇقىڭىز يوق",
|
||||
"permissionDenied": "بۇ مەشغۇلاتنى ئېلىپ بېرىش ھوقۇقىڭىز يوق",
|
||||
"error": {
|
||||
"update": "بۇ تۈرنى يېڭىلاش ھوقۇقىڭىز يوق.",
|
||||
"delete": "بۇ تۈرنى ئۆچۈرۈش ھوقۇقىڭىز يوق.",
|
||||
"add": "بۇ قىسقۇچقا تۈر قوشۇش ھوقۇقىڭىز يوق.",
|
||||
"rename": "بۇ قىسقۇچنىڭ نامىنى ئۆزگەرتىش ھوقۇقىڭىز يوق.",
|
||||
"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": "بۇ سىزنى دۇنيادىكى ھەممە تىلنى، جۈملىدىن سۈنئىي تىللارنىمۇ ئۆگىنىشىڭىزغا ياردەم بېرىدىغان ناھايىتى پايدىلىق تور بېكەت.",
|
||||
"title": "تىل ئۆگىنىش",
|
||||
"description": "بۇ دۇنيادىكى almost ھەر بىر تىلنى، جۈملىدىن سۈنئىي تىللارنى ئۆگىنىشىڭىزگە ياردەم بېرىدىغان ئىنتايىن قوللىنىشلىق تور بېكەت.",
|
||||
"explore": "ئىزدىنىش",
|
||||
"fortune": {
|
||||
"quote": "Stay hungry, stay foolish.",
|
||||
"author": "— ستىۋ جوۋبس"
|
||||
"quote": "ئاچ قورساق، ئەخمەق بولۇپ تۇرۇڭ.",
|
||||
"author": "— Steve Jobs"
|
||||
},
|
||||
"translator": {
|
||||
"name": "تەرجىمە",
|
||||
"description": "خالىغان تىلغا تەرجىمە قىلىپ خەلقئارالىق فونېتىك ئېلىپبې (IPA) بىلەن ئىزاھاتلاش"
|
||||
"name": "تەرجىمان",
|
||||
"description": "ھەر قانداق تىلغا تەرجىمە قىلىڭ ۋە خەلقئارالىق فونېتىكىلىق ئېلىپبە (IPA) بىلەن ئىزاھلاڭ"
|
||||
},
|
||||
"textSpeaker": {
|
||||
"name": "تېكىست ئوقۇغۇچى",
|
||||
"description": "تېكىستنى پەرقلەندۈرۈپ ئوقىيدۇ، دەۋرىي ئوقۇش ۋە سۈرئەت تەڭشەشنى قوللايدۇ"
|
||||
"description": "تېكىستنى تونۇپ ۋە ئۈنلۈك ئوقۇپ بېرىدۇ، دەۋرىي قويۇش ۋە سۈرئەت تەڭشەشنى قوللايدۇ"
|
||||
},
|
||||
"srtPlayer": {
|
||||
"name": "SRT سىن ئوپىراتورى",
|
||||
"description": "SRT خەت ئاستى فايلى ئاساسىدا سىننى جۈملە-جۈملە قويۇپ، يەرلىك ئىخچام ئاۋازنى ئىمتىلايدۇ"
|
||||
"name": "SRT ۋىدېئو قويغۇچ",
|
||||
"description": "SRT تر پودكاست ھۆججەتلىرىگە ئاساسەن ۋىدېئولارنى جۈمە بويىچە قويۇپ، ئانا تىللىقلارنىڭ تەلەپپۇزىنى دوراڭ"
|
||||
},
|
||||
"alphabet": {
|
||||
"name": "ئېلىپبې",
|
||||
"description": "ئېلىپبېدىن يېڭى تىل ئۆگىنىشنى باشلاڭ"
|
||||
"name": "ئېلىپبە",
|
||||
"description": "يېڭى بىر تىلنى ئېلىپبەدىن باشلاپ ئۆگىنىڭ"
|
||||
},
|
||||
"memorize": {
|
||||
"name": "ئەستە ساقلاش",
|
||||
"description": "تىل A دىن تىل غا، تىل B دىن تىل A غا، دىكتات قوللايدۇ"
|
||||
"name": "يادلاش",
|
||||
"description": "تىل A دىن تىل B گە، تىل B دىن تىل A غا، دىكتات قىلىشنى قوللايدۇ"
|
||||
},
|
||||
"dictionary": {
|
||||
"name": "لۇغەت",
|
||||
"description": "سۆز ۋە سۆزنى ئىزدەپ، تەپسىلىي ئىزاھات ۋە مىساللار بىلەن تەمىنلەيدۇ"
|
||||
"description": "سۆزلەر ۋە ئىبارىلەرنى تەپسىلىي ئېنىقلىما ۋە مىساللار بىلەن ئىزدەڭ"
|
||||
},
|
||||
"moreFeatures": {
|
||||
"name": "تېخىمۇ كۆپ ئىقتىدار",
|
||||
"description": "ئىشلەۋاتىدۇ، كۈتكۈن بولۇڭ"
|
||||
"name": "تېخىمۇ كۆپ ئىقتىدارلار",
|
||||
"description": "تەرەققىيات ئاستىدا، دىققەت قىلىپ تۇرۇڭ"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "دەلىللەش",
|
||||
"title": "كىرىش",
|
||||
"signUpTitle": "تىزىملىتىش",
|
||||
"signIn": "كىرىش",
|
||||
"signUp": "تىزىملىتىش",
|
||||
"email": "ئېلخەت",
|
||||
"password": "ئىم",
|
||||
"confirmPassword": "ئىمنى جەزملەش",
|
||||
"name": "نام",
|
||||
"password": "پارول",
|
||||
"confirmPassword": "پارولنى جەزىملەڭ",
|
||||
"name": "ئىسىم",
|
||||
"username": "ئىشلەتكۈچى ئاتى",
|
||||
"emailOrUsername": "ئېلخەت ياكى ئىشلەتكۈچى ئاتى",
|
||||
"signInButton": "كىرىش",
|
||||
"signUpButton": "تىزىملىتىش",
|
||||
"noAccount": "ھېساباتىڭىز يوقمۇ؟",
|
||||
"hasAccount": "ھېساباتىڭىز بارمۇ؟",
|
||||
"signInWithGitHub": "GitHub بىلەن كىرىڭ",
|
||||
"signUpWithGitHub": "GitHub بىلەن تىزىملىتىڭ",
|
||||
"invalidEmail": "ئىناۋەتلىك ئېلخەت ئادرېسى كىرگۈزۈڭ",
|
||||
"passwordTooShort": "ئىم كەم دېگەندە 8 ھەرپتىن تۇرۇشى كېرەك",
|
||||
"passwordsNotMatch": "ئىم ماس كەلمەيدۇ",
|
||||
"nameRequired": "نامىڭىزنى كىرگۈزۈڭ",
|
||||
"emailRequired": "ئېلخىتىڭىزنى كىرگۈزۈڭ",
|
||||
"passwordRequired": "ئىمىڭىزنى كىرگۈزۈڭ",
|
||||
"confirmPasswordRequired": "ئىمىڭىزنى جەزملەڭ",
|
||||
"loading": "چىقىرىۋېتىلىۋاتىدۇ..."
|
||||
"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": {
|
||||
"folder_selector": {
|
||||
"selectFolder": "قىسقۇچ تاللاڭ",
|
||||
"noFolders": "قىسقۇچ تېپىلمىدى",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
"deck_selector": {
|
||||
"selectDeck": "دېك تاللاش",
|
||||
"noDecks": "دېك يوق",
|
||||
"goToDecks": "دېكلەرگە بار",
|
||||
"noCards": "كارتا يوق",
|
||||
"new": "يېڭى",
|
||||
"learning": "ئۆگىنىش",
|
||||
"review": "تەكرار",
|
||||
"due": "ۋاقتى كەلدى"
|
||||
},
|
||||
"memorize": {
|
||||
"answer": "جاۋاب",
|
||||
"next": "كېيىنكى",
|
||||
"reverse": "تەتۈر",
|
||||
"dictation": "دىكتات",
|
||||
"noTextPairs": "ئىشلەتكىلى بولىدىغان تېكىست جۈپى يوق",
|
||||
"disorder": "بەت ئارلاش",
|
||||
"previous": "ئىلگىرىكى"
|
||||
"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": "بۇ قىسقۇچنى زىيارەت قىلىشقا ھوقۇقىڭىز يوق"
|
||||
"unauthorized": "ھوقۇقسىز"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
"title": "تىل ئۆگىنىش",
|
||||
"title": "learn-languages",
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "كىرىش",
|
||||
"profile": "پروفىل",
|
||||
"folders": "قىسقۇچلار"
|
||||
"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": "مېنىڭ پروفىلىم",
|
||||
"myProfile": "شەخسىي ئۇچۇرۇم",
|
||||
"email": "ئېلخەت: {email}",
|
||||
"logout": "چىقىش"
|
||||
"logout": "چىكىنىش"
|
||||
},
|
||||
"settings": {
|
||||
"title": "تەڭشەكلەر",
|
||||
"themeColor": "تېما رەڭگى",
|
||||
"themeColorDescription": "ياقتۇرىدىغان تېما رەڭگىڭىزنى تاللاڭ"
|
||||
},
|
||||
"srt_player": {
|
||||
"uploadVideo": "سىن يۈكلەڭ",
|
||||
"uploadSubtitle": "خەت ئاستى يۈكلەڭ",
|
||||
"uploadVideo": "ۋىدېئو يۈكلەش",
|
||||
"uploadSubtitle": "تر پودكاست يۈكلەش",
|
||||
"pause": "ۋاقىتلىق توختىتىش",
|
||||
"play": "قويۇش",
|
||||
"previous": "ئىلگىرىكى",
|
||||
"previous": "ئالدىنقى",
|
||||
"next": "كېيىنكى",
|
||||
"restart": "قايتا باشلاش",
|
||||
"autoPause": "ئاپتوماتىك توختىتىش ({enabled})",
|
||||
"uploadVideoAndSubtitle": "سىن ھەم خەت ئاستى فايلىنى يۈكلەڭ",
|
||||
"uploadVideoFile": "سىن فايلى يۈكلەڭ",
|
||||
"uploadSubtitleFile": "خەت ئاستى فايلى يۈكلەڭ",
|
||||
"processingSubtitle": "خەت ئاستى فايلى بىر تەرەپ قىلىۋاتىدۇ...",
|
||||
"needBothFiles": "ئۆگىنىشنى باشلاش ئۈچۈن سىن ھەم خەت ئاستى فايلىنىڭ ھەممىسى لازىم",
|
||||
"videoFile": "سىن فايلى",
|
||||
"subtitleFile": "خەت ئاستى فايلى",
|
||||
"uploadVideoAndSubtitle": "ۋىدېئو ۋە تر پودكاست ھۆججەتلىرىنى يۈكلەڭ",
|
||||
"uploadVideoFile": "ۋىدېئو ھۆججىتى يۈكلەڭ",
|
||||
"uploadSubtitleFile": "تر پودكاست ھۆججىتى يۈكلەڭ",
|
||||
"processingSubtitle": "تر پودكاست ھۆججىتى بىر تەرەپ قىلىنىۋاتىدۇ...",
|
||||
"needBothFiles": "ئۆگىنىشنى باشلاش ئۈچۈن ۋىدېئو ۋە تر پودكاست ھۆججەتلىرى كېرەك",
|
||||
"videoFile": "ۋىدېئو ھۆججىتى",
|
||||
"subtitleFile": "تر پودكاست ھۆججىتى",
|
||||
"uploaded": "يۈكلەندى",
|
||||
"notUploaded": "يۈكلەنمىدى",
|
||||
"upload": "يۈكلەش",
|
||||
"uploadVideoButton": "ۋىدېئو يۈكلەش",
|
||||
"uploadSubtitleButton": "تر پودكاست يۈكلەش",
|
||||
"subtitleUploaded": "تر پودكاست يۈكلەندى ({count} تۈر)",
|
||||
"subtitleNotUploaded": "تر پودكاست يۈكلەنمىدى",
|
||||
"autoPauseStatus": "ئاپتوماتىك توختىتىش: {enabled}",
|
||||
"on": "ئوچۇق",
|
||||
"off": "تاقاق",
|
||||
"videoUploadFailed": "سىن يۈكلەش مەغلۇب بولدى",
|
||||
"subtitleUploadFailed": "خەت ئاستى يۈكلەش مەغلۇب بولدى"
|
||||
"videoUploadFailed": "ۋىدېئو يۈكلەش مەغلۇپ بولدى",
|
||||
"subtitleUploadFailed": "تر پودكاست يۈكلەش مەغلۇپ بولدى",
|
||||
"subtitleLoadSuccess": "تر پودكاست مۇۋەپپەقىيەتلىك يۈكلەندى",
|
||||
"subtitleLoadFailed": "تر پودكاست يۈكلەش مەغلۇپ بولدى",
|
||||
"settings": "تەڭشەكلەر",
|
||||
"shortcuts": "تېزلەتمەلەر",
|
||||
"keyboardShortcuts": "كۇنۇپكا تاختىسى تېزلەتمەلىرى",
|
||||
"playPause": "قويۇش/توختىتىش",
|
||||
"autoPauseToggle": "ئاپتوماتىك توختىتىش",
|
||||
"subtitleSettings": "ئاستى سىزىق تەڭشەكلىرى",
|
||||
"fontSize": "خەت چوڭلۇقى",
|
||||
"textColor": "خەت رەڭگى",
|
||||
"backgroundColor": "تەگلىك رەڭگى",
|
||||
"position": "ئورنى",
|
||||
"opacity": "سۈزۈكلۈك",
|
||||
"top": "ئۈستى",
|
||||
"center": "ئوتتۇرا",
|
||||
"bottom": "ئاستى"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "IPA ھاسىل قىلىش",
|
||||
"viewSavedItems": "ساقلانغان تۈرلەرنى كۆرۈش",
|
||||
"confirmDeleteAll": "ھەممىنى ئۆچۈرەمسىز؟ (H/Y)"
|
||||
"confirmDeleteAll": "ھەممىنى ئۆچۈرەمسىز؟ (Y/N)",
|
||||
"saved": "ساقلاندى",
|
||||
"clearAll": "ھەممىنى تازىلاش",
|
||||
"language": "تىل",
|
||||
"customLanguage": "ياكى تىل كىرگۈزۈڭ...",
|
||||
"languages": {
|
||||
"auto": "ئاپتوماتىك",
|
||||
"chinese": "خەنزۇچە",
|
||||
"english": "ئىنگلىزچە",
|
||||
"japanese": "ياپونچە",
|
||||
"korean": "كورېيەچە",
|
||||
"french": "فرانسۇزچە",
|
||||
"german": "گېرمانچە",
|
||||
"italian": "ئىتاليانچە",
|
||||
"spanish": "ئىسپانچە",
|
||||
"portuguese": "پورتۇگالچە",
|
||||
"russian": "رۇسچە"
|
||||
}
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "تىل پەرقلەندۈرۈش",
|
||||
"generateIPA": "IPA ھاسىل قىلىش",
|
||||
"detectLanguage": "تىلنى تونۇش",
|
||||
"sourceLanguage": "مەنبە تىلى",
|
||||
"auto": "ئاپتوماتىك",
|
||||
"generateIPA": "ipa ھاسىل قىلىش",
|
||||
"translateInto": "تەرجىمە قىلىش",
|
||||
"chinese": "خەنزۇچە",
|
||||
"english": "ئىنگلىزچە",
|
||||
"french": "فرانسۇزچە",
|
||||
"french": "فىرانسۇزچە",
|
||||
"german": "گېرمانچە",
|
||||
"italian": "ئىتاليانچە",
|
||||
"japanese": "ياپونچە",
|
||||
@@ -190,69 +489,150 @@
|
||||
"russian": "رۇسچە",
|
||||
"spanish": "ئىسپانچە",
|
||||
"other": "باشقا",
|
||||
"translating": "تەرجىمە قىلىۋاتىدۇ...",
|
||||
"translating": "تەرجىمە قىلىنىۋاتىدۇ...",
|
||||
"translate": "تەرجىمە قىلىش",
|
||||
"inputLanguage": "بىر تىل كىرگۈزۈڭ.",
|
||||
"history": "تارىخ",
|
||||
"enterLanguage": "تىل كىرگۈزۈڭ",
|
||||
"add_to_folder": {
|
||||
"notAuthenticated": "دەلىتلەنمىدىڭىز",
|
||||
"chooseFolder": "قوشۇلىدىغان قىسقۇچنى تاللاڭ",
|
||||
"notAuthenticated": "تىزىملىتىلمىدىڭىز",
|
||||
"chooseFolder": "قوشۇش ئۈچۈن قىسقۇچ تاللاڭ",
|
||||
"noFolders": "قىسقۇچ تېپىلمىدى",
|
||||
"folderInfo": "{id}. {name}",
|
||||
"close": "تاقاش",
|
||||
"success": "تېكىست جۈپى قىسقۇچقا قوشۇلدى",
|
||||
"error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇب بولدى"
|
||||
"error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇپ بولدى"
|
||||
},
|
||||
"autoSave": "ئاپتوماتىك ساقلاش"
|
||||
"autoSave": "ئاپتوماتىك ساقلاش",
|
||||
"customLanguage": "ياكى تىل تىل كىرۇڭ...",
|
||||
"pleaseLogin": "كارتا ساقلاش ئۈچۈن كىرىڭ",
|
||||
"pleaseCreateDeck": "ئاۋۋال دېك قۇرۇڭ",
|
||||
"noTranslationToSave": "ساقلايدىغان تەرجىمە يوق",
|
||||
"noDeckSelected": "دېك تاللانمىدى",
|
||||
"saveAsCard": "كارتا ساقلاش",
|
||||
"selectDeck": "دېك تاللاش",
|
||||
"front": "ئالدى",
|
||||
"back": "كەينى",
|
||||
"cancel": "بىكار قىلىش",
|
||||
"save": "ساقلاش",
|
||||
"savedToDeck": "{deckName} غا ساقلاندى",
|
||||
"saveFailed": "ساقلاش مەغلۇپ"
|
||||
},
|
||||
"dictionary": {
|
||||
"title": "لۇغەت",
|
||||
"description": "تەپسىلىي ئىلمىيى ۋە مىساللار بىلەن سۆز ۋە ئىبارە ئىزدەش",
|
||||
"searchPlaceholder": "ئىزدەيدىغان سۆز ياكى ئىبارە كىرگۈزۈڭ...",
|
||||
"description": "سۆزلەر ۋە ئىبارىلەرنى تەپسىلىي ئېنىقلىما ۋە مىساللار بىلەن ئىزدەڭ",
|
||||
"searchPlaceholder": "ئىزدەش ئۈچۈن سۆز ياكى ئىبارە كىرگۈزۈڭ...",
|
||||
"searching": "ئىزدەۋاتىدۇ...",
|
||||
"search": "ئىزدە",
|
||||
"languageSettings": "تىل تەڭشىكى",
|
||||
"queryLanguage": "سۈرەشتۈرۈش تىلى",
|
||||
"queryLanguageHint": "ئىزدەمدەكچى بولغان سۆز/ئىبارە قايسى تىلدا",
|
||||
"definitionLanguage": "ئىلمىيى تىلى",
|
||||
"definitionLanguageHint": "ئىلمىيىنى قايسى تىلدا كۆرۈشنى ئويلىشىسىز",
|
||||
"search": "ئىزدەش",
|
||||
"languageSettings": "تىل تەڭشەكلىرى",
|
||||
"queryLanguage": "سۈرۈشتۈرۈش تىلى",
|
||||
"queryLanguageHint": "ئىزدىمەكچى بولغان سۆز/ئىبارە قايسى تىلدا",
|
||||
"definitionLanguage": "ئېنىقلىما تىلى",
|
||||
"definitionLanguageHint": "ئېنىقلىمىلارنى قايسى تىلدا كۆرمەكچى",
|
||||
"otherLanguagePlaceholder": "ياكى باشقا تىل كىرگۈزۈڭ...",
|
||||
"currentSettings": "نۆۋەتتىكى تەڭشەك: سۈرەشتۈرۈش {queryLang}، ئىلمىيى {definitionLang}",
|
||||
"relookup": "قايتا ئىزدە",
|
||||
"saveToFolder": "قىسقۇچقا ساقلا",
|
||||
"loading": "يۈكلىۋاتىدۇ...",
|
||||
"other": "باشقا",
|
||||
"currentSettings": "نۆۋەتتىكى تەڭشەكلەر: سۈرۈشتۈرۈش {queryLang}، ئېنىقلىما {definitionLang}",
|
||||
"relookup": "قايتا ئىزدەش",
|
||||
"saveToFolder": "قىسقۇچقا ساقلاش",
|
||||
"loading": "يۈكلىنىۋاتىدۇ...",
|
||||
"noResults": "نەتىجە تېپىلمىدى",
|
||||
"tryOtherWords": "باشقا سۆز ياكى ئىبارە سىناڭ",
|
||||
"welcomeTitle": "لۇغەتكە مەرھەمەت",
|
||||
"tryOtherWords": "باشقا سۆز ياكى ئىبارىلەرنى سىناڭ",
|
||||
"welcomeTitle": "لۇغەتكە خۇش كەلدىڭىز",
|
||||
"welcomeHint": "ئىزدەشنى باشلاش ئۈچۈن يۇقىرىدىكى ئىزدەش رامكىسىغا سۆز ياكى ئىبارە كىرگۈزۈڭ",
|
||||
"lookupFailed": "ئىزدەش مەغلۇب بولدى، كېيىنرەك قايتا سىناڭ",
|
||||
"relookupSuccess": "مۇۋەپپەقىيەتلىك قايتا ئىزدىدى",
|
||||
"relookupFailed": "لۇغەت قايتا ئىزدىشى مەغلۇب بولدى",
|
||||
"pleaseLogin": "ئاۋۋال تىزىملىتىڭ",
|
||||
"pleaseCreateFolder": "ئاۋۋال قىسقۇچ قۇرۇڭ",
|
||||
"lookupFailed": "ئىزدەش مەغلۇپ بولدى، كېيىن قايتا سىناڭ",
|
||||
"relookupSuccess": "مۇۋەپپەقىيەتلىك قايتا ئىزدەلدى",
|
||||
"relookupFailed": "لۇغەت قايتا ئىزدەش مەغلۇپ بولدى",
|
||||
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ",
|
||||
"pleaseCreateFolder": "ئاۋۋال بىر قىسقۇچ قۇرۇڭ",
|
||||
"savedToFolder": "قىسقۇچقا ساقلاندى: {folderName}",
|
||||
"saveFailed": "ساقلاش مەغلۇب بولدى، كېيىنرەك قايتا سىناڭ"
|
||||
"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": "ئىسىمسىز",
|
||||
"anonymous": "نامسىز",
|
||||
"email": "ئېلخەت",
|
||||
"verified": "دەلىللەندى",
|
||||
"unverified": "دەلىتلەنمىدى",
|
||||
"accountInfo": "ھېسابات ئۇچۇرى",
|
||||
"userId": "ئىشلەتكۈچى كودى",
|
||||
"username": "ئىشلەتكۈچى نامى",
|
||||
"displayName": "كۆرسىتىلىدىغان نام",
|
||||
"verified": "دەلىللەنگەن",
|
||||
"unverified": "دەلىللەنمىگەن",
|
||||
"accountInfo": "ھېسابات ئۇچۇرلىرى",
|
||||
"userId": "ئىشلەتكۈچى كىملىكى",
|
||||
"username": "ئىشلەتكۈچى ئاتى",
|
||||
"displayName": "كۆرسىتىش ئاتى",
|
||||
"notSet": "تەڭشەلمىگەن",
|
||||
"memberSince": "تىزىملاتقان ۋاقىت",
|
||||
"folders": {
|
||||
"title": "قىسقۇچلار",
|
||||
"noFolders": "قىسقۇچ يوق",
|
||||
"folderName": "قىسقۇچ نامى",
|
||||
"totalPairs": "تېكىست جۈپ سانى",
|
||||
"createdAt": "قۇرۇلغان ۋاقىت",
|
||||
"actions": "مەشغۇلات",
|
||||
"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,6 +1,7 @@
|
||||
{
|
||||
"alphabet": {
|
||||
"chooseCharacters": "请选择您想学习的字符",
|
||||
"chooseAlphabetHint": "选择一种语言的字母表开始学习",
|
||||
"japanese": "日语假名",
|
||||
"english": "英文字母",
|
||||
"uyghur": "维吾尔字母",
|
||||
@@ -14,7 +15,11 @@
|
||||
"roman": "罗马音",
|
||||
"letter": "字母",
|
||||
"random": "随机模式",
|
||||
"randomNext": "随机下一个"
|
||||
"randomNext": "随机下一个",
|
||||
"previousLetter": "上一个字母",
|
||||
"nextLetter": "下一个字母",
|
||||
"keyboardHint": "使用左右箭头键或空格键随机切换,ESC键返回",
|
||||
"swipeHint": "使用左右箭头键或滑动切换字母"
|
||||
},
|
||||
"folders": {
|
||||
"title": "文件夹",
|
||||
@@ -24,7 +29,22 @@
|
||||
"noFoldersYet": "还没有文件夹",
|
||||
"folderInfo": "ID: {id} • {totalPairs} 个文本对",
|
||||
"enterFolderName": "输入文件夹名称:",
|
||||
"confirmDelete": "输入 \"{name}\" 以删除:"
|
||||
"confirmDelete": "输入 \"{name}\" 以删除:",
|
||||
"myFolders": "我的文件夹",
|
||||
"publicFolders": "公开文件夹",
|
||||
"public": "公开",
|
||||
"private": "私有",
|
||||
"setPublic": "设为公开",
|
||||
"setPrivate": "设为私有",
|
||||
"publicFolderInfo": "{userName} • {totalPairs} 个文本对",
|
||||
"searchPlaceholder": "搜索公开文件夹...",
|
||||
"loading": "加载中...",
|
||||
"noPublicFolders": "没有找到公开文件夹",
|
||||
"unknownUser": "未知用户",
|
||||
"enterNewName": "输入新名称:",
|
||||
"favorite": "收藏",
|
||||
"unfavorite": "取消收藏",
|
||||
"pleaseLogin": "请先登录"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "您不是此文件夹的所有者",
|
||||
@@ -54,6 +74,77 @@
|
||||
"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": {
|
||||
"title": "学语言",
|
||||
"description": "这里是一个非常有用的网站,可以帮助您学习世界上几乎每一种语言,包括人造语言。",
|
||||
@@ -93,6 +184,7 @@
|
||||
},
|
||||
"auth": {
|
||||
"title": "登录",
|
||||
"signUpTitle": "注册",
|
||||
"signIn": "登录",
|
||||
"signUp": "注册",
|
||||
"email": "邮箱",
|
||||
@@ -118,25 +210,102 @@
|
||||
"identifierRequired": "请输入邮箱或用户名",
|
||||
"passwordRequired": "请输入密码",
|
||||
"confirmPasswordRequired": "请确认密码",
|
||||
"loading": "加载中..."
|
||||
"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": {
|
||||
"folder_selector": {
|
||||
"selectFolder": "选择文件夹",
|
||||
"noFolders": "未找到文件夹",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
"deck_selector": {
|
||||
"selectDeck": "选择牌组",
|
||||
"noDecks": "未找到牌组",
|
||||
"goToDecks": "前往牌组",
|
||||
"noCards": "无卡片",
|
||||
"new": "新卡片",
|
||||
"learning": "学习中",
|
||||
"review": "复习",
|
||||
"due": "待复习"
|
||||
},
|
||||
"memorize": {
|
||||
"answer": "答案",
|
||||
"next": "下一个",
|
||||
"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": "听写",
|
||||
"noTextPairs": "没有可用的文本对",
|
||||
"disorder": "乱序",
|
||||
"previous": "上一个"
|
||||
"clickToPlay": "点击播放",
|
||||
"yourAnswer": "你的答案",
|
||||
"typeWhatYouHear": "输入你听到的内容",
|
||||
"correct": "正确",
|
||||
"incorrect": "错误",
|
||||
"restart": "重新开始",
|
||||
"orderLimited": "顺序有限",
|
||||
"orderInfinite": "顺序无限",
|
||||
"randomLimited": "随机有限",
|
||||
"randomInfinite": "随机无限",
|
||||
"noIpa": "无音标"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "您无权访问该文件夹"
|
||||
"unauthorized": "您无权访问该牌组"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
@@ -144,13 +313,66 @@
|
||||
"sourceCode": "源码",
|
||||
"sign_in": "登录",
|
||||
"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": {
|
||||
"myProfile": "我的个人资料",
|
||||
"email": "邮箱:{email}",
|
||||
"logout": "退出登录"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"themeColor": "主题色",
|
||||
"themeColorDescription": "选择您喜欢的主题色"
|
||||
},
|
||||
"srt_player": {
|
||||
"upload": "上传",
|
||||
"uploadVideo": "上传视频",
|
||||
@@ -170,19 +392,58 @@
|
||||
"subtitleFile": "字幕文件",
|
||||
"uploaded": "已上传",
|
||||
"notUploaded": "未上传",
|
||||
"uploadVideoButton": "上传视频",
|
||||
"uploadSubtitleButton": "上传字幕",
|
||||
"subtitleUploaded": "字幕已上传 ({count} 条)",
|
||||
"subtitleNotUploaded": "字幕未上传",
|
||||
"autoPauseStatus": "自动暂停: {enabled}",
|
||||
"on": "开",
|
||||
"off": "关",
|
||||
"videoUploadFailed": "视频上传失败",
|
||||
"subtitleUploadFailed": "字幕上传失败"
|
||||
"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)"
|
||||
"confirmDeleteAll": "确定删光吗?(Y/N)",
|
||||
"saved": "已保存",
|
||||
"clearAll": "清空全部",
|
||||
"language": "语言",
|
||||
"customLanguage": "或输入语言...",
|
||||
"languages": {
|
||||
"auto": "自动",
|
||||
"chinese": "中文",
|
||||
"english": "英语",
|
||||
"japanese": "日语",
|
||||
"korean": "韩语",
|
||||
"french": "法语",
|
||||
"german": "德语",
|
||||
"italian": "意大利语",
|
||||
"spanish": "西班牙语",
|
||||
"portuguese": "葡萄牙语",
|
||||
"russian": "俄语"
|
||||
}
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "检测语言",
|
||||
"sourceLanguage": "源语言",
|
||||
"auto": "自动",
|
||||
"generateIPA": "生成国际音标",
|
||||
"translateInto": "翻译为",
|
||||
"chinese": "中文",
|
||||
@@ -210,7 +471,20 @@
|
||||
"success": "文本对已添加到文件夹",
|
||||
"error": "添加文本对到文件夹失败"
|
||||
},
|
||||
"autoSave": "自动保存"
|
||||
"autoSave": "自动保存",
|
||||
"customLanguage": "或输入语言...",
|
||||
"pleaseLogin": "请登录后保存卡片",
|
||||
"pleaseCreateDeck": "请先创建卡组",
|
||||
"noTranslationToSave": "没有可保存的翻译",
|
||||
"noDeckSelected": "未选择卡组",
|
||||
"saveAsCard": "保存为卡片",
|
||||
"selectDeck": "选择卡组",
|
||||
"front": "正面",
|
||||
"back": "背面",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"savedToDeck": "已保存到 {deckName}",
|
||||
"saveFailed": "保存失败"
|
||||
},
|
||||
"dictionary": {
|
||||
"title": "词典",
|
||||
@@ -224,6 +498,7 @@
|
||||
"definitionLanguage": "释义语言",
|
||||
"definitionLanguageHint": "你希望用什么语言查看释义",
|
||||
"otherLanguagePlaceholder": "或输入其他语言...",
|
||||
"other": "其他",
|
||||
"currentSettings": "当前设置:查询 {queryLang},释义 {definitionLang}",
|
||||
"relookup": "重新查询",
|
||||
"saveToFolder": "保存到文件夹",
|
||||
@@ -238,7 +513,45 @@
|
||||
"pleaseLogin": "请先登录",
|
||||
"pleaseCreateFolder": "请先创建文件夹",
|
||||
"savedToFolder": "已保存到文件夹:{folderName}",
|
||||
"saveFailed": "保存失败,请稍后重试"
|
||||
"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": "匿名",
|
||||
@@ -251,14 +564,67 @@
|
||||
"displayName": "显示名称",
|
||||
"notSet": "未设置",
|
||||
"memberSince": "注册时间",
|
||||
"folders": {
|
||||
"title": "文件夹",
|
||||
"noFolders": "还没有文件夹",
|
||||
"folderName": "文件夹名称",
|
||||
"totalPairs": "文本对数量",
|
||||
"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": "还没有关注任何人"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
package.json
16
package.json
@@ -11,38 +11,46 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.2.0",
|
||||
"@prisma/client": "^7.2.0",
|
||||
"@prisma/adapter-pg": "^7.4.2",
|
||||
"@prisma/client": "7.4.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-auth": "^1.4.10",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.1.1",
|
||||
"next-intl": "^4.7.0",
|
||||
"nodemailer": "^8.0.2",
|
||||
"openai": "^6.27.0",
|
||||
"pg": "^8.16.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"sonner": "^2.0.7",
|
||||
"sql.js": "^1.14.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"unstorage": "^1.17.3",
|
||||
"zod": "^4.3.5"
|
||||
"winston": "^3.19.0",
|
||||
"zod": "^4.3.5",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@better-auth/cli": "^1.4.10",
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/sql.js": "^1.4.9",
|
||||
"@typescript-eslint/eslint-plugin": "^8.51.0",
|
||||
"@typescript-eslint/parser": "^8.51.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.1.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"prisma": "^7.2.0",
|
||||
"prisma": "^7.4.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
|
||||
703
pnpm-lock.yaml
generated
703
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,120 +0,0 @@
|
||||
-- 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,138 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `ipa1` on the `pairs` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `ipa2` on the `pairs` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
-- 重命名并修改类型为 TEXT
|
||||
ALTER TABLE "pairs"
|
||||
RENAME COLUMN "locale1" TO "language1";
|
||||
|
||||
ALTER TABLE "pairs"
|
||||
ALTER COLUMN "language1" SET DATA TYPE VARCHAR(20);
|
||||
|
||||
ALTER TABLE "pairs"
|
||||
RENAME COLUMN "locale2" TO "language2";
|
||||
|
||||
ALTER TABLE "pairs"
|
||||
ALTER COLUMN "language2" SET DATA TYPE VARCHAR(20);
|
||||
|
||||
-- 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_word_id" INTEGER,
|
||||
"dictionary_phrase_id" INTEGER,
|
||||
|
||||
CONSTRAINT "dictionary_lookups_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_words" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"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_words_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_phrases" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"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_phrases_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_word_entries" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"word_id" INTEGER NOT NULL,
|
||||
"ipa" TEXT NOT NULL,
|
||||
"definition" TEXT NOT NULL,
|
||||
"part_of_speech" TEXT NOT NULL,
|
||||
"example" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "dictionary_word_entries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_phrase_entries" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"phrase_id" INTEGER NOT NULL,
|
||||
"definition" TEXT NOT NULL,
|
||||
"example" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "dictionary_phrase_entries_pkey" PRIMARY KEY ("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_text_query_lang_definition_lang_idx" ON "dictionary_lookups"("text", "query_lang", "definition_lang");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_words_standard_form_idx" ON "dictionary_words"("standard_form");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_words_query_lang_definition_lang_idx" ON "dictionary_words"("query_lang", "definition_lang");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "dictionary_words_standard_form_query_lang_definition_lang_key" ON "dictionary_words"("standard_form", "query_lang", "definition_lang");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_phrases_standard_form_idx" ON "dictionary_phrases"("standard_form");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_phrases_query_lang_definition_lang_idx" ON "dictionary_phrases"("query_lang", "definition_lang");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "dictionary_phrases_standard_form_query_lang_definition_lang_key" ON "dictionary_phrases"("standard_form", "query_lang", "definition_lang");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_word_entries_word_id_idx" ON "dictionary_word_entries"("word_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_word_entries_created_at_idx" ON "dictionary_word_entries"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_phrase_entries_phrase_id_idx" ON "dictionary_phrase_entries"("phrase_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_phrase_entries_created_at_idx" ON "dictionary_phrase_entries"("created_at");
|
||||
|
||||
-- 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_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_word_id_fkey" FOREIGN KEY ("dictionary_word_id") REFERENCES "dictionary_words"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_phrase_id_fkey" FOREIGN KEY ("dictionary_phrase_id") REFERENCES "dictionary_phrases"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dictionary_word_entries" ADD CONSTRAINT "dictionary_word_entries_word_id_fkey" FOREIGN KEY ("word_id") REFERENCES "dictionary_words"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dictionary_phrase_entries" ADD CONSTRAINT "dictionary_phrase_entries_phrase_id_fkey" FOREIGN KEY ("phrase_id") REFERENCES "dictionary_phrases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,8 +0,0 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "dictionary_phrases_standard_form_query_lang_definition_lang_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "dictionary_words_standard_form_query_lang_definition_lang_key";
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "pairs_folder_id_locale1_locale2_text1_key" RENAME TO "pairs_folder_id_language1_language2_text1_key";
|
||||
@@ -1,30 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "translation_history" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"source_text" TEXT NOT NULL,
|
||||
"source_language" VARCHAR(20) NOT NULL,
|
||||
"target_language" VARCHAR(20) 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 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 "translation_history" ADD CONSTRAINT "translation_history_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -1,11 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[folder_id,language1,language2,text1,text2]` on the table `pairs` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- DropIndex
|
||||
DROP INDEX "pairs_folder_id_language1_language2_text1_key";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "pairs_folder_id_language1_language2_text1_text2_key" ON "pairs"("folder_id", "language1", "language2", "text1", "text2");
|
||||
@@ -1,7 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "pairs" ALTER COLUMN "language1" SET DATA TYPE TEXT,
|
||||
ALTER COLUMN "language2" SET DATA TYPE TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "translation_history" ALTER COLUMN "source_language" SET DATA TYPE TEXT,
|
||||
ALTER COLUMN "target_language" SET DATA TYPE TEXT;
|
||||
@@ -1,94 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `dictionary_phrase_id` on the `dictionary_lookups` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `dictionary_word_id` on the `dictionary_lookups` table. All the data in the column will be lost.
|
||||
- You are about to drop the `dictionary_phrase_entries` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `dictionary_phrases` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `dictionary_word_entries` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `dictionary_words` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "dictionary_lookups" DROP CONSTRAINT "dictionary_lookups_dictionary_phrase_id_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "dictionary_lookups" DROP CONSTRAINT "dictionary_lookups_dictionary_word_id_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "dictionary_phrase_entries" DROP CONSTRAINT "dictionary_phrase_entries_phrase_id_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "dictionary_word_entries" DROP CONSTRAINT "dictionary_word_entries_word_id_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "dictionary_lookups_text_query_lang_definition_lang_idx";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "dictionary_lookups" DROP COLUMN "dictionary_phrase_id",
|
||||
DROP COLUMN "dictionary_word_id",
|
||||
ADD COLUMN "dictionary_item_id" INTEGER,
|
||||
ADD COLUMN "normalized_text" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "dictionary_phrase_entries";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "dictionary_phrases";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "dictionary_word_entries";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "dictionary_words";
|
||||
|
||||
-- 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")
|
||||
);
|
||||
|
||||
-- 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 "dictionary_lookups_normalized_text_idx" ON "dictionary_lookups"("normalized_text");
|
||||
|
||||
-- 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_entries" ADD CONSTRAINT "dictionary_entries_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "dictionary_items"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,12 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[username]` on the table `user` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "user" ADD COLUMN "displayUsername" TEXT,
|
||||
ADD COLUMN "username" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_username_key" ON "user"("username");
|
||||
262
prisma/migrations/20260310014042_init/migration.sql
Normal file
262
prisma/migrations/20260310014042_init/migration.sql
Normal file
@@ -0,0 +1,262 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- 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;
|
||||
207
prisma/migrations/20260310111728_anki_refactor/migration.sql
Normal file
207
prisma/migrations/20260310111728_anki_refactor/migration.sql
Normal file
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
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;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "decks" ADD COLUMN "new_per_day" INTEGER NOT NULL DEFAULT 20,
|
||||
ADD COLUMN "rev_per_day" INTEGER NOT NULL DEFAULT 200;
|
||||
@@ -7,21 +7,27 @@ datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// User & Auth
|
||||
// ============================================
|
||||
|
||||
model User {
|
||||
id String @id
|
||||
name String
|
||||
email String @unique
|
||||
emailVerified Boolean @default(false)
|
||||
image String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
displayUsername String?
|
||||
username String? @unique
|
||||
accounts Account[]
|
||||
dictionaryLookUps DictionaryLookUp[]
|
||||
folders Folder[]
|
||||
sessions Session[]
|
||||
translationHistories TranslationHistory[]
|
||||
id String @id
|
||||
name String
|
||||
email String @unique
|
||||
emailVerified Boolean @default(false)
|
||||
image String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
displayUsername String?
|
||||
username String @unique
|
||||
bio String?
|
||||
accounts Account[]
|
||||
decks Deck[]
|
||||
deckFavorites DeckFavorite[]
|
||||
sessions Session[]
|
||||
followers Follow[] @relation("UserFollowers")
|
||||
following Follow[] @relation("UserFollowing")
|
||||
|
||||
@@map("user")
|
||||
}
|
||||
@@ -73,104 +79,98 @@ model Verification {
|
||||
@@map("verification")
|
||||
}
|
||||
|
||||
model Pair {
|
||||
id Int @id @default(autoincrement())
|
||||
language1 String
|
||||
language2 String
|
||||
text1 String
|
||||
text2 String
|
||||
ipa1 String?
|
||||
ipa2 String?
|
||||
folderId Int @map("folder_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
|
||||
// ============================================
|
||||
// Deck & Card
|
||||
// ============================================
|
||||
|
||||
@@unique([folderId, language1, language2, text1, text2])
|
||||
@@index([folderId])
|
||||
@@map("pairs")
|
||||
enum Visibility {
|
||||
PUBLIC
|
||||
PRIVATE
|
||||
}
|
||||
|
||||
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[]
|
||||
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])
|
||||
@@map("folders")
|
||||
@@index([visibility])
|
||||
@@map("decks")
|
||||
}
|
||||
|
||||
model DictionaryLookUp {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String? @map("user_id")
|
||||
text String
|
||||
queryLang String @map("query_lang")
|
||||
definitionLang String @map("definition_lang")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
dictionaryItemId Int? @map("dictionary_item_id")
|
||||
normalizedText String @default("") @map("normalized_text")
|
||||
dictionaryItem DictionaryItem? @relation(fields: [dictionaryItemId], references: [id])
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
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([userId])
|
||||
@@index([createdAt])
|
||||
@@index([normalizedText])
|
||||
@@map("dictionary_lookups")
|
||||
@@index([deckId])
|
||||
@@index([word])
|
||||
@@map("cards")
|
||||
}
|
||||
|
||||
model DictionaryItem {
|
||||
id Int @id @default(autoincrement())
|
||||
frequency Int @default(1)
|
||||
standardForm String @map("standard_form")
|
||||
queryLang String @map("query_lang")
|
||||
definitionLang String @map("definition_lang")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
entries DictionaryEntry[]
|
||||
lookups DictionaryLookUp[]
|
||||
|
||||
@@unique([standardForm, queryLang, definitionLang])
|
||||
@@index([standardForm])
|
||||
@@index([queryLang, definitionLang])
|
||||
@@map("dictionary_items")
|
||||
}
|
||||
|
||||
model DictionaryEntry {
|
||||
id Int @id @default(autoincrement())
|
||||
itemId Int @map("item_id")
|
||||
ipa String?
|
||||
model CardMeaning {
|
||||
id Int @id @default(autoincrement())
|
||||
cardId Int
|
||||
partOfSpeech String?
|
||||
definition String
|
||||
partOfSpeech String? @map("part_of_speech")
|
||||
example String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
item DictionaryItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
example String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([itemId])
|
||||
@@index([createdAt])
|
||||
@@map("dictionary_entries")
|
||||
@@index([cardId])
|
||||
@@map("card_meanings")
|
||||
}
|
||||
|
||||
model TranslationHistory {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String? @map("user_id")
|
||||
sourceText String @map("source_text")
|
||||
sourceLanguage String @map("source_language")
|
||||
targetLanguage String @map("target_language")
|
||||
translatedText String @map("translated_text")
|
||||
sourceIpa String? @map("source_ipa")
|
||||
targetIpa String? @map("target_ipa")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
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([createdAt])
|
||||
@@index([sourceText, targetLanguage])
|
||||
@@index([translatedText, sourceLanguage, targetLanguage])
|
||||
@@map("translation_history")
|
||||
@@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")
|
||||
}
|
||||
|
||||
147
scripts/find-missing-translations.ts
Normal file
147
scripts/find-missing-translations.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 查找缺失的翻译键
|
||||
* 用法: 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();
|
||||
154
scripts/find-unused-translations.ts
Normal file
154
scripts/find-unused-translations.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 查找多余的翻译键
|
||||
* 用法: 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();
|
||||
99
src/app/(auth)/forgot-password/page.tsx
Normal file
99
src/app/(auth)/forgot-password/page.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
163
src/app/(auth)/login/page.tsx
Normal file
163
src/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
25
src/app/(auth)/logout/page.tsx
Normal file
25
src/app/(auth)/logout/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
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 (<></>);
|
||||
}
|
||||
13
src/app/(auth)/profile/page.tsx
Normal file
13
src/app/(auth)/profile/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
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");
|
||||
}
|
||||
154
src/app/(auth)/reset-password/page.tsx
Normal file
154
src/app/(auth)/reset-password/page.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
133
src/app/(auth)/signup/page.tsx
Normal file
133
src/app/(auth)/signup/page.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
103
src/app/(auth)/users/[username]/DeleteAccountButton.tsx
Normal file
103
src/app/(auth)/users/[username]/DeleteAccountButton.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
src/app/(auth)/users/[username]/followers/page.tsx
Normal file
44
src/app/(auth)/users/[username]/followers/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
44
src/app/(auth)/users/[username]/following/page.tsx
Normal file
44
src/app/(auth)/users/[username]/following/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
199
src/app/(auth)/users/[username]/page.tsx
Normal file
199
src/app/(auth)/users/[username]/page.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -54,8 +54,8 @@ export default function Alphabet() {
|
||||
{t("chooseCharacters")}
|
||||
</h1>
|
||||
{/* 副标题说明 */}
|
||||
<p className="text-gray-600 mb-8 text-lg">
|
||||
选择一种语言的字母表开始学习
|
||||
<p className="text-lg text-gray-600 text-center">
|
||||
{t("chooseAlphabetHint")}
|
||||
</p>
|
||||
|
||||
{/* 语言选择按钮网格 */}
|
||||
|
||||
280
src/app/(features)/dictionary/DictionaryClient.tsx
Normal file
280
src/app/(features)/dictionary/DictionaryClient.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
"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,13 +1,15 @@
|
||||
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">
|
||||
@@ -21,19 +23,17 @@ export function DictionaryEntry({ entry }: DictionaryEntryProps) {
|
||||
)}
|
||||
</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}
|
||||
|
||||
80
src/app/(features)/dictionary/LanguageSelector.tsx
Normal file
80
src/app/(features)/dictionary/LanguageSelector.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"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,117 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { POPULAR_LANGUAGES } from "./constants";
|
||||
|
||||
interface SearchFormProps {
|
||||
defaultQueryLang?: string;
|
||||
defaultDefinitionLang?: string;
|
||||
}
|
||||
|
||||
export function SearchForm({ defaultQueryLang = "english", defaultDefinitionLang = "chinese" }: SearchFormProps) {
|
||||
const t = useTranslations("dictionary");
|
||||
const [queryLang, setQueryLang] = useState(defaultQueryLang);
|
||||
const [definitionLang, setDefinitionLang] = useState(defaultDefinitionLang);
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const searchQuery = formData.get("searchQuery") as string;
|
||||
|
||||
if (!searchQuery?.trim()) return;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
q: searchQuery,
|
||||
ql: queryLang,
|
||||
dl: definitionLang,
|
||||
});
|
||||
|
||||
router.push(`/dictionary?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 页面标题 */}
|
||||
<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"
|
||||
defaultValue=""
|
||||
placeholder={t("searchPlaceholder")}
|
||||
variant="search"
|
||||
required
|
||||
/>
|
||||
<LightButton
|
||||
type="submit"
|
||||
className="px-6 py-3 whitespace-nowrap text-center sm:min-w-30"
|
||||
>
|
||||
{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">
|
||||
{/* 查询语言 */}
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm mb-2">
|
||||
{t("queryLanguage")} ({t("queryLanguageHint")})
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{POPULAR_LANGUAGES.map((lang) => (
|
||||
<LightButton
|
||||
key={lang.code}
|
||||
type="button"
|
||||
selected={queryLang === lang.code}
|
||||
onClick={() => setQueryLang(lang.code)}
|
||||
className="text-sm px-3 py-1"
|
||||
>
|
||||
{lang.nativeName}
|
||||
</LightButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 释义语言 */}
|
||||
<div>
|
||||
<label className="block text-gray-700 text-sm mb-2">
|
||||
{t("definitionLanguage")} ({t("definitionLanguageHint")})
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{POPULAR_LANGUAGES.map((lang) => (
|
||||
<LightButton
|
||||
key={lang.code}
|
||||
type="button"
|
||||
selected={definitionLang === lang.code}
|
||||
onClick={() => setDefinitionLang(lang.code)}
|
||||
className="text-sm px-3 py-1"
|
||||
>
|
||||
{lang.nativeName}
|
||||
</LightButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, RefreshCw } from "lucide-react";
|
||||
import { CircleButton, LightButton } from "@/design-system/base/button";
|
||||
import { toast } from "sonner";
|
||||
import { actionCreatePair } from "@/modules/folder/folder-aciton";
|
||||
import { TSharedItem } from "@/shared/dictionary-type";
|
||||
import { TSharedFolder } from "@/shared/folder-type";
|
||||
import { actionLookUpDictionary } from "@/modules/dictionary/dictionary-action";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Session = {
|
||||
user: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
image?: string | null;
|
||||
};
|
||||
} | null;
|
||||
|
||||
interface SaveButtonClientProps {
|
||||
session: Session;
|
||||
folders: TSharedFolder[];
|
||||
searchResult: TSharedItem;
|
||||
queryLang: string;
|
||||
definitionLang: string;
|
||||
}
|
||||
|
||||
export function SaveButtonClient({ session, folders, searchResult, queryLang, definitionLang }: SaveButtonClientProps) {
|
||||
const handleSave = async () => {
|
||||
if (!session) {
|
||||
toast.error("Please login first");
|
||||
return;
|
||||
}
|
||||
if (folders.length === 0) {
|
||||
toast.error("Please create a folder first");
|
||||
return;
|
||||
}
|
||||
|
||||
const folderSelect = document.getElementById("folder-select") as HTMLSelectElement;
|
||||
const folderId = folderSelect?.value ? Number(folderSelect.value) : folders[0]?.id;
|
||||
|
||||
const definition = searchResult.entries.reduce((p, e) => {
|
||||
return { ...p, definition: p.definition + ' | ' + e.definition };
|
||||
}).definition;
|
||||
|
||||
try {
|
||||
await actionCreatePair({
|
||||
text1: searchResult.standardForm,
|
||||
text2: definition,
|
||||
language1: queryLang,
|
||||
language2: definitionLang,
|
||||
ipa1: searchResult.entries[0].ipa,
|
||||
folderId: folderId,
|
||||
});
|
||||
|
||||
const folderName = folders.find((f) => f.id === folderId)?.name || "Unknown";
|
||||
toast.success(`Saved to ${folderName}`);
|
||||
} catch (error) {
|
||||
toast.error("Save failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CircleButton
|
||||
onClick={handleSave}
|
||||
className="w-10 h-10 shrink-0"
|
||||
title="Save to folder"
|
||||
>
|
||||
<Plus />
|
||||
</CircleButton>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReLookupButtonClientProps {
|
||||
searchQuery: string;
|
||||
queryLang: string;
|
||||
definitionLang: string;
|
||||
}
|
||||
|
||||
export function ReLookupButtonClient({ searchQuery, queryLang, definitionLang }: ReLookupButtonClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleRelookup = async () => {
|
||||
const getNativeName = (code: string): string => {
|
||||
const popularLanguages: Record<string, string> = {
|
||||
english: "English",
|
||||
chinese: "中文",
|
||||
japanese: "日本語",
|
||||
korean: "한국어",
|
||||
italian: "Italiano",
|
||||
uyghur: "ئۇيغۇرچە",
|
||||
};
|
||||
return popularLanguages[code] || code;
|
||||
};
|
||||
|
||||
try {
|
||||
await actionLookUpDictionary({
|
||||
text: searchQuery,
|
||||
queryLang: getNativeName(queryLang),
|
||||
definitionLang: getNativeName(definitionLang),
|
||||
forceRelook: true
|
||||
});
|
||||
|
||||
toast.success("Re-lookup successful");
|
||||
// 刷新页面以显示新结果
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast.error("Re-lookup failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LightButton
|
||||
onClick={handleRelookup}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm"
|
||||
leftIcon={<RefreshCw className="w-4 h-4" />}
|
||||
>
|
||||
Re-lookup
|
||||
</LightButton>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { auth } from "@/auth";
|
||||
import { DictionaryEntry } from "./DictionaryEntry";
|
||||
import { TSharedItem } from "@/shared/dictionary-type";
|
||||
import { SaveButtonClient, ReLookupButtonClient } from "./SearchResult.client";
|
||||
import { headers } from "next/headers";
|
||||
import { actionGetFoldersByUserId } from "@/modules/folder/folder-aciton";
|
||||
import { TSharedFolder } from "@/shared/folder-type";
|
||||
|
||||
interface SearchResultProps {
|
||||
searchResult: TSharedItem | null;
|
||||
searchQuery: string;
|
||||
queryLang: string;
|
||||
definitionLang: string;
|
||||
}
|
||||
|
||||
export async function SearchResult({
|
||||
searchResult,
|
||||
searchQuery,
|
||||
queryLang,
|
||||
definitionLang
|
||||
}: SearchResultProps) {
|
||||
// 获取用户会话和文件夹
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
let folders: TSharedFolder[] = [];
|
||||
|
||||
if (session?.user?.id) {
|
||||
const result = await actionGetFoldersByUserId(session.user.id as string);
|
||||
if (result.success && result.data) {
|
||||
folders = result.data;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{!searchResult ? (
|
||||
<div className="text-center py-12 bg-white/20 rounded-lg">
|
||||
<p className="text-gray-800 text-xl">No results found</p>
|
||||
<p className="text-gray-600 mt-2">Try other words</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg p-6 shadow-lg">
|
||||
{/* 标题和保存按钮 */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-2">
|
||||
{searchResult.standardForm}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{session && folders.length > 0 && (
|
||||
<select
|
||||
id="folder-select"
|
||||
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[#35786f]"
|
||||
>
|
||||
{folders.map((folder) => (
|
||||
<option key={folder.id} value={folder.id}>
|
||||
{folder.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<SaveButtonClient
|
||||
session={session}
|
||||
folders={folders}
|
||||
searchResult={searchResult}
|
||||
queryLang={queryLang}
|
||||
definitionLang={definitionLang}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 条目列表 */}
|
||||
<div className="space-y-6">
|
||||
{searchResult.entries.map((entry, index) => (
|
||||
<div key={index} className="border-t border-gray-200 pt-4">
|
||||
<DictionaryEntry entry={entry} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 重新查询按钮 */}
|
||||
<div className="border-t border-gray-200 pt-4 mt-4">
|
||||
<ReLookupButtonClient
|
||||
searchQuery={searchQuery}
|
||||
queryLang={queryLang}
|
||||
definitionLang={definitionLang}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +1,20 @@
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { SearchForm } from "./SearchForm";
|
||||
import { SearchResult } from "./SearchResult";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { actionLookUpDictionary } from "@/modules/dictionary/dictionary-action";
|
||||
import { TSharedItem } from "@/shared/dictionary-type";
|
||||
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";
|
||||
|
||||
interface DictionaryPageProps {
|
||||
searchParams: Promise<{ q?: string; ql?: string; dl?: string; }>;
|
||||
}
|
||||
|
||||
export default async function DictionaryPage({ searchParams }: DictionaryPageProps) {
|
||||
const t = await getTranslations("dictionary");
|
||||
|
||||
// 从 searchParams 获取搜索参数
|
||||
const { q: searchQuery, ql: queryLang = "english", dl: definitionLang = "chinese" } = await searchParams;
|
||||
|
||||
// 如果有搜索查询,获取搜索结果
|
||||
let searchResult: TSharedItem | undefined | null = null;
|
||||
if (searchQuery) {
|
||||
const getNativeName = (code: string): string => {
|
||||
const popularLanguages: Record<string, string> = {
|
||||
english: "English",
|
||||
chinese: "中文",
|
||||
japanese: "日本語",
|
||||
korean: "한국어",
|
||||
italian: "Italiano",
|
||||
uyghur: "ئۇيغۇرچە",
|
||||
};
|
||||
return popularLanguages[code] || code;
|
||||
};
|
||||
|
||||
const result = await actionLookUpDictionary({
|
||||
text: searchQuery,
|
||||
queryLang: getNativeName(queryLang),
|
||||
definitionLang: getNativeName(definitionLang),
|
||||
forceRelook: false
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
searchResult = result.data;
|
||||
}
|
||||
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 (
|
||||
<PageLayout>
|
||||
{/* 搜索区域 */}
|
||||
<div className="mb-8">
|
||||
<SearchForm
|
||||
defaultQueryLang={queryLang}
|
||||
defaultDefinitionLang={definitionLang}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 搜索结果区域 */}
|
||||
<div>
|
||||
{searchQuery && (
|
||||
<SearchResult
|
||||
searchResult={searchResult}
|
||||
searchQuery={searchQuery}
|
||||
queryLang={queryLang}
|
||||
definitionLang={definitionLang}
|
||||
/>
|
||||
)}
|
||||
{!searchQuery && (
|
||||
<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>
|
||||
);
|
||||
return <DictionaryClient initialDecks={decks} />;
|
||||
}
|
||||
|
||||
148
src/app/(features)/dictionary/stores/dictionaryStore.ts
Normal file
148
src/app/(features)/dictionary/stores/dictionaryStore.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
"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' }
|
||||
)
|
||||
);
|
||||
202
src/app/(features)/explore/ExploreClient.tsx
Normal file
202
src/app/(features)/explore/ExploreClient.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
152
src/app/(features)/explore/[id]/ExploreDetailClient.tsx
Normal file
152
src/app/(features)/explore/[id]/ExploreDetailClient.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
23
src/app/(features)/explore/[id]/page.tsx
Normal file
23
src/app/(features)/explore/[id]/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
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} />;
|
||||
}
|
||||
9
src/app/(features)/explore/page.tsx
Normal file
9
src/app/(features)/explore/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
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} />;
|
||||
}
|
||||
130
src/app/(features)/favorites/FavoritesClient.tsx
Normal file
130
src/app/(features)/favorites/FavoritesClient.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
22
src/app/(features)/favorites/page.tsx
Normal file
22
src/app/(features)/favorites/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { Folder as Fd } from "lucide-react";
|
||||
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PrimaryButton } from "@/design-system/base/button";
|
||||
|
||||
interface FolderSelectorProps {
|
||||
folders: TSharedFolderWithTotalPairs[];
|
||||
}
|
||||
|
||||
const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
|
||||
const t = useTranslations("memorize.folder_selector");
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{folders.length === 0 ? (
|
||||
// 空状态 - 显示提示和跳转按钮
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-4">
|
||||
{t("noFolders")}
|
||||
</h1>
|
||||
<Link href="/folders">
|
||||
<PrimaryButton className="px-6 py-2">
|
||||
Go to Folders
|
||||
</PrimaryButton>
|
||||
</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-lg 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="shrink-0">
|
||||
<Fd className="text-gray-600" size="md" />
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export { FolderSelector };
|
||||
@@ -1,195 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { LinkButton, CircleToggleButton, LightButton } from "@/design-system/base/button";
|
||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
||||
import { useTranslations } from "next-intl";
|
||||
import localFont from "next/font/local";
|
||||
import { isNonNegativeInteger, SeededRandom } from "@/utils/random";
|
||||
import { TSharedPair } from "@/shared/folder-type";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
|
||||
const myFont = localFont({
|
||||
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
|
||||
});
|
||||
|
||||
interface MemorizeProps {
|
||||
textPairs: TSharedPair[];
|
||||
}
|
||||
|
||||
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 (
|
||||
<PageLayout>
|
||||
<p className="text-gray-700 text-center">{t("noTextPairs")}</p>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
const textPair = getTextPairs()[newIndex];
|
||||
const language = textPair[reverse ? "language2" : "language1"];
|
||||
const text = textPair[reverse ? "text2" : "text1"];
|
||||
|
||||
// 映射语言到 TTS 支持的格式
|
||||
const languageMap: Record<string, TTS_SUPPORTED_LANGUAGES> = {
|
||||
"chinese": "Chinese",
|
||||
"english": "English",
|
||||
"japanese": "Japanese",
|
||||
"korean": "Korean",
|
||||
"french": "French",
|
||||
"german": "German",
|
||||
"italian": "Italian",
|
||||
"portuguese": "Portuguese",
|
||||
"spanish": "Spanish",
|
||||
"russian": "Russian",
|
||||
};
|
||||
|
||||
const ttsLanguage = languageMap[language?.toLowerCase()] || "Auto";
|
||||
|
||||
getTTSUrl(text, ttsLanguage).then((url) => {
|
||||
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 (
|
||||
<PageLayout>
|
||||
{/* 进度指示器 */}
|
||||
<div className="flex justify-center mb-4">
|
||||
<LinkButton onClick={handleIndexClick} className="text-sm">
|
||||
{index + 1} / {getTextPairs().length}
|
||||
</LinkButton>
|
||||
</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">
|
||||
<LightButton
|
||||
onClick={handleNext}
|
||||
className="px-4 py-2 rounded-full text-sm"
|
||||
>
|
||||
{show === "question" ? t("answer") : t("next")}
|
||||
</LightButton>
|
||||
<LightButton
|
||||
onClick={handlePrevious}
|
||||
className="px-4 py-2 rounded-full text-sm"
|
||||
>
|
||||
{t("previous")}
|
||||
</LightButton>
|
||||
<CircleToggleButton
|
||||
selected={reverse}
|
||||
onClick={toggleReverse}
|
||||
>
|
||||
{t("reverse")}
|
||||
</CircleToggleButton>
|
||||
<CircleToggleButton
|
||||
selected={dictation}
|
||||
onClick={toggleDictation}
|
||||
>
|
||||
{t("dictation")}
|
||||
</CircleToggleButton>
|
||||
<CircleToggleButton
|
||||
selected={disorder}
|
||||
onClick={toggleDisorder}
|
||||
>
|
||||
{t("disorder")}
|
||||
</CircleToggleButton>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export { Memorize };
|
||||
@@ -1,37 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { isNonNegativeInteger } from "@/utils/random";
|
||||
import { FolderSelector } from "./FolderSelector";
|
||||
import { Memorize } from "./Memorize";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { actionGetFoldersWithTotalPairsByUserId, actionGetPairsByFolderId } from "@/modules/folder/folder-aciton";
|
||||
|
||||
export default async function MemorizePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ folder_id?: string; }>;
|
||||
}) {
|
||||
const tParam = (await searchParams).folder_id;
|
||||
|
||||
const t = await getTranslations("memorize.page");
|
||||
|
||||
const folder_id = tParam
|
||||
? isNonNegativeInteger(tParam)
|
||||
? parseInt(tParam)
|
||||
: null
|
||||
: null;
|
||||
|
||||
if (!folder_id) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) redirect("/auth?redirect=/memorize");
|
||||
|
||||
return (
|
||||
<FolderSelector
|
||||
folders={(await actionGetFoldersWithTotalPairsByUserId(session.user.id)).data!}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Memorize textPairs={(await actionGetPairsByFolderId(folder_id)).data!} />;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
import { useState, useRef, forwardRef, useEffect, useCallback } from "react";
|
||||
import { SubtitleDisplay } from "./SubtitleDisplay";
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
import { RangeInput } from "@/components/ui/RangeInput";
|
||||
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>
|
||||
<RangeInput
|
||||
className="seekbar"
|
||||
min={0}
|
||||
max={srtLength}
|
||||
onChange={(value) => {
|
||||
if (videoRef.current && parsedSrtRef.current) {
|
||||
videoRef.current.currentTime = parsedSrtRef.current[value]?.start || 0;
|
||||
setProgress(value);
|
||||
}
|
||||
}}
|
||||
value={progress}
|
||||
/>
|
||||
<span>{spanText}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
VideoPanel.displayName = "VideoPanel";
|
||||
|
||||
export { VideoPanel };
|
||||
310
src/app/(features)/srt-player/components/ControlPanel.tsx
Normal file
310
src/app/(features)/srt-player/components/ControlPanel.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"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';
|
||||
@@ -1,47 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef } from "react";
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
import { FileInputProps } from "../../types/controls";
|
||||
|
||||
interface FileInputComponentProps extends FileInputProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export 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"
|
||||
/>
|
||||
<LightButton
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</LightButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
import { PlayButtonProps } from "../../types/player";
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { SeekBarProps } from "../../types/player";
|
||||
import { RangeInput } from "@/components/ui/RangeInput";
|
||||
|
||||
export function SeekBar({ value, max, onChange, disabled, className }: SeekBarProps) {
|
||||
return (
|
||||
<RangeInput
|
||||
value={value}
|
||||
max={max}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
import { SpeedControlProps } from "../../types/player";
|
||||
import { getPlaybackRateOptions, getPlaybackRateLabel } from "../../utils/timeUtils";
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { SubtitleTextProps } from "../../types/subtitle";
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
"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 { VideoElement };
|
||||
@@ -1,77 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronLeft, ChevronRight, RotateCcw, Pause } from "lucide-react";
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
import { ControlBarProps } from "../../types/controls";
|
||||
import { PlayButton } from "../atoms/PlayButton";
|
||||
import { SpeedControl } from "../atoms/SpeedControl";
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { SubtitleDisplayProps } from "../../types/subtitle";
|
||||
import { SubtitleText } from "../atoms/SubtitleText";
|
||||
|
||||
export 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { Video, FileText } from "lucide-react";
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
import { FileUploadProps } from "../../types/controls";
|
||||
import { useFileUpload } from "../../hooks/useFileUpload";
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
"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 { VideoPlayer };
|
||||
@@ -9,10 +9,9 @@ export function useFileUpload() {
|
||||
onError?: (error: Error) => void
|
||||
) => {
|
||||
try {
|
||||
// 验证文件大小(限制为100MB)
|
||||
const maxSize = 100 * 1024 * 1024; // 100MB
|
||||
const maxSize = 1000 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
throw new Error(`文件大小超过限制 (${(file.size / 1024 / 1024).toFixed(2)}MB > 100MB)`);
|
||||
throw new Error(`文件大小超过限制 (${(file.size / 1024 / 1024).toFixed(2)}MB > 1000MB)`);
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
@@ -34,7 +33,6 @@ export function useFileUpload() {
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
// 验证文件类型
|
||||
if (!file.type.startsWith('video/')) {
|
||||
onError?.(new Error('请选择有效的视频文件'));
|
||||
return;
|
||||
@@ -61,7 +59,6 @@ export function useFileUpload() {
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
// 验证文件扩展名
|
||||
if (!file.name.toLowerCase().endsWith('.srt')) {
|
||||
onError?.(new Error('请选择.srt格式的字幕文件'));
|
||||
return;
|
||||
@@ -80,6 +77,5 @@ export function useFileUpload() {
|
||||
return {
|
||||
uploadVideo,
|
||||
uploadSubtitle,
|
||||
uploadFile,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,68 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { KeyboardShortcut } from "../types/controls";
|
||||
import { useEffect } from "react";
|
||||
import { useSrtPlayerStore } from "../stores/srtPlayerStore";
|
||||
|
||||
export function useKeyboardShortcuts(
|
||||
shortcuts: KeyboardShortcut[],
|
||||
enabled: boolean = true
|
||||
) {
|
||||
const handleKeyDown = useCallback((event: globalThis.KeyboardEvent) => {
|
||||
if (!enabled) return;
|
||||
|
||||
// 防止在输入框中触发快捷键
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
|
||||
const shortcut = shortcuts.find(s => s.key === event.key);
|
||||
if (shortcut) {
|
||||
event.preventDefault();
|
||||
shortcut.action();
|
||||
}
|
||||
}, [shortcuts, enabled]);
|
||||
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);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
}, [enabled, togglePlayPause, nextSubtitle, previousSubtitle, restartSubtitle, toggleAutoPause]);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
}
|
||||
export function useKeyboardShortcuts(
|
||||
shortcuts: Array<{ key: string; action: () => void }>,
|
||||
isEnabled: boolean = true
|
||||
) {
|
||||
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);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [shortcuts, isEnabled]);
|
||||
}
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
"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,110 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { SubtitleEntry } from "../types/subtitle";
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useSrtPlayerStore } from "../stores/srtPlayerStore";
|
||||
|
||||
export function useSubtitleSync(
|
||||
subtitles: SubtitleEntry[],
|
||||
currentTime: number,
|
||||
isPlaying: boolean,
|
||||
autoPause: boolean,
|
||||
onSubtitleChange: (subtitle: SubtitleEntry | null) => void,
|
||||
onAutoPauseTrigger?: (subtitle: SubtitleEntry) => void
|
||||
) {
|
||||
const lastSubtitleRef = useRef<SubtitleEntry | null>(null);
|
||||
const rafIdRef = useRef<number>(0);
|
||||
export function useSubtitleSync() {
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastIndexRef = useRef<number | null>(null);
|
||||
|
||||
// 获取当前时间对应的字幕
|
||||
const getCurrentSubtitle = useCallback((time: number): SubtitleEntry | null => {
|
||||
return subtitles.find(subtitle => time >= subtitle.start && time <= subtitle.end) || null;
|
||||
}, [subtitles]);
|
||||
const subtitleData = useSrtPlayerStore((state) => state.subtitle.data);
|
||||
const isPlaying = useSrtPlayerStore((state) => state.video.isPlaying);
|
||||
const autoPause = useSrtPlayerStore((state) => state.controls.autoPause);
|
||||
const playbackRate = useSrtPlayerStore((state) => state.video.playbackRate);
|
||||
const currentTime = useSrtPlayerStore((state) => state.video.currentTime);
|
||||
|
||||
// 获取最近的字幕索引
|
||||
const getNearestIndex = useCallback((time: number): number | null => {
|
||||
if (subtitles.length === 0) return null;
|
||||
|
||||
// 如果时间早于第一个字幕开始时间
|
||||
if (time < subtitles[0].start) return null;
|
||||
|
||||
// 如果时间晚于最后一个字幕结束时间
|
||||
if (time > subtitles[subtitles.length - 1].end) return subtitles.length - 1;
|
||||
|
||||
// 二分查找找到当前时间对应的字幕
|
||||
let left = 0;
|
||||
let right = subtitles.length - 1;
|
||||
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
const subtitle = subtitles[mid];
|
||||
|
||||
if (time >= subtitle.start && time <= subtitle.end) {
|
||||
return mid;
|
||||
} else if (time < subtitle.start) {
|
||||
right = mid - 1;
|
||||
const setCurrentSubtitle = useSrtPlayerStore((state) => state.setCurrentSubtitle);
|
||||
const pause = useSrtPlayerStore((state) => state.pause);
|
||||
|
||||
const scheduleAutoPause = useCallback(() => {
|
||||
if (!autoPause || !isPlaying) {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTimeNow = useSrtPlayerStore.getState().video.currentTime;
|
||||
const currentIndexNow = useSrtPlayerStore.getState().subtitle.currentIndex;
|
||||
|
||||
if (currentIndexNow === null || !subtitleData[currentIndexNow]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subtitle = subtitleData[currentIndexNow];
|
||||
const timeUntilEnd = subtitle.end - currentTimeNow;
|
||||
|
||||
if (timeUntilEnd <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const advanceTime = 0.15;
|
||||
const realTimeUntilPause = (timeUntilEnd - advanceTime) / playbackRate;
|
||||
|
||||
if (realTimeUntilPause > 0) {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
pause();
|
||||
}, realTimeUntilPause * 1000);
|
||||
}
|
||||
}, [autoPause, isPlaying, subtitleData, playbackRate, pause]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!subtitleData || subtitleData.length === 0) {
|
||||
setCurrentSubtitle('', null);
|
||||
lastIndexRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let newIndex: number | null = null;
|
||||
|
||||
for (let i = 0; i < subtitleData.length; i++) {
|
||||
const subtitle = subtitleData[i];
|
||||
if (currentTime >= subtitle.start && currentTime <= subtitle.end) {
|
||||
newIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (newIndex !== lastIndexRef.current) {
|
||||
lastIndexRef.current = newIndex;
|
||||
if (newIndex !== null) {
|
||||
setCurrentSubtitle(subtitleData[newIndex].text, newIndex);
|
||||
} else {
|
||||
left = mid + 1;
|
||||
setCurrentSubtitle('', null);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到完全匹配的字幕,返回最近的字幕索引
|
||||
return right >= 0 ? right : null;
|
||||
}, [subtitles]);
|
||||
}, [subtitleData, currentTime, setCurrentSubtitle]);
|
||||
|
||||
// 检查是否需要自动暂停
|
||||
const shouldAutoPause = useCallback((subtitle: SubtitleEntry, time: number): boolean => {
|
||||
return autoPause &&
|
||||
time >= subtitle.end - 0.2 && // 增加时间窗口,确保自动暂停更可靠
|
||||
time < subtitle.end;
|
||||
}, [autoPause]);
|
||||
|
||||
// 启动/停止同步循环
|
||||
useEffect(() => {
|
||||
const syncSubtitles = () => {
|
||||
const currentSubtitle = getCurrentSubtitle(currentTime);
|
||||
|
||||
// 检查字幕是否发生变化
|
||||
if (currentSubtitle !== lastSubtitleRef.current) {
|
||||
const previousSubtitle = lastSubtitleRef.current;
|
||||
lastSubtitleRef.current = currentSubtitle;
|
||||
|
||||
// 只有当有当前字幕时才调用onSubtitleChange
|
||||
// 在字幕间隙时保持之前的字幕索引,避免进度条跳到0
|
||||
if (currentSubtitle) {
|
||||
onSubtitleChange(currentSubtitle);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否需要自动暂停
|
||||
// 每次都检查,不只在字幕变化时检查
|
||||
if (currentSubtitle && shouldAutoPause(currentSubtitle, currentTime)) {
|
||||
onAutoPauseTrigger?.(currentSubtitle);
|
||||
} else if (!currentSubtitle && lastSubtitleRef.current && shouldAutoPause(lastSubtitleRef.current, currentTime)) {
|
||||
// 在字幕结束时,如果前一个字幕需要自动暂停,也要触发
|
||||
onAutoPauseTrigger?.(lastSubtitleRef.current);
|
||||
}
|
||||
|
||||
rafIdRef.current = requestAnimationFrame(syncSubtitles);
|
||||
};
|
||||
scheduleAutoPause();
|
||||
}, [isPlaying, autoPause]);
|
||||
|
||||
if (subtitles.length > 0) {
|
||||
rafIdRef.current = requestAnimationFrame(syncSubtitles);
|
||||
useEffect(() => {
|
||||
if (isPlaying && autoPause) {
|
||||
scheduleAutoPause();
|
||||
}
|
||||
|
||||
}, [playbackRate, currentTime]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (rafIdRef.current) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [subtitles.length, currentTime, getCurrentSubtitle, onSubtitleChange, shouldAutoPause, onAutoPauseTrigger]);
|
||||
|
||||
// 重置最后字幕引用
|
||||
useEffect(() => {
|
||||
lastSubtitleRef.current = null;
|
||||
}, [subtitles]);
|
||||
|
||||
return {
|
||||
getCurrentSubtitle,
|
||||
getNearestIndex,
|
||||
shouldAutoPause,
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
}
|
||||
|
||||
44
src/app/(features)/srt-player/hooks/useVideoSync.ts
Normal file
44
src/app/(features)/srt-player/hooks/useVideoSync.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
"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,114 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useRef, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { Video, FileText } from "lucide-react";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { useSrtPlayer } from "./hooks/useSrtPlayer";
|
||||
import { useSubtitleSync } from "./hooks/useSubtitleSync";
|
||||
import { useKeyboardShortcuts, createSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||
import { useFileUpload } from "./hooks/useFileUpload";
|
||||
import { loadSubtitle } from "./utils/subtitleParser";
|
||||
import { VideoPlayer } from "./components/compounds/VideoPlayer";
|
||||
import { SubtitleArea } from "./components/compounds/SubtitleArea";
|
||||
import { ControlBar } from "./components/compounds/ControlBar";
|
||||
import { UploadZone } from "./components/compounds/UploadZone";
|
||||
import { SeekBar } from "./components/atoms/SeekBar";
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
import { HStack } from "@/design-system/layout/stack";
|
||||
import { Video, FileText, ChevronLeft, ChevronRight, RotateCcw, Pause, Play } from "lucide-react";
|
||||
import { useVideoSync } from "./hooks/useVideoSync";
|
||||
import { useSubtitleSync } from "./hooks/useSubtitleSync";
|
||||
import { useSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||
import { loadSubtitle } from "./utils/subtitleParser";
|
||||
import { useSrtPlayerStore } from "./stores/srtPlayerStore";
|
||||
import { useFileUpload } from "./hooks/useFileUpload";
|
||||
import { setVideoRef } from "./stores/srtPlayerStore";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function SrtPlayerPage() {
|
||||
const t = useTranslations("home");
|
||||
const srtT = useTranslations("srt_player");
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const { uploadVideo, uploadSubtitle } = useFileUpload();
|
||||
const {
|
||||
state,
|
||||
actions,
|
||||
videoRef,
|
||||
videoEventHandlers,
|
||||
subtitleActions
|
||||
} = useSrtPlayer();
|
||||
|
||||
// 字幕同步
|
||||
useSubtitleSync(
|
||||
state.subtitle.data,
|
||||
state.video.currentTime,
|
||||
state.video.isPlaying,
|
||||
state.controls.autoPause,
|
||||
(subtitle) => {
|
||||
if (subtitle) {
|
||||
subtitleActions.setCurrentSubtitle(subtitle.text, subtitle.index);
|
||||
} else {
|
||||
subtitleActions.setCurrentSubtitle("", null);
|
||||
}
|
||||
},
|
||||
(subtitle) => {
|
||||
// 自动暂停逻辑
|
||||
actions.seek(subtitle.start);
|
||||
actions.pause();
|
||||
}
|
||||
);
|
||||
const subtitleUrl = useSrtPlayerStore((state) => state.subtitle.url);
|
||||
const setSubtitleData = useSrtPlayerStore((state) => state.setSubtitleData);
|
||||
const setSubtitleUrl = useSrtPlayerStore((state) => state.setSubtitleUrl);
|
||||
const setVideoUrl = useSrtPlayerStore((state) => state.setVideoUrl);
|
||||
|
||||
// 键盘快捷键
|
||||
const shortcuts = React.useMemo(() =>
|
||||
createSrtPlayerShortcuts(
|
||||
actions.togglePlayPause,
|
||||
actions.nextSubtitle,
|
||||
actions.previousSubtitle,
|
||||
actions.restartSubtitle,
|
||||
actions.toggleAutoPause
|
||||
), [
|
||||
actions.togglePlayPause,
|
||||
actions.nextSubtitle,
|
||||
actions.previousSubtitle,
|
||||
actions.restartSubtitle,
|
||||
actions.toggleAutoPause
|
||||
]
|
||||
);
|
||||
const videoUrl = useSrtPlayerStore((state) => state.video.url);
|
||||
const subtitleData = useSrtPlayerStore((state) => state.subtitle.data);
|
||||
const currentIndex = useSrtPlayerStore((state) => state.subtitle.currentIndex);
|
||||
const isPlaying = useSrtPlayerStore((state) => state.video.isPlaying);
|
||||
const playbackRate = useSrtPlayerStore((state) => state.video.playbackRate);
|
||||
const autoPause = useSrtPlayerStore((state) => state.controls.autoPause);
|
||||
|
||||
useKeyboardShortcuts(shortcuts);
|
||||
const togglePlayPause = useSrtPlayerStore((state) => state.togglePlayPause);
|
||||
const nextSubtitle = useSrtPlayerStore((state) => state.nextSubtitle);
|
||||
const previousSubtitle = useSrtPlayerStore((state) => state.previousSubtitle);
|
||||
const restartSubtitle = useSrtPlayerStore((state) => state.restartSubtitle);
|
||||
const setPlaybackRate = useSrtPlayerStore((state) => state.setPlaybackRate);
|
||||
const toggleAutoPause = useSrtPlayerStore((state) => state.toggleAutoPause);
|
||||
const seek = useSrtPlayerStore((state) => state.seek);
|
||||
|
||||
// 处理字幕文件加载
|
||||
React.useEffect(() => {
|
||||
if (state.subtitle.url) {
|
||||
loadSubtitle(state.subtitle.url)
|
||||
.then(subtitleData => {
|
||||
subtitleActions.setSubtitleData(subtitleData);
|
||||
useVideoSync(videoRef);
|
||||
useSubtitleSync();
|
||||
useSrtPlayerShortcuts();
|
||||
|
||||
useEffect(() => {
|
||||
setVideoRef(videoRef);
|
||||
}, [videoRef]);
|
||||
|
||||
const canPlay = !!videoUrl && !!subtitleUrl && subtitleData.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (subtitleUrl) {
|
||||
loadSubtitle(subtitleUrl)
|
||||
.then((subtitleData) => {
|
||||
setSubtitleData(subtitleData);
|
||||
toast.success(srtT("subtitleLoadSuccess"));
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
toast.error(srtT("subtitleLoadFailed") + ": " + error.message);
|
||||
});
|
||||
}
|
||||
}, [srtT, state.subtitle.url, subtitleActions]);
|
||||
}, [srtT, subtitleUrl, setSubtitleData]);
|
||||
|
||||
// 处理进度条变化
|
||||
const handleSeek = React.useCallback((index: number) => {
|
||||
if (state.subtitle.data[index]) {
|
||||
actions.seek(state.subtitle.data[index].start);
|
||||
}
|
||||
}, [state.subtitle.data, actions]);
|
||||
|
||||
// 处理视频上传
|
||||
const handleVideoUpload = React.useCallback(() => {
|
||||
uploadVideo(actions.setVideoUrl, (error) => {
|
||||
toast.error(srtT("videoUploadFailed") + ": " + error.message);
|
||||
const handleVideoUpload = () => {
|
||||
uploadVideo((url) => {
|
||||
setVideoUrl(url);
|
||||
}, (error) => {
|
||||
toast.error(srtT('videoUploadFailed') + ': ' + error.message);
|
||||
});
|
||||
}, [uploadVideo, actions.setVideoUrl, srtT]);
|
||||
};
|
||||
|
||||
// 处理字幕上传
|
||||
const handleSubtitleUpload = React.useCallback(() => {
|
||||
uploadSubtitle(actions.setSubtitleUrl, (error) => {
|
||||
toast.error(srtT("subtitleUploadFailed") + ": " + error.message);
|
||||
const handleSubtitleUpload = () => {
|
||||
uploadSubtitle((url) => {
|
||||
setSubtitleUrl(url);
|
||||
}, (error) => {
|
||||
toast.error(srtT('subtitleUploadFailed') + ': ' + error.message);
|
||||
});
|
||||
}, [uploadSubtitle, actions.setSubtitleUrl, srtT]);
|
||||
};
|
||||
|
||||
// 检查是否可以播放
|
||||
const canPlay = state.video.url && state.subtitle.url && state.subtitle.data.length > 0;
|
||||
const handlePlaybackRateChange = () => {
|
||||
const rates = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
|
||||
const currentIndexRate = rates.indexOf(playbackRate);
|
||||
const nextIndexRate = (currentIndexRate + 1) % rates.length;
|
||||
setPlaybackRate(rates[nextIndexRate]);
|
||||
};
|
||||
|
||||
const currentSubtitle = currentIndex !== null ? subtitleData[currentIndex] : null;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 标题区域 */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-800 mb-2">
|
||||
{t("srtPlayer.name")}
|
||||
@@ -118,157 +102,78 @@ export default function SrtPlayerPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 视频播放器区域 */}
|
||||
<div className="aspect-video bg-black relative rounded-md overflow-hidden">
|
||||
{(!state.video.url || !state.subtitle.url || state.subtitle.data.length === 0) && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-75 z-10">
|
||||
<div className="text-center text-white">
|
||||
<p className="text-lg mb-2">
|
||||
{!state.video.url && !state.subtitle.url
|
||||
? srtT("uploadVideoAndSubtitle")
|
||||
: !state.video.url
|
||||
? srtT("uploadVideoFile")
|
||||
: !state.subtitle.url
|
||||
? srtT("uploadSubtitleFile")
|
||||
: srtT("processingSubtitle")
|
||||
}
|
||||
</p>
|
||||
{(!state.video.url || !state.subtitle.url) && (
|
||||
<p className="text-sm text-gray-300">
|
||||
{srtT("needBothFiles")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<video
|
||||
ref={videoRef}
|
||||
width="85%"
|
||||
className="mx-auto"
|
||||
playsInline
|
||||
/>
|
||||
|
||||
{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="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="p-3 bg-gray-50 border-t rounded-b-xl">
|
||||
{/* 上传区域和状态指示器 */}
|
||||
<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 className="mx-auto mt-4 flex items-center justify-center flex-wrap gap-2 w-[85%]">
|
||||
<div className="border-gray-200 border-2 flex items p-2 justify-between items-center rounded gap-8">
|
||||
<div className="flex items-center flex-col">
|
||||
<Video size={16} />
|
||||
<span className="text-sm">{srtT("videoFile")}</span>
|
||||
</div>
|
||||
<LightButton onClick={handleVideoUpload} disabled={!!videoUrl}>
|
||||
{videoUrl ? srtT("uploaded") : srtT("uploadVideoButton")}
|
||||
</LightButton>
|
||||
</div>
|
||||
<div className="border-gray-200 border-2 flex items p-2 justify-between items-center rounded gap-8">
|
||||
<div className="flex items-center flex-col">
|
||||
<FileText size={16} />
|
||||
<span className="text-sm">
|
||||
{subtitleData.length > 0 ? srtT("subtitleUploaded", { count: subtitleData.length }) : srtT("subtitleNotUploaded")}
|
||||
</span>
|
||||
</div>
|
||||
<LightButton onClick={handleSubtitleUpload} disabled={!!subtitleUrl}>
|
||||
{subtitleUrl ? srtT("uploaded") : srtT("uploadSubtitleButton")}
|
||||
</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canPlay && (
|
||||
<HStack gap={2} className="mx-auto mt-4 w-[85%]" justify={"center"} wrap>
|
||||
{isPlaying ? (
|
||||
<LightButton onClick={togglePlayPause} leftIcon={<Pause className="w-4 h-4" />}>
|
||||
{srtT('pause')}
|
||||
</LightButton>
|
||||
) : (
|
||||
<LightButton onClick={togglePlayPause} leftIcon={<Play className="w-4 h-4" />}>
|
||||
{srtT('play')}
|
||||
</LightButton>
|
||||
)}
|
||||
<LightButton onClick={previousSubtitle} leftIcon={<ChevronLeft className="w-4 h-4" />}>
|
||||
{srtT('previous')}
|
||||
</LightButton>
|
||||
<LightButton onClick={nextSubtitle} rightIcon={<ChevronRight className="w-4 h-4" />}>
|
||||
{srtT('next')}
|
||||
</LightButton>
|
||||
<LightButton onClick={restartSubtitle} leftIcon={<RotateCcw className="w-4 h-4" />}>
|
||||
{srtT('restart')}
|
||||
</LightButton>
|
||||
<LightButton onClick={handlePlaybackRateChange}>
|
||||
{playbackRate}x
|
||||
</LightButton>
|
||||
<LightButton onClick={toggleAutoPause}>
|
||||
{srtT('autoPause', { enabled: autoPause ? srtT('on') : srtT('off') })}
|
||||
</LightButton>
|
||||
</HStack>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
217
src/app/(features)/srt-player/stores/srtPlayerStore.ts
Normal file
217
src/app/(features)/srt-player/stores/srtPlayerStore.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
"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' }
|
||||
)
|
||||
);
|
||||
@@ -1,74 +0,0 @@
|
||||
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),
|
||||
);
|
||||
}
|
||||
132
src/app/(features)/srt-player/types.ts
Normal file
132
src/app/(features)/srt-player/types.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
// ==================== 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,
|
||||
}),
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
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/subtitle";
|
||||
import { SubtitleEntry } from "../types";
|
||||
|
||||
export function parseSrt(data: string): SubtitleEntry[] {
|
||||
const lines = data.split(/\r?\n/);
|
||||
@@ -62,13 +62,12 @@ export function getNearestIndex(
|
||||
): number | null {
|
||||
for (let i = 0; i < subtitles.length; i++) {
|
||||
const subtitle = subtitles[i];
|
||||
const isBefore = currentTime - subtitle.start >= 0;
|
||||
const isAfter = currentTime - subtitle.end >= 0;
|
||||
const isWithin = currentTime >= subtitle.start && currentTime <= subtitle.end;
|
||||
|
||||
if (!isBefore || !isAfter) return i - 1;
|
||||
if (isBefore && !isAfter) return i;
|
||||
if (isWithin) return i;
|
||||
if (currentTime < subtitle.start) return i > 0 ? i - 1 : null;
|
||||
}
|
||||
return null;
|
||||
return subtitles.length > 0 ? subtitles.length - 1 : null;
|
||||
}
|
||||
|
||||
export function getCurrentSubtitle(
|
||||
@@ -96,4 +95,4 @@ export async function loadSubtitle(url: string): Promise<SubtitleEntry[]> {
|
||||
console.error('加载字幕失败', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
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`;
|
||||
}
|
||||
@@ -60,11 +60,12 @@ export function SaveList({ show = false, handleUse }: SaveListProps) {
|
||||
const [data, setData] = useState(getFromLocalStorage());
|
||||
const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => {
|
||||
const current_data = getFromLocalStorage();
|
||||
if (!current_data) return;
|
||||
|
||||
current_data.splice(
|
||||
current_data.findIndex((v) => v.text === item.text),
|
||||
1,
|
||||
);
|
||||
const index = current_data.findIndex((v) => v.text === item.text);
|
||||
if (index === -1) return;
|
||||
|
||||
current_data.splice(index, 1);
|
||||
setIntoLocalStorage(current_data);
|
||||
refresh();
|
||||
};
|
||||
@@ -78,33 +79,25 @@ export function SaveList({ show = false, handleUse }: SaveListProps) {
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
if (show)
|
||||
if (show && data)
|
||||
return (
|
||||
<div
|
||||
className="my-4 p-2 mx-4 md:mx-32 border border-gray-200 rounded-lg"
|
||||
style={{ fontFamily: "Times New Roman, serif" }}
|
||||
>
|
||||
<div className="flex flex-row justify-center gap-8 items-center">
|
||||
<IconClick
|
||||
src={IMAGES.refresh}
|
||||
alt="refresh"
|
||||
onClick={refresh}
|
||||
size="lg"
|
||||
className=""
|
||||
></IconClick>
|
||||
<IconClick
|
||||
src={IMAGES.delete}
|
||||
alt="delete"
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<p className="text-sm text-gray-600">{t("saved")}</p>
|
||||
<button
|
||||
onClick={handleDeleteAll}
|
||||
size="lg"
|
||||
className=""
|
||||
></IconClick>
|
||||
className="text-xs text-gray-500 hover:text-gray-800"
|
||||
>
|
||||
{t("clearAll")}
|
||||
</button>
|
||||
</div>
|
||||
<ul>
|
||||
{data.map((v) => (
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{data.map((item, i) => (
|
||||
<TextCard
|
||||
item={v}
|
||||
key={crypto.randomUUID()}
|
||||
key={i}
|
||||
item={item}
|
||||
handleUse={handleUse}
|
||||
handleDel={handleDel}
|
||||
></TextCard>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
import { IconClick } from "@/design-system/base/button";
|
||||
import { LightButton, IconClick } from "@/design-system/base/button";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { Textarea } from "@/design-system/base/textarea";
|
||||
import { IMAGES } from "@/config/images";
|
||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||
import {
|
||||
@@ -18,6 +19,38 @@ import { genIPA, genLanguage } from "@/modules/translator/translator-action";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
||||
|
||||
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() {
|
||||
const t = useTranslations("text_speaker");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -30,6 +63,8 @@ export default function TextSpeakerPage() {
|
||||
const [autopause, setAutopause] = useState(true);
|
||||
const textRef = useRef("");
|
||||
const [language, setLanguage] = useState<string | null>(null);
|
||||
const [selectedLanguage, setSelectedLanguage] = useState<string>("Auto");
|
||||
const [customLanguage, setCustomLanguage] = useState<string>("");
|
||||
const [ipa, setIPA] = useState<string>("");
|
||||
const objurlRef = useRef<string | null>(null);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
@@ -48,8 +83,8 @@ export default function TextSpeakerPage() {
|
||||
const handleEnded = () => {
|
||||
if (autopause) {
|
||||
setPause(true);
|
||||
} else {
|
||||
load(objurlRef.current!);
|
||||
} else if (objurlRef.current) {
|
||||
load(objurlRef.current);
|
||||
play();
|
||||
}
|
||||
};
|
||||
@@ -93,8 +128,15 @@ export default function TextSpeakerPage() {
|
||||
} else {
|
||||
// 第一次播放
|
||||
try {
|
||||
let theLanguage = language;
|
||||
if (!theLanguage) {
|
||||
let theLanguage: string;
|
||||
|
||||
if (customLanguage.trim()) {
|
||||
theLanguage = customLanguage.trim();
|
||||
} else if (selectedLanguage !== "Auto") {
|
||||
theLanguage = selectedLanguage;
|
||||
} else if (language) {
|
||||
theLanguage = language;
|
||||
} else {
|
||||
const tmp_language = await genLanguage(textRef.current.slice(0, 30));
|
||||
setLanguage(tmp_language);
|
||||
theLanguage = tmp_language;
|
||||
@@ -102,7 +144,6 @@ export default function TextSpeakerPage() {
|
||||
|
||||
theLanguage = theLanguage.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
|
||||
|
||||
// 检查语言是否在 TTS 支持列表中
|
||||
const supportedLanguages: TTS_SUPPORTED_LANGUAGES[] = [
|
||||
"Auto", "Chinese", "English", "German", "Italian", "Portuguese",
|
||||
"Spanish", "Japanese", "Korean", "French", "Russian"
|
||||
@@ -138,6 +179,8 @@ export default function TextSpeakerPage() {
|
||||
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
textRef.current = e.target.value.trim();
|
||||
setLanguage(null);
|
||||
setSelectedLanguage("Auto");
|
||||
setCustomLanguage("");
|
||||
setIPA("");
|
||||
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
||||
objurlRef.current = null;
|
||||
@@ -187,7 +230,7 @@ export default function TextSpeakerPage() {
|
||||
theIPA = tmp_ipa;
|
||||
}
|
||||
|
||||
const save = getFromLocalStorage();
|
||||
const save = getFromLocalStorage() ?? [];
|
||||
const oldIndex = save.findIndex((v) => v.text === textRef.current);
|
||||
if (oldIndex !== -1) {
|
||||
const oldItem = save[oldIndex];
|
||||
@@ -226,11 +269,12 @@ export default function TextSpeakerPage() {
|
||||
style={{ fontFamily: "Times New Roman, serif" }}
|
||||
>
|
||||
{/* 文本输入框 */}
|
||||
<textarea
|
||||
className="text-2xl resize-none focus:outline-0 min-h-64 w-full border-gray-200 border-b p-4"
|
||||
<Textarea
|
||||
variant="bordered"
|
||||
className="text-2xl min-h-64"
|
||||
onChange={handleInputChange}
|
||||
ref={textareaRef}
|
||||
></textarea>
|
||||
/>
|
||||
{/* IPA 显示区域 */}
|
||||
{(ipa.length !== 0 && (
|
||||
<div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b px-4">
|
||||
@@ -293,7 +337,7 @@ export default function TextSpeakerPage() {
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
setAutopause(!autopause);
|
||||
if (objurlRef) {
|
||||
if (objurlRef.current) {
|
||||
stop();
|
||||
}
|
||||
setPause(true);
|
||||
@@ -317,6 +361,40 @@ export default function TextSpeakerPage() {
|
||||
alt="save"
|
||||
className={`${saving ? "bg-gray-200" : ""}`}
|
||||
></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">
|
||||
<LightButton
|
||||
|
||||
@@ -1,59 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { LightButton, PrimaryButton, IconClick } from "@/design-system/base/button";
|
||||
import { LightButton, PrimaryButton, IconClick, CircleButton } from "@/design-system/base/button";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { Textarea } from "@/design-system/base/textarea";
|
||||
import { Select } from "@/design-system/base/select";
|
||||
import { IMAGES } from "@/config/images";
|
||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { actionTranslateText } from "@/modules/translator/translator-action";
|
||||
import { actionCreateCard } from "@/modules/card/card-action";
|
||||
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
|
||||
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
||||
import type { CardType } from "@/modules/card/card-action-dto";
|
||||
import { toast } from "sonner";
|
||||
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
||||
import { TSharedTranslationResult } from "@/shared/translator-type";
|
||||
import { Plus } from "lucide-react";
|
||||
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() {
|
||||
const t = useTranslations("translator");
|
||||
|
||||
const taref = useRef<HTMLTextAreaElement>(null);
|
||||
const sourceContainerRef = useRef<HTMLDivElement>(null);
|
||||
const targetContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [sourceLanguage, setSourceLanguage] = useState<string>("Auto");
|
||||
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
|
||||
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 [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 lastTTS = useRef({
|
||||
text: "",
|
||||
url: "",
|
||||
});
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
const [decks, setDecks] = useState<ActionOutputDeck[]>([]);
|
||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const tts = async (text: string, locale: string) => {
|
||||
if (lastTTS.current.text !== text) {
|
||||
try {
|
||||
// Map language name to TTS format
|
||||
let theLanguage = locale.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
|
||||
|
||||
// Check if language is in TTS supported list
|
||||
const supportedLanguages: TTS_SUPPORTED_LANGUAGES[] = [
|
||||
"Auto", "Chinese", "English", "German", "Italian", "Portuguese",
|
||||
"Spanish", "Japanese", "Korean", "French", "Russian"
|
||||
];
|
||||
|
||||
if (!supportedLanguages.includes(theLanguage as TTS_SUPPORTED_LANGUAGES)) {
|
||||
theLanguage = "Auto";
|
||||
useEffect(() => {
|
||||
if (session?.user?.id) {
|
||||
actionGetDecksByUserId(session.user.id).then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setDecks(result.data);
|
||||
}
|
||||
|
||||
const url = await getTTSUrl(text, theLanguage as TTS_SUPPORTED_LANGUAGES);
|
||||
await load(url);
|
||||
await play();
|
||||
lastTTS.current.text = text;
|
||||
lastTTS.current.url = url;
|
||||
} catch (error) {
|
||||
toast.error("Failed to generate audio");
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [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 updateButtonCounts = () => {
|
||||
if (sourceContainerRef.current) {
|
||||
const width = sourceContainerRef.current.offsetWidth;
|
||||
setSourceButtonCount(calculateButtonCount(width, true));
|
||||
}
|
||||
if (targetContainerRef.current) {
|
||||
const width = targetContainerRef.current.offsetWidth;
|
||||
setTargetButtonCount(calculateButtonCount(width, false));
|
||||
}
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
const translate = async () => {
|
||||
if (!taref.current || processing) return;
|
||||
@@ -61,26 +161,30 @@ export default function TranslatorPage() {
|
||||
setProcessing(true);
|
||||
|
||||
const sourceText = taref.current.value;
|
||||
const effectiveSourceLanguage = customSourceLanguage.trim() || sourceLanguage;
|
||||
const effectiveTargetLanguage = customTargetLanguage.trim() || targetLanguage;
|
||||
|
||||
// 判断是否需要强制重新翻译
|
||||
// 只有当源文本和目标语言都与上次相同时,才强制重新翻译
|
||||
const forceRetranslate =
|
||||
lastTranslation?.sourceText === sourceText &&
|
||||
lastTranslation?.targetLanguage === targetLanguage;
|
||||
lastTranslation?.sourceLanguage === effectiveSourceLanguage &&
|
||||
lastTranslation?.targetLanguage === effectiveTargetLanguage;
|
||||
|
||||
try {
|
||||
const result = await actionTranslateText({
|
||||
sourceText,
|
||||
targetLanguage,
|
||||
targetLanguage: effectiveTargetLanguage,
|
||||
forceRetranslate,
|
||||
needIpa,
|
||||
sourceLanguage: effectiveSourceLanguage === "Auto" ? undefined : effectiveSourceLanguage,
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
setTranslationResult(result.data);
|
||||
setLastTranslation({
|
||||
sourceText,
|
||||
targetLanguage,
|
||||
sourceLanguage: effectiveSourceLanguage,
|
||||
targetLanguage: effectiveTargetLanguage,
|
||||
});
|
||||
} else {
|
||||
toast.error(result.message || "翻译失败,请重试");
|
||||
@@ -93,6 +197,66 @@ export default function TranslatorPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const visibleSourceButtons = SOURCE_LANGUAGES.slice(0, sourceButtonCount);
|
||||
const visibleTargetButtons = TARGET_LANGUAGES.slice(0, targetButtonCount);
|
||||
|
||||
const handleSaveCard = async () => {
|
||||
if (!session) {
|
||||
toast.error(t("pleaseLogin"));
|
||||
return;
|
||||
}
|
||||
if (decks.length === 0) {
|
||||
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 (
|
||||
<div className="min-h-[calc(100vh-64px)] bg-white">
|
||||
{/* TCard Component */}
|
||||
@@ -101,13 +265,13 @@ export default function TranslatorPage() {
|
||||
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
||||
{/* ICard1 Component */}
|
||||
<div className="border border-gray-200 rounded-lg w-full h-64 p-2">
|
||||
<textarea
|
||||
className="resize-none h-8/12 w-full focus:outline-0"
|
||||
<Textarea
|
||||
className="resize-none h-8/12 w-full"
|
||||
ref={taref}
|
||||
onKeyDown={(e) => {
|
||||
if (e.ctrlKey && e.key === "Enter") translate();
|
||||
}}
|
||||
></textarea>
|
||||
/>
|
||||
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
|
||||
{translationResult?.sourceIpa || ""}
|
||||
</div>
|
||||
@@ -125,18 +289,41 @@ export default function TranslatorPage() {
|
||||
src={IMAGES.play_arrow}
|
||||
alt="play"
|
||||
onClick={() => {
|
||||
const t = taref.current?.value;
|
||||
if (!t) return;
|
||||
tts(t, translationResult?.sourceLanguage || "");
|
||||
const text = taref.current?.value;
|
||||
if (!text) return;
|
||||
tts(text, translationResult?.sourceLanguage || "");
|
||||
}}
|
||||
></IconClick>
|
||||
</div>
|
||||
</div>
|
||||
<div className="option1 w-full flex flex-row justify-between items-center">
|
||||
<span>{t("detectLanguage")}</span>
|
||||
<div ref={sourceContainerRef} className="option1 w-full flex gap-1 items-center overflow-x-auto">
|
||||
<span className="shrink-0">{t("sourceLanguage")}</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
|
||||
selected={needIpa}
|
||||
onClick={() => setNeedIpa((prev) => !prev)}
|
||||
className="shrink-0"
|
||||
>
|
||||
{t("generateIPA")}
|
||||
</LightButton>
|
||||
@@ -172,43 +359,35 @@ export default function TranslatorPage() {
|
||||
></IconClick>
|
||||
</div>
|
||||
</div>
|
||||
<div className="option2 w-full flex gap-1 items-center flex-wrap">
|
||||
<span>{t("translateInto")}</span>
|
||||
<LightButton
|
||||
selected={targetLanguage === "Chinese"}
|
||||
onClick={() => setTargetLanguage("Chinese")}
|
||||
>
|
||||
{t("chinese")}
|
||||
</LightButton>
|
||||
<LightButton
|
||||
selected={targetLanguage === "English"}
|
||||
onClick={() => setTargetLanguage("English")}
|
||||
>
|
||||
{t("english")}
|
||||
</LightButton>
|
||||
<LightButton
|
||||
selected={targetLanguage === "Italian"}
|
||||
onClick={() => setTargetLanguage("Italian")}
|
||||
>
|
||||
{t("italian")}
|
||||
</LightButton>
|
||||
<LightButton
|
||||
selected={!["Chinese", "English", "Italian"].includes(targetLanguage)}
|
||||
onClick={() => {
|
||||
const newLang = prompt(t("enterLanguage"));
|
||||
if (newLang) {
|
||||
setTargetLanguage(newLang);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("other")}
|
||||
</LightButton>
|
||||
<div ref={targetContainerRef} className="option2 w-full flex gap-1 items-center overflow-x-auto">
|
||||
<span className="shrink-0">{t("translateInto")}</span>
|
||||
{visibleTargetButtons.map((lang) => (
|
||||
<LightButton
|
||||
key={lang.value}
|
||||
selected={!customTargetLanguage && targetLanguage === lang.value}
|
||||
onClick={() => {
|
||||
setTargetLanguage(lang.value);
|
||||
setCustomTargetLanguage("");
|
||||
}}
|
||||
className="shrink-0"
|
||||
>
|
||||
{getLangLabel(t, lang.label)}
|
||||
</LightButton>
|
||||
))}
|
||||
<Input
|
||||
variant="bordered"
|
||||
size="sm"
|
||||
value={customTargetLanguage}
|
||||
onChange={(e) => setCustomTargetLanguage(e.target.value)}
|
||||
placeholder={t("customLanguage")}
|
||||
className="w-auto min-w-[120px] shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TranslateButton Component */}
|
||||
<div className="w-screen flex justify-center items-center">
|
||||
<div className="w-screen flex justify-center items-center gap-4">
|
||||
<PrimaryButton
|
||||
onClick={translate}
|
||||
disabled={processing}
|
||||
@@ -217,7 +396,49 @@ export default function TranslatorPage() {
|
||||
>
|
||||
{t("translate")}
|
||||
</PrimaryButton>
|
||||
{translationResult && session && decks.length > 0 && (
|
||||
<CircleButton
|
||||
onClick={() => setShowSaveModal(true)}
|
||||
title={t("saveAsCard")}
|
||||
>
|
||||
<Plus size={20} />
|
||||
</CircleButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSaveModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
||||
<h2 className="text-xl font-semibold mb-4">{t("saveAsCard")}</h2>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t("selectDeck")}
|
||||
</label>
|
||||
<Select id="deck-select-translator" className="w-full">
|
||||
{decks.map((deck) => (
|
||||
<option key={deck.id} value={deck.id}>
|
||||
{deck.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="mb-4 p-3 bg-gray-50 rounded text-sm">
|
||||
<div className="font-medium mb-1">{t("front")}:</div>
|
||||
<div className="text-gray-700 mb-2">{lastTranslation?.sourceText}</div>
|
||||
<div className="font-medium mb-1">{t("back")}:</div>
|
||||
<div className="text-gray-700">{translationResult?.translatedText}</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<LightButton onClick={() => setShowSaveModal(false)}>
|
||||
{t("cancel")}
|
||||
</LightButton>
|
||||
<PrimaryButton onClick={handleSaveCard} loading={isSaving}>
|
||||
{t("save")}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useActionState, startTransition } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { LightButton, LinkButton } from "@/design-system/base/button";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { actionSignIn, actionSignUp, ActionOutputAuth } from "@/modules/auth/auth-action";
|
||||
|
||||
interface AuthFormProps {
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
export 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: ActionOutputAuth | undefined, formData: FormData) => {
|
||||
if (clearSignIn) {
|
||||
setClearSignIn(false);
|
||||
return undefined;
|
||||
}
|
||||
return actionSignIn(undefined, formData);
|
||||
},
|
||||
undefined
|
||||
);
|
||||
const [signUpState, signUpActionForm, isSignUpPending] = useActionState(
|
||||
async (_prevState: ActionOutputAuth | undefined, formData: FormData) => {
|
||||
if (clearSignUp) {
|
||||
setClearSignUp(false);
|
||||
return undefined;
|
||||
}
|
||||
return actionSignUp(undefined, formData);
|
||||
},
|
||||
undefined
|
||||
);
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const validateForm = (formData: FormData): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
const identifier = formData.get("identifier") as string;
|
||||
const email = formData.get("email") as string;
|
||||
const username = formData.get("username") as string;
|
||||
const password = formData.get("password") as string;
|
||||
const confirmPassword = formData.get("confirmPassword") as string;
|
||||
|
||||
// 登录模式验证
|
||||
if (mode === 'signin') {
|
||||
if (!identifier) {
|
||||
newErrors.identifier = t("identifierRequired");
|
||||
}
|
||||
} else {
|
||||
// 注册模式验证
|
||||
if (!email) {
|
||||
newErrors.email = t("emailRequired");
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
newErrors.email = t("invalidEmail");
|
||||
}
|
||||
|
||||
if (!username) {
|
||||
newErrors.username = t("usernameRequired");
|
||||
} else if (username.length < 3) {
|
||||
newErrors.username = t("usernameTooShort");
|
||||
} else if (!/^[a-zA-Z0-9_]+$/.test(username)) {
|
||||
newErrors.username = t("usernameInvalid");
|
||||
}
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
newErrors.password = t("passwordRequired");
|
||||
} else if (password.length < 8) {
|
||||
newErrors.password = t("passwordTooShort");
|
||||
}
|
||||
|
||||
if (mode === 'signup') {
|
||||
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 (
|
||||
<PageLayout>
|
||||
{/* 页面标题 */}
|
||||
<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 === 'signin' ? (
|
||||
<div>
|
||||
<Input
|
||||
type="text"
|
||||
name="identifier"
|
||||
placeholder={t("emailOrUsername")}
|
||||
className="w-full px-3 py-2"
|
||||
/>
|
||||
{errors.identifier && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.identifier}</p>
|
||||
)}
|
||||
{currentError?.errors?.email && (
|
||||
<p className="text-red-500 text-sm mt-1">{currentError.errors.email[0]}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 用户名输入(仅注册模式) */}
|
||||
<div>
|
||||
<Input
|
||||
type="text"
|
||||
name="username"
|
||||
placeholder={t("username")}
|
||||
className="w-full px-3 py-2"
|
||||
/>
|
||||
{errors.username && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.username}</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">
|
||||
<LinkButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode(mode === 'signin' ? 'signup' : 'signin');
|
||||
setErrors({});
|
||||
// 清除服务器端错误状态
|
||||
if (mode === 'signin') {
|
||||
setClearSignIn(true);
|
||||
} else {
|
||||
setClearSignUp(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{mode === 'signin'
|
||||
? `${t("noAccount")} ${t("signUp")}`
|
||||
: `${t("hasAccount")} ${t("signIn")}`
|
||||
}
|
||||
</LinkButton>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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} />;
|
||||
}
|
||||
229
src/app/decks/DecksClient.tsx
Normal file
229
src/app/decks/DecksClient.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
285
src/app/decks/[deck_id]/AddCardModal.tsx
Normal file
285
src/app/decks/[deck_id]/AddCardModal.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
133
src/app/decks/[deck_id]/CardItem.tsx
Normal file
133
src/app/decks/[deck_id]/CardItem.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
229
src/app/decks/[deck_id]/EditCardModal.tsx
Normal file
229
src/app/decks/[deck_id]/EditCardModal.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
152
src/app/decks/[deck_id]/InDeck.tsx
Normal file
152
src/app/decks/[deck_id]/InDeck.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
468
src/app/decks/[deck_id]/learn/Memorize.tsx
Normal file
468
src/app/decks/[deck_id]/learn/Memorize.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
"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 };
|
||||
34
src/app/decks/[deck_id]/learn/page.tsx
Normal file
34
src/app/decks/[deck_id]/learn/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
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} />;
|
||||
}
|
||||
37
src/app/decks/[deck_id]/page.tsx
Normal file
37
src/app/decks/[deck_id]/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
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} />;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user