Compare commits

..

54 Commits

Author SHA1 Message Date
dda7d64dee fix: add scrollable container to Memorize card to prevent button jumping 2026-03-18 14:22:00 +08:00
911343ce0d fix: add missing noIpa translation 2026-03-18 10:19:36 +08:00
130ab226ff fix: JSON syntax errors and add missing translations
- Fix missing comma in en-US.json
- Add noIpa translation to all locale files
2026-03-18 10:16:46 +08:00
59d22ccf4c fix: add missing comma in en-US.json 2026-03-18 09:48:45 +08:00
06012c43f2 feat: add study modes to Memorize page
- Add 4 study modes: order-limited, order-infinite, random-limited, random-infinite
- Add mode selector buttons with icons
- Update progress display for infinite modes
- Add translations for all 8 locales
2026-03-18 08:52:45 +08:00
c54376cbe6 feat: display card meanings as table in Memorize
- Change card back display from joined string to structured table
- Each meaning shows part of speech and definition separately
- Improved readability for multiple meanings
2026-03-18 08:42:22 +08:00
3ed3478c66 fix: change default theme color to mist in CSS
Prevent FOUC (Flash of Unstyled Content) on page refresh by aligning
CSS default colors with the DEFAULT_THEME setting in theme-presets.ts
2026-03-18 08:36:22 +08:00
bc7608e049 fix: add missing translations and fix namespace usage
- Fix srt-player to use srtT namespace for error messages
- Add deck_id.enterLanguageName and language labels (english, chinese, japanese, korean)
- Add memorize.review.nextCard translation
- Update all 8 locales with consistent translations
2026-03-18 08:34:04 +08:00
1ef337801d refactor: unify i18n function calls and simplify scripts
- Replace dynamic t(lang.labelKey) with static t(lang.label) using helper functions
- Add getLanguageLabel/getLangLabel/getLocaleLabel helper functions for switch-based label lookup
- Simplify translation check scripts to only detect literal string calls
- Fix namespace lookup for dotted namespaces like 'memorize.review'
2026-03-18 08:13:58 +08:00
286add7fff fix: rewrite translation check scripts with proper regex
- Fix regex to handle 'await getTranslations' pattern
- Add word boundary to prevent false matches like 'get("q")'
- Improve namespace detection for dotted namespaces
- Reduce false positives in both scripts
2026-03-18 07:59:21 +08:00
de7c1321c2 refactor: remove Anki import/export and simplify card system
- Remove Anki apkg import/export functionality
- Remove OCR feature module
- Remove note and note-type modules
- Simplify card/deck modules (remove spaced repetition complexity)
- Update translator and dictionary features
- Clean up unused translations and update i18n files
- Simplify prisma schema
2026-03-17 20:24:42 +08:00
95ce49378b feat: add translation check scripts
- find-missing-translations.ts: detect translation keys used in code but missing in message files
- find-unused-translations.ts: detect translation keys in message files but not used in code
2026-03-17 20:24:06 +08:00
2f5ec1c0f0 feat(translator): add custom target language input
- Replace Select with Input for custom language entry
- Users can now type any target language they want
- Add i18n translations for all 8 languages
2026-03-16 12:07:46 +08:00
f53fa5e2a1 refactor: unify design-system components across pages
- Replace native textarea with Textarea in translator and text-speaker pages
- Replace custom loading spinners with Skeleton in InDeck and FavoritesClient pages
- Add shared constants DEFAULT_NEW_PER_DAY, DEFAULT_REV_PER_DAY
2026-03-16 09:44:51 +08:00
1d5732abc8 refactor: optimize repoGetTodayStudyStats with SQL aggregation and use shared constants
- Replace JS counting with Prisma groupBy for better performance
- Add DEFAULT_NEW_PER_DAY and DEFAULT_REV_PER_DAY constants
- Use constants in InDeck.tsx
2026-03-16 09:31:21 +08:00
ada2f249ee refactor: add shared utilities and replace console.log with logger
- Add shared action-utils.ts with getCurrentUserId and requireAuth helpers
- Add shared constants for anki defaults (FIELD_SEPARATOR, DEFAULT_NEW_PER_DAY, DEFAULT_REV_PER_DAY)
- Add shared time constants (SECONDS_PER_DAY, MS_PER_DAY, etc.)
- Replace console.error with logger in auth.ts
2026-03-16 09:24:57 +08:00
bc0b392875 feat(deck): add daily learning limits and today's study stats
- Add newPerDay and revPerDay fields to Deck model (Anki-style)
- Add settings modal to configure daily limits per deck
- Display today's studied counts (new/review/learning) on deck page
- Add i18n translations for all 8 languages
- Fix JSON syntax errors in fr-FR.json and it-IT.json
- Fix double counting bug in repoGetTodayStudyStats
2026-03-16 09:01:55 +08:00
a68951f1d3 refactor(ui): use design-system components across pages
- Replace custom spinners with Skeleton
- Replace native inputs/select with design-system components
- Simplify dictation mode (user self-judges instead of input)
- Set body background to primary-50
- Clean up answer button shortcuts
2026-03-16 07:58:43 +08:00
c525bd4591 feat(learn): add reverse and dictation modes for card review
- Add reverse mode to swap card front/back
- Add dictation mode with TTS audio playback and answer verification
- Add i18n translations for new features in all 8 languages
- Integrate useAudioPlayer hook for TTS playback
2026-03-14 11:52:56 +08:00
6213dd2338 refactor: move memorize feature to /decks/[deck_id]/learn route
- Delete (features)/memorize directory
- Create /decks/[deck_id]/learn with Memorize component and page
- Update InDeck.tsx to navigate to new learn route
- Fix homepage memorize link to point to /decks
2026-03-14 11:34:46 +08:00
af684a15ce feat: add reset deck progress feature for deck detail page 2026-03-13 22:02:55 +08:00
279eee2953 i18n: fix navbar 'folders' to 'decks' and add follow section 2026-03-13 19:30:44 +08:00
168f0c161e i18n: add follow section to all languages and fix duplicate decks 2026-03-13 19:05:38 +08:00
7c71ffcf31 fix(card): use RELEARNING_STEPS for relearning card interval preview 2026-03-13 15:44:06 +08:00
4243cdc68b fix(card): improve SM-2 algorithm An An SM-2 algorithm for LEARNING/RElearning cards now uses correct steps ( RELEARNING_STEPS for relearning cards)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 15:36:29 +08:00
cbb9326f84 refactor(anki): improve APKG import/export reliability
- Use crypto.getRandomValues for GUID generation
- Use SHA1 checksum for consistent hashing
- Add proper deep cloning for note type fields/templates
- Improve unique ID generation with timestamp XOR random
- Add file size validation for APKG uploads

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 15:10:34 +08:00
49ad953add fix(card): improve SM-2 algorithm compatibility with Anki
- Fix scheduleNewCard ease===3 (Good) to use steps[1] or graduate
- Fix scheduleLearningCard ease===2 (Hard) to repeat current step
- Ensure graduating cards get DEFAULT_FACTOR
- Fix interval calculations for learning card graduation

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 15:09:54 +08:00
f1eafa8015 i18n: add card type labels for memorize feature
Add translations for cardTypeNew, cardTypeLearning, cardTypeReview,
cardTypeRelearning in all 8 supported languages (en-US, zh-CN, ja-JP,
ko-KR, de-DE, fr-FR, it-IT, ug-CN).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 15:08:02 +08:00
12e502313b feat(memorize): enhance review UI with dynamic intervals and keyboard shortcuts
- Add keyboard shortcuts: Space/Enter to show answer, 1-4 for responses
- Display dynamic preview intervals on answer buttons (1m, 6m, 4d, etc.)
- Add card type indicator (New/Learning/Review/Relearning) with color badges
- Highlight Good button as recommended option with ring and icon
- Show keyboard hint on Show Answer button

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 15:07:49 +08:00
13e8f51ada feat(memorize): add interval preview calculation utility
- Add calculatePreviewIntervals for Again/Hard/Good/Easy buttons
- Support NEW, LEARNING, RELEARNING, and REVIEW card types
- Use SM2_CONFIG constants for accurate interval calculation
- All intervals returned in minutes for consistent formatting

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 15:07:32 +08:00
7ba31a37bd feat: 添加 Anki APKG 导入/导出功能
- 添加 APKG 解析器 (src/lib/anki/apkg-parser.ts)
- 添加 APKG 导出器 (src/lib/anki/apkg-exporter.ts)
- 添加导入/导出 Server Actions
- 添加导入/导出 UI 组件
- 集成到牌组页面
- 添加 i18n 翻译

同时修复断链:
- /folders → /decks (Navbar, signup, profile)
2026-03-11 10:37:23 +08:00
4d4062985d fix: 修复 React Compiler 严格模式下的 lint 错误
- Memorize: 将 loadCards 内联到 useEffect 中避免变量提升问题
- DecksClient: 修复 effect 中异步加载,创建 deck 后使用 actionGetDeckById
- LanguageSettings: 使用 effect 设置 cookie 避免 render 期间修改
- theme-provider: 修复 hydration 逻辑避免 render 期间访问 ref
2026-03-11 09:51:25 +08:00
804c28ada9 refactor: 修复 modules 三层架构违规
- auth: actionDeleteAccount 改用 service+repo,forgot-password 完整三层实现
- card: serviceCheckCardOwnership 替代直接调用 repository
- deck: 移除 service 层的 use server 指令
- dictionary: 数据转换逻辑从 repository 移到 service
- ocr: 认证移到 action 层,跨模块调用改用 service
- translator: genIPA/genLanguage 改用 service 层
2026-03-11 09:40:53 +08:00
e68e24a9fb style: 降低注销按钮的视觉显著性
- 改用 ghost 样式替代 error 样式
- 使用小尺寸
2026-03-10 20:21:38 +08:00
8099320e00 feat: 添加注销账号功能
- 在个人资料页面添加注销账号按钮
- 需要输入用户名确认才能删除
- 删除所有用户数据:牌组、卡片、笔记、关注等
- 添加 8 种语言翻译
2026-03-10 19:54:19 +08:00
db0b0ff348 fix: 强制 username 登录也需要邮箱验证
- 添加 sendOnSignIn: true 配置
- 在 hook 中拦截 /sign-in/username 请求
- 检查用户邮箱是否已验证,未验证返回 403
2026-03-10 19:41:30 +08:00
6f4b123a84 fix: 添加邮箱验证重发功能
- 登录时检测 403 错误(邮箱未验证)
- 显示重发验证邮件按钮
- 修复邮件发送失败时静默忽略的问题
- 添加 8 种语言的验证相关翻译
2026-03-10 19:38:54 +08:00
57ad1b8699 refactor: 完全重构为 Anki 兼容数据结构
- 用 Deck 替换 Folder
- 用 Note + Card 替换 Pair (双向复习)
- 添加 NoteType (卡片模板)
- 添加 Revlog (复习历史)
- 实现 SM-2 间隔重复算法
- 更新所有前端页面
- 添加数据库迁移
2026-03-10 19:20:46 +08:00
9b78fd5215 feat: 添加 OCR 词汇提取功能
新增 OCR 页面,用户可上传教材词汇表截图,使用 GLM-4.6V 视觉模型
提取单词-释义对并保存到指定文件夹。

- AI 管道: src/lib/bigmodel/ocr/ (orchestrator, types)
- 后端模块: src/modules/ocr/ (action-service-repository 架构)
- 前端页面: src/app/(features)/ocr/ (拖拽上传、folder 选择)
- i18n: 8 种语言翻译支持
2026-03-10 15:21:45 +08:00
683a4104ec feat: 添加用户关注功能
- 新增 Follow 表和 User.bio 字段 (Prisma schema)
- 创建 follow 模块 (action-service-repository)
- 新增 FollowButton/FollowStats/UserList 组件
- 用户页面显示 bio、粉丝/关注数、关注按钮
- 新增 /users/[username]/followers 和 following 页面
- 添加 en-US/zh-CN i18n 翻译

⚠️ 需要运行: prisma migrate dev --name add_follow_and_bio
2026-03-10 14:58:43 +08:00
abcae1b8d1 feat: 添加移动端下拉菜单和主题色设置
- 新增 MobileMenu 组件,小屏幕使用汉堡菜单替代多个按钮
- 重构 LanguageSettings 为统一下拉框样式
- 新增设置页面,支持主题色切换
- 翻译页添加源语言选择器
- 更新 8 种语言的 i18n 翻译
2026-03-10 13:44:52 +08:00
6b9fba254d refactor: 使用 openai SDK 替换 fetch 调用 LLM
All checks were successful
continuous-integration/drone/push Build is passing
- 安装 openai 包
- 重命名 zhipu.ts -> llm.ts
- 使用 OpenAI SDK 替代原生 fetch 实现
- 更新所有导入路径
2026-03-10 11:58:27 +08:00
0cb240791b feat(auth): 强制要求用户名,- 添加 hooks 验证注册时 username 必填
All checks were successful
continuous-integration/drone/push Build is passing
- 修改数据库 schema: username 设为 NOT NULL
- 重置并重新初始化本地和生产数据库
- 更新 .env.example 添加 Resend SMTP 配置说明
2026-03-10 09:45:55 +08:00
d9fd09c13d feat(auth): 强制要求 username 并- 添加 hooks 验证 username 必填
- 修改 schema: username 改为 NOT NULL
- 重置本地和生产数据库
2026-03-10 09:45:15 +08:00
5406543cbe feat(auth): 添加忘记密码功能
- 添加忘记密码页面,支持通过邮箱重置密码
- 添加重置密码页面
- 登录页面添加忘记密码链接
- 添加邮件发送功能
- 完善所有8种语言的翻译 (en-US, zh-CN, ja-JP, ko-KR, de-DE, fr-FR, it-IT, ug-CN)
2026-03-09 20:45:18 +08:00
d2a3d32376 chore: 添加 CI 并发控制,更新 Node.js 版本要求 2026-03-09 20:00:01 +08:00
436d58be52 fix(auth): 修复登录注册失败无错误提示的问题
All checks were successful
continuous-integration/drone/push Build is passing
better-auth 客户端不抛出异常,而是返回 { data, error } 对象
修改错误处理逻辑检查 error 对象而非 try-catch
2026-03-09 19:52:41 +08:00
11a265d52e i18n: 完整翻译所有语言文件
- de-DE: 德语完整翻译
- fr-FR: 法语完整翻译
- it-IT: 意大利语完整翻译
- ja-JP: 日语完整翻译
- ko-KR: 韩语完整翻译
- ug-CN: 维吾尔语完整翻译

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

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

Medium 级别:
- InFolder.tsx: 修复 throw result.message 为 throw new Error()
- localStorageOperators.ts: 返回类型改为 T | null,添加 schema 验证
- SaveList.tsx: 处理 data 可能为 null 的情况
2026-03-09 19:11:49 +08:00
020744b353 fix(i18n): 补充页面缺失的中英文翻译并修复登录重定向循环
- 补充 login/signup/dictionary/srt-player/alphabet 页面的翻译
- 修复登录页面邮箱登录时 password 参数错误
- 修复登录/注册页面的无限重定向循环问题
- 调整登录/注册卡片宽度为 w-96
2026-03-09 18:41:41 +08:00
719aef5a7f fix(dictionary): 修复语义映射和错误日志
- 修复语义映射:强制将输入转换为查询语言的对应词
- 移除拼写自动纠正,避免错误纠正(如 franch→franchise)
- 修复 winston 日志 Error 对象序列化问题
2026-03-09 18:14:14 +08:00
6c811a77db perf(dictionary): 优化 AI 编排性能,4 次 LLM 调用减少到 2 次
- 合并 Stage 1+2+3 为单次 preprocessInput 调用
- 精简 Stage 4 词条生成 prompt
- 删除旧的 stage 文件
- 预期性能提升 60%+ (33s → ~8-13s)
2026-03-09 18:04:12 +08:00
3652e350e6 fix(dictionary): 修复 AI 编排系统的错误处理和超时控制
- 修复 orchestrator 中 throw 字符串的问题,改为 throw LookUpError
- 为 zhipu.ts 添加 30 秒超时控制,防止 LLM 调用卡死
- stage1 添加 isEmpty 和 isNaturalLanguage 字段验证
- stage2 改为降级处理而非直接失败,提升用户体验
- types.ts 添加 canMap 字段
- AGENTS.md 添加禁止擅自运行 pnpm dev 的说明
2026-03-09 17:19:12 +08:00
154 changed files with 11610 additions and 4938 deletions

View File

@@ -2,6 +2,8 @@
kind: pipeline
type: docker
name: learn-languages
concurrency:
limit: 1
platform:
os: linux

View File

@@ -13,3 +13,11 @@ DATABASE_URL=
// DashScore
DASHSCORE_API_KEY=
// SMTP Email - Resend (https://resend.com)
SMTP_HOST=smtp.resend.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=resend
SMTP_PASS=re_your_resend_api_key
SMTP_FROM=onboarding@resend.dev

View File

@@ -1,7 +1,7 @@
# LEARN-LANGUAGES 知识库
**生成时间:** 2026-03-08
**提交:** 91c59c3
**提交:** 6ba5ae9
**分支:** dev
## 概述
@@ -104,6 +104,60 @@ log.info("Fetched folders", { count: folders.length });
log.error("Failed to fetch folders", { error });
```
### i18n 翻译检查
**注意:翻译缺失不会被 build 检测出来。**
**系统性检查翻译缺失的方法(改进版):**
#### 步骤 1: 使用 AST-grep 搜索所有翻译模式
```bash
# 搜索所有 useTranslations 和 getTranslations 声明
ast-grep --pattern 'useTranslations($ARG)' --lang tsx --paths src/
# 搜索所有带插值的 t() 调用
ast-grep --pattern 't($ARG, $OPTS)' --lang tsx --paths src/
# 搜索所有简单 t() 调用
ast-grep --pattern 't($ARG)' --lang tsx --paths src/
```
**AST-grep 能捕获 31 种不同的翻译键模式, 而 grep 只能捕获 1 种模式。**
#### 步骤 2: 按文件提取所有翻译键
逐个 `.tsx` 文件检查使用的翻译键:
1. 找到该文件使用的 namespace`useTranslations("namespace")``getTranslations("namespace")`
2. 提取该文件中所有 `t("...")` 调用
3. 注意动态键模式:
- 模板字面量: `t(\`prefix.${variable}\`)`
- 条件键: `t(condition ? "a" : "b")`
- 变量键: `t(variable)`
4. 对比 `messages/en-US.json`,找出缺失的键
5. 先补全 `en-US.json`(作为基准语言)
6. 再根据 `en-US.json` 补全其他 7 种语言
#### 步骤 3: 验证 JSON 文件结构
**注意JSON 语法错误会导致 build 失败,常见错误:**
- 重复的键(同一对象中出现两次相同的键名)
- 缺少逗号或多余的逗号
- 缺少闭合括号 `}`
```bash
# 验证 JSON 格式
node -e "console.log(JSON.parse(require('fs').readFileSync('messages/en-US.json', 'utf8')))"
```
#### 步骤 4: 对比验证
```bash
# 列出代码中使用的所有 namespace
ast-grep --pattern 'useTranslations($ARG)' --lang tsx --paths src/ | grep -o 'useTranslations\|getTranslations' | sort | uniq
# 对比 messages/en-US.json 中的 namespace 列表
node -e "console.log(Object.keys(JSON.parse(require('fs').readFileSync('messages/en-US.json', 'utf8'))).join('\n'))"
```
## 反模式 (本项目)
-`index.ts` barrel exports
@@ -112,6 +166,7 @@ log.error("Failed to fetch folders", { error });
- ❌ Server Component 可行时用 Client Component
- ❌ npm 或 yarn (使用 pnpm)
- ❌ 生产代码中使用 `console.log` (使用 winston logger)
- ❌ 擅自运行 `pnpm dev` (不需要,用 `pnpm build` 验证即可)
## 独特风格

View File

@@ -45,7 +45,7 @@
### 前置要求
- Node.js 23+
- Node.js 24+
- PostgreSQL 14+
- pnpm 8+ (推荐) 或 npm/yarn

View File

@@ -1,37 +1,89 @@
{
"alphabet": {
"chooseCharacters": "Bitte wählen Sie die Zeichen aus, die Sie lernen möchten",
"chooseAlphabetHint": "Wählen Sie ein Alphabet, um mit dem Lernen zu beginnen",
"japanese": "Japanische Kana",
"english": "Englisches Alphabet",
"uyghur": "Uigurisches Alphabet",
"esperanto": "Esperanto-Alphabet",
"loading": "Laden...",
"loading": "Wird geladen...",
"loadFailed": "Laden fehlgeschlagen, bitte versuchen Sie es erneut",
"hideLetter": "Zeichen ausblenden",
"showLetter": "Zeichen anzeigen",
"hideLetter": "Buchstabe ausblenden",
"showLetter": "Buchstabe anzeigen",
"hideIPA": "IPA ausblenden",
"showIPA": "IPA anzeigen",
"roman": "Romanisierung",
"letter": "Zeichen",
"random": "Zufälliger Modus",
"randomNext": "Zufällig weiter"
"letter": "Buchstabe",
"random": "Zufallsmodus",
"randomNext": "Zufällig weiter",
"previousLetter": "Vorheriger Buchstabe",
"nextLetter": "Nächster Buchstabe",
"keyboardHint": "Verwenden Sie die Pfeiltasten links/rechts oder Leertaste für Zufall, ESC zum Zurückgehen",
"swipeHint": "Verwenden Sie die Pfeiltasten links/rechts oder wischen Sie zum Navigieren, ESC zum Zurückgehen"
},
"folders": {
"title": "Ordner",
"subtitle": "Verwalten Sie Ihre Sammlungen",
"newFolder": "Neuer Ordner",
"creating": "Erstellen...",
"noFoldersYet": "Noch keine Ordner",
"creating": "Wird erstellt...",
"noFoldersYet": "Noch keine Ordner vorhanden",
"folderInfo": "ID: {id} • {totalPairs} Paare",
"enterFolderName": "Ordnernamen eingeben:",
"confirmDelete": "Geben Sie \"{name}\" ein, um zu löschen:"
"confirmDelete": "Geben Sie \"{name}\" zum Löschen ein:",
"myFolders": "Meine Ordner",
"publicFolders": "Öffentliche Ordner",
"public": "Öffentlich",
"private": "Privat",
"setPublic": "Öffentlich machen",
"setPrivate": "Privat machen",
"publicFolderInfo": "{userName} • {totalPairs} Paare",
"searchPlaceholder": "Öffentliche Ordner durchsuchen...",
"loading": "Wird geladen...",
"noPublicFolders": "Keine öffentlichen Ordner gefunden",
"unknownUser": "Unbekannter Benutzer",
"enterNewName": "Neuen Namen eingeben:",
"favorite": "Favorisieren",
"unfavorite": "Aus Favoriten entfernen",
"pleaseLogin": "Bitte melden Sie sich zuerst an"
},
"decks": {
"title": "Decks",
"noDecks": "Noch keine Decks",
"deckName": "Deckname",
"totalCards": "Gesamtkarten",
"createdAt": "Erstellt am",
"actions": "Aktionen",
"view": "Anzeigen",
"subtitle": "Lern-Decks verwalten",
"newDeck": "Neues Deck",
"noDecksYet": "Noch keine Decks",
"loading": "Laden...",
"deckInfo": "ID: {id} · {totalCards} Karten",
"enterDeckName": "Deck-Name eingeben:",
"enterNewName": "Neuen Namen eingeben:",
"confirmDelete": "\"{name}\" eingeben zum Löschen:",
"public": "Öffentlich",
"private": "Privat",
"setPublic": "Öffentlich machen",
"setPrivate": "Privat machen",
"importApkg": "APKG importieren",
"exportApkg": "APKG exportieren",
"clickToUpload": "Klicken zum Hochladen",
"apkgFilesOnly": "Nur .apkg Dateien",
"parsing": "Analysieren...",
"foundDecks": "{count} Decks gefunden",
"back": "Zurück",
"import": "Importieren",
"importing": "Importieren...",
"exportSuccess": "Export erfolgreich",
"goToDecks": "Zu Decks"
},
"folder_id": {
"unauthorized": "Sie sind nicht der Eigentümer dieses Ordners",
"unauthorized": "Sie sind nicht der Besitzer dieses Ordners",
"back": "Zurück",
"textPairs": "Textpaare",
"itemsCount": "{count} Elemente",
"memorize": "Einprägen",
"itemsCount": "{count} Einträge",
"memorize": "Auswendig lernen",
"loadingTextPairs": "Textpaare werden geladen...",
"noTextPairs": "Keine Textpaare in diesem Ordner",
"addNewTextPair": "Neues Textpaar hinzufügen",
@@ -42,14 +94,14 @@
"text2": "Text 2",
"language1": "Sprache 1",
"language2": "Sprache 2",
"enterLanguageName": "Bitte geben Sie den Sprachennamen ein",
"enterLanguageName": "Bitte Sprachnamen eingeben",
"edit": "Bearbeiten",
"delete": "Löschen",
"permissionDenied": "Sie haben keine Berechtigung, diese Aktion auszuführen",
"permissionDenied": "Sie haben keine Berechtigung für diese Aktion",
"error": {
"update": "Sie haben keine Berechtigung, dieses Element zu aktualisieren.",
"delete": "Sie haben keine Berechtigung, dieses Element zu löschen.",
"add": "Sie haben keine Berechtigung, Elemente zu diesem Ordner hinzuzufügen.",
"update": "Sie haben keine Berechtigung, diesen Eintrag zu aktualisieren.",
"delete": "Sie haben keine Berechtigung, diesen Eintrag zu löschen.",
"add": "Sie haben keine Berechtigung, Einträge zu diesem Ordner hinzuzufügen.",
"rename": "Sie haben keine Berechtigung, diesen Ordner umzubenennen.",
"deleteFolder": "Sie haben keine Berechtigung, diesen Ordner zu löschen."
}
@@ -57,42 +109,43 @@
"home": {
"title": "Sprachen lernen",
"description": "Hier ist eine sehr nützliche Website, die Ihnen hilft, fast jede Sprache der Welt zu lernen, einschließlich konstruierter Sprachen.",
"explore": "Erkunden",
"explore": "Entdecken",
"fortune": {
"quote": "Bleib hungrig, bleib dumm.",
"quote": "Stay hungry, stay foolish.",
"author": "— Steve Jobs"
},
"translator": {
"name": "Übersetzer",
"description": "In jede Sprache übersetzen und mit Internationalem Phonetischem Alphabet (IPA) annotieren"
"description": "In jede Sprache übersetzen und mit dem Internationalen Phonetischen Alphabet (IPA) annotieren"
},
"textSpeaker": {
"name": "Text-Sprecher",
"name": "Textvorleser",
"description": "Text erkennen und vorlesen, unterstützt Schleifenwiedergabe und Geschwindigkeitsanpassung"
},
"srtPlayer": {
"name": "SRT-Videoplayer",
"description": "Videos basierend auf SRT-Untertiteldateien satzweise abspielen, um die Aussprache von Muttersprachlern zu imitieren"
"description": "Videos Satz für Satz basierend auf SRT-Untertiteldateien abspielen, um die Aussprache von Muttersprachlern nachzuahmen"
},
"alphabet": {
"name": "Alphabet",
"description": "Beginnen Sie mit dem Erlernen einer neuen Sprache mit dem Alphabet"
"description": "Beginnen Sie mit dem Lernen einer neuen Sprache vom Alphabet aus"
},
"memorize": {
"name": "Einprägen",
"name": "Auswendig lernen",
"description": "Sprache A zu Sprache B, Sprache B zu Sprache A, unterstützt Diktat"
},
"dictionary": {
"name": "Wörterbuch",
"description": "Wörter und Redewendungen nachschlagen mit detaillierten Definitionen und Beispielen"
"description": "Wörter und Ausdrücke mit detaillierten Definitionen und Beispielen nachschlagen"
},
"moreFeatures": {
"name": "Weitere Funktionen",
"description": "In Entwicklung, bleiben Sie dran"
"description": "In Entwicklung, bleiben Sie gespannt"
}
},
"auth": {
"title": "Authentifizierung",
"title": "Anmelden",
"signUpTitle": "Registrieren",
"signIn": "Anmelden",
"signUp": "Registrieren",
"email": "E-Mail",
@@ -109,34 +162,86 @@
"signUpWithGitHub": "Mit GitHub registrieren",
"invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"passwordTooShort": "Das Passwort muss mindestens 8 Zeichen lang sein",
"passwordsNotMatch": "Passwörter stimmen nicht überein",
"passwordsNotMatch": "Die Passwörter stimmen nicht überein",
"nameRequired": "Bitte geben Sie Ihren Namen ein",
"usernameRequired": "Bitte geben Sie Ihren Benutzernamen ein",
"usernameRequired": "Bitte geben Sie einen Benutzernamen ein",
"usernameTooShort": "Der Benutzername muss mindestens 3 Zeichen lang sein",
"usernameInvalid": "Der Benutzername darf nur Buchstaben, Zahlen und Unterstriche enthalten",
"emailRequired": "Bitte geben Sie Ihre E-Mail ein",
"identifierRequired": "Bitte geben Sie Ihre E-Mail oder Ihren Benutzernamen ein",
"passwordRequired": "Bitte geben Sie Ihr Passwort ein",
"confirmPasswordRequired": "Bitte bestätigen Sie Ihr Passwort",
"loading": "Laden..."
"loading": "Wird geladen...",
"confirm": "Bestätigen",
"noAccountLink": "Haben Sie kein Konto? Registrieren Sie sich",
"hasAccountLink": "Haben Sie bereits ein Konto? Anmelden",
"usernamePlaceholder": "Benutzername",
"emailPlaceholder": "E-Mail-Adresse",
"passwordPlaceholder": "Passwort",
"usernameOrEmailPlaceholder": "Benutzername oder E-Mail",
"loginFailed": "Anmeldung fehlgeschlagen",
"signUpFailed": "Registrierung fehlgeschlagen",
"fillAllFields": "Bitte füllen Sie alle Felder aus",
"enterCredentials": "Bitte geben Sie Benutzername und Passwort ein",
"forgotPassword": "Passwort vergessen",
"forgotPasswordHint": "Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen einen Link zum Zurücksetzen Ihres Passworts.",
"sendResetEmail": "Reset-E-Mail senden",
"resetPasswordFailed": "Reset-E-Mail konnte nicht gesendet werden",
"resetPasswordEmailSent": "Reset-E-Mail erfolgreich gesendet",
"resetPasswordEmailSentHint": "Wir haben einen Link zum Zurücksetzen Ihres Passworts an Ihre E-Mail-Adresse gesendet. Bitte überprüfen Sie Ihren Posteingang.",
"verifyYourEmail": "E-Mail bestätigen",
"verificationEmailSent": "Bestätigungs-E-Mail gesendet",
"verificationEmailSentHint": "Wir haben eine Bestätigungs-E-Mail an {email} gesendet. Bitte klicken Sie auf den Link in der E-Mail, um Ihr Konto zu bestätigen.",
"checkYourEmail": "Überprüfen Sie Ihre E-Mail",
"backToLogin": "Zurück zur Anmeldung",
"resetPassword": "Passwort zurücksetzen",
"newPassword": "Neues Passwort",
"invalidToken": "Ungültiger oder abgelaufener Link",
"invalidTokenHint": "Dieser Link zum Zurücksetzen des Passworts ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen an.",
"requestNewToken": "Neuen Reset-Link anfordern",
"resetPasswordSuccess": "Passwort erfolgreich zurückgesetzt",
"resetPasswordSuccessTitle": "Passwort-Zurücksetzung abgeschlossen",
"resetPasswordSuccessHint": "Ihr Passwort wurde erfolgreich zurückgesetzt. Sie können sich jetzt mit Ihrem neuen Passwort anmelden.",
"emailNotVerified": "Bitte verifizieren Sie Ihre E-Mail-Adresse",
"emailNotVerifiedHint": "Ihre E-Mail-Adresse wurde nicht verifiziert. Bitte überprüfen Sie Ihren Posteingang oder fordern Sie eine neue Verifizierungs-E-Mail an.",
"resendVerification": "Verifizierungs-E-Mail erneut senden",
"resendSuccess": "Verifizierungs-E-Mail gesendet! Bitte überprüfen Sie Ihren Posteingang.",
"resendFailed": "Verifizierungs-E-Mail konnte nicht gesendet werden"
},
"memorize": {
"folder_selector": {
"selectFolder": "Wählen Sie einen Ordner aus",
"noFolders": "Keine Ordner gefunden",
"folderInfo": "{id}. {name} ({count})"
"deck_selector": {
"selectDeck": "Deck wählen",
"noDecks": "Keine Decks",
"goToDecks": "Zu Decks",
"noCards": "Keine Karten",
"new": "Neu",
"learning": "Lernen",
"review": "Wiederholen",
"due": "Fällig"
},
"memorize": {
"answer": "Antwort",
"next": "Weiter",
"reverse": "Umkehren",
"dictation": "Diktat",
"noTextPairs": "Keine Textpaare verfügbar",
"disorder": "Mischen",
"previous": "Zurück"
"review": {
"loading": "Laden...",
"backToDecks": "Zurück zu Decks",
"allDone": "Alles erledigt!",
"allDoneDesc": "Lernen für heute abgeschlossen!",
"reviewedCount": "{count} Karten wiederholt",
"progress": "{current} / {total}",
"nextReview": "Nächste Wiederholung",
"interval": "Intervall",
"ease": "Schwierigkeit",
"lapses": "Fehler",
"showAnswer": "Antwort zeigen",
"nextCard": "Weiter",
"again": "Nochmal",
"restart": "Neustart",
"orderLimited": "Reihenfolge begrenzt",
"orderInfinite": "Reihenfolge unbegrenzt",
"randomLimited": "Zufällig begrenzt",
"randomInfinite": "Zufällig unbegrenzt",
"noIpa": "Kein IPA verfügbar"
},
"page": {
"unauthorized": "Sie sind nicht berechtigt, auf diesen Ordner zuzugreifen"
"unauthorized": "Nicht autorisiert"
}
},
"navbar": {
@@ -144,13 +249,66 @@
"sourceCode": "GitHub",
"sign_in": "Anmelden",
"profile": "Profil",
"folders": "Ordner"
"folders": "Decks",
"explore": "Erkunden",
"favorites": "Favoriten",
"settings": "Einstellungen"
},
"ocr": {
"title": "OCR-Erkennung",
"description": "Text aus Bildern extrahieren",
"uploadImage": "Bild hochladen",
"dragDropHint": "Ziehen und ablegen",
"supportedFormats": "Unterstützt: JPG, PNG, WEBP",
"selectDeck": "Deck wählen",
"chooseDeck": "Deck wählen",
"noDecks": "Keine Decks verfügbar",
"languageHints": "Sprachhinweise",
"sourceLanguageHint": "Quellsprache",
"targetLanguageHint": "Zielsprache",
"process": "Verarbeiten",
"processing": "Verarbeiten...",
"preview": "Vorschau",
"extractedPairs": "Extrahierte Paare",
"word": "Wort",
"definition": "Definition",
"pairsCount": "{count} Paare",
"savePairs": "Speichern",
"saving": "Speichern...",
"saved": "Gespeichert",
"saveFailed": "Speichern fehlgeschlagen",
"noImage": "Bitte Bild hochladen",
"noDeck": "Bitte Deck wählen",
"processingFailed": "Verarbeitung fehlgeschlagen",
"tryAgain": "Erneut versuchen",
"detectedLanguages": "Erkannte Sprachen",
"invalidFileType": "Ungültiger Dateityp",
"ocrFailed": "OCR fehlgeschlagen",
"uploadSection": "Bild hochladen",
"dropOrClick": "Ablegen oder klicken",
"changeImage": "Bild ändern",
"deckSelection": "Deck wählen",
"sourceLanguagePlaceholder": "z.B. Englisch",
"targetLanguagePlaceholder": "z.B. Deutsch",
"processButton": "Erkennung starten",
"resultsPreview": "Ergebnisvorschau",
"saveButton": "In Deck speichern",
"ocrSuccess": "OCR erfolgreich",
"savedToDeck": "In Deck gespeichert",
"noResultsToSave": "Keine Ergebnisse",
"detectedSourceLanguage": "Erkannte Quellsprache",
"detectedTargetLanguage": "Erkannte Zielsprache"
},
"profile": {
"myProfile": "Mein Profil",
"email": "E-Mail: {email}",
"logout": "Abmelden"
},
"settings": {
"title": "Einstellungen",
"themeColor": "Designfarbe",
"themeColorDescription": "Wählen Sie Ihre bevorzugte Designfarbe"
},
"srt_player": {
"uploadVideo": "Video hochladen",
"uploadSubtitle": "Untertitel hochladen",
@@ -170,21 +328,60 @@
"uploaded": "Hochgeladen",
"notUploaded": "Nicht hochgeladen",
"upload": "Hochladen",
"uploadVideoButton": "Video hochladen",
"uploadSubtitleButton": "Untertitel hochladen",
"subtitleUploaded": "Untertitel hochgeladen ({count} Einträge)",
"subtitleNotUploaded": "Untertitel nicht hochgeladen",
"autoPauseStatus": "Auto-Pause: {enabled}",
"on": "Ein",
"off": "Aus",
"videoUploadFailed": "Video-Upload fehlgeschlagen",
"subtitleUploadFailed": "Untertitel-Upload fehlgeschlagen"
"subtitleUploadFailed": "Untertitel-Upload fehlgeschlagen",
"subtitleLoadSuccess": "Untertitel erfolgreich geladen",
"subtitleLoadFailed": "Laden der Untertitel fehlgeschlagen",
"settings": "Einstellungen",
"shortcuts": "Tastenkürzel",
"keyboardShortcuts": "Tastaturkürzel",
"playPause": "Wiedergabe/Pause",
"autoPauseToggle": "Auto-Pause",
"subtitleSettings": "Untertiteleinstellungen",
"fontSize": "Schriftgröße",
"textColor": "Textfarbe",
"backgroundColor": "Hintergrundfarbe",
"position": "Position",
"opacity": "Deckkraft",
"top": "Oben",
"center": "Mitte",
"bottom": "Unten"
},
"text_speaker": {
"generateIPA": "IPA generieren",
"viewSavedItems": "Gespeicherte Elemente anzeigen",
"confirmDeleteAll": "Sind Sie sicher, dass Sie alles löschen möchten? (J/N)"
"viewSavedItems": "Gespeicherte Einträge anzeigen",
"confirmDeleteAll": "Sind Sie sicher, dass Sie alles löschen möchten? (J/N)",
"saved": "Gespeichert",
"clearAll": "Alles löschen",
"language": "Sprache",
"customLanguage": "oder Sprache eingeben...",
"languages": {
"auto": "Automatisch",
"chinese": "Chinesisch",
"english": "Englisch",
"japanese": "Japanisch",
"korean": "Koreanisch",
"french": "Französisch",
"german": "Deutsch",
"italian": "Italienisch",
"spanish": "Spanisch",
"portuguese": "Portugiesisch",
"russian": "Russisch"
}
},
"translator": {
"detectLanguage": "Sprache erkennen",
"sourceLanguage": "Quellsprache",
"auto": "Automatisch",
"generateIPA": "IPA generieren",
"translateInto": "Übersetzen in",
"translateInto": "übersetzen in",
"chinese": "Chinesisch",
"english": "Englisch",
"french": "Französisch",
@@ -196,49 +393,104 @@
"russian": "Russisch",
"spanish": "Spanisch",
"other": "Andere",
"translating": "Übersetzung läuft...",
"translate": "Übersetzen",
"translating": "wird übersetzt...",
"translate": "übersetzen",
"inputLanguage": "Geben Sie eine Sprache ein.",
"history": "Verlauf",
"enterLanguage": "Sprache eingeben",
"add_to_folder": {
"notAuthenticated": "Sie sind nicht authentifiziert",
"chooseFolder": "Wählen Sie einen Ordner zum Hinzufügen aus",
"chooseFolder": "Wählen Sie einen Ordner zum Hinzufügen",
"noFolders": "Keine Ordner gefunden",
"folderInfo": "{id}. {name}",
"close": "Schließen",
"success": "Textpaar zum Ordner hinzugefügt",
"error": "Textpaar konnte nicht zum Ordner hinzugefügt werden"
"error": "Fehler beim Hinzufügen des Textpaars zum Ordner"
},
"autoSave": "Automatisch speichern"
"autoSave": "Autom. Speichern",
"customLanguage": "oder Sprache eingeben...",
"pleaseLogin": "Bitte anmelden um Karten zu speichern",
"pleaseCreateDeck": "Bitte erst zuerst ein Deck",
"noTranslationToSave": "Keine Übersetzung zum Speichern",
"noDeckSelected": "Kein Deck ausgewählt",
"saveAsCard": "Als Karte speichern",
"selectDeck": "Deck wählen",
"front": "Vorderseite",
"back": "Rückseite",
"cancel": "Abbrechen",
"save": "Speichern",
"savedToDeck": "Karte in {deckName} gespeichert",
"saveFailed": "Karte speichern fehlgeschlagen"
},
"dictionary": {
"title": "Wörterbuch",
"description": "Wörter und Ausdrücke mit detaillierten Definitionen und Beispielen nachschlagen",
"searchPlaceholder": "Wort oder Ausdruck zum Nachschlagen eingeben...",
"searching": "Suche...",
"searchPlaceholder": "Geben Sie ein Wort oder einen Ausdruck zum Nachschlagen ein...",
"searching": "Suche läuft...",
"search": "Suchen",
"languageSettings": "Spracheinstellungen",
"queryLanguage": "Abfragesprache",
"queryLanguageHint": "Welche Sprache hat das Wort/die Phrase, die Sie nachschlagen möchten",
"queryLanguageHint": "In welcher Sprache ist das Wort/der Ausdruck, den Sie nachschlagen möchten",
"definitionLanguage": "Definitionssprache",
"definitionLanguageHint": "In welcher Sprache möchten Sie die Definitionen sehen",
"otherLanguagePlaceholder": "Oder eine andere Sprache eingeben...",
"definitionLanguageHint": "In welcher Sprache möchten Sie die Definitionen",
"otherLanguagePlaceholder": "Oder geben Sie eine andere Sprache ein...",
"other": "Andere",
"currentSettings": "Aktuelle Einstellungen: Abfrage {queryLang}, Definition {definitionLang}",
"relookup": "Neu suchen",
"relookup": "Erneut suchen",
"saveToFolder": "In Ordner speichern",
"loading": "Laden...",
"loading": "Wird geladen...",
"noResults": "Keine Ergebnisse gefunden",
"tryOtherWords": "Versuchen Sie andere Wörter oder Ausdrücke",
"welcomeTitle": "Willkommen beim Wörterbuch",
"welcomeHint": "Geben Sie oben im Suchfeld ein Wort oder einen Ausdruck ein, um zu suchen",
"lookupFailed": "Suche fehlgeschlagen, bitte später erneut versuchen",
"relookupSuccess": "Erfolgreich neu gesucht",
"relookupFailed": "Wörterbuch Neu-Suche fehlgeschlagen",
"welcomeTitle": "Willkommen im Wörterbuch",
"welcomeHint": "Geben Sie oben in das Suchfeld ein Wort oder einen Ausdruck ein, um mit dem Nachschlagen zu beginnen",
"lookupFailed": "Suche fehlgeschlagen, bitte versuchen Sie es später erneut",
"relookupSuccess": "Erneute Suche erfolgreich",
"relookupFailed": "Erneute Wörterbuchsuche fehlgeschlagen",
"pleaseLogin": "Bitte melden Sie sich zuerst an",
"pleaseCreateFolder": "Bitte erstellen Sie zuerst einen Ordner",
"savedToFolder": "Im Ordner gespeichert: {folderName}",
"saveFailed": "Speichern fehlgeschlagen, bitte später erneut versuchen"
"savedToFolder": "In Ordner gespeichert: {folderName}",
"saveFailed": "Speichern fehlgeschlagen, bitte versuchen Sie es später erneut",
"definition": "Definition",
"example": "Beispiel"
},
"explore": {
"title": "Entdecken",
"subtitle": "Öffentliche Ordner entdecken",
"searchPlaceholder": "Öffentliche Ordner durchsuchen...",
"loading": "Wird geladen...",
"noFolders": "Keine öffentlichen Ordner gefunden",
"folderInfo": "{userName} • {totalPairs} Paare",
"unknownUser": "Unbekannter Benutzer",
"favorite": "Favorisieren",
"unfavorite": "Aus Favoriten entfernen",
"pleaseLogin": "Bitte melden Sie sich zuerst an",
"sortByFavorites": "Nach Favoriten sortieren",
"sortByFavoritesActive": "Sortierung nach Favoriten aufheben",
"noDecks": "Keine öffentlichen Decks",
"deckInfo": "{userName} · {totalCards} Karten"
},
"exploreDetail": {
"title": "Ordnerdetails",
"createdBy": "Erstellt von: {name}",
"unknownUser": "Unbekannter Benutzer",
"totalPairs": "Gesamtpaare",
"favorites": "Favoriten",
"createdAt": "Erstellt am",
"viewContent": "Inhalt anzeigen",
"favorite": "Favorisieren",
"unfavorite": "Aus Favoriten entfernen",
"favorited": "Favorisiert",
"unfavorited": "Aus Favoriten entfernt",
"pleaseLogin": "Bitte melden Sie sich zuerst an",
"totalCards": "{count} Karten"
},
"favorites": {
"title": "Meine Favoriten",
"subtitle": "Ordner, die Sie favorisiert haben",
"loading": "Wird geladen...",
"noFavorites": "Noch keine Favoriten",
"folderInfo": "{userName} • {totalPairs} Paare",
"unknownUser": "Unbekannter Benutzer"
},
"user_profile": {
"anonymous": "Anonym",
@@ -251,14 +503,120 @@
"displayName": "Anzeigename",
"notSet": "Nicht festgelegt",
"memberSince": "Mitglied seit",
"logout": "Abmelden",
"deleteAccount": {
"button": "Konto löschen",
"title": "Konto löschen",
"warning": "Diese Aktion ist unwiderruflich. Alle Ihre Daten werden dauerhaft gelöscht.",
"warningDecks": "Alle Ihre Decks und Karten",
"warningCards": "All Ihr Lernfortschritt",
"warningHistory": "All Ihr Übersetzungs- und Wörterbuchverlauf",
"warningPermanent": "Diese Aktion kann nicht rückgängig gemacht werden",
"confirmLabel": "Geben Sie Ihren Benutzernamen zur Bestätigung ein:",
"usernameMismatch": "Benutzername stimmt nicht überein",
"cancel": "Abbrechen",
"confirm": "Mein Konto löschen",
"success": "Konto erfolgreich gelöscht",
"failed": "Konto konnte nicht gelöscht werden"
},
"folders": {
"title": "Ordner",
"noFolders": "Noch keine Ordner",
"folderName": "Ordnername",
"totalPairs": "Anzahl der Paare",
"title": "Decks",
"noFolders": "Noch keine Decks",
"folderName": "Deckname",
"totalPairs": "Gesamtkarten",
"createdAt": "Erstellt am",
"actions": "Aktionen",
"view": "Anzeigen"
},
"joined": "Beigetreten",
"decks": {
"title": "Meine Decks",
"noDecks": "Keine Decks",
"deckName": "Deck-Name",
"totalCards": "Gesamtkarten",
"createdAt": "Erstellt am",
"actions": "Aktionen",
"view": "Ansehen"
}
},
"follow": {
"follow": "Folgen",
"following": "Folge ich",
"followers": "Follower",
"followersOf": "{username}s Follower",
"followingOf": "{username} folgt",
"noFollowers": "Noch keine Follower",
"noFollowing": "Folgt noch niemandem"
},
"deck_id": {
"unauthorized": "Sie sind nicht der Besitzer dieses Decks",
"back": "Zurück",
"cards": "Karten",
"itemsCount": "{count} Elemente",
"memorize": "Auswendig lernen",
"loadingCards": "Karten werden geladen...",
"noCards": "Keine Karten in diesem Deck",
"card": "Karte",
"addNewCard": "Neue Karte hinzufügen",
"add": "Hinzufügen",
"adding": "Wird hinzugefügt...",
"updateCard": "Karte aktualisieren",
"update": "Aktualisieren",
"updating": "Wird aktualisiert...",
"word": "Wort",
"definition": "Definition",
"ipa": "IPA",
"example": "Beispiel",
"wordAndDefinitionRequired": "Wort und Definition sind erforderlich",
"edit": "Bearbeiten",
"delete": "Löschen",
"permissionDenied": "Sie haben keine Berechtigung für diese Aktion",
"resetProgress": "Fortschritt zurücksetzen",
"resetProgressTitle": "Lernfortschritt zurücksetzen",
"resetProgressConfirm": "Fortschritt wirklich zurücksetzen?",
"resetSuccess": "Fortschritt zurückgesetzt",
"resetting": "Zurücksetzen...",
"cancel": "Abbrechen",
"settings": "Einstellungen",
"settingsTitle": "Deck-Einstellungen",
"newPerDay": "Neue pro Tag",
"newPerDayHint": "Neue Karten pro Tag",
"revPerDay": "Wiederholungen pro Tag",
"revPerDayHint": "Wiederholungen pro Tag",
"save": "Speichern",
"saving": "Speichern...",
"settingsSaved": "Einstellungen gespeichert",
"todayNew": "Heute neu",
"todayReview": "Heute wiederholen",
"todayLearning": "Lernen",
"error": {
"update": "Keine Berechtigung zum Aktualisieren",
"delete": "Keine Berechtigung zum Löschen",
"add": "Keine Berechtigung zum Hinzufügen"
},
"ipaPlaceholder": "IPA eingeben",
"examplePlaceholder": "Beispiel eingeben",
"wordRequired": "Bitte Wort eingeben",
"definitionRequired": "Bitte Definition eingeben",
"cardAdded": "Karte hinzugefügt",
"cardType": "Kartentyp",
"wordCard": "Wortkarte",
"phraseCard": "Phrasenkarte",
"sentenceCard": "Satzkarte",
"sentence": "Satz",
"sentencePlaceholder": "Satz eingeben",
"wordPlaceholder": "Wort eingeben",
"queryLang": "Abfragesprache",
"enterLanguageName": "Bitte Sprachnamen eingeben",
"english": "Englisch",
"chinese": "Chinesisch",
"japanese": "Japanisch",
"korean": "Koreanisch",
"meanings": "Bedeutungen",
"addMeaning": "Bedeutung hinzufügen",
"partOfSpeech": "Wortart",
"deleteConfirm": "Karte wirklich löschen?",
"cardDeleted": "Karte gelöscht",
"cardUpdated": "Karte aktualisiert"
}
}

View File

@@ -1,6 +1,7 @@
{
"alphabet": {
"chooseCharacters": "Please select the characters you want to learn",
"chooseAlphabetHint": "Select an alphabet to start learning",
"japanese": "Japanese Kana",
"english": "English Alphabet",
"uyghur": "Uyghur Alphabet",
@@ -14,7 +15,11 @@
"roman": "Romanization",
"letter": "Letter",
"random": "Random Mode",
"randomNext": "Random Next"
"randomNext": "Random Next",
"previousLetter": "Previous letter",
"nextLetter": "Next letter",
"keyboardHint": "Use left/right arrow keys or space for random, ESC to go back",
"swipeHint": "Use left/right arrow keys or swipe to navigate, ESC to go back"
},
"folders": {
"title": "Folders",
@@ -69,6 +74,77 @@
"deleteFolder": "You do not have permission to delete this folder."
}
},
"deck_id": {
"unauthorized": "You are not the owner of this deck",
"back": "Back",
"cards": "Cards",
"itemsCount": "{count} items",
"memorize": "Memorize",
"loadingCards": "Loading cards...",
"noCards": "No cards in this deck",
"card": "Card",
"addNewCard": "Add New Card",
"add": "Add",
"adding": "Adding...",
"updateCard": "Update Card",
"update": "Update",
"updating": "Updating...",
"word": "Word",
"definition": "Definition",
"ipa": "IPA",
"ipaPlaceholder": "Enter IPA pronunciation",
"example": "Example",
"examplePlaceholder": "Enter an example sentence",
"wordAndDefinitionRequired": "Word and definition are required",
"wordRequired": "Word is required",
"definitionRequired": "At least one definition is required",
"cardAdded": "Card added successfully",
"cardType": "Card Type",
"wordCard": "Word",
"phraseCard": "Phrase",
"sentenceCard": "Sentence",
"sentence": "Sentence",
"sentencePlaceholder": "Enter a sentence",
"wordPlaceholder": "Enter a word",
"queryLang": "Language",
"enterLanguageName": "Please enter language name",
"english": "English",
"chinese": "Chinese",
"japanese": "Japanese",
"korean": "Korean",
"meanings": "Meanings",
"addMeaning": "Add Meaning",
"partOfSpeech": "Part of Speech",
"edit": "Edit",
"delete": "Delete",
"deleteConfirm": "Are you sure you want to delete this card?",
"cardDeleted": "Card deleted",
"permissionDenied": "You do not have permission to perform this action",
"resetProgress": "Reset",
"resetProgressTitle": "Reset Deck Progress",
"resetProgressConfirm": "This will reset all cards in this deck to new state. Your learning progress will be lost. Are you sure?",
"resetSuccess": "Successfully reset {count} cards",
"resetting": "Resetting...",
"cancel": "Cancel",
"settings": "Settings",
"settingsTitle": "Deck Settings",
"newPerDay": "New Cards Per Day",
"newPerDayHint": "Maximum new cards to learn each day",
"revPerDay": "Review Cards Per Day",
"revPerDayHint": "Maximum review cards each day",
"save": "Save",
"saving": "Saving...",
"settingsSaved": "Settings saved",
"todayNew": "New",
"todayReview": "Review",
"todayLearning": "Learning",
"cardUpdated": "Card updated",
"error": {
"update": "You do not have permission to update this card.",
"delete": "You do not have permission to delete this card.",
"add": "You do not have permission to add cards to this deck."
}
},
"home": {
"title": "Learn Languages",
"description": "Here is a very useful website to help you learn almost every language in the world, including constructed ones.",
@@ -107,7 +183,8 @@
}
},
"auth": {
"title": "Authentication",
"title": "Sign In",
"signUpTitle": "Sign Up",
"signIn": "Sign In",
"signUp": "Sign Up",
"email": "Email",
@@ -133,25 +210,102 @@
"identifierRequired": "Please enter your email or username",
"passwordRequired": "Please enter your password",
"confirmPasswordRequired": "Please confirm your password",
"loading": "Loading..."
"loading": "Loading...",
"confirm": "Confirm",
"noAccountLink": "Don't have an account? Sign up",
"hasAccountLink": "Already have an account? Sign in",
"usernamePlaceholder": "Username",
"emailPlaceholder": "Email address",
"passwordPlaceholder": "Password",
"usernameOrEmailPlaceholder": "Username or email",
"loginFailed": "Login failed",
"signUpFailed": "Sign up failed",
"fillAllFields": "Please fill in all fields",
"enterCredentials": "Please enter username and password",
"forgotPassword": "Forgot Password",
"forgotPasswordHint": "Enter your email address and we'll send you a link to reset your password.",
"sendResetEmail": "Send Reset Email",
"resetPasswordFailed": "Failed to send reset email",
"resetPasswordEmailSent": "Reset email sent successfully",
"resetPasswordEmailSentHint": "We've sent a password reset link to your email address. Please check your inbox.",
"verifyYourEmail": "Verify Your Email",
"verificationEmailSent": "Verification email sent",
"verificationEmailSentHint": "We've sent a verification email to {email}. Please click the link in the email to verify your account.",
"checkYourEmail": "Check Your Email",
"backToLogin": "Back to Login",
"resetPassword": "Reset Password",
"newPassword": "New Password",
"invalidToken": "Invalid or Expired Link",
"invalidTokenHint": "This password reset link is invalid or has expired. Please request a new one.",
"requestNewToken": "Request New Reset Link",
"resetPasswordSuccess": "Password reset successfully",
"resetPasswordSuccessTitle": "Password Reset Complete",
"resetPasswordSuccessHint": "Your password has been reset successfully. You can now log in with your new password.",
"emailNotVerified": "Please verify your email address",
"emailNotVerifiedHint": "Your email has not been verified. Please check your inbox or request a new verification email.",
"resendVerification": "Resend Verification Email",
"resendSuccess": "Verification email sent! Please check your inbox.",
"resendFailed": "Failed to send verification email"
},
"memorize": {
"folder_selector": {
"selectFolder": "Select a folder",
"noFolders": "No folders found",
"folderInfo": "{id}. {name} ({count})"
"deck_selector": {
"selectDeck": "Select a deck",
"noDecks": "No decks found",
"goToDecks": "Go to Decks",
"noCards": "No cards",
"new": "New",
"learning": "Learning",
"review": "Review",
"due": "Due"
},
"memorize": {
"answer": "Answer",
"next": "Next",
"review": {
"loading": "Loading cards...",
"backToDecks": "Back to Decks",
"allDone": "All Done!",
"allDoneDesc": "You've reviewed all due cards.",
"reviewedCount": "Reviewed {count} cards",
"progress": "{current} / {total}",
"nextReview": "Next review",
"interval": "Interval",
"ease": "Ease",
"lapses": "Lapses",
"showAnswer": "Show Answer",
"nextCard": "Next",
"again": "Again",
"hard": "Hard",
"good": "Good",
"easy": "Easy",
"now": "now",
"lessThanMinute": "<1 min",
"inMinutes": "{count} min",
"inHours": "{count}h",
"inDays": "{count}d",
"inMonths": "{count}mo",
"minutes": "<1 min",
"days": "{count}d",
"months": "{count}mo",
"minAbbr": "m",
"dayAbbr": "d",
"cardTypeNew": "New",
"cardTypeLearning": "Learning",
"cardTypeReview": "Review",
"cardTypeRelearning": "Relearning",
"reverse": "Reverse",
"dictation": "Dictation",
"noTextPairs": "No text pairs available",
"disorder": "Disorder",
"previous": "Previous"
"clickToPlay": "Click to play audio",
"restart": "Restart",
"yourAnswer": "Your answer",
"typeWhatYouHear": "Type what you hear...",
"correct": "Correct",
"incorrect": "Incorrect",
"orderLimited": "Order",
"orderInfinite": "Loop",
"randomLimited": "Random",
"randomInfinite": "Random Loop",
"noIpa": "No IPA available"
},
"page": {
"unauthorized": "You are not authorized to access this folder"
"unauthorized": "You are not authorized to access this deck"
}
},
"navbar": {
@@ -159,15 +313,66 @@
"sourceCode": "GitHub",
"sign_in": "Sign In",
"profile": "Profile",
"folders": "Folders",
"folders": "Decks",
"explore": "Explore",
"favorites": "Favorites"
"favorites": "Favorites",
"settings": "Settings"
},
"ocr": {
"title": "OCR Vocabulary Extractor",
"description": "Upload vocabulary table screenshots from textbooks to extract word-definition pairs",
"uploadSection": "Upload Image",
"uploadImage": "Upload Image",
"dragDropHint": "Drag and drop an image here, or click to select",
"dropOrClick": "Drag and drop an image here, or click to select",
"changeImage": "Click to change image",
"supportedFormats": "Supports: JPG, PNG, WebP",
"invalidFileType": "Invalid file type. Please upload an image file (JPG, PNG, or WebP).",
"deckSelection": "Select Deck",
"selectDeck": "Select a deck",
"chooseDeck": "Choose a deck to save extracted pairs",
"noDecks": "No decks available. Please create a deck first.",
"languageHints": "Language Hints (Optional)",
"sourceLanguageHint": "Source language (e.g., English)",
"targetLanguageHint": "Target/Translation language (e.g., Chinese)",
"sourceLanguagePlaceholder": "Source language (e.g., English)",
"targetLanguagePlaceholder": "Target/Translation language (e.g., Chinese)",
"process": "Process Image",
"processButton": "Process Image",
"processing": "Processing...",
"preview": "Preview",
"resultsPreview": "Results Preview",
"extractedPairs": "Extracted {count} pairs",
"word": "Word",
"definition": "Definition",
"pairsCount": "{count} pairs extracted",
"savePairs": "Save to Deck",
"saveButton": "Save",
"saving": "Saving...",
"saved": "Successfully saved {count} pairs to {deck}",
"ocrSuccess": "Successfully extracted {count} pairs to {deck}",
"ocrFailed": "OCR processing failed. Please try again.",
"savedToDeck": "Saved to {deckName}",
"saveFailed": "Failed to save pairs",
"noImage": "Please upload an image first",
"noDeck": "Please select a deck",
"noResultsToSave": "No results to save",
"processingFailed": "OCR processing failed",
"tryAgain": "Please try again with a clearer image",
"detectedLanguages": "Detected: {source} → {target}",
"detectedSourceLanguage": "Detected source language",
"detectedTargetLanguage": "Detected target language"
},
"profile": {
"myProfile": "My Profile",
"email": "Email: {email}",
"logout": "Logout"
},
"settings": {
"title": "Settings",
"themeColor": "Theme Color",
"themeColorDescription": "Choose your preferred theme color"
},
"srt_player": {
"uploadVideo": "Upload Video",
"uploadSubtitle": "Upload Subtitle",
@@ -187,21 +392,61 @@
"uploaded": "Uploaded",
"notUploaded": "Not Uploaded",
"upload": "Upload",
"uploadVideoButton": "Upload Video",
"uploadSubtitleButton": "Upload Subtitle",
"subtitleUploaded": "Subtitle Uploaded ({count} entries)",
"subtitleNotUploaded": "Subtitle Not Uploaded",
"autoPauseStatus": "Auto Pause: {enabled}",
"on": "On",
"off": "Off",
"videoUploadFailed": "Video upload failed",
"subtitleUploadFailed": "Subtitle upload failed"
"subtitleUploadFailed": "Subtitle upload failed",
"subtitleLoadSuccess": "Subtitle loaded successfully",
"subtitleLoadFailed": "Subtitle load failed",
"settings": "Settings",
"shortcuts": "Shortcuts",
"keyboardShortcuts": "Keyboard Shortcuts",
"playPause": "Play/Pause",
"autoPauseToggle": "Toggle Auto Pause",
"subtitleSettings": "Subtitle Settings",
"fontSize": "Font Size",
"textColor": "Text Color",
"backgroundColor": "Background Color",
"position": "Position",
"opacity": "Opacity",
"top": "Top",
"center": "Center",
"bottom": "Bottom"
},
"text_speaker": {
"generateIPA": "Generate IPA",
"viewSavedItems": "View Saved Items",
"confirmDeleteAll": "Are you sure you want to delete everything? (Y/N)"
"confirmDeleteAll": "Are you sure you want to delete everything? (Y/N)",
"saved": "Saved",
"clearAll": "Clear All",
"language": "Language",
"customLanguage": "or type language...",
"languages": {
"auto": "Auto",
"chinese": "Chinese",
"english": "English",
"japanese": "Japanese",
"korean": "Korean",
"french": "French",
"german": "German",
"italian": "Italian",
"spanish": "Spanish",
"portuguese": "Portuguese",
"russian": "Russian"
}
},
"translator": {
"detectLanguage": "detect language",
"sourceLanguage": "source language",
"auto": "Auto",
"generateIPA": "generate ipa",
"translateInto": "translate into",
"customLanguage": "or type language...",
"chinese": "Chinese",
"english": "English",
"french": "French",
@@ -227,7 +472,19 @@
"success": "Text pair added to folder",
"error": "Failed to add text pair to folder"
},
"autoSave": "Auto Save"
"autoSave": "Auto Save",
"pleaseLogin": "Please login to save cards",
"pleaseCreateDeck": "Please create a deck first",
"noTranslationToSave": "No translation to save",
"noDeckSelected": "No deck selected",
"saveAsCard": "Save as Card",
"selectDeck": "Select Deck",
"front": "Front",
"back": "Back",
"cancel": "Cancel",
"save": "Save",
"savedToDeck": "Card saved to {deckName}",
"saveFailed": "Failed to save card"
},
"dictionary": {
"title": "Dictionary",
@@ -256,15 +513,17 @@
"pleaseLogin": "Please log in first",
"pleaseCreateFolder": "Please create a folder first",
"savedToFolder": "Saved to folder: {folderName}",
"saveFailed": "Save failed, please try again later"
"saveFailed": "Save failed, please try again later",
"definition": "Definition",
"example": "Example"
},
"explore": {
"title": "Explore",
"subtitle": "Discover public folders",
"searchPlaceholder": "Search public folders...",
"subtitle": "Discover public decks",
"searchPlaceholder": "Search public decks...",
"loading": "Loading...",
"noFolders": "No public folders found",
"folderInfo": "{userName} • {totalPairs} pairs",
"noDecks": "No public decks found",
"deckInfo": "{userName} • {cardCount} cards",
"unknownUser": "Unknown User",
"favorite": "Favorite",
"unfavorite": "Unfavorite",
@@ -272,6 +531,20 @@
"sortByFavorites": "Sort by favorites",
"sortByFavoritesActive": "Undo sort by favorites"
},
"exploreDetail": {
"title": "Deck Details",
"createdBy": "Created by: {name}",
"unknownUser": "Unknown User",
"totalCards": "Total Cards",
"favorites": "Favorites",
"createdAt": "Created At",
"viewContent": "View Content",
"favorite": "Favorite",
"unfavorite": "Unfavorite",
"favorited": "Favorited",
"unfavorited": "Unfavorited",
"pleaseLogin": "Please login first"
},
"favorites": {
"title": "My Favorites",
"subtitle": "Folders you've favorited",
@@ -291,14 +564,67 @@
"displayName": "Display Name",
"notSet": "Not Set",
"memberSince": "Member Since",
"folders": {
"title": "Folders",
"noFolders": "No folders yet",
"folderName": "Folder Name",
"totalPairs": "Total Pairs",
"joined": "Joined",
"logout": "Logout",
"deleteAccount": {
"button": "Delete Account",
"title": "Delete Account",
"warning": "This action is irreversible. All your data will be permanently deleted.",
"warningDecks": "All your decks and cards",
"warningCards": "All your learning progress",
"warningHistory": "All your translation and dictionary history",
"warningPermanent": "This action cannot be undone",
"confirmLabel": "Type your username to confirm:",
"usernameMismatch": "Username does not match",
"cancel": "Cancel",
"confirm": "Delete My Account",
"success": "Account deleted successfully",
"failed": "Failed to delete account"
},
"decks": {
"title": "Decks",
"noDecks": "No decks yet",
"deckName": "Deck Name",
"totalCards": "Total Cards",
"createdAt": "Created At",
"actions": "Actions",
"view": "View"
}
},
"decks": {
"title": "Decks",
"subtitle": "Manage your flashcard decks",
"newDeck": "New Deck",
"noDecksYet": "No decks yet",
"loading": "Loading...",
"deckInfo": "ID: {id} • {totalCards} cards",
"enterDeckName": "Enter deck name:",
"enterNewName": "Enter new name:",
"confirmDelete": "Type \"{name}\" to delete:",
"public": "Public",
"private": "Private",
"setPublic": "Set Public",
"setPrivate": "Set Private",
"importApkg": "Import APKG",
"exportApkg": "Export APKG",
"clickToUpload": "Click to upload an APKG file",
"apkgFilesOnly": "Only .apkg files are supported",
"parsing": "Parsing...",
"foundDecks": "Found {count} deck(s)",
"deckName": "Deck Name",
"back": "Back",
"import": "Import",
"importing": "Importing...",
"exportSuccess": "Deck exported successfully",
"goToDecks": "Go to Decks"
},
"follow": {
"follow": "Follow",
"following": "Following",
"followers": "Followers",
"followersOf": "{username}'s Followers",
"followingOf": "{username}'s Following",
"noFollowers": "No followers yet",
"noFollowing": "Not following anyone yet"
}
}

View File

@@ -1,6 +1,7 @@
{
"alphabet": {
"chooseCharacters": "Veuillez sélectionner les caractères que vous souhaitez apprendre",
"chooseAlphabetHint": "Sélectionnez un alphabet pour commencer à apprendre",
"japanese": "Kana japonais",
"english": "Alphabet anglais",
"uyghur": "Alphabet ouïghour",
@@ -14,29 +15,80 @@
"roman": "Romanisation",
"letter": "Lettre",
"random": "Mode aléatoire",
"randomNext": "Suivant aléatoire"
"randomNext": "Suivant aléatoire",
"previousLetter": "Lettre précédente",
"nextLetter": "Lettre suivante",
"keyboardHint": "Utilisez les touches fléchées gauche/droite ou espace pour aléatoire, ÉCHAP pour revenir",
"swipeHint": "Utilisez les touches fléchées gauche/droite ou balayez pour naviguer, ÉCHAP pour revenir"
},
"folders": {
"title": "Dossiers",
"subtitle": "Gérez vos collections",
"newFolder": "Nouveau dossier",
"creating": "Création...",
"noFoldersYet": "Aucun dossier pour le moment",
"noFoldersYet": "Pas encore de dossiers",
"folderInfo": "ID : {id} • {totalPairs} paires",
"enterFolderName": "Entrez le nom du dossier :",
"confirmDelete": "Tapez \"{name}\" pour supprimer:"
"confirmDelete": "Tapez \"{name}\" pour supprimer :",
"myFolders": "Mes dossiers",
"publicFolders": "Dossiers publics",
"public": "Public",
"private": "Privé",
"setPublic": "Définir comme public",
"setPrivate": "Définir comme privé",
"publicFolderInfo": "{userName} • {totalPairs} paires",
"searchPlaceholder": "Rechercher des dossiers publics...",
"loading": "Chargement...",
"noPublicFolders": "Aucun dossier public trouvé",
"unknownUser": "Utilisateur inconnu",
"enterNewName": "Entrez le nouveau nom :",
"favorite": "Favori",
"unfavorite": "Retirer des favoris",
"pleaseLogin": "Veuillez vous connecter d'abord"
},
"decks": {
"title": "Decks",
"noDecks": "Pas encore de decks",
"deckName": "Nom du deck",
"totalCards": "Total des cartes",
"createdAt": "Créé le",
"actions": "Actions",
"view": "Voir",
"subtitle": "Gérer vos decks d'apprentissage",
"newDeck": "Nouveau deck",
"noDecksYet": "Pas encore de decks",
"loading": "Chargement...",
"deckInfo": "ID: {id} · {totalCards} cartes",
"enterDeckName": "Nom du deck:",
"enterNewName": "Nouveau nom:",
"confirmDelete": "Tapez \"{name}\" pour supprimer:",
"public": "Public",
"private": "Privé",
"setPublic": "Rendre public",
"setPrivate": "Rendre privé",
"importApkg": "Importer APKG",
"exportApkg": "Exporter APKG",
"clickToUpload": "Cliquez pour télécharger",
"apkgFilesOnly": "Fichiers .apkg uniquement",
"parsing": "Analyse...",
"foundDecks": "{count} decks trouvés",
"back": "Retour",
"import": "Importer",
"importing": "Import...",
"exportSuccess": "Export réussi",
"goToDecks": "Aller aux decks"
},
"folder_id": {
"unauthorized": "Vous n'êtes pas le propriétaire de ce dossier",
"back": "Retour",
"textPairs": "Paires de textes",
"textPairs": "Paires de texte",
"itemsCount": "{count} éléments",
"memorize": "Mémoriser",
"loadingTextPairs": "Chargement des paires de textes...",
"noTextPairs": "Aucune paire de textes dans ce dossier",
"addNewTextPair": "Ajouter une nouvelle paire de textes",
"loadingTextPairs": "Chargement des paires de texte...",
"noTextPairs": "Aucune paire de texte dans ce dossier",
"addNewTextPair": "Ajouter une nouvelle paire de texte",
"add": "Ajouter",
"updateTextPair": "Mettre à jour la paire de textes",
"updateTextPair": "Mettre à jour la paire de texte",
"update": "Mettre à jour",
"text1": "Texte 1",
"text2": "Texte 2",
@@ -54,17 +106,88 @@
"deleteFolder": "Vous n'avez pas la permission de supprimer ce dossier."
}
},
"deck_id": {
"unauthorized": "Vous n'êtes pas le propriétaire de ce deck",
"back": "Retour",
"cards": "Cartes",
"itemsCount": "{count} éléments",
"memorize": "Mémoriser",
"loadingCards": "Chargement des cartes...",
"noCards": "Aucune carte dans ce deck",
"card": "Carte",
"addNewCard": "Ajouter une nouvelle carte",
"add": "Ajouter",
"adding": "Ajout en cours...",
"updateCard": "Mettre à jour la carte",
"update": "Mettre à jour",
"updating": "Mise à jour en cours...",
"word": "Mot",
"definition": "Définition",
"ipa": "IPA",
"example": "Exemple",
"wordAndDefinitionRequired": "Le mot et la définition sont requis",
"edit": "Modifier",
"delete": "Supprimer",
"permissionDenied": "Vous n'avez pas la permission d'effectuer cette action",
"resetProgress": "Réinitialiser progression",
"resetProgressTitle": "Réinitialiser la progression",
"resetProgressConfirm": "Réinitialiser la progression?",
"resetSuccess": "Progression réinitialisée",
"resetting": "Réinitialisation...",
"cancel": "Annuler",
"settings": "Paramètres",
"settingsTitle": "Paramètres du deck",
"newPerDay": "Nouvelles par jour",
"newPerDayHint": "Nouvelles cartes par jour",
"revPerDay": "Révisions par jour",
"revPerDayHint": "Révisions par jour",
"save": "Enregistrer",
"saving": "Enregistrement...",
"settingsSaved": "Paramètres enregistrés",
"todayNew": "Nouvelles aujourd'hui",
"todayReview": "Révisions aujourd'hui",
"todayLearning": "En apprentissage",
"error": {
"update": "Pas autorisé à modifier",
"delete": "Pas autorisé à supprimer",
"add": "Pas autorisé à ajouter"
},
"ipaPlaceholder": "Entrer IPA",
"examplePlaceholder": "Entrer exemple",
"wordRequired": "Veuillez entrer un mot",
"definitionRequired": "Veuillez entrer une définition",
"cardAdded": "Carte ajoutée",
"cardType": "Type de carte",
"wordCard": "Carte mot",
"phraseCard": "Carte phrase",
"sentenceCard": "Carte phrase",
"sentence": "Phrase",
"sentencePlaceholder": "Entrer phrase",
"wordPlaceholder": "Entrer mot",
"queryLang": "Langue de requête",
"enterLanguageName": "Veuillez entrer le nom de la langue",
"english": "Anglais",
"chinese": "Chinois",
"japanese": "Japonais",
"korean": "Coréen",
"meanings": "Significations",
"addMeaning": "Ajouter signification",
"partOfSpeech": "Partie du discours",
"deleteConfirm": "Supprimer cette carte?",
"cardDeleted": "Carte supprimée",
"cardUpdated": "Carte mise à jour"
},
"home": {
"title": "Apprendre les langues",
"description": "Voici un site web très utile pour vous aider à apprendre presque toutes les langues du monde, y compris les langues construites.",
"description": "Voici un site Web très utile pour vous aider à apprendre presque toutes les langues du monde, y compris les langues construites.",
"explore": "Explorer",
"fortune": {
"quote": "Stay hungry, stay foolish.",
"quote": "Restez affamés, restez fous.",
"author": "— Steve Jobs"
},
"translator": {
"name": "Traducteur",
"description": "Traduire dans n'importe quelle langue et annoter avec l'alphabet phonétique international (API)"
"description": "Traduire vers n'importe quelle langue et annoter avec l'Alphabet Phonétique International (API)"
},
"textSpeaker": {
"name": "Lecteur de texte",
@@ -76,15 +199,15 @@
},
"alphabet": {
"name": "Alphabet",
"description": "Commencer à apprendre une nouvelle langue par l'alphabet"
"description": "Commencez à apprendre une nouvelle langue à partir de l'alphabet"
},
"memorize": {
"name": "Mémoriser",
"description": "Langue A vers langue B, langue B vers langue A, prend en charge la dictée"
"description": "Langue A vers Langue B, Langue B vers Langue A, prend en charge la dictée"
},
"dictionary": {
"name": "Dictionnaire",
"description": "Rechercher des mots et des phrases avec des définitions détaillées et des exemples"
"description": "Rechercher des mots et des expressions avec des définitions détaillées et des exemples"
},
"moreFeatures": {
"name": "Plus de fonctionnalités",
@@ -92,7 +215,8 @@
}
},
"auth": {
"title": "Authentification",
"title": "Se connecter",
"signUpTitle": "S'inscrire",
"signIn": "Se connecter",
"signUp": "S'inscrire",
"email": "E-mail",
@@ -111,78 +235,222 @@
"passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères",
"passwordsNotMatch": "Les mots de passe ne correspondent pas",
"nameRequired": "Veuillez entrer votre nom",
"usernameRequired": "Veuillez entrer votre nom d'utilisateur",
"usernameRequired": "Veuillez entrer un nom d'utilisateur",
"usernameTooShort": "Le nom d'utilisateur doit contenir au moins 3 caractères",
"usernameInvalid": "Le nom d'utilisateur ne peut contenir que des lettres, des chiffres et des underscores",
"emailRequired": "Veuillez entrer votre e-mail",
"identifierRequired": "Veuillez entrer votre e-mail ou nom d'utilisateur",
"passwordRequired": "Veuillez entrer votre mot de passe",
"confirmPasswordRequired": "Veuillez confirmer votre mot de passe",
"loading": "Chargement..."
"loading": "Chargement...",
"confirm": "Confirmer",
"noAccountLink": "Vous n'avez pas de compte ? Inscrivez-vous",
"hasAccountLink": "Vous avez déjà un compte ? Connectez-vous",
"usernamePlaceholder": "Nom d'utilisateur",
"emailPlaceholder": "Adresse e-mail",
"passwordPlaceholder": "Mot de passe",
"usernameOrEmailPlaceholder": "Nom d'utilisateur ou e-mail",
"loginFailed": "Échec de la connexion",
"signUpFailed": "Échec de l'inscription",
"fillAllFields": "Veuillez remplir tous les champs",
"enterCredentials": "Veuillez entrer le nom d'utilisateur et le mot de passe",
"forgotPassword": "Mot de passe oublié",
"forgotPasswordHint": "Entrez votre adresse e-mail et nous vous enverrons un lien pour réinitialiser votre mot de passe.",
"sendResetEmail": "Envoyer l'e-mail de réinitialisation",
"resetPasswordFailed": "Échec de l'envoi de l'e-mail de réinitialisation",
"resetPasswordEmailSent": "E-mail de réinitialisation envoyé avec succès",
"resetPasswordEmailSentHint": "Nous avons envoyé un lien de réinitialisation de mot de passe à votre adresse e-mail. Veuillez vérifier votre boîte de réception.",
"verifyYourEmail": "Vérifier votre e-mail",
"verificationEmailSent": "E-mail de vérification envoyé",
"verificationEmailSentHint": "Nous avons envoyé un e-mail de vérification à {email}. Veuillez cliquer sur le lien dans l'e-mail pour vérifier votre compte.",
"checkYourEmail": "Vérifiez votre e-mail",
"backToLogin": "Retour à la connexion",
"resetPassword": "Réinitialiser le mot de passe",
"newPassword": "Nouveau mot de passe",
"invalidToken": "Lien invalide ou expiré",
"invalidTokenHint": "Ce lien de réinitialisation de mot de passe est invalide ou a expiré. Veuillez en demander un nouveau.",
"requestNewToken": "Demander un nouveau lien de réinitialisation",
"resetPasswordSuccess": "Mot de passe réinitialisé avec succès",
"resetPasswordSuccessTitle": "Réinitialisation du mot de passe terminée",
"resetPasswordSuccessHint": "Votre mot de passe a été réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.",
"emailNotVerified": "Veuillez vérifier votre adresse e-mail",
"emailNotVerifiedHint": "Votre adresse e-mail n'a pas été vérifiée. Veuillez vérifier votre boîte de réception ou demander un nouvel e-mail de vérification.",
"resendVerification": "Renvoyer l'e-mail de vérification",
"resendSuccess": "E-mail de vérification envoyé ! Veuillez vérifier votre boîte de réception.",
"resendFailed": "Échec de l'envoi de l'e-mail de vérification"
},
"memorize": {
"folder_selector": {
"selectFolder": "Sélectionner un dossier",
"noFolders": "Aucun dossier trouvé",
"folderInfo": "{id}. {name} ({count})"
"deck_selector": {
"selectDeck": "Choisir deck",
"noDecks": "Pas de decks",
"goToDecks": "Aller aux decks",
"noCards": "Pas de cartes",
"new": "Nouveau",
"learning": "Apprentissage",
"review": "Révision",
"due": "À faire"
},
"memorize": {
"answer": "Réponse",
"next": "Suivant",
"reverse": "Inverser",
"dictation": "Dictée",
"noTextPairs": "Aucune paire de textes disponible",
"disorder": "Désordre",
"previous": "Précédent"
"review": {
"loading": "Chargement...",
"backToDecks": "Retour aux decks",
"allDone": "Tout terminé!",
"allDoneDesc": "Apprentissage terminé pour aujourd'hui!",
"reviewedCount": "{count} cartes révisées",
"progress": "{current} / {total}",
"nextReview": "Prochaine révision",
"interval": "Intervalle",
"ease": "Facilité",
"lapses": "Erreurs",
"showAnswer": "Montrer réponse",
"nextCard": "Suivant",
"again": "Encore",
"restart": "Recommencer",
"orderLimited": "Ordre limité",
"orderInfinite": "Ordre infini",
"randomLimited": "Aléatoire limité",
"randomInfinite": "Aléatoire infini",
"noIpa": "Pas d'IPA disponible"
},
"page": {
"unauthorized": "Vous n'êtes pas autorisé à accéder à ce dossier"
"unauthorized": "Non autorisé"
}
},
"navbar": {
"title": "learn-languages",
"sourceCode": "GitHub",
"sign_in": "Se connecter",
"sign_in": "Connexion",
"profile": "Profil",
"folders": "Dossiers"
"folders": "Decks",
"explore": "Explorer",
"favorites": "Favoris",
"settings": "Paramètres"
},
"ocr": {
"title": "Reconnaissance OCR",
"description": "Extraire le texte des images",
"uploadImage": "Télécharger image",
"dragDropHint": "Glisser-déposer",
"supportedFormats": "Formats: JPG, PNG, WEBP",
"selectDeck": "Choisir deck",
"chooseDeck": "Choisir un deck",
"noDecks": "Pas de decks disponibles",
"languageHints": "Indications de langue",
"sourceLanguageHint": "Langue source",
"targetLanguageHint": "Langue cible",
"process": "Traiter",
"processing": "Traitement...",
"preview": "Aperçu",
"extractedPairs": "Paires extraites",
"word": "Mot",
"definition": "Définition",
"pairsCount": "{count} paires",
"savePairs": "Enregistrer",
"saving": "Enregistrement...",
"saved": "Enregistré",
"saveFailed": "Échec de l'enregistrement",
"noImage": "Veuillez télécharger une image",
"noDeck": "Veuillez choisir un deck",
"processingFailed": "Traitement échoué",
"tryAgain": "Réessayer",
"detectedLanguages": "Langues détectées",
"uploadSection": "Télécharger image",
"dropOrClick": "Déposer ou cliquer",
"changeImage": "Changer image",
"invalidFileType": "Type de fichier invalide",
"deckSelection": "Choisir deck",
"sourceLanguagePlaceholder": "ex: Anglais",
"targetLanguagePlaceholder": "ex: Français",
"processButton": "Démarrer reconnaissance",
"resultsPreview": "Aperçu des résultats",
"saveButton": "Enregistrer dans le deck",
"ocrSuccess": "OCR réussi",
"ocrFailed": "OCR échoué",
"savedToDeck": "Enregistré dans le deck",
"noResultsToSave": "Pas de résultats",
"detectedSourceLanguage": "Langue source détectée",
"detectedTargetLanguage": "Langue cible détectée"
},
"profile": {
"myProfile": "Mon profil",
"email": "E-mail : {email}",
"logout": "Se déconnecter"
"logout": "Déconnexion"
},
"settings": {
"title": "Paramètres",
"themeColor": "Couleur du thème",
"themeColorDescription": "Choisissez votre couleur de thème préférée"
},
"srt_player": {
"uploadVideo": "Télécharger une vidéo",
"uploadSubtitle": "Télécharger des sous-titres",
"uploadVideo": "Télécharger la vidéo",
"uploadSubtitle": "Télécharger les sous-titres",
"pause": "Pause",
"play": "Lire",
"play": "Lecture",
"previous": "Précédent",
"next": "Suivant",
"restart": "Redémarrer",
"restart": "Recommencer",
"autoPause": "Pause automatique ({enabled})",
"uploadVideoAndSubtitle": "Veuillez télécharger des fichiers vidéo et de sous-titres",
"uploadVideoFile": "Veuillez télécharger un fichier vidéo",
"uploadSubtitleFile": "Veuillez télécharger un fichier de sous-titres",
"uploadVideoAndSubtitle": "Veuillez télécharger les fichiers vidéo et sous-titres",
"uploadVideoFile": "Veuillez télécharger le fichier vidéo",
"uploadSubtitleFile": "Veuillez télécharger le fichier de sous-titres",
"processingSubtitle": "Traitement du fichier de sous-titres...",
"needBothFiles": "Les fichiers vidéo et de sous-titres sont requis pour commencer l'apprentissage",
"needBothFiles": "Les fichiers vidéo et sous-titres sont tous deux requis pour commencer l'apprentissage",
"videoFile": "Fichier vidéo",
"subtitleFile": "Fichier de sous-titres",
"uploaded": "Téléchargé",
"notUploaded": "Non téléchargé",
"upload": "Télécharger",
"uploadVideoButton": "Télécharger la vidéo",
"uploadSubtitleButton": "Télécharger les sous-titres",
"subtitleUploaded": "Sous-titres téléchargés ({count} entrées)",
"subtitleNotUploaded": "Sous-titres non téléchargés",
"autoPauseStatus": "Pause automatique : {enabled}",
"on": "Activé",
"off": "Désactivé",
"videoUploadFailed": "Échec du téléchargement de la vidéo",
"subtitleUploadFailed": "Échec du téléchargement des sous-titres"
"subtitleUploadFailed": "Échec du téléchargement des sous-titres",
"subtitleLoadSuccess": "Sous-titres chargés avec succès",
"subtitleLoadFailed": "Échec du chargement des sous-titres",
"settings": "Paramètres",
"shortcuts": "Raccourcis",
"keyboardShortcuts": "Raccourcis clavier",
"playPause": "Lecture/Pause",
"autoPauseToggle": "Pause auto",
"subtitleSettings": "Paramètres sous-titres",
"fontSize": "Taille police",
"textColor": "Couleur texte",
"backgroundColor": "Couleur fond",
"position": "Position",
"opacity": "Opacité",
"top": "Haut",
"center": "Centre",
"bottom": "Bas"
},
"text_speaker": {
"generateIPA": "Générer l'API",
"viewSavedItems": "Voir les éléments enregistrés",
"confirmDeleteAll": "Êtes-vous sûr de vouloir tout supprimer? (O/N)"
"confirmDeleteAll": "Êtes-vous sûr de vouloir tout supprimer ? (O/N)",
"saved": "Enregistré",
"clearAll": "Tout effacer",
"language": "Langue",
"customLanguage": "ou entrer une langue...",
"languages": {
"auto": "Auto",
"chinese": "Chinois",
"english": "Anglais",
"japanese": "Japonais",
"korean": "Coréen",
"french": "Français",
"german": "Allemand",
"italian": "Italien",
"spanish": "Espagnol",
"portuguese": "Portugais",
"russian": "Russe"
}
},
"translator": {
"detectLanguage": "détecter la langue",
"sourceLanguage": "langue source",
"auto": "Auto",
"generateIPA": "générer l'api",
"translateInto": "traduire en",
"chinese": "Chinois",
@@ -200,45 +468,100 @@
"translate": "traduire",
"inputLanguage": "Entrez une langue.",
"history": "Historique",
"enterLanguage": "Entrer la langue",
"enterLanguage": "Entrez la langue",
"add_to_folder": {
"notAuthenticated": "Vous n'êtes pas authentifié",
"chooseFolder": "Choisir un dossier à ajouter",
"chooseFolder": "Choisissez un dossier à ajouter",
"noFolders": "Aucun dossier trouvé",
"folderInfo": "{id}. {name}",
"close": "Fermer",
"success": "Paire de textes ajoutée au dossier",
"error": "Échec de l'ajout de la paire de textes au dossier"
"success": "Paire de texte ajoutée au dossier",
"error": "Échec de l'ajout de la paire de texte au dossier"
},
"autoSave": "Sauvegarde automatique"
"autoSave": "Sauvegarde automatique",
"customLanguage": "ou tapez la langue...",
"pleaseLogin": "Connectez-vous pour sauvegarder",
"pleaseCreateDeck": "Créez d'abord un deck",
"noTranslationToSave": "Pas de traduction à sauvegarder",
"noDeckSelected": "Aucun deck sélectionné",
"saveAsCard": "Sauvegarder comme carte",
"selectDeck": "Sélectionner deck",
"front": "Recto",
"back": "Verso",
"cancel": "Annuler",
"save": "Sauvegarder",
"savedToDeck": "Carte sauvegardée dans {deckName}",
"saveFailed": "Échec de la sauvegarde"
},
"dictionary": {
"title": "Dictionnaire",
"description": "Rechercher des mots et des phrases avec des définitions détaillées et des exemples",
"searchPlaceholder": "Entrez un mot ou une phrase à rechercher...",
"description": "Rechercher des mots et des expressions avec des définitions détaillées et des exemples",
"searchPlaceholder": "Entrez un mot ou une expression à rechercher...",
"searching": "Recherche...",
"search": "Rechercher",
"languageSettings": "Paramètres linguistiques",
"languageSettings": "Paramètres de langue",
"queryLanguage": "Langue de requête",
"queryLanguageHint": "Quelle est la langue du mot/phrase que vous souhaitez rechercher",
"queryLanguageHint": "Dans quelle langue est le mot/l'expression que vous voulez rechercher",
"definitionLanguage": "Langue de définition",
"definitionLanguageHint": "Dans quelle langue souhaitez-vous voir les définitions",
"definitionLanguageHint": "Dans quelle langue voulez-vous les définitions",
"otherLanguagePlaceholder": "Ou entrez une autre langue...",
"other": "Autre",
"currentSettings": "Paramètres actuels : Requête {queryLang}, Définition {definitionLang}",
"relookup": "Rechercher à nouveau",
"saveToFolder": "Enregistrer dans le dossier",
"loading": "Chargement...",
"noResults": "Aucun résultat trouvé",
"tryOtherWords": "Essayez d'autres mots ou phrases",
"tryOtherWords": "Essayez d'autres mots ou expressions",
"welcomeTitle": "Bienvenue dans le dictionnaire",
"welcomeHint": "Entrez un mot ou une phrase dans la zone de recherche ci-dessus pour commencer",
"lookupFailed": "Recherche échouée, veuillez réessayer plus tard",
"relookupSuccess": "Recherche répétée avec succès",
"relookupFailed": "Nouvelle recherche de dictionnaire échouée",
"pleaseLogin": "Veuillez d'abord vous connecter",
"pleaseCreateFolder": "Veuillez d'abord créer un dossier",
"welcomeHint": "Entrez un mot ou une expression dans la zone de recherche ci-dessus pour commencer la recherche",
"lookupFailed": "La recherche a échoué, veuillez réessayer plus tard",
"relookupSuccess": "Recherche effectuée avec succès",
"relookupFailed": "La nouvelle recherche dans le dictionnaire a échoué",
"pleaseLogin": "Veuillez vous connecter d'abord",
"pleaseCreateFolder": "Veuillez créer un dossier d'abord",
"savedToFolder": "Enregistré dans le dossier : {folderName}",
"saveFailed": "Échec de l'enregistrement, veuillez réessayer plus tard"
"saveFailed": "Échec de l'enregistrement, veuillez réessayer plus tard",
"definition": "Définition",
"example": "Exemple"
},
"explore": {
"title": "Explorer",
"subtitle": "Découvrir les dossiers publics",
"searchPlaceholder": "Rechercher des dossiers publics...",
"loading": "Chargement...",
"noFolders": "Aucun dossier public trouvé",
"folderInfo": "{userName} • {totalPairs} paires",
"unknownUser": "Utilisateur inconnu",
"favorite": "Favori",
"unfavorite": "Retirer des favoris",
"pleaseLogin": "Veuillez vous connecter d'abord",
"sortByFavorites": "Trier par favoris",
"sortByFavoritesActive": "Annuler le tri par favoris",
"noDecks": "Pas de decks publics",
"deckInfo": "{userName} · {totalCards} cartes"
},
"exploreDetail": {
"title": "Détails du dossier",
"createdBy": "Créé par : {name}",
"unknownUser": "Utilisateur inconnu",
"totalPairs": "Total des paires",
"favorites": "Favoris",
"createdAt": "Créé le",
"viewContent": "Voir le contenu",
"favorite": "Favori",
"unfavorite": "Retirer des favoris",
"favorited": "Ajouté aux favoris",
"unfavorited": "Retiré des favoris",
"pleaseLogin": "Veuillez vous connecter d'abord",
"totalCards": "{count} cartes"
},
"favorites": {
"title": "Mes favoris",
"subtitle": "Les dossiers que vous avez mis en favoris",
"loading": "Chargement...",
"noFavorites": "Pas encore de favoris",
"folderInfo": "{userName} • {totalPairs} paires",
"unknownUser": "Utilisateur inconnu"
},
"user_profile": {
"anonymous": "Anonyme",
@@ -251,14 +574,40 @@
"displayName": "Nom d'affichage",
"notSet": "Non défini",
"memberSince": "Membre depuis",
"folders": {
"title": "Dossiers",
"noFolders": "Aucun dossier pour le moment",
"folderName": "Nom du dossier",
"totalPairs": "Nombre de paires",
"logout": "Déconnexion",
"deleteAccount": {
"button": "Supprimer le compte",
"title": "Supprimer le compte",
"warning": "Cette action est irréversible. Toutes vos données seront définitivement supprimées.",
"warningDecks": "Tous vos decks et cartes",
"warningCards": "Tout votre progression d'apprentissage",
"warningHistory": "Tout votre historique de traduction et de dictionnaire",
"warningPermanent": "Cette action ne peut pas être annulée",
"confirmLabel": "Tapez votre nom d'utilisateur pour confirmer :",
"usernameMismatch": "Le nom d'utilisateur ne correspond pas",
"cancel": "Annuler",
"confirm": "Supprimer mon compte",
"success": "Compte supprimé avec succès",
"failed": "Échec de la suppression du compte"
},
"decks": {
"title": "Decks",
"noDecks": "Pas encore de decks",
"deckName": "Nom du deck",
"totalCards": "Total des cartes",
"createdAt": "Créé le",
"actions": "Actions",
"view": "Voir"
}
},
"joined": "Inscrit le"
},
"follow": {
"follow": "Suivre",
"following": "Abonné",
"followers": "Abonnés",
"followersOf": "Abonnés de {username}",
"followingOf": "Abonnements de {username}",
"noFollowers": "Pas encore d'abonnés",
"noFollowing": "Ne suit personne"
}
}

View File

@@ -1,48 +1,100 @@
{
"alphabet": {
"chooseCharacters": "Seleziona i caratteri che desideri imparare",
"japanese": "Kana giapponese",
"english": "Alfabeto inglese",
"uyghur": "Alfabeto uiguro",
"esperanto": "Alfabeto esperanto",
"chooseCharacters": "Seleziona i caratteri che vuoi imparare",
"chooseAlphabetHint": "Seleziona un alfabeto per iniziare a imparare",
"japanese": "Kana Giapponese",
"english": "Alfabeto Inglese",
"uyghur": "Alfabeto Uiguro",
"esperanto": "Alfabeto Esperanto",
"loading": "Caricamento...",
"loadFailed": "Caricamento fallito, riprova",
"hideLetter": "Nascondi lettera",
"showLetter": "Mostra lettera",
"hideLetter": "Nascondi Lettera",
"showLetter": "Mostra Lettera",
"hideIPA": "Nascondi IPA",
"showIPA": "Mostra IPA",
"roman": "Romanizzazione",
"letter": "Lettera",
"random": "Modalità casuale",
"randomNext": "Successivo casuale"
"random": "Modalità Casuale",
"randomNext": "Prossimo Casuale",
"previousLetter": "Lettera precedente",
"nextLetter": "Lettera successiva",
"keyboardHint": "Usa le frecce sinistra/destra o spazio per casuale, ESC per tornare indietro",
"swipeHint": "Usa le frecce sinistra/destra o scorri per navigare, ESC per tornare indietro"
},
"folders": {
"title": "Cartelle",
"subtitle": "Gestisci le tue collezioni",
"newFolder": "Nuova cartella",
"newFolder": "Nuova Cartella",
"creating": "Creazione...",
"noFoldersYet": "Nessuna cartella ancora",
"folderInfo": "ID: {id} • {totalPairs} coppie",
"enterFolderName": "Inserisci nome cartella:",
"confirmDelete": "Digita \"{name}\" per eliminare:"
"enterFolderName": "Inserisci il nome della cartella:",
"confirmDelete": "Digita \"{name}\" per eliminare:",
"myFolders": "Le Mie Cartelle",
"publicFolders": "Cartelle Pubbliche",
"public": "Pubblica",
"private": "Privata",
"setPublic": "Imposta Pubblica",
"setPrivate": "Imposta Privata",
"publicFolderInfo": "{userName} • {totalPairs} coppie",
"searchPlaceholder": "Cerca cartelle pubbliche...",
"loading": "Caricamento...",
"noPublicFolders": "Nessuna cartella pubblica trovata",
"unknownUser": "Utente Sconosciuto",
"enterNewName": "Inserisci nuovo nome:",
"favorite": "Preferito",
"unfavorite": "Rimuovi dai preferiti",
"pleaseLogin": "Per favore accedi prima"
},
"decks": {
"title": "Mazzi",
"noDecks": "Nessun mazzo ancora",
"deckName": "Nome del mazzo",
"totalCards": "Totale carte",
"createdAt": "Creato il",
"actions": "Azioni",
"view": "Visualizza",
"subtitle": "Gestisci i tuoi deck",
"newDeck": "Nuovo deck",
"noDecksYet": "Nessun deck ancora",
"loading": "Caricamento...",
"deckInfo": "ID: {id} · {totalCards} carte",
"enterDeckName": "Nome deck:",
"enterNewName": "Nuovo nome:",
"confirmDelete": "Digita \"{name}\" per eliminare:",
"public": "Pubblico",
"private": "Privato",
"setPublic": "Rendi pubblico",
"setPrivate": "Rendi privato",
"importApkg": "Importa APKG",
"exportApkg": "Esporta APKG",
"clickToUpload": "Clicca per caricare",
"apkgFilesOnly": "Solo file .apkg",
"parsing": "Analisi...",
"foundDecks": "{count} deck trovati",
"back": "Indietro",
"import": "Importa",
"importing": "Importazione...",
"exportSuccess": "Esportazione riuscita",
"goToDecks": "Vai ai deck"
},
"folder_id": {
"unauthorized": "Non sei il proprietario di questa cartella",
"back": "Indietro",
"textPairs": "Coppie di testi",
"textPairs": "Coppie di Testo",
"itemsCount": "{count} elementi",
"memorize": "Memorizza",
"loadingTextPairs": "Caricamento coppie di testi...",
"noTextPairs": "Nessuna coppia di testi in questa cartella",
"addNewTextPair": "Aggiungi nuova coppia di testi",
"loadingTextPairs": "Caricamento coppie di testo...",
"noTextPairs": "Nessuna coppia di testo in questa cartella",
"addNewTextPair": "Aggiungi Nuova Coppia di Testo",
"add": "Aggiungi",
"updateTextPair": "Aggiorna coppia di testi",
"updateTextPair": "Aggiorna Coppia di Testo",
"update": "Aggiorna",
"text1": "Testo 1",
"text2": "Testo 2",
"language1": "Lingua 1",
"language2": "Lingua 2",
"enterLanguageName": "Inserisci il nome della lingua",
"language1": "Locale 1",
"language2": "Locale 2",
"enterLanguageName": "Per favore inserisci il nome della lingua",
"edit": "Modifica",
"delete": "Elimina",
"permissionDenied": "Non hai il permesso di eseguire questa azione",
@@ -54,9 +106,80 @@
"deleteFolder": "Non hai il permesso di eliminare questa cartella."
}
},
"deck_id": {
"unauthorized": "Non sei il proprietario di questo deck",
"back": "Indietro",
"cards": "Schede",
"itemsCount": "{count} elementi",
"memorize": "Memorizza",
"loadingCards": "Caricamento schede...",
"noCards": "Nessuna scheda in questo deck",
"card": "Scheda",
"addNewCard": "Aggiungi nuova scheda",
"add": "Aggiungi",
"adding": "Aggiunta in corso...",
"updateCard": "Aggiorna scheda",
"update": "Aggiorna",
"updating": "Aggiornamento in corso...",
"word": "Parola",
"definition": "Definizione",
"ipa": "IPA",
"example": "Esempio",
"wordAndDefinitionRequired": "Parola e definizione sono obbligatori",
"edit": "Modifica",
"delete": "Elimina",
"permissionDenied": "Non hai il permesso per questa accion",
"resetProgress": "Reimposta progresso",
"resetProgressTitle": "Reimposta progresso di apprendimento",
"resetProgressConfirm": "Reimpostare il progresso?",
"resetSuccess": "Progresso reimpostato",
"resetting": "Reimpostazione...",
"cancel": "Annulla",
"settings": "Impostazioni",
"settingsTitle": "Impostazioni deck",
"newPerDay": "Nuove al giorno",
"newPerDayHint": "Nuove carte al giorno",
"revPerDay": "Ripassate al giorno",
"revPerDayHint": "Ripassi al giorno",
"save": "Salva",
"saving": "Salvataggio...",
"settingsSaved": "Impostazioni salvate",
"todayNew": "Oggi nuove",
"todayReview": "Oggi ripasso",
"todayLearning": "In apprendimento",
"error": {
"update": "Nessun permesso di aggiornare",
"delete": "Nessun permesso di eliminare",
"add": "Nessun permesso di aggiungere"
},
"ipaPlaceholder": "Inserisci IPA",
"examplePlaceholder": "Inserisci esempio",
"wordRequired": "Inserisci una parola",
"definitionRequired": "Inserisci una definizione",
"cardAdded": "Carta aggiunta",
"cardType": "Tipo di carta",
"wordCard": "Carta parola",
"phraseCard": "Carta frase",
"sentenceCard": "Carta frase",
"sentence": "Frase",
"sentencePlaceholder": "Inserisci frase",
"wordPlaceholder": "Inserisci parola",
"queryLang": "Lingua di query",
"enterLanguageName": "Inserisci il nome della lingua",
"english": "Inglese",
"chinese": "Cinese",
"japanese": "Giapponese",
"korean": "Coreano",
"meanings": "Significati",
"addMeaning": "Aggiungi significato",
"partOfSpeech": "Parte del discorso",
"deleteConfirm": "Eliminare questa carta?",
"cardDeleted": "Carta eliminata",
"cardUpdated": "Carta aggiornata"
},
"home": {
"title": "Impara le lingue",
"description": "Questo è un sito web molto utile che ti aiuta a imparare quasi tutte le lingue del mondo, incluse quelle costruite.",
"title": "Impara le Lingue",
"description": "Ecco un sito molto utile per aiutarti a imparare quasi tutte le lingue del mondo, incluse quelle costruite.",
"explore": "Esplora",
"fortune": {
"quote": "Stay hungry, stay foolish.",
@@ -64,15 +187,15 @@
},
"translator": {
"name": "Traduttore",
"description": "Traduci in qualsiasi lingua e annota con l'alfabeto fonetico internazionale (IPA)"
"description": "Traduci in qualsiasi lingua e annota con l'Alfabeto Fonetico Internazionale (IPA)"
},
"textSpeaker": {
"name": "Lettore di testo",
"description": "Riconosce e legge il testo ad alta voce, supporta la riproduzione in loop e la regolazione della velocità"
"name": "Lettore Testo",
"description": "Riconosci e leggi il testo ad alta voce, supporta riproduzione in loop e regolazione della velocità"
},
"srtPlayer": {
"name": "Lettore video SRT",
"description": "Riproduci video frase per frase basandoti su file di sottotitoli SRT per imitare la pronuncia dei madrelingua"
"name": "Lettore Video SRT",
"description": "Riproduci video frase per frase basandoti sui file di sottotitoli SRT per imitare la pronuncia dei madrelingua"
},
"alphabet": {
"name": "Alfabeto",
@@ -80,63 +203,141 @@
},
"memorize": {
"name": "Memorizza",
"description": "Lingua A verso lingua B, lingua B verso lingua A, supporta dettatura"
"description": "Lingua A a Lingua B, Lingua B a Lingua A, supporta dettatura"
},
"dictionary": {
"name": "Dizionario",
"description": "Cerca parole e frasi con definizioni dettagliate ed esempi"
},
"moreFeatures": {
"name": "Altre funzionalità",
"description": "In sviluppo, rimani sintonizzato"
"name": "Altre Funzionalità",
"description": "In sviluppo, resta sintonizzato"
}
},
"auth": {
"title": "Autenticazione",
"title": "Accedi",
"signUpTitle": "Registrati",
"signIn": "Accedi",
"signUp": "Registrati",
"email": "Email",
"password": "Password",
"confirmPassword": "Conferma password",
"confirmPassword": "Conferma Password",
"name": "Nome",
"username": "Nome utente",
"emailOrUsername": "Email o nome utente",
"username": "Nome Utente",
"emailOrUsername": "Email o Nome Utente",
"signInButton": "Accedi",
"signUpButton": "Registrati",
"noAccount": "Non hai un account?",
"hasAccount": "Hai già un account?",
"signInWithGitHub": "Accedi con GitHub",
"signUpWithGitHub": "Registrati con GitHub",
"invalidEmail": "Inserisci un indirizzo email valido",
"invalidEmail": "Per favore inserisci un indirizzo email valido",
"passwordTooShort": "La password deve essere di almeno 8 caratteri",
"passwordsNotMatch": "Le password non corrispondono",
"nameRequired": "Inserisci il tuo nome",
"usernameRequired": "Inserisci il tuo nome utente",
"nameRequired": "Per favore inserisci il tuo nome",
"usernameRequired": "Per favore inserisci un nome utente",
"usernameTooShort": "Il nome utente deve essere di almeno 3 caratteri",
"usernameInvalid": "Il nome utente può contenere solo lettere, numeri e underscore",
"emailRequired": "Inserisci la tua email",
"identifierRequired": "Inserisci la tua email o nome utente",
"passwordRequired": "Inserisci la tua password",
"confirmPasswordRequired": "Conferma la tua password",
"loading": "Caricamento..."
"usernameInvalid": "Il nome utente può contenere solo lettere, numeri e trattini bassi",
"emailRequired": "Per favore inserisci la tua email",
"identifierRequired": "Per favore inserisci la tua email o nome utente",
"passwordRequired": "Per favore inserisci la tua password",
"confirmPasswordRequired": "Per favore conferma la tua password",
"loading": "Caricamento...",
"confirm": "Conferma",
"noAccountLink": "Non hai un account? Registrati",
"hasAccountLink": "Hai già un account? Accedi",
"usernamePlaceholder": "Nome utente",
"emailPlaceholder": "Indirizzo email",
"passwordPlaceholder": "Password",
"usernameOrEmailPlaceholder": "Nome utente o email",
"loginFailed": "Accesso fallito",
"signUpFailed": "Registrazione fallita",
"fillAllFields": "Per favore compila tutti i campi",
"enterCredentials": "Per favore inserisci nome utente e password",
"forgotPassword": "Password Dimenticata",
"forgotPasswordHint": "Inserisci il tuo indirizzo email e ti invieremo un link per reimpostare la password.",
"sendResetEmail": "Invia Email di Reset",
"resetPasswordFailed": "Impossibile inviare email di reset",
"resetPasswordEmailSent": "Email di reset inviata con successo",
"resetPasswordEmailSentHint": "Abbiamo inviato un link per reimpostare la password al tuo indirizzo email. Controlla la tua casella di posta.",
"verifyYourEmail": "Verifica la tua Email",
"verificationEmailSent": "Email di verifica inviata",
"verificationEmailSentHint": "Abbiamo inviato un'email di verifica a {email}. Clicca sul link nell'email per verificare il tuo account.",
"checkYourEmail": "Controlla la tua Email",
"backToLogin": "Torna al Login",
"resetPassword": "Reimposta Password",
"newPassword": "Nuova Password",
"invalidToken": "Link Non Valido o Scaduto",
"invalidTokenHint": "Questo link per reimpostare la password non è valido o è scaduto. Richiedine uno nuovo.",
"requestNewToken": "Richiedi Nuovo Link di Reset",
"resetPasswordSuccess": "Password reimpostata con successo",
"resetPasswordSuccessTitle": "Reimpostazione Password Completata",
"resetPasswordSuccessHint": "La tua password è stata reimpostata con successo. Ora puoi accedere con la tua nuova password.",
"emailNotVerified": "Verifica il tuo indirizzo email",
"emailNotVerifiedHint": "Il tuo indirizzo email non è stato verificato. Controlla la tua casella di posta o richiedi una nuova email di verifica.",
"resendVerification": "Invia di nuovo email di verifica",
"resendSuccess": "Email di verifica inviata! Controlla la tua casella di posta.",
"resendFailed": "Impossibile inviare l'email di verifica"
},
"memorize": {
"folder_selector": {
"selectFolder": "Seleziona una cartella",
"noFolders": "Nessuna cartella trovata",
"folderInfo": "{id}. {name} ({count})"
"deck_selector": {
"selectDeck": "Seleziona deck",
"noDecks": "Nessun deck",
"goToDecks": "Vai ai deck",
"noCards": "Nessuna carta",
"new": "Nuovo",
"learning": "Apprendimento",
"review": "Ripasso",
"due": "In scadenza"
},
"memorize": {
"answer": "Risposta",
"next": "Successivo",
"review": {
"loading": "Caricamento...",
"backToDecks": "Torna ai deck",
"allDone": "Tutto fatto!",
"allDoneDesc": "Apprendimento di oggi completato!",
"reviewedCount": "{count} carte ripassate",
"progress": "{current} / {total}",
"nextReview": "Prossimo ripasso",
"interval": "Intervallo",
"ease": "Difficoltà",
"lapses": "Errori",
"showAnswer": "Mostra risposta",
"nextCard": "Prossima",
"again": "Ancora",
"restart": "Ricomincia",
"hard": "Difficile",
"good": "Buono",
"easy": "Facile",
"now": "Ora",
"lessThanMinute": "meno di 1 minuto",
"inMinutes": "tra {n} minuti",
"inHours": "tra {n} ore",
"inDays": "tra {n} giorni",
"inMonths": "tra {n} mesi",
"minutes": "minuti",
"days": "giorni",
"months": "mesi",
"minAbbr": "min",
"dayAbbr": "g",
"cardTypeNew": "Nuovo",
"cardTypeLearning": "Apprendimento",
"cardTypeReview": "Ripasso",
"cardTypeRelearning": "Riapprendimento",
"reverse": "Inverti",
"dictation": "Dettatura",
"noTextPairs": "Nessuna coppia di testi disponibile",
"disorder": "Disordine",
"previous": "Precedente"
"dictation": "Dettato",
"clickToPlay": "Clicca per riprodurre",
"yourAnswer": "La tua risposta",
"typeWhatYouHear": "Scrivi cosa senti",
"correct": "Corretto!",
"incorrect": "Errato",
"orderLimited": "Ordine limitato",
"orderInfinite": "Ordine infinito",
"randomLimited": "Casuale limitato",
"randomInfinite": "Casuale infinito",
"noIpa": "Nessun IPA disponibile"
},
"page": {
"unauthorized": "Non sei autorizzato ad accedere a questa cartella"
"unauthorized": "Non autorizzato"
}
},
"navbar": {
@@ -144,45 +345,137 @@
"sourceCode": "GitHub",
"sign_in": "Accedi",
"profile": "Profilo",
"folders": "Cartelle"
"folders": "Mazzi",
"explore": "Esplora",
"favorites": "Preferiti",
"settings": "Impostazioni"
},
"ocr": {
"title": "Riconoscimento OCR",
"description": "Estrai testo dalle immagini",
"uploadImage": "Carica immagine",
"dragDropHint": "Trascina e rilascia",
"supportedFormats": "Supportati: JPG, PNG, WEBP",
"selectDeck": "Seleziona deck",
"chooseDeck": "Scegli un deck",
"noDecks": "Nessun deck disponibile",
"languageHints": "Suggerimenti lingua",
"sourceLanguageHint": "Lingua sorgente",
"targetLanguageHint": "Lingua target",
"process": "Elabora",
"processing": "Elaborazione...",
"preview": "Anteprima",
"extractedPairs": "Coppie estratte",
"word": "Parola",
"definition": "Definizione",
"pairsCount": "{count} coppie",
"savePairs": "Salva",
"saving": "Salvataggio...",
"saved": "Salvato",
"saveFailed": "Salvataggio fallito",
"noImage": "Carica un'immagine",
"noDeck": "Seleziona un deck",
"processingFailed": "Elaborazione fallita",
"tryAgain": "Riprova",
"detectedLanguages": "Lingue rilevate",
"uploadSection": "Carica immagine",
"dropOrClick": "Rilascia o clicca",
"changeImage": "Cambia immagine",
"invalidFileType": "Tipo di file non valido",
"deckSelection": "Seleziona deck",
"sourceLanguagePlaceholder": "es: Inglese",
"targetLanguagePlaceholder": "es: Italiano",
"processButton": "Avvia riconoscimento",
"resultsPreview": "Anteprima risultati",
"saveButton": "Salva nel deck",
"ocrSuccess": "OCR riuscito",
"ocrFailed": "OCR fallito",
"savedToDeck": "Salvato nel deck",
"noResultsToSave": "Nessun risultato",
"detectedSourceLanguage": "Lingua sorgente rilevata",
"detectedTargetLanguage": "Lingua target rilevata"
},
"profile": {
"myProfile": "Il mio profilo",
"myProfile": "Il Mio Profilo",
"email": "Email: {email}",
"logout": "Esci"
},
"settings": {
"title": "Impostazioni",
"themeColor": "Colore del tema",
"themeColorDescription": "Scegli il tuo colore del tema preferito"
},
"srt_player": {
"uploadVideo": "Carica video",
"uploadSubtitle": "Carica sottotitoli",
"uploadVideo": "Carica Video",
"uploadSubtitle": "Carica Sottotitoli",
"pause": "Pausa",
"play": "Riproduci",
"previous": "Precedente",
"next": "Successivo",
"restart": "Riavvia",
"autoPause": "Pausa automatica ({enabled})",
"uploadVideoAndSubtitle": "Carica i file video e sottotitoli",
"uploadVideoFile": "Carica un file video",
"uploadSubtitleFile": "Carica un file di sottotitoli",
"autoPause": "Pausa Automatica ({enabled})",
"uploadVideoAndSubtitle": "Per favore carica file video e sottotitoli",
"uploadVideoFile": "Per favore carica il file video",
"uploadSubtitleFile": "Per favore carica il file sottotitoli",
"processingSubtitle": "Elaborazione file sottotitoli...",
"needBothFiles": "Sono richiesti sia i file video che i sottotitoli per iniziare l'apprendimento",
"videoFile": "File video",
"subtitleFile": "File sottotitoli",
"needBothFiles": "Sono richiesti sia il file video che quello dei sottotitoli per iniziare a imparare",
"videoFile": "File Video",
"subtitleFile": "File Sottotitoli",
"uploaded": "Caricato",
"notUploaded": "Non caricato",
"notUploaded": "Non Caricato",
"upload": "Carica",
"autoPauseStatus": "Pausa automatica: {enabled}",
"uploadVideoButton": "Carica Video",
"uploadSubtitleButton": "Carica Sottotitoli",
"subtitleUploaded": "Sottotitoli Caricati ({count} voci)",
"subtitleNotUploaded": "Sottotitoli Non Caricati",
"autoPauseStatus": "Pausa Automatica: {enabled}",
"on": "Attivo",
"off": "Disattivo",
"videoUploadFailed": "Caricamento video fallito",
"subtitleUploadFailed": "Caricamento sottotitoli fallito"
"subtitleUploadFailed": "Caricamento sottotitoli fallito",
"subtitleLoadSuccess": "Sottotitoli caricati con successo",
"subtitleLoadFailed": "Caricamento sottotitoli fallito",
"settings": "Impostazioni",
"shortcuts": "Scorciatoie",
"keyboardShortcuts": "Scorciatoie tastiera",
"playPause": "Riproduci/Pausa",
"autoPauseToggle": "Auto-pausa",
"subtitleSettings": "Impostazioni sottotitoli",
"fontSize": "Dimensione carattere",
"textColor": "Colore testo",
"backgroundColor": "Colore sfondo",
"position": "Posizione",
"opacity": "Opacità",
"top": "Alto",
"center": "Centro",
"bottom": "Basso"
},
"text_speaker": {
"generateIPA": "Genera IPA",
"viewSavedItems": "Visualizza elementi salvati",
"confirmDeleteAll": "Sei sicuro di voler eliminare tutto? (S/N)"
"viewSavedItems": "Visualizza Elementi Salvati",
"confirmDeleteAll": "Sei sicuro di voler eliminare tutto? (S/N)",
"saved": "Salvato",
"clearAll": "Cancella tutto",
"language": "Lingua",
"customLanguage": "o inserisci lingua...",
"languages": {
"auto": "Auto",
"chinese": "Cinese",
"english": "Inglese",
"japanese": "Giapponese",
"korean": "Coreano",
"french": "Francese",
"german": "Tedesco",
"italian": "Italiano",
"spanish": "Spagnolo",
"portuguese": "Portoghese",
"russian": "Russo"
}
},
"translator": {
"detectLanguage": "rileva lingua",
"sourceLanguage": "lingua di origine",
"auto": "Auto",
"generateIPA": "genera ipa",
"translateInto": "traduci in",
"chinese": "Cinese",
@@ -203,14 +496,27 @@
"enterLanguage": "Inserisci lingua",
"add_to_folder": {
"notAuthenticated": "Non sei autenticato",
"chooseFolder": "Scegli una cartella a cui aggiungere",
"chooseFolder": "Scegli una Cartella a cui Aggiungere",
"noFolders": "Nessuna cartella trovata",
"folderInfo": "{id}. {name}",
"close": "Chiudi",
"success": "Coppia di testi aggiunta alla cartella",
"error": "Impossibile aggiungere la coppia di testi alla cartella"
"success": "Coppia di testo aggiunta alla cartella",
"error": "Impossibile aggiungere coppia di testo alla cartella"
},
"autoSave": "Salvataggio automatico"
"autoSave": "Salvataggio Automatico",
"customLanguage": "o digita lingua...",
"pleaseLogin": "Accedi per salvare le carte",
"pleaseCreateDeck": "Crea prima un deck",
"noTranslationToSave": "Nessuna traduzione da salvare",
"noDeckSelected": "Nessun deck selezionato",
"saveAsCard": "Salva come carta",
"selectDeck": "Seleziona deck",
"front": "Fronte",
"back": "Retro",
"cancel": "Annulla",
"save": "Salva",
"savedToDeck": "Carta salvata in {deckName}",
"saveFailed": "Salvataggio fallito"
},
"dictionary": {
"title": "Dizionario",
@@ -218,47 +524,115 @@
"searchPlaceholder": "Inserisci una parola o frase da cercare...",
"searching": "Ricerca...",
"search": "Cerca",
"languageSettings": "Impostazioni lingua",
"queryLanguage": "Lingua di interrogazione",
"queryLanguageHint": "Quale è la lingua della parola/frase che vuoi cercare",
"definitionLanguage": "Lingua di definizione",
"definitionLanguageHint": "In quale lingua vuoi vedere le definizioni",
"languageSettings": "Impostazioni Lingua",
"queryLanguage": "Lingua di Query",
"queryLanguageHint": "In che lingua è la parola/frase che vuoi cercare",
"definitionLanguage": "Lingua delle Definizioni",
"definitionLanguageHint": "In che lingua vuoi le definizioni",
"otherLanguagePlaceholder": "Oppure inserisci un'altra lingua...",
"currentSettings": "Impostazioni attuali: Interrogazione {queryLang}, Definizione {definitionLang}",
"other": "Altro",
"currentSettings": "Impostazioni attuali: Query {queryLang}, Definizione {definitionLang}",
"relookup": "Ricerca di nuovo",
"saveToFolder": "Salva nella cartella",
"loading": "Caricamento...",
"noResults": "Nessun risultato trovato",
"tryOtherWords": "Prova altre parole o frasi",
"welcomeTitle": "Benvenuto nel dizionario",
"welcomeHint": "Inserisci una parola o frase nella casella di ricerca sopra per iniziare",
"welcomeTitle": "Benvenuto nel Dizionario",
"welcomeHint": "Inserisci una parola o frase nella casella di ricerca sopra per iniziare a cercare",
"lookupFailed": "Ricerca fallita, riprova più tardi",
"relookupSuccess": "Ricerca ripetuta con successo",
"relookupFailed": "Nuova ricerca del dizionario fallita",
"pleaseLogin": "Accedi prima",
"pleaseCreateFolder": "Crea prima una cartella",
"relookupSuccess": "Ricerca effettuata con successo",
"relookupFailed": "Ricerca dizionario fallita",
"pleaseLogin": "Per favore accedi prima",
"pleaseCreateFolder": "Per favore crea prima una cartella",
"savedToFolder": "Salvato nella cartella: {folderName}",
"saveFailed": "Salvataggio fallito, riprova più tardi"
"saveFailed": "Salvataggio fallito, riprova più tardi",
"definition": "Definizione",
"example": "Esempio"
},
"explore": {
"title": "Esplora",
"subtitle": "Scopri cartelle pubbliche",
"searchPlaceholder": "Cerca cartelle pubbliche...",
"loading": "Caricamento...",
"noFolders": "Nessuna cartella pubblica trovata",
"folderInfo": "{userName} • {totalPairs} coppie",
"unknownUser": "Utente Sconosciuto",
"favorite": "Preferito",
"unfavorite": "Rimuovi dai preferiti",
"pleaseLogin": "Per favore accedi prima",
"sortByFavorites": "Ordina per preferiti",
"sortByFavoritesActive": "Annulla ordinamento per preferiti",
"noDecks": "Nessun deck pubblico",
"deckInfo": "{userName} · {totalCards} carte"
},
"exploreDetail": {
"title": "Dettagli Cartella",
"createdBy": "Creata da: {name}",
"unknownUser": "Utente Sconosciuto",
"totalPairs": "Coppie Totali",
"favorites": "Preferiti",
"createdAt": "Creata Il",
"viewContent": "Visualizza Contenuto",
"favorite": "Preferito",
"unfavorite": "Rimuovi dai preferiti",
"favorited": "Aggiunto ai preferiti",
"unfavorited": "Rimosso dai preferiti",
"pleaseLogin": "Per favore accedi prima",
"totalCards": "{count} carte"
},
"favorites": {
"title": "I Miei Preferiti",
"subtitle": "Cartelle che hai aggiunto ai preferiti",
"loading": "Caricamento...",
"noFavorites": "Nessun preferito ancora",
"folderInfo": "{userName} • {totalPairs} coppie",
"unknownUser": "Utente Sconosciuto"
},
"user_profile": {
"anonymous": "Anonimo",
"email": "Email",
"verified": "Verificato",
"unverified": "Non verificato",
"accountInfo": "Informazioni account",
"userId": "ID utente",
"username": "Nome utente",
"displayName": "Nome visualizzato",
"notSet": "Non impostato",
"memberSince": "Membro dal",
"folders": {
"title": "Cartelle",
"noFolders": "Nessuna cartella ancora",
"folderName": "Nome cartella",
"totalPairs": "Numero di coppie",
"createdAt": "Creato il",
"unverified": "Non Verificato",
"accountInfo": "Informazioni Account",
"userId": "ID Utente",
"username": "Nome Utente",
"displayName": "Nome Visualizzato",
"notSet": "Non Impostato",
"memberSince": "Membro Dal",
"logout": "Esci",
"deleteAccount": {
"button": "Elimina Account",
"title": "Elimina Account",
"warning": "Questa azione è irreversibile. Tutti i tuoi dati saranno eliminati definitivamente.",
"warningDecks": "Tutti i tuoi mazzi e le tue carte",
"warningCards": "Tutto il tuo progresso di apprendimento",
"warningHistory": "Tutto il tuo cronologia di traduzione e dizionario",
"warningPermanent": "Questa azione non può essere annullata",
"confirmLabel": "Digita il tuo nome utente per confermare:",
"usernameMismatch": "Il nome utente non corrisponde",
"cancel": "Annulla",
"confirm": "Elimina il mio account",
"success": "Account eliminato con successo",
"failed": "Impossibile eliminare l'account"
},
"decks": {
"title": "Mazzi",
"noDecks": "Nessun mazzo ancora",
"deckName": "Nome del mazzo",
"totalCards": "Totale carte",
"createdAt": "Creata Il",
"actions": "Azioni",
"view": "Visualizza"
}
},
"joined": "Iscritto il"
},
"follow": {
"follow": "Segui",
"following": "Stai seguendo",
"followers": "Seguaci",
"followersOf": "Seguaci di {username}",
"followingOf": "Seguiti da {username}",
"noFollowers": "Nessun seguace ancora",
"noFollowing": "Non segui ancora nessuno"
}
}

View File

@@ -1,10 +1,11 @@
{
"alphabet": {
"chooseCharacters": "学習したい文字を選択してください",
"chooseAlphabetHint": "学習を始めるアルファベットを選択してください",
"japanese": "日本語仮名",
"english": "英語アルファベット",
"uyghur": "ウイグル文字",
"esperanto": "エスペラント文字",
"uyghur": "ウイグル語アルファベット",
"esperanto": "エスペラント語アルファベット",
"loading": "読み込み中...",
"loadFailed": "読み込みに失敗しました。もう一度お試しください",
"hideLetter": "文字を非表示",
@@ -14,20 +15,39 @@
"roman": "ローマ字",
"letter": "文字",
"random": "ランダムモード",
"randomNext": "ランダム次へ"
"randomNext": "ランダム次へ",
"previousLetter": "前の文字",
"nextLetter": "次の文字",
"keyboardHint": "左右の矢印キーまたはスペースキーでランダム移動、ESCで戻る",
"swipeHint": "左右の矢印キーまたはスワイプで移動、ESCで戻る"
},
"folders": {
"title": "フォルダー",
"subtitle": "コレクションを管理",
"newFolder": "新規フォルダー",
"creating": "作成中...",
"noFoldersYet": "フォルダーがありません",
"folderInfo": "ID: {id} • {totalPairs}",
"noFoldersYet": "まだフォルダーがありません",
"folderInfo": "ID: {id} • {totalPairs} ペア",
"enterFolderName": "フォルダー名を入力:",
"confirmDelete": "削除するには「{name}」と入力してください:"
"confirmDelete": "削除するには「{name}」と入力してください:",
"myFolders": "マイフォルダー",
"publicFolders": "公開フォルダー",
"public": "公開",
"private": "非公開",
"setPublic": "公開に設定",
"setPrivate": "非公開に設定",
"publicFolderInfo": "{userName} • {totalPairs} ペア",
"searchPlaceholder": "公開フォルダーを検索...",
"loading": "読み込み中...",
"noPublicFolders": "公開フォルダーが見つかりません",
"unknownUser": "不明なユーザー",
"enterNewName": "新しい名前を入力:",
"favorite": "お気に入り",
"unfavorite": "お気に入り解除",
"pleaseLogin": "まずログインしてください"
},
"folder_id": {
"unauthorized": "あなたはこのフォルダーの所有者ではありません",
"unauthorized": "このフォルダーの所有者ではありません",
"back": "戻る",
"textPairs": "テキストペア",
"itemsCount": "{count} 項目",
@@ -45,34 +65,105 @@
"enterLanguageName": "言語名を入力してください",
"edit": "編集",
"delete": "削除",
"permissionDenied": "この操作を実行する権限がありません",
"permissionDenied": "このアクションを実行する権限がありません",
"error": {
"update": "この項目を更新する権限がありません。",
"delete": "この項目を削除する権限がありません。",
"add": "このフォルダーに項目を追加する権限がありません。",
"rename": "このフォルダーを変更する権限がありません。",
"rename": "このフォルダーの名前を変更する権限がありません。",
"deleteFolder": "このフォルダーを削除する権限がありません。"
}
},
"deck_id": {
"unauthorized": "このデッキの所有者ではありません",
"back": "戻る",
"cards": "カード",
"itemsCount": "{count}件",
"memorize": "暗記",
"loadingCards": "カードを読み込み中...",
"noCards": "このデッキにはカードがありません",
"card": "カード",
"addNewCard": "新しいカードを追加",
"add": "追加",
"adding": "追加中...",
"updateCard": "カードを更新",
"update": "更新",
"updating": "更新中...",
"word": "単語",
"definition": "定義",
"ipa": "発音記号",
"example": "例文",
"wordAndDefinitionRequired": "単語と定義は必須です",
"edit": "編集",
"delete": "削除",
"permissionDenied": "この操作を実行する権限がありません",
"resetProgress": "進捗をリセット",
"resetProgressTitle": "学習進捗をリセット",
"resetProgressConfirm": "このデッキの学習進捗をリセットしますか?",
"resetSuccess": "リセットしました",
"resetting": "リセット中...",
"cancel": "キャンセル",
"settings": "設定",
"settingsTitle": "デッキ設定",
"newPerDay": "1日の新規カード",
"newPerDayHint": "毎日の新規カード数",
"revPerDay": "1日の復習",
"revPerDayHint": "毎日の復習数",
"save": "保存",
"saving": "保存中...",
"settingsSaved": "設定を保存しました",
"todayNew": "今日の新規",
"todayReview": "今日の復習",
"todayLearning": "学習中",
"error": {
"update": "更新する権限がありません",
"delete": "削除する権限がありません",
"add": "追加する権限がありません"
},
"ipaPlaceholder": "IPAを入力",
"examplePlaceholder": "例文を入力",
"wordRequired": "単語を入力してください",
"definitionRequired": "定義を入力してください",
"cardAdded": "カードを追加しました",
"cardType": "カードタイプ",
"wordCard": "単語カード",
"phraseCard": "フレーズカード",
"sentenceCard": "文章カード",
"sentence": "文章",
"sentencePlaceholder": "文章を入力",
"wordPlaceholder": "単語を入力",
"queryLang": "検索言語",
"enterLanguageName": "言語名を入力してください",
"english": "英語",
"chinese": "中国語",
"japanese": "日本語",
"korean": "韓国語",
"meanings": "意味",
"addMeaning": "意味を追加",
"partOfSpeech": "品詞",
"deleteConfirm": "このカードを削除しますか?",
"cardDeleted": "カードを削除しました",
"cardUpdated": "カードを更新しました"
},
"home": {
"title": "言語を学ぶ",
"description": "これは、人工言語を含む世界のほぼすべての言語を学ぶのに役立つ非常に便利なウェブサイトです。",
"description": "ここは世界のほぼすべての言語(人工言語を含む)を学ぶのに役立つ非常に便利なウェブサイトです。",
"explore": "探索",
"fortune": {
"quote": "Stay hungry, stay foolish.",
"author": "— スティーブ・ジョブズ"
"author": "— Steve Jobs"
},
"translator": {
"name": "翻訳",
"description": "任意の言語に翻訳し、国際音声記号IPAで注釈を付けます"
"name": "翻訳",
"description": "あらゆる言語に翻訳し、国際音声記号IPAで注釈を付けます"
},
"textSpeaker": {
"name": "テキストスピーカー",
"description": "テキストを認識して音読します。ループ再生と速度調整をサポート"
"description": "テキストを認識して読み上げ、ループ再生と速度調整をサポート"
},
"srtPlayer": {
"name": "SRTビデオプレーヤー",
"description": "SRT字幕ファイルに基づいてビデオを文ごとに再生し、ネイティブスピーカーの発音を模倣します"
"description": "SRT字幕ファイルに基づいて文ごとにビデオを再生し、ネイティブスピーカーの発音を模倣"
},
"alphabet": {
"name": "アルファベット",
@@ -80,32 +171,33 @@
},
"memorize": {
"name": "暗記",
"description": "言語Aから言語B、言語Bから言語A、ディクテーションをサポート"
"description": "言語Aから言語B、言語Bから言語A、書き取りをサポート"
},
"dictionary": {
"name": "辞書",
"description": "単語やフレーズを調べ、詳細な定義と例を表示"
"description": "詳細な定義と例文で単語やフレーズを検索"
},
"moreFeatures": {
"name": "その他の機能",
"description": "開発中です。お楽しみに"
"description": "開発中お楽しみに"
}
},
"auth": {
"title": "認証",
"signIn": "ログイン",
"title": "サインイン",
"signUpTitle": "新規登録",
"signIn": "サインイン",
"signUp": "新規登録",
"email": "メールアドレス",
"password": "パスワード",
"confirmPassword": "パスワード確認",
"confirmPassword": "パスワード確認",
"name": "名前",
"username": "ユーザー名",
"emailOrUsername": "メールアドレスまたはユーザー名",
"signInButton": "ログイン",
"signInButton": "サインイン",
"signUpButton": "新規登録",
"noAccount": "アカウントをお持ちでないですか?",
"hasAccount": "すでにアカウントをお持ちですか?",
"signInWithGitHub": "GitHubでログイン",
"signInWithGitHub": "GitHubでサインイン",
"signUpWithGitHub": "GitHubで新規登録",
"invalidEmail": "有効なメールアドレスを入力してください",
"passwordTooShort": "パスワードは8文字以上である必要があります",
@@ -113,44 +205,174 @@
"nameRequired": "名前を入力してください",
"usernameRequired": "ユーザー名を入力してください",
"usernameTooShort": "ユーザー名は3文字以上である必要があります",
"usernameInvalid": "ユーザー名には数字アンダースコアのみ使用できます",
"usernameInvalid": "ユーザー名には文字、数字アンダースコアのみ使用できます",
"emailRequired": "メールアドレスを入力してください",
"identifierRequired": "メールアドレスまたはユーザー名を入力してください",
"passwordRequired": "パスワードを入力してください",
"confirmPasswordRequired": "パスワード確認)を入力してください",
"loading": "読み込み中..."
"confirmPasswordRequired": "パスワード確認してください",
"loading": "読み込み中...",
"confirm": "確認",
"noAccountLink": "アカウントをお持ちでないですか? 新規登録",
"hasAccountLink": "すでにアカウントをお持ちですか? サインイン",
"usernamePlaceholder": "ユーザー名",
"emailPlaceholder": "メールアドレス",
"passwordPlaceholder": "パスワード",
"usernameOrEmailPlaceholder": "ユーザー名またはメールアドレス",
"loginFailed": "ログインに失敗しました",
"signUpFailed": "新規登録に失敗しました",
"fillAllFields": "すべてのフィールドに入力してください",
"enterCredentials": "ユーザー名とパスワードを入力してください",
"forgotPassword": "パスワードをお忘れですか",
"forgotPasswordHint": "メールアドレスを入力してください。パスワードリセット用のリンクをお送りします。",
"sendResetEmail": "リセットメールを送信",
"resetPasswordFailed": "リセットメールの送信に失敗しました",
"resetPasswordEmailSent": "リセットメールを送信しました",
"resetPasswordEmailSentHint": "パスワードリセット用のリンクをメールでお送りしました。受信トレイをご確認ください。",
"verifyYourEmail": "メールアドレスを確認",
"verificationEmailSent": "確認メールを送信しました",
"verificationEmailSentHint": "{email} に確認メールを送信しました。メール内のリンクをクリックしてアカウントを確認してください。",
"checkYourEmail": "メールをご確認ください",
"backToLogin": "ログインに戻る",
"resetPassword": "パスワードをリセット",
"newPassword": "新しいパスワード",
"invalidToken": "無効または期限切れのリンク",
"invalidTokenHint": "このパスワードリセットリンクは無効または期限切れです。新しいものをリクエストしてください。",
"requestNewToken": "新しいリセットリンクをリクエスト",
"resetPasswordSuccess": "パスワードのリセットに成功しました",
"resetPasswordSuccessTitle": "パスワードリセット完了",
"resetPasswordSuccessHint": "パスワードが正常にリセットされました。新しいパスワードでログインできます。",
"emailNotVerified": "メールアドレスを確認してください",
"emailNotVerifiedHint": "メールアドレスが確認されていません。受信トレイをご確認いただくか、新しい確認メールをリクエストしてください。",
"resendVerification": "確認メールを再送信",
"resendSuccess": "確認メールを送信しました!受信トレイをご確認ください。",
"resendFailed": "確認メールの送信に失敗しました"
},
"memorize": {
"folder_selector": {
"selectFolder": "フォルダーを選択",
"noFolders": "フォルダーが見つかりません",
"folderInfo": "{id}. {name} ({count})"
"deck_selector": {
"selectDeck": "デッキを選択",
"noDecks": "デッキが見つかりません",
"goToDecks": "デッキへ移動",
"noCards": "カードなし",
"new": "新規",
"learning": "学習中",
"review": "復習",
"due": "予定"
},
"memorize": {
"answer": "回答",
"next": "次へ",
"reverse": "逆順",
"dictation": "ディクテーション",
"noTextPairs": "利用可能なテキストペアがありません",
"disorder": "ランダム",
"previous": "前へ"
"review": {
"loading": "読み込み中...",
"backToDecks": "デッキに戻る",
"allDone": "完了!",
"allDoneDesc": "すべての復習カードが完了しました。",
"reviewedCount": "{count} 枚のカードを復習",
"progress": "{current} / {total}",
"nextReview": "次の復習",
"interval": "間隔",
"ease": "易しさ",
"lapses": "忘回数",
"showAnswer": "答えを表示",
"nextCard": "次へ",
"again": "もう一度",
"hard": "難しい",
"good": "普通",
"easy": "簡単",
"now": "今",
"lessThanMinute": "<1分",
"inMinutes": "{count}分",
"inHours": "{count}時間",
"inDays": "{count}日",
"inMonths": "{count}ヶ月",
"minutes": "<1分",
"days": "{count}日",
"months": "{count}ヶ月",
"minAbbr": "分",
"dayAbbr": "日",
"cardTypeNew": "新規",
"cardTypeLearning": "学習中",
"cardTypeReview": "復習",
"cardTypeRelearning": "再学習",
"reverse": "反転",
"dictation": "聴き取り",
"clickToPlay": "クリックして再生",
"yourAnswer": "あなたの答え",
"typeWhatYouHear": "聞こえた内容を入力",
"correct": "正解",
"incorrect": "不正解",
"restart": "最初から",
"orderLimited": "順序制限",
"orderInfinite": "順序無限",
"randomLimited": "ランダム制限",
"randomInfinite": "ランダム無限",
"noIpa": "IPAなし"
},
"page": {
"unauthorized": "このフォルダーにアクセスする権限がありません"
"unauthorized": "このデッキにアクセスする権限がありません"
}
},
"navbar": {
"title": "learn-languages",
"sourceCode": "GitHub",
"sign_in": "ログイン",
"sign_in": "サインイン",
"profile": "プロフィール",
"folders": "フォルダー"
"folders": "デッキ",
"explore": "探索",
"favorites": "お気に入り",
"settings": "設定"
},
"ocr": {
"title": "OCR認識",
"description": "画像からテキストを抽出",
"uploadImage": "画像をアップロード",
"dragDropHint": "ドラッグ&ドロップ",
"supportedFormats": "対応形式JPG, PNG, WEBP",
"selectDeck": "デッキを選択",
"chooseDeck": "デッキを選択",
"noDecks": "デッキがありません",
"languageHints": "言語ヒント",
"sourceLanguageHint": "ソース言語ヒント",
"targetLanguageHint": "ターゲット言語ヒント",
"process": "処理",
"processing": "処理中...",
"preview": "プレビュー",
"extractedPairs": "抽出ペア",
"word": "単語",
"definition": "定義",
"pairsCount": "{count}ペア",
"savePairs": "保存",
"saving": "保存中...",
"saved": "保存済み",
"saveFailed": "保存失敗",
"noImage": "画像をアップロードしてください",
"noDeck": "デッキを選択してください",
"processingFailed": "処理失敗",
"tryAgain": "再試行",
"detectedLanguages": "検出言語",
"invalidFileType": "無効なファイル形式",
"ocrFailed": "OCR失敗",
"uploadSection": "画像をアップロード",
"dropOrClick": "ドロップまたはクリック",
"changeImage": "画像を変更",
"deckSelection": "デッキを選択",
"sourceLanguagePlaceholder": "例:英語",
"targetLanguagePlaceholder": "例:日本語",
"processButton": "認識開始",
"resultsPreview": "結果プレビュー",
"saveButton": "デッキに保存",
"ocrSuccess": "OCR成功",
"savedToDeck": "デッキに保存しました",
"noResultsToSave": "結果がありません",
"detectedSourceLanguage": "検出ソース言語",
"detectedTargetLanguage": "検出ターゲット言語"
},
"profile": {
"myProfile": "マイプロフィール",
"email": "メールアドレス: {email}",
"email": "メール: {email}",
"logout": "ログアウト"
},
"settings": {
"title": "設定",
"themeColor": "テーマカラー",
"themeColorDescription": "お好みのテーマカラーを選択してください"
},
"srt_player": {
"uploadVideo": "ビデオをアップロード",
"uploadSubtitle": "字幕をアップロード",
@@ -170,21 +392,60 @@
"uploaded": "アップロード済み",
"notUploaded": "未アップロード",
"upload": "アップロード",
"uploadVideoButton": "ビデオをアップロード",
"uploadSubtitleButton": "字幕をアップロード",
"subtitleUploaded": "字幕をアップロード済み ({count} エントリ)",
"subtitleNotUploaded": "字幕がアップロードされていません",
"autoPauseStatus": "自動一時停止: {enabled}",
"on": "オン",
"off": "オフ",
"videoUploadFailed": "ビデオのアップロードに失敗しました",
"subtitleUploadFailed": "字幕のアップロードに失敗しました"
"subtitleUploadFailed": "字幕のアップロードに失敗しました",
"subtitleLoadSuccess": "字幕の読み込みに成功しました",
"subtitleLoadFailed": "字幕の読み込みに失敗しました",
"settings": "設定",
"shortcuts": "ショートカット",
"keyboardShortcuts": "キーボードショートカット",
"playPause": "再生/一時停止",
"autoPauseToggle": "自動一時停止",
"subtitleSettings": "字幕設定",
"fontSize": "フォントサイズ",
"textColor": "文字色",
"backgroundColor": "背景色",
"position": "位置",
"opacity": "不透明度",
"top": "上",
"center": "中央",
"bottom": "下"
},
"text_speaker": {
"generateIPA": "IPAを生成",
"viewSavedItems": "保存済みアイテムを表示",
"confirmDeleteAll": "本当にすべて削除しすか? (Y/N)"
"viewSavedItems": "保存済み項目を表示",
"confirmDeleteAll": "すべて削除してもよろしいですか? (Y/N)",
"saved": "保存済み",
"clearAll": "すべてクリア",
"language": "言語",
"customLanguage": "または言語を入力...",
"languages": {
"auto": "自動",
"chinese": "中国語",
"english": "英語",
"japanese": "日本語",
"korean": "韓国語",
"french": "フランス語",
"german": "ドイツ語",
"italian": "イタリア語",
"spanish": "スペイン語",
"portuguese": "ポルトガル語",
"russian": "ロシア語"
}
},
"translator": {
"detectLanguage": "言語を検出",
"generateIPA": "IPAを生成",
"translateInto": "翻訳",
"sourceLanguage": "ソース言語",
"auto": "自動",
"generateIPA": "ipaを生成",
"translateInto": "翻訳先",
"chinese": "中国語",
"english": "英語",
"french": "フランス語",
@@ -207,38 +468,93 @@
"noFolders": "フォルダーが見つかりません",
"folderInfo": "{id}. {name}",
"close": "閉じる",
"success": "テキストペアフォルダーに追加ました",
"error": "テキストペアの追加に失敗しました"
"success": "テキストペアフォルダーに追加されました",
"error": "テキストペアをフォルダーに追加できませんでした"
},
"autoSave": "自動保存"
"autoSave": "自動保存",
"customLanguage": "または言語を入力...",
"pleaseLogin": "ログインしてカードを保存",
"pleaseCreateDeck": "先にデッキを作成",
"noTranslationToSave": "保存する翻訳なし",
"noDeckSelected": "デッキ未選択",
"saveAsCard": "カードとして保存",
"selectDeck": "デッキ選択",
"front": "表面",
"back": "裏面",
"cancel": "キャンセル",
"save": "保存",
"savedToDeck": "{deckName}に保存",
"saveFailed": "保存失敗"
},
"dictionary": {
"title": "辞書",
"description": "詳細な定義と例で単語やフレーズを検索",
"description": "詳細な定義と例で単語やフレーズを検索",
"searchPlaceholder": "検索する単語やフレーズを入力...",
"searching": "検索中...",
"search": "検索",
"languageSettings": "言語設定",
"queryLanguage": "クエリ言語",
"queryLanguageHint": "検索する単語/フレーズの言語",
"queryLanguageHint": "検索したい単語/フレーズの言語",
"definitionLanguage": "定義言語",
"definitionLanguageHint": "定義を表示する言語",
"otherLanguagePlaceholder": "またはの言語を入力...",
"currentSettings": "現在の設定:クエリ {queryLang}、定義 {definitionLang}",
"otherLanguagePlaceholder": "またはの言語を入力...",
"other": "その他",
"currentSettings": "現在の設定: クエリ {queryLang}, 定義 {definitionLang}",
"relookup": "再検索",
"saveToFolder": "フォルダに保存",
"saveToFolder": "フォルダに保存",
"loading": "読み込み中...",
"noResults": "結果が見つかりません",
"tryOtherWords": "の単語やフレーズを試してください",
"tryOtherWords": "の単語やフレーズを試してください",
"welcomeTitle": "辞書へようこそ",
"welcomeHint": "上の検索ボックスに単語やフレーズを入力して検索を始",
"welcomeHint": "上の検索ボックスに単語やフレーズを入力して検索を始めましょう",
"lookupFailed": "検索に失敗しました。後でもう一度お試しください",
"relookupSuccess": "再検索しました",
"relookupSuccess": "再検索に成功しました",
"relookupFailed": "辞書の再検索に失敗しました",
"pleaseLogin": "まずログインしてください",
"pleaseCreateFolder": "まずフォルダを作成してください",
"savedToFolder": "フォルダに保存しました{folderName}",
"saveFailed": "保存に失敗しました。後でもう一度お試しください"
"pleaseCreateFolder": "まずフォルダを作成してください",
"savedToFolder": "フォルダに保存しました: {folderName}",
"saveFailed": "保存に失敗しました。後でもう一度お試しください",
"definition": "定義",
"example": "例文"
},
"explore": {
"title": "探索",
"subtitle": "公開フォルダーを発見",
"searchPlaceholder": "公開フォルダーを検索...",
"loading": "読み込み中...",
"noFolders": "公開フォルダーが見つかりません",
"folderInfo": "{userName} • {totalPairs} ペア",
"unknownUser": "不明なユーザー",
"favorite": "お気に入り",
"unfavorite": "お気に入り解除",
"pleaseLogin": "まずログインしてください",
"sortByFavorites": "お気に入り順に並べ替え",
"sortByFavoritesActive": "お気に入り順の並べ替えを解除",
"noDecks": "公開デッキなし",
"deckInfo": "{userName} · {totalCards}枚"
},
"exploreDetail": {
"title": "フォルダー詳細",
"createdBy": "作成者: {name}",
"unknownUser": "不明なユーザー",
"totalPairs": "合計ペア数",
"favorites": "お気に入り",
"createdAt": "作成日",
"viewContent": "コンテンツを表示",
"favorite": "お気に入り",
"unfavorite": "お気に入り解除",
"favorited": "お気に入りに追加しました",
"unfavorited": "お気に入りから削除しました",
"pleaseLogin": "まずログインしてください",
"totalCards": "{count}枚"
},
"favorites": {
"title": "マイお気に入り",
"subtitle": "お気に入りに追加したフォルダー",
"loading": "読み込み中...",
"noFavorites": "まだお気に入りがありません",
"folderInfo": "{userName} • {totalPairs} ペア",
"unknownUser": "不明なユーザー"
},
"user_profile": {
"anonymous": "匿名",
@@ -251,14 +567,67 @@
"displayName": "表示名",
"notSet": "未設定",
"memberSince": "登録日",
"folders": {
"title": "フォルダー",
"noFolders": "フォルダーがありません",
"folderName": "フォルダー名",
"totalPairs": "テキストペア数",
"logout": "ログアウト",
"deleteAccount": {
"button": "アカウント削除",
"title": "アカウント削除",
"warning": "この操作は取り消せません。すべてのデータが完全に削除されます。",
"warningDecks": "すべてのデッキとカード",
"warningCards": "すべての学習履歴",
"warningHistory": "すべての翻訳と辞書の履歴",
"warningPermanent": "この操作は取り消せません",
"confirmLabel": "確認のためユーザー名を入力してください:",
"usernameMismatch": "ユーザー名が一致しません",
"cancel": "キャンセル",
"confirm": "アカウントを削除する",
"success": "アカウントが正常に削除されました",
"failed": "アカウントの削除に失敗しました"
},
"decks": {
"title": "デッキ",
"noDecks": "まだデッキがありません",
"deckName": "デッキ名",
"totalCards": "合計カード数",
"createdAt": "作成日",
"actions": "操作",
"actions": "アクション",
"view": "表示"
}
},
"joined": "登録日"
},
"decks": {
"title": "デッキ",
"subtitle": "学習デッキを管理",
"newDeck": "新規デッキ",
"noDecksYet": "デッキなし",
"loading": "読込中...",
"deckInfo": "ID: {id} · {totalCards}枚",
"enterDeckName": "デッキ名:",
"enterNewName": "新しい名前:",
"confirmDelete": "削除確認:「{name}」を入力",
"public": "公開",
"private": "非公開",
"setPublic": "公開に設定",
"setPrivate": "非公開に設定",
"importApkg": "APKGインポート",
"exportApkg": "APKGエクスポート",
"clickToUpload": "クリックでアップロード",
"apkgFilesOnly": ".apkgのみ",
"parsing": "解析中...",
"foundDecks": "{count}デッキ発見",
"deckName": "デッキ名",
"back": "戻る",
"import": "インポート",
"importing": "インポート中...",
"exportSuccess": "エクスポート成功",
"goToDecks": "デッキへ"
},
"follow": {
"follow": "フォロー",
"following": "フォロー中",
"followers": "フォロワー",
"followersOf": "{username}のフォロワー",
"followingOf": "{username}のフォロー中",
"noFollowers": "まだフォロワーがいません",
"noFollowing": "まだ誰もフォローしていません"
}
}

View File

@@ -1,10 +1,11 @@
{
"alphabet": {
"chooseCharacters": "학습할 문자를 선택하세요",
"chooseCharacters": "배우고 싶은 문자를 선택하세요",
"chooseAlphabetHint": "학습을 시작할 알파벳을 선택하세요",
"japanese": "일본어 가나",
"english": "영 알파벳",
"uyghur": "위구르 문자",
"esperanto": "에스페란토 문자",
"english": "영 알파벳",
"uyghur": "위구르어 알파벳",
"esperanto": "에스페란토 알파벳",
"loading": "로딩 중...",
"loadFailed": "로딩 실패, 다시 시도해주세요",
"hideLetter": "문자 숨기기",
@@ -14,17 +15,68 @@
"roman": "로마자 표기",
"letter": "문자",
"random": "무작위 모드",
"randomNext": "무작위 다음"
"randomNext": "무작위 다음",
"previousLetter": "이전 문자",
"nextLetter": "다음 문자",
"keyboardHint": "왼쪽/오른쪽 화살표 키 또는 스페이스바로 무작위, ESC로 뒤로가기",
"swipeHint": "왼쪽/오른쪽 화살표 키 또는 스와이프로 탐색, ESC로 뒤로가기"
},
"folders": {
"title": "폴더",
"subtitle": "컬렉션 관리",
"newFolder": "새 폴더",
"creating": "생성 중...",
"noFoldersYet": "폴더가 없습니다",
"noFoldersYet": "아직 폴더가 없습니다",
"folderInfo": "ID: {id} • {totalPairs} 쌍",
"enterFolderName": "폴더 이름 입력:",
"confirmDelete": "삭제하려면 \"{name}\"을(를) 입력하세요:"
"confirmDelete": "삭제하려면 \"{name}\"을(를) 입력하세요:",
"myFolders": "내 폴더",
"publicFolders": "공개 폴더",
"public": "공개",
"private": "비공개",
"setPublic": "공개로 설정",
"setPrivate": "비공개로 설정",
"publicFolderInfo": "{userName} • {totalPairs} 쌍",
"searchPlaceholder": "공개 폴더 검색...",
"loading": "로딩 중...",
"noPublicFolders": "공개 폴더를 찾을 수 없습니다",
"unknownUser": "알 수 없는 사용자",
"enterNewName": "새 이름 입력:",
"favorite": "즐겨찾기",
"unfavorite": "즐겨찾기 해제",
"pleaseLogin": "먼저 로그인해주세요"
},
"decks": {
"title": "덱",
"noDecks": "덱이 없습니다",
"deckName": "덱 이름",
"totalCards": "총 카드",
"createdAt": "생성일",
"actions": "작업",
"view": "보기",
"subtitle": "학습 덱 관리",
"newDeck": "새 덱",
"noDecksYet": "덱이 없습니다",
"loading": "로딩 중...",
"deckInfo": "ID: {id} · {totalCards}장",
"enterDeckName": "덱 이름 입력:",
"enterNewName": "새 이름 입력:",
"confirmDelete": "삭제하려면 \"{name}\" 입력:",
"public": "공개",
"private": "비공개",
"setPublic": "공개로 설정",
"setPrivate": "비공개로 설정",
"importApkg": "APKG 가져오기",
"exportApkg": "APKG 내보내기",
"clickToUpload": "클릭하여 업로드",
"apkgFilesOnly": ".apkg 파일만",
"parsing": "파싱 중...",
"foundDecks": "{count}개 덱 발견",
"back": "뒤로",
"import": "가져오기",
"importing": "가져오는 중...",
"exportSuccess": "내보내기 성공",
"goToDecks": "덱으로"
},
"folder_id": {
"unauthorized": "이 폴더의 소유자가 아닙니다",
@@ -36,39 +88,110 @@
"noTextPairs": "이 폴더에 텍스트 쌍이 없습니다",
"addNewTextPair": "새 텍스트 쌍 추가",
"add": "추가",
"updateTextPair": "텍스트 쌍 업데이트",
"update": "업데이트",
"updateTextPair": "텍스트 쌍 수정",
"update": "수정",
"text1": "텍스트 1",
"text2": "텍스트 2",
"language1": "언어 1",
"language2": "언어 2",
"language1": "로캘 1",
"language2": "로캘 2",
"enterLanguageName": "언어 이름을 입력하세요",
"edit": "편집",
"delete": "삭제",
"permissionDenied": "이 작업을 수행할 권한이 없습니다",
"error": {
"update": "이 항목을 업데이트할 권한이 없습니다.",
"update": "이 항목을 수정할 권한이 없습니다.",
"delete": "이 항목을 삭제할 권한이 없습니다.",
"add": "이 폴더에 항목을 추가할 권한이 없습니다.",
"rename": "이 폴더 이름을 변경할 권한이 없습니다.",
"rename": "이 폴더 이름을 변경할 권한이 없습니다.",
"deleteFolder": "이 폴더를 삭제할 권한이 없습니다."
}
},
"deck_id": {
"unauthorized": "이 덱의 소유자가 아닙니다",
"back": "뒤로",
"cards": "카드",
"itemsCount": "{count}개",
"memorize": "암기",
"loadingCards": "카드 불러오는 중...",
"noCards": "이 덱에 카드가 없습니다",
"card": "카드",
"addNewCard": "새 카드 추가",
"add": "추가",
"adding": "추가 중...",
"updateCard": "카드 업데이트",
"update": "업데이트",
"updating": "업데이트 중...",
"word": "단어",
"definition": "정의",
"ipa": "IPA",
"example": "예문",
"wordAndDefinitionRequired": "단어와 정의는 필수입니다",
"edit": "편집",
"delete": "삭제",
"permissionDenied": "이 작업을 수행할 권한이 없습니다",
"resetProgress": "진행 초기화",
"resetProgressTitle": "학습 진행 초기화",
"resetProgressConfirm": "이 덱의 학습 진행을 초기화하시겠습니까?",
"resetSuccess": "초기화됨",
"resetting": "초기화 중...",
"cancel": "취소",
"settings": "설정",
"settingsTitle": "덱 설정",
"newPerDay": "일일 새 카드",
"newPerDayHint": "매일 학습할 새 카드 수",
"revPerDay": "일일 복습",
"revPerDayHint": "매일 복습할 카드 수",
"save": "저장",
"saving": "저장 중...",
"settingsSaved": "설정 저장됨",
"todayNew": "오늘 새 카드",
"todayReview": "오늘 복습",
"todayLearning": "학습 중",
"error": {
"update": "업데이트 권한이 없습니다",
"delete": "삭제 권한이 없습니다",
"add": "추가 권한이 없습니다"
},
"ipaPlaceholder": "IPA 입력",
"examplePlaceholder": "예문 입력",
"wordRequired": "단어를 입력하세요",
"definitionRequired": "정의를 입력하세요",
"cardAdded": "카드 추가됨",
"cardType": "카드 유형",
"wordCard": "단어 카드",
"phraseCard": "구문 카드",
"sentenceCard": "문장 카드",
"sentence": "문장",
"sentencePlaceholder": "문장 입력",
"wordPlaceholder": "단어 입력",
"queryLang": "검색 언어",
"enterLanguageName": "언어 이름을 입력하세요",
"english": "영어",
"chinese": "중국어",
"japanese": "일본어",
"korean": "한국어",
"meanings": "의미",
"addMeaning": "의미 추가",
"partOfSpeech": "품사",
"deleteConfirm": "이 카드를 삭제하시겠습니까?",
"cardDeleted": "카드 삭제됨",
"cardUpdated": "카드 업데이트됨"
},
"home": {
"title": "언어 학습",
"description": "인공 언어를 포함하여 세상의 거의 모든 언어를 배우는 데 도움이 되는 매우 유용한 웹사이트입니다.",
"title": "언어 배우기",
"description": "세계의 거의 모든 언어(인공어 포함)를 배우는 데 도움이 되는 매우 유용한 웹사이트입니다.",
"explore": "탐색",
"fortune": {
"quote": "Stay hungry, stay foolish.",
"author": "— 스티브 잡스"
"author": "— Steve Jobs"
},
"translator": {
"name": "번역기",
"description": "모든 언어로 번역하고 국제 음성 기호(IPA)로 주석 추가"
"description": "모든 언어로 번역하고 국제 음성 기호(IPA)로 주석 달기"
},
"textSpeaker": {
"name": "텍스트 스피커",
"description": "텍스트 인식하고 읽어줍니다. 반복 재생 및 속도 조 지원"
"description": "텍스트 인식 및 낭독, 반복 재생 및 속도 조 지원"
},
"srtPlayer": {
"name": "SRT 비디오 플레이어",
@@ -84,7 +207,7 @@
},
"dictionary": {
"name": "사전",
"description": "단어와 구문을 조회하고 자세한 정의와 예제 제공"
"description": "상세한 정의와 예문으로 단어 및 구문 검색"
},
"moreFeatures": {
"name": "더 많은 기능",
@@ -92,7 +215,8 @@
}
},
"auth": {
"title": "인",
"title": "로그인",
"signUpTitle": "회원가입",
"signIn": "로그인",
"signUp": "회원가입",
"email": "이메일",
@@ -113,30 +237,82 @@
"nameRequired": "이름을 입력하세요",
"usernameRequired": "사용자명을 입력하세요",
"usernameTooShort": "사용자명은 최소 3자 이상이어야 합니다",
"usernameInvalid": "사용자명은 문, 숫자, 밑줄만 포함할 수 있습니다",
"usernameInvalid": "사용자명은 문, 숫자, 밑줄만 포함할 수 있습니다",
"emailRequired": "이메일을 입력하세요",
"identifierRequired": "이메일 또는 사용자명을 입력하세요",
"passwordRequired": "비밀번호를 입력하세요",
"confirmPasswordRequired": "비밀번호 확인을 입력하세요",
"loading": "로딩 중..."
"confirmPasswordRequired": "비밀번호 확인하세요",
"loading": "로딩 중...",
"confirm": "확인",
"noAccountLink": "계정이 없으신가요? 회원가입",
"hasAccountLink": "이미 계정이 있으신가요? 로그인",
"usernamePlaceholder": "사용자명",
"emailPlaceholder": "이메일 주소",
"passwordPlaceholder": "비밀번호",
"usernameOrEmailPlaceholder": "사용자명 또는 이메일",
"loginFailed": "로그인 실패",
"signUpFailed": "회원가입 실패",
"fillAllFields": "모든 필드를 입력하세요",
"enterCredentials": "사용자명과 비밀번호를 입력하세요",
"forgotPassword": "비밀번호 찾기",
"forgotPasswordHint": "이메일 주소를 입력하시면 비밀번호 재설정 링크를 보내드립니다.",
"sendResetEmail": "재설정 이메일 보내기",
"resetPasswordFailed": "재설정 이메일 전송 실패",
"resetPasswordEmailSent": "재설정 이메일이 전송되었습니다",
"resetPasswordEmailSentHint": "비밀번호 재설정 링크를 이메일로 보냈습니다. 받은 편지함을 확인해주세요.",
"verifyYourEmail": "이메일 인증",
"verificationEmailSent": "인증 이메일이 전송되었습니다",
"verificationEmailSentHint": "{email}로 인증 이메일을 보냈습니다. 이메일의 링크를 클릭하여 계정을 인증해주세요.",
"checkYourEmail": "이메일을 확인하세요",
"backToLogin": "로그인으로 돌아가기",
"resetPassword": "비밀번호 재설정",
"newPassword": "새 비밀번호",
"invalidToken": "유효하지 않거나 만료된 링크",
"invalidTokenHint": "이 비밀번호 재설정 링크는 유효하지 않거나 만료되었습니다. 새로 요청해 주세요.",
"requestNewToken": "새 재설정 링크 요청",
"resetPasswordSuccess": "비밀번호 재설정 성공",
"resetPasswordSuccessTitle": "비밀번호 재설정 완료",
"resetPasswordSuccessHint": "비밀번호가 성공적으로 재설정되었습니다. 새 비밀번호로 로그인할 수 있습니다.",
"emailNotVerified": "이메일 주소를 인증해 주세요",
"emailNotVerifiedHint": "이메일이 인증되지 않았습니다. 받은 편지함을 확인하거나 새 인증 이메일을 요청해 주세요.",
"resendVerification": "인증 이메일 다시 보내기",
"resendSuccess": "인증 이메일이 발송되었습니다! 받은 편지함을 확인해 주세요.",
"resendFailed": "인증 이메일 발송에 실패했습니다"
},
"memorize": {
"folder_selector": {
"selectFolder": "폴더 선택",
"noFolders": "폴더를 찾을 수 없습니다",
"folderInfo": "{id}. {name} ({count})"
"deck_selector": {
"selectDeck": " 선택",
"noDecks": "덱이 없습니다",
"goToDecks": "덱으로 이동",
"noCards": "카드가 없습니다",
"new": "새로",
"learning": "학습 중",
"review": "복습",
"due": "예정"
},
"memorize": {
"answer": "정답",
"next": "다음",
"reverse": "반대",
"dictation": "받아쓰기",
"noTextPairs": "사용 가능한 텍스트 쌍이 없습니다",
"disorder": "무작위",
"previous": "이전"
"review": {
"loading": "로딩 중...",
"backToDecks": "덱으로 돌아가기",
"allDone": "모두 완료!",
"allDoneDesc": "오늘의 학습을 완료했습니다!",
"reviewedCount": "{count}장 복습 완료",
"progress": "{current} / {total}",
"nextReview": "다음 복습",
"interval": "간격",
"ease": "난이도",
"lapses": "실패 횟수",
"showAnswer": "정답 보기",
"nextCard": "다음",
"again": "다시",
"restart": "다시 시작",
"orderLimited": "순서 제한",
"orderInfinite": "순서 무제한",
"randomLimited": "무작위 제한",
"randomInfinite": "무작위 무제한",
"noIpa": "IPA 없음"
},
"page": {
"unauthorized": "이 폴더에 액세스할 권한이 없습니다"
"unauthorized": "권한이 없습니다"
}
},
"navbar": {
@@ -144,13 +320,66 @@
"sourceCode": "GitHub",
"sign_in": "로그인",
"profile": "프로필",
"folders": "폴더"
"folders": "덱",
"explore": "탐색",
"favorites": "즐겨찾기",
"settings": "설정"
},
"ocr": {
"title": "OCR 인식",
"description": "이미지에서 텍스트 추출",
"uploadImage": "이미지 업로드",
"dragDropHint": "드래그 앤 드롭",
"supportedFormats": "지원 형식: JPG, PNG, WEBP",
"selectDeck": "덱 선택",
"chooseDeck": "덱 선택",
"noDecks": "덱이 없습니다",
"languageHints": "언어 힌트",
"sourceLanguageHint": "원본 언어 힌트",
"targetLanguageHint": "대상 언어 힌트",
"process": "처리",
"processing": "처리 중...",
"preview": "미리보기",
"extractedPairs": "추출된 쌍",
"word": "단어",
"definition": "정의",
"pairsCount": "{count}쌍",
"savePairs": "저장",
"saving": "저장 중...",
"saved": "저장됨",
"saveFailed": "저장 실패",
"noImage": "이미지를 업로드하세요",
"noDeck": "덱을 선택하세요",
"processingFailed": "처리 실패",
"tryAgain": "재시도",
"detectedLanguages": "감지된 언어",
"uploadSection": "이미지 업로드",
"dropOrClick": "드롭 또는 클릭",
"changeImage": "이미지 변경",
"invalidFileType": "잘못된 파일 형식",
"deckSelection": "덱 선택",
"sourceLanguagePlaceholder": "예: 영어",
"targetLanguagePlaceholder": "예: 한국어",
"processButton": "인식 시작",
"resultsPreview": "결과 미리보기",
"saveButton": "덱에 저장",
"ocrSuccess": "OCR 성공",
"ocrFailed": "OCR 실패",
"savedToDeck": "덱에 저장됨",
"noResultsToSave": "저장할 결과 없음",
"detectedSourceLanguage": "감지된 원본 언어",
"detectedTargetLanguage": "감지된 대상 언어"
},
"profile": {
"myProfile": "내 프로필",
"email": "이메일: {email}",
"logout": "로그아웃"
},
"settings": {
"title": "설정",
"themeColor": "테마 색상",
"themeColorDescription": "원하는 테마 색상을 선택하세요"
},
"srt_player": {
"uploadVideo": "비디오 업로드",
"uploadSubtitle": "자막 업로드",
@@ -158,7 +387,7 @@
"play": "재생",
"previous": "이전",
"next": "다음",
"restart": "처음부터",
"restart": "다시 시작",
"autoPause": "자동 일시정지 ({enabled})",
"uploadVideoAndSubtitle": "비디오와 자막 파일을 업로드하세요",
"uploadVideoFile": "비디오 파일을 업로드하세요",
@@ -170,21 +399,60 @@
"uploaded": "업로드됨",
"notUploaded": "업로드되지 않음",
"upload": "업로드",
"uploadVideoButton": "비디오 업로드",
"uploadSubtitleButton": "자막 업로드",
"subtitleUploaded": "자막 업로드됨 ({count}개 항목)",
"subtitleNotUploaded": "자막 업로드되지 않음",
"autoPauseStatus": "자동 일시정지: {enabled}",
"on": "켜기",
"off": "끄기",
"videoUploadFailed": "비디오 업로드 실패",
"subtitleUploadFailed": "자막 업로드 실패"
"subtitleUploadFailed": "자막 업로드 실패",
"subtitleLoadSuccess": "자막 로드 성공",
"subtitleLoadFailed": "자막 로드 실패",
"settings": "설정",
"shortcuts": "단축키",
"keyboardShortcuts": "키보드 단축키",
"playPause": "재생/일시정지",
"autoPauseToggle": "자동 일시정지",
"subtitleSettings": "자막 설정",
"fontSize": "글꼴 크기",
"textColor": "글자 색",
"backgroundColor": "배경색",
"position": "위치",
"opacity": "불투명도",
"top": "위",
"center": "중앙",
"bottom": "아래"
},
"text_speaker": {
"generateIPA": "IPA 생성",
"viewSavedItems": "저장된 항목 보기",
"confirmDeleteAll": "정말 모두 삭제하시겠습니까? (Y/N)"
"confirmDeleteAll": "모든 것을 삭제하시겠습니까? (Y/N)",
"saved": "저장됨",
"clearAll": "모두 지우기",
"language": "언어",
"customLanguage": "또는 언어 입력...",
"languages": {
"auto": "자동",
"chinese": "중국어",
"english": "영어",
"japanese": "일본어",
"korean": "한국어",
"french": "프랑스어",
"german": "독일어",
"italian": "이탈리아어",
"spanish": "스페인어",
"portuguese": "포르투갈어",
"russian": "러시아어"
}
},
"translator": {
"detectLanguage": "언어 감지",
"sourceLanguage": "원본 언어",
"auto": "자동",
"generateIPA": "IPA 생성",
"translateInto": "번역",
"translateInto": "번역할 언어",
"chinese": "중국어",
"english": "영어",
"french": "프랑스어",
@@ -207,38 +475,93 @@
"noFolders": "폴더를 찾을 수 없습니다",
"folderInfo": "{id}. {name}",
"close": "닫기",
"success": "텍스트 쌍 폴더에 추가했습니다",
"error": "텍스트 쌍 추가 실패"
"success": "텍스트 쌍 폴더에 추가",
"error": "폴더에 텍스트 쌍 추가 실패"
},
"autoSave": "자동 저장"
"autoSave": "자동 저장",
"customLanguage": "또는 언어 입력...",
"pleaseLogin": "카드를 저장하려면 로그인하세요",
"pleaseCreateDeck": "먼저 덱을 만드세요",
"noTranslationToSave": "저장할 번역이 없습니다",
"noDeckSelected": "덱이 선택되지 않았습니다",
"saveAsCard": "카드로 저장",
"selectDeck": "덱 선택",
"front": "앞면",
"back": "뒷면",
"cancel": "취소",
"save": "저장",
"savedToDeck": "{deckName}에 카드 저장됨",
"saveFailed": "카드 저장 실패"
},
"dictionary": {
"title": "사전",
"description": "상세한 정의와 예로 단어 및 구문 검색",
"description": "상세한 정의와 예문으로 단어 및 구문 검색",
"searchPlaceholder": "검색할 단어나 구문을 입력하세요...",
"searching": "검색 중...",
"search": "검색",
"languageSettings": "언어 설정",
"queryLanguage": "쿼리 언어",
"queryLanguageHint": "검색하려는 단어/구문의 언어",
"queryLanguage": "질의 언어",
"queryLanguageHint": "검색 단어/구문의 언어",
"definitionLanguage": "정의 언어",
"definitionLanguageHint": "정의를 표시할 언어",
"otherLanguagePlaceholder": "또는 다른 언어 입력하세요...",
"currentSettings": "현재 설정: 쿼리 {queryLang}, 정의 {definitionLang}",
"relookup": "재검색",
"otherLanguagePlaceholder": "또는 다른 언어 입력...",
"other": "기타",
"currentSettings": "현재 설정: 질의 {queryLang}, 정의 {definitionLang}",
"relookup": "다시 검색",
"saveToFolder": "폴더에 저장",
"loading": "로 중...",
"noResults": "결과를 찾을 수 없습니다",
"loading": "로 중...",
"noResults": "검색 결과 없음",
"tryOtherWords": "다른 단어나 구문을 시도하세요",
"welcomeTitle": "사전에 오신 것을 환영합니다",
"welcomeHint": "위 검색 상자에 단어나 구문을 입력하여 검색을 시작하세요",
"welcomeHint": "위 검색 상자에 단어나 구문을 입력하여 검색을 시작하세요",
"lookupFailed": "검색 실패, 나중에 다시 시도하세요",
"relookupSuccess": "재검색했습니다",
"relookupFailed": "사전 검색 실패",
"relookupSuccess": "다시 검색 성공",
"relookupFailed": "사전 다시 검색 실패",
"pleaseLogin": "먼저 로그인하세요",
"pleaseCreateFolder": "먼저 폴더를 만드세요",
"pleaseCreateFolder": "먼저 폴더를 생성하세요",
"savedToFolder": "폴더에 저장됨: {folderName}",
"saveFailed": "저장 실패, 나중에 다시 시도하세요"
"saveFailed": "저장 실패, 나중에 다시 시도하세요",
"definition": "정의",
"example": "예문"
},
"explore": {
"title": "탐색",
"subtitle": "공개 폴더 발견",
"searchPlaceholder": "공개 폴더 검색...",
"loading": "로딩 중...",
"noFolders": "공개 폴더를 찾을 수 없습니다",
"folderInfo": "{userName} • {totalPairs} 쌍",
"unknownUser": "알 수 없는 사용자",
"favorite": "즐겨찾기",
"unfavorite": "즐겨찾기 해제",
"pleaseLogin": "먼저 로그인해주세요",
"sortByFavorites": "즐겨찾기순 정렬",
"sortByFavoritesActive": "즐겨찾기순 정렬 해제",
"noDecks": "공개 덱 없음",
"deckInfo": "{userName} · {totalCards}장"
},
"exploreDetail": {
"title": "폴더 상세",
"createdBy": "생성자: {name}",
"unknownUser": "알 수 없는 사용자",
"totalPairs": "총 쌍",
"favorites": "즐겨찾기",
"createdAt": "생성일",
"viewContent": "내용 보기",
"favorite": "즐겨찾기",
"unfavorite": "즐겨찾기 해제",
"favorited": "즐겨찾기됨",
"unfavorited": "즐겨찾기 해제됨",
"pleaseLogin": "먼저 로그인해주세요",
"totalCards": "총 {count}장"
},
"favorites": {
"title": "내 즐겨찾기",
"subtitle": "즐겨찾기한 폴더",
"loading": "로딩 중...",
"noFavorites": "아직 즐겨찾기가 없습니다",
"folderInfo": "{userName} • {totalPairs} 쌍",
"unknownUser": "알 수 없는 사용자"
},
"user_profile": {
"anonymous": "익명",
@@ -251,14 +574,49 @@
"displayName": "표시 이름",
"notSet": "설정되지 않음",
"memberSince": "가입일",
"logout": "로그아웃",
"deleteAccount": {
"button": "계정 삭제",
"title": "계정 삭제",
"warning": "이 작업은 되돌릴 수 없습니다. 모든 데이터가 영구적으로 삭제됩니다.",
"warningDecks": "모든 덱과 카드",
"warningCards": "모든 학습 진행 상황",
"warningHistory": "모든 번역 및 사전 기록",
"warningPermanent": "이 작업은 취소할 수 없습니다",
"confirmLabel": "확인을 위해 사용자명을 입력하세요:",
"usernameMismatch": "사용자명이 일치하지 않습니다",
"cancel": "취소",
"confirm": "내 계정 삭제",
"success": "계정이 성공적으로 삭제되었습니다",
"failed": "계정 삭제에 실패했습니다"
},
"folders": {
"title": "폴더",
"noFolders": "폴더가 없습니다",
"folderName": "폴더 이름",
"totalPairs": "텍스트 쌍 수",
"title": "",
"noFolders": "아직 덱이 없습니다",
"folderName": " 이름",
"totalPairs": "총 카드 수",
"createdAt": "생성일",
"actions": "작업",
"view": "보기"
},
"joined": "가입일",
"decks": {
"title": "내 덱",
"noDecks": "덱이 없습니다",
"deckName": "덱 이름",
"totalCards": "총 카드",
"createdAt": "생성일",
"actions": "작업",
"view": "보기"
}
},
"follow": {
"follow": "팔로우",
"following": "팔로잉",
"followers": "팔로워",
"followersOf": "{username}의 팔로워",
"followingOf": "{username}의 팔로잉",
"noFollowers": "아직 팔로워가 없습니다",
"noFollowing": "아직 팔로우하는 사람이 없습니다"
}
}

View File

@@ -1,193 +1,486 @@
{
"alphabet": {
"chooseCharacters": "ئۆگىنەرلىك ھەرپلەرنى تاللاڭ",
"japanese": "ياپونىيە كانا",
"english": "ئىنگلىز ئېلىپبې",
"uyghur": ۇيغۇر ئېلىپبېسى",
"esperanto": "ئېسپېرانتو ئېلىپبېسى",
"loading": "چىقىرىۋېتىلىۋاتىدۇ...",
"loadFailed": "چىقىرىش مەغلۇب بولدى، قايتا سىناڭ",
"hideLetter": "ھەرپنى يوشۇرۇش",
"showLetter": "ھەرپنى كۆرسىتىش",
"hideIPA": "IPA نى يوشۇرۇش",
"showIPA": "IPA نى كۆرسىتىش",
"roman": "روماللاشتۇرۇش",
"chooseCharacters": "ئۆگەنمەكچى بولغان ھەرپلەرنى تاللاڭ",
"chooseAlphabetHint": "ئۆگىنىشنى باشلاش ئۈچۈن بىر ئېلىپبە تاللاڭ",
"japanese": "ياپون يېزىقى",
"english": ىنگلىز ئېلىپبەسى",
"uyghur": "ئۇيغۇر ئېلىپبەسى",
"esperanto": "ئېسپېرانتو ئېلىپبەسى",
"loading": "يۈكلىنىۋاتىدۇ...",
"loadFailed": "يۈكلەش مەغلۇپ بولدى، قايتا سىناڭ",
"hideLetter": "ھەرپنى يوشۇر",
"showLetter": "ھەرپنى كۆرسەت",
"hideIPA": "IPA نى يوشۇر",
"showIPA": "IPA نى كۆرسەت",
"roman": "لاتىن يېزىقى",
"letter": "ھەرپ",
"random": "ئىختىيارىي ھالەت",
"randomNext": "ئىختىيارىي كېيىنكى"
"randomNext": "ئىختىيارىي كېيىنكى",
"previousLetter": "ئالدىنقى ھەرپ",
"nextLetter": "كېيىنكى ھەرپ",
"keyboardHint": "سول/ئوڭ يا ئوق كۇنۇپكىلىرىنى ياكى بوشلۇق كۇنۇپكىسىنى ئىختىيارىي ئالماشتۇرۇش ئۈچۈن ئىشلىتىڭ، ESC قايتىش ئۈچۈن",
"swipeHint": "سول/ئوڭ يا ئوق كۇنۇپكىلىرىنى ياكى سىيرىشنى ئىشلىتىپ يۆنىلىڭ، ESC قايتىش ئۈچۈن"
},
"folders": {
"title": "قىسقۇچلار",
"subtitle": "توپلىمىڭىزنى باشقۇرۇڭ",
"subtitle": "يىغىپ ساقلاشلىرىڭىزنى باشقۇرۇڭ",
"newFolder": "يېڭى قىسقۇچ",
"creating": "قۇرۇۋاتىدۇ...",
"noFoldersYet": "قىسقۇچ يوق",
"folderInfo": ود: {id} • {totalPairs} جۈپ",
"enterFolderName": "قىسقۇچ نامىنى كىرگۈزۈڭ:",
"confirmDelete": "ئۆچۈرۈش ئۈچۈن «{name}» نى كىرگۈزۈڭ:"
"noFoldersYet": "تېخى قىسقۇچ يوق",
"folderInfo": ىملىك: {id} • {totalPairs} جۈپ",
"enterFolderName": "قىسقۇچ ئاتىنى كىرگۈزۈڭ:",
"confirmDelete": "ئۆچۈرۈش ئۈچۈن \"{name}\" نى كىرگۈزۈڭ:",
"myFolders": "قىسقۇچلىرىم",
"publicFolders": "ئاممىۋى قىسقۇچلار",
"public": "ئاممىۋى",
"private": "شەخسىي",
"setPublic": "ئاممىۋى قىلىپ تەڭشە",
"setPrivate": "شەخسىي قىلىپ تەڭشە",
"publicFolderInfo": "{userName} • {totalPairs} جۈپ",
"searchPlaceholder": "ئاممىۋى قىسقۇچلارنى ئىزدەڭ...",
"loading": "يۈكلىنىۋاتىدۇ...",
"noPublicFolders": "ئاممىۋى قىسقۇچ تېپىلمىدى",
"unknownUser": "نامەلۇم ئىشلەتكۈچى",
"enterNewName": "يېڭى ئات كىرگۈزۈڭ:",
"favorite": "يىغىپ ساقلا",
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ"
},
"decks": {
"title": "دېكلار",
"noDecks": "تېخى دېك يوق",
"deckName": "دېك ئاتى",
"totalCards": "جەمئىي كارتا",
"createdAt": "قۇرۇلغان ۋاقتى",
"actions": "مەشغۇلاتلار",
"view": "كۆرۈش",
"subtitle": "دېكلەرنى باشقۇرۇڭ",
"newDeck": "يېڭى دېك",
"noDecksYet": "دېك يوق",
"loading": "يۈكلىنىۋاتىدۇ...",
"deckInfo": "ID: {id} · {totalCards} كارتا",
"enterDeckName": "دېك ئاتى:",
"enterNewName": "يېڭى ئات:",
"confirmDelete": "ئۆچۈرۈش: \"{name}\"",
"public": "ئاممىۋىي",
"private": "شەخسىي",
"setPublic": "ئاممىۋىي قىلىش",
"setPrivate": "شەخسىي قىلىش",
"importApkg": "APKG ئەكىرىش",
"exportApkg": "APKG چىقىرىش",
"clickToUpload": "چېكىپ يۈكلەش",
"apkgFilesOnly": ".apkg ھۆججىتىلا",
"parsing": "تەھلىل قىلىنىۋاتىدۇ...",
"foundDecks": "{count} دېك تېپىلدى",
"back": "قايتىش",
"import": "ئەكىرىش",
"importing": "ئەكىرىلىۋاتىدۇ...",
"exportSuccess": "چىقىرىش مۇۋەپپەقىيەتلىك",
"goToDecks": "دېكلەرگە بېرىش"
},
"folder_id": {
"unauthorized": "سىز بۇ قىسقۇچنىڭ ئىگىسى ئەمەس",
"back": "كەينىگە",
"unauthorized": "بۇ قىسقۇچنىڭ ئىگىسى ئەمەسسىز",
"back": "قايتىش",
"textPairs": "تېكىست جۈپلىرى",
"itemsCount": "{count} تۈر",
"memorize": "ئەستە ساقلاش",
"loadingTextPairs": "تېكىست جۈپلىرى چىقىرىۋېتىلىۋاتىدۇ...",
"memorize": "يادلاش",
"loadingTextPairs": "تېكىست جۈپلىرى يۈكلىنىۋاتىدۇ...",
"noTextPairs": "بۇ قىسقۇچتا تېكىست جۈپى يوق",
"addNewTextPair": "يېڭى تېكىست جۈپى قوشۇڭ",
"addNewTextPair": "يېڭى تېكىست جۈپى قوشۇش",
"add": "قوشۇش",
"updateTextPair": "تېكىست جۈپىنى يېڭىلاڭ",
"updateTextPair": "تېكىست جۈپىنى يېڭىلاش",
"update": "يېڭىلاش",
"text1": "تېكىست 1",
"text2": "تېكىست 2",
"language1": "تىل 1",
"language2": "تىل 2",
"enterLanguageName": "تىل نامىنى كىرگۈزۈڭ",
"enterLanguageName": "تىل ئاتىنى كىرگۈزۈڭ",
"edit": "تەھرىرلەش",
"delete": "ئۆچۈرۈش",
"permissionDenied": "بۇ مەشغۇلاتنى ئىجرا قىلىش ھوقۇقىڭىز يوق",
"permissionDenied": "بۇ مەشغۇلاتنى ئېلىپ بېرىش ھوقۇقىڭىز يوق",
"error": {
"update": "بۇ تۈرنى يېڭىلاش ھوقۇقىڭىز يوق.",
"delete": "بۇ تۈرنى ئۆچۈرۈش ھوقۇقىڭىز يوق.",
"add": "بۇ قىسقۇچقا تۈر قوشۇش ھوقۇقىڭىز يوق.",
"rename": "بۇ قىسقۇچنىڭ نامىنى ئۆزگەرتىش ھوقۇقىڭىز يوق.",
"rename": "بۇ قىسقۇچنىڭ ئاتىنى ئۆزگەرتىش ھوقۇقىڭىز يوق.",
"deleteFolder": "بۇ قىسقۇچنى ئۆچۈرۈش ھوقۇقىڭىز يوق."
}
},
"deck_id": {
"unauthorized": "بۇ دېكنىڭ ئىگىسى ئەمەس",
"back": "قايتىش",
"cards": "كارتلار",
"itemsCount": "{count} تۈر",
"memorize": "يادلاش",
"loadingCards": "كارتلار يۈكلىنىۋاتىدۇ...",
"noCards": "بۇ دېكتا كارت يوق",
"card": "كارتا",
"addNewCard": "يېڭى كارتا قوشۇش",
"add": "قوشۇش",
"adding": "قوشۇلىۋاتىدۇ...",
"updateCard": "كارتىنى يېڭىلاش",
"update": "يېڭىلاش",
"updating": "يېڭىلىنىۋاتىدۇ...",
"word": "سۆز",
"definition": "ئېنىقلىما",
"ipa": "IPA",
"example": "مىسال",
"wordAndDefinitionRequired": "سۆز ۋە ئېنىقلىما زۆرۈر",
"edit": "تەھرىرلەش",
"delete": "ئۆچۈرۈش",
"permissionDenied": "بۇ مەشغۇلاتنى ئېلىپ بېرىش ھوقۇقىڭىز يوق",
"resetProgress": "ئىلگىرىلەشنى ئەسلىگە قايتۇرۇش",
"resetProgressTitle": "ئۆگىنىش ئىلگىرىلەشىنى ئەسلىگە قايتۇرۇش",
"resetProgressConfirm": "ئىلگىرىلەشنى ئەسلىگە قايتۇرامسىز؟",
"resetSuccess": "ئەسلىگە قايتۇرۇلدى",
"resetting": "ئەسلىگە قايتۇرۇۋاتىدۇ...",
"cancel": "بىكار قىلىش",
"settings": "تەڭشەكلەر",
"settingsTitle": "دېك تەڭشەكلىرى",
"newPerDay": "كۈندىلىك يېڭى",
"newPerDayHint": "كۈندە يېڭى كارتا سانى",
"revPerDay": "كۈندىلىك تەكرار",
"revPerDayHint": "كۈندە تەكرار سانى",
"save": "ساقلاش",
"saving": "ساقلاۋاتىدۇ...",
"settingsSaved": "تەڭشەكلەر ساقلاندى",
"todayNew": "بۈگۈنكى يېڭى",
"todayReview": "بۈگۈنكى تەكرار",
"todayLearning": "ئۆگىنىۋاتىدۇ",
"error": {
"update": "يېڭىلاش ھوقۇقى يوق",
"delete": "ئۆچۈرۈش ھوقۇقى يوق",
"add": "قوشۇش ھوقۇقى يوق"
},
"ipaPlaceholder": "IPA كىرگۈزۈڭ",
"examplePlaceholder": "مىسال كىرگۈزۈڭ",
"wordRequired": "سۆز كىرگۈزۈڭ",
"definitionRequired": "ئېنىقلىما كىرگۈزۈڭ",
"cardAdded": "كارتا قوشۇلدى",
"cardType": "كارتا تىپى",
"wordCard": "سۆز كارتىسى",
"phraseCard": "جۈملە كارتىسى",
"sentenceCard": "جۈملە كارتىسى",
"sentence": "جۈملە",
"sentencePlaceholder": "جۈملە كىرگۈزۈڭ",
"wordPlaceholder": "سۆز كىرگۈزۈڭ",
"queryLang": "سۈرۈشتۈرۈش تىلى",
"enterLanguageName": "تىل ئاتىنى كىرگۈزۈڭ",
"english": "ئىنگىلىزچە",
"chinese": "خەنزۇچە",
"japanese": "ياپونچە",
"korean": "كورىيەچە",
"meanings": "مەنىلىرى",
"addMeaning": "مەنا قوشۇش",
"partOfSpeech": "سۆز بۆلىكى",
"deleteConfirm": "بۇ كارتىنى ئۆچۈرەمسىز؟",
"cardDeleted": "كارتا ئۆچۈرۈلدى",
"cardUpdated": "كارتا يېڭىلاندى"
},
"home": {
"title": "تىل ئۆگىنىڭ",
"description": "بۇ سىزنى دۇنيادىكى ھەممە تىلنى، جۈملىدىن سۈنئىي تىللارنىمۇ ئۆگىنىشىڭىزغا ياردەم بېرىدىغان ناھايىتى پايدىلىق تور بېكەت.",
"title": "تىل ئۆگىنىش",
"description": "بۇ دۇنيادىكى almost ھەر بىر تىلنى، جۈملىدىن سۈنئىي تىللارنى ئۆگىنىشىڭىزگە ياردەم بېرىدىغان ئىنتايىن قوللىنىشلىق تور بېكەت.",
"explore": "ئىزدىنىش",
"fortune": {
"quote": "Stay hungry, stay foolish.",
"author": "— ستىۋ جوۋبس"
"quote": "ئاچ قورساق، ئەخمەق بولۇپ تۇرۇڭ.",
"author": "— Steve Jobs"
},
"translator": {
"name": "تەرجىمە",
"description": "خالىغان تىلغا تەرجىمە قىلىپ خەلقئارالىق فونېتىك ئېلىپبې (IPA) بىلەن ئىزاھاتلاش"
"name": "تەرجىمان",
"description": "ھەر قانداق تىلغا تەرجىمە قىلىڭ ۋە خەلقئارالىق فونېتىكىلىق ئېلىپبە (IPA) بىلەن ئىزاھلاڭ"
},
"textSpeaker": {
"name": "تېكىست ئوقۇغۇچى",
"description": "تېكىستنى پەرقلەندۈرۈپ ئوقىيدۇ، دەۋرىي ئوقۇش ۋە سۈرئەت تەڭشەشنى قوللايدۇ"
"description": "تېكىستنى تونۇپ ۋە ئۈنلۈك ئوقۇپ بېرىدۇ، دەۋرىي قويۇش ۋە سۈرئەت تەڭشەشنى قوللايدۇ"
},
"srtPlayer": {
"name": "SRT سىن ئوپىراتورى",
"description": "SRT خەت ئاستى فايلى ئاساسىدا سىننى جۈملە-جۈملە قويۇپ، يەرلىك ئىخچام ئاۋازنى ئىمتىلايدۇ"
"name": "SRT ۋىدېئو قويغۇچ",
"description": "SRT تر پودكاست ھۆججەتلىرىگە ئاساسەن ۋىدېئولارنى جۈمە بويىچە قويۇپ، ئانا تىللىقلارنىڭ تەلەپپۇزىنى دوراڭ"
},
"alphabet": {
"name": "ئېلىپبې",
"description": "ئېلىپبېدىن يېڭى تىل ئۆگىنىشنى باشلاڭ"
"name": "ئېلىپبە",
"description": "يېڭى بىر تىلنى ئېلىپبەدىن باشلاپ ئۆگىنىڭ"
},
"memorize": {
"name": "ئەستە ساقلاش",
"description": "تىل A دىن تىل B غا، تىل B دىن تىل A غا، دىكتات قوللايدۇ"
"name": "يادلاش",
"description": "تىل A دىن تىل B گە، تىل B دىن تىل A غا، دىكتات قىلىشنى قوللايدۇ"
},
"dictionary": {
"name": "لۇغەت",
"description": "سۆز ۋە ئىبارە ئىزدەپ، تەپسىلىي ئىزاھات ۋە مىساللار بىلەن تەمىنلەيدۇ"
"description": "سۆزلەر ۋە ئىبارىلەرنى تەپسىلىي ئېنىقلىما ۋە مىساللار بىلەن ئىزدەڭ"
},
"moreFeatures": {
"name": "تېخىمۇ كۆپ ئىقتىدار",
"description": "ئىشلەۋاتىدۇ، كۈتكۈن بولۇڭ"
"name": "تېخىمۇ كۆپ ئىقتىدارلار",
"description": "تەرەققىيات ئاستىدا، دىققەت قىلىپ تۇرۇڭ"
}
},
"auth": {
"title": "دەلىللەش",
"title": "كىرىش",
"signUpTitle": "تىزىملىتىش",
"signIn": "كىرىش",
"signUp": "تىزىملىتىش",
"email": "ئېلخەت",
"password": "ئىم",
"confirmPassword": "ئىمنى جەزملەش",
"name": "نام",
"username": "ئىشلەتكۈچى نامى",
"emailOrUsername": "ئېلخەت ياكى ئىشلەتكۈچى نامى",
"password": "پارول",
"confirmPassword": "پارولنى جەزىملەڭ",
"name": "ئىسىم",
"username": "ئىشلەتكۈچى ئاتى",
"emailOrUsername": "ئېلخەت ياكى ئىشلەتكۈچى ئاتى",
"signInButton": "كىرىش",
"signUpButton": "تىزىملىتىش",
"noAccount": "ھېساباتىڭىز يوقمۇ؟",
"hasAccount": "ھېساباتىڭىز بارمۇ؟",
"signInWithGitHub": "GitHub بىلەن كىرىڭ",
"signUpWithGitHub": "GitHub بىلەن تىزىملىتىڭ",
"invalidEmail": ىناۋەتلىك ئېلخەت ئادرېسى كىرگۈزۈڭ",
"passwordTooShort": "ئىم كەم دېگەندە 8 ھەرپتىن تۇرۇشى كېرەك",
"passwordsNotMatch": "ئىم ماس كەلمەيدۇ",
"nameRequired": "نامىڭىزنى كىرگۈزۈڭ",
"usernameRequired": "ئىشلەتكۈچى نامىڭىزنى كىرگۈزۈڭ",
"usernameTooShort": "ئىشلەتكۈچى نامى كەم دېگەندە 3 ھەرپتىن تۇرۇشى كېرەك",
"usernameInvalid": "ئىشلەتكۈچى نامى پەقەت ھەرپ، سان ۋە ئاستى سىزىقنى ئۆز ئىچىگە ئالىدۇ",
"emailRequired": "ئېلخىتىڭىزنى كىرگۈزۈڭ",
"identifierRequired": "ئېلخەت ياكى ئىشلەتكۈچى نامىڭىزنى كىرگۈزۈڭ",
"passwordRequired": "ئىمىڭىزنى كىرگۈزۈڭ",
"confirmPasswordRequired": "ئىمىڭىزنى جەزملەڭ",
"loading": "چىقىرىۋېتىلىۋاتىدۇ..."
"signInWithGitHub": "GitHub بىلەن كىرىش",
"signUpWithGitHub": "GitHub بىلەن تىزىملىتىش",
"invalidEmail": ۈنۈملۈك ئېلخەت ئادرېسى كىرگۈزۈڭ",
"passwordTooShort": "پارول ئەڭ ئاز 8 ھەرپ بولۇشى كېرەك",
"passwordsNotMatch": "پاروللار ماس كەلمەيدۇ",
"nameRequired": "ئىسىمىڭىزنى كىرگۈزۈڭ",
"usernameRequired": "ئىشلەتكۈچى ئاتىنى كىرگۈزۈڭ",
"usernameTooShort": "ئىشلەتكۈچى ئاتى ئەڭ ئاز 3 ھەرپ بولۇشى كېرەك",
"usernameInvalid": "ئىشلەتكۈچى ئاتى پەقەت ھەرپ، سان ۋە ئاستى سىزىقنى ئۆز ئىچىگە ئالىدۇ",
"emailRequired": "ئېلخەت كىرگۈزۈڭ",
"identifierRequired": "ئېلخەت ياكى ئىشلەتكۈچى ئاتىنى كىرگۈزۈڭ",
"passwordRequired": "پارول كىرگۈزۈڭ",
"confirmPasswordRequired": "پارولنى جەزىملەڭ",
"loading": "يۈكلىنىۋاتىدۇ...",
"confirm": "جەزىملەش",
"noAccountLink": "ھېساباتىڭىز يوقمۇ؟ تىزىملىتىڭ",
"hasAccountLink": "ھېساباتىڭىز بارمۇ؟ كىرىڭ",
"usernamePlaceholder": "ئىشلەتكۈچى ئاتى",
"emailPlaceholder": "ئېلخەت ئادرېسى",
"passwordPlaceholder": "پارول",
"usernameOrEmailPlaceholder": "ئىشلەتكۈچى ئاتى ياكى ئېلخەت",
"loginFailed": "كىرىش مەغلۇپ بولدى",
"signUpFailed": "تىزىملىتىش مەغلۇپ بولدى",
"fillAllFields": "ھەممە بۆلەكلەرنى تولدۇرۇڭ",
"enterCredentials": "ئىشلەتكۈچى ئاتى ۋە پارول كىرگۈزۈڭ",
"forgotPassword": "پارولنى ئۇنتۇپ قالدىڭىزمۇ",
"forgotPasswordHint": "ئېلخەت ئادرېسىڭىزنى كىرگۈزۈڭ، بىز سىزگە پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسى ئەۋەتىمىز.",
"sendResetEmail": "ئەسلىگە قايتۇرۇش ئېلخېتى ئەۋەتىش",
"resetPasswordFailed": "ئەسلىگە قايتۇرۇش ئېلخېتى ئەۋەتىش مەغلۇپ بولدى",
"resetPasswordEmailSent": "ئەسلىگە قايتۇرۇش ئېلخېتى مۇۋەپپەقىيەتلىك ئەۋەتىلدى",
"resetPasswordEmailSentHint": "پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسىنى ئېلخەت ئادرېسىڭىزغا ئەۋەتتۇق. ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ.",
"verifyYourEmail": "ئېلخەتنى دەلىللەش",
"verificationEmailSent": "دەلىللەش ئېلخېتى ئەۋەتىلدى",
"verificationEmailSentHint": "{email} غا دەلىللەش ئېلخېتى ئەۋەتتۇق. ئېلخەتتىكى ئۇلانمىنى چېكىپ ھېساباتىڭىزنى دەلىللەڭ.",
"checkYourEmail": "ئېلخېتىڭىزنى تەكشۈرۈڭ",
"backToLogin": "كىرىشكە قايتىش",
"resetPassword": "پارولنى ئەسلىگە قايتۇرۇش",
"newPassword": "يېڭى پارول",
"invalidToken": "ئۇلانما ئىناۋەتسىز ياكى ۋاقتى ئۆتكەن",
"invalidTokenHint": "بۇ پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسى ئىناۋەتسىز ياكى ۋاقتى ئۆتكەن. يېڭىدىن سوراڭ.",
"requestNewToken": "يېڭى ئەسلىگە قايتۇرۇش ئۇلانمىسى سوراش",
"resetPasswordSuccess": "پارول مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى",
"resetPasswordSuccessTitle": "پارول ئەسلىگە قايتۇرۇش تاماملاندى",
"resetPasswordSuccessHint": "پارولىڭىز مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى. يېڭى پارول بىلەن كىرسىڭىز بولىدۇ.",
"emailNotVerified": "ئېلخەت ئادرېسىڭىزنى دەلىللەڭ",
"emailNotVerifiedHint": "ئېلخەت ئادرېسىڭىز دەلىللەنمىگەن. ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ ياكى يېڭى دەلىللەش ئېلخېتى سوراڭ.",
"resendVerification": "دەلىللەش ئېلخېتىنى قايتا ئەۋەتىش",
"resendSuccess": "دەلىللەش ئېلخېتى ئەۋەتىلدى! ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ.",
"resendFailed": "دەلىللەش ئېلخېتى ئەۋەتىش مەغلۇپ بولدى"
},
"memorize": {
"folder_selector": {
"selectFolder": "قىسقۇچ تاللاڭ",
"noFolders": "قىسقۇچ تېپىلمىدى",
"folderInfo": "{id}. {name} ({count})"
"deck_selector": {
"selectDeck": "دېك تاللاش",
"noDecks": "دېك يوق",
"goToDecks": "دېكلەرگە بار",
"noCards": "كارتا يوق",
"new": "يېڭى",
"learning": "ئۆگىنىش",
"review": "تەكرار",
"due": "ۋاقتى كەلدى"
},
"memorize": {
"answer": "جاۋاب",
"next": "كېيىنكى",
"reverse": "تەتۈر",
"dictation": "دىكتات",
"noTextPairs": "ئىشلەتكىلى بولىدىغان تېكىست جۈپى يوق",
"disorder": "بەت ئارلاش",
"previous": "ئىلگىرىكى"
"review": {
"loading": "يۈكلىنىۋاتىدۇ...",
"backToDecks": "دېكلەرگە قايتىش",
"allDone": "ھەممىسى تامام!",
"allDoneDesc": "بۈگۈنكى ئۆگىنىش تامام!",
"reviewedCount": "{count} كارتا تەكرارلاندى",
"progress": "{current} / {total}",
"nextReview": "كېيىنكى تەكرار",
"interval": "ئارىلىق",
"ease": "قىيىنلىق",
"lapses": "خاتالىق",
"showAnswer": "جاۋابنى كۆرسەت",
"nextCard": "كېيىنكى",
"again": "يەنە",
"hard": "قىيىن",
"good": "ياخشى",
"easy": "ئاسان",
"now": "ھازىر",
"lessThanMinute": "1 مىنۇتتىن ئاز",
"inMinutes": "{n} مىنۇتتىن كېيىن",
"inHours": "{n} سائەتتىن كېيىن",
"inDays": "{n} كۈندىن كېيىن",
"inMonths": "{n} ئايدىن كېيىن",
"minutes": "مىنۇت",
"days": "كۈن",
"months": "ئاي",
"minAbbr": "مىن",
"dayAbbr": "كۈن",
"cardTypeNew": "يېڭى",
"cardTypeLearning": "ئۆگىنىش",
"cardTypeReview": "تەكرار",
"cardTypeRelearning": "قايتا ئۆگىنىش",
"reverse": "ئەكسىچە",
"dictation": "ئىملا",
"clickToPlay": "چېكىپ قويۇش",
"yourAnswer": "جاۋابىڭىز",
"typeWhatYouHear": "ئاڭلىغىنىڭىزنى يېزىڭ",
"correct": "توغرا!",
"incorrect": "خاتا",
"restart": "قايتا باشلا",
"orderLimited": "تەرتىپلى چەكلەنگەن",
"orderInfinite": "تەرتىپلى چەكسىز",
"randomLimited": "ئىختىيارى چەكلەنگەن",
"randomInfinite": "ئىختىيارى چەكسىز",
"noIpa": "IPA يوق"
},
"page": {
"unauthorized": "بۇ قىسقۇچنى زىيارەت قىلىشقا ھوقۇقىڭىز يوق"
"unauthorized": "ھوقۇقسىز"
}
},
"navbar": {
"title": "تىل ئۆگىنىش",
"title": "learn-languages",
"sourceCode": "GitHub",
"sign_in": "كىرىش",
"profile": "پروفىل",
"folders": "قىسقۇچلار"
"profile": "شەخسىي ئۇچۇر",
"folders": "دېكلار",
"explore": "ئىزدىنىش",
"favorites": "يىغىپ ساقلاش",
"settings": "تەڭشەكلەر"
},
"ocr": {
"title": "OCR تونۇش",
"description": "رەسىمدىن تېكىست ئېلىش",
"uploadImage": "رەسىم يۈكلەش",
"dragDropHint": "سۆرەپ تاشلاش",
"supportedFormats": "قوللايدىغان فورمات: JPG, PNG, WEBP",
"selectDeck": "دېك تاللاش",
"chooseDeck": "دېك تاللاڭ",
"noDecks": "دېك يوق",
"languageHints": "تىل بېشارىتى",
"sourceLanguageHint": "مەنبە تىلى",
"targetLanguageHint": "نىشان تىلى",
"process": "بىر تەرەپ قىلىش",
"processing": "بىر تەرەپ قىلىنىۋاتىدۇ...",
"preview": "ئالدىن كۆرۈش",
"extractedPairs": "ئېلىنغان جۈپلەر",
"word": "سۆز",
"definition": "ئېنىقلىما",
"pairsCount": "{count} جۈپ",
"savePairs": "ساقلاش",
"saving": "ساقلاۋاتىدۇ...",
"saved": "ساقلاندى",
"saveFailed": "ساقلاش مەغلۇپ بولدى",
"noImage": "رەسىم يۈكلەڭ",
"noDeck": "دېك تاللاڭ",
"processingFailed": "بىر تەرەپ قىلىش مەغلۇپ بولدى",
"tryAgain": "قايتا سىناڭ",
"detectedLanguages": "تونۇلغان تىللار",
"uploadSection": "رەسىم يۈكلەش",
"dropOrClick": "تاشلاش ياكى چېكىش",
"changeImage": "رەسىم ئالماشتۇرۇش",
"invalidFileType": "ئىناۋەتسىز فايىل تىپى",
"deckSelection": "دېك تاللاش",
"sourceLanguagePlaceholder": "مەسىلەن: ئىنگلىزچە",
"targetLanguagePlaceholder": "مەسىلەن: ئۇيغۇرچە",
"processButton": "تونۇشنى باشلاش",
"resultsPreview": "نەتىجە ئالدىن كۆرۈش",
"saveButton": "دېككە ساقلاش",
"ocrSuccess": "OCR مۇۋەپپەقىيەتلىك",
"ocrFailed": "OCR مەغلۇپ بولدى",
"savedToDeck": "دېككە ساقلاندى",
"noResultsToSave": "نەتىجە يوق",
"detectedSourceLanguage": "تونۇلغان مەنبە تىلى",
"detectedTargetLanguage": "تونۇلغان نىشان تىلى"
},
"profile": {
"myProfile": "مېنىڭ پروفىلىم",
"myProfile": "شەخسىي ئۇچۇرۇم",
"email": "ئېلخەت: {email}",
"logout": "چىقىش"
"logout": "چىكىنىش"
},
"settings": {
"title": "تەڭشەكلەر",
"themeColor": "تېما رەڭگى",
"themeColorDescription": "ياقتۇرىدىغان تېما رەڭگىڭىزنى تاللاڭ"
},
"srt_player": {
"uploadVideo": "سىن يۈكلەڭ",
"uploadSubtitle": "خەت ئاستى يۈكلەڭ",
"uploadVideo": "ۋىدېئو يۈكلەش",
"uploadSubtitle": "تر پودكاست يۈكلەش",
"pause": "ۋاقىتلىق توختىتىش",
"play": "قويۇش",
"previous": ىلگىرىكى",
"previous": الدىنقى",
"next": "كېيىنكى",
"restart": "قايتا باشلاش",
"autoPause": "ئاپتوماتىك توختىتىش ({enabled})",
"uploadVideoAndSubtitle": "سىن ھەم خەت ئاستى فايلىنى يۈكلەڭ",
"uploadVideoFile": "سىن فايلى يۈكلەڭ",
"uploadSubtitleFile": "خەت ئاستى فايلى يۈكلەڭ",
"processingSubtitle": "خەت ئاستى فايلى بىر تەرەپ قىلىۋاتىدۇ...",
"needBothFiles": "ئۆگىنىشنى باشلاش ئۈچۈن سىن ھەم خەت ئاستى فايلىنىڭ ھەممىسى لازىم",
"videoFile": "سىن فايلى",
"subtitleFile": "خەت ئاستى فايلى",
"uploadVideoAndSubtitle": "ۋىدېئو ۋە تر پودكاست ھۆججەتلىرىنى يۈكلەڭ",
"uploadVideoFile": "ۋىدېئو ھۆججىتى يۈكلەڭ",
"uploadSubtitleFile": "تر پودكاست ھۆججىتى يۈكلەڭ",
"processingSubtitle": "تر پودكاست ھۆججىتى بىر تەرەپ قىلىنىۋاتىدۇ...",
"needBothFiles": "ئۆگىنىشنى باشلاش ئۈچۈن ۋىدېئو ۋە تر پودكاست ھۆججەتلىرى كېرەك",
"videoFile": "ۋىدېئو ھۆججىتى",
"subtitleFile": "تر پودكاست ھۆججىتى",
"uploaded": "يۈكلەندى",
"notUploaded": "يۈكلەنمىدى",
"upload": "يۈكلەش",
"uploadVideoButton": "ۋىدېئو يۈكلەش",
"uploadSubtitleButton": "تر پودكاست يۈكلەش",
"subtitleUploaded": "تر پودكاست يۈكلەندى ({count} تۈر)",
"subtitleNotUploaded": "تر پودكاست يۈكلەنمىدى",
"autoPauseStatus": "ئاپتوماتىك توختىتىش: {enabled}",
"on": "ئوچۇق",
"off": "تاقاق",
"videoUploadFailed": "سىن يۈكلەش مەغلۇب بولدى",
"subtitleUploadFailed": "خەت ئاستى يۈكلەش مەغلۇب بولدى"
"videoUploadFailed": "ۋىدېئو يۈكلەش مەغلۇپ بولدى",
"subtitleUploadFailed": "تر پودكاست يۈكلەش مەغلۇپ بولدى",
"subtitleLoadSuccess": "تر پودكاست مۇۋەپپەقىيەتلىك يۈكلەندى",
"subtitleLoadFailed": "تر پودكاست يۈكلەش مەغلۇپ بولدى",
"settings": "تەڭشەكلەر",
"shortcuts": "تېزلەتمەلەر",
"keyboardShortcuts": "كۇنۇپكا تاختىسى تېزلەتمەلىرى",
"playPause": "قويۇش/توختىتىش",
"autoPauseToggle": "ئاپتوماتىك توختىتىش",
"subtitleSettings": "ئاستى سىزىق تەڭشەكلىرى",
"fontSize": "خەت چوڭلۇقى",
"textColor": "خەت رەڭگى",
"backgroundColor": "تەگلىك رەڭگى",
"position": "ئورنى",
"opacity": "سۈزۈكلۈك",
"top": "ئۈستى",
"center": "ئوتتۇرا",
"bottom": "ئاستى"
},
"text_speaker": {
"generateIPA": "IPA ھاسىل قىلىش",
"viewSavedItems": "ساقلانغان تۈرلەرنى كۆرۈش",
"confirmDeleteAll": "ھەممىنى ئۆچۈرەمسىز؟ (H/Y)"
"confirmDeleteAll": "ھەممىنى ئۆچۈرەمسىز؟ (Y/N)",
"saved": "ساقلاندى",
"clearAll": "ھەممىنى تازىلاش",
"language": "تىل",
"customLanguage": "ياكى تىل كىرگۈزۈڭ...",
"languages": {
"auto": "ئاپتوماتىك",
"chinese": "خەنزۇچە",
"english": "ئىنگلىزچە",
"japanese": "ياپونچە",
"korean": "كورېيەچە",
"french": "فرانسۇزچە",
"german": "گېرمانچە",
"italian": "ئىتاليانچە",
"spanish": "ئىسپانچە",
"portuguese": "پورتۇگالچە",
"russian": "رۇسچە"
}
},
"translator": {
"detectLanguage": "تىل پەرقلەندۈرۈش",
"generateIPA": "IPA ھاسىل قىلىش",
"detectLanguage": "تىلنى تونۇش",
"sourceLanguage": "مەنبە تىلى",
"auto": "ئاپتوماتىك",
"generateIPA": "ipa ھاسىل قىلىش",
"translateInto": "تەرجىمە قىلىش",
"chinese": "خەنزۇچە",
"english": "ئىنگلىزچە",
"french": "فرانسۇزچە",
"french": ىرانسۇزچە",
"german": "گېرمانچە",
"italian": "ئىتاليانچە",
"japanese": "ياپونچە",
@@ -196,69 +489,150 @@
"russian": "رۇسچە",
"spanish": "ئىسپانچە",
"other": "باشقا",
"translating": "تەرجىمە قىلىۋاتىدۇ...",
"translating": "تەرجىمە قىلىنىۋاتىدۇ...",
"translate": "تەرجىمە قىلىش",
"inputLanguage": "بىر تىل كىرگۈزۈڭ.",
"history": "تارىخ",
"enterLanguage": "تىل كىرگۈزۈڭ",
"add_to_folder": {
"notAuthenticated": "دەلىتلەنمىدىڭىز",
"chooseFolder": "قوشۇلىدىغان قىسقۇچنى تاللاڭ",
"notAuthenticated": "تىزىملىتىلمىدىڭىز",
"chooseFolder": "قوشۇش ئۈچۈن قىسقۇچ تاللاڭ",
"noFolders": "قىسقۇچ تېپىلمىدى",
"folderInfo": "{id}. {name}",
"close": "تاقاش",
"success": "تېكىست جۈپى قىسقۇچقا قوشۇلدى",
"error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇب بولدى"
"error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇپ بولدى"
},
"autoSave": "ئاپتوماتىك ساقلاش"
"autoSave": "ئاپتوماتىك ساقلاش",
"customLanguage": "ياكى تىل تىل كىرۇڭ...",
"pleaseLogin": "كارتا ساقلاش ئۈچۈن كىرىڭ",
"pleaseCreateDeck": "ئاۋۋال دېك قۇرۇڭ",
"noTranslationToSave": "ساقلايدىغان تەرجىمە يوق",
"noDeckSelected": "دېك تاللانمىدى",
"saveAsCard": "كارتا ساقلاش",
"selectDeck": "دېك تاللاش",
"front": "ئالدى",
"back": "كەينى",
"cancel": "بىكار قىلىش",
"save": "ساقلاش",
"savedToDeck": "{deckName} غا ساقلاندى",
"saveFailed": "ساقلاش مەغلۇپ"
},
"dictionary": {
"title": "لۇغەت",
"description": "تەپسىلىي ئىزاھات ۋە مىساللار بىلەن سۆز ۋە ئىبارە ئىزدەش",
"searchPlaceholder": "ئىزدەيدىغان سۆز ياكى ئىبارە كىرگۈزۈڭ...",
"description": "سۆزلەر ۋە ئىبارىلەرنى تەپسىلىي ئېنىقلىما ۋە مىساللار بىلەن ئىزدەڭ",
"searchPlaceholder": "ئىزدەش ئۈچۈن سۆز ياكى ئىبارە كىرگۈزۈڭ...",
"searching": "ئىزدەۋاتىدۇ...",
"search": "ئىزدە",
"languageSettings": "تىل تەڭشىكى",
"queryLanguage": "سۈرەشتۈرۈش تىلى",
"queryLanguageHint": "ئىزدەمدەكچى بولغان سۆز/ئىبارە قايسى تىلدا",
"definitionLanguage": ىزاھات تىلى",
"definitionLanguageHint": ىزاھاتنى قايسى تىلدا كۆرۈشنى ئويلىشىسىز",
"search": "ئىزدەش",
"languageSettings": "تىل تەڭشەكلىرى",
"queryLanguage": "سۈرۈشتۈرۈش تىلى",
"queryLanguageHint": "ئىزدىمەكچى بولغان سۆز/ئىبارە قايسى تىلدا",
"definitionLanguage": ېنىقلىما تىلى",
"definitionLanguageHint": ېنىقلىمىلارنى قايسى تىلدا كۆرمەكچى",
"otherLanguagePlaceholder": "ياكى باشقا تىل كىرگۈزۈڭ...",
"currentSettings": "نۆۋەتتىكى تەڭشەك: سۈرەشتۈرۈش {queryLang}، ئىزاھات {definitionLang}",
"relookup": "قايتا ئىزدە",
"saveToFolder": "قىسقۇچقا ساقلا",
"loading": "يۈكلىۋاتىدۇ...",
"other": "باشقا",
"currentSettings": "نۆۋەتتىكى تەڭشەكلەر: سۈرۈشتۈرۈش {queryLang}، ئېنىقلىما {definitionLang}",
"relookup": "قايتا ئىزدەش",
"saveToFolder": "قىسقۇچقا ساقلاش",
"loading": "يۈكلىنىۋاتىدۇ...",
"noResults": "نەتىجە تېپىلمىدى",
"tryOtherWords": "باشقا سۆز ياكى ئىبارە سىناڭ",
"welcomeTitle": "لۇغەتكە مەرھەمەت",
"tryOtherWords": "باشقا سۆز ياكى ئىبارىلەرنى سىناڭ",
"welcomeTitle": "لۇغەتكە خۇش كەلدىڭىز",
"welcomeHint": "ئىزدەشنى باشلاش ئۈچۈن يۇقىرىدىكى ئىزدەش رامكىسىغا سۆز ياكى ئىبارە كىرگۈزۈڭ",
"lookupFailed": "ئىزدەش مەغلۇب بولدى، كېيىنرەك قايتا سىناڭ",
"relookupSuccess": "مۇۋەپپەقىيەتلىك قايتا ئىزدىدى",
"relookupFailed": "لۇغەت قايتا ئىزدىشى مەغلۇب بولدى",
"pleaseLogin": "ئاۋۋال تىزىملىتىڭ",
"pleaseCreateFolder": "ئاۋۋال قىسقۇچ قۇرۇڭ",
"lookupFailed": "ئىزدەش مەغلۇپ بولدى، كېيىن قايتا سىناڭ",
"relookupSuccess": "مۇۋەپپەقىيەتلىك قايتا ئىزدەلدى",
"relookupFailed": "لۇغەت قايتا ئىزدەش مەغلۇپ بولدى",
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ",
"pleaseCreateFolder": "ئاۋۋال بىر قىسقۇچ قۇرۇڭ",
"savedToFolder": "قىسقۇچقا ساقلاندى: {folderName}",
"saveFailed": "ساقلاش مەغلۇب بولدى، كېيىنرەك قايتا سىناڭ"
"saveFailed": "ساقلاش مەغلۇپ بولدى، كېيىن قايتا سىناڭ",
"definition": "ئېنىقلىما",
"example": "مىسال"
},
"explore": {
"title": "ئىزدىنىش",
"subtitle": "ئاممىۋى قىسقۇچلارنى بايقاڭ",
"searchPlaceholder": "ئاممىۋى قىسقۇچلارنى ئىزدەڭ...",
"loading": "يۈكلىنىۋاتىدۇ...",
"noFolders": "ئاممىۋى قىسقۇچ تېپىلمىدى",
"folderInfo": "{userName} • {totalPairs} جۈپ",
"unknownUser": "نامەلۇم ئىشلەتكۈچى",
"favorite": "يىغىپ ساقلا",
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ",
"sortByFavorites": "يىغىپ ساقلاش بويىچە تەرتىپلەش",
"sortByFavoritesActive": "يىغىپ ساقلاش بويىچە تەرتىپلەشنى بىكار قىلىش",
"noDecks": "ئاممىۋىي دېك يوق",
"deckInfo": "{userName} · {totalCards} كارتا"
},
"exploreDetail": {
"title": "قىسقۇچ تەپسىلاتلىرى",
"createdBy": "قۇرغۇچى: {name}",
"unknownUser": "نامەلۇم ئىشلەتكۈچى",
"totalPairs": "جەمئىي جۈپ",
"favorites": "يىغىپ ساقلانغانلار",
"createdAt": "قۇرۇلغان ۋاقتى",
"viewContent": "مەزمۇننى كۆرۈش",
"favorite": "يىغىپ ساقلا",
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
"favorited": "يىغىپ ساقلاندى",
"unfavorited": "يىغىپ ساقلاش بىكار قىلىندى",
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ",
"totalCards": "{count} كارتا"
},
"favorites": {
"title": "يىغىپ ساقلىغانلىرىم",
"subtitle": "يىغىپ ساقلىغان قىسقۇچلىرىڭىز",
"loading": "يۈكلىنىۋاتىدۇ...",
"noFavorites": "تېخى يىغىپ ساقلانمىغان",
"folderInfo": "{userName} • {totalPairs} جۈپ",
"unknownUser": "نامەلۇم ئىشلەتكۈچى"
},
"user_profile": {
"anonymous": "ئىسىمسىز",
"anonymous": "نامسىز",
"email": "ئېلخەت",
"verified": "دەلىللەندى",
"unverified": "دەلىتلەنمىدى",
"accountInfo": "ھېسابات ئۇچۇرى",
"userId": "ئىشلەتكۈچى كودى",
"username": "ئىشلەتكۈچى نامى",
"displayName": "كۆرسىتىلىدىغان نام",
"verified": "دەلىللەنگەن",
"unverified": "دەلىللەنمىگەن",
"accountInfo": "ھېسابات ئۇچۇرلىرى",
"userId": "ئىشلەتكۈچى كىملىكى",
"username": "ئىشلەتكۈچى ئاتى",
"displayName": "كۆرسىتىش ئاتى",
"notSet": "تەڭشەلمىگەن",
"memberSince": "تىزىملاتقان ۋاقىت",
"folders": {
"title": "قىسقۇچلار",
"noFolders": "قىسقۇچ يوق",
"folderName": "قىسقۇچ نامى",
"totalPairs": "تېكىست جۈپ سانى",
"createdAt": "قۇرۇلغان ۋاقىت",
"actions": "مەشغۇلات",
"memberSince": "ئەزا بولغاندىن بېرى",
"logout": "چىكىنىش",
"deleteAccount": {
"button": "ھېساباتنى ئۆچۈرۈش",
"title": "ھېساباتنى ئۆچۈرۈش",
"warning": "بۇ مەشغۇلاتنى ئەسلىگە قايتۇرغىلى بولمايدۇ. بارلىق سانلىق مەلۇماتلىرىڭىز مەڭگۈلۈك ئۆچۈرۈلىدۇ.",
"warningDecks": "بارلىق دېك ۋە كارتلىرىڭىز",
"warningCards": "بارلىق ئۆگىنىش ئىلگىرىلەشلىرىڭىز",
"warningHistory": "بارلىق تەرجىمە ۋە لۇغەت تارىخىڭىز",
"warningPermanent": "بۇ مەشغۇلاتنى بىكار قىلغىلى بولمايدۇ",
"confirmLabel": "جەزىملەش ئۈچۈن ئىشلەتكۈچى ئاتىڭىزنى كىرگۈزۈڭ:",
"usernameMismatch": "ئىشلەتكۈچى ئاتى ماس كەلمەيدۇ",
"cancel": "بىكار قىلىش",
"confirm": "ھېساباتىمنى ئۆچۈرۈش",
"success": "ھېسابات مۇۋەپپەقىيەتلىك ئۆچۈرۈلدى",
"failed": "ھېساباتنى ئۆچۈرۈش مەغلۇپ بولدى"
},
"decks": {
"title": "دېكلار",
"noDecks": "تېخى دېك يوق",
"deckName": "دېك ئاتى",
"totalCards": "جەمئىي كارتا",
"createdAt": "قۇرۇلغان ۋاقتى",
"actions": "مەشغۇلاتلار",
"view": "كۆرۈش"
}
},
"joined": "قوشۇلدى"
},
"follow": {
"follow": "ئەگىشىش",
"following": "ئەگىشىۋاتىدۇ",
"followers": "ئەگەشكۈچىلەر",
"followersOf": "{username} نىڭ ئەگەشكۈچىلىرى",
"followingOf": "{username} نىڭ ئەگىشىدىغانلىرى",
"noFollowers": "تېخى ئەگەشكۈچى يوق",
"noFollowing": "تېخى ئەگىشىدىغان ئادەم يوق"
}
}

View File

@@ -1,6 +1,7 @@
{
"alphabet": {
"chooseCharacters": "请选择您想学习的字符",
"chooseAlphabetHint": "选择一种语言的字母表开始学习",
"japanese": "日语假名",
"english": "英文字母",
"uyghur": "维吾尔字母",
@@ -14,7 +15,11 @@
"roman": "罗马音",
"letter": "字母",
"random": "随机模式",
"randomNext": "随机下一个"
"randomNext": "随机下一个",
"previousLetter": "上一个字母",
"nextLetter": "下一个字母",
"keyboardHint": "使用左右箭头键或空格键随机切换ESC键返回",
"swipeHint": "使用左右箭头键或滑动切换字母"
},
"folders": {
"title": "文件夹",
@@ -69,6 +74,77 @@
"deleteFolder": "您没有权限删除此文件夹"
}
},
"deck_id": {
"unauthorized": "您不是此牌组的所有者",
"back": "返回",
"cards": "卡片",
"itemsCount": "{count} 个",
"memorize": "记忆",
"loadingCards": "加载卡片中...",
"noCards": "此牌组中没有卡片",
"card": "卡片",
"addNewCard": "添加新卡片",
"add": "添加",
"adding": "添加中...",
"updateCard": "更新卡片",
"update": "更新",
"updating": "更新中...",
"word": "单词",
"definition": "释义",
"ipa": "音标",
"example": "例句",
"wordAndDefinitionRequired": "单词和释义都是必需的",
"edit": "编辑",
"delete": "删除",
"permissionDenied": "您没有权限执行此操作",
"resetProgress": "重置进度",
"resetProgressTitle": "重置学习进度",
"resetProgressConfirm": "确定要重置这个卡组的学习进度吗?",
"resetSuccess": "进度已重置",
"resetting": "重置中...",
"cancel": "取消",
"settings": "设置",
"settingsTitle": "卡组设置",
"newPerDay": "每日新卡",
"newPerDayHint": "每天学习的新卡片数量",
"revPerDay": "每日复习",
"revPerDayHint": "每天复习的卡片数量",
"save": "保存",
"saving": "保存中...",
"settingsSaved": "设置已保存",
"todayNew": "今日新卡",
"todayReview": "今日复习",
"todayLearning": "学习中",
"error": {
"update": "您没有权限更新此卡片",
"delete": "您没有权限删除此卡片",
"add": "您没有权限向此牌组添加卡片"
},
"ipaPlaceholder": "输入IPA音标",
"examplePlaceholder": "输入例句",
"wordRequired": "请输入单词",
"definitionRequired": "请输入至少一个释义",
"cardAdded": "卡片已添加",
"cardType": "卡片类型",
"wordCard": "单词卡",
"phraseCard": "短语卡",
"sentenceCard": "句子卡",
"sentence": "句子",
"sentencePlaceholder": "输入句子",
"wordPlaceholder": "输入单词",
"queryLang": "查询语言",
"enterLanguageName": "请输入语言名称",
"english": "英语",
"chinese": "中文",
"japanese": "日语",
"korean": "韩语",
"meanings": "释义",
"addMeaning": "添加释义",
"partOfSpeech": "词性",
"deleteConfirm": "确定删除这张卡片吗?",
"cardDeleted": "卡片已删除",
"cardUpdated": "卡片已更新"
},
"home": {
"title": "学语言",
"description": "这里是一个非常有用的网站,可以帮助您学习世界上几乎每一种语言,包括人造语言。",
@@ -108,6 +184,7 @@
},
"auth": {
"title": "登录",
"signUpTitle": "注册",
"signIn": "登录",
"signUp": "注册",
"email": "邮箱",
@@ -133,25 +210,102 @@
"identifierRequired": "请输入邮箱或用户名",
"passwordRequired": "请输入密码",
"confirmPasswordRequired": "请确认密码",
"loading": "加载中..."
"loading": "加载中...",
"confirm": "确认",
"noAccountLink": "没有账号?去注册",
"hasAccountLink": "已有账号?去登录",
"usernamePlaceholder": "用户名",
"emailPlaceholder": "邮箱地址",
"passwordPlaceholder": "密码",
"usernameOrEmailPlaceholder": "用户名或邮箱地址",
"loginFailed": "登录失败",
"signUpFailed": "注册失败",
"fillAllFields": "请填写所有字段",
"enterCredentials": "请输入用户名和密码",
"forgotPassword": "忘记密码",
"forgotPasswordHint": "输入您的邮箱地址,我们将向您发送重置密码的链接。",
"sendResetEmail": "发送重置邮件",
"resetPasswordFailed": "发送重置邮件失败",
"resetPasswordEmailSent": "重置邮件已发送",
"resetPasswordEmailSentHint": "我们已向您的邮箱发送了密码重置链接,请查收。",
"verifyYourEmail": "验证您的邮箱",
"verificationEmailSent": "验证邮件已发送",
"verificationEmailSentHint": "我们已向 {email} 发送了验证邮件,请点击邮件中的链接完成验证。",
"checkYourEmail": "请查收邮件",
"backToLogin": "返回登录",
"resetPassword": "重置密码",
"newPassword": "新密码",
"invalidToken": "链接无效或已过期",
"invalidTokenHint": "此密码重置链接无效或已过期,请重新申请。",
"requestNewToken": "重新申请重置链接",
"resetPasswordSuccess": "密码重置成功",
"resetPasswordSuccessTitle": "密码重置完成",
"resetPasswordSuccessHint": "您的密码已成功重置,现在可以使用新密码登录了。",
"emailNotVerified": "请验证您的邮箱地址",
"emailNotVerifiedHint": "您的邮箱尚未验证。请检查收件箱或重新发送验证邮件。",
"resendVerification": "重新发送验证邮件",
"resendSuccess": "验证邮件已发送!请检查您的收件箱。",
"resendFailed": "发送验证邮件失败"
},
"memorize": {
"folder_selector": {
"selectFolder": "选择文件夹",
"noFolders": "未找到文件夹",
"folderInfo": "{id}. {name} ({count})"
"deck_selector": {
"selectDeck": "选择牌组",
"noDecks": "未找到牌组",
"goToDecks": "前往牌组",
"noCards": "无卡片",
"new": "新卡片",
"learning": "学习中",
"review": "复习",
"due": "待复习"
},
"memorize": {
"answer": "答案",
"next": "下一个",
"review": {
"loading": "加载中...",
"backToDecks": "返回牌组",
"allDone": "全部完成!",
"allDoneDesc": "您已完成所有待复习卡片。",
"reviewedCount": "已复习 {count} 张卡片",
"progress": "{current} / {total}",
"nextReview": "下次复习",
"interval": "间隔",
"ease": "难度系数",
"lapses": "遗忘次数",
"showAnswer": "显示答案",
"nextCard": "下一张",
"again": "重来",
"hard": "困难",
"good": "良好",
"easy": "简单",
"now": "现在",
"lessThanMinute": "<1 分钟",
"inMinutes": "{count} 分钟",
"inHours": "{count} 小时",
"inDays": "{count} 天",
"inMonths": "{count} 个月",
"minutes": "<1 分钟",
"days": "{count} 天",
"months": "{count} 个月",
"minAbbr": "分",
"dayAbbr": "天",
"cardTypeNew": "新卡片",
"cardTypeLearning": "学习中",
"cardTypeReview": "复习中",
"cardTypeRelearning": "重学中",
"reverse": "反向",
"dictation": "听写",
"noTextPairs": "没有可用的文本对",
"disorder": "乱序",
"previous": "上一个"
"clickToPlay": "点击播放",
"yourAnswer": "你的答案",
"typeWhatYouHear": "输入你听到的内容",
"correct": "正确",
"incorrect": "错误",
"restart": "重新开始",
"orderLimited": "顺序有限",
"orderInfinite": "顺序无限",
"randomLimited": "随机有限",
"randomInfinite": "随机无限",
"noIpa": "无音标"
},
"page": {
"unauthorized": "您无权访问该文件夹"
"unauthorized": "您无权访问该牌组"
}
},
"navbar": {
@@ -159,15 +313,66 @@
"sourceCode": "源码",
"sign_in": "登录",
"profile": "个人资料",
"folders": "文件夹",
"folders": "牌组",
"explore": "探索",
"favorites": "收藏"
"favorites": "收藏",
"settings": "设置"
},
"ocr": {
"title": "OCR文字识别",
"description": "从图片中提取文字创建学习卡片",
"uploadSection": "上传图片",
"uploadImage": "上传图片",
"dragDropHint": "拖放或点击上传",
"dropOrClick": "拖放或点击",
"changeImage": "更换图片",
"supportedFormats": "支持格式JPG, PNG, WEBP",
"invalidFileType": "无效的文件类型",
"deckSelection": "选择卡组",
"selectDeck": "选择卡组",
"chooseDeck": "选择卡组保存",
"noDecks": "没有可用的卡组",
"languageHints": "语言提示",
"sourceLanguageHint": "源语言提示",
"targetLanguageHint": "目标语言提示",
"sourceLanguagePlaceholder": "如:英语",
"targetLanguagePlaceholder": "如:中文",
"process": "处理",
"processButton": "开始识别",
"processing": "处理中...",
"preview": "预览",
"resultsPreview": "结果预览",
"extractedPairs": "提取的语言对",
"word": "单词",
"definition": "释义",
"pairsCount": "{count}对",
"savePairs": "保存",
"saveButton": "保存到卡组",
"saving": "保存中...",
"saved": "已保存",
"ocrSuccess": "OCR识别成功",
"savedToDeck": "已保存到卡组",
"saveFailed": "保存失败",
"noImage": "请上传图片",
"noDeck": "请选择卡组",
"noResultsToSave": "无结果可保存",
"processingFailed": "处理失败",
"tryAgain": "重试",
"detectedLanguages": "检测到的语言",
"detectedSourceLanguage": "检测到的源语言",
"detectedTargetLanguage": "检测到的目标语言",
"ocrFailed": "OCR识别失败"
},
"profile": {
"myProfile": "我的个人资料",
"email": "邮箱:{email}",
"logout": "退出登录"
},
"settings": {
"title": "设置",
"themeColor": "主题色",
"themeColorDescription": "选择您喜欢的主题色"
},
"srt_player": {
"upload": "上传",
"uploadVideo": "上传视频",
@@ -187,19 +392,58 @@
"subtitleFile": "字幕文件",
"uploaded": "已上传",
"notUploaded": "未上传",
"uploadVideoButton": "上传视频",
"uploadSubtitleButton": "上传字幕",
"subtitleUploaded": "字幕已上传 ({count} 条)",
"subtitleNotUploaded": "字幕未上传",
"autoPauseStatus": "自动暂停: {enabled}",
"on": "开",
"off": "关",
"videoUploadFailed": "视频上传失败",
"subtitleUploadFailed": "字幕上传失败"
"subtitleUploadFailed": "字幕上传失败",
"subtitleLoadSuccess": "字幕加载成功",
"subtitleLoadFailed": "字幕加载失败",
"settings": "设置",
"shortcuts": "快捷键",
"keyboardShortcuts": "键盘快捷键",
"playPause": "播放/暂停",
"autoPauseToggle": "自动暂停开关",
"subtitleSettings": "字幕设置",
"fontSize": "字体大小",
"textColor": "文字颜色",
"backgroundColor": "背景颜色",
"position": "位置",
"opacity": "透明度",
"top": "顶部",
"center": "居中",
"bottom": "底部"
},
"text_speaker": {
"generateIPA": "生成IPA",
"viewSavedItems": "查看保存项",
"confirmDeleteAll": "确定删光吗?(Y/N)"
"confirmDeleteAll": "确定删光吗?(Y/N)",
"saved": "已保存",
"clearAll": "清空全部",
"language": "语言",
"customLanguage": "或输入语言...",
"languages": {
"auto": "自动",
"chinese": "中文",
"english": "英语",
"japanese": "日语",
"korean": "韩语",
"french": "法语",
"german": "德语",
"italian": "意大利语",
"spanish": "西班牙语",
"portuguese": "葡萄牙语",
"russian": "俄语"
}
},
"translator": {
"detectLanguage": "检测语言",
"sourceLanguage": "源语言",
"auto": "自动",
"generateIPA": "生成国际音标",
"translateInto": "翻译为",
"chinese": "中文",
@@ -227,7 +471,20 @@
"success": "文本对已添加到文件夹",
"error": "添加文本对到文件夹失败"
},
"autoSave": "自动保存"
"autoSave": "自动保存",
"customLanguage": "或输入语言...",
"pleaseLogin": "请登录后保存卡片",
"pleaseCreateDeck": "请先创建卡组",
"noTranslationToSave": "没有可保存的翻译",
"noDeckSelected": "未选择卡组",
"saveAsCard": "保存为卡片",
"selectDeck": "选择卡组",
"front": "正面",
"back": "背面",
"cancel": "取消",
"save": "保存",
"savedToDeck": "已保存到 {deckName}",
"saveFailed": "保存失败"
},
"dictionary": {
"title": "词典",
@@ -256,15 +513,17 @@
"pleaseLogin": "请先登录",
"pleaseCreateFolder": "请先创建文件夹",
"savedToFolder": "已保存到文件夹:{folderName}",
"saveFailed": "保存失败,请稍后重试"
"saveFailed": "保存失败,请稍后重试",
"definition": "释义",
"example": "例句"
},
"explore": {
"title": "探索",
"subtitle": "发现公开文件夹",
"searchPlaceholder": "搜索公开文件夹...",
"subtitle": "发现公开牌组",
"searchPlaceholder": "搜索公开牌组...",
"loading": "加载中...",
"noFolders": "没有找到公开文件夹",
"folderInfo": "{userName} {totalPairs} 个文本对",
"noDecks": "暂无公开卡组",
"deckInfo": "{userName} · {totalCards} 张",
"unknownUser": "未知用户",
"favorite": "收藏",
"unfavorite": "取消收藏",
@@ -272,13 +531,19 @@
"sortByFavorites": "按收藏数排序",
"sortByFavoritesActive": "取消按收藏数排序"
},
"favorites": {
"title": "收藏",
"subtitle": "我收藏的文件夹",
"loading": "加载中...",
"noFavorites": "还没有收藏",
"folderInfo": "{userName} • {totalPairs} 个文本对",
"unknownUser": "未知用户"
"exploreDetail": {
"title": "牌组详情",
"createdBy": "创建者:{name}",
"unknownUser": "未知用户",
"totalCards": "共 {count} 张",
"favorites": "收藏数",
"createdAt": "创建时间",
"viewContent": "查看内容",
"favorite": "收藏",
"unfavorite": "取消收藏",
"favorited": "已收藏",
"unfavorited": "已取消收藏",
"pleaseLogin": "请先登录"
},
"favorites": {
"title": "我的收藏",
@@ -299,14 +564,67 @@
"displayName": "显示名称",
"notSet": "未设置",
"memberSince": "注册时间",
"folders": {
"title": "文件夹",
"noFolders": "还没有文件夹",
"folderName": "文件夹名称",
"totalPairs": "文本对数量",
"joined": "注册于",
"logout": "登出",
"deleteAccount": {
"button": "注销账号",
"title": "注销账号",
"warning": "此操作不可逆,您的所有数据将被永久删除。",
"warningDecks": "您的所有牌组和卡片",
"warningCards": "您的所有学习进度",
"warningHistory": "您的所有翻译和词典历史",
"warningPermanent": "此操作无法撤销",
"confirmLabel": "输入您的用户名以确认:",
"usernameMismatch": "用户名不匹配",
"cancel": "取消",
"confirm": "注销我的账号",
"success": "账号已成功注销",
"failed": "注销账号失败"
},
"decks": {
"title": "牌组",
"noDecks": "还没有牌组",
"deckName": "牌组名称",
"totalCards": "卡片数量",
"createdAt": "创建时间",
"actions": "操作",
"view": "查看"
}
},
"decks": {
"title": "牌组",
"subtitle": "管理你的学习卡组",
"newDeck": "新建卡组",
"noDecksYet": "暂无卡组",
"loading": "加载中...",
"deckInfo": "ID: {id} · {totalCards} 张",
"enterDeckName": "输入卡组名称:",
"enterNewName": "输入新名称:",
"confirmDelete": "输入 \"{name}\" 确认删除:",
"public": "公开",
"private": "私有",
"setPublic": "设为公开",
"setPrivate": "设为私有",
"importApkg": "导入 APKG",
"exportApkg": "导出 APKG",
"clickToUpload": "点击上传",
"apkgFilesOnly": "仅支持 .apkg 文件",
"parsing": "解析中...",
"foundDecks": "发现 {count} 个卡组",
"deckName": "卡组名称",
"back": "返回",
"import": "导入",
"importing": "导入中...",
"exportSuccess": "导出成功",
"goToDecks": "前往卡组"
},
"follow": {
"follow": "关注",
"following": "已关注",
"followers": "粉丝",
"followersOf": "{username} 的粉丝",
"followingOf": "{username} 的关注",
"noFollowers": "还没有粉丝",
"noFollowing": "还没有关注任何人"
}
}

View File

@@ -18,13 +18,17 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.2.3",
"jszip": "^3.10.1",
"lucide-react": "^0.562.0",
"next": "16.1.1",
"next-intl": "^4.7.0",
"nodemailer": "^8.0.2",
"openai": "^6.27.0",
"pg": "^8.16.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"sonner": "^2.0.7",
"sql.js": "^1.14.1",
"tailwind-merge": "^3.4.0",
"unstorage": "^1.17.3",
"winston": "^3.19.0",
@@ -36,8 +40,10 @@
"@eslint/eslintrc": "^3.3.3",
"@tailwindcss/postcss": "^4.1.18",
"@types/node": "^25.0.3",
"@types/nodemailer": "^7.0.11",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@types/sql.js": "^1.4.9",
"@typescript-eslint/eslint-plugin": "^8.51.0",
"@typescript-eslint/parser": "^8.51.0",
"babel-plugin-react-compiler": "^1.0.0",

159
pnpm-lock.yaml generated
View File

@@ -23,7 +23,7 @@ importers:
version: 3.0.3
better-auth:
specifier: ^1.4.10
version: 1.4.10(@prisma/client@7.4.2(prisma@7.4.2(@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.15)(@prisma/client@5.22.0(prisma@7.4.2(@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.4.2(@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.4.2(@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.4.2(prisma@7.4.2(@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.15)(@prisma/client@5.22.0(prisma@7.4.2(@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)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@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)(sql.js@1.14.1))(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.4.2(@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
@@ -33,6 +33,9 @@ importers:
dotenv:
specifier: ^17.2.3
version: 17.2.3
jszip:
specifier: ^3.10.1
version: 3.10.1
lucide-react:
specifier: ^0.562.0
version: 0.562.0(react@19.2.3)
@@ -42,6 +45,12 @@ importers:
next-intl:
specifier: ^4.7.0
version: 4.7.0(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))(react@19.2.3)(typescript@5.9.3)
nodemailer:
specifier: ^8.0.2
version: 8.0.2
openai:
specifier: ^6.27.0
version: 6.27.0(zod@4.3.5)
pg:
specifier: ^8.16.3
version: 8.16.3
@@ -54,6 +63,9 @@ importers:
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
sql.js:
specifier: ^1.14.1
version: 1.14.1
tailwind-merge:
specifier: ^3.4.0
version: 3.4.0
@@ -72,7 +84,7 @@ importers:
devDependencies:
'@better-auth/cli':
specifier: ^1.4.10
version: 1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@types/react@19.2.7)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(mysql2@3.15.3)(nanostores@1.1.0)(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))(postgres@3.4.7)(prisma@7.4.2(@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(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(mysql2@3.15.3)(nanostores@1.1.0)(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))(postgres@3.4.7)(prisma@7.4.2(@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)(sql.js@1.14.1)
'@eslint/eslintrc':
specifier: ^3.3.3
version: 3.3.3
@@ -82,12 +94,18 @@ importers:
'@types/node':
specifier: ^25.0.3
version: 25.0.3
'@types/nodemailer':
specifier: ^7.0.11
version: 7.0.11
'@types/react':
specifier: 19.2.7
version: 19.2.7
'@types/react-dom':
specifier: 19.2.3
version: 19.2.3(@types/react@19.2.7)
'@types/sql.js':
specifier: ^1.4.9
version: 1.4.9
'@typescript-eslint/eslint-plugin':
specifier: ^8.51.0
version: 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
@@ -1037,6 +1055,9 @@ packages:
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
'@types/emscripten@1.41.5':
resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -1049,6 +1070,9 @@ packages:
'@types/node@25.0.3':
resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==}
'@types/nodemailer@7.0.11':
resolution: {integrity: sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==}
'@types/pg@8.15.6':
resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==}
@@ -1060,6 +1084,9 @@ packages:
'@types/react@19.2.7':
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
'@types/sql.js@1.4.9':
resolution: {integrity: sha512-ep8b36RKHlgWPqjNG9ToUrPiwkhwh0AEzy883mO5Xnd+cL6VBH1EvSjBAAuxLUFF2Vn/moE3Me6v9E1Lo+48GQ==}
'@types/triple-beam@1.3.5':
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
@@ -1608,6 +1635,9 @@ packages:
cookie-es@1.2.2:
resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -2206,6 +2236,9 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -2354,6 +2387,9 @@ packages:
resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==}
engines: {node: '>=16'}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
@@ -2405,6 +2441,9 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -2430,6 +2469,9 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
lightningcss-android-arm64@1.30.2:
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
engines: {node: '>= 12.0.0'}
@@ -2661,6 +2703,10 @@ packages:
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
nodemailer@8.0.2:
resolution: {integrity: sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw==}
engines: {node: '>=6.0.0'}
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
@@ -2718,6 +2764,18 @@ packages:
resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
engines: {node: '>=18'}
openai@6.27.0:
resolution: {integrity: sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ==}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.25 || ^4.0
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -2734,6 +2792,9 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -2872,6 +2933,9 @@ packages:
typescript:
optional: true
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
prompts@2.4.2:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'}
@@ -2917,6 +2981,9 @@ packages:
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
engines: {node: '>=0.10.0'}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
@@ -2977,6 +3044,9 @@ packages:
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
engines: {node: '>=0.4'}
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
@@ -3025,6 +3095,9 @@ packages:
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
engines: {node: '>= 0.4'}
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -3083,6 +3156,9 @@ packages:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
sql.js@1.14.1:
resolution: {integrity: sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==}
sqlstring@2.3.3:
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
engines: {node: '>= 0.6'}
@@ -3123,6 +3199,9 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'}
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
@@ -3671,7 +3750,7 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@better-auth/cli@1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@types/react@19.2.7)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(mysql2@3.15.3)(nanostores@1.1.0)(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))(postgres@3.4.7)(prisma@7.4.2(@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)':
'@better-auth/cli@1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(mysql2@3.15.3)(nanostores@1.1.0)(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))(postgres@3.4.7)(prisma@7.4.2(@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)(sql.js@1.14.1)':
dependencies:
'@babel/core': 7.28.5
'@babel/preset-react': 7.28.5(@babel/core@7.28.5)
@@ -3683,13 +3762,13 @@ snapshots:
'@mrleebo/prisma-ast': 0.13.1
'@prisma/client': 5.22.0(prisma@7.4.2(@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
better-auth: 1.4.10(@prisma/client@5.22.0(prisma@7.4.2(@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)))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@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.4.2(@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.4.2(@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)
better-auth: 1.4.10(@prisma/client@5.22.0(prisma@7.4.2(@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)))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@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)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@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)(sql.js@1.14.1))(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.4.2(@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)
better-sqlite3: 12.5.0
c12: 3.3.2
chalk: 5.6.2
commander: 12.1.0
dotenv: 17.2.3
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@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))(@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.4.2(@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)
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@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))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@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)(sql.js@1.14.1)
open: 10.2.0
pg: 8.16.3
prettier: 3.7.4
@@ -4383,6 +4462,8 @@ snapshots:
tslib: 2.8.1
optional: true
'@types/emscripten@1.41.5': {}
'@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {}
@@ -4393,6 +4474,10 @@ snapshots:
dependencies:
undici-types: 7.16.0
'@types/nodemailer@7.0.11':
dependencies:
'@types/node': 25.0.3
'@types/pg@8.15.6':
dependencies:
'@types/node': 25.0.3
@@ -4407,6 +4492,11 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/sql.js@1.4.9':
dependencies:
'@types/emscripten': 1.41.5
'@types/node': 25.0.3
'@types/triple-beam@1.3.5': {}
'@typescript-eslint/eslint-plugin@8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
@@ -4778,7 +4868,7 @@ snapshots:
bcryptjs@3.0.3: {}
better-auth@1.4.10(@prisma/client@5.22.0(prisma@7.4.2(@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)))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@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.4.2(@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.4.2(@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):
better-auth@1.4.10(@prisma/client@5.22.0(prisma@7.4.2(@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)))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@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)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@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)(sql.js@1.14.1))(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.4.2(@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):
dependencies:
'@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)
'@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))
@@ -4795,7 +4885,7 @@ snapshots:
optionalDependencies:
'@prisma/client': 5.22.0(prisma@7.4.2(@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))
better-sqlite3: 12.5.0
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@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))(@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.4.2(@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)
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@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))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@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)(sql.js@1.14.1)
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
@@ -4803,7 +4893,7 @@ snapshots:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
better-auth@1.4.10(@prisma/client@7.4.2(prisma@7.4.2(@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.15)(@prisma/client@5.22.0(prisma@7.4.2(@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.4.2(@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.4.2(@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):
better-auth@1.4.10(@prisma/client@7.4.2(prisma@7.4.2(@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.15)(@prisma/client@5.22.0(prisma@7.4.2(@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)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@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)(sql.js@1.14.1))(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.4.2(@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):
dependencies:
'@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)
'@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))
@@ -4820,7 +4910,7 @@ snapshots:
optionalDependencies:
'@prisma/client': 7.4.2(prisma@7.4.2(@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.15)(@prisma/client@7.4.2(prisma@7.4.2(@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))(@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.4.2(@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)
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@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))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@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)(sql.js@1.14.1)
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
@@ -5002,6 +5092,8 @@ snapshots:
cookie-es@1.2.2: {}
core-util-is@1.0.3: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -5093,12 +5185,13 @@ snapshots:
dotenv@17.2.3: {}
drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@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))(@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.4.2(@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):
drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@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))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@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)(sql.js@1.14.1):
optionalDependencies:
'@electric-sql/pglite': 0.3.15
'@prisma/client': 7.4.2(prisma@7.4.2(@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)
'@types/pg': 8.15.6
'@types/react': 19.2.7
'@types/sql.js': 1.4.9
better-sqlite3: 12.5.0
kysely: 0.28.8
mysql2: 3.15.3
@@ -5106,6 +5199,7 @@ snapshots:
postgres: 3.4.7
prisma: 7.4.2(@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
sql.js: 1.14.1
dunder-proto@1.0.1:
dependencies:
@@ -5653,6 +5747,8 @@ snapshots:
ignore@7.0.5: {}
immediate@3.0.6: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -5805,6 +5901,8 @@ snapshots:
dependencies:
is-inside-container: 1.0.0
isarray@1.0.0: {}
isarray@2.0.5: {}
isexe@2.0.0: {}
@@ -5849,6 +5947,13 @@ snapshots:
object.assign: 4.1.7
object.values: 1.2.1
jszip@3.10.1:
dependencies:
lie: 3.3.0
pako: 1.0.11
readable-stream: 2.3.8
setimmediate: 1.0.5
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -5870,6 +5975,10 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
lie@3.3.0:
dependencies:
immediate: 3.0.6
lightningcss-android-arm64@1.30.2:
optional: true
@@ -6068,6 +6177,8 @@ snapshots:
node-releases@2.0.27: {}
nodemailer@8.0.2: {}
normalize-path@3.0.0: {}
nypm@0.6.2:
@@ -6143,6 +6254,10 @@ snapshots:
is-inside-container: 1.0.0
wsl-utils: 0.1.0
openai@6.27.0(zod@4.3.5):
optionalDependencies:
zod: 4.3.5
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -6166,6 +6281,8 @@ snapshots:
dependencies:
p-limit: 3.1.0
pako@1.0.11: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -6295,6 +6412,8 @@ snapshots:
- react
- react-dom
process-nextick-args@2.0.1: {}
prompts@2.4.2:
dependencies:
kleur: 3.0.3
@@ -6346,6 +6465,16 @@ snapshots:
react@19.2.3: {}
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
inherits: 2.0.4
isarray: 1.0.0
process-nextick-args: 2.0.1
safe-buffer: 5.1.2
string_decoder: 1.1.1
util-deprecate: 1.0.2
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
@@ -6414,6 +6543,8 @@ snapshots:
has-symbols: 1.1.0
isarray: 2.0.5
safe-buffer@5.1.2: {}
safe-buffer@5.2.1: {}
safe-push-apply@1.0.0:
@@ -6463,6 +6594,8 @@ snapshots:
es-errors: 1.3.0
es-object-atoms: 1.1.1
setimmediate@1.0.5: {}
sharp@0.34.5:
dependencies:
'@img/colour': 1.0.0
@@ -6552,6 +6685,8 @@ snapshots:
split2@4.2.0: {}
sql.js@1.14.1: {}
sqlstring@2.3.3: {}
stable-hash@0.0.5: {}
@@ -6615,6 +6750,10 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1

View File

@@ -1,120 +0,0 @@
-- CreateTable
CREATE TABLE "pairs" (
"id" SERIAL NOT NULL,
"locale1" VARCHAR(10) NOT NULL,
"locale2" VARCHAR(10) NOT NULL,
"text1" TEXT NOT NULL,
"text2" TEXT NOT NULL,
"ipa1" TEXT,
"ipa2" TEXT,
"folder_id" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "pairs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "folders" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "folders_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
"image" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "session" (
"id" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"token" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"userId" TEXT NOT NULL,
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "account" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"accessToken" TEXT,
"refreshToken" TEXT,
"idToken" TEXT,
"accessTokenExpiresAt" TIMESTAMP(3),
"refreshTokenExpiresAt" TIMESTAMP(3),
"scope" TEXT,
"password" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "verification" (
"id" TEXT NOT NULL,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "pairs_folder_id_idx" ON "pairs"("folder_id");
-- CreateIndex
CREATE UNIQUE INDEX "pairs_folder_id_locale1_locale2_text1_key" ON "pairs"("folder_id", "locale1", "locale2", "text1");
-- CreateIndex
CREATE INDEX "folders_user_id_idx" ON "folders"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
-- CreateIndex
CREATE INDEX "session_userId_idx" ON "session"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
-- CreateIndex
CREATE INDEX "account_userId_idx" ON "account"("userId");
-- CreateIndex
CREATE INDEX "verification_identifier_idx" ON "verification"("identifier");
-- AddForeignKey
ALTER TABLE "pairs" ADD CONSTRAINT "pairs_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "folders" ADD CONSTRAINT "folders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,138 +0,0 @@
/*
Warnings:
- You are about to drop the column `ipa1` on the `pairs` table. All the data in the column will be lost.
- You are about to drop the column `ipa2` on the `pairs` table. All the data in the column will be lost.
*/
-- AlterTable
-- 重命名并修改类型为 TEXT
ALTER TABLE "pairs"
RENAME COLUMN "locale1" TO "language1";
ALTER TABLE "pairs"
ALTER COLUMN "language1" SET DATA TYPE VARCHAR(20);
ALTER TABLE "pairs"
RENAME COLUMN "locale2" TO "language2";
ALTER TABLE "pairs"
ALTER COLUMN "language2" SET DATA TYPE VARCHAR(20);
-- CreateTable
CREATE TABLE "dictionary_lookups" (
"id" SERIAL NOT NULL,
"user_id" TEXT,
"text" TEXT NOT NULL,
"query_lang" TEXT NOT NULL,
"definition_lang" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"dictionary_word_id" INTEGER,
"dictionary_phrase_id" INTEGER,
CONSTRAINT "dictionary_lookups_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dictionary_words" (
"id" SERIAL NOT NULL,
"standard_form" TEXT NOT NULL,
"query_lang" TEXT NOT NULL,
"definition_lang" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "dictionary_words_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dictionary_phrases" (
"id" SERIAL NOT NULL,
"standard_form" TEXT NOT NULL,
"query_lang" TEXT NOT NULL,
"definition_lang" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "dictionary_phrases_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dictionary_word_entries" (
"id" SERIAL NOT NULL,
"word_id" INTEGER NOT NULL,
"ipa" TEXT NOT NULL,
"definition" TEXT NOT NULL,
"part_of_speech" TEXT NOT NULL,
"example" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "dictionary_word_entries_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dictionary_phrase_entries" (
"id" SERIAL NOT NULL,
"phrase_id" INTEGER NOT NULL,
"definition" TEXT NOT NULL,
"example" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "dictionary_phrase_entries_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "dictionary_lookups_user_id_idx" ON "dictionary_lookups"("user_id");
-- CreateIndex
CREATE INDEX "dictionary_lookups_created_at_idx" ON "dictionary_lookups"("created_at");
-- CreateIndex
CREATE INDEX "dictionary_lookups_text_query_lang_definition_lang_idx" ON "dictionary_lookups"("text", "query_lang", "definition_lang");
-- CreateIndex
CREATE INDEX "dictionary_words_standard_form_idx" ON "dictionary_words"("standard_form");
-- CreateIndex
CREATE INDEX "dictionary_words_query_lang_definition_lang_idx" ON "dictionary_words"("query_lang", "definition_lang");
-- CreateIndex
CREATE UNIQUE INDEX "dictionary_words_standard_form_query_lang_definition_lang_key" ON "dictionary_words"("standard_form", "query_lang", "definition_lang");
-- CreateIndex
CREATE INDEX "dictionary_phrases_standard_form_idx" ON "dictionary_phrases"("standard_form");
-- CreateIndex
CREATE INDEX "dictionary_phrases_query_lang_definition_lang_idx" ON "dictionary_phrases"("query_lang", "definition_lang");
-- CreateIndex
CREATE UNIQUE INDEX "dictionary_phrases_standard_form_query_lang_definition_lang_key" ON "dictionary_phrases"("standard_form", "query_lang", "definition_lang");
-- CreateIndex
CREATE INDEX "dictionary_word_entries_word_id_idx" ON "dictionary_word_entries"("word_id");
-- CreateIndex
CREATE INDEX "dictionary_word_entries_created_at_idx" ON "dictionary_word_entries"("created_at");
-- CreateIndex
CREATE INDEX "dictionary_phrase_entries_phrase_id_idx" ON "dictionary_phrase_entries"("phrase_id");
-- CreateIndex
CREATE INDEX "dictionary_phrase_entries_created_at_idx" ON "dictionary_phrase_entries"("created_at");
-- AddForeignKey
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_word_id_fkey" FOREIGN KEY ("dictionary_word_id") REFERENCES "dictionary_words"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_phrase_id_fkey" FOREIGN KEY ("dictionary_phrase_id") REFERENCES "dictionary_phrases"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dictionary_word_entries" ADD CONSTRAINT "dictionary_word_entries_word_id_fkey" FOREIGN KEY ("word_id") REFERENCES "dictionary_words"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dictionary_phrase_entries" ADD CONSTRAINT "dictionary_phrase_entries_phrase_id_fkey" FOREIGN KEY ("phrase_id") REFERENCES "dictionary_phrases"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,8 +0,0 @@
-- DropIndex
DROP INDEX "dictionary_phrases_standard_form_query_lang_definition_lang_key";
-- DropIndex
DROP INDEX "dictionary_words_standard_form_query_lang_definition_lang_key";
-- RenameIndex
ALTER INDEX "pairs_folder_id_locale1_locale2_text1_key" RENAME TO "pairs_folder_id_language1_language2_text1_key";

View File

@@ -1,30 +0,0 @@
-- CreateTable
CREATE TABLE "translation_history" (
"id" SERIAL NOT NULL,
"user_id" TEXT,
"source_text" TEXT NOT NULL,
"source_language" VARCHAR(20) NOT NULL,
"target_language" VARCHAR(20) NOT NULL,
"translated_text" TEXT NOT NULL,
"source_ipa" TEXT,
"target_ipa" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "translation_history_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "translation_history_user_id_idx" ON "translation_history"("user_id");
-- CreateIndex
CREATE INDEX "translation_history_created_at_idx" ON "translation_history"("created_at");
-- CreateIndex
CREATE INDEX "translation_history_source_text_target_language_idx" ON "translation_history"("source_text", "target_language");
-- CreateIndex
CREATE INDEX "translation_history_translated_text_source_language_target__idx" ON "translation_history"("translated_text", "source_language", "target_language");
-- AddForeignKey
ALTER TABLE "translation_history" ADD CONSTRAINT "translation_history_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -1,11 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[folder_id,language1,language2,text1,text2]` on the table `pairs` will be added. If there are existing duplicate values, this will fail.
*/
-- DropIndex
DROP INDEX "pairs_folder_id_language1_language2_text1_key";
-- CreateIndex
CREATE UNIQUE INDEX "pairs_folder_id_language1_language2_text1_text2_key" ON "pairs"("folder_id", "language1", "language2", "text1", "text2");

View File

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

View File

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

View File

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

View File

@@ -1,33 +0,0 @@
-- CreateEnum
CREATE TYPE "Visibility" AS ENUM ('PRIVATE', 'PUBLIC');
-- AlterTable
ALTER TABLE "folders" ADD COLUMN "visibility" "Visibility" NOT NULL DEFAULT 'PRIVATE';
-- CreateTable
CREATE TABLE "folder_favorites" (
"id" SERIAL NOT NULL,
"user_id" TEXT NOT NULL,
"folder_id" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "folder_favorites_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "folder_favorites_folder_id_idx" ON "folder_favorites"("folder_id");
-- CreateIndex
CREATE UNIQUE INDEX "folder_favorites_user_id_folder_id_key" ON "folder_favorites"("user_id", "folder_id");
-- CreateIndex
CREATE INDEX "folder_favorites_user_id_idx" ON "folder_favorites"("user_id");
-- CreateIndex
CREATE INDEX "folders_visibility_idx" ON "folders"("visibility");
-- AddForeignKey
ALTER TABLE "folder_favorites" ADD CONSTRAINT "folder_favorites_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "folder_favorites" ADD CONSTRAINT "folder_favorites_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,262 @@
-- CreateEnum
CREATE TYPE "Visibility" AS ENUM ('PRIVATE', 'PUBLIC');
-- CreateTable
CREATE TABLE "user" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
"image" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"displayUsername" TEXT,
"username" TEXT NOT NULL,
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "session" (
"id" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"token" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"userId" TEXT NOT NULL,
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "account" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"accessToken" TEXT,
"refreshToken" TEXT,
"idToken" TEXT,
"accessTokenExpiresAt" TIMESTAMP(3),
"refreshTokenExpiresAt" TIMESTAMP(3),
"scope" TEXT,
"password" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "verification" (
"id" TEXT NOT NULL,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "pairs" (
"id" SERIAL NOT NULL,
"language1" TEXT NOT NULL,
"language2" TEXT NOT NULL,
"text1" TEXT NOT NULL,
"text2" TEXT NOT NULL,
"ipa1" TEXT,
"ipa2" TEXT,
"folder_id" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "pairs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "folders" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"visibility" "Visibility" NOT NULL DEFAULT 'PRIVATE',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "folders_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "folder_favorites" (
"id" SERIAL NOT NULL,
"user_id" TEXT NOT NULL,
"folder_id" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "folder_favorites_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dictionary_lookups" (
"id" SERIAL NOT NULL,
"user_id" TEXT,
"text" TEXT NOT NULL,
"query_lang" TEXT NOT NULL,
"definition_lang" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"dictionary_item_id" INTEGER,
"normalized_text" TEXT NOT NULL DEFAULT '',
CONSTRAINT "dictionary_lookups_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dictionary_items" (
"id" SERIAL NOT NULL,
"frequency" INTEGER NOT NULL DEFAULT 1,
"standard_form" TEXT NOT NULL,
"query_lang" TEXT NOT NULL,
"definition_lang" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "dictionary_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dictionary_entries" (
"id" SERIAL NOT NULL,
"item_id" INTEGER NOT NULL,
"ipa" TEXT,
"definition" TEXT NOT NULL,
"part_of_speech" TEXT,
"example" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "dictionary_entries_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "translation_history" (
"id" SERIAL NOT NULL,
"user_id" TEXT,
"source_text" TEXT NOT NULL,
"source_language" TEXT NOT NULL,
"target_language" TEXT NOT NULL,
"translated_text" TEXT NOT NULL,
"source_ipa" TEXT,
"target_ipa" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "translation_history_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
-- CreateIndex
CREATE UNIQUE INDEX "user_username_key" ON "user"("username");
-- CreateIndex
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
-- CreateIndex
CREATE INDEX "session_userId_idx" ON "session"("userId");
-- CreateIndex
CREATE INDEX "account_userId_idx" ON "account"("userId");
-- CreateIndex
CREATE INDEX "verification_identifier_idx" ON "verification"("identifier");
-- CreateIndex
CREATE INDEX "pairs_folder_id_idx" ON "pairs"("folder_id");
-- CreateIndex
CREATE UNIQUE INDEX "pairs_folder_id_language1_language2_text1_text2_key" ON "pairs"("folder_id", "language1", "language2", "text1", "text2");
-- CreateIndex
CREATE INDEX "folders_user_id_idx" ON "folders"("user_id");
-- CreateIndex
CREATE INDEX "folders_visibility_idx" ON "folders"("visibility");
-- CreateIndex
CREATE INDEX "folder_favorites_user_id_idx" ON "folder_favorites"("user_id");
-- CreateIndex
CREATE INDEX "folder_favorites_folder_id_idx" ON "folder_favorites"("folder_id");
-- CreateIndex
CREATE UNIQUE INDEX "folder_favorites_user_id_folder_id_key" ON "folder_favorites"("user_id", "folder_id");
-- CreateIndex
CREATE INDEX "dictionary_lookups_user_id_idx" ON "dictionary_lookups"("user_id");
-- CreateIndex
CREATE INDEX "dictionary_lookups_created_at_idx" ON "dictionary_lookups"("created_at");
-- CreateIndex
CREATE INDEX "dictionary_lookups_normalized_text_idx" ON "dictionary_lookups"("normalized_text");
-- CreateIndex
CREATE INDEX "dictionary_items_standard_form_idx" ON "dictionary_items"("standard_form");
-- CreateIndex
CREATE INDEX "dictionary_items_query_lang_definition_lang_idx" ON "dictionary_items"("query_lang", "definition_lang");
-- CreateIndex
CREATE UNIQUE INDEX "dictionary_items_standard_form_query_lang_definition_lang_key" ON "dictionary_items"("standard_form", "query_lang", "definition_lang");
-- CreateIndex
CREATE INDEX "dictionary_entries_item_id_idx" ON "dictionary_entries"("item_id");
-- CreateIndex
CREATE INDEX "dictionary_entries_created_at_idx" ON "dictionary_entries"("created_at");
-- CreateIndex
CREATE INDEX "translation_history_user_id_idx" ON "translation_history"("user_id");
-- CreateIndex
CREATE INDEX "translation_history_created_at_idx" ON "translation_history"("created_at");
-- CreateIndex
CREATE INDEX "translation_history_source_text_target_language_idx" ON "translation_history"("source_text", "target_language");
-- CreateIndex
CREATE INDEX "translation_history_translated_text_source_language_target__idx" ON "translation_history"("translated_text", "source_language", "target_language");
-- AddForeignKey
ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pairs" ADD CONSTRAINT "pairs_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "folders" ADD CONSTRAINT "folders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "folder_favorites" ADD CONSTRAINT "folder_favorites_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "folder_favorites" ADD CONSTRAINT "folder_favorites_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_item_id_fkey" FOREIGN KEY ("dictionary_item_id") REFERENCES "dictionary_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dictionary_entries" ADD CONSTRAINT "dictionary_entries_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "dictionary_items"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "translation_history" ADD CONSTRAINT "translation_history_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,27 @@
-- AlterTable
ALTER TABLE "user" ADD COLUMN "bio" TEXT;
-- CreateTable
CREATE TABLE "follows" (
"id" TEXT NOT NULL,
"follower_id" TEXT NOT NULL,
"following_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "follows_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "follows_follower_id_idx" ON "follows"("follower_id");
-- CreateIndex
CREATE INDEX "follows_following_id_idx" ON "follows"("following_id");
-- CreateIndex
CREATE UNIQUE INDEX "follows_follower_id_following_id_key" ON "follows"("follower_id", "following_id");
-- AddForeignKey
ALTER TABLE "follows" ADD CONSTRAINT "follows_follower_id_fkey" FOREIGN KEY ("follower_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "follows" ADD CONSTRAINT "follows_following_id_fkey" FOREIGN KEY ("following_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,207 @@
/*
Warnings:
- You are about to drop the `folder_favorites` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `folders` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `pairs` table. If the table is not empty, all the data it contains will be lost.
*/
-- CreateEnum
CREATE TYPE "CardType" AS ENUM ('NEW', 'LEARNING', 'REVIEW', 'RELEARNING');
-- CreateEnum
CREATE TYPE "CardQueue" AS ENUM ('USER_BURIED', 'SCHED_BURIED', 'SUSPENDED', 'NEW', 'LEARNING', 'REVIEW', 'IN_LEARNING', 'PREVIEW');
-- CreateEnum
CREATE TYPE "NoteKind" AS ENUM ('STANDARD', 'CLOZE');
-- DropForeignKey
ALTER TABLE "folder_favorites" DROP CONSTRAINT "folder_favorites_folder_id_fkey";
-- DropForeignKey
ALTER TABLE "folder_favorites" DROP CONSTRAINT "folder_favorites_user_id_fkey";
-- DropForeignKey
ALTER TABLE "folders" DROP CONSTRAINT "folders_user_id_fkey";
-- DropForeignKey
ALTER TABLE "pairs" DROP CONSTRAINT "pairs_folder_id_fkey";
-- DropTable
DROP TABLE "folder_favorites";
-- DropTable
DROP TABLE "folders";
-- DropTable
DROP TABLE "pairs";
-- CreateTable
CREATE TABLE "note_types" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"kind" "NoteKind" NOT NULL DEFAULT 'STANDARD',
"css" TEXT NOT NULL DEFAULT '',
"fields" JSONB NOT NULL DEFAULT '[]',
"templates" JSONB NOT NULL DEFAULT '[]',
"user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "note_types_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "decks" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"desc" TEXT NOT NULL DEFAULT '',
"user_id" TEXT NOT NULL,
"visibility" "Visibility" NOT NULL DEFAULT 'PRIVATE',
"collapsed" BOOLEAN NOT NULL DEFAULT false,
"conf" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "decks_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "deck_favorites" (
"id" SERIAL NOT NULL,
"user_id" TEXT NOT NULL,
"deck_id" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "deck_favorites_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notes" (
"id" BIGINT NOT NULL,
"guid" TEXT NOT NULL,
"note_type_id" INTEGER NOT NULL,
"mod" INTEGER NOT NULL,
"usn" INTEGER NOT NULL DEFAULT -1,
"tags" TEXT NOT NULL DEFAULT ' ',
"flds" TEXT NOT NULL,
"sfld" TEXT NOT NULL,
"csum" INTEGER NOT NULL,
"flags" INTEGER NOT NULL DEFAULT 0,
"data" TEXT NOT NULL DEFAULT '',
"user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cards" (
"id" BIGINT NOT NULL,
"note_id" BIGINT NOT NULL,
"deck_id" INTEGER NOT NULL,
"ord" INTEGER NOT NULL,
"mod" INTEGER NOT NULL,
"usn" INTEGER NOT NULL DEFAULT -1,
"type" "CardType" NOT NULL DEFAULT 'NEW',
"queue" "CardQueue" NOT NULL DEFAULT 'NEW',
"due" INTEGER NOT NULL,
"ivl" INTEGER NOT NULL DEFAULT 0,
"factor" INTEGER NOT NULL DEFAULT 2500,
"reps" INTEGER NOT NULL DEFAULT 0,
"lapses" INTEGER NOT NULL DEFAULT 0,
"left" INTEGER NOT NULL DEFAULT 0,
"odue" INTEGER NOT NULL DEFAULT 0,
"odid" INTEGER NOT NULL DEFAULT 0,
"flags" INTEGER NOT NULL DEFAULT 0,
"data" TEXT NOT NULL DEFAULT '',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "cards_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "revlogs" (
"id" BIGINT NOT NULL,
"card_id" BIGINT NOT NULL,
"usn" INTEGER NOT NULL DEFAULT -1,
"ease" INTEGER NOT NULL,
"ivl" INTEGER NOT NULL,
"lastIvl" INTEGER NOT NULL,
"factor" INTEGER NOT NULL,
"time" INTEGER NOT NULL,
"type" INTEGER NOT NULL,
CONSTRAINT "revlogs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "note_types_user_id_idx" ON "note_types"("user_id");
-- CreateIndex
CREATE INDEX "decks_user_id_idx" ON "decks"("user_id");
-- CreateIndex
CREATE INDEX "decks_visibility_idx" ON "decks"("visibility");
-- CreateIndex
CREATE INDEX "deck_favorites_user_id_idx" ON "deck_favorites"("user_id");
-- CreateIndex
CREATE INDEX "deck_favorites_deck_id_idx" ON "deck_favorites"("deck_id");
-- CreateIndex
CREATE UNIQUE INDEX "deck_favorites_user_id_deck_id_key" ON "deck_favorites"("user_id", "deck_id");
-- CreateIndex
CREATE UNIQUE INDEX "notes_guid_key" ON "notes"("guid");
-- CreateIndex
CREATE INDEX "notes_user_id_idx" ON "notes"("user_id");
-- CreateIndex
CREATE INDEX "notes_note_type_id_idx" ON "notes"("note_type_id");
-- CreateIndex
CREATE INDEX "notes_csum_idx" ON "notes"("csum");
-- CreateIndex
CREATE INDEX "cards_note_id_idx" ON "cards"("note_id");
-- CreateIndex
CREATE INDEX "cards_deck_id_idx" ON "cards"("deck_id");
-- CreateIndex
CREATE INDEX "cards_deck_id_queue_due_idx" ON "cards"("deck_id", "queue", "due");
-- CreateIndex
CREATE INDEX "revlogs_card_id_idx" ON "revlogs"("card_id");
-- AddForeignKey
ALTER TABLE "note_types" ADD CONSTRAINT "note_types_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "decks" ADD CONSTRAINT "decks_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "deck_favorites" ADD CONSTRAINT "deck_favorites_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "deck_favorites" ADD CONSTRAINT "deck_favorites_deck_id_fkey" FOREIGN KEY ("deck_id") REFERENCES "decks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notes" ADD CONSTRAINT "notes_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notes" ADD CONSTRAINT "notes_note_type_id_fkey" FOREIGN KEY ("note_type_id") REFERENCES "note_types"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "cards" ADD CONSTRAINT "cards_note_id_fkey" FOREIGN KEY ("note_id") REFERENCES "notes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "cards" ADD CONSTRAINT "cards_deck_id_fkey" FOREIGN KEY ("deck_id") REFERENCES "decks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "revlogs" ADD CONSTRAINT "revlogs_card_id_fkey" FOREIGN KEY ("card_id") REFERENCES "cards"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "decks" ADD COLUMN "new_per_day" INTEGER NOT NULL DEFAULT 20,
ADD COLUMN "rev_per_day" INTEGER NOT NULL DEFAULT 200;

View File

@@ -7,6 +7,10 @@ datasource db {
provider = "postgresql"
}
// ============================================
// User & Auth
// ============================================
model User {
id String @id
name String
@@ -16,13 +20,14 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
displayUsername String?
username String? @unique
username String @unique
bio String?
accounts Account[]
dictionaryLookUps DictionaryLookUp[]
folders Folder[]
folderFavorites FolderFavorite[]
decks Deck[]
deckFavorites DeckFavorite[]
sessions Session[]
translationHistories TranslationHistory[]
followers Follow[] @relation("UserFollowers")
following Follow[] @relation("UserFollowing")
@@map("user")
}
@@ -74,127 +79,98 @@ model Verification {
@@map("verification")
}
model Pair {
id Int @id @default(autoincrement())
language1 String
language2 String
text1 String
text2 String
ipa1 String?
ipa2 String?
folderId Int @map("folder_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
@@unique([folderId, language1, language2, text1, text2])
@@index([folderId])
@@map("pairs")
}
// ============================================
// Deck & Card
// ============================================
enum Visibility {
PRIVATE
PUBLIC
PRIVATE
}
model Folder {
enum CardType {
WORD
PHRASE
SENTENCE
}
model Deck {
id Int @id @default(autoincrement())
name String
userId String @map("user_id")
desc String @db.Text @default("")
userId String
visibility Visibility @default(PRIVATE)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
pairs Pair[]
favorites FolderFavorite[]
cards Card[]
favorites DeckFavorite[]
@@index([userId])
@@index([visibility])
@@map("folders")
@@map("decks")
}
model FolderFavorite {
model Card {
id Int @id @default(autoincrement())
userId String @map("user_id")
folderId Int @map("folder_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
@@unique([userId, folderId])
@@index([userId])
@@index([folderId])
@@map("folder_favorites")
}
model DictionaryLookUp {
id Int @id @default(autoincrement())
userId String? @map("user_id")
text String
queryLang String @map("query_lang")
definitionLang String @map("definition_lang")
createdAt DateTime @default(now()) @map("created_at")
dictionaryItemId Int? @map("dictionary_item_id")
normalizedText String @default("") @map("normalized_text")
dictionaryItem DictionaryItem? @relation(fields: [dictionaryItemId], references: [id])
user User? @relation(fields: [userId], references: [id])
@@index([userId])
@@index([createdAt])
@@index([normalizedText])
@@map("dictionary_lookups")
}
model DictionaryItem {
id Int @id @default(autoincrement())
frequency Int @default(1)
standardForm String @map("standard_form")
queryLang String @map("query_lang")
definitionLang String @map("definition_lang")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
entries DictionaryEntry[]
lookups DictionaryLookUp[]
@@unique([standardForm, queryLang, definitionLang])
@@index([standardForm])
@@index([queryLang, definitionLang])
@@map("dictionary_items")
}
model DictionaryEntry {
id Int @id @default(autoincrement())
itemId Int @map("item_id")
deckId Int
word String
ipa String?
definition String
partOfSpeech String? @map("part_of_speech")
example String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
item DictionaryItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
queryLang String
cardType CardType @default(WORD)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
meanings CardMeaning[]
@@index([itemId])
@@index([createdAt])
@@map("dictionary_entries")
@@index([deckId])
@@index([word])
@@map("cards")
}
model TranslationHistory {
model CardMeaning {
id Int @id @default(autoincrement())
userId String? @map("user_id")
sourceText String @map("source_text")
sourceLanguage String @map("source_language")
targetLanguage String @map("target_language")
translatedText String @map("translated_text")
sourceIpa String? @map("source_ipa")
targetIpa String? @map("target_ipa")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User? @relation(fields: [userId], references: [id])
cardId Int
partOfSpeech String?
definition String
example String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([createdAt])
@@index([sourceText, targetLanguage])
@@index([translatedText, sourceLanguage, targetLanguage])
@@map("translation_history")
@@index([cardId])
@@map("card_meanings")
}
model DeckFavorite {
id Int @id @default(autoincrement())
userId String
deckId Int
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
@@unique([userId, deckId])
@@index([userId])
@@index([deckId])
@@map("deck_favorites")
}
// ============================================
// Social
// ============================================
model Follow {
id String @id @default(cuid())
followerId String @map("follower_id")
followingId String @map("following_id")
createdAt DateTime @default(now()) @map("created_at")
follower User @relation("UserFollowers", fields: [followerId], references: [id], onDelete: Cascade)
following User @relation("UserFollowing", fields: [followingId], references: [id], onDelete: Cascade)
@@unique([followerId, followingId])
@@index([followerId])
@@index([followingId])
@@map("follows")
}

View File

@@ -0,0 +1,147 @@
/**
* 查找缺失的翻译键
* 用法: npx tsx scripts/find-missing-translations.ts [locale]
*/
import * as fs from "fs";
import * as path from "path";
const SRC_DIR = "./src";
const MESSAGES_DIR = "./messages";
const ALL_LOCALES = ["en-US", "zh-CN", "ja-JP", "ko-KR", "de-DE", "fr-FR", "it-IT", "ug-CN"];
function parseString(s: string): string | null {
s = s.trim();
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1);
}
if (s.startsWith("`") && s.endsWith("`") && !s.includes("${")) {
return s.slice(1, -1);
}
return null;
}
function getBindings(content: string): Map<string, string> {
const bindings = new Map<string, string>();
const pattern = /(?:const|let|var)\s+(\w+)\s*=\s*(?:await\s+)?(?:useTranslations|getTranslations)\s*\(\s*([^)]*)\s*\)/g;
let match;
while ((match = pattern.exec(content)) !== null) {
const varName = match[1];
const arg = match[2].trim();
bindings.set(varName, arg ? parseString(arg) || "" : "__ROOT__");
}
return bindings;
}
function getUsages(content: string, file: string): { file: string; line: number; ns: string; key: string }[] {
const usages: { file: string; line: number; ns: string; key: string }[] = [];
const bindings = getBindings(content);
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
for (const [varName, ns] of bindings) {
const pattern = new RegExp(`\\b${varName}\\s*\\(\\s*("[^"]*"|'[^']*'|\`[^\`]*\`)(?:\\s*,|\\s*\\))`, "g");
let match;
while ((match = pattern.exec(line)) !== null) {
const key = parseString(match[1]);
if (key) usages.push({ file, line: i + 1, ns, key });
}
}
}
return usages;
}
function getFiles(dir: string): string[] {
const files: string[] = [];
if (!fs.existsSync(dir)) return files;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...getFiles(p));
else if (entry.isFile() && /\.(tsx?|ts)$/.test(entry.name)) files.push(p);
}
return files;
}
function keyExists(key: string, ns: string, trans: Record<string, unknown>): boolean {
let obj: unknown;
if (ns === "__ROOT__") {
obj = trans;
} else {
obj = trans[ns];
if (typeof obj !== "object" || obj === null) {
obj = trans;
for (const part of ns.split(".")) {
if (typeof obj !== "object" || obj === null) return false;
obj = (obj as Record<string, unknown>)[part];
}
}
}
if (typeof obj !== "object" || obj === null) return false;
for (const part of key.split(".")) {
if (typeof obj !== "object" || obj === null) return false;
obj = (obj as Record<string, unknown>)[part];
}
return typeof obj === "string";
}
function main() {
const locales = process.argv[2] ? [process.argv[2]] : ALL_LOCALES;
const files = getFiles(SRC_DIR);
const usages: { file: string; line: number; ns: string; key: string }[] = [];
for (const f of files) {
usages.push(...getUsages(fs.readFileSync(f, "utf-8"), f));
}
const unique = new Map<string, { file: string; line: number; ns: string; key: string }>();
for (const u of usages) {
unique.set(`${u.file}:${u.line}:${u.ns}:${u.key}`, u);
}
console.log(`Scanned ${files.length} files, ${unique.size} usages\n`);
for (const locale of locales) {
console.log(`\n${"=".repeat(50)}\nLocale: ${locale}\n${"=".repeat(50)}`);
const filePath = path.join(MESSAGES_DIR, `${locale}.json`);
if (!fs.existsSync(filePath)) {
console.log(`File not found: ${filePath}`);
continue;
}
const trans = JSON.parse(fs.readFileSync(filePath, "utf-8"));
const missing = Array.from(unique.values()).filter(u => !keyExists(u.key, u.ns, trans));
if (missing.length === 0) {
console.log("All translations exist!");
} else {
console.log(`\nMissing ${missing.length} translations:\n`);
const byFile = new Map<string, typeof missing>();
for (const u of missing) {
if (!byFile.has(u.file)) byFile.set(u.file, []);
byFile.get(u.file)!.push(u);
}
for (const [file, list] of byFile) {
console.log(file);
for (const u of list) {
console.log(` L${u.line} [${u.ns === "__ROOT__" ? "root" : u.ns}] ${u.key}`);
}
console.log();
}
}
}
console.log("\nDone!");
}
main();

View File

@@ -0,0 +1,154 @@
/**
* 查找多余的翻译键
* 用法: npx tsx scripts/find-unused-translations.ts [locale]
*/
import * as fs from "fs";
import * as path from "path";
const SRC_DIR = "./src";
const MESSAGES_DIR = "./messages";
const ALL_LOCALES = ["en-US", "zh-CN", "ja-JP", "ko-KR", "de-DE", "fr-FR", "it-IT", "ug-CN"];
function parseString(s: string): string | null {
s = s.trim();
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1);
}
if (s.startsWith("`") && s.endsWith("`") && !s.includes("${")) {
return s.slice(1, -1);
}
return null;
}
function getBindings(content: string): Map<string, string> {
const bindings = new Map<string, string>();
const pattern = /(?:const|let|var)\s+(\w+)\s*=\s*(?:await\s+)?(?:useTranslations|getTranslations)\s*\(\s*([^)]*)\s*\)/g;
let match;
while ((match = pattern.exec(content)) !== null) {
const varName = match[1];
const arg = match[2].trim();
bindings.set(varName, arg ? parseString(arg) || "" : "__ROOT__");
}
return bindings;
}
function getUsedKeys(content: string): Map<string, Set<string>> {
const used = new Map<string, Set<string>>();
const bindings = getBindings(content);
for (const [varName, ns] of bindings) {
const pattern = new RegExp(`\\b${varName}\\s*\\(\\s*("[^"]*"|'[^']*'|\`[^\`]*\`)(?:\\s*,|\\s*\\))`, "g");
let match;
while ((match = pattern.exec(content)) !== null) {
const key = parseString(match[1]);
if (key) {
if (!used.has(ns)) used.set(ns, new Set());
used.get(ns)!.add(key);
}
}
}
return used;
}
function getFiles(dir: string): string[] {
const files: string[] = [];
if (!fs.existsSync(dir)) return files;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...getFiles(p));
else if (entry.isFile() && /\.(tsx?|ts)$/.test(entry.name)) files.push(p);
}
return files;
}
function flattenKeys(obj: Record<string, unknown>, prefix = ""): string[] {
const keys: string[] = [];
for (const key of Object.keys(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof obj[key] === "object" && obj[key] !== null) {
keys.push(...flattenKeys(obj[key] as Record<string, unknown>, fullKey));
} else if (typeof obj[key] === "string") {
keys.push(fullKey);
}
}
return keys;
}
function isUsed(fullKey: string, used: Map<string, Set<string>>): boolean {
const parts = fullKey.split(".");
for (let i = 1; i < parts.length; i++) {
const ns = parts.slice(0, i).join(".");
const key = parts.slice(i).join(".");
const nsKeys = used.get(ns);
if (nsKeys) {
if (nsKeys.has(key)) return true;
for (const k of nsKeys) {
if (key.startsWith(k + ".")) return true;
}
}
}
const rootKeys = used.get("__ROOT__");
return rootKeys?.has(fullKey) ?? false;
}
function main() {
const locales = process.argv[2] ? [process.argv[2]] : ALL_LOCALES;
const files = getFiles(SRC_DIR);
const allUsed = new Map<string, Set<string>>();
for (const f of files) {
const used = getUsedKeys(fs.readFileSync(f, "utf-8"));
for (const [ns, keys] of used) {
if (!allUsed.has(ns)) allUsed.set(ns, new Set());
for (const k of keys) allUsed.get(ns)!.add(k);
}
}
console.log(`Scanned ${files.length} files, ${allUsed.size} namespaces\n`);
for (const locale of locales) {
console.log(`\n${"=".repeat(50)}\nLocale: ${locale}\n${"=".repeat(50)}`);
const filePath = path.join(MESSAGES_DIR, `${locale}.json`);
if (!fs.existsSync(filePath)) {
console.log(`File not found: ${filePath}`);
continue;
}
const trans = JSON.parse(fs.readFileSync(filePath, "utf-8"));
const allKeys = flattenKeys(trans);
const unused = allKeys.filter(k => !isUsed(k, allUsed));
console.log(`Total: ${allKeys.length} keys`);
if (unused.length === 0) {
console.log("No unused translations!");
} else {
console.log(`\n${unused.length} potentially unused:\n`);
const grouped = new Map<string, string[]>();
for (const k of unused) {
const [ns, ...rest] = k.split(".");
if (!grouped.has(ns)) grouped.set(ns, []);
grouped.get(ns)!.push(rest.join("."));
}
for (const [ns, keys] of grouped) {
console.log(`${ns}`);
for (const k of keys) console.log(` ${k}`);
console.log();
}
}
}
console.log("\nDone!");
}
main();

View File

@@ -0,0 +1,99 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Card, CardBody } from "@/design-system/base/card";
import { Input } from "@/design-system/base/input";
import { PrimaryButton } from "@/design-system/base/button";
import { VStack } from "@/design-system/layout/stack";
import { actionRequestPasswordReset } from "@/modules/auth/forgot-password-action";
export default function ForgotPasswordPage() {
const t = useTranslations("auth");
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
const handleResetRequest = async () => {
if (!email) {
toast.error(t("emailRequired"));
return;
}
setLoading(true);
const result = await actionRequestPasswordReset({ email });
if (!result.success) {
toast.error(result.message);
} else {
setSent(true);
toast.success(result.message);
}
setLoading(false);
};
if (sent) {
return (
<div className="flex justify-center items-center min-h-screen">
<Card className="w-96">
<CardBody>
<VStack gap={4} align="center" justify="center">
<h1 className="text-2xl font-bold text-center w-full">
{t("checkYourEmail")}
</h1>
<p className="text-center text-gray-600">
{t("resetPasswordEmailSentHint")}
</p>
<Link
href="/login"
className="text-primary-500 hover:underline"
>
{t("backToLogin")}
</Link>
</VStack>
</CardBody>
</Card>
</div>
);
}
return (
<div className="flex justify-center items-center min-h-screen">
<Card className="w-96">
<CardBody>
<VStack gap={4} align="center" justify="center">
<h1 className="text-3xl font-bold text-center w-full">
{t("forgotPassword")}
</h1>
<p className="text-center text-gray-600 text-sm">
{t("forgotPasswordHint")}
</p>
<VStack gap={0} align="center" justify="center" className="w-full">
<Input
type="email"
placeholder={t("emailPlaceholder")}
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</VStack>
<PrimaryButton
onClick={handleResetRequest}
loading={loading}
fullWidth
>
{t("sendResetEmail")}
</PrimaryButton>
<Link
href="/login"
className="text-center text-primary-500 hover:underline"
>
{t("backToLogin")}
</Link>
</VStack>
</CardBody>
</Card>
</div>
);
}

View File

@@ -1,55 +1,97 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { authClient } from "@/lib/auth-client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Card, CardBody } from "@/design-system/base/card";
import { Input } from "@/design-system/base/input";
import { PrimaryButton } from "@/design-system/base/button";
import { PrimaryButton, LinkButton } from "@/design-system/base/button";
import { VStack } from "@/design-system/layout/stack";
export default function LoginPage() {
const t = useTranslations("auth");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [resendLoading, setResendLoading] = useState(false);
const [showResendOption, setShowResendOption] = useState(false);
const [unverifiedEmail, setUnverifiedEmail] = useState("");
const searchParams = useSearchParams();
const redirectTo = searchParams.get("redirect");
const session = authClient.useSession().data;
const { data: session, isPending } = authClient.useSession();
const router = useRouter();
useEffect(() => {
if (session) {
router.push(redirectTo ?? "/profile");
if (!isPending && session?.user?.username && !redirectTo) {
router.push("/decks");
}
}, [session, router, redirectTo]);
}, [session, isPending, router, redirectTo]);
const handleResendVerification = async () => {
if (!unverifiedEmail) return;
setResendLoading(true);
try {
const { error } = await authClient.sendVerificationEmail({
email: unverifiedEmail,
callbackURL: "/login",
});
if (error) {
toast.error(t("resendFailed"));
} else {
toast.success(t("resendSuccess"));
setShowResendOption(false);
}
} finally {
setResendLoading(false);
}
};
const handleLogin = async () => {
if (!username || !password) {
toast.error("请输入用户名和密码");
toast.error(t("enterCredentials"));
return;
}
setLoading(true);
setShowResendOption(false);
try {
if (username.includes("@")) {
await authClient.signIn.email({
const { error } = await authClient.signIn.email({
email: username,
password: username
password: password,
});
if (error) {
if (error.status === 403) {
setUnverifiedEmail(username);
setShowResendOption(true);
toast.error(t("emailNotVerified"));
} else {
await authClient.signIn.username({
toast.error(error.message ?? t("loginFailed"));
}
return;
}
} else {
const { error } = await authClient.signIn.username({
username: username,
password: password,
});
if (error) {
if (error.status === 403) {
toast.error(t("emailNotVerified"));
} else {
toast.error(error.message ?? t("loginFailed"));
}
router.push(redirectTo ?? "/profile");
} catch (error) {
toast.error("登录失败");
return;
}
}
router.push(redirectTo ?? "/decks");
} finally {
setLoading(false);
}
@@ -57,39 +99,61 @@ export default function LoginPage() {
return (
<div className="flex justify-center items-center min-h-screen">
<Card className="w-80">
<Card className="w-96">
<CardBody>
<VStack gap={4} align="center" justify="center">
<h1 className="text-3xl font-bold text-center w-full"></h1>
<h1 className="text-3xl font-bold text-center w-full">{t("title")}</h1>
<VStack gap={0} align="center" justify="center" className="w-full">
<Input
placeholder="用户名或邮箱地址"
placeholder={t("usernameOrEmailPlaceholder")}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<Input
type="password"
placeholder="密码"
placeholder={t("passwordPlaceholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</VStack>
<Link
href="/forgot-password"
className="text-sm text-gray-500 hover:text-primary-500 self-end"
>
{t("forgotPassword")}
</Link>
{showResendOption && (
<div className="w-full p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg text-sm">
<p className="text-yellow-800 dark:text-yellow-200 mb-2">
{t("emailNotVerifiedHint")}
</p>
<LinkButton
onClick={handleResendVerification}
loading={resendLoading}
size="sm"
>
{t("resendVerification")}
</LinkButton>
</div>
)}
<PrimaryButton
onClick={handleLogin}
loading={loading}
fullWidth
>
{t("confirm")}
</PrimaryButton>
<Link
href={"/signup" + (redirectTo ? `?redirect=${redirectTo}` : "")}
className="text-center text-primary-500 hover:underline"
>
{t("noAccountLink")}
</Link>
</VStack>
</CardBody>

View File

@@ -5,9 +5,9 @@ import { headers } from "next/headers";
export default async function ProfilePage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
if (!session?.user?.id) {
redirect("/login?redirect=/profile");
}
redirect(`/users/${session.user.username}`);
redirect(session.user.username ? `/users/${session.user.username}` : "/decks");
}

View File

@@ -0,0 +1,154 @@
"use client";
import { useState } from "react";
import { authClient } from "@/lib/auth-client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Card, CardBody } from "@/design-system/base/card";
import { Input } from "@/design-system/base/input";
import { PrimaryButton } from "@/design-system/base/button";
import { VStack } from "@/design-system/layout/stack";
export default function ResetPasswordPage() {
const t = useTranslations("auth");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token");
const handleResetPassword = async () => {
if (!password || !confirmPassword) {
toast.error(t("fillAllFields"));
return;
}
if (password !== confirmPassword) {
toast.error(t("passwordsNotMatch"));
return;
}
if (password.length < 8) {
toast.error(t("passwordTooShort"));
return;
}
if (!token) {
toast.error(t("invalidToken"));
return;
}
setLoading(true);
const { error } = await authClient.resetPassword({
newPassword: password,
token,
});
if (error) {
toast.error(error.message ?? t("resetPasswordFailed"));
} else {
setSuccess(true);
toast.success(t("resetPasswordSuccess"));
setTimeout(() => {
router.push("/login");
}, 2000);
}
setLoading(false);
};
if (success) {
return (
<div className="flex justify-center items-center min-h-screen">
<Card className="w-96">
<CardBody>
<VStack gap={4} align="center" justify="center">
<h1 className="text-2xl font-bold text-center w-full">
{t("resetPasswordSuccessTitle")}
</h1>
<p className="text-center text-gray-600">
{t("resetPasswordSuccessHint")}
</p>
<Link
href="/login"
className="text-primary-500 hover:underline"
>
{t("backToLogin")}
</Link>
</VStack>
</CardBody>
</Card>
</div>
);
}
if (!token) {
return (
<div className="flex justify-center items-center min-h-screen">
<Card className="w-96">
<CardBody>
<VStack gap={4} align="center" justify="center">
<h1 className="text-2xl font-bold text-center w-full">
{t("invalidToken")}
</h1>
<p className="text-center text-gray-600">
{t("invalidTokenHint")}
</p>
<Link
href="/forgot-password"
className="text-primary-500 hover:underline"
>
{t("requestNewToken")}
</Link>
</VStack>
</CardBody>
</Card>
</div>
);
}
return (
<div className="flex justify-center items-center min-h-screen">
<Card className="w-96">
<CardBody>
<VStack gap={4} align="center" justify="center">
<h1 className="text-3xl font-bold text-center w-full">
{t("resetPassword")}
</h1>
<VStack gap={0} align="center" justify="center" className="w-full">
<Input
type="password"
placeholder={t("newPassword")}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Input
type="password"
placeholder={t("confirmPassword")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</VStack>
<PrimaryButton
onClick={handleResetPassword}
loading={loading}
fullWidth
>
{t("resetPassword")}
</PrimaryButton>
<Link
href="/login"
className="text-center text-primary-500 hover:underline"
>
{t("backToLogin")}
</Link>
</VStack>
</CardBody>
</Card>
</div>
);
}

View File

@@ -6,75 +6,106 @@ import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Card, CardBody } from "@/design-system/base/card";
import { Input } from "@/design-system/base/input";
import { PrimaryButton } from "@/design-system/base/button";
import { VStack } from "@/design-system/layout/stack";
export default function SignUpPage() {
const t = useTranslations("auth");
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [verificationSent, setVerificationSent] = useState(false);
const searchParams = useSearchParams();
const redirectTo = searchParams.get("redirect");
const session = authClient.useSession().data;
const { data: session, isPending } = authClient.useSession();
const router = useRouter();
useEffect(() => {
if (session) {
router.push(redirectTo ?? "/profile");
if (!isPending && session?.user?.username && !redirectTo && !verificationSent) {
router.push("/decks");
}
}, [session, router, redirectTo]);
}, [session, isPending, router, redirectTo, verificationSent]);
const handleSignUp = async () => {
if (!username || !email || !password) {
toast.error("请填写所有字段");
toast.error(t("fillAllFields"));
return;
}
setLoading(true);
try {
await authClient.signUp.email({
const { error } = await authClient.signUp.email({
email: email,
name: username,
username: username,
password: password,
});
router.push(redirectTo ?? "/profile");
} catch (error) {
toast.error("注册失败");
if (error) {
toast.error(error.message ?? t("signUpFailed"));
return;
}
setVerificationSent(true);
toast.success(t("verificationEmailSent"));
} finally {
setLoading(false);
}
};
if (verificationSent) {
return (
<div className="flex justify-center items-center min-h-screen">
<Card className="w-80">
<Card className="w-96">
<CardBody>
<VStack gap={4} align="center" justify="center">
<h1 className="text-3xl font-bold text-center w-full"></h1>
<h1 className="text-2xl font-bold text-center w-full">
{t("verifyYourEmail")}
</h1>
<p className="text-center text-gray-600">
{t("verificationEmailSentHint", { email })}
</p>
<Link
href="/login"
className="text-primary-500 hover:underline"
>
{t("backToLogin")}
</Link>
</VStack>
</CardBody>
</Card>
</div>
);
}
return (
<div className="flex justify-center items-center min-h-screen">
<Card className="w-96">
<CardBody>
<VStack gap={4} align="center" justify="center">
<h1 className="text-3xl font-bold text-center w-full">{t("signUpTitle")}</h1>
<VStack gap={0} align="center" justify="center" className="w-full">
<Input
placeholder="用户名"
placeholder={t("usernamePlaceholder")}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<Input
type="email"
placeholder="邮箱地址"
placeholder={t("emailPlaceholder")}
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Input
type="password"
placeholder="密码"
placeholder={t("passwordPlaceholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
@@ -85,14 +116,14 @@ export default function SignUpPage() {
loading={loading}
fullWidth
>
{t("confirm")}
</PrimaryButton>
<Link
href={"/login" + (redirectTo ? `?redirect=${redirectTo}` : "")}
className="text-center text-primary-500 hover:underline"
>
{t("hasAccountLink")}
</Link>
</VStack>
</CardBody>

View File

@@ -0,0 +1,103 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Button } from "@/design-system/base/button";
import { Modal } from "@/design-system/overlay/modal";
import { actionDeleteAccount } from "@/modules/auth/auth-action";
interface DeleteAccountButtonProps {
username: string;
}
export function DeleteAccountButton({ username }: DeleteAccountButtonProps) {
const t = useTranslations("user_profile");
const router = useRouter();
const [showModal, setShowModal] = useState(false);
const [confirmUsername, setConfirmUsername] = useState("");
const [loading, setLoading] = useState(false);
const handleDelete = async () => {
if (confirmUsername !== username) {
toast.error(t("deleteAccount.usernameMismatch"));
return;
}
setLoading(true);
try {
const result = await actionDeleteAccount();
if (result.success) {
toast.success(t("deleteAccount.success"));
router.push("/");
} else {
toast.error(result.message || t("deleteAccount.failed"));
}
} catch {
toast.error(t("deleteAccount.failed"));
} finally {
setLoading(false);
setShowModal(false);
}
};
return (
<>
<button
onClick={() => setShowModal(true)}
className="text-xs text-gray-400 hover:text-red-500 transition-colors"
>
{t("deleteAccount.button")}
</button>
<Modal open={showModal} onClose={() => setShowModal(false)}>
<div className="p-6">
<h2 className="text-xl font-bold text-red-600 mb-4">
{t("deleteAccount.title")}
</h2>
<div className="space-y-4">
<p className="text-gray-700">
{t("deleteAccount.warning")}
</p>
<ul className="list-disc list-inside text-gray-600 text-sm space-y-1">
<li>{t("deleteAccount.warningDecks")}</li>
<li>{t("deleteAccount.warningCards")}</li>
<li>{t("deleteAccount.warningHistory")}</li>
<li>{t("deleteAccount.warningPermanent")}</li>
</ul>
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
{t("deleteAccount.confirmLabel")} <span className="font-mono font-bold">{username}</span>
</label>
<input
type="text"
value={confirmUsername}
onChange={(e) => setConfirmUsername(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500"
placeholder={username}
/>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<Button variant="secondary" onClick={() => setShowModal(false)}>
{t("deleteAccount.cancel")}
</Button>
<Button
variant="error"
onClick={handleDelete}
loading={loading}
disabled={confirmUsername !== username}
>
{t("deleteAccount.confirm")}
</Button>
</div>
</div>
</Modal>
</>
);
}

View File

@@ -0,0 +1,44 @@
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { PageLayout } from "@/components/ui/PageLayout";
import { UserList } from "@/components/follow/UserList";
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
import { actionGetFollowers } from "@/modules/follow/follow-action";
interface FollowersPageProps {
params: Promise<{ username: string }>;
}
export default async function FollowersPage({ params }: FollowersPageProps) {
const { username } = await params;
const t = await getTranslations("follow");
const userResult = await actionGetUserProfileByUsername({ username });
if (!userResult.success || !userResult.data) {
notFound();
}
const user = userResult.data;
const followersResult = await actionGetFollowers({
userId: user.id,
page: 1,
limit: 50,
});
const followers = followersResult.success && followersResult.data
? followersResult.data.followers.map((f) => f.user)
: [];
return (
<PageLayout>
<div className="bg-white rounded-lg shadow-md p-6">
<h1 className="text-2xl font-bold text-gray-800 mb-6">
{t("followersOf", { username: user.displayUsername || user.username || "User" })}
</h1>
<UserList users={followers} emptyMessage={t("noFollowers")} />
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,44 @@
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { PageLayout } from "@/components/ui/PageLayout";
import { UserList } from "@/components/follow/UserList";
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
import { actionGetFollowing } from "@/modules/follow/follow-action";
interface FollowingPageProps {
params: Promise<{ username: string }>;
}
export default async function FollowingPage({ params }: FollowingPageProps) {
const { username } = await params;
const t = await getTranslations("follow");
const userResult = await actionGetUserProfileByUsername({ username });
if (!userResult.success || !userResult.data) {
notFound();
}
const user = userResult.data;
const followingResult = await actionGetFollowing({
userId: user.id,
page: 1,
limit: 50,
});
const following = followingResult.success && followingResult.data
? followingResult.data.following.map((f) => f.user)
: [];
return (
<PageLayout>
<div className="bg-white rounded-lg shadow-md p-6">
<h1 className="text-2xl font-bold text-gray-800 mb-6">
{t("followingOf", { username: user.displayUsername || user.username || "User" })}
</h1>
<UserList users={following} emptyMessage={t("noFollowing")} />
</div>
</PageLayout>
);
}

View File

@@ -1,14 +1,16 @@
import Image from "next/image";
import Link from "next/link";
import { PageLayout } from "@/components/ui/PageLayout";
import { LightButton, LinkButton } from "@/design-system/base/button";
import { LinkButton } from "@/design-system/base/button";
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
import { repoGetFoldersWithTotalPairsByUserId } from "@/modules/folder/folder-repository";
import { repoGetDecksByUserId } from "@/modules/deck/deck-repository";
import { actionGetFollowStatus } from "@/modules/follow/follow-action";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { auth } from "@/auth";
import { headers } from "next/headers";
// import { LogoutButton } from "./LogoutButton";
import { FollowStats } from "@/components/follow/FollowStats";
import { DeleteAccountButton } from "./DeleteAccountButton";
interface UserPageProps {
params: Promise<{ username: string; }>;
@@ -18,10 +20,8 @@ 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) {
@@ -30,24 +30,34 @@ export default async function UserPage({ params }: UserPageProps) {
const user = result.data;
// Get user's folders
const folders = await repoGetFoldersWithTotalPairsByUserId(user.id);
const [decks, followStatus] = await Promise.all([
repoGetDecksByUserId({ userId: user.id }),
actionGetFollowStatus({ targetUserId: user.id }),
]);
// Check if viewing own profile
const isOwnProfile = session?.user?.username === username || session?.user?.email === username;
const followersCount = followStatus.success && followStatus.data ? followStatus.data.followersCount : 0;
const followingCount = followStatus.success && followStatus.data ? followStatus.data.followingCount : 0;
const isFollowing = followStatus.success && followStatus.data ? followStatus.data.isFollowing : false;
return (
<PageLayout>
{/* Header */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<div></div>
{isOwnProfile && <LinkButton href="/logout"></LinkButton>}
<div className="flex items-center gap-3">
{isOwnProfile && (
<>
<LinkButton href="/logout">{t("logout")}</LinkButton>
<DeleteAccountButton username={username} />
</>
)}
</div>
<div className="flex items-center space-x-6">
{/* Avatar */}
</div>
<div className="flex flex-col sm:flex-row items-start sm:items-center space-y-4 sm:space-y-0 sm:space-x-6">
{user.image ? (
<div className="relative w-24 h-24 rounded-full border-4 border-primary-500 overflow-hidden">
<div className="relative w-24 h-24 rounded-full border-4 border-primary-500 overflow-hidden flex-shrink-0">
<Image
src={user.image}
alt={user.displayUsername || user.username || user.email}
@@ -57,14 +67,13 @@ export default async function UserPage({ params }: UserPageProps) {
/>
</div>
) : (
<div className="w-24 h-24 rounded-full bg-primary-500 border-4 border-primary-500 flex items-center justify-center">
<div className="w-24 h-24 rounded-full bg-primary-500 border-4 border-primary-500 flex items-center justify-center flex-shrink-0">
<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")}
@@ -74,27 +83,39 @@ export default async function UserPage({ params }: UserPageProps) {
@{user.username}
</p>
)}
<p className="text-gray-600 text-sm mb-1">
{user.email}
{user.bio && (
<p className="text-gray-700 mt-2 mb-2">
{user.bio}
</p>
<div className="flex items-center space-x-4 text-sm">
)}
<div className="flex flex-wrap items-center gap-4 text-sm mt-3">
<span className="text-gray-500">
Joined: {new Date(user.createdAt).toLocaleDateString()}
{t("joined")}: {new Date(user.createdAt).toLocaleDateString()}
</span>
{user.emailVerified && (
<span className="flex items-center text-green-600">
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.293 12.293a1 1 0 101.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
Verified
{t("verified")}
</span>
)}
</div>
<div className="mt-3">
<FollowStats
userId={user.id}
initialFollowersCount={followersCount}
initialFollowingCount={followingCount}
initialIsFollowing={isFollowing}
currentUserId={session?.user?.id}
isOwnProfile={isOwnProfile}
username={user.username || user.id}
/>
</div>
</div>
</div>
</div>
{/* 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">
@@ -123,47 +144,46 @@ export default async function UserPage({ params }: UserPageProps) {
</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>
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("decks.title")}</h2>
{decks.length === 0 ? (
<p className="text-gray-500 text-center py-8">{t("decks.noDecks")}</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t("folders.folderName")}
{t("decks.deckName")}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t("folders.totalPairs")}
{t("decks.totalCards")}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t("folders.createdAt")}
{t("decks.createdAt")}
</th>
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{t("folders.actions")}
{t("decks.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">
{decks.map((deck) => (
<tr key={deck.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{folder.name}</div>
<div className="text-sm text-gray-500">ID: {folder.id}</div>
<div className="text-sm font-medium text-gray-900">{deck.name}</div>
<div className="text-sm text-gray-500">ID: {deck.id}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{folder.total}</div>
<div className="text-sm text-gray-900">{deck.cardCount ?? 0}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(folder.createdAt).toLocaleDateString()}
{new Date(deck.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Link href={`/folders/${folder.id}`}>
<Link href={`/decks/${deck.id}`}>
<LinkButton>
{t("folders.view")}
{t("decks.view")}
</LinkButton>
</Link>
</td>

View File

@@ -54,8 +54,8 @@ export default function Alphabet() {
{t("chooseCharacters")}
</h1>
{/* 副标题说明 */}
<p className="text-gray-600 mb-8 text-lg">
<p className="text-lg text-gray-600 text-center">
{t("chooseAlphabetHint")}
</p>
{/* 语言选择按钮网格 */}

View File

@@ -7,19 +7,25 @@ import { useDictionaryStore } from "./stores/dictionaryStore";
import { PageLayout } from "@/components/ui/PageLayout";
import { LightButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { Select } from "@/design-system/base/select";
import { Skeleton } from "@/design-system/feedback/skeleton";
import { HStack, VStack } from "@/design-system/layout/stack";
import { Plus, RefreshCw } from "lucide-react";
import { DictionaryEntry } from "./DictionaryEntry";
import { LanguageSelector } from "./LanguageSelector";
import { authClient } from "@/lib/auth-client";
import { actionGetFoldersByUserId, actionCreatePair } from "@/modules/folder/folder-aciton";
import { TSharedFolder } from "@/shared/folder-type";
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
import { actionCreateCard } from "@/modules/card/card-action";
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
import type { CardType } from "@/modules/card/card-action-dto";
import { toast } from "sonner";
import { getNativeName } from "./stores/dictionaryStore";
interface DictionaryClientProps {
initialFolders: TSharedFolder[];
initialDecks: ActionOutputDeck[];
}
export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
const t = useTranslations("dictionary");
const router = useRouter();
const searchParams = useSearchParams();
@@ -39,7 +45,8 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
} = useDictionaryStore();
const { data: session } = authClient.useSession();
const [folders, setFolders] = useState<TSharedFolder[]>(initialFolders);
const [decks, setDecks] = useState<ActionOutputDeck[]>(initialDecks);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
const q = searchParams.get("q") || undefined;
@@ -55,9 +62,9 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
useEffect(() => {
if (session?.user?.id) {
actionGetFoldersByUserId(session.user.id).then((result) => {
actionGetDecksByUserId(session.user.id).then((result) => {
if (result.success && result.data) {
setFolders(result.data);
setDecks(result.data);
}
});
}
@@ -79,37 +86,67 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
const handleSave = async () => {
if (!session) {
toast.error("Please login first");
toast.error(t("pleaseLogin"));
return;
}
if (folders.length === 0) {
toast.error("Please create a folder first");
if (decks.length === 0) {
toast.error(t("pleaseCreateFolder"));
return;
}
if (!searchResult?.entries?.length) {
toast.error("No dictionary item to save. Please search first.");
return;
}
const folderSelect = document.getElementById("folder-select") as HTMLSelectElement;
const folderId = folderSelect?.value ? Number(folderSelect.value) : folders[0]?.id;
const deckSelect = document.getElementById("deck-select") as HTMLSelectElement;
const deckId = deckSelect?.value ? Number(deckSelect.value) : decks[0]?.id;
if (!searchResult) return;
if (!deckId) {
toast.error("No deck selected");
return;
}
const definition = searchResult.entries.reduce((p, e) => {
return { ...p, definition: p.definition + ' | ' + e.definition };
}).definition;
setIsSaving(true);
try {
await actionCreatePair({
text1: searchResult.standardForm,
text2: definition,
language1: queryLang,
language2: definitionLang,
ipa1: searchResult.entries[0].ipa,
folderId: folderId,
const hasIpa = searchResult.entries.some((e) => e.ipa);
const hasSpaces = searchResult.standardForm.includes(" ");
let cardType: CardType = "WORD";
if (!hasIpa) {
cardType = "SENTENCE";
} else if (hasSpaces) {
cardType = "PHRASE";
}
const ipa = searchResult.entries.find((e) => e.ipa)?.ipa || null;
const meanings = searchResult.entries.map((e) => ({
partOfSpeech: e.partOfSpeech || null,
definition: e.definition,
example: e.example || null,
}));
const cardResult = await actionCreateCard({
deckId,
word: searchResult.standardForm,
ipa,
queryLang: getNativeName(queryLang),
cardType,
meanings,
});
const folderName = folders.find((f) => f.id === folderId)?.name || "Unknown";
toast.success(`Saved to ${folderName}`);
if (!cardResult.success) {
toast.error(cardResult.message || t("saveFailed"));
setIsSaving(false);
return;
}
const deckName = decks.find((d) => d.id === deckId)?.name || "Unknown";
toast.success(t("savedToFolder", { folderName: deckName }));
} catch (error) {
toast.error("Save failed");
console.error("Save error:", error);
toast.error(t("saveFailed"));
} finally {
setIsSaving(false);
}
};
@@ -133,10 +170,11 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
placeholder={t("searchPlaceholder")}
variant="search"
required
containerClassName="flex-1"
/>
<LightButton
type="submit"
className="px-6 py-3 whitespace-nowrap text-center sm:min-w-30"
className="h-10 px-6 rounded-full whitespace-nowrap"
loading={isSearching}
>
{t("search")}
@@ -167,14 +205,14 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
<div className="mt-8">
{isSearching ? (
<div className="text-center py-12">
<div className="w-8 h-8 border-2 border-gray-200 border-t-primary-500 rounded-full animate-spin mx-auto mb-3"></div>
<VStack align="center" className="py-12">
<Skeleton variant="circular" className="w-8 h-8 mb-3" />
<p className="text-gray-600">{t("searching")}</p>
</div>
</VStack>
) : query && !searchResult ? (
<div className="text-center py-12 bg-white/20 rounded-lg">
<p className="text-gray-800 text-xl">No results found</p>
<p className="text-gray-600 mt-2">Try other words</p>
<p className="text-gray-800 text-xl">{t("noResults")}</p>
<p className="text-gray-600 mt-2">{t("tryOtherWords")}</p>
</div>
) : searchResult ? (
<div className="bg-white rounded-lg p-6 shadow-lg">
@@ -184,27 +222,30 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
{searchResult.standardForm}
</h2>
</div>
<div className="flex items-center gap-2 ml-4">
{session && folders.length > 0 && (
<select
id="folder-select"
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[#35786f]"
<HStack align="center" gap={2} className="ml-4">
{session && decks.length > 0 && (
<Select
id="deck-select"
variant="bordered"
size="sm"
>
{folders.map((folder) => (
<option key={folder.id} value={folder.id}>
{folder.name}
{decks.map((deck) => (
<option key={deck.id} value={deck.id}>
{deck.name}
</option>
))}
</select>
</Select>
)}
<LightButton
onClick={handleSave}
className="w-10 h-10 shrink-0"
title="Save to folder"
title={t("saveToFolder")}
loading={isSaving}
disabled={isSaving}
>
<Plus />
</LightButton>
</div>
</HStack>
</div>
<div className="space-y-6">
@@ -222,7 +263,7 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
loading={isSearching}
>
<RefreshCw className="w-4 h-4" />
Re-lookup
{t("relookup")}
</LightButton>
</div>
</div>

View File

@@ -1,13 +1,15 @@
import { TSharedEntry } from "@/shared/dictionary-type";
import { useTranslations } from "next-intl";
interface DictionaryEntryProps {
entry: TSharedEntry;
}
export function DictionaryEntry({ entry }: DictionaryEntryProps) {
const t = useTranslations("dictionary");
return (
<div>
{/* 音标和词性 */}
<div className="flex items-center gap-3 mb-3">
{entry.ipa && (
<span className="text-gray-600 text-lg">
@@ -21,19 +23,17 @@ export function DictionaryEntry({ entry }: DictionaryEntryProps) {
)}
</div>
{/* 释义 */}
<div className="mb-3">
<h3 className="text-sm font-semibold text-gray-700 mb-1">
{t("definition")}
</h3>
<p className="text-gray-800">{entry.definition}</p>
</div>
{/* 例句 */}
{entry.example && (
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-1">
{t("example")}
</h3>
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
{entry.example}

View File

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

View File

@@ -1,12 +1,15 @@
"use client";
import {
Folder as Fd,
Layers,
Heart,
Search,
ArrowUpDown,
} from "lucide-react";
import { CircleButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { Skeleton } from "@/design-system/feedback/skeleton";
import { HStack } from "@/design-system/layout/stack";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
@@ -14,35 +17,35 @@ import { toast } from "sonner";
import { PageLayout } from "@/components/ui/PageLayout";
import { PageHeader } from "@/components/ui/PageHeader";
import {
actionSearchPublicFolders,
actionToggleFavorite,
actionCheckFavorite,
} from "@/modules/folder/folder-aciton";
import { TPublicFolder } from "@/shared/folder-type";
actionSearchPublicDecks,
actionToggleDeckFavorite,
actionCheckDeckFavorite,
} from "@/modules/deck/deck-action";
import type { ActionOutputPublicDeck } from "@/modules/deck/deck-action-dto";
import { authClient } from "@/lib/auth-client";
interface PublicFolderCardProps {
folder: TPublicFolder;
interface PublicDeckCardProps {
deck: ActionOutputPublicDeck;
currentUserId?: string;
onUpdateFavorite: (folderId: number, isFavorited: boolean, favoriteCount: number) => void;
onUpdateFavorite: (deckId: number, isFavorited: boolean, favoriteCount: number) => void;
}
const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFolderCardProps) => {
const PublicDeckCard = ({ deck, currentUserId, onUpdateFavorite }: PublicDeckCardProps) => {
const router = useRouter();
const t = useTranslations("explore");
const [isFavorited, setIsFavorited] = useState(false);
const [favoriteCount, setFavoriteCount] = useState(folder.favoriteCount);
const [favoriteCount, setFavoriteCount] = useState(deck.favoriteCount);
useEffect(() => {
if (currentUserId) {
actionCheckFavorite(folder.id).then((result) => {
actionCheckDeckFavorite({ deckId: deck.id }).then((result) => {
if (result.success && result.data) {
setIsFavorited(result.data.isFavorited);
setFavoriteCount(result.data.favoriteCount);
}
});
}
}, [folder.id, currentUserId]);
}, [deck.id, currentUserId]);
const handleToggleFavorite = async (e: React.MouseEvent) => {
e.stopPropagation();
@@ -50,11 +53,11 @@ const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFol
toast.error(t("pleaseLogin"));
return;
}
const result = await actionToggleFavorite(folder.id);
const result = await actionToggleDeckFavorite({ deckId: deck.id });
if (result.success && result.data) {
setIsFavorited(result.data.isFavorited);
setFavoriteCount(result.data.favoriteCount);
onUpdateFavorite(folder.id, result.data.isFavorited, result.data.favoriteCount);
onUpdateFavorite(deck.id, result.data.isFavorited, result.data.favoriteCount);
} else {
toast.error(result.message);
}
@@ -64,13 +67,13 @@ const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFol
<div
className="group bg-white border border-gray-200 sm:border-2 rounded-lg p-3 sm:p-5 hover:border-primary-300 hover:shadow-md cursor-pointer transition-all overflow-hidden"
onClick={() => {
router.push(`/explore/${folder.id}`);
router.push(`/explore/${deck.id}`);
}}
>
<div className="flex items-start justify-between mb-2 sm:mb-3">
<div className="shrink-0 w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-primary-50 flex items-center justify-center text-primary-500">
<Fd size={18} className="sm:hidden" />
<Fd size={22} className="hidden sm:block" />
<Layers size={18} className="sm:hidden" />
<Layers size={22} className="hidden sm:block" />
</div>
<CircleButton
onClick={handleToggleFavorite}
@@ -83,12 +86,12 @@ const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFol
</CircleButton>
</div>
<h3 className="font-semibold text-gray-900 truncate text-sm sm:text-base mb-1 sm:mb-2">{folder.name}</h3>
<h3 className="font-semibold text-gray-900 truncate text-sm sm:text-base mb-1 sm:mb-2">{deck.name}</h3>
<p className="text-xs sm:text-sm text-gray-500 mb-2 sm:mb-3 line-clamp-2">
{t("folderInfo", {
userName: folder.userName ?? folder.userUsername ?? t("unknownUser"),
totalPairs: folder.totalPairs,
{t("deckInfo", {
userName: deck.userName ?? deck.userUsername ?? t("unknownUser"),
cardCount: deck.cardCount ?? 0,
})}
</p>
@@ -101,13 +104,13 @@ const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFol
};
interface ExploreClientProps {
initialPublicFolders: TPublicFolder[];
initialPublicDecks: ActionOutputPublicDeck[];
}
export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
export function ExploreClient({ initialPublicDecks }: ExploreClientProps) {
const t = useTranslations("explore");
const router = useRouter();
const [publicFolders, setPublicFolders] = useState<TPublicFolder[]>(initialPublicFolders);
const [publicDecks, setPublicDecks] = useState<ActionOutputPublicDeck[]>(initialPublicDecks);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [sortByFavorites, setSortByFavorites] = useState(false);
@@ -117,13 +120,13 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
const handleSearch = async () => {
if (!searchQuery.trim()) {
setPublicFolders(initialPublicFolders);
setPublicDecks(initialPublicDecks);
return;
}
setLoading(true);
const result = await actionSearchPublicFolders(searchQuery.trim());
const result = await actionSearchPublicDecks({ query: searchQuery.trim() });
if (result.success && result.data) {
setPublicFolders(result.data);
setPublicDecks(result.data);
}
setLoading(false);
};
@@ -132,14 +135,14 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
setSortByFavorites((prev) => !prev);
};
const sortedFolders = sortByFavorites
? [...publicFolders].sort((a, b) => b.favoriteCount - a.favoriteCount)
: publicFolders;
const sortedDecks = sortByFavorites
? [...publicDecks].sort((a, b) => b.favoriteCount - a.favoriteCount)
: publicDecks;
const handleUpdateFavorite = (folderId: number, _isFavorited: boolean, favoriteCount: number) => {
setPublicFolders((prev) =>
prev.map((f) =>
f.id === folderId ? { ...f, favoriteCount } : f
const handleUpdateFavorite = (deckId: number, _isFavorited: boolean, favoriteCount: number) => {
setPublicDecks((prev) =>
prev.map((d) =>
d.id === deckId ? { ...d, favoriteCount } : d
)
);
};
@@ -148,18 +151,16 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
<PageLayout>
<PageHeader title={t("title")} subtitle={t("subtitle")} />
<div className="flex items-center gap-2 mb-6">
<div className="relative flex-1">
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
<HStack align="center" gap={2} className="mb-6">
<Input
variant="bordered"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder={t("searchPlaceholder")}
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
leftIcon={<Search size={18} />}
containerClassName="flex-1"
/>
</div>
<CircleButton
onClick={handleToggleSort}
title={sortByFavorites ? t("sortByFavoritesActive") : t("sortByFavorites")}
@@ -170,26 +171,26 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
<CircleButton onClick={handleSearch}>
<Search size={18} />
</CircleButton>
</div>
</HStack>
{loading ? (
<div className="p-8 text-center">
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
<Skeleton variant="circular" className="w-8 h-8 mx-auto mb-3" />
<p className="text-sm text-gray-500">{t("loading")}</p>
</div>
) : sortedFolders.length === 0 ? (
) : sortedDecks.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
<Fd size={24} className="text-gray-400" />
<Layers size={24} className="text-gray-400" />
</div>
<p className="text-sm">{t("noFolders")}</p>
<p className="text-sm">{t("noDecks")}</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{sortedFolders.map((folder) => (
<PublicFolderCard
key={folder.id}
folder={folder}
{sortedDecks.map((deck) => (
<PublicDeckCard
key={deck.id}
deck={deck}
currentUserId={currentUserId}
onUpdateFavorite={handleUpdateFavorite}
/>

View File

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

View File

@@ -1,8 +1,8 @@
import { redirect } from "next/navigation";
import { InFolder } from "@/app/folders/[folder_id]/InFolder";
import { actionGetFolderVisibility } from "@/modules/folder/folder-aciton";
import { ExploreDetailClient } from "./ExploreDetailClient";
import { actionGetPublicDeckById } from "@/modules/deck/deck-action";
export default async function ExploreFolderPage({
export default async function ExploreDeckPage({
params,
}: {
params: Promise<{ id: string }>;
@@ -13,17 +13,11 @@ export default async function ExploreFolderPage({
redirect("/explore");
}
const folderInfo = (await actionGetFolderVisibility(Number(id))).data;
const result = await actionGetPublicDeckById({ deckId: Number(id) });
if (!folderInfo) {
if (!result.success || !result.data) {
redirect("/explore");
}
const isPublic = folderInfo.visibility === "PUBLIC";
if (!isPublic) {
redirect("/explore");
}
return <InFolder folderId={Number(id)} isReadOnly={true} />;
return <ExploreDetailClient deck={result.data} />;
}

View File

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

View File

@@ -2,33 +2,24 @@
import {
ChevronRight,
Folder as Fd,
Layers as DeckIcon,
Heart,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { PageLayout } from "@/components/ui/PageLayout";
import { PageHeader } from "@/components/ui/PageHeader";
import { CardList } from "@/components/ui/CardList";
import { actionGetUserFavorites, actionToggleFavorite } from "@/modules/folder/folder-aciton";
type UserFavorite = {
id: number;
folderId: number;
folderName: string;
folderCreatedAt: Date;
folderTotalPairs: number;
folderOwnerId: string;
folderOwnerName: string | null;
folderOwnerUsername: string | null;
favoritedAt: Date;
};
import { VStack } from "@/design-system/layout/stack";
import { Skeleton } from "@/design-system/feedback/skeleton";
import { actionGetUserFavoriteDecks, actionToggleDeckFavorite } from "@/modules/deck/deck-action";
import type { ActionOutputUserFavoriteDeck } from "@/modules/deck/deck-action-dto";
interface FavoriteCardProps {
favorite: UserFavorite;
onRemoveFavorite: (folderId: number) => void;
favorite: ActionOutputUserFavoriteDeck;
onRemoveFavorite: (deckId: number) => void;
}
const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
@@ -41,9 +32,9 @@ const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
if (isRemoving) return;
setIsRemoving(true);
const result = await actionToggleFavorite(favorite.folderId);
const result = await actionToggleDeckFavorite({ deckId: favorite.id });
if (result.success) {
onRemoveFavorite(favorite.folderId);
onRemoveFavorite(favorite.id);
} else {
toast.error(result.message);
}
@@ -54,20 +45,20 @@ const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
<div
className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() => {
router.push(`/explore/${favorite.folderId}`);
router.push(`/explore/${favorite.id}`);
}}
>
<div className="flex items-center gap-4 flex-1">
<div className="shrink-0 text-primary-500">
<Fd size={24} />
<DeckIcon size={24} />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{favorite.folderName}</h3>
<h3 className="font-semibold text-gray-900 truncate">{favorite.name}</h3>
<p className="text-sm text-gray-500 mt-0.5">
{t("folderInfo", {
userName: favorite.folderOwnerName ?? favorite.folderOwnerUsername ?? t("unknownUser"),
totalPairs: favorite.folderTotalPairs,
userName: favorite.userName ?? favorite.userUsername ?? t("unknownUser"),
totalPairs: favorite.cardCount ?? 0,
})}
</p>
</div>
@@ -86,29 +77,25 @@ const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
};
interface FavoritesClientProps {
userId: string;
initialFavorites: ActionOutputUserFavoriteDeck[];
}
export function FavoritesClient({ userId }: FavoritesClientProps) {
export function FavoritesClient({ initialFavorites }: FavoritesClientProps) {
const t = useTranslations("favorites");
const [favorites, setFavorites] = useState<UserFavorite[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadFavorites();
}, [userId]);
const [favorites, setFavorites] = useState<ActionOutputUserFavoriteDeck[]>(initialFavorites);
const [loading, setLoading] = useState(false);
const loadFavorites = async () => {
setLoading(true);
const result = await actionGetUserFavorites();
const result = await actionGetUserFavoriteDecks();
if (result.success && result.data) {
setFavorites(result.data);
}
setLoading(false);
};
const handleRemoveFavorite = (folderId: number) => {
setFavorites((prev) => prev.filter((f) => f.folderId !== folderId));
const handleRemoveFavorite = (deckId: number) => {
setFavorites((prev) => prev.filter((f) => f.id !== deckId));
};
return (
@@ -117,10 +104,10 @@ export function FavoritesClient({ userId }: FavoritesClientProps) {
<CardList>
{loading ? (
<div className="p-8 text-center">
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
<VStack align="center" className="p-8">
<Skeleton variant="circular" className="w-8 h-8" />
<p className="text-sm text-gray-500">{t("loading")}</p>
</div>
</VStack>
) : favorites.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">

View File

@@ -2,6 +2,8 @@ import { auth } from "@/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { FavoritesClient } from "./FavoritesClient";
import { actionGetUserFavoriteDecks } from "@/modules/deck/deck-action";
import type { ActionOutputUserFavoriteDeck } from "@/modules/deck/deck-action-dto";
export default async function FavoritesPage() {
const session = await auth.api.getSession({ headers: await headers() });
@@ -10,5 +12,11 @@ export default async function FavoritesPage() {
redirect("/login?redirect=/favorites");
}
return <FavoritesClient userId={session.user.id} />;
let favorites: ActionOutputUserFavoriteDeck[] = [];
const result = await actionGetUserFavoriteDecks();
if (result.success && result.data) {
favorites = result.data;
}
return <FavoritesClient initialFavorites={favorites} />;
}

View File

@@ -1,93 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { Folder as Fd } from "lucide-react";
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
import { PageLayout } from "@/components/ui/PageLayout";
import { PrimaryButton } from "@/design-system/base/button";
interface FolderSelectorProps {
folders: TSharedFolderWithTotalPairs[];
}
const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
const t = useTranslations("memorize.folder_selector");
const router = useRouter();
return (
<PageLayout>
{folders.length === 0 ? (
// 空状态 - 显示提示和跳转按钮
<div className="text-center">
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-4">
{t("noFolders")}
</h1>
<Link href="/folders">
<PrimaryButton className="px-6 py-2">
Go to Folders
</PrimaryButton>
</Link>
</div>
) : (
<>
{/* 页面标题 */}
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-6">
{t("selectFolder")}
</h1>
{/* 文件夹列表 */}
<div className="border border-gray-200 rounded-lg max-h-96 overflow-y-auto">
{folders
.toSorted((a, b) => a.id - b.id)
.map((folder) => (
<div
key={folder.id}
onClick={() =>
router.push(`/memorize?folder_id=${folder.id}`)
}
className="flex flex-row items-center p-4 gap-3 hover:cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0"
>
{/* 文件夹图标 */}
<div className="shrink-0">
<Fd className="text-gray-600" size="md" />
</div>
{/* 文件夹信息 */}
<div className="flex-1">
<div className="font-medium text-gray-900">
{folder.name}
</div>
<div className="text-sm text-gray-500">
{t("folderInfo", {
id: folder.id,
name: folder.name,
count: folder.total,
})}
</div>
</div>
{/* 右箭头 */}
<div className="text-gray-400">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>
))}
</div>
</>
)}
</PageLayout>
);
};
export { FolderSelector };

View File

@@ -1,195 +0,0 @@
"use client";
import { useState } from "react";
import { LinkButton, CircleToggleButton, LightButton } from "@/design-system/base/button";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
import { useTranslations } from "next-intl";
import localFont from "next/font/local";
import { isNonNegativeInteger, SeededRandom } from "@/utils/random";
import { TSharedPair } from "@/shared/folder-type";
import { PageLayout } from "@/components/ui/PageLayout";
const myFont = localFont({
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
});
interface MemorizeProps {
textPairs: TSharedPair[];
}
const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
const t = useTranslations("memorize.memorize");
const [reverse, setReverse] = useState(false);
const [dictation, setDictation] = useState(false);
const [disorder, setDisorder] = useState(false);
const [index, setIndex] = useState(0);
const [show, setShow] = useState<"question" | "answer">("question");
const { load, play } = useAudioPlayer();
if (textPairs.length === 0) {
return (
<PageLayout>
<p className="text-gray-700 text-center">{t("noTextPairs")}</p>
</PageLayout>
);
}
const rng = new SeededRandom(textPairs[0].folderId);
const disorderedTextPairs = textPairs.toSorted(() => rng.next() - 0.5);
textPairs.sort((a, b) => a.id - b.id);
const getTextPairs = () => disorder ? disorderedTextPairs : textPairs;
const handleIndexClick = () => {
const newIndex = prompt("Input a index number.")?.trim();
if (
newIndex &&
isNonNegativeInteger(newIndex) &&
parseInt(newIndex) <= textPairs.length &&
parseInt(newIndex) > 0
) {
setIndex(parseInt(newIndex) - 1);
}
};
const handleNext = async () => {
if (show === "answer") {
const newIndex = (index + 1) % getTextPairs().length;
setIndex(newIndex);
if (dictation) {
const textPair = getTextPairs()[newIndex];
const language = textPair[reverse ? "language2" : "language1"];
const text = textPair[reverse ? "text2" : "text1"];
// 映射语言到 TTS 支持的格式
const languageMap: Record<string, TTS_SUPPORTED_LANGUAGES> = {
"chinese": "Chinese",
"english": "English",
"japanese": "Japanese",
"korean": "Korean",
"french": "French",
"german": "German",
"italian": "Italian",
"portuguese": "Portuguese",
"spanish": "Spanish",
"russian": "Russian",
};
const ttsLanguage = languageMap[language?.toLowerCase()] || "Auto";
getTTSUrl(text, ttsLanguage).then((url) => {
load(url);
play();
});
}
}
setShow(show === "question" ? "answer" : "question");
};
const handlePrevious = () => {
setIndex(
(index - 1 + getTextPairs().length) % getTextPairs().length,
);
setShow("question");
};
const toggleReverse = () => setReverse(!reverse);
const toggleDictation = () => setDictation(!dictation);
const toggleDisorder = () => setDisorder(!disorder);
const createText = (text: string) => {
return (
<div className="text-gray-900 text-xl md:text-2xl p-6 h-[20dvh] overflow-y-auto text-center">
{text}
</div>
);
};
const [text1, text2] = reverse
? [getTextPairs()[index].text2, getTextPairs()[index].text1]
: [getTextPairs()[index].text1, getTextPairs()[index].text2];
return (
<PageLayout>
{/* 进度指示器 */}
<div className="flex justify-center mb-4">
<LinkButton onClick={handleIndexClick} className="text-sm">
{index + 1} / {getTextPairs().length}
</LinkButton>
</div>
{/* 文本显示区域 */}
<div className={`h-[40dvh] ${myFont.className} mb-4`}>
{(() => {
if (dictation) {
if (show === "question") {
return (
<div className="h-full flex items-center justify-center">
<div className="text-gray-400 text-4xl">?</div>
</div>
);
} else {
return (
<div className="space-y-2">
{createText(text1)}
<div className="border-t border-gray-200"></div>
{createText(text2)}
</div>
);
}
} else {
if (show === "question") {
return createText(text1);
} else {
return (
<div className="space-y-2">
{createText(text1)}
<div className="border-t border-gray-200"></div>
{createText(text2)}
</div>
);
}
}
})()}
</div>
{/* 底部按钮 */}
<div className="flex flex-row gap-2 items-center justify-center flex-wrap">
<LightButton
onClick={handleNext}
className="px-4 py-2 rounded-full text-sm"
>
{show === "question" ? t("answer") : t("next")}
</LightButton>
<LightButton
onClick={handlePrevious}
className="px-4 py-2 rounded-full text-sm"
>
{t("previous")}
</LightButton>
<CircleToggleButton
selected={reverse}
onClick={toggleReverse}
>
{t("reverse")}
</CircleToggleButton>
<CircleToggleButton
selected={dictation}
onClick={toggleDictation}
>
{t("dictation")}
</CircleToggleButton>
<CircleToggleButton
selected={disorder}
onClick={toggleDisorder}
>
{t("disorder")}
</CircleToggleButton>
</div>
</PageLayout>
);
};
export { Memorize };

View File

@@ -1,37 +0,0 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { isNonNegativeInteger } from "@/utils/random";
import { FolderSelector } from "./FolderSelector";
import { Memorize } from "./Memorize";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { actionGetFoldersWithTotalPairsByUserId, actionGetPairsByFolderId } from "@/modules/folder/folder-aciton";
export default async function MemorizePage({
searchParams,
}: {
searchParams: Promise<{ folder_id?: string; }>;
}) {
const tParam = (await searchParams).folder_id;
const t = await getTranslations("memorize.page");
const folder_id = tParam
? isNonNegativeInteger(tParam)
? parseInt(tParam)
: null
: null;
if (!folder_id) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login?redirect=/memorize");
return (
<FolderSelector
folders={(await actionGetFoldersWithTotalPairsByUserId(session.user.id)).data!}
/>
);
}
return <Memorize textPairs={(await actionGetPairsByFolderId(folder_id)).data!} />;
}

View File

@@ -70,7 +70,7 @@ export default function SrtPlayerPage() {
uploadVideo((url) => {
setVideoUrl(url);
}, (error) => {
toast.error(t('videoUploadFailed') + ': ' + error.message);
toast.error(srtT('videoUploadFailed') + ': ' + error.message);
});
};
@@ -78,7 +78,7 @@ export default function SrtPlayerPage() {
uploadSubtitle((url) => {
setSubtitleUrl(url);
}, (error) => {
toast.error(t('subtitleUploadFailed') + ': ' + error.message);
toast.error(srtT('subtitleUploadFailed') + ': ' + error.message);
});
};
@@ -127,21 +127,21 @@ export default function SrtPlayerPage() {
<div className="border-gray-200 border-2 flex items p-2 justify-between items-center rounded gap-8">
<div className="flex items-center flex-col">
<Video size={16} />
<span className="text-sm"></span>
<span className="text-sm">{srtT("videoFile")}</span>
</div>
<LightButton onClick={handleVideoUpload} disabled={!!videoUrl}>
{videoUrl ? '已上传' : '上传视频'}
{videoUrl ? srtT("uploaded") : srtT("uploadVideoButton")}
</LightButton>
</div>
<div className="border-gray-200 border-2 flex items p-2 justify-between items-center rounded gap-8">
<div className="flex items-center flex-col">
<FileText size={16} />
<span className="text-sm">
{subtitleData.length > 0 ? `字幕已上传 (${subtitleData.length} 条)` : "字幕未上传"}
{subtitleData.length > 0 ? srtT("subtitleUploaded", { count: subtitleData.length }) : srtT("subtitleNotUploaded")}
</span>
</div>
<LightButton onClick={handleSubtitleUpload} disabled={!!subtitleUrl}>
{subtitleUrl ? '已上传' : '上传字幕'}
{subtitleUrl ? srtT("uploaded") : srtT("uploadSubtitleButton")}
</LightButton>
</div>
</div>

View File

@@ -62,13 +62,12 @@ export function getNearestIndex(
): number | null {
for (let i = 0; i < subtitles.length; i++) {
const subtitle = subtitles[i];
const isBefore = currentTime - subtitle.start >= 0;
const isAfter = currentTime - subtitle.end >= 0;
const isWithin = currentTime >= subtitle.start && currentTime <= subtitle.end;
if (!isBefore || !isAfter) return i - 1;
if (isBefore && !isAfter) return i;
if (isWithin) return i;
if (currentTime < subtitle.start) return i > 0 ? i - 1 : null;
}
return null;
return subtitles.length > 0 ? subtitles.length - 1 : null;
}
export function getCurrentSubtitle(

View File

@@ -60,11 +60,12 @@ export function SaveList({ show = false, handleUse }: SaveListProps) {
const [data, setData] = useState(getFromLocalStorage());
const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => {
const current_data = getFromLocalStorage();
if (!current_data) return;
current_data.splice(
current_data.findIndex((v) => v.text === item.text),
1,
);
const index = current_data.findIndex((v) => v.text === item.text);
if (index === -1) return;
current_data.splice(index, 1);
setIntoLocalStorage(current_data);
refresh();
};
@@ -78,33 +79,25 @@ export function SaveList({ show = false, handleUse }: SaveListProps) {
refresh();
}
};
if (show)
if (show && data)
return (
<div
className="my-4 p-2 mx-4 md:mx-32 border border-gray-200 rounded-lg"
style={{ fontFamily: "Times New Roman, serif" }}
>
<div className="flex flex-row justify-center gap-8 items-center">
<IconClick
src={IMAGES.refresh}
alt="refresh"
onClick={refresh}
size="lg"
className=""
></IconClick>
<IconClick
src={IMAGES.delete}
alt="delete"
<div className="flex justify-between items-center mb-2">
<p className="text-sm text-gray-600">{t("saved")}</p>
<button
onClick={handleDeleteAll}
size="lg"
className=""
></IconClick>
className="text-xs text-gray-500 hover:text-gray-800"
>
{t("clearAll")}
</button>
</div>
<ul>
{data.map((v) => (
<ul className="divide-y divide-gray-100">
{data.map((item, i) => (
<TextCard
item={v}
key={crypto.randomUUID()}
key={i}
item={item}
handleUse={handleUse}
handleDel={handleDel}
></TextCard>

View File

@@ -1,7 +1,8 @@
"use client";
import { LightButton } from "@/design-system/base/button";
import { IconClick } from "@/design-system/base/button";
import { LightButton, IconClick } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { Textarea } from "@/design-system/base/textarea";
import { IMAGES } from "@/config/images";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import {
@@ -18,6 +19,38 @@ import { genIPA, genLanguage } from "@/modules/translator/translator-action";
import { PageLayout } from "@/components/ui/PageLayout";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
const TTS_LANGUAGES = [
{ value: "Auto", label: "auto" },
{ value: "Chinese", label: "chinese" },
{ value: "English", label: "english" },
{ value: "Japanese", label: "japanese" },
{ value: "Korean", label: "korean" },
{ value: "French", label: "french" },
{ value: "German", label: "german" },
{ value: "Italian", label: "italian" },
{ value: "Spanish", label: "spanish" },
{ value: "Portuguese", label: "portuguese" },
{ value: "Russian", label: "russian" },
] as const;
type TTSLabel = typeof TTS_LANGUAGES[number]["label"];
function getLanguageLabel(t: (key: string) => string, label: TTSLabel): string {
switch (label) {
case "auto": return t("languages.auto");
case "chinese": return t("languages.chinese");
case "english": return t("languages.english");
case "japanese": return t("languages.japanese");
case "korean": return t("languages.korean");
case "french": return t("languages.french");
case "german": return t("languages.german");
case "italian": return t("languages.italian");
case "spanish": return t("languages.spanish");
case "portuguese": return t("languages.portuguese");
case "russian": return t("languages.russian");
}
}
export default function TextSpeakerPage() {
const t = useTranslations("text_speaker");
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -30,6 +63,8 @@ export default function TextSpeakerPage() {
const [autopause, setAutopause] = useState(true);
const textRef = useRef("");
const [language, setLanguage] = useState<string | null>(null);
const [selectedLanguage, setSelectedLanguage] = useState<string>("Auto");
const [customLanguage, setCustomLanguage] = useState<string>("");
const [ipa, setIPA] = useState<string>("");
const objurlRef = useRef<string | null>(null);
const [processing, setProcessing] = useState(false);
@@ -48,8 +83,8 @@ export default function TextSpeakerPage() {
const handleEnded = () => {
if (autopause) {
setPause(true);
} else {
load(objurlRef.current!);
} else if (objurlRef.current) {
load(objurlRef.current);
play();
}
};
@@ -93,8 +128,15 @@ export default function TextSpeakerPage() {
} else {
// 第一次播放
try {
let theLanguage = language;
if (!theLanguage) {
let theLanguage: string;
if (customLanguage.trim()) {
theLanguage = customLanguage.trim();
} else if (selectedLanguage !== "Auto") {
theLanguage = selectedLanguage;
} else if (language) {
theLanguage = language;
} else {
const tmp_language = await genLanguage(textRef.current.slice(0, 30));
setLanguage(tmp_language);
theLanguage = tmp_language;
@@ -102,7 +144,6 @@ export default function TextSpeakerPage() {
theLanguage = theLanguage.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
// 检查语言是否在 TTS 支持列表中
const supportedLanguages: TTS_SUPPORTED_LANGUAGES[] = [
"Auto", "Chinese", "English", "German", "Italian", "Portuguese",
"Spanish", "Japanese", "Korean", "French", "Russian"
@@ -138,6 +179,8 @@ export default function TextSpeakerPage() {
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
textRef.current = e.target.value.trim();
setLanguage(null);
setSelectedLanguage("Auto");
setCustomLanguage("");
setIPA("");
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
objurlRef.current = null;
@@ -187,7 +230,7 @@ export default function TextSpeakerPage() {
theIPA = tmp_ipa;
}
const save = getFromLocalStorage();
const save = getFromLocalStorage() ?? [];
const oldIndex = save.findIndex((v) => v.text === textRef.current);
if (oldIndex !== -1) {
const oldItem = save[oldIndex];
@@ -226,11 +269,12 @@ export default function TextSpeakerPage() {
style={{ fontFamily: "Times New Roman, serif" }}
>
{/* 文本输入框 */}
<textarea
className="text-2xl resize-none focus:outline-0 min-h-64 w-full border-gray-200 border-b p-4"
<Textarea
variant="bordered"
className="text-2xl min-h-64"
onChange={handleInputChange}
ref={textareaRef}
></textarea>
/>
{/* IPA 显示区域 */}
{(ipa.length !== 0 && (
<div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b px-4">
@@ -293,7 +337,7 @@ export default function TextSpeakerPage() {
size="lg"
onClick={() => {
setAutopause(!autopause);
if (objurlRef) {
if (objurlRef.current) {
stop();
}
setPause(true);
@@ -317,6 +361,40 @@ export default function TextSpeakerPage() {
alt="save"
className={`${saving ? "bg-gray-200" : ""}`}
></IconClick>
{/* 语言选择器 */}
<div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center">
<span className="text-sm text-gray-600">{t("language")}</span>
{TTS_LANGUAGES.slice(0, 6).map((lang) => (
<LightButton
key={lang.value}
selected={!customLanguage && selectedLanguage === lang.value}
onClick={() => {
setSelectedLanguage(lang.value);
setCustomLanguage("");
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
objurlRef.current = null;
setPause(true);
}}
size="sm"
>
{getLanguageLabel(t, lang.label)}
</LightButton>
))}
<Input
variant="bordered"
size="sm"
value={customLanguage}
onChange={(e) => {
setCustomLanguage(e.target.value);
setSelectedLanguage("Auto");
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
objurlRef.current = null;
setPause(true);
}}
placeholder={t("customLanguage")}
className="w-auto min-w-[120px]"
/>
</div>
{/* 功能开关按钮 */}
<div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center">
<LightButton

View File

@@ -1,35 +1,138 @@
"use client";
import { LightButton, PrimaryButton, IconClick } from "@/design-system/base/button";
import { LightButton, PrimaryButton, IconClick, CircleButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { Textarea } from "@/design-system/base/textarea";
import { Select } from "@/design-system/base/select";
import { IMAGES } from "@/config/images";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { useTranslations } from "next-intl";
import { useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { actionTranslateText } from "@/modules/translator/translator-action";
import { actionCreateCard } from "@/modules/card/card-action";
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
import type { CardType } from "@/modules/card/card-action-dto";
import { toast } from "sonner";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
import { TSharedTranslationResult } from "@/shared/translator-type";
import { Plus } from "lucide-react";
import { authClient } from "@/lib/auth-client";
const SOURCE_LANGUAGES = [
{ value: "Auto", label: "auto" },
{ value: "Chinese", label: "chinese" },
{ value: "English", label: "english" },
{ value: "Japanese", label: "japanese" },
{ value: "Korean", label: "korean" },
{ value: "French", label: "french" },
{ value: "German", label: "german" },
{ value: "Italian", label: "italian" },
{ value: "Spanish", label: "spanish" },
{ value: "Portuguese", label: "portuguese" },
{ value: "Russian", label: "russian" },
] as const;
const TARGET_LANGUAGES = [
{ value: "Chinese", label: "chinese" },
{ value: "English", label: "english" },
{ value: "Japanese", label: "japanese" },
{ value: "Korean", label: "korean" },
{ value: "French", label: "french" },
{ value: "German", label: "german" },
{ value: "Italian", label: "italian" },
{ value: "Spanish", label: "spanish" },
{ value: "Portuguese", label: "portuguese" },
{ value: "Russian", label: "russian" },
] as const;
type LangLabel = typeof SOURCE_LANGUAGES[number]["label"];
function getLangLabel(t: (key: string) => string, label: LangLabel): string {
switch (label) {
case "auto": return t("auto");
case "chinese": return t("chinese");
case "english": return t("english");
case "japanese": return t("japanese");
case "korean": return t("korean");
case "french": return t("french");
case "german": return t("german");
case "italian": return t("italian");
case "spanish": return t("spanish");
case "portuguese": return t("portuguese");
case "russian": return t("russian");
}
}
// Estimated button width in pixels (including gap)
const BUTTON_WIDTH = 80;
const LABEL_WIDTH = 100;
const INPUT_WIDTH = 140;
const IPA_BUTTON_WIDTH = 100;
export default function TranslatorPage() {
const t = useTranslations("translator");
const taref = useRef<HTMLTextAreaElement>(null);
const sourceContainerRef = useRef<HTMLDivElement>(null);
const targetContainerRef = useRef<HTMLDivElement>(null);
const [sourceLanguage, setSourceLanguage] = useState<string>("Auto");
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
const [customSourceLanguage, setCustomSourceLanguage] = useState<string>("");
const [customTargetLanguage, setCustomTargetLanguage] = useState<string>("");
const [translationResult, setTranslationResult] = useState<TSharedTranslationResult | null>(null);
const [needIpa, setNeedIpa] = useState(true);
const [processing, setProcessing] = useState(false);
const [lastTranslation, setLastTranslation] = useState<{
sourceText: string;
sourceLanguage: string;
targetLanguage: string;
} | null>(null);
const [sourceButtonCount, setSourceButtonCount] = useState(2);
const [targetButtonCount, setTargetButtonCount] = useState(2);
const { load, play } = useAudioPlayer();
const lastTTS = useRef({
text: "",
url: "",
});
const tts = async (text: string, locale: string) => {
if (lastTTS.current.text !== text) {
const { data: session } = authClient.useSession();
const [decks, setDecks] = useState<ActionOutputDeck[]>([]);
const [showSaveModal, setShowSaveModal] = useState(false);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (session?.user?.id) {
actionGetDecksByUserId(session.user.id).then((result) => {
if (result.success && result.data) {
setDecks(result.data);
}
});
}
}, [session?.user?.id]);
// Calculate how many buttons to show based on container width
const calculateButtonCount = useCallback((containerWidth: number, hasIpa: boolean) => {
// Reserve space for label, input, and IPA button (for source)
const reservedWidth = LABEL_WIDTH + INPUT_WIDTH + (hasIpa ? IPA_BUTTON_WIDTH : 0);
const availableWidth = containerWidth - reservedWidth;
return Math.max(0, Math.floor(availableWidth / BUTTON_WIDTH));
}, []);
useEffect(() => {
const updateButtonCounts = () => {
if (sourceContainerRef.current) {
const width = sourceContainerRef.current.offsetWidth;
setSourceButtonCount(calculateButtonCount(width, true));
}
if (targetContainerRef.current) {
const width = targetContainerRef.current.offsetWidth;
setTargetButtonCount(calculateButtonCount(width, false));
}
};
updateButtonCounts();
window.addEventListener("resize", updateButtonCounts);
return () => window.removeEventListener("resize", updateButtonCounts);
}, [calculateButtonCount]);
const tts = useCallback(async (text: string, locale: string) => {
try {
// Map language name to TTS format
let theLanguage = locale.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
@@ -47,13 +150,10 @@ export default function TranslatorPage() {
const url = await getTTSUrl(text, theLanguage as TTS_SUPPORTED_LANGUAGES);
await load(url);
await play();
lastTTS.current.text = text;
lastTTS.current.url = url;
} catch (error) {
toast.error("Failed to generate audio");
}
}
};
}, [load, play]);
const translate = async () => {
if (!taref.current || processing) return;
@@ -61,26 +161,30 @@ export default function TranslatorPage() {
setProcessing(true);
const sourceText = taref.current.value;
const effectiveSourceLanguage = customSourceLanguage.trim() || sourceLanguage;
const effectiveTargetLanguage = customTargetLanguage.trim() || targetLanguage;
// 判断是否需要强制重新翻译
// 只有当源文本和目标语言都与上次相同时,才强制重新翻译
const forceRetranslate =
lastTranslation?.sourceText === sourceText &&
lastTranslation?.targetLanguage === targetLanguage;
lastTranslation?.sourceLanguage === effectiveSourceLanguage &&
lastTranslation?.targetLanguage === effectiveTargetLanguage;
try {
const result = await actionTranslateText({
sourceText,
targetLanguage,
targetLanguage: effectiveTargetLanguage,
forceRetranslate,
needIpa,
sourceLanguage: effectiveSourceLanguage === "Auto" ? undefined : effectiveSourceLanguage,
});
if (result.success && result.data) {
setTranslationResult(result.data);
setLastTranslation({
sourceText,
targetLanguage,
sourceLanguage: effectiveSourceLanguage,
targetLanguage: effectiveTargetLanguage,
});
} else {
toast.error(result.message || "翻译失败,请重试");
@@ -93,6 +197,66 @@ export default function TranslatorPage() {
}
};
const visibleSourceButtons = SOURCE_LANGUAGES.slice(0, sourceButtonCount);
const visibleTargetButtons = TARGET_LANGUAGES.slice(0, targetButtonCount);
const handleSaveCard = async () => {
if (!session) {
toast.error(t("pleaseLogin"));
return;
}
if (decks.length === 0) {
toast.error(t("pleaseCreateDeck"));
return;
}
if (!lastTranslation?.sourceText || !translationResult?.translatedText) {
toast.error(t("noTranslationToSave"));
return;
}
const deckSelect = document.getElementById("deck-select-translator") as HTMLSelectElement;
const deckId = deckSelect?.value ? Number(deckSelect.value) : decks[0]?.id;
if (!deckId) {
toast.error(t("noDeckSelected"));
return;
}
setIsSaving(true);
try {
const sourceText = lastTranslation.sourceText;
const hasSpaces = sourceText.includes(" ");
let cardType: CardType = "WORD";
if (!translationResult.sourceIpa) {
cardType = "SENTENCE";
} else if (hasSpaces) {
cardType = "PHRASE";
}
await actionCreateCard({
deckId,
word: sourceText,
ipa: translationResult.sourceIpa || null,
queryLang: lastTranslation.sourceLanguage,
cardType,
meanings: [{
partOfSpeech: null,
definition: translationResult.translatedText,
example: null,
}],
});
const deckName = decks.find((d) => d.id === deckId)?.name || "Unknown";
toast.success(t("savedToDeck", { deckName }));
setShowSaveModal(false);
} catch (error) {
toast.error(t("saveFailed"));
} finally {
setIsSaving(false);
}
};
return (
<div className="min-h-[calc(100vh-64px)] bg-white">
{/* TCard Component */}
@@ -101,13 +265,13 @@ export default function TranslatorPage() {
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
{/* ICard1 Component */}
<div className="border border-gray-200 rounded-lg w-full h-64 p-2">
<textarea
className="resize-none h-8/12 w-full focus:outline-0"
<Textarea
className="resize-none h-8/12 w-full"
ref={taref}
onKeyDown={(e) => {
if (e.ctrlKey && e.key === "Enter") translate();
}}
></textarea>
/>
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
{translationResult?.sourceIpa || ""}
</div>
@@ -125,18 +289,41 @@ export default function TranslatorPage() {
src={IMAGES.play_arrow}
alt="play"
onClick={() => {
const t = taref.current?.value;
if (!t) return;
tts(t, translationResult?.sourceLanguage || "");
const text = taref.current?.value;
if (!text) return;
tts(text, translationResult?.sourceLanguage || "");
}}
></IconClick>
</div>
</div>
<div className="option1 w-full flex flex-row justify-between items-center">
<span>{t("detectLanguage")}</span>
<div ref={sourceContainerRef} className="option1 w-full flex gap-1 items-center overflow-x-auto">
<span className="shrink-0">{t("sourceLanguage")}</span>
{visibleSourceButtons.map((lang) => (
<LightButton
key={lang.value}
selected={!customSourceLanguage && sourceLanguage === lang.value}
onClick={() => {
setSourceLanguage(lang.value);
setCustomSourceLanguage("");
}}
className="shrink-0"
>
{getLangLabel(t, lang.label)}
</LightButton>
))}
<Input
variant="bordered"
size="sm"
value={customSourceLanguage}
onChange={(e) => setCustomSourceLanguage(e.target.value)}
placeholder={t("customLanguage")}
className="w-auto min-w-[120px] shrink-0"
/>
<div className="flex-1"></div>
<LightButton
selected={needIpa}
onClick={() => setNeedIpa((prev) => !prev)}
className="shrink-0"
>
{t("generateIPA")}
</LightButton>
@@ -172,43 +359,35 @@ export default function TranslatorPage() {
></IconClick>
</div>
</div>
<div className="option2 w-full flex gap-1 items-center flex-wrap">
<span>{t("translateInto")}</span>
<div ref={targetContainerRef} className="option2 w-full flex gap-1 items-center overflow-x-auto">
<span className="shrink-0">{t("translateInto")}</span>
{visibleTargetButtons.map((lang) => (
<LightButton
selected={targetLanguage === "Chinese"}
onClick={() => setTargetLanguage("Chinese")}
>
{t("chinese")}
</LightButton>
<LightButton
selected={targetLanguage === "English"}
onClick={() => setTargetLanguage("English")}
>
{t("english")}
</LightButton>
<LightButton
selected={targetLanguage === "Italian"}
onClick={() => setTargetLanguage("Italian")}
>
{t("italian")}
</LightButton>
<LightButton
selected={!["Chinese", "English", "Italian"].includes(targetLanguage)}
key={lang.value}
selected={!customTargetLanguage && targetLanguage === lang.value}
onClick={() => {
const newLang = prompt(t("enterLanguage"));
if (newLang) {
setTargetLanguage(newLang);
}
setTargetLanguage(lang.value);
setCustomTargetLanguage("");
}}
className="shrink-0"
>
{t("other")}
{getLangLabel(t, lang.label)}
</LightButton>
))}
<Input
variant="bordered"
size="sm"
value={customTargetLanguage}
onChange={(e) => setCustomTargetLanguage(e.target.value)}
placeholder={t("customLanguage")}
className="w-auto min-w-[120px] shrink-0"
/>
</div>
</div>
</div>
{/* TranslateButton Component */}
<div className="w-screen flex justify-center items-center">
<div className="w-screen flex justify-center items-center gap-4">
<PrimaryButton
onClick={translate}
disabled={processing}
@@ -217,7 +396,49 @@ export default function TranslatorPage() {
>
{t("translate")}
</PrimaryButton>
{translationResult && session && decks.length > 0 && (
<CircleButton
onClick={() => setShowSaveModal(true)}
title={t("saveAsCard")}
>
<Plus size={20} />
</CircleButton>
)}
</div>
{showSaveModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
<h2 className="text-xl font-semibold mb-4">{t("saveAsCard")}</h2>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("selectDeck")}
</label>
<Select id="deck-select-translator" className="w-full">
{decks.map((deck) => (
<option key={deck.id} value={deck.id}>
{deck.name}
</option>
))}
</Select>
</div>
<div className="mb-4 p-3 bg-gray-50 rounded text-sm">
<div className="font-medium mb-1">{t("front")}:</div>
<div className="text-gray-700 mb-2">{lastTranslation?.sourceText}</div>
<div className="font-medium mb-1">{t("back")}:</div>
<div className="text-gray-700">{translationResult?.translatedText}</div>
</div>
<div className="flex justify-end gap-2">
<LightButton onClick={() => setShowSaveModal(false)}>
{t("cancel")}
</LightButton>
<PrimaryButton onClick={handleSaveCard} loading={isSaving}>
{t("save")}
</PrimaryButton>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -2,14 +2,16 @@
import {
ChevronRight,
Folder as Fd,
FolderPen,
FolderPlus,
Layers,
Pencil,
Plus,
Globe,
Lock,
Trash2,
} from "lucide-react";
import { CircleButton, LightButton } from "@/design-system/base/button";
import { Skeleton } from "@/design-system/feedback/skeleton";
import { VStack } from "@/design-system/layout/stack";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
@@ -18,30 +20,33 @@ import { PageLayout } from "@/components/ui/PageLayout";
import { PageHeader } from "@/components/ui/PageHeader";
import { CardList } from "@/components/ui/CardList";
import {
actionCreateFolder,
actionDeleteFolderById,
actionGetFoldersWithTotalPairsByUserId,
actionRenameFolderById,
actionSetFolderVisibility,
} from "@/modules/folder/folder-aciton";
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
actionCreateDeck,
actionDeleteDeck,
actionGetDecksByUserId,
actionUpdateDeck,
actionGetDeckById,
} from "@/modules/deck/deck-action";
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
interface FolderCardProps {
folder: TSharedFolderWithTotalPairs;
onUpdateFolder: (folderId: number, updates: Partial<TSharedFolderWithTotalPairs>) => void;
onDeleteFolder: (folderId: number) => void;
interface DeckCardProps {
deck: ActionOutputDeck;
onUpdateDeck: (deckId: number, updates: Partial<ActionOutputDeck>) => void;
onDeleteDeck: (deckId: number) => void;
}
const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps) => {
const DeckCard = ({ deck, onUpdateDeck, onDeleteDeck }: DeckCardProps) => {
const router = useRouter();
const t = useTranslations("folders");
const t = useTranslations("decks");
const handleToggleVisibility = async (e: React.MouseEvent) => {
e.stopPropagation();
const newVisibility = folder.visibility === "PUBLIC" ? "PRIVATE" : "PUBLIC";
const result = await actionSetFolderVisibility(folder.id, newVisibility);
const newVisibility = deck.visibility === "PUBLIC" ? "PRIVATE" : "PUBLIC";
const result = await actionUpdateDeck({
deckId: deck.id,
visibility: newVisibility,
});
if (result.success) {
onUpdateFolder(folder.id, { visibility: newVisibility });
onUpdateDeck(deck.id, { visibility: newVisibility });
} else {
toast.error(result.message);
}
@@ -51,9 +56,12 @@ const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps)
e.stopPropagation();
const newName = prompt(t("enterNewName"))?.trim();
if (newName && newName.length > 0) {
const result = await actionRenameFolderById(folder.id, newName);
const result = await actionUpdateDeck({
deckId: deck.id,
name: newName,
});
if (result.success) {
onUpdateFolder(folder.id, { name: newName });
onUpdateDeck(deck.id, { name: newName });
} else {
toast.error(result.message);
}
@@ -62,11 +70,11 @@ const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps)
const handleDelete = async (e: React.MouseEvent) => {
e.stopPropagation();
const confirm = prompt(t("confirmDelete", { name: folder.name }));
if (confirm === folder.name) {
const result = await actionDeleteFolderById(folder.id);
const confirm = prompt(t("confirmDelete", { name: deck.name }));
if (confirm === deck.name) {
const result = await actionDeleteDeck({ deckId: deck.id });
if (result.success) {
onDeleteFolder(folder.id);
onDeleteDeck(deck.id);
} else {
toast.error(result.message);
}
@@ -77,31 +85,31 @@ const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps)
<div
className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() => {
router.push(`/folders/${folder.id}`);
router.push(`/decks/${deck.id}`);
}}
>
<div className="flex items-center gap-4 flex-1">
<div className="shrink-0 text-primary-500">
<Fd size={24} />
<Layers size={24} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900 truncate">{folder.name}</h3>
<h3 className="font-semibold text-gray-900 truncate">{deck.name}</h3>
<span className="flex items-center gap-1 text-xs text-gray-400">
{folder.visibility === "PUBLIC" ? (
{deck.visibility === "PUBLIC" ? (
<Globe size={12} />
) : (
<Lock size={12} />
)}
{folder.visibility === "PUBLIC" ? t("public") : t("private")}
{deck.visibility === "PUBLIC" ? t("public") : t("private")}
</span>
</div>
<p className="text-sm text-gray-500 mt-0.5">
{t("folderInfo", {
id: folder.id,
name: folder.name,
totalPairs: folder.total,
{t("deckInfo", {
id: deck.id,
name: deck.name,
totalCards: deck.cardCount ?? 0,
})}
</p>
</div>
@@ -110,16 +118,16 @@ const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps)
<div className="flex items-center gap-1 ml-4">
<CircleButton
onClick={handleToggleVisibility}
title={folder.visibility === "PUBLIC" ? t("setPrivate") : t("setPublic")}
title={deck.visibility === "PUBLIC" ? t("setPrivate") : t("setPublic")}
>
{folder.visibility === "PUBLIC" ? (
{deck.visibility === "PUBLIC" ? (
<Lock size={18} />
) : (
<Globe size={18} />
)}
</CircleButton>
<CircleButton onClick={handleRename}>
<FolderPen size={18} />
<Pencil size={18} />
</CircleButton>
<CircleButton
onClick={handleDelete}
@@ -133,46 +141,49 @@ const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps)
);
};
interface FoldersClientProps {
interface DecksClientProps {
userId: string;
}
export function FoldersClient({ userId }: FoldersClientProps) {
const t = useTranslations("folders");
export function DecksClient({ userId }: DecksClientProps) {
const t = useTranslations("decks");
const router = useRouter();
const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>([]);
const [decks, setDecks] = useState<ActionOutputDeck[]>([]);
const [loading, setLoading] = useState(true);
const loadFolders = async () => {
const loadDecks = async () => {
setLoading(true);
const result = await actionGetFoldersWithTotalPairsByUserId(userId);
const result = await actionGetDecksByUserId(userId);
if (result.success && result.data) {
setFolders(result.data);
setDecks(result.data);
}
setLoading(false);
};
useEffect(() => {
loadFolders();
loadDecks();
}, [userId]);
const handleUpdateFolder = (folderId: number, updates: Partial<TSharedFolderWithTotalPairs>) => {
setFolders((prev) =>
prev.map((f) => (f.id === folderId ? { ...f, ...updates } : f))
const handleUpdateDeck = (deckId: number, updates: Partial<ActionOutputDeck>) => {
setDecks((prev) =>
prev.map((d) => (d.id === deckId ? { ...d, ...updates } : d))
);
};
const handleDeleteFolder = (folderId: number) => {
setFolders((prev) => prev.filter((f) => f.id !== folderId));
const handleDeleteDeck = (deckId: number) => {
setDecks((prev) => prev.filter((d) => d.id !== deckId));
};
const handleCreateFolder = async () => {
const folderName = prompt(t("enterFolderName"));
if (!folderName?.trim()) return;
const handleCreateDeck = async () => {
const deckName = prompt(t("enterDeckName"));
if (!deckName?.trim()) return;
const result = await actionCreateFolder(userId, folderName.trim());
if (result.success) {
loadFolders();
const result = await actionCreateDeck({ name: deckName.trim() });
if (result.success && result.deckId) {
const deckResult = await actionGetDeckById({ deckId: result.deckId });
if (deckResult.success && deckResult.data) {
setDecks((prev) => [...prev, deckResult.data!]);
}
} else {
toast.error(result.message);
}
@@ -182,33 +193,33 @@ export function FoldersClient({ userId }: FoldersClientProps) {
<PageLayout>
<PageHeader title={t("title")} subtitle={t("subtitle")} />
<div className="mb-4">
<LightButton onClick={handleCreateFolder}>
<FolderPlus size={18} />
{t("newFolder")}
<div className="mb-4 flex gap-2">
<LightButton onClick={handleCreateDeck}>
<Plus size={18} />
{t("newDeck")}
</LightButton>
</div>
<CardList>
{loading ? (
<div className="p-8 text-center">
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
<VStack align="center" className="p-8">
<Skeleton variant="circular" className="w-8 h-8 mb-3" />
<p className="text-sm text-gray-500">{t("loading")}</p>
</div>
) : folders.length === 0 ? (
</VStack>
) : decks.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
<Fd size={24} className="text-gray-400" />
<Layers size={24} className="text-gray-400" />
</div>
<p className="text-sm">{t("noFoldersYet")}</p>
<p className="text-sm">{t("noDecksYet")}</p>
</div>
) : (
folders.map((folder) => (
<FolderCard
key={folder.id}
folder={folder}
onUpdateFolder={handleUpdateFolder}
onDeleteFolder={handleDeleteFolder}
decks.map((deck) => (
<DeckCard
key={deck.id}
deck={deck}
onUpdateDeck={handleUpdateDeck}
onDeleteDeck={handleDeleteDeck}
/>
))
)}

View File

@@ -0,0 +1,285 @@
"use client";
import { LightButton, PrimaryButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { Select } from "@/design-system/base/select";
import { Textarea } from "@/design-system/base/textarea";
import { Modal } from "@/design-system/overlay/modal";
import { VStack, HStack } from "@/design-system/layout/stack";
import { Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { actionCreateCard } from "@/modules/card/card-action";
import type { CardType, CardMeaning } from "@/modules/card/card-action-dto";
import { toast } from "sonner";
const QUERY_LANGUAGE_LABELS = {
english: "english",
chinese: "chinese",
japanese: "japanese",
korean: "korean",
} as const;
const QUERY_LANGUAGES = [
{ value: "en", label: "english" as const },
{ value: "zh", label: "chinese" as const },
{ value: "ja", label: "japanese" as const },
{ value: "ko", label: "korean" as const },
] as const;
interface AddCardModalProps {
isOpen: boolean;
onClose: () => void;
deckId: number;
onAdded: () => void;
}
export function AddCardModal({
isOpen,
onClose,
deckId,
onAdded,
}: AddCardModalProps) {
const t = useTranslations("deck_id");
const [cardType, setCardType] = useState<CardType>("WORD");
const [word, setWord] = useState("");
const [ipa, setIpa] = useState("");
const [queryLang, setQueryLang] = useState("en");
const [customQueryLang, setCustomQueryLang] = useState("");
const [meanings, setMeanings] = useState<CardMeaning[]>([
{ partOfSpeech: null, definition: "", example: null }
]);
const [isSubmitting, setIsSubmitting] = useState(false);
const showIpa = cardType === "WORD" || cardType === "PHRASE";
const addMeaning = () => {
setMeanings([...meanings, { partOfSpeech: null, definition: "", example: null }]);
};
const removeMeaning = (index: number) => {
if (meanings.length > 1) {
setMeanings(meanings.filter((_, i) => i !== index));
}
};
const updateMeaning = (
index: number,
field: "partOfSpeech" | "definition" | "example",
value: string
) => {
const updated = [...meanings];
updated[index] = {
...updated[index],
[field]: value || null
};
setMeanings(updated);
};
const resetForm = () => {
setCardType("WORD");
setWord("");
setIpa("");
setQueryLang("en");
setCustomQueryLang("");
setMeanings([{ partOfSpeech: null, definition: "", example: null }]);
};
const handleAdd = async () => {
if (!word.trim()) {
toast.error(t("wordRequired"));
return;
}
const validMeanings = meanings.filter(m => m.definition?.trim());
if (validMeanings.length === 0) {
toast.error(t("definitionRequired"));
return;
}
setIsSubmitting(true);
const effectiveQueryLang = customQueryLang.trim() || queryLang;
try {
const cardResult = await actionCreateCard({
deckId,
word: word.trim(),
ipa: showIpa && ipa.trim() ? ipa.trim() : null,
queryLang: effectiveQueryLang,
cardType,
meanings: validMeanings.map(m => ({
partOfSpeech: cardType === "SENTENCE" ? null : (m.partOfSpeech?.trim() || null),
definition: m.definition!.trim(),
example: m.example?.trim() || null,
})),
});
if (!cardResult.success) {
throw new Error(cardResult.message || "Failed to create card");
}
resetForm();
onAdded();
onClose();
toast.success(t("cardAdded") || "Card added successfully");
} catch (error) {
toast.error(error instanceof Error ? error.message : "Unknown error");
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
resetForm();
onClose();
};
return (
<Modal open={isOpen} onClose={handleClose} size="md">
<Modal.Header>
<Modal.Title>{t("addNewCard")}</Modal.Title>
<Modal.CloseButton onClick={handleClose} />
</Modal.Header>
<Modal.Body className="space-y-4">
<HStack gap={3}>
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("cardType")}
</label>
<Select
value={cardType}
onChange={(e) => setCardType(e.target.value as CardType)}
className="w-full"
>
<option value="WORD">{t("wordCard")}</option>
<option value="PHRASE">{t("phraseCard")}</option>
<option value="SENTENCE">{t("sentenceCard")}</option>
</Select>
</div>
</HStack>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("queryLang")}
</label>
<HStack gap={2} className="flex-wrap">
{QUERY_LANGUAGES.map((lang) => (
<LightButton
key={lang.value}
selected={!customQueryLang && queryLang === lang.value}
onClick={() => {
setQueryLang(lang.value);
setCustomQueryLang("");
}}
size="sm"
>
{t(lang.label)}
</LightButton>
))}
<Input
value={customQueryLang}
onChange={(e) => setCustomQueryLang(e.target.value)}
placeholder={t("enterLanguageName")}
className="w-auto min-w-[100px] flex-1"
size="sm"
/>
</HStack>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{cardType === "SENTENCE" ? t("sentence") : t("word")} *
</label>
<Input
value={word}
onChange={(e) => setWord(e.target.value)}
className="w-full"
placeholder={cardType === "SENTENCE" ? t("sentencePlaceholder") : t("wordPlaceholder")}
/>
</div>
{showIpa && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("ipa")}
</label>
<Input
value={ipa}
onChange={(e) => setIpa(e.target.value)}
className="w-full"
placeholder={t("ipaPlaceholder")}
/>
</div>
)}
<div>
<HStack justify="between" className="mb-2">
<label className="block text-sm font-medium text-gray-700">
{t("meanings")} *
</label>
<button
onClick={addMeaning}
className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1"
>
<Plus size={14} />
{t("addMeaning")}
</button>
</HStack>
<VStack gap={4}>
{meanings.map((meaning, index) => (
<div key={index} className="p-3 bg-gray-50 rounded-lg space-y-2">
<HStack gap={2}>
{cardType !== "SENTENCE" && (
<div className="w-28 shrink-0">
<Input
value={meaning.partOfSpeech || ""}
onChange={(e) => updateMeaning(index, "partOfSpeech", e.target.value)}
placeholder={t("partOfSpeech")}
className="w-full"
/>
</div>
)}
<div className="flex-1">
<Input
value={meaning.definition || ""}
onChange={(e) => updateMeaning(index, "definition", e.target.value)}
placeholder={t("definition")}
className="w-full"
/>
</div>
{meanings.length > 1 && (
<button
onClick={() => removeMeaning(index)}
className="p-2 text-gray-400 hover:text-red-500"
>
<Trash2 size={16} />
</button>
)}
</HStack>
<Textarea
value={meaning.example || ""}
onChange={(e) => updateMeaning(index, "example", e.target.value)}
placeholder={t("examplePlaceholder")}
className="w-full min-h-[40px] text-sm"
/>
</div>
))}
</VStack>
</div>
</Modal.Body>
<Modal.Footer>
<LightButton onClick={handleClose}>
{t("cancel")}
</LightButton>
<PrimaryButton onClick={handleAdd} loading={isSubmitting}>
{isSubmitting ? t("adding") : t("add")}
</PrimaryButton>
</Modal.Footer>
</Modal>
);
}

View File

@@ -0,0 +1,133 @@
import { Trash2, Pencil } from "lucide-react";
import { useState } from "react";
import { CircleButton } from "@/design-system/base/button";
import { useTranslations } from "next-intl";
import type { ActionOutputCard, CardType } from "@/modules/card/card-action-dto";
import { toast } from "sonner";
import { actionDeleteCard } from "@/modules/card/card-action";
import { EditCardModal } from "./EditCardModal";
interface CardItemProps {
card: ActionOutputCard;
isReadOnly: boolean;
onDel: () => void;
onUpdated: () => void;
}
const CARD_TYPE_LABELS: Record<CardType, string> = {
WORD: "Word",
PHRASE: "Phrase",
SENTENCE: "Sentence",
};
export function CardItem({
card,
isReadOnly,
onDel,
onUpdated,
}: CardItemProps) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const t = useTranslations("deck_id");
const frontText = card.word;
const backText = card.meanings.map((m) =>
m.partOfSpeech ? `${m.partOfSpeech}: ${m.definition}` : m.definition
).join("; ");
const handleDelete = async () => {
try {
const result = await actionDeleteCard({ cardId: card.id });
if (result.success) {
toast.success(t("cardDeleted"));
onDel();
} else {
toast.error(result.message);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Unknown error");
}
setShowDeleteConfirm(false);
};
return (
<>
<div className="group border-b border-gray-100 hover:bg-gray-50 transition-colors">
<div className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className="px-2 py-1 bg-gray-100 rounded-md">
{t("card")}
</span>
<span className="px-2 py-1 bg-blue-50 text-blue-600 rounded-md">
{CARD_TYPE_LABELS[card.cardType]}
</span>
</div>
<div className="flex items-center gap-1 opacity-50 group-hover:opacity-100 transition-opacity">
{!isReadOnly && (
<>
<CircleButton
onClick={() => setShowEditModal(true)}
title={t("edit")}
className="text-gray-400 hover:text-blue-500 hover:bg-blue-50"
>
<Pencil size={14} />
</CircleButton>
<CircleButton
onClick={() => setShowDeleteConfirm(true)}
title={t("delete")}
className="text-gray-400 hover:text-red-500 hover:bg-red-50"
>
<Trash2 size={14} />
</CircleButton>
</>
)}
</div>
</div>
<div className="text-gray-900 grid grid-cols-2 gap-4 w-3/4">
<div>
{frontText.length > 30
? frontText.substring(0, 30) + "..."
: frontText}
</div>
<div>
{backText.length > 30
? backText.substring(0, 30) + "..."
: backText}
</div>
</div>
</div>
</div>
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-4 max-w-sm mx-4">
<p className="text-gray-700 mb-4">{t("deleteConfirm")}</p>
<div className="flex gap-2 justify-end">
<button
onClick={() => setShowDeleteConfirm(false)}
className="px-3 py-1 text-gray-600 hover:bg-gray-100 rounded"
>
{t("cancel")}
</button>
<button
onClick={handleDelete}
className="px-3 py-1 text-red-600 hover:bg-red-50 rounded"
>
{t("delete")}
</button>
</div>
</div>
</div>
)}
<EditCardModal
isOpen={showEditModal}
onClose={() => setShowEditModal(false)}
card={card}
onUpdated={onUpdated}
/>
</>
);
}

View File

@@ -0,0 +1,229 @@
"use client";
import { LightButton, PrimaryButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { Textarea } from "@/design-system/base/textarea";
import { Modal } from "@/design-system/overlay/modal";
import { VStack, HStack } from "@/design-system/layout/stack";
import { Plus, Trash2 } from "lucide-react";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { actionUpdateCard } from "@/modules/card/card-action";
import type { ActionOutputCard, CardMeaning } from "@/modules/card/card-action-dto";
import { toast } from "sonner";
interface EditCardModalProps {
isOpen: boolean;
onClose: () => void;
card: ActionOutputCard | null;
onUpdated: () => void;
}
export function EditCardModal({
isOpen,
onClose,
card,
onUpdated,
}: EditCardModalProps) {
const t = useTranslations("deck_id");
const [word, setWord] = useState("");
const [ipa, setIpa] = useState("");
const [meanings, setMeanings] = useState<CardMeaning[]>([
{ partOfSpeech: null, definition: "", example: null }
]);
const [isSubmitting, setIsSubmitting] = useState(false);
const showIpa = card?.cardType === "WORD" || card?.cardType === "PHRASE";
useEffect(() => {
if (card) {
setWord(card.word);
setIpa(card.ipa || "");
setMeanings(
card.meanings.length > 0
? card.meanings
: [{ partOfSpeech: null, definition: "", example: null }]
);
}
}, [card]);
const addMeaning = () => {
setMeanings([...meanings, { partOfSpeech: null, definition: "", example: null }]);
};
const removeMeaning = (index: number) => {
if (meanings.length > 1) {
setMeanings(meanings.filter((_, i) => i !== index));
}
};
const updateMeaning = (index: number, field: keyof CardMeaning, value: string) => {
const updated = [...meanings];
updated[index] = { ...updated[index], [field]: value || null };
setMeanings(updated);
};
const handleUpdate = async () => {
if (!card) return;
if (!word.trim()) {
toast.error(t("wordRequired"));
return;
}
const validMeanings = meanings.filter(m => m.definition?.trim());
if (validMeanings.length === 0) {
toast.error(t("definitionRequired"));
return;
}
setIsSubmitting(true);
try {
const result = await actionUpdateCard({
cardId: card.id,
word: word.trim(),
ipa: showIpa && ipa.trim() ? ipa.trim() : null,
meanings: validMeanings.map(m => ({
partOfSpeech: card.cardType === "SENTENCE" ? null : (m.partOfSpeech?.trim() || null),
definition: m.definition!.trim(),
example: m.example?.trim() || null,
})),
});
if (!result.success) {
throw new Error(result.message || "Failed to update card");
}
onUpdated();
onClose();
toast.success(t("cardUpdated") || "Card updated successfully");
} catch (error) {
toast.error(error instanceof Error ? error.message : "Unknown error");
} finally {
setIsSubmitting(false);
}
};
if (!card) return null;
const cardTypeLabel = card.cardType === "WORD"
? t("wordCard")
: card.cardType === "PHRASE"
? t("phraseCard")
: t("sentenceCard");
return (
<Modal open={isOpen} onClose={onClose} size="md">
<Modal.Header>
<Modal.Title>{t("updateCard")}</Modal.Title>
<Modal.CloseButton onClick={onClose} />
</Modal.Header>
<Modal.Body className="space-y-4">
<HStack gap={2} className="text-sm text-gray-500">
<span className="px-2 py-1 bg-gray-100 rounded-md">
{t("card")}
</span>
<span className="px-2 py-1 bg-blue-50 text-blue-600 rounded-md">
{cardTypeLabel}
</span>
<span className="px-2 py-1 bg-gray-100 rounded-md">
{card.queryLang}
</span>
</HStack>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{card.cardType === "SENTENCE" ? t("sentence") : t("word")} *
</label>
<Input
value={word}
onChange={(e) => setWord(e.target.value)}
className="w-full"
/>
</div>
{showIpa && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("ipa")}
</label>
<Input
value={ipa}
onChange={(e) => setIpa(e.target.value)}
className="w-full"
placeholder={t("ipaPlaceholder")}
/>
</div>
)}
<div>
<HStack justify="between" className="mb-2">
<label className="block text-sm font-medium text-gray-700">
{t("meanings")} *
</label>
<button
onClick={addMeaning}
className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1"
>
<Plus size={14} />
{t("addMeaning")}
</button>
</HStack>
<VStack gap={4}>
{meanings.map((meaning, index) => (
<div key={index} className="p-3 bg-gray-50 rounded-lg space-y-2">
<HStack gap={2}>
{card.cardType !== "SENTENCE" && (
<div className="w-28 shrink-0">
<Input
value={meaning.partOfSpeech || ""}
onChange={(e) => updateMeaning(index, "partOfSpeech", e.target.value)}
placeholder={t("partOfSpeech")}
className="w-full"
/>
</div>
)}
<div className="flex-1">
<Input
value={meaning.definition || ""}
onChange={(e) => updateMeaning(index, "definition", e.target.value)}
placeholder={t("definition")}
className="w-full"
/>
</div>
{meanings.length > 1 && (
<button
onClick={() => removeMeaning(index)}
className="p-2 text-gray-400 hover:text-red-500"
>
<Trash2 size={16} />
</button>
)}
</HStack>
<Textarea
value={meaning.example || ""}
onChange={(e) => updateMeaning(index, "example", e.target.value)}
placeholder={t("examplePlaceholder")}
className="w-full min-h-[40px] text-sm"
/>
</div>
))}
</VStack>
</div>
</Modal.Body>
<Modal.Footer>
<LightButton onClick={onClose}>
{t("cancel")}
</LightButton>
<PrimaryButton onClick={handleUpdate} loading={isSubmitting}>
{isSubmitting ? t("updating") : t("update")}
</PrimaryButton>
</Modal.Footer>
</Modal>
);
}

View File

@@ -0,0 +1,152 @@
"use client";
import { ArrowLeft, Plus } from "lucide-react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { CardItem } from "./CardItem";
import { useTranslations } from "next-intl";
import { PageLayout } from "@/components/ui/PageLayout";
import { PrimaryButton, CircleButton, LinkButton } from "@/design-system/base/button";
import { CardList } from "@/components/ui/CardList";
import { VStack } from "@/design-system/layout/stack";
import { Skeleton } from "@/design-system/feedback/skeleton";
import { actionGetCardsByDeckId, actionDeleteCard } from "@/modules/card/card-action";
import { actionGetDeckById } from "@/modules/deck/deck-action";
import type { ActionOutputCard } from "@/modules/card/card-action-dto";
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
import { toast } from "sonner";
import { AddCardModal } from "./AddCardModal";
export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boolean }) {
const [cards, setCards] = useState<ActionOutputCard[]>([]);
const [loading, setLoading] = useState(true);
const [openAddModal, setAddModal] = useState(false);
const [deckInfo, setDeckInfo] = useState<ActionOutputDeck | null>(null);
const router = useRouter();
const t = useTranslations("deck_id");
useEffect(() => {
const fetchCards = async () => {
setLoading(true);
try {
const [cardsResult, deckResult] = await Promise.all([
actionGetCardsByDeckId({ deckId }),
actionGetDeckById({ deckId }),
]);
if (!cardsResult.success || !cardsResult.data) {
throw new Error(cardsResult.message || "Failed to load cards");
}
setCards(cardsResult.data);
if (deckResult.success && deckResult.data) {
setDeckInfo(deckResult.data);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Unknown error");
} finally {
setLoading(false);
}
};
fetchCards();
}, [deckId]);
const refreshCards = async () => {
const result = await actionGetCardsByDeckId({ deckId });
if (result.success && result.data) {
setCards(result.data);
} else {
toast.error(result.message);
}
};
const handleDeleteCard = async (cardId: number) => {
try {
const result = await actionDeleteCard({ cardId });
if (result.success) {
toast.success(t("cardDeleted"));
await refreshCards();
} else {
toast.error(result.message);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Unknown error");
}
};
return (
<PageLayout>
<div className="mb-6">
<LinkButton
onClick={router.back}
className="flex items-center gap-2 mb-4"
>
<ArrowLeft size={16} />
<span className="text-sm">{t("back")}</span>
</LinkButton>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-1">
{deckInfo?.name || t("cards")}
</h1>
<p className="text-sm text-gray-500">
{t("itemsCount", { count: cards.length })}
</p>
</div>
<div className="flex items-center gap-2">
<PrimaryButton
onClick={() => {
router.push(`/decks/${deckId}/learn`);
}}
>
{t("memorize")}
</PrimaryButton>
{!isReadOnly && (
<CircleButton
onClick={() => {
setAddModal(true);
}}
>
<Plus size={18} className="text-gray-700" />
</CircleButton>
)}
</div>
</div>
</div>
<CardList>
{loading ? (
<VStack align="center" className="p-8">
<Skeleton variant="circular" className="w-8 h-8" />
<p className="text-sm text-gray-500">{t("loadingCards")}</p>
</VStack>
) : cards.length === 0 ? (
<div className="p-12 text-center">
<p className="text-sm text-gray-500 mb-2">{t("noCards")}</p>
</div>
) : (
<div className="divide-y divide-gray-100">
{cards.map((card) => (
<CardItem
key={card.id}
card={card}
isReadOnly={isReadOnly}
onDel={() => handleDeleteCard(card.id)}
onUpdated={refreshCards}
/>
))}
</div>
)}
</CardList>
<AddCardModal
isOpen={openAddModal}
onClose={() => setAddModal(false)}
deckId={deckId}
onAdded={refreshCards}
/>
</PageLayout>
);
}

View File

@@ -0,0 +1,468 @@
"use client";
import { useState, useEffect, useTransition, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import localFont from "next/font/local";
import { Layers, Check, RotateCcw, Volume2, Headphones, ChevronLeft, ChevronRight, Shuffle, List, Repeat, Infinity } from "lucide-react";
import { actionGetCardsByDeckId } from "@/modules/card/card-action";
import type { ActionOutputCard } from "@/modules/card/card-action-dto";
import { PageLayout } from "@/components/ui/PageLayout";
import { LightButton, CircleButton } from "@/design-system/base/button";
import { Progress } from "@/design-system/feedback/progress";
import { Skeleton } from "@/design-system/feedback/skeleton";
import { HStack, VStack } from "@/design-system/layout/stack";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { getTTSUrl, type TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
const myFont = localFont({
src: "../../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
});
type StudyMode = "order-limited" | "order-infinite" | "random-limited" | "random-infinite";
interface MemorizeProps {
deckId: number;
deckName: string;
}
const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
const t = useTranslations("memorize.review");
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [originalCards, setOriginalCards] = useState<ActionOutputCard[]>([]);
const [cards, setCards] = useState<ActionOutputCard[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [showAnswer, setShowAnswer] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isReversed, setIsReversed] = useState(false);
const [isDictation, setIsDictation] = useState(false);
const [studyMode, setStudyMode] = useState<StudyMode>("order-limited");
const { play, stop, load } = useAudioPlayer();
const audioUrlRef = useRef<string | null>(null);
const [isAudioLoading, setIsAudioLoading] = useState(false);
const shuffleCards = useCallback((cardArray: ActionOutputCard[]): ActionOutputCard[] => {
const shuffled = [...cardArray];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}, []);
useEffect(() => {
let ignore = false;
const loadCards = async () => {
setIsLoading(true);
setError(null);
startTransition(async () => {
const result = await actionGetCardsByDeckId({ deckId, limit: 100 });
if (!ignore) {
if (result.success && result.data) {
setOriginalCards(result.data);
setCards(result.data);
setCurrentIndex(0);
setShowAnswer(false);
setIsReversed(false);
setIsDictation(false);
} else {
setError(result.message);
}
setIsLoading(false);
}
});
};
loadCards();
return () => {
ignore = true;
};
}, [deckId]);
useEffect(() => {
if (studyMode.startsWith("random")) {
setCards(shuffleCards(originalCards));
} else {
setCards(originalCards);
}
setCurrentIndex(0);
setShowAnswer(false);
}, [studyMode, originalCards, shuffleCards]);
const getCurrentCard = (): ActionOutputCard | null => {
return cards[currentIndex] ?? null;
};
const getFrontText = (card: ActionOutputCard): string => {
if (isReversed) {
return card.meanings.map((m) =>
m.partOfSpeech ? `${m.partOfSpeech}: ${m.definition}` : m.definition
).join("; ");
}
return card.word;
};
const getBackContent = (card: ActionOutputCard): React.ReactNode => {
if (isReversed) {
return <span className="text-gray-900 text-xl md:text-2xl text-center">{card.word}</span>;
}
return (
<VStack align="stretch" gap={2} className="w-full max-w-lg">
{card.meanings.map((m, idx) => (
<div key={idx} className="flex gap-3 text-left">
{m.partOfSpeech && (
<span className="text-primary-600 text-sm font-medium min-w-[60px] shrink-0">
{m.partOfSpeech}
</span>
)}
<span className="text-gray-800">{m.definition}</span>
</div>
))}
</VStack>
);
};
const handleShowAnswer = useCallback(() => {
setShowAnswer(true);
}, []);
const isInfinite = studyMode.endsWith("infinite");
const handleNextCard = useCallback(() => {
if (isInfinite) {
if (currentIndex >= cards.length - 1) {
if (studyMode.startsWith("random")) {
setCards(shuffleCards(originalCards));
}
setCurrentIndex(0);
} else {
setCurrentIndex(currentIndex + 1);
}
} else {
if (currentIndex < cards.length - 1) {
setCurrentIndex(currentIndex + 1);
}
}
setShowAnswer(false);
setIsReversed(false);
setIsDictation(false);
cleanupAudio();
}, [currentIndex, cards.length, isInfinite, studyMode, originalCards, shuffleCards]);
const handlePrevCard = useCallback(() => {
if (isInfinite) {
if (currentIndex <= 0) {
setCurrentIndex(cards.length - 1);
} else {
setCurrentIndex(currentIndex - 1);
}
} else {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1);
}
}
setShowAnswer(false);
setIsReversed(false);
setIsDictation(false);
cleanupAudio();
}, [currentIndex, cards.length, isInfinite]);
const cleanupAudio = useCallback(() => {
if (audioUrlRef.current) {
URL.revokeObjectURL(audioUrlRef.current);
audioUrlRef.current = null;
}
stop();
}, [stop]);
const playTTS = useCallback(async (text: string) => {
if (isAudioLoading) return;
setIsAudioLoading(true);
try {
const hasChinese = /[\u4e00-\u9fff]/.test(text);
const hasJapanese = /[\u3040-\u309f\u30a0-\u30ff]/.test(text);
const hasKorean = /[\uac00-\ud7af]/.test(text);
let lang: TTS_SUPPORTED_LANGUAGES = "Auto";
if (hasChinese) lang = "Chinese";
else if (hasJapanese) lang = "Japanese";
else if (hasKorean) lang = "Korean";
else if (/^[a-zA-Z\s]/.test(text)) lang = "English";
const audioUrl = await getTTSUrl(text, lang);
if (audioUrl && audioUrl !== "error") {
audioUrlRef.current = audioUrl;
await load(audioUrl);
play();
}
} catch (e) {
console.error("TTS playback failed", e);
} finally {
setIsAudioLoading(false);
}
}, [isAudioLoading, load, play]);
const playCurrentCard = useCallback(() => {
const currentCard = getCurrentCard();
if (!currentCard) return;
const text = isReversed
? currentCard.meanings.map((m) => m.definition).join("; ")
: currentCard.word;
if (text) {
playTTS(text);
}
}, [isReversed, playTTS]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
if (!showAnswer) {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
handleShowAnswer();
}
} else {
if (e.key === "ArrowRight" || e.key === " " || e.key === "Enter") {
e.preventDefault();
handleNextCard();
} else if (e.key === "ArrowLeft") {
e.preventDefault();
handlePrevCard();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [showAnswer, handleShowAnswer, handleNextCard, handlePrevCard]);
if (isLoading) {
return (
<PageLayout>
<VStack align="center" className="py-12">
<Skeleton variant="circular" className="h-12 w-12 mb-4" />
<p className="text-gray-600">{t("loading")}</p>
</VStack>
</PageLayout>
);
}
if (error) {
return (
<PageLayout>
<VStack align="center" className="py-12">
<div className="text-red-600 mb-4 px-4 py-3 bg-red-50 border border-red-200 rounded-lg max-w-md">
{error}
</div>
<LightButton onClick={() => router.push("/decks")} className="px-4 py-2">
{t("backToDecks")}
</LightButton>
</VStack>
</PageLayout>
);
}
if (cards.length === 0) {
return (
<PageLayout>
<VStack align="center" className="py-12">
<div className="text-green-500 mb-4">
<Check className="w-16 h-16 mx-auto" />
</div>
<h2 className="text-2xl font-bold text-gray-800 mb-2">{t("allDone")}</h2>
<p className="text-gray-600 mb-6">{t("allDoneDesc")}</p>
<LightButton onClick={() => router.push("/decks")} className="px-4 py-2">
{t("backToDecks")}
</LightButton>
</VStack>
</PageLayout>
);
}
const currentCard = getCurrentCard()!;
const displayFront = getFrontText(currentCard);
const isFinished = !isInfinite && currentIndex === cards.length - 1 && showAnswer;
const studyModeOptions: { value: StudyMode; label: string; icon: React.ReactNode }[] = [
{ value: "order-limited", label: t("orderLimited"), icon: <List className="w-4 h-4" /> },
{ value: "order-infinite", label: t("orderInfinite"), icon: <Repeat className="w-4 h-4" /> },
{ value: "random-limited", label: t("randomLimited"), icon: <Shuffle className="w-4 h-4" /> },
{ value: "random-infinite", label: t("randomInfinite"), icon: <Infinity className="w-4 h-4" /> },
];
return (
<PageLayout>
<HStack justify="between" className="mb-4">
<HStack gap={2} className="text-gray-600">
<Layers className="w-5 h-5" />
<span className="font-medium">{deckName}</span>
</HStack>
{!isInfinite && (
<span className="text-sm text-gray-500">
{t("progress", { current: currentIndex + 1, total: cards.length })}
</span>
)}
</HStack>
{!isInfinite && (
<Progress
value={((currentIndex + 1) / cards.length) * 100}
showLabel={false}
animated={false}
className="mb-6"
/>
)}
<VStack gap={2} className="mb-4">
<HStack justify="center" gap={1} className="flex-wrap">
{studyModeOptions.map((option) => (
<LightButton
key={option.value}
onClick={() => setStudyMode(option.value)}
selected={studyMode === option.value}
leftIcon={option.icon}
size="sm"
>
{option.label}
</LightButton>
))}
</HStack>
<HStack justify="center" gap={2}>
<LightButton
onClick={() => {
setIsReversed(!isReversed);
setShowAnswer(false);
}}
selected={isReversed}
leftIcon={<RotateCcw className="w-4 h-4" />}
size="sm"
>
{t("reverse")}
</LightButton>
<LightButton
onClick={() => {
setIsDictation(!isDictation);
}}
selected={isDictation}
leftIcon={<Headphones className="w-4 h-4" />}
size="sm"
>
{t("dictation")}
</LightButton>
</HStack>
</VStack>
<div className={`bg-white border border-gray-200 rounded-xl shadow-sm mb-6 h-[50dvh] flex flex-col ${myFont.className}`}>
<div className="flex-1 overflow-y-auto">
{isDictation ? (
<>
<VStack align="center" justify="center" gap={4} className="p-8 min-h-[20dvh]">
{currentCard.ipa ? (
<div className="text-gray-700 text-2xl text-center font-mono">
{currentCard.ipa}
</div>
) : (
<div className="text-gray-400 text-lg">
{t("noIpa")}
</div>
)}
</VStack>
{showAnswer && (
<>
<div className="border-t border-gray-200" />
<VStack align="center" justify="center" className="p-8 min-h-[20dvh] bg-gray-50 rounded-b-xl">
<div className="text-gray-900 text-xl md:text-2xl text-center whitespace-pre-line">
{displayFront}
</div>
{getBackContent(currentCard)}
</VStack>
</>
)}
</>
) : (
<>
<HStack align="center" justify="center" className="p-8 min-h-[20dvh]">
<div className="text-gray-900 text-xl md:text-2xl text-center whitespace-pre-line">
{displayFront}
</div>
</HStack>
{showAnswer && (
<>
<div className="border-t border-gray-200" />
<VStack align="center" justify="center" className="p-8 min-h-[20dvh] bg-gray-50 rounded-b-xl">
{getBackContent(currentCard)}
</VStack>
</>
)}
</>
)}
</div>
</div>
<HStack justify="center">
{!showAnswer ? (
<LightButton
onClick={handleShowAnswer}
disabled={isPending}
className="px-8 py-3 text-lg rounded-full"
>
{t("showAnswer")}
<span className="ml-2 text-xs opacity-60">Space</span>
</LightButton>
) : isFinished ? (
<VStack align="center" gap={4}>
<div className="text-green-500">
<Check className="w-12 h-12" />
</div>
<p className="text-gray-600">{t("allDoneDesc")}</p>
<HStack gap={2}>
<LightButton onClick={() => router.push("/decks")} className="px-4 py-2">
{t("backToDecks")}
</LightButton>
<LightButton onClick={() => setCurrentIndex(0)} className="px-4 py-2">
{t("restart")}
</LightButton>
</HStack>
</VStack>
) : (
<HStack gap={4}>
<LightButton
onClick={handlePrevCard}
className="px-4 py-2"
>
<ChevronLeft className="w-5 h-5" />
</LightButton>
<span className="text-gray-500 text-sm">
{t("nextCard")}
<span className="ml-2 text-xs opacity-60">Space</span>
</span>
<LightButton
onClick={handleNextCard}
className="px-4 py-2"
>
<ChevronRight className="w-5 h-5" />
</LightButton>
</HStack>
)}
</HStack>
</PageLayout>
);
};
export { Memorize };

View File

@@ -0,0 +1,34 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { actionGetDeckById } from "@/modules/deck/deck-action";
import { Memorize } from "./Memorize";
export default async function LearnPage({
params,
}: {
params: Promise<{ deck_id: string }>;
}) {
const session = await auth.api.getSession({ headers: await headers() });
const { deck_id } = await params;
const deckId = Number(deck_id);
if (!deckId) {
redirect("/decks");
}
const deckInfo = (await actionGetDeckById({ deckId })).data;
if (!deckInfo) {
redirect("/decks");
}
const isOwner = session?.user?.id === deckInfo.userId;
const isPublic = deckInfo.visibility === "PUBLIC";
if (!isOwner && !isPublic) {
redirect("/decks");
}
return <Memorize deckId={deckId} deckName={deckInfo.name} />;
}

View File

@@ -0,0 +1,37 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { InDeck } from "./InDeck";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { actionGetDeckById } from "@/modules/deck/deck-action";
export default async function DecksPage({
params,
}: {
params: Promise<{ deck_id: number; }>;
}) {
const session = await auth.api.getSession({ headers: await headers() });
const { deck_id } = await params;
const t = await getTranslations("deck_id");
if (!deck_id) {
redirect("/decks");
}
const deckInfo = (await actionGetDeckById({ deckId: Number(deck_id) })).data;
if (!deckInfo) {
redirect("/decks");
}
const isOwner = session?.user?.id === deckInfo.userId;
const isPublic = deckInfo.visibility === "PUBLIC";
if (!isOwner && !isPublic) {
redirect("/decks");
}
const isReadOnly = !isOwner;
return <InDeck deckId={Number(deck_id)} isReadOnly={isReadOnly} />;
}

View File

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

View File

@@ -1,99 +0,0 @@
import { LightButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { LocaleSelector } from "@/components/ui/LocaleSelector";
import { X } from "lucide-react";
import { useRef, useState } from "react";
import { useTranslations } from "next-intl";
interface AddTextPairModalProps {
isOpen: boolean;
onClose: () => void;
onAdd: (
text1: string,
text2: string,
language1: string,
language2: string,
) => void;
}
export function AddTextPairModal({
isOpen,
onClose,
onAdd,
}: AddTextPairModalProps) {
const t = useTranslations("folder_id");
const input1Ref = useRef<HTMLInputElement>(null);
const input2Ref = useRef<HTMLInputElement>(null);
const [language1, setLanguage1] = useState("english");
const [language2, setLanguage2] = useState("chinese");
if (!isOpen) return null;
const handleAdd = () => {
if (
!input1Ref.current?.value ||
!input2Ref.current?.value ||
!language1 ||
!language2
)
return;
const text1 = input1Ref.current.value;
const text2 = input2Ref.current.value;
if (
typeof text1 === "string" &&
typeof text2 === "string" &&
typeof language1 === "string" &&
typeof language2 === "string" &&
text1.trim() !== "" &&
text2.trim() !== "" &&
language1.trim() !== "" &&
language2.trim() !== ""
) {
onAdd(text1, text2, language1, language2);
input1Ref.current.value = "";
input2Ref.current.value = "";
}
};
return (
<div
className="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAdd();
}
}}
>
<div className="bg-white rounded-md p-6 w-full max-w-md mx-4">
<div className="flex">
<h2 className="flex-1 text-xl font-light mb-4 text-center">
{t("addNewTextPair")}
</h2>
<X onClick={onClose} className="hover:cursor-pointer"></X>
</div>
<div>
<div>
{t("text1")}
<Input ref={input1Ref} className="w-full"></Input>
</div>
<div>
{t("text2")}
<Input ref={input2Ref} className="w-full"></Input>
</div>
<div>
{t("language1")}
<LocaleSelector value={language1} onChange={setLanguage1} />
</div>
<div>
{t("language2")}
<LocaleSelector value={language2} onChange={setLanguage2} />
</div>
</div>
<LightButton onClick={handleAdd}>{t("add")}</LightButton>
</div>
</div>
);
}

View File

@@ -1,155 +0,0 @@
"use client";
import { ArrowLeft, Plus } from "lucide-react";
import { useEffect, useState } from "react";
import { redirect, useRouter } from "next/navigation";
import { AddTextPairModal } from "./AddTextPairModal";
import { TextPairCard } from "./TextPairCard";
import { useTranslations } from "next-intl";
import { PageLayout } from "@/components/ui/PageLayout";
import { PrimaryButton, CircleButton, LinkButton } from "@/design-system/base/button";
import { CardList } from "@/components/ui/CardList";
import { actionCreatePair, actionDeletePairById, actionGetPairsByFolderId } from "@/modules/folder/folder-aciton";
import { TSharedPair } from "@/shared/folder-type";
import { toast } from "sonner";
export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnly: boolean; }) {
const [textPairs, setTextPairs] = useState<TSharedPair[]>([]);
const [loading, setLoading] = useState(true);
const [openAddModal, setAddModal] = useState(false);
const router = useRouter();
const t = useTranslations("folder_id");
useEffect(() => {
const fetchTextPairs = async () => {
setLoading(true);
await actionGetPairsByFolderId(folderId)
.then(result => {
if (!result.success || !result.data) throw result.message;
return result.data;
}).then(setTextPairs)
.catch(toast.error)
.finally(() => {
setLoading(false);
});
};
fetchTextPairs();
}, [folderId]);
const refreshTextPairs = async () => {
await actionGetPairsByFolderId(folderId)
.then(result => {
if (!result.success || !result.data) throw result.message;
return result.data;
}).then(setTextPairs)
.catch(toast.error);
};
return (
<PageLayout>
{/* 顶部导航和标题栏 */}
<div className="mb-6">
{/* 返回按钮 */}
<LinkButton
onClick={router.back}
className="flex items-center gap-2 mb-4"
>
<ArrowLeft size={16} />
<span className="text-sm">{t("back")}</span>
</LinkButton>
{/* 页面标题和操作按钮 */}
<div className="flex items-center justify-between">
{/* 标题区域 */}
<div>
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-1">
{t("textPairs")}
</h1>
<p className="text-sm text-gray-500">
{t("itemsCount", { count: textPairs.length })}
</p>
</div>
{/* 操作按钮区域 */}
<div className="flex items-center gap-2">
<PrimaryButton
onClick={() => {
redirect(`/memorize?folder_id=${folderId}`);
}}
>
{t("memorize")}
</PrimaryButton>
{!isReadOnly && (
<CircleButton
onClick={() => {
setAddModal(true);
}}
>
<Plus size={18} className="text-gray-700" />
</CircleButton>
)}
</div>
</div>
</div>
{/* 文本对列表 */}
<CardList>
{loading ? (
// 加载状态
<div className="p-8 text-center">
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
<p className="text-sm text-gray-500">{t("loadingTextPairs")}</p>
</div>
) : textPairs.length === 0 ? (
// 空状态
<div className="p-12 text-center">
<p className="text-sm text-gray-500 mb-2">{t("noTextPairs")}</p>
</div>
) : (
// 文本对卡片列表
<div className="divide-y divide-gray-100">
{textPairs
.toSorted((a, b) => a.id - b.id)
.map((textPair) => (
<TextPairCard
key={textPair.id}
textPair={textPair}
isReadOnly={isReadOnly}
onDel={() => {
actionDeletePairById(textPair.id)
.then(result => {
if (!result.success) throw result.message;
}).then(refreshTextPairs)
.catch(toast.error);
}}
refreshTextPairs={refreshTextPairs}
/>
))}
</div>
)}
</CardList>
{/* 添加文本对模态框 */}
<AddTextPairModal
isOpen={openAddModal}
onClose={() => setAddModal(false)}
onAdd={async (
text1: string,
text2: string,
language1: string,
language2: string,
) => {
await actionCreatePair({
text1: text1,
text2: text2,
language1: language1,
language2: language2,
folderId: folderId,
});
refreshTextPairs();
}}
/>
</PageLayout>
);
};

View File

@@ -1,86 +0,0 @@
import { Edit, Trash2 } from "lucide-react";
import { useState } from "react";
import { CircleButton } from "@/design-system/base/button";
import { UpdateTextPairModal } from "./UpdateTextPairModal";
import { useTranslations } from "next-intl";
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 {
textPair: TSharedPair;
isReadOnly: boolean;
onDel: () => void;
refreshTextPairs: () => void;
}
export function TextPairCard({
textPair,
isReadOnly,
onDel,
refreshTextPairs,
}: TextPairCardProps) {
const [openUpdateModal, setOpenUpdateModal] = useState(false);
const t = useTranslations("folder_id");
return (
<div className="group border-b border-gray-100 hover:bg-gray-50 transition-colors">
<div className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className="px-2 py-1 bg-gray-100 rounded-md">
{textPair.language1.toUpperCase()}
</span>
<span></span>
<span className="px-2 py-1 bg-gray-100 rounded-md">
{textPair.language2.toUpperCase()}
</span>
</div>
<div className="flex items-center gap-1 opacity-50 group-hover:opacity-100 transition-opacity">
{!isReadOnly && (
<>
<CircleButton
onClick={() => setOpenUpdateModal(true)}
title={t("edit")}
className="text-gray-400 hover:text-gray-600"
>
<Edit size={14} />
</CircleButton>
<CircleButton
onClick={onDel}
title={t("delete")}
className="text-gray-400 hover:text-red-500 hover:bg-red-50"
>
<Trash2 size={14} />
</CircleButton>
</>
)}
</div>
</div>
<div className="text-gray-900 grid grid-cols-2 gap-4 w-3/4">
<div>
{textPair.text1.length > 30
? textPair.text1.substring(0, 30) + "..."
: textPair.text1}
</div>
<div>
{textPair.text2.length > 30
? textPair.text2.substring(0, 30) + "..."
: textPair.text2}
</div>
</div>
</div>
<UpdateTextPairModal
isOpen={openUpdateModal}
onClose={() => setOpenUpdateModal(false)}
onUpdate={async (id: number, data: ActionInputUpdatePairById) => {
await actionUpdatePairById(id, data).then(result => result.success ? toast.success(result.message) : toast.error(result.message));
setOpenUpdateModal(false);
refreshTextPairs();
}}
textPair={textPair}
/>
</div>
);
}

View File

@@ -1,103 +0,0 @@
import { LightButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { LocaleSelector } from "@/components/ui/LocaleSelector";
import { X } from "lucide-react";
import { useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { TSharedPair } from "@/shared/folder-type";
import { ActionInputUpdatePairById } from "@/modules/folder/folder-action-dto";
interface UpdateTextPairModalProps {
isOpen: boolean;
onClose: () => void;
textPair: TSharedPair;
onUpdate: (id: number, tp: ActionInputUpdatePairById) => void;
}
export function UpdateTextPairModal({
isOpen,
onClose,
onUpdate,
textPair,
}: UpdateTextPairModalProps) {
const t = useTranslations("folder_id");
const input1Ref = useRef<HTMLInputElement>(null);
const input2Ref = useRef<HTMLInputElement>(null);
const [language1, setLanguage1] = useState(textPair.language1);
const [language2, setLanguage2] = useState(textPair.language2);
if (!isOpen) return null;
const handleUpdate = () => {
if (
!input1Ref.current?.value ||
!input2Ref.current?.value ||
!language1 ||
!language2
)
return;
const text1 = input1Ref.current.value;
const text2 = input2Ref.current.value;
if (
typeof text1 === "string" &&
typeof text2 === "string" &&
typeof language1 === "string" &&
typeof language2 === "string" &&
text1.trim() !== "" &&
text2.trim() !== "" &&
language1.trim() !== "" &&
language2.trim() !== ""
) {
onUpdate(textPair.id, { text1, text2, language1, language2 });
}
};
return (
<div
className="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleUpdate();
}
}}
>
<div className="bg-white rounded-md p-6 w-full max-w-md mx-4">
<div className="flex">
<h2 className="flex-1 text-xl font-light mb-4 text-center">
{t("updateTextPair")}
</h2>
<X onClick={onClose} className="hover:cursor-pointer"></X>
</div>
<div>
<div>
{t("text1")}
<Input
defaultValue={textPair.text1}
ref={input1Ref}
className="w-full"
></Input>
</div>
<div>
{t("text2")}
<Input
defaultValue={textPair.text2}
ref={input2Ref}
className="w-full"
></Input>
</div>
<div>
{t("language1")}
<LocaleSelector value={language1} onChange={setLanguage1} />
</div>
<div>
{t("language2")}
<LocaleSelector value={language2} onChange={setLanguage2} />
</div>
</div>
<LightButton onClick={handleUpdate}>{t("update")}</LightButton>
</div>
</div>
);
}

View File

@@ -1,37 +0,0 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { InFolder } from "./InFolder";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { actionGetFolderVisibility } from "@/modules/folder/folder-aciton";
export default async function FoldersPage({
params,
}: {
params: Promise<{ folder_id: number; }>;
}) {
const session = await auth.api.getSession({ headers: await headers() });
const { folder_id } = await params;
const t = await getTranslations("folder_id");
if (!folder_id) {
redirect("/folders");
}
const folderInfo = (await actionGetFolderVisibility(Number(folder_id))).data;
if (!folderInfo) {
redirect("/folders");
}
const isOwner = session?.user?.id === folderInfo.userId;
const isPublic = folderInfo.visibility === "PUBLIC";
if (!isOwner && !isPublic) {
redirect("/folders");
}
const isReadOnly = !isOwner;
return <InFolder folderId={Number(folder_id)} isReadOnly={isReadOnly} />;
}

View File

@@ -5,18 +5,17 @@
* 使用 @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-primary-50: var(--primary-50);
--color-primary-100: var(--primary-100);
--color-primary-200: var(--primary-200);
--color-primary-300: var(--primary-300);
--color-primary-400: var(--primary-400);
--color-primary-500: var(--primary-500);
--color-primary-600: var(--primary-600);
--color-primary-700: var(--primary-700);
--color-primary-800: var(--primary-800);
--color-primary-900: var(--primary-900);
--color-primary-950: var(--primary-950);
/* 中性色 */
--color-gray-50: #f9fafb;
@@ -100,6 +99,19 @@
* 定义全局 CSS 变量用于主题切换和动态样式
*/
:root {
/* 主题色 - 默认 Mist */
--primary-50: #f7f8fa;
--primary-100: #eef1f5;
--primary-200: #dce2eb;
--primary-300: #c4cdd9;
--primary-400: #a3b0c1;
--primary-500: #8594a8;
--primary-600: #6b7a8d;
--primary-700: #596474;
--primary-800: #4b5360;
--primary-900: #414850;
--primary-950: #22262b;
/* 基础颜色 */
--background: #ffffff;
--foreground: #111827;
@@ -114,7 +126,7 @@
/* 边框 */
--border: #d1d5db;
--border-secondary: #e5e7eb;
--border-focus: #35786f;
--border-focus: #8594a8;
/* 圆角 - 更小的圆角 */
--radius-xs: 0.125rem;
@@ -132,7 +144,7 @@
--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);
--shadow-primary: 0 4px 14px 0 rgba(133, 148, 168, 0.39);
/* 间距 */
--spacing-xs: 0.25rem;
@@ -165,7 +177,7 @@ body {
height: 100%;
margin: 0;
padding: 0;
background: var(--background);
background: var(--primary-50);
color: var(--foreground);
font-family: var(--font-geist-sans), -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
font-size: 1rem;

View File

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

View File

@@ -73,7 +73,7 @@ export default async function HomePage() {
color="#dd7486"
></LinkArea>
<LinkArea
href="/memorize"
href="/decks"
name={t("memorize.name")}
description={t("memorize.description")}
color="#cc9988"

57
src/app/settings/page.tsx Normal file
View File

@@ -0,0 +1,57 @@
"use client";
import { useTheme } from "@/components/theme-provider";
import { useTranslations } from "next-intl";
import { cn } from "@/utils/cn";
export default function SettingsPage() {
const t = useTranslations("settings");
const { currentTheme, setTheme, availableThemes } = useTheme();
return (
<div className="min-h-[calc(100vh-64px)] bg-white p-4 md:p-8">
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">
{t("title")}
</h1>
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-gray-800 mb-3">
{t("themeColor")}
</h2>
<p className="text-sm text-gray-600 mb-4">
{t("themeColorDescription")}
</p>
<div className="grid grid-cols-4 sm:grid-cols-8 gap-3">
{availableThemes.map((theme) => (
<button
key={theme.id}
onClick={() => setTheme(theme.id)}
className={cn(
"group relative flex flex-col items-center gap-2 p-2 rounded-lg transition-all",
currentTheme === theme.id
? "ring-2 ring-offset-2"
: "hover:bg-gray-50"
)}
style={{
["--tw-ring-color" as string]: theme.colors[500],
}}
>
<div
className="w-8 h-8 rounded-full shadow-md ring-1 ring-black/10"
style={{ backgroundColor: theme.colors[500] }}
/>
<span className="text-xs text-gray-600 group-hover:text-gray-900">
{theme.name}
</span>
</button>
))}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,21 +1,89 @@
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { nextCookies } from "better-auth/next-js";
import { prisma } from "./lib/db";
import { username } from "better-auth/plugins";
import { createAuthMiddleware, APIError } from "better-auth/api";
import { prisma } from "./lib/db";
import { createLogger } from "./lib/logger";
const log = createLogger("auth");
import {
sendEmail,
generateVerificationEmailHtml,
generateResetPasswordEmailHtml,
} from "./lib/email";
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "postgresql"
provider: "postgresql",
}),
emailAndPassword: {
enabled: true
enabled: true,
requireEmailVerification: true,
sendResetPassword: async ({ user, url }) => {
const result = await sendEmail({
to: user.email,
subject: "重置您的密码 - Learn Languages",
html: generateResetPasswordEmailHtml(url, user.name || "用户"),
});
if (!result.success) {
log.error("Failed to send reset password email", { error: result.error });
}
},
},
emailVerification: {
sendOnSignUp: true,
sendOnSignIn: true,
sendVerificationEmail: async ({ user, url }) => {
const result = await sendEmail({
to: user.email,
subject: "验证您的邮箱 - Learn Languages",
html: generateVerificationEmailHtml(url, user.name || "用户"),
});
if (!result.success) {
log.error("Failed to send verification email", { error: result.error });
}
},
},
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID as string,
clientSecret: process.env.GITHUB_CLIENT_SECRET as string
clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
},
},
plugins: [nextCookies(), username()]
plugins: [nextCookies(), username()],
hooks: {
before: createAuthMiddleware(async (ctx) => {
if (ctx.path === "/sign-up/email" || ctx.path === "/update-user") {
const body = ctx.body as { username?: string };
if (!body.username || body.username.trim() === "") {
throw new APIError("BAD_REQUEST", {
message: "Username is required",
});
}
}
if (ctx.path === "/sign-in/username") {
const body = ctx.body as { username?: string };
if (body.username) {
const user = await prisma.user.findFirst({
where: {
OR: [
{ username: body.username },
{ email: body.username },
],
},
select: { emailVerified: true },
});
if (user && !user.emailVerified) {
throw new APIError("FORBIDDEN", {
message: "Please verify your email address before signing in",
});
}
}
}
}),
},
});

View File

@@ -0,0 +1,47 @@
"use client";
import { useState, useTransition } from "react";
import { PrimaryButton, LightButton } from "@/design-system/base/button";
import { actionToggleFollow } from "@/modules/follow/follow-action";
import { toast } from "sonner";
interface FollowButtonProps {
targetUserId: string;
initialIsFollowing: boolean;
onFollowChange?: (isFollowing: boolean, followersCount: number) => void;
}
export function FollowButton({
targetUserId,
initialIsFollowing,
onFollowChange,
}: FollowButtonProps) {
const [isFollowing, setIsFollowing] = useState(initialIsFollowing);
const [isPending, startTransition] = useTransition();
const handleToggleFollow = () => {
startTransition(async () => {
const result = await actionToggleFollow({ targetUserId });
if (result.success && result.data) {
setIsFollowing(result.data.isFollowing);
onFollowChange?.(result.data.isFollowing, result.data.followersCount);
} else {
toast.error(result.message || "Failed to update follow status");
}
});
};
if (isFollowing) {
return (
<LightButton onClick={handleToggleFollow} disabled={isPending}>
{isPending ? "..." : "Following"}
</LightButton>
);
}
return (
<PrimaryButton onClick={handleToggleFollow} disabled={isPending}>
{isPending ? "..." : "Follow"}
</PrimaryButton>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import { useState } from "react";
import { FollowButton } from "./FollowButton";
interface FollowStatsProps {
userId: string;
initialFollowersCount: number;
initialFollowingCount: number;
initialIsFollowing: boolean;
currentUserId?: string;
isOwnProfile: boolean;
username: string;
}
export function FollowStats({
userId,
initialFollowersCount,
initialFollowingCount,
initialIsFollowing,
currentUserId,
isOwnProfile,
username,
}: FollowStatsProps) {
const [followersCount, setFollowersCount] = useState(initialFollowersCount);
const handleFollowChange = (isFollowing: boolean, count: number) => {
setFollowersCount(count);
};
return (
<div className="flex items-center gap-4">
<a
href={`/users/${username}/followers`}
className="text-sm text-gray-600 hover:text-primary-500 transition-colors"
>
<span className="font-semibold text-gray-900">{followersCount}</span> followers
</a>
<a
href={`/users/${username}/following`}
className="text-sm text-gray-600 hover:text-primary-500 transition-colors"
>
<span className="font-semibold text-gray-900">{initialFollowingCount}</span> following
</a>
{currentUserId && !isOwnProfile && (
<FollowButton
targetUserId={userId}
initialIsFollowing={initialIsFollowing}
onFollowChange={handleFollowChange}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,68 @@
import Image from "next/image";
import Link from "next/link";
interface UserItem {
id: string;
username: string | null;
displayUsername: string | null;
image: string | null;
bio: string | null;
}
interface UserListProps {
users: UserItem[];
emptyMessage: string;
}
export function UserList({ users, emptyMessage }: UserListProps) {
if (users.length === 0) {
return (
<div className="text-center py-12 text-gray-500">
{emptyMessage}
</div>
);
}
return (
<div className="space-y-4">
{users.map((user) => (
<Link
key={user.id}
href={`/users/${user.username || user.id}`}
className="flex items-center gap-4 p-4 bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow"
>
{user.image ? (
<div className="relative w-12 h-12 rounded-full overflow-hidden flex-shrink-0">
<Image
src={user.image}
alt={user.displayUsername || user.username || "User"}
fill
className="object-cover"
unoptimized
/>
</div>
) : (
<div className="w-12 h-12 rounded-full bg-primary-500 flex items-center justify-center flex-shrink-0">
<span className="text-lg font-bold text-white">
{(user.displayUsername || user.username || "U")[0].toUpperCase()}
</span>
</div>
)}
<div className="flex-1 min-w-0">
<div className="font-semibold text-gray-900 truncate">
{user.displayUsername || user.username || "Anonymous"}
</div>
{user.username && (
<div className="text-sm text-gray-500">@{user.username}</div>
)}
{user.bio && (
<div className="text-sm text-gray-600 truncate mt-1">
{user.bio}
</div>
)}
</div>
</Link>
))}
</div>
);
}

View File

@@ -1,81 +1,104 @@
"use client";
import { GhostLightButton } from "@/design-system/base/button";
import { useState } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import { Languages } from "lucide-react";
import { cn } from "@/utils/cn";
const languages = [
{ code: "en-US", label: "English" },
{ code: "zh-CN", label: "中文" },
{ code: "ja-JP", label: "日本語" },
{ code: "ko-KR", label: "한국어" },
{ code: "de-DE", label: "Deutsch" },
{ code: "fr-FR", label: "Français" },
{ code: "it-IT", label: "Italiano" },
{ code: "ug-CN", label: "ئۇيغۇرچە" },
];
export function LanguageSettings() {
const [showLanguageMenu, setShowLanguageMenu] = useState(false);
const handleLanguageClick = () => {
setShowLanguageMenu((prev) => !prev);
const [isOpen, setIsOpen] = useState(false);
const [pendingLocale, setPendingLocale] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
const setLocale = async (locale: string) => {
document.cookie = `locale=${locale}`;
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsOpen(false);
};
if (isOpen) {
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}
}, [isOpen]);
useEffect(() => {
if (pendingLocale) {
document.cookie = `locale=${pendingLocale}; path=/`;
window.location.reload();
};
}
}, [pendingLocale]);
const setLocale = useCallback((locale: string) => {
setPendingLocale(locale);
}, []);
return (
<>
<GhostLightButton
size="md"
onClick={handleLanguageClick}
<div className="relative" ref={menuRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-center p-2 rounded-md text-white hover:bg-white/10 transition-colors"
aria-label="切换语言"
aria-expanded={isOpen}
>
<Languages size={20} />
</GhostLightButton>
<div className="relative">
{showLanguageMenu && (
<div>
<div className="absolute top-10 right-0 rounded-md shadow-md flex flex-col gap-2">
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("en-US")}
>
English
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("zh-CN")}
>
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("ja-JP")}
>
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("ko-KR")}
>
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("de-DE")}
>
Deutsch
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("fr-FR")}
>
Français
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("it-IT")}
>
Italiano
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("ug-CN")}
>
ئۇيغۇرچە
</GhostLightButton>
</div>
</div>
</button>
<div
className={cn(
"absolute right-0 top-full mt-2 w-40 rounded-lg bg-white shadow-lg ring-1 ring-black/5 overflow-hidden transition-all duration-200 origin-top-right z-50",
isOpen
? "opacity-100 scale-100"
: "opacity-0 scale-95 pointer-events-none"
)}
</div></>
role="menu"
>
<div className="py-1">
{languages.map((lang) => (
<button
key={lang.code}
onClick={() => setLocale(lang.code)}
className="w-full flex items-center px-4 py-2.5 text-gray-700 hover:bg-gray-50 hover:text-gray-900 transition-colors text-left"
role="menuitem"
>
{lang.label}
</button>
))}
</div>
</div>
{isOpen && (
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
)}
</div>
);
}

View File

@@ -0,0 +1,92 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Menu, X } from "lucide-react";
import { cn } from "@/utils/cn";
import type { NavigationItem } from "./Navbar";
interface MobileMenuProps {
items: NavigationItem[];
}
export function MobileMenu({ items }: MobileMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
document.body.style.overflow = "hidden";
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.body.style.overflow = "";
};
}, [isOpen]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsOpen(false);
};
if (isOpen) {
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}
}, [isOpen]);
return (
<div className="relative" ref={menuRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-center p-2 rounded-md text-white hover:bg-white/10 transition-colors"
aria-label={isOpen ? "关闭菜单" : "打开菜单"}
aria-expanded={isOpen}
>
{isOpen ? <X size={24} /> : <Menu size={24} />}
</button>
<div
className={cn(
"absolute right-0 top-full mt-2 w-56 rounded-lg bg-white shadow-lg ring-1 ring-black/5 overflow-hidden transition-all duration-200 origin-top-right z-50",
isOpen
? "opacity-100 scale-100"
: "opacity-0 scale-95 pointer-events-none"
)}
role="menu"
>
<div className="py-1">
{items.map((item, index) => (
<a
key={index}
href={item.href}
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-gray-50 hover:text-gray-900 transition-colors"
role="menuitem"
onClick={() => setIsOpen(false)}
target={item.external ? "_blank" : undefined}
rel={item.external ? "noopener noreferrer" : undefined}
>
{item.icon && <span className="shrink-0 text-gray-500">{item.icon}</span>}
<span>{item.label}</span>
</a>
))}
</div>
</div>
{isOpen && (
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
)}
</div>
);
}

View File

@@ -1,11 +1,18 @@
import Image from "next/image";
import { IMAGES } from "@/config/images";
import { Compass, Folder, Heart, Home, User } from "lucide-react";
import { Compass, Folder, Heart, Home, Settings, User, Github } from "lucide-react";
import { LanguageSettings } from "./LanguageSettings";
import { MobileMenu } from "./MobileMenu";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { getTranslations } from "next-intl/server";
import { GhostLightButton } from "@/design-system/base/button";
import type { ReactNode } from "react";
export interface NavigationItem {
label: string;
href: string;
icon?: ReactNode;
external?: boolean;
}
export async function Navbar() {
const t = await getTranslations("navbar");
@@ -13,49 +20,38 @@ export async function Navbar() {
headers: await headers()
});
const mobileMenuItems: NavigationItem[] = [
{ label: t("folders"), href: "/decks", icon: <Folder size={18} /> },
{ label: t("explore"), href: "/explore", icon: <Compass size={18} /> },
...(session ? [{ label: t("favorites"), href: "/favorites", icon: <Heart size={18} /> }] : []),
{ label: t("sourceCode"), href: "https://github.com/GoddoNebianU/learn-languages", icon: <Github size={18} />, external: true },
{ label: t("settings"), href: "/settings", icon: <Settings size={18} /> },
...(session
? [{ label: t("profile"), href: "/profile", icon: <User size={18} /> }]
: [{ label: t("sign_in"), href: "/login", icon: <User size={18} /> }]
),
];
return (
<div className="flex justify-between items-center w-full h-16 px-4 md:px-8 bg-primary-500 text-white">
<GhostLightButton href="/" className="border-b hidden! md:block!" size="md">
{t("title")}
</GhostLightButton>
<GhostLightButton className="block! md:hidden!" size="md" href={"/"}>
<GhostLightButton className="block! md:hidden!" size="md" href="/">
<Home size={20} />
</GhostLightButton>
<div className="flex gap-0.5 justify-center items-center">
<LanguageSettings />
<GhostLightButton
className="md:hidden! block!"
size="md"
href="https://github.com/GoddoNebianU/learn-languages"
>
<Image
src={IMAGES.github_mark_white}
alt="GitHub"
width={20}
height={20}
/>
</GhostLightButton>
<GhostLightButton href="/folders" className="md:block! hidden!" size="md">
<GhostLightButton href="/decks" className="md:block! hidden!" size="md">
{t("folders")}
</GhostLightButton>
<GhostLightButton href="/folders" className="md:hidden! block!" size="md">
<Folder size={20} />
</GhostLightButton>
<GhostLightButton href="/explore" className="md:block! hidden!" size="md">
{t("explore")}
</GhostLightButton>
<GhostLightButton href="/explore" className="md:hidden! block!" size="md">
<Compass size={20} />
</GhostLightButton>
{session && (
<>
<GhostLightButton href="/favorites" className="md:block! hidden!" size="md">
{t("favorites")}
</GhostLightButton>
<GhostLightButton href="/favorites" className="md:hidden! block!" size="md">
<Heart size={20} />
</GhostLightButton>
</>
)}
<GhostLightButton
className="hidden! md:block!"
@@ -64,23 +60,21 @@ export async function Navbar() {
>
{t("sourceCode")}
</GhostLightButton>
{
(() => {
return session &&
<>
<GhostLightButton href="/profile" className="hidden! md:block!" size="md">{t("profile")}</GhostLightButton>
<GhostLightButton href="/profile" className="md:hidden! block!" size="md">
<User size={20} />
<GhostLightButton href="/settings" className="hidden! md:block!" size="md">
{t("settings")}
</GhostLightButton>
</>
|| <>
<GhostLightButton href="/login" className="hidden! md:block!" size="md">{t("sign_in")}</GhostLightButton>
<GhostLightButton href="/login" className="md:hidden! block!" size="md">
<User size={20} />
{session ? (
<GhostLightButton href="/profile" className="hidden! md:block!" size="md">
{t("profile")}
</GhostLightButton>
</>;
})()
}
) : (
<GhostLightButton href="/login" className="hidden! md:block!" size="md">
{t("sign_in")}
</GhostLightButton>
)}
<div className="md:hidden!">
<MobileMenu items={mobileMenuItems} />
</div>
</div>
</div>
);

View File

@@ -0,0 +1,79 @@
"use client";
import { createContext, useContext, useEffect, useState, useMemo } from "react";
import {
THEME_PRESETS,
DEFAULT_THEME,
getThemePreset,
applyThemeColors,
type ThemePreset,
} from "@/shared/theme-presets";
type ThemeContextType = {
currentTheme: string;
themePreset: ThemePreset;
setTheme: (themeId: string) => void;
availableThemes: ThemePreset[];
};
const ThemeContext = createContext<ThemeContextType | null>(null);
const STORAGE_KEY = "theme-preset";
function getInitialTheme(): string {
if (typeof window === "undefined") return DEFAULT_THEME;
const saved = localStorage.getItem(STORAGE_KEY);
return saved && getThemePreset(saved) ? saved : DEFAULT_THEME;
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [currentTheme, setCurrentTheme] = useState<string>(DEFAULT_THEME);
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
const savedTheme = getInitialTheme();
if (savedTheme !== currentTheme) {
setCurrentTheme(savedTheme);
}
setHydrated(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!hydrated) return;
const preset = getThemePreset(currentTheme);
if (preset) {
applyThemeColors(preset);
localStorage.setItem(STORAGE_KEY, currentTheme);
}
}, [currentTheme, hydrated]);
const setTheme = (themeId: string) => {
if (getThemePreset(themeId)) {
setCurrentTheme(themeId);
}
};
const themePreset = useMemo(() => getThemePreset(currentTheme) || THEME_PRESETS[0], [currentTheme]);
return (
<ThemeContext.Provider
value={{
currentTheme,
themePreset,
setTheme,
availableThemes: THEME_PRESETS,
}}
>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}

View File

@@ -21,7 +21,25 @@ const COMMON_LANGUAGES = [
{ label: "portuguese", value: "portuguese" },
{ label: "russian", value: "russian" },
{ label: "other", value: "other" },
];
] as const;
type LocaleLabel = typeof COMMON_LANGUAGES[number]["label"];
function getLocaleLabel(t: (key: string) => string, label: LocaleLabel): string {
switch (label) {
case "chinese": return t("translator.chinese");
case "english": return t("translator.english");
case "italian": return t("translator.italian");
case "japanese": return t("translator.japanese");
case "korean": return t("translator.korean");
case "french": return t("translator.french");
case "german": return t("translator.german");
case "spanish": return t("translator.spanish");
case "portuguese": return t("translator.portuguese");
case "russian": return t("translator.russian");
case "other": return t("translator.other");
}
}
interface LocaleSelectorProps {
value: string;
@@ -62,7 +80,7 @@ export function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
>
{COMMON_LANGUAGES.map((lang) => (
<option key={lang.value} value={lang.value}>
{t(`translator.${lang.label}`)}
{getLocaleLabel(t, lang.label)}
</option>
))}
</Select>

View File

@@ -31,6 +31,7 @@ const selectVariants = cva(
default: "border-b-2 border-gray-300 bg-transparent rounded-t-md",
bordered: "border-gray-300 bg-white",
filled: "border-transparent bg-gray-100",
light: "border-transparent bg-gray-100 shadow-sm hover:bg-gray-200 font-semibold cursor-pointer",
},
size: {
sm: "h-9 px-3 text-sm",
@@ -48,6 +49,11 @@ const selectVariants = cva(
error: true,
className: "bg-error-50",
},
{
variant: "light",
error: true,
className: "bg-error-50 hover:bg-error-100",
},
],
defaultVariants: {
variant: "default",

View File

@@ -1,18 +1,15 @@
# 词典查询模块化架构
# 词典查询架构
本目录包含词典查询系统的**多阶段 LLM 调用**实现,将查询过程拆分为 4 个独立的 LLM 调用,每个阶段之间有代码层面的数据验证,只要有一环失败,直接返回错误
2 次 LLM 调用的词典查询系统
## 目录结构
```
dictionary/
├── index.ts # 主导出文件
├── orchestrator.ts # 主编排器,串联所有阶段
├── types.ts # 类型定义
── stage1-inputAnalysis.ts # 阶段1输入解析与语言识别
├── stage2-semanticMapping.ts # 阶段2跨语言语义映射决策
├── stage3-standardForm.ts # 阶段3standardForm 生成与规范化
└── stage4-entriesGeneration.ts # 阶段4释义与词条生成
├── orchestrator.ts # 编排器
├── stage1-preprocess.ts # 阶段1预处理输入分析+语义映射+标准形式)
├── stage4-entriesGeneration.ts # 阶段2词条生成
── types.ts # 类型定义
```
## 工作流程
@@ -20,187 +17,22 @@ dictionary/
```
用户输入
[阶段1] 输入分析 → 代码验证 → 失败则返回错误
[阶段1] 预处理1次LLM→ isValid, standardForm, inputType
[阶段2] 语义映射 → 代码验证 → 失败则保守处理(不映射)
[阶段3] 标准形式 → 代码验证 → 失败则返回错误
[阶段4] 词条生成 → 代码验证 → 失败则返回错误
[阶段2] 词条生成1次LLM→ entries
最终结果
```
## 各阶段详细说明
## 性能
### 阶段 1输入分析
- 原 4 次 LLM 调用 → 现 2 次
- 预期耗时8-13s原 33s
**文件**: `stage1-inputAnalysis.ts`
**目的**:
- 判断输入是否有效
- 判断是「单词」还是「短语」
- 识别输入语言
**返回**: `InputAnalysisResult`
**代码验证**:
- `isValid` 必须是 boolean
- 输入为空或无效时立即返回错误
### 阶段 2语义映射
**文件**: `stage2-semanticMapping.ts`
**目的**:
- 决定是否启用"语义级查询"
- **严格条件**:只有输入符合"明确、基础、可词典化的语义概念"且语言不一致时才映射
- 不符合条件则**直接失败**(快速失败)
**返回**: `SemanticMappingResult`
**代码验证**:
- `shouldMap` 必须是 boolean
- 如果 `shouldMap=true`,必须有 `mappedQuery`
- 如果不应该映射,**抛出异常**(不符合条件直接失败)
- **失败则直接返回错误响应**,不继续后续阶段
**映射条件**(必须同时满足):
a) 输入语言 ≠ 查询语言
b) 输入是明确、基础、可词典化的语义概念(如常见动词、名词、形容词)
**不符合条件的例子**
- 复杂句子:"我喜欢吃苹果"
- 专业术语
- 无法确定语义的词汇
### 阶段 3标准形式生成
**文件**: `stage3-standardForm.ts`
**目的**:
- 确定最终词条的"标准形"(整个系统的锚点)
- 修正拼写错误
- 还原为词典形式(动词原形、辞书形等)
- **如果进行了语义映射**:基于映射结果生成标准形式,同时参考原始输入的语义上下文
**参数**:
- `inputText`: 用于生成标准形式的文本(可能是映射后的结果)
- `queryLang`: 查询语言
- `originalInput`: (可选)原始用户输入,用于语义参考
**返回**: `StandardFormResult`
**代码验证**:
- `standardForm` 不能为空
- `confidence` 必须是 "high" | "medium" | "low"
- 失败时使用原输入作为标准形式
**特殊逻辑**:
- 当进行了语义映射时(即提供了 `originalInput`),阶段 3 会:
1. 基于 `inputText`(映射结果)生成标准形式
2. 参考 `originalInput` 的语义上下文,确保标准形式符合用户的真实查询意图
3. 例如:原始输入 "吃"(中文)→ 映射为 "to eat"(英语)→ 标准形式 "eat"
### 阶段 4词条生成
**文件**: `stage4-entriesGeneration.ts`
**目的**:
- 生成真正的词典内容
- 根据类型生成单词或短语条目
**返回**: `EntriesGenerationResult`
**代码验证**:
- `entries` 必须是非空数组
- 每个条目必须有 `definition``example`
- 单词条目必须有 `partOfSpeech`
- **失败则抛出异常**(核心阶段)
## 使用方式
### 基本使用
## 使用
```typescript
import { lookUp } from "@/lib/server/bigmodel/dictionaryActions";
import { executeDictionaryLookup } from "@/lib/bigmodel/dictionary/orchestrator";
const result = await lookUp({
text: "hello",
queryLang: "English",
definitionLang: "中文"
});
const result = await executeDictionaryLookup("hello", "English", "中文");
```
### 高级使用(直接调用编排器)
```typescript
import { executeDictionaryLookup } from "@/lib/server/bigmodel/dictionary";
const result = await executeDictionaryLookup(
"hello",
"English",
"中文"
);
```
### 单独测试某个阶段
```typescript
import { analyzeInput } from "@/lib/server/bigmodel/dictionary";
const analysis = await analyzeInput("hello");
console.log(analysis);
```
## 设计优势
### 1. 代码层面的数据验证
每个阶段完成后都有严格的类型检查和数据验证,确保数据质量。
### 2. 快速失败
只要有一个阶段失败,立即返回错误,不浪费后续的 LLM 调用。
### 3. 可观测性
每个阶段都有 console.log 输出,方便调试和追踪问题。
### 4. 模块化
每个阶段独立文件,可以单独测试、修改或替换。
### 5. 容错性
非核心阶段阶段2、3失败时有降级策略不会导致整个查询失败。
## 日志示例
```
[阶段1] 开始输入分析...
[阶段1] 输入分析完成: { isValid: true, inputType: 'word', inputLanguage: 'English' }
[阶段2] 开始语义映射...
[阶段2] 语义映射完成: { shouldMap: false }
[阶段3] 开始生成标准形式...
[阶段3] 标准形式生成完成: { standardForm: 'hello', confidence: 'high' }
[阶段4] 开始生成词条...
[阶段4] 词条生成完成: { entries: [...] }
[完成] 词典查询成功
```
## 扩展建议
### 添加缓存
对阶段1、3的结果进行缓存避免重复调用 LLM。
### 添加指标
记录每个阶段的耗时和成功率,用于性能优化。
### 并行化
某些阶段可以并行执行(如果有依赖关系允许的话)。
### A/B 测试
为某个阶段创建不同版本的实现,进行效果对比。
## 注意事项
- 每个阶段都是独立的 LLM 调用,会增加总耗时
- 需要控制 token 使用量,避免成本过高
- 错误处理要完善,避免某个阶段卡住整个流程
- 日志记录要清晰,方便问题排查

View File

@@ -1,7 +1,5 @@
import { ServiceOutputLookUp } from "@/modules/dictionary/dictionary-service-dto";
import { analyzeInput } from "./stage1-inputAnalysis";
import { determineSemanticMapping } from "./stage2-semanticMapping";
import { generateStandardForm } from "./stage3-standardForm";
import { preprocessInput } from "./stage1-preprocess";
import { generateEntries } from "./stage4-entriesGeneration";
import { LookUpError } from "@/lib/errors";
import { createLogger } from "@/lib/logger";
@@ -14,64 +12,28 @@ export async function executeDictionaryLookup(
definitionLang: string
): Promise<ServiceOutputLookUp> {
try {
log.debug("[Stage 1] Starting input analysis");
const analysis = await analyzeInput(text);
log.debug("[Stage 1] Preprocessing input");
const preprocessed = await preprocessInput(text, queryLang);
if (!analysis.isValid) {
log.debug("[Stage 1] Invalid input", { reason: analysis.reason });
throw new LookUpError(analysis.reason || "无效输入");
if (!preprocessed.isValid) {
log.debug("[Stage 1] Invalid input", { reason: preprocessed.reason });
throw new LookUpError(preprocessed.reason || "无效输入");
}
if (analysis.isEmpty) {
log.debug("[Stage 1] Empty input");
throw new LookUpError("输入为空");
}
log.debug("[Stage 1] Preprocess complete", { preprocessed });
log.debug("[Stage 1] Analysis complete", { analysis });
log.debug("[Stage 2] Starting semantic mapping");
const semanticMapping = await determineSemanticMapping(
text,
queryLang,
analysis.inputLanguage ?? text
);
log.debug("[Stage 2] Semantic mapping complete", { semanticMapping });
log.debug("[Stage 3] Generating standard form");
// 如果进行了语义映射,标准形式要基于映射后的结果
// 同时传递原始输入作为语义参考
const shouldUseMapping = semanticMapping.shouldMap && semanticMapping.mappedQuery;
const inputForStandardForm = shouldUseMapping ? semanticMapping.mappedQuery! : text;
const standardFormResult = await generateStandardForm(
inputForStandardForm,
queryLang,
shouldUseMapping ? text : undefined // 如果进行了映射,传递原始输入作为语义参考
);
if (!standardFormResult.standardForm) {
log.error("[Stage 3] Standard form is empty");
throw "无法生成标准形式";
}
log.debug("[Stage 3] Standard form complete", { standardFormResult });
log.debug("[Stage 4] Generating entries");
log.debug("[Stage 2] Generating entries");
const entriesResult = await generateEntries(
standardFormResult.standardForm,
preprocessed.standardForm,
queryLang,
definitionLang,
analysis.inputType === "unknown"
? (standardFormResult.standardForm.includes(" ") ? "phrase" : "word")
: analysis.inputType
preprocessed.inputType
);
log.debug("[Stage 4] Entries complete", { entriesResult });
log.debug("[Stage 2] Entries complete", { entriesResult });
const finalResult: ServiceOutputLookUp = {
standardForm: standardFormResult.standardForm,
standardForm: preprocessed.standardForm,
entries: entriesResult.entries,
};
@@ -79,8 +41,7 @@ export async function executeDictionaryLookup(
return finalResult;
} catch (error) {
log.error("Dictionary lookup failed", { error });
log.error("Dictionary lookup failed", { error: error instanceof Error ? error.message : String(error) });
const errorMessage = error instanceof Error ? error.message : "未知错误";
throw new LookUpError(errorMessage);
}

View File

@@ -1,69 +0,0 @@
import { getAnswer } from "../zhipu";
import { parseAIGeneratedJSON } from "@/utils/json";
import { InputAnalysisResult } from "./types";
import { createLogger } from "@/lib/logger";
const log = createLogger("dictionary-stage1");
/**
* 阶段 1输入解析与语言识别
*
* 独立的 LLM 调用,分析输入文本
*/
export async function analyzeInput(text: string): Promise<InputAnalysisResult> {
const prompt = `
你是一个输入分析器。分析用户输入并返回 JSON 结果。
用户输入位于 <text> 标签内:
<text>${text}</text>
你的任务是:
1. 判断输入是否为空或明显非法
2. 判断输入是「单词」还是「短语」
3. 识别输入所属语言
返回 JSON 格式:
{
"isValid": true/false,
"isEmpty": true/false,
"isNaturalLanguage": true/false,
"inputLanguage": "检测到的语言名称(如 English、中文、日本語等",
"inputType": "word/phrase/unknown",
"reason": "错误原因,成功时为空字符串\"\""
}
若输入为空、非自然语言或无法识别语言,设置 isValid 为 false并在 reason 中说明原因。
若输入有效,设置 isValid 为 truereason 为空字符串 ""。
只返回 JSON不要任何其他文字。
`.trim();
try {
const result = await getAnswer([
{
role: "system",
content: "你是一个输入分析器,只返回 JSON 格式的分析结果。",
},
{
role: "user",
content: prompt,
},
]).then(parseAIGeneratedJSON<InputAnalysisResult>);
// 代码层面的数据验证
if (typeof result.isValid !== "boolean") {
throw new Error("阶段1isValid 字段类型错误");
}
// 确保 reason 字段存在
if (typeof result.reason !== "string") {
result.reason = "";
}
return result;
} catch (error) {
log.error("Stage 1 failed", { error });
// 失败时抛出错误,包含 reason
throw new Error("输入分析失败:无法识别输入类型或语言");
}
}

View File

@@ -0,0 +1,87 @@
import { getAnswer } from "../llm";
import { parseAIGeneratedJSON } from "@/utils/json";
import { PreprocessResult } from "./types";
import { createLogger } from "@/lib/logger";
const log = createLogger("dictionary-preprocess");
export async function preprocessInput(
text: string,
queryLang: string
): Promise<PreprocessResult> {
const prompt = `
你是一个词典预处理系统。分析输入并生成标准形式。
用户输入:<input>${text}</input>
查询语言:<queryLang>${queryLang}</queryLang>
任务:
1. 判断输入是否有效(非空、是自然语言)
2. 识别输入类型(单词/短语)
3. 将输入转换为查询语言的对应词(语义映射)
4. 生成标准形式(必须是查询语言)
重要规则:
- standardForm 必须是查询语言的词汇
- 例如:查询语言=维吾尔语,输入="japanese" → standardForm="ياپونىيە"
- 例如:查询语言=中文,输入="japanese" → standardForm="日语"
- 例如:查询语言=English输入="日语" → standardForm="Japanese"
- 如果输入本身就是查询语言,则保持不变
- 只做词典形式还原,不纠正拼写
返回 JSON
{
"isValid": boolean,
"inputType": "word" | "phrase",
"standardForm": "查询语言对应的标准形式",
"confidence": "high" | "medium" | "low",
"reason": "错误原因,成功时为空字符串"
}
注意:
- isValid=false 时,在 reason 中说明原因
- 成功时 reason 为空字符串 ""
- 只返回 JSON不要其他文字
`.trim();
try {
const result = await getAnswer([
{
role: "system",
content: "你是词典预处理系统,只返回 JSON。",
},
{
role: "user",
content: prompt,
},
]).then(parseAIGeneratedJSON<PreprocessResult>);
if (typeof result.isValid !== "boolean") {
throw new Error("预处理isValid 字段类型错误");
}
if (!result.standardForm || result.standardForm.trim().length === 0) {
throw new Error(result.reason || "预处理standardForm 为空");
}
if (!["word", "phrase"].includes(result.inputType)) {
result.inputType = result.standardForm.includes(" ") ? "phrase" : "word";
}
let confidence: "high" | "medium" | "low" = "low";
const cv = result.confidence?.toLowerCase();
if (cv === "高" || cv === "high") confidence = "high";
else if (cv === "中" || cv === "medium") confidence = "medium";
return {
isValid: result.isValid,
inputType: result.inputType as "word" | "phrase",
standardForm: result.standardForm,
confidence,
reason: typeof result.reason === "string" ? result.reason : "",
};
} catch (error) {
log.error("Preprocess failed", { error: error instanceof Error ? error.message : String(error) });
throw error;
}
}

View File

@@ -1,109 +0,0 @@
import { getAnswer } from "../zhipu";
import { parseAIGeneratedJSON } from "@/utils/json";
import { SemanticMappingResult } from "./types";
import { createLogger } from "@/lib/logger";
const log = createLogger("dictionary-stage2");
/**
* 阶段 2跨语言语义映射决策
*
* 独立的 LLM 调用,决定是否需要语义映射
* 如果输入不符合"明确、基础、可词典化的语义概念"且语言不一致,直接返回失败
*/
export async function determineSemanticMapping(
text: string,
queryLang: string,
inputLanguage: string
): Promise<SemanticMappingResult> {
// 如果输入语言就是查询语言,不需要映射
if (inputLanguage.toLowerCase() === queryLang.toLowerCase()) {
return {
shouldMap: false,
reason: "输入语言与查询语言一致",
};
}
const prompt = `
你是一个语义映射决策器。判断是否需要对输入进行跨语言语义映射。
查询语言:${queryLang}
输入语言:${inputLanguage}
用户输入:${text}
判断规则:
1. 若输入表达一个**明确、基础、可词典化的语义概念**(如常见动词、名词、形容词),则应该映射
2. 若输入不符合上述条件(如复杂句子、专业术语、无法确定语义的词汇),则不应该映射
映射条件必须同时满足:
a) 输入语言 ≠ 查询语言
b) 输入是明确、基础、可词典化的语义概念
例如:
- 查询语言=English输入="吃"(中文)→ 应该映射 → coreSemantic="to eat"
- 查询语言=Italiano输入="run"English→ 应该映射 → coreSemantic="correre"
- 查询语言=中文,输入="hello"English→ 应该映射 → coreSemantic="你好"
- 查询语言=English输入="我喜欢吃苹果"(中文,复杂句子)→ 不应该映射 → canMap=false
返回 JSON 格式:
{
"shouldMap": true/false,
"canMap": true/false,
"coreSemantic": "提取的核心语义(用${queryLang}表达)",
"mappedQuery": "映射到${queryLang}的标准表达",
"reason": "错误原因,成功时为空字符串\"\""
}
- canMap=true 表示输入符合"明确、基础、可词典化的语义概念"
- shouldMap=true 表示需要进行映射
- 只有 canMap=true 且语言不一致时shouldMap 才为 true
- 如果 shouldMap=false在 reason 中说明原因
- 如果 shouldMap=truereason 为空字符串 ""
只返回 JSON不要任何其他文字。
`.trim();
try {
const result = await getAnswer([
{
role: "system",
content: `你是一个语义映射决策器,只返回 JSON 格式的结果。`,
},
{
role: "user",
content: prompt,
},
]).then(parseAIGeneratedJSON<SemanticMappingResult>);
// 代码层面的数据验证
if (typeof result.shouldMap !== "boolean") {
throw new Error("阶段2shouldMap 字段类型错误");
}
// 确保 reason 字段存在
if (typeof result.reason !== "string") {
result.reason = "";
}
// 如果不应该映射,返回错误
if (!result.shouldMap) {
throw new Error(result.reason || "输入不符合可词典化的语义概念,无法进行跨语言查询");
}
if (!result.mappedQuery || result.mappedQuery.trim().length === 0) {
throw new Error("语义映射失败:映射结果为空");
}
return {
shouldMap: result.shouldMap,
coreSemantic: result.coreSemantic,
mappedQuery: result.mappedQuery,
reason: result.reason,
};
} catch (error) {
log.error("Stage 2 failed", { error });
// 失败时直接抛出错误,让编排器返回错误响应
throw error;
}
}

View File

@@ -1,100 +0,0 @@
import { getAnswer } from "../zhipu";
import { parseAIGeneratedJSON } from "@/utils/json";
import { StandardFormResult } from "./types";
import { createLogger } from "@/lib/logger";
const log = createLogger("dictionary-stage3");
/**
* 阶段 3standardForm 生成与规范化
*
* 独立的 LLM 调用,生成标准形式
*/
export async function generateStandardForm(
inputText: string,
queryLang: string,
originalInput?: string
): Promise<StandardFormResult> {
const prompt = `
你是一个词典标准形式生成器。为输入生成该语言下的标准形式。
查询语言:${queryLang}
当前输入:${inputText}
${originalInput ? `原始输入(语义参考):${originalInput}` : ''}
${originalInput ? `
**重要说明**
- 当前输入是经过语义映射后的结果(从原始语言映射到查询语言)
- 原始输入提供了语义上下文,帮助你理解用户的真实查询意图
- 你需要基于**当前输入**生成标准形式,但要参考**原始输入的语义**以确保准确性
例如:
- 原始输入:"吃"(中文),当前输入:"to eat"(英语)→ 标准形式应为 "eat"
- 原始输入:"走"(中文),当前输入:"to walk"(英语)→ 标准形式应为 "walk"
` : ''}
规则:
1. 尝试修正明显拼写错误
2. 还原为该语言中**最常见、最自然、最标准**的形式:
* 英语:动词原形、名词单数
* 日语:辞书形
* 意大利语:不定式或最常见规范形式
* 维吾尔语:标准拉丁化或阿拉伯字母形式
* 中文:标准简化字
3. ${originalInput ? '参考原始输入的语义,确保标准形式符合用户的真实查询意图':'若无法确定或输入本身已规范,则保持不变'}
返回 JSON 格式:
{
"standardForm": "标准形式",
"confidence": "high/medium/low",
"reason": "错误原因,成功时为空字符串\"\""
}
成功生成标准形式时reason 为空字符串 ""。
失败时,在 reason 中说明失败原因。
只返回 JSON不要任何其他文字。
`.trim();
try {
const result = await getAnswer([
{
role: "system",
content: "你是一个词典标准形式生成器,只返回 JSON 格式的结果。",
},
{
role: "user",
content: prompt,
},
]).then(parseAIGeneratedJSON<StandardFormResult>);
// 代码层面的数据验证
if (!result.standardForm || result.standardForm.trim().length === 0) {
throw new Error(result.reason || "阶段3standardForm 为空");
}
// 处理 confidence 可能是中文或英文的情况
let confidence: "high" | "medium" | "low" = "low";
const confidenceValue = result.confidence?.toLowerCase();
if (confidenceValue === "高" || confidenceValue === "high") {
confidence = "high";
} else if (confidenceValue === "中" || confidenceValue === "medium") {
confidence = "medium";
} else if (confidenceValue === "低" || confidenceValue === "low") {
confidence = "low";
}
// 确保 reason 字段存在
const reason = typeof result.reason === "string" ? result.reason : "";
return {
standardForm: result.standardForm,
confidence,
reason,
};
} catch (error) {
log.error("Stage 3 failed", { error });
// 失败时抛出错误
throw error;
}
}

View File

@@ -1,15 +1,9 @@
import { getAnswer } from "../zhipu";
import { getAnswer } from "../llm";
import { parseAIGeneratedJSON } from "@/utils/json";
import { EntriesGenerationResult } from "./types";
import { createLogger } from "@/lib/logger";
const log = createLogger("dictionary-stage4");
/**
* 阶段 4释义与词条生成
*
* 独立的 LLM 调用,生成词典条目
*/
const log = createLogger("dictionary-entries");
export async function generateEntries(
standardForm: string,
@@ -20,89 +14,57 @@ export async function generateEntries(
const isWord = inputType === "word";
const prompt = `
你是一个词典条目生成器。为标准形式生成词典条目
你是专业词典编纂专家。为词条"${standardForm}"${queryLang})生成${definitionLang}释义
标准形式:${standardForm}
查询语言:${queryLang}
释义语言:${definitionLang}
词条类型:${isWord ? "单词" : "短语"}
【核心要求】
生成尽可能完整、全面的词典条目,包括:
${isWord ? `- 所有常见词性(名词、动词、形容词、副词等)
- 每个词性下的所有常用义项
- 专业领域含义、口语含义、习语用法` : `- 所有常见含义和用法
- 字面义和引申义
- 不同语境下的解释`}
${isWord ? `
单词条目要求:
- ipa音标如适用
- partOfSpeech词性
- definition释义使用 ${definitionLang}
- example例句使用 ${queryLang}
` : `
短语条目要求:
- definition短语释义使用 ${definitionLang}
- example例句使用 ${queryLang}
`}
【JSON格式】
${isWord ? `{"entries":[{"ipa":"国际音标","partOfSpeech":"词性","definition":"详细释义","example":"自然例句"}]}` : `{"entries":[{"definition":"详细释义","example":"自然例句"}]}`}
生成 1-3 个条目,返回 JSON 格式:
{
"entries": [
${isWord ? `
{
"ipa": "音标",
"partOfSpeech": "词性",
"definition": "释义",
"example": "例句"
}` : `
{
"definition": "释义",
"example": "例句"
}`}
]
}
【质量标准】
- 条目数量:尽可能多,不要遗漏常用义项
- 释义:准确、完整、符合母语者习惯
- 例句:自然、地道、展示实际用法
- IPA使用标准国际音标单词/短语必填)
只返回 JSON不要任何其他文字
只返回JSON不要其他内容
`.trim();
try {
const result = await getAnswer([
{
role: "system",
content: `你是一个词典条目生成器,只返回 JSON 格式的结果。`,
},
{
role: "user",
content: prompt,
},
{ role: "system", content: "专业词典编纂专家返回完整JSON词典数据。" },
{ role: "user", content: prompt },
]).then(parseAIGeneratedJSON<EntriesGenerationResult>);
// 代码层面的数据验证
if (!result.entries || !Array.isArray(result.entries) || result.entries.length === 0) {
throw new Error("阶段4entries 为空或不是数组");
if (!result.entries?.length) {
throw new Error("词条生成失败:结果为空");
}
// 处理每个条目,清理 IPA 格式
for (const entry of result.entries) {
// 清理 IPA删除两端可能包含的方括号、斜杠等字符
if (entry.ipa) {
entry.ipa = entry.ipa.trim();
// 删除开头的 [ / /
entry.ipa = entry.ipa.replace(/^[\[\/]/, '');
// 删除结尾的 ] / /
entry.ipa = entry.ipa.replace(/[\]\/]$/, '');
entry.ipa = entry.ipa.trim().replace(/^[\[\/]/, '').replace(/[\]\/]$/, '');
}
if (!entry.definition || entry.definition.trim().length === 0) {
throw new Error("阶段4条目缺少 definition");
if (!entry.definition?.trim()) {
throw new Error("词条缺少释义");
}
if (!entry.example || entry.example.trim().length === 0) {
throw new Error("阶段4条目缺少 example");
if (!entry.example?.trim()) {
throw new Error("词条缺少例句");
}
if (isWord && !entry.partOfSpeech) {
throw new Error("阶段4单词条目缺少 partOfSpeech");
throw new Error("单词条目缺少词性");
}
}
log.info("Generated dictionary entries", { count: result.entries.length });
return result;
} catch (error) {
log.error("Stage 4 failed", { error });
throw error; // 阶段4失败应该返回错误因为这个阶段是核心
log.error("Entries generation failed", { error: error instanceof Error ? error.message : String(error) });
throw error;
}
}

View File

@@ -1,43 +1,21 @@
/**
* 词典查询的类型定义
*/
export interface DictionaryContext {
queryLang: string;
definitionLang: string;
}
// 阶段1输入分析结果
export interface InputAnalysisResult {
export interface PreprocessResult {
isValid: boolean;
isEmpty: boolean;
isNaturalLanguage: boolean;
inputLanguage?: string;
inputType: "word" | "phrase" | "unknown";
reason: string;
}
// 阶段2语义映射结果
export interface SemanticMappingResult {
shouldMap: boolean;
coreSemantic?: string;
mappedQuery?: string;
reason: string;
}
// 阶段3标准形式结果
export interface StandardFormResult {
inputType: "word" | "phrase";
standardForm: string;
confidence: "high" | "medium" | "low";
reason: string;
}
// 阶段4词条生成结果
export interface EntriesGenerationResult {
entries: Array<{
ipa?: string;
definition: string;
partOfSpeech?: string;
example: string; // example 必需
example: string;
}>;
}

46
src/lib/bigmodel/llm.ts Normal file
View File

@@ -0,0 +1,46 @@
"use server";
type Messages = Array<
| { role: "system"; content: string }
| { role: "user"; content: string }
| { role: "assistant"; content: string }
>;
async function getAnswer(prompt: string): Promise<string>;
async function getAnswer(prompt: Messages): Promise<string>;
async function getAnswer(prompt: string | Messages): Promise<string> {
const messages: Messages = typeof prompt === "string"
? [{ role: "user", content: prompt }]
: prompt;
const response = await fetch("https://open.bigmodel.cn/api/paas/v4/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.ZHIPU_API_KEY}`,
},
body: JSON.stringify({
model: process.env.ZHIPU_MODEL_NAME || "glm-4.6",
messages,
temperature: 0.2,
thinking: {
type: "disabled"
}
}),
});
if (!response.ok) {
throw new Error(`AI API 请求失败: ${response.status}`);
}
const data = await response.json() as { choices?: Array<{ message?: { content?: string } }> };
const content = data.choices?.[0]?.message?.content;
if (!content) {
throw new Error("AI API 返回空响应");
}
return content.trim();
}
export { getAnswer };

View File

@@ -0,0 +1,152 @@
import OpenAI from "openai";
import { parseAIGeneratedJSON } from "@/utils/json";
import { createLogger } from "@/lib/logger";
import { OCRInput, OCROutput, OCRRawResponse } from "./types";
const log = createLogger("ocr-orchestrator");
const openai = new OpenAI({
apiKey: process.env.ZHIPU_API_KEY,
baseURL: "https://open.bigmodel.cn/api/paas/v4",
});
/**
* Executes OCR on an image to extract vocabulary word-definition pairs.
*
* Uses GLM-4.6V vision model to analyze vocabulary table images and
* extract structured word-definition pairs.
*
* @param input - OCR input containing base64 image and optional language hints
* @returns Structured output with extracted pairs and detected languages
* @throws Error if OCR fails or response is malformed
*
* @example
* ```typescript
* const result = await executeOCR({
* imageBase64: "iVBORw0KGgo...",
* sourceLanguage: "English",
* targetLanguage: "Chinese"
* });
* // result.pairs: [{ word: "hello", definition: "你好" }, ...]
* ```
*/
export async function executeOCR(input: OCRInput): Promise<OCROutput> {
const { imageBase64, sourceLanguage, targetLanguage } = input;
log.debug("Starting OCR", {
hasSourceHint: !!sourceLanguage,
hasTargetHint: !!targetLanguage,
imageLength: imageBase64.length,
});
const languageHints: string[] = [];
if (sourceLanguage) {
languageHints.push(`源语言提示: ${sourceLanguage}`);
}
if (targetLanguage) {
languageHints.push(`目标语言提示: ${targetLanguage}`);
}
const prompt = `
你是一个专业的OCR识别助手专门从词汇表截图中提取单词和释义。
${languageHints.length > 0 ? `语言提示:\n${languageHints.join("\n")}\n` : ""}
你的任务是分析图片中的词汇表,提取所有单词-释义对。
要求:
1. 识别图片中的词汇表结构(可能是两列或多列)
2. 提取每一行的单词和对应的释义/翻译
3. 自动检测源语言和目标语言
4. 保持原始大小写和拼写
5. 如果图片模糊或不清晰,尽力识别并标注置信度较低的项目
6. 忽略表头、页码等非词汇内容
返回 JSON 格式:
{
"pairs": [
{ "word": "单词1", "definition": "释义1" },
{ "word": "单词2", "definition": "释义2" }
],
"detectedSourceLanguage": "检测到的源语言",
"detectedTargetLanguage": "检测到的目标语言"
}
只返回 JSON不要任何其他文字。
`.trim();
try {
const response = await openai.chat.completions.create({
model: "glm-4.6v",
messages: [
{
role: "user",
content: [
{
type: "image_url",
image_url: {
url: imageBase64,
},
},
{
type: "text",
text: prompt,
},
],
},
],
temperature: 0.1,
});
const content = response.choices[0]?.message?.content;
if (!content) {
log.error("OCR returned empty response");
throw new Error("OCR 返回空响应");
}
log.debug("Received OCR response", { contentLength: content.length });
const parsed = parseAIGeneratedJSON<OCRRawResponse>(content);
if (!parsed.pairs || !Array.isArray(parsed.pairs)) {
log.error("Invalid OCR response: missing or invalid pairs array", { parsed });
throw new Error("OCR 响应格式无效:缺少 pairs 数组");
}
const validPairs = parsed.pairs.filter((pair) => {
const isValid = typeof pair.word === "string" && typeof pair.definition === "string";
if (!isValid) {
log.warn("Skipping invalid pair", { pair });
}
return isValid;
});
if (validPairs.length === 0) {
log.error("No valid pairs extracted from image");
throw new Error("未能从图片中提取有效的词汇对");
}
const result: OCROutput = {
pairs: validPairs,
detectedSourceLanguage: parsed.detectedSourceLanguage,
detectedTargetLanguage: parsed.detectedTargetLanguage,
};
log.info("OCR completed successfully", {
pairCount: result.pairs.length,
sourceLanguage: result.detectedSourceLanguage,
targetLanguage: result.detectedTargetLanguage,
});
return result;
} catch (error) {
if (error instanceof Error && error.message.startsWith("OCR")) {
throw error;
}
log.error("OCR failed", { error });
const errorMessage = error instanceof Error ? error.message : "未知错误";
throw new Error(`OCR 处理失败: ${errorMessage}`);
}
}

View File

@@ -0,0 +1,44 @@
/**
* Input for OCR pipeline
*/
export interface OCRInput {
/** Base64 encoded image (without data URL prefix) */
imageBase64: string;
/** Optional: hint about source language */
sourceLanguage?: string;
/** Optional: hint about target/translation language */
targetLanguage?: string;
}
/**
* Single word-definition pair extracted from image
*/
export interface VocabularyPair {
/** The original word */
word: string;
/** The translation/definition */
definition: string;
}
/**
* Output from OCR pipeline
*/
export interface OCROutput {
/** Extracted word-definition pairs */
pairs: VocabularyPair[];
/** Detected source language */
detectedSourceLanguage?: string;
/** Detected target/translation language */
detectedTargetLanguage?: string;
}
/**
* Internal structure for AI response parsing
*/
interface OCRRawResponse {
pairs: Array<{ word: string; definition: string }>;
detectedSourceLanguage?: string;
detectedTargetLanguage?: string;
}
export type { OCRRawResponse };

View File

@@ -1,4 +1,4 @@
import { getAnswer } from "../zhipu";
import { getAnswer } from "../llm";
import { parseAIGeneratedJSON } from "@/utils/json";
import { LanguageDetectionResult, TranslationLLMResponse } from "./types";
import { createLogger } from "@/lib/logger";
@@ -132,19 +132,28 @@ async function generateIPA(
export async function executeTranslation(
sourceText: string,
targetLanguage: string,
needIpa: boolean
needIpa: boolean,
sourceLanguage?: string
): Promise<TranslationLLMResponse> {
try {
log.debug("Starting translation", { sourceText, targetLanguage, needIpa });
log.debug("Starting translation", { sourceText, targetLanguage, needIpa, sourceLanguage });
let detectedLanguage: string;
if (sourceLanguage) {
log.debug("[Stage 1] Using provided source language", { sourceLanguage });
detectedLanguage = sourceLanguage;
} else {
log.debug("[Stage 1] Detecting source language");
const detectionResult = await detectLanguage(sourceText);
log.debug("[Stage 1] Detection result", { detectionResult });
detectedLanguage = detectionResult.sourceLanguage;
}
log.debug("[Stage 2] Performing translation");
const translatedText = await performTranslation(
sourceText,
detectionResult.sourceLanguage,
detectedLanguage,
targetLanguage
);
log.debug("[Stage 2] Translation complete", { translatedText });
@@ -159,19 +168,19 @@ export async function executeTranslation(
let targetIpa: string | undefined;
if (needIpa) {
log.debug("[Stage 3] Generating IPA");
sourceIpa = await generateIPA(sourceText, detectionResult.sourceLanguage);
log.debug("[Stage 3] Source IPA", { sourceIpa });
targetIpa = await generateIPA(translatedText, targetLanguage);
log.debug("[Stage 3] Target IPA", { targetIpa });
log.debug("[Stage 3] Generating IPA in parallel");
[sourceIpa, targetIpa] = await Promise.all([
generateIPA(sourceText, detectedLanguage),
generateIPA(translatedText, targetLanguage),
]);
log.debug("[Stage 3] IPA complete", { sourceIpa, targetIpa });
}
// Assemble final result
const finalResult: TranslationLLMResponse = {
sourceText,
translatedText,
sourceLanguage: detectionResult.sourceLanguage,
sourceLanguage: detectedLanguage,
targetLanguage,
sourceIpa,
targetIpa,

View File

@@ -1,45 +0,0 @@
"use server";
type Messages = { role: string; content: string; }[];
async function callZhipuAPI(
messages: Messages,
model = process.env.ZHIPU_MODEL_NAME,
) {
const url = "https://open.bigmodel.cn/api/paas/v4/chat/completions";
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.ZHIPU_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: model,
messages: messages,
temperature: 0.2,
thinking: {
type: "disabled",
},
}),
});
if (!response.ok) {
throw new Error(`API 调用失败: ${response.status} ${response.statusText}`);
}
return await response.json();
}
async function getAnswer(prompt: string): Promise<string>;
async function getAnswer(prompt: Messages): Promise<string>;
async function getAnswer(prompt: string | Messages): Promise<string> {
const messages = typeof prompt === "string"
? [{ role: "user", content: prompt }]
: prompt;
const response = await callZhipuAPI(messages);
return response.choices[0].message.content.trim() as string;
}
export { getAnswer };

View File

@@ -1,7 +1,9 @@
"use client";
import { z } from "zod";
interface LocalStorageOperator<T> {
get: () => T;
get: () => T | null;
set: (value: T) => void;
}
@@ -9,22 +11,29 @@ export function getLocalStorageOperator<T extends z.ZodType>(
key: string,
schema: T
): LocalStorageOperator<z.infer<T>> {
const get = (): z.infer<T> => {
const get = (): z.infer<T> | null => {
if (typeof window === "undefined") {
return [] as unknown as z.infer<T>;
return null;
}
try {
const item = localStorage.getItem(key);
if (item === null) {
return [] as unknown as z.infer<T>;
return null;
}
const parsed = JSON.parse(item);
return schema.parse(parsed);
const result = schema.safeParse(parsed);
if (!result.success) {
console.warn(`[localStorage] Schema validation failed for key "${key}":`, result.error.message);
return null;
}
return result.data;
} catch (error) {
console.error(`Error reading from localStorage key "${key}":`, error);
return [] as unknown as z.infer<T>;
console.error(`[localStorage] Error reading key "${key}":`, error instanceof Error ? error.message : String(error));
return null;
}
};
@@ -36,7 +45,7 @@ export function getLocalStorageOperator<T extends z.ZodType>(
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.error(`Error writing to localStorage key "${key}":`, error);
console.error(`[localStorage] Error writing key "${key}":`, error instanceof Error ? error.message : String(error));
}
};

104
src/lib/email.ts Normal file
View File

@@ -0,0 +1,104 @@
import nodemailer from "nodemailer";
import { createLogger } from "@/lib/logger";
const log = createLogger("email");
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_SECURE === "true",
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
interface SendEmailOptions {
to: string;
subject: string;
html: string;
text?: string;
}
export async function sendEmail({ to, subject, html, text }: SendEmailOptions) {
try {
const info = await transporter.sendMail({
from: process.env.SMTP_FROM || process.env.SMTP_USER,
to,
subject,
html,
text,
});
log.info("Email sent", { to, subject, messageId: info.messageId });
return { success: true, messageId: info.messageId };
} catch (error) {
log.error("Failed to send email", { to, subject, error });
return { success: false, error };
}
}
export function generateVerificationEmailHtml(url: string, userName: string) {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.button { display: inline-block; padding: 12px 24px; background-color: #3b82f6; color: white; text-decoration: none; border-radius: 6px; }
.footer { margin-top: 20px; padding-top: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px; }
</style>
</head>
<body>
<div class="container">
<h1>验证您的邮箱地址</h1>
<p>您好,${userName}</p>
<p>感谢您注册。请点击下方按钮验证您的邮箱地址:</p>
<p>
<a href="${url}" class="button">验证邮箱</a>
</p>
<p>或者复制以下链接到浏览器:</p>
<p style="word-break: break-all; color: #666;">${url}</p>
<p>此链接将在 24 小时后过期。</p>
<div class="footer">
<p>如果您没有注册此账户,请忽略此邮件。</p>
</div>
</div>
</body>
</html>
`;
}
export function generateResetPasswordEmailHtml(url: string, userName: string) {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.button { display: inline-block; padding: 12px 24px; background-color: #3b82f6; color: white; text-decoration: none; border-radius: 6px; }
.footer { margin-top: 20px; padding-top: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px; }
</style>
</head>
<body>
<div class="container">
<h1>重置您的密码</h1>
<p>您好,${userName}</p>
<p>我们收到了重置您账户密码的请求。请点击下方按钮设置新密码:</p>
<p>
<a href="${url}" class="button">重置密码</a>
</p>
<p>或者复制以下链接到浏览器:</p>
<p style="word-break: break-all; color: #666;">${url}</p>
<p>此链接将在 1 小时后过期。</p>
<div class="footer">
<p>如果您没有请求重置密码,请忽略此邮件,您的密码不会被更改。</p>
</div>
</div>
</body>
</html>
`;
}

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