Compare commits

..

69 Commits

Author SHA1 Message Date
436d58be52 fix(auth): 修复登录注册失败无错误提示的问题
All checks were successful
continuous-integration/drone/push Build is passing
better-auth 客户端不抛出异常,而是返回 { data, error } 对象
修改错误处理逻辑检查 error 对象而非 try-catch
2026-03-09 19:52:41 +08:00
11a265d52e i18n: 完整翻译所有语言文件
- de-DE: 德语完整翻译
- fr-FR: 法语完整翻译
- it-IT: 意大利语完整翻译
- ja-JP: 日语完整翻译
- ko-KR: 韩语完整翻译
- ug-CN: 维吾尔语完整翻译

所有翻译保持与 en-US.json 结构一致,保留插值变量
2026-03-09 19:49:34 +08:00
fb4346377a feat(explore): 添加文件夹详情页面
- 修复 folder-aciton.ts 文件名拼写错误为 folder-action.ts
- 修复所有导入路径中的拼写错误
- 添加 repoGetPublicFolderById 和 actionGetPublicFolderById
- 创建 ExploreDetailClient 详情页组件
- /explore/[id] 现在显示文件夹详情和链接到 /folders/[id]
- 添加 exploreDetail 中英文翻译
2026-03-09 19:39:03 +08:00
c83aefabfa fix: 修复代码审查发现的所有 bug
Critical 级别:
- zhipu.ts: 添加 API 响应边界检查
- DictionaryClient.tsx: 添加 entries 数组边界检查
- subtitleParser.ts: 修复 getNearestIndex 逻辑错误

High 级别:
- text-speaker/page.tsx: 修复非空断言和 ref 检查
- folder-repository.ts: 添加 user 关系 null 检查

Medium 级别:
- InFolder.tsx: 修复 throw result.message 为 throw new Error()
- localStorageOperators.ts: 返回类型改为 T | null,添加 schema 验证
- SaveList.tsx: 处理 data 可能为 null 的情况
2026-03-09 19:11:49 +08:00
020744b353 fix(i18n): 补充页面缺失的中英文翻译并修复登录重定向循环
- 补充 login/signup/dictionary/srt-player/alphabet 页面的翻译
- 修复登录页面邮箱登录时 password 参数错误
- 修复登录/注册页面的无限重定向循环问题
- 调整登录/注册卡片宽度为 w-96
2026-03-09 18:41:41 +08:00
719aef5a7f fix(dictionary): 修复语义映射和错误日志
- 修复语义映射:强制将输入转换为查询语言的对应词
- 移除拼写自动纠正,避免错误纠正(如 franch→franchise)
- 修复 winston 日志 Error 对象序列化问题
2026-03-09 18:14:14 +08:00
6c811a77db perf(dictionary): 优化 AI 编排性能,4 次 LLM 调用减少到 2 次
- 合并 Stage 1+2+3 为单次 preprocessInput 调用
- 精简 Stage 4 词条生成 prompt
- 删除旧的 stage 文件
- 预期性能提升 60%+ (33s → ~8-13s)
2026-03-09 18:04:12 +08:00
3652e350e6 fix(dictionary): 修复 AI 编排系统的错误处理和超时控制
- 修复 orchestrator 中 throw 字符串的问题,改为 throw LookUpError
- 为 zhipu.ts 添加 30 秒超时控制,防止 LLM 调用卡死
- stage1 添加 isEmpty 和 isNaturalLanguage 字段验证
- stage2 改为降级处理而非直接失败,提升用户体验
- types.ts 添加 canMap 字段
- AGENTS.md 添加禁止擅自运行 pnpm dev 的说明
2026-03-09 17:19:12 +08:00
6ba5ae993a fix: language selector mutual exclusion with preset buttons
- When "Other" is selected, preset language buttons are deselected
- Only one option can be selected at a time
- Refactor dictionary page with zustand store
- Add custom language input option to dictionary
- Fix multiple issues in dictionary bigmodel pipeline
2026-03-08 16:10:41 +08:00
b643205f72 refactor(folders): 优化刷新逻辑,只更新特定文件夹而非全量刷新
- FoldersClient: 使用 onUpdateFolder/onDeleteFolder 回调局部更新
- ExploreClient: 使用 onUpdateFavorite 只更新收藏数
- FavoritesClient: 使用 onRemoveFavorite 从列表移除,避免重新请求
2026-03-08 15:07:05 +08:00
c6878ed1e5 style(explore): 将公开文件夹改为网格布局展示
- 移除 CardList 组件,改用 CSS Grid
- 响应式网格: 1/2/3/4 列 (sm/lg/xl)
- 重新设计卡片样式:圆角边框、hover 效果
- 文件夹图标移至左上角,收藏按钮移至右上角
2026-03-08 15:03:35 +08:00
e74cd80fac style(explore): 移动收藏数到文件夹名左侧 2026-03-08 15:01:29 +08:00
c01c94abd0 refactor: 替换服务端 console.log/error 为 winston logger
- folder-action.ts: 18处 console.log -> log.error
- auth-action.ts: 4处 console.error -> log.error
- dictionary-action/service.ts: 3处 -> log.error
- translator-action/service.ts: 3处 -> log.error
- bigmodel/translator/orchestrator.ts: console -> log.debug/info/error
- bigmodel/tts.ts: console -> log.error/warn
- bigmodel/dictionary/*.ts: console -> log.error/debug/info

客户端代码(browser、page.tsx)保留 console.error
2026-03-08 14:58:43 +08:00
0881846717 feat(logger): 添加 winston 日志系统
- 新增 src/lib/logger/ 模块
- 支持 dev/prod 环境不同输出格式
- createLogger() 创建带上下文的 logger
- 更新 AGENTS.md 添加日志使用约定
2026-03-08 14:52:24 +08:00
d7149366e9 feat(folders): 完善公开文件夹功能 - 添加 /explore 和 /favorites 页面
- 新增 /explore 页面:浏览和搜索公开文件夹
- 新增 /explore/[id] 页面:以只读模式查看公开文件夹
- 新增 /favorites 页面:管理收藏的文件夹
- 重构 /folders 页面:仅显示当前用户的文件夹
- 更新导航栏:添加 Explore 和 Favorites 链接
- 添加 i18n 翻译:explore 和 favorites 相关文本
- 更新 AGENTS.md:添加数据库迁移规范(必须使用 migrate dev)
2026-03-08 14:47:35 +08:00
b0fa1a4201 feat(folders): 添加公开文件夹和收藏功能
- 新增文件夹可见性控制(公开/私有)
- 添加公开文件夹浏览和搜索
- 实现文件夹收藏功能
- 新增 FolderFavorite 数据模型
- 更新 Prisma 至 7.4.2
- 添加相关 i18n 翻译
2026-03-08 14:20:12 +08:00
b407783d61 feat(i18n): 添加用户名相关翻译并修复多处翻译错误
- 为 6 种语言添加 username, emailOrUsername 等字段的翻译
- 修复德语 Steve Jobs 名言中的拼写错误 (bleiv -> bleib)
- 改进维吾尔语翻译质量和术语一致性
- 修复维吾尔语中 'ئىلمىيى' -> 'ئىزاھات' 等表述问题
2026-03-08 13:32:06 +08:00
ca33d4353f refactor: 优化文件夹列表 UI 样式和布局 2026-03-08 13:05:48 +08:00
ff57f5e0a5 docs: 更新 AGENTS.md 知识库,新增模块/管道/设计系统文档 2026-03-08 11:32:31 +08:00
91c59c3ad9 update AGENTS.md 2026-03-08 10:24:31 +08:00
1df184d1ad update AGENTS.md 2026-03-08 09:51:05 +08:00
f6e21aa2fe move email above joined date in user profile 2026-03-08 09:48:07 +08:00
67ac0bf7b6 Sun Mar 8 09:35:08 AM CST 2026 2026-03-08 09:35:08 +08:00
dd1c6a7b52 Sun Mar 8 09:25:22 AM CST 2026 2026-03-08 09:25:22 +08:00
e2d8e17f62 Sun Mar 8 08:27:26 AM CST 2026 2026-03-08 08:27:26 +08:00
63486757b9 从claude code迁移到opencode 2026-03-06 09:29:08 +08:00
45ffe5733b 从claude code迁移到opencode 2026-03-06 09:28:27 +08:00
613df6824b Wed Mar 4 09:32:00 AM CST 2026 2026-03-04 09:32:00 +08:00
bf80e17514 eslint 2026-02-24 21:22:14 +08:00
d71c79c87a srt 2026-02-24 21:18:30 +08:00
559690dc56 ... 2026-02-24 20:56:46 +08:00
884b30d7f6 ... 2026-02-24 20:54:54 +08:00
01cd122d93 ... 2026-02-24 08:05:44 +08:00
94840c1b0a abstract range 2026-02-24 08:02:39 +08:00
72ced7866e flatten folder design-system 2026-02-24 07:56:21 +08:00
690222ccb7 remove folder tokens 2026-02-24 07:44:37 +08:00
6dc933dc1e remove all index.ts 2026-02-24 07:43:29 +08:00
1be24065e0 ... 2026-02-20 22:53:10 +08:00
757c27c94a remove index.ts 2026-02-20 22:38:57 +08:00
6ea8b4d4b9 ... 2026-02-20 22:32:31 +08:00
9e9ac373c6 简化登录 2026-02-20 22:28:55 +08:00
0149fde0bd fix color 2026-02-14 03:55:58 +08:00
8f25791fa1 less buttons 2026-02-14 02:08:40 +08:00
b586c1071b ... 2026-02-10 15:11:09 +08:00
b8cb884e9e Design System 重构继续完成 2026-02-10 04:58:50 +08:00
73d0b0d5fe Design System 重构完成 2026-02-10 03:54:09 +08:00
fe5e8533b5 layout 2026-02-06 04:41:59 +08:00
12eb5c412a layout 2026-02-06 04:36:06 +08:00
3635fbd256 button 2026-02-06 04:13:50 +08:00
058ecf7e39 button 2026-02-06 04:01:41 +08:00
6c7095ffb3 ... 2026-02-06 03:43:49 +08:00
8ed9b011f4 ... 2026-02-06 03:28:53 +08:00
2537b9fe75 ... 2026-02-06 03:22:20 +08:00
5e24fa76a3 ... 2026-02-06 03:16:06 +08:00
9d42a45bb1 ... 2026-02-03 20:29:55 +08:00
d5dde77ee9 ... 2026-02-03 20:00:56 +08:00
c4a9247cad ... 2026-02-03 19:18:29 +08:00
56552863bf ... 2026-02-03 17:27:58 +08:00
0af99b6b70 修改语言图标 2026-02-03 17:04:41 +08:00
eaf97b8279 ... 2026-02-02 23:57:01 +08:00
76749549ff ... 2026-02-02 23:32:39 +08:00
fa6301538b ... 2026-01-22 16:01:07 +08:00
d4d5a53747 补全翻译 2026-01-18 13:06:08 +08:00
ec265be26b 重构 2026-01-14 16:57:35 +08:00
804baa64b2 重构 2026-01-13 23:02:07 +08:00
a1e42127e6 update ignore 2026-01-13 15:17:59 +08:00
f1d706e20c ... 2026-01-13 14:46:27 +08:00
c7cdf40f2f change varchar to text 2026-01-08 10:18:05 +08:00
a55e763525 解决dictionary搜索框溢出问题 2026-01-08 09:45:08 +08:00
205 changed files with 13429 additions and 6694 deletions

View File

@@ -35,3 +35,5 @@ build.sh
# prisma
/generated/prisma
.claude

2
.gitignore vendored
View File

@@ -50,3 +50,5 @@ test.js
/generated/prisma
certificates
.opencode

View File

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

168
AGENTS.md Normal file
View File

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

125
CLAUDE.md
View File

@@ -1,125 +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
# 数据库操作
pnpm prisma generate # 生成 Prisma client 到 src/generated/prisma
pnpm prisma db push # 推送 schema 变更到数据库
pnpm prisma studio # 打开 Prisma Studio 查看数据库
```
## 技术栈
- **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
- **edge-tts-universal** 文本转语音
- **pnpm** 包管理器
## 架构设计
### 路由结构
应用使用 Next.js App Router 和基于功能的组织方式:
```
src/app/
├── (features)/ # 功能模块translator, alphabet, memorize, dictionary, srt-player
│ └── [locale]/ # 国际化路由
├── auth/ # 认证页面sign-in, sign-up
├── folders/ # 用户学习文件夹管理
├── api/ # API 路由
└── profile/ # 用户资料页面
```
### 数据库 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` 配置。
### 环境变量
需要在 `.env.local` 中配置:
```env
# LLM 集成
ZHIPU_API_KEY=your-api-key
ZHIPU_MODEL_NAME=your-model-name
# 认证
BETTER_AUTH_SECRET=your-secret
BETTER_AUTH_URL=http://localhost:3000
GITHUB_CLIENT_ID=your-client-id
GITHUB_CLIENT_SECRET=your-client-secret
# 数据库
DATABASE_URL=postgresql://username:password@localhost:5432/database_name
// DashScore
DASHSCORE_API_KEY=
```
## 重要配置细节
- **Prisma client 输出**: 自定义目录 `src/generated/prisma`(不是默认的 `node_modules/.prisma`
- **Standalone 输出**: 为 Docker 部署配置
- **React Compiler**: 在 `next.config.ts` 中启用以自动优化
- **HTTPS 开发**: 开发服务器使用 `--experimental-https` 标志
- **图片优化**: 通过 remote patterns 允许 GitHub 头像
## 代码组织
- `src/lib/actions/`: 数据库变更的 Server Actions
- `src/lib/server/`: 服务端工具AI 集成、认证、翻译器)
- `src/lib/browser/`: 客户端工具
- `src/hooks/`: 自定义 React hooks认证 hooks、会话管理
- `src/i18n/`: 国际化配置
- `messages/`: 各支持语言的翻译文件
- `src/components/`: 可复用的 UI 组件buttons, cards 等)
## 开发注意事项
- 使用 pnpm而不是 npm 或 yarn
- schema 变更后,先运行 `pnpm prisma generate` 再运行 `pnpm prisma db push`
- 应用使用 TypeScript 严格模式 - 确保类型安全
- 所有面向用户的文本都需要国际化
- Better-auth 处理会话管理 - 使用 authClient 适配器进行认证操作

518
README.md
View File

@@ -1,162 +1,372 @@
# 多语言学习平台
# 🌍 多语言学习平台
一个基于 Next.js 构建的全功能多语言学习平台,提供翻译、发音、字幕播放、字母学习等多种语言学习工具,帮助用户更高效地掌握新语言。
<div align="center">
## ✨ 主要功能
[![Next.js](https://img.shields.io/badge/Next.js-16.1.1-black?logo=next.js)](https://nextjs.org/)
[![React](https://img.shields.io/badge/React-19.2.3-61DAFB?logo=react)](https://reactjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.9.3-3178C6?logo=typescript)](https://www.typescriptlang.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-Latest-336791?logo=postgresql)](https://www.postgresql.org/)
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue)](./LICENSE)
- **智能翻译工具** - 支持多语言互译,包含国际音标(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** - 国际化解决方案
- **qwen3-tts-flash** - 通义千问语音合成
### 开发工具
- **ESLint** - 代码质量检查
- **pnpm** - 高效的包管理器
## 📁 项目结构
```
src/
├── app/ # Next.js App Router 路由
│ ├── (features)/ # 功能模块路由
│ ├── api/ # API 路由
│ └── auth/ # 认证相关页面
├── components/ # React 组件
│ ├── buttons/ # 按钮组件
│ ├── cards/ # 卡片组件
│ └── ...
├── lib/ # 工具函数和库
│ ├── actions/ # Server Actions
│ ├── browser/ # 浏览器端工具
│ └── server/ # 服务器端工具
├── hooks/ # 自定义 React Hooks
├── i18n/ # 国际化配置
└── config/ # 应用配置
```
## 🚀 快速开始
### 环境要求
- Node.js 24
- 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
ZHIPU_API_KEY=your-zhipu-api-key
ZHIPU_MODEL_NAME=your-zhipu-model-name
// Auth
BETTER_AUTH_SECRET=your-better-auth-secret
BETTER_AUTH_URL=http://localhost:3000
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
// Database
DATABASE_URL=postgresql://username:password@localhost:5432/database_name
```
注意:所有带 `your-` 前缀的值需要替换为你的实际配置。
4. 初始化数据库
```bash
pnpm prisma generate
pnpm prisma db push
```
5. 启动开发服务器
```bash
pnpm run dev
```
访问 [http://localhost:3000](http://localhost:3000) 查看应用。
## 📚 API 文档
### 认证系统
应用使用 better-auth 提供安全的用户认证系统,支持邮箱/密码登录和第三方登录。
### 数据模型
核心数据模型包括:
- **User** - 用户信息
- **Folder** - 学习资料文件夹
- **Pair** - 语言对(翻译对、词汇对等)
详细模型定义请参考 [prisma/schema.prisma](./prisma/schema.prisma)
## 🌍 国际化
应用支持多语言,当前语言文件位于 `messages/` 目录。添加新语言:
1.`messages/` 目录创建对应语言的 JSON 文件
2.`src/i18n/config.ts` 中添加语言配置
## 🤝 贡献指南
我们欢迎各种形式的贡献!请遵循以下步骤:
1. Fork 项目
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 打开 Pull Request
## 📄 许可证
本项目采用 AGPL-3.0 许可证 - 查看 [LICENSE](./LICENSE) 文件了解详情。
## 📞 支持
如果您遇到问题或有建议,请通过以下方式联系:
- 提交 [Issue](../../issues)
- 发送邮件至 [goddonebianu@outlook.com]
</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 23+
- 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>

View File

@@ -1,37 +1,57 @@
{
"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"
},
"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,55 +62,66 @@
"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"
"delete": "Löschen",
"permissionDenied": "Sie haben keine Berechtigung für diese Aktion",
"error": {
"update": "Sie haben keine Berechtigung, diesen Eintrag zu aktualisieren.",
"delete": "Sie haben keine Berechtigung, diesen Eintrag zu löschen.",
"add": "Sie haben keine Berechtigung, Einträge zu diesem Ordner hinzuzufügen.",
"rename": "Sie haben keine Berechtigung, diesen Ordner umzubenennen.",
"deleteFolder": "Sie haben keine Berechtigung, diesen Ordner zu löschen."
}
},
"home": {
"title": "Sprachen lernen",
"description": "Hier ist eine sehr nützliche Website, die Ihnen hilft, fast jede Sprache der Welt zu lernen, einschließlich konstruierter Sprachen.",
"explore": "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?",
@@ -99,16 +130,31 @@
"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"
},
"memorize": {
"folder_selector": {
"selectFolder": "Wählen Sie einen Ordner aus",
"selectFolder": "Wählen Sie einen Ordner",
"noFolders": "Keine Ordner gefunden",
"folderInfo": "{id}. {name} ({count})"
},
@@ -130,7 +176,9 @@
"sourceCode": "GitHub",
"sign_in": "Anmelden",
"profile": "Profil",
"folders": "Ordner"
"folders": "Ordner",
"explore": "Entdecken",
"favorites": "Favoriten"
},
"profile": {
"myProfile": "Mein Profil",
@@ -156,67 +204,141 @@
"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"
},
"text_speaker": {
"generateIPA": "IPA generieren",
"viewSavedItems": "Gespeicherte Elemente anzeigen",
"viewSavedItems": "Gespeicherte Einträge anzeigen",
"confirmDeleteAll": "Sind Sie sicher, dass Sie alles löschen möchten? (J/N)"
},
"translator": {
"detectLanguage": "Sprache erkennen",
"generateIPA": "IPA generieren",
"translateInto": "Übersetzen in",
"translateInto": "übersetzen in",
"chinese": "Chinesisch",
"english": "Englisch",
"french": "Französisch",
"german": "Deutsch",
"italian": "Italienisch",
"japanese": "Japanisch",
"korean": "Koreanisch",
"portuguese": "Portugiesisch",
"russian": "Russisch",
"spanish": "Spanisch",
"other": "Andere",
"translating": "Ü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"
},
"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"
},
"exploreDetail": {
"title": "Ordnerdetails",
"createdBy": "Erstellt von: {name}",
"unknownUser": "Unbekannter Benutzer",
"totalPairs": "Gesamtpaare",
"favorites": "Favoriten",
"createdAt": "Erstellt am",
"viewContent": "Inhalt anzeigen",
"favorite": "Favorisieren",
"unfavorite": "Aus Favoriten entfernen",
"favorited": "Favorisiert",
"unfavorited": "Aus Favoriten entfernt",
"pleaseLogin": "Bitte melden Sie sich zuerst an"
},
"favorites": {
"title": "Meine Favoriten",
"subtitle": "Ordner, die Sie favorisiert haben",
"loading": "Wird geladen...",
"noFavorites": "Noch keine Favoriten",
"folderInfo": "{userName} • {totalPairs} Paare",
"unknownUser": "Unbekannter Benutzer"
},
"user_profile": {
"anonymous": "Anonym",
"email": "E-Mail",
"verified": "Verifiziert",
"unverified": "Nicht verifiziert",
"accountInfo": "Kontoinformationen",
"userId": "Benutzer-ID",
"username": "Benutzername",
"displayName": "Anzeigename",
"notSet": "Nicht festgelegt",
"memberSince": "Mitglied seit",
"logout": "Abmelden",
"folders": {
"title": "Ordner",
"noFolders": "Noch keine Ordner",
"folderName": "Ordnername",
"totalPairs": "Gesamtpaare",
"createdAt": "Erstellt am",
"actions": "Aktionen",
"view": "Anzeigen"
}
}
}

View File

@@ -1,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",
@@ -44,7 +64,15 @@
"language2": "Locale 2",
"enterLanguageName": "Please enter language name",
"edit": "Edit",
"delete": "Delete"
"delete": "Delete",
"permissionDenied": "You do not have permission to perform this action",
"error": {
"update": "You do not have permission to update this item.",
"delete": "You do not have permission to delete this item.",
"add": "You do not have permission to add items to this folder.",
"rename": "You do not have permission to rename this folder.",
"deleteFolder": "You do not have permission to delete this folder."
}
},
"home": {
"title": "Learn Languages",
@@ -84,13 +112,16 @@
}
},
"auth": {
"title": "Authentication",
"title": "Sign In",
"signUpTitle": "Sign Up",
"signIn": "Sign In",
"signUp": "Sign Up",
"email": "Email",
"password": "Password",
"confirmPassword": "Confirm Password",
"name": "Name",
"username": "Username",
"emailOrUsername": "Email or Username",
"signInButton": "Sign In",
"signUpButton": "Sign Up",
"noAccount": "Don't have an account?",
@@ -101,10 +132,25 @@
"passwordTooShort": "Password must be at least 8 characters",
"passwordsNotMatch": "Passwords do not match",
"nameRequired": "Please enter your name",
"usernameRequired": "Please enter a username",
"usernameTooShort": "Username must be at least 3 characters",
"usernameInvalid": "Username can only contain letters, numbers, and underscores",
"emailRequired": "Please enter your email",
"identifierRequired": "Please enter your email or username",
"passwordRequired": "Please enter your password",
"confirmPasswordRequired": "Please confirm your password",
"loading": "Loading..."
"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"
},
"memorize": {
"folder_selector": {
@@ -130,7 +176,9 @@
"sourceCode": "GitHub",
"sign_in": "Sign In",
"profile": "Profile",
"folders": "Folders"
"folders": "Folders",
"explore": "Explore",
"favorites": "Favorites"
},
"profile": {
"myProfile": "My Profile",
@@ -156,11 +204,17 @@
"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"
},
"text_speaker": {
"generateIPA": "Generate IPA",
@@ -173,7 +227,14 @@
"translateInto": "translate into",
"chinese": "Chinese",
"english": "English",
"french": "French",
"german": "German",
"italian": "Italian",
"japanese": "Japanese",
"korean": "Korean",
"portuguese": "Portuguese",
"russian": "Russian",
"spanish": "Spanish",
"other": "Other",
"translating": "translating...",
"translate": "translate",
@@ -203,6 +264,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",
@@ -217,6 +279,66 @@
"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 folders",
"searchPlaceholder": "Search public folders...",
"loading": "Loading...",
"noFolders": "No public folders found",
"folderInfo": "{userName} • {totalPairs} pairs",
"unknownUser": "Unknown User",
"favorite": "Favorite",
"unfavorite": "Unfavorite",
"pleaseLogin": "Please login first",
"sortByFavorites": "Sort by favorites",
"sortByFavoritesActive": "Undo sort by favorites"
},
"exploreDetail": {
"title": "Folder Details",
"createdBy": "Created by: {name}",
"unknownUser": "Unknown User",
"totalPairs": "Total Pairs",
"favorites": "Favorites",
"createdAt": "Created At",
"viewContent": "View Content",
"favorite": "Favorite",
"unfavorite": "Unfavorite",
"favorited": "Favorited",
"unfavorited": "Unfavorited",
"pleaseLogin": "Please login first"
},
"favorites": {
"title": "My Favorites",
"subtitle": "Folders you've favorited",
"loading": "Loading...",
"noFavorites": "No favorites yet",
"folderInfo": "{userName} • {totalPairs} pairs",
"unknownUser": "Unknown User"
},
"user_profile": {
"anonymous": "Anonymous",
"email": "Email",
"verified": "Verified",
"unverified": "Unverified",
"accountInfo": "Account Information",
"userId": "User ID",
"username": "Username",
"displayName": "Display Name",
"notSet": "Not Set",
"memberSince": "Member Since",
"logout": "Logout",
"folders": {
"title": "Folders",
"noFolders": "No folders yet",
"folderName": "Folder Name",
"totalPairs": "Total Pairs",
"createdAt": "Created At",
"actions": "Actions",
"view": "View"
}
}
}

View File

@@ -1,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,48 @@
"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"
},
"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",
@@ -44,19 +64,27 @@
"language2": "Langue 2",
"enterLanguageName": "Veuillez entrer le nom de la langue",
"edit": "Modifier",
"delete": "Supprimer"
"delete": "Supprimer",
"permissionDenied": "Vous n'avez pas la permission d'effectuer cette action",
"error": {
"update": "Vous n'avez pas la permission de mettre à jour cet élément.",
"delete": "Vous n'avez pas la permission de supprimer cet élément.",
"add": "Vous n'avez pas la permission d'ajouter des éléments à ce dossier.",
"rename": "Vous n'avez pas la permission de renommer ce dossier.",
"deleteFolder": "Vous n'avez pas la permission de supprimer ce dossier."
}
},
"home": {
"title": "Apprendre les langues",
"description": "Voici un site web très utile pour vous aider à apprendre presque toutes les langues du monde, y compris les langues construites.",
"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",
@@ -68,15 +96,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",
@@ -84,27 +112,45 @@
}
},
"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"
},
"memorize": {
"folder_selector": {
@@ -117,7 +163,7 @@
"next": "Suivant",
"reverse": "Inverser",
"dictation": "Dictée",
"noTextPairs": "Aucune paire de textes disponible",
"noTextPairs": "Aucune paire de texte disponible",
"disorder": "Désordre",
"previous": "Précédent"
},
@@ -126,46 +172,54 @@
}
},
"navbar": {
"title": "learn-languages",
"title": "apprendre-langues",
"sourceCode": "GitHub",
"sign_in": "Se connecter",
"profile": "Profil",
"folders": "Dossiers"
"folders": "Dossiers",
"explore": "Explorer",
"favorites": "Favoris"
},
"profile": {
"myProfile": "Mon profil",
"email": "E-mail: {email}",
"logout": "Se déconnecter"
"email": "E-mail : {email}",
"logout": "Déconnexion"
},
"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"
},
"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)"
},
"translator": {
"detectLanguage": "détecter la langue",
@@ -173,50 +227,118 @@
"translateInto": "traduire en",
"chinese": "Chinois",
"english": "Anglais",
"french": "Français",
"german": "Allemand",
"italian": "Italien",
"japanese": "Japonais",
"korean": "Coréen",
"portuguese": "Portugais",
"russian": "Russe",
"spanish": "Espagnol",
"other": "Autre",
"translating": "traduction...",
"translate": "traduire",
"inputLanguage": "Entrez une langue.",
"history": "Historique",
"enterLanguage": "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"
},
"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"
},
"exploreDetail": {
"title": "Détails du dossier",
"createdBy": "Créé par : {name}",
"unknownUser": "Utilisateur inconnu",
"totalPairs": "Total des paires",
"favorites": "Favoris",
"createdAt": "Créé le",
"viewContent": "Voir le contenu",
"favorite": "Favori",
"unfavorite": "Retirer des favoris",
"favorited": "Ajouté aux favoris",
"unfavorited": "Retiré des favoris",
"pleaseLogin": "Veuillez vous connecter d'abord"
},
"favorites": {
"title": "Mes favoris",
"subtitle": "Les dossiers que vous avez mis en favoris",
"loading": "Chargement...",
"noFavorites": "Pas encore de favoris",
"folderInfo": "{userName} • {totalPairs} paires",
"unknownUser": "Utilisateur inconnu"
},
"user_profile": {
"anonymous": "Anonyme",
"email": "E-mail",
"verified": "Vérifié",
"unverified": "Non vérifié",
"accountInfo": "Informations du compte",
"userId": "ID utilisateur",
"username": "Nom d'utilisateur",
"displayName": "Nom d'affichage",
"notSet": "Non défini",
"memberSince": "Membre depuis",
"logout": "Déconnexion",
"folders": {
"title": "Dossiers",
"noFolders": "Pas encore de dossiers",
"folderName": "Nom du dossier",
"totalPairs": "Total des paires",
"createdAt": "Créé le",
"actions": "Actions",
"view": "Voir"
}
}
}

View File

@@ -1,54 +1,82 @@
{
"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"
},
"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"
"delete": "Elimina",
"permissionDenied": "Non hai il permesso di eseguire questa azione",
"error": {
"update": "Non hai il permesso di aggiornare questo elemento.",
"delete": "Non hai il permesso di eliminare questo elemento.",
"add": "Non hai il permesso di aggiungere elementi a questa cartella.",
"rename": "Non hai il permesso di rinominare questa cartella.",
"deleteFolder": "Non hai il permesso di eliminare questa cartella."
}
},
"home": {
"title": "Impara le lingue",
"description": "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.",
@@ -56,15 +84,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",
@@ -72,39 +100,57 @@
},
"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"
},
"memorize": {
"folder_selector": {
@@ -117,8 +163,8 @@
"next": "Successivo",
"reverse": "Inverti",
"dictation": "Dettatura",
"noTextPairs": "Nessuna coppia di testi disponibile",
"disorder": "Disordine",
"noTextPairs": "Nessuna coppia di testo disponibile",
"disorder": "Disordina",
"previous": "Precedente"
},
"page": {
@@ -126,45 +172,53 @@
}
},
"navbar": {
"title": "learn-languages",
"title": "impara-lingue",
"sourceCode": "GitHub",
"sign_in": "Accedi",
"profile": "Profilo",
"folders": "Cartelle"
"folders": "Cartelle",
"explore": "Esplora",
"favorites": "Preferiti"
},
"profile": {
"myProfile": "Il mio profilo",
"myProfile": "Il Mio Profilo",
"email": "Email: {email}",
"logout": "Esci"
},
"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"
},
"text_speaker": {
"generateIPA": "Genera IPA",
"viewSavedItems": "Visualizza elementi salvati",
"viewSavedItems": "Visualizza Elementi Salvati",
"confirmDeleteAll": "Sei sicuro di voler eliminare tutto? (S/N)"
},
"translator": {
@@ -173,7 +227,14 @@
"translateInto": "traduci in",
"chinese": "Cinese",
"english": "Inglese",
"french": "Francese",
"german": "Tedesco",
"italian": "Italiano",
"japanese": "Giapponese",
"korean": "Coreano",
"portuguese": "Portoghese",
"russian": "Russo",
"spanish": "Spagnolo",
"other": "Altro",
"translating": "traduzione...",
"translate": "traduci",
@@ -182,14 +243,14 @@
"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"
},
"dictionary": {
"title": "Dizionario",
@@ -197,26 +258,87 @@
"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"
},
"exploreDetail": {
"title": "Dettagli Cartella",
"createdBy": "Creata da: {name}",
"unknownUser": "Utente Sconosciuto",
"totalPairs": "Coppie Totali",
"favorites": "Preferiti",
"createdAt": "Creata Il",
"viewContent": "Visualizza Contenuto",
"favorite": "Preferito",
"unfavorite": "Rimuovi dai preferiti",
"favorited": "Aggiunto ai preferiti",
"unfavorited": "Rimosso dai preferiti",
"pleaseLogin": "Per favore accedi prima"
},
"favorites": {
"title": "I Miei Preferiti",
"subtitle": "Cartelle che hai aggiunto ai preferiti",
"loading": "Caricamento...",
"noFavorites": "Nessun preferito ancora",
"folderInfo": "{userName} • {totalPairs} coppie",
"unknownUser": "Utente Sconosciuto"
},
"user_profile": {
"anonymous": "Anonimo",
"email": "Email",
"verified": "Verificato",
"unverified": "Non Verificato",
"accountInfo": "Informazioni Account",
"userId": "ID Utente",
"username": "Nome Utente",
"displayName": "Nome Visualizzato",
"notSet": "Non Impostato",
"memberSince": "Membro Dal",
"logout": "Esci",
"folders": {
"title": "Cartelle",
"noFolders": "Nessuna cartella ancora",
"folderName": "Nome Cartella",
"totalPairs": "Coppie Totali",
"createdAt": "Creata Il",
"actions": "Azioni",
"view": "Visualizza"
}
}
}

View File

@@ -1,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": "このフォルダーにはテキストペアがありません",
@@ -44,27 +64,35 @@
"language2": "言語2",
"enterLanguageName": "言語名を入力してください",
"edit": "編集",
"delete": "削除"
"delete": "削除",
"permissionDenied": "このアクションを実行する権限がありません",
"error": {
"update": "この項目を更新する権限がありません。",
"delete": "この項目を削除する権限がありません。",
"add": "このフォルダーに項目を追加する権限がありません。",
"rename": "このフォルダーの名前を変更する権限がありません。",
"deleteFolder": "このフォルダーを削除する権限がありません。"
}
},
"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": "アルファベット",
@@ -72,39 +100,57 @@
},
"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": "ユーザー名とパスワードを入力してください"
},
"memorize": {
"folder_selector": {
@@ -113,12 +159,12 @@
"folderInfo": "{id}. {name} ({count})"
},
"memorize": {
"answer": "答",
"answer": "答",
"next": "次へ",
"reverse": "逆順",
"dictation": "ディクテーション",
"dictation": "書き取り",
"noTextPairs": "利用可能なテキストペアがありません",
"disorder": "ランダム",
"disorder": "シャッフル",
"previous": "前へ"
},
"page": {
@@ -128,13 +174,15 @@
"navbar": {
"title": "learn-languages",
"sourceCode": "GitHub",
"sign_in": "ログイン",
"sign_in": "サインイン",
"profile": "プロフィール",
"folders": "フォルダー"
"folders": "フォルダー",
"explore": "探索",
"favorites": "お気に入り"
},
"profile": {
"myProfile": "マイプロフィール",
"email": "メールアドレス: {email}",
"email": "メール: {email}",
"logout": "ログアウト"
},
"srt_player": {
@@ -156,24 +204,37 @@
"uploaded": "アップロード済み",
"notUploaded": "未アップロード",
"upload": "アップロード",
"uploadVideoButton": "ビデオをアップロード",
"uploadSubtitleButton": "字幕をアップロード",
"subtitleUploaded": "字幕をアップロード済み ({count} エントリ)",
"subtitleNotUploaded": "字幕がアップロードされていません",
"autoPauseStatus": "自動一時停止: {enabled}",
"on": "オン",
"off": "オフ",
"videoUploadFailed": "ビデオのアップロードに失敗しました",
"subtitleUploadFailed": "字幕のアップロードに失敗しました"
"subtitleUploadFailed": "字幕のアップロードに失敗しました",
"subtitleLoadSuccess": "字幕の読み込みに成功しました",
"subtitleLoadFailed": "字幕の読み込みに失敗しました"
},
"text_speaker": {
"generateIPA": "IPAを生成",
"viewSavedItems": "保存済みアイテムを表示",
"confirmDeleteAll": "本当にすべて削除しすか? (Y/N)"
"viewSavedItems": "保存済み項目を表示",
"confirmDeleteAll": "すべて削除してもよろしいですか? (Y/N)"
},
"translator": {
"detectLanguage": "言語を検出",
"generateIPA": "IPAを生成",
"translateInto": "翻訳",
"generateIPA": "ipaを生成",
"translateInto": "翻訳",
"chinese": "中国語",
"english": "英語",
"french": "フランス語",
"german": "ドイツ語",
"italian": "イタリア語",
"japanese": "日本語",
"korean": "韓国語",
"portuguese": "ポルトガル語",
"russian": "ロシア語",
"spanish": "スペイン語",
"other": "その他",
"translating": "翻訳中...",
"translate": "翻訳",
@@ -186,37 +247,98 @@
"noFolders": "フォルダーが見つかりません",
"folderInfo": "{id}. {name}",
"close": "閉じる",
"success": "テキストペアフォルダーに追加ました",
"error": "テキストペアの追加に失敗しました"
"success": "テキストペアフォルダーに追加されました",
"error": "テキストペアをフォルダーに追加できませんでした"
},
"autoSave": "自動保存"
},
"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": "お気に入り順の並べ替えを解除"
},
"exploreDetail": {
"title": "フォルダー詳細",
"createdBy": "作成者: {name}",
"unknownUser": "不明なユーザー",
"totalPairs": "合計ペア数",
"favorites": "お気に入り",
"createdAt": "作成日",
"viewContent": "コンテンツを表示",
"favorite": "お気に入り",
"unfavorite": "お気に入り解除",
"favorited": "お気に入りに追加しました",
"unfavorited": "お気に入りから削除しました",
"pleaseLogin": "まずログインしてください"
},
"favorites": {
"title": "マイお気に入り",
"subtitle": "お気に入りに追加したフォルダー",
"loading": "読み込み中...",
"noFavorites": "まだお気に入りがありません",
"folderInfo": "{userName} • {totalPairs} ペア",
"unknownUser": "不明なユーザー"
},
"user_profile": {
"anonymous": "匿名",
"email": "メールアドレス",
"verified": "認証済み",
"unverified": "未認証",
"accountInfo": "アカウント情報",
"userId": "ユーザーID",
"username": "ユーザー名",
"displayName": "表示名",
"notSet": "未設定",
"memberSince": "登録日",
"logout": "ログアウト",
"folders": {
"title": "フォルダー",
"noFolders": "まだフォルダーがありません",
"folderName": "フォルダー名",
"totalPairs": "合計ペア数",
"createdAt": "作成日",
"actions": "アクション",
"view": "表示"
}
}
}

View File

@@ -1,12 +1,13 @@
{
"alphabet": {
"chooseCharacters": "학습할 문자를 선택하세요",
"chooseCharacters": "배우고 싶은 문자를 선택하세요",
"chooseAlphabetHint": "학습을 시작할 알파벳을 선택하세요",
"japanese": "일본어 가나",
"english": "영 알파벳",
"uyghur": "위구르 문자",
"esperanto": "에스페란토 문자",
"english": "영 알파벳",
"uyghur": "위구르어 알파벳",
"esperanto": "에스페란토 알파벳",
"loading": "로딩 중...",
"loadFailed": "로딩 실패, 다시 시도해 주세요",
"loadFailed": "로딩 실패, 다시 시도해주세요",
"hideLetter": "문자 숨기기",
"showLetter": "문자 표시",
"hideIPA": "IPA 숨기기",
@@ -14,17 +15,36 @@
"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": "이 폴더의 소유자가 아닙니다",
@@ -36,31 +56,39 @@
"noTextPairs": "이 폴더에 텍스트 쌍이 없습니다",
"addNewTextPair": "새 텍스트 쌍 추가",
"add": "추가",
"updateTextPair": "텍스트 쌍 업데이트",
"update": "업데이트",
"updateTextPair": "텍스트 쌍 수정",
"update": "수정",
"text1": "텍스트 1",
"text2": "텍스트 2",
"language1": "언어 1",
"language2": "언어 2",
"language1": "로캘 1",
"language2": "로캘 2",
"enterLanguageName": "언어 이름을 입력하세요",
"edit": "편집",
"delete": "삭제"
"delete": "삭제",
"permissionDenied": "이 작업을 수행할 권한이 없습니다",
"error": {
"update": "이 항목을 수정할 권한이 없습니다.",
"delete": "이 항목을 삭제할 권한이 없습니다.",
"add": "이 폴더에 항목을 추가할 권한이 없습니다.",
"rename": "이 폴더의 이름을 변경할 권한이 없습니다.",
"deleteFolder": "이 폴더를 삭제할 권한이 없습니다."
}
},
"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 비디오 플레이어",
@@ -76,21 +104,24 @@
},
"dictionary": {
"name": "사전",
"description": "단어와 구문을 조회하고 자세한 정의와 예제 제공"
"description": "상세한 정의와 예문으로 단어 및 구문 검색"
},
"moreFeatures": {
"name": "더 많은 기능",
"description": "개발 중, 기대해 주세요"
"description": "개발 중, 기대해주세요"
}
},
"auth": {
"title": "인",
"title": "로그인",
"signUpTitle": "회원가입",
"signIn": "로그인",
"signUp": "회원가입",
"email": "이메일",
"password": "비밀번호",
"confirmPassword": "비밀번호 확인",
"name": "이름",
"username": "사용자명",
"emailOrUsername": "이메일 또는 사용자명",
"signInButton": "로그인",
"signUpButton": "회원가입",
"noAccount": "계정이 없으신가요?",
@@ -101,10 +132,25 @@
"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": "사용자명과 비밀번호를 입력하세요"
},
"memorize": {
"folder_selector": {
@@ -122,7 +168,7 @@
"previous": "이전"
},
"page": {
"unauthorized": "이 폴더에 액세스할 권한이 없습니다"
"unauthorized": "이 폴더에 접근할 권한이 없습니다"
}
},
"navbar": {
@@ -130,7 +176,9 @@
"sourceCode": "GitHub",
"sign_in": "로그인",
"profile": "프로필",
"folders": "폴더"
"folders": "폴더",
"explore": "탐색",
"favorites": "즐겨찾기"
},
"profile": {
"myProfile": "내 프로필",
@@ -144,7 +192,7 @@
"play": "재생",
"previous": "이전",
"next": "다음",
"restart": "처음부터",
"restart": "다시 시작",
"autoPause": "자동 일시정지 ({enabled})",
"uploadVideoAndSubtitle": "비디오와 자막 파일을 업로드하세요",
"uploadVideoFile": "비디오 파일을 업로드하세요",
@@ -156,24 +204,37 @@
"uploaded": "업로드됨",
"notUploaded": "업로드되지 않음",
"upload": "업로드",
"uploadVideoButton": "비디오 업로드",
"uploadSubtitleButton": "자막 업로드",
"subtitleUploaded": "자막 업로드됨 ({count}개 항목)",
"subtitleNotUploaded": "자막 업로드되지 않음",
"autoPauseStatus": "자동 일시정지: {enabled}",
"on": "켜기",
"off": "끄기",
"videoUploadFailed": "비디오 업로드 실패",
"subtitleUploadFailed": "자막 업로드 실패"
"subtitleUploadFailed": "자막 업로드 실패",
"subtitleLoadSuccess": "자막 로드 성공",
"subtitleLoadFailed": "자막 로드 실패"
},
"text_speaker": {
"generateIPA": "IPA 생성",
"viewSavedItems": "저장된 항목 보기",
"confirmDeleteAll": "정말 모두 삭제하시겠습니까? (Y/N)"
"confirmDeleteAll": "모든 것을 삭제하시겠습니까? (Y/N)"
},
"translator": {
"detectLanguage": "언어 감지",
"generateIPA": "IPA 생성",
"translateInto": "번역",
"translateInto": "번역할 언어",
"chinese": "중국어",
"english": "영어",
"french": "프랑스어",
"german": "독일어",
"italian": "이탈리아어",
"japanese": "일본어",
"korean": "한국어",
"portuguese": "포르투갈어",
"russian": "러시아어",
"spanish": "스페인어",
"other": "기타",
"translating": "번역 중...",
"translate": "번역",
@@ -186,37 +247,98 @@
"noFolders": "폴더를 찾을 수 없습니다",
"folderInfo": "{id}. {name}",
"close": "닫기",
"success": "텍스트 쌍 폴더에 추가했습니다",
"error": "텍스트 쌍 추가 실패"
"success": "텍스트 쌍 폴더에 추가",
"error": "폴더에 텍스트 쌍 추가 실패"
},
"autoSave": "자동 저장"
},
"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": "즐겨찾기순 정렬 해제"
},
"exploreDetail": {
"title": "폴더 상세",
"createdBy": "생성자: {name}",
"unknownUser": "알 수 없는 사용자",
"totalPairs": "총 쌍",
"favorites": "즐겨찾기",
"createdAt": "생성일",
"viewContent": "내용 보기",
"favorite": "즐겨찾기",
"unfavorite": "즐겨찾기 해제",
"favorited": "즐겨찾기됨",
"unfavorited": "즐겨찾기 해제됨",
"pleaseLogin": "먼저 로그인해주세요"
},
"favorites": {
"title": "내 즐겨찾기",
"subtitle": "즐겨찾기한 폴더",
"loading": "로딩 중...",
"noFavorites": "아직 즐겨찾기가 없습니다",
"folderInfo": "{userName} • {totalPairs} 쌍",
"unknownUser": "알 수 없는 사용자"
},
"user_profile": {
"anonymous": "익명",
"email": "이메일",
"verified": "인증됨",
"unverified": "미인증",
"accountInfo": "계정 정보",
"userId": "사용자 ID",
"username": "사용자명",
"displayName": "표시 이름",
"notSet": "설정되지 않음",
"memberSince": "가입일",
"logout": "로그아웃",
"folders": {
"title": "폴더",
"noFolders": "아직 폴더가 없습니다",
"folderName": "폴더 이름",
"totalPairs": "총 쌍",
"createdAt": "생성일",
"actions": "작업",
"view": "보기"
}
}
}

View File

@@ -1,114 +1,160 @@
{
"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": "ئاۋۋال تىزىمغا كىرىڭ"
},
"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": "ئۆچۈرۈش"
"delete": "ئۆچۈرۈش",
"permissionDenied": "بۇ مەشغۇلاتنى ئېلىپ بېرىش ھوقۇقىڭىز يوق",
"error": {
"update": "بۇ تۈرنى يېڭىلاش ھوقۇقىڭىز يوق.",
"delete": "بۇ تۈرنى ئۆچۈرۈش ھوقۇقىڭىز يوق.",
"add": "بۇ قىسقۇچقا تۈر قوشۇش ھوقۇقىڭىز يوق.",
"rename": "بۇ قىسقۇچنىڭ ئاتىنى ئۆزگەرتىش ھوقۇقىڭىز يوق.",
"deleteFolder": "بۇ قىسقۇچنى ئۆچۈرۈش ھوقۇقىڭىز يوق."
}
},
"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": "ئىشلەتكۈچى ئاتى ۋە پارول كىرگۈزۈڭ"
},
"memorize": {
"folder_selector": {
"selectFolder": "قىسقۇچ تاللاڭ",
"selectFolder": "بىر قىسقۇچ تاللاڭ",
"noFolders": "قىسقۇچ تېپىلمىدى",
"folderInfo": "{id}. {name} ({count})"
},
@@ -117,106 +163,182 @@
"next": "كېيىنكى",
"reverse": "تەتۈر",
"dictation": "دىكتات",
"noTextPairs": "ئىشلەتكىلى بولىدىغان تېكىست جۈپى يوق",
"disorder": "بەت ئارلاش",
"previous": ىلگىرىكى"
"noTextPairs": "تېكىست جۈپى يوق",
"disorder": "قالايمىقانلاشتۇرۇش",
"previous": الدىنقى"
},
"page": {
"unauthorized": "بۇ قىسقۇچنى زىيارەت قىلىشقا ھوقۇقىڭىز يوق"
"unauthorized": "بۇ قىسقۇچنى زىيارەت قىلىش ھوقۇقىڭىز يوق"
}
},
"navbar": {
"title": "تىل ئۆگىنىش",
"title": "تىل-ئۆگىنىش",
"sourceCode": "GitHub",
"sign_in": "كىرىش",
"profile": "پروفىل",
"folders": "قىسقۇچلار"
"profile": "شەخسىي ئۇچۇر",
"folders": "قىسقۇچلار",
"explore": "ئىزدىنىش",
"favorites": "يىغىپ ساقلانغانلار"
},
"profile": {
"myProfile": "مېنىڭ پروفىلىم",
"myProfile": "شەخسىي ئۇچۇرۇم",
"email": "ئېلخەت: {email}",
"logout": "چىقىش"
"logout": "چىكىنىش"
},
"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": "تر پودكاست يۈكلەش مەغلۇپ بولدى"
},
"text_speaker": {
"generateIPA": "IPA ھاسىل قىلىش",
"viewSavedItems": "ساقلانغان تۈرلەرنى كۆرۈش",
"confirmDeleteAll": "ھەممىنى ئۆچۈرەمسىز؟ (H/Y)"
"confirmDeleteAll": "ھەممىنى ئۆچۈرەمسىز؟ (Y/N)"
},
"translator": {
"detectLanguage": "تىل پەرقلەندۈرۈش",
"generateIPA": "IPA ھاسىل قىلىش",
"detectLanguage": "تىلنى تونۇش",
"generateIPA": "ipa ھاسىل قىلىش",
"translateInto": "تەرجىمە قىلىش",
"chinese": "خەنزۇچە",
"english": "ئىنگلىزچە",
"french": "فىرانسۇزچە",
"german": "گېرمانچە",
"italian": "ئىتاليانچە",
"japanese": "ياپونچە",
"korean": "كورېيەچە",
"portuguese": "پورتۇگالچە",
"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": "ئاپتوماتىك ساقلاش"
},
"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": "يىغىپ ساقلاش بويىچە تەرتىپلەشنى بىكار قىلىش"
},
"exploreDetail": {
"title": "قىسقۇچ تەپسىلاتلىرى",
"createdBy": "قۇرغۇچى: {name}",
"unknownUser": "نامەلۇم ئىشلەتكۈچى",
"totalPairs": "جەمئىي جۈپ",
"favorites": "يىغىپ ساقلانغانلار",
"createdAt": "قۇرۇلغان ۋاقتى",
"viewContent": "مەزمۇننى كۆرۈش",
"favorite": "يىغىپ ساقلا",
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
"favorited": "يىغىپ ساقلاندى",
"unfavorited": "يىغىپ ساقلاش بىكار قىلىندى",
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ"
},
"favorites": {
"title": "يىغىپ ساقلىغانلىرىم",
"subtitle": "يىغىپ ساقلىغان قىسقۇچلىرىڭىز",
"loading": "يۈكلىنىۋاتىدۇ...",
"noFavorites": "تېخى يىغىپ ساقلانمىغان",
"folderInfo": "{userName} • {totalPairs} جۈپ",
"unknownUser": "نامەلۇم ئىشلەتكۈچى"
},
"user_profile": {
"anonymous": "نامسىز",
"email": "ئېلخەت",
"verified": "دەلىللەنگەن",
"unverified": "دەلىللەنمىگەن",
"accountInfo": "ھېسابات ئۇچۇرلىرى",
"userId": "ئىشلەتكۈچى كىملىكى",
"username": "ئىشلەتكۈچى ئاتى",
"displayName": "كۆرسىتىش ئاتى",
"notSet": "تەڭشەلمىگەن",
"memberSince": "ئەزا بولغاندىن بېرى",
"logout": "چىكىنىش",
"folders": {
"title": "قىسقۇچلار",
"noFolders": "تېخى قىسقۇچ يوق",
"folderName": "قىسقۇچ ئاتى",
"totalPairs": "جەمئىي جۈپ",
"createdAt": "قۇرۇلغان ۋاقتى",
"actions": "مەشغۇلاتلار",
"view": "كۆرۈش"
}
}
}

View File

@@ -1,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": "您不是此文件夹的所有者",
@@ -44,7 +64,15 @@
"language2": "语言2",
"enterLanguageName": "请输入语言名称",
"edit": "编辑",
"delete": "删除"
"delete": "删除",
"permissionDenied": "您没有权限执行此操作",
"error": {
"update": "您没有权限更新此项目",
"delete": "您没有权限删除此项目",
"add": "您没有权限向此文件夹添加项目",
"rename": "您没有权限重命名此文件夹",
"deleteFolder": "您没有权限删除此文件夹"
}
},
"home": {
"title": "学语言",
@@ -85,12 +113,15 @@
},
"auth": {
"title": "登录",
"signUpTitle": "注册",
"signIn": "登录",
"signUp": "注册",
"email": "邮箱",
"password": "密码",
"confirmPassword": "确认密码",
"name": "用户名",
"username": "用户名",
"emailOrUsername": "邮箱或用户名",
"signInButton": "登录",
"signUpButton": "注册",
"noAccount": "还没有账户?",
@@ -101,10 +132,25 @@
"passwordTooShort": "密码至少需要8个字符",
"passwordsNotMatch": "两次输入的密码不匹配",
"nameRequired": "请输入用户名",
"usernameRequired": "请输入用户名",
"usernameTooShort": "用户名至少需要3个字符",
"usernameInvalid": "用户名只能包含字母、数字和下划线",
"emailRequired": "请输入邮箱",
"identifierRequired": "请输入邮箱或用户名",
"passwordRequired": "请输入密码",
"confirmPasswordRequired": "请确认密码",
"loading": "加载中..."
"loading": "加载中...",
"confirm": "确认",
"noAccountLink": "没有账号?去注册",
"hasAccountLink": "已有账号?去登录",
"usernamePlaceholder": "用户名",
"emailPlaceholder": "邮箱地址",
"passwordPlaceholder": "密码",
"usernameOrEmailPlaceholder": "用户名或邮箱地址",
"loginFailed": "登录失败",
"signUpFailed": "注册失败",
"fillAllFields": "请填写所有字段",
"enterCredentials": "请输入用户名和密码"
},
"memorize": {
"folder_selector": {
@@ -130,7 +176,9 @@
"sourceCode": "源码",
"sign_in": "登录",
"profile": "个人资料",
"folders": "文件夹"
"folders": "文件夹",
"explore": "探索",
"favorites": "收藏"
},
"profile": {
"myProfile": "我的个人资料",
@@ -156,11 +204,17 @@
"subtitleFile": "字幕文件",
"uploaded": "已上传",
"notUploaded": "未上传",
"uploadVideoButton": "上传视频",
"uploadSubtitleButton": "上传字幕",
"subtitleUploaded": "字幕已上传 ({count} 条)",
"subtitleNotUploaded": "字幕未上传",
"autoPauseStatus": "自动暂停: {enabled}",
"on": "开",
"off": "关",
"videoUploadFailed": "视频上传失败",
"subtitleUploadFailed": "字幕上传失败"
"subtitleUploadFailed": "字幕上传失败",
"subtitleLoadSuccess": "字幕加载成功",
"subtitleLoadFailed": "字幕加载失败"
},
"text_speaker": {
"generateIPA": "生成IPA",
@@ -173,7 +227,14 @@
"translateInto": "翻译为",
"chinese": "中文",
"english": "英文",
"french": "法语",
"german": "德语",
"italian": "意大利语",
"japanese": "日语",
"korean": "韩语",
"portuguese": "葡萄牙语",
"russian": "俄语",
"spanish": "西班牙语",
"other": "其他",
"translating": "翻译中...",
"translate": "翻译",
@@ -203,6 +264,7 @@
"definitionLanguage": "释义语言",
"definitionLanguageHint": "你希望用什么语言查看释义",
"otherLanguagePlaceholder": "或输入其他语言...",
"other": "其他",
"currentSettings": "当前设置:查询 {queryLang},释义 {definitionLang}",
"relookup": "重新查询",
"saveToFolder": "保存到文件夹",
@@ -217,6 +279,66 @@
"pleaseLogin": "请先登录",
"pleaseCreateFolder": "请先创建文件夹",
"savedToFolder": "已保存到文件夹:{folderName}",
"saveFailed": "保存失败,请稍后重试"
"saveFailed": "保存失败,请稍后重试",
"definition": "释义",
"example": "例句"
},
"explore": {
"title": "探索",
"subtitle": "发现公开文件夹",
"searchPlaceholder": "搜索公开文件夹...",
"loading": "加载中...",
"noFolders": "没有找到公开文件夹",
"folderInfo": "{userName} • {totalPairs} 个文本对",
"unknownUser": "未知用户",
"favorite": "收藏",
"unfavorite": "取消收藏",
"pleaseLogin": "请先登录",
"sortByFavorites": "按收藏数排序",
"sortByFavoritesActive": "取消按收藏数排序"
},
"exploreDetail": {
"title": "文件夹详情",
"createdBy": "创建者:{name}",
"unknownUser": "未知用户",
"totalPairs": "词对数量",
"favorites": "收藏数",
"createdAt": "创建时间",
"viewContent": "查看内容",
"favorite": "收藏",
"unfavorite": "取消收藏",
"favorited": "已收藏",
"unfavorited": "已取消收藏",
"pleaseLogin": "请先登录"
},
"favorites": {
"title": "我的收藏",
"subtitle": "收藏的公开文件夹",
"loading": "加载中...",
"noFavorites": "还没有收藏任何文件夹",
"folderInfo": "{userName} • {totalPairs} 个文本对",
"unknownUser": "未知用户"
},
"user_profile": {
"anonymous": "匿名",
"email": "邮箱",
"verified": "已验证",
"unverified": "未验证",
"accountInfo": "账户信息",
"userId": "用户ID",
"username": "用户名",
"displayName": "显示名称",
"notSet": "未设置",
"memberSince": "注册时间",
"logout": "登出",
"folders": {
"title": "文件夹",
"noFolders": "还没有文件夹",
"folderName": "文件夹名称",
"totalPairs": "文本对数量",
"createdAt": "创建时间",
"actions": "操作",
"view": "查看"
}
}
}

View File

@@ -2,7 +2,7 @@
"name": "learn-languages",
"version": "0.1.0",
"private": true,
"license": "GPL-3.0-only",
"license": "AGPL-3.0-only",
"type": "module",
"scripts": {
"dev": "next dev --experimental-https",
@@ -11,10 +11,12 @@
"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",
"lucide-react": "^0.562.0",
"next": "16.1.1",
@@ -23,8 +25,11 @@
"react": "19.2.3",
"react-dom": "19.2.3",
"sonner": "^2.0.7",
"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",
@@ -39,7 +44,7 @@
"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"
},

591
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
-- 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;

View File

@@ -0,0 +1,94 @@
/*
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;

View File

@@ -0,0 +1,12 @@
/*
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");

View File

@@ -0,0 +1,33 @@
-- CreateEnum
CREATE TYPE "Visibility" AS ENUM ('PRIVATE', 'PUBLIC');
-- AlterTable
ALTER TABLE "folders" ADD COLUMN "visibility" "Visibility" NOT NULL DEFAULT 'PRIVATE';
-- 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")
);
-- 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 "folder_favorites_user_id_idx" ON "folder_favorites"("user_id");
-- CreateIndex
CREATE INDEX "folders_visibility_idx" ON "folders"("visibility");
-- 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;

View File

@@ -10,25 +10,27 @@ datasource db {
model User {
id String @id
name String
email String
email String @unique
emailVerified Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
displayUsername String?
username String? @unique
accounts Account[]
folders Folder[]
dictionaryLookUps DictionaryLookUp[]
folders Folder[]
folderFavorites FolderFavorite[]
sessions Session[]
translationHistories TranslationHistory[]
@@unique([email])
@@map("user")
}
model Session {
id String @id
expiresAt DateTime
token String
token String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ipAddress String?
@@ -36,7 +38,6 @@ model Session {
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([token])
@@index([userId])
@@map("session")
}
@@ -46,7 +47,6 @@ model Account {
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
@@ -56,6 +56,7 @@ model Account {
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("account")
@@ -75,16 +76,15 @@ model Verification {
model Pair {
id Int @id @default(autoincrement())
language1 String
language2 String
text1 String
text2 String
language1 String @db.VarChar(20)
language2 String @db.VarChar(20)
ipa1 String?
ipa2 String?
folderId Int @map("folder_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
@@unique([folderId, language1, language2, text1, text2])
@@ -92,20 +92,42 @@ model Pair {
@@map("pairs")
}
enum Visibility {
PRIVATE
PUBLIC
}
model Folder {
id Int @id @default(autoincrement())
name String
userId String @map("user_id")
visibility Visibility @default(PRIVATE)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
pairs Pair[]
favorites FolderFavorite[]
@@index([userId])
@@index([visibility])
@@map("folders")
}
model FolderFavorite {
id Int @id @default(autoincrement())
userId String @map("user_id")
folderId Int @map("folder_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
@@unique([userId, folderId])
@@index([userId])
@@index([folderId])
@@map("folder_favorites")
}
model DictionaryLookUp {
id Int @id @default(autoincrement())
userId String? @map("user_id")
@@ -113,96 +135,62 @@ model DictionaryLookUp {
queryLang String @map("query_lang")
definitionLang String @map("definition_lang")
createdAt DateTime @default(now()) @map("created_at")
dictionaryWordId Int? @map("dictionary_word_id")
dictionaryPhraseId Int? @map("dictionary_phrase_id")
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])
dictionaryWord DictionaryWord? @relation(fields: [dictionaryWordId], references: [id], onDelete: SetNull)
dictionaryPhrase DictionaryPhrase? @relation(fields: [dictionaryPhraseId], references: [id], onDelete: SetNull)
@@index([userId])
@@index([createdAt])
@@index([text, queryLang, definitionLang])
@@index([normalizedText])
@@map("dictionary_lookups")
}
model DictionaryWord {
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[]
entries DictionaryWordEntry[]
@@unique([standardForm, queryLang, definitionLang])
@@index([standardForm])
@@index([queryLang, definitionLang])
@@map("dictionary_words")
@@map("dictionary_items")
}
model DictionaryPhrase {
model DictionaryEntry {
id Int @id @default(autoincrement())
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")
lookups DictionaryLookUp[]
entries DictionaryPhraseEntry[]
@@index([standardForm])
@@index([queryLang, definitionLang])
@@map("dictionary_phrases")
}
model DictionaryWordEntry {
id Int @id @default(autoincrement())
wordId Int @map("word_id")
ipa String
itemId Int @map("item_id")
ipa String?
definition String
partOfSpeech String @map("part_of_speech")
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)
word DictionaryWord @relation(fields: [wordId], references: [id], onDelete: Cascade)
@@index([wordId])
@@index([itemId])
@@index([createdAt])
@@map("dictionary_word_entries")
}
model DictionaryPhraseEntry {
id Int @id @default(autoincrement())
phraseId Int @map("phrase_id")
definition String
example String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
phrase DictionaryPhrase @relation(fields: [phraseId], references: [id], onDelete: Cascade)
@@index([phraseId])
@@index([createdAt])
@@map("dictionary_phrase_entries")
@@map("dictionary_entries")
}
model TranslationHistory {
id Int @id @default(autoincrement())
userId String? @map("user_id")
sourceText String @map("source_text")
sourceLanguage String @map("source_language") @db.VarChar(20)
targetLanguage String @map("target_language") @db.VarChar(20)
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], onDelete: SetNull)
user User? @relation(fields: [userId], references: [id])
@@index([userId])
@@index([createdAt])

View File

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

View File

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

View 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}` : "/folders");
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,75 +1,42 @@
import { DictWordEntry, DictPhraseEntry } from "./types";
import { TSharedEntry } from "@/shared/dictionary-type";
import { useTranslations } from "next-intl";
interface DictionaryEntryProps {
entry: DictWordEntry | DictPhraseEntry;
entry: TSharedEntry;
}
export function DictionaryEntry({ entry }: DictionaryEntryProps) {
// 检查是否有 ipa 字段来判断是否为单词条目
const isWordEntry = "ipa" in entry && "partOfSpeech" in entry;
const t = useTranslations("dictionary");
if (isWordEntry) {
// 单词条目
const wordEntry = entry as DictWordEntry;
return (
<div>
{/* 音标和词性 */}
<div className="flex items-center gap-3 mb-3">
{wordEntry.ipa && (
{entry.ipa && (
<span className="text-gray-600 text-lg">
[{wordEntry.ipa}]
[{entry.ipa}]
</span>
)}
{wordEntry.partOfSpeech && (
{entry.partOfSpeech && (
<span className="px-3 py-1 bg-[#35786f] text-white text-sm rounded-full">
{wordEntry.partOfSpeech}
{entry.partOfSpeech}
</span>
)}
</div>
{/* 释义 */}
<div className="mb-3">
<h3 className="text-sm font-semibold text-gray-700 mb-1">
{t("definition")}
</h3>
<p className="text-gray-800">{wordEntry.definition}</p>
<p className="text-gray-800">{entry.definition}</p>
</div>
{/* 例句 */}
{wordEntry.example && (
{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]">
{wordEntry.example}
</p>
</div>
)}
</div>
);
}
// 短语条目
const phraseEntry = entry as DictPhraseEntry;
return (
<div>
{/* 释义 */}
<div className="mb-3">
<h3 className="text-sm font-semibold text-gray-700 mb-1">
</h3>
<p className="text-gray-800">{phraseEntry.definition}</p>
</div>
{/* 例句 */}
{phraseEntry.example && (
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-1">
</h3>
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
{phraseEntry.example}
{entry.example}
</p>
</div>
)}

View File

@@ -1,141 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import Container from "@/components/ui/Container";
import { lookUp } from "@/lib/server/bigmodel/dictionaryActions";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import { Folder } from "../../../../generated/prisma/browser";
import { getFoldersByUserId } from "@/lib/server/services/folderService";
import { DictLookUpResponse, isDictErrorResponse } from "./types";
import { SearchForm } from "./SearchForm";
import { SearchResult } from "./SearchResult";
import { useTranslations } from "next-intl";
import { POPULAR_LANGUAGES } from "./constants";
export default function Dictionary() {
const t = useTranslations("dictionary");
const [searchQuery, setSearchQuery] = useState("");
const [searchResult, setSearchResult] = useState<DictLookUpResponse | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const [queryLang, setQueryLang] = useState("english");
const [definitionLang, setDefinitionLang] = useState("chinese");
const [selectedFolderId, setSelectedFolderId] = useState<number | null>(null);
const [folders, setFolders] = useState<Folder[]>([]);
const { data: session } = authClient.useSession();
// 加载用户的文件夹列表
useEffect(() => {
if (session) {
getFoldersByUserId(session.user.id as string)
.then((loadedFolders) => {
setFolders(loadedFolders);
// 如果有文件夹且未选择,默认选择第一个
if (loadedFolders.length > 0 && !selectedFolderId) {
setSelectedFolderId(loadedFolders[0].id);
}
});
}
}, [session, selectedFolderId]);
// 将 code 转换为 nativeName
const getNativeName = (code: string) => {
return POPULAR_LANGUAGES.find(l => l.code === code)?.nativeName || code;
};
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
if (!searchQuery.trim()) return;
setIsSearching(true);
setHasSearched(true);
setSearchResult(null);
try {
// 使用查询语言和释义语言的 nativeName
const result = await lookUp({
text: searchQuery,
definitionLang: getNativeName(definitionLang),
queryLang: getNativeName(queryLang),
forceRelook: false
})
// 检查是否为错误响应
if (isDictErrorResponse(result)) {
toast.error(result.error);
setSearchResult(null);
} else {
setSearchResult(result);
}
} catch (error) {
console.error("词典查询失败:", error);
toast.error(t("lookupFailed"));
setSearchResult(null);
} finally {
setIsSearching(false);
}
};
return (
<div className="min-h-[calc(100vh-64px)] bg-[#35786f]">
{/* 搜索区域 */}
<div className="flex items-center justify-center px-4 py-12">
<Container className="max-w-3xl w-full p-4">
<SearchForm
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
isSearching={isSearching}
onSearch={handleSearch}
queryLang={queryLang}
onQueryLangChange={setQueryLang}
definitionLang={definitionLang}
onDefinitionLangChange={setDefinitionLang}
/>
</Container>
</div>
{/* 搜索结果区域 */}
<div className="flex-1 px-4 pb-12">
<Container className="max-w-3xl w-full p-4">
{isSearching && (
<div className="text-center py-8">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
<p className="mt-4 text-white">{t("loading")}</p>
</div>
)}
{!isSearching && hasSearched && !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>
)}
{!isSearching && searchResult && !isDictErrorResponse(searchResult) && (
<SearchResult
searchResult={searchResult}
searchQuery={searchQuery}
queryLang={queryLang}
definitionLang={definitionLang}
folders={folders}
selectedFolderId={selectedFolderId}
onFolderSelect={setSelectedFolderId}
onResultUpdate={setSearchResult}
onSearchingChange={setIsSearching}
getNativeName={getNativeName}
/>
)}
{!hasSearched && (
<div className="text-center py-12 bg-white/20 rounded-lg">
<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>
)}
</Container>
</div>
</div>
);
}

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

View File

@@ -1,129 +0,0 @@
import { LightButton } from "@/components/ui/buttons";
import { POPULAR_LANGUAGES } from "./constants";
import { useTranslations } from "next-intl";
interface SearchFormProps {
searchQuery: string;
onSearchQueryChange: (query: string) => void;
isSearching: boolean;
onSearch: (e: React.FormEvent) => void;
queryLang: string;
onQueryLangChange: (lang: string) => void;
definitionLang: string;
onDefinitionLangChange: (lang: string) => void;
}
export function SearchForm({
searchQuery,
onSearchQueryChange,
isSearching,
onSearch,
queryLang,
onQueryLangChange,
definitionLang,
onDefinitionLangChange,
}: SearchFormProps) {
const t = useTranslations("dictionary");
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={onSearch} className="flex gap-2">
<input
type="text"
value={searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onSearchQueryChange(e.target.value)}
placeholder={t("searchPlaceholder")}
className="flex-1 px-4 py-3 text-lg text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
/>
<LightButton
type="submit"
disabled={isSearching || !searchQuery.trim()}
className="px-6 py-3"
>
{isSearching ? t("searching") : 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 mb-2">
{POPULAR_LANGUAGES.map((lang) => (
<LightButton
key={lang.code}
selected={queryLang === lang.code}
onClick={() => onQueryLangChange(lang.code)}
className="text-sm px-3 py-1"
>
{lang.nativeName}
</LightButton>
))}
</div>
<input
type="text"
value={queryLang}
onChange={(e) => onQueryLangChange(e.target.value)}
placeholder={t("otherLanguagePlaceholder")}
className="w-full px-3 py-2 text-sm text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
/>
</div>
{/* 释义语言 */}
<div>
<label className="block text-gray-700 text-sm mb-2">
{t("definitionLanguage")} ({t("definitionLanguageHint")})
</label>
<div className="flex flex-wrap gap-2 mb-2">
{POPULAR_LANGUAGES.map((lang) => (
<LightButton
key={lang.code}
selected={definitionLang === lang.code}
onClick={() => onDefinitionLangChange(lang.code)}
className="text-sm px-3 py-1"
>
{lang.nativeName}
</LightButton>
))}
</div>
<input
type="text"
value={definitionLang}
onChange={(e) => onDefinitionLangChange(e.target.value)}
placeholder={t("otherLanguagePlaceholder")}
className="w-full px-3 py-2 text-sm text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
/>
</div>
{/* 当前设置显示 */}
<div className="text-center text-gray-700 text-sm pt-2 border-t border-gray-300">
{t("currentSettings", {
queryLang: POPULAR_LANGUAGES.find(l => l.code === queryLang)?.nativeName || queryLang,
definitionLang: POPULAR_LANGUAGES.find(l => l.code === definitionLang)?.nativeName || definitionLang
})}
</div>
</div>
</div>
</>
);
}

View File

@@ -1,155 +0,0 @@
import { Plus, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import { Folder } from "../../../../generated/prisma/browser";
import { createPair } from "@/lib/server/services/pairService";
import { lookUp } from "@/lib/server/bigmodel/dictionaryActions";
import {
DictWordResponse,
DictPhraseResponse,
isDictWordResponse,
DictWordEntry,
isDictErrorResponse,
} from "./types";
import { DictionaryEntry } from "./DictionaryEntry";
import { POPULAR_LANGUAGES } from "./constants";
import { useTranslations } from "next-intl";
interface SearchResultProps {
searchResult: DictWordResponse | DictPhraseResponse;
searchQuery: string;
queryLang: string;
definitionLang: string;
folders: Folder[];
selectedFolderId: number | null;
onFolderSelect: (folderId: number | null) => void;
onResultUpdate: (newResult: DictWordResponse | DictPhraseResponse) => void;
onSearchingChange: (isSearching: boolean) => void;
getNativeName: (code: string) => string;
}
export function SearchResult({
searchResult,
searchQuery,
queryLang,
definitionLang,
folders,
selectedFolderId,
onFolderSelect,
onResultUpdate,
onSearchingChange,
getNativeName,
}: SearchResultProps) {
const t = useTranslations("dictionary");
const { data: session } = authClient.useSession();
const handleRelookup = async () => {
onSearchingChange(true);
try {
const result = await lookUp({
text: searchQuery,
definitionLang: getNativeName(definitionLang),
queryLang: getNativeName(queryLang),
forceRelook: true
});
if (isDictErrorResponse(result)) {
toast.error(result.error);
} else {
onResultUpdate(result);
toast.success(t("relookupSuccess"));
}
} catch (error) {
console.error("词典重新查询失败:", error);
toast.error(t("lookupFailed"));
} finally {
onSearchingChange(false);
}
};
const handleSave = () => {
if (!session) {
toast.error(t("pleaseLogin"));
return;
}
if (!selectedFolderId) {
toast.error(t("pleaseCreateFolder"));
return;
}
const entry = searchResult.entries[0];
createPair({
text1: searchResult.standardForm,
text2: entry.definition,
language1: queryLang,
language2: definitionLang,
ipa1: isDictWordResponse(searchResult) && (entry as DictWordEntry).ipa ? (entry as DictWordEntry).ipa : undefined,
folderId: selectedFolderId,
})
.then(() => {
const folderName = folders.find(f => f.id === selectedFolderId)?.name || "Unknown";
toast.success(t("savedToFolder", { folderName }));
})
.catch(() => {
toast.error(t("saveFailed"));
});
};
return (
<div className="space-y-6">
<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
value={selectedFolderId || ""}
onChange={(e) => onFolderSelect(e.target.value ? Number(e.target.value) : null)}
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>
)}
<button
onClick={handleSave}
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-10 h-10 flex justify-center items-center shrink-0"
title={t("saveToFolder")}
>
<Plus />
</button>
</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">
<button
onClick={handleRelookup}
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
>
<RefreshCw className="w-4 h-4" />
{t("relookup")}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,11 +0,0 @@
// 类型定义
export * from "./types";
// 常量
export * from "./constants";
// 组件
export { default as DictionaryPage } from "./DictionaryPage";
export { SearchForm } from "./SearchForm";
export { SearchResult } from "./SearchResult";
export { DictionaryEntry } from "./DictionaryEntry";

View File

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

View File

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

View File

@@ -1,2 +0,0 @@
// 从 shared 文件夹导出所有词典类型和类型守卫
export * from "@/lib/shared";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,21 @@
"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/server/bigmodel/tts";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
import { useTranslations } from "next-intl";
import localFont from "next/font/local";
import { isNonNegativeInteger, SeededRandom } from "@/lib/utils";
import { Pair } from "../../../../generated/prisma/browser";
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: Pair[];
textPairs: TSharedPair[];
}
const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
@@ -27,11 +29,9 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
if (textPairs.length === 0) {
return (
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
<p className="text-gray-700">{t("noTextPairs")}</p>
</div>
</div>
<PageLayout>
<p className="text-gray-700 text-center">{t("noTextPairs")}</p>
</PageLayout>
);
}
@@ -112,17 +112,12 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
: [getTextPairs()[index].text1, getTextPairs()[index].text2];
return (
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4 py-8">
<div className="w-full max-w-2xl">
<div className="bg-white rounded-2xl shadow-xl p-6 md:p-8">
<PageLayout>
{/* 进度指示器 */}
<div className="flex justify-center mb-4">
<button
onClick={handleIndexClick}
className="text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
<LinkButton onClick={handleIndexClick} className="text-sm">
{index + 1} / {getTextPairs().length}
</button>
</LinkButton>
</div>
{/* 文本显示区域 */}
@@ -162,53 +157,39 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
{/* 底部按钮 */}
<div className="flex flex-row gap-2 items-center justify-center flex-wrap">
<button
<LightButton
onClick={handleNext}
className="px-4 py-2 rounded-full bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors text-sm"
className="px-4 py-2 rounded-full text-sm"
>
{show === "question" ? t("answer") : t("next")}
</button>
<button
</LightButton>
<LightButton
onClick={handlePrevious}
className="px-4 py-2 rounded-full bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors text-sm"
className="px-4 py-2 rounded-full text-sm"
>
{t("previous")}
</button>
<button
</LightButton>
<CircleToggleButton
selected={reverse}
onClick={toggleReverse}
className={`px-4 py-2 rounded-full transition-colors text-sm ${
reverse
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
{t("reverse")}
</button>
<button
</CircleToggleButton>
<CircleToggleButton
selected={dictation}
onClick={toggleDictation}
className={`px-4 py-2 rounded-full transition-colors text-sm ${
dictation
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
{t("dictation")}
</button>
<button
</CircleToggleButton>
<CircleToggleButton
selected={disorder}
onClick={toggleDisorder}
className={`px-4 py-2 rounded-full transition-colors text-sm ${
disorder
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
{t("disorder")}
</button>
</div>
</div>
</div>
</CircleToggleButton>
</div>
</PageLayout>
);
};
export default Memorize;
export { Memorize };

View File

@@ -1,14 +1,11 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import {
getFoldersWithTotalPairsByUserId,
} from "@/lib/server/services/folderService";
import { isNonNegativeInteger } from "@/lib/utils";
import FolderSelector from "./FolderSelector";
import Memorize from "./Memorize";
import { getPairsByFolderId } from "@/lib/server/services/pairService";
import { 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-action";
export default async function MemorizePage({
searchParams,
@@ -27,13 +24,14 @@ export default async function MemorizePage({
if (!folder_id) {
const session = await auth.api.getSession({ headers: await headers() });
if(!session) redirect("/auth?redirect=/memorize")
if (!session) redirect("/login?redirect=/memorize");
return (
<FolderSelector
folders={await getFoldersWithTotalPairsByUserId(session.user.id)}
folders={(await actionGetFoldersWithTotalPairsByUserId(session.user.id)).data!}
/>
);
}
return <Memorize textPairs={await getPairsByFolderId(folder_id)} />;
return <Memorize textPairs={(await actionGetPairsByFolderId(folder_id)).data!} />;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,45 +0,0 @@
"use client";
import React, { useRef } from "react";
import { FileInputProps } from "../../types/controls";
interface FileInputComponentProps extends FileInputProps {
children: React.ReactNode;
}
export default function FileInput({ accept, onFileSelect, disabled, className, children }: FileInputComponentProps) {
const inputRef = useRef<HTMLInputElement>(null);
const handleClick = React.useCallback(() => {
if (!disabled && inputRef.current) {
inputRef.current.click();
}
}, [disabled]);
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
onFileSelect(file);
}
}, [onFileSelect]);
return (
<>
<input
ref={inputRef}
type="file"
accept={accept}
onChange={handleChange}
disabled={disabled}
className="hidden"
/>
<button
onClick={handleClick}
disabled={disabled}
className={`px-2 py-1 rounded shadow font-bold hover:cursor-pointer hover:bg-gray-200 text-gray-800 bg-white ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className || ''}`}
>
{children}
</button>
</>
);
}

View File

@@ -1,20 +0,0 @@
"use client";
import React from "react";
import { useTranslations } from "next-intl";
import { LightButton } from "@/components/ui/buttons";
import { PlayButtonProps } from "../../types/player";
export default function PlayButton({ isPlaying, onToggle, disabled, className }: PlayButtonProps) {
const t = useTranslations("srt_player");
return (
<LightButton
onClick={disabled ? undefined : onToggle}
disabled={disabled}
className={`px-4 py-2 ${className || ''}`}
>
{isPlaying ? t("pause") : t("play")}
</LightButton>
);
}

View File

@@ -1,26 +0,0 @@
"use client";
import React from "react";
import { SeekBarProps } from "../../types/player";
export default function SeekBar({ value, max, onChange, disabled, className }: SeekBarProps) {
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = parseInt(event.target.value);
onChange(newValue);
}, [onChange]);
return (
<input
type="range"
min={0}
max={max}
value={value}
onChange={handleChange}
disabled={disabled}
className={`w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className || ''}`}
style={{
background: `linear-gradient(to right, #374151 0%, #374151 ${(value / max) * 100}%, #e5e7eb ${(value / max) * 100}%, #e5e7eb 100%)`
}}
/>
);
}

View File

@@ -1,25 +0,0 @@
"use client";
import React from "react";
import { LightButton } from "@/components/ui/buttons";
import { SpeedControlProps } from "../../types/player";
import { getPlaybackRateOptions, getPlaybackRateLabel } from "../../utils/timeUtils";
export default function SpeedControl({ playbackRate, onPlaybackRateChange, disabled, className }: SpeedControlProps) {
const speedOptions = getPlaybackRateOptions();
const handleSpeedChange = React.useCallback(() => {
const currentIndex = speedOptions.indexOf(playbackRate);
const nextIndex = (currentIndex + 1) % speedOptions.length;
onPlaybackRateChange(speedOptions[nextIndex]);
}, [playbackRate, onPlaybackRateChange, speedOptions]);
return (
<LightButton
onClick={disabled ? undefined : handleSpeedChange}
className={`${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className || ''}`}
>
{getPlaybackRateLabel(playbackRate)}
</LightButton>
);
}

View File

@@ -1,44 +0,0 @@
"use client";
import React from "react";
import { SubtitleTextProps } from "../../types/subtitle";
export default function SubtitleText({ text, onWordClick, style, className }: SubtitleTextProps) {
const handleWordClick = React.useCallback((word: string) => {
onWordClick?.(word);
}, [onWordClick]);
// 将文本分割成单词,保持标点符号
const renderTextWithClickableWords = () => {
if (!text) return null;
// 匹配单词和标点符号
const parts = text.match(/[\w']+|[^\w\s]+|\s+/g) || [];
return parts.map((part, index) => {
// 如果是单词(字母和撇号组成)
if (/^[\w']+$/.test(part)) {
return (
<span
key={index}
onClick={() => handleWordClick(part)}
className="hover:bg-gray-700 hover:underline hover:cursor-pointer rounded px-1 transition-colors"
>
{part}
</span>
);
}
// 如果是空格或其他字符,直接渲染
return <span key={index}>{part}</span>;
});
};
return (
<div
className={`overflow-auto h-16 mt-2 wrap-break-words font-sans text-white text-center text-2xl ${className || ''}`}
style={style}
>
{renderTextWithClickableWords()}
</div>
);
}

View File

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

View File

@@ -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 "@/components/ui/buttons";
import { ControlBarProps } from "../../types/controls";
import PlayButton from "../atoms/PlayButton";
import SpeedControl from "../atoms/SpeedControl";
export default function ControlBar({
isPlaying,
onPlayPause,
onPrevious,
onNext,
onRestart,
playbackRate,
onPlaybackRateChange,
autoPause,
onAutoPauseToggle,
disabled,
className
}: ControlBarProps) {
const t = useTranslations("srt_player");
return (
<div className={`flex flex-wrap gap-2 justify-center ${className || ''}`}>
<PlayButton
isPlaying={isPlaying}
onToggle={onPlayPause}
disabled={disabled}
/>
<LightButton
onClick={disabled ? undefined : onPrevious}
disabled={disabled}
className="flex items-center px-3 py-2"
>
<ChevronLeft className="w-4 h-4 mr-2" />
{t("previous")}
</LightButton>
<LightButton
onClick={disabled ? undefined : onNext}
disabled={disabled}
className="flex items-center px-3 py-2"
>
{t("next")}
<ChevronRight className="w-4 h-4 ml-2" />
</LightButton>
<LightButton
onClick={disabled ? undefined : onRestart}
disabled={disabled}
className="flex items-center px-3 py-2"
>
<RotateCcw className="w-4 h-4 mr-2" />
{t("restart")}
</LightButton>
<SpeedControl
playbackRate={playbackRate}
onPlaybackRateChange={onPlaybackRateChange}
disabled={disabled}
/>
<LightButton
onClick={disabled ? undefined : onAutoPauseToggle}
disabled={disabled}
className="flex items-center px-3 py-2"
>
<Pause className="w-4 h-4 mr-2" />
{t("autoPause", { enabled: autoPause ? t("on") : t("off") })}
</LightButton>
</div>
);
}

View File

@@ -1,37 +0,0 @@
"use client";
import React from "react";
import { SubtitleDisplayProps } from "../../types/subtitle";
import SubtitleText from "../atoms/SubtitleText";
export default function SubtitleArea({ subtitle, onWordClick, settings, className }: SubtitleDisplayProps) {
const handleWordClick = React.useCallback((word: string) => {
// 打开有道词典页面查询单词
window.open(
`https://www.youdao.com/result?word=${encodeURIComponent(word)}&lang=en`,
"_blank"
);
onWordClick?.(word);
}, [onWordClick]);
const subtitleStyle = React.useMemo(() => {
if (!settings) return { backgroundColor: 'rgba(0, 0, 0, 0.5)' };
return {
backgroundColor: settings.backgroundColor,
color: settings.textColor,
fontSize: `${settings.fontSize}px`,
fontFamily: settings.fontFamily,
opacity: settings.opacity,
};
}, [settings]);
return (
<SubtitleText
text={subtitle}
onWordClick={handleWordClick}
style={subtitleStyle}
className={className}
/>
);
}

View File

@@ -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 "@/components/ui/buttons";
import { FileUploadProps } from "../../types/controls";
import { useFileUpload } from "../../hooks/useFileUpload";
export default function UploadZone({ onVideoUpload, onSubtitleUpload, className }: FileUploadProps) {
const t = useTranslations("srt_player");
const { uploadVideo, uploadSubtitle } = useFileUpload();
const handleVideoUpload = React.useCallback(() => {
uploadVideo(onVideoUpload, (error) => {
toast.error(t("videoUploadFailed") + ": " + error.message);
});
}, [uploadVideo, onVideoUpload, t]);
const handleSubtitleUpload = React.useCallback(() => {
uploadSubtitle(onSubtitleUpload, (error) => {
toast.error(t("subtitleUploadFailed") + ": " + error.message);
});
}, [uploadSubtitle, onSubtitleUpload, t]);
return (
<div className={`flex gap-3 ${className || ''}`}>
<LightButton
onClick={handleVideoUpload}
className="flex-1 py-2 px-3 text-sm"
>
<Video className="w-4 h-4 mr-2" />
{t("uploadVideo")}
</LightButton>
<LightButton
onClick={handleSubtitleUpload}
className="flex-1 py-2 px-3 text-sm"
>
<FileText className="w-4 h-4 mr-2" />
{t("uploadSubtitle")}
</LightButton>
</div>
);
}

View File

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

View File

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

View File

@@ -1,16 +1,67 @@
"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) => {
export function useSrtPlayerShortcuts(enabled: boolean = true) {
const togglePlayPause = useSrtPlayerStore((state) => state.togglePlayPause);
const nextSubtitle = useSrtPlayerStore((state) => state.nextSubtitle);
const previousSubtitle = useSrtPlayerStore((state) => state.previousSubtitle);
const restartSubtitle = useSrtPlayerStore((state) => state.restartSubtitle);
const toggleAutoPause = useSrtPlayerStore((state) => state.toggleAutoPause);
useEffect(() => {
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (!enabled) return;
// 防止在输入框中触发快捷键
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
return;
}
switch (event.key) {
case ' ':
event.preventDefault();
togglePlayPause();
break;
case 'n':
case 'N':
event.preventDefault();
nextSubtitle();
break;
case 'p':
case 'P':
event.preventDefault();
previousSubtitle();
break;
case 'r':
case 'R':
event.preventDefault();
restartSubtitle();
break;
case 'a':
case 'A':
event.preventDefault();
toggleAutoPause();
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [enabled, togglePlayPause, nextSubtitle, previousSubtitle, restartSubtitle, toggleAutoPause]);
}
export function useKeyboardShortcuts(
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;
@@ -21,48 +72,11 @@ export function useKeyboardShortcuts(
event.preventDefault();
shortcut.action();
}
}, [shortcuts, enabled]);
};
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
}
export function createSrtPlayerShortcuts(
playPause: () => void,
next: () => void,
previous: () => void,
restart: () => void,
toggleAutoPause: () => void
): KeyboardShortcut[] {
return [
{
key: ' ',
description: '播放/暂停',
action: playPause,
},
{
key: 'n',
description: '下一句',
action: next,
},
{
key: 'p',
description: '上一句',
action: previous,
},
{
key: 'r',
description: '句首',
action: restart,
},
{
key: 'a',
description: '切换自动暂停',
action: toggleAutoPause,
},
];
}, [shortcuts, isEnabled]);
}

View File

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

View File

@@ -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;
const setCurrentSubtitle = useSrtPlayerStore((state) => state.setCurrentSubtitle);
const pause = useSrtPlayerStore((state) => state.pause);
// 如果时间早于第一个字幕开始时间
if (time < subtitles[0].start) return null;
const scheduleAutoPause = useCallback(() => {
if (!autoPause || !isPlaying) {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
return;
}
// 如果时间晚于最后一个字幕结束时间
if (time > subtitles[subtitles.length - 1].end) return subtitles.length - 1;
const currentTimeNow = useSrtPlayerStore.getState().video.currentTime;
const currentIndexNow = useSrtPlayerStore.getState().subtitle.currentIndex;
// 二分查找找到当前时间对应的字幕
let left = 0;
let right = subtitles.length - 1;
if (currentIndexNow === null || !subtitleData[currentIndexNow]) {
return;
}
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const subtitle = subtitles[mid];
const subtitle = subtitleData[currentIndexNow];
const timeUntilEnd = subtitle.end - currentTimeNow;
if (time >= subtitle.start && time <= subtitle.end) {
return mid;
} else if (time < subtitle.start) {
right = mid - 1;
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);
}
}
}, [subtitleData, currentTime, setCurrentSubtitle]);
// 如果没有找到完全匹配的字幕,返回最近的字幕索引
return right >= 0 ? right : null;
}, [subtitles]);
// 检查是否需要自动暂停
const shouldAutoPause = useCallback((subtitle: SubtitleEntry, time: number): boolean => {
return autoPause &&
time >= subtitle.end - 0.2 && // 增加时间窗口,确保自动暂停更可靠
time < subtitle.end;
}, [autoPause]);
// 启动/停止同步循环
useEffect(() => {
const syncSubtitles = () => {
const currentSubtitle = getCurrentSubtitle(currentTime);
scheduleAutoPause();
}, [isPlaying, autoPause]);
// 检查字幕是否发生变化
if (currentSubtitle !== lastSubtitleRef.current) {
const previousSubtitle = lastSubtitleRef.current;
lastSubtitleRef.current = currentSubtitle;
// 只有当有当前字幕时才调用onSubtitleChange
// 在字幕间隙时保持之前的字幕索引避免进度条跳到0
if (currentSubtitle) {
onSubtitleChange(currentSubtitle);
}
}
// 检查是否需要自动暂停
// 每次都检查,不只在字幕变化时检查
if (currentSubtitle && shouldAutoPause(currentSubtitle, currentTime)) {
onAutoPauseTrigger?.(currentSubtitle);
} else if (!currentSubtitle && lastSubtitleRef.current && shouldAutoPause(lastSubtitleRef.current, currentTime)) {
// 在字幕结束时,如果前一个字幕需要自动暂停,也要触发
onAutoPauseTrigger?.(lastSubtitleRef.current);
}
rafIdRef.current = requestAnimationFrame(syncSubtitles);
};
if (subtitles.length > 0) {
rafIdRef.current = requestAnimationFrame(syncSubtitles);
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,
};
}, []);
}

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

View File

@@ -1,115 +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 { useSrtPlayer } from "./hooks/useSrtPlayer";
import { PageLayout } from "@/components/ui/PageLayout";
import { LightButton } from "@/design-system/base/button";
import { HStack } from "@/design-system/layout/stack";
import { Video, FileText, ChevronLeft, ChevronRight, RotateCcw, Pause, Play } from "lucide-react";
import { useVideoSync } from "./hooks/useVideoSync";
import { useSubtitleSync } from "./hooks/useSubtitleSync";
import { useKeyboardShortcuts, createSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
import { useFileUpload } from "./hooks/useFileUpload";
import { useSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
import { loadSubtitle } from "./utils/subtitleParser";
import VideoPlayer from "./components/compounds/VideoPlayer";
import SubtitleArea from "./components/compounds/SubtitleArea";
import ControlBar from "./components/compounds/ControlBar";
import UploadZone from "./components/compounds/UploadZone";
import SeekBar from "./components/atoms/SeekBar";
import { LightButton } from "@/components/ui/buttons";
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(t('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(t('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 (
<div className="min-h-screen bg-gray-50">
<div className="container mx-auto px-4 py-8">
<div className="max-w-6xl mx-auto">
{/* 标题区域 */}
<PageLayout>
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-800 mb-2">
{t("srtPlayer.name")}
@@ -119,162 +102,78 @@ export default function SrtPlayerPage() {
</p>
</div>
{/* 主要内容区域 */}
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
{/* 视频播放器区域 */}
<div className="aspect-video bg-black relative">
{(!state.video.url || !state.subtitle.url || state.subtitle.data.length === 0) && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-75 z-10">
<div className="text-center text-white">
<p className="text-lg mb-2">
{!state.video.url && !state.subtitle.url
? srtT("uploadVideoAndSubtitle")
: !state.video.url
? srtT("uploadVideoFile")
: !state.subtitle.url
? srtT("uploadSubtitleFile")
: srtT("processingSubtitle")
}
</p>
{(!state.video.url || !state.subtitle.url) && (
<p className="text-sm text-gray-300">
{srtT("needBothFiles")}
</p>
)}
</div>
</div>
)}
{state.video.url && (
<VideoPlayer
<video
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"
width="85%"
className="mx-auto"
playsInline
/>
)}
</VideoPlayer>
)}
<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">
{/* 上传区域和状态指示器 */}
<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 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>
<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")}
<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>
<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")}
{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>
</div>
</div>
</div>
</div>
{/* 控制按钮和进度条 */}
<div className={`space-y-4 ${canPlay ? '' : 'opacity-50 pointer-events-none'}`}>
{/* 控制按钮 */}
<ControlBar
isPlaying={state.video.isPlaying}
onPlayPause={actions.togglePlayPause}
onPrevious={actions.previousSubtitle}
onNext={actions.nextSubtitle}
onRestart={actions.restartSubtitle}
playbackRate={state.video.playbackRate}
onPlaybackRateChange={actions.setPlaybackRate}
autoPause={state.controls.autoPause}
onAutoPauseToggle={actions.toggleAutoPause}
disabled={!canPlay}
className="justify-center"
/>
{/* 进度条 */}
<div className="space-y-2">
<SeekBar
value={state.subtitle.currentIndex ?? 0}
max={Math.max(0, state.subtitle.data.length - 1)}
onChange={handleSeek}
disabled={!canPlay}
className="h-3"
/>
{/* 字幕进度显示 */}
<div className="flex justify-between items-center text-sm text-gray-600 px-2">
<span>
{state.subtitle.currentIndex !== null ?
`${state.subtitle.currentIndex + 1}/${state.subtitle.data.length}` :
'0/0'
}
</span>
<div className="flex items-center gap-4">
{/* 播放速度显示 */}
<span className="bg-gray-200 px-2 py-1 rounded text-xs">
{state.video.playbackRate}x
</span>
{/* 自动暂停状态 */}
<span className={`px-2 py-1 rounded text-xs ${state.controls.autoPause
? 'bg-gray-800 text-white'
: 'bg-gray-100 text-gray-600'
}`}>
{srtT("autoPauseStatus", { enabled: state.controls.autoPause ? srtT("on") : srtT("off") })}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
) : (
<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>
);
}

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import { SubtitleEntry } from "../types/subtitle";
import { logger } from "@/lib/logger";
import { SubtitleEntry } from "../types";
export function parseSrt(data: string): SubtitleEntry[] {
const lines = data.split(/\r?\n/);
@@ -63,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(
@@ -94,7 +92,7 @@ export async function loadSubtitle(url: string): Promise<SubtitleEntry[]> {
const data = await response.text();
return parseSrt(data);
} catch (error) {
logger.error('加载字幕失败', error);
console.error('加载字幕失败', error);
return [];
}
}

View File

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

View File

@@ -6,8 +6,8 @@ import {
TextSpeakerArraySchema,
TextSpeakerItemSchema,
} from "@/lib/interfaces";
import { IconClick } from "@/components/ui/buttons";
import IMAGES from "@/config/images";
import { IconClick } from "@/design-system/base/button";
import { IMAGES } from "@/config/images";
import { useTranslations } from "next-intl";
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
@@ -24,7 +24,7 @@ function TextCard({ item, handleUse, handleDel }: TextCardProps) {
handleDel(item);
};
return (
<div className="p-2 border-b border-gray-200 rounded-2xl bg-gray-100 m-2 grid grid-cols-8">
<div className="p-2 border-b border-gray-200 rounded-lg bg-gray-100 m-2 grid grid-cols-8">
<div className="col-span-7" onClick={onUseClick}>
<div className="max-h-26 hover:cursor-pointer text-3xl overflow-y-auto">
{item.text}
@@ -39,7 +39,7 @@ function TextCard({ item, handleUse, handleDel }: TextCardProps) {
alt="delete"
onClick={onDelClick}
className="place-self-center"
size={42}
size="lg"
></IconClick>
</div>
</div>
@@ -50,7 +50,7 @@ interface SaveListProps {
show?: boolean;
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
}
export default function SaveList({ show = false, handleUse }: SaveListProps) {
export function SaveList({ show = false, handleUse }: SaveListProps) {
const t = useTranslations("text_speaker");
const { get: getFromLocalStorage, set: setIntoLocalStorage } =
getLocalStorageOperator<typeof TextSpeakerArraySchema>(
@@ -60,11 +60,12 @@ export default 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 default 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-2xl"
style={{ fontFamily: "Times New Roman, serif" }}
className="my-4 p-2 mx-4 md:mx-32 border border-gray-200 rounded-lg"
>
<div className="flex flex-row justify-center gap-8 items-center">
<IconClick
src={IMAGES.refresh}
alt="refresh"
onClick={refresh}
size={48}
className=""
></IconClick>
<IconClick
src={IMAGES.delete}
alt="delete"
<div className="flex justify-between items-center mb-2">
<p className="text-sm text-gray-600">{t("saved")}</p>
<button
onClick={handleDeleteAll}
size={48}
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>

View File

@@ -1,8 +1,8 @@
"use client";
import { LightButton } from "@/components/ui/buttons";
import { IconClick } from "@/components/ui/buttons";
import IMAGES from "@/config/images";
import { LightButton } from "@/design-system/base/button";
import { IconClick } from "@/design-system/base/button";
import { IMAGES } from "@/config/images";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import {
TextSpeakerArraySchema,
@@ -10,14 +10,13 @@ import {
} from "@/lib/interfaces";
import { ChangeEvent, useEffect, useRef, useState } from "react";
import z from "zod";
import SaveList from "./SaveList";
import { SaveList } from "./SaveList";
import { useTranslations } from "next-intl";
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
import { genIPA, genLanguage } from "@/lib/server/bigmodel/translatorActions";
import { logger } from "@/lib/logger";
import PageLayout from "@/components/ui/PageLayout";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/server/bigmodel/tts";
import { genIPA, genLanguage } from "@/modules/translator/translator-action";
import { PageLayout } from "@/components/ui/PageLayout";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
export default function TextSpeakerPage() {
const t = useTranslations("text_speaker");
@@ -49,8 +48,8 @@ export default function TextSpeakerPage() {
const handleEnded = () => {
if (autopause) {
setPause(true);
} else {
load(objurlRef.current!);
} else if (objurlRef.current) {
load(objurlRef.current);
play();
}
};
@@ -75,7 +74,7 @@ export default function TextSpeakerPage() {
setIPA(data.ipa);
})
.catch((e) => {
logger.error("生成 IPA 失败", e);
console.error("生成 IPA 失败", e);
setIPA("");
});
}
@@ -120,7 +119,7 @@ export default function TextSpeakerPage() {
load(objurlRef.current);
play();
} catch (e) {
logger.error("播放音频失败", e);
console.error("播放音频失败", e);
setPause(true);
setLanguage(null);
setProcessing(false);
@@ -188,7 +187,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];
@@ -212,7 +211,7 @@ export default function TextSpeakerPage() {
}
setIntoLocalStorage(save);
} catch (e) {
logger.error("保存到本地存储失败", e);
console.error("保存到本地存储失败", e);
setLanguage(null);
} finally {
setSaving(false);
@@ -223,7 +222,7 @@ export default function TextSpeakerPage() {
<PageLayout className="items-start py-4">
{/* 文本输入区域 */}
<div
className="border border-gray-200 rounded-2xl"
className="border border-gray-200 rounded-lg"
style={{ fontFamily: "Times New Roman, serif" }}
>
{/* 文本输入框 */}
@@ -243,37 +242,37 @@ export default function TextSpeakerPage() {
<div className="p-4 relative w-full flex flex-row flex-wrap gap-2 justify-center items-center">
{/* 速度调节面板 */}
{showSpeedAdjust && (
<div className="bg-white p-6 rounded-2xl border-gray-200 border-2 shadow-2xl absolute left-1/2 -translate-x-1/2 -translate-y-full -top-4 flex flex-row flex-wrap gap-2 justify-center items-center z-10">
<div className="bg-white p-6 rounded-lg border-gray-200 border-2 shadow-2xl absolute left-1/2 -translate-x-1/2 -translate-y-full -top-4 flex flex-row flex-wrap gap-2 justify-center items-center z-10">
<IconClick
size={45}
size="lg"
onClick={letMeSetSpeed(0.5)}
src={IMAGES.speed_0_5x}
alt="0.5x"
className={speed === 0.5 ? "bg-gray-200" : ""}
></IconClick>
<IconClick
size={45}
size="lg"
onClick={letMeSetSpeed(0.7)}
src={IMAGES.speed_0_7x}
alt="0.7x"
className={speed === 0.7 ? "bg-gray-200" : ""}
></IconClick>
<IconClick
size={45}
size="lg"
onClick={letMeSetSpeed(1)}
src={IMAGES.speed_1x}
alt="1x"
className={speed === 1 ? "bg-gray-200" : ""}
></IconClick>
<IconClick
size={45}
size="lg"
onClick={letMeSetSpeed(1.2)}
src={IMAGES.speed_1_2_x}
alt="1.2x"
className={speed === 1.2 ? "bg-gray-200" : ""}
></IconClick>
<IconClick
size={45}
size="lg"
onClick={letMeSetSpeed(1.5)}
src={IMAGES.speed_1_5x}
alt="1.5x"
@@ -283,7 +282,7 @@ export default function TextSpeakerPage() {
)}
{/* 播放/暂停按钮 */}
<IconClick
size={45}
size="lg"
onClick={speak}
src={pause ? IMAGES.play_arrow : IMAGES.pause}
alt="playorpause"
@@ -291,10 +290,10 @@ export default function TextSpeakerPage() {
></IconClick>
{/* 自动暂停按钮 */}
<IconClick
size={45}
size="lg"
onClick={() => {
setAutopause(!autopause);
if (objurlRef) {
if (objurlRef.current) {
stop();
}
setPause(true);
@@ -304,7 +303,7 @@ export default function TextSpeakerPage() {
></IconClick>
{/* 速度调节按钮 */}
<IconClick
size={45}
size="lg"
onClick={() => setShowSpeedAdjust(!showSpeedAdjust)}
src={IMAGES.speed}
alt="speed"
@@ -312,7 +311,7 @@ export default function TextSpeakerPage() {
></IconClick>
{/* 保存按钮 */}
<IconClick
size={45}
size="lg"
onClick={save}
src={IMAGES.save}
alt="save"
@@ -339,7 +338,7 @@ export default function TextSpeakerPage() {
</div>
{/* 保存列表 */}
{showSaveList && (
<div className="mt-4 border border-gray-200 rounded-2xl overflow-hidden">
<div className="mt-4 border border-gray-200 rounded-lg overflow-hidden">
<SaveList show={showSaveList} handleUse={handleUseItem}></SaveList>
</div>
)}

View File

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

View File

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

View File

@@ -1,32 +1,21 @@
"use client";
import { LightButton } from "@/components/ui/buttons";
import { IconClick } from "@/components/ui/buttons";
import IMAGES from "@/config/images";
import { LightButton, PrimaryButton, IconClick } from "@/design-system/base/button";
import { IMAGES } from "@/config/images";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { TranslationHistorySchema } from "@/lib/interfaces";
import { tlsoPush, tlso } from "@/lib/browser/localStorageOperators";
import { logger } from "@/lib/logger";
import { Plus, Trash } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRef, useState } from "react";
import z from "zod";
import AddToFolder from "./AddToFolder";
import { translateText } from "@/lib/server/bigmodel/translatorActions";
import type { TranslateTextOutput } from "@/lib/server/services/types";
import { actionTranslateText } from "@/modules/translator/translator-action";
import { toast } from "sonner";
import FolderSelector from "./FolderSelector";
import { createPair } from "@/lib/server/services/pairService";
import { shallowEqual } from "@/lib/utils";
import { authClient } from "@/lib/auth-client";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/server/bigmodel/tts";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
import { TSharedTranslationResult } from "@/shared/translator-type";
export default function TranslatorPage() {
const t = useTranslations("translator");
const taref = useRef<HTMLTextAreaElement>(null);
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
const [translationResult, setTranslationResult] = useState<TranslateTextOutput | null>(null);
const [translationResult, setTranslationResult] = useState<TSharedTranslationResult | null>(null);
const [needIpa, setNeedIpa] = useState(true);
const [processing, setProcessing] = useState(false);
const [lastTranslation, setLastTranslation] = useState<{
@@ -34,18 +23,10 @@ export default function TranslatorPage() {
targetLanguage: string;
} | null>(null);
const { load, play } = useAudioPlayer();
const [history, setHistory] = useState<z.infer<typeof TranslationHistorySchema>[]>(() => tlso.get());
const [showAddToFolder, setShowAddToFolder] = useState(false);
const [addToFolderItem, setAddToFolderItem] = useState<z.infer<
typeof TranslationHistorySchema
> | null>(null);
const lastTTS = useRef({
text: "",
url: "",
});
const [autoSave, setAutoSave] = useState(false);
const [autoSaveFolderId, setAutoSaveFolderId] = useState<number | null>(null);
const { data: session } = authClient.useSession();
const tts = async (text: string, locale: string) => {
if (lastTTS.current.text !== text) {
@@ -65,14 +46,13 @@ export default function TranslatorPage() {
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");
logger.error("生成音频失败", error);
}
}
await play();
};
const translate = async () => {
@@ -89,46 +69,21 @@ export default function TranslatorPage() {
lastTranslation?.targetLanguage === targetLanguage;
try {
const result = await translateText({
const result = await actionTranslateText({
sourceText,
targetLanguage,
forceRetranslate,
needIpa,
userId: session?.user?.id,
});
setTranslationResult(result);
if (result.success && result.data) {
setTranslationResult(result.data);
setLastTranslation({
sourceText,
targetLanguage,
});
// 更新本地历史记录
const historyItem = {
text1: result.sourceText,
text2: result.translatedText,
language1: result.sourceLanguage,
language2: result.targetLanguage,
};
setHistory(tlsoPush(historyItem));
// 自动保存到文件夹
if (autoSave && autoSaveFolderId) {
createPair({
text1: result.sourceText,
text2: result.translatedText,
language1: result.sourceLanguage,
language2: result.targetLanguage,
ipa1: result.sourceIpa || undefined,
ipa2: result.targetIpa || undefined,
folderId: autoSaveFolderId,
})
.then(() => {
toast.success(`${sourceText} 保存到文件夹 ${autoSaveFolderId} 成功`);
})
.catch((error) => {
toast.error(`保存失败: ${error.message}`);
});
} else {
toast.error(result.message || "翻译失败,请重试");
}
} catch (error) {
toast.error("翻译失败,请重试");
@@ -139,13 +94,13 @@ export default function TranslatorPage() {
};
return (
<>
<div className="min-h-[calc(100vh-64px)] bg-white">
{/* TCard Component */}
<div className="w-screen flex flex-col md:flex-row md:justify-between gap-2 p-2">
{/* Card Component - Left Side */}
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
{/* ICard1 Component */}
<div className="border border-gray-200 rounded-2xl w-full h-64 p-2">
<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"
ref={taref}
@@ -191,7 +146,7 @@ export default function TranslatorPage() {
{/* Card Component - Right Side */}
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
{/* ICard2 Component */}
<div className="bg-gray-100 rounded-2xl w-full h-64 p-2">
<div className="bg-gray-100 rounded-lg w-full h-64 p-2">
<div className="h-2/3 w-full overflow-y-auto">{translationResult?.translatedText || ""}</div>
<div className="ipa w-full h-1/6 overflow-y-auto text-gray-600">
{translationResult?.targetIpa || ""}
@@ -254,91 +209,15 @@ export default function TranslatorPage() {
{/* TranslateButton Component */}
<div className="w-screen flex justify-center items-center">
<button
className={`duration-150 ease-in text-xl font-extrabold border rounded-4xl p-3 border-gray-200 h-16 ${processing ? "bg-gray-200" : "bg-white hover:bg-gray-200 hover:cursor-pointer"}`}
<PrimaryButton
onClick={translate}
disabled={processing}
size="lg"
className="text-xl"
>
{t("translate")}
</button>
</div>
{/* AutoSave Component */}
<div className="w-screen flex justify-center items-center">
<label className="flex items-center">
<input
type="checkbox"
checked={autoSave}
onChange={(e) => {
const checked = e.target.checked;
if (checked === true && !session) {
toast.warning("Please login to enable auto-save");
return;
}
if (checked === false) setAutoSaveFolderId(null);
setAutoSave(checked);
}}
className="mr-2"
/>
{t("autoSave")}
{autoSaveFolderId ? ` (${autoSaveFolderId})` : ""}
</label>
</div>
{history.length > 0 && (
<div className="m-6 flex flex-col items-center">
<h1 className="text-2xl font-light">{t("history")}</h1>
<div className="border border-gray-200 rounded-2xl m-4">
{history.toReversed().map((item, index) => (
<div
key={index}
className="border-b border-gray-200 p-2 group hover:bg-gray-50 flex gap-2 flex-row justify-between items-start"
>
<div className="flex-1 flex flex-col">
<p className="text-sm font-light">{item.text1}</p>
<p className="text-sm font-light">{item.text2}</p>
</div>
<div className="flex gap-2">
<button
onClick={() => {
if (!session?.user) {
toast.info("请先登录后再保存到文件夹");
return;
}
setShowAddToFolder(true);
setAddToFolderItem(item);
}}
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-8 h-8 flex justify-center items-center"
>
<Plus />
</button>
<button
onClick={() => {
setHistory(
tlso.set(
tlso.get().filter((v) => !shallowEqual(v, item)),
) || [],
);
}}
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-8 h-8 flex justify-center items-center"
>
<Trash />
</button>
</PrimaryButton>
</div>
</div>
))}
</div>
{showAddToFolder && (
<AddToFolder setShow={setShowAddToFolder} item={addToFolderItem!} />
)}
{autoSave && !autoSaveFolderId && (
<FolderSelector
userId={session!.user.id as string}
cancel={() => setAutoSave(false)}
setSelectedFolderId={(id) => setAutoSaveFolderId(id)}
/>
)}
</div>
)}
</>
);
}

View File

@@ -1,259 +0,0 @@
"use client";
import { useState, useActionState, startTransition } from "react";
import { useTranslations } from "next-intl";
import { signInAction, signUpAction, SignUpState } from "@/lib/actions/auth";
import Container from "@/components/ui/Container";
import Input from "@/components/ui/Input";
import { LightButton } from "@/components/ui/buttons";
import { authClient } from "@/lib/auth-client";
interface AuthFormProps {
redirectTo?: string;
}
export default function AuthForm({ redirectTo }: AuthFormProps) {
const t = useTranslations("auth");
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
const [clearSignIn, setClearSignIn] = useState(false);
const [clearSignUp, setClearSignUp] = useState(false);
const [signInState, signInActionForm, isSignInPending] = useActionState(
async (prevState: SignUpState | undefined, formData: FormData) => {
if (clearSignIn) {
setClearSignIn(false);
return undefined;
}
return signInAction(prevState || {}, formData);
},
undefined
);
const [signUpState, signUpActionForm, isSignUpPending] = useActionState(
async (prevState: SignUpState | undefined, formData: FormData) => {
if (clearSignUp) {
setClearSignUp(false);
return undefined;
}
return signUpAction(prevState || {}, formData);
},
undefined
);
const [errors, setErrors] = useState<Record<string, string>>({});
const validateForm = (formData: FormData): boolean => {
const newErrors: Record<string, string> = {};
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const name = formData.get("name") as string;
const confirmPassword = formData.get("confirmPassword") as string;
if (!email) {
newErrors.email = t("emailRequired");
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
newErrors.email = t("invalidEmail");
}
if (!password) {
newErrors.password = t("passwordRequired");
} else if (password.length < 8) {
newErrors.password = t("passwordTooShort");
}
if (mode === 'signup') {
if (!name) {
newErrors.name = t("nameRequired");
}
if (!confirmPassword) {
newErrors.confirmPassword = t("confirmPasswordRequired");
} else if (password !== confirmPassword) {
newErrors.confirmPassword = t("passwordsNotMatch");
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
// 基本客户端验证
if (!validateForm(formData)) {
return;
}
// 添加 redirectTo 到 formData
if (redirectTo) {
formData.append("redirectTo", redirectTo);
}
// 使用 startTransition 包装 action 调用
startTransition(() => {
// 根据模式调用相应的 action
if (mode === 'signin') {
signInActionForm(formData);
} else {
signUpActionForm(formData);
}
});
};
const handleGitHubSignIn = async () => {
await authClient.signIn.social({
provider: "github",
callbackURL: redirectTo || "/"
});
};
const currentError = mode === 'signin' ? signInState : signUpState;
return (
<div className="h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4">
<Container className="p-8 max-w-md w-full">
{/* 页面标题 */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-800 mb-2">{t(mode === 'signin' ? 'signIn' : 'signUp')}</h1>
</div>
{/* 服务器端错误提示 */}
{currentError?.message && (
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
{currentError.message}
</div>
)}
{/* 登录/注册表单 */}
<form onSubmit={handleFormSubmit} className="space-y-4">
{/* 用户名输入(仅注册模式显示) */}
{mode === 'signup' && (
<div>
<Input
type="text"
name="name"
placeholder={t("name")}
className="w-full px-3 py-2"
/>
{/* 客户端验证错误 */}
{errors.name && (
<p className="text-red-500 text-sm mt-1">{errors.name}</p>
)}
{/* 服务器端验证错误 */}
{currentError?.errors?.username && (
<p className="text-red-500 text-sm mt-1">{currentError.errors.username[0]}</p>
)}
</div>
)}
{/* 邮箱输入 */}
<div>
<Input
type="email"
name="email"
placeholder={t("email")}
className="w-full px-3 py-2"
/>
{errors.email && (
<p className="text-red-500 text-sm mt-1">{errors.email}</p>
)}
{currentError?.errors?.email && (
<p className="text-red-500 text-sm mt-1">{currentError.errors.email[0]}</p>
)}
</div>
{/* 密码输入 */}
<div>
<Input
type="password"
name="password"
placeholder={t("password")}
className="w-full px-3 py-2"
/>
{errors.password && (
<p className="text-red-500 text-sm mt-1">{errors.password}</p>
)}
{currentError?.errors?.password && (
<p className="text-red-500 text-sm mt-1">{currentError.errors.password[0]}</p>
)}
</div>
{/* 确认密码输入(仅注册模式显示) */}
{mode === 'signup' && (
<div>
<Input
type="password"
name="confirmPassword"
placeholder={t("confirmPassword")}
className="w-full px-3 py-2"
/>
{errors.confirmPassword && (
<p className="text-red-500 text-sm mt-1">{errors.confirmPassword}</p>
)}
</div>
)}
{/* 提交按钮 */}
<LightButton
type="submit"
className={`w-full py-2 ${isSignInPending || isSignUpPending ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{isSignInPending || isSignUpPending
? t("loading")
: t(mode === 'signin' ? 'signInButton' : 'signUpButton')
}
</LightButton>
</form>
{/* 第三方登录区域 */}
<div className="mt-6">
{/* 分隔线 */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500"></span>
</div>
</div>
{/* GitHub 登录按钮 */}
<LightButton
onClick={handleGitHubSignIn}
className="w-full mt-4 py-2 flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
{t(mode === 'signin' ? 'signInWithGitHub' : 'signUpWithGitHub')}
</LightButton>
</div>
{/* 模式切换链接 */}
<div className="mt-6 text-center">
<button
type="button"
onClick={() => {
setMode(mode === 'signin' ? 'signup' : 'signin');
setErrors({});
// 清除服务器端错误状态
if (mode === 'signin') {
setClearSignIn(true);
} else {
setClearSignUp(true);
}
}}
className="text-[#35786f] hover:underline"
>
{mode === 'signin'
? `${t("noAccount")} ${t("signUp")}`
: `${t("hasAccount")} ${t("signIn")}`
}
</button>
</div>
</Container>
</div>
);
}

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { LightButton } from "@/components/ui/buttons";
import Input from "@/components/ui/Input";
import { LightButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { LocaleSelector } from "@/components/ui/LocaleSelector";
import { X } from "lucide-react";
import { useRef, useState } from "react";
@@ -16,7 +16,7 @@ interface AddTextPairModalProps {
) => void;
}
export default function AddTextPairModal({
export function AddTextPairModal({
isOpen,
onClose,
onAdd,
@@ -67,7 +67,7 @@ export default function AddTextPairModal({
}
}}
>
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
<div className="bg-white rounded-md p-6 w-full max-w-md mx-4">
<div className="flex">
<h2 className="flex-1 text-xl font-light mb-4 text-center">
{t("addNewTextPair")}

View File

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

View File

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

View File

@@ -1,20 +1,20 @@
import { LightButton } from "@/components/ui/buttons";
import Input from "@/components/ui/Input";
import { LightButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { LocaleSelector } from "@/components/ui/LocaleSelector";
import { X } from "lucide-react";
import { useRef, useState } from "react";
import { UpdatePairInput } from "@/lib/server/services/types";
import { TextPair } from "./InFolder";
import { useTranslations } from "next-intl";
import { TSharedPair } from "@/shared/folder-type";
import { ActionInputUpdatePairById } from "@/modules/folder/folder-action-dto";
interface UpdateTextPairModalProps {
isOpen: boolean;
onClose: () => void;
textPair: TextPair;
onUpdate: (id: number, tp: UpdatePairInput) => void;
textPair: TSharedPair;
onUpdate: (id: number, tp: ActionInputUpdatePairById) => void;
}
export default function UpdateTextPairModal({
export function UpdateTextPairModal({
isOpen,
onClose,
onUpdate,
@@ -63,7 +63,7 @@ export default function UpdateTextPairModal({
}
}}
>
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
<div className="bg-white rounded-md p-6 w-full max-w-md mx-4">
<div className="flex">
<h2 className="flex-1 text-xl font-light mb-4 text-center">
{t("updateTextPair")}

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ function LinkArea({ href, name, description, color }: LinkAreaProps) {
<Link
href={href}
style={{ backgroundColor: color }}
className={`h-32 md:h-64 flex md:justify-center items-center`}
className={`hover:scale-105 transition-transform duration-200 h-32 md:h-64 flex md:justify-center items-center`}
>
<div className="text-white m-8">
<h1 className="md:text-4xl text-3xl">{name}</h1>
@@ -27,7 +27,7 @@ export default async function HomePage() {
const t = await getTranslations("home");
return (
<>
<div className="bg-[#35786f] text-white w-full min-h-[75dvh] flex justify-center items-center">
<div className="bg-primary-500 text-white w-full min-h-[75dvh] flex justify-center items-center">
<div className="mb-16 mx-16 md:mx-0 md:max-w-[60dvw]">
<h1 className="text-6xl md:text-9xl mb-8 font-extrabold">
{t("title")}

View File

@@ -1,20 +0,0 @@
"use client";
import { LightButton } from "@/components/ui/buttons";
import { authClient } from "@/lib/auth-client";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
export default function LogoutButton() {
const t = useTranslations("profile");
const router = useRouter();
return <LightButton onClick={async () => {
authClient.signOut({
fetchOptions: {
onSuccess: () => {
router.push("/auth?redirect=/profile");
}
}
});
}}> {t("logout")}</LightButton >;
}

View File

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

View File

@@ -1,7 +1,8 @@
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { nextCookies } from "better-auth/next-js";
import prisma from "./lib/db";
import { prisma } from "./lib/db";
import { username } from "better-auth/plugins";
export const auth = betterAuth({
database: prismaAdapter(prisma, {
@@ -16,5 +17,5 @@ export const auth = betterAuth({
clientSecret: process.env.GITHUB_CLIENT_SECRET as string
},
},
plugins: [nextCookies()]
plugins: [nextCookies(), username()]
});

View File

@@ -1,10 +1,10 @@
"use client";
import IMAGES from "@/config/images";
import { IconClick, GhostButton } from "./ui/buttons";
import { GhostLightButton } from "@/design-system/base/button";
import { useState } from "react";
import { Languages } from "lucide-react";
export default function LanguageSettings() {
export function LanguageSettings() {
const [showLanguageMenu, setShowLanguageMenu] = useState(false);
const handleLanguageClick = () => {
setShowLanguageMenu((prev) => !prev);
@@ -15,65 +15,64 @@ export default function LanguageSettings() {
};
return (
<>
<IconClick
src={IMAGES.language_white}
alt="language"
disableOnHoverBgChange={true}
<GhostLightButton
size="md"
onClick={handleLanguageClick}
size={40}
></IconClick>
>
<Languages size={20} />
</GhostLightButton>
<div className="relative">
{showLanguageMenu && (
<div>
<div className="absolute top-10 right-0 rounded-md shadow-md flex flex-col gap-2">
<GhostButton
className="w-full bg-[#35786f]"
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("en-US")}
>
English
</GhostButton>
<GhostButton
className="w-full bg-[#35786f]"
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("zh-CN")}
>
</GhostButton>
<GhostButton
className="w-full bg-[#35786f]"
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("ja-JP")}
>
</GhostButton>
<GhostButton
className="w-full bg-[#35786f]"
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("ko-KR")}
>
</GhostButton>
<GhostButton
className="w-full bg-[#35786f]"
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("de-DE")}
>
Deutsch
</GhostButton>
<GhostButton
className="w-full bg-[#35786f]"
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("fr-FR")}
>
Français
</GhostButton>
<GhostButton
className="w-full bg-[#35786f]"
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("it-IT")}
>
Italiano
</GhostButton>
<GhostButton
className="w-full bg-[#35786f]"
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("ug-CN")}
>
ئۇيغۇرچە
</GhostButton>
</GhostLightButton>
</div>
</div>
)}

View File

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

View File

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

View File

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

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