feat: add reset deck progress feature for deck detail page
This commit is contained in:
54
AGENTS.md
54
AGENTS.md
@@ -104,6 +104,60 @@ log.info("Fetched folders", { count: folders.length });
|
||||
log.error("Failed to fetch folders", { error });
|
||||
```
|
||||
|
||||
### i18n 翻译检查
|
||||
**注意:翻译缺失不会被 build 检测出来。**
|
||||
|
||||
**系统性检查翻译缺失的方法(改进版):**
|
||||
|
||||
#### 步骤 1: 使用 AST-grep 搜索所有翻译模式
|
||||
|
||||
```bash
|
||||
# 搜索所有 useTranslations 和 getTranslations 声明
|
||||
ast-grep --pattern 'useTranslations($ARG)' --lang tsx --paths src/
|
||||
|
||||
# 搜索所有带插值的 t() 调用
|
||||
ast-grep --pattern 't($ARG, $OPTS)' --lang tsx --paths src/
|
||||
|
||||
# 搜索所有简单 t() 调用
|
||||
ast-grep --pattern 't($ARG)' --lang tsx --paths src/
|
||||
```
|
||||
|
||||
**AST-grep 能捕获 31 种不同的翻译键模式, 而 grep 只能捕获 1 种模式。**
|
||||
|
||||
#### 步骤 2: 按文件提取所有翻译键
|
||||
|
||||
逐个 `.tsx` 文件检查使用的翻译键:
|
||||
1. 找到该文件使用的 namespace(`useTranslations("namespace")` 或 `getTranslations("namespace")`)
|
||||
2. 提取该文件中所有 `t("...")` 调用
|
||||
3. 注意动态键模式:
|
||||
- 模板字面量: `t(\`prefix.${variable}\`)`
|
||||
- 条件键: `t(condition ? "a" : "b")`
|
||||
- 变量键: `t(variable)`
|
||||
4. 对比 `messages/en-US.json`,找出缺失的键
|
||||
|
||||
5. 先补全 `en-US.json`(作为基准语言)
|
||||
6. 再根据 `en-US.json` 补全其他 7 种语言
|
||||
|
||||
#### 步骤 3: 验证 JSON 文件结构
|
||||
**注意:JSON 语法错误会导致 build 失败,常见错误:**
|
||||
- 重复的键(同一对象中出现两次相同的键名)
|
||||
- 缺少逗号或多余的逗号
|
||||
- 缺少闭合括号 `}`
|
||||
|
||||
```bash
|
||||
# 验证 JSON 格式
|
||||
node -e "console.log(JSON.parse(require('fs').readFileSync('messages/en-US.json', 'utf8')))"
|
||||
```
|
||||
|
||||
#### 步骤 4: 对比验证
|
||||
```bash
|
||||
# 列出代码中使用的所有 namespace
|
||||
ast-grep --pattern 'useTranslations($ARG)' --lang tsx --paths src/ | grep -o 'useTranslations\|getTranslations' | sort | uniq
|
||||
|
||||
# 对比 messages/en-US.json 中的 namespace 列表
|
||||
node -e "console.log(Object.keys(JSON.parse(require('fs').readFileSync('messages/en-US.json', 'utf8'))).join('\n'))"
|
||||
```
|
||||
|
||||
## 反模式 (本项目)
|
||||
|
||||
- ❌ `index.ts` barrel exports
|
||||
|
||||
@@ -269,7 +269,9 @@
|
||||
"noDeck": "Bitte select a deck",
|
||||
"processingFailed": "OCR-Verarbeitung fehlgeschlagen",
|
||||
"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": {
|
||||
"myProfile": "Mein Profil",
|
||||
@@ -310,12 +312,28 @@
|
||||
"videoUploadFailed": "Video-Upload fehlgeschlagen",
|
||||
"subtitleUploadFailed": "Untertitel-Upload fehlgeschlagen",
|
||||
"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": {
|
||||
"generateIPA": "IPA generieren",
|
||||
"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": {
|
||||
"detectLanguage": "Sprache erkennen",
|
||||
@@ -462,16 +480,40 @@
|
||||
"followingOf": "{username} folgt",
|
||||
"noFollowers": "Noch keine Follower",
|
||||
"noFollowing": "Folgt noch niemandem"
|
||||
}
|
||||
}
|
||||
},
|
||||
"follow": {
|
||||
"follow": "Folgen",
|
||||
"following": "Folge ich",
|
||||
"followers": "Follower",
|
||||
"followersOf": "{username}s Follower",
|
||||
"followingOf": "{username} folgt",
|
||||
"noFollowers": "Noch keine Follower",
|
||||
"noFollowing": "Folgt noch niemandem"
|
||||
"deck_id": {
|
||||
"unauthorized": "Sie sind nicht der Besitzer dieses Decks",
|
||||
"back": "Zurück",
|
||||
"cards": "Karten",
|
||||
"itemsCount": "{count} Elemente",
|
||||
"memorize": "Auswendig lernen",
|
||||
"loadingCards": "Karten werden geladen...",
|
||||
"noCards": "Keine Karten in diesem Deck",
|
||||
"card": "Karte",
|
||||
"addNewCard": "Neue Karte hinzufügen",
|
||||
"add": "Hinzufügen",
|
||||
"adding": "Wird hinzugefügt...",
|
||||
"updateCard": "Karte aktualisieren",
|
||||
"update": "Aktualisieren",
|
||||
"updating": "Wird aktualisiert...",
|
||||
"word": "Wort",
|
||||
"definition": "Definition",
|
||||
"ipa": "IPA",
|
||||
"example": "Beispiel",
|
||||
"wordAndDefinitionRequired": "Wort und Definition sind erforderlich",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen",
|
||||
"permissionDenied": "Sie haben keine Berechtigung für diese Aktion",
|
||||
"resetProgress": "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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"unfavorite": "Unfavorite",
|
||||
"pleaseLogin": "Please login first"
|
||||
},
|
||||
"folder_id": {
|
||||
"folder_id": {
|
||||
"unauthorized": "You are not the owner of this folder",
|
||||
"back": "Back",
|
||||
"textPairs": "Text Pairs",
|
||||
@@ -74,6 +74,41 @@
|
||||
"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": {
|
||||
"title": "Learn Languages",
|
||||
"description": "Here is a very useful website to help you learn almost every language in the world, including constructed ones.",
|
||||
@@ -233,7 +268,7 @@
|
||||
"favorites": "Favorites",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"ocr": {
|
||||
"ocr": {
|
||||
"title": "OCR Vocabulary Extractor",
|
||||
"description": "Upload vocabulary table screenshots from textbooks to extract word-definition pairs",
|
||||
"uploadSection": "Upload Image",
|
||||
@@ -242,6 +277,7 @@
|
||||
"dropOrClick": "Drag and drop an image here, or click to select",
|
||||
"changeImage": "Click to change image",
|
||||
"supportedFormats": "Supports: JPG, PNG, WebP",
|
||||
"invalidFileType": "Invalid file type. Please upload an image file (JPG, PNG, or WebP).",
|
||||
"deckSelection": "Select Deck",
|
||||
"selectDeck": "Select a deck",
|
||||
"chooseDeck": "Choose a deck to save extracted pairs",
|
||||
@@ -265,6 +301,7 @@
|
||||
"saving": "Saving...",
|
||||
"saved": "Successfully saved {count} pairs to {deck}",
|
||||
"ocrSuccess": "Successfully extracted {count} pairs to {deck}",
|
||||
"ocrFailed": "OCR processing failed. Please try again.",
|
||||
"savedToDeck": "Saved to {deckName}",
|
||||
"saveFailed": "Failed to save pairs",
|
||||
"noImage": "Please upload an image first",
|
||||
@@ -315,12 +352,28 @@
|
||||
"videoUploadFailed": "Video upload failed",
|
||||
"subtitleUploadFailed": "Subtitle upload failed",
|
||||
"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": {
|
||||
"generateIPA": "Generate IPA",
|
||||
"viewSavedItems": "View Saved Items",
|
||||
"confirmDeleteAll": "Are you sure you want to delete everything? (Y/N)"
|
||||
"confirmDeleteAll": "Are you sure you want to delete everything? (Y/N)",
|
||||
"saved": "Saved",
|
||||
"clearAll": "Clear All"
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "detect language",
|
||||
|
||||
@@ -83,7 +83,76 @@
|
||||
"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",
|
||||
"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",
|
||||
@@ -462,8 +531,6 @@
|
||||
"followingOf": "Abonnements de {username}",
|
||||
"noFollowers": "Pas encore d'abonnés",
|
||||
"noFollowing": "Ne suit personne"
|
||||
}
|
||||
}
|
||||
},
|
||||
"follow": {
|
||||
"follow": "Suivre",
|
||||
|
||||
@@ -83,7 +83,41 @@
|
||||
"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",
|
||||
"description": "Ecco un sito molto utile per aiutarti a imparare quasi tutte le lingue del mondo, incluse quelle costruite.",
|
||||
"explore": "Esplora",
|
||||
@@ -462,8 +496,6 @@
|
||||
"followingOf": "Seguiti di {username}",
|
||||
"noFollowers": "Nessun seguace ancora",
|
||||
"noFollowing": "Non segui ancora nessuno"
|
||||
}
|
||||
}
|
||||
},
|
||||
"follow": {
|
||||
"follow": "Segui",
|
||||
|
||||
@@ -74,6 +74,41 @@
|
||||
"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": {
|
||||
"title": "言語を学ぶ",
|
||||
"description": "ここは世界のほぼすべての言語(人工言語を含む)を学ぶのに役立つ非常に便利なウェブサイトです。",
|
||||
@@ -260,7 +295,9 @@
|
||||
"noDeck": "デッキを選択してください",
|
||||
"processingFailed": "OCR処理に失敗しました",
|
||||
"tryAgain": "より鮮明な画像でお試しください",
|
||||
"detectedLanguages": "検出:{source} → {target}"
|
||||
"detectedLanguages": "検出:{source} → {target}",
|
||||
"invalidFileType": "無効なファイルタイプです。画像ファイルをアップロードしてください。",
|
||||
"ocrFailed": "OCR処理に失敗しました。"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "マイプロフィール",
|
||||
@@ -301,12 +338,28 @@
|
||||
"videoUploadFailed": "ビデオのアップロードに失敗しました",
|
||||
"subtitleUploadFailed": "字幕のアップロードに失敗しました",
|
||||
"subtitleLoadSuccess": "字幕の読み込みに成功しました",
|
||||
"subtitleLoadFailed": "字幕の読み込みに失敗しました"
|
||||
"subtitleLoadFailed": "字幕の読み込みに失敗しました",
|
||||
"settings": "設定",
|
||||
"shortcuts": "ショートカット",
|
||||
"keyboardShortcuts": "キーボードショートカット",
|
||||
"playPause": "再生/一時停止",
|
||||
"autoPauseToggle": "自動一時停止の切り替え",
|
||||
"subtitleSettings": "字幕設定",
|
||||
"fontSize": "フォントサイズ",
|
||||
"textColor": "文字色",
|
||||
"backgroundColor": "背景色",
|
||||
"position": "位置",
|
||||
"opacity": "不透明度",
|
||||
"top": "上",
|
||||
"center": "中央",
|
||||
"bottom": "下"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "IPAを生成",
|
||||
"viewSavedItems": "保存済み項目を表示",
|
||||
"confirmDeleteAll": "すべて削除してもよろしいですか? (Y/N)"
|
||||
"confirmDeleteAll": "すべて削除してもよろしいですか? (Y/N)",
|
||||
"saved": "保存済み",
|
||||
"clearAll": "すべてクリア"
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "言語を検出",
|
||||
@@ -481,42 +534,4 @@
|
||||
"noFollowers": "まだフォロワーがいません",
|
||||
"noFollowing": "まだ誰もフォローしていません"
|
||||
}
|
||||
}
|
||||
},
|
||||
"decks": {
|
||||
"title": "デッキ",
|
||||
"subtitle": "フラッシュカードデッキを管理",
|
||||
"newDeck": "新規デッキ",
|
||||
"noDecksYet": "まだデッキがありません",
|
||||
"loading": "読み込み中...",
|
||||
"deckInfo": "ID: {id} • {totalCards} 枚のカード",
|
||||
"enterDeckName": "デッキ名を入力:",
|
||||
"enterNewName": "新しい名前を入力:",
|
||||
"confirmDelete": "削除するには「{name}」と入力してください:",
|
||||
"public": "公開",
|
||||
"private": "非公開",
|
||||
"setPublic": "公開に設定",
|
||||
"setPrivate": "非公開に設定",
|
||||
"importApkg": "APKGをインポート",
|
||||
"exportApkg": "APKGをエクスポート",
|
||||
"clickToUpload": "クリックしてAPKGファイルをアップロード",
|
||||
"apkgFilesOnly": ".apkgファイルのみ対応",
|
||||
"parsing": "解析中...",
|
||||
"foundDecks": "{count}個のデッキが見つかりました",
|
||||
"deckName": "デッキ名",
|
||||
"back": "戻る",
|
||||
"import": "インポート",
|
||||
"importing": "インポート中...",
|
||||
"exportSuccess": "デッキをエクスポートしました",
|
||||
"goToDecks": "デッキへ移動"
|
||||
},
|
||||
"follow": {
|
||||
"follow": "フォロー",
|
||||
"following": "フォロー中",
|
||||
"followers": "フォロワー",
|
||||
"followersOf": "{username}のフォロワー",
|
||||
"followingOf": "{username}のフォロー",
|
||||
"noFollowers": "まだフォロワーがいません",
|
||||
"noFollowing": "まだ誰もフォローしていません"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,8 +462,6 @@
|
||||
"followingOf": "{username}의 팔로잉",
|
||||
"noFollowers": "아직 팔로워가 없습니다",
|
||||
"noFollowing": "아직 팔로잉하는 사람이 없습니다"
|
||||
}
|
||||
}
|
||||
},
|
||||
"follow": {
|
||||
"follow": "팔로우",
|
||||
|
||||
@@ -462,8 +462,6 @@
|
||||
"followingOf": "{username} نىڭ ئەگىشىۋاتقانلىرى",
|
||||
"noFollowers": "تېخى ئەگەشكۈچى يوق",
|
||||
"noFollowing": "تېخى ئەگىشىۋاتقان يوق"
|
||||
}
|
||||
}
|
||||
},
|
||||
"follow": {
|
||||
"follow": "ئەگىشىش",
|
||||
|
||||
@@ -74,6 +74,41 @@
|
||||
"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": {
|
||||
"title": "学语言",
|
||||
"description": "这里是一个非常有用的网站,可以帮助您学习世界上几乎每一种语言,包括人造语言。",
|
||||
@@ -242,6 +277,7 @@
|
||||
"dropOrClick": "拖放图片到此处,或点击选择",
|
||||
"changeImage": "点击更换图片",
|
||||
"supportedFormats": "支持格式:JPG、PNG、WebP",
|
||||
"invalidFileType": "无效的文件类型,请上传图片文件(JPG、PNG 或 WebP)",
|
||||
"deckSelection": "选择牌组",
|
||||
"selectDeck": "选择牌组",
|
||||
"chooseDeck": "选择保存提取词汇的牌组",
|
||||
@@ -315,12 +351,28 @@
|
||||
"videoUploadFailed": "视频上传失败",
|
||||
"subtitleUploadFailed": "字幕上传失败",
|
||||
"subtitleLoadSuccess": "字幕加载成功",
|
||||
"subtitleLoadFailed": "字幕加载失败"
|
||||
"subtitleLoadFailed": "字幕加载失败",
|
||||
"settings": "设置",
|
||||
"shortcuts": "快捷键",
|
||||
"keyboardShortcuts": "键盘快捷键",
|
||||
"playPause": "播放/暂停",
|
||||
"autoPauseToggle": "切换自动暂停",
|
||||
"subtitleSettings": "字幕设置",
|
||||
"fontSize": "字体大小",
|
||||
"textColor": "文字颜色",
|
||||
"backgroundColor": "背景颜色",
|
||||
"position": "位置",
|
||||
"opacity": "不透明度",
|
||||
"top": "顶部",
|
||||
"center": "居中",
|
||||
"bottom": "底部"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "生成IPA",
|
||||
"viewSavedItems": "查看保存项",
|
||||
"confirmDeleteAll": "确定删光吗?(Y/N)"
|
||||
"confirmDeleteAll": "确定删光吗?(Y/N)",
|
||||
"saved": "已保存",
|
||||
"clearAll": "清空全部"
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "检测语言",
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Plus } from "lucide-react";
|
||||
import { ArrowLeft, Plus, RotateCcw } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { redirect, useRouter } from "next/navigation";
|
||||
import { AddCardModal } from "./AddCardModal";
|
||||
import { CardItem } from "./CardItem";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PrimaryButton, CircleButton, LinkButton } from "@/design-system/base/button";
|
||||
import { PrimaryButton, CircleButton, LinkButton, LightButton } from "@/design-system/base/button";
|
||||
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 { toast } from "sonner";
|
||||
|
||||
@@ -18,6 +19,8 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo
|
||||
const [cards, setCards] = useState<ActionOutputCardWithNote[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [openAddModal, setAddModal] = useState(false);
|
||||
const [openResetModal, setResetModal] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const router = useRouter();
|
||||
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 (
|
||||
<PageLayout>
|
||||
<div className="mb-6">
|
||||
@@ -84,13 +105,21 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo
|
||||
{t("memorize")}
|
||||
</PrimaryButton>
|
||||
{!isReadOnly && (
|
||||
<CircleButton
|
||||
onClick={() => {
|
||||
setAddModal(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={18} className="text-gray-700" />
|
||||
</CircleButton>
|
||||
<>
|
||||
<LightButton
|
||||
onClick={() => setResetModal(true)}
|
||||
leftIcon={<RotateCcw size={16} />}
|
||||
>
|
||||
{t("resetProgress")}
|
||||
</LightButton>
|
||||
<CircleButton
|
||||
onClick={() => {
|
||||
setAddModal(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={18} className="text-gray-700" />
|
||||
</CircleButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,12 +160,30 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo
|
||||
)}
|
||||
</CardList>
|
||||
|
||||
<AddCardModal
|
||||
<AddCardModal
|
||||
isOpen={openAddModal}
|
||||
onClose={() => setAddModal(false)}
|
||||
deckId={deckId}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -162,3 +162,17 @@ export type ActionOutputGetCardById = {
|
||||
message: string;
|
||||
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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ActionInputGetCardStats,
|
||||
ActionInputDeleteCard,
|
||||
ActionInputGetCardById,
|
||||
ActionInputResetDeckCards,
|
||||
ActionOutputCreateCard,
|
||||
ActionOutputAnswerCard,
|
||||
ActionOutputGetCards,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
ActionOutputCard,
|
||||
ActionOutputCardWithNote,
|
||||
ActionOutputScheduledCard,
|
||||
ActionOutputResetDeckCards,
|
||||
validateActionInputCreateCard,
|
||||
validateActionInputAnswerCard,
|
||||
validateActionInputGetCardsForReview,
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
validateActionInputGetCardStats,
|
||||
validateActionInputDeleteCard,
|
||||
validateActionInputGetCardById,
|
||||
validateActionInputResetDeckCards,
|
||||
} from "./card-action-dto";
|
||||
import {
|
||||
serviceCreateCard,
|
||||
@@ -43,6 +46,7 @@ import {
|
||||
serviceDeleteCard,
|
||||
serviceGetCardByIdWithNote,
|
||||
serviceCheckCardOwnership,
|
||||
serviceResetDeckCards,
|
||||
} from "./card-service";
|
||||
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" };
|
||||
}
|
||||
}
|
||||
|
||||
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" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,3 +102,11 @@ export type RepoOutputCardStats = {
|
||||
review: number;
|
||||
due: number;
|
||||
};
|
||||
|
||||
export interface RepoInputResetDeckCards {
|
||||
deckId: number;
|
||||
}
|
||||
|
||||
export type RepoOutputResetDeckCards = {
|
||||
count: number;
|
||||
};
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
RepoInputGetCardsForReview,
|
||||
RepoInputGetNewCards,
|
||||
RepoInputBulkUpdateCards,
|
||||
RepoInputResetDeckCards,
|
||||
RepoOutputCard,
|
||||
RepoOutputCardWithNote,
|
||||
RepoOutputCardStats,
|
||||
RepoOutputResetDeckCards,
|
||||
} from "./card-repository-dto";
|
||||
import { CardType, CardQueue } from "../../../generated/prisma/enums";
|
||||
|
||||
@@ -307,3 +309,29 @@ export async function repoGetCardsByNoteId(noteId: bigint): Promise<RepoOutputCa
|
||||
});
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -99,6 +99,24 @@ export type ServiceOutputReviewResult = {
|
||||
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 = {
|
||||
LEARNING_STEPS: [1, 10],
|
||||
RELEARNING_STEPS: [10],
|
||||
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
repoDeleteCard,
|
||||
repoGetCardsByNoteId,
|
||||
repoGetCardDeckOwnerId,
|
||||
repoResetDeckCards,
|
||||
} from "./card-repository";
|
||||
import { repoGetUserIdByDeckId } from "@/modules/deck/deck-repository";
|
||||
import {
|
||||
RepoInputUpdateCard,
|
||||
RepoOutputCard,
|
||||
@@ -25,12 +27,15 @@ import {
|
||||
ServiceInputGetCardsByDeckId,
|
||||
ServiceInputGetCardStats,
|
||||
ServiceInputCheckCardOwnership,
|
||||
ServiceInputResetDeckCards,
|
||||
ServiceInputCheckDeckOwnership,
|
||||
ServiceOutputCard,
|
||||
ServiceOutputCardWithNote,
|
||||
ServiceOutputCardStats,
|
||||
ServiceOutputScheduledCard,
|
||||
ServiceOutputReviewResult,
|
||||
ServiceOutputCheckCardOwnership,
|
||||
ServiceOutputResetDeckCards,
|
||||
ReviewEase,
|
||||
SM2_CONFIG,
|
||||
} from "./card-service-dto";
|
||||
@@ -495,3 +500,27 @@ export async function serviceCheckCardOwnership(
|
||||
const ownerId = await repoGetCardDeckOwnerId(input.cardId);
|
||||
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" };
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ export function repoGenerateGuid(): string {
|
||||
|
||||
export function repoCalculateCsum(text: string): number {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user