Compare commits

...

2 Commits

17 changed files with 625 additions and 39 deletions

View File

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

View File

@@ -237,7 +237,7 @@
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "Anmelden", "sign_in": "Anmelden",
"profile": "Profil", "profile": "Profil",
"folders": "Ordner", "folders": "Decks",
"explore": "Erkunden", "explore": "Erkunden",
"favorites": "Favoriten", "favorites": "Favoriten",
"settings": "Einstellungen" "settings": "Einstellungen"
@@ -269,7 +269,9 @@
"noDeck": "Bitte select a deck", "noDeck": "Bitte select a deck",
"processingFailed": "OCR-Verarbeitung fehlgeschlagen", "processingFailed": "OCR-Verarbeitung fehlgeschlagen",
"tryAgain": "Bitte try again with a clearer image", "tryAgain": "Bitte try again with a clearer image",
"detectedLanguages": "Erkannt: {source} → {target}" "detectedLanguages": "Erkannt: {source} → {target}",
"invalidFileType": "Ungültiger Dateityp. Bitte laden Sie eine Bilddatei hoch.",
"ocrFailed": "OCR-Verarbeitung fehlgeschlagen."
}, },
"profile": { "profile": {
"myProfile": "Mein Profil", "myProfile": "Mein Profil",
@@ -310,12 +312,28 @@
"videoUploadFailed": "Video-Upload fehlgeschlagen", "videoUploadFailed": "Video-Upload fehlgeschlagen",
"subtitleUploadFailed": "Untertitel-Upload fehlgeschlagen", "subtitleUploadFailed": "Untertitel-Upload fehlgeschlagen",
"subtitleLoadSuccess": "Untertitel erfolgreich geladen", "subtitleLoadSuccess": "Untertitel erfolgreich geladen",
"subtitleLoadFailed": "Laden der Untertitel fehlgeschlagen" "subtitleLoadFailed": "Laden der Untertitel fehlgeschlagen",
"settings": "Einstellungen",
"shortcuts": "Tastenkürzel",
"keyboardShortcuts": "Tastaturkürzel",
"playPause": "Wiedergabe/Pause",
"autoPauseToggle": "Auto-Pause umschalten",
"subtitleSettings": "Untertiteleinstellungen",
"fontSize": "Schriftgröße",
"textColor": "Textfarbe",
"backgroundColor": "Hintergrundfarbe",
"position": "Position",
"opacity": "Deckkraft",
"top": "Oben",
"center": "Mitte",
"bottom": "Unten"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "IPA generieren", "generateIPA": "IPA generieren",
"viewSavedItems": "Gespeicherte Einträge 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)",
"saved": "Gespeichert",
"clearAll": "Alles löschen"
}, },
"translator": { "translator": {
"detectLanguage": "Sprache erkennen", "detectLanguage": "Sprache erkennen",
@@ -462,5 +480,40 @@
"followingOf": "{username} folgt", "followingOf": "{username} folgt",
"noFollowers": "Noch keine Follower", "noFollowers": "Noch keine Follower",
"noFollowing": "Folgt noch niemandem" "noFollowing": "Folgt noch niemandem"
},
"deck_id": {
"unauthorized": "Sie sind nicht der Besitzer dieses Decks",
"back": "Zurück",
"cards": "Karten",
"itemsCount": "{count} Elemente",
"memorize": "Auswendig lernen",
"loadingCards": "Karten werden geladen...",
"noCards": "Keine Karten in diesem Deck",
"card": "Karte",
"addNewCard": "Neue Karte hinzufügen",
"add": "Hinzufügen",
"adding": "Wird hinzugefügt...",
"updateCard": "Karte aktualisieren",
"update": "Aktualisieren",
"updating": "Wird aktualisiert...",
"word": "Wort",
"definition": "Definition",
"ipa": "IPA",
"example": "Beispiel",
"wordAndDefinitionRequired": "Wort und Definition sind erforderlich",
"edit": "Bearbeiten",
"delete": "Löschen",
"permissionDenied": "Sie haben keine Berechtigung für diese Aktion",
"resetProgress": "Zurücksetzen",
"resetProgressTitle": "Deck-Fortschritt zurücksetzen",
"resetProgressConfirm": "Dies setzt alle Karten in diesem Deck auf den neuen Zustand zurück. Ihr Lernfortschritt geht verloren. Sind Sie sicher?",
"resetSuccess": "{count} Karten erfolgreich zurückgesetzt",
"resetting": "Wird zurückgesetzt...",
"cancel": "Abbrechen",
"error": {
"update": "Sie haben keine Berechtigung, diese Karte zu aktualisieren.",
"delete": "Sie haben keine Berechtigung, diese Karte zu löschen.",
"add": "Sie haben keine Berechtigung, Karten zu diesem Deck hinzuzufügen."
}
} }
} }

View File

@@ -74,6 +74,41 @@
"deleteFolder": "You do not have permission to delete this folder." "deleteFolder": "You do not have permission to delete this folder."
} }
}, },
"deck_id": {
"unauthorized": "You are not the owner of this deck",
"back": "Back",
"cards": "Cards",
"itemsCount": "{count} items",
"memorize": "Memorize",
"loadingCards": "Loading cards...",
"noCards": "No cards in this deck",
"card": "Card",
"addNewCard": "Add New Card",
"add": "Add",
"adding": "Adding...",
"updateCard": "Update Card",
"update": "Update",
"updating": "Updating...",
"word": "Word",
"definition": "Definition",
"ipa": "IPA",
"example": "Example",
"wordAndDefinitionRequired": "Word and definition are required",
"edit": "Edit",
"delete": "Delete",
"permissionDenied": "You do not have permission to perform this action",
"resetProgress": "Reset",
"resetProgressTitle": "Reset Deck Progress",
"resetProgressConfirm": "This will reset all cards in this deck to new state. Your learning progress will be lost. Are you sure?",
"resetSuccess": "Successfully reset {count} cards",
"resetting": "Resetting...",
"cancel": "Cancel",
"error": {
"update": "You do not have permission to update this card.",
"delete": "You do not have permission to delete this card.",
"add": "You do not have permission to add cards to this deck."
}
},
"home": { "home": {
"title": "Learn Languages", "title": "Learn Languages",
"description": "Here is a very useful website to help you learn almost every language in the world, including constructed ones.", "description": "Here is a very useful website to help you learn almost every language in the world, including constructed ones.",
@@ -228,7 +263,7 @@
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "Sign In", "sign_in": "Sign In",
"profile": "Profile", "profile": "Profile",
"folders": "Folders", "folders": "Decks",
"explore": "Explore", "explore": "Explore",
"favorites": "Favorites", "favorites": "Favorites",
"settings": "Settings" "settings": "Settings"
@@ -242,6 +277,7 @@
"dropOrClick": "Drag and drop an image here, or click to select", "dropOrClick": "Drag and drop an image here, or click to select",
"changeImage": "Click to change image", "changeImage": "Click to change image",
"supportedFormats": "Supports: JPG, PNG, WebP", "supportedFormats": "Supports: JPG, PNG, WebP",
"invalidFileType": "Invalid file type. Please upload an image file (JPG, PNG, or WebP).",
"deckSelection": "Select Deck", "deckSelection": "Select Deck",
"selectDeck": "Select a deck", "selectDeck": "Select a deck",
"chooseDeck": "Choose a deck to save extracted pairs", "chooseDeck": "Choose a deck to save extracted pairs",
@@ -265,6 +301,7 @@
"saving": "Saving...", "saving": "Saving...",
"saved": "Successfully saved {count} pairs to {deck}", "saved": "Successfully saved {count} pairs to {deck}",
"ocrSuccess": "Successfully extracted {count} pairs to {deck}", "ocrSuccess": "Successfully extracted {count} pairs to {deck}",
"ocrFailed": "OCR processing failed. Please try again.",
"savedToDeck": "Saved to {deckName}", "savedToDeck": "Saved to {deckName}",
"saveFailed": "Failed to save pairs", "saveFailed": "Failed to save pairs",
"noImage": "Please upload an image first", "noImage": "Please upload an image first",
@@ -315,12 +352,28 @@
"videoUploadFailed": "Video upload failed", "videoUploadFailed": "Video upload failed",
"subtitleUploadFailed": "Subtitle upload failed", "subtitleUploadFailed": "Subtitle upload failed",
"subtitleLoadSuccess": "Subtitle loaded successfully", "subtitleLoadSuccess": "Subtitle loaded successfully",
"subtitleLoadFailed": "Subtitle load failed" "subtitleLoadFailed": "Subtitle load failed",
"settings": "Settings",
"shortcuts": "Shortcuts",
"keyboardShortcuts": "Keyboard Shortcuts",
"playPause": "Play/Pause",
"autoPauseToggle": "Toggle Auto Pause",
"subtitleSettings": "Subtitle Settings",
"fontSize": "Font Size",
"textColor": "Text Color",
"backgroundColor": "Background Color",
"position": "Position",
"opacity": "Opacity",
"top": "Top",
"center": "Center",
"bottom": "Bottom"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "Generate IPA", "generateIPA": "Generate IPA",
"viewSavedItems": "View Saved Items", "viewSavedItems": "View Saved Items",
"confirmDeleteAll": "Are you sure you want to delete everything? (Y/N)" "confirmDeleteAll": "Are you sure you want to delete everything? (Y/N)",
"saved": "Saved",
"clearAll": "Clear All"
}, },
"translator": { "translator": {
"detectLanguage": "detect language", "detectLanguage": "detect language",

View File

@@ -83,7 +83,76 @@
"deleteFolder": "Vous n'avez pas la permission de supprimer ce dossier." "deleteFolder": "Vous n'avez pas la permission de supprimer ce dossier."
} }
}, },
"home": { "deck_id": {
"unauthorized": "Vous n'êtes pas le propriétaire de ce deck",
"back": "Retour",
"cards": "Cartes",
"itemsCount": "{count} éléments",
"memorize": "Mémoriser",
"loadingCards": "Chargement des cartes...",
"noCards": "Aucune carte dans ce deck",
"card": "Carte",
"addNewCard": "Ajouter une nouvelle carte",
"add": "Ajouter",
"adding": "Ajout en cours...",
"updateCard": "Mettre à jour la carte",
"update": "Mettre à jour",
"updating": "Mise à jour en cours...",
"word": "Mot",
"definition": "Définition",
"ipa": "IPA",
"example": "Exemple",
"wordAndDefinitionRequired": "Le mot et la définition sont requis",
"edit": "Modifier",
"delete": "Supprimer",
"permissionDenied": "Vous n'avez pas la permission d'effectuer cette action",
"resetProgress": "Réinitialiser",
"resetProgressTitle": "Réinitialiser la progression du deck",
"resetProgressConfirm": "Cela réinitialisera toutes les cartes de ce deck à l'état neuf. Votre progression d'apprentissage sera perdue. Êtes-vous sûr?",
"resetSuccess": "{count} cartes réinitialisées avec succès",
"resetting": "Réinitialisation en cours...",
"cancel": "Annuler",
"error": {
"update": "Vous n'avez pas la permission de mettre à jour cette carte.",
"delete": "Vous n'avez pas la permission de supprimer cette carte.",
"add": "Vous n'avez pas la permission d'ajouter des cartes à ce deck."
}
},
"deck_id": {
"unauthorized": "Vous n'êtes pas le propriétaire de ce paquet",
"back": "Retour",
"cards": "Cartes",
"itemsCount": "{count} éléments",
"memorize": "Mémoriser",
"loadingCards": "Chargement des cartes...",
"noCards": "Aucune carte dans ce paquet",
"card": "Carte",
"addNewCard": "Ajouter une nouvelle carte",
"add": "Ajouter",
"adding": "Ajout en cours...",
"updateCard": "Mettre à jour la carte",
"update": "Mettre à jour",
"updating": "Mise à jour en cours...",
"word": "Mot",
"definition": "Définition",
"ipa": "IPA",
"example": "Exemple",
"wordAndDefinitionRequired": "Le mot et la définition sont requis",
"edit": "Modifier",
"delete": "Supprimer",
"permissionDenied": "Vous n'avez pas la permission d'effectuer cette action",
"resetProgress": "Réinitialiser",
"resetProgressTitle": "Réinitialiser la progression du paquet",
"resetProgressConfirm": "Cela réinitialisera toutes les cards in this deck to new state. Your learning progress will be lost. Are you sure?",
"resetSuccess": "{count} cards réinitialisées avec succès",
"resetting": "Réinitialisation en cours...",
"cancel": "Annuler",
"error": {
"update": "Vous n'avez pas la permission de mettre à jour cette carte.",
"delete": "Vous n'avez pas la permission de supprimer cette carte.",
"add": "Vous n'avez pas la permission d'ajouter des cartes à ce paquet."
}
},
"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",
@@ -237,7 +306,7 @@
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "Connexion", "sign_in": "Connexion",
"profile": "Profil", "profile": "Profil",
"folders": "Dossiers", "folders": "Decks",
"explore": "Explorer", "explore": "Explorer",
"favorites": "Favoris", "favorites": "Favoris",
"settings": "Paramètres" "settings": "Paramètres"
@@ -454,6 +523,15 @@
"view": "Voir" "view": "Voir"
} }
}, },
"follow": {
"follow": "Suivre",
"following": "Abonnements",
"followers": "Abonnés",
"followersOf": "Abonnés de {username}",
"followingOf": "Abonnements de {username}",
"noFollowers": "Pas encore d'abonnés",
"noFollowing": "Ne suit personne"
},
"follow": { "follow": {
"follow": "Suivre", "follow": "Suivre",
"following": "Abonné", "following": "Abonné",

View File

@@ -83,7 +83,41 @@
"deleteFolder": "Non hai il permesso di eliminare questa cartella." "deleteFolder": "Non hai il permesso di eliminare questa cartella."
} }
}, },
"home": { "deck_id": {
"unauthorized": "Non sei il proprietario di questo deck",
"back": "Indietro",
"cards": "Schede",
"itemsCount": "{count} elementi",
"memorize": "Memorizza",
"loadingCards": "Caricamento schede...",
"noCards": "Nessuna scheda in questo deck",
"card": "Scheda",
"addNewCard": "Aggiungi nuova scheda",
"add": "Aggiungi",
"adding": "Aggiunta in corso...",
"updateCard": "Aggiorna scheda",
"update": "Aggiorna",
"updating": "Aggiornamento in corso...",
"word": "Parola",
"definition": "Definizione",
"ipa": "IPA",
"example": "Esempio",
"wordAndDefinitionRequired": "Parola e definizione sono obbligatori",
"edit": "Modifica",
"delete": "Elimina",
"permissionDenied": "Non hai il permesso per questa accion",
"resetProgress": "Ripristina",
"resetProgressTitle": "Ripristina progresso del deck",
"resetProgressConfirm": "Questo ripristinerá tutte las tarjetas de este deck al nuevo estado. Su progreso de aprendizaje se perderá Are you seguro?",
"resetSuccess": "{count} tarjetas ripristinate exitosamente",
"resetting": "Ripristazione en curso...",
"cancel": "Annulla",
"error": {
"update": "Non hai il permesso per aggiorn this card.",
"delete": "Non hai il permesso per delete this card.",
"add": "Non hai il permesso per add cards to this deck."
}
},
"title": "Impara le Lingue", "title": "Impara le Lingue",
"description": "Ecco un sito molto utile per aiutarti 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",
@@ -237,7 +271,7 @@
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "Accedi", "sign_in": "Accedi",
"profile": "Profilo", "profile": "Profilo",
"folders": "Cartelle", "folders": "Mazzi",
"explore": "Esplora", "explore": "Esplora",
"favorites": "Preferiti", "favorites": "Preferiti",
"settings": "Impostazioni" "settings": "Impostazioni"
@@ -447,13 +481,22 @@
"decks": { "decks": {
"title": "Mazzi", "title": "Mazzi",
"noDecks": "Nessun mazzo ancora", "noDecks": "Nessun mazzo ancora",
"deckName": "Nome Mazzo", "deckName": "Nome del mazzo",
"totalCards": "Carte Totali", "totalCards": "Totale carte",
"createdAt": "Creata Il", "createdAt": "Creata Il",
"actions": "Azioni", "actions": "Azioni",
"view": "Visualizza" "view": "Visualizza"
} }
}, },
"follow": {
"follow": "Segui",
"following": "Seguiti",
"followers": "Seguaci",
"followersOf": "Seguaci di {username}",
"followingOf": "Seguiti di {username}",
"noFollowers": "Nessun seguace ancora",
"noFollowing": "Non segui ancora nessuno"
},
"follow": { "follow": {
"follow": "Segui", "follow": "Segui",
"following": "Stai seguendo", "following": "Stai seguendo",

View File

@@ -74,6 +74,41 @@
"deleteFolder": "このフォルダーを削除する権限がありません。" "deleteFolder": "このフォルダーを削除する権限がありません。"
} }
}, },
"deck_id": {
"unauthorized": "このデッキの所有者ではありません",
"back": "戻る",
"cards": "カード",
"itemsCount": "{count}件",
"memorize": "暗記",
"loadingCards": "カードを読み込み中...",
"noCards": "このデッキにはカードがありません",
"card": "カード",
"addNewCard": "新しいカードを追加",
"add": "追加",
"adding": "追加中...",
"updateCard": "カードを更新",
"update": "更新",
"updating": "更新中...",
"word": "単語",
"definition": "定義",
"ipa": "発音記号",
"example": "例文",
"wordAndDefinitionRequired": "単語と定義は必須です",
"edit": "編集",
"delete": "削除",
"permissionDenied": "この操作を実行する権限がありません",
"resetProgress": "リセット",
"resetProgressTitle": "デッキの進捗をリセット",
"resetProgressConfirm": "このデッキのすべてのカードが新しい状態にリセットされます。学習の進捗は失われます。続行してもよろしいですか?",
"resetSuccess": "{count}枚のカードを正常にリセットしました",
"resetting": "リセット中...",
"cancel": "キャンセル",
"error": {
"update": "このカードを更新する権限がありません。",
"delete": "このカードを削除する権限がありません。",
"add": "このデッキにカードを追加する権限がありません。"
}
},
"home": { "home": {
"title": "言語を学ぶ", "title": "言語を学ぶ",
"description": "ここは世界のほぼすべての言語(人工言語を含む)を学ぶのに役立つ非常に便利なウェブサイトです。", "description": "ここは世界のほぼすべての言語(人工言語を含む)を学ぶのに役立つ非常に便利なウェブサイトです。",
@@ -228,7 +263,7 @@
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "サインイン", "sign_in": "サインイン",
"profile": "プロフィール", "profile": "プロフィール",
"folders": "フォルダー", "folders": "デッキ",
"explore": "探索", "explore": "探索",
"favorites": "お気に入り", "favorites": "お気に入り",
"settings": "設定" "settings": "設定"
@@ -260,7 +295,9 @@
"noDeck": "デッキを選択してください", "noDeck": "デッキを選択してください",
"processingFailed": "OCR処理に失敗しました", "processingFailed": "OCR処理に失敗しました",
"tryAgain": "より鮮明な画像でお試しください", "tryAgain": "より鮮明な画像でお試しください",
"detectedLanguages": "検出:{source} → {target}" "detectedLanguages": "検出:{source} → {target}",
"invalidFileType": "無効なファイルタイプです。画像ファイルをアップロードしてください。",
"ocrFailed": "OCR処理に失敗しました。"
}, },
"profile": { "profile": {
"myProfile": "マイプロフィール", "myProfile": "マイプロフィール",
@@ -301,12 +338,28 @@
"videoUploadFailed": "ビデオのアップロードに失敗しました", "videoUploadFailed": "ビデオのアップロードに失敗しました",
"subtitleUploadFailed": "字幕のアップロードに失敗しました", "subtitleUploadFailed": "字幕のアップロードに失敗しました",
"subtitleLoadSuccess": "字幕の読み込みに成功しました", "subtitleLoadSuccess": "字幕の読み込みに成功しました",
"subtitleLoadFailed": "字幕の読み込みに失敗しました" "subtitleLoadFailed": "字幕の読み込みに失敗しました",
"settings": "設定",
"shortcuts": "ショートカット",
"keyboardShortcuts": "キーボードショートカット",
"playPause": "再生/一時停止",
"autoPauseToggle": "自動一時停止の切り替え",
"subtitleSettings": "字幕設定",
"fontSize": "フォントサイズ",
"textColor": "文字色",
"backgroundColor": "背景色",
"position": "位置",
"opacity": "不透明度",
"top": "上",
"center": "中央",
"bottom": "下"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "IPAを生成", "generateIPA": "IPAを生成",
"viewSavedItems": "保存済み項目を表示", "viewSavedItems": "保存済み項目を表示",
"confirmDeleteAll": "すべて削除してもよろしいですか? (Y/N)" "confirmDeleteAll": "すべて削除してもよろしいですか? (Y/N)",
"saved": "保存済み",
"clearAll": "すべてクリア"
}, },
"translator": { "translator": {
"detectLanguage": "言語を検出", "detectLanguage": "言語を検出",
@@ -469,7 +522,7 @@
"back": "戻る", "back": "戻る",
"import": "インポート", "import": "インポート",
"importing": "インポート中...", "importing": "インポート中...",
"exportSuccess": "デッキエクスポートしました", "exportSuccess": "デッキエクスポートが成功しました",
"goToDecks": "デッキへ移動" "goToDecks": "デッキへ移動"
}, },
"follow": { "follow": {
@@ -477,7 +530,7 @@
"following": "フォロー中", "following": "フォロー中",
"followers": "フォロワー", "followers": "フォロワー",
"followersOf": "{username}のフォロワー", "followersOf": "{username}のフォロワー",
"followingOf": "{username}のフォロー", "followingOf": "{username}のフォロー",
"noFollowers": "まだフォロワーがいません", "noFollowers": "まだフォロワーがいません",
"noFollowing": "まだ誰もフォローしていません" "noFollowing": "まだ誰もフォローしていません"
} }

View File

@@ -237,7 +237,7 @@
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "로그인", "sign_in": "로그인",
"profile": "프로필", "profile": "프로필",
"folders": "폴더", "folders": "",
"explore": "탐색", "explore": "탐색",
"favorites": "즐겨찾기", "favorites": "즐겨찾기",
"settings": "설정" "settings": "설정"
@@ -454,6 +454,15 @@
"view": "보기" "view": "보기"
} }
}, },
"follow": {
"follow": "팔로우",
"following": "팔로잉",
"followers": "팔로워",
"followersOf": "{username}의 팔로워",
"followingOf": "{username}의 팔로잉",
"noFollowers": "아직 팔로워가 없습니다",
"noFollowing": "아직 팔로잉하는 사람이 없습니다"
},
"follow": { "follow": {
"follow": "팔로우", "follow": "팔로우",
"following": "팔로잉", "following": "팔로잉",

View File

@@ -237,7 +237,7 @@
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "كىرىش", "sign_in": "كىرىش",
"profile": "شەخسىي ئۇچۇر", "profile": "شەخسىي ئۇچۇر",
"folders": "قىسقۇچلار", "folders": "دېكلار",
"explore": "ئىزدىنىش", "explore": "ئىزدىنىش",
"favorites": "يىغىپ ساقلاش", "favorites": "يىغىپ ساقلاش",
"settings": "تەڭشەكلەر" "settings": "تەڭشەكلەر"
@@ -454,6 +454,15 @@
"view": "كۆرۈش" "view": "كۆرۈش"
} }
}, },
"follow": {
"follow": "ئەگىشىش",
"following": "ئەگىشىۋاتقانلار",
"followers": "ئەگەشكۈچىلەر",
"followersOf": "{username} نىڭ ئەگەشكۈچىلىرى",
"followingOf": "{username} نىڭ ئەگىشىۋاتقانلىرى",
"noFollowers": "تېخى ئەگەشكۈچى يوق",
"noFollowing": "تېخى ئەگىشىۋاتقان يوق"
},
"follow": { "follow": {
"follow": "ئەگىشىش", "follow": "ئەگىشىش",
"following": "ئەگىشىۋاتىدۇ", "following": "ئەگىشىۋاتىدۇ",

View File

@@ -74,6 +74,41 @@
"deleteFolder": "您没有权限删除此文件夹" "deleteFolder": "您没有权限删除此文件夹"
} }
}, },
"deck_id": {
"unauthorized": "您不是此牌组的所有者",
"back": "返回",
"cards": "卡片",
"itemsCount": "{count} 个",
"memorize": "记忆",
"loadingCards": "加载卡片中...",
"noCards": "此牌组中没有卡片",
"card": "卡片",
"addNewCard": "添加新卡片",
"add": "添加",
"adding": "添加中...",
"updateCard": "更新卡片",
"update": "更新",
"updating": "更新中...",
"word": "单词",
"definition": "释义",
"ipa": "音标",
"example": "例句",
"wordAndDefinitionRequired": "单词和释义都是必需的",
"edit": "编辑",
"delete": "删除",
"permissionDenied": "您没有权限执行此操作",
"resetProgress": "重置进度",
"resetProgressTitle": "重置牌组进度",
"resetProgressConfirm": "这将把牌组内所有卡片重置为新卡片状态。您的学习进度将会丢失。确定要继续吗?",
"resetSuccess": "成功重置 {count} 张卡片",
"resetting": "重置中...",
"cancel": "取消",
"error": {
"update": "您没有权限更新此卡片",
"delete": "您没有权限删除此卡片",
"add": "您没有权限向此牌组添加卡片"
}
},
"home": { "home": {
"title": "学语言", "title": "学语言",
"description": "这里是一个非常有用的网站,可以帮助您学习世界上几乎每一种语言,包括人造语言。", "description": "这里是一个非常有用的网站,可以帮助您学习世界上几乎每一种语言,包括人造语言。",
@@ -228,7 +263,7 @@
"sourceCode": "源码", "sourceCode": "源码",
"sign_in": "登录", "sign_in": "登录",
"profile": "个人资料", "profile": "个人资料",
"folders": "文件夹", "folders": "牌组",
"explore": "探索", "explore": "探索",
"favorites": "收藏", "favorites": "收藏",
"settings": "设置" "settings": "设置"
@@ -242,6 +277,7 @@
"dropOrClick": "拖放图片到此处,或点击选择", "dropOrClick": "拖放图片到此处,或点击选择",
"changeImage": "点击更换图片", "changeImage": "点击更换图片",
"supportedFormats": "支持格式JPG、PNG、WebP", "supportedFormats": "支持格式JPG、PNG、WebP",
"invalidFileType": "无效的文件类型请上传图片文件JPG、PNG 或 WebP",
"deckSelection": "选择牌组", "deckSelection": "选择牌组",
"selectDeck": "选择牌组", "selectDeck": "选择牌组",
"chooseDeck": "选择保存提取词汇的牌组", "chooseDeck": "选择保存提取词汇的牌组",
@@ -315,12 +351,28 @@
"videoUploadFailed": "视频上传失败", "videoUploadFailed": "视频上传失败",
"subtitleUploadFailed": "字幕上传失败", "subtitleUploadFailed": "字幕上传失败",
"subtitleLoadSuccess": "字幕加载成功", "subtitleLoadSuccess": "字幕加载成功",
"subtitleLoadFailed": "字幕加载失败" "subtitleLoadFailed": "字幕加载失败",
"settings": "设置",
"shortcuts": "快捷键",
"keyboardShortcuts": "键盘快捷键",
"playPause": "播放/暂停",
"autoPauseToggle": "切换自动暂停",
"subtitleSettings": "字幕设置",
"fontSize": "字体大小",
"textColor": "文字颜色",
"backgroundColor": "背景颜色",
"position": "位置",
"opacity": "不透明度",
"top": "顶部",
"center": "居中",
"bottom": "底部"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "生成IPA", "generateIPA": "生成IPA",
"viewSavedItems": "查看保存项", "viewSavedItems": "查看保存项",
"confirmDeleteAll": "确定删光吗?(Y/N)" "confirmDeleteAll": "确定删光吗?(Y/N)",
"saved": "已保存",
"clearAll": "清空全部"
}, },
"translator": { "translator": {
"detectLanguage": "检测语言", "detectLanguage": "检测语言",

View File

@@ -1,15 +1,16 @@
"use client"; "use client";
import { ArrowLeft, Plus } from "lucide-react"; import { ArrowLeft, Plus, RotateCcw } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { redirect, useRouter } from "next/navigation"; import { redirect, useRouter } from "next/navigation";
import { AddCardModal } from "./AddCardModal"; import { AddCardModal } from "./AddCardModal";
import { CardItem } from "./CardItem"; import { CardItem } from "./CardItem";
import { useTranslations } from "next-intl"; 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, LightButton } from "@/design-system/base/button";
import { CardList } from "@/components/ui/CardList"; import { CardList } from "@/components/ui/CardList";
import { actionGetCardsByDeckIdWithNotes, actionDeleteCard } from "@/modules/card/card-action"; import { Modal } from "@/design-system/overlay/modal";
import { actionGetCardsByDeckIdWithNotes, actionDeleteCard, actionResetDeckCards } from "@/modules/card/card-action";
import type { ActionOutputCardWithNote } from "@/modules/card/card-action-dto"; import type { ActionOutputCardWithNote } from "@/modules/card/card-action-dto";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -18,6 +19,8 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo
const [cards, setCards] = useState<ActionOutputCardWithNote[]>([]); const [cards, setCards] = useState<ActionOutputCardWithNote[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [openAddModal, setAddModal] = useState(false); const [openAddModal, setAddModal] = useState(false);
const [openResetModal, setResetModal] = useState(false);
const [resetting, setResetting] = useState(false);
const router = useRouter(); const router = useRouter();
const t = useTranslations("deck_id"); const t = useTranslations("deck_id");
@@ -54,6 +57,24 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo
}); });
}; };
const handleResetDeck = async () => {
setResetting(true);
try {
const result = await actionResetDeckCards({ deckId });
if (result.success) {
toast.success(t("resetSuccess", { count: result.data?.count ?? 0 }));
setResetModal(false);
await refreshCards();
} else {
toast.error(result.message);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Unknown error");
} finally {
setResetting(false);
}
};
return ( return (
<PageLayout> <PageLayout>
<div className="mb-6"> <div className="mb-6">
@@ -84,6 +105,13 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo
{t("memorize")} {t("memorize")}
</PrimaryButton> </PrimaryButton>
{!isReadOnly && ( {!isReadOnly && (
<>
<LightButton
onClick={() => setResetModal(true)}
leftIcon={<RotateCcw size={16} />}
>
{t("resetProgress")}
</LightButton>
<CircleButton <CircleButton
onClick={() => { onClick={() => {
setAddModal(true); setAddModal(true);
@@ -91,6 +119,7 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo
> >
<Plus size={18} className="text-gray-700" /> <Plus size={18} className="text-gray-700" />
</CircleButton> </CircleButton>
</>
)} )}
</div> </div>
</div> </div>
@@ -137,6 +166,24 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo
deckId={deckId} deckId={deckId}
onAdded={refreshCards} onAdded={refreshCards}
/> />
{/* Reset Progress Confirmation Modal */}
<Modal open={openResetModal} onClose={() => setResetModal(false)} size="sm">
<Modal.Header>
<Modal.Title>{t("resetProgressTitle")}</Modal.Title>
</Modal.Header>
<Modal.Body>
<p className="text-gray-600">{t("resetProgressConfirm")}</p>
</Modal.Body>
<Modal.Footer>
<LightButton onClick={() => setResetModal(false)}>
{t("cancel")}
</LightButton>
<PrimaryButton onClick={handleResetDeck} loading={resetting}>
{resetting ? t("resetting") : t("resetProgress")}
</PrimaryButton>
</Modal.Footer>
</Modal>
</PageLayout> </PageLayout>
); );
}; };

View File

@@ -162,3 +162,17 @@ export type ActionOutputGetCardById = {
message: string; message: string;
data?: ActionOutputCardWithNote; data?: ActionOutputCardWithNote;
}; };
export const schemaActionInputResetDeckCards = z.object({
deckId: z.number().int().positive(),
});
export type ActionInputResetDeckCards = z.infer<typeof schemaActionInputResetDeckCards>;
export const validateActionInputResetDeckCards = generateValidator(schemaActionInputResetDeckCards);
export type ActionOutputResetDeckCards = {
success: boolean;
message: string;
data?: {
count: number;
};
};

View File

@@ -13,6 +13,7 @@ import {
ActionInputGetCardStats, ActionInputGetCardStats,
ActionInputDeleteCard, ActionInputDeleteCard,
ActionInputGetCardById, ActionInputGetCardById,
ActionInputResetDeckCards,
ActionOutputCreateCard, ActionOutputCreateCard,
ActionOutputAnswerCard, ActionOutputAnswerCard,
ActionOutputGetCards, ActionOutputGetCards,
@@ -23,6 +24,7 @@ import {
ActionOutputCard, ActionOutputCard,
ActionOutputCardWithNote, ActionOutputCardWithNote,
ActionOutputScheduledCard, ActionOutputScheduledCard,
ActionOutputResetDeckCards,
validateActionInputCreateCard, validateActionInputCreateCard,
validateActionInputAnswerCard, validateActionInputAnswerCard,
validateActionInputGetCardsForReview, validateActionInputGetCardsForReview,
@@ -31,6 +33,7 @@ import {
validateActionInputGetCardStats, validateActionInputGetCardStats,
validateActionInputDeleteCard, validateActionInputDeleteCard,
validateActionInputGetCardById, validateActionInputGetCardById,
validateActionInputResetDeckCards,
} from "./card-action-dto"; } from "./card-action-dto";
import { import {
serviceCreateCard, serviceCreateCard,
@@ -43,6 +46,7 @@ import {
serviceDeleteCard, serviceDeleteCard,
serviceGetCardByIdWithNote, serviceGetCardByIdWithNote,
serviceCheckCardOwnership, serviceCheckCardOwnership,
serviceResetDeckCards,
} from "./card-service"; } from "./card-service";
import { CardQueue } from "../../../generated/prisma/enums"; import { CardQueue } from "../../../generated/prisma/enums";
@@ -425,3 +429,36 @@ export async function actionGetCardById(
return { success: false, message: "An error occurred while fetching the card" }; return { success: false, message: "An error occurred while fetching the card" };
} }
} }
export async function actionResetDeckCards(
input: unknown,
): Promise<ActionOutputResetDeckCards> {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
}
const validated = validateActionInputResetDeckCards(input);
const result = await serviceResetDeckCards({
deckId: validated.deckId,
userId,
});
if (!result.success) {
return { success: false, message: result.message };
}
return {
success: true,
message: result.message,
data: { count: result.count },
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to reset deck cards", { error: e });
return { success: false, message: "An error occurred while resetting deck cards" };
}
}

View File

@@ -102,3 +102,11 @@ export type RepoOutputCardStats = {
review: number; review: number;
due: number; due: number;
}; };
export interface RepoInputResetDeckCards {
deckId: number;
}
export type RepoOutputResetDeckCards = {
count: number;
};

View File

@@ -7,9 +7,11 @@ import {
RepoInputGetCardsForReview, RepoInputGetCardsForReview,
RepoInputGetNewCards, RepoInputGetNewCards,
RepoInputBulkUpdateCards, RepoInputBulkUpdateCards,
RepoInputResetDeckCards,
RepoOutputCard, RepoOutputCard,
RepoOutputCardWithNote, RepoOutputCardWithNote,
RepoOutputCardStats, RepoOutputCardStats,
RepoOutputResetDeckCards,
} from "./card-repository-dto"; } from "./card-repository-dto";
import { CardType, CardQueue } from "../../../generated/prisma/enums"; import { CardType, CardQueue } from "../../../generated/prisma/enums";
@@ -307,3 +309,29 @@ export async function repoGetCardsByNoteId(noteId: bigint): Promise<RepoOutputCa
}); });
return cards; return cards;
} }
export async function repoResetDeckCards(
input: RepoInputResetDeckCards,
): Promise<RepoOutputResetDeckCards> {
log.debug("Resetting deck cards", { deckId: input.deckId });
const result = await prisma.card.updateMany({
where: { deckId: input.deckId },
data: {
type: CardType.NEW,
queue: CardQueue.NEW,
due: 0,
ivl: 0,
factor: 2500,
reps: 0,
lapses: 0,
left: 0,
odue: 0,
odid: 0,
mod: Math.floor(Date.now() / 1000),
},
});
log.info("Deck cards reset", { deckId: input.deckId, count: result.count });
return { count: result.count };
}

View File

@@ -99,6 +99,24 @@ export type ServiceOutputReviewResult = {
scheduled: ServiceOutputScheduledCard; scheduled: ServiceOutputScheduledCard;
}; };
export interface ServiceInputResetDeckCards {
deckId: number;
userId: string;
}
export interface ServiceInputCheckDeckOwnership {
deckId: number;
userId: string;
}
export type ServiceOutputCheckDeckOwnership = boolean;
export type ServiceOutputResetDeckCards = {
success: boolean;
count: number;
message: string;
};
export const SM2_CONFIG = { export const SM2_CONFIG = {
LEARNING_STEPS: [1, 10], LEARNING_STEPS: [1, 10],
RELEARNING_STEPS: [10], RELEARNING_STEPS: [10],

View File

@@ -12,7 +12,9 @@ import {
repoDeleteCard, repoDeleteCard,
repoGetCardsByNoteId, repoGetCardsByNoteId,
repoGetCardDeckOwnerId, repoGetCardDeckOwnerId,
repoResetDeckCards,
} from "./card-repository"; } from "./card-repository";
import { repoGetUserIdByDeckId } from "@/modules/deck/deck-repository";
import { import {
RepoInputUpdateCard, RepoInputUpdateCard,
RepoOutputCard, RepoOutputCard,
@@ -25,12 +27,15 @@ import {
ServiceInputGetCardsByDeckId, ServiceInputGetCardsByDeckId,
ServiceInputGetCardStats, ServiceInputGetCardStats,
ServiceInputCheckCardOwnership, ServiceInputCheckCardOwnership,
ServiceInputResetDeckCards,
ServiceInputCheckDeckOwnership,
ServiceOutputCard, ServiceOutputCard,
ServiceOutputCardWithNote, ServiceOutputCardWithNote,
ServiceOutputCardStats, ServiceOutputCardStats,
ServiceOutputScheduledCard, ServiceOutputScheduledCard,
ServiceOutputReviewResult, ServiceOutputReviewResult,
ServiceOutputCheckCardOwnership, ServiceOutputCheckCardOwnership,
ServiceOutputResetDeckCards,
ReviewEase, ReviewEase,
SM2_CONFIG, SM2_CONFIG,
} from "./card-service-dto"; } from "./card-service-dto";
@@ -495,3 +500,27 @@ export async function serviceCheckCardOwnership(
const ownerId = await repoGetCardDeckOwnerId(input.cardId); const ownerId = await repoGetCardDeckOwnerId(input.cardId);
return ownerId === input.userId; return ownerId === input.userId;
} }
export async function serviceCheckDeckOwnership(
input: ServiceInputCheckDeckOwnership,
): Promise<ServiceOutputCheckCardOwnership> {
log.debug("Checking deck ownership", { deckId: input.deckId });
const ownerId = await repoGetUserIdByDeckId(input.deckId);
return ownerId === input.userId;
}
export async function serviceResetDeckCards(
input: ServiceInputResetDeckCards,
): Promise<ServiceOutputResetDeckCards> {
log.info("Resetting deck cards", { deckId: input.deckId, userId: input.userId });
const isOwner = await serviceCheckDeckOwnership({ deckId: input.deckId, userId: input.userId });
if (!isOwner) {
return { success: false, count: 0, message: "You do not have permission to reset this deck" };
}
const result = await repoResetDeckCards({ deckId: input.deckId });
log.info("Deck cards reset successfully", { deckId: input.deckId, count: result.count });
return { success: true, count: result.count, message: "Deck cards reset successfully" };
}

View File

@@ -32,7 +32,8 @@ export function repoGenerateGuid(): string {
export function repoCalculateCsum(text: string): number { export function repoCalculateCsum(text: string): number {
const hash = createHash("sha1").update(text.normalize("NFC")).digest("hex"); const hash = createHash("sha1").update(text.normalize("NFC")).digest("hex");
return parseInt(hash.substring(0, 8), 16); // Use 7 hex chars to stay within INTEGER range (max 268,435,455 < 2,147,483,647)
return parseInt(hash.substring(0, 7), 16);
} }
export function repoJoinFields(fields: string[]): string { export function repoJoinFields(fields: string[]): string {