Compare commits
32 Commits
be3eb17490
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| b8cb884e9e | |||
| 73d0b0d5fe | |||
| fe5e8533b5 | |||
| 12eb5c412a | |||
| 3635fbd256 | |||
| 058ecf7e39 | |||
| 6c7095ffb3 | |||
| 8ed9b011f4 | |||
| 2537b9fe75 | |||
| 5e24fa76a3 | |||
| 9d42a45bb1 | |||
| d5dde77ee9 | |||
| c4a9247cad | |||
| 56552863bf | |||
| 0af99b6b70 | |||
| eaf97b8279 | |||
| 76749549ff | |||
| fa6301538b | |||
| d4d5a53747 | |||
| ec265be26b | |||
| 804baa64b2 | |||
| a1e42127e6 | |||
| f1d706e20c | |||
| c7cdf40f2f | |||
| a55e763525 | |||
| 9715844eae | |||
| 504ecd259d | |||
| 06e90687f1 | |||
| b093ed2b4f | |||
| 37e221d8b8 | |||
| f1dcd5afaa | |||
| 66d17df59d |
@@ -35,3 +35,5 @@ build.sh
|
|||||||
|
|
||||||
# prisma
|
# prisma
|
||||||
/generated/prisma
|
/generated/prisma
|
||||||
|
|
||||||
|
.claude
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -50,3 +50,5 @@ test.js
|
|||||||
/generated/prisma
|
/generated/prisma
|
||||||
|
|
||||||
certificates
|
certificates
|
||||||
|
|
||||||
|
.claude
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -6,5 +6,8 @@
|
|||||||
},
|
},
|
||||||
"[typescriptreact]": {
|
"[typescriptreact]": {
|
||||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||||
|
},
|
||||||
|
"[css]": {
|
||||||
|
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
69
CLAUDE.md
69
CLAUDE.md
@@ -22,9 +22,7 @@ pnpm run start
|
|||||||
pnpm run lint
|
pnpm run lint
|
||||||
|
|
||||||
# 数据库操作
|
# 数据库操作
|
||||||
pnpm prisma generate # 生成 Prisma client 到 src/generated/prisma
|
# 不要进行数据库操作,让用户操作数据库
|
||||||
pnpm prisma db push # 推送 schema 变更到数据库
|
|
||||||
pnpm prisma studio # 打开 Prisma Studio 查看数据库
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
@@ -36,7 +34,7 @@ pnpm prisma studio # 打开 Prisma Studio 查看数据库
|
|||||||
- **PostgreSQL** + **Prisma ORM**(自定义输出目录:`src/generated/prisma`)
|
- **PostgreSQL** + **Prisma ORM**(自定义输出目录:`src/generated/prisma`)
|
||||||
- **better-auth** 身份验证(邮箱/密码 + OAuth)
|
- **better-auth** 身份验证(邮箱/密码 + OAuth)
|
||||||
- **next-intl** 国际化(支持:en-US, zh-CN, ja-JP, ko-KR, de-DE, fr-FR, it-IT, ug-CN)
|
- **next-intl** 国际化(支持:en-US, zh-CN, ja-JP, ko-KR, de-DE, fr-FR, it-IT, ug-CN)
|
||||||
- **edge-tts-universal** 文本转语音
|
- **阿里云千问 TTS** (qwen3-tts-flash) 文本转语音
|
||||||
- **pnpm** 包管理器
|
- **pnpm** 包管理器
|
||||||
|
|
||||||
## 架构设计
|
## 架构设计
|
||||||
@@ -51,10 +49,36 @@ src/app/
|
|||||||
│ └── [locale]/ # 国际化路由
|
│ └── [locale]/ # 国际化路由
|
||||||
├── auth/ # 认证页面(sign-in, sign-up)
|
├── auth/ # 认证页面(sign-in, sign-up)
|
||||||
├── folders/ # 用户学习文件夹管理
|
├── folders/ # 用户学习文件夹管理
|
||||||
├── api/ # API 路由
|
├── users/[username]/# 用户资料页面(Server Component)
|
||||||
└── profile/ # 用户资料页面
|
├── 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
|
### 数据库 Schema
|
||||||
|
|
||||||
核心模型(见 [prisma/schema.prisma](prisma/schema.prisma)):
|
核心模型(见 [prisma/schema.prisma](prisma/schema.prisma)):
|
||||||
@@ -76,31 +100,6 @@ src/app/
|
|||||||
|
|
||||||
**LLM 集成**: 使用智谱 AI API 进行翻译和 IPA 生成。通过环境变量 `ZHIPU_API_KEY` 和 `ZHIPU_MODEL_NAME` 配置。
|
**LLM 集成**: 使用智谱 AI API 进行翻译和 IPA 生成。通过环境变量 `ZHIPU_API_KEY` 和 `ZHIPU_MODEL_NAME` 配置。
|
||||||
|
|
||||||
### 环境变量
|
|
||||||
|
|
||||||
需要在 `.env.local` 中配置:
|
|
||||||
|
|
||||||
```env
|
|
||||||
# LLM 集成
|
|
||||||
ZHIPU_API_KEY=your-api-key
|
|
||||||
ZHIPU_MODEL_NAME=your-model-name
|
|
||||||
|
|
||||||
# 认证
|
|
||||||
BETTER_AUTH_SECRET=your-secret
|
|
||||||
BETTER_AUTH_URL=http://localhost:3000
|
|
||||||
GITHUB_CLIENT_ID=your-client-id
|
|
||||||
GITHUB_CLIENT_SECRET=your-client-secret
|
|
||||||
|
|
||||||
# 数据库
|
|
||||||
DATABASE_URL=postgresql://username:password@localhost:5432/database_name
|
|
||||||
|
|
||||||
// DashScore
|
|
||||||
DASHSCORE_API_KEY=
|
|
||||||
```
|
|
||||||
|
|
||||||
## 重要配置细节
|
|
||||||
|
|
||||||
- **Prisma client 输出**: 自定义目录 `src/generated/prisma`(不是默认的 `node_modules/.prisma`)
|
|
||||||
- **Standalone 输出**: 为 Docker 部署配置
|
- **Standalone 输出**: 为 Docker 部署配置
|
||||||
- **React Compiler**: 在 `next.config.ts` 中启用以自动优化
|
- **React Compiler**: 在 `next.config.ts` 中启用以自动优化
|
||||||
- **HTTPS 开发**: 开发服务器使用 `--experimental-https` 标志
|
- **HTTPS 开发**: 开发服务器使用 `--experimental-https` 标志
|
||||||
@@ -108,18 +107,22 @@ DASHSCORE_API_KEY=
|
|||||||
|
|
||||||
## 代码组织
|
## 代码组织
|
||||||
|
|
||||||
- `src/lib/actions/`: 数据库变更的 Server Actions
|
- `src/modules/`: 业务模块(auth, folder, dictionary, translator)
|
||||||
|
- `src/lib/actions/`: 数据库变更的 Server Actions(旧架构,正在迁移到 modules)
|
||||||
- `src/lib/server/`: 服务端工具(AI 集成、认证、翻译器)
|
- `src/lib/server/`: 服务端工具(AI 集成、认证、翻译器)
|
||||||
- `src/lib/browser/`: 客户端工具
|
- `src/lib/browser/`: 客户端工具
|
||||||
- `src/hooks/`: 自定义 React hooks(认证 hooks、会话管理)
|
- `src/hooks/`: 自定义 React hooks(认证 hooks、会话管理)
|
||||||
- `src/i18n/`: 国际化配置
|
- `src/i18n/`: 国际化配置
|
||||||
- `messages/`: 各支持语言的翻译文件
|
- `messages/`: 各支持语言的翻译文件
|
||||||
- `src/components/`: 可复用的 UI 组件(buttons, cards 等)
|
- `src/components/`: 可复用的 UI 组件(buttons, cards 等)
|
||||||
|
- `src/shared/`: 共享常量和类型定义
|
||||||
|
|
||||||
## 开发注意事项
|
## 开发注意事项
|
||||||
|
|
||||||
- 使用 pnpm,而不是 npm 或 yarn
|
- 使用 pnpm,而不是 npm 或 yarn
|
||||||
- schema 变更后,先运行 `pnpm prisma generate` 再运行 `pnpm prisma db push`
|
|
||||||
- 应用使用 TypeScript 严格模式 - 确保类型安全
|
- 应用使用 TypeScript 严格模式 - 确保类型安全
|
||||||
- 所有面向用户的文本都需要国际化
|
- 所有面向用户的文本都需要国际化
|
||||||
|
- **优先使用 Server Components**,只在需要交互时使用 Client Components
|
||||||
|
- **新功能应遵循 action-service-repository 架构**
|
||||||
- Better-auth 处理会话管理 - 使用 authClient 适配器进行认证操作
|
- Better-auth 处理会话管理 - 使用 authClient 适配器进行认证操作
|
||||||
|
- 使用 better-auth username 插件支持用户名登录
|
||||||
|
|||||||
45
README.md
45
README.md
@@ -9,7 +9,9 @@
|
|||||||
- **SRT字幕播放器** - 结合视频字幕学习,支持多种字幕格式
|
- **SRT字幕播放器** - 结合视频字幕学习,支持多种字幕格式
|
||||||
- **字母学习模块** - 针对初学者的字母和发音基础学习
|
- **字母学习模块** - 针对初学者的字母和发音基础学习
|
||||||
- **记忆强化工具** - 通过科学记忆法巩固学习内容
|
- **记忆强化工具** - 通过科学记忆法巩固学习内容
|
||||||
|
- **词典查询** - 查询单词和短语,提供详细释义和例句
|
||||||
- **个人学习空间** - 用户可以创建、管理和组织自己的学习资料
|
- **个人学习空间** - 用户可以创建、管理和组织自己的学习资料
|
||||||
|
- **用户资料系统** - 支持用户名登录、个人资料页面展示
|
||||||
|
|
||||||
## 🛠 技术栈
|
## 🛠 技术栈
|
||||||
|
|
||||||
@@ -26,7 +28,7 @@
|
|||||||
|
|
||||||
### 国际化与辅助功能
|
### 国际化与辅助功能
|
||||||
- **next-intl** - 国际化解决方案
|
- **next-intl** - 国际化解决方案
|
||||||
- **qwen3-tts-flash** - 通义千问语音合成
|
- **阿里云千问 TTS** - qwen3-tts-flash 语音合成
|
||||||
|
|
||||||
### 开发工具
|
### 开发工具
|
||||||
- **ESLint** - 代码质量检查
|
- **ESLint** - 代码质量检查
|
||||||
@@ -38,8 +40,16 @@
|
|||||||
src/
|
src/
|
||||||
├── app/ # Next.js App Router 路由
|
├── app/ # Next.js App Router 路由
|
||||||
│ ├── (features)/ # 功能模块路由
|
│ ├── (features)/ # 功能模块路由
|
||||||
│ ├── api/ # API 路由
|
│ ├── auth/ # 认证相关页面
|
||||||
│ └── auth/ # 认证相关页面
|
│ ├── profile/ # 用户资料重定向
|
||||||
|
│ ├── users/[username]/ # 用户资料页面
|
||||||
|
│ ├── folders/ # 文件夹管理
|
||||||
|
│ └── api/ # API 路由
|
||||||
|
├── modules/ # 业务模块(action-service-repository 架构)
|
||||||
|
│ ├── auth/ # 认证模块
|
||||||
|
│ ├── folder/ # 文件夹模块
|
||||||
|
│ ├── dictionary/ # 词典模块
|
||||||
|
│ └── translator/ # 翻译模块
|
||||||
├── components/ # React 组件
|
├── components/ # React 组件
|
||||||
│ ├── buttons/ # 按钮组件
|
│ ├── buttons/ # 按钮组件
|
||||||
│ ├── cards/ # 卡片组件
|
│ ├── cards/ # 卡片组件
|
||||||
@@ -50,6 +60,7 @@ src/
|
|||||||
│ └── server/ # 服务器端工具
|
│ └── server/ # 服务器端工具
|
||||||
├── hooks/ # 自定义 React Hooks
|
├── hooks/ # 自定义 React Hooks
|
||||||
├── i18n/ # 国际化配置
|
├── i18n/ # 国际化配置
|
||||||
|
├── shared/ # 共享常量和类型
|
||||||
└── config/ # 应用配置
|
└── config/ # 应用配置
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -57,7 +68,7 @@ src/
|
|||||||
|
|
||||||
### 环境要求
|
### 环境要求
|
||||||
|
|
||||||
- Node.js 24
|
- Node.js 23
|
||||||
- PostgreSQL 数据库
|
- PostgreSQL 数据库
|
||||||
- pnpm (推荐) 或 npm
|
- pnpm (推荐) 或 npm
|
||||||
|
|
||||||
@@ -85,17 +96,20 @@ cp .env.example .env.local
|
|||||||
然后编辑 `.env.local` 文件,配置所有必要的环境变量:
|
然后编辑 `.env.local` 文件,配置所有必要的环境变量:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
// LLM
|
# LLM 集成(智谱 AI 用于翻译和 IPA 生成)
|
||||||
ZHIPU_API_KEY=your-zhipu-api-key
|
ZHIPU_API_KEY=your-zhipu-api-key
|
||||||
ZHIPU_MODEL_NAME=your-zhipu-model-name
|
ZHIPU_MODEL_NAME=your-zhipu-model-name
|
||||||
|
|
||||||
// Auth
|
# 阿里云千问 TTS(文本转语音)
|
||||||
|
DASHSCORE_API_KEY=your-dashscore-api-key
|
||||||
|
|
||||||
|
# 认证
|
||||||
BETTER_AUTH_SECRET=your-better-auth-secret
|
BETTER_AUTH_SECRET=your-better-auth-secret
|
||||||
BETTER_AUTH_URL=http://localhost:3000
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
GITHUB_CLIENT_ID=your-github-client-id
|
GITHUB_CLIENT_ID=your-github-client-id
|
||||||
GITHUB_CLIENT_SECRET=your-github-client-secret
|
GITHUB_CLIENT_SECRET=your-github-client-secret
|
||||||
|
|
||||||
// Database
|
# 数据库
|
||||||
DATABASE_URL=postgresql://username:password@localhost:5432/database_name
|
DATABASE_URL=postgresql://username:password@localhost:5432/database_name
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -118,14 +132,27 @@ pnpm run dev
|
|||||||
|
|
||||||
### 认证系统
|
### 认证系统
|
||||||
|
|
||||||
应用使用 better-auth 提供安全的用户认证系统,支持邮箱/密码登录和第三方登录。
|
应用使用 better-auth 提供安全的用户认证系统,支持:
|
||||||
|
- 邮箱/密码登录和注册
|
||||||
|
- **用户名登录**(可通过用户名或邮箱登录)
|
||||||
|
- GitHub OAuth 第三方登录
|
||||||
|
- 邮箱验证功能
|
||||||
|
|
||||||
|
### 后端架构
|
||||||
|
|
||||||
|
项目采用 **Action-Service-Repository 三层架构**:
|
||||||
|
- **Action 层**:处理 Server Actions、表单验证、重定向
|
||||||
|
- **Service 层**:业务逻辑、better-auth 集成
|
||||||
|
- **Repository 层**:Prisma 数据库操作
|
||||||
|
|
||||||
### 数据模型
|
### 数据模型
|
||||||
|
|
||||||
核心数据模型包括:
|
核心数据模型包括:
|
||||||
- **User** - 用户信息
|
- **User** - 用户信息(支持用户名、邮箱、头像)
|
||||||
- **Folder** - 学习资料文件夹
|
- **Folder** - 学习资料文件夹
|
||||||
- **Pair** - 语言对(翻译对、词汇对等)
|
- **Pair** - 语言对(翻译对、词汇对等)
|
||||||
|
- **Session/Account** - 认证会话追踪
|
||||||
|
- **Verification** - 邮箱验证系统
|
||||||
|
|
||||||
详细模型定义请参考 [prisma/schema.prisma](./prisma/schema.prisma)
|
详细模型定义请参考 [prisma/schema.prisma](./prisma/schema.prisma)
|
||||||
|
|
||||||
|
|||||||
@@ -42,8 +42,17 @@
|
|||||||
"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",
|
||||||
"edit": "Bearbeiten",
|
"edit": "Bearbeiten",
|
||||||
"delete": "Löschen"
|
"delete": "Löschen",
|
||||||
|
"permissionDenied": "Sie haben keine Berechtigung, diese Aktion auszuführen",
|
||||||
|
"error": {
|
||||||
|
"update": "Sie haben keine Berechtigung, dieses Element zu aktualisieren.",
|
||||||
|
"delete": "Sie haben keine Berechtigung, dieses Element zu löschen.",
|
||||||
|
"add": "Sie haben keine Berechtigung, Elemente zu diesem Ordner hinzuzufügen.",
|
||||||
|
"rename": "Sie haben keine Berechtigung, diesen Ordner umzubenennen.",
|
||||||
|
"deleteFolder": "Sie haben keine Berechtigung, diesen Ordner zu löschen."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "Sprachen lernen",
|
"title": "Sprachen lernen",
|
||||||
@@ -172,7 +181,14 @@
|
|||||||
"translateInto": "Übersetzen in",
|
"translateInto": "Übersetzen in",
|
||||||
"chinese": "Chinesisch",
|
"chinese": "Chinesisch",
|
||||||
"english": "Englisch",
|
"english": "Englisch",
|
||||||
|
"french": "Französisch",
|
||||||
|
"german": "Deutsch",
|
||||||
"italian": "Italienisch",
|
"italian": "Italienisch",
|
||||||
|
"japanese": "Japanisch",
|
||||||
|
"korean": "Koreanisch",
|
||||||
|
"portuguese": "Portugiesisch",
|
||||||
|
"russian": "Russisch",
|
||||||
|
"spanish": "Spanisch",
|
||||||
"other": "Andere",
|
"other": "Andere",
|
||||||
"translating": "Übersetzung läuft...",
|
"translating": "Übersetzung läuft...",
|
||||||
"translate": "Übersetzen",
|
"translate": "Übersetzen",
|
||||||
@@ -189,5 +205,54 @@
|
|||||||
"error": "Textpaar konnte nicht zum Ordner hinzugefügt werden"
|
"error": "Textpaar konnte nicht zum Ordner hinzugefügt werden"
|
||||||
},
|
},
|
||||||
"autoSave": "Automatisch speichern"
|
"autoSave": "Automatisch speichern"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"title": "Wörterbuch",
|
||||||
|
"description": "Wörter und Ausdrücke mit detaillierten Definitionen und Beispielen nachschlagen",
|
||||||
|
"searchPlaceholder": "Wort oder Ausdruck zum Nachschlagen eingeben...",
|
||||||
|
"searching": "Suche...",
|
||||||
|
"search": "Suchen",
|
||||||
|
"languageSettings": "Spracheinstellungen",
|
||||||
|
"queryLanguage": "Abfragesprache",
|
||||||
|
"queryLanguageHint": "Welche Sprache hat das Wort/die Phrase, die Sie nachschlagen möchten",
|
||||||
|
"definitionLanguage": "Definitionssprache",
|
||||||
|
"definitionLanguageHint": "In welcher Sprache möchten Sie die Definitionen sehen",
|
||||||
|
"otherLanguagePlaceholder": "Oder eine andere Sprache eingeben...",
|
||||||
|
"currentSettings": "Aktuelle Einstellungen: Abfrage {queryLang}, Definition {definitionLang}",
|
||||||
|
"relookup": "Neu suchen",
|
||||||
|
"saveToFolder": "In Ordner speichern",
|
||||||
|
"loading": "Laden...",
|
||||||
|
"noResults": "Keine Ergebnisse gefunden",
|
||||||
|
"tryOtherWords": "Versuchen Sie andere Wörter oder Ausdrücke",
|
||||||
|
"welcomeTitle": "Willkommen beim Wörterbuch",
|
||||||
|
"welcomeHint": "Geben Sie oben im Suchfeld ein Wort oder einen Ausdruck ein, um zu suchen",
|
||||||
|
"lookupFailed": "Suche fehlgeschlagen, bitte später erneut versuchen",
|
||||||
|
"relookupSuccess": "Erfolgreich neu gesucht",
|
||||||
|
"relookupFailed": "Wörterbuch Neu-Suche fehlgeschlagen",
|
||||||
|
"pleaseLogin": "Bitte melden Sie sich zuerst an",
|
||||||
|
"pleaseCreateFolder": "Bitte erstellen Sie zuerst einen Ordner",
|
||||||
|
"savedToFolder": "Im Ordner gespeichert: {folderName}",
|
||||||
|
"saveFailed": "Speichern fehlgeschlagen, bitte später erneut versuchen"
|
||||||
|
},
|
||||||
|
"user_profile": {
|
||||||
|
"anonymous": "Anonym",
|
||||||
|
"email": "E-Mail",
|
||||||
|
"verified": "Verifiziert",
|
||||||
|
"unverified": "Nicht verifiziert",
|
||||||
|
"accountInfo": "Kontoinformationen",
|
||||||
|
"userId": "Benutzer-ID",
|
||||||
|
"username": "Benutzername",
|
||||||
|
"displayName": "Anzeigename",
|
||||||
|
"notSet": "Nicht festgelegt",
|
||||||
|
"memberSince": "Mitglied seit",
|
||||||
|
"folders": {
|
||||||
|
"title": "Ordner",
|
||||||
|
"noFolders": "Noch keine Ordner",
|
||||||
|
"folderName": "Ordnername",
|
||||||
|
"totalPairs": "Anzahl der Paare",
|
||||||
|
"createdAt": "Erstellt am",
|
||||||
|
"actions": "Aktionen",
|
||||||
|
"view": "Ansehen"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,8 +42,17 @@
|
|||||||
"text2": "Text 2",
|
"text2": "Text 2",
|
||||||
"language1": "Locale 1",
|
"language1": "Locale 1",
|
||||||
"language2": "Locale 2",
|
"language2": "Locale 2",
|
||||||
|
"enterLanguageName": "Please enter language name",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete"
|
"delete": "Delete",
|
||||||
|
"permissionDenied": "You do not have permission to perform this action",
|
||||||
|
"error": {
|
||||||
|
"update": "You do not have permission to update this item.",
|
||||||
|
"delete": "You do not have permission to delete this item.",
|
||||||
|
"add": "You do not have permission to add items to this folder.",
|
||||||
|
"rename": "You do not have permission to rename this folder.",
|
||||||
|
"deleteFolder": "You do not have permission to delete this folder."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "Learn Languages",
|
"title": "Learn Languages",
|
||||||
@@ -90,6 +99,8 @@
|
|||||||
"password": "Password",
|
"password": "Password",
|
||||||
"confirmPassword": "Confirm Password",
|
"confirmPassword": "Confirm Password",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
"username": "Username",
|
||||||
|
"emailOrUsername": "Email or Username",
|
||||||
"signInButton": "Sign In",
|
"signInButton": "Sign In",
|
||||||
"signUpButton": "Sign Up",
|
"signUpButton": "Sign Up",
|
||||||
"noAccount": "Don't have an account?",
|
"noAccount": "Don't have an account?",
|
||||||
@@ -100,7 +111,11 @@
|
|||||||
"passwordTooShort": "Password must be at least 8 characters",
|
"passwordTooShort": "Password must be at least 8 characters",
|
||||||
"passwordsNotMatch": "Passwords do not match",
|
"passwordsNotMatch": "Passwords do not match",
|
||||||
"nameRequired": "Please enter your name",
|
"nameRequired": "Please enter your name",
|
||||||
|
"usernameRequired": "Please enter a username",
|
||||||
|
"usernameTooShort": "Username must be at least 3 characters",
|
||||||
|
"usernameInvalid": "Username can only contain letters, numbers, and underscores",
|
||||||
"emailRequired": "Please enter your email",
|
"emailRequired": "Please enter your email",
|
||||||
|
"identifierRequired": "Please enter your email or username",
|
||||||
"passwordRequired": "Please enter your password",
|
"passwordRequired": "Please enter your password",
|
||||||
"confirmPasswordRequired": "Please confirm your password",
|
"confirmPasswordRequired": "Please confirm your password",
|
||||||
"loading": "Loading..."
|
"loading": "Loading..."
|
||||||
@@ -172,7 +187,14 @@
|
|||||||
"translateInto": "translate into",
|
"translateInto": "translate into",
|
||||||
"chinese": "Chinese",
|
"chinese": "Chinese",
|
||||||
"english": "English",
|
"english": "English",
|
||||||
|
"french": "French",
|
||||||
|
"german": "German",
|
||||||
"italian": "Italian",
|
"italian": "Italian",
|
||||||
|
"japanese": "Japanese",
|
||||||
|
"korean": "Korean",
|
||||||
|
"portuguese": "Portuguese",
|
||||||
|
"russian": "Russian",
|
||||||
|
"spanish": "Spanish",
|
||||||
"other": "Other",
|
"other": "Other",
|
||||||
"translating": "translating...",
|
"translating": "translating...",
|
||||||
"translate": "translate",
|
"translate": "translate",
|
||||||
@@ -189,5 +211,54 @@
|
|||||||
"error": "Failed to add text pair to folder"
|
"error": "Failed to add text pair to folder"
|
||||||
},
|
},
|
||||||
"autoSave": "Auto Save"
|
"autoSave": "Auto Save"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"title": "Dictionary",
|
||||||
|
"description": "Look up words and phrases with detailed definitions and examples",
|
||||||
|
"searchPlaceholder": "Enter a word or phrase to look up...",
|
||||||
|
"searching": "Searching...",
|
||||||
|
"search": "Search",
|
||||||
|
"languageSettings": "Language Settings",
|
||||||
|
"queryLanguage": "Query Language",
|
||||||
|
"queryLanguageHint": "What language is the word/phrase you want to look up",
|
||||||
|
"definitionLanguage": "Definition Language",
|
||||||
|
"definitionLanguageHint": "What language do you want the definitions in",
|
||||||
|
"otherLanguagePlaceholder": "Or enter another language...",
|
||||||
|
"currentSettings": "Current settings: Query {queryLang}, Definition {definitionLang}",
|
||||||
|
"relookup": "Re-search",
|
||||||
|
"saveToFolder": "Save to folder",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"noResults": "No results found",
|
||||||
|
"tryOtherWords": "Try other words or phrases",
|
||||||
|
"welcomeTitle": "Welcome to Dictionary",
|
||||||
|
"welcomeHint": "Enter a word or phrase in the search box above to start looking up",
|
||||||
|
"lookupFailed": "Search failed, please try again later",
|
||||||
|
"relookupSuccess": "Re-searched successfully",
|
||||||
|
"relookupFailed": "Dictionary re-search failed",
|
||||||
|
"pleaseLogin": "Please log in first",
|
||||||
|
"pleaseCreateFolder": "Please create a folder first",
|
||||||
|
"savedToFolder": "Saved to folder: {folderName}",
|
||||||
|
"saveFailed": "Save failed, please try again later"
|
||||||
|
},
|
||||||
|
"user_profile": {
|
||||||
|
"anonymous": "Anonymous",
|
||||||
|
"email": "Email",
|
||||||
|
"verified": "Verified",
|
||||||
|
"unverified": "Unverified",
|
||||||
|
"accountInfo": "Account Information",
|
||||||
|
"userId": "User ID",
|
||||||
|
"username": "Username",
|
||||||
|
"displayName": "Display Name",
|
||||||
|
"notSet": "Not Set",
|
||||||
|
"memberSince": "Member Since",
|
||||||
|
"folders": {
|
||||||
|
"title": "Folders",
|
||||||
|
"noFolders": "No folders yet",
|
||||||
|
"folderName": "Folder Name",
|
||||||
|
"totalPairs": "Total Pairs",
|
||||||
|
"createdAt": "Created At",
|
||||||
|
"actions": "Actions",
|
||||||
|
"view": "View"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,8 +42,17 @@
|
|||||||
"text2": "Texte 2",
|
"text2": "Texte 2",
|
||||||
"language1": "Langue 1",
|
"language1": "Langue 1",
|
||||||
"language2": "Langue 2",
|
"language2": "Langue 2",
|
||||||
|
"enterLanguageName": "Veuillez entrer le nom de la langue",
|
||||||
"edit": "Modifier",
|
"edit": "Modifier",
|
||||||
"delete": "Supprimer"
|
"delete": "Supprimer",
|
||||||
|
"permissionDenied": "Vous n'avez pas la permission d'effectuer cette action",
|
||||||
|
"error": {
|
||||||
|
"update": "Vous n'avez pas la permission de mettre à jour cet élément.",
|
||||||
|
"delete": "Vous n'avez pas la permission de supprimer cet élément.",
|
||||||
|
"add": "Vous n'avez pas la permission d'ajouter des éléments à ce dossier.",
|
||||||
|
"rename": "Vous n'avez pas la permission de renommer ce dossier.",
|
||||||
|
"deleteFolder": "Vous n'avez pas la permission de supprimer ce dossier."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "Apprendre les langues",
|
"title": "Apprendre les langues",
|
||||||
@@ -172,7 +181,14 @@
|
|||||||
"translateInto": "traduire en",
|
"translateInto": "traduire en",
|
||||||
"chinese": "Chinois",
|
"chinese": "Chinois",
|
||||||
"english": "Anglais",
|
"english": "Anglais",
|
||||||
|
"french": "Français",
|
||||||
|
"german": "Allemand",
|
||||||
"italian": "Italien",
|
"italian": "Italien",
|
||||||
|
"japanese": "Japonais",
|
||||||
|
"korean": "Coréen",
|
||||||
|
"portuguese": "Portugais",
|
||||||
|
"russian": "Russe",
|
||||||
|
"spanish": "Espagnol",
|
||||||
"other": "Autre",
|
"other": "Autre",
|
||||||
"translating": "traduction...",
|
"translating": "traduction...",
|
||||||
"translate": "traduire",
|
"translate": "traduire",
|
||||||
@@ -189,5 +205,54 @@
|
|||||||
"error": "Échec de l'ajout de la paire de textes au dossier"
|
"error": "Échec de l'ajout de la paire de textes au dossier"
|
||||||
},
|
},
|
||||||
"autoSave": "Sauvegarde automatique"
|
"autoSave": "Sauvegarde automatique"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"title": "Dictionnaire",
|
||||||
|
"description": "Rechercher des mots et des phrases avec des définitions détaillées et des exemples",
|
||||||
|
"searchPlaceholder": "Entrez un mot ou une phrase à rechercher...",
|
||||||
|
"searching": "Recherche...",
|
||||||
|
"search": "Rechercher",
|
||||||
|
"languageSettings": "Paramètres linguistiques",
|
||||||
|
"queryLanguage": "Langue de requête",
|
||||||
|
"queryLanguageHint": "Quelle est la langue du mot/phrase que vous souhaitez rechercher",
|
||||||
|
"definitionLanguage": "Langue de définition",
|
||||||
|
"definitionLanguageHint": "Dans quelle langue souhaitez-vous voir les définitions",
|
||||||
|
"otherLanguagePlaceholder": "Ou entrez une autre langue...",
|
||||||
|
"currentSettings": "Paramètres actuels : Requête {queryLang}, Définition {definitionLang}",
|
||||||
|
"relookup": "Rechercher à nouveau",
|
||||||
|
"saveToFolder": "Enregistrer dans le dossier",
|
||||||
|
"loading": "Chargement...",
|
||||||
|
"noResults": "Aucun résultat trouvé",
|
||||||
|
"tryOtherWords": "Essayez d'autres mots ou phrases",
|
||||||
|
"welcomeTitle": "Bienvenue dans le dictionnaire",
|
||||||
|
"welcomeHint": "Entrez un mot ou une phrase dans la zone de recherche ci-dessus pour commencer",
|
||||||
|
"lookupFailed": "Recherche échouée, veuillez réessayer plus tard",
|
||||||
|
"relookupSuccess": "Recherche répétée avec succès",
|
||||||
|
"relookupFailed": "Nouvelle recherche de dictionnaire échouée",
|
||||||
|
"pleaseLogin": "Veuillez d'abord vous connecter",
|
||||||
|
"pleaseCreateFolder": "Veuillez d'abord créer un dossier",
|
||||||
|
"savedToFolder": "Enregistré dans le dossier : {folderName}",
|
||||||
|
"saveFailed": "Échec de l'enregistrement, veuillez réessayer plus tard"
|
||||||
|
},
|
||||||
|
"user_profile": {
|
||||||
|
"anonymous": "Anonyme",
|
||||||
|
"email": "E-mail",
|
||||||
|
"verified": "Vérifié",
|
||||||
|
"unverified": "Non vérifié",
|
||||||
|
"accountInfo": "Informations du compte",
|
||||||
|
"userId": "ID utilisateur",
|
||||||
|
"username": "Nom d'utilisateur",
|
||||||
|
"displayName": "Nom d'affichage",
|
||||||
|
"notSet": "Non défini",
|
||||||
|
"memberSince": "Membre depuis",
|
||||||
|
"folders": {
|
||||||
|
"title": "Dossiers",
|
||||||
|
"noFolders": "Aucun dossier pour le moment",
|
||||||
|
"folderName": "Nom du dossier",
|
||||||
|
"totalPairs": "Nombre de paires",
|
||||||
|
"createdAt": "Créé le",
|
||||||
|
"actions": "Actions",
|
||||||
|
"view": "Voir"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,8 +42,17 @@
|
|||||||
"text2": "Testo 2",
|
"text2": "Testo 2",
|
||||||
"language1": "Lingua 1",
|
"language1": "Lingua 1",
|
||||||
"language2": "Lingua 2",
|
"language2": "Lingua 2",
|
||||||
|
"enterLanguageName": "Inserisci il nome della lingua",
|
||||||
"edit": "Modifica",
|
"edit": "Modifica",
|
||||||
"delete": "Elimina"
|
"delete": "Elimina",
|
||||||
|
"permissionDenied": "Non hai il permesso di eseguire questa azione",
|
||||||
|
"error": {
|
||||||
|
"update": "Non hai il permesso di aggiornare questo elemento.",
|
||||||
|
"delete": "Non hai il permesso di eliminare questo elemento.",
|
||||||
|
"add": "Non hai il permesso di aggiungere elementi a questa cartella.",
|
||||||
|
"rename": "Non hai il permesso di rinominare questa cartella.",
|
||||||
|
"deleteFolder": "Non hai il permesso di eliminare questa cartella."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "Impara le lingue",
|
"title": "Impara le lingue",
|
||||||
@@ -172,7 +181,14 @@
|
|||||||
"translateInto": "traduci in",
|
"translateInto": "traduci in",
|
||||||
"chinese": "Cinese",
|
"chinese": "Cinese",
|
||||||
"english": "Inglese",
|
"english": "Inglese",
|
||||||
|
"french": "Francese",
|
||||||
|
"german": "Tedesco",
|
||||||
"italian": "Italiano",
|
"italian": "Italiano",
|
||||||
|
"japanese": "Giapponese",
|
||||||
|
"korean": "Coreano",
|
||||||
|
"portuguese": "Portoghese",
|
||||||
|
"russian": "Russo",
|
||||||
|
"spanish": "Spagnolo",
|
||||||
"other": "Altro",
|
"other": "Altro",
|
||||||
"translating": "traduzione...",
|
"translating": "traduzione...",
|
||||||
"translate": "traduci",
|
"translate": "traduci",
|
||||||
@@ -189,5 +205,54 @@
|
|||||||
"error": "Impossibile aggiungere la coppia di testi alla cartella"
|
"error": "Impossibile aggiungere la coppia di testi alla cartella"
|
||||||
},
|
},
|
||||||
"autoSave": "Salvataggio automatico"
|
"autoSave": "Salvataggio automatico"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"title": "Dizionario",
|
||||||
|
"description": "Cerca parole e frasi con definizioni dettagliate ed esempi",
|
||||||
|
"searchPlaceholder": "Inserisci una parola o frase da cercare...",
|
||||||
|
"searching": "Ricerca...",
|
||||||
|
"search": "Cerca",
|
||||||
|
"languageSettings": "Impostazioni lingua",
|
||||||
|
"queryLanguage": "Lingua di interrogazione",
|
||||||
|
"queryLanguageHint": "Quale è la lingua della parola/frase che vuoi cercare",
|
||||||
|
"definitionLanguage": "Lingua di definizione",
|
||||||
|
"definitionLanguageHint": "In quale lingua vuoi vedere le definizioni",
|
||||||
|
"otherLanguagePlaceholder": "Oppure inserisci un'altra lingua...",
|
||||||
|
"currentSettings": "Impostazioni attuali: Interrogazione {queryLang}, Definizione {definitionLang}",
|
||||||
|
"relookup": "Ricerca di nuovo",
|
||||||
|
"saveToFolder": "Salva nella cartella",
|
||||||
|
"loading": "Caricamento...",
|
||||||
|
"noResults": "Nessun risultato trovato",
|
||||||
|
"tryOtherWords": "Prova altre parole o frasi",
|
||||||
|
"welcomeTitle": "Benvenuto nel dizionario",
|
||||||
|
"welcomeHint": "Inserisci una parola o frase nella casella di ricerca sopra per iniziare",
|
||||||
|
"lookupFailed": "Ricerca fallita, riprova più tardi",
|
||||||
|
"relookupSuccess": "Ricerca ripetuta con successo",
|
||||||
|
"relookupFailed": "Nuova ricerca del dizionario fallita",
|
||||||
|
"pleaseLogin": "Accedi prima",
|
||||||
|
"pleaseCreateFolder": "Crea prima una cartella",
|
||||||
|
"savedToFolder": "Salvato nella cartella: {folderName}",
|
||||||
|
"saveFailed": "Salvataggio fallito, riprova più tardi"
|
||||||
|
},
|
||||||
|
"user_profile": {
|
||||||
|
"anonymous": "Anonimo",
|
||||||
|
"email": "Email",
|
||||||
|
"verified": "Verificato",
|
||||||
|
"unverified": "Non verificato",
|
||||||
|
"accountInfo": "Informazioni account",
|
||||||
|
"userId": "ID utente",
|
||||||
|
"username": "Nome utente",
|
||||||
|
"displayName": "Nome visualizzato",
|
||||||
|
"notSet": "Non impostato",
|
||||||
|
"memberSince": "Membro dal",
|
||||||
|
"folders": {
|
||||||
|
"title": "Cartelle",
|
||||||
|
"noFolders": "Nessuna cartella ancora",
|
||||||
|
"folderName": "Nome cartella",
|
||||||
|
"totalPairs": "Numero di coppie",
|
||||||
|
"createdAt": "Creato il",
|
||||||
|
"actions": "Azioni",
|
||||||
|
"view": "Visualizza"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,8 +42,17 @@
|
|||||||
"text2": "テキスト2",
|
"text2": "テキスト2",
|
||||||
"language1": "言語1",
|
"language1": "言語1",
|
||||||
"language2": "言語2",
|
"language2": "言語2",
|
||||||
|
"enterLanguageName": "言語名を入力してください",
|
||||||
"edit": "編集",
|
"edit": "編集",
|
||||||
"delete": "削除"
|
"delete": "削除",
|
||||||
|
"permissionDenied": "この操作を実行する権限がありません",
|
||||||
|
"error": {
|
||||||
|
"update": "この項目を更新する権限がありません。",
|
||||||
|
"delete": "この項目を削除する権限がありません。",
|
||||||
|
"add": "このフォルダーに項目を追加する権限がありません。",
|
||||||
|
"rename": "このフォルダー名を変更する権限がありません。",
|
||||||
|
"deleteFolder": "このフォルダーを削除する権限がありません。"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "言語を学ぶ",
|
"title": "言語を学ぶ",
|
||||||
@@ -172,7 +181,14 @@
|
|||||||
"translateInto": "翻訳",
|
"translateInto": "翻訳",
|
||||||
"chinese": "中国語",
|
"chinese": "中国語",
|
||||||
"english": "英語",
|
"english": "英語",
|
||||||
|
"french": "フランス語",
|
||||||
|
"german": "ドイツ語",
|
||||||
"italian": "イタリア語",
|
"italian": "イタリア語",
|
||||||
|
"japanese": "日本語",
|
||||||
|
"korean": "韓国語",
|
||||||
|
"portuguese": "ポルトガル語",
|
||||||
|
"russian": "ロシア語",
|
||||||
|
"spanish": "スペイン語",
|
||||||
"other": "その他",
|
"other": "その他",
|
||||||
"translating": "翻訳中...",
|
"translating": "翻訳中...",
|
||||||
"translate": "翻訳",
|
"translate": "翻訳",
|
||||||
@@ -189,5 +205,54 @@
|
|||||||
"error": "テキストペアの追加に失敗しました"
|
"error": "テキストペアの追加に失敗しました"
|
||||||
},
|
},
|
||||||
"autoSave": "自動保存"
|
"autoSave": "自動保存"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"title": "辞書",
|
||||||
|
"description": "詳細な定義と例で単語やフレーズを検索",
|
||||||
|
"searchPlaceholder": "検索する単語やフレーズを入力...",
|
||||||
|
"searching": "検索中...",
|
||||||
|
"search": "検索",
|
||||||
|
"languageSettings": "言語設定",
|
||||||
|
"queryLanguage": "クエリ言語",
|
||||||
|
"queryLanguageHint": "検索する単語/フレーズの言語",
|
||||||
|
"definitionLanguage": "定義言語",
|
||||||
|
"definitionLanguageHint": "定義を表示する言語",
|
||||||
|
"otherLanguagePlaceholder": "または他の言語を入力...",
|
||||||
|
"currentSettings": "現在の設定:クエリ {queryLang}、定義 {definitionLang}",
|
||||||
|
"relookup": "再検索",
|
||||||
|
"saveToFolder": "フォルダに保存",
|
||||||
|
"loading": "読み込み中...",
|
||||||
|
"noResults": "結果が見つかりません",
|
||||||
|
"tryOtherWords": "他の単語やフレーズを試してください",
|
||||||
|
"welcomeTitle": "辞書へようこそ",
|
||||||
|
"welcomeHint": "上の検索ボックスに単語やフレーズを入力して検索を開始",
|
||||||
|
"lookupFailed": "検索に失敗しました。後でもう一度お試しください",
|
||||||
|
"relookupSuccess": "再検索しました",
|
||||||
|
"relookupFailed": "辞書の再検索に失敗しました",
|
||||||
|
"pleaseLogin": "まずログインしてください",
|
||||||
|
"pleaseCreateFolder": "まずフォルダを作成してください",
|
||||||
|
"savedToFolder": "フォルダに保存しました:{folderName}",
|
||||||
|
"saveFailed": "保存に失敗しました。後でもう一度お試しください"
|
||||||
|
},
|
||||||
|
"user_profile": {
|
||||||
|
"anonymous": "匿名",
|
||||||
|
"email": "メールアドレス",
|
||||||
|
"verified": "認証済み",
|
||||||
|
"unverified": "未認証",
|
||||||
|
"accountInfo": "アカウント情報",
|
||||||
|
"userId": "ユーザーID",
|
||||||
|
"username": "ユーザー名",
|
||||||
|
"displayName": "表示名",
|
||||||
|
"notSet": "未設定",
|
||||||
|
"memberSince": "登録日",
|
||||||
|
"folders": {
|
||||||
|
"title": "フォルダー",
|
||||||
|
"noFolders": "フォルダーがありません",
|
||||||
|
"folderName": "フォルダー名",
|
||||||
|
"totalPairs": "テキストペア数",
|
||||||
|
"createdAt": "作成日",
|
||||||
|
"actions": "操作",
|
||||||
|
"view": "表示"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,8 +42,17 @@
|
|||||||
"text2": "텍스트 2",
|
"text2": "텍스트 2",
|
||||||
"language1": "언어 1",
|
"language1": "언어 1",
|
||||||
"language2": "언어 2",
|
"language2": "언어 2",
|
||||||
|
"enterLanguageName": "언어 이름을 입력하세요",
|
||||||
"edit": "편집",
|
"edit": "편집",
|
||||||
"delete": "삭제"
|
"delete": "삭제",
|
||||||
|
"permissionDenied": "이 작업을 수행할 권한이 없습니다",
|
||||||
|
"error": {
|
||||||
|
"update": "이 항목을 업데이트할 권한이 없습니다.",
|
||||||
|
"delete": "이 항목을 삭제할 권한이 없습니다.",
|
||||||
|
"add": "이 폴더에 항목을 추가할 권한이 없습니다.",
|
||||||
|
"rename": "이 폴더 이름을 변경할 권한이 없습니다.",
|
||||||
|
"deleteFolder": "이 폴더를 삭제할 권한이 없습니다."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "언어 학습",
|
"title": "언어 학습",
|
||||||
@@ -172,7 +181,14 @@
|
|||||||
"translateInto": "번역",
|
"translateInto": "번역",
|
||||||
"chinese": "중국어",
|
"chinese": "중국어",
|
||||||
"english": "영어",
|
"english": "영어",
|
||||||
|
"french": "프랑스어",
|
||||||
|
"german": "독일어",
|
||||||
"italian": "이탈리아어",
|
"italian": "이탈리아어",
|
||||||
|
"japanese": "일본어",
|
||||||
|
"korean": "한국어",
|
||||||
|
"portuguese": "포르투갈어",
|
||||||
|
"russian": "러시아어",
|
||||||
|
"spanish": "스페인어",
|
||||||
"other": "기타",
|
"other": "기타",
|
||||||
"translating": "번역 중...",
|
"translating": "번역 중...",
|
||||||
"translate": "번역",
|
"translate": "번역",
|
||||||
@@ -189,5 +205,54 @@
|
|||||||
"error": "텍스트 쌍 추가 실패"
|
"error": "텍스트 쌍 추가 실패"
|
||||||
},
|
},
|
||||||
"autoSave": "자동 저장"
|
"autoSave": "자동 저장"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"title": "사전",
|
||||||
|
"description": "상세한 정의와 예제로 단어 및 구문 검색",
|
||||||
|
"searchPlaceholder": "검색할 단어나 구문을 입력하세요...",
|
||||||
|
"searching": "검색 중...",
|
||||||
|
"search": "검색",
|
||||||
|
"languageSettings": "언어 설정",
|
||||||
|
"queryLanguage": "쿼리 언어",
|
||||||
|
"queryLanguageHint": "검색하려는 단어/구문의 언어",
|
||||||
|
"definitionLanguage": "정의 언어",
|
||||||
|
"definitionLanguageHint": "정의를 표시할 언어",
|
||||||
|
"otherLanguagePlaceholder": "또는 다른 언어를 입력하세요...",
|
||||||
|
"currentSettings": "현재 설정: 쿼리 {queryLang}, 정의 {definitionLang}",
|
||||||
|
"relookup": "재검색",
|
||||||
|
"saveToFolder": "폴더에 저장",
|
||||||
|
"loading": "로드 중...",
|
||||||
|
"noResults": "결과를 찾을 수 없습니다",
|
||||||
|
"tryOtherWords": "다른 단어나 구문을 시도하세요",
|
||||||
|
"welcomeTitle": "사전에 오신 것을 환영합니다",
|
||||||
|
"welcomeHint": "위 검색 상자에 단어나 구문을 입력하여 검색을 시작하세요",
|
||||||
|
"lookupFailed": "검색 실패, 나중에 다시 시도하세요",
|
||||||
|
"relookupSuccess": "재검색했습니다",
|
||||||
|
"relookupFailed": "사전 재검색 실패",
|
||||||
|
"pleaseLogin": "먼저 로그인하세요",
|
||||||
|
"pleaseCreateFolder": "먼저 폴더를 만드세요",
|
||||||
|
"savedToFolder": "폴더에 저장됨: {folderName}",
|
||||||
|
"saveFailed": "저장 실패, 나중에 다시 시도하세요"
|
||||||
|
},
|
||||||
|
"user_profile": {
|
||||||
|
"anonymous": "익명",
|
||||||
|
"email": "이메일",
|
||||||
|
"verified": "인증됨",
|
||||||
|
"unverified": "미인증",
|
||||||
|
"accountInfo": "계정 정보",
|
||||||
|
"userId": "사용자 ID",
|
||||||
|
"username": "사용자명",
|
||||||
|
"displayName": "표시 이름",
|
||||||
|
"notSet": "설정되지 않음",
|
||||||
|
"memberSince": "가입일",
|
||||||
|
"folders": {
|
||||||
|
"title": "폴더",
|
||||||
|
"noFolders": "폴더가 없습니다",
|
||||||
|
"folderName": "폴더 이름",
|
||||||
|
"totalPairs": "텍스트 쌍 수",
|
||||||
|
"createdAt": "생성일",
|
||||||
|
"actions": "작업",
|
||||||
|
"view": "보기"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,8 +42,17 @@
|
|||||||
"text2": "تېكىست 2",
|
"text2": "تېكىست 2",
|
||||||
"language1": "تىل 1",
|
"language1": "تىل 1",
|
||||||
"language2": "تىل 2",
|
"language2": "تىل 2",
|
||||||
|
"enterLanguageName": "تىل نامىنى كىرگۈزۈڭ",
|
||||||
"edit": "تەھرىرلەش",
|
"edit": "تەھرىرلەش",
|
||||||
"delete": "ئۆچۈرۈش"
|
"delete": "ئۆچۈرۈش",
|
||||||
|
"permissionDenied": "بۇ مەشغۇلاتنى ئىجرا قىلىش ھوقۇقىڭىز يوق",
|
||||||
|
"error": {
|
||||||
|
"update": "بۇ تۈرنى يېڭىلاش ھوقۇقىڭىز يوق.",
|
||||||
|
"delete": "بۇ تۈرنى ئۆچۈرۈش ھوقۇقىڭىز يوق.",
|
||||||
|
"add": "بۇ قىسقۇچقا تۈر قوشۇش ھوقۇقىڭىز يوق.",
|
||||||
|
"rename": "بۇ قىسقۇچنىڭ نامىنى ئۆزگەرتىش ھوقۇقىڭىز يوق.",
|
||||||
|
"deleteFolder": "بۇ قىسقۇچنى ئۆچۈرۈش ھوقۇقىڭىز يوق."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "تىل ئۆگىنىڭ",
|
"title": "تىل ئۆگىنىڭ",
|
||||||
@@ -172,7 +181,14 @@
|
|||||||
"translateInto": "تەرجىمە قىلىش",
|
"translateInto": "تەرجىمە قىلىش",
|
||||||
"chinese": "خەنزۇچە",
|
"chinese": "خەنزۇچە",
|
||||||
"english": "ئىنگلىزچە",
|
"english": "ئىنگلىزچە",
|
||||||
|
"french": "فرانسۇزچە",
|
||||||
|
"german": "گېرمانچە",
|
||||||
"italian": "ئىتاليانچە",
|
"italian": "ئىتاليانچە",
|
||||||
|
"japanese": "ياپونچە",
|
||||||
|
"korean": "كورېيەچە",
|
||||||
|
"portuguese": "پورتۇگالچە",
|
||||||
|
"russian": "رۇسچە",
|
||||||
|
"spanish": "ئىسپانچە",
|
||||||
"other": "باشقا",
|
"other": "باشقا",
|
||||||
"translating": "تەرجىمە قىلىۋاتىدۇ...",
|
"translating": "تەرجىمە قىلىۋاتىدۇ...",
|
||||||
"translate": "تەرجىمە قىلىش",
|
"translate": "تەرجىمە قىلىش",
|
||||||
@@ -189,5 +205,54 @@
|
|||||||
"error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇب بولدى"
|
"error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇب بولدى"
|
||||||
},
|
},
|
||||||
"autoSave": "ئاپتوماتىك ساقلاش"
|
"autoSave": "ئاپتوماتىك ساقلاش"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"title": "لۇغەت",
|
||||||
|
"description": "تەپسىلىي ئىلمىيى ۋە مىساللار بىلەن سۆز ۋە ئىبارە ئىزدەش",
|
||||||
|
"searchPlaceholder": "ئىزدەيدىغان سۆز ياكى ئىبارە كىرگۈزۈڭ...",
|
||||||
|
"searching": "ئىزدەۋاتىدۇ...",
|
||||||
|
"search": "ئىزدە",
|
||||||
|
"languageSettings": "تىل تەڭشىكى",
|
||||||
|
"queryLanguage": "سۈرەشتۈرۈش تىلى",
|
||||||
|
"queryLanguageHint": "ئىزدەمدەكچى بولغان سۆز/ئىبارە قايسى تىلدا",
|
||||||
|
"definitionLanguage": "ئىلمىيى تىلى",
|
||||||
|
"definitionLanguageHint": "ئىلمىيىنى قايسى تىلدا كۆرۈشنى ئويلىشىسىز",
|
||||||
|
"otherLanguagePlaceholder": "ياكى باشقا تىل كىرگۈزۈڭ...",
|
||||||
|
"currentSettings": "نۆۋەتتىكى تەڭشەك: سۈرەشتۈرۈش {queryLang}، ئىلمىيى {definitionLang}",
|
||||||
|
"relookup": "قايتا ئىزدە",
|
||||||
|
"saveToFolder": "قىسقۇچقا ساقلا",
|
||||||
|
"loading": "يۈكلىۋاتىدۇ...",
|
||||||
|
"noResults": "نەتىجە تېپىلمىدى",
|
||||||
|
"tryOtherWords": "باشقا سۆز ياكى ئىبارە سىناڭ",
|
||||||
|
"welcomeTitle": "لۇغەتكە مەرھەمەت",
|
||||||
|
"welcomeHint": "ئىزدەشنى باشلاش ئۈچۈن يۇقىرىدىكى ئىزدەش رامكىسىغا سۆز ياكى ئىبارە كىرگۈزۈڭ",
|
||||||
|
"lookupFailed": "ئىزدەش مەغلۇب بولدى، كېيىنرەك قايتا سىناڭ",
|
||||||
|
"relookupSuccess": "مۇۋەپپەقىيەتلىك قايتا ئىزدىدى",
|
||||||
|
"relookupFailed": "لۇغەت قايتا ئىزدىشى مەغلۇب بولدى",
|
||||||
|
"pleaseLogin": "ئاۋۋال تىزىملىتىڭ",
|
||||||
|
"pleaseCreateFolder": "ئاۋۋال قىسقۇچ قۇرۇڭ",
|
||||||
|
"savedToFolder": "قىسقۇچقا ساقلاندى: {folderName}",
|
||||||
|
"saveFailed": "ساقلاش مەغلۇب بولدى، كېيىنرەك قايتا سىناڭ"
|
||||||
|
},
|
||||||
|
"user_profile": {
|
||||||
|
"anonymous": "ئىسىمسىز",
|
||||||
|
"email": "ئېلخەت",
|
||||||
|
"verified": "دەلىللەندى",
|
||||||
|
"unverified": "دەلىتلەنمىدى",
|
||||||
|
"accountInfo": "ھېسابات ئۇچۇرى",
|
||||||
|
"userId": "ئىشلەتكۈچى كودى",
|
||||||
|
"username": "ئىشلەتكۈچى نامى",
|
||||||
|
"displayName": "كۆرسىتىلىدىغان نام",
|
||||||
|
"notSet": "تەڭشەلمىگەن",
|
||||||
|
"memberSince": "تىزىملاتقان ۋاقىت",
|
||||||
|
"folders": {
|
||||||
|
"title": "قىسقۇچلار",
|
||||||
|
"noFolders": "قىسقۇچ يوق",
|
||||||
|
"folderName": "قىسقۇچ نامى",
|
||||||
|
"totalPairs": "تېكىست جۈپ سانى",
|
||||||
|
"createdAt": "قۇرۇلغان ۋاقىت",
|
||||||
|
"actions": "مەشغۇلات",
|
||||||
|
"view": "كۆرۈش"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,8 +42,17 @@
|
|||||||
"text2": "文本2",
|
"text2": "文本2",
|
||||||
"language1": "语言1",
|
"language1": "语言1",
|
||||||
"language2": "语言2",
|
"language2": "语言2",
|
||||||
|
"enterLanguageName": "请输入语言名称",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"delete": "删除"
|
"delete": "删除",
|
||||||
|
"permissionDenied": "您没有权限执行此操作",
|
||||||
|
"error": {
|
||||||
|
"update": "您没有权限更新此项目",
|
||||||
|
"delete": "您没有权限删除此项目",
|
||||||
|
"add": "您没有权限向此文件夹添加项目",
|
||||||
|
"rename": "您没有权限重命名此文件夹",
|
||||||
|
"deleteFolder": "您没有权限删除此文件夹"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "学语言",
|
"title": "学语言",
|
||||||
@@ -90,6 +99,8 @@
|
|||||||
"password": "密码",
|
"password": "密码",
|
||||||
"confirmPassword": "确认密码",
|
"confirmPassword": "确认密码",
|
||||||
"name": "用户名",
|
"name": "用户名",
|
||||||
|
"username": "用户名",
|
||||||
|
"emailOrUsername": "邮箱或用户名",
|
||||||
"signInButton": "登录",
|
"signInButton": "登录",
|
||||||
"signUpButton": "注册",
|
"signUpButton": "注册",
|
||||||
"noAccount": "还没有账户?",
|
"noAccount": "还没有账户?",
|
||||||
@@ -100,7 +111,11 @@
|
|||||||
"passwordTooShort": "密码至少需要8个字符",
|
"passwordTooShort": "密码至少需要8个字符",
|
||||||
"passwordsNotMatch": "两次输入的密码不匹配",
|
"passwordsNotMatch": "两次输入的密码不匹配",
|
||||||
"nameRequired": "请输入用户名",
|
"nameRequired": "请输入用户名",
|
||||||
|
"usernameRequired": "请输入用户名",
|
||||||
|
"usernameTooShort": "用户名至少需要3个字符",
|
||||||
|
"usernameInvalid": "用户名只能包含字母、数字和下划线",
|
||||||
"emailRequired": "请输入邮箱",
|
"emailRequired": "请输入邮箱",
|
||||||
|
"identifierRequired": "请输入邮箱或用户名",
|
||||||
"passwordRequired": "请输入密码",
|
"passwordRequired": "请输入密码",
|
||||||
"confirmPasswordRequired": "请确认密码",
|
"confirmPasswordRequired": "请确认密码",
|
||||||
"loading": "加载中..."
|
"loading": "加载中..."
|
||||||
@@ -172,7 +187,14 @@
|
|||||||
"translateInto": "翻译为",
|
"translateInto": "翻译为",
|
||||||
"chinese": "中文",
|
"chinese": "中文",
|
||||||
"english": "英文",
|
"english": "英文",
|
||||||
|
"french": "法语",
|
||||||
|
"german": "德语",
|
||||||
"italian": "意大利语",
|
"italian": "意大利语",
|
||||||
|
"japanese": "日语",
|
||||||
|
"korean": "韩语",
|
||||||
|
"portuguese": "葡萄牙语",
|
||||||
|
"russian": "俄语",
|
||||||
|
"spanish": "西班牙语",
|
||||||
"other": "其他",
|
"other": "其他",
|
||||||
"translating": "翻译中...",
|
"translating": "翻译中...",
|
||||||
"translate": "翻译",
|
"translate": "翻译",
|
||||||
@@ -189,5 +211,54 @@
|
|||||||
"error": "添加文本对到文件夹失败"
|
"error": "添加文本对到文件夹失败"
|
||||||
},
|
},
|
||||||
"autoSave": "自动保存"
|
"autoSave": "自动保存"
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"title": "词典",
|
||||||
|
"description": "查询单词和短语,提供详细的释义和例句",
|
||||||
|
"searchPlaceholder": "输入要查询的单词或短语...",
|
||||||
|
"searching": "查询中...",
|
||||||
|
"search": "查询",
|
||||||
|
"languageSettings": "语言设置",
|
||||||
|
"queryLanguage": "查询语言",
|
||||||
|
"queryLanguageHint": "你要查询的单词/短语是什么语言",
|
||||||
|
"definitionLanguage": "释义语言",
|
||||||
|
"definitionLanguageHint": "你希望用什么语言查看释义",
|
||||||
|
"otherLanguagePlaceholder": "或输入其他语言...",
|
||||||
|
"currentSettings": "当前设置:查询 {queryLang},释义 {definitionLang}",
|
||||||
|
"relookup": "重新查询",
|
||||||
|
"saveToFolder": "保存到文件夹",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"noResults": "未找到结果",
|
||||||
|
"tryOtherWords": "尝试其他单词或短语",
|
||||||
|
"welcomeTitle": "欢迎使用词典",
|
||||||
|
"welcomeHint": "在上方搜索框中输入单词或短语开始查询",
|
||||||
|
"lookupFailed": "查询失败,请稍后重试",
|
||||||
|
"relookupSuccess": "已重新查询",
|
||||||
|
"relookupFailed": "词典重新查询失败",
|
||||||
|
"pleaseLogin": "请先登录",
|
||||||
|
"pleaseCreateFolder": "请先创建文件夹",
|
||||||
|
"savedToFolder": "已保存到文件夹:{folderName}",
|
||||||
|
"saveFailed": "保存失败,请稍后重试"
|
||||||
|
},
|
||||||
|
"user_profile": {
|
||||||
|
"anonymous": "匿名",
|
||||||
|
"email": "邮箱",
|
||||||
|
"verified": "已验证",
|
||||||
|
"unverified": "未验证",
|
||||||
|
"accountInfo": "账户信息",
|
||||||
|
"userId": "用户ID",
|
||||||
|
"username": "用户名",
|
||||||
|
"displayName": "显示名称",
|
||||||
|
"notSet": "未设置",
|
||||||
|
"memberSince": "注册时间",
|
||||||
|
"folders": {
|
||||||
|
"title": "文件夹",
|
||||||
|
"noFolders": "还没有文件夹",
|
||||||
|
"folderName": "文件夹名称",
|
||||||
|
"totalPairs": "文本对数量",
|
||||||
|
"createdAt": "创建时间",
|
||||||
|
"actions": "操作",
|
||||||
|
"view": "查看"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "learn-languages",
|
"name": "learn-languages",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "GPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --experimental-https",
|
"dev": "next dev --experimental-https",
|
||||||
@@ -15,6 +15,8 @@
|
|||||||
"@prisma/client": "^7.2.0",
|
"@prisma/client": "^7.2.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-auth": "^1.4.10",
|
"better-auth": "^1.4.10",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
@@ -23,6 +25,7 @@
|
|||||||
"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",
|
||||||
"unstorage": "^1.17.3",
|
"unstorage": "^1.17.3",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
|
|||||||
27
pnpm-lock.yaml
generated
27
pnpm-lock.yaml
generated
@@ -24,6 +24,12 @@ importers:
|
|||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.4.10
|
specifier: ^1.4.10
|
||||||
version: 1.4.10(@prisma/client@7.2.0(prisma@7.2.0(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.2)(@prisma/client@5.22.0(prisma@7.2.0(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.2.0(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.2.0(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 1.4.10(@prisma/client@7.2.0(prisma@7.2.0(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.2)(@prisma/client@5.22.0(prisma@7.2.0(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.2.0(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.2.0(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
class-variance-authority:
|
||||||
|
specifier: ^0.7.1
|
||||||
|
version: 0.7.1
|
||||||
|
clsx:
|
||||||
|
specifier: ^2.1.1
|
||||||
|
version: 2.1.1
|
||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^17.2.3
|
specifier: ^17.2.3
|
||||||
version: 17.2.3
|
version: 17.2.3
|
||||||
@@ -48,6 +54,9 @@ importers:
|
|||||||
sonner:
|
sonner:
|
||||||
specifier: ^2.0.7
|
specifier: ^2.0.7
|
||||||
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
tailwind-merge:
|
||||||
|
specifier: ^3.4.0
|
||||||
|
version: 3.4.0
|
||||||
unstorage:
|
unstorage:
|
||||||
specifier: ^1.17.3
|
specifier: ^1.17.3
|
||||||
version: 1.17.3
|
version: 1.17.3
|
||||||
@@ -1480,9 +1489,16 @@ packages:
|
|||||||
citty@0.1.6:
|
citty@0.1.6:
|
||||||
resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==}
|
resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==}
|
||||||
|
|
||||||
|
class-variance-authority@0.7.1:
|
||||||
|
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||||
|
|
||||||
client-only@0.0.1:
|
client-only@0.0.1:
|
||||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||||
|
|
||||||
|
clsx@2.1.1:
|
||||||
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
@@ -3024,6 +3040,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
tailwind-merge@3.4.0:
|
||||||
|
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
|
||||||
|
|
||||||
tailwindcss@4.1.18:
|
tailwindcss@4.1.18:
|
||||||
resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
|
resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
|
||||||
|
|
||||||
@@ -4778,8 +4797,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
consola: 3.4.2
|
consola: 3.4.2
|
||||||
|
|
||||||
|
class-variance-authority@0.7.1:
|
||||||
|
dependencies:
|
||||||
|
clsx: 2.1.1
|
||||||
|
|
||||||
client-only@0.0.1: {}
|
client-only@0.0.1: {}
|
||||||
|
|
||||||
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
@@ -6407,6 +6432,8 @@ snapshots:
|
|||||||
|
|
||||||
supports-preserve-symlinks-flag@1.0.0: {}
|
supports-preserve-symlinks-flag@1.0.0: {}
|
||||||
|
|
||||||
|
tailwind-merge@3.4.0: {}
|
||||||
|
|
||||||
tailwindcss@4.1.18: {}
|
tailwindcss@4.1.18: {}
|
||||||
|
|
||||||
tapable@2.3.0: {}
|
tapable@2.3.0: {}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- 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";
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/*
|
||||||
|
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");
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "pairs" ALTER COLUMN "language1" SET DATA TYPE TEXT,
|
||||||
|
ALTER COLUMN "language2" SET DATA TYPE TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "translation_history" ALTER COLUMN "source_language" SET DATA TYPE TEXT,
|
||||||
|
ALTER COLUMN "target_language" SET DATA TYPE TEXT;
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `dictionary_phrase_id` on the `dictionary_lookups` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `dictionary_word_id` on the `dictionary_lookups` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the `dictionary_phrase_entries` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `dictionary_phrases` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `dictionary_word_entries` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `dictionary_words` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "dictionary_lookups" DROP CONSTRAINT "dictionary_lookups_dictionary_phrase_id_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "dictionary_lookups" DROP CONSTRAINT "dictionary_lookups_dictionary_word_id_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "dictionary_phrase_entries" DROP CONSTRAINT "dictionary_phrase_entries_phrase_id_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "dictionary_word_entries" DROP CONSTRAINT "dictionary_word_entries_word_id_fkey";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "dictionary_lookups_text_query_lang_definition_lang_idx";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "dictionary_lookups" DROP COLUMN "dictionary_phrase_id",
|
||||||
|
DROP COLUMN "dictionary_word_id",
|
||||||
|
ADD COLUMN "dictionary_item_id" INTEGER,
|
||||||
|
ADD COLUMN "normalized_text" TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "dictionary_phrase_entries";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "dictionary_phrases";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "dictionary_word_entries";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "dictionary_words";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "dictionary_items" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"frequency" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"standard_form" TEXT NOT NULL,
|
||||||
|
"query_lang" TEXT NOT NULL,
|
||||||
|
"definition_lang" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "dictionary_items_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "dictionary_entries" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"item_id" INTEGER NOT NULL,
|
||||||
|
"ipa" TEXT,
|
||||||
|
"definition" TEXT NOT NULL,
|
||||||
|
"part_of_speech" TEXT,
|
||||||
|
"example" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "dictionary_entries_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "dictionary_items_standard_form_idx" ON "dictionary_items"("standard_form");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "dictionary_items_query_lang_definition_lang_idx" ON "dictionary_items"("query_lang", "definition_lang");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "dictionary_items_standard_form_query_lang_definition_lang_key" ON "dictionary_items"("standard_form", "query_lang", "definition_lang");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "dictionary_entries_item_id_idx" ON "dictionary_entries"("item_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "dictionary_entries_created_at_idx" ON "dictionary_entries"("created_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "dictionary_lookups_normalized_text_idx" ON "dictionary_lookups"("normalized_text");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_item_id_fkey" FOREIGN KEY ("dictionary_item_id") REFERENCES "dictionary_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "dictionary_entries" ADD CONSTRAINT "dictionary_entries_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "dictionary_items"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[username]` on the table `user` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "user" ADD COLUMN "displayUsername" TEXT,
|
||||||
|
ADD COLUMN "username" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "user_username_key" ON "user"("username");
|
||||||
@@ -10,24 +10,26 @@ datasource db {
|
|||||||
model User {
|
model User {
|
||||||
id String @id
|
id String @id
|
||||||
name String
|
name String
|
||||||
email String
|
email String @unique
|
||||||
emailVerified Boolean @default(false)
|
emailVerified Boolean @default(false)
|
||||||
image String?
|
image String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
sessions Session[]
|
displayUsername String?
|
||||||
|
username String? @unique
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
folders Folder[]
|
|
||||||
dictionaryLookUps DictionaryLookUp[]
|
dictionaryLookUps DictionaryLookUp[]
|
||||||
|
folders Folder[]
|
||||||
|
sessions Session[]
|
||||||
|
translationHistories TranslationHistory[]
|
||||||
|
|
||||||
@@unique([email])
|
|
||||||
@@map("user")
|
@@map("user")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
id String @id
|
id String @id
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
token String
|
token String @unique
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
ipAddress String?
|
ipAddress String?
|
||||||
@@ -35,7 +37,6 @@ model Session {
|
|||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([token])
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@map("session")
|
@@map("session")
|
||||||
}
|
}
|
||||||
@@ -45,7 +46,6 @@ model Account {
|
|||||||
accountId String
|
accountId String
|
||||||
providerId String
|
providerId String
|
||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
accessToken String?
|
accessToken String?
|
||||||
refreshToken String?
|
refreshToken String?
|
||||||
idToken String?
|
idToken String?
|
||||||
@@ -55,6 +55,7 @@ model Account {
|
|||||||
password String?
|
password String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@map("account")
|
@@map("account")
|
||||||
@@ -74,19 +75,18 @@ model Verification {
|
|||||||
|
|
||||||
model Pair {
|
model Pair {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
|
language1 String
|
||||||
|
language2 String
|
||||||
text1 String
|
text1 String
|
||||||
text2 String
|
text2 String
|
||||||
language1 String @db.VarChar(20)
|
|
||||||
language2 String @db.VarChar(20)
|
|
||||||
ipa1 String?
|
ipa1 String?
|
||||||
ipa2 String?
|
ipa2 String?
|
||||||
folderId Int @map("folder_id")
|
folderId Int @map("folder_id")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
|
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([folderId, language1, language2, text1])
|
@@unique([folderId, language1, language2, text1, text2])
|
||||||
@@index([folderId])
|
@@index([folderId])
|
||||||
@@map("pairs")
|
@@map("pairs")
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,6 @@ model Folder {
|
|||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
pairs Pair[]
|
pairs Pair[]
|
||||||
|
|
||||||
@@ -112,81 +111,66 @@ model DictionaryLookUp {
|
|||||||
queryLang String @map("query_lang")
|
queryLang String @map("query_lang")
|
||||||
definitionLang String @map("definition_lang")
|
definitionLang String @map("definition_lang")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
dictionaryWordId Int? @map("dictionary_word_id")
|
dictionaryItemId Int? @map("dictionary_item_id")
|
||||||
dictionaryPhraseId Int? @map("dictionary_phrase_id")
|
normalizedText String @default("") @map("normalized_text")
|
||||||
|
dictionaryItem DictionaryItem? @relation(fields: [dictionaryItemId], references: [id])
|
||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id])
|
||||||
dictionaryWord DictionaryWord? @relation(fields: [dictionaryWordId], references: [id], onDelete: SetNull)
|
|
||||||
dictionaryPhrase DictionaryPhrase? @relation(fields: [dictionaryPhraseId], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([text, queryLang, definitionLang])
|
@@index([normalizedText])
|
||||||
@@map("dictionary_lookups")
|
@@map("dictionary_lookups")
|
||||||
}
|
}
|
||||||
|
|
||||||
model DictionaryWord {
|
model DictionaryItem {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
|
frequency Int @default(1)
|
||||||
standardForm String @map("standard_form")
|
standardForm String @map("standard_form")
|
||||||
queryLang String @map("query_lang")
|
queryLang String @map("query_lang")
|
||||||
definitionLang String @map("definition_lang")
|
definitionLang String @map("definition_lang")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
entries DictionaryEntry[]
|
||||||
lookups DictionaryLookUp[]
|
lookups DictionaryLookUp[]
|
||||||
entries DictionaryWordEntry[]
|
|
||||||
|
|
||||||
@@unique([standardForm, queryLang, definitionLang])
|
@@unique([standardForm, queryLang, definitionLang])
|
||||||
@@index([standardForm])
|
@@index([standardForm])
|
||||||
@@index([queryLang, definitionLang])
|
@@index([queryLang, definitionLang])
|
||||||
@@map("dictionary_words")
|
@@map("dictionary_items")
|
||||||
}
|
}
|
||||||
|
|
||||||
model DictionaryPhrase {
|
model DictionaryEntry {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
standardForm String @map("standard_form")
|
itemId Int @map("item_id")
|
||||||
queryLang String @map("query_lang")
|
ipa String?
|
||||||
definitionLang String @map("definition_lang")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
|
||||||
|
|
||||||
lookups DictionaryLookUp[]
|
|
||||||
entries DictionaryPhraseEntry[]
|
|
||||||
|
|
||||||
@@unique([standardForm, queryLang, definitionLang])
|
|
||||||
@@index([standardForm])
|
|
||||||
@@index([queryLang, definitionLang])
|
|
||||||
@@map("dictionary_phrases")
|
|
||||||
}
|
|
||||||
|
|
||||||
model DictionaryWordEntry {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
wordId Int @map("word_id")
|
|
||||||
ipa String
|
|
||||||
definition String
|
definition String
|
||||||
partOfSpeech String @map("part_of_speech")
|
partOfSpeech String? @map("part_of_speech")
|
||||||
example String
|
example String
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
item DictionaryItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
word DictionaryWord @relation(fields: [wordId], references: [id], onDelete: Cascade)
|
@@index([itemId])
|
||||||
|
|
||||||
@@index([wordId])
|
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@map("dictionary_word_entries")
|
@@map("dictionary_entries")
|
||||||
}
|
}
|
||||||
|
|
||||||
model DictionaryPhraseEntry {
|
model TranslationHistory {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
phraseId Int @map("phrase_id")
|
userId String? @map("user_id")
|
||||||
definition String
|
sourceText String @map("source_text")
|
||||||
example String
|
sourceLanguage String @map("source_language")
|
||||||
|
targetLanguage String @map("target_language")
|
||||||
|
translatedText String @map("translated_text")
|
||||||
|
sourceIpa String? @map("source_ipa")
|
||||||
|
targetIpa String? @map("target_ipa")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
user User? @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
phrase DictionaryPhrase @relation(fields: [phraseId], references: [id], onDelete: Cascade)
|
@@index([userId])
|
||||||
|
|
||||||
@@index([phraseId])
|
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@map("dictionary_phrase_entries")
|
@@index([sourceText, targetLanguage])
|
||||||
|
@@index([translatedText, sourceLanguage, targetLanguage])
|
||||||
|
@@map("translation_history")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
||||||
import { IconClick } from "@/components/ui/buttons";
|
import { IconClick, CircleToggleButton, CircleButton, PrimaryButton } from "@/design-system/base/button";
|
||||||
import IMAGES from "@/config/images";
|
import { IMAGES } from "@/config/images";
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
|
import { Card } from "@/design-system/base/card";
|
||||||
|
|
||||||
interface AlphabetCardProps {
|
interface AlphabetCardProps {
|
||||||
alphabet: Letter[];
|
alphabet: Letter[];
|
||||||
@@ -13,7 +15,7 @@ interface AlphabetCardProps {
|
|||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AlphabetCard({ alphabet, alphabetType, onBack }: AlphabetCardProps) {
|
export function AlphabetCard({ alphabet, alphabetType, onBack }: AlphabetCardProps) {
|
||||||
const t = useTranslations("alphabet");
|
const t = useTranslations("alphabet");
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const [showIPA, setShowIPA] = useState(true);
|
const [showIPA, setShowIPA] = useState(true);
|
||||||
@@ -97,12 +99,11 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4 py-8">
|
<PageLayout className="relative">
|
||||||
<div className="w-full max-w-2xl">
|
{/* 右上角返回按钮 - outside the white card */}
|
||||||
{/* 右上角返回按钮 */}
|
|
||||||
<div className="flex justify-end mb-4">
|
<div className="flex justify-end mb-4">
|
||||||
<IconClick
|
<IconClick
|
||||||
size={32}
|
size="lg"
|
||||||
alt="close"
|
alt="close"
|
||||||
src={IMAGES.close}
|
src={IMAGES.close}
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
@@ -111,7 +112,7 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 白色主卡片容器 */}
|
{/* 白色主卡片容器 */}
|
||||||
<div className="bg-white rounded-2xl shadow-xl p-8 md:p-12">
|
<Card padding="xl">
|
||||||
{/* 顶部进度指示器和显示选项按钮 */}
|
{/* 顶部进度指示器和显示选项按钮 */}
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
{/* 当前字母进度 */}
|
{/* 当前字母进度 */}
|
||||||
@@ -120,51 +121,35 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
|
|||||||
</span>
|
</span>
|
||||||
{/* 显示选项切换按钮组 */}
|
{/* 显示选项切换按钮组 */}
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<button
|
<CircleToggleButton
|
||||||
|
selected={showLetter}
|
||||||
onClick={() => setShowLetter(!showLetter)}
|
onClick={() => setShowLetter(!showLetter)}
|
||||||
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
|
||||||
showLetter
|
|
||||||
? "bg-[#35786f] text-white"
|
|
||||||
: "bg-gray-200 text-gray-600"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{t("letter")}
|
{t("letter")}
|
||||||
</button>
|
</CircleToggleButton>
|
||||||
{/* IPA 音标显示切换 */}
|
{/* IPA 音标显示切换 */}
|
||||||
<button
|
<CircleToggleButton
|
||||||
|
selected={showIPA}
|
||||||
onClick={() => setShowIPA(!showIPA)}
|
onClick={() => setShowIPA(!showIPA)}
|
||||||
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
|
||||||
showIPA
|
|
||||||
? "bg-[#35786f] text-white"
|
|
||||||
: "bg-gray-200 text-gray-600"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
IPA
|
IPA
|
||||||
</button>
|
</CircleToggleButton>
|
||||||
{/* 罗马音显示切换(仅日语显示) */}
|
{/* 罗马音显示切换(仅日语显示) */}
|
||||||
{hasRomanization && (
|
{hasRomanization && (
|
||||||
<button
|
<CircleToggleButton
|
||||||
|
selected={showRoman}
|
||||||
onClick={() => setShowRoman(!showRoman)}
|
onClick={() => setShowRoman(!showRoman)}
|
||||||
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
|
||||||
showRoman
|
|
||||||
? "bg-[#35786f] text-white"
|
|
||||||
: "bg-gray-200 text-gray-600"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{t("roman")}
|
{t("roman")}
|
||||||
</button>
|
</CircleToggleButton>
|
||||||
)}
|
)}
|
||||||
{/* 随机模式切换 */}
|
{/* 随机模式切换 */}
|
||||||
<button
|
<CircleToggleButton
|
||||||
|
selected={isRandomMode}
|
||||||
onClick={() => setIsRandomMode(!isRandomMode)}
|
onClick={() => setIsRandomMode(!isRandomMode)}
|
||||||
className={`px-3 py-1 rounded-full text-sm transition-colors ${
|
|
||||||
isRandomMode
|
|
||||||
? "bg-[#35786f] text-white"
|
|
||||||
: "bg-gray-200 text-gray-600"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{t("random")}
|
{t("random")}
|
||||||
</button>
|
</CircleToggleButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -199,36 +184,28 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
|
|||||||
{/* 底部导航控制区域 */}
|
{/* 底部导航控制区域 */}
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
{/* 上一个按钮 */}
|
{/* 上一个按钮 */}
|
||||||
<button
|
<CircleButton onClick={goToPrevious} aria-label="上一个字母">
|
||||||
onClick={goToPrevious}
|
<ChevronLeft size={20} />
|
||||||
className="p-3 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
|
</CircleButton>
|
||||||
aria-label="上一个字母"
|
|
||||||
>
|
|
||||||
<ChevronLeft size={24} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 中间区域:随机按钮 */}
|
{/* 中间区域:随机按钮 */}
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
{isRandomMode && (
|
{isRandomMode && (
|
||||||
<button
|
<PrimaryButton
|
||||||
onClick={goToRandom}
|
onClick={goToRandom}
|
||||||
className="px-4 py-2 rounded-full bg-[#35786f] text-white text-sm font-medium hover:bg-[#2d5f58] transition-colors"
|
className="rounded-full px-4 py-2 text-sm"
|
||||||
>
|
>
|
||||||
{t("randomNext")}
|
{t("randomNext")}
|
||||||
</button>
|
</PrimaryButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 下一个按钮 */}
|
{/* 下一个按钮 */}
|
||||||
<button
|
<CircleButton onClick={goToNext} aria-label="下一个字母">
|
||||||
onClick={goToNext}
|
<ChevronRight size={20} />
|
||||||
className="p-3 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
|
</CircleButton>
|
||||||
aria-label="下一个字母"
|
|
||||||
>
|
|
||||||
<ChevronRight size={24} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* 底部操作提示文字 */}
|
{/* 底部操作提示文字 */}
|
||||||
<div className="text-center mt-6 text-white text-sm">
|
<div className="text-center mt-6 text-white text-sm">
|
||||||
@@ -239,7 +216,6 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
|
|||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 全屏触摸事件监听层(用于滑动切换) */}
|
{/* 全屏触摸事件监听层(用于滑动切换) */}
|
||||||
<div
|
<div
|
||||||
@@ -248,6 +224,6 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
|
|||||||
onTouchMove={onTouchMove}
|
onTouchMove={onTouchMove}
|
||||||
onTouchEnd={onTouchEnd}
|
onTouchEnd={onTouchEnd}
|
||||||
/>
|
/>
|
||||||
</div>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { LightButton } from "@/components/ui/buttons";
|
import { LightButton } from "@/design-system/base/button";
|
||||||
import { IconClick } from "@/components/ui/buttons";
|
import { IconClick } from "@/design-system/base/button";
|
||||||
import IMAGES from "@/config/images";
|
import { IMAGES } from "@/config/images";
|
||||||
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
||||||
import {
|
import {
|
||||||
Dispatch,
|
Dispatch,
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
export default function MemoryCard({
|
export function MemoryCard({
|
||||||
alphabet,
|
alphabet,
|
||||||
setChosenAlphabet,
|
setChosenAlphabet,
|
||||||
}: {
|
}: {
|
||||||
@@ -45,10 +45,10 @@ export default function MemoryCard({
|
|||||||
className="w-full flex justify-center items-center"
|
className="w-full flex justify-center items-center"
|
||||||
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()}
|
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<div className="m-4 p-4 w-full md:w-[60dvw] flex-col rounded-2xl shadow border-gray-200 border flex justify-center items-center">
|
<div className="m-4 p-4 w-full md:w-[60dvw] flex-col rounded-lg shadow border-gray-200 border flex justify-center items-center">
|
||||||
<div className="w-full flex justify-end items-center">
|
<div className="w-full flex justify-end items-center">
|
||||||
<IconClick
|
<IconClick
|
||||||
size={32}
|
size="lg"
|
||||||
alt="close"
|
alt="close"
|
||||||
src={IMAGES.close}
|
src={IMAGES.close}
|
||||||
onClick={() => setChosenAlphabet(null)}
|
onClick={() => setChosenAlphabet(null)}
|
||||||
@@ -64,13 +64,13 @@ export default function MemoryCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row mt-32 items-center justify-center gap-2">
|
<div className="flex flex-row mt-32 items-center justify-center gap-2">
|
||||||
<IconClick
|
<IconClick
|
||||||
size={48}
|
size="lg"
|
||||||
alt="refresh"
|
alt="refresh"
|
||||||
src={IMAGES.refresh}
|
src={IMAGES.refresh}
|
||||||
onClick={refresh}
|
onClick={refresh}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
<IconClick
|
<IconClick
|
||||||
size={48}
|
size="lg"
|
||||||
alt="more"
|
alt="more"
|
||||||
src={IMAGES.more_horiz}
|
src={IMAGES.more_horiz}
|
||||||
onClick={() => setMore(!more)}
|
onClick={() => setMore(!more)}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
import { Letter, SupportedAlphabets } from "@/lib/interfaces";
|
||||||
import Container from "@/components/ui/Container";
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
import { LightButton } from "@/components/ui/buttons";
|
import { LightButton } from "@/design-system/base/button";
|
||||||
import AlphabetCard from "./AlphabetCard";
|
import { AlphabetCard } from "./AlphabetCard";
|
||||||
|
|
||||||
export default function Alphabet() {
|
export default function Alphabet() {
|
||||||
const t = useTranslations("alphabet");
|
const t = useTranslations("alphabet");
|
||||||
@@ -48,8 +48,7 @@ export default function Alphabet() {
|
|||||||
// 语言选择界面
|
// 语言选择界面
|
||||||
if (!chosenAlphabet) {
|
if (!chosenAlphabet) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex flex-col items-center justify-center px-4">
|
<PageLayout>
|
||||||
<Container className="p-8 max-w-2xl w-full text-center">
|
|
||||||
{/* 页面标题 */}
|
{/* 页面标题 */}
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
|
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
|
||||||
{t("chooseCharacters")}
|
{t("chooseCharacters")}
|
||||||
@@ -105,30 +104,25 @@ export default function Alphabet() {
|
|||||||
</div>
|
</div>
|
||||||
</LightButton>
|
</LightButton>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</PageLayout>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载状态
|
// 加载状态
|
||||||
if (loadingState === "loading") {
|
if (loadingState === "loading") {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center">
|
<PageLayout>
|
||||||
<Container className="p-8 text-center">
|
<div className="text-2xl text-gray-600 text-center">{t("loading")}</div>
|
||||||
<div className="text-2xl text-gray-600">{t("loading")}</div>
|
</PageLayout>
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 错误状态
|
// 错误状态
|
||||||
if (loadingState === "error") {
|
if (loadingState === "error") {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center">
|
<PageLayout>
|
||||||
<Container className="p-8 text-center">
|
<div className="text-2xl text-red-600 text-center">{t("loadFailed")}</div>
|
||||||
<div className="text-2xl text-red-600">{t("loadFailed")}</div>
|
</PageLayout>
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { LightButton } from "@/components/ui/buttons";
|
|
||||||
import Container from "@/components/ui/Container";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Folder } from "../../../../generated/prisma/browser";
|
|
||||||
import { getFoldersByUserId } from "@/lib/server/services/folderService";
|
|
||||||
import { Folder as Fd } from "lucide-react";
|
|
||||||
import { createPair } from "@/lib/server/services/pairService";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
|
|
||||||
interface AddToFolderProps {
|
|
||||||
definitionLang: string;
|
|
||||||
queryLang: string;
|
|
||||||
standardForm: string;
|
|
||||||
definition: string;
|
|
||||||
ipa?: string;
|
|
||||||
setShow: (show: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AddToFolder: React.FC<AddToFolderProps> = ({
|
|
||||||
definitionLang,
|
|
||||||
queryLang,
|
|
||||||
standardForm,
|
|
||||||
definition,
|
|
||||||
ipa,
|
|
||||||
setShow,
|
|
||||||
}) => {
|
|
||||||
const { data: session } = authClient.useSession();
|
|
||||||
const [folders, setFolders] = useState<Folder[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!session) return;
|
|
||||||
const userId = session.user.id as string;
|
|
||||||
getFoldersByUserId(userId)
|
|
||||||
.then(setFolders)
|
|
||||||
.then(() => setLoading(false));
|
|
||||||
}, [session]);
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed left-0 top-0 z-50 w-screen h-screen bg-black/50 flex justify-center items-center">
|
|
||||||
<Container className="p-6">
|
|
||||||
<h1 className="text-xl font-bold mb-4">选择文件夹保存</h1>
|
|
||||||
<div className="border border-gray-200 rounded-2xl">
|
|
||||||
{loading ? (
|
|
||||||
<span>加载中...</span>
|
|
||||||
) : folders.length > 0 ? (
|
|
||||||
folders.map((folder) => (
|
|
||||||
<button
|
|
||||||
key={folder.id}
|
|
||||||
className="p-2 flex items-center justify-start hover:bg-gray-50 gap-2 hover:cursor-pointer w-full border-b border-gray-200"
|
|
||||||
onClick={() => {
|
|
||||||
createPair({
|
|
||||||
text1: standardForm,
|
|
||||||
text2: definition,
|
|
||||||
language1: queryLang,
|
|
||||||
language2: definitionLang,
|
|
||||||
ipa1: ipa || undefined,
|
|
||||||
folder: {
|
|
||||||
connect: {
|
|
||||||
id: folder.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success(`已保存到文件夹:${folder.name}`);
|
|
||||||
setShow(false);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("保存失败,请稍后重试");
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Fd />
|
|
||||||
{folder.name}
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="p-4 text-gray-500">暂无文件夹</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex justify-end gap-2">
|
|
||||||
<LightButton onClick={() => setShow(false)}>关闭</LightButton>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AddToFolder;
|
|
||||||
45
src/app/(features)/dictionary/DictionaryEntry.tsx
Normal file
45
src/app/(features)/dictionary/DictionaryEntry.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { TSharedEntry } from "@/shared/dictionary-type";
|
||||||
|
|
||||||
|
interface DictionaryEntryProps {
|
||||||
|
entry: TSharedEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DictionaryEntry({ entry }: DictionaryEntryProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* 音标和词性 */}
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
{entry.ipa && (
|
||||||
|
<span className="text-gray-600 text-lg">
|
||||||
|
[{entry.ipa}]
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{entry.partOfSpeech && (
|
||||||
|
<span className="px-3 py-1 bg-[#35786f] text-white text-sm rounded-full">
|
||||||
|
{entry.partOfSpeech}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 释义 */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||||
|
释义
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-800">{entry.definition}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 例句 */}
|
||||||
|
{entry.example && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||||
|
例句
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
|
||||||
|
{entry.example}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/app/(features)/dictionary/SearchForm.tsx
Normal file
117
src/app/(features)/dictionary/SearchForm.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
src/app/(features)/dictionary/SearchResult.client.tsx
Normal file
122
src/app/(features)/dictionary/SearchResult.client.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/app/(features)/dictionary/SearchResult.tsx
Normal file
93
src/app/(features)/dictionary/SearchResult.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
src/app/(features)/dictionary/constants.ts
Normal file
8
src/app/(features)/dictionary/constants.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export const POPULAR_LANGUAGES = [
|
||||||
|
{ code: "english", name: "英语", nativeName: "English" },
|
||||||
|
{ code: "chinese", name: "中文", nativeName: "中文" },
|
||||||
|
{ code: "japanese", name: "日语", nativeName: "日本語" },
|
||||||
|
{ code: "korean", name: "韩语", nativeName: "한국어" },
|
||||||
|
{ code: "italian", name: "意大利语", nativeName: "Italiano" },
|
||||||
|
{ code: "uyghur", name: "维吾尔语", nativeName: "ئۇيغۇرچە" },
|
||||||
|
] as const;
|
||||||
@@ -1,398 +1,75 @@
|
|||||||
"use client";
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
|
import { SearchForm } from "./SearchForm";
|
||||||
|
import { SearchResult } from "./SearchResult";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { actionLookUpDictionary } from "@/modules/dictionary/dictionary-action";
|
||||||
|
import { TSharedItem } from "@/shared/dictionary-type";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
interface DictionaryPageProps {
|
||||||
import Container from "@/components/ui/Container";
|
searchParams: Promise<{ q?: string; ql?: string; dl?: string; }>;
|
||||||
import { LightButton } from "@/components/ui/buttons";
|
|
||||||
import { lookUp } from "@/lib/server/bigmodel/dictionaryActions";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Plus } from "lucide-react";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
import { Folder } from "../../../../generated/prisma/browser";
|
|
||||||
import { getFoldersByUserId } from "@/lib/server/services/folderService";
|
|
||||||
import { createPair } from "@/lib/server/services/pairService";
|
|
||||||
|
|
||||||
// 主流语言列表
|
|
||||||
const POPULAR_LANGUAGES = [
|
|
||||||
{ code: "english", name: "英语" },
|
|
||||||
{ code: "chinese", name: "中文" },
|
|
||||||
{ code: "japanese", name: "日语" },
|
|
||||||
{ code: "korean", name: "韩语" },
|
|
||||||
{ code: "french", name: "法语" },
|
|
||||||
{ code: "german", name: "德语" },
|
|
||||||
{ code: "italian", name: "意大利语" },
|
|
||||||
{ code: "spanish", name: "西班牙语" },
|
|
||||||
];
|
|
||||||
|
|
||||||
type DictionaryWordEntry = {
|
|
||||||
ipa: string;
|
|
||||||
definition: string;
|
|
||||||
partOfSpeech: string;
|
|
||||||
example: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type DictionaryPhraseEntry = {
|
|
||||||
definition: string;
|
|
||||||
example: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type DictionaryErrorResponse = {
|
|
||||||
error: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type DictionarySuccessResponse = {
|
|
||||||
standardForm: string;
|
|
||||||
entries: (DictionaryWordEntry | DictionaryPhraseEntry)[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type DictionaryResponse = DictionarySuccessResponse | DictionaryErrorResponse;
|
|
||||||
|
|
||||||
// 类型守卫:判断是否为单词条目
|
|
||||||
function isWordEntry(entry: DictionaryWordEntry | DictionaryPhraseEntry): entry is DictionaryWordEntry {
|
|
||||||
return "ipa" in entry && "partOfSpeech" in entry;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 类型守卫:判断是否为错误响应
|
export default async function DictionaryPage({ searchParams }: DictionaryPageProps) {
|
||||||
function isErrorResponse(response: DictionaryResponse): response is DictionaryErrorResponse {
|
const t = await getTranslations("dictionary");
|
||||||
return "error" in response;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Dictionary() {
|
// 从 searchParams 获取搜索参数
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const { q: searchQuery, ql: queryLang = "english", dl: definitionLang = "chinese" } = await searchParams;
|
||||||
const [searchResult, setSearchResult] = useState<DictionaryResponse | null>(null);
|
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
|
||||||
const [hasSearched, setHasSearched] = useState(false);
|
|
||||||
const [queryLang, setQueryLang] = useState("english");
|
|
||||||
const [definitionLang, setDefinitionLang] = useState("chinese");
|
|
||||||
const [showLangSettings, setShowLangSettings] = useState(false);
|
|
||||||
const [selectedFolderId, setSelectedFolderId] = useState<number | null>(null);
|
|
||||||
const [folders, setFolders] = useState<Folder[]>([]);
|
|
||||||
const { data: session } = authClient.useSession();
|
|
||||||
|
|
||||||
// 加载用户的文件夹列表
|
// 如果有搜索查询,获取搜索结果
|
||||||
useEffect(() => {
|
let searchResult: TSharedItem | undefined | null = null;
|
||||||
if (session) {
|
if (searchQuery) {
|
||||||
getFoldersByUserId(session.user.id as string)
|
const getNativeName = (code: string): string => {
|
||||||
.then((loadedFolders) => {
|
const popularLanguages: Record<string, string> = {
|
||||||
setFolders(loadedFolders);
|
english: "English",
|
||||||
// 如果有文件夹且未选择,默认选择第一个
|
chinese: "中文",
|
||||||
if (loadedFolders.length > 0 && !selectedFolderId) {
|
japanese: "日本語",
|
||||||
setSelectedFolderId(loadedFolders[0].id);
|
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;
|
||||||
}
|
}
|
||||||
}, [session, selectedFolderId]);
|
|
||||||
|
|
||||||
const handleSearch = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!searchQuery.trim()) return;
|
|
||||||
|
|
||||||
setIsSearching(true);
|
|
||||||
setHasSearched(true);
|
|
||||||
setSearchResult(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 使用查询语言和释义语言
|
|
||||||
const result = await lookUp(searchQuery, queryLang, definitionLang);
|
|
||||||
|
|
||||||
// 检查是否为错误响应
|
|
||||||
if (isErrorResponse(result)) {
|
|
||||||
toast.error(result.error);
|
|
||||||
setSearchResult(null);
|
|
||||||
} else {
|
|
||||||
setSearchResult(result);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error("词典查询失败:", error);
|
|
||||||
toast.error("查询失败,请稍后重试");
|
|
||||||
setSearchResult(null);
|
|
||||||
} finally {
|
|
||||||
setIsSearching(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[calc(100vh-64px)] bg-[#35786f]">
|
<PageLayout>
|
||||||
{/* 搜索区域 */}
|
{/* 搜索区域 */}
|
||||||
<div className="flex items-center justify-center px-4 py-12">
|
<div className="mb-8">
|
||||||
<Container className="max-w-3xl w-full p-4">
|
<SearchForm
|
||||||
{/* 页面标题 */}
|
defaultQueryLang={queryLang}
|
||||||
<div className="text-center mb-8">
|
defaultDefinitionLang={definitionLang}
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
|
|
||||||
词典
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-700 text-lg">
|
|
||||||
查询单词和短语,提供详细的释义和例句
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 搜索表单 */}
|
|
||||||
<form onSubmit={handleSearch} className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
|
|
||||||
placeholder="输入要查询的单词或短语..."
|
|
||||||
className="flex-1 px-4 py-3 text-lg text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
|
|
||||||
/>
|
/>
|
||||||
<LightButton
|
|
||||||
type="submit"
|
|
||||||
disabled={isSearching || !searchQuery.trim()}
|
|
||||||
className="px-6 py-3"
|
|
||||||
>
|
|
||||||
{isSearching ? "查询中..." : "查询"}
|
|
||||||
</LightButton>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* 语言设置 */}
|
|
||||||
<div className="mt-4 bg-white/20 rounded-lg p-4">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<span className="text-gray-800 font-semibold">语言设置</span>
|
|
||||||
<LightButton
|
|
||||||
onClick={() => setShowLangSettings(!showLangSettings)}
|
|
||||||
className="text-sm px-4 py-2"
|
|
||||||
>
|
|
||||||
{showLangSettings ? "收起" : "展开"}
|
|
||||||
</LightButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showLangSettings && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* 查询语言 */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-gray-700 text-sm mb-2">
|
|
||||||
查询语言 (你要查询的单词/短语是什么语言)
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
|
||||||
{POPULAR_LANGUAGES.map((lang) => (
|
|
||||||
<LightButton
|
|
||||||
key={lang.code}
|
|
||||||
selected={queryLang === lang.code}
|
|
||||||
onClick={() => setQueryLang(lang.code)}
|
|
||||||
className="text-sm px-3 py-1"
|
|
||||||
>
|
|
||||||
{lang.name}
|
|
||||||
</LightButton>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={queryLang}
|
|
||||||
onChange={(e) => setQueryLang(e.target.value)}
|
|
||||||
placeholder="或输入其他语言..."
|
|
||||||
className="w-full px-3 py-2 text-sm text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 释义语言 */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-gray-700 text-sm mb-2">
|
|
||||||
释义语言 (你希望用什么语言查看释义)
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
|
||||||
{POPULAR_LANGUAGES.map((lang) => (
|
|
||||||
<LightButton
|
|
||||||
key={lang.code}
|
|
||||||
selected={definitionLang === lang.code}
|
|
||||||
onClick={() => setDefinitionLang(lang.code)}
|
|
||||||
className="text-sm px-3 py-1"
|
|
||||||
>
|
|
||||||
{lang.name}
|
|
||||||
</LightButton>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={definitionLang}
|
|
||||||
onChange={(e) => setDefinitionLang(e.target.value)}
|
|
||||||
placeholder="或输入其他语言..."
|
|
||||||
className="w-full px-3 py-2 text-sm text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 当前设置显示 */}
|
|
||||||
<div className="text-center text-gray-700 text-sm pt-2 border-t border-gray-300">
|
|
||||||
当前设置:查询 <span className="font-semibold">{POPULAR_LANGUAGES.find(l => l.code === queryLang)?.name || queryLang}</span>
|
|
||||||
,释义 <span className="font-semibold">{POPULAR_LANGUAGES.find(l => l.code === definitionLang)?.name || definitionLang}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 搜索提示 */}
|
|
||||||
<div className="mt-4 text-center text-gray-700 text-sm">
|
|
||||||
<p>试试搜索:hello, look up, dictionary</p>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 搜索结果区域 */}
|
{/* 搜索结果区域 */}
|
||||||
<div className="flex-1 px-4 pb-12">
|
|
||||||
<Container className="max-w-3xl w-full p-4">
|
|
||||||
{isSearching && (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
|
|
||||||
<p className="mt-4 text-white">加载中...</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isSearching && hasSearched && !searchResult && (
|
|
||||||
<div className="text-center py-12 bg-white/20 rounded-lg">
|
|
||||||
<p className="text-gray-800 text-xl">未找到结果</p>
|
|
||||||
<p className="text-gray-600 mt-2">尝试其他单词或短语</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isSearching && searchResult && !isErrorResponse(searchResult) && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="bg-white rounded-lg p-6 shadow-lg">
|
|
||||||
{/* 标题和保存按钮 */}
|
|
||||||
<div className="flex items-start justify-between mb-6">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h2 className="text-3xl font-bold text-gray-800 mb-2">
|
|
||||||
{searchResult.standardForm}
|
|
||||||
</h2>
|
|
||||||
{searchResult.standardForm !== searchQuery && (
|
|
||||||
<p className="text-gray-500 text-sm">
|
|
||||||
原始输入: {searchQuery}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 ml-4">
|
|
||||||
{session && folders.length > 0 && (
|
|
||||||
<select
|
|
||||||
value={selectedFolderId || ""}
|
|
||||||
onChange={(e) => setSelectedFolderId(e.target.value ? Number(e.target.value) : null)}
|
|
||||||
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[#35786f]"
|
|
||||||
>
|
|
||||||
{folders.map((folder) => (
|
|
||||||
<option key={folder.id} value={folder.id}>
|
|
||||||
{folder.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (!session) {
|
|
||||||
toast.error("请先登录");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!selectedFolderId) {
|
|
||||||
toast.error("请先创建文件夹");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!searchResult || isErrorResponse(searchResult)) return;
|
|
||||||
|
|
||||||
const entry = searchResult.entries[0];
|
|
||||||
createPair({
|
|
||||||
text1: searchResult.standardForm,
|
|
||||||
text2: entry.definition,
|
|
||||||
language1: queryLang,
|
|
||||||
language2: definitionLang,
|
|
||||||
ipa1: isWordEntry(entry) ? entry.ipa : undefined,
|
|
||||||
folder: {
|
|
||||||
connect: {
|
|
||||||
id: selectedFolderId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
const folderName = folders.find(f => f.id === selectedFolderId)?.name;
|
|
||||||
toast.success(`已保存到文件夹:${folderName}`);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("保存失败,请稍后重试");
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-10 h-10 flex justify-center items-center flex-shrink-0"
|
|
||||||
title="保存到文件夹"
|
|
||||||
>
|
|
||||||
<Plus />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 条目列表 */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{searchResult.entries.map((entry, index) => (
|
|
||||||
<div key={index} className="border-t border-gray-200 pt-4">
|
|
||||||
{isWordEntry(entry) ? (
|
|
||||||
// 单词条目
|
|
||||||
<div>
|
<div>
|
||||||
{/* 音标和词性 */}
|
{searchQuery && (
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<SearchResult
|
||||||
{entry.ipa && (
|
searchResult={searchResult}
|
||||||
<span className="text-gray-600 text-lg">
|
searchQuery={searchQuery}
|
||||||
{entry.ipa}
|
queryLang={queryLang}
|
||||||
</span>
|
definitionLang={definitionLang}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{entry.partOfSpeech && (
|
{!searchQuery && (
|
||||||
<span className="px-3 py-1 bg-[#35786f] text-white text-sm rounded-full">
|
<div className="text-center py-12">
|
||||||
{entry.partOfSpeech}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 释义 */}
|
|
||||||
<div className="mb-3">
|
|
||||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
|
||||||
释义
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-800">{entry.definition}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 例句 */}
|
|
||||||
{entry.example && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
|
||||||
例句
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
|
|
||||||
{entry.example}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// 短语条目
|
|
||||||
<div>
|
|
||||||
{/* 释义 */}
|
|
||||||
<div className="mb-3">
|
|
||||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
|
||||||
释义
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-800">{entry.definition}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 例句 */}
|
|
||||||
{entry.example && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
|
||||||
例句
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
|
|
||||||
{entry.example}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!hasSearched && (
|
|
||||||
<div className="text-center py-12 bg-white/20 rounded-lg">
|
|
||||||
<div className="text-6xl mb-4">📚</div>
|
<div className="text-6xl mb-4">📚</div>
|
||||||
<p className="text-gray-800 text-xl mb-2">欢迎使用词典</p>
|
<p className="text-gray-800 text-xl mb-2">{t("welcomeTitle")}</p>
|
||||||
<p className="text-gray-600">在上方搜索框中输入单词或短语开始查询</p>
|
<p className="text-gray-600">{t("welcomeHint")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Folder } from "../../../../generated/prisma/browser";
|
|
||||||
import { Folder as Fd } from "lucide-react";
|
import { Folder as Fd } from "lucide-react";
|
||||||
|
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
|
||||||
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
|
import { PrimaryButton } from "@/design-system/base/button";
|
||||||
|
|
||||||
interface FolderSelectorProps {
|
interface FolderSelectorProps {
|
||||||
folders: (Folder & { total: number })[];
|
folders: TSharedFolderWithTotalPairs[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
|
const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
|
||||||
@@ -15,20 +17,17 @@ const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4 py-8">
|
<PageLayout>
|
||||||
<div className="w-full max-w-2xl">
|
|
||||||
<div className="bg-white rounded-2xl shadow-xl p-6 md:p-8">
|
|
||||||
{folders.length === 0 ? (
|
{folders.length === 0 ? (
|
||||||
// 空状态 - 显示提示和跳转按钮
|
// 空状态 - 显示提示和跳转按钮
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-4">
|
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-4">
|
||||||
{t("noFolders")}
|
{t("noFolders")}
|
||||||
</h1>
|
</h1>
|
||||||
<Link
|
<Link href="/folders">
|
||||||
className="inline-block px-6 py-2 bg-[#35786f] text-white rounded-full hover:bg-[#2d5f58] transition-colors"
|
<PrimaryButton className="px-6 py-2">
|
||||||
href="/folders"
|
|
||||||
>
|
|
||||||
Go to Folders
|
Go to Folders
|
||||||
|
</PrimaryButton>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -38,7 +37,7 @@ const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
|
|||||||
{t("selectFolder")}
|
{t("selectFolder")}
|
||||||
</h1>
|
</h1>
|
||||||
{/* 文件夹列表 */}
|
{/* 文件夹列表 */}
|
||||||
<div className="border border-gray-200 rounded-2xl max-h-96 overflow-y-auto">
|
<div className="border border-gray-200 rounded-lg max-h-96 overflow-y-auto">
|
||||||
{folders
|
{folders
|
||||||
.toSorted((a, b) => a.id - b.id)
|
.toSorted((a, b) => a.id - b.id)
|
||||||
.map((folder) => (
|
.map((folder) => (
|
||||||
@@ -50,8 +49,8 @@ const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
|
|||||||
className="flex flex-row items-center p-4 gap-3 hover:cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0"
|
className="flex flex-row items-center p-4 gap-3 hover:cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0"
|
||||||
>
|
>
|
||||||
{/* 文件夹图标 */}
|
{/* 文件夹图标 */}
|
||||||
<div className="flex-shrink-0">
|
<div className="shrink-0">
|
||||||
<Fd className="text-gray-600" size={24} />
|
<Fd className="text-gray-600" size="md" />
|
||||||
</div>
|
</div>
|
||||||
{/* 文件夹信息 */}
|
{/* 文件夹信息 */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -87,10 +86,8 @@ const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</PageLayout>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FolderSelector;
|
export { FolderSelector };
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { LinkButton, CircleToggleButton, LightButton } from "@/design-system/base/button";
|
||||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||||
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/server/bigmodel/tts";
|
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
import { isNonNegativeInteger, SeededRandom } from "@/lib/utils";
|
import { isNonNegativeInteger, SeededRandom } from "@/utils/random";
|
||||||
import { Pair } from "../../../../generated/prisma/browser";
|
import { TSharedPair } from "@/shared/folder-type";
|
||||||
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
|
|
||||||
const myFont = localFont({
|
const myFont = localFont({
|
||||||
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
|
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
|
||||||
});
|
});
|
||||||
|
|
||||||
interface MemorizeProps {
|
interface MemorizeProps {
|
||||||
textPairs: Pair[];
|
textPairs: TSharedPair[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
||||||
@@ -27,11 +29,9 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
|||||||
|
|
||||||
if (textPairs.length === 0) {
|
if (textPairs.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4">
|
<PageLayout>
|
||||||
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
|
<p className="text-gray-700 text-center">{t("noTextPairs")}</p>
|
||||||
<p className="text-gray-700">{t("noTextPairs")}</p>
|
</PageLayout>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,17 +112,12 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
|||||||
: [getTextPairs()[index].text1, getTextPairs()[index].text2];
|
: [getTextPairs()[index].text1, getTextPairs()[index].text2];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4 py-8">
|
<PageLayout>
|
||||||
<div className="w-full max-w-2xl">
|
|
||||||
<div className="bg-white rounded-2xl shadow-xl p-6 md:p-8">
|
|
||||||
{/* 进度指示器 */}
|
{/* 进度指示器 */}
|
||||||
<div className="flex justify-center mb-4">
|
<div className="flex justify-center mb-4">
|
||||||
<button
|
<LinkButton onClick={handleIndexClick} className="text-sm">
|
||||||
onClick={handleIndexClick}
|
|
||||||
className="text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
|
||||||
>
|
|
||||||
{index + 1} / {getTextPairs().length}
|
{index + 1} / {getTextPairs().length}
|
||||||
</button>
|
</LinkButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 文本显示区域 */}
|
{/* 文本显示区域 */}
|
||||||
@@ -162,53 +157,39 @@ const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
|||||||
|
|
||||||
{/* 底部按钮 */}
|
{/* 底部按钮 */}
|
||||||
<div className="flex flex-row gap-2 items-center justify-center flex-wrap">
|
<div className="flex flex-row gap-2 items-center justify-center flex-wrap">
|
||||||
<button
|
<LightButton
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
className="px-4 py-2 rounded-full bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors text-sm"
|
className="px-4 py-2 rounded-full text-sm"
|
||||||
>
|
>
|
||||||
{show === "question" ? t("answer") : t("next")}
|
{show === "question" ? t("answer") : t("next")}
|
||||||
</button>
|
</LightButton>
|
||||||
<button
|
<LightButton
|
||||||
onClick={handlePrevious}
|
onClick={handlePrevious}
|
||||||
className="px-4 py-2 rounded-full bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors text-sm"
|
className="px-4 py-2 rounded-full text-sm"
|
||||||
>
|
>
|
||||||
{t("previous")}
|
{t("previous")}
|
||||||
</button>
|
</LightButton>
|
||||||
<button
|
<CircleToggleButton
|
||||||
|
selected={reverse}
|
||||||
onClick={toggleReverse}
|
onClick={toggleReverse}
|
||||||
className={`px-4 py-2 rounded-full transition-colors text-sm ${
|
|
||||||
reverse
|
|
||||||
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
|
|
||||||
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{t("reverse")}
|
{t("reverse")}
|
||||||
</button>
|
</CircleToggleButton>
|
||||||
<button
|
<CircleToggleButton
|
||||||
|
selected={dictation}
|
||||||
onClick={toggleDictation}
|
onClick={toggleDictation}
|
||||||
className={`px-4 py-2 rounded-full transition-colors text-sm ${
|
|
||||||
dictation
|
|
||||||
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
|
|
||||||
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{t("dictation")}
|
{t("dictation")}
|
||||||
</button>
|
</CircleToggleButton>
|
||||||
<button
|
<CircleToggleButton
|
||||||
|
selected={disorder}
|
||||||
onClick={toggleDisorder}
|
onClick={toggleDisorder}
|
||||||
className={`px-4 py-2 rounded-full transition-colors text-sm ${
|
|
||||||
disorder
|
|
||||||
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
|
|
||||||
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{t("disorder")}
|
{t("disorder")}
|
||||||
</button>
|
</CircleToggleButton>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</PageLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Memorize;
|
export { Memorize };
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import {
|
import { isNonNegativeInteger } from "@/utils/random";
|
||||||
getFoldersWithTotalPairsByUserId,
|
import { FolderSelector } from "./FolderSelector";
|
||||||
} from "@/lib/server/services/folderService";
|
import { Memorize } from "./Memorize";
|
||||||
import { isNonNegativeInteger } from "@/lib/utils";
|
|
||||||
import FolderSelector from "./FolderSelector";
|
|
||||||
import Memorize from "./Memorize";
|
|
||||||
import { getPairsByFolderId } from "@/lib/server/services/pairService";
|
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import { actionGetFoldersWithTotalPairsByUserId, actionGetPairsByFolderId } from "@/modules/folder/folder-aciton";
|
||||||
|
|
||||||
export default async function MemorizePage({
|
export default async function MemorizePage({
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -27,13 +24,14 @@ 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("/auth?redirect=/memorize");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FolderSelector
|
<FolderSelector
|
||||||
folders={await getFoldersWithTotalPairsByUserId(session.user.id)}
|
folders={(await actionGetFoldersWithTotalPairsByUserId(session.user.id)).data!}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Memorize textPairs={await getPairsByFolderId(folder_id)} />;
|
return <Memorize textPairs={(await actionGetPairsByFolderId(folder_id)).data!} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export default function SubtitleDisplay({ subtitle }: { subtitle: string }) {
|
export function SubtitleDisplay({ subtitle }: { subtitle: string }) {
|
||||||
const words = subtitle.match(/\b[\w']+(?:-[\w']+)*\b/g) || [];
|
const words = subtitle.match(/\b[\w']+(?:-[\w']+)*\b/g) || [];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useRef, forwardRef, useEffect, useCallback } from "react";
|
import { useState, useRef, forwardRef, useEffect, useCallback } from "react";
|
||||||
import SubtitleDisplay from "./SubtitleDisplay";
|
import { SubtitleDisplay } from "./SubtitleDisplay";
|
||||||
import { LightButton } from "@/components/ui/buttons";
|
import { LightButton } from "@/design-system/base/button";
|
||||||
|
import { RangeInput } from "@/components/ui/RangeInput";
|
||||||
import { getIndex, parseSrt, getNearistIndex } from "../subtitle";
|
import { getIndex, parseSrt, getNearistIndex } from "../subtitle";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
@@ -196,15 +197,18 @@ const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
|
|||||||
{t("autoPause", { enabled: autoPause ? "Yes" : "No" })}
|
{t("autoPause", { enabled: autoPause ? "Yes" : "No" })}
|
||||||
</LightButton>
|
</LightButton>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<RangeInput
|
||||||
className="seekbar"
|
className="seekbar"
|
||||||
type="range"
|
|
||||||
min={0}
|
min={0}
|
||||||
max={srtLength}
|
max={srtLength}
|
||||||
onChange={handleSeek}
|
onChange={(value) => {
|
||||||
step={1}
|
if (videoRef.current && parsedSrtRef.current) {
|
||||||
|
videoRef.current.currentTime = parsedSrtRef.current[value]?.start || 0;
|
||||||
|
setProgress(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
value={progress}
|
value={progress}
|
||||||
></input>
|
/>
|
||||||
<span>{spanText}</span>
|
<span>{spanText}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -213,4 +217,4 @@ const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
|
|||||||
|
|
||||||
VideoPanel.displayName = "VideoPanel";
|
VideoPanel.displayName = "VideoPanel";
|
||||||
|
|
||||||
export default VideoPanel;
|
export { VideoPanel };
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useRef } from "react";
|
import React, { useRef } from "react";
|
||||||
|
import { Button } from "@/design-system/base/button";
|
||||||
import { FileInputProps } from "../../types/controls";
|
import { FileInputProps } from "../../types/controls";
|
||||||
|
|
||||||
interface FileInputComponentProps extends FileInputProps {
|
interface FileInputComponentProps extends FileInputProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FileInput({ accept, onFileSelect, disabled, className, children }: FileInputComponentProps) {
|
export function FileInput({ accept, onFileSelect, disabled, className, children }: FileInputComponentProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleClick = React.useCallback(() => {
|
const handleClick = React.useCallback(() => {
|
||||||
@@ -33,13 +34,15 @@ export default function FileInput({ accept, onFileSelect, disabled, className, c
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`px-2 py-1 rounded shadow font-bold hover:cursor-pointer hover:bg-gray-200 text-gray-800 bg-white ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className || ''}`}
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className={className}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { LightButton } from "@/components/ui/buttons";
|
import { LightButton } from "@/design-system/base/button";
|
||||||
import { PlayButtonProps } from "../../types/player";
|
import { PlayButtonProps } from "../../types/player";
|
||||||
|
|
||||||
export default function PlayButton({ isPlaying, onToggle, disabled, className }: PlayButtonProps) {
|
export function PlayButton({ isPlaying, onToggle, disabled, className }: PlayButtonProps) {
|
||||||
const t = useTranslations("srt_player");
|
const t = useTranslations("srt_player");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,25 +2,16 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { SeekBarProps } from "../../types/player";
|
import { SeekBarProps } from "../../types/player";
|
||||||
|
import { RangeInput } from "@/components/ui/RangeInput";
|
||||||
|
|
||||||
export default function SeekBar({ value, max, onChange, disabled, className }: SeekBarProps) {
|
export function SeekBar({ value, max, onChange, disabled, className }: SeekBarProps) {
|
||||||
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const newValue = parseInt(event.target.value);
|
|
||||||
onChange(newValue);
|
|
||||||
}, [onChange]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<RangeInput
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={max}
|
|
||||||
value={value}
|
value={value}
|
||||||
onChange={handleChange}
|
max={max}
|
||||||
|
onChange={onChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className || ''}`}
|
className={className}
|
||||||
style={{
|
|
||||||
background: `linear-gradient(to right, #374151 0%, #374151 ${(value / max) * 100}%, #e5e7eb ${(value / max) * 100}%, #e5e7eb 100%)`
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { LightButton } from "@/components/ui/buttons";
|
import { LightButton } from "@/design-system/base/button";
|
||||||
import { SpeedControlProps } from "../../types/player";
|
import { SpeedControlProps } from "../../types/player";
|
||||||
import { getPlaybackRateOptions, getPlaybackRateLabel } from "../../utils/timeUtils";
|
import { getPlaybackRateOptions, getPlaybackRateLabel } from "../../utils/timeUtils";
|
||||||
|
|
||||||
export default function SpeedControl({ playbackRate, onPlaybackRateChange, disabled, className }: SpeedControlProps) {
|
export function SpeedControl({ playbackRate, onPlaybackRateChange, disabled, className }: SpeedControlProps) {
|
||||||
const speedOptions = getPlaybackRateOptions();
|
const speedOptions = getPlaybackRateOptions();
|
||||||
|
|
||||||
const handleSpeedChange = React.useCallback(() => {
|
const handleSpeedChange = React.useCallback(() => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { SubtitleTextProps } from "../../types/subtitle";
|
import { SubtitleTextProps } from "../../types/subtitle";
|
||||||
|
|
||||||
export default function SubtitleText({ text, onWordClick, style, className }: SubtitleTextProps) {
|
export function SubtitleText({ text, onWordClick, style, className }: SubtitleTextProps) {
|
||||||
const handleWordClick = React.useCallback((word: string) => {
|
const handleWordClick = React.useCallback((word: string) => {
|
||||||
onWordClick?.(word);
|
onWordClick?.(word);
|
||||||
}, [onWordClick]);
|
}, [onWordClick]);
|
||||||
|
|||||||
@@ -46,4 +46,4 @@ const VideoElement = forwardRef<HTMLVideoElement, VideoElementProps>(
|
|||||||
|
|
||||||
VideoElement.displayName = "VideoElement";
|
VideoElement.displayName = "VideoElement";
|
||||||
|
|
||||||
export default VideoElement;
|
export { VideoElement };
|
||||||
@@ -3,12 +3,12 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { ChevronLeft, ChevronRight, RotateCcw, Pause } from "lucide-react";
|
import { ChevronLeft, ChevronRight, RotateCcw, Pause } from "lucide-react";
|
||||||
import { LightButton } from "@/components/ui/buttons";
|
import { LightButton } from "@/design-system/base/button";
|
||||||
import { ControlBarProps } from "../../types/controls";
|
import { ControlBarProps } from "../../types/controls";
|
||||||
import PlayButton from "../atoms/PlayButton";
|
import { PlayButton } from "../atoms/PlayButton";
|
||||||
import SpeedControl from "../atoms/SpeedControl";
|
import { SpeedControl } from "../atoms/SpeedControl";
|
||||||
|
|
||||||
export default function ControlBar({
|
export function ControlBar({
|
||||||
isPlaying,
|
isPlaying,
|
||||||
onPlayPause,
|
onPlayPause,
|
||||||
onPrevious,
|
onPrevious,
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { SubtitleDisplayProps } from "../../types/subtitle";
|
import { SubtitleDisplayProps } from "../../types/subtitle";
|
||||||
import SubtitleText from "../atoms/SubtitleText";
|
import { SubtitleText } from "../atoms/SubtitleText";
|
||||||
|
|
||||||
export default function SubtitleArea({ subtitle, onWordClick, settings, className }: SubtitleDisplayProps) {
|
export function SubtitleArea({ subtitle, onWordClick, settings, className }: SubtitleDisplayProps) {
|
||||||
const handleWordClick = React.useCallback((word: string) => {
|
const handleWordClick = React.useCallback((word: string) => {
|
||||||
// 打开有道词典页面查询单词
|
// 打开有道词典页面查询单词
|
||||||
window.open(
|
window.open(
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import React 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 { Video, FileText } from "lucide-react";
|
||||||
import { LightButton } from "@/components/ui/buttons";
|
import { LightButton } from "@/design-system/base/button";
|
||||||
import { FileUploadProps } from "../../types/controls";
|
import { FileUploadProps } from "../../types/controls";
|
||||||
import { useFileUpload } from "../../hooks/useFileUpload";
|
import { useFileUpload } from "../../hooks/useFileUpload";
|
||||||
|
|
||||||
export default function UploadZone({ onVideoUpload, onSubtitleUpload, className }: FileUploadProps) {
|
export function UploadZone({ onVideoUpload, onSubtitleUpload, className }: FileUploadProps) {
|
||||||
const t = useTranslations("srt_player");
|
const t = useTranslations("srt_player");
|
||||||
const { uploadVideo, uploadSubtitle } = useFileUpload();
|
const { uploadVideo, uploadSubtitle } = useFileUpload();
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React, { forwardRef } from "react";
|
import React, { forwardRef } from "react";
|
||||||
import { VideoElementProps } from "../../types/player";
|
import { VideoElementProps } from "../../types/player";
|
||||||
import VideoElement from "../atoms/VideoElement";
|
import { VideoElement } from "../atoms/VideoElement";
|
||||||
|
|
||||||
interface VideoPlayerComponentProps extends VideoElementProps {
|
interface VideoPlayerComponentProps extends VideoElementProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -38,4 +38,4 @@ const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerComponentProps>(
|
|||||||
|
|
||||||
VideoPlayer.displayName = "VideoPlayer";
|
VideoPlayer.displayName = "VideoPlayer";
|
||||||
|
|
||||||
export default VideoPlayer;
|
export { VideoPlayer };
|
||||||
@@ -4,17 +4,18 @@ import React 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 { Video, FileText } from "lucide-react";
|
||||||
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
import { useSrtPlayer } from "./hooks/useSrtPlayer";
|
import { useSrtPlayer } from "./hooks/useSrtPlayer";
|
||||||
import { useSubtitleSync } from "./hooks/useSubtitleSync";
|
import { useSubtitleSync } from "./hooks/useSubtitleSync";
|
||||||
import { useKeyboardShortcuts, createSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
|
import { useKeyboardShortcuts, createSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||||
import { useFileUpload } from "./hooks/useFileUpload";
|
import { useFileUpload } from "./hooks/useFileUpload";
|
||||||
import { loadSubtitle } from "./utils/subtitleParser";
|
import { loadSubtitle } from "./utils/subtitleParser";
|
||||||
import VideoPlayer from "./components/compounds/VideoPlayer";
|
import { VideoPlayer } from "./components/compounds/VideoPlayer";
|
||||||
import SubtitleArea from "./components/compounds/SubtitleArea";
|
import { SubtitleArea } from "./components/compounds/SubtitleArea";
|
||||||
import ControlBar from "./components/compounds/ControlBar";
|
import { ControlBar } from "./components/compounds/ControlBar";
|
||||||
import UploadZone from "./components/compounds/UploadZone";
|
import { UploadZone } from "./components/compounds/UploadZone";
|
||||||
import SeekBar from "./components/atoms/SeekBar";
|
import { SeekBar } from "./components/atoms/SeekBar";
|
||||||
import { LightButton } from "@/components/ui/buttons";
|
import { LightButton } from "@/design-system/base/button";
|
||||||
|
|
||||||
export default function SrtPlayerPage() {
|
export default function SrtPlayerPage() {
|
||||||
const t = useTranslations("home");
|
const t = useTranslations("home");
|
||||||
@@ -106,9 +107,7 @@ export default function SrtPlayerPage() {
|
|||||||
const canPlay = state.video.url && state.subtitle.url && state.subtitle.data.length > 0;
|
const canPlay = state.video.url && state.subtitle.url && state.subtitle.data.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<PageLayout>
|
||||||
<div className="container mx-auto px-4 py-8">
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
{/* 标题区域 */}
|
{/* 标题区域 */}
|
||||||
<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">
|
||||||
@@ -119,10 +118,8 @@ export default function SrtPlayerPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 主要内容区域 */}
|
|
||||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
|
||||||
{/* 视频播放器区域 */}
|
{/* 视频播放器区域 */}
|
||||||
<div className="aspect-video bg-black relative">
|
<div className="aspect-video bg-black relative rounded-md overflow-hidden">
|
||||||
{(!state.video.url || !state.subtitle.url || state.subtitle.data.length === 0) && (
|
{(!state.video.url || !state.subtitle.url || state.subtitle.data.length === 0) && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-75 z-10">
|
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-75 z-10">
|
||||||
<div className="text-center text-white">
|
<div className="text-center text-white">
|
||||||
@@ -164,7 +161,7 @@ export default function SrtPlayerPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 控制面板 */}
|
{/* 控制面板 */}
|
||||||
<div className="p-3 bg-gray-50 border-t">
|
<div className="p-3 bg-gray-50 border-t rounded-b-xl">
|
||||||
{/* 上传区域和状态指示器 */}
|
{/* 上传区域和状态指示器 */}
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
@@ -272,9 +269,6 @@ export default function SrtPlayerPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PageLayout>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { SubtitleEntry } from "../types/subtitle";
|
import { SubtitleEntry } from "../types/subtitle";
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
|
|
||||||
export function parseSrt(data: string): SubtitleEntry[] {
|
export function parseSrt(data: string): SubtitleEntry[] {
|
||||||
const lines = data.split(/\r?\n/);
|
const lines = data.split(/\r?\n/);
|
||||||
@@ -94,7 +93,7 @@ export async function loadSubtitle(url: string): Promise<SubtitleEntry[]> {
|
|||||||
const data = await response.text();
|
const data = await response.text();
|
||||||
return parseSrt(data);
|
return parseSrt(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('加载字幕失败', error);
|
console.error('加载字幕失败', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,8 +6,8 @@ import {
|
|||||||
TextSpeakerArraySchema,
|
TextSpeakerArraySchema,
|
||||||
TextSpeakerItemSchema,
|
TextSpeakerItemSchema,
|
||||||
} from "@/lib/interfaces";
|
} from "@/lib/interfaces";
|
||||||
import { IconClick } from "@/components/ui/buttons";
|
import { IconClick } from "@/design-system/base/button";
|
||||||
import IMAGES from "@/config/images";
|
import { IMAGES } from "@/config/images";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ function TextCard({ item, handleUse, handleDel }: TextCardProps) {
|
|||||||
handleDel(item);
|
handleDel(item);
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="p-2 border-b border-gray-200 rounded-2xl bg-gray-100 m-2 grid grid-cols-8">
|
<div className="p-2 border-b border-gray-200 rounded-lg bg-gray-100 m-2 grid grid-cols-8">
|
||||||
<div className="col-span-7" onClick={onUseClick}>
|
<div className="col-span-7" onClick={onUseClick}>
|
||||||
<div className="max-h-26 hover:cursor-pointer text-3xl overflow-y-auto">
|
<div className="max-h-26 hover:cursor-pointer text-3xl overflow-y-auto">
|
||||||
{item.text}
|
{item.text}
|
||||||
@@ -39,7 +39,7 @@ function TextCard({ item, handleUse, handleDel }: TextCardProps) {
|
|||||||
alt="delete"
|
alt="delete"
|
||||||
onClick={onDelClick}
|
onClick={onDelClick}
|
||||||
className="place-self-center"
|
className="place-self-center"
|
||||||
size={42}
|
size="lg"
|
||||||
></IconClick>
|
></IconClick>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,7 +50,7 @@ interface SaveListProps {
|
|||||||
show?: boolean;
|
show?: boolean;
|
||||||
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
|
handleUse: (item: z.infer<typeof TextSpeakerItemSchema>) => void;
|
||||||
}
|
}
|
||||||
export default function SaveList({ show = false, handleUse }: SaveListProps) {
|
export function SaveList({ show = false, handleUse }: SaveListProps) {
|
||||||
const t = useTranslations("text_speaker");
|
const t = useTranslations("text_speaker");
|
||||||
const { get: getFromLocalStorage, set: setIntoLocalStorage } =
|
const { get: getFromLocalStorage, set: setIntoLocalStorage } =
|
||||||
getLocalStorageOperator<typeof TextSpeakerArraySchema>(
|
getLocalStorageOperator<typeof TextSpeakerArraySchema>(
|
||||||
@@ -81,7 +81,7 @@ export default function SaveList({ show = false, handleUse }: SaveListProps) {
|
|||||||
if (show)
|
if (show)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="my-4 p-2 mx-4 md:mx-32 border border-gray-200 rounded-2xl"
|
className="my-4 p-2 mx-4 md:mx-32 border border-gray-200 rounded-lg"
|
||||||
style={{ fontFamily: "Times New Roman, serif" }}
|
style={{ fontFamily: "Times New Roman, serif" }}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row justify-center gap-8 items-center">
|
<div className="flex flex-row justify-center gap-8 items-center">
|
||||||
@@ -89,14 +89,14 @@ export default function SaveList({ show = false, handleUse }: SaveListProps) {
|
|||||||
src={IMAGES.refresh}
|
src={IMAGES.refresh}
|
||||||
alt="refresh"
|
alt="refresh"
|
||||||
onClick={refresh}
|
onClick={refresh}
|
||||||
size={48}
|
size="lg"
|
||||||
className=""
|
className=""
|
||||||
></IconClick>
|
></IconClick>
|
||||||
<IconClick
|
<IconClick
|
||||||
src={IMAGES.delete}
|
src={IMAGES.delete}
|
||||||
alt="delete"
|
alt="delete"
|
||||||
onClick={handleDeleteAll}
|
onClick={handleDeleteAll}
|
||||||
size={48}
|
size="lg"
|
||||||
className=""
|
className=""
|
||||||
></IconClick>
|
></IconClick>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LightButton } from "@/components/ui/buttons";
|
import { LightButton } from "@/design-system/base/button";
|
||||||
import { IconClick } from "@/components/ui/buttons";
|
import { IconClick } from "@/design-system/base/button";
|
||||||
import IMAGES from "@/config/images";
|
import { IMAGES } from "@/config/images";
|
||||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||||
import {
|
import {
|
||||||
TextSpeakerArraySchema,
|
TextSpeakerArraySchema,
|
||||||
@@ -10,14 +10,13 @@ import {
|
|||||||
} from "@/lib/interfaces";
|
} from "@/lib/interfaces";
|
||||||
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import SaveList from "./SaveList";
|
import { SaveList } from "./SaveList";
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
import { getLocalStorageOperator } from "@/lib/browser/localStorageOperators";
|
||||||
import { genIPA, genLanguage } from "@/lib/server/bigmodel/translatorActions";
|
import { genIPA, genLanguage } from "@/modules/translator/translator-action";
|
||||||
import { logger } from "@/lib/logger";
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
import PageLayout from "@/components/ui/PageLayout";
|
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
||||||
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/server/bigmodel/tts";
|
|
||||||
|
|
||||||
export default function TextSpeakerPage() {
|
export default function TextSpeakerPage() {
|
||||||
const t = useTranslations("text_speaker");
|
const t = useTranslations("text_speaker");
|
||||||
@@ -75,7 +74,7 @@ export default function TextSpeakerPage() {
|
|||||||
setIPA(data.ipa);
|
setIPA(data.ipa);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
logger.error("生成 IPA 失败", e);
|
console.error("生成 IPA 失败", e);
|
||||||
setIPA("");
|
setIPA("");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -120,7 +119,7 @@ export default function TextSpeakerPage() {
|
|||||||
load(objurlRef.current);
|
load(objurlRef.current);
|
||||||
play();
|
play();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error("播放音频失败", e);
|
console.error("播放音频失败", e);
|
||||||
setPause(true);
|
setPause(true);
|
||||||
setLanguage(null);
|
setLanguage(null);
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
@@ -212,7 +211,7 @@ export default function TextSpeakerPage() {
|
|||||||
}
|
}
|
||||||
setIntoLocalStorage(save);
|
setIntoLocalStorage(save);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error("保存到本地存储失败", e);
|
console.error("保存到本地存储失败", e);
|
||||||
setLanguage(null);
|
setLanguage(null);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -223,7 +222,7 @@ export default function TextSpeakerPage() {
|
|||||||
<PageLayout className="items-start py-4">
|
<PageLayout className="items-start py-4">
|
||||||
{/* 文本输入区域 */}
|
{/* 文本输入区域 */}
|
||||||
<div
|
<div
|
||||||
className="border border-gray-200 rounded-2xl"
|
className="border border-gray-200 rounded-lg"
|
||||||
style={{ fontFamily: "Times New Roman, serif" }}
|
style={{ fontFamily: "Times New Roman, serif" }}
|
||||||
>
|
>
|
||||||
{/* 文本输入框 */}
|
{/* 文本输入框 */}
|
||||||
@@ -243,37 +242,37 @@ export default function TextSpeakerPage() {
|
|||||||
<div className="p-4 relative w-full flex flex-row flex-wrap gap-2 justify-center items-center">
|
<div className="p-4 relative w-full flex flex-row flex-wrap gap-2 justify-center items-center">
|
||||||
{/* 速度调节面板 */}
|
{/* 速度调节面板 */}
|
||||||
{showSpeedAdjust && (
|
{showSpeedAdjust && (
|
||||||
<div className="bg-white p-6 rounded-2xl border-gray-200 border-2 shadow-2xl absolute left-1/2 -translate-x-1/2 -translate-y-full -top-4 flex flex-row flex-wrap gap-2 justify-center items-center z-10">
|
<div className="bg-white p-6 rounded-lg border-gray-200 border-2 shadow-2xl absolute left-1/2 -translate-x-1/2 -translate-y-full -top-4 flex flex-row flex-wrap gap-2 justify-center items-center z-10">
|
||||||
<IconClick
|
<IconClick
|
||||||
size={45}
|
size="lg"
|
||||||
onClick={letMeSetSpeed(0.5)}
|
onClick={letMeSetSpeed(0.5)}
|
||||||
src={IMAGES.speed_0_5x}
|
src={IMAGES.speed_0_5x}
|
||||||
alt="0.5x"
|
alt="0.5x"
|
||||||
className={speed === 0.5 ? "bg-gray-200" : ""}
|
className={speed === 0.5 ? "bg-gray-200" : ""}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
<IconClick
|
<IconClick
|
||||||
size={45}
|
size="lg"
|
||||||
onClick={letMeSetSpeed(0.7)}
|
onClick={letMeSetSpeed(0.7)}
|
||||||
src={IMAGES.speed_0_7x}
|
src={IMAGES.speed_0_7x}
|
||||||
alt="0.7x"
|
alt="0.7x"
|
||||||
className={speed === 0.7 ? "bg-gray-200" : ""}
|
className={speed === 0.7 ? "bg-gray-200" : ""}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
<IconClick
|
<IconClick
|
||||||
size={45}
|
size="lg"
|
||||||
onClick={letMeSetSpeed(1)}
|
onClick={letMeSetSpeed(1)}
|
||||||
src={IMAGES.speed_1x}
|
src={IMAGES.speed_1x}
|
||||||
alt="1x"
|
alt="1x"
|
||||||
className={speed === 1 ? "bg-gray-200" : ""}
|
className={speed === 1 ? "bg-gray-200" : ""}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
<IconClick
|
<IconClick
|
||||||
size={45}
|
size="lg"
|
||||||
onClick={letMeSetSpeed(1.2)}
|
onClick={letMeSetSpeed(1.2)}
|
||||||
src={IMAGES.speed_1_2_x}
|
src={IMAGES.speed_1_2_x}
|
||||||
alt="1.2x"
|
alt="1.2x"
|
||||||
className={speed === 1.2 ? "bg-gray-200" : ""}
|
className={speed === 1.2 ? "bg-gray-200" : ""}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
<IconClick
|
<IconClick
|
||||||
size={45}
|
size="lg"
|
||||||
onClick={letMeSetSpeed(1.5)}
|
onClick={letMeSetSpeed(1.5)}
|
||||||
src={IMAGES.speed_1_5x}
|
src={IMAGES.speed_1_5x}
|
||||||
alt="1.5x"
|
alt="1.5x"
|
||||||
@@ -283,7 +282,7 @@ export default function TextSpeakerPage() {
|
|||||||
)}
|
)}
|
||||||
{/* 播放/暂停按钮 */}
|
{/* 播放/暂停按钮 */}
|
||||||
<IconClick
|
<IconClick
|
||||||
size={45}
|
size="lg"
|
||||||
onClick={speak}
|
onClick={speak}
|
||||||
src={pause ? IMAGES.play_arrow : IMAGES.pause}
|
src={pause ? IMAGES.play_arrow : IMAGES.pause}
|
||||||
alt="playorpause"
|
alt="playorpause"
|
||||||
@@ -291,7 +290,7 @@ export default function TextSpeakerPage() {
|
|||||||
></IconClick>
|
></IconClick>
|
||||||
{/* 自动暂停按钮 */}
|
{/* 自动暂停按钮 */}
|
||||||
<IconClick
|
<IconClick
|
||||||
size={45}
|
size="lg"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAutopause(!autopause);
|
setAutopause(!autopause);
|
||||||
if (objurlRef) {
|
if (objurlRef) {
|
||||||
@@ -304,7 +303,7 @@ export default function TextSpeakerPage() {
|
|||||||
></IconClick>
|
></IconClick>
|
||||||
{/* 速度调节按钮 */}
|
{/* 速度调节按钮 */}
|
||||||
<IconClick
|
<IconClick
|
||||||
size={45}
|
size="lg"
|
||||||
onClick={() => setShowSpeedAdjust(!showSpeedAdjust)}
|
onClick={() => setShowSpeedAdjust(!showSpeedAdjust)}
|
||||||
src={IMAGES.speed}
|
src={IMAGES.speed}
|
||||||
alt="speed"
|
alt="speed"
|
||||||
@@ -312,7 +311,7 @@ export default function TextSpeakerPage() {
|
|||||||
></IconClick>
|
></IconClick>
|
||||||
{/* 保存按钮 */}
|
{/* 保存按钮 */}
|
||||||
<IconClick
|
<IconClick
|
||||||
size={45}
|
size="lg"
|
||||||
onClick={save}
|
onClick={save}
|
||||||
src={IMAGES.save}
|
src={IMAGES.save}
|
||||||
alt="save"
|
alt="save"
|
||||||
@@ -339,7 +338,7 @@ export default function TextSpeakerPage() {
|
|||||||
</div>
|
</div>
|
||||||
{/* 保存列表 */}
|
{/* 保存列表 */}
|
||||||
{showSaveList && (
|
{showSaveList && (
|
||||||
<div className="mt-4 border border-gray-200 rounded-2xl overflow-hidden">
|
<div className="mt-4 border border-gray-200 rounded-lg overflow-hidden">
|
||||||
<SaveList show={showSaveList} handleUse={handleUseItem}></SaveList>
|
<SaveList show={showSaveList} handleUse={handleUseItem}></SaveList>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { LightButton } from "@/components/ui/buttons";
|
|
||||||
import Container from "@/components/ui/Container";
|
|
||||||
import { TranslationHistorySchema } from "@/lib/interfaces";
|
|
||||||
import { Dispatch, useEffect, useState } from "react";
|
|
||||||
import z from "zod";
|
|
||||||
import { Folder } from "../../../../generated/prisma/browser";
|
|
||||||
import { getFoldersByUserId } from "@/lib/server/services/folderService";
|
|
||||||
import { Folder as Fd } from "lucide-react";
|
|
||||||
import { createPair } from "@/lib/server/services/pairService";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
|
|
||||||
interface AddToFolderProps {
|
|
||||||
item: z.infer<typeof TranslationHistorySchema>;
|
|
||||||
setShow: Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AddToFolder: React.FC<AddToFolderProps> = ({ item, setShow }) => {
|
|
||||||
const { data: session } = authClient.useSession();
|
|
||||||
const [folders, setFolders] = useState<Folder[]>([]);
|
|
||||||
const t = useTranslations("translator.add_to_folder");
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!session) return;
|
|
||||||
const userId = session.user.id;
|
|
||||||
getFoldersByUserId(userId)
|
|
||||||
.then(setFolders)
|
|
||||||
.then(() => setLoading(false));
|
|
||||||
}, [session]);
|
|
||||||
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return (
|
|
||||||
<div className="fixed left-0 top-0 z-50 w-screen h-screen bg-black/50 flex justify-center items-center">
|
|
||||||
<Container className="p-6">
|
|
||||||
<div>{t("notAuthenticated")}</div>
|
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="fixed left-0 top-0 z-50 w-screen h-screen bg-black/50 flex justify-center items-center">
|
|
||||||
<Container className="p-6">
|
|
||||||
<h1>{t("chooseFolder")}</h1>
|
|
||||||
<div className="border border-gray-200 rounded-2xl">
|
|
||||||
{(loading && <span>...</span>) ||
|
|
||||||
(folders.length > 0 &&
|
|
||||||
folders.map((folder) => (
|
|
||||||
<button
|
|
||||||
key={folder.id}
|
|
||||||
className="p-2 flex items-center justify-start hover:bg-gray-50 gap-2 hover:cursor-pointer w-full border-b border-gray-200"
|
|
||||||
onClick={() => {
|
|
||||||
createPair({
|
|
||||||
text1: item.text1,
|
|
||||||
text2: item.text2,
|
|
||||||
language1: item.language1,
|
|
||||||
language2: item.language2,
|
|
||||||
folder: {
|
|
||||||
connect: {
|
|
||||||
id: folder.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success(t("success"));
|
|
||||||
setShow(false);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error(t("error"));
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Fd />
|
|
||||||
{t("folderInfo", { id: folder.id, name: folder.name })}
|
|
||||||
</button>
|
|
||||||
))) || <div>{t("noFolders")}</div>}
|
|
||||||
</div>
|
|
||||||
<LightButton onClick={() => setShow(false)}>{t("close")}</LightButton>
|
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AddToFolder;
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import Container from "@/components/ui/Container";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Folder } from "../../../../generated/prisma/browser";
|
|
||||||
import { getFoldersByUserId } from "@/lib/server/services/folderService";
|
|
||||||
import { LightButton } from "@/components/ui/buttons";
|
|
||||||
import { Folder as Fd } from "lucide-react";
|
|
||||||
|
|
||||||
interface FolderSelectorProps {
|
|
||||||
setSelectedFolderId: (id: number) => void;
|
|
||||||
userId: string;
|
|
||||||
cancel: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FolderSelector: React.FC<FolderSelectorProps> = ({
|
|
||||||
setSelectedFolderId,
|
|
||||||
userId,
|
|
||||||
cancel,
|
|
||||||
}) => {
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [folders, setFolders] = useState<Folder[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getFoldersByUserId(userId)
|
|
||||||
.then(setFolders)
|
|
||||||
.then(() => setLoading(false));
|
|
||||||
}, [userId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`bg-black/50 fixed inset-0 z-50 flex justify-center items-center`}
|
|
||||||
>
|
|
||||||
<Container className="p-6">
|
|
||||||
{(loading && <p>Loading...</p>) ||
|
|
||||||
(folders.length > 0 && (
|
|
||||||
<>
|
|
||||||
<h1>Select a Folder</h1>
|
|
||||||
<div className="m-2 border-gray-200 border rounded-2xl max-h-96 overflow-y-auto">
|
|
||||||
{folders.map((folder) => (
|
|
||||||
<button
|
|
||||||
className="p-2 w-full flex hover:bg-gray-50 gap-2"
|
|
||||||
key={folder.id}
|
|
||||||
onClick={() => setSelectedFolderId(folder.id)}
|
|
||||||
>
|
|
||||||
<Fd />
|
|
||||||
{folder.id}. {folder.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)) || <p>No folders found</p>}
|
|
||||||
<LightButton onClick={cancel}>Cancel</LightButton>
|
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FolderSelector;
|
|
||||||
@@ -1,51 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LightButton } from "@/components/ui/buttons";
|
import { LightButton, PrimaryButton, IconClick } from "@/design-system/base/button";
|
||||||
import { IconClick } from "@/components/ui/buttons";
|
import { IMAGES } from "@/config/images";
|
||||||
import IMAGES from "@/config/images";
|
|
||||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||||
import { TranslationHistorySchema } from "@/lib/interfaces";
|
|
||||||
import { tlsoPush, tlso } from "@/lib/browser/localStorageOperators";
|
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
import { Plus, Trash } from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import z from "zod";
|
import { actionTranslateText } from "@/modules/translator/translator-action";
|
||||||
import AddToFolder from "./AddToFolder";
|
|
||||||
import {
|
|
||||||
genIPA,
|
|
||||||
genLocale,
|
|
||||||
genTranslation,
|
|
||||||
} from "@/lib/server/bigmodel/translatorActions";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import FolderSelector from "./FolderSelector";
|
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
||||||
import { createPair } from "@/lib/server/services/pairService";
|
import { TSharedTranslationResult } from "@/shared/translator-type";
|
||||||
import { shallowEqual } from "@/lib/utils";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/server/bigmodel/tts";
|
|
||||||
|
|
||||||
export default function TranslatorPage() {
|
export default function TranslatorPage() {
|
||||||
const t = useTranslations("translator");
|
const t = useTranslations("translator");
|
||||||
|
|
||||||
const taref = useRef<HTMLTextAreaElement>(null);
|
const taref = useRef<HTMLTextAreaElement>(null);
|
||||||
const [lang, setLang] = useState<string>("chinese");
|
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
|
||||||
const [tresult, setTresult] = useState<string>("");
|
const [translationResult, setTranslationResult] = useState<TSharedTranslationResult | null>(null);
|
||||||
const [genIpa, setGenIpa] = useState(true);
|
const [needIpa, setNeedIpa] = useState(true);
|
||||||
const [ipaTexts, setIpaTexts] = useState(["", ""]);
|
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
|
const [lastTranslation, setLastTranslation] = useState<{
|
||||||
|
sourceText: string;
|
||||||
|
targetLanguage: string;
|
||||||
|
} | null>(null);
|
||||||
const { load, play } = useAudioPlayer();
|
const { load, play } = useAudioPlayer();
|
||||||
const [history, setHistory] = useState<z.infer<typeof TranslationHistorySchema>[]>(() => tlso.get());
|
|
||||||
const [showAddToFolder, setShowAddToFolder] = useState(false);
|
|
||||||
const [addToFolderItem, setAddToFolderItem] = useState<z.infer<
|
|
||||||
typeof TranslationHistorySchema
|
|
||||||
> | null>(null);
|
|
||||||
const lastTTS = useRef({
|
const lastTTS = useRef({
|
||||||
text: "",
|
text: "",
|
||||||
url: "",
|
url: "",
|
||||||
});
|
});
|
||||||
const [autoSave, setAutoSave] = useState(false);
|
|
||||||
const [autoSaveFolderId, setAutoSaveFolderId] = useState<number | null>(null);
|
|
||||||
const { data: session } = authClient.useSession();
|
|
||||||
|
|
||||||
const tts = async (text: string, locale: string) => {
|
const tts = async (text: string, locale: string) => {
|
||||||
if (lastTTS.current.text !== text) {
|
if (lastTTS.current.text !== text) {
|
||||||
@@ -65,129 +46,61 @@ export default function TranslatorPage() {
|
|||||||
|
|
||||||
const url = await getTTSUrl(text, theLanguage as TTS_SUPPORTED_LANGUAGES);
|
const url = await getTTSUrl(text, theLanguage as TTS_SUPPORTED_LANGUAGES);
|
||||||
await load(url);
|
await load(url);
|
||||||
|
await play();
|
||||||
lastTTS.current.text = text;
|
lastTTS.current.text = text;
|
||||||
lastTTS.current.url = url;
|
lastTTS.current.url = url;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Failed to generate audio");
|
toast.error("Failed to generate audio");
|
||||||
logger.error("生成音频失败", error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await play();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const translate = async () => {
|
const translate = async () => {
|
||||||
if (!taref.current) return;
|
if (!taref.current || processing) return;
|
||||||
if (processing) return;
|
|
||||||
|
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
|
|
||||||
const text1 = taref.current.value;
|
const sourceText = taref.current.value;
|
||||||
|
|
||||||
const llmres: {
|
// 判断是否需要强制重新翻译
|
||||||
text1: string | null;
|
// 只有当源文本和目标语言都与上次相同时,才强制重新翻译
|
||||||
text2: string | null;
|
const forceRetranslate =
|
||||||
language1: string | null;
|
lastTranslation?.sourceText === sourceText &&
|
||||||
language2: string | null;
|
lastTranslation?.targetLanguage === targetLanguage;
|
||||||
ipa1: string | null;
|
|
||||||
ipa2: string | null;
|
|
||||||
} = {
|
|
||||||
text1: text1,
|
|
||||||
text2: null,
|
|
||||||
language1: null,
|
|
||||||
language2: null,
|
|
||||||
ipa1: null,
|
|
||||||
ipa2: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
let historyUpdated = false;
|
try {
|
||||||
|
const result = await actionTranslateText({
|
||||||
// 检查更新历史记录
|
sourceText,
|
||||||
const checkUpdateLocalStorage = () => {
|
targetLanguage,
|
||||||
if (historyUpdated) return;
|
forceRetranslate,
|
||||||
if (llmres.text1 && llmres.text2 && llmres.language1 && llmres.language2) {
|
needIpa,
|
||||||
setHistory(
|
|
||||||
tlsoPush({
|
|
||||||
text1: llmres.text1,
|
|
||||||
text2: llmres.text2,
|
|
||||||
language1: llmres.language1,
|
|
||||||
language2: llmres.language2,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (autoSave && autoSaveFolderId) {
|
|
||||||
createPair({
|
|
||||||
text1: llmres.text1,
|
|
||||||
text2: llmres.text2,
|
|
||||||
language1: llmres.language1,
|
|
||||||
language2: llmres.language2,
|
|
||||||
folder: {
|
|
||||||
connect: {
|
|
||||||
id: autoSaveFolderId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success(
|
|
||||||
llmres.text1 + "保存到文件夹" + autoSaveFolderId + "成功",
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
toast.error(
|
|
||||||
llmres.text1 +
|
|
||||||
"保存到文件夹" +
|
|
||||||
autoSaveFolderId +
|
|
||||||
"失败:" +
|
|
||||||
error.message,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setTranslationResult(result.data);
|
||||||
|
setLastTranslation({
|
||||||
|
sourceText,
|
||||||
|
targetLanguage,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error(result.message || "翻译失败,请重试");
|
||||||
}
|
}
|
||||||
historyUpdated = true;
|
} catch (error) {
|
||||||
}
|
toast.error("翻译失败,请重试");
|
||||||
};
|
console.error("翻译错误:", error);
|
||||||
// 更新局部翻译状态
|
} finally {
|
||||||
const updateState = (stateName: keyof typeof llmres, value: string) => {
|
|
||||||
llmres[stateName] = value;
|
|
||||||
checkUpdateLocalStorage();
|
|
||||||
};
|
|
||||||
|
|
||||||
genTranslation(text1, lang)
|
|
||||||
.then(async (text2) => {
|
|
||||||
updateState("text2", text2);
|
|
||||||
setTresult(text2);
|
|
||||||
// 生成两个locale
|
|
||||||
genLocale(text1).then((locale) => {
|
|
||||||
updateState("language1", locale);
|
|
||||||
});
|
|
||||||
genLocale(text2).then((locale) => {
|
|
||||||
updateState("language2", locale);
|
|
||||||
});
|
|
||||||
// 生成俩IPA
|
|
||||||
if (genIpa) {
|
|
||||||
genIPA(text1).then((ipa1) => {
|
|
||||||
setIpaTexts((prev) => [ipa1, prev[1]]);
|
|
||||||
updateState("ipa1", ipa1);
|
|
||||||
});
|
|
||||||
genIPA(text2).then((ipa2) => {
|
|
||||||
setIpaTexts((prev) => [prev[0], ipa2]);
|
|
||||||
updateState("ipa2", ipa2);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Translation failed");
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
});
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="min-h-[calc(100vh-64px)] bg-white">
|
||||||
{/* TCard Component */}
|
{/* TCard Component */}
|
||||||
<div className="w-screen flex flex-col md:flex-row md:justify-between gap-2 p-2">
|
<div className="w-screen flex flex-col md:flex-row md:justify-between gap-2 p-2">
|
||||||
{/* Card Component - Left Side */}
|
{/* Card Component - Left Side */}
|
||||||
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
||||||
{/* ICard1 Component */}
|
{/* ICard1 Component */}
|
||||||
<div className="border border-gray-200 rounded-2xl w-full h-64 p-2">
|
<div className="border border-gray-200 rounded-lg w-full h-64 p-2">
|
||||||
<textarea
|
<textarea
|
||||||
className="resize-none h-8/12 w-full focus:outline-0"
|
className="resize-none h-8/12 w-full focus:outline-0"
|
||||||
ref={taref}
|
ref={taref}
|
||||||
@@ -196,7 +109,7 @@ export default function TranslatorPage() {
|
|||||||
}}
|
}}
|
||||||
></textarea>
|
></textarea>
|
||||||
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
|
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
|
||||||
{ipaTexts[0]}
|
{translationResult?.sourceIpa || ""}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2/12 w-full flex justify-end items-center">
|
<div className="h-2/12 w-full flex justify-end items-center">
|
||||||
<IconClick
|
<IconClick
|
||||||
@@ -214,7 +127,7 @@ export default function TranslatorPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const t = taref.current?.value;
|
const t = taref.current?.value;
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
tts(t, tlso.get().find((v) => v.text1 === t)?.language1 || "");
|
tts(t, translationResult?.sourceLanguage || "");
|
||||||
}}
|
}}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
</div>
|
</div>
|
||||||
@@ -222,8 +135,8 @@ export default function TranslatorPage() {
|
|||||||
<div className="option1 w-full flex flex-row justify-between items-center">
|
<div className="option1 w-full flex flex-row justify-between items-center">
|
||||||
<span>{t("detectLanguage")}</span>
|
<span>{t("detectLanguage")}</span>
|
||||||
<LightButton
|
<LightButton
|
||||||
selected={genIpa}
|
selected={needIpa}
|
||||||
onClick={() => setGenIpa((prev) => !prev)}
|
onClick={() => setNeedIpa((prev) => !prev)}
|
||||||
>
|
>
|
||||||
{t("generateIPA")}
|
{t("generateIPA")}
|
||||||
</LightButton>
|
</LightButton>
|
||||||
@@ -233,26 +146,27 @@ export default function TranslatorPage() {
|
|||||||
{/* Card Component - Right Side */}
|
{/* Card Component - Right Side */}
|
||||||
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
||||||
{/* ICard2 Component */}
|
{/* ICard2 Component */}
|
||||||
<div className="bg-gray-100 rounded-2xl w-full h-64 p-2">
|
<div className="bg-gray-100 rounded-lg w-full h-64 p-2">
|
||||||
<div className="h-2/3 w-full overflow-y-auto">{tresult}</div>
|
<div className="h-2/3 w-full overflow-y-auto">{translationResult?.translatedText || ""}</div>
|
||||||
<div className="ipa w-full h-1/6 overflow-y-auto text-gray-600">
|
<div className="ipa w-full h-1/6 overflow-y-auto text-gray-600">
|
||||||
{ipaTexts[1]}
|
{translationResult?.targetIpa || ""}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1/6 w-full flex justify-end items-center">
|
<div className="h-1/6 w-full flex justify-end items-center">
|
||||||
<IconClick
|
<IconClick
|
||||||
src={IMAGES.copy_all}
|
src={IMAGES.copy_all}
|
||||||
alt="copy"
|
alt="copy"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await navigator.clipboard.writeText(tresult);
|
await navigator.clipboard.writeText(translationResult?.translatedText || "");
|
||||||
}}
|
}}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
<IconClick
|
<IconClick
|
||||||
src={IMAGES.play_arrow}
|
src={IMAGES.play_arrow}
|
||||||
alt="play"
|
alt="play"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (!translationResult) return;
|
||||||
tts(
|
tts(
|
||||||
tresult,
|
translationResult.translatedText,
|
||||||
tlso.get().find((v) => v.text2 === tresult)?.language2 || "",
|
translationResult.targetLanguage,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
@@ -261,29 +175,29 @@ export default function TranslatorPage() {
|
|||||||
<div className="option2 w-full flex gap-1 items-center flex-wrap">
|
<div className="option2 w-full flex gap-1 items-center flex-wrap">
|
||||||
<span>{t("translateInto")}</span>
|
<span>{t("translateInto")}</span>
|
||||||
<LightButton
|
<LightButton
|
||||||
selected={lang === "chinese"}
|
selected={targetLanguage === "Chinese"}
|
||||||
onClick={() => setLang("chinese")}
|
onClick={() => setTargetLanguage("Chinese")}
|
||||||
>
|
>
|
||||||
{t("chinese")}
|
{t("chinese")}
|
||||||
</LightButton>
|
</LightButton>
|
||||||
<LightButton
|
<LightButton
|
||||||
selected={lang === "english"}
|
selected={targetLanguage === "English"}
|
||||||
onClick={() => setLang("english")}
|
onClick={() => setTargetLanguage("English")}
|
||||||
>
|
>
|
||||||
{t("english")}
|
{t("english")}
|
||||||
</LightButton>
|
</LightButton>
|
||||||
<LightButton
|
<LightButton
|
||||||
selected={lang === "italian"}
|
selected={targetLanguage === "Italian"}
|
||||||
onClick={() => setLang("italian")}
|
onClick={() => setTargetLanguage("Italian")}
|
||||||
>
|
>
|
||||||
{t("italian")}
|
{t("italian")}
|
||||||
</LightButton>
|
</LightButton>
|
||||||
<LightButton
|
<LightButton
|
||||||
selected={!["chinese", "english", "italian"].includes(lang)}
|
selected={!["Chinese", "English", "Italian"].includes(targetLanguage)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newLang = prompt(t("enterLanguage"));
|
const newLang = prompt(t("enterLanguage"));
|
||||||
if (newLang) {
|
if (newLang) {
|
||||||
setLang(newLang);
|
setTargetLanguage(newLang);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -295,87 +209,15 @@ export default function TranslatorPage() {
|
|||||||
|
|
||||||
{/* TranslateButton Component */}
|
{/* TranslateButton Component */}
|
||||||
<div className="w-screen flex justify-center items-center">
|
<div className="w-screen flex justify-center items-center">
|
||||||
<button
|
<PrimaryButton
|
||||||
className={`duration-150 ease-in text-xl font-extrabold border rounded-4xl p-3 border-gray-200 h-16 ${processing ? "bg-gray-200" : "bg-white hover:bg-gray-200 hover:cursor-pointer"}`}
|
|
||||||
onClick={translate}
|
onClick={translate}
|
||||||
|
disabled={processing}
|
||||||
|
size="lg"
|
||||||
|
className="text-xl"
|
||||||
>
|
>
|
||||||
{t("translate")}
|
{t("translate")}
|
||||||
</button>
|
</PrimaryButton>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AutoSave Component */}
|
|
||||||
<div className="w-screen flex justify-center items-center">
|
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={autoSave}
|
|
||||||
onChange={(e) => {
|
|
||||||
const checked = e.target.checked;
|
|
||||||
if (checked === true && !session) {
|
|
||||||
toast.warning("Please login to enable auto-save");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (checked === false) setAutoSaveFolderId(null);
|
|
||||||
setAutoSave(checked);
|
|
||||||
}}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
{t("autoSave")}
|
|
||||||
{autoSaveFolderId ? ` (${autoSaveFolderId})` : ""}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{history.length > 0 && (
|
|
||||||
<div className="m-6 flex flex-col items-center">
|
|
||||||
<h1 className="text-2xl font-light">{t("history")}</h1>
|
|
||||||
<div className="border border-gray-200 rounded-2xl m-4">
|
|
||||||
{history.toReversed().map((item, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="border-b border-gray-200 p-2 group hover:bg-gray-50 flex gap-2 flex-row justify-between items-start"
|
|
||||||
>
|
|
||||||
<div className="flex-1 flex flex-col">
|
|
||||||
<p className="text-sm font-light">{item.text1}</p>
|
|
||||||
<p className="text-sm font-light">{item.text2}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowAddToFolder(true);
|
|
||||||
setAddToFolderItem(item);
|
|
||||||
}}
|
|
||||||
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-8 h-8 flex justify-center items-center"
|
|
||||||
>
|
|
||||||
<Plus />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setHistory(
|
|
||||||
tlso.set(
|
|
||||||
tlso.get().filter((v) => !shallowEqual(v, item)),
|
|
||||||
) || [],
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-8 h-8 flex justify-center items-center"
|
|
||||||
>
|
|
||||||
<Trash />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{showAddToFolder && (
|
|
||||||
<AddToFolder setShow={setShowAddToFolder} item={addToFolderItem!} />
|
|
||||||
)}
|
|
||||||
{autoSave && !autoSaveFolderId && (
|
|
||||||
<FolderSelector
|
|
||||||
userId={session!.user.id as string}
|
|
||||||
cancel={() => setAutoSave(false)}
|
|
||||||
setSelectedFolderId={(id) => setAutoSaveFolderId(id)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,39 +2,39 @@
|
|||||||
|
|
||||||
import { useState, useActionState, startTransition } from "react";
|
import { useState, useActionState, startTransition } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { signInAction, signUpAction, SignUpState } from "@/lib/actions/auth";
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
import Container from "@/components/ui/Container";
|
import { Input } from "@/design-system/base/input";
|
||||||
import Input from "@/components/ui/Input";
|
import { LightButton, LinkButton } from "@/design-system/base/button";
|
||||||
import { LightButton } from "@/components/ui/buttons";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { actionSignIn, actionSignUp, ActionOutputAuth } from "@/modules/auth/auth-action";
|
||||||
|
|
||||||
interface AuthFormProps {
|
interface AuthFormProps {
|
||||||
redirectTo?: string;
|
redirectTo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AuthForm({ redirectTo }: AuthFormProps) {
|
export function AuthForm({ redirectTo }: AuthFormProps) {
|
||||||
const t = useTranslations("auth");
|
const t = useTranslations("auth");
|
||||||
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
|
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
|
||||||
const [clearSignIn, setClearSignIn] = useState(false);
|
const [clearSignIn, setClearSignIn] = useState(false);
|
||||||
const [clearSignUp, setClearSignUp] = useState(false);
|
const [clearSignUp, setClearSignUp] = useState(false);
|
||||||
|
|
||||||
const [signInState, signInActionForm, isSignInPending] = useActionState(
|
const [signInState, signInActionForm, isSignInPending] = useActionState(
|
||||||
async (prevState: SignUpState | undefined, formData: FormData) => {
|
async (_prevState: ActionOutputAuth | undefined, formData: FormData) => {
|
||||||
if (clearSignIn) {
|
if (clearSignIn) {
|
||||||
setClearSignIn(false);
|
setClearSignIn(false);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return signInAction(prevState || {}, formData);
|
return actionSignIn(undefined, formData);
|
||||||
},
|
},
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
const [signUpState, signUpActionForm, isSignUpPending] = useActionState(
|
const [signUpState, signUpActionForm, isSignUpPending] = useActionState(
|
||||||
async (prevState: SignUpState | undefined, formData: FormData) => {
|
async (_prevState: ActionOutputAuth | undefined, formData: FormData) => {
|
||||||
if (clearSignUp) {
|
if (clearSignUp) {
|
||||||
setClearSignUp(false);
|
setClearSignUp(false);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return signUpAction(prevState || {}, formData);
|
return actionSignUp(undefined, formData);
|
||||||
},
|
},
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
@@ -44,17 +44,34 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
|||||||
const validateForm = (formData: FormData): boolean => {
|
const validateForm = (formData: FormData): boolean => {
|
||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
const identifier = formData.get("identifier") as string;
|
||||||
const email = formData.get("email") as string;
|
const email = formData.get("email") as string;
|
||||||
|
const username = formData.get("username") as string;
|
||||||
const password = formData.get("password") as string;
|
const password = formData.get("password") as string;
|
||||||
const name = formData.get("name") as string;
|
|
||||||
const confirmPassword = formData.get("confirmPassword") as string;
|
const confirmPassword = formData.get("confirmPassword") as string;
|
||||||
|
|
||||||
|
// 登录模式验证
|
||||||
|
if (mode === 'signin') {
|
||||||
|
if (!identifier) {
|
||||||
|
newErrors.identifier = t("identifierRequired");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 注册模式验证
|
||||||
if (!email) {
|
if (!email) {
|
||||||
newErrors.email = t("emailRequired");
|
newErrors.email = t("emailRequired");
|
||||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
newErrors.email = t("invalidEmail");
|
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) {
|
if (!password) {
|
||||||
newErrors.password = t("passwordRequired");
|
newErrors.password = t("passwordRequired");
|
||||||
} else if (password.length < 8) {
|
} else if (password.length < 8) {
|
||||||
@@ -62,10 +79,6 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'signup') {
|
if (mode === 'signup') {
|
||||||
if (!name) {
|
|
||||||
newErrors.name = t("nameRequired");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!confirmPassword) {
|
if (!confirmPassword) {
|
||||||
newErrors.confirmPassword = t("confirmPasswordRequired");
|
newErrors.confirmPassword = t("confirmPasswordRequired");
|
||||||
} else if (password !== confirmPassword) {
|
} else if (password !== confirmPassword) {
|
||||||
@@ -112,8 +125,7 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
|||||||
const currentError = mode === 'signin' ? signInState : signUpState;
|
const currentError = mode === 'signin' ? signInState : signUpState;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4">
|
<PageLayout>
|
||||||
<Container className="p-8 max-w-md w-full">
|
|
||||||
{/* 页面标题 */}
|
{/* 页面标题 */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">{t(mode === 'signin' ? 'signIn' : 'signUp')}</h1>
|
<h1 className="text-3xl font-bold text-gray-800 mb-2">{t(mode === 'signin' ? 'signIn' : 'signUp')}</h1>
|
||||||
@@ -128,27 +140,41 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
|||||||
|
|
||||||
{/* 登录/注册表单 */}
|
{/* 登录/注册表单 */}
|
||||||
<form onSubmit={handleFormSubmit} className="space-y-4">
|
<form onSubmit={handleFormSubmit} className="space-y-4">
|
||||||
{/* 用户名输入(仅注册模式显示) */}
|
{/* 邮箱/用户名输入(登录模式)或 用户名输入(注册模式) */}
|
||||||
{mode === 'signup' && (
|
{mode === 'signin' ? (
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="identifier"
|
||||||
placeholder={t("name")}
|
placeholder={t("emailOrUsername")}
|
||||||
className="w-full px-3 py-2"
|
className="w-full px-3 py-2"
|
||||||
/>
|
/>
|
||||||
{/* 客户端验证错误 */}
|
{errors.identifier && (
|
||||||
{errors.name && (
|
<p className="text-red-500 text-sm mt-1">{errors.identifier}</p>
|
||||||
<p className="text-red-500 text-sm mt-1">{errors.name}</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 && (
|
{currentError?.errors?.username && (
|
||||||
<p className="text-red-500 text-sm mt-1">{currentError.errors.username[0]}</p>
|
<p className="text-red-500 text-sm mt-1">{currentError.errors.username[0]}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 邮箱输入 */}
|
{/* 邮箱输入(仅注册模式) */}
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
@@ -163,6 +189,8 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
|||||||
<p className="text-red-500 text-sm mt-1">{currentError.errors.email[0]}</p>
|
<p className="text-red-500 text-sm mt-1">{currentError.errors.email[0]}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 密码输入 */}
|
{/* 密码输入 */}
|
||||||
<div>
|
<div>
|
||||||
@@ -233,7 +261,7 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
|||||||
|
|
||||||
{/* 模式切换链接 */}
|
{/* 模式切换链接 */}
|
||||||
<div className="mt-6 text-center">
|
<div className="mt-6 text-center">
|
||||||
<button
|
<LinkButton
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMode(mode === 'signin' ? 'signup' : 'signin');
|
setMode(mode === 'signin' ? 'signup' : 'signin');
|
||||||
@@ -245,15 +273,13 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
|
|||||||
setClearSignUp(true);
|
setClearSignUp(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="text-[#35786f] hover:underline"
|
|
||||||
>
|
>
|
||||||
{mode === 'signin'
|
{mode === 'signin'
|
||||||
? `${t("noAccount")} ${t("signUp")}`
|
? `${t("noAccount")} ${t("signUp")}`
|
||||||
: `${t("hasAccount")} ${t("signIn")}`
|
: `${t("hasAccount")} ${t("signIn")}`
|
||||||
}
|
}
|
||||||
</button>
|
</LinkButton>
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</div>
|
</div>
|
||||||
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import AuthForm from "./AuthForm";
|
import { AuthForm } from "./AuthForm";
|
||||||
|
|
||||||
export default async function AuthPage(
|
export default async function AuthPage(
|
||||||
props: {
|
props: {
|
||||||
|
|||||||
@@ -7,24 +7,19 @@ import {
|
|||||||
FolderPlus,
|
FolderPlus,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { CircleButton, DashedButton } from "@/design-system/base/button";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { logger } from "@/lib/logger";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Folder } from "../../../generated/prisma/browser";
|
|
||||||
import {
|
|
||||||
createFolder,
|
|
||||||
deleteFolderById,
|
|
||||||
getFoldersWithTotalPairsByUserId,
|
|
||||||
renameFolderById,
|
|
||||||
} from "@/lib/server/services/folderService";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { toast } from "sonner";
|
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 { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
|
||||||
|
|
||||||
interface FolderProps {
|
interface FolderProps {
|
||||||
folder: Folder & { total: number };
|
folder: TSharedFolderWithTotalPairs;
|
||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +36,7 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 flex-1">
|
<div className="flex items-center gap-3 flex-1">
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<Fd className="text-gray-600" size={24} />
|
<Fd className="text-gray-600" size="md" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -57,63 +52,82 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<CircleButton
|
||||||
onClick={(e) => {
|
onClick={(e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const newName = prompt("Input a new name.")?.trim();
|
const newName = prompt("Input a new name.")?.trim();
|
||||||
if (newName && newName.length > 0) {
|
if (newName && newName.length > 0) {
|
||||||
renameFolderById(folder.id, newName).then(refresh);
|
actionRenameFolderById(folder.id, newName)
|
||||||
|
.then(result => {
|
||||||
|
if (result.success) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.error(result.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
|
||||||
>
|
>
|
||||||
<FolderPen size={16} />
|
<FolderPen size={16} />
|
||||||
</button>
|
</CircleButton>
|
||||||
<button
|
<CircleButton
|
||||||
onClick={(e) => {
|
onClick={(e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const confirm = prompt(t("confirmDelete", { name: folder.name }));
|
const confirm = prompt(t("confirmDelete", { name: folder.name }));
|
||||||
if (confirm === folder.name) {
|
if (confirm === folder.name) {
|
||||||
deleteFolderById(folder.id).then(refresh);
|
actionDeleteFolderById(folder.id)
|
||||||
|
.then(result => {
|
||||||
|
if (result.success) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
toast.error(result.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
className="text-gray-400 hover:text-red-500 hover:bg-red-50"
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
</button>
|
</CircleButton>
|
||||||
<ChevronRight size={18} className="text-gray-400" />
|
<ChevronRight size={18} className="text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function FoldersClient({ userId }: { userId: string }) {
|
export function FoldersClient({ userId }: { userId: string; }) {
|
||||||
const t = useTranslations("folders");
|
const t = useTranslations("folders");
|
||||||
const [folders, setFolders] = useState<(Folder & { total: number })[]>(
|
const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>(
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
getFoldersWithTotalPairsByUserId(userId)
|
actionGetFoldersWithTotalPairsByUserId(userId)
|
||||||
.then((folders) => {
|
.then((folders) => {
|
||||||
setFolders(folders);
|
if (folders.success && folders.data) {
|
||||||
|
setFolders(folders.data);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
}
|
||||||
.catch((error) => {
|
|
||||||
logger.error("加载文件夹失败", error);
|
|
||||||
toast.error("加载出错,请重试。");
|
|
||||||
});
|
});
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
const updateFolders = async () => {
|
const updateFolders = async () => {
|
||||||
try {
|
setLoading(true);
|
||||||
const updatedFolders = await getFoldersWithTotalPairsByUserId(userId);
|
await actionGetFoldersWithTotalPairsByUserId(userId)
|
||||||
setFolders(updatedFolders);
|
.then(async result => {
|
||||||
} catch (error) {
|
if (!result.success) toast.error(result.message);
|
||||||
logger.error("更新文件夹失败", error);
|
else await actionGetFoldersWithTotalPairsByUserId(userId)
|
||||||
|
.then((folders) => {
|
||||||
|
if (folders.success && folders.data) {
|
||||||
|
setFolders(folders.data);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -121,27 +135,30 @@ export default function FoldersClient({ userId }: { userId: string }) {
|
|||||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||||
|
|
||||||
{/* 新建文件夹按钮 */}
|
{/* 新建文件夹按钮 */}
|
||||||
<button
|
<DashedButton
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const folderName = prompt(t("enterFolderName"));
|
const folderName = prompt(t("enterFolderName"));
|
||||||
if (!folderName) return;
|
if (!folderName) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await createFolder({
|
await actionCreateFolder(userId, folderName)
|
||||||
name: folderName,
|
.then(result => {
|
||||||
user: { connect: { id: userId } },
|
if (result.success) {
|
||||||
|
updateFolders();
|
||||||
|
} else {
|
||||||
|
toast.error(result.message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
await updateFolders();
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full p-3 border-2 border-dashed border-gray-300 rounded-xl text-gray-500 hover:border-gray-400 hover:text-gray-600 transition-colors flex items-center justify-center gap-2"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<FolderPlus size={18} />
|
<FolderPlus size={18} />
|
||||||
<span>{loading ? t("creating") : t("newFolder")}</span>
|
<span>{loading ? t("creating") : t("newFolder")}</span>
|
||||||
</button>
|
</DashedButton>
|
||||||
|
|
||||||
{/* 文件夹列表 */}
|
{/* 文件夹列表 */}
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
@@ -150,13 +167,13 @@ export default function FoldersClient({ userId }: { userId: string }) {
|
|||||||
// 空状态
|
// 空状态
|
||||||
<div className="text-center py-12 text-gray-400">
|
<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">
|
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
|
||||||
<FolderPlus size={24} className="text-gray-400" />
|
<FolderPlus size="md" className="text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm">{t("noFoldersYet")}</p>
|
<p className="text-sm">{t("noFoldersYet")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// 文件夹卡片列表
|
// 文件夹卡片列表
|
||||||
<div className="rounded-xl border border-gray-200 overflow-hidden">
|
<div className="rounded-md border border-gray-200 overflow-hidden">
|
||||||
{folders
|
{folders
|
||||||
.toSorted((a, b) => a.id - b.id)
|
.toSorted((a, b) => a.id - b.id)
|
||||||
.map((folder) => (
|
.map((folder) => (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { LightButton } from "@/components/ui/buttons";
|
import { LightButton } from "@/design-system/base/button";
|
||||||
import Input from "@/components/ui/Input";
|
import { Input } from "@/design-system/base/input";
|
||||||
import { LocaleSelector } from "@/components/ui/LocaleSelector";
|
import { LocaleSelector } from "@/components/ui/LocaleSelector";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
@@ -16,7 +16,7 @@ interface AddTextPairModalProps {
|
|||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AddTextPairModal({
|
export function AddTextPairModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onAdd,
|
onAdd,
|
||||||
@@ -67,7 +67,7 @@ export default function AddTextPairModal({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
|
<div className="bg-white rounded-md p-6 w-full max-w-md mx-4">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<h2 className="flex-1 text-xl font-light mb-4 text-center">
|
<h2 className="flex-1 text-xl font-light mb-4 text-center">
|
||||||
{t("addNewTextPair")}
|
{t("addNewTextPair")}
|
||||||
|
|||||||
@@ -3,30 +3,19 @@
|
|||||||
import { ArrowLeft, Plus } from "lucide-react";
|
import { ArrowLeft, Plus } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { redirect, useRouter } from "next/navigation";
|
import { redirect, useRouter } from "next/navigation";
|
||||||
import {
|
import { AddTextPairModal } from "./AddTextPairModal";
|
||||||
createPair,
|
import { TextPairCard } from "./TextPairCard";
|
||||||
deletePairById,
|
|
||||||
getPairsByFolderId,
|
|
||||||
} from "@/lib/server/services/pairService";
|
|
||||||
import AddTextPairModal from "./AddTextPairModal";
|
|
||||||
import TextPairCard from "./TextPairCard";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import PageLayout from "@/components/ui/PageLayout";
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
import { GreenButton } from "@/components/ui/buttons";
|
import { PrimaryButton, IconButton, LinkButton } from "@/design-system/base/button";
|
||||||
import { logger } from "@/lib/logger";
|
import { CardList } from "@/components/ui/CardList";
|
||||||
import { IconButton } from "@/components/ui/buttons";
|
import { actionCreatePair, actionDeletePairById, actionGetPairsByFolderId } from "@/modules/folder/folder-aciton";
|
||||||
import CardList from "@/components/ui/CardList";
|
import { TSharedPair } from "@/shared/folder-type";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export interface TextPair {
|
|
||||||
id: number;
|
|
||||||
text1: string;
|
|
||||||
text2: string;
|
|
||||||
language1: string;
|
|
||||||
language2: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function InFolder({ folderId }: { folderId: number }) {
|
export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnly: boolean; }) {
|
||||||
const [textPairs, setTextPairs] = useState<TextPair[]>([]);
|
const [textPairs, setTextPairs] = useState<TSharedPair[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [openAddModal, setAddModal] = useState(false);
|
const [openAddModal, setAddModal] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -35,25 +24,26 @@ export default function InFolder({ folderId }: { folderId: number }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTextPairs = async () => {
|
const fetchTextPairs = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
await actionGetPairsByFolderId(folderId)
|
||||||
const data = await getPairsByFolderId(folderId);
|
.then(result => {
|
||||||
setTextPairs(data as TextPair[]);
|
if (!result.success || !result.data) throw result.message;
|
||||||
} catch (error) {
|
return result.data;
|
||||||
logger.error("获取文本对失败", error);
|
}).then(setTextPairs)
|
||||||
} finally {
|
.catch(toast.error)
|
||||||
|
.finally(() => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
});
|
||||||
};
|
};
|
||||||
fetchTextPairs();
|
fetchTextPairs();
|
||||||
}, [folderId]);
|
}, [folderId]);
|
||||||
|
|
||||||
const refreshTextPairs = async () => {
|
const refreshTextPairs = async () => {
|
||||||
try {
|
await actionGetPairsByFolderId(folderId)
|
||||||
const data = await getPairsByFolderId(folderId);
|
.then(result => {
|
||||||
setTextPairs(data as TextPair[]);
|
if (!result.success || !result.data) throw result.message;
|
||||||
} catch (error) {
|
return result.data;
|
||||||
logger.error("获取文本对失败", error);
|
}).then(setTextPairs)
|
||||||
}
|
.catch(toast.error);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -61,13 +51,13 @@ export default function InFolder({ folderId }: { folderId: number }) {
|
|||||||
{/* 顶部导航和标题栏 */}
|
{/* 顶部导航和标题栏 */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
{/* 返回按钮 */}
|
{/* 返回按钮 */}
|
||||||
<button
|
<LinkButton
|
||||||
onClick={router.back}
|
onClick={router.back}
|
||||||
className="flex items-center gap-2 text-gray-500 hover:text-gray-700 transition-colors mb-4"
|
className="flex items-center gap-2 mb-4"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
<span className="text-sm">{t("back")}</span>
|
<span className="text-sm">{t("back")}</span>
|
||||||
</button>
|
</LinkButton>
|
||||||
|
|
||||||
{/* 页面标题和操作按钮 */}
|
{/* 页面标题和操作按钮 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -83,19 +73,21 @@ export default function InFolder({ folderId }: { folderId: number }) {
|
|||||||
|
|
||||||
{/* 操作按钮区域 */}
|
{/* 操作按钮区域 */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<GreenButton
|
<PrimaryButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
redirect(`/memorize?folder_id=${folderId}`);
|
redirect(`/memorize?folder_id=${folderId}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("memorize")}
|
{t("memorize")}
|
||||||
</GreenButton>
|
</PrimaryButton>
|
||||||
|
{!isReadOnly && (
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAddModal(true);
|
setAddModal(true);
|
||||||
}}
|
}}
|
||||||
icon={<Plus size={18} className="text-gray-700" />}
|
icon={<Plus size={18} className="text-gray-700" />}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,9 +114,13 @@ export default function InFolder({ folderId }: { folderId: number }) {
|
|||||||
<TextPairCard
|
<TextPairCard
|
||||||
key={textPair.id}
|
key={textPair.id}
|
||||||
textPair={textPair}
|
textPair={textPair}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
onDel={() => {
|
onDel={() => {
|
||||||
deletePairById(textPair.id);
|
actionDeletePairById(textPair.id)
|
||||||
refreshTextPairs();
|
.then(result => {
|
||||||
|
if (!result.success) throw result.message;
|
||||||
|
}).then(refreshTextPairs)
|
||||||
|
.catch(toast.error);
|
||||||
}}
|
}}
|
||||||
refreshTextPairs={refreshTextPairs}
|
refreshTextPairs={refreshTextPairs}
|
||||||
/>
|
/>
|
||||||
@@ -143,20 +139,16 @@ export default function InFolder({ folderId }: { folderId: number }) {
|
|||||||
language1: string,
|
language1: string,
|
||||||
language2: string,
|
language2: string,
|
||||||
) => {
|
) => {
|
||||||
await createPair({
|
await actionCreatePair({
|
||||||
text1: text1,
|
text1: text1,
|
||||||
text2: text2,
|
text2: text2,
|
||||||
language1: language1,
|
language1: language1,
|
||||||
language2: language2,
|
language2: language2,
|
||||||
folder: {
|
folderId: folderId,
|
||||||
connect: {
|
|
||||||
id: folderId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
refreshTextPairs();
|
refreshTextPairs();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
import { Edit, Trash2 } from "lucide-react";
|
import { Edit, Trash2 } from "lucide-react";
|
||||||
import { TextPair } from "./InFolder";
|
|
||||||
import { updatePairById } from "@/lib/server/services/pairService";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import UpdateTextPairModal from "./UpdateTextPairModal";
|
import { CircleButton } from "@/design-system/base/button";
|
||||||
|
import { UpdateTextPairModal } from "./UpdateTextPairModal";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { PairUpdateInput } from "../../../../generated/prisma/models";
|
import { TSharedPair } from "@/shared/folder-type";
|
||||||
|
import { actionUpdatePairById } from "@/modules/folder/folder-aciton";
|
||||||
|
import { ActionInputUpdatePairById } from "@/modules/folder/folder-action-dto";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface TextPairCardProps {
|
interface TextPairCardProps {
|
||||||
textPair: TextPair;
|
textPair: TSharedPair;
|
||||||
|
isReadOnly: boolean;
|
||||||
onDel: () => void;
|
onDel: () => void;
|
||||||
refreshTextPairs: () => void;
|
refreshTextPairs: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TextPairCard({
|
export function TextPairCard({
|
||||||
textPair,
|
textPair,
|
||||||
|
isReadOnly,
|
||||||
onDel,
|
onDel,
|
||||||
refreshTextPairs,
|
refreshTextPairs,
|
||||||
}: TextPairCardProps) {
|
}: TextPairCardProps) {
|
||||||
@@ -34,20 +38,24 @@ export default function TextPairCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 opacity-50 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center gap-1 opacity-50 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
{!isReadOnly && (
|
||||||
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-md transition-colors"
|
<>
|
||||||
|
<CircleButton
|
||||||
onClick={() => setOpenUpdateModal(true)}
|
onClick={() => setOpenUpdateModal(true)}
|
||||||
title={t("edit")}
|
title={t("edit")}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
>
|
>
|
||||||
<Edit size={14} />
|
<Edit size={14} />
|
||||||
</button>
|
</CircleButton>
|
||||||
<button
|
<CircleButton
|
||||||
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-colors"
|
|
||||||
onClick={onDel}
|
onClick={onDel}
|
||||||
title={t("delete")}
|
title={t("delete")}
|
||||||
|
className="text-gray-400 hover:text-red-500 hover:bg-red-50"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</CircleButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-gray-900 grid grid-cols-2 gap-4 w-3/4">
|
<div className="text-gray-900 grid grid-cols-2 gap-4 w-3/4">
|
||||||
@@ -66,8 +74,8 @@ export default function TextPairCard({
|
|||||||
<UpdateTextPairModal
|
<UpdateTextPairModal
|
||||||
isOpen={openUpdateModal}
|
isOpen={openUpdateModal}
|
||||||
onClose={() => setOpenUpdateModal(false)}
|
onClose={() => setOpenUpdateModal(false)}
|
||||||
onUpdate={async (id: number, data: PairUpdateInput) => {
|
onUpdate={async (id: number, data: ActionInputUpdatePairById) => {
|
||||||
await updatePairById(id, data);
|
await actionUpdatePairById(id, data).then(result => result.success ? toast.success(result.message) : toast.error(result.message));
|
||||||
setOpenUpdateModal(false);
|
setOpenUpdateModal(false);
|
||||||
refreshTextPairs();
|
refreshTextPairs();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import { LightButton } from "@/components/ui/buttons";
|
import { LightButton } from "@/design-system/base/button";
|
||||||
import Input from "@/components/ui/Input";
|
import { Input } from "@/design-system/base/input";
|
||||||
import { LocaleSelector } from "@/components/ui/LocaleSelector";
|
import { LocaleSelector } from "@/components/ui/LocaleSelector";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { PairUpdateInput } from "../../../../generated/prisma/models";
|
|
||||||
import { TextPair } from "./InFolder";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { TSharedPair } from "@/shared/folder-type";
|
||||||
|
import { ActionInputUpdatePairById } from "@/modules/folder/folder-action-dto";
|
||||||
|
|
||||||
interface UpdateTextPairModalProps {
|
interface UpdateTextPairModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
textPair: TextPair;
|
textPair: TSharedPair;
|
||||||
onUpdate: (id: number, tp: PairUpdateInput) => void;
|
onUpdate: (id: number, tp: ActionInputUpdatePairById) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UpdateTextPairModal({
|
export function UpdateTextPairModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
@@ -63,7 +63,7 @@ export default function UpdateTextPairModal({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
|
<div className="bg-white rounded-md p-6 w-full max-w-md mx-4">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<h2 className="flex-1 text-xl font-light mb-4 text-center">
|
<h2 className="flex-1 text-xl font-light mb-4 text-center">
|
||||||
{t("updateTextPair")}
|
{t("updateTextPair")}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import InFolder from "./InFolder";
|
import { InFolder } from "./InFolder";
|
||||||
import { getUserIdByFolderId } from "@/lib/server/services/folderService";
|
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import { actionGetUserIdByFolderId } from "@/modules/folder/folder-aciton";
|
||||||
|
|
||||||
export default async function FoldersPage({
|
export default async function FoldersPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
@@ -16,9 +17,11 @@ export default async function FoldersPage({
|
|||||||
if (!folder_id) {
|
if (!folder_id) {
|
||||||
redirect("/folders");
|
redirect("/folders");
|
||||||
}
|
}
|
||||||
if (!session) redirect(`/auth?redirect=/folders/${folder_id}`);
|
|
||||||
if ((await getUserIdByFolderId(Number(folder_id))) !== session.user.id) {
|
// Allow non-authenticated users to view folders (read-only mode)
|
||||||
return <p>{t("unauthorized")}</p>;
|
const folderUserId = (await actionGetUserIdByFolderId(Number(folder_id))).data;
|
||||||
}
|
const isOwner = session?.user?.id === folderUserId;
|
||||||
return <InFolder folderId={Number(folder_id)} />;
|
const isReadOnly = !isOwner;
|
||||||
|
|
||||||
|
return <InFolder folderId={Number(folder_id)} isReadOnly={isReadOnly} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import FoldersClient from "./FoldersClient";
|
import { FoldersClient } from "./FoldersClient";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,230 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tailwind CSS v4 主题配置
|
||||||
|
* 使用 @theme 指令定义主题变量
|
||||||
|
*/
|
||||||
|
@theme {
|
||||||
|
/* 主色 - Teal */
|
||||||
|
--color-primary-50: #f0f9f8;
|
||||||
|
--color-primary-100: #e0f2f0;
|
||||||
|
--color-primary-200: #bce6e1;
|
||||||
|
--color-primary-300: #8dd4cc;
|
||||||
|
--color-primary-400: #5ec2b7;
|
||||||
|
--color-primary-500: #35786f;
|
||||||
|
--color-primary-600: #2a605b;
|
||||||
|
--color-primary-700: #1f4844;
|
||||||
|
--color-primary-800: #183835;
|
||||||
|
--color-primary-900: #122826;
|
||||||
|
--color-primary-950: #0a1413;
|
||||||
|
|
||||||
|
/* 中性色 */
|
||||||
|
--color-gray-50: #f9fafb;
|
||||||
|
--color-gray-100: #f3f4f6;
|
||||||
|
--color-gray-200: #e5e7eb;
|
||||||
|
--color-gray-300: #d1d5db;
|
||||||
|
--color-gray-400: #9ca3af;
|
||||||
|
--color-gray-500: #6b7280;
|
||||||
|
--color-gray-600: #4b5563;
|
||||||
|
--color-gray-700: #374151;
|
||||||
|
--color-gray-800: #1f2937;
|
||||||
|
--color-gray-900: #111827;
|
||||||
|
--color-gray-950: #030712;
|
||||||
|
|
||||||
|
/* 语义色 - Success */
|
||||||
|
--color-success-50: #f0fdf4;
|
||||||
|
--color-success-100: #dcfce7;
|
||||||
|
--color-success-200: #bbf7d0;
|
||||||
|
--color-success-300: #86efac;
|
||||||
|
--color-success-400: #4ade80;
|
||||||
|
--color-success-500: #22c55e;
|
||||||
|
--color-success-600: #16a34a;
|
||||||
|
--color-success-700: #15803d;
|
||||||
|
--color-success-800: #166534;
|
||||||
|
--color-success-900: #14532d;
|
||||||
|
--color-success-950: #052e16;
|
||||||
|
|
||||||
|
/* 语义色 - Warning */
|
||||||
|
--color-warning-50: #fffbeb;
|
||||||
|
--color-warning-100: #fef3c7;
|
||||||
|
--color-warning-200: #fde68a;
|
||||||
|
--color-warning-300: #fcd34d;
|
||||||
|
--color-warning-400: #fbbf24;
|
||||||
|
--color-warning-500: #f59e0b;
|
||||||
|
--color-warning-600: #d97706;
|
||||||
|
--color-warning-700: #b45309;
|
||||||
|
--color-warning-800: #92400e;
|
||||||
|
--color-warning-900: #78350f;
|
||||||
|
--color-warning-950: #451a03;
|
||||||
|
|
||||||
|
/* 语义色 - Error */
|
||||||
|
--color-error-50: #fef2f2;
|
||||||
|
--color-error-100: #fee2e2;
|
||||||
|
--color-error-200: #fecaca;
|
||||||
|
--color-error-300: #fca5a5;
|
||||||
|
--color-error-400: #f87171;
|
||||||
|
--color-error-500: #ef4444;
|
||||||
|
--color-error-600: #dc2626;
|
||||||
|
--color-error-700: #b91c1c;
|
||||||
|
--color-error-800: #991b1b;
|
||||||
|
--color-error-900: #7f1d1d;
|
||||||
|
--color-error-950: #450a0a;
|
||||||
|
|
||||||
|
/* 语义色 - Info */
|
||||||
|
--color-info-50: #eff6ff;
|
||||||
|
--color-info-100: #dbeafe;
|
||||||
|
--color-info-200: #bfdbfe;
|
||||||
|
--color-info-300: #93c5fd;
|
||||||
|
--color-info-400: #60a5fa;
|
||||||
|
--color-info-500: #3b82f6;
|
||||||
|
--color-info-600: #2563eb;
|
||||||
|
--color-info-700: #1d4ed8;
|
||||||
|
--color-info-800: #1e40af;
|
||||||
|
--color-info-900: #1e3a8a;
|
||||||
|
--color-info-950: #172554;
|
||||||
|
|
||||||
|
/* 圆角 - 更小的圆角 */
|
||||||
|
--radius-xs: 0.125rem;
|
||||||
|
--radius-sm: 0.25rem;
|
||||||
|
--radius-md: 0.375rem;
|
||||||
|
--radius-lg: 0.5rem;
|
||||||
|
--radius-xl: 0.625rem;
|
||||||
|
--radius-2xl: 0.75rem;
|
||||||
|
--radius-3xl: 1rem;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Design System CSS 变量
|
||||||
|
*
|
||||||
|
* 定义全局 CSS 变量用于主题切换和动态样式
|
||||||
|
*/
|
||||||
:root {
|
:root {
|
||||||
|
/* 基础颜色 */
|
||||||
--background: #ffffff;
|
--background: #ffffff;
|
||||||
--foreground: #171717;
|
--foreground: #111827;
|
||||||
|
--foreground-secondary: #4b5563;
|
||||||
|
--foreground-tertiary: #6b7280;
|
||||||
|
--foreground-disabled: #9ca3af;
|
||||||
|
|
||||||
|
/* 背景 */
|
||||||
|
--background-secondary: #f3f4f6;
|
||||||
|
--background-tertiary: #e5e7eb;
|
||||||
|
|
||||||
|
/* 边框 */
|
||||||
|
--border: #d1d5db;
|
||||||
|
--border-secondary: #e5e7eb;
|
||||||
|
--border-focus: #35786f;
|
||||||
|
|
||||||
|
/* 圆角 - 更小的圆角 */
|
||||||
|
--radius-xs: 0.125rem;
|
||||||
|
--radius-sm: 0.25rem;
|
||||||
|
--radius-md: 0.375rem;
|
||||||
|
--radius-lg: 0.5rem;
|
||||||
|
--radius-xl: 0.625rem;
|
||||||
|
--radius-2xl: 0.75rem;
|
||||||
|
--radius-3xl: 1rem;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* 阴影 */
|
||||||
|
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
|
--shadow-primary: 0 4px 14px 0 rgba(53, 120, 111, 0.39);
|
||||||
|
|
||||||
|
/* 间距 */
|
||||||
|
--spacing-xs: 0.25rem;
|
||||||
|
--spacing-sm: 0.5rem;
|
||||||
|
--spacing-md: 1rem;
|
||||||
|
--spacing-lg: 1.5rem;
|
||||||
|
--spacing-xl: 2rem;
|
||||||
|
--spacing-2xl: 3rem;
|
||||||
|
|
||||||
|
/* 过渡 */
|
||||||
|
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-slow: 350ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
/**
|
||||||
--color-background: var(--background);
|
* 全局基础样式
|
||||||
--color-foreground: var(--foreground);
|
*/
|
||||||
--font-sans: var(--font-geist-sans);
|
* {
|
||||||
--font-mono: var(--font-geist-mono);
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* @media (prefers-color-scheme: dark) {
|
html {
|
||||||
:root {
|
height: 100%;
|
||||||
--background: #0a0a0a;
|
-webkit-font-smoothing: antialiased;
|
||||||
--foreground: #ededed;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
} */
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
|
font-family: var(--font-geist-sans), -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-block {
|
/**
|
||||||
font-family: var(--font-geist-mono), monospace;
|
* 代码块字体
|
||||||
|
*/
|
||||||
|
.code-block,
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
pre,
|
||||||
|
samp {
|
||||||
|
font-family: var(--font-geist-mono), ui-monospace, SFMono-Regular, Monaco, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导航栏按钮样式
|
||||||
|
*/
|
||||||
|
.navbar-btn {
|
||||||
|
@apply border-0 bg-transparent hover:bg-black/30 shadow-none;
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 焦点可见性优化
|
||||||
|
*/
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--border-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选择文本样式
|
||||||
|
*/
|
||||||
|
::selection {
|
||||||
|
background-color: var(--color-primary-200);
|
||||||
|
color: var(--color-primary-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 滚动条样式
|
||||||
|
*/
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--background-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--foreground-tertiary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--foreground-secondary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function LinkArea({ href, name, description, color }: LinkAreaProps) {
|
|||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: color }}
|
||||||
className={`h-32 md:h-64 flex md:justify-center items-center`}
|
className={`hover:scale-105 transition-transform duration-200 h-32 md:h-64 flex md:justify-center items-center`}
|
||||||
>
|
>
|
||||||
<div className="text-white m-8">
|
<div className="text-white m-8">
|
||||||
<h1 className="md:text-4xl text-3xl">{name}</h1>
|
<h1 className="md:text-4xl text-3xl">{name}</h1>
|
||||||
|
|||||||
@@ -1,49 +1,16 @@
|
|||||||
import Image from "next/image";
|
|
||||||
import PageLayout from "@/components/ui/PageLayout";
|
|
||||||
import PageHeader from "@/components/ui/PageHeader";
|
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import LogoutButton from "./LogoutButton";
|
|
||||||
|
|
||||||
export default async function ProfilePage() {
|
export default async function ProfilePage() {
|
||||||
const t = await getTranslations("profile");
|
|
||||||
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
redirect("/auth?redirect=/profile");
|
redirect("/auth?redirect=/profile");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// 已登录,跳转到用户资料页面
|
||||||
<PageLayout>
|
// 优先使用 username,如果没有则使用 email
|
||||||
<PageHeader title={t("myProfile")} />
|
const username = (session.user.username as string) || (session.user.email as string);
|
||||||
|
redirect(`/users/${username}`);
|
||||||
{/* 用户信息区域 */}
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
{/* 用户头像 */}
|
|
||||||
{session.user.image && (
|
|
||||||
<Image
|
|
||||||
width={80}
|
|
||||||
height={80}
|
|
||||||
alt="User Avatar"
|
|
||||||
src={session.user.image as string}
|
|
||||||
className="rounded-full"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 用户名和邮箱 */}
|
|
||||||
<div className="text-center">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-800">
|
|
||||||
{session.user.name}
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600">{t("email", { email: session.user.email })}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 登出按钮 */}
|
|
||||||
<LogoutButton />
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LightButton } from "@/components/ui/buttons";
|
import { LightButton } from "@/design-system/base/button";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
export default function LogoutButton() {
|
export function LogoutButton() {
|
||||||
const t = useTranslations("profile");
|
const t = useTranslations("profile");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
return <LightButton onClick={async () => {
|
return <LightButton onClick={async () => {
|
||||||
195
src/app/users/[username]/page.tsx
Normal file
195
src/app/users/[username]/page.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
|
import { LinkButton } from "@/design-system/base/button";
|
||||||
|
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
|
||||||
|
import { repoGetFoldersWithTotalPairsByUserId } from "@/modules/folder/folder-repository";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { LogoutButton } from "@/app/users/[username]/LogoutButton";
|
||||||
|
|
||||||
|
interface UserPageProps {
|
||||||
|
params: Promise<{ username: string; }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function UserPage({ params }: UserPageProps) {
|
||||||
|
const { username } = await params;
|
||||||
|
const t = await getTranslations("user_profile");
|
||||||
|
|
||||||
|
// Get current session
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
|
||||||
|
// Get user profile
|
||||||
|
const result = await actionGetUserProfileByUsername({ username });
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = result.data;
|
||||||
|
|
||||||
|
// Get user's folders
|
||||||
|
const folders = await repoGetFoldersWithTotalPairsByUserId(user.id);
|
||||||
|
|
||||||
|
// Check if viewing own profile
|
||||||
|
const isOwnProfile = session?.user?.username === username || session?.user?.email === username;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div></div>
|
||||||
|
{isOwnProfile && <LogoutButton />}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-6">
|
||||||
|
{/* Avatar */}
|
||||||
|
{user.image ? (
|
||||||
|
<div className="relative w-24 h-24 rounded-full border-4 border-[#35786f] overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={user.image}
|
||||||
|
alt={user.displayUsername || user.username || user.email}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-24 h-24 rounded-full bg-[#35786f] border-4 border-[#35786f] flex items-center justify-center">
|
||||||
|
<span className="text-3xl font-bold text-white">
|
||||||
|
{(user.displayUsername || user.username || user.email)[0].toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User Info */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-800 mb-2">
|
||||||
|
{user.displayUsername || user.username || t("anonymous")}
|
||||||
|
</h1>
|
||||||
|
{user.username && (
|
||||||
|
<p className="text-gray-600 text-sm mb-1">
|
||||||
|
@{user.username}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center space-x-4 text-sm">
|
||||||
|
<span className="text-gray-500">
|
||||||
|
Joined: {new Date(user.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
{user.emailVerified && (
|
||||||
|
<span className="flex items-center text-green-600">
|
||||||
|
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 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" />
|
||||||
|
</svg>
|
||||||
|
Verified
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</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 */}
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("accountInfo")}</h2>
|
||||||
|
<dl className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-gray-500">{t("userId")}</dt>
|
||||||
|
<dd className="mt-1 text-sm text-gray-900 font-mono break-all">{user.id}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-gray-500">{t("username")}</dt>
|
||||||
|
<dd className="mt-1 text-sm text-gray-900">
|
||||||
|
{user.username || <span className="text-gray-400">{t("notSet")}</span>}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-gray-500">{t("displayName")}</dt>
|
||||||
|
<dd className="mt-1 text-sm text-gray-900">
|
||||||
|
{user.displayUsername || <span className="text-gray-400">{t("notSet")}</span>}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-gray-500">{t("memberSince")}</dt>
|
||||||
|
<dd className="mt-1 text-sm text-gray-900">
|
||||||
|
{new Date(user.createdAt).toLocaleDateString()}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Folders Section */}
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("folders.title")}</h2>
|
||||||
|
{folders.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-center py-8">{t("folders.noFolders")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t("folders.folderName")}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t("folders.totalPairs")}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t("folders.createdAt")}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t("folders.actions")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{folders.map((folder) => (
|
||||||
|
<tr key={folder.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm font-medium text-gray-900">{folder.name}</div>
|
||||||
|
<div className="text-sm text-gray-500">ID: {folder.id}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-900">{folder.total}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{new Date(folder.createdAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<Link href={`/folders/${folder.id}`}>
|
||||||
|
<LinkButton>
|
||||||
|
{t("folders.view")}
|
||||||
|
</LinkButton>
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
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 { prisma } from "./lib/db";
|
||||||
|
import { username } from "better-auth/plugins";
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
database: prismaAdapter(prisma, {
|
database: prismaAdapter(prisma, {
|
||||||
@@ -16,5 +17,5 @@ export const auth = betterAuth({
|
|||||||
clientSecret: process.env.GITHUB_CLIENT_SECRET as string
|
clientSecret: process.env.GITHUB_CLIENT_SECRET as string
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [nextCookies()]
|
plugins: [nextCookies(), username()]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import IMAGES from "@/config/images";
|
import { GhostLightButton } from "@/design-system/base/button";
|
||||||
import { IconClick, GhostButton } from "./ui/buttons";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Languages } from "lucide-react";
|
||||||
|
|
||||||
export default function LanguageSettings() {
|
export function LanguageSettings() {
|
||||||
const [showLanguageMenu, setShowLanguageMenu] = useState(false);
|
const [showLanguageMenu, setShowLanguageMenu] = useState(false);
|
||||||
const handleLanguageClick = () => {
|
const handleLanguageClick = () => {
|
||||||
setShowLanguageMenu((prev) => !prev);
|
setShowLanguageMenu((prev) => !prev);
|
||||||
@@ -15,65 +15,59 @@ export default function LanguageSettings() {
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<IconClick
|
<Languages onClick={handleLanguageClick} size={28} className="text-white hover:text-white/80" />
|
||||||
src={IMAGES.language_white}
|
|
||||||
alt="language"
|
|
||||||
disableOnHoverBgChange={true}
|
|
||||||
onClick={handleLanguageClick}
|
|
||||||
size={40}
|
|
||||||
></IconClick>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{showLanguageMenu && (
|
{showLanguageMenu && (
|
||||||
<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">
|
||||||
<GhostButton
|
<GhostLightButton
|
||||||
className="w-full bg-[#35786f]"
|
className="w-full bg-[#35786f]"
|
||||||
onClick={() => setLocale("en-US")}
|
onClick={() => setLocale("en-US")}
|
||||||
>
|
>
|
||||||
English
|
English
|
||||||
</GhostButton>
|
</GhostLightButton>
|
||||||
<GhostButton
|
<GhostLightButton
|
||||||
className="w-full bg-[#35786f]"
|
className="w-full bg-[#35786f]"
|
||||||
onClick={() => setLocale("zh-CN")}
|
onClick={() => setLocale("zh-CN")}
|
||||||
>
|
>
|
||||||
中文
|
中文
|
||||||
</GhostButton>
|
</GhostLightButton>
|
||||||
<GhostButton
|
<GhostLightButton
|
||||||
className="w-full bg-[#35786f]"
|
className="w-full bg-[#35786f]"
|
||||||
onClick={() => setLocale("ja-JP")}
|
onClick={() => setLocale("ja-JP")}
|
||||||
>
|
>
|
||||||
日本語
|
日本語
|
||||||
</GhostButton>
|
</GhostLightButton>
|
||||||
<GhostButton
|
<GhostLightButton
|
||||||
className="w-full bg-[#35786f]"
|
className="w-full bg-[#35786f]"
|
||||||
onClick={() => setLocale("ko-KR")}
|
onClick={() => setLocale("ko-KR")}
|
||||||
>
|
>
|
||||||
한국어
|
한국어
|
||||||
</GhostButton>
|
</GhostLightButton>
|
||||||
<GhostButton
|
<GhostLightButton
|
||||||
className="w-full bg-[#35786f]"
|
className="w-full bg-[#35786f]"
|
||||||
onClick={() => setLocale("de-DE")}
|
onClick={() => setLocale("de-DE")}
|
||||||
>
|
>
|
||||||
Deutsch
|
Deutsch
|
||||||
</GhostButton>
|
</GhostLightButton>
|
||||||
<GhostButton
|
<GhostLightButton
|
||||||
className="w-full bg-[#35786f]"
|
className="w-full bg-[#35786f]"
|
||||||
onClick={() => setLocale("fr-FR")}
|
onClick={() => setLocale("fr-FR")}
|
||||||
>
|
>
|
||||||
Français
|
Français
|
||||||
</GhostButton>
|
</GhostLightButton>
|
||||||
<GhostButton
|
<GhostLightButton
|
||||||
className="w-full bg-[#35786f]"
|
className="w-full bg-[#35786f]"
|
||||||
onClick={() => setLocale("it-IT")}
|
onClick={() => setLocale("it-IT")}
|
||||||
>
|
>
|
||||||
Italiano
|
Italiano
|
||||||
</GhostButton>
|
</GhostLightButton>
|
||||||
<GhostButton
|
<GhostLightButton
|
||||||
className="w-full bg-[#35786f]"
|
className="w-full bg-[#35786f]"
|
||||||
onClick={() => setLocale("ug-CN")}
|
onClick={() => setLocale("ug-CN")}
|
||||||
>
|
>
|
||||||
ئۇيغۇرچە
|
ئۇيغۇرچە
|
||||||
</GhostButton>
|
</GhostLightButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
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 { Folder, 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";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { GhostButton } from "../ui/buttons";
|
import { GhostLightButton } from "@/design-system/base/button";
|
||||||
|
|
||||||
export async function Navbar() {
|
export async function Navbar() {
|
||||||
const t = await getTranslations("navbar");
|
const t = await getTranslations("navbar");
|
||||||
@@ -15,16 +15,17 @@ export async function Navbar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between items-center w-full h-16 px-4 md:px-8 bg-[#35786f] text-white">
|
<div className="flex justify-between items-center w-full h-16 px-4 md:px-8 bg-[#35786f] text-white">
|
||||||
<GhostButton href="/" className="text-lg md:text-xl border-b hidden! md:block!">
|
<GhostLightButton href="/" className="border-b hidden! md:block!" size="md">
|
||||||
{t("title")}
|
{t("title")}
|
||||||
</GhostButton>
|
</GhostLightButton>
|
||||||
<GhostButton className="block! md:hidden!" href={"/"}>
|
<GhostLightButton className="block! md:hidden!" size="md" href={"/"}>
|
||||||
<Home size={20} />
|
<Home size={20} />
|
||||||
</GhostButton>
|
</GhostLightButton>
|
||||||
<div className="flex text-base md:text-xl gap-0.5 justify-center items-center flex-wrap">
|
<div className="flex gap-0.5 justify-center items-center flex-wrap">
|
||||||
<LanguageSettings />
|
<LanguageSettings />
|
||||||
<GhostButton
|
<GhostLightButton
|
||||||
className="md:hidden! block! border-0 bg-transparent hover:bg-black/30 shadow-none p-2"
|
className="md:hidden! block!"
|
||||||
|
size="md"
|
||||||
href="https://github.com/GoddoNebianU/learn-languages"
|
href="https://github.com/GoddoNebianU/learn-languages"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
@@ -33,35 +34,35 @@ export async function Navbar() {
|
|||||||
width={20}
|
width={20}
|
||||||
height={20}
|
height={20}
|
||||||
/>
|
/>
|
||||||
</GhostButton>
|
</GhostLightButton>
|
||||||
<GhostButton href="/folders" className="md:block! hidden! border-0 bg-transparent hover:bg-black/30 shadow-none">
|
<GhostLightButton href="/folders" className="md:block! hidden!" size="md">
|
||||||
{t("folders")}
|
{t("folders")}
|
||||||
</GhostButton>
|
</GhostLightButton>
|
||||||
<GhostButton href="/folders" className="md:hidden! block! border-0 bg-transparent hover:bg-black/30 shadow-none p-2">
|
<GhostLightButton href="/folders" className="md:hidden! block!" size="md">
|
||||||
<Folder size={20} />
|
<Folder size={20} />
|
||||||
</GhostButton>
|
</GhostLightButton>
|
||||||
<GhostButton
|
<GhostLightButton
|
||||||
className="hidden! md:block! border-0 bg-transparent hover:bg-black/30 shadow-none"
|
className="hidden! md:block!"
|
||||||
|
size="md"
|
||||||
href="https://github.com/GoddoNebianU/learn-languages"
|
href="https://github.com/GoddoNebianU/learn-languages"
|
||||||
>
|
>
|
||||||
{t("sourceCode")}
|
{t("sourceCode")}
|
||||||
</GhostButton>
|
</GhostLightButton>
|
||||||
{
|
{
|
||||||
(() => {
|
(() => {
|
||||||
return session &&
|
return session &&
|
||||||
<>
|
<>
|
||||||
<GhostButton href="/profile" className="hidden! md:block! text-sm md:text-base border-0 bg-transparent hover:bg-black/30 shadow-none px-2 py-1">{t("profile")}</GhostButton>
|
<GhostLightButton href="/profile" className="hidden! md:block!" size="md">{t("profile")}</GhostLightButton>
|
||||||
<GhostButton href="/profile" className="md:hidden! block! border-0 bg-transparent hover:bg-black/30 shadow-none p-2">
|
<GhostLightButton href="/profile" className="md:hidden! block!" size="md">
|
||||||
<User size={20} />
|
<User size={20} />
|
||||||
</GhostButton>
|
</GhostLightButton>
|
||||||
</>
|
</>
|
||||||
|| <>
|
|| <>
|
||||||
<GhostButton href="/auth" className="hidden! md:block! text-sm md:text-base border-0 bg-transparent hover:bg-black/30 shadow-none px-2 py-1">{t("sign_in")}</GhostButton>
|
<GhostLightButton href="/auth" className="hidden! md:block!" size="md">{t("sign_in")}</GhostLightButton>
|
||||||
<GhostButton href="/auth" className="md:hidden! block! border-0 bg-transparent hover:bg-black/30 shadow-none p-2">
|
<GhostLightButton href="/auth" className="md:hidden! block!" size="md">
|
||||||
<User size={20} />
|
<User size={20} />
|
||||||
</GhostButton>
|
</GhostLightButton>
|
||||||
</>;
|
</>;
|
||||||
|
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { COLORS } from "@/lib/theme/colors";
|
|
||||||
|
|
||||||
export type ButtonVariant = "primary" | "secondary" | "ghost" | "icon";
|
|
||||||
export type ButtonSize = "sm" | "md" | "lg";
|
|
||||||
|
|
||||||
export interface ButtonProps {
|
|
||||||
// Content
|
|
||||||
children?: React.ReactNode;
|
|
||||||
|
|
||||||
// Behavior
|
|
||||||
onClick?: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
type?: "button" | "submit" | "reset";
|
|
||||||
|
|
||||||
// Styling
|
|
||||||
variant?: ButtonVariant;
|
|
||||||
size?: ButtonSize;
|
|
||||||
className?: string;
|
|
||||||
selected?: boolean;
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
|
|
||||||
// Icons
|
|
||||||
leftIcon?: React.ReactNode;
|
|
||||||
rightIcon?: React.ReactNode;
|
|
||||||
iconSrc?: string; // For Next.js Image icons
|
|
||||||
iconAlt?: string;
|
|
||||||
|
|
||||||
// Navigation
|
|
||||||
href?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Button({
|
|
||||||
variant = "secondary",
|
|
||||||
size = "md",
|
|
||||||
selected = false,
|
|
||||||
href,
|
|
||||||
iconSrc,
|
|
||||||
iconAlt,
|
|
||||||
leftIcon,
|
|
||||||
rightIcon,
|
|
||||||
children,
|
|
||||||
className = "",
|
|
||||||
style,
|
|
||||||
type = "button",
|
|
||||||
disabled = false,
|
|
||||||
...props
|
|
||||||
}: ButtonProps) {
|
|
||||||
// Base classes
|
|
||||||
const baseClasses = "inline-flex items-center justify-center gap-2 rounded font-bold shadow hover:cursor-pointer transition-colors";
|
|
||||||
|
|
||||||
// Variant-specific classes
|
|
||||||
const variantStyles: Record<ButtonVariant, string> = {
|
|
||||||
primary: `
|
|
||||||
text-white
|
|
||||||
hover:opacity-90
|
|
||||||
`,
|
|
||||||
secondary: `
|
|
||||||
text-black
|
|
||||||
hover:bg-gray-100
|
|
||||||
`,
|
|
||||||
ghost: `
|
|
||||||
hover:bg-black/30
|
|
||||||
p-2
|
|
||||||
`,
|
|
||||||
icon: `
|
|
||||||
p-2 bg-gray-200 rounded-full
|
|
||||||
hover:bg-gray-300
|
|
||||||
`
|
|
||||||
};
|
|
||||||
|
|
||||||
// Size-specific classes
|
|
||||||
const sizeStyles: Record<ButtonSize, string> = {
|
|
||||||
sm: "px-3 py-1 text-sm",
|
|
||||||
md: "px-4 py-2",
|
|
||||||
lg: "px-6 py-3 text-lg"
|
|
||||||
};
|
|
||||||
|
|
||||||
const variantClass = variantStyles[variant];
|
|
||||||
const sizeClass = sizeStyles[size];
|
|
||||||
|
|
||||||
// Selected state for secondary variant
|
|
||||||
const selectedClass = variant === "secondary" && selected ? "bg-gray-100" : "";
|
|
||||||
|
|
||||||
// Background color for primary variant
|
|
||||||
const backgroundColor = variant === "primary" ? COLORS.primary : undefined;
|
|
||||||
|
|
||||||
// Combine all classes
|
|
||||||
const combinedClasses = `
|
|
||||||
${baseClasses}
|
|
||||||
${variantClass}
|
|
||||||
${sizeClass}
|
|
||||||
${selectedClass}
|
|
||||||
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
|
||||||
${className}
|
|
||||||
`.trim().replace(/\s+/g, " ");
|
|
||||||
|
|
||||||
// Icon rendering helper for SVG icons
|
|
||||||
const renderSvgIcon = (icon: React.ReactNode, position: "left" | "right") => {
|
|
||||||
if (!icon) return null;
|
|
||||||
return (
|
|
||||||
<span className={`flex items-center ${position === "left" ? "-ml-1 mr-2" : "-mr-1 ml-2"}`}>
|
|
||||||
{icon}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Image icon rendering for Next.js Image
|
|
||||||
const renderImageIcon = () => {
|
|
||||||
if (!iconSrc) return null;
|
|
||||||
const sizeMap = { sm: 16, md: 20, lg: 24 };
|
|
||||||
const imgSize = sizeMap[size] || 20;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Image
|
|
||||||
src={iconSrc}
|
|
||||||
width={imgSize}
|
|
||||||
height={imgSize}
|
|
||||||
alt={iconAlt || "icon"}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Content assembly
|
|
||||||
const content = (
|
|
||||||
<>
|
|
||||||
{renderImageIcon()}
|
|
||||||
{renderSvgIcon(leftIcon, "left")}
|
|
||||||
{children}
|
|
||||||
{renderSvgIcon(rightIcon, "right")}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
// If href is provided, render as Link
|
|
||||||
if (href) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={href}
|
|
||||||
className={combinedClasses}
|
|
||||||
style={{ ...style, backgroundColor }}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise render as button
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type={type}
|
|
||||||
disabled={disabled}
|
|
||||||
className={combinedClasses}
|
|
||||||
style={{ ...style, backgroundColor }}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,30 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* CardList - 可滚动的卡片列表容器
|
* CardList - 可滚动的卡片列表容器
|
||||||
*
|
*
|
||||||
* 用于显示可滚动的列表内容,如文件夹列表、文本对列表等
|
* 使用 Design System 重写的卡片列表组件
|
||||||
* - 最大高度 96 (24rem)
|
|
||||||
* - 垂直滚动
|
|
||||||
* - 圆角边框
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <CardList>
|
|
||||||
* {items.map(item => (
|
|
||||||
* <div key={item.id}>{item.name}</div>
|
|
||||||
* ))}
|
|
||||||
* </CardList>
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
|
import { VStack } from "@/design-system/layout/stack";
|
||||||
|
|
||||||
interface CardListProps {
|
interface CardListProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
/** 额外的 CSS 类名 */
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CardList({ children, className = "" }: CardListProps) {
|
export function CardList({ children, className = "" }: CardListProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`max-h-96 overflow-y-auto rounded-xl border border-gray-200 overflow-hidden ${className}`}>
|
<div className={`max-h-96 overflow-y-auto rounded-lg border-2 border-gray-200 ${className}`}>
|
||||||
|
<VStack gap={0}>
|
||||||
{children}
|
{children}
|
||||||
|
</VStack>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Container - 容器组件
|
||||||
|
*
|
||||||
|
* 使用 Design System 重写的容器组件
|
||||||
|
*/
|
||||||
|
import { Container as DSContainer } from "@/design-system/layout/container";
|
||||||
|
import { Card } from "@/design-system/base/card";
|
||||||
|
|
||||||
interface ContainerProps {
|
interface ContainerProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Container({ children, className }: ContainerProps) {
|
export function Container({ children, className = "" }: ContainerProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<DSContainer size="2xl" className={`mx-auto ${className}`}>
|
||||||
className={`w-full max-w-2xl mx-auto bg-white border border-gray-200 rounded-2xl ${className}`}
|
<Card variant="bordered" padding="md">
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</Card>
|
||||||
|
</DSContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
interface Props {
|
|
||||||
ref?: React.Ref<HTMLInputElement>;
|
|
||||||
placeholder?: string;
|
|
||||||
type?: string;
|
|
||||||
className?: string;
|
|
||||||
name?: string;
|
|
||||||
defaultValue?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Input({
|
|
||||||
ref,
|
|
||||||
placeholder = "",
|
|
||||||
type = "text",
|
|
||||||
className = "",
|
|
||||||
name = "",
|
|
||||||
defaultValue = "",
|
|
||||||
}: Props) {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
ref={ref}
|
|
||||||
placeholder={placeholder}
|
|
||||||
type={type}
|
|
||||||
className={`block focus:outline-none border-b-2 border-gray-600 ${className}`}
|
|
||||||
name={name}
|
|
||||||
defaultValue={defaultValue}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* LocaleSelector - 语言选择器组件
|
||||||
|
*
|
||||||
|
* 使用 Design System 重写的语言选择器组件
|
||||||
|
*/
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Input } from "@/design-system/base/input";
|
||||||
|
import { Select } from "@/design-system/base/select";
|
||||||
|
import { VStack } from "@/design-system/layout/stack";
|
||||||
|
|
||||||
const COMMON_LANGUAGES = [
|
const COMMON_LANGUAGES = [
|
||||||
{ label: "中文", value: "chinese" },
|
{ label: "chinese", value: "chinese" },
|
||||||
{ label: "英文", value: "english" },
|
{ label: "english", value: "english" },
|
||||||
{ label: "意大利语", value: "italian" },
|
{ label: "italian", value: "italian" },
|
||||||
{ label: "日语", value: "japanese" },
|
{ label: "japanese", value: "japanese" },
|
||||||
{ label: "韩语", value: "korean" },
|
{ label: "korean", value: "korean" },
|
||||||
{ label: "法语", value: "french" },
|
{ label: "french", value: "french" },
|
||||||
{ label: "德语", value: "german" },
|
{ label: "german", value: "german" },
|
||||||
{ label: "西班牙语", value: "spanish" },
|
{ label: "spanish", value: "spanish" },
|
||||||
{ label: "葡萄牙语", value: "portuguese" },
|
{ label: "portuguese", value: "portuguese" },
|
||||||
{ label: "俄语", value: "russian" },
|
{ label: "russian", value: "russian" },
|
||||||
{ label: "其他", value: "other" },
|
{ label: "other", value: "other" },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface LocaleSelectorProps {
|
interface LocaleSelectorProps {
|
||||||
@@ -20,6 +29,7 @@ interface LocaleSelectorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
|
export function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
|
||||||
|
const t = useTranslations();
|
||||||
const [customInput, setCustomInput] = useState("");
|
const [customInput, setCustomInput] = useState("");
|
||||||
const isCommonLanguage = COMMON_LANGUAGES.some((l) => l.value === value && l.value !== "other");
|
const isCommonLanguage = COMMON_LANGUAGES.some((l) => l.value === value && l.value !== "other");
|
||||||
const showCustomInput = value === "other" || !isCommonLanguage;
|
const showCustomInput = value === "other" || !isCommonLanguage;
|
||||||
@@ -34,7 +44,8 @@ export function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 当选择常见语言或"其他"时
|
// 当选择常见语言或"其他"时
|
||||||
const handleSelectChange = (selectedValue: string) => {
|
const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const selectedValue = e.target.value;
|
||||||
if (selectedValue === "other") {
|
if (selectedValue === "other") {
|
||||||
setCustomInput("");
|
setCustomInput("");
|
||||||
onChange("other");
|
onChange("other");
|
||||||
@@ -44,27 +55,26 @@ export function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<VStack gap={2}>
|
||||||
<select
|
<Select
|
||||||
value={isCommonLanguage ? value : "other"}
|
value={isCommonLanguage ? value : "other"}
|
||||||
onChange={(e) => handleSelectChange(e.target.value)}
|
onChange={handleSelectChange}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-[#35786f]"
|
|
||||||
>
|
>
|
||||||
{COMMON_LANGUAGES.map((lang) => (
|
{COMMON_LANGUAGES.map((lang) => (
|
||||||
<option key={lang.value} value={lang.value}>
|
<option key={lang.value} value={lang.value}>
|
||||||
{lang.label}
|
{t(`translator.${lang.label}`)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</Select>
|
||||||
{showCustomInput && (
|
{showCustomInput && (
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => handleCustomInputChange(e.target.value)}
|
onChange={(e) => handleCustomInputChange(e.target.value)}
|
||||||
placeholder="请输入语言名称"
|
placeholder={t("folder_id.enterLanguageName")}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-[#35786f] mt-2"
|
variant="bordered"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* PageHeader - 页面标题组件
|
* PageHeader - 页面标题组件
|
||||||
*
|
*
|
||||||
* 用于 PageLayout 内的页面标题,支持主标题和可选的副标题
|
* 使用 Design System 重写的页面标题组件
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <PageHeader title="我的文件夹" subtitle="管理和组织你的学习内容" />
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
|
import { VStack } from "@/design-system/layout/stack";
|
||||||
|
|
||||||
interface PageHeaderProps {
|
interface PageHeaderProps {
|
||||||
/** 页面主标题 */
|
|
||||||
title: string;
|
title: string;
|
||||||
/** 可选的副标题/描述 */
|
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PageHeader({ title, subtitle }: PageHeaderProps) {
|
export function PageHeader({ title, subtitle, className = "" }: PageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-6">
|
<VStack gap={2} className={`mb-6 ${className}`}>
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-2">
|
<h1 className="text-2xl md:text-3xl font-bold text-gray-800">
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<p className="text-sm text-gray-500">{subtitle}</p>
|
<p className="text-sm text-gray-500">{subtitle}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</VStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,64 @@
|
|||||||
/**
|
/**
|
||||||
* PageLayout - 统一的页面布局组件
|
* PageLayout - 页面布局组件
|
||||||
*
|
*
|
||||||
* 提供应用统一的标准页面布局:
|
* 使用 Design System 重写的页面布局组件
|
||||||
* - 绿色背景 (#35786f)
|
|
||||||
* - 居中的白色圆角卡片
|
|
||||||
* - 响应式内边距
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <PageLayout>
|
|
||||||
* <PageHeader title="标题" subtitle="副标题" />
|
|
||||||
* <div>页面内容</div>
|
|
||||||
* </PageLayout>
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
|
import { Card } from "@/design-system/base/card";
|
||||||
|
import { Container } from "@/design-system/layout/container";
|
||||||
|
|
||||||
|
type PageLayoutVariant = "centered-card" | "full-width" | "fullscreen";
|
||||||
|
|
||||||
interface PageLayoutProps {
|
interface PageLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
/** 额外的 CSS 类名,用于自定义布局行为 */
|
|
||||||
className?: string;
|
className?: string;
|
||||||
|
variant?: PageLayoutVariant;
|
||||||
|
align?: "center" | "start" | "end";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PageLayout({ children, className = "" }: PageLayoutProps) {
|
const alignClasses = {
|
||||||
|
center: "items-center",
|
||||||
|
start: "items-start",
|
||||||
|
end: "items-end",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PageLayout({
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
variant = "centered-card",
|
||||||
|
align = "center",
|
||||||
|
}: PageLayoutProps) {
|
||||||
|
// 居中卡片布局
|
||||||
|
if (variant === "centered-card") {
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4 py-8 ${className}`}>
|
<div className={`min-h-[calc(100vh-64px)] bg-primary-500 flex ${alignClasses[align]} justify-center px-4 py-8 ${className}`}>
|
||||||
<div className="w-full max-w-2xl">
|
<div className="w-full max-w-2xl">
|
||||||
<div className="bg-white rounded-2xl shadow-xl p-6 md:p-8">
|
<Card padding="lg" className="p-6 md:p-8">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 全宽布局
|
||||||
|
if (variant === "full-width") {
|
||||||
|
return (
|
||||||
|
<div className={`min-h-[calc(100vh-64px)] bg-primary-500 px-4 py-8 ${className}`}>
|
||||||
|
<Container size="2xl">
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全屏布局
|
||||||
|
if (variant === "fullscreen") {
|
||||||
|
return (
|
||||||
|
<div className={`min-h-[calc(100vh-64px)] bg-primary-500 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
45
src/components/ui/RangeInput.tsx
Normal file
45
src/components/ui/RangeInput.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"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%)`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
// 向后兼容的按钮组件包装器
|
|
||||||
// 这些组件将新 Button 组件包装,以保持向后兼容
|
|
||||||
|
|
||||||
import Button from "../Button";
|
|
||||||
|
|
||||||
// LightButton: 次要按钮,支持 selected 状态
|
|
||||||
export const LightButton = (props: any) => <Button variant="secondary" {...props} />;
|
|
||||||
|
|
||||||
// GreenButton: 主题色主要按钮
|
|
||||||
export const GreenButton = (props: any) => <Button variant="primary" {...props} />;
|
|
||||||
|
|
||||||
// IconButton: SVG 图标按钮
|
|
||||||
export const IconButton = (props: any) => {
|
|
||||||
const { icon, ...rest } = props;
|
|
||||||
return <Button variant="icon" leftIcon={icon} {...rest} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
// GhostButton: 透明导航按钮
|
|
||||||
export const GhostButton = (props: any) => {
|
|
||||||
const { className, children, ...rest } = props;
|
|
||||||
return (
|
|
||||||
<Button variant="ghost" className={className} {...rest}>
|
|
||||||
{children}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// IconClick: 图片图标按钮
|
|
||||||
export const IconClick = (props: any) => {
|
|
||||||
// IconClick 使用 src/alt 属性,需要映射到 Button 的 iconSrc/iconAlt
|
|
||||||
const { src, alt, size, disableOnHoverBgChange, className, ...rest } = props;
|
|
||||||
let buttonSize: "sm" | "md" | "lg" = "md";
|
|
||||||
if (typeof size === "number") {
|
|
||||||
if (size <= 20) buttonSize = "sm";
|
|
||||||
else if (size >= 32) buttonSize = "lg";
|
|
||||||
} else if (typeof size === "string") {
|
|
||||||
buttonSize = (size === "sm" || size === "md" || size === "lg") ? size : "md";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果禁用悬停背景变化,通过 className 覆盖
|
|
||||||
const hoverClass = disableOnHoverBgChange ? "hover:bg-black/30 hover:cursor-pointer border-0 bg-transparent shadow-none" : "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="icon"
|
|
||||||
iconSrc={src}
|
|
||||||
iconAlt={alt}
|
|
||||||
size={buttonSize}
|
|
||||||
className={`${hoverClass} ${className || ""}`}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// PlainButton: 基础小按钮
|
|
||||||
export const PlainButton = (props: any) => <Button variant="secondary" size="sm" {...props} />;
|
|
||||||
37
src/components/ui/index.ts
Normal file
37
src/components/ui/index.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// 统一的 UI 组件导出
|
||||||
|
// 可以从 '@/components/ui' 导入所有组件
|
||||||
|
|
||||||
|
// Design System 组件(向后兼容)
|
||||||
|
export { Input, type InputVariant, type InputProps } from '@/design-system/base/input';
|
||||||
|
export { Select, type SelectVariant, type SelectSize, type SelectProps } from '@/design-system/base/select';
|
||||||
|
export { Textarea, type TextareaVariant, type TextareaProps } from '@/design-system/base/textarea';
|
||||||
|
export { Card, type CardVariant, type CardPadding, type CardProps } from '@/design-system/base/card';
|
||||||
|
export {
|
||||||
|
Button,
|
||||||
|
PrimaryButton,
|
||||||
|
SecondaryButton,
|
||||||
|
LightButton,
|
||||||
|
SuccessButton,
|
||||||
|
WarningButton,
|
||||||
|
ErrorButton,
|
||||||
|
GhostButton,
|
||||||
|
GhostLightButton,
|
||||||
|
OutlineButton,
|
||||||
|
LinkButton,
|
||||||
|
IconButton,
|
||||||
|
IconClick,
|
||||||
|
CircleButton,
|
||||||
|
CircleToggleButton,
|
||||||
|
DashedButton,
|
||||||
|
type ButtonVariant,
|
||||||
|
type ButtonSize,
|
||||||
|
type ButtonProps
|
||||||
|
} from '@/design-system/base/button';
|
||||||
|
|
||||||
|
// 业务特定组件
|
||||||
|
export { RangeInput } from './RangeInput';
|
||||||
|
export { Container } from './Container';
|
||||||
|
export { PageLayout } from './PageLayout';
|
||||||
|
export { PageHeader } from './PageHeader';
|
||||||
|
export { CardList } from './CardList';
|
||||||
|
export { LocaleSelector } from './LocaleSelector';
|
||||||
@@ -21,4 +21,4 @@ const IMAGES = {
|
|||||||
github_mark_white: "/images/github-mark/github-mark-white.svg",
|
github_mark_white: "/images/github-mark/github-mark-white.svg",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default IMAGES;
|
export { IMAGES };
|
||||||
|
|||||||
585
src/design-system/README.md
Normal file
585
src/design-system/README.md
Normal file
@@ -0,0 +1,585 @@
|
|||||||
|
# Design System
|
||||||
|
|
||||||
|
完整的设计系统,提供可复用的 UI 组件和设计令牌,确保整个应用的一致性。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/design-system/
|
||||||
|
├── tokens/ # 设计令牌(颜色、间距、字体等)
|
||||||
|
├── lib/ # 工具函数
|
||||||
|
├── base/ # 基础组件
|
||||||
|
│ ├── button/
|
||||||
|
│ ├── input/
|
||||||
|
│ ├── textarea/
|
||||||
|
│ ├── card/
|
||||||
|
│ ├── checkbox/
|
||||||
|
│ ├── radio/
|
||||||
|
│ ├── switch/
|
||||||
|
│ └── select/
|
||||||
|
├── feedback/ # 反馈组件
|
||||||
|
│ ├── alert/
|
||||||
|
│ ├── progress/
|
||||||
|
│ ├── skeleton/
|
||||||
|
│ └── toast/
|
||||||
|
├── overlay/ # 覆盖组件
|
||||||
|
│ └── modal/
|
||||||
|
├── data-display/ # 数据展示组件
|
||||||
|
│ ├── badge/
|
||||||
|
│ └── divider/
|
||||||
|
├── layout/ # 布局组件
|
||||||
|
│ ├── container/
|
||||||
|
│ ├── grid/
|
||||||
|
│ └── stack/
|
||||||
|
├── navigation/ # 导航组件
|
||||||
|
│ └── tabs/
|
||||||
|
└── index.ts # 统一导出
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add class-variance-authority clsx tailwind-merge
|
||||||
|
```
|
||||||
|
|
||||||
|
### 导入组件
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 方式 1: 从主入口导入(简单但 tree-shaking 较差)
|
||||||
|
import { Button, Input, Card } from '@/design-system';
|
||||||
|
|
||||||
|
// 方式 2: 从子路径导入(更好的 tree-shaking)
|
||||||
|
import { Button } from '@/design-system/base/button';
|
||||||
|
import { Input } from '@/design-system/base/input';
|
||||||
|
import { Card } from '@/design-system/base/card';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用组件
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Button, Card } from '@/design-system';
|
||||||
|
|
||||||
|
export function MyComponent() {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<h1>标题</h1>
|
||||||
|
<p>内容</p>
|
||||||
|
<Button variant="primary">点击我</Button>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 组件列表
|
||||||
|
|
||||||
|
### 基础组件
|
||||||
|
|
||||||
|
| 组件 | 说明 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| [Button](#button) | 按钮 | ✅ |
|
||||||
|
| [Input](#input) | 输入框 | ✅ |
|
||||||
|
| [Textarea](#textarea) | 多行文本输入 | ✅ |
|
||||||
|
| [Card](#card) | 卡片容器 | ✅ |
|
||||||
|
| [Checkbox](#checkbox) | 复选框 | ✅ |
|
||||||
|
| [Radio](#radio) | 单选按钮 | ✅ |
|
||||||
|
| [Switch](#switch) | 开关 | ✅ |
|
||||||
|
| [Select](#select) | 下拉选择框 | ✅ |
|
||||||
|
|
||||||
|
### 反馈组件
|
||||||
|
|
||||||
|
| 组件 | 说明 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| [Alert](#alert) | 警告提示 | ✅ |
|
||||||
|
| [Progress](#progress) | 进度条 | ✅ |
|
||||||
|
| [Skeleton](#skeleton) | 骨架屏 | ✅ |
|
||||||
|
| [Toast](#toast) | 通知提示 | ✅ |
|
||||||
|
|
||||||
|
### 覆盖组件
|
||||||
|
|
||||||
|
| 组件 | 说明 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| [Modal](#modal) | 模态框 | ✅ |
|
||||||
|
|
||||||
|
### 数据展示组件
|
||||||
|
|
||||||
|
| 组件 | 说明 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| [Badge](#badge) | 徽章 | ✅ |
|
||||||
|
| [Divider](#divider) | 分隔线 | ✅ |
|
||||||
|
|
||||||
|
### 布局组件
|
||||||
|
|
||||||
|
| 组件 | 说明 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| [Container](#container) | 容器 | ✅ |
|
||||||
|
| [Grid](#grid) | 网格布局 | ✅ |
|
||||||
|
| [Stack](#stack) | 堆叠布局 | ✅ |
|
||||||
|
|
||||||
|
### 导航组件
|
||||||
|
|
||||||
|
| 组件 | 说明 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| [Tabs](#tabs) | 标签页 | ✅ |
|
||||||
|
|
||||||
|
## 组件 API
|
||||||
|
|
||||||
|
### Button
|
||||||
|
|
||||||
|
按钮组件,支持多种变体和尺寸。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Button } from '@/design-system';
|
||||||
|
|
||||||
|
<Button variant="primary" size="md" onClick={handleClick}>
|
||||||
|
点击我
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**变体 (variant)**: `primary` | `secondary` | `success` | `warning` | `error` | `ghost` | `outline` | `link`
|
||||||
|
|
||||||
|
**尺寸 (size)**: `sm` | `md` | `lg`
|
||||||
|
|
||||||
|
**快捷组件**: `PrimaryButton`, `SecondaryButton`, `SuccessButton`, `WarningButton`, `ErrorButton`, `GhostButton`, `OutlineButton`, `LinkButton`
|
||||||
|
|
||||||
|
### Input
|
||||||
|
|
||||||
|
输入框组件。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Input } from '@/design-system';
|
||||||
|
|
||||||
|
<Input
|
||||||
|
variant="bordered"
|
||||||
|
placeholder="请输入内容"
|
||||||
|
error={hasError}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**变体 (variant)**: `default` | `bordered` | `filled` | `search`
|
||||||
|
|
||||||
|
**尺寸 (size)**: `sm` | `md` | `lg`
|
||||||
|
|
||||||
|
### Textarea
|
||||||
|
|
||||||
|
多行文本输入组件。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Textarea } from '@/design-system';
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
variant="bordered"
|
||||||
|
placeholder="请输入内容"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**变体 (variant)**: `default` | `bordered` | `filled`
|
||||||
|
|
||||||
|
### Card
|
||||||
|
|
||||||
|
卡片容器组件。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Card, CardHeader, CardTitle, CardBody, CardFooter } from '@/design-system';
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>标题</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<p>内容</p>
|
||||||
|
</CardBody>
|
||||||
|
<CardFooter>
|
||||||
|
<Button>确定</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
**变体 (variant)**: `default` | `bordered` | `elevated` | `flat`
|
||||||
|
|
||||||
|
**内边距 (padding)**: `none` | `xs` | `sm` | `md` | `lg` | `xl`
|
||||||
|
|
||||||
|
### Checkbox
|
||||||
|
|
||||||
|
复选框组件。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Checkbox } from '@/design-system';
|
||||||
|
|
||||||
|
<Checkbox checked={checked} onChange={setChecked}>
|
||||||
|
同意条款
|
||||||
|
</Checkbox>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Radio
|
||||||
|
|
||||||
|
单选按钮组件。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Radio, RadioGroup } from '@/design-system';
|
||||||
|
|
||||||
|
<RadioGroup name="choice" value={value} onChange={setValue}>
|
||||||
|
<Radio value="1">选项 1</Radio>
|
||||||
|
<Radio value="2">选项 2</Radio>
|
||||||
|
</RadioGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Switch
|
||||||
|
|
||||||
|
开关组件。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Switch } from '@/design-system';
|
||||||
|
|
||||||
|
<Switch checked={enabled} onChange={setEnabled} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alert
|
||||||
|
|
||||||
|
警告提示组件。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Alert } from '@/design-system';
|
||||||
|
|
||||||
|
<Alert variant="success" title="成功">
|
||||||
|
操作成功完成
|
||||||
|
</Alert>
|
||||||
|
```
|
||||||
|
|
||||||
|
**变体 (variant)**: `info` | `success` | `warning` | `error`
|
||||||
|
|
||||||
|
### Progress
|
||||||
|
|
||||||
|
进度条组件。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Progress } from '@/design-system';
|
||||||
|
|
||||||
|
<Progress value={60} showLabel />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Skeleton
|
||||||
|
|
||||||
|
骨架屏组件。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Skeleton, TextSkeleton, CardSkeleton } from '@/design-system';
|
||||||
|
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<TextSkeleton lines={3} />
|
||||||
|
<CardSkeleton />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Toast
|
||||||
|
|
||||||
|
通知提示组件(基于 sonner)。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { toast } from '@/design-system';
|
||||||
|
|
||||||
|
toast.success("操作成功!");
|
||||||
|
toast.error("发生错误");
|
||||||
|
toast.promise(promise, {
|
||||||
|
loading: "加载中...",
|
||||||
|
success: "加载成功",
|
||||||
|
error: "加载失败",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modal
|
||||||
|
|
||||||
|
模态框组件。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Modal } from '@/design-system';
|
||||||
|
|
||||||
|
<Modal open={open} onClose={() => setOpen(false)}>
|
||||||
|
<Modal.Header>
|
||||||
|
<Modal.Title>标题</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<p>内容</p>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary">确定</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Badge
|
||||||
|
|
||||||
|
徽章组件。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Badge } from '@/design-system';
|
||||||
|
|
||||||
|
<Badge variant="success">成功</Badge>
|
||||||
|
<Badge dot />
|
||||||
|
```
|
||||||
|
|
||||||
|
**变体 (variant)**: `default` | `primary` | `success` | `warning` | `error` | `info`
|
||||||
|
|
||||||
|
### Divider
|
||||||
|
|
||||||
|
分隔线组件。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Divider } from '@/design-system';
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<Divider>或者</Divider>
|
||||||
|
<Divider orientation="vertical" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Container
|
||||||
|
|
||||||
|
容器组件。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Container } from '@/design-system';
|
||||||
|
|
||||||
|
<Container size="lg" padding="xl">
|
||||||
|
<p>内容</p>
|
||||||
|
</Container>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Grid
|
||||||
|
|
||||||
|
网格布局组件。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Grid } from '@/design-system';
|
||||||
|
|
||||||
|
<Grid cols={3} gap={4}>
|
||||||
|
<div>项目 1</div>
|
||||||
|
<div>项目 2</div>
|
||||||
|
<div>项目 3</div>
|
||||||
|
</Grid>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stack
|
||||||
|
|
||||||
|
堆叠布局组件。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Stack, VStack, HStack } from '@/design-system';
|
||||||
|
|
||||||
|
<VStack gap={4}>
|
||||||
|
<div>项目 1</div>
|
||||||
|
<div>项目 2</div>
|
||||||
|
</VStack>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tabs
|
||||||
|
|
||||||
|
标签页组件。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Tabs } from '@/design-system';
|
||||||
|
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<Tabs.List>
|
||||||
|
<Tabs.Trigger value="tab1">标签 1</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="tab2">标签 2</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
<Tabs.Content value="tab1">
|
||||||
|
<p>内容 1</p>
|
||||||
|
</Tabs.Content>
|
||||||
|
<Tabs.Content value="tab2">
|
||||||
|
<p>内容 2</p>
|
||||||
|
</Tabs.Content>
|
||||||
|
</Tabs>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 设计令牌
|
||||||
|
|
||||||
|
### 颜色
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
在组件中使用:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="bg-primary-500 text-white">主色背景</div>
|
||||||
|
<div className="text-success-600">成功文本</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 间距
|
||||||
|
|
||||||
|
基于 8pt 网格系统:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="p-4"> // 16px
|
||||||
|
<div className="p-6"> // 24px
|
||||||
|
<div className="p-8"> // 32px
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字体
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="text-sm">小文本</div>
|
||||||
|
<div className="text-base">正常文本</div>
|
||||||
|
<div className="text-lg">大文本</div>
|
||||||
|
<div className="font-semibold">半粗体</div>
|
||||||
|
<div className="font-bold">粗体</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 圆角
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="rounded-lg"> // 8px
|
||||||
|
<div className="rounded-xl"> // 12px
|
||||||
|
<div className="rounded-2xl"> // 16px
|
||||||
|
```
|
||||||
|
|
||||||
|
### 阴影
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="shadow-sm"> // 小阴影
|
||||||
|
<div className="shadow-md"> // 中阴影
|
||||||
|
<div className="shadow-lg"> // 大阴影
|
||||||
|
<div className="shadow-xl"> // 超大阴影
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工具函数
|
||||||
|
|
||||||
|
### cn
|
||||||
|
|
||||||
|
合并 Tailwind CSS 类名的工具函数。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { cn } from '@/design-system';
|
||||||
|
|
||||||
|
const className = cn(
|
||||||
|
'base-class',
|
||||||
|
isActive && 'active-class',
|
||||||
|
'another-class'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. 组件导入
|
||||||
|
|
||||||
|
对于更好的 tree-shaking,建议从子路径导入:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ 推荐
|
||||||
|
import { Button } from '@/design-system/base/button';
|
||||||
|
|
||||||
|
// ❌ 不推荐(但也可以)
|
||||||
|
import { Button } from '@/design-system';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 样式覆盖
|
||||||
|
|
||||||
|
使用 `className` 属性覆盖样式:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Button className="w-full">全宽按钮</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 组合组件
|
||||||
|
|
||||||
|
利用组件组合来构建复杂 UI:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>标题</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<VStack gap={4}>
|
||||||
|
<Input placeholder="输入框" />
|
||||||
|
<Button>提交</Button>
|
||||||
|
</VStack>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 可访问性
|
||||||
|
|
||||||
|
所有组件都内置了可访问性支持:
|
||||||
|
|
||||||
|
- 正确的 ARIA 属性
|
||||||
|
- 键盘导航支持
|
||||||
|
- 焦点管理
|
||||||
|
- 屏幕阅读器友好
|
||||||
|
|
||||||
|
## 迁移指南
|
||||||
|
|
||||||
|
### 从旧组件迁移
|
||||||
|
|
||||||
|
旧的组件路径:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
```
|
||||||
|
|
||||||
|
新的组件路径:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Button } from '@/design-system/base/button';
|
||||||
|
import { Input } from '@/design-system/base/input';
|
||||||
|
```
|
||||||
|
|
||||||
|
### API 变化
|
||||||
|
|
||||||
|
大部分 API 保持兼容,但有以下变化:
|
||||||
|
|
||||||
|
1. **颜色不再使用硬编码值**
|
||||||
|
```tsx
|
||||||
|
// 旧
|
||||||
|
style={{ backgroundColor: '#35786f' }}
|
||||||
|
|
||||||
|
// 新
|
||||||
|
className="bg-primary-500"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **变体命名更加一致**
|
||||||
|
```tsx
|
||||||
|
// 旧
|
||||||
|
<Button variant="icon" />
|
||||||
|
|
||||||
|
// 新
|
||||||
|
<Button variant="ghost" />
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **新增语义色变体**
|
||||||
|
```tsx
|
||||||
|
<Button variant="success">成功</Button>
|
||||||
|
<Button variant="warning">警告</Button>
|
||||||
|
<Button variant="error">错误</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 贡献
|
||||||
|
|
||||||
|
添加新组件时,请遵循以下规范:
|
||||||
|
|
||||||
|
1. 在对应的目录下创建组件
|
||||||
|
2. 使用 `cva` 定义变体样式
|
||||||
|
3. 使用 `forwardRef` 支持 ref 转发
|
||||||
|
4. 添加完整的 TypeScript 类型
|
||||||
|
5. 编写详细的 JSDoc 注释和示例
|
||||||
|
6. 在导出文件中添加导出
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
AGPL-3.0-only
|
||||||
351
src/design-system/base/button/button.tsx
Normal file
351
src/design-system/base/button/button.tsx
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/design-system/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button 组件
|
||||||
|
*
|
||||||
|
* Design System 中的按钮组件,支持多种变体、尺寸和状态。
|
||||||
|
* 自动处理 Link/button 切换,支持图标和加载状态。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // Primary 按钮
|
||||||
|
* <Button variant="primary" onClick={handleClick}>
|
||||||
|
* 点击我
|
||||||
|
* </Button>
|
||||||
|
*
|
||||||
|
* // 带图标的按钮
|
||||||
|
* <Button variant="secondary" leftIcon={<Icon />}>
|
||||||
|
* 带图标
|
||||||
|
* </Button>
|
||||||
|
*
|
||||||
|
* // 作为链接使用
|
||||||
|
* <Button variant="primary" href="/path">
|
||||||
|
* 链接按钮
|
||||||
|
* </Button>
|
||||||
|
*
|
||||||
|
* // 加载状态
|
||||||
|
* <Button variant="primary" loading>
|
||||||
|
* 提交中...
|
||||||
|
* </Button>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按钮变体样式
|
||||||
|
*/
|
||||||
|
const buttonVariants = cva(
|
||||||
|
// 基础样式
|
||||||
|
"inline-flex items-center justify-center gap-2 rounded-md font-semibold shadow transition-all duration-250 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
primary: "bg-primary-500 text-white hover:bg-primary-600 shadow-md",
|
||||||
|
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 shadow-sm",
|
||||||
|
success: "bg-success-500 text-white hover:bg-success-600 shadow-md",
|
||||||
|
warning: "bg-warning-500 text-white hover:bg-warning-600 shadow-md",
|
||||||
|
error: "bg-error-500 text-white hover:bg-error-600 shadow-md",
|
||||||
|
ghost: "bg-transparent text-gray-700 hover:bg-gray-100 shadow-none",
|
||||||
|
"ghost-light": "bg-transparent text-white hover:bg-white/10 shadow-none",
|
||||||
|
outline: "border-2 border-gray-300 text-gray-700 hover:bg-gray-50 shadow-none",
|
||||||
|
link: "text-primary-500 hover:text-primary-600 hover:underline shadow-none px-0",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
sm: "h-8 px-3 text-sm",
|
||||||
|
md: "h-10 px-4 text-base",
|
||||||
|
lg: "h-12 px-6 text-lg",
|
||||||
|
},
|
||||||
|
fullWidth: {
|
||||||
|
true: "w-full",
|
||||||
|
false: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
compoundVariants: [
|
||||||
|
// 链接变体不应用高度和圆角
|
||||||
|
{
|
||||||
|
variant: "link",
|
||||||
|
size: "sm",
|
||||||
|
className: "h-auto px-0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
variant: "link",
|
||||||
|
size: "md",
|
||||||
|
className: "h-auto px-0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
variant: "link",
|
||||||
|
size: "lg",
|
||||||
|
className: "h-auto px-0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "secondary",
|
||||||
|
size: "md",
|
||||||
|
fullWidth: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||||
|
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
// 内容
|
||||||
|
children?: React.ReactNode;
|
||||||
|
|
||||||
|
// 导航
|
||||||
|
href?: string;
|
||||||
|
openInNewTab?: boolean;
|
||||||
|
|
||||||
|
// 图标
|
||||||
|
leftIcon?: React.ReactNode;
|
||||||
|
rightIcon?: React.ReactNode;
|
||||||
|
iconSrc?: string; // For Next.js Image icons
|
||||||
|
iconAlt?: string;
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
loading?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
|
||||||
|
// 样式
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button 组件
|
||||||
|
*/
|
||||||
|
export function Button({
|
||||||
|
variant = "secondary",
|
||||||
|
size = "md",
|
||||||
|
fullWidth = false,
|
||||||
|
href,
|
||||||
|
openInNewTab = false,
|
||||||
|
iconSrc,
|
||||||
|
iconAlt,
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
loading = false,
|
||||||
|
selected = false,
|
||||||
|
disabled,
|
||||||
|
type = "button",
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
// 确保 size 有默认值
|
||||||
|
const actualSize = size ?? "md";
|
||||||
|
|
||||||
|
// 计算样式
|
||||||
|
const computedClass = cn(
|
||||||
|
buttonVariants({ variant, size: actualSize, fullWidth }),
|
||||||
|
selected && variant === "secondary" && "bg-gray-200",
|
||||||
|
className
|
||||||
|
);
|
||||||
|
|
||||||
|
// 图标尺寸映射
|
||||||
|
const iconSize = { sm: 14, md: 16, lg: 20 }[actualSize];
|
||||||
|
|
||||||
|
// 渲染 SVG 图标
|
||||||
|
const renderSvgIcon = (icon: React.ReactNode, position: "left" | "right") => {
|
||||||
|
if (!icon) return null;
|
||||||
|
return (
|
||||||
|
<span className={`flex items-center shrink-0 ${position === "left" ? "-ml-1 mr-2" : "-mr-1 ml-2"}`}>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染 Next.js Image 图标
|
||||||
|
const renderImageIcon = () => {
|
||||||
|
if (!iconSrc) return null;
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
src={iconSrc}
|
||||||
|
width={iconSize}
|
||||||
|
height={iconSize}
|
||||||
|
alt={iconAlt || "icon"}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染加载图标
|
||||||
|
const renderLoadingIcon = () => {
|
||||||
|
if (!loading) return null;
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="animate-spin h-4 w-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 组装内容
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{loading && renderLoadingIcon()}
|
||||||
|
{renderImageIcon()}
|
||||||
|
{renderSvgIcon(leftIcon, "left")}
|
||||||
|
{children}
|
||||||
|
{renderSvgIcon(rightIcon, "right")}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 如果提供了 href,渲染为 Link
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={computedClass}
|
||||||
|
target={openInNewTab ? "_blank" : undefined}
|
||||||
|
rel={openInNewTab ? "noopener noreferrer" : undefined}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则渲染为 button
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
className={computedClass}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预定义的按钮快捷组件
|
||||||
|
*/
|
||||||
|
export const PrimaryButton = (props: Omit<ButtonProps, "variant">) => (
|
||||||
|
<Button variant="primary" {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SecondaryButton = (props: Omit<ButtonProps, "variant">) => (
|
||||||
|
<Button variant="secondary" {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// LightButton: 次要按钮的别名(向后兼容)
|
||||||
|
export const LightButton = SecondaryButton;
|
||||||
|
|
||||||
|
export const SuccessButton = (props: Omit<ButtonProps, "variant">) => (
|
||||||
|
<Button variant="success" {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const WarningButton = (props: Omit<ButtonProps, "variant">) => (
|
||||||
|
<Button variant="warning" {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ErrorButton = (props: Omit<ButtonProps, "variant">) => (
|
||||||
|
<Button variant="error" {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const GhostButton = (props: Omit<ButtonProps, "variant">) => (
|
||||||
|
<Button variant="ghost" {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// GhostLightButton: 透明按钮(白色文字,用于深色背景)
|
||||||
|
export const GhostLightButton = (props: Omit<ButtonProps, "variant">) => (
|
||||||
|
<Button variant="ghost-light" {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const OutlineButton = (props: Omit<ButtonProps, "variant">) => (
|
||||||
|
<Button variant="outline" {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const LinkButton = (props: Omit<ButtonProps, "variant">) => (
|
||||||
|
<Button variant="link" {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== 其他便捷组件 ==========
|
||||||
|
|
||||||
|
// IconButton: SVG 图标按钮(使用 ghost 变体)
|
||||||
|
export const IconButton = (props: Omit<ButtonProps, "variant"> & { icon?: React.ReactNode }) => {
|
||||||
|
const { icon, ...rest } = props;
|
||||||
|
return <Button variant="ghost" leftIcon={icon} {...rest} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// IconClick: 图片图标按钮(支持 Next.js Image)
|
||||||
|
export const IconClick = (props: Omit<ButtonProps, "variant"> & {
|
||||||
|
src?: string;
|
||||||
|
alt?: string;
|
||||||
|
size?: number | "sm" | "md" | "lg";
|
||||||
|
disableOnHoverBgChange?: boolean;
|
||||||
|
}) => {
|
||||||
|
const { src, alt, size, disableOnHoverBgChange, className, ...rest } = props;
|
||||||
|
let buttonSize: "sm" | "md" | "lg" = "md";
|
||||||
|
if (typeof size === "number") {
|
||||||
|
if (size <= 20) buttonSize = "sm";
|
||||||
|
else if (size >= 32) buttonSize = "lg";
|
||||||
|
} else if (typeof size === "string") {
|
||||||
|
buttonSize = (size === "sm" || size === "md" || size === "lg") ? size : "md";
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoverClass = disableOnHoverBgChange ? "hover:bg-black/30" : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
iconSrc={src}
|
||||||
|
iconAlt={alt}
|
||||||
|
size={buttonSize}
|
||||||
|
className={`${hoverClass} ${className || ""}`}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// CircleButton: 圆形图标按钮
|
||||||
|
export const CircleButton = (props: Omit<ButtonProps, "variant"> & { icon?: React.ReactNode }) => {
|
||||||
|
const { icon, className, ...rest } = props;
|
||||||
|
return <Button variant="ghost" leftIcon={icon} className={`rounded-full ${className || ""}`} {...rest} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// CircleToggleButton: 带选中状态的圆形切换按钮
|
||||||
|
export const CircleToggleButton = (props: Omit<ButtonProps, "variant"> & { selected?: boolean }) => {
|
||||||
|
const { selected, className, children, ...rest } = props;
|
||||||
|
const selectedClass = selected
|
||||||
|
? "bg-primary-500 text-white"
|
||||||
|
: "bg-gray-200 text-gray-600 hover:bg-gray-300";
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className={`rounded-full px-3 py-1 text-sm transition-colors ${selectedClass} ${className || ""}`}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// DashedButton: 虚线边框按钮(使用 outline 变体近似)
|
||||||
|
export const DashedButton = (props: Omit<ButtonProps, "variant">) => (
|
||||||
|
<Button variant="outline" className="border-dashed" {...props} />
|
||||||
|
);
|
||||||
1
src/design-system/base/button/index.ts
Normal file
1
src/design-system/base/button/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './button';
|
||||||
198
src/design-system/base/card/card.tsx
Normal file
198
src/design-system/base/card/card.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/design-system/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card 卡片组件
|
||||||
|
*
|
||||||
|
* Design System 中的卡片容器组件,提供统一的内容包装样式。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // 默认卡片
|
||||||
|
* <Card>
|
||||||
|
* <p>卡片内容</p>
|
||||||
|
* </Card>
|
||||||
|
*
|
||||||
|
* // 带边框的卡片
|
||||||
|
* <Card variant="bordered" padding="lg">
|
||||||
|
* <p>带边框的内容</p>
|
||||||
|
* </Card>
|
||||||
|
*
|
||||||
|
* // 无内边距卡片
|
||||||
|
* <Card padding="none">
|
||||||
|
* <img src="image.jpg" alt="完全填充的图片" />
|
||||||
|
* </Card>
|
||||||
|
*
|
||||||
|
* // 可点击的卡片
|
||||||
|
* <Card clickable onClick={handleClick}>
|
||||||
|
* <p>点击我</p>
|
||||||
|
* </Card>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡片变体样式
|
||||||
|
*/
|
||||||
|
const cardVariants = cva(
|
||||||
|
// 基础样式
|
||||||
|
"rounded-lg bg-white transition-all duration-250",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "shadow-xl",
|
||||||
|
bordered: "border-2 border-gray-200 shadow-sm",
|
||||||
|
elevated: "shadow-2xl",
|
||||||
|
flat: "border border-gray-200 shadow-none",
|
||||||
|
},
|
||||||
|
padding: {
|
||||||
|
none: "",
|
||||||
|
xs: "p-3",
|
||||||
|
sm: "p-4",
|
||||||
|
md: "p-6",
|
||||||
|
lg: "p-8",
|
||||||
|
xl: "p-10",
|
||||||
|
},
|
||||||
|
clickable: {
|
||||||
|
true: "cursor-pointer hover:shadow-primary/25 hover:-translate-y-0.5 active:translate-y-0",
|
||||||
|
false: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
padding: "md",
|
||||||
|
clickable: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type CardVariant = VariantProps<typeof cardVariants>["variant"];
|
||||||
|
export type CardPadding = VariantProps<typeof cardVariants>["padding"];
|
||||||
|
|
||||||
|
export interface CardProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof cardVariants> {
|
||||||
|
// 子元素
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card 卡片组件
|
||||||
|
*/
|
||||||
|
export function Card({
|
||||||
|
variant = "default",
|
||||||
|
padding = "md",
|
||||||
|
clickable = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: CardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(cardVariants({ variant, padding, clickable }), className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CardSection - 卡片内容区块
|
||||||
|
* 用于组织卡片内部的多个内容区块
|
||||||
|
*/
|
||||||
|
export interface CardSectionProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
children: React.ReactNode;
|
||||||
|
noPadding?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardSection({
|
||||||
|
noPadding = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: CardSectionProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
!noPadding && "p-6",
|
||||||
|
"first:rounded-t-2xl last:rounded-b-2xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CardHeader - 卡片头部
|
||||||
|
*/
|
||||||
|
export interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardHeader({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: CardHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("flex items-center justify-between p-6 border-b border-gray-200", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CardTitle - 卡片标题
|
||||||
|
*/
|
||||||
|
export interface CardTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardTitle({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: CardTitleProps) {
|
||||||
|
return (
|
||||||
|
<h3
|
||||||
|
className={cn("text-lg font-semibold text-gray-900", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CardBody - 卡片主体
|
||||||
|
*/
|
||||||
|
export const CardBody = CardSection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CardFooter - 卡片底部
|
||||||
|
*/
|
||||||
|
export interface CardFooterProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardFooter({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: CardFooterProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("flex items-center justify-end gap-2 p-6 border-t border-gray-200", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/design-system/base/card/index.ts
Normal file
1
src/design-system/base/card/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './card';
|
||||||
170
src/design-system/base/checkbox/checkbox.tsx
Normal file
170
src/design-system/base/checkbox/checkbox.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { forwardRef } from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/design-system/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checkbox 复选框组件
|
||||||
|
*
|
||||||
|
* Design System 中的复选框组件,支持多种状态和尺寸。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // 默认复选框
|
||||||
|
* <Checkbox>同意条款</Checkbox>
|
||||||
|
*
|
||||||
|
* // 受控组件
|
||||||
|
* <Checkbox checked={checked} onChange={handleChange}>
|
||||||
|
* 同意条款
|
||||||
|
* </Checkbox>
|
||||||
|
*
|
||||||
|
* // 错误状态
|
||||||
|
* <Checkbox error>必选项</Checkbox>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复选框变体样式
|
||||||
|
*/
|
||||||
|
const checkboxVariants = cva(
|
||||||
|
// 基础样式
|
||||||
|
"peer h-4 w-4 shrink-0 rounded border-2 transition-all duration-250 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-gray-300 checked:bg-primary-500 checked:border-primary-500",
|
||||||
|
success: "border-gray-300 checked:bg-success-500 checked:border-success-500",
|
||||||
|
warning: "border-gray-300 checked:bg-warning-500 checked:border-warning-500",
|
||||||
|
error: "border-gray-300 checked:bg-error-500 checked:border-error-500",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
sm: "h-3.5 w-3.5",
|
||||||
|
md: "h-4 w-4",
|
||||||
|
lg: "h-5 w-5",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
true: "border-error-500",
|
||||||
|
false: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "md",
|
||||||
|
error: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type CheckboxVariant = VariantProps<typeof checkboxVariants>["variant"];
|
||||||
|
export type CheckboxSize = VariantProps<typeof checkboxVariants>["size"];
|
||||||
|
|
||||||
|
export interface CheckboxProps
|
||||||
|
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
|
||||||
|
VariantProps<typeof checkboxVariants> {
|
||||||
|
// 标签文本
|
||||||
|
label?: React.ReactNode;
|
||||||
|
// 标签位置
|
||||||
|
labelPosition?: "left" | "right";
|
||||||
|
// 自定义复选框类名
|
||||||
|
checkboxClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checkbox 复选框组件
|
||||||
|
*/
|
||||||
|
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
variant = "default",
|
||||||
|
size = "md",
|
||||||
|
error = false,
|
||||||
|
label,
|
||||||
|
labelPosition = "right",
|
||||||
|
className,
|
||||||
|
checkboxClassName,
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const checkboxId = React.useId();
|
||||||
|
|
||||||
|
const renderCheckbox = () => (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="checkbox"
|
||||||
|
id={checkboxId}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
checkboxVariants({ variant, size, error }),
|
||||||
|
checkboxClassName
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderLabel = () => {
|
||||||
|
if (!label) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
htmlFor={checkboxId}
|
||||||
|
className={cn(
|
||||||
|
"text-base font-normal leading-none",
|
||||||
|
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
|
||||||
|
labelPosition === "left" ? "mr-2" : "ml-2"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!label) {
|
||||||
|
return renderCheckbox();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("inline-flex items-center", className)}>
|
||||||
|
{labelPosition === "left" && renderLabel()}
|
||||||
|
{renderCheckbox()}
|
||||||
|
{labelPosition === "right" && renderLabel()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Checkbox.displayName = "Checkbox";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CheckboxGroup - 复选框组
|
||||||
|
*/
|
||||||
|
export interface CheckboxGroupProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
required?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CheckboxGroup({
|
||||||
|
children,
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
required,
|
||||||
|
className,
|
||||||
|
}: CheckboxGroupProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-2", className)}>
|
||||||
|
{label && (
|
||||||
|
<div className="text-base font-medium text-gray-900">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-error-500 ml-1">*</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">{children}</div>
|
||||||
|
{error && <p className="text-sm text-error-500">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/design-system/base/checkbox/index.ts
Normal file
1
src/design-system/base/checkbox/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './checkbox';
|
||||||
1
src/design-system/base/input/index.ts
Normal file
1
src/design-system/base/input/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './input';
|
||||||
151
src/design-system/base/input/input.tsx
Normal file
151
src/design-system/base/input/input.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { forwardRef } from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/design-system/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input 输入框组件
|
||||||
|
*
|
||||||
|
* Design System 中的输入框组件,支持多种样式变体和尺寸。
|
||||||
|
* 完全可访问,支持焦点状态和错误状态。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // 默认样式
|
||||||
|
* <Input placeholder="请输入内容" />
|
||||||
|
*
|
||||||
|
* // 带边框样式
|
||||||
|
* <Input variant="bordered" placeholder="带边框的输入框" />
|
||||||
|
*
|
||||||
|
* // 填充样式
|
||||||
|
* <Input variant="filled" placeholder="填充背景的输入框" />
|
||||||
|
*
|
||||||
|
* // 错误状态
|
||||||
|
* <Input variant="bordered" error placeholder="有错误的输入框" />
|
||||||
|
*
|
||||||
|
* // 禁用状态
|
||||||
|
* <Input disabled placeholder="禁用的输入框" />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输入框变体样式
|
||||||
|
*/
|
||||||
|
const inputVariants = cva(
|
||||||
|
// 基础样式
|
||||||
|
"flex w-full rounded-md border px-3 py-2 text-base transition-all duration-250 placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-b-2 border-gray-300 bg-transparent rounded-t-md",
|
||||||
|
bordered: "border-gray-300 bg-white",
|
||||||
|
filled: "border-transparent bg-gray-100",
|
||||||
|
search: "border-gray-200 bg-white pl-10 rounded-full",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
sm: "h-9 px-3 text-sm",
|
||||||
|
md: "h-10 px-4 text-base",
|
||||||
|
lg: "h-12 px-5 text-lg",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
true: "border-error-500 focus-visible:ring-error-500",
|
||||||
|
false: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
compoundVariants: [
|
||||||
|
// 填充变体的错误状态
|
||||||
|
{
|
||||||
|
variant: "filled",
|
||||||
|
error: true,
|
||||||
|
className: "bg-error-50",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "md",
|
||||||
|
error: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type InputVariant = VariantProps<typeof inputVariants>["variant"];
|
||||||
|
export type InputSize = VariantProps<typeof inputVariants>["size"];
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
|
||||||
|
VariantProps<typeof inputVariants> {
|
||||||
|
// 左侧图标(通常用于搜索框)
|
||||||
|
leftIcon?: React.ReactNode;
|
||||||
|
// 右侧图标(例如清除按钮)
|
||||||
|
rightIcon?: React.ReactNode;
|
||||||
|
// 容器类名(用于包裹图标和输入框)
|
||||||
|
containerClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input 输入框组件
|
||||||
|
*/
|
||||||
|
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
variant = "default",
|
||||||
|
size = "md",
|
||||||
|
error = false,
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
type = "text",
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
// 如果有左侧图标,使用相对定位的容器
|
||||||
|
if (leftIcon) {
|
||||||
|
return (
|
||||||
|
<div className={cn("relative", containerClassName)}>
|
||||||
|
{/* 左侧图标 */}
|
||||||
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
|
||||||
|
{leftIcon}
|
||||||
|
</div>
|
||||||
|
{/* 输入框 */}
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
inputVariants({ variant, size, error }),
|
||||||
|
leftIcon && "pl-10"
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{/* 右侧图标 */}
|
||||||
|
{rightIcon && (
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||||
|
{rightIcon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通输入框
|
||||||
|
return (
|
||||||
|
<div className={cn("relative", containerClassName)}>
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type={type}
|
||||||
|
className={cn(inputVariants({ variant, size, error }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{rightIcon && (
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||||
|
{rightIcon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Input.displayName = "Input";
|
||||||
1
src/design-system/base/radio/index.ts
Normal file
1
src/design-system/base/radio/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './radio';
|
||||||
220
src/design-system/base/radio/radio.tsx
Normal file
220
src/design-system/base/radio/radio.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { forwardRef } from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/design-system/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Radio 单选按钮组件
|
||||||
|
*
|
||||||
|
* Design System 中的单选按钮组件,支持多种状态和尺寸。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // 默认单选按钮
|
||||||
|
* <Radio name="choice" value="1">选项 1</Radio>
|
||||||
|
* <Radio name="choice" value="2">选项 2</Radio>
|
||||||
|
*
|
||||||
|
* // 受控组件
|
||||||
|
* <Radio
|
||||||
|
* name="choice"
|
||||||
|
* value="1"
|
||||||
|
* checked={value === "1"}
|
||||||
|
* onChange={(e) => setValue(e.target.value)}
|
||||||
|
* >
|
||||||
|
* 选项 1
|
||||||
|
* </Radio>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单选按钮变体样式
|
||||||
|
*/
|
||||||
|
const radioVariants = cva(
|
||||||
|
// 基础样式
|
||||||
|
"peer h-4 w-4 shrink-0 rounded-full border-2 transition-all duration-250 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 appearance-none cursor-pointer",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-gray-300 checked:border-primary-500",
|
||||||
|
success: "border-gray-300 checked:border-success-500",
|
||||||
|
warning: "border-gray-300 checked:border-warning-500",
|
||||||
|
error: "border-gray-300 checked:border-error-500",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
sm: "h-3.5 w-3.5",
|
||||||
|
md: "h-4 w-4",
|
||||||
|
lg: "h-5 w-5",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
true: "border-error-500",
|
||||||
|
false: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "md",
|
||||||
|
error: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type RadioVariant = VariantProps<typeof radioVariants>["variant"];
|
||||||
|
export type RadioSize = VariantProps<typeof radioVariants>["size"];
|
||||||
|
|
||||||
|
export interface RadioProps
|
||||||
|
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
|
||||||
|
VariantProps<typeof radioVariants> {
|
||||||
|
// 标签文本
|
||||||
|
label?: React.ReactNode;
|
||||||
|
// 标签位置
|
||||||
|
labelPosition?: "left" | "right";
|
||||||
|
// 自定义单选按钮类名
|
||||||
|
radioClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Radio 单选按钮组件
|
||||||
|
*/
|
||||||
|
export const Radio = forwardRef<HTMLInputElement, RadioProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
variant = "default",
|
||||||
|
size = "md",
|
||||||
|
error = false,
|
||||||
|
label,
|
||||||
|
labelPosition = "right",
|
||||||
|
className,
|
||||||
|
radioClassName,
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const radioId = React.useId();
|
||||||
|
|
||||||
|
const renderRadio = () => (
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="radio"
|
||||||
|
id={radioId}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
radioVariants({ variant, size, error }),
|
||||||
|
"peer/radio",
|
||||||
|
radioClassName
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{/* 选中状态的圆点 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full pointer-events-none transition-all duration-250",
|
||||||
|
"peer-checked/radio:bg-current",
|
||||||
|
size === "sm" && "h-1.5 w-1.5",
|
||||||
|
size === "md" && "h-2 w-2",
|
||||||
|
size === "lg" && "h-2.5 w-2.5",
|
||||||
|
variant === "default" && "text-primary-500",
|
||||||
|
variant === "success" && "text-success-500",
|
||||||
|
variant === "warning" && "text-warning-500",
|
||||||
|
variant === "error" && "text-error-500"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderLabel = () => {
|
||||||
|
if (!label) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
htmlFor={radioId}
|
||||||
|
className={cn(
|
||||||
|
"text-base font-normal leading-none",
|
||||||
|
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
|
||||||
|
labelPosition === "left" ? "mr-2" : "ml-2"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!label) {
|
||||||
|
return renderRadio();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("inline-flex items-center", className)}>
|
||||||
|
{labelPosition === "left" && renderLabel()}
|
||||||
|
{renderRadio()}
|
||||||
|
{labelPosition === "right" && renderLabel()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Radio.displayName = "Radio";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RadioGroup - 单选按钮组
|
||||||
|
*/
|
||||||
|
export interface RadioGroupProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
name: string;
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
required?: boolean;
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
className?: string;
|
||||||
|
orientation?: "vertical" | "horizontal";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RadioGroup({
|
||||||
|
children,
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
required,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
}: RadioGroupProps) {
|
||||||
|
// 为每个 Radio 注入 name 和 onChange
|
||||||
|
const enhancedChildren = React.Children.map(children, (child) => {
|
||||||
|
if (React.isValidElement(child)) {
|
||||||
|
const childProps = child.props as { value?: string; onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void };
|
||||||
|
return React.cloneElement(child as React.ReactElement<any>, {
|
||||||
|
name,
|
||||||
|
checked: value !== undefined ? childProps.value === value : undefined,
|
||||||
|
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange?.(e.target.value);
|
||||||
|
childProps.onChange?.(e);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-2", className)}>
|
||||||
|
{label && (
|
||||||
|
<div className="text-base font-medium text-gray-900">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-error-500 ml-1">*</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
orientation === "vertical" ? "space-y-2" : "flex gap-4"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{enhancedChildren}
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-error-500">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/design-system/base/select/index.ts
Normal file
1
src/design-system/base/select/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './select';
|
||||||
112
src/design-system/base/select/select.tsx
Normal file
112
src/design-system/base/select/select.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { forwardRef } from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/design-system/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select 下拉选择框组件
|
||||||
|
*
|
||||||
|
* Design System 中的下拉选择框组件。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <Select>
|
||||||
|
* <option value="">请选择</option>
|
||||||
|
* <option value="1">选项 1</option>
|
||||||
|
* <option value="2">选项 2</option>
|
||||||
|
* </Select>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select 变体样式
|
||||||
|
*/
|
||||||
|
const selectVariants = cva(
|
||||||
|
// 基础样式
|
||||||
|
"flex w-full appearance-none items-center justify-between rounded-md border px-3 py-2 pr-8 text-base transition-all duration-250 placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-b-2 border-gray-300 bg-transparent rounded-t-md",
|
||||||
|
bordered: "border-gray-300 bg-white",
|
||||||
|
filled: "border-transparent bg-gray-100",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
sm: "h-9 px-3 text-sm",
|
||||||
|
md: "h-10 px-4 text-base",
|
||||||
|
lg: "h-12 px-5 text-lg",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
true: "border-error-500 focus-visible:ring-error-500",
|
||||||
|
false: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
compoundVariants: [
|
||||||
|
{
|
||||||
|
variant: "filled",
|
||||||
|
error: true,
|
||||||
|
className: "bg-error-50",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "md",
|
||||||
|
error: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SelectVariant = VariantProps<typeof selectVariants>["variant"];
|
||||||
|
export type SelectSize = VariantProps<typeof selectVariants>["size"];
|
||||||
|
|
||||||
|
export interface SelectProps
|
||||||
|
extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "size">,
|
||||||
|
VariantProps<typeof selectVariants> {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select 下拉选择框组件
|
||||||
|
*/
|
||||||
|
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
variant = "default",
|
||||||
|
size = "md",
|
||||||
|
error = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
ref={ref}
|
||||||
|
className={cn(selectVariants({ variant, size, error }), className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
{/* 下拉箭头图标 */}
|
||||||
|
<div className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 9l-7 7-7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Select.displayName = "Select";
|
||||||
1
src/design-system/base/switch/index.ts
Normal file
1
src/design-system/base/switch/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './switch';
|
||||||
182
src/design-system/base/switch/switch.tsx
Normal file
182
src/design-system/base/switch/switch.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { forwardRef, useState } from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/design-system/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch 开关组件
|
||||||
|
*
|
||||||
|
* Design System 中的开关组件,用于二进制状态切换。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // 默认开关
|
||||||
|
* <Switch checked={checked} onChange={setChecked} />
|
||||||
|
*
|
||||||
|
* // 带标签
|
||||||
|
* <Switch label="启用通知" checked={checked} onChange={setChecked} />
|
||||||
|
*
|
||||||
|
* // 不同尺寸
|
||||||
|
* <Switch size="sm" checked={checked} onChange={setChecked} />
|
||||||
|
* <Switch size="lg" checked={checked} onChange={setChecked} />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开关变体样式
|
||||||
|
*/
|
||||||
|
const switchVariants = cva(
|
||||||
|
// 基础样式
|
||||||
|
"peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 transition-all duration-250 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 appearance-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-gray-300 bg-gray-100 checked:border-primary-500 checked:bg-primary-500",
|
||||||
|
success:
|
||||||
|
"border-gray-300 bg-gray-100 checked:border-success-500 checked:bg-success-500",
|
||||||
|
warning:
|
||||||
|
"border-gray-300 bg-gray-100 checked:border-warning-500 checked:bg-warning-500",
|
||||||
|
error:
|
||||||
|
"border-gray-300 bg-gray-100 checked:border-error-500 checked:bg-error-500",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
sm: "h-5 w-9",
|
||||||
|
md: "h-6 w-11",
|
||||||
|
lg: "h-7 w-13",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "md",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SwitchVariant = VariantProps<typeof switchVariants>["variant"];
|
||||||
|
export type SwitchSize = VariantProps<typeof switchVariants>["size"];
|
||||||
|
|
||||||
|
export interface SwitchProps
|
||||||
|
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
|
||||||
|
VariantProps<typeof switchVariants> {
|
||||||
|
// 标签文本
|
||||||
|
label?: React.ReactNode;
|
||||||
|
// 标签位置
|
||||||
|
labelPosition?: "left" | "right";
|
||||||
|
// 自定义开关类名
|
||||||
|
switchClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch 开关组件
|
||||||
|
*/
|
||||||
|
export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
variant = "default",
|
||||||
|
size = "md",
|
||||||
|
label,
|
||||||
|
labelPosition = "right",
|
||||||
|
className,
|
||||||
|
switchClassName,
|
||||||
|
disabled,
|
||||||
|
checked,
|
||||||
|
defaultChecked,
|
||||||
|
onChange,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const switchId = React.useId();
|
||||||
|
const [internalChecked, setInternalChecked] = useState(
|
||||||
|
checked ?? defaultChecked ?? false
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理受控和非受控模式
|
||||||
|
const isControlled = checked !== undefined;
|
||||||
|
const isChecked = isControlled ? checked : internalChecked;
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (!isControlled) {
|
||||||
|
setInternalChecked(e.target.checked);
|
||||||
|
}
|
||||||
|
onChange?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 确保 size 有默认值
|
||||||
|
const actualSize = size ?? "md";
|
||||||
|
|
||||||
|
// 滑块大小
|
||||||
|
const thumbSize = {
|
||||||
|
sm: "h-3.5 w-3.5",
|
||||||
|
md: "h-4 w-4",
|
||||||
|
lg: "h-5 w-5",
|
||||||
|
}[actualSize];
|
||||||
|
|
||||||
|
// 滑块位移
|
||||||
|
const thumbTranslate = {
|
||||||
|
sm: isChecked ? "translate-x-4" : "translate-x-0.5",
|
||||||
|
md: isChecked ? "translate-x-5" : "translate-x-0.5",
|
||||||
|
lg: isChecked ? "translate-x-6" : "translate-x-0.5",
|
||||||
|
}[actualSize];
|
||||||
|
|
||||||
|
const renderSwitch = () => (
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="checkbox"
|
||||||
|
id={switchId}
|
||||||
|
disabled={disabled}
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={cn(
|
||||||
|
switchVariants({ variant, size }),
|
||||||
|
"peer/switch",
|
||||||
|
switchClassName
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{/* 滑块 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute top-1/2 -translate-y-1/2 rounded-full bg-white shadow-sm transition-transform duration-250",
|
||||||
|
thumbSize,
|
||||||
|
thumbTranslate
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderLabel = () => {
|
||||||
|
if (!label) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
htmlFor={switchId}
|
||||||
|
className={cn(
|
||||||
|
"text-base font-normal leading-none",
|
||||||
|
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
|
||||||
|
labelPosition === "left" ? "mr-3" : "ml-3"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!label) {
|
||||||
|
return renderSwitch();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("inline-flex items-center", className)}>
|
||||||
|
{labelPosition === "left" && renderLabel()}
|
||||||
|
{renderSwitch()}
|
||||||
|
{labelPosition === "right" && renderLabel()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Switch.displayName = "Switch";
|
||||||
1
src/design-system/base/textarea/index.ts
Normal file
1
src/design-system/base/textarea/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './textarea';
|
||||||
104
src/design-system/base/textarea/textarea.tsx
Normal file
104
src/design-system/base/textarea/textarea.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { forwardRef } from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/design-system/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Textarea 多行文本输入组件
|
||||||
|
*
|
||||||
|
* Design System 中的多行文本输入组件,支持多种样式变体。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // 默认样式
|
||||||
|
* <Textarea placeholder="请输入内容" rows={4} />
|
||||||
|
*
|
||||||
|
* // 带边框样式
|
||||||
|
* <Textarea variant="bordered" placeholder="带边框的文本域" />
|
||||||
|
*
|
||||||
|
* // 填充样式
|
||||||
|
* <Textarea variant="filled" placeholder="填充背景的文本域" />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Textarea 变体样式
|
||||||
|
*/
|
||||||
|
const textareaVariants = cva(
|
||||||
|
// 基础样式
|
||||||
|
"flex w-full rounded-md border px-3 py-2 text-base transition-all duration-250 placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-b-2 border-gray-300 bg-transparent rounded-t-xl",
|
||||||
|
bordered: "border-gray-300 bg-white",
|
||||||
|
filled: "border-transparent bg-gray-100",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
true: "border-error-500 focus-visible:ring-error-500",
|
||||||
|
false: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
compoundVariants: [
|
||||||
|
{
|
||||||
|
variant: "filled",
|
||||||
|
error: true,
|
||||||
|
className: "bg-error-50",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
error: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type TextareaVariant = VariantProps<typeof textareaVariants>["variant"];
|
||||||
|
|
||||||
|
export interface TextareaProps
|
||||||
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||||
|
VariantProps<typeof textareaVariants> {
|
||||||
|
// 自动调整高度
|
||||||
|
autoResize?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Textarea 多行文本输入组件
|
||||||
|
*/
|
||||||
|
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
variant = "default",
|
||||||
|
error = false,
|
||||||
|
className,
|
||||||
|
autoResize = false,
|
||||||
|
onChange,
|
||||||
|
rows = 3,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
// 自动调整高度的 change 处理
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (autoResize) {
|
||||||
|
const target = e.target;
|
||||||
|
target.style.height = "auto";
|
||||||
|
target.style.height = `${target.scrollHeight}px`;
|
||||||
|
}
|
||||||
|
onChange?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
rows={rows}
|
||||||
|
className={cn(textareaVariants({ variant, error }), className)}
|
||||||
|
onChange={handleChange}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Textarea.displayName = "Textarea";
|
||||||
160
src/design-system/data-display/badge/badge.tsx
Normal file
160
src/design-system/data-display/badge/badge.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/design-system/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Badge 徽章组件
|
||||||
|
*
|
||||||
|
* Design System 中的徽章组件,用于显示状态、标签等信息。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // 默认徽章
|
||||||
|
* <Badge>新</Badge>
|
||||||
|
*
|
||||||
|
* // 不同变体
|
||||||
|
* <Badge variant="success">成功</Badge>
|
||||||
|
* <Badge variant="warning">警告</Badge>
|
||||||
|
* <Badge variant="error">错误</Badge>
|
||||||
|
*
|
||||||
|
* // 不同尺寸
|
||||||
|
* <Badge size="sm">小</Badge>
|
||||||
|
* <Badge size="lg">大</Badge>
|
||||||
|
*
|
||||||
|
* // 圆形徽章
|
||||||
|
* <Badge variant="primary" dot />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Badge 变体样式
|
||||||
|
*/
|
||||||
|
const badgeVariants = cva(
|
||||||
|
// 基础样式
|
||||||
|
"inline-flex items-center justify-center rounded-full font-medium transition-colors duration-250",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-gray-100 text-gray-800",
|
||||||
|
primary: "bg-primary-100 text-primary-800",
|
||||||
|
success: "bg-success-100 text-success-800",
|
||||||
|
warning: "bg-warning-100 text-warning-800",
|
||||||
|
error: "bg-error-100 text-error-800",
|
||||||
|
info: "bg-info-100 text-info-800",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
sm: "px-2 py-0.5 text-xs",
|
||||||
|
md: "px-2.5 py-1 text-sm",
|
||||||
|
lg: "px-3 py-1.5 text-base",
|
||||||
|
},
|
||||||
|
dot: {
|
||||||
|
true: "px-2 py-1",
|
||||||
|
false: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "md",
|
||||||
|
dot: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
|
||||||
|
export type BadgeSize = VariantProps<typeof badgeVariants>["size"];
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {
|
||||||
|
// 子元素
|
||||||
|
children?: React.ReactNode;
|
||||||
|
// 是否为圆点样式(不显示文字)
|
||||||
|
dot?: boolean;
|
||||||
|
// 圆点颜色(仅当 dot=true 时有效)
|
||||||
|
dotColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Badge 徽章组件
|
||||||
|
*/
|
||||||
|
export function Badge({
|
||||||
|
variant = "default",
|
||||||
|
size = "md",
|
||||||
|
dot = false,
|
||||||
|
dotColor,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: BadgeProps) {
|
||||||
|
// 圆点颜色映射
|
||||||
|
const dotColors = {
|
||||||
|
default: "bg-gray-400",
|
||||||
|
primary: "bg-primary-500",
|
||||||
|
success: "bg-success-500",
|
||||||
|
warning: "bg-warning-500",
|
||||||
|
error: "bg-error-500",
|
||||||
|
info: "bg-info-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 确保 variant 有默认值
|
||||||
|
const actualVariant = variant ?? "default";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant: actualVariant, size, dot }), className)} {...props}>
|
||||||
|
{dot && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"h-2 w-2 rounded-full",
|
||||||
|
dotColor || dotColors[actualVariant]
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!dot && children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StatusBadge - 状态徽章
|
||||||
|
*/
|
||||||
|
export interface StatusBadgeProps extends Omit<BadgeProps, "variant" | "children"> {
|
||||||
|
status: "online" | "offline" | "busy" | "away";
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({ status, label, ...props }: StatusBadgeProps) {
|
||||||
|
const statusConfig = {
|
||||||
|
online: { variant: "success" as const, defaultLabel: "在线" },
|
||||||
|
offline: { variant: "default" as const, defaultLabel: "离线" },
|
||||||
|
busy: { variant: "error" as const, defaultLabel: "忙碌" },
|
||||||
|
away: { variant: "warning" as const, defaultLabel: "离开" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = statusConfig[status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant={config.variant} {...props}>
|
||||||
|
{label || config.defaultLabel}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CounterBadge - 计数徽章
|
||||||
|
*/
|
||||||
|
export interface CounterBadgeProps extends Omit<BadgeProps, "children"> {
|
||||||
|
count: number;
|
||||||
|
max?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CounterBadge({ count, max = 99, ...props }: CounterBadgeProps) {
|
||||||
|
const displayCount = count > max ? `${max}+` : count;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant="error" size="sm" {...props}>
|
||||||
|
{displayCount}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user