Compare commits
32 Commits
804c28ada9
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| dda7d64dee | |||
| 911343ce0d | |||
| 130ab226ff | |||
| 59d22ccf4c | |||
| 06012c43f2 | |||
| c54376cbe6 | |||
| 3ed3478c66 | |||
| bc7608e049 | |||
| 1ef337801d | |||
| 286add7fff | |||
| de7c1321c2 | |||
| 95ce49378b | |||
| 2f5ec1c0f0 | |||
| f53fa5e2a1 | |||
| 1d5732abc8 | |||
| ada2f249ee | |||
| bc0b392875 | |||
| a68951f1d3 | |||
| c525bd4591 | |||
| 6213dd2338 | |||
| af684a15ce | |||
| 279eee2953 | |||
| 168f0c161e | |||
| 7c71ffcf31 | |||
| 4243cdc68b | |||
| cbb9326f84 | |||
| 49ad953add | |||
| f1eafa8015 | |||
| 12e502313b | |||
| 13e8f51ada | |||
| 7ba31a37bd | |||
| 4d4062985d |
54
AGENTS.md
54
AGENTS.md
@@ -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
|
||||||
|
|||||||
@@ -53,7 +53,30 @@
|
|||||||
"totalCards": "Gesamtkarten",
|
"totalCards": "Gesamtkarten",
|
||||||
"createdAt": "Erstellt am",
|
"createdAt": "Erstellt am",
|
||||||
"actions": "Aktionen",
|
"actions": "Aktionen",
|
||||||
"view": "Anzeigen"
|
"view": "Anzeigen",
|
||||||
|
"subtitle": "Lern-Decks verwalten",
|
||||||
|
"newDeck": "Neues Deck",
|
||||||
|
"noDecksYet": "Noch keine Decks",
|
||||||
|
"loading": "Laden...",
|
||||||
|
"deckInfo": "ID: {id} · {totalCards} Karten",
|
||||||
|
"enterDeckName": "Deck-Name eingeben:",
|
||||||
|
"enterNewName": "Neuen Namen eingeben:",
|
||||||
|
"confirmDelete": "\"{name}\" eingeben zum Löschen:",
|
||||||
|
"public": "Öffentlich",
|
||||||
|
"private": "Privat",
|
||||||
|
"setPublic": "Öffentlich machen",
|
||||||
|
"setPrivate": "Privat machen",
|
||||||
|
"importApkg": "APKG importieren",
|
||||||
|
"exportApkg": "APKG exportieren",
|
||||||
|
"clickToUpload": "Klicken zum Hochladen",
|
||||||
|
"apkgFilesOnly": "Nur .apkg Dateien",
|
||||||
|
"parsing": "Analysieren...",
|
||||||
|
"foundDecks": "{count} Decks gefunden",
|
||||||
|
"back": "Zurück",
|
||||||
|
"import": "Importieren",
|
||||||
|
"importing": "Importieren...",
|
||||||
|
"exportSuccess": "Export erfolgreich",
|
||||||
|
"goToDecks": "Zu Decks"
|
||||||
},
|
},
|
||||||
"folder_id": {
|
"folder_id": {
|
||||||
"unauthorized": "Sie sind nicht der Besitzer dieses Ordners",
|
"unauthorized": "Sie sind nicht der Besitzer dieses Ordners",
|
||||||
@@ -187,8 +210,8 @@
|
|||||||
},
|
},
|
||||||
"memorize": {
|
"memorize": {
|
||||||
"deck_selector": {
|
"deck_selector": {
|
||||||
"selectDeck": "Deck auswählen",
|
"selectDeck": "Deck wählen",
|
||||||
"noDecks": "Keine Decks gefunden",
|
"noDecks": "Keine Decks",
|
||||||
"goToDecks": "Zu Decks",
|
"goToDecks": "Zu Decks",
|
||||||
"noCards": "Keine Karten",
|
"noCards": "Keine Karten",
|
||||||
"new": "Neu",
|
"new": "Neu",
|
||||||
@@ -199,33 +222,26 @@
|
|||||||
"review": {
|
"review": {
|
||||||
"loading": "Laden...",
|
"loading": "Laden...",
|
||||||
"backToDecks": "Zurück zu Decks",
|
"backToDecks": "Zurück zu Decks",
|
||||||
"allDone": "Fertig!",
|
"allDone": "Alles erledigt!",
|
||||||
"allDoneDesc": "Alle fälligen Karten wurden wiederholt.",
|
"allDoneDesc": "Lernen für heute abgeschlossen!",
|
||||||
"reviewedCount": "{count} Karten wiederholt",
|
"reviewedCount": "{count} Karten wiederholt",
|
||||||
"progress": "{current} / {total}",
|
"progress": "{current} / {total}",
|
||||||
"nextReview": "Nächste Wiederholung",
|
"nextReview": "Nächste Wiederholung",
|
||||||
"interval": "Intervall",
|
"interval": "Intervall",
|
||||||
"ease": "Leichtigkeit",
|
"ease": "Schwierigkeit",
|
||||||
"lapses": "Verlernungen",
|
"lapses": "Fehler",
|
||||||
"showAnswer": "Antwort zeigen",
|
"showAnswer": "Antwort zeigen",
|
||||||
|
"nextCard": "Weiter",
|
||||||
"again": "Nochmal",
|
"again": "Nochmal",
|
||||||
"hard": "Schwer",
|
"restart": "Neustart",
|
||||||
"good": "Gut",
|
"orderLimited": "Reihenfolge begrenzt",
|
||||||
"easy": "Leicht",
|
"orderInfinite": "Reihenfolge unbegrenzt",
|
||||||
"now": "jetzt",
|
"randomLimited": "Zufällig begrenzt",
|
||||||
"lessThanMinute": "<1 Min",
|
"randomInfinite": "Zufällig unbegrenzt",
|
||||||
"inMinutes": "{count} Min",
|
"noIpa": "Kein IPA verfügbar"
|
||||||
"inHours": "{count} Std",
|
|
||||||
"inDays": "{count} Tage",
|
|
||||||
"inMonths": "{count} Monate",
|
|
||||||
"minutes": "<1 Min",
|
|
||||||
"days": "{count} Tage",
|
|
||||||
"months": "{count} Monate",
|
|
||||||
"minAbbr": "m",
|
|
||||||
"dayAbbr": "T"
|
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "Sie sind nicht berechtigt, auf dieses Deck zuzugreifen"
|
"unauthorized": "Nicht autorisiert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navbar": {
|
"navbar": {
|
||||||
@@ -233,39 +249,55 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
"ocr": {
|
"ocr": {
|
||||||
"title": "OCR Vokabel-Extraktion",
|
"title": "OCR-Erkennung",
|
||||||
"description": "Laden Sie Screenshots von Vokabeltabellen aus Lehrbüchern hoch, um Wort-Definition-Paare zu extrahieren",
|
"description": "Text aus Bildern extrahieren",
|
||||||
"uploadImage": "Bild hochladen",
|
"uploadImage": "Bild hochladen",
|
||||||
"dragDropHint": "Ziehen Sie ein Bild hierher oder klicken Sie zum Auswählen",
|
"dragDropHint": "Ziehen und ablegen",
|
||||||
"supportedFormats": "Unterstüt: JPG, PNG, WebP",
|
"supportedFormats": "Unterstützt: JPG, PNG, WEBP",
|
||||||
"selectDeck": "Deck auswählen",
|
"selectDeck": "Deck wählen",
|
||||||
"chooseDeck": "Wählen Sie einen Deck zum Speichern der extrahierten Paare",
|
"chooseDeck": "Deck wählen",
|
||||||
"noDecks": "Keine Decks verfügbar. Bitte create a deck first.",
|
"noDecks": "Keine Decks verfügbar",
|
||||||
"languageHints": "Sprachhinweise (Optional)",
|
"languageHints": "Sprachhinweise",
|
||||||
"sourceLanguageHint": "Quellsprache (z.B. Englisch)",
|
"sourceLanguageHint": "Quellsprache",
|
||||||
"targetLanguageHint": "Ziel-/Übersetzungssprache (z.B. Chinesisch)",
|
"targetLanguageHint": "Zielsprache",
|
||||||
"process": "Bild verarbeiten",
|
"process": "Verarbeiten",
|
||||||
"processing": "Verarbeitung...",
|
"processing": "Verarbeiten...",
|
||||||
"preview": "Vorschau",
|
"preview": "Vorschau",
|
||||||
"extractedPairs": "Extrahierte Paare",
|
"extractedPairs": "Extrahierte Paare",
|
||||||
"word": "Wort",
|
"word": "Wort",
|
||||||
"definition": "Definition",
|
"definition": "Definition",
|
||||||
"pairsCount": "{count} Paare extrahiert",
|
"pairsCount": "{count} Paare",
|
||||||
"savePairs": "In Deck speichern",
|
"savePairs": "Speichern",
|
||||||
"saving": "Speichern...",
|
"saving": "Speichern...",
|
||||||
"saved": "{count} Paare erfolgreich in {deck} gespeichert",
|
"saved": "Gespeichert",
|
||||||
"saveFailed": "Speichern fehlgeschlagen",
|
"saveFailed": "Speichern fehlgeschlagen",
|
||||||
"noImage": "Bitte laden Sie zuerst ein Bild hoch",
|
"noImage": "Bitte Bild hochladen",
|
||||||
"noDeck": "Bitte select a deck",
|
"noDeck": "Bitte Deck wählen",
|
||||||
"processingFailed": "OCR-Verarbeitung fehlgeschlagen",
|
"processingFailed": "Verarbeitung fehlgeschlagen",
|
||||||
"tryAgain": "Bitte try again with a clearer image",
|
"tryAgain": "Erneut versuchen",
|
||||||
"detectedLanguages": "Erkannt: {source} → {target}"
|
"detectedLanguages": "Erkannte Sprachen",
|
||||||
|
"invalidFileType": "Ungültiger Dateityp",
|
||||||
|
"ocrFailed": "OCR fehlgeschlagen",
|
||||||
|
"uploadSection": "Bild hochladen",
|
||||||
|
"dropOrClick": "Ablegen oder klicken",
|
||||||
|
"changeImage": "Bild ändern",
|
||||||
|
"deckSelection": "Deck wählen",
|
||||||
|
"sourceLanguagePlaceholder": "z.B. Englisch",
|
||||||
|
"targetLanguagePlaceholder": "z.B. Deutsch",
|
||||||
|
"processButton": "Erkennung starten",
|
||||||
|
"resultsPreview": "Ergebnisvorschau",
|
||||||
|
"saveButton": "In Deck speichern",
|
||||||
|
"ocrSuccess": "OCR erfolgreich",
|
||||||
|
"savedToDeck": "In Deck gespeichert",
|
||||||
|
"noResultsToSave": "Keine Ergebnisse",
|
||||||
|
"detectedSourceLanguage": "Erkannte Quellsprache",
|
||||||
|
"detectedTargetLanguage": "Erkannte Zielsprache"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"myProfile": "Mein Profil",
|
"myProfile": "Mein Profil",
|
||||||
@@ -306,12 +338,43 @@
|
|||||||
"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",
|
||||||
|
"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",
|
||||||
|
"language": "Sprache",
|
||||||
|
"customLanguage": "oder Sprache eingeben...",
|
||||||
|
"languages": {
|
||||||
|
"auto": "Automatisch",
|
||||||
|
"chinese": "Chinesisch",
|
||||||
|
"english": "Englisch",
|
||||||
|
"japanese": "Japanisch",
|
||||||
|
"korean": "Koreanisch",
|
||||||
|
"french": "Französisch",
|
||||||
|
"german": "Deutsch",
|
||||||
|
"italian": "Italienisch",
|
||||||
|
"spanish": "Spanisch",
|
||||||
|
"portuguese": "Portugiesisch",
|
||||||
|
"russian": "Russisch"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"detectLanguage": "Sprache erkennen",
|
"detectLanguage": "Sprache erkennen",
|
||||||
@@ -344,7 +407,20 @@
|
|||||||
"success": "Textpaar zum Ordner hinzugefügt",
|
"success": "Textpaar zum Ordner hinzugefügt",
|
||||||
"error": "Fehler beim Hinzufügen des Textpaars zum Ordner"
|
"error": "Fehler beim Hinzufügen des Textpaars zum Ordner"
|
||||||
},
|
},
|
||||||
"autoSave": "Autom. Speichern"
|
"autoSave": "Autom. Speichern",
|
||||||
|
"customLanguage": "oder Sprache eingeben...",
|
||||||
|
"pleaseLogin": "Bitte anmelden um Karten zu speichern",
|
||||||
|
"pleaseCreateDeck": "Bitte erst zuerst ein Deck",
|
||||||
|
"noTranslationToSave": "Keine Übersetzung zum Speichern",
|
||||||
|
"noDeckSelected": "Kein Deck ausgewählt",
|
||||||
|
"saveAsCard": "Als Karte speichern",
|
||||||
|
"selectDeck": "Deck wählen",
|
||||||
|
"front": "Vorderseite",
|
||||||
|
"back": "Rückseite",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"save": "Speichern",
|
||||||
|
"savedToDeck": "Karte in {deckName} gespeichert",
|
||||||
|
"saveFailed": "Karte speichern fehlgeschlagen"
|
||||||
},
|
},
|
||||||
"dictionary": {
|
"dictionary": {
|
||||||
"title": "Wörterbuch",
|
"title": "Wörterbuch",
|
||||||
@@ -389,7 +465,9 @@
|
|||||||
"unfavorite": "Aus Favoriten entfernen",
|
"unfavorite": "Aus Favoriten entfernen",
|
||||||
"pleaseLogin": "Bitte melden Sie sich zuerst an",
|
"pleaseLogin": "Bitte melden Sie sich zuerst an",
|
||||||
"sortByFavorites": "Nach Favoriten sortieren",
|
"sortByFavorites": "Nach Favoriten sortieren",
|
||||||
"sortByFavoritesActive": "Sortierung nach Favoriten aufheben"
|
"sortByFavoritesActive": "Sortierung nach Favoriten aufheben",
|
||||||
|
"noDecks": "Keine öffentlichen Decks",
|
||||||
|
"deckInfo": "{userName} · {totalCards} Karten"
|
||||||
},
|
},
|
||||||
"exploreDetail": {
|
"exploreDetail": {
|
||||||
"title": "Ordnerdetails",
|
"title": "Ordnerdetails",
|
||||||
@@ -403,7 +481,8 @@
|
|||||||
"unfavorite": "Aus Favoriten entfernen",
|
"unfavorite": "Aus Favoriten entfernen",
|
||||||
"favorited": "Favorisiert",
|
"favorited": "Favorisiert",
|
||||||
"unfavorited": "Aus Favoriten entfernt",
|
"unfavorited": "Aus Favoriten entfernt",
|
||||||
"pleaseLogin": "Bitte melden Sie sich zuerst an"
|
"pleaseLogin": "Bitte melden Sie sich zuerst an",
|
||||||
|
"totalCards": "{count} Karten"
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "Meine Favoriten",
|
"title": "Meine Favoriten",
|
||||||
@@ -448,6 +527,96 @@
|
|||||||
"createdAt": "Erstellt am",
|
"createdAt": "Erstellt am",
|
||||||
"actions": "Aktionen",
|
"actions": "Aktionen",
|
||||||
"view": "Anzeigen"
|
"view": "Anzeigen"
|
||||||
|
},
|
||||||
|
"joined": "Beigetreten",
|
||||||
|
"decks": {
|
||||||
|
"title": "Meine Decks",
|
||||||
|
"noDecks": "Keine Decks",
|
||||||
|
"deckName": "Deck-Name",
|
||||||
|
"totalCards": "Gesamtkarten",
|
||||||
|
"createdAt": "Erstellt am",
|
||||||
|
"actions": "Aktionen",
|
||||||
|
"view": "Ansehen"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"follow": {
|
||||||
|
"follow": "Folgen",
|
||||||
|
"following": "Folge ich",
|
||||||
|
"followers": "Follower",
|
||||||
|
"followersOf": "{username}s Follower",
|
||||||
|
"followingOf": "{username} folgt",
|
||||||
|
"noFollowers": "Noch keine Follower",
|
||||||
|
"noFollowing": "Folgt noch niemandem"
|
||||||
|
},
|
||||||
|
"deck_id": {
|
||||||
|
"unauthorized": "Sie sind nicht der Besitzer dieses Decks",
|
||||||
|
"back": "Zurück",
|
||||||
|
"cards": "Karten",
|
||||||
|
"itemsCount": "{count} Elemente",
|
||||||
|
"memorize": "Auswendig lernen",
|
||||||
|
"loadingCards": "Karten werden geladen...",
|
||||||
|
"noCards": "Keine Karten in diesem Deck",
|
||||||
|
"card": "Karte",
|
||||||
|
"addNewCard": "Neue Karte hinzufügen",
|
||||||
|
"add": "Hinzufügen",
|
||||||
|
"adding": "Wird hinzugefügt...",
|
||||||
|
"updateCard": "Karte aktualisieren",
|
||||||
|
"update": "Aktualisieren",
|
||||||
|
"updating": "Wird aktualisiert...",
|
||||||
|
"word": "Wort",
|
||||||
|
"definition": "Definition",
|
||||||
|
"ipa": "IPA",
|
||||||
|
"example": "Beispiel",
|
||||||
|
"wordAndDefinitionRequired": "Wort und Definition sind erforderlich",
|
||||||
|
"edit": "Bearbeiten",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"permissionDenied": "Sie haben keine Berechtigung für diese Aktion",
|
||||||
|
"resetProgress": "Fortschritt zurücksetzen",
|
||||||
|
"resetProgressTitle": "Lernfortschritt zurücksetzen",
|
||||||
|
"resetProgressConfirm": "Fortschritt wirklich zurücksetzen?",
|
||||||
|
"resetSuccess": "Fortschritt zurückgesetzt",
|
||||||
|
"resetting": "Zurücksetzen...",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"settings": "Einstellungen",
|
||||||
|
"settingsTitle": "Deck-Einstellungen",
|
||||||
|
"newPerDay": "Neue pro Tag",
|
||||||
|
"newPerDayHint": "Neue Karten pro Tag",
|
||||||
|
"revPerDay": "Wiederholungen pro Tag",
|
||||||
|
"revPerDayHint": "Wiederholungen pro Tag",
|
||||||
|
"save": "Speichern",
|
||||||
|
"saving": "Speichern...",
|
||||||
|
"settingsSaved": "Einstellungen gespeichert",
|
||||||
|
"todayNew": "Heute neu",
|
||||||
|
"todayReview": "Heute wiederholen",
|
||||||
|
"todayLearning": "Lernen",
|
||||||
|
"error": {
|
||||||
|
"update": "Keine Berechtigung zum Aktualisieren",
|
||||||
|
"delete": "Keine Berechtigung zum Löschen",
|
||||||
|
"add": "Keine Berechtigung zum Hinzufügen"
|
||||||
|
},
|
||||||
|
"ipaPlaceholder": "IPA eingeben",
|
||||||
|
"examplePlaceholder": "Beispiel eingeben",
|
||||||
|
"wordRequired": "Bitte Wort eingeben",
|
||||||
|
"definitionRequired": "Bitte Definition eingeben",
|
||||||
|
"cardAdded": "Karte hinzugefügt",
|
||||||
|
"cardType": "Kartentyp",
|
||||||
|
"wordCard": "Wortkarte",
|
||||||
|
"phraseCard": "Phrasenkarte",
|
||||||
|
"sentenceCard": "Satzkarte",
|
||||||
|
"sentence": "Satz",
|
||||||
|
"sentencePlaceholder": "Satz eingeben",
|
||||||
|
"wordPlaceholder": "Wort eingeben",
|
||||||
|
"queryLang": "Abfragesprache",
|
||||||
|
"enterLanguageName": "Bitte Sprachnamen eingeben",
|
||||||
|
"english": "Englisch",
|
||||||
|
"chinese": "Chinesisch",
|
||||||
|
"japanese": "Japanisch",
|
||||||
|
"korean": "Koreanisch",
|
||||||
|
"meanings": "Bedeutungen",
|
||||||
|
"addMeaning": "Bedeutung hinzufügen",
|
||||||
|
"partOfSpeech": "Wortart",
|
||||||
|
"deleteConfirm": "Karte wirklich löschen?",
|
||||||
|
"cardDeleted": "Karte gelöscht",
|
||||||
|
"cardUpdated": "Karte aktualisiert"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,6 +74,77 @@
|
|||||||
"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",
|
||||||
|
"ipaPlaceholder": "Enter IPA pronunciation",
|
||||||
|
"example": "Example",
|
||||||
|
"examplePlaceholder": "Enter an example sentence",
|
||||||
|
"wordAndDefinitionRequired": "Word and definition are required",
|
||||||
|
"wordRequired": "Word is required",
|
||||||
|
"definitionRequired": "At least one definition is required",
|
||||||
|
"cardAdded": "Card added successfully",
|
||||||
|
"cardType": "Card Type",
|
||||||
|
"wordCard": "Word",
|
||||||
|
"phraseCard": "Phrase",
|
||||||
|
"sentenceCard": "Sentence",
|
||||||
|
"sentence": "Sentence",
|
||||||
|
"sentencePlaceholder": "Enter a sentence",
|
||||||
|
"wordPlaceholder": "Enter a word",
|
||||||
|
"queryLang": "Language",
|
||||||
|
"enterLanguageName": "Please enter language name",
|
||||||
|
"english": "English",
|
||||||
|
"chinese": "Chinese",
|
||||||
|
"japanese": "Japanese",
|
||||||
|
"korean": "Korean",
|
||||||
|
"meanings": "Meanings",
|
||||||
|
"addMeaning": "Add Meaning",
|
||||||
|
"partOfSpeech": "Part of Speech",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteConfirm": "Are you sure you want to delete this card?",
|
||||||
|
"cardDeleted": "Card deleted",
|
||||||
|
"permissionDenied": "You do not have permission to perform this action",
|
||||||
|
"resetProgress": "Reset",
|
||||||
|
"resetProgressTitle": "Reset Deck Progress",
|
||||||
|
"resetProgressConfirm": "This will reset all cards in this deck to new state. Your learning progress will be lost. Are you sure?",
|
||||||
|
"resetSuccess": "Successfully reset {count} cards",
|
||||||
|
"resetting": "Resetting...",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"settings": "Settings",
|
||||||
|
"settingsTitle": "Deck Settings",
|
||||||
|
"newPerDay": "New Cards Per Day",
|
||||||
|
"newPerDayHint": "Maximum new cards to learn each day",
|
||||||
|
"revPerDay": "Review Cards Per Day",
|
||||||
|
"revPerDayHint": "Maximum review cards each day",
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"settingsSaved": "Settings saved",
|
||||||
|
"todayNew": "New",
|
||||||
|
"todayReview": "Review",
|
||||||
|
"todayLearning": "Learning",
|
||||||
|
"cardUpdated": "Card updated",
|
||||||
|
"error": {
|
||||||
|
"update": "You do not have permission to update this card.",
|
||||||
|
"delete": "You do not have permission to delete this card.",
|
||||||
|
"add": "You do not have permission to add cards to this deck."
|
||||||
|
}
|
||||||
|
},
|
||||||
"home": {
|
"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.",
|
||||||
@@ -199,6 +270,7 @@
|
|||||||
"ease": "Ease",
|
"ease": "Ease",
|
||||||
"lapses": "Lapses",
|
"lapses": "Lapses",
|
||||||
"showAnswer": "Show Answer",
|
"showAnswer": "Show Answer",
|
||||||
|
"nextCard": "Next",
|
||||||
"again": "Again",
|
"again": "Again",
|
||||||
"hard": "Hard",
|
"hard": "Hard",
|
||||||
"good": "Good",
|
"good": "Good",
|
||||||
@@ -213,7 +285,24 @@
|
|||||||
"days": "{count}d",
|
"days": "{count}d",
|
||||||
"months": "{count}mo",
|
"months": "{count}mo",
|
||||||
"minAbbr": "m",
|
"minAbbr": "m",
|
||||||
"dayAbbr": "d"
|
"dayAbbr": "d",
|
||||||
|
"cardTypeNew": "New",
|
||||||
|
"cardTypeLearning": "Learning",
|
||||||
|
"cardTypeReview": "Review",
|
||||||
|
"cardTypeRelearning": "Relearning",
|
||||||
|
"reverse": "Reverse",
|
||||||
|
"dictation": "Dictation",
|
||||||
|
"clickToPlay": "Click to play audio",
|
||||||
|
"restart": "Restart",
|
||||||
|
"yourAnswer": "Your answer",
|
||||||
|
"typeWhatYouHear": "Type what you hear...",
|
||||||
|
"correct": "Correct",
|
||||||
|
"incorrect": "Incorrect",
|
||||||
|
"orderLimited": "Order",
|
||||||
|
"orderInfinite": "Loop",
|
||||||
|
"randomLimited": "Random",
|
||||||
|
"randomInfinite": "Random Loop",
|
||||||
|
"noIpa": "No IPA available"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "You are not authorized to access this deck"
|
"unauthorized": "You are not authorized to access this deck"
|
||||||
@@ -224,7 +313,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"
|
||||||
@@ -238,6 +327,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",
|
||||||
@@ -261,6 +351,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",
|
||||||
@@ -311,12 +402,43 @@
|
|||||||
"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",
|
||||||
|
"language": "Language",
|
||||||
|
"customLanguage": "or type language...",
|
||||||
|
"languages": {
|
||||||
|
"auto": "Auto",
|
||||||
|
"chinese": "Chinese",
|
||||||
|
"english": "English",
|
||||||
|
"japanese": "Japanese",
|
||||||
|
"korean": "Korean",
|
||||||
|
"french": "French",
|
||||||
|
"german": "German",
|
||||||
|
"italian": "Italian",
|
||||||
|
"spanish": "Spanish",
|
||||||
|
"portuguese": "Portuguese",
|
||||||
|
"russian": "Russian"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"detectLanguage": "detect language",
|
"detectLanguage": "detect language",
|
||||||
@@ -324,6 +446,7 @@
|
|||||||
"auto": "Auto",
|
"auto": "Auto",
|
||||||
"generateIPA": "generate ipa",
|
"generateIPA": "generate ipa",
|
||||||
"translateInto": "translate into",
|
"translateInto": "translate into",
|
||||||
|
"customLanguage": "or type language...",
|
||||||
"chinese": "Chinese",
|
"chinese": "Chinese",
|
||||||
"english": "English",
|
"english": "English",
|
||||||
"french": "French",
|
"french": "French",
|
||||||
@@ -349,7 +472,19 @@
|
|||||||
"success": "Text pair added to folder",
|
"success": "Text pair added to folder",
|
||||||
"error": "Failed to add text pair to folder"
|
"error": "Failed to add text pair to folder"
|
||||||
},
|
},
|
||||||
"autoSave": "Auto Save"
|
"autoSave": "Auto Save",
|
||||||
|
"pleaseLogin": "Please login to save cards",
|
||||||
|
"pleaseCreateDeck": "Please create a deck first",
|
||||||
|
"noTranslationToSave": "No translation to save",
|
||||||
|
"noDeckSelected": "No deck selected",
|
||||||
|
"saveAsCard": "Save as Card",
|
||||||
|
"selectDeck": "Select Deck",
|
||||||
|
"front": "Front",
|
||||||
|
"back": "Back",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save",
|
||||||
|
"savedToDeck": "Card saved to {deckName}",
|
||||||
|
"saveFailed": "Failed to save card"
|
||||||
},
|
},
|
||||||
"dictionary": {
|
"dictionary": {
|
||||||
"title": "Dictionary",
|
"title": "Dictionary",
|
||||||
@@ -456,6 +591,33 @@
|
|||||||
"view": "View"
|
"view": "View"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"decks": {
|
||||||
|
"title": "Decks",
|
||||||
|
"subtitle": "Manage your flashcard decks",
|
||||||
|
"newDeck": "New Deck",
|
||||||
|
"noDecksYet": "No decks yet",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"deckInfo": "ID: {id} • {totalCards} cards",
|
||||||
|
"enterDeckName": "Enter deck name:",
|
||||||
|
"enterNewName": "Enter new name:",
|
||||||
|
"confirmDelete": "Type \"{name}\" to delete:",
|
||||||
|
"public": "Public",
|
||||||
|
"private": "Private",
|
||||||
|
"setPublic": "Set Public",
|
||||||
|
"setPrivate": "Set Private",
|
||||||
|
"importApkg": "Import APKG",
|
||||||
|
"exportApkg": "Export APKG",
|
||||||
|
"clickToUpload": "Click to upload an APKG file",
|
||||||
|
"apkgFilesOnly": "Only .apkg files are supported",
|
||||||
|
"parsing": "Parsing...",
|
||||||
|
"foundDecks": "Found {count} deck(s)",
|
||||||
|
"deckName": "Deck Name",
|
||||||
|
"back": "Back",
|
||||||
|
"import": "Import",
|
||||||
|
"importing": "Importing...",
|
||||||
|
"exportSuccess": "Deck exported successfully",
|
||||||
|
"goToDecks": "Go to Decks"
|
||||||
|
},
|
||||||
"follow": {
|
"follow": {
|
||||||
"follow": "Follow",
|
"follow": "Follow",
|
||||||
"following": "Following",
|
"following": "Following",
|
||||||
|
|||||||
@@ -53,7 +53,30 @@
|
|||||||
"totalCards": "Total des cartes",
|
"totalCards": "Total des cartes",
|
||||||
"createdAt": "Créé le",
|
"createdAt": "Créé le",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"view": "Voir"
|
"view": "Voir",
|
||||||
|
"subtitle": "Gérer vos decks d'apprentissage",
|
||||||
|
"newDeck": "Nouveau deck",
|
||||||
|
"noDecksYet": "Pas encore de decks",
|
||||||
|
"loading": "Chargement...",
|
||||||
|
"deckInfo": "ID: {id} · {totalCards} cartes",
|
||||||
|
"enterDeckName": "Nom du deck:",
|
||||||
|
"enterNewName": "Nouveau nom:",
|
||||||
|
"confirmDelete": "Tapez \"{name}\" pour supprimer:",
|
||||||
|
"public": "Public",
|
||||||
|
"private": "Privé",
|
||||||
|
"setPublic": "Rendre public",
|
||||||
|
"setPrivate": "Rendre privé",
|
||||||
|
"importApkg": "Importer APKG",
|
||||||
|
"exportApkg": "Exporter APKG",
|
||||||
|
"clickToUpload": "Cliquez pour télécharger",
|
||||||
|
"apkgFilesOnly": "Fichiers .apkg uniquement",
|
||||||
|
"parsing": "Analyse...",
|
||||||
|
"foundDecks": "{count} decks trouvés",
|
||||||
|
"back": "Retour",
|
||||||
|
"import": "Importer",
|
||||||
|
"importing": "Import...",
|
||||||
|
"exportSuccess": "Export réussi",
|
||||||
|
"goToDecks": "Aller aux decks"
|
||||||
},
|
},
|
||||||
"folder_id": {
|
"folder_id": {
|
||||||
"unauthorized": "Vous n'êtes pas le propriétaire de ce dossier",
|
"unauthorized": "Vous n'êtes pas le propriétaire de ce dossier",
|
||||||
@@ -83,6 +106,77 @@
|
|||||||
"deleteFolder": "Vous n'avez pas la permission de supprimer ce dossier."
|
"deleteFolder": "Vous n'avez pas la permission de supprimer ce dossier."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"deck_id": {
|
||||||
|
"unauthorized": "Vous n'êtes pas le propriétaire de ce deck",
|
||||||
|
"back": "Retour",
|
||||||
|
"cards": "Cartes",
|
||||||
|
"itemsCount": "{count} éléments",
|
||||||
|
"memorize": "Mémoriser",
|
||||||
|
"loadingCards": "Chargement des cartes...",
|
||||||
|
"noCards": "Aucune carte dans ce deck",
|
||||||
|
"card": "Carte",
|
||||||
|
"addNewCard": "Ajouter une nouvelle carte",
|
||||||
|
"add": "Ajouter",
|
||||||
|
"adding": "Ajout en cours...",
|
||||||
|
"updateCard": "Mettre à jour la carte",
|
||||||
|
"update": "Mettre à jour",
|
||||||
|
"updating": "Mise à jour en cours...",
|
||||||
|
"word": "Mot",
|
||||||
|
"definition": "Définition",
|
||||||
|
"ipa": "IPA",
|
||||||
|
"example": "Exemple",
|
||||||
|
"wordAndDefinitionRequired": "Le mot et la définition sont requis",
|
||||||
|
"edit": "Modifier",
|
||||||
|
"delete": "Supprimer",
|
||||||
|
"permissionDenied": "Vous n'avez pas la permission d'effectuer cette action",
|
||||||
|
"resetProgress": "Réinitialiser progression",
|
||||||
|
"resetProgressTitle": "Réinitialiser la progression",
|
||||||
|
"resetProgressConfirm": "Réinitialiser la progression?",
|
||||||
|
"resetSuccess": "Progression réinitialisée",
|
||||||
|
"resetting": "Réinitialisation...",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"settings": "Paramètres",
|
||||||
|
"settingsTitle": "Paramètres du deck",
|
||||||
|
"newPerDay": "Nouvelles par jour",
|
||||||
|
"newPerDayHint": "Nouvelles cartes par jour",
|
||||||
|
"revPerDay": "Révisions par jour",
|
||||||
|
"revPerDayHint": "Révisions par jour",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"saving": "Enregistrement...",
|
||||||
|
"settingsSaved": "Paramètres enregistrés",
|
||||||
|
"todayNew": "Nouvelles aujourd'hui",
|
||||||
|
"todayReview": "Révisions aujourd'hui",
|
||||||
|
"todayLearning": "En apprentissage",
|
||||||
|
"error": {
|
||||||
|
"update": "Pas autorisé à modifier",
|
||||||
|
"delete": "Pas autorisé à supprimer",
|
||||||
|
"add": "Pas autorisé à ajouter"
|
||||||
|
},
|
||||||
|
"ipaPlaceholder": "Entrer IPA",
|
||||||
|
"examplePlaceholder": "Entrer exemple",
|
||||||
|
"wordRequired": "Veuillez entrer un mot",
|
||||||
|
"definitionRequired": "Veuillez entrer une définition",
|
||||||
|
"cardAdded": "Carte ajoutée",
|
||||||
|
"cardType": "Type de carte",
|
||||||
|
"wordCard": "Carte mot",
|
||||||
|
"phraseCard": "Carte phrase",
|
||||||
|
"sentenceCard": "Carte phrase",
|
||||||
|
"sentence": "Phrase",
|
||||||
|
"sentencePlaceholder": "Entrer phrase",
|
||||||
|
"wordPlaceholder": "Entrer mot",
|
||||||
|
"queryLang": "Langue de requête",
|
||||||
|
"enterLanguageName": "Veuillez entrer le nom de la langue",
|
||||||
|
"english": "Anglais",
|
||||||
|
"chinese": "Chinois",
|
||||||
|
"japanese": "Japonais",
|
||||||
|
"korean": "Coréen",
|
||||||
|
"meanings": "Significations",
|
||||||
|
"addMeaning": "Ajouter signification",
|
||||||
|
"partOfSpeech": "Partie du discours",
|
||||||
|
"deleteConfirm": "Supprimer cette carte?",
|
||||||
|
"cardDeleted": "Carte supprimée",
|
||||||
|
"cardUpdated": "Carte mise à jour"
|
||||||
|
},
|
||||||
"home": {
|
"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.",
|
||||||
@@ -187,10 +281,10 @@
|
|||||||
},
|
},
|
||||||
"memorize": {
|
"memorize": {
|
||||||
"deck_selector": {
|
"deck_selector": {
|
||||||
"selectDeck": "Sélectionner un deck",
|
"selectDeck": "Choisir deck",
|
||||||
"noDecks": "Aucun deck trouvé",
|
"noDecks": "Pas de decks",
|
||||||
"goToDecks": "Aller aux decks",
|
"goToDecks": "Aller aux decks",
|
||||||
"noCards": "Aucune carte",
|
"noCards": "Pas de cartes",
|
||||||
"new": "Nouveau",
|
"new": "Nouveau",
|
||||||
"learning": "Apprentissage",
|
"learning": "Apprentissage",
|
||||||
"review": "Révision",
|
"review": "Révision",
|
||||||
@@ -199,33 +293,26 @@
|
|||||||
"review": {
|
"review": {
|
||||||
"loading": "Chargement...",
|
"loading": "Chargement...",
|
||||||
"backToDecks": "Retour aux decks",
|
"backToDecks": "Retour aux decks",
|
||||||
"allDone": "Terminé !",
|
"allDone": "Tout terminé!",
|
||||||
"allDoneDesc": "Vous avez révisé toutes les cartes dues.",
|
"allDoneDesc": "Apprentissage terminé pour aujourd'hui!",
|
||||||
"reviewedCount": "{count} cartes révisées",
|
"reviewedCount": "{count} cartes révisées",
|
||||||
"progress": "{current} / {total}",
|
"progress": "{current} / {total}",
|
||||||
"nextReview": "Prochaine révision",
|
"nextReview": "Prochaine révision",
|
||||||
"interval": "Intervalle",
|
"interval": "Intervalle",
|
||||||
"ease": "Facilité",
|
"ease": "Facilité",
|
||||||
"lapses": "Oublis",
|
"lapses": "Erreurs",
|
||||||
"showAnswer": "Afficher la réponse",
|
"showAnswer": "Montrer réponse",
|
||||||
|
"nextCard": "Suivant",
|
||||||
"again": "Encore",
|
"again": "Encore",
|
||||||
"hard": "Difficile",
|
"restart": "Recommencer",
|
||||||
"good": "Bien",
|
"orderLimited": "Ordre limité",
|
||||||
"easy": "Facile",
|
"orderInfinite": "Ordre infini",
|
||||||
"now": "maintenant",
|
"randomLimited": "Aléatoire limité",
|
||||||
"lessThanMinute": "<1 min",
|
"randomInfinite": "Aléatoire infini",
|
||||||
"inMinutes": "{count} min",
|
"noIpa": "Pas d'IPA disponible"
|
||||||
"inHours": "{count}h",
|
|
||||||
"inDays": "{count}j",
|
|
||||||
"inMonths": "{count}mois",
|
|
||||||
"minutes": "<1 min",
|
|
||||||
"days": "{count}j",
|
|
||||||
"months": "{count}mois",
|
|
||||||
"minAbbr": "m",
|
|
||||||
"dayAbbr": "j"
|
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "Vous n'êtes pas autorisé à accéder à ce deck"
|
"unauthorized": "Non autorisé"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navbar": {
|
"navbar": {
|
||||||
@@ -233,39 +320,55 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
"ocr": {
|
"ocr": {
|
||||||
"title": "Extraction OCR de vocabulaire",
|
"title": "Reconnaissance OCR",
|
||||||
"description": "Téléchargez des captures d'écran de tableaux de vocabulaire pour extraire les paires mot-définition",
|
"description": "Extraire le texte des images",
|
||||||
"uploadImage": "Télécharger une image",
|
"uploadImage": "Télécharger image",
|
||||||
"dragDropHint": "Glissez-déposez une image ici, ou cliquez pour sélectionner",
|
"dragDropHint": "Glisser-déposer",
|
||||||
"supportedFormats": "Supportés : JPG, PNG, WebP",
|
"supportedFormats": "Formats: JPG, PNG, WEBP",
|
||||||
"selectDeck": "Sélectionner un deck",
|
"selectDeck": "Choisir deck",
|
||||||
"chooseDeck": "Choisissez a deck to save the extracted pairs",
|
"chooseDeck": "Choisir un deck",
|
||||||
"noDecks": "Aucun deck disponible. Please create a deck first.",
|
"noDecks": "Pas de decks disponibles",
|
||||||
"languageHints": "Indices de langue (Optionnel)",
|
"languageHints": "Indications de langue",
|
||||||
"sourceLanguageHint": "Langue source (ex : Anglais)",
|
"sourceLanguageHint": "Langue source",
|
||||||
"targetLanguageHint": "Langue cible/traduction (ex : Chinois)",
|
"targetLanguageHint": "Langue cible",
|
||||||
"process": "Traiter l'image",
|
"process": "Traiter",
|
||||||
"processing": "Traitement...",
|
"processing": "Traitement...",
|
||||||
"preview": "Aperçu",
|
"preview": "Aperçu",
|
||||||
"extractedPairs": "Paires extraites",
|
"extractedPairs": "Paires extraites",
|
||||||
"word": "Mot",
|
"word": "Mot",
|
||||||
"definition": "Définition",
|
"definition": "Définition",
|
||||||
"pairsCount": "{count} paires extraites",
|
"pairsCount": "{count} paires",
|
||||||
"savePairs": "Sauvegarder dans le deck",
|
"savePairs": "Enregistrer",
|
||||||
"saving": "Sauvegarde...",
|
"saving": "Enregistrement...",
|
||||||
"saved": "{count} paires sauvegardées dans {deck}",
|
"saved": "Enregistré",
|
||||||
"saveFailed": "Échec de la sauvegarde",
|
"saveFailed": "Échec de l'enregistrement",
|
||||||
"noImage": "Veuillez first upload an image",
|
"noImage": "Veuillez télécharger une image",
|
||||||
"noDeck": "Please select a deck",
|
"noDeck": "Veuillez choisir un deck",
|
||||||
"processingFailed": "Échec du traitement OCR",
|
"processingFailed": "Traitement échoué",
|
||||||
"tryAgain": "Please try again with a clearer image",
|
"tryAgain": "Réessayer",
|
||||||
"detectedLanguages": "Détecté : {source} → {target}"
|
"detectedLanguages": "Langues détectées",
|
||||||
|
"uploadSection": "Télécharger image",
|
||||||
|
"dropOrClick": "Déposer ou cliquer",
|
||||||
|
"changeImage": "Changer image",
|
||||||
|
"invalidFileType": "Type de fichier invalide",
|
||||||
|
"deckSelection": "Choisir deck",
|
||||||
|
"sourceLanguagePlaceholder": "ex: Anglais",
|
||||||
|
"targetLanguagePlaceholder": "ex: Français",
|
||||||
|
"processButton": "Démarrer reconnaissance",
|
||||||
|
"resultsPreview": "Aperçu des résultats",
|
||||||
|
"saveButton": "Enregistrer dans le deck",
|
||||||
|
"ocrSuccess": "OCR réussi",
|
||||||
|
"ocrFailed": "OCR échoué",
|
||||||
|
"savedToDeck": "Enregistré dans le deck",
|
||||||
|
"noResultsToSave": "Pas de résultats",
|
||||||
|
"detectedSourceLanguage": "Langue source détectée",
|
||||||
|
"detectedTargetLanguage": "Langue cible détectée"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"myProfile": "Mon profil",
|
"myProfile": "Mon profil",
|
||||||
@@ -306,12 +409,43 @@
|
|||||||
"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",
|
"subtitleLoadSuccess": "Sous-titres chargés avec succès",
|
||||||
"subtitleLoadFailed": "Échec du chargement des sous-titres"
|
"subtitleLoadFailed": "Échec du chargement des sous-titres",
|
||||||
|
"settings": "Paramètres",
|
||||||
|
"shortcuts": "Raccourcis",
|
||||||
|
"keyboardShortcuts": "Raccourcis clavier",
|
||||||
|
"playPause": "Lecture/Pause",
|
||||||
|
"autoPauseToggle": "Pause auto",
|
||||||
|
"subtitleSettings": "Paramètres sous-titres",
|
||||||
|
"fontSize": "Taille police",
|
||||||
|
"textColor": "Couleur texte",
|
||||||
|
"backgroundColor": "Couleur fond",
|
||||||
|
"position": "Position",
|
||||||
|
"opacity": "Opacité",
|
||||||
|
"top": "Haut",
|
||||||
|
"center": "Centre",
|
||||||
|
"bottom": "Bas"
|
||||||
},
|
},
|
||||||
"text_speaker": {
|
"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)",
|
||||||
|
"saved": "Enregistré",
|
||||||
|
"clearAll": "Tout effacer",
|
||||||
|
"language": "Langue",
|
||||||
|
"customLanguage": "ou entrer une langue...",
|
||||||
|
"languages": {
|
||||||
|
"auto": "Auto",
|
||||||
|
"chinese": "Chinois",
|
||||||
|
"english": "Anglais",
|
||||||
|
"japanese": "Japonais",
|
||||||
|
"korean": "Coréen",
|
||||||
|
"french": "Français",
|
||||||
|
"german": "Allemand",
|
||||||
|
"italian": "Italien",
|
||||||
|
"spanish": "Espagnol",
|
||||||
|
"portuguese": "Portugais",
|
||||||
|
"russian": "Russe"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"detectLanguage": "détecter la langue",
|
"detectLanguage": "détecter la langue",
|
||||||
@@ -344,7 +478,20 @@
|
|||||||
"success": "Paire de texte ajoutée au dossier",
|
"success": "Paire de texte ajoutée au dossier",
|
||||||
"error": "Échec de l'ajout de la paire de texte au dossier"
|
"error": "Échec de l'ajout de la paire de texte au dossier"
|
||||||
},
|
},
|
||||||
"autoSave": "Sauvegarde automatique"
|
"autoSave": "Sauvegarde automatique",
|
||||||
|
"customLanguage": "ou tapez la langue...",
|
||||||
|
"pleaseLogin": "Connectez-vous pour sauvegarder",
|
||||||
|
"pleaseCreateDeck": "Créez d'abord un deck",
|
||||||
|
"noTranslationToSave": "Pas de traduction à sauvegarder",
|
||||||
|
"noDeckSelected": "Aucun deck sélectionné",
|
||||||
|
"saveAsCard": "Sauvegarder comme carte",
|
||||||
|
"selectDeck": "Sélectionner deck",
|
||||||
|
"front": "Recto",
|
||||||
|
"back": "Verso",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"save": "Sauvegarder",
|
||||||
|
"savedToDeck": "Carte sauvegardée dans {deckName}",
|
||||||
|
"saveFailed": "Échec de la sauvegarde"
|
||||||
},
|
},
|
||||||
"dictionary": {
|
"dictionary": {
|
||||||
"title": "Dictionnaire",
|
"title": "Dictionnaire",
|
||||||
@@ -389,7 +536,9 @@
|
|||||||
"unfavorite": "Retirer des favoris",
|
"unfavorite": "Retirer des favoris",
|
||||||
"pleaseLogin": "Veuillez vous connecter d'abord",
|
"pleaseLogin": "Veuillez vous connecter d'abord",
|
||||||
"sortByFavorites": "Trier par favoris",
|
"sortByFavorites": "Trier par favoris",
|
||||||
"sortByFavoritesActive": "Annuler le tri par favoris"
|
"sortByFavoritesActive": "Annuler le tri par favoris",
|
||||||
|
"noDecks": "Pas de decks publics",
|
||||||
|
"deckInfo": "{userName} · {totalCards} cartes"
|
||||||
},
|
},
|
||||||
"exploreDetail": {
|
"exploreDetail": {
|
||||||
"title": "Détails du dossier",
|
"title": "Détails du dossier",
|
||||||
@@ -403,7 +552,8 @@
|
|||||||
"unfavorite": "Retirer des favoris",
|
"unfavorite": "Retirer des favoris",
|
||||||
"favorited": "Ajouté aux favoris",
|
"favorited": "Ajouté aux favoris",
|
||||||
"unfavorited": "Retiré des favoris",
|
"unfavorited": "Retiré des favoris",
|
||||||
"pleaseLogin": "Veuillez vous connecter d'abord"
|
"pleaseLogin": "Veuillez vous connecter d'abord",
|
||||||
|
"totalCards": "{count} cartes"
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "Mes favoris",
|
"title": "Mes favoris",
|
||||||
@@ -448,6 +598,16 @@
|
|||||||
"createdAt": "Créé le",
|
"createdAt": "Créé le",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"view": "Voir"
|
"view": "Voir"
|
||||||
}
|
},
|
||||||
|
"joined": "Inscrit le"
|
||||||
|
},
|
||||||
|
"follow": {
|
||||||
|
"follow": "Suivre",
|
||||||
|
"following": "Abonné",
|
||||||
|
"followers": "Abonnés",
|
||||||
|
"followersOf": "Abonnés de {username}",
|
||||||
|
"followingOf": "Abonnements de {username}",
|
||||||
|
"noFollowers": "Pas encore d'abonnés",
|
||||||
|
"noFollowing": "Ne suit personne"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,30 @@
|
|||||||
"totalCards": "Totale carte",
|
"totalCards": "Totale carte",
|
||||||
"createdAt": "Creato il",
|
"createdAt": "Creato il",
|
||||||
"actions": "Azioni",
|
"actions": "Azioni",
|
||||||
"view": "Visualizza"
|
"view": "Visualizza",
|
||||||
|
"subtitle": "Gestisci i tuoi deck",
|
||||||
|
"newDeck": "Nuovo deck",
|
||||||
|
"noDecksYet": "Nessun deck ancora",
|
||||||
|
"loading": "Caricamento...",
|
||||||
|
"deckInfo": "ID: {id} · {totalCards} carte",
|
||||||
|
"enterDeckName": "Nome deck:",
|
||||||
|
"enterNewName": "Nuovo nome:",
|
||||||
|
"confirmDelete": "Digita \"{name}\" per eliminare:",
|
||||||
|
"public": "Pubblico",
|
||||||
|
"private": "Privato",
|
||||||
|
"setPublic": "Rendi pubblico",
|
||||||
|
"setPrivate": "Rendi privato",
|
||||||
|
"importApkg": "Importa APKG",
|
||||||
|
"exportApkg": "Esporta APKG",
|
||||||
|
"clickToUpload": "Clicca per caricare",
|
||||||
|
"apkgFilesOnly": "Solo file .apkg",
|
||||||
|
"parsing": "Analisi...",
|
||||||
|
"foundDecks": "{count} deck trovati",
|
||||||
|
"back": "Indietro",
|
||||||
|
"import": "Importa",
|
||||||
|
"importing": "Importazione...",
|
||||||
|
"exportSuccess": "Esportazione riuscita",
|
||||||
|
"goToDecks": "Vai ai deck"
|
||||||
},
|
},
|
||||||
"folder_id": {
|
"folder_id": {
|
||||||
"unauthorized": "Non sei il proprietario di questa cartella",
|
"unauthorized": "Non sei il proprietario di questa cartella",
|
||||||
@@ -83,6 +106,77 @@
|
|||||||
"deleteFolder": "Non hai il permesso di eliminare questa cartella."
|
"deleteFolder": "Non hai il permesso di eliminare questa cartella."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"deck_id": {
|
||||||
|
"unauthorized": "Non sei il proprietario di questo deck",
|
||||||
|
"back": "Indietro",
|
||||||
|
"cards": "Schede",
|
||||||
|
"itemsCount": "{count} elementi",
|
||||||
|
"memorize": "Memorizza",
|
||||||
|
"loadingCards": "Caricamento schede...",
|
||||||
|
"noCards": "Nessuna scheda in questo deck",
|
||||||
|
"card": "Scheda",
|
||||||
|
"addNewCard": "Aggiungi nuova scheda",
|
||||||
|
"add": "Aggiungi",
|
||||||
|
"adding": "Aggiunta in corso...",
|
||||||
|
"updateCard": "Aggiorna scheda",
|
||||||
|
"update": "Aggiorna",
|
||||||
|
"updating": "Aggiornamento in corso...",
|
||||||
|
"word": "Parola",
|
||||||
|
"definition": "Definizione",
|
||||||
|
"ipa": "IPA",
|
||||||
|
"example": "Esempio",
|
||||||
|
"wordAndDefinitionRequired": "Parola e definizione sono obbligatori",
|
||||||
|
"edit": "Modifica",
|
||||||
|
"delete": "Elimina",
|
||||||
|
"permissionDenied": "Non hai il permesso per questa accion",
|
||||||
|
"resetProgress": "Reimposta progresso",
|
||||||
|
"resetProgressTitle": "Reimposta progresso di apprendimento",
|
||||||
|
"resetProgressConfirm": "Reimpostare il progresso?",
|
||||||
|
"resetSuccess": "Progresso reimpostato",
|
||||||
|
"resetting": "Reimpostazione...",
|
||||||
|
"cancel": "Annulla",
|
||||||
|
"settings": "Impostazioni",
|
||||||
|
"settingsTitle": "Impostazioni deck",
|
||||||
|
"newPerDay": "Nuove al giorno",
|
||||||
|
"newPerDayHint": "Nuove carte al giorno",
|
||||||
|
"revPerDay": "Ripassate al giorno",
|
||||||
|
"revPerDayHint": "Ripassi al giorno",
|
||||||
|
"save": "Salva",
|
||||||
|
"saving": "Salvataggio...",
|
||||||
|
"settingsSaved": "Impostazioni salvate",
|
||||||
|
"todayNew": "Oggi nuove",
|
||||||
|
"todayReview": "Oggi ripasso",
|
||||||
|
"todayLearning": "In apprendimento",
|
||||||
|
"error": {
|
||||||
|
"update": "Nessun permesso di aggiornare",
|
||||||
|
"delete": "Nessun permesso di eliminare",
|
||||||
|
"add": "Nessun permesso di aggiungere"
|
||||||
|
},
|
||||||
|
"ipaPlaceholder": "Inserisci IPA",
|
||||||
|
"examplePlaceholder": "Inserisci esempio",
|
||||||
|
"wordRequired": "Inserisci una parola",
|
||||||
|
"definitionRequired": "Inserisci una definizione",
|
||||||
|
"cardAdded": "Carta aggiunta",
|
||||||
|
"cardType": "Tipo di carta",
|
||||||
|
"wordCard": "Carta parola",
|
||||||
|
"phraseCard": "Carta frase",
|
||||||
|
"sentenceCard": "Carta frase",
|
||||||
|
"sentence": "Frase",
|
||||||
|
"sentencePlaceholder": "Inserisci frase",
|
||||||
|
"wordPlaceholder": "Inserisci parola",
|
||||||
|
"queryLang": "Lingua di query",
|
||||||
|
"enterLanguageName": "Inserisci il nome della lingua",
|
||||||
|
"english": "Inglese",
|
||||||
|
"chinese": "Cinese",
|
||||||
|
"japanese": "Giapponese",
|
||||||
|
"korean": "Coreano",
|
||||||
|
"meanings": "Significati",
|
||||||
|
"addMeaning": "Aggiungi significato",
|
||||||
|
"partOfSpeech": "Parte del discorso",
|
||||||
|
"deleteConfirm": "Eliminare questa carta?",
|
||||||
|
"cardDeleted": "Carta eliminata",
|
||||||
|
"cardUpdated": "Carta aggiornata"
|
||||||
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"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.",
|
||||||
@@ -187,45 +281,63 @@
|
|||||||
},
|
},
|
||||||
"memorize": {
|
"memorize": {
|
||||||
"deck_selector": {
|
"deck_selector": {
|
||||||
"selectDeck": "Seleziona un mazzo",
|
"selectDeck": "Seleziona deck",
|
||||||
"noDecks": "Nessun mazzo trovato",
|
"noDecks": "Nessun deck",
|
||||||
"goToDecks": "Vai ai mazzi",
|
"goToDecks": "Vai ai deck",
|
||||||
"noCards": "Nessuna carta",
|
"noCards": "Nessuna carta",
|
||||||
"new": "Nuove",
|
"new": "Nuovo",
|
||||||
"learning": "In apprendimento",
|
"learning": "Apprendimento",
|
||||||
"review": "Ripasso",
|
"review": "Ripasso",
|
||||||
"due": "In scadenza"
|
"due": "In scadenza"
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"loading": "Caricamento...",
|
"loading": "Caricamento...",
|
||||||
"backToDecks": "Torna ai mazzi",
|
"backToDecks": "Torna ai deck",
|
||||||
"allDone": "Fatto!",
|
"allDone": "Tutto fatto!",
|
||||||
"allDoneDesc": "Hai ripassato tutte le carte in scadenza.",
|
"allDoneDesc": "Apprendimento di oggi completato!",
|
||||||
"reviewedCount": "{count} carte ripassate",
|
"reviewedCount": "{count} carte ripassate",
|
||||||
"progress": "{current} / {total}",
|
"progress": "{current} / {total}",
|
||||||
"nextReview": "Prossima revisione",
|
"nextReview": "Prossimo ripasso",
|
||||||
"interval": "Intervallo",
|
"interval": "Intervallo",
|
||||||
"ease": "Facilità",
|
"ease": "Difficoltà",
|
||||||
"lapses": "Dimenticanze",
|
"lapses": "Errori",
|
||||||
"showAnswer": "Mostra risposta",
|
"showAnswer": "Mostra risposta",
|
||||||
|
"nextCard": "Prossima",
|
||||||
"again": "Ancora",
|
"again": "Ancora",
|
||||||
|
"restart": "Ricomincia",
|
||||||
"hard": "Difficile",
|
"hard": "Difficile",
|
||||||
"good": "Bene",
|
"good": "Buono",
|
||||||
"easy": "Facile",
|
"easy": "Facile",
|
||||||
"now": "ora",
|
"now": "Ora",
|
||||||
"lessThanMinute": "<1 min",
|
"lessThanMinute": "meno di 1 minuto",
|
||||||
"inMinutes": "{count} min",
|
"inMinutes": "tra {n} minuti",
|
||||||
"inHours": "{count}h",
|
"inHours": "tra {n} ore",
|
||||||
"inDays": "{count}g",
|
"inDays": "tra {n} giorni",
|
||||||
"inMonths": "{count}mesi",
|
"inMonths": "tra {n} mesi",
|
||||||
"minutes": "<1 min",
|
"minutes": "minuti",
|
||||||
"days": "{count}g",
|
"days": "giorni",
|
||||||
"months": "{count}mesi",
|
"months": "mesi",
|
||||||
"minAbbr": "m",
|
"minAbbr": "min",
|
||||||
"dayAbbr": "g"
|
"dayAbbr": "g",
|
||||||
|
"cardTypeNew": "Nuovo",
|
||||||
|
"cardTypeLearning": "Apprendimento",
|
||||||
|
"cardTypeReview": "Ripasso",
|
||||||
|
"cardTypeRelearning": "Riapprendimento",
|
||||||
|
"reverse": "Inverti",
|
||||||
|
"dictation": "Dettato",
|
||||||
|
"clickToPlay": "Clicca per riprodurre",
|
||||||
|
"yourAnswer": "La tua risposta",
|
||||||
|
"typeWhatYouHear": "Scrivi cosa senti",
|
||||||
|
"correct": "Corretto!",
|
||||||
|
"incorrect": "Errato",
|
||||||
|
"orderLimited": "Ordine limitato",
|
||||||
|
"orderInfinite": "Ordine infinito",
|
||||||
|
"randomLimited": "Casuale limitato",
|
||||||
|
"randomInfinite": "Casuale infinito",
|
||||||
|
"noIpa": "Nessun IPA disponibile"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "Non sei autorizzato ad accedere a questo mazzo"
|
"unauthorized": "Non autorizzato"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navbar": {
|
"navbar": {
|
||||||
@@ -233,39 +345,55 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
"ocr": {
|
"ocr": {
|
||||||
"title": "Estrazione vocaboli OCR",
|
"title": "Riconoscimento OCR",
|
||||||
"description": "Carica screenshot di tabelle di vocaboli per estrarre coppie parola-definizione",
|
"description": "Estrai testo dalle immagini",
|
||||||
"uploadImage": "Carica immagine",
|
"uploadImage": "Carica immagine",
|
||||||
"dragDropHint": "Trascina e rilascia un'immagine qui, o clicca per selezionare",
|
"dragDropHint": "Trascina e rilascia",
|
||||||
"supportedFormats": "Supportati: JPG, PNG, WebP",
|
"supportedFormats": "Supportati: JPG, PNG, WEBP",
|
||||||
"selectDeck": "Seleziona un mazzo",
|
"selectDeck": "Seleziona deck",
|
||||||
"chooseDeck": "Scegli un mazzo per salvare le coppie estratte",
|
"chooseDeck": "Scegli un deck",
|
||||||
"noDecks": "Nessun mazzo disponibile. Creane prima un mazzo.",
|
"noDecks": "Nessun deck disponibile",
|
||||||
"languageHints": "Suggerimenti lingua (Opzionale)",
|
"languageHints": "Suggerimenti lingua",
|
||||||
"sourceLanguageHint": "Lingua sorgente (es: Inglese)",
|
"sourceLanguageHint": "Lingua sorgente",
|
||||||
"targetLanguageHint": "Lingua target/traduzione (es: Cinese)",
|
"targetLanguageHint": "Lingua target",
|
||||||
"process": "Elabora immagine",
|
"process": "Elabora",
|
||||||
"processing": "Elaborazione...",
|
"processing": "Elaborazione...",
|
||||||
"preview": "Anteprima",
|
"preview": "Anteprima",
|
||||||
"extractedPairs": "Coppie estratte",
|
"extractedPairs": "Coppie estratte",
|
||||||
"word": "Parola",
|
"word": "Parola",
|
||||||
"definition": "Definizione",
|
"definition": "Definizione",
|
||||||
"pairsCount": "{count} coppie estratte",
|
"pairsCount": "{count} coppie",
|
||||||
"savePairs": "Salva nel mazzo",
|
"savePairs": "Salva",
|
||||||
"saving": "Salvataggio...",
|
"saving": "Salvataggio...",
|
||||||
"saved": "{count} coppie salvate in {deck}",
|
"saved": "Salvato",
|
||||||
"saveFailed": "Salvataggio fallito",
|
"saveFailed": "Salvataggio fallito",
|
||||||
"noImage": "Carica prima un'immagine",
|
"noImage": "Carica un'immagine",
|
||||||
"noDeck": "Seleziona un mazzo",
|
"noDeck": "Seleziona un deck",
|
||||||
"processingFailed": "Elaborazione OCR fallita",
|
"processingFailed": "Elaborazione fallita",
|
||||||
"tryAgain": "Riprova con un'immagine più chiara",
|
"tryAgain": "Riprova",
|
||||||
"detectedLanguages": "Rilevato: {source} → {target}"
|
"detectedLanguages": "Lingue rilevate",
|
||||||
|
"uploadSection": "Carica immagine",
|
||||||
|
"dropOrClick": "Rilascia o clicca",
|
||||||
|
"changeImage": "Cambia immagine",
|
||||||
|
"invalidFileType": "Tipo di file non valido",
|
||||||
|
"deckSelection": "Seleziona deck",
|
||||||
|
"sourceLanguagePlaceholder": "es: Inglese",
|
||||||
|
"targetLanguagePlaceholder": "es: Italiano",
|
||||||
|
"processButton": "Avvia riconoscimento",
|
||||||
|
"resultsPreview": "Anteprima risultati",
|
||||||
|
"saveButton": "Salva nel deck",
|
||||||
|
"ocrSuccess": "OCR riuscito",
|
||||||
|
"ocrFailed": "OCR fallito",
|
||||||
|
"savedToDeck": "Salvato nel deck",
|
||||||
|
"noResultsToSave": "Nessun risultato",
|
||||||
|
"detectedSourceLanguage": "Lingua sorgente rilevata",
|
||||||
|
"detectedTargetLanguage": "Lingua target rilevata"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"myProfile": "Il Mio Profilo",
|
"myProfile": "Il Mio Profilo",
|
||||||
@@ -306,12 +434,43 @@
|
|||||||
"videoUploadFailed": "Caricamento video fallito",
|
"videoUploadFailed": "Caricamento video fallito",
|
||||||
"subtitleUploadFailed": "Caricamento sottotitoli fallito",
|
"subtitleUploadFailed": "Caricamento sottotitoli fallito",
|
||||||
"subtitleLoadSuccess": "Sottotitoli caricati con successo",
|
"subtitleLoadSuccess": "Sottotitoli caricati con successo",
|
||||||
"subtitleLoadFailed": "Caricamento sottotitoli fallito"
|
"subtitleLoadFailed": "Caricamento sottotitoli fallito",
|
||||||
|
"settings": "Impostazioni",
|
||||||
|
"shortcuts": "Scorciatoie",
|
||||||
|
"keyboardShortcuts": "Scorciatoie tastiera",
|
||||||
|
"playPause": "Riproduci/Pausa",
|
||||||
|
"autoPauseToggle": "Auto-pausa",
|
||||||
|
"subtitleSettings": "Impostazioni sottotitoli",
|
||||||
|
"fontSize": "Dimensione carattere",
|
||||||
|
"textColor": "Colore testo",
|
||||||
|
"backgroundColor": "Colore sfondo",
|
||||||
|
"position": "Posizione",
|
||||||
|
"opacity": "Opacità",
|
||||||
|
"top": "Alto",
|
||||||
|
"center": "Centro",
|
||||||
|
"bottom": "Basso"
|
||||||
},
|
},
|
||||||
"text_speaker": {
|
"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)",
|
||||||
|
"saved": "Salvato",
|
||||||
|
"clearAll": "Cancella tutto",
|
||||||
|
"language": "Lingua",
|
||||||
|
"customLanguage": "o inserisci lingua...",
|
||||||
|
"languages": {
|
||||||
|
"auto": "Auto",
|
||||||
|
"chinese": "Cinese",
|
||||||
|
"english": "Inglese",
|
||||||
|
"japanese": "Giapponese",
|
||||||
|
"korean": "Coreano",
|
||||||
|
"french": "Francese",
|
||||||
|
"german": "Tedesco",
|
||||||
|
"italian": "Italiano",
|
||||||
|
"spanish": "Spagnolo",
|
||||||
|
"portuguese": "Portoghese",
|
||||||
|
"russian": "Russo"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"detectLanguage": "rileva lingua",
|
"detectLanguage": "rileva lingua",
|
||||||
@@ -344,7 +503,20 @@
|
|||||||
"success": "Coppia di testo aggiunta alla cartella",
|
"success": "Coppia di testo aggiunta alla cartella",
|
||||||
"error": "Impossibile aggiungere coppia di testo alla cartella"
|
"error": "Impossibile aggiungere coppia di testo alla cartella"
|
||||||
},
|
},
|
||||||
"autoSave": "Salvataggio Automatico"
|
"autoSave": "Salvataggio Automatico",
|
||||||
|
"customLanguage": "o digita lingua...",
|
||||||
|
"pleaseLogin": "Accedi per salvare le carte",
|
||||||
|
"pleaseCreateDeck": "Crea prima un deck",
|
||||||
|
"noTranslationToSave": "Nessuna traduzione da salvare",
|
||||||
|
"noDeckSelected": "Nessun deck selezionato",
|
||||||
|
"saveAsCard": "Salva come carta",
|
||||||
|
"selectDeck": "Seleziona deck",
|
||||||
|
"front": "Fronte",
|
||||||
|
"back": "Retro",
|
||||||
|
"cancel": "Annulla",
|
||||||
|
"save": "Salva",
|
||||||
|
"savedToDeck": "Carta salvata in {deckName}",
|
||||||
|
"saveFailed": "Salvataggio fallito"
|
||||||
},
|
},
|
||||||
"dictionary": {
|
"dictionary": {
|
||||||
"title": "Dizionario",
|
"title": "Dizionario",
|
||||||
@@ -389,7 +561,9 @@
|
|||||||
"unfavorite": "Rimuovi dai preferiti",
|
"unfavorite": "Rimuovi dai preferiti",
|
||||||
"pleaseLogin": "Per favore accedi prima",
|
"pleaseLogin": "Per favore accedi prima",
|
||||||
"sortByFavorites": "Ordina per preferiti",
|
"sortByFavorites": "Ordina per preferiti",
|
||||||
"sortByFavoritesActive": "Annulla ordinamento per preferiti"
|
"sortByFavoritesActive": "Annulla ordinamento per preferiti",
|
||||||
|
"noDecks": "Nessun deck pubblico",
|
||||||
|
"deckInfo": "{userName} · {totalCards} carte"
|
||||||
},
|
},
|
||||||
"exploreDetail": {
|
"exploreDetail": {
|
||||||
"title": "Dettagli Cartella",
|
"title": "Dettagli Cartella",
|
||||||
@@ -403,7 +577,8 @@
|
|||||||
"unfavorite": "Rimuovi dai preferiti",
|
"unfavorite": "Rimuovi dai preferiti",
|
||||||
"favorited": "Aggiunto ai preferiti",
|
"favorited": "Aggiunto ai preferiti",
|
||||||
"unfavorited": "Rimosso dai preferiti",
|
"unfavorited": "Rimosso dai preferiti",
|
||||||
"pleaseLogin": "Per favore accedi prima"
|
"pleaseLogin": "Per favore accedi prima",
|
||||||
|
"totalCards": "{count} carte"
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "I Miei Preferiti",
|
"title": "I Miei Preferiti",
|
||||||
@@ -443,11 +618,21 @@
|
|||||||
"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"
|
||||||
}
|
},
|
||||||
|
"joined": "Iscritto il"
|
||||||
|
},
|
||||||
|
"follow": {
|
||||||
|
"follow": "Segui",
|
||||||
|
"following": "Stai seguendo",
|
||||||
|
"followers": "Seguaci",
|
||||||
|
"followersOf": "Seguaci di {username}",
|
||||||
|
"followingOf": "Seguiti da {username}",
|
||||||
|
"noFollowers": "Nessun seguace ancora",
|
||||||
|
"noFollowing": "Non segui ancora nessuno"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,6 +74,77 @@
|
|||||||
"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": "リセットしました",
|
||||||
|
"resetting": "リセット中...",
|
||||||
|
"cancel": "キャンセル",
|
||||||
|
"settings": "設定",
|
||||||
|
"settingsTitle": "デッキ設定",
|
||||||
|
"newPerDay": "1日の新規カード",
|
||||||
|
"newPerDayHint": "毎日の新規カード数",
|
||||||
|
"revPerDay": "1日の復習",
|
||||||
|
"revPerDayHint": "毎日の復習数",
|
||||||
|
"save": "保存",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"settingsSaved": "設定を保存しました",
|
||||||
|
"todayNew": "今日の新規",
|
||||||
|
"todayReview": "今日の復習",
|
||||||
|
"todayLearning": "学習中",
|
||||||
|
"error": {
|
||||||
|
"update": "更新する権限がありません",
|
||||||
|
"delete": "削除する権限がありません",
|
||||||
|
"add": "追加する権限がありません"
|
||||||
|
},
|
||||||
|
"ipaPlaceholder": "IPAを入力",
|
||||||
|
"examplePlaceholder": "例文を入力",
|
||||||
|
"wordRequired": "単語を入力してください",
|
||||||
|
"definitionRequired": "定義を入力してください",
|
||||||
|
"cardAdded": "カードを追加しました",
|
||||||
|
"cardType": "カードタイプ",
|
||||||
|
"wordCard": "単語カード",
|
||||||
|
"phraseCard": "フレーズカード",
|
||||||
|
"sentenceCard": "文章カード",
|
||||||
|
"sentence": "文章",
|
||||||
|
"sentencePlaceholder": "文章を入力",
|
||||||
|
"wordPlaceholder": "単語を入力",
|
||||||
|
"queryLang": "検索言語",
|
||||||
|
"enterLanguageName": "言語名を入力してください",
|
||||||
|
"english": "英語",
|
||||||
|
"chinese": "中国語",
|
||||||
|
"japanese": "日本語",
|
||||||
|
"korean": "韓国語",
|
||||||
|
"meanings": "意味",
|
||||||
|
"addMeaning": "意味を追加",
|
||||||
|
"partOfSpeech": "品詞",
|
||||||
|
"deleteConfirm": "このカードを削除しますか?",
|
||||||
|
"cardDeleted": "カードを削除しました",
|
||||||
|
"cardUpdated": "カードを更新しました"
|
||||||
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "言語を学ぶ",
|
"title": "言語を学ぶ",
|
||||||
"description": "ここは世界のほぼすべての言語(人工言語を含む)を学ぶのに役立つ非常に便利なウェブサイトです。",
|
"description": "ここは世界のほぼすべての言語(人工言語を含む)を学ぶのに役立つ非常に便利なウェブサイトです。",
|
||||||
@@ -199,6 +270,7 @@
|
|||||||
"ease": "易しさ",
|
"ease": "易しさ",
|
||||||
"lapses": "忘回数",
|
"lapses": "忘回数",
|
||||||
"showAnswer": "答えを表示",
|
"showAnswer": "答えを表示",
|
||||||
|
"nextCard": "次へ",
|
||||||
"again": "もう一度",
|
"again": "もう一度",
|
||||||
"hard": "難しい",
|
"hard": "難しい",
|
||||||
"good": "普通",
|
"good": "普通",
|
||||||
@@ -213,7 +285,24 @@
|
|||||||
"days": "{count}日",
|
"days": "{count}日",
|
||||||
"months": "{count}ヶ月",
|
"months": "{count}ヶ月",
|
||||||
"minAbbr": "分",
|
"minAbbr": "分",
|
||||||
"dayAbbr": "日"
|
"dayAbbr": "日",
|
||||||
|
"cardTypeNew": "新規",
|
||||||
|
"cardTypeLearning": "学習中",
|
||||||
|
"cardTypeReview": "復習",
|
||||||
|
"cardTypeRelearning": "再学習",
|
||||||
|
"reverse": "反転",
|
||||||
|
"dictation": "聴き取り",
|
||||||
|
"clickToPlay": "クリックして再生",
|
||||||
|
"yourAnswer": "あなたの答え",
|
||||||
|
"typeWhatYouHear": "聞こえた内容を入力",
|
||||||
|
"correct": "正解",
|
||||||
|
"incorrect": "不正解",
|
||||||
|
"restart": "最初から",
|
||||||
|
"orderLimited": "順序制限",
|
||||||
|
"orderInfinite": "順序無限",
|
||||||
|
"randomLimited": "ランダム制限",
|
||||||
|
"randomInfinite": "ランダム無限",
|
||||||
|
"noIpa": "IPAなし"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "このデッキにアクセスする権限がありません"
|
"unauthorized": "このデッキにアクセスする権限がありません"
|
||||||
@@ -224,39 +313,55 @@
|
|||||||
"sourceCode": "GitHub",
|
"sourceCode": "GitHub",
|
||||||
"sign_in": "サインイン",
|
"sign_in": "サインイン",
|
||||||
"profile": "プロフィール",
|
"profile": "プロフィール",
|
||||||
"folders": "フォルダー",
|
"folders": "デッキ",
|
||||||
"explore": "探索",
|
"explore": "探索",
|
||||||
"favorites": "お気に入り",
|
"favorites": "お気に入り",
|
||||||
"settings": "設定"
|
"settings": "設定"
|
||||||
},
|
},
|
||||||
"ocr": {
|
"ocr": {
|
||||||
"title": "OCR語彙抽出",
|
"title": "OCR認識",
|
||||||
"description": "教科書の語彙表のスクリーンショットをアップロードして単語と定義のペアを抽出",
|
"description": "画像からテキストを抽出",
|
||||||
"uploadImage": "画像をアップロード",
|
"uploadImage": "画像をアップロード",
|
||||||
"dragDropHint": "ここに画像をドラッグ&ドロップ、またはクリックして選択",
|
"dragDropHint": "ドラッグ&ドロップ",
|
||||||
"supportedFormats": "対応形式:JPG、PNG、WebP",
|
"supportedFormats": "対応形式:JPG, PNG, WEBP",
|
||||||
"selectDeck": "デッキを選択",
|
"selectDeck": "デッキを選択",
|
||||||
"chooseDeck": "抽出したペアを保存するデッキを選択",
|
"chooseDeck": "デッキを選択",
|
||||||
"noDecks": "デッキがありません。まずデッキを作成してください。",
|
"noDecks": "デッキがありません",
|
||||||
"languageHints": "言語ヒント(オプション)",
|
"languageHints": "言語ヒント",
|
||||||
"sourceLanguageHint": "ソース言語(例:英語)",
|
"sourceLanguageHint": "ソース言語ヒント",
|
||||||
"targetLanguageHint": "ターゲット/翻訳言語(例:中国語)",
|
"targetLanguageHint": "ターゲット言語ヒント",
|
||||||
"process": "画像を処理",
|
"process": "処理",
|
||||||
"processing": "処理中...",
|
"processing": "処理中...",
|
||||||
"preview": "プレビュー",
|
"preview": "プレビュー",
|
||||||
"extractedPairs": "抽出されたペア",
|
"extractedPairs": "抽出ペア",
|
||||||
"word": "単語",
|
"word": "単語",
|
||||||
"definition": "定義",
|
"definition": "定義",
|
||||||
"pairsCount": "{count} ペアを抽出",
|
"pairsCount": "{count}ペア",
|
||||||
"savePairs": "デッキに保存",
|
"savePairs": "保存",
|
||||||
"saving": "保存中...",
|
"saving": "保存中...",
|
||||||
"saved": "{count} ペアを {deck} に保存しました",
|
"saved": "保存済み",
|
||||||
"saveFailed": "保存に失敗しました",
|
"saveFailed": "保存失敗",
|
||||||
"noImage": "先に画像をアップロードしてください",
|
"noImage": "画像をアップロードしてください",
|
||||||
"noDeck": "デッキを選択してください",
|
"noDeck": "デッキを選択してください",
|
||||||
"processingFailed": "OCR処理に失敗しました",
|
"processingFailed": "処理失敗",
|
||||||
"tryAgain": "より鮮明な画像でお試しください",
|
"tryAgain": "再試行",
|
||||||
"detectedLanguages": "検出:{source} → {target}"
|
"detectedLanguages": "検出言語",
|
||||||
|
"invalidFileType": "無効なファイル形式",
|
||||||
|
"ocrFailed": "OCR失敗",
|
||||||
|
"uploadSection": "画像をアップロード",
|
||||||
|
"dropOrClick": "ドロップまたはクリック",
|
||||||
|
"changeImage": "画像を変更",
|
||||||
|
"deckSelection": "デッキを選択",
|
||||||
|
"sourceLanguagePlaceholder": "例:英語",
|
||||||
|
"targetLanguagePlaceholder": "例:日本語",
|
||||||
|
"processButton": "認識開始",
|
||||||
|
"resultsPreview": "結果プレビュー",
|
||||||
|
"saveButton": "デッキに保存",
|
||||||
|
"ocrSuccess": "OCR成功",
|
||||||
|
"savedToDeck": "デッキに保存しました",
|
||||||
|
"noResultsToSave": "結果がありません",
|
||||||
|
"detectedSourceLanguage": "検出ソース言語",
|
||||||
|
"detectedTargetLanguage": "検出ターゲット言語"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"myProfile": "マイプロフィール",
|
"myProfile": "マイプロフィール",
|
||||||
@@ -297,12 +402,43 @@
|
|||||||
"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": "すべてクリア",
|
||||||
|
"language": "言語",
|
||||||
|
"customLanguage": "または言語を入力...",
|
||||||
|
"languages": {
|
||||||
|
"auto": "自動",
|
||||||
|
"chinese": "中国語",
|
||||||
|
"english": "英語",
|
||||||
|
"japanese": "日本語",
|
||||||
|
"korean": "韓国語",
|
||||||
|
"french": "フランス語",
|
||||||
|
"german": "ドイツ語",
|
||||||
|
"italian": "イタリア語",
|
||||||
|
"spanish": "スペイン語",
|
||||||
|
"portuguese": "ポルトガル語",
|
||||||
|
"russian": "ロシア語"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"detectLanguage": "言語を検出",
|
"detectLanguage": "言語を検出",
|
||||||
@@ -335,7 +471,20 @@
|
|||||||
"success": "テキストペアがフォルダーに追加されました",
|
"success": "テキストペアがフォルダーに追加されました",
|
||||||
"error": "テキストペアをフォルダーに追加できませんでした"
|
"error": "テキストペアをフォルダーに追加できませんでした"
|
||||||
},
|
},
|
||||||
"autoSave": "自動保存"
|
"autoSave": "自動保存",
|
||||||
|
"customLanguage": "または言語を入力...",
|
||||||
|
"pleaseLogin": "ログインしてカードを保存",
|
||||||
|
"pleaseCreateDeck": "先にデッキを作成",
|
||||||
|
"noTranslationToSave": "保存する翻訳なし",
|
||||||
|
"noDeckSelected": "デッキ未選択",
|
||||||
|
"saveAsCard": "カードとして保存",
|
||||||
|
"selectDeck": "デッキ選択",
|
||||||
|
"front": "表面",
|
||||||
|
"back": "裏面",
|
||||||
|
"cancel": "キャンセル",
|
||||||
|
"save": "保存",
|
||||||
|
"savedToDeck": "{deckName}に保存",
|
||||||
|
"saveFailed": "保存失敗"
|
||||||
},
|
},
|
||||||
"dictionary": {
|
"dictionary": {
|
||||||
"title": "辞書",
|
"title": "辞書",
|
||||||
@@ -380,7 +529,9 @@
|
|||||||
"unfavorite": "お気に入り解除",
|
"unfavorite": "お気に入り解除",
|
||||||
"pleaseLogin": "まずログインしてください",
|
"pleaseLogin": "まずログインしてください",
|
||||||
"sortByFavorites": "お気に入り順に並べ替え",
|
"sortByFavorites": "お気に入り順に並べ替え",
|
||||||
"sortByFavoritesActive": "お気に入り順の並べ替えを解除"
|
"sortByFavoritesActive": "お気に入り順の並べ替えを解除",
|
||||||
|
"noDecks": "公開デッキなし",
|
||||||
|
"deckInfo": "{userName} · {totalCards}枚"
|
||||||
},
|
},
|
||||||
"exploreDetail": {
|
"exploreDetail": {
|
||||||
"title": "フォルダー詳細",
|
"title": "フォルダー詳細",
|
||||||
@@ -394,7 +545,8 @@
|
|||||||
"unfavorite": "お気に入り解除",
|
"unfavorite": "お気に入り解除",
|
||||||
"favorited": "お気に入りに追加しました",
|
"favorited": "お気に入りに追加しました",
|
||||||
"unfavorited": "お気に入りから削除しました",
|
"unfavorited": "お気に入りから削除しました",
|
||||||
"pleaseLogin": "まずログインしてください"
|
"pleaseLogin": "まずログインしてください",
|
||||||
|
"totalCards": "{count}枚"
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "マイお気に入り",
|
"title": "マイお気に入り",
|
||||||
@@ -439,6 +591,43 @@
|
|||||||
"createdAt": "作成日",
|
"createdAt": "作成日",
|
||||||
"actions": "アクション",
|
"actions": "アクション",
|
||||||
"view": "表示"
|
"view": "表示"
|
||||||
}
|
},
|
||||||
|
"joined": "登録日"
|
||||||
|
},
|
||||||
|
"decks": {
|
||||||
|
"title": "デッキ",
|
||||||
|
"subtitle": "学習デッキを管理",
|
||||||
|
"newDeck": "新規デッキ",
|
||||||
|
"noDecksYet": "デッキなし",
|
||||||
|
"loading": "読込中...",
|
||||||
|
"deckInfo": "ID: {id} · {totalCards}枚",
|
||||||
|
"enterDeckName": "デッキ名:",
|
||||||
|
"enterNewName": "新しい名前:",
|
||||||
|
"confirmDelete": "削除確認:「{name}」を入力",
|
||||||
|
"public": "公開",
|
||||||
|
"private": "非公開",
|
||||||
|
"setPublic": "公開に設定",
|
||||||
|
"setPrivate": "非公開に設定",
|
||||||
|
"importApkg": "APKGインポート",
|
||||||
|
"exportApkg": "APKGエクスポート",
|
||||||
|
"clickToUpload": "クリックでアップロード",
|
||||||
|
"apkgFilesOnly": ".apkgのみ",
|
||||||
|
"parsing": "解析中...",
|
||||||
|
"foundDecks": "{count}デッキ発見",
|
||||||
|
"deckName": "デッキ名",
|
||||||
|
"back": "戻る",
|
||||||
|
"import": "インポート",
|
||||||
|
"importing": "インポート中...",
|
||||||
|
"exportSuccess": "エクスポート成功",
|
||||||
|
"goToDecks": "デッキへ"
|
||||||
|
},
|
||||||
|
"follow": {
|
||||||
|
"follow": "フォロー",
|
||||||
|
"following": "フォロー中",
|
||||||
|
"followers": "フォロワー",
|
||||||
|
"followersOf": "{username}のフォロワー",
|
||||||
|
"followingOf": "{username}のフォロー中",
|
||||||
|
"noFollowers": "まだフォロワーがいません",
|
||||||
|
"noFollowing": "まだ誰もフォローしていません"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,30 @@
|
|||||||
"totalCards": "총 카드",
|
"totalCards": "총 카드",
|
||||||
"createdAt": "생성일",
|
"createdAt": "생성일",
|
||||||
"actions": "작업",
|
"actions": "작업",
|
||||||
"view": "보기"
|
"view": "보기",
|
||||||
|
"subtitle": "학습 덱 관리",
|
||||||
|
"newDeck": "새 덱",
|
||||||
|
"noDecksYet": "덱이 없습니다",
|
||||||
|
"loading": "로딩 중...",
|
||||||
|
"deckInfo": "ID: {id} · {totalCards}장",
|
||||||
|
"enterDeckName": "덱 이름 입력:",
|
||||||
|
"enterNewName": "새 이름 입력:",
|
||||||
|
"confirmDelete": "삭제하려면 \"{name}\" 입력:",
|
||||||
|
"public": "공개",
|
||||||
|
"private": "비공개",
|
||||||
|
"setPublic": "공개로 설정",
|
||||||
|
"setPrivate": "비공개로 설정",
|
||||||
|
"importApkg": "APKG 가져오기",
|
||||||
|
"exportApkg": "APKG 내보내기",
|
||||||
|
"clickToUpload": "클릭하여 업로드",
|
||||||
|
"apkgFilesOnly": ".apkg 파일만",
|
||||||
|
"parsing": "파싱 중...",
|
||||||
|
"foundDecks": "{count}개 덱 발견",
|
||||||
|
"back": "뒤로",
|
||||||
|
"import": "가져오기",
|
||||||
|
"importing": "가져오는 중...",
|
||||||
|
"exportSuccess": "내보내기 성공",
|
||||||
|
"goToDecks": "덱으로"
|
||||||
},
|
},
|
||||||
"folder_id": {
|
"folder_id": {
|
||||||
"unauthorized": "이 폴더의 소유자가 아닙니다",
|
"unauthorized": "이 폴더의 소유자가 아닙니다",
|
||||||
@@ -83,6 +106,77 @@
|
|||||||
"deleteFolder": "이 폴더를 삭제할 권한이 없습니다."
|
"deleteFolder": "이 폴더를 삭제할 권한이 없습니다."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"deck_id": {
|
||||||
|
"unauthorized": "이 덱의 소유자가 아닙니다",
|
||||||
|
"back": "뒤로",
|
||||||
|
"cards": "카드",
|
||||||
|
"itemsCount": "{count}개",
|
||||||
|
"memorize": "암기",
|
||||||
|
"loadingCards": "카드 불러오는 중...",
|
||||||
|
"noCards": "이 덱에 카드가 없습니다",
|
||||||
|
"card": "카드",
|
||||||
|
"addNewCard": "새 카드 추가",
|
||||||
|
"add": "추가",
|
||||||
|
"adding": "추가 중...",
|
||||||
|
"updateCard": "카드 업데이트",
|
||||||
|
"update": "업데이트",
|
||||||
|
"updating": "업데이트 중...",
|
||||||
|
"word": "단어",
|
||||||
|
"definition": "정의",
|
||||||
|
"ipa": "IPA",
|
||||||
|
"example": "예문",
|
||||||
|
"wordAndDefinitionRequired": "단어와 정의는 필수입니다",
|
||||||
|
"edit": "편집",
|
||||||
|
"delete": "삭제",
|
||||||
|
"permissionDenied": "이 작업을 수행할 권한이 없습니다",
|
||||||
|
"resetProgress": "진행 초기화",
|
||||||
|
"resetProgressTitle": "학습 진행 초기화",
|
||||||
|
"resetProgressConfirm": "이 덱의 학습 진행을 초기화하시겠습니까?",
|
||||||
|
"resetSuccess": "초기화됨",
|
||||||
|
"resetting": "초기화 중...",
|
||||||
|
"cancel": "취소",
|
||||||
|
"settings": "설정",
|
||||||
|
"settingsTitle": "덱 설정",
|
||||||
|
"newPerDay": "일일 새 카드",
|
||||||
|
"newPerDayHint": "매일 학습할 새 카드 수",
|
||||||
|
"revPerDay": "일일 복습",
|
||||||
|
"revPerDayHint": "매일 복습할 카드 수",
|
||||||
|
"save": "저장",
|
||||||
|
"saving": "저장 중...",
|
||||||
|
"settingsSaved": "설정 저장됨",
|
||||||
|
"todayNew": "오늘 새 카드",
|
||||||
|
"todayReview": "오늘 복습",
|
||||||
|
"todayLearning": "학습 중",
|
||||||
|
"error": {
|
||||||
|
"update": "업데이트 권한이 없습니다",
|
||||||
|
"delete": "삭제 권한이 없습니다",
|
||||||
|
"add": "추가 권한이 없습니다"
|
||||||
|
},
|
||||||
|
"ipaPlaceholder": "IPA 입력",
|
||||||
|
"examplePlaceholder": "예문 입력",
|
||||||
|
"wordRequired": "단어를 입력하세요",
|
||||||
|
"definitionRequired": "정의를 입력하세요",
|
||||||
|
"cardAdded": "카드 추가됨",
|
||||||
|
"cardType": "카드 유형",
|
||||||
|
"wordCard": "단어 카드",
|
||||||
|
"phraseCard": "구문 카드",
|
||||||
|
"sentenceCard": "문장 카드",
|
||||||
|
"sentence": "문장",
|
||||||
|
"sentencePlaceholder": "문장 입력",
|
||||||
|
"wordPlaceholder": "단어 입력",
|
||||||
|
"queryLang": "검색 언어",
|
||||||
|
"enterLanguageName": "언어 이름을 입력하세요",
|
||||||
|
"english": "영어",
|
||||||
|
"chinese": "중국어",
|
||||||
|
"japanese": "일본어",
|
||||||
|
"korean": "한국어",
|
||||||
|
"meanings": "의미",
|
||||||
|
"addMeaning": "의미 추가",
|
||||||
|
"partOfSpeech": "품사",
|
||||||
|
"deleteConfirm": "이 카드를 삭제하시겠습니까?",
|
||||||
|
"cardDeleted": "카드 삭제됨",
|
||||||
|
"cardUpdated": "카드 업데이트됨"
|
||||||
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "언어 배우기",
|
"title": "언어 배우기",
|
||||||
"description": "세계의 거의 모든 언어(인공어 포함)를 배우는 데 도움이 되는 매우 유용한 웹사이트입니다.",
|
"description": "세계의 거의 모든 언어(인공어 포함)를 배우는 데 도움이 되는 매우 유용한 웹사이트입니다.",
|
||||||
@@ -188,10 +282,10 @@
|
|||||||
"memorize": {
|
"memorize": {
|
||||||
"deck_selector": {
|
"deck_selector": {
|
||||||
"selectDeck": "덱 선택",
|
"selectDeck": "덱 선택",
|
||||||
"noDecks": "덱을 찾을 수 없습니다",
|
"noDecks": "덱이 없습니다",
|
||||||
"goToDecks": "덱으로 이동",
|
"goToDecks": "덱으로 이동",
|
||||||
"noCards": "카드 없음",
|
"noCards": "카드가 없습니다",
|
||||||
"new": "새 카드",
|
"new": "새로",
|
||||||
"learning": "학습 중",
|
"learning": "학습 중",
|
||||||
"review": "복습",
|
"review": "복습",
|
||||||
"due": "예정"
|
"due": "예정"
|
||||||
@@ -199,33 +293,26 @@
|
|||||||
"review": {
|
"review": {
|
||||||
"loading": "로딩 중...",
|
"loading": "로딩 중...",
|
||||||
"backToDecks": "덱으로 돌아가기",
|
"backToDecks": "덱으로 돌아가기",
|
||||||
"allDone": "완료!",
|
"allDone": "모두 완료!",
|
||||||
"allDoneDesc": "모든 복습 카드를 완료했습니다.",
|
"allDoneDesc": "오늘의 학습을 완료했습니다!",
|
||||||
"reviewedCount": "{count}장의 카드 복습함",
|
"reviewedCount": "{count}장 복습 완료",
|
||||||
"progress": "{current} / {total}",
|
"progress": "{current} / {total}",
|
||||||
"nextReview": "다음 복습",
|
"nextReview": "다음 복습",
|
||||||
"interval": "간격",
|
"interval": "간격",
|
||||||
"ease": "난이도",
|
"ease": "난이도",
|
||||||
"lapses": "망각 횟수",
|
"lapses": "실패 횟수",
|
||||||
"showAnswer": "정답 보기",
|
"showAnswer": "정답 보기",
|
||||||
|
"nextCard": "다음",
|
||||||
"again": "다시",
|
"again": "다시",
|
||||||
"hard": "어려움",
|
"restart": "다시 시작",
|
||||||
"good": "보통",
|
"orderLimited": "순서 제한",
|
||||||
"easy": "쉬움",
|
"orderInfinite": "순서 무제한",
|
||||||
"now": "지금",
|
"randomLimited": "무작위 제한",
|
||||||
"lessThanMinute": "<1분",
|
"randomInfinite": "무작위 무제한",
|
||||||
"inMinutes": "{count}분",
|
"noIpa": "IPA 없음"
|
||||||
"inHours": "{count}시간",
|
|
||||||
"inDays": "{count}일",
|
|
||||||
"inMonths": "{count}개월",
|
|
||||||
"minutes": "<1분",
|
|
||||||
"days": "{count}일",
|
|
||||||
"months": "{count}개월",
|
|
||||||
"minAbbr": "분",
|
|
||||||
"dayAbbr": "일"
|
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "이 덱에 접근할 권한이 없습니다"
|
"unauthorized": "권한이 없습니다"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navbar": {
|
"navbar": {
|
||||||
@@ -233,39 +320,55 @@
|
|||||||
"sourceCode": "GitHub",
|
"sourceCode": "GitHub",
|
||||||
"sign_in": "로그인",
|
"sign_in": "로그인",
|
||||||
"profile": "프로필",
|
"profile": "프로필",
|
||||||
"folders": "폴더",
|
"folders": "덱",
|
||||||
"explore": "탐색",
|
"explore": "탐색",
|
||||||
"favorites": "즐겨찾기",
|
"favorites": "즐겨찾기",
|
||||||
"settings": "설정"
|
"settings": "설정"
|
||||||
},
|
},
|
||||||
"ocr": {
|
"ocr": {
|
||||||
"title": "OCR 어휘 추출",
|
"title": "OCR 인식",
|
||||||
"description": "교과서 어휘표 스크린샷 only어업로드하여 단어-정의 쌍 추출",
|
"description": "이미지에서 텍스트 추출",
|
||||||
"uploadImage": "이미지 업로드",
|
"uploadImage": "이미지 업로드",
|
||||||
"dragDropHint": "이미지를 여기에 끌어다 놓거나 클릭하여 선택",
|
"dragDropHint": "드래그 앤 드롭",
|
||||||
"supportedFormats": "지원 형식: JPG, PNG, WebP",
|
"supportedFormats": "지원 형식: JPG, PNG, WEBP",
|
||||||
"selectDeck": "덱 선택",
|
"selectDeck": "덱 선택",
|
||||||
"chooseDeck": "추출된 쌍을 저장할 덱 선택",
|
"chooseDeck": "덱 선택",
|
||||||
"noDecks": "덱이 없습니다. 먼저 덱을 만드세요.",
|
"noDecks": "덱이 없습니다",
|
||||||
"languageHints": "언어 힌트 (선택사항)",
|
"languageHints": "언어 힌트",
|
||||||
"sourceLanguageHint": "소스 언어 (예: 영어)",
|
"sourceLanguageHint": "원본 언어 힌트",
|
||||||
"targetLanguageHint": "대상/번역 언어 (예: 중국어)",
|
"targetLanguageHint": "대상 언어 힌트",
|
||||||
"process": "이미지 처리",
|
"process": "처리",
|
||||||
"processing": "처리中...",
|
"processing": "처리 중...",
|
||||||
"preview": "미리보기",
|
"preview": "미리보기",
|
||||||
"extractedPairs": "추출된 쌍",
|
"extractedPairs": "추출된 쌍",
|
||||||
"word": "단어",
|
"word": "단어",
|
||||||
"definition": "정의",
|
"definition": "정의",
|
||||||
"pairsCount": "{count} 쌍 추출됨",
|
"pairsCount": "{count}쌍",
|
||||||
"savePairs": "덱에 저장",
|
"savePairs": "저장",
|
||||||
"saving": "저장中...",
|
"saving": "저장 중...",
|
||||||
"saved": "{deck}에 {count} 쌍 저장 완료",
|
"saved": "저장됨",
|
||||||
"saveFailed": "저장 실패",
|
"saveFailed": "저장 실패",
|
||||||
"noImage": "먼저 이미지를 업로드하세요",
|
"noImage": "이미지를 업로드하세요",
|
||||||
"noDeck": "덱을 선택하세요",
|
"noDeck": "덱을 선택하세요",
|
||||||
"processingFailed": "OCR 처리 실패",
|
"processingFailed": "처리 실패",
|
||||||
"tryAgain": "더 선晰的图像로 다시 시도하세요",
|
"tryAgain": "재시도",
|
||||||
"detectedLanguages": "감지됨: {source} → {target}"
|
"detectedLanguages": "감지된 언어",
|
||||||
|
"uploadSection": "이미지 업로드",
|
||||||
|
"dropOrClick": "드롭 또는 클릭",
|
||||||
|
"changeImage": "이미지 변경",
|
||||||
|
"invalidFileType": "잘못된 파일 형식",
|
||||||
|
"deckSelection": "덱 선택",
|
||||||
|
"sourceLanguagePlaceholder": "예: 영어",
|
||||||
|
"targetLanguagePlaceholder": "예: 한국어",
|
||||||
|
"processButton": "인식 시작",
|
||||||
|
"resultsPreview": "결과 미리보기",
|
||||||
|
"saveButton": "덱에 저장",
|
||||||
|
"ocrSuccess": "OCR 성공",
|
||||||
|
"ocrFailed": "OCR 실패",
|
||||||
|
"savedToDeck": "덱에 저장됨",
|
||||||
|
"noResultsToSave": "저장할 결과 없음",
|
||||||
|
"detectedSourceLanguage": "감지된 원본 언어",
|
||||||
|
"detectedTargetLanguage": "감지된 대상 언어"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"myProfile": "내 프로필",
|
"myProfile": "내 프로필",
|
||||||
@@ -306,12 +409,43 @@
|
|||||||
"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": "모두 지우기",
|
||||||
|
"language": "언어",
|
||||||
|
"customLanguage": "또는 언어 입력...",
|
||||||
|
"languages": {
|
||||||
|
"auto": "자동",
|
||||||
|
"chinese": "중국어",
|
||||||
|
"english": "영어",
|
||||||
|
"japanese": "일본어",
|
||||||
|
"korean": "한국어",
|
||||||
|
"french": "프랑스어",
|
||||||
|
"german": "독일어",
|
||||||
|
"italian": "이탈리아어",
|
||||||
|
"spanish": "스페인어",
|
||||||
|
"portuguese": "포르투갈어",
|
||||||
|
"russian": "러시아어"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"detectLanguage": "언어 감지",
|
"detectLanguage": "언어 감지",
|
||||||
@@ -344,7 +478,20 @@
|
|||||||
"success": "텍스트 쌍이 폴더에 추가됨",
|
"success": "텍스트 쌍이 폴더에 추가됨",
|
||||||
"error": "폴더에 텍스트 쌍 추가 실패"
|
"error": "폴더에 텍스트 쌍 추가 실패"
|
||||||
},
|
},
|
||||||
"autoSave": "자동 저장"
|
"autoSave": "자동 저장",
|
||||||
|
"customLanguage": "또는 언어 입력...",
|
||||||
|
"pleaseLogin": "카드를 저장하려면 로그인하세요",
|
||||||
|
"pleaseCreateDeck": "먼저 덱을 만드세요",
|
||||||
|
"noTranslationToSave": "저장할 번역이 없습니다",
|
||||||
|
"noDeckSelected": "덱이 선택되지 않았습니다",
|
||||||
|
"saveAsCard": "카드로 저장",
|
||||||
|
"selectDeck": "덱 선택",
|
||||||
|
"front": "앞면",
|
||||||
|
"back": "뒷면",
|
||||||
|
"cancel": "취소",
|
||||||
|
"save": "저장",
|
||||||
|
"savedToDeck": "{deckName}에 카드 저장됨",
|
||||||
|
"saveFailed": "카드 저장 실패"
|
||||||
},
|
},
|
||||||
"dictionary": {
|
"dictionary": {
|
||||||
"title": "사전",
|
"title": "사전",
|
||||||
@@ -389,7 +536,9 @@
|
|||||||
"unfavorite": "즐겨찾기 해제",
|
"unfavorite": "즐겨찾기 해제",
|
||||||
"pleaseLogin": "먼저 로그인해주세요",
|
"pleaseLogin": "먼저 로그인해주세요",
|
||||||
"sortByFavorites": "즐겨찾기순 정렬",
|
"sortByFavorites": "즐겨찾기순 정렬",
|
||||||
"sortByFavoritesActive": "즐겨찾기순 정렬 해제"
|
"sortByFavoritesActive": "즐겨찾기순 정렬 해제",
|
||||||
|
"noDecks": "공개 덱 없음",
|
||||||
|
"deckInfo": "{userName} · {totalCards}장"
|
||||||
},
|
},
|
||||||
"exploreDetail": {
|
"exploreDetail": {
|
||||||
"title": "폴더 상세",
|
"title": "폴더 상세",
|
||||||
@@ -403,7 +552,8 @@
|
|||||||
"unfavorite": "즐겨찾기 해제",
|
"unfavorite": "즐겨찾기 해제",
|
||||||
"favorited": "즐겨찾기됨",
|
"favorited": "즐겨찾기됨",
|
||||||
"unfavorited": "즐겨찾기 해제됨",
|
"unfavorited": "즐겨찾기 해제됨",
|
||||||
"pleaseLogin": "먼저 로그인해주세요"
|
"pleaseLogin": "먼저 로그인해주세요",
|
||||||
|
"totalCards": "총 {count}장"
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "내 즐겨찾기",
|
"title": "내 즐겨찾기",
|
||||||
@@ -448,6 +598,25 @@
|
|||||||
"createdAt": "생성일",
|
"createdAt": "생성일",
|
||||||
"actions": "작업",
|
"actions": "작업",
|
||||||
"view": "보기"
|
"view": "보기"
|
||||||
|
},
|
||||||
|
"joined": "가입일",
|
||||||
|
"decks": {
|
||||||
|
"title": "내 덱",
|
||||||
|
"noDecks": "덱이 없습니다",
|
||||||
|
"deckName": "덱 이름",
|
||||||
|
"totalCards": "총 카드",
|
||||||
|
"createdAt": "생성일",
|
||||||
|
"actions": "작업",
|
||||||
|
"view": "보기"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"follow": {
|
||||||
|
"follow": "팔로우",
|
||||||
|
"following": "팔로잉",
|
||||||
|
"followers": "팔로워",
|
||||||
|
"followersOf": "{username}의 팔로워",
|
||||||
|
"followingOf": "{username}의 팔로잉",
|
||||||
|
"noFollowers": "아직 팔로워가 없습니다",
|
||||||
|
"noFollowing": "아직 팔로우하는 사람이 없습니다"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,30 @@
|
|||||||
"totalCards": "جەمئىي كارتا",
|
"totalCards": "جەمئىي كارتا",
|
||||||
"createdAt": "قۇرۇلغان ۋاقتى",
|
"createdAt": "قۇرۇلغان ۋاقتى",
|
||||||
"actions": "مەشغۇلاتلار",
|
"actions": "مەشغۇلاتلار",
|
||||||
"view": "كۆرۈش"
|
"view": "كۆرۈش",
|
||||||
|
"subtitle": "دېكلەرنى باشقۇرۇڭ",
|
||||||
|
"newDeck": "يېڭى دېك",
|
||||||
|
"noDecksYet": "دېك يوق",
|
||||||
|
"loading": "يۈكلىنىۋاتىدۇ...",
|
||||||
|
"deckInfo": "ID: {id} · {totalCards} كارتا",
|
||||||
|
"enterDeckName": "دېك ئاتى:",
|
||||||
|
"enterNewName": "يېڭى ئات:",
|
||||||
|
"confirmDelete": "ئۆچۈرۈش: \"{name}\"",
|
||||||
|
"public": "ئاممىۋىي",
|
||||||
|
"private": "شەخسىي",
|
||||||
|
"setPublic": "ئاممىۋىي قىلىش",
|
||||||
|
"setPrivate": "شەخسىي قىلىش",
|
||||||
|
"importApkg": "APKG ئەكىرىش",
|
||||||
|
"exportApkg": "APKG چىقىرىش",
|
||||||
|
"clickToUpload": "چېكىپ يۈكلەش",
|
||||||
|
"apkgFilesOnly": ".apkg ھۆججىتىلا",
|
||||||
|
"parsing": "تەھلىل قىلىنىۋاتىدۇ...",
|
||||||
|
"foundDecks": "{count} دېك تېپىلدى",
|
||||||
|
"back": "قايتىش",
|
||||||
|
"import": "ئەكىرىش",
|
||||||
|
"importing": "ئەكىرىلىۋاتىدۇ...",
|
||||||
|
"exportSuccess": "چىقىرىش مۇۋەپپەقىيەتلىك",
|
||||||
|
"goToDecks": "دېكلەرگە بېرىش"
|
||||||
},
|
},
|
||||||
"folder_id": {
|
"folder_id": {
|
||||||
"unauthorized": "بۇ قىسقۇچنىڭ ئىگىسى ئەمەسسىز",
|
"unauthorized": "بۇ قىسقۇچنىڭ ئىگىسى ئەمەسسىز",
|
||||||
@@ -83,6 +106,77 @@
|
|||||||
"deleteFolder": "بۇ قىسقۇچنى ئۆچۈرۈش ھوقۇقىڭىز يوق."
|
"deleteFolder": "بۇ قىسقۇچنى ئۆچۈرۈش ھوقۇقىڭىز يوق."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"deck_id": {
|
||||||
|
"unauthorized": "بۇ دېكنىڭ ئىگىسى ئەمەس",
|
||||||
|
"back": "قايتىش",
|
||||||
|
"cards": "كارتلار",
|
||||||
|
"itemsCount": "{count} تۈر",
|
||||||
|
"memorize": "يادلاش",
|
||||||
|
"loadingCards": "كارتلار يۈكلىنىۋاتىدۇ...",
|
||||||
|
"noCards": "بۇ دېكتا كارت يوق",
|
||||||
|
"card": "كارتا",
|
||||||
|
"addNewCard": "يېڭى كارتا قوشۇش",
|
||||||
|
"add": "قوشۇش",
|
||||||
|
"adding": "قوشۇلىۋاتىدۇ...",
|
||||||
|
"updateCard": "كارتىنى يېڭىلاش",
|
||||||
|
"update": "يېڭىلاش",
|
||||||
|
"updating": "يېڭىلىنىۋاتىدۇ...",
|
||||||
|
"word": "سۆز",
|
||||||
|
"definition": "ئېنىقلىما",
|
||||||
|
"ipa": "IPA",
|
||||||
|
"example": "مىسال",
|
||||||
|
"wordAndDefinitionRequired": "سۆز ۋە ئېنىقلىما زۆرۈر",
|
||||||
|
"edit": "تەھرىرلەش",
|
||||||
|
"delete": "ئۆچۈرۈش",
|
||||||
|
"permissionDenied": "بۇ مەشغۇلاتنى ئېلىپ بېرىش ھوقۇقىڭىز يوق",
|
||||||
|
"resetProgress": "ئىلگىرىلەشنى ئەسلىگە قايتۇرۇش",
|
||||||
|
"resetProgressTitle": "ئۆگىنىش ئىلگىرىلەشىنى ئەسلىگە قايتۇرۇش",
|
||||||
|
"resetProgressConfirm": "ئىلگىرىلەشنى ئەسلىگە قايتۇرامسىز؟",
|
||||||
|
"resetSuccess": "ئەسلىگە قايتۇرۇلدى",
|
||||||
|
"resetting": "ئەسلىگە قايتۇرۇۋاتىدۇ...",
|
||||||
|
"cancel": "بىكار قىلىش",
|
||||||
|
"settings": "تەڭشەكلەر",
|
||||||
|
"settingsTitle": "دېك تەڭشەكلىرى",
|
||||||
|
"newPerDay": "كۈندىلىك يېڭى",
|
||||||
|
"newPerDayHint": "كۈندە يېڭى كارتا سانى",
|
||||||
|
"revPerDay": "كۈندىلىك تەكرار",
|
||||||
|
"revPerDayHint": "كۈندە تەكرار سانى",
|
||||||
|
"save": "ساقلاش",
|
||||||
|
"saving": "ساقلاۋاتىدۇ...",
|
||||||
|
"settingsSaved": "تەڭشەكلەر ساقلاندى",
|
||||||
|
"todayNew": "بۈگۈنكى يېڭى",
|
||||||
|
"todayReview": "بۈگۈنكى تەكرار",
|
||||||
|
"todayLearning": "ئۆگىنىۋاتىدۇ",
|
||||||
|
"error": {
|
||||||
|
"update": "يېڭىلاش ھوقۇقى يوق",
|
||||||
|
"delete": "ئۆچۈرۈش ھوقۇقى يوق",
|
||||||
|
"add": "قوشۇش ھوقۇقى يوق"
|
||||||
|
},
|
||||||
|
"ipaPlaceholder": "IPA كىرگۈزۈڭ",
|
||||||
|
"examplePlaceholder": "مىسال كىرگۈزۈڭ",
|
||||||
|
"wordRequired": "سۆز كىرگۈزۈڭ",
|
||||||
|
"definitionRequired": "ئېنىقلىما كىرگۈزۈڭ",
|
||||||
|
"cardAdded": "كارتا قوشۇلدى",
|
||||||
|
"cardType": "كارتا تىپى",
|
||||||
|
"wordCard": "سۆز كارتىسى",
|
||||||
|
"phraseCard": "جۈملە كارتىسى",
|
||||||
|
"sentenceCard": "جۈملە كارتىسى",
|
||||||
|
"sentence": "جۈملە",
|
||||||
|
"sentencePlaceholder": "جۈملە كىرگۈزۈڭ",
|
||||||
|
"wordPlaceholder": "سۆز كىرگۈزۈڭ",
|
||||||
|
"queryLang": "سۈرۈشتۈرۈش تىلى",
|
||||||
|
"enterLanguageName": "تىل ئاتىنى كىرگۈزۈڭ",
|
||||||
|
"english": "ئىنگىلىزچە",
|
||||||
|
"chinese": "خەنزۇچە",
|
||||||
|
"japanese": "ياپونچە",
|
||||||
|
"korean": "كورىيەچە",
|
||||||
|
"meanings": "مەنىلىرى",
|
||||||
|
"addMeaning": "مەنا قوشۇش",
|
||||||
|
"partOfSpeech": "سۆز بۆلىكى",
|
||||||
|
"deleteConfirm": "بۇ كارتىنى ئۆچۈرەمسىز؟",
|
||||||
|
"cardDeleted": "كارتا ئۆچۈرۈلدى",
|
||||||
|
"cardUpdated": "كارتا يېڭىلاندى"
|
||||||
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "تىل ئۆگىنىش",
|
"title": "تىل ئۆگىنىش",
|
||||||
"description": "بۇ دۇنيادىكى almost ھەر بىر تىلنى، جۈملىدىن سۈنئىي تىللارنى ئۆگىنىشىڭىزگە ياردەم بېرىدىغان ئىنتايىن قوللىنىشلىق تور بېكەت.",
|
"description": "بۇ دۇنيادىكى almost ھەر بىر تىلنى، جۈملىدىن سۈنئىي تىللارنى ئۆگىنىشىڭىزگە ياردەم بېرىدىغان ئىنتايىن قوللىنىشلىق تور بېكەت.",
|
||||||
@@ -187,45 +281,63 @@
|
|||||||
},
|
},
|
||||||
"memorize": {
|
"memorize": {
|
||||||
"deck_selector": {
|
"deck_selector": {
|
||||||
"selectDeck": "بىر دېك تاللاڭ",
|
"selectDeck": "دېك تاللاش",
|
||||||
"noDecks": "دېك تېپىلمىدى",
|
"noDecks": "دېك يوق",
|
||||||
"goToDecks": "دېكلارغا بېرىڭ",
|
"goToDecks": "دېكلەرگە بار",
|
||||||
"noCards": "كارتا يوق",
|
"noCards": "كارتا يوق",
|
||||||
"new": "يېڭى",
|
"new": "يېڭى",
|
||||||
"learning": "ئۆگىنىۋاتىدۇ",
|
"learning": "ئۆگىنىش",
|
||||||
"review": "تەكرار",
|
"review": "تەكرار",
|
||||||
"due": "ۋاقتى كەلدى"
|
"due": "ۋاقتى كەلدى"
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"loading": "يۈكلىنىۋاتىدۇ...",
|
"loading": "يۈكلىنىۋاتىدۇ...",
|
||||||
"backToDecks": "دېكلارغا قايتىڭ",
|
"backToDecks": "دېكلەرگە قايتىش",
|
||||||
"allDone": "تامام!",
|
"allDone": "ھەممىسى تامام!",
|
||||||
"allDoneDesc": "بارلىق تەكرارلاش كارتلىرى تاماملاندى.",
|
"allDoneDesc": "بۈگۈنكى ئۆگىنىش تامام!",
|
||||||
"reviewedCount": "{count} كارتا تەكرارلاندى",
|
"reviewedCount": "{count} كارتا تەكرارلاندى",
|
||||||
"progress": "{current} / {total}",
|
"progress": "{current} / {total}",
|
||||||
"nextReview": "كېيىنكى تەكرار",
|
"nextReview": "كېيىنكى تەكرار",
|
||||||
"interval": "ئارىلىق",
|
"interval": "ئارىلىق",
|
||||||
"ease": "ئاسانلىق",
|
"ease": "قىيىنلىق",
|
||||||
"lapses": "ئۇنتۇش سانى",
|
"lapses": "خاتالىق",
|
||||||
"showAnswer": "جاۋابنى كۆرسەت",
|
"showAnswer": "جاۋابنى كۆرسەت",
|
||||||
|
"nextCard": "كېيىنكى",
|
||||||
"again": "يەنە",
|
"again": "يەنە",
|
||||||
"hard": "قىيىن",
|
"hard": "قىيىن",
|
||||||
"good": "ياخشى",
|
"good": "ياخشى",
|
||||||
"easy": "ئاسان",
|
"easy": "ئاسان",
|
||||||
"now": "ھازىر",
|
"now": "ھازىر",
|
||||||
"lessThanMinute": "<1 مىنۇت",
|
"lessThanMinute": "1 مىنۇتتىن ئاز",
|
||||||
"inMinutes": "{count} مىنۇت",
|
"inMinutes": "{n} مىنۇتتىن كېيىن",
|
||||||
"inHours": "{count} سائەت",
|
"inHours": "{n} سائەتتىن كېيىن",
|
||||||
"inDays": "{count} كۈن",
|
"inDays": "{n} كۈندىن كېيىن",
|
||||||
"inMonths": "{count} ئاي",
|
"inMonths": "{n} ئايدىن كېيىن",
|
||||||
"minutes": "<1 مىنۇت",
|
"minutes": "مىنۇت",
|
||||||
"days": "{count} كۈن",
|
"days": "كۈن",
|
||||||
"months": "{count} ئاي",
|
"months": "ئاي",
|
||||||
"minAbbr": "م",
|
"minAbbr": "مىن",
|
||||||
"dayAbbr": "ك"
|
"dayAbbr": "كۈن",
|
||||||
|
"cardTypeNew": "يېڭى",
|
||||||
|
"cardTypeLearning": "ئۆگىنىش",
|
||||||
|
"cardTypeReview": "تەكرار",
|
||||||
|
"cardTypeRelearning": "قايتا ئۆگىنىش",
|
||||||
|
"reverse": "ئەكسىچە",
|
||||||
|
"dictation": "ئىملا",
|
||||||
|
"clickToPlay": "چېكىپ قويۇش",
|
||||||
|
"yourAnswer": "جاۋابىڭىز",
|
||||||
|
"typeWhatYouHear": "ئاڭلىغىنىڭىزنى يېزىڭ",
|
||||||
|
"correct": "توغرا!",
|
||||||
|
"incorrect": "خاتا",
|
||||||
|
"restart": "قايتا باشلا",
|
||||||
|
"orderLimited": "تەرتىپلى چەكلەنگەن",
|
||||||
|
"orderInfinite": "تەرتىپلى چەكسىز",
|
||||||
|
"randomLimited": "ئىختىيارى چەكلەنگەن",
|
||||||
|
"randomInfinite": "ئىختىيارى چەكسىز",
|
||||||
|
"noIpa": "IPA يوق"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "بۇ دېكنى زىيارەت قىلىش ھوقۇقىڭىز يوق"
|
"unauthorized": "ھوقۇقسىز"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navbar": {
|
"navbar": {
|
||||||
@@ -233,39 +345,55 @@
|
|||||||
"sourceCode": "GitHub",
|
"sourceCode": "GitHub",
|
||||||
"sign_in": "كىرىش",
|
"sign_in": "كىرىش",
|
||||||
"profile": "شەخسىي ئۇچۇر",
|
"profile": "شەخسىي ئۇچۇر",
|
||||||
"folders": "قىسقۇچلار",
|
"folders": "دېكلار",
|
||||||
"explore": "ئىزدىنىش",
|
"explore": "ئىزدىنىش",
|
||||||
"favorites": "يىغىپ ساقلاش",
|
"favorites": "يىغىپ ساقلاش",
|
||||||
"settings": "تەڭشەكلەر"
|
"settings": "تەڭشەكلەر"
|
||||||
},
|
},
|
||||||
"ocr": {
|
"ocr": {
|
||||||
"title": "OCR سۆز ئاستىرىش",
|
"title": "OCR تونۇش",
|
||||||
"description": "دەرىسلىك كىتابىدىكى سۆز جەدۋىلى سۈرەتلىرىنى يۈكلەپ سۆز-مەنا جۈپلىرىنى ئاستىرىڭ",
|
"description": "رەسىمدىن تېكىست ئېلىش",
|
||||||
"uploadImage": "سۈرەت يۈكلەش",
|
"uploadImage": "رەسىم يۈكلەش",
|
||||||
"dragDropHint": "سۈرەتنى بۇ يەرگە سۆرەڭ ياكى چېكىپ تاللاڭ",
|
"dragDropHint": "سۆرەپ تاشلاش",
|
||||||
"supportedFormats": "قوللايدىغان فورماتلار: JPG، PNG، WebP",
|
"supportedFormats": "قوللايدىغان فورمات: JPG, PNG, WEBP",
|
||||||
"selectDeck": "دېك تاللاش",
|
"selectDeck": "دېك تاللاش",
|
||||||
"chooseDeck": "ئاستىرىلغان جۈپلەرنى ساقلاش ئۈچۈن دېك تاللاڭ",
|
"chooseDeck": "دېك تاللاڭ",
|
||||||
"noDecks": "دېك يوق. ئاۋۋال دېك قۇرۇڭ.",
|
"noDecks": "دېك يوق",
|
||||||
"languageHints": "تىل ئۇچۇرلىرى (ئىختىيارىي)",
|
"languageHints": "تىل بېشارىتى",
|
||||||
"sourceLanguageHint": "مەنبە تىلى (مىسال: ئىنگىلىزچە)",
|
"sourceLanguageHint": "مەنبە تىلى",
|
||||||
"targetLanguageHint": "نىشان/تەرجىمە تىلى (مىسال: خەنزۇچە)",
|
"targetLanguageHint": "نىشان تىلى",
|
||||||
"process": "سۈرەتنى بىر تەرەپ قىلىش",
|
"process": "بىر تەرەپ قىلىش",
|
||||||
"processing": "بىر تەرەپ قىلىۋاتىدۇ...",
|
"processing": "بىر تەرەپ قىلىنىۋاتىدۇ...",
|
||||||
"preview": "ئالدىن كۆرۈش",
|
"preview": "ئالدىن كۆرۈش",
|
||||||
"extractedPairs": "ئاستىرىلغان جۈپلەر",
|
"extractedPairs": "ئېلىنغان جۈپلەر",
|
||||||
"word": "سۆز",
|
"word": "سۆز",
|
||||||
"definition": "مەنا",
|
"definition": "ئېنىقلىما",
|
||||||
"pairsCount": "{count} جۈپ ئاستىرىلدى",
|
"pairsCount": "{count} جۈپ",
|
||||||
"savePairs": "دېككە ساقلاش",
|
"savePairs": "ساقلاش",
|
||||||
"saving": "ساقلاۋاتىدۇ...",
|
"saving": "ساقلاۋاتىدۇ...",
|
||||||
"saved": "{deck} غا {count} جۈپ ساقلاندى",
|
"saved": "ساقلاندى",
|
||||||
"saveFailed": "ساقلاش مەغلۇپ بولدى",
|
"saveFailed": "ساقلاش مەغلۇپ بولدى",
|
||||||
"noImage": "ئاۋۋال سۈرەت يۈكلەڭ",
|
"noImage": "رەسىم يۈكلەڭ",
|
||||||
"noDeck": "دېك تاللاڭ",
|
"noDeck": "دېك تاللاڭ",
|
||||||
"processingFailed": "OCR بىر تەرەپ قىلىش مەغلۇپ بولدى",
|
"processingFailed": "بىر تەرەپ قىلىش مەغلۇپ بولدى",
|
||||||
"tryAgain": "تېخىمۇ ئېنىق سۈرەت بىلەن قايتا سىناڭ",
|
"tryAgain": "قايتا سىناڭ",
|
||||||
"detectedLanguages": "بايقالدى: {source} → {target}"
|
"detectedLanguages": "تونۇلغان تىللار",
|
||||||
|
"uploadSection": "رەسىم يۈكلەش",
|
||||||
|
"dropOrClick": "تاشلاش ياكى چېكىش",
|
||||||
|
"changeImage": "رەسىم ئالماشتۇرۇش",
|
||||||
|
"invalidFileType": "ئىناۋەتسىز فايىل تىپى",
|
||||||
|
"deckSelection": "دېك تاللاش",
|
||||||
|
"sourceLanguagePlaceholder": "مەسىلەن: ئىنگلىزچە",
|
||||||
|
"targetLanguagePlaceholder": "مەسىلەن: ئۇيغۇرچە",
|
||||||
|
"processButton": "تونۇشنى باشلاش",
|
||||||
|
"resultsPreview": "نەتىجە ئالدىن كۆرۈش",
|
||||||
|
"saveButton": "دېككە ساقلاش",
|
||||||
|
"ocrSuccess": "OCR مۇۋەپپەقىيەتلىك",
|
||||||
|
"ocrFailed": "OCR مەغلۇپ بولدى",
|
||||||
|
"savedToDeck": "دېككە ساقلاندى",
|
||||||
|
"noResultsToSave": "نەتىجە يوق",
|
||||||
|
"detectedSourceLanguage": "تونۇلغان مەنبە تىلى",
|
||||||
|
"detectedTargetLanguage": "تونۇلغان نىشان تىلى"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"myProfile": "شەخسىي ئۇچۇرۇم",
|
"myProfile": "شەخسىي ئۇچۇرۇم",
|
||||||
@@ -306,12 +434,43 @@
|
|||||||
"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": "ھەممىنى تازىلاش",
|
||||||
|
"language": "تىل",
|
||||||
|
"customLanguage": "ياكى تىل كىرگۈزۈڭ...",
|
||||||
|
"languages": {
|
||||||
|
"auto": "ئاپتوماتىك",
|
||||||
|
"chinese": "خەنزۇچە",
|
||||||
|
"english": "ئىنگلىزچە",
|
||||||
|
"japanese": "ياپونچە",
|
||||||
|
"korean": "كورېيەچە",
|
||||||
|
"french": "فرانسۇزچە",
|
||||||
|
"german": "گېرمانچە",
|
||||||
|
"italian": "ئىتاليانچە",
|
||||||
|
"spanish": "ئىسپانچە",
|
||||||
|
"portuguese": "پورتۇگالچە",
|
||||||
|
"russian": "رۇسچە"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"detectLanguage": "تىلنى تونۇش",
|
"detectLanguage": "تىلنى تونۇش",
|
||||||
@@ -344,7 +503,20 @@
|
|||||||
"success": "تېكىست جۈپى قىسقۇچقا قوشۇلدى",
|
"success": "تېكىست جۈپى قىسقۇچقا قوشۇلدى",
|
||||||
"error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇپ بولدى"
|
"error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇپ بولدى"
|
||||||
},
|
},
|
||||||
"autoSave": "ئاپتوماتىك ساقلاش"
|
"autoSave": "ئاپتوماتىك ساقلاش",
|
||||||
|
"customLanguage": "ياكى تىل تىل كىرۇڭ...",
|
||||||
|
"pleaseLogin": "كارتا ساقلاش ئۈچۈن كىرىڭ",
|
||||||
|
"pleaseCreateDeck": "ئاۋۋال دېك قۇرۇڭ",
|
||||||
|
"noTranslationToSave": "ساقلايدىغان تەرجىمە يوق",
|
||||||
|
"noDeckSelected": "دېك تاللانمىدى",
|
||||||
|
"saveAsCard": "كارتا ساقلاش",
|
||||||
|
"selectDeck": "دېك تاللاش",
|
||||||
|
"front": "ئالدى",
|
||||||
|
"back": "كەينى",
|
||||||
|
"cancel": "بىكار قىلىش",
|
||||||
|
"save": "ساقلاش",
|
||||||
|
"savedToDeck": "{deckName} غا ساقلاندى",
|
||||||
|
"saveFailed": "ساقلاش مەغلۇپ"
|
||||||
},
|
},
|
||||||
"dictionary": {
|
"dictionary": {
|
||||||
"title": "لۇغەت",
|
"title": "لۇغەت",
|
||||||
@@ -389,7 +561,9 @@
|
|||||||
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
|
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
|
||||||
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ",
|
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ",
|
||||||
"sortByFavorites": "يىغىپ ساقلاش بويىچە تەرتىپلەش",
|
"sortByFavorites": "يىغىپ ساقلاش بويىچە تەرتىپلەش",
|
||||||
"sortByFavoritesActive": "يىغىپ ساقلاش بويىچە تەرتىپلەشنى بىكار قىلىش"
|
"sortByFavoritesActive": "يىغىپ ساقلاش بويىچە تەرتىپلەشنى بىكار قىلىش",
|
||||||
|
"noDecks": "ئاممىۋىي دېك يوق",
|
||||||
|
"deckInfo": "{userName} · {totalCards} كارتا"
|
||||||
},
|
},
|
||||||
"exploreDetail": {
|
"exploreDetail": {
|
||||||
"title": "قىسقۇچ تەپسىلاتلىرى",
|
"title": "قىسقۇچ تەپسىلاتلىرى",
|
||||||
@@ -403,7 +577,8 @@
|
|||||||
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
|
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
|
||||||
"favorited": "يىغىپ ساقلاندى",
|
"favorited": "يىغىپ ساقلاندى",
|
||||||
"unfavorited": "يىغىپ ساقلاش بىكار قىلىندى",
|
"unfavorited": "يىغىپ ساقلاش بىكار قىلىندى",
|
||||||
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ"
|
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ",
|
||||||
|
"totalCards": "{count} كارتا"
|
||||||
},
|
},
|
||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "يىغىپ ساقلىغانلىرىم",
|
"title": "يىغىپ ساقلىغانلىرىم",
|
||||||
@@ -448,6 +623,16 @@
|
|||||||
"createdAt": "قۇرۇلغان ۋاقتى",
|
"createdAt": "قۇرۇلغان ۋاقتى",
|
||||||
"actions": "مەشغۇلاتلار",
|
"actions": "مەشغۇلاتلار",
|
||||||
"view": "كۆرۈش"
|
"view": "كۆرۈش"
|
||||||
}
|
},
|
||||||
|
"joined": "قوشۇلدى"
|
||||||
|
},
|
||||||
|
"follow": {
|
||||||
|
"follow": "ئەگىشىش",
|
||||||
|
"following": "ئەگىشىۋاتىدۇ",
|
||||||
|
"followers": "ئەگەشكۈچىلەر",
|
||||||
|
"followersOf": "{username} نىڭ ئەگەشكۈچىلىرى",
|
||||||
|
"followingOf": "{username} نىڭ ئەگىشىدىغانلىرى",
|
||||||
|
"noFollowers": "تېخى ئەگەشكۈچى يوق",
|
||||||
|
"noFollowing": "تېخى ئەگىشىدىغان ئادەم يوق"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,6 +74,77 @@
|
|||||||
"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": "进度已重置",
|
||||||
|
"resetting": "重置中...",
|
||||||
|
"cancel": "取消",
|
||||||
|
"settings": "设置",
|
||||||
|
"settingsTitle": "卡组设置",
|
||||||
|
"newPerDay": "每日新卡",
|
||||||
|
"newPerDayHint": "每天学习的新卡片数量",
|
||||||
|
"revPerDay": "每日复习",
|
||||||
|
"revPerDayHint": "每天复习的卡片数量",
|
||||||
|
"save": "保存",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"settingsSaved": "设置已保存",
|
||||||
|
"todayNew": "今日新卡",
|
||||||
|
"todayReview": "今日复习",
|
||||||
|
"todayLearning": "学习中",
|
||||||
|
"error": {
|
||||||
|
"update": "您没有权限更新此卡片",
|
||||||
|
"delete": "您没有权限删除此卡片",
|
||||||
|
"add": "您没有权限向此牌组添加卡片"
|
||||||
|
},
|
||||||
|
"ipaPlaceholder": "输入IPA音标",
|
||||||
|
"examplePlaceholder": "输入例句",
|
||||||
|
"wordRequired": "请输入单词",
|
||||||
|
"definitionRequired": "请输入至少一个释义",
|
||||||
|
"cardAdded": "卡片已添加",
|
||||||
|
"cardType": "卡片类型",
|
||||||
|
"wordCard": "单词卡",
|
||||||
|
"phraseCard": "短语卡",
|
||||||
|
"sentenceCard": "句子卡",
|
||||||
|
"sentence": "句子",
|
||||||
|
"sentencePlaceholder": "输入句子",
|
||||||
|
"wordPlaceholder": "输入单词",
|
||||||
|
"queryLang": "查询语言",
|
||||||
|
"enterLanguageName": "请输入语言名称",
|
||||||
|
"english": "英语",
|
||||||
|
"chinese": "中文",
|
||||||
|
"japanese": "日语",
|
||||||
|
"korean": "韩语",
|
||||||
|
"meanings": "释义",
|
||||||
|
"addMeaning": "添加释义",
|
||||||
|
"partOfSpeech": "词性",
|
||||||
|
"deleteConfirm": "确定删除这张卡片吗?",
|
||||||
|
"cardDeleted": "卡片已删除",
|
||||||
|
"cardUpdated": "卡片已更新"
|
||||||
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "学语言",
|
"title": "学语言",
|
||||||
"description": "这里是一个非常有用的网站,可以帮助您学习世界上几乎每一种语言,包括人造语言。",
|
"description": "这里是一个非常有用的网站,可以帮助您学习世界上几乎每一种语言,包括人造语言。",
|
||||||
@@ -199,6 +270,7 @@
|
|||||||
"ease": "难度系数",
|
"ease": "难度系数",
|
||||||
"lapses": "遗忘次数",
|
"lapses": "遗忘次数",
|
||||||
"showAnswer": "显示答案",
|
"showAnswer": "显示答案",
|
||||||
|
"nextCard": "下一张",
|
||||||
"again": "重来",
|
"again": "重来",
|
||||||
"hard": "困难",
|
"hard": "困难",
|
||||||
"good": "良好",
|
"good": "良好",
|
||||||
@@ -213,7 +285,24 @@
|
|||||||
"days": "{count} 天",
|
"days": "{count} 天",
|
||||||
"months": "{count} 个月",
|
"months": "{count} 个月",
|
||||||
"minAbbr": "分",
|
"minAbbr": "分",
|
||||||
"dayAbbr": "天"
|
"dayAbbr": "天",
|
||||||
|
"cardTypeNew": "新卡片",
|
||||||
|
"cardTypeLearning": "学习中",
|
||||||
|
"cardTypeReview": "复习中",
|
||||||
|
"cardTypeRelearning": "重学中",
|
||||||
|
"reverse": "反向",
|
||||||
|
"dictation": "听写",
|
||||||
|
"clickToPlay": "点击播放",
|
||||||
|
"yourAnswer": "你的答案",
|
||||||
|
"typeWhatYouHear": "输入你听到的内容",
|
||||||
|
"correct": "正确",
|
||||||
|
"incorrect": "错误",
|
||||||
|
"restart": "重新开始",
|
||||||
|
"orderLimited": "顺序有限",
|
||||||
|
"orderInfinite": "顺序无限",
|
||||||
|
"randomLimited": "随机有限",
|
||||||
|
"randomInfinite": "随机无限",
|
||||||
|
"noIpa": "无音标"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "您无权访问该牌组"
|
"unauthorized": "您无权访问该牌组"
|
||||||
@@ -224,53 +313,55 @@
|
|||||||
"sourceCode": "源码",
|
"sourceCode": "源码",
|
||||||
"sign_in": "登录",
|
"sign_in": "登录",
|
||||||
"profile": "个人资料",
|
"profile": "个人资料",
|
||||||
"folders": "文件夹",
|
"folders": "牌组",
|
||||||
"explore": "探索",
|
"explore": "探索",
|
||||||
"favorites": "收藏",
|
"favorites": "收藏",
|
||||||
"settings": "设置"
|
"settings": "设置"
|
||||||
},
|
},
|
||||||
"ocr": {
|
"ocr": {
|
||||||
"title": "OCR 词汇提取",
|
"title": "OCR文字识别",
|
||||||
"description": "上传教材词汇表截图,提取单词-释义对",
|
"description": "从图片中提取文字创建学习卡片",
|
||||||
"uploadSection": "上传图片",
|
"uploadSection": "上传图片",
|
||||||
"uploadImage": "上传图片",
|
"uploadImage": "上传图片",
|
||||||
"dragDropHint": "拖放图片到此处,或点击选择",
|
"dragDropHint": "拖放或点击上传",
|
||||||
"dropOrClick": "拖放图片到此处,或点击选择",
|
"dropOrClick": "拖放或点击",
|
||||||
"changeImage": "点击更换图片",
|
"changeImage": "更换图片",
|
||||||
"supportedFormats": "支持格式:JPG、PNG、WebP",
|
"supportedFormats": "支持格式:JPG, PNG, WEBP",
|
||||||
"deckSelection": "选择牌组",
|
"invalidFileType": "无效的文件类型",
|
||||||
"selectDeck": "选择牌组",
|
"deckSelection": "选择卡组",
|
||||||
"chooseDeck": "选择保存提取词汇的牌组",
|
"selectDeck": "选择卡组",
|
||||||
"noDecks": "暂无牌组,请先创建牌组",
|
"chooseDeck": "选择卡组保存",
|
||||||
"languageHints": "语言提示(可选)",
|
"noDecks": "没有可用的卡组",
|
||||||
"sourceLanguageHint": "源语言(如:英语)",
|
"languageHints": "语言提示",
|
||||||
"targetLanguageHint": "目标/翻译语言(如:中文)",
|
"sourceLanguageHint": "源语言提示",
|
||||||
"sourceLanguagePlaceholder": "源语言(如:英语)",
|
"targetLanguageHint": "目标语言提示",
|
||||||
"targetLanguagePlaceholder": "目标/翻译语言(如:中文)",
|
"sourceLanguagePlaceholder": "如:英语",
|
||||||
"process": "处理图片",
|
"targetLanguagePlaceholder": "如:中文",
|
||||||
"processButton": "处理图片",
|
"process": "处理",
|
||||||
|
"processButton": "开始识别",
|
||||||
"processing": "处理中...",
|
"processing": "处理中...",
|
||||||
"preview": "预览",
|
"preview": "预览",
|
||||||
"resultsPreview": "结果预览",
|
"resultsPreview": "结果预览",
|
||||||
"extractedPairs": "已提取 {count} 个词汇对",
|
"extractedPairs": "提取的语言对",
|
||||||
"word": "单词",
|
"word": "单词",
|
||||||
"definition": "释义",
|
"definition": "释义",
|
||||||
"pairsCount": "{count} 个词汇对",
|
"pairsCount": "{count}对",
|
||||||
"savePairs": "保存到牌组",
|
"savePairs": "保存",
|
||||||
"saveButton": "保存",
|
"saveButton": "保存到卡组",
|
||||||
"saving": "保存中...",
|
"saving": "保存中...",
|
||||||
"saved": "成功将 {count} 个词汇对保存到 {deck}",
|
"saved": "已保存",
|
||||||
"ocrSuccess": "成功将 {count} 个词汇对保存到 {deck}",
|
"ocrSuccess": "OCR识别成功",
|
||||||
"savedToDeck": "已保存到 {deckName}",
|
"savedToDeck": "已保存到卡组",
|
||||||
"saveFailed": "保存失败",
|
"saveFailed": "保存失败",
|
||||||
"noImage": "请先上传图片",
|
"noImage": "请上传图片",
|
||||||
"noDeck": "请选择牌组",
|
"noDeck": "请选择卡组",
|
||||||
"noResultsToSave": "没有可保存的结果",
|
"noResultsToSave": "无结果可保存",
|
||||||
"processingFailed": "OCR 处理失败",
|
"processingFailed": "处理失败",
|
||||||
"tryAgain": "请尝试上传更清晰的图片",
|
"tryAgain": "重试",
|
||||||
"detectedLanguages": "检测到:{source} → {target}",
|
"detectedLanguages": "检测到的语言",
|
||||||
"detectedSourceLanguage": "检测到的源语言",
|
"detectedSourceLanguage": "检测到的源语言",
|
||||||
"detectedTargetLanguage": "检测到的目标语言"
|
"detectedTargetLanguage": "检测到的目标语言",
|
||||||
|
"ocrFailed": "OCR识别失败"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"myProfile": "我的个人资料",
|
"myProfile": "我的个人资料",
|
||||||
@@ -311,12 +402,43 @@
|
|||||||
"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": "清空全部",
|
||||||
|
"language": "语言",
|
||||||
|
"customLanguage": "或输入语言...",
|
||||||
|
"languages": {
|
||||||
|
"auto": "自动",
|
||||||
|
"chinese": "中文",
|
||||||
|
"english": "英语",
|
||||||
|
"japanese": "日语",
|
||||||
|
"korean": "韩语",
|
||||||
|
"french": "法语",
|
||||||
|
"german": "德语",
|
||||||
|
"italian": "意大利语",
|
||||||
|
"spanish": "西班牙语",
|
||||||
|
"portuguese": "葡萄牙语",
|
||||||
|
"russian": "俄语"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"detectLanguage": "检测语言",
|
"detectLanguage": "检测语言",
|
||||||
@@ -349,7 +471,20 @@
|
|||||||
"success": "文本对已添加到文件夹",
|
"success": "文本对已添加到文件夹",
|
||||||
"error": "添加文本对到文件夹失败"
|
"error": "添加文本对到文件夹失败"
|
||||||
},
|
},
|
||||||
"autoSave": "自动保存"
|
"autoSave": "自动保存",
|
||||||
|
"customLanguage": "或输入语言...",
|
||||||
|
"pleaseLogin": "请登录后保存卡片",
|
||||||
|
"pleaseCreateDeck": "请先创建卡组",
|
||||||
|
"noTranslationToSave": "没有可保存的翻译",
|
||||||
|
"noDeckSelected": "未选择卡组",
|
||||||
|
"saveAsCard": "保存为卡片",
|
||||||
|
"selectDeck": "选择卡组",
|
||||||
|
"front": "正面",
|
||||||
|
"back": "背面",
|
||||||
|
"cancel": "取消",
|
||||||
|
"save": "保存",
|
||||||
|
"savedToDeck": "已保存到 {deckName}",
|
||||||
|
"saveFailed": "保存失败"
|
||||||
},
|
},
|
||||||
"dictionary": {
|
"dictionary": {
|
||||||
"title": "词典",
|
"title": "词典",
|
||||||
@@ -387,8 +522,8 @@
|
|||||||
"subtitle": "发现公开牌组",
|
"subtitle": "发现公开牌组",
|
||||||
"searchPlaceholder": "搜索公开牌组...",
|
"searchPlaceholder": "搜索公开牌组...",
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
"noDecks": "没有找到公开牌组",
|
"noDecks": "暂无公开卡组",
|
||||||
"deckInfo": "{userName} • {cardCount} 张卡片",
|
"deckInfo": "{userName} · {totalCards} 张",
|
||||||
"unknownUser": "未知用户",
|
"unknownUser": "未知用户",
|
||||||
"favorite": "收藏",
|
"favorite": "收藏",
|
||||||
"unfavorite": "取消收藏",
|
"unfavorite": "取消收藏",
|
||||||
@@ -400,7 +535,7 @@
|
|||||||
"title": "牌组详情",
|
"title": "牌组详情",
|
||||||
"createdBy": "创建者:{name}",
|
"createdBy": "创建者:{name}",
|
||||||
"unknownUser": "未知用户",
|
"unknownUser": "未知用户",
|
||||||
"totalCards": "卡片数量",
|
"totalCards": "共 {count} 张",
|
||||||
"favorites": "收藏数",
|
"favorites": "收藏数",
|
||||||
"createdAt": "创建时间",
|
"createdAt": "创建时间",
|
||||||
"viewContent": "查看内容",
|
"viewContent": "查看内容",
|
||||||
@@ -429,7 +564,7 @@
|
|||||||
"displayName": "显示名称",
|
"displayName": "显示名称",
|
||||||
"notSet": "未设置",
|
"notSet": "未设置",
|
||||||
"memberSince": "注册时间",
|
"memberSince": "注册时间",
|
||||||
"joined": "加入于",
|
"joined": "注册于",
|
||||||
"logout": "登出",
|
"logout": "登出",
|
||||||
"deleteAccount": {
|
"deleteAccount": {
|
||||||
"button": "注销账号",
|
"button": "注销账号",
|
||||||
@@ -456,6 +591,33 @@
|
|||||||
"view": "查看"
|
"view": "查看"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"decks": {
|
||||||
|
"title": "牌组",
|
||||||
|
"subtitle": "管理你的学习卡组",
|
||||||
|
"newDeck": "新建卡组",
|
||||||
|
"noDecksYet": "暂无卡组",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"deckInfo": "ID: {id} · {totalCards} 张",
|
||||||
|
"enterDeckName": "输入卡组名称:",
|
||||||
|
"enterNewName": "输入新名称:",
|
||||||
|
"confirmDelete": "输入 \"{name}\" 确认删除:",
|
||||||
|
"public": "公开",
|
||||||
|
"private": "私有",
|
||||||
|
"setPublic": "设为公开",
|
||||||
|
"setPrivate": "设为私有",
|
||||||
|
"importApkg": "导入 APKG",
|
||||||
|
"exportApkg": "导出 APKG",
|
||||||
|
"clickToUpload": "点击上传",
|
||||||
|
"apkgFilesOnly": "仅支持 .apkg 文件",
|
||||||
|
"parsing": "解析中...",
|
||||||
|
"foundDecks": "发现 {count} 个卡组",
|
||||||
|
"deckName": "卡组名称",
|
||||||
|
"back": "返回",
|
||||||
|
"import": "导入",
|
||||||
|
"importing": "导入中...",
|
||||||
|
"exportSuccess": "导出成功",
|
||||||
|
"goToDecks": "前往卡组"
|
||||||
|
},
|
||||||
"follow": {
|
"follow": {
|
||||||
"follow": "关注",
|
"follow": "关注",
|
||||||
"following": "已关注",
|
"following": "已关注",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"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",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
"sql.js": "^1.14.1",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"unstorage": "^1.17.3",
|
"unstorage": "^1.17.3",
|
||||||
"winston": "^3.19.0",
|
"winston": "^3.19.0",
|
||||||
@@ -41,6 +43,7 @@
|
|||||||
"@types/nodemailer": "^7.0.11",
|
"@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",
|
||||||
|
"@types/sql.js": "^1.4.9",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.51.0",
|
"@typescript-eslint/eslint-plugin": "^8.51.0",
|
||||||
"@typescript-eslint/parser": "^8.51.0",
|
"@typescript-eslint/parser": "^8.51.0",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
|
|||||||
121
pnpm-lock.yaml
generated
121
pnpm-lock.yaml
generated
@@ -23,7 +23,7 @@ importers:
|
|||||||
version: 3.0.3
|
version: 3.0.3
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.4.10
|
specifier: ^1.4.10
|
||||||
version: 1.4.10(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 1.4.10(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -33,6 +33,9 @@ importers:
|
|||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^17.2.3
|
specifier: ^17.2.3
|
||||||
version: 17.2.3
|
version: 17.2.3
|
||||||
|
jszip:
|
||||||
|
specifier: ^3.10.1
|
||||||
|
version: 3.10.1
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.562.0
|
specifier: ^0.562.0
|
||||||
version: 0.562.0(react@19.2.3)
|
version: 0.562.0(react@19.2.3)
|
||||||
@@ -60,6 +63,9 @@ importers:
|
|||||||
sonner:
|
sonner:
|
||||||
specifier: ^2.0.7
|
specifier: ^2.0.7
|
||||||
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
sql.js:
|
||||||
|
specifier: ^1.14.1
|
||||||
|
version: 1.14.1
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.4.0
|
specifier: ^3.4.0
|
||||||
version: 3.4.0
|
version: 3.4.0
|
||||||
@@ -78,7 +84,7 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
'@better-auth/cli':
|
'@better-auth/cli':
|
||||||
specifier: ^1.4.10
|
specifier: ^1.4.10
|
||||||
version: 1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@types/react@19.2.7)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(mysql2@3.15.3)(nanostores@1.1.0)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(mysql2@3.15.3)(nanostores@1.1.0)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sql.js@1.14.1)
|
||||||
'@eslint/eslintrc':
|
'@eslint/eslintrc':
|
||||||
specifier: ^3.3.3
|
specifier: ^3.3.3
|
||||||
version: 3.3.3
|
version: 3.3.3
|
||||||
@@ -97,6 +103,9 @@ importers:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: 19.2.3
|
specifier: 19.2.3
|
||||||
version: 19.2.3(@types/react@19.2.7)
|
version: 19.2.3(@types/react@19.2.7)
|
||||||
|
'@types/sql.js':
|
||||||
|
specifier: ^1.4.9
|
||||||
|
version: 1.4.9
|
||||||
'@typescript-eslint/eslint-plugin':
|
'@typescript-eslint/eslint-plugin':
|
||||||
specifier: ^8.51.0
|
specifier: ^8.51.0
|
||||||
version: 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
version: 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||||
@@ -1046,6 +1055,9 @@ packages:
|
|||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||||
|
|
||||||
|
'@types/emscripten@1.41.5':
|
||||||
|
resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==}
|
||||||
|
|
||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
@@ -1072,6 +1084,9 @@ packages:
|
|||||||
'@types/react@19.2.7':
|
'@types/react@19.2.7':
|
||||||
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
|
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
|
||||||
|
|
||||||
|
'@types/sql.js@1.4.9':
|
||||||
|
resolution: {integrity: sha512-ep8b36RKHlgWPqjNG9ToUrPiwkhwh0AEzy883mO5Xnd+cL6VBH1EvSjBAAuxLUFF2Vn/moE3Me6v9E1Lo+48GQ==}
|
||||||
|
|
||||||
'@types/triple-beam@1.3.5':
|
'@types/triple-beam@1.3.5':
|
||||||
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
|
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
|
||||||
|
|
||||||
@@ -1620,6 +1635,9 @@ packages:
|
|||||||
cookie-es@1.2.2:
|
cookie-es@1.2.2:
|
||||||
resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==}
|
resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==}
|
||||||
|
|
||||||
|
core-util-is@1.0.3:
|
||||||
|
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -2218,6 +2236,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
|
immediate@3.0.6:
|
||||||
|
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -2366,6 +2387,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==}
|
resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
|
isarray@1.0.0:
|
||||||
|
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||||
|
|
||||||
isarray@2.0.5:
|
isarray@2.0.5:
|
||||||
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
|
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
|
||||||
|
|
||||||
@@ -2417,6 +2441,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
|
|
||||||
|
jszip@3.10.1:
|
||||||
|
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
@@ -2442,6 +2469,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
lie@3.3.0:
|
||||||
|
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||||
|
|
||||||
lightningcss-android-arm64@1.30.2:
|
lightningcss-android-arm64@1.30.2:
|
||||||
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
|
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -2762,6 +2792,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
pako@1.0.11:
|
||||||
|
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -2900,6 +2933,9 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
process-nextick-args@2.0.1:
|
||||||
|
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||||
|
|
||||||
prompts@2.4.2:
|
prompts@2.4.2:
|
||||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -2945,6 +2981,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
|
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
readable-stream@2.3.8:
|
||||||
|
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||||
|
|
||||||
readable-stream@3.6.2:
|
readable-stream@3.6.2:
|
||||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -3005,6 +3044,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
|
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
|
||||||
engines: {node: '>=0.4'}
|
engines: {node: '>=0.4'}
|
||||||
|
|
||||||
|
safe-buffer@5.1.2:
|
||||||
|
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||||
|
|
||||||
safe-buffer@5.2.1:
|
safe-buffer@5.2.1:
|
||||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||||
|
|
||||||
@@ -3053,6 +3095,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
setimmediate@1.0.5:
|
||||||
|
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||||
|
|
||||||
sharp@0.34.5:
|
sharp@0.34.5:
|
||||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
@@ -3111,6 +3156,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||||
engines: {node: '>= 10.x'}
|
engines: {node: '>= 10.x'}
|
||||||
|
|
||||||
|
sql.js@1.14.1:
|
||||||
|
resolution: {integrity: sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==}
|
||||||
|
|
||||||
sqlstring@2.3.3:
|
sqlstring@2.3.3:
|
||||||
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
|
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -3151,6 +3199,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
|
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
string_decoder@1.1.1:
|
||||||
|
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||||
|
|
||||||
string_decoder@1.3.0:
|
string_decoder@1.3.0:
|
||||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||||
|
|
||||||
@@ -3699,7 +3750,7 @@ snapshots:
|
|||||||
'@babel/helper-string-parser': 7.27.1
|
'@babel/helper-string-parser': 7.27.1
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
|
|
||||||
'@better-auth/cli@1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@types/react@19.2.7)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(mysql2@3.15.3)(nanostores@1.1.0)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
'@better-auth/cli@1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(mysql2@3.15.3)(nanostores@1.1.0)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sql.js@1.14.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.28.5
|
||||||
'@babel/preset-react': 7.28.5(@babel/core@7.28.5)
|
'@babel/preset-react': 7.28.5(@babel/core@7.28.5)
|
||||||
@@ -3711,13 +3762,13 @@ snapshots:
|
|||||||
'@mrleebo/prisma-ast': 0.13.1
|
'@mrleebo/prisma-ast': 0.13.1
|
||||||
'@prisma/client': 5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))
|
'@prisma/client': 5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))
|
||||||
'@types/pg': 8.15.6
|
'@types/pg': 8.15.6
|
||||||
better-auth: 1.4.10(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
better-auth: 1.4.10(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
better-sqlite3: 12.5.0
|
better-sqlite3: 12.5.0
|
||||||
c12: 3.3.2
|
c12: 3.3.2
|
||||||
chalk: 5.6.2
|
chalk: 5.6.2
|
||||||
commander: 12.1.0
|
commander: 12.1.0
|
||||||
dotenv: 17.2.3
|
dotenv: 17.2.3
|
||||||
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)
|
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1)
|
||||||
open: 10.2.0
|
open: 10.2.0
|
||||||
pg: 8.16.3
|
pg: 8.16.3
|
||||||
prettier: 3.7.4
|
prettier: 3.7.4
|
||||||
@@ -4411,6 +4462,8 @@ snapshots:
|
|||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@types/emscripten@1.41.5': {}
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
@@ -4439,6 +4492,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@types/sql.js@1.4.9':
|
||||||
|
dependencies:
|
||||||
|
'@types/emscripten': 1.41.5
|
||||||
|
'@types/node': 25.0.3
|
||||||
|
|
||||||
'@types/triple-beam@1.3.5': {}
|
'@types/triple-beam@1.3.5': {}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
'@typescript-eslint/eslint-plugin@8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||||
@@ -4810,7 +4868,7 @@ snapshots:
|
|||||||
|
|
||||||
bcryptjs@3.0.3: {}
|
bcryptjs@3.0.3: {}
|
||||||
|
|
||||||
better-auth@1.4.10(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
better-auth@1.4.10(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)
|
'@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)
|
||||||
'@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))
|
'@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))
|
||||||
@@ -4827,7 +4885,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@prisma/client': 5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))
|
'@prisma/client': 5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))
|
||||||
better-sqlite3: 12.5.0
|
better-sqlite3: 12.5.0
|
||||||
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)
|
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1)
|
||||||
mysql2: 3.15.3
|
mysql2: 3.15.3
|
||||||
next: 16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
next: 16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
pg: 8.16.3
|
pg: 8.16.3
|
||||||
@@ -4835,7 +4893,7 @@ snapshots:
|
|||||||
react: 19.2.3
|
react: 19.2.3
|
||||||
react-dom: 19.2.3(react@19.2.3)
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
|
||||||
better-auth@1.4.10(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
better-auth@1.4.10(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)
|
'@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)
|
||||||
'@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))
|
'@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))
|
||||||
@@ -4852,7 +4910,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)
|
'@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)
|
||||||
better-sqlite3: 12.5.0
|
better-sqlite3: 12.5.0
|
||||||
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)
|
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1)
|
||||||
mysql2: 3.15.3
|
mysql2: 3.15.3
|
||||||
next: 16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
next: 16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
pg: 8.16.3
|
pg: 8.16.3
|
||||||
@@ -5034,6 +5092,8 @@ snapshots:
|
|||||||
|
|
||||||
cookie-es@1.2.2: {}
|
cookie-es@1.2.2: {}
|
||||||
|
|
||||||
|
core-util-is@1.0.3: {}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
@@ -5125,12 +5185,13 @@ snapshots:
|
|||||||
|
|
||||||
dotenv@17.2.3: {}
|
dotenv@17.2.3: {}
|
||||||
|
|
||||||
drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3):
|
drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@electric-sql/pglite': 0.3.15
|
'@electric-sql/pglite': 0.3.15
|
||||||
'@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)
|
'@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)
|
||||||
'@types/pg': 8.15.6
|
'@types/pg': 8.15.6
|
||||||
'@types/react': 19.2.7
|
'@types/react': 19.2.7
|
||||||
|
'@types/sql.js': 1.4.9
|
||||||
better-sqlite3: 12.5.0
|
better-sqlite3: 12.5.0
|
||||||
kysely: 0.28.8
|
kysely: 0.28.8
|
||||||
mysql2: 3.15.3
|
mysql2: 3.15.3
|
||||||
@@ -5138,6 +5199,7 @@ snapshots:
|
|||||||
postgres: 3.4.7
|
postgres: 3.4.7
|
||||||
prisma: 7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)
|
prisma: 7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)
|
||||||
react: 19.2.3
|
react: 19.2.3
|
||||||
|
sql.js: 1.14.1
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5685,6 +5747,8 @@ snapshots:
|
|||||||
|
|
||||||
ignore@7.0.5: {}
|
ignore@7.0.5: {}
|
||||||
|
|
||||||
|
immediate@3.0.6: {}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
parent-module: 1.0.1
|
parent-module: 1.0.1
|
||||||
@@ -5837,6 +5901,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-inside-container: 1.0.0
|
is-inside-container: 1.0.0
|
||||||
|
|
||||||
|
isarray@1.0.0: {}
|
||||||
|
|
||||||
isarray@2.0.5: {}
|
isarray@2.0.5: {}
|
||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
@@ -5881,6 +5947,13 @@ snapshots:
|
|||||||
object.assign: 4.1.7
|
object.assign: 4.1.7
|
||||||
object.values: 1.2.1
|
object.values: 1.2.1
|
||||||
|
|
||||||
|
jszip@3.10.1:
|
||||||
|
dependencies:
|
||||||
|
lie: 3.3.0
|
||||||
|
pako: 1.0.11
|
||||||
|
readable-stream: 2.3.8
|
||||||
|
setimmediate: 1.0.5
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
@@ -5902,6 +5975,10 @@ snapshots:
|
|||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
|
|
||||||
|
lie@3.3.0:
|
||||||
|
dependencies:
|
||||||
|
immediate: 3.0.6
|
||||||
|
|
||||||
lightningcss-android-arm64@1.30.2:
|
lightningcss-android-arm64@1.30.2:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -6204,6 +6281,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-limit: 3.1.0
|
p-limit: 3.1.0
|
||||||
|
|
||||||
|
pako@1.0.11: {}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
callsites: 3.1.0
|
callsites: 3.1.0
|
||||||
@@ -6333,6 +6412,8 @@ snapshots:
|
|||||||
- react
|
- react
|
||||||
- react-dom
|
- react-dom
|
||||||
|
|
||||||
|
process-nextick-args@2.0.1: {}
|
||||||
|
|
||||||
prompts@2.4.2:
|
prompts@2.4.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
kleur: 3.0.3
|
kleur: 3.0.3
|
||||||
@@ -6384,6 +6465,16 @@ snapshots:
|
|||||||
|
|
||||||
react@19.2.3: {}
|
react@19.2.3: {}
|
||||||
|
|
||||||
|
readable-stream@2.3.8:
|
||||||
|
dependencies:
|
||||||
|
core-util-is: 1.0.3
|
||||||
|
inherits: 2.0.4
|
||||||
|
isarray: 1.0.0
|
||||||
|
process-nextick-args: 2.0.1
|
||||||
|
safe-buffer: 5.1.2
|
||||||
|
string_decoder: 1.1.1
|
||||||
|
util-deprecate: 1.0.2
|
||||||
|
|
||||||
readable-stream@3.6.2:
|
readable-stream@3.6.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
inherits: 2.0.4
|
inherits: 2.0.4
|
||||||
@@ -6452,6 +6543,8 @@ snapshots:
|
|||||||
has-symbols: 1.1.0
|
has-symbols: 1.1.0
|
||||||
isarray: 2.0.5
|
isarray: 2.0.5
|
||||||
|
|
||||||
|
safe-buffer@5.1.2: {}
|
||||||
|
|
||||||
safe-buffer@5.2.1: {}
|
safe-buffer@5.2.1: {}
|
||||||
|
|
||||||
safe-push-apply@1.0.0:
|
safe-push-apply@1.0.0:
|
||||||
@@ -6501,6 +6594,8 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
es-object-atoms: 1.1.1
|
es-object-atoms: 1.1.1
|
||||||
|
|
||||||
|
setimmediate@1.0.5: {}
|
||||||
|
|
||||||
sharp@0.34.5:
|
sharp@0.34.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@img/colour': 1.0.0
|
'@img/colour': 1.0.0
|
||||||
@@ -6590,6 +6685,8 @@ snapshots:
|
|||||||
|
|
||||||
split2@4.2.0: {}
|
split2@4.2.0: {}
|
||||||
|
|
||||||
|
sql.js@1.14.1: {}
|
||||||
|
|
||||||
sqlstring@2.3.3: {}
|
sqlstring@2.3.3: {}
|
||||||
|
|
||||||
stable-hash@0.0.5: {}
|
stable-hash@0.0.5: {}
|
||||||
@@ -6653,6 +6750,10 @@ snapshots:
|
|||||||
define-properties: 1.2.1
|
define-properties: 1.2.1
|
||||||
es-object-atoms: 1.1.1
|
es-object-atoms: 1.1.1
|
||||||
|
|
||||||
|
string_decoder@1.1.1:
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: 5.1.2
|
||||||
|
|
||||||
string_decoder@1.3.0:
|
string_decoder@1.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "decks" ADD COLUMN "new_per_day" INTEGER NOT NULL DEFAULT 20,
|
||||||
|
ADD COLUMN "rev_per_day" INTEGER NOT NULL DEFAULT 200;
|
||||||
@@ -12,27 +12,22 @@ datasource db {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id
|
id String @id
|
||||||
name String
|
name String
|
||||||
email String @unique
|
email String @unique
|
||||||
emailVerified Boolean @default(false)
|
emailVerified Boolean @default(false)
|
||||||
image String?
|
image String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
displayUsername String?
|
displayUsername String?
|
||||||
username String @unique
|
username String @unique
|
||||||
bio String?
|
bio String?
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
dictionaryLookUps DictionaryLookUp[]
|
decks Deck[]
|
||||||
// Anki-compatible relations
|
deckFavorites DeckFavorite[]
|
||||||
decks Deck[]
|
sessions Session[]
|
||||||
deckFavorites DeckFavorite[]
|
followers Follow[] @relation("UserFollowers")
|
||||||
noteTypes NoteType[]
|
following Follow[] @relation("UserFollowing")
|
||||||
notes Note[]
|
|
||||||
sessions Session[]
|
|
||||||
translationHistories TranslationHistory[]
|
|
||||||
followers Follow[] @relation("UserFollowers")
|
|
||||||
following Follow[] @relation("UserFollowing")
|
|
||||||
|
|
||||||
@@map("user")
|
@@map("user")
|
||||||
}
|
}
|
||||||
@@ -85,88 +80,75 @@ model Verification {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Anki-compatible Models
|
// Deck & Card
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
/// Card type: 0=new, 1=learning, 2=review, 3=relearning
|
|
||||||
enum CardType {
|
|
||||||
NEW
|
|
||||||
LEARNING
|
|
||||||
REVIEW
|
|
||||||
RELEARNING
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Card queue: -3=user buried, -2=sched buried, -1=suspended, 0=new, 1=learning, 2=review, 3=in learning, 4=preview
|
|
||||||
enum CardQueue {
|
|
||||||
USER_BURIED
|
|
||||||
SCHED_BURIED
|
|
||||||
SUSPENDED
|
|
||||||
NEW
|
|
||||||
LEARNING
|
|
||||||
REVIEW
|
|
||||||
IN_LEARNING
|
|
||||||
PREVIEW
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Note type: 0=standard, 1=cloze
|
|
||||||
enum NoteKind {
|
|
||||||
STANDARD
|
|
||||||
CLOZE
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deck visibility (our extension, not in Anki)
|
|
||||||
enum Visibility {
|
enum Visibility {
|
||||||
PRIVATE
|
|
||||||
PUBLIC
|
PUBLIC
|
||||||
|
PRIVATE
|
||||||
}
|
}
|
||||||
|
|
||||||
/// NoteType (Anki: models) - Defines fields and templates for notes
|
enum CardType {
|
||||||
model NoteType {
|
WORD
|
||||||
id Int @id @default(autoincrement())
|
PHRASE
|
||||||
name String
|
SENTENCE
|
||||||
kind NoteKind @default(STANDARD)
|
|
||||||
css String @default("")
|
|
||||||
fields Json @default("[]")
|
|
||||||
templates Json @default("[]")
|
|
||||||
userId String @map("user_id")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
notes Note[]
|
|
||||||
|
|
||||||
@@index([userId])
|
|
||||||
@@map("note_types")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deck (Anki: decks) - Container for cards
|
|
||||||
model Deck {
|
model Deck {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
desc String @default("")
|
desc String @db.Text @default("")
|
||||||
userId String @map("user_id")
|
userId String
|
||||||
visibility Visibility @default(PRIVATE)
|
visibility Visibility @default(PRIVATE)
|
||||||
collapsed Boolean @default(false)
|
createdAt DateTime @default(now())
|
||||||
conf Json @default("{}")
|
updatedAt DateTime @updatedAt
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
cards Card[]
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
favorites DeckFavorite[]
|
||||||
cards Card[]
|
|
||||||
favorites DeckFavorite[]
|
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([visibility])
|
@@index([visibility])
|
||||||
@@map("decks")
|
@@map("decks")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// DeckFavorite - Users can favorite public decks
|
model Card {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
deckId Int
|
||||||
|
word String
|
||||||
|
ipa String?
|
||||||
|
queryLang String
|
||||||
|
cardType CardType @default(WORD)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
|
||||||
|
meanings CardMeaning[]
|
||||||
|
|
||||||
|
@@index([deckId])
|
||||||
|
@@index([word])
|
||||||
|
@@map("cards")
|
||||||
|
}
|
||||||
|
|
||||||
|
model CardMeaning {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
cardId Int
|
||||||
|
partOfSpeech String?
|
||||||
|
definition String
|
||||||
|
example String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([cardId])
|
||||||
|
@@map("card_meanings")
|
||||||
|
}
|
||||||
|
|
||||||
model DeckFavorite {
|
model DeckFavorite {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId String @map("user_id")
|
userId String
|
||||||
deckId Int @map("deck_id")
|
deckId Int
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now())
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
|
||||||
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([userId, deckId])
|
@@unique([userId, deckId])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@ -174,156 +156,10 @@ model DeckFavorite {
|
|||||||
@@map("deck_favorites")
|
@@map("deck_favorites")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Note (Anki: notes) - Contains field data, one note can have multiple cards
|
|
||||||
model Note {
|
|
||||||
id BigInt @id
|
|
||||||
guid String @unique
|
|
||||||
noteTypeId Int @map("note_type_id")
|
|
||||||
mod Int
|
|
||||||
usn Int @default(-1)
|
|
||||||
tags String @default(" ")
|
|
||||||
flds String
|
|
||||||
sfld String
|
|
||||||
csum Int
|
|
||||||
flags Int @default(0)
|
|
||||||
data String @default("")
|
|
||||||
userId String @map("user_id")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
noteType NoteType @relation(fields: [noteTypeId], references: [id], onDelete: Cascade)
|
|
||||||
cards Card[]
|
|
||||||
|
|
||||||
@@index([userId])
|
|
||||||
@@index([noteTypeId])
|
|
||||||
@@index([csum])
|
|
||||||
@@map("notes")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Card (Anki: cards) - Scheduling information, what you review
|
|
||||||
model Card {
|
|
||||||
id BigInt @id
|
|
||||||
noteId BigInt @map("note_id")
|
|
||||||
deckId Int @map("deck_id")
|
|
||||||
ord Int
|
|
||||||
mod Int
|
|
||||||
usn Int @default(-1)
|
|
||||||
type CardType @default(NEW)
|
|
||||||
queue CardQueue @default(NEW)
|
|
||||||
due Int
|
|
||||||
ivl Int @default(0)
|
|
||||||
factor Int @default(2500)
|
|
||||||
reps Int @default(0)
|
|
||||||
lapses Int @default(0)
|
|
||||||
left Int @default(0)
|
|
||||||
odue Int @default(0)
|
|
||||||
odid Int @default(0)
|
|
||||||
flags Int @default(0)
|
|
||||||
data String @default("")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
|
||||||
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
|
||||||
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
|
|
||||||
revlogs Revlog[]
|
|
||||||
|
|
||||||
@@index([noteId])
|
|
||||||
@@index([deckId])
|
|
||||||
@@index([deckId, queue, due])
|
|
||||||
@@map("cards")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Revlog (Anki: revlog) - Review history
|
|
||||||
model Revlog {
|
|
||||||
id BigInt @id
|
|
||||||
cardId BigInt @map("card_id")
|
|
||||||
usn Int @default(-1)
|
|
||||||
ease Int
|
|
||||||
ivl Int
|
|
||||||
lastIvl Int
|
|
||||||
factor Int
|
|
||||||
time Int
|
|
||||||
type Int
|
|
||||||
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([cardId])
|
|
||||||
@@map("revlogs")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Other Models
|
// Social
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
model DictionaryLookUp {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
userId String? @map("user_id")
|
|
||||||
text String
|
|
||||||
queryLang String @map("query_lang")
|
|
||||||
definitionLang String @map("definition_lang")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
dictionaryItemId Int? @map("dictionary_item_id")
|
|
||||||
normalizedText String @default("") @map("normalized_text")
|
|
||||||
dictionaryItem DictionaryItem? @relation(fields: [dictionaryItemId], references: [id], onDelete: SetNull)
|
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
|
||||||
|
|
||||||
@@index([userId])
|
|
||||||
@@index([createdAt])
|
|
||||||
@@index([normalizedText])
|
|
||||||
@@map("dictionary_lookups")
|
|
||||||
}
|
|
||||||
|
|
||||||
model DictionaryItem {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
frequency Int @default(1)
|
|
||||||
standardForm String @map("standard_form")
|
|
||||||
queryLang String @map("query_lang")
|
|
||||||
definitionLang String @map("definition_lang")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
|
||||||
entries DictionaryEntry[]
|
|
||||||
lookups DictionaryLookUp[]
|
|
||||||
|
|
||||||
@@unique([standardForm, queryLang, definitionLang])
|
|
||||||
@@index([standardForm])
|
|
||||||
@@index([queryLang, definitionLang])
|
|
||||||
@@map("dictionary_items")
|
|
||||||
}
|
|
||||||
|
|
||||||
model DictionaryEntry {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
itemId Int @map("item_id")
|
|
||||||
ipa String?
|
|
||||||
definition String
|
|
||||||
partOfSpeech String? @map("part_of_speech")
|
|
||||||
example String
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
|
||||||
item DictionaryItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@index([itemId])
|
|
||||||
@@index([createdAt])
|
|
||||||
@@map("dictionary_entries")
|
|
||||||
}
|
|
||||||
|
|
||||||
model TranslationHistory {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
userId String? @map("user_id")
|
|
||||||
sourceText String @map("source_text")
|
|
||||||
sourceLanguage String @map("source_language")
|
|
||||||
targetLanguage String @map("target_language")
|
|
||||||
translatedText String @map("translated_text")
|
|
||||||
sourceIpa String? @map("source_ipa")
|
|
||||||
targetIpa String? @map("target_ipa")
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
|
||||||
user User? @relation(fields: [userId], references: [id])
|
|
||||||
|
|
||||||
@@index([userId])
|
|
||||||
@@index([createdAt])
|
|
||||||
@@index([sourceText, targetLanguage])
|
|
||||||
@@index([translatedText, sourceLanguage, targetLanguage])
|
|
||||||
@@map("translation_history")
|
|
||||||
}
|
|
||||||
|
|
||||||
model Follow {
|
model Follow {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
followerId String @map("follower_id")
|
followerId String @map("follower_id")
|
||||||
|
|||||||
147
scripts/find-missing-translations.ts
Normal file
147
scripts/find-missing-translations.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* 查找缺失的翻译键
|
||||||
|
* 用法: npx tsx scripts/find-missing-translations.ts [locale]
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
const SRC_DIR = "./src";
|
||||||
|
const MESSAGES_DIR = "./messages";
|
||||||
|
const ALL_LOCALES = ["en-US", "zh-CN", "ja-JP", "ko-KR", "de-DE", "fr-FR", "it-IT", "ug-CN"];
|
||||||
|
|
||||||
|
function parseString(s: string): string | null {
|
||||||
|
s = s.trim();
|
||||||
|
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||||
|
return s.slice(1, -1);
|
||||||
|
}
|
||||||
|
if (s.startsWith("`") && s.endsWith("`") && !s.includes("${")) {
|
||||||
|
return s.slice(1, -1);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBindings(content: string): Map<string, string> {
|
||||||
|
const bindings = new Map<string, string>();
|
||||||
|
const pattern = /(?:const|let|var)\s+(\w+)\s*=\s*(?:await\s+)?(?:useTranslations|getTranslations)\s*\(\s*([^)]*)\s*\)/g;
|
||||||
|
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(content)) !== null) {
|
||||||
|
const varName = match[1];
|
||||||
|
const arg = match[2].trim();
|
||||||
|
bindings.set(varName, arg ? parseString(arg) || "" : "__ROOT__");
|
||||||
|
}
|
||||||
|
|
||||||
|
return bindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUsages(content: string, file: string): { file: string; line: number; ns: string; key: string }[] {
|
||||||
|
const usages: { file: string; line: number; ns: string; key: string }[] = [];
|
||||||
|
const bindings = getBindings(content);
|
||||||
|
const lines = content.split("\n");
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
for (const [varName, ns] of bindings) {
|
||||||
|
const pattern = new RegExp(`\\b${varName}\\s*\\(\\s*("[^"]*"|'[^']*'|\`[^\`]*\`)(?:\\s*,|\\s*\\))`, "g");
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(line)) !== null) {
|
||||||
|
const key = parseString(match[1]);
|
||||||
|
if (key) usages.push({ file, line: i + 1, ns, key });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return usages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFiles(dir: string): string[] {
|
||||||
|
const files: string[] = [];
|
||||||
|
if (!fs.existsSync(dir)) return files;
|
||||||
|
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const p = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) files.push(...getFiles(p));
|
||||||
|
else if (entry.isFile() && /\.(tsx?|ts)$/.test(entry.name)) files.push(p);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyExists(key: string, ns: string, trans: Record<string, unknown>): boolean {
|
||||||
|
let obj: unknown;
|
||||||
|
|
||||||
|
if (ns === "__ROOT__") {
|
||||||
|
obj = trans;
|
||||||
|
} else {
|
||||||
|
obj = trans[ns];
|
||||||
|
if (typeof obj !== "object" || obj === null) {
|
||||||
|
obj = trans;
|
||||||
|
for (const part of ns.split(".")) {
|
||||||
|
if (typeof obj !== "object" || obj === null) return false;
|
||||||
|
obj = (obj as Record<string, unknown>)[part];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof obj !== "object" || obj === null) return false;
|
||||||
|
|
||||||
|
for (const part of key.split(".")) {
|
||||||
|
if (typeof obj !== "object" || obj === null) return false;
|
||||||
|
obj = (obj as Record<string, unknown>)[part];
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof obj === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const locales = process.argv[2] ? [process.argv[2]] : ALL_LOCALES;
|
||||||
|
|
||||||
|
const files = getFiles(SRC_DIR);
|
||||||
|
const usages: { file: string; line: number; ns: string; key: string }[] = [];
|
||||||
|
|
||||||
|
for (const f of files) {
|
||||||
|
usages.push(...getUsages(fs.readFileSync(f, "utf-8"), f));
|
||||||
|
}
|
||||||
|
|
||||||
|
const unique = new Map<string, { file: string; line: number; ns: string; key: string }>();
|
||||||
|
for (const u of usages) {
|
||||||
|
unique.set(`${u.file}:${u.line}:${u.ns}:${u.key}`, u);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Scanned ${files.length} files, ${unique.size} usages\n`);
|
||||||
|
|
||||||
|
for (const locale of locales) {
|
||||||
|
console.log(`\n${"=".repeat(50)}\nLocale: ${locale}\n${"=".repeat(50)}`);
|
||||||
|
|
||||||
|
const filePath = path.join(MESSAGES_DIR, `${locale}.json`);
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
console.log(`File not found: ${filePath}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trans = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||||
|
const missing = Array.from(unique.values()).filter(u => !keyExists(u.key, u.ns, trans));
|
||||||
|
|
||||||
|
if (missing.length === 0) {
|
||||||
|
console.log("All translations exist!");
|
||||||
|
} else {
|
||||||
|
console.log(`\nMissing ${missing.length} translations:\n`);
|
||||||
|
const byFile = new Map<string, typeof missing>();
|
||||||
|
for (const u of missing) {
|
||||||
|
if (!byFile.has(u.file)) byFile.set(u.file, []);
|
||||||
|
byFile.get(u.file)!.push(u);
|
||||||
|
}
|
||||||
|
for (const [file, list] of byFile) {
|
||||||
|
console.log(file);
|
||||||
|
for (const u of list) {
|
||||||
|
console.log(` L${u.line} [${u.ns === "__ROOT__" ? "root" : u.ns}] ${u.key}`);
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\nDone!");
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
154
scripts/find-unused-translations.ts
Normal file
154
scripts/find-unused-translations.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* 查找多余的翻译键
|
||||||
|
* 用法: npx tsx scripts/find-unused-translations.ts [locale]
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
const SRC_DIR = "./src";
|
||||||
|
const MESSAGES_DIR = "./messages";
|
||||||
|
const ALL_LOCALES = ["en-US", "zh-CN", "ja-JP", "ko-KR", "de-DE", "fr-FR", "it-IT", "ug-CN"];
|
||||||
|
|
||||||
|
function parseString(s: string): string | null {
|
||||||
|
s = s.trim();
|
||||||
|
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||||
|
return s.slice(1, -1);
|
||||||
|
}
|
||||||
|
if (s.startsWith("`") && s.endsWith("`") && !s.includes("${")) {
|
||||||
|
return s.slice(1, -1);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBindings(content: string): Map<string, string> {
|
||||||
|
const bindings = new Map<string, string>();
|
||||||
|
const pattern = /(?:const|let|var)\s+(\w+)\s*=\s*(?:await\s+)?(?:useTranslations|getTranslations)\s*\(\s*([^)]*)\s*\)/g;
|
||||||
|
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(content)) !== null) {
|
||||||
|
const varName = match[1];
|
||||||
|
const arg = match[2].trim();
|
||||||
|
bindings.set(varName, arg ? parseString(arg) || "" : "__ROOT__");
|
||||||
|
}
|
||||||
|
|
||||||
|
return bindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUsedKeys(content: string): Map<string, Set<string>> {
|
||||||
|
const used = new Map<string, Set<string>>();
|
||||||
|
const bindings = getBindings(content);
|
||||||
|
|
||||||
|
for (const [varName, ns] of bindings) {
|
||||||
|
const pattern = new RegExp(`\\b${varName}\\s*\\(\\s*("[^"]*"|'[^']*'|\`[^\`]*\`)(?:\\s*,|\\s*\\))`, "g");
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(content)) !== null) {
|
||||||
|
const key = parseString(match[1]);
|
||||||
|
if (key) {
|
||||||
|
if (!used.has(ns)) used.set(ns, new Set());
|
||||||
|
used.get(ns)!.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return used;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFiles(dir: string): string[] {
|
||||||
|
const files: string[] = [];
|
||||||
|
if (!fs.existsSync(dir)) return files;
|
||||||
|
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const p = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) files.push(...getFiles(p));
|
||||||
|
else if (entry.isFile() && /\.(tsx?|ts)$/.test(entry.name)) files.push(p);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenKeys(obj: Record<string, unknown>, prefix = ""): string[] {
|
||||||
|
const keys: string[] = [];
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||||
|
if (typeof obj[key] === "object" && obj[key] !== null) {
|
||||||
|
keys.push(...flattenKeys(obj[key] as Record<string, unknown>, fullKey));
|
||||||
|
} else if (typeof obj[key] === "string") {
|
||||||
|
keys.push(fullKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUsed(fullKey: string, used: Map<string, Set<string>>): boolean {
|
||||||
|
const parts = fullKey.split(".");
|
||||||
|
|
||||||
|
for (let i = 1; i < parts.length; i++) {
|
||||||
|
const ns = parts.slice(0, i).join(".");
|
||||||
|
const key = parts.slice(i).join(".");
|
||||||
|
|
||||||
|
const nsKeys = used.get(ns);
|
||||||
|
if (nsKeys) {
|
||||||
|
if (nsKeys.has(key)) return true;
|
||||||
|
for (const k of nsKeys) {
|
||||||
|
if (key.startsWith(k + ".")) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootKeys = used.get("__ROOT__");
|
||||||
|
return rootKeys?.has(fullKey) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const locales = process.argv[2] ? [process.argv[2]] : ALL_LOCALES;
|
||||||
|
|
||||||
|
const files = getFiles(SRC_DIR);
|
||||||
|
const allUsed = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
for (const f of files) {
|
||||||
|
const used = getUsedKeys(fs.readFileSync(f, "utf-8"));
|
||||||
|
for (const [ns, keys] of used) {
|
||||||
|
if (!allUsed.has(ns)) allUsed.set(ns, new Set());
|
||||||
|
for (const k of keys) allUsed.get(ns)!.add(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Scanned ${files.length} files, ${allUsed.size} namespaces\n`);
|
||||||
|
|
||||||
|
for (const locale of locales) {
|
||||||
|
console.log(`\n${"=".repeat(50)}\nLocale: ${locale}\n${"=".repeat(50)}`);
|
||||||
|
|
||||||
|
const filePath = path.join(MESSAGES_DIR, `${locale}.json`);
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
console.log(`File not found: ${filePath}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trans = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||||
|
const allKeys = flattenKeys(trans);
|
||||||
|
const unused = allKeys.filter(k => !isUsed(k, allUsed));
|
||||||
|
|
||||||
|
console.log(`Total: ${allKeys.length} keys`);
|
||||||
|
|
||||||
|
if (unused.length === 0) {
|
||||||
|
console.log("No unused translations!");
|
||||||
|
} else {
|
||||||
|
console.log(`\n${unused.length} potentially unused:\n`);
|
||||||
|
const grouped = new Map<string, string[]>();
|
||||||
|
for (const k of unused) {
|
||||||
|
const [ns, ...rest] = k.split(".");
|
||||||
|
if (!grouped.has(ns)) grouped.set(ns, []);
|
||||||
|
grouped.get(ns)!.push(rest.join("."));
|
||||||
|
}
|
||||||
|
for (const [ns, keys] of grouped) {
|
||||||
|
console.log(`${ns}`);
|
||||||
|
for (const k of keys) console.log(` ${k}`);
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\nDone!");
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -9,5 +9,5 @@ export default async function ProfilePage() {
|
|||||||
redirect("/login?redirect=/profile");
|
redirect("/login?redirect=/profile");
|
||||||
}
|
}
|
||||||
|
|
||||||
redirect(session.user.username ? `/users/${session.user.username}` : "/folders");
|
redirect(session.user.username ? `/users/${session.user.username}` : "/decks");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function SignUpPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPending && session?.user?.username && !redirectTo && !verificationSent) {
|
if (!isPending && session?.user?.username && !redirectTo && !verificationSent) {
|
||||||
router.push("/folders");
|
router.push("/decks");
|
||||||
}
|
}
|
||||||
}, [session, isPending, router, redirectTo, verificationSent]);
|
}, [session, isPending, router, redirectTo, verificationSent]);
|
||||||
|
|
||||||
|
|||||||
@@ -7,19 +7,22 @@ import { useDictionaryStore } from "./stores/dictionaryStore";
|
|||||||
import { PageLayout } from "@/components/ui/PageLayout";
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
import { LightButton } from "@/design-system/base/button";
|
import { LightButton } from "@/design-system/base/button";
|
||||||
import { Input } from "@/design-system/base/input";
|
import { Input } from "@/design-system/base/input";
|
||||||
|
import { Select } from "@/design-system/base/select";
|
||||||
|
import { Skeleton } from "@/design-system/feedback/skeleton";
|
||||||
|
import { HStack, VStack } from "@/design-system/layout/stack";
|
||||||
import { Plus, RefreshCw } from "lucide-react";
|
import { Plus, RefreshCw } from "lucide-react";
|
||||||
import { DictionaryEntry } from "./DictionaryEntry";
|
import { DictionaryEntry } from "./DictionaryEntry";
|
||||||
import { LanguageSelector } from "./LanguageSelector";
|
import { LanguageSelector } from "./LanguageSelector";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
|
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
|
||||||
import { actionCreateNote } from "@/modules/note/note-action";
|
|
||||||
import { actionCreateCard } from "@/modules/card/card-action";
|
import { actionCreateCard } from "@/modules/card/card-action";
|
||||||
import { actionGetNoteTypesByUserId, actionCreateDefaultBasicNoteType } from "@/modules/note-type/note-type-action";
|
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
||||||
import type { TSharedDeck } from "@/shared/anki-type";
|
import type { CardType } from "@/modules/card/card-action-dto";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { getNativeName } from "./stores/dictionaryStore";
|
||||||
|
|
||||||
interface DictionaryClientProps {
|
interface DictionaryClientProps {
|
||||||
initialDecks: TSharedDeck[];
|
initialDecks: ActionOutputDeck[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
|
export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
|
||||||
@@ -42,8 +45,7 @@ export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
|
|||||||
} = useDictionaryStore();
|
} = useDictionaryStore();
|
||||||
|
|
||||||
const { data: session } = authClient.useSession();
|
const { data: session } = authClient.useSession();
|
||||||
const [decks, setDecks] = useState<TSharedDeck[]>(initialDecks);
|
const [decks, setDecks] = useState<ActionOutputDeck[]>(initialDecks);
|
||||||
const [defaultNoteTypeId, setDefaultNoteTypeId] = useState<number | null>(null);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -62,29 +64,7 @@ export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
|
|||||||
if (session?.user?.id) {
|
if (session?.user?.id) {
|
||||||
actionGetDecksByUserId(session.user.id).then((result) => {
|
actionGetDecksByUserId(session.user.id).then((result) => {
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setDecks(result.data as TSharedDeck[]);
|
setDecks(result.data);
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [session?.user?.id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (session?.user?.id) {
|
|
||||||
actionGetNoteTypesByUserId().then(async (result) => {
|
|
||||||
if (result.success && result.data) {
|
|
||||||
const basicNoteType = result.data.find(
|
|
||||||
(nt) => nt.name === "Basic Vocabulary"
|
|
||||||
);
|
|
||||||
if (basicNoteType) {
|
|
||||||
setDefaultNoteTypeId(basicNoteType.id);
|
|
||||||
} else if (result.data.length > 0) {
|
|
||||||
setDefaultNoteTypeId(result.data[0].id);
|
|
||||||
} else {
|
|
||||||
const createResult = await actionCreateDefaultBasicNoteType();
|
|
||||||
if (createResult.success && createResult.data) {
|
|
||||||
setDefaultNoteTypeId(createResult.data.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -113,11 +93,10 @@ export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
|
|||||||
toast.error(t("pleaseCreateFolder"));
|
toast.error(t("pleaseCreateFolder"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!defaultNoteTypeId) {
|
if (!searchResult?.entries?.length) {
|
||||||
toast.error("No note type available. Please try again.");
|
toast.error("No dictionary item to save. Please search first.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!searchResult?.entries?.length) return;
|
|
||||||
|
|
||||||
const deckSelect = document.getElementById("deck-select") as HTMLSelectElement;
|
const deckSelect = document.getElementById("deck-select") as HTMLSelectElement;
|
||||||
const deckId = deckSelect?.value ? Number(deckSelect.value) : decks[0]?.id;
|
const deckId = deckSelect?.value ? Number(deckSelect.value) : decks[0]?.id;
|
||||||
@@ -129,43 +108,38 @@ export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
|
|||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
|
|
||||||
const definition = searchResult.entries
|
|
||||||
.map((e) => e.definition)
|
|
||||||
.join(" | ");
|
|
||||||
|
|
||||||
const ipa = searchResult.entries[0]?.ipa || "";
|
|
||||||
const example = searchResult.entries
|
|
||||||
.map((e) => e.example)
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" | ") || "";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const noteResult = await actionCreateNote({
|
const hasIpa = searchResult.entries.some((e) => e.ipa);
|
||||||
noteTypeId: defaultNoteTypeId,
|
const hasSpaces = searchResult.standardForm.includes(" ");
|
||||||
fields: [searchResult.standardForm, definition, ipa, example],
|
let cardType: CardType = "WORD";
|
||||||
tags: ["dictionary"],
|
if (!hasIpa) {
|
||||||
|
cardType = "SENTENCE";
|
||||||
|
} else if (hasSpaces) {
|
||||||
|
cardType = "PHRASE";
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipa = searchResult.entries.find((e) => e.ipa)?.ipa || null;
|
||||||
|
const meanings = searchResult.entries.map((e) => ({
|
||||||
|
partOfSpeech: e.partOfSpeech || null,
|
||||||
|
definition: e.definition,
|
||||||
|
example: e.example || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const cardResult = await actionCreateCard({
|
||||||
|
deckId,
|
||||||
|
word: searchResult.standardForm,
|
||||||
|
ipa,
|
||||||
|
queryLang: getNativeName(queryLang),
|
||||||
|
cardType,
|
||||||
|
meanings,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!noteResult.success || !noteResult.data) {
|
if (!cardResult.success) {
|
||||||
toast.error(t("saveFailed"));
|
toast.error(cardResult.message || t("saveFailed"));
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const noteId = BigInt(noteResult.data.id);
|
|
||||||
|
|
||||||
await actionCreateCard({
|
|
||||||
noteId,
|
|
||||||
deckId,
|
|
||||||
ord: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
await actionCreateCard({
|
|
||||||
noteId,
|
|
||||||
deckId,
|
|
||||||
ord: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const deckName = decks.find((d) => d.id === deckId)?.name || "Unknown";
|
const deckName = decks.find((d) => d.id === deckId)?.name || "Unknown";
|
||||||
toast.success(t("savedToFolder", { folderName: deckName }));
|
toast.success(t("savedToFolder", { folderName: deckName }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -231,10 +205,10 @@ export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
|
|||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
{isSearching ? (
|
{isSearching ? (
|
||||||
<div className="text-center py-12">
|
<VStack align="center" className="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>
|
<Skeleton variant="circular" className="w-8 h-8 mb-3" />
|
||||||
<p className="text-gray-600">{t("searching")}</p>
|
<p className="text-gray-600">{t("searching")}</p>
|
||||||
</div>
|
</VStack>
|
||||||
) : query && !searchResult ? (
|
) : query && !searchResult ? (
|
||||||
<div className="text-center py-12 bg-white/20 rounded-lg">
|
<div className="text-center py-12 bg-white/20 rounded-lg">
|
||||||
<p className="text-gray-800 text-xl">{t("noResults")}</p>
|
<p className="text-gray-800 text-xl">{t("noResults")}</p>
|
||||||
@@ -248,18 +222,19 @@ export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
|
|||||||
{searchResult.standardForm}
|
{searchResult.standardForm}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 ml-4">
|
<HStack align="center" gap={2} className="ml-4">
|
||||||
{session && decks.length > 0 && (
|
{session && decks.length > 0 && (
|
||||||
<select
|
<Select
|
||||||
id="deck-select"
|
id="deck-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]"
|
variant="bordered"
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
{decks.map((deck) => (
|
{decks.map((deck) => (
|
||||||
<option key={deck.id} value={deck.id}>
|
<option key={deck.id} value={deck.id}>
|
||||||
{deck.name}
|
{deck.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
<LightButton
|
<LightButton
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
@@ -270,7 +245,7 @@ export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
|
|||||||
>
|
>
|
||||||
<Plus />
|
<Plus />
|
||||||
</LightButton>
|
</LightButton>
|
||||||
</div>
|
</HStack>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -2,17 +2,17 @@ import { DictionaryClient } from "./DictionaryClient";
|
|||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
|
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
|
||||||
import type { TSharedDeck } from "@/shared/anki-type";
|
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
||||||
|
|
||||||
export default async function DictionaryPage() {
|
export default async function DictionaryPage() {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
|
||||||
let decks: TSharedDeck[] = [];
|
let decks: ActionOutputDeck[] = [];
|
||||||
|
|
||||||
if (session?.user?.id) {
|
if (session?.user?.id) {
|
||||||
const result = await actionGetDecksByUserId(session.user.id as string);
|
const result = await actionGetDecksByUserId(session.user.id as string);
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
decks = result.data as TSharedDeck[];
|
decks = result.data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import {
|
|||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { CircleButton } from "@/design-system/base/button";
|
import { CircleButton } from "@/design-system/base/button";
|
||||||
|
import { Input } from "@/design-system/base/input";
|
||||||
|
import { Skeleton } from "@/design-system/feedback/skeleton";
|
||||||
|
import { HStack } from "@/design-system/layout/stack";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -148,18 +151,16 @@ export function ExploreClient({ initialPublicDecks }: ExploreClientProps) {
|
|||||||
<PageLayout>
|
<PageLayout>
|
||||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-6">
|
<HStack align="center" gap={2} className="mb-6">
|
||||||
<div className="relative flex-1">
|
<Input
|
||||||
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
variant="bordered"
|
||||||
<input
|
value={searchQuery}
|
||||||
type="text"
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
value={searchQuery}
|
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
placeholder={t("searchPlaceholder")}
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
leftIcon={<Search size={18} />}
|
||||||
placeholder={t("searchPlaceholder")}
|
containerClassName="flex-1"
|
||||||
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>
|
|
||||||
<CircleButton
|
<CircleButton
|
||||||
onClick={handleToggleSort}
|
onClick={handleToggleSort}
|
||||||
title={sortByFavorites ? t("sortByFavoritesActive") : t("sortByFavorites")}
|
title={sortByFavorites ? t("sortByFavoritesActive") : t("sortByFavorites")}
|
||||||
@@ -170,11 +171,11 @@ export function ExploreClient({ initialPublicDecks }: ExploreClientProps) {
|
|||||||
<CircleButton onClick={handleSearch}>
|
<CircleButton onClick={handleSearch}>
|
||||||
<Search size={18} />
|
<Search size={18} />
|
||||||
</CircleButton>
|
</CircleButton>
|
||||||
</div>
|
</HStack>
|
||||||
|
|
||||||
{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>
|
<Skeleton variant="circular" className="w-8 h-8 mx-auto mb-3" />
|
||||||
<p className="text-sm text-gray-500">{t("loading")}</p>
|
<p className="text-sm text-gray-500">{t("loading")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : sortedDecks.length === 0 ? (
|
) : sortedDecks.length === 0 ? (
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ 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 { VStack } from "@/design-system/layout/stack";
|
||||||
|
import { Skeleton } from "@/design-system/feedback/skeleton";
|
||||||
import { actionGetUserFavoriteDecks, actionToggleDeckFavorite } from "@/modules/deck/deck-action";
|
import { actionGetUserFavoriteDecks, actionToggleDeckFavorite } from "@/modules/deck/deck-action";
|
||||||
import type { ActionOutputUserFavoriteDeck } from "@/modules/deck/deck-action-dto";
|
import type { ActionOutputUserFavoriteDeck } from "@/modules/deck/deck-action-dto";
|
||||||
|
|
||||||
@@ -102,10 +104,10 @@ export function FavoritesClient({ initialFavorites }: FavoritesClientProps) {
|
|||||||
|
|
||||||
<CardList>
|
<CardList>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-8 text-center">
|
<VStack align="center" className="p-8">
|
||||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
|
<Skeleton variant="circular" className="w-8 h-8" />
|
||||||
<p className="text-sm text-gray-500">{t("loading")}</p>
|
<p className="text-sm text-gray-500">{t("loading")}</p>
|
||||||
</div>
|
</VStack>
|
||||||
) : favorites.length === 0 ? (
|
) : favorites.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">
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { Layers } from "lucide-react";
|
|
||||||
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
|
||||||
import type { ActionOutputCardStats } from "@/modules/card/card-action-dto";
|
|
||||||
import { PageLayout } from "@/components/ui/PageLayout";
|
|
||||||
import { PrimaryButton } from "@/design-system/base/button";
|
|
||||||
|
|
||||||
interface DeckWithStats extends ActionOutputDeck {
|
|
||||||
stats?: ActionOutputCardStats;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DeckSelectorProps {
|
|
||||||
decks: ActionOutputDeck[];
|
|
||||||
deckStats: Map<number, ActionOutputCardStats | undefined>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DeckSelector: React.FC<DeckSelectorProps> = ({ decks, deckStats }) => {
|
|
||||||
const t = useTranslations("memorize.deck_selector");
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const formatCardStats = (stats: ActionOutputCardStats | undefined): string => {
|
|
||||||
if (!stats) return t("noCards");
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (stats.new > 0) parts.push(`${t("new")}: ${stats.new}`);
|
|
||||||
if (stats.learning > 0) parts.push(`${t("learning")}: ${stats.learning}`);
|
|
||||||
if (stats.review > 0) parts.push(`${t("review")}: ${stats.review}`);
|
|
||||||
if (stats.due > 0) parts.push(`${t("due")}: ${stats.due}`);
|
|
||||||
return parts.length > 0 ? parts.join(" • ") : t("noCards");
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDueCount = (deckId: number): number => {
|
|
||||||
const stats = deckStats.get(deckId);
|
|
||||||
return stats?.due ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
{decks.length === 0 ? (
|
|
||||||
<div className="text-center">
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-4">
|
|
||||||
{t("noDecks")}
|
|
||||||
</h1>
|
|
||||||
<Link href="/decks">
|
|
||||||
<PrimaryButton className="px-6 py-2">
|
|
||||||
{t("goToDecks")}
|
|
||||||
</PrimaryButton>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-6">
|
|
||||||
{t("selectDeck")}
|
|
||||||
</h1>
|
|
||||||
<div className="border border-gray-200 rounded-lg max-h-96 overflow-y-auto">
|
|
||||||
{decks
|
|
||||||
.toSorted((a, b) => a.id - b.id)
|
|
||||||
.map((deck) => {
|
|
||||||
const stats = deckStats.get(deck.id);
|
|
||||||
const dueCount = getDueCount(deck.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={deck.id}
|
|
||||||
onClick={() =>
|
|
||||||
router.push(`/memorize?deck_id=${deck.id}`)
|
|
||||||
}
|
|
||||||
className="flex flex-row items-center p-4 gap-3 hover:cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0"
|
|
||||||
>
|
|
||||||
<div className="shrink-0">
|
|
||||||
<Layers className="text-gray-600 w-5 h-5" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-medium text-gray-900">
|
|
||||||
{deck.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
{formatCardStats(stats)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{dueCount > 0 && (
|
|
||||||
<div className="bg-blue-500 text-white text-xs font-bold px-2 py-1 rounded-full">
|
|
||||||
{dueCount}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="text-gray-400">
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 5l7 7-7 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { DeckSelector };
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect, useTransition } from "react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import localFont from "next/font/local";
|
|
||||||
import { Layers, Check, Clock } from "lucide-react";
|
|
||||||
import type { ActionOutputCardWithNote, ActionOutputScheduledCard } from "@/modules/card/card-action-dto";
|
|
||||||
import { actionGetCardsForReview, actionAnswerCard } from "@/modules/card/card-action";
|
|
||||||
import { PageLayout } from "@/components/ui/PageLayout";
|
|
||||||
import { LightButton } from "@/design-system/base/button";
|
|
||||||
|
|
||||||
const myFont = localFont({
|
|
||||||
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
|
|
||||||
});
|
|
||||||
|
|
||||||
interface MemorizeProps {
|
|
||||||
deckId: number;
|
|
||||||
deckName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReviewEase = 1 | 2 | 3 | 4;
|
|
||||||
|
|
||||||
const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
|
|
||||||
const t = useTranslations("memorize.review");
|
|
||||||
const router = useRouter();
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
|
|
||||||
const [cards, setCards] = useState<ActionOutputCardWithNote[]>([]);
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
|
||||||
const [showAnswer, setShowAnswer] = useState(false);
|
|
||||||
const [lastScheduled, setLastScheduled] = useState<ActionOutputScheduledCard | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadCards();
|
|
||||||
}, [deckId]);
|
|
||||||
|
|
||||||
const loadCards = () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
startTransition(async () => {
|
|
||||||
const result = await actionGetCardsForReview({ deckId, limit: 50 });
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setCards(result.data);
|
|
||||||
setCurrentIndex(0);
|
|
||||||
setShowAnswer(false);
|
|
||||||
setLastScheduled(null);
|
|
||||||
} else {
|
|
||||||
setError(result.message);
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCurrentCard = (): ActionOutputCardWithNote | null => {
|
|
||||||
return cards[currentIndex] ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getNoteFields = (card: ActionOutputCardWithNote): string[] => {
|
|
||||||
return card.note.flds.split('\x1f');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleShowAnswer = () => {
|
|
||||||
setShowAnswer(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAnswer = (ease: ReviewEase) => {
|
|
||||||
const card = getCurrentCard();
|
|
||||||
if (!card) return;
|
|
||||||
|
|
||||||
startTransition(async () => {
|
|
||||||
const result = await actionAnswerCard({
|
|
||||||
cardId: BigInt(card.id),
|
|
||||||
ease,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setLastScheduled(result.data.scheduled);
|
|
||||||
|
|
||||||
const remainingCards = cards.filter((_, idx) => idx !== currentIndex);
|
|
||||||
setCards(remainingCards);
|
|
||||||
|
|
||||||
if (remainingCards.length === 0) {
|
|
||||||
setCurrentIndex(0);
|
|
||||||
} else if (currentIndex >= remainingCards.length) {
|
|
||||||
setCurrentIndex(remainingCards.length - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowAnswer(false);
|
|
||||||
} else {
|
|
||||||
setError(result.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatNextReview = (scheduled: ActionOutputScheduledCard): string => {
|
|
||||||
const now = new Date();
|
|
||||||
const nextReview = new Date(scheduled.nextReviewDate);
|
|
||||||
const diffMs = nextReview.getTime() - now.getTime();
|
|
||||||
|
|
||||||
if (diffMs < 0) return t("now");
|
|
||||||
|
|
||||||
const diffMins = Math.floor(diffMs / 60000);
|
|
||||||
const diffHours = Math.floor(diffMs / 3600000);
|
|
||||||
const diffDays = Math.floor(diffMs / 86400000);
|
|
||||||
|
|
||||||
if (diffMins < 1) return t("lessThanMinute");
|
|
||||||
if (diffMins < 60) return t("inMinutes", { count: diffMins });
|
|
||||||
if (diffHours < 24) return t("inHours", { count: diffHours });
|
|
||||||
if (diffDays < 30) return t("inDays", { count: diffDays });
|
|
||||||
return t("inMonths", { count: Math.floor(diffDays / 30) });
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatInterval = (ivl: number): string => {
|
|
||||||
if (ivl < 1) return t("minutes");
|
|
||||||
if (ivl < 30) return t("days", { count: ivl });
|
|
||||||
return t("months", { count: Math.floor(ivl / 30) });
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 mx-auto mb-4"></div>
|
|
||||||
<p className="text-gray-600">{t("loading")}</p>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-red-600 mb-4">{error}</p>
|
|
||||||
<LightButton onClick={() => router.push("/memorize")} className="px-4 py-2">
|
|
||||||
{t("backToDecks")}
|
|
||||||
</LightButton>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cards.length === 0) {
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<div className="text-green-500 mb-4">
|
|
||||||
<Check className="w-16 h-16 mx-auto" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-800 mb-2">{t("allDone")}</h2>
|
|
||||||
<p className="text-gray-600 mb-6">{t("allDoneDesc")}</p>
|
|
||||||
<LightButton onClick={() => router.push("/memorize")} className="px-4 py-2">
|
|
||||||
{t("backToDecks")}
|
|
||||||
</LightButton>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentCard = getCurrentCard()!;
|
|
||||||
const fields = getNoteFields(currentCard);
|
|
||||||
const front = fields[0] ?? "";
|
|
||||||
const back = fields[1] ?? "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="flex items-center gap-2 text-gray-600">
|
|
||||||
<Layers className="w-5 h-5" />
|
|
||||||
<span className="font-medium">{deckName}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
{t("progress", { current: currentIndex + 1, total: cards.length + currentIndex })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2 mb-6">
|
|
||||||
<div
|
|
||||||
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
|
||||||
style={{ width: `${Math.max(0, ((currentIndex) / (cards.length + currentIndex)) * 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{lastScheduled && (
|
|
||||||
<div className="mb-4 p-3 bg-gray-50 rounded-lg text-sm text-gray-600">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Clock className="w-4 h-4" />
|
|
||||||
<span>
|
|
||||||
{t("nextReview")}: {formatNextReview(lastScheduled)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={`bg-white border border-gray-200 rounded-xl shadow-sm mb-6 ${myFont.className}`}>
|
|
||||||
<div className="p-8 min-h-[20dvh] flex items-center justify-center">
|
|
||||||
<div className="text-gray-900 text-xl md:text-2xl text-center">
|
|
||||||
{front}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showAnswer && (
|
|
||||||
<>
|
|
||||||
<div className="border-t border-gray-200" />
|
|
||||||
<div className="p-8 min-h-[20dvh] flex items-center justify-center bg-gray-50 rounded-b-xl">
|
|
||||||
<div className="text-gray-900 text-xl md:text-2xl text-center">
|
|
||||||
{back}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-center gap-4 mb-6 text-sm text-gray-500">
|
|
||||||
<span>{t("interval")}: {formatInterval(currentCard.ivl)}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{t("ease")}: {currentCard.factor / 10}%</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{t("lapses")}: {currentCard.lapses}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-center">
|
|
||||||
{!showAnswer ? (
|
|
||||||
<LightButton
|
|
||||||
onClick={handleShowAnswer}
|
|
||||||
disabled={isPending}
|
|
||||||
className="px-8 py-3 text-lg rounded-full"
|
|
||||||
>
|
|
||||||
{t("showAnswer")}
|
|
||||||
</LightButton>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-wrap justify-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => handleAnswer(1)}
|
|
||||||
disabled={isPending}
|
|
||||||
className="flex flex-col items-center px-6 py-3 rounded-xl bg-red-100 hover:bg-red-200 text-red-700 transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<span className="font-medium">{t("again")}</span>
|
|
||||||
<span className="text-xs opacity-75"><1{t("minAbbr")}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => handleAnswer(2)}
|
|
||||||
disabled={isPending}
|
|
||||||
className="flex flex-col items-center px-6 py-3 rounded-xl bg-orange-100 hover:bg-orange-200 text-orange-700 transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<span className="font-medium">{t("hard")}</span>
|
|
||||||
<span className="text-xs opacity-75">6{t("minAbbr")}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => handleAnswer(3)}
|
|
||||||
disabled={isPending}
|
|
||||||
className="flex flex-col items-center px-6 py-3 rounded-xl bg-green-100 hover:bg-green-200 text-green-700 transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<span className="font-medium">{t("good")}</span>
|
|
||||||
<span className="text-xs opacity-75">10{t("minAbbr")}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => handleAnswer(4)}
|
|
||||||
disabled={isPending}
|
|
||||||
className="flex flex-col items-center px-6 py-3 rounded-xl bg-blue-100 hover:bg-blue-200 text-blue-700 transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<span className="font-medium">{t("easy")}</span>
|
|
||||||
<span className="text-xs opacity-75">4{t("dayAbbr")}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { Memorize };
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import { isNonNegativeInteger } from "@/utils/random";
|
|
||||||
import { DeckSelector } from "./DeckSelector";
|
|
||||||
import { Memorize } from "./Memorize";
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
|
|
||||||
import { actionGetCardStats } from "@/modules/card/card-action";
|
|
||||||
|
|
||||||
export default async function MemorizePage({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<{ deck_id?: string; }>;
|
|
||||||
}) {
|
|
||||||
const deckIdParam = (await searchParams).deck_id;
|
|
||||||
|
|
||||||
const t = await getTranslations("memorize.page");
|
|
||||||
|
|
||||||
const deckId = deckIdParam
|
|
||||||
? isNonNegativeInteger(deckIdParam)
|
|
||||||
? parseInt(deckIdParam)
|
|
||||||
: null
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
|
||||||
if (!session) redirect("/login?redirect=/memorize");
|
|
||||||
|
|
||||||
if (!deckId) {
|
|
||||||
const decksResult = await actionGetDecksByUserId(session.user.id);
|
|
||||||
const decks = decksResult.data ?? [];
|
|
||||||
|
|
||||||
const deckStats = new Map<number, Awaited<ReturnType<typeof actionGetCardStats>>["data"]>();
|
|
||||||
for (const deck of decks) {
|
|
||||||
const statsResult = await actionGetCardStats({ deckId: deck.id });
|
|
||||||
if (statsResult.success && statsResult.data) {
|
|
||||||
deckStats.set(deck.id, statsResult.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DeckSelector
|
|
||||||
decks={decks}
|
|
||||||
deckStats={deckStats}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const decksResult = await actionGetDecksByUserId(session.user.id);
|
|
||||||
const deck = decksResult.data?.find(d => d.id === deckId);
|
|
||||||
|
|
||||||
if (!deck) {
|
|
||||||
redirect("/memorize");
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Memorize deckId={deckId} deckName={deck.name} />;
|
|
||||||
}
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useCallback, useRef } from "react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { PageLayout } from "@/components/ui/PageLayout";
|
|
||||||
import { PrimaryButton, LightButton } from "@/design-system/base/button";
|
|
||||||
import { Input } from "@/design-system/base/input";
|
|
||||||
import { Select } from "@/design-system/base/select";
|
|
||||||
import { Card } from "@/design-system/base/card";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Upload, FileImage, Loader2 } from "lucide-react";
|
|
||||||
import { actionProcessOCR } from "@/modules/ocr/ocr-action";
|
|
||||||
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
|
||||||
|
|
||||||
interface ActionOutputProcessOCR {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
data?: {
|
|
||||||
pairsCreated: number;
|
|
||||||
sourceLanguage?: string;
|
|
||||||
targetLanguage?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OCRClientProps {
|
|
||||||
initialDecks: ActionOutputDeck[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OCRClient({ initialDecks }: OCRClientProps) {
|
|
||||||
const t = useTranslations("ocr");
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [decks, setDecks] = useState<ActionOutputDeck[]>(initialDecks);
|
|
||||||
|
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
|
||||||
const [selectedDeckId, setSelectedDeckId] = useState<number | null>(
|
|
||||||
initialDecks.length > 0 ? initialDecks[0].id : null
|
|
||||||
);
|
|
||||||
const [sourceLanguage, setSourceLanguage] = useState<string>("");
|
|
||||||
const [targetLanguage, setTargetLanguage] = useState<string>("");
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
|
||||||
const [ocrResult, setOcrResult] = useState<ActionOutputProcessOCR | null>(null);
|
|
||||||
|
|
||||||
const handleFileChange = useCallback((file: File | null) => {
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
if (!file.type.startsWith("image/")) {
|
|
||||||
toast.error(t("invalidFileType"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(file);
|
|
||||||
setPreviewUrl(url);
|
|
||||||
setSelectedFile(file);
|
|
||||||
setOcrResult(null);
|
|
||||||
}, [t]);
|
|
||||||
|
|
||||||
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const file = e.dataTransfer.files[0];
|
|
||||||
handleFileChange(file);
|
|
||||||
}, [handleFileChange]);
|
|
||||||
|
|
||||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fileToBase64 = async (file: File): Promise<string> => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
const result = reader.result as string;
|
|
||||||
const base64 = result.split(",")[1];
|
|
||||||
resolve(base64);
|
|
||||||
};
|
|
||||||
reader.onerror = reject;
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleProcess = async () => {
|
|
||||||
if (!selectedFile) {
|
|
||||||
toast.error(t("noImage"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedDeckId) {
|
|
||||||
toast.error(t("noDeck"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsProcessing(true);
|
|
||||||
setOcrResult(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const base64 = await fileToBase64(selectedFile);
|
|
||||||
|
|
||||||
const result = await actionProcessOCR({
|
|
||||||
imageBase64: base64,
|
|
||||||
deckId: selectedDeckId,
|
|
||||||
sourceLanguage: sourceLanguage || undefined,
|
|
||||||
targetLanguage: targetLanguage || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setOcrResult(result);
|
|
||||||
const deckName = decks.find(d => d.id === selectedDeckId)?.name || "";
|
|
||||||
toast.success(t("ocrSuccess", { count: result.data.pairsCreated, deck: deckName }));
|
|
||||||
} else {
|
|
||||||
toast.error(result.message || t("ocrFailed"));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error(t("processingFailed"));
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!ocrResult || !selectedDeckId) {
|
|
||||||
toast.error(t("noResultsToSave"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const deckName = decks.find(d => d.id === selectedDeckId)?.name || "Unknown";
|
|
||||||
toast.success(t("savedToDeck", { deckName }));
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(t("saveFailed"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearImage = () => {
|
|
||||||
if (previewUrl) {
|
|
||||||
URL.revokeObjectURL(previewUrl);
|
|
||||||
}
|
|
||||||
setPreviewUrl(null);
|
|
||||||
setSelectedFile(null);
|
|
||||||
setOcrResult(null);
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageLayout variant="centered-card">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<Card variant="bordered" padding="lg">
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Upload Section */}
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
|
||||||
{t("uploadSection")}
|
|
||||||
</h2>
|
|
||||||
<div
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-primary-500 hover:bg-primary-50 transition-colors"
|
|
||||||
>
|
|
||||||
{previewUrl ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<img
|
|
||||||
src={previewUrl}
|
|
||||||
alt="Preview"
|
|
||||||
className="mx-auto max-w-full h-64 object-contain rounded-lg"
|
|
||||||
/>
|
|
||||||
<p className="text-gray-600">{t("changeImage")}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Upload className="mx-auto h-12 w-12 text-gray-400" />
|
|
||||||
<p className="text-gray-600">{t("dropOrClick")}</p>
|
|
||||||
<p className="text-sm text-gray-500">{t("supportedFormats")}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={(e) => handleFileChange(e.target.files?.[0] || null)}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Deck Selection */}
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
|
||||||
{t("deckSelection")}
|
|
||||||
</h2>
|
|
||||||
<Select
|
|
||||||
value={selectedDeckId?.toString() || ""}
|
|
||||||
onChange={(e) => setSelectedDeckId(Number(e.target.value))}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<option value="">{t("selectDeck")}</option>
|
|
||||||
{decks.map((deck) => (
|
|
||||||
<option key={deck.id} value={deck.id}>
|
|
||||||
{deck.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Language Hints */}
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
|
||||||
{t("languageHints")}
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder={t("sourceLanguagePlaceholder")}
|
|
||||||
value={sourceLanguage}
|
|
||||||
onChange={(e) => setSourceLanguage(e.target.value)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder={t("targetLanguagePlaceholder")}
|
|
||||||
value={targetLanguage}
|
|
||||||
onChange={(e) => setTargetLanguage(e.target.value)}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Process Button */}
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<PrimaryButton
|
|
||||||
onClick={handleProcess}
|
|
||||||
disabled={!selectedFile || !selectedDeckId || isProcessing}
|
|
||||||
loading={isProcessing}
|
|
||||||
className="px-8 py-3 text-lg"
|
|
||||||
>
|
|
||||||
{t("processButton")}
|
|
||||||
</PrimaryButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Results Preview */}
|
|
||||||
{ocrResult && ocrResult.data && (
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
|
||||||
{t("resultsPreview")}
|
|
||||||
</h2>
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="text-center py-4">
|
|
||||||
<p className="text-gray-800">{t("extractedPairs", { count: ocrResult.data.pairsCreated })}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{ocrResult.data.sourceLanguage && (
|
|
||||||
<div className="mt-4 text-sm text-gray-500">
|
|
||||||
{t("detectedSourceLanguage")}: {ocrResult.data.sourceLanguage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{ocrResult.data.targetLanguage && (
|
|
||||||
<div className="mt-1 text-sm text-gray-500">
|
|
||||||
{t("detectedTargetLanguage")}: {ocrResult.data.targetLanguage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex justify-center">
|
|
||||||
<LightButton
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!selectedDeckId}
|
|
||||||
className="px-6 py-2"
|
|
||||||
>
|
|
||||||
{t("saveButton")}
|
|
||||||
</LightButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</PageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { OCRClient } from "./OCRClient";
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
|
|
||||||
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
|
||||||
|
|
||||||
export default async function OCRPage() {
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
|
||||||
|
|
||||||
let decks: ActionOutputDeck[] = [];
|
|
||||||
|
|
||||||
if (session?.user?.id) {
|
|
||||||
const result = await actionGetDecksByUserId(session.user.id as string);
|
|
||||||
if (result.success && result.data) {
|
|
||||||
decks = result.data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return <OCRClient initialDecks={decks} />;
|
|
||||||
}
|
|
||||||
@@ -70,7 +70,7 @@ export default function SrtPlayerPage() {
|
|||||||
uploadVideo((url) => {
|
uploadVideo((url) => {
|
||||||
setVideoUrl(url);
|
setVideoUrl(url);
|
||||||
}, (error) => {
|
}, (error) => {
|
||||||
toast.error(t('videoUploadFailed') + ': ' + error.message);
|
toast.error(srtT('videoUploadFailed') + ': ' + error.message);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ export default function SrtPlayerPage() {
|
|||||||
uploadSubtitle((url) => {
|
uploadSubtitle((url) => {
|
||||||
setSubtitleUrl(url);
|
setSubtitleUrl(url);
|
||||||
}, (error) => {
|
}, (error) => {
|
||||||
toast.error(t('subtitleUploadFailed') + ': ' + error.message);
|
toast.error(srtT('subtitleUploadFailed') + ': ' + error.message);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LightButton } from "@/design-system/base/button";
|
import { LightButton, IconClick } from "@/design-system/base/button";
|
||||||
import { IconClick } from "@/design-system/base/button";
|
import { Input } from "@/design-system/base/input";
|
||||||
|
import { Textarea } from "@/design-system/base/textarea";
|
||||||
import { IMAGES } from "@/config/images";
|
import { IMAGES } from "@/config/images";
|
||||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +19,38 @@ import { genIPA, genLanguage } from "@/modules/translator/translator-action";
|
|||||||
import { PageLayout } from "@/components/ui/PageLayout";
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
||||||
|
|
||||||
|
const TTS_LANGUAGES = [
|
||||||
|
{ value: "Auto", label: "auto" },
|
||||||
|
{ value: "Chinese", label: "chinese" },
|
||||||
|
{ value: "English", label: "english" },
|
||||||
|
{ value: "Japanese", label: "japanese" },
|
||||||
|
{ value: "Korean", label: "korean" },
|
||||||
|
{ value: "French", label: "french" },
|
||||||
|
{ value: "German", label: "german" },
|
||||||
|
{ value: "Italian", label: "italian" },
|
||||||
|
{ value: "Spanish", label: "spanish" },
|
||||||
|
{ value: "Portuguese", label: "portuguese" },
|
||||||
|
{ value: "Russian", label: "russian" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type TTSLabel = typeof TTS_LANGUAGES[number]["label"];
|
||||||
|
|
||||||
|
function getLanguageLabel(t: (key: string) => string, label: TTSLabel): string {
|
||||||
|
switch (label) {
|
||||||
|
case "auto": return t("languages.auto");
|
||||||
|
case "chinese": return t("languages.chinese");
|
||||||
|
case "english": return t("languages.english");
|
||||||
|
case "japanese": return t("languages.japanese");
|
||||||
|
case "korean": return t("languages.korean");
|
||||||
|
case "french": return t("languages.french");
|
||||||
|
case "german": return t("languages.german");
|
||||||
|
case "italian": return t("languages.italian");
|
||||||
|
case "spanish": return t("languages.spanish");
|
||||||
|
case "portuguese": return t("languages.portuguese");
|
||||||
|
case "russian": return t("languages.russian");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function TextSpeakerPage() {
|
export default function TextSpeakerPage() {
|
||||||
const t = useTranslations("text_speaker");
|
const t = useTranslations("text_speaker");
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -30,6 +63,8 @@ export default function TextSpeakerPage() {
|
|||||||
const [autopause, setAutopause] = useState(true);
|
const [autopause, setAutopause] = useState(true);
|
||||||
const textRef = useRef("");
|
const textRef = useRef("");
|
||||||
const [language, setLanguage] = useState<string | null>(null);
|
const [language, setLanguage] = useState<string | null>(null);
|
||||||
|
const [selectedLanguage, setSelectedLanguage] = useState<string>("Auto");
|
||||||
|
const [customLanguage, setCustomLanguage] = useState<string>("");
|
||||||
const [ipa, setIPA] = useState<string>("");
|
const [ipa, setIPA] = useState<string>("");
|
||||||
const objurlRef = useRef<string | null>(null);
|
const objurlRef = useRef<string | null>(null);
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
@@ -93,8 +128,15 @@ export default function TextSpeakerPage() {
|
|||||||
} else {
|
} else {
|
||||||
// 第一次播放
|
// 第一次播放
|
||||||
try {
|
try {
|
||||||
let theLanguage = language;
|
let theLanguage: string;
|
||||||
if (!theLanguage) {
|
|
||||||
|
if (customLanguage.trim()) {
|
||||||
|
theLanguage = customLanguage.trim();
|
||||||
|
} else if (selectedLanguage !== "Auto") {
|
||||||
|
theLanguage = selectedLanguage;
|
||||||
|
} else if (language) {
|
||||||
|
theLanguage = language;
|
||||||
|
} else {
|
||||||
const tmp_language = await genLanguage(textRef.current.slice(0, 30));
|
const tmp_language = await genLanguage(textRef.current.slice(0, 30));
|
||||||
setLanguage(tmp_language);
|
setLanguage(tmp_language);
|
||||||
theLanguage = tmp_language;
|
theLanguage = tmp_language;
|
||||||
@@ -102,7 +144,6 @@ export default function TextSpeakerPage() {
|
|||||||
|
|
||||||
theLanguage = theLanguage.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
|
theLanguage = theLanguage.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
|
||||||
|
|
||||||
// 检查语言是否在 TTS 支持列表中
|
|
||||||
const supportedLanguages: TTS_SUPPORTED_LANGUAGES[] = [
|
const supportedLanguages: TTS_SUPPORTED_LANGUAGES[] = [
|
||||||
"Auto", "Chinese", "English", "German", "Italian", "Portuguese",
|
"Auto", "Chinese", "English", "German", "Italian", "Portuguese",
|
||||||
"Spanish", "Japanese", "Korean", "French", "Russian"
|
"Spanish", "Japanese", "Korean", "French", "Russian"
|
||||||
@@ -138,6 +179,8 @@ export default function TextSpeakerPage() {
|
|||||||
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
textRef.current = e.target.value.trim();
|
textRef.current = e.target.value.trim();
|
||||||
setLanguage(null);
|
setLanguage(null);
|
||||||
|
setSelectedLanguage("Auto");
|
||||||
|
setCustomLanguage("");
|
||||||
setIPA("");
|
setIPA("");
|
||||||
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
||||||
objurlRef.current = null;
|
objurlRef.current = null;
|
||||||
@@ -226,11 +269,12 @@ export default function TextSpeakerPage() {
|
|||||||
style={{ fontFamily: "Times New Roman, serif" }}
|
style={{ fontFamily: "Times New Roman, serif" }}
|
||||||
>
|
>
|
||||||
{/* 文本输入框 */}
|
{/* 文本输入框 */}
|
||||||
<textarea
|
<Textarea
|
||||||
className="text-2xl resize-none focus:outline-0 min-h-64 w-full border-gray-200 border-b p-4"
|
variant="bordered"
|
||||||
|
className="text-2xl min-h-64"
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
></textarea>
|
/>
|
||||||
{/* IPA 显示区域 */}
|
{/* IPA 显示区域 */}
|
||||||
{(ipa.length !== 0 && (
|
{(ipa.length !== 0 && (
|
||||||
<div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b px-4">
|
<div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b px-4">
|
||||||
@@ -317,6 +361,40 @@ export default function TextSpeakerPage() {
|
|||||||
alt="save"
|
alt="save"
|
||||||
className={`${saving ? "bg-gray-200" : ""}`}
|
className={`${saving ? "bg-gray-200" : ""}`}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
|
{/* 语言选择器 */}
|
||||||
|
<div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center">
|
||||||
|
<span className="text-sm text-gray-600">{t("language")}</span>
|
||||||
|
{TTS_LANGUAGES.slice(0, 6).map((lang) => (
|
||||||
|
<LightButton
|
||||||
|
key={lang.value}
|
||||||
|
selected={!customLanguage && selectedLanguage === lang.value}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedLanguage(lang.value);
|
||||||
|
setCustomLanguage("");
|
||||||
|
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
||||||
|
objurlRef.current = null;
|
||||||
|
setPause(true);
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{getLanguageLabel(t, lang.label)}
|
||||||
|
</LightButton>
|
||||||
|
))}
|
||||||
|
<Input
|
||||||
|
variant="bordered"
|
||||||
|
size="sm"
|
||||||
|
value={customLanguage}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCustomLanguage(e.target.value);
|
||||||
|
setSelectedLanguage("Auto");
|
||||||
|
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
||||||
|
objurlRef.current = null;
|
||||||
|
setPause(true);
|
||||||
|
}}
|
||||||
|
placeholder={t("customLanguage")}
|
||||||
|
className="w-auto min-w-[120px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{/* 功能开关按钮 */}
|
{/* 功能开关按钮 */}
|
||||||
<div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center">
|
<div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center">
|
||||||
<LightButton
|
<LightButton
|
||||||
|
|||||||
@@ -1,49 +1,85 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LightButton, PrimaryButton, IconClick } from "@/design-system/base/button";
|
import { LightButton, PrimaryButton, IconClick, CircleButton } from "@/design-system/base/button";
|
||||||
|
import { Input } from "@/design-system/base/input";
|
||||||
|
import { Textarea } from "@/design-system/base/textarea";
|
||||||
import { Select } from "@/design-system/base/select";
|
import { Select } from "@/design-system/base/select";
|
||||||
import { IMAGES } from "@/config/images";
|
import { IMAGES } from "@/config/images";
|
||||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { actionTranslateText } from "@/modules/translator/translator-action";
|
import { actionTranslateText } from "@/modules/translator/translator-action";
|
||||||
|
import { actionCreateCard } from "@/modules/card/card-action";
|
||||||
|
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
|
||||||
|
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
||||||
|
import type { CardType } from "@/modules/card/card-action-dto";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
||||||
import { TSharedTranslationResult } from "@/shared/translator-type";
|
import { TSharedTranslationResult } from "@/shared/translator-type";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
const SOURCE_LANGUAGES = [
|
const SOURCE_LANGUAGES = [
|
||||||
{ value: "Auto", labelKey: "auto" },
|
{ value: "Auto", label: "auto" },
|
||||||
{ value: "Chinese", labelKey: "chinese" },
|
{ value: "Chinese", label: "chinese" },
|
||||||
{ value: "English", labelKey: "english" },
|
{ value: "English", label: "english" },
|
||||||
{ value: "Japanese", labelKey: "japanese" },
|
{ value: "Japanese", label: "japanese" },
|
||||||
{ value: "Korean", labelKey: "korean" },
|
{ value: "Korean", label: "korean" },
|
||||||
{ value: "French", labelKey: "french" },
|
{ value: "French", label: "french" },
|
||||||
{ value: "German", labelKey: "german" },
|
{ value: "German", label: "german" },
|
||||||
{ value: "Italian", labelKey: "italian" },
|
{ value: "Italian", label: "italian" },
|
||||||
{ value: "Spanish", labelKey: "spanish" },
|
{ value: "Spanish", label: "spanish" },
|
||||||
{ value: "Portuguese", labelKey: "portuguese" },
|
{ value: "Portuguese", label: "portuguese" },
|
||||||
{ value: "Russian", labelKey: "russian" },
|
{ value: "Russian", label: "russian" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const TARGET_LANGUAGES = [
|
const TARGET_LANGUAGES = [
|
||||||
{ value: "Chinese", labelKey: "chinese" },
|
{ value: "Chinese", label: "chinese" },
|
||||||
{ value: "English", labelKey: "english" },
|
{ value: "English", label: "english" },
|
||||||
{ value: "Japanese", labelKey: "japanese" },
|
{ value: "Japanese", label: "japanese" },
|
||||||
{ value: "Korean", labelKey: "korean" },
|
{ value: "Korean", label: "korean" },
|
||||||
{ value: "French", labelKey: "french" },
|
{ value: "French", label: "french" },
|
||||||
{ value: "German", labelKey: "german" },
|
{ value: "German", label: "german" },
|
||||||
{ value: "Italian", labelKey: "italian" },
|
{ value: "Italian", label: "italian" },
|
||||||
{ value: "Spanish", labelKey: "spanish" },
|
{ value: "Spanish", label: "spanish" },
|
||||||
{ value: "Portuguese", labelKey: "portuguese" },
|
{ value: "Portuguese", label: "portuguese" },
|
||||||
{ value: "Russian", labelKey: "russian" },
|
{ value: "Russian", label: "russian" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
type LangLabel = typeof SOURCE_LANGUAGES[number]["label"];
|
||||||
|
|
||||||
|
function getLangLabel(t: (key: string) => string, label: LangLabel): string {
|
||||||
|
switch (label) {
|
||||||
|
case "auto": return t("auto");
|
||||||
|
case "chinese": return t("chinese");
|
||||||
|
case "english": return t("english");
|
||||||
|
case "japanese": return t("japanese");
|
||||||
|
case "korean": return t("korean");
|
||||||
|
case "french": return t("french");
|
||||||
|
case "german": return t("german");
|
||||||
|
case "italian": return t("italian");
|
||||||
|
case "spanish": return t("spanish");
|
||||||
|
case "portuguese": return t("portuguese");
|
||||||
|
case "russian": return t("russian");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimated button width in pixels (including gap)
|
||||||
|
const BUTTON_WIDTH = 80;
|
||||||
|
const LABEL_WIDTH = 100;
|
||||||
|
const INPUT_WIDTH = 140;
|
||||||
|
const IPA_BUTTON_WIDTH = 100;
|
||||||
|
|
||||||
export default function TranslatorPage() {
|
export default function TranslatorPage() {
|
||||||
const t = useTranslations("translator");
|
const t = useTranslations("translator");
|
||||||
|
|
||||||
const taref = useRef<HTMLTextAreaElement>(null);
|
const taref = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const sourceContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const targetContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const [sourceLanguage, setSourceLanguage] = useState<string>("Auto");
|
const [sourceLanguage, setSourceLanguage] = useState<string>("Auto");
|
||||||
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
|
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
|
||||||
|
const [customSourceLanguage, setCustomSourceLanguage] = useState<string>("");
|
||||||
|
const [customTargetLanguage, setCustomTargetLanguage] = useState<string>("");
|
||||||
const [translationResult, setTranslationResult] = useState<TSharedTranslationResult | null>(null);
|
const [translationResult, setTranslationResult] = useState<TSharedTranslationResult | null>(null);
|
||||||
const [needIpa, setNeedIpa] = useState(true);
|
const [needIpa, setNeedIpa] = useState(true);
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
@@ -52,38 +88,72 @@ export default function TranslatorPage() {
|
|||||||
sourceLanguage: string;
|
sourceLanguage: string;
|
||||||
targetLanguage: string;
|
targetLanguage: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [sourceButtonCount, setSourceButtonCount] = useState(2);
|
||||||
|
const [targetButtonCount, setTargetButtonCount] = useState(2);
|
||||||
const { load, play } = useAudioPlayer();
|
const { load, play } = useAudioPlayer();
|
||||||
const lastTTS = useRef({
|
|
||||||
text: "",
|
|
||||||
url: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const tts = async (text: string, locale: string) => {
|
const { data: session } = authClient.useSession();
|
||||||
if (lastTTS.current.text !== text) {
|
const [decks, setDecks] = useState<ActionOutputDeck[]>([]);
|
||||||
try {
|
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||||
// Map language name to TTS format
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
let theLanguage = locale.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
|
|
||||||
|
|
||||||
// Check if language is in TTS supported list
|
useEffect(() => {
|
||||||
const supportedLanguages: TTS_SUPPORTED_LANGUAGES[] = [
|
if (session?.user?.id) {
|
||||||
"Auto", "Chinese", "English", "German", "Italian", "Portuguese",
|
actionGetDecksByUserId(session.user.id).then((result) => {
|
||||||
"Spanish", "Japanese", "Korean", "French", "Russian"
|
if (result.success && result.data) {
|
||||||
];
|
setDecks(result.data);
|
||||||
|
|
||||||
if (!supportedLanguages.includes(theLanguage as TTS_SUPPORTED_LANGUAGES)) {
|
|
||||||
theLanguage = "Auto";
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
const url = await getTTSUrl(text, theLanguage as TTS_SUPPORTED_LANGUAGES);
|
|
||||||
await load(url);
|
|
||||||
await play();
|
|
||||||
lastTTS.current.text = text;
|
|
||||||
lastTTS.current.url = url;
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Failed to generate audio");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
}, [session?.user?.id]);
|
||||||
|
|
||||||
|
// Calculate how many buttons to show based on container width
|
||||||
|
const calculateButtonCount = useCallback((containerWidth: number, hasIpa: boolean) => {
|
||||||
|
// Reserve space for label, input, and IPA button (for source)
|
||||||
|
const reservedWidth = LABEL_WIDTH + INPUT_WIDTH + (hasIpa ? IPA_BUTTON_WIDTH : 0);
|
||||||
|
const availableWidth = containerWidth - reservedWidth;
|
||||||
|
return Math.max(0, Math.floor(availableWidth / BUTTON_WIDTH));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateButtonCounts = () => {
|
||||||
|
if (sourceContainerRef.current) {
|
||||||
|
const width = sourceContainerRef.current.offsetWidth;
|
||||||
|
setSourceButtonCount(calculateButtonCount(width, true));
|
||||||
|
}
|
||||||
|
if (targetContainerRef.current) {
|
||||||
|
const width = targetContainerRef.current.offsetWidth;
|
||||||
|
setTargetButtonCount(calculateButtonCount(width, false));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateButtonCounts();
|
||||||
|
window.addEventListener("resize", updateButtonCounts);
|
||||||
|
return () => window.removeEventListener("resize", updateButtonCounts);
|
||||||
|
}, [calculateButtonCount]);
|
||||||
|
|
||||||
|
const tts = useCallback(async (text: string, locale: string) => {
|
||||||
|
try {
|
||||||
|
// Map language name to TTS format
|
||||||
|
let theLanguage = locale.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
|
||||||
|
|
||||||
|
// Check if language is in TTS supported list
|
||||||
|
const supportedLanguages: TTS_SUPPORTED_LANGUAGES[] = [
|
||||||
|
"Auto", "Chinese", "English", "German", "Italian", "Portuguese",
|
||||||
|
"Spanish", "Japanese", "Korean", "French", "Russian"
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!supportedLanguages.includes(theLanguage as TTS_SUPPORTED_LANGUAGES)) {
|
||||||
|
theLanguage = "Auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = await getTTSUrl(text, theLanguage as TTS_SUPPORTED_LANGUAGES);
|
||||||
|
await load(url);
|
||||||
|
await play();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to generate audio");
|
||||||
|
}
|
||||||
|
}, [load, play]);
|
||||||
|
|
||||||
const translate = async () => {
|
const translate = async () => {
|
||||||
if (!taref.current || processing) return;
|
if (!taref.current || processing) return;
|
||||||
@@ -91,29 +161,30 @@ export default function TranslatorPage() {
|
|||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
|
|
||||||
const sourceText = taref.current.value;
|
const sourceText = taref.current.value;
|
||||||
|
const effectiveSourceLanguage = customSourceLanguage.trim() || sourceLanguage;
|
||||||
|
const effectiveTargetLanguage = customTargetLanguage.trim() || targetLanguage;
|
||||||
|
|
||||||
// 判断是否需要强制重新翻译
|
// 判断是否需要强制重新翻译
|
||||||
// 只有当源文本、源语言和目标语言都与上次相同时,才强制重新翻译
|
|
||||||
const forceRetranslate =
|
const forceRetranslate =
|
||||||
lastTranslation?.sourceText === sourceText &&
|
lastTranslation?.sourceText === sourceText &&
|
||||||
lastTranslation?.sourceLanguage === sourceLanguage &&
|
lastTranslation?.sourceLanguage === effectiveSourceLanguage &&
|
||||||
lastTranslation?.targetLanguage === targetLanguage;
|
lastTranslation?.targetLanguage === effectiveTargetLanguage;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await actionTranslateText({
|
const result = await actionTranslateText({
|
||||||
sourceText,
|
sourceText,
|
||||||
targetLanguage,
|
targetLanguage: effectiveTargetLanguage,
|
||||||
forceRetranslate,
|
forceRetranslate,
|
||||||
needIpa,
|
needIpa,
|
||||||
sourceLanguage: sourceLanguage === "Auto" ? undefined : sourceLanguage,
|
sourceLanguage: effectiveSourceLanguage === "Auto" ? undefined : effectiveSourceLanguage,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setTranslationResult(result.data);
|
setTranslationResult(result.data);
|
||||||
setLastTranslation({
|
setLastTranslation({
|
||||||
sourceText,
|
sourceText,
|
||||||
sourceLanguage,
|
sourceLanguage: effectiveSourceLanguage,
|
||||||
targetLanguage,
|
targetLanguage: effectiveTargetLanguage,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message || "翻译失败,请重试");
|
toast.error(result.message || "翻译失败,请重试");
|
||||||
@@ -126,6 +197,66 @@ export default function TranslatorPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const visibleSourceButtons = SOURCE_LANGUAGES.slice(0, sourceButtonCount);
|
||||||
|
const visibleTargetButtons = TARGET_LANGUAGES.slice(0, targetButtonCount);
|
||||||
|
|
||||||
|
const handleSaveCard = async () => {
|
||||||
|
if (!session) {
|
||||||
|
toast.error(t("pleaseLogin"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (decks.length === 0) {
|
||||||
|
toast.error(t("pleaseCreateDeck"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!lastTranslation?.sourceText || !translationResult?.translatedText) {
|
||||||
|
toast.error(t("noTranslationToSave"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deckSelect = document.getElementById("deck-select-translator") as HTMLSelectElement;
|
||||||
|
const deckId = deckSelect?.value ? Number(deckSelect.value) : decks[0]?.id;
|
||||||
|
|
||||||
|
if (!deckId) {
|
||||||
|
toast.error(t("noDeckSelected"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sourceText = lastTranslation.sourceText;
|
||||||
|
const hasSpaces = sourceText.includes(" ");
|
||||||
|
let cardType: CardType = "WORD";
|
||||||
|
if (!translationResult.sourceIpa) {
|
||||||
|
cardType = "SENTENCE";
|
||||||
|
} else if (hasSpaces) {
|
||||||
|
cardType = "PHRASE";
|
||||||
|
}
|
||||||
|
|
||||||
|
await actionCreateCard({
|
||||||
|
deckId,
|
||||||
|
word: sourceText,
|
||||||
|
ipa: translationResult.sourceIpa || null,
|
||||||
|
queryLang: lastTranslation.sourceLanguage,
|
||||||
|
cardType,
|
||||||
|
meanings: [{
|
||||||
|
partOfSpeech: null,
|
||||||
|
definition: translationResult.translatedText,
|
||||||
|
example: null,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const deckName = decks.find((d) => d.id === deckId)?.name || "Unknown";
|
||||||
|
toast.success(t("savedToDeck", { deckName }));
|
||||||
|
setShowSaveModal(false);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("saveFailed"));
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[calc(100vh-64px)] bg-white">
|
<div className="min-h-[calc(100vh-64px)] bg-white">
|
||||||
{/* TCard Component */}
|
{/* TCard Component */}
|
||||||
@@ -134,13 +265,13 @@ export default function TranslatorPage() {
|
|||||||
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
||||||
{/* ICard1 Component */}
|
{/* ICard1 Component */}
|
||||||
<div className="border border-gray-200 rounded-lg w-full h-64 p-2">
|
<div className="border border-gray-200 rounded-lg w-full h-64 p-2">
|
||||||
<textarea
|
<Textarea
|
||||||
className="resize-none h-8/12 w-full focus:outline-0"
|
className="resize-none h-8/12 w-full"
|
||||||
ref={taref}
|
ref={taref}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.ctrlKey && e.key === "Enter") translate();
|
if (e.ctrlKey && e.key === "Enter") translate();
|
||||||
}}
|
}}
|
||||||
></textarea>
|
/>
|
||||||
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
|
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
|
||||||
{translationResult?.sourceIpa || ""}
|
{translationResult?.sourceIpa || ""}
|
||||||
</div>
|
</div>
|
||||||
@@ -158,49 +289,36 @@ export default function TranslatorPage() {
|
|||||||
src={IMAGES.play_arrow}
|
src={IMAGES.play_arrow}
|
||||||
alt="play"
|
alt="play"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const t = taref.current?.value;
|
const text = taref.current?.value;
|
||||||
if (!t) return;
|
if (!text) return;
|
||||||
tts(t, translationResult?.sourceLanguage || "");
|
tts(text, translationResult?.sourceLanguage || "");
|
||||||
}}
|
}}
|
||||||
></IconClick>
|
></IconClick>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="option1 w-full flex gap-1 items-center overflow-x-auto">
|
<div ref={sourceContainerRef} className="option1 w-full flex gap-1 items-center overflow-x-auto">
|
||||||
<span className="shrink-0">{t("sourceLanguage")}</span>
|
<span className="shrink-0">{t("sourceLanguage")}</span>
|
||||||
<LightButton
|
{visibleSourceButtons.map((lang) => (
|
||||||
selected={sourceLanguage === "Auto"}
|
<LightButton
|
||||||
onClick={() => setSourceLanguage("Auto")}
|
key={lang.value}
|
||||||
className="shrink-0 hidden lg:inline-flex"
|
selected={!customSourceLanguage && sourceLanguage === lang.value}
|
||||||
>
|
onClick={() => {
|
||||||
{t("auto")}
|
setSourceLanguage(lang.value);
|
||||||
</LightButton>
|
setCustomSourceLanguage("");
|
||||||
<LightButton
|
}}
|
||||||
selected={sourceLanguage === "Chinese"}
|
className="shrink-0"
|
||||||
onClick={() => setSourceLanguage("Chinese")}
|
>
|
||||||
className="shrink-0 hidden lg:inline-flex"
|
{getLangLabel(t, lang.label)}
|
||||||
>
|
</LightButton>
|
||||||
{t("chinese")}
|
))}
|
||||||
</LightButton>
|
<Input
|
||||||
<LightButton
|
variant="bordered"
|
||||||
selected={sourceLanguage === "English"}
|
|
||||||
onClick={() => setSourceLanguage("English")}
|
|
||||||
className="shrink-0 hidden xl:inline-flex"
|
|
||||||
>
|
|
||||||
{t("english")}
|
|
||||||
</LightButton>
|
|
||||||
<Select
|
|
||||||
value={sourceLanguage}
|
|
||||||
onChange={(e) => setSourceLanguage(e.target.value)}
|
|
||||||
variant="light"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-auto min-w-[100px] shrink-0"
|
value={customSourceLanguage}
|
||||||
>
|
onChange={(e) => setCustomSourceLanguage(e.target.value)}
|
||||||
{SOURCE_LANGUAGES.map((lang) => (
|
placeholder={t("customLanguage")}
|
||||||
<option key={lang.value} value={lang.value}>
|
className="w-auto min-w-[120px] shrink-0"
|
||||||
{t(lang.labelKey)}
|
/>
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<div className="flex-1"></div>
|
<div className="flex-1"></div>
|
||||||
<LightButton
|
<LightButton
|
||||||
selected={needIpa}
|
selected={needIpa}
|
||||||
@@ -241,48 +359,35 @@ export default function TranslatorPage() {
|
|||||||
></IconClick>
|
></IconClick>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="option2 w-full flex gap-1 items-center overflow-x-auto">
|
<div ref={targetContainerRef} className="option2 w-full flex gap-1 items-center overflow-x-auto">
|
||||||
<span className="shrink-0">{t("translateInto")}</span>
|
<span className="shrink-0">{t("translateInto")}</span>
|
||||||
<LightButton
|
{visibleTargetButtons.map((lang) => (
|
||||||
selected={targetLanguage === "Chinese"}
|
<LightButton
|
||||||
onClick={() => setTargetLanguage("Chinese")}
|
key={lang.value}
|
||||||
className="shrink-0 hidden lg:inline-flex"
|
selected={!customTargetLanguage && targetLanguage === lang.value}
|
||||||
>
|
onClick={() => {
|
||||||
{t("chinese")}
|
setTargetLanguage(lang.value);
|
||||||
</LightButton>
|
setCustomTargetLanguage("");
|
||||||
<LightButton
|
}}
|
||||||
selected={targetLanguage === "English"}
|
className="shrink-0"
|
||||||
onClick={() => setTargetLanguage("English")}
|
>
|
||||||
className="shrink-0 hidden lg:inline-flex"
|
{getLangLabel(t, lang.label)}
|
||||||
>
|
</LightButton>
|
||||||
{t("english")}
|
))}
|
||||||
</LightButton>
|
<Input
|
||||||
<LightButton
|
variant="bordered"
|
||||||
selected={targetLanguage === "Japanese"}
|
|
||||||
onClick={() => setTargetLanguage("Japanese")}
|
|
||||||
className="shrink-0 hidden xl:inline-flex"
|
|
||||||
>
|
|
||||||
{t("japanese")}
|
|
||||||
</LightButton>
|
|
||||||
<Select
|
|
||||||
value={targetLanguage}
|
|
||||||
onChange={(e) => setTargetLanguage(e.target.value)}
|
|
||||||
variant="light"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-auto min-w-[100px] shrink-0"
|
value={customTargetLanguage}
|
||||||
>
|
onChange={(e) => setCustomTargetLanguage(e.target.value)}
|
||||||
{TARGET_LANGUAGES.map((lang) => (
|
placeholder={t("customLanguage")}
|
||||||
<option key={lang.value} value={lang.value}>
|
className="w-auto min-w-[120px] shrink-0"
|
||||||
{t(lang.labelKey)}
|
/>
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TranslateButton Component */}
|
{/* TranslateButton Component */}
|
||||||
<div className="w-screen flex justify-center items-center">
|
<div className="w-screen flex justify-center items-center gap-4">
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={translate}
|
onClick={translate}
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
@@ -291,7 +396,49 @@ export default function TranslatorPage() {
|
|||||||
>
|
>
|
||||||
{t("translate")}
|
{t("translate")}
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
|
{translationResult && session && decks.length > 0 && (
|
||||||
|
<CircleButton
|
||||||
|
onClick={() => setShowSaveModal(true)}
|
||||||
|
title={t("saveAsCard")}
|
||||||
|
>
|
||||||
|
<Plus size={20} />
|
||||||
|
</CircleButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showSaveModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">{t("saveAsCard")}</h2>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t("selectDeck")}
|
||||||
|
</label>
|
||||||
|
<Select id="deck-select-translator" className="w-full">
|
||||||
|
{decks.map((deck) => (
|
||||||
|
<option key={deck.id} value={deck.id}>
|
||||||
|
{deck.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="mb-4 p-3 bg-gray-50 rounded text-sm">
|
||||||
|
<div className="font-medium mb-1">{t("front")}:</div>
|
||||||
|
<div className="text-gray-700 mb-2">{lastTranslation?.sourceText}</div>
|
||||||
|
<div className="font-medium mb-1">{t("back")}:</div>
|
||||||
|
<div className="text-gray-700">{translationResult?.translatedText}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<LightButton onClick={() => setShowSaveModal(false)}>
|
||||||
|
{t("cancel")}
|
||||||
|
</LightButton>
|
||||||
|
<PrimaryButton onClick={handleSaveCard} loading={isSaving}>
|
||||||
|
{t("save")}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { CircleButton, LightButton } from "@/design-system/base/button";
|
import { CircleButton, LightButton } from "@/design-system/base/button";
|
||||||
|
import { Skeleton } from "@/design-system/feedback/skeleton";
|
||||||
|
import { VStack } from "@/design-system/layout/stack";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -22,6 +24,7 @@ import {
|
|||||||
actionDeleteDeck,
|
actionDeleteDeck,
|
||||||
actionGetDecksByUserId,
|
actionGetDecksByUserId,
|
||||||
actionUpdateDeck,
|
actionUpdateDeck,
|
||||||
|
actionGetDeckById,
|
||||||
} from "@/modules/deck/deck-action";
|
} from "@/modules/deck/deck-action";
|
||||||
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
||||||
|
|
||||||
@@ -176,8 +179,11 @@ export function DecksClient({ userId }: DecksClientProps) {
|
|||||||
if (!deckName?.trim()) return;
|
if (!deckName?.trim()) return;
|
||||||
|
|
||||||
const result = await actionCreateDeck({ name: deckName.trim() });
|
const result = await actionCreateDeck({ name: deckName.trim() });
|
||||||
if (result.success) {
|
if (result.success && result.deckId) {
|
||||||
loadDecks();
|
const deckResult = await actionGetDeckById({ deckId: result.deckId });
|
||||||
|
if (deckResult.success && deckResult.data) {
|
||||||
|
setDecks((prev) => [...prev, deckResult.data!]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message);
|
toast.error(result.message);
|
||||||
}
|
}
|
||||||
@@ -187,7 +193,7 @@ export function DecksClient({ userId }: DecksClientProps) {
|
|||||||
<PageLayout>
|
<PageLayout>
|
||||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4 flex gap-2">
|
||||||
<LightButton onClick={handleCreateDeck}>
|
<LightButton onClick={handleCreateDeck}>
|
||||||
<Plus size={18} />
|
<Plus size={18} />
|
||||||
{t("newDeck")}
|
{t("newDeck")}
|
||||||
@@ -196,10 +202,10 @@ export function DecksClient({ userId }: DecksClientProps) {
|
|||||||
|
|
||||||
<CardList>
|
<CardList>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-8 text-center">
|
<VStack align="center" className="p-8">
|
||||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
|
<Skeleton variant="circular" className="w-8 h-8 mb-3" />
|
||||||
<p className="text-sm text-gray-500">{t("loading")}</p>
|
<p className="text-sm text-gray-500">{t("loading")}</p>
|
||||||
</div>
|
</VStack>
|
||||||
) : decks.length === 0 ? (
|
) : decks.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">
|
||||||
|
|||||||
@@ -1,15 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LightButton } from "@/design-system/base/button";
|
import { LightButton, PrimaryButton } from "@/design-system/base/button";
|
||||||
import { Input } from "@/design-system/base/input";
|
import { Input } from "@/design-system/base/input";
|
||||||
import { X } from "lucide-react";
|
import { Select } from "@/design-system/base/select";
|
||||||
import { useRef, useState } from "react";
|
import { Textarea } from "@/design-system/base/textarea";
|
||||||
|
import { Modal } from "@/design-system/overlay/modal";
|
||||||
|
import { VStack, HStack } from "@/design-system/layout/stack";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { actionCreateNote } from "@/modules/note/note-action";
|
|
||||||
import { actionCreateCard } from "@/modules/card/card-action";
|
import { actionCreateCard } from "@/modules/card/card-action";
|
||||||
import { actionGetNoteTypesByUserId, actionCreateDefaultBasicNoteType } from "@/modules/note-type/note-type-action";
|
import type { CardType, CardMeaning } from "@/modules/card/card-action-dto";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const QUERY_LANGUAGE_LABELS = {
|
||||||
|
english: "english",
|
||||||
|
chinese: "chinese",
|
||||||
|
japanese: "japanese",
|
||||||
|
korean: "korean",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const QUERY_LANGUAGES = [
|
||||||
|
{ value: "en", label: "english" as const },
|
||||||
|
{ value: "zh", label: "chinese" as const },
|
||||||
|
{ value: "ja", label: "japanese" as const },
|
||||||
|
{ value: "ko", label: "korean" as const },
|
||||||
|
] as const;
|
||||||
|
|
||||||
interface AddCardModalProps {
|
interface AddCardModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -24,75 +41,89 @@ export function AddCardModal({
|
|||||||
onAdded,
|
onAdded,
|
||||||
}: AddCardModalProps) {
|
}: AddCardModalProps) {
|
||||||
const t = useTranslations("deck_id");
|
const t = useTranslations("deck_id");
|
||||||
const wordRef = useRef<HTMLInputElement>(null);
|
|
||||||
const definitionRef = useRef<HTMLInputElement>(null);
|
const [cardType, setCardType] = useState<CardType>("WORD");
|
||||||
const ipaRef = useRef<HTMLInputElement>(null);
|
const [word, setWord] = useState("");
|
||||||
const exampleRef = useRef<HTMLInputElement>(null);
|
const [ipa, setIpa] = useState("");
|
||||||
|
const [queryLang, setQueryLang] = useState("en");
|
||||||
|
const [customQueryLang, setCustomQueryLang] = useState("");
|
||||||
|
const [meanings, setMeanings] = useState<CardMeaning[]>([
|
||||||
|
{ partOfSpeech: null, definition: "", example: null }
|
||||||
|
]);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
const showIpa = cardType === "WORD" || cardType === "PHRASE";
|
||||||
|
|
||||||
|
const addMeaning = () => {
|
||||||
|
setMeanings([...meanings, { partOfSpeech: null, definition: "", example: null }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMeaning = (index: number) => {
|
||||||
|
if (meanings.length > 1) {
|
||||||
|
setMeanings(meanings.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMeaning = (
|
||||||
|
index: number,
|
||||||
|
field: "partOfSpeech" | "definition" | "example",
|
||||||
|
value: string
|
||||||
|
) => {
|
||||||
|
const updated = [...meanings];
|
||||||
|
updated[index] = {
|
||||||
|
...updated[index],
|
||||||
|
[field]: value || null
|
||||||
|
};
|
||||||
|
setMeanings(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setCardType("WORD");
|
||||||
|
setWord("");
|
||||||
|
setIpa("");
|
||||||
|
setQueryLang("en");
|
||||||
|
setCustomQueryLang("");
|
||||||
|
setMeanings([{ partOfSpeech: null, definition: "", example: null }]);
|
||||||
|
};
|
||||||
|
|
||||||
const handleAdd = async () => {
|
const handleAdd = async () => {
|
||||||
const word = wordRef.current?.value?.trim();
|
if (!word.trim()) {
|
||||||
const definition = definitionRef.current?.value?.trim();
|
toast.error(t("wordRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!word || !definition) {
|
const validMeanings = meanings.filter(m => m.definition?.trim());
|
||||||
toast.error(t("wordAndDefinitionRequired"));
|
if (validMeanings.length === 0) {
|
||||||
|
toast.error(t("definitionRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
const effectiveQueryLang = customQueryLang.trim() || queryLang;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let noteTypesResult = await actionGetNoteTypesByUserId();
|
|
||||||
|
|
||||||
if (!noteTypesResult.success || !noteTypesResult.data || noteTypesResult.data.length === 0) {
|
|
||||||
const createResult = await actionCreateDefaultBasicNoteType();
|
|
||||||
if (!createResult.success || !createResult.data) {
|
|
||||||
throw new Error(createResult.message || "Failed to create note type");
|
|
||||||
}
|
|
||||||
noteTypesResult = await actionGetNoteTypesByUserId();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!noteTypesResult.success || !noteTypesResult.data || noteTypesResult.data.length === 0) {
|
|
||||||
throw new Error("No note type available");
|
|
||||||
}
|
|
||||||
|
|
||||||
const noteTypeId = noteTypesResult.data[0].id;
|
|
||||||
|
|
||||||
const fields = [
|
|
||||||
word,
|
|
||||||
definition,
|
|
||||||
ipaRef.current?.value?.trim() || "",
|
|
||||||
exampleRef.current?.value?.trim() || "",
|
|
||||||
];
|
|
||||||
|
|
||||||
const noteResult = await actionCreateNote({
|
|
||||||
noteTypeId,
|
|
||||||
fields,
|
|
||||||
tags: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!noteResult.success || !noteResult.data) {
|
|
||||||
throw new Error(noteResult.message || "Failed to create note");
|
|
||||||
}
|
|
||||||
|
|
||||||
const cardResult = await actionCreateCard({
|
const cardResult = await actionCreateCard({
|
||||||
noteId: BigInt(noteResult.data.id),
|
|
||||||
deckId,
|
deckId,
|
||||||
|
word: word.trim(),
|
||||||
|
ipa: showIpa && ipa.trim() ? ipa.trim() : null,
|
||||||
|
queryLang: effectiveQueryLang,
|
||||||
|
cardType,
|
||||||
|
meanings: validMeanings.map(m => ({
|
||||||
|
partOfSpeech: cardType === "SENTENCE" ? null : (m.partOfSpeech?.trim() || null),
|
||||||
|
definition: m.definition!.trim(),
|
||||||
|
example: m.example?.trim() || null,
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!cardResult.success) {
|
if (!cardResult.success) {
|
||||||
throw new Error(cardResult.message || "Failed to create card");
|
throw new Error(cardResult.message || "Failed to create card");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wordRef.current) wordRef.current.value = "";
|
resetForm();
|
||||||
if (definitionRef.current) definitionRef.current.value = "";
|
|
||||||
if (ipaRef.current) ipaRef.current.value = "";
|
|
||||||
if (exampleRef.current) exampleRef.current.value = "";
|
|
||||||
|
|
||||||
onAdded();
|
onAdded();
|
||||||
onClose();
|
onClose();
|
||||||
|
toast.success(t("cardAdded") || "Card added successfully");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -100,55 +131,155 @@ export function AddCardModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
resetForm();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal open={isOpen} onClose={handleClose} size="md">
|
||||||
className="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50"
|
<Modal.Header>
|
||||||
onKeyDown={(e) => {
|
<Modal.Title>{t("addNewCard")}</Modal.Title>
|
||||||
if (e.key === "Enter") {
|
<Modal.CloseButton onClick={handleClose} />
|
||||||
e.preventDefault();
|
</Modal.Header>
|
||||||
handleAdd();
|
|
||||||
}
|
<Modal.Body className="space-y-4">
|
||||||
}}
|
<HStack gap={3}>
|
||||||
>
|
<div className="flex-1">
|
||||||
<div className="bg-white rounded-md p-6 w-full max-w-md mx-4">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
<div className="flex">
|
{t("cardType")}
|
||||||
<h2 className="flex-1 text-xl font-light mb-4 text-center">
|
</label>
|
||||||
{t("addNewCard")}
|
<Select
|
||||||
</h2>
|
value={cardType}
|
||||||
<X onClick={onClose} className="hover:cursor-pointer"></X>
|
onChange={(e) => setCardType(e.target.value as CardType)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<option value="WORD">{t("wordCard")}</option>
|
||||||
|
<option value="PHRASE">{t("phraseCard")}</option>
|
||||||
|
<option value="SENTENCE">{t("sentenceCard")}</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t("queryLang")}
|
||||||
|
</label>
|
||||||
|
<HStack gap={2} className="flex-wrap">
|
||||||
|
{QUERY_LANGUAGES.map((lang) => (
|
||||||
|
<LightButton
|
||||||
|
key={lang.value}
|
||||||
|
selected={!customQueryLang && queryLang === lang.value}
|
||||||
|
onClick={() => {
|
||||||
|
setQueryLang(lang.value);
|
||||||
|
setCustomQueryLang("");
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{t(lang.label)}
|
||||||
|
</LightButton>
|
||||||
|
))}
|
||||||
|
<Input
|
||||||
|
value={customQueryLang}
|
||||||
|
onChange={(e) => setCustomQueryLang(e.target.value)}
|
||||||
|
placeholder={t("enterLanguageName")}
|
||||||
|
className="w-auto min-w-[100px] flex-1"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
{t("word")} *
|
{cardType === "SENTENCE" ? t("sentence") : t("word")} *
|
||||||
</label>
|
</label>
|
||||||
<Input ref={wordRef} className="w-full"></Input>
|
<Input
|
||||||
</div>
|
value={word}
|
||||||
<div>
|
onChange={(e) => setWord(e.target.value)}
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
className="w-full"
|
||||||
{t("definition")} *
|
placeholder={cardType === "SENTENCE" ? t("sentencePlaceholder") : t("wordPlaceholder")}
|
||||||
</label>
|
/>
|
||||||
<Input ref={definitionRef} className="w-full"></Input>
|
</div>
|
||||||
</div>
|
|
||||||
|
{showIpa && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
{t("ipa")}
|
{t("ipa")}
|
||||||
</label>
|
</label>
|
||||||
<Input ref={ipaRef} className="w-full"></Input>
|
<Input
|
||||||
|
value={ipa}
|
||||||
|
onChange={(e) => setIpa(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
placeholder={t("ipaPlaceholder")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{t("example")}
|
<div>
|
||||||
|
<HStack justify="between" className="mb-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t("meanings")} *
|
||||||
</label>
|
</label>
|
||||||
<Input ref={exampleRef} className="w-full"></Input>
|
<button
|
||||||
</div>
|
onClick={addMeaning}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
{t("addMeaning")}
|
||||||
|
</button>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<VStack gap={4}>
|
||||||
|
{meanings.map((meaning, index) => (
|
||||||
|
<div key={index} className="p-3 bg-gray-50 rounded-lg space-y-2">
|
||||||
|
<HStack gap={2}>
|
||||||
|
{cardType !== "SENTENCE" && (
|
||||||
|
<div className="w-28 shrink-0">
|
||||||
|
<Input
|
||||||
|
value={meaning.partOfSpeech || ""}
|
||||||
|
onChange={(e) => updateMeaning(index, "partOfSpeech", e.target.value)}
|
||||||
|
placeholder={t("partOfSpeech")}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
value={meaning.definition || ""}
|
||||||
|
onChange={(e) => updateMeaning(index, "definition", e.target.value)}
|
||||||
|
placeholder={t("definition")}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{meanings.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => removeMeaning(index)}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-500"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
<Textarea
|
||||||
|
value={meaning.example || ""}
|
||||||
|
onChange={(e) => updateMeaning(index, "example", e.target.value)}
|
||||||
|
placeholder={t("examplePlaceholder")}
|
||||||
|
className="w-full min-h-[40px] text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4">
|
</Modal.Body>
|
||||||
<LightButton onClick={handleAdd} disabled={isSubmitting}>
|
|
||||||
{isSubmitting ? t("adding") : t("add")}
|
<Modal.Footer>
|
||||||
</LightButton>
|
<LightButton onClick={handleClose}>
|
||||||
</div>
|
{t("cancel")}
|
||||||
</div>
|
</LightButton>
|
||||||
</div>
|
<PrimaryButton onClick={handleAdd} loading={isSubmitting}>
|
||||||
|
{isSubmitting ? t("adding") : t("add")}
|
||||||
|
</PrimaryButton>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +1,133 @@
|
|||||||
import { Edit, Trash2 } from "lucide-react";
|
import { Trash2, Pencil } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { CircleButton } from "@/design-system/base/button";
|
import { CircleButton } from "@/design-system/base/button";
|
||||||
import { UpdateCardModal } from "./UpdateCardModal";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import type { ActionOutputCardWithNote } from "@/modules/card/card-action-dto";
|
import type { ActionOutputCard, CardType } from "@/modules/card/card-action-dto";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { actionDeleteCard } from "@/modules/card/card-action";
|
||||||
|
import { EditCardModal } from "./EditCardModal";
|
||||||
|
|
||||||
interface CardItemProps {
|
interface CardItemProps {
|
||||||
card: ActionOutputCardWithNote;
|
card: ActionOutputCard;
|
||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
onDel: () => void;
|
onDel: () => void;
|
||||||
refreshCards: () => void;
|
onUpdated: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CARD_TYPE_LABELS: Record<CardType, string> = {
|
||||||
|
WORD: "Word",
|
||||||
|
PHRASE: "Phrase",
|
||||||
|
SENTENCE: "Sentence",
|
||||||
|
};
|
||||||
|
|
||||||
export function CardItem({
|
export function CardItem({
|
||||||
card,
|
card,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
onDel,
|
onDel,
|
||||||
refreshCards,
|
onUpdated,
|
||||||
}: CardItemProps) {
|
}: CardItemProps) {
|
||||||
const [openUpdateModal, setOpenUpdateModal] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const t = useTranslations("deck_id");
|
const t = useTranslations("deck_id");
|
||||||
|
|
||||||
const fields = card.note.flds.split('\x1f');
|
const frontText = card.word;
|
||||||
const field1 = fields[0] || "";
|
const backText = card.meanings.map((m) =>
|
||||||
const field2 = fields[1] || "";
|
m.partOfSpeech ? `${m.partOfSpeech}: ${m.definition}` : m.definition
|
||||||
|
).join("; ");
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
const result = await actionDeleteCard({ cardId: card.id });
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(t("cardDeleted"));
|
||||||
|
onDel();
|
||||||
|
} else {
|
||||||
|
toast.error(result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||||
|
}
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
<>
|
||||||
<div className="p-4">
|
<div className="group border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="p-4">
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||||
{t("card")}
|
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
||||||
</span>
|
{t("card")}
|
||||||
</div>
|
</span>
|
||||||
|
<span className="px-2 py-1 bg-blue-50 text-blue-600 rounded-md">
|
||||||
|
{CARD_TYPE_LABELS[card.cardType]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 opacity-50 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center gap-1 opacity-50 group-hover:opacity-100 transition-opacity">
|
||||||
{!isReadOnly && (
|
{!isReadOnly && (
|
||||||
<>
|
<>
|
||||||
<CircleButton
|
<CircleButton
|
||||||
onClick={() => setOpenUpdateModal(true)}
|
onClick={() => setShowEditModal(true)}
|
||||||
title={t("edit")}
|
title={t("edit")}
|
||||||
className="text-gray-400 hover:text-gray-600"
|
className="text-gray-400 hover:text-blue-500 hover:bg-blue-50"
|
||||||
>
|
>
|
||||||
<Edit size={14} />
|
<Pencil size={14} />
|
||||||
</CircleButton>
|
</CircleButton>
|
||||||
<CircleButton
|
<CircleButton
|
||||||
onClick={onDel}
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
title={t("delete")}
|
title={t("delete")}
|
||||||
className="text-gray-400 hover:text-red-500 hover:bg-red-50"
|
className="text-gray-400 hover:text-red-500 hover:bg-red-50"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</CircleButton>
|
</CircleButton>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="text-gray-900 grid grid-cols-2 gap-4 w-3/4">
|
||||||
<div className="text-gray-900 grid grid-cols-2 gap-4 w-3/4">
|
<div>
|
||||||
<div>
|
{frontText.length > 30
|
||||||
{field1.length > 30
|
? frontText.substring(0, 30) + "..."
|
||||||
? field1.substring(0, 30) + "..."
|
: frontText}
|
||||||
: field1}
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
{backText.length > 30
|
||||||
{field2.length > 30
|
? backText.substring(0, 30) + "..."
|
||||||
? field2.substring(0, 30) + "..."
|
: backText}
|
||||||
: field2}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<UpdateCardModal
|
|
||||||
isOpen={openUpdateModal}
|
{showDeleteConfirm && (
|
||||||
onClose={() => setOpenUpdateModal(false)}
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-4 max-w-sm mx-4">
|
||||||
|
<p className="text-gray-700 mb-4">{t("deleteConfirm")}</p>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
className="px-3 py-1 text-gray-600 hover:bg-gray-100 rounded"
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="px-3 py-1 text-red-600 hover:bg-red-50 rounded"
|
||||||
|
>
|
||||||
|
{t("delete")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EditCardModal
|
||||||
|
isOpen={showEditModal}
|
||||||
|
onClose={() => setShowEditModal(false)}
|
||||||
card={card}
|
card={card}
|
||||||
onUpdated={() => {
|
onUpdated={onUpdated}
|
||||||
setOpenUpdateModal(false);
|
|
||||||
refreshCards();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
229
src/app/decks/[deck_id]/EditCardModal.tsx
Normal file
229
src/app/decks/[deck_id]/EditCardModal.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LightButton, PrimaryButton } from "@/design-system/base/button";
|
||||||
|
import { Input } from "@/design-system/base/input";
|
||||||
|
import { Textarea } from "@/design-system/base/textarea";
|
||||||
|
import { Modal } from "@/design-system/overlay/modal";
|
||||||
|
import { VStack, HStack } from "@/design-system/layout/stack";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { actionUpdateCard } from "@/modules/card/card-action";
|
||||||
|
import type { ActionOutputCard, CardMeaning } from "@/modules/card/card-action-dto";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface EditCardModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
card: ActionOutputCard | null;
|
||||||
|
onUpdated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditCardModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
card,
|
||||||
|
onUpdated,
|
||||||
|
}: EditCardModalProps) {
|
||||||
|
const t = useTranslations("deck_id");
|
||||||
|
|
||||||
|
const [word, setWord] = useState("");
|
||||||
|
const [ipa, setIpa] = useState("");
|
||||||
|
const [meanings, setMeanings] = useState<CardMeaning[]>([
|
||||||
|
{ partOfSpeech: null, definition: "", example: null }
|
||||||
|
]);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const showIpa = card?.cardType === "WORD" || card?.cardType === "PHRASE";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (card) {
|
||||||
|
setWord(card.word);
|
||||||
|
setIpa(card.ipa || "");
|
||||||
|
setMeanings(
|
||||||
|
card.meanings.length > 0
|
||||||
|
? card.meanings
|
||||||
|
: [{ partOfSpeech: null, definition: "", example: null }]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [card]);
|
||||||
|
|
||||||
|
const addMeaning = () => {
|
||||||
|
setMeanings([...meanings, { partOfSpeech: null, definition: "", example: null }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMeaning = (index: number) => {
|
||||||
|
if (meanings.length > 1) {
|
||||||
|
setMeanings(meanings.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMeaning = (index: number, field: keyof CardMeaning, value: string) => {
|
||||||
|
const updated = [...meanings];
|
||||||
|
updated[index] = { ...updated[index], [field]: value || null };
|
||||||
|
setMeanings(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
if (!card) return;
|
||||||
|
|
||||||
|
if (!word.trim()) {
|
||||||
|
toast.error(t("wordRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validMeanings = meanings.filter(m => m.definition?.trim());
|
||||||
|
if (validMeanings.length === 0) {
|
||||||
|
toast.error(t("definitionRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await actionUpdateCard({
|
||||||
|
cardId: card.id,
|
||||||
|
word: word.trim(),
|
||||||
|
ipa: showIpa && ipa.trim() ? ipa.trim() : null,
|
||||||
|
meanings: validMeanings.map(m => ({
|
||||||
|
partOfSpeech: card.cardType === "SENTENCE" ? null : (m.partOfSpeech?.trim() || null),
|
||||||
|
definition: m.definition!.trim(),
|
||||||
|
example: m.example?.trim() || null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.message || "Failed to update card");
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdated();
|
||||||
|
onClose();
|
||||||
|
toast.success(t("cardUpdated") || "Card updated successfully");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!card) return null;
|
||||||
|
|
||||||
|
const cardTypeLabel = card.cardType === "WORD"
|
||||||
|
? t("wordCard")
|
||||||
|
: card.cardType === "PHRASE"
|
||||||
|
? t("phraseCard")
|
||||||
|
: t("sentenceCard");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={isOpen} onClose={onClose} size="md">
|
||||||
|
<Modal.Header>
|
||||||
|
<Modal.Title>{t("updateCard")}</Modal.Title>
|
||||||
|
<Modal.CloseButton onClick={onClose} />
|
||||||
|
</Modal.Header>
|
||||||
|
|
||||||
|
<Modal.Body className="space-y-4">
|
||||||
|
<HStack gap={2} className="text-sm text-gray-500">
|
||||||
|
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
||||||
|
{t("card")}
|
||||||
|
</span>
|
||||||
|
<span className="px-2 py-1 bg-blue-50 text-blue-600 rounded-md">
|
||||||
|
{cardTypeLabel}
|
||||||
|
</span>
|
||||||
|
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
||||||
|
{card.queryLang}
|
||||||
|
</span>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{card.cardType === "SENTENCE" ? t("sentence") : t("word")} *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={word}
|
||||||
|
onChange={(e) => setWord(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showIpa && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t("ipa")}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={ipa}
|
||||||
|
onChange={(e) => setIpa(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
placeholder={t("ipaPlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<HStack justify="between" className="mb-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t("meanings")} *
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={addMeaning}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
{t("addMeaning")}
|
||||||
|
</button>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<VStack gap={4}>
|
||||||
|
{meanings.map((meaning, index) => (
|
||||||
|
<div key={index} className="p-3 bg-gray-50 rounded-lg space-y-2">
|
||||||
|
<HStack gap={2}>
|
||||||
|
{card.cardType !== "SENTENCE" && (
|
||||||
|
<div className="w-28 shrink-0">
|
||||||
|
<Input
|
||||||
|
value={meaning.partOfSpeech || ""}
|
||||||
|
onChange={(e) => updateMeaning(index, "partOfSpeech", e.target.value)}
|
||||||
|
placeholder={t("partOfSpeech")}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
value={meaning.definition || ""}
|
||||||
|
onChange={(e) => updateMeaning(index, "definition", e.target.value)}
|
||||||
|
placeholder={t("definition")}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{meanings.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => removeMeaning(index)}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-500"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
<Textarea
|
||||||
|
value={meaning.example || ""}
|
||||||
|
onChange={(e) => updateMeaning(index, "example", e.target.value)}
|
||||||
|
placeholder={t("examplePlaceholder")}
|
||||||
|
className="w-full min-h-[40px] text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</div>
|
||||||
|
</Modal.Body>
|
||||||
|
|
||||||
|
<Modal.Footer>
|
||||||
|
<LightButton onClick={onClose}>
|
||||||
|
{t("cancel")}
|
||||||
|
</LightButton>
|
||||||
|
<PrimaryButton onClick={handleUpdate} loading={isSubmitting}>
|
||||||
|
{isSubmitting ? t("updating") : t("update")}
|
||||||
|
</PrimaryButton>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,56 +2,76 @@
|
|||||||
|
|
||||||
import { ArrowLeft, Plus } from "lucide-react";
|
import { ArrowLeft, Plus } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { redirect, useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
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 } 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 { VStack } from "@/design-system/layout/stack";
|
||||||
import type { ActionOutputCardWithNote } from "@/modules/card/card-action-dto";
|
import { Skeleton } from "@/design-system/feedback/skeleton";
|
||||||
|
import { actionGetCardsByDeckId, actionDeleteCard } from "@/modules/card/card-action";
|
||||||
|
import { actionGetDeckById } from "@/modules/deck/deck-action";
|
||||||
|
import type { ActionOutputCard } from "@/modules/card/card-action-dto";
|
||||||
|
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { AddCardModal } from "./AddCardModal";
|
||||||
|
|
||||||
|
export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boolean }) {
|
||||||
export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boolean; }) {
|
const [cards, setCards] = useState<ActionOutputCard[]>([]);
|
||||||
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 [deckInfo, setDeckInfo] = useState<ActionOutputDeck | null>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations("deck_id");
|
const t = useTranslations("deck_id");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCards = async () => {
|
const fetchCards = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await actionGetCardsByDeckIdWithNotes({ deckId })
|
try {
|
||||||
.then(result => {
|
const [cardsResult, deckResult] = await Promise.all([
|
||||||
if (!result.success || !result.data) {
|
actionGetCardsByDeckId({ deckId }),
|
||||||
throw new Error(result.message || "Failed to load cards");
|
actionGetDeckById({ deckId }),
|
||||||
}
|
]);
|
||||||
return result.data;
|
|
||||||
}).then(setCards)
|
if (!cardsResult.success || !cardsResult.data) {
|
||||||
.catch((error) => {
|
throw new Error(cardsResult.message || "Failed to load cards");
|
||||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
}
|
||||||
})
|
setCards(cardsResult.data);
|
||||||
.finally(() => {
|
|
||||||
setLoading(false);
|
if (deckResult.success && deckResult.data) {
|
||||||
});
|
setDeckInfo(deckResult.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
fetchCards();
|
fetchCards();
|
||||||
}, [deckId]);
|
}, [deckId]);
|
||||||
|
|
||||||
const refreshCards = async () => {
|
const refreshCards = async () => {
|
||||||
await actionGetCardsByDeckIdWithNotes({ deckId })
|
const result = await actionGetCardsByDeckId({ deckId });
|
||||||
.then(result => {
|
if (result.success && result.data) {
|
||||||
if (!result.success || !result.data) {
|
setCards(result.data);
|
||||||
throw new Error(result.message || "Failed to refresh cards");
|
} else {
|
||||||
}
|
toast.error(result.message);
|
||||||
return result.data;
|
}
|
||||||
}).then(setCards)
|
};
|
||||||
.catch((error) => {
|
|
||||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
const handleDeleteCard = async (cardId: number) => {
|
||||||
});
|
try {
|
||||||
|
const result = await actionDeleteCard({ cardId });
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(t("cardDeleted"));
|
||||||
|
await refreshCards();
|
||||||
|
} else {
|
||||||
|
toast.error(result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -68,7 +88,7 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-1">
|
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-1">
|
||||||
{t("cards")}
|
{deckInfo?.name || t("cards")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
{t("itemsCount", { count: cards.length })}
|
{t("itemsCount", { count: cards.length })}
|
||||||
@@ -78,7 +98,7 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
redirect(`/memorize?deck_id=${deckId}`);
|
router.push(`/decks/${deckId}/learn`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("memorize")}
|
{t("memorize")}
|
||||||
@@ -98,35 +118,25 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo
|
|||||||
|
|
||||||
<CardList>
|
<CardList>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-8 text-center">
|
<VStack align="center" className="p-8">
|
||||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
|
<Skeleton variant="circular" className="w-8 h-8" />
|
||||||
<p className="text-sm text-gray-500">{t("loadingCards")}</p>
|
<p className="text-sm text-gray-500">{t("loadingCards")}</p>
|
||||||
</div>
|
</VStack>
|
||||||
) : cards.length === 0 ? (
|
) : cards.length === 0 ? (
|
||||||
<div className="p-12 text-center">
|
<div className="p-12 text-center">
|
||||||
<p className="text-sm text-gray-500 mb-2">{t("noCards")}</p>
|
<p className="text-sm text-gray-500 mb-2">{t("noCards")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-gray-100">
|
<div className="divide-y divide-gray-100">
|
||||||
{cards
|
{cards.map((card) => (
|
||||||
.toSorted((a, b) => Number(BigInt(a.id) - BigInt(b.id)))
|
<CardItem
|
||||||
.map((card) => (
|
key={card.id}
|
||||||
<CardItem
|
card={card}
|
||||||
key={card.id}
|
isReadOnly={isReadOnly}
|
||||||
card={card}
|
onDel={() => handleDeleteCard(card.id)}
|
||||||
isReadOnly={isReadOnly}
|
onUpdated={refreshCards}
|
||||||
onDel={() => {
|
/>
|
||||||
actionDeleteCard({ cardId: BigInt(card.id) })
|
))}
|
||||||
.then(result => {
|
|
||||||
if (!result.success) throw new Error(result.message || "Delete failed");
|
|
||||||
}).then(refreshCards)
|
|
||||||
.catch((error) => {
|
|
||||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
refreshCards={refreshCards}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardList>
|
</CardList>
|
||||||
@@ -139,4 +149,4 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo
|
|||||||
/>
|
/>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { LightButton } from "@/design-system/base/button";
|
|
||||||
import { Input } from "@/design-system/base/input";
|
|
||||||
import { X } from "lucide-react";
|
|
||||||
import { useRef, useState, useEffect } from "react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { actionUpdateNote } from "@/modules/note/note-action";
|
|
||||||
import type { ActionOutputCardWithNote } from "@/modules/card/card-action-dto";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface UpdateCardModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
card: ActionOutputCardWithNote;
|
|
||||||
onUpdated: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UpdateCardModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
card,
|
|
||||||
onUpdated,
|
|
||||||
}: UpdateCardModalProps) {
|
|
||||||
const t = useTranslations("deck_id");
|
|
||||||
const wordRef = useRef<HTMLInputElement>(null);
|
|
||||||
const definitionRef = useRef<HTMLInputElement>(null);
|
|
||||||
const ipaRef = useRef<HTMLInputElement>(null);
|
|
||||||
const exampleRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen && card) {
|
|
||||||
const fields = card.note.flds.split('\x1f');
|
|
||||||
if (wordRef.current) wordRef.current.value = fields[0] || "";
|
|
||||||
if (definitionRef.current) definitionRef.current.value = fields[1] || "";
|
|
||||||
if (ipaRef.current) ipaRef.current.value = fields[2] || "";
|
|
||||||
if (exampleRef.current) exampleRef.current.value = fields[3] || "";
|
|
||||||
}
|
|
||||||
}, [isOpen, card]);
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
const handleUpdate = async () => {
|
|
||||||
const word = wordRef.current?.value?.trim();
|
|
||||||
const definition = definitionRef.current?.value?.trim();
|
|
||||||
|
|
||||||
if (!word || !definition) {
|
|
||||||
toast.error(t("wordAndDefinitionRequired"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fields = [
|
|
||||||
word,
|
|
||||||
definition,
|
|
||||||
ipaRef.current?.value?.trim() || "",
|
|
||||||
exampleRef.current?.value?.trim() || "",
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = await actionUpdateNote({
|
|
||||||
noteId: BigInt(card.note.id),
|
|
||||||
fields,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.message || "Failed to update note");
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success(result.message);
|
|
||||||
onUpdated();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
handleUpdate();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="bg-white rounded-md p-6 w-full max-w-md mx-4">
|
|
||||||
<div className="flex">
|
|
||||||
<h2 className="flex-1 text-xl font-light mb-4 text-center">
|
|
||||||
{t("updateCard")}
|
|
||||||
</h2>
|
|
||||||
<X onClick={onClose} className="hover:cursor-pointer"></X>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{t("word")} *
|
|
||||||
</label>
|
|
||||||
<Input ref={wordRef} className="w-full"></Input>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{t("definition")} *
|
|
||||||
</label>
|
|
||||||
<Input ref={definitionRef} className="w-full"></Input>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{t("ipa")}
|
|
||||||
</label>
|
|
||||||
<Input ref={ipaRef} className="w-full"></Input>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{t("example")}
|
|
||||||
</label>
|
|
||||||
<Input ref={exampleRef} className="w-full"></Input>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4">
|
|
||||||
<LightButton onClick={handleUpdate} disabled={isSubmitting}>
|
|
||||||
{isSubmitting ? t("updating") : t("update")}
|
|
||||||
</LightButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
468
src/app/decks/[deck_id]/learn/Memorize.tsx
Normal file
468
src/app/decks/[deck_id]/learn/Memorize.tsx
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useTransition, useCallback, useRef } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import localFont from "next/font/local";
|
||||||
|
import { Layers, Check, RotateCcw, Volume2, Headphones, ChevronLeft, ChevronRight, Shuffle, List, Repeat, Infinity } from "lucide-react";
|
||||||
|
import { actionGetCardsByDeckId } from "@/modules/card/card-action";
|
||||||
|
import type { ActionOutputCard } from "@/modules/card/card-action-dto";
|
||||||
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
|
import { LightButton, CircleButton } from "@/design-system/base/button";
|
||||||
|
import { Progress } from "@/design-system/feedback/progress";
|
||||||
|
import { Skeleton } from "@/design-system/feedback/skeleton";
|
||||||
|
import { HStack, VStack } from "@/design-system/layout/stack";
|
||||||
|
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||||
|
import { getTTSUrl, type TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
||||||
|
|
||||||
|
const myFont = localFont({
|
||||||
|
src: "../../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
|
||||||
|
});
|
||||||
|
|
||||||
|
type StudyMode = "order-limited" | "order-infinite" | "random-limited" | "random-infinite";
|
||||||
|
|
||||||
|
interface MemorizeProps {
|
||||||
|
deckId: number;
|
||||||
|
deckName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
|
||||||
|
const t = useTranslations("memorize.review");
|
||||||
|
const router = useRouter();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const [originalCards, setOriginalCards] = useState<ActionOutputCard[]>([]);
|
||||||
|
const [cards, setCards] = useState<ActionOutputCard[]>([]);
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [showAnswer, setShowAnswer] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isReversed, setIsReversed] = useState(false);
|
||||||
|
const [isDictation, setIsDictation] = useState(false);
|
||||||
|
const [studyMode, setStudyMode] = useState<StudyMode>("order-limited");
|
||||||
|
const { play, stop, load } = useAudioPlayer();
|
||||||
|
const audioUrlRef = useRef<string | null>(null);
|
||||||
|
const [isAudioLoading, setIsAudioLoading] = useState(false);
|
||||||
|
|
||||||
|
const shuffleCards = useCallback((cardArray: ActionOutputCard[]): ActionOutputCard[] => {
|
||||||
|
const shuffled = [...cardArray];
|
||||||
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||||
|
}
|
||||||
|
return shuffled;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let ignore = false;
|
||||||
|
|
||||||
|
const loadCards = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await actionGetCardsByDeckId({ deckId, limit: 100 });
|
||||||
|
if (!ignore) {
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setOriginalCards(result.data);
|
||||||
|
setCards(result.data);
|
||||||
|
setCurrentIndex(0);
|
||||||
|
setShowAnswer(false);
|
||||||
|
setIsReversed(false);
|
||||||
|
setIsDictation(false);
|
||||||
|
} else {
|
||||||
|
setError(result.message);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCards();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ignore = true;
|
||||||
|
};
|
||||||
|
}, [deckId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (studyMode.startsWith("random")) {
|
||||||
|
setCards(shuffleCards(originalCards));
|
||||||
|
} else {
|
||||||
|
setCards(originalCards);
|
||||||
|
}
|
||||||
|
setCurrentIndex(0);
|
||||||
|
setShowAnswer(false);
|
||||||
|
}, [studyMode, originalCards, shuffleCards]);
|
||||||
|
|
||||||
|
const getCurrentCard = (): ActionOutputCard | null => {
|
||||||
|
return cards[currentIndex] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFrontText = (card: ActionOutputCard): string => {
|
||||||
|
if (isReversed) {
|
||||||
|
return card.meanings.map((m) =>
|
||||||
|
m.partOfSpeech ? `${m.partOfSpeech}: ${m.definition}` : m.definition
|
||||||
|
).join("; ");
|
||||||
|
}
|
||||||
|
return card.word;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBackContent = (card: ActionOutputCard): React.ReactNode => {
|
||||||
|
if (isReversed) {
|
||||||
|
return <span className="text-gray-900 text-xl md:text-2xl text-center">{card.word}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack align="stretch" gap={2} className="w-full max-w-lg">
|
||||||
|
{card.meanings.map((m, idx) => (
|
||||||
|
<div key={idx} className="flex gap-3 text-left">
|
||||||
|
{m.partOfSpeech && (
|
||||||
|
<span className="text-primary-600 text-sm font-medium min-w-[60px] shrink-0">
|
||||||
|
{m.partOfSpeech}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-gray-800">{m.definition}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowAnswer = useCallback(() => {
|
||||||
|
setShowAnswer(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isInfinite = studyMode.endsWith("infinite");
|
||||||
|
|
||||||
|
const handleNextCard = useCallback(() => {
|
||||||
|
if (isInfinite) {
|
||||||
|
if (currentIndex >= cards.length - 1) {
|
||||||
|
if (studyMode.startsWith("random")) {
|
||||||
|
setCards(shuffleCards(originalCards));
|
||||||
|
}
|
||||||
|
setCurrentIndex(0);
|
||||||
|
} else {
|
||||||
|
setCurrentIndex(currentIndex + 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (currentIndex < cards.length - 1) {
|
||||||
|
setCurrentIndex(currentIndex + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setShowAnswer(false);
|
||||||
|
setIsReversed(false);
|
||||||
|
setIsDictation(false);
|
||||||
|
cleanupAudio();
|
||||||
|
}, [currentIndex, cards.length, isInfinite, studyMode, originalCards, shuffleCards]);
|
||||||
|
|
||||||
|
const handlePrevCard = useCallback(() => {
|
||||||
|
if (isInfinite) {
|
||||||
|
if (currentIndex <= 0) {
|
||||||
|
setCurrentIndex(cards.length - 1);
|
||||||
|
} else {
|
||||||
|
setCurrentIndex(currentIndex - 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
setCurrentIndex(currentIndex - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setShowAnswer(false);
|
||||||
|
setIsReversed(false);
|
||||||
|
setIsDictation(false);
|
||||||
|
cleanupAudio();
|
||||||
|
}, [currentIndex, cards.length, isInfinite]);
|
||||||
|
|
||||||
|
const cleanupAudio = useCallback(() => {
|
||||||
|
if (audioUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(audioUrlRef.current);
|
||||||
|
audioUrlRef.current = null;
|
||||||
|
}
|
||||||
|
stop();
|
||||||
|
}, [stop]);
|
||||||
|
|
||||||
|
const playTTS = useCallback(async (text: string) => {
|
||||||
|
if (isAudioLoading) return;
|
||||||
|
|
||||||
|
setIsAudioLoading(true);
|
||||||
|
try {
|
||||||
|
const hasChinese = /[\u4e00-\u9fff]/.test(text);
|
||||||
|
const hasJapanese = /[\u3040-\u309f\u30a0-\u30ff]/.test(text);
|
||||||
|
const hasKorean = /[\uac00-\ud7af]/.test(text);
|
||||||
|
|
||||||
|
let lang: TTS_SUPPORTED_LANGUAGES = "Auto";
|
||||||
|
if (hasChinese) lang = "Chinese";
|
||||||
|
else if (hasJapanese) lang = "Japanese";
|
||||||
|
else if (hasKorean) lang = "Korean";
|
||||||
|
else if (/^[a-zA-Z\s]/.test(text)) lang = "English";
|
||||||
|
|
||||||
|
const audioUrl = await getTTSUrl(text, lang);
|
||||||
|
|
||||||
|
if (audioUrl && audioUrl !== "error") {
|
||||||
|
audioUrlRef.current = audioUrl;
|
||||||
|
await load(audioUrl);
|
||||||
|
play();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("TTS playback failed", e);
|
||||||
|
} finally {
|
||||||
|
setIsAudioLoading(false);
|
||||||
|
}
|
||||||
|
}, [isAudioLoading, load, play]);
|
||||||
|
|
||||||
|
const playCurrentCard = useCallback(() => {
|
||||||
|
const currentCard = getCurrentCard();
|
||||||
|
if (!currentCard) return;
|
||||||
|
|
||||||
|
const text = isReversed
|
||||||
|
? currentCard.meanings.map((m) => m.definition).join("; ")
|
||||||
|
: currentCard.word;
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
playTTS(text);
|
||||||
|
}
|
||||||
|
}, [isReversed, playTTS]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!showAnswer) {
|
||||||
|
if (e.key === " " || e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleShowAnswer();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (e.key === "ArrowRight" || e.key === " " || e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleNextCard();
|
||||||
|
} else if (e.key === "ArrowLeft") {
|
||||||
|
e.preventDefault();
|
||||||
|
handlePrevCard();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [showAnswer, handleShowAnswer, handleNextCard, handlePrevCard]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<VStack align="center" className="py-12">
|
||||||
|
<Skeleton variant="circular" className="h-12 w-12 mb-4" />
|
||||||
|
<p className="text-gray-600">{t("loading")}</p>
|
||||||
|
</VStack>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<VStack align="center" className="py-12">
|
||||||
|
<div className="text-red-600 mb-4 px-4 py-3 bg-red-50 border border-red-200 rounded-lg max-w-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
<LightButton onClick={() => router.push("/decks")} className="px-4 py-2">
|
||||||
|
{t("backToDecks")}
|
||||||
|
</LightButton>
|
||||||
|
</VStack>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cards.length === 0) {
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<VStack align="center" className="py-12">
|
||||||
|
<div className="text-green-500 mb-4">
|
||||||
|
<Check className="w-16 h-16 mx-auto" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-800 mb-2">{t("allDone")}</h2>
|
||||||
|
<p className="text-gray-600 mb-6">{t("allDoneDesc")}</p>
|
||||||
|
<LightButton onClick={() => router.push("/decks")} className="px-4 py-2">
|
||||||
|
{t("backToDecks")}
|
||||||
|
</LightButton>
|
||||||
|
</VStack>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCard = getCurrentCard()!;
|
||||||
|
const displayFront = getFrontText(currentCard);
|
||||||
|
const isFinished = !isInfinite && currentIndex === cards.length - 1 && showAnswer;
|
||||||
|
|
||||||
|
const studyModeOptions: { value: StudyMode; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{ value: "order-limited", label: t("orderLimited"), icon: <List className="w-4 h-4" /> },
|
||||||
|
{ value: "order-infinite", label: t("orderInfinite"), icon: <Repeat className="w-4 h-4" /> },
|
||||||
|
{ value: "random-limited", label: t("randomLimited"), icon: <Shuffle className="w-4 h-4" /> },
|
||||||
|
{ value: "random-infinite", label: t("randomInfinite"), icon: <Infinity className="w-4 h-4" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<HStack justify="between" className="mb-4">
|
||||||
|
<HStack gap={2} className="text-gray-600">
|
||||||
|
<Layers className="w-5 h-5" />
|
||||||
|
<span className="font-medium">{deckName}</span>
|
||||||
|
</HStack>
|
||||||
|
{!isInfinite && (
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{t("progress", { current: currentIndex + 1, total: cards.length })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{!isInfinite && (
|
||||||
|
<Progress
|
||||||
|
value={((currentIndex + 1) / cards.length) * 100}
|
||||||
|
showLabel={false}
|
||||||
|
animated={false}
|
||||||
|
className="mb-6"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<VStack gap={2} className="mb-4">
|
||||||
|
<HStack justify="center" gap={1} className="flex-wrap">
|
||||||
|
{studyModeOptions.map((option) => (
|
||||||
|
<LightButton
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => setStudyMode(option.value)}
|
||||||
|
selected={studyMode === option.value}
|
||||||
|
leftIcon={option.icon}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</LightButton>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack justify="center" gap={2}>
|
||||||
|
<LightButton
|
||||||
|
onClick={() => {
|
||||||
|
setIsReversed(!isReversed);
|
||||||
|
setShowAnswer(false);
|
||||||
|
}}
|
||||||
|
selected={isReversed}
|
||||||
|
leftIcon={<RotateCcw className="w-4 h-4" />}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{t("reverse")}
|
||||||
|
</LightButton>
|
||||||
|
<LightButton
|
||||||
|
onClick={() => {
|
||||||
|
setIsDictation(!isDictation);
|
||||||
|
}}
|
||||||
|
selected={isDictation}
|
||||||
|
leftIcon={<Headphones className="w-4 h-4" />}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{t("dictation")}
|
||||||
|
</LightButton>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
<div className={`bg-white border border-gray-200 rounded-xl shadow-sm mb-6 h-[50dvh] flex flex-col ${myFont.className}`}>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{isDictation ? (
|
||||||
|
<>
|
||||||
|
<VStack align="center" justify="center" gap={4} className="p-8 min-h-[20dvh]">
|
||||||
|
{currentCard.ipa ? (
|
||||||
|
<div className="text-gray-700 text-2xl text-center font-mono">
|
||||||
|
{currentCard.ipa}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-gray-400 text-lg">
|
||||||
|
{t("noIpa")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{showAnswer && (
|
||||||
|
<>
|
||||||
|
<div className="border-t border-gray-200" />
|
||||||
|
<VStack align="center" justify="center" className="p-8 min-h-[20dvh] bg-gray-50 rounded-b-xl">
|
||||||
|
<div className="text-gray-900 text-xl md:text-2xl text-center whitespace-pre-line">
|
||||||
|
{displayFront}
|
||||||
|
</div>
|
||||||
|
{getBackContent(currentCard)}
|
||||||
|
</VStack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<HStack align="center" justify="center" className="p-8 min-h-[20dvh]">
|
||||||
|
<div className="text-gray-900 text-xl md:text-2xl text-center whitespace-pre-line">
|
||||||
|
{displayFront}
|
||||||
|
</div>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{showAnswer && (
|
||||||
|
<>
|
||||||
|
<div className="border-t border-gray-200" />
|
||||||
|
<VStack align="center" justify="center" className="p-8 min-h-[20dvh] bg-gray-50 rounded-b-xl">
|
||||||
|
{getBackContent(currentCard)}
|
||||||
|
</VStack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HStack justify="center">
|
||||||
|
{!showAnswer ? (
|
||||||
|
<LightButton
|
||||||
|
onClick={handleShowAnswer}
|
||||||
|
disabled={isPending}
|
||||||
|
className="px-8 py-3 text-lg rounded-full"
|
||||||
|
>
|
||||||
|
{t("showAnswer")}
|
||||||
|
<span className="ml-2 text-xs opacity-60">Space</span>
|
||||||
|
</LightButton>
|
||||||
|
) : isFinished ? (
|
||||||
|
<VStack align="center" gap={4}>
|
||||||
|
<div className="text-green-500">
|
||||||
|
<Check className="w-12 h-12" />
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600">{t("allDoneDesc")}</p>
|
||||||
|
<HStack gap={2}>
|
||||||
|
<LightButton onClick={() => router.push("/decks")} className="px-4 py-2">
|
||||||
|
{t("backToDecks")}
|
||||||
|
</LightButton>
|
||||||
|
<LightButton onClick={() => setCurrentIndex(0)} className="px-4 py-2">
|
||||||
|
{t("restart")}
|
||||||
|
</LightButton>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<HStack gap={4}>
|
||||||
|
<LightButton
|
||||||
|
onClick={handlePrevCard}
|
||||||
|
className="px-4 py-2"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</LightButton>
|
||||||
|
<span className="text-gray-500 text-sm">
|
||||||
|
{t("nextCard")}
|
||||||
|
<span className="ml-2 text-xs opacity-60">Space</span>
|
||||||
|
</span>
|
||||||
|
<LightButton
|
||||||
|
onClick={handleNextCard}
|
||||||
|
className="px-4 py-2"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
</LightButton>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Memorize };
|
||||||
34
src/app/decks/[deck_id]/learn/page.tsx
Normal file
34
src/app/decks/[deck_id]/learn/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { actionGetDeckById } from "@/modules/deck/deck-action";
|
||||||
|
import { Memorize } from "./Memorize";
|
||||||
|
|
||||||
|
export default async function LearnPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ deck_id: string }>;
|
||||||
|
}) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
const { deck_id } = await params;
|
||||||
|
const deckId = Number(deck_id);
|
||||||
|
|
||||||
|
if (!deckId) {
|
||||||
|
redirect("/decks");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deckInfo = (await actionGetDeckById({ deckId })).data;
|
||||||
|
|
||||||
|
if (!deckInfo) {
|
||||||
|
redirect("/decks");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = session?.user?.id === deckInfo.userId;
|
||||||
|
const isPublic = deckInfo.visibility === "PUBLIC";
|
||||||
|
|
||||||
|
if (!isOwner && !isPublic) {
|
||||||
|
redirect("/decks");
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Memorize deckId={deckId} deckName={deckInfo.name} />;
|
||||||
|
}
|
||||||
@@ -99,18 +99,18 @@
|
|||||||
* 定义全局 CSS 变量用于主题切换和动态样式
|
* 定义全局 CSS 变量用于主题切换和动态样式
|
||||||
*/
|
*/
|
||||||
:root {
|
:root {
|
||||||
/* 主题色 - 默认 Teal */
|
/* 主题色 - 默认 Mist */
|
||||||
--primary-50: #f0f9f8;
|
--primary-50: #f7f8fa;
|
||||||
--primary-100: #e0f2f0;
|
--primary-100: #eef1f5;
|
||||||
--primary-200: #bce6e1;
|
--primary-200: #dce2eb;
|
||||||
--primary-300: #8dd4cc;
|
--primary-300: #c4cdd9;
|
||||||
--primary-400: #5ec2b7;
|
--primary-400: #a3b0c1;
|
||||||
--primary-500: #35786f;
|
--primary-500: #8594a8;
|
||||||
--primary-600: #2a605b;
|
--primary-600: #6b7a8d;
|
||||||
--primary-700: #1f4844;
|
--primary-700: #596474;
|
||||||
--primary-800: #183835;
|
--primary-800: #4b5360;
|
||||||
--primary-900: #122826;
|
--primary-900: #414850;
|
||||||
--primary-950: #0a1413;
|
--primary-950: #22262b;
|
||||||
|
|
||||||
/* 基础颜色 */
|
/* 基础颜色 */
|
||||||
--background: #ffffff;
|
--background: #ffffff;
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
/* 边框 */
|
/* 边框 */
|
||||||
--border: #d1d5db;
|
--border: #d1d5db;
|
||||||
--border-secondary: #e5e7eb;
|
--border-secondary: #e5e7eb;
|
||||||
--border-focus: #35786f;
|
--border-focus: #8594a8;
|
||||||
|
|
||||||
/* 圆角 - 更小的圆角 */
|
/* 圆角 - 更小的圆角 */
|
||||||
--radius-xs: 0.125rem;
|
--radius-xs: 0.125rem;
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||||
--shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
--shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
--shadow-primary: 0 4px 14px 0 rgba(53, 120, 111, 0.39);
|
--shadow-primary: 0 4px 14px 0 rgba(133, 148, 168, 0.39);
|
||||||
|
|
||||||
/* 间距 */
|
/* 间距 */
|
||||||
--spacing-xs: 0.25rem;
|
--spacing-xs: 0.25rem;
|
||||||
@@ -177,7 +177,7 @@ body {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: var(--background);
|
background: var(--primary-50);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: var(--font-geist-sans), -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
font-family: var(--font-geist-sans), -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export default async function HomePage() {
|
|||||||
color="#dd7486"
|
color="#dd7486"
|
||||||
></LinkArea>
|
></LinkArea>
|
||||||
<LinkArea
|
<LinkArea
|
||||||
href="/memorize"
|
href="/decks"
|
||||||
name={t("memorize.name")}
|
name={t("memorize.name")}
|
||||||
description={t("memorize.description")}
|
description={t("memorize.description")}
|
||||||
color="#cc9988"
|
color="#cc9988"
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { nextCookies } from "better-auth/next-js";
|
|||||||
import { username } from "better-auth/plugins";
|
import { username } from "better-auth/plugins";
|
||||||
import { createAuthMiddleware, APIError } from "better-auth/api";
|
import { createAuthMiddleware, APIError } from "better-auth/api";
|
||||||
import { prisma } from "./lib/db";
|
import { prisma } from "./lib/db";
|
||||||
|
import { createLogger } from "./lib/logger";
|
||||||
|
|
||||||
|
const log = createLogger("auth");
|
||||||
|
|
||||||
import {
|
import {
|
||||||
sendEmail,
|
sendEmail,
|
||||||
generateVerificationEmailHtml,
|
generateVerificationEmailHtml,
|
||||||
@@ -24,7 +28,7 @@ export const auth = betterAuth({
|
|||||||
html: generateResetPasswordEmailHtml(url, user.name || "用户"),
|
html: generateResetPasswordEmailHtml(url, user.name || "用户"),
|
||||||
});
|
});
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.error("[email] Failed to send reset password email:", result.error);
|
log.error("Failed to send reset password email", { error: result.error });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -38,7 +42,7 @@ export const auth = betterAuth({
|
|||||||
html: generateVerificationEmailHtml(url, user.name || "用户"),
|
html: generateVerificationEmailHtml(url, user.name || "用户"),
|
||||||
});
|
});
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.error("[email] Failed to send verification email:", result.error);
|
log.error("Failed to send verification email", { error: result.error });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { Languages } from "lucide-react";
|
import { Languages } from "lucide-react";
|
||||||
import { cn } from "@/utils/cn";
|
import { cn } from "@/utils/cn";
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ const languages = [
|
|||||||
|
|
||||||
export function LanguageSettings() {
|
export function LanguageSettings() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [pendingLocale, setPendingLocale] = useState<string | null>(null);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -46,10 +47,16 @@ export function LanguageSettings() {
|
|||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const setLocale = async (locale: string) => {
|
useEffect(() => {
|
||||||
document.cookie = `locale=${locale}`;
|
if (pendingLocale) {
|
||||||
window.location.reload();
|
document.cookie = `locale=${pendingLocale}; path=/`;
|
||||||
};
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}, [pendingLocale]);
|
||||||
|
|
||||||
|
const setLocale = useCallback((locale: string) => {
|
||||||
|
setPendingLocale(locale);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={menuRef}>
|
<div className="relative" ref={menuRef}>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export async function Navbar() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const mobileMenuItems: NavigationItem[] = [
|
const mobileMenuItems: NavigationItem[] = [
|
||||||
{ label: t("folders"), href: "/folders", icon: <Folder size={18} /> },
|
{ label: t("folders"), href: "/decks", icon: <Folder size={18} /> },
|
||||||
{ label: t("explore"), href: "/explore", icon: <Compass size={18} /> },
|
{ label: t("explore"), href: "/explore", icon: <Compass size={18} /> },
|
||||||
...(session ? [{ label: t("favorites"), href: "/favorites", icon: <Heart size={18} /> }] : []),
|
...(session ? [{ label: t("favorites"), href: "/favorites", icon: <Heart size={18} /> }] : []),
|
||||||
{ label: t("sourceCode"), href: "https://github.com/GoddoNebianU/learn-languages", icon: <Github size={18} />, external: true },
|
{ label: t("sourceCode"), href: "https://github.com/GoddoNebianU/learn-languages", icon: <Github size={18} />, external: true },
|
||||||
@@ -42,7 +42,7 @@ export async function Navbar() {
|
|||||||
</GhostLightButton>
|
</GhostLightButton>
|
||||||
<div className="flex gap-0.5 justify-center items-center">
|
<div className="flex gap-0.5 justify-center items-center">
|
||||||
<LanguageSettings />
|
<LanguageSettings />
|
||||||
<GhostLightButton href="/folders" className="md:block! hidden!" size="md">
|
<GhostLightButton href="/decks" className="md:block! hidden!" size="md">
|
||||||
{t("folders")}
|
{t("folders")}
|
||||||
</GhostLightButton>
|
</GhostLightButton>
|
||||||
<GhostLightButton href="/explore" className="md:block! hidden!" size="md">
|
<GhostLightButton href="/explore" className="md:block! hidden!" size="md">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext, useEffect, useState } from "react";
|
import { createContext, useContext, useEffect, useState, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
THEME_PRESETS,
|
THEME_PRESETS,
|
||||||
DEFAULT_THEME,
|
DEFAULT_THEME,
|
||||||
@@ -20,26 +20,33 @@ const ThemeContext = createContext<ThemeContextType | null>(null);
|
|||||||
|
|
||||||
const STORAGE_KEY = "theme-preset";
|
const STORAGE_KEY = "theme-preset";
|
||||||
|
|
||||||
|
function getInitialTheme(): string {
|
||||||
|
if (typeof window === "undefined") return DEFAULT_THEME;
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return saved && getThemePreset(saved) ? saved : DEFAULT_THEME;
|
||||||
|
}
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [currentTheme, setCurrentTheme] = useState<string>(DEFAULT_THEME);
|
const [currentTheme, setCurrentTheme] = useState<string>(DEFAULT_THEME);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [hydrated, setHydrated] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
const savedTheme = getInitialTheme();
|
||||||
const savedTheme = localStorage.getItem(STORAGE_KEY);
|
if (savedTheme !== currentTheme) {
|
||||||
if (savedTheme && getThemePreset(savedTheme)) {
|
|
||||||
setCurrentTheme(savedTheme);
|
setCurrentTheme(savedTheme);
|
||||||
}
|
}
|
||||||
|
setHydrated(true);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mounted) return;
|
if (!hydrated) return;
|
||||||
const preset = getThemePreset(currentTheme);
|
const preset = getThemePreset(currentTheme);
|
||||||
if (preset) {
|
if (preset) {
|
||||||
applyThemeColors(preset);
|
applyThemeColors(preset);
|
||||||
localStorage.setItem(STORAGE_KEY, currentTheme);
|
localStorage.setItem(STORAGE_KEY, currentTheme);
|
||||||
}
|
}
|
||||||
}, [currentTheme, mounted]);
|
}, [currentTheme, hydrated]);
|
||||||
|
|
||||||
const setTheme = (themeId: string) => {
|
const setTheme = (themeId: string) => {
|
||||||
if (getThemePreset(themeId)) {
|
if (getThemePreset(themeId)) {
|
||||||
@@ -47,11 +54,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const themePreset = getThemePreset(currentTheme) || THEME_PRESETS[0];
|
const themePreset = useMemo(() => getThemePreset(currentTheme) || THEME_PRESETS[0], [currentTheme]);
|
||||||
|
|
||||||
if (!mounted) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider
|
<ThemeContext.Provider
|
||||||
|
|||||||
@@ -21,7 +21,25 @@ const COMMON_LANGUAGES = [
|
|||||||
{ label: "portuguese", value: "portuguese" },
|
{ label: "portuguese", value: "portuguese" },
|
||||||
{ label: "russian", value: "russian" },
|
{ label: "russian", value: "russian" },
|
||||||
{ label: "other", value: "other" },
|
{ label: "other", value: "other" },
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
|
type LocaleLabel = typeof COMMON_LANGUAGES[number]["label"];
|
||||||
|
|
||||||
|
function getLocaleLabel(t: (key: string) => string, label: LocaleLabel): string {
|
||||||
|
switch (label) {
|
||||||
|
case "chinese": return t("translator.chinese");
|
||||||
|
case "english": return t("translator.english");
|
||||||
|
case "italian": return t("translator.italian");
|
||||||
|
case "japanese": return t("translator.japanese");
|
||||||
|
case "korean": return t("translator.korean");
|
||||||
|
case "french": return t("translator.french");
|
||||||
|
case "german": return t("translator.german");
|
||||||
|
case "spanish": return t("translator.spanish");
|
||||||
|
case "portuguese": return t("translator.portuguese");
|
||||||
|
case "russian": return t("translator.russian");
|
||||||
|
case "other": return t("translator.other");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface LocaleSelectorProps {
|
interface LocaleSelectorProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -62,7 +80,7 @@ export function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
|
|||||||
>
|
>
|
||||||
{COMMON_LANGUAGES.map((lang) => (
|
{COMMON_LANGUAGES.map((lang) => (
|
||||||
<option key={lang.value} value={lang.value}>
|
<option key={lang.value} value={lang.value}>
|
||||||
{t(`translator.${lang.label}`)}
|
{getLocaleLabel(t, lang.label)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -14,17 +14,31 @@ export async function generateEntries(
|
|||||||
const isWord = inputType === "word";
|
const isWord = inputType === "word";
|
||||||
|
|
||||||
const prompt = `
|
const prompt = `
|
||||||
生成词典条目。词语:"${standardForm}"(${queryLang})。用${definitionLang}释义。
|
你是专业词典编纂专家。为词条"${standardForm}"(${queryLang})生成${definitionLang}释义。
|
||||||
|
|
||||||
返回 JSON:
|
【核心要求】
|
||||||
${isWord ? `{"entries":[{"ipa":"音标","partOfSpeech":"词性","definition":"释义","example":"例句"}]}` : `{"entries":[{"definition":"释义","example":"例句"}]}`}
|
生成尽可能完整、全面的词典条目,包括:
|
||||||
|
${isWord ? `- 所有常见词性(名词、动词、形容词、副词等)
|
||||||
|
- 每个词性下的所有常用义项
|
||||||
|
- 专业领域含义、口语含义、习语用法` : `- 所有常见含义和用法
|
||||||
|
- 字面义和引申义
|
||||||
|
- 不同语境下的解释`}
|
||||||
|
|
||||||
只返回 JSON。
|
【JSON格式】
|
||||||
|
${isWord ? `{"entries":[{"ipa":"国际音标","partOfSpeech":"词性","definition":"详细释义","example":"自然例句"}]}` : `{"entries":[{"definition":"详细释义","example":"自然例句"}]}`}
|
||||||
|
|
||||||
|
【质量标准】
|
||||||
|
- 条目数量:尽可能多,不要遗漏常用义项
|
||||||
|
- 释义:准确、完整、符合母语者习惯
|
||||||
|
- 例句:自然、地道、展示实际用法
|
||||||
|
- IPA:使用标准国际音标(单词/短语必填)
|
||||||
|
|
||||||
|
只返回JSON,不要其他内容。
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await getAnswer([
|
const result = await getAnswer([
|
||||||
{ role: "system", content: "词典条目生成器,只返回 JSON。" },
|
{ role: "system", content: "专业词典编纂专家,返回完整JSON词典数据。" },
|
||||||
{ role: "user", content: prompt },
|
{ role: "user", content: prompt },
|
||||||
]).then(parseAIGeneratedJSON<EntriesGenerationResult>);
|
]).then(parseAIGeneratedJSON<EntriesGenerationResult>);
|
||||||
|
|
||||||
@@ -47,6 +61,7 @@ ${isWord ? `{"entries":[{"ipa":"音标","partOfSpeech":"词性","definition":"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.info("Generated dictionary entries", { count: result.entries.length });
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Entries generation failed", { error: error instanceof Error ? error.message : String(error) });
|
log.error("Entries generation failed", { error: error instanceof Error ? error.message : String(error) });
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
"use server";
|
"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<
|
type Messages = Array<
|
||||||
| { role: "system"; content: string }
|
| { role: "system"; content: string }
|
||||||
| { role: "user"; content: string }
|
| { role: "user"; content: string }
|
||||||
@@ -20,13 +13,29 @@ async function getAnswer(prompt: string | Messages): Promise<string> {
|
|||||||
? [{ role: "user", content: prompt }]
|
? [{ role: "user", content: prompt }]
|
||||||
: prompt;
|
: prompt;
|
||||||
|
|
||||||
const response = await openai.chat.completions.create({
|
const response = await fetch("https://open.bigmodel.cn/api/paas/v4/chat/completions", {
|
||||||
model: process.env.ZHIPU_MODEL_NAME || "glm-4",
|
method: "POST",
|
||||||
messages: messages as OpenAI.Chat.Completions.ChatCompletionMessageParam[],
|
headers: {
|
||||||
temperature: 0.2,
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${process.env.ZHIPU_API_KEY}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: process.env.ZHIPU_MODEL_NAME || "glm-4.6",
|
||||||
|
messages,
|
||||||
|
temperature: 0.2,
|
||||||
|
thinking: {
|
||||||
|
type: "disabled"
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const content = response.choices[0]?.message?.content;
|
if (!response.ok) {
|
||||||
|
throw new Error(`AI API 请求失败: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as { choices?: Array<{ message?: { content?: string } }> };
|
||||||
|
const content = data.choices?.[0]?.message?.content;
|
||||||
|
|
||||||
if (!content) {
|
if (!content) {
|
||||||
throw new Error("AI API 返回空响应");
|
throw new Error("AI API 返回空响应");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,12 +168,12 @@ export async function executeTranslation(
|
|||||||
let targetIpa: string | undefined;
|
let targetIpa: string | undefined;
|
||||||
|
|
||||||
if (needIpa) {
|
if (needIpa) {
|
||||||
log.debug("[Stage 3] Generating IPA");
|
log.debug("[Stage 3] Generating IPA in parallel");
|
||||||
sourceIpa = await generateIPA(sourceText, detectedLanguage);
|
[sourceIpa, targetIpa] = await Promise.all([
|
||||||
log.debug("[Stage 3] Source IPA", { sourceIpa });
|
generateIPA(sourceText, detectedLanguage),
|
||||||
|
generateIPA(translatedText, targetLanguage),
|
||||||
targetIpa = await generateIPA(translatedText, targetLanguage);
|
]);
|
||||||
log.debug("[Stage 3] Target IPA", { targetIpa });
|
log.debug("[Stage 3] IPA complete", { sourceIpa, targetIpa });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assemble final result
|
// Assemble final result
|
||||||
|
|||||||
@@ -74,20 +74,8 @@ export async function repoDeleteUserCascade(dto: RepoInputDeleteUserCascade): Pr
|
|||||||
log.info("Starting cascade delete for user", { userId });
|
log.info("Starting cascade delete for user", { userId });
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
await tx.revlog.deleteMany({
|
|
||||||
where: { card: { note: { userId } } }
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.card.deleteMany({
|
await tx.card.deleteMany({
|
||||||
where: { note: { userId } }
|
where: { deck: { userId } }
|
||||||
});
|
|
||||||
|
|
||||||
await tx.note.deleteMany({
|
|
||||||
where: { userId }
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.noteType.deleteMany({
|
|
||||||
where: { userId }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.deckFavorite.deleteMany({
|
await tx.deckFavorite.deleteMany({
|
||||||
@@ -107,14 +95,6 @@ export async function repoDeleteUserCascade(dto: RepoInputDeleteUserCascade): Pr
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.dictionaryLookUp.deleteMany({
|
|
||||||
where: { userId }
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.translationHistory.deleteMany({
|
|
||||||
where: { userId }
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.session.deleteMany({
|
await tx.session.deleteMany({
|
||||||
where: { userId }
|
where: { userId }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,164 +1,54 @@
|
|||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { generateValidator } from "@/utils/validate";
|
import { generateValidator } from "@/utils/validate";
|
||||||
|
import type { RepoOutputCard, RepoOutputCardStats, CardMeaning, CardType } from "./card-repository-dto";
|
||||||
|
|
||||||
|
export type ActionOutputCard = RepoOutputCard;
|
||||||
|
export type ActionOutputCardStats = RepoOutputCardStats;
|
||||||
|
export type { CardMeaning, CardType };
|
||||||
|
|
||||||
|
const CardMeaningSchema = z.object({
|
||||||
|
partOfSpeech: z.string().nullable(),
|
||||||
|
definition: z.string(),
|
||||||
|
example: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
export const schemaActionInputCreateCard = z.object({
|
export const schemaActionInputCreateCard = z.object({
|
||||||
noteId: z.bigint(),
|
|
||||||
deckId: z.number().int().positive(),
|
deckId: z.number().int().positive(),
|
||||||
ord: z.number().int().min(0).optional(),
|
word: z.string().min(1),
|
||||||
|
ipa: z.string().optional().nullable(),
|
||||||
|
queryLang: z.string().min(1),
|
||||||
|
cardType: z.enum(["WORD", "PHRASE", "SENTENCE"]),
|
||||||
|
meanings: z.array(CardMeaningSchema).min(1),
|
||||||
});
|
});
|
||||||
export type ActionInputCreateCard = z.infer<typeof schemaActionInputCreateCard>;
|
export type ActionInputCreateCard = z.infer<typeof schemaActionInputCreateCard>;
|
||||||
export const validateActionInputCreateCard = generateValidator(schemaActionInputCreateCard);
|
export const validateActionInputCreateCard = generateValidator(schemaActionInputCreateCard);
|
||||||
|
|
||||||
export const schemaActionInputAnswerCard = z.object({
|
export const schemaActionInputUpdateCard = z.object({
|
||||||
cardId: z.bigint(),
|
cardId: z.number().int().positive(),
|
||||||
ease: z.union([
|
word: z.string().min(1).optional(),
|
||||||
z.literal(1),
|
ipa: z.string().optional().nullable(),
|
||||||
z.literal(2),
|
meanings: z.array(CardMeaningSchema).min(1).optional(),
|
||||||
z.literal(3),
|
|
||||||
z.literal(4),
|
|
||||||
]),
|
|
||||||
});
|
});
|
||||||
export type ActionInputAnswerCard = z.infer<typeof schemaActionInputAnswerCard>;
|
export type ActionInputUpdateCard = z.infer<typeof schemaActionInputUpdateCard>;
|
||||||
export const validateActionInputAnswerCard = generateValidator(schemaActionInputAnswerCard);
|
export const validateActionInputUpdateCard = generateValidator(schemaActionInputUpdateCard);
|
||||||
|
|
||||||
export const schemaActionInputGetCardsForReview = z.object({
|
export const schemaActionInputDeleteCard = z.object({
|
||||||
deckId: z.number().int().positive(),
|
cardId: z.number().int().positive(),
|
||||||
limit: z.number().int().min(1).max(100).optional(),
|
|
||||||
});
|
});
|
||||||
export type ActionInputGetCardsForReview = z.infer<typeof schemaActionInputGetCardsForReview>;
|
export type ActionInputDeleteCard = z.infer<typeof schemaActionInputDeleteCard>;
|
||||||
export const validateActionInputGetCardsForReview = generateValidator(schemaActionInputGetCardsForReview);
|
export const validateActionInputDeleteCard = generateValidator(schemaActionInputDeleteCard);
|
||||||
|
|
||||||
export const schemaActionInputGetNewCards = z.object({
|
|
||||||
deckId: z.number().int().positive(),
|
|
||||||
limit: z.number().int().min(1).max(100).optional(),
|
|
||||||
});
|
|
||||||
export type ActionInputGetNewCards = z.infer<typeof schemaActionInputGetNewCards>;
|
|
||||||
export const validateActionInputGetNewCards = generateValidator(schemaActionInputGetNewCards);
|
|
||||||
|
|
||||||
export const schemaActionInputGetCardsByDeckId = z.object({
|
export const schemaActionInputGetCardsByDeckId = z.object({
|
||||||
deckId: z.number().int().positive(),
|
deckId: z.number().int().positive(),
|
||||||
limit: z.number().int().min(1).max(100).optional(),
|
limit: z.number().int().min(1).max(100).optional(),
|
||||||
offset: z.number().int().min(0).optional(),
|
offset: z.number().int().min(0).optional(),
|
||||||
queue: z.union([
|
|
||||||
z.enum(["USER_BURIED", "SCHED_BURIED", "SUSPENDED", "NEW", "LEARNING", "REVIEW", "IN_LEARNING", "PREVIEW"]),
|
|
||||||
z.array(z.enum(["USER_BURIED", "SCHED_BURIED", "SUSPENDED", "NEW", "LEARNING", "REVIEW", "IN_LEARNING", "PREVIEW"])),
|
|
||||||
]).optional(),
|
|
||||||
});
|
});
|
||||||
export type ActionInputGetCardsByDeckId = z.infer<typeof schemaActionInputGetCardsByDeckId>;
|
export type ActionInputGetCardsByDeckId = z.infer<typeof schemaActionInputGetCardsByDeckId>;
|
||||||
export const validateActionInputGetCardsByDeckId = generateValidator(schemaActionInputGetCardsByDeckId);
|
export const validateActionInputGetCardsByDeckId = generateValidator(schemaActionInputGetCardsByDeckId);
|
||||||
|
|
||||||
export const schemaActionInputGetCardStats = z.object({
|
export const schemaActionInputGetRandomCard = z.object({
|
||||||
deckId: z.number().int().positive(),
|
deckId: z.number().int().positive(),
|
||||||
|
excludeIds: z.array(z.number().int().positive()).optional(),
|
||||||
});
|
});
|
||||||
export type ActionInputGetCardStats = z.infer<typeof schemaActionInputGetCardStats>;
|
export type ActionInputGetRandomCard = z.infer<typeof schemaActionInputGetRandomCard>;
|
||||||
export const validateActionInputGetCardStats = generateValidator(schemaActionInputGetCardStats);
|
export const validateActionInputGetRandomCard = generateValidator(schemaActionInputGetRandomCard);
|
||||||
|
|
||||||
export const schemaActionInputDeleteCard = z.object({
|
|
||||||
cardId: z.bigint(),
|
|
||||||
});
|
|
||||||
export type ActionInputDeleteCard = z.infer<typeof schemaActionInputDeleteCard>;
|
|
||||||
export const validateActionInputDeleteCard = generateValidator(schemaActionInputDeleteCard);
|
|
||||||
|
|
||||||
export const schemaActionInputGetCardById = z.object({
|
|
||||||
cardId: z.bigint(),
|
|
||||||
});
|
|
||||||
export type ActionInputGetCardById = z.infer<typeof schemaActionInputGetCardById>;
|
|
||||||
export const validateActionInputGetCardById = generateValidator(schemaActionInputGetCardById);
|
|
||||||
|
|
||||||
export type ActionOutputCard = {
|
|
||||||
id: string;
|
|
||||||
noteId: string;
|
|
||||||
deckId: number;
|
|
||||||
ord: number;
|
|
||||||
mod: number;
|
|
||||||
usn: number;
|
|
||||||
type: "NEW" | "LEARNING" | "REVIEW" | "RELEARNING";
|
|
||||||
queue: "USER_BURIED" | "SCHED_BURIED" | "SUSPENDED" | "NEW" | "LEARNING" | "REVIEW" | "IN_LEARNING" | "PREVIEW";
|
|
||||||
due: number;
|
|
||||||
ivl: number;
|
|
||||||
factor: number;
|
|
||||||
reps: number;
|
|
||||||
lapses: number;
|
|
||||||
left: number;
|
|
||||||
odue: number;
|
|
||||||
odid: number;
|
|
||||||
flags: number;
|
|
||||||
data: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActionOutputCardWithNote = ActionOutputCard & {
|
|
||||||
note: {
|
|
||||||
id: string;
|
|
||||||
flds: string;
|
|
||||||
sfld: string;
|
|
||||||
tags: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActionOutputCardStats = {
|
|
||||||
total: number;
|
|
||||||
new: number;
|
|
||||||
learning: number;
|
|
||||||
review: number;
|
|
||||||
due: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActionOutputScheduledCard = {
|
|
||||||
cardId: string;
|
|
||||||
newType: "NEW" | "LEARNING" | "REVIEW" | "RELEARNING";
|
|
||||||
newQueue: "USER_BURIED" | "SCHED_BURIED" | "SUSPENDED" | "NEW" | "LEARNING" | "REVIEW" | "IN_LEARNING" | "PREVIEW";
|
|
||||||
newDue: number;
|
|
||||||
newIvl: number;
|
|
||||||
newFactor: number;
|
|
||||||
newReps: number;
|
|
||||||
newLapses: number;
|
|
||||||
nextReviewDate: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActionOutputCreateCard = {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
data?: {
|
|
||||||
cardId: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActionOutputAnswerCard = {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
data?: {
|
|
||||||
card: ActionOutputCard;
|
|
||||||
scheduled: ActionOutputScheduledCard;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActionOutputGetCards = {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
data?: ActionOutputCard[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActionOutputGetCardsWithNote = {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
data?: ActionOutputCardWithNote[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActionOutputGetCardStats = {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
data?: ActionOutputCardStats;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActionOutputDeleteCard = {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActionOutputGetCardById = {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
data?: ActionOutputCardWithNote;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -4,300 +4,131 @@ import { auth } from "@/auth";
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { createLogger } from "@/lib/logger";
|
import { createLogger } from "@/lib/logger";
|
||||||
import { ValidateError } from "@/lib/errors";
|
import { ValidateError } from "@/lib/errors";
|
||||||
import {
|
|
||||||
ActionInputCreateCard,
|
|
||||||
ActionInputAnswerCard,
|
|
||||||
ActionInputGetCardsForReview,
|
|
||||||
ActionInputGetNewCards,
|
|
||||||
ActionInputGetCardsByDeckId,
|
|
||||||
ActionInputGetCardStats,
|
|
||||||
ActionInputDeleteCard,
|
|
||||||
ActionInputGetCardById,
|
|
||||||
ActionOutputCreateCard,
|
|
||||||
ActionOutputAnswerCard,
|
|
||||||
ActionOutputGetCards,
|
|
||||||
ActionOutputGetCardsWithNote,
|
|
||||||
ActionOutputGetCardStats,
|
|
||||||
ActionOutputDeleteCard,
|
|
||||||
ActionOutputGetCardById,
|
|
||||||
ActionOutputCard,
|
|
||||||
ActionOutputCardWithNote,
|
|
||||||
ActionOutputScheduledCard,
|
|
||||||
validateActionInputCreateCard,
|
|
||||||
validateActionInputAnswerCard,
|
|
||||||
validateActionInputGetCardsForReview,
|
|
||||||
validateActionInputGetNewCards,
|
|
||||||
validateActionInputGetCardsByDeckId,
|
|
||||||
validateActionInputGetCardStats,
|
|
||||||
validateActionInputDeleteCard,
|
|
||||||
validateActionInputGetCardById,
|
|
||||||
} from "./card-action-dto";
|
|
||||||
import {
|
import {
|
||||||
serviceCreateCard,
|
serviceCreateCard,
|
||||||
serviceAnswerCard,
|
serviceUpdateCard,
|
||||||
serviceGetCardsForReview,
|
|
||||||
serviceGetNewCards,
|
|
||||||
serviceGetCardsByDeckId,
|
|
||||||
serviceGetCardsByDeckIdWithNotes,
|
|
||||||
serviceGetCardStats,
|
|
||||||
serviceDeleteCard,
|
serviceDeleteCard,
|
||||||
serviceGetCardByIdWithNote,
|
serviceGetCardById,
|
||||||
serviceCheckCardOwnership,
|
serviceGetCardsByDeckId,
|
||||||
|
serviceGetRandomCard,
|
||||||
|
serviceGetCardStats,
|
||||||
|
serviceCheckDeckOwnership,
|
||||||
} from "./card-service";
|
} from "./card-service";
|
||||||
import { CardQueue } from "../../../generated/prisma/enums";
|
import type { ActionOutputCard, ActionOutputCardStats } from "./card-action-dto";
|
||||||
|
import type { CardMeaning, CardType } from "./card-repository-dto";
|
||||||
|
import {
|
||||||
|
validateActionInputCreateCard,
|
||||||
|
validateActionInputUpdateCard,
|
||||||
|
validateActionInputDeleteCard,
|
||||||
|
validateActionInputGetCardsByDeckId,
|
||||||
|
validateActionInputGetRandomCard,
|
||||||
|
} from "./card-action-dto";
|
||||||
|
|
||||||
const log = createLogger("card-action");
|
const log = createLogger("card-action");
|
||||||
|
|
||||||
function mapCardToOutput(card: {
|
function mapCardToOutput(card: any): ActionOutputCard {
|
||||||
id: bigint;
|
|
||||||
noteId: bigint;
|
|
||||||
deckId: number;
|
|
||||||
ord: number;
|
|
||||||
mod: number;
|
|
||||||
usn: number;
|
|
||||||
type: string;
|
|
||||||
queue: string;
|
|
||||||
due: number;
|
|
||||||
ivl: number;
|
|
||||||
factor: number;
|
|
||||||
reps: number;
|
|
||||||
lapses: number;
|
|
||||||
left: number;
|
|
||||||
odue: number;
|
|
||||||
odid: number;
|
|
||||||
flags: number;
|
|
||||||
data: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}): ActionOutputCard {
|
|
||||||
return {
|
return {
|
||||||
id: card.id.toString(),
|
id: card.id,
|
||||||
noteId: card.noteId.toString(),
|
|
||||||
deckId: card.deckId,
|
deckId: card.deckId,
|
||||||
ord: card.ord,
|
word: card.word,
|
||||||
mod: card.mod,
|
ipa: card.ipa,
|
||||||
usn: card.usn,
|
queryLang: card.queryLang,
|
||||||
type: card.type as ActionOutputCard["type"],
|
cardType: card.cardType,
|
||||||
queue: card.queue as ActionOutputCard["queue"],
|
meanings: card.meanings,
|
||||||
due: card.due,
|
|
||||||
ivl: card.ivl,
|
|
||||||
factor: card.factor,
|
|
||||||
reps: card.reps,
|
|
||||||
lapses: card.lapses,
|
|
||||||
left: card.left,
|
|
||||||
odue: card.odue,
|
|
||||||
odid: card.odid,
|
|
||||||
flags: card.flags,
|
|
||||||
data: card.data,
|
|
||||||
createdAt: card.createdAt,
|
createdAt: card.createdAt,
|
||||||
updatedAt: card.updatedAt,
|
updatedAt: card.updatedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapCardWithNoteToOutput(card: {
|
|
||||||
id: bigint;
|
|
||||||
noteId: bigint;
|
|
||||||
deckId: number;
|
|
||||||
ord: number;
|
|
||||||
mod: number;
|
|
||||||
usn: number;
|
|
||||||
type: string;
|
|
||||||
queue: string;
|
|
||||||
due: number;
|
|
||||||
ivl: number;
|
|
||||||
factor: number;
|
|
||||||
reps: number;
|
|
||||||
lapses: number;
|
|
||||||
left: number;
|
|
||||||
odue: number;
|
|
||||||
odid: number;
|
|
||||||
flags: number;
|
|
||||||
data: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
note: {
|
|
||||||
id: bigint;
|
|
||||||
flds: string;
|
|
||||||
sfld: string;
|
|
||||||
tags: string;
|
|
||||||
};
|
|
||||||
}): ActionOutputCardWithNote {
|
|
||||||
return {
|
|
||||||
...mapCardToOutput(card),
|
|
||||||
note: {
|
|
||||||
id: card.note.id.toString(),
|
|
||||||
flds: card.note.flds,
|
|
||||||
sfld: card.note.sfld,
|
|
||||||
tags: card.note.tags,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapScheduledToOutput(scheduled: {
|
|
||||||
cardId: bigint;
|
|
||||||
newType: string;
|
|
||||||
newQueue: string;
|
|
||||||
newDue: number;
|
|
||||||
newIvl: number;
|
|
||||||
newFactor: number;
|
|
||||||
newReps: number;
|
|
||||||
newLapses: number;
|
|
||||||
nextReviewDate: Date;
|
|
||||||
}): ActionOutputScheduledCard {
|
|
||||||
return {
|
|
||||||
cardId: scheduled.cardId.toString(),
|
|
||||||
newType: scheduled.newType as ActionOutputScheduledCard["newType"],
|
|
||||||
newQueue: scheduled.newQueue as ActionOutputScheduledCard["newQueue"],
|
|
||||||
newDue: scheduled.newDue,
|
|
||||||
newIvl: scheduled.newIvl,
|
|
||||||
newFactor: scheduled.newFactor,
|
|
||||||
newReps: scheduled.newReps,
|
|
||||||
newLapses: scheduled.newLapses,
|
|
||||||
nextReviewDate: scheduled.nextReviewDate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkCardOwnership(cardId: bigint): Promise<boolean> {
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
|
||||||
if (!session?.user?.id) return false;
|
|
||||||
|
|
||||||
return serviceCheckCardOwnership({ cardId, userId: session.user.id });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCurrentUserId(): Promise<string | null> {
|
async function getCurrentUserId(): Promise<string | null> {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
return session?.user?.id ?? null;
|
return session?.user?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function actionCreateCard(
|
async function checkDeckOwnership(deckId: number): Promise<boolean> {
|
||||||
input: unknown,
|
const userId = await getCurrentUserId();
|
||||||
): Promise<ActionOutputCreateCard> {
|
if (!userId) return false;
|
||||||
|
return serviceCheckDeckOwnership({ deckId, userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function actionCreateCard(input: unknown) {
|
||||||
try {
|
try {
|
||||||
const userId = await getCurrentUserId();
|
const userId = await getCurrentUserId();
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return { success: false, message: "Unauthorized" };
|
return { success: false, message: "Unauthorized" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const validated = validateActionInputCreateCard(input);
|
const validated = validateActionInputCreateCard(input);
|
||||||
const cardId = await serviceCreateCard(validated);
|
const isOwner = await checkDeckOwnership(validated.deckId);
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: "Card created successfully",
|
|
||||||
data: { cardId: cardId.toString() },
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ValidateError) {
|
|
||||||
return { success: false, message: e.message };
|
|
||||||
}
|
|
||||||
log.error("Failed to create card", { error: e });
|
|
||||||
return { success: false, message: "An error occurred while creating the card" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function actionAnswerCard(
|
|
||||||
input: unknown,
|
|
||||||
): Promise<ActionOutputAnswerCard> {
|
|
||||||
try {
|
|
||||||
const userId = await getCurrentUserId();
|
|
||||||
if (!userId) {
|
|
||||||
return { success: false, message: "Unauthorized" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const validated = validateActionInputAnswerCard(input);
|
|
||||||
|
|
||||||
const isOwner = await checkCardOwnership(validated.cardId);
|
|
||||||
if (!isOwner) {
|
if (!isOwner) {
|
||||||
return { success: false, message: "You do not have permission to answer this card" };
|
return { success: false, message: "You do not have permission to add cards to this deck" };
|
||||||
}
|
}
|
||||||
|
const result = await serviceCreateCard(validated);
|
||||||
const result = await serviceAnswerCard(validated);
|
return result;
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: "Card answered successfully",
|
|
||||||
data: {
|
|
||||||
card: mapCardToOutput(result.card),
|
|
||||||
scheduled: mapScheduledToOutput(result.scheduled),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ValidateError) {
|
if (e instanceof ValidateError) {
|
||||||
return { success: false, message: e.message };
|
return { success: false, message: e.message };
|
||||||
}
|
}
|
||||||
log.error("Failed to answer card", { error: e });
|
log.error("Failed to create card", { error: e instanceof Error ? e.message : String(e) });
|
||||||
return { success: false, message: "An error occurred while answering the card" };
|
return { success: false, message: "Failed to create card" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function actionGetCardsForReview(
|
export async function actionUpdateCard(input: unknown) {
|
||||||
input: unknown,
|
try {
|
||||||
): Promise<ActionOutputGetCardsWithNote> {
|
const validated = validateActionInputUpdateCard(input);
|
||||||
|
const card = await serviceGetCardById(validated.cardId);
|
||||||
|
if (!card) {
|
||||||
|
return { success: false, message: "Card not found" };
|
||||||
|
}
|
||||||
|
const isOwner = await checkDeckOwnership(card.deckId);
|
||||||
|
if (!isOwner) {
|
||||||
|
return { success: false, message: "You do not have permission to update this card" };
|
||||||
|
}
|
||||||
|
const result = await serviceUpdateCard(validated);
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ValidateError) {
|
||||||
|
return { success: false, message: e.message };
|
||||||
|
}
|
||||||
|
log.error("Failed to update card", { error: e instanceof Error ? e.message : String(e) });
|
||||||
|
return { success: false, message: "Failed to update card" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function actionDeleteCard(input: unknown) {
|
||||||
|
try {
|
||||||
|
const validated = validateActionInputDeleteCard(input);
|
||||||
|
const card = await serviceGetCardById(validated.cardId);
|
||||||
|
if (!card) {
|
||||||
|
return { success: false, message: "Card not found" };
|
||||||
|
}
|
||||||
|
const isOwner = await checkDeckOwnership(card.deckId);
|
||||||
|
if (!isOwner) {
|
||||||
|
return { success: false, message: "You do not have permission to delete this card" };
|
||||||
|
}
|
||||||
|
const result = await serviceDeleteCard(validated);
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ValidateError) {
|
||||||
|
return { success: false, message: e.message };
|
||||||
|
}
|
||||||
|
log.error("Failed to delete card", { error: e instanceof Error ? e.message : String(e) });
|
||||||
|
return { success: false, message: "Failed to delete card" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function actionGetCardsByDeckId(input: unknown) {
|
||||||
try {
|
try {
|
||||||
const userId = await getCurrentUserId();
|
const userId = await getCurrentUserId();
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return { success: false, message: "Unauthorized" };
|
return { success: false, message: "Unauthorized" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const validated = validateActionInputGetCardsForReview(input);
|
|
||||||
const cards = await serviceGetCardsForReview(validated);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: "Cards fetched successfully",
|
|
||||||
data: cards.map(mapCardWithNoteToOutput),
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ValidateError) {
|
|
||||||
return { success: false, message: e.message };
|
|
||||||
}
|
|
||||||
log.error("Failed to get cards for review", { error: e });
|
|
||||||
return { success: false, message: "An error occurred while fetching cards" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function actionGetNewCards(
|
|
||||||
input: unknown,
|
|
||||||
): Promise<ActionOutputGetCardsWithNote> {
|
|
||||||
try {
|
|
||||||
const userId = await getCurrentUserId();
|
|
||||||
if (!userId) {
|
|
||||||
return { success: false, message: "Unauthorized" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const validated = validateActionInputGetNewCards(input);
|
|
||||||
const cards = await serviceGetNewCards(validated);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: "New cards fetched successfully",
|
|
||||||
data: cards.map(mapCardWithNoteToOutput),
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ValidateError) {
|
|
||||||
return { success: false, message: e.message };
|
|
||||||
}
|
|
||||||
log.error("Failed to get new cards", { error: e });
|
|
||||||
return { success: false, message: "An error occurred while fetching new cards" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function actionGetCardsByDeckId(
|
|
||||||
input: unknown,
|
|
||||||
): Promise<ActionOutputGetCards> {
|
|
||||||
try {
|
|
||||||
const userId = await getCurrentUserId();
|
|
||||||
if (!userId) {
|
|
||||||
return { success: false, message: "Unauthorized" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const validated = validateActionInputGetCardsByDeckId(input);
|
const validated = validateActionInputGetCardsByDeckId(input);
|
||||||
const queue = validated.queue as CardQueue | CardQueue[] | undefined;
|
const isOwner = await checkDeckOwnership(validated.deckId);
|
||||||
const cards = await serviceGetCardsByDeckId({
|
if (!isOwner) {
|
||||||
...validated,
|
return { success: false, message: "You do not have permission to view cards in this deck" };
|
||||||
queue,
|
}
|
||||||
});
|
const cards = await serviceGetCardsByDeckId(validated);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Cards fetched successfully",
|
message: "Cards fetched successfully",
|
||||||
@@ -307,121 +138,83 @@ export async function actionGetCardsByDeckId(
|
|||||||
if (e instanceof ValidateError) {
|
if (e instanceof ValidateError) {
|
||||||
return { success: false, message: e.message };
|
return { success: false, message: e.message };
|
||||||
}
|
}
|
||||||
log.error("Failed to get cards by deck", { error: e });
|
log.error("Failed to get cards", { error: e instanceof Error ? e.message : String(e) });
|
||||||
return { success: false, message: "An error occurred while fetching cards" };
|
return { success: false, message: "Failed to get cards" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function actionGetCardsByDeckIdWithNotes(
|
export async function actionGetCardById(cardId: number) {
|
||||||
input: unknown,
|
|
||||||
): Promise<ActionOutputGetCardsWithNote> {
|
|
||||||
try {
|
try {
|
||||||
const userId = await getCurrentUserId();
|
const userId = await getCurrentUserId();
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return { success: false, message: "Unauthorized" };
|
return { success: false, message: "Unauthorized" };
|
||||||
}
|
}
|
||||||
|
const card = await serviceGetCardById(cardId);
|
||||||
const validated = validateActionInputGetCardsByDeckId(input);
|
if (!card) {
|
||||||
const queue = validated.queue as CardQueue | CardQueue[] | undefined;
|
return { success: false, message: "Card not found" };
|
||||||
const cards = await serviceGetCardsByDeckIdWithNotes({
|
}
|
||||||
...validated,
|
const isOwner = await checkDeckOwnership(card.deckId);
|
||||||
queue,
|
if (!isOwner) {
|
||||||
});
|
return { success: false, message: "You do not have permission to view this card" };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Cards fetched successfully",
|
message: "Card fetched successfully",
|
||||||
data: cards.map(mapCardWithNoteToOutput),
|
data: mapCardToOutput(card),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
log.error("Failed to get card", { error: e instanceof Error ? e.message : String(e) });
|
||||||
|
return { success: false, message: "Failed to get card" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function actionGetRandomCard(input: unknown) {
|
||||||
|
try {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return { success: false, message: "Unauthorized" };
|
||||||
|
}
|
||||||
|
const validated = validateActionInputGetRandomCard(input);
|
||||||
|
const isOwner = await checkDeckOwnership(validated.deckId);
|
||||||
|
if (!isOwner) {
|
||||||
|
return { success: false, message: "You do not have permission to view cards in this deck" };
|
||||||
|
}
|
||||||
|
const card = await serviceGetRandomCard(validated);
|
||||||
|
if (!card) {
|
||||||
|
return { success: false, message: "No cards available" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Random card fetched successfully",
|
||||||
|
data: mapCardToOutput(card),
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ValidateError) {
|
if (e instanceof ValidateError) {
|
||||||
return { success: false, message: e.message };
|
return { success: false, message: e.message };
|
||||||
}
|
}
|
||||||
log.error("Failed to get cards by deck with notes", { error: e });
|
log.error("Failed to get random card", { error: e instanceof Error ? e.message : String(e) });
|
||||||
return { success: false, message: "An error occurred while fetching cards" };
|
return { success: false, message: "Failed to get random card" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function actionGetCardStats(
|
export async function actionGetCardStats(deckId: number) {
|
||||||
input: unknown,
|
|
||||||
): Promise<ActionOutputGetCardStats> {
|
|
||||||
try {
|
try {
|
||||||
const userId = await getCurrentUserId();
|
const userId = await getCurrentUserId();
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return { success: false, message: "Unauthorized" };
|
return { success: false, message: "Unauthorized" };
|
||||||
}
|
}
|
||||||
|
const isOwner = await checkDeckOwnership(deckId);
|
||||||
const validated = validateActionInputGetCardStats(input);
|
if (!isOwner) {
|
||||||
const stats = await serviceGetCardStats(validated);
|
return { success: false, message: "You do not have permission to view stats for this deck" };
|
||||||
|
}
|
||||||
|
const stats = await serviceGetCardStats(deckId);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Card stats fetched successfully",
|
message: "Card stats fetched successfully",
|
||||||
data: stats,
|
data: stats,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ValidateError) {
|
log.error("Failed to get card stats", { error: e instanceof Error ? e.message : String(e) });
|
||||||
return { success: false, message: e.message };
|
return { success: false, message: "Failed to get card stats" };
|
||||||
}
|
|
||||||
log.error("Failed to get card stats", { error: e });
|
|
||||||
return { success: false, message: "An error occurred while fetching card stats" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function actionDeleteCard(
|
|
||||||
input: unknown,
|
|
||||||
): Promise<ActionOutputDeleteCard> {
|
|
||||||
try {
|
|
||||||
const userId = await getCurrentUserId();
|
|
||||||
if (!userId) {
|
|
||||||
return { success: false, message: "Unauthorized" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const validated = validateActionInputDeleteCard(input);
|
|
||||||
|
|
||||||
const isOwner = await checkCardOwnership(validated.cardId);
|
|
||||||
if (!isOwner) {
|
|
||||||
return { success: false, message: "You do not have permission to delete this card" };
|
|
||||||
}
|
|
||||||
|
|
||||||
await serviceDeleteCard(validated.cardId);
|
|
||||||
|
|
||||||
return { success: true, message: "Card deleted successfully" };
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ValidateError) {
|
|
||||||
return { success: false, message: e.message };
|
|
||||||
}
|
|
||||||
log.error("Failed to delete card", { error: e });
|
|
||||||
return { success: false, message: "An error occurred while deleting the card" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function actionGetCardById(
|
|
||||||
input: unknown,
|
|
||||||
): Promise<ActionOutputGetCardById> {
|
|
||||||
try {
|
|
||||||
const userId = await getCurrentUserId();
|
|
||||||
if (!userId) {
|
|
||||||
return { success: false, message: "Unauthorized" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const validated = validateActionInputGetCardById(input);
|
|
||||||
const card = await serviceGetCardByIdWithNote(validated.cardId);
|
|
||||||
|
|
||||||
if (!card) {
|
|
||||||
return { success: false, message: "Card not found" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: "Card fetched successfully",
|
|
||||||
data: mapCardWithNoteToOutput(card),
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ValidateError) {
|
|
||||||
return { success: false, message: e.message };
|
|
||||||
}
|
|
||||||
log.error("Failed to get card by id", { error: e });
|
|
||||||
return { success: false, message: "An error occurred while fetching the card" };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,104 +1,65 @@
|
|||||||
import { CardType, CardQueue } from "../../../generated/prisma/enums";
|
export type CardMeaning = {
|
||||||
|
partOfSpeech: string | null;
|
||||||
|
definition: string;
|
||||||
|
example?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CardTypeEnum = {
|
||||||
|
WORD: "WORD",
|
||||||
|
PHRASE: "PHRASE",
|
||||||
|
SENTENCE: "SENTENCE",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type CardType = keyof typeof CardTypeEnum;
|
||||||
|
|
||||||
export interface RepoInputCreateCard {
|
export interface RepoInputCreateCard {
|
||||||
id: bigint;
|
|
||||||
noteId: bigint;
|
|
||||||
deckId: number;
|
deckId: number;
|
||||||
ord: number;
|
word: string;
|
||||||
due: number;
|
ipa?: string | null;
|
||||||
type?: CardType;
|
queryLang: string;
|
||||||
queue?: CardQueue;
|
cardType: CardType;
|
||||||
ivl?: number;
|
meanings: CardMeaning[];
|
||||||
factor?: number;
|
|
||||||
reps?: number;
|
|
||||||
lapses?: number;
|
|
||||||
left?: number;
|
|
||||||
odue?: number;
|
|
||||||
odid?: number;
|
|
||||||
flags?: number;
|
|
||||||
data?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RepoInputUpdateCard {
|
export interface RepoInputUpdateCard {
|
||||||
ord?: number;
|
cardId: number;
|
||||||
mod?: number;
|
word?: string;
|
||||||
usn?: number;
|
ipa?: string | null;
|
||||||
type?: CardType;
|
meanings?: CardMeaning[];
|
||||||
queue?: CardQueue;
|
}
|
||||||
due?: number;
|
|
||||||
ivl?: number;
|
export interface RepoInputDeleteCard {
|
||||||
factor?: number;
|
cardId: number;
|
||||||
reps?: number;
|
|
||||||
lapses?: number;
|
|
||||||
left?: number;
|
|
||||||
odue?: number;
|
|
||||||
odid?: number;
|
|
||||||
flags?: number;
|
|
||||||
data?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RepoInputGetCardsByDeckId {
|
export interface RepoInputGetCardsByDeckId {
|
||||||
deckId: number;
|
deckId: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
queue?: CardQueue | CardQueue[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RepoInputGetCardsForReview {
|
export interface RepoInputGetRandomCard {
|
||||||
deckId: number;
|
deckId: number;
|
||||||
limit?: number;
|
excludeIds?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RepoInputGetNewCards {
|
export interface RepoInputCheckCardOwnership {
|
||||||
deckId: number;
|
cardId: number;
|
||||||
limit?: number;
|
userId: string;
|
||||||
}
|
|
||||||
|
|
||||||
export interface RepoInputBulkUpdateCard {
|
|
||||||
id: bigint;
|
|
||||||
data: RepoInputUpdateCard;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RepoInputBulkUpdateCards {
|
|
||||||
cards: RepoInputBulkUpdateCard[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RepoOutputCard = {
|
export type RepoOutputCard = {
|
||||||
id: bigint;
|
id: number;
|
||||||
noteId: bigint;
|
|
||||||
deckId: number;
|
deckId: number;
|
||||||
ord: number;
|
word: string;
|
||||||
mod: number;
|
ipa: string | null;
|
||||||
usn: number;
|
queryLang: string;
|
||||||
type: CardType;
|
cardType: CardType;
|
||||||
queue: CardQueue;
|
meanings: CardMeaning[];
|
||||||
due: number;
|
|
||||||
ivl: number;
|
|
||||||
factor: number;
|
|
||||||
reps: number;
|
|
||||||
lapses: number;
|
|
||||||
left: number;
|
|
||||||
odue: number;
|
|
||||||
odid: number;
|
|
||||||
flags: number;
|
|
||||||
data: string;
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
};
|
}
|
||||||
|
|
||||||
export type RepoOutputCardWithNote = RepoOutputCard & {
|
|
||||||
note: {
|
|
||||||
id: bigint;
|
|
||||||
flds: string;
|
|
||||||
sfld: string;
|
|
||||||
tags: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RepoOutputCardStats = {
|
export type RepoOutputCardStats = {
|
||||||
total: number;
|
total: number;
|
||||||
new: number;
|
}
|
||||||
learning: number;
|
|
||||||
review: number;
|
|
||||||
due: number;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,269 +1,131 @@
|
|||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { createLogger } from "@/lib/logger";
|
|
||||||
import {
|
import {
|
||||||
RepoInputCreateCard,
|
RepoInputCreateCard,
|
||||||
RepoInputUpdateCard,
|
RepoInputUpdateCard,
|
||||||
|
RepoInputDeleteCard,
|
||||||
RepoInputGetCardsByDeckId,
|
RepoInputGetCardsByDeckId,
|
||||||
RepoInputGetCardsForReview,
|
RepoInputGetRandomCard,
|
||||||
RepoInputGetNewCards,
|
RepoInputCheckCardOwnership,
|
||||||
RepoInputBulkUpdateCards,
|
|
||||||
RepoOutputCard,
|
RepoOutputCard,
|
||||||
RepoOutputCardWithNote,
|
|
||||||
RepoOutputCardStats,
|
RepoOutputCardStats,
|
||||||
|
CardMeaning,
|
||||||
} from "./card-repository-dto";
|
} from "./card-repository-dto";
|
||||||
import { CardType, CardQueue } from "../../../generated/prisma/enums";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { createLogger } from "@/lib/logger";
|
||||||
|
|
||||||
const log = createLogger("card-repository");
|
const log = createLogger("card-repository");
|
||||||
|
|
||||||
export async function repoCreateCard(
|
export async function repoCreateCard(input: RepoInputCreateCard): Promise<number> {
|
||||||
input: RepoInputCreateCard,
|
log.debug("Creating card", { deckId: input.deckId, word: input.word });
|
||||||
): Promise<bigint> {
|
|
||||||
log.debug("Creating card", { noteId: input.noteId.toString(), deckId: input.deckId });
|
|
||||||
const card = await prisma.card.create({
|
const card = await prisma.card.create({
|
||||||
data: {
|
data: {
|
||||||
id: input.id,
|
|
||||||
noteId: input.noteId,
|
|
||||||
deckId: input.deckId,
|
deckId: input.deckId,
|
||||||
ord: input.ord,
|
word: input.word,
|
||||||
due: input.due,
|
ipa: input.ipa,
|
||||||
mod: Math.floor(Date.now() / 1000),
|
queryLang: input.queryLang,
|
||||||
type: input.type ?? CardType.NEW,
|
cardType: input.cardType,
|
||||||
queue: input.queue ?? CardQueue.NEW,
|
meanings: {
|
||||||
ivl: input.ivl ?? 0,
|
create: input.meanings.map((m: CardMeaning) => ({
|
||||||
factor: input.factor ?? 2500,
|
partOfSpeech: m.partOfSpeech,
|
||||||
reps: input.reps ?? 0,
|
definition: m.definition,
|
||||||
lapses: input.lapses ?? 0,
|
example: m.example,
|
||||||
left: input.left ?? 0,
|
})),
|
||||||
odue: input.odue ?? 0,
|
},
|
||||||
odid: input.odid ?? 0,
|
|
||||||
flags: input.flags ?? 0,
|
|
||||||
data: input.data ?? "",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
log.info("Card created", { cardId: card.id.toString() });
|
log.info("Card created", { cardId: card.id });
|
||||||
return card.id;
|
return card.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function repoUpdateCard(
|
export async function repoUpdateCard(input: RepoInputUpdateCard): Promise<void> {
|
||||||
id: bigint,
|
log.debug("Updating card", { cardId: input.cardId });
|
||||||
input: RepoInputUpdateCard,
|
await prisma.$transaction(async (tx) => {
|
||||||
): Promise<void> {
|
if (input.word !== undefined) {
|
||||||
log.debug("Updating card", { cardId: id.toString() });
|
await tx.card.update({
|
||||||
await prisma.card.update({
|
where: { id: input.cardId },
|
||||||
where: { id },
|
data: { word: input.word },
|
||||||
data: {
|
});
|
||||||
...input,
|
}
|
||||||
updatedAt: new Date(),
|
if (input.ipa !== undefined) {
|
||||||
},
|
await tx.card.update({
|
||||||
|
where: { id: input.cardId },
|
||||||
|
data: { ipa: input.ipa },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (input.meanings !== undefined) {
|
||||||
|
await tx.cardMeaning.deleteMany({
|
||||||
|
where: { cardId: input.cardId },
|
||||||
|
});
|
||||||
|
await tx.cardMeaning.createMany({
|
||||||
|
data: input.meanings.map((m: CardMeaning) => ({
|
||||||
|
cardId: input.cardId,
|
||||||
|
partOfSpeech: m.partOfSpeech,
|
||||||
|
definition: m.definition,
|
||||||
|
example: m.example,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.card.update({
|
||||||
|
where: { id: input.cardId },
|
||||||
|
data: { updatedAt: new Date() },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
log.info("Card updated", { cardId: id.toString() });
|
log.info("Card updated", { cardId: input.cardId });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function repoGetCardById(id: bigint): Promise<RepoOutputCard | null> {
|
export async function repoDeleteCard(input: RepoInputDeleteCard): Promise<void> {
|
||||||
const card = await prisma.card.findUnique({
|
log.debug("Deleting card", { cardId: input.cardId });
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoGetCardByIdWithNote(
|
|
||||||
id: bigint,
|
|
||||||
): Promise<RepoOutputCardWithNote | null> {
|
|
||||||
const card = await prisma.card.findUnique({
|
|
||||||
where: { id },
|
|
||||||
include: {
|
|
||||||
note: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
flds: true,
|
|
||||||
sfld: true,
|
|
||||||
tags: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoGetCardsByDeckId(
|
|
||||||
input: RepoInputGetCardsByDeckId,
|
|
||||||
): Promise<RepoOutputCard[]> {
|
|
||||||
const { deckId, limit = 50, offset = 0, queue } = input;
|
|
||||||
|
|
||||||
const queueFilter = queue
|
|
||||||
? Array.isArray(queue)
|
|
||||||
? { in: queue }
|
|
||||||
: queue
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const cards = await prisma.card.findMany({
|
|
||||||
where: {
|
|
||||||
deckId,
|
|
||||||
queue: queueFilter,
|
|
||||||
},
|
|
||||||
orderBy: { due: "asc" },
|
|
||||||
take: limit,
|
|
||||||
skip: offset,
|
|
||||||
});
|
|
||||||
|
|
||||||
log.debug("Fetched cards by deck", { deckId, count: cards.length });
|
|
||||||
return cards;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoGetCardsByDeckIdWithNotes(
|
|
||||||
input: RepoInputGetCardsByDeckId,
|
|
||||||
): Promise<RepoOutputCardWithNote[]> {
|
|
||||||
const { deckId, limit = 100, offset = 0, queue } = input;
|
|
||||||
|
|
||||||
const queueFilter = queue
|
|
||||||
? Array.isArray(queue)
|
|
||||||
? { in: queue }
|
|
||||||
: queue
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const cards = await prisma.card.findMany({
|
|
||||||
where: {
|
|
||||||
deckId,
|
|
||||||
queue: queueFilter,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
note: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
flds: true,
|
|
||||||
sfld: true,
|
|
||||||
tags: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { id: "asc" },
|
|
||||||
take: limit,
|
|
||||||
skip: offset,
|
|
||||||
});
|
|
||||||
|
|
||||||
log.debug("Fetched cards by deck with notes", { deckId, count: cards.length });
|
|
||||||
return cards;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoGetCardsForReview(
|
|
||||||
input: RepoInputGetCardsForReview,
|
|
||||||
): Promise<RepoOutputCardWithNote[]> {
|
|
||||||
const { deckId, limit = 20 } = input;
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const todayDays = Math.floor(now / 86400);
|
|
||||||
|
|
||||||
const cards = await prisma.card.findMany({
|
|
||||||
where: {
|
|
||||||
deckId,
|
|
||||||
queue: { in: [CardQueue.NEW, CardQueue.LEARNING, CardQueue.REVIEW] },
|
|
||||||
OR: [
|
|
||||||
{ type: CardType.NEW },
|
|
||||||
{
|
|
||||||
type: { in: [CardType.LEARNING, CardType.REVIEW] },
|
|
||||||
due: { lte: todayDays },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
note: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
flds: true,
|
|
||||||
sfld: true,
|
|
||||||
tags: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: [
|
|
||||||
{ type: "asc" },
|
|
||||||
{ due: "asc" },
|
|
||||||
],
|
|
||||||
take: limit,
|
|
||||||
});
|
|
||||||
|
|
||||||
log.debug("Fetched cards for review", { deckId, count: cards.length });
|
|
||||||
return cards;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoGetNewCards(
|
|
||||||
input: RepoInputGetNewCards,
|
|
||||||
): Promise<RepoOutputCardWithNote[]> {
|
|
||||||
const { deckId, limit = 20 } = input;
|
|
||||||
|
|
||||||
const cards = await prisma.card.findMany({
|
|
||||||
where: {
|
|
||||||
deckId,
|
|
||||||
type: CardType.NEW,
|
|
||||||
queue: CardQueue.NEW,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
note: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
flds: true,
|
|
||||||
sfld: true,
|
|
||||||
tags: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { due: "asc" },
|
|
||||||
take: limit,
|
|
||||||
});
|
|
||||||
|
|
||||||
log.debug("Fetched new cards", { deckId, count: cards.length });
|
|
||||||
return cards;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoDeleteCard(id: bigint): Promise<void> {
|
|
||||||
log.debug("Deleting card", { cardId: id.toString() });
|
|
||||||
await prisma.card.delete({
|
await prisma.card.delete({
|
||||||
where: { id },
|
where: { id: input.cardId },
|
||||||
});
|
});
|
||||||
log.info("Card deleted", { cardId: id.toString() });
|
log.info("Card deleted", { cardId: input.cardId });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function repoBulkUpdateCards(
|
export async function repoGetCardById(cardId: number): Promise<RepoOutputCard | null> {
|
||||||
input: RepoInputBulkUpdateCards,
|
const card = await prisma.card.findUnique({
|
||||||
): Promise<void> {
|
where: { id: cardId },
|
||||||
log.debug("Bulk updating cards", { count: input.cards.length });
|
include: { meanings: { orderBy: { createdAt: "asc" } } },
|
||||||
|
});
|
||||||
await prisma.$transaction(
|
return card as RepoOutputCard | null;
|
||||||
input.cards.map((item) =>
|
|
||||||
prisma.card.update({
|
|
||||||
where: { id: item.id },
|
|
||||||
data: {
|
|
||||||
...item.data,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
log.info("Bulk update completed", { count: input.cards.length });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function repoGetCardStats(deckId: number): Promise<RepoOutputCardStats> {
|
export async function repoGetCardsByDeckId(input: RepoInputGetCardsByDeckId): Promise<RepoOutputCard[]> {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const { deckId, limit = 50, offset = 0 } = input;
|
||||||
const todayDays = Math.floor(now / 86400);
|
const cards = await prisma.card.findMany({
|
||||||
|
where: { deckId },
|
||||||
const [total, newCards, learning, review, due] = await Promise.all([
|
include: { meanings: { orderBy: { createdAt: "asc" } } },
|
||||||
prisma.card.count({ where: { deckId } }),
|
orderBy: { createdAt: "desc" },
|
||||||
prisma.card.count({ where: { deckId, type: CardType.NEW } }),
|
take: limit,
|
||||||
prisma.card.count({ where: { deckId, type: CardType.LEARNING } }),
|
skip: offset,
|
||||||
prisma.card.count({ where: { deckId, type: CardType.REVIEW } }),
|
});
|
||||||
prisma.card.count({
|
log.debug("Fetched cards by deck", { deckId, count: cards.length });
|
||||||
where: {
|
return cards as RepoOutputCard[];
|
||||||
deckId,
|
|
||||||
type: { in: [CardType.LEARNING, CardType.REVIEW] },
|
|
||||||
due: { lte: todayDays },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return { total, new: newCards, learning, review, due };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function repoGetCardDeckOwnerId(cardId: bigint): Promise<string | null> {
|
export async function repoGetRandomCard(input: RepoInputGetRandomCard): Promise<RepoOutputCard | null> {
|
||||||
|
const { deckId, excludeIds = [] } = input;
|
||||||
|
const whereClause = excludeIds.length > 0
|
||||||
|
? { deckId, id: { notIn: excludeIds } }
|
||||||
|
: { deckId };
|
||||||
|
const count = await prisma.card.count({ where: whereClause });
|
||||||
|
if (count === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const skip = Math.floor(Math.random() * count);
|
||||||
|
const cards = await prisma.card.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
include: { meanings: { orderBy: { createdAt: "asc" } } },
|
||||||
|
skip,
|
||||||
|
take: 1,
|
||||||
|
});
|
||||||
|
const card = cards[0];
|
||||||
|
if (!card) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
log.debug("Got random card", { cardId: card.id, deckId });
|
||||||
|
return card as RepoOutputCard;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repoGetCardDeckOwnerId(cardId: number): Promise<string | null> {
|
||||||
const card = await prisma.card.findUnique({
|
const card = await prisma.card.findUnique({
|
||||||
where: { id: cardId },
|
where: { id: cardId },
|
||||||
include: {
|
include: {
|
||||||
@@ -275,35 +137,12 @@ export async function repoGetCardDeckOwnerId(cardId: bigint): Promise<string | n
|
|||||||
return card?.deck.userId ?? null;
|
return card?.deck.userId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function repoGetNextDueCard(deckId: number): Promise<RepoOutputCard | null> {
|
export async function repoCheckCardOwnership(input: RepoInputCheckCardOwnership): Promise<boolean> {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const ownerId = await repoGetCardDeckOwnerId(input.cardId);
|
||||||
const todayDays = Math.floor(now / 86400);
|
return ownerId === input.userId;
|
||||||
|
|
||||||
const card = await prisma.card.findFirst({
|
|
||||||
where: {
|
|
||||||
deckId,
|
|
||||||
queue: { in: [CardQueue.NEW, CardQueue.LEARNING, CardQueue.REVIEW] },
|
|
||||||
OR: [
|
|
||||||
{ type: CardType.NEW },
|
|
||||||
{
|
|
||||||
type: { in: [CardType.LEARNING, CardType.REVIEW] },
|
|
||||||
due: { lte: todayDays },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
orderBy: [
|
|
||||||
{ type: "asc" },
|
|
||||||
{ due: "asc" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
return card;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function repoGetCardsByNoteId(noteId: bigint): Promise<RepoOutputCard[]> {
|
export async function repoGetCardStats(deckId: number): Promise<RepoOutputCardStats> {
|
||||||
const cards = await prisma.card.findMany({
|
const total = await prisma.card.count({ where: { deckId } });
|
||||||
where: { noteId },
|
return { total };
|
||||||
orderBy: { ord: "asc" },
|
|
||||||
});
|
|
||||||
return cards;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,126 +1,4 @@
|
|||||||
import { CardType, CardQueue } from "../../../generated/prisma/enums";
|
import type { RepoOutputCard, RepoOutputCardStats } from "./card-repository-dto";
|
||||||
|
|
||||||
export type ReviewEase = 1 | 2 | 3 | 4;
|
export type ServiceOutputCard = RepoOutputCard;
|
||||||
|
export type ServiceOutputCardStats = RepoOutputCardStats;
|
||||||
export interface ServiceInputCreateCard {
|
|
||||||
noteId: bigint;
|
|
||||||
deckId: number;
|
|
||||||
ord?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ServiceInputAnswerCard {
|
|
||||||
cardId: bigint;
|
|
||||||
ease: ReviewEase;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ServiceInputGetCardsForReview {
|
|
||||||
deckId: number;
|
|
||||||
limit?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ServiceInputGetNewCards {
|
|
||||||
deckId: number;
|
|
||||||
limit?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ServiceInputGetCardsByDeckId {
|
|
||||||
deckId: number;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
queue?: CardQueue | CardQueue[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ServiceInputGetCardStats {
|
|
||||||
deckId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ServiceInputCheckCardOwnership {
|
|
||||||
cardId: bigint;
|
|
||||||
userId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ServiceOutputCheckCardOwnership = boolean;
|
|
||||||
|
|
||||||
export type ServiceOutputCard = {
|
|
||||||
id: bigint;
|
|
||||||
noteId: bigint;
|
|
||||||
deckId: number;
|
|
||||||
ord: number;
|
|
||||||
mod: number;
|
|
||||||
usn: number;
|
|
||||||
type: CardType;
|
|
||||||
queue: CardQueue;
|
|
||||||
due: number;
|
|
||||||
ivl: number;
|
|
||||||
factor: number;
|
|
||||||
reps: number;
|
|
||||||
lapses: number;
|
|
||||||
left: number;
|
|
||||||
odue: number;
|
|
||||||
odid: number;
|
|
||||||
flags: number;
|
|
||||||
data: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ServiceOutputCardWithNote = ServiceOutputCard & {
|
|
||||||
note: {
|
|
||||||
id: bigint;
|
|
||||||
flds: string;
|
|
||||||
sfld: string;
|
|
||||||
tags: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ServiceOutputCardStats = {
|
|
||||||
total: number;
|
|
||||||
new: number;
|
|
||||||
learning: number;
|
|
||||||
review: number;
|
|
||||||
due: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ServiceOutputScheduledCard = {
|
|
||||||
cardId: bigint;
|
|
||||||
newType: CardType;
|
|
||||||
newQueue: CardQueue;
|
|
||||||
newDue: number;
|
|
||||||
newIvl: number;
|
|
||||||
newFactor: number;
|
|
||||||
newReps: number;
|
|
||||||
newLapses: number;
|
|
||||||
nextReviewDate: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ServiceOutputReviewResult = {
|
|
||||||
success: boolean;
|
|
||||||
card: ServiceOutputCard;
|
|
||||||
scheduled: ServiceOutputScheduledCard;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SM2_CONFIG = {
|
|
||||||
LEARNING_STEPS: [1, 10],
|
|
||||||
RELEARNING_STEPS: [10],
|
|
||||||
GRADUATING_INTERVAL_GOOD: 1,
|
|
||||||
GRADUATING_INTERVAL_EASY: 4,
|
|
||||||
EASY_INTERVAL: 4,
|
|
||||||
MINIMUM_FACTOR: 1300,
|
|
||||||
DEFAULT_FACTOR: 2500,
|
|
||||||
MAXIMUM_INTERVAL: 36500,
|
|
||||||
EASY_BONUS: 1.3,
|
|
||||||
HARD_INTERVAL: 1.2,
|
|
||||||
NEW_INTERVAL: 0.0,
|
|
||||||
INTERVAL_MODIFIER: 1.0,
|
|
||||||
FACTOR_ADJUSTMENTS: {
|
|
||||||
1: -200,
|
|
||||||
2: -150,
|
|
||||||
3: 0,
|
|
||||||
4: 150,
|
|
||||||
},
|
|
||||||
INITIAL_INTERVALS: {
|
|
||||||
2: 1,
|
|
||||||
3: 3,
|
|
||||||
4: 4,
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|||||||
@@ -1,485 +1,104 @@
|
|||||||
|
import type {
|
||||||
|
RepoOutputCard,
|
||||||
|
RepoOutputCardStats,
|
||||||
|
CardMeaning,
|
||||||
|
CardType,
|
||||||
|
} from "./card-repository-dto";
|
||||||
|
import type {
|
||||||
|
RepoInputCreateCard,
|
||||||
|
RepoInputUpdateCard,
|
||||||
|
RepoInputDeleteCard,
|
||||||
|
RepoInputGetCardsByDeckId,
|
||||||
|
RepoInputGetRandomCard,
|
||||||
|
RepoInputCheckCardOwnership,
|
||||||
|
} from "./card-repository-dto";
|
||||||
|
import { repoGetUserIdByDeckId } from "@/modules/deck/deck-repository";
|
||||||
import { createLogger } from "@/lib/logger";
|
import { createLogger } from "@/lib/logger";
|
||||||
import {
|
import {
|
||||||
repoCreateCard,
|
repoCreateCard,
|
||||||
repoUpdateCard,
|
repoUpdateCard,
|
||||||
repoGetCardById,
|
|
||||||
repoGetCardByIdWithNote,
|
|
||||||
repoGetCardsByDeckId,
|
|
||||||
repoGetCardsByDeckIdWithNotes,
|
|
||||||
repoGetCardsForReview,
|
|
||||||
repoGetNewCards,
|
|
||||||
repoGetCardStats,
|
|
||||||
repoDeleteCard,
|
repoDeleteCard,
|
||||||
repoGetCardsByNoteId,
|
repoGetCardById,
|
||||||
repoGetCardDeckOwnerId,
|
repoGetCardsByDeckId,
|
||||||
|
repoGetRandomCard,
|
||||||
|
repoGetCardStats,
|
||||||
|
repoCheckCardOwnership,
|
||||||
} from "./card-repository";
|
} from "./card-repository";
|
||||||
import {
|
|
||||||
RepoInputUpdateCard,
|
|
||||||
RepoOutputCard,
|
|
||||||
} from "./card-repository-dto";
|
|
||||||
import {
|
|
||||||
ServiceInputCreateCard,
|
|
||||||
ServiceInputAnswerCard,
|
|
||||||
ServiceInputGetCardsForReview,
|
|
||||||
ServiceInputGetNewCards,
|
|
||||||
ServiceInputGetCardsByDeckId,
|
|
||||||
ServiceInputGetCardStats,
|
|
||||||
ServiceInputCheckCardOwnership,
|
|
||||||
ServiceOutputCard,
|
|
||||||
ServiceOutputCardWithNote,
|
|
||||||
ServiceOutputCardStats,
|
|
||||||
ServiceOutputScheduledCard,
|
|
||||||
ServiceOutputReviewResult,
|
|
||||||
ServiceOutputCheckCardOwnership,
|
|
||||||
ReviewEase,
|
|
||||||
SM2_CONFIG,
|
|
||||||
} from "./card-service-dto";
|
|
||||||
import { CardType, CardQueue } from "../../../generated/prisma/enums";
|
|
||||||
|
|
||||||
const log = createLogger("card-service");
|
const log = createLogger("card-service");
|
||||||
|
|
||||||
function generateCardId(): bigint {
|
export type { CardMeaning as ServiceCardMeaning, CardType as ServiceCardType };
|
||||||
return BigInt(Date.now());
|
|
||||||
|
export type ServiceInputCreateCard = RepoInputCreateCard;
|
||||||
|
export type ServiceInputUpdateCard = RepoInputUpdateCard;
|
||||||
|
export type ServiceInputDeleteCard = RepoInputDeleteCard;
|
||||||
|
export type ServiceInputGetCardsByDeckId = RepoInputGetCardsByDeckId;
|
||||||
|
export type ServiceInputGetRandomCard = RepoInputGetRandomCard;
|
||||||
|
export type ServiceInputCheckCardOwnership = RepoInputCheckCardOwnership;
|
||||||
|
|
||||||
|
export type ServiceInputCheckDeckOwnership = {
|
||||||
|
deckId: number;
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceOutputCard = RepoOutputCard;
|
||||||
|
export type ServiceOutputCardStats = RepoOutputCardStats;
|
||||||
|
|
||||||
|
export async function serviceCreateCard(input: ServiceInputCreateCard): Promise<{ success: boolean; cardId?: number; message: string }> {
|
||||||
|
log.info("Creating card", { deckId: input.deckId, word: input.word });
|
||||||
|
const cardId = await repoCreateCard(input);
|
||||||
|
log.info("Card created", { cardId });
|
||||||
|
return { success: true, cardId, message: "Card created successfully" };
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateDueDate(intervalDays: number): number {
|
export async function serviceUpdateCard(input: ServiceInputUpdateCard): Promise<{ success: boolean; message: string }> {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
log.info("Updating card", { cardId: input.cardId });
|
||||||
const todayStart = Math.floor(now / 86400) * 86400;
|
|
||||||
return Math.floor(todayStart / 86400) + intervalDays;
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateNextReviewTime(intervalDays: number): Date {
|
|
||||||
const now = Date.now();
|
|
||||||
return new Date(now + intervalDays * 86400 * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampInterval(interval: number): number {
|
|
||||||
return Math.min(Math.max(1, interval), SM2_CONFIG.MAXIMUM_INTERVAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleNewCard(ease: ReviewEase, currentFactor: number): {
|
|
||||||
type: CardType;
|
|
||||||
queue: CardQueue;
|
|
||||||
ivl: number;
|
|
||||||
due: number;
|
|
||||||
newFactor: number;
|
|
||||||
} {
|
|
||||||
if (ease === 1) {
|
|
||||||
return {
|
|
||||||
type: CardType.LEARNING,
|
|
||||||
queue: CardQueue.LEARNING,
|
|
||||||
ivl: 0,
|
|
||||||
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60,
|
|
||||||
newFactor: currentFactor,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ease === 2) {
|
|
||||||
if (SM2_CONFIG.LEARNING_STEPS.length >= 2) {
|
|
||||||
const avgStep = (SM2_CONFIG.LEARNING_STEPS[0] + SM2_CONFIG.LEARNING_STEPS[1]) / 2;
|
|
||||||
return {
|
|
||||||
type: CardType.LEARNING,
|
|
||||||
queue: CardQueue.LEARNING,
|
|
||||||
ivl: 0,
|
|
||||||
due: Math.floor(Date.now() / 1000) + avgStep * 60,
|
|
||||||
newFactor: currentFactor,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
type: CardType.LEARNING,
|
|
||||||
queue: CardQueue.LEARNING,
|
|
||||||
ivl: 0,
|
|
||||||
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60,
|
|
||||||
newFactor: currentFactor,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ease === 3) {
|
|
||||||
return {
|
|
||||||
type: CardType.LEARNING,
|
|
||||||
queue: CardQueue.LEARNING,
|
|
||||||
ivl: 0,
|
|
||||||
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60,
|
|
||||||
newFactor: currentFactor,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const ivl = SM2_CONFIG.EASY_INTERVAL;
|
|
||||||
const newFactor = SM2_CONFIG.DEFAULT_FACTOR + SM2_CONFIG.FACTOR_ADJUSTMENTS[4];
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: CardType.REVIEW,
|
|
||||||
queue: CardQueue.REVIEW,
|
|
||||||
ivl,
|
|
||||||
due: calculateDueDate(ivl),
|
|
||||||
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, newFactor),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleLearningCard(ease: ReviewEase, currentFactor: number, left: number): {
|
|
||||||
type: CardType;
|
|
||||||
queue: CardQueue;
|
|
||||||
ivl: number;
|
|
||||||
due: number;
|
|
||||||
newFactor: number;
|
|
||||||
newLeft: number;
|
|
||||||
} {
|
|
||||||
const steps = SM2_CONFIG.LEARNING_STEPS;
|
|
||||||
const totalSteps = steps.length;
|
|
||||||
|
|
||||||
if (ease === 1) {
|
|
||||||
return {
|
|
||||||
type: CardType.LEARNING,
|
|
||||||
queue: CardQueue.LEARNING,
|
|
||||||
ivl: 0,
|
|
||||||
due: Math.floor(Date.now() / 1000) + steps[0] * 60,
|
|
||||||
newFactor: currentFactor,
|
|
||||||
newLeft: totalSteps * 1000,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const stepIndex = Math.floor(left % 1000);
|
|
||||||
|
|
||||||
if (ease === 2) {
|
|
||||||
if (stepIndex === 0 && steps.length >= 2) {
|
|
||||||
const avgStep = (steps[0] + steps[1]) / 2;
|
|
||||||
return {
|
|
||||||
type: CardType.LEARNING,
|
|
||||||
queue: CardQueue.LEARNING,
|
|
||||||
ivl: 0,
|
|
||||||
due: Math.floor(Date.now() / 1000) + avgStep * 60,
|
|
||||||
newFactor: currentFactor,
|
|
||||||
newLeft: left,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (stepIndex < steps.length - 1) {
|
|
||||||
const nextStep = stepIndex + 1;
|
|
||||||
return {
|
|
||||||
type: CardType.LEARNING,
|
|
||||||
queue: CardQueue.LEARNING,
|
|
||||||
ivl: 0,
|
|
||||||
due: Math.floor(Date.now() / 1000) + steps[nextStep] * 60,
|
|
||||||
newFactor: currentFactor,
|
|
||||||
newLeft: nextStep * 1000 + (totalSteps - nextStep),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ease === 3) {
|
|
||||||
if (stepIndex < steps.length - 1) {
|
|
||||||
const nextStep = stepIndex + 1;
|
|
||||||
return {
|
|
||||||
type: CardType.LEARNING,
|
|
||||||
queue: CardQueue.LEARNING,
|
|
||||||
ivl: 0,
|
|
||||||
due: Math.floor(Date.now() / 1000) + steps[nextStep] * 60,
|
|
||||||
newFactor: currentFactor,
|
|
||||||
newLeft: nextStep * 1000 + (totalSteps - nextStep),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const ivl = SM2_CONFIG.GRADUATING_INTERVAL_GOOD;
|
|
||||||
return {
|
|
||||||
type: CardType.REVIEW,
|
|
||||||
queue: CardQueue.REVIEW,
|
|
||||||
ivl,
|
|
||||||
due: calculateDueDate(ivl),
|
|
||||||
newFactor: SM2_CONFIG.DEFAULT_FACTOR,
|
|
||||||
newLeft: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const ivl = SM2_CONFIG.GRADUATING_INTERVAL_EASY;
|
|
||||||
const newFactor = SM2_CONFIG.DEFAULT_FACTOR + SM2_CONFIG.FACTOR_ADJUSTMENTS[4];
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: CardType.REVIEW,
|
|
||||||
queue: CardQueue.REVIEW,
|
|
||||||
ivl,
|
|
||||||
due: calculateDueDate(ivl),
|
|
||||||
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, newFactor),
|
|
||||||
newLeft: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleReviewCard(
|
|
||||||
ease: ReviewEase,
|
|
||||||
currentIvl: number,
|
|
||||||
currentFactor: number,
|
|
||||||
lapses: number,
|
|
||||||
): {
|
|
||||||
type: CardType;
|
|
||||||
queue: CardQueue;
|
|
||||||
ivl: number;
|
|
||||||
due: number;
|
|
||||||
newFactor: number;
|
|
||||||
newLapses: number;
|
|
||||||
} {
|
|
||||||
if (ease === 1) {
|
|
||||||
const newFactor = Math.max(SM2_CONFIG.MINIMUM_FACTOR, currentFactor + SM2_CONFIG.FACTOR_ADJUSTMENTS[1]);
|
|
||||||
const newIvl = Math.max(1, Math.floor(currentIvl * SM2_CONFIG.NEW_INTERVAL));
|
|
||||||
return {
|
|
||||||
type: CardType.RELEARNING,
|
|
||||||
queue: CardQueue.LEARNING,
|
|
||||||
ivl: newIvl,
|
|
||||||
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.RELEARNING_STEPS[0] * 60,
|
|
||||||
newFactor,
|
|
||||||
newLapses: lapses + 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let newFactor: number;
|
|
||||||
let newIvl: number;
|
|
||||||
|
|
||||||
if (ease === 2) {
|
|
||||||
newFactor = Math.max(SM2_CONFIG.MINIMUM_FACTOR, currentFactor + SM2_CONFIG.FACTOR_ADJUSTMENTS[2]);
|
|
||||||
newIvl = Math.floor(currentIvl * SM2_CONFIG.HARD_INTERVAL * SM2_CONFIG.INTERVAL_MODIFIER);
|
|
||||||
} else if (ease === 3) {
|
|
||||||
newFactor = currentFactor;
|
|
||||||
newIvl = Math.floor(currentIvl * (currentFactor / 1000) * SM2_CONFIG.INTERVAL_MODIFIER);
|
|
||||||
} else {
|
|
||||||
newIvl = Math.floor(currentIvl * (currentFactor / 1000) * SM2_CONFIG.EASY_BONUS * SM2_CONFIG.INTERVAL_MODIFIER);
|
|
||||||
newFactor = Math.max(SM2_CONFIG.MINIMUM_FACTOR, currentFactor + SM2_CONFIG.FACTOR_ADJUSTMENTS[4]);
|
|
||||||
}
|
|
||||||
|
|
||||||
newIvl = clampInterval(newIvl);
|
|
||||||
newIvl = Math.max(currentIvl + 1, newIvl);
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: CardType.REVIEW,
|
|
||||||
queue: CardQueue.REVIEW,
|
|
||||||
ivl: newIvl,
|
|
||||||
due: calculateDueDate(newIvl),
|
|
||||||
newFactor,
|
|
||||||
newLapses: lapses,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapToServiceOutput(card: RepoOutputCard): ServiceOutputCard {
|
|
||||||
return {
|
|
||||||
id: card.id,
|
|
||||||
noteId: card.noteId,
|
|
||||||
deckId: card.deckId,
|
|
||||||
ord: card.ord,
|
|
||||||
mod: card.mod,
|
|
||||||
usn: card.usn,
|
|
||||||
type: card.type,
|
|
||||||
queue: card.queue,
|
|
||||||
due: card.due,
|
|
||||||
ivl: card.ivl,
|
|
||||||
factor: card.factor,
|
|
||||||
reps: card.reps,
|
|
||||||
lapses: card.lapses,
|
|
||||||
left: card.left,
|
|
||||||
odue: card.odue,
|
|
||||||
odid: card.odid,
|
|
||||||
flags: card.flags,
|
|
||||||
data: card.data,
|
|
||||||
createdAt: card.createdAt,
|
|
||||||
updatedAt: card.updatedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serviceCreateCard(
|
|
||||||
input: ServiceInputCreateCard,
|
|
||||||
): Promise<bigint> {
|
|
||||||
log.info("Creating card from note", { noteId: input.noteId.toString(), deckId: input.deckId });
|
|
||||||
|
|
||||||
const existingCards = await repoGetCardsByNoteId(input.noteId);
|
|
||||||
const maxOrd = existingCards.reduce((max, c) => Math.max(max, c.ord), -1);
|
|
||||||
const ord = input.ord ?? maxOrd + 1;
|
|
||||||
|
|
||||||
const cardId = await repoCreateCard({
|
|
||||||
id: generateCardId(),
|
|
||||||
noteId: input.noteId,
|
|
||||||
deckId: input.deckId,
|
|
||||||
ord,
|
|
||||||
due: ord,
|
|
||||||
type: CardType.NEW,
|
|
||||||
queue: CardQueue.NEW,
|
|
||||||
});
|
|
||||||
|
|
||||||
log.info("Card created", { cardId: cardId.toString() });
|
|
||||||
return cardId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serviceAnswerCard(
|
|
||||||
input: ServiceInputAnswerCard,
|
|
||||||
): Promise<ServiceOutputReviewResult> {
|
|
||||||
log.info("Answering card", { cardId: input.cardId.toString(), ease: input.ease });
|
|
||||||
|
|
||||||
const card = await repoGetCardById(input.cardId);
|
const card = await repoGetCardById(input.cardId);
|
||||||
if (!card) {
|
if (!card) {
|
||||||
throw new Error(`Card not found: ${input.cardId.toString()}`);
|
return { success: false, message: "Card not found" };
|
||||||
}
|
}
|
||||||
|
await repoUpdateCard(input);
|
||||||
|
log.info("Card updated", { cardId: input.cardId });
|
||||||
|
return { success: true, message: "Card updated successfully" };
|
||||||
|
}
|
||||||
|
|
||||||
const { ease } = input;
|
export async function serviceDeleteCard(input: ServiceInputDeleteCard): Promise<{ success: boolean; message: string }> {
|
||||||
let updateData: RepoInputUpdateCard;
|
log.info("Deleting card", { cardId: input.cardId });
|
||||||
let scheduled: ServiceOutputScheduledCard;
|
const card = await repoGetCardById(input.cardId);
|
||||||
|
if (!card) {
|
||||||
if (card.type === CardType.NEW) {
|
return { success: false, message: "Card not found" };
|
||||||
const result = scheduleNewCard(ease, card.factor);
|
|
||||||
updateData = {
|
|
||||||
type: result.type,
|
|
||||||
queue: result.queue,
|
|
||||||
ivl: result.ivl,
|
|
||||||
due: result.due,
|
|
||||||
factor: result.newFactor,
|
|
||||||
reps: card.reps + 1,
|
|
||||||
left: result.type === CardType.LEARNING
|
|
||||||
? SM2_CONFIG.LEARNING_STEPS.length * 1000
|
|
||||||
: 0,
|
|
||||||
mod: Math.floor(Date.now() / 1000),
|
|
||||||
};
|
|
||||||
scheduled = {
|
|
||||||
cardId: card.id,
|
|
||||||
newType: result.type,
|
|
||||||
newQueue: result.queue,
|
|
||||||
newDue: result.due,
|
|
||||||
newIvl: result.ivl,
|
|
||||||
newFactor: result.newFactor,
|
|
||||||
newReps: card.reps + 1,
|
|
||||||
newLapses: card.lapses,
|
|
||||||
nextReviewDate: calculateNextReviewTime(result.ivl),
|
|
||||||
};
|
|
||||||
} else if (card.type === CardType.LEARNING || card.type === CardType.RELEARNING) {
|
|
||||||
const result = scheduleLearningCard(ease, card.factor, card.left);
|
|
||||||
updateData = {
|
|
||||||
type: result.type,
|
|
||||||
queue: result.queue,
|
|
||||||
ivl: result.ivl,
|
|
||||||
due: result.due,
|
|
||||||
factor: result.newFactor,
|
|
||||||
reps: card.reps + 1,
|
|
||||||
left: result.newLeft,
|
|
||||||
mod: Math.floor(Date.now() / 1000),
|
|
||||||
};
|
|
||||||
scheduled = {
|
|
||||||
cardId: card.id,
|
|
||||||
newType: result.type,
|
|
||||||
newQueue: result.queue,
|
|
||||||
newDue: result.due,
|
|
||||||
newIvl: result.ivl,
|
|
||||||
newFactor: result.newFactor,
|
|
||||||
newReps: card.reps + 1,
|
|
||||||
newLapses: card.lapses,
|
|
||||||
nextReviewDate: calculateNextReviewTime(result.ivl),
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const result = scheduleReviewCard(ease, card.ivl, card.factor, card.lapses);
|
|
||||||
updateData = {
|
|
||||||
type: result.type,
|
|
||||||
queue: result.queue,
|
|
||||||
ivl: result.ivl,
|
|
||||||
due: result.due,
|
|
||||||
factor: result.newFactor,
|
|
||||||
reps: card.reps + 1,
|
|
||||||
lapses: result.newLapses,
|
|
||||||
left: result.type === CardType.RELEARNING
|
|
||||||
? SM2_CONFIG.RELEARNING_STEPS.length * 1000
|
|
||||||
: 0,
|
|
||||||
mod: Math.floor(Date.now() / 1000),
|
|
||||||
};
|
|
||||||
scheduled = {
|
|
||||||
cardId: card.id,
|
|
||||||
newType: result.type,
|
|
||||||
newQueue: result.queue,
|
|
||||||
newDue: result.due,
|
|
||||||
newIvl: result.ivl,
|
|
||||||
newFactor: result.newFactor,
|
|
||||||
newReps: card.reps + 1,
|
|
||||||
newLapses: result.newLapses,
|
|
||||||
nextReviewDate: calculateNextReviewTime(result.ivl),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
await repoDeleteCard(input);
|
||||||
await repoUpdateCard(input.cardId, updateData);
|
log.info("Card deleted", { cardId: input.cardId });
|
||||||
|
return { success: true, message: "Card deleted successfully" };
|
||||||
const updatedCard = await repoGetCardById(input.cardId);
|
|
||||||
if (!updatedCard) {
|
|
||||||
throw new Error(`Card not found after update: ${input.cardId.toString()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("Card answered and scheduled", {
|
|
||||||
cardId: input.cardId.toString(),
|
|
||||||
newType: scheduled.newType,
|
|
||||||
newIvl: scheduled.newIvl,
|
|
||||||
nextReview: scheduled.nextReviewDate.toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
card: mapToServiceOutput(updatedCard),
|
|
||||||
scheduled,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function serviceGetNextCardForReview(
|
export async function serviceGetCardById(cardId: number): Promise<ServiceOutputCard | null> {
|
||||||
deckId: number,
|
return repoGetCardById(cardId);
|
||||||
): Promise<ServiceOutputCardWithNote | null> {
|
|
||||||
log.debug("Getting next card for review", { deckId });
|
|
||||||
const cards = await repoGetCardsForReview({ deckId, limit: 1 });
|
|
||||||
return cards[0] ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function serviceGetCardsForReview(
|
export async function serviceGetCardsByDeckId(input: ServiceInputGetCardsByDeckId): Promise<ServiceOutputCard[]> {
|
||||||
input: ServiceInputGetCardsForReview,
|
|
||||||
): Promise<ServiceOutputCardWithNote[]> {
|
|
||||||
log.debug("Getting cards for review", { deckId: input.deckId });
|
|
||||||
return repoGetCardsForReview(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serviceGetNewCards(
|
|
||||||
input: ServiceInputGetNewCards,
|
|
||||||
): Promise<ServiceOutputCardWithNote[]> {
|
|
||||||
log.debug("Getting new cards", { deckId: input.deckId });
|
|
||||||
return repoGetNewCards(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serviceGetCardsByDeckId(
|
|
||||||
input: ServiceInputGetCardsByDeckId,
|
|
||||||
): Promise<ServiceOutputCard[]> {
|
|
||||||
log.debug("Getting cards by deck", { deckId: input.deckId });
|
log.debug("Getting cards by deck", { deckId: input.deckId });
|
||||||
const cards = await repoGetCardsByDeckId(input);
|
return repoGetCardsByDeckId(input);
|
||||||
return cards.map(mapToServiceOutput);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function serviceGetCardsByDeckIdWithNotes(
|
export async function serviceGetRandomCard(input: ServiceInputGetRandomCard): Promise<ServiceOutputCard | null> {
|
||||||
input: ServiceInputGetCardsByDeckId,
|
log.debug("Getting random card", { deckId: input.deckId });
|
||||||
): Promise<ServiceOutputCardWithNote[]> {
|
return repoGetRandomCard(input);
|
||||||
log.debug("Getting cards by deck with notes", { deckId: input.deckId });
|
|
||||||
return repoGetCardsByDeckIdWithNotes(input);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function serviceGetCardById(
|
export async function serviceGetCardStats(deckId: number): Promise<ServiceOutputCardStats> {
|
||||||
cardId: bigint,
|
log.debug("Getting card stats", { deckId });
|
||||||
): Promise<ServiceOutputCard | null> {
|
return repoGetCardStats(deckId);
|
||||||
const card = await repoGetCardById(cardId);
|
|
||||||
return card ? mapToServiceOutput(card) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function serviceGetCardByIdWithNote(
|
export async function serviceCheckCardOwnership(input: ServiceInputCheckCardOwnership): Promise<boolean> {
|
||||||
cardId: bigint,
|
log.debug("Checking card ownership", { cardId: input.cardId });
|
||||||
): Promise<ServiceOutputCardWithNote | null> {
|
return repoCheckCardOwnership(input);
|
||||||
return repoGetCardByIdWithNote(cardId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function serviceGetCardStats(
|
export async function serviceCheckDeckOwnership(input: ServiceInputCheckDeckOwnership): Promise<boolean> {
|
||||||
input: ServiceInputGetCardStats,
|
log.debug("Checking deck ownership", { deckId: input.deckId });
|
||||||
): Promise<ServiceOutputCardStats> {
|
const ownerId = await repoGetUserIdByDeckId(input.deckId);
|
||||||
log.debug("Getting card stats", { deckId: input.deckId });
|
|
||||||
return repoGetCardStats(input.deckId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serviceDeleteCard(cardId: bigint): Promise<void> {
|
|
||||||
log.info("Deleting card", { cardId: cardId.toString() });
|
|
||||||
await repoDeleteCard(cardId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serviceCheckCardOwnership(
|
|
||||||
input: ServiceInputCheckCardOwnership,
|
|
||||||
): Promise<ServiceOutputCheckCardOwnership> {
|
|
||||||
log.debug("Checking card ownership", { cardId: input.cardId.toString() });
|
|
||||||
const ownerId = await repoGetCardDeckOwnerId(input.cardId);
|
|
||||||
return ownerId === input.userId;
|
return ownerId === input.userId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export const schemaActionInputUpdateDeck = z.object({
|
|||||||
name: z.string().min(1).max(100).optional(),
|
name: z.string().min(1).max(100).optional(),
|
||||||
desc: z.string().max(500).optional(),
|
desc: z.string().max(500).optional(),
|
||||||
visibility: z.enum(["PRIVATE", "PUBLIC"]).optional(),
|
visibility: z.enum(["PRIVATE", "PUBLIC"]).optional(),
|
||||||
collapsed: z.boolean().optional(),
|
|
||||||
});
|
});
|
||||||
export type ActionInputUpdateDeck = z.infer<typeof schemaActionInputUpdateDeck>;
|
export type ActionInputUpdateDeck = z.infer<typeof schemaActionInputUpdateDeck>;
|
||||||
export const validateActionInputUpdateDeck = generateValidator(schemaActionInputUpdateDeck);
|
export const validateActionInputUpdateDeck = generateValidator(schemaActionInputUpdateDeck);
|
||||||
@@ -44,8 +43,6 @@ export type ActionOutputDeck = {
|
|||||||
desc: string;
|
desc: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
visibility: "PRIVATE" | "PUBLIC";
|
visibility: "PRIVATE" | "PUBLIC";
|
||||||
collapsed: boolean;
|
|
||||||
conf: unknown;
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
cardCount?: number;
|
cardCount?: number;
|
||||||
|
|||||||
@@ -100,7 +100,6 @@ export async function actionUpdateDeck(input: ActionInputUpdateDeck): Promise<Ac
|
|||||||
name: validatedInput.name,
|
name: validatedInput.name,
|
||||||
desc: validatedInput.desc,
|
desc: validatedInput.desc,
|
||||||
visibility: validatedInput.visibility as Visibility | undefined,
|
visibility: validatedInput.visibility as Visibility | undefined,
|
||||||
collapsed: validatedInput.collapsed,
|
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ValidateError) {
|
if (e instanceof ValidateError) {
|
||||||
|
|||||||
@@ -1,90 +1,74 @@
|
|||||||
import { Visibility } from "../../../generated/prisma/enums";
|
import { Visibility } from "../../../generated/prisma/enums";
|
||||||
|
|
||||||
|
import type { DictionaryItemWithEntries } from "@/shared/card-type";
|
||||||
|
|
||||||
export interface RepoInputCreateDeck {
|
export interface RepoInputCreateDeck {
|
||||||
name: string;
|
name: string;
|
||||||
desc?: string;
|
desc?: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
visibility?: Visibility;
|
visibility?: Visibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RepoInputUpdateDeck {
|
export interface RepoInputUpdateDeck {
|
||||||
id: number;
|
id: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
desc?: string;
|
desc?: string;
|
||||||
visibility?: Visibility;
|
visibility?: Visibility;
|
||||||
collapsed?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RepoInputGetDeckById {
|
export interface RepoInputGetDeckById {
|
||||||
id: number;
|
id: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RepoInputGetDecksByUserId {
|
export interface RepoInputGetDecksByUserId {
|
||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RepoInputGetPublicDecks {
|
export interface RepoInputGetPublicDecks {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
orderBy?: "createdAt" | "name";
|
orderBy?: "createdAt" | "name";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RepoInputDeleteDeck {
|
export interface RepoInputDeleteDeck {
|
||||||
id: number;
|
id: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RepoOutputDeck = {
|
export type RepoOutputDeck = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
desc: string;
|
desc: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
collapsed: boolean;
|
|
||||||
conf: unknown;
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
cardCount?: number;
|
cardCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RepoOutputPublicDeck = RepoOutputDeck & {
|
export type RepoOutputPublicDeck = RepoOutputDeck & {
|
||||||
userName: string | null;
|
userName: string | null;
|
||||||
userUsername: string | null;
|
userUsername: string | null;
|
||||||
favoriteCount: number;
|
favoriteCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RepoOutputDeckOwnership = {
|
export type RepoOutputDeckOwnership = {
|
||||||
userId: string;
|
userId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface RepoInputToggleDeckFavorite {
|
export interface RepoInputToggleDeckFavorite {
|
||||||
deckId: number;
|
deckId: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RepoInputCheckDeckFavorite {
|
export interface RepoInputCheckDeckFavorite {
|
||||||
deckId: number;
|
deckId: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RepoInputSearchPublicDecks {
|
export interface RepoInputSearchPublicDecks {
|
||||||
query: string;
|
query: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RepoInputGetPublicDeckById {
|
export interface RepoInputGetPublicDeckById {
|
||||||
deckId: number;
|
deckId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RepoOutputDeckFavorite = {
|
export type RepoOutputDeckFavorite = {
|
||||||
isFavorited: boolean;
|
isFavorited: boolean;
|
||||||
favoriteCount: number;
|
favoriteCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface RepoInputGetUserFavoriteDecks {
|
export interface RepoInputGetUserFavoriteDecks {
|
||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RepoOutputUserFavoriteDeck = RepoOutputPublicDeck & {
|
export type RepoOutputUserFavoriteDeck = RepoOutputPublicDeck & {
|
||||||
favoritedAt: Date;
|
favoritedAt: Date;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -57,8 +57,6 @@ export async function repoGetDeckById(input: RepoInputGetDeckById): Promise<Repo
|
|||||||
desc: deck.desc,
|
desc: deck.desc,
|
||||||
userId: deck.userId,
|
userId: deck.userId,
|
||||||
visibility: deck.visibility,
|
visibility: deck.visibility,
|
||||||
collapsed: deck.collapsed,
|
|
||||||
conf: deck.conf,
|
|
||||||
createdAt: deck.createdAt,
|
createdAt: deck.createdAt,
|
||||||
updatedAt: deck.updatedAt,
|
updatedAt: deck.updatedAt,
|
||||||
cardCount: deck._count?.cards ?? 0,
|
cardCount: deck._count?.cards ?? 0,
|
||||||
@@ -84,8 +82,6 @@ export async function repoGetDecksByUserId(input: RepoInputGetDecksByUserId): Pr
|
|||||||
desc: deck.desc,
|
desc: deck.desc,
|
||||||
userId: deck.userId,
|
userId: deck.userId,
|
||||||
visibility: deck.visibility,
|
visibility: deck.visibility,
|
||||||
collapsed: deck.collapsed,
|
|
||||||
conf: deck.conf,
|
|
||||||
createdAt: deck.createdAt,
|
createdAt: deck.createdAt,
|
||||||
updatedAt: deck.updatedAt,
|
updatedAt: deck.updatedAt,
|
||||||
cardCount: deck._count?.cards ?? 0,
|
cardCount: deck._count?.cards ?? 0,
|
||||||
@@ -116,8 +112,6 @@ export async function repoGetPublicDecks(input: RepoInputGetPublicDecks = {}): P
|
|||||||
desc: deck.desc,
|
desc: deck.desc,
|
||||||
userId: deck.userId,
|
userId: deck.userId,
|
||||||
visibility: deck.visibility,
|
visibility: deck.visibility,
|
||||||
collapsed: deck.collapsed,
|
|
||||||
conf: deck.conf,
|
|
||||||
createdAt: deck.createdAt,
|
createdAt: deck.createdAt,
|
||||||
updatedAt: deck.updatedAt,
|
updatedAt: deck.updatedAt,
|
||||||
cardCount: deck._count?.cards ?? 0,
|
cardCount: deck._count?.cards ?? 0,
|
||||||
@@ -173,8 +167,6 @@ export async function repoGetPublicDeckById(input: RepoInputGetPublicDeckById):
|
|||||||
desc: deck.desc,
|
desc: deck.desc,
|
||||||
userId: deck.userId,
|
userId: deck.userId,
|
||||||
visibility: deck.visibility,
|
visibility: deck.visibility,
|
||||||
collapsed: deck.collapsed,
|
|
||||||
conf: deck.conf,
|
|
||||||
createdAt: deck.createdAt,
|
createdAt: deck.createdAt,
|
||||||
updatedAt: deck.updatedAt,
|
updatedAt: deck.updatedAt,
|
||||||
cardCount: deck._count?.cards ?? 0,
|
cardCount: deck._count?.cards ?? 0,
|
||||||
@@ -277,8 +269,6 @@ export async function repoSearchPublicDecks(input: RepoInputSearchPublicDecks):
|
|||||||
desc: deck.desc,
|
desc: deck.desc,
|
||||||
userId: deck.userId,
|
userId: deck.userId,
|
||||||
visibility: deck.visibility,
|
visibility: deck.visibility,
|
||||||
collapsed: deck.collapsed,
|
|
||||||
conf: deck.conf,
|
|
||||||
createdAt: deck.createdAt,
|
createdAt: deck.createdAt,
|
||||||
updatedAt: deck.updatedAt,
|
updatedAt: deck.updatedAt,
|
||||||
cardCount: deck._count?.cards ?? 0,
|
cardCount: deck._count?.cards ?? 0,
|
||||||
@@ -314,8 +304,6 @@ export async function repoGetUserFavoriteDecks(
|
|||||||
desc: fav.deck.desc,
|
desc: fav.deck.desc,
|
||||||
userId: fav.deck.userId,
|
userId: fav.deck.userId,
|
||||||
visibility: fav.deck.visibility,
|
visibility: fav.deck.visibility,
|
||||||
collapsed: fav.deck.collapsed,
|
|
||||||
conf: fav.deck.conf,
|
|
||||||
createdAt: fav.deck.createdAt,
|
createdAt: fav.deck.createdAt,
|
||||||
updatedAt: fav.deck.updatedAt,
|
updatedAt: fav.deck.updatedAt,
|
||||||
cardCount: fav.deck._count?.cards ?? 0,
|
cardCount: fav.deck._count?.cards ?? 0,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export type ServiceInputUpdateDeck = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
desc?: string;
|
desc?: string;
|
||||||
visibility?: Visibility;
|
visibility?: Visibility;
|
||||||
collapsed?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ServiceInputDeleteDeck = {
|
export type ServiceInputDeleteDeck = {
|
||||||
@@ -43,8 +42,6 @@ export type ServiceOutputDeck = {
|
|||||||
desc: string;
|
desc: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
collapsed: boolean;
|
|
||||||
conf: unknown;
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
cardCount?: number;
|
cardCount?: number;
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ export async function serviceUpdateDeck(input: ServiceInputUpdateDeck): Promise<
|
|||||||
name: input.name,
|
name: input.name,
|
||||||
desc: input.desc,
|
desc: input.desc,
|
||||||
visibility: input.visibility,
|
visibility: input.visibility,
|
||||||
collapsed: input.collapsed,
|
|
||||||
});
|
});
|
||||||
log.info("Deck updated successfully", { deckId: input.deckId });
|
log.info("Deck updated successfully", { deckId: input.deckId });
|
||||||
return { success: true, message: "Deck updated successfully" };
|
return { success: true, message: "Deck updated successfully" };
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
import { TSharedItem } from "@/shared/dictionary-type";
|
|
||||||
import { LENGTH_MAX_DICTIONARY_TEXT, LENGTH_MAX_LANGUAGE, LENGTH_MIN_DICTIONARY_TEXT, LENGTH_MIN_LANGUAGE } from "@/shared/constant";
|
|
||||||
import { generateValidator } from "@/utils/validate";
|
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
import { generateValidator } from "@/utils/validate";
|
||||||
|
|
||||||
const schemaActionInputLookUpDictionary = z.object({
|
export const schemaActionLookUpDictionary = z.object({
|
||||||
text: z.string().min(LENGTH_MIN_DICTIONARY_TEXT).max(LENGTH_MAX_DICTIONARY_TEXT),
|
text: z.string().min(1),
|
||||||
queryLang: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE),
|
queryLang: z.string().min(1),
|
||||||
forceRelook: z.boolean(),
|
definitionLang: z.string().min(1),
|
||||||
definitionLang: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE),
|
|
||||||
userId: z.string().optional()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ActionInputLookUpDictionary = z.infer<typeof schemaActionInputLookUpDictionary>;
|
export type ActionInputLookUpDictionary = z.infer<typeof schemaActionLookUpDictionary>;
|
||||||
|
export const validateActionInputLookUpDictionary = generateValidator(schemaActionLookUpDictionary);
|
||||||
export const validateActionInputLookUpDictionary = generateValidator(schemaActionInputLookUpDictionary);
|
|
||||||
|
|
||||||
export type ActionOutputLookUpDictionary = {
|
export type ActionOutputLookUpDictionary = {
|
||||||
message: string,
|
success: boolean;
|
||||||
success: boolean;
|
message: string;
|
||||||
data?: TSharedItem;
|
data?: {
|
||||||
|
standardForm: string;
|
||||||
|
entries: Array<{
|
||||||
|
ipa?: string;
|
||||||
|
definition: string;
|
||||||
|
partOfSpeech?: string;
|
||||||
|
example: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,30 +1,38 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { ActionInputLookUpDictionary, ActionOutputLookUpDictionary, validateActionInputLookUpDictionary } from "./dictionary-action-dto";
|
import { executeDictionaryLookup } from "@/lib/bigmodel/dictionary/orchestrator";
|
||||||
import { ValidateError } from "@/lib/errors";
|
|
||||||
import { createLogger } from "@/lib/logger";
|
import { createLogger } from "@/lib/logger";
|
||||||
import { serviceLookUp } from "./dictionary-service";
|
import { LookUpError } from "@/lib/errors";
|
||||||
|
import {
|
||||||
|
ActionInputLookUpDictionary,
|
||||||
|
ActionOutputLookUpDictionary,
|
||||||
|
validateActionInputLookUpDictionary,
|
||||||
|
} from "./dictionary-action-dto";
|
||||||
|
|
||||||
const log = createLogger("dictionary-action");
|
const log = createLogger("dictionary-action");
|
||||||
|
|
||||||
export const actionLookUpDictionary = async (dto: ActionInputLookUpDictionary): Promise<ActionOutputLookUpDictionary> => {
|
export async function actionLookUpDictionary(
|
||||||
try {
|
input: unknown,
|
||||||
return {
|
): Promise<ActionOutputLookUpDictionary> {
|
||||||
message: 'success',
|
try {
|
||||||
success: true,
|
const validated = validateActionInputLookUpDictionary(input);
|
||||||
data: await serviceLookUp(validateActionInputLookUpDictionary(dto))
|
|
||||||
};
|
const result = await executeDictionaryLookup(
|
||||||
} catch (e) {
|
validated.text,
|
||||||
if (e instanceof ValidateError) {
|
validated.queryLang,
|
||||||
return {
|
validated.definitionLang
|
||||||
success: false,
|
);
|
||||||
message: e.message
|
|
||||||
};
|
return {
|
||||||
}
|
success: true,
|
||||||
log.error("Dictionary lookup failed", { error: e instanceof Error ? e.message : String(e) });
|
message: "Lookup successful",
|
||||||
return {
|
data: result,
|
||||||
success: false,
|
};
|
||||||
message: 'Unknown error occured.'
|
} catch (e) {
|
||||||
};
|
if (e instanceof LookUpError) {
|
||||||
|
return { success: false, message: e.message };
|
||||||
}
|
}
|
||||||
};
|
log.error("Dictionary lookup failed", { error: e instanceof Error ? e.message : String(e) });
|
||||||
|
return { success: false, message: "Lookup failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
export type RepoInputCreateDictionaryLookUp = {
|
|
||||||
userId?: string;
|
|
||||||
text: string;
|
|
||||||
queryLang: string;
|
|
||||||
definitionLang: string;
|
|
||||||
dictionaryItemId?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RepoOutputSelectLastLookUpResultEntry = {
|
|
||||||
id: number;
|
|
||||||
itemId: number;
|
|
||||||
ipa: string | null;
|
|
||||||
definition: string;
|
|
||||||
partOfSpeech: string | null;
|
|
||||||
example: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RepoOutputSelectLastLookUpResultItem = {
|
|
||||||
id: number;
|
|
||||||
frequency: number;
|
|
||||||
standardForm: string;
|
|
||||||
queryLang: string;
|
|
||||||
definitionLang: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
entries: RepoOutputSelectLastLookUpResultEntry[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RepoOutputSelectLastLookUpResult = RepoOutputSelectLastLookUpResultItem | null;
|
|
||||||
|
|
||||||
export type RepoInputCreateDictionaryItem = {
|
|
||||||
standardForm: string;
|
|
||||||
queryLang: string;
|
|
||||||
definitionLang: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RepoInputCreateDictionaryEntry = {
|
|
||||||
itemId: number;
|
|
||||||
ipa?: string;
|
|
||||||
definition: string;
|
|
||||||
partOfSpeech?: string;
|
|
||||||
example: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RepoInputCreateDictionaryEntryWithoutItemId = {
|
|
||||||
ipa?: string;
|
|
||||||
definition: string;
|
|
||||||
partOfSpeech?: string;
|
|
||||||
example: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RepoInputSelectLastLookUpResult = {
|
|
||||||
text: string,
|
|
||||||
queryLang: string,
|
|
||||||
definitionLang: string;
|
|
||||||
};
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { stringNormalize } from "@/utils/string";
|
|
||||||
import {
|
|
||||||
RepoInputCreateDictionaryEntryWithoutItemId,
|
|
||||||
RepoInputCreateDictionaryItem,
|
|
||||||
RepoInputCreateDictionaryLookUp,
|
|
||||||
RepoInputSelectLastLookUpResult,
|
|
||||||
RepoOutputSelectLastLookUpResult,
|
|
||||||
} from "./dictionary-repository-dto";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
|
|
||||||
export async function repoSelectLastLookUpResult(dto: RepoInputSelectLastLookUpResult): Promise<RepoOutputSelectLastLookUpResult> {
|
|
||||||
const result = await prisma.dictionaryLookUp.findFirst({
|
|
||||||
where: {
|
|
||||||
normalizedText: stringNormalize(dto.text),
|
|
||||||
queryLang: dto.queryLang,
|
|
||||||
definitionLang: dto.definitionLang,
|
|
||||||
dictionaryItemId: {
|
|
||||||
not: null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
dictionaryItem: {
|
|
||||||
include: {
|
|
||||||
entries: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
createdAt: 'desc'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result?.dictionaryItem) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.dictionaryItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoCreateLookUp(content: RepoInputCreateDictionaryLookUp) {
|
|
||||||
return (await prisma.dictionaryLookUp.create({
|
|
||||||
data: { ...content, normalizedText: stringNormalize(content.text) }
|
|
||||||
})).id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoCreateLookUpWithItemAndEntries(
|
|
||||||
itemData: RepoInputCreateDictionaryItem,
|
|
||||||
lookUpData: RepoInputCreateDictionaryLookUp,
|
|
||||||
entries: RepoInputCreateDictionaryEntryWithoutItemId[]
|
|
||||||
) {
|
|
||||||
return await prisma.$transaction(async (tx) => {
|
|
||||||
const item = await tx.dictionaryItem.create({
|
|
||||||
data: itemData
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.dictionaryLookUp.create({
|
|
||||||
data: {
|
|
||||||
...lookUpData,
|
|
||||||
normalizedText: stringNormalize(lookUpData.text),
|
|
||||||
dictionaryItemId: item.id
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
await tx.dictionaryEntry.create({
|
|
||||||
data: {
|
|
||||||
...entry,
|
|
||||||
itemId: item.id
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return item.id;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import { TSharedItem } from "@/shared/dictionary-type";
|
export type ServiceOutputLookUp = {
|
||||||
|
standardForm: string;
|
||||||
export type ServiceInputLookUp = {
|
entries: Array<{
|
||||||
text: string,
|
ipa?: string;
|
||||||
queryLang: string,
|
definition: string;
|
||||||
definitionLang: string,
|
partOfSpeech?: string;
|
||||||
forceRelook: boolean,
|
example: string;
|
||||||
userId?: string;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ServiceOutputLookUp = TSharedItem;
|
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import { executeDictionaryLookup } from "@/lib/bigmodel/dictionary/orchestrator";
|
|
||||||
import { repoCreateLookUp, repoCreateLookUpWithItemAndEntries, repoSelectLastLookUpResult } from "./dictionary-repository";
|
|
||||||
import { ServiceInputLookUp } from "./dictionary-service-dto";
|
|
||||||
import { createLogger } from "@/lib/logger";
|
|
||||||
import { RepoOutputSelectLastLookUpResultItem } from "./dictionary-repository-dto";
|
|
||||||
|
|
||||||
const log = createLogger("dictionary-service");
|
|
||||||
|
|
||||||
function transformRawItemToSharedItem(rawItem: RepoOutputSelectLastLookUpResultItem) {
|
|
||||||
return {
|
|
||||||
id: rawItem.id,
|
|
||||||
standardForm: rawItem.standardForm,
|
|
||||||
entries: rawItem.entries.map(entry => ({
|
|
||||||
ipa: entry.ipa ?? undefined,
|
|
||||||
definition: entry.definition,
|
|
||||||
partOfSpeech: entry.partOfSpeech ?? undefined,
|
|
||||||
example: entry.example
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const serviceLookUp = async (dto: ServiceInputLookUp) => {
|
|
||||||
const {
|
|
||||||
text,
|
|
||||||
queryLang,
|
|
||||||
userId,
|
|
||||||
definitionLang,
|
|
||||||
forceRelook
|
|
||||||
} = dto;
|
|
||||||
|
|
||||||
const lastLookUpResult = await repoSelectLastLookUpResult({
|
|
||||||
text,
|
|
||||||
queryLang,
|
|
||||||
definitionLang,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (forceRelook || !lastLookUpResult) {
|
|
||||||
const response = await executeDictionaryLookup(
|
|
||||||
text,
|
|
||||||
queryLang,
|
|
||||||
definitionLang
|
|
||||||
);
|
|
||||||
|
|
||||||
repoCreateLookUpWithItemAndEntries(
|
|
||||||
{
|
|
||||||
standardForm: response.standardForm,
|
|
||||||
queryLang,
|
|
||||||
definitionLang
|
|
||||||
},
|
|
||||||
{
|
|
||||||
userId,
|
|
||||||
text,
|
|
||||||
queryLang,
|
|
||||||
definitionLang,
|
|
||||||
},
|
|
||||||
response.entries
|
|
||||||
).catch(error => {
|
|
||||||
log.error("Failed to save dictionary data", { error: error instanceof Error ? error.message : String(error) });
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} else {
|
|
||||||
const transformedResult = transformRawItemToSharedItem(lastLookUpResult);
|
|
||||||
|
|
||||||
repoCreateLookUp({
|
|
||||||
userId: userId,
|
|
||||||
text: text,
|
|
||||||
queryLang: queryLang,
|
|
||||||
definitionLang: definitionLang,
|
|
||||||
dictionaryItemId: transformedResult.id
|
|
||||||
}).catch(error => {
|
|
||||||
log.error("Failed to save dictionary data", { error: error instanceof Error ? error.message : String(error) });
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
standardForm: transformedResult.standardForm,
|
|
||||||
entries: transformedResult.entries
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import z from "zod";
|
|
||||||
import { generateValidator } from "@/utils/validate";
|
|
||||||
import { NoteKind } from "../../../generated/prisma/enums";
|
|
||||||
import {
|
|
||||||
schemaNoteTypeField,
|
|
||||||
schemaNoteTypeTemplate,
|
|
||||||
NoteTypeField,
|
|
||||||
NoteTypeTemplate,
|
|
||||||
} from "./note-type-repository-dto";
|
|
||||||
|
|
||||||
export const LENGTH_MIN_NOTE_TYPE_NAME = 1;
|
|
||||||
export const LENGTH_MAX_NOTE_TYPE_NAME = 100;
|
|
||||||
export const LENGTH_MAX_CSS = 50000;
|
|
||||||
|
|
||||||
const schemaNoteTypeFieldAction = z.object({
|
|
||||||
name: z.string().min(1).max(schemaNoteTypeField.name.maxLength),
|
|
||||||
ord: z.number().int(),
|
|
||||||
sticky: z.boolean(),
|
|
||||||
rtl: z.boolean(),
|
|
||||||
font: z.string().max(schemaNoteTypeField.font.maxLength).optional(),
|
|
||||||
size: z.number().int().min(schemaNoteTypeField.size.min).max(schemaNoteTypeField.size.max).optional(),
|
|
||||||
media: z.array(z.string()).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const schemaNoteTypeTemplateAction = z.object({
|
|
||||||
name: z.string().min(1).max(schemaNoteTypeTemplate.name.maxLength),
|
|
||||||
ord: z.number().int(),
|
|
||||||
qfmt: z.string().min(1).max(schemaNoteTypeTemplate.qfmt.maxLength),
|
|
||||||
afmt: z.string().min(1).max(schemaNoteTypeTemplate.afmt.maxLength),
|
|
||||||
bqfmt: z.string().max(schemaNoteTypeTemplate.bqfmt.maxLength).optional(),
|
|
||||||
bafmt: z.string().max(schemaNoteTypeTemplate.bafmt.maxLength).optional(),
|
|
||||||
did: z.number().int().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemaActionInputCreateNoteType = z.object({
|
|
||||||
name: z.string().min(LENGTH_MIN_NOTE_TYPE_NAME).max(LENGTH_MAX_NOTE_TYPE_NAME),
|
|
||||||
kind: z.enum(["STANDARD", "CLOZE"]).optional(),
|
|
||||||
css: z.string().max(LENGTH_MAX_CSS).optional(),
|
|
||||||
fields: z.array(schemaNoteTypeFieldAction).min(1),
|
|
||||||
templates: z.array(schemaNoteTypeTemplateAction).min(1),
|
|
||||||
});
|
|
||||||
export type ActionInputCreateNoteType = z.infer<typeof schemaActionInputCreateNoteType>;
|
|
||||||
export const validateActionInputCreateNoteType = generateValidator(schemaActionInputCreateNoteType);
|
|
||||||
|
|
||||||
export const schemaActionInputUpdateNoteType = z.object({
|
|
||||||
id: z.number().int().positive(),
|
|
||||||
name: z.string().min(LENGTH_MIN_NOTE_TYPE_NAME).max(LENGTH_MAX_NOTE_TYPE_NAME).optional(),
|
|
||||||
kind: z.enum(["STANDARD", "CLOZE"]).optional(),
|
|
||||||
css: z.string().max(LENGTH_MAX_CSS).optional(),
|
|
||||||
fields: z.array(schemaNoteTypeFieldAction).min(1).optional(),
|
|
||||||
templates: z.array(schemaNoteTypeTemplateAction).min(1).optional(),
|
|
||||||
});
|
|
||||||
export type ActionInputUpdateNoteType = z.infer<typeof schemaActionInputUpdateNoteType>;
|
|
||||||
export const validateActionInputUpdateNoteType = generateValidator(schemaActionInputUpdateNoteType);
|
|
||||||
|
|
||||||
export const schemaActionInputGetNoteTypeById = z.object({
|
|
||||||
id: z.number().int().positive(),
|
|
||||||
});
|
|
||||||
export type ActionInputGetNoteTypeById = z.infer<typeof schemaActionInputGetNoteTypeById>;
|
|
||||||
export const validateActionInputGetNoteTypeById = generateValidator(schemaActionInputGetNoteTypeById);
|
|
||||||
|
|
||||||
export const schemaActionInputDeleteNoteType = z.object({
|
|
||||||
id: z.number().int().positive(),
|
|
||||||
});
|
|
||||||
export type ActionInputDeleteNoteType = z.infer<typeof schemaActionInputDeleteNoteType>;
|
|
||||||
export const validateActionInputDeleteNoteType = generateValidator(schemaActionInputDeleteNoteType);
|
|
||||||
|
|
||||||
export type ActionOutputNoteType = {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
kind: NoteKind;
|
|
||||||
css: string;
|
|
||||||
fields: NoteTypeField[];
|
|
||||||
templates: NoteTypeTemplate[];
|
|
||||||
userId: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActionOutputCreateNoteType = {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
data?: { id: number };
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActionOutputUpdateNoteType = {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActionOutputGetNoteTypeById = {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
data?: ActionOutputNoteType;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActionOutputGetNoteTypesByUserId = {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
data?: ActionOutputNoteType[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActionOutputDeleteNoteType = {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { createLogger } from "@/lib/logger";
|
|
||||||
import { ValidateError } from "@/lib/errors";
|
|
||||||
import {
|
|
||||||
ActionInputCreateNoteType,
|
|
||||||
ActionInputUpdateNoteType,
|
|
||||||
ActionInputDeleteNoteType,
|
|
||||||
ActionOutputCreateNoteType,
|
|
||||||
ActionOutputUpdateNoteType,
|
|
||||||
ActionOutputGetNoteTypeById,
|
|
||||||
ActionOutputGetNoteTypesByUserId,
|
|
||||||
ActionOutputDeleteNoteType,
|
|
||||||
validateActionInputCreateNoteType,
|
|
||||||
validateActionInputUpdateNoteType,
|
|
||||||
validateActionInputDeleteNoteType,
|
|
||||||
} from "./note-type-action-dto";
|
|
||||||
import {
|
|
||||||
serviceCreateNoteType,
|
|
||||||
serviceUpdateNoteType,
|
|
||||||
serviceGetNoteTypeById,
|
|
||||||
serviceGetNoteTypesByUserId,
|
|
||||||
serviceDeleteNoteType,
|
|
||||||
} from "./note-type-service";
|
|
||||||
import {
|
|
||||||
DEFAULT_BASIC_NOTE_TYPE_FIELDS,
|
|
||||||
DEFAULT_BASIC_NOTE_TYPE_TEMPLATES,
|
|
||||||
DEFAULT_BASIC_NOTE_TYPE_CSS,
|
|
||||||
DEFAULT_CLOZE_NOTE_TYPE_FIELDS,
|
|
||||||
DEFAULT_CLOZE_NOTE_TYPE_TEMPLATES,
|
|
||||||
DEFAULT_CLOZE_NOTE_TYPE_CSS,
|
|
||||||
} from "./note-type-repository-dto";
|
|
||||||
|
|
||||||
const log = createLogger("note-type-action");
|
|
||||||
|
|
||||||
export async function actionCreateNoteType(
|
|
||||||
input: ActionInputCreateNoteType,
|
|
||||||
): Promise<ActionOutputCreateNoteType> {
|
|
||||||
try {
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: "Unauthorized",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const validated = validateActionInputCreateNoteType(input);
|
|
||||||
|
|
||||||
const id = await serviceCreateNoteType({
|
|
||||||
name: validated.name,
|
|
||||||
kind: validated.kind,
|
|
||||||
css: validated.css,
|
|
||||||
fields: validated.fields,
|
|
||||||
templates: validated.templates,
|
|
||||||
userId: session.user.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: "Note type created successfully",
|
|
||||||
data: { id },
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ValidateError) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: e.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
log.error("Create note type failed", { error: e });
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: "Failed to create note type",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function actionUpdateNoteType(
|
|
||||||
input: ActionInputUpdateNoteType,
|
|
||||||
): Promise<ActionOutputUpdateNoteType> {
|
|
||||||
try {
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: "Unauthorized",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const validated = validateActionInputUpdateNoteType(input);
|
|
||||||
|
|
||||||
await serviceUpdateNoteType({
|
|
||||||
id: validated.id,
|
|
||||||
name: validated.name,
|
|
||||||
kind: validated.kind,
|
|
||||||
css: validated.css,
|
|
||||||
fields: validated.fields,
|
|
||||||
templates: validated.templates,
|
|
||||||
userId: session.user.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: "Note type updated successfully",
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ValidateError) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: e.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
log.error("Update note type failed", { error: e });
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: "Failed to update note type",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function actionGetNoteTypeById(
|
|
||||||
id: number,
|
|
||||||
): Promise<ActionOutputGetNoteTypeById> {
|
|
||||||
try {
|
|
||||||
const noteType = await serviceGetNoteTypeById({ id });
|
|
||||||
|
|
||||||
if (!noteType) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: "Note type not found",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: "Note type retrieved successfully",
|
|
||||||
data: noteType,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
log.error("Get note type failed", { error: e });
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: "Failed to retrieve note type",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function actionGetNoteTypesByUserId(): Promise<ActionOutputGetNoteTypesByUserId> {
|
|
||||||
try {
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: "Unauthorized",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const noteTypes = await serviceGetNoteTypesByUserId({
|
|
||||||
userId: session.user.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: "Note types retrieved successfully",
|
|
||||||
data: noteTypes,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
log.error("Get note types failed", { error: e });
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: "Failed to retrieve note types",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function actionDeleteNoteType(
|
|
||||||
input: ActionInputDeleteNoteType,
|
|
||||||
): Promise<ActionOutputDeleteNoteType> {
|
|
||||||
try {
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: "Unauthorized",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const validated = validateActionInputDeleteNoteType(input);
|
|
||||||
|
|
||||||
await serviceDeleteNoteType({
|
|
||||||
id: validated.id,
|
|
||||||
userId: session.user.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: "Note type deleted successfully",
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ValidateError) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: e.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
log.error("Delete note type failed", { error: e });
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: "Failed to delete note type",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function actionCreateDefaultBasicNoteType(): Promise<ActionOutputCreateNoteType> {
|
|
||||||
return actionCreateNoteType({
|
|
||||||
name: "Basic Vocabulary",
|
|
||||||
kind: "STANDARD",
|
|
||||||
css: DEFAULT_BASIC_NOTE_TYPE_CSS,
|
|
||||||
fields: DEFAULT_BASIC_NOTE_TYPE_FIELDS,
|
|
||||||
templates: DEFAULT_BASIC_NOTE_TYPE_TEMPLATES,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function actionCreateDefaultClozeNoteType(): Promise<ActionOutputCreateNoteType> {
|
|
||||||
return actionCreateNoteType({
|
|
||||||
name: "Cloze",
|
|
||||||
kind: "CLOZE",
|
|
||||||
css: DEFAULT_CLOZE_NOTE_TYPE_CSS,
|
|
||||||
fields: DEFAULT_CLOZE_NOTE_TYPE_FIELDS,
|
|
||||||
templates: DEFAULT_CLOZE_NOTE_TYPE_TEMPLATES,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function actionGetDefaultBasicNoteTypeTemplate() {
|
|
||||||
return {
|
|
||||||
name: "Basic Vocabulary",
|
|
||||||
kind: "STANDARD" as const,
|
|
||||||
css: DEFAULT_BASIC_NOTE_TYPE_CSS,
|
|
||||||
fields: DEFAULT_BASIC_NOTE_TYPE_FIELDS,
|
|
||||||
templates: DEFAULT_BASIC_NOTE_TYPE_TEMPLATES,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function actionGetDefaultClozeNoteTypeTemplate() {
|
|
||||||
return {
|
|
||||||
name: "Cloze",
|
|
||||||
kind: "CLOZE" as const,
|
|
||||||
css: DEFAULT_CLOZE_NOTE_TYPE_CSS,
|
|
||||||
fields: DEFAULT_CLOZE_NOTE_TYPE_FIELDS,
|
|
||||||
templates: DEFAULT_CLOZE_NOTE_TYPE_TEMPLATES,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
import { NoteKind } from "../../../generated/prisma/enums";
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Field Schema (Anki flds structure)
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export interface NoteTypeField {
|
|
||||||
name: string;
|
|
||||||
ord: number;
|
|
||||||
sticky: boolean;
|
|
||||||
rtl: boolean;
|
|
||||||
font?: string;
|
|
||||||
size?: number;
|
|
||||||
media?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const schemaNoteTypeField = {
|
|
||||||
name: { minLength: 1, maxLength: 50 },
|
|
||||||
font: { maxLength: 100 },
|
|
||||||
size: { min: 8, max: 72 },
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Template Schema (Anki tmpls structure)
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export interface NoteTypeTemplate {
|
|
||||||
name: string;
|
|
||||||
ord: number;
|
|
||||||
qfmt: string;
|
|
||||||
afmt: string;
|
|
||||||
bqfmt?: string;
|
|
||||||
bafmt?: string;
|
|
||||||
did?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const schemaNoteTypeTemplate = {
|
|
||||||
name: { minLength: 1, maxLength: 100 },
|
|
||||||
qfmt: { maxLength: 10000 },
|
|
||||||
afmt: { maxLength: 10000 },
|
|
||||||
bqfmt: { maxLength: 10000 },
|
|
||||||
bafmt: { maxLength: 10000 },
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Repository Input Types
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export interface RepoInputCreateNoteType {
|
|
||||||
name: string;
|
|
||||||
kind?: NoteKind;
|
|
||||||
css?: string;
|
|
||||||
fields: NoteTypeField[];
|
|
||||||
templates: NoteTypeTemplate[];
|
|
||||||
userId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RepoInputUpdateNoteType {
|
|
||||||
id: number;
|
|
||||||
name?: string;
|
|
||||||
kind?: NoteKind;
|
|
||||||
css?: string;
|
|
||||||
fields?: NoteTypeField[];
|
|
||||||
templates?: NoteTypeTemplate[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RepoInputGetNoteTypeById {
|
|
||||||
id: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RepoInputGetNoteTypesByUserId {
|
|
||||||
userId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RepoInputDeleteNoteType {
|
|
||||||
id: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RepoInputCheckNotesExist {
|
|
||||||
noteTypeId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Repository Output Types
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export type RepoOutputNoteType = {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
kind: NoteKind;
|
|
||||||
css: string;
|
|
||||||
fields: NoteTypeField[];
|
|
||||||
templates: NoteTypeTemplate[];
|
|
||||||
userId: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RepoOutputNoteTypeOwnership = {
|
|
||||||
userId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RepoOutputNotesExistCheck = {
|
|
||||||
exists: boolean;
|
|
||||||
count: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Default Note Types
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export const DEFAULT_BASIC_NOTE_TYPE_FIELDS: NoteTypeField[] = [
|
|
||||||
{ name: "Word", ord: 0, sticky: false, rtl: false, font: "Arial", size: 20 },
|
|
||||||
{ name: "Definition", ord: 1, sticky: false, rtl: false, font: "Arial", size: 20 },
|
|
||||||
{ name: "IPA", ord: 2, sticky: false, rtl: false, font: "Arial", size: 20 },
|
|
||||||
{ name: "Example", ord: 3, sticky: false, rtl: false, font: "Arial", size: 20 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const DEFAULT_BASIC_NOTE_TYPE_TEMPLATES: NoteTypeTemplate[] = [
|
|
||||||
{
|
|
||||||
name: "Word → Definition",
|
|
||||||
ord: 0,
|
|
||||||
qfmt: "{{Word}}<br>{{IPA}}",
|
|
||||||
afmt: "{{FrontSide}}<hr id=answer>{{Definition}}<br><br>{{Example}}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Definition → Word",
|
|
||||||
ord: 1,
|
|
||||||
qfmt: "{{Definition}}",
|
|
||||||
afmt: "{{FrontSide}}<hr id=answer>{{Word}}<br>{{IPA}}",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const DEFAULT_BASIC_NOTE_TYPE_CSS = `.card {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
font-size: 20px;
|
|
||||||
text-align: center;
|
|
||||||
color: #333;
|
|
||||||
background-color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card1 {
|
|
||||||
background-color: #e8f4f8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card2 {
|
|
||||||
background-color: #f8f4e8;
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
border: none;
|
|
||||||
border-top: 1px solid #ccc;
|
|
||||||
margin: 20px 0;
|
|
||||||
}`;
|
|
||||||
|
|
||||||
export const DEFAULT_CLOZE_NOTE_TYPE_FIELDS: NoteTypeField[] = [
|
|
||||||
{ name: "Text", ord: 0, sticky: false, rtl: false, font: "Arial", size: 20 },
|
|
||||||
{ name: "Extra", ord: 1, sticky: false, rtl: false, font: "Arial", size: 20 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const DEFAULT_CLOZE_NOTE_TYPE_TEMPLATES: NoteTypeTemplate[] = [
|
|
||||||
{
|
|
||||||
name: "Cloze",
|
|
||||||
ord: 0,
|
|
||||||
qfmt: "{{cloze:Text}}",
|
|
||||||
afmt: "{{cloze:Text}}<br><br>{{Extra}}",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const DEFAULT_CLOZE_NOTE_TYPE_CSS = `.card {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
font-size: 20px;
|
|
||||||
text-align: center;
|
|
||||||
color: #333;
|
|
||||||
background-color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cloze {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #0066cc;
|
|
||||||
}`;
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { createLogger } from "@/lib/logger";
|
|
||||||
import {
|
|
||||||
RepoInputCreateNoteType,
|
|
||||||
RepoInputUpdateNoteType,
|
|
||||||
RepoInputGetNoteTypeById,
|
|
||||||
RepoInputGetNoteTypesByUserId,
|
|
||||||
RepoInputDeleteNoteType,
|
|
||||||
RepoInputCheckNotesExist,
|
|
||||||
RepoOutputNoteType,
|
|
||||||
RepoOutputNoteTypeOwnership,
|
|
||||||
RepoOutputNotesExistCheck,
|
|
||||||
NoteTypeField,
|
|
||||||
NoteTypeTemplate,
|
|
||||||
} from "./note-type-repository-dto";
|
|
||||||
import { NoteKind } from "../../../generated/prisma/enums";
|
|
||||||
|
|
||||||
const log = createLogger("note-type-repository");
|
|
||||||
|
|
||||||
export async function repoCreateNoteType(
|
|
||||||
input: RepoInputCreateNoteType,
|
|
||||||
): Promise<number> {
|
|
||||||
const noteType = await prisma.noteType.create({
|
|
||||||
data: {
|
|
||||||
name: input.name,
|
|
||||||
kind: input.kind ?? NoteKind.STANDARD,
|
|
||||||
css: input.css ?? "",
|
|
||||||
fields: input.fields as unknown as object,
|
|
||||||
templates: input.templates as unknown as object,
|
|
||||||
userId: input.userId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
log.info("Created note type", { id: noteType.id, name: noteType.name });
|
|
||||||
return noteType.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoUpdateNoteType(
|
|
||||||
input: RepoInputUpdateNoteType,
|
|
||||||
): Promise<void> {
|
|
||||||
const updateData: {
|
|
||||||
name?: string;
|
|
||||||
kind?: NoteKind;
|
|
||||||
css?: string;
|
|
||||||
fields?: object;
|
|
||||||
templates?: object;
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
if (input.name !== undefined) updateData.name = input.name;
|
|
||||||
if (input.kind !== undefined) updateData.kind = input.kind;
|
|
||||||
if (input.css !== undefined) updateData.css = input.css;
|
|
||||||
if (input.fields !== undefined)
|
|
||||||
updateData.fields = input.fields as unknown as object;
|
|
||||||
if (input.templates !== undefined)
|
|
||||||
updateData.templates = input.templates as unknown as object;
|
|
||||||
|
|
||||||
await prisma.noteType.update({
|
|
||||||
where: { id: input.id },
|
|
||||||
data: updateData,
|
|
||||||
});
|
|
||||||
|
|
||||||
log.info("Updated note type", { id: input.id });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoGetNoteTypeById(
|
|
||||||
input: RepoInputGetNoteTypeById,
|
|
||||||
): Promise<RepoOutputNoteType | null> {
|
|
||||||
const noteType = await prisma.noteType.findUnique({
|
|
||||||
where: { id: input.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!noteType) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: noteType.id,
|
|
||||||
name: noteType.name,
|
|
||||||
kind: noteType.kind,
|
|
||||||
css: noteType.css,
|
|
||||||
fields: noteType.fields as unknown as NoteTypeField[],
|
|
||||||
templates: noteType.templates as unknown as NoteTypeTemplate[],
|
|
||||||
userId: noteType.userId,
|
|
||||||
createdAt: noteType.createdAt,
|
|
||||||
updatedAt: noteType.updatedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoGetNoteTypesByUserId(
|
|
||||||
input: RepoInputGetNoteTypesByUserId,
|
|
||||||
): Promise<RepoOutputNoteType[]> {
|
|
||||||
const noteTypes = await prisma.noteType.findMany({
|
|
||||||
where: { userId: input.userId },
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return noteTypes.map((nt) => ({
|
|
||||||
id: nt.id,
|
|
||||||
name: nt.name,
|
|
||||||
kind: nt.kind,
|
|
||||||
css: nt.css,
|
|
||||||
fields: nt.fields as unknown as NoteTypeField[],
|
|
||||||
templates: nt.templates as unknown as NoteTypeTemplate[],
|
|
||||||
userId: nt.userId,
|
|
||||||
createdAt: nt.createdAt,
|
|
||||||
updatedAt: nt.updatedAt,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoGetNoteTypeOwnership(
|
|
||||||
noteTypeId: number,
|
|
||||||
): Promise<RepoOutputNoteTypeOwnership | null> {
|
|
||||||
const noteType = await prisma.noteType.findUnique({
|
|
||||||
where: { id: noteTypeId },
|
|
||||||
select: { userId: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
return noteType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoDeleteNoteType(
|
|
||||||
input: RepoInputDeleteNoteType,
|
|
||||||
): Promise<void> {
|
|
||||||
await prisma.noteType.delete({
|
|
||||||
where: { id: input.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
log.info("Deleted note type", { id: input.id });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoCheckNotesExist(
|
|
||||||
input: RepoInputCheckNotesExist,
|
|
||||||
): Promise<RepoOutputNotesExistCheck> {
|
|
||||||
const count = await prisma.note.count({
|
|
||||||
where: { noteTypeId: input.noteTypeId },
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
exists: count > 0,
|
|
||||||
count,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoGetNoteTypeNameById(
|
|
||||||
noteTypeId: number,
|
|
||||||
): Promise<string | null> {
|
|
||||||
const noteType = await prisma.noteType.findUnique({
|
|
||||||
where: { id: noteTypeId },
|
|
||||||
select: { name: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
return noteType?.name ?? null;
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { NoteKind } from "../../../generated/prisma/enums";
|
|
||||||
import { NoteTypeField, NoteTypeTemplate } from "./note-type-repository-dto";
|
|
||||||
|
|
||||||
export type ServiceInputCreateNoteType = {
|
|
||||||
name: string;
|
|
||||||
kind?: NoteKind;
|
|
||||||
css?: string;
|
|
||||||
fields: NoteTypeField[];
|
|
||||||
templates: NoteTypeTemplate[];
|
|
||||||
userId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ServiceInputUpdateNoteType = {
|
|
||||||
id: number;
|
|
||||||
name?: string;
|
|
||||||
kind?: NoteKind;
|
|
||||||
css?: string;
|
|
||||||
fields?: NoteTypeField[];
|
|
||||||
templates?: NoteTypeTemplate[];
|
|
||||||
userId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ServiceInputGetNoteTypeById = {
|
|
||||||
id: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ServiceInputGetNoteTypesByUserId = {
|
|
||||||
userId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ServiceInputDeleteNoteType = {
|
|
||||||
id: number;
|
|
||||||
userId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ServiceInputValidateFields = {
|
|
||||||
fields: NoteTypeField[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ServiceInputValidateTemplates = {
|
|
||||||
templates: NoteTypeTemplate[];
|
|
||||||
fields: NoteTypeField[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ServiceOutputNoteType = {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
kind: NoteKind;
|
|
||||||
css: string;
|
|
||||||
fields: NoteTypeField[];
|
|
||||||
templates: NoteTypeTemplate[];
|
|
||||||
userId: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ServiceOutputValidation = {
|
|
||||||
success: boolean;
|
|
||||||
errors: string[];
|
|
||||||
};
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
import { createLogger } from "@/lib/logger";
|
|
||||||
import { ValidateError } from "@/lib/errors";
|
|
||||||
import {
|
|
||||||
repoCreateNoteType,
|
|
||||||
repoGetNoteTypeById,
|
|
||||||
repoGetNoteTypesByUserId,
|
|
||||||
repoUpdateNoteType,
|
|
||||||
repoDeleteNoteType,
|
|
||||||
repoGetNoteTypeOwnership,
|
|
||||||
repoCheckNotesExist,
|
|
||||||
} from "./note-type-repository";
|
|
||||||
import {
|
|
||||||
ServiceInputCreateNoteType,
|
|
||||||
ServiceInputUpdateNoteType,
|
|
||||||
ServiceInputGetNoteTypeById,
|
|
||||||
ServiceInputGetNoteTypesByUserId,
|
|
||||||
ServiceInputDeleteNoteType,
|
|
||||||
ServiceInputValidateFields,
|
|
||||||
ServiceInputValidateTemplates,
|
|
||||||
ServiceOutputNoteType,
|
|
||||||
ServiceOutputValidation,
|
|
||||||
} from "./note-type-service-dto";
|
|
||||||
import { schemaNoteTypeField, schemaNoteTypeTemplate } from "./note-type-repository-dto";
|
|
||||||
|
|
||||||
const log = createLogger("note-type-service");
|
|
||||||
|
|
||||||
export function serviceValidateFields(
|
|
||||||
input: ServiceInputValidateFields,
|
|
||||||
): ServiceOutputValidation {
|
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
if (!Array.isArray(input.fields) || input.fields.length === 0) {
|
|
||||||
errors.push("Fields must be a non-empty array");
|
|
||||||
return { success: false, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
const seenNames = new Set<string>();
|
|
||||||
const seenOrds = new Set<number>();
|
|
||||||
|
|
||||||
for (let i = 0; i < input.fields.length; i++) {
|
|
||||||
const field = input.fields[i];
|
|
||||||
|
|
||||||
if (!field.name || field.name.trim().length === 0) {
|
|
||||||
errors.push(`Field ${i}: name is required`);
|
|
||||||
} else if (field.name.length > schemaNoteTypeField.name.maxLength) {
|
|
||||||
errors.push(`Field ${i}: name exceeds maximum length of ${schemaNoteTypeField.name.maxLength}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seenNames.has(field.name)) {
|
|
||||||
errors.push(`Field ${i}: duplicate field name "${field.name}"`);
|
|
||||||
}
|
|
||||||
seenNames.add(field.name);
|
|
||||||
|
|
||||||
if (typeof field.ord !== "number") {
|
|
||||||
errors.push(`Field ${i}: ord must be a number`);
|
|
||||||
} else if (seenOrds.has(field.ord)) {
|
|
||||||
errors.push(`Field ${i}: duplicate ordinal ${field.ord}`);
|
|
||||||
}
|
|
||||||
seenOrds.add(field.ord);
|
|
||||||
|
|
||||||
if (typeof field.sticky !== "boolean") {
|
|
||||||
errors.push(`Field ${i}: sticky must be a boolean`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof field.rtl !== "boolean") {
|
|
||||||
errors.push(`Field ${i}: rtl must be a boolean`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.font && field.font.length > schemaNoteTypeField.font.maxLength) {
|
|
||||||
errors.push(`Field ${i}: font exceeds maximum length`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.size !== undefined && (field.size < schemaNoteTypeField.size.min || field.size > schemaNoteTypeField.size.max)) {
|
|
||||||
errors.push(`Field ${i}: size must be between ${schemaNoteTypeField.size.min} and ${schemaNoteTypeField.size.max}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: errors.length === 0, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serviceValidateTemplates(
|
|
||||||
input: ServiceInputValidateTemplates,
|
|
||||||
): ServiceOutputValidation {
|
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
if (!Array.isArray(input.templates) || input.templates.length === 0) {
|
|
||||||
errors.push("Templates must be a non-empty array");
|
|
||||||
return { success: false, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldNames = new Set(input.fields.map((f) => f.name));
|
|
||||||
const seenNames = new Set<string>();
|
|
||||||
const seenOrds = new Set<number>();
|
|
||||||
|
|
||||||
const mustachePattern = /\{\{([^}]+)\}\}/g;
|
|
||||||
|
|
||||||
for (let i = 0; i < input.templates.length; i++) {
|
|
||||||
const template = input.templates[i];
|
|
||||||
|
|
||||||
if (!template.name || template.name.trim().length === 0) {
|
|
||||||
errors.push(`Template ${i}: name is required`);
|
|
||||||
} else if (template.name.length > schemaNoteTypeTemplate.name.maxLength) {
|
|
||||||
errors.push(`Template ${i}: name exceeds maximum length`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seenNames.has(template.name)) {
|
|
||||||
errors.push(`Template ${i}: duplicate template name "${template.name}"`);
|
|
||||||
}
|
|
||||||
seenNames.add(template.name);
|
|
||||||
|
|
||||||
if (typeof template.ord !== "number") {
|
|
||||||
errors.push(`Template ${i}: ord must be a number`);
|
|
||||||
} else if (seenOrds.has(template.ord)) {
|
|
||||||
errors.push(`Template ${i}: duplicate ordinal ${template.ord}`);
|
|
||||||
}
|
|
||||||
seenOrds.add(template.ord);
|
|
||||||
|
|
||||||
if (!template.qfmt || template.qfmt.trim().length === 0) {
|
|
||||||
errors.push(`Template ${i}: qfmt (question format) is required`);
|
|
||||||
} else if (template.qfmt.length > schemaNoteTypeTemplate.qfmt.maxLength) {
|
|
||||||
errors.push(`Template ${i}: qfmt exceeds maximum length`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!template.afmt || template.afmt.trim().length === 0) {
|
|
||||||
errors.push(`Template ${i}: afmt (answer format) is required`);
|
|
||||||
} else if (template.afmt.length > schemaNoteTypeTemplate.afmt.maxLength) {
|
|
||||||
errors.push(`Template ${i}: afmt exceeds maximum length`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const qfmtMatches = template.qfmt.match(mustachePattern) || [];
|
|
||||||
const afmtMatches = template.afmt.match(mustachePattern) || [];
|
|
||||||
const allMatches = [...qfmtMatches, ...afmtMatches];
|
|
||||||
|
|
||||||
for (const match of allMatches) {
|
|
||||||
const content = match.slice(2, -2).trim();
|
|
||||||
|
|
||||||
if (content.startsWith("cloze:")) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content === "FrontSide") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content.startsWith("type:")) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fieldNames.has(content)) {
|
|
||||||
log.warn(`Template ${i}: unknown field reference "{{${content}}}"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: errors.length === 0, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serviceCreateNoteType(
|
|
||||||
input: ServiceInputCreateNoteType,
|
|
||||||
): Promise<number> {
|
|
||||||
const fieldsValidation = serviceValidateFields({ fields: input.fields });
|
|
||||||
if (!fieldsValidation.success) {
|
|
||||||
throw new ValidateError(`Invalid fields: ${fieldsValidation.errors.join("; ")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const templatesValidation = serviceValidateTemplates({
|
|
||||||
templates: input.templates,
|
|
||||||
fields: input.fields,
|
|
||||||
});
|
|
||||||
if (!templatesValidation.success) {
|
|
||||||
throw new ValidateError(`Invalid templates: ${templatesValidation.errors.join("; ")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("Creating note type", { name: input.name, userId: input.userId });
|
|
||||||
|
|
||||||
return repoCreateNoteType({
|
|
||||||
name: input.name,
|
|
||||||
kind: input.kind,
|
|
||||||
css: input.css,
|
|
||||||
fields: input.fields,
|
|
||||||
templates: input.templates,
|
|
||||||
userId: input.userId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serviceUpdateNoteType(
|
|
||||||
input: ServiceInputUpdateNoteType,
|
|
||||||
): Promise<void> {
|
|
||||||
const ownership = await repoGetNoteTypeOwnership(input.id);
|
|
||||||
if (!ownership) {
|
|
||||||
throw new ValidateError("Note type not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ownership.userId !== input.userId) {
|
|
||||||
throw new ValidateError("You do not have permission to update this note type");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.fields) {
|
|
||||||
const fieldsValidation = serviceValidateFields({ fields: input.fields });
|
|
||||||
if (!fieldsValidation.success) {
|
|
||||||
throw new ValidateError(`Invalid fields: ${fieldsValidation.errors.join("; ")}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.templates && input.fields) {
|
|
||||||
const templatesValidation = serviceValidateTemplates({
|
|
||||||
templates: input.templates,
|
|
||||||
fields: input.fields,
|
|
||||||
});
|
|
||||||
if (!templatesValidation.success) {
|
|
||||||
throw new ValidateError(`Invalid templates: ${templatesValidation.errors.join("; ")}`);
|
|
||||||
}
|
|
||||||
} else if (input.templates) {
|
|
||||||
const existing = await repoGetNoteTypeById({ id: input.id });
|
|
||||||
if (existing) {
|
|
||||||
const templatesValidation = serviceValidateTemplates({
|
|
||||||
templates: input.templates,
|
|
||||||
fields: existing.fields,
|
|
||||||
});
|
|
||||||
if (!templatesValidation.success) {
|
|
||||||
throw new ValidateError(`Invalid templates: ${templatesValidation.errors.join("; ")}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("Updating note type", { id: input.id });
|
|
||||||
|
|
||||||
await repoUpdateNoteType({
|
|
||||||
id: input.id,
|
|
||||||
name: input.name,
|
|
||||||
kind: input.kind,
|
|
||||||
css: input.css,
|
|
||||||
fields: input.fields,
|
|
||||||
templates: input.templates,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serviceGetNoteTypeById(
|
|
||||||
input: ServiceInputGetNoteTypeById,
|
|
||||||
): Promise<ServiceOutputNoteType | null> {
|
|
||||||
return repoGetNoteTypeById(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serviceGetNoteTypesByUserId(
|
|
||||||
input: ServiceInputGetNoteTypesByUserId,
|
|
||||||
): Promise<ServiceOutputNoteType[]> {
|
|
||||||
return repoGetNoteTypesByUserId(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serviceDeleteNoteType(
|
|
||||||
input: ServiceInputDeleteNoteType,
|
|
||||||
): Promise<void> {
|
|
||||||
const ownership = await repoGetNoteTypeOwnership(input.id);
|
|
||||||
if (!ownership) {
|
|
||||||
throw new ValidateError("Note type not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ownership.userId !== input.userId) {
|
|
||||||
throw new ValidateError("You do not have permission to delete this note type");
|
|
||||||
}
|
|
||||||
|
|
||||||
const notesCheck = await repoCheckNotesExist({ noteTypeId: input.id });
|
|
||||||
if (notesCheck.exists) {
|
|
||||||
throw new ValidateError(
|
|
||||||
`Cannot delete note type: ${notesCheck.count} notes are using this type`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("Deleting note type", { id: input.id });
|
|
||||||
|
|
||||||
await repoDeleteNoteType({ id: input.id });
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import { generateValidator } from "@/utils/validate";
|
|
||||||
import z from "zod";
|
|
||||||
|
|
||||||
export const LENGTH_MAX_NOTE_FIELD = 65535;
|
|
||||||
export const LENGTH_MIN_NOTE_FIELD = 0;
|
|
||||||
export const LENGTH_MAX_TAG = 100;
|
|
||||||
export const MAX_FIELDS = 100;
|
|
||||||
export const MAX_TAGS = 100;
|
|
||||||
|
|
||||||
export const schemaActionInputCreateNote = z.object({
|
|
||||||
noteTypeId: z.number().int().positive(),
|
|
||||||
fields: z
|
|
||||||
.array(z.string().max(LENGTH_MAX_NOTE_FIELD))
|
|
||||||
.min(1)
|
|
||||||
.max(MAX_FIELDS),
|
|
||||||
tags: z.array(z.string().max(LENGTH_MAX_TAG)).max(MAX_TAGS).optional(),
|
|
||||||
});
|
|
||||||
export type ActionInputCreateNote = z.infer<typeof schemaActionInputCreateNote>;
|
|
||||||
export const validateActionInputCreateNote = generateValidator(
|
|
||||||
schemaActionInputCreateNote,
|
|
||||||
);
|
|
||||||
|
|
||||||
export const schemaActionInputUpdateNote = z.object({
|
|
||||||
noteId: z.bigint(),
|
|
||||||
fields: z
|
|
||||||
.array(z.string().max(LENGTH_MAX_NOTE_FIELD))
|
|
||||||
.min(1)
|
|
||||||
.max(MAX_FIELDS)
|
|
||||||
.optional(),
|
|
||||||
tags: z.array(z.string().max(LENGTH_MAX_TAG)).max(MAX_TAGS).optional(),
|
|
||||||
});
|
|
||||||
export type ActionInputUpdateNote = z.infer<typeof schemaActionInputUpdateNote>;
|
|
||||||
export const validateActionInputUpdateNote = generateValidator(
|
|
||||||
schemaActionInputUpdateNote,
|
|
||||||
);
|
|
||||||
|
|
||||||
export const schemaActionInputDeleteNote = z.object({
|
|
||||||
noteId: z.bigint(),
|
|
||||||
});
|
|
||||||
export type ActionInputDeleteNote = z.infer<typeof schemaActionInputDeleteNote>;
|
|
||||||
export const validateActionInputDeleteNote = generateValidator(
|
|
||||||
schemaActionInputDeleteNote,
|
|
||||||
);
|
|
||||||
|
|
||||||
export const schemaActionInputGetNoteById = z.object({
|
|
||||||
noteId: z.bigint(),
|
|
||||||
});
|
|
||||||
export type ActionInputGetNoteById = z.infer<typeof schemaActionInputGetNoteById>;
|
|
||||||
export const validateActionInputGetNoteById = generateValidator(
|
|
||||||
schemaActionInputGetNoteById,
|
|
||||||
);
|
|
||||||
|
|
||||||
export const schemaActionInputGetNotesByNoteTypeId = z.object({
|
|
||||||
noteTypeId: z.number().int().positive(),
|
|
||||||
limit: z.number().int().positive().max(1000).optional(),
|
|
||||||
offset: z.number().int().nonnegative().optional(),
|
|
||||||
});
|
|
||||||
export type ActionInputGetNotesByNoteTypeId = z.infer<
|
|
||||||
typeof schemaActionInputGetNotesByNoteTypeId
|
|
||||||
>;
|
|
||||||
export const validateActionInputGetNotesByNoteTypeId = generateValidator(
|
|
||||||
schemaActionInputGetNotesByNoteTypeId,
|
|
||||||
);
|
|
||||||
|
|
||||||
export const schemaActionInputGetNotesByUserId = z.object({
|
|
||||||
userId: z.string().min(1),
|
|
||||||
limit: z.number().int().positive().max(1000).optional(),
|
|
||||||
offset: z.number().int().nonnegative().optional(),
|
|
||||||
});
|
|
||||||
export type ActionInputGetNotesByUserId = z.infer<
|
|
||||||
typeof schemaActionInputGetNotesByUserId
|
|
||||||
>;
|
|
||||||
export const validateActionInputGetNotesByUserId = generateValidator(
|
|
||||||
schemaActionInputGetNotesByUserId,
|
|
||||||
);
|
|
||||||
|
|
||||||
export type ActionOutputNote = {
|
|
||||||
id: string;
|
|
||||||
guid: string;
|
|
||||||
noteTypeId: number;
|
|
||||||
mod: number;
|
|
||||||
usn: number;
|
|
||||||
tags: string[];
|
|
||||||
fields: string[];
|
|
||||||
sfld: string;
|
|
||||||
csum: number;
|
|
||||||
flags: number;
|
|
||||||
data: string;
|
|
||||||
userId: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActionOutputCreateNote = {
|
|
||||||
message: string;
|
|
||||||
success: boolean;
|
|
||||||
data?: {
|
|
||||||
id: string;
|
|
||||||
guid: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActionOutputUpdateNote = {
|
|
||||||
message: string;
|
|
||||||
success: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActionOutputDeleteNote = {
|
|
||||||
message: string;
|
|
||||||
success: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActionOutputGetNoteById = {
|
|
||||||
message: string;
|
|
||||||
success: boolean;
|
|
||||||
data?: ActionOutputNote;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActionOutputGetNotes = {
|
|
||||||
message: string;
|
|
||||||
success: boolean;
|
|
||||||
data?: ActionOutputNote[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActionOutputNoteCount = {
|
|
||||||
message: string;
|
|
||||||
success: boolean;
|
|
||||||
data?: {
|
|
||||||
count: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,344 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { createLogger } from "@/lib/logger";
|
|
||||||
import { ValidateError } from "@/lib/errors";
|
|
||||||
import {
|
|
||||||
ActionInputCreateNote,
|
|
||||||
ActionInputUpdateNote,
|
|
||||||
ActionInputDeleteNote,
|
|
||||||
ActionInputGetNoteById,
|
|
||||||
ActionInputGetNotesByNoteTypeId,
|
|
||||||
ActionInputGetNotesByUserId,
|
|
||||||
ActionOutputCreateNote,
|
|
||||||
ActionOutputUpdateNote,
|
|
||||||
ActionOutputDeleteNote,
|
|
||||||
ActionOutputGetNoteById,
|
|
||||||
ActionOutputGetNotes,
|
|
||||||
ActionOutputNoteCount,
|
|
||||||
ActionOutputNote,
|
|
||||||
validateActionInputCreateNote,
|
|
||||||
validateActionInputUpdateNote,
|
|
||||||
validateActionInputDeleteNote,
|
|
||||||
validateActionInputGetNoteById,
|
|
||||||
validateActionInputGetNotesByNoteTypeId,
|
|
||||||
validateActionInputGetNotesByUserId,
|
|
||||||
} from "./note-action-dto";
|
|
||||||
import {
|
|
||||||
serviceCreateNote,
|
|
||||||
serviceUpdateNote,
|
|
||||||
serviceDeleteNote,
|
|
||||||
serviceGetNoteById,
|
|
||||||
serviceGetNotesByNoteTypeId,
|
|
||||||
serviceGetNotesByUserId,
|
|
||||||
serviceCountNotesByUserId,
|
|
||||||
serviceCountNotesByNoteTypeId,
|
|
||||||
NoteNotFoundError,
|
|
||||||
NoteOwnershipError,
|
|
||||||
} from "./note-service";
|
|
||||||
|
|
||||||
const log = createLogger("note-action");
|
|
||||||
|
|
||||||
function mapNoteToOutput(note: {
|
|
||||||
id: bigint;
|
|
||||||
guid: string;
|
|
||||||
noteTypeId: number;
|
|
||||||
mod: number;
|
|
||||||
usn: number;
|
|
||||||
tags: string[];
|
|
||||||
fields: string[];
|
|
||||||
sfld: string;
|
|
||||||
csum: number;
|
|
||||||
flags: number;
|
|
||||||
data: string;
|
|
||||||
userId: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}): ActionOutputNote {
|
|
||||||
return {
|
|
||||||
id: note.id.toString(),
|
|
||||||
guid: note.guid,
|
|
||||||
noteTypeId: note.noteTypeId,
|
|
||||||
mod: note.mod,
|
|
||||||
usn: note.usn,
|
|
||||||
tags: note.tags,
|
|
||||||
fields: note.fields,
|
|
||||||
sfld: note.sfld,
|
|
||||||
csum: note.csum,
|
|
||||||
flags: note.flags,
|
|
||||||
data: note.data,
|
|
||||||
userId: note.userId,
|
|
||||||
createdAt: note.createdAt,
|
|
||||||
updatedAt: note.updatedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requireAuth(): Promise<string> {
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
throw new Error("Unauthorized");
|
|
||||||
}
|
|
||||||
return session.user.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function actionCreateNote(
|
|
||||||
input: unknown,
|
|
||||||
): Promise<ActionOutputCreateNote> {
|
|
||||||
try {
|
|
||||||
const userId = await requireAuth();
|
|
||||||
const validated = validateActionInputCreateNote(input);
|
|
||||||
|
|
||||||
log.debug("Creating note", { userId, noteTypeId: validated.noteTypeId });
|
|
||||||
|
|
||||||
const result = await serviceCreateNote({
|
|
||||||
...validated,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
log.info("Note created", { id: result.id.toString(), guid: result.guid });
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: "Note created successfully",
|
|
||||||
data: {
|
|
||||||
id: result.id.toString(),
|
|
||||||
guid: result.guid,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ValidateError) {
|
|
||||||
return { success: false, message: e.message };
|
|
||||||
}
|
|
||||||
if (e instanceof Error && e.message === "Unauthorized") {
|
|
||||||
return { success: false, message: "Unauthorized" };
|
|
||||||
}
|
|
||||||
log.error("Failed to create note", { error: e });
|
|
||||||
return { success: false, message: "An unknown error occurred" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function actionUpdateNote(
|
|
||||||
input: unknown,
|
|
||||||
): Promise<ActionOutputUpdateNote> {
|
|
||||||
try {
|
|
||||||
const userId = await requireAuth();
|
|
||||||
const validated = validateActionInputUpdateNote(input);
|
|
||||||
|
|
||||||
log.debug("Updating note", { noteId: validated.noteId.toString() });
|
|
||||||
|
|
||||||
await serviceUpdateNote({
|
|
||||||
...validated,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
log.info("Note updated", { noteId: validated.noteId.toString() });
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: "Note updated successfully",
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ValidateError) {
|
|
||||||
return { success: false, message: e.message };
|
|
||||||
}
|
|
||||||
if (e instanceof NoteNotFoundError) {
|
|
||||||
return { success: false, message: "Note not found" };
|
|
||||||
}
|
|
||||||
if (e instanceof NoteOwnershipError) {
|
|
||||||
return { success: false, message: "You do not have permission to update this note" };
|
|
||||||
}
|
|
||||||
if (e instanceof Error && e.message === "Unauthorized") {
|
|
||||||
return { success: false, message: "Unauthorized" };
|
|
||||||
}
|
|
||||||
log.error("Failed to update note", { error: e });
|
|
||||||
return { success: false, message: "An unknown error occurred" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function actionDeleteNote(
|
|
||||||
input: unknown,
|
|
||||||
): Promise<ActionOutputDeleteNote> {
|
|
||||||
try {
|
|
||||||
const userId = await requireAuth();
|
|
||||||
const validated = validateActionInputDeleteNote(input);
|
|
||||||
|
|
||||||
log.debug("Deleting note", { noteId: validated.noteId.toString() });
|
|
||||||
|
|
||||||
await serviceDeleteNote({
|
|
||||||
...validated,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
log.info("Note deleted", { noteId: validated.noteId.toString() });
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: "Note deleted successfully",
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ValidateError) {
|
|
||||||
return { success: false, message: e.message };
|
|
||||||
}
|
|
||||||
if (e instanceof NoteNotFoundError) {
|
|
||||||
return { success: false, message: "Note not found" };
|
|
||||||
}
|
|
||||||
if (e instanceof NoteOwnershipError) {
|
|
||||||
return { success: false, message: "You do not have permission to delete this note" };
|
|
||||||
}
|
|
||||||
if (e instanceof Error && e.message === "Unauthorized") {
|
|
||||||
return { success: false, message: "Unauthorized" };
|
|
||||||
}
|
|
||||||
log.error("Failed to delete note", { error: e });
|
|
||||||
return { success: false, message: "An unknown error occurred" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function actionGetNoteById(
|
|
||||||
input: unknown,
|
|
||||||
): Promise<ActionOutputGetNoteById> {
|
|
||||||
try {
|
|
||||||
const validated = validateActionInputGetNoteById(input);
|
|
||||||
|
|
||||||
log.debug("Fetching note", { noteId: validated.noteId.toString() });
|
|
||||||
|
|
||||||
const note = await serviceGetNoteById(validated);
|
|
||||||
|
|
||||||
if (!note) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: "Note not found",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: "Note retrieved successfully",
|
|
||||||
data: mapNoteToOutput(note),
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ValidateError) {
|
|
||||||
return { success: false, message: e.message };
|
|
||||||
}
|
|
||||||
log.error("Failed to get note", { error: e });
|
|
||||||
return { success: false, message: "An unknown error occurred" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function actionGetNotesByNoteTypeId(
|
|
||||||
input: unknown,
|
|
||||||
): Promise<ActionOutputGetNotes> {
|
|
||||||
try {
|
|
||||||
const validated = validateActionInputGetNotesByNoteTypeId(input);
|
|
||||||
|
|
||||||
log.debug("Fetching notes by note type", { noteTypeId: validated.noteTypeId });
|
|
||||||
|
|
||||||
const notes = await serviceGetNotesByNoteTypeId(validated);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: "Notes retrieved successfully",
|
|
||||||
data: notes.map(mapNoteToOutput),
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ValidateError) {
|
|
||||||
return { success: false, message: e.message };
|
|
||||||
}
|
|
||||||
log.error("Failed to get notes by note type", { error: e });
|
|
||||||
return { success: false, message: "An unknown error occurred" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function actionGetNotesByUserId(
|
|
||||||
input: unknown,
|
|
||||||
): Promise<ActionOutputGetNotes> {
|
|
||||||
try {
|
|
||||||
const validated = validateActionInputGetNotesByUserId(input);
|
|
||||||
|
|
||||||
log.debug("Fetching notes by user", { userId: validated.userId });
|
|
||||||
|
|
||||||
const notes = await serviceGetNotesByUserId(validated);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: "Notes retrieved successfully",
|
|
||||||
data: notes.map(mapNoteToOutput),
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ValidateError) {
|
|
||||||
return { success: false, message: e.message };
|
|
||||||
}
|
|
||||||
log.error("Failed to get notes by user", { error: e });
|
|
||||||
return { success: false, message: "An unknown error occurred" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function actionGetMyNotes(
|
|
||||||
limit?: number,
|
|
||||||
offset?: number,
|
|
||||||
): Promise<ActionOutputGetNotes> {
|
|
||||||
try {
|
|
||||||
const userId = await requireAuth();
|
|
||||||
|
|
||||||
log.debug("Fetching current user's notes", { userId, limit, offset });
|
|
||||||
|
|
||||||
const notes = await serviceGetNotesByUserId({
|
|
||||||
userId,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: "Notes retrieved successfully",
|
|
||||||
data: notes.map(mapNoteToOutput),
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Error && e.message === "Unauthorized") {
|
|
||||||
return { success: false, message: "Unauthorized" };
|
|
||||||
}
|
|
||||||
log.error("Failed to get user's notes", { error: e });
|
|
||||||
return { success: false, message: "An unknown error occurred" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function actionGetMyNoteCount(): Promise<ActionOutputNoteCount> {
|
|
||||||
try {
|
|
||||||
const userId = await requireAuth();
|
|
||||||
|
|
||||||
log.debug("Counting current user's notes", { userId });
|
|
||||||
|
|
||||||
const result = await serviceCountNotesByUserId(userId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: "Note count retrieved successfully",
|
|
||||||
data: { count: result.count },
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Error && e.message === "Unauthorized") {
|
|
||||||
return { success: false, message: "Unauthorized" };
|
|
||||||
}
|
|
||||||
log.error("Failed to count user's notes", { error: e });
|
|
||||||
return { success: false, message: "An unknown error occurred" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function actionGetNoteCountByNoteType(
|
|
||||||
noteTypeId: number,
|
|
||||||
): Promise<ActionOutputNoteCount> {
|
|
||||||
try {
|
|
||||||
log.debug("Counting notes by note type", { noteTypeId });
|
|
||||||
|
|
||||||
const result = await serviceCountNotesByNoteTypeId(noteTypeId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: "Note count retrieved successfully",
|
|
||||||
data: { count: result.count },
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
log.error("Failed to count notes by note type", { error: e });
|
|
||||||
return { success: false, message: "An unknown error occurred" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
// Repository layer DTOs for Note module
|
|
||||||
// Follows Anki-compatible note structure with BigInt IDs
|
|
||||||
|
|
||||||
export interface RepoInputCreateNote {
|
|
||||||
noteTypeId: number;
|
|
||||||
fields: string[];
|
|
||||||
tags?: string[];
|
|
||||||
userId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RepoInputUpdateNote {
|
|
||||||
id: bigint;
|
|
||||||
fields?: string[];
|
|
||||||
tags?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RepoInputGetNoteById {
|
|
||||||
id: bigint;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RepoInputGetNotesByNoteTypeId {
|
|
||||||
noteTypeId: number;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RepoInputGetNotesByUserId {
|
|
||||||
userId: string;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RepoInputDeleteNote {
|
|
||||||
id: bigint;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RepoInputCheckNoteOwnership {
|
|
||||||
noteId: bigint;
|
|
||||||
userId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RepoOutputNote = {
|
|
||||||
id: bigint;
|
|
||||||
guid: string;
|
|
||||||
noteTypeId: number;
|
|
||||||
mod: number;
|
|
||||||
usn: number;
|
|
||||||
tags: string;
|
|
||||||
flds: string;
|
|
||||||
sfld: string;
|
|
||||||
csum: number;
|
|
||||||
flags: number;
|
|
||||||
data: string;
|
|
||||||
userId: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RepoOutputNoteWithFields = Omit<RepoOutputNote, "flds" | "tags"> & {
|
|
||||||
fields: string[];
|
|
||||||
tagsArray: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RepoOutputNoteOwnership = {
|
|
||||||
userId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function types
|
|
||||||
export type RepoHelperGenerateGuid = () => string;
|
|
||||||
export type RepoHelperCalculateCsum = (text: string) => number;
|
|
||||||
export type RepoHelperJoinFields = (fields: string[]) => string;
|
|
||||||
export type RepoHelperSplitFields = (flds: string) => string[];
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { createLogger } from "@/lib/logger";
|
|
||||||
import { createHash } from "crypto";
|
|
||||||
import {
|
|
||||||
RepoInputCreateNote,
|
|
||||||
RepoInputUpdateNote,
|
|
||||||
RepoInputGetNoteById,
|
|
||||||
RepoInputGetNotesByNoteTypeId,
|
|
||||||
RepoInputGetNotesByUserId,
|
|
||||||
RepoInputDeleteNote,
|
|
||||||
RepoInputCheckNoteOwnership,
|
|
||||||
RepoOutputNote,
|
|
||||||
RepoOutputNoteWithFields,
|
|
||||||
RepoOutputNoteOwnership,
|
|
||||||
} from "./note-repository-dto";
|
|
||||||
|
|
||||||
const log = createLogger("note-repository");
|
|
||||||
|
|
||||||
const FIELD_SEPARATOR = "\x1f";
|
|
||||||
const BASE91_CHARS =
|
|
||||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&()*+,-./:;<=>?@[]^_`{|}~";
|
|
||||||
|
|
||||||
export function repoGenerateGuid(): string {
|
|
||||||
let guid = "";
|
|
||||||
const bytes = new Uint8Array(10);
|
|
||||||
crypto.getRandomValues(bytes);
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
guid += BASE91_CHARS[bytes[i] % BASE91_CHARS.length];
|
|
||||||
}
|
|
||||||
return guid;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function repoCalculateCsum(text: string): number {
|
|
||||||
const hash = createHash("sha1").update(text.normalize("NFC")).digest("hex");
|
|
||||||
return parseInt(hash.substring(0, 8), 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function repoJoinFields(fields: string[]): string {
|
|
||||||
return fields.join(FIELD_SEPARATOR);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function repoSplitFields(flds: string): string[] {
|
|
||||||
return flds.split(FIELD_SEPARATOR);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoCreateNote(
|
|
||||||
input: RepoInputCreateNote,
|
|
||||||
): Promise<bigint> {
|
|
||||||
const now = Date.now();
|
|
||||||
const id = BigInt(now);
|
|
||||||
const guid = repoGenerateGuid();
|
|
||||||
const flds = repoJoinFields(input.fields);
|
|
||||||
const sfld = input.fields[0] ?? "";
|
|
||||||
const csum = repoCalculateCsum(sfld);
|
|
||||||
const tags = input.tags?.join(" ") ?? " ";
|
|
||||||
|
|
||||||
log.debug("Creating note", { id: id.toString(), guid, noteTypeId: input.noteTypeId });
|
|
||||||
|
|
||||||
await prisma.note.create({
|
|
||||||
data: {
|
|
||||||
id,
|
|
||||||
guid,
|
|
||||||
noteTypeId: input.noteTypeId,
|
|
||||||
mod: Math.floor(now / 1000),
|
|
||||||
usn: -1,
|
|
||||||
tags,
|
|
||||||
flds,
|
|
||||||
sfld,
|
|
||||||
csum,
|
|
||||||
flags: 0,
|
|
||||||
data: "",
|
|
||||||
userId: input.userId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
log.info("Note created", { id: id.toString(), guid });
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoUpdateNote(input: RepoInputUpdateNote): Promise<void> {
|
|
||||||
const now = Date.now();
|
|
||||||
const updateData: {
|
|
||||||
mod?: number;
|
|
||||||
usn?: number;
|
|
||||||
flds?: string;
|
|
||||||
sfld?: string;
|
|
||||||
csum?: number;
|
|
||||||
tags?: string;
|
|
||||||
} = {
|
|
||||||
mod: Math.floor(now / 1000),
|
|
||||||
usn: -1,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (input.fields) {
|
|
||||||
updateData.flds = repoJoinFields(input.fields);
|
|
||||||
updateData.sfld = input.fields[0] ?? "";
|
|
||||||
updateData.csum = repoCalculateCsum(updateData.sfld);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.tags) {
|
|
||||||
updateData.tags = input.tags.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug("Updating note", { id: input.id.toString() });
|
|
||||||
|
|
||||||
await prisma.note.update({
|
|
||||||
where: { id: input.id },
|
|
||||||
data: updateData,
|
|
||||||
});
|
|
||||||
|
|
||||||
log.info("Note updated", { id: input.id.toString() });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoGetNoteById(
|
|
||||||
input: RepoInputGetNoteById,
|
|
||||||
): Promise<RepoOutputNote | null> {
|
|
||||||
const note = await prisma.note.findUnique({
|
|
||||||
where: { id: input.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!note) {
|
|
||||||
log.debug("Note not found", { id: input.id.toString() });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: note.id,
|
|
||||||
guid: note.guid,
|
|
||||||
noteTypeId: note.noteTypeId,
|
|
||||||
mod: note.mod,
|
|
||||||
usn: note.usn,
|
|
||||||
tags: note.tags,
|
|
||||||
flds: note.flds,
|
|
||||||
sfld: note.sfld,
|
|
||||||
csum: note.csum,
|
|
||||||
flags: note.flags,
|
|
||||||
data: note.data,
|
|
||||||
userId: note.userId,
|
|
||||||
createdAt: note.createdAt,
|
|
||||||
updatedAt: note.updatedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoGetNoteByIdWithFields(
|
|
||||||
input: RepoInputGetNoteById,
|
|
||||||
): Promise<RepoOutputNoteWithFields | null> {
|
|
||||||
const note = await repoGetNoteById(input);
|
|
||||||
if (!note) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...note,
|
|
||||||
fields: repoSplitFields(note.flds),
|
|
||||||
tagsArray: note.tags.trim() === "" ? [] : note.tags.trim().split(" "),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoGetNotesByNoteTypeId(
|
|
||||||
input: RepoInputGetNotesByNoteTypeId,
|
|
||||||
): Promise<RepoOutputNote[]> {
|
|
||||||
const { noteTypeId, limit = 50, offset = 0 } = input;
|
|
||||||
|
|
||||||
log.debug("Fetching notes by note type", { noteTypeId, limit, offset });
|
|
||||||
|
|
||||||
const notes = await prisma.note.findMany({
|
|
||||||
where: { noteTypeId },
|
|
||||||
orderBy: { id: "desc" },
|
|
||||||
take: limit,
|
|
||||||
skip: offset,
|
|
||||||
});
|
|
||||||
|
|
||||||
log.info("Fetched notes by note type", { noteTypeId, count: notes.length });
|
|
||||||
|
|
||||||
return notes.map((note) => ({
|
|
||||||
id: note.id,
|
|
||||||
guid: note.guid,
|
|
||||||
noteTypeId: note.noteTypeId,
|
|
||||||
mod: note.mod,
|
|
||||||
usn: note.usn,
|
|
||||||
tags: note.tags,
|
|
||||||
flds: note.flds,
|
|
||||||
sfld: note.sfld,
|
|
||||||
csum: note.csum,
|
|
||||||
flags: note.flags,
|
|
||||||
data: note.data,
|
|
||||||
userId: note.userId,
|
|
||||||
createdAt: note.createdAt,
|
|
||||||
updatedAt: note.updatedAt,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoGetNotesByUserId(
|
|
||||||
input: RepoInputGetNotesByUserId,
|
|
||||||
): Promise<RepoOutputNote[]> {
|
|
||||||
const { userId, limit = 50, offset = 0 } = input;
|
|
||||||
|
|
||||||
log.debug("Fetching notes by user", { userId, limit, offset });
|
|
||||||
|
|
||||||
const notes = await prisma.note.findMany({
|
|
||||||
where: { userId },
|
|
||||||
orderBy: { id: "desc" },
|
|
||||||
take: limit,
|
|
||||||
skip: offset,
|
|
||||||
});
|
|
||||||
|
|
||||||
log.info("Fetched notes by user", { userId, count: notes.length });
|
|
||||||
|
|
||||||
return notes.map((note) => ({
|
|
||||||
id: note.id,
|
|
||||||
guid: note.guid,
|
|
||||||
noteTypeId: note.noteTypeId,
|
|
||||||
mod: note.mod,
|
|
||||||
usn: note.usn,
|
|
||||||
tags: note.tags,
|
|
||||||
flds: note.flds,
|
|
||||||
sfld: note.sfld,
|
|
||||||
csum: note.csum,
|
|
||||||
flags: note.flags,
|
|
||||||
data: note.data,
|
|
||||||
userId: note.userId,
|
|
||||||
createdAt: note.createdAt,
|
|
||||||
updatedAt: note.updatedAt,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoGetNotesByUserIdWithFields(
|
|
||||||
input: RepoInputGetNotesByUserId,
|
|
||||||
): Promise<RepoOutputNoteWithFields[]> {
|
|
||||||
const notes = await repoGetNotesByUserId(input);
|
|
||||||
|
|
||||||
return notes.map((note) => ({
|
|
||||||
...note,
|
|
||||||
fields: repoSplitFields(note.flds),
|
|
||||||
tagsArray: note.tags.trim() === "" ? [] : note.tags.trim().split(" "),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoDeleteNote(input: RepoInputDeleteNote): Promise<void> {
|
|
||||||
log.debug("Deleting note", { id: input.id.toString() });
|
|
||||||
|
|
||||||
await prisma.note.delete({
|
|
||||||
where: { id: input.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
log.info("Note deleted", { id: input.id.toString() });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoCheckNoteOwnership(
|
|
||||||
input: RepoInputCheckNoteOwnership,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const note = await prisma.note.findUnique({
|
|
||||||
where: { id: input.noteId },
|
|
||||||
select: { userId: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
return note?.userId === input.userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoGetNoteOwnership(
|
|
||||||
input: RepoInputGetNoteById,
|
|
||||||
): Promise<RepoOutputNoteOwnership | null> {
|
|
||||||
const note = await prisma.note.findUnique({
|
|
||||||
where: { id: input.id },
|
|
||||||
select: { userId: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!note) return null;
|
|
||||||
|
|
||||||
return { userId: note.userId };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoCountNotesByUserId(userId: string): Promise<number> {
|
|
||||||
return prisma.note.count({
|
|
||||||
where: { userId },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoCountNotesByNoteTypeId(
|
|
||||||
noteTypeId: number,
|
|
||||||
): Promise<number> {
|
|
||||||
return prisma.note.count({
|
|
||||||
where: { noteTypeId },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
export type ServiceInputCreateNote = {
|
|
||||||
noteTypeId: number;
|
|
||||||
fields: string[];
|
|
||||||
tags?: string[];
|
|
||||||
userId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ServiceInputUpdateNote = {
|
|
||||||
noteId: bigint;
|
|
||||||
fields?: string[];
|
|
||||||
tags?: string[];
|
|
||||||
userId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ServiceInputDeleteNote = {
|
|
||||||
noteId: bigint;
|
|
||||||
userId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ServiceInputGetNoteById = {
|
|
||||||
noteId: bigint;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ServiceInputGetNotesByNoteTypeId = {
|
|
||||||
noteTypeId: number;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ServiceInputGetNotesByUserId = {
|
|
||||||
userId: string;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ServiceOutputNote = {
|
|
||||||
id: bigint;
|
|
||||||
guid: string;
|
|
||||||
noteTypeId: number;
|
|
||||||
mod: number;
|
|
||||||
usn: number;
|
|
||||||
tags: string[];
|
|
||||||
fields: string[];
|
|
||||||
sfld: string;
|
|
||||||
csum: number;
|
|
||||||
flags: number;
|
|
||||||
data: string;
|
|
||||||
userId: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ServiceOutputCreateNote = {
|
|
||||||
id: bigint;
|
|
||||||
guid: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ServiceOutputNoteCount = {
|
|
||||||
count: number;
|
|
||||||
};
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import { createLogger } from "@/lib/logger";
|
|
||||||
import {
|
|
||||||
repoCreateNote,
|
|
||||||
repoUpdateNote,
|
|
||||||
repoGetNoteByIdWithFields,
|
|
||||||
repoGetNotesByNoteTypeId,
|
|
||||||
repoGetNotesByUserIdWithFields,
|
|
||||||
repoDeleteNote,
|
|
||||||
repoCheckNoteOwnership,
|
|
||||||
repoCountNotesByUserId,
|
|
||||||
repoCountNotesByNoteTypeId,
|
|
||||||
} from "./note-repository";
|
|
||||||
import {
|
|
||||||
ServiceInputCreateNote,
|
|
||||||
ServiceInputUpdateNote,
|
|
||||||
ServiceInputDeleteNote,
|
|
||||||
ServiceInputGetNoteById,
|
|
||||||
ServiceInputGetNotesByNoteTypeId,
|
|
||||||
ServiceInputGetNotesByUserId,
|
|
||||||
ServiceOutputNote,
|
|
||||||
ServiceOutputCreateNote,
|
|
||||||
ServiceOutputNoteCount,
|
|
||||||
} from "./note-service-dto";
|
|
||||||
|
|
||||||
const log = createLogger("note-service");
|
|
||||||
|
|
||||||
export class NoteNotFoundError extends Error {
|
|
||||||
constructor(noteId: bigint) {
|
|
||||||
super(`Note not found: ${noteId.toString()}`);
|
|
||||||
this.name = "NoteNotFoundError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class NoteOwnershipError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super("You do not have permission to access this note");
|
|
||||||
this.name = "NoteOwnershipError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serviceCreateNote(
|
|
||||||
input: ServiceInputCreateNote,
|
|
||||||
): Promise<ServiceOutputCreateNote> {
|
|
||||||
log.debug("Creating note", { userId: input.userId, noteTypeId: input.noteTypeId });
|
|
||||||
|
|
||||||
const id = await repoCreateNote({
|
|
||||||
noteTypeId: input.noteTypeId,
|
|
||||||
fields: input.fields,
|
|
||||||
tags: input.tags,
|
|
||||||
userId: input.userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const note = await repoGetNoteByIdWithFields({ id });
|
|
||||||
if (!note) {
|
|
||||||
throw new NoteNotFoundError(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("Note created successfully", { id: id.toString(), guid: note.guid });
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
guid: note.guid,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serviceUpdateNote(
|
|
||||||
input: ServiceInputUpdateNote,
|
|
||||||
): Promise<void> {
|
|
||||||
log.debug("Updating note", { noteId: input.noteId.toString() });
|
|
||||||
|
|
||||||
const isOwner = await repoCheckNoteOwnership({
|
|
||||||
noteId: input.noteId,
|
|
||||||
userId: input.userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isOwner) {
|
|
||||||
throw new NoteOwnershipError();
|
|
||||||
}
|
|
||||||
|
|
||||||
await repoUpdateNote({
|
|
||||||
id: input.noteId,
|
|
||||||
fields: input.fields,
|
|
||||||
tags: input.tags,
|
|
||||||
});
|
|
||||||
|
|
||||||
log.info("Note updated successfully", { noteId: input.noteId.toString() });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serviceDeleteNote(
|
|
||||||
input: ServiceInputDeleteNote,
|
|
||||||
): Promise<void> {
|
|
||||||
log.debug("Deleting note", { noteId: input.noteId.toString() });
|
|
||||||
|
|
||||||
const isOwner = await repoCheckNoteOwnership({
|
|
||||||
noteId: input.noteId,
|
|
||||||
userId: input.userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isOwner) {
|
|
||||||
throw new NoteOwnershipError();
|
|
||||||
}
|
|
||||||
|
|
||||||
await repoDeleteNote({ id: input.noteId });
|
|
||||||
|
|
||||||
log.info("Note deleted successfully", { noteId: input.noteId.toString() });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serviceGetNoteById(
|
|
||||||
input: ServiceInputGetNoteById,
|
|
||||||
): Promise<ServiceOutputNote | null> {
|
|
||||||
log.debug("Fetching note by id", { noteId: input.noteId.toString() });
|
|
||||||
|
|
||||||
const note = await repoGetNoteByIdWithFields({ id: input.noteId });
|
|
||||||
|
|
||||||
if (!note) {
|
|
||||||
log.debug("Note not found", { noteId: input.noteId.toString() });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: note.id,
|
|
||||||
guid: note.guid,
|
|
||||||
noteTypeId: note.noteTypeId,
|
|
||||||
mod: note.mod,
|
|
||||||
usn: note.usn,
|
|
||||||
tags: note.tagsArray,
|
|
||||||
fields: note.fields,
|
|
||||||
sfld: note.sfld,
|
|
||||||
csum: note.csum,
|
|
||||||
flags: note.flags,
|
|
||||||
data: note.data,
|
|
||||||
userId: note.userId,
|
|
||||||
createdAt: note.createdAt,
|
|
||||||
updatedAt: note.updatedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serviceGetNotesByNoteTypeId(
|
|
||||||
input: ServiceInputGetNotesByNoteTypeId,
|
|
||||||
): Promise<ServiceOutputNote[]> {
|
|
||||||
log.debug("Fetching notes by note type", { noteTypeId: input.noteTypeId });
|
|
||||||
|
|
||||||
const notes = await repoGetNotesByNoteTypeId(input);
|
|
||||||
|
|
||||||
return notes.map((note) => ({
|
|
||||||
id: note.id,
|
|
||||||
guid: note.guid,
|
|
||||||
noteTypeId: note.noteTypeId,
|
|
||||||
mod: note.mod,
|
|
||||||
usn: note.usn,
|
|
||||||
tags: note.tags.trim() === "" ? [] : note.tags.trim().split(" "),
|
|
||||||
fields: note.flds.split("\x1f"),
|
|
||||||
sfld: note.sfld,
|
|
||||||
csum: note.csum,
|
|
||||||
flags: note.flags,
|
|
||||||
data: note.data,
|
|
||||||
userId: note.userId,
|
|
||||||
createdAt: note.createdAt,
|
|
||||||
updatedAt: note.updatedAt,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serviceGetNotesByUserId(
|
|
||||||
input: ServiceInputGetNotesByUserId,
|
|
||||||
): Promise<ServiceOutputNote[]> {
|
|
||||||
log.debug("Fetching notes by user", { userId: input.userId });
|
|
||||||
|
|
||||||
const notes = await repoGetNotesByUserIdWithFields(input);
|
|
||||||
|
|
||||||
return notes.map((note) => ({
|
|
||||||
id: note.id,
|
|
||||||
guid: note.guid,
|
|
||||||
noteTypeId: note.noteTypeId,
|
|
||||||
mod: note.mod,
|
|
||||||
usn: note.usn,
|
|
||||||
tags: note.tagsArray,
|
|
||||||
fields: note.fields,
|
|
||||||
sfld: note.sfld,
|
|
||||||
csum: note.csum,
|
|
||||||
flags: note.flags,
|
|
||||||
data: note.data,
|
|
||||||
userId: note.userId,
|
|
||||||
createdAt: note.createdAt,
|
|
||||||
updatedAt: note.updatedAt,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serviceCountNotesByUserId(
|
|
||||||
userId: string,
|
|
||||||
): Promise<ServiceOutputNoteCount> {
|
|
||||||
const count = await repoCountNotesByUserId(userId);
|
|
||||||
return { count };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serviceCountNotesByNoteTypeId(
|
|
||||||
noteTypeId: number,
|
|
||||||
): Promise<ServiceOutputNoteCount> {
|
|
||||||
const count = await repoCountNotesByNoteTypeId(noteTypeId);
|
|
||||||
return { count };
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export const schemaActionInputProcessOCR = z.object({
|
|
||||||
imageBase64: z.string().min(1, "Image is required"),
|
|
||||||
deckId: z.number().int().positive("Deck ID must be positive"),
|
|
||||||
sourceLanguage: z.string().optional(),
|
|
||||||
targetLanguage: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ActionInputProcessOCR = z.infer<typeof schemaActionInputProcessOCR>;
|
|
||||||
|
|
||||||
export interface ActionOutputProcessOCR {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
data?: {
|
|
||||||
pairsCreated: number;
|
|
||||||
sourceLanguage?: string;
|
|
||||||
targetLanguage?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { validate } from "@/utils/validate";
|
|
||||||
import { ValidateError } from "@/lib/errors";
|
|
||||||
import { createLogger } from "@/lib/logger";
|
|
||||||
import { serviceProcessOCR } from "./ocr-service";
|
|
||||||
import { schemaActionInputProcessOCR } from "./ocr-action-dto";
|
|
||||||
import type { ActionOutputProcessOCR } from "./ocr-action-dto";
|
|
||||||
|
|
||||||
const log = createLogger("ocr-action");
|
|
||||||
|
|
||||||
export async function actionProcessOCR(
|
|
||||||
input: unknown
|
|
||||||
): Promise<ActionOutputProcessOCR> {
|
|
||||||
try {
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
log.warn("Unauthorized OCR attempt");
|
|
||||||
return { success: false, message: "Unauthorized" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const validatedInput = validate(input, schemaActionInputProcessOCR);
|
|
||||||
return serviceProcessOCR({
|
|
||||||
...validatedInput,
|
|
||||||
userId: session.user.id,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ValidateError) {
|
|
||||||
return { success: false, message: e.message };
|
|
||||||
}
|
|
||||||
log.error("OCR action failed", { error: e });
|
|
||||||
return { success: false, message: "Unknown error occurred." };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export {};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export {};
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export const schemaServiceInputProcessOCR = z.object({
|
|
||||||
imageBase64: z.string().min(1, "Image is required"),
|
|
||||||
deckId: z.number().int().positive("Deck ID must be positive"),
|
|
||||||
sourceLanguage: z.string().optional(),
|
|
||||||
targetLanguage: z.string().optional(),
|
|
||||||
userId: z.string().min(1, "User ID is required"),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ServiceInputProcessOCR = z.infer<typeof schemaServiceInputProcessOCR>;
|
|
||||||
|
|
||||||
export interface ServiceOutputProcessOCR {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
data?: {
|
|
||||||
pairsCreated: number;
|
|
||||||
sourceLanguage?: string;
|
|
||||||
targetLanguage?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import { executeOCR } from "@/lib/bigmodel/ocr/orchestrator";
|
|
||||||
import { serviceCheckOwnership } from "@/modules/deck/deck-service";
|
|
||||||
import { serviceCreateNote } from "@/modules/note/note-service";
|
|
||||||
import { serviceCreateCard } from "@/modules/card/card-service";
|
|
||||||
import { serviceGetNoteTypesByUserId, serviceCreateNoteType } from "@/modules/note-type/note-type-service";
|
|
||||||
import { createLogger } from "@/lib/logger";
|
|
||||||
import type { ServiceInputProcessOCR, ServiceOutputProcessOCR } from "./ocr-service-dto";
|
|
||||||
import { NoteKind } from "../../../generated/prisma/enums";
|
|
||||||
|
|
||||||
const log = createLogger("ocr-service");
|
|
||||||
|
|
||||||
const VOCABULARY_NOTE_TYPE_NAME = "Vocabulary (OCR)";
|
|
||||||
|
|
||||||
async function getOrCreateVocabularyNoteType(userId: string): Promise<number> {
|
|
||||||
const existingTypes = await serviceGetNoteTypesByUserId({ userId });
|
|
||||||
const existing = existingTypes.find((nt) => nt.name === VOCABULARY_NOTE_TYPE_NAME);
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
return existing.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fields = [
|
|
||||||
{ name: "Word", ord: 0, sticky: false, rtl: false, font: "Arial", size: 20, media: [] },
|
|
||||||
{ name: "Definition", ord: 1, sticky: false, rtl: false, font: "Arial", size: 20, media: [] },
|
|
||||||
{ name: "Source Language", ord: 2, sticky: false, rtl: false, font: "Arial", size: 16, media: [] },
|
|
||||||
{ name: "Target Language", ord: 3, sticky: false, rtl: false, font: "Arial", size: 16, media: [] },
|
|
||||||
];
|
|
||||||
|
|
||||||
const templates = [
|
|
||||||
{
|
|
||||||
name: "Word → Definition",
|
|
||||||
ord: 0,
|
|
||||||
qfmt: "{{Word}}",
|
|
||||||
afmt: "{{FrontSide}}<hr id=answer>{{Definition}}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Definition → Word",
|
|
||||||
ord: 1,
|
|
||||||
qfmt: "{{Definition}}",
|
|
||||||
afmt: "{{FrontSide}}<hr id=answer>{{Word}}",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const css = ".card { font-family: Arial; font-size: 20px; text-align: center; color: black; background-color: white; }";
|
|
||||||
|
|
||||||
const noteTypeId = await serviceCreateNoteType({
|
|
||||||
name: VOCABULARY_NOTE_TYPE_NAME,
|
|
||||||
kind: NoteKind.STANDARD,
|
|
||||||
css,
|
|
||||||
fields,
|
|
||||||
templates,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
log.info("Created vocabulary note type", { noteTypeId, userId });
|
|
||||||
return noteTypeId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serviceProcessOCR(
|
|
||||||
input: ServiceInputProcessOCR
|
|
||||||
): Promise<ServiceOutputProcessOCR> {
|
|
||||||
log.info("Processing OCR request", { deckId: input.deckId, userId: input.userId });
|
|
||||||
|
|
||||||
const isOwner = await serviceCheckOwnership({
|
|
||||||
deckId: input.deckId,
|
|
||||||
userId: input.userId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isOwner) {
|
|
||||||
log.warn("Deck ownership mismatch", {
|
|
||||||
deckId: input.deckId,
|
|
||||||
userId: input.userId
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: "You don't have permission to modify this deck"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let ocrResult;
|
|
||||||
try {
|
|
||||||
log.debug("Calling OCR pipeline");
|
|
||||||
ocrResult = await executeOCR({
|
|
||||||
imageBase64: input.imageBase64,
|
|
||||||
sourceLanguage: input.sourceLanguage,
|
|
||||||
targetLanguage: input.targetLanguage,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
log.error("OCR pipeline failed", { error });
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: "Failed to process image. Please try again."
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ocrResult.pairs || ocrResult.pairs.length === 0) {
|
|
||||||
log.info("No vocabulary pairs extracted from image");
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: "No vocabulary pairs could be extracted from the image"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceLanguage = ocrResult.detectedSourceLanguage || input.sourceLanguage || "Unknown";
|
|
||||||
const targetLanguage = ocrResult.detectedTargetLanguage || input.targetLanguage || "Unknown";
|
|
||||||
|
|
||||||
const noteTypeId = await getOrCreateVocabularyNoteType(input.userId);
|
|
||||||
|
|
||||||
let pairsCreated = 0;
|
|
||||||
for (const pair of ocrResult.pairs) {
|
|
||||||
try {
|
|
||||||
const { id: noteId } = await serviceCreateNote({
|
|
||||||
noteTypeId,
|
|
||||||
userId: input.userId,
|
|
||||||
fields: [pair.word, pair.definition, sourceLanguage, targetLanguage],
|
|
||||||
tags: ["ocr"],
|
|
||||||
});
|
|
||||||
|
|
||||||
await serviceCreateCard({
|
|
||||||
noteId,
|
|
||||||
deckId: input.deckId,
|
|
||||||
ord: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
await serviceCreateCard({
|
|
||||||
noteId,
|
|
||||||
deckId: input.deckId,
|
|
||||||
ord: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
pairsCreated++;
|
|
||||||
} catch (error) {
|
|
||||||
log.error("Failed to create note/card", {
|
|
||||||
word: pair.word,
|
|
||||||
error
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("OCR processing complete", {
|
|
||||||
pairsCreated,
|
|
||||||
sourceLanguage,
|
|
||||||
targetLanguage
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `Successfully created ${pairsCreated} vocabulary pairs`,
|
|
||||||
data: {
|
|
||||||
pairsCreated,
|
|
||||||
sourceLanguage,
|
|
||||||
targetLanguage,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
25
src/modules/shared/action-utils.ts
Normal file
25
src/modules/shared/action-utils.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"use server-headers";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { createLogger } from "@/lib/logger";
|
||||||
|
|
||||||
|
const log = createLogger("shared-action-utils");
|
||||||
|
|
||||||
|
export async function getCurrentUserId(): Promise<string | null> {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
log.warn("Unauthenticated access attempt");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return session.user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAuth(): Promise<string> {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
log.warn("Authentication required but rejected");
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
@@ -1,50 +1,40 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { serviceGenIPA, serviceGenLanguage, serviceTranslateText } from "./translator-service";
|
||||||
import {
|
import {
|
||||||
ActionInputTranslateText,
|
ActionInputTranslateText,
|
||||||
ActionOutputTranslateText,
|
ActionOutputTranslateText,
|
||||||
validateActionInputTranslateText,
|
validateActionInputTranslateText,
|
||||||
} from "./translator-action-dto";
|
} from "./translator-action-dto";
|
||||||
import { ValidateError } from "@/lib/errors";
|
|
||||||
import { createLogger } from "@/lib/logger";
|
import { createLogger } from "@/lib/logger";
|
||||||
import { serviceTranslateText, serviceGenIPA, serviceGenLanguage } from "./translator-service";
|
import { ValidateError } from "@/lib/errors";
|
||||||
|
|
||||||
const log = createLogger("translator-action");
|
const log = createLogger("translator-action");
|
||||||
|
|
||||||
export const actionTranslateText = async (
|
export const actionTranslateText = async (
|
||||||
dto: ActionInputTranslateText
|
input: unknown,
|
||||||
): Promise<ActionOutputTranslateText> => {
|
): Promise<ActionOutputTranslateText> => {
|
||||||
try {
|
try {
|
||||||
|
const validated = validateActionInputTranslateText(input);
|
||||||
|
const result = await serviceTranslateText(validated);
|
||||||
return {
|
return {
|
||||||
message: "success",
|
|
||||||
success: true,
|
success: true,
|
||||||
data: await serviceTranslateText(validateActionInputTranslateText(dto)),
|
message: "Translation completed",
|
||||||
|
data: result,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ValidateError) {
|
if (e instanceof ValidateError) {
|
||||||
return {
|
return { success: false, message: e.message };
|
||||||
success: false,
|
|
||||||
message: e.message,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
log.error("Translation action failed", { error: e });
|
log.error("Translation failed", { error: e instanceof Error ? e.message : String(e) });
|
||||||
return {
|
return { success: false, message: "Translation failed" };
|
||||||
success: false,
|
|
||||||
message: "Unknown error occurred.",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export const genIPA = async (text: string): Promise<string> => {
|
||||||
* @deprecated 保留此函数以支持旧代码(text-speaker 功能)
|
|
||||||
*/
|
|
||||||
export const genIPA = async (text: string) => {
|
|
||||||
return serviceGenIPA({ text });
|
return serviceGenIPA({ text });
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export const genLanguage = async (text: string): Promise<string> => {
|
||||||
* @deprecated 保留此函数以支持旧代码(text-speaker 功能)
|
|
||||||
*/
|
|
||||||
export const genLanguage = async (text: string) => {
|
|
||||||
return serviceGenLanguage({ text });
|
return serviceGenLanguage({ text });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
export type RepoInputSelectLatestTranslation = {
|
|
||||||
sourceText: string;
|
|
||||||
targetLanguage: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RepoOutputSelectLatestTranslation = {
|
|
||||||
id: number;
|
|
||||||
translatedText: string;
|
|
||||||
sourceLanguage: string;
|
|
||||||
targetLanguage: string;
|
|
||||||
sourceIpa: string | null;
|
|
||||||
targetIpa: string | null;
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
export type RepoInputCreateTranslationHistory = {
|
|
||||||
userId?: string;
|
|
||||||
sourceText: string;
|
|
||||||
sourceLanguage: string;
|
|
||||||
targetLanguage: string;
|
|
||||||
translatedText: string;
|
|
||||||
sourceIpa?: string;
|
|
||||||
targetIpa?: string;
|
|
||||||
};
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import {
|
|
||||||
RepoInputCreateTranslationHistory,
|
|
||||||
RepoInputSelectLatestTranslation,
|
|
||||||
RepoOutputSelectLatestTranslation,
|
|
||||||
} from "./translator-repository-dto";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
|
|
||||||
export async function repoSelectLatestTranslation(
|
|
||||||
dto: RepoInputSelectLatestTranslation
|
|
||||||
): Promise<RepoOutputSelectLatestTranslation> {
|
|
||||||
const result = await prisma.translationHistory.findFirst({
|
|
||||||
where: {
|
|
||||||
sourceText: dto.sourceText,
|
|
||||||
targetLanguage: dto.targetLanguage,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
createdAt: "desc",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: result.id,
|
|
||||||
translatedText: result.translatedText,
|
|
||||||
sourceLanguage: result.sourceLanguage,
|
|
||||||
targetLanguage: result.targetLanguage,
|
|
||||||
sourceIpa: result.sourceIpa,
|
|
||||||
targetIpa: result.targetIpa,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repoCreateTranslationHistory(
|
|
||||||
data: RepoInputCreateTranslationHistory
|
|
||||||
) {
|
|
||||||
return await prisma.translationHistory.create({
|
|
||||||
data: data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { executeTranslation } from "@/lib/bigmodel/translator/orchestrator";
|
import { executeTranslation } from "@/lib/bigmodel/translator/orchestrator";
|
||||||
import { getAnswer } from "@/lib/bigmodel/llm";
|
import { getAnswer } from "@/lib/bigmodel/llm";
|
||||||
import { repoCreateTranslationHistory, repoSelectLatestTranslation } from "./translator-repository";
|
|
||||||
import {
|
import {
|
||||||
ServiceInputTranslateText,
|
ServiceInputTranslateText,
|
||||||
ServiceOutputTranslateText,
|
ServiceOutputTranslateText,
|
||||||
@@ -16,68 +15,23 @@ const log = createLogger("translator-service");
|
|||||||
export const serviceTranslateText = async (
|
export const serviceTranslateText = async (
|
||||||
dto: ServiceInputTranslateText
|
dto: ServiceInputTranslateText
|
||||||
): Promise<ServiceOutputTranslateText> => {
|
): Promise<ServiceOutputTranslateText> => {
|
||||||
const { sourceText, targetLanguage, forceRetranslate, needIpa, userId, sourceLanguage } = dto;
|
const { sourceText, targetLanguage, sourceLanguage, needIpa } = dto;
|
||||||
|
|
||||||
// Check for existing translation
|
const response = await executeTranslation(
|
||||||
const lastTranslation = await repoSelectLatestTranslation({
|
|
||||||
sourceText,
|
sourceText,
|
||||||
targetLanguage,
|
targetLanguage,
|
||||||
});
|
needIpa,
|
||||||
|
sourceLanguage
|
||||||
|
);
|
||||||
|
|
||||||
if (forceRetranslate || !lastTranslation) {
|
return {
|
||||||
// Call AI for translation
|
sourceText: response.sourceText,
|
||||||
const response = await executeTranslation(
|
translatedText: response.translatedText,
|
||||||
sourceText,
|
sourceLanguage: response.sourceLanguage,
|
||||||
targetLanguage,
|
targetLanguage: response.targetLanguage,
|
||||||
needIpa,
|
sourceIpa: response.sourceIpa || "",
|
||||||
sourceLanguage
|
targetIpa: response.targetIpa || "",
|
||||||
);
|
};
|
||||||
|
|
||||||
// Save translation history asynchronously (don't block response)
|
|
||||||
repoCreateTranslationHistory({
|
|
||||||
userId,
|
|
||||||
sourceText,
|
|
||||||
sourceLanguage: response.sourceLanguage,
|
|
||||||
targetLanguage: response.targetLanguage,
|
|
||||||
translatedText: response.translatedText,
|
|
||||||
sourceIpa: needIpa ? response.sourceIpa : undefined,
|
|
||||||
targetIpa: needIpa ? response.targetIpa : undefined,
|
|
||||||
}).catch((error) => {
|
|
||||||
log.error("Failed to save translation data", { error });
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
sourceText: response.sourceText,
|
|
||||||
translatedText: response.translatedText,
|
|
||||||
sourceLanguage: response.sourceLanguage,
|
|
||||||
targetLanguage: response.targetLanguage,
|
|
||||||
sourceIpa: response.sourceIpa || "",
|
|
||||||
targetIpa: response.targetIpa || "",
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// Return cached translation
|
|
||||||
// Still save a history record for analytics
|
|
||||||
repoCreateTranslationHistory({
|
|
||||||
userId,
|
|
||||||
sourceText,
|
|
||||||
sourceLanguage: lastTranslation.sourceLanguage,
|
|
||||||
targetLanguage: lastTranslation.targetLanguage,
|
|
||||||
translatedText: lastTranslation.translatedText,
|
|
||||||
sourceIpa: lastTranslation.sourceIpa || undefined,
|
|
||||||
targetIpa: lastTranslation.targetIpa || undefined,
|
|
||||||
}).catch((error) => {
|
|
||||||
log.error("Failed to save translation data", { error });
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
sourceText,
|
|
||||||
translatedText: lastTranslation.translatedText,
|
|
||||||
sourceLanguage: lastTranslation.sourceLanguage,
|
|
||||||
targetLanguage: lastTranslation.targetLanguage,
|
|
||||||
sourceIpa: lastTranslation.sourceIpa || "",
|
|
||||||
targetIpa: lastTranslation.targetIpa || "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const serviceGenIPA = async (
|
export const serviceGenIPA = async (
|
||||||
@@ -96,7 +50,7 @@ export const serviceGenIPA = async (
|
|||||||
不要附带任何说明
|
不要附带任何说明
|
||||||
不要擅自增减符号
|
不要擅自增减符号
|
||||||
不许用"/"或者"[]"包裹
|
不许用"/"或者"[]"包裹
|
||||||
`.trim(),
|
`.trim(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.replaceAll("[", "")
|
.replaceAll("[", "")
|
||||||
@@ -139,7 +93,7 @@ export const serviceGenLanguage = async (
|
|||||||
2. 首字母大写,其余小写
|
2. 首字母大写,其余小写
|
||||||
3. 不要附带任何说明
|
3. 不要附带任何说明
|
||||||
4. 不要擅自增减符号
|
4. 不要擅自增减符号
|
||||||
`.trim()
|
`.trim()
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared types for Anki-compatible data structures
|
|
||||||
* Based on Anki's official database schema
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { CardType, CardQueue, NoteKind, Visibility } from "../../generated/prisma/enums";
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// NoteType (Anki: models)
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export interface NoteTypeField {
|
|
||||||
name: string;
|
|
||||||
ord: number;
|
|
||||||
sticky: boolean;
|
|
||||||
rtl: boolean;
|
|
||||||
font: string;
|
|
||||||
size: number;
|
|
||||||
media: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NoteTypeTemplate {
|
|
||||||
name: string;
|
|
||||||
ord: number;
|
|
||||||
qfmt: string; // Question format (Mustache template)
|
|
||||||
afmt: string; // Answer format (Mustache template)
|
|
||||||
bqfmt?: string; // Browser question format
|
|
||||||
bafmt?: string; // Browser answer format
|
|
||||||
did?: number; // Deck override
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TSharedNoteType {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
kind: NoteKind;
|
|
||||||
css: string;
|
|
||||||
fields: NoteTypeField[];
|
|
||||||
templates: NoteTypeTemplate[];
|
|
||||||
userId: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Deck (Anki: decks) - replaces Folder
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export interface TSharedDeck {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
desc: string;
|
|
||||||
userId: string;
|
|
||||||
visibility: Visibility;
|
|
||||||
collapsed: boolean;
|
|
||||||
conf: Record<string, unknown>;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
cardCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Note (Anki: notes)
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export interface TSharedNote {
|
|
||||||
id: bigint;
|
|
||||||
guid: string;
|
|
||||||
noteTypeId: number;
|
|
||||||
mod: number;
|
|
||||||
usn: number;
|
|
||||||
tags: string; // Space-separated
|
|
||||||
flds: string; // Field values separated by 0x1f
|
|
||||||
sfld: string; // Sort field
|
|
||||||
csum: number; // Checksum of first field
|
|
||||||
flags: number;
|
|
||||||
data: string;
|
|
||||||
userId: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to get fields as array
|
|
||||||
export function getNoteFields(note: TSharedNote): string[] {
|
|
||||||
return note.flds.split('\x1f');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to set fields from array
|
|
||||||
export function setNoteFields(fields: string[]): string {
|
|
||||||
return fields.join('\x1f');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to get tags as array
|
|
||||||
export function getNoteTags(note: TSharedNote): string[] {
|
|
||||||
return note.tags.trim().split(' ').filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to set tags from array
|
|
||||||
export function setNoteTags(tags: string[]): string {
|
|
||||||
return ` ${tags.join(' ')} `;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Card (Anki: cards)
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export interface TSharedCard {
|
|
||||||
id: bigint;
|
|
||||||
noteId: bigint;
|
|
||||||
deckId: number;
|
|
||||||
ord: number;
|
|
||||||
mod: number;
|
|
||||||
usn: number;
|
|
||||||
type: CardType;
|
|
||||||
queue: CardQueue;
|
|
||||||
due: number;
|
|
||||||
ivl: number;
|
|
||||||
factor: number;
|
|
||||||
reps: number;
|
|
||||||
lapses: number;
|
|
||||||
left: number;
|
|
||||||
odue: number;
|
|
||||||
odid: number;
|
|
||||||
flags: number;
|
|
||||||
data: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Card for review (with note data)
|
|
||||||
export interface TCardForReview extends TSharedCard {
|
|
||||||
note: TSharedNote & {
|
|
||||||
noteType: TSharedNoteType;
|
|
||||||
};
|
|
||||||
deck: TSharedDeck;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Review
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export type ReviewEase = 1 | 2 | 3 | 4; // Again, Hard, Good, Easy
|
|
||||||
|
|
||||||
export interface TSharedRevlog {
|
|
||||||
id: bigint;
|
|
||||||
cardId: bigint;
|
|
||||||
usn: number;
|
|
||||||
ease: number;
|
|
||||||
ivl: number;
|
|
||||||
lastIvl: number;
|
|
||||||
factor: number;
|
|
||||||
time: number; // Review time in ms
|
|
||||||
type: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Deck Favorites
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export interface TSharedDeckFavorite {
|
|
||||||
id: number;
|
|
||||||
userId: string;
|
|
||||||
deckId: number;
|
|
||||||
createdAt: Date;
|
|
||||||
deck?: TSharedDeck;
|
|
||||||
}
|
|
||||||
33
src/shared/card-type.ts
Normal file
33
src/shared/card-type.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export type DictionaryItemEntry = {
|
||||||
|
id: number;
|
||||||
|
itemId: number;
|
||||||
|
ipa: string | null;
|
||||||
|
definition: string;
|
||||||
|
partOfSpeech: string | null;
|
||||||
|
example: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DictionaryItemWithEntries = {
|
||||||
|
id: number;
|
||||||
|
frequency: number;
|
||||||
|
standardForm: string;
|
||||||
|
queryLang: string;
|
||||||
|
definitionLang: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
entries: DictionaryItemEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CardSide = "A" | "B";
|
||||||
|
|
||||||
|
export type CardForStudy = {
|
||||||
|
id: number;
|
||||||
|
deckId: number;
|
||||||
|
showSideAFirst: boolean;
|
||||||
|
sideA: DictionaryItemWithEntries;
|
||||||
|
sideB: DictionaryItemWithEntries;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
@@ -20,3 +20,17 @@ export const LENGTH_MAX_USERNAME = 30;
|
|||||||
export const LENGTH_MIN_USERNAME = 3;
|
export const LENGTH_MIN_USERNAME = 3;
|
||||||
export const LENGTH_MAX_PASSWORD = 100;
|
export const LENGTH_MAX_PASSWORD = 100;
|
||||||
export const LENGTH_MIN_PASSWORD = 8;
|
export const LENGTH_MIN_PASSWORD = 8;
|
||||||
|
|
||||||
|
export const FIELD_SEPARATOR = "\x1f";
|
||||||
|
|
||||||
|
export const DEFAULT_NEW_PER_DAY = 20;
|
||||||
|
export const DEFAULT_REV_PER_DAY = 200;
|
||||||
|
|
||||||
|
export const SECONDS_PER_MINUTE = 60;
|
||||||
|
export const SECONDS_PER_HOUR = 3600;
|
||||||
|
export const SECONDS_PER_DAY = 86400;
|
||||||
|
|
||||||
|
export const MS_PER_SECOND = 1000;
|
||||||
|
export const MS_PER_MINUTE = 60000;
|
||||||
|
export const MS_PER_HOUR = 3600000;
|
||||||
|
export const MS_PER_DAY = 86400000;
|
||||||
@@ -6,6 +6,7 @@ export type TSharedEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type TSharedItem = {
|
export type TSharedItem = {
|
||||||
|
id?: number;
|
||||||
standardForm: string,
|
standardForm: string,
|
||||||
entries: TSharedEntry[];
|
entries: TSharedEntry[];
|
||||||
};
|
};
|
||||||
@@ -280,7 +280,7 @@ export const THEME_PRESETS: ThemePreset[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DEFAULT_THEME = "teal";
|
export const DEFAULT_THEME = "mist";
|
||||||
|
|
||||||
export function getThemePreset(id: string): ThemePreset | undefined {
|
export function getThemePreset(id: string): ThemePreset | undefined {
|
||||||
return THEME_PRESETS.find((preset) => preset.id === id);
|
return THEME_PRESETS.find((preset) => preset.id === id);
|
||||||
|
|||||||
Reference in New Issue
Block a user