Compare commits

...

25 Commits

Author SHA1 Message Date
b8cb884e9e Design System 重构继续完成 2026-02-10 04:58:50 +08:00
73d0b0d5fe Design System 重构完成 2026-02-10 03:54:09 +08:00
fe5e8533b5 layout 2026-02-06 04:41:59 +08:00
12eb5c412a layout 2026-02-06 04:36:06 +08:00
3635fbd256 button 2026-02-06 04:13:50 +08:00
058ecf7e39 button 2026-02-06 04:01:41 +08:00
6c7095ffb3 ... 2026-02-06 03:43:49 +08:00
8ed9b011f4 ... 2026-02-06 03:28:53 +08:00
2537b9fe75 ... 2026-02-06 03:22:20 +08:00
5e24fa76a3 ... 2026-02-06 03:16:06 +08:00
9d42a45bb1 ... 2026-02-03 20:29:55 +08:00
d5dde77ee9 ... 2026-02-03 20:00:56 +08:00
c4a9247cad ... 2026-02-03 19:18:29 +08:00
56552863bf ... 2026-02-03 17:27:58 +08:00
0af99b6b70 修改语言图标 2026-02-03 17:04:41 +08:00
eaf97b8279 ... 2026-02-02 23:57:01 +08:00
76749549ff ... 2026-02-02 23:32:39 +08:00
fa6301538b ... 2026-01-22 16:01:07 +08:00
d4d5a53747 补全翻译 2026-01-18 13:06:08 +08:00
ec265be26b 重构 2026-01-14 16:57:35 +08:00
804baa64b2 重构 2026-01-13 23:02:07 +08:00
a1e42127e6 update ignore 2026-01-13 15:17:59 +08:00
f1d706e20c ... 2026-01-13 14:46:27 +08:00
c7cdf40f2f change varchar to text 2026-01-08 10:18:05 +08:00
a55e763525 解决dictionary搜索框溢出问题 2026-01-08 09:45:08 +08:00
190 changed files with 8752 additions and 3149 deletions

View File

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

2
.gitignore vendored
View File

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

View File

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

View File

@@ -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 层 DTOZod 验证)
├── {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 插件支持用户名登录

View File

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

View File

@@ -44,7 +44,15 @@
"language2": "Sprache 2", "language2": "Sprache 2",
"enterLanguageName": "Bitte geben Sie den Sprachennamen ein", "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",
@@ -173,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",
@@ -218,5 +233,26 @@
"pleaseCreateFolder": "Bitte erstellen Sie zuerst einen Ordner", "pleaseCreateFolder": "Bitte erstellen Sie zuerst einen Ordner",
"savedToFolder": "Im Ordner gespeichert: {folderName}", "savedToFolder": "Im Ordner gespeichert: {folderName}",
"saveFailed": "Speichern fehlgeschlagen, bitte später erneut versuchen" "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"
}
} }
} }

View File

@@ -44,7 +44,15 @@
"language2": "Locale 2", "language2": "Locale 2",
"enterLanguageName": "Please enter language name", "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",
@@ -91,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?",
@@ -101,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..."
@@ -173,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",
@@ -218,5 +239,26 @@
"pleaseCreateFolder": "Please create a folder first", "pleaseCreateFolder": "Please create a folder first",
"savedToFolder": "Saved to folder: {folderName}", "savedToFolder": "Saved to folder: {folderName}",
"saveFailed": "Save failed, please try again later" "saveFailed": "Save failed, please try again later"
},
"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"
}
} }
} }

View File

@@ -44,7 +44,15 @@
"language2": "Langue 2", "language2": "Langue 2",
"enterLanguageName": "Veuillez entrer le nom de la langue", "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",
@@ -173,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",
@@ -218,5 +233,26 @@
"pleaseCreateFolder": "Veuillez d'abord créer un dossier", "pleaseCreateFolder": "Veuillez d'abord créer un dossier",
"savedToFolder": "Enregistré dans le dossier : {folderName}", "savedToFolder": "Enregistré dans le dossier : {folderName}",
"saveFailed": "Échec de l'enregistrement, veuillez réessayer plus tard" "saveFailed": "Échec de l'enregistrement, veuillez réessayer plus tard"
},
"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"
}
} }
} }

View File

@@ -44,7 +44,15 @@
"language2": "Lingua 2", "language2": "Lingua 2",
"enterLanguageName": "Inserisci il nome della lingua", "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",
@@ -173,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",
@@ -218,5 +233,26 @@
"pleaseCreateFolder": "Crea prima una cartella", "pleaseCreateFolder": "Crea prima una cartella",
"savedToFolder": "Salvato nella cartella: {folderName}", "savedToFolder": "Salvato nella cartella: {folderName}",
"saveFailed": "Salvataggio fallito, riprova più tardi" "saveFailed": "Salvataggio fallito, riprova più tardi"
},
"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"
}
} }
} }

View File

@@ -44,7 +44,15 @@
"language2": "言語2", "language2": "言語2",
"enterLanguageName": "言語名を入力してください", "enterLanguageName": "言語名を入力してください",
"edit": "編集", "edit": "編集",
"delete": "削除" "delete": "削除",
"permissionDenied": "この操作を実行する権限がありません",
"error": {
"update": "この項目を更新する権限がありません。",
"delete": "この項目を削除する権限がありません。",
"add": "このフォルダーに項目を追加する権限がありません。",
"rename": "このフォルダー名を変更する権限がありません。",
"deleteFolder": "このフォルダーを削除する権限がありません。"
}
}, },
"home": { "home": {
"title": "言語を学ぶ", "title": "言語を学ぶ",
@@ -173,7 +181,14 @@
"translateInto": "翻訳", "translateInto": "翻訳",
"chinese": "中国語", "chinese": "中国語",
"english": "英語", "english": "英語",
"french": "フランス語",
"german": "ドイツ語",
"italian": "イタリア語", "italian": "イタリア語",
"japanese": "日本語",
"korean": "韓国語",
"portuguese": "ポルトガル語",
"russian": "ロシア語",
"spanish": "スペイン語",
"other": "その他", "other": "その他",
"translating": "翻訳中...", "translating": "翻訳中...",
"translate": "翻訳", "translate": "翻訳",
@@ -218,5 +233,26 @@
"pleaseCreateFolder": "まずフォルダを作成してください", "pleaseCreateFolder": "まずフォルダを作成してください",
"savedToFolder": "フォルダに保存しました:{folderName}", "savedToFolder": "フォルダに保存しました:{folderName}",
"saveFailed": "保存に失敗しました。後でもう一度お試しください" "saveFailed": "保存に失敗しました。後でもう一度お試しください"
},
"user_profile": {
"anonymous": "匿名",
"email": "メールアドレス",
"verified": "認証済み",
"unverified": "未認証",
"accountInfo": "アカウント情報",
"userId": "ユーザーID",
"username": "ユーザー名",
"displayName": "表示名",
"notSet": "未設定",
"memberSince": "登録日",
"folders": {
"title": "フォルダー",
"noFolders": "フォルダーがありません",
"folderName": "フォルダー名",
"totalPairs": "テキストペア数",
"createdAt": "作成日",
"actions": "操作",
"view": "表示"
}
} }
} }

View File

@@ -44,7 +44,15 @@
"language2": "언어 2", "language2": "언어 2",
"enterLanguageName": "언어 이름을 입력하세요", "enterLanguageName": "언어 이름을 입력하세요",
"edit": "편집", "edit": "편집",
"delete": "삭제" "delete": "삭제",
"permissionDenied": "이 작업을 수행할 권한이 없습니다",
"error": {
"update": "이 항목을 업데이트할 권한이 없습니다.",
"delete": "이 항목을 삭제할 권한이 없습니다.",
"add": "이 폴더에 항목을 추가할 권한이 없습니다.",
"rename": "이 폴더 이름을 변경할 권한이 없습니다.",
"deleteFolder": "이 폴더를 삭제할 권한이 없습니다."
}
}, },
"home": { "home": {
"title": "언어 학습", "title": "언어 학습",
@@ -173,7 +181,14 @@
"translateInto": "번역", "translateInto": "번역",
"chinese": "중국어", "chinese": "중국어",
"english": "영어", "english": "영어",
"french": "프랑스어",
"german": "독일어",
"italian": "이탈리아어", "italian": "이탈리아어",
"japanese": "일본어",
"korean": "한국어",
"portuguese": "포르투갈어",
"russian": "러시아어",
"spanish": "스페인어",
"other": "기타", "other": "기타",
"translating": "번역 중...", "translating": "번역 중...",
"translate": "번역", "translate": "번역",
@@ -218,5 +233,26 @@
"pleaseCreateFolder": "먼저 폴더를 만드세요", "pleaseCreateFolder": "먼저 폴더를 만드세요",
"savedToFolder": "폴더에 저장됨: {folderName}", "savedToFolder": "폴더에 저장됨: {folderName}",
"saveFailed": "저장 실패, 나중에 다시 시도하세요" "saveFailed": "저장 실패, 나중에 다시 시도하세요"
},
"user_profile": {
"anonymous": "익명",
"email": "이메일",
"verified": "인증됨",
"unverified": "미인증",
"accountInfo": "계정 정보",
"userId": "사용자 ID",
"username": "사용자명",
"displayName": "표시 이름",
"notSet": "설정되지 않음",
"memberSince": "가입일",
"folders": {
"title": "폴더",
"noFolders": "폴더가 없습니다",
"folderName": "폴더 이름",
"totalPairs": "텍스트 쌍 수",
"createdAt": "생성일",
"actions": "작업",
"view": "보기"
}
} }
} }

View File

@@ -44,7 +44,15 @@
"language2": "تىل 2", "language2": "تىل 2",
"enterLanguageName": "تىل نامىنى كىرگۈزۈڭ", "enterLanguageName": "تىل نامىنى كىرگۈزۈڭ",
"edit": "تەھرىرلەش", "edit": "تەھرىرلەش",
"delete": "ئۆچۈرۈش" "delete": "ئۆچۈرۈش",
"permissionDenied": "بۇ مەشغۇلاتنى ئىجرا قىلىش ھوقۇقىڭىز يوق",
"error": {
"update": "بۇ تۈرنى يېڭىلاش ھوقۇقىڭىز يوق.",
"delete": "بۇ تۈرنى ئۆچۈرۈش ھوقۇقىڭىز يوق.",
"add": "بۇ قىسقۇچقا تۈر قوشۇش ھوقۇقىڭىز يوق.",
"rename": "بۇ قىسقۇچنىڭ نامىنى ئۆزگەرتىش ھوقۇقىڭىز يوق.",
"deleteFolder": "بۇ قىسقۇچنى ئۆچۈرۈش ھوقۇقىڭىز يوق."
}
}, },
"home": { "home": {
"title": "تىل ئۆگىنىڭ", "title": "تىل ئۆگىنىڭ",
@@ -173,7 +181,14 @@
"translateInto": "تەرجىمە قىلىش", "translateInto": "تەرجىمە قىلىش",
"chinese": "خەنزۇچە", "chinese": "خەنزۇچە",
"english": "ئىنگلىزچە", "english": "ئىنگلىزچە",
"french": "فرانسۇزچە",
"german": "گېرمانچە",
"italian": "ئىتاليانچە", "italian": "ئىتاليانچە",
"japanese": "ياپونچە",
"korean": "كورېيەچە",
"portuguese": "پورتۇگالچە",
"russian": "رۇسچە",
"spanish": "ئىسپانچە",
"other": "باشقا", "other": "باشقا",
"translating": "تەرجىمە قىلىۋاتىدۇ...", "translating": "تەرجىمە قىلىۋاتىدۇ...",
"translate": "تەرجىمە قىلىش", "translate": "تەرجىمە قىلىش",
@@ -218,5 +233,26 @@
"pleaseCreateFolder": "ئاۋۋال قىسقۇچ قۇرۇڭ", "pleaseCreateFolder": "ئاۋۋال قىسقۇچ قۇرۇڭ",
"savedToFolder": "قىسقۇچقا ساقلاندى: {folderName}", "savedToFolder": "قىسقۇچقا ساقلاندى: {folderName}",
"saveFailed": "ساقلاش مەغلۇب بولدى، كېيىنرەك قايتا سىناڭ" "saveFailed": "ساقلاش مەغلۇب بولدى، كېيىنرەك قايتا سىناڭ"
},
"user_profile": {
"anonymous": "ئىسىمسىز",
"email": "ئېلخەت",
"verified": "دەلىللەندى",
"unverified": "دەلىتلەنمىدى",
"accountInfo": "ھېسابات ئۇچۇرى",
"userId": "ئىشلەتكۈچى كودى",
"username": "ئىشلەتكۈچى نامى",
"displayName": "كۆرسىتىلىدىغان نام",
"notSet": "تەڭشەلمىگەن",
"memberSince": "تىزىملاتقان ۋاقىت",
"folders": {
"title": "قىسقۇچلار",
"noFolders": "قىسقۇچ يوق",
"folderName": "قىسقۇچ نامى",
"totalPairs": "تېكىست جۈپ سانى",
"createdAt": "قۇرۇلغان ۋاقىت",
"actions": "مەشغۇلات",
"view": "كۆرۈش"
}
} }
} }

View File

@@ -44,7 +44,15 @@
"language2": "语言2", "language2": "语言2",
"enterLanguageName": "请输入语言名称", "enterLanguageName": "请输入语言名称",
"edit": "编辑", "edit": "编辑",
"delete": "删除" "delete": "删除",
"permissionDenied": "您没有权限执行此操作",
"error": {
"update": "您没有权限更新此项目",
"delete": "您没有权限删除此项目",
"add": "您没有权限向此文件夹添加项目",
"rename": "您没有权限重命名此文件夹",
"deleteFolder": "您没有权限删除此文件夹"
}
}, },
"home": { "home": {
"title": "学语言", "title": "学语言",
@@ -91,6 +99,8 @@
"password": "密码", "password": "密码",
"confirmPassword": "确认密码", "confirmPassword": "确认密码",
"name": "用户名", "name": "用户名",
"username": "用户名",
"emailOrUsername": "邮箱或用户名",
"signInButton": "登录", "signInButton": "登录",
"signUpButton": "注册", "signUpButton": "注册",
"noAccount": "还没有账户?", "noAccount": "还没有账户?",
@@ -101,7 +111,11 @@
"passwordTooShort": "密码至少需要8个字符", "passwordTooShort": "密码至少需要8个字符",
"passwordsNotMatch": "两次输入的密码不匹配", "passwordsNotMatch": "两次输入的密码不匹配",
"nameRequired": "请输入用户名", "nameRequired": "请输入用户名",
"usernameRequired": "请输入用户名",
"usernameTooShort": "用户名至少需要3个字符",
"usernameInvalid": "用户名只能包含字母、数字和下划线",
"emailRequired": "请输入邮箱", "emailRequired": "请输入邮箱",
"identifierRequired": "请输入邮箱或用户名",
"passwordRequired": "请输入密码", "passwordRequired": "请输入密码",
"confirmPasswordRequired": "请确认密码", "confirmPasswordRequired": "请确认密码",
"loading": "加载中..." "loading": "加载中..."
@@ -173,7 +187,14 @@
"translateInto": "翻译为", "translateInto": "翻译为",
"chinese": "中文", "chinese": "中文",
"english": "英文", "english": "英文",
"french": "法语",
"german": "德语",
"italian": "意大利语", "italian": "意大利语",
"japanese": "日语",
"korean": "韩语",
"portuguese": "葡萄牙语",
"russian": "俄语",
"spanish": "西班牙语",
"other": "其他", "other": "其他",
"translating": "翻译中...", "translating": "翻译中...",
"translate": "翻译", "translate": "翻译",
@@ -218,5 +239,26 @@
"pleaseCreateFolder": "请先创建文件夹", "pleaseCreateFolder": "请先创建文件夹",
"savedToFolder": "已保存到文件夹:{folderName}", "savedToFolder": "已保存到文件夹:{folderName}",
"saveFailed": "保存失败,请稍后重试" "saveFailed": "保存失败,请稍后重试"
},
"user_profile": {
"anonymous": "匿名",
"email": "邮箱",
"verified": "已验证",
"unverified": "未验证",
"accountInfo": "账户信息",
"userId": "用户ID",
"username": "用户名",
"displayName": "显示名称",
"notSet": "未设置",
"memberSince": "注册时间",
"folders": {
"title": "文件夹",
"noFolders": "还没有文件夹",
"folderName": "文件夹名称",
"totalPairs": "文本对数量",
"createdAt": "创建时间",
"actions": "操作",
"view": "查看"
}
} }
} }

View File

@@ -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
View File

@@ -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: {}

View File

@@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE "pairs" ALTER COLUMN "language1" SET DATA TYPE TEXT,
ALTER COLUMN "language2" SET DATA TYPE TEXT;
-- AlterTable
ALTER TABLE "translation_history" ALTER COLUMN "source_language" SET DATA TYPE TEXT,
ALTER COLUMN "target_language" SET DATA TYPE TEXT;

View File

@@ -0,0 +1,94 @@
/*
Warnings:
- You are about to drop the column `dictionary_phrase_id` on the `dictionary_lookups` table. All the data in the column will be lost.
- You are about to drop the column `dictionary_word_id` on the `dictionary_lookups` table. All the data in the column will be lost.
- You are about to drop the `dictionary_phrase_entries` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `dictionary_phrases` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `dictionary_word_entries` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `dictionary_words` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "dictionary_lookups" DROP CONSTRAINT "dictionary_lookups_dictionary_phrase_id_fkey";
-- DropForeignKey
ALTER TABLE "dictionary_lookups" DROP CONSTRAINT "dictionary_lookups_dictionary_word_id_fkey";
-- DropForeignKey
ALTER TABLE "dictionary_phrase_entries" DROP CONSTRAINT "dictionary_phrase_entries_phrase_id_fkey";
-- DropForeignKey
ALTER TABLE "dictionary_word_entries" DROP CONSTRAINT "dictionary_word_entries_word_id_fkey";
-- DropIndex
DROP INDEX "dictionary_lookups_text_query_lang_definition_lang_idx";
-- AlterTable
ALTER TABLE "dictionary_lookups" DROP COLUMN "dictionary_phrase_id",
DROP COLUMN "dictionary_word_id",
ADD COLUMN "dictionary_item_id" INTEGER,
ADD COLUMN "normalized_text" TEXT NOT NULL DEFAULT '';
-- DropTable
DROP TABLE "dictionary_phrase_entries";
-- DropTable
DROP TABLE "dictionary_phrases";
-- DropTable
DROP TABLE "dictionary_word_entries";
-- DropTable
DROP TABLE "dictionary_words";
-- CreateTable
CREATE TABLE "dictionary_items" (
"id" SERIAL NOT NULL,
"frequency" INTEGER NOT NULL DEFAULT 1,
"standard_form" TEXT NOT NULL,
"query_lang" TEXT NOT NULL,
"definition_lang" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "dictionary_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dictionary_entries" (
"id" SERIAL NOT NULL,
"item_id" INTEGER NOT NULL,
"ipa" TEXT,
"definition" TEXT NOT NULL,
"part_of_speech" TEXT,
"example" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "dictionary_entries_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "dictionary_items_standard_form_idx" ON "dictionary_items"("standard_form");
-- CreateIndex
CREATE INDEX "dictionary_items_query_lang_definition_lang_idx" ON "dictionary_items"("query_lang", "definition_lang");
-- CreateIndex
CREATE UNIQUE INDEX "dictionary_items_standard_form_query_lang_definition_lang_key" ON "dictionary_items"("standard_form", "query_lang", "definition_lang");
-- CreateIndex
CREATE INDEX "dictionary_entries_item_id_idx" ON "dictionary_entries"("item_id");
-- CreateIndex
CREATE INDEX "dictionary_entries_created_at_idx" ON "dictionary_entries"("created_at");
-- CreateIndex
CREATE INDEX "dictionary_lookups_normalized_text_idx" ON "dictionary_lookups"("normalized_text");
-- AddForeignKey
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_item_id_fkey" FOREIGN KEY ("dictionary_item_id") REFERENCES "dictionary_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dictionary_entries" ADD CONSTRAINT "dictionary_entries_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "dictionary_items"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,12 @@
/*
Warnings:
- A unique constraint covering the columns `[username]` on the table `user` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "user" ADD COLUMN "displayUsername" TEXT,
ADD COLUMN "username" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "user_username_key" ON "user"("username");

View File

@@ -10,25 +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[] 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?
@@ -36,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")
} }
@@ -46,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?
@@ -56,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")
@@ -75,17 +75,16 @@ 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, text2]) @@unique([folderId, language1, language2, text1, text2])
@@index([folderId]) @@index([folderId])
@@ -98,111 +97,76 @@ 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[]
@@index([userId]) @@index([userId])
@@map("folders") @@map("folders")
} }
model DictionaryLookUp { model DictionaryLookUp {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId String? @map("user_id") userId String? @map("user_id")
text String text String
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())
standardForm String @map("standard_form") frequency Int @default(1)
queryLang String @map("query_lang") standardForm String @map("standard_form")
definitionLang String @map("definition_lang") queryLang String @map("query_lang")
createdAt DateTime @default(now()) @map("created_at") definitionLang String @map("definition_lang")
updatedAt DateTime @updatedAt @map("updated_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
lookups DictionaryLookUp[] entries DictionaryEntry[]
entries DictionaryWordEntry[] lookups DictionaryLookUp[]
@@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[]
@@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 {
id Int @id @default(autoincrement())
phraseId Int @map("phrase_id")
definition String
example String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
phrase DictionaryPhrase @relation(fields: [phraseId], references: [id], onDelete: Cascade)
@@index([phraseId])
@@index([createdAt])
@@map("dictionary_phrase_entries")
} }
model TranslationHistory { model TranslationHistory {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId String? @map("user_id") userId String? @map("user_id")
sourceText String @map("source_text") sourceText String @map("source_text")
sourceLanguage String @map("source_language") @db.VarChar(20) sourceLanguage String @map("source_language")
targetLanguage String @map("target_language") @db.VarChar(20) targetLanguage String @map("target_language")
translatedText String @map("translated_text") translatedText String @map("translated_text")
sourceIpa String? @map("source_ipa") sourceIpa String? @map("source_ipa")
targetIpa String? @map("target_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])
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([userId]) @@index([userId])
@@index([createdAt]) @@index([createdAt])

View File

@@ -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,148 +99,122 @@ 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="lg"
size={32} alt="close"
alt="close" src={IMAGES.close}
src={IMAGES.close} onClick={onBack}
onClick={onBack} className="bg-white rounded-full shadow-md"
className="bg-white rounded-full shadow-md" />
/> </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">
{/* 当前字母进度 */} {/* 当前字母进度 */}
<span className="text-sm text-gray-500"> <span className="text-sm text-gray-500">
{currentIndex + 1} / {alphabet.length} {currentIndex + 1} / {alphabet.length}
</span> </span>
{/* 显示选项切换按钮组 */} {/* 显示选项切换按钮组 */}
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
<button <CircleToggleButton
onClick={() => setShowLetter(!showLetter)} selected={showLetter}
className={`px-3 py-1 rounded-full text-sm transition-colors ${ onClick={() => setShowLetter(!showLetter)}
showLetter
? "bg-[#35786f] text-white"
: "bg-gray-200 text-gray-600"
}`}
>
{t("letter")}
</button>
{/* IPA 音标显示切换 */}
<button
onClick={() => setShowIPA(!showIPA)}
className={`px-3 py-1 rounded-full text-sm transition-colors ${
showIPA
? "bg-[#35786f] text-white"
: "bg-gray-200 text-gray-600"
}`}
>
IPA
</button>
{/* 罗马音显示切换(仅日语显示) */}
{hasRomanization && (
<button
onClick={() => setShowRoman(!showRoman)}
className={`px-3 py-1 rounded-full text-sm transition-colors ${
showRoman
? "bg-[#35786f] text-white"
: "bg-gray-200 text-gray-600"
}`}
>
{t("roman")}
</button>
)}
{/* 随机模式切换 */}
<button
onClick={() => setIsRandomMode(!isRandomMode)}
className={`px-3 py-1 rounded-full text-sm transition-colors ${
isRandomMode
? "bg-[#35786f] text-white"
: "bg-gray-200 text-gray-600"
}`}
>
{t("random")}
</button>
</div>
</div>
{/* 字母主要内容显示区域 */}
<div className="text-center mb-8">
{/* 字母本身(可隐藏) */}
{showLetter ? (
<div className="text-6xl md:text-8xl font-bold text-gray-800 mb-4">
{currentLetter.letter}
</div>
) : (
<div className="text-6xl md:text-8xl font-bold text-gray-300 mb-4 h-20 md:h-24 flex items-center justify-center">
<span className="text-2xl md:text-3xl text-gray-400">?</span>
</div>
)}
{/* IPA 音标显示 */}
{showIPA && (
<div className="text-2xl md:text-3xl text-gray-600 mb-2">
{currentLetter.letter_sound_ipa}
</div>
)}
{/* 罗马音显示(日语) */}
{showRoman && hasRomanization && currentLetter.roman_letter && (
<div className="text-lg md:text-xl text-gray-500">
{currentLetter.roman_letter}
</div>
)}
</div>
{/* 底部导航控制区域 */}
<div className="flex justify-between items-center">
{/* 上一个按钮 */}
<button
onClick={goToPrevious}
className="p-3 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
aria-label="上一个字母"
> >
<ChevronLeft size={24} /> {t("letter")}
</button> </CircleToggleButton>
{/* IPA 音标显示切换 */}
{/* 中间区域:随机按钮 */} <CircleToggleButton
<div className="flex gap-2 items-center"> selected={showIPA}
{isRandomMode && ( onClick={() => setShowIPA(!showIPA)}
<button
onClick={goToRandom}
className="px-4 py-2 rounded-full bg-[#35786f] text-white text-sm font-medium hover:bg-[#2d5f58] transition-colors"
>
{t("randomNext")}
</button>
)}
</div>
{/* 下一个按钮 */}
<button
onClick={goToNext}
className="p-3 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
aria-label="下一个字母"
> >
<ChevronRight size={24} /> IPA
</button> </CircleToggleButton>
{/* 罗马音显示切换(仅日语显示) */}
{hasRomanization && (
<CircleToggleButton
selected={showRoman}
onClick={() => setShowRoman(!showRoman)}
>
{t("roman")}
</CircleToggleButton>
)}
{/* 随机模式切换 */}
<CircleToggleButton
selected={isRandomMode}
onClick={() => setIsRandomMode(!isRandomMode)}
>
{t("random")}
</CircleToggleButton>
</div> </div>
</div> </div>
{/* 底部操作提示文字 */} {/* 字母主要内容显示区域 */}
<div className="text-center mt-6 text-white text-sm"> <div className="text-center mb-8">
<p> {/* 字母本身(可隐藏) */}
{isRandomMode {showLetter ? (
? "使用左右箭头键或空格键随机切换字母ESC键返回" <div className="text-6xl md:text-8xl font-bold text-gray-800 mb-4">
: "使用左右箭头键或滑动切换字母ESC键返回" {currentLetter.letter}
} </div>
</p> ) : (
<div className="text-6xl md:text-8xl font-bold text-gray-300 mb-4 h-20 md:h-24 flex items-center justify-center">
<span className="text-2xl md:text-3xl text-gray-400">?</span>
</div>
)}
{/* IPA 音标显示 */}
{showIPA && (
<div className="text-2xl md:text-3xl text-gray-600 mb-2">
{currentLetter.letter_sound_ipa}
</div>
)}
{/* 罗马音显示(日语) */}
{showRoman && hasRomanization && currentLetter.roman_letter && (
<div className="text-lg md:text-xl text-gray-500">
{currentLetter.roman_letter}
</div>
)}
</div> </div>
{/* 底部导航控制区域 */}
<div className="flex justify-between items-center">
{/* 上一个按钮 */}
<CircleButton onClick={goToPrevious} aria-label="上一个字母">
<ChevronLeft size={20} />
</CircleButton>
{/* 中间区域:随机按钮 */}
<div className="flex gap-2 items-center">
{isRandomMode && (
<PrimaryButton
onClick={goToRandom}
className="rounded-full px-4 py-2 text-sm"
>
{t("randomNext")}
</PrimaryButton>
)}
</div>
{/* 下一个按钮 */}
<CircleButton onClick={goToNext} aria-label="下一个字母">
<ChevronRight size={20} />
</CircleButton>
</div>
</Card>
{/* 底部操作提示文字 */}
<div className="text-center mt-6 text-white text-sm">
<p>
{isRandomMode
? "使用左右箭头键或空格键随机切换字母ESC键返回"
: "使用左右箭头键或滑动切换字母ESC键返回"
}
</p>
</div> </div>
{/* 全屏触摸事件监听层(用于滑动切换) */} {/* 全屏触摸事件监听层(用于滑动切换) */}
@@ -248,6 +224,6 @@ export default function AlphabetCard({ alphabet, alphabetType, onBack }: Alphabe
onTouchMove={onTouchMove} onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd} onTouchEnd={onTouchEnd}
/> />
</div> </PageLayout>
); );
} }

View File

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

View File

@@ -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,87 +48,81 @@ 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")} </h1>
</h1> {/* 副标题说明 */}
{/* 副标题说明 */} <p className="text-gray-600 mb-8 text-lg">
<p className="text-gray-600 mb-8 text-lg">
</p>
</p>
{/* 语言选择按钮网格 */} {/* 语言选择按钮网格 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 日语假名选项 */} {/* 日语假名选项 */}
<LightButton <LightButton
onClick={() => setChosenAlphabet("japanese")} onClick={() => setChosenAlphabet("japanese")}
className="p-6 text-lg font-medium hover:scale-105 transition-transform" className="p-6 text-lg font-medium hover:scale-105 transition-transform"
> >
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<span className="text-2xl mb-2"></span> <span className="text-2xl mb-2"></span>
<span>{t("japanese")}</span> <span>{t("japanese")}</span>
</div> </div>
</LightButton> </LightButton>
{/* 英语字母选项 */} {/* 英语字母选项 */}
<LightButton <LightButton
onClick={() => setChosenAlphabet("english")} onClick={() => setChosenAlphabet("english")}
className="p-6 text-lg font-medium hover:scale-105 transition-transform" className="p-6 text-lg font-medium hover:scale-105 transition-transform"
> >
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<span className="text-2xl mb-2">ABC</span> <span className="text-2xl mb-2">ABC</span>
<span>{t("english")}</span> <span>{t("english")}</span>
</div> </div>
</LightButton> </LightButton>
{/* 维吾尔语字母选项 */} {/* 维吾尔语字母选项 */}
<LightButton <LightButton
onClick={() => setChosenAlphabet("uyghur")} onClick={() => setChosenAlphabet("uyghur")}
className="p-6 text-lg font-medium hover:scale-105 transition-transform" className="p-6 text-lg font-medium hover:scale-105 transition-transform"
> >
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<span className="text-2xl mb-2">ئۇيغۇر</span> <span className="text-2xl mb-2">ئۇيغۇر</span>
<span>{t("uyghur")}</span> <span>{t("uyghur")}</span>
</div> </div>
</LightButton> </LightButton>
{/* 世界语字母选项 */} {/* 世界语字母选项 */}
<LightButton <LightButton
onClick={() => setChosenAlphabet("esperanto")} onClick={() => setChosenAlphabet("esperanto")}
className="p-6 text-lg font-medium hover:scale-105 transition-transform" className="p-6 text-lg font-medium hover:scale-105 transition-transform"
> >
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<span className="text-2xl mb-2">ABCĜĤ</span> <span className="text-2xl mb-2">ABCĜĤ</span>
<span>{t("esperanto")}</span> <span>{t("esperanto")}</span>
</div> </div>
</LightButton> </LightButton>
</div> </div>
</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>
); );
} }

View File

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

View File

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

View File

@@ -1,29 +1,38 @@
import { LightButton } from "@/components/ui/buttons"; "use client";
import { POPULAR_LANGUAGES } from "./constants";
import { LightButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { POPULAR_LANGUAGES } from "./constants";
interface SearchFormProps { interface SearchFormProps {
searchQuery: string; defaultQueryLang?: string;
onSearchQueryChange: (query: string) => void; defaultDefinitionLang?: string;
isSearching: boolean;
onSearch: (e: React.FormEvent) => void;
queryLang: string;
onQueryLangChange: (lang: string) => void;
definitionLang: string;
onDefinitionLangChange: (lang: string) => void;
} }
export function SearchForm({ export function SearchForm({ defaultQueryLang = "english", defaultDefinitionLang = "chinese" }: SearchFormProps) {
searchQuery,
onSearchQueryChange,
isSearching,
onSearch,
queryLang,
onQueryLangChange,
definitionLang,
onDefinitionLangChange,
}: SearchFormProps) {
const t = useTranslations("dictionary"); 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 ( return (
<> <>
@@ -38,20 +47,20 @@ export function SearchForm({
</div> </div>
{/* 搜索表单 */} {/* 搜索表单 */}
<form onSubmit={onSearch} className="flex gap-2"> <form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-2">
<input <Input
type="text" type="text"
value={searchQuery} name="searchQuery"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onSearchQueryChange(e.target.value)} defaultValue=""
placeholder={t("searchPlaceholder")} placeholder={t("searchPlaceholder")}
className="flex-1 px-4 py-3 text-lg text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded" variant="search"
required
/> />
<LightButton <LightButton
type="submit" type="submit"
disabled={isSearching || !searchQuery.trim()} className="px-6 py-3 whitespace-nowrap text-center sm:min-w-30"
className="px-6 py-3"
> >
{isSearching ? t("searching") : t("search")} {t("search")}
</LightButton> </LightButton>
</form> </form>
@@ -62,68 +71,47 @@ export function SearchForm({
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
{/* 查询语言 */} {/* 查询语言 */}
<div> <div>
<label className="block text-gray-700 text-sm mb-2"> <label className="block text-gray-700 text-sm mb-2">
{t("queryLanguage")} ({t("queryLanguageHint")}) {t("queryLanguage")} ({t("queryLanguageHint")})
</label> </label>
<div className="flex flex-wrap gap-2 mb-2"> <div className="flex flex-wrap gap-2">
{POPULAR_LANGUAGES.map((lang) => ( {POPULAR_LANGUAGES.map((lang) => (
<LightButton <LightButton
key={lang.code} key={lang.code}
selected={queryLang === lang.code} type="button"
onClick={() => onQueryLangChange(lang.code)} selected={queryLang === lang.code}
className="text-sm px-3 py-1" onClick={() => setQueryLang(lang.code)}
> className="text-sm px-3 py-1"
{lang.nativeName} >
</LightButton> {lang.nativeName}
))} </LightButton>
</div> ))}
<input
type="text"
value={queryLang}
onChange={(e) => onQueryLangChange(e.target.value)}
placeholder={t("otherLanguagePlaceholder")}
className="w-full px-3 py-2 text-sm text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
/>
</div>
{/* 释义语言 */}
<div>
<label className="block text-gray-700 text-sm mb-2">
{t("definitionLanguage")} ({t("definitionLanguageHint")})
</label>
<div className="flex flex-wrap gap-2 mb-2">
{POPULAR_LANGUAGES.map((lang) => (
<LightButton
key={lang.code}
selected={definitionLang === lang.code}
onClick={() => onDefinitionLangChange(lang.code)}
className="text-sm px-3 py-1"
>
{lang.nativeName}
</LightButton>
))}
</div>
<input
type="text"
value={definitionLang}
onChange={(e) => onDefinitionLangChange(e.target.value)}
placeholder={t("otherLanguagePlaceholder")}
className="w-full px-3 py-2 text-sm text-gray-800 focus:outline-none border-b-2 border-gray-600 bg-white/90 rounded"
/>
</div>
{/* 当前设置显示 */}
<div className="text-center text-gray-700 text-sm pt-2 border-t border-gray-300">
{t("currentSettings", {
queryLang: POPULAR_LANGUAGES.find(l => l.code === queryLang)?.nativeName || queryLang,
definitionLang: POPULAR_LANGUAGES.find(l => l.code === definitionLang)?.nativeName || definitionLang
})}
</div> </div>
</div> </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>
</> </>
); );
} }

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

View File

@@ -1,155 +1,93 @@
import { Plus, RefreshCw } from "lucide-react"; import { auth } from "@/auth";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import { Folder } from "../../../../generated/prisma/browser";
import { createPair } from "@/lib/server/services/pairService";
import { lookUp } from "@/lib/server/bigmodel/dictionaryActions";
import {
DictWordResponse,
DictPhraseResponse,
isDictWordResponse,
DictWordEntry,
isDictErrorResponse,
} from "./types";
import { DictionaryEntry } from "./DictionaryEntry"; import { DictionaryEntry } from "./DictionaryEntry";
import { POPULAR_LANGUAGES } from "./constants"; import { TSharedItem } from "@/shared/dictionary-type";
import { useTranslations } from "next-intl"; 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 { interface SearchResultProps {
searchResult: DictWordResponse | DictPhraseResponse; searchResult: TSharedItem | null;
searchQuery: string; searchQuery: string;
queryLang: string; queryLang: string;
definitionLang: string; definitionLang: string;
folders: Folder[];
selectedFolderId: number | null;
onFolderSelect: (folderId: number | null) => void;
onResultUpdate: (newResult: DictWordResponse | DictPhraseResponse) => void;
onSearchingChange: (isSearching: boolean) => void;
getNativeName: (code: string) => string;
} }
export function SearchResult({ export async function SearchResult({
searchResult, searchResult,
searchQuery, searchQuery,
queryLang, queryLang,
definitionLang, definitionLang
folders,
selectedFolderId,
onFolderSelect,
onResultUpdate,
onSearchingChange,
getNativeName,
}: SearchResultProps) { }: SearchResultProps) {
const t = useTranslations("dictionary"); // 获取用户会话和文件夹
const { data: session } = authClient.useSession(); const session = await auth.api.getSession({ headers: await headers() });
let folders: TSharedFolder[] = [];
const handleRelookup = async () => { if (session?.user?.id) {
onSearchingChange(true); const result = await actionGetFoldersByUserId(session.user.id as string);
if (result.success && result.data) {
try { folders = result.data;
const result = await lookUp({
text: searchQuery,
definitionLang: getNativeName(definitionLang),
queryLang: getNativeName(queryLang),
forceRelook: true
});
if (isDictErrorResponse(result)) {
toast.error(result.error);
} else {
onResultUpdate(result);
toast.success(t("relookupSuccess"));
}
} catch (error) {
console.error("词典重新查询失败:", error);
toast.error(t("lookupFailed"));
} finally {
onSearchingChange(false);
} }
}; }
const handleSave = () => {
if (!session) {
toast.error(t("pleaseLogin"));
return;
}
if (!selectedFolderId) {
toast.error(t("pleaseCreateFolder"));
return;
}
const entry = searchResult.entries[0];
createPair({
text1: searchResult.standardForm,
text2: entry.definition,
language1: queryLang,
language2: definitionLang,
ipa1: isDictWordResponse(searchResult) && (entry as DictWordEntry).ipa ? (entry as DictWordEntry).ipa : undefined,
folderId: selectedFolderId,
})
.then(() => {
const folderName = folders.find(f => f.id === selectedFolderId)?.name || "Unknown";
toast.success(t("savedToFolder", { folderName }));
})
.catch(() => {
toast.error(t("saveFailed"));
});
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="bg-white rounded-lg p-6 shadow-lg"> {!searchResult ? (
{/* 标题和保存按钮 */} <div className="text-center py-12 bg-white/20 rounded-lg">
<div className="flex items-start justify-between mb-6"> <p className="text-gray-800 text-xl">No results found</p>
<div className="flex-1"> <p className="text-gray-600 mt-2">Try other words</p>
<h2 className="text-3xl font-bold text-gray-800 mb-2">
{searchResult.standardForm}
</h2>
</div>
<div className="flex items-center gap-2 ml-4">
{session && folders.length > 0 && (
<select
value={selectedFolderId || ""}
onChange={(e) => onFolderSelect(e.target.value ? Number(e.target.value) : null)}
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[#35786f]"
>
{folders.map((folder) => (
<option key={folder.id} value={folder.id}>
{folder.name}
</option>
))}
</select>
)}
<button
onClick={handleSave}
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-10 h-10 flex justify-center items-center shrink-0"
title={t("saveToFolder")}
>
<Plus />
</button>
</div>
</div> </div>
) : (
{/* 条目列表 */} <div className="bg-white rounded-lg p-6 shadow-lg">
<div className="space-y-6"> {/* 标题和保存按钮 */}
{searchResult.entries.map((entry, index) => ( <div className="flex items-start justify-between mb-6">
<div key={index} className="border-t border-gray-200 pt-4"> <div className="flex-1">
<DictionaryEntry entry={entry} /> <h2 className="text-3xl font-bold text-gray-800 mb-2">
{searchResult.standardForm}
</h2>
</div> </div>
))} <div className="flex items-center gap-2 ml-4">
</div> {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="border-t border-gray-200 pt-4 mt-4"> <div className="space-y-6">
<button {searchResult.entries.map((entry, index) => (
onClick={handleRelookup} <div key={index} className="border-t border-gray-200 pt-4">
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors" <DictionaryEntry entry={entry} />
> </div>
<RefreshCw className="w-4 h-4" /> ))}
{t("relookup")} </div>
</button>
{/* 重新查询按钮 */}
<div className="border-t border-gray-200 pt-4 mt-4">
<ReLookupButtonClient
searchQuery={searchQuery}
queryLang={queryLang}
definitionLang={definitionLang}
/>
</div>
</div> </div>
</div> )}
</div> </div>
); );
} }

View File

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

View File

@@ -1 +1,75 @@
export { default } from "./DictionaryPage"; 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";
interface DictionaryPageProps {
searchParams: Promise<{ q?: string; ql?: string; dl?: string; }>;
}
export default async function DictionaryPage({ searchParams }: DictionaryPageProps) {
const t = await getTranslations("dictionary");
// 从 searchParams 获取搜索参数
const { q: searchQuery, ql: queryLang = "english", dl: definitionLang = "chinese" } = await searchParams;
// 如果有搜索查询,获取搜索结果
let searchResult: TSharedItem | undefined | null = null;
if (searchQuery) {
const getNativeName = (code: string): string => {
const popularLanguages: Record<string, string> = {
english: "English",
chinese: "中文",
japanese: "日本語",
korean: "한국어",
italian: "Italiano",
uyghur: "ئۇيغۇرچە",
};
return popularLanguages[code] || code;
};
const result = await actionLookUpDictionary({
text: searchQuery,
queryLang: getNativeName(queryLang),
definitionLang: getNativeName(definitionLang),
forceRelook: false
});
if (result.success && result.data) {
searchResult = result.data;
}
}
return (
<PageLayout>
{/* 搜索区域 */}
<div className="mb-8">
<SearchForm
defaultQueryLang={queryLang}
defaultDefinitionLang={definitionLang}
/>
</div>
{/* 搜索结果区域 */}
<div>
{searchQuery && (
<SearchResult
searchResult={searchResult}
searchQuery={searchQuery}
queryLang={queryLang}
definitionLang={definitionLang}
/>
)}
{!searchQuery && (
<div className="text-center py-12">
<div className="text-6xl mb-4">📚</div>
<p className="text-gray-800 text-xl mb-2">{t("welcomeTitle")}</p>
<p className="text-gray-600">{t("welcomeHint")}</p>
</div>
)}
</div>
</PageLayout>
);
}

View File

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

View File

@@ -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,82 +17,77 @@ 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"> {folders.length === 0 ? (
<div className="bg-white rounded-2xl shadow-xl p-6 md:p-8"> // 空状态 - 显示提示和跳转按钮
{folders.length === 0 ? ( <div className="text-center">
// 空状态 - 显示提示和跳转按钮 <h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-4">
<div className="text-center"> {t("noFolders")}
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-4"> </h1>
{t("noFolders")} <Link href="/folders">
</h1> <PrimaryButton className="px-6 py-2">
<Link Go to Folders
className="inline-block px-6 py-2 bg-[#35786f] text-white rounded-full hover:bg-[#2d5f58] transition-colors" </PrimaryButton>
href="/folders" </Link>
>
Go to Folders
</Link>
</div>
) : (
<>
{/* 页面标题 */}
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-6">
{t("selectFolder")}
</h1>
{/* 文件夹列表 */}
<div className="border border-gray-200 rounded-2xl max-h-96 overflow-y-auto">
{folders
.toSorted((a, b) => a.id - b.id)
.map((folder) => (
<div
key={folder.id}
onClick={() =>
router.push(`/memorize?folder_id=${folder.id}`)
}
className="flex flex-row items-center p-4 gap-3 hover:cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0"
>
{/* 文件夹图标 */}
<div className="flex-shrink-0">
<Fd className="text-gray-600" size={24} />
</div>
{/* 文件夹信息 */}
<div className="flex-1">
<div className="font-medium text-gray-900">
{folder.name}
</div>
<div className="text-sm text-gray-500">
{t("folderInfo", {
id: folder.id,
name: folder.name,
count: folder.total,
})}
</div>
</div>
{/* 右箭头 */}
<div className="text-gray-400">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>
))}
</div>
</>
)}
</div> </div>
</div> ) : (
</div> <>
{/* 页面标题 */}
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-6">
{t("selectFolder")}
</h1>
{/* 文件夹列表 */}
<div className="border border-gray-200 rounded-lg max-h-96 overflow-y-auto">
{folders
.toSorted((a, b) => a.id - b.id)
.map((folder) => (
<div
key={folder.id}
onClick={() =>
router.push(`/memorize?folder_id=${folder.id}`)
}
className="flex flex-row items-center p-4 gap-3 hover:cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0"
>
{/* 文件夹图标 */}
<div className="shrink-0">
<Fd className="text-gray-600" size="md" />
</div>
{/* 文件夹信息 */}
<div className="flex-1">
<div className="font-medium text-gray-900">
{folder.name}
</div>
<div className="text-sm text-gray-500">
{t("folderInfo", {
id: folder.id,
name: folder.name,
count: folder.total,
})}
</div>
</div>
{/* 右箭头 */}
<div className="text-gray-400">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>
))}
</div>
</>
)}
</PageLayout>
); );
}; };
export default FolderSelector; export { FolderSelector };

View File

@@ -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,103 +112,84 @@ 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">
{/* 进度指示器 */} <LinkButton onClick={handleIndexClick} className="text-sm">
<div className="flex justify-center mb-4"> {index + 1} / {getTextPairs().length}
<button </LinkButton>
onClick={handleIndexClick}
className="text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
{index + 1} / {getTextPairs().length}
</button>
</div>
{/* 文本显示区域 */}
<div className={`h-[40dvh] ${myFont.className} mb-4`}>
{(() => {
if (dictation) {
if (show === "question") {
return (
<div className="h-full flex items-center justify-center">
<div className="text-gray-400 text-4xl">?</div>
</div>
);
} else {
return (
<div className="space-y-2">
{createText(text1)}
<div className="border-t border-gray-200"></div>
{createText(text2)}
</div>
);
}
} else {
if (show === "question") {
return createText(text1);
} else {
return (
<div className="space-y-2">
{createText(text1)}
<div className="border-t border-gray-200"></div>
{createText(text2)}
</div>
);
}
}
})()}
</div>
{/* 底部按钮 */}
<div className="flex flex-row gap-2 items-center justify-center flex-wrap">
<button
onClick={handleNext}
className="px-4 py-2 rounded-full bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors text-sm"
>
{show === "question" ? t("answer") : t("next")}
</button>
<button
onClick={handlePrevious}
className="px-4 py-2 rounded-full bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors text-sm"
>
{t("previous")}
</button>
<button
onClick={toggleReverse}
className={`px-4 py-2 rounded-full transition-colors text-sm ${
reverse
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
{t("reverse")}
</button>
<button
onClick={toggleDictation}
className={`px-4 py-2 rounded-full transition-colors text-sm ${
dictation
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
{t("dictation")}
</button>
<button
onClick={toggleDisorder}
className={`px-4 py-2 rounded-full transition-colors text-sm ${
disorder
? "bg-[#35786f] text-white hover:bg-[#2d5f58]"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
}`}
>
{t("disorder")}
</button>
</div>
</div>
</div> </div>
</div>
{/* 文本显示区域 */}
<div className={`h-[40dvh] ${myFont.className} mb-4`}>
{(() => {
if (dictation) {
if (show === "question") {
return (
<div className="h-full flex items-center justify-center">
<div className="text-gray-400 text-4xl">?</div>
</div>
);
} else {
return (
<div className="space-y-2">
{createText(text1)}
<div className="border-t border-gray-200"></div>
{createText(text2)}
</div>
);
}
} else {
if (show === "question") {
return createText(text1);
} else {
return (
<div className="space-y-2">
{createText(text1)}
<div className="border-t border-gray-200"></div>
{createText(text2)}
</div>
);
}
}
})()}
</div>
{/* 底部按钮 */}
<div className="flex flex-row gap-2 items-center justify-center flex-wrap">
<LightButton
onClick={handleNext}
className="px-4 py-2 rounded-full text-sm"
>
{show === "question" ? t("answer") : t("next")}
</LightButton>
<LightButton
onClick={handlePrevious}
className="px-4 py-2 rounded-full text-sm"
>
{t("previous")}
</LightButton>
<CircleToggleButton
selected={reverse}
onClick={toggleReverse}
>
{t("reverse")}
</CircleToggleButton>
<CircleToggleButton
selected={dictation}
onClick={toggleDictation}
>
{t("dictation")}
</CircleToggleButton>
<CircleToggleButton
selected={disorder}
onClick={toggleDisorder}
>
{t("disorder")}
</CircleToggleButton>
</div>
</PageLayout>
); );
}; };
export default Memorize; export { Memorize };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(() => {

View File

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

View File

@@ -46,4 +46,4 @@ const VideoElement = forwardRef<HTMLVideoElement, VideoElementProps>(
VideoElement.displayName = "VideoElement"; VideoElement.displayName = "VideoElement";
export default VideoElement; export { VideoElement };

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,23 +107,19 @@ 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">
{/* 标题区域 */} <h1 className="text-4xl font-bold text-gray-800 mb-2">
<div className="text-center mb-8"> {t("srtPlayer.name")}
<h1 className="text-4xl font-bold text-gray-800 mb-2"> </h1>
{t("srtPlayer.name")} <p className="text-lg text-gray-600">
</h1> {t("srtPlayer.description")}
<p className="text-lg text-gray-600"> </p>
{t("srtPlayer.description")} </div>
</p>
</div>
{/* 主要内容区域 */} {/* 视频播放器区域 */}
<div className="bg-white rounded-xl shadow-lg overflow-hidden"> <div className="aspect-video bg-black relative rounded-md overflow-hidden">
{/* 视频播放器区域 */}
<div className="aspect-video bg-black relative">
{(!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">
@@ -163,10 +160,10 @@ 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">
<div className={`flex-1 p-2 rounded-lg border-2 transition-all ${state.video.url <div className={`flex-1 p-2 rounded-lg border-2 transition-all ${state.video.url
? 'border-gray-800 bg-gray-100' ? 'border-gray-800 bg-gray-100'
@@ -269,12 +266,9 @@ export default function SrtPlayerPage() {
</span> </span>
</div> </div>
</div> </div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </PageLayout>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,32 +1,21 @@
"use client"; "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 { translateText } from "@/lib/server/bigmodel/translatorActions";
import type { TranslateTextOutput } from "@/lib/server/services/types";
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 [targetLanguage, setTargetLanguage] = useState<string>("Chinese"); const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
const [translationResult, setTranslationResult] = useState<TranslateTextOutput | null>(null); const [translationResult, setTranslationResult] = useState<TSharedTranslationResult | null>(null);
const [needIpa, setNeedIpa] = useState(true); const [needIpa, setNeedIpa] = useState(true);
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const [lastTranslation, setLastTranslation] = useState<{ const [lastTranslation, setLastTranslation] = useState<{
@@ -34,18 +23,10 @@ export default function TranslatorPage() {
targetLanguage: string; targetLanguage: string;
} | null>(null); } | 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,14 +46,13 @@ 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 () => {
@@ -89,46 +69,21 @@ export default function TranslatorPage() {
lastTranslation?.targetLanguage === targetLanguage; lastTranslation?.targetLanguage === targetLanguage;
try { try {
const result = await translateText({ const result = await actionTranslateText({
sourceText, sourceText,
targetLanguage, targetLanguage,
forceRetranslate, forceRetranslate,
needIpa, needIpa,
userId: session?.user?.id,
}); });
setTranslationResult(result); if (result.success && result.data) {
setLastTranslation({ setTranslationResult(result.data);
sourceText, setLastTranslation({
targetLanguage, sourceText,
}); targetLanguage,
});
// 更新本地历史记录 } else {
const historyItem = { toast.error(result.message || "翻译失败,请重试");
text1: result.sourceText,
text2: result.translatedText,
language1: result.sourceLanguage,
language2: result.targetLanguage,
};
setHistory(tlsoPush(historyItem));
// 自动保存到文件夹
if (autoSave && autoSaveFolderId) {
createPair({
text1: result.sourceText,
text2: result.translatedText,
language1: result.sourceLanguage,
language2: result.targetLanguage,
ipa1: result.sourceIpa || undefined,
ipa2: result.targetIpa || undefined,
folderId: autoSaveFolderId,
})
.then(() => {
toast.success(`${sourceText} 保存到文件夹 ${autoSaveFolderId} 成功`);
})
.catch((error) => {
toast.error(`保存失败: ${error.message}`);
});
} }
} catch (error) { } catch (error) {
toast.error("翻译失败,请重试"); toast.error("翻译失败,请重试");
@@ -139,13 +94,13 @@ export default function TranslatorPage() {
}; };
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}
@@ -191,7 +146,7 @@ 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">{translationResult?.translatedText || ""}</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">
{translationResult?.targetIpa || ""} {translationResult?.targetIpa || ""}
@@ -254,91 +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> </div>
</div>
{/* AutoSave Component */}
<div className="w-screen flex justify-center items-center">
<label className="flex items-center">
<input
type="checkbox"
checked={autoSave}
onChange={(e) => {
const checked = e.target.checked;
if (checked === true && !session) {
toast.warning("Please login to enable auto-save");
return;
}
if (checked === false) setAutoSaveFolderId(null);
setAutoSave(checked);
}}
className="mr-2"
/>
{t("autoSave")}
{autoSaveFolderId ? ` (${autoSaveFolderId})` : ""}
</label>
</div>
{history.length > 0 && (
<div className="m-6 flex flex-col items-center">
<h1 className="text-2xl font-light">{t("history")}</h1>
<div className="border border-gray-200 rounded-2xl m-4">
{history.toReversed().map((item, index) => (
<div
key={index}
className="border-b border-gray-200 p-2 group hover:bg-gray-50 flex gap-2 flex-row justify-between items-start"
>
<div className="flex-1 flex flex-col">
<p className="text-sm font-light">{item.text1}</p>
<p className="text-sm font-light">{item.text2}</p>
</div>
<div className="flex gap-2">
<button
onClick={() => {
if (!session?.user) {
toast.info("请先登录后再保存到文件夹");
return;
}
setShowAddToFolder(true);
setAddToFolderItem(item);
}}
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-8 h-8 flex justify-center items-center"
>
<Plus />
</button>
<button
onClick={() => {
setHistory(
tlso.set(
tlso.get().filter((v) => !shallowEqual(v, item)),
) || [],
);
}}
className="hover:bg-gray-200 hover:cursor-pointer rounded-4xl border border-gray-200 w-8 h-8 flex justify-center items-center"
>
<Trash />
</button>
</div>
</div>
))}
</div>
{showAddToFolder && (
<AddToFolder setShow={setShowAddToFolder} item={addToFolderItem!} />
)}
{autoSave && !autoSaveFolderId && (
<FolderSelector
userId={session!.user.id as string}
cancel={() => setAutoSave(false)}
setSelectedFolderId={(id) => setAutoSaveFolderId(id)}
/>
)}
</div>
)}
</>
); );
} }

View File

@@ -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,15 +44,32 @@ 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 (!email) { // 登录模式验证
newErrors.email = t("emailRequired"); if (mode === 'signin') {
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { if (!identifier) {
newErrors.email = t("invalidEmail"); newErrors.identifier = t("identifierRequired");
}
} else {
// 注册模式验证
if (!email) {
newErrors.email = t("emailRequired");
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
newErrors.email = t("invalidEmail");
}
if (!username) {
newErrors.username = t("usernameRequired");
} else if (username.length < 3) {
newErrors.username = t("usernameTooShort");
} else if (!/^[a-zA-Z0-9_]+$/.test(username)) {
newErrors.username = t("usernameInvalid");
}
} }
if (!password) { if (!password) {
@@ -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,41 +140,57 @@ 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 && (
{currentError?.errors?.username && ( <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.username[0]}</p>
)} )}
</div> </div>
)} ) : (
<>
{/* 用户名输入(仅注册模式) */}
<div>
<Input
type="text"
name="username"
placeholder={t("username")}
className="w-full px-3 py-2"
/>
{errors.username && (
<p className="text-red-500 text-sm mt-1">{errors.username}</p>
)}
{currentError?.errors?.username && (
<p className="text-red-500 text-sm mt-1">{currentError.errors.username[0]}</p>
)}
</div>
{/* 邮箱输入 */} {/* 邮箱输入(仅注册模式) */}
<div> <div>
<Input <Input
type="email" type="email"
name="email" name="email"
placeholder={t("email")} placeholder={t("email")}
className="w-full px-3 py-2" className="w-full px-3 py-2"
/> />
{errors.email && ( {errors.email && (
<p className="text-red-500 text-sm mt-1">{errors.email}</p> <p className="text-red-500 text-sm mt-1">{errors.email}</p>
)} )}
{currentError?.errors?.email && ( {currentError?.errors?.email && (
<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>
@@ -225,7 +253,7 @@ export default function AuthForm({ redirectTo }: AuthFormProps) {
className="w-full mt-4 py-2 flex items-center justify-center gap-2" className="w-full mt-4 py-2 flex items-center justify-center gap-2"
> >
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor"> <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/> <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg> </svg>
{t(mode === 'signin' ? 'signInWithGitHub' : 'signUpWithGitHub')} {t(mode === 'signin' ? 'signInWithGitHub' : 'signUpWithGitHub')}
</LightButton> </LightButton>
@@ -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> </div>
</Container> </PageLayout>
</div>
); );
} }

View File

@@ -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: {

View File

@@ -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) {
setLoading(false); setFolders(folders.data);
}) 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 => {
userId: userId, if (result.success) {
}); updateFolders();
await updateFolders(); } else {
toast.error(result.message);
}
});
} 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) => (

View File

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

View File

@@ -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)
setLoading(false); .finally(() => {
} 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>
<IconButton {!isReadOnly && (
onClick={() => { <IconButton
setAddModal(true); onClick={() => {
}} 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,7 +139,7 @@ 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,
@@ -155,4 +151,4 @@ export default function InFolder({ folderId }: { folderId: number }) {
/> />
</PageLayout> </PageLayout>
); );
} };

View File

@@ -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 { UpdatePairInput } from "@/lib/server/services/types"; 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" <>
onClick={() => setOpenUpdateModal(true)} <CircleButton
title={t("edit")} onClick={() => setOpenUpdateModal(true)}
> title={t("edit")}
<Edit size={14} /> className="text-gray-400 hover:text-gray-600"
</button> >
<button <Edit size={14} />
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-colors" </CircleButton>
onClick={onDel} <CircleButton
title={t("delete")} onClick={onDel}
> title={t("delete")}
<Trash2 size={14} /> className="text-gray-400 hover:text-red-500 hover:bg-red-50"
</button> >
<Trash2 size={14} />
</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: UpdatePairInput) => { 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();
}} }}

View File

@@ -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 { UpdatePairInput } from "@/lib/server/services/types";
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: UpdatePairInput) => 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")}

View File

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

View File

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

View File

@@ -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; /* 基础颜色 */
--foreground: #171717; --background: #ffffff;
--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 {
background: var(--background); height: 100%;
color: var(--foreground); margin: 0;
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif; padding: 0;
background: var(--background);
color: var(--foreground);
font-family: var(--font-geist-sans), -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
font-size: 1rem;
line-height: 1.5;
text-rendering: optimizeLegibility;
} }
.code-block { /**
font-family: var(--font-geist-mono), monospace; * 代码块字体
*/
.code-block,
code,
kbd,
pre,
samp {
font-family: var(--font-geist-mono), ui-monospace, SFMono-Regular, Monaco, Consolas, monospace;
}
/**
* 导航栏按钮样式
*/
.navbar-btn {
@apply border-0 bg-transparent hover:bg-black/30 shadow-none;
transition: background-color var(--transition-fast);
}
/**
* 焦点可见性优化
*/
:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
/**
* 选择文本样式
*/
::selection {
background-color: var(--color-primary-200);
color: var(--color-primary-900);
}
/**
* 滚动条样式
*/
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--background-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--foreground-tertiary);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: var(--foreground-secondary);
} }

View File

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

View File

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

View File

@@ -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 () => {

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,30 +1,21 @@
/** /**
* CardList - 可滚动的卡片列表容器 * 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}`}>
{children} <VStack gap={0}>
{children}
</VStack>
</div> </div>
); );
} }

View File

@@ -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} </Card>
</div> </DSContainer>
); );
} }

View File

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

View File

@@ -1,5 +1,13 @@
/**
* LocaleSelector - 语言选择器组件
*
* 使用 Design System 重写的语言选择器组件
*/
import { useTranslations } from "next-intl"; 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: "chinese", value: "chinese" }, { label: "chinese", value: "chinese" },
@@ -36,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");
@@ -46,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}>
{t(`translator.${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={t("folder_id.enterLanguageName")} 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>
); );
} }

View File

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

View File

@@ -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 = {
return ( center: "items-center",
<div className={`min-h-[calc(100vh-64px)] bg-[#35786f] flex items-center justify-center px-4 py-8 ${className}`}> start: "items-start",
<div className="w-full max-w-2xl"> end: "items-end",
<div className="bg-white rounded-2xl shadow-xl p-6 md:p-8"> };
{children}
export function PageLayout({
children,
className = "",
variant = "centered-card",
align = "center",
}: PageLayoutProps) {
// 居中卡片布局
if (variant === "centered-card") {
return (
<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">
<Card padding="lg" className="p-6 md:p-8">
{children}
</Card>
</div> </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;
} }

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

View File

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

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

View File

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

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

View File

@@ -0,0 +1 @@
export * from './button';

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

View File

@@ -0,0 +1 @@
export * from './card';

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

View File

@@ -0,0 +1 @@
export * from './checkbox';

View File

@@ -0,0 +1 @@
export * from './input';

View 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";

View File

@@ -0,0 +1 @@
export * from './radio';

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

View File

@@ -0,0 +1 @@
export * from './select';

View 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";

View File

@@ -0,0 +1 @@
export * from './switch';

View 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";

View File

@@ -0,0 +1 @@
export * from './textarea';

View 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";

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

View File

@@ -0,0 +1 @@
export * from './badge';

View File

@@ -0,0 +1,102 @@
"use client";
import React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/design-system/lib/utils";
/**
* Divider 分隔线组件
*
* Design System 中的分隔线组件,用于分隔内容区域。
*
* @example
* ```tsx
* // 水平分隔线
* <Divider />
*
* // 带文字的分隔线
* <Divider>或者</Divider>
*
* // 垂直分隔线
* <Divider orientation="vertical" />
*
* // 不同样式
* <Divider variant="dashed" />
* <Divider variant="dotted" />
* ```
*/
/**
* Divider 变体样式
*/
const dividerVariants = cva(
// 基础样式
"border-gray-300",
{
variants: {
variant: {
solid: "border-solid",
dashed: "border-dashed",
dotted: "border-dotted",
},
orientation: {
horizontal: "w-full border-t",
vertical: "h-full border-l",
},
},
defaultVariants: {
variant: "solid",
orientation: "horizontal",
},
}
);
export type DividerVariant = VariantProps<typeof dividerVariants>["variant"];
export type DividerOrientation = VariantProps<typeof dividerVariants>["orientation"];
export interface DividerProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof dividerVariants> {
// 子元素(用于带文字的分隔线)
children?: React.ReactNode;
// 文字位置(仅水平分隔线有效)
labelPosition?: "center" | "left" | "right";
}
/**
* Divider 分隔线组件
*/
export function Divider({
variant = "solid",
orientation = "horizontal",
labelPosition = "center",
children,
className,
...props
}: DividerProps) {
// 带文字的水平分隔线
if (children && orientation === "horizontal") {
const labelAlignment = {
left: "justify-start",
center: "justify-center",
right: "justify-end",
}[labelPosition];
return (
<div className={cn("flex items-center gap-4 w-full", className)} {...props}>
<div className={cn("flex-1 border-t", `border-${variant}`)} />
<span className="text-sm text-gray-500 whitespace-nowrap">{children}</span>
<div className={cn("flex-1 border-t", `border-${variant}`)} />
</div>
);
}
return (
<div
className={cn(dividerVariants({ variant, orientation }), className)}
role="separator"
aria-orientation={orientation as "horizontal" | "vertical"}
{...props}
/>
);
}

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