Compare commits

..

18 Commits

Author SHA1 Message Date
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
6ba5ae993a fix: language selector mutual exclusion with preset buttons
- When "Other" is selected, preset language buttons are deselected
- Only one option can be selected at a time
- Refactor dictionary page with zustand store
- Add custom language input option to dictionary
- Fix multiple issues in dictionary bigmodel pipeline
2026-03-08 16:10:41 +08:00
b643205f72 refactor(folders): 优化刷新逻辑,只更新特定文件夹而非全量刷新
- FoldersClient: 使用 onUpdateFolder/onDeleteFolder 回调局部更新
- ExploreClient: 使用 onUpdateFavorite 只更新收藏数
- FavoritesClient: 使用 onRemoveFavorite 从列表移除,避免重新请求
2026-03-08 15:07:05 +08:00
c6878ed1e5 style(explore): 将公开文件夹改为网格布局展示
- 移除 CardList 组件,改用 CSS Grid
- 响应式网格: 1/2/3/4 列 (sm/lg/xl)
- 重新设计卡片样式:圆角边框、hover 效果
- 文件夹图标移至左上角,收藏按钮移至右上角
2026-03-08 15:03:35 +08:00
e74cd80fac style(explore): 移动收藏数到文件夹名左侧 2026-03-08 15:01:29 +08:00
c01c94abd0 refactor: 替换服务端 console.log/error 为 winston logger
- folder-action.ts: 18处 console.log -> log.error
- auth-action.ts: 4处 console.error -> log.error
- dictionary-action/service.ts: 3处 -> log.error
- translator-action/service.ts: 3处 -> log.error
- bigmodel/translator/orchestrator.ts: console -> log.debug/info/error
- bigmodel/tts.ts: console -> log.error/warn
- bigmodel/dictionary/*.ts: console -> log.error/debug/info

客户端代码(browser、page.tsx)保留 console.error
2026-03-08 14:58:43 +08:00
79 changed files with 3247 additions and 2304 deletions

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
# LEARN-LANGUAGES 知识库 # LEARN-LANGUAGES 知识库
**生成时间:** 2026-03-08 **生成时间:** 2026-03-08
**提交:** 91c59c3 **提交:** 6ba5ae9
**分支:** dev **分支:** dev
## 概述 ## 概述
@@ -112,6 +112,7 @@ log.error("Failed to fetch folders", { error });
- ❌ Server Component 可行时用 Client Component - ❌ Server Component 可行时用 Client Component
- ❌ npm 或 yarn (使用 pnpm) - ❌ npm 或 yarn (使用 pnpm)
- ❌ 生产代码中使用 `console.log` (使用 winston logger) - ❌ 生产代码中使用 `console.log` (使用 winston logger)
- ❌ 擅自运行 `pnpm dev` (不需要,用 `pnpm build` 验证即可)
## 独特风格 ## 独特风格

View File

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

View File

@@ -1,37 +1,57 @@
{ {
"alphabet": { "alphabet": {
"chooseCharacters": "Bitte wählen Sie die Zeichen aus, die Sie lernen möchten", "chooseCharacters": "Bitte wählen Sie die Zeichen aus, die Sie lernen möchten",
"chooseAlphabetHint": "Wählen Sie ein Alphabet, um mit dem Lernen zu beginnen",
"japanese": "Japanische Kana", "japanese": "Japanische Kana",
"english": "Englisches Alphabet", "english": "Englisches Alphabet",
"uyghur": "Uigurisches Alphabet", "uyghur": "Uigurisches Alphabet",
"esperanto": "Esperanto-Alphabet", "esperanto": "Esperanto-Alphabet",
"loading": "Laden...", "loading": "Wird geladen...",
"loadFailed": "Laden fehlgeschlagen, bitte versuchen Sie es erneut", "loadFailed": "Laden fehlgeschlagen, bitte versuchen Sie es erneut",
"hideLetter": "Zeichen ausblenden", "hideLetter": "Buchstabe ausblenden",
"showLetter": "Zeichen anzeigen", "showLetter": "Buchstabe anzeigen",
"hideIPA": "IPA ausblenden", "hideIPA": "IPA ausblenden",
"showIPA": "IPA anzeigen", "showIPA": "IPA anzeigen",
"roman": "Romanisierung", "roman": "Romanisierung",
"letter": "Zeichen", "letter": "Buchstabe",
"random": "Zufälliger Modus", "random": "Zufallsmodus",
"randomNext": "Zufällig weiter" "randomNext": "Zufällig weiter",
"previousLetter": "Vorheriger Buchstabe",
"nextLetter": "Nächster Buchstabe",
"keyboardHint": "Verwenden Sie die Pfeiltasten links/rechts oder Leertaste für Zufall, ESC zum Zurückgehen",
"swipeHint": "Verwenden Sie die Pfeiltasten links/rechts oder wischen Sie zum Navigieren, ESC zum Zurückgehen"
}, },
"folders": { "folders": {
"title": "Ordner", "title": "Ordner",
"subtitle": "Verwalten Sie Ihre Sammlungen", "subtitle": "Verwalten Sie Ihre Sammlungen",
"newFolder": "Neuer Ordner", "newFolder": "Neuer Ordner",
"creating": "Erstellen...", "creating": "Wird erstellt...",
"noFoldersYet": "Noch keine Ordner", "noFoldersYet": "Noch keine Ordner vorhanden",
"folderInfo": "ID: {id} • {totalPairs} Paare", "folderInfo": "ID: {id} • {totalPairs} Paare",
"enterFolderName": "Ordnernamen eingeben:", "enterFolderName": "Ordnernamen eingeben:",
"confirmDelete": "Geben Sie \"{name}\" ein, um zu löschen:" "confirmDelete": "Geben Sie \"{name}\" zum Löschen ein:",
"myFolders": "Meine Ordner",
"publicFolders": "Öffentliche Ordner",
"public": "Öffentlich",
"private": "Privat",
"setPublic": "Öffentlich machen",
"setPrivate": "Privat machen",
"publicFolderInfo": "{userName} • {totalPairs} Paare",
"searchPlaceholder": "Öffentliche Ordner durchsuchen...",
"loading": "Wird geladen...",
"noPublicFolders": "Keine öffentlichen Ordner gefunden",
"unknownUser": "Unbekannter Benutzer",
"enterNewName": "Neuen Namen eingeben:",
"favorite": "Favorisieren",
"unfavorite": "Aus Favoriten entfernen",
"pleaseLogin": "Bitte melden Sie sich zuerst an"
}, },
"folder_id": { "folder_id": {
"unauthorized": "Sie sind nicht der Eigentümer dieses Ordners", "unauthorized": "Sie sind nicht der Besitzer dieses Ordners",
"back": "Zurück", "back": "Zurück",
"textPairs": "Textpaare", "textPairs": "Textpaare",
"itemsCount": "{count} Elemente", "itemsCount": "{count} Einträge",
"memorize": "Einprägen", "memorize": "Auswendig lernen",
"loadingTextPairs": "Textpaare werden geladen...", "loadingTextPairs": "Textpaare werden geladen...",
"noTextPairs": "Keine Textpaare in diesem Ordner", "noTextPairs": "Keine Textpaare in diesem Ordner",
"addNewTextPair": "Neues Textpaar hinzufügen", "addNewTextPair": "Neues Textpaar hinzufügen",
@@ -42,14 +62,14 @@
"text2": "Text 2", "text2": "Text 2",
"language1": "Sprache 1", "language1": "Sprache 1",
"language2": "Sprache 2", "language2": "Sprache 2",
"enterLanguageName": "Bitte geben Sie den Sprachennamen ein", "enterLanguageName": "Bitte Sprachnamen eingeben",
"edit": "Bearbeiten", "edit": "Bearbeiten",
"delete": "Löschen", "delete": "Löschen",
"permissionDenied": "Sie haben keine Berechtigung, diese Aktion auszuführen", "permissionDenied": "Sie haben keine Berechtigung für diese Aktion",
"error": { "error": {
"update": "Sie haben keine Berechtigung, dieses Element zu aktualisieren.", "update": "Sie haben keine Berechtigung, diesen Eintrag zu aktualisieren.",
"delete": "Sie haben keine Berechtigung, dieses Element zu löschen.", "delete": "Sie haben keine Berechtigung, diesen Eintrag zu löschen.",
"add": "Sie haben keine Berechtigung, Elemente zu diesem Ordner hinzuzufügen.", "add": "Sie haben keine Berechtigung, Einträge zu diesem Ordner hinzuzufügen.",
"rename": "Sie haben keine Berechtigung, diesen Ordner umzubenennen.", "rename": "Sie haben keine Berechtigung, diesen Ordner umzubenennen.",
"deleteFolder": "Sie haben keine Berechtigung, diesen Ordner zu löschen." "deleteFolder": "Sie haben keine Berechtigung, diesen Ordner zu löschen."
} }
@@ -57,42 +77,43 @@
"home": { "home": {
"title": "Sprachen lernen", "title": "Sprachen lernen",
"description": "Hier ist eine sehr nützliche Website, die Ihnen hilft, fast jede Sprache der Welt zu lernen, einschließlich konstruierter Sprachen.", "description": "Hier ist eine sehr nützliche Website, die Ihnen hilft, fast jede Sprache der Welt zu lernen, einschließlich konstruierter Sprachen.",
"explore": "Erkunden", "explore": "Entdecken",
"fortune": { "fortune": {
"quote": "Bleib hungrig, bleib dumm.", "quote": "Stay hungry, stay foolish.",
"author": "— Steve Jobs" "author": "— Steve Jobs"
}, },
"translator": { "translator": {
"name": "Übersetzer", "name": "Übersetzer",
"description": "In jede Sprache übersetzen und mit Internationalem Phonetischem Alphabet (IPA) annotieren" "description": "In jede Sprache übersetzen und mit dem Internationalen Phonetischen Alphabet (IPA) annotieren"
}, },
"textSpeaker": { "textSpeaker": {
"name": "Text-Sprecher", "name": "Textvorleser",
"description": "Text erkennen und vorlesen, unterstützt Schleifenwiedergabe und Geschwindigkeitsanpassung" "description": "Text erkennen und vorlesen, unterstützt Schleifenwiedergabe und Geschwindigkeitsanpassung"
}, },
"srtPlayer": { "srtPlayer": {
"name": "SRT-Videoplayer", "name": "SRT-Videoplayer",
"description": "Videos basierend auf SRT-Untertiteldateien satzweise abspielen, um die Aussprache von Muttersprachlern zu imitieren" "description": "Videos Satz für Satz basierend auf SRT-Untertiteldateien abspielen, um die Aussprache von Muttersprachlern nachzuahmen"
}, },
"alphabet": { "alphabet": {
"name": "Alphabet", "name": "Alphabet",
"description": "Beginnen Sie mit dem Erlernen einer neuen Sprache mit dem Alphabet" "description": "Beginnen Sie mit dem Lernen einer neuen Sprache vom Alphabet aus"
}, },
"memorize": { "memorize": {
"name": "Einprägen", "name": "Auswendig lernen",
"description": "Sprache A zu Sprache B, Sprache B zu Sprache A, unterstützt Diktat" "description": "Sprache A zu Sprache B, Sprache B zu Sprache A, unterstützt Diktat"
}, },
"dictionary": { "dictionary": {
"name": "Wörterbuch", "name": "Wörterbuch",
"description": "Wörter und Redewendungen nachschlagen mit detaillierten Definitionen und Beispielen" "description": "Wörter und Ausdrücke mit detaillierten Definitionen und Beispielen nachschlagen"
}, },
"moreFeatures": { "moreFeatures": {
"name": "Weitere Funktionen", "name": "Weitere Funktionen",
"description": "In Entwicklung, bleiben Sie dran" "description": "In Entwicklung, bleiben Sie gespannt"
} }
}, },
"auth": { "auth": {
"title": "Authentifizierung", "title": "Anmelden",
"signUpTitle": "Registrieren",
"signIn": "Anmelden", "signIn": "Anmelden",
"signUp": "Registrieren", "signUp": "Registrieren",
"email": "E-Mail", "email": "E-Mail",
@@ -109,20 +130,47 @@
"signUpWithGitHub": "Mit GitHub registrieren", "signUpWithGitHub": "Mit GitHub registrieren",
"invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein", "invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"passwordTooShort": "Das Passwort muss mindestens 8 Zeichen lang sein", "passwordTooShort": "Das Passwort muss mindestens 8 Zeichen lang sein",
"passwordsNotMatch": "Passwörter stimmen nicht überein", "passwordsNotMatch": "Die Passwörter stimmen nicht überein",
"nameRequired": "Bitte geben Sie Ihren Namen ein", "nameRequired": "Bitte geben Sie Ihren Namen ein",
"usernameRequired": "Bitte geben Sie Ihren Benutzernamen ein", "usernameRequired": "Bitte geben Sie einen Benutzernamen ein",
"usernameTooShort": "Der Benutzername muss mindestens 3 Zeichen lang sein", "usernameTooShort": "Der Benutzername muss mindestens 3 Zeichen lang sein",
"usernameInvalid": "Der Benutzername darf nur Buchstaben, Zahlen und Unterstriche enthalten", "usernameInvalid": "Der Benutzername darf nur Buchstaben, Zahlen und Unterstriche enthalten",
"emailRequired": "Bitte geben Sie Ihre E-Mail ein", "emailRequired": "Bitte geben Sie Ihre E-Mail ein",
"identifierRequired": "Bitte geben Sie Ihre E-Mail oder Ihren Benutzernamen ein", "identifierRequired": "Bitte geben Sie Ihre E-Mail oder Ihren Benutzernamen ein",
"passwordRequired": "Bitte geben Sie Ihr Passwort ein", "passwordRequired": "Bitte geben Sie Ihr Passwort ein",
"confirmPasswordRequired": "Bitte bestätigen Sie Ihr Passwort", "confirmPasswordRequired": "Bitte bestätigen Sie Ihr Passwort",
"loading": "Laden..." "loading": "Wird geladen...",
"confirm": "Bestätigen",
"noAccountLink": "Haben Sie kein Konto? Registrieren Sie sich",
"hasAccountLink": "Haben Sie bereits ein Konto? Anmelden",
"usernamePlaceholder": "Benutzername",
"emailPlaceholder": "E-Mail-Adresse",
"passwordPlaceholder": "Passwort",
"usernameOrEmailPlaceholder": "Benutzername oder E-Mail",
"loginFailed": "Anmeldung fehlgeschlagen",
"signUpFailed": "Registrierung fehlgeschlagen",
"fillAllFields": "Bitte füllen Sie alle Felder aus",
"enterCredentials": "Bitte geben Sie Benutzername und Passwort ein",
"forgotPassword": "Passwort vergessen",
"forgotPasswordHint": "Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen einen Link zum Zurücksetzen Ihres Passworts.",
"sendResetEmail": "Reset-E-Mail senden",
"resetPasswordFailed": "Reset-E-Mail konnte nicht gesendet werden",
"resetPasswordEmailSent": "Reset-E-Mail erfolgreich gesendet",
"resetPasswordEmailSentHint": "Wir haben einen Link zum Zurücksetzen Ihres Passworts an Ihre E-Mail-Adresse gesendet. Bitte überprüfen Sie Ihren Posteingang.",
"checkYourEmail": "Überprüfen Sie Ihre E-Mail",
"backToLogin": "Zurück zur Anmeldung",
"resetPassword": "Passwort zurücksetzen",
"newPassword": "Neues Passwort",
"invalidToken": "Ungültiger oder abgelaufener Link",
"invalidTokenHint": "Dieser Link zum Zurücksetzen des Passworts ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen an.",
"requestNewToken": "Neuen Reset-Link anfordern",
"resetPasswordSuccess": "Passwort erfolgreich zurückgesetzt",
"resetPasswordSuccessTitle": "Passwort-Zurücksetzung abgeschlossen",
"resetPasswordSuccessHint": "Ihr Passwort wurde erfolgreich zurückgesetzt. Sie können sich jetzt mit Ihrem neuen Passwort anmelden."
}, },
"memorize": { "memorize": {
"folder_selector": { "folder_selector": {
"selectFolder": "Wählen Sie einen Ordner aus", "selectFolder": "Wählen Sie einen Ordner",
"noFolders": "Keine Ordner gefunden", "noFolders": "Keine Ordner gefunden",
"folderInfo": "{id}. {name} ({count})" "folderInfo": "{id}. {name} ({count})"
}, },
@@ -144,7 +192,9 @@
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "Anmelden", "sign_in": "Anmelden",
"profile": "Profil", "profile": "Profil",
"folders": "Ordner" "folders": "Ordner",
"explore": "Entdecken",
"favorites": "Favoriten"
}, },
"profile": { "profile": {
"myProfile": "Mein Profil", "myProfile": "Mein Profil",
@@ -170,21 +220,27 @@
"uploaded": "Hochgeladen", "uploaded": "Hochgeladen",
"notUploaded": "Nicht hochgeladen", "notUploaded": "Nicht hochgeladen",
"upload": "Hochladen", "upload": "Hochladen",
"uploadVideoButton": "Video hochladen",
"uploadSubtitleButton": "Untertitel hochladen",
"subtitleUploaded": "Untertitel hochgeladen ({count} Einträge)",
"subtitleNotUploaded": "Untertitel nicht hochgeladen",
"autoPauseStatus": "Auto-Pause: {enabled}", "autoPauseStatus": "Auto-Pause: {enabled}",
"on": "Ein", "on": "Ein",
"off": "Aus", "off": "Aus",
"videoUploadFailed": "Video-Upload fehlgeschlagen", "videoUploadFailed": "Video-Upload fehlgeschlagen",
"subtitleUploadFailed": "Untertitel-Upload fehlgeschlagen" "subtitleUploadFailed": "Untertitel-Upload fehlgeschlagen",
"subtitleLoadSuccess": "Untertitel erfolgreich geladen",
"subtitleLoadFailed": "Laden der Untertitel fehlgeschlagen"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "IPA generieren", "generateIPA": "IPA generieren",
"viewSavedItems": "Gespeicherte Elemente anzeigen", "viewSavedItems": "Gespeicherte Einträge anzeigen",
"confirmDeleteAll": "Sind Sie sicher, dass Sie alles löschen möchten? (J/N)" "confirmDeleteAll": "Sind Sie sicher, dass Sie alles löschen möchten? (J/N)"
}, },
"translator": { "translator": {
"detectLanguage": "Sprache erkennen", "detectLanguage": "Sprache erkennen",
"generateIPA": "IPA generieren", "generateIPA": "IPA generieren",
"translateInto": "Übersetzen in", "translateInto": "übersetzen in",
"chinese": "Chinesisch", "chinese": "Chinesisch",
"english": "Englisch", "english": "Englisch",
"french": "Französisch", "french": "Französisch",
@@ -196,49 +252,88 @@
"russian": "Russisch", "russian": "Russisch",
"spanish": "Spanisch", "spanish": "Spanisch",
"other": "Andere", "other": "Andere",
"translating": "Übersetzung läuft...", "translating": "wird übersetzt...",
"translate": "Übersetzen", "translate": "übersetzen",
"inputLanguage": "Geben Sie eine Sprache ein.", "inputLanguage": "Geben Sie eine Sprache ein.",
"history": "Verlauf", "history": "Verlauf",
"enterLanguage": "Sprache eingeben", "enterLanguage": "Sprache eingeben",
"add_to_folder": { "add_to_folder": {
"notAuthenticated": "Sie sind nicht authentifiziert", "notAuthenticated": "Sie sind nicht authentifiziert",
"chooseFolder": "Wählen Sie einen Ordner zum Hinzufügen aus", "chooseFolder": "Wählen Sie einen Ordner zum Hinzufügen",
"noFolders": "Keine Ordner gefunden", "noFolders": "Keine Ordner gefunden",
"folderInfo": "{id}. {name}", "folderInfo": "{id}. {name}",
"close": "Schließen", "close": "Schließen",
"success": "Textpaar zum Ordner hinzugefügt", "success": "Textpaar zum Ordner hinzugefügt",
"error": "Textpaar konnte nicht zum Ordner hinzugefügt werden" "error": "Fehler beim Hinzufügen des Textpaars zum Ordner"
}, },
"autoSave": "Automatisch speichern" "autoSave": "Autom. Speichern"
}, },
"dictionary": { "dictionary": {
"title": "Wörterbuch", "title": "Wörterbuch",
"description": "Wörter und Ausdrücke mit detaillierten Definitionen und Beispielen nachschlagen", "description": "Wörter und Ausdrücke mit detaillierten Definitionen und Beispielen nachschlagen",
"searchPlaceholder": "Wort oder Ausdruck zum Nachschlagen eingeben...", "searchPlaceholder": "Geben Sie ein Wort oder einen Ausdruck zum Nachschlagen ein...",
"searching": "Suche...", "searching": "Suche läuft...",
"search": "Suchen", "search": "Suchen",
"languageSettings": "Spracheinstellungen", "languageSettings": "Spracheinstellungen",
"queryLanguage": "Abfragesprache", "queryLanguage": "Abfragesprache",
"queryLanguageHint": "Welche Sprache hat das Wort/die Phrase, die Sie nachschlagen möchten", "queryLanguageHint": "In welcher Sprache ist das Wort/der Ausdruck, den Sie nachschlagen möchten",
"definitionLanguage": "Definitionssprache", "definitionLanguage": "Definitionssprache",
"definitionLanguageHint": "In welcher Sprache möchten Sie die Definitionen sehen", "definitionLanguageHint": "In welcher Sprache möchten Sie die Definitionen",
"otherLanguagePlaceholder": "Oder eine andere Sprache eingeben...", "otherLanguagePlaceholder": "Oder geben Sie eine andere Sprache ein...",
"other": "Andere",
"currentSettings": "Aktuelle Einstellungen: Abfrage {queryLang}, Definition {definitionLang}", "currentSettings": "Aktuelle Einstellungen: Abfrage {queryLang}, Definition {definitionLang}",
"relookup": "Neu suchen", "relookup": "Erneut suchen",
"saveToFolder": "In Ordner speichern", "saveToFolder": "In Ordner speichern",
"loading": "Laden...", "loading": "Wird geladen...",
"noResults": "Keine Ergebnisse gefunden", "noResults": "Keine Ergebnisse gefunden",
"tryOtherWords": "Versuchen Sie andere Wörter oder Ausdrücke", "tryOtherWords": "Versuchen Sie andere Wörter oder Ausdrücke",
"welcomeTitle": "Willkommen beim Wörterbuch", "welcomeTitle": "Willkommen im Wörterbuch",
"welcomeHint": "Geben Sie oben im Suchfeld ein Wort oder einen Ausdruck ein, um zu suchen", "welcomeHint": "Geben Sie oben in das Suchfeld ein Wort oder einen Ausdruck ein, um mit dem Nachschlagen zu beginnen",
"lookupFailed": "Suche fehlgeschlagen, bitte später erneut versuchen", "lookupFailed": "Suche fehlgeschlagen, bitte versuchen Sie es später erneut",
"relookupSuccess": "Erfolgreich neu gesucht", "relookupSuccess": "Erneute Suche erfolgreich",
"relookupFailed": "Wörterbuch Neu-Suche fehlgeschlagen", "relookupFailed": "Erneute Wörterbuchsuche fehlgeschlagen",
"pleaseLogin": "Bitte melden Sie sich zuerst an", "pleaseLogin": "Bitte melden Sie sich zuerst an",
"pleaseCreateFolder": "Bitte erstellen Sie zuerst einen Ordner", "pleaseCreateFolder": "Bitte erstellen Sie zuerst einen Ordner",
"savedToFolder": "Im Ordner gespeichert: {folderName}", "savedToFolder": "In Ordner gespeichert: {folderName}",
"saveFailed": "Speichern fehlgeschlagen, bitte später erneut versuchen" "saveFailed": "Speichern fehlgeschlagen, bitte versuchen Sie es später erneut",
"definition": "Definition",
"example": "Beispiel"
},
"explore": {
"title": "Entdecken",
"subtitle": "Öffentliche Ordner entdecken",
"searchPlaceholder": "Öffentliche Ordner durchsuchen...",
"loading": "Wird geladen...",
"noFolders": "Keine öffentlichen Ordner gefunden",
"folderInfo": "{userName} • {totalPairs} Paare",
"unknownUser": "Unbekannter Benutzer",
"favorite": "Favorisieren",
"unfavorite": "Aus Favoriten entfernen",
"pleaseLogin": "Bitte melden Sie sich zuerst an",
"sortByFavorites": "Nach Favoriten sortieren",
"sortByFavoritesActive": "Sortierung nach Favoriten aufheben"
},
"exploreDetail": {
"title": "Ordnerdetails",
"createdBy": "Erstellt von: {name}",
"unknownUser": "Unbekannter Benutzer",
"totalPairs": "Gesamtpaare",
"favorites": "Favoriten",
"createdAt": "Erstellt am",
"viewContent": "Inhalt anzeigen",
"favorite": "Favorisieren",
"unfavorite": "Aus Favoriten entfernen",
"favorited": "Favorisiert",
"unfavorited": "Aus Favoriten entfernt",
"pleaseLogin": "Bitte melden Sie sich zuerst an"
},
"favorites": {
"title": "Meine Favoriten",
"subtitle": "Ordner, die Sie favorisiert haben",
"loading": "Wird geladen...",
"noFavorites": "Noch keine Favoriten",
"folderInfo": "{userName} • {totalPairs} Paare",
"unknownUser": "Unbekannter Benutzer"
}, },
"user_profile": { "user_profile": {
"anonymous": "Anonym", "anonymous": "Anonym",
@@ -251,14 +346,15 @@
"displayName": "Anzeigename", "displayName": "Anzeigename",
"notSet": "Nicht festgelegt", "notSet": "Nicht festgelegt",
"memberSince": "Mitglied seit", "memberSince": "Mitglied seit",
"logout": "Abmelden",
"folders": { "folders": {
"title": "Ordner", "title": "Ordner",
"noFolders": "Noch keine Ordner", "noFolders": "Noch keine Ordner",
"folderName": "Ordnername", "folderName": "Ordnername",
"totalPairs": "Anzahl der Paare", "totalPairs": "Gesamtpaare",
"createdAt": "Erstellt am", "createdAt": "Erstellt am",
"actions": "Aktionen", "actions": "Aktionen",
"view": "Ansehen" "view": "Anzeigen"
} }
} }
} }

View File

@@ -1,6 +1,7 @@
{ {
"alphabet": { "alphabet": {
"chooseCharacters": "Please select the characters you want to learn", "chooseCharacters": "Please select the characters you want to learn",
"chooseAlphabetHint": "Select an alphabet to start learning",
"japanese": "Japanese Kana", "japanese": "Japanese Kana",
"english": "English Alphabet", "english": "English Alphabet",
"uyghur": "Uyghur Alphabet", "uyghur": "Uyghur Alphabet",
@@ -14,7 +15,11 @@
"roman": "Romanization", "roman": "Romanization",
"letter": "Letter", "letter": "Letter",
"random": "Random Mode", "random": "Random Mode",
"randomNext": "Random Next" "randomNext": "Random Next",
"previousLetter": "Previous letter",
"nextLetter": "Next letter",
"keyboardHint": "Use left/right arrow keys or space for random, ESC to go back",
"swipeHint": "Use left/right arrow keys or swipe to navigate, ESC to go back"
}, },
"folders": { "folders": {
"title": "Folders", "title": "Folders",
@@ -107,7 +112,8 @@
} }
}, },
"auth": { "auth": {
"title": "Authentication", "title": "Sign In",
"signUpTitle": "Sign Up",
"signIn": "Sign In", "signIn": "Sign In",
"signUp": "Sign Up", "signUp": "Sign Up",
"email": "Email", "email": "Email",
@@ -133,7 +139,34 @@
"identifierRequired": "Please enter your email or username", "identifierRequired": "Please enter your email or username",
"passwordRequired": "Please enter your password", "passwordRequired": "Please enter your password",
"confirmPasswordRequired": "Please confirm your password", "confirmPasswordRequired": "Please confirm your password",
"loading": "Loading..." "loading": "Loading...",
"confirm": "Confirm",
"noAccountLink": "Don't have an account? Sign up",
"hasAccountLink": "Already have an account? Sign in",
"usernamePlaceholder": "Username",
"emailPlaceholder": "Email address",
"passwordPlaceholder": "Password",
"usernameOrEmailPlaceholder": "Username or email",
"loginFailed": "Login failed",
"signUpFailed": "Sign up failed",
"fillAllFields": "Please fill in all fields",
"enterCredentials": "Please enter username and password",
"forgotPassword": "Forgot Password",
"forgotPasswordHint": "Enter your email address and we'll send you a link to reset your password.",
"sendResetEmail": "Send Reset Email",
"resetPasswordFailed": "Failed to send reset email",
"resetPasswordEmailSent": "Reset email sent successfully",
"resetPasswordEmailSentHint": "We've sent a password reset link to your email address. Please check your inbox.",
"checkYourEmail": "Check Your Email",
"backToLogin": "Back to Login",
"resetPassword": "Reset Password",
"newPassword": "New Password",
"invalidToken": "Invalid or Expired Link",
"invalidTokenHint": "This password reset link is invalid or has expired. Please request a new one.",
"requestNewToken": "Request New Reset Link",
"resetPasswordSuccess": "Password reset successfully",
"resetPasswordSuccessTitle": "Password Reset Complete",
"resetPasswordSuccessHint": "Your password has been reset successfully. You can now log in with your new password."
}, },
"memorize": { "memorize": {
"folder_selector": { "folder_selector": {
@@ -187,11 +220,17 @@
"uploaded": "Uploaded", "uploaded": "Uploaded",
"notUploaded": "Not Uploaded", "notUploaded": "Not Uploaded",
"upload": "Upload", "upload": "Upload",
"uploadVideoButton": "Upload Video",
"uploadSubtitleButton": "Upload Subtitle",
"subtitleUploaded": "Subtitle Uploaded ({count} entries)",
"subtitleNotUploaded": "Subtitle Not Uploaded",
"autoPauseStatus": "Auto Pause: {enabled}", "autoPauseStatus": "Auto Pause: {enabled}",
"on": "On", "on": "On",
"off": "Off", "off": "Off",
"videoUploadFailed": "Video upload failed", "videoUploadFailed": "Video upload failed",
"subtitleUploadFailed": "Subtitle upload failed" "subtitleUploadFailed": "Subtitle upload failed",
"subtitleLoadSuccess": "Subtitle loaded successfully",
"subtitleLoadFailed": "Subtitle load failed"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "Generate IPA", "generateIPA": "Generate IPA",
@@ -241,6 +280,7 @@
"definitionLanguage": "Definition Language", "definitionLanguage": "Definition Language",
"definitionLanguageHint": "What language do you want the definitions in", "definitionLanguageHint": "What language do you want the definitions in",
"otherLanguagePlaceholder": "Or enter another language...", "otherLanguagePlaceholder": "Or enter another language...",
"other": "Other",
"currentSettings": "Current settings: Query {queryLang}, Definition {definitionLang}", "currentSettings": "Current settings: Query {queryLang}, Definition {definitionLang}",
"relookup": "Re-search", "relookup": "Re-search",
"saveToFolder": "Save to folder", "saveToFolder": "Save to folder",
@@ -255,7 +295,9 @@
"pleaseLogin": "Please log in first", "pleaseLogin": "Please log in first",
"pleaseCreateFolder": "Please create a folder first", "pleaseCreateFolder": "Please create a folder first",
"savedToFolder": "Saved to folder: {folderName}", "savedToFolder": "Saved to folder: {folderName}",
"saveFailed": "Save failed, please try again later" "saveFailed": "Save failed, please try again later",
"definition": "Definition",
"example": "Example"
}, },
"explore": { "explore": {
"title": "Explore", "title": "Explore",
@@ -267,6 +309,22 @@
"unknownUser": "Unknown User", "unknownUser": "Unknown User",
"favorite": "Favorite", "favorite": "Favorite",
"unfavorite": "Unfavorite", "unfavorite": "Unfavorite",
"pleaseLogin": "Please login first",
"sortByFavorites": "Sort by favorites",
"sortByFavoritesActive": "Undo sort by favorites"
},
"exploreDetail": {
"title": "Folder Details",
"createdBy": "Created by: {name}",
"unknownUser": "Unknown User",
"totalPairs": "Total Pairs",
"favorites": "Favorites",
"createdAt": "Created At",
"viewContent": "View Content",
"favorite": "Favorite",
"unfavorite": "Unfavorite",
"favorited": "Favorited",
"unfavorited": "Unfavorited",
"pleaseLogin": "Please login first" "pleaseLogin": "Please login first"
}, },
"favorites": { "favorites": {
@@ -288,6 +346,7 @@
"displayName": "Display Name", "displayName": "Display Name",
"notSet": "Not Set", "notSet": "Not Set",
"memberSince": "Member Since", "memberSince": "Member Since",
"logout": "Logout",
"folders": { "folders": {
"title": "Folders", "title": "Folders",
"noFolders": "No folders yet", "noFolders": "No folders yet",

View File

@@ -1,6 +1,7 @@
{ {
"alphabet": { "alphabet": {
"chooseCharacters": "Veuillez sélectionner les caractères que vous souhaitez apprendre", "chooseCharacters": "Veuillez sélectionner les caractères que vous souhaitez apprendre",
"chooseAlphabetHint": "Sélectionnez un alphabet pour commencer à apprendre",
"japanese": "Kana japonais", "japanese": "Kana japonais",
"english": "Alphabet anglais", "english": "Alphabet anglais",
"uyghur": "Alphabet ouïghour", "uyghur": "Alphabet ouïghour",
@@ -14,29 +15,48 @@
"roman": "Romanisation", "roman": "Romanisation",
"letter": "Lettre", "letter": "Lettre",
"random": "Mode aléatoire", "random": "Mode aléatoire",
"randomNext": "Suivant aléatoire" "randomNext": "Suivant aléatoire",
"previousLetter": "Lettre précédente",
"nextLetter": "Lettre suivante",
"keyboardHint": "Utilisez les touches fléchées gauche/droite ou espace pour aléatoire, ÉCHAP pour revenir",
"swipeHint": "Utilisez les touches fléchées gauche/droite ou balayez pour naviguer, ÉCHAP pour revenir"
}, },
"folders": { "folders": {
"title": "Dossiers", "title": "Dossiers",
"subtitle": "Gérez vos collections", "subtitle": "Gérez vos collections",
"newFolder": "Nouveau dossier", "newFolder": "Nouveau dossier",
"creating": "Création...", "creating": "Création...",
"noFoldersYet": "Aucun dossier pour le moment", "noFoldersYet": "Pas encore de dossiers",
"folderInfo": "ID: {id} • {totalPairs} paires", "folderInfo": "ID : {id} • {totalPairs} paires",
"enterFolderName": "Entrez le nom du dossier:", "enterFolderName": "Entrez le nom du dossier :",
"confirmDelete": "Tapez \"{name}\" pour supprimer:" "confirmDelete": "Tapez \"{name}\" pour supprimer :",
"myFolders": "Mes dossiers",
"publicFolders": "Dossiers publics",
"public": "Public",
"private": "Privé",
"setPublic": "Définir comme public",
"setPrivate": "Définir comme privé",
"publicFolderInfo": "{userName} • {totalPairs} paires",
"searchPlaceholder": "Rechercher des dossiers publics...",
"loading": "Chargement...",
"noPublicFolders": "Aucun dossier public trouvé",
"unknownUser": "Utilisateur inconnu",
"enterNewName": "Entrez le nouveau nom :",
"favorite": "Favori",
"unfavorite": "Retirer des favoris",
"pleaseLogin": "Veuillez vous connecter d'abord"
}, },
"folder_id": { "folder_id": {
"unauthorized": "Vous n'êtes pas le propriétaire de ce dossier", "unauthorized": "Vous n'êtes pas le propriétaire de ce dossier",
"back": "Retour", "back": "Retour",
"textPairs": "Paires de textes", "textPairs": "Paires de texte",
"itemsCount": "{count} éléments", "itemsCount": "{count} éléments",
"memorize": "Mémoriser", "memorize": "Mémoriser",
"loadingTextPairs": "Chargement des paires de textes...", "loadingTextPairs": "Chargement des paires de texte...",
"noTextPairs": "Aucune paire de textes dans ce dossier", "noTextPairs": "Aucune paire de texte dans ce dossier",
"addNewTextPair": "Ajouter une nouvelle paire de textes", "addNewTextPair": "Ajouter une nouvelle paire de texte",
"add": "Ajouter", "add": "Ajouter",
"updateTextPair": "Mettre à jour la paire de textes", "updateTextPair": "Mettre à jour la paire de texte",
"update": "Mettre à jour", "update": "Mettre à jour",
"text1": "Texte 1", "text1": "Texte 1",
"text2": "Texte 2", "text2": "Texte 2",
@@ -56,15 +76,15 @@
}, },
"home": { "home": {
"title": "Apprendre les langues", "title": "Apprendre les langues",
"description": "Voici un site web très utile pour vous aider à apprendre presque toutes les langues du monde, y compris les langues construites.", "description": "Voici un site Web très utile pour vous aider à apprendre presque toutes les langues du monde, y compris les langues construites.",
"explore": "Explorer", "explore": "Explorer",
"fortune": { "fortune": {
"quote": "Stay hungry, stay foolish.", "quote": "Restez affamés, restez fous.",
"author": "— Steve Jobs" "author": "— Steve Jobs"
}, },
"translator": { "translator": {
"name": "Traducteur", "name": "Traducteur",
"description": "Traduire dans n'importe quelle langue et annoter avec l'alphabet phonétique international (API)" "description": "Traduire vers n'importe quelle langue et annoter avec l'Alphabet Phonétique International (API)"
}, },
"textSpeaker": { "textSpeaker": {
"name": "Lecteur de texte", "name": "Lecteur de texte",
@@ -76,15 +96,15 @@
}, },
"alphabet": { "alphabet": {
"name": "Alphabet", "name": "Alphabet",
"description": "Commencer à apprendre une nouvelle langue par l'alphabet" "description": "Commencez à apprendre une nouvelle langue à partir de l'alphabet"
}, },
"memorize": { "memorize": {
"name": "Mémoriser", "name": "Mémoriser",
"description": "Langue A vers langue B, langue B vers langue A, prend en charge la dictée" "description": "Langue A vers Langue B, Langue B vers Langue A, prend en charge la dictée"
}, },
"dictionary": { "dictionary": {
"name": "Dictionnaire", "name": "Dictionnaire",
"description": "Rechercher des mots et des phrases avec des définitions détaillées et des exemples" "description": "Rechercher des mots et des expressions avec des définitions détaillées et des exemples"
}, },
"moreFeatures": { "moreFeatures": {
"name": "Plus de fonctionnalités", "name": "Plus de fonctionnalités",
@@ -92,7 +112,8 @@
} }
}, },
"auth": { "auth": {
"title": "Authentification", "title": "Se connecter",
"signUpTitle": "S'inscrire",
"signIn": "Se connecter", "signIn": "Se connecter",
"signUp": "S'inscrire", "signUp": "S'inscrire",
"email": "E-mail", "email": "E-mail",
@@ -103,22 +124,49 @@
"emailOrUsername": "E-mail ou nom d'utilisateur", "emailOrUsername": "E-mail ou nom d'utilisateur",
"signInButton": "Se connecter", "signInButton": "Se connecter",
"signUpButton": "S'inscrire", "signUpButton": "S'inscrire",
"noAccount": "Vous n'avez pas de compte?", "noAccount": "Vous n'avez pas de compte ?",
"hasAccount": "Vous avez déjà un compte?", "hasAccount": "Vous avez déjà un compte ?",
"signInWithGitHub": "Se connecter avec GitHub", "signInWithGitHub": "Se connecter avec GitHub",
"signUpWithGitHub": "S'inscrire avec GitHub", "signUpWithGitHub": "S'inscrire avec GitHub",
"invalidEmail": "Veuillez entrer une adresse e-mail valide", "invalidEmail": "Veuillez entrer une adresse e-mail valide",
"passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères", "passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères",
"passwordsNotMatch": "Les mots de passe ne correspondent pas", "passwordsNotMatch": "Les mots de passe ne correspondent pas",
"nameRequired": "Veuillez entrer votre nom", "nameRequired": "Veuillez entrer votre nom",
"usernameRequired": "Veuillez entrer votre nom d'utilisateur", "usernameRequired": "Veuillez entrer un nom d'utilisateur",
"usernameTooShort": "Le nom d'utilisateur doit contenir au moins 3 caractères", "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", "usernameInvalid": "Le nom d'utilisateur ne peut contenir que des lettres, des chiffres et des underscores",
"emailRequired": "Veuillez entrer votre e-mail", "emailRequired": "Veuillez entrer votre e-mail",
"identifierRequired": "Veuillez entrer votre e-mail ou nom d'utilisateur", "identifierRequired": "Veuillez entrer votre e-mail ou nom d'utilisateur",
"passwordRequired": "Veuillez entrer votre mot de passe", "passwordRequired": "Veuillez entrer votre mot de passe",
"confirmPasswordRequired": "Veuillez confirmer votre mot de passe", "confirmPasswordRequired": "Veuillez confirmer votre mot de passe",
"loading": "Chargement..." "loading": "Chargement...",
"confirm": "Confirmer",
"noAccountLink": "Vous n'avez pas de compte ? Inscrivez-vous",
"hasAccountLink": "Vous avez déjà un compte ? Connectez-vous",
"usernamePlaceholder": "Nom d'utilisateur",
"emailPlaceholder": "Adresse e-mail",
"passwordPlaceholder": "Mot de passe",
"usernameOrEmailPlaceholder": "Nom d'utilisateur ou e-mail",
"loginFailed": "Échec de la connexion",
"signUpFailed": "Échec de l'inscription",
"fillAllFields": "Veuillez remplir tous les champs",
"enterCredentials": "Veuillez entrer le nom d'utilisateur et le mot de passe",
"forgotPassword": "Mot de passe oublié",
"forgotPasswordHint": "Entrez votre adresse e-mail et nous vous enverrons un lien pour réinitialiser votre mot de passe.",
"sendResetEmail": "Envoyer l'e-mail de réinitialisation",
"resetPasswordFailed": "Échec de l'envoi de l'e-mail de réinitialisation",
"resetPasswordEmailSent": "E-mail de réinitialisation envoyé avec succès",
"resetPasswordEmailSentHint": "Nous avons envoyé un lien de réinitialisation de mot de passe à votre adresse e-mail. Veuillez vérifier votre boîte de réception.",
"checkYourEmail": "Vérifiez votre e-mail",
"backToLogin": "Retour à la connexion",
"resetPassword": "Réinitialiser le mot de passe",
"newPassword": "Nouveau mot de passe",
"invalidToken": "Lien invalide ou expiré",
"invalidTokenHint": "Ce lien de réinitialisation de mot de passe est invalide ou a expiré. Veuillez en demander un nouveau.",
"requestNewToken": "Demander un nouveau lien de réinitialisation",
"resetPasswordSuccess": "Mot de passe réinitialisé avec succès",
"resetPasswordSuccessTitle": "Réinitialisation du mot de passe terminée",
"resetPasswordSuccessHint": "Votre mot de passe a été réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe."
}, },
"memorize": { "memorize": {
"folder_selector": { "folder_selector": {
@@ -131,7 +179,7 @@
"next": "Suivant", "next": "Suivant",
"reverse": "Inverser", "reverse": "Inverser",
"dictation": "Dictée", "dictation": "Dictée",
"noTextPairs": "Aucune paire de textes disponible", "noTextPairs": "Aucune paire de texte disponible",
"disorder": "Désordre", "disorder": "Désordre",
"previous": "Précédent" "previous": "Précédent"
}, },
@@ -140,46 +188,54 @@
} }
}, },
"navbar": { "navbar": {
"title": "learn-languages", "title": "apprendre-langues",
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "Se connecter", "sign_in": "Se connecter",
"profile": "Profil", "profile": "Profil",
"folders": "Dossiers" "folders": "Dossiers",
"explore": "Explorer",
"favorites": "Favoris"
}, },
"profile": { "profile": {
"myProfile": "Mon profil", "myProfile": "Mon profil",
"email": "E-mail: {email}", "email": "E-mail : {email}",
"logout": "Se déconnecter" "logout": "Déconnexion"
}, },
"srt_player": { "srt_player": {
"uploadVideo": "Télécharger une vidéo", "uploadVideo": "Télécharger la vidéo",
"uploadSubtitle": "Télécharger des sous-titres", "uploadSubtitle": "Télécharger les sous-titres",
"pause": "Pause", "pause": "Pause",
"play": "Lire", "play": "Lecture",
"previous": "Précédent", "previous": "Précédent",
"next": "Suivant", "next": "Suivant",
"restart": "Redémarrer", "restart": "Recommencer",
"autoPause": "Pause automatique ({enabled})", "autoPause": "Pause automatique ({enabled})",
"uploadVideoAndSubtitle": "Veuillez télécharger des fichiers vidéo et de sous-titres", "uploadVideoAndSubtitle": "Veuillez télécharger les fichiers vidéo et sous-titres",
"uploadVideoFile": "Veuillez télécharger un fichier vidéo", "uploadVideoFile": "Veuillez télécharger le fichier vidéo",
"uploadSubtitleFile": "Veuillez télécharger un fichier de sous-titres", "uploadSubtitleFile": "Veuillez télécharger le fichier de sous-titres",
"processingSubtitle": "Traitement du fichier de sous-titres...", "processingSubtitle": "Traitement du fichier de sous-titres...",
"needBothFiles": "Les fichiers vidéo et de sous-titres sont requis pour commencer l'apprentissage", "needBothFiles": "Les fichiers vidéo et sous-titres sont tous deux requis pour commencer l'apprentissage",
"videoFile": "Fichier vidéo", "videoFile": "Fichier vidéo",
"subtitleFile": "Fichier de sous-titres", "subtitleFile": "Fichier de sous-titres",
"uploaded": "Téléchargé", "uploaded": "Téléchargé",
"notUploaded": "Non téléchargé", "notUploaded": "Non téléchargé",
"upload": "Télécharger", "upload": "Télécharger",
"autoPauseStatus": "Pause automatique: {enabled}", "uploadVideoButton": "Télécharger la vidéo",
"uploadSubtitleButton": "Télécharger les sous-titres",
"subtitleUploaded": "Sous-titres téléchargés ({count} entrées)",
"subtitleNotUploaded": "Sous-titres non téléchargés",
"autoPauseStatus": "Pause automatique : {enabled}",
"on": "Activé", "on": "Activé",
"off": "Désactivé", "off": "Désactivé",
"videoUploadFailed": "Échec du téléchargement de la vidéo", "videoUploadFailed": "Échec du téléchargement de la vidéo",
"subtitleUploadFailed": "Échec du téléchargement des sous-titres" "subtitleUploadFailed": "Échec du téléchargement des sous-titres",
"subtitleLoadSuccess": "Sous-titres chargés avec succès",
"subtitleLoadFailed": "Échec du chargement des sous-titres"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "Générer l'API", "generateIPA": "Générer l'API",
"viewSavedItems": "Voir les éléments enregistrés", "viewSavedItems": "Voir les éléments enregistrés",
"confirmDeleteAll": "Êtes-vous sûr de vouloir tout supprimer? (O/N)" "confirmDeleteAll": "Êtes-vous sûr de vouloir tout supprimer ? (O/N)"
}, },
"translator": { "translator": {
"detectLanguage": "détecter la langue", "detectLanguage": "détecter la langue",
@@ -200,45 +256,84 @@
"translate": "traduire", "translate": "traduire",
"inputLanguage": "Entrez une langue.", "inputLanguage": "Entrez une langue.",
"history": "Historique", "history": "Historique",
"enterLanguage": "Entrer la langue", "enterLanguage": "Entrez la langue",
"add_to_folder": { "add_to_folder": {
"notAuthenticated": "Vous n'êtes pas authentifié", "notAuthenticated": "Vous n'êtes pas authentifié",
"chooseFolder": "Choisir un dossier à ajouter", "chooseFolder": "Choisissez un dossier à ajouter",
"noFolders": "Aucun dossier trouvé", "noFolders": "Aucun dossier trouvé",
"folderInfo": "{id}. {name}", "folderInfo": "{id}. {name}",
"close": "Fermer", "close": "Fermer",
"success": "Paire de textes ajoutée au dossier", "success": "Paire de texte ajoutée au dossier",
"error": "Échec de l'ajout de la paire de textes au dossier" "error": "Échec de l'ajout de la paire de texte au dossier"
}, },
"autoSave": "Sauvegarde automatique" "autoSave": "Sauvegarde automatique"
}, },
"dictionary": { "dictionary": {
"title": "Dictionnaire", "title": "Dictionnaire",
"description": "Rechercher des mots et des phrases avec des définitions détaillées et des exemples", "description": "Rechercher des mots et des expressions avec des définitions détaillées et des exemples",
"searchPlaceholder": "Entrez un mot ou une phrase à rechercher...", "searchPlaceholder": "Entrez un mot ou une expression à rechercher...",
"searching": "Recherche...", "searching": "Recherche...",
"search": "Rechercher", "search": "Rechercher",
"languageSettings": "Paramètres linguistiques", "languageSettings": "Paramètres de langue",
"queryLanguage": "Langue de requête", "queryLanguage": "Langue de requête",
"queryLanguageHint": "Quelle est la langue du mot/phrase que vous souhaitez rechercher", "queryLanguageHint": "Dans quelle langue est le mot/l'expression que vous voulez rechercher",
"definitionLanguage": "Langue de définition", "definitionLanguage": "Langue de définition",
"definitionLanguageHint": "Dans quelle langue souhaitez-vous voir les définitions", "definitionLanguageHint": "Dans quelle langue voulez-vous les définitions",
"otherLanguagePlaceholder": "Ou entrez une autre langue...", "otherLanguagePlaceholder": "Ou entrez une autre langue...",
"other": "Autre",
"currentSettings": "Paramètres actuels : Requête {queryLang}, Définition {definitionLang}", "currentSettings": "Paramètres actuels : Requête {queryLang}, Définition {definitionLang}",
"relookup": "Rechercher à nouveau", "relookup": "Rechercher à nouveau",
"saveToFolder": "Enregistrer dans le dossier", "saveToFolder": "Enregistrer dans le dossier",
"loading": "Chargement...", "loading": "Chargement...",
"noResults": "Aucun résultat trouvé", "noResults": "Aucun résultat trouvé",
"tryOtherWords": "Essayez d'autres mots ou phrases", "tryOtherWords": "Essayez d'autres mots ou expressions",
"welcomeTitle": "Bienvenue dans le dictionnaire", "welcomeTitle": "Bienvenue dans le dictionnaire",
"welcomeHint": "Entrez un mot ou une phrase dans la zone de recherche ci-dessus pour commencer", "welcomeHint": "Entrez un mot ou une expression dans la zone de recherche ci-dessus pour commencer la recherche",
"lookupFailed": "Recherche échouée, veuillez réessayer plus tard", "lookupFailed": "La recherche a échoué, veuillez réessayer plus tard",
"relookupSuccess": "Recherche répétée avec succès", "relookupSuccess": "Recherche effectuée avec succès",
"relookupFailed": "Nouvelle recherche de dictionnaire échouée", "relookupFailed": "La nouvelle recherche dans le dictionnaire a échoué",
"pleaseLogin": "Veuillez d'abord vous connecter", "pleaseLogin": "Veuillez vous connecter d'abord",
"pleaseCreateFolder": "Veuillez d'abord créer un dossier", "pleaseCreateFolder": "Veuillez créer un dossier d'abord",
"savedToFolder": "Enregistré dans le dossier : {folderName}", "savedToFolder": "Enregistré dans le dossier : {folderName}",
"saveFailed": "Échec de l'enregistrement, veuillez réessayer plus tard" "saveFailed": "Échec de l'enregistrement, veuillez réessayer plus tard",
"definition": "Définition",
"example": "Exemple"
},
"explore": {
"title": "Explorer",
"subtitle": "Découvrir les dossiers publics",
"searchPlaceholder": "Rechercher des dossiers publics...",
"loading": "Chargement...",
"noFolders": "Aucun dossier public trouvé",
"folderInfo": "{userName} • {totalPairs} paires",
"unknownUser": "Utilisateur inconnu",
"favorite": "Favori",
"unfavorite": "Retirer des favoris",
"pleaseLogin": "Veuillez vous connecter d'abord",
"sortByFavorites": "Trier par favoris",
"sortByFavoritesActive": "Annuler le tri par favoris"
},
"exploreDetail": {
"title": "Détails du dossier",
"createdBy": "Créé par : {name}",
"unknownUser": "Utilisateur inconnu",
"totalPairs": "Total des paires",
"favorites": "Favoris",
"createdAt": "Créé le",
"viewContent": "Voir le contenu",
"favorite": "Favori",
"unfavorite": "Retirer des favoris",
"favorited": "Ajouté aux favoris",
"unfavorited": "Retiré des favoris",
"pleaseLogin": "Veuillez vous connecter d'abord"
},
"favorites": {
"title": "Mes favoris",
"subtitle": "Les dossiers que vous avez mis en favoris",
"loading": "Chargement...",
"noFavorites": "Pas encore de favoris",
"folderInfo": "{userName} • {totalPairs} paires",
"unknownUser": "Utilisateur inconnu"
}, },
"user_profile": { "user_profile": {
"anonymous": "Anonyme", "anonymous": "Anonyme",
@@ -251,11 +346,12 @@
"displayName": "Nom d'affichage", "displayName": "Nom d'affichage",
"notSet": "Non défini", "notSet": "Non défini",
"memberSince": "Membre depuis", "memberSince": "Membre depuis",
"logout": "Déconnexion",
"folders": { "folders": {
"title": "Dossiers", "title": "Dossiers",
"noFolders": "Aucun dossier pour le moment", "noFolders": "Pas encore de dossiers",
"folderName": "Nom du dossier", "folderName": "Nom du dossier",
"totalPairs": "Nombre de paires", "totalPairs": "Total des paires",
"createdAt": "Créé le", "createdAt": "Créé le",
"actions": "Actions", "actions": "Actions",
"view": "Voir" "view": "Voir"

View File

@@ -1,48 +1,68 @@
{ {
"alphabet": { "alphabet": {
"chooseCharacters": "Seleziona i caratteri che desideri imparare", "chooseCharacters": "Seleziona i caratteri che vuoi imparare",
"japanese": "Kana giapponese", "chooseAlphabetHint": "Seleziona un alfabeto per iniziare a imparare",
"english": "Alfabeto inglese", "japanese": "Kana Giapponese",
"uyghur": "Alfabeto uiguro", "english": "Alfabeto Inglese",
"esperanto": "Alfabeto esperanto", "uyghur": "Alfabeto Uiguro",
"esperanto": "Alfabeto Esperanto",
"loading": "Caricamento...", "loading": "Caricamento...",
"loadFailed": "Caricamento fallito, riprova", "loadFailed": "Caricamento fallito, riprova",
"hideLetter": "Nascondi lettera", "hideLetter": "Nascondi Lettera",
"showLetter": "Mostra lettera", "showLetter": "Mostra Lettera",
"hideIPA": "Nascondi IPA", "hideIPA": "Nascondi IPA",
"showIPA": "Mostra IPA", "showIPA": "Mostra IPA",
"roman": "Romanizzazione", "roman": "Romanizzazione",
"letter": "Lettera", "letter": "Lettera",
"random": "Modalità casuale", "random": "Modalità Casuale",
"randomNext": "Successivo casuale" "randomNext": "Prossimo Casuale",
"previousLetter": "Lettera precedente",
"nextLetter": "Lettera successiva",
"keyboardHint": "Usa le frecce sinistra/destra o spazio per casuale, ESC per tornare indietro",
"swipeHint": "Usa le frecce sinistra/destra o scorri per navigare, ESC per tornare indietro"
}, },
"folders": { "folders": {
"title": "Cartelle", "title": "Cartelle",
"subtitle": "Gestisci le tue collezioni", "subtitle": "Gestisci le tue collezioni",
"newFolder": "Nuova cartella", "newFolder": "Nuova Cartella",
"creating": "Creazione...", "creating": "Creazione...",
"noFoldersYet": "Nessuna cartella ancora", "noFoldersYet": "Nessuna cartella ancora",
"folderInfo": "ID: {id} • {totalPairs} coppie", "folderInfo": "ID: {id} • {totalPairs} coppie",
"enterFolderName": "Inserisci nome cartella:", "enterFolderName": "Inserisci il nome della cartella:",
"confirmDelete": "Digita \"{name}\" per eliminare:" "confirmDelete": "Digita \"{name}\" per eliminare:",
"myFolders": "Le Mie Cartelle",
"publicFolders": "Cartelle Pubbliche",
"public": "Pubblica",
"private": "Privata",
"setPublic": "Imposta Pubblica",
"setPrivate": "Imposta Privata",
"publicFolderInfo": "{userName} • {totalPairs} coppie",
"searchPlaceholder": "Cerca cartelle pubbliche...",
"loading": "Caricamento...",
"noPublicFolders": "Nessuna cartella pubblica trovata",
"unknownUser": "Utente Sconosciuto",
"enterNewName": "Inserisci nuovo nome:",
"favorite": "Preferito",
"unfavorite": "Rimuovi dai preferiti",
"pleaseLogin": "Per favore accedi prima"
}, },
"folder_id": { "folder_id": {
"unauthorized": "Non sei il proprietario di questa cartella", "unauthorized": "Non sei il proprietario di questa cartella",
"back": "Indietro", "back": "Indietro",
"textPairs": "Coppie di testi", "textPairs": "Coppie di Testo",
"itemsCount": "{count} elementi", "itemsCount": "{count} elementi",
"memorize": "Memorizza", "memorize": "Memorizza",
"loadingTextPairs": "Caricamento coppie di testi...", "loadingTextPairs": "Caricamento coppie di testo...",
"noTextPairs": "Nessuna coppia di testi in questa cartella", "noTextPairs": "Nessuna coppia di testo in questa cartella",
"addNewTextPair": "Aggiungi nuova coppia di testi", "addNewTextPair": "Aggiungi Nuova Coppia di Testo",
"add": "Aggiungi", "add": "Aggiungi",
"updateTextPair": "Aggiorna coppia di testi", "updateTextPair": "Aggiorna Coppia di Testo",
"update": "Aggiorna", "update": "Aggiorna",
"text1": "Testo 1", "text1": "Testo 1",
"text2": "Testo 2", "text2": "Testo 2",
"language1": "Lingua 1", "language1": "Locale 1",
"language2": "Lingua 2", "language2": "Locale 2",
"enterLanguageName": "Inserisci il nome della lingua", "enterLanguageName": "Per favore inserisci il nome della lingua",
"edit": "Modifica", "edit": "Modifica",
"delete": "Elimina", "delete": "Elimina",
"permissionDenied": "Non hai il permesso di eseguire questa azione", "permissionDenied": "Non hai il permesso di eseguire questa azione",
@@ -55,8 +75,8 @@
} }
}, },
"home": { "home": {
"title": "Impara le lingue", "title": "Impara le Lingue",
"description": "Questo è un sito web molto utile che ti aiuta a imparare quasi tutte le lingue del mondo, incluse quelle costruite.", "description": "Ecco un sito molto utile per aiutarti a imparare quasi tutte le lingue del mondo, incluse quelle costruite.",
"explore": "Esplora", "explore": "Esplora",
"fortune": { "fortune": {
"quote": "Stay hungry, stay foolish.", "quote": "Stay hungry, stay foolish.",
@@ -64,15 +84,15 @@
}, },
"translator": { "translator": {
"name": "Traduttore", "name": "Traduttore",
"description": "Traduci in qualsiasi lingua e annota con l'alfabeto fonetico internazionale (IPA)" "description": "Traduci in qualsiasi lingua e annota con l'Alfabeto Fonetico Internazionale (IPA)"
}, },
"textSpeaker": { "textSpeaker": {
"name": "Lettore di testo", "name": "Lettore Testo",
"description": "Riconosce e legge il testo ad alta voce, supporta la riproduzione in loop e la regolazione della velocità" "description": "Riconosci e leggi il testo ad alta voce, supporta riproduzione in loop e regolazione della velocità"
}, },
"srtPlayer": { "srtPlayer": {
"name": "Lettore video SRT", "name": "Lettore Video SRT",
"description": "Riproduci video frase per frase basandoti su file di sottotitoli SRT per imitare la pronuncia dei madrelingua" "description": "Riproduci video frase per frase basandoti sui file di sottotitoli SRT per imitare la pronuncia dei madrelingua"
}, },
"alphabet": { "alphabet": {
"name": "Alfabeto", "name": "Alfabeto",
@@ -80,45 +100,73 @@
}, },
"memorize": { "memorize": {
"name": "Memorizza", "name": "Memorizza",
"description": "Lingua A verso lingua B, lingua B verso lingua A, supporta dettatura" "description": "Lingua A a Lingua B, Lingua B a Lingua A, supporta dettatura"
}, },
"dictionary": { "dictionary": {
"name": "Dizionario", "name": "Dizionario",
"description": "Cerca parole e frasi con definizioni dettagliate ed esempi" "description": "Cerca parole e frasi con definizioni dettagliate ed esempi"
}, },
"moreFeatures": { "moreFeatures": {
"name": "Altre funzionalità", "name": "Altre Funzionalità",
"description": "In sviluppo, rimani sintonizzato" "description": "In sviluppo, resta sintonizzato"
} }
}, },
"auth": { "auth": {
"title": "Autenticazione", "title": "Accedi",
"signUpTitle": "Registrati",
"signIn": "Accedi", "signIn": "Accedi",
"signUp": "Registrati", "signUp": "Registrati",
"email": "Email", "email": "Email",
"password": "Password", "password": "Password",
"confirmPassword": "Conferma password", "confirmPassword": "Conferma Password",
"name": "Nome", "name": "Nome",
"username": "Nome utente", "username": "Nome Utente",
"emailOrUsername": "Email o nome utente", "emailOrUsername": "Email o Nome Utente",
"signInButton": "Accedi", "signInButton": "Accedi",
"signUpButton": "Registrati", "signUpButton": "Registrati",
"noAccount": "Non hai un account?", "noAccount": "Non hai un account?",
"hasAccount": "Hai già un account?", "hasAccount": "Hai già un account?",
"signInWithGitHub": "Accedi con GitHub", "signInWithGitHub": "Accedi con GitHub",
"signUpWithGitHub": "Registrati con GitHub", "signUpWithGitHub": "Registrati con GitHub",
"invalidEmail": "Inserisci un indirizzo email valido", "invalidEmail": "Per favore inserisci un indirizzo email valido",
"passwordTooShort": "La password deve essere di almeno 8 caratteri", "passwordTooShort": "La password deve essere di almeno 8 caratteri",
"passwordsNotMatch": "Le password non corrispondono", "passwordsNotMatch": "Le password non corrispondono",
"nameRequired": "Inserisci il tuo nome", "nameRequired": "Per favore inserisci il tuo nome",
"usernameRequired": "Inserisci il tuo nome utente", "usernameRequired": "Per favore inserisci un nome utente",
"usernameTooShort": "Il nome utente deve essere di almeno 3 caratteri", "usernameTooShort": "Il nome utente deve essere di almeno 3 caratteri",
"usernameInvalid": "Il nome utente può contenere solo lettere, numeri e underscore", "usernameInvalid": "Il nome utente può contenere solo lettere, numeri e trattini bassi",
"emailRequired": "Inserisci la tua email", "emailRequired": "Per favore inserisci la tua email",
"identifierRequired": "Inserisci la tua email o nome utente", "identifierRequired": "Per favore inserisci la tua email o nome utente",
"passwordRequired": "Inserisci la tua password", "passwordRequired": "Per favore inserisci la tua password",
"confirmPasswordRequired": "Conferma la tua password", "confirmPasswordRequired": "Per favore conferma la tua password",
"loading": "Caricamento..." "loading": "Caricamento...",
"confirm": "Conferma",
"noAccountLink": "Non hai un account? Registrati",
"hasAccountLink": "Hai già un account? Accedi",
"usernamePlaceholder": "Nome utente",
"emailPlaceholder": "Indirizzo email",
"passwordPlaceholder": "Password",
"usernameOrEmailPlaceholder": "Nome utente o email",
"loginFailed": "Accesso fallito",
"signUpFailed": "Registrazione fallita",
"fillAllFields": "Per favore compila tutti i campi",
"enterCredentials": "Per favore inserisci nome utente e password",
"forgotPassword": "Password Dimenticata",
"forgotPasswordHint": "Inserisci il tuo indirizzo email e ti invieremo un link per reimpostare la password.",
"sendResetEmail": "Invia Email di Reset",
"resetPasswordFailed": "Impossibile inviare email di reset",
"resetPasswordEmailSent": "Email di reset inviata con successo",
"resetPasswordEmailSentHint": "Abbiamo inviato un link per reimpostare la password al tuo indirizzo email. Controlla la tua casella di posta.",
"checkYourEmail": "Controlla la tua Email",
"backToLogin": "Torna al Login",
"resetPassword": "Reimposta Password",
"newPassword": "Nuova Password",
"invalidToken": "Link Non Valido o Scaduto",
"invalidTokenHint": "Questo link per reimpostare la password non è valido o è scaduto. Richiedine uno nuovo.",
"requestNewToken": "Richiedi Nuovo Link di Reset",
"resetPasswordSuccess": "Password reimpostata con successo",
"resetPasswordSuccessTitle": "Reimpostazione Password Completata",
"resetPasswordSuccessHint": "La tua password è stata reimpostata con successo. Ora puoi accedere con la tua nuova password."
}, },
"memorize": { "memorize": {
"folder_selector": { "folder_selector": {
@@ -131,8 +179,8 @@
"next": "Successivo", "next": "Successivo",
"reverse": "Inverti", "reverse": "Inverti",
"dictation": "Dettatura", "dictation": "Dettatura",
"noTextPairs": "Nessuna coppia di testi disponibile", "noTextPairs": "Nessuna coppia di testo disponibile",
"disorder": "Disordine", "disorder": "Disordina",
"previous": "Precedente" "previous": "Precedente"
}, },
"page": { "page": {
@@ -140,45 +188,53 @@
} }
}, },
"navbar": { "navbar": {
"title": "learn-languages", "title": "impara-lingue",
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "Accedi", "sign_in": "Accedi",
"profile": "Profilo", "profile": "Profilo",
"folders": "Cartelle" "folders": "Cartelle",
"explore": "Esplora",
"favorites": "Preferiti"
}, },
"profile": { "profile": {
"myProfile": "Il mio profilo", "myProfile": "Il Mio Profilo",
"email": "Email: {email}", "email": "Email: {email}",
"logout": "Esci" "logout": "Esci"
}, },
"srt_player": { "srt_player": {
"uploadVideo": "Carica video", "uploadVideo": "Carica Video",
"uploadSubtitle": "Carica sottotitoli", "uploadSubtitle": "Carica Sottotitoli",
"pause": "Pausa", "pause": "Pausa",
"play": "Riproduci", "play": "Riproduci",
"previous": "Precedente", "previous": "Precedente",
"next": "Successivo", "next": "Successivo",
"restart": "Riavvia", "restart": "Riavvia",
"autoPause": "Pausa automatica ({enabled})", "autoPause": "Pausa Automatica ({enabled})",
"uploadVideoAndSubtitle": "Carica i file video e sottotitoli", "uploadVideoAndSubtitle": "Per favore carica file video e sottotitoli",
"uploadVideoFile": "Carica un file video", "uploadVideoFile": "Per favore carica il file video",
"uploadSubtitleFile": "Carica un file di sottotitoli", "uploadSubtitleFile": "Per favore carica il file sottotitoli",
"processingSubtitle": "Elaborazione file sottotitoli...", "processingSubtitle": "Elaborazione file sottotitoli...",
"needBothFiles": "Sono richiesti sia i file video che i sottotitoli per iniziare l'apprendimento", "needBothFiles": "Sono richiesti sia il file video che quello dei sottotitoli per iniziare a imparare",
"videoFile": "File video", "videoFile": "File Video",
"subtitleFile": "File sottotitoli", "subtitleFile": "File Sottotitoli",
"uploaded": "Caricato", "uploaded": "Caricato",
"notUploaded": "Non caricato", "notUploaded": "Non Caricato",
"upload": "Carica", "upload": "Carica",
"autoPauseStatus": "Pausa automatica: {enabled}", "uploadVideoButton": "Carica Video",
"uploadSubtitleButton": "Carica Sottotitoli",
"subtitleUploaded": "Sottotitoli Caricati ({count} voci)",
"subtitleNotUploaded": "Sottotitoli Non Caricati",
"autoPauseStatus": "Pausa Automatica: {enabled}",
"on": "Attivo", "on": "Attivo",
"off": "Disattivo", "off": "Disattivo",
"videoUploadFailed": "Caricamento video fallito", "videoUploadFailed": "Caricamento video fallito",
"subtitleUploadFailed": "Caricamento sottotitoli fallito" "subtitleUploadFailed": "Caricamento sottotitoli fallito",
"subtitleLoadSuccess": "Sottotitoli caricati con successo",
"subtitleLoadFailed": "Caricamento sottotitoli fallito"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "Genera IPA", "generateIPA": "Genera IPA",
"viewSavedItems": "Visualizza elementi salvati", "viewSavedItems": "Visualizza Elementi Salvati",
"confirmDeleteAll": "Sei sicuro di voler eliminare tutto? (S/N)" "confirmDeleteAll": "Sei sicuro di voler eliminare tutto? (S/N)"
}, },
"translator": { "translator": {
@@ -203,14 +259,14 @@
"enterLanguage": "Inserisci lingua", "enterLanguage": "Inserisci lingua",
"add_to_folder": { "add_to_folder": {
"notAuthenticated": "Non sei autenticato", "notAuthenticated": "Non sei autenticato",
"chooseFolder": "Scegli una cartella a cui aggiungere", "chooseFolder": "Scegli una Cartella a cui Aggiungere",
"noFolders": "Nessuna cartella trovata", "noFolders": "Nessuna cartella trovata",
"folderInfo": "{id}. {name}", "folderInfo": "{id}. {name}",
"close": "Chiudi", "close": "Chiudi",
"success": "Coppia di testi aggiunta alla cartella", "success": "Coppia di testo aggiunta alla cartella",
"error": "Impossibile aggiungere la coppia di testi alla cartella" "error": "Impossibile aggiungere coppia di testo alla cartella"
}, },
"autoSave": "Salvataggio automatico" "autoSave": "Salvataggio Automatico"
}, },
"dictionary": { "dictionary": {
"title": "Dizionario", "title": "Dizionario",
@@ -218,45 +274,85 @@
"searchPlaceholder": "Inserisci una parola o frase da cercare...", "searchPlaceholder": "Inserisci una parola o frase da cercare...",
"searching": "Ricerca...", "searching": "Ricerca...",
"search": "Cerca", "search": "Cerca",
"languageSettings": "Impostazioni lingua", "languageSettings": "Impostazioni Lingua",
"queryLanguage": "Lingua di interrogazione", "queryLanguage": "Lingua di Query",
"queryLanguageHint": "Quale è la lingua della parola/frase che vuoi cercare", "queryLanguageHint": "In che lingua è la parola/frase che vuoi cercare",
"definitionLanguage": "Lingua di definizione", "definitionLanguage": "Lingua delle Definizioni",
"definitionLanguageHint": "In quale lingua vuoi vedere le definizioni", "definitionLanguageHint": "In che lingua vuoi le definizioni",
"otherLanguagePlaceholder": "Oppure inserisci un'altra lingua...", "otherLanguagePlaceholder": "Oppure inserisci un'altra lingua...",
"currentSettings": "Impostazioni attuali: Interrogazione {queryLang}, Definizione {definitionLang}", "other": "Altro",
"currentSettings": "Impostazioni attuali: Query {queryLang}, Definizione {definitionLang}",
"relookup": "Ricerca di nuovo", "relookup": "Ricerca di nuovo",
"saveToFolder": "Salva nella cartella", "saveToFolder": "Salva nella cartella",
"loading": "Caricamento...", "loading": "Caricamento...",
"noResults": "Nessun risultato trovato", "noResults": "Nessun risultato trovato",
"tryOtherWords": "Prova altre parole o frasi", "tryOtherWords": "Prova altre parole o frasi",
"welcomeTitle": "Benvenuto nel dizionario", "welcomeTitle": "Benvenuto nel Dizionario",
"welcomeHint": "Inserisci una parola o frase nella casella di ricerca sopra per iniziare", "welcomeHint": "Inserisci una parola o frase nella casella di ricerca sopra per iniziare a cercare",
"lookupFailed": "Ricerca fallita, riprova più tardi", "lookupFailed": "Ricerca fallita, riprova più tardi",
"relookupSuccess": "Ricerca ripetuta con successo", "relookupSuccess": "Ricerca effettuata con successo",
"relookupFailed": "Nuova ricerca del dizionario fallita", "relookupFailed": "Ricerca dizionario fallita",
"pleaseLogin": "Accedi prima", "pleaseLogin": "Per favore accedi prima",
"pleaseCreateFolder": "Crea prima una cartella", "pleaseCreateFolder": "Per favore crea prima una cartella",
"savedToFolder": "Salvato nella cartella: {folderName}", "savedToFolder": "Salvato nella cartella: {folderName}",
"saveFailed": "Salvataggio fallito, riprova più tardi" "saveFailed": "Salvataggio fallito, riprova più tardi",
"definition": "Definizione",
"example": "Esempio"
},
"explore": {
"title": "Esplora",
"subtitle": "Scopri cartelle pubbliche",
"searchPlaceholder": "Cerca cartelle pubbliche...",
"loading": "Caricamento...",
"noFolders": "Nessuna cartella pubblica trovata",
"folderInfo": "{userName} • {totalPairs} coppie",
"unknownUser": "Utente Sconosciuto",
"favorite": "Preferito",
"unfavorite": "Rimuovi dai preferiti",
"pleaseLogin": "Per favore accedi prima",
"sortByFavorites": "Ordina per preferiti",
"sortByFavoritesActive": "Annulla ordinamento per preferiti"
},
"exploreDetail": {
"title": "Dettagli Cartella",
"createdBy": "Creata da: {name}",
"unknownUser": "Utente Sconosciuto",
"totalPairs": "Coppie Totali",
"favorites": "Preferiti",
"createdAt": "Creata Il",
"viewContent": "Visualizza Contenuto",
"favorite": "Preferito",
"unfavorite": "Rimuovi dai preferiti",
"favorited": "Aggiunto ai preferiti",
"unfavorited": "Rimosso dai preferiti",
"pleaseLogin": "Per favore accedi prima"
},
"favorites": {
"title": "I Miei Preferiti",
"subtitle": "Cartelle che hai aggiunto ai preferiti",
"loading": "Caricamento...",
"noFavorites": "Nessun preferito ancora",
"folderInfo": "{userName} • {totalPairs} coppie",
"unknownUser": "Utente Sconosciuto"
}, },
"user_profile": { "user_profile": {
"anonymous": "Anonimo", "anonymous": "Anonimo",
"email": "Email", "email": "Email",
"verified": "Verificato", "verified": "Verificato",
"unverified": "Non verificato", "unverified": "Non Verificato",
"accountInfo": "Informazioni account", "accountInfo": "Informazioni Account",
"userId": "ID utente", "userId": "ID Utente",
"username": "Nome utente", "username": "Nome Utente",
"displayName": "Nome visualizzato", "displayName": "Nome Visualizzato",
"notSet": "Non impostato", "notSet": "Non Impostato",
"memberSince": "Membro dal", "memberSince": "Membro Dal",
"logout": "Esci",
"folders": { "folders": {
"title": "Cartelle", "title": "Cartelle",
"noFolders": "Nessuna cartella ancora", "noFolders": "Nessuna cartella ancora",
"folderName": "Nome cartella", "folderName": "Nome Cartella",
"totalPairs": "Numero di coppie", "totalPairs": "Coppie Totali",
"createdAt": "Creato il", "createdAt": "Creata Il",
"actions": "Azioni", "actions": "Azioni",
"view": "Visualizza" "view": "Visualizza"
} }

View File

@@ -1,10 +1,11 @@
{ {
"alphabet": { "alphabet": {
"chooseCharacters": "学習したい文字を選択してください", "chooseCharacters": "学習したい文字を選択してください",
"chooseAlphabetHint": "学習を始めるアルファベットを選択してください",
"japanese": "日本語仮名", "japanese": "日本語仮名",
"english": "英語アルファベット", "english": "英語アルファベット",
"uyghur": "ウイグル文字", "uyghur": "ウイグル語アルファベット",
"esperanto": "エスペラント文字", "esperanto": "エスペラント語アルファベット",
"loading": "読み込み中...", "loading": "読み込み中...",
"loadFailed": "読み込みに失敗しました。もう一度お試しください", "loadFailed": "読み込みに失敗しました。もう一度お試しください",
"hideLetter": "文字を非表示", "hideLetter": "文字を非表示",
@@ -14,23 +15,42 @@
"roman": "ローマ字", "roman": "ローマ字",
"letter": "文字", "letter": "文字",
"random": "ランダムモード", "random": "ランダムモード",
"randomNext": "ランダム次へ" "randomNext": "ランダム次へ",
"previousLetter": "前の文字",
"nextLetter": "次の文字",
"keyboardHint": "左右の矢印キーまたはスペースキーでランダム移動、ESCで戻る",
"swipeHint": "左右の矢印キーまたはスワイプで移動、ESCで戻る"
}, },
"folders": { "folders": {
"title": "フォルダー", "title": "フォルダー",
"subtitle": "コレクションを管理", "subtitle": "コレクションを管理",
"newFolder": "新規フォルダー", "newFolder": "新規フォルダー",
"creating": "作成中...", "creating": "作成中...",
"noFoldersYet": "フォルダーがありません", "noFoldersYet": "まだフォルダーがありません",
"folderInfo": "ID: {id} • {totalPairs}", "folderInfo": "ID: {id} • {totalPairs} ペア",
"enterFolderName": "フォルダー名を入力:", "enterFolderName": "フォルダー名を入力:",
"confirmDelete": "削除するには「{name}」と入力してください:" "confirmDelete": "削除するには「{name}」と入力してください:",
"myFolders": "マイフォルダー",
"publicFolders": "公開フォルダー",
"public": "公開",
"private": "非公開",
"setPublic": "公開に設定",
"setPrivate": "非公開に設定",
"publicFolderInfo": "{userName} • {totalPairs} ペア",
"searchPlaceholder": "公開フォルダーを検索...",
"loading": "読み込み中...",
"noPublicFolders": "公開フォルダーが見つかりません",
"unknownUser": "不明なユーザー",
"enterNewName": "新しい名前を入力:",
"favorite": "お気に入り",
"unfavorite": "お気に入り解除",
"pleaseLogin": "まずログインしてください"
}, },
"folder_id": { "folder_id": {
"unauthorized": "あなたはこのフォルダーの所有者ではありません", "unauthorized": "このフォルダーの所有者ではありません",
"back": "戻る", "back": "戻る",
"textPairs": "テキストペア", "textPairs": "テキストペア",
"itemsCount": "{count}項目", "itemsCount": "{count} 項目",
"memorize": "暗記", "memorize": "暗記",
"loadingTextPairs": "テキストペアを読み込み中...", "loadingTextPairs": "テキストペアを読み込み中...",
"noTextPairs": "このフォルダーにはテキストペアがありません", "noTextPairs": "このフォルダーにはテキストペアがありません",
@@ -45,34 +65,34 @@
"enterLanguageName": "言語名を入力してください", "enterLanguageName": "言語名を入力してください",
"edit": "編集", "edit": "編集",
"delete": "削除", "delete": "削除",
"permissionDenied": "この操作を実行する権限がありません", "permissionDenied": "このアクションを実行する権限がありません",
"error": { "error": {
"update": "この項目を更新する権限がありません。", "update": "この項目を更新する権限がありません。",
"delete": "この項目を削除する権限がありません。", "delete": "この項目を削除する権限がありません。",
"add": "このフォルダーに項目を追加する権限がありません。", "add": "このフォルダーに項目を追加する権限がありません。",
"rename": "このフォルダーを変更する権限がありません。", "rename": "このフォルダーの名前を変更する権限がありません。",
"deleteFolder": "このフォルダーを削除する権限がありません。" "deleteFolder": "このフォルダーを削除する権限がありません。"
} }
}, },
"home": { "home": {
"title": "言語を学ぶ", "title": "言語を学ぶ",
"description": "これは、人工言語を含む世界のほぼすべての言語を学ぶのに役立つ非常に便利なウェブサイトです。", "description": "ここは世界のほぼすべての言語(人工言語を含む)を学ぶのに役立つ非常に便利なウェブサイトです。",
"explore": "探索", "explore": "探索",
"fortune": { "fortune": {
"quote": "Stay hungry, stay foolish.", "quote": "Stay hungry, stay foolish.",
"author": "— スティーブ・ジョブズ" "author": "— Steve Jobs"
}, },
"translator": { "translator": {
"name": "翻訳", "name": "翻訳",
"description": "任意の言語に翻訳し、国際音声記号IPAで注釈を付けます" "description": "あらゆる言語に翻訳し、国際音声記号IPAで注釈を付けます"
}, },
"textSpeaker": { "textSpeaker": {
"name": "テキストスピーカー", "name": "テキストスピーカー",
"description": "テキストを認識して音読します。ループ再生と速度調整をサポート" "description": "テキストを認識して読み上げ、ループ再生と速度調整をサポート"
}, },
"srtPlayer": { "srtPlayer": {
"name": "SRTビデオプレーヤー", "name": "SRTビデオプレーヤー",
"description": "SRT字幕ファイルに基づいてビデオを文ごとに再生し、ネイティブスピーカーの発音を模倣します" "description": "SRT字幕ファイルに基づいて文ごとにビデオを再生し、ネイティブスピーカーの発音を模倣"
}, },
"alphabet": { "alphabet": {
"name": "アルファベット", "name": "アルファベット",
@@ -80,32 +100,33 @@
}, },
"memorize": { "memorize": {
"name": "暗記", "name": "暗記",
"description": "言語Aから言語B、言語Bから言語A、ディクテーションをサポート" "description": "言語Aから言語B、言語Bから言語A、書き取りをサポート"
}, },
"dictionary": { "dictionary": {
"name": "辞書", "name": "辞書",
"description": "単語やフレーズを調べ、詳細な定義と例を表示" "description": "詳細な定義と例文で単語やフレーズを検索"
}, },
"moreFeatures": { "moreFeatures": {
"name": "その他の機能", "name": "その他の機能",
"description": "開発中です。お楽しみに" "description": "開発中お楽しみに"
} }
}, },
"auth": { "auth": {
"title": "認証", "title": "サインイン",
"signIn": "ログイン", "signUpTitle": "新規登録",
"signIn": "サインイン",
"signUp": "新規登録", "signUp": "新規登録",
"email": "メールアドレス", "email": "メールアドレス",
"password": "パスワード", "password": "パスワード",
"confirmPassword": "パスワード確認", "confirmPassword": "パスワード確認",
"name": "名前", "name": "名前",
"username": "ユーザー名", "username": "ユーザー名",
"emailOrUsername": "メールアドレスまたはユーザー名", "emailOrUsername": "メールアドレスまたはユーザー名",
"signInButton": "ログイン", "signInButton": "サインイン",
"signUpButton": "新規登録", "signUpButton": "新規登録",
"noAccount": "アカウントをお持ちでないですか?", "noAccount": "アカウントをお持ちでないですか?",
"hasAccount": "すでにアカウントをお持ちですか?", "hasAccount": "すでにアカウントをお持ちですか?",
"signInWithGitHub": "GitHubでログイン", "signInWithGitHub": "GitHubでサインイン",
"signUpWithGitHub": "GitHubで新規登録", "signUpWithGitHub": "GitHubで新規登録",
"invalidEmail": "有効なメールアドレスを入力してください", "invalidEmail": "有効なメールアドレスを入力してください",
"passwordTooShort": "パスワードは8文字以上である必要があります", "passwordTooShort": "パスワードは8文字以上である必要があります",
@@ -113,12 +134,39 @@
"nameRequired": "名前を入力してください", "nameRequired": "名前を入力してください",
"usernameRequired": "ユーザー名を入力してください", "usernameRequired": "ユーザー名を入力してください",
"usernameTooShort": "ユーザー名は3文字以上である必要があります", "usernameTooShort": "ユーザー名は3文字以上である必要があります",
"usernameInvalid": "ユーザー名には数字アンダースコアのみ使用できます", "usernameInvalid": "ユーザー名には文字、数字アンダースコアのみ使用できます",
"emailRequired": "メールアドレスを入力してください", "emailRequired": "メールアドレスを入力してください",
"identifierRequired": "メールアドレスまたはユーザー名を入力してください", "identifierRequired": "メールアドレスまたはユーザー名を入力してください",
"passwordRequired": "パスワードを入力してください", "passwordRequired": "パスワードを入力してください",
"confirmPasswordRequired": "パスワード確認)を入力してください", "confirmPasswordRequired": "パスワード確認してください",
"loading": "読み込み中..." "loading": "読み込み中...",
"confirm": "確認",
"noAccountLink": "アカウントをお持ちでないですか? 新規登録",
"hasAccountLink": "すでにアカウントをお持ちですか? サインイン",
"usernamePlaceholder": "ユーザー名",
"emailPlaceholder": "メールアドレス",
"passwordPlaceholder": "パスワード",
"usernameOrEmailPlaceholder": "ユーザー名またはメールアドレス",
"loginFailed": "ログインに失敗しました",
"signUpFailed": "新規登録に失敗しました",
"fillAllFields": "すべてのフィールドに入力してください",
"enterCredentials": "ユーザー名とパスワードを入力してください",
"forgotPassword": "パスワードをお忘れですか",
"forgotPasswordHint": "メールアドレスを入力してください。パスワードリセット用のリンクをお送りします。",
"sendResetEmail": "リセットメールを送信",
"resetPasswordFailed": "リセットメールの送信に失敗しました",
"resetPasswordEmailSent": "リセットメールを送信しました",
"resetPasswordEmailSentHint": "パスワードリセット用のリンクをメールでお送りしました。受信トレイをご確認ください。",
"checkYourEmail": "メールをご確認ください",
"backToLogin": "ログインに戻る",
"resetPassword": "パスワードをリセット",
"newPassword": "新しいパスワード",
"invalidToken": "無効または期限切れのリンク",
"invalidTokenHint": "このパスワードリセットリンクは無効または期限切れです。新しいものをリクエストしてください。",
"requestNewToken": "新しいリセットリンクをリクエスト",
"resetPasswordSuccess": "パスワードのリセットに成功しました",
"resetPasswordSuccessTitle": "パスワードリセット完了",
"resetPasswordSuccessHint": "パスワードが正常にリセットされました。新しいパスワードでログインできます。"
}, },
"memorize": { "memorize": {
"folder_selector": { "folder_selector": {
@@ -127,12 +175,12 @@
"folderInfo": "{id}. {name} ({count})" "folderInfo": "{id}. {name} ({count})"
}, },
"memorize": { "memorize": {
"answer": "答", "answer": "答",
"next": "次へ", "next": "次へ",
"reverse": "逆順", "reverse": "逆順",
"dictation": "ディクテーション", "dictation": "書き取り",
"noTextPairs": "利用可能なテキストペアがありません", "noTextPairs": "利用可能なテキストペアがありません",
"disorder": "ランダム", "disorder": "シャッフル",
"previous": "前へ" "previous": "前へ"
}, },
"page": { "page": {
@@ -142,13 +190,15 @@
"navbar": { "navbar": {
"title": "learn-languages", "title": "learn-languages",
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "ログイン", "sign_in": "サインイン",
"profile": "プロフィール", "profile": "プロフィール",
"folders": "フォルダー" "folders": "フォルダー",
"explore": "探索",
"favorites": "お気に入り"
}, },
"profile": { "profile": {
"myProfile": "マイプロフィール", "myProfile": "マイプロフィール",
"email": "メールアドレス: {email}", "email": "メール: {email}",
"logout": "ログアウト" "logout": "ログアウト"
}, },
"srt_player": { "srt_player": {
@@ -170,21 +220,27 @@
"uploaded": "アップロード済み", "uploaded": "アップロード済み",
"notUploaded": "未アップロード", "notUploaded": "未アップロード",
"upload": "アップロード", "upload": "アップロード",
"uploadVideoButton": "ビデオをアップロード",
"uploadSubtitleButton": "字幕をアップロード",
"subtitleUploaded": "字幕をアップロード済み ({count} エントリ)",
"subtitleNotUploaded": "字幕がアップロードされていません",
"autoPauseStatus": "自動一時停止: {enabled}", "autoPauseStatus": "自動一時停止: {enabled}",
"on": "オン", "on": "オン",
"off": "オフ", "off": "オフ",
"videoUploadFailed": "ビデオのアップロードに失敗しました", "videoUploadFailed": "ビデオのアップロードに失敗しました",
"subtitleUploadFailed": "字幕のアップロードに失敗しました" "subtitleUploadFailed": "字幕のアップロードに失敗しました",
"subtitleLoadSuccess": "字幕の読み込みに成功しました",
"subtitleLoadFailed": "字幕の読み込みに失敗しました"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "IPAを生成", "generateIPA": "IPAを生成",
"viewSavedItems": "保存済みアイテムを表示", "viewSavedItems": "保存済み項目を表示",
"confirmDeleteAll": "本当にすべて削除しすか? (Y/N)" "confirmDeleteAll": "すべて削除してもよろしいですか? (Y/N)"
}, },
"translator": { "translator": {
"detectLanguage": "言語を検出", "detectLanguage": "言語を検出",
"generateIPA": "IPAを生成", "generateIPA": "ipaを生成",
"translateInto": "翻訳", "translateInto": "翻訳",
"chinese": "中国語", "chinese": "中国語",
"english": "英語", "english": "英語",
"french": "フランス語", "french": "フランス語",
@@ -207,38 +263,77 @@
"noFolders": "フォルダーが見つかりません", "noFolders": "フォルダーが見つかりません",
"folderInfo": "{id}. {name}", "folderInfo": "{id}. {name}",
"close": "閉じる", "close": "閉じる",
"success": "テキストペアフォルダーに追加ました", "success": "テキストペアフォルダーに追加されました",
"error": "テキストペアの追加に失敗しました" "error": "テキストペアをフォルダーに追加できませんでした"
}, },
"autoSave": "自動保存" "autoSave": "自動保存"
}, },
"dictionary": { "dictionary": {
"title": "辞書", "title": "辞書",
"description": "詳細な定義と例で単語やフレーズを検索", "description": "詳細な定義と例で単語やフレーズを検索",
"searchPlaceholder": "検索する単語やフレーズを入力...", "searchPlaceholder": "検索する単語やフレーズを入力...",
"searching": "検索中...", "searching": "検索中...",
"search": "検索", "search": "検索",
"languageSettings": "言語設定", "languageSettings": "言語設定",
"queryLanguage": "クエリ言語", "queryLanguage": "クエリ言語",
"queryLanguageHint": "検索する単語/フレーズの言語", "queryLanguageHint": "検索したい単語/フレーズの言語",
"definitionLanguage": "定義言語", "definitionLanguage": "定義言語",
"definitionLanguageHint": "定義を表示する言語", "definitionLanguageHint": "定義を表示する言語",
"otherLanguagePlaceholder": "またはの言語を入力...", "otherLanguagePlaceholder": "またはの言語を入力...",
"currentSettings": "現在の設定:クエリ {queryLang}、定義 {definitionLang}", "other": "その他",
"currentSettings": "現在の設定: クエリ {queryLang}, 定義 {definitionLang}",
"relookup": "再検索", "relookup": "再検索",
"saveToFolder": "フォルダに保存", "saveToFolder": "フォルダに保存",
"loading": "読み込み中...", "loading": "読み込み中...",
"noResults": "結果が見つかりません", "noResults": "結果が見つかりません",
"tryOtherWords": "の単語やフレーズを試してください", "tryOtherWords": "の単語やフレーズを試してください",
"welcomeTitle": "辞書へようこそ", "welcomeTitle": "辞書へようこそ",
"welcomeHint": "上の検索ボックスに単語やフレーズを入力して検索を始", "welcomeHint": "上の検索ボックスに単語やフレーズを入力して検索を始めましょう",
"lookupFailed": "検索に失敗しました。後でもう一度お試しください", "lookupFailed": "検索に失敗しました。後でもう一度お試しください",
"relookupSuccess": "再検索しました", "relookupSuccess": "再検索に成功しました",
"relookupFailed": "辞書の再検索に失敗しました", "relookupFailed": "辞書の再検索に失敗しました",
"pleaseLogin": "まずログインしてください", "pleaseLogin": "まずログインしてください",
"pleaseCreateFolder": "まずフォルダを作成してください", "pleaseCreateFolder": "まずフォルダを作成してください",
"savedToFolder": "フォルダに保存しました{folderName}", "savedToFolder": "フォルダに保存しました: {folderName}",
"saveFailed": "保存に失敗しました。後でもう一度お試しください" "saveFailed": "保存に失敗しました。後でもう一度お試しください",
"definition": "定義",
"example": "例文"
},
"explore": {
"title": "探索",
"subtitle": "公開フォルダーを発見",
"searchPlaceholder": "公開フォルダーを検索...",
"loading": "読み込み中...",
"noFolders": "公開フォルダーが見つかりません",
"folderInfo": "{userName} • {totalPairs} ペア",
"unknownUser": "不明なユーザー",
"favorite": "お気に入り",
"unfavorite": "お気に入り解除",
"pleaseLogin": "まずログインしてください",
"sortByFavorites": "お気に入り順に並べ替え",
"sortByFavoritesActive": "お気に入り順の並べ替えを解除"
},
"exploreDetail": {
"title": "フォルダー詳細",
"createdBy": "作成者: {name}",
"unknownUser": "不明なユーザー",
"totalPairs": "合計ペア数",
"favorites": "お気に入り",
"createdAt": "作成日",
"viewContent": "コンテンツを表示",
"favorite": "お気に入り",
"unfavorite": "お気に入り解除",
"favorited": "お気に入りに追加しました",
"unfavorited": "お気に入りから削除しました",
"pleaseLogin": "まずログインしてください"
},
"favorites": {
"title": "マイお気に入り",
"subtitle": "お気に入りに追加したフォルダー",
"loading": "読み込み中...",
"noFavorites": "まだお気に入りがありません",
"folderInfo": "{userName} • {totalPairs} ペア",
"unknownUser": "不明なユーザー"
}, },
"user_profile": { "user_profile": {
"anonymous": "匿名", "anonymous": "匿名",
@@ -251,13 +346,14 @@
"displayName": "表示名", "displayName": "表示名",
"notSet": "未設定", "notSet": "未設定",
"memberSince": "登録日", "memberSince": "登録日",
"logout": "ログアウト",
"folders": { "folders": {
"title": "フォルダー", "title": "フォルダー",
"noFolders": "フォルダーがありません", "noFolders": "まだフォルダーがありません",
"folderName": "フォルダー名", "folderName": "フォルダー名",
"totalPairs": "テキストペア数", "totalPairs": "合計ペア数",
"createdAt": "作成日", "createdAt": "作成日",
"actions": "操作", "actions": "アクション",
"view": "表示" "view": "表示"
} }
} }

View File

@@ -1,12 +1,13 @@
{ {
"alphabet": { "alphabet": {
"chooseCharacters": "학습할 문자를 선택하세요", "chooseCharacters": "배우고 싶은 문자를 선택하세요",
"chooseAlphabetHint": "학습을 시작할 알파벳을 선택하세요",
"japanese": "일본어 가나", "japanese": "일본어 가나",
"english": "영 알파벳", "english": "영 알파벳",
"uyghur": "위구르 문자", "uyghur": "위구르어 알파벳",
"esperanto": "에스페란토 문자", "esperanto": "에스페란토 알파벳",
"loading": "로딩 중...", "loading": "로딩 중...",
"loadFailed": "로딩 실패, 다시 시도해 주세요", "loadFailed": "로딩 실패, 다시 시도해주세요",
"hideLetter": "문자 숨기기", "hideLetter": "문자 숨기기",
"showLetter": "문자 표시", "showLetter": "문자 표시",
"hideIPA": "IPA 숨기기", "hideIPA": "IPA 숨기기",
@@ -14,17 +15,36 @@
"roman": "로마자 표기", "roman": "로마자 표기",
"letter": "문자", "letter": "문자",
"random": "무작위 모드", "random": "무작위 모드",
"randomNext": "무작위 다음" "randomNext": "무작위 다음",
"previousLetter": "이전 문자",
"nextLetter": "다음 문자",
"keyboardHint": "왼쪽/오른쪽 화살표 키 또는 스페이스바로 무작위, ESC로 뒤로가기",
"swipeHint": "왼쪽/오른쪽 화살표 키 또는 스와이프로 탐색, ESC로 뒤로가기"
}, },
"folders": { "folders": {
"title": "폴더", "title": "폴더",
"subtitle": "컬렉션 관리", "subtitle": "컬렉션 관리",
"newFolder": "새 폴더", "newFolder": "새 폴더",
"creating": "생성 중...", "creating": "생성 중...",
"noFoldersYet": "폴더가 없습니다", "noFoldersYet": "아직 폴더가 없습니다",
"folderInfo": "ID: {id} • {totalPairs}쌍", "folderInfo": "ID: {id} • {totalPairs} 쌍",
"enterFolderName": "폴더 이름 입력:", "enterFolderName": "폴더 이름 입력:",
"confirmDelete": "삭제하려면 \"{name}\"을(를) 입력하세요:" "confirmDelete": "삭제하려면 \"{name}\"을(를) 입력하세요:",
"myFolders": "내 폴더",
"publicFolders": "공개 폴더",
"public": "공개",
"private": "비공개",
"setPublic": "공개로 설정",
"setPrivate": "비공개로 설정",
"publicFolderInfo": "{userName} • {totalPairs} 쌍",
"searchPlaceholder": "공개 폴더 검색...",
"loading": "로딩 중...",
"noPublicFolders": "공개 폴더를 찾을 수 없습니다",
"unknownUser": "알 수 없는 사용자",
"enterNewName": "새 이름 입력:",
"favorite": "즐겨찾기",
"unfavorite": "즐겨찾기 해제",
"pleaseLogin": "먼저 로그인해주세요"
}, },
"folder_id": { "folder_id": {
"unauthorized": "이 폴더의 소유자가 아닙니다", "unauthorized": "이 폴더의 소유자가 아닙니다",
@@ -36,39 +56,39 @@
"noTextPairs": "이 폴더에 텍스트 쌍이 없습니다", "noTextPairs": "이 폴더에 텍스트 쌍이 없습니다",
"addNewTextPair": "새 텍스트 쌍 추가", "addNewTextPair": "새 텍스트 쌍 추가",
"add": "추가", "add": "추가",
"updateTextPair": "텍스트 쌍 업데이트", "updateTextPair": "텍스트 쌍 수정",
"update": "업데이트", "update": "수정",
"text1": "텍스트 1", "text1": "텍스트 1",
"text2": "텍스트 2", "text2": "텍스트 2",
"language1": "언어 1", "language1": "로캘 1",
"language2": "언어 2", "language2": "로캘 2",
"enterLanguageName": "언어 이름을 입력하세요", "enterLanguageName": "언어 이름을 입력하세요",
"edit": "편집", "edit": "편집",
"delete": "삭제", "delete": "삭제",
"permissionDenied": "이 작업을 수행할 권한이 없습니다", "permissionDenied": "이 작업을 수행할 권한이 없습니다",
"error": { "error": {
"update": "이 항목을 업데이트할 권한이 없습니다.", "update": "이 항목을 수정할 권한이 없습니다.",
"delete": "이 항목을 삭제할 권한이 없습니다.", "delete": "이 항목을 삭제할 권한이 없습니다.",
"add": "이 폴더에 항목을 추가할 권한이 없습니다.", "add": "이 폴더에 항목을 추가할 권한이 없습니다.",
"rename": "이 폴더 이름을 변경할 권한이 없습니다.", "rename": "이 폴더 이름을 변경할 권한이 없습니다.",
"deleteFolder": "이 폴더를 삭제할 권한이 없습니다." "deleteFolder": "이 폴더를 삭제할 권한이 없습니다."
} }
}, },
"home": { "home": {
"title": "언어 학습", "title": "언어 배우기",
"description": "인공 언어를 포함하여 세상의 거의 모든 언어를 배우는 데 도움이 되는 매우 유용한 웹사이트입니다.", "description": "세계의 거의 모든 언어(인공어 포함)를 배우는 데 도움이 되는 매우 유용한 웹사이트입니다.",
"explore": "탐색", "explore": "탐색",
"fortune": { "fortune": {
"quote": "Stay hungry, stay foolish.", "quote": "Stay hungry, stay foolish.",
"author": "— 스티브 잡스" "author": "— Steve Jobs"
}, },
"translator": { "translator": {
"name": "번역기", "name": "번역기",
"description": "모든 언어로 번역하고 국제 음성 기호(IPA)로 주석 추가" "description": "모든 언어로 번역하고 국제 음성 기호(IPA)로 주석 달기"
}, },
"textSpeaker": { "textSpeaker": {
"name": "텍스트 스피커", "name": "텍스트 스피커",
"description": "텍스트 인식하고 읽어줍니다. 반복 재생 및 속도 조 지원" "description": "텍스트 인식 및 낭독, 반복 재생 및 속도 조 지원"
}, },
"srtPlayer": { "srtPlayer": {
"name": "SRT 비디오 플레이어", "name": "SRT 비디오 플레이어",
@@ -84,15 +104,16 @@
}, },
"dictionary": { "dictionary": {
"name": "사전", "name": "사전",
"description": "단어와 구문을 조회하고 자세한 정의와 예제 제공" "description": "상세한 정의와 예문으로 단어 및 구문 검색"
}, },
"moreFeatures": { "moreFeatures": {
"name": "더 많은 기능", "name": "더 많은 기능",
"description": "개발 중, 기대해 주세요" "description": "개발 중, 기대해주세요"
} }
}, },
"auth": { "auth": {
"title": "인", "title": "로그인",
"signUpTitle": "회원가입",
"signIn": "로그인", "signIn": "로그인",
"signUp": "회원가입", "signUp": "회원가입",
"email": "이메일", "email": "이메일",
@@ -113,12 +134,39 @@
"nameRequired": "이름을 입력하세요", "nameRequired": "이름을 입력하세요",
"usernameRequired": "사용자명을 입력하세요", "usernameRequired": "사용자명을 입력하세요",
"usernameTooShort": "사용자명은 최소 3자 이상이어야 합니다", "usernameTooShort": "사용자명은 최소 3자 이상이어야 합니다",
"usernameInvalid": "사용자명은 문, 숫자, 밑줄만 포함할 수 있습니다", "usernameInvalid": "사용자명은 문, 숫자, 밑줄만 포함할 수 있습니다",
"emailRequired": "이메일을 입력하세요", "emailRequired": "이메일을 입력하세요",
"identifierRequired": "이메일 또는 사용자명을 입력하세요", "identifierRequired": "이메일 또는 사용자명을 입력하세요",
"passwordRequired": "비밀번호를 입력하세요", "passwordRequired": "비밀번호를 입력하세요",
"confirmPasswordRequired": "비밀번호 확인을 입력하세요", "confirmPasswordRequired": "비밀번호 확인하세요",
"loading": "로딩 중..." "loading": "로딩 중...",
"confirm": "확인",
"noAccountLink": "계정이 없으신가요? 회원가입",
"hasAccountLink": "이미 계정이 있으신가요? 로그인",
"usernamePlaceholder": "사용자명",
"emailPlaceholder": "이메일 주소",
"passwordPlaceholder": "비밀번호",
"usernameOrEmailPlaceholder": "사용자명 또는 이메일",
"loginFailed": "로그인 실패",
"signUpFailed": "회원가입 실패",
"fillAllFields": "모든 필드를 입력하세요",
"enterCredentials": "사용자명과 비밀번호를 입력하세요",
"forgotPassword": "비밀번호 찾기",
"forgotPasswordHint": "이메일 주소를 입력하시면 비밀번호 재설정 링크를 보내드립니다.",
"sendResetEmail": "재설정 이메일 보내기",
"resetPasswordFailed": "재설정 이메일 전송 실패",
"resetPasswordEmailSent": "재설정 이메일이 전송되었습니다",
"resetPasswordEmailSentHint": "비밀번호 재설정 링크를 이메일로 보냈습니다. 받은 편지함을 확인해주세요.",
"checkYourEmail": "이메일을 확인하세요",
"backToLogin": "로그인으로 돌아가기",
"resetPassword": "비밀번호 재설정",
"newPassword": "새 비밀번호",
"invalidToken": "유효하지 않거나 만료된 링크",
"invalidTokenHint": "이 비밀번호 재설정 링크는 유효하지 않거나 만료되었습니다. 새로 요청해 주세요.",
"requestNewToken": "새 재설정 링크 요청",
"resetPasswordSuccess": "비밀번호 재설정 성공",
"resetPasswordSuccessTitle": "비밀번호 재설정 완료",
"resetPasswordSuccessHint": "비밀번호가 성공적으로 재설정되었습니다. 새 비밀번호로 로그인할 수 있습니다."
}, },
"memorize": { "memorize": {
"folder_selector": { "folder_selector": {
@@ -136,7 +184,7 @@
"previous": "이전" "previous": "이전"
}, },
"page": { "page": {
"unauthorized": "이 폴더에 액세스할 권한이 없습니다" "unauthorized": "이 폴더에 접근할 권한이 없습니다"
} }
}, },
"navbar": { "navbar": {
@@ -144,7 +192,9 @@
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "로그인", "sign_in": "로그인",
"profile": "프로필", "profile": "프로필",
"folders": "폴더" "folders": "폴더",
"explore": "탐색",
"favorites": "즐겨찾기"
}, },
"profile": { "profile": {
"myProfile": "내 프로필", "myProfile": "내 프로필",
@@ -158,7 +208,7 @@
"play": "재생", "play": "재생",
"previous": "이전", "previous": "이전",
"next": "다음", "next": "다음",
"restart": "처음부터", "restart": "다시 시작",
"autoPause": "자동 일시정지 ({enabled})", "autoPause": "자동 일시정지 ({enabled})",
"uploadVideoAndSubtitle": "비디오와 자막 파일을 업로드하세요", "uploadVideoAndSubtitle": "비디오와 자막 파일을 업로드하세요",
"uploadVideoFile": "비디오 파일을 업로드하세요", "uploadVideoFile": "비디오 파일을 업로드하세요",
@@ -170,21 +220,27 @@
"uploaded": "업로드됨", "uploaded": "업로드됨",
"notUploaded": "업로드되지 않음", "notUploaded": "업로드되지 않음",
"upload": "업로드", "upload": "업로드",
"uploadVideoButton": "비디오 업로드",
"uploadSubtitleButton": "자막 업로드",
"subtitleUploaded": "자막 업로드됨 ({count}개 항목)",
"subtitleNotUploaded": "자막 업로드되지 않음",
"autoPauseStatus": "자동 일시정지: {enabled}", "autoPauseStatus": "자동 일시정지: {enabled}",
"on": "켜기", "on": "켜기",
"off": "끄기", "off": "끄기",
"videoUploadFailed": "비디오 업로드 실패", "videoUploadFailed": "비디오 업로드 실패",
"subtitleUploadFailed": "자막 업로드 실패" "subtitleUploadFailed": "자막 업로드 실패",
"subtitleLoadSuccess": "자막 로드 성공",
"subtitleLoadFailed": "자막 로드 실패"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "IPA 생성", "generateIPA": "IPA 생성",
"viewSavedItems": "저장된 항목 보기", "viewSavedItems": "저장된 항목 보기",
"confirmDeleteAll": "정말 모두 삭제하시겠습니까? (Y/N)" "confirmDeleteAll": "모든 것을 삭제하시겠습니까? (Y/N)"
}, },
"translator": { "translator": {
"detectLanguage": "언어 감지", "detectLanguage": "언어 감지",
"generateIPA": "IPA 생성", "generateIPA": "IPA 생성",
"translateInto": "번역", "translateInto": "번역할 언어",
"chinese": "중국어", "chinese": "중국어",
"english": "영어", "english": "영어",
"french": "프랑스어", "french": "프랑스어",
@@ -207,38 +263,77 @@
"noFolders": "폴더를 찾을 수 없습니다", "noFolders": "폴더를 찾을 수 없습니다",
"folderInfo": "{id}. {name}", "folderInfo": "{id}. {name}",
"close": "닫기", "close": "닫기",
"success": "텍스트 쌍 폴더에 추가했습니다", "success": "텍스트 쌍 폴더에 추가",
"error": "텍스트 쌍 추가 실패" "error": "폴더에 텍스트 쌍 추가 실패"
}, },
"autoSave": "자동 저장" "autoSave": "자동 저장"
}, },
"dictionary": { "dictionary": {
"title": "사전", "title": "사전",
"description": "상세한 정의와 예로 단어 및 구문 검색", "description": "상세한 정의와 예문으로 단어 및 구문 검색",
"searchPlaceholder": "검색할 단어나 구문을 입력하세요...", "searchPlaceholder": "검색할 단어나 구문을 입력하세요...",
"searching": "검색 중...", "searching": "검색 중...",
"search": "검색", "search": "검색",
"languageSettings": "언어 설정", "languageSettings": "언어 설정",
"queryLanguage": "쿼리 언어", "queryLanguage": "질의 언어",
"queryLanguageHint": "검색하려는 단어/구문의 언어", "queryLanguageHint": "검색 단어/구문의 언어",
"definitionLanguage": "정의 언어", "definitionLanguage": "정의 언어",
"definitionLanguageHint": "정의를 표시할 언어", "definitionLanguageHint": "정의를 표시할 언어",
"otherLanguagePlaceholder": "또는 다른 언어 입력하세요...", "otherLanguagePlaceholder": "또는 다른 언어 입력...",
"currentSettings": "현재 설정: 쿼리 {queryLang}, 정의 {definitionLang}", "other": "기타",
"relookup": "재검색", "currentSettings": "현재 설정: 질의 {queryLang}, 정의 {definitionLang}",
"relookup": "다시 검색",
"saveToFolder": "폴더에 저장", "saveToFolder": "폴더에 저장",
"loading": "로 중...", "loading": "로 중...",
"noResults": "결과를 찾을 수 없습니다", "noResults": "검색 결과 없음",
"tryOtherWords": "다른 단어나 구문을 시도하세요", "tryOtherWords": "다른 단어나 구문을 시도하세요",
"welcomeTitle": "사전에 오신 것을 환영합니다", "welcomeTitle": "사전에 오신 것을 환영합니다",
"welcomeHint": "위 검색 상자에 단어나 구문을 입력하여 검색을 시작하세요", "welcomeHint": "위 검색 상자에 단어나 구문을 입력하여 검색을 시작하세요",
"lookupFailed": "검색 실패, 나중에 다시 시도하세요", "lookupFailed": "검색 실패, 나중에 다시 시도하세요",
"relookupSuccess": "재검색했습니다", "relookupSuccess": "다시 검색 성공",
"relookupFailed": "사전 검색 실패", "relookupFailed": "사전 다시 검색 실패",
"pleaseLogin": "먼저 로그인하세요", "pleaseLogin": "먼저 로그인하세요",
"pleaseCreateFolder": "먼저 폴더를 만드세요", "pleaseCreateFolder": "먼저 폴더를 생성하세요",
"savedToFolder": "폴더에 저장됨: {folderName}", "savedToFolder": "폴더에 저장됨: {folderName}",
"saveFailed": "저장 실패, 나중에 다시 시도하세요" "saveFailed": "저장 실패, 나중에 다시 시도하세요",
"definition": "정의",
"example": "예문"
},
"explore": {
"title": "탐색",
"subtitle": "공개 폴더 발견",
"searchPlaceholder": "공개 폴더 검색...",
"loading": "로딩 중...",
"noFolders": "공개 폴더를 찾을 수 없습니다",
"folderInfo": "{userName} • {totalPairs} 쌍",
"unknownUser": "알 수 없는 사용자",
"favorite": "즐겨찾기",
"unfavorite": "즐겨찾기 해제",
"pleaseLogin": "먼저 로그인해주세요",
"sortByFavorites": "즐겨찾기순 정렬",
"sortByFavoritesActive": "즐겨찾기순 정렬 해제"
},
"exploreDetail": {
"title": "폴더 상세",
"createdBy": "생성자: {name}",
"unknownUser": "알 수 없는 사용자",
"totalPairs": "총 쌍",
"favorites": "즐겨찾기",
"createdAt": "생성일",
"viewContent": "내용 보기",
"favorite": "즐겨찾기",
"unfavorite": "즐겨찾기 해제",
"favorited": "즐겨찾기됨",
"unfavorited": "즐겨찾기 해제됨",
"pleaseLogin": "먼저 로그인해주세요"
},
"favorites": {
"title": "내 즐겨찾기",
"subtitle": "즐겨찾기한 폴더",
"loading": "로딩 중...",
"noFavorites": "아직 즐겨찾기가 없습니다",
"folderInfo": "{userName} • {totalPairs} 쌍",
"unknownUser": "알 수 없는 사용자"
}, },
"user_profile": { "user_profile": {
"anonymous": "익명", "anonymous": "익명",
@@ -251,11 +346,12 @@
"displayName": "표시 이름", "displayName": "표시 이름",
"notSet": "설정되지 않음", "notSet": "설정되지 않음",
"memberSince": "가입일", "memberSince": "가입일",
"logout": "로그아웃",
"folders": { "folders": {
"title": "폴더", "title": "폴더",
"noFolders": "폴더가 없습니다", "noFolders": "아직 폴더가 없습니다",
"folderName": "폴더 이름", "folderName": "폴더 이름",
"totalPairs": "텍스트 쌍 수", "totalPairs": "총 쌍",
"createdAt": "생성일", "createdAt": "생성일",
"actions": "작업", "actions": "작업",
"view": "보기" "view": "보기"

View File

@@ -1,128 +1,176 @@
{ {
"alphabet": { "alphabet": {
"chooseCharacters": "ئۆگىنەرلىك ھەرپلەرنى تاللاڭ", "chooseCharacters": "ئۆگەنمەكچى بولغان ھەرپلەرنى تاللاڭ",
"japanese": "ياپونىيە كانا", "chooseAlphabetHint": "ئۆگىنىشنى باشلاش ئۈچۈن بىر ئېلىپبە تاللاڭ",
"english": "ئىنگلىز ئېلىپبې", "japanese": "ياپون يېزىقى",
"uyghur": ۇيغۇر ئېلىپبېسى", "english": ىنگلىز ئېلىپبەسى",
"esperanto": "ئېسپېرانتو ئېلىپبېسى", "uyghur": "ئۇيغۇر ئېلىپبەسى",
"loading": "چىقىرىۋېتىلىۋاتىدۇ...", "esperanto": "ئېسپېرانتو ئېلىپبەسى",
"loadFailed": "چىقىرىش مەغلۇب بولدى، قايتا سىناڭ", "loading": "يۈكلىنىۋاتىدۇ...",
"hideLetter": "ھەرپنى يوشۇرۇش", "loadFailed": "يۈكلەش مەغلۇپ بولدى، قايتا سىناڭ",
"showLetter": "ھەرپنى كۆرسىتىش", "hideLetter": "ھەرپنى يوشۇر",
"hideIPA": "IPA نى يوشۇرۇش", "showLetter": "ھەرپنى كۆرسەت",
"showIPA": "IPA نى كۆرسىتىش", "hideIPA": "IPA نى يوشۇر",
"roman": "روماللاشتۇرۇش", "showIPA": "IPA نى كۆرسەت",
"roman": "لاتىن يېزىقى",
"letter": "ھەرپ", "letter": "ھەرپ",
"random": "ئىختىيارىي ھالەت", "random": "ئىختىيارىي ھالەت",
"randomNext": "ئىختىيارىي كېيىنكى" "randomNext": "ئىختىيارىي كېيىنكى",
"previousLetter": "ئالدىنقى ھەرپ",
"nextLetter": "كېيىنكى ھەرپ",
"keyboardHint": "سول/ئوڭ يا ئوق كۇنۇپكىلىرىنى ياكى بوشلۇق كۇنۇپكىسىنى ئىختىيارىي ئالماشتۇرۇش ئۈچۈن ئىشلىتىڭ، ESC قايتىش ئۈچۈن",
"swipeHint": "سول/ئوڭ يا ئوق كۇنۇپكىلىرىنى ياكى سىيرىشنى ئىشلىتىپ يۆنىلىڭ، ESC قايتىش ئۈچۈن"
}, },
"folders": { "folders": {
"title": "قىسقۇچلار", "title": "قىسقۇچلار",
"subtitle": "توپلىمىڭىزنى باشقۇرۇڭ", "subtitle": "يىغىپ ساقلاشلىرىڭىزنى باشقۇرۇڭ",
"newFolder": "يېڭى قىسقۇچ", "newFolder": "يېڭى قىسقۇچ",
"creating": "قۇرۇۋاتىدۇ...", "creating": "قۇرۇۋاتىدۇ...",
"noFoldersYet": "قىسقۇچ يوق", "noFoldersYet": "تېخى قىسقۇچ يوق",
"folderInfo": ود: {id} • {totalPairs} جۈپ", "folderInfo": ىملىك: {id} • {totalPairs} جۈپ",
"enterFolderName": "قىسقۇچ نامىنى كىرگۈزۈڭ:", "enterFolderName": "قىسقۇچ ئاتىنى كىرگۈزۈڭ:",
"confirmDelete": "ئۆچۈرۈش ئۈچۈن «{name}» نى كىرگۈزۈڭ:" "confirmDelete": "ئۆچۈرۈش ئۈچۈن \"{name}\" نى كىرگۈزۈڭ:",
"myFolders": "قىسقۇچلىرىم",
"publicFolders": "ئاممىۋى قىسقۇچلار",
"public": "ئاممىۋى",
"private": "شەخسىي",
"setPublic": "ئاممىۋى قىلىپ تەڭشە",
"setPrivate": "شەخسىي قىلىپ تەڭشە",
"publicFolderInfo": "{userName} • {totalPairs} جۈپ",
"searchPlaceholder": "ئاممىۋى قىسقۇچلارنى ئىزدەڭ...",
"loading": "يۈكلىنىۋاتىدۇ...",
"noPublicFolders": "ئاممىۋى قىسقۇچ تېپىلمىدى",
"unknownUser": "نامەلۇم ئىشلەتكۈچى",
"enterNewName": "يېڭى ئات كىرگۈزۈڭ:",
"favorite": "يىغىپ ساقلا",
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ"
}, },
"folder_id": { "folder_id": {
"unauthorized": "سىز بۇ قىسقۇچنىڭ ئىگىسى ئەمەس", "unauthorized": "بۇ قىسقۇچنىڭ ئىگىسى ئەمەسسىز",
"back": "كەينىگە", "back": "قايتىش",
"textPairs": "تېكىست جۈپلىرى", "textPairs": "تېكىست جۈپلىرى",
"itemsCount": "{count} تۈر", "itemsCount": "{count} تۈر",
"memorize": "ئەستە ساقلاش", "memorize": "يادلاش",
"loadingTextPairs": "تېكىست جۈپلىرى چىقىرىۋېتىلىۋاتىدۇ...", "loadingTextPairs": "تېكىست جۈپلىرى يۈكلىنىۋاتىدۇ...",
"noTextPairs": "بۇ قىسقۇچتا تېكىست جۈپى يوق", "noTextPairs": "بۇ قىسقۇچتا تېكىست جۈپى يوق",
"addNewTextPair": "يېڭى تېكىست جۈپى قوشۇڭ", "addNewTextPair": "يېڭى تېكىست جۈپى قوشۇش",
"add": "قوشۇش", "add": "قوشۇش",
"updateTextPair": "تېكىست جۈپىنى يېڭىلاڭ", "updateTextPair": "تېكىست جۈپىنى يېڭىلاش",
"update": "يېڭىلاش", "update": "يېڭىلاش",
"text1": "تېكىست 1", "text1": "تېكىست 1",
"text2": "تېكىست 2", "text2": "تېكىست 2",
"language1": "تىل 1", "language1": "تىل 1",
"language2": "تىل 2", "language2": "تىل 2",
"enterLanguageName": "تىل نامىنى كىرگۈزۈڭ", "enterLanguageName": "تىل ئاتىنى كىرگۈزۈڭ",
"edit": "تەھرىرلەش", "edit": "تەھرىرلەش",
"delete": "ئۆچۈرۈش", "delete": "ئۆچۈرۈش",
"permissionDenied": "بۇ مەشغۇلاتنى ئىجرا قىلىش ھوقۇقىڭىز يوق", "permissionDenied": "بۇ مەشغۇلاتنى ئېلىپ بېرىش ھوقۇقىڭىز يوق",
"error": { "error": {
"update": "بۇ تۈرنى يېڭىلاش ھوقۇقىڭىز يوق.", "update": "بۇ تۈرنى يېڭىلاش ھوقۇقىڭىز يوق.",
"delete": "بۇ تۈرنى ئۆچۈرۈش ھوقۇقىڭىز يوق.", "delete": "بۇ تۈرنى ئۆچۈرۈش ھوقۇقىڭىز يوق.",
"add": "بۇ قىسقۇچقا تۈر قوشۇش ھوقۇقىڭىز يوق.", "add": "بۇ قىسقۇچقا تۈر قوشۇش ھوقۇقىڭىز يوق.",
"rename": "بۇ قىسقۇچنىڭ نامىنى ئۆزگەرتىش ھوقۇقىڭىز يوق.", "rename": "بۇ قىسقۇچنىڭ ئاتىنى ئۆزگەرتىش ھوقۇقىڭىز يوق.",
"deleteFolder": "بۇ قىسقۇچنى ئۆچۈرۈش ھوقۇقىڭىز يوق." "deleteFolder": "بۇ قىسقۇچنى ئۆچۈرۈش ھوقۇقىڭىز يوق."
} }
}, },
"home": { "home": {
"title": "تىل ئۆگىنىڭ", "title": "تىل ئۆگىنىش",
"description": "بۇ سىزنى دۇنيادىكى ھەممە تىلنى، جۈملىدىن سۈنئىي تىللارنىمۇ ئۆگىنىشىڭىزغا ياردەم بېرىدىغان ناھايىتى پايدىلىق تور بېكەت.", "description": "بۇ دۇنيادىكى almost ھەر بىر تىلنى، جۈملىدىن سۈنئىي تىللارنى ئۆگىنىشىڭىزگە ياردەم بېرىدىغان ئىنتايىن قوللىنىشلىق تور بېكەت.",
"explore": "ئىزدىنىش", "explore": "ئىزدىنىش",
"fortune": { "fortune": {
"quote": "Stay hungry, stay foolish.", "quote": "ئاچ قورساق، ئەخمەق بولۇپ تۇرۇڭ.",
"author": "— ستىۋ جوۋبس" "author": "— Steve Jobs"
}, },
"translator": { "translator": {
"name": "تەرجىمە", "name": "تەرجىمان",
"description": "خالىغان تىلغا تەرجىمە قىلىپ خەلقئارالىق فونېتىك ئېلىپبې (IPA) بىلەن ئىزاھاتلاش" "description": "ھەر قانداق تىلغا تەرجىمە قىلىڭ ۋە خەلقئارالىق فونېتىكىلىق ئېلىپبە (IPA) بىلەن ئىزاھلاڭ"
}, },
"textSpeaker": { "textSpeaker": {
"name": "تېكىست ئوقۇغۇچى", "name": "تېكىست ئوقۇغۇچى",
"description": "تېكىستنى پەرقلەندۈرۈپ ئوقىيدۇ، دەۋرىي ئوقۇش ۋە سۈرئەت تەڭشەشنى قوللايدۇ" "description": "تېكىستنى تونۇپ ۋە ئۈنلۈك ئوقۇپ بېرىدۇ، دەۋرىي قويۇش ۋە سۈرئەت تەڭشەشنى قوللايدۇ"
}, },
"srtPlayer": { "srtPlayer": {
"name": "SRT سىن ئوپىراتورى", "name": "SRT ۋىدېئو قويغۇچ",
"description": "SRT خەت ئاستى فايلى ئاساسىدا سىننى جۈملە-جۈملە قويۇپ، يەرلىك ئىخچام ئاۋازنى ئىمتىلايدۇ" "description": "SRT تر پودكاست ھۆججەتلىرىگە ئاساسەن ۋىدېئولارنى جۈمە بويىچە قويۇپ، ئانا تىللىقلارنىڭ تەلەپپۇزىنى دوراڭ"
}, },
"alphabet": { "alphabet": {
"name": "ئېلىپبې", "name": "ئېلىپبە",
"description": "ئېلىپبېدىن يېڭى تىل ئۆگىنىشنى باشلاڭ" "description": "يېڭى بىر تىلنى ئېلىپبەدىن باشلاپ ئۆگىنىڭ"
}, },
"memorize": { "memorize": {
"name": "ئەستە ساقلاش", "name": "يادلاش",
"description": "تىل A دىن تىل B غا، تىل B دىن تىل A غا، دىكتات قوللايدۇ" "description": "تىل A دىن تىل B گە، تىل B دىن تىل A غا، دىكتات قىلىشنى قوللايدۇ"
}, },
"dictionary": { "dictionary": {
"name": "لۇغەت", "name": "لۇغەت",
"description": "سۆز ۋە ئىبارە ئىزدەپ، تەپسىلىي ئىزاھات ۋە مىساللار بىلەن تەمىنلەيدۇ" "description": "سۆزلەر ۋە ئىبارىلەرنى تەپسىلىي ئېنىقلىما ۋە مىساللار بىلەن ئىزدەڭ"
}, },
"moreFeatures": { "moreFeatures": {
"name": "تېخىمۇ كۆپ ئىقتىدار", "name": "تېخىمۇ كۆپ ئىقتىدارلار",
"description": "ئىشلەۋاتىدۇ، كۈتكۈن بولۇڭ" "description": "تەرەققىيات ئاستىدا، دىققەت قىلىپ تۇرۇڭ"
} }
}, },
"auth": { "auth": {
"title": "دەلىللەش", "title": "كىرىش",
"signUpTitle": "تىزىملىتىش",
"signIn": "كىرىش", "signIn": "كىرىش",
"signUp": "تىزىملىتىش", "signUp": "تىزىملىتىش",
"email": "ئېلخەت", "email": "ئېلخەت",
"password": "ئىم", "password": "پارول",
"confirmPassword": "ئىمنى جەزملەش", "confirmPassword": "پارولنى جەزىملەڭ",
"name": "نام", "name": "ئىسىم",
"username": "ئىشلەتكۈچى نامى", "username": "ئىشلەتكۈچى ئاتى",
"emailOrUsername": "ئېلخەت ياكى ئىشلەتكۈچى نامى", "emailOrUsername": "ئېلخەت ياكى ئىشلەتكۈچى ئاتى",
"signInButton": "كىرىش", "signInButton": "كىرىش",
"signUpButton": "تىزىملىتىش", "signUpButton": "تىزىملىتىش",
"noAccount": "ھېساباتىڭىز يوقمۇ؟", "noAccount": "ھېساباتىڭىز يوقمۇ؟",
"hasAccount": "ھېساباتىڭىز بارمۇ؟", "hasAccount": "ھېساباتىڭىز بارمۇ؟",
"signInWithGitHub": "GitHub بىلەن كىرىڭ", "signInWithGitHub": "GitHub بىلەن كىرىش",
"signUpWithGitHub": "GitHub بىلەن تىزىملىتىڭ", "signUpWithGitHub": "GitHub بىلەن تىزىملىتىش",
"invalidEmail": ىناۋەتلىك ئېلخەت ئادرېسى كىرگۈزۈڭ", "invalidEmail": ۈنۈملۈك ئېلخەت ئادرېسى كىرگۈزۈڭ",
"passwordTooShort": "ئىم كەم دېگەندە 8 ھەرپتىن تۇرۇشى كېرەك", "passwordTooShort": "پارول ئەڭ ئاز 8 ھەرپ بولۇشى كېرەك",
"passwordsNotMatch": "ئىم ماس كەلمەيدۇ", "passwordsNotMatch": "پاروللار ماس كەلمەيدۇ",
"nameRequired": "نامىڭىزنى كىرگۈزۈڭ", "nameRequired": "ئىسىمىڭىزنى كىرگۈزۈڭ",
"usernameRequired": "ئىشلەتكۈچى نامىڭىزنى كىرگۈزۈڭ", "usernameRequired": "ئىشلەتكۈچى ئاتىنى كىرگۈزۈڭ",
"usernameTooShort": "ئىشلەتكۈچى نامى كەم دېگەندە 3 ھەرپتىن تۇرۇشى كېرەك", "usernameTooShort": "ئىشلەتكۈچى ئاتى ئەڭ ئاز 3 ھەرپ بولۇشى كېرەك",
"usernameInvalid": "ئىشلەتكۈچى نامى پەقەت ھەرپ، سان ۋە ئاستى سىزىقنى ئۆز ئىچىگە ئالىدۇ", "usernameInvalid": "ئىشلەتكۈچى ئاتى پەقەت ھەرپ، سان ۋە ئاستى سىزىقنى ئۆز ئىچىگە ئالىدۇ",
"emailRequired": "ئېلخىتىڭىزنى كىرگۈزۈڭ", "emailRequired": "ئېلخەت كىرگۈزۈڭ",
"identifierRequired": "ئېلخەت ياكى ئىشلەتكۈچى نامىڭىزنى كىرگۈزۈڭ", "identifierRequired": "ئېلخەت ياكى ئىشلەتكۈچى ئاتىنى كىرگۈزۈڭ",
"passwordRequired": "ئىمىڭىزنى كىرگۈزۈڭ", "passwordRequired": "پارول كىرگۈزۈڭ",
"confirmPasswordRequired": "ئىمىڭىزنى جەزملەڭ", "confirmPasswordRequired": "پارولنى جەزىملەڭ",
"loading": "چىقىرىۋېتىلىۋاتىدۇ..." "loading": "يۈكلىنىۋاتىدۇ...",
"confirm": "جەزىملەش",
"noAccountLink": "ھېساباتىڭىز يوقمۇ؟ تىزىملىتىڭ",
"hasAccountLink": "ھېساباتىڭىز بارمۇ؟ كىرىڭ",
"usernamePlaceholder": "ئىشلەتكۈچى ئاتى",
"emailPlaceholder": "ئېلخەت ئادرېسى",
"passwordPlaceholder": "پارول",
"usernameOrEmailPlaceholder": "ئىشلەتكۈچى ئاتى ياكى ئېلخەت",
"loginFailed": "كىرىش مەغلۇپ بولدى",
"signUpFailed": "تىزىملىتىش مەغلۇپ بولدى",
"fillAllFields": "ھەممە بۆلەكلەرنى تولدۇرۇڭ",
"enterCredentials": "ئىشلەتكۈچى ئاتى ۋە پارول كىرگۈزۈڭ",
"forgotPassword": "پارولنى ئۇنتۇپ قالدىڭىزمۇ",
"forgotPasswordHint": "ئېلخەت ئادرېسىڭىزنى كىرگۈزۈڭ، بىز سىزگە پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسى ئەۋەتىمىز.",
"sendResetEmail": "ئەسلىگە قايتۇرۇش ئېلخېتى ئەۋەتىش",
"resetPasswordFailed": "ئەسلىگە قايتۇرۇش ئېلخېتى ئەۋەتىش مەغلۇپ بولدى",
"resetPasswordEmailSent": "ئەسلىگە قايتۇرۇش ئېلخېتى مۇۋەپپەقىيەتلىك ئەۋەتىلدى",
"resetPasswordEmailSentHint": "پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسىنى ئېلخەت ئادرېسىڭىزغا ئەۋەتتۇق. ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ.",
"checkYourEmail": "ئېلخېتىڭىزنى تەكشۈرۈڭ",
"backToLogin": "كىرىشكە قايتىش",
"resetPassword": "پارولنى ئەسلىگە قايتۇرۇش",
"newPassword": "يېڭى پارول",
"invalidToken": "ئۇلانما ئىناۋەتسىز ياكى ۋاقتى ئۆتكەن",
"invalidTokenHint": "بۇ پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسى ئىناۋەتسىز ياكى ۋاقتى ئۆتكەن. يېڭىدىن سوراڭ.",
"requestNewToken": "يېڭى ئەسلىگە قايتۇرۇش ئۇلانمىسى سوراش",
"resetPasswordSuccess": "پارول مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى",
"resetPasswordSuccessTitle": "پارول ئەسلىگە قايتۇرۇش تاماملاندى",
"resetPasswordSuccessHint": "پارولىڭىز مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى. يېڭى پارول بىلەن كىرسىڭىز بولىدۇ."
}, },
"memorize": { "memorize": {
"folder_selector": { "folder_selector": {
"selectFolder": "قىسقۇچ تاللاڭ", "selectFolder": "بىر قىسقۇچ تاللاڭ",
"noFolders": "قىسقۇچ تېپىلمىدى", "noFolders": "قىسقۇچ تېپىلمىدى",
"folderInfo": "{id}. {name} ({count})" "folderInfo": "{id}. {name} ({count})"
}, },
@@ -131,63 +179,71 @@
"next": "كېيىنكى", "next": "كېيىنكى",
"reverse": "تەتۈر", "reverse": "تەتۈر",
"dictation": "دىكتات", "dictation": "دىكتات",
"noTextPairs": "ئىشلەتكىلى بولىدىغان تېكىست جۈپى يوق", "noTextPairs": "تېكىست جۈپى يوق",
"disorder": "بەت ئارلاش", "disorder": "قالايمىقانلاشتۇرۇش",
"previous": ىلگىرىكى" "previous": الدىنقى"
}, },
"page": { "page": {
"unauthorized": "بۇ قىسقۇچنى زىيارەت قىلىشقا ھوقۇقىڭىز يوق" "unauthorized": "بۇ قىسقۇچنى زىيارەت قىلىش ھوقۇقىڭىز يوق"
} }
}, },
"navbar": { "navbar": {
"title": "تىل ئۆگىنىش", "title": "تىل-ئۆگىنىش",
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "كىرىش", "sign_in": "كىرىش",
"profile": "پروفىل", "profile": "شەخسىي ئۇچۇر",
"folders": "قىسقۇچلار" "folders": "قىسقۇچلار",
"explore": "ئىزدىنىش",
"favorites": "يىغىپ ساقلانغانلار"
}, },
"profile": { "profile": {
"myProfile": "مېنىڭ پروفىلىم", "myProfile": "شەخسىي ئۇچۇرۇم",
"email": "ئېلخەت: {email}", "email": "ئېلخەت: {email}",
"logout": "چىقىش" "logout": "چىكىنىش"
}, },
"srt_player": { "srt_player": {
"uploadVideo": "سىن يۈكلەڭ", "uploadVideo": "ۋىدېئو يۈكلەش",
"uploadSubtitle": "خەت ئاستى يۈكلەڭ", "uploadSubtitle": "تر پودكاست يۈكلەش",
"pause": "ۋاقىتلىق توختىتىش", "pause": "ۋاقىتلىق توختىتىش",
"play": "قويۇش", "play": "قويۇش",
"previous": ىلگىرىكى", "previous": الدىنقى",
"next": "كېيىنكى", "next": "كېيىنكى",
"restart": "قايتا باشلاش", "restart": "قايتا باشلاش",
"autoPause": "ئاپتوماتىك توختىتىش ({enabled})", "autoPause": "ئاپتوماتىك توختىتىش ({enabled})",
"uploadVideoAndSubtitle": "سىن ھەم خەت ئاستى فايلىنى يۈكلەڭ", "uploadVideoAndSubtitle": "ۋىدېئو ۋە تر پودكاست ھۆججەتلىرىنى يۈكلەڭ",
"uploadVideoFile": "سىن فايلى يۈكلەڭ", "uploadVideoFile": "ۋىدېئو ھۆججىتى يۈكلەڭ",
"uploadSubtitleFile": "خەت ئاستى فايلى يۈكلەڭ", "uploadSubtitleFile": "تر پودكاست ھۆججىتى يۈكلەڭ",
"processingSubtitle": "خەت ئاستى فايلى بىر تەرەپ قىلىۋاتىدۇ...", "processingSubtitle": "تر پودكاست ھۆججىتى بىر تەرەپ قىلىنىۋاتىدۇ...",
"needBothFiles": "ئۆگىنىشنى باشلاش ئۈچۈن سىن ھەم خەت ئاستى فايلىنىڭ ھەممىسى لازىم", "needBothFiles": "ئۆگىنىشنى باشلاش ئۈچۈن ۋىدېئو ۋە تر پودكاست ھۆججەتلىرى كېرەك",
"videoFile": "سىن فايلى", "videoFile": "ۋىدېئو ھۆججىتى",
"subtitleFile": "خەت ئاستى فايلى", "subtitleFile": "تر پودكاست ھۆججىتى",
"uploaded": "يۈكلەندى", "uploaded": "يۈكلەندى",
"notUploaded": "يۈكلەنمىدى", "notUploaded": "يۈكلەنمىدى",
"upload": "يۈكلەش", "upload": "يۈكلەش",
"uploadVideoButton": "ۋىدېئو يۈكلەش",
"uploadSubtitleButton": "تر پودكاست يۈكلەش",
"subtitleUploaded": "تر پودكاست يۈكلەندى ({count} تۈر)",
"subtitleNotUploaded": "تر پودكاست يۈكلەنمىدى",
"autoPauseStatus": "ئاپتوماتىك توختىتىش: {enabled}", "autoPauseStatus": "ئاپتوماتىك توختىتىش: {enabled}",
"on": "ئوچۇق", "on": "ئوچۇق",
"off": "تاقاق", "off": "تاقاق",
"videoUploadFailed": "سىن يۈكلەش مەغلۇب بولدى", "videoUploadFailed": "ۋىدېئو يۈكلەش مەغلۇپ بولدى",
"subtitleUploadFailed": "خەت ئاستى يۈكلەش مەغلۇب بولدى" "subtitleUploadFailed": "تر پودكاست يۈكلەش مەغلۇپ بولدى",
"subtitleLoadSuccess": "تر پودكاست مۇۋەپپەقىيەتلىك يۈكلەندى",
"subtitleLoadFailed": "تر پودكاست يۈكلەش مەغلۇپ بولدى"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "IPA ھاسىل قىلىش", "generateIPA": "IPA ھاسىل قىلىش",
"viewSavedItems": "ساقلانغان تۈرلەرنى كۆرۈش", "viewSavedItems": "ساقلانغان تۈرلەرنى كۆرۈش",
"confirmDeleteAll": "ھەممىنى ئۆچۈرەمسىز؟ (H/Y)" "confirmDeleteAll": "ھەممىنى ئۆچۈرەمسىز؟ (Y/N)"
}, },
"translator": { "translator": {
"detectLanguage": "تىل پەرقلەندۈرۈش", "detectLanguage": "تىلنى تونۇش",
"generateIPA": "IPA ھاسىل قىلىش", "generateIPA": "ipa ھاسىل قىلىش",
"translateInto": "تەرجىمە قىلىش", "translateInto": "تەرجىمە قىلىش",
"chinese": "خەنزۇچە", "chinese": "خەنزۇچە",
"english": "ئىنگلىزچە", "english": "ئىنگلىزچە",
"french": "فرانسۇزچە", "french": ىرانسۇزچە",
"german": "گېرمانچە", "german": "گېرمانچە",
"italian": "ئىتاليانچە", "italian": "ئىتاليانچە",
"japanese": "ياپونچە", "japanese": "ياپونچە",
@@ -196,68 +252,108 @@
"russian": "رۇسچە", "russian": "رۇسچە",
"spanish": "ئىسپانچە", "spanish": "ئىسپانچە",
"other": "باشقا", "other": "باشقا",
"translating": "تەرجىمە قىلىۋاتىدۇ...", "translating": "تەرجىمە قىلىنىۋاتىدۇ...",
"translate": "تەرجىمە قىلىش", "translate": "تەرجىمە قىلىش",
"inputLanguage": "بىر تىل كىرگۈزۈڭ.", "inputLanguage": "بىر تىل كىرگۈزۈڭ.",
"history": "تارىخ", "history": "تارىخ",
"enterLanguage": "تىل كىرگۈزۈڭ", "enterLanguage": "تىل كىرگۈزۈڭ",
"add_to_folder": { "add_to_folder": {
"notAuthenticated": "دەلىتلەنمىدىڭىز", "notAuthenticated": "تىزىملىتىلمىدىڭىز",
"chooseFolder": "قوشۇلىدىغان قىسقۇچنى تاللاڭ", "chooseFolder": "قوشۇش ئۈچۈن قىسقۇچ تاللاڭ",
"noFolders": "قىسقۇچ تېپىلمىدى", "noFolders": "قىسقۇچ تېپىلمىدى",
"folderInfo": "{id}. {name}", "folderInfo": "{id}. {name}",
"close": "تاقاش", "close": "تاقاش",
"success": "تېكىست جۈپى قىسقۇچقا قوشۇلدى", "success": "تېكىست جۈپى قىسقۇچقا قوشۇلدى",
"error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇب بولدى" "error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇپ بولدى"
}, },
"autoSave": "ئاپتوماتىك ساقلاش" "autoSave": "ئاپتوماتىك ساقلاش"
}, },
"dictionary": { "dictionary": {
"title": "لۇغەت", "title": "لۇغەت",
"description": "تەپسىلىي ئىزاھات ۋە مىساللار بىلەن سۆز ۋە ئىبارە ئىزدەش", "description": "سۆزلەر ۋە ئىبارىلەرنى تەپسىلىي ئېنىقلىما ۋە مىساللار بىلەن ئىزدەڭ",
"searchPlaceholder": "ئىزدەيدىغان سۆز ياكى ئىبارە كىرگۈزۈڭ...", "searchPlaceholder": "ئىزدەش ئۈچۈن سۆز ياكى ئىبارە كىرگۈزۈڭ...",
"searching": "ئىزدەۋاتىدۇ...", "searching": "ئىزدەۋاتىدۇ...",
"search": "ئىزدە", "search": "ئىزدەش",
"languageSettings": "تىل تەڭشىكى", "languageSettings": "تىل تەڭشەكلىرى",
"queryLanguage": "سۈرەشتۈرۈش تىلى", "queryLanguage": "سۈرۈشتۈرۈش تىلى",
"queryLanguageHint": "ئىزدەمدەكچى بولغان سۆز/ئىبارە قايسى تىلدا", "queryLanguageHint": "ئىزدىمەكچى بولغان سۆز/ئىبارە قايسى تىلدا",
"definitionLanguage": ىزاھات تىلى", "definitionLanguage": ېنىقلىما تىلى",
"definitionLanguageHint": ىزاھاتنى قايسى تىلدا كۆرۈشنى ئويلىشىسىز", "definitionLanguageHint": ېنىقلىمىلارنى قايسى تىلدا كۆرمەكچى",
"otherLanguagePlaceholder": "ياكى باشقا تىل كىرگۈزۈڭ...", "otherLanguagePlaceholder": "ياكى باشقا تىل كىرگۈزۈڭ...",
"currentSettings": "نۆۋەتتىكى تەڭشەك: سۈرەشتۈرۈش {queryLang}، ئىزاھات {definitionLang}", "other": "باشقا",
"relookup": "قايتا ئىزدە", "currentSettings": "نۆۋەتتىكى تەڭشەكلەر: سۈرۈشتۈرۈش {queryLang}، ئېنىقلىما {definitionLang}",
"saveToFolder": "قىسقۇچقا ساقلا", "relookup": "قايتا ئىزدەش",
"loading": "يۈكلىۋاتىدۇ...", "saveToFolder": "قىسقۇچقا ساقلاش",
"loading": "يۈكلىنىۋاتىدۇ...",
"noResults": "نەتىجە تېپىلمىدى", "noResults": "نەتىجە تېپىلمىدى",
"tryOtherWords": "باشقا سۆز ياكى ئىبارە سىناڭ", "tryOtherWords": "باشقا سۆز ياكى ئىبارىلەرنى سىناڭ",
"welcomeTitle": "لۇغەتكە مەرھەمەت", "welcomeTitle": "لۇغەتكە خۇش كەلدىڭىز",
"welcomeHint": "ئىزدەشنى باشلاش ئۈچۈن يۇقىرىدىكى ئىزدەش رامكىسىغا سۆز ياكى ئىبارە كىرگۈزۈڭ", "welcomeHint": "ئىزدەشنى باشلاش ئۈچۈن يۇقىرىدىكى ئىزدەش رامكىسىغا سۆز ياكى ئىبارە كىرگۈزۈڭ",
"lookupFailed": "ئىزدەش مەغلۇب بولدى، كېيىنرەك قايتا سىناڭ", "lookupFailed": "ئىزدەش مەغلۇپ بولدى، كېيىن قايتا سىناڭ",
"relookupSuccess": "مۇۋەپپەقىيەتلىك قايتا ئىزدىدى", "relookupSuccess": "مۇۋەپپەقىيەتلىك قايتا ئىزدەلدى",
"relookupFailed": "لۇغەت قايتا ئىزدىشى مەغلۇب بولدى", "relookupFailed": "لۇغەت قايتا ئىزدەش مەغلۇپ بولدى",
"pleaseLogin": "ئاۋۋال تىزىملىتىڭ", "pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ",
"pleaseCreateFolder": "ئاۋۋال قىسقۇچ قۇرۇڭ", "pleaseCreateFolder": "ئاۋۋال بىر قىسقۇچ قۇرۇڭ",
"savedToFolder": "قىسقۇچقا ساقلاندى: {folderName}", "savedToFolder": "قىسقۇچقا ساقلاندى: {folderName}",
"saveFailed": "ساقلاش مەغلۇب بولدى، كېيىنرەك قايتا سىناڭ" "saveFailed": "ساقلاش مەغلۇپ بولدى، كېيىن قايتا سىناڭ",
"definition": "ئېنىقلىما",
"example": "مىسال"
},
"explore": {
"title": "ئىزدىنىش",
"subtitle": "ئاممىۋى قىسقۇچلارنى بايقاڭ",
"searchPlaceholder": "ئاممىۋى قىسقۇچلارنى ئىزدەڭ...",
"loading": "يۈكلىنىۋاتىدۇ...",
"noFolders": "ئاممىۋى قىسقۇچ تېپىلمىدى",
"folderInfo": "{userName} • {totalPairs} جۈپ",
"unknownUser": "نامەلۇم ئىشلەتكۈچى",
"favorite": "يىغىپ ساقلا",
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ",
"sortByFavorites": "يىغىپ ساقلاش بويىچە تەرتىپلەش",
"sortByFavoritesActive": "يىغىپ ساقلاش بويىچە تەرتىپلەشنى بىكار قىلىش"
},
"exploreDetail": {
"title": "قىسقۇچ تەپسىلاتلىرى",
"createdBy": "قۇرغۇچى: {name}",
"unknownUser": "نامەلۇم ئىشلەتكۈچى",
"totalPairs": "جەمئىي جۈپ",
"favorites": "يىغىپ ساقلانغانلار",
"createdAt": "قۇرۇلغان ۋاقتى",
"viewContent": "مەزمۇننى كۆرۈش",
"favorite": "يىغىپ ساقلا",
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
"favorited": "يىغىپ ساقلاندى",
"unfavorited": "يىغىپ ساقلاش بىكار قىلىندى",
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ"
},
"favorites": {
"title": "يىغىپ ساقلىغانلىرىم",
"subtitle": "يىغىپ ساقلىغان قىسقۇچلىرىڭىز",
"loading": "يۈكلىنىۋاتىدۇ...",
"noFavorites": "تېخى يىغىپ ساقلانمىغان",
"folderInfo": "{userName} • {totalPairs} جۈپ",
"unknownUser": "نامەلۇم ئىشلەتكۈچى"
}, },
"user_profile": { "user_profile": {
"anonymous": "ئىسىمسىز", "anonymous": "نامسىز",
"email": "ئېلخەت", "email": "ئېلخەت",
"verified": "دەلىللەندى", "verified": "دەلىللەنگەن",
"unverified": "دەلىتلەنمىدى", "unverified": "دەلىللەنمىگەن",
"accountInfo": "ھېسابات ئۇچۇرى", "accountInfo": "ھېسابات ئۇچۇرلىرى",
"userId": "ئىشلەتكۈچى كودى", "userId": "ئىشلەتكۈچى كىملىكى",
"username": "ئىشلەتكۈچى نامى", "username": "ئىشلەتكۈچى ئاتى",
"displayName": "كۆرسىتىلىدىغان نام", "displayName": "كۆرسىتىش ئاتى",
"notSet": "تەڭشەلمىگەن", "notSet": "تەڭشەلمىگەن",
"memberSince": "تىزىملاتقان ۋاقىت", "memberSince": "ئەزا بولغاندىن بېرى",
"logout": "چىكىنىش",
"folders": { "folders": {
"title": "قىسقۇچلار", "title": "قىسقۇچلار",
"noFolders": "قىسقۇچ يوق", "noFolders": "تېخى قىسقۇچ يوق",
"folderName": "قىسقۇچ نامى", "folderName": "قىسقۇچ ئاتى",
"totalPairs": "تېكىست جۈپ سانى", "totalPairs": "جەمئىي جۈپ",
"createdAt": "قۇرۇلغان ۋاقىت", "createdAt": "قۇرۇلغان ۋاقتى",
"actions": "مەشغۇلات", "actions": "مەشغۇلاتلار",
"view": "كۆرۈش" "view": "كۆرۈش"
} }
} }

View File

@@ -1,6 +1,7 @@
{ {
"alphabet": { "alphabet": {
"chooseCharacters": "请选择您想学习的字符", "chooseCharacters": "请选择您想学习的字符",
"chooseAlphabetHint": "选择一种语言的字母表开始学习",
"japanese": "日语假名", "japanese": "日语假名",
"english": "英文字母", "english": "英文字母",
"uyghur": "维吾尔字母", "uyghur": "维吾尔字母",
@@ -14,7 +15,11 @@
"roman": "罗马音", "roman": "罗马音",
"letter": "字母", "letter": "字母",
"random": "随机模式", "random": "随机模式",
"randomNext": "随机下一个" "randomNext": "随机下一个",
"previousLetter": "上一个字母",
"nextLetter": "下一个字母",
"keyboardHint": "使用左右箭头键或空格键随机切换ESC键返回",
"swipeHint": "使用左右箭头键或滑动切换字母"
}, },
"folders": { "folders": {
"title": "文件夹", "title": "文件夹",
@@ -108,6 +113,7 @@
}, },
"auth": { "auth": {
"title": "登录", "title": "登录",
"signUpTitle": "注册",
"signIn": "登录", "signIn": "登录",
"signUp": "注册", "signUp": "注册",
"email": "邮箱", "email": "邮箱",
@@ -133,7 +139,34 @@
"identifierRequired": "请输入邮箱或用户名", "identifierRequired": "请输入邮箱或用户名",
"passwordRequired": "请输入密码", "passwordRequired": "请输入密码",
"confirmPasswordRequired": "请确认密码", "confirmPasswordRequired": "请确认密码",
"loading": "加载中..." "loading": "加载中...",
"confirm": "确认",
"noAccountLink": "没有账号?去注册",
"hasAccountLink": "已有账号?去登录",
"usernamePlaceholder": "用户名",
"emailPlaceholder": "邮箱地址",
"passwordPlaceholder": "密码",
"usernameOrEmailPlaceholder": "用户名或邮箱地址",
"loginFailed": "登录失败",
"signUpFailed": "注册失败",
"fillAllFields": "请填写所有字段",
"enterCredentials": "请输入用户名和密码",
"forgotPassword": "忘记密码",
"forgotPasswordHint": "输入您的邮箱地址,我们将向您发送重置密码的链接。",
"sendResetEmail": "发送重置邮件",
"resetPasswordFailed": "发送重置邮件失败",
"resetPasswordEmailSent": "重置邮件已发送",
"resetPasswordEmailSentHint": "我们已向您的邮箱发送了密码重置链接,请查收。",
"checkYourEmail": "请查收邮件",
"backToLogin": "返回登录",
"resetPassword": "重置密码",
"newPassword": "新密码",
"invalidToken": "链接无效或已过期",
"invalidTokenHint": "此密码重置链接无效或已过期,请重新申请。",
"requestNewToken": "重新申请重置链接",
"resetPasswordSuccess": "密码重置成功",
"resetPasswordSuccessTitle": "密码重置完成",
"resetPasswordSuccessHint": "您的密码已成功重置,现在可以使用新密码登录了。"
}, },
"memorize": { "memorize": {
"folder_selector": { "folder_selector": {
@@ -187,11 +220,17 @@
"subtitleFile": "字幕文件", "subtitleFile": "字幕文件",
"uploaded": "已上传", "uploaded": "已上传",
"notUploaded": "未上传", "notUploaded": "未上传",
"uploadVideoButton": "上传视频",
"uploadSubtitleButton": "上传字幕",
"subtitleUploaded": "字幕已上传 ({count} 条)",
"subtitleNotUploaded": "字幕未上传",
"autoPauseStatus": "自动暂停: {enabled}", "autoPauseStatus": "自动暂停: {enabled}",
"on": "开", "on": "开",
"off": "关", "off": "关",
"videoUploadFailed": "视频上传失败", "videoUploadFailed": "视频上传失败",
"subtitleUploadFailed": "字幕上传失败" "subtitleUploadFailed": "字幕上传失败",
"subtitleLoadSuccess": "字幕加载成功",
"subtitleLoadFailed": "字幕加载失败"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "生成IPA", "generateIPA": "生成IPA",
@@ -241,6 +280,7 @@
"definitionLanguage": "释义语言", "definitionLanguage": "释义语言",
"definitionLanguageHint": "你希望用什么语言查看释义", "definitionLanguageHint": "你希望用什么语言查看释义",
"otherLanguagePlaceholder": "或输入其他语言...", "otherLanguagePlaceholder": "或输入其他语言...",
"other": "其他",
"currentSettings": "当前设置:查询 {queryLang},释义 {definitionLang}", "currentSettings": "当前设置:查询 {queryLang},释义 {definitionLang}",
"relookup": "重新查询", "relookup": "重新查询",
"saveToFolder": "保存到文件夹", "saveToFolder": "保存到文件夹",
@@ -255,7 +295,9 @@
"pleaseLogin": "请先登录", "pleaseLogin": "请先登录",
"pleaseCreateFolder": "请先创建文件夹", "pleaseCreateFolder": "请先创建文件夹",
"savedToFolder": "已保存到文件夹:{folderName}", "savedToFolder": "已保存到文件夹:{folderName}",
"saveFailed": "保存失败,请稍后重试" "saveFailed": "保存失败,请稍后重试",
"definition": "释义",
"example": "例句"
}, },
"explore": { "explore": {
"title": "探索", "title": "探索",
@@ -267,15 +309,23 @@
"unknownUser": "未知用户", "unknownUser": "未知用户",
"favorite": "收藏", "favorite": "收藏",
"unfavorite": "取消收藏", "unfavorite": "取消收藏",
"pleaseLogin": "请先登录" "pleaseLogin": "请先登录",
"sortByFavorites": "按收藏数排序",
"sortByFavoritesActive": "取消按收藏数排序"
}, },
"favorites": { "exploreDetail": {
"title": "收藏", "title": "文件夹详情",
"subtitle": "我收藏的文件夹", "createdBy": "创建者:{name}",
"loading": "加载中...", "unknownUser": "未知用户",
"noFavorites": "还没有收藏", "totalPairs": "词对数量",
"folderInfo": "{userName} • {totalPairs} 个文本对", "favorites": "收藏数",
"unknownUser": "未知用户" "createdAt": "创建时间",
"viewContent": "查看内容",
"favorite": "收藏",
"unfavorite": "取消收藏",
"favorited": "已收藏",
"unfavorited": "已取消收藏",
"pleaseLogin": "请先登录"
}, },
"favorites": { "favorites": {
"title": "我的收藏", "title": "我的收藏",
@@ -296,6 +346,7 @@
"displayName": "显示名称", "displayName": "显示名称",
"notSet": "未设置", "notSet": "未设置",
"memberSince": "注册时间", "memberSince": "注册时间",
"logout": "登出",
"folders": { "folders": {
"title": "文件夹", "title": "文件夹",
"noFolders": "还没有文件夹", "noFolders": "还没有文件夹",

View File

@@ -21,6 +21,8 @@
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"next": "16.1.1", "next": "16.1.1",
"next-intl": "^4.7.0", "next-intl": "^4.7.0",
"nodemailer": "^8.0.2",
"openai": "^6.27.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
@@ -36,6 +38,7 @@
"@eslint/eslintrc": "^3.3.3", "@eslint/eslintrc": "^3.3.3",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"@types/nodemailer": "^7.0.11",
"@types/react": "19.2.7", "@types/react": "19.2.7",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@typescript-eslint/eslint-plugin": "^8.51.0", "@typescript-eslint/eslint-plugin": "^8.51.0",

38
pnpm-lock.yaml generated
View File

@@ -42,6 +42,12 @@ importers:
next-intl: next-intl:
specifier: ^4.7.0 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) 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: pg:
specifier: ^8.16.3 specifier: ^8.16.3
version: 8.16.3 version: 8.16.3
@@ -82,6 +88,9 @@ importers:
'@types/node': '@types/node':
specifier: ^25.0.3 specifier: ^25.0.3
version: 25.0.3 version: 25.0.3
'@types/nodemailer':
specifier: ^7.0.11
version: 7.0.11
'@types/react': '@types/react':
specifier: 19.2.7 specifier: 19.2.7
version: 19.2.7 version: 19.2.7
@@ -1049,6 +1058,9 @@ packages:
'@types/node@25.0.3': '@types/node@25.0.3':
resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==}
'@types/nodemailer@7.0.11':
resolution: {integrity: sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==}
'@types/pg@8.15.6': '@types/pg@8.15.6':
resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==}
@@ -2661,6 +2673,10 @@ packages:
node-releases@2.0.27: node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} 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: normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2718,6 +2734,18 @@ packages:
resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
engines: {node: '>=18'} 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: optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -4393,6 +4421,10 @@ snapshots:
dependencies: dependencies:
undici-types: 7.16.0 undici-types: 7.16.0
'@types/nodemailer@7.0.11':
dependencies:
'@types/node': 25.0.3
'@types/pg@8.15.6': '@types/pg@8.15.6':
dependencies: dependencies:
'@types/node': 25.0.3 '@types/node': 25.0.3
@@ -6068,6 +6100,8 @@ snapshots:
node-releases@2.0.27: {} node-releases@2.0.27: {}
nodemailer@8.0.2: {}
normalize-path@3.0.0: {} normalize-path@3.0.0: {}
nypm@0.6.2: nypm@0.6.2:
@@ -6143,6 +6177,10 @@ snapshots:
is-inside-container: 1.0.0 is-inside-container: 1.0.0
wsl-utils: 0.1.0 wsl-utils: 0.1.0
openai@6.27.0(zod@4.3.5):
optionalDependencies:
zod: 4.3.5
optionator@0.9.4: optionator@0.9.4:
dependencies: dependencies:
deep-is: 0.1.4 deep-is: 0.1.4

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

@@ -16,7 +16,7 @@ model User {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
displayUsername String? displayUsername String?
username String? @unique username String @unique
accounts Account[] accounts Account[]
dictionaryLookUps DictionaryLookUp[] dictionaryLookUps DictionaryLookUp[]
folders Folder[] folders Folder[]

View File

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

View File

@@ -1,17 +1,18 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect } from "react";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import Link from "next/link"; import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Card, CardBody } from "@/design-system/base/card"; import { Card, CardBody } from "@/design-system/base/card";
import { Input } from "@/design-system/base/input"; import { Input } from "@/design-system/base/input";
import { PrimaryButton } from "@/design-system/base/button"; import { PrimaryButton } from "@/design-system/base/button";
import { VStack } from "@/design-system/layout/stack"; import { VStack } from "@/design-system/layout/stack";
export default function LoginPage() { export default function LoginPage() {
const t = useTranslations("auth");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -19,37 +20,43 @@ export default function LoginPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const redirectTo = searchParams.get("redirect"); const redirectTo = searchParams.get("redirect");
const session = authClient.useSession().data; const { data: session, isPending } = authClient.useSession();
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
if (session) { if (!isPending && session?.user?.username && !redirectTo) {
router.push(redirectTo ?? "/profile"); router.push("/folders");
} }
}, [session, router, redirectTo]); }, [session, isPending, router, redirectTo]);
const handleLogin = async () => { const handleLogin = async () => {
if (!username || !password) { if (!username || !password) {
toast.error("请输入用户名和密码"); toast.error(t("enterCredentials"));
return; return;
} }
setLoading(true); setLoading(true);
try { try {
if (username.includes("@")) { if (username.includes("@")) {
await authClient.signIn.email({ const { error } = await authClient.signIn.email({
email: username, email: username,
password: username password: password,
}); });
if (error) {
toast.error(error.message ?? t("loginFailed"));
return;
}
} else { } else {
await authClient.signIn.username({ const { error } = await authClient.signIn.username({
username: username, username: username,
password: password, password: password,
}); });
if (error) {
toast.error(error.message ?? t("loginFailed"));
return;
}
} }
router.push(redirectTo ?? "/profile"); router.push(redirectTo ?? "/folders");
} catch (error) {
toast.error("登录失败");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -57,39 +64,46 @@ export default function LoginPage() {
return ( return (
<div className="flex justify-center items-center min-h-screen"> <div className="flex justify-center items-center min-h-screen">
<Card className="w-80"> <Card className="w-96">
<CardBody> <CardBody>
<VStack gap={4} align="center" justify="center"> <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"> <VStack gap={0} align="center" justify="center" className="w-full">
<Input <Input
placeholder="用户名或邮箱地址" placeholder={t("usernameOrEmailPlaceholder")}
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
/> />
<Input <Input
type="password" type="password"
placeholder="密码" placeholder={t("passwordPlaceholder")}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
/> />
</VStack> </VStack>
<Link
href="/forgot-password"
className="text-sm text-gray-500 hover:text-primary-500 self-end"
>
{t("forgotPassword")}
</Link>
<PrimaryButton <PrimaryButton
onClick={handleLogin} onClick={handleLogin}
loading={loading} loading={loading}
fullWidth fullWidth
> >
{t("confirm")}
</PrimaryButton> </PrimaryButton>
<Link <Link
href={"/signup" + (redirectTo ? `?redirect=${redirectTo}` : "")} href={"/signup" + (redirectTo ? `?redirect=${redirectTo}` : "")}
className="text-center text-primary-500 hover:underline" className="text-center text-primary-500 hover:underline"
> >
{t("noAccountLink")}
</Link> </Link>
</VStack> </VStack>
</CardBody> </CardBody>

View File

@@ -8,7 +8,7 @@ export default async function LogoutPage(
} }
) { ) {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const redirectTo = props.searchParams ?? null; const redirectTo = searchParams.redirect ?? null;
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: await headers() headers: await headers()

View File

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

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

View File

@@ -42,7 +42,7 @@ export default async function UserPage({ params }: UserPageProps) {
<div className="bg-white rounded-lg shadow-md p-6 mb-6"> <div className="bg-white rounded-lg shadow-md p-6 mb-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div></div> <div></div>
{isOwnProfile && <LinkButton href="/logout"></LinkButton>} {isOwnProfile && <LinkButton href="/logout">{t("logout")}</LinkButton>}
</div> </div>
<div className="flex items-center space-x-6"> <div className="flex items-center space-x-6">
{/* Avatar */} {/* Avatar */}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,80 @@
"use client";
import { useState } from "react";
import { LightButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { POPULAR_LANGUAGES } from "./constants";
import { useTranslations } from "next-intl";
interface LanguageSelectorProps {
label: string;
hint: string;
value: string;
onChange: (value: string) => void;
}
export function LanguageSelector({ label, hint, value, onChange }: LanguageSelectorProps) {
const t = useTranslations("dictionary");
const [showCustomInput, setShowCustomInput] = useState(false);
const [customLang, setCustomLang] = useState("");
const isPresetLanguage = POPULAR_LANGUAGES.some((lang) => lang.code === value);
const handlePresetSelect = (code: string) => {
onChange(code);
setShowCustomInput(false);
setCustomLang("");
};
const handleCustomToggle = () => {
setShowCustomInput(!showCustomInput);
if (!showCustomInput && customLang.trim()) {
onChange(customLang.trim());
}
};
const handleCustomChange = (newValue: string) => {
setCustomLang(newValue);
if (newValue.trim()) {
onChange(newValue.trim());
}
};
return (
<div>
<label className="block text-gray-700 text-sm mb-2">
{label} ({hint})
</label>
<div className="flex flex-wrap gap-2 mb-2">
{POPULAR_LANGUAGES.map((lang) => (
<LightButton
key={lang.code}
type="button"
selected={isPresetLanguage && value === lang.code}
onClick={() => handlePresetSelect(lang.code)}
className="text-sm px-3 py-1"
>
{lang.nativeName}
</LightButton>
))}
<LightButton
type="button"
selected={!isPresetLanguage && !!value}
onClick={handleCustomToggle}
className="text-sm px-3 py-1"
>
{t("other")}
</LightButton>
</div>
{(showCustomInput || (!isPresetLanguage && value)) && (
<Input
type="text"
value={isPresetLanguage ? customLang : value}
onChange={(e) => handleCustomChange(e.target.value)}
placeholder={t("otherLanguagePlaceholder")}
className="text-sm"
/>
)}
</div>
);
}

View File

@@ -1,117 +0,0 @@
"use client";
import { LightButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { POPULAR_LANGUAGES } from "./constants";
interface SearchFormProps {
defaultQueryLang?: string;
defaultDefinitionLang?: string;
}
export function SearchForm({ defaultQueryLang = "english", defaultDefinitionLang = "chinese" }: SearchFormProps) {
const t = useTranslations("dictionary");
const [queryLang, setQueryLang] = useState(defaultQueryLang);
const [definitionLang, setDefinitionLang] = useState(defaultDefinitionLang);
const router = useRouter();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const searchQuery = formData.get("searchQuery") as string;
if (!searchQuery?.trim()) return;
const params = new URLSearchParams({
q: searchQuery,
ql: queryLang,
dl: definitionLang,
});
router.push(`/dictionary?${params.toString()}`);
};
return (
<>
{/* 页面标题 */}
<div className="text-center mb-8">
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
{t("title")}
</h1>
<p className="text-gray-700 text-lg">
{t("description")}
</p>
</div>
{/* 搜索表单 */}
<form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-2">
<Input
type="text"
name="searchQuery"
defaultValue=""
placeholder={t("searchPlaceholder")}
variant="search"
required
/>
<LightButton
type="submit"
className="px-6 py-3 whitespace-nowrap text-center sm:min-w-30"
>
{t("search")}
</LightButton>
</form>
{/* 语言设置 */}
<div className="mt-4 bg-white/20 rounded-lg p-4">
<div className="mb-3">
<span className="text-gray-800 font-semibold">{t("languageSettings")}</span>
</div>
<div className="space-y-4">
{/* 查询语言 */}
<div>
<label className="block text-gray-700 text-sm mb-2">
{t("queryLanguage")} ({t("queryLanguageHint")})
</label>
<div className="flex flex-wrap gap-2">
{POPULAR_LANGUAGES.map((lang) => (
<LightButton
key={lang.code}
type="button"
selected={queryLang === lang.code}
onClick={() => setQueryLang(lang.code)}
className="text-sm px-3 py-1"
>
{lang.nativeName}
</LightButton>
))}
</div>
</div>
{/* 释义语言 */}
<div>
<label className="block text-gray-700 text-sm mb-2">
{t("definitionLanguage")} ({t("definitionLanguageHint")})
</label>
<div className="flex flex-wrap gap-2">
{POPULAR_LANGUAGES.map((lang) => (
<LightButton
key={lang.code}
type="button"
selected={definitionLang === lang.code}
onClick={() => setDefinitionLang(lang.code)}
className="text-sm px-3 py-1"
>
{lang.nativeName}
</LightButton>
))}
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -1,122 +0,0 @@
"use client";
import { Plus, RefreshCw } from "lucide-react";
import { CircleButton, LightButton } from "@/design-system/base/button";
import { toast } from "sonner";
import { actionCreatePair } from "@/modules/folder/folder-aciton";
import { TSharedItem } from "@/shared/dictionary-type";
import { TSharedFolder } from "@/shared/folder-type";
import { actionLookUpDictionary } from "@/modules/dictionary/dictionary-action";
import { useRouter } from "next/navigation";
type Session = {
user: {
id: string;
name?: string | null;
email?: string | null;
image?: string | null;
};
} | null;
interface SaveButtonClientProps {
session: Session;
folders: TSharedFolder[];
searchResult: TSharedItem;
queryLang: string;
definitionLang: string;
}
export function SaveButtonClient({ session, folders, searchResult, queryLang, definitionLang }: SaveButtonClientProps) {
const handleSave = async () => {
if (!session) {
toast.error("Please login first");
return;
}
if (folders.length === 0) {
toast.error("Please create a folder first");
return;
}
const folderSelect = document.getElementById("folder-select") as HTMLSelectElement;
const folderId = folderSelect?.value ? Number(folderSelect.value) : folders[0]?.id;
const definition = searchResult.entries.reduce((p, e) => {
return { ...p, definition: p.definition + ' | ' + e.definition };
}).definition;
try {
await actionCreatePair({
text1: searchResult.standardForm,
text2: definition,
language1: queryLang,
language2: definitionLang,
ipa1: searchResult.entries[0].ipa,
folderId: folderId,
});
const folderName = folders.find((f) => f.id === folderId)?.name || "Unknown";
toast.success(`Saved to ${folderName}`);
} catch (error) {
toast.error("Save failed");
}
};
return (
<CircleButton
onClick={handleSave}
className="w-10 h-10 shrink-0"
title="Save to folder"
>
<Plus />
</CircleButton>
);
}
interface ReLookupButtonClientProps {
searchQuery: string;
queryLang: string;
definitionLang: string;
}
export function ReLookupButtonClient({ searchQuery, queryLang, definitionLang }: ReLookupButtonClientProps) {
const router = useRouter();
const handleRelookup = async () => {
const getNativeName = (code: string): string => {
const popularLanguages: Record<string, string> = {
english: "English",
chinese: "中文",
japanese: "日本語",
korean: "한국어",
italian: "Italiano",
uyghur: "ئۇيغۇرچە",
};
return popularLanguages[code] || code;
};
try {
await actionLookUpDictionary({
text: searchQuery,
queryLang: getNativeName(queryLang),
definitionLang: getNativeName(definitionLang),
forceRelook: true
});
toast.success("Re-lookup successful");
// 刷新页面以显示新结果
router.refresh();
} catch (error) {
toast.error("Re-lookup failed");
}
};
return (
<LightButton
onClick={handleRelookup}
className="flex items-center gap-2 px-4 py-2 text-sm"
leftIcon={<RefreshCw className="w-4 h-4" />}
>
Re-lookup
</LightButton>
);
}

View File

@@ -1,93 +0,0 @@
import { auth } from "@/auth";
import { DictionaryEntry } from "./DictionaryEntry";
import { TSharedItem } from "@/shared/dictionary-type";
import { SaveButtonClient, ReLookupButtonClient } from "./SearchResult.client";
import { headers } from "next/headers";
import { actionGetFoldersByUserId } from "@/modules/folder/folder-aciton";
import { TSharedFolder } from "@/shared/folder-type";
interface SearchResultProps {
searchResult: TSharedItem | null;
searchQuery: string;
queryLang: string;
definitionLang: string;
}
export async function SearchResult({
searchResult,
searchQuery,
queryLang,
definitionLang
}: SearchResultProps) {
// 获取用户会话和文件夹
const session = await auth.api.getSession({ headers: await headers() });
let folders: TSharedFolder[] = [];
if (session?.user?.id) {
const result = await actionGetFoldersByUserId(session.user.id as string);
if (result.success && result.data) {
folders = result.data;
}
}
return (
<div className="space-y-6">
{!searchResult ? (
<div className="text-center py-12 bg-white/20 rounded-lg">
<p className="text-gray-800 text-xl">No results found</p>
<p className="text-gray-600 mt-2">Try other words</p>
</div>
) : (
<div className="bg-white rounded-lg p-6 shadow-lg">
{/* 标题和保存按钮 */}
<div className="flex items-start justify-between mb-6">
<div className="flex-1">
<h2 className="text-3xl font-bold text-gray-800 mb-2">
{searchResult.standardForm}
</h2>
</div>
<div className="flex items-center gap-2 ml-4">
{session && folders.length > 0 && (
<select
id="folder-select"
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[#35786f]"
>
{folders.map((folder) => (
<option key={folder.id} value={folder.id}>
{folder.name}
</option>
))}
</select>
)}
<SaveButtonClient
session={session}
folders={folders}
searchResult={searchResult}
queryLang={queryLang}
definitionLang={definitionLang}
/>
</div>
</div>
{/* 条目列表 */}
<div className="space-y-6">
{searchResult.entries.map((entry, index) => (
<div key={index} className="border-t border-gray-200 pt-4">
<DictionaryEntry entry={entry} />
</div>
))}
</div>
{/* 重新查询按钮 */}
<div className="border-t border-gray-200 pt-4 mt-4">
<ReLookupButtonClient
searchQuery={searchQuery}
queryLang={queryLang}
definitionLang={definitionLang}
/>
</div>
</div>
)}
</div>
);
}

View File

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

View File

@@ -0,0 +1,148 @@
"use client";
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { TSharedItem } from "@/shared/dictionary-type";
import { actionLookUpDictionary } from "@/modules/dictionary/dictionary-action";
import { toast } from "sonner";
const POPULAR_LANGUAGES_MAP: Record<string, string> = {
english: "English",
chinese: "中文",
japanese: "日本語",
korean: "한국어",
italian: "Italiano",
uyghur: "ئۇيغۇرچە",
};
export function getNativeName(code: string): string {
return POPULAR_LANGUAGES_MAP[code] || code;
}
export interface DictionaryState {
query: string;
queryLang: string;
definitionLang: string;
searchResult: TSharedItem | null;
isSearching: boolean;
}
export interface DictionaryActions {
setQuery: (query: string) => void;
setQueryLang: (lang: string) => void;
setDefinitionLang: (lang: string) => void;
setSearchResult: (result: TSharedItem | null) => void;
search: () => Promise<void>;
relookup: () => Promise<void>;
syncFromUrl: (params: { q?: string; ql?: string; dl?: string }) => void;
}
export type DictionaryStore = DictionaryState & DictionaryActions;
const initialState: DictionaryState = {
query: "",
queryLang: "english",
definitionLang: "chinese",
searchResult: null,
isSearching: false,
};
export const useDictionaryStore = create<DictionaryStore>()(
devtools(
(set, get) => ({
...initialState,
setQuery: (query) => set({ query }),
setQueryLang: (queryLang) => set({ queryLang }),
setDefinitionLang: (definitionLang) => set({ definitionLang }),
setSearchResult: (searchResult) => set({ searchResult }),
search: async () => {
const { query, queryLang, definitionLang } = get();
if (!query.trim()) {
return;
}
set({ isSearching: true });
try {
const result = await actionLookUpDictionary({
text: query,
queryLang: getNativeName(queryLang),
definitionLang: getNativeName(definitionLang),
forceRelook: false,
});
if (result.success && result.data) {
set({ searchResult: result.data });
} else {
set({ searchResult: null });
if (result.message) {
toast.error(result.message);
}
}
} catch (error) {
set({ searchResult: null });
toast.error("Search failed");
} finally {
set({ isSearching: false });
}
},
relookup: async () => {
const { query, queryLang, definitionLang } = get();
if (!query.trim()) {
return;
}
set({ isSearching: true });
try {
const result = await actionLookUpDictionary({
text: query,
queryLang: getNativeName(queryLang),
definitionLang: getNativeName(definitionLang),
forceRelook: true,
});
if (result.success && result.data) {
set({ searchResult: result.data });
toast.success("Re-lookup successful");
} else {
if (result.message) {
toast.error(result.message);
}
}
} catch (error) {
toast.error("Re-lookup failed");
} finally {
set({ isSearching: false });
}
},
syncFromUrl: (params) => {
const updates: Partial<DictionaryState> = {};
if (params.q !== undefined) {
updates.query = params.q;
}
if (params.ql !== undefined) {
updates.queryLang = params.ql;
}
if (params.dl !== undefined) {
updates.definitionLang = params.dl;
}
if (Object.keys(updates).length > 0) {
set(updates);
}
},
}),
{ name: 'dictionary-store' }
)
);

View File

@@ -1,10 +1,10 @@
"use client"; "use client";
import { import {
ChevronRight,
Folder as Fd, Folder as Fd,
Heart, Heart,
Search, Search,
ArrowUpDown,
} from "lucide-react"; } from "lucide-react";
import { CircleButton } from "@/design-system/base/button"; import { CircleButton } from "@/design-system/base/button";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -13,22 +13,21 @@ import { useTranslations } from "next-intl";
import { toast } from "sonner"; import { toast } from "sonner";
import { PageLayout } from "@/components/ui/PageLayout"; import { PageLayout } from "@/components/ui/PageLayout";
import { PageHeader } from "@/components/ui/PageHeader"; import { PageHeader } from "@/components/ui/PageHeader";
import { CardList } from "@/components/ui/CardList";
import { import {
actionSearchPublicFolders, actionSearchPublicFolders,
actionToggleFavorite, actionToggleFavorite,
actionCheckFavorite, actionCheckFavorite,
} from "@/modules/folder/folder-aciton"; } from "@/modules/folder/folder-action";
import { TPublicFolder } from "@/shared/folder-type"; import { TPublicFolder } from "@/shared/folder-type";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
interface PublicFolderCardProps { interface PublicFolderCardProps {
folder: TPublicFolder; folder: TPublicFolder;
currentUserId?: string; currentUserId?: string;
onFavoriteChange?: () => void; onUpdateFavorite: (folderId: number, isFavorited: boolean, favoriteCount: number) => void;
} }
const PublicFolderCard = ({ folder, currentUserId, onFavoriteChange }: PublicFolderCardProps) => { const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFolderCardProps) => {
const router = useRouter(); const router = useRouter();
const t = useTranslations("explore"); const t = useTranslations("explore");
const [isFavorited, setIsFavorited] = useState(false); const [isFavorited, setIsFavorited] = useState(false);
@@ -55,7 +54,7 @@ const PublicFolderCard = ({ folder, currentUserId, onFavoriteChange }: PublicFol
if (result.success && result.data) { if (result.success && result.data) {
setIsFavorited(result.data.isFavorited); setIsFavorited(result.data.isFavorited);
setFavoriteCount(result.data.favoriteCount); setFavoriteCount(result.data.favoriteCount);
onFavoriteChange?.(); onUpdateFavorite(folder.id, result.data.isFavorited, result.data.favoriteCount);
} else { } else {
toast.error(result.message); toast.error(result.message);
} }
@@ -63,45 +62,39 @@ const PublicFolderCard = ({ folder, currentUserId, onFavoriteChange }: PublicFol
return ( return (
<div <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" 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={() => { onClick={() => {
router.push(`/explore/${folder.id}`); router.push(`/explore/${folder.id}`);
}} }}
> >
<div className="flex items-center gap-4 flex-1"> <div className="flex items-start justify-between mb-2 sm:mb-3">
<div className="shrink-0 text-primary-500"> <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={24} /> <Fd size={18} className="sm:hidden" />
</div> <Fd size={22} className="hidden sm:block" />
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{folder.name}</h3>
<p className="text-sm text-gray-500 mt-0.5">
{t("folderInfo", {
userName: folder.userName ?? folder.userUsername ?? t("unknownUser"),
totalPairs: folder.totalPairs,
})}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 text-sm text-gray-400">
<Heart
size={14}
className={isFavorited ? "fill-red-500 text-red-500" : ""}
/>
<span>{favoriteCount}</span>
</div> </div>
<CircleButton <CircleButton
onClick={handleToggleFavorite} onClick={handleToggleFavorite}
title={isFavorited ? t("unfavorite") : t("favorite")} title={isFavorited ? t("unfavorite") : t("favorite")}
> >
<Heart <Heart
size={18} size={16}
className={isFavorited ? "fill-red-500 text-red-500" : ""} className={`sm:w-[18px] sm:h-[18px] sm:text-[18px] ${isFavorited ? "fill-red-500 text-red-500" : ""}`}
/> />
</CircleButton> </CircleButton>
<ChevronRight size={20} className="text-gray-400" /> </div>
<h3 className="font-semibold text-gray-900 truncate text-sm sm:text-base mb-1 sm:mb-2">{folder.name}</h3>
<p className="text-xs sm:text-sm text-gray-500 mb-2 sm:mb-3 line-clamp-2">
{t("folderInfo", {
userName: folder.userName ?? folder.userUsername ?? t("unknownUser"),
totalPairs: folder.totalPairs,
})}
</p>
<div className="flex items-center gap-1 text-xs sm:text-sm text-gray-400">
<Heart size={12} className="sm:w-3.5 sm:h-3.5" />
<span>{favoriteCount}</span>
</div> </div>
</div> </div>
); );
@@ -117,6 +110,7 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
const [publicFolders, setPublicFolders] = useState<TPublicFolder[]>(initialPublicFolders); const [publicFolders, setPublicFolders] = useState<TPublicFolder[]>(initialPublicFolders);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [sortByFavorites, setSortByFavorites] = useState(false);
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
const currentUserId = session?.user?.id; const currentUserId = session?.user?.id;
@@ -134,20 +128,27 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
setLoading(false); setLoading(false);
}; };
const refreshFolders = async () => { const handleToggleSort = () => {
setLoading(true); setSortByFavorites((prev) => !prev);
const result = await actionSearchPublicFolders(searchQuery.trim() || ""); };
if (result.success && result.data) {
setPublicFolders(result.data); const sortedFolders = sortByFavorites
} ? [...publicFolders].sort((a, b) => b.favoriteCount - a.favoriteCount)
setLoading(false); : publicFolders;
const handleUpdateFavorite = (folderId: number, _isFavorited: boolean, favoriteCount: number) => {
setPublicFolders((prev) =>
prev.map((f) =>
f.id === folderId ? { ...f, favoriteCount } : f
)
);
}; };
return ( return (
<PageLayout> <PageLayout>
<PageHeader title={t("title")} subtitle={t("subtitle")} /> <PageHeader title={t("title")} subtitle={t("subtitle")} />
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-6">
<div className="relative flex-1"> <div className="relative flex-1">
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" /> <Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input <input
@@ -159,37 +160,42 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
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" className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
/> />
</div> </div>
<CircleButton
onClick={handleToggleSort}
title={sortByFavorites ? t("sortByFavoritesActive") : t("sortByFavorites")}
className={sortByFavorites ? "bg-primary-100 text-primary-600 hover:bg-primary-200" : ""}
>
<ArrowUpDown size={18} />
</CircleButton>
<CircleButton onClick={handleSearch}> <CircleButton onClick={handleSearch}>
<Search size={18} /> <Search size={18} />
</CircleButton> </CircleButton>
</div> </div>
<div className="mt-4"> {loading ? (
<CardList> <div className="p-8 text-center">
{loading ? ( <div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
<div className="p-8 text-center"> <p className="text-sm text-gray-500">{t("loading")}</p>
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div> </div>
<p className="text-sm text-gray-500">{t("loading")}</p> ) : sortedFolders.length === 0 ? (
</div> <div className="text-center py-12 text-gray-400">
) : publicFolders.length === 0 ? ( <div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
<div className="text-center py-12 text-gray-400"> <Fd size={24} className="text-gray-400" />
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center"> </div>
<Fd size={24} className="text-gray-400" /> <p className="text-sm">{t("noFolders")}</p>
</div> </div>
<p className="text-sm">{t("noFolders")}</p> ) : (
</div> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
) : ( {sortedFolders.map((folder) => (
publicFolders.map((folder) => ( <PublicFolderCard
<PublicFolderCard key={folder.id}
key={folder.id} folder={folder}
folder={folder} currentUserId={currentUserId}
currentUserId={currentUserId} onUpdateFavorite={handleUpdateFavorite}
onFavoriteChange={refreshFolders} />
/> ))}
)) </div>
)} )}
</CardList>
</div>
</PageLayout> </PageLayout>
); );
} }

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { ExploreClient } from "./ExploreClient"; import { ExploreClient } from "./ExploreClient";
import { actionGetPublicFolders } from "@/modules/folder/folder-aciton"; import { actionGetPublicFolders } from "@/modules/folder/folder-action";
export default async function ExplorePage() { export default async function ExplorePage() {
const publicFoldersResult = await actionGetPublicFolders(); const publicFoldersResult = await actionGetPublicFolders();

View File

@@ -8,10 +8,11 @@ import {
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { PageLayout } from "@/components/ui/PageLayout"; import { PageLayout } from "@/components/ui/PageLayout";
import { PageHeader } from "@/components/ui/PageHeader"; import { PageHeader } from "@/components/ui/PageHeader";
import { CardList } from "@/components/ui/CardList"; import { CardList } from "@/components/ui/CardList";
import { actionGetUserFavorites } from "@/modules/folder/folder-aciton"; import { actionGetUserFavorites, actionToggleFavorite } from "@/modules/folder/folder-action";
type UserFavorite = { type UserFavorite = {
id: number; id: number;
@@ -27,11 +28,27 @@ type UserFavorite = {
interface FavoriteCardProps { interface FavoriteCardProps {
favorite: UserFavorite; favorite: UserFavorite;
onRemoveFavorite: (folderId: number) => void;
} }
const FavoriteCard = ({ favorite }: FavoriteCardProps) => { const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
const router = useRouter(); const router = useRouter();
const t = useTranslations("favorites"); const t = useTranslations("favorites");
const [isRemoving, setIsRemoving] = useState(false);
const handleRemoveFavorite = async (e: React.MouseEvent) => {
e.stopPropagation();
if (isRemoving) return;
setIsRemoving(true);
const result = await actionToggleFavorite(favorite.folderId);
if (result.success) {
onRemoveFavorite(favorite.folderId);
} else {
toast.error(result.message);
}
setIsRemoving(false);
};
return ( return (
<div <div
@@ -57,7 +74,11 @@ const FavoriteCard = ({ favorite }: FavoriteCardProps) => {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Heart size={18} className="fill-red-500 text-red-500" /> <Heart
size={18}
className="fill-red-500 text-red-500 cursor-pointer hover:scale-110 transition-transform"
onClick={handleRemoveFavorite}
/>
<ChevronRight size={20} className="text-gray-400" /> <ChevronRight size={20} className="text-gray-400" />
</div> </div>
</div> </div>
@@ -86,31 +107,37 @@ export function FavoritesClient({ userId }: FavoritesClientProps) {
setLoading(false); setLoading(false);
}; };
const handleRemoveFavorite = (folderId: number) => {
setFavorites((prev) => prev.filter((f) => f.folderId !== folderId));
};
return ( return (
<PageLayout> <PageLayout>
<PageHeader title={t("title")} subtitle={t("subtitle")} /> <PageHeader title={t("title")} subtitle={t("subtitle")} />
<div className="mt-4"> <CardList>
<CardList> {loading ? (
{loading ? ( <div className="p-8 text-center">
<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>
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div> <p className="text-sm text-gray-500">{t("loading")}</p>
<p className="text-sm text-gray-500">{t("loading")}</p> </div>
) : favorites.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
<Heart size={24} className="text-gray-400" />
</div> </div>
) : favorites.length === 0 ? ( <p className="text-sm">{t("noFavorites")}</p>
<div className="text-center py-12 text-gray-400"> </div>
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center"> ) : (
<Heart size={24} className="text-gray-400" /> favorites.map((favorite) => (
</div> <FavoriteCard
<p className="text-sm">{t("noFavorites")}</p> key={favorite.id}
</div> favorite={favorite}
) : ( onRemoveFavorite={handleRemoveFavorite}
favorites.map((favorite) => ( />
<FavoriteCard key={favorite.id} favorite={favorite} /> ))
)) )}
)} </CardList>
</CardList>
</div>
</PageLayout> </PageLayout>
); );
} }

View File

@@ -5,7 +5,7 @@ import { FolderSelector } from "./FolderSelector";
import { Memorize } from "./Memorize"; import { Memorize } from "./Memorize";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { actionGetFoldersWithTotalPairsByUserId, actionGetPairsByFolderId } from "@/modules/folder/folder-aciton"; import { actionGetFoldersWithTotalPairsByUserId, actionGetPairsByFolderId } from "@/modules/folder/folder-action";
export default async function MemorizePage({ export default async function MemorizePage({
searchParams, searchParams,

View File

@@ -115,6 +115,8 @@ export default function SrtPlayerPage() {
key={i} key={i}
href={`/dictionary?q=${s}`} href={`/dictionary?q=${s}`}
className="px-1 h-fit hover:bg-gray-200 hover:cursor-pointer" className="px-1 h-fit hover:bg-gray-200 hover:cursor-pointer"
target="_blank"
rel="noopener noreferrer"
> >
{s} {s}
</Link> </Link>
@@ -125,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="border-gray-200 border-2 flex items p-2 justify-between items-center rounded gap-8">
<div className="flex items-center flex-col"> <div className="flex items-center flex-col">
<Video size={16} /> <Video size={16} />
<span className="text-sm"></span> <span className="text-sm">{srtT("videoFile")}</span>
</div> </div>
<LightButton onClick={handleVideoUpload} disabled={!!videoUrl}> <LightButton onClick={handleVideoUpload} disabled={!!videoUrl}>
{videoUrl ? '已上传' : '上传视频'} {videoUrl ? srtT("uploaded") : srtT("uploadVideoButton")}
</LightButton> </LightButton>
</div> </div>
<div className="border-gray-200 border-2 flex items p-2 justify-between items-center rounded gap-8"> <div className="border-gray-200 border-2 flex items p-2 justify-between items-center rounded gap-8">
<div className="flex items-center flex-col"> <div className="flex items-center flex-col">
<FileText size={16} /> <FileText size={16} />
<span className="text-sm"> <span className="text-sm">
{subtitleData.length > 0 ? `字幕已上传 (${subtitleData.length} 条)` : "字幕未上传"} {subtitleData.length > 0 ? srtT("subtitleUploaded", { count: subtitleData.length }) : srtT("subtitleNotUploaded")}
</span> </span>
</div> </div>
<LightButton onClick={handleSubtitleUpload} disabled={!!subtitleUrl}> <LightButton onClick={handleSubtitleUpload} disabled={!!subtitleUrl}>
{subtitleUrl ? '已上传' : '上传字幕'} {subtitleUrl ? srtT("uploaded") : srtT("uploadSubtitleButton")}
</LightButton> </LightButton>
</div> </div>
</div> </div>

View File

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

View File

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

View File

@@ -48,8 +48,8 @@ export default function TextSpeakerPage() {
const handleEnded = () => { const handleEnded = () => {
if (autopause) { if (autopause) {
setPause(true); setPause(true);
} else { } else if (objurlRef.current) {
load(objurlRef.current!); load(objurlRef.current);
play(); play();
} }
}; };
@@ -187,7 +187,7 @@ export default function TextSpeakerPage() {
theIPA = tmp_ipa; theIPA = tmp_ipa;
} }
const save = getFromLocalStorage(); const save = getFromLocalStorage() ?? [];
const oldIndex = save.findIndex((v) => v.text === textRef.current); const oldIndex = save.findIndex((v) => v.text === textRef.current);
if (oldIndex !== -1) { if (oldIndex !== -1) {
const oldItem = save[oldIndex]; const oldItem = save[oldIndex];
@@ -293,7 +293,7 @@ export default function TextSpeakerPage() {
size="lg" size="lg"
onClick={() => { onClick={() => {
setAutopause(!autopause); setAutopause(!autopause);
if (objurlRef) { if (objurlRef.current) {
stop(); stop();
} }
setPause(true); setPause(true);

View File

@@ -23,15 +23,16 @@ import {
actionGetFoldersWithTotalPairsByUserId, actionGetFoldersWithTotalPairsByUserId,
actionRenameFolderById, actionRenameFolderById,
actionSetFolderVisibility, actionSetFolderVisibility,
} from "@/modules/folder/folder-aciton"; } from "@/modules/folder/folder-action";
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type"; import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
interface FolderCardProps { interface FolderCardProps {
folder: TSharedFolderWithTotalPairs; folder: TSharedFolderWithTotalPairs;
refresh: () => void; onUpdateFolder: (folderId: number, updates: Partial<TSharedFolderWithTotalPairs>) => void;
onDeleteFolder: (folderId: number) => void;
} }
const FolderCard = ({ folder, refresh }: FolderCardProps) => { const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps) => {
const router = useRouter(); const router = useRouter();
const t = useTranslations("folders"); const t = useTranslations("folders");
@@ -40,12 +41,38 @@ const FolderCard = ({ folder, refresh }: FolderCardProps) => {
const newVisibility = folder.visibility === "PUBLIC" ? "PRIVATE" : "PUBLIC"; const newVisibility = folder.visibility === "PUBLIC" ? "PRIVATE" : "PUBLIC";
const result = await actionSetFolderVisibility(folder.id, newVisibility); const result = await actionSetFolderVisibility(folder.id, newVisibility);
if (result.success) { if (result.success) {
refresh(); onUpdateFolder(folder.id, { visibility: newVisibility });
} else { } else {
toast.error(result.message); toast.error(result.message);
} }
}; };
const handleRename = async (e: React.MouseEvent) => {
e.stopPropagation();
const newName = prompt(t("enterNewName"))?.trim();
if (newName && newName.length > 0) {
const result = await actionRenameFolderById(folder.id, newName);
if (result.success) {
onUpdateFolder(folder.id, { name: newName });
} else {
toast.error(result.message);
}
}
};
const handleDelete = async (e: React.MouseEvent) => {
e.stopPropagation();
const confirm = prompt(t("confirmDelete", { name: folder.name }));
if (confirm === folder.name) {
const result = await actionDeleteFolderById(folder.id);
if (result.success) {
onDeleteFolder(folder.id);
} else {
toast.error(result.message);
}
}
};
return ( return (
<div <div
className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors" className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors"
@@ -91,42 +118,16 @@ const FolderCard = ({ folder, refresh }: FolderCardProps) => {
<Globe size={18} /> <Globe size={18} />
)} )}
</CircleButton> </CircleButton>
<CircleButton <CircleButton onClick={handleRename}>
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
const newName = prompt(t("enterNewName"))?.trim();
if (newName && newName.length > 0) {
actionRenameFolderById(folder.id, newName).then((result) => {
if (result.success) {
refresh();
} else {
toast.error(result.message);
}
});
}
}}
>
<FolderPen size={18} /> <FolderPen size={18} />
</CircleButton> </CircleButton>
<CircleButton <CircleButton
onClick={(e: React.MouseEvent) => { onClick={handleDelete}
e.stopPropagation(); className="hover:text-red-500 hover:bg-red-50"
const confirm = prompt(t("confirmDelete", { name: folder.name }));
if (confirm === folder.name) {
actionDeleteFolderById(folder.id).then((result) => {
if (result.success) {
refresh();
} else {
toast.error(result.message);
}
});
}
}}
className="text-gray-400 hover:text-red-500 hover:bg-red-50"
> >
<Trash2 size={18} /> <Trash2 size={18} />
</CircleButton> </CircleButton>
<ChevronRight size={20} className="text-gray-400 ml-1" /> <ChevronRight size={20} className="text-gray-400" />
</div> </div>
</div> </div>
); );
@@ -142,10 +143,6 @@ export function FoldersClient({ userId }: FoldersClientProps) {
const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>([]); const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => {
loadFolders();
}, [userId]);
const loadFolders = async () => { const loadFolders = async () => {
setLoading(true); setLoading(true);
const result = await actionGetFoldersWithTotalPairsByUserId(userId); const result = await actionGetFoldersWithTotalPairsByUserId(userId);
@@ -155,19 +152,29 @@ export function FoldersClient({ userId }: FoldersClientProps) {
setLoading(false); setLoading(false);
}; };
useEffect(() => {
loadFolders();
}, [userId]);
const handleUpdateFolder = (folderId: number, updates: Partial<TSharedFolderWithTotalPairs>) => {
setFolders((prev) =>
prev.map((f) => (f.id === folderId ? { ...f, ...updates } : f))
);
};
const handleDeleteFolder = (folderId: number) => {
setFolders((prev) => prev.filter((f) => f.id !== folderId));
};
const handleCreateFolder = async () => { const handleCreateFolder = async () => {
const folderName = prompt(t("enterFolderName")); const folderName = prompt(t("enterFolderName"));
if (!folderName) return; if (!folderName?.trim()) return;
setLoading(true);
try { const result = await actionCreateFolder(userId, folderName.trim());
const result = await actionCreateFolder(userId, folderName); if (result.success) {
if (result.success) { loadFolders();
loadFolders(); } else {
} else { toast.error(result.message);
toast.error(result.message);
}
} finally {
setLoading(false);
} }
}; };
@@ -175,14 +182,12 @@ export function FoldersClient({ userId }: FoldersClientProps) {
<PageLayout> <PageLayout>
<PageHeader title={t("title")} subtitle={t("subtitle")} /> <PageHeader title={t("title")} subtitle={t("subtitle")} />
<LightButton <div className="mb-4">
onClick={handleCreateFolder} <LightButton onClick={handleCreateFolder}>
disabled={loading} <FolderPlus size={18} />
className="w-full border-dashed mb-4" {t("newFolder")}
> </LightButton>
<FolderPlus size={20} /> </div>
<span>{loading ? t("creating") : t("newFolder")}</span>
</LightButton>
<CardList> <CardList>
{loading ? ( {loading ? (
@@ -193,16 +198,19 @@ export function FoldersClient({ userId }: FoldersClientProps) {
) : folders.length === 0 ? ( ) : folders.length === 0 ? (
<div className="text-center py-12 text-gray-400"> <div className="text-center py-12 text-gray-400">
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center"> <div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
<FolderPlus size={24} className="text-gray-400" /> <Fd size={24} className="text-gray-400" />
</div> </div>
<p className="text-sm">{t("noFoldersYet")}</p> <p className="text-sm">{t("noFoldersYet")}</p>
</div> </div>
) : ( ) : (
folders folders.map((folder) => (
.toSorted((a, b) => b.id - a.id) <FolderCard
.map((folder) => ( key={folder.id}
<FolderCard key={folder.id} folder={folder} refresh={loadFolders} /> folder={folder}
)) onUpdateFolder={handleUpdateFolder}
onDeleteFolder={handleDeleteFolder}
/>
))
)} )}
</CardList> </CardList>
</PageLayout> </PageLayout>

View File

@@ -9,7 +9,7 @@ import { useTranslations } from "next-intl";
import { PageLayout } from "@/components/ui/PageLayout"; import { PageLayout } from "@/components/ui/PageLayout";
import { PrimaryButton, CircleButton, LinkButton } from "@/design-system/base/button"; import { PrimaryButton, CircleButton, LinkButton } from "@/design-system/base/button";
import { CardList } from "@/components/ui/CardList"; import { CardList } from "@/components/ui/CardList";
import { actionCreatePair, actionDeletePairById, actionGetPairsByFolderId } from "@/modules/folder/folder-aciton"; import { actionCreatePair, actionDeletePairById, actionGetPairsByFolderId } from "@/modules/folder/folder-action";
import { TSharedPair } from "@/shared/folder-type"; import { TSharedPair } from "@/shared/folder-type";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -26,10 +26,14 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
setLoading(true); setLoading(true);
await actionGetPairsByFolderId(folderId) await actionGetPairsByFolderId(folderId)
.then(result => { .then(result => {
if (!result.success || !result.data) throw result.message; if (!result.success || !result.data) {
throw new Error(result.message || "Failed to load text pairs");
}
return result.data; return result.data;
}).then(setTextPairs) }).then(setTextPairs)
.catch(toast.error) .catch((error) => {
toast.error(error instanceof Error ? error.message : "Unknown error");
})
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
}); });
@@ -40,10 +44,14 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
const refreshTextPairs = async () => { const refreshTextPairs = async () => {
await actionGetPairsByFolderId(folderId) await actionGetPairsByFolderId(folderId)
.then(result => { .then(result => {
if (!result.success || !result.data) throw result.message; if (!result.success || !result.data) {
throw new Error(result.message || "Failed to refresh text pairs");
}
return result.data; return result.data;
}).then(setTextPairs) }).then(setTextPairs)
.catch(toast.error); .catch((error) => {
toast.error(error instanceof Error ? error.message : "Unknown error");
});
}; };
return ( return (
@@ -119,9 +127,11 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
onDel={() => { onDel={() => {
actionDeletePairById(textPair.id) actionDeletePairById(textPair.id)
.then(result => { .then(result => {
if (!result.success) throw result.message; if (!result.success) throw new Error(result.message || "Delete failed");
}).then(refreshTextPairs) }).then(refreshTextPairs)
.catch(toast.error); .catch((error) => {
toast.error(error instanceof Error ? error.message : "Unknown error");
});
}} }}
refreshTextPairs={refreshTextPairs} refreshTextPairs={refreshTextPairs}
/> />

View File

@@ -4,7 +4,7 @@ import { CircleButton } from "@/design-system/base/button";
import { UpdateTextPairModal } from "./UpdateTextPairModal"; import { UpdateTextPairModal } from "./UpdateTextPairModal";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { TSharedPair } from "@/shared/folder-type"; import { TSharedPair } from "@/shared/folder-type";
import { actionUpdatePairById } from "@/modules/folder/folder-aciton"; import { actionUpdatePairById } from "@/modules/folder/folder-action";
import { ActionInputUpdatePairById } from "@/modules/folder/folder-action-dto"; import { ActionInputUpdatePairById } from "@/modules/folder/folder-action-dto";
import { toast } from "sonner"; import { toast } from "sonner";

View File

@@ -3,7 +3,7 @@ import { getTranslations } from "next-intl/server";
import { InFolder } from "./InFolder"; import { InFolder } from "./InFolder";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { actionGetFolderVisibility } from "@/modules/folder/folder-aciton"; import { actionGetFolderVisibility } from "@/modules/folder/folder-action";
export default async function FoldersPage({ export default async function FoldersPage({
params, params,

View File

@@ -1,21 +1,57 @@
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma"; import { prismaAdapter } from "better-auth/adapters/prisma";
import { nextCookies } from "better-auth/next-js"; import { nextCookies } from "better-auth/next-js";
import { prisma } from "./lib/db";
import { username } from "better-auth/plugins"; import { username } from "better-auth/plugins";
import { createAuthMiddleware, APIError } from "better-auth/api";
import { prisma } from "./lib/db";
import {
sendEmail,
generateVerificationEmailHtml,
generateResetPasswordEmailHtml,
} from "./lib/email";
export const auth = betterAuth({ export const auth = betterAuth({
database: prismaAdapter(prisma, { database: prismaAdapter(prisma, {
provider: "postgresql" provider: "postgresql",
}),
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
sendResetPassword: async ({ user, url }) => {
void sendEmail({
to: user.email,
subject: "重置您的密码 - Learn Languages",
html: generateResetPasswordEmailHtml(url, user.name || "用户"),
});
},
},
emailVerification: {
sendOnSignUp: true,
sendVerificationEmail: async ({ user, url }) => {
void sendEmail({
to: user.email,
subject: "验证您的邮箱 - Learn Languages",
html: generateVerificationEmailHtml(url, user.name || "用户"),
});
},
},
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID as string,
clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
},
},
plugins: [nextCookies(), username()],
hooks: {
before: createAuthMiddleware(async (ctx) => {
if (ctx.path !== "/sign-up/email" && ctx.path !== "/update-user") return;
const body = ctx.body as { username?: string };
if (!body.username || body.username.trim() === "") {
throw new APIError("BAD_REQUEST", {
message: "Username is required",
});
}
}), }),
emailAndPassword: { },
enabled: true
},
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID as string,
clientSecret: process.env.GITHUB_CLIENT_SECRET as string
},
},
plugins: [nextCookies(), username()]
}); });

View File

@@ -1,18 +1,15 @@
# 词典查询模块化架构 # 词典查询架构
本目录包含词典查询系统的**多阶段 LLM 调用**实现,将查询过程拆分为 4 个独立的 LLM 调用,每个阶段之间有代码层面的数据验证,只要有一环失败,直接返回错误 2 次 LLM 调用的词典查询系统
## 目录结构 ## 目录结构
``` ```
dictionary/ dictionary/
├── index.ts # 主导出文件 ├── orchestrator.ts # 编排器
├── orchestrator.ts # 主编排器,串联所有阶段 ├── stage1-preprocess.ts # 阶段1预处理输入分析+语义映射+标准形式)
├── types.ts # 类型定义 ├── stage4-entriesGeneration.ts # 阶段2词条生成
── stage1-inputAnalysis.ts # 阶段1输入解析与语言识别 ── types.ts # 类型定义
├── stage2-semanticMapping.ts # 阶段2跨语言语义映射决策
├── stage3-standardForm.ts # 阶段3standardForm 生成与规范化
└── stage4-entriesGeneration.ts # 阶段4释义与词条生成
``` ```
## 工作流程 ## 工作流程
@@ -20,187 +17,22 @@ dictionary/
``` ```
用户输入 用户输入
[阶段1] 输入分析 → 代码验证 → 失败则返回错误 [阶段1] 预处理1次LLM→ isValid, standardForm, inputType
[阶段2] 语义映射 → 代码验证 → 失败则保守处理(不映射) [阶段2] 词条生成1次LLM→ entries
[阶段3] 标准形式 → 代码验证 → 失败则返回错误
[阶段4] 词条生成 → 代码验证 → 失败则返回错误
最终结果 最终结果
``` ```
## 各阶段详细说明 ## 性能
### 阶段 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 ```typescript
import { lookUp } from "@/lib/server/bigmodel/dictionaryActions"; import { executeDictionaryLookup } from "@/lib/bigmodel/dictionary/orchestrator";
const result = await lookUp({ const result = await executeDictionaryLookup("hello", "English", "中文");
text: "hello",
queryLang: "English",
definitionLang: "中文"
});
``` ```
### 高级使用(直接调用编排器)
```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,9 +1,10 @@
import { ServiceOutputLookUp } from "@/modules/dictionary/dictionary-service-dto"; import { ServiceOutputLookUp } from "@/modules/dictionary/dictionary-service-dto";
import { analyzeInput } from "./stage1-inputAnalysis"; import { preprocessInput } from "./stage1-preprocess";
import { determineSemanticMapping } from "./stage2-semanticMapping";
import { generateStandardForm } from "./stage3-standardForm";
import { generateEntries } from "./stage4-entriesGeneration"; import { generateEntries } from "./stage4-entriesGeneration";
import { LookUpError } from "@/lib/errors"; import { LookUpError } from "@/lib/errors";
import { createLogger } from "@/lib/logger";
const log = createLogger("dictionary-orchestrator");
export async function executeDictionaryLookup( export async function executeDictionaryLookup(
text: string, text: string,
@@ -11,80 +12,36 @@ export async function executeDictionaryLookup(
definitionLang: string definitionLang: string
): Promise<ServiceOutputLookUp> { ): Promise<ServiceOutputLookUp> {
try { try {
// ========== 阶段 1输入分析 ========== log.debug("[Stage 1] Preprocessing input");
console.log("[阶段1] 开始输入分析..."); const preprocessed = await preprocessInput(text, queryLang);
const analysis = await analyzeInput(text);
// 代码层面验证:输入是否有效 if (!preprocessed.isValid) {
if (!analysis.isValid) { log.debug("[Stage 1] Invalid input", { reason: preprocessed.reason });
console.log("[阶段1] 输入无效:", analysis.reason); throw new LookUpError(preprocessed.reason || "无效输入");
throw analysis.reason || "无效输入";
} }
if (analysis.isEmpty) { log.debug("[Stage 1] Preprocess complete", { preprocessed });
console.log("[阶段1] 输入为空");
throw "输入为空";
}
console.log("[阶段1] 输入分析完成:", analysis); log.debug("[Stage 2] Generating entries");
// ========== 阶段 2语义映射 ==========
console.log("[阶段2] 开始语义映射...");
const semanticMapping = await determineSemanticMapping(
text,
queryLang,
analysis.inputLanguage || text
);
console.log("[阶段2] 语义映射完成:", semanticMapping);
// ========== 阶段 3生成标准形式 ==========
console.log("[阶段3] 开始生成标准形式...");
// 如果进行了语义映射,标准形式要基于映射后的结果
// 同时传递原始输入作为语义参考
const shouldUseMapping = semanticMapping.shouldMap && semanticMapping.mappedQuery;
const inputForStandardForm = shouldUseMapping ? semanticMapping.mappedQuery! : text;
const standardFormResult = await generateStandardForm(
inputForStandardForm,
queryLang,
shouldUseMapping ? text : undefined // 如果进行了映射,传递原始输入作为语义参考
);
// 代码层面验证:标准形式不能为空
if (!standardFormResult.standardForm) {
console.error("[阶段3] 标准形式为空");
throw "无法生成标准形式";
}
console.log("[阶段3] 标准形式生成完成:", standardFormResult);
// ========== 阶段 4生成词条 ==========
console.log("[阶段4] 开始生成词条...");
const entriesResult = await generateEntries( const entriesResult = await generateEntries(
standardFormResult.standardForm, preprocessed.standardForm,
queryLang, queryLang,
definitionLang, definitionLang,
analysis.inputType === "unknown" preprocessed.inputType
? (standardFormResult.standardForm.includes(" ") ? "phrase" : "word")
: analysis.inputType
); );
console.log("[阶段4] 词条生成完成:", entriesResult); log.debug("[Stage 2] Entries complete", { entriesResult });
// ========== 组装最终结果 ==========
const finalResult: ServiceOutputLookUp = { const finalResult: ServiceOutputLookUp = {
standardForm: standardFormResult.standardForm, standardForm: preprocessed.standardForm,
entries: entriesResult.entries, entries: entriesResult.entries,
}; };
console.log("[完成] 词典查询成功"); log.info("Dictionary lookup completed successfully");
return finalResult; return finalResult;
} catch (error) { } catch (error) {
console.error("[错误] 词典查询失败:", error); log.error("Dictionary lookup failed", { error: error instanceof Error ? error.message : String(error) });
const errorMessage = error instanceof Error ? error.message : "未知错误"; const errorMessage = error instanceof Error ? error.message : "未知错误";
throw new LookUpError(errorMessage); throw new LookUpError(errorMessage);
} }

View File

@@ -1,66 +0,0 @@
import { getAnswer } from "../zhipu";
import { parseAIGeneratedJSON } from "@/utils/json";
import { InputAnalysisResult } from "./types";
/**
* 阶段 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) {
console.error("阶段1失败", 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,106 +0,0 @@
import { getAnswer } from "../zhipu";
import { parseAIGeneratedJSON } from "@/utils/json";
import { SemanticMappingResult } from "./types";
/**
* 阶段 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<any>);
// 代码层面的数据验证
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) {
console.error("阶段2失败", error);
// 失败时直接抛出错误,让编排器返回错误响应
throw error;
}
}

View File

@@ -1,97 +0,0 @@
import { getAnswer } from "../zhipu";
import { parseAIGeneratedJSON } from "@/utils/json";
import { StandardFormResult } from "./types";
/**
* 阶段 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<any>);
// 代码层面的数据验证
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) {
console.error("阶段3失败", error);
// 失败时抛出错误
throw error;
}
}

View File

@@ -1,12 +1,9 @@
import { getAnswer } from "../zhipu"; import { getAnswer } from "../llm";
import { parseAIGeneratedJSON } from "@/utils/json"; import { parseAIGeneratedJSON } from "@/utils/json";
import { EntriesGenerationResult } from "./types"; import { EntriesGenerationResult } from "./types";
import { createLogger } from "@/lib/logger";
/** const log = createLogger("dictionary-entries");
* 阶段 4释义与词条生成
*
* 独立的 LLM 调用,生成词典条目
*/
export async function generateEntries( export async function generateEntries(
standardForm: string, standardForm: string,
@@ -17,93 +14,42 @@ export async function generateEntries(
const isWord = inputType === "word"; const isWord = inputType === "word";
const prompt = ` const prompt = `
你是一个词典条目生成器。为标准形式生成词典条目。 生成词典条目。词语:"${standardForm}"${queryLang})。用${definitionLang}释义
标准形式:${standardForm} 返回 JSON
查询语言:${queryLang} ${isWord ? `{"entries":[{"ipa":"音标","partOfSpeech":"词性","definition":"释义","example":"例句"}]}` : `{"entries":[{"definition":"释义","example":"例句"}]}`}
释义语言:${definitionLang}
词条类型:${isWord ? "单词" : "短语"}
${isWord ? ` 只返回 JSON。
单词条目要求:
- ipa音标如适用
- partOfSpeech词性
- definition释义使用 ${definitionLang}
- example例句使用 ${queryLang}
` : `
短语条目要求:
- definition短语释义使用 ${definitionLang}
- example例句使用 ${queryLang}
`}
生成 1-3 个条目,返回 JSON 格式:
{
"entries": [
${isWord ? `
{
"ipa": "音标",
"partOfSpeech": "词性",
"definition": "释义",
"example": "例句"
}` : `
{
"definition": "释义",
"example": "例句"
}`}
]
}
只返回 JSON不要任何其他文字。
`.trim(); `.trim();
try { try {
const result = await getAnswer([ const result = await getAnswer([
{ { role: "system", content: "词典条目生成器,只返回 JSON。" },
role: "system", { role: "user", content: prompt },
content: `你是一个词典条目生成器,只返回 JSON 格式的结果。`,
},
{
role: "user",
content: prompt,
},
]).then(parseAIGeneratedJSON<EntriesGenerationResult>); ]).then(parseAIGeneratedJSON<EntriesGenerationResult>);
// 代码层面的数据验证 if (!result.entries?.length) {
if (!result.entries || !Array.isArray(result.entries) || result.entries.length === 0) { throw new Error("词条生成失败:结果为空");
throw new Error("阶段4entries 为空或不是数组");
} }
// 处理每个条目,清理 IPA 格式
for (const entry of result.entries) { for (const entry of result.entries) {
// 清理 IPA删除两端可能包含的方括号、斜杠等字符
if (entry.ipa) { if (entry.ipa) {
entry.ipa = entry.ipa.trim(); entry.ipa = entry.ipa.trim().replace(/^[\[\/]/, '').replace(/[\]\/]$/, '');
// 删除开头的 [ / /
entry.ipa = entry.ipa.replace(/^[\[\/]/, '');
// 删除结尾的 ] / /
entry.ipa = entry.ipa.replace(/[\]\/]$/, '');
} }
if (!entry.definition?.trim()) {
if (!entry.definition || entry.definition.trim().length === 0) { throw new Error("词条缺少释义");
throw new Error("阶段4条目缺少 definition");
} }
if (!entry.example?.trim()) {
if (!entry.example || entry.example.trim().length === 0) { throw new Error("词条缺少例句");
throw new Error("阶段4条目缺少 example");
} }
if (isWord && !entry.partOfSpeech) { if (isWord && !entry.partOfSpeech) {
throw new Error("阶段4单词条目缺少 partOfSpeech"); throw new Error("单词条目缺少词性");
}
if (isWord && !entry.ipa) {
throw new Error("阶段4单词条目缺少 ipa");
} }
} }
return result; return result;
} catch (error) { } catch (error) {
console.error("阶段4失败", error); log.error("Entries generation failed", { error: error instanceof Error ? error.message : String(error) });
throw error; // 阶段4失败应该返回错误因为这个阶段是核心 throw error;
} }
} }

View File

@@ -1,43 +1,21 @@
/**
* 词典查询的类型定义
*/
export interface DictionaryContext { export interface DictionaryContext {
queryLang: string; queryLang: string;
definitionLang: string; definitionLang: string;
} }
// 阶段1输入分析结果 export interface PreprocessResult {
export interface InputAnalysisResult {
isValid: boolean; isValid: boolean;
isEmpty: boolean; inputType: "word" | "phrase";
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 {
standardForm: string; standardForm: string;
confidence: "high" | "medium" | "low"; confidence: "high" | "medium" | "low";
reason: string; reason: string;
} }
// 阶段4词条生成结果
export interface EntriesGenerationResult { export interface EntriesGenerationResult {
entries: Array<{ entries: Array<{
ipa?: string; ipa?: string;
definition: string; definition: string;
partOfSpeech?: string; partOfSpeech?: string;
example: string; // example 必需 example: string;
}>; }>;
} }

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

@@ -0,0 +1,37 @@
"use server";
import OpenAI from "openai";
const openai = new OpenAI({
apiKey: process.env.ZHIPU_API_KEY,
baseURL: "https://open.bigmodel.cn/api/paas/v4",
});
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 openai.chat.completions.create({
model: process.env.ZHIPU_MODEL_NAME || "glm-4",
messages: messages as OpenAI.Chat.Completions.ChatCompletionMessageParam[],
temperature: 0.2,
});
const content = response.choices[0]?.message?.content;
if (!content) {
throw new Error("AI API 返回空响应");
}
return content.trim();
}
export { getAnswer };

View File

@@ -1,6 +1,9 @@
import { getAnswer } from "../zhipu"; import { getAnswer } from "../llm";
import { parseAIGeneratedJSON } from "@/utils/json"; import { parseAIGeneratedJSON } from "@/utils/json";
import { LanguageDetectionResult, TranslationLLMResponse } from "./types"; import { LanguageDetectionResult, TranslationLLMResponse } from "./types";
import { createLogger } from "@/lib/logger";
const log = createLogger("translator-orchestrator");
async function detectLanguage(text: string): Promise<LanguageDetectionResult> { async function detectLanguage(text: string): Promise<LanguageDetectionResult> {
const prompt = ` const prompt = `
@@ -40,7 +43,7 @@ async function detectLanguage(text: string): Promise<LanguageDetectionResult> {
return result; return result;
} catch (error) { } catch (error) {
console.error("Language detection failed:", error); log.error("Language detection failed", { error });
throw new Error("Failed to detect source language"); throw new Error("Failed to detect source language");
} }
} }
@@ -82,7 +85,7 @@ async function performTranslation(
return result.trim(); return result.trim();
} catch (error) { } catch (error) {
console.error("Translation failed:", error); log.error("Translation failed", { error });
throw new Error("Translation failed"); throw new Error("Translation failed");
} }
} }
@@ -121,7 +124,7 @@ async function generateIPA(
return result.trim(); return result.trim();
} catch (error) { } catch (error) {
console.error("IPA generation failed:", error); log.error("IPA generation failed", { error });
return ""; return "";
} }
} }
@@ -132,24 +135,19 @@ export async function executeTranslation(
needIpa: boolean needIpa: boolean
): Promise<TranslationLLMResponse> { ): Promise<TranslationLLMResponse> {
try { try {
console.log("[翻译] 开始翻译流程..."); log.debug("Starting translation", { sourceText, targetLanguage, needIpa });
console.log("[翻译] 源文本:", sourceText);
console.log("[翻译] 目标语言:", targetLanguage);
console.log("[翻译] 需要 IPA:", needIpa);
// Stage 1: Detect source language log.debug("[Stage 1] Detecting source language");
console.log("[阶段1] 检测源语言...");
const detectionResult = await detectLanguage(sourceText); const detectionResult = await detectLanguage(sourceText);
console.log("[阶段1] 检测结果:", detectionResult); log.debug("[Stage 1] Detection result", { detectionResult });
// Stage 2: Perform translation log.debug("[Stage 2] Performing translation");
console.log("[阶段2] 执行翻译...");
const translatedText = await performTranslation( const translatedText = await performTranslation(
sourceText, sourceText,
detectionResult.sourceLanguage, detectionResult.sourceLanguage,
targetLanguage targetLanguage
); );
console.log("[阶段2] 翻译完成:", translatedText); log.debug("[Stage 2] Translation complete", { translatedText });
// Validate translation result // Validate translation result
if (!translatedText) { if (!translatedText) {
@@ -161,12 +159,12 @@ export async function executeTranslation(
let targetIpa: string | undefined; let targetIpa: string | undefined;
if (needIpa) { if (needIpa) {
console.log("[阶段3] 生成 IPA..."); log.debug("[Stage 3] Generating IPA");
sourceIpa = await generateIPA(sourceText, detectionResult.sourceLanguage); sourceIpa = await generateIPA(sourceText, detectionResult.sourceLanguage);
console.log("[阶段3] 源文本 IPA:", sourceIpa); log.debug("[Stage 3] Source IPA", { sourceIpa });
targetIpa = await generateIPA(translatedText, targetLanguage); targetIpa = await generateIPA(translatedText, targetLanguage);
console.log("[阶段3] 目标文本 IPA:", targetIpa); log.debug("[Stage 3] Target IPA", { targetIpa });
} }
// Assemble final result // Assemble final result
@@ -179,10 +177,10 @@ export async function executeTranslation(
targetIpa, targetIpa,
}; };
console.log("[完成] 翻译流程成功"); log.info("Translation completed successfully");
return finalResult; return finalResult;
} catch (error) { } catch (error) {
console.error("[错误] 翻译失败:", error); log.error("Translation failed", { error });
const errorMessage = error instanceof Error ? error.message : "未知错误"; const errorMessage = error instanceof Error ? error.message : "未知错误";
throw new Error(errorMessage); throw new Error(errorMessage);
} }

View File

@@ -1,5 +1,9 @@
"use server"; "use server";
import { createLogger } from "@/lib/logger";
const log = createLogger("tts");
// ==================== 类型定义 ==================== // ==================== 类型定义 ====================
/** /**
@@ -147,7 +151,7 @@ class QwenTTSService {
return data; return data;
} catch (error) { } catch (error) {
console.error('语音合成请求失败:', error); log.error("TTS request failed", { error });
throw error; throw error;
} }
} }
@@ -157,11 +161,7 @@ export type TTS_SUPPORTED_LANGUAGES = 'Auto' | 'Chinese' | 'English' | 'German'
export async function getTTSUrl(text: string, lang: TTS_SUPPORTED_LANGUAGES) { export async function getTTSUrl(text: string, lang: TTS_SUPPORTED_LANGUAGES) {
try { try {
if (!process.env.DASHSCORE_API_KEY) { if (!process.env.DASHSCORE_API_KEY) {
console.warn( log.warn("DASHSCORE_API_KEY not set");
`⚠️ 环境变量 DASHSCORE_API_KEY 未设置\n` +
` 请在 .env 文件中设置或直接传入API Key\n` +
` 获取API Key: https://help.aliyun.com/zh/model-studio/get-api-key`
);
throw "API Key设置错误"; throw "API Key设置错误";
} }
const ttsService = new QwenTTSService( const ttsService = new QwenTTSService(
@@ -176,7 +176,7 @@ export async function getTTSUrl(text: string, lang: TTS_SUPPORTED_LANGUAGES) {
); );
return result.output.audio.url; return result.output.audio.url;
} catch (error) { } catch (error) {
console.error('TTS合成失败:', error instanceof Error ? error.message : error); log.error("TTS synthesis failed", { error: error instanceof Error ? error.message : error });
return "error"; return "error";
} }
} }

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

View File

@@ -4,6 +4,7 @@ import { auth } from "@/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ValidateError } from "@/lib/errors"; import { ValidateError } from "@/lib/errors";
import { createLogger } from "@/lib/logger";
import { import {
ActionInputGetUserProfileByUsername, ActionInputGetUserProfileByUsername,
ActionInputSignIn, ActionInputSignIn,
@@ -23,6 +24,8 @@ import {
// Re-export types for use in components // Re-export types for use in components
export type { ActionOutputAuth, ActionOutputUserProfile } from "./auth-action-dto"; export type { ActionOutputAuth, ActionOutputUserProfile } from "./auth-action-dto";
const log = createLogger("auth-action");
/** /**
* Sign up action * Sign up action
* Creates a new user account * Creates a new user account
@@ -68,7 +71,7 @@ export async function actionSignUp(prevState: ActionOutputAuth | undefined, form
message: e.message, message: e.message,
}; };
} }
console.error("Sign up error:", e); log.error("Sign up failed", { error: e });
return { return {
success: false, success: false,
message: "Registration failed. Please try again later.", message: "Registration failed. Please try again later.",
@@ -121,7 +124,7 @@ export async function actionSignIn(_prevState: ActionOutputAuth | undefined, for
message: e.message, message: e.message,
}; };
} }
console.error("Sign in error:", e); log.error("Sign in failed", { error: e });
return { return {
success: false, success: false,
message: "Sign in failed. Please check your credentials.", message: "Sign in failed. Please check your credentials.",
@@ -144,7 +147,7 @@ export async function signOutAction() {
if (e instanceof Error && e.message.includes('NEXT_REDIRECT')) { if (e instanceof Error && e.message.includes('NEXT_REDIRECT')) {
throw e; throw e;
} }
console.error("Sign out error:", e); log.error("Sign out failed", { error: e });
redirect("/login"); redirect("/login");
} }
} }
@@ -170,7 +173,7 @@ export async function actionGetUserProfileByUsername(dto: ActionInputGetUserProf
data: userProfile, data: userProfile,
}; };
} catch (e) { } catch (e) {
console.error("Get user profile error:", e); log.error("Get user profile failed", { error: e });
return { return {
success: false, success: false,
message: "Failed to retrieve user profile", message: "Failed to retrieve user profile",

View File

@@ -2,8 +2,11 @@
import { ActionInputLookUpDictionary, ActionOutputLookUpDictionary, validateActionInputLookUpDictionary } from "./dictionary-action-dto"; import { ActionInputLookUpDictionary, ActionOutputLookUpDictionary, validateActionInputLookUpDictionary } from "./dictionary-action-dto";
import { ValidateError } from "@/lib/errors"; import { ValidateError } from "@/lib/errors";
import { createLogger } from "@/lib/logger";
import { serviceLookUp } from "./dictionary-service"; import { serviceLookUp } from "./dictionary-service";
const log = createLogger("dictionary-action");
export const actionLookUpDictionary = async (dto: ActionInputLookUpDictionary): Promise<ActionOutputLookUpDictionary> => { export const actionLookUpDictionary = async (dto: ActionInputLookUpDictionary): Promise<ActionOutputLookUpDictionary> => {
try { try {
return { return {
@@ -18,7 +21,7 @@ export const actionLookUpDictionary = async (dto: ActionInputLookUpDictionary):
message: e.message message: e.message
}; };
} }
console.log(e); log.error("Dictionary lookup failed", { error: e instanceof Error ? e.message : String(e) });
return { return {
success: false, success: false,
message: 'Unknown error occured.' message: 'Unknown error occured.'

View File

@@ -1,6 +1,9 @@
import { executeDictionaryLookup } from "@/lib/bigmodel/dictionary/orchestrator"; import { executeDictionaryLookup } from "@/lib/bigmodel/dictionary/orchestrator";
import { repoCreateLookUp, repoCreateLookUpWithItemAndEntries, repoSelectLastLookUpResult } from "./dictionary-repository"; import { repoCreateLookUp, repoCreateLookUpWithItemAndEntries, repoSelectLastLookUpResult } from "./dictionary-repository";
import { ServiceInputLookUp } from "./dictionary-service-dto"; import { ServiceInputLookUp } from "./dictionary-service-dto";
import { createLogger } from "@/lib/logger";
const log = createLogger("dictionary-service");
export const serviceLookUp = async (dto: ServiceInputLookUp) => { export const serviceLookUp = async (dto: ServiceInputLookUp) => {
const { const {
@@ -39,7 +42,7 @@ export const serviceLookUp = async (dto: ServiceInputLookUp) => {
}, },
response.entries response.entries
).catch(error => { ).catch(error => {
console.error('Failed to save dictionary data:', error); log.error("Failed to save dictionary data", { error: error instanceof Error ? error.message : String(error) });
}); });
return response; return response;
@@ -51,7 +54,7 @@ export const serviceLookUp = async (dto: ServiceInputLookUp) => {
definitionLang: definitionLang, definitionLang: definitionLang,
dictionaryItemId: lastLookUpResult.id dictionaryItemId: lastLookUpResult.id
}).catch(error => { }).catch(error => {
console.error('Failed to save dictionary data:', error); log.error("Failed to save dictionary data", { error: error instanceof Error ? error.message : String(error) });
}); });
return { return {
standardForm: lastLookUpResult.standardForm, standardForm: lastLookUpResult.standardForm,

View File

@@ -62,6 +62,12 @@ export type ActionOutputGetPublicFolders = {
data?: ActionOutputPublicFolder[]; data?: ActionOutputPublicFolder[];
}; };
export type ActionOutputGetPublicFolderById = {
message: string;
success: boolean;
data?: ActionOutputPublicFolder;
};
export type ActionOutputSetFolderVisibility = { export type ActionOutputSetFolderVisibility = {
message: string; message: string;
success: boolean; success: boolean;

View File

@@ -3,11 +3,15 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { ValidateError } from "@/lib/errors"; import { ValidateError } from "@/lib/errors";
import { createLogger } from "@/lib/logger";
const log = createLogger("folder-action");
import { import {
ActionInputCreatePair, ActionInputCreatePair,
ActionInputUpdatePairById, ActionInputUpdatePairById,
ActionOutputGetFoldersWithTotalPairsByUserId, ActionOutputGetFoldersWithTotalPairsByUserId,
ActionOutputGetPublicFolders, ActionOutputGetPublicFolders,
ActionOutputGetPublicFolderById,
ActionOutputSetFolderVisibility, ActionOutputSetFolderVisibility,
ActionOutputToggleFavorite, ActionOutputToggleFavorite,
ActionOutputCheckFavorite, ActionOutputCheckFavorite,
@@ -27,6 +31,7 @@ import {
repoGetFoldersWithTotalPairsByUserId, repoGetFoldersWithTotalPairsByUserId,
repoGetPairsByFolderId, repoGetPairsByFolderId,
repoGetPublicFolders, repoGetPublicFolders,
repoGetPublicFolderById,
repoGetUserIdByFolderId, repoGetUserIdByFolderId,
repoRenameFolderById, repoRenameFolderById,
repoSearchPublicFolders, repoSearchPublicFolders,
@@ -68,7 +73,7 @@ export async function actionGetPairsByFolderId(folderId: number) {
data: await repoGetPairsByFolderId(folderId) data: await repoGetPairsByFolderId(folderId)
}; };
} catch (e) { } catch (e) {
console.log(e); log.error("Operation failed", { error: e });
return { return {
success: false, success: false,
message: 'Unknown error occured.' message: 'Unknown error occured.'
@@ -93,7 +98,7 @@ export async function actionUpdatePairById(id: number, dto: ActionInputUpdatePai
message: 'success', message: 'success',
}; };
} catch (e) { } catch (e) {
console.log(e); log.error("Operation failed", { error: e });
return { return {
success: false, success: false,
message: 'Unknown error occured.' message: 'Unknown error occured.'
@@ -109,7 +114,7 @@ export async function actionGetUserIdByFolderId(folderId: number) {
data: await repoGetUserIdByFolderId(folderId) data: await repoGetUserIdByFolderId(folderId)
}; };
} catch (e) { } catch (e) {
console.log(e); log.error("Operation failed", { error: e });
return { return {
success: false, success: false,
message: 'Unknown error occured.' message: 'Unknown error occured.'
@@ -125,7 +130,7 @@ export async function actionGetFolderVisibility(folderId: number) {
data: await repoGetFolderVisibility(folderId) data: await repoGetFolderVisibility(folderId)
}; };
} catch (e) { } catch (e) {
console.log(e); log.error("Operation failed", { error: e });
return { return {
success: false, success: false,
message: 'Unknown error occured.' message: 'Unknown error occured.'
@@ -149,7 +154,7 @@ export async function actionDeleteFolderById(folderId: number) {
message: 'success', message: 'success',
}; };
} catch (e) { } catch (e) {
console.log(e); log.error("Operation failed", { error: e });
return { return {
success: false, success: false,
message: 'Unknown error occured.' message: 'Unknown error occured.'
@@ -173,7 +178,7 @@ export async function actionDeletePairById(id: number) {
message: 'success' message: 'success'
}; };
} catch (e) { } catch (e) {
console.log(e); log.error("Operation failed", { error: e });
return { return {
success: false, success: false,
message: 'Unknown error occured.' message: 'Unknown error occured.'
@@ -189,7 +194,7 @@ export async function actionGetFoldersWithTotalPairsByUserId(id: string): Promis
data: await repoGetFoldersWithTotalPairsByUserId(id) data: await repoGetFoldersWithTotalPairsByUserId(id)
}; };
} catch (e) { } catch (e) {
console.log(e); log.error("Operation failed", { error: e });
return { return {
success: false, success: false,
message: 'Unknown error occured.' message: 'Unknown error occured.'
@@ -205,7 +210,7 @@ export async function actionGetFoldersByUserId(userId: string) {
data: await repoGetFoldersByUserId(userId) data: await repoGetFoldersByUserId(userId)
}; };
} catch (e) { } catch (e) {
console.log(e); log.error("Operation failed", { error: e });
return { return {
success: false, success: false,
message: 'Unknown error occured.' message: 'Unknown error occured.'
@@ -236,7 +241,7 @@ export async function actionCreatePair(dto: ActionInputCreatePair) {
message: e.message message: e.message
}; };
} }
console.log(e); log.error("Operation failed", { error: e });
return { return {
success: false, success: false,
message: 'Unknown error occured.' message: 'Unknown error occured.'
@@ -266,7 +271,7 @@ export async function actionCreateFolder(userId: string, folderName: string) {
message: e.message message: e.message
}; };
} }
console.log(e); log.error("Operation failed", { error: e });
return { return {
success: false, success: false,
message: 'Unknown error occured.' message: 'Unknown error occured.'
@@ -302,7 +307,7 @@ export async function actionRenameFolderById(id: number, newName: string) {
message: e.message message: e.message
}; };
} }
console.log(e); log.error("Operation failed", { error: e });
return { return {
success: false, success: false,
message: 'Unknown error occured.' message: 'Unknown error occured.'
@@ -332,7 +337,7 @@ export async function actionSetFolderVisibility(
message: 'success', message: 'success',
}; };
} catch (e) { } catch (e) {
console.log(e); log.error("Operation failed", { error: e });
return { return {
success: false, success: false,
message: 'Unknown error occured.', message: 'Unknown error occured.',
@@ -352,7 +357,7 @@ export async function actionGetPublicFolders(): Promise<ActionOutputGetPublicFol
})), })),
}; };
} catch (e) { } catch (e) {
console.log(e); log.error("Operation failed", { error: e });
return { return {
success: false, success: false,
message: 'Unknown error occured.', message: 'Unknown error occured.',
@@ -372,7 +377,33 @@ export async function actionSearchPublicFolders(query: string): Promise<ActionOu
})), })),
}; };
} catch (e) { } catch (e) {
console.log(e); log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.',
};
}
}
export async function actionGetPublicFolderById(folderId: number): Promise<ActionOutputGetPublicFolderById> {
try {
const folder = await repoGetPublicFolderById(folderId);
if (!folder) {
return {
success: false,
message: 'Folder not found.',
};
}
return {
success: true,
message: 'success',
data: {
...folder,
visibility: folder.visibility as "PRIVATE" | "PUBLIC",
},
};
} catch (e) {
log.error("Operation failed", { error: e });
return { return {
success: false, success: false,
message: 'Unknown error occured.', message: 'Unknown error occured.',
@@ -411,7 +442,7 @@ export async function actionToggleFavorite(
}, },
}; };
} catch (e) { } catch (e) {
console.log(e); log.error("Operation failed", { error: e });
return { return {
success: false, success: false,
message: 'Unknown error occured.', message: 'Unknown error occured.',
@@ -449,7 +480,7 @@ export async function actionCheckFavorite(
}, },
}; };
} catch (e) { } catch (e) {
console.log(e); log.error("Operation failed", { error: e });
return { return {
success: false, success: false,
message: 'Unknown error occured.', message: 'Unknown error occured.',
@@ -487,7 +518,7 @@ export async function actionGetUserFavorites(): Promise<ActionOutputGetUserFavor
})), })),
}; };
} catch (e) { } catch (e) {
console.log(e); log.error("Operation failed", { error: e });
return { return {
success: false, success: false,
message: 'Unknown error occured.', message: 'Unknown error occured.',

View File

@@ -171,6 +171,32 @@ export async function repoGetFolderVisibility(
return folder; return folder;
} }
export async function repoGetPublicFolderById(
folderId: number,
): Promise<RepoOutputPublicFolder | null> {
const folder = await prisma.folder.findUnique({
where: { id: folderId, visibility: Visibility.PUBLIC },
include: {
_count: { select: { pairs: true, favorites: true } },
user: { select: { name: true, username: true } },
},
});
if (!folder) return null;
return {
id: folder.id,
name: folder.name,
visibility: folder.visibility,
createdAt: folder.createdAt,
userId: folder.userId,
userName: folder.user?.name ?? "Unknown",
userUsername: folder.user?.username ?? "unknown",
totalPairs: folder._count.pairs,
favoriteCount: folder._count.favorites,
};
}
export async function repoGetPublicFolders( export async function repoGetPublicFolders(
input: RepoInputGetPublicFolders = {}, input: RepoInputGetPublicFolders = {},
): Promise<RepoOutputPublicFolder[]> { ): Promise<RepoOutputPublicFolder[]> {
@@ -192,8 +218,8 @@ export async function repoGetPublicFolders(
visibility: folder.visibility, visibility: folder.visibility,
createdAt: folder.createdAt, createdAt: folder.createdAt,
userId: folder.userId, userId: folder.userId,
userName: folder.user.name, userName: folder.user?.name ?? "Unknown",
userUsername: folder.user.username, userUsername: folder.user?.username ?? "unknown",
totalPairs: folder._count.pairs, totalPairs: folder._count.pairs,
favoriteCount: folder._count.favorites, favoriteCount: folder._count.favorites,
})); }));
@@ -221,8 +247,8 @@ export async function repoSearchPublicFolders(
visibility: folder.visibility, visibility: folder.visibility,
createdAt: folder.createdAt, createdAt: folder.createdAt,
userId: folder.userId, userId: folder.userId,
userName: folder.user.name, userName: folder.user?.name ?? "Unknown",
userUsername: folder.user.username, userUsername: folder.user?.username ?? "unknown",
totalPairs: folder._count.pairs, totalPairs: folder._count.pairs,
favoriteCount: folder._count.favorites, favoriteCount: folder._count.favorites,
})); }));
@@ -300,8 +326,8 @@ export async function repoGetUserFavorites(input: RepoInputGetUserFavorites) {
folderCreatedAt: fav.folder.createdAt, folderCreatedAt: fav.folder.createdAt,
folderTotalPairs: fav.folder._count.pairs, folderTotalPairs: fav.folder._count.pairs,
folderOwnerId: fav.folder.userId, folderOwnerId: fav.folder.userId,
folderOwnerName: fav.folder.user.name, folderOwnerName: fav.folder.user?.name ?? "Unknown",
folderOwnerUsername: fav.folder.user.username, folderOwnerUsername: fav.folder.user?.username ?? "unknown",
favoritedAt: fav.createdAt, favoritedAt: fav.createdAt,
})); }));
} }

View File

@@ -0,0 +1,108 @@
import { Visibility } from "../../../generated/prisma/enums";
export type ServiceInputCreateFolder = {
name: string;
userId: string;
};
export type ServiceInputRenameFolder = {
folderId: number;
newName: string;
};
export type ServiceInputDeleteFolder = {
folderId: number;
};
export type ServiceInputSetVisibility = {
folderId: number;
visibility: Visibility;
};
export type ServiceInputCheckOwnership = {
folderId: number;
userId: string;
};
export type ServiceInputCheckPairOwnership = {
pairId: number;
userId: string;
};
export type ServiceInputCreatePair = {
folderId: number;
text1: string;
text2: string;
language1: string;
language2: string;
};
export type ServiceInputUpdatePair = {
pairId: number;
text1?: string;
text2?: string;
language1?: string;
language2?: string;
};
export type ServiceInputDeletePair = {
pairId: number;
};
export type ServiceInputGetPublicFolders = {
limit?: number;
offset?: number;
};
export type ServiceInputSearchPublicFolders = {
query: string;
limit?: number;
};
export type ServiceInputToggleFavorite = {
folderId: number;
userId: string;
};
export type ServiceInputCheckFavorite = {
folderId: number;
userId: string;
};
export type ServiceInputGetUserFavorites = {
userId: string;
limit?: number;
offset?: number;
};
export type ServiceOutputFolder = {
id: number;
name: string;
visibility: Visibility;
createdAt: Date;
userId: string;
};
export type ServiceOutputFolderWithDetails = ServiceOutputFolder & {
userName: string | null;
userUsername: string | null;
totalPairs: number;
favoriteCount: number;
};
export type ServiceOutputFavoriteStatus = {
isFavorited: boolean;
favoriteCount: number;
};
export type ServiceOutputUserFavorite = {
id: number;
folderId: number;
folderName: string;
folderCreatedAt: Date;
folderTotalPairs: number;
folderOwnerId: string;
folderOwnerName: string | null;
folderOwnerUsername: string | null;
favoritedAt: Date;
};

View File

@@ -6,8 +6,11 @@ import {
validateActionInputTranslateText, validateActionInputTranslateText,
} from "./translator-action-dto"; } from "./translator-action-dto";
import { ValidateError } from "@/lib/errors"; import { ValidateError } from "@/lib/errors";
import { createLogger } from "@/lib/logger";
import { serviceTranslateText } from "./translator-service"; import { serviceTranslateText } from "./translator-service";
import { getAnswer } from "@/lib/bigmodel/zhipu"; import { getAnswer } from "@/lib/bigmodel/llm";
const log = createLogger("translator-action");
export const actionTranslateText = async ( export const actionTranslateText = async (
dto: ActionInputTranslateText dto: ActionInputTranslateText
@@ -25,7 +28,7 @@ export const actionTranslateText = async (
message: e.message, message: e.message,
}; };
} }
console.log(e); log.error("Translation action failed", { error: e });
return { return {
success: false, success: false,
message: "Unknown error occurred.", message: "Unknown error occurred.",

View File

@@ -1,6 +1,9 @@
import { executeTranslation } from "@/lib/bigmodel/translator/orchestrator"; import { executeTranslation } from "@/lib/bigmodel/translator/orchestrator";
import { repoCreateTranslationHistory, repoSelectLatestTranslation } from "./translator-repository"; import { repoCreateTranslationHistory, repoSelectLatestTranslation } from "./translator-repository";
import { ServiceInputTranslateText, ServiceOutputTranslateText } from "./translator-service-dto"; import { ServiceInputTranslateText, ServiceOutputTranslateText } from "./translator-service-dto";
import { createLogger } from "@/lib/logger";
const log = createLogger("translator-service");
export const serviceTranslateText = async ( export const serviceTranslateText = async (
dto: ServiceInputTranslateText dto: ServiceInputTranslateText
@@ -31,7 +34,7 @@ export const serviceTranslateText = async (
sourceIpa: needIpa ? response.sourceIpa : undefined, sourceIpa: needIpa ? response.sourceIpa : undefined,
targetIpa: needIpa ? response.targetIpa : undefined, targetIpa: needIpa ? response.targetIpa : undefined,
}).catch((error) => { }).catch((error) => {
console.error("Failed to save translation data:", error); log.error("Failed to save translation data", { error });
}); });
return { return {
@@ -54,7 +57,7 @@ export const serviceTranslateText = async (
sourceIpa: lastTranslation.sourceIpa || undefined, sourceIpa: lastTranslation.sourceIpa || undefined,
targetIpa: lastTranslation.targetIpa || undefined, targetIpa: lastTranslation.targetIpa || undefined,
}).catch((error) => { }).catch((error) => {
console.error("Failed to save translation data:", error); log.error("Failed to save translation data", { error });
}); });
return { return {