Compare commits
47 Commits
8f25791fa1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b9fba254d | |||
| 0cb240791b | |||
| d9fd09c13d | |||
| 5406543cbe | |||
| d2a3d32376 | |||
| 436d58be52 | |||
| 11a265d52e | |||
| fb4346377a | |||
| c83aefabfa | |||
| 020744b353 | |||
| 719aef5a7f | |||
| 6c811a77db | |||
| 3652e350e6 | |||
| 6ba5ae993a | |||
| b643205f72 | |||
| c6878ed1e5 | |||
| e74cd80fac | |||
| c01c94abd0 | |||
| 0881846717 | |||
| d7149366e9 | |||
| b0fa1a4201 | |||
| b407783d61 | |||
| ca33d4353f | |||
| ff57f5e0a5 | |||
| 91c59c3ad9 | |||
| 1df184d1ad | |||
| f6e21aa2fe | |||
| 67ac0bf7b6 | |||
| dd1c6a7b52 | |||
| e2d8e17f62 | |||
| 63486757b9 | |||
| 45ffe5733b | |||
| 613df6824b | |||
| bf80e17514 | |||
| d71c79c87a | |||
| 559690dc56 | |||
| 884b30d7f6 | |||
| 01cd122d93 | |||
| 94840c1b0a | |||
| 72ced7866e | |||
| 690222ccb7 | |||
| 6dc933dc1e | |||
| 1be24065e0 | |||
| 757c27c94a | |||
| 6ea8b4d4b9 | |||
| 9e9ac373c6 | |||
| 0149fde0bd |
@@ -2,6 +2,8 @@
|
|||||||
kind: pipeline
|
kind: pipeline
|
||||||
type: docker
|
type: docker
|
||||||
name: learn-languages
|
name: learn-languages
|
||||||
|
concurrency:
|
||||||
|
limit: 1
|
||||||
|
|
||||||
platform:
|
platform:
|
||||||
os: linux
|
os: linux
|
||||||
|
|||||||
@@ -13,3 +13,11 @@ DATABASE_URL=
|
|||||||
|
|
||||||
// DashScore
|
// DashScore
|
||||||
DASHSCORE_API_KEY=
|
DASHSCORE_API_KEY=
|
||||||
|
|
||||||
|
// SMTP Email - Resend (https://resend.com)
|
||||||
|
SMTP_HOST=smtp.resend.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_USER=resend
|
||||||
|
SMTP_PASS=re_your_resend_api_key
|
||||||
|
SMTP_FROM=onboarding@resend.dev
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -51,4 +51,4 @@ test.js
|
|||||||
|
|
||||||
certificates
|
certificates
|
||||||
|
|
||||||
.claude
|
.opencode
|
||||||
|
|||||||
168
AGENTS.md
Normal file
168
AGENTS.md
Normal 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/`)
|
||||||
|
- 未配置测试基础设施
|
||||||
128
CLAUDE.md
128
CLAUDE.md
@@ -1,128 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
本文件为 Claude Code (claude.ai/code) 在此代码库中工作时提供指导。
|
|
||||||
|
|
||||||
## 项目概述
|
|
||||||
|
|
||||||
这是一个基于 Next.js 16 构建的全栈语言学习平台,提供翻译工具、文本转语音、字幕播放、字母学习和记忆功能。平台支持 8 种语言,具有完整的国际化支持。
|
|
||||||
|
|
||||||
## 开发命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 启动开发服务器(启用 HTTPS)
|
|
||||||
pnpm run dev
|
|
||||||
|
|
||||||
# 构建生产版本(standalone 输出模式,用于 Docker)
|
|
||||||
pnpm run build
|
|
||||||
|
|
||||||
# 启动生产服务器
|
|
||||||
pnpm run start
|
|
||||||
|
|
||||||
# 代码检查
|
|
||||||
pnpm run lint
|
|
||||||
|
|
||||||
# 数据库操作
|
|
||||||
# 不要进行数据库操作,让用户操作数据库
|
|
||||||
```
|
|
||||||
|
|
||||||
## 技术栈
|
|
||||||
|
|
||||||
- **Next.js 16** 使用 App Router 和 standalone 输出模式
|
|
||||||
- **React 19** 启用 React Compiler 进行优化
|
|
||||||
- **TypeScript** 严格模式和 ES2023 目标
|
|
||||||
- **Tailwind CSS v4** 样式框架
|
|
||||||
- **PostgreSQL** + **Prisma ORM**(自定义输出目录:`src/generated/prisma`)
|
|
||||||
- **better-auth** 身份验证(邮箱/密码 + OAuth)
|
|
||||||
- **next-intl** 国际化(支持:en-US, zh-CN, ja-JP, ko-KR, de-DE, fr-FR, it-IT, ug-CN)
|
|
||||||
- **阿里云千问 TTS** (qwen3-tts-flash) 文本转语音
|
|
||||||
- **pnpm** 包管理器
|
|
||||||
|
|
||||||
## 架构设计
|
|
||||||
|
|
||||||
### 路由结构
|
|
||||||
|
|
||||||
应用使用 Next.js App Router 和基于功能的组织方式:
|
|
||||||
|
|
||||||
```
|
|
||||||
src/app/
|
|
||||||
├── (features)/ # 功能模块(translator, alphabet, memorize, dictionary, srt-player)
|
|
||||||
│ └── [locale]/ # 国际化路由
|
|
||||||
├── auth/ # 认证页面(sign-in, sign-up)
|
|
||||||
├── folders/ # 用户学习文件夹管理
|
|
||||||
├── users/[username]/# 用户资料页面(Server Component)
|
|
||||||
├── profile/ # 重定向到当前用户的资料页面
|
|
||||||
└── api/ # API 路由
|
|
||||||
```
|
|
||||||
|
|
||||||
### 后端架构模式
|
|
||||||
|
|
||||||
项目使用 **Action-Service-Repository 三层架构**:
|
|
||||||
|
|
||||||
```
|
|
||||||
src/modules/{module}/
|
|
||||||
├── {module}-action.ts # Server Actions 层(表单处理、重定向)
|
|
||||||
├── {module}-action-dto.ts # Action 层 DTO(Zod 验证)
|
|
||||||
├── {module}-service.ts # Service 层(业务逻辑)
|
|
||||||
├── {module}-service-dto.ts # Service 层 DTO
|
|
||||||
├── {module}-repository.ts # Repository 层(数据库操作)
|
|
||||||
└── {module}-repository-dto.ts # Repository 层 DTO
|
|
||||||
```
|
|
||||||
|
|
||||||
各层职责:
|
|
||||||
- **Action 层**:处理表单数据、验证输入、调用 service 层、处理重定向和错误响应
|
|
||||||
- **Service 层**:实现业务逻辑、调用 better-auth API、协调多个 repository 操作
|
|
||||||
- **Repository 层**:直接使用 Prisma 进行数据库查询和操作
|
|
||||||
|
|
||||||
现有模块:
|
|
||||||
- `auth` - 认证和用户管理(支持用户名/邮箱登录)
|
|
||||||
- `folder` - 学习文件夹管理
|
|
||||||
- `dictionary` - 词典查询
|
|
||||||
- `translator` - 翻译服务
|
|
||||||
|
|
||||||
### 数据库 Schema
|
|
||||||
|
|
||||||
核心模型(见 [prisma/schema.prisma](prisma/schema.prisma)):
|
|
||||||
- **User**: 用户中心实体,包含认证信息
|
|
||||||
- **Folder**: 用户拥有的学习资料容器(级联删除 pairs)
|
|
||||||
- **Pair**: 语言对(翻译/词汇),支持 IPA,唯一约束为 (folderId, locale1, locale2, text1)
|
|
||||||
- **Session/Account**: better-auth 追踪
|
|
||||||
- **Verification**: 邮箱验证系统
|
|
||||||
|
|
||||||
### 核心模式
|
|
||||||
|
|
||||||
**Server Actions**: 数据库变更使用 `src/lib/actions/` 中的 Server Actions,配合类型安全的 Prisma 操作。
|
|
||||||
|
|
||||||
**基于功能的组件**: 每个功能在 `(features)/` 下有自己的路由组,带有 locale 前缀。
|
|
||||||
|
|
||||||
**国际化**: 所有面向用户的内容通过 next-intl 处理。消息文件在 `messages/` 目录。locale 自动检测并在路由中前缀。
|
|
||||||
|
|
||||||
**认证流程**: better-auth 使用客户端适配器 (`authClient`),通过 hooks 管理会话,受保护的路由使用条件渲染。
|
|
||||||
|
|
||||||
**LLM 集成**: 使用智谱 AI API 进行翻译和 IPA 生成。通过环境变量 `ZHIPU_API_KEY` 和 `ZHIPU_MODEL_NAME` 配置。
|
|
||||||
|
|
||||||
- **Standalone 输出**: 为 Docker 部署配置
|
|
||||||
- **React Compiler**: 在 `next.config.ts` 中启用以自动优化
|
|
||||||
- **HTTPS 开发**: 开发服务器使用 `--experimental-https` 标志
|
|
||||||
- **图片优化**: 通过 remote patterns 允许 GitHub 头像
|
|
||||||
|
|
||||||
## 代码组织
|
|
||||||
|
|
||||||
- `src/modules/`: 业务模块(auth, folder, dictionary, translator)
|
|
||||||
- `src/lib/actions/`: 数据库变更的 Server Actions(旧架构,正在迁移到 modules)
|
|
||||||
- `src/lib/server/`: 服务端工具(AI 集成、认证、翻译器)
|
|
||||||
- `src/lib/browser/`: 客户端工具
|
|
||||||
- `src/hooks/`: 自定义 React hooks(认证 hooks、会话管理)
|
|
||||||
- `src/i18n/`: 国际化配置
|
|
||||||
- `messages/`: 各支持语言的翻译文件
|
|
||||||
- `src/components/`: 可复用的 UI 组件(buttons, cards 等)
|
|
||||||
- `src/shared/`: 共享常量和类型定义
|
|
||||||
|
|
||||||
## 开发注意事项
|
|
||||||
|
|
||||||
- 使用 pnpm,而不是 npm 或 yarn
|
|
||||||
- 应用使用 TypeScript 严格模式 - 确保类型安全
|
|
||||||
- 所有面向用户的文本都需要国际化
|
|
||||||
- **优先使用 Server Components**,只在需要交互时使用 Client Components
|
|
||||||
- **新功能应遵循 action-service-repository 架构**
|
|
||||||
- Better-auth 处理会话管理 - 使用 authClient 适配器进行认证操作
|
|
||||||
- 使用 better-auth username 插件支持用户名登录
|
|
||||||
545
README.md
545
README.md
@@ -1,189 +1,372 @@
|
|||||||
# 多语言学习平台
|
# 🌍 多语言学习平台
|
||||||
|
|
||||||
一个基于 Next.js 构建的全功能多语言学习平台,提供翻译、发音、字幕播放、字母学习等多种语言学习工具,帮助用户更高效地掌握新语言。
|
<div align="center">
|
||||||
|
|
||||||
## ✨ 主要功能
|
[](https://nextjs.org/)
|
||||||
|
[](https://reactjs.org/)
|
||||||
|
[](https://www.typescriptlang.org/)
|
||||||
|
[](https://www.postgresql.org/)
|
||||||
|
[](./LICENSE)
|
||||||
|
|
||||||
- **智能翻译工具** - 支持多语言互译,包含国际音标(IPA)标注
|
**一个现代化的全栈多语言学习平台,集成 AI 驱动的翻译、发音、词典和学习管理功能**
|
||||||
- **文本语音合成** - 将文本转换为自然语音,提高发音学习效果
|
|
||||||
- **SRT字幕播放器** - 结合视频字幕学习,支持多种字幕格式
|
|
||||||
- **字母学习模块** - 针对初学者的字母和发音基础学习
|
|
||||||
- **记忆强化工具** - 通过科学记忆法巩固学习内容
|
|
||||||
- **词典查询** - 查询单词和短语,提供详细释义和例句
|
|
||||||
- **个人学习空间** - 用户可以创建、管理和组织自己的学习资料
|
|
||||||
- **用户资料系统** - 支持用户名登录、个人资料页面展示
|
|
||||||
|
|
||||||
## 🛠 技术栈
|
[在线演示](#) · [报告问题](../../issues) · [功能建议](../../issues)
|
||||||
|
|
||||||
### 前端框架
|
</div>
|
||||||
- **Next.js 16** - React 全栈框架,使用 App Router
|
|
||||||
- **React 19** - 用户界面构建
|
|
||||||
- **TypeScript** - 类型安全的 JavaScript
|
|
||||||
- **Tailwind CSS** - 实用优先的 CSS 框架
|
|
||||||
|
|
||||||
### 数据与后端
|
|
||||||
- **PostgreSQL** - 主数据库
|
|
||||||
- **Prisma** - 现代数据库工具包和 ORM
|
|
||||||
- **better-auth** - 安全的身份验证系统
|
|
||||||
|
|
||||||
### 国际化与辅助功能
|
|
||||||
- **next-intl** - 国际化解决方案
|
|
||||||
- **阿里云千问 TTS** - qwen3-tts-flash 语音合成
|
|
||||||
|
|
||||||
### 开发工具
|
|
||||||
- **ESLint** - 代码质量检查
|
|
||||||
- **pnpm** - 高效的包管理器
|
|
||||||
|
|
||||||
## 📁 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── app/ # Next.js App Router 路由
|
|
||||||
│ ├── (features)/ # 功能模块路由
|
|
||||||
│ ├── auth/ # 认证相关页面
|
|
||||||
│ ├── profile/ # 用户资料重定向
|
|
||||||
│ ├── users/[username]/ # 用户资料页面
|
|
||||||
│ ├── folders/ # 文件夹管理
|
|
||||||
│ └── api/ # API 路由
|
|
||||||
├── modules/ # 业务模块(action-service-repository 架构)
|
|
||||||
│ ├── auth/ # 认证模块
|
|
||||||
│ ├── folder/ # 文件夹模块
|
|
||||||
│ ├── dictionary/ # 词典模块
|
|
||||||
│ └── translator/ # 翻译模块
|
|
||||||
├── components/ # React 组件
|
|
||||||
│ ├── buttons/ # 按钮组件
|
|
||||||
│ ├── cards/ # 卡片组件
|
|
||||||
│ └── ...
|
|
||||||
├── lib/ # 工具函数和库
|
|
||||||
│ ├── actions/ # Server Actions
|
|
||||||
│ ├── browser/ # 浏览器端工具
|
|
||||||
│ └── server/ # 服务器端工具
|
|
||||||
├── hooks/ # 自定义 React Hooks
|
|
||||||
├── i18n/ # 国际化配置
|
|
||||||
├── shared/ # 共享常量和类型
|
|
||||||
└── config/ # 应用配置
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 快速开始
|
|
||||||
|
|
||||||
### 环境要求
|
|
||||||
|
|
||||||
- Node.js 23
|
|
||||||
- PostgreSQL 数据库
|
|
||||||
- pnpm (推荐) 或 npm
|
|
||||||
|
|
||||||
### 本地开发
|
|
||||||
|
|
||||||
1. 克隆项目
|
|
||||||
```bash
|
|
||||||
git clone <repository-url>
|
|
||||||
cd learn-languages
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 安装依赖
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
```
|
|
||||||
|
|
||||||
3. 设置环境变量
|
|
||||||
|
|
||||||
从项目提供的示例文件复制环境变量模板:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env.local
|
|
||||||
```
|
|
||||||
|
|
||||||
然后编辑 `.env.local` 文件,配置所有必要的环境变量:
|
|
||||||
|
|
||||||
```env
|
|
||||||
# LLM 集成(智谱 AI 用于翻译和 IPA 生成)
|
|
||||||
ZHIPU_API_KEY=your-zhipu-api-key
|
|
||||||
ZHIPU_MODEL_NAME=your-zhipu-model-name
|
|
||||||
|
|
||||||
# 阿里云千问 TTS(文本转语音)
|
|
||||||
DASHSCORE_API_KEY=your-dashscore-api-key
|
|
||||||
|
|
||||||
# 认证
|
|
||||||
BETTER_AUTH_SECRET=your-better-auth-secret
|
|
||||||
BETTER_AUTH_URL=http://localhost:3000
|
|
||||||
GITHUB_CLIENT_ID=your-github-client-id
|
|
||||||
GITHUB_CLIENT_SECRET=your-github-client-secret
|
|
||||||
|
|
||||||
# 数据库
|
|
||||||
DATABASE_URL=postgresql://username:password@localhost:5432/database_name
|
|
||||||
```
|
|
||||||
|
|
||||||
注意:所有带 `your-` 前缀的值需要替换为你的实际配置。
|
|
||||||
|
|
||||||
4. 初始化数据库
|
|
||||||
```bash
|
|
||||||
pnpm prisma generate
|
|
||||||
pnpm prisma db push
|
|
||||||
```
|
|
||||||
|
|
||||||
5. 启动开发服务器
|
|
||||||
```bash
|
|
||||||
pnpm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
访问 [http://localhost:3000](http://localhost:3000) 查看应用。
|
|
||||||
|
|
||||||
## 📚 API 文档
|
|
||||||
|
|
||||||
### 认证系统
|
|
||||||
|
|
||||||
应用使用 better-auth 提供安全的用户认证系统,支持:
|
|
||||||
- 邮箱/密码登录和注册
|
|
||||||
- **用户名登录**(可通过用户名或邮箱登录)
|
|
||||||
- GitHub OAuth 第三方登录
|
|
||||||
- 邮箱验证功能
|
|
||||||
|
|
||||||
### 后端架构
|
|
||||||
|
|
||||||
项目采用 **Action-Service-Repository 三层架构**:
|
|
||||||
- **Action 层**:处理 Server Actions、表单验证、重定向
|
|
||||||
- **Service 层**:业务逻辑、better-auth 集成
|
|
||||||
- **Repository 层**:Prisma 数据库操作
|
|
||||||
|
|
||||||
### 数据模型
|
|
||||||
|
|
||||||
核心数据模型包括:
|
|
||||||
- **User** - 用户信息(支持用户名、邮箱、头像)
|
|
||||||
- **Folder** - 学习资料文件夹
|
|
||||||
- **Pair** - 语言对(翻译对、词汇对等)
|
|
||||||
- **Session/Account** - 认证会话追踪
|
|
||||||
- **Verification** - 邮箱验证系统
|
|
||||||
|
|
||||||
详细模型定义请参考 [prisma/schema.prisma](./prisma/schema.prisma)
|
|
||||||
|
|
||||||
## 🌍 国际化
|
|
||||||
|
|
||||||
应用支持多语言,当前语言文件位于 `messages/` 目录。添加新语言:
|
|
||||||
|
|
||||||
1. 在 `messages/` 目录创建对应语言的 JSON 文件
|
|
||||||
2. 在 `src/i18n/config.ts` 中添加语言配置
|
|
||||||
|
|
||||||
## 🤝 贡献指南
|
|
||||||
|
|
||||||
我们欢迎各种形式的贡献!请遵循以下步骤:
|
|
||||||
|
|
||||||
1. Fork 项目
|
|
||||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
|
||||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
|
||||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
|
||||||
5. 打开 Pull Request
|
|
||||||
|
|
||||||
## 📄 许可证
|
|
||||||
|
|
||||||
本项目采用 AGPL-3.0 许可证 - 查看 [LICENSE](./LICENSE) 文件了解详情。
|
|
||||||
|
|
||||||
## 📞 支持
|
|
||||||
|
|
||||||
如果您遇到问题或有建议,请通过以下方式联系:
|
|
||||||
|
|
||||||
- 提交 [Issue](../../issues)
|
|
||||||
- 发送邮件至 [goddonebianu@outlook.com]
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Happy Learning!** 🌟
|
## ✨ 核心特性
|
||||||
|
|
||||||
|
### 🎯 学习工具
|
||||||
|
- **智能翻译** - 基于 AI 的多语言互译,支持 IPA 音标标注
|
||||||
|
- **词典查询** - 详细的单词释义、词性分析、例句展示
|
||||||
|
- **语音合成** - 阿里云千问 TTS 提供自然的语音输出
|
||||||
|
- **个人学习空间** - 文件夹管理、学习资料组织
|
||||||
|
|
||||||
|
### 🔐 用户系统
|
||||||
|
- **多方式认证** - 邮箱/用户名登录、GitHub OAuth
|
||||||
|
- **个人资料** - 用户主页、学习进度追踪
|
||||||
|
- **数据安全** - better-auth 提供企业级安全保障
|
||||||
|
|
||||||
|
### 🌐 国际化
|
||||||
|
- **8 种语言** - en-US, zh-CN, ja-JP, ko-KR, de-DE, fr-FR, it-IT, ug-CN
|
||||||
|
- **完整本地化** - 所有界面文本支持多语言
|
||||||
|
|
||||||
|
### 🏗️ 技术亮点
|
||||||
|
- **App Router** - 采用 Next.js 16 最新路由系统
|
||||||
|
- **Server Components** - 优先服务端渲染,优化性能
|
||||||
|
- **Action-Service-Repository** - 清晰的三层架构设计
|
||||||
|
- **类型安全** - TypeScript 严格模式 + Zod 验证
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 前置要求
|
||||||
|
|
||||||
|
- Node.js 24+
|
||||||
|
- PostgreSQL 14+
|
||||||
|
- pnpm 8+ (推荐) 或 npm/yarn
|
||||||
|
|
||||||
|
### 安装步骤
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 克隆项目
|
||||||
|
git clone <repository-url>
|
||||||
|
cd learn-languages
|
||||||
|
|
||||||
|
# 2. 安装依赖
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# 3. 配置环境变量
|
||||||
|
cp .env.example .env.local
|
||||||
|
# 编辑 .env.local 填写必要配置
|
||||||
|
|
||||||
|
# 4. 初始化数据库
|
||||||
|
pnpm prisma generate
|
||||||
|
pnpm prisma db push
|
||||||
|
|
||||||
|
# 5. 启动开发服务器
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 **http://localhost:3000** 开始使用!
|
||||||
|
|
||||||
|
### 环境变量配置
|
||||||
|
|
||||||
|
```env
|
||||||
|
# 🤖 AI 服务(必需)
|
||||||
|
ZHIPU_API_KEY=your-api-key # 智谱 AI - 翻译和词典
|
||||||
|
ZHIPU_MODEL_NAME=your-model-name # 模型名称
|
||||||
|
DASHSCORE_API_KEY=your-api-key # 阿里云 TTS
|
||||||
|
|
||||||
|
# 🔐 认证配置(必需)
|
||||||
|
BETTER_AUTH_SECRET=your-secret # 随机字符串
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# 🐙 GitHub OAuth(可选)
|
||||||
|
GITHUB_CLIENT_ID=your-client-id
|
||||||
|
GITHUB_CLIENT_SECRET=your-client-secret
|
||||||
|
|
||||||
|
# 💾 数据库(必需)
|
||||||
|
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 技术栈
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td width="50%">
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
- **Next.js 16** - App Router
|
||||||
|
- **React 19** - UI 框架
|
||||||
|
- **TypeScript 5.9** - 类型安全
|
||||||
|
- **Tailwind CSS 4** - 样式方案
|
||||||
|
- **Zustand** - 状态管理
|
||||||
|
- **next-intl** - 国际化
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td width="50%">
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
- **PostgreSQL** - 关系数据库
|
||||||
|
- **Prisma 7** - ORM
|
||||||
|
- **better-auth** - 认证系统
|
||||||
|
- **智谱 AI** - LLM 服务
|
||||||
|
- **阿里云 TTS** - 语音合成
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 项目架构
|
||||||
|
|
||||||
|
```
|
||||||
|
learn-languages/
|
||||||
|
├── 📂 src/
|
||||||
|
│ ├── 📂 app/ # Next.js App Router
|
||||||
|
│ │ ├── 📂 (auth)/ # 认证相关页面
|
||||||
|
│ │ ├── 📂 folders/ # 文件夹管理
|
||||||
|
│ │ ├── 📂 users/[username]/ # 用户资料
|
||||||
|
│ │ └── 📂 api/ # API 路由
|
||||||
|
│ │
|
||||||
|
│ ├── 📂 modules/ # 业务模块(三层架构)
|
||||||
|
│ │ ├── 📂 auth/ # 认证模块
|
||||||
|
│ │ ├── 📂 folder/ # 文件夹模块
|
||||||
|
│ │ ├── 📂 dictionary/ # 词典模块
|
||||||
|
│ │ └── 📂 translator/ # 翻译模块
|
||||||
|
│ │
|
||||||
|
│ ├── 📂 components/ # React 组件
|
||||||
|
│ │ ├── 📂 ui/ # 通用 UI 组件
|
||||||
|
│ │ └── 📂 layout/ # 布局组件
|
||||||
|
│ │
|
||||||
|
│ ├── 📂 design-system/ # 设计系统
|
||||||
|
│ │ ├── 📂 base/ # 基础组件
|
||||||
|
│ │ ├── 📂 layout/ # 布局组件
|
||||||
|
│ │ └── 📂 feedback/ # 反馈组件
|
||||||
|
│ │
|
||||||
|
│ ├── 📂 lib/ # 工具库
|
||||||
|
│ │ ├── 📂 bigmodel/ # AI 集成
|
||||||
|
│ │ ├── 📂 browser/ # 浏览器工具
|
||||||
|
│ │ └── 📂 server/ # 服务端工具
|
||||||
|
│ │
|
||||||
|
│ ├── 📂 hooks/ # 自定义 Hooks
|
||||||
|
│ ├── 📂 i18n/ # 国际化配置
|
||||||
|
│ ├── 📂 shared/ # 共享类型和常量
|
||||||
|
│ └── 📂 config/ # 应用配置
|
||||||
|
│
|
||||||
|
├── 📂 prisma/ # 数据库 Schema
|
||||||
|
├── 📂 messages/ # 多语言文件
|
||||||
|
└── 📂 public/ # 静态资源
|
||||||
|
```
|
||||||
|
|
||||||
|
### 架构设计:Action-Service-Repository
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Presentation Layer │
|
||||||
|
│ (Server Components / Client Components)│
|
||||||
|
└────────────────┬────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────────────▼────────────────────────┐
|
||||||
|
│ Action Layer │
|
||||||
|
│ • Server Actions │
|
||||||
|
│ • Form Validation (Zod) │
|
||||||
|
│ • Redirect & Error Handling │
|
||||||
|
└────────────────┬────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────────────▼────────────────────────┐
|
||||||
|
│ Service Layer │
|
||||||
|
│ • Business Logic │
|
||||||
|
│ • better-auth Integration │
|
||||||
|
│ • Cross-module Coordination │
|
||||||
|
└────────────────┬────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────────────▼────────────────────────┐
|
||||||
|
│ Repository Layer │
|
||||||
|
│ • Prisma Database Operations │
|
||||||
|
│ • Data Access Abstraction │
|
||||||
|
│ • Query Optimization │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 核心模块
|
||||||
|
|
||||||
|
### 认证系统 (auth)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 支持多种登录方式
|
||||||
|
- 邮箱/密码登录
|
||||||
|
- 用户名登录
|
||||||
|
- GitHub OAuth
|
||||||
|
- 邮箱验证
|
||||||
|
```
|
||||||
|
|
||||||
|
### 翻译模块 (translator)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// AI 驱动的智能翻译
|
||||||
|
- 多语言互译
|
||||||
|
- IPA 音标标注
|
||||||
|
- 翻译历史记录
|
||||||
|
- 上下文理解
|
||||||
|
```
|
||||||
|
|
||||||
|
### 词典模块 (dictionary)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 智能词典查询
|
||||||
|
- 单词释义
|
||||||
|
- 词性分析
|
||||||
|
- 例句展示
|
||||||
|
- 词频统计
|
||||||
|
```
|
||||||
|
|
||||||
|
### 文件夹模块 (folder)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 学习资料管理
|
||||||
|
- 创建/删除文件夹
|
||||||
|
- 添加语言对
|
||||||
|
- IPA 标注
|
||||||
|
- 批量管理
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗄️ 数据模型
|
||||||
|
|
||||||
|
核心数据模型关系:
|
||||||
|
|
||||||
|
```
|
||||||
|
User (用户)
|
||||||
|
├─ Account (账户)
|
||||||
|
├─ Session (会话)
|
||||||
|
├─ Folder (文件夹)
|
||||||
|
│ └─ Pair (语言对)
|
||||||
|
├─ DictionaryLookUp (查询记录)
|
||||||
|
│ └─ DictionaryItem (词典项)
|
||||||
|
│ └─ DictionaryEntry (词条)
|
||||||
|
└─ TranslationHistory (翻译历史)
|
||||||
|
```
|
||||||
|
|
||||||
|
详细模型定义:[prisma/schema.prisma](./prisma/schema.prisma)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌍 国际化支持
|
||||||
|
|
||||||
|
当前支持的语言:
|
||||||
|
|
||||||
|
| 语言 | 代码 | 区域 |
|
||||||
|
|------|------|------|
|
||||||
|
| English | en-US | 美国 |
|
||||||
|
| 中文 | zh-CN | 中国 |
|
||||||
|
| 日本語 | ja-JP | 日本 |
|
||||||
|
| 한국어 | ko-KR | 韩国 |
|
||||||
|
| Deutsch | de-DE | 德国 |
|
||||||
|
| Français | fr-FR | 法国 |
|
||||||
|
| Italiano | it-IT | 意大利 |
|
||||||
|
| ئۇيغۇرچە | ug-CN | 新疆 |
|
||||||
|
|
||||||
|
添加新语言:
|
||||||
|
|
||||||
|
1. 在 `messages/` 创建语言文件
|
||||||
|
2. 在 `src/i18n/config.ts` 添加配置
|
||||||
|
3. 更新语言选择器组件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 开发指南
|
||||||
|
|
||||||
|
### 可用脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开发
|
||||||
|
pnpm dev # 启动开发服务器 (HTTPS)
|
||||||
|
pnpm build # 构建生产版本
|
||||||
|
pnpm start # 启动生产服务器
|
||||||
|
pnpm lint # 代码检查
|
||||||
|
|
||||||
|
# 数据库
|
||||||
|
pnpm prisma studio # 打开数据库 GUI
|
||||||
|
pnpm prisma db push # 同步 Schema
|
||||||
|
pnpm prisma migrate # 创建迁移
|
||||||
|
```
|
||||||
|
|
||||||
|
### 代码规范
|
||||||
|
|
||||||
|
- ✅ TypeScript 严格模式
|
||||||
|
- ✅ ESLint + TypeScript Plugin
|
||||||
|
- ✅ 优先使用 Server Components
|
||||||
|
- ✅ 新功能遵循 Action-Service-Repository
|
||||||
|
- ✅ 所有用户文本需要国际化
|
||||||
|
- ✅ 组件复用设计系统和业务组件
|
||||||
|
|
||||||
|
### 目录约定
|
||||||
|
|
||||||
|
- `modules/` - 业务模块,每个模块包含:
|
||||||
|
- `*-action.ts` - Server Actions
|
||||||
|
- `*-service.ts` - 业务逻辑
|
||||||
|
- `*-repository.ts` - 数据访问
|
||||||
|
- `*-dto.ts` - 数据传输对象
|
||||||
|
- `components/` - 业务相关组件
|
||||||
|
- `design-system/` - 可复用基础组件
|
||||||
|
- `lib/` - 工具函数和库
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 贡献指南
|
||||||
|
|
||||||
|
我们欢迎各种贡献!
|
||||||
|
|
||||||
|
### 贡献流程
|
||||||
|
|
||||||
|
1. Fork 本仓库
|
||||||
|
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||||
|
3. 提交更改 (`git commit -m 'Add: AmazingFeature'`)
|
||||||
|
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||||
|
5. 开启 Pull Request
|
||||||
|
|
||||||
|
### 代码提交规范
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: 新功能
|
||||||
|
fix: 修复问题
|
||||||
|
docs: 文档变更
|
||||||
|
style: 代码格式
|
||||||
|
refactor: 重构
|
||||||
|
test: 测试相关
|
||||||
|
chore: 构建/工具
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 许可证
|
||||||
|
|
||||||
|
本项目采用 [AGPL-3.0](./LICENSE) 许可证。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 联系方式
|
||||||
|
|
||||||
|
- **问题反馈**:[GitHub Issues](../../issues)
|
||||||
|
- **邮箱**:goddonebianu@outlook.com
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
**如果这个项目对你有帮助,请给一个 ⭐️ Star!**
|
||||||
|
|
||||||
|
Made with ❤️ by the community
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,37 +1,57 @@
|
|||||||
{
|
{
|
||||||
"alphabet": {
|
"alphabet": {
|
||||||
"chooseCharacters": "Bitte wählen Sie die Zeichen aus, die Sie lernen möchten",
|
"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",
|
"japanese": "Japanische Kana",
|
||||||
"english": "Englisches Alphabet",
|
"english": "Englisches Alphabet",
|
||||||
"uyghur": "Uigurisches Alphabet",
|
"uyghur": "Uigurisches Alphabet",
|
||||||
"esperanto": "Esperanto-Alphabet",
|
"esperanto": "Esperanto-Alphabet",
|
||||||
"loading": "Laden...",
|
"loading": "Wird geladen...",
|
||||||
"loadFailed": "Laden fehlgeschlagen, bitte versuchen Sie es erneut",
|
"loadFailed": "Laden fehlgeschlagen, bitte versuchen Sie es erneut",
|
||||||
"hideLetter": "Zeichen ausblenden",
|
"hideLetter": "Buchstabe ausblenden",
|
||||||
"showLetter": "Zeichen anzeigen",
|
"showLetter": "Buchstabe anzeigen",
|
||||||
"hideIPA": "IPA ausblenden",
|
"hideIPA": "IPA ausblenden",
|
||||||
"showIPA": "IPA anzeigen",
|
"showIPA": "IPA anzeigen",
|
||||||
"roman": "Romanisierung",
|
"roman": "Romanisierung",
|
||||||
"letter": "Zeichen",
|
"letter": "Buchstabe",
|
||||||
"random": "Zufälliger Modus",
|
"random": "Zufallsmodus",
|
||||||
"randomNext": "Zufällig weiter"
|
"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": {
|
"folders": {
|
||||||
"title": "Ordner",
|
"title": "Ordner",
|
||||||
"subtitle": "Verwalten Sie Ihre Sammlungen",
|
"subtitle": "Verwalten Sie Ihre Sammlungen",
|
||||||
"newFolder": "Neuer Ordner",
|
"newFolder": "Neuer Ordner",
|
||||||
"creating": "Erstellen...",
|
"creating": "Wird erstellt...",
|
||||||
"noFoldersYet": "Noch keine Ordner",
|
"noFoldersYet": "Noch keine Ordner vorhanden",
|
||||||
"folderInfo": "ID: {id} • {totalPairs} Paare",
|
"folderInfo": "ID: {id} • {totalPairs} Paare",
|
||||||
"enterFolderName": "Ordnernamen eingeben:",
|
"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": {
|
"folder_id": {
|
||||||
"unauthorized": "Sie sind nicht der Eigentümer dieses Ordners",
|
"unauthorized": "Sie sind nicht der Besitzer dieses Ordners",
|
||||||
"back": "Zurück",
|
"back": "Zurück",
|
||||||
"textPairs": "Textpaare",
|
"textPairs": "Textpaare",
|
||||||
"itemsCount": "{count} Elemente",
|
"itemsCount": "{count} Einträge",
|
||||||
"memorize": "Einprägen",
|
"memorize": "Auswendig lernen",
|
||||||
"loadingTextPairs": "Textpaare werden geladen...",
|
"loadingTextPairs": "Textpaare werden geladen...",
|
||||||
"noTextPairs": "Keine Textpaare in diesem Ordner",
|
"noTextPairs": "Keine Textpaare in diesem Ordner",
|
||||||
"addNewTextPair": "Neues Textpaar hinzufügen",
|
"addNewTextPair": "Neues Textpaar hinzufügen",
|
||||||
@@ -42,14 +62,14 @@
|
|||||||
"text2": "Text 2",
|
"text2": "Text 2",
|
||||||
"language1": "Sprache 1",
|
"language1": "Sprache 1",
|
||||||
"language2": "Sprache 2",
|
"language2": "Sprache 2",
|
||||||
"enterLanguageName": "Bitte geben Sie den Sprachennamen ein",
|
"enterLanguageName": "Bitte Sprachnamen eingeben",
|
||||||
"edit": "Bearbeiten",
|
"edit": "Bearbeiten",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"permissionDenied": "Sie haben keine Berechtigung, diese Aktion auszuführen",
|
"permissionDenied": "Sie haben keine Berechtigung für diese Aktion",
|
||||||
"error": {
|
"error": {
|
||||||
"update": "Sie haben keine Berechtigung, dieses Element zu aktualisieren.",
|
"update": "Sie haben keine Berechtigung, diesen Eintrag zu aktualisieren.",
|
||||||
"delete": "Sie haben keine Berechtigung, dieses Element zu löschen.",
|
"delete": "Sie haben keine Berechtigung, diesen Eintrag zu löschen.",
|
||||||
"add": "Sie haben keine Berechtigung, Elemente zu diesem Ordner hinzuzufügen.",
|
"add": "Sie haben keine Berechtigung, Einträge zu diesem Ordner hinzuzufügen.",
|
||||||
"rename": "Sie haben keine Berechtigung, diesen Ordner umzubenennen.",
|
"rename": "Sie haben keine Berechtigung, diesen Ordner umzubenennen.",
|
||||||
"deleteFolder": "Sie haben keine Berechtigung, diesen Ordner zu löschen."
|
"deleteFolder": "Sie haben keine Berechtigung, diesen Ordner zu löschen."
|
||||||
}
|
}
|
||||||
@@ -57,48 +77,51 @@
|
|||||||
"home": {
|
"home": {
|
||||||
"title": "Sprachen lernen",
|
"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.",
|
"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": {
|
"fortune": {
|
||||||
"quote": "Bleib hungrig, bleiv dumm.",
|
"quote": "Stay hungry, stay foolish.",
|
||||||
"author": "— Steve Jobs"
|
"author": "— Steve Jobs"
|
||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"name": "Übersetzer",
|
"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": {
|
"textSpeaker": {
|
||||||
"name": "Text-Sprecher",
|
"name": "Textvorleser",
|
||||||
"description": "Text erkennen und vorlesen, unterstützt Schleifenwiedergabe und Geschwindigkeitsanpassung"
|
"description": "Text erkennen und vorlesen, unterstützt Schleifenwiedergabe und Geschwindigkeitsanpassung"
|
||||||
},
|
},
|
||||||
"srtPlayer": {
|
"srtPlayer": {
|
||||||
"name": "SRT-Videoplayer",
|
"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": {
|
"alphabet": {
|
||||||
"name": "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": {
|
"memorize": {
|
||||||
"name": "Einprägen",
|
"name": "Auswendig lernen",
|
||||||
"description": "Sprache A zu Sprache B, Sprache B zu Sprache A, unterstützt Diktat"
|
"description": "Sprache A zu Sprache B, Sprache B zu Sprache A, unterstützt Diktat"
|
||||||
},
|
},
|
||||||
"dictionary": {
|
"dictionary": {
|
||||||
"name": "Wörterbuch",
|
"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": {
|
"moreFeatures": {
|
||||||
"name": "Weitere Funktionen",
|
"name": "Weitere Funktionen",
|
||||||
"description": "In Entwicklung, bleiben Sie dran"
|
"description": "In Entwicklung, bleiben Sie gespannt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "Authentifizierung",
|
"title": "Anmelden",
|
||||||
|
"signUpTitle": "Registrieren",
|
||||||
"signIn": "Anmelden",
|
"signIn": "Anmelden",
|
||||||
"signUp": "Registrieren",
|
"signUp": "Registrieren",
|
||||||
"email": "E-Mail",
|
"email": "E-Mail",
|
||||||
"password": "Passwort",
|
"password": "Passwort",
|
||||||
"confirmPassword": "Passwort bestätigen",
|
"confirmPassword": "Passwort bestätigen",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
"username": "Benutzername",
|
||||||
|
"emailOrUsername": "E-Mail oder Benutzername",
|
||||||
"signInButton": "Anmelden",
|
"signInButton": "Anmelden",
|
||||||
"signUpButton": "Registrieren",
|
"signUpButton": "Registrieren",
|
||||||
"noAccount": "Haben Sie kein Konto?",
|
"noAccount": "Haben Sie kein Konto?",
|
||||||
@@ -107,16 +130,47 @@
|
|||||||
"signUpWithGitHub": "Mit GitHub registrieren",
|
"signUpWithGitHub": "Mit GitHub registrieren",
|
||||||
"invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
"invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||||
"passwordTooShort": "Das Passwort muss mindestens 8 Zeichen lang sein",
|
"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",
|
"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",
|
"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",
|
"passwordRequired": "Bitte geben Sie Ihr Passwort ein",
|
||||||
"confirmPasswordRequired": "Bitte bestätigen Sie Ihr Passwort",
|
"confirmPasswordRequired": "Bitte bestätigen Sie Ihr Passwort",
|
||||||
"loading": "Laden..."
|
"loading": "Wird geladen...",
|
||||||
|
"confirm": "Bestätigen",
|
||||||
|
"noAccountLink": "Haben Sie kein Konto? Registrieren Sie sich",
|
||||||
|
"hasAccountLink": "Haben Sie bereits ein Konto? Anmelden",
|
||||||
|
"usernamePlaceholder": "Benutzername",
|
||||||
|
"emailPlaceholder": "E-Mail-Adresse",
|
||||||
|
"passwordPlaceholder": "Passwort",
|
||||||
|
"usernameOrEmailPlaceholder": "Benutzername oder E-Mail",
|
||||||
|
"loginFailed": "Anmeldung fehlgeschlagen",
|
||||||
|
"signUpFailed": "Registrierung fehlgeschlagen",
|
||||||
|
"fillAllFields": "Bitte füllen Sie alle Felder aus",
|
||||||
|
"enterCredentials": "Bitte geben Sie Benutzername und Passwort ein",
|
||||||
|
"forgotPassword": "Passwort vergessen",
|
||||||
|
"forgotPasswordHint": "Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen einen Link zum Zurücksetzen Ihres Passworts.",
|
||||||
|
"sendResetEmail": "Reset-E-Mail senden",
|
||||||
|
"resetPasswordFailed": "Reset-E-Mail konnte nicht gesendet werden",
|
||||||
|
"resetPasswordEmailSent": "Reset-E-Mail erfolgreich gesendet",
|
||||||
|
"resetPasswordEmailSentHint": "Wir haben einen Link zum Zurücksetzen Ihres Passworts an Ihre E-Mail-Adresse gesendet. Bitte überprüfen Sie Ihren Posteingang.",
|
||||||
|
"checkYourEmail": "Überprüfen Sie Ihre E-Mail",
|
||||||
|
"backToLogin": "Zurück zur Anmeldung",
|
||||||
|
"resetPassword": "Passwort zurücksetzen",
|
||||||
|
"newPassword": "Neues Passwort",
|
||||||
|
"invalidToken": "Ungültiger oder abgelaufener Link",
|
||||||
|
"invalidTokenHint": "Dieser Link zum Zurücksetzen des Passworts ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen an.",
|
||||||
|
"requestNewToken": "Neuen Reset-Link anfordern",
|
||||||
|
"resetPasswordSuccess": "Passwort erfolgreich zurückgesetzt",
|
||||||
|
"resetPasswordSuccessTitle": "Passwort-Zurücksetzung abgeschlossen",
|
||||||
|
"resetPasswordSuccessHint": "Ihr Passwort wurde erfolgreich zurückgesetzt. Sie können sich jetzt mit Ihrem neuen Passwort anmelden."
|
||||||
},
|
},
|
||||||
"memorize": {
|
"memorize": {
|
||||||
"folder_selector": {
|
"folder_selector": {
|
||||||
"selectFolder": "Wählen Sie einen Ordner aus",
|
"selectFolder": "Wählen Sie einen Ordner",
|
||||||
"noFolders": "Keine Ordner gefunden",
|
"noFolders": "Keine Ordner gefunden",
|
||||||
"folderInfo": "{id}. {name} ({count})"
|
"folderInfo": "{id}. {name} ({count})"
|
||||||
},
|
},
|
||||||
@@ -138,7 +192,9 @@
|
|||||||
"sourceCode": "GitHub",
|
"sourceCode": "GitHub",
|
||||||
"sign_in": "Anmelden",
|
"sign_in": "Anmelden",
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"folders": "Ordner"
|
"folders": "Ordner",
|
||||||
|
"explore": "Entdecken",
|
||||||
|
"favorites": "Favoriten"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"myProfile": "Mein Profil",
|
"myProfile": "Mein Profil",
|
||||||
@@ -164,21 +220,27 @@
|
|||||||
"uploaded": "Hochgeladen",
|
"uploaded": "Hochgeladen",
|
||||||
"notUploaded": "Nicht hochgeladen",
|
"notUploaded": "Nicht hochgeladen",
|
||||||
"upload": "Hochladen",
|
"upload": "Hochladen",
|
||||||
|
"uploadVideoButton": "Video hochladen",
|
||||||
|
"uploadSubtitleButton": "Untertitel hochladen",
|
||||||
|
"subtitleUploaded": "Untertitel hochgeladen ({count} Einträge)",
|
||||||
|
"subtitleNotUploaded": "Untertitel nicht hochgeladen",
|
||||||
"autoPauseStatus": "Auto-Pause: {enabled}",
|
"autoPauseStatus": "Auto-Pause: {enabled}",
|
||||||
"on": "Ein",
|
"on": "Ein",
|
||||||
"off": "Aus",
|
"off": "Aus",
|
||||||
"videoUploadFailed": "Video-Upload fehlgeschlagen",
|
"videoUploadFailed": "Video-Upload fehlgeschlagen",
|
||||||
"subtitleUploadFailed": "Untertitel-Upload fehlgeschlagen"
|
"subtitleUploadFailed": "Untertitel-Upload fehlgeschlagen",
|
||||||
|
"subtitleLoadSuccess": "Untertitel erfolgreich geladen",
|
||||||
|
"subtitleLoadFailed": "Laden der Untertitel fehlgeschlagen"
|
||||||
},
|
},
|
||||||
"text_speaker": {
|
"text_speaker": {
|
||||||
"generateIPA": "IPA generieren",
|
"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)"
|
"confirmDeleteAll": "Sind Sie sicher, dass Sie alles löschen möchten? (J/N)"
|
||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"detectLanguage": "Sprache erkennen",
|
"detectLanguage": "Sprache erkennen",
|
||||||
"generateIPA": "IPA generieren",
|
"generateIPA": "IPA generieren",
|
||||||
"translateInto": "Übersetzen in",
|
"translateInto": "übersetzen in",
|
||||||
"chinese": "Chinesisch",
|
"chinese": "Chinesisch",
|
||||||
"english": "Englisch",
|
"english": "Englisch",
|
||||||
"french": "Französisch",
|
"french": "Französisch",
|
||||||
@@ -190,49 +252,88 @@
|
|||||||
"russian": "Russisch",
|
"russian": "Russisch",
|
||||||
"spanish": "Spanisch",
|
"spanish": "Spanisch",
|
||||||
"other": "Andere",
|
"other": "Andere",
|
||||||
"translating": "Übersetzung läuft...",
|
"translating": "wird übersetzt...",
|
||||||
"translate": "Übersetzen",
|
"translate": "übersetzen",
|
||||||
"inputLanguage": "Geben Sie eine Sprache ein.",
|
"inputLanguage": "Geben Sie eine Sprache ein.",
|
||||||
"history": "Verlauf",
|
"history": "Verlauf",
|
||||||
"enterLanguage": "Sprache eingeben",
|
"enterLanguage": "Sprache eingeben",
|
||||||
"add_to_folder": {
|
"add_to_folder": {
|
||||||
"notAuthenticated": "Sie sind nicht authentifiziert",
|
"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",
|
"noFolders": "Keine Ordner gefunden",
|
||||||
"folderInfo": "{id}. {name}",
|
"folderInfo": "{id}. {name}",
|
||||||
"close": "Schließen",
|
"close": "Schließen",
|
||||||
"success": "Textpaar zum Ordner hinzugefügt",
|
"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": {
|
"dictionary": {
|
||||||
"title": "Wörterbuch",
|
"title": "Wörterbuch",
|
||||||
"description": "Wörter und Ausdrücke mit detaillierten Definitionen und Beispielen nachschlagen",
|
"description": "Wörter und Ausdrücke mit detaillierten Definitionen und Beispielen nachschlagen",
|
||||||
"searchPlaceholder": "Wort oder Ausdruck zum Nachschlagen eingeben...",
|
"searchPlaceholder": "Geben Sie ein Wort oder einen Ausdruck zum Nachschlagen ein...",
|
||||||
"searching": "Suche...",
|
"searching": "Suche läuft...",
|
||||||
"search": "Suchen",
|
"search": "Suchen",
|
||||||
"languageSettings": "Spracheinstellungen",
|
"languageSettings": "Spracheinstellungen",
|
||||||
"queryLanguage": "Abfragesprache",
|
"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",
|
"definitionLanguage": "Definitionssprache",
|
||||||
"definitionLanguageHint": "In welcher Sprache möchten Sie die Definitionen sehen",
|
"definitionLanguageHint": "In welcher Sprache möchten Sie die Definitionen",
|
||||||
"otherLanguagePlaceholder": "Oder eine andere Sprache eingeben...",
|
"otherLanguagePlaceholder": "Oder geben Sie eine andere Sprache ein...",
|
||||||
|
"other": "Andere",
|
||||||
"currentSettings": "Aktuelle Einstellungen: Abfrage {queryLang}, Definition {definitionLang}",
|
"currentSettings": "Aktuelle Einstellungen: Abfrage {queryLang}, Definition {definitionLang}",
|
||||||
"relookup": "Neu suchen",
|
"relookup": "Erneut suchen",
|
||||||
"saveToFolder": "In Ordner speichern",
|
"saveToFolder": "In Ordner speichern",
|
||||||
"loading": "Laden...",
|
"loading": "Wird geladen...",
|
||||||
"noResults": "Keine Ergebnisse gefunden",
|
"noResults": "Keine Ergebnisse gefunden",
|
||||||
"tryOtherWords": "Versuchen Sie andere Wörter oder Ausdrücke",
|
"tryOtherWords": "Versuchen Sie andere Wörter oder Ausdrücke",
|
||||||
"welcomeTitle": "Willkommen beim Wörterbuch",
|
"welcomeTitle": "Willkommen im Wörterbuch",
|
||||||
"welcomeHint": "Geben Sie oben im Suchfeld ein Wort oder einen Ausdruck ein, um zu suchen",
|
"welcomeHint": "Geben Sie oben in das Suchfeld ein Wort oder einen Ausdruck ein, um mit dem Nachschlagen zu beginnen",
|
||||||
"lookupFailed": "Suche fehlgeschlagen, bitte später erneut versuchen",
|
"lookupFailed": "Suche fehlgeschlagen, bitte versuchen Sie es später erneut",
|
||||||
"relookupSuccess": "Erfolgreich neu gesucht",
|
"relookupSuccess": "Erneute Suche erfolgreich",
|
||||||
"relookupFailed": "Wörterbuch Neu-Suche fehlgeschlagen",
|
"relookupFailed": "Erneute Wörterbuchsuche fehlgeschlagen",
|
||||||
"pleaseLogin": "Bitte melden Sie sich zuerst an",
|
"pleaseLogin": "Bitte melden Sie sich zuerst an",
|
||||||
"pleaseCreateFolder": "Bitte erstellen Sie zuerst einen Ordner",
|
"pleaseCreateFolder": "Bitte erstellen Sie zuerst einen Ordner",
|
||||||
"savedToFolder": "Im Ordner gespeichert: {folderName}",
|
"savedToFolder": "In Ordner gespeichert: {folderName}",
|
||||||
"saveFailed": "Speichern fehlgeschlagen, bitte später erneut versuchen"
|
"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": {
|
"user_profile": {
|
||||||
"anonymous": "Anonym",
|
"anonymous": "Anonym",
|
||||||
@@ -245,14 +346,15 @@
|
|||||||
"displayName": "Anzeigename",
|
"displayName": "Anzeigename",
|
||||||
"notSet": "Nicht festgelegt",
|
"notSet": "Nicht festgelegt",
|
||||||
"memberSince": "Mitglied seit",
|
"memberSince": "Mitglied seit",
|
||||||
|
"logout": "Abmelden",
|
||||||
"folders": {
|
"folders": {
|
||||||
"title": "Ordner",
|
"title": "Ordner",
|
||||||
"noFolders": "Noch keine Ordner",
|
"noFolders": "Noch keine Ordner",
|
||||||
"folderName": "Ordnername",
|
"folderName": "Ordnername",
|
||||||
"totalPairs": "Anzahl der Paare",
|
"totalPairs": "Gesamtpaare",
|
||||||
"createdAt": "Erstellt am",
|
"createdAt": "Erstellt am",
|
||||||
"actions": "Aktionen",
|
"actions": "Aktionen",
|
||||||
"view": "Ansehen"
|
"view": "Anzeigen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"alphabet": {
|
"alphabet": {
|
||||||
"chooseCharacters": "Please select the characters you want to learn",
|
"chooseCharacters": "Please select the characters you want to learn",
|
||||||
|
"chooseAlphabetHint": "Select an alphabet to start learning",
|
||||||
"japanese": "Japanese Kana",
|
"japanese": "Japanese Kana",
|
||||||
"english": "English Alphabet",
|
"english": "English Alphabet",
|
||||||
"uyghur": "Uyghur Alphabet",
|
"uyghur": "Uyghur Alphabet",
|
||||||
@@ -14,7 +15,11 @@
|
|||||||
"roman": "Romanization",
|
"roman": "Romanization",
|
||||||
"letter": "Letter",
|
"letter": "Letter",
|
||||||
"random": "Random Mode",
|
"random": "Random Mode",
|
||||||
"randomNext": "Random Next"
|
"randomNext": "Random Next",
|
||||||
|
"previousLetter": "Previous letter",
|
||||||
|
"nextLetter": "Next letter",
|
||||||
|
"keyboardHint": "Use left/right arrow keys or space for random, ESC to go back",
|
||||||
|
"swipeHint": "Use left/right arrow keys or swipe to navigate, ESC to go back"
|
||||||
},
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
"title": "Folders",
|
"title": "Folders",
|
||||||
@@ -24,7 +29,22 @@
|
|||||||
"noFoldersYet": "No folders yet",
|
"noFoldersYet": "No folders yet",
|
||||||
"folderInfo": "ID: {id} • {totalPairs} pairs",
|
"folderInfo": "ID: {id} • {totalPairs} pairs",
|
||||||
"enterFolderName": "Enter folder name:",
|
"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": {
|
"folder_id": {
|
||||||
"unauthorized": "You are not the owner of this folder",
|
"unauthorized": "You are not the owner of this folder",
|
||||||
@@ -92,7 +112,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "Authentication",
|
"title": "Sign In",
|
||||||
|
"signUpTitle": "Sign Up",
|
||||||
"signIn": "Sign In",
|
"signIn": "Sign In",
|
||||||
"signUp": "Sign Up",
|
"signUp": "Sign Up",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
@@ -118,7 +139,34 @@
|
|||||||
"identifierRequired": "Please enter your email or username",
|
"identifierRequired": "Please enter your email or username",
|
||||||
"passwordRequired": "Please enter your password",
|
"passwordRequired": "Please enter your password",
|
||||||
"confirmPasswordRequired": "Please confirm your password",
|
"confirmPasswordRequired": "Please confirm your password",
|
||||||
"loading": "Loading..."
|
"loading": "Loading...",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"noAccountLink": "Don't have an account? Sign up",
|
||||||
|
"hasAccountLink": "Already have an account? Sign in",
|
||||||
|
"usernamePlaceholder": "Username",
|
||||||
|
"emailPlaceholder": "Email address",
|
||||||
|
"passwordPlaceholder": "Password",
|
||||||
|
"usernameOrEmailPlaceholder": "Username or email",
|
||||||
|
"loginFailed": "Login failed",
|
||||||
|
"signUpFailed": "Sign up failed",
|
||||||
|
"fillAllFields": "Please fill in all fields",
|
||||||
|
"enterCredentials": "Please enter username and password",
|
||||||
|
"forgotPassword": "Forgot Password",
|
||||||
|
"forgotPasswordHint": "Enter your email address and we'll send you a link to reset your password.",
|
||||||
|
"sendResetEmail": "Send Reset Email",
|
||||||
|
"resetPasswordFailed": "Failed to send reset email",
|
||||||
|
"resetPasswordEmailSent": "Reset email sent successfully",
|
||||||
|
"resetPasswordEmailSentHint": "We've sent a password reset link to your email address. Please check your inbox.",
|
||||||
|
"checkYourEmail": "Check Your Email",
|
||||||
|
"backToLogin": "Back to Login",
|
||||||
|
"resetPassword": "Reset Password",
|
||||||
|
"newPassword": "New Password",
|
||||||
|
"invalidToken": "Invalid or Expired Link",
|
||||||
|
"invalidTokenHint": "This password reset link is invalid or has expired. Please request a new one.",
|
||||||
|
"requestNewToken": "Request New Reset Link",
|
||||||
|
"resetPasswordSuccess": "Password reset successfully",
|
||||||
|
"resetPasswordSuccessTitle": "Password Reset Complete",
|
||||||
|
"resetPasswordSuccessHint": "Your password has been reset successfully. You can now log in with your new password."
|
||||||
},
|
},
|
||||||
"memorize": {
|
"memorize": {
|
||||||
"folder_selector": {
|
"folder_selector": {
|
||||||
@@ -144,7 +192,9 @@
|
|||||||
"sourceCode": "GitHub",
|
"sourceCode": "GitHub",
|
||||||
"sign_in": "Sign In",
|
"sign_in": "Sign In",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"folders": "Folders"
|
"folders": "Folders",
|
||||||
|
"explore": "Explore",
|
||||||
|
"favorites": "Favorites"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"myProfile": "My Profile",
|
"myProfile": "My Profile",
|
||||||
@@ -170,11 +220,17 @@
|
|||||||
"uploaded": "Uploaded",
|
"uploaded": "Uploaded",
|
||||||
"notUploaded": "Not Uploaded",
|
"notUploaded": "Not Uploaded",
|
||||||
"upload": "Upload",
|
"upload": "Upload",
|
||||||
|
"uploadVideoButton": "Upload Video",
|
||||||
|
"uploadSubtitleButton": "Upload Subtitle",
|
||||||
|
"subtitleUploaded": "Subtitle Uploaded ({count} entries)",
|
||||||
|
"subtitleNotUploaded": "Subtitle Not Uploaded",
|
||||||
"autoPauseStatus": "Auto Pause: {enabled}",
|
"autoPauseStatus": "Auto Pause: {enabled}",
|
||||||
"on": "On",
|
"on": "On",
|
||||||
"off": "Off",
|
"off": "Off",
|
||||||
"videoUploadFailed": "Video upload failed",
|
"videoUploadFailed": "Video upload failed",
|
||||||
"subtitleUploadFailed": "Subtitle upload failed"
|
"subtitleUploadFailed": "Subtitle upload failed",
|
||||||
|
"subtitleLoadSuccess": "Subtitle loaded successfully",
|
||||||
|
"subtitleLoadFailed": "Subtitle load failed"
|
||||||
},
|
},
|
||||||
"text_speaker": {
|
"text_speaker": {
|
||||||
"generateIPA": "Generate IPA",
|
"generateIPA": "Generate IPA",
|
||||||
@@ -224,6 +280,7 @@
|
|||||||
"definitionLanguage": "Definition Language",
|
"definitionLanguage": "Definition Language",
|
||||||
"definitionLanguageHint": "What language do you want the definitions in",
|
"definitionLanguageHint": "What language do you want the definitions in",
|
||||||
"otherLanguagePlaceholder": "Or enter another language...",
|
"otherLanguagePlaceholder": "Or enter another language...",
|
||||||
|
"other": "Other",
|
||||||
"currentSettings": "Current settings: Query {queryLang}, Definition {definitionLang}",
|
"currentSettings": "Current settings: Query {queryLang}, Definition {definitionLang}",
|
||||||
"relookup": "Re-search",
|
"relookup": "Re-search",
|
||||||
"saveToFolder": "Save to folder",
|
"saveToFolder": "Save to folder",
|
||||||
@@ -238,7 +295,45 @@
|
|||||||
"pleaseLogin": "Please log in first",
|
"pleaseLogin": "Please log in first",
|
||||||
"pleaseCreateFolder": "Please create a folder first",
|
"pleaseCreateFolder": "Please create a folder first",
|
||||||
"savedToFolder": "Saved to folder: {folderName}",
|
"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": {
|
"user_profile": {
|
||||||
"anonymous": "Anonymous",
|
"anonymous": "Anonymous",
|
||||||
@@ -251,6 +346,7 @@
|
|||||||
"displayName": "Display Name",
|
"displayName": "Display Name",
|
||||||
"notSet": "Not Set",
|
"notSet": "Not Set",
|
||||||
"memberSince": "Member Since",
|
"memberSince": "Member Since",
|
||||||
|
"logout": "Logout",
|
||||||
"folders": {
|
"folders": {
|
||||||
"title": "Folders",
|
"title": "Folders",
|
||||||
"noFolders": "No folders yet",
|
"noFolders": "No folders yet",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"alphabet": {
|
"alphabet": {
|
||||||
"chooseCharacters": "Veuillez sélectionner les caractères que vous souhaitez apprendre",
|
"chooseCharacters": "Veuillez sélectionner les caractères que vous souhaitez apprendre",
|
||||||
|
"chooseAlphabetHint": "Sélectionnez un alphabet pour commencer à apprendre",
|
||||||
"japanese": "Kana japonais",
|
"japanese": "Kana japonais",
|
||||||
"english": "Alphabet anglais",
|
"english": "Alphabet anglais",
|
||||||
"uyghur": "Alphabet ouïghour",
|
"uyghur": "Alphabet ouïghour",
|
||||||
@@ -14,29 +15,48 @@
|
|||||||
"roman": "Romanisation",
|
"roman": "Romanisation",
|
||||||
"letter": "Lettre",
|
"letter": "Lettre",
|
||||||
"random": "Mode aléatoire",
|
"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": {
|
"folders": {
|
||||||
"title": "Dossiers",
|
"title": "Dossiers",
|
||||||
"subtitle": "Gérez vos collections",
|
"subtitle": "Gérez vos collections",
|
||||||
"newFolder": "Nouveau dossier",
|
"newFolder": "Nouveau dossier",
|
||||||
"creating": "Création...",
|
"creating": "Création...",
|
||||||
"noFoldersYet": "Aucun dossier pour le moment",
|
"noFoldersYet": "Pas encore de dossiers",
|
||||||
"folderInfo": "ID: {id} • {totalPairs} paires",
|
"folderInfo": "ID : {id} • {totalPairs} paires",
|
||||||
"enterFolderName": "Entrez le nom du dossier:",
|
"enterFolderName": "Entrez le nom du dossier :",
|
||||||
"confirmDelete": "Tapez \"{name}\" pour supprimer:"
|
"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": {
|
"folder_id": {
|
||||||
"unauthorized": "Vous n'êtes pas le propriétaire de ce dossier",
|
"unauthorized": "Vous n'êtes pas le propriétaire de ce dossier",
|
||||||
"back": "Retour",
|
"back": "Retour",
|
||||||
"textPairs": "Paires de textes",
|
"textPairs": "Paires de texte",
|
||||||
"itemsCount": "{count} éléments",
|
"itemsCount": "{count} éléments",
|
||||||
"memorize": "Mémoriser",
|
"memorize": "Mémoriser",
|
||||||
"loadingTextPairs": "Chargement des paires de textes...",
|
"loadingTextPairs": "Chargement des paires de texte...",
|
||||||
"noTextPairs": "Aucune paire de textes dans ce dossier",
|
"noTextPairs": "Aucune paire de texte dans ce dossier",
|
||||||
"addNewTextPair": "Ajouter une nouvelle paire de textes",
|
"addNewTextPair": "Ajouter une nouvelle paire de texte",
|
||||||
"add": "Ajouter",
|
"add": "Ajouter",
|
||||||
"updateTextPair": "Mettre à jour la paire de textes",
|
"updateTextPair": "Mettre à jour la paire de texte",
|
||||||
"update": "Mettre à jour",
|
"update": "Mettre à jour",
|
||||||
"text1": "Texte 1",
|
"text1": "Texte 1",
|
||||||
"text2": "Texte 2",
|
"text2": "Texte 2",
|
||||||
@@ -56,15 +76,15 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "Apprendre les langues",
|
"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",
|
"explore": "Explorer",
|
||||||
"fortune": {
|
"fortune": {
|
||||||
"quote": "Stay hungry, stay foolish.",
|
"quote": "Restez affamés, restez fous.",
|
||||||
"author": "— Steve Jobs"
|
"author": "— Steve Jobs"
|
||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"name": "Traducteur",
|
"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": {
|
"textSpeaker": {
|
||||||
"name": "Lecteur de texte",
|
"name": "Lecteur de texte",
|
||||||
@@ -76,15 +96,15 @@
|
|||||||
},
|
},
|
||||||
"alphabet": {
|
"alphabet": {
|
||||||
"name": "Alphabet",
|
"name": "Alphabet",
|
||||||
"description": "Commencer à apprendre une nouvelle langue par l'alphabet"
|
"description": "Commencez à apprendre une nouvelle langue à partir de l'alphabet"
|
||||||
},
|
},
|
||||||
"memorize": {
|
"memorize": {
|
||||||
"name": "Mémoriser",
|
"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": {
|
"dictionary": {
|
||||||
"name": "Dictionnaire",
|
"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": {
|
"moreFeatures": {
|
||||||
"name": "Plus de fonctionnalités",
|
"name": "Plus de fonctionnalités",
|
||||||
@@ -92,27 +112,61 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "Authentification",
|
"title": "Se connecter",
|
||||||
|
"signUpTitle": "S'inscrire",
|
||||||
"signIn": "Se connecter",
|
"signIn": "Se connecter",
|
||||||
"signUp": "S'inscrire",
|
"signUp": "S'inscrire",
|
||||||
"email": "E-mail",
|
"email": "E-mail",
|
||||||
"password": "Mot de passe",
|
"password": "Mot de passe",
|
||||||
"confirmPassword": "Confirmer le mot de passe",
|
"confirmPassword": "Confirmer le mot de passe",
|
||||||
"name": "Nom",
|
"name": "Nom",
|
||||||
|
"username": "Nom d'utilisateur",
|
||||||
|
"emailOrUsername": "E-mail ou nom d'utilisateur",
|
||||||
"signInButton": "Se connecter",
|
"signInButton": "Se connecter",
|
||||||
"signUpButton": "S'inscrire",
|
"signUpButton": "S'inscrire",
|
||||||
"noAccount": "Vous n'avez pas de compte?",
|
"noAccount": "Vous n'avez pas de compte ?",
|
||||||
"hasAccount": "Vous avez déjà un compte?",
|
"hasAccount": "Vous avez déjà un compte ?",
|
||||||
"signInWithGitHub": "Se connecter avec GitHub",
|
"signInWithGitHub": "Se connecter avec GitHub",
|
||||||
"signUpWithGitHub": "S'inscrire avec GitHub",
|
"signUpWithGitHub": "S'inscrire avec GitHub",
|
||||||
"invalidEmail": "Veuillez entrer une adresse e-mail valide",
|
"invalidEmail": "Veuillez entrer une adresse e-mail valide",
|
||||||
"passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères",
|
"passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères",
|
||||||
"passwordsNotMatch": "Les mots de passe ne correspondent pas",
|
"passwordsNotMatch": "Les mots de passe ne correspondent pas",
|
||||||
"nameRequired": "Veuillez entrer votre nom",
|
"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",
|
"emailRequired": "Veuillez entrer votre e-mail",
|
||||||
|
"identifierRequired": "Veuillez entrer votre e-mail ou nom d'utilisateur",
|
||||||
"passwordRequired": "Veuillez entrer votre mot de passe",
|
"passwordRequired": "Veuillez entrer votre mot de passe",
|
||||||
"confirmPasswordRequired": "Veuillez confirmer votre mot de passe",
|
"confirmPasswordRequired": "Veuillez confirmer votre mot de passe",
|
||||||
"loading": "Chargement..."
|
"loading": "Chargement...",
|
||||||
|
"confirm": "Confirmer",
|
||||||
|
"noAccountLink": "Vous n'avez pas de compte ? Inscrivez-vous",
|
||||||
|
"hasAccountLink": "Vous avez déjà un compte ? Connectez-vous",
|
||||||
|
"usernamePlaceholder": "Nom d'utilisateur",
|
||||||
|
"emailPlaceholder": "Adresse e-mail",
|
||||||
|
"passwordPlaceholder": "Mot de passe",
|
||||||
|
"usernameOrEmailPlaceholder": "Nom d'utilisateur ou e-mail",
|
||||||
|
"loginFailed": "Échec de la connexion",
|
||||||
|
"signUpFailed": "Échec de l'inscription",
|
||||||
|
"fillAllFields": "Veuillez remplir tous les champs",
|
||||||
|
"enterCredentials": "Veuillez entrer le nom d'utilisateur et le mot de passe",
|
||||||
|
"forgotPassword": "Mot de passe oublié",
|
||||||
|
"forgotPasswordHint": "Entrez votre adresse e-mail et nous vous enverrons un lien pour réinitialiser votre mot de passe.",
|
||||||
|
"sendResetEmail": "Envoyer l'e-mail de réinitialisation",
|
||||||
|
"resetPasswordFailed": "Échec de l'envoi de l'e-mail de réinitialisation",
|
||||||
|
"resetPasswordEmailSent": "E-mail de réinitialisation envoyé avec succès",
|
||||||
|
"resetPasswordEmailSentHint": "Nous avons envoyé un lien de réinitialisation de mot de passe à votre adresse e-mail. Veuillez vérifier votre boîte de réception.",
|
||||||
|
"checkYourEmail": "Vérifiez votre e-mail",
|
||||||
|
"backToLogin": "Retour à la connexion",
|
||||||
|
"resetPassword": "Réinitialiser le mot de passe",
|
||||||
|
"newPassword": "Nouveau mot de passe",
|
||||||
|
"invalidToken": "Lien invalide ou expiré",
|
||||||
|
"invalidTokenHint": "Ce lien de réinitialisation de mot de passe est invalide ou a expiré. Veuillez en demander un nouveau.",
|
||||||
|
"requestNewToken": "Demander un nouveau lien de réinitialisation",
|
||||||
|
"resetPasswordSuccess": "Mot de passe réinitialisé avec succès",
|
||||||
|
"resetPasswordSuccessTitle": "Réinitialisation du mot de passe terminée",
|
||||||
|
"resetPasswordSuccessHint": "Votre mot de passe a été réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe."
|
||||||
},
|
},
|
||||||
"memorize": {
|
"memorize": {
|
||||||
"folder_selector": {
|
"folder_selector": {
|
||||||
@@ -125,7 +179,7 @@
|
|||||||
"next": "Suivant",
|
"next": "Suivant",
|
||||||
"reverse": "Inverser",
|
"reverse": "Inverser",
|
||||||
"dictation": "Dictée",
|
"dictation": "Dictée",
|
||||||
"noTextPairs": "Aucune paire de textes disponible",
|
"noTextPairs": "Aucune paire de texte disponible",
|
||||||
"disorder": "Désordre",
|
"disorder": "Désordre",
|
||||||
"previous": "Précédent"
|
"previous": "Précédent"
|
||||||
},
|
},
|
||||||
@@ -134,46 +188,54 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navbar": {
|
"navbar": {
|
||||||
"title": "learn-languages",
|
"title": "apprendre-langues",
|
||||||
"sourceCode": "GitHub",
|
"sourceCode": "GitHub",
|
||||||
"sign_in": "Se connecter",
|
"sign_in": "Se connecter",
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"folders": "Dossiers"
|
"folders": "Dossiers",
|
||||||
|
"explore": "Explorer",
|
||||||
|
"favorites": "Favoris"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"myProfile": "Mon profil",
|
"myProfile": "Mon profil",
|
||||||
"email": "E-mail: {email}",
|
"email": "E-mail : {email}",
|
||||||
"logout": "Se déconnecter"
|
"logout": "Déconnexion"
|
||||||
},
|
},
|
||||||
"srt_player": {
|
"srt_player": {
|
||||||
"uploadVideo": "Télécharger une vidéo",
|
"uploadVideo": "Télécharger la vidéo",
|
||||||
"uploadSubtitle": "Télécharger des sous-titres",
|
"uploadSubtitle": "Télécharger les sous-titres",
|
||||||
"pause": "Pause",
|
"pause": "Pause",
|
||||||
"play": "Lire",
|
"play": "Lecture",
|
||||||
"previous": "Précédent",
|
"previous": "Précédent",
|
||||||
"next": "Suivant",
|
"next": "Suivant",
|
||||||
"restart": "Redémarrer",
|
"restart": "Recommencer",
|
||||||
"autoPause": "Pause automatique ({enabled})",
|
"autoPause": "Pause automatique ({enabled})",
|
||||||
"uploadVideoAndSubtitle": "Veuillez télécharger des fichiers vidéo et de sous-titres",
|
"uploadVideoAndSubtitle": "Veuillez télécharger les fichiers vidéo et sous-titres",
|
||||||
"uploadVideoFile": "Veuillez télécharger un fichier vidéo",
|
"uploadVideoFile": "Veuillez télécharger le fichier vidéo",
|
||||||
"uploadSubtitleFile": "Veuillez télécharger un fichier de sous-titres",
|
"uploadSubtitleFile": "Veuillez télécharger le fichier de sous-titres",
|
||||||
"processingSubtitle": "Traitement du 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",
|
"videoFile": "Fichier vidéo",
|
||||||
"subtitleFile": "Fichier de sous-titres",
|
"subtitleFile": "Fichier de sous-titres",
|
||||||
"uploaded": "Téléchargé",
|
"uploaded": "Téléchargé",
|
||||||
"notUploaded": "Non téléchargé",
|
"notUploaded": "Non téléchargé",
|
||||||
"upload": "Télécharger",
|
"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é",
|
"on": "Activé",
|
||||||
"off": "Désactivé",
|
"off": "Désactivé",
|
||||||
"videoUploadFailed": "Échec du téléchargement de la vidéo",
|
"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": {
|
"text_speaker": {
|
||||||
"generateIPA": "Générer l'API",
|
"generateIPA": "Générer l'API",
|
||||||
"viewSavedItems": "Voir les éléments enregistrés",
|
"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": {
|
"translator": {
|
||||||
"detectLanguage": "détecter la langue",
|
"detectLanguage": "détecter la langue",
|
||||||
@@ -194,45 +256,84 @@
|
|||||||
"translate": "traduire",
|
"translate": "traduire",
|
||||||
"inputLanguage": "Entrez une langue.",
|
"inputLanguage": "Entrez une langue.",
|
||||||
"history": "Historique",
|
"history": "Historique",
|
||||||
"enterLanguage": "Entrer la langue",
|
"enterLanguage": "Entrez la langue",
|
||||||
"add_to_folder": {
|
"add_to_folder": {
|
||||||
"notAuthenticated": "Vous n'êtes pas authentifié",
|
"notAuthenticated": "Vous n'êtes pas authentifié",
|
||||||
"chooseFolder": "Choisir un dossier à ajouter",
|
"chooseFolder": "Choisissez un dossier à ajouter",
|
||||||
"noFolders": "Aucun dossier trouvé",
|
"noFolders": "Aucun dossier trouvé",
|
||||||
"folderInfo": "{id}. {name}",
|
"folderInfo": "{id}. {name}",
|
||||||
"close": "Fermer",
|
"close": "Fermer",
|
||||||
"success": "Paire de textes ajoutée au dossier",
|
"success": "Paire de texte ajoutée au dossier",
|
||||||
"error": "Échec de l'ajout de la paire de textes au dossier"
|
"error": "Échec de l'ajout de la paire de texte au dossier"
|
||||||
},
|
},
|
||||||
"autoSave": "Sauvegarde automatique"
|
"autoSave": "Sauvegarde automatique"
|
||||||
},
|
},
|
||||||
"dictionary": {
|
"dictionary": {
|
||||||
"title": "Dictionnaire",
|
"title": "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",
|
||||||
"searchPlaceholder": "Entrez un mot ou une phrase à rechercher...",
|
"searchPlaceholder": "Entrez un mot ou une expression à rechercher...",
|
||||||
"searching": "Recherche...",
|
"searching": "Recherche...",
|
||||||
"search": "Rechercher",
|
"search": "Rechercher",
|
||||||
"languageSettings": "Paramètres linguistiques",
|
"languageSettings": "Paramètres de langue",
|
||||||
"queryLanguage": "Langue de requête",
|
"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",
|
"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...",
|
"otherLanguagePlaceholder": "Ou entrez une autre langue...",
|
||||||
|
"other": "Autre",
|
||||||
"currentSettings": "Paramètres actuels : Requête {queryLang}, Définition {definitionLang}",
|
"currentSettings": "Paramètres actuels : Requête {queryLang}, Définition {definitionLang}",
|
||||||
"relookup": "Rechercher à nouveau",
|
"relookup": "Rechercher à nouveau",
|
||||||
"saveToFolder": "Enregistrer dans le dossier",
|
"saveToFolder": "Enregistrer dans le dossier",
|
||||||
"loading": "Chargement...",
|
"loading": "Chargement...",
|
||||||
"noResults": "Aucun résultat trouvé",
|
"noResults": "Aucun résultat trouvé",
|
||||||
"tryOtherWords": "Essayez d'autres mots ou phrases",
|
"tryOtherWords": "Essayez d'autres mots ou expressions",
|
||||||
"welcomeTitle": "Bienvenue dans le dictionnaire",
|
"welcomeTitle": "Bienvenue dans le dictionnaire",
|
||||||
"welcomeHint": "Entrez un mot ou une phrase dans la zone de recherche ci-dessus pour commencer",
|
"welcomeHint": "Entrez un mot ou une expression dans la zone de recherche ci-dessus pour commencer la recherche",
|
||||||
"lookupFailed": "Recherche échouée, veuillez réessayer plus tard",
|
"lookupFailed": "La recherche a échoué, veuillez réessayer plus tard",
|
||||||
"relookupSuccess": "Recherche répétée avec succès",
|
"relookupSuccess": "Recherche effectuée avec succès",
|
||||||
"relookupFailed": "Nouvelle recherche de dictionnaire échouée",
|
"relookupFailed": "La nouvelle recherche dans le dictionnaire a échoué",
|
||||||
"pleaseLogin": "Veuillez d'abord vous connecter",
|
"pleaseLogin": "Veuillez vous connecter d'abord",
|
||||||
"pleaseCreateFolder": "Veuillez d'abord créer un dossier",
|
"pleaseCreateFolder": "Veuillez créer un dossier d'abord",
|
||||||
"savedToFolder": "Enregistré dans le dossier : {folderName}",
|
"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": {
|
"user_profile": {
|
||||||
"anonymous": "Anonyme",
|
"anonymous": "Anonyme",
|
||||||
@@ -245,11 +346,12 @@
|
|||||||
"displayName": "Nom d'affichage",
|
"displayName": "Nom d'affichage",
|
||||||
"notSet": "Non défini",
|
"notSet": "Non défini",
|
||||||
"memberSince": "Membre depuis",
|
"memberSince": "Membre depuis",
|
||||||
|
"logout": "Déconnexion",
|
||||||
"folders": {
|
"folders": {
|
||||||
"title": "Dossiers",
|
"title": "Dossiers",
|
||||||
"noFolders": "Aucun dossier pour le moment",
|
"noFolders": "Pas encore de dossiers",
|
||||||
"folderName": "Nom du dossier",
|
"folderName": "Nom du dossier",
|
||||||
"totalPairs": "Nombre de paires",
|
"totalPairs": "Total des paires",
|
||||||
"createdAt": "Créé le",
|
"createdAt": "Créé le",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"view": "Voir"
|
"view": "Voir"
|
||||||
|
|||||||
@@ -1,48 +1,68 @@
|
|||||||
{
|
{
|
||||||
"alphabet": {
|
"alphabet": {
|
||||||
"chooseCharacters": "Seleziona i caratteri che desideri imparare",
|
"chooseCharacters": "Seleziona i caratteri che vuoi imparare",
|
||||||
"japanese": "Kana giapponese",
|
"chooseAlphabetHint": "Seleziona un alfabeto per iniziare a imparare",
|
||||||
"english": "Alfabeto inglese",
|
"japanese": "Kana Giapponese",
|
||||||
"uyghur": "Alfabeto uiguro",
|
"english": "Alfabeto Inglese",
|
||||||
"esperanto": "Alfabeto esperanto",
|
"uyghur": "Alfabeto Uiguro",
|
||||||
|
"esperanto": "Alfabeto Esperanto",
|
||||||
"loading": "Caricamento...",
|
"loading": "Caricamento...",
|
||||||
"loadFailed": "Caricamento fallito, riprova",
|
"loadFailed": "Caricamento fallito, riprova",
|
||||||
"hideLetter": "Nascondi lettera",
|
"hideLetter": "Nascondi Lettera",
|
||||||
"showLetter": "Mostra lettera",
|
"showLetter": "Mostra Lettera",
|
||||||
"hideIPA": "Nascondi IPA",
|
"hideIPA": "Nascondi IPA",
|
||||||
"showIPA": "Mostra IPA",
|
"showIPA": "Mostra IPA",
|
||||||
"roman": "Romanizzazione",
|
"roman": "Romanizzazione",
|
||||||
"letter": "Lettera",
|
"letter": "Lettera",
|
||||||
"random": "Modalità casuale",
|
"random": "Modalità Casuale",
|
||||||
"randomNext": "Successivo 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": {
|
"folders": {
|
||||||
"title": "Cartelle",
|
"title": "Cartelle",
|
||||||
"subtitle": "Gestisci le tue collezioni",
|
"subtitle": "Gestisci le tue collezioni",
|
||||||
"newFolder": "Nuova cartella",
|
"newFolder": "Nuova Cartella",
|
||||||
"creating": "Creazione...",
|
"creating": "Creazione...",
|
||||||
"noFoldersYet": "Nessuna cartella ancora",
|
"noFoldersYet": "Nessuna cartella ancora",
|
||||||
"folderInfo": "ID: {id} • {totalPairs} coppie",
|
"folderInfo": "ID: {id} • {totalPairs} coppie",
|
||||||
"enterFolderName": "Inserisci nome cartella:",
|
"enterFolderName": "Inserisci il nome della cartella:",
|
||||||
"confirmDelete": "Digita \"{name}\" per eliminare:"
|
"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": {
|
"folder_id": {
|
||||||
"unauthorized": "Non sei il proprietario di questa cartella",
|
"unauthorized": "Non sei il proprietario di questa cartella",
|
||||||
"back": "Indietro",
|
"back": "Indietro",
|
||||||
"textPairs": "Coppie di testi",
|
"textPairs": "Coppie di Testo",
|
||||||
"itemsCount": "{count} elementi",
|
"itemsCount": "{count} elementi",
|
||||||
"memorize": "Memorizza",
|
"memorize": "Memorizza",
|
||||||
"loadingTextPairs": "Caricamento coppie di testi...",
|
"loadingTextPairs": "Caricamento coppie di testo...",
|
||||||
"noTextPairs": "Nessuna coppia di testi in questa cartella",
|
"noTextPairs": "Nessuna coppia di testo in questa cartella",
|
||||||
"addNewTextPair": "Aggiungi nuova coppia di testi",
|
"addNewTextPair": "Aggiungi Nuova Coppia di Testo",
|
||||||
"add": "Aggiungi",
|
"add": "Aggiungi",
|
||||||
"updateTextPair": "Aggiorna coppia di testi",
|
"updateTextPair": "Aggiorna Coppia di Testo",
|
||||||
"update": "Aggiorna",
|
"update": "Aggiorna",
|
||||||
"text1": "Testo 1",
|
"text1": "Testo 1",
|
||||||
"text2": "Testo 2",
|
"text2": "Testo 2",
|
||||||
"language1": "Lingua 1",
|
"language1": "Locale 1",
|
||||||
"language2": "Lingua 2",
|
"language2": "Locale 2",
|
||||||
"enterLanguageName": "Inserisci il nome della lingua",
|
"enterLanguageName": "Per favore inserisci il nome della lingua",
|
||||||
"edit": "Modifica",
|
"edit": "Modifica",
|
||||||
"delete": "Elimina",
|
"delete": "Elimina",
|
||||||
"permissionDenied": "Non hai il permesso di eseguire questa azione",
|
"permissionDenied": "Non hai il permesso di eseguire questa azione",
|
||||||
@@ -55,8 +75,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "Impara le lingue",
|
"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.",
|
"description": "Ecco un sito molto utile per aiutarti a imparare quasi tutte le lingue del mondo, incluse quelle costruite.",
|
||||||
"explore": "Esplora",
|
"explore": "Esplora",
|
||||||
"fortune": {
|
"fortune": {
|
||||||
"quote": "Stay hungry, stay foolish.",
|
"quote": "Stay hungry, stay foolish.",
|
||||||
@@ -64,15 +84,15 @@
|
|||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"name": "Traduttore",
|
"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": {
|
"textSpeaker": {
|
||||||
"name": "Lettore di testo",
|
"name": "Lettore Testo",
|
||||||
"description": "Riconosce e legge il testo ad alta voce, supporta la riproduzione in loop e la regolazione della velocità"
|
"description": "Riconosci e leggi il testo ad alta voce, supporta riproduzione in loop e regolazione della velocità"
|
||||||
},
|
},
|
||||||
"srtPlayer": {
|
"srtPlayer": {
|
||||||
"name": "Lettore video SRT",
|
"name": "Lettore Video SRT",
|
||||||
"description": "Riproduci video frase per frase basandoti su file di sottotitoli SRT per imitare la pronuncia dei madrelingua"
|
"description": "Riproduci video frase per frase basandoti sui file di sottotitoli SRT per imitare la pronuncia dei madrelingua"
|
||||||
},
|
},
|
||||||
"alphabet": {
|
"alphabet": {
|
||||||
"name": "Alfabeto",
|
"name": "Alfabeto",
|
||||||
@@ -80,39 +100,73 @@
|
|||||||
},
|
},
|
||||||
"memorize": {
|
"memorize": {
|
||||||
"name": "Memorizza",
|
"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": {
|
"dictionary": {
|
||||||
"name": "Dizionario",
|
"name": "Dizionario",
|
||||||
"description": "Cerca parole e frasi con definizioni dettagliate ed esempi"
|
"description": "Cerca parole e frasi con definizioni dettagliate ed esempi"
|
||||||
},
|
},
|
||||||
"moreFeatures": {
|
"moreFeatures": {
|
||||||
"name": "Altre funzionalità",
|
"name": "Altre Funzionalità",
|
||||||
"description": "In sviluppo, rimani sintonizzato"
|
"description": "In sviluppo, resta sintonizzato"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "Autenticazione",
|
"title": "Accedi",
|
||||||
|
"signUpTitle": "Registrati",
|
||||||
"signIn": "Accedi",
|
"signIn": "Accedi",
|
||||||
"signUp": "Registrati",
|
"signUp": "Registrati",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"confirmPassword": "Conferma password",
|
"confirmPassword": "Conferma Password",
|
||||||
"name": "Nome",
|
"name": "Nome",
|
||||||
|
"username": "Nome Utente",
|
||||||
|
"emailOrUsername": "Email o Nome Utente",
|
||||||
"signInButton": "Accedi",
|
"signInButton": "Accedi",
|
||||||
"signUpButton": "Registrati",
|
"signUpButton": "Registrati",
|
||||||
"noAccount": "Non hai un account?",
|
"noAccount": "Non hai un account?",
|
||||||
"hasAccount": "Hai già un account?",
|
"hasAccount": "Hai già un account?",
|
||||||
"signInWithGitHub": "Accedi con GitHub",
|
"signInWithGitHub": "Accedi con GitHub",
|
||||||
"signUpWithGitHub": "Registrati 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",
|
"passwordTooShort": "La password deve essere di almeno 8 caratteri",
|
||||||
"passwordsNotMatch": "Le password non corrispondono",
|
"passwordsNotMatch": "Le password non corrispondono",
|
||||||
"nameRequired": "Inserisci il tuo nome",
|
"nameRequired": "Per favore inserisci il tuo nome",
|
||||||
"emailRequired": "Inserisci la tua email",
|
"usernameRequired": "Per favore inserisci un nome utente",
|
||||||
"passwordRequired": "Inserisci la tua password",
|
"usernameTooShort": "Il nome utente deve essere di almeno 3 caratteri",
|
||||||
"confirmPasswordRequired": "Conferma la tua password",
|
"usernameInvalid": "Il nome utente può contenere solo lettere, numeri e trattini bassi",
|
||||||
"loading": "Caricamento..."
|
"emailRequired": "Per favore inserisci la tua email",
|
||||||
|
"identifierRequired": "Per favore inserisci la tua email o nome utente",
|
||||||
|
"passwordRequired": "Per favore inserisci la tua password",
|
||||||
|
"confirmPasswordRequired": "Per favore conferma la tua password",
|
||||||
|
"loading": "Caricamento...",
|
||||||
|
"confirm": "Conferma",
|
||||||
|
"noAccountLink": "Non hai un account? Registrati",
|
||||||
|
"hasAccountLink": "Hai già un account? Accedi",
|
||||||
|
"usernamePlaceholder": "Nome utente",
|
||||||
|
"emailPlaceholder": "Indirizzo email",
|
||||||
|
"passwordPlaceholder": "Password",
|
||||||
|
"usernameOrEmailPlaceholder": "Nome utente o email",
|
||||||
|
"loginFailed": "Accesso fallito",
|
||||||
|
"signUpFailed": "Registrazione fallita",
|
||||||
|
"fillAllFields": "Per favore compila tutti i campi",
|
||||||
|
"enterCredentials": "Per favore inserisci nome utente e password",
|
||||||
|
"forgotPassword": "Password Dimenticata",
|
||||||
|
"forgotPasswordHint": "Inserisci il tuo indirizzo email e ti invieremo un link per reimpostare la password.",
|
||||||
|
"sendResetEmail": "Invia Email di Reset",
|
||||||
|
"resetPasswordFailed": "Impossibile inviare email di reset",
|
||||||
|
"resetPasswordEmailSent": "Email di reset inviata con successo",
|
||||||
|
"resetPasswordEmailSentHint": "Abbiamo inviato un link per reimpostare la password al tuo indirizzo email. Controlla la tua casella di posta.",
|
||||||
|
"checkYourEmail": "Controlla la tua Email",
|
||||||
|
"backToLogin": "Torna al Login",
|
||||||
|
"resetPassword": "Reimposta Password",
|
||||||
|
"newPassword": "Nuova Password",
|
||||||
|
"invalidToken": "Link Non Valido o Scaduto",
|
||||||
|
"invalidTokenHint": "Questo link per reimpostare la password non è valido o è scaduto. Richiedine uno nuovo.",
|
||||||
|
"requestNewToken": "Richiedi Nuovo Link di Reset",
|
||||||
|
"resetPasswordSuccess": "Password reimpostata con successo",
|
||||||
|
"resetPasswordSuccessTitle": "Reimpostazione Password Completata",
|
||||||
|
"resetPasswordSuccessHint": "La tua password è stata reimpostata con successo. Ora puoi accedere con la tua nuova password."
|
||||||
},
|
},
|
||||||
"memorize": {
|
"memorize": {
|
||||||
"folder_selector": {
|
"folder_selector": {
|
||||||
@@ -125,8 +179,8 @@
|
|||||||
"next": "Successivo",
|
"next": "Successivo",
|
||||||
"reverse": "Inverti",
|
"reverse": "Inverti",
|
||||||
"dictation": "Dettatura",
|
"dictation": "Dettatura",
|
||||||
"noTextPairs": "Nessuna coppia di testi disponibile",
|
"noTextPairs": "Nessuna coppia di testo disponibile",
|
||||||
"disorder": "Disordine",
|
"disorder": "Disordina",
|
||||||
"previous": "Precedente"
|
"previous": "Precedente"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
@@ -134,45 +188,53 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navbar": {
|
"navbar": {
|
||||||
"title": "learn-languages",
|
"title": "impara-lingue",
|
||||||
"sourceCode": "GitHub",
|
"sourceCode": "GitHub",
|
||||||
"sign_in": "Accedi",
|
"sign_in": "Accedi",
|
||||||
"profile": "Profilo",
|
"profile": "Profilo",
|
||||||
"folders": "Cartelle"
|
"folders": "Cartelle",
|
||||||
|
"explore": "Esplora",
|
||||||
|
"favorites": "Preferiti"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"myProfile": "Il mio profilo",
|
"myProfile": "Il Mio Profilo",
|
||||||
"email": "Email: {email}",
|
"email": "Email: {email}",
|
||||||
"logout": "Esci"
|
"logout": "Esci"
|
||||||
},
|
},
|
||||||
"srt_player": {
|
"srt_player": {
|
||||||
"uploadVideo": "Carica video",
|
"uploadVideo": "Carica Video",
|
||||||
"uploadSubtitle": "Carica sottotitoli",
|
"uploadSubtitle": "Carica Sottotitoli",
|
||||||
"pause": "Pausa",
|
"pause": "Pausa",
|
||||||
"play": "Riproduci",
|
"play": "Riproduci",
|
||||||
"previous": "Precedente",
|
"previous": "Precedente",
|
||||||
"next": "Successivo",
|
"next": "Successivo",
|
||||||
"restart": "Riavvia",
|
"restart": "Riavvia",
|
||||||
"autoPause": "Pausa automatica ({enabled})",
|
"autoPause": "Pausa Automatica ({enabled})",
|
||||||
"uploadVideoAndSubtitle": "Carica i file video e sottotitoli",
|
"uploadVideoAndSubtitle": "Per favore carica file video e sottotitoli",
|
||||||
"uploadVideoFile": "Carica un file video",
|
"uploadVideoFile": "Per favore carica il file video",
|
||||||
"uploadSubtitleFile": "Carica un file di sottotitoli",
|
"uploadSubtitleFile": "Per favore carica il file sottotitoli",
|
||||||
"processingSubtitle": "Elaborazione file sottotitoli...",
|
"processingSubtitle": "Elaborazione file sottotitoli...",
|
||||||
"needBothFiles": "Sono richiesti sia i file video che i sottotitoli per iniziare l'apprendimento",
|
"needBothFiles": "Sono richiesti sia il file video che quello dei sottotitoli per iniziare a imparare",
|
||||||
"videoFile": "File video",
|
"videoFile": "File Video",
|
||||||
"subtitleFile": "File sottotitoli",
|
"subtitleFile": "File Sottotitoli",
|
||||||
"uploaded": "Caricato",
|
"uploaded": "Caricato",
|
||||||
"notUploaded": "Non caricato",
|
"notUploaded": "Non Caricato",
|
||||||
"upload": "Carica",
|
"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",
|
"on": "Attivo",
|
||||||
"off": "Disattivo",
|
"off": "Disattivo",
|
||||||
"videoUploadFailed": "Caricamento video fallito",
|
"videoUploadFailed": "Caricamento video fallito",
|
||||||
"subtitleUploadFailed": "Caricamento sottotitoli fallito"
|
"subtitleUploadFailed": "Caricamento sottotitoli fallito",
|
||||||
|
"subtitleLoadSuccess": "Sottotitoli caricati con successo",
|
||||||
|
"subtitleLoadFailed": "Caricamento sottotitoli fallito"
|
||||||
},
|
},
|
||||||
"text_speaker": {
|
"text_speaker": {
|
||||||
"generateIPA": "Genera IPA",
|
"generateIPA": "Genera IPA",
|
||||||
"viewSavedItems": "Visualizza elementi salvati",
|
"viewSavedItems": "Visualizza Elementi Salvati",
|
||||||
"confirmDeleteAll": "Sei sicuro di voler eliminare tutto? (S/N)"
|
"confirmDeleteAll": "Sei sicuro di voler eliminare tutto? (S/N)"
|
||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
@@ -197,14 +259,14 @@
|
|||||||
"enterLanguage": "Inserisci lingua",
|
"enterLanguage": "Inserisci lingua",
|
||||||
"add_to_folder": {
|
"add_to_folder": {
|
||||||
"notAuthenticated": "Non sei autenticato",
|
"notAuthenticated": "Non sei autenticato",
|
||||||
"chooseFolder": "Scegli una cartella a cui aggiungere",
|
"chooseFolder": "Scegli una Cartella a cui Aggiungere",
|
||||||
"noFolders": "Nessuna cartella trovata",
|
"noFolders": "Nessuna cartella trovata",
|
||||||
"folderInfo": "{id}. {name}",
|
"folderInfo": "{id}. {name}",
|
||||||
"close": "Chiudi",
|
"close": "Chiudi",
|
||||||
"success": "Coppia di testi aggiunta alla cartella",
|
"success": "Coppia di testo aggiunta alla cartella",
|
||||||
"error": "Impossibile aggiungere la coppia di testi alla cartella"
|
"error": "Impossibile aggiungere coppia di testo alla cartella"
|
||||||
},
|
},
|
||||||
"autoSave": "Salvataggio automatico"
|
"autoSave": "Salvataggio Automatico"
|
||||||
},
|
},
|
||||||
"dictionary": {
|
"dictionary": {
|
||||||
"title": "Dizionario",
|
"title": "Dizionario",
|
||||||
@@ -212,45 +274,85 @@
|
|||||||
"searchPlaceholder": "Inserisci una parola o frase da cercare...",
|
"searchPlaceholder": "Inserisci una parola o frase da cercare...",
|
||||||
"searching": "Ricerca...",
|
"searching": "Ricerca...",
|
||||||
"search": "Cerca",
|
"search": "Cerca",
|
||||||
"languageSettings": "Impostazioni lingua",
|
"languageSettings": "Impostazioni Lingua",
|
||||||
"queryLanguage": "Lingua di interrogazione",
|
"queryLanguage": "Lingua di Query",
|
||||||
"queryLanguageHint": "Quale è la lingua della parola/frase che vuoi cercare",
|
"queryLanguageHint": "In che lingua è la parola/frase che vuoi cercare",
|
||||||
"definitionLanguage": "Lingua di definizione",
|
"definitionLanguage": "Lingua delle Definizioni",
|
||||||
"definitionLanguageHint": "In quale lingua vuoi vedere le definizioni",
|
"definitionLanguageHint": "In che lingua vuoi le definizioni",
|
||||||
"otherLanguagePlaceholder": "Oppure inserisci un'altra lingua...",
|
"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",
|
"relookup": "Ricerca di nuovo",
|
||||||
"saveToFolder": "Salva nella cartella",
|
"saveToFolder": "Salva nella cartella",
|
||||||
"loading": "Caricamento...",
|
"loading": "Caricamento...",
|
||||||
"noResults": "Nessun risultato trovato",
|
"noResults": "Nessun risultato trovato",
|
||||||
"tryOtherWords": "Prova altre parole o frasi",
|
"tryOtherWords": "Prova altre parole o frasi",
|
||||||
"welcomeTitle": "Benvenuto nel dizionario",
|
"welcomeTitle": "Benvenuto nel Dizionario",
|
||||||
"welcomeHint": "Inserisci una parola o frase nella casella di ricerca sopra per iniziare",
|
"welcomeHint": "Inserisci una parola o frase nella casella di ricerca sopra per iniziare a cercare",
|
||||||
"lookupFailed": "Ricerca fallita, riprova più tardi",
|
"lookupFailed": "Ricerca fallita, riprova più tardi",
|
||||||
"relookupSuccess": "Ricerca ripetuta con successo",
|
"relookupSuccess": "Ricerca effettuata con successo",
|
||||||
"relookupFailed": "Nuova ricerca del dizionario fallita",
|
"relookupFailed": "Ricerca dizionario fallita",
|
||||||
"pleaseLogin": "Accedi prima",
|
"pleaseLogin": "Per favore accedi prima",
|
||||||
"pleaseCreateFolder": "Crea prima una cartella",
|
"pleaseCreateFolder": "Per favore crea prima una cartella",
|
||||||
"savedToFolder": "Salvato nella cartella: {folderName}",
|
"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": {
|
"user_profile": {
|
||||||
"anonymous": "Anonimo",
|
"anonymous": "Anonimo",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"verified": "Verificato",
|
"verified": "Verificato",
|
||||||
"unverified": "Non verificato",
|
"unverified": "Non Verificato",
|
||||||
"accountInfo": "Informazioni account",
|
"accountInfo": "Informazioni Account",
|
||||||
"userId": "ID utente",
|
"userId": "ID Utente",
|
||||||
"username": "Nome utente",
|
"username": "Nome Utente",
|
||||||
"displayName": "Nome visualizzato",
|
"displayName": "Nome Visualizzato",
|
||||||
"notSet": "Non impostato",
|
"notSet": "Non Impostato",
|
||||||
"memberSince": "Membro dal",
|
"memberSince": "Membro Dal",
|
||||||
|
"logout": "Esci",
|
||||||
"folders": {
|
"folders": {
|
||||||
"title": "Cartelle",
|
"title": "Cartelle",
|
||||||
"noFolders": "Nessuna cartella ancora",
|
"noFolders": "Nessuna cartella ancora",
|
||||||
"folderName": "Nome cartella",
|
"folderName": "Nome Cartella",
|
||||||
"totalPairs": "Numero di coppie",
|
"totalPairs": "Coppie Totali",
|
||||||
"createdAt": "Creato il",
|
"createdAt": "Creata Il",
|
||||||
"actions": "Azioni",
|
"actions": "Azioni",
|
||||||
"view": "Visualizza"
|
"view": "Visualizza"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"alphabet": {
|
"alphabet": {
|
||||||
"chooseCharacters": "学習したい文字を選択してください",
|
"chooseCharacters": "学習したい文字を選択してください",
|
||||||
|
"chooseAlphabetHint": "学習を始めるアルファベットを選択してください",
|
||||||
"japanese": "日本語仮名",
|
"japanese": "日本語仮名",
|
||||||
"english": "英語アルファベット",
|
"english": "英語アルファベット",
|
||||||
"uyghur": "ウイグル文字",
|
"uyghur": "ウイグル語アルファベット",
|
||||||
"esperanto": "エスペラント文字",
|
"esperanto": "エスペラント語アルファベット",
|
||||||
"loading": "読み込み中...",
|
"loading": "読み込み中...",
|
||||||
"loadFailed": "読み込みに失敗しました。もう一度お試しください",
|
"loadFailed": "読み込みに失敗しました。もう一度お試しください",
|
||||||
"hideLetter": "文字を非表示",
|
"hideLetter": "文字を非表示",
|
||||||
@@ -14,23 +15,42 @@
|
|||||||
"roman": "ローマ字",
|
"roman": "ローマ字",
|
||||||
"letter": "文字",
|
"letter": "文字",
|
||||||
"random": "ランダムモード",
|
"random": "ランダムモード",
|
||||||
"randomNext": "ランダムで次へ"
|
"randomNext": "ランダム次へ",
|
||||||
|
"previousLetter": "前の文字",
|
||||||
|
"nextLetter": "次の文字",
|
||||||
|
"keyboardHint": "左右の矢印キーまたはスペースキーでランダム移動、ESCで戻る",
|
||||||
|
"swipeHint": "左右の矢印キーまたはスワイプで移動、ESCで戻る"
|
||||||
},
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
"title": "フォルダー",
|
"title": "フォルダー",
|
||||||
"subtitle": "コレクションを管理",
|
"subtitle": "コレクションを管理",
|
||||||
"newFolder": "新規フォルダー",
|
"newFolder": "新規フォルダー",
|
||||||
"creating": "作成中...",
|
"creating": "作成中...",
|
||||||
"noFoldersYet": "フォルダーがありません",
|
"noFoldersYet": "まだフォルダーがありません",
|
||||||
"folderInfo": "ID: {id} • {totalPairs}組",
|
"folderInfo": "ID: {id} • {totalPairs} ペア",
|
||||||
"enterFolderName": "フォルダー名を入力:",
|
"enterFolderName": "フォルダー名を入力:",
|
||||||
"confirmDelete": "削除するには「{name}」と入力してください:"
|
"confirmDelete": "削除するには「{name}」と入力してください:",
|
||||||
|
"myFolders": "マイフォルダー",
|
||||||
|
"publicFolders": "公開フォルダー",
|
||||||
|
"public": "公開",
|
||||||
|
"private": "非公開",
|
||||||
|
"setPublic": "公開に設定",
|
||||||
|
"setPrivate": "非公開に設定",
|
||||||
|
"publicFolderInfo": "{userName} • {totalPairs} ペア",
|
||||||
|
"searchPlaceholder": "公開フォルダーを検索...",
|
||||||
|
"loading": "読み込み中...",
|
||||||
|
"noPublicFolders": "公開フォルダーが見つかりません",
|
||||||
|
"unknownUser": "不明なユーザー",
|
||||||
|
"enterNewName": "新しい名前を入力:",
|
||||||
|
"favorite": "お気に入り",
|
||||||
|
"unfavorite": "お気に入り解除",
|
||||||
|
"pleaseLogin": "まずログインしてください"
|
||||||
},
|
},
|
||||||
"folder_id": {
|
"folder_id": {
|
||||||
"unauthorized": "あなたはこのフォルダーの所有者ではありません",
|
"unauthorized": "このフォルダーの所有者ではありません",
|
||||||
"back": "戻る",
|
"back": "戻る",
|
||||||
"textPairs": "テキストペア",
|
"textPairs": "テキストペア",
|
||||||
"itemsCount": "{count}項目",
|
"itemsCount": "{count} 項目",
|
||||||
"memorize": "暗記",
|
"memorize": "暗記",
|
||||||
"loadingTextPairs": "テキストペアを読み込み中...",
|
"loadingTextPairs": "テキストペアを読み込み中...",
|
||||||
"noTextPairs": "このフォルダーにはテキストペアがありません",
|
"noTextPairs": "このフォルダーにはテキストペアがありません",
|
||||||
@@ -45,34 +65,34 @@
|
|||||||
"enterLanguageName": "言語名を入力してください",
|
"enterLanguageName": "言語名を入力してください",
|
||||||
"edit": "編集",
|
"edit": "編集",
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
"permissionDenied": "この操作を実行する権限がありません",
|
"permissionDenied": "このアクションを実行する権限がありません",
|
||||||
"error": {
|
"error": {
|
||||||
"update": "この項目を更新する権限がありません。",
|
"update": "この項目を更新する権限がありません。",
|
||||||
"delete": "この項目を削除する権限がありません。",
|
"delete": "この項目を削除する権限がありません。",
|
||||||
"add": "このフォルダーに項目を追加する権限がありません。",
|
"add": "このフォルダーに項目を追加する権限がありません。",
|
||||||
"rename": "このフォルダー名を変更する権限がありません。",
|
"rename": "このフォルダーの名前を変更する権限がありません。",
|
||||||
"deleteFolder": "このフォルダーを削除する権限がありません。"
|
"deleteFolder": "このフォルダーを削除する権限がありません。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "言語を学ぶ",
|
"title": "言語を学ぶ",
|
||||||
"description": "これは、人工言語を含む世界中のほぼすべての言語を学ぶのに役立つ非常に便利なウェブサイトです。",
|
"description": "ここは世界のほぼすべての言語(人工言語を含む)を学ぶのに役立つ非常に便利なウェブサイトです。",
|
||||||
"explore": "探索",
|
"explore": "探索",
|
||||||
"fortune": {
|
"fortune": {
|
||||||
"quote": "Stay hungry, stay foolish.",
|
"quote": "Stay hungry, stay foolish.",
|
||||||
"author": "— スティーブ・ジョブズ"
|
"author": "— Steve Jobs"
|
||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"name": "翻訳",
|
"name": "翻訳者",
|
||||||
"description": "任意の言語に翻訳し、国際音声記号(IPA)で注釈を付けます"
|
"description": "あらゆる言語に翻訳し、国際音声記号(IPA)で注釈を付けます"
|
||||||
},
|
},
|
||||||
"textSpeaker": {
|
"textSpeaker": {
|
||||||
"name": "テキストスピーカー",
|
"name": "テキストスピーカー",
|
||||||
"description": "テキストを認識して音読します。ループ再生と速度調整をサポート"
|
"description": "テキストを認識して読み上げ、ループ再生と速度調整をサポート"
|
||||||
},
|
},
|
||||||
"srtPlayer": {
|
"srtPlayer": {
|
||||||
"name": "SRTビデオプレーヤー",
|
"name": "SRTビデオプレーヤー",
|
||||||
"description": "SRT字幕ファイルに基づいてビデオを文ごとに再生し、ネイティブスピーカーの発音を模倣します"
|
"description": "SRT字幕ファイルに基づいて文ごとにビデオを再生し、ネイティブスピーカーの発音を模倣"
|
||||||
},
|
},
|
||||||
"alphabet": {
|
"alphabet": {
|
||||||
"name": "アルファベット",
|
"name": "アルファベット",
|
||||||
@@ -80,39 +100,73 @@
|
|||||||
},
|
},
|
||||||
"memorize": {
|
"memorize": {
|
||||||
"name": "暗記",
|
"name": "暗記",
|
||||||
"description": "言語Aから言語B、言語Bから言語A、ディクテーションをサポート"
|
"description": "言語Aから言語B、言語Bから言語A、書き取りをサポート"
|
||||||
},
|
},
|
||||||
"dictionary": {
|
"dictionary": {
|
||||||
"name": "辞書",
|
"name": "辞書",
|
||||||
"description": "単語やフレーズを調べ、詳細な定義と例を表示"
|
"description": "詳細な定義と例文で単語やフレーズを検索"
|
||||||
},
|
},
|
||||||
"moreFeatures": {
|
"moreFeatures": {
|
||||||
"name": "その他の機能",
|
"name": "その他の機能",
|
||||||
"description": "開発中です。お楽しみに"
|
"description": "開発中、お楽しみに"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "認証",
|
"title": "サインイン",
|
||||||
"signIn": "ログイン",
|
"signUpTitle": "新規登録",
|
||||||
|
"signIn": "サインイン",
|
||||||
"signUp": "新規登録",
|
"signUp": "新規登録",
|
||||||
"email": "メールアドレス",
|
"email": "メールアドレス",
|
||||||
"password": "パスワード",
|
"password": "パスワード",
|
||||||
"confirmPassword": "パスワード(確認)",
|
"confirmPassword": "パスワード確認",
|
||||||
"name": "名前",
|
"name": "名前",
|
||||||
"signInButton": "ログイン",
|
"username": "ユーザー名",
|
||||||
|
"emailOrUsername": "メールアドレスまたはユーザー名",
|
||||||
|
"signInButton": "サインイン",
|
||||||
"signUpButton": "新規登録",
|
"signUpButton": "新規登録",
|
||||||
"noAccount": "アカウントをお持ちでないですか?",
|
"noAccount": "アカウントをお持ちでないですか?",
|
||||||
"hasAccount": "すでにアカウントをお持ちですか?",
|
"hasAccount": "すでにアカウントをお持ちですか?",
|
||||||
"signInWithGitHub": "GitHubでログイン",
|
"signInWithGitHub": "GitHubでサインイン",
|
||||||
"signUpWithGitHub": "GitHubで新規登録",
|
"signUpWithGitHub": "GitHubで新規登録",
|
||||||
"invalidEmail": "有効なメールアドレスを入力してください",
|
"invalidEmail": "有効なメールアドレスを入力してください",
|
||||||
"passwordTooShort": "パスワードは8文字以上である必要があります",
|
"passwordTooShort": "パスワードは8文字以上である必要があります",
|
||||||
"passwordsNotMatch": "パスワードが一致しません",
|
"passwordsNotMatch": "パスワードが一致しません",
|
||||||
"nameRequired": "名前を入力してください",
|
"nameRequired": "名前を入力してください",
|
||||||
|
"usernameRequired": "ユーザー名を入力してください",
|
||||||
|
"usernameTooShort": "ユーザー名は3文字以上である必要があります",
|
||||||
|
"usernameInvalid": "ユーザー名には文字、数字、アンダースコアのみ使用できます",
|
||||||
"emailRequired": "メールアドレスを入力してください",
|
"emailRequired": "メールアドレスを入力してください",
|
||||||
|
"identifierRequired": "メールアドレスまたはユーザー名を入力してください",
|
||||||
"passwordRequired": "パスワードを入力してください",
|
"passwordRequired": "パスワードを入力してください",
|
||||||
"confirmPasswordRequired": "パスワード(確認)を入力してください",
|
"confirmPasswordRequired": "パスワードを確認してください",
|
||||||
"loading": "読み込み中..."
|
"loading": "読み込み中...",
|
||||||
|
"confirm": "確認",
|
||||||
|
"noAccountLink": "アカウントをお持ちでないですか? 新規登録",
|
||||||
|
"hasAccountLink": "すでにアカウントをお持ちですか? サインイン",
|
||||||
|
"usernamePlaceholder": "ユーザー名",
|
||||||
|
"emailPlaceholder": "メールアドレス",
|
||||||
|
"passwordPlaceholder": "パスワード",
|
||||||
|
"usernameOrEmailPlaceholder": "ユーザー名またはメールアドレス",
|
||||||
|
"loginFailed": "ログインに失敗しました",
|
||||||
|
"signUpFailed": "新規登録に失敗しました",
|
||||||
|
"fillAllFields": "すべてのフィールドに入力してください",
|
||||||
|
"enterCredentials": "ユーザー名とパスワードを入力してください",
|
||||||
|
"forgotPassword": "パスワードをお忘れですか",
|
||||||
|
"forgotPasswordHint": "メールアドレスを入力してください。パスワードリセット用のリンクをお送りします。",
|
||||||
|
"sendResetEmail": "リセットメールを送信",
|
||||||
|
"resetPasswordFailed": "リセットメールの送信に失敗しました",
|
||||||
|
"resetPasswordEmailSent": "リセットメールを送信しました",
|
||||||
|
"resetPasswordEmailSentHint": "パスワードリセット用のリンクをメールでお送りしました。受信トレイをご確認ください。",
|
||||||
|
"checkYourEmail": "メールをご確認ください",
|
||||||
|
"backToLogin": "ログインに戻る",
|
||||||
|
"resetPassword": "パスワードをリセット",
|
||||||
|
"newPassword": "新しいパスワード",
|
||||||
|
"invalidToken": "無効または期限切れのリンク",
|
||||||
|
"invalidTokenHint": "このパスワードリセットリンクは無効または期限切れです。新しいものをリクエストしてください。",
|
||||||
|
"requestNewToken": "新しいリセットリンクをリクエスト",
|
||||||
|
"resetPasswordSuccess": "パスワードのリセットに成功しました",
|
||||||
|
"resetPasswordSuccessTitle": "パスワードリセット完了",
|
||||||
|
"resetPasswordSuccessHint": "パスワードが正常にリセットされました。新しいパスワードでログインできます。"
|
||||||
},
|
},
|
||||||
"memorize": {
|
"memorize": {
|
||||||
"folder_selector": {
|
"folder_selector": {
|
||||||
@@ -121,12 +175,12 @@
|
|||||||
"folderInfo": "{id}. {name} ({count})"
|
"folderInfo": "{id}. {name} ({count})"
|
||||||
},
|
},
|
||||||
"memorize": {
|
"memorize": {
|
||||||
"answer": "回答",
|
"answer": "答え",
|
||||||
"next": "次へ",
|
"next": "次へ",
|
||||||
"reverse": "逆順",
|
"reverse": "逆順",
|
||||||
"dictation": "ディクテーション",
|
"dictation": "書き取り",
|
||||||
"noTextPairs": "利用可能なテキストペアがありません",
|
"noTextPairs": "利用可能なテキストペアがありません",
|
||||||
"disorder": "ランダム",
|
"disorder": "シャッフル",
|
||||||
"previous": "前へ"
|
"previous": "前へ"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
@@ -136,13 +190,15 @@
|
|||||||
"navbar": {
|
"navbar": {
|
||||||
"title": "learn-languages",
|
"title": "learn-languages",
|
||||||
"sourceCode": "GitHub",
|
"sourceCode": "GitHub",
|
||||||
"sign_in": "ログイン",
|
"sign_in": "サインイン",
|
||||||
"profile": "プロフィール",
|
"profile": "プロフィール",
|
||||||
"folders": "フォルダー"
|
"folders": "フォルダー",
|
||||||
|
"explore": "探索",
|
||||||
|
"favorites": "お気に入り"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"myProfile": "マイプロフィール",
|
"myProfile": "マイプロフィール",
|
||||||
"email": "メールアドレス: {email}",
|
"email": "メール: {email}",
|
||||||
"logout": "ログアウト"
|
"logout": "ログアウト"
|
||||||
},
|
},
|
||||||
"srt_player": {
|
"srt_player": {
|
||||||
@@ -164,21 +220,27 @@
|
|||||||
"uploaded": "アップロード済み",
|
"uploaded": "アップロード済み",
|
||||||
"notUploaded": "未アップロード",
|
"notUploaded": "未アップロード",
|
||||||
"upload": "アップロード",
|
"upload": "アップロード",
|
||||||
|
"uploadVideoButton": "ビデオをアップロード",
|
||||||
|
"uploadSubtitleButton": "字幕をアップロード",
|
||||||
|
"subtitleUploaded": "字幕をアップロード済み ({count} エントリ)",
|
||||||
|
"subtitleNotUploaded": "字幕がアップロードされていません",
|
||||||
"autoPauseStatus": "自動一時停止: {enabled}",
|
"autoPauseStatus": "自動一時停止: {enabled}",
|
||||||
"on": "オン",
|
"on": "オン",
|
||||||
"off": "オフ",
|
"off": "オフ",
|
||||||
"videoUploadFailed": "ビデオのアップロードに失敗しました",
|
"videoUploadFailed": "ビデオのアップロードに失敗しました",
|
||||||
"subtitleUploadFailed": "字幕のアップロードに失敗しました"
|
"subtitleUploadFailed": "字幕のアップロードに失敗しました",
|
||||||
|
"subtitleLoadSuccess": "字幕の読み込みに成功しました",
|
||||||
|
"subtitleLoadFailed": "字幕の読み込みに失敗しました"
|
||||||
},
|
},
|
||||||
"text_speaker": {
|
"text_speaker": {
|
||||||
"generateIPA": "IPAを生成",
|
"generateIPA": "IPAを生成",
|
||||||
"viewSavedItems": "保存済みアイテムを表示",
|
"viewSavedItems": "保存済み項目を表示",
|
||||||
"confirmDeleteAll": "本当にすべて削除しますか? (Y/N)"
|
"confirmDeleteAll": "すべて削除してもよろしいですか? (Y/N)"
|
||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"detectLanguage": "言語を検出",
|
"detectLanguage": "言語を検出",
|
||||||
"generateIPA": "IPAを生成",
|
"generateIPA": "ipaを生成",
|
||||||
"translateInto": "翻訳",
|
"translateInto": "翻訳先",
|
||||||
"chinese": "中国語",
|
"chinese": "中国語",
|
||||||
"english": "英語",
|
"english": "英語",
|
||||||
"french": "フランス語",
|
"french": "フランス語",
|
||||||
@@ -201,38 +263,77 @@
|
|||||||
"noFolders": "フォルダーが見つかりません",
|
"noFolders": "フォルダーが見つかりません",
|
||||||
"folderInfo": "{id}. {name}",
|
"folderInfo": "{id}. {name}",
|
||||||
"close": "閉じる",
|
"close": "閉じる",
|
||||||
"success": "テキストペアをフォルダーに追加しました",
|
"success": "テキストペアがフォルダーに追加されました",
|
||||||
"error": "テキストペアの追加に失敗しました"
|
"error": "テキストペアをフォルダーに追加できませんでした"
|
||||||
},
|
},
|
||||||
"autoSave": "自動保存"
|
"autoSave": "自動保存"
|
||||||
},
|
},
|
||||||
"dictionary": {
|
"dictionary": {
|
||||||
"title": "辞書",
|
"title": "辞書",
|
||||||
"description": "詳細な定義と例で単語やフレーズを検索",
|
"description": "詳細な定義と例文で単語やフレーズを検索",
|
||||||
"searchPlaceholder": "検索する単語やフレーズを入力...",
|
"searchPlaceholder": "検索する単語やフレーズを入力...",
|
||||||
"searching": "検索中...",
|
"searching": "検索中...",
|
||||||
"search": "検索",
|
"search": "検索",
|
||||||
"languageSettings": "言語設定",
|
"languageSettings": "言語設定",
|
||||||
"queryLanguage": "クエリ言語",
|
"queryLanguage": "クエリ言語",
|
||||||
"queryLanguageHint": "検索する単語/フレーズの言語",
|
"queryLanguageHint": "検索したい単語/フレーズの言語",
|
||||||
"definitionLanguage": "定義言語",
|
"definitionLanguage": "定義言語",
|
||||||
"definitionLanguageHint": "定義を表示する言語",
|
"definitionLanguageHint": "定義を表示する言語",
|
||||||
"otherLanguagePlaceholder": "または他の言語を入力...",
|
"otherLanguagePlaceholder": "または別の言語を入力...",
|
||||||
"currentSettings": "現在の設定:クエリ {queryLang}、定義 {definitionLang}",
|
"other": "その他",
|
||||||
|
"currentSettings": "現在の設定: クエリ {queryLang}, 定義 {definitionLang}",
|
||||||
"relookup": "再検索",
|
"relookup": "再検索",
|
||||||
"saveToFolder": "フォルダに保存",
|
"saveToFolder": "フォルダーに保存",
|
||||||
"loading": "読み込み中...",
|
"loading": "読み込み中...",
|
||||||
"noResults": "結果が見つかりません",
|
"noResults": "結果が見つかりません",
|
||||||
"tryOtherWords": "他の単語やフレーズを試してください",
|
"tryOtherWords": "別の単語やフレーズを試してください",
|
||||||
"welcomeTitle": "辞書へようこそ",
|
"welcomeTitle": "辞書へようこそ",
|
||||||
"welcomeHint": "上の検索ボックスに単語やフレーズを入力して検索を開始",
|
"welcomeHint": "上の検索ボックスに単語やフレーズを入力して検索を始めましょう",
|
||||||
"lookupFailed": "検索に失敗しました。後でもう一度お試しください",
|
"lookupFailed": "検索に失敗しました。後でもう一度お試しください",
|
||||||
"relookupSuccess": "再検索しました",
|
"relookupSuccess": "再検索に成功しました",
|
||||||
"relookupFailed": "辞書の再検索に失敗しました",
|
"relookupFailed": "辞書の再検索に失敗しました",
|
||||||
"pleaseLogin": "まずログインしてください",
|
"pleaseLogin": "まずログインしてください",
|
||||||
"pleaseCreateFolder": "まずフォルダを作成してください",
|
"pleaseCreateFolder": "まずフォルダーを作成してください",
|
||||||
"savedToFolder": "フォルダに保存しました:{folderName}",
|
"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": {
|
"user_profile": {
|
||||||
"anonymous": "匿名",
|
"anonymous": "匿名",
|
||||||
@@ -245,13 +346,14 @@
|
|||||||
"displayName": "表示名",
|
"displayName": "表示名",
|
||||||
"notSet": "未設定",
|
"notSet": "未設定",
|
||||||
"memberSince": "登録日",
|
"memberSince": "登録日",
|
||||||
|
"logout": "ログアウト",
|
||||||
"folders": {
|
"folders": {
|
||||||
"title": "フォルダー",
|
"title": "フォルダー",
|
||||||
"noFolders": "フォルダーがありません",
|
"noFolders": "まだフォルダーがありません",
|
||||||
"folderName": "フォルダー名",
|
"folderName": "フォルダー名",
|
||||||
"totalPairs": "テキストペア数",
|
"totalPairs": "合計ペア数",
|
||||||
"createdAt": "作成日",
|
"createdAt": "作成日",
|
||||||
"actions": "操作",
|
"actions": "アクション",
|
||||||
"view": "表示"
|
"view": "表示"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
"alphabet": {
|
"alphabet": {
|
||||||
"chooseCharacters": "학습할 문자를 선택하세요",
|
"chooseCharacters": "배우고 싶은 문자를 선택하세요",
|
||||||
|
"chooseAlphabetHint": "학습을 시작할 알파벳을 선택하세요",
|
||||||
"japanese": "일본어 가나",
|
"japanese": "일본어 가나",
|
||||||
"english": "영문 알파벳",
|
"english": "영어 알파벳",
|
||||||
"uyghur": "위구르 문자",
|
"uyghur": "위구르어 알파벳",
|
||||||
"esperanto": "에스페란토 문자",
|
"esperanto": "에스페란토 알파벳",
|
||||||
"loading": "로딩 중...",
|
"loading": "로딩 중...",
|
||||||
"loadFailed": "로딩 실패, 다시 시도해 주세요",
|
"loadFailed": "로딩 실패, 다시 시도해주세요",
|
||||||
"hideLetter": "문자 숨기기",
|
"hideLetter": "문자 숨기기",
|
||||||
"showLetter": "문자 표시",
|
"showLetter": "문자 표시",
|
||||||
"hideIPA": "IPA 숨기기",
|
"hideIPA": "IPA 숨기기",
|
||||||
@@ -14,17 +15,36 @@
|
|||||||
"roman": "로마자 표기",
|
"roman": "로마자 표기",
|
||||||
"letter": "문자",
|
"letter": "문자",
|
||||||
"random": "무작위 모드",
|
"random": "무작위 모드",
|
||||||
"randomNext": "무작위 다음"
|
"randomNext": "무작위 다음",
|
||||||
|
"previousLetter": "이전 문자",
|
||||||
|
"nextLetter": "다음 문자",
|
||||||
|
"keyboardHint": "왼쪽/오른쪽 화살표 키 또는 스페이스바로 무작위, ESC로 뒤로가기",
|
||||||
|
"swipeHint": "왼쪽/오른쪽 화살표 키 또는 스와이프로 탐색, ESC로 뒤로가기"
|
||||||
},
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
"title": "폴더",
|
"title": "폴더",
|
||||||
"subtitle": "컬렉션 관리",
|
"subtitle": "컬렉션 관리",
|
||||||
"newFolder": "새 폴더",
|
"newFolder": "새 폴더",
|
||||||
"creating": "생성 중...",
|
"creating": "생성 중...",
|
||||||
"noFoldersYet": "폴더가 없습니다",
|
"noFoldersYet": "아직 폴더가 없습니다",
|
||||||
"folderInfo": "ID: {id} • {totalPairs}쌍",
|
"folderInfo": "ID: {id} • {totalPairs} 쌍",
|
||||||
"enterFolderName": "폴더 이름 입력:",
|
"enterFolderName": "폴더 이름 입력:",
|
||||||
"confirmDelete": "삭제하려면 \"{name}\"을(를) 입력하세요:"
|
"confirmDelete": "삭제하려면 \"{name}\"을(를) 입력하세요:",
|
||||||
|
"myFolders": "내 폴더",
|
||||||
|
"publicFolders": "공개 폴더",
|
||||||
|
"public": "공개",
|
||||||
|
"private": "비공개",
|
||||||
|
"setPublic": "공개로 설정",
|
||||||
|
"setPrivate": "비공개로 설정",
|
||||||
|
"publicFolderInfo": "{userName} • {totalPairs} 쌍",
|
||||||
|
"searchPlaceholder": "공개 폴더 검색...",
|
||||||
|
"loading": "로딩 중...",
|
||||||
|
"noPublicFolders": "공개 폴더를 찾을 수 없습니다",
|
||||||
|
"unknownUser": "알 수 없는 사용자",
|
||||||
|
"enterNewName": "새 이름 입력:",
|
||||||
|
"favorite": "즐겨찾기",
|
||||||
|
"unfavorite": "즐겨찾기 해제",
|
||||||
|
"pleaseLogin": "먼저 로그인해주세요"
|
||||||
},
|
},
|
||||||
"folder_id": {
|
"folder_id": {
|
||||||
"unauthorized": "이 폴더의 소유자가 아닙니다",
|
"unauthorized": "이 폴더의 소유자가 아닙니다",
|
||||||
@@ -36,39 +56,39 @@
|
|||||||
"noTextPairs": "이 폴더에 텍스트 쌍이 없습니다",
|
"noTextPairs": "이 폴더에 텍스트 쌍이 없습니다",
|
||||||
"addNewTextPair": "새 텍스트 쌍 추가",
|
"addNewTextPair": "새 텍스트 쌍 추가",
|
||||||
"add": "추가",
|
"add": "추가",
|
||||||
"updateTextPair": "텍스트 쌍 업데이트",
|
"updateTextPair": "텍스트 쌍 수정",
|
||||||
"update": "업데이트",
|
"update": "수정",
|
||||||
"text1": "텍스트 1",
|
"text1": "텍스트 1",
|
||||||
"text2": "텍스트 2",
|
"text2": "텍스트 2",
|
||||||
"language1": "언어 1",
|
"language1": "로캘 1",
|
||||||
"language2": "언어 2",
|
"language2": "로캘 2",
|
||||||
"enterLanguageName": "언어 이름을 입력하세요",
|
"enterLanguageName": "언어 이름을 입력하세요",
|
||||||
"edit": "편집",
|
"edit": "편집",
|
||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
"permissionDenied": "이 작업을 수행할 권한이 없습니다",
|
"permissionDenied": "이 작업을 수행할 권한이 없습니다",
|
||||||
"error": {
|
"error": {
|
||||||
"update": "이 항목을 업데이트할 권한이 없습니다.",
|
"update": "이 항목을 수정할 권한이 없습니다.",
|
||||||
"delete": "이 항목을 삭제할 권한이 없습니다.",
|
"delete": "이 항목을 삭제할 권한이 없습니다.",
|
||||||
"add": "이 폴더에 항목을 추가할 권한이 없습니다.",
|
"add": "이 폴더에 항목을 추가할 권한이 없습니다.",
|
||||||
"rename": "이 폴더 이름을 변경할 권한이 없습니다.",
|
"rename": "이 폴더의 이름을 변경할 권한이 없습니다.",
|
||||||
"deleteFolder": "이 폴더를 삭제할 권한이 없습니다."
|
"deleteFolder": "이 폴더를 삭제할 권한이 없습니다."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "언어 학습",
|
"title": "언어 배우기",
|
||||||
"description": "인공 언어를 포함하여 세상의 거의 모든 언어를 배우는 데 도움이 되는 매우 유용한 웹사이트입니다.",
|
"description": "세계의 거의 모든 언어(인공어 포함)를 배우는 데 도움이 되는 매우 유용한 웹사이트입니다.",
|
||||||
"explore": "탐색",
|
"explore": "탐색",
|
||||||
"fortune": {
|
"fortune": {
|
||||||
"quote": "Stay hungry, stay foolish.",
|
"quote": "Stay hungry, stay foolish.",
|
||||||
"author": "— 스티브 잡스"
|
"author": "— Steve Jobs"
|
||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"name": "번역기",
|
"name": "번역기",
|
||||||
"description": "모든 언어로 번역하고 국제 음성 기호(IPA)로 주석 추가"
|
"description": "모든 언어로 번역하고 국제 음성 기호(IPA)로 주석 달기"
|
||||||
},
|
},
|
||||||
"textSpeaker": {
|
"textSpeaker": {
|
||||||
"name": "텍스트 스피커",
|
"name": "텍스트 스피커",
|
||||||
"description": "텍스트를 인식하고 읽어줍니다. 반복 재생 및 속도 조정 지원"
|
"description": "텍스트 인식 및 낭독, 반복 재생 및 속도 조절 지원"
|
||||||
},
|
},
|
||||||
"srtPlayer": {
|
"srtPlayer": {
|
||||||
"name": "SRT 비디오 플레이어",
|
"name": "SRT 비디오 플레이어",
|
||||||
@@ -84,21 +104,24 @@
|
|||||||
},
|
},
|
||||||
"dictionary": {
|
"dictionary": {
|
||||||
"name": "사전",
|
"name": "사전",
|
||||||
"description": "단어와 구문을 조회하고 자세한 정의와 예제 제공"
|
"description": "상세한 정의와 예문으로 단어 및 구문 검색"
|
||||||
},
|
},
|
||||||
"moreFeatures": {
|
"moreFeatures": {
|
||||||
"name": "더 많은 기능",
|
"name": "더 많은 기능",
|
||||||
"description": "개발 중, 기대해 주세요"
|
"description": "개발 중, 기대해주세요"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "인증",
|
"title": "로그인",
|
||||||
|
"signUpTitle": "회원가입",
|
||||||
"signIn": "로그인",
|
"signIn": "로그인",
|
||||||
"signUp": "회원가입",
|
"signUp": "회원가입",
|
||||||
"email": "이메일",
|
"email": "이메일",
|
||||||
"password": "비밀번호",
|
"password": "비밀번호",
|
||||||
"confirmPassword": "비밀번호 확인",
|
"confirmPassword": "비밀번호 확인",
|
||||||
"name": "이름",
|
"name": "이름",
|
||||||
|
"username": "사용자명",
|
||||||
|
"emailOrUsername": "이메일 또는 사용자명",
|
||||||
"signInButton": "로그인",
|
"signInButton": "로그인",
|
||||||
"signUpButton": "회원가입",
|
"signUpButton": "회원가입",
|
||||||
"noAccount": "계정이 없으신가요?",
|
"noAccount": "계정이 없으신가요?",
|
||||||
@@ -109,10 +132,41 @@
|
|||||||
"passwordTooShort": "비밀번호는 최소 8자 이상이어야 합니다",
|
"passwordTooShort": "비밀번호는 최소 8자 이상이어야 합니다",
|
||||||
"passwordsNotMatch": "비밀번호가 일치하지 않습니다",
|
"passwordsNotMatch": "비밀번호가 일치하지 않습니다",
|
||||||
"nameRequired": "이름을 입력하세요",
|
"nameRequired": "이름을 입력하세요",
|
||||||
|
"usernameRequired": "사용자명을 입력하세요",
|
||||||
|
"usernameTooShort": "사용자명은 최소 3자 이상이어야 합니다",
|
||||||
|
"usernameInvalid": "사용자명은 문자, 숫자, 밑줄만 포함할 수 있습니다",
|
||||||
"emailRequired": "이메일을 입력하세요",
|
"emailRequired": "이메일을 입력하세요",
|
||||||
|
"identifierRequired": "이메일 또는 사용자명을 입력하세요",
|
||||||
"passwordRequired": "비밀번호를 입력하세요",
|
"passwordRequired": "비밀번호를 입력하세요",
|
||||||
"confirmPasswordRequired": "비밀번호 확인을 입력하세요",
|
"confirmPasswordRequired": "비밀번호를 확인하세요",
|
||||||
"loading": "로딩 중..."
|
"loading": "로딩 중...",
|
||||||
|
"confirm": "확인",
|
||||||
|
"noAccountLink": "계정이 없으신가요? 회원가입",
|
||||||
|
"hasAccountLink": "이미 계정이 있으신가요? 로그인",
|
||||||
|
"usernamePlaceholder": "사용자명",
|
||||||
|
"emailPlaceholder": "이메일 주소",
|
||||||
|
"passwordPlaceholder": "비밀번호",
|
||||||
|
"usernameOrEmailPlaceholder": "사용자명 또는 이메일",
|
||||||
|
"loginFailed": "로그인 실패",
|
||||||
|
"signUpFailed": "회원가입 실패",
|
||||||
|
"fillAllFields": "모든 필드를 입력하세요",
|
||||||
|
"enterCredentials": "사용자명과 비밀번호를 입력하세요",
|
||||||
|
"forgotPassword": "비밀번호 찾기",
|
||||||
|
"forgotPasswordHint": "이메일 주소를 입력하시면 비밀번호 재설정 링크를 보내드립니다.",
|
||||||
|
"sendResetEmail": "재설정 이메일 보내기",
|
||||||
|
"resetPasswordFailed": "재설정 이메일 전송 실패",
|
||||||
|
"resetPasswordEmailSent": "재설정 이메일이 전송되었습니다",
|
||||||
|
"resetPasswordEmailSentHint": "비밀번호 재설정 링크를 이메일로 보냈습니다. 받은 편지함을 확인해주세요.",
|
||||||
|
"checkYourEmail": "이메일을 확인하세요",
|
||||||
|
"backToLogin": "로그인으로 돌아가기",
|
||||||
|
"resetPassword": "비밀번호 재설정",
|
||||||
|
"newPassword": "새 비밀번호",
|
||||||
|
"invalidToken": "유효하지 않거나 만료된 링크",
|
||||||
|
"invalidTokenHint": "이 비밀번호 재설정 링크는 유효하지 않거나 만료되었습니다. 새로 요청해 주세요.",
|
||||||
|
"requestNewToken": "새 재설정 링크 요청",
|
||||||
|
"resetPasswordSuccess": "비밀번호 재설정 성공",
|
||||||
|
"resetPasswordSuccessTitle": "비밀번호 재설정 완료",
|
||||||
|
"resetPasswordSuccessHint": "비밀번호가 성공적으로 재설정되었습니다. 새 비밀번호로 로그인할 수 있습니다."
|
||||||
},
|
},
|
||||||
"memorize": {
|
"memorize": {
|
||||||
"folder_selector": {
|
"folder_selector": {
|
||||||
@@ -130,7 +184,7 @@
|
|||||||
"previous": "이전"
|
"previous": "이전"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "이 폴더에 액세스할 권한이 없습니다"
|
"unauthorized": "이 폴더에 접근할 권한이 없습니다"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navbar": {
|
"navbar": {
|
||||||
@@ -138,7 +192,9 @@
|
|||||||
"sourceCode": "GitHub",
|
"sourceCode": "GitHub",
|
||||||
"sign_in": "로그인",
|
"sign_in": "로그인",
|
||||||
"profile": "프로필",
|
"profile": "프로필",
|
||||||
"folders": "폴더"
|
"folders": "폴더",
|
||||||
|
"explore": "탐색",
|
||||||
|
"favorites": "즐겨찾기"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"myProfile": "내 프로필",
|
"myProfile": "내 프로필",
|
||||||
@@ -152,7 +208,7 @@
|
|||||||
"play": "재생",
|
"play": "재생",
|
||||||
"previous": "이전",
|
"previous": "이전",
|
||||||
"next": "다음",
|
"next": "다음",
|
||||||
"restart": "처음부터",
|
"restart": "다시 시작",
|
||||||
"autoPause": "자동 일시정지 ({enabled})",
|
"autoPause": "자동 일시정지 ({enabled})",
|
||||||
"uploadVideoAndSubtitle": "비디오와 자막 파일을 업로드하세요",
|
"uploadVideoAndSubtitle": "비디오와 자막 파일을 업로드하세요",
|
||||||
"uploadVideoFile": "비디오 파일을 업로드하세요",
|
"uploadVideoFile": "비디오 파일을 업로드하세요",
|
||||||
@@ -164,21 +220,27 @@
|
|||||||
"uploaded": "업로드됨",
|
"uploaded": "업로드됨",
|
||||||
"notUploaded": "업로드되지 않음",
|
"notUploaded": "업로드되지 않음",
|
||||||
"upload": "업로드",
|
"upload": "업로드",
|
||||||
|
"uploadVideoButton": "비디오 업로드",
|
||||||
|
"uploadSubtitleButton": "자막 업로드",
|
||||||
|
"subtitleUploaded": "자막 업로드됨 ({count}개 항목)",
|
||||||
|
"subtitleNotUploaded": "자막 업로드되지 않음",
|
||||||
"autoPauseStatus": "자동 일시정지: {enabled}",
|
"autoPauseStatus": "자동 일시정지: {enabled}",
|
||||||
"on": "켜기",
|
"on": "켜기",
|
||||||
"off": "끄기",
|
"off": "끄기",
|
||||||
"videoUploadFailed": "비디오 업로드 실패",
|
"videoUploadFailed": "비디오 업로드 실패",
|
||||||
"subtitleUploadFailed": "자막 업로드 실패"
|
"subtitleUploadFailed": "자막 업로드 실패",
|
||||||
|
"subtitleLoadSuccess": "자막 로드 성공",
|
||||||
|
"subtitleLoadFailed": "자막 로드 실패"
|
||||||
},
|
},
|
||||||
"text_speaker": {
|
"text_speaker": {
|
||||||
"generateIPA": "IPA 생성",
|
"generateIPA": "IPA 생성",
|
||||||
"viewSavedItems": "저장된 항목 보기",
|
"viewSavedItems": "저장된 항목 보기",
|
||||||
"confirmDeleteAll": "정말 모두 삭제하시겠습니까? (Y/N)"
|
"confirmDeleteAll": "모든 것을 삭제하시겠습니까? (Y/N)"
|
||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"detectLanguage": "언어 감지",
|
"detectLanguage": "언어 감지",
|
||||||
"generateIPA": "IPA 생성",
|
"generateIPA": "IPA 생성",
|
||||||
"translateInto": "번역",
|
"translateInto": "번역할 언어",
|
||||||
"chinese": "중국어",
|
"chinese": "중국어",
|
||||||
"english": "영어",
|
"english": "영어",
|
||||||
"french": "프랑스어",
|
"french": "프랑스어",
|
||||||
@@ -201,38 +263,77 @@
|
|||||||
"noFolders": "폴더를 찾을 수 없습니다",
|
"noFolders": "폴더를 찾을 수 없습니다",
|
||||||
"folderInfo": "{id}. {name}",
|
"folderInfo": "{id}. {name}",
|
||||||
"close": "닫기",
|
"close": "닫기",
|
||||||
"success": "텍스트 쌍을 폴더에 추가했습니다",
|
"success": "텍스트 쌍이 폴더에 추가됨",
|
||||||
"error": "텍스트 쌍 추가 실패"
|
"error": "폴더에 텍스트 쌍 추가 실패"
|
||||||
},
|
},
|
||||||
"autoSave": "자동 저장"
|
"autoSave": "자동 저장"
|
||||||
},
|
},
|
||||||
"dictionary": {
|
"dictionary": {
|
||||||
"title": "사전",
|
"title": "사전",
|
||||||
"description": "상세한 정의와 예제로 단어 및 구문 검색",
|
"description": "상세한 정의와 예문으로 단어 및 구문 검색",
|
||||||
"searchPlaceholder": "검색할 단어나 구문을 입력하세요...",
|
"searchPlaceholder": "검색할 단어나 구문을 입력하세요...",
|
||||||
"searching": "검색 중...",
|
"searching": "검색 중...",
|
||||||
"search": "검색",
|
"search": "검색",
|
||||||
"languageSettings": "언어 설정",
|
"languageSettings": "언어 설정",
|
||||||
"queryLanguage": "쿼리 언어",
|
"queryLanguage": "질의 언어",
|
||||||
"queryLanguageHint": "검색하려는 단어/구문의 언어",
|
"queryLanguageHint": "검색할 단어/구문의 언어",
|
||||||
"definitionLanguage": "정의 언어",
|
"definitionLanguage": "정의 언어",
|
||||||
"definitionLanguageHint": "정의를 표시할 언어",
|
"definitionLanguageHint": "정의를 표시할 언어",
|
||||||
"otherLanguagePlaceholder": "또는 다른 언어를 입력하세요...",
|
"otherLanguagePlaceholder": "또는 다른 언어 입력...",
|
||||||
"currentSettings": "현재 설정: 쿼리 {queryLang}, 정의 {definitionLang}",
|
"other": "기타",
|
||||||
"relookup": "재검색",
|
"currentSettings": "현재 설정: 질의 {queryLang}, 정의 {definitionLang}",
|
||||||
|
"relookup": "다시 검색",
|
||||||
"saveToFolder": "폴더에 저장",
|
"saveToFolder": "폴더에 저장",
|
||||||
"loading": "로드 중...",
|
"loading": "로딩 중...",
|
||||||
"noResults": "결과를 찾을 수 없습니다",
|
"noResults": "검색 결과 없음",
|
||||||
"tryOtherWords": "다른 단어나 구문을 시도하세요",
|
"tryOtherWords": "다른 단어나 구문을 시도하세요",
|
||||||
"welcomeTitle": "사전에 오신 것을 환영합니다",
|
"welcomeTitle": "사전에 오신 것을 환영합니다",
|
||||||
"welcomeHint": "위 검색 상자에 단어나 구문을 입력하여 검색을 시작하세요",
|
"welcomeHint": "위의 검색 상자에 단어나 구문을 입력하여 검색을 시작하세요",
|
||||||
"lookupFailed": "검색 실패, 나중에 다시 시도하세요",
|
"lookupFailed": "검색 실패, 나중에 다시 시도하세요",
|
||||||
"relookupSuccess": "재검색했습니다",
|
"relookupSuccess": "다시 검색 성공",
|
||||||
"relookupFailed": "사전 재검색 실패",
|
"relookupFailed": "사전 다시 검색 실패",
|
||||||
"pleaseLogin": "먼저 로그인하세요",
|
"pleaseLogin": "먼저 로그인하세요",
|
||||||
"pleaseCreateFolder": "먼저 폴더를 만드세요",
|
"pleaseCreateFolder": "먼저 폴더를 생성하세요",
|
||||||
"savedToFolder": "폴더에 저장됨: {folderName}",
|
"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": {
|
"user_profile": {
|
||||||
"anonymous": "익명",
|
"anonymous": "익명",
|
||||||
@@ -245,11 +346,12 @@
|
|||||||
"displayName": "표시 이름",
|
"displayName": "표시 이름",
|
||||||
"notSet": "설정되지 않음",
|
"notSet": "설정되지 않음",
|
||||||
"memberSince": "가입일",
|
"memberSince": "가입일",
|
||||||
|
"logout": "로그아웃",
|
||||||
"folders": {
|
"folders": {
|
||||||
"title": "폴더",
|
"title": "폴더",
|
||||||
"noFolders": "폴더가 없습니다",
|
"noFolders": "아직 폴더가 없습니다",
|
||||||
"folderName": "폴더 이름",
|
"folderName": "폴더 이름",
|
||||||
"totalPairs": "텍스트 쌍 수",
|
"totalPairs": "총 쌍",
|
||||||
"createdAt": "생성일",
|
"createdAt": "생성일",
|
||||||
"actions": "작업",
|
"actions": "작업",
|
||||||
"view": "보기"
|
"view": "보기"
|
||||||
|
|||||||
@@ -1,122 +1,176 @@
|
|||||||
{
|
{
|
||||||
"alphabet": {
|
"alphabet": {
|
||||||
"chooseCharacters": "ئۆگىنەرلىك ھەرپلەرنى تاللاڭ",
|
"chooseCharacters": "ئۆگەنمەكچى بولغان ھەرپلەرنى تاللاڭ",
|
||||||
"japanese": "ياپونىيە كانا",
|
"chooseAlphabetHint": "ئۆگىنىشنى باشلاش ئۈچۈن بىر ئېلىپبە تاللاڭ",
|
||||||
"english": "ئىنگلىز ئېلىپبې",
|
"japanese": "ياپون يېزىقى",
|
||||||
"uyghur": "ئۇيغۇر ئېلىپبېسى",
|
"english": "ئىنگلىز ئېلىپبەسى",
|
||||||
"esperanto": "ئېسپېرانتو ئېلىپبېسى",
|
"uyghur": "ئۇيغۇر ئېلىپبەسى",
|
||||||
"loading": "چىقىرىۋېتىلىۋاتىدۇ...",
|
"esperanto": "ئېسپېرانتو ئېلىپبەسى",
|
||||||
"loadFailed": "چىقىرىش مەغلۇب بولدى، قايتا سىناڭ",
|
"loading": "يۈكلىنىۋاتىدۇ...",
|
||||||
"hideLetter": "ھەرپنى يوشۇرۇش",
|
"loadFailed": "يۈكلەش مەغلۇپ بولدى، قايتا سىناڭ",
|
||||||
"showLetter": "ھەرپنى كۆرسىتىش",
|
"hideLetter": "ھەرپنى يوشۇر",
|
||||||
"hideIPA": "IPA نى يوشۇرۇش",
|
"showLetter": "ھەرپنى كۆرسەت",
|
||||||
"showIPA": "IPA نى كۆرسىتىش",
|
"hideIPA": "IPA نى يوشۇر",
|
||||||
"roman": "روماللاشتۇرۇش",
|
"showIPA": "IPA نى كۆرسەت",
|
||||||
|
"roman": "لاتىن يېزىقى",
|
||||||
"letter": "ھەرپ",
|
"letter": "ھەرپ",
|
||||||
"random": "ئىختىيارىي ھالەت",
|
"random": "ئىختىيارىي ھالەت",
|
||||||
"randomNext": "ئىختىيارىي كېيىنكى"
|
"randomNext": "ئىختىيارىي كېيىنكى",
|
||||||
|
"previousLetter": "ئالدىنقى ھەرپ",
|
||||||
|
"nextLetter": "كېيىنكى ھەرپ",
|
||||||
|
"keyboardHint": "سول/ئوڭ يا ئوق كۇنۇپكىلىرىنى ياكى بوشلۇق كۇنۇپكىسىنى ئىختىيارىي ئالماشتۇرۇش ئۈچۈن ئىشلىتىڭ، ESC قايتىش ئۈچۈن",
|
||||||
|
"swipeHint": "سول/ئوڭ يا ئوق كۇنۇپكىلىرىنى ياكى سىيرىشنى ئىشلىتىپ يۆنىلىڭ، ESC قايتىش ئۈچۈن"
|
||||||
},
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
"title": "قىسقۇچلار",
|
"title": "قىسقۇچلار",
|
||||||
"subtitle": "توپلىمىڭىزنى باشقۇرۇڭ",
|
"subtitle": "يىغىپ ساقلاشلىرىڭىزنى باشقۇرۇڭ",
|
||||||
"newFolder": "يېڭى قىسقۇچ",
|
"newFolder": "يېڭى قىسقۇچ",
|
||||||
"creating": "قۇرۇۋاتىدۇ...",
|
"creating": "قۇرۇۋاتىدۇ...",
|
||||||
"noFoldersYet": "قىسقۇچ يوق",
|
"noFoldersYet": "تېخى قىسقۇچ يوق",
|
||||||
"folderInfo": "كود: {id} • {totalPairs} جۈپ",
|
"folderInfo": "كىملىك: {id} • {totalPairs} جۈپ",
|
||||||
"enterFolderName": "قىسقۇچ نامىنى كىرگۈزۈڭ:",
|
"enterFolderName": "قىسقۇچ ئاتىنى كىرگۈزۈڭ:",
|
||||||
"confirmDelete": "ئۆچۈرۈش ئۈچۈن «{name}» نى كىرگۈزۈڭ:"
|
"confirmDelete": "ئۆچۈرۈش ئۈچۈن \"{name}\" نى كىرگۈزۈڭ:",
|
||||||
|
"myFolders": "قىسقۇچلىرىم",
|
||||||
|
"publicFolders": "ئاممىۋى قىسقۇچلار",
|
||||||
|
"public": "ئاممىۋى",
|
||||||
|
"private": "شەخسىي",
|
||||||
|
"setPublic": "ئاممىۋى قىلىپ تەڭشە",
|
||||||
|
"setPrivate": "شەخسىي قىلىپ تەڭشە",
|
||||||
|
"publicFolderInfo": "{userName} • {totalPairs} جۈپ",
|
||||||
|
"searchPlaceholder": "ئاممىۋى قىسقۇچلارنى ئىزدەڭ...",
|
||||||
|
"loading": "يۈكلىنىۋاتىدۇ...",
|
||||||
|
"noPublicFolders": "ئاممىۋى قىسقۇچ تېپىلمىدى",
|
||||||
|
"unknownUser": "نامەلۇم ئىشلەتكۈچى",
|
||||||
|
"enterNewName": "يېڭى ئات كىرگۈزۈڭ:",
|
||||||
|
"favorite": "يىغىپ ساقلا",
|
||||||
|
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
|
||||||
|
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ"
|
||||||
},
|
},
|
||||||
"folder_id": {
|
"folder_id": {
|
||||||
"unauthorized": "سىز بۇ قىسقۇچنىڭ ئىگىسى ئەمەس",
|
"unauthorized": "بۇ قىسقۇچنىڭ ئىگىسى ئەمەسسىز",
|
||||||
"back": "كەينىگە",
|
"back": "قايتىش",
|
||||||
"textPairs": "تېكىست جۈپلىرى",
|
"textPairs": "تېكىست جۈپلىرى",
|
||||||
"itemsCount": "{count} تۈر",
|
"itemsCount": "{count} تۈر",
|
||||||
"memorize": "ئەستە ساقلاش",
|
"memorize": "يادلاش",
|
||||||
"loadingTextPairs": "تېكىست جۈپلىرى چىقىرىۋېتىلىۋاتىدۇ...",
|
"loadingTextPairs": "تېكىست جۈپلىرى يۈكلىنىۋاتىدۇ...",
|
||||||
"noTextPairs": "بۇ قىسقۇچتا تېكىست جۈپى يوق",
|
"noTextPairs": "بۇ قىسقۇچتا تېكىست جۈپى يوق",
|
||||||
"addNewTextPair": "يېڭى تېكىست جۈپى قوشۇڭ",
|
"addNewTextPair": "يېڭى تېكىست جۈپى قوشۇش",
|
||||||
"add": "قوشۇش",
|
"add": "قوشۇش",
|
||||||
"updateTextPair": "تېكىست جۈپىنى يېڭىلاڭ",
|
"updateTextPair": "تېكىست جۈپىنى يېڭىلاش",
|
||||||
"update": "يېڭىلاش",
|
"update": "يېڭىلاش",
|
||||||
"text1": "تېكىست 1",
|
"text1": "تېكىست 1",
|
||||||
"text2": "تېكىست 2",
|
"text2": "تېكىست 2",
|
||||||
"language1": "تىل 1",
|
"language1": "تىل 1",
|
||||||
"language2": "تىل 2",
|
"language2": "تىل 2",
|
||||||
"enterLanguageName": "تىل نامىنى كىرگۈزۈڭ",
|
"enterLanguageName": "تىل ئاتىنى كىرگۈزۈڭ",
|
||||||
"edit": "تەھرىرلەش",
|
"edit": "تەھرىرلەش",
|
||||||
"delete": "ئۆچۈرۈش",
|
"delete": "ئۆچۈرۈش",
|
||||||
"permissionDenied": "بۇ مەشغۇلاتنى ئىجرا قىلىش ھوقۇقىڭىز يوق",
|
"permissionDenied": "بۇ مەشغۇلاتنى ئېلىپ بېرىش ھوقۇقىڭىز يوق",
|
||||||
"error": {
|
"error": {
|
||||||
"update": "بۇ تۈرنى يېڭىلاش ھوقۇقىڭىز يوق.",
|
"update": "بۇ تۈرنى يېڭىلاش ھوقۇقىڭىز يوق.",
|
||||||
"delete": "بۇ تۈرنى ئۆچۈرۈش ھوقۇقىڭىز يوق.",
|
"delete": "بۇ تۈرنى ئۆچۈرۈش ھوقۇقىڭىز يوق.",
|
||||||
"add": "بۇ قىسقۇچقا تۈر قوشۇش ھوقۇقىڭىز يوق.",
|
"add": "بۇ قىسقۇچقا تۈر قوشۇش ھوقۇقىڭىز يوق.",
|
||||||
"rename": "بۇ قىسقۇچنىڭ نامىنى ئۆزگەرتىش ھوقۇقىڭىز يوق.",
|
"rename": "بۇ قىسقۇچنىڭ ئاتىنى ئۆزگەرتىش ھوقۇقىڭىز يوق.",
|
||||||
"deleteFolder": "بۇ قىسقۇچنى ئۆچۈرۈش ھوقۇقىڭىز يوق."
|
"deleteFolder": "بۇ قىسقۇچنى ئۆچۈرۈش ھوقۇقىڭىز يوق."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "تىل ئۆگىنىڭ",
|
"title": "تىل ئۆگىنىش",
|
||||||
"description": "بۇ سىزنى دۇنيادىكى ھەممە تىلنى، جۈملىدىن سۈنئىي تىللارنىمۇ ئۆگىنىشىڭىزغا ياردەم بېرىدىغان ناھايىتى پايدىلىق تور بېكەت.",
|
"description": "بۇ دۇنيادىكى almost ھەر بىر تىلنى، جۈملىدىن سۈنئىي تىللارنى ئۆگىنىشىڭىزگە ياردەم بېرىدىغان ئىنتايىن قوللىنىشلىق تور بېكەت.",
|
||||||
"explore": "ئىزدىنىش",
|
"explore": "ئىزدىنىش",
|
||||||
"fortune": {
|
"fortune": {
|
||||||
"quote": "Stay hungry, stay foolish.",
|
"quote": "ئاچ قورساق، ئەخمەق بولۇپ تۇرۇڭ.",
|
||||||
"author": "— ستىۋ جوۋبس"
|
"author": "— Steve Jobs"
|
||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"name": "تەرجىمە",
|
"name": "تەرجىمان",
|
||||||
"description": "خالىغان تىلغا تەرجىمە قىلىپ خەلقئارالىق فونېتىك ئېلىپبې (IPA) بىلەن ئىزاھاتلاش"
|
"description": "ھەر قانداق تىلغا تەرجىمە قىلىڭ ۋە خەلقئارالىق فونېتىكىلىق ئېلىپبە (IPA) بىلەن ئىزاھلاڭ"
|
||||||
},
|
},
|
||||||
"textSpeaker": {
|
"textSpeaker": {
|
||||||
"name": "تېكىست ئوقۇغۇچى",
|
"name": "تېكىست ئوقۇغۇچى",
|
||||||
"description": "تېكىستنى پەرقلەندۈرۈپ ئوقىيدۇ، دەۋرىي ئوقۇش ۋە سۈرئەت تەڭشەشنى قوللايدۇ"
|
"description": "تېكىستنى تونۇپ ۋە ئۈنلۈك ئوقۇپ بېرىدۇ، دەۋرىي قويۇش ۋە سۈرئەت تەڭشەشنى قوللايدۇ"
|
||||||
},
|
},
|
||||||
"srtPlayer": {
|
"srtPlayer": {
|
||||||
"name": "SRT سىن ئوپىراتورى",
|
"name": "SRT ۋىدېئو قويغۇچ",
|
||||||
"description": "SRT خەت ئاستى فايلى ئاساسىدا سىننى جۈملە-جۈملە قويۇپ، يەرلىك ئىخچام ئاۋازنى ئىمتىلايدۇ"
|
"description": "SRT تر پودكاست ھۆججەتلىرىگە ئاساسەن ۋىدېئولارنى جۈمە بويىچە قويۇپ، ئانا تىللىقلارنىڭ تەلەپپۇزىنى دوراڭ"
|
||||||
},
|
},
|
||||||
"alphabet": {
|
"alphabet": {
|
||||||
"name": "ئېلىپبې",
|
"name": "ئېلىپبە",
|
||||||
"description": "ئېلىپبېدىن يېڭى تىل ئۆگىنىشنى باشلاڭ"
|
"description": "يېڭى بىر تىلنى ئېلىپبەدىن باشلاپ ئۆگىنىڭ"
|
||||||
},
|
},
|
||||||
"memorize": {
|
"memorize": {
|
||||||
"name": "ئەستە ساقلاش",
|
"name": "يادلاش",
|
||||||
"description": "تىل A دىن تىل غا، تىل B دىن تىل A غا، دىكتات قوللايدۇ"
|
"description": "تىل A دىن تىل B گە، تىل B دىن تىل A غا، دىكتات قىلىشنى قوللايدۇ"
|
||||||
},
|
},
|
||||||
"dictionary": {
|
"dictionary": {
|
||||||
"name": "لۇغەت",
|
"name": "لۇغەت",
|
||||||
"description": "سۆز ۋە سۆزنى ئىزدەپ، تەپسىلىي ئىزاھات ۋە مىساللار بىلەن تەمىنلەيدۇ"
|
"description": "سۆزلەر ۋە ئىبارىلەرنى تەپسىلىي ئېنىقلىما ۋە مىساللار بىلەن ئىزدەڭ"
|
||||||
},
|
},
|
||||||
"moreFeatures": {
|
"moreFeatures": {
|
||||||
"name": "تېخىمۇ كۆپ ئىقتىدار",
|
"name": "تېخىمۇ كۆپ ئىقتىدارلار",
|
||||||
"description": "ئىشلەۋاتىدۇ، كۈتكۈن بولۇڭ"
|
"description": "تەرەققىيات ئاستىدا، دىققەت قىلىپ تۇرۇڭ"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "دەلىللەش",
|
"title": "كىرىش",
|
||||||
|
"signUpTitle": "تىزىملىتىش",
|
||||||
"signIn": "كىرىش",
|
"signIn": "كىرىش",
|
||||||
"signUp": "تىزىملىتىش",
|
"signUp": "تىزىملىتىش",
|
||||||
"email": "ئېلخەت",
|
"email": "ئېلخەت",
|
||||||
"password": "ئىم",
|
"password": "پارول",
|
||||||
"confirmPassword": "ئىمنى جەزملەش",
|
"confirmPassword": "پارولنى جەزىملەڭ",
|
||||||
"name": "نام",
|
"name": "ئىسىم",
|
||||||
|
"username": "ئىشلەتكۈچى ئاتى",
|
||||||
|
"emailOrUsername": "ئېلخەت ياكى ئىشلەتكۈچى ئاتى",
|
||||||
"signInButton": "كىرىش",
|
"signInButton": "كىرىش",
|
||||||
"signUpButton": "تىزىملىتىش",
|
"signUpButton": "تىزىملىتىش",
|
||||||
"noAccount": "ھېساباتىڭىز يوقمۇ؟",
|
"noAccount": "ھېساباتىڭىز يوقمۇ؟",
|
||||||
"hasAccount": "ھېساباتىڭىز بارمۇ؟",
|
"hasAccount": "ھېساباتىڭىز بارمۇ؟",
|
||||||
"signInWithGitHub": "GitHub بىلەن كىرىڭ",
|
"signInWithGitHub": "GitHub بىلەن كىرىش",
|
||||||
"signUpWithGitHub": "GitHub بىلەن تىزىملىتىڭ",
|
"signUpWithGitHub": "GitHub بىلەن تىزىملىتىش",
|
||||||
"invalidEmail": "ئىناۋەتلىك ئېلخەت ئادرېسى كىرگۈزۈڭ",
|
"invalidEmail": "ئۈنۈملۈك ئېلخەت ئادرېسى كىرگۈزۈڭ",
|
||||||
"passwordTooShort": "ئىم كەم دېگەندە 8 ھەرپتىن تۇرۇشى كېرەك",
|
"passwordTooShort": "پارول ئەڭ ئاز 8 ھەرپ بولۇشى كېرەك",
|
||||||
"passwordsNotMatch": "ئىم ماس كەلمەيدۇ",
|
"passwordsNotMatch": "پاروللار ماس كەلمەيدۇ",
|
||||||
"nameRequired": "نامىڭىزنى كىرگۈزۈڭ",
|
"nameRequired": "ئىسىمىڭىزنى كىرگۈزۈڭ",
|
||||||
"emailRequired": "ئېلخىتىڭىزنى كىرگۈزۈڭ",
|
"usernameRequired": "ئىشلەتكۈچى ئاتىنى كىرگۈزۈڭ",
|
||||||
"passwordRequired": "ئىمىڭىزنى كىرگۈزۈڭ",
|
"usernameTooShort": "ئىشلەتكۈچى ئاتى ئەڭ ئاز 3 ھەرپ بولۇشى كېرەك",
|
||||||
"confirmPasswordRequired": "ئىمىڭىزنى جەزملەڭ",
|
"usernameInvalid": "ئىشلەتكۈچى ئاتى پەقەت ھەرپ، سان ۋە ئاستى سىزىقنى ئۆز ئىچىگە ئالىدۇ",
|
||||||
"loading": "چىقىرىۋېتىلىۋاتىدۇ..."
|
"emailRequired": "ئېلخەت كىرگۈزۈڭ",
|
||||||
|
"identifierRequired": "ئېلخەت ياكى ئىشلەتكۈچى ئاتىنى كىرگۈزۈڭ",
|
||||||
|
"passwordRequired": "پارول كىرگۈزۈڭ",
|
||||||
|
"confirmPasswordRequired": "پارولنى جەزىملەڭ",
|
||||||
|
"loading": "يۈكلىنىۋاتىدۇ...",
|
||||||
|
"confirm": "جەزىملەش",
|
||||||
|
"noAccountLink": "ھېساباتىڭىز يوقمۇ؟ تىزىملىتىڭ",
|
||||||
|
"hasAccountLink": "ھېساباتىڭىز بارمۇ؟ كىرىڭ",
|
||||||
|
"usernamePlaceholder": "ئىشلەتكۈچى ئاتى",
|
||||||
|
"emailPlaceholder": "ئېلخەت ئادرېسى",
|
||||||
|
"passwordPlaceholder": "پارول",
|
||||||
|
"usernameOrEmailPlaceholder": "ئىشلەتكۈچى ئاتى ياكى ئېلخەت",
|
||||||
|
"loginFailed": "كىرىش مەغلۇپ بولدى",
|
||||||
|
"signUpFailed": "تىزىملىتىش مەغلۇپ بولدى",
|
||||||
|
"fillAllFields": "ھەممە بۆلەكلەرنى تولدۇرۇڭ",
|
||||||
|
"enterCredentials": "ئىشلەتكۈچى ئاتى ۋە پارول كىرگۈزۈڭ",
|
||||||
|
"forgotPassword": "پارولنى ئۇنتۇپ قالدىڭىزمۇ",
|
||||||
|
"forgotPasswordHint": "ئېلخەت ئادرېسىڭىزنى كىرگۈزۈڭ، بىز سىزگە پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسى ئەۋەتىمىز.",
|
||||||
|
"sendResetEmail": "ئەسلىگە قايتۇرۇش ئېلخېتى ئەۋەتىش",
|
||||||
|
"resetPasswordFailed": "ئەسلىگە قايتۇرۇش ئېلخېتى ئەۋەتىش مەغلۇپ بولدى",
|
||||||
|
"resetPasswordEmailSent": "ئەسلىگە قايتۇرۇش ئېلخېتى مۇۋەپپەقىيەتلىك ئەۋەتىلدى",
|
||||||
|
"resetPasswordEmailSentHint": "پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسىنى ئېلخەت ئادرېسىڭىزغا ئەۋەتتۇق. ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ.",
|
||||||
|
"checkYourEmail": "ئېلخېتىڭىزنى تەكشۈرۈڭ",
|
||||||
|
"backToLogin": "كىرىشكە قايتىش",
|
||||||
|
"resetPassword": "پارولنى ئەسلىگە قايتۇرۇش",
|
||||||
|
"newPassword": "يېڭى پارول",
|
||||||
|
"invalidToken": "ئۇلانما ئىناۋەتسىز ياكى ۋاقتى ئۆتكەن",
|
||||||
|
"invalidTokenHint": "بۇ پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسى ئىناۋەتسىز ياكى ۋاقتى ئۆتكەن. يېڭىدىن سوراڭ.",
|
||||||
|
"requestNewToken": "يېڭى ئەسلىگە قايتۇرۇش ئۇلانمىسى سوراش",
|
||||||
|
"resetPasswordSuccess": "پارول مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى",
|
||||||
|
"resetPasswordSuccessTitle": "پارول ئەسلىگە قايتۇرۇش تاماملاندى",
|
||||||
|
"resetPasswordSuccessHint": "پارولىڭىز مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى. يېڭى پارول بىلەن كىرسىڭىز بولىدۇ."
|
||||||
},
|
},
|
||||||
"memorize": {
|
"memorize": {
|
||||||
"folder_selector": {
|
"folder_selector": {
|
||||||
"selectFolder": "قىسقۇچ تاللاڭ",
|
"selectFolder": "بىر قىسقۇچ تاللاڭ",
|
||||||
"noFolders": "قىسقۇچ تېپىلمىدى",
|
"noFolders": "قىسقۇچ تېپىلمىدى",
|
||||||
"folderInfo": "{id}. {name} ({count})"
|
"folderInfo": "{id}. {name} ({count})"
|
||||||
},
|
},
|
||||||
@@ -125,63 +179,71 @@
|
|||||||
"next": "كېيىنكى",
|
"next": "كېيىنكى",
|
||||||
"reverse": "تەتۈر",
|
"reverse": "تەتۈر",
|
||||||
"dictation": "دىكتات",
|
"dictation": "دىكتات",
|
||||||
"noTextPairs": "ئىشلەتكىلى بولىدىغان تېكىست جۈپى يوق",
|
"noTextPairs": "تېكىست جۈپى يوق",
|
||||||
"disorder": "بەت ئارلاش",
|
"disorder": "قالايمىقانلاشتۇرۇش",
|
||||||
"previous": "ئىلگىرىكى"
|
"previous": "ئالدىنقى"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "بۇ قىسقۇچنى زىيارەت قىلىشقا ھوقۇقىڭىز يوق"
|
"unauthorized": "بۇ قىسقۇچنى زىيارەت قىلىش ھوقۇقىڭىز يوق"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navbar": {
|
"navbar": {
|
||||||
"title": "تىل ئۆگىنىش",
|
"title": "تىل-ئۆگىنىش",
|
||||||
"sourceCode": "GitHub",
|
"sourceCode": "GitHub",
|
||||||
"sign_in": "كىرىش",
|
"sign_in": "كىرىش",
|
||||||
"profile": "پروفىل",
|
"profile": "شەخسىي ئۇچۇر",
|
||||||
"folders": "قىسقۇچلار"
|
"folders": "قىسقۇچلار",
|
||||||
|
"explore": "ئىزدىنىش",
|
||||||
|
"favorites": "يىغىپ ساقلانغانلار"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"myProfile": "مېنىڭ پروفىلىم",
|
"myProfile": "شەخسىي ئۇچۇرۇم",
|
||||||
"email": "ئېلخەت: {email}",
|
"email": "ئېلخەت: {email}",
|
||||||
"logout": "چىقىش"
|
"logout": "چىكىنىش"
|
||||||
},
|
},
|
||||||
"srt_player": {
|
"srt_player": {
|
||||||
"uploadVideo": "سىن يۈكلەڭ",
|
"uploadVideo": "ۋىدېئو يۈكلەش",
|
||||||
"uploadSubtitle": "خەت ئاستى يۈكلەڭ",
|
"uploadSubtitle": "تر پودكاست يۈكلەش",
|
||||||
"pause": "ۋاقىتلىق توختىتىش",
|
"pause": "ۋاقىتلىق توختىتىش",
|
||||||
"play": "قويۇش",
|
"play": "قويۇش",
|
||||||
"previous": "ئىلگىرىكى",
|
"previous": "ئالدىنقى",
|
||||||
"next": "كېيىنكى",
|
"next": "كېيىنكى",
|
||||||
"restart": "قايتا باشلاش",
|
"restart": "قايتا باشلاش",
|
||||||
"autoPause": "ئاپتوماتىك توختىتىش ({enabled})",
|
"autoPause": "ئاپتوماتىك توختىتىش ({enabled})",
|
||||||
"uploadVideoAndSubtitle": "سىن ھەم خەت ئاستى فايلىنى يۈكلەڭ",
|
"uploadVideoAndSubtitle": "ۋىدېئو ۋە تر پودكاست ھۆججەتلىرىنى يۈكلەڭ",
|
||||||
"uploadVideoFile": "سىن فايلى يۈكلەڭ",
|
"uploadVideoFile": "ۋىدېئو ھۆججىتى يۈكلەڭ",
|
||||||
"uploadSubtitleFile": "خەت ئاستى فايلى يۈكلەڭ",
|
"uploadSubtitleFile": "تر پودكاست ھۆججىتى يۈكلەڭ",
|
||||||
"processingSubtitle": "خەت ئاستى فايلى بىر تەرەپ قىلىۋاتىدۇ...",
|
"processingSubtitle": "تر پودكاست ھۆججىتى بىر تەرەپ قىلىنىۋاتىدۇ...",
|
||||||
"needBothFiles": "ئۆگىنىشنى باشلاش ئۈچۈن سىن ھەم خەت ئاستى فايلىنىڭ ھەممىسى لازىم",
|
"needBothFiles": "ئۆگىنىشنى باشلاش ئۈچۈن ۋىدېئو ۋە تر پودكاست ھۆججەتلىرى كېرەك",
|
||||||
"videoFile": "سىن فايلى",
|
"videoFile": "ۋىدېئو ھۆججىتى",
|
||||||
"subtitleFile": "خەت ئاستى فايلى",
|
"subtitleFile": "تر پودكاست ھۆججىتى",
|
||||||
"uploaded": "يۈكلەندى",
|
"uploaded": "يۈكلەندى",
|
||||||
"notUploaded": "يۈكلەنمىدى",
|
"notUploaded": "يۈكلەنمىدى",
|
||||||
"upload": "يۈكلەش",
|
"upload": "يۈكلەش",
|
||||||
|
"uploadVideoButton": "ۋىدېئو يۈكلەش",
|
||||||
|
"uploadSubtitleButton": "تر پودكاست يۈكلەش",
|
||||||
|
"subtitleUploaded": "تر پودكاست يۈكلەندى ({count} تۈر)",
|
||||||
|
"subtitleNotUploaded": "تر پودكاست يۈكلەنمىدى",
|
||||||
"autoPauseStatus": "ئاپتوماتىك توختىتىش: {enabled}",
|
"autoPauseStatus": "ئاپتوماتىك توختىتىش: {enabled}",
|
||||||
"on": "ئوچۇق",
|
"on": "ئوچۇق",
|
||||||
"off": "تاقاق",
|
"off": "تاقاق",
|
||||||
"videoUploadFailed": "سىن يۈكلەش مەغلۇب بولدى",
|
"videoUploadFailed": "ۋىدېئو يۈكلەش مەغلۇپ بولدى",
|
||||||
"subtitleUploadFailed": "خەت ئاستى يۈكلەش مەغلۇب بولدى"
|
"subtitleUploadFailed": "تر پودكاست يۈكلەش مەغلۇپ بولدى",
|
||||||
|
"subtitleLoadSuccess": "تر پودكاست مۇۋەپپەقىيەتلىك يۈكلەندى",
|
||||||
|
"subtitleLoadFailed": "تر پودكاست يۈكلەش مەغلۇپ بولدى"
|
||||||
},
|
},
|
||||||
"text_speaker": {
|
"text_speaker": {
|
||||||
"generateIPA": "IPA ھاسىل قىلىش",
|
"generateIPA": "IPA ھاسىل قىلىش",
|
||||||
"viewSavedItems": "ساقلانغان تۈرلەرنى كۆرۈش",
|
"viewSavedItems": "ساقلانغان تۈرلەرنى كۆرۈش",
|
||||||
"confirmDeleteAll": "ھەممىنى ئۆچۈرەمسىز؟ (H/Y)"
|
"confirmDeleteAll": "ھەممىنى ئۆچۈرەمسىز؟ (Y/N)"
|
||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"detectLanguage": "تىل پەرقلەندۈرۈش",
|
"detectLanguage": "تىلنى تونۇش",
|
||||||
"generateIPA": "IPA ھاسىل قىلىش",
|
"generateIPA": "ipa ھاسىل قىلىش",
|
||||||
"translateInto": "تەرجىمە قىلىش",
|
"translateInto": "تەرجىمە قىلىش",
|
||||||
"chinese": "خەنزۇچە",
|
"chinese": "خەنزۇچە",
|
||||||
"english": "ئىنگلىزچە",
|
"english": "ئىنگلىزچە",
|
||||||
"french": "فرانسۇزچە",
|
"french": "فىرانسۇزچە",
|
||||||
"german": "گېرمانچە",
|
"german": "گېرمانچە",
|
||||||
"italian": "ئىتاليانچە",
|
"italian": "ئىتاليانچە",
|
||||||
"japanese": "ياپونچە",
|
"japanese": "ياپونچە",
|
||||||
@@ -190,68 +252,108 @@
|
|||||||
"russian": "رۇسچە",
|
"russian": "رۇسچە",
|
||||||
"spanish": "ئىسپانچە",
|
"spanish": "ئىسپانچە",
|
||||||
"other": "باشقا",
|
"other": "باشقا",
|
||||||
"translating": "تەرجىمە قىلىۋاتىدۇ...",
|
"translating": "تەرجىمە قىلىنىۋاتىدۇ...",
|
||||||
"translate": "تەرجىمە قىلىش",
|
"translate": "تەرجىمە قىلىش",
|
||||||
"inputLanguage": "بىر تىل كىرگۈزۈڭ.",
|
"inputLanguage": "بىر تىل كىرگۈزۈڭ.",
|
||||||
"history": "تارىخ",
|
"history": "تارىخ",
|
||||||
"enterLanguage": "تىل كىرگۈزۈڭ",
|
"enterLanguage": "تىل كىرگۈزۈڭ",
|
||||||
"add_to_folder": {
|
"add_to_folder": {
|
||||||
"notAuthenticated": "دەلىتلەنمىدىڭىز",
|
"notAuthenticated": "تىزىملىتىلمىدىڭىز",
|
||||||
"chooseFolder": "قوشۇلىدىغان قىسقۇچنى تاللاڭ",
|
"chooseFolder": "قوشۇش ئۈچۈن قىسقۇچ تاللاڭ",
|
||||||
"noFolders": "قىسقۇچ تېپىلمىدى",
|
"noFolders": "قىسقۇچ تېپىلمىدى",
|
||||||
"folderInfo": "{id}. {name}",
|
"folderInfo": "{id}. {name}",
|
||||||
"close": "تاقاش",
|
"close": "تاقاش",
|
||||||
"success": "تېكىست جۈپى قىسقۇچقا قوشۇلدى",
|
"success": "تېكىست جۈپى قىسقۇچقا قوشۇلدى",
|
||||||
"error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇب بولدى"
|
"error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇپ بولدى"
|
||||||
},
|
},
|
||||||
"autoSave": "ئاپتوماتىك ساقلاش"
|
"autoSave": "ئاپتوماتىك ساقلاش"
|
||||||
},
|
},
|
||||||
"dictionary": {
|
"dictionary": {
|
||||||
"title": "لۇغەت",
|
"title": "لۇغەت",
|
||||||
"description": "تەپسىلىي ئىلمىيى ۋە مىساللار بىلەن سۆز ۋە ئىبارە ئىزدەش",
|
"description": "سۆزلەر ۋە ئىبارىلەرنى تەپسىلىي ئېنىقلىما ۋە مىساللار بىلەن ئىزدەڭ",
|
||||||
"searchPlaceholder": "ئىزدەيدىغان سۆز ياكى ئىبارە كىرگۈزۈڭ...",
|
"searchPlaceholder": "ئىزدەش ئۈچۈن سۆز ياكى ئىبارە كىرگۈزۈڭ...",
|
||||||
"searching": "ئىزدەۋاتىدۇ...",
|
"searching": "ئىزدەۋاتىدۇ...",
|
||||||
"search": "ئىزدە",
|
"search": "ئىزدەش",
|
||||||
"languageSettings": "تىل تەڭشىكى",
|
"languageSettings": "تىل تەڭشەكلىرى",
|
||||||
"queryLanguage": "سۈرەشتۈرۈش تىلى",
|
"queryLanguage": "سۈرۈشتۈرۈش تىلى",
|
||||||
"queryLanguageHint": "ئىزدەمدەكچى بولغان سۆز/ئىبارە قايسى تىلدا",
|
"queryLanguageHint": "ئىزدىمەكچى بولغان سۆز/ئىبارە قايسى تىلدا",
|
||||||
"definitionLanguage": "ئىلمىيى تىلى",
|
"definitionLanguage": "ئېنىقلىما تىلى",
|
||||||
"definitionLanguageHint": "ئىلمىيىنى قايسى تىلدا كۆرۈشنى ئويلىشىسىز",
|
"definitionLanguageHint": "ئېنىقلىمىلارنى قايسى تىلدا كۆرمەكچى",
|
||||||
"otherLanguagePlaceholder": "ياكى باشقا تىل كىرگۈزۈڭ...",
|
"otherLanguagePlaceholder": "ياكى باشقا تىل كىرگۈزۈڭ...",
|
||||||
"currentSettings": "نۆۋەتتىكى تەڭشەك: سۈرەشتۈرۈش {queryLang}، ئىلمىيى {definitionLang}",
|
"other": "باشقا",
|
||||||
"relookup": "قايتا ئىزدە",
|
"currentSettings": "نۆۋەتتىكى تەڭشەكلەر: سۈرۈشتۈرۈش {queryLang}، ئېنىقلىما {definitionLang}",
|
||||||
"saveToFolder": "قىسقۇچقا ساقلا",
|
"relookup": "قايتا ئىزدەش",
|
||||||
"loading": "يۈكلىۋاتىدۇ...",
|
"saveToFolder": "قىسقۇچقا ساقلاش",
|
||||||
|
"loading": "يۈكلىنىۋاتىدۇ...",
|
||||||
"noResults": "نەتىجە تېپىلمىدى",
|
"noResults": "نەتىجە تېپىلمىدى",
|
||||||
"tryOtherWords": "باشقا سۆز ياكى ئىبارە سىناڭ",
|
"tryOtherWords": "باشقا سۆز ياكى ئىبارىلەرنى سىناڭ",
|
||||||
"welcomeTitle": "لۇغەتكە مەرھەمەت",
|
"welcomeTitle": "لۇغەتكە خۇش كەلدىڭىز",
|
||||||
"welcomeHint": "ئىزدەشنى باشلاش ئۈچۈن يۇقىرىدىكى ئىزدەش رامكىسىغا سۆز ياكى ئىبارە كىرگۈزۈڭ",
|
"welcomeHint": "ئىزدەشنى باشلاش ئۈچۈن يۇقىرىدىكى ئىزدەش رامكىسىغا سۆز ياكى ئىبارە كىرگۈزۈڭ",
|
||||||
"lookupFailed": "ئىزدەش مەغلۇب بولدى، كېيىنرەك قايتا سىناڭ",
|
"lookupFailed": "ئىزدەش مەغلۇپ بولدى، كېيىن قايتا سىناڭ",
|
||||||
"relookupSuccess": "مۇۋەپپەقىيەتلىك قايتا ئىزدىدى",
|
"relookupSuccess": "مۇۋەپپەقىيەتلىك قايتا ئىزدەلدى",
|
||||||
"relookupFailed": "لۇغەت قايتا ئىزدىشى مەغلۇب بولدى",
|
"relookupFailed": "لۇغەت قايتا ئىزدەش مەغلۇپ بولدى",
|
||||||
"pleaseLogin": "ئاۋۋال تىزىملىتىڭ",
|
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ",
|
||||||
"pleaseCreateFolder": "ئاۋۋال قىسقۇچ قۇرۇڭ",
|
"pleaseCreateFolder": "ئاۋۋال بىر قىسقۇچ قۇرۇڭ",
|
||||||
"savedToFolder": "قىسقۇچقا ساقلاندى: {folderName}",
|
"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": {
|
"user_profile": {
|
||||||
"anonymous": "ئىسىمسىز",
|
"anonymous": "نامسىز",
|
||||||
"email": "ئېلخەت",
|
"email": "ئېلخەت",
|
||||||
"verified": "دەلىللەندى",
|
"verified": "دەلىللەنگەن",
|
||||||
"unverified": "دەلىتلەنمىدى",
|
"unverified": "دەلىللەنمىگەن",
|
||||||
"accountInfo": "ھېسابات ئۇچۇرى",
|
"accountInfo": "ھېسابات ئۇچۇرلىرى",
|
||||||
"userId": "ئىشلەتكۈچى كودى",
|
"userId": "ئىشلەتكۈچى كىملىكى",
|
||||||
"username": "ئىشلەتكۈچى نامى",
|
"username": "ئىشلەتكۈچى ئاتى",
|
||||||
"displayName": "كۆرسىتىلىدىغان نام",
|
"displayName": "كۆرسىتىش ئاتى",
|
||||||
"notSet": "تەڭشەلمىگەن",
|
"notSet": "تەڭشەلمىگەن",
|
||||||
"memberSince": "تىزىملاتقان ۋاقىت",
|
"memberSince": "ئەزا بولغاندىن بېرى",
|
||||||
|
"logout": "چىكىنىش",
|
||||||
"folders": {
|
"folders": {
|
||||||
"title": "قىسقۇچلار",
|
"title": "قىسقۇچلار",
|
||||||
"noFolders": "قىسقۇچ يوق",
|
"noFolders": "تېخى قىسقۇچ يوق",
|
||||||
"folderName": "قىسقۇچ نامى",
|
"folderName": "قىسقۇچ ئاتى",
|
||||||
"totalPairs": "تېكىست جۈپ سانى",
|
"totalPairs": "جەمئىي جۈپ",
|
||||||
"createdAt": "قۇرۇلغان ۋاقىت",
|
"createdAt": "قۇرۇلغان ۋاقتى",
|
||||||
"actions": "مەشغۇلات",
|
"actions": "مەشغۇلاتلار",
|
||||||
"view": "كۆرۈش"
|
"view": "كۆرۈش"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"alphabet": {
|
"alphabet": {
|
||||||
"chooseCharacters": "请选择您想学习的字符",
|
"chooseCharacters": "请选择您想学习的字符",
|
||||||
|
"chooseAlphabetHint": "选择一种语言的字母表开始学习",
|
||||||
"japanese": "日语假名",
|
"japanese": "日语假名",
|
||||||
"english": "英文字母",
|
"english": "英文字母",
|
||||||
"uyghur": "维吾尔字母",
|
"uyghur": "维吾尔字母",
|
||||||
@@ -14,7 +15,11 @@
|
|||||||
"roman": "罗马音",
|
"roman": "罗马音",
|
||||||
"letter": "字母",
|
"letter": "字母",
|
||||||
"random": "随机模式",
|
"random": "随机模式",
|
||||||
"randomNext": "随机下一个"
|
"randomNext": "随机下一个",
|
||||||
|
"previousLetter": "上一个字母",
|
||||||
|
"nextLetter": "下一个字母",
|
||||||
|
"keyboardHint": "使用左右箭头键或空格键随机切换,ESC键返回",
|
||||||
|
"swipeHint": "使用左右箭头键或滑动切换字母"
|
||||||
},
|
},
|
||||||
"folders": {
|
"folders": {
|
||||||
"title": "文件夹",
|
"title": "文件夹",
|
||||||
@@ -24,7 +29,22 @@
|
|||||||
"noFoldersYet": "还没有文件夹",
|
"noFoldersYet": "还没有文件夹",
|
||||||
"folderInfo": "ID: {id} • {totalPairs} 个文本对",
|
"folderInfo": "ID: {id} • {totalPairs} 个文本对",
|
||||||
"enterFolderName": "输入文件夹名称:",
|
"enterFolderName": "输入文件夹名称:",
|
||||||
"confirmDelete": "输入 \"{name}\" 以删除:"
|
"confirmDelete": "输入 \"{name}\" 以删除:",
|
||||||
|
"myFolders": "我的文件夹",
|
||||||
|
"publicFolders": "公开文件夹",
|
||||||
|
"public": "公开",
|
||||||
|
"private": "私有",
|
||||||
|
"setPublic": "设为公开",
|
||||||
|
"setPrivate": "设为私有",
|
||||||
|
"publicFolderInfo": "{userName} • {totalPairs} 个文本对",
|
||||||
|
"searchPlaceholder": "搜索公开文件夹...",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"noPublicFolders": "没有找到公开文件夹",
|
||||||
|
"unknownUser": "未知用户",
|
||||||
|
"enterNewName": "输入新名称:",
|
||||||
|
"favorite": "收藏",
|
||||||
|
"unfavorite": "取消收藏",
|
||||||
|
"pleaseLogin": "请先登录"
|
||||||
},
|
},
|
||||||
"folder_id": {
|
"folder_id": {
|
||||||
"unauthorized": "您不是此文件夹的所有者",
|
"unauthorized": "您不是此文件夹的所有者",
|
||||||
@@ -93,6 +113,7 @@
|
|||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "登录",
|
"title": "登录",
|
||||||
|
"signUpTitle": "注册",
|
||||||
"signIn": "登录",
|
"signIn": "登录",
|
||||||
"signUp": "注册",
|
"signUp": "注册",
|
||||||
"email": "邮箱",
|
"email": "邮箱",
|
||||||
@@ -118,7 +139,34 @@
|
|||||||
"identifierRequired": "请输入邮箱或用户名",
|
"identifierRequired": "请输入邮箱或用户名",
|
||||||
"passwordRequired": "请输入密码",
|
"passwordRequired": "请输入密码",
|
||||||
"confirmPasswordRequired": "请确认密码",
|
"confirmPasswordRequired": "请确认密码",
|
||||||
"loading": "加载中..."
|
"loading": "加载中...",
|
||||||
|
"confirm": "确认",
|
||||||
|
"noAccountLink": "没有账号?去注册",
|
||||||
|
"hasAccountLink": "已有账号?去登录",
|
||||||
|
"usernamePlaceholder": "用户名",
|
||||||
|
"emailPlaceholder": "邮箱地址",
|
||||||
|
"passwordPlaceholder": "密码",
|
||||||
|
"usernameOrEmailPlaceholder": "用户名或邮箱地址",
|
||||||
|
"loginFailed": "登录失败",
|
||||||
|
"signUpFailed": "注册失败",
|
||||||
|
"fillAllFields": "请填写所有字段",
|
||||||
|
"enterCredentials": "请输入用户名和密码",
|
||||||
|
"forgotPassword": "忘记密码",
|
||||||
|
"forgotPasswordHint": "输入您的邮箱地址,我们将向您发送重置密码的链接。",
|
||||||
|
"sendResetEmail": "发送重置邮件",
|
||||||
|
"resetPasswordFailed": "发送重置邮件失败",
|
||||||
|
"resetPasswordEmailSent": "重置邮件已发送",
|
||||||
|
"resetPasswordEmailSentHint": "我们已向您的邮箱发送了密码重置链接,请查收。",
|
||||||
|
"checkYourEmail": "请查收邮件",
|
||||||
|
"backToLogin": "返回登录",
|
||||||
|
"resetPassword": "重置密码",
|
||||||
|
"newPassword": "新密码",
|
||||||
|
"invalidToken": "链接无效或已过期",
|
||||||
|
"invalidTokenHint": "此密码重置链接无效或已过期,请重新申请。",
|
||||||
|
"requestNewToken": "重新申请重置链接",
|
||||||
|
"resetPasswordSuccess": "密码重置成功",
|
||||||
|
"resetPasswordSuccessTitle": "密码重置完成",
|
||||||
|
"resetPasswordSuccessHint": "您的密码已成功重置,现在可以使用新密码登录了。"
|
||||||
},
|
},
|
||||||
"memorize": {
|
"memorize": {
|
||||||
"folder_selector": {
|
"folder_selector": {
|
||||||
@@ -144,7 +192,9 @@
|
|||||||
"sourceCode": "源码",
|
"sourceCode": "源码",
|
||||||
"sign_in": "登录",
|
"sign_in": "登录",
|
||||||
"profile": "个人资料",
|
"profile": "个人资料",
|
||||||
"folders": "文件夹"
|
"folders": "文件夹",
|
||||||
|
"explore": "探索",
|
||||||
|
"favorites": "收藏"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"myProfile": "我的个人资料",
|
"myProfile": "我的个人资料",
|
||||||
@@ -170,11 +220,17 @@
|
|||||||
"subtitleFile": "字幕文件",
|
"subtitleFile": "字幕文件",
|
||||||
"uploaded": "已上传",
|
"uploaded": "已上传",
|
||||||
"notUploaded": "未上传",
|
"notUploaded": "未上传",
|
||||||
|
"uploadVideoButton": "上传视频",
|
||||||
|
"uploadSubtitleButton": "上传字幕",
|
||||||
|
"subtitleUploaded": "字幕已上传 ({count} 条)",
|
||||||
|
"subtitleNotUploaded": "字幕未上传",
|
||||||
"autoPauseStatus": "自动暂停: {enabled}",
|
"autoPauseStatus": "自动暂停: {enabled}",
|
||||||
"on": "开",
|
"on": "开",
|
||||||
"off": "关",
|
"off": "关",
|
||||||
"videoUploadFailed": "视频上传失败",
|
"videoUploadFailed": "视频上传失败",
|
||||||
"subtitleUploadFailed": "字幕上传失败"
|
"subtitleUploadFailed": "字幕上传失败",
|
||||||
|
"subtitleLoadSuccess": "字幕加载成功",
|
||||||
|
"subtitleLoadFailed": "字幕加载失败"
|
||||||
},
|
},
|
||||||
"text_speaker": {
|
"text_speaker": {
|
||||||
"generateIPA": "生成IPA",
|
"generateIPA": "生成IPA",
|
||||||
@@ -224,6 +280,7 @@
|
|||||||
"definitionLanguage": "释义语言",
|
"definitionLanguage": "释义语言",
|
||||||
"definitionLanguageHint": "你希望用什么语言查看释义",
|
"definitionLanguageHint": "你希望用什么语言查看释义",
|
||||||
"otherLanguagePlaceholder": "或输入其他语言...",
|
"otherLanguagePlaceholder": "或输入其他语言...",
|
||||||
|
"other": "其他",
|
||||||
"currentSettings": "当前设置:查询 {queryLang},释义 {definitionLang}",
|
"currentSettings": "当前设置:查询 {queryLang},释义 {definitionLang}",
|
||||||
"relookup": "重新查询",
|
"relookup": "重新查询",
|
||||||
"saveToFolder": "保存到文件夹",
|
"saveToFolder": "保存到文件夹",
|
||||||
@@ -238,7 +295,45 @@
|
|||||||
"pleaseLogin": "请先登录",
|
"pleaseLogin": "请先登录",
|
||||||
"pleaseCreateFolder": "请先创建文件夹",
|
"pleaseCreateFolder": "请先创建文件夹",
|
||||||
"savedToFolder": "已保存到文件夹:{folderName}",
|
"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": {
|
"user_profile": {
|
||||||
"anonymous": "匿名",
|
"anonymous": "匿名",
|
||||||
@@ -251,6 +346,7 @@
|
|||||||
"displayName": "显示名称",
|
"displayName": "显示名称",
|
||||||
"notSet": "未设置",
|
"notSet": "未设置",
|
||||||
"memberSince": "注册时间",
|
"memberSince": "注册时间",
|
||||||
|
"logout": "登出",
|
||||||
"folders": {
|
"folders": {
|
||||||
"title": "文件夹",
|
"title": "文件夹",
|
||||||
"noFolders": "还没有文件夹",
|
"noFolders": "还没有文件夹",
|
||||||
|
|||||||
13
package.json
13
package.json
@@ -11,8 +11,8 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.2.0",
|
"@prisma/adapter-pg": "^7.4.2",
|
||||||
"@prisma/client": "^7.2.0",
|
"@prisma/client": "7.4.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-auth": "^1.4.10",
|
"better-auth": "^1.4.10",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -21,19 +21,24 @@
|
|||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
"next-intl": "^4.7.0",
|
"next-intl": "^4.7.0",
|
||||||
|
"nodemailer": "^8.0.2",
|
||||||
|
"openai": "^6.27.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"unstorage": "^1.17.3",
|
"unstorage": "^1.17.3",
|
||||||
"zod": "^4.3.5"
|
"winston": "^3.19.0",
|
||||||
|
"zod": "^4.3.5",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@better-auth/cli": "^1.4.10",
|
"@better-auth/cli": "^1.4.10",
|
||||||
"@eslint/eslintrc": "^3.3.3",
|
"@eslint/eslintrc": "^3.3.3",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/node": "^25.0.3",
|
"@types/node": "^25.0.3",
|
||||||
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/react": "19.2.7",
|
"@types/react": "19.2.7",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.51.0",
|
"@typescript-eslint/eslint-plugin": "^8.51.0",
|
||||||
@@ -42,7 +47,7 @@
|
|||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-next": "16.1.1",
|
"eslint-config-next": "16.1.1",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"prisma": "^7.2.0",
|
"prisma": "^7.4.2",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
|
|||||||
602
pnpm-lock.yaml
generated
602
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,120 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "pairs" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"locale1" VARCHAR(10) NOT NULL,
|
|
||||||
"locale2" VARCHAR(10) NOT NULL,
|
|
||||||
"text1" TEXT NOT NULL,
|
|
||||||
"text2" TEXT NOT NULL,
|
|
||||||
"ipa1" TEXT,
|
|
||||||
"ipa2" TEXT,
|
|
||||||
"folder_id" INTEGER NOT NULL,
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "pairs_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "folders" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"user_id" TEXT NOT NULL,
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "folders_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "user" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"email" TEXT NOT NULL,
|
|
||||||
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"image" TEXT,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "session" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
"token" TEXT NOT NULL,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
"ipAddress" TEXT,
|
|
||||||
"userAgent" TEXT,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "account" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"accountId" TEXT NOT NULL,
|
|
||||||
"providerId" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"accessToken" TEXT,
|
|
||||||
"refreshToken" TEXT,
|
|
||||||
"idToken" TEXT,
|
|
||||||
"accessTokenExpiresAt" TIMESTAMP(3),
|
|
||||||
"refreshTokenExpiresAt" TIMESTAMP(3),
|
|
||||||
"scope" TEXT,
|
|
||||||
"password" TEXT,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "verification" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"identifier" TEXT NOT NULL,
|
|
||||||
"value" TEXT NOT NULL,
|
|
||||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "pairs_folder_id_idx" ON "pairs"("folder_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "pairs_folder_id_locale1_locale2_text1_key" ON "pairs"("folder_id", "locale1", "locale2", "text1");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "folders_user_id_idx" ON "folders"("user_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "session_userId_idx" ON "session"("userId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "account_userId_idx" ON "account"("userId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "verification_identifier_idx" ON "verification"("identifier");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "pairs" ADD CONSTRAINT "pairs_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "folders" ADD CONSTRAINT "folders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- You are about to drop the column `ipa1` on the `pairs` table. All the data in the column will be lost.
|
|
||||||
- You are about to drop the column `ipa2` on the `pairs` table. All the data in the column will be lost.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- AlterTable
|
|
||||||
-- 重命名并修改类型为 TEXT
|
|
||||||
ALTER TABLE "pairs"
|
|
||||||
RENAME COLUMN "locale1" TO "language1";
|
|
||||||
|
|
||||||
ALTER TABLE "pairs"
|
|
||||||
ALTER COLUMN "language1" SET DATA TYPE VARCHAR(20);
|
|
||||||
|
|
||||||
ALTER TABLE "pairs"
|
|
||||||
RENAME COLUMN "locale2" TO "language2";
|
|
||||||
|
|
||||||
ALTER TABLE "pairs"
|
|
||||||
ALTER COLUMN "language2" SET DATA TYPE VARCHAR(20);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "dictionary_lookups" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"user_id" TEXT,
|
|
||||||
"text" TEXT NOT NULL,
|
|
||||||
"query_lang" TEXT NOT NULL,
|
|
||||||
"definition_lang" TEXT NOT NULL,
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"dictionary_word_id" INTEGER,
|
|
||||||
"dictionary_phrase_id" INTEGER,
|
|
||||||
|
|
||||||
CONSTRAINT "dictionary_lookups_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "dictionary_words" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"standard_form" TEXT NOT NULL,
|
|
||||||
"query_lang" TEXT NOT NULL,
|
|
||||||
"definition_lang" TEXT NOT NULL,
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "dictionary_words_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "dictionary_phrases" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"standard_form" TEXT NOT NULL,
|
|
||||||
"query_lang" TEXT NOT NULL,
|
|
||||||
"definition_lang" TEXT NOT NULL,
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "dictionary_phrases_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "dictionary_word_entries" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"word_id" INTEGER NOT NULL,
|
|
||||||
"ipa" TEXT NOT NULL,
|
|
||||||
"definition" TEXT NOT NULL,
|
|
||||||
"part_of_speech" TEXT NOT NULL,
|
|
||||||
"example" TEXT NOT NULL,
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "dictionary_word_entries_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "dictionary_phrase_entries" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"phrase_id" INTEGER NOT NULL,
|
|
||||||
"definition" TEXT NOT NULL,
|
|
||||||
"example" TEXT NOT NULL,
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "dictionary_phrase_entries_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "dictionary_lookups_user_id_idx" ON "dictionary_lookups"("user_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "dictionary_lookups_created_at_idx" ON "dictionary_lookups"("created_at");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "dictionary_lookups_text_query_lang_definition_lang_idx" ON "dictionary_lookups"("text", "query_lang", "definition_lang");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "dictionary_words_standard_form_idx" ON "dictionary_words"("standard_form");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "dictionary_words_query_lang_definition_lang_idx" ON "dictionary_words"("query_lang", "definition_lang");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "dictionary_words_standard_form_query_lang_definition_lang_key" ON "dictionary_words"("standard_form", "query_lang", "definition_lang");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "dictionary_phrases_standard_form_idx" ON "dictionary_phrases"("standard_form");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "dictionary_phrases_query_lang_definition_lang_idx" ON "dictionary_phrases"("query_lang", "definition_lang");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "dictionary_phrases_standard_form_query_lang_definition_lang_key" ON "dictionary_phrases"("standard_form", "query_lang", "definition_lang");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "dictionary_word_entries_word_id_idx" ON "dictionary_word_entries"("word_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "dictionary_word_entries_created_at_idx" ON "dictionary_word_entries"("created_at");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "dictionary_phrase_entries_phrase_id_idx" ON "dictionary_phrase_entries"("phrase_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "dictionary_phrase_entries_created_at_idx" ON "dictionary_phrase_entries"("created_at");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_word_id_fkey" FOREIGN KEY ("dictionary_word_id") REFERENCES "dictionary_words"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_phrase_id_fkey" FOREIGN KEY ("dictionary_phrase_id") REFERENCES "dictionary_phrases"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "dictionary_word_entries" ADD CONSTRAINT "dictionary_word_entries_word_id_fkey" FOREIGN KEY ("word_id") REFERENCES "dictionary_words"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "dictionary_phrase_entries" ADD CONSTRAINT "dictionary_phrase_entries_phrase_id_fkey" FOREIGN KEY ("phrase_id") REFERENCES "dictionary_phrases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
-- DropIndex
|
|
||||||
DROP INDEX "dictionary_phrases_standard_form_query_lang_definition_lang_key";
|
|
||||||
|
|
||||||
-- DropIndex
|
|
||||||
DROP INDEX "dictionary_words_standard_form_query_lang_definition_lang_key";
|
|
||||||
|
|
||||||
-- RenameIndex
|
|
||||||
ALTER INDEX "pairs_folder_id_locale1_locale2_text1_key" RENAME TO "pairs_folder_id_language1_language2_text1_key";
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "translation_history" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"user_id" TEXT,
|
|
||||||
"source_text" TEXT NOT NULL,
|
|
||||||
"source_language" VARCHAR(20) NOT NULL,
|
|
||||||
"target_language" VARCHAR(20) NOT NULL,
|
|
||||||
"translated_text" TEXT NOT NULL,
|
|
||||||
"source_ipa" TEXT,
|
|
||||||
"target_ipa" TEXT,
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "translation_history_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "translation_history_user_id_idx" ON "translation_history"("user_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "translation_history_created_at_idx" ON "translation_history"("created_at");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "translation_history_source_text_target_language_idx" ON "translation_history"("source_text", "target_language");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "translation_history_translated_text_source_language_target__idx" ON "translation_history"("translated_text", "source_language", "target_language");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "translation_history" ADD CONSTRAINT "translation_history_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- A unique constraint covering the columns `[folder_id,language1,language2,text1,text2]` on the table `pairs` will be added. If there are existing duplicate values, this will fail.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- DropIndex
|
|
||||||
DROP INDEX "pairs_folder_id_language1_language2_text1_key";
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "pairs_folder_id_language1_language2_text1_text2_key" ON "pairs"("folder_id", "language1", "language2", "text1", "text2");
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "pairs" ALTER COLUMN "language1" SET DATA TYPE TEXT,
|
|
||||||
ALTER COLUMN "language2" SET DATA TYPE TEXT;
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "translation_history" ALTER COLUMN "source_language" SET DATA TYPE TEXT,
|
|
||||||
ALTER COLUMN "target_language" SET DATA TYPE TEXT;
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- You are about to drop the column `dictionary_phrase_id` on the `dictionary_lookups` table. All the data in the column will be lost.
|
|
||||||
- You are about to drop the column `dictionary_word_id` on the `dictionary_lookups` table. All the data in the column will be lost.
|
|
||||||
- You are about to drop the `dictionary_phrase_entries` table. If the table is not empty, all the data it contains will be lost.
|
|
||||||
- You are about to drop the `dictionary_phrases` table. If the table is not empty, all the data it contains will be lost.
|
|
||||||
- You are about to drop the `dictionary_word_entries` table. If the table is not empty, all the data it contains will be lost.
|
|
||||||
- You are about to drop the `dictionary_words` table. If the table is not empty, all the data it contains will be lost.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- DropForeignKey
|
|
||||||
ALTER TABLE "dictionary_lookups" DROP CONSTRAINT "dictionary_lookups_dictionary_phrase_id_fkey";
|
|
||||||
|
|
||||||
-- DropForeignKey
|
|
||||||
ALTER TABLE "dictionary_lookups" DROP CONSTRAINT "dictionary_lookups_dictionary_word_id_fkey";
|
|
||||||
|
|
||||||
-- DropForeignKey
|
|
||||||
ALTER TABLE "dictionary_phrase_entries" DROP CONSTRAINT "dictionary_phrase_entries_phrase_id_fkey";
|
|
||||||
|
|
||||||
-- DropForeignKey
|
|
||||||
ALTER TABLE "dictionary_word_entries" DROP CONSTRAINT "dictionary_word_entries_word_id_fkey";
|
|
||||||
|
|
||||||
-- DropIndex
|
|
||||||
DROP INDEX "dictionary_lookups_text_query_lang_definition_lang_idx";
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "dictionary_lookups" DROP COLUMN "dictionary_phrase_id",
|
|
||||||
DROP COLUMN "dictionary_word_id",
|
|
||||||
ADD COLUMN "dictionary_item_id" INTEGER,
|
|
||||||
ADD COLUMN "normalized_text" TEXT NOT NULL DEFAULT '';
|
|
||||||
|
|
||||||
-- DropTable
|
|
||||||
DROP TABLE "dictionary_phrase_entries";
|
|
||||||
|
|
||||||
-- DropTable
|
|
||||||
DROP TABLE "dictionary_phrases";
|
|
||||||
|
|
||||||
-- DropTable
|
|
||||||
DROP TABLE "dictionary_word_entries";
|
|
||||||
|
|
||||||
-- DropTable
|
|
||||||
DROP TABLE "dictionary_words";
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "dictionary_items" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"frequency" INTEGER NOT NULL DEFAULT 1,
|
|
||||||
"standard_form" TEXT NOT NULL,
|
|
||||||
"query_lang" TEXT NOT NULL,
|
|
||||||
"definition_lang" TEXT NOT NULL,
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "dictionary_items_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "dictionary_entries" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"item_id" INTEGER NOT NULL,
|
|
||||||
"ipa" TEXT,
|
|
||||||
"definition" TEXT NOT NULL,
|
|
||||||
"part_of_speech" TEXT,
|
|
||||||
"example" TEXT NOT NULL,
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "dictionary_entries_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "dictionary_items_standard_form_idx" ON "dictionary_items"("standard_form");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "dictionary_items_query_lang_definition_lang_idx" ON "dictionary_items"("query_lang", "definition_lang");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "dictionary_items_standard_form_query_lang_definition_lang_key" ON "dictionary_items"("standard_form", "query_lang", "definition_lang");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "dictionary_entries_item_id_idx" ON "dictionary_entries"("item_id");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "dictionary_entries_created_at_idx" ON "dictionary_entries"("created_at");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "dictionary_lookups_normalized_text_idx" ON "dictionary_lookups"("normalized_text");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_item_id_fkey" FOREIGN KEY ("dictionary_item_id") REFERENCES "dictionary_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "dictionary_entries" ADD CONSTRAINT "dictionary_entries_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "dictionary_items"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- A unique constraint covering the columns `[username]` on the table `user` will be added. If there are existing duplicate values, this will fail.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "user" ADD COLUMN "displayUsername" TEXT,
|
|
||||||
ADD COLUMN "username" TEXT;
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "user_username_key" ON "user"("username");
|
|
||||||
262
prisma/migrations/20260310014042_init/migration.sql
Normal file
262
prisma/migrations/20260310014042_init/migration.sql
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Visibility" AS ENUM ('PRIVATE', 'PUBLIC');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "user" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"image" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"displayUsername" TEXT,
|
||||||
|
"username" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "session" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"ipAddress" TEXT,
|
||||||
|
"userAgent" TEXT,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "account" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"providerId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"accessToken" TEXT,
|
||||||
|
"refreshToken" TEXT,
|
||||||
|
"idToken" TEXT,
|
||||||
|
"accessTokenExpiresAt" TIMESTAMP(3),
|
||||||
|
"refreshTokenExpiresAt" TIMESTAMP(3),
|
||||||
|
"scope" TEXT,
|
||||||
|
"password" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "verification" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"identifier" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "pairs" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"language1" TEXT NOT NULL,
|
||||||
|
"language2" TEXT NOT NULL,
|
||||||
|
"text1" TEXT NOT NULL,
|
||||||
|
"text2" TEXT NOT NULL,
|
||||||
|
"ipa1" TEXT,
|
||||||
|
"ipa2" TEXT,
|
||||||
|
"folder_id" INTEGER NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "pairs_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "folders" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"visibility" "Visibility" NOT NULL DEFAULT 'PRIVATE',
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "folders_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "folder_favorites" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"folder_id" INTEGER NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "folder_favorites_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "dictionary_lookups" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"user_id" TEXT,
|
||||||
|
"text" TEXT NOT NULL,
|
||||||
|
"query_lang" TEXT NOT NULL,
|
||||||
|
"definition_lang" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"dictionary_item_id" INTEGER,
|
||||||
|
"normalized_text" TEXT NOT NULL DEFAULT '',
|
||||||
|
|
||||||
|
CONSTRAINT "dictionary_lookups_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "dictionary_items" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"frequency" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"standard_form" TEXT NOT NULL,
|
||||||
|
"query_lang" TEXT NOT NULL,
|
||||||
|
"definition_lang" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "dictionary_items_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "dictionary_entries" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"item_id" INTEGER NOT NULL,
|
||||||
|
"ipa" TEXT,
|
||||||
|
"definition" TEXT NOT NULL,
|
||||||
|
"part_of_speech" TEXT,
|
||||||
|
"example" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "dictionary_entries_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "translation_history" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"user_id" TEXT,
|
||||||
|
"source_text" TEXT NOT NULL,
|
||||||
|
"source_language" TEXT NOT NULL,
|
||||||
|
"target_language" TEXT NOT NULL,
|
||||||
|
"translated_text" TEXT NOT NULL,
|
||||||
|
"source_ipa" TEXT,
|
||||||
|
"target_ipa" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "translation_history_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "user_username_key" ON "user"("username");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "session_userId_idx" ON "session"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "account_userId_idx" ON "account"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "verification_identifier_idx" ON "verification"("identifier");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "pairs_folder_id_idx" ON "pairs"("folder_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "pairs_folder_id_language1_language2_text1_text2_key" ON "pairs"("folder_id", "language1", "language2", "text1", "text2");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "folders_user_id_idx" ON "folders"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "folders_visibility_idx" ON "folders"("visibility");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "folder_favorites_user_id_idx" ON "folder_favorites"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "folder_favorites_folder_id_idx" ON "folder_favorites"("folder_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "folder_favorites_user_id_folder_id_key" ON "folder_favorites"("user_id", "folder_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "dictionary_lookups_user_id_idx" ON "dictionary_lookups"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "dictionary_lookups_created_at_idx" ON "dictionary_lookups"("created_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "dictionary_lookups_normalized_text_idx" ON "dictionary_lookups"("normalized_text");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "dictionary_items_standard_form_idx" ON "dictionary_items"("standard_form");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "dictionary_items_query_lang_definition_lang_idx" ON "dictionary_items"("query_lang", "definition_lang");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "dictionary_items_standard_form_query_lang_definition_lang_key" ON "dictionary_items"("standard_form", "query_lang", "definition_lang");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "dictionary_entries_item_id_idx" ON "dictionary_entries"("item_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "dictionary_entries_created_at_idx" ON "dictionary_entries"("created_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "translation_history_user_id_idx" ON "translation_history"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "translation_history_created_at_idx" ON "translation_history"("created_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "translation_history_source_text_target_language_idx" ON "translation_history"("source_text", "target_language");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "translation_history_translated_text_source_language_target__idx" ON "translation_history"("translated_text", "source_language", "target_language");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "pairs" ADD CONSTRAINT "pairs_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "folders" ADD CONSTRAINT "folders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "folder_favorites" ADD CONSTRAINT "folder_favorites_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "folder_favorites" ADD CONSTRAINT "folder_favorites_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_item_id_fkey" FOREIGN KEY ("dictionary_item_id") REFERENCES "dictionary_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "dictionary_entries" ADD CONSTRAINT "dictionary_entries_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "dictionary_items"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "translation_history" ADD CONSTRAINT "translation_history_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -16,10 +16,11 @@ model User {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
displayUsername String?
|
displayUsername String?
|
||||||
username String? @unique
|
username String @unique
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
dictionaryLookUps DictionaryLookUp[]
|
dictionaryLookUps DictionaryLookUp[]
|
||||||
folders Folder[]
|
folders Folder[]
|
||||||
|
folderFavorites FolderFavorite[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
translationHistories TranslationHistory[]
|
translationHistories TranslationHistory[]
|
||||||
|
|
||||||
@@ -91,19 +92,42 @@ model Pair {
|
|||||||
@@map("pairs")
|
@@map("pairs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum Visibility {
|
||||||
|
PRIVATE
|
||||||
|
PUBLIC
|
||||||
|
}
|
||||||
|
|
||||||
model Folder {
|
model Folder {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
visibility Visibility @default(PRIVATE)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
pairs Pair[]
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
pairs Pair[]
|
||||||
|
favorites FolderFavorite[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
|
@@index([visibility])
|
||||||
@@map("folders")
|
@@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 {
|
model DictionaryLookUp {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId String? @map("user_id")
|
userId String? @map("user_id")
|
||||||
|
|||||||
102
src/app/(auth)/forgot-password/page.tsx
Normal file
102
src/app/(auth)/forgot-password/page.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Card, CardBody } from "@/design-system/base/card";
|
||||||
|
import { Input } from "@/design-system/base/input";
|
||||||
|
import { PrimaryButton } from "@/design-system/base/button";
|
||||||
|
import { VStack } from "@/design-system/layout/stack";
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
const t = useTranslations("auth");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
|
||||||
|
const handleResetRequest = async () => {
|
||||||
|
if (!email) {
|
||||||
|
toast.error(t("emailRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
const { error } = await authClient.requestPasswordReset({
|
||||||
|
email,
|
||||||
|
redirectTo: "/reset-password",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error(error.message ?? t("resetPasswordFailed"));
|
||||||
|
} else {
|
||||||
|
setSent(true);
|
||||||
|
toast.success(t("resetPasswordEmailSent"));
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sent) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center min-h-screen">
|
||||||
|
<Card className="w-96">
|
||||||
|
<CardBody>
|
||||||
|
<VStack gap={4} align="center" justify="center">
|
||||||
|
<h1 className="text-2xl font-bold text-center w-full">
|
||||||
|
{t("checkYourEmail")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-center text-gray-600">
|
||||||
|
{t("resetPasswordEmailSentHint")}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="text-primary-500 hover:underline"
|
||||||
|
>
|
||||||
|
{t("backToLogin")}
|
||||||
|
</Link>
|
||||||
|
</VStack>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center min-h-screen">
|
||||||
|
<Card className="w-96">
|
||||||
|
<CardBody>
|
||||||
|
<VStack gap={4} align="center" justify="center">
|
||||||
|
<h1 className="text-3xl font-bold text-center w-full">
|
||||||
|
{t("forgotPassword")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-center text-gray-600 text-sm">
|
||||||
|
{t("forgotPasswordHint")}
|
||||||
|
</p>
|
||||||
|
<VStack gap={0} align="center" justify="center" className="w-full">
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder={t("emailPlaceholder")}
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={handleResetRequest}
|
||||||
|
loading={loading}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{t("sendResetEmail")}
|
||||||
|
</PrimaryButton>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="text-center text-primary-500 hover:underline"
|
||||||
|
>
|
||||||
|
{t("backToLogin")}
|
||||||
|
</Link>
|
||||||
|
</VStack>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
src/app/(auth)/login/page.tsx
Normal file
113
src/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Card, CardBody } from "@/design-system/base/card";
|
||||||
|
import { Input } from "@/design-system/base/input";
|
||||||
|
import { PrimaryButton } from "@/design-system/base/button";
|
||||||
|
import { VStack } from "@/design-system/layout/stack";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const t = useTranslations("auth");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const redirectTo = searchParams.get("redirect");
|
||||||
|
|
||||||
|
const { data: session, isPending } = authClient.useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPending && session?.user?.username && !redirectTo) {
|
||||||
|
router.push("/folders");
|
||||||
|
}
|
||||||
|
}, [session, isPending, router, redirectTo]);
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (!username || !password) {
|
||||||
|
toast.error(t("enterCredentials"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (username.includes("@")) {
|
||||||
|
const { error } = await authClient.signIn.email({
|
||||||
|
email: username,
|
||||||
|
password: password,
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
toast.error(error.message ?? t("loginFailed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { error } = await authClient.signIn.username({
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
toast.error(error.message ?? t("loginFailed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
router.push(redirectTo ?? "/folders");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center min-h-screen">
|
||||||
|
<Card className="w-96">
|
||||||
|
<CardBody>
|
||||||
|
<VStack gap={4} align="center" justify="center">
|
||||||
|
<h1 className="text-3xl font-bold text-center w-full">{t("title")}</h1>
|
||||||
|
|
||||||
|
<VStack gap={0} align="center" justify="center" className="w-full">
|
||||||
|
<Input
|
||||||
|
placeholder={t("usernameOrEmailPlaceholder")}
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder={t("passwordPlaceholder")}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/forgot-password"
|
||||||
|
className="text-sm text-gray-500 hover:text-primary-500 self-end"
|
||||||
|
>
|
||||||
|
{t("forgotPassword")}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={handleLogin}
|
||||||
|
loading={loading}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{t("confirm")}
|
||||||
|
</PrimaryButton>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={"/signup" + (redirectTo ? `?redirect=${redirectTo}` : "")}
|
||||||
|
className="text-center text-primary-500 hover:underline"
|
||||||
|
>
|
||||||
|
{t("noAccountLink")}
|
||||||
|
</Link>
|
||||||
|
</VStack>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/app/(auth)/logout/page.tsx
Normal file
25
src/app/(auth)/logout/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { auth } from "@/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function LogoutPage(
|
||||||
|
props: {
|
||||||
|
searchParams: Promise<{ [key: string]: string | undefined; }>;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const searchParams = await props.searchParams;
|
||||||
|
const redirectTo = searchParams.redirect ?? null;
|
||||||
|
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers()
|
||||||
|
});
|
||||||
|
if (session) {
|
||||||
|
await auth.api.signOut({
|
||||||
|
headers: await headers()
|
||||||
|
});
|
||||||
|
redirect("/login" + (redirectTo ? `?redirect=${redirectTo}` : ""));
|
||||||
|
} else {
|
||||||
|
redirect("/profile");
|
||||||
|
}
|
||||||
|
return (<></>);
|
||||||
|
}
|
||||||
13
src/app/(auth)/profile/page.tsx
Normal file
13
src/app/(auth)/profile/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { auth } from "@/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
export default async function ProfilePage() {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
redirect("/login?redirect=/profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(session.user.username ? `/users/${session.user.username}` : "/folders");
|
||||||
|
}
|
||||||
154
src/app/(auth)/reset-password/page.tsx
Normal file
154
src/app/(auth)/reset-password/page.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Card, CardBody } from "@/design-system/base/card";
|
||||||
|
import { Input } from "@/design-system/base/input";
|
||||||
|
import { PrimaryButton } from "@/design-system/base/button";
|
||||||
|
import { VStack } from "@/design-system/layout/stack";
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
const t = useTranslations("auth");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const token = searchParams.get("token");
|
||||||
|
|
||||||
|
const handleResetPassword = async () => {
|
||||||
|
if (!password || !confirmPassword) {
|
||||||
|
toast.error(t("fillAllFields"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
toast.error(t("passwordsNotMatch"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
toast.error(t("passwordTooShort"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
toast.error(t("invalidToken"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
const { error } = await authClient.resetPassword({
|
||||||
|
newPassword: password,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error(error.message ?? t("resetPasswordFailed"));
|
||||||
|
} else {
|
||||||
|
setSuccess(true);
|
||||||
|
toast.success(t("resetPasswordSuccess"));
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push("/login");
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center min-h-screen">
|
||||||
|
<Card className="w-96">
|
||||||
|
<CardBody>
|
||||||
|
<VStack gap={4} align="center" justify="center">
|
||||||
|
<h1 className="text-2xl font-bold text-center w-full">
|
||||||
|
{t("resetPasswordSuccessTitle")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-center text-gray-600">
|
||||||
|
{t("resetPasswordSuccessHint")}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="text-primary-500 hover:underline"
|
||||||
|
>
|
||||||
|
{t("backToLogin")}
|
||||||
|
</Link>
|
||||||
|
</VStack>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center min-h-screen">
|
||||||
|
<Card className="w-96">
|
||||||
|
<CardBody>
|
||||||
|
<VStack gap={4} align="center" justify="center">
|
||||||
|
<h1 className="text-2xl font-bold text-center w-full">
|
||||||
|
{t("invalidToken")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-center text-gray-600">
|
||||||
|
{t("invalidTokenHint")}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/forgot-password"
|
||||||
|
className="text-primary-500 hover:underline"
|
||||||
|
>
|
||||||
|
{t("requestNewToken")}
|
||||||
|
</Link>
|
||||||
|
</VStack>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center min-h-screen">
|
||||||
|
<Card className="w-96">
|
||||||
|
<CardBody>
|
||||||
|
<VStack gap={4} align="center" justify="center">
|
||||||
|
<h1 className="text-3xl font-bold text-center w-full">
|
||||||
|
{t("resetPassword")}
|
||||||
|
</h1>
|
||||||
|
<VStack gap={0} align="center" justify="center" className="w-full">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder={t("newPassword")}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder={t("confirmPassword")}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={handleResetPassword}
|
||||||
|
loading={loading}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{t("resetPassword")}
|
||||||
|
</PrimaryButton>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="text-center text-primary-500 hover:underline"
|
||||||
|
>
|
||||||
|
{t("backToLogin")}
|
||||||
|
</Link>
|
||||||
|
</VStack>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/app/(auth)/signup/page.tsx
Normal file
106
src/app/(auth)/signup/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { PageLayout } from "@/components/ui/PageLayout";
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
import { LinkButton } from "@/design-system/base/button";
|
import { LightButton, LinkButton } from "@/design-system/base/button";
|
||||||
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
|
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
|
||||||
import { repoGetFoldersWithTotalPairsByUserId } from "@/modules/folder/folder-repository";
|
import { repoGetFoldersWithTotalPairsByUserId } from "@/modules/folder/folder-repository";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { LogoutButton } from "@/app/users/[username]/LogoutButton";
|
// import { LogoutButton } from "./LogoutButton";
|
||||||
|
|
||||||
interface UserPageProps {
|
interface UserPageProps {
|
||||||
params: Promise<{ username: string; }>;
|
params: Promise<{ username: string; }>;
|
||||||
@@ -42,12 +42,12 @@ export default async function UserPage({ params }: UserPageProps) {
|
|||||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div></div>
|
<div></div>
|
||||||
{isOwnProfile && <LogoutButton />}
|
{isOwnProfile && <LinkButton href="/logout">{t("logout")}</LinkButton>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-6">
|
<div className="flex items-center space-x-6">
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
{user.image ? (
|
{user.image ? (
|
||||||
<div className="relative w-24 h-24 rounded-full border-4 border-[#35786f] overflow-hidden">
|
<div className="relative w-24 h-24 rounded-full border-4 border-primary-500 overflow-hidden">
|
||||||
<Image
|
<Image
|
||||||
src={user.image}
|
src={user.image}
|
||||||
alt={user.displayUsername || user.username || user.email}
|
alt={user.displayUsername || user.username || user.email}
|
||||||
@@ -57,7 +57,7 @@ export default async function UserPage({ params }: UserPageProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-24 h-24 rounded-full bg-[#35786f] border-4 border-[#35786f] flex items-center justify-center">
|
<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">
|
<span className="text-3xl font-bold text-white">
|
||||||
{(user.displayUsername || user.username || user.email)[0].toUpperCase()}
|
{(user.displayUsername || user.username || user.email)[0].toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
@@ -74,6 +74,9 @@ export default async function UserPage({ params }: UserPageProps) {
|
|||||||
@{user.username}
|
@{user.username}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<p className="text-gray-600 text-sm mb-1">
|
||||||
|
{user.email}
|
||||||
|
</p>
|
||||||
<div className="flex items-center space-x-4 text-sm">
|
<div className="flex items-center space-x-4 text-sm">
|
||||||
<span className="text-gray-500">
|
<span className="text-gray-500">
|
||||||
Joined: {new Date(user.createdAt).toLocaleDateString()}
|
Joined: {new Date(user.createdAt).toLocaleDateString()}
|
||||||
@@ -81,7 +84,7 @@ export default async function UserPage({ params }: UserPageProps) {
|
|||||||
{user.emailVerified && (
|
{user.emailVerified && (
|
||||||
<span className="flex items-center text-green-600">
|
<span className="flex items-center text-green-600">
|
||||||
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
<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 00016zm3.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" />
|
<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>
|
</svg>
|
||||||
Verified
|
Verified
|
||||||
</span>
|
</span>
|
||||||
@@ -91,25 +94,6 @@ export default async function UserPage({ params }: UserPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email Section */}
|
|
||||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("email")}</h2>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<span className="text-gray-700">{user.email}</span>
|
|
||||||
</div>
|
|
||||||
{user.emailVerified ? (
|
|
||||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
|
|
||||||
✓ {t("verified")}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800">
|
|
||||||
{t("unverified")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Account Info */}
|
{/* Account Info */}
|
||||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
<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>
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("accountInfo")}</h2>
|
||||||
@@ -54,8 +54,8 @@ export default function Alphabet() {
|
|||||||
{t("chooseCharacters")}
|
{t("chooseCharacters")}
|
||||||
</h1>
|
</h1>
|
||||||
{/* 副标题说明 */}
|
{/* 副标题说明 */}
|
||||||
<p className="text-gray-600 mb-8 text-lg">
|
<p className="text-lg text-gray-600 text-center">
|
||||||
选择一种语言的字母表开始学习
|
{t("chooseAlphabetHint")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* 语言选择按钮网格 */}
|
{/* 语言选择按钮网格 */}
|
||||||
|
|||||||
240
src/app/(features)/dictionary/DictionaryClient.tsx
Normal file
240
src/app/(features)/dictionary/DictionaryClient.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import { TSharedEntry } from "@/shared/dictionary-type";
|
import { TSharedEntry } from "@/shared/dictionary-type";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
interface DictionaryEntryProps {
|
interface DictionaryEntryProps {
|
||||||
entry: TSharedEntry;
|
entry: TSharedEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DictionaryEntry({ entry }: DictionaryEntryProps) {
|
export function DictionaryEntry({ entry }: DictionaryEntryProps) {
|
||||||
|
const t = useTranslations("dictionary");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* 音标和词性 */}
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
{entry.ipa && (
|
{entry.ipa && (
|
||||||
<span className="text-gray-600 text-lg">
|
<span className="text-gray-600 text-lg">
|
||||||
@@ -21,19 +23,17 @@ export function DictionaryEntry({ entry }: DictionaryEntryProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 释义 */}
|
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||||
释义
|
{t("definition")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-800">{entry.definition}</p>
|
<p className="text-gray-800">{entry.definition}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 例句 */}
|
|
||||||
{entry.example && (
|
{entry.example && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||||
例句
|
{t("example")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
|
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
|
||||||
{entry.example}
|
{entry.example}
|
||||||
|
|||||||
80
src/app/(features)/dictionary/LanguageSelector.tsx
Normal file
80
src/app/(features)/dictionary/LanguageSelector.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { LightButton } from "@/design-system/base/button";
|
||||||
|
import { Input } from "@/design-system/base/input";
|
||||||
|
import { POPULAR_LANGUAGES } from "./constants";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
interface LanguageSelectorProps {
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LanguageSelector({ label, hint, value, onChange }: LanguageSelectorProps) {
|
||||||
|
const t = useTranslations("dictionary");
|
||||||
|
const [showCustomInput, setShowCustomInput] = useState(false);
|
||||||
|
const [customLang, setCustomLang] = useState("");
|
||||||
|
|
||||||
|
const isPresetLanguage = POPULAR_LANGUAGES.some((lang) => lang.code === value);
|
||||||
|
|
||||||
|
const handlePresetSelect = (code: string) => {
|
||||||
|
onChange(code);
|
||||||
|
setShowCustomInput(false);
|
||||||
|
setCustomLang("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomToggle = () => {
|
||||||
|
setShowCustomInput(!showCustomInput);
|
||||||
|
if (!showCustomInput && customLang.trim()) {
|
||||||
|
onChange(customLang.trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomChange = (newValue: string) => {
|
||||||
|
setCustomLang(newValue);
|
||||||
|
if (newValue.trim()) {
|
||||||
|
onChange(newValue.trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-700 text-sm mb-2">
|
||||||
|
{label} ({hint})
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{POPULAR_LANGUAGES.map((lang) => (
|
||||||
|
<LightButton
|
||||||
|
key={lang.code}
|
||||||
|
type="button"
|
||||||
|
selected={isPresetLanguage && value === lang.code}
|
||||||
|
onClick={() => handlePresetSelect(lang.code)}
|
||||||
|
className="text-sm px-3 py-1"
|
||||||
|
>
|
||||||
|
{lang.nativeName}
|
||||||
|
</LightButton>
|
||||||
|
))}
|
||||||
|
<LightButton
|
||||||
|
type="button"
|
||||||
|
selected={!isPresetLanguage && !!value}
|
||||||
|
onClick={handleCustomToggle}
|
||||||
|
className="text-sm px-3 py-1"
|
||||||
|
>
|
||||||
|
{t("other")}
|
||||||
|
</LightButton>
|
||||||
|
</div>
|
||||||
|
{(showCustomInput || (!isPresetLanguage && value)) && (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={isPresetLanguage ? customLang : value}
|
||||||
|
onChange={(e) => handleCustomChange(e.target.value)}
|
||||||
|
placeholder={t("otherLanguagePlaceholder")}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { LightButton } from "@/design-system/base/button";
|
|
||||||
import { Input } from "@/design-system/base/input";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { POPULAR_LANGUAGES } from "./constants";
|
|
||||||
|
|
||||||
interface SearchFormProps {
|
|
||||||
defaultQueryLang?: string;
|
|
||||||
defaultDefinitionLang?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SearchForm({ defaultQueryLang = "english", defaultDefinitionLang = "chinese" }: SearchFormProps) {
|
|
||||||
const t = useTranslations("dictionary");
|
|
||||||
const [queryLang, setQueryLang] = useState(defaultQueryLang);
|
|
||||||
const [definitionLang, setDefinitionLang] = useState(defaultDefinitionLang);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const formData = new FormData(e.currentTarget);
|
|
||||||
const searchQuery = formData.get("searchQuery") as string;
|
|
||||||
|
|
||||||
if (!searchQuery?.trim()) return;
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
q: searchQuery,
|
|
||||||
ql: queryLang,
|
|
||||||
dl: definitionLang,
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push(`/dictionary?${params.toString()}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* 页面标题 */}
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
|
|
||||||
{t("title")}
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-700 text-lg">
|
|
||||||
{t("description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 搜索表单 */}
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-2">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
name="searchQuery"
|
|
||||||
defaultValue=""
|
|
||||||
placeholder={t("searchPlaceholder")}
|
|
||||||
variant="search"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<LightButton
|
|
||||||
type="submit"
|
|
||||||
className="px-6 py-3 whitespace-nowrap text-center sm:min-w-30"
|
|
||||||
>
|
|
||||||
{t("search")}
|
|
||||||
</LightButton>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* 语言设置 */}
|
|
||||||
<div className="mt-4 bg-white/20 rounded-lg p-4">
|
|
||||||
<div className="mb-3">
|
|
||||||
<span className="text-gray-800 font-semibold">{t("languageSettings")}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* 查询语言 */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-gray-700 text-sm mb-2">
|
|
||||||
{t("queryLanguage")} ({t("queryLanguageHint")})
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{POPULAR_LANGUAGES.map((lang) => (
|
|
||||||
<LightButton
|
|
||||||
key={lang.code}
|
|
||||||
type="button"
|
|
||||||
selected={queryLang === lang.code}
|
|
||||||
onClick={() => setQueryLang(lang.code)}
|
|
||||||
className="text-sm px-3 py-1"
|
|
||||||
>
|
|
||||||
{lang.nativeName}
|
|
||||||
</LightButton>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 释义语言 */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-gray-700 text-sm mb-2">
|
|
||||||
{t("definitionLanguage")} ({t("definitionLanguageHint")})
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{POPULAR_LANGUAGES.map((lang) => (
|
|
||||||
<LightButton
|
|
||||||
key={lang.code}
|
|
||||||
type="button"
|
|
||||||
selected={definitionLang === lang.code}
|
|
||||||
onClick={() => setDefinitionLang(lang.code)}
|
|
||||||
className="text-sm px-3 py-1"
|
|
||||||
>
|
|
||||||
{lang.nativeName}
|
|
||||||
</LightButton>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Plus, RefreshCw } from "lucide-react";
|
|
||||||
import { CircleButton, LightButton } from "@/design-system/base/button";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { actionCreatePair } from "@/modules/folder/folder-aciton";
|
|
||||||
import { TSharedItem } from "@/shared/dictionary-type";
|
|
||||||
import { TSharedFolder } from "@/shared/folder-type";
|
|
||||||
import { actionLookUpDictionary } from "@/modules/dictionary/dictionary-action";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
type Session = {
|
|
||||||
user: {
|
|
||||||
id: string;
|
|
||||||
name?: string | null;
|
|
||||||
email?: string | null;
|
|
||||||
image?: string | null;
|
|
||||||
};
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
interface SaveButtonClientProps {
|
|
||||||
session: Session;
|
|
||||||
folders: TSharedFolder[];
|
|
||||||
searchResult: TSharedItem;
|
|
||||||
queryLang: string;
|
|
||||||
definitionLang: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SaveButtonClient({ session, folders, searchResult, queryLang, definitionLang }: SaveButtonClientProps) {
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!session) {
|
|
||||||
toast.error("Please login first");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (folders.length === 0) {
|
|
||||||
toast.error("Please create a folder first");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const folderSelect = document.getElementById("folder-select") as HTMLSelectElement;
|
|
||||||
const folderId = folderSelect?.value ? Number(folderSelect.value) : folders[0]?.id;
|
|
||||||
|
|
||||||
const definition = searchResult.entries.reduce((p, e) => {
|
|
||||||
return { ...p, definition: p.definition + ' | ' + e.definition };
|
|
||||||
}).definition;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await actionCreatePair({
|
|
||||||
text1: searchResult.standardForm,
|
|
||||||
text2: definition,
|
|
||||||
language1: queryLang,
|
|
||||||
language2: definitionLang,
|
|
||||||
ipa1: searchResult.entries[0].ipa,
|
|
||||||
folderId: folderId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const folderName = folders.find((f) => f.id === folderId)?.name || "Unknown";
|
|
||||||
toast.success(`Saved to ${folderName}`);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Save failed");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CircleButton
|
|
||||||
onClick={handleSave}
|
|
||||||
className="w-10 h-10 shrink-0"
|
|
||||||
title="Save to folder"
|
|
||||||
>
|
|
||||||
<Plus />
|
|
||||||
</CircleButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReLookupButtonClientProps {
|
|
||||||
searchQuery: string;
|
|
||||||
queryLang: string;
|
|
||||||
definitionLang: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReLookupButtonClient({ searchQuery, queryLang, definitionLang }: ReLookupButtonClientProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handleRelookup = async () => {
|
|
||||||
const getNativeName = (code: string): string => {
|
|
||||||
const popularLanguages: Record<string, string> = {
|
|
||||||
english: "English",
|
|
||||||
chinese: "中文",
|
|
||||||
japanese: "日本語",
|
|
||||||
korean: "한국어",
|
|
||||||
italian: "Italiano",
|
|
||||||
uyghur: "ئۇيغۇرچە",
|
|
||||||
};
|
|
||||||
return popularLanguages[code] || code;
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await actionLookUpDictionary({
|
|
||||||
text: searchQuery,
|
|
||||||
queryLang: getNativeName(queryLang),
|
|
||||||
definitionLang: getNativeName(definitionLang),
|
|
||||||
forceRelook: true
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.success("Re-lookup successful");
|
|
||||||
// 刷新页面以显示新结果
|
|
||||||
router.refresh();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Re-lookup failed");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LightButton
|
|
||||||
onClick={handleRelookup}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 text-sm"
|
|
||||||
leftIcon={<RefreshCw className="w-4 h-4" />}
|
|
||||||
>
|
|
||||||
Re-lookup
|
|
||||||
</LightButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import { auth } from "@/auth";
|
|
||||||
import { DictionaryEntry } from "./DictionaryEntry";
|
|
||||||
import { TSharedItem } from "@/shared/dictionary-type";
|
|
||||||
import { SaveButtonClient, ReLookupButtonClient } from "./SearchResult.client";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { actionGetFoldersByUserId } from "@/modules/folder/folder-aciton";
|
|
||||||
import { TSharedFolder } from "@/shared/folder-type";
|
|
||||||
|
|
||||||
interface SearchResultProps {
|
|
||||||
searchResult: TSharedItem | null;
|
|
||||||
searchQuery: string;
|
|
||||||
queryLang: string;
|
|
||||||
definitionLang: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function SearchResult({
|
|
||||||
searchResult,
|
|
||||||
searchQuery,
|
|
||||||
queryLang,
|
|
||||||
definitionLang
|
|
||||||
}: SearchResultProps) {
|
|
||||||
// 获取用户会话和文件夹
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
|
||||||
let folders: TSharedFolder[] = [];
|
|
||||||
|
|
||||||
if (session?.user?.id) {
|
|
||||||
const result = await actionGetFoldersByUserId(session.user.id as string);
|
|
||||||
if (result.success && result.data) {
|
|
||||||
folders = result.data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{!searchResult ? (
|
|
||||||
<div className="text-center py-12 bg-white/20 rounded-lg">
|
|
||||||
<p className="text-gray-800 text-xl">No results found</p>
|
|
||||||
<p className="text-gray-600 mt-2">Try other words</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="bg-white rounded-lg p-6 shadow-lg">
|
|
||||||
{/* 标题和保存按钮 */}
|
|
||||||
<div className="flex items-start justify-between mb-6">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h2 className="text-3xl font-bold text-gray-800 mb-2">
|
|
||||||
{searchResult.standardForm}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 ml-4">
|
|
||||||
{session && folders.length > 0 && (
|
|
||||||
<select
|
|
||||||
id="folder-select"
|
|
||||||
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[#35786f]"
|
|
||||||
>
|
|
||||||
{folders.map((folder) => (
|
|
||||||
<option key={folder.id} value={folder.id}>
|
|
||||||
{folder.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
<SaveButtonClient
|
|
||||||
session={session}
|
|
||||||
folders={folders}
|
|
||||||
searchResult={searchResult}
|
|
||||||
queryLang={queryLang}
|
|
||||||
definitionLang={definitionLang}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 条目列表 */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{searchResult.entries.map((entry, index) => (
|
|
||||||
<div key={index} className="border-t border-gray-200 pt-4">
|
|
||||||
<DictionaryEntry entry={entry} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 重新查询按钮 */}
|
|
||||||
<div className="border-t border-gray-200 pt-4 mt-4">
|
|
||||||
<ReLookupButtonClient
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
queryLang={queryLang}
|
|
||||||
definitionLang={definitionLang}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,75 +1,20 @@
|
|||||||
import { PageLayout } from "@/components/ui/PageLayout";
|
import { DictionaryClient } from "./DictionaryClient";
|
||||||
import { SearchForm } from "./SearchForm";
|
import { auth } from "@/auth";
|
||||||
import { SearchResult } from "./SearchResult";
|
import { headers } from "next/headers";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { actionGetFoldersByUserId } from "@/modules/folder/folder-action";
|
||||||
import { actionLookUpDictionary } from "@/modules/dictionary/dictionary-action";
|
import { TSharedFolder } from "@/shared/folder-type";
|
||||||
import { TSharedItem } from "@/shared/dictionary-type";
|
|
||||||
|
|
||||||
interface DictionaryPageProps {
|
export default async function DictionaryPage() {
|
||||||
searchParams: Promise<{ q?: string; ql?: string; dl?: string; }>;
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
}
|
|
||||||
|
let folders: TSharedFolder[] = [];
|
||||||
export default async function DictionaryPage({ searchParams }: DictionaryPageProps) {
|
|
||||||
const t = await getTranslations("dictionary");
|
if (session?.user?.id) {
|
||||||
|
const result = await actionGetFoldersByUserId(session.user.id as string);
|
||||||
// 从 searchParams 获取搜索参数
|
if (result.success && result.data) {
|
||||||
const { q: searchQuery, ql: queryLang = "english", dl: definitionLang = "chinese" } = await searchParams;
|
folders = result.data;
|
||||||
|
|
||||||
// 如果有搜索查询,获取搜索结果
|
|
||||||
let searchResult: TSharedItem | undefined | null = null;
|
|
||||||
if (searchQuery) {
|
|
||||||
const getNativeName = (code: string): string => {
|
|
||||||
const popularLanguages: Record<string, string> = {
|
|
||||||
english: "English",
|
|
||||||
chinese: "中文",
|
|
||||||
japanese: "日本語",
|
|
||||||
korean: "한국어",
|
|
||||||
italian: "Italiano",
|
|
||||||
uyghur: "ئۇيغۇرچە",
|
|
||||||
};
|
|
||||||
return popularLanguages[code] || code;
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await actionLookUpDictionary({
|
|
||||||
text: searchQuery,
|
|
||||||
queryLang: getNativeName(queryLang),
|
|
||||||
definitionLang: getNativeName(definitionLang),
|
|
||||||
forceRelook: false
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
searchResult = result.data;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return <DictionaryClient initialFolders={folders} />;
|
||||||
<PageLayout>
|
|
||||||
{/* 搜索区域 */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<SearchForm
|
|
||||||
defaultQueryLang={queryLang}
|
|
||||||
defaultDefinitionLang={definitionLang}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 搜索结果区域 */}
|
|
||||||
<div>
|
|
||||||
{searchQuery && (
|
|
||||||
<SearchResult
|
|
||||||
searchResult={searchResult}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
queryLang={queryLang}
|
|
||||||
definitionLang={definitionLang}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!searchQuery && (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<div className="text-6xl mb-4">📚</div>
|
|
||||||
<p className="text-gray-800 text-xl mb-2">{t("welcomeTitle")}</p>
|
|
||||||
<p className="text-gray-600">{t("welcomeHint")}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
148
src/app/(features)/dictionary/stores/dictionaryStore.ts
Normal file
148
src/app/(features)/dictionary/stores/dictionaryStore.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { devtools } from 'zustand/middleware';
|
||||||
|
import { TSharedItem } from "@/shared/dictionary-type";
|
||||||
|
import { actionLookUpDictionary } from "@/modules/dictionary/dictionary-action";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const POPULAR_LANGUAGES_MAP: Record<string, string> = {
|
||||||
|
english: "English",
|
||||||
|
chinese: "中文",
|
||||||
|
japanese: "日本語",
|
||||||
|
korean: "한국어",
|
||||||
|
italian: "Italiano",
|
||||||
|
uyghur: "ئۇيغۇرچە",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getNativeName(code: string): string {
|
||||||
|
return POPULAR_LANGUAGES_MAP[code] || code;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictionaryState {
|
||||||
|
query: string;
|
||||||
|
queryLang: string;
|
||||||
|
definitionLang: string;
|
||||||
|
searchResult: TSharedItem | null;
|
||||||
|
isSearching: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictionaryActions {
|
||||||
|
setQuery: (query: string) => void;
|
||||||
|
setQueryLang: (lang: string) => void;
|
||||||
|
setDefinitionLang: (lang: string) => void;
|
||||||
|
setSearchResult: (result: TSharedItem | null) => void;
|
||||||
|
search: () => Promise<void>;
|
||||||
|
relookup: () => Promise<void>;
|
||||||
|
syncFromUrl: (params: { q?: string; ql?: string; dl?: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DictionaryStore = DictionaryState & DictionaryActions;
|
||||||
|
|
||||||
|
const initialState: DictionaryState = {
|
||||||
|
query: "",
|
||||||
|
queryLang: "english",
|
||||||
|
definitionLang: "chinese",
|
||||||
|
searchResult: null,
|
||||||
|
isSearching: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDictionaryStore = create<DictionaryStore>()(
|
||||||
|
devtools(
|
||||||
|
(set, get) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
|
setQuery: (query) => set({ query }),
|
||||||
|
|
||||||
|
setQueryLang: (queryLang) => set({ queryLang }),
|
||||||
|
|
||||||
|
setDefinitionLang: (definitionLang) => set({ definitionLang }),
|
||||||
|
|
||||||
|
setSearchResult: (searchResult) => set({ searchResult }),
|
||||||
|
|
||||||
|
search: async () => {
|
||||||
|
const { query, queryLang, definitionLang } = get();
|
||||||
|
|
||||||
|
if (!query.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isSearching: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await actionLookUpDictionary({
|
||||||
|
text: query,
|
||||||
|
queryLang: getNativeName(queryLang),
|
||||||
|
definitionLang: getNativeName(definitionLang),
|
||||||
|
forceRelook: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
set({ searchResult: result.data });
|
||||||
|
} else {
|
||||||
|
set({ searchResult: null });
|
||||||
|
if (result.message) {
|
||||||
|
toast.error(result.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
set({ searchResult: null });
|
||||||
|
toast.error("Search failed");
|
||||||
|
} finally {
|
||||||
|
set({ isSearching: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
relookup: async () => {
|
||||||
|
const { query, queryLang, definitionLang } = get();
|
||||||
|
|
||||||
|
if (!query.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isSearching: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await actionLookUpDictionary({
|
||||||
|
text: query,
|
||||||
|
queryLang: getNativeName(queryLang),
|
||||||
|
definitionLang: getNativeName(definitionLang),
|
||||||
|
forceRelook: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
set({ searchResult: result.data });
|
||||||
|
toast.success("Re-lookup successful");
|
||||||
|
} else {
|
||||||
|
if (result.message) {
|
||||||
|
toast.error(result.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Re-lookup failed");
|
||||||
|
} finally {
|
||||||
|
set({ isSearching: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
syncFromUrl: (params) => {
|
||||||
|
const updates: Partial<DictionaryState> = {};
|
||||||
|
|
||||||
|
if (params.q !== undefined) {
|
||||||
|
updates.query = params.q;
|
||||||
|
}
|
||||||
|
if (params.ql !== undefined) {
|
||||||
|
updates.queryLang = params.ql;
|
||||||
|
}
|
||||||
|
if (params.dl !== undefined) {
|
||||||
|
updates.definitionLang = params.dl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
set(updates);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ name: 'dictionary-store' }
|
||||||
|
)
|
||||||
|
);
|
||||||
201
src/app/(features)/explore/ExploreClient.tsx
Normal file
201
src/app/(features)/explore/ExploreClient.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
src/app/(features)/explore/[id]/ExploreDetailClient.tsx
Normal file
146
src/app/(features)/explore/[id]/ExploreDetailClient.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/app/(features)/explore/[id]/page.tsx
Normal file
23
src/app/(features)/explore/[id]/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { ExploreDetailClient } from "./ExploreDetailClient";
|
||||||
|
import { 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} />;
|
||||||
|
}
|
||||||
9
src/app/(features)/explore/page.tsx
Normal file
9
src/app/(features)/explore/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { ExploreClient } from "./ExploreClient";
|
||||||
|
import { 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} />;
|
||||||
|
}
|
||||||
143
src/app/(features)/favorites/FavoritesClient.tsx
Normal file
143
src/app/(features)/favorites/FavoritesClient.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/app/(features)/favorites/page.tsx
Normal file
14
src/app/(features)/favorites/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { FolderSelector } from "./FolderSelector";
|
|||||||
import { Memorize } from "./Memorize";
|
import { Memorize } from "./Memorize";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { actionGetFoldersWithTotalPairsByUserId, actionGetPairsByFolderId } from "@/modules/folder/folder-aciton";
|
import { actionGetFoldersWithTotalPairsByUserId, actionGetPairsByFolderId } from "@/modules/folder/folder-action";
|
||||||
|
|
||||||
export default async function MemorizePage({
|
export default async function MemorizePage({
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -24,7 +24,7 @@ export default async function MemorizePage({
|
|||||||
|
|
||||||
if (!folder_id) {
|
if (!folder_id) {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
if (!session) redirect("/auth?redirect=/memorize");
|
if (!session) redirect("/login?redirect=/memorize");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FolderSelector
|
<FolderSelector
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
export function SubtitleDisplay({ subtitle }: { subtitle: string }) {
|
|
||||||
const words = subtitle.match(/\b[\w']+(?:-[\w']+)*\b/g) || [];
|
|
||||||
let i = 0;
|
|
||||||
return (
|
|
||||||
<div className="w-full subtitle overflow-auto h-16 mt-2 wrap-break-word bg-black/50 font-sans text-white text-center text-2xl">
|
|
||||||
{words.map((v) => (
|
|
||||||
<span
|
|
||||||
onClick={() => {
|
|
||||||
window.open(
|
|
||||||
`https://www.youdao.com/result?word=${v}&lang=en`,
|
|
||||||
"_blank",
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
key={i++}
|
|
||||||
className="hover:bg-gray-700 hover:underline hover:cursor-pointer"
|
|
||||||
>
|
|
||||||
{v + " "}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
import { useState, useRef, forwardRef, useEffect, useCallback } from "react";
|
|
||||||
import { SubtitleDisplay } from "./SubtitleDisplay";
|
|
||||||
import { LightButton } from "@/design-system/base/button";
|
|
||||||
import { RangeInput } from "@/components/ui/RangeInput";
|
|
||||||
import { getIndex, parseSrt, getNearistIndex } from "../subtitle";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
type VideoPanelProps = {
|
|
||||||
videoUrl: string | null;
|
|
||||||
srtUrl: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
|
|
||||||
({ videoUrl, srtUrl }, videoRef) => {
|
|
||||||
const t = useTranslations("srt_player");
|
|
||||||
videoRef = videoRef as React.RefObject<HTMLVideoElement>;
|
|
||||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
|
||||||
const [srtLength, setSrtLength] = useState<number>(0);
|
|
||||||
const [progress, setProgress] = useState<number>(-1);
|
|
||||||
const [autoPause, setAutoPause] = useState<boolean>(true);
|
|
||||||
const [spanText, setSpanText] = useState<string>("");
|
|
||||||
const [subtitle, setSubtitle] = useState<string>("");
|
|
||||||
const parsedSrtRef = useRef<
|
|
||||||
{ start: number; end: number; text: string; }[] | null
|
|
||||||
>(null);
|
|
||||||
const rafldRef = useRef<number>(0);
|
|
||||||
const ready = useRef({
|
|
||||||
vid: false,
|
|
||||||
sub: false,
|
|
||||||
all: function () {
|
|
||||||
return this.vid && this.sub;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const togglePlayPause = useCallback(() => {
|
|
||||||
if (!videoUrl) return;
|
|
||||||
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!video) return;
|
|
||||||
if (video.paused || video.currentTime === 0) {
|
|
||||||
video.play();
|
|
||||||
} else {
|
|
||||||
video.pause();
|
|
||||||
}
|
|
||||||
setIsPlaying(!video.paused);
|
|
||||||
}, [videoRef, videoUrl]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDownEvent = (e: globalThis.KeyboardEvent) => {
|
|
||||||
if (e.key === "n") {
|
|
||||||
next();
|
|
||||||
} else if (e.key === "p") {
|
|
||||||
previous();
|
|
||||||
} else if (e.key === " ") {
|
|
||||||
togglePlayPause();
|
|
||||||
} else if (e.key === "r") {
|
|
||||||
restart();
|
|
||||||
} else if (e.key === "a") {
|
|
||||||
handleAutoPauseToggle();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleKeyDownEvent);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDownEvent);
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const cb = () => {
|
|
||||||
if (ready.current.all()) {
|
|
||||||
if (!parsedSrtRef.current) {
|
|
||||||
} else if (isPlaying) {
|
|
||||||
// 这里负责显示当前时间的字幕与自动暂停
|
|
||||||
const srt = parsedSrtRef.current;
|
|
||||||
const ct = videoRef.current?.currentTime as number;
|
|
||||||
const index = getIndex(srt, ct);
|
|
||||||
if (index !== null) {
|
|
||||||
setSubtitle(srt[index].text);
|
|
||||||
if (
|
|
||||||
autoPause &&
|
|
||||||
ct >= srt[index].end - 0.05 &&
|
|
||||||
ct < srt[index].end
|
|
||||||
) {
|
|
||||||
videoRef.current!.currentTime = srt[index].start;
|
|
||||||
togglePlayPause();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setSubtitle("");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rafldRef.current = requestAnimationFrame(cb);
|
|
||||||
};
|
|
||||||
rafldRef.current = requestAnimationFrame(cb);
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(rafldRef.current);
|
|
||||||
};
|
|
||||||
}, [autoPause, isPlaying, togglePlayPause, videoRef]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (videoUrl && videoRef.current) {
|
|
||||||
videoRef.current.src = videoUrl;
|
|
||||||
videoRef.current.load();
|
|
||||||
setIsPlaying(false);
|
|
||||||
ready.current["vid"] = true;
|
|
||||||
}
|
|
||||||
}, [videoRef, videoUrl]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (srtUrl) {
|
|
||||||
fetch(srtUrl)
|
|
||||||
.then((response) => response.text())
|
|
||||||
.then((data) => {
|
|
||||||
parsedSrtRef.current = parseSrt(data);
|
|
||||||
setSrtLength(parsedSrtRef.current.length);
|
|
||||||
ready.current["sub"] = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [srtUrl]);
|
|
||||||
|
|
||||||
const timeUpdate = () => {
|
|
||||||
if (!parsedSrtRef.current || !videoRef.current) return;
|
|
||||||
const index = getIndex(
|
|
||||||
parsedSrtRef.current,
|
|
||||||
videoRef.current.currentTime,
|
|
||||||
);
|
|
||||||
if (!index) return;
|
|
||||||
setSpanText(`${index + 1}/${parsedSrtRef.current.length}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (videoRef.current && parsedSrtRef.current) {
|
|
||||||
const newProgress = parseInt(e.target.value);
|
|
||||||
videoRef.current.currentTime =
|
|
||||||
parsedSrtRef.current[newProgress]?.start || 0;
|
|
||||||
setProgress(newProgress);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAutoPauseToggle = () => {
|
|
||||||
setAutoPause(!autoPause);
|
|
||||||
};
|
|
||||||
|
|
||||||
const next = () => {
|
|
||||||
if (!parsedSrtRef.current || !videoRef.current) return;
|
|
||||||
const i = getNearistIndex(
|
|
||||||
parsedSrtRef.current,
|
|
||||||
videoRef.current.currentTime,
|
|
||||||
);
|
|
||||||
if (i != null && i + 1 < parsedSrtRef.current.length) {
|
|
||||||
videoRef.current.currentTime = parsedSrtRef.current[i + 1].start;
|
|
||||||
videoRef.current.play();
|
|
||||||
setIsPlaying(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const previous = () => {
|
|
||||||
if (!parsedSrtRef.current || !videoRef.current) return;
|
|
||||||
const i = getNearistIndex(
|
|
||||||
parsedSrtRef.current,
|
|
||||||
videoRef.current.currentTime,
|
|
||||||
);
|
|
||||||
if (i != null && i - 1 >= 0) {
|
|
||||||
videoRef.current.currentTime = parsedSrtRef.current[i - 1].start;
|
|
||||||
videoRef.current.play();
|
|
||||||
setIsPlaying(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const restart = () => {
|
|
||||||
if (!parsedSrtRef.current || !videoRef.current) return;
|
|
||||||
const i = getNearistIndex(
|
|
||||||
parsedSrtRef.current,
|
|
||||||
videoRef.current.currentTime,
|
|
||||||
);
|
|
||||||
if (i != null && i >= 0) {
|
|
||||||
videoRef.current.currentTime = parsedSrtRef.current[i].start;
|
|
||||||
videoRef.current.play();
|
|
||||||
setIsPlaying(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full flex flex-col">
|
|
||||||
<video
|
|
||||||
className="bg-gray-200"
|
|
||||||
ref={videoRef}
|
|
||||||
onTimeUpdate={timeUpdate}
|
|
||||||
></video>
|
|
||||||
<SubtitleDisplay subtitle={subtitle}></SubtitleDisplay>
|
|
||||||
<div className="buttons flex mt-2 gap-2 flex-wrap">
|
|
||||||
<LightButton onClick={togglePlayPause}>
|
|
||||||
{isPlaying ? t("pause") : t("play")}
|
|
||||||
</LightButton>
|
|
||||||
<LightButton onClick={previous}>{t("previous")}</LightButton>
|
|
||||||
<LightButton onClick={next}>{t("next")}</LightButton>
|
|
||||||
<LightButton onClick={restart}>{t("restart")}</LightButton>
|
|
||||||
<LightButton onClick={handleAutoPauseToggle}>
|
|
||||||
{t("autoPause", { enabled: autoPause ? "Yes" : "No" })}
|
|
||||||
</LightButton>
|
|
||||||
</div>
|
|
||||||
<RangeInput
|
|
||||||
className="seekbar"
|
|
||||||
min={0}
|
|
||||||
max={srtLength}
|
|
||||||
onChange={(value) => {
|
|
||||||
if (videoRef.current && parsedSrtRef.current) {
|
|
||||||
videoRef.current.currentTime = parsedSrtRef.current[value]?.start || 0;
|
|
||||||
setProgress(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
value={progress}
|
|
||||||
/>
|
|
||||||
<span>{spanText}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
VideoPanel.displayName = "VideoPanel";
|
|
||||||
|
|
||||||
export { VideoPanel };
|
|
||||||
310
src/app/(features)/srt-player/components/ControlPanel.tsx
Normal file
310
src/app/(features)/srt-player/components/ControlPanel.tsx
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Video, FileText, ChevronLeft, ChevronRight, RotateCcw, Pause, Play, Settings, Keyboard } from 'lucide-react';
|
||||||
|
import { Button, LightButton } from '@/design-system/base/button';
|
||||||
|
import { Range } from '@/design-system/base/range';
|
||||||
|
import { HStack, VStack } from '@/design-system/layout/stack';
|
||||||
|
import { useSrtPlayerStore } from '../stores/srtPlayerStore';
|
||||||
|
import { useFileUpload } from '../hooks/useFileUpload';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export function ControlPanel() {
|
||||||
|
const t = useTranslations('srt_player');
|
||||||
|
const { uploadVideo, uploadSubtitle } = useFileUpload();
|
||||||
|
|
||||||
|
const videoUrl = useSrtPlayerStore((state) => state.video.url);
|
||||||
|
const subtitleUrl = useSrtPlayerStore((state) => state.subtitle.url);
|
||||||
|
const subtitleData = useSrtPlayerStore((state) => state.subtitle.data);
|
||||||
|
const currentIndex = useSrtPlayerStore((state) => state.subtitle.currentIndex);
|
||||||
|
const isPlaying = useSrtPlayerStore((state) => state.video.isPlaying);
|
||||||
|
const playbackRate = useSrtPlayerStore((state) => state.video.playbackRate);
|
||||||
|
const autoPause = useSrtPlayerStore((state) => state.controls.autoPause);
|
||||||
|
const showSettings = useSrtPlayerStore((state) => state.controls.showSettings);
|
||||||
|
const showShortcuts = useSrtPlayerStore((state) => state.controls.showShortcuts);
|
||||||
|
const settings = useSrtPlayerStore((state) => state.subtitle.settings);
|
||||||
|
|
||||||
|
const togglePlayPause = useSrtPlayerStore((state) => state.togglePlayPause);
|
||||||
|
const nextSubtitle = useSrtPlayerStore((state) => state.nextSubtitle);
|
||||||
|
const previousSubtitle = useSrtPlayerStore((state) => state.previousSubtitle);
|
||||||
|
const restartSubtitle = useSrtPlayerStore((state) => state.restartSubtitle);
|
||||||
|
const setPlaybackRate = useSrtPlayerStore((state) => state.setPlaybackRate);
|
||||||
|
const toggleAutoPause = useSrtPlayerStore((state) => state.toggleAutoPause);
|
||||||
|
const setVideoUrl = useSrtPlayerStore((state) => state.setVideoUrl);
|
||||||
|
const setSubtitleUrl = useSrtPlayerStore((state) => state.setSubtitleUrl);
|
||||||
|
const seek = useSrtPlayerStore((state) => state.seek);
|
||||||
|
const toggleSettings = useSrtPlayerStore((state) => state.toggleSettings);
|
||||||
|
const toggleShortcuts = useSrtPlayerStore((state) => state.toggleShortcuts);
|
||||||
|
const updateSettings = useSrtPlayerStore((state) => state.updateSettings);
|
||||||
|
|
||||||
|
const canPlay = useMemo(() => !!videoUrl && !!subtitleUrl && subtitleData.length > 0, [videoUrl, subtitleUrl, subtitleData]);
|
||||||
|
const currentProgress = currentIndex ?? 0;
|
||||||
|
const totalProgress = Math.max(0, subtitleData.length - 1);
|
||||||
|
|
||||||
|
const handleVideoUpload = useCallback(() => {
|
||||||
|
uploadVideo(setVideoUrl, (error) => {
|
||||||
|
toast.error(t('videoUploadFailed') + ': ' + error.message);
|
||||||
|
});
|
||||||
|
}, [uploadVideo, setVideoUrl, t]);
|
||||||
|
|
||||||
|
const handleSubtitleUpload = useCallback(() => {
|
||||||
|
uploadSubtitle((url) => {
|
||||||
|
setSubtitleUrl(url);
|
||||||
|
}, (error) => {
|
||||||
|
toast.error(t('subtitleUploadFailed') + ': ' + error.message);
|
||||||
|
});
|
||||||
|
}, [uploadSubtitle, setSubtitleUrl, t]);
|
||||||
|
|
||||||
|
const handleSeek = useCallback((index: number) => {
|
||||||
|
if (subtitleData[index]) {
|
||||||
|
seek(subtitleData[index].start);
|
||||||
|
}
|
||||||
|
}, [subtitleData, seek]);
|
||||||
|
|
||||||
|
const handlePlaybackRateChange = useCallback(() => {
|
||||||
|
const rates = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
|
||||||
|
const currentIndexRate = rates.indexOf(playbackRate);
|
||||||
|
const nextIndexRate = (currentIndexRate + 1) % rates.length;
|
||||||
|
setPlaybackRate(rates[nextIndexRate]);
|
||||||
|
}, [playbackRate, setPlaybackRate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 bg-gray-50 border-t rounded-b-xl">
|
||||||
|
<VStack gap={3}>
|
||||||
|
<HStack gap={3}>
|
||||||
|
<div
|
||||||
|
className={`flex-1 p-2 rounded-lg border-2 transition-all ${
|
||||||
|
videoUrl ? 'border-gray-800 bg-gray-100' : 'border-gray-300 bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<HStack gap={2} justify="between">
|
||||||
|
<HStack gap={2}>
|
||||||
|
<Video className="w-5 h-5 text-gray-600" />
|
||||||
|
<VStack gap={0}>
|
||||||
|
<h3 className="font-semibold text-gray-800 text-sm">{t('videoFile')}</h3>
|
||||||
|
<p className="text-xs text-gray-600">{videoUrl ? t('uploaded') : t('notUploaded')}</p>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
<LightButton
|
||||||
|
onClick={videoUrl ? undefined : handleVideoUpload}
|
||||||
|
disabled={!!videoUrl}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{videoUrl ? t('uploaded') : t('upload')}
|
||||||
|
</LightButton>
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`flex-1 p-2 rounded-lg border-2 transition-all ${
|
||||||
|
subtitleUrl ? 'border-gray-800 bg-gray-100' : 'border-gray-300 bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<HStack gap={2} justify="between">
|
||||||
|
<HStack gap={2}>
|
||||||
|
<FileText className="w-5 h-5 text-gray-600" />
|
||||||
|
<VStack gap={0}>
|
||||||
|
<h3 className="font-semibold text-gray-800 text-sm">{t('subtitleFile')}</h3>
|
||||||
|
<p className="text-xs text-gray-600">{subtitleUrl ? t('uploaded') : t('notUploaded')}</p>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
<LightButton
|
||||||
|
onClick={subtitleUrl ? undefined : handleSubtitleUpload}
|
||||||
|
disabled={!!subtitleUrl}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{subtitleUrl ? t('uploaded') : t('upload')}
|
||||||
|
</LightButton>
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<VStack
|
||||||
|
gap={4}
|
||||||
|
className={!canPlay ? 'opacity-50 pointer-events-none' : ''}
|
||||||
|
>
|
||||||
|
<HStack gap={2} justify="center" wrap>
|
||||||
|
<Button
|
||||||
|
onClick={togglePlayPause}
|
||||||
|
disabled={!canPlay}
|
||||||
|
leftIcon={isPlaying ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
{isPlaying ? t('pause') : t('play')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={previousSubtitle}
|
||||||
|
disabled={!canPlay}
|
||||||
|
leftIcon={<ChevronLeft className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
{t('previous')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={nextSubtitle}
|
||||||
|
disabled={!canPlay}
|
||||||
|
rightIcon={<ChevronRight className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
{t('next')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={restartSubtitle}
|
||||||
|
disabled={!canPlay}
|
||||||
|
leftIcon={<RotateCcw className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
{t('restart')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handlePlaybackRateChange}
|
||||||
|
disabled={!canPlay}
|
||||||
|
>
|
||||||
|
{playbackRate}x
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={toggleAutoPause}
|
||||||
|
disabled={!canPlay}
|
||||||
|
leftIcon={<Pause className="w-4 h-4" />}
|
||||||
|
variant={autoPause ? 'primary' : 'secondary'}
|
||||||
|
>
|
||||||
|
{t('autoPause', { enabled: autoPause ? t('on') : t('off') })}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<LightButton
|
||||||
|
onClick={toggleSettings}
|
||||||
|
leftIcon={<Settings className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
{t('settings')}
|
||||||
|
</LightButton>
|
||||||
|
|
||||||
|
<LightButton
|
||||||
|
onClick={toggleShortcuts}
|
||||||
|
leftIcon={<Keyboard className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
{t('shortcuts')}
|
||||||
|
</LightButton>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<VStack gap={2}>
|
||||||
|
<Range
|
||||||
|
value={currentProgress}
|
||||||
|
min={0}
|
||||||
|
max={totalProgress}
|
||||||
|
onChange={handleSeek}
|
||||||
|
disabled={!canPlay}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HStack gap={4} justify="between" className="text-sm text-gray-600 px-2">
|
||||||
|
<span>
|
||||||
|
{currentIndex !== null ? `${currentIndex + 1}/${subtitleData.length}` : '0/0'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<HStack gap={4}>
|
||||||
|
<span className="bg-gray-200 px-2 py-1 rounded text-xs">
|
||||||
|
{playbackRate}x
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded text-xs ${
|
||||||
|
autoPause ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('autoPauseStatus', { enabled: autoPause ? t('on') : t('off') })}
|
||||||
|
</span>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{showSettings && (
|
||||||
|
<div className="p-3 bg-white rounded-lg border border-gray-200">
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-3">{t('subtitleSettings')}</h3>
|
||||||
|
<VStack gap={3}>
|
||||||
|
<HStack gap={2} className="w-full">
|
||||||
|
<span className="text-sm text-gray-600 w-20">{t('fontSize')}</span>
|
||||||
|
<Range
|
||||||
|
value={settings.fontSize}
|
||||||
|
min={12}
|
||||||
|
max={48}
|
||||||
|
onChange={(value) => updateSettings({ fontSize: value })}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-600 w-12">{settings.fontSize}px</span>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack gap={2} className="w-full">
|
||||||
|
<span className="text-sm text-gray-600 w-20">{t('textColor')}</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={settings.textColor}
|
||||||
|
onChange={(e) => updateSettings({ textColor: e.target.value })}
|
||||||
|
className="w-8 h-8 rounded cursor-pointer"
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack gap={2} className="w-full">
|
||||||
|
<span className="text-sm text-gray-600 w-20">{t('backgroundColor')}</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={settings.backgroundColor.replace(/rgba?\([^)]+\)/, '#000000')}
|
||||||
|
onChange={(e) => updateSettings({ backgroundColor: e.target.value })}
|
||||||
|
className="w-8 h-8 rounded cursor-pointer"
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack gap={2} className="w-full">
|
||||||
|
<span className="text-sm text-gray-600 w-20">{t('position')}</span>
|
||||||
|
<HStack gap={2}>
|
||||||
|
{(['top', 'center', 'bottom'] as const).map((pos) => (
|
||||||
|
<Button
|
||||||
|
key={pos}
|
||||||
|
size="sm"
|
||||||
|
variant={settings.position === pos ? 'primary' : 'secondary'}
|
||||||
|
onClick={() => updateSettings({ position: pos })}
|
||||||
|
>
|
||||||
|
{t(pos)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack gap={2} className="w-full">
|
||||||
|
<span className="text-sm text-gray-600 w-20">{t('opacity')}</span>
|
||||||
|
<Range
|
||||||
|
value={settings.opacity}
|
||||||
|
min={0.1}
|
||||||
|
max={1}
|
||||||
|
step={0.1}
|
||||||
|
onChange={(value) => updateSettings({ opacity: value })}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-600 w-12">{Math.round(settings.opacity * 100)}%</span>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showShortcuts && (
|
||||||
|
<div className="p-3 bg-white rounded-lg border border-gray-200">
|
||||||
|
<h3 className="font-semibold text-gray-800 mb-3">{t('keyboardShortcuts')}</h3>
|
||||||
|
<VStack gap={2}>
|
||||||
|
{[
|
||||||
|
{ key: 'Space', desc: t('playPause') },
|
||||||
|
{ key: 'N', desc: t('next') },
|
||||||
|
{ key: 'P', desc: t('previous') },
|
||||||
|
{ key: 'R', desc: t('restart') },
|
||||||
|
{ key: 'A', desc: t('autoPauseToggle') },
|
||||||
|
].map((shortcut) => (
|
||||||
|
<HStack key={shortcut.key} gap={2} justify="between" className="w-full">
|
||||||
|
<kbd className="px-2 py-1 bg-gray-100 rounded text-sm font-mono">{shortcut.key}</kbd>
|
||||||
|
<span className="text-sm text-gray-600">{shortcut.desc}</span>
|
||||||
|
</HStack>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useEffect, forwardRef } from 'react';
|
||||||
|
import { useSrtPlayerStore } from '../stores/srtPlayerStore';
|
||||||
|
import { setVideoRef } from '../stores/srtPlayerStore';
|
||||||
|
|
||||||
|
export const VideoPlayerPanel = forwardRef<HTMLVideoElement>((_, ref) => {
|
||||||
|
const localVideoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const videoRef = (ref as React.RefObject<HTMLVideoElement>) || localVideoRef;
|
||||||
|
|
||||||
|
const videoUrl = useSrtPlayerStore((state) => state.video.url);
|
||||||
|
const subtitleUrl = useSrtPlayerStore((state) => state.subtitle.url);
|
||||||
|
const subtitleData = useSrtPlayerStore((state) => state.subtitle.data);
|
||||||
|
const currentText = useSrtPlayerStore((state) => state.subtitle.currentText);
|
||||||
|
const settings = useSrtPlayerStore((state) => state.subtitle.settings);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVideoRef(videoRef);
|
||||||
|
}, [videoRef]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="aspect-video bg-black relative rounded-md overflow-hidden">
|
||||||
|
{(!videoUrl || !subtitleUrl || subtitleData.length === 0) && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-75 z-10">
|
||||||
|
<div className="text-center text-white">
|
||||||
|
<p className="text-lg mb-2">
|
||||||
|
{!videoUrl && !subtitleUrl
|
||||||
|
? '请上传视频和字幕文件'
|
||||||
|
: !videoUrl
|
||||||
|
? '请上传视频文件'
|
||||||
|
: !subtitleUrl
|
||||||
|
? '请上传字幕文件'
|
||||||
|
: '正在处理字幕...'}
|
||||||
|
</p>
|
||||||
|
{(!videoUrl || !subtitleUrl) && (
|
||||||
|
<p className="text-sm text-gray-300">需要同时上传视频和字幕文件才能播放</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{videoUrl && (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={videoUrl}
|
||||||
|
className="w-full h-full"
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{subtitleUrl && subtitleData.length > 0 && currentText && (
|
||||||
|
<div
|
||||||
|
className="absolute px-4 py-2 text-center w-full"
|
||||||
|
style={{
|
||||||
|
bottom: settings.position === 'top' ? 'auto' : settings.position === 'center' ? '50%' : '0',
|
||||||
|
top: settings.position === 'top' ? '0' : 'auto',
|
||||||
|
transform: settings.position === 'center' ? 'translateY(-50%)' : 'none',
|
||||||
|
backgroundColor: settings.backgroundColor,
|
||||||
|
color: settings.textColor,
|
||||||
|
fontSize: `${settings.fontSize}px`,
|
||||||
|
fontFamily: settings.fontFamily,
|
||||||
|
opacity: settings.opacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
VideoPlayerPanel.displayName = 'VideoPlayerPanel';
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useRef } from "react";
|
|
||||||
import { LightButton } from "@/design-system/base/button";
|
|
||||||
import { FileInputProps } from "../../types/controls";
|
|
||||||
|
|
||||||
interface FileInputComponentProps extends FileInputProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FileInput({ accept, onFileSelect, disabled, className, children }: FileInputComponentProps) {
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const handleClick = React.useCallback(() => {
|
|
||||||
if (!disabled && inputRef.current) {
|
|
||||||
inputRef.current.click();
|
|
||||||
}
|
|
||||||
}, [disabled]);
|
|
||||||
|
|
||||||
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
onFileSelect(file);
|
|
||||||
}
|
|
||||||
}, [onFileSelect]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="file"
|
|
||||||
accept={accept}
|
|
||||||
onChange={handleChange}
|
|
||||||
disabled={disabled}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
<LightButton
|
|
||||||
onClick={handleClick}
|
|
||||||
disabled={disabled}
|
|
||||||
size="sm"
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</LightButton>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { LightButton } from "@/design-system/base/button";
|
|
||||||
import { PlayButtonProps } from "../../types/player";
|
|
||||||
|
|
||||||
export function PlayButton({ isPlaying, onToggle, disabled, className }: PlayButtonProps) {
|
|
||||||
const t = useTranslations("srt_player");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LightButton
|
|
||||||
onClick={disabled ? undefined : onToggle}
|
|
||||||
disabled={disabled}
|
|
||||||
className={`px-4 py-2 ${className || ''}`}
|
|
||||||
>
|
|
||||||
{isPlaying ? t("pause") : t("play")}
|
|
||||||
</LightButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { SeekBarProps } from "../../types/player";
|
|
||||||
import { RangeInput } from "@/components/ui/RangeInput";
|
|
||||||
|
|
||||||
export function SeekBar({ value, max, onChange, disabled, className }: SeekBarProps) {
|
|
||||||
return (
|
|
||||||
<RangeInput
|
|
||||||
value={value}
|
|
||||||
max={max}
|
|
||||||
onChange={onChange}
|
|
||||||
disabled={disabled}
|
|
||||||
className={className}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { LightButton } from "@/design-system/base/button";
|
|
||||||
import { SpeedControlProps } from "../../types/player";
|
|
||||||
import { getPlaybackRateOptions, getPlaybackRateLabel } from "../../utils/timeUtils";
|
|
||||||
|
|
||||||
export function SpeedControl({ playbackRate, onPlaybackRateChange, disabled, className }: SpeedControlProps) {
|
|
||||||
const speedOptions = getPlaybackRateOptions();
|
|
||||||
|
|
||||||
const handleSpeedChange = React.useCallback(() => {
|
|
||||||
const currentIndex = speedOptions.indexOf(playbackRate);
|
|
||||||
const nextIndex = (currentIndex + 1) % speedOptions.length;
|
|
||||||
onPlaybackRateChange(speedOptions[nextIndex]);
|
|
||||||
}, [playbackRate, onPlaybackRateChange, speedOptions]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LightButton
|
|
||||||
onClick={disabled ? undefined : handleSpeedChange}
|
|
||||||
className={`${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className || ''}`}
|
|
||||||
>
|
|
||||||
{getPlaybackRateLabel(playbackRate)}
|
|
||||||
</LightButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { SubtitleTextProps } from "../../types/subtitle";
|
|
||||||
|
|
||||||
export function SubtitleText({ text, onWordClick, style, className }: SubtitleTextProps) {
|
|
||||||
const handleWordClick = React.useCallback((word: string) => {
|
|
||||||
onWordClick?.(word);
|
|
||||||
}, [onWordClick]);
|
|
||||||
|
|
||||||
// 将文本分割成单词,保持标点符号
|
|
||||||
const renderTextWithClickableWords = () => {
|
|
||||||
if (!text) return null;
|
|
||||||
|
|
||||||
// 匹配单词和标点符号
|
|
||||||
const parts = text.match(/[\w']+|[^\w\s]+|\s+/g) || [];
|
|
||||||
|
|
||||||
return parts.map((part, index) => {
|
|
||||||
// 如果是单词(字母和撇号组成)
|
|
||||||
if (/^[\w']+$/.test(part)) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={index}
|
|
||||||
onClick={() => handleWordClick(part)}
|
|
||||||
className="hover:bg-gray-700 hover:underline hover:cursor-pointer rounded px-1 transition-colors"
|
|
||||||
>
|
|
||||||
{part}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// 如果是空格或其他字符,直接渲染
|
|
||||||
return <span key={index}>{part}</span>;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`overflow-auto h-16 mt-2 wrap-break-words font-sans text-white text-center text-2xl ${className || ''}`}
|
|
||||||
style={style}
|
|
||||||
>
|
|
||||||
{renderTextWithClickableWords()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { forwardRef } from "react";
|
|
||||||
import { VideoElementProps } from "../../types/player";
|
|
||||||
|
|
||||||
const VideoElement = forwardRef<HTMLVideoElement, VideoElementProps>(
|
|
||||||
({ src, onTimeUpdate, onLoadedMetadata, onPlay, onPause, onEnded, className }, ref) => {
|
|
||||||
const handleTimeUpdate = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
|
||||||
const video = event.currentTarget;
|
|
||||||
onTimeUpdate?.(video.currentTime);
|
|
||||||
}, [onTimeUpdate]);
|
|
||||||
|
|
||||||
const handleLoadedMetadata = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
|
||||||
const video = event.currentTarget;
|
|
||||||
onLoadedMetadata?.(video.duration);
|
|
||||||
}, [onLoadedMetadata]);
|
|
||||||
|
|
||||||
const handlePlay = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
|
||||||
onPlay?.();
|
|
||||||
}, [onPlay]);
|
|
||||||
|
|
||||||
const handlePause = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
|
||||||
onPause?.();
|
|
||||||
}, [onPause]);
|
|
||||||
|
|
||||||
const handleEnded = React.useCallback((event: React.SyntheticEvent<HTMLVideoElement>) => {
|
|
||||||
onEnded?.();
|
|
||||||
}, [onEnded]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<video
|
|
||||||
ref={ref}
|
|
||||||
src={src}
|
|
||||||
onTimeUpdate={handleTimeUpdate}
|
|
||||||
onLoadedMetadata={handleLoadedMetadata}
|
|
||||||
onPlay={handlePlay}
|
|
||||||
onPause={handlePause}
|
|
||||||
onEnded={handleEnded}
|
|
||||||
className={`bg-gray-200 w-full ${className || ""}`}
|
|
||||||
playsInline
|
|
||||||
controls={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
VideoElement.displayName = "VideoElement";
|
|
||||||
|
|
||||||
export { VideoElement };
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { ChevronLeft, ChevronRight, RotateCcw, Pause } from "lucide-react";
|
|
||||||
import { LightButton } from "@/design-system/base/button";
|
|
||||||
import { ControlBarProps } from "../../types/controls";
|
|
||||||
import { PlayButton } from "../atoms/PlayButton";
|
|
||||||
import { SpeedControl } from "../atoms/SpeedControl";
|
|
||||||
|
|
||||||
export function ControlBar({
|
|
||||||
isPlaying,
|
|
||||||
onPlayPause,
|
|
||||||
onPrevious,
|
|
||||||
onNext,
|
|
||||||
onRestart,
|
|
||||||
playbackRate,
|
|
||||||
onPlaybackRateChange,
|
|
||||||
autoPause,
|
|
||||||
onAutoPauseToggle,
|
|
||||||
disabled,
|
|
||||||
className
|
|
||||||
}: ControlBarProps) {
|
|
||||||
const t = useTranslations("srt_player");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`flex flex-wrap gap-2 justify-center ${className || ''}`}>
|
|
||||||
<PlayButton
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
onToggle={onPlayPause}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<LightButton
|
|
||||||
onClick={disabled ? undefined : onPrevious}
|
|
||||||
disabled={disabled}
|
|
||||||
className="flex items-center px-3 py-2"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
|
||||||
{t("previous")}
|
|
||||||
</LightButton>
|
|
||||||
|
|
||||||
<LightButton
|
|
||||||
onClick={disabled ? undefined : onNext}
|
|
||||||
disabled={disabled}
|
|
||||||
className="flex items-center px-3 py-2"
|
|
||||||
>
|
|
||||||
{t("next")}
|
|
||||||
<ChevronRight className="w-4 h-4 ml-2" />
|
|
||||||
</LightButton>
|
|
||||||
|
|
||||||
<LightButton
|
|
||||||
onClick={disabled ? undefined : onRestart}
|
|
||||||
disabled={disabled}
|
|
||||||
className="flex items-center px-3 py-2"
|
|
||||||
>
|
|
||||||
<RotateCcw className="w-4 h-4 mr-2" />
|
|
||||||
{t("restart")}
|
|
||||||
</LightButton>
|
|
||||||
|
|
||||||
<SpeedControl
|
|
||||||
playbackRate={playbackRate}
|
|
||||||
onPlaybackRateChange={onPlaybackRateChange}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<LightButton
|
|
||||||
onClick={disabled ? undefined : onAutoPauseToggle}
|
|
||||||
disabled={disabled}
|
|
||||||
className="flex items-center px-3 py-2"
|
|
||||||
>
|
|
||||||
<Pause className="w-4 h-4 mr-2" />
|
|
||||||
{t("autoPause", { enabled: autoPause ? t("on") : t("off") })}
|
|
||||||
</LightButton>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { SubtitleDisplayProps } from "../../types/subtitle";
|
|
||||||
import { SubtitleText } from "../atoms/SubtitleText";
|
|
||||||
|
|
||||||
export function SubtitleArea({ subtitle, onWordClick, settings, className }: SubtitleDisplayProps) {
|
|
||||||
const handleWordClick = React.useCallback((word: string) => {
|
|
||||||
// 打开有道词典页面查询单词
|
|
||||||
window.open(
|
|
||||||
`https://www.youdao.com/result?word=${encodeURIComponent(word)}&lang=en`,
|
|
||||||
"_blank"
|
|
||||||
);
|
|
||||||
onWordClick?.(word);
|
|
||||||
}, [onWordClick]);
|
|
||||||
|
|
||||||
const subtitleStyle = React.useMemo(() => {
|
|
||||||
if (!settings) return { backgroundColor: 'rgba(0, 0, 0, 0.5)' };
|
|
||||||
|
|
||||||
return {
|
|
||||||
backgroundColor: settings.backgroundColor,
|
|
||||||
color: settings.textColor,
|
|
||||||
fontSize: `${settings.fontSize}px`,
|
|
||||||
fontFamily: settings.fontFamily,
|
|
||||||
opacity: settings.opacity,
|
|
||||||
};
|
|
||||||
}, [settings]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SubtitleText
|
|
||||||
text={subtitle}
|
|
||||||
onWordClick={handleWordClick}
|
|
||||||
style={subtitleStyle}
|
|
||||||
className={className}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Video, FileText } from "lucide-react";
|
|
||||||
import { LightButton } from "@/design-system/base/button";
|
|
||||||
import { FileUploadProps } from "../../types/controls";
|
|
||||||
import { useFileUpload } from "../../hooks/useFileUpload";
|
|
||||||
|
|
||||||
export function UploadZone({ onVideoUpload, onSubtitleUpload, className }: FileUploadProps) {
|
|
||||||
const t = useTranslations("srt_player");
|
|
||||||
const { uploadVideo, uploadSubtitle } = useFileUpload();
|
|
||||||
|
|
||||||
const handleVideoUpload = React.useCallback(() => {
|
|
||||||
uploadVideo(onVideoUpload, (error) => {
|
|
||||||
toast.error(t("videoUploadFailed") + ": " + error.message);
|
|
||||||
});
|
|
||||||
}, [uploadVideo, onVideoUpload, t]);
|
|
||||||
|
|
||||||
const handleSubtitleUpload = React.useCallback(() => {
|
|
||||||
uploadSubtitle(onSubtitleUpload, (error) => {
|
|
||||||
toast.error(t("subtitleUploadFailed") + ": " + error.message);
|
|
||||||
});
|
|
||||||
}, [uploadSubtitle, onSubtitleUpload, t]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`flex gap-3 ${className || ''}`}>
|
|
||||||
<LightButton
|
|
||||||
onClick={handleVideoUpload}
|
|
||||||
className="flex-1 py-2 px-3 text-sm"
|
|
||||||
>
|
|
||||||
<Video className="w-4 h-4 mr-2" />
|
|
||||||
{t("uploadVideo")}
|
|
||||||
</LightButton>
|
|
||||||
|
|
||||||
<LightButton
|
|
||||||
onClick={handleSubtitleUpload}
|
|
||||||
className="flex-1 py-2 px-3 text-sm"
|
|
||||||
>
|
|
||||||
<FileText className="w-4 h-4 mr-2" />
|
|
||||||
{t("uploadSubtitle")}
|
|
||||||
</LightButton>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { forwardRef } from "react";
|
|
||||||
import { VideoElementProps } from "../../types/player";
|
|
||||||
import { VideoElement } from "../atoms/VideoElement";
|
|
||||||
|
|
||||||
interface VideoPlayerComponentProps extends VideoElementProps {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerComponentProps>(
|
|
||||||
({
|
|
||||||
src,
|
|
||||||
onTimeUpdate,
|
|
||||||
onLoadedMetadata,
|
|
||||||
onPlay,
|
|
||||||
onPause,
|
|
||||||
onEnded,
|
|
||||||
className,
|
|
||||||
children
|
|
||||||
}, ref) => {
|
|
||||||
return (
|
|
||||||
<div className={`w-full flex flex-col ${className || ''}`}>
|
|
||||||
<VideoElement
|
|
||||||
ref={ref}
|
|
||||||
src={src}
|
|
||||||
onTimeUpdate={onTimeUpdate}
|
|
||||||
onLoadedMetadata={onLoadedMetadata}
|
|
||||||
onPlay={onPlay}
|
|
||||||
onPause={onPause}
|
|
||||||
onEnded={onEnded}
|
|
||||||
/>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
VideoPlayer.displayName = "VideoPlayer";
|
|
||||||
|
|
||||||
export { VideoPlayer };
|
|
||||||
@@ -9,10 +9,9 @@ export function useFileUpload() {
|
|||||||
onError?: (error: Error) => void
|
onError?: (error: Error) => void
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
// 验证文件大小(限制为100MB)
|
const maxSize = 1000 * 1024 * 1024;
|
||||||
const maxSize = 100 * 1024 * 1024; // 100MB
|
|
||||||
if (file.size > maxSize) {
|
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);
|
const url = URL.createObjectURL(file);
|
||||||
@@ -34,7 +33,6 @@ export function useFileUpload() {
|
|||||||
input.onchange = (e) => {
|
input.onchange = (e) => {
|
||||||
const file = (e.target as HTMLInputElement).files?.[0];
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
// 验证文件类型
|
|
||||||
if (!file.type.startsWith('video/')) {
|
if (!file.type.startsWith('video/')) {
|
||||||
onError?.(new Error('请选择有效的视频文件'));
|
onError?.(new Error('请选择有效的视频文件'));
|
||||||
return;
|
return;
|
||||||
@@ -61,7 +59,6 @@ export function useFileUpload() {
|
|||||||
input.onchange = (e) => {
|
input.onchange = (e) => {
|
||||||
const file = (e.target as HTMLInputElement).files?.[0];
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
// 验证文件扩展名
|
|
||||||
if (!file.name.toLowerCase().endsWith('.srt')) {
|
if (!file.name.toLowerCase().endsWith('.srt')) {
|
||||||
onError?.(new Error('请选择.srt格式的字幕文件'));
|
onError?.(new Error('请选择.srt格式的字幕文件'));
|
||||||
return;
|
return;
|
||||||
@@ -80,6 +77,5 @@ export function useFileUpload() {
|
|||||||
return {
|
return {
|
||||||
uploadVideo,
|
uploadVideo,
|
||||||
uploadSubtitle,
|
uploadSubtitle,
|
||||||
uploadFile,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,68 +1,82 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { KeyboardShortcut } from "../types/controls";
|
import { useSrtPlayerStore } from "../stores/srtPlayerStore";
|
||||||
|
|
||||||
export function useKeyboardShortcuts(
|
export function useSrtPlayerShortcuts(enabled: boolean = true) {
|
||||||
shortcuts: KeyboardShortcut[],
|
const togglePlayPause = useSrtPlayerStore((state) => state.togglePlayPause);
|
||||||
enabled: boolean = true
|
const nextSubtitle = useSrtPlayerStore((state) => state.nextSubtitle);
|
||||||
) {
|
const previousSubtitle = useSrtPlayerStore((state) => state.previousSubtitle);
|
||||||
const handleKeyDown = useCallback((event: globalThis.KeyboardEvent) => {
|
const restartSubtitle = useSrtPlayerStore((state) => state.restartSubtitle);
|
||||||
if (!enabled) return;
|
const toggleAutoPause = useSrtPlayerStore((state) => state.toggleAutoPause);
|
||||||
|
|
||||||
// 防止在输入框中触发快捷键
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shortcut = shortcuts.find(s => s.key === event.key);
|
|
||||||
if (shortcut) {
|
|
||||||
event.preventDefault();
|
|
||||||
shortcut.action();
|
|
||||||
}
|
|
||||||
}, [shortcuts, enabled]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||||
|
if (!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);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [handleKeyDown]);
|
}, [enabled, togglePlayPause, nextSubtitle, previousSubtitle, restartSubtitle, toggleAutoPause]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSrtPlayerShortcuts(
|
export function useKeyboardShortcuts(
|
||||||
playPause: () => void,
|
shortcuts: Array<{ key: string; action: () => void }>,
|
||||||
next: () => void,
|
isEnabled: boolean = true
|
||||||
previous: () => void,
|
) {
|
||||||
restart: () => void,
|
useEffect(() => {
|
||||||
toggleAutoPause: () => void
|
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||||
): KeyboardShortcut[] {
|
if (!isEnabled) return;
|
||||||
return [
|
|
||||||
{
|
const target = event.target as HTMLElement;
|
||||||
key: ' ',
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||||
description: '播放/暂停',
|
return;
|
||||||
action: playPause,
|
}
|
||||||
},
|
|
||||||
{
|
const shortcut = shortcuts.find(s => s.key === event.key);
|
||||||
key: 'n',
|
if (shortcut) {
|
||||||
description: '下一句',
|
event.preventDefault();
|
||||||
action: next,
|
shortcut.action();
|
||||||
},
|
}
|
||||||
{
|
};
|
||||||
key: 'p',
|
|
||||||
description: '上一句',
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
action: previous,
|
return () => {
|
||||||
},
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
{
|
};
|
||||||
key: 'r',
|
}, [shortcuts, isEnabled]);
|
||||||
description: '句首',
|
}
|
||||||
action: restart,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'a',
|
|
||||||
description: '切换自动暂停',
|
|
||||||
action: toggleAutoPause,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,306 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useReducer, useCallback, useRef, useEffect } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { VideoState, VideoControls } from "../types/player";
|
|
||||||
import { SubtitleState, SubtitleEntry } from "../types/subtitle";
|
|
||||||
import { ControlState, ControlActions } from "../types/controls";
|
|
||||||
|
|
||||||
export interface SrtPlayerState {
|
|
||||||
video: VideoState;
|
|
||||||
subtitle: SubtitleState;
|
|
||||||
controls: ControlState;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SrtPlayerActions extends VideoControls, ControlActions {
|
|
||||||
setVideoUrl: (url: string | null) => void;
|
|
||||||
setSubtitleUrl: (url: string | null) => void;
|
|
||||||
nextSubtitle: () => void;
|
|
||||||
previousSubtitle: () => void;
|
|
||||||
restartSubtitle: () => void;
|
|
||||||
setSubtitleSettings: (settings: Partial<SubtitleState['settings']>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: SrtPlayerState = {
|
|
||||||
video: {
|
|
||||||
url: null,
|
|
||||||
isPlaying: false,
|
|
||||||
currentTime: 0,
|
|
||||||
duration: 0,
|
|
||||||
playbackRate: 1.0,
|
|
||||||
volume: 1.0,
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
url: null,
|
|
||||||
data: [],
|
|
||||||
currentText: "",
|
|
||||||
currentIndex: null,
|
|
||||||
settings: {
|
|
||||||
fontSize: 24,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
||||||
textColor: "#ffffff",
|
|
||||||
position: "bottom",
|
|
||||||
fontFamily: "sans-serif",
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
controls: {
|
|
||||||
autoPause: true,
|
|
||||||
showShortcuts: false,
|
|
||||||
showSettings: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
type SrtPlayerAction =
|
|
||||||
| { type: "SET_VIDEO_URL"; payload: string | null }
|
|
||||||
| { type: "SET_PLAYING"; payload: boolean }
|
|
||||||
| { type: "SET_CURRENT_TIME"; payload: number }
|
|
||||||
| { type: "SET_DURATION"; payload: number }
|
|
||||||
| { type: "SET_PLAYBACK_RATE"; payload: number }
|
|
||||||
| { type: "SET_VOLUME"; payload: number }
|
|
||||||
| { type: "SET_SUBTITLE_URL"; payload: string | null }
|
|
||||||
| { type: "SET_SUBTITLE_DATA"; payload: SubtitleEntry[] }
|
|
||||||
| { type: "SET_CURRENT_SUBTITLE"; payload: { text: string; index: number | null } }
|
|
||||||
| { type: "SET_SUBTITLE_SETTINGS"; payload: Partial<SubtitleState['settings']> }
|
|
||||||
| { type: "TOGGLE_AUTO_PAUSE" }
|
|
||||||
| { type: "TOGGLE_SHORTCUTS" }
|
|
||||||
| { type: "TOGGLE_SETTINGS" };
|
|
||||||
|
|
||||||
function srtPlayerReducer(state: SrtPlayerState, action: SrtPlayerAction): SrtPlayerState {
|
|
||||||
switch (action.type) {
|
|
||||||
case "SET_VIDEO_URL":
|
|
||||||
return { ...state, video: { ...state.video, url: action.payload } };
|
|
||||||
case "SET_PLAYING":
|
|
||||||
return { ...state, video: { ...state.video, isPlaying: action.payload } };
|
|
||||||
case "SET_CURRENT_TIME":
|
|
||||||
return { ...state, video: { ...state.video, currentTime: action.payload } };
|
|
||||||
case "SET_DURATION":
|
|
||||||
return { ...state, video: { ...state.video, duration: action.payload } };
|
|
||||||
case "SET_PLAYBACK_RATE":
|
|
||||||
return { ...state, video: { ...state.video, playbackRate: action.payload } };
|
|
||||||
case "SET_VOLUME":
|
|
||||||
return { ...state, video: { ...state.video, volume: action.payload } };
|
|
||||||
case "SET_SUBTITLE_URL":
|
|
||||||
return { ...state, subtitle: { ...state.subtitle, url: action.payload } };
|
|
||||||
case "SET_SUBTITLE_DATA":
|
|
||||||
return { ...state, subtitle: { ...state.subtitle, data: action.payload } };
|
|
||||||
case "SET_CURRENT_SUBTITLE":
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
subtitle: {
|
|
||||||
...state.subtitle,
|
|
||||||
currentText: action.payload.text,
|
|
||||||
currentIndex: action.payload.index,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
case "SET_SUBTITLE_SETTINGS":
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
subtitle: {
|
|
||||||
...state.subtitle,
|
|
||||||
settings: { ...state.subtitle.settings, ...action.payload },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
case "TOGGLE_AUTO_PAUSE":
|
|
||||||
return { ...state, controls: { ...state.controls, autoPause: !state.controls.autoPause } };
|
|
||||||
case "TOGGLE_SHORTCUTS":
|
|
||||||
return { ...state, controls: { ...state.controls, showShortcuts: !state.controls.showShortcuts } };
|
|
||||||
case "TOGGLE_SETTINGS":
|
|
||||||
return { ...state, controls: { ...state.controls, showSettings: !state.controls.showSettings } };
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSrtPlayer() {
|
|
||||||
const [state, dispatch] = useReducer(srtPlayerReducer, initialState);
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
|
||||||
|
|
||||||
// Video controls
|
|
||||||
const play = useCallback(() => {
|
|
||||||
// 检查是否同时有视频和字幕
|
|
||||||
if (!state.video.url || !state.subtitle.url || state.subtitle.data.length === 0) {
|
|
||||||
toast.error("请先上传视频和字幕文件");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (videoRef.current) {
|
|
||||||
videoRef.current.play().catch(error => {
|
|
||||||
toast.error("视频播放失败: " + error.message);
|
|
||||||
});
|
|
||||||
dispatch({ type: "SET_PLAYING", payload: true });
|
|
||||||
}
|
|
||||||
}, [state.video.url, state.subtitle.url, state.subtitle.data.length, dispatch]);
|
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
|
||||||
if (videoRef.current) {
|
|
||||||
videoRef.current.pause();
|
|
||||||
dispatch({ type: "SET_PLAYING", payload: false });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const togglePlayPause = useCallback(() => {
|
|
||||||
if (state.video.isPlaying) {
|
|
||||||
pause();
|
|
||||||
} else {
|
|
||||||
play();
|
|
||||||
}
|
|
||||||
}, [state.video.isPlaying, play, pause]);
|
|
||||||
|
|
||||||
const seek = useCallback((time: number) => {
|
|
||||||
if (videoRef.current) {
|
|
||||||
videoRef.current.currentTime = time;
|
|
||||||
dispatch({ type: "SET_CURRENT_TIME", payload: time });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setPlaybackRate = useCallback((rate: number) => {
|
|
||||||
if (videoRef.current) {
|
|
||||||
videoRef.current.playbackRate = rate;
|
|
||||||
dispatch({ type: "SET_PLAYBACK_RATE", payload: rate });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setVolume = useCallback((volume: number) => {
|
|
||||||
if (videoRef.current) {
|
|
||||||
videoRef.current.volume = volume;
|
|
||||||
dispatch({ type: "SET_VOLUME", payload: volume });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const restart = useCallback(() => {
|
|
||||||
if (videoRef.current && state.subtitle.currentIndex !== null) {
|
|
||||||
const currentSubtitle = state.subtitle.data[state.subtitle.currentIndex];
|
|
||||||
if (currentSubtitle) {
|
|
||||||
seek(currentSubtitle.start);
|
|
||||||
play();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
|
|
||||||
|
|
||||||
// URL setters
|
|
||||||
const setVideoUrl = useCallback((url: string | null) => {
|
|
||||||
dispatch({ type: "SET_VIDEO_URL", payload: url });
|
|
||||||
if (url && videoRef.current) {
|
|
||||||
videoRef.current.src = url;
|
|
||||||
videoRef.current.load();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setSubtitleUrl = useCallback((url: string | null) => {
|
|
||||||
dispatch({ type: "SET_SUBTITLE_URL", payload: url });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Subtitle controls
|
|
||||||
const nextSubtitle = useCallback(() => {
|
|
||||||
if (state.subtitle.currentIndex !== null &&
|
|
||||||
state.subtitle.currentIndex + 1 < state.subtitle.data.length) {
|
|
||||||
const nextIndex = state.subtitle.currentIndex + 1;
|
|
||||||
const nextSubtitle = state.subtitle.data[nextIndex];
|
|
||||||
seek(nextSubtitle.start);
|
|
||||||
play();
|
|
||||||
}
|
|
||||||
}, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
|
|
||||||
|
|
||||||
const previousSubtitle = useCallback(() => {
|
|
||||||
if (state.subtitle.currentIndex !== null && state.subtitle.currentIndex > 0) {
|
|
||||||
const prevIndex = state.subtitle.currentIndex - 1;
|
|
||||||
const prevSubtitle = state.subtitle.data[prevIndex];
|
|
||||||
seek(prevSubtitle.start);
|
|
||||||
play();
|
|
||||||
}
|
|
||||||
}, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
|
|
||||||
|
|
||||||
const restartSubtitle = useCallback(() => {
|
|
||||||
if (state.subtitle.currentIndex !== null) {
|
|
||||||
const currentSubtitle = state.subtitle.data[state.subtitle.currentIndex];
|
|
||||||
seek(currentSubtitle.start);
|
|
||||||
play();
|
|
||||||
}
|
|
||||||
}, [state.subtitle.currentIndex, state.subtitle.data, seek, play]);
|
|
||||||
|
|
||||||
const setSubtitleSettings = useCallback((settings: Partial<SubtitleState['settings']>) => {
|
|
||||||
dispatch({ type: "SET_SUBTITLE_SETTINGS", payload: settings });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Control actions
|
|
||||||
const toggleAutoPause = useCallback(() => {
|
|
||||||
dispatch({ type: "TOGGLE_AUTO_PAUSE" });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleShortcuts = useCallback(() => {
|
|
||||||
dispatch({ type: "TOGGLE_SHORTCUTS" });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleSettings = useCallback(() => {
|
|
||||||
dispatch({ type: "TOGGLE_SETTINGS" });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Video event handlers
|
|
||||||
const handleTimeUpdate = useCallback(() => {
|
|
||||||
if (videoRef.current) {
|
|
||||||
dispatch({ type: "SET_CURRENT_TIME", payload: videoRef.current.currentTime });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleLoadedMetadata = useCallback(() => {
|
|
||||||
if (videoRef.current) {
|
|
||||||
dispatch({ type: "SET_DURATION", payload: videoRef.current.duration });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePlay = useCallback(() => {
|
|
||||||
dispatch({ type: "SET_PLAYING", payload: true });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePause = useCallback(() => {
|
|
||||||
dispatch({ type: "SET_PLAYING", payload: false });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Set subtitle data
|
|
||||||
const setSubtitleData = useCallback((data: SubtitleEntry[]) => {
|
|
||||||
dispatch({ type: "SET_SUBTITLE_DATA", payload: data });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Set current subtitle
|
|
||||||
const setCurrentSubtitle = useCallback((text: string, index: number | null) => {
|
|
||||||
dispatch({ type: "SET_CURRENT_SUBTITLE", payload: { text, index } });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const actions: SrtPlayerActions = {
|
|
||||||
play,
|
|
||||||
pause,
|
|
||||||
togglePlayPause,
|
|
||||||
seek,
|
|
||||||
setPlaybackRate,
|
|
||||||
setVolume,
|
|
||||||
restart,
|
|
||||||
setVideoUrl,
|
|
||||||
setSubtitleUrl,
|
|
||||||
nextSubtitle,
|
|
||||||
previousSubtitle,
|
|
||||||
restartSubtitle,
|
|
||||||
setSubtitleSettings,
|
|
||||||
toggleAutoPause,
|
|
||||||
toggleShortcuts,
|
|
||||||
toggleSettings,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
state,
|
|
||||||
actions,
|
|
||||||
videoRef,
|
|
||||||
videoEventHandlers: {
|
|
||||||
onTimeUpdate: handleTimeUpdate,
|
|
||||||
onLoadedMetadata: handleLoadedMetadata,
|
|
||||||
onPlay: handlePlay,
|
|
||||||
onPause: handlePause,
|
|
||||||
},
|
|
||||||
subtitleActions: {
|
|
||||||
setSubtitleData,
|
|
||||||
setCurrentSubtitle,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UseSrtPlayerReturn = ReturnType<typeof useSrtPlayer>;
|
|
||||||
@@ -1,110 +1,101 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
import { SubtitleEntry } from "../types/subtitle";
|
import { useSrtPlayerStore } from "../stores/srtPlayerStore";
|
||||||
|
|
||||||
export function useSubtitleSync(
|
export function useSubtitleSync() {
|
||||||
subtitles: SubtitleEntry[],
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
currentTime: number,
|
const lastIndexRef = useRef<number | null>(null);
|
||||||
isPlaying: boolean,
|
|
||||||
autoPause: boolean,
|
|
||||||
onSubtitleChange: (subtitle: SubtitleEntry | null) => void,
|
|
||||||
onAutoPauseTrigger?: (subtitle: SubtitleEntry) => void
|
|
||||||
) {
|
|
||||||
const lastSubtitleRef = useRef<SubtitleEntry | null>(null);
|
|
||||||
const rafIdRef = useRef<number>(0);
|
|
||||||
|
|
||||||
// 获取当前时间对应的字幕
|
const subtitleData = useSrtPlayerStore((state) => state.subtitle.data);
|
||||||
const getCurrentSubtitle = useCallback((time: number): SubtitleEntry | null => {
|
const isPlaying = useSrtPlayerStore((state) => state.video.isPlaying);
|
||||||
return subtitles.find(subtitle => time >= subtitle.start && time <= subtitle.end) || null;
|
const autoPause = useSrtPlayerStore((state) => state.controls.autoPause);
|
||||||
}, [subtitles]);
|
const playbackRate = useSrtPlayerStore((state) => state.video.playbackRate);
|
||||||
|
const currentTime = useSrtPlayerStore((state) => state.video.currentTime);
|
||||||
|
|
||||||
// 获取最近的字幕索引
|
const setCurrentSubtitle = useSrtPlayerStore((state) => state.setCurrentSubtitle);
|
||||||
const getNearestIndex = useCallback((time: number): number | null => {
|
const pause = useSrtPlayerStore((state) => state.pause);
|
||||||
if (subtitles.length === 0) return null;
|
|
||||||
|
const scheduleAutoPause = useCallback(() => {
|
||||||
// 如果时间早于第一个字幕开始时间
|
if (!autoPause || !isPlaying) {
|
||||||
if (time < subtitles[0].start) return null;
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
// 如果时间晚于最后一个字幕结束时间
|
timeoutRef.current = null;
|
||||||
if (time > subtitles[subtitles.length - 1].end) return subtitles.length - 1;
|
}
|
||||||
|
return;
|
||||||
// 二分查找找到当前时间对应的字幕
|
}
|
||||||
let left = 0;
|
|
||||||
let right = subtitles.length - 1;
|
const currentTimeNow = useSrtPlayerStore.getState().video.currentTime;
|
||||||
|
const currentIndexNow = useSrtPlayerStore.getState().subtitle.currentIndex;
|
||||||
while (left <= right) {
|
|
||||||
const mid = Math.floor((left + right) / 2);
|
if (currentIndexNow === null || !subtitleData[currentIndexNow]) {
|
||||||
const subtitle = subtitles[mid];
|
return;
|
||||||
|
}
|
||||||
if (time >= subtitle.start && time <= subtitle.end) {
|
|
||||||
return mid;
|
const subtitle = subtitleData[currentIndexNow];
|
||||||
} else if (time < subtitle.start) {
|
const timeUntilEnd = subtitle.end - currentTimeNow;
|
||||||
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 {
|
} 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(() => {
|
useEffect(() => {
|
||||||
const syncSubtitles = () => {
|
scheduleAutoPause();
|
||||||
const currentSubtitle = getCurrentSubtitle(currentTime);
|
}, [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) {
|
useEffect(() => {
|
||||||
rafIdRef.current = requestAnimationFrame(syncSubtitles);
|
if (isPlaying && autoPause) {
|
||||||
|
scheduleAutoPause();
|
||||||
}
|
}
|
||||||
|
}, [playbackRate, currentTime]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (rafIdRef.current) {
|
if (timeoutRef.current) {
|
||||||
cancelAnimationFrame(rafIdRef.current);
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [subtitles.length, currentTime, getCurrentSubtitle, onSubtitleChange, shouldAutoPause, onAutoPauseTrigger]);
|
}, []);
|
||||||
|
}
|
||||||
// 重置最后字幕引用
|
|
||||||
useEffect(() => {
|
|
||||||
lastSubtitleRef.current = null;
|
|
||||||
}, [subtitles]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
getCurrentSubtitle,
|
|
||||||
getNearestIndex,
|
|
||||||
shouldAutoPause,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
44
src/app/(features)/srt-player/hooks/useVideoSync.ts
Normal file
44
src/app/(features)/srt-player/hooks/useVideoSync.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, type RefObject } from 'react';
|
||||||
|
import { useSrtPlayerStore } from '../stores/srtPlayerStore';
|
||||||
|
|
||||||
|
export function useVideoSync(videoRef: RefObject<HTMLVideoElement | null>) {
|
||||||
|
const setCurrentTime = useSrtPlayerStore((state) => state.setCurrentTime);
|
||||||
|
const setDuration = useSrtPlayerStore((state) => state.setDuration);
|
||||||
|
const play = useSrtPlayerStore((state) => state.play);
|
||||||
|
const pause = useSrtPlayerStore((state) => state.pause);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
setCurrentTime(video.currentTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
setDuration(video.duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlay = () => {
|
||||||
|
play();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePause = () => {
|
||||||
|
pause();
|
||||||
|
};
|
||||||
|
|
||||||
|
video.addEventListener('timeupdate', handleTimeUpdate);
|
||||||
|
video.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
|
video.addEventListener('play', handlePlay);
|
||||||
|
video.addEventListener('pause', handlePause);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
video.removeEventListener('timeupdate', handleTimeUpdate);
|
||||||
|
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||||
|
video.removeEventListener('play', handlePlay);
|
||||||
|
video.removeEventListener('pause', handlePause);
|
||||||
|
};
|
||||||
|
}, [videoRef, setCurrentTime, setDuration, play, pause]);
|
||||||
|
}
|
||||||
@@ -1,114 +1,98 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import { useRef, useEffect } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Video, FileText } from "lucide-react";
|
|
||||||
import { PageLayout } from "@/components/ui/PageLayout";
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
import { useSrtPlayer } from "./hooks/useSrtPlayer";
|
|
||||||
import { useSubtitleSync } from "./hooks/useSubtitleSync";
|
|
||||||
import { useKeyboardShortcuts, createSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
|
|
||||||
import { useFileUpload } from "./hooks/useFileUpload";
|
|
||||||
import { loadSubtitle } from "./utils/subtitleParser";
|
|
||||||
import { VideoPlayer } from "./components/compounds/VideoPlayer";
|
|
||||||
import { SubtitleArea } from "./components/compounds/SubtitleArea";
|
|
||||||
import { ControlBar } from "./components/compounds/ControlBar";
|
|
||||||
import { UploadZone } from "./components/compounds/UploadZone";
|
|
||||||
import { SeekBar } from "./components/atoms/SeekBar";
|
|
||||||
import { LightButton } from "@/design-system/base/button";
|
import { LightButton } from "@/design-system/base/button";
|
||||||
|
import { HStack } from "@/design-system/layout/stack";
|
||||||
|
import { Video, FileText, ChevronLeft, ChevronRight, RotateCcw, Pause, Play } from "lucide-react";
|
||||||
|
import { useVideoSync } from "./hooks/useVideoSync";
|
||||||
|
import { useSubtitleSync } from "./hooks/useSubtitleSync";
|
||||||
|
import { useSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||||
|
import { loadSubtitle } from "./utils/subtitleParser";
|
||||||
|
import { useSrtPlayerStore } from "./stores/srtPlayerStore";
|
||||||
|
import { useFileUpload } from "./hooks/useFileUpload";
|
||||||
|
import { setVideoRef } from "./stores/srtPlayerStore";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
export default function SrtPlayerPage() {
|
export default function SrtPlayerPage() {
|
||||||
const t = useTranslations("home");
|
const t = useTranslations("home");
|
||||||
const srtT = useTranslations("srt_player");
|
const srtT = useTranslations("srt_player");
|
||||||
|
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const { uploadVideo, uploadSubtitle } = useFileUpload();
|
const { uploadVideo, uploadSubtitle } = useFileUpload();
|
||||||
const {
|
|
||||||
state,
|
|
||||||
actions,
|
|
||||||
videoRef,
|
|
||||||
videoEventHandlers,
|
|
||||||
subtitleActions
|
|
||||||
} = useSrtPlayer();
|
|
||||||
|
|
||||||
// 字幕同步
|
const subtitleUrl = useSrtPlayerStore((state) => state.subtitle.url);
|
||||||
useSubtitleSync(
|
const setSubtitleData = useSrtPlayerStore((state) => state.setSubtitleData);
|
||||||
state.subtitle.data,
|
const setSubtitleUrl = useSrtPlayerStore((state) => state.setSubtitleUrl);
|
||||||
state.video.currentTime,
|
const setVideoUrl = useSrtPlayerStore((state) => state.setVideoUrl);
|
||||||
state.video.isPlaying,
|
|
||||||
state.controls.autoPause,
|
|
||||||
(subtitle) => {
|
|
||||||
if (subtitle) {
|
|
||||||
subtitleActions.setCurrentSubtitle(subtitle.text, subtitle.index);
|
|
||||||
} else {
|
|
||||||
subtitleActions.setCurrentSubtitle("", null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
(subtitle) => {
|
|
||||||
// 自动暂停逻辑
|
|
||||||
actions.seek(subtitle.start);
|
|
||||||
actions.pause();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// 键盘快捷键
|
const videoUrl = useSrtPlayerStore((state) => state.video.url);
|
||||||
const shortcuts = React.useMemo(() =>
|
const subtitleData = useSrtPlayerStore((state) => state.subtitle.data);
|
||||||
createSrtPlayerShortcuts(
|
const currentIndex = useSrtPlayerStore((state) => state.subtitle.currentIndex);
|
||||||
actions.togglePlayPause,
|
const isPlaying = useSrtPlayerStore((state) => state.video.isPlaying);
|
||||||
actions.nextSubtitle,
|
const playbackRate = useSrtPlayerStore((state) => state.video.playbackRate);
|
||||||
actions.previousSubtitle,
|
const autoPause = useSrtPlayerStore((state) => state.controls.autoPause);
|
||||||
actions.restartSubtitle,
|
|
||||||
actions.toggleAutoPause
|
|
||||||
), [
|
|
||||||
actions.togglePlayPause,
|
|
||||||
actions.nextSubtitle,
|
|
||||||
actions.previousSubtitle,
|
|
||||||
actions.restartSubtitle,
|
|
||||||
actions.toggleAutoPause
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
// 处理字幕文件加载
|
useVideoSync(videoRef);
|
||||||
React.useEffect(() => {
|
useSubtitleSync();
|
||||||
if (state.subtitle.url) {
|
useSrtPlayerShortcuts();
|
||||||
loadSubtitle(state.subtitle.url)
|
|
||||||
.then(subtitleData => {
|
useEffect(() => {
|
||||||
subtitleActions.setSubtitleData(subtitleData);
|
setVideoRef(videoRef);
|
||||||
|
}, [videoRef]);
|
||||||
|
|
||||||
|
const canPlay = !!videoUrl && !!subtitleUrl && subtitleData.length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (subtitleUrl) {
|
||||||
|
loadSubtitle(subtitleUrl)
|
||||||
|
.then((subtitleData) => {
|
||||||
|
setSubtitleData(subtitleData);
|
||||||
toast.success(srtT("subtitleLoadSuccess"));
|
toast.success(srtT("subtitleLoadSuccess"));
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch((error) => {
|
||||||
toast.error(srtT("subtitleLoadFailed") + ": " + error.message);
|
toast.error(srtT("subtitleLoadFailed") + ": " + error.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [srtT, state.subtitle.url, subtitleActions]);
|
}, [srtT, subtitleUrl, setSubtitleData]);
|
||||||
|
|
||||||
// 处理进度条变化
|
const handleVideoUpload = () => {
|
||||||
const handleSeek = React.useCallback((index: number) => {
|
uploadVideo((url) => {
|
||||||
if (state.subtitle.data[index]) {
|
setVideoUrl(url);
|
||||||
actions.seek(state.subtitle.data[index].start);
|
}, (error) => {
|
||||||
}
|
toast.error(t('videoUploadFailed') + ': ' + error.message);
|
||||||
}, [state.subtitle.data, actions]);
|
|
||||||
|
|
||||||
// 处理视频上传
|
|
||||||
const handleVideoUpload = React.useCallback(() => {
|
|
||||||
uploadVideo(actions.setVideoUrl, (error) => {
|
|
||||||
toast.error(srtT("videoUploadFailed") + ": " + error.message);
|
|
||||||
});
|
});
|
||||||
}, [uploadVideo, actions.setVideoUrl, srtT]);
|
};
|
||||||
|
|
||||||
// 处理字幕上传
|
const handleSubtitleUpload = () => {
|
||||||
const handleSubtitleUpload = React.useCallback(() => {
|
uploadSubtitle((url) => {
|
||||||
uploadSubtitle(actions.setSubtitleUrl, (error) => {
|
setSubtitleUrl(url);
|
||||||
toast.error(srtT("subtitleUploadFailed") + ": " + error.message);
|
}, (error) => {
|
||||||
|
toast.error(t('subtitleUploadFailed') + ': ' + error.message);
|
||||||
});
|
});
|
||||||
}, [uploadSubtitle, actions.setSubtitleUrl, srtT]);
|
};
|
||||||
|
|
||||||
// 检查是否可以播放
|
const handlePlaybackRateChange = () => {
|
||||||
const canPlay = state.video.url && state.subtitle.url && state.subtitle.data.length > 0;
|
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 (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
{/* 标题区域 */}
|
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-4xl font-bold text-gray-800 mb-2">
|
<h1 className="text-4xl font-bold text-gray-800 mb-2">
|
||||||
{t("srtPlayer.name")}
|
{t("srtPlayer.name")}
|
||||||
@@ -118,157 +102,78 @@ export default function SrtPlayerPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 视频播放器区域 */}
|
<video
|
||||||
<div className="aspect-video bg-black relative rounded-md overflow-hidden">
|
ref={videoRef}
|
||||||
{(!state.video.url || !state.subtitle.url || state.subtitle.data.length === 0) && (
|
width="85%"
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-75 z-10">
|
className="mx-auto"
|
||||||
<div className="text-center text-white">
|
playsInline
|
||||||
<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 && (
|
<div className="shadow rounded h-20 w-[85%] mx-auto flex-wrap flex items-begin justify-center">
|
||||||
<VideoPlayer
|
{currentSubtitle && currentSubtitle.text.split(" ").map((s, i) => (
|
||||||
ref={videoRef}
|
<Link
|
||||||
src={state.video.url}
|
key={i}
|
||||||
{...videoEventHandlers}
|
href={`/dictionary?q=${s}`}
|
||||||
className="w-full h-full"
|
className="px-1 h-fit hover:bg-gray-200 hover:cursor-pointer"
|
||||||
>
|
target="_blank"
|
||||||
{state.subtitle.url && state.subtitle.data.length > 0 && (
|
rel="noopener noreferrer"
|
||||||
<SubtitleArea
|
>
|
||||||
subtitle={state.subtitle.currentText}
|
{s}
|
||||||
settings={state.subtitle.settings}
|
</Link>
|
||||||
className="absolute bottom-0 left-0 right-0 px-4 py-2"
|
))}
|
||||||
/>
|
</div>
|
||||||
)}
|
|
||||||
</VideoPlayer>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 控制面板 */}
|
<div className="mx-auto mt-4 flex items-center justify-center flex-wrap gap-2 w-[85%]">
|
||||||
<div className="p-3 bg-gray-50 border-t rounded-b-xl">
|
<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">
|
||||||
<div className="mb-3">
|
<Video size={16} />
|
||||||
<div className="flex gap-3">
|
<span className="text-sm">{srtT("videoFile")}</span>
|
||||||
<div className={`flex-1 p-2 rounded-lg border-2 transition-all ${state.video.url
|
|
||||||
? 'border-gray-800 bg-gray-100'
|
|
||||||
: 'border-gray-300 bg-white'
|
|
||||||
}`}>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Video className="w-5 h-5 text-gray-600" />
|
|
||||||
<div className="text-left">
|
|
||||||
<h3 className="font-semibold text-gray-800 text-sm">{srtT("videoFile")}</h3>
|
|
||||||
<p className="text-xs text-gray-600">
|
|
||||||
{state.video.url ? srtT("uploaded") : srtT("notUploaded")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<LightButton
|
|
||||||
onClick={state.video.url ? undefined : handleVideoUpload}
|
|
||||||
disabled={!!state.video.url}
|
|
||||||
className="px-2 py-1 text-xs"
|
|
||||||
>
|
|
||||||
{state.video.url ? srtT("uploaded") : srtT("upload")}
|
|
||||||
</LightButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`flex-1 p-2 rounded-lg border-2 transition-all ${state.subtitle.url
|
|
||||||
? 'border-gray-800 bg-gray-100'
|
|
||||||
: 'border-gray-300 bg-white'
|
|
||||||
}`}>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FileText className="w-5 h-5 text-gray-600" />
|
|
||||||
<div className="text-left">
|
|
||||||
<h3 className="font-semibold text-gray-800 text-sm">{srtT("subtitleFile")}</h3>
|
|
||||||
<p className="text-xs text-gray-600">
|
|
||||||
{state.subtitle.url ? srtT("uploaded") : srtT("notUploaded")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<LightButton
|
|
||||||
onClick={state.subtitle.url ? undefined : handleSubtitleUpload}
|
|
||||||
disabled={!!state.subtitle.url}
|
|
||||||
className="px-2 py-1 text-xs"
|
|
||||||
>
|
|
||||||
{state.subtitle.url ? srtT("uploaded") : srtT("upload")}
|
|
||||||
</LightButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 控制按钮和进度条 */}
|
|
||||||
<div className={`space-y-4 ${canPlay ? '' : 'opacity-50 pointer-events-none'}`}>
|
|
||||||
{/* 控制按钮 */}
|
|
||||||
<ControlBar
|
|
||||||
isPlaying={state.video.isPlaying}
|
|
||||||
onPlayPause={actions.togglePlayPause}
|
|
||||||
onPrevious={actions.previousSubtitle}
|
|
||||||
onNext={actions.nextSubtitle}
|
|
||||||
onRestart={actions.restartSubtitle}
|
|
||||||
playbackRate={state.video.playbackRate}
|
|
||||||
onPlaybackRateChange={actions.setPlaybackRate}
|
|
||||||
autoPause={state.controls.autoPause}
|
|
||||||
onAutoPauseToggle={actions.toggleAutoPause}
|
|
||||||
disabled={!canPlay}
|
|
||||||
className="justify-center"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 进度条 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<SeekBar
|
|
||||||
value={state.subtitle.currentIndex ?? 0}
|
|
||||||
max={Math.max(0, state.subtitle.data.length - 1)}
|
|
||||||
onChange={handleSeek}
|
|
||||||
disabled={!canPlay}
|
|
||||||
className="h-3"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 字幕进度显示 */}
|
|
||||||
<div className="flex justify-between items-center text-sm text-gray-600 px-2">
|
|
||||||
<span>
|
|
||||||
{state.subtitle.currentIndex !== null ?
|
|
||||||
`${state.subtitle.currentIndex + 1}/${state.subtitle.data.length}` :
|
|
||||||
'0/0'
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
{/* 播放速度显示 */}
|
|
||||||
<span className="bg-gray-200 px-2 py-1 rounded text-xs">
|
|
||||||
{state.video.playbackRate}x
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* 自动暂停状态 */}
|
|
||||||
<span className={`px-2 py-1 rounded text-xs ${state.controls.autoPause
|
|
||||||
? 'bg-gray-800 text-white'
|
|
||||||
: 'bg-gray-100 text-gray-600'
|
|
||||||
}`}>
|
|
||||||
{srtT("autoPauseStatus", { enabled: state.controls.autoPause ? srtT("on") : srtT("off") })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<LightButton onClick={handleVideoUpload} disabled={!!videoUrl}>
|
||||||
|
{videoUrl ? srtT("uploaded") : srtT("uploadVideoButton")}
|
||||||
|
</LightButton>
|
||||||
|
</div>
|
||||||
|
<div className="border-gray-200 border-2 flex items p-2 justify-between items-center rounded gap-8">
|
||||||
|
<div className="flex items-center flex-col">
|
||||||
|
<FileText size={16} />
|
||||||
|
<span className="text-sm">
|
||||||
|
{subtitleData.length > 0 ? srtT("subtitleUploaded", { count: subtitleData.length }) : srtT("subtitleNotUploaded")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<LightButton onClick={handleSubtitleUpload} disabled={!!subtitleUrl}>
|
||||||
|
{subtitleUrl ? srtT("uploaded") : srtT("uploadSubtitleButton")}
|
||||||
|
</LightButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{canPlay && (
|
||||||
|
<HStack gap={2} className="mx-auto mt-4 w-[85%]" justify={"center"} wrap>
|
||||||
|
{isPlaying ? (
|
||||||
|
<LightButton onClick={togglePlayPause} leftIcon={<Pause className="w-4 h-4" />}>
|
||||||
|
{srtT('pause')}
|
||||||
|
</LightButton>
|
||||||
|
) : (
|
||||||
|
<LightButton onClick={togglePlayPause} leftIcon={<Play className="w-4 h-4" />}>
|
||||||
|
{srtT('play')}
|
||||||
|
</LightButton>
|
||||||
|
)}
|
||||||
|
<LightButton onClick={previousSubtitle} leftIcon={<ChevronLeft className="w-4 h-4" />}>
|
||||||
|
{srtT('previous')}
|
||||||
|
</LightButton>
|
||||||
|
<LightButton onClick={nextSubtitle} rightIcon={<ChevronRight className="w-4 h-4" />}>
|
||||||
|
{srtT('next')}
|
||||||
|
</LightButton>
|
||||||
|
<LightButton onClick={restartSubtitle} leftIcon={<RotateCcw className="w-4 h-4" />}>
|
||||||
|
{srtT('restart')}
|
||||||
|
</LightButton>
|
||||||
|
<LightButton onClick={handlePlaybackRateChange}>
|
||||||
|
{playbackRate}x
|
||||||
|
</LightButton>
|
||||||
|
<LightButton onClick={toggleAutoPause}>
|
||||||
|
{srtT('autoPause', { enabled: autoPause ? srtT('on') : srtT('off') })}
|
||||||
|
</LightButton>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
217
src/app/(features)/srt-player/stores/srtPlayerStore.ts
Normal file
217
src/app/(features)/srt-player/stores/srtPlayerStore.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { devtools } from 'zustand/middleware';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type {
|
||||||
|
SrtPlayerStore,
|
||||||
|
VideoState,
|
||||||
|
SubtitleState,
|
||||||
|
ControlState,
|
||||||
|
SubtitleSettings,
|
||||||
|
SubtitleEntry,
|
||||||
|
} from '../types';
|
||||||
|
import type { RefObject } from 'react';
|
||||||
|
|
||||||
|
let videoRef: RefObject<HTMLVideoElement | null> | null;
|
||||||
|
|
||||||
|
export function setVideoRef(ref: RefObject<HTMLVideoElement | null> | null) {
|
||||||
|
videoRef = ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialVideoState: VideoState = {
|
||||||
|
url: null,
|
||||||
|
isPlaying: false,
|
||||||
|
currentTime: 0,
|
||||||
|
duration: 0,
|
||||||
|
playbackRate: 1.0,
|
||||||
|
volume: 1.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialSubtitleSettings: SubtitleSettings = {
|
||||||
|
fontSize: 24,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
textColor: '#ffffff',
|
||||||
|
position: 'bottom',
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
opacity: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialSubtitleState: SubtitleState = {
|
||||||
|
url: null,
|
||||||
|
data: [],
|
||||||
|
currentText: '',
|
||||||
|
currentIndex: null,
|
||||||
|
settings: initialSubtitleSettings,
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialControlState: ControlState = {
|
||||||
|
autoPause: true,
|
||||||
|
showShortcuts: false,
|
||||||
|
showSettings: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSrtPlayerStore = create<SrtPlayerStore>()(
|
||||||
|
devtools(
|
||||||
|
(set, get) => ({
|
||||||
|
video: initialVideoState,
|
||||||
|
subtitle: initialSubtitleState,
|
||||||
|
controls: initialControlState,
|
||||||
|
|
||||||
|
setVideoUrl: (url) =>
|
||||||
|
set((state) => {
|
||||||
|
if (videoRef?.current) {
|
||||||
|
videoRef.current.src = url || '';
|
||||||
|
videoRef.current.load();
|
||||||
|
}
|
||||||
|
return { video: { ...state.video, url } };
|
||||||
|
}),
|
||||||
|
|
||||||
|
setPlaying: (playing) =>
|
||||||
|
set((state) => ({ video: { ...state.video, isPlaying: playing } })),
|
||||||
|
|
||||||
|
setCurrentTime: (time) =>
|
||||||
|
set((state) => ({ video: { ...state.video, currentTime: time } })),
|
||||||
|
|
||||||
|
setDuration: (duration) =>
|
||||||
|
set((state) => ({ video: { ...state.video, duration } })),
|
||||||
|
|
||||||
|
setPlaybackRate: (rate) =>
|
||||||
|
set((state) => {
|
||||||
|
if (videoRef?.current) {
|
||||||
|
videoRef.current.playbackRate = rate;
|
||||||
|
}
|
||||||
|
return { video: { ...state.video, playbackRate: rate } };
|
||||||
|
}),
|
||||||
|
|
||||||
|
setVolume: (volume) =>
|
||||||
|
set((state) => {
|
||||||
|
if (videoRef?.current) {
|
||||||
|
videoRef.current.volume = volume;
|
||||||
|
}
|
||||||
|
return { video: { ...state.video, volume } };
|
||||||
|
}),
|
||||||
|
|
||||||
|
play: () => {
|
||||||
|
const state = get();
|
||||||
|
if (!state.video.url || !state.subtitle.url || state.subtitle.data.length === 0) {
|
||||||
|
toast.error('请先上传视频和字幕文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (videoRef?.current) {
|
||||||
|
videoRef.current.play().catch((error) => {
|
||||||
|
toast.error('视频播放失败: ' + error.message);
|
||||||
|
});
|
||||||
|
set({ video: { ...state.video, isPlaying: true } });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
pause: () => {
|
||||||
|
if (videoRef?.current) {
|
||||||
|
if (!videoRef.current.paused) {
|
||||||
|
videoRef.current.pause();
|
||||||
|
}
|
||||||
|
set((state) => ({ video: { ...state.video, isPlaying: false } }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
togglePlayPause: () => {
|
||||||
|
const state = get();
|
||||||
|
if (state.video.isPlaying) {
|
||||||
|
get().pause();
|
||||||
|
} else {
|
||||||
|
get().play();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
seek: (time) => {
|
||||||
|
if (videoRef?.current) {
|
||||||
|
videoRef.current.currentTime = time;
|
||||||
|
set((state) => ({ video: { ...state.video, currentTime: time } }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
restart: () => {
|
||||||
|
const state = get();
|
||||||
|
if (state.subtitle.currentIndex !== null) {
|
||||||
|
const currentSubtitle = state.subtitle.data[state.subtitle.currentIndex];
|
||||||
|
if (currentSubtitle) {
|
||||||
|
get().seek(currentSubtitle.start);
|
||||||
|
get().play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setSubtitleUrl: (url) =>
|
||||||
|
set((state) => ({ subtitle: { ...state.subtitle, url } })),
|
||||||
|
|
||||||
|
setSubtitleData: (data) =>
|
||||||
|
set((state) => ({ subtitle: { ...state.subtitle, data } })),
|
||||||
|
|
||||||
|
setCurrentSubtitle: (text, index) =>
|
||||||
|
set((state) => ({
|
||||||
|
subtitle: {
|
||||||
|
...state.subtitle,
|
||||||
|
currentText: text,
|
||||||
|
currentIndex: index,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
|
||||||
|
updateSettings: (settings) =>
|
||||||
|
set((state) => ({
|
||||||
|
subtitle: {
|
||||||
|
...state.subtitle,
|
||||||
|
settings: { ...state.subtitle.settings, ...settings },
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
|
||||||
|
nextSubtitle: () => {
|
||||||
|
const state = get();
|
||||||
|
if (
|
||||||
|
state.subtitle.currentIndex !== null &&
|
||||||
|
state.subtitle.currentIndex + 1 < state.subtitle.data.length
|
||||||
|
) {
|
||||||
|
const nextIndex = state.subtitle.currentIndex + 1;
|
||||||
|
const nextSubtitle = state.subtitle.data[nextIndex];
|
||||||
|
get().seek(nextSubtitle.start);
|
||||||
|
get().play();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
previousSubtitle: () => {
|
||||||
|
const state = get();
|
||||||
|
if (state.subtitle.currentIndex !== null && state.subtitle.currentIndex > 0) {
|
||||||
|
const prevIndex = state.subtitle.currentIndex - 1;
|
||||||
|
const prevSubtitle = state.subtitle.data[prevIndex];
|
||||||
|
get().seek(prevSubtitle.start);
|
||||||
|
get().play();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
restartSubtitle: () => {
|
||||||
|
const state = get();
|
||||||
|
if (state.subtitle.currentIndex !== null) {
|
||||||
|
const currentSubtitle = state.subtitle.data[state.subtitle.currentIndex];
|
||||||
|
get().seek(currentSubtitle.start);
|
||||||
|
get().play();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleAutoPause: () =>
|
||||||
|
set((state) => ({
|
||||||
|
controls: { ...state.controls, autoPause: !state.controls.autoPause },
|
||||||
|
})),
|
||||||
|
|
||||||
|
toggleShortcuts: () =>
|
||||||
|
set((state) => ({
|
||||||
|
controls: { ...state.controls, showShortcuts: !state.controls.showShortcuts },
|
||||||
|
})),
|
||||||
|
|
||||||
|
toggleSettings: () =>
|
||||||
|
set((state) => ({
|
||||||
|
controls: { ...state.controls, showSettings: !state.controls.showSettings },
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
{ name: 'srt-player-store' }
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
export function parseSrt(data: string) {
|
|
||||||
const lines = data.split(/\r?\n/);
|
|
||||||
const result = [];
|
|
||||||
const re = new RegExp(
|
|
||||||
"(\\d{2}:\\d{2}:\\d{2},\\d{3})\\s*-->\\s*(\\d{2}:\\d{2}:\\d{2},\\d{3})",
|
|
||||||
);
|
|
||||||
let i = 0;
|
|
||||||
while (i < lines.length) {
|
|
||||||
if (!lines[i].trim()) {
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
if (i >= lines.length) break;
|
|
||||||
const timeMatch = lines[i].match(re);
|
|
||||||
if (!timeMatch) {
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const start = toSeconds(timeMatch[1]);
|
|
||||||
const end = toSeconds(timeMatch[2]);
|
|
||||||
i++;
|
|
||||||
let text = "";
|
|
||||||
while (i < lines.length && lines[i].trim()) {
|
|
||||||
text += lines[i] + "\n";
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
result.push({ start, end, text: text.trim() });
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getNearistIndex(
|
|
||||||
srt: { start: number; end: number; text: string }[],
|
|
||||||
ct: number,
|
|
||||||
) {
|
|
||||||
for (let i = 0; i < srt.length; i++) {
|
|
||||||
const s = srt[i];
|
|
||||||
const l = ct - s.start >= 0;
|
|
||||||
const r = ct - s.end >= 0;
|
|
||||||
if (!(l || r)) return i - 1;
|
|
||||||
if (l && !r) return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getIndex(
|
|
||||||
srt: { start: number; end: number; text: string }[],
|
|
||||||
ct: number,
|
|
||||||
) {
|
|
||||||
for (let i = 0; i < srt.length; i++) {
|
|
||||||
if (ct >= srt[i].start && ct <= srt[i].end) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSubtitle(
|
|
||||||
srt: { start: number; end: number; text: string }[],
|
|
||||||
currentTime: number,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
srt.find((sub) => currentTime >= sub.start && currentTime <= sub.end) ||
|
|
||||||
null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toSeconds(timeStr: string): number {
|
|
||||||
const [h, m, s] = timeStr.replace(",", ".").split(":");
|
|
||||||
return parseFloat(
|
|
||||||
(parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s)).toFixed(3),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
132
src/app/(features)/srt-player/types.ts
Normal file
132
src/app/(features)/srt-player/types.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
// ==================== Video Types ====================
|
||||||
|
|
||||||
|
export interface VideoState {
|
||||||
|
url: string | null;
|
||||||
|
isPlaying: boolean;
|
||||||
|
currentTime: number;
|
||||||
|
duration: number;
|
||||||
|
playbackRate: number;
|
||||||
|
volume: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoControls {
|
||||||
|
play: () => void;
|
||||||
|
pause: () => void;
|
||||||
|
togglePlayPause: () => void;
|
||||||
|
seek: (time: number) => void;
|
||||||
|
setPlaybackRate: (rate: number) => void;
|
||||||
|
setVolume: (volume: number) => void;
|
||||||
|
restart: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Subtitle Types ====================
|
||||||
|
|
||||||
|
export interface SubtitleEntry {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
text: string;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleState {
|
||||||
|
url: string | null;
|
||||||
|
data: SubtitleEntry[];
|
||||||
|
currentText: string;
|
||||||
|
currentIndex: number | null;
|
||||||
|
settings: SubtitleSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleSettings {
|
||||||
|
fontSize: number;
|
||||||
|
backgroundColor: string;
|
||||||
|
textColor: string;
|
||||||
|
position: 'top' | 'center' | 'bottom';
|
||||||
|
fontFamily: string;
|
||||||
|
opacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtitleControls {
|
||||||
|
next: () => void;
|
||||||
|
previous: () => void;
|
||||||
|
goToIndex: (index: number) => void;
|
||||||
|
toggleAutoPause: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Controls Types ====================
|
||||||
|
|
||||||
|
export interface ControlState {
|
||||||
|
autoPause: boolean;
|
||||||
|
showShortcuts: boolean;
|
||||||
|
showSettings: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControlActions {
|
||||||
|
toggleAutoPause: () => void;
|
||||||
|
toggleShortcuts: () => void;
|
||||||
|
toggleSettings: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyboardShortcut {
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
action: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Store Types ====================
|
||||||
|
|
||||||
|
export interface SrtPlayerStore {
|
||||||
|
// Video state
|
||||||
|
video: VideoState;
|
||||||
|
|
||||||
|
// Subtitle state
|
||||||
|
subtitle: SubtitleState;
|
||||||
|
|
||||||
|
// Controls state
|
||||||
|
controls: ControlState;
|
||||||
|
|
||||||
|
// Video actions
|
||||||
|
setVideoUrl: (url: string | null) => void;
|
||||||
|
setPlaying: (playing: boolean) => void;
|
||||||
|
setCurrentTime: (time: number) => void;
|
||||||
|
setDuration: (duration: number) => void;
|
||||||
|
setPlaybackRate: (rate: number) => void;
|
||||||
|
setVolume: (volume: number) => void;
|
||||||
|
play: () => void;
|
||||||
|
pause: () => void;
|
||||||
|
togglePlayPause: () => void;
|
||||||
|
seek: (time: number) => void;
|
||||||
|
restart: () => void;
|
||||||
|
|
||||||
|
// Subtitle actions
|
||||||
|
setSubtitleUrl: (url: string | null) => void;
|
||||||
|
setSubtitleData: (data: SubtitleEntry[]) => void;
|
||||||
|
setCurrentSubtitle: (text: string, index: number | null) => void;
|
||||||
|
updateSettings: (settings: Partial<SubtitleSettings>) => void;
|
||||||
|
nextSubtitle: () => void;
|
||||||
|
previousSubtitle: () => void;
|
||||||
|
restartSubtitle: () => void;
|
||||||
|
|
||||||
|
// Controls actions
|
||||||
|
toggleAutoPause: () => void;
|
||||||
|
toggleShortcuts: () => void;
|
||||||
|
toggleSettings: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Selectors ====================
|
||||||
|
|
||||||
|
export const selectors = {
|
||||||
|
canPlay: (state: SrtPlayerStore) =>
|
||||||
|
!!state.video.url &&
|
||||||
|
!!state.subtitle.url &&
|
||||||
|
state.subtitle.data.length > 0,
|
||||||
|
|
||||||
|
currentSubtitle: (state: SrtPlayerStore) =>
|
||||||
|
state.subtitle.currentIndex !== null
|
||||||
|
? state.subtitle.data[state.subtitle.currentIndex]
|
||||||
|
: null,
|
||||||
|
|
||||||
|
progress: (state: SrtPlayerStore) => ({
|
||||||
|
current: state.subtitle.currentIndex ?? 0,
|
||||||
|
total: state.subtitle.data.length,
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
export interface ControlState {
|
|
||||||
autoPause: boolean;
|
|
||||||
showShortcuts: boolean;
|
|
||||||
showSettings: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ControlActions {
|
|
||||||
toggleAutoPause: () => void;
|
|
||||||
toggleShortcuts: () => void;
|
|
||||||
toggleSettings: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ControlBarProps {
|
|
||||||
isPlaying: boolean;
|
|
||||||
onPlayPause: () => void;
|
|
||||||
onPrevious: () => void;
|
|
||||||
onNext: () => void;
|
|
||||||
onRestart: () => void;
|
|
||||||
playbackRate: number;
|
|
||||||
onPlaybackRateChange: (rate: number) => void;
|
|
||||||
autoPause: boolean;
|
|
||||||
onAutoPauseToggle: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NavigationButtonProps {
|
|
||||||
onClick: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
className?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AutoPauseToggleProps {
|
|
||||||
enabled: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KeyboardShortcut {
|
|
||||||
key: string;
|
|
||||||
description: string;
|
|
||||||
action: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShortcutHintProps {
|
|
||||||
shortcuts: KeyboardShortcut[];
|
|
||||||
visible: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileUploadProps {
|
|
||||||
onVideoUpload: (url: string) => void;
|
|
||||||
onSubtitleUpload: (url: string) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileInputProps {
|
|
||||||
accept: string;
|
|
||||||
onFileSelect: (file: File) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
export interface VideoState {
|
|
||||||
url: string | null;
|
|
||||||
isPlaying: boolean;
|
|
||||||
currentTime: number;
|
|
||||||
duration: number;
|
|
||||||
playbackRate: number;
|
|
||||||
volume: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VideoControls {
|
|
||||||
play: () => void;
|
|
||||||
pause: () => void;
|
|
||||||
togglePlayPause: () => void;
|
|
||||||
seek: (time: number) => void;
|
|
||||||
setPlaybackRate: (rate: number) => void;
|
|
||||||
setVolume: (volume: number) => void;
|
|
||||||
restart: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VideoElementProps {
|
|
||||||
src?: string;
|
|
||||||
onTimeUpdate?: (time: number) => void;
|
|
||||||
onLoadedMetadata?: (duration: number) => void;
|
|
||||||
onPlay?: () => void;
|
|
||||||
onPause?: () => void;
|
|
||||||
onEnded?: () => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlayButtonProps {
|
|
||||||
isPlaying: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SeekBarProps {
|
|
||||||
value: number;
|
|
||||||
max: number;
|
|
||||||
onChange: (value: number) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SpeedControlProps {
|
|
||||||
playbackRate: number;
|
|
||||||
onPlaybackRateChange: (rate: number) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VolumeControlProps {
|
|
||||||
volume: number;
|
|
||||||
onVolumeChange: (volume: number) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
export interface SubtitleEntry {
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
text: string;
|
|
||||||
index: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubtitleState {
|
|
||||||
url: string | null;
|
|
||||||
data: SubtitleEntry[];
|
|
||||||
currentText: string;
|
|
||||||
currentIndex: number | null;
|
|
||||||
settings: SubtitleSettings;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubtitleSettings {
|
|
||||||
fontSize: number;
|
|
||||||
backgroundColor: string;
|
|
||||||
textColor: string;
|
|
||||||
position: 'top' | 'center' | 'bottom';
|
|
||||||
fontFamily: string;
|
|
||||||
opacity: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubtitleDisplayProps {
|
|
||||||
subtitle: string;
|
|
||||||
onWordClick?: (word: string) => void;
|
|
||||||
settings?: SubtitleSettings;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubtitleTextProps {
|
|
||||||
text: string;
|
|
||||||
onWordClick?: (word: string) => void;
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubtitleSettingsProps {
|
|
||||||
settings: SubtitleSettings;
|
|
||||||
onSettingsChange: (settings: SubtitleSettings) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubtitleControls {
|
|
||||||
next: () => void;
|
|
||||||
previous: () => void;
|
|
||||||
goToIndex: (index: number) => void;
|
|
||||||
toggleAutoPause: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubtitleSyncProps {
|
|
||||||
subtitles: SubtitleEntry[];
|
|
||||||
currentTime: number;
|
|
||||||
isPlaying: boolean;
|
|
||||||
autoPause: boolean;
|
|
||||||
onSubtitleChange: (subtitle: SubtitleEntry | null) => void;
|
|
||||||
onAutoPauseTrigger?: (subtitle: SubtitleEntry) => void;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SubtitleEntry } from "../types/subtitle";
|
import { SubtitleEntry } from "../types";
|
||||||
|
|
||||||
export function parseSrt(data: string): SubtitleEntry[] {
|
export function parseSrt(data: string): SubtitleEntry[] {
|
||||||
const lines = data.split(/\r?\n/);
|
const lines = data.split(/\r?\n/);
|
||||||
@@ -62,13 +62,12 @@ export function getNearestIndex(
|
|||||||
): number | null {
|
): number | null {
|
||||||
for (let i = 0; i < subtitles.length; i++) {
|
for (let i = 0; i < subtitles.length; i++) {
|
||||||
const subtitle = subtitles[i];
|
const subtitle = subtitles[i];
|
||||||
const isBefore = currentTime - subtitle.start >= 0;
|
const isWithin = currentTime >= subtitle.start && currentTime <= subtitle.end;
|
||||||
const isAfter = currentTime - subtitle.end >= 0;
|
|
||||||
|
|
||||||
if (!isBefore || !isAfter) return i - 1;
|
if (isWithin) return i;
|
||||||
if (isBefore && !isAfter) 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(
|
export function getCurrentSubtitle(
|
||||||
@@ -96,4 +95,4 @@ export async function loadSubtitle(url: string): Promise<SubtitleEntry[]> {
|
|||||||
console.error('加载字幕失败', error);
|
console.error('加载字幕失败', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
export function formatTime(seconds: number): string {
|
|
||||||
const hours = Math.floor(seconds / 3600);
|
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
|
||||||
const secs = Math.floor(seconds % 60);
|
|
||||||
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function timeToSeconds(timeStr: string): number {
|
|
||||||
const parts = timeStr.split(':');
|
|
||||||
|
|
||||||
if (parts.length === 3) {
|
|
||||||
// HH:MM:SS format
|
|
||||||
const [h, m, s] = parts;
|
|
||||||
return parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s);
|
|
||||||
} else if (parts.length === 2) {
|
|
||||||
// MM:SS format
|
|
||||||
const [m, s] = parts;
|
|
||||||
return parseInt(m) * 60 + parseFloat(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function secondsToTime(seconds: number): string {
|
|
||||||
const hours = Math.floor(seconds / 3600);
|
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
|
||||||
const secs = Math.floor(seconds % 60);
|
|
||||||
const ms = Math.floor((seconds % 1) * 1000);
|
|
||||||
|
|
||||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')},${ms.toString().padStart(3, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clampTime(time: number, min: number = 0, max: number = Infinity): number {
|
|
||||||
return Math.min(Math.max(time, min), max);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPlaybackRateOptions(): number[] {
|
|
||||||
return [0.5, 0.7, 1.0, 1.2, 1.5, 2.0];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPlaybackRateLabel(rate: number): string {
|
|
||||||
return `${rate}x`;
|
|
||||||
}
|
|
||||||
@@ -60,11 +60,12 @@ export function SaveList({ show = false, handleUse }: SaveListProps) {
|
|||||||
const [data, setData] = useState(getFromLocalStorage());
|
const [data, setData] = useState(getFromLocalStorage());
|
||||||
const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => {
|
const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => {
|
||||||
const current_data = getFromLocalStorage();
|
const current_data = getFromLocalStorage();
|
||||||
|
if (!current_data) return;
|
||||||
|
|
||||||
current_data.splice(
|
const index = current_data.findIndex((v) => v.text === item.text);
|
||||||
current_data.findIndex((v) => v.text === item.text),
|
if (index === -1) return;
|
||||||
1,
|
|
||||||
);
|
current_data.splice(index, 1);
|
||||||
setIntoLocalStorage(current_data);
|
setIntoLocalStorage(current_data);
|
||||||
refresh();
|
refresh();
|
||||||
};
|
};
|
||||||
@@ -78,33 +79,25 @@ export function SaveList({ show = false, handleUse }: SaveListProps) {
|
|||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (show)
|
if (show && data)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="my-4 p-2 mx-4 md:mx-32 border border-gray-200 rounded-lg"
|
className="my-4 p-2 mx-4 md:mx-32 border border-gray-200 rounded-lg"
|
||||||
style={{ fontFamily: "Times New Roman, serif" }}
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-row justify-center gap-8 items-center">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<IconClick
|
<p className="text-sm text-gray-600">{t("saved")}</p>
|
||||||
src={IMAGES.refresh}
|
<button
|
||||||
alt="refresh"
|
|
||||||
onClick={refresh}
|
|
||||||
size="lg"
|
|
||||||
className=""
|
|
||||||
></IconClick>
|
|
||||||
<IconClick
|
|
||||||
src={IMAGES.delete}
|
|
||||||
alt="delete"
|
|
||||||
onClick={handleDeleteAll}
|
onClick={handleDeleteAll}
|
||||||
size="lg"
|
className="text-xs text-gray-500 hover:text-gray-800"
|
||||||
className=""
|
>
|
||||||
></IconClick>
|
{t("clearAll")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul className="divide-y divide-gray-100">
|
||||||
{data.map((v) => (
|
{data.map((item, i) => (
|
||||||
<TextCard
|
<TextCard
|
||||||
item={v}
|
key={i}
|
||||||
key={crypto.randomUUID()}
|
item={item}
|
||||||
handleUse={handleUse}
|
handleUse={handleUse}
|
||||||
handleDel={handleDel}
|
handleDel={handleDel}
|
||||||
></TextCard>
|
></TextCard>
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ export default function TextSpeakerPage() {
|
|||||||
const handleEnded = () => {
|
const handleEnded = () => {
|
||||||
if (autopause) {
|
if (autopause) {
|
||||||
setPause(true);
|
setPause(true);
|
||||||
} else {
|
} else if (objurlRef.current) {
|
||||||
load(objurlRef.current!);
|
load(objurlRef.current);
|
||||||
play();
|
play();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -187,7 +187,7 @@ export default function TextSpeakerPage() {
|
|||||||
theIPA = tmp_ipa;
|
theIPA = tmp_ipa;
|
||||||
}
|
}
|
||||||
|
|
||||||
const save = getFromLocalStorage();
|
const save = getFromLocalStorage() ?? [];
|
||||||
const oldIndex = save.findIndex((v) => v.text === textRef.current);
|
const oldIndex = save.findIndex((v) => v.text === textRef.current);
|
||||||
if (oldIndex !== -1) {
|
if (oldIndex !== -1) {
|
||||||
const oldItem = save[oldIndex];
|
const oldItem = save[oldIndex];
|
||||||
@@ -293,7 +293,7 @@ export default function TextSpeakerPage() {
|
|||||||
size="lg"
|
size="lg"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAutopause(!autopause);
|
setAutopause(!autopause);
|
||||||
if (objurlRef) {
|
if (objurlRef.current) {
|
||||||
stop();
|
stop();
|
||||||
}
|
}
|
||||||
setPause(true);
|
setPause(true);
|
||||||
|
|||||||
@@ -1,285 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useActionState, startTransition } from "react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { PageLayout } from "@/components/ui/PageLayout";
|
|
||||||
import { Input } from "@/design-system/base/input";
|
|
||||||
import { LightButton, LinkButton } from "@/design-system/base/button";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
import { actionSignIn, actionSignUp, ActionOutputAuth } from "@/modules/auth/auth-action";
|
|
||||||
|
|
||||||
interface AuthFormProps {
|
|
||||||
redirectTo?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthForm({ redirectTo }: AuthFormProps) {
|
|
||||||
const t = useTranslations("auth");
|
|
||||||
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
|
|
||||||
const [clearSignIn, setClearSignIn] = useState(false);
|
|
||||||
const [clearSignUp, setClearSignUp] = useState(false);
|
|
||||||
|
|
||||||
const [signInState, signInActionForm, isSignInPending] = useActionState(
|
|
||||||
async (_prevState: ActionOutputAuth | undefined, formData: FormData) => {
|
|
||||||
if (clearSignIn) {
|
|
||||||
setClearSignIn(false);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return actionSignIn(undefined, formData);
|
|
||||||
},
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
const [signUpState, signUpActionForm, isSignUpPending] = useActionState(
|
|
||||||
async (_prevState: ActionOutputAuth | undefined, formData: FormData) => {
|
|
||||||
if (clearSignUp) {
|
|
||||||
setClearSignUp(false);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return actionSignUp(undefined, formData);
|
|
||||||
},
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
const validateForm = (formData: FormData): boolean => {
|
|
||||||
const newErrors: Record<string, string> = {};
|
|
||||||
|
|
||||||
const identifier = formData.get("identifier") as string;
|
|
||||||
const email = formData.get("email") as string;
|
|
||||||
const username = formData.get("username") as string;
|
|
||||||
const password = formData.get("password") as string;
|
|
||||||
const confirmPassword = formData.get("confirmPassword") as string;
|
|
||||||
|
|
||||||
// 登录模式验证
|
|
||||||
if (mode === 'signin') {
|
|
||||||
if (!identifier) {
|
|
||||||
newErrors.identifier = t("identifierRequired");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 注册模式验证
|
|
||||||
if (!email) {
|
|
||||||
newErrors.email = t("emailRequired");
|
|
||||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
||||||
newErrors.email = t("invalidEmail");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!username) {
|
|
||||||
newErrors.username = t("usernameRequired");
|
|
||||||
} else if (username.length < 3) {
|
|
||||||
newErrors.username = t("usernameTooShort");
|
|
||||||
} else if (!/^[a-zA-Z0-9_]+$/.test(username)) {
|
|
||||||
newErrors.username = t("usernameInvalid");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!password) {
|
|
||||||
newErrors.password = t("passwordRequired");
|
|
||||||
} else if (password.length < 8) {
|
|
||||||
newErrors.password = t("passwordTooShort");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === 'signup') {
|
|
||||||
if (!confirmPassword) {
|
|
||||||
newErrors.confirmPassword = t("confirmPasswordRequired");
|
|
||||||
} else if (password !== confirmPassword) {
|
|
||||||
newErrors.confirmPassword = t("passwordsNotMatch");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors(newErrors);
|
|
||||||
return Object.keys(newErrors).length === 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const formData = new FormData(e.currentTarget);
|
|
||||||
|
|
||||||
// 基本客户端验证
|
|
||||||
if (!validateForm(formData)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加 redirectTo 到 formData
|
|
||||||
if (redirectTo) {
|
|
||||||
formData.append("redirectTo", redirectTo);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 startTransition 包装 action 调用
|
|
||||||
startTransition(() => {
|
|
||||||
// 根据模式调用相应的 action
|
|
||||||
if (mode === 'signin') {
|
|
||||||
signInActionForm(formData);
|
|
||||||
} else {
|
|
||||||
signUpActionForm(formData);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGitHubSignIn = async () => {
|
|
||||||
await authClient.signIn.social({
|
|
||||||
provider: "github",
|
|
||||||
callbackURL: redirectTo || "/"
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentError = mode === 'signin' ? signInState : signUpState;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
{/* 页面标题 */}
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">{t(mode === 'signin' ? 'signIn' : 'signUp')}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 服务器端错误提示 */}
|
|
||||||
{currentError?.message && (
|
|
||||||
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
|
|
||||||
{currentError.message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 登录/注册表单 */}
|
|
||||||
<form onSubmit={handleFormSubmit} className="space-y-4">
|
|
||||||
{/* 邮箱/用户名输入(登录模式)或 用户名输入(注册模式) */}
|
|
||||||
{mode === 'signin' ? (
|
|
||||||
<div>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
name="identifier"
|
|
||||||
placeholder={t("emailOrUsername")}
|
|
||||||
className="w-full px-3 py-2"
|
|
||||||
/>
|
|
||||||
{errors.identifier && (
|
|
||||||
<p className="text-red-500 text-sm mt-1">{errors.identifier}</p>
|
|
||||||
)}
|
|
||||||
{currentError?.errors?.email && (
|
|
||||||
<p className="text-red-500 text-sm mt-1">{currentError.errors.email[0]}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* 用户名输入(仅注册模式) */}
|
|
||||||
<div>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
name="username"
|
|
||||||
placeholder={t("username")}
|
|
||||||
className="w-full px-3 py-2"
|
|
||||||
/>
|
|
||||||
{errors.username && (
|
|
||||||
<p className="text-red-500 text-sm mt-1">{errors.username}</p>
|
|
||||||
)}
|
|
||||||
{currentError?.errors?.username && (
|
|
||||||
<p className="text-red-500 text-sm mt-1">{currentError.errors.username[0]}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 邮箱输入(仅注册模式) */}
|
|
||||||
<div>
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
placeholder={t("email")}
|
|
||||||
className="w-full px-3 py-2"
|
|
||||||
/>
|
|
||||||
{errors.email && (
|
|
||||||
<p className="text-red-500 text-sm mt-1">{errors.email}</p>
|
|
||||||
)}
|
|
||||||
{currentError?.errors?.email && (
|
|
||||||
<p className="text-red-500 text-sm mt-1">{currentError.errors.email[0]}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 密码输入 */}
|
|
||||||
<div>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
name="password"
|
|
||||||
placeholder={t("password")}
|
|
||||||
className="w-full px-3 py-2"
|
|
||||||
/>
|
|
||||||
{errors.password && (
|
|
||||||
<p className="text-red-500 text-sm mt-1">{errors.password}</p>
|
|
||||||
)}
|
|
||||||
{currentError?.errors?.password && (
|
|
||||||
<p className="text-red-500 text-sm mt-1">{currentError.errors.password[0]}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 确认密码输入(仅注册模式显示) */}
|
|
||||||
{mode === 'signup' && (
|
|
||||||
<div>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
name="confirmPassword"
|
|
||||||
placeholder={t("confirmPassword")}
|
|
||||||
className="w-full px-3 py-2"
|
|
||||||
/>
|
|
||||||
{errors.confirmPassword && (
|
|
||||||
<p className="text-red-500 text-sm mt-1">{errors.confirmPassword}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 提交按钮 */}
|
|
||||||
<LightButton
|
|
||||||
type="submit"
|
|
||||||
className={`w-full py-2 ${isSignInPending || isSignUpPending ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
||||||
>
|
|
||||||
{isSignInPending || isSignUpPending
|
|
||||||
? t("loading")
|
|
||||||
: t(mode === 'signin' ? 'signInButton' : 'signUpButton')
|
|
||||||
}
|
|
||||||
</LightButton>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* 第三方登录区域 */}
|
|
||||||
<div className="mt-6">
|
|
||||||
{/* 分隔线 */}
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<div className="w-full border-t border-gray-300"></div>
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-sm">
|
|
||||||
<span className="px-2 bg-white text-gray-500">或</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* GitHub 登录按钮 */}
|
|
||||||
<LightButton
|
|
||||||
onClick={handleGitHubSignIn}
|
|
||||||
className="w-full mt-4 py-2 flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
|
||||||
</svg>
|
|
||||||
{t(mode === 'signin' ? 'signInWithGitHub' : 'signUpWithGitHub')}
|
|
||||||
</LightButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 模式切换链接 */}
|
|
||||||
<div className="mt-6 text-center">
|
|
||||||
<LinkButton
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setMode(mode === 'signin' ? 'signup' : 'signin');
|
|
||||||
setErrors({});
|
|
||||||
// 清除服务器端错误状态
|
|
||||||
if (mode === 'signin') {
|
|
||||||
setClearSignIn(true);
|
|
||||||
} else {
|
|
||||||
setClearSignUp(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{mode === 'signin'
|
|
||||||
? `${t("noAccount")} ${t("signUp")}`
|
|
||||||
: `${t("hasAccount")} ${t("signIn")}`
|
|
||||||
}
|
|
||||||
</LinkButton>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { auth } from "@/auth";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { AuthForm } from "./AuthForm";
|
|
||||||
|
|
||||||
export default async function AuthPage(
|
|
||||||
props: {
|
|
||||||
searchParams: Promise<{ [key: string]: string | string[] | undefined; }>;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const searchParams = await props.searchParams;
|
|
||||||
const redirectTo = searchParams.redirect as string | undefined;
|
|
||||||
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
|
||||||
if (session) {
|
|
||||||
redirect(redirectTo || '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
return <AuthForm redirectTo={redirectTo} />;
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
Folder as Fd,
|
Folder as Fd,
|
||||||
FolderPen,
|
FolderPen,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
|
Globe,
|
||||||
|
Lock,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { CircleButton, LightButton } from "@/design-system/base/button";
|
import { CircleButton, LightButton } from "@/design-system/base/button";
|
||||||
@@ -15,33 +17,87 @@ import { toast } from "sonner";
|
|||||||
import { PageLayout } from "@/components/ui/PageLayout";
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
import { PageHeader } from "@/components/ui/PageHeader";
|
import { PageHeader } from "@/components/ui/PageHeader";
|
||||||
import { CardList } from "@/components/ui/CardList";
|
import { CardList } from "@/components/ui/CardList";
|
||||||
import { actionCreateFolder, actionDeleteFolderById, actionGetFoldersWithTotalPairsByUserId, actionRenameFolderById } from "@/modules/folder/folder-aciton";
|
import {
|
||||||
|
actionCreateFolder,
|
||||||
|
actionDeleteFolderById,
|
||||||
|
actionGetFoldersWithTotalPairsByUserId,
|
||||||
|
actionRenameFolderById,
|
||||||
|
actionSetFolderVisibility,
|
||||||
|
} from "@/modules/folder/folder-action";
|
||||||
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
|
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
|
||||||
|
|
||||||
interface FolderProps {
|
interface FolderCardProps {
|
||||||
folder: TSharedFolderWithTotalPairs;
|
folder: TSharedFolderWithTotalPairs;
|
||||||
refresh: () => void;
|
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 router = useRouter();
|
||||||
const t = useTranslations("folders");
|
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 (
|
return (
|
||||||
<div
|
<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={() => {
|
onClick={() => {
|
||||||
router.push(`/folders/${folder.id}`);
|
router.push(`/folders/${folder.id}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 flex-1">
|
<div className="flex items-center gap-4 flex-1">
|
||||||
<div className="shrink-0">
|
<div className="shrink-0 text-primary-500">
|
||||||
<Fd className="text-gray-600" size="md" />
|
<Fd size={24} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-medium text-gray-900">{folder.name}</h3>
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-sm text-gray-500">
|
<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", {
|
{t("folderInfo", {
|
||||||
id: folder.id,
|
id: folder.id,
|
||||||
name: folder.name,
|
name: folder.name,
|
||||||
@@ -51,142 +107,112 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1 ml-4">
|
||||||
<CircleButton
|
<CircleButton
|
||||||
onClick={(e: React.MouseEvent) => {
|
onClick={handleToggleVisibility}
|
||||||
e.stopPropagation();
|
title={folder.visibility === "PUBLIC" ? t("setPrivate") : t("setPublic")}
|
||||||
const newName = prompt("Input a new name.")?.trim();
|
|
||||||
if (newName && newName.length > 0) {
|
|
||||||
actionRenameFolderById(folder.id, newName)
|
|
||||||
.then(result => {
|
|
||||||
if (result.success) {
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
toast.error(result.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<FolderPen size={16} />
|
{folder.visibility === "PUBLIC" ? (
|
||||||
|
<Lock size={18} />
|
||||||
|
) : (
|
||||||
|
<Globe size={18} />
|
||||||
|
)}
|
||||||
|
</CircleButton>
|
||||||
|
<CircleButton onClick={handleRename}>
|
||||||
|
<FolderPen size={18} />
|
||||||
</CircleButton>
|
</CircleButton>
|
||||||
<CircleButton
|
<CircleButton
|
||||||
onClick={(e: React.MouseEvent) => {
|
onClick={handleDelete}
|
||||||
e.stopPropagation();
|
className="hover:text-red-500 hover:bg-red-50"
|
||||||
const confirm = prompt(t("confirmDelete", { name: folder.name }));
|
|
||||||
if (confirm === folder.name) {
|
|
||||||
actionDeleteFolderById(folder.id)
|
|
||||||
.then(result => {
|
|
||||||
if (result.success) {
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
toast.error(result.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="text-gray-400 hover:text-red-500 hover:bg-red-50"
|
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={18} />
|
||||||
</CircleButton>
|
</CircleButton>
|
||||||
<ChevronRight size={18} className="text-gray-400" />
|
<ChevronRight size={20} className="text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function FoldersClient({ userId }: { userId: string; }) {
|
interface FoldersClientProps {
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FoldersClient({ userId }: FoldersClientProps) {
|
||||||
const t = useTranslations("folders");
|
const t = useTranslations("folders");
|
||||||
const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>(
|
const router = useRouter();
|
||||||
[],
|
const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>([]);
|
||||||
);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
const loadFolders = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const result = await actionGetFoldersWithTotalPairsByUserId(userId);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setFolders(result.data);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
loadFolders();
|
||||||
actionGetFoldersWithTotalPairsByUserId(userId)
|
|
||||||
.then((folders) => {
|
|
||||||
if (folders.success && folders.data) {
|
|
||||||
setFolders(folders.data);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
const updateFolders = async () => {
|
const handleUpdateFolder = (folderId: number, updates: Partial<TSharedFolderWithTotalPairs>) => {
|
||||||
setLoading(true);
|
setFolders((prev) =>
|
||||||
await actionGetFoldersWithTotalPairsByUserId(userId)
|
prev.map((f) => (f.id === folderId ? { ...f, ...updates } : f))
|
||||||
.then(async result => {
|
);
|
||||||
if (!result.success) toast.error(result.message);
|
};
|
||||||
else await actionGetFoldersWithTotalPairsByUserId(userId)
|
|
||||||
.then((folders) => {
|
const handleDeleteFolder = (folderId: number) => {
|
||||||
if (folders.success && folders.data) {
|
setFolders((prev) => prev.filter((f) => f.id !== folderId));
|
||||||
setFolders(folders.data);
|
};
|
||||||
}
|
|
||||||
});
|
const handleCreateFolder = async () => {
|
||||||
});
|
const folderName = prompt(t("enterFolderName"));
|
||||||
setLoading(false);
|
if (!folderName?.trim()) return;
|
||||||
|
|
||||||
|
const result = await actionCreateFolder(userId, folderName.trim());
|
||||||
|
if (result.success) {
|
||||||
|
loadFolders();
|
||||||
|
} else {
|
||||||
|
toast.error(result.message);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||||
|
|
||||||
{/* 新建文件夹按钮 */}
|
<div className="mb-4">
|
||||||
<LightButton
|
<LightButton onClick={handleCreateFolder}>
|
||||||
onClick={async () => {
|
<FolderPlus size={18} />
|
||||||
const folderName = prompt(t("enterFolderName"));
|
{t("newFolder")}
|
||||||
if (!folderName) return;
|
</LightButton>
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await actionCreateFolder(userId, folderName)
|
|
||||||
.then(result => {
|
|
||||||
if (result.success) {
|
|
||||||
updateFolders();
|
|
||||||
} else {
|
|
||||||
toast.error(result.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full border-dashed"
|
|
||||||
>
|
|
||||||
<FolderPlus size={18} />
|
|
||||||
<span>{loading ? t("creating") : t("newFolder")}</span>
|
|
||||||
</LightButton>
|
|
||||||
|
|
||||||
{/* 文件夹列表 */}
|
|
||||||
<div className="mt-4">
|
|
||||||
<CardList>
|
|
||||||
{folders.length === 0 ? (
|
|
||||||
// 空状态
|
|
||||||
<div className="text-center py-12 text-gray-400">
|
|
||||||
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
|
|
||||||
<FolderPlus size="md" className="text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm">{t("noFoldersYet")}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// 文件夹卡片列表
|
|
||||||
<div className="rounded-md border border-gray-200 overflow-hidden">
|
|
||||||
{folders
|
|
||||||
.toSorted((a, b) => a.id - b.id)
|
|
||||||
.map((folder) => (
|
|
||||||
<FolderCard
|
|
||||||
key={folder.id}
|
|
||||||
folder={folder}
|
|
||||||
refresh={updateFolders}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardList>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CardList>
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
|
||||||
|
<p className="text-sm text-gray-500">{t("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">
|
||||||
|
<Fd size={24} className="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm">{t("noFoldersYet")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
folders.map((folder) => (
|
||||||
|
<FolderCard
|
||||||
|
key={folder.id}
|
||||||
|
folder={folder}
|
||||||
|
onUpdateFolder={handleUpdateFolder}
|
||||||
|
onDeleteFolder={handleDeleteFolder}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardList>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useTranslations } from "next-intl";
|
|||||||
import { PageLayout } from "@/components/ui/PageLayout";
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
import { PrimaryButton, CircleButton, LinkButton } from "@/design-system/base/button";
|
import { PrimaryButton, CircleButton, LinkButton } from "@/design-system/base/button";
|
||||||
import { CardList } from "@/components/ui/CardList";
|
import { CardList } from "@/components/ui/CardList";
|
||||||
import { actionCreatePair, actionDeletePairById, actionGetPairsByFolderId } from "@/modules/folder/folder-aciton";
|
import { actionCreatePair, actionDeletePairById, actionGetPairsByFolderId } from "@/modules/folder/folder-action";
|
||||||
import { TSharedPair } from "@/shared/folder-type";
|
import { TSharedPair } from "@/shared/folder-type";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -26,10 +26,14 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
await actionGetPairsByFolderId(folderId)
|
await actionGetPairsByFolderId(folderId)
|
||||||
.then(result => {
|
.then(result => {
|
||||||
if (!result.success || !result.data) throw result.message;
|
if (!result.success || !result.data) {
|
||||||
|
throw new Error(result.message || "Failed to load text pairs");
|
||||||
|
}
|
||||||
return result.data;
|
return result.data;
|
||||||
}).then(setTextPairs)
|
}).then(setTextPairs)
|
||||||
.catch(toast.error)
|
.catch((error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||||
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
@@ -40,10 +44,14 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
|
|||||||
const refreshTextPairs = async () => {
|
const refreshTextPairs = async () => {
|
||||||
await actionGetPairsByFolderId(folderId)
|
await actionGetPairsByFolderId(folderId)
|
||||||
.then(result => {
|
.then(result => {
|
||||||
if (!result.success || !result.data) throw result.message;
|
if (!result.success || !result.data) {
|
||||||
|
throw new Error(result.message || "Failed to refresh text pairs");
|
||||||
|
}
|
||||||
return result.data;
|
return result.data;
|
||||||
}).then(setTextPairs)
|
}).then(setTextPairs)
|
||||||
.catch(toast.error);
|
.catch((error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -119,9 +127,11 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
|
|||||||
onDel={() => {
|
onDel={() => {
|
||||||
actionDeletePairById(textPair.id)
|
actionDeletePairById(textPair.id)
|
||||||
.then(result => {
|
.then(result => {
|
||||||
if (!result.success) throw result.message;
|
if (!result.success) throw new Error(result.message || "Delete failed");
|
||||||
}).then(refreshTextPairs)
|
}).then(refreshTextPairs)
|
||||||
.catch(toast.error);
|
.catch((error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
refreshTextPairs={refreshTextPairs}
|
refreshTextPairs={refreshTextPairs}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { CircleButton } from "@/design-system/base/button";
|
|||||||
import { UpdateTextPairModal } from "./UpdateTextPairModal";
|
import { UpdateTextPairModal } from "./UpdateTextPairModal";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { TSharedPair } from "@/shared/folder-type";
|
import { TSharedPair } from "@/shared/folder-type";
|
||||||
import { actionUpdatePairById } from "@/modules/folder/folder-aciton";
|
import { actionUpdatePairById } from "@/modules/folder/folder-action";
|
||||||
import { ActionInputUpdatePairById } from "@/modules/folder/folder-action-dto";
|
import { ActionInputUpdatePairById } from "@/modules/folder/folder-action-dto";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getTranslations } from "next-intl/server";
|
|||||||
import { InFolder } from "./InFolder";
|
import { InFolder } from "./InFolder";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { actionGetUserIdByFolderId } from "@/modules/folder/folder-aciton";
|
import { actionGetFolderVisibility } from "@/modules/folder/folder-action";
|
||||||
|
|
||||||
export default async function FoldersPage({
|
export default async function FoldersPage({
|
||||||
params,
|
params,
|
||||||
@@ -18,9 +18,19 @@ export default async function FoldersPage({
|
|||||||
redirect("/folders");
|
redirect("/folders");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow non-authenticated users to view folders (read-only mode)
|
const folderInfo = (await actionGetFolderVisibility(Number(folder_id))).data;
|
||||||
const folderUserId = (await actionGetUserIdByFolderId(Number(folder_id))).data;
|
|
||||||
const isOwner = session?.user?.id === folderUserId;
|
if (!folderInfo) {
|
||||||
|
redirect("/folders");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = session?.user?.id === folderInfo.userId;
|
||||||
|
const isPublic = folderInfo.visibility === "PUBLIC";
|
||||||
|
|
||||||
|
if (!isOwner && !isPublic) {
|
||||||
|
redirect("/folders");
|
||||||
|
}
|
||||||
|
|
||||||
const isReadOnly = !isOwner;
|
const isReadOnly = !isOwner;
|
||||||
|
|
||||||
return <InFolder folderId={Number(folder_id)} isReadOnly={isReadOnly} />;
|
return <InFolder folderId={Number(folder_id)} isReadOnly={isReadOnly} />;
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { FoldersClient } from "./FoldersClient";
|
import { FoldersClient } from "./FoldersClient";
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default async function FoldersPage() {
|
export default async function FoldersPage() {
|
||||||
const session = await auth.api.getSession(
|
const session = await auth.api.getSession(
|
||||||
{ headers: await headers() }
|
{ headers: await headers() }
|
||||||
);
|
);
|
||||||
if (!session) redirect(`/auth?redirect=/folders`);
|
|
||||||
|
if (!session) {
|
||||||
|
redirect("/login?redirect=/folders");
|
||||||
|
}
|
||||||
|
|
||||||
return <FoldersClient userId={session.user.id} />;
|
return <FoldersClient userId={session.user.id} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Viewport } from "next";
|
|||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import { Navbar } from "@/components/layout/Navbar";
|
import { Navbar } from "@/components/layout/Navbar";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
width: "device-width",
|
width: "device-width",
|
||||||
@@ -23,11 +24,13 @@ export default async function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={`antialiased`}>
|
<body className={`antialiased`}>
|
||||||
<NextIntlClientProvider>
|
<StrictMode>
|
||||||
<Navbar></Navbar>
|
<NextIntlClientProvider>
|
||||||
{children}
|
<Navbar></Navbar>
|
||||||
<Toaster />
|
{children}
|
||||||
</NextIntlClientProvider>
|
<Toaster />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
</StrictMode>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default async function HomePage() {
|
|||||||
const t = await getTranslations("home");
|
const t = await getTranslations("home");
|
||||||
return (
|
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]">
|
<div className="mb-16 mx-16 md:mx-0 md:max-w-[60dvw]">
|
||||||
<h1 className="text-6xl md:text-9xl mb-8 font-extrabold">
|
<h1 className="text-6xl md:text-9xl mb-8 font-extrabold">
|
||||||
{t("title")}
|
{t("title")}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import { auth } from "@/auth";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
|
|
||||||
export default async function ProfilePage() {
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
redirect("/auth?redirect=/profile");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 已登录,跳转到用户资料页面
|
|
||||||
// 优先使用 username,如果没有则使用 email
|
|
||||||
const username = (session.user.username as string) || (session.user.email as string);
|
|
||||||
redirect(`/users/${username}`);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { LightButton } from "@/design-system/base/button";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
export 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 >;
|
|
||||||
}
|
|
||||||
62
src/auth.ts
62
src/auth.ts
@@ -1,21 +1,57 @@
|
|||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { prismaAdapter } from "better-auth/adapters/prisma";
|
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||||
import { nextCookies } from "better-auth/next-js";
|
import { nextCookies } from "better-auth/next-js";
|
||||||
import { prisma } from "./lib/db";
|
|
||||||
import { username } from "better-auth/plugins";
|
import { username } from "better-auth/plugins";
|
||||||
|
import { createAuthMiddleware, APIError } from "better-auth/api";
|
||||||
|
import { prisma } from "./lib/db";
|
||||||
|
import {
|
||||||
|
sendEmail,
|
||||||
|
generateVerificationEmailHtml,
|
||||||
|
generateResetPasswordEmailHtml,
|
||||||
|
} from "./lib/email";
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
database: prismaAdapter(prisma, {
|
database: prismaAdapter(prisma, {
|
||||||
provider: "postgresql"
|
provider: "postgresql",
|
||||||
|
}),
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
requireEmailVerification: true,
|
||||||
|
sendResetPassword: async ({ user, url }) => {
|
||||||
|
void sendEmail({
|
||||||
|
to: user.email,
|
||||||
|
subject: "重置您的密码 - Learn Languages",
|
||||||
|
html: generateResetPasswordEmailHtml(url, user.name || "用户"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emailVerification: {
|
||||||
|
sendOnSignUp: true,
|
||||||
|
sendVerificationEmail: async ({ user, url }) => {
|
||||||
|
void sendEmail({
|
||||||
|
to: user.email,
|
||||||
|
subject: "验证您的邮箱 - Learn Languages",
|
||||||
|
html: generateVerificationEmailHtml(url, user.name || "用户"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
socialProviders: {
|
||||||
|
github: {
|
||||||
|
clientId: process.env.GITHUB_CLIENT_ID as string,
|
||||||
|
clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [nextCookies(), username()],
|
||||||
|
hooks: {
|
||||||
|
before: createAuthMiddleware(async (ctx) => {
|
||||||
|
if (ctx.path !== "/sign-up/email" && ctx.path !== "/update-user") return;
|
||||||
|
|
||||||
|
const body = ctx.body as { username?: string };
|
||||||
|
if (!body.username || body.username.trim() === "") {
|
||||||
|
throw new APIError("BAD_REQUEST", {
|
||||||
|
message: "Username is required",
|
||||||
|
});
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
emailAndPassword: {
|
},
|
||||||
enabled: true
|
|
||||||
},
|
|
||||||
socialProviders: {
|
|
||||||
github: {
|
|
||||||
clientId: process.env.GITHUB_CLIENT_ID as string,
|
|
||||||
clientSecret: process.env.GITHUB_CLIENT_SECRET as string
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [nextCookies(), username()]
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,49 +26,49 @@ export function LanguageSettings() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="absolute top-10 right-0 rounded-md shadow-md flex flex-col gap-2">
|
<div className="absolute top-10 right-0 rounded-md shadow-md flex flex-col gap-2">
|
||||||
<GhostLightButton
|
<GhostLightButton
|
||||||
className="w-full bg-[#35786f]"
|
className="w-full bg-primary-500"
|
||||||
onClick={() => setLocale("en-US")}
|
onClick={() => setLocale("en-US")}
|
||||||
>
|
>
|
||||||
English
|
English
|
||||||
</GhostLightButton>
|
</GhostLightButton>
|
||||||
<GhostLightButton
|
<GhostLightButton
|
||||||
className="w-full bg-[#35786f]"
|
className="w-full bg-primary-500"
|
||||||
onClick={() => setLocale("zh-CN")}
|
onClick={() => setLocale("zh-CN")}
|
||||||
>
|
>
|
||||||
中文
|
中文
|
||||||
</GhostLightButton>
|
</GhostLightButton>
|
||||||
<GhostLightButton
|
<GhostLightButton
|
||||||
className="w-full bg-[#35786f]"
|
className="w-full bg-primary-500"
|
||||||
onClick={() => setLocale("ja-JP")}
|
onClick={() => setLocale("ja-JP")}
|
||||||
>
|
>
|
||||||
日本語
|
日本語
|
||||||
</GhostLightButton>
|
</GhostLightButton>
|
||||||
<GhostLightButton
|
<GhostLightButton
|
||||||
className="w-full bg-[#35786f]"
|
className="w-full bg-primary-500"
|
||||||
onClick={() => setLocale("ko-KR")}
|
onClick={() => setLocale("ko-KR")}
|
||||||
>
|
>
|
||||||
한국어
|
한국어
|
||||||
</GhostLightButton>
|
</GhostLightButton>
|
||||||
<GhostLightButton
|
<GhostLightButton
|
||||||
className="w-full bg-[#35786f]"
|
className="w-full bg-primary-500"
|
||||||
onClick={() => setLocale("de-DE")}
|
onClick={() => setLocale("de-DE")}
|
||||||
>
|
>
|
||||||
Deutsch
|
Deutsch
|
||||||
</GhostLightButton>
|
</GhostLightButton>
|
||||||
<GhostLightButton
|
<GhostLightButton
|
||||||
className="w-full bg-[#35786f]"
|
className="w-full bg-primary-500"
|
||||||
onClick={() => setLocale("fr-FR")}
|
onClick={() => setLocale("fr-FR")}
|
||||||
>
|
>
|
||||||
Français
|
Français
|
||||||
</GhostLightButton>
|
</GhostLightButton>
|
||||||
<GhostLightButton
|
<GhostLightButton
|
||||||
className="w-full bg-[#35786f]"
|
className="w-full bg-primary-500"
|
||||||
onClick={() => setLocale("it-IT")}
|
onClick={() => setLocale("it-IT")}
|
||||||
>
|
>
|
||||||
Italiano
|
Italiano
|
||||||
</GhostLightButton>
|
</GhostLightButton>
|
||||||
<GhostLightButton
|
<GhostLightButton
|
||||||
className="w-full bg-[#35786f]"
|
className="w-full bg-primary-500"
|
||||||
onClick={() => setLocale("ug-CN")}
|
onClick={() => setLocale("ug-CN")}
|
||||||
>
|
>
|
||||||
ئۇيغۇرچە
|
ئۇيغۇرچە
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { IMAGES } from "@/config/images";
|
import { IMAGES } from "@/config/images";
|
||||||
import { Folder, Home, User } from "lucide-react";
|
import { Compass, Folder, Heart, Home, User } from "lucide-react";
|
||||||
import { LanguageSettings } from "./LanguageSettings";
|
import { LanguageSettings } from "./LanguageSettings";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
@@ -41,6 +41,22 @@ export async function Navbar() {
|
|||||||
<GhostLightButton href="/folders" className="md:hidden! block!" size="md">
|
<GhostLightButton href="/folders" className="md:hidden! block!" size="md">
|
||||||
<Folder size={20} />
|
<Folder size={20} />
|
||||||
</GhostLightButton>
|
</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
|
<GhostLightButton
|
||||||
className="hidden! md:block!"
|
className="hidden! md:block!"
|
||||||
size="md"
|
size="md"
|
||||||
@@ -58,8 +74,8 @@ export async function Navbar() {
|
|||||||
</GhostLightButton>
|
</GhostLightButton>
|
||||||
</>
|
</>
|
||||||
|| <>
|
|| <>
|
||||||
<GhostLightButton href="/auth" className="hidden! md:block!" size="md">{t("sign_in")}</GhostLightButton>
|
<GhostLightButton href="/login" className="hidden! md:block!" size="md">{t("sign_in")}</GhostLightButton>
|
||||||
<GhostLightButton href="/auth" className="md:hidden! block!" size="md">
|
<GhostLightButton href="/login" className="md:hidden! block!" size="md">
|
||||||
<User size={20} />
|
<User size={20} />
|
||||||
</GhostLightButton>
|
</GhostLightButton>
|
||||||
</>;
|
</>;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface CardListProps {
|
|||||||
export function CardList({ children, className = "" }: CardListProps) {
|
export function CardList({ children, className = "" }: CardListProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`max-h-96 overflow-y-auto rounded-lg border-2 border-gray-200 ${className}`}>
|
<div className={`max-h-96 overflow-y-auto rounded-lg border-2 border-gray-200 ${className}`}>
|
||||||
<VStack gap={0}>
|
<VStack gap={0} align="stretch">
|
||||||
{children}
|
{children}
|
||||||
</VStack>
|
</VStack>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
interface RangeInputProps {
|
|
||||||
value: number;
|
|
||||||
max: number;
|
|
||||||
onChange: (value: number) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
className?: string;
|
|
||||||
min?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RangeInput({
|
|
||||||
value,
|
|
||||||
max,
|
|
||||||
onChange,
|
|
||||||
disabled = false,
|
|
||||||
className = "",
|
|
||||||
min = 0,
|
|
||||||
}: RangeInputProps) {
|
|
||||||
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const newValue = parseInt(event.target.value);
|
|
||||||
onChange(newValue);
|
|
||||||
}, [onChange]);
|
|
||||||
|
|
||||||
const progressPercentage = ((value - min) / (max - min)) * 100;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
value={value}
|
|
||||||
onChange={handleChange}
|
|
||||||
disabled={disabled}
|
|
||||||
className={`w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-500 ${
|
|
||||||
disabled ? "opacity-50 cursor-not-allowed" : ""
|
|
||||||
} ${className}`}
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(to right, #374151 0%, #374151 ${progressPercentage}%, #e5e7eb ${progressPercentage}%, #e5e7eb 100%)`
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -19,9 +19,7 @@ export {
|
|||||||
type ButtonSize,
|
type ButtonSize,
|
||||||
type ButtonProps
|
type ButtonProps
|
||||||
} from '@/design-system/base/button';
|
} from '@/design-system/base/button';
|
||||||
|
export { RangeInput, Range, type RangeProps } from '@/design-system/base/range';
|
||||||
// 业务特定组件
|
|
||||||
export { RangeInput } from './RangeInput';
|
|
||||||
export { Container } from './Container';
|
export { Container } from './Container';
|
||||||
export { PageLayout } from './PageLayout';
|
export { PageLayout } from './PageLayout';
|
||||||
export { PageHeader } from './PageHeader';
|
export { PageHeader } from './PageHeader';
|
||||||
|
|||||||
83
src/design-system/AGENTS.md
Normal file
83
src/design-system/AGENTS.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# 设计系统指南
|
||||||
|
|
||||||
|
**生成时间:** 2026-03-08
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
基于 CVA 的可复用 UI 组件库,与业务组件分离。
|
||||||
|
|
||||||
|
## 组件分类
|
||||||
|
|
||||||
|
| 类别 | 路径 | 组件 |
|
||||||
|
|------|------|------|
|
||||||
|
| 基础 | `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 |
|
||||||
|
|
||||||
|
## CVA 模式
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
const buttonVariants = cva("base-styles", {
|
||||||
|
variants: {
|
||||||
|
variant: { primary: "...", secondary: "...", ghost: "..." },
|
||||||
|
size: { sm: "...", md: "...", lg: "..." },
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: "secondary", size: "md" },
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||||
|
```
|
||||||
|
|
||||||
|
## 组件模板
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
"use client";
|
||||||
|
import { forwardRef } from "react";
|
||||||
|
import { cn } from "@/design-system/lib/utils";
|
||||||
|
|
||||||
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, leftIcon, rightIcon, children, ...props }, ref) => (
|
||||||
|
<button ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props}>
|
||||||
|
{leftIcon && <span className="mr-2">{leftIcon}</span>}
|
||||||
|
{children}
|
||||||
|
{rightIcon && <span className="ml-2">{rightIcon}</span>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
export const PrimaryButton = (props: Omit<ButtonProps, "variant">) => <Button variant="primary" {...props} />;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 复合组件
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Card variant="bordered"><CardHeader><CardTitle>标题</CardTitle></CardHeader><CardBody>内容</CardBody></Card>
|
||||||
|
<Modal open={isOpen}><Modal.Header><Modal.Title>标题</Modal.Title></Modal.Header><Modal.Body>内容</Modal.Body></Modal>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 导入方式
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ 显式导入
|
||||||
|
import { Button } from "@/design-system/base/button";
|
||||||
|
import { Card, CardHeader, CardTitle, CardBody } from "@/design-system/base/card";
|
||||||
|
// ❌ 不要创建 barrel export
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工具函数
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { cn } from "@/design-system/lib/utils";
|
||||||
|
<div className={cn("base", condition && "conditional", className)} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## 添加新组件
|
||||||
|
|
||||||
|
1. 确定类别目录
|
||||||
|
2. 创建 `{name}.tsx`,使用 CVA 定义变体
|
||||||
|
3. 添加 `"use client"` + `forwardRef` + `displayName`
|
||||||
|
4. 导出组件、变体类型、快捷组件
|
||||||
@@ -6,34 +6,34 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/design-system/
|
src/design-system/
|
||||||
├── tokens/ # 设计令牌(颜色、间距、字体等)
|
|
||||||
├── lib/ # 工具函数
|
├── lib/ # 工具函数
|
||||||
|
│ └── utils.ts
|
||||||
├── base/ # 基础组件
|
├── base/ # 基础组件
|
||||||
│ ├── button/
|
│ ├── button.tsx
|
||||||
│ ├── input/
|
│ ├── input.tsx
|
||||||
│ ├── textarea/
|
│ ├── textarea.tsx
|
||||||
│ ├── card/
|
│ ├── card.tsx
|
||||||
│ ├── checkbox/
|
│ ├── checkbox.tsx
|
||||||
│ ├── radio/
|
│ ├── radio.tsx
|
||||||
│ ├── switch/
|
│ ├── switch.tsx
|
||||||
│ └── select/
|
│ ├── select.tsx
|
||||||
|
│ └── range.tsx
|
||||||
├── feedback/ # 反馈组件
|
├── feedback/ # 反馈组件
|
||||||
│ ├── alert/
|
│ ├── alert.tsx
|
||||||
│ ├── progress/
|
│ ├── progress.tsx
|
||||||
│ ├── skeleton/
|
│ ├── skeleton.tsx
|
||||||
│ └── toast/
|
│ └── toast.tsx
|
||||||
├── overlay/ # 覆盖组件
|
├── overlay/ # 覆盖组件
|
||||||
│ └── modal/
|
│ └── modal.tsx
|
||||||
├── data-display/ # 数据展示组件
|
├── data-display/ # 数据展示组件
|
||||||
│ ├── badge/
|
│ ├── badge.tsx
|
||||||
│ └── divider/
|
│ └── divider.tsx
|
||||||
├── layout/ # 布局组件
|
├── layout/ # 布局组件
|
||||||
│ ├── container/
|
│ ├── container.tsx
|
||||||
│ ├── grid/
|
│ ├── grid.tsx
|
||||||
│ └── stack/
|
│ └── stack.tsx
|
||||||
├── navigation/ # 导航组件
|
└── navigation/ # 导航组件
|
||||||
│ └── tabs/
|
└── tabs.tsx
|
||||||
└── index.ts # 统一导出
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
@@ -47,10 +47,7 @@ pnpm add class-variance-authority clsx tailwind-merge
|
|||||||
### 导入组件
|
### 导入组件
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// 方式 1: 从主入口导入(简单但 tree-shaking 较差)
|
// 使用显式导入以获得更好的 tree-shaking
|
||||||
import { Button, Input, Card } from '@/design-system';
|
|
||||||
|
|
||||||
// 方式 2: 从子路径导入(更好的 tree-shaking)
|
|
||||||
import { Button } from '@/design-system/base/button';
|
import { Button } from '@/design-system/base/button';
|
||||||
import { Input } from '@/design-system/base/input';
|
import { Input } from '@/design-system/base/input';
|
||||||
import { Card } from '@/design-system/base/card';
|
import { Card } from '@/design-system/base/card';
|
||||||
@@ -59,7 +56,8 @@ import { Card } from '@/design-system/base/card';
|
|||||||
### 使用组件
|
### 使用组件
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Button, Card } from '@/design-system';
|
import { Button } from '@/design-system/base/button';
|
||||||
|
import { Card } from '@/design-system/base/card';
|
||||||
|
|
||||||
export function MyComponent() {
|
export function MyComponent() {
|
||||||
return (
|
return (
|
||||||
@@ -86,6 +84,7 @@ export function MyComponent() {
|
|||||||
| [Radio](#radio) | 单选按钮 | ✅ |
|
| [Radio](#radio) | 单选按钮 | ✅ |
|
||||||
| [Switch](#switch) | 开关 | ✅ |
|
| [Switch](#switch) | 开关 | ✅ |
|
||||||
| [Select](#select) | 下拉选择框 | ✅ |
|
| [Select](#select) | 下拉选择框 | ✅ |
|
||||||
|
| [Range](#range) | 范围滑块 | ✅ |
|
||||||
|
|
||||||
### 反馈组件
|
### 反馈组件
|
||||||
|
|
||||||
@@ -130,7 +129,7 @@ export function MyComponent() {
|
|||||||
按钮组件,支持多种变体和尺寸。
|
按钮组件,支持多种变体和尺寸。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Button } from '@/design-system';
|
import { Button } from '@/design-system/base/button';
|
||||||
|
|
||||||
<Button variant="primary" size="md" onClick={handleClick}>
|
<Button variant="primary" size="md" onClick={handleClick}>
|
||||||
点击我
|
点击我
|
||||||
@@ -148,7 +147,7 @@ import { Button } from '@/design-system';
|
|||||||
输入框组件。
|
输入框组件。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Input } from '@/design-system';
|
import { Input } from '@/design-system/base/input';
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
variant="bordered"
|
variant="bordered"
|
||||||
@@ -166,7 +165,7 @@ import { Input } from '@/design-system';
|
|||||||
多行文本输入组件。
|
多行文本输入组件。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Textarea } from '@/design-system';
|
import { Textarea } from '@/design-system/base/textarea';
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
variant="bordered"
|
variant="bordered"
|
||||||
@@ -182,7 +181,7 @@ import { Textarea } from '@/design-system';
|
|||||||
卡片容器组件。
|
卡片容器组件。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Card, CardHeader, CardTitle, CardBody, CardFooter } from '@/design-system';
|
import { Card, CardHeader, CardTitle, CardBody, CardFooter } from '@/design-system/base/card';
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -206,7 +205,7 @@ import { Card, CardHeader, CardTitle, CardBody, CardFooter } from '@/design-syst
|
|||||||
复选框组件。
|
复选框组件。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Checkbox } from '@/design-system';
|
import { Checkbox } from '@/design-system/base/checkbox';
|
||||||
|
|
||||||
<Checkbox checked={checked} onChange={setChecked}>
|
<Checkbox checked={checked} onChange={setChecked}>
|
||||||
同意条款
|
同意条款
|
||||||
@@ -218,7 +217,7 @@ import { Checkbox } from '@/design-system';
|
|||||||
单选按钮组件。
|
单选按钮组件。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Radio, RadioGroup } from '@/design-system';
|
import { Radio, RadioGroup } from '@/design-system/base/radio';
|
||||||
|
|
||||||
<RadioGroup name="choice" value={value} onChange={setValue}>
|
<RadioGroup name="choice" value={value} onChange={setValue}>
|
||||||
<Radio value="1">选项 1</Radio>
|
<Radio value="1">选项 1</Radio>
|
||||||
@@ -231,17 +230,30 @@ import { Radio, RadioGroup } from '@/design-system';
|
|||||||
开关组件。
|
开关组件。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Switch } from '@/design-system';
|
import { Switch } from '@/design-system/base/switch';
|
||||||
|
|
||||||
<Switch checked={enabled} onChange={setEnabled} />
|
<Switch checked={enabled} onChange={setEnabled} />
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Range
|
||||||
|
|
||||||
|
范围滑块组件。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Range } from '@/design-system/base/range';
|
||||||
|
|
||||||
|
<Range value={50} min={0} max={100} onChange={setValue} />
|
||||||
|
<Range value={75} min={0} max={100} disabled />
|
||||||
|
```
|
||||||
|
|
||||||
|
**别名**: `RangeInput`(向后兼容)
|
||||||
|
|
||||||
### Alert
|
### Alert
|
||||||
|
|
||||||
警告提示组件。
|
警告提示组件。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Alert } from '@/design-system';
|
import { Alert } from '@/design-system/feedback/alert';
|
||||||
|
|
||||||
<Alert variant="success" title="成功">
|
<Alert variant="success" title="成功">
|
||||||
操作成功完成
|
操作成功完成
|
||||||
@@ -255,7 +267,7 @@ import { Alert } from '@/design-system';
|
|||||||
进度条组件。
|
进度条组件。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Progress } from '@/design-system';
|
import { Progress } from '@/design-system/feedback/progress';
|
||||||
|
|
||||||
<Progress value={60} showLabel />
|
<Progress value={60} showLabel />
|
||||||
```
|
```
|
||||||
@@ -265,7 +277,7 @@ import { Progress } from '@/design-system';
|
|||||||
骨架屏组件。
|
骨架屏组件。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Skeleton, TextSkeleton, CardSkeleton } from '@/design-system';
|
import { Skeleton, TextSkeleton, CardSkeleton } from '@/design-system/feedback/skeleton';
|
||||||
|
|
||||||
<Skeleton className="h-4 w-32" />
|
<Skeleton className="h-4 w-32" />
|
||||||
<TextSkeleton lines={3} />
|
<TextSkeleton lines={3} />
|
||||||
@@ -277,7 +289,7 @@ import { Skeleton, TextSkeleton, CardSkeleton } from '@/design-system';
|
|||||||
通知提示组件(基于 sonner)。
|
通知提示组件(基于 sonner)。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { toast } from '@/design-system';
|
import { toast } from '@/design-system/feedback/toast';
|
||||||
|
|
||||||
toast.success("操作成功!");
|
toast.success("操作成功!");
|
||||||
toast.error("发生错误");
|
toast.error("发生错误");
|
||||||
@@ -293,7 +305,7 @@ toast.promise(promise, {
|
|||||||
模态框组件。
|
模态框组件。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Modal } from '@/design-system';
|
import { Modal } from '@/design-system/overlay/modal';
|
||||||
|
|
||||||
<Modal open={open} onClose={() => setOpen(false)}>
|
<Modal open={open} onClose={() => setOpen(false)}>
|
||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
@@ -316,7 +328,7 @@ import { Modal } from '@/design-system';
|
|||||||
徽章组件。
|
徽章组件。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Badge } from '@/design-system';
|
import { Badge } from '@/design-system/data-display/badge';
|
||||||
|
|
||||||
<Badge variant="success">成功</Badge>
|
<Badge variant="success">成功</Badge>
|
||||||
<Badge dot />
|
<Badge dot />
|
||||||
@@ -329,7 +341,7 @@ import { Badge } from '@/design-system';
|
|||||||
分隔线组件。
|
分隔线组件。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Divider } from '@/design-system';
|
import { Divider } from '@/design-system/data-display/divider';
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
<Divider>或者</Divider>
|
<Divider>或者</Divider>
|
||||||
@@ -341,7 +353,7 @@ import { Divider } from '@/design-system';
|
|||||||
容器组件。
|
容器组件。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Container } from '@/design-system';
|
import { Container } from '@/design-system/layout/container';
|
||||||
|
|
||||||
<Container size="lg" padding="xl">
|
<Container size="lg" padding="xl">
|
||||||
<p>内容</p>
|
<p>内容</p>
|
||||||
@@ -353,7 +365,7 @@ import { Container } from '@/design-system';
|
|||||||
网格布局组件。
|
网格布局组件。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Grid } from '@/design-system';
|
import { Grid } from '@/design-system/layout/grid';
|
||||||
|
|
||||||
<Grid cols={3} gap={4}>
|
<Grid cols={3} gap={4}>
|
||||||
<div>项目 1</div>
|
<div>项目 1</div>
|
||||||
@@ -367,7 +379,7 @@ import { Grid } from '@/design-system';
|
|||||||
堆叠布局组件。
|
堆叠布局组件。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Stack, VStack, HStack } from '@/design-system';
|
import { Stack, VStack, HStack } from '@/design-system/layout/stack';
|
||||||
|
|
||||||
<VStack gap={4}>
|
<VStack gap={4}>
|
||||||
<div>项目 1</div>
|
<div>项目 1</div>
|
||||||
@@ -380,7 +392,7 @@ import { Stack, VStack, HStack } from '@/design-system';
|
|||||||
标签页组件。
|
标签页组件。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Tabs } from '@/design-system';
|
import { Tabs } from '@/design-system/navigation/tabs';
|
||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
@@ -401,17 +413,6 @@ import { Tabs } from '@/design-system';
|
|||||||
### 颜色
|
### 颜色
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { colors } from '@/design-system/tokens';
|
|
||||||
|
|
||||||
// 主色
|
|
||||||
colors.primary.500 // #35786f
|
|
||||||
|
|
||||||
// 语义色
|
|
||||||
colors.success.500 // #22c55e
|
|
||||||
colors.warning.500 // #f59e0b
|
|
||||||
colors.error.500 // #ef4444
|
|
||||||
colors.info.500 // #3b82f6
|
|
||||||
```
|
|
||||||
|
|
||||||
在组件中使用:
|
在组件中使用:
|
||||||
|
|
||||||
@@ -464,7 +465,7 @@ colors.info.500 // #3b82f6
|
|||||||
合并 Tailwind CSS 类名的工具函数。
|
合并 Tailwind CSS 类名的工具函数。
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { cn } from '@/design-system';
|
import { cn } from '@/design-system/lib/utils';
|
||||||
|
|
||||||
const className = cn(
|
const className = cn(
|
||||||
'base-class',
|
'base-class',
|
||||||
@@ -480,11 +481,10 @@ const className = cn(
|
|||||||
对于更好的 tree-shaking,建议从子路径导入:
|
对于更好的 tree-shaking,建议从子路径导入:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// ✅ 推荐
|
// ✅ 使用显式导入
|
||||||
import { Button } from '@/design-system/base/button';
|
import { Button } from '@/design-system/base/button';
|
||||||
|
import { Input } from '@/design-system/base/input';
|
||||||
// ❌ 不推荐(但也可以)
|
import { Card } from '@/design-system/base/card';
|
||||||
import { Button } from '@/design-system';
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 样式覆盖
|
### 2. 样式覆盖
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import React from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { cn } from "@/design-system/lib/utils";
|
import { cn } from "@/utils/cn";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Button 组件
|
* Button 组件
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './button';
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user