Compare commits
1 Commits
dev
...
5898a6ba65
| Author | SHA1 | Date | |
|---|---|---|---|
| 5898a6ba65 |
54
AGENTS.md
54
AGENTS.md
@@ -104,60 +104,6 @@ log.info("Fetched folders", { count: folders.length });
|
||||
log.error("Failed to fetch folders", { error });
|
||||
```
|
||||
|
||||
### i18n 翻译检查
|
||||
**注意:翻译缺失不会被 build 检测出来。**
|
||||
|
||||
**系统性检查翻译缺失的方法(改进版):**
|
||||
|
||||
#### 步骤 1: 使用 AST-grep 搜索所有翻译模式
|
||||
|
||||
```bash
|
||||
# 搜索所有 useTranslations 和 getTranslations 声明
|
||||
ast-grep --pattern 'useTranslations($ARG)' --lang tsx --paths src/
|
||||
|
||||
# 搜索所有带插值的 t() 调用
|
||||
ast-grep --pattern 't($ARG, $OPTS)' --lang tsx --paths src/
|
||||
|
||||
# 搜索所有简单 t() 调用
|
||||
ast-grep --pattern 't($ARG)' --lang tsx --paths src/
|
||||
```
|
||||
|
||||
**AST-grep 能捕获 31 种不同的翻译键模式, 而 grep 只能捕获 1 种模式。**
|
||||
|
||||
#### 步骤 2: 按文件提取所有翻译键
|
||||
|
||||
逐个 `.tsx` 文件检查使用的翻译键:
|
||||
1. 找到该文件使用的 namespace(`useTranslations("namespace")` 或 `getTranslations("namespace")`)
|
||||
2. 提取该文件中所有 `t("...")` 调用
|
||||
3. 注意动态键模式:
|
||||
- 模板字面量: `t(\`prefix.${variable}\`)`
|
||||
- 条件键: `t(condition ? "a" : "b")`
|
||||
- 变量键: `t(variable)`
|
||||
4. 对比 `messages/en-US.json`,找出缺失的键
|
||||
|
||||
5. 先补全 `en-US.json`(作为基准语言)
|
||||
6. 再根据 `en-US.json` 补全其他 7 种语言
|
||||
|
||||
#### 步骤 3: 验证 JSON 文件结构
|
||||
**注意:JSON 语法错误会导致 build 失败,常见错误:**
|
||||
- 重复的键(同一对象中出现两次相同的键名)
|
||||
- 缺少逗号或多余的逗号
|
||||
- 缺少闭合括号 `}`
|
||||
|
||||
```bash
|
||||
# 验证 JSON 格式
|
||||
node -e "console.log(JSON.parse(require('fs').readFileSync('messages/en-US.json', 'utf8')))"
|
||||
```
|
||||
|
||||
#### 步骤 4: 对比验证
|
||||
```bash
|
||||
# 列出代码中使用的所有 namespace
|
||||
ast-grep --pattern 'useTranslations($ARG)' --lang tsx --paths src/ | grep -o 'useTranslations\|getTranslations' | sort | uniq
|
||||
|
||||
# 对比 messages/en-US.json 中的 namespace 列表
|
||||
node -e "console.log(Object.keys(JSON.parse(require('fs').readFileSync('messages/en-US.json', 'utf8'))).join('\n'))"
|
||||
```
|
||||
|
||||
## 反模式 (本项目)
|
||||
|
||||
- ❌ `index.ts` barrel exports
|
||||
|
||||
@@ -46,38 +46,6 @@
|
||||
"unfavorite": "Aus Favoriten entfernen",
|
||||
"pleaseLogin": "Bitte melden Sie sich zuerst an"
|
||||
},
|
||||
"decks": {
|
||||
"title": "Decks",
|
||||
"noDecks": "Noch keine Decks",
|
||||
"deckName": "Deckname",
|
||||
"totalCards": "Gesamtkarten",
|
||||
"createdAt": "Erstellt am",
|
||||
"actions": "Aktionen",
|
||||
"view": "Anzeigen",
|
||||
"subtitle": "Lern-Decks verwalten",
|
||||
"newDeck": "Neues Deck",
|
||||
"noDecksYet": "Noch keine Decks",
|
||||
"loading": "Laden...",
|
||||
"deckInfo": "ID: {id} · {totalCards} Karten",
|
||||
"enterDeckName": "Deck-Name eingeben:",
|
||||
"enterNewName": "Neuen Namen eingeben:",
|
||||
"confirmDelete": "\"{name}\" eingeben zum Löschen:",
|
||||
"public": "Öffentlich",
|
||||
"private": "Privat",
|
||||
"setPublic": "Öffentlich machen",
|
||||
"setPrivate": "Privat machen",
|
||||
"importApkg": "APKG importieren",
|
||||
"exportApkg": "APKG exportieren",
|
||||
"clickToUpload": "Klicken zum Hochladen",
|
||||
"apkgFilesOnly": "Nur .apkg Dateien",
|
||||
"parsing": "Analysieren...",
|
||||
"foundDecks": "{count} Decks gefunden",
|
||||
"back": "Zurück",
|
||||
"import": "Importieren",
|
||||
"importing": "Importieren...",
|
||||
"exportSuccess": "Export erfolgreich",
|
||||
"goToDecks": "Zu Decks"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "Sie sind nicht der Besitzer dieses Ordners",
|
||||
"back": "Zurück",
|
||||
@@ -189,9 +157,6 @@
|
||||
"resetPasswordFailed": "Reset-E-Mail konnte nicht gesendet werden",
|
||||
"resetPasswordEmailSent": "Reset-E-Mail erfolgreich gesendet",
|
||||
"resetPasswordEmailSentHint": "Wir haben einen Link zum Zurücksetzen Ihres Passworts an Ihre E-Mail-Adresse gesendet. Bitte überprüfen Sie Ihren Posteingang.",
|
||||
"verifyYourEmail": "E-Mail bestätigen",
|
||||
"verificationEmailSent": "Bestätigungs-E-Mail gesendet",
|
||||
"verificationEmailSentHint": "Wir haben eine Bestätigungs-E-Mail an {email} gesendet. Bitte klicken Sie auf den Link in der E-Mail, um Ihr Konto zu bestätigen.",
|
||||
"checkYourEmail": "Überprüfen Sie Ihre E-Mail",
|
||||
"backToLogin": "Zurück zur Anmeldung",
|
||||
"resetPassword": "Passwort zurücksetzen",
|
||||
@@ -201,47 +166,25 @@
|
||||
"requestNewToken": "Neuen Reset-Link anfordern",
|
||||
"resetPasswordSuccess": "Passwort erfolgreich zurückgesetzt",
|
||||
"resetPasswordSuccessTitle": "Passwort-Zurücksetzung abgeschlossen",
|
||||
"resetPasswordSuccessHint": "Ihr Passwort wurde erfolgreich zurückgesetzt. Sie können sich jetzt mit Ihrem neuen Passwort anmelden.",
|
||||
"emailNotVerified": "Bitte verifizieren Sie Ihre E-Mail-Adresse",
|
||||
"emailNotVerifiedHint": "Ihre E-Mail-Adresse wurde nicht verifiziert. Bitte überprüfen Sie Ihren Posteingang oder fordern Sie eine neue Verifizierungs-E-Mail an.",
|
||||
"resendVerification": "Verifizierungs-E-Mail erneut senden",
|
||||
"resendSuccess": "Verifizierungs-E-Mail gesendet! Bitte überprüfen Sie Ihren Posteingang.",
|
||||
"resendFailed": "Verifizierungs-E-Mail konnte nicht gesendet werden"
|
||||
"resetPasswordSuccessHint": "Ihr Passwort wurde erfolgreich zurückgesetzt. Sie können sich jetzt mit Ihrem neuen Passwort anmelden."
|
||||
},
|
||||
"memorize": {
|
||||
"deck_selector": {
|
||||
"selectDeck": "Deck wählen",
|
||||
"noDecks": "Keine Decks",
|
||||
"goToDecks": "Zu Decks",
|
||||
"noCards": "Keine Karten",
|
||||
"new": "Neu",
|
||||
"learning": "Lernen",
|
||||
"review": "Wiederholen",
|
||||
"due": "Fällig"
|
||||
"folder_selector": {
|
||||
"selectFolder": "Wählen Sie einen Ordner",
|
||||
"noFolders": "Keine Ordner gefunden",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
},
|
||||
"review": {
|
||||
"loading": "Laden...",
|
||||
"backToDecks": "Zurück zu Decks",
|
||||
"allDone": "Alles erledigt!",
|
||||
"allDoneDesc": "Lernen für heute abgeschlossen!",
|
||||
"reviewedCount": "{count} Karten wiederholt",
|
||||
"progress": "{current} / {total}",
|
||||
"nextReview": "Nächste Wiederholung",
|
||||
"interval": "Intervall",
|
||||
"ease": "Schwierigkeit",
|
||||
"lapses": "Fehler",
|
||||
"showAnswer": "Antwort zeigen",
|
||||
"nextCard": "Weiter",
|
||||
"again": "Nochmal",
|
||||
"restart": "Neustart",
|
||||
"orderLimited": "Reihenfolge begrenzt",
|
||||
"orderInfinite": "Reihenfolge unbegrenzt",
|
||||
"randomLimited": "Zufällig begrenzt",
|
||||
"randomInfinite": "Zufällig unbegrenzt",
|
||||
"noIpa": "Kein IPA verfügbar"
|
||||
"memorize": {
|
||||
"answer": "Antwort",
|
||||
"next": "Weiter",
|
||||
"reverse": "Umkehren",
|
||||
"dictation": "Diktat",
|
||||
"noTextPairs": "Keine Textpaare verfügbar",
|
||||
"disorder": "Mischen",
|
||||
"previous": "Zurück"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "Nicht autorisiert"
|
||||
"unauthorized": "Sie sind nicht berechtigt, auf diesen Ordner zuzugreifen"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
@@ -249,56 +192,11 @@
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "Anmelden",
|
||||
"profile": "Profil",
|
||||
"folders": "Decks",
|
||||
"folders": "Ordner",
|
||||
"explore": "Erkunden",
|
||||
"favorites": "Favoriten",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"ocr": {
|
||||
"title": "OCR-Erkennung",
|
||||
"description": "Text aus Bildern extrahieren",
|
||||
"uploadImage": "Bild hochladen",
|
||||
"dragDropHint": "Ziehen und ablegen",
|
||||
"supportedFormats": "Unterstützt: JPG, PNG, WEBP",
|
||||
"selectDeck": "Deck wählen",
|
||||
"chooseDeck": "Deck wählen",
|
||||
"noDecks": "Keine Decks verfügbar",
|
||||
"languageHints": "Sprachhinweise",
|
||||
"sourceLanguageHint": "Quellsprache",
|
||||
"targetLanguageHint": "Zielsprache",
|
||||
"process": "Verarbeiten",
|
||||
"processing": "Verarbeiten...",
|
||||
"preview": "Vorschau",
|
||||
"extractedPairs": "Extrahierte Paare",
|
||||
"word": "Wort",
|
||||
"definition": "Definition",
|
||||
"pairsCount": "{count} Paare",
|
||||
"savePairs": "Speichern",
|
||||
"saving": "Speichern...",
|
||||
"saved": "Gespeichert",
|
||||
"saveFailed": "Speichern fehlgeschlagen",
|
||||
"noImage": "Bitte Bild hochladen",
|
||||
"noDeck": "Bitte Deck wählen",
|
||||
"processingFailed": "Verarbeitung fehlgeschlagen",
|
||||
"tryAgain": "Erneut versuchen",
|
||||
"detectedLanguages": "Erkannte Sprachen",
|
||||
"invalidFileType": "Ungültiger Dateityp",
|
||||
"ocrFailed": "OCR fehlgeschlagen",
|
||||
"uploadSection": "Bild hochladen",
|
||||
"dropOrClick": "Ablegen oder klicken",
|
||||
"changeImage": "Bild ändern",
|
||||
"deckSelection": "Deck wählen",
|
||||
"sourceLanguagePlaceholder": "z.B. Englisch",
|
||||
"targetLanguagePlaceholder": "z.B. Deutsch",
|
||||
"processButton": "Erkennung starten",
|
||||
"resultsPreview": "Ergebnisvorschau",
|
||||
"saveButton": "In Deck speichern",
|
||||
"ocrSuccess": "OCR erfolgreich",
|
||||
"savedToDeck": "In Deck gespeichert",
|
||||
"noResultsToSave": "Keine Ergebnisse",
|
||||
"detectedSourceLanguage": "Erkannte Quellsprache",
|
||||
"detectedTargetLanguage": "Erkannte Zielsprache"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "Mein Profil",
|
||||
"email": "E-Mail: {email}",
|
||||
@@ -338,43 +236,12 @@
|
||||
"videoUploadFailed": "Video-Upload fehlgeschlagen",
|
||||
"subtitleUploadFailed": "Untertitel-Upload fehlgeschlagen",
|
||||
"subtitleLoadSuccess": "Untertitel erfolgreich geladen",
|
||||
"subtitleLoadFailed": "Laden der Untertitel fehlgeschlagen",
|
||||
"settings": "Einstellungen",
|
||||
"shortcuts": "Tastenkürzel",
|
||||
"keyboardShortcuts": "Tastaturkürzel",
|
||||
"playPause": "Wiedergabe/Pause",
|
||||
"autoPauseToggle": "Auto-Pause",
|
||||
"subtitleSettings": "Untertiteleinstellungen",
|
||||
"fontSize": "Schriftgröße",
|
||||
"textColor": "Textfarbe",
|
||||
"backgroundColor": "Hintergrundfarbe",
|
||||
"position": "Position",
|
||||
"opacity": "Deckkraft",
|
||||
"top": "Oben",
|
||||
"center": "Mitte",
|
||||
"bottom": "Unten"
|
||||
"subtitleLoadFailed": "Laden der Untertitel fehlgeschlagen"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "IPA generieren",
|
||||
"viewSavedItems": "Gespeicherte Einträge anzeigen",
|
||||
"confirmDeleteAll": "Sind Sie sicher, dass Sie alles löschen möchten? (J/N)",
|
||||
"saved": "Gespeichert",
|
||||
"clearAll": "Alles löschen",
|
||||
"language": "Sprache",
|
||||
"customLanguage": "oder Sprache eingeben...",
|
||||
"languages": {
|
||||
"auto": "Automatisch",
|
||||
"chinese": "Chinesisch",
|
||||
"english": "Englisch",
|
||||
"japanese": "Japanisch",
|
||||
"korean": "Koreanisch",
|
||||
"french": "Französisch",
|
||||
"german": "Deutsch",
|
||||
"italian": "Italienisch",
|
||||
"spanish": "Spanisch",
|
||||
"portuguese": "Portugiesisch",
|
||||
"russian": "Russisch"
|
||||
}
|
||||
"confirmDeleteAll": "Sind Sie sicher, dass Sie alles löschen möchten? (J/N)"
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "Sprache erkennen",
|
||||
@@ -407,20 +274,7 @@
|
||||
"success": "Textpaar zum Ordner hinzugefügt",
|
||||
"error": "Fehler beim Hinzufügen des Textpaars zum Ordner"
|
||||
},
|
||||
"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"
|
||||
"autoSave": "Autom. Speichern"
|
||||
},
|
||||
"dictionary": {
|
||||
"title": "Wörterbuch",
|
||||
@@ -465,9 +319,7 @@
|
||||
"unfavorite": "Aus Favoriten entfernen",
|
||||
"pleaseLogin": "Bitte melden Sie sich zuerst an",
|
||||
"sortByFavorites": "Nach Favoriten sortieren",
|
||||
"sortByFavoritesActive": "Sortierung nach Favoriten aufheben",
|
||||
"noDecks": "Keine öffentlichen Decks",
|
||||
"deckInfo": "{userName} · {totalCards} Karten"
|
||||
"sortByFavoritesActive": "Sortierung nach Favoriten aufheben"
|
||||
},
|
||||
"exploreDetail": {
|
||||
"title": "Ordnerdetails",
|
||||
@@ -481,8 +333,7 @@
|
||||
"unfavorite": "Aus Favoriten entfernen",
|
||||
"favorited": "Favorisiert",
|
||||
"unfavorited": "Aus Favoriten entfernt",
|
||||
"pleaseLogin": "Bitte melden Sie sich zuerst an",
|
||||
"totalCards": "{count} Karten"
|
||||
"pleaseLogin": "Bitte melden Sie sich zuerst an"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "Meine Favoriten",
|
||||
@@ -504,119 +355,14 @@
|
||||
"notSet": "Nicht festgelegt",
|
||||
"memberSince": "Mitglied seit",
|
||||
"logout": "Abmelden",
|
||||
"deleteAccount": {
|
||||
"button": "Konto löschen",
|
||||
"title": "Konto löschen",
|
||||
"warning": "Diese Aktion ist unwiderruflich. Alle Ihre Daten werden dauerhaft gelöscht.",
|
||||
"warningDecks": "Alle Ihre Decks und Karten",
|
||||
"warningCards": "All Ihr Lernfortschritt",
|
||||
"warningHistory": "All Ihr Übersetzungs- und Wörterbuchverlauf",
|
||||
"warningPermanent": "Diese Aktion kann nicht rückgängig gemacht werden",
|
||||
"confirmLabel": "Geben Sie Ihren Benutzernamen zur Bestätigung ein:",
|
||||
"usernameMismatch": "Benutzername stimmt nicht überein",
|
||||
"cancel": "Abbrechen",
|
||||
"confirm": "Mein Konto löschen",
|
||||
"success": "Konto erfolgreich gelöscht",
|
||||
"failed": "Konto konnte nicht gelöscht werden"
|
||||
},
|
||||
"folders": {
|
||||
"title": "Decks",
|
||||
"noFolders": "Noch keine Decks",
|
||||
"folderName": "Deckname",
|
||||
"totalPairs": "Gesamtkarten",
|
||||
"title": "Ordner",
|
||||
"noFolders": "Noch keine Ordner",
|
||||
"folderName": "Ordnername",
|
||||
"totalPairs": "Gesamtpaare",
|
||||
"createdAt": "Erstellt am",
|
||||
"actions": "Aktionen",
|
||||
"view": "Anzeigen"
|
||||
},
|
||||
"joined": "Beigetreten",
|
||||
"decks": {
|
||||
"title": "Meine Decks",
|
||||
"noDecks": "Keine Decks",
|
||||
"deckName": "Deck-Name",
|
||||
"totalCards": "Gesamtkarten",
|
||||
"createdAt": "Erstellt am",
|
||||
"actions": "Aktionen",
|
||||
"view": "Ansehen"
|
||||
}
|
||||
},
|
||||
"follow": {
|
||||
"follow": "Folgen",
|
||||
"following": "Folge ich",
|
||||
"followers": "Follower",
|
||||
"followersOf": "{username}s Follower",
|
||||
"followingOf": "{username} folgt",
|
||||
"noFollowers": "Noch keine Follower",
|
||||
"noFollowing": "Folgt noch niemandem"
|
||||
},
|
||||
"deck_id": {
|
||||
"unauthorized": "Sie sind nicht der Besitzer dieses Decks",
|
||||
"back": "Zurück",
|
||||
"cards": "Karten",
|
||||
"itemsCount": "{count} Elemente",
|
||||
"memorize": "Auswendig lernen",
|
||||
"loadingCards": "Karten werden geladen...",
|
||||
"noCards": "Keine Karten in diesem Deck",
|
||||
"card": "Karte",
|
||||
"addNewCard": "Neue Karte hinzufügen",
|
||||
"add": "Hinzufügen",
|
||||
"adding": "Wird hinzugefügt...",
|
||||
"updateCard": "Karte aktualisieren",
|
||||
"update": "Aktualisieren",
|
||||
"updating": "Wird aktualisiert...",
|
||||
"word": "Wort",
|
||||
"definition": "Definition",
|
||||
"ipa": "IPA",
|
||||
"example": "Beispiel",
|
||||
"wordAndDefinitionRequired": "Wort und Definition sind erforderlich",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen",
|
||||
"permissionDenied": "Sie haben keine Berechtigung für diese Aktion",
|
||||
"resetProgress": "Fortschritt zurücksetzen",
|
||||
"resetProgressTitle": "Lernfortschritt zurücksetzen",
|
||||
"resetProgressConfirm": "Fortschritt wirklich zurücksetzen?",
|
||||
"resetSuccess": "Fortschritt zurückgesetzt",
|
||||
"resetting": "Zurücksetzen...",
|
||||
"cancel": "Abbrechen",
|
||||
"settings": "Einstellungen",
|
||||
"settingsTitle": "Deck-Einstellungen",
|
||||
"newPerDay": "Neue pro Tag",
|
||||
"newPerDayHint": "Neue Karten pro Tag",
|
||||
"revPerDay": "Wiederholungen pro Tag",
|
||||
"revPerDayHint": "Wiederholungen pro Tag",
|
||||
"save": "Speichern",
|
||||
"saving": "Speichern...",
|
||||
"settingsSaved": "Einstellungen gespeichert",
|
||||
"todayNew": "Heute neu",
|
||||
"todayReview": "Heute wiederholen",
|
||||
"todayLearning": "Lernen",
|
||||
"error": {
|
||||
"update": "Keine Berechtigung zum Aktualisieren",
|
||||
"delete": "Keine Berechtigung zum Löschen",
|
||||
"add": "Keine Berechtigung zum Hinzufügen"
|
||||
},
|
||||
"ipaPlaceholder": "IPA eingeben",
|
||||
"examplePlaceholder": "Beispiel eingeben",
|
||||
"wordRequired": "Bitte Wort eingeben",
|
||||
"definitionRequired": "Bitte Definition eingeben",
|
||||
"cardAdded": "Karte hinzugefügt",
|
||||
"cardType": "Kartentyp",
|
||||
"wordCard": "Wortkarte",
|
||||
"phraseCard": "Phrasenkarte",
|
||||
"sentenceCard": "Satzkarte",
|
||||
"sentence": "Satz",
|
||||
"sentencePlaceholder": "Satz eingeben",
|
||||
"wordPlaceholder": "Wort eingeben",
|
||||
"queryLang": "Abfragesprache",
|
||||
"enterLanguageName": "Bitte Sprachnamen eingeben",
|
||||
"english": "Englisch",
|
||||
"chinese": "Chinesisch",
|
||||
"japanese": "Japanisch",
|
||||
"korean": "Koreanisch",
|
||||
"meanings": "Bedeutungen",
|
||||
"addMeaning": "Bedeutung hinzufügen",
|
||||
"partOfSpeech": "Wortart",
|
||||
"deleteConfirm": "Karte wirklich löschen?",
|
||||
"cardDeleted": "Karte gelöscht",
|
||||
"cardUpdated": "Karte aktualisiert"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,77 +74,6 @@
|
||||
"deleteFolder": "You do not have permission to delete this folder."
|
||||
}
|
||||
},
|
||||
"deck_id": {
|
||||
"unauthorized": "You are not the owner of this deck",
|
||||
"back": "Back",
|
||||
"cards": "Cards",
|
||||
"itemsCount": "{count} items",
|
||||
"memorize": "Memorize",
|
||||
"loadingCards": "Loading cards...",
|
||||
"noCards": "No cards in this deck",
|
||||
"card": "Card",
|
||||
"addNewCard": "Add New Card",
|
||||
"add": "Add",
|
||||
"adding": "Adding...",
|
||||
"updateCard": "Update Card",
|
||||
"update": "Update",
|
||||
"updating": "Updating...",
|
||||
"word": "Word",
|
||||
"definition": "Definition",
|
||||
"ipa": "IPA",
|
||||
"ipaPlaceholder": "Enter IPA pronunciation",
|
||||
"example": "Example",
|
||||
"examplePlaceholder": "Enter an example sentence",
|
||||
"wordAndDefinitionRequired": "Word and definition are required",
|
||||
"wordRequired": "Word is required",
|
||||
"definitionRequired": "At least one definition is required",
|
||||
"cardAdded": "Card added successfully",
|
||||
"cardType": "Card Type",
|
||||
"wordCard": "Word",
|
||||
"phraseCard": "Phrase",
|
||||
"sentenceCard": "Sentence",
|
||||
"sentence": "Sentence",
|
||||
"sentencePlaceholder": "Enter a sentence",
|
||||
"wordPlaceholder": "Enter a word",
|
||||
"queryLang": "Language",
|
||||
"enterLanguageName": "Please enter language name",
|
||||
"english": "English",
|
||||
"chinese": "Chinese",
|
||||
"japanese": "Japanese",
|
||||
"korean": "Korean",
|
||||
"meanings": "Meanings",
|
||||
"addMeaning": "Add Meaning",
|
||||
"partOfSpeech": "Part of Speech",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"deleteConfirm": "Are you sure you want to delete this card?",
|
||||
"cardDeleted": "Card deleted",
|
||||
"permissionDenied": "You do not have permission to perform this action",
|
||||
"resetProgress": "Reset",
|
||||
"resetProgressTitle": "Reset Deck Progress",
|
||||
"resetProgressConfirm": "This will reset all cards in this deck to new state. Your learning progress will be lost. Are you sure?",
|
||||
"resetSuccess": "Successfully reset {count} cards",
|
||||
"resetting": "Resetting...",
|
||||
"cancel": "Cancel",
|
||||
"settings": "Settings",
|
||||
"settingsTitle": "Deck Settings",
|
||||
"newPerDay": "New Cards Per Day",
|
||||
"newPerDayHint": "Maximum new cards to learn each day",
|
||||
"revPerDay": "Review Cards Per Day",
|
||||
"revPerDayHint": "Maximum review cards each day",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"settingsSaved": "Settings saved",
|
||||
"todayNew": "New",
|
||||
"todayReview": "Review",
|
||||
"todayLearning": "Learning",
|
||||
"cardUpdated": "Card updated",
|
||||
"error": {
|
||||
"update": "You do not have permission to update this card.",
|
||||
"delete": "You do not have permission to delete this card.",
|
||||
"add": "You do not have permission to add cards to this deck."
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"title": "Learn Languages",
|
||||
"description": "Here is a very useful website to help you learn almost every language in the world, including constructed ones.",
|
||||
@@ -228,9 +157,6 @@
|
||||
"resetPasswordFailed": "Failed to send reset email",
|
||||
"resetPasswordEmailSent": "Reset email sent successfully",
|
||||
"resetPasswordEmailSentHint": "We've sent a password reset link to your email address. Please check your inbox.",
|
||||
"verifyYourEmail": "Verify Your Email",
|
||||
"verificationEmailSent": "Verification email sent",
|
||||
"verificationEmailSentHint": "We've sent a verification email to {email}. Please click the link in the email to verify your account.",
|
||||
"checkYourEmail": "Check Your Email",
|
||||
"backToLogin": "Back to Login",
|
||||
"resetPassword": "Reset Password",
|
||||
@@ -240,72 +166,25 @@
|
||||
"requestNewToken": "Request New Reset Link",
|
||||
"resetPasswordSuccess": "Password reset successfully",
|
||||
"resetPasswordSuccessTitle": "Password Reset Complete",
|
||||
"resetPasswordSuccessHint": "Your password has been reset successfully. You can now log in with your new password.",
|
||||
"emailNotVerified": "Please verify your email address",
|
||||
"emailNotVerifiedHint": "Your email has not been verified. Please check your inbox or request a new verification email.",
|
||||
"resendVerification": "Resend Verification Email",
|
||||
"resendSuccess": "Verification email sent! Please check your inbox.",
|
||||
"resendFailed": "Failed to send verification email"
|
||||
"resetPasswordSuccessHint": "Your password has been reset successfully. You can now log in with your new password."
|
||||
},
|
||||
"memorize": {
|
||||
"deck_selector": {
|
||||
"selectDeck": "Select a deck",
|
||||
"noDecks": "No decks found",
|
||||
"goToDecks": "Go to Decks",
|
||||
"noCards": "No cards",
|
||||
"new": "New",
|
||||
"learning": "Learning",
|
||||
"review": "Review",
|
||||
"due": "Due"
|
||||
"folder_selector": {
|
||||
"selectFolder": "Select a folder",
|
||||
"noFolders": "No folders found",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
},
|
||||
"review": {
|
||||
"loading": "Loading cards...",
|
||||
"backToDecks": "Back to Decks",
|
||||
"allDone": "All Done!",
|
||||
"allDoneDesc": "You've reviewed all due cards.",
|
||||
"reviewedCount": "Reviewed {count} cards",
|
||||
"progress": "{current} / {total}",
|
||||
"nextReview": "Next review",
|
||||
"interval": "Interval",
|
||||
"ease": "Ease",
|
||||
"lapses": "Lapses",
|
||||
"showAnswer": "Show Answer",
|
||||
"nextCard": "Next",
|
||||
"again": "Again",
|
||||
"hard": "Hard",
|
||||
"good": "Good",
|
||||
"easy": "Easy",
|
||||
"now": "now",
|
||||
"lessThanMinute": "<1 min",
|
||||
"inMinutes": "{count} min",
|
||||
"inHours": "{count}h",
|
||||
"inDays": "{count}d",
|
||||
"inMonths": "{count}mo",
|
||||
"minutes": "<1 min",
|
||||
"days": "{count}d",
|
||||
"months": "{count}mo",
|
||||
"minAbbr": "m",
|
||||
"dayAbbr": "d",
|
||||
"cardTypeNew": "New",
|
||||
"cardTypeLearning": "Learning",
|
||||
"cardTypeReview": "Review",
|
||||
"cardTypeRelearning": "Relearning",
|
||||
"memorize": {
|
||||
"answer": "Answer",
|
||||
"next": "Next",
|
||||
"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"
|
||||
"noTextPairs": "No text pairs available",
|
||||
"disorder": "Disorder",
|
||||
"previous": "Previous"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "You are not authorized to access this deck"
|
||||
"unauthorized": "You are not authorized to access this folder"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
@@ -313,56 +192,11 @@
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "Sign In",
|
||||
"profile": "Profile",
|
||||
"folders": "Decks",
|
||||
"folders": "Folders",
|
||||
"explore": "Explore",
|
||||
"favorites": "Favorites",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"ocr": {
|
||||
"title": "OCR Vocabulary Extractor",
|
||||
"description": "Upload vocabulary table screenshots from textbooks to extract word-definition pairs",
|
||||
"uploadSection": "Upload Image",
|
||||
"uploadImage": "Upload Image",
|
||||
"dragDropHint": "Drag and drop an image here, or click to select",
|
||||
"dropOrClick": "Drag and drop an image here, or click to select",
|
||||
"changeImage": "Click to change image",
|
||||
"supportedFormats": "Supports: JPG, PNG, WebP",
|
||||
"invalidFileType": "Invalid file type. Please upload an image file (JPG, PNG, or WebP).",
|
||||
"deckSelection": "Select Deck",
|
||||
"selectDeck": "Select a deck",
|
||||
"chooseDeck": "Choose a deck to save extracted pairs",
|
||||
"noDecks": "No decks available. Please create a deck first.",
|
||||
"languageHints": "Language Hints (Optional)",
|
||||
"sourceLanguageHint": "Source language (e.g., English)",
|
||||
"targetLanguageHint": "Target/Translation language (e.g., Chinese)",
|
||||
"sourceLanguagePlaceholder": "Source language (e.g., English)",
|
||||
"targetLanguagePlaceholder": "Target/Translation language (e.g., Chinese)",
|
||||
"process": "Process Image",
|
||||
"processButton": "Process Image",
|
||||
"processing": "Processing...",
|
||||
"preview": "Preview",
|
||||
"resultsPreview": "Results Preview",
|
||||
"extractedPairs": "Extracted {count} pairs",
|
||||
"word": "Word",
|
||||
"definition": "Definition",
|
||||
"pairsCount": "{count} pairs extracted",
|
||||
"savePairs": "Save to Deck",
|
||||
"saveButton": "Save",
|
||||
"saving": "Saving...",
|
||||
"saved": "Successfully saved {count} pairs to {deck}",
|
||||
"ocrSuccess": "Successfully extracted {count} pairs to {deck}",
|
||||
"ocrFailed": "OCR processing failed. Please try again.",
|
||||
"savedToDeck": "Saved to {deckName}",
|
||||
"saveFailed": "Failed to save pairs",
|
||||
"noImage": "Please upload an image first",
|
||||
"noDeck": "Please select a deck",
|
||||
"noResultsToSave": "No results to save",
|
||||
"processingFailed": "OCR processing failed",
|
||||
"tryAgain": "Please try again with a clearer image",
|
||||
"detectedLanguages": "Detected: {source} → {target}",
|
||||
"detectedSourceLanguage": "Detected source language",
|
||||
"detectedTargetLanguage": "Detected target language"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "My Profile",
|
||||
"email": "Email: {email}",
|
||||
@@ -402,43 +236,12 @@
|
||||
"videoUploadFailed": "Video upload failed",
|
||||
"subtitleUploadFailed": "Subtitle upload failed",
|
||||
"subtitleLoadSuccess": "Subtitle loaded successfully",
|
||||
"subtitleLoadFailed": "Subtitle load failed",
|
||||
"settings": "Settings",
|
||||
"shortcuts": "Shortcuts",
|
||||
"keyboardShortcuts": "Keyboard Shortcuts",
|
||||
"playPause": "Play/Pause",
|
||||
"autoPauseToggle": "Toggle Auto Pause",
|
||||
"subtitleSettings": "Subtitle Settings",
|
||||
"fontSize": "Font Size",
|
||||
"textColor": "Text Color",
|
||||
"backgroundColor": "Background Color",
|
||||
"position": "Position",
|
||||
"opacity": "Opacity",
|
||||
"top": "Top",
|
||||
"center": "Center",
|
||||
"bottom": "Bottom"
|
||||
"subtitleLoadFailed": "Subtitle load failed"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "Generate IPA",
|
||||
"viewSavedItems": "View Saved Items",
|
||||
"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"
|
||||
}
|
||||
"confirmDeleteAll": "Are you sure you want to delete everything? (Y/N)"
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "detect language",
|
||||
@@ -446,7 +249,6 @@
|
||||
"auto": "Auto",
|
||||
"generateIPA": "generate ipa",
|
||||
"translateInto": "translate into",
|
||||
"customLanguage": "or type language...",
|
||||
"chinese": "Chinese",
|
||||
"english": "English",
|
||||
"french": "French",
|
||||
@@ -472,19 +274,7 @@
|
||||
"success": "Text pair added to folder",
|
||||
"error": "Failed to add text pair to folder"
|
||||
},
|
||||
"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"
|
||||
"autoSave": "Auto Save"
|
||||
},
|
||||
"dictionary": {
|
||||
"title": "Dictionary",
|
||||
@@ -519,11 +309,11 @@
|
||||
},
|
||||
"explore": {
|
||||
"title": "Explore",
|
||||
"subtitle": "Discover public decks",
|
||||
"searchPlaceholder": "Search public decks...",
|
||||
"subtitle": "Discover public folders",
|
||||
"searchPlaceholder": "Search public folders...",
|
||||
"loading": "Loading...",
|
||||
"noDecks": "No public decks found",
|
||||
"deckInfo": "{userName} • {cardCount} cards",
|
||||
"noFolders": "No public folders found",
|
||||
"folderInfo": "{userName} • {totalPairs} pairs",
|
||||
"unknownUser": "Unknown User",
|
||||
"favorite": "Favorite",
|
||||
"unfavorite": "Unfavorite",
|
||||
@@ -532,10 +322,10 @@
|
||||
"sortByFavoritesActive": "Undo sort by favorites"
|
||||
},
|
||||
"exploreDetail": {
|
||||
"title": "Deck Details",
|
||||
"title": "Folder Details",
|
||||
"createdBy": "Created by: {name}",
|
||||
"unknownUser": "Unknown User",
|
||||
"totalCards": "Total Cards",
|
||||
"totalPairs": "Total Pairs",
|
||||
"favorites": "Favorites",
|
||||
"createdAt": "Created At",
|
||||
"viewContent": "View Content",
|
||||
@@ -566,58 +356,16 @@
|
||||
"memberSince": "Member Since",
|
||||
"joined": "Joined",
|
||||
"logout": "Logout",
|
||||
"deleteAccount": {
|
||||
"button": "Delete Account",
|
||||
"title": "Delete Account",
|
||||
"warning": "This action is irreversible. All your data will be permanently deleted.",
|
||||
"warningDecks": "All your decks and cards",
|
||||
"warningCards": "All your learning progress",
|
||||
"warningHistory": "All your translation and dictionary history",
|
||||
"warningPermanent": "This action cannot be undone",
|
||||
"confirmLabel": "Type your username to confirm:",
|
||||
"usernameMismatch": "Username does not match",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Delete My Account",
|
||||
"success": "Account deleted successfully",
|
||||
"failed": "Failed to delete account"
|
||||
},
|
||||
"decks": {
|
||||
"title": "Decks",
|
||||
"noDecks": "No decks yet",
|
||||
"deckName": "Deck Name",
|
||||
"totalCards": "Total Cards",
|
||||
"folders": {
|
||||
"title": "Folders",
|
||||
"noFolders": "No folders yet",
|
||||
"folderName": "Folder Name",
|
||||
"totalPairs": "Total Pairs",
|
||||
"createdAt": "Created At",
|
||||
"actions": "Actions",
|
||||
"view": "View"
|
||||
}
|
||||
},
|
||||
"decks": {
|
||||
"title": "Decks",
|
||||
"subtitle": "Manage your flashcard decks",
|
||||
"newDeck": "New Deck",
|
||||
"noDecksYet": "No decks yet",
|
||||
"loading": "Loading...",
|
||||
"deckInfo": "ID: {id} • {totalCards} cards",
|
||||
"enterDeckName": "Enter deck name:",
|
||||
"enterNewName": "Enter new name:",
|
||||
"confirmDelete": "Type \"{name}\" to delete:",
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"setPublic": "Set Public",
|
||||
"setPrivate": "Set Private",
|
||||
"importApkg": "Import APKG",
|
||||
"exportApkg": "Export APKG",
|
||||
"clickToUpload": "Click to upload an APKG file",
|
||||
"apkgFilesOnly": "Only .apkg files are supported",
|
||||
"parsing": "Parsing...",
|
||||
"foundDecks": "Found {count} deck(s)",
|
||||
"deckName": "Deck Name",
|
||||
"back": "Back",
|
||||
"import": "Import",
|
||||
"importing": "Importing...",
|
||||
"exportSuccess": "Deck exported successfully",
|
||||
"goToDecks": "Go to Decks"
|
||||
},
|
||||
"follow": {
|
||||
"follow": "Follow",
|
||||
"following": "Following",
|
||||
|
||||
@@ -46,38 +46,6 @@
|
||||
"unfavorite": "Retirer des favoris",
|
||||
"pleaseLogin": "Veuillez vous connecter d'abord"
|
||||
},
|
||||
"decks": {
|
||||
"title": "Decks",
|
||||
"noDecks": "Pas encore de decks",
|
||||
"deckName": "Nom du deck",
|
||||
"totalCards": "Total des cartes",
|
||||
"createdAt": "Créé le",
|
||||
"actions": "Actions",
|
||||
"view": "Voir",
|
||||
"subtitle": "Gérer vos decks d'apprentissage",
|
||||
"newDeck": "Nouveau deck",
|
||||
"noDecksYet": "Pas encore de decks",
|
||||
"loading": "Chargement...",
|
||||
"deckInfo": "ID: {id} · {totalCards} cartes",
|
||||
"enterDeckName": "Nom du deck:",
|
||||
"enterNewName": "Nouveau nom:",
|
||||
"confirmDelete": "Tapez \"{name}\" pour supprimer:",
|
||||
"public": "Public",
|
||||
"private": "Privé",
|
||||
"setPublic": "Rendre public",
|
||||
"setPrivate": "Rendre privé",
|
||||
"importApkg": "Importer APKG",
|
||||
"exportApkg": "Exporter APKG",
|
||||
"clickToUpload": "Cliquez pour télécharger",
|
||||
"apkgFilesOnly": "Fichiers .apkg uniquement",
|
||||
"parsing": "Analyse...",
|
||||
"foundDecks": "{count} decks trouvés",
|
||||
"back": "Retour",
|
||||
"import": "Importer",
|
||||
"importing": "Import...",
|
||||
"exportSuccess": "Export réussi",
|
||||
"goToDecks": "Aller aux decks"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "Vous n'êtes pas le propriétaire de ce dossier",
|
||||
"back": "Retour",
|
||||
@@ -106,77 +74,6 @@
|
||||
"deleteFolder": "Vous n'avez pas la permission de supprimer ce dossier."
|
||||
}
|
||||
},
|
||||
"deck_id": {
|
||||
"unauthorized": "Vous n'êtes pas le propriétaire de ce deck",
|
||||
"back": "Retour",
|
||||
"cards": "Cartes",
|
||||
"itemsCount": "{count} éléments",
|
||||
"memorize": "Mémoriser",
|
||||
"loadingCards": "Chargement des cartes...",
|
||||
"noCards": "Aucune carte dans ce deck",
|
||||
"card": "Carte",
|
||||
"addNewCard": "Ajouter une nouvelle carte",
|
||||
"add": "Ajouter",
|
||||
"adding": "Ajout en cours...",
|
||||
"updateCard": "Mettre à jour la carte",
|
||||
"update": "Mettre à jour",
|
||||
"updating": "Mise à jour en cours...",
|
||||
"word": "Mot",
|
||||
"definition": "Définition",
|
||||
"ipa": "IPA",
|
||||
"example": "Exemple",
|
||||
"wordAndDefinitionRequired": "Le mot et la définition sont requis",
|
||||
"edit": "Modifier",
|
||||
"delete": "Supprimer",
|
||||
"permissionDenied": "Vous n'avez pas la permission d'effectuer cette action",
|
||||
"resetProgress": "Réinitialiser progression",
|
||||
"resetProgressTitle": "Réinitialiser la progression",
|
||||
"resetProgressConfirm": "Réinitialiser la progression?",
|
||||
"resetSuccess": "Progression réinitialisée",
|
||||
"resetting": "Réinitialisation...",
|
||||
"cancel": "Annuler",
|
||||
"settings": "Paramètres",
|
||||
"settingsTitle": "Paramètres du deck",
|
||||
"newPerDay": "Nouvelles par jour",
|
||||
"newPerDayHint": "Nouvelles cartes par jour",
|
||||
"revPerDay": "Révisions par jour",
|
||||
"revPerDayHint": "Révisions par jour",
|
||||
"save": "Enregistrer",
|
||||
"saving": "Enregistrement...",
|
||||
"settingsSaved": "Paramètres enregistrés",
|
||||
"todayNew": "Nouvelles aujourd'hui",
|
||||
"todayReview": "Révisions aujourd'hui",
|
||||
"todayLearning": "En apprentissage",
|
||||
"error": {
|
||||
"update": "Pas autorisé à modifier",
|
||||
"delete": "Pas autorisé à supprimer",
|
||||
"add": "Pas autorisé à ajouter"
|
||||
},
|
||||
"ipaPlaceholder": "Entrer IPA",
|
||||
"examplePlaceholder": "Entrer exemple",
|
||||
"wordRequired": "Veuillez entrer un mot",
|
||||
"definitionRequired": "Veuillez entrer une définition",
|
||||
"cardAdded": "Carte ajoutée",
|
||||
"cardType": "Type de carte",
|
||||
"wordCard": "Carte mot",
|
||||
"phraseCard": "Carte phrase",
|
||||
"sentenceCard": "Carte phrase",
|
||||
"sentence": "Phrase",
|
||||
"sentencePlaceholder": "Entrer phrase",
|
||||
"wordPlaceholder": "Entrer mot",
|
||||
"queryLang": "Langue de requête",
|
||||
"enterLanguageName": "Veuillez entrer le nom de la langue",
|
||||
"english": "Anglais",
|
||||
"chinese": "Chinois",
|
||||
"japanese": "Japonais",
|
||||
"korean": "Coréen",
|
||||
"meanings": "Significations",
|
||||
"addMeaning": "Ajouter signification",
|
||||
"partOfSpeech": "Partie du discours",
|
||||
"deleteConfirm": "Supprimer cette carte?",
|
||||
"cardDeleted": "Carte supprimée",
|
||||
"cardUpdated": "Carte mise à jour"
|
||||
},
|
||||
"home": {
|
||||
"title": "Apprendre les langues",
|
||||
"description": "Voici un site Web très utile pour vous aider à apprendre presque toutes les langues du monde, y compris les langues construites.",
|
||||
@@ -260,9 +157,6 @@
|
||||
"resetPasswordFailed": "Échec de l'envoi de l'e-mail de réinitialisation",
|
||||
"resetPasswordEmailSent": "E-mail de réinitialisation envoyé avec succès",
|
||||
"resetPasswordEmailSentHint": "Nous avons envoyé un lien de réinitialisation de mot de passe à votre adresse e-mail. Veuillez vérifier votre boîte de réception.",
|
||||
"verifyYourEmail": "Vérifier votre e-mail",
|
||||
"verificationEmailSent": "E-mail de vérification envoyé",
|
||||
"verificationEmailSentHint": "Nous avons envoyé un e-mail de vérification à {email}. Veuillez cliquer sur le lien dans l'e-mail pour vérifier votre compte.",
|
||||
"checkYourEmail": "Vérifiez votre e-mail",
|
||||
"backToLogin": "Retour à la connexion",
|
||||
"resetPassword": "Réinitialiser le mot de passe",
|
||||
@@ -272,47 +166,25 @@
|
||||
"requestNewToken": "Demander un nouveau lien de réinitialisation",
|
||||
"resetPasswordSuccess": "Mot de passe réinitialisé avec succès",
|
||||
"resetPasswordSuccessTitle": "Réinitialisation du mot de passe terminée",
|
||||
"resetPasswordSuccessHint": "Votre mot de passe a été réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.",
|
||||
"emailNotVerified": "Veuillez vérifier votre adresse e-mail",
|
||||
"emailNotVerifiedHint": "Votre adresse e-mail n'a pas été vérifiée. Veuillez vérifier votre boîte de réception ou demander un nouvel e-mail de vérification.",
|
||||
"resendVerification": "Renvoyer l'e-mail de vérification",
|
||||
"resendSuccess": "E-mail de vérification envoyé ! Veuillez vérifier votre boîte de réception.",
|
||||
"resendFailed": "Échec de l'envoi de l'e-mail de vérification"
|
||||
"resetPasswordSuccessHint": "Votre mot de passe a été réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe."
|
||||
},
|
||||
"memorize": {
|
||||
"deck_selector": {
|
||||
"selectDeck": "Choisir deck",
|
||||
"noDecks": "Pas de decks",
|
||||
"goToDecks": "Aller aux decks",
|
||||
"noCards": "Pas de cartes",
|
||||
"new": "Nouveau",
|
||||
"learning": "Apprentissage",
|
||||
"review": "Révision",
|
||||
"due": "À faire"
|
||||
"folder_selector": {
|
||||
"selectFolder": "Sélectionner un dossier",
|
||||
"noFolders": "Aucun dossier trouvé",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
},
|
||||
"review": {
|
||||
"loading": "Chargement...",
|
||||
"backToDecks": "Retour aux decks",
|
||||
"allDone": "Tout terminé!",
|
||||
"allDoneDesc": "Apprentissage terminé pour aujourd'hui!",
|
||||
"reviewedCount": "{count} cartes révisées",
|
||||
"progress": "{current} / {total}",
|
||||
"nextReview": "Prochaine révision",
|
||||
"interval": "Intervalle",
|
||||
"ease": "Facilité",
|
||||
"lapses": "Erreurs",
|
||||
"showAnswer": "Montrer réponse",
|
||||
"nextCard": "Suivant",
|
||||
"again": "Encore",
|
||||
"restart": "Recommencer",
|
||||
"orderLimited": "Ordre limité",
|
||||
"orderInfinite": "Ordre infini",
|
||||
"randomLimited": "Aléatoire limité",
|
||||
"randomInfinite": "Aléatoire infini",
|
||||
"noIpa": "Pas d'IPA disponible"
|
||||
"memorize": {
|
||||
"answer": "Réponse",
|
||||
"next": "Suivant",
|
||||
"reverse": "Inverser",
|
||||
"dictation": "Dictée",
|
||||
"noTextPairs": "Aucune paire de texte disponible",
|
||||
"disorder": "Désordre",
|
||||
"previous": "Précédent"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "Non autorisé"
|
||||
"unauthorized": "Vous n'êtes pas autorisé à accéder à ce dossier"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
@@ -320,56 +192,11 @@
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "Connexion",
|
||||
"profile": "Profil",
|
||||
"folders": "Decks",
|
||||
"folders": "Dossiers",
|
||||
"explore": "Explorer",
|
||||
"favorites": "Favoris",
|
||||
"settings": "Paramètres"
|
||||
},
|
||||
"ocr": {
|
||||
"title": "Reconnaissance OCR",
|
||||
"description": "Extraire le texte des images",
|
||||
"uploadImage": "Télécharger image",
|
||||
"dragDropHint": "Glisser-déposer",
|
||||
"supportedFormats": "Formats: JPG, PNG, WEBP",
|
||||
"selectDeck": "Choisir deck",
|
||||
"chooseDeck": "Choisir un deck",
|
||||
"noDecks": "Pas de decks disponibles",
|
||||
"languageHints": "Indications de langue",
|
||||
"sourceLanguageHint": "Langue source",
|
||||
"targetLanguageHint": "Langue cible",
|
||||
"process": "Traiter",
|
||||
"processing": "Traitement...",
|
||||
"preview": "Aperçu",
|
||||
"extractedPairs": "Paires extraites",
|
||||
"word": "Mot",
|
||||
"definition": "Définition",
|
||||
"pairsCount": "{count} paires",
|
||||
"savePairs": "Enregistrer",
|
||||
"saving": "Enregistrement...",
|
||||
"saved": "Enregistré",
|
||||
"saveFailed": "Échec de l'enregistrement",
|
||||
"noImage": "Veuillez télécharger une image",
|
||||
"noDeck": "Veuillez choisir un deck",
|
||||
"processingFailed": "Traitement échoué",
|
||||
"tryAgain": "Réessayer",
|
||||
"detectedLanguages": "Langues détectées",
|
||||
"uploadSection": "Télécharger image",
|
||||
"dropOrClick": "Déposer ou cliquer",
|
||||
"changeImage": "Changer image",
|
||||
"invalidFileType": "Type de fichier invalide",
|
||||
"deckSelection": "Choisir deck",
|
||||
"sourceLanguagePlaceholder": "ex: Anglais",
|
||||
"targetLanguagePlaceholder": "ex: Français",
|
||||
"processButton": "Démarrer reconnaissance",
|
||||
"resultsPreview": "Aperçu des résultats",
|
||||
"saveButton": "Enregistrer dans le deck",
|
||||
"ocrSuccess": "OCR réussi",
|
||||
"ocrFailed": "OCR échoué",
|
||||
"savedToDeck": "Enregistré dans le deck",
|
||||
"noResultsToSave": "Pas de résultats",
|
||||
"detectedSourceLanguage": "Langue source détectée",
|
||||
"detectedTargetLanguage": "Langue cible détectée"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "Mon profil",
|
||||
"email": "E-mail : {email}",
|
||||
@@ -409,43 +236,12 @@
|
||||
"videoUploadFailed": "Échec du téléchargement de la vidéo",
|
||||
"subtitleUploadFailed": "Échec du téléchargement des sous-titres",
|
||||
"subtitleLoadSuccess": "Sous-titres chargés avec succès",
|
||||
"subtitleLoadFailed": "Échec du chargement des sous-titres",
|
||||
"settings": "Paramètres",
|
||||
"shortcuts": "Raccourcis",
|
||||
"keyboardShortcuts": "Raccourcis clavier",
|
||||
"playPause": "Lecture/Pause",
|
||||
"autoPauseToggle": "Pause auto",
|
||||
"subtitleSettings": "Paramètres sous-titres",
|
||||
"fontSize": "Taille police",
|
||||
"textColor": "Couleur texte",
|
||||
"backgroundColor": "Couleur fond",
|
||||
"position": "Position",
|
||||
"opacity": "Opacité",
|
||||
"top": "Haut",
|
||||
"center": "Centre",
|
||||
"bottom": "Bas"
|
||||
"subtitleLoadFailed": "Échec du chargement des sous-titres"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "Générer l'API",
|
||||
"viewSavedItems": "Voir les éléments enregistrés",
|
||||
"confirmDeleteAll": "Êtes-vous sûr de vouloir tout supprimer ? (O/N)",
|
||||
"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"
|
||||
}
|
||||
"confirmDeleteAll": "Êtes-vous sûr de vouloir tout supprimer ? (O/N)"
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "détecter la langue",
|
||||
@@ -478,20 +274,7 @@
|
||||
"success": "Paire de texte ajoutée au dossier",
|
||||
"error": "Échec de l'ajout de la paire de texte au dossier"
|
||||
},
|
||||
"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"
|
||||
"autoSave": "Sauvegarde automatique"
|
||||
},
|
||||
"dictionary": {
|
||||
"title": "Dictionnaire",
|
||||
@@ -536,9 +319,7 @@
|
||||
"unfavorite": "Retirer des favoris",
|
||||
"pleaseLogin": "Veuillez vous connecter d'abord",
|
||||
"sortByFavorites": "Trier par favoris",
|
||||
"sortByFavoritesActive": "Annuler le tri par favoris",
|
||||
"noDecks": "Pas de decks publics",
|
||||
"deckInfo": "{userName} · {totalCards} cartes"
|
||||
"sortByFavoritesActive": "Annuler le tri par favoris"
|
||||
},
|
||||
"exploreDetail": {
|
||||
"title": "Détails du dossier",
|
||||
@@ -552,8 +333,7 @@
|
||||
"unfavorite": "Retirer des favoris",
|
||||
"favorited": "Ajouté aux favoris",
|
||||
"unfavorited": "Retiré des favoris",
|
||||
"pleaseLogin": "Veuillez vous connecter d'abord",
|
||||
"totalCards": "{count} cartes"
|
||||
"pleaseLogin": "Veuillez vous connecter d'abord"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "Mes favoris",
|
||||
@@ -575,39 +355,14 @@
|
||||
"notSet": "Non défini",
|
||||
"memberSince": "Membre depuis",
|
||||
"logout": "Déconnexion",
|
||||
"deleteAccount": {
|
||||
"button": "Supprimer le compte",
|
||||
"title": "Supprimer le compte",
|
||||
"warning": "Cette action est irréversible. Toutes vos données seront définitivement supprimées.",
|
||||
"warningDecks": "Tous vos decks et cartes",
|
||||
"warningCards": "Tout votre progression d'apprentissage",
|
||||
"warningHistory": "Tout votre historique de traduction et de dictionnaire",
|
||||
"warningPermanent": "Cette action ne peut pas être annulée",
|
||||
"confirmLabel": "Tapez votre nom d'utilisateur pour confirmer :",
|
||||
"usernameMismatch": "Le nom d'utilisateur ne correspond pas",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Supprimer mon compte",
|
||||
"success": "Compte supprimé avec succès",
|
||||
"failed": "Échec de la suppression du compte"
|
||||
},
|
||||
"decks": {
|
||||
"title": "Decks",
|
||||
"noDecks": "Pas encore de decks",
|
||||
"deckName": "Nom du deck",
|
||||
"totalCards": "Total des cartes",
|
||||
"folders": {
|
||||
"title": "Dossiers",
|
||||
"noFolders": "Pas encore de dossiers",
|
||||
"folderName": "Nom du dossier",
|
||||
"totalPairs": "Total des paires",
|
||||
"createdAt": "Créé le",
|
||||
"actions": "Actions",
|
||||
"view": "Voir"
|
||||
},
|
||||
"joined": "Inscrit le"
|
||||
},
|
||||
"follow": {
|
||||
"follow": "Suivre",
|
||||
"following": "Abonné",
|
||||
"followers": "Abonnés",
|
||||
"followersOf": "Abonnés de {username}",
|
||||
"followingOf": "Abonnements de {username}",
|
||||
"noFollowers": "Pas encore d'abonnés",
|
||||
"noFollowing": "Ne suit personne"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,38 +46,6 @@
|
||||
"unfavorite": "Rimuovi dai preferiti",
|
||||
"pleaseLogin": "Per favore accedi prima"
|
||||
},
|
||||
"decks": {
|
||||
"title": "Mazzi",
|
||||
"noDecks": "Nessun mazzo ancora",
|
||||
"deckName": "Nome del mazzo",
|
||||
"totalCards": "Totale carte",
|
||||
"createdAt": "Creato il",
|
||||
"actions": "Azioni",
|
||||
"view": "Visualizza",
|
||||
"subtitle": "Gestisci i tuoi deck",
|
||||
"newDeck": "Nuovo deck",
|
||||
"noDecksYet": "Nessun deck ancora",
|
||||
"loading": "Caricamento...",
|
||||
"deckInfo": "ID: {id} · {totalCards} carte",
|
||||
"enterDeckName": "Nome deck:",
|
||||
"enterNewName": "Nuovo nome:",
|
||||
"confirmDelete": "Digita \"{name}\" per eliminare:",
|
||||
"public": "Pubblico",
|
||||
"private": "Privato",
|
||||
"setPublic": "Rendi pubblico",
|
||||
"setPrivate": "Rendi privato",
|
||||
"importApkg": "Importa APKG",
|
||||
"exportApkg": "Esporta APKG",
|
||||
"clickToUpload": "Clicca per caricare",
|
||||
"apkgFilesOnly": "Solo file .apkg",
|
||||
"parsing": "Analisi...",
|
||||
"foundDecks": "{count} deck trovati",
|
||||
"back": "Indietro",
|
||||
"import": "Importa",
|
||||
"importing": "Importazione...",
|
||||
"exportSuccess": "Esportazione riuscita",
|
||||
"goToDecks": "Vai ai deck"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "Non sei il proprietario di questa cartella",
|
||||
"back": "Indietro",
|
||||
@@ -106,77 +74,6 @@
|
||||
"deleteFolder": "Non hai il permesso di eliminare questa cartella."
|
||||
}
|
||||
},
|
||||
"deck_id": {
|
||||
"unauthorized": "Non sei il proprietario di questo deck",
|
||||
"back": "Indietro",
|
||||
"cards": "Schede",
|
||||
"itemsCount": "{count} elementi",
|
||||
"memorize": "Memorizza",
|
||||
"loadingCards": "Caricamento schede...",
|
||||
"noCards": "Nessuna scheda in questo deck",
|
||||
"card": "Scheda",
|
||||
"addNewCard": "Aggiungi nuova scheda",
|
||||
"add": "Aggiungi",
|
||||
"adding": "Aggiunta in corso...",
|
||||
"updateCard": "Aggiorna scheda",
|
||||
"update": "Aggiorna",
|
||||
"updating": "Aggiornamento in corso...",
|
||||
"word": "Parola",
|
||||
"definition": "Definizione",
|
||||
"ipa": "IPA",
|
||||
"example": "Esempio",
|
||||
"wordAndDefinitionRequired": "Parola e definizione sono obbligatori",
|
||||
"edit": "Modifica",
|
||||
"delete": "Elimina",
|
||||
"permissionDenied": "Non hai il permesso per questa accion",
|
||||
"resetProgress": "Reimposta progresso",
|
||||
"resetProgressTitle": "Reimposta progresso di apprendimento",
|
||||
"resetProgressConfirm": "Reimpostare il progresso?",
|
||||
"resetSuccess": "Progresso reimpostato",
|
||||
"resetting": "Reimpostazione...",
|
||||
"cancel": "Annulla",
|
||||
"settings": "Impostazioni",
|
||||
"settingsTitle": "Impostazioni deck",
|
||||
"newPerDay": "Nuove al giorno",
|
||||
"newPerDayHint": "Nuove carte al giorno",
|
||||
"revPerDay": "Ripassate al giorno",
|
||||
"revPerDayHint": "Ripassi al giorno",
|
||||
"save": "Salva",
|
||||
"saving": "Salvataggio...",
|
||||
"settingsSaved": "Impostazioni salvate",
|
||||
"todayNew": "Oggi nuove",
|
||||
"todayReview": "Oggi ripasso",
|
||||
"todayLearning": "In apprendimento",
|
||||
"error": {
|
||||
"update": "Nessun permesso di aggiornare",
|
||||
"delete": "Nessun permesso di eliminare",
|
||||
"add": "Nessun permesso di aggiungere"
|
||||
},
|
||||
"ipaPlaceholder": "Inserisci IPA",
|
||||
"examplePlaceholder": "Inserisci esempio",
|
||||
"wordRequired": "Inserisci una parola",
|
||||
"definitionRequired": "Inserisci una definizione",
|
||||
"cardAdded": "Carta aggiunta",
|
||||
"cardType": "Tipo di carta",
|
||||
"wordCard": "Carta parola",
|
||||
"phraseCard": "Carta frase",
|
||||
"sentenceCard": "Carta frase",
|
||||
"sentence": "Frase",
|
||||
"sentencePlaceholder": "Inserisci frase",
|
||||
"wordPlaceholder": "Inserisci parola",
|
||||
"queryLang": "Lingua di query",
|
||||
"enterLanguageName": "Inserisci il nome della lingua",
|
||||
"english": "Inglese",
|
||||
"chinese": "Cinese",
|
||||
"japanese": "Giapponese",
|
||||
"korean": "Coreano",
|
||||
"meanings": "Significati",
|
||||
"addMeaning": "Aggiungi significato",
|
||||
"partOfSpeech": "Parte del discorso",
|
||||
"deleteConfirm": "Eliminare questa carta?",
|
||||
"cardDeleted": "Carta eliminata",
|
||||
"cardUpdated": "Carta aggiornata"
|
||||
},
|
||||
"home": {
|
||||
"title": "Impara le Lingue",
|
||||
"description": "Ecco un sito molto utile per aiutarti a imparare quasi tutte le lingue del mondo, incluse quelle costruite.",
|
||||
@@ -260,9 +157,6 @@
|
||||
"resetPasswordFailed": "Impossibile inviare email di reset",
|
||||
"resetPasswordEmailSent": "Email di reset inviata con successo",
|
||||
"resetPasswordEmailSentHint": "Abbiamo inviato un link per reimpostare la password al tuo indirizzo email. Controlla la tua casella di posta.",
|
||||
"verifyYourEmail": "Verifica la tua Email",
|
||||
"verificationEmailSent": "Email di verifica inviata",
|
||||
"verificationEmailSentHint": "Abbiamo inviato un'email di verifica a {email}. Clicca sul link nell'email per verificare il tuo account.",
|
||||
"checkYourEmail": "Controlla la tua Email",
|
||||
"backToLogin": "Torna al Login",
|
||||
"resetPassword": "Reimposta Password",
|
||||
@@ -272,72 +166,25 @@
|
||||
"requestNewToken": "Richiedi Nuovo Link di Reset",
|
||||
"resetPasswordSuccess": "Password reimpostata con successo",
|
||||
"resetPasswordSuccessTitle": "Reimpostazione Password Completata",
|
||||
"resetPasswordSuccessHint": "La tua password è stata reimpostata con successo. Ora puoi accedere con la tua nuova password.",
|
||||
"emailNotVerified": "Verifica il tuo indirizzo email",
|
||||
"emailNotVerifiedHint": "Il tuo indirizzo email non è stato verificato. Controlla la tua casella di posta o richiedi una nuova email di verifica.",
|
||||
"resendVerification": "Invia di nuovo email di verifica",
|
||||
"resendSuccess": "Email di verifica inviata! Controlla la tua casella di posta.",
|
||||
"resendFailed": "Impossibile inviare l'email di verifica"
|
||||
"resetPasswordSuccessHint": "La tua password è stata reimpostata con successo. Ora puoi accedere con la tua nuova password."
|
||||
},
|
||||
"memorize": {
|
||||
"deck_selector": {
|
||||
"selectDeck": "Seleziona deck",
|
||||
"noDecks": "Nessun deck",
|
||||
"goToDecks": "Vai ai deck",
|
||||
"noCards": "Nessuna carta",
|
||||
"new": "Nuovo",
|
||||
"learning": "Apprendimento",
|
||||
"review": "Ripasso",
|
||||
"due": "In scadenza"
|
||||
"folder_selector": {
|
||||
"selectFolder": "Seleziona una cartella",
|
||||
"noFolders": "Nessuna cartella trovata",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
},
|
||||
"review": {
|
||||
"loading": "Caricamento...",
|
||||
"backToDecks": "Torna ai deck",
|
||||
"allDone": "Tutto fatto!",
|
||||
"allDoneDesc": "Apprendimento di oggi completato!",
|
||||
"reviewedCount": "{count} carte ripassate",
|
||||
"progress": "{current} / {total}",
|
||||
"nextReview": "Prossimo ripasso",
|
||||
"interval": "Intervallo",
|
||||
"ease": "Difficoltà",
|
||||
"lapses": "Errori",
|
||||
"showAnswer": "Mostra risposta",
|
||||
"nextCard": "Prossima",
|
||||
"again": "Ancora",
|
||||
"restart": "Ricomincia",
|
||||
"hard": "Difficile",
|
||||
"good": "Buono",
|
||||
"easy": "Facile",
|
||||
"now": "Ora",
|
||||
"lessThanMinute": "meno di 1 minuto",
|
||||
"inMinutes": "tra {n} minuti",
|
||||
"inHours": "tra {n} ore",
|
||||
"inDays": "tra {n} giorni",
|
||||
"inMonths": "tra {n} mesi",
|
||||
"minutes": "minuti",
|
||||
"days": "giorni",
|
||||
"months": "mesi",
|
||||
"minAbbr": "min",
|
||||
"dayAbbr": "g",
|
||||
"cardTypeNew": "Nuovo",
|
||||
"cardTypeLearning": "Apprendimento",
|
||||
"cardTypeReview": "Ripasso",
|
||||
"cardTypeRelearning": "Riapprendimento",
|
||||
"memorize": {
|
||||
"answer": "Risposta",
|
||||
"next": "Successivo",
|
||||
"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"
|
||||
"dictation": "Dettatura",
|
||||
"noTextPairs": "Nessuna coppia di testo disponibile",
|
||||
"disorder": "Disordina",
|
||||
"previous": "Precedente"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "Non autorizzato"
|
||||
"unauthorized": "Non sei autorizzato ad accedere a questa cartella"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
@@ -345,56 +192,11 @@
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "Accedi",
|
||||
"profile": "Profilo",
|
||||
"folders": "Mazzi",
|
||||
"folders": "Cartelle",
|
||||
"explore": "Esplora",
|
||||
"favorites": "Preferiti",
|
||||
"settings": "Impostazioni"
|
||||
},
|
||||
"ocr": {
|
||||
"title": "Riconoscimento OCR",
|
||||
"description": "Estrai testo dalle immagini",
|
||||
"uploadImage": "Carica immagine",
|
||||
"dragDropHint": "Trascina e rilascia",
|
||||
"supportedFormats": "Supportati: JPG, PNG, WEBP",
|
||||
"selectDeck": "Seleziona deck",
|
||||
"chooseDeck": "Scegli un deck",
|
||||
"noDecks": "Nessun deck disponibile",
|
||||
"languageHints": "Suggerimenti lingua",
|
||||
"sourceLanguageHint": "Lingua sorgente",
|
||||
"targetLanguageHint": "Lingua target",
|
||||
"process": "Elabora",
|
||||
"processing": "Elaborazione...",
|
||||
"preview": "Anteprima",
|
||||
"extractedPairs": "Coppie estratte",
|
||||
"word": "Parola",
|
||||
"definition": "Definizione",
|
||||
"pairsCount": "{count} coppie",
|
||||
"savePairs": "Salva",
|
||||
"saving": "Salvataggio...",
|
||||
"saved": "Salvato",
|
||||
"saveFailed": "Salvataggio fallito",
|
||||
"noImage": "Carica un'immagine",
|
||||
"noDeck": "Seleziona un deck",
|
||||
"processingFailed": "Elaborazione fallita",
|
||||
"tryAgain": "Riprova",
|
||||
"detectedLanguages": "Lingue rilevate",
|
||||
"uploadSection": "Carica immagine",
|
||||
"dropOrClick": "Rilascia o clicca",
|
||||
"changeImage": "Cambia immagine",
|
||||
"invalidFileType": "Tipo di file non valido",
|
||||
"deckSelection": "Seleziona deck",
|
||||
"sourceLanguagePlaceholder": "es: Inglese",
|
||||
"targetLanguagePlaceholder": "es: Italiano",
|
||||
"processButton": "Avvia riconoscimento",
|
||||
"resultsPreview": "Anteprima risultati",
|
||||
"saveButton": "Salva nel deck",
|
||||
"ocrSuccess": "OCR riuscito",
|
||||
"ocrFailed": "OCR fallito",
|
||||
"savedToDeck": "Salvato nel deck",
|
||||
"noResultsToSave": "Nessun risultato",
|
||||
"detectedSourceLanguage": "Lingua sorgente rilevata",
|
||||
"detectedTargetLanguage": "Lingua target rilevata"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "Il Mio Profilo",
|
||||
"email": "Email: {email}",
|
||||
@@ -434,43 +236,12 @@
|
||||
"videoUploadFailed": "Caricamento video fallito",
|
||||
"subtitleUploadFailed": "Caricamento sottotitoli fallito",
|
||||
"subtitleLoadSuccess": "Sottotitoli caricati con successo",
|
||||
"subtitleLoadFailed": "Caricamento sottotitoli fallito",
|
||||
"settings": "Impostazioni",
|
||||
"shortcuts": "Scorciatoie",
|
||||
"keyboardShortcuts": "Scorciatoie tastiera",
|
||||
"playPause": "Riproduci/Pausa",
|
||||
"autoPauseToggle": "Auto-pausa",
|
||||
"subtitleSettings": "Impostazioni sottotitoli",
|
||||
"fontSize": "Dimensione carattere",
|
||||
"textColor": "Colore testo",
|
||||
"backgroundColor": "Colore sfondo",
|
||||
"position": "Posizione",
|
||||
"opacity": "Opacità",
|
||||
"top": "Alto",
|
||||
"center": "Centro",
|
||||
"bottom": "Basso"
|
||||
"subtitleLoadFailed": "Caricamento sottotitoli fallito"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "Genera IPA",
|
||||
"viewSavedItems": "Visualizza Elementi Salvati",
|
||||
"confirmDeleteAll": "Sei sicuro di voler eliminare tutto? (S/N)",
|
||||
"saved": "Salvato",
|
||||
"clearAll": "Cancella tutto",
|
||||
"language": "Lingua",
|
||||
"customLanguage": "o inserisci lingua...",
|
||||
"languages": {
|
||||
"auto": "Auto",
|
||||
"chinese": "Cinese",
|
||||
"english": "Inglese",
|
||||
"japanese": "Giapponese",
|
||||
"korean": "Coreano",
|
||||
"french": "Francese",
|
||||
"german": "Tedesco",
|
||||
"italian": "Italiano",
|
||||
"spanish": "Spagnolo",
|
||||
"portuguese": "Portoghese",
|
||||
"russian": "Russo"
|
||||
}
|
||||
"confirmDeleteAll": "Sei sicuro di voler eliminare tutto? (S/N)"
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "rileva lingua",
|
||||
@@ -503,20 +274,7 @@
|
||||
"success": "Coppia di testo aggiunta alla cartella",
|
||||
"error": "Impossibile aggiungere coppia di testo alla cartella"
|
||||
},
|
||||
"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"
|
||||
"autoSave": "Salvataggio Automatico"
|
||||
},
|
||||
"dictionary": {
|
||||
"title": "Dizionario",
|
||||
@@ -561,9 +319,7 @@
|
||||
"unfavorite": "Rimuovi dai preferiti",
|
||||
"pleaseLogin": "Per favore accedi prima",
|
||||
"sortByFavorites": "Ordina per preferiti",
|
||||
"sortByFavoritesActive": "Annulla ordinamento per preferiti",
|
||||
"noDecks": "Nessun deck pubblico",
|
||||
"deckInfo": "{userName} · {totalCards} carte"
|
||||
"sortByFavoritesActive": "Annulla ordinamento per preferiti"
|
||||
},
|
||||
"exploreDetail": {
|
||||
"title": "Dettagli Cartella",
|
||||
@@ -577,8 +333,7 @@
|
||||
"unfavorite": "Rimuovi dai preferiti",
|
||||
"favorited": "Aggiunto ai preferiti",
|
||||
"unfavorited": "Rimosso dai preferiti",
|
||||
"pleaseLogin": "Per favore accedi prima",
|
||||
"totalCards": "{count} carte"
|
||||
"pleaseLogin": "Per favore accedi prima"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "I Miei Preferiti",
|
||||
@@ -600,39 +355,14 @@
|
||||
"notSet": "Non Impostato",
|
||||
"memberSince": "Membro Dal",
|
||||
"logout": "Esci",
|
||||
"deleteAccount": {
|
||||
"button": "Elimina Account",
|
||||
"title": "Elimina Account",
|
||||
"warning": "Questa azione è irreversibile. Tutti i tuoi dati saranno eliminati definitivamente.",
|
||||
"warningDecks": "Tutti i tuoi mazzi e le tue carte",
|
||||
"warningCards": "Tutto il tuo progresso di apprendimento",
|
||||
"warningHistory": "Tutto il tuo cronologia di traduzione e dizionario",
|
||||
"warningPermanent": "Questa azione non può essere annullata",
|
||||
"confirmLabel": "Digita il tuo nome utente per confermare:",
|
||||
"usernameMismatch": "Il nome utente non corrisponde",
|
||||
"cancel": "Annulla",
|
||||
"confirm": "Elimina il mio account",
|
||||
"success": "Account eliminato con successo",
|
||||
"failed": "Impossibile eliminare l'account"
|
||||
},
|
||||
"decks": {
|
||||
"title": "Mazzi",
|
||||
"noDecks": "Nessun mazzo ancora",
|
||||
"deckName": "Nome del mazzo",
|
||||
"totalCards": "Totale carte",
|
||||
"folders": {
|
||||
"title": "Cartelle",
|
||||
"noFolders": "Nessuna cartella ancora",
|
||||
"folderName": "Nome Cartella",
|
||||
"totalPairs": "Coppie Totali",
|
||||
"createdAt": "Creata Il",
|
||||
"actions": "Azioni",
|
||||
"view": "Visualizza"
|
||||
},
|
||||
"joined": "Iscritto il"
|
||||
},
|
||||
"follow": {
|
||||
"follow": "Segui",
|
||||
"following": "Stai seguendo",
|
||||
"followers": "Seguaci",
|
||||
"followersOf": "Seguaci di {username}",
|
||||
"followingOf": "Seguiti da {username}",
|
||||
"noFollowers": "Nessun seguace ancora",
|
||||
"noFollowing": "Non segui ancora nessuno"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,77 +74,6 @@
|
||||
"deleteFolder": "このフォルダーを削除する権限がありません。"
|
||||
}
|
||||
},
|
||||
"deck_id": {
|
||||
"unauthorized": "このデッキの所有者ではありません",
|
||||
"back": "戻る",
|
||||
"cards": "カード",
|
||||
"itemsCount": "{count}件",
|
||||
"memorize": "暗記",
|
||||
"loadingCards": "カードを読み込み中...",
|
||||
"noCards": "このデッキにはカードがありません",
|
||||
"card": "カード",
|
||||
"addNewCard": "新しいカードを追加",
|
||||
"add": "追加",
|
||||
"adding": "追加中...",
|
||||
"updateCard": "カードを更新",
|
||||
"update": "更新",
|
||||
"updating": "更新中...",
|
||||
"word": "単語",
|
||||
"definition": "定義",
|
||||
"ipa": "発音記号",
|
||||
"example": "例文",
|
||||
"wordAndDefinitionRequired": "単語と定義は必須です",
|
||||
"edit": "編集",
|
||||
"delete": "削除",
|
||||
"permissionDenied": "この操作を実行する権限がありません",
|
||||
"resetProgress": "進捗をリセット",
|
||||
"resetProgressTitle": "学習進捗をリセット",
|
||||
"resetProgressConfirm": "このデッキの学習進捗をリセットしますか?",
|
||||
"resetSuccess": "リセットしました",
|
||||
"resetting": "リセット中...",
|
||||
"cancel": "キャンセル",
|
||||
"settings": "設定",
|
||||
"settingsTitle": "デッキ設定",
|
||||
"newPerDay": "1日の新規カード",
|
||||
"newPerDayHint": "毎日の新規カード数",
|
||||
"revPerDay": "1日の復習",
|
||||
"revPerDayHint": "毎日の復習数",
|
||||
"save": "保存",
|
||||
"saving": "保存中...",
|
||||
"settingsSaved": "設定を保存しました",
|
||||
"todayNew": "今日の新規",
|
||||
"todayReview": "今日の復習",
|
||||
"todayLearning": "学習中",
|
||||
"error": {
|
||||
"update": "更新する権限がありません",
|
||||
"delete": "削除する権限がありません",
|
||||
"add": "追加する権限がありません"
|
||||
},
|
||||
"ipaPlaceholder": "IPAを入力",
|
||||
"examplePlaceholder": "例文を入力",
|
||||
"wordRequired": "単語を入力してください",
|
||||
"definitionRequired": "定義を入力してください",
|
||||
"cardAdded": "カードを追加しました",
|
||||
"cardType": "カードタイプ",
|
||||
"wordCard": "単語カード",
|
||||
"phraseCard": "フレーズカード",
|
||||
"sentenceCard": "文章カード",
|
||||
"sentence": "文章",
|
||||
"sentencePlaceholder": "文章を入力",
|
||||
"wordPlaceholder": "単語を入力",
|
||||
"queryLang": "検索言語",
|
||||
"enterLanguageName": "言語名を入力してください",
|
||||
"english": "英語",
|
||||
"chinese": "中国語",
|
||||
"japanese": "日本語",
|
||||
"korean": "韓国語",
|
||||
"meanings": "意味",
|
||||
"addMeaning": "意味を追加",
|
||||
"partOfSpeech": "品詞",
|
||||
"deleteConfirm": "このカードを削除しますか?",
|
||||
"cardDeleted": "カードを削除しました",
|
||||
"cardUpdated": "カードを更新しました"
|
||||
},
|
||||
"home": {
|
||||
"title": "言語を学ぶ",
|
||||
"description": "ここは世界のほぼすべての言語(人工言語を含む)を学ぶのに役立つ非常に便利なウェブサイトです。",
|
||||
@@ -228,9 +157,6 @@
|
||||
"resetPasswordFailed": "リセットメールの送信に失敗しました",
|
||||
"resetPasswordEmailSent": "リセットメールを送信しました",
|
||||
"resetPasswordEmailSentHint": "パスワードリセット用のリンクをメールでお送りしました。受信トレイをご確認ください。",
|
||||
"verifyYourEmail": "メールアドレスを確認",
|
||||
"verificationEmailSent": "確認メールを送信しました",
|
||||
"verificationEmailSentHint": "{email} に確認メールを送信しました。メール内のリンクをクリックしてアカウントを確認してください。",
|
||||
"checkYourEmail": "メールをご確認ください",
|
||||
"backToLogin": "ログインに戻る",
|
||||
"resetPassword": "パスワードをリセット",
|
||||
@@ -240,72 +166,25 @@
|
||||
"requestNewToken": "新しいリセットリンクをリクエスト",
|
||||
"resetPasswordSuccess": "パスワードのリセットに成功しました",
|
||||
"resetPasswordSuccessTitle": "パスワードリセット完了",
|
||||
"resetPasswordSuccessHint": "パスワードが正常にリセットされました。新しいパスワードでログインできます。",
|
||||
"emailNotVerified": "メールアドレスを確認してください",
|
||||
"emailNotVerifiedHint": "メールアドレスが確認されていません。受信トレイをご確認いただくか、新しい確認メールをリクエストしてください。",
|
||||
"resendVerification": "確認メールを再送信",
|
||||
"resendSuccess": "確認メールを送信しました!受信トレイをご確認ください。",
|
||||
"resendFailed": "確認メールの送信に失敗しました"
|
||||
"resetPasswordSuccessHint": "パスワードが正常にリセットされました。新しいパスワードでログインできます。"
|
||||
},
|
||||
"memorize": {
|
||||
"deck_selector": {
|
||||
"selectDeck": "デッキを選択",
|
||||
"noDecks": "デッキが見つかりません",
|
||||
"goToDecks": "デッキへ移動",
|
||||
"noCards": "カードなし",
|
||||
"new": "新規",
|
||||
"learning": "学習中",
|
||||
"review": "復習",
|
||||
"due": "予定"
|
||||
"folder_selector": {
|
||||
"selectFolder": "フォルダーを選択",
|
||||
"noFolders": "フォルダーが見つかりません",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
},
|
||||
"review": {
|
||||
"loading": "読み込み中...",
|
||||
"backToDecks": "デッキに戻る",
|
||||
"allDone": "完了!",
|
||||
"allDoneDesc": "すべての復習カードが完了しました。",
|
||||
"reviewedCount": "{count} 枚のカードを復習",
|
||||
"progress": "{current} / {total}",
|
||||
"nextReview": "次の復習",
|
||||
"interval": "間隔",
|
||||
"ease": "易しさ",
|
||||
"lapses": "忘回数",
|
||||
"showAnswer": "答えを表示",
|
||||
"nextCard": "次へ",
|
||||
"again": "もう一度",
|
||||
"hard": "難しい",
|
||||
"good": "普通",
|
||||
"easy": "簡単",
|
||||
"now": "今",
|
||||
"lessThanMinute": "<1分",
|
||||
"inMinutes": "{count}分",
|
||||
"inHours": "{count}時間",
|
||||
"inDays": "{count}日",
|
||||
"inMonths": "{count}ヶ月",
|
||||
"minutes": "<1分",
|
||||
"days": "{count}日",
|
||||
"months": "{count}ヶ月",
|
||||
"minAbbr": "分",
|
||||
"dayAbbr": "日",
|
||||
"cardTypeNew": "新規",
|
||||
"cardTypeLearning": "学習中",
|
||||
"cardTypeReview": "復習",
|
||||
"cardTypeRelearning": "再学習",
|
||||
"reverse": "反転",
|
||||
"dictation": "聴き取り",
|
||||
"clickToPlay": "クリックして再生",
|
||||
"yourAnswer": "あなたの答え",
|
||||
"typeWhatYouHear": "聞こえた内容を入力",
|
||||
"correct": "正解",
|
||||
"incorrect": "不正解",
|
||||
"restart": "最初から",
|
||||
"orderLimited": "順序制限",
|
||||
"orderInfinite": "順序無限",
|
||||
"randomLimited": "ランダム制限",
|
||||
"randomInfinite": "ランダム無限",
|
||||
"noIpa": "IPAなし"
|
||||
"memorize": {
|
||||
"answer": "答え",
|
||||
"next": "次へ",
|
||||
"reverse": "逆順",
|
||||
"dictation": "書き取り",
|
||||
"noTextPairs": "利用可能なテキストペアがありません",
|
||||
"disorder": "シャッフル",
|
||||
"previous": "前へ"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "このデッキにアクセスする権限がありません"
|
||||
"unauthorized": "このフォルダーにアクセスする権限がありません"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
@@ -313,56 +192,11 @@
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "サインイン",
|
||||
"profile": "プロフィール",
|
||||
"folders": "デッキ",
|
||||
"folders": "フォルダー",
|
||||
"explore": "探索",
|
||||
"favorites": "お気に入り",
|
||||
"settings": "設定"
|
||||
},
|
||||
"ocr": {
|
||||
"title": "OCR認識",
|
||||
"description": "画像からテキストを抽出",
|
||||
"uploadImage": "画像をアップロード",
|
||||
"dragDropHint": "ドラッグ&ドロップ",
|
||||
"supportedFormats": "対応形式:JPG, PNG, WEBP",
|
||||
"selectDeck": "デッキを選択",
|
||||
"chooseDeck": "デッキを選択",
|
||||
"noDecks": "デッキがありません",
|
||||
"languageHints": "言語ヒント",
|
||||
"sourceLanguageHint": "ソース言語ヒント",
|
||||
"targetLanguageHint": "ターゲット言語ヒント",
|
||||
"process": "処理",
|
||||
"processing": "処理中...",
|
||||
"preview": "プレビュー",
|
||||
"extractedPairs": "抽出ペア",
|
||||
"word": "単語",
|
||||
"definition": "定義",
|
||||
"pairsCount": "{count}ペア",
|
||||
"savePairs": "保存",
|
||||
"saving": "保存中...",
|
||||
"saved": "保存済み",
|
||||
"saveFailed": "保存失敗",
|
||||
"noImage": "画像をアップロードしてください",
|
||||
"noDeck": "デッキを選択してください",
|
||||
"processingFailed": "処理失敗",
|
||||
"tryAgain": "再試行",
|
||||
"detectedLanguages": "検出言語",
|
||||
"invalidFileType": "無効なファイル形式",
|
||||
"ocrFailed": "OCR失敗",
|
||||
"uploadSection": "画像をアップロード",
|
||||
"dropOrClick": "ドロップまたはクリック",
|
||||
"changeImage": "画像を変更",
|
||||
"deckSelection": "デッキを選択",
|
||||
"sourceLanguagePlaceholder": "例:英語",
|
||||
"targetLanguagePlaceholder": "例:日本語",
|
||||
"processButton": "認識開始",
|
||||
"resultsPreview": "結果プレビュー",
|
||||
"saveButton": "デッキに保存",
|
||||
"ocrSuccess": "OCR成功",
|
||||
"savedToDeck": "デッキに保存しました",
|
||||
"noResultsToSave": "結果がありません",
|
||||
"detectedSourceLanguage": "検出ソース言語",
|
||||
"detectedTargetLanguage": "検出ターゲット言語"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "マイプロフィール",
|
||||
"email": "メール: {email}",
|
||||
@@ -402,43 +236,12 @@
|
||||
"videoUploadFailed": "ビデオのアップロードに失敗しました",
|
||||
"subtitleUploadFailed": "字幕のアップロードに失敗しました",
|
||||
"subtitleLoadSuccess": "字幕の読み込みに成功しました",
|
||||
"subtitleLoadFailed": "字幕の読み込みに失敗しました",
|
||||
"settings": "設定",
|
||||
"shortcuts": "ショートカット",
|
||||
"keyboardShortcuts": "キーボードショートカット",
|
||||
"playPause": "再生/一時停止",
|
||||
"autoPauseToggle": "自動一時停止",
|
||||
"subtitleSettings": "字幕設定",
|
||||
"fontSize": "フォントサイズ",
|
||||
"textColor": "文字色",
|
||||
"backgroundColor": "背景色",
|
||||
"position": "位置",
|
||||
"opacity": "不透明度",
|
||||
"top": "上",
|
||||
"center": "中央",
|
||||
"bottom": "下"
|
||||
"subtitleLoadFailed": "字幕の読み込みに失敗しました"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "IPAを生成",
|
||||
"viewSavedItems": "保存済み項目を表示",
|
||||
"confirmDeleteAll": "すべて削除してもよろしいですか? (Y/N)",
|
||||
"saved": "保存済み",
|
||||
"clearAll": "すべてクリア",
|
||||
"language": "言語",
|
||||
"customLanguage": "または言語を入力...",
|
||||
"languages": {
|
||||
"auto": "自動",
|
||||
"chinese": "中国語",
|
||||
"english": "英語",
|
||||
"japanese": "日本語",
|
||||
"korean": "韓国語",
|
||||
"french": "フランス語",
|
||||
"german": "ドイツ語",
|
||||
"italian": "イタリア語",
|
||||
"spanish": "スペイン語",
|
||||
"portuguese": "ポルトガル語",
|
||||
"russian": "ロシア語"
|
||||
}
|
||||
"confirmDeleteAll": "すべて削除してもよろしいですか? (Y/N)"
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "言語を検出",
|
||||
@@ -471,20 +274,7 @@
|
||||
"success": "テキストペアがフォルダーに追加されました",
|
||||
"error": "テキストペアをフォルダーに追加できませんでした"
|
||||
},
|
||||
"autoSave": "自動保存",
|
||||
"customLanguage": "または言語を入力...",
|
||||
"pleaseLogin": "ログインしてカードを保存",
|
||||
"pleaseCreateDeck": "先にデッキを作成",
|
||||
"noTranslationToSave": "保存する翻訳なし",
|
||||
"noDeckSelected": "デッキ未選択",
|
||||
"saveAsCard": "カードとして保存",
|
||||
"selectDeck": "デッキ選択",
|
||||
"front": "表面",
|
||||
"back": "裏面",
|
||||
"cancel": "キャンセル",
|
||||
"save": "保存",
|
||||
"savedToDeck": "{deckName}に保存",
|
||||
"saveFailed": "保存失敗"
|
||||
"autoSave": "自動保存"
|
||||
},
|
||||
"dictionary": {
|
||||
"title": "辞書",
|
||||
@@ -529,9 +319,7 @@
|
||||
"unfavorite": "お気に入り解除",
|
||||
"pleaseLogin": "まずログインしてください",
|
||||
"sortByFavorites": "お気に入り順に並べ替え",
|
||||
"sortByFavoritesActive": "お気に入り順の並べ替えを解除",
|
||||
"noDecks": "公開デッキなし",
|
||||
"deckInfo": "{userName} · {totalCards}枚"
|
||||
"sortByFavoritesActive": "お気に入り順の並べ替えを解除"
|
||||
},
|
||||
"exploreDetail": {
|
||||
"title": "フォルダー詳細",
|
||||
@@ -545,8 +333,7 @@
|
||||
"unfavorite": "お気に入り解除",
|
||||
"favorited": "お気に入りに追加しました",
|
||||
"unfavorited": "お気に入りから削除しました",
|
||||
"pleaseLogin": "まずログインしてください",
|
||||
"totalCards": "{count}枚"
|
||||
"pleaseLogin": "まずログインしてください"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "マイお気に入り",
|
||||
@@ -568,66 +355,14 @@
|
||||
"notSet": "未設定",
|
||||
"memberSince": "登録日",
|
||||
"logout": "ログアウト",
|
||||
"deleteAccount": {
|
||||
"button": "アカウント削除",
|
||||
"title": "アカウント削除",
|
||||
"warning": "この操作は取り消せません。すべてのデータが完全に削除されます。",
|
||||
"warningDecks": "すべてのデッキとカード",
|
||||
"warningCards": "すべての学習履歴",
|
||||
"warningHistory": "すべての翻訳と辞書の履歴",
|
||||
"warningPermanent": "この操作は取り消せません",
|
||||
"confirmLabel": "確認のためユーザー名を入力してください:",
|
||||
"usernameMismatch": "ユーザー名が一致しません",
|
||||
"cancel": "キャンセル",
|
||||
"confirm": "アカウントを削除する",
|
||||
"success": "アカウントが正常に削除されました",
|
||||
"failed": "アカウントの削除に失敗しました"
|
||||
},
|
||||
"decks": {
|
||||
"title": "デッキ",
|
||||
"noDecks": "まだデッキがありません",
|
||||
"deckName": "デッキ名",
|
||||
"totalCards": "合計カード数",
|
||||
"folders": {
|
||||
"title": "フォルダー",
|
||||
"noFolders": "まだフォルダーがありません",
|
||||
"folderName": "フォルダー名",
|
||||
"totalPairs": "合計ペア数",
|
||||
"createdAt": "作成日",
|
||||
"actions": "アクション",
|
||||
"view": "表示"
|
||||
},
|
||||
"joined": "登録日"
|
||||
},
|
||||
"decks": {
|
||||
"title": "デッキ",
|
||||
"subtitle": "学習デッキを管理",
|
||||
"newDeck": "新規デッキ",
|
||||
"noDecksYet": "デッキなし",
|
||||
"loading": "読込中...",
|
||||
"deckInfo": "ID: {id} · {totalCards}枚",
|
||||
"enterDeckName": "デッキ名:",
|
||||
"enterNewName": "新しい名前:",
|
||||
"confirmDelete": "削除確認:「{name}」を入力",
|
||||
"public": "公開",
|
||||
"private": "非公開",
|
||||
"setPublic": "公開に設定",
|
||||
"setPrivate": "非公開に設定",
|
||||
"importApkg": "APKGインポート",
|
||||
"exportApkg": "APKGエクスポート",
|
||||
"clickToUpload": "クリックでアップロード",
|
||||
"apkgFilesOnly": ".apkgのみ",
|
||||
"parsing": "解析中...",
|
||||
"foundDecks": "{count}デッキ発見",
|
||||
"deckName": "デッキ名",
|
||||
"back": "戻る",
|
||||
"import": "インポート",
|
||||
"importing": "インポート中...",
|
||||
"exportSuccess": "エクスポート成功",
|
||||
"goToDecks": "デッキへ"
|
||||
},
|
||||
"follow": {
|
||||
"follow": "フォロー",
|
||||
"following": "フォロー中",
|
||||
"followers": "フォロワー",
|
||||
"followersOf": "{username}のフォロワー",
|
||||
"followingOf": "{username}のフォロー中",
|
||||
"noFollowers": "まだフォロワーがいません",
|
||||
"noFollowing": "まだ誰もフォローしていません"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,38 +46,6 @@
|
||||
"unfavorite": "즐겨찾기 해제",
|
||||
"pleaseLogin": "먼저 로그인해주세요"
|
||||
},
|
||||
"decks": {
|
||||
"title": "덱",
|
||||
"noDecks": "덱이 없습니다",
|
||||
"deckName": "덱 이름",
|
||||
"totalCards": "총 카드",
|
||||
"createdAt": "생성일",
|
||||
"actions": "작업",
|
||||
"view": "보기",
|
||||
"subtitle": "학습 덱 관리",
|
||||
"newDeck": "새 덱",
|
||||
"noDecksYet": "덱이 없습니다",
|
||||
"loading": "로딩 중...",
|
||||
"deckInfo": "ID: {id} · {totalCards}장",
|
||||
"enterDeckName": "덱 이름 입력:",
|
||||
"enterNewName": "새 이름 입력:",
|
||||
"confirmDelete": "삭제하려면 \"{name}\" 입력:",
|
||||
"public": "공개",
|
||||
"private": "비공개",
|
||||
"setPublic": "공개로 설정",
|
||||
"setPrivate": "비공개로 설정",
|
||||
"importApkg": "APKG 가져오기",
|
||||
"exportApkg": "APKG 내보내기",
|
||||
"clickToUpload": "클릭하여 업로드",
|
||||
"apkgFilesOnly": ".apkg 파일만",
|
||||
"parsing": "파싱 중...",
|
||||
"foundDecks": "{count}개 덱 발견",
|
||||
"back": "뒤로",
|
||||
"import": "가져오기",
|
||||
"importing": "가져오는 중...",
|
||||
"exportSuccess": "내보내기 성공",
|
||||
"goToDecks": "덱으로"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "이 폴더의 소유자가 아닙니다",
|
||||
"back": "뒤로",
|
||||
@@ -106,77 +74,6 @@
|
||||
"deleteFolder": "이 폴더를 삭제할 권한이 없습니다."
|
||||
}
|
||||
},
|
||||
"deck_id": {
|
||||
"unauthorized": "이 덱의 소유자가 아닙니다",
|
||||
"back": "뒤로",
|
||||
"cards": "카드",
|
||||
"itemsCount": "{count}개",
|
||||
"memorize": "암기",
|
||||
"loadingCards": "카드 불러오는 중...",
|
||||
"noCards": "이 덱에 카드가 없습니다",
|
||||
"card": "카드",
|
||||
"addNewCard": "새 카드 추가",
|
||||
"add": "추가",
|
||||
"adding": "추가 중...",
|
||||
"updateCard": "카드 업데이트",
|
||||
"update": "업데이트",
|
||||
"updating": "업데이트 중...",
|
||||
"word": "단어",
|
||||
"definition": "정의",
|
||||
"ipa": "IPA",
|
||||
"example": "예문",
|
||||
"wordAndDefinitionRequired": "단어와 정의는 필수입니다",
|
||||
"edit": "편집",
|
||||
"delete": "삭제",
|
||||
"permissionDenied": "이 작업을 수행할 권한이 없습니다",
|
||||
"resetProgress": "진행 초기화",
|
||||
"resetProgressTitle": "학습 진행 초기화",
|
||||
"resetProgressConfirm": "이 덱의 학습 진행을 초기화하시겠습니까?",
|
||||
"resetSuccess": "초기화됨",
|
||||
"resetting": "초기화 중...",
|
||||
"cancel": "취소",
|
||||
"settings": "설정",
|
||||
"settingsTitle": "덱 설정",
|
||||
"newPerDay": "일일 새 카드",
|
||||
"newPerDayHint": "매일 학습할 새 카드 수",
|
||||
"revPerDay": "일일 복습",
|
||||
"revPerDayHint": "매일 복습할 카드 수",
|
||||
"save": "저장",
|
||||
"saving": "저장 중...",
|
||||
"settingsSaved": "설정 저장됨",
|
||||
"todayNew": "오늘 새 카드",
|
||||
"todayReview": "오늘 복습",
|
||||
"todayLearning": "학습 중",
|
||||
"error": {
|
||||
"update": "업데이트 권한이 없습니다",
|
||||
"delete": "삭제 권한이 없습니다",
|
||||
"add": "추가 권한이 없습니다"
|
||||
},
|
||||
"ipaPlaceholder": "IPA 입력",
|
||||
"examplePlaceholder": "예문 입력",
|
||||
"wordRequired": "단어를 입력하세요",
|
||||
"definitionRequired": "정의를 입력하세요",
|
||||
"cardAdded": "카드 추가됨",
|
||||
"cardType": "카드 유형",
|
||||
"wordCard": "단어 카드",
|
||||
"phraseCard": "구문 카드",
|
||||
"sentenceCard": "문장 카드",
|
||||
"sentence": "문장",
|
||||
"sentencePlaceholder": "문장 입력",
|
||||
"wordPlaceholder": "단어 입력",
|
||||
"queryLang": "검색 언어",
|
||||
"enterLanguageName": "언어 이름을 입력하세요",
|
||||
"english": "영어",
|
||||
"chinese": "중국어",
|
||||
"japanese": "일본어",
|
||||
"korean": "한국어",
|
||||
"meanings": "의미",
|
||||
"addMeaning": "의미 추가",
|
||||
"partOfSpeech": "품사",
|
||||
"deleteConfirm": "이 카드를 삭제하시겠습니까?",
|
||||
"cardDeleted": "카드 삭제됨",
|
||||
"cardUpdated": "카드 업데이트됨"
|
||||
},
|
||||
"home": {
|
||||
"title": "언어 배우기",
|
||||
"description": "세계의 거의 모든 언어(인공어 포함)를 배우는 데 도움이 되는 매우 유용한 웹사이트입니다.",
|
||||
@@ -260,9 +157,6 @@
|
||||
"resetPasswordFailed": "재설정 이메일 전송 실패",
|
||||
"resetPasswordEmailSent": "재설정 이메일이 전송되었습니다",
|
||||
"resetPasswordEmailSentHint": "비밀번호 재설정 링크를 이메일로 보냈습니다. 받은 편지함을 확인해주세요.",
|
||||
"verifyYourEmail": "이메일 인증",
|
||||
"verificationEmailSent": "인증 이메일이 전송되었습니다",
|
||||
"verificationEmailSentHint": "{email}로 인증 이메일을 보냈습니다. 이메일의 링크를 클릭하여 계정을 인증해주세요.",
|
||||
"checkYourEmail": "이메일을 확인하세요",
|
||||
"backToLogin": "로그인으로 돌아가기",
|
||||
"resetPassword": "비밀번호 재설정",
|
||||
@@ -272,47 +166,25 @@
|
||||
"requestNewToken": "새 재설정 링크 요청",
|
||||
"resetPasswordSuccess": "비밀번호 재설정 성공",
|
||||
"resetPasswordSuccessTitle": "비밀번호 재설정 완료",
|
||||
"resetPasswordSuccessHint": "비밀번호가 성공적으로 재설정되었습니다. 새 비밀번호로 로그인할 수 있습니다.",
|
||||
"emailNotVerified": "이메일 주소를 인증해 주세요",
|
||||
"emailNotVerifiedHint": "이메일이 인증되지 않았습니다. 받은 편지함을 확인하거나 새 인증 이메일을 요청해 주세요.",
|
||||
"resendVerification": "인증 이메일 다시 보내기",
|
||||
"resendSuccess": "인증 이메일이 발송되었습니다! 받은 편지함을 확인해 주세요.",
|
||||
"resendFailed": "인증 이메일 발송에 실패했습니다"
|
||||
"resetPasswordSuccessHint": "비밀번호가 성공적으로 재설정되었습니다. 새 비밀번호로 로그인할 수 있습니다."
|
||||
},
|
||||
"memorize": {
|
||||
"deck_selector": {
|
||||
"selectDeck": "덱 선택",
|
||||
"noDecks": "덱이 없습니다",
|
||||
"goToDecks": "덱으로 이동",
|
||||
"noCards": "카드가 없습니다",
|
||||
"new": "새로",
|
||||
"learning": "학습 중",
|
||||
"review": "복습",
|
||||
"due": "예정"
|
||||
"folder_selector": {
|
||||
"selectFolder": "폴더 선택",
|
||||
"noFolders": "폴더를 찾을 수 없습니다",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
},
|
||||
"review": {
|
||||
"loading": "로딩 중...",
|
||||
"backToDecks": "덱으로 돌아가기",
|
||||
"allDone": "모두 완료!",
|
||||
"allDoneDesc": "오늘의 학습을 완료했습니다!",
|
||||
"reviewedCount": "{count}장 복습 완료",
|
||||
"progress": "{current} / {total}",
|
||||
"nextReview": "다음 복습",
|
||||
"interval": "간격",
|
||||
"ease": "난이도",
|
||||
"lapses": "실패 횟수",
|
||||
"showAnswer": "정답 보기",
|
||||
"nextCard": "다음",
|
||||
"again": "다시",
|
||||
"restart": "다시 시작",
|
||||
"orderLimited": "순서 제한",
|
||||
"orderInfinite": "순서 무제한",
|
||||
"randomLimited": "무작위 제한",
|
||||
"randomInfinite": "무작위 무제한",
|
||||
"noIpa": "IPA 없음"
|
||||
"memorize": {
|
||||
"answer": "정답",
|
||||
"next": "다음",
|
||||
"reverse": "반대",
|
||||
"dictation": "받아쓰기",
|
||||
"noTextPairs": "사용 가능한 텍스트 쌍이 없습니다",
|
||||
"disorder": "무작위",
|
||||
"previous": "이전"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "권한이 없습니다"
|
||||
"unauthorized": "이 폴더에 접근할 권한이 없습니다"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
@@ -320,56 +192,11 @@
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "로그인",
|
||||
"profile": "프로필",
|
||||
"folders": "덱",
|
||||
"folders": "폴더",
|
||||
"explore": "탐색",
|
||||
"favorites": "즐겨찾기",
|
||||
"settings": "설정"
|
||||
},
|
||||
"ocr": {
|
||||
"title": "OCR 인식",
|
||||
"description": "이미지에서 텍스트 추출",
|
||||
"uploadImage": "이미지 업로드",
|
||||
"dragDropHint": "드래그 앤 드롭",
|
||||
"supportedFormats": "지원 형식: JPG, PNG, WEBP",
|
||||
"selectDeck": "덱 선택",
|
||||
"chooseDeck": "덱 선택",
|
||||
"noDecks": "덱이 없습니다",
|
||||
"languageHints": "언어 힌트",
|
||||
"sourceLanguageHint": "원본 언어 힌트",
|
||||
"targetLanguageHint": "대상 언어 힌트",
|
||||
"process": "처리",
|
||||
"processing": "처리 중...",
|
||||
"preview": "미리보기",
|
||||
"extractedPairs": "추출된 쌍",
|
||||
"word": "단어",
|
||||
"definition": "정의",
|
||||
"pairsCount": "{count}쌍",
|
||||
"savePairs": "저장",
|
||||
"saving": "저장 중...",
|
||||
"saved": "저장됨",
|
||||
"saveFailed": "저장 실패",
|
||||
"noImage": "이미지를 업로드하세요",
|
||||
"noDeck": "덱을 선택하세요",
|
||||
"processingFailed": "처리 실패",
|
||||
"tryAgain": "재시도",
|
||||
"detectedLanguages": "감지된 언어",
|
||||
"uploadSection": "이미지 업로드",
|
||||
"dropOrClick": "드롭 또는 클릭",
|
||||
"changeImage": "이미지 변경",
|
||||
"invalidFileType": "잘못된 파일 형식",
|
||||
"deckSelection": "덱 선택",
|
||||
"sourceLanguagePlaceholder": "예: 영어",
|
||||
"targetLanguagePlaceholder": "예: 한국어",
|
||||
"processButton": "인식 시작",
|
||||
"resultsPreview": "결과 미리보기",
|
||||
"saveButton": "덱에 저장",
|
||||
"ocrSuccess": "OCR 성공",
|
||||
"ocrFailed": "OCR 실패",
|
||||
"savedToDeck": "덱에 저장됨",
|
||||
"noResultsToSave": "저장할 결과 없음",
|
||||
"detectedSourceLanguage": "감지된 원본 언어",
|
||||
"detectedTargetLanguage": "감지된 대상 언어"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "내 프로필",
|
||||
"email": "이메일: {email}",
|
||||
@@ -409,43 +236,12 @@
|
||||
"videoUploadFailed": "비디오 업로드 실패",
|
||||
"subtitleUploadFailed": "자막 업로드 실패",
|
||||
"subtitleLoadSuccess": "자막 로드 성공",
|
||||
"subtitleLoadFailed": "자막 로드 실패",
|
||||
"settings": "설정",
|
||||
"shortcuts": "단축키",
|
||||
"keyboardShortcuts": "키보드 단축키",
|
||||
"playPause": "재생/일시정지",
|
||||
"autoPauseToggle": "자동 일시정지",
|
||||
"subtitleSettings": "자막 설정",
|
||||
"fontSize": "글꼴 크기",
|
||||
"textColor": "글자 색",
|
||||
"backgroundColor": "배경색",
|
||||
"position": "위치",
|
||||
"opacity": "불투명도",
|
||||
"top": "위",
|
||||
"center": "중앙",
|
||||
"bottom": "아래"
|
||||
"subtitleLoadFailed": "자막 로드 실패"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "IPA 생성",
|
||||
"viewSavedItems": "저장된 항목 보기",
|
||||
"confirmDeleteAll": "모든 것을 삭제하시겠습니까? (Y/N)",
|
||||
"saved": "저장됨",
|
||||
"clearAll": "모두 지우기",
|
||||
"language": "언어",
|
||||
"customLanguage": "또는 언어 입력...",
|
||||
"languages": {
|
||||
"auto": "자동",
|
||||
"chinese": "중국어",
|
||||
"english": "영어",
|
||||
"japanese": "일본어",
|
||||
"korean": "한국어",
|
||||
"french": "프랑스어",
|
||||
"german": "독일어",
|
||||
"italian": "이탈리아어",
|
||||
"spanish": "스페인어",
|
||||
"portuguese": "포르투갈어",
|
||||
"russian": "러시아어"
|
||||
}
|
||||
"confirmDeleteAll": "모든 것을 삭제하시겠습니까? (Y/N)"
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "언어 감지",
|
||||
@@ -478,20 +274,7 @@
|
||||
"success": "텍스트 쌍이 폴더에 추가됨",
|
||||
"error": "폴더에 텍스트 쌍 추가 실패"
|
||||
},
|
||||
"autoSave": "자동 저장",
|
||||
"customLanguage": "또는 언어 입력...",
|
||||
"pleaseLogin": "카드를 저장하려면 로그인하세요",
|
||||
"pleaseCreateDeck": "먼저 덱을 만드세요",
|
||||
"noTranslationToSave": "저장할 번역이 없습니다",
|
||||
"noDeckSelected": "덱이 선택되지 않았습니다",
|
||||
"saveAsCard": "카드로 저장",
|
||||
"selectDeck": "덱 선택",
|
||||
"front": "앞면",
|
||||
"back": "뒷면",
|
||||
"cancel": "취소",
|
||||
"save": "저장",
|
||||
"savedToDeck": "{deckName}에 카드 저장됨",
|
||||
"saveFailed": "카드 저장 실패"
|
||||
"autoSave": "자동 저장"
|
||||
},
|
||||
"dictionary": {
|
||||
"title": "사전",
|
||||
@@ -536,9 +319,7 @@
|
||||
"unfavorite": "즐겨찾기 해제",
|
||||
"pleaseLogin": "먼저 로그인해주세요",
|
||||
"sortByFavorites": "즐겨찾기순 정렬",
|
||||
"sortByFavoritesActive": "즐겨찾기순 정렬 해제",
|
||||
"noDecks": "공개 덱 없음",
|
||||
"deckInfo": "{userName} · {totalCards}장"
|
||||
"sortByFavoritesActive": "즐겨찾기순 정렬 해제"
|
||||
},
|
||||
"exploreDetail": {
|
||||
"title": "폴더 상세",
|
||||
@@ -552,8 +333,7 @@
|
||||
"unfavorite": "즐겨찾기 해제",
|
||||
"favorited": "즐겨찾기됨",
|
||||
"unfavorited": "즐겨찾기 해제됨",
|
||||
"pleaseLogin": "먼저 로그인해주세요",
|
||||
"totalCards": "총 {count}장"
|
||||
"pleaseLogin": "먼저 로그인해주세요"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "내 즐겨찾기",
|
||||
@@ -575,48 +355,14 @@
|
||||
"notSet": "설정되지 않음",
|
||||
"memberSince": "가입일",
|
||||
"logout": "로그아웃",
|
||||
"deleteAccount": {
|
||||
"button": "계정 삭제",
|
||||
"title": "계정 삭제",
|
||||
"warning": "이 작업은 되돌릴 수 없습니다. 모든 데이터가 영구적으로 삭제됩니다.",
|
||||
"warningDecks": "모든 덱과 카드",
|
||||
"warningCards": "모든 학습 진행 상황",
|
||||
"warningHistory": "모든 번역 및 사전 기록",
|
||||
"warningPermanent": "이 작업은 취소할 수 없습니다",
|
||||
"confirmLabel": "확인을 위해 사용자명을 입력하세요:",
|
||||
"usernameMismatch": "사용자명이 일치하지 않습니다",
|
||||
"cancel": "취소",
|
||||
"confirm": "내 계정 삭제",
|
||||
"success": "계정이 성공적으로 삭제되었습니다",
|
||||
"failed": "계정 삭제에 실패했습니다"
|
||||
},
|
||||
"folders": {
|
||||
"title": "덱",
|
||||
"noFolders": "아직 덱이 없습니다",
|
||||
"folderName": "덱 이름",
|
||||
"totalPairs": "총 카드 수",
|
||||
"createdAt": "생성일",
|
||||
"actions": "작업",
|
||||
"view": "보기"
|
||||
},
|
||||
"joined": "가입일",
|
||||
"decks": {
|
||||
"title": "내 덱",
|
||||
"noDecks": "덱이 없습니다",
|
||||
"deckName": "덱 이름",
|
||||
"totalCards": "총 카드",
|
||||
"title": "폴더",
|
||||
"noFolders": "아직 폴더가 없습니다",
|
||||
"folderName": "폴더 이름",
|
||||
"totalPairs": "총 쌍",
|
||||
"createdAt": "생성일",
|
||||
"actions": "작업",
|
||||
"view": "보기"
|
||||
}
|
||||
},
|
||||
"follow": {
|
||||
"follow": "팔로우",
|
||||
"following": "팔로잉",
|
||||
"followers": "팔로워",
|
||||
"followersOf": "{username}의 팔로워",
|
||||
"followingOf": "{username}의 팔로잉",
|
||||
"noFollowers": "아직 팔로워가 없습니다",
|
||||
"noFollowing": "아직 팔로우하는 사람이 없습니다"
|
||||
}
|
||||
}
|
||||
@@ -46,38 +46,6 @@
|
||||
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
|
||||
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ"
|
||||
},
|
||||
"decks": {
|
||||
"title": "دېكلار",
|
||||
"noDecks": "تېخى دېك يوق",
|
||||
"deckName": "دېك ئاتى",
|
||||
"totalCards": "جەمئىي كارتا",
|
||||
"createdAt": "قۇرۇلغان ۋاقتى",
|
||||
"actions": "مەشغۇلاتلار",
|
||||
"view": "كۆرۈش",
|
||||
"subtitle": "دېكلەرنى باشقۇرۇڭ",
|
||||
"newDeck": "يېڭى دېك",
|
||||
"noDecksYet": "دېك يوق",
|
||||
"loading": "يۈكلىنىۋاتىدۇ...",
|
||||
"deckInfo": "ID: {id} · {totalCards} كارتا",
|
||||
"enterDeckName": "دېك ئاتى:",
|
||||
"enterNewName": "يېڭى ئات:",
|
||||
"confirmDelete": "ئۆچۈرۈش: \"{name}\"",
|
||||
"public": "ئاممىۋىي",
|
||||
"private": "شەخسىي",
|
||||
"setPublic": "ئاممىۋىي قىلىش",
|
||||
"setPrivate": "شەخسىي قىلىش",
|
||||
"importApkg": "APKG ئەكىرىش",
|
||||
"exportApkg": "APKG چىقىرىش",
|
||||
"clickToUpload": "چېكىپ يۈكلەش",
|
||||
"apkgFilesOnly": ".apkg ھۆججىتىلا",
|
||||
"parsing": "تەھلىل قىلىنىۋاتىدۇ...",
|
||||
"foundDecks": "{count} دېك تېپىلدى",
|
||||
"back": "قايتىش",
|
||||
"import": "ئەكىرىش",
|
||||
"importing": "ئەكىرىلىۋاتىدۇ...",
|
||||
"exportSuccess": "چىقىرىش مۇۋەپپەقىيەتلىك",
|
||||
"goToDecks": "دېكلەرگە بېرىش"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "بۇ قىسقۇچنىڭ ئىگىسى ئەمەسسىز",
|
||||
"back": "قايتىش",
|
||||
@@ -106,77 +74,6 @@
|
||||
"deleteFolder": "بۇ قىسقۇچنى ئۆچۈرۈش ھوقۇقىڭىز يوق."
|
||||
}
|
||||
},
|
||||
"deck_id": {
|
||||
"unauthorized": "بۇ دېكنىڭ ئىگىسى ئەمەس",
|
||||
"back": "قايتىش",
|
||||
"cards": "كارتلار",
|
||||
"itemsCount": "{count} تۈر",
|
||||
"memorize": "يادلاش",
|
||||
"loadingCards": "كارتلار يۈكلىنىۋاتىدۇ...",
|
||||
"noCards": "بۇ دېكتا كارت يوق",
|
||||
"card": "كارتا",
|
||||
"addNewCard": "يېڭى كارتا قوشۇش",
|
||||
"add": "قوشۇش",
|
||||
"adding": "قوشۇلىۋاتىدۇ...",
|
||||
"updateCard": "كارتىنى يېڭىلاش",
|
||||
"update": "يېڭىلاش",
|
||||
"updating": "يېڭىلىنىۋاتىدۇ...",
|
||||
"word": "سۆز",
|
||||
"definition": "ئېنىقلىما",
|
||||
"ipa": "IPA",
|
||||
"example": "مىسال",
|
||||
"wordAndDefinitionRequired": "سۆز ۋە ئېنىقلىما زۆرۈر",
|
||||
"edit": "تەھرىرلەش",
|
||||
"delete": "ئۆچۈرۈش",
|
||||
"permissionDenied": "بۇ مەشغۇلاتنى ئېلىپ بېرىش ھوقۇقىڭىز يوق",
|
||||
"resetProgress": "ئىلگىرىلەشنى ئەسلىگە قايتۇرۇش",
|
||||
"resetProgressTitle": "ئۆگىنىش ئىلگىرىلەشىنى ئەسلىگە قايتۇرۇش",
|
||||
"resetProgressConfirm": "ئىلگىرىلەشنى ئەسلىگە قايتۇرامسىز؟",
|
||||
"resetSuccess": "ئەسلىگە قايتۇرۇلدى",
|
||||
"resetting": "ئەسلىگە قايتۇرۇۋاتىدۇ...",
|
||||
"cancel": "بىكار قىلىش",
|
||||
"settings": "تەڭشەكلەر",
|
||||
"settingsTitle": "دېك تەڭشەكلىرى",
|
||||
"newPerDay": "كۈندىلىك يېڭى",
|
||||
"newPerDayHint": "كۈندە يېڭى كارتا سانى",
|
||||
"revPerDay": "كۈندىلىك تەكرار",
|
||||
"revPerDayHint": "كۈندە تەكرار سانى",
|
||||
"save": "ساقلاش",
|
||||
"saving": "ساقلاۋاتىدۇ...",
|
||||
"settingsSaved": "تەڭشەكلەر ساقلاندى",
|
||||
"todayNew": "بۈگۈنكى يېڭى",
|
||||
"todayReview": "بۈگۈنكى تەكرار",
|
||||
"todayLearning": "ئۆگىنىۋاتىدۇ",
|
||||
"error": {
|
||||
"update": "يېڭىلاش ھوقۇقى يوق",
|
||||
"delete": "ئۆچۈرۈش ھوقۇقى يوق",
|
||||
"add": "قوشۇش ھوقۇقى يوق"
|
||||
},
|
||||
"ipaPlaceholder": "IPA كىرگۈزۈڭ",
|
||||
"examplePlaceholder": "مىسال كىرگۈزۈڭ",
|
||||
"wordRequired": "سۆز كىرگۈزۈڭ",
|
||||
"definitionRequired": "ئېنىقلىما كىرگۈزۈڭ",
|
||||
"cardAdded": "كارتا قوشۇلدى",
|
||||
"cardType": "كارتا تىپى",
|
||||
"wordCard": "سۆز كارتىسى",
|
||||
"phraseCard": "جۈملە كارتىسى",
|
||||
"sentenceCard": "جۈملە كارتىسى",
|
||||
"sentence": "جۈملە",
|
||||
"sentencePlaceholder": "جۈملە كىرگۈزۈڭ",
|
||||
"wordPlaceholder": "سۆز كىرگۈزۈڭ",
|
||||
"queryLang": "سۈرۈشتۈرۈش تىلى",
|
||||
"enterLanguageName": "تىل ئاتىنى كىرگۈزۈڭ",
|
||||
"english": "ئىنگىلىزچە",
|
||||
"chinese": "خەنزۇچە",
|
||||
"japanese": "ياپونچە",
|
||||
"korean": "كورىيەچە",
|
||||
"meanings": "مەنىلىرى",
|
||||
"addMeaning": "مەنا قوشۇش",
|
||||
"partOfSpeech": "سۆز بۆلىكى",
|
||||
"deleteConfirm": "بۇ كارتىنى ئۆچۈرەمسىز؟",
|
||||
"cardDeleted": "كارتا ئۆچۈرۈلدى",
|
||||
"cardUpdated": "كارتا يېڭىلاندى"
|
||||
},
|
||||
"home": {
|
||||
"title": "تىل ئۆگىنىش",
|
||||
"description": "بۇ دۇنيادىكى almost ھەر بىر تىلنى، جۈملىدىن سۈنئىي تىللارنى ئۆگىنىشىڭىزگە ياردەم بېرىدىغان ئىنتايىن قوللىنىشلىق تور بېكەت.",
|
||||
@@ -260,9 +157,6 @@
|
||||
"resetPasswordFailed": "ئەسلىگە قايتۇرۇش ئېلخېتى ئەۋەتىش مەغلۇپ بولدى",
|
||||
"resetPasswordEmailSent": "ئەسلىگە قايتۇرۇش ئېلخېتى مۇۋەپپەقىيەتلىك ئەۋەتىلدى",
|
||||
"resetPasswordEmailSentHint": "پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسىنى ئېلخەت ئادرېسىڭىزغا ئەۋەتتۇق. ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ.",
|
||||
"verifyYourEmail": "ئېلخەتنى دەلىللەش",
|
||||
"verificationEmailSent": "دەلىللەش ئېلخېتى ئەۋەتىلدى",
|
||||
"verificationEmailSentHint": "{email} غا دەلىللەش ئېلخېتى ئەۋەتتۇق. ئېلخەتتىكى ئۇلانمىنى چېكىپ ھېساباتىڭىزنى دەلىللەڭ.",
|
||||
"checkYourEmail": "ئېلخېتىڭىزنى تەكشۈرۈڭ",
|
||||
"backToLogin": "كىرىشكە قايتىش",
|
||||
"resetPassword": "پارولنى ئەسلىگە قايتۇرۇش",
|
||||
@@ -272,72 +166,25 @@
|
||||
"requestNewToken": "يېڭى ئەسلىگە قايتۇرۇش ئۇلانمىسى سوراش",
|
||||
"resetPasswordSuccess": "پارول مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى",
|
||||
"resetPasswordSuccessTitle": "پارول ئەسلىگە قايتۇرۇش تاماملاندى",
|
||||
"resetPasswordSuccessHint": "پارولىڭىز مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى. يېڭى پارول بىلەن كىرسىڭىز بولىدۇ.",
|
||||
"emailNotVerified": "ئېلخەت ئادرېسىڭىزنى دەلىللەڭ",
|
||||
"emailNotVerifiedHint": "ئېلخەت ئادرېسىڭىز دەلىللەنمىگەن. ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ ياكى يېڭى دەلىللەش ئېلخېتى سوراڭ.",
|
||||
"resendVerification": "دەلىللەش ئېلخېتىنى قايتا ئەۋەتىش",
|
||||
"resendSuccess": "دەلىللەش ئېلخېتى ئەۋەتىلدى! ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ.",
|
||||
"resendFailed": "دەلىللەش ئېلخېتى ئەۋەتىش مەغلۇپ بولدى"
|
||||
"resetPasswordSuccessHint": "پارولىڭىز مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى. يېڭى پارول بىلەن كىرسىڭىز بولىدۇ."
|
||||
},
|
||||
"memorize": {
|
||||
"deck_selector": {
|
||||
"selectDeck": "دېك تاللاش",
|
||||
"noDecks": "دېك يوق",
|
||||
"goToDecks": "دېكلەرگە بار",
|
||||
"noCards": "كارتا يوق",
|
||||
"new": "يېڭى",
|
||||
"learning": "ئۆگىنىش",
|
||||
"review": "تەكرار",
|
||||
"due": "ۋاقتى كەلدى"
|
||||
"folder_selector": {
|
||||
"selectFolder": "بىر قىسقۇچ تاللاڭ",
|
||||
"noFolders": "قىسقۇچ تېپىلمىدى",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
},
|
||||
"review": {
|
||||
"loading": "يۈكلىنىۋاتىدۇ...",
|
||||
"backToDecks": "دېكلەرگە قايتىش",
|
||||
"allDone": "ھەممىسى تامام!",
|
||||
"allDoneDesc": "بۈگۈنكى ئۆگىنىش تامام!",
|
||||
"reviewedCount": "{count} كارتا تەكرارلاندى",
|
||||
"progress": "{current} / {total}",
|
||||
"nextReview": "كېيىنكى تەكرار",
|
||||
"interval": "ئارىلىق",
|
||||
"ease": "قىيىنلىق",
|
||||
"lapses": "خاتالىق",
|
||||
"showAnswer": "جاۋابنى كۆرسەت",
|
||||
"nextCard": "كېيىنكى",
|
||||
"again": "يەنە",
|
||||
"hard": "قىيىن",
|
||||
"good": "ياخشى",
|
||||
"easy": "ئاسان",
|
||||
"now": "ھازىر",
|
||||
"lessThanMinute": "1 مىنۇتتىن ئاز",
|
||||
"inMinutes": "{n} مىنۇتتىن كېيىن",
|
||||
"inHours": "{n} سائەتتىن كېيىن",
|
||||
"inDays": "{n} كۈندىن كېيىن",
|
||||
"inMonths": "{n} ئايدىن كېيىن",
|
||||
"minutes": "مىنۇت",
|
||||
"days": "كۈن",
|
||||
"months": "ئاي",
|
||||
"minAbbr": "مىن",
|
||||
"dayAbbr": "كۈن",
|
||||
"cardTypeNew": "يېڭى",
|
||||
"cardTypeLearning": "ئۆگىنىش",
|
||||
"cardTypeReview": "تەكرار",
|
||||
"cardTypeRelearning": "قايتا ئۆگىنىش",
|
||||
"reverse": "ئەكسىچە",
|
||||
"dictation": "ئىملا",
|
||||
"clickToPlay": "چېكىپ قويۇش",
|
||||
"yourAnswer": "جاۋابىڭىز",
|
||||
"typeWhatYouHear": "ئاڭلىغىنىڭىزنى يېزىڭ",
|
||||
"correct": "توغرا!",
|
||||
"incorrect": "خاتا",
|
||||
"restart": "قايتا باشلا",
|
||||
"orderLimited": "تەرتىپلى چەكلەنگەن",
|
||||
"orderInfinite": "تەرتىپلى چەكسىز",
|
||||
"randomLimited": "ئىختىيارى چەكلەنگەن",
|
||||
"randomInfinite": "ئىختىيارى چەكسىز",
|
||||
"noIpa": "IPA يوق"
|
||||
"memorize": {
|
||||
"answer": "جاۋاب",
|
||||
"next": "كېيىنكى",
|
||||
"reverse": "تەتۈر",
|
||||
"dictation": "دىكتات",
|
||||
"noTextPairs": "تېكىست جۈپى يوق",
|
||||
"disorder": "قالايمىقانلاشتۇرۇش",
|
||||
"previous": "ئالدىنقى"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "ھوقۇقسىز"
|
||||
"unauthorized": "بۇ قىسقۇچنى زىيارەت قىلىش ھوقۇقىڭىز يوق"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
@@ -345,56 +192,11 @@
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "كىرىش",
|
||||
"profile": "شەخسىي ئۇچۇر",
|
||||
"folders": "دېكلار",
|
||||
"folders": "قىسقۇچلار",
|
||||
"explore": "ئىزدىنىش",
|
||||
"favorites": "يىغىپ ساقلاش",
|
||||
"settings": "تەڭشەكلەر"
|
||||
},
|
||||
"ocr": {
|
||||
"title": "OCR تونۇش",
|
||||
"description": "رەسىمدىن تېكىست ئېلىش",
|
||||
"uploadImage": "رەسىم يۈكلەش",
|
||||
"dragDropHint": "سۆرەپ تاشلاش",
|
||||
"supportedFormats": "قوللايدىغان فورمات: JPG, PNG, WEBP",
|
||||
"selectDeck": "دېك تاللاش",
|
||||
"chooseDeck": "دېك تاللاڭ",
|
||||
"noDecks": "دېك يوق",
|
||||
"languageHints": "تىل بېشارىتى",
|
||||
"sourceLanguageHint": "مەنبە تىلى",
|
||||
"targetLanguageHint": "نىشان تىلى",
|
||||
"process": "بىر تەرەپ قىلىش",
|
||||
"processing": "بىر تەرەپ قىلىنىۋاتىدۇ...",
|
||||
"preview": "ئالدىن كۆرۈش",
|
||||
"extractedPairs": "ئېلىنغان جۈپلەر",
|
||||
"word": "سۆز",
|
||||
"definition": "ئېنىقلىما",
|
||||
"pairsCount": "{count} جۈپ",
|
||||
"savePairs": "ساقلاش",
|
||||
"saving": "ساقلاۋاتىدۇ...",
|
||||
"saved": "ساقلاندى",
|
||||
"saveFailed": "ساقلاش مەغلۇپ بولدى",
|
||||
"noImage": "رەسىم يۈكلەڭ",
|
||||
"noDeck": "دېك تاللاڭ",
|
||||
"processingFailed": "بىر تەرەپ قىلىش مەغلۇپ بولدى",
|
||||
"tryAgain": "قايتا سىناڭ",
|
||||
"detectedLanguages": "تونۇلغان تىللار",
|
||||
"uploadSection": "رەسىم يۈكلەش",
|
||||
"dropOrClick": "تاشلاش ياكى چېكىش",
|
||||
"changeImage": "رەسىم ئالماشتۇرۇش",
|
||||
"invalidFileType": "ئىناۋەتسىز فايىل تىپى",
|
||||
"deckSelection": "دېك تاللاش",
|
||||
"sourceLanguagePlaceholder": "مەسىلەن: ئىنگلىزچە",
|
||||
"targetLanguagePlaceholder": "مەسىلەن: ئۇيغۇرچە",
|
||||
"processButton": "تونۇشنى باشلاش",
|
||||
"resultsPreview": "نەتىجە ئالدىن كۆرۈش",
|
||||
"saveButton": "دېككە ساقلاش",
|
||||
"ocrSuccess": "OCR مۇۋەپپەقىيەتلىك",
|
||||
"ocrFailed": "OCR مەغلۇپ بولدى",
|
||||
"savedToDeck": "دېككە ساقلاندى",
|
||||
"noResultsToSave": "نەتىجە يوق",
|
||||
"detectedSourceLanguage": "تونۇلغان مەنبە تىلى",
|
||||
"detectedTargetLanguage": "تونۇلغان نىشان تىلى"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "شەخسىي ئۇچۇرۇم",
|
||||
"email": "ئېلخەت: {email}",
|
||||
@@ -434,43 +236,12 @@
|
||||
"videoUploadFailed": "ۋىدېئو يۈكلەش مەغلۇپ بولدى",
|
||||
"subtitleUploadFailed": "تر پودكاست يۈكلەش مەغلۇپ بولدى",
|
||||
"subtitleLoadSuccess": "تر پودكاست مۇۋەپپەقىيەتلىك يۈكلەندى",
|
||||
"subtitleLoadFailed": "تر پودكاست يۈكلەش مەغلۇپ بولدى",
|
||||
"settings": "تەڭشەكلەر",
|
||||
"shortcuts": "تېزلەتمەلەر",
|
||||
"keyboardShortcuts": "كۇنۇپكا تاختىسى تېزلەتمەلىرى",
|
||||
"playPause": "قويۇش/توختىتىش",
|
||||
"autoPauseToggle": "ئاپتوماتىك توختىتىش",
|
||||
"subtitleSettings": "ئاستى سىزىق تەڭشەكلىرى",
|
||||
"fontSize": "خەت چوڭلۇقى",
|
||||
"textColor": "خەت رەڭگى",
|
||||
"backgroundColor": "تەگلىك رەڭگى",
|
||||
"position": "ئورنى",
|
||||
"opacity": "سۈزۈكلۈك",
|
||||
"top": "ئۈستى",
|
||||
"center": "ئوتتۇرا",
|
||||
"bottom": "ئاستى"
|
||||
"subtitleLoadFailed": "تر پودكاست يۈكلەش مەغلۇپ بولدى"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "IPA ھاسىل قىلىش",
|
||||
"viewSavedItems": "ساقلانغان تۈرلەرنى كۆرۈش",
|
||||
"confirmDeleteAll": "ھەممىنى ئۆچۈرەمسىز؟ (Y/N)",
|
||||
"saved": "ساقلاندى",
|
||||
"clearAll": "ھەممىنى تازىلاش",
|
||||
"language": "تىل",
|
||||
"customLanguage": "ياكى تىل كىرگۈزۈڭ...",
|
||||
"languages": {
|
||||
"auto": "ئاپتوماتىك",
|
||||
"chinese": "خەنزۇچە",
|
||||
"english": "ئىنگلىزچە",
|
||||
"japanese": "ياپونچە",
|
||||
"korean": "كورېيەچە",
|
||||
"french": "فرانسۇزچە",
|
||||
"german": "گېرمانچە",
|
||||
"italian": "ئىتاليانچە",
|
||||
"spanish": "ئىسپانچە",
|
||||
"portuguese": "پورتۇگالچە",
|
||||
"russian": "رۇسچە"
|
||||
}
|
||||
"confirmDeleteAll": "ھەممىنى ئۆچۈرەمسىز؟ (Y/N)"
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "تىلنى تونۇش",
|
||||
@@ -503,20 +274,7 @@
|
||||
"success": "تېكىست جۈپى قىسقۇچقا قوشۇلدى",
|
||||
"error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇپ بولدى"
|
||||
},
|
||||
"autoSave": "ئاپتوماتىك ساقلاش",
|
||||
"customLanguage": "ياكى تىل تىل كىرۇڭ...",
|
||||
"pleaseLogin": "كارتا ساقلاش ئۈچۈن كىرىڭ",
|
||||
"pleaseCreateDeck": "ئاۋۋال دېك قۇرۇڭ",
|
||||
"noTranslationToSave": "ساقلايدىغان تەرجىمە يوق",
|
||||
"noDeckSelected": "دېك تاللانمىدى",
|
||||
"saveAsCard": "كارتا ساقلاش",
|
||||
"selectDeck": "دېك تاللاش",
|
||||
"front": "ئالدى",
|
||||
"back": "كەينى",
|
||||
"cancel": "بىكار قىلىش",
|
||||
"save": "ساقلاش",
|
||||
"savedToDeck": "{deckName} غا ساقلاندى",
|
||||
"saveFailed": "ساقلاش مەغلۇپ"
|
||||
"autoSave": "ئاپتوماتىك ساقلاش"
|
||||
},
|
||||
"dictionary": {
|
||||
"title": "لۇغەت",
|
||||
@@ -561,9 +319,7 @@
|
||||
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
|
||||
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ",
|
||||
"sortByFavorites": "يىغىپ ساقلاش بويىچە تەرتىپلەش",
|
||||
"sortByFavoritesActive": "يىغىپ ساقلاش بويىچە تەرتىپلەشنى بىكار قىلىش",
|
||||
"noDecks": "ئاممىۋىي دېك يوق",
|
||||
"deckInfo": "{userName} · {totalCards} كارتا"
|
||||
"sortByFavoritesActive": "يىغىپ ساقلاش بويىچە تەرتىپلەشنى بىكار قىلىش"
|
||||
},
|
||||
"exploreDetail": {
|
||||
"title": "قىسقۇچ تەپسىلاتلىرى",
|
||||
@@ -577,8 +333,7 @@
|
||||
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
|
||||
"favorited": "يىغىپ ساقلاندى",
|
||||
"unfavorited": "يىغىپ ساقلاش بىكار قىلىندى",
|
||||
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ",
|
||||
"totalCards": "{count} كارتا"
|
||||
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "يىغىپ ساقلىغانلىرىم",
|
||||
@@ -600,39 +355,14 @@
|
||||
"notSet": "تەڭشەلمىگەن",
|
||||
"memberSince": "ئەزا بولغاندىن بېرى",
|
||||
"logout": "چىكىنىش",
|
||||
"deleteAccount": {
|
||||
"button": "ھېساباتنى ئۆچۈرۈش",
|
||||
"title": "ھېساباتنى ئۆچۈرۈش",
|
||||
"warning": "بۇ مەشغۇلاتنى ئەسلىگە قايتۇرغىلى بولمايدۇ. بارلىق سانلىق مەلۇماتلىرىڭىز مەڭگۈلۈك ئۆچۈرۈلىدۇ.",
|
||||
"warningDecks": "بارلىق دېك ۋە كارتلىرىڭىز",
|
||||
"warningCards": "بارلىق ئۆگىنىش ئىلگىرىلەشلىرىڭىز",
|
||||
"warningHistory": "بارلىق تەرجىمە ۋە لۇغەت تارىخىڭىز",
|
||||
"warningPermanent": "بۇ مەشغۇلاتنى بىكار قىلغىلى بولمايدۇ",
|
||||
"confirmLabel": "جەزىملەش ئۈچۈن ئىشلەتكۈچى ئاتىڭىزنى كىرگۈزۈڭ:",
|
||||
"usernameMismatch": "ئىشلەتكۈچى ئاتى ماس كەلمەيدۇ",
|
||||
"cancel": "بىكار قىلىش",
|
||||
"confirm": "ھېساباتىمنى ئۆچۈرۈش",
|
||||
"success": "ھېسابات مۇۋەپپەقىيەتلىك ئۆچۈرۈلدى",
|
||||
"failed": "ھېساباتنى ئۆچۈرۈش مەغلۇپ بولدى"
|
||||
},
|
||||
"decks": {
|
||||
"title": "دېكلار",
|
||||
"noDecks": "تېخى دېك يوق",
|
||||
"deckName": "دېك ئاتى",
|
||||
"totalCards": "جەمئىي كارتا",
|
||||
"folders": {
|
||||
"title": "قىسقۇچلار",
|
||||
"noFolders": "تېخى قىسقۇچ يوق",
|
||||
"folderName": "قىسقۇچ ئاتى",
|
||||
"totalPairs": "جەمئىي جۈپ",
|
||||
"createdAt": "قۇرۇلغان ۋاقتى",
|
||||
"actions": "مەشغۇلاتلار",
|
||||
"view": "كۆرۈش"
|
||||
},
|
||||
"joined": "قوشۇلدى"
|
||||
},
|
||||
"follow": {
|
||||
"follow": "ئەگىشىش",
|
||||
"following": "ئەگىشىۋاتىدۇ",
|
||||
"followers": "ئەگەشكۈچىلەر",
|
||||
"followersOf": "{username} نىڭ ئەگەشكۈچىلىرى",
|
||||
"followingOf": "{username} نىڭ ئەگىشىدىغانلىرى",
|
||||
"noFollowers": "تېخى ئەگەشكۈچى يوق",
|
||||
"noFollowing": "تېخى ئەگىشىدىغان ئادەم يوق"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,77 +74,6 @@
|
||||
"deleteFolder": "您没有权限删除此文件夹"
|
||||
}
|
||||
},
|
||||
"deck_id": {
|
||||
"unauthorized": "您不是此牌组的所有者",
|
||||
"back": "返回",
|
||||
"cards": "卡片",
|
||||
"itemsCount": "{count} 个",
|
||||
"memorize": "记忆",
|
||||
"loadingCards": "加载卡片中...",
|
||||
"noCards": "此牌组中没有卡片",
|
||||
"card": "卡片",
|
||||
"addNewCard": "添加新卡片",
|
||||
"add": "添加",
|
||||
"adding": "添加中...",
|
||||
"updateCard": "更新卡片",
|
||||
"update": "更新",
|
||||
"updating": "更新中...",
|
||||
"word": "单词",
|
||||
"definition": "释义",
|
||||
"ipa": "音标",
|
||||
"example": "例句",
|
||||
"wordAndDefinitionRequired": "单词和释义都是必需的",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"permissionDenied": "您没有权限执行此操作",
|
||||
"resetProgress": "重置进度",
|
||||
"resetProgressTitle": "重置学习进度",
|
||||
"resetProgressConfirm": "确定要重置这个卡组的学习进度吗?",
|
||||
"resetSuccess": "进度已重置",
|
||||
"resetting": "重置中...",
|
||||
"cancel": "取消",
|
||||
"settings": "设置",
|
||||
"settingsTitle": "卡组设置",
|
||||
"newPerDay": "每日新卡",
|
||||
"newPerDayHint": "每天学习的新卡片数量",
|
||||
"revPerDay": "每日复习",
|
||||
"revPerDayHint": "每天复习的卡片数量",
|
||||
"save": "保存",
|
||||
"saving": "保存中...",
|
||||
"settingsSaved": "设置已保存",
|
||||
"todayNew": "今日新卡",
|
||||
"todayReview": "今日复习",
|
||||
"todayLearning": "学习中",
|
||||
"error": {
|
||||
"update": "您没有权限更新此卡片",
|
||||
"delete": "您没有权限删除此卡片",
|
||||
"add": "您没有权限向此牌组添加卡片"
|
||||
},
|
||||
"ipaPlaceholder": "输入IPA音标",
|
||||
"examplePlaceholder": "输入例句",
|
||||
"wordRequired": "请输入单词",
|
||||
"definitionRequired": "请输入至少一个释义",
|
||||
"cardAdded": "卡片已添加",
|
||||
"cardType": "卡片类型",
|
||||
"wordCard": "单词卡",
|
||||
"phraseCard": "短语卡",
|
||||
"sentenceCard": "句子卡",
|
||||
"sentence": "句子",
|
||||
"sentencePlaceholder": "输入句子",
|
||||
"wordPlaceholder": "输入单词",
|
||||
"queryLang": "查询语言",
|
||||
"enterLanguageName": "请输入语言名称",
|
||||
"english": "英语",
|
||||
"chinese": "中文",
|
||||
"japanese": "日语",
|
||||
"korean": "韩语",
|
||||
"meanings": "释义",
|
||||
"addMeaning": "添加释义",
|
||||
"partOfSpeech": "词性",
|
||||
"deleteConfirm": "确定删除这张卡片吗?",
|
||||
"cardDeleted": "卡片已删除",
|
||||
"cardUpdated": "卡片已更新"
|
||||
},
|
||||
"home": {
|
||||
"title": "学语言",
|
||||
"description": "这里是一个非常有用的网站,可以帮助您学习世界上几乎每一种语言,包括人造语言。",
|
||||
@@ -228,9 +157,6 @@
|
||||
"resetPasswordFailed": "发送重置邮件失败",
|
||||
"resetPasswordEmailSent": "重置邮件已发送",
|
||||
"resetPasswordEmailSentHint": "我们已向您的邮箱发送了密码重置链接,请查收。",
|
||||
"verifyYourEmail": "验证您的邮箱",
|
||||
"verificationEmailSent": "验证邮件已发送",
|
||||
"verificationEmailSentHint": "我们已向 {email} 发送了验证邮件,请点击邮件中的链接完成验证。",
|
||||
"checkYourEmail": "请查收邮件",
|
||||
"backToLogin": "返回登录",
|
||||
"resetPassword": "重置密码",
|
||||
@@ -240,72 +166,25 @@
|
||||
"requestNewToken": "重新申请重置链接",
|
||||
"resetPasswordSuccess": "密码重置成功",
|
||||
"resetPasswordSuccessTitle": "密码重置完成",
|
||||
"resetPasswordSuccessHint": "您的密码已成功重置,现在可以使用新密码登录了。",
|
||||
"emailNotVerified": "请验证您的邮箱地址",
|
||||
"emailNotVerifiedHint": "您的邮箱尚未验证。请检查收件箱或重新发送验证邮件。",
|
||||
"resendVerification": "重新发送验证邮件",
|
||||
"resendSuccess": "验证邮件已发送!请检查您的收件箱。",
|
||||
"resendFailed": "发送验证邮件失败"
|
||||
"resetPasswordSuccessHint": "您的密码已成功重置,现在可以使用新密码登录了。"
|
||||
},
|
||||
"memorize": {
|
||||
"deck_selector": {
|
||||
"selectDeck": "选择牌组",
|
||||
"noDecks": "未找到牌组",
|
||||
"goToDecks": "前往牌组",
|
||||
"noCards": "无卡片",
|
||||
"new": "新卡片",
|
||||
"learning": "学习中",
|
||||
"review": "复习",
|
||||
"due": "待复习"
|
||||
"folder_selector": {
|
||||
"selectFolder": "选择文件夹",
|
||||
"noFolders": "未找到文件夹",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
},
|
||||
"review": {
|
||||
"loading": "加载中...",
|
||||
"backToDecks": "返回牌组",
|
||||
"allDone": "全部完成!",
|
||||
"allDoneDesc": "您已完成所有待复习卡片。",
|
||||
"reviewedCount": "已复习 {count} 张卡片",
|
||||
"progress": "{current} / {total}",
|
||||
"nextReview": "下次复习",
|
||||
"interval": "间隔",
|
||||
"ease": "难度系数",
|
||||
"lapses": "遗忘次数",
|
||||
"showAnswer": "显示答案",
|
||||
"nextCard": "下一张",
|
||||
"again": "重来",
|
||||
"hard": "困难",
|
||||
"good": "良好",
|
||||
"easy": "简单",
|
||||
"now": "现在",
|
||||
"lessThanMinute": "<1 分钟",
|
||||
"inMinutes": "{count} 分钟",
|
||||
"inHours": "{count} 小时",
|
||||
"inDays": "{count} 天",
|
||||
"inMonths": "{count} 个月",
|
||||
"minutes": "<1 分钟",
|
||||
"days": "{count} 天",
|
||||
"months": "{count} 个月",
|
||||
"minAbbr": "分",
|
||||
"dayAbbr": "天",
|
||||
"cardTypeNew": "新卡片",
|
||||
"cardTypeLearning": "学习中",
|
||||
"cardTypeReview": "复习中",
|
||||
"cardTypeRelearning": "重学中",
|
||||
"memorize": {
|
||||
"answer": "答案",
|
||||
"next": "下一个",
|
||||
"reverse": "反向",
|
||||
"dictation": "听写",
|
||||
"clickToPlay": "点击播放",
|
||||
"yourAnswer": "你的答案",
|
||||
"typeWhatYouHear": "输入你听到的内容",
|
||||
"correct": "正确",
|
||||
"incorrect": "错误",
|
||||
"restart": "重新开始",
|
||||
"orderLimited": "顺序有限",
|
||||
"orderInfinite": "顺序无限",
|
||||
"randomLimited": "随机有限",
|
||||
"randomInfinite": "随机无限",
|
||||
"noIpa": "无音标"
|
||||
"noTextPairs": "没有可用的文本对",
|
||||
"disorder": "乱序",
|
||||
"previous": "上一个"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "您无权访问该牌组"
|
||||
"unauthorized": "您无权访问该文件夹"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
@@ -313,56 +192,11 @@
|
||||
"sourceCode": "源码",
|
||||
"sign_in": "登录",
|
||||
"profile": "个人资料",
|
||||
"folders": "牌组",
|
||||
"folders": "文件夹",
|
||||
"explore": "探索",
|
||||
"favorites": "收藏",
|
||||
"settings": "设置"
|
||||
},
|
||||
"ocr": {
|
||||
"title": "OCR文字识别",
|
||||
"description": "从图片中提取文字创建学习卡片",
|
||||
"uploadSection": "上传图片",
|
||||
"uploadImage": "上传图片",
|
||||
"dragDropHint": "拖放或点击上传",
|
||||
"dropOrClick": "拖放或点击",
|
||||
"changeImage": "更换图片",
|
||||
"supportedFormats": "支持格式:JPG, PNG, WEBP",
|
||||
"invalidFileType": "无效的文件类型",
|
||||
"deckSelection": "选择卡组",
|
||||
"selectDeck": "选择卡组",
|
||||
"chooseDeck": "选择卡组保存",
|
||||
"noDecks": "没有可用的卡组",
|
||||
"languageHints": "语言提示",
|
||||
"sourceLanguageHint": "源语言提示",
|
||||
"targetLanguageHint": "目标语言提示",
|
||||
"sourceLanguagePlaceholder": "如:英语",
|
||||
"targetLanguagePlaceholder": "如:中文",
|
||||
"process": "处理",
|
||||
"processButton": "开始识别",
|
||||
"processing": "处理中...",
|
||||
"preview": "预览",
|
||||
"resultsPreview": "结果预览",
|
||||
"extractedPairs": "提取的语言对",
|
||||
"word": "单词",
|
||||
"definition": "释义",
|
||||
"pairsCount": "{count}对",
|
||||
"savePairs": "保存",
|
||||
"saveButton": "保存到卡组",
|
||||
"saving": "保存中...",
|
||||
"saved": "已保存",
|
||||
"ocrSuccess": "OCR识别成功",
|
||||
"savedToDeck": "已保存到卡组",
|
||||
"saveFailed": "保存失败",
|
||||
"noImage": "请上传图片",
|
||||
"noDeck": "请选择卡组",
|
||||
"noResultsToSave": "无结果可保存",
|
||||
"processingFailed": "处理失败",
|
||||
"tryAgain": "重试",
|
||||
"detectedLanguages": "检测到的语言",
|
||||
"detectedSourceLanguage": "检测到的源语言",
|
||||
"detectedTargetLanguage": "检测到的目标语言",
|
||||
"ocrFailed": "OCR识别失败"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "我的个人资料",
|
||||
"email": "邮箱:{email}",
|
||||
@@ -402,43 +236,12 @@
|
||||
"videoUploadFailed": "视频上传失败",
|
||||
"subtitleUploadFailed": "字幕上传失败",
|
||||
"subtitleLoadSuccess": "字幕加载成功",
|
||||
"subtitleLoadFailed": "字幕加载失败",
|
||||
"settings": "设置",
|
||||
"shortcuts": "快捷键",
|
||||
"keyboardShortcuts": "键盘快捷键",
|
||||
"playPause": "播放/暂停",
|
||||
"autoPauseToggle": "自动暂停开关",
|
||||
"subtitleSettings": "字幕设置",
|
||||
"fontSize": "字体大小",
|
||||
"textColor": "文字颜色",
|
||||
"backgroundColor": "背景颜色",
|
||||
"position": "位置",
|
||||
"opacity": "透明度",
|
||||
"top": "顶部",
|
||||
"center": "居中",
|
||||
"bottom": "底部"
|
||||
"subtitleLoadFailed": "字幕加载失败"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "生成IPA",
|
||||
"viewSavedItems": "查看保存项",
|
||||
"confirmDeleteAll": "确定删光吗?(Y/N)",
|
||||
"saved": "已保存",
|
||||
"clearAll": "清空全部",
|
||||
"language": "语言",
|
||||
"customLanguage": "或输入语言...",
|
||||
"languages": {
|
||||
"auto": "自动",
|
||||
"chinese": "中文",
|
||||
"english": "英语",
|
||||
"japanese": "日语",
|
||||
"korean": "韩语",
|
||||
"french": "法语",
|
||||
"german": "德语",
|
||||
"italian": "意大利语",
|
||||
"spanish": "西班牙语",
|
||||
"portuguese": "葡萄牙语",
|
||||
"russian": "俄语"
|
||||
}
|
||||
"confirmDeleteAll": "确定删光吗?(Y/N)"
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "检测语言",
|
||||
@@ -471,20 +274,7 @@
|
||||
"success": "文本对已添加到文件夹",
|
||||
"error": "添加文本对到文件夹失败"
|
||||
},
|
||||
"autoSave": "自动保存",
|
||||
"customLanguage": "或输入语言...",
|
||||
"pleaseLogin": "请登录后保存卡片",
|
||||
"pleaseCreateDeck": "请先创建卡组",
|
||||
"noTranslationToSave": "没有可保存的翻译",
|
||||
"noDeckSelected": "未选择卡组",
|
||||
"saveAsCard": "保存为卡片",
|
||||
"selectDeck": "选择卡组",
|
||||
"front": "正面",
|
||||
"back": "背面",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"savedToDeck": "已保存到 {deckName}",
|
||||
"saveFailed": "保存失败"
|
||||
"autoSave": "自动保存"
|
||||
},
|
||||
"dictionary": {
|
||||
"title": "词典",
|
||||
@@ -519,11 +309,11 @@
|
||||
},
|
||||
"explore": {
|
||||
"title": "探索",
|
||||
"subtitle": "发现公开牌组",
|
||||
"searchPlaceholder": "搜索公开牌组...",
|
||||
"subtitle": "发现公开文件夹",
|
||||
"searchPlaceholder": "搜索公开文件夹...",
|
||||
"loading": "加载中...",
|
||||
"noDecks": "暂无公开卡组",
|
||||
"deckInfo": "{userName} · {totalCards} 张",
|
||||
"noFolders": "没有找到公开文件夹",
|
||||
"folderInfo": "{userName} • {totalPairs} 个文本对",
|
||||
"unknownUser": "未知用户",
|
||||
"favorite": "收藏",
|
||||
"unfavorite": "取消收藏",
|
||||
@@ -532,10 +322,10 @@
|
||||
"sortByFavoritesActive": "取消按收藏数排序"
|
||||
},
|
||||
"exploreDetail": {
|
||||
"title": "牌组详情",
|
||||
"title": "文件夹详情",
|
||||
"createdBy": "创建者:{name}",
|
||||
"unknownUser": "未知用户",
|
||||
"totalCards": "共 {count} 张",
|
||||
"totalPairs": "词对数量",
|
||||
"favorites": "收藏数",
|
||||
"createdAt": "创建时间",
|
||||
"viewContent": "查看内容",
|
||||
@@ -564,60 +354,18 @@
|
||||
"displayName": "显示名称",
|
||||
"notSet": "未设置",
|
||||
"memberSince": "注册时间",
|
||||
"joined": "注册于",
|
||||
"joined": "加入于",
|
||||
"logout": "登出",
|
||||
"deleteAccount": {
|
||||
"button": "注销账号",
|
||||
"title": "注销账号",
|
||||
"warning": "此操作不可逆,您的所有数据将被永久删除。",
|
||||
"warningDecks": "您的所有牌组和卡片",
|
||||
"warningCards": "您的所有学习进度",
|
||||
"warningHistory": "您的所有翻译和词典历史",
|
||||
"warningPermanent": "此操作无法撤销",
|
||||
"confirmLabel": "输入您的用户名以确认:",
|
||||
"usernameMismatch": "用户名不匹配",
|
||||
"cancel": "取消",
|
||||
"confirm": "注销我的账号",
|
||||
"success": "账号已成功注销",
|
||||
"failed": "注销账号失败"
|
||||
},
|
||||
"decks": {
|
||||
"title": "牌组",
|
||||
"noDecks": "还没有牌组",
|
||||
"deckName": "牌组名称",
|
||||
"totalCards": "卡片数量",
|
||||
"folders": {
|
||||
"title": "文件夹",
|
||||
"noFolders": "还没有文件夹",
|
||||
"folderName": "文件夹名称",
|
||||
"totalPairs": "文本对数量",
|
||||
"createdAt": "创建时间",
|
||||
"actions": "操作",
|
||||
"view": "查看"
|
||||
}
|
||||
},
|
||||
"decks": {
|
||||
"title": "牌组",
|
||||
"subtitle": "管理你的学习卡组",
|
||||
"newDeck": "新建卡组",
|
||||
"noDecksYet": "暂无卡组",
|
||||
"loading": "加载中...",
|
||||
"deckInfo": "ID: {id} · {totalCards} 张",
|
||||
"enterDeckName": "输入卡组名称:",
|
||||
"enterNewName": "输入新名称:",
|
||||
"confirmDelete": "输入 \"{name}\" 确认删除:",
|
||||
"public": "公开",
|
||||
"private": "私有",
|
||||
"setPublic": "设为公开",
|
||||
"setPrivate": "设为私有",
|
||||
"importApkg": "导入 APKG",
|
||||
"exportApkg": "导出 APKG",
|
||||
"clickToUpload": "点击上传",
|
||||
"apkgFilesOnly": "仅支持 .apkg 文件",
|
||||
"parsing": "解析中...",
|
||||
"foundDecks": "发现 {count} 个卡组",
|
||||
"deckName": "卡组名称",
|
||||
"back": "返回",
|
||||
"import": "导入",
|
||||
"importing": "导入中...",
|
||||
"exportSuccess": "导出成功",
|
||||
"goToDecks": "前往卡组"
|
||||
},
|
||||
"follow": {
|
||||
"follow": "关注",
|
||||
"following": "已关注",
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.1.1",
|
||||
"next-intl": "^4.7.0",
|
||||
@@ -28,7 +27,6 @@
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"sonner": "^2.0.7",
|
||||
"sql.js": "^1.14.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"unstorage": "^1.17.3",
|
||||
"winston": "^3.19.0",
|
||||
@@ -43,7 +41,6 @@
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/sql.js": "^1.4.9",
|
||||
"@typescript-eslint/eslint-plugin": "^8.51.0",
|
||||
"@typescript-eslint/parser": "^8.51.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
|
||||
121
pnpm-lock.yaml
generated
121
pnpm-lock.yaml
generated
@@ -23,7 +23,7 @@ importers:
|
||||
version: 3.0.3
|
||||
better-auth:
|
||||
specifier: ^1.4.10
|
||||
version: 1.4.10(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(@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)
|
||||
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)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -33,9 +33,6 @@ importers:
|
||||
dotenv:
|
||||
specifier: ^17.2.3
|
||||
version: 17.2.3
|
||||
jszip:
|
||||
specifier: ^3.10.1
|
||||
version: 3.10.1
|
||||
lucide-react:
|
||||
specifier: ^0.562.0
|
||||
version: 0.562.0(react@19.2.3)
|
||||
@@ -63,9 +60,6 @@ importers:
|
||||
sonner:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
sql.js:
|
||||
specifier: ^1.14.1
|
||||
version: 1.14.1
|
||||
tailwind-merge:
|
||||
specifier: ^3.4.0
|
||||
version: 3.4.0
|
||||
@@ -84,7 +78,7 @@ importers:
|
||||
devDependencies:
|
||||
'@better-auth/cli':
|
||||
specifier: ^1.4.10
|
||||
version: 1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@types/react@19.2.7)(@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)
|
||||
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)
|
||||
'@eslint/eslintrc':
|
||||
specifier: ^3.3.3
|
||||
version: 3.3.3
|
||||
@@ -103,9 +97,6 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: 19.2.3
|
||||
version: 19.2.3(@types/react@19.2.7)
|
||||
'@types/sql.js':
|
||||
specifier: ^1.4.9
|
||||
version: 1.4.9
|
||||
'@typescript-eslint/eslint-plugin':
|
||||
specifier: ^8.51.0
|
||||
version: 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
@@ -1055,9 +1046,6 @@ packages:
|
||||
'@tybys/wasm-util@0.10.1':
|
||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||
|
||||
'@types/emscripten@1.41.5':
|
||||
resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==}
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
@@ -1084,9 +1072,6 @@ packages:
|
||||
'@types/react@19.2.7':
|
||||
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
|
||||
|
||||
'@types/sql.js@1.4.9':
|
||||
resolution: {integrity: sha512-ep8b36RKHlgWPqjNG9ToUrPiwkhwh0AEzy883mO5Xnd+cL6VBH1EvSjBAAuxLUFF2Vn/moE3Me6v9E1Lo+48GQ==}
|
||||
|
||||
'@types/triple-beam@1.3.5':
|
||||
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
|
||||
|
||||
@@ -1635,9 +1620,6 @@ packages:
|
||||
cookie-es@1.2.2:
|
||||
resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==}
|
||||
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -2236,9 +2218,6 @@ packages:
|
||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
immediate@3.0.6:
|
||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2387,9 +2366,6 @@ packages:
|
||||
resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
isarray@1.0.0:
|
||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||
|
||||
isarray@2.0.5:
|
||||
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
|
||||
|
||||
@@ -2441,9 +2417,6 @@ packages:
|
||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
jszip@3.10.1:
|
||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
@@ -2469,9 +2442,6 @@ packages:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
lie@3.3.0:
|
||||
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||
|
||||
lightningcss-android-arm64@1.30.2:
|
||||
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -2792,9 +2762,6 @@ packages:
|
||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
pako@1.0.11:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
|
||||
parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2933,9 +2900,6 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
||||
prompts@2.4.2:
|
||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -2981,9 +2945,6 @@ packages:
|
||||
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||
|
||||
readable-stream@3.6.2:
|
||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -3044,9 +3005,6 @@ packages:
|
||||
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
|
||||
engines: {node: '>=0.4'}
|
||||
|
||||
safe-buffer@5.1.2:
|
||||
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||
|
||||
safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
@@ -3095,9 +3053,6 @@ packages:
|
||||
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
setimmediate@1.0.5:
|
||||
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||
|
||||
sharp@0.34.5:
|
||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
@@ -3156,9 +3111,6 @@ packages:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
sql.js@1.14.1:
|
||||
resolution: {integrity: sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==}
|
||||
|
||||
sqlstring@2.3.3:
|
||||
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -3199,9 +3151,6 @@ packages:
|
||||
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
string_decoder@1.1.1:
|
||||
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||
|
||||
string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
@@ -3750,7 +3699,7 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@better-auth/cli@1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@types/react@19.2.7)(@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)':
|
||||
'@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)':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
'@babel/preset-react': 7.28.5(@babel/core@7.28.5)
|
||||
@@ -3762,13 +3711,13 @@ snapshots:
|
||||
'@mrleebo/prisma-ast': 0.13.1
|
||||
'@prisma/client': 5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))
|
||||
'@types/pg': 8.15.6
|
||||
better-auth: 1.4.10(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(@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-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-sqlite3: 12.5.0
|
||||
c12: 3.3.2
|
||||
chalk: 5.6.2
|
||||
commander: 12.1.0
|
||||
dotenv: 17.2.3
|
||||
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(@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)
|
||||
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)
|
||||
open: 10.2.0
|
||||
pg: 8.16.3
|
||||
prettier: 3.7.4
|
||||
@@ -4462,8 +4411,6 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@types/emscripten@1.41.5': {}
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
@@ -4492,11 +4439,6 @@ snapshots:
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/sql.js@1.4.9':
|
||||
dependencies:
|
||||
'@types/emscripten': 1.41.5
|
||||
'@types/node': 25.0.3
|
||||
|
||||
'@types/triple-beam@1.3.5': {}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
@@ -4868,7 +4810,7 @@ snapshots:
|
||||
|
||||
bcryptjs@3.0.3: {}
|
||||
|
||||
better-auth@1.4.10(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(@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-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):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)
|
||||
'@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))
|
||||
@@ -4885,7 +4827,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@prisma/client': 5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))
|
||||
better-sqlite3: 12.5.0
|
||||
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(@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)
|
||||
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)
|
||||
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
|
||||
@@ -4893,7 +4835,7 @@ snapshots:
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
|
||||
better-auth@1.4.10(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(@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-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):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)
|
||||
'@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))
|
||||
@@ -4910,7 +4852,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)
|
||||
better-sqlite3: 12.5.0
|
||||
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(@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)
|
||||
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)
|
||||
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
|
||||
@@ -5092,8 +5034,6 @@ snapshots:
|
||||
|
||||
cookie-es@1.2.2: {}
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -5185,13 +5125,12 @@ snapshots:
|
||||
|
||||
dotenv@17.2.3: {}
|
||||
|
||||
drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(@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):
|
||||
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):
|
||||
optionalDependencies:
|
||||
'@electric-sql/pglite': 0.3.15
|
||||
'@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)
|
||||
'@types/pg': 8.15.6
|
||||
'@types/react': 19.2.7
|
||||
'@types/sql.js': 1.4.9
|
||||
better-sqlite3: 12.5.0
|
||||
kysely: 0.28.8
|
||||
mysql2: 3.15.3
|
||||
@@ -5199,7 +5138,6 @@ snapshots:
|
||||
postgres: 3.4.7
|
||||
prisma: 7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)
|
||||
react: 19.2.3
|
||||
sql.js: 1.14.1
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
@@ -5747,8 +5685,6 @@ snapshots:
|
||||
|
||||
ignore@7.0.5: {}
|
||||
|
||||
immediate@3.0.6: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
dependencies:
|
||||
parent-module: 1.0.1
|
||||
@@ -5901,8 +5837,6 @@ snapshots:
|
||||
dependencies:
|
||||
is-inside-container: 1.0.0
|
||||
|
||||
isarray@1.0.0: {}
|
||||
|
||||
isarray@2.0.5: {}
|
||||
|
||||
isexe@2.0.0: {}
|
||||
@@ -5947,13 +5881,6 @@ snapshots:
|
||||
object.assign: 4.1.7
|
||||
object.values: 1.2.1
|
||||
|
||||
jszip@3.10.1:
|
||||
dependencies:
|
||||
lie: 3.3.0
|
||||
pako: 1.0.11
|
||||
readable-stream: 2.3.8
|
||||
setimmediate: 1.0.5
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
@@ -5975,10 +5902,6 @@ snapshots:
|
||||
prelude-ls: 1.2.1
|
||||
type-check: 0.4.0
|
||||
|
||||
lie@3.3.0:
|
||||
dependencies:
|
||||
immediate: 3.0.6
|
||||
|
||||
lightningcss-android-arm64@1.30.2:
|
||||
optional: true
|
||||
|
||||
@@ -6281,8 +6204,6 @@ snapshots:
|
||||
dependencies:
|
||||
p-limit: 3.1.0
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
@@ -6412,8 +6333,6 @@ snapshots:
|
||||
- react
|
||||
- react-dom
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
prompts@2.4.2:
|
||||
dependencies:
|
||||
kleur: 3.0.3
|
||||
@@ -6465,16 +6384,6 @@ snapshots:
|
||||
|
||||
react@19.2.3: {}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
dependencies:
|
||||
core-util-is: 1.0.3
|
||||
inherits: 2.0.4
|
||||
isarray: 1.0.0
|
||||
process-nextick-args: 2.0.1
|
||||
safe-buffer: 5.1.2
|
||||
string_decoder: 1.1.1
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
readable-stream@3.6.2:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
@@ -6543,8 +6452,6 @@ snapshots:
|
||||
has-symbols: 1.1.0
|
||||
isarray: 2.0.5
|
||||
|
||||
safe-buffer@5.1.2: {}
|
||||
|
||||
safe-buffer@5.2.1: {}
|
||||
|
||||
safe-push-apply@1.0.0:
|
||||
@@ -6594,8 +6501,6 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
setimmediate@1.0.5: {}
|
||||
|
||||
sharp@0.34.5:
|
||||
dependencies:
|
||||
'@img/colour': 1.0.0
|
||||
@@ -6685,8 +6590,6 @@ snapshots:
|
||||
|
||||
split2@4.2.0: {}
|
||||
|
||||
sql.js@1.14.1: {}
|
||||
|
||||
sqlstring@2.3.3: {}
|
||||
|
||||
stable-hash@0.0.5: {}
|
||||
@@ -6750,10 +6653,6 @@ snapshots:
|
||||
define-properties: 1.2.1
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
string_decoder@1.1.1:
|
||||
dependencies:
|
||||
safe-buffer: 5.1.2
|
||||
|
||||
string_decoder@1.3.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "user" ADD COLUMN "bio" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "follows" (
|
||||
"id" TEXT NOT NULL,
|
||||
"follower_id" TEXT NOT NULL,
|
||||
"following_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "follows_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "follows_follower_id_idx" ON "follows"("follower_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "follows_following_id_idx" ON "follows"("following_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "follows_follower_id_following_id_key" ON "follows"("follower_id", "following_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "follows" ADD CONSTRAINT "follows_follower_id_fkey" FOREIGN KEY ("follower_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "follows" ADD CONSTRAINT "follows_following_id_fkey" FOREIGN KEY ("following_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,207 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `folder_favorites` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `folders` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `pairs` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CardType" AS ENUM ('NEW', 'LEARNING', 'REVIEW', 'RELEARNING');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CardQueue" AS ENUM ('USER_BURIED', 'SCHED_BURIED', 'SUSPENDED', 'NEW', 'LEARNING', 'REVIEW', 'IN_LEARNING', 'PREVIEW');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NoteKind" AS ENUM ('STANDARD', 'CLOZE');
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "folder_favorites" DROP CONSTRAINT "folder_favorites_folder_id_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "folder_favorites" DROP CONSTRAINT "folder_favorites_user_id_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "folders" DROP CONSTRAINT "folders_user_id_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "pairs" DROP CONSTRAINT "pairs_folder_id_fkey";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "folder_favorites";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "folders";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "pairs";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "note_types" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"kind" "NoteKind" NOT NULL DEFAULT 'STANDARD',
|
||||
"css" TEXT NOT NULL DEFAULT '',
|
||||
"fields" JSONB NOT NULL DEFAULT '[]',
|
||||
"templates" JSONB NOT NULL DEFAULT '[]',
|
||||
"user_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "note_types_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "decks" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"desc" TEXT NOT NULL DEFAULT '',
|
||||
"user_id" TEXT NOT NULL,
|
||||
"visibility" "Visibility" NOT NULL DEFAULT 'PRIVATE',
|
||||
"collapsed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"conf" JSONB NOT NULL DEFAULT '{}',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "decks_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "deck_favorites" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"deck_id" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "deck_favorites_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notes" (
|
||||
"id" BIGINT NOT NULL,
|
||||
"guid" TEXT NOT NULL,
|
||||
"note_type_id" INTEGER NOT NULL,
|
||||
"mod" INTEGER NOT NULL,
|
||||
"usn" INTEGER NOT NULL DEFAULT -1,
|
||||
"tags" TEXT NOT NULL DEFAULT ' ',
|
||||
"flds" TEXT NOT NULL,
|
||||
"sfld" TEXT NOT NULL,
|
||||
"csum" INTEGER NOT NULL,
|
||||
"flags" INTEGER NOT NULL DEFAULT 0,
|
||||
"data" TEXT NOT NULL DEFAULT '',
|
||||
"user_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "notes_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "cards" (
|
||||
"id" BIGINT NOT NULL,
|
||||
"note_id" BIGINT NOT NULL,
|
||||
"deck_id" INTEGER NOT NULL,
|
||||
"ord" INTEGER NOT NULL,
|
||||
"mod" INTEGER NOT NULL,
|
||||
"usn" INTEGER NOT NULL DEFAULT -1,
|
||||
"type" "CardType" NOT NULL DEFAULT 'NEW',
|
||||
"queue" "CardQueue" NOT NULL DEFAULT 'NEW',
|
||||
"due" INTEGER NOT NULL,
|
||||
"ivl" INTEGER NOT NULL DEFAULT 0,
|
||||
"factor" INTEGER NOT NULL DEFAULT 2500,
|
||||
"reps" INTEGER NOT NULL DEFAULT 0,
|
||||
"lapses" INTEGER NOT NULL DEFAULT 0,
|
||||
"left" INTEGER NOT NULL DEFAULT 0,
|
||||
"odue" INTEGER NOT NULL DEFAULT 0,
|
||||
"odid" INTEGER NOT NULL DEFAULT 0,
|
||||
"flags" INTEGER NOT NULL DEFAULT 0,
|
||||
"data" TEXT NOT NULL DEFAULT '',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "cards_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "revlogs" (
|
||||
"id" BIGINT NOT NULL,
|
||||
"card_id" BIGINT NOT NULL,
|
||||
"usn" INTEGER NOT NULL DEFAULT -1,
|
||||
"ease" INTEGER NOT NULL,
|
||||
"ivl" INTEGER NOT NULL,
|
||||
"lastIvl" INTEGER NOT NULL,
|
||||
"factor" INTEGER NOT NULL,
|
||||
"time" INTEGER NOT NULL,
|
||||
"type" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "revlogs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "note_types_user_id_idx" ON "note_types"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "decks_user_id_idx" ON "decks"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "decks_visibility_idx" ON "decks"("visibility");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "deck_favorites_user_id_idx" ON "deck_favorites"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "deck_favorites_deck_id_idx" ON "deck_favorites"("deck_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "deck_favorites_user_id_deck_id_key" ON "deck_favorites"("user_id", "deck_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "notes_guid_key" ON "notes"("guid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notes_user_id_idx" ON "notes"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notes_note_type_id_idx" ON "notes"("note_type_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "notes_csum_idx" ON "notes"("csum");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "cards_note_id_idx" ON "cards"("note_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "cards_deck_id_idx" ON "cards"("deck_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "cards_deck_id_queue_due_idx" ON "cards"("deck_id", "queue", "due");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "revlogs_card_id_idx" ON "revlogs"("card_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "note_types" ADD CONSTRAINT "note_types_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "decks" ADD CONSTRAINT "decks_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "deck_favorites" ADD CONSTRAINT "deck_favorites_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "deck_favorites" ADD CONSTRAINT "deck_favorites_deck_id_fkey" FOREIGN KEY ("deck_id") REFERENCES "decks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notes" ADD CONSTRAINT "notes_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notes" ADD CONSTRAINT "notes_note_type_id_fkey" FOREIGN KEY ("note_type_id") REFERENCES "note_types"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "cards" ADD CONSTRAINT "cards_note_id_fkey" FOREIGN KEY ("note_id") REFERENCES "notes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "cards" ADD CONSTRAINT "cards_deck_id_fkey" FOREIGN KEY ("deck_id") REFERENCES "decks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "revlogs" ADD CONSTRAINT "revlogs_card_id_fkey" FOREIGN KEY ("card_id") REFERENCES "cards"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,3 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "decks" ADD COLUMN "new_per_day" INTEGER NOT NULL DEFAULT 20,
|
||||
ADD COLUMN "rev_per_day" INTEGER NOT NULL DEFAULT 200;
|
||||
@@ -7,10 +7,6 @@ datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// User & Auth
|
||||
// ============================================
|
||||
|
||||
model User {
|
||||
id String @id
|
||||
name String
|
||||
@@ -23,9 +19,11 @@ model User {
|
||||
username String @unique
|
||||
bio String?
|
||||
accounts Account[]
|
||||
decks Deck[]
|
||||
deckFavorites DeckFavorite[]
|
||||
dictionaryLookUps DictionaryLookUp[]
|
||||
folders Folder[]
|
||||
folderFavorites FolderFavorite[]
|
||||
sessions Session[]
|
||||
translationHistories TranslationHistory[]
|
||||
followers Follow[] @relation("UserFollowers")
|
||||
following Follow[] @relation("UserFollowing")
|
||||
|
||||
@@ -79,86 +77,130 @@ model Verification {
|
||||
@@map("verification")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Deck & Card
|
||||
// ============================================
|
||||
model Pair {
|
||||
id Int @id @default(autoincrement())
|
||||
language1 String
|
||||
language2 String
|
||||
text1 String
|
||||
text2 String
|
||||
ipa1 String?
|
||||
ipa2 String?
|
||||
folderId Int @map("folder_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([folderId, language1, language2, text1, text2])
|
||||
@@index([folderId])
|
||||
@@map("pairs")
|
||||
}
|
||||
|
||||
enum Visibility {
|
||||
PUBLIC
|
||||
PRIVATE
|
||||
PUBLIC
|
||||
}
|
||||
|
||||
enum CardType {
|
||||
WORD
|
||||
PHRASE
|
||||
SENTENCE
|
||||
}
|
||||
|
||||
model Deck {
|
||||
model Folder {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
desc String @db.Text @default("")
|
||||
userId String
|
||||
userId String @map("user_id")
|
||||
visibility Visibility @default(PRIVATE)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
cards Card[]
|
||||
favorites DeckFavorite[]
|
||||
pairs Pair[]
|
||||
favorites FolderFavorite[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([visibility])
|
||||
@@map("decks")
|
||||
@@map("folders")
|
||||
}
|
||||
|
||||
model Card {
|
||||
model FolderFavorite {
|
||||
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[]
|
||||
userId String @map("user_id")
|
||||
folderId Int @map("folder_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@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 {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String
|
||||
deckId Int
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
|
||||
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, deckId])
|
||||
@@unique([userId, folderId])
|
||||
@@index([userId])
|
||||
@@index([deckId])
|
||||
@@map("deck_favorites")
|
||||
@@index([folderId])
|
||||
@@map("folder_favorites")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 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])
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([userId])
|
||||
@@index([createdAt])
|
||||
@@index([normalizedText])
|
||||
@@map("dictionary_lookups")
|
||||
}
|
||||
|
||||
model DictionaryItem {
|
||||
id Int @id @default(autoincrement())
|
||||
frequency Int @default(1)
|
||||
standardForm String @map("standard_form")
|
||||
queryLang String @map("query_lang")
|
||||
definitionLang String @map("definition_lang")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
entries DictionaryEntry[]
|
||||
lookups DictionaryLookUp[]
|
||||
|
||||
@@unique([standardForm, queryLang, definitionLang])
|
||||
@@index([standardForm])
|
||||
@@index([queryLang, definitionLang])
|
||||
@@map("dictionary_items")
|
||||
}
|
||||
|
||||
model DictionaryEntry {
|
||||
id Int @id @default(autoincrement())
|
||||
itemId Int @map("item_id")
|
||||
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 {
|
||||
id String @id @default(cuid())
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
/**
|
||||
* 查找缺失的翻译键
|
||||
* 用法: 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();
|
||||
@@ -1,154 +0,0 @@
|
||||
/**
|
||||
* 查找多余的翻译键
|
||||
* 用法: 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();
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -8,7 +9,6 @@ import { Card, CardBody } from "@/design-system/base/card";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { PrimaryButton } from "@/design-system/base/button";
|
||||
import { VStack } from "@/design-system/layout/stack";
|
||||
import { actionRequestPasswordReset } from "@/modules/auth/forgot-password-action";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const t = useTranslations("auth");
|
||||
@@ -23,13 +23,16 @@ export default function ForgotPasswordPage() {
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const result = await actionRequestPasswordReset({ email });
|
||||
const { error } = await authClient.requestPasswordReset({
|
||||
email,
|
||||
redirectTo: "/reset-password",
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.message);
|
||||
if (error) {
|
||||
toast.error(error.message ?? t("resetPasswordFailed"));
|
||||
} else {
|
||||
setSent(true);
|
||||
toast.success(result.message);
|
||||
toast.success(t("resetPasswordEmailSent"));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import { toast } from "sonner";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardBody } from "@/design-system/base/card";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { PrimaryButton, LinkButton } from "@/design-system/base/button";
|
||||
import { PrimaryButton } from "@/design-system/base/button";
|
||||
import { VStack } from "@/design-system/layout/stack";
|
||||
|
||||
export default function LoginPage() {
|
||||
@@ -16,9 +16,6 @@ export default function LoginPage() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [resendLoading, setResendLoading] = useState(false);
|
||||
const [showResendOption, setShowResendOption] = useState(false);
|
||||
const [unverifiedEmail, setUnverifiedEmail] = useState("");
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const redirectTo = searchParams.get("redirect");
|
||||
@@ -28,31 +25,10 @@ export default function LoginPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && session?.user?.username && !redirectTo) {
|
||||
router.push("/decks");
|
||||
router.push("/folders");
|
||||
}
|
||||
}, [session, isPending, router, redirectTo]);
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
if (!unverifiedEmail) return;
|
||||
|
||||
setResendLoading(true);
|
||||
try {
|
||||
const { error } = await authClient.sendVerificationEmail({
|
||||
email: unverifiedEmail,
|
||||
callbackURL: "/login",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(t("resendFailed"));
|
||||
} else {
|
||||
toast.success(t("resendSuccess"));
|
||||
setShowResendOption(false);
|
||||
}
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!username || !password) {
|
||||
toast.error(t("enterCredentials"));
|
||||
@@ -60,7 +36,6 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setShowResendOption(false);
|
||||
try {
|
||||
if (username.includes("@")) {
|
||||
const { error } = await authClient.signIn.email({
|
||||
@@ -68,13 +43,7 @@ export default function LoginPage() {
|
||||
password: password,
|
||||
});
|
||||
if (error) {
|
||||
if (error.status === 403) {
|
||||
setUnverifiedEmail(username);
|
||||
setShowResendOption(true);
|
||||
toast.error(t("emailNotVerified"));
|
||||
} else {
|
||||
toast.error(error.message ?? t("loginFailed"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@@ -83,15 +52,11 @@ export default function LoginPage() {
|
||||
password: password,
|
||||
});
|
||||
if (error) {
|
||||
if (error.status === 403) {
|
||||
toast.error(t("emailNotVerified"));
|
||||
} else {
|
||||
toast.error(error.message ?? t("loginFailed"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
router.push(redirectTo ?? "/decks");
|
||||
router.push(redirectTo ?? "/folders");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -126,21 +91,6 @@ export default function LoginPage() {
|
||||
{t("forgotPassword")}
|
||||
</Link>
|
||||
|
||||
{showResendOption && (
|
||||
<div className="w-full p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg text-sm">
|
||||
<p className="text-yellow-800 dark:text-yellow-200 mb-2">
|
||||
{t("emailNotVerifiedHint")}
|
||||
</p>
|
||||
<LinkButton
|
||||
onClick={handleResendVerification}
|
||||
loading={resendLoading}
|
||||
size="sm"
|
||||
>
|
||||
{t("resendVerification")}
|
||||
</LinkButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PrimaryButton
|
||||
onClick={handleLogin}
|
||||
loading={loading}
|
||||
|
||||
@@ -9,5 +9,5 @@ export default async function ProfilePage() {
|
||||
redirect("/login?redirect=/profile");
|
||||
}
|
||||
|
||||
redirect(session.user.username ? `/users/${session.user.username}` : "/decks");
|
||||
redirect(session.user.username ? `/users/${session.user.username}` : "/folders");
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ export default function SignUpPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [verificationSent, setVerificationSent] = useState(false);
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const redirectTo = searchParams.get("redirect");
|
||||
@@ -27,10 +26,10 @@ export default function SignUpPage() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && session?.user?.username && !redirectTo && !verificationSent) {
|
||||
router.push("/decks");
|
||||
if (!isPending && session?.user?.username && !redirectTo) {
|
||||
router.push("/folders");
|
||||
}
|
||||
}, [session, isPending, router, redirectTo, verificationSent]);
|
||||
}, [session, isPending, router, redirectTo]);
|
||||
|
||||
const handleSignUp = async () => {
|
||||
if (!username || !email || !password) {
|
||||
@@ -50,38 +49,12 @@ export default function SignUpPage() {
|
||||
toast.error(error.message ?? t("signUpFailed"));
|
||||
return;
|
||||
}
|
||||
setVerificationSent(true);
|
||||
toast.success(t("verificationEmailSent"));
|
||||
router.push(redirectTo ?? "/folders");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (verificationSent) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Card className="w-96">
|
||||
<CardBody>
|
||||
<VStack gap={4} align="center" justify="center">
|
||||
<h1 className="text-2xl font-bold text-center w-full">
|
||||
{t("verifyYourEmail")}
|
||||
</h1>
|
||||
<p className="text-center text-gray-600">
|
||||
{t("verificationEmailSentHint", { email })}
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-primary-500 hover:underline"
|
||||
>
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Card className="w-96">
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/design-system/base/button";
|
||||
import { Modal } from "@/design-system/overlay/modal";
|
||||
import { actionDeleteAccount } from "@/modules/auth/auth-action";
|
||||
|
||||
interface DeleteAccountButtonProps {
|
||||
username: string;
|
||||
}
|
||||
|
||||
export function DeleteAccountButton({ username }: DeleteAccountButtonProps) {
|
||||
const t = useTranslations("user_profile");
|
||||
const router = useRouter();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [confirmUsername, setConfirmUsername] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (confirmUsername !== username) {
|
||||
toast.error(t("deleteAccount.usernameMismatch"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await actionDeleteAccount();
|
||||
if (result.success) {
|
||||
toast.success(t("deleteAccount.success"));
|
||||
router.push("/");
|
||||
} else {
|
||||
toast.error(result.message || t("deleteAccount.failed"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("deleteAccount.failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setShowModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="text-xs text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
{t("deleteAccount.button")}
|
||||
</button>
|
||||
|
||||
<Modal open={showModal} onClose={() => setShowModal(false)}>
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-bold text-red-600 mb-4">
|
||||
{t("deleteAccount.title")}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-700">
|
||||
{t("deleteAccount.warning")}
|
||||
</p>
|
||||
|
||||
<ul className="list-disc list-inside text-gray-600 text-sm space-y-1">
|
||||
<li>{t("deleteAccount.warningDecks")}</li>
|
||||
<li>{t("deleteAccount.warningCards")}</li>
|
||||
<li>{t("deleteAccount.warningHistory")}</li>
|
||||
<li>{t("deleteAccount.warningPermanent")}</li>
|
||||
</ul>
|
||||
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{t("deleteAccount.confirmLabel")} <span className="font-mono font-bold">{username}</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={confirmUsername}
|
||||
onChange={(e) => setConfirmUsername(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
placeholder={username}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button variant="secondary" onClick={() => setShowModal(false)}>
|
||||
{t("deleteAccount.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="error"
|
||||
onClick={handleDelete}
|
||||
loading={loading}
|
||||
disabled={confirmUsername !== username}
|
||||
>
|
||||
{t("deleteAccount.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,14 +3,13 @@ import Link from "next/link";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { LinkButton } from "@/design-system/base/button";
|
||||
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
|
||||
import { repoGetDecksByUserId } from "@/modules/deck/deck-repository";
|
||||
import { repoGetFoldersWithTotalPairsByUserId } from "@/modules/folder/folder-repository";
|
||||
import { actionGetFollowStatus } from "@/modules/follow/follow-action";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { FollowStats } from "@/components/follow/FollowStats";
|
||||
import { DeleteAccountButton } from "./DeleteAccountButton";
|
||||
|
||||
interface UserPageProps {
|
||||
params: Promise<{ username: string; }>;
|
||||
@@ -30,8 +29,8 @@ export default async function UserPage({ params }: UserPageProps) {
|
||||
|
||||
const user = result.data;
|
||||
|
||||
const [decks, followStatus] = await Promise.all([
|
||||
repoGetDecksByUserId({ userId: user.id }),
|
||||
const [folders, followStatus] = await Promise.all([
|
||||
repoGetFoldersWithTotalPairsByUserId(user.id),
|
||||
actionGetFollowStatus({ targetUserId: user.id }),
|
||||
]);
|
||||
|
||||
@@ -46,14 +45,7 @@ export default async function UserPage({ params }: UserPageProps) {
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div></div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isOwnProfile && (
|
||||
<>
|
||||
<LinkButton href="/logout">{t("logout")}</LinkButton>
|
||||
<DeleteAccountButton username={username} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isOwnProfile && <LinkButton href="/logout">{t("logout")}</LinkButton>}
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center space-y-4 sm:space-y-0 sm:space-x-6">
|
||||
{user.image ? (
|
||||
@@ -145,45 +137,45 @@ export default async function UserPage({ params }: UserPageProps) {
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("decks.title")}</h2>
|
||||
{decks.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">{t("decks.noDecks")}</p>
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("folders.title")}</h2>
|
||||
{folders.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">{t("folders.noFolders")}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t("decks.deckName")}
|
||||
{t("folders.folderName")}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t("decks.totalCards")}
|
||||
{t("folders.totalPairs")}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t("decks.createdAt")}
|
||||
{t("folders.createdAt")}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t("decks.actions")}
|
||||
{t("folders.actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{decks.map((deck) => (
|
||||
<tr key={deck.id} className="hover:bg-gray-50">
|
||||
{folders.map((folder) => (
|
||||
<tr key={folder.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{deck.name}</div>
|
||||
<div className="text-sm text-gray-500">ID: {deck.id}</div>
|
||||
<div className="text-sm font-medium text-gray-900">{folder.name}</div>
|
||||
<div className="text-sm text-gray-500">ID: {folder.id}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{deck.cardCount ?? 0}</div>
|
||||
<div className="text-sm text-gray-900">{folder.total}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(deck.createdAt).toLocaleDateString()}
|
||||
{new Date(folder.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Link href={`/decks/${deck.id}`}>
|
||||
<Link href={`/folders/${folder.id}`}>
|
||||
<LinkButton>
|
||||
{t("decks.view")}
|
||||
{t("folders.view")}
|
||||
</LinkButton>
|
||||
</Link>
|
||||
</td>
|
||||
|
||||
@@ -7,25 +7,19 @@ import { useDictionaryStore } from "./stores/dictionaryStore";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { Select } from "@/design-system/base/select";
|
||||
import { Skeleton } from "@/design-system/feedback/skeleton";
|
||||
import { HStack, VStack } from "@/design-system/layout/stack";
|
||||
import { Plus, RefreshCw } from "lucide-react";
|
||||
import { DictionaryEntry } from "./DictionaryEntry";
|
||||
import { LanguageSelector } from "./LanguageSelector";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
|
||||
import { actionCreateCard } from "@/modules/card/card-action";
|
||||
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
||||
import type { CardType } from "@/modules/card/card-action-dto";
|
||||
import { actionGetFoldersByUserId, actionCreatePair } from "@/modules/folder/folder-action";
|
||||
import { TSharedFolder } from "@/shared/folder-type";
|
||||
import { toast } from "sonner";
|
||||
import { getNativeName } from "./stores/dictionaryStore";
|
||||
|
||||
interface DictionaryClientProps {
|
||||
initialDecks: ActionOutputDeck[];
|
||||
initialFolders: TSharedFolder[];
|
||||
}
|
||||
|
||||
export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
|
||||
export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
|
||||
const t = useTranslations("dictionary");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -45,8 +39,7 @@ export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
|
||||
} = useDictionaryStore();
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
const [decks, setDecks] = useState<ActionOutputDeck[]>(initialDecks);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [folders, setFolders] = useState<TSharedFolder[]>(initialFolders);
|
||||
|
||||
useEffect(() => {
|
||||
const q = searchParams.get("q") || undefined;
|
||||
@@ -62,9 +55,9 @@ export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user?.id) {
|
||||
actionGetDecksByUserId(session.user.id).then((result) => {
|
||||
actionGetFoldersByUserId(session.user.id).then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setDecks(result.data);
|
||||
setFolders(result.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -86,67 +79,37 @@ export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!session) {
|
||||
toast.error(t("pleaseLogin"));
|
||||
toast.error("Please login first");
|
||||
return;
|
||||
}
|
||||
if (decks.length === 0) {
|
||||
toast.error(t("pleaseCreateFolder"));
|
||||
return;
|
||||
}
|
||||
if (!searchResult?.entries?.length) {
|
||||
toast.error("No dictionary item to save. Please search first.");
|
||||
if (folders.length === 0) {
|
||||
toast.error("Please create a folder first");
|
||||
return;
|
||||
}
|
||||
|
||||
const deckSelect = document.getElementById("deck-select") as HTMLSelectElement;
|
||||
const deckId = deckSelect?.value ? Number(deckSelect.value) : decks[0]?.id;
|
||||
const folderSelect = document.getElementById("folder-select") as HTMLSelectElement;
|
||||
const folderId = folderSelect?.value ? Number(folderSelect.value) : folders[0]?.id;
|
||||
|
||||
if (!deckId) {
|
||||
toast.error("No deck selected");
|
||||
return;
|
||||
}
|
||||
if (!searchResult?.entries?.length) return;
|
||||
|
||||
setIsSaving(true);
|
||||
const definition = searchResult.entries
|
||||
.map((e) => e.definition)
|
||||
.join(" | ");
|
||||
|
||||
try {
|
||||
const hasIpa = searchResult.entries.some((e) => e.ipa);
|
||||
const hasSpaces = searchResult.standardForm.includes(" ");
|
||||
let cardType: CardType = "WORD";
|
||||
if (!hasIpa) {
|
||||
cardType = "SENTENCE";
|
||||
} else if (hasSpaces) {
|
||||
cardType = "PHRASE";
|
||||
}
|
||||
|
||||
const ipa = searchResult.entries.find((e) => e.ipa)?.ipa || null;
|
||||
const meanings = searchResult.entries.map((e) => ({
|
||||
partOfSpeech: e.partOfSpeech || null,
|
||||
definition: e.definition,
|
||||
example: e.example || null,
|
||||
}));
|
||||
|
||||
const cardResult = await actionCreateCard({
|
||||
deckId,
|
||||
word: searchResult.standardForm,
|
||||
ipa,
|
||||
queryLang: getNativeName(queryLang),
|
||||
cardType,
|
||||
meanings,
|
||||
await actionCreatePair({
|
||||
text1: searchResult.standardForm,
|
||||
text2: definition,
|
||||
language1: queryLang,
|
||||
language2: definitionLang,
|
||||
ipa1: searchResult.entries[0]?.ipa,
|
||||
folderId: folderId,
|
||||
});
|
||||
|
||||
if (!cardResult.success) {
|
||||
toast.error(cardResult.message || t("saveFailed"));
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const deckName = decks.find((d) => d.id === deckId)?.name || "Unknown";
|
||||
toast.success(t("savedToFolder", { folderName: deckName }));
|
||||
const folderName = folders.find((f) => f.id === folderId)?.name || "Unknown";
|
||||
toast.success(`Saved to ${folderName}`);
|
||||
} catch (error) {
|
||||
console.error("Save error:", error);
|
||||
toast.error(t("saveFailed"));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
toast.error("Save failed");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -205,14 +168,14 @@ export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
|
||||
|
||||
<div className="mt-8">
|
||||
{isSearching ? (
|
||||
<VStack align="center" className="py-12">
|
||||
<Skeleton variant="circular" className="w-8 h-8 mb-3" />
|
||||
<div className="text-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-primary-500 rounded-full animate-spin mx-auto mb-3"></div>
|
||||
<p className="text-gray-600">{t("searching")}</p>
|
||||
</VStack>
|
||||
</div>
|
||||
) : query && !searchResult ? (
|
||||
<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-600 mt-2">{t("tryOtherWords")}</p>
|
||||
<p className="text-gray-800 text-xl">No results found</p>
|
||||
<p className="text-gray-600 mt-2">Try other words</p>
|
||||
</div>
|
||||
) : searchResult ? (
|
||||
<div className="bg-white rounded-lg p-6 shadow-lg">
|
||||
@@ -222,30 +185,27 @@ export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
|
||||
{searchResult.standardForm}
|
||||
</h2>
|
||||
</div>
|
||||
<HStack align="center" gap={2} className="ml-4">
|
||||
{session && decks.length > 0 && (
|
||||
<Select
|
||||
id="deck-select"
|
||||
variant="bordered"
|
||||
size="sm"
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{session && folders.length > 0 && (
|
||||
<select
|
||||
id="folder-select"
|
||||
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[#35786f]"
|
||||
>
|
||||
{decks.map((deck) => (
|
||||
<option key={deck.id} value={deck.id}>
|
||||
{deck.name}
|
||||
{folders.map((folder) => (
|
||||
<option key={folder.id} value={folder.id}>
|
||||
{folder.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</select>
|
||||
)}
|
||||
<LightButton
|
||||
onClick={handleSave}
|
||||
className="w-10 h-10 shrink-0"
|
||||
title={t("saveToFolder")}
|
||||
loading={isSaving}
|
||||
disabled={isSaving}
|
||||
title="Save to folder"
|
||||
>
|
||||
<Plus />
|
||||
</LightButton>
|
||||
</HStack>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
@@ -263,7 +223,7 @@ export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
|
||||
loading={isSearching}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
{t("relookup")}
|
||||
Re-lookup
|
||||
</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { DictionaryClient } from "./DictionaryClient";
|
||||
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";
|
||||
import { actionGetFoldersByUserId } from "@/modules/folder/folder-action";
|
||||
import { TSharedFolder } from "@/shared/folder-type";
|
||||
|
||||
export default async function DictionaryPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
let decks: ActionOutputDeck[] = [];
|
||||
let folders: TSharedFolder[] = [];
|
||||
|
||||
if (session?.user?.id) {
|
||||
const result = await actionGetDecksByUserId(session.user.id as string);
|
||||
const result = await actionGetFoldersByUserId(session.user.id as string);
|
||||
if (result.success && result.data) {
|
||||
decks = result.data;
|
||||
folders = result.data;
|
||||
}
|
||||
}
|
||||
|
||||
return <DictionaryClient initialDecks={decks} />;
|
||||
return <DictionaryClient initialFolders={folders} />;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Layers,
|
||||
Folder as Fd,
|
||||
Heart,
|
||||
Search,
|
||||
ArrowUpDown,
|
||||
} from "lucide-react";
|
||||
import { CircleButton } from "@/design-system/base/button";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { Skeleton } from "@/design-system/feedback/skeleton";
|
||||
import { HStack } from "@/design-system/layout/stack";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -17,35 +14,35 @@ import { toast } from "sonner";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import {
|
||||
actionSearchPublicDecks,
|
||||
actionToggleDeckFavorite,
|
||||
actionCheckDeckFavorite,
|
||||
} from "@/modules/deck/deck-action";
|
||||
import type { ActionOutputPublicDeck } from "@/modules/deck/deck-action-dto";
|
||||
actionSearchPublicFolders,
|
||||
actionToggleFavorite,
|
||||
actionCheckFavorite,
|
||||
} from "@/modules/folder/folder-action";
|
||||
import { TPublicFolder } from "@/shared/folder-type";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
interface PublicDeckCardProps {
|
||||
deck: ActionOutputPublicDeck;
|
||||
interface PublicFolderCardProps {
|
||||
folder: TPublicFolder;
|
||||
currentUserId?: string;
|
||||
onUpdateFavorite: (deckId: number, isFavorited: boolean, favoriteCount: number) => void;
|
||||
onUpdateFavorite: (folderId: number, isFavorited: boolean, favoriteCount: number) => void;
|
||||
}
|
||||
|
||||
const PublicDeckCard = ({ deck, currentUserId, onUpdateFavorite }: PublicDeckCardProps) => {
|
||||
const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFolderCardProps) => {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("explore");
|
||||
const [isFavorited, setIsFavorited] = useState(false);
|
||||
const [favoriteCount, setFavoriteCount] = useState(deck.favoriteCount);
|
||||
const [favoriteCount, setFavoriteCount] = useState(folder.favoriteCount);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUserId) {
|
||||
actionCheckDeckFavorite({ deckId: deck.id }).then((result) => {
|
||||
actionCheckFavorite(folder.id).then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setIsFavorited(result.data.isFavorited);
|
||||
setFavoriteCount(result.data.favoriteCount);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [deck.id, currentUserId]);
|
||||
}, [folder.id, currentUserId]);
|
||||
|
||||
const handleToggleFavorite = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -53,11 +50,11 @@ const PublicDeckCard = ({ deck, currentUserId, onUpdateFavorite }: PublicDeckCar
|
||||
toast.error(t("pleaseLogin"));
|
||||
return;
|
||||
}
|
||||
const result = await actionToggleDeckFavorite({ deckId: deck.id });
|
||||
const result = await actionToggleFavorite(folder.id);
|
||||
if (result.success && result.data) {
|
||||
setIsFavorited(result.data.isFavorited);
|
||||
setFavoriteCount(result.data.favoriteCount);
|
||||
onUpdateFavorite(deck.id, result.data.isFavorited, result.data.favoriteCount);
|
||||
onUpdateFavorite(folder.id, result.data.isFavorited, result.data.favoriteCount);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
@@ -67,13 +64,13 @@ const PublicDeckCard = ({ deck, currentUserId, onUpdateFavorite }: PublicDeckCar
|
||||
<div
|
||||
className="group bg-white border border-gray-200 sm:border-2 rounded-lg p-3 sm:p-5 hover:border-primary-300 hover:shadow-md cursor-pointer transition-all overflow-hidden"
|
||||
onClick={() => {
|
||||
router.push(`/explore/${deck.id}`);
|
||||
router.push(`/explore/${folder.id}`);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2 sm:mb-3">
|
||||
<div className="shrink-0 w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-primary-50 flex items-center justify-center text-primary-500">
|
||||
<Layers size={18} className="sm:hidden" />
|
||||
<Layers size={22} className="hidden sm:block" />
|
||||
<Fd size={18} className="sm:hidden" />
|
||||
<Fd size={22} className="hidden sm:block" />
|
||||
</div>
|
||||
<CircleButton
|
||||
onClick={handleToggleFavorite}
|
||||
@@ -86,12 +83,12 @@ const PublicDeckCard = ({ deck, currentUserId, onUpdateFavorite }: PublicDeckCar
|
||||
</CircleButton>
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold text-gray-900 truncate text-sm sm:text-base mb-1 sm:mb-2">{deck.name}</h3>
|
||||
<h3 className="font-semibold text-gray-900 truncate text-sm sm:text-base mb-1 sm:mb-2">{folder.name}</h3>
|
||||
|
||||
<p className="text-xs sm:text-sm text-gray-500 mb-2 sm:mb-3 line-clamp-2">
|
||||
{t("deckInfo", {
|
||||
userName: deck.userName ?? deck.userUsername ?? t("unknownUser"),
|
||||
cardCount: deck.cardCount ?? 0,
|
||||
{t("folderInfo", {
|
||||
userName: folder.userName ?? folder.userUsername ?? t("unknownUser"),
|
||||
totalPairs: folder.totalPairs,
|
||||
})}
|
||||
</p>
|
||||
|
||||
@@ -104,13 +101,13 @@ const PublicDeckCard = ({ deck, currentUserId, onUpdateFavorite }: PublicDeckCar
|
||||
};
|
||||
|
||||
interface ExploreClientProps {
|
||||
initialPublicDecks: ActionOutputPublicDeck[];
|
||||
initialPublicFolders: TPublicFolder[];
|
||||
}
|
||||
|
||||
export function ExploreClient({ initialPublicDecks }: ExploreClientProps) {
|
||||
export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
|
||||
const t = useTranslations("explore");
|
||||
const router = useRouter();
|
||||
const [publicDecks, setPublicDecks] = useState<ActionOutputPublicDeck[]>(initialPublicDecks);
|
||||
const [publicFolders, setPublicFolders] = useState<TPublicFolder[]>(initialPublicFolders);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [sortByFavorites, setSortByFavorites] = useState(false);
|
||||
@@ -120,13 +117,13 @@ export function ExploreClient({ initialPublicDecks }: ExploreClientProps) {
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
setPublicDecks(initialPublicDecks);
|
||||
setPublicFolders(initialPublicFolders);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const result = await actionSearchPublicDecks({ query: searchQuery.trim() });
|
||||
const result = await actionSearchPublicFolders(searchQuery.trim());
|
||||
if (result.success && result.data) {
|
||||
setPublicDecks(result.data);
|
||||
setPublicFolders(result.data);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
@@ -135,14 +132,14 @@ export function ExploreClient({ initialPublicDecks }: ExploreClientProps) {
|
||||
setSortByFavorites((prev) => !prev);
|
||||
};
|
||||
|
||||
const sortedDecks = sortByFavorites
|
||||
? [...publicDecks].sort((a, b) => b.favoriteCount - a.favoriteCount)
|
||||
: publicDecks;
|
||||
const sortedFolders = sortByFavorites
|
||||
? [...publicFolders].sort((a, b) => b.favoriteCount - a.favoriteCount)
|
||||
: publicFolders;
|
||||
|
||||
const handleUpdateFavorite = (deckId: number, _isFavorited: boolean, favoriteCount: number) => {
|
||||
setPublicDecks((prev) =>
|
||||
prev.map((d) =>
|
||||
d.id === deckId ? { ...d, favoriteCount } : d
|
||||
const handleUpdateFavorite = (folderId: number, _isFavorited: boolean, favoriteCount: number) => {
|
||||
setPublicFolders((prev) =>
|
||||
prev.map((f) =>
|
||||
f.id === folderId ? { ...f, favoriteCount } : f
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -151,16 +148,18 @@ export function ExploreClient({ initialPublicDecks }: ExploreClientProps) {
|
||||
<PageLayout>
|
||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<HStack align="center" gap={2} className="mb-6">
|
||||
<Input
|
||||
variant="bordered"
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
leftIcon={<Search size={18} />}
|
||||
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
|
||||
onClick={handleToggleSort}
|
||||
title={sortByFavorites ? t("sortByFavoritesActive") : t("sortByFavorites")}
|
||||
@@ -171,26 +170,26 @@ export function ExploreClient({ initialPublicDecks }: ExploreClientProps) {
|
||||
<CircleButton onClick={handleSearch}>
|
||||
<Search size={18} />
|
||||
</CircleButton>
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<Skeleton variant="circular" className="w-8 h-8 mx-auto mb-3" />
|
||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
|
||||
<p className="text-sm text-gray-500">{t("loading")}</p>
|
||||
</div>
|
||||
) : sortedDecks.length === 0 ? (
|
||||
) : sortedFolders.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<Layers size={24} className="text-gray-400" />
|
||||
<Fd size={24} className="text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm">{t("noDecks")}</p>
|
||||
<p className="text-sm">{t("noFolders")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{sortedDecks.map((deck) => (
|
||||
<PublicDeckCard
|
||||
key={deck.id}
|
||||
deck={deck}
|
||||
{sortedFolders.map((folder) => (
|
||||
<PublicFolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
currentUserId={currentUserId}
|
||||
onUpdateFavorite={handleUpdateFavorite}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Layers, Heart, ExternalLink, ArrowLeft } from "lucide-react";
|
||||
import { Folder as Fd, Heart, ExternalLink, ArrowLeft } from "lucide-react";
|
||||
import { CircleButton } from "@/design-system/base/button";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -8,42 +8,42 @@ import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
actionToggleDeckFavorite,
|
||||
actionCheckDeckFavorite,
|
||||
} from "@/modules/deck/deck-action";
|
||||
import type { ActionOutputPublicDeck } from "@/modules/deck/deck-action-dto";
|
||||
actionToggleFavorite,
|
||||
actionCheckFavorite,
|
||||
} from "@/modules/folder/folder-action";
|
||||
import { ActionOutputPublicFolder } from "@/modules/folder/folder-action-dto";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
interface ExploreDetailClientProps {
|
||||
deck: ActionOutputPublicDeck;
|
||||
folder: ActionOutputPublicFolder;
|
||||
}
|
||||
|
||||
export function ExploreDetailClient({ deck }: ExploreDetailClientProps) {
|
||||
export function ExploreDetailClient({ folder }: ExploreDetailClientProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("exploreDetail");
|
||||
const [isFavorited, setIsFavorited] = useState(false);
|
||||
const [favoriteCount, setFavoriteCount] = useState(deck.favoriteCount);
|
||||
const [favoriteCount, setFavoriteCount] = useState(folder.favoriteCount);
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
const currentUserId = session?.user?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUserId) {
|
||||
actionCheckDeckFavorite({ deckId: deck.id }).then((result) => {
|
||||
actionCheckFavorite(folder.id).then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setIsFavorited(result.data.isFavorited);
|
||||
setFavoriteCount(result.data.favoriteCount);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [deck.id, currentUserId]);
|
||||
}, [folder.id, currentUserId]);
|
||||
|
||||
const handleToggleFavorite = async () => {
|
||||
if (!currentUserId) {
|
||||
toast.error(t("pleaseLogin"));
|
||||
return;
|
||||
}
|
||||
const result = await actionToggleDeckFavorite({ deckId: deck.id });
|
||||
const result = await actionToggleFavorite(folder.id);
|
||||
if (result.success && result.data) {
|
||||
setIsFavorited(result.data.isFavorited);
|
||||
setFavoriteCount(result.data.favoriteCount);
|
||||
@@ -79,15 +79,15 @@ export function ExploreDetailClient({ deck }: ExploreDetailClientProps) {
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 sm:w-16 sm:h-16 rounded-xl bg-primary-50 flex items-center justify-center text-primary-500">
|
||||
<Layers size={28} className="sm:w-8 sm:h-8" />
|
||||
<Fd size={28} className="sm:w-8 sm:h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900">
|
||||
{deck.name}
|
||||
{folder.name}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{t("createdBy", {
|
||||
name: deck.userName ?? deck.userUsername ?? t("unknownUser"),
|
||||
name: folder.userName ?? folder.userUsername ?? t("unknownUser"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
@@ -104,19 +104,13 @@ export function ExploreDetailClient({ deck }: ExploreDetailClientProps) {
|
||||
</CircleButton>
|
||||
</div>
|
||||
|
||||
{deck.desc && (
|
||||
<p className="text-gray-600 mb-6 text-sm sm:text-base">
|
||||
{deck.desc}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-6 py-4 border-y border-gray-100">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl sm:text-3xl font-bold text-primary-600">
|
||||
{deck.cardCount ?? 0}
|
||||
{folder.totalPairs}
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm text-gray-500 mt-1">
|
||||
{t("totalCards")}
|
||||
{t("totalPairs")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center border-x border-gray-100">
|
||||
@@ -130,7 +124,7 @@ export function ExploreDetailClient({ deck }: ExploreDetailClientProps) {
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg sm:text-xl font-semibold text-gray-700">
|
||||
{formatDate(deck.createdAt)}
|
||||
{formatDate(folder.createdAt)}
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm text-gray-500 mt-1">
|
||||
{t("createdAt")}
|
||||
@@ -139,7 +133,7 @@ export function ExploreDetailClient({ deck }: ExploreDetailClientProps) {
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/decks/${deck.id}`}
|
||||
href={`/folders/${folder.id}`}
|
||||
className="flex items-center justify-center gap-2 w-full py-3 px-4 bg-primary-500 hover:bg-primary-600 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
<ExternalLink size={18} />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { ExploreDetailClient } from "./ExploreDetailClient";
|
||||
import { actionGetPublicDeckById } from "@/modules/deck/deck-action";
|
||||
import { actionGetPublicFolderById } from "@/modules/folder/folder-action";
|
||||
|
||||
export default async function ExploreDeckPage({
|
||||
export default async function ExploreFolderPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -13,11 +13,11 @@ export default async function ExploreDeckPage({
|
||||
redirect("/explore");
|
||||
}
|
||||
|
||||
const result = await actionGetPublicDeckById({ deckId: Number(id) });
|
||||
const result = await actionGetPublicFolderById(Number(id));
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
redirect("/explore");
|
||||
}
|
||||
|
||||
return <ExploreDetailClient deck={result.data} />;
|
||||
return <ExploreDetailClient folder={result.data} />;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ExploreClient } from "./ExploreClient";
|
||||
import { actionGetPublicDecks } from "@/modules/deck/deck-action";
|
||||
import { actionGetPublicFolders } from "@/modules/folder/folder-action";
|
||||
|
||||
export default async function ExplorePage() {
|
||||
const publicDecksResult = await actionGetPublicDecks();
|
||||
const publicDecks = publicDecksResult.success ? publicDecksResult.data ?? [] : [];
|
||||
const publicFoldersResult = await actionGetPublicFolders();
|
||||
const publicFolders = publicFoldersResult.success ? publicFoldersResult.data ?? [] : [];
|
||||
|
||||
return <ExploreClient initialPublicDecks={publicDecks} />;
|
||||
return <ExploreClient initialPublicFolders={publicFolders} />;
|
||||
}
|
||||
|
||||
@@ -2,24 +2,33 @@
|
||||
|
||||
import {
|
||||
ChevronRight,
|
||||
Layers as DeckIcon,
|
||||
Folder as Fd,
|
||||
Heart,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { CardList } from "@/components/ui/CardList";
|
||||
import { VStack } from "@/design-system/layout/stack";
|
||||
import { Skeleton } from "@/design-system/feedback/skeleton";
|
||||
import { actionGetUserFavoriteDecks, actionToggleDeckFavorite } from "@/modules/deck/deck-action";
|
||||
import type { ActionOutputUserFavoriteDeck } from "@/modules/deck/deck-action-dto";
|
||||
import { actionGetUserFavorites, actionToggleFavorite } from "@/modules/folder/folder-action";
|
||||
|
||||
type UserFavorite = {
|
||||
id: number;
|
||||
folderId: number;
|
||||
folderName: string;
|
||||
folderCreatedAt: Date;
|
||||
folderTotalPairs: number;
|
||||
folderOwnerId: string;
|
||||
folderOwnerName: string | null;
|
||||
folderOwnerUsername: string | null;
|
||||
favoritedAt: Date;
|
||||
};
|
||||
|
||||
interface FavoriteCardProps {
|
||||
favorite: ActionOutputUserFavoriteDeck;
|
||||
onRemoveFavorite: (deckId: number) => void;
|
||||
favorite: UserFavorite;
|
||||
onRemoveFavorite: (folderId: number) => void;
|
||||
}
|
||||
|
||||
const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
|
||||
@@ -32,9 +41,9 @@ const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
|
||||
if (isRemoving) return;
|
||||
|
||||
setIsRemoving(true);
|
||||
const result = await actionToggleDeckFavorite({ deckId: favorite.id });
|
||||
const result = await actionToggleFavorite(favorite.folderId);
|
||||
if (result.success) {
|
||||
onRemoveFavorite(favorite.id);
|
||||
onRemoveFavorite(favorite.folderId);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
@@ -45,20 +54,20 @@ const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
|
||||
<div
|
||||
className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
onClick={() => {
|
||||
router.push(`/explore/${favorite.id}`);
|
||||
router.push(`/explore/${favorite.folderId}`);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="shrink-0 text-primary-500">
|
||||
<DeckIcon size={24} />
|
||||
<Fd size={24} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{favorite.name}</h3>
|
||||
<h3 className="font-semibold text-gray-900 truncate">{favorite.folderName}</h3>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{t("folderInfo", {
|
||||
userName: favorite.userName ?? favorite.userUsername ?? t("unknownUser"),
|
||||
totalPairs: favorite.cardCount ?? 0,
|
||||
userName: favorite.folderOwnerName ?? favorite.folderOwnerUsername ?? t("unknownUser"),
|
||||
totalPairs: favorite.folderTotalPairs,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
@@ -77,25 +86,29 @@ const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
|
||||
};
|
||||
|
||||
interface FavoritesClientProps {
|
||||
initialFavorites: ActionOutputUserFavoriteDeck[];
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export function FavoritesClient({ initialFavorites }: FavoritesClientProps) {
|
||||
export function FavoritesClient({ userId }: FavoritesClientProps) {
|
||||
const t = useTranslations("favorites");
|
||||
const [favorites, setFavorites] = useState<ActionOutputUserFavoriteDeck[]>(initialFavorites);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [favorites, setFavorites] = useState<UserFavorite[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadFavorites();
|
||||
}, [userId]);
|
||||
|
||||
const loadFavorites = async () => {
|
||||
setLoading(true);
|
||||
const result = await actionGetUserFavoriteDecks();
|
||||
const result = await actionGetUserFavorites();
|
||||
if (result.success && result.data) {
|
||||
setFavorites(result.data);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleRemoveFavorite = (deckId: number) => {
|
||||
setFavorites((prev) => prev.filter((f) => f.id !== deckId));
|
||||
const handleRemoveFavorite = (folderId: number) => {
|
||||
setFavorites((prev) => prev.filter((f) => f.folderId !== folderId));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -104,10 +117,10 @@ export function FavoritesClient({ initialFavorites }: FavoritesClientProps) {
|
||||
|
||||
<CardList>
|
||||
{loading ? (
|
||||
<VStack align="center" className="p-8">
|
||||
<Skeleton variant="circular" className="w-8 h-8" />
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
|
||||
<p className="text-sm text-gray-500">{t("loading")}</p>
|
||||
</VStack>
|
||||
</div>
|
||||
) : favorites.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
|
||||
@@ -2,8 +2,6 @@ import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { FavoritesClient } from "./FavoritesClient";
|
||||
import { actionGetUserFavoriteDecks } from "@/modules/deck/deck-action";
|
||||
import type { ActionOutputUserFavoriteDeck } from "@/modules/deck/deck-action-dto";
|
||||
|
||||
export default async function FavoritesPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
@@ -12,11 +10,5 @@ export default async function FavoritesPage() {
|
||||
redirect("/login?redirect=/favorites");
|
||||
}
|
||||
|
||||
let favorites: ActionOutputUserFavoriteDeck[] = [];
|
||||
const result = await actionGetUserFavoriteDecks();
|
||||
if (result.success && result.data) {
|
||||
favorites = result.data;
|
||||
}
|
||||
|
||||
return <FavoritesClient initialFavorites={favorites} />;
|
||||
return <FavoritesClient userId={session.user.id} />;
|
||||
}
|
||||
|
||||
93
src/app/(features)/memorize/FolderSelector.tsx
Normal file
93
src/app/(features)/memorize/FolderSelector.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { Folder as Fd } from "lucide-react";
|
||||
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PrimaryButton } from "@/design-system/base/button";
|
||||
|
||||
interface FolderSelectorProps {
|
||||
folders: TSharedFolderWithTotalPairs[];
|
||||
}
|
||||
|
||||
const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
|
||||
const t = useTranslations("memorize.folder_selector");
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{folders.length === 0 ? (
|
||||
// 空状态 - 显示提示和跳转按钮
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-4">
|
||||
{t("noFolders")}
|
||||
</h1>
|
||||
<Link href="/folders">
|
||||
<PrimaryButton className="px-6 py-2">
|
||||
Go to Folders
|
||||
</PrimaryButton>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 页面标题 */}
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-6">
|
||||
{t("selectFolder")}
|
||||
</h1>
|
||||
{/* 文件夹列表 */}
|
||||
<div className="border border-gray-200 rounded-lg max-h-96 overflow-y-auto">
|
||||
{folders
|
||||
.toSorted((a, b) => a.id - b.id)
|
||||
.map((folder) => (
|
||||
<div
|
||||
key={folder.id}
|
||||
onClick={() =>
|
||||
router.push(`/memorize?folder_id=${folder.id}`)
|
||||
}
|
||||
className="flex flex-row items-center p-4 gap-3 hover:cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0"
|
||||
>
|
||||
{/* 文件夹图标 */}
|
||||
<div className="shrink-0">
|
||||
<Fd className="text-gray-600" size="md" />
|
||||
</div>
|
||||
{/* 文件夹信息 */}
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-900">
|
||||
{folder.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{t("folderInfo", {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
count: folder.total,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* 右箭头 */}
|
||||
<div className="text-gray-400">
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export { FolderSelector };
|
||||
195
src/app/(features)/memorize/Memorize.tsx
Normal file
195
src/app/(features)/memorize/Memorize.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { LinkButton, CircleToggleButton, LightButton } from "@/design-system/base/button";
|
||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
||||
import { useTranslations } from "next-intl";
|
||||
import localFont from "next/font/local";
|
||||
import { isNonNegativeInteger, SeededRandom } from "@/utils/random";
|
||||
import { TSharedPair } from "@/shared/folder-type";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
|
||||
const myFont = localFont({
|
||||
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
|
||||
});
|
||||
|
||||
interface MemorizeProps {
|
||||
textPairs: TSharedPair[];
|
||||
}
|
||||
|
||||
const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
||||
const t = useTranslations("memorize.memorize");
|
||||
const [reverse, setReverse] = useState(false);
|
||||
const [dictation, setDictation] = useState(false);
|
||||
const [disorder, setDisorder] = useState(false);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [show, setShow] = useState<"question" | "answer">("question");
|
||||
const { load, play } = useAudioPlayer();
|
||||
|
||||
if (textPairs.length === 0) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<p className="text-gray-700 text-center">{t("noTextPairs")}</p>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const rng = new SeededRandom(textPairs[0].folderId);
|
||||
const disorderedTextPairs = textPairs.toSorted(() => rng.next() - 0.5);
|
||||
|
||||
textPairs.sort((a, b) => a.id - b.id);
|
||||
|
||||
const getTextPairs = () => disorder ? disorderedTextPairs : textPairs;
|
||||
|
||||
const handleIndexClick = () => {
|
||||
const newIndex = prompt("Input a index number.")?.trim();
|
||||
if (
|
||||
newIndex &&
|
||||
isNonNegativeInteger(newIndex) &&
|
||||
parseInt(newIndex) <= textPairs.length &&
|
||||
parseInt(newIndex) > 0
|
||||
) {
|
||||
setIndex(parseInt(newIndex) - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
if (show === "answer") {
|
||||
const newIndex = (index + 1) % getTextPairs().length;
|
||||
setIndex(newIndex);
|
||||
if (dictation) {
|
||||
const textPair = getTextPairs()[newIndex];
|
||||
const language = textPair[reverse ? "language2" : "language1"];
|
||||
const text = textPair[reverse ? "text2" : "text1"];
|
||||
|
||||
// 映射语言到 TTS 支持的格式
|
||||
const languageMap: Record<string, TTS_SUPPORTED_LANGUAGES> = {
|
||||
"chinese": "Chinese",
|
||||
"english": "English",
|
||||
"japanese": "Japanese",
|
||||
"korean": "Korean",
|
||||
"french": "French",
|
||||
"german": "German",
|
||||
"italian": "Italian",
|
||||
"portuguese": "Portuguese",
|
||||
"spanish": "Spanish",
|
||||
"russian": "Russian",
|
||||
};
|
||||
|
||||
const ttsLanguage = languageMap[language?.toLowerCase()] || "Auto";
|
||||
|
||||
getTTSUrl(text, ttsLanguage).then((url) => {
|
||||
load(url);
|
||||
play();
|
||||
});
|
||||
}
|
||||
}
|
||||
setShow(show === "question" ? "answer" : "question");
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
setIndex(
|
||||
(index - 1 + getTextPairs().length) % getTextPairs().length,
|
||||
);
|
||||
setShow("question");
|
||||
};
|
||||
|
||||
const toggleReverse = () => setReverse(!reverse);
|
||||
const toggleDictation = () => setDictation(!dictation);
|
||||
const toggleDisorder = () => setDisorder(!disorder);
|
||||
|
||||
const createText = (text: string) => {
|
||||
return (
|
||||
<div className="text-gray-900 text-xl md:text-2xl p-6 h-[20dvh] overflow-y-auto text-center">
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const [text1, text2] = reverse
|
||||
? [getTextPairs()[index].text2, getTextPairs()[index].text1]
|
||||
: [getTextPairs()[index].text1, getTextPairs()[index].text2];
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 进度指示器 */}
|
||||
<div className="flex justify-center mb-4">
|
||||
<LinkButton onClick={handleIndexClick} className="text-sm">
|
||||
{index + 1} / {getTextPairs().length}
|
||||
</LinkButton>
|
||||
</div>
|
||||
|
||||
{/* 文本显示区域 */}
|
||||
<div className={`h-[40dvh] ${myFont.className} mb-4`}>
|
||||
{(() => {
|
||||
if (dictation) {
|
||||
if (show === "question") {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-gray-400 text-4xl">?</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{createText(text1)}
|
||||
<div className="border-t border-gray-200"></div>
|
||||
{createText(text2)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (show === "question") {
|
||||
return createText(text1);
|
||||
} else {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{createText(text1)}
|
||||
<div className="border-t border-gray-200"></div>
|
||||
{createText(text2)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="flex flex-row gap-2 items-center justify-center flex-wrap">
|
||||
<LightButton
|
||||
onClick={handleNext}
|
||||
className="px-4 py-2 rounded-full text-sm"
|
||||
>
|
||||
{show === "question" ? t("answer") : t("next")}
|
||||
</LightButton>
|
||||
<LightButton
|
||||
onClick={handlePrevious}
|
||||
className="px-4 py-2 rounded-full text-sm"
|
||||
>
|
||||
{t("previous")}
|
||||
</LightButton>
|
||||
<CircleToggleButton
|
||||
selected={reverse}
|
||||
onClick={toggleReverse}
|
||||
>
|
||||
{t("reverse")}
|
||||
</CircleToggleButton>
|
||||
<CircleToggleButton
|
||||
selected={dictation}
|
||||
onClick={toggleDictation}
|
||||
>
|
||||
{t("dictation")}
|
||||
</CircleToggleButton>
|
||||
<CircleToggleButton
|
||||
selected={disorder}
|
||||
onClick={toggleDisorder}
|
||||
>
|
||||
{t("disorder")}
|
||||
</CircleToggleButton>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export { Memorize };
|
||||
37
src/app/(features)/memorize/page.tsx
Normal file
37
src/app/(features)/memorize/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { isNonNegativeInteger } from "@/utils/random";
|
||||
import { FolderSelector } from "./FolderSelector";
|
||||
import { Memorize } from "./Memorize";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { actionGetFoldersWithTotalPairsByUserId, actionGetPairsByFolderId } from "@/modules/folder/folder-action";
|
||||
|
||||
export default async function MemorizePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ folder_id?: string; }>;
|
||||
}) {
|
||||
const tParam = (await searchParams).folder_id;
|
||||
|
||||
const t = await getTranslations("memorize.page");
|
||||
|
||||
const folder_id = tParam
|
||||
? isNonNegativeInteger(tParam)
|
||||
? parseInt(tParam)
|
||||
: null
|
||||
: null;
|
||||
|
||||
if (!folder_id) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) redirect("/login?redirect=/memorize");
|
||||
|
||||
return (
|
||||
<FolderSelector
|
||||
folders={(await actionGetFoldersWithTotalPairsByUserId(session.user.id)).data!}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Memorize textPairs={(await actionGetPairsByFolderId(folder_id)).data!} />;
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export default function SrtPlayerPage() {
|
||||
uploadVideo((url) => {
|
||||
setVideoUrl(url);
|
||||
}, (error) => {
|
||||
toast.error(srtT('videoUploadFailed') + ': ' + error.message);
|
||||
toast.error(t('videoUploadFailed') + ': ' + error.message);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function SrtPlayerPage() {
|
||||
uploadSubtitle((url) => {
|
||||
setSubtitleUrl(url);
|
||||
}, (error) => {
|
||||
toast.error(srtT('subtitleUploadFailed') + ': ' + error.message);
|
||||
toast.error(t('subtitleUploadFailed') + ': ' + error.message);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { LightButton, IconClick } from "@/design-system/base/button";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { Textarea } from "@/design-system/base/textarea";
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
import { IconClick } from "@/design-system/base/button";
|
||||
import { IMAGES } from "@/config/images";
|
||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||
import {
|
||||
@@ -19,38 +18,6 @@ import { genIPA, genLanguage } from "@/modules/translator/translator-action";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
||||
|
||||
const TTS_LANGUAGES = [
|
||||
{ value: "Auto", label: "auto" },
|
||||
{ value: "Chinese", label: "chinese" },
|
||||
{ value: "English", label: "english" },
|
||||
{ value: "Japanese", label: "japanese" },
|
||||
{ value: "Korean", label: "korean" },
|
||||
{ value: "French", label: "french" },
|
||||
{ value: "German", label: "german" },
|
||||
{ value: "Italian", label: "italian" },
|
||||
{ value: "Spanish", label: "spanish" },
|
||||
{ value: "Portuguese", label: "portuguese" },
|
||||
{ value: "Russian", label: "russian" },
|
||||
] as const;
|
||||
|
||||
type TTSLabel = typeof TTS_LANGUAGES[number]["label"];
|
||||
|
||||
function getLanguageLabel(t: (key: string) => string, label: TTSLabel): string {
|
||||
switch (label) {
|
||||
case "auto": return t("languages.auto");
|
||||
case "chinese": return t("languages.chinese");
|
||||
case "english": return t("languages.english");
|
||||
case "japanese": return t("languages.japanese");
|
||||
case "korean": return t("languages.korean");
|
||||
case "french": return t("languages.french");
|
||||
case "german": return t("languages.german");
|
||||
case "italian": return t("languages.italian");
|
||||
case "spanish": return t("languages.spanish");
|
||||
case "portuguese": return t("languages.portuguese");
|
||||
case "russian": return t("languages.russian");
|
||||
}
|
||||
}
|
||||
|
||||
export default function TextSpeakerPage() {
|
||||
const t = useTranslations("text_speaker");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -63,8 +30,6 @@ export default function TextSpeakerPage() {
|
||||
const [autopause, setAutopause] = useState(true);
|
||||
const textRef = useRef("");
|
||||
const [language, setLanguage] = useState<string | null>(null);
|
||||
const [selectedLanguage, setSelectedLanguage] = useState<string>("Auto");
|
||||
const [customLanguage, setCustomLanguage] = useState<string>("");
|
||||
const [ipa, setIPA] = useState<string>("");
|
||||
const objurlRef = useRef<string | null>(null);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
@@ -128,15 +93,8 @@ export default function TextSpeakerPage() {
|
||||
} else {
|
||||
// 第一次播放
|
||||
try {
|
||||
let theLanguage: string;
|
||||
|
||||
if (customLanguage.trim()) {
|
||||
theLanguage = customLanguage.trim();
|
||||
} else if (selectedLanguage !== "Auto") {
|
||||
theLanguage = selectedLanguage;
|
||||
} else if (language) {
|
||||
theLanguage = language;
|
||||
} else {
|
||||
let theLanguage = language;
|
||||
if (!theLanguage) {
|
||||
const tmp_language = await genLanguage(textRef.current.slice(0, 30));
|
||||
setLanguage(tmp_language);
|
||||
theLanguage = tmp_language;
|
||||
@@ -144,6 +102,7 @@ export default function TextSpeakerPage() {
|
||||
|
||||
theLanguage = theLanguage.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
|
||||
|
||||
// 检查语言是否在 TTS 支持列表中
|
||||
const supportedLanguages: TTS_SUPPORTED_LANGUAGES[] = [
|
||||
"Auto", "Chinese", "English", "German", "Italian", "Portuguese",
|
||||
"Spanish", "Japanese", "Korean", "French", "Russian"
|
||||
@@ -179,8 +138,6 @@ export default function TextSpeakerPage() {
|
||||
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
textRef.current = e.target.value.trim();
|
||||
setLanguage(null);
|
||||
setSelectedLanguage("Auto");
|
||||
setCustomLanguage("");
|
||||
setIPA("");
|
||||
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
||||
objurlRef.current = null;
|
||||
@@ -269,12 +226,11 @@ export default function TextSpeakerPage() {
|
||||
style={{ fontFamily: "Times New Roman, serif" }}
|
||||
>
|
||||
{/* 文本输入框 */}
|
||||
<Textarea
|
||||
variant="bordered"
|
||||
className="text-2xl min-h-64"
|
||||
<textarea
|
||||
className="text-2xl resize-none focus:outline-0 min-h-64 w-full border-gray-200 border-b p-4"
|
||||
onChange={handleInputChange}
|
||||
ref={textareaRef}
|
||||
/>
|
||||
></textarea>
|
||||
{/* IPA 显示区域 */}
|
||||
{(ipa.length !== 0 && (
|
||||
<div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b px-4">
|
||||
@@ -361,40 +317,6 @@ export default function TextSpeakerPage() {
|
||||
alt="save"
|
||||
className={`${saving ? "bg-gray-200" : ""}`}
|
||||
></IconClick>
|
||||
{/* 语言选择器 */}
|
||||
<div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center">
|
||||
<span className="text-sm text-gray-600">{t("language")}</span>
|
||||
{TTS_LANGUAGES.slice(0, 6).map((lang) => (
|
||||
<LightButton
|
||||
key={lang.value}
|
||||
selected={!customLanguage && selectedLanguage === lang.value}
|
||||
onClick={() => {
|
||||
setSelectedLanguage(lang.value);
|
||||
setCustomLanguage("");
|
||||
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
||||
objurlRef.current = null;
|
||||
setPause(true);
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
{getLanguageLabel(t, lang.label)}
|
||||
</LightButton>
|
||||
))}
|
||||
<Input
|
||||
variant="bordered"
|
||||
size="sm"
|
||||
value={customLanguage}
|
||||
onChange={(e) => {
|
||||
setCustomLanguage(e.target.value);
|
||||
setSelectedLanguage("Auto");
|
||||
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
|
||||
objurlRef.current = null;
|
||||
setPause(true);
|
||||
}}
|
||||
placeholder={t("customLanguage")}
|
||||
className="w-auto min-w-[120px]"
|
||||
/>
|
||||
</div>
|
||||
{/* 功能开关按钮 */}
|
||||
<div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center">
|
||||
<LightButton
|
||||
|
||||
@@ -1,85 +1,49 @@
|
||||
"use client";
|
||||
|
||||
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 { LightButton, PrimaryButton, IconClick } from "@/design-system/base/button";
|
||||
import { Select } from "@/design-system/base/select";
|
||||
import { IMAGES } from "@/config/images";
|
||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { actionTranslateText } from "@/modules/translator/translator-action";
|
||||
import { actionCreateCard } from "@/modules/card/card-action";
|
||||
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
|
||||
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
||||
import type { CardType } from "@/modules/card/card-action-dto";
|
||||
import { toast } from "sonner";
|
||||
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
||||
import { TSharedTranslationResult } from "@/shared/translator-type";
|
||||
import { Plus } from "lucide-react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
const SOURCE_LANGUAGES = [
|
||||
{ value: "Auto", label: "auto" },
|
||||
{ value: "Chinese", label: "chinese" },
|
||||
{ value: "English", label: "english" },
|
||||
{ value: "Japanese", label: "japanese" },
|
||||
{ value: "Korean", label: "korean" },
|
||||
{ value: "French", label: "french" },
|
||||
{ value: "German", label: "german" },
|
||||
{ value: "Italian", label: "italian" },
|
||||
{ value: "Spanish", label: "spanish" },
|
||||
{ value: "Portuguese", label: "portuguese" },
|
||||
{ value: "Russian", label: "russian" },
|
||||
{ value: "Auto", labelKey: "auto" },
|
||||
{ value: "Chinese", labelKey: "chinese" },
|
||||
{ value: "English", labelKey: "english" },
|
||||
{ value: "Japanese", labelKey: "japanese" },
|
||||
{ value: "Korean", labelKey: "korean" },
|
||||
{ value: "French", labelKey: "french" },
|
||||
{ value: "German", labelKey: "german" },
|
||||
{ value: "Italian", labelKey: "italian" },
|
||||
{ value: "Spanish", labelKey: "spanish" },
|
||||
{ value: "Portuguese", labelKey: "portuguese" },
|
||||
{ value: "Russian", labelKey: "russian" },
|
||||
] as const;
|
||||
|
||||
const TARGET_LANGUAGES = [
|
||||
{ value: "Chinese", label: "chinese" },
|
||||
{ value: "English", label: "english" },
|
||||
{ value: "Japanese", label: "japanese" },
|
||||
{ value: "Korean", label: "korean" },
|
||||
{ value: "French", label: "french" },
|
||||
{ value: "German", label: "german" },
|
||||
{ value: "Italian", label: "italian" },
|
||||
{ value: "Spanish", label: "spanish" },
|
||||
{ value: "Portuguese", label: "portuguese" },
|
||||
{ value: "Russian", label: "russian" },
|
||||
{ value: "Chinese", labelKey: "chinese" },
|
||||
{ value: "English", labelKey: "english" },
|
||||
{ value: "Japanese", labelKey: "japanese" },
|
||||
{ value: "Korean", labelKey: "korean" },
|
||||
{ value: "French", labelKey: "french" },
|
||||
{ value: "German", labelKey: "german" },
|
||||
{ value: "Italian", labelKey: "italian" },
|
||||
{ value: "Spanish", labelKey: "spanish" },
|
||||
{ value: "Portuguese", labelKey: "portuguese" },
|
||||
{ value: "Russian", labelKey: "russian" },
|
||||
] as const;
|
||||
|
||||
type LangLabel = typeof SOURCE_LANGUAGES[number]["label"];
|
||||
|
||||
function getLangLabel(t: (key: string) => string, label: LangLabel): string {
|
||||
switch (label) {
|
||||
case "auto": return t("auto");
|
||||
case "chinese": return t("chinese");
|
||||
case "english": return t("english");
|
||||
case "japanese": return t("japanese");
|
||||
case "korean": return t("korean");
|
||||
case "french": return t("french");
|
||||
case "german": return t("german");
|
||||
case "italian": return t("italian");
|
||||
case "spanish": return t("spanish");
|
||||
case "portuguese": return t("portuguese");
|
||||
case "russian": return t("russian");
|
||||
}
|
||||
}
|
||||
|
||||
// Estimated button width in pixels (including gap)
|
||||
const BUTTON_WIDTH = 80;
|
||||
const LABEL_WIDTH = 100;
|
||||
const INPUT_WIDTH = 140;
|
||||
const IPA_BUTTON_WIDTH = 100;
|
||||
|
||||
export default function TranslatorPage() {
|
||||
const t = useTranslations("translator");
|
||||
|
||||
const taref = useRef<HTMLTextAreaElement>(null);
|
||||
const sourceContainerRef = useRef<HTMLDivElement>(null);
|
||||
const targetContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [sourceLanguage, setSourceLanguage] = useState<string>("Auto");
|
||||
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
|
||||
const [customSourceLanguage, setCustomSourceLanguage] = useState<string>("");
|
||||
const [customTargetLanguage, setCustomTargetLanguage] = useState<string>("");
|
||||
const [translationResult, setTranslationResult] = useState<TSharedTranslationResult | null>(null);
|
||||
const [needIpa, setNeedIpa] = useState(true);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
@@ -88,51 +52,14 @@ export default function TranslatorPage() {
|
||||
sourceLanguage: string;
|
||||
targetLanguage: string;
|
||||
} | null>(null);
|
||||
const [sourceButtonCount, setSourceButtonCount] = useState(2);
|
||||
const [targetButtonCount, setTargetButtonCount] = useState(2);
|
||||
const { load, play } = useAudioPlayer();
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
const [decks, setDecks] = useState<ActionOutputDeck[]>([]);
|
||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user?.id) {
|
||||
actionGetDecksByUserId(session.user.id).then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setDecks(result.data);
|
||||
}
|
||||
const lastTTS = useRef({
|
||||
text: "",
|
||||
url: "",
|
||||
});
|
||||
}
|
||||
}, [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) => {
|
||||
const tts = async (text: string, locale: string) => {
|
||||
if (lastTTS.current.text !== text) {
|
||||
try {
|
||||
// Map language name to TTS format
|
||||
let theLanguage = locale.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
|
||||
@@ -150,10 +77,13 @@ export default function TranslatorPage() {
|
||||
const url = await getTTSUrl(text, theLanguage as TTS_SUPPORTED_LANGUAGES);
|
||||
await load(url);
|
||||
await play();
|
||||
lastTTS.current.text = text;
|
||||
lastTTS.current.url = url;
|
||||
} catch (error) {
|
||||
toast.error("Failed to generate audio");
|
||||
}
|
||||
}, [load, play]);
|
||||
}
|
||||
};
|
||||
|
||||
const translate = async () => {
|
||||
if (!taref.current || processing) return;
|
||||
@@ -161,30 +91,29 @@ export default function TranslatorPage() {
|
||||
setProcessing(true);
|
||||
|
||||
const sourceText = taref.current.value;
|
||||
const effectiveSourceLanguage = customSourceLanguage.trim() || sourceLanguage;
|
||||
const effectiveTargetLanguage = customTargetLanguage.trim() || targetLanguage;
|
||||
|
||||
// 判断是否需要强制重新翻译
|
||||
// 只有当源文本、源语言和目标语言都与上次相同时,才强制重新翻译
|
||||
const forceRetranslate =
|
||||
lastTranslation?.sourceText === sourceText &&
|
||||
lastTranslation?.sourceLanguage === effectiveSourceLanguage &&
|
||||
lastTranslation?.targetLanguage === effectiveTargetLanguage;
|
||||
lastTranslation?.sourceLanguage === sourceLanguage &&
|
||||
lastTranslation?.targetLanguage === targetLanguage;
|
||||
|
||||
try {
|
||||
const result = await actionTranslateText({
|
||||
sourceText,
|
||||
targetLanguage: effectiveTargetLanguage,
|
||||
targetLanguage,
|
||||
forceRetranslate,
|
||||
needIpa,
|
||||
sourceLanguage: effectiveSourceLanguage === "Auto" ? undefined : effectiveSourceLanguage,
|
||||
sourceLanguage: sourceLanguage === "Auto" ? undefined : sourceLanguage,
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
setTranslationResult(result.data);
|
||||
setLastTranslation({
|
||||
sourceText,
|
||||
sourceLanguage: effectiveSourceLanguage,
|
||||
targetLanguage: effectiveTargetLanguage,
|
||||
sourceLanguage,
|
||||
targetLanguage,
|
||||
});
|
||||
} else {
|
||||
toast.error(result.message || "翻译失败,请重试");
|
||||
@@ -197,66 +126,6 @@ export default function TranslatorPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const visibleSourceButtons = SOURCE_LANGUAGES.slice(0, sourceButtonCount);
|
||||
const visibleTargetButtons = TARGET_LANGUAGES.slice(0, targetButtonCount);
|
||||
|
||||
const handleSaveCard = async () => {
|
||||
if (!session) {
|
||||
toast.error(t("pleaseLogin"));
|
||||
return;
|
||||
}
|
||||
if (decks.length === 0) {
|
||||
toast.error(t("pleaseCreateDeck"));
|
||||
return;
|
||||
}
|
||||
if (!lastTranslation?.sourceText || !translationResult?.translatedText) {
|
||||
toast.error(t("noTranslationToSave"));
|
||||
return;
|
||||
}
|
||||
|
||||
const deckSelect = document.getElementById("deck-select-translator") as HTMLSelectElement;
|
||||
const deckId = deckSelect?.value ? Number(deckSelect.value) : decks[0]?.id;
|
||||
|
||||
if (!deckId) {
|
||||
toast.error(t("noDeckSelected"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const sourceText = lastTranslation.sourceText;
|
||||
const hasSpaces = sourceText.includes(" ");
|
||||
let cardType: CardType = "WORD";
|
||||
if (!translationResult.sourceIpa) {
|
||||
cardType = "SENTENCE";
|
||||
} else if (hasSpaces) {
|
||||
cardType = "PHRASE";
|
||||
}
|
||||
|
||||
await actionCreateCard({
|
||||
deckId,
|
||||
word: sourceText,
|
||||
ipa: translationResult.sourceIpa || null,
|
||||
queryLang: lastTranslation.sourceLanguage,
|
||||
cardType,
|
||||
meanings: [{
|
||||
partOfSpeech: null,
|
||||
definition: translationResult.translatedText,
|
||||
example: null,
|
||||
}],
|
||||
});
|
||||
|
||||
const deckName = decks.find((d) => d.id === deckId)?.name || "Unknown";
|
||||
toast.success(t("savedToDeck", { deckName }));
|
||||
setShowSaveModal(false);
|
||||
} catch (error) {
|
||||
toast.error(t("saveFailed"));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-64px)] bg-white">
|
||||
{/* TCard Component */}
|
||||
@@ -265,13 +134,13 @@ export default function TranslatorPage() {
|
||||
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
|
||||
{/* ICard1 Component */}
|
||||
<div className="border border-gray-200 rounded-lg w-full h-64 p-2">
|
||||
<Textarea
|
||||
className="resize-none h-8/12 w-full"
|
||||
<textarea
|
||||
className="resize-none h-8/12 w-full focus:outline-0"
|
||||
ref={taref}
|
||||
onKeyDown={(e) => {
|
||||
if (e.ctrlKey && e.key === "Enter") translate();
|
||||
}}
|
||||
/>
|
||||
></textarea>
|
||||
<div className="ipa w-full h-2/12 overflow-auto text-gray-600">
|
||||
{translationResult?.sourceIpa || ""}
|
||||
</div>
|
||||
@@ -289,36 +158,49 @@ export default function TranslatorPage() {
|
||||
src={IMAGES.play_arrow}
|
||||
alt="play"
|
||||
onClick={() => {
|
||||
const text = taref.current?.value;
|
||||
if (!text) return;
|
||||
tts(text, translationResult?.sourceLanguage || "");
|
||||
const t = taref.current?.value;
|
||||
if (!t) return;
|
||||
tts(t, translationResult?.sourceLanguage || "");
|
||||
}}
|
||||
></IconClick>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={sourceContainerRef} className="option1 w-full flex gap-1 items-center overflow-x-auto">
|
||||
<div className="option1 w-full flex gap-1 items-center overflow-x-auto">
|
||||
<span className="shrink-0">{t("sourceLanguage")}</span>
|
||||
{visibleSourceButtons.map((lang) => (
|
||||
<LightButton
|
||||
key={lang.value}
|
||||
selected={!customSourceLanguage && sourceLanguage === lang.value}
|
||||
onClick={() => {
|
||||
setSourceLanguage(lang.value);
|
||||
setCustomSourceLanguage("");
|
||||
}}
|
||||
className="shrink-0"
|
||||
selected={sourceLanguage === "Auto"}
|
||||
onClick={() => setSourceLanguage("Auto")}
|
||||
className="shrink-0 hidden lg:inline-flex"
|
||||
>
|
||||
{getLangLabel(t, lang.label)}
|
||||
{t("auto")}
|
||||
</LightButton>
|
||||
))}
|
||||
<Input
|
||||
variant="bordered"
|
||||
<LightButton
|
||||
selected={sourceLanguage === "Chinese"}
|
||||
onClick={() => setSourceLanguage("Chinese")}
|
||||
className="shrink-0 hidden lg:inline-flex"
|
||||
>
|
||||
{t("chinese")}
|
||||
</LightButton>
|
||||
<LightButton
|
||||
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"
|
||||
value={customSourceLanguage}
|
||||
onChange={(e) => setCustomSourceLanguage(e.target.value)}
|
||||
placeholder={t("customLanguage")}
|
||||
className="w-auto min-w-[120px] shrink-0"
|
||||
/>
|
||||
className="w-auto min-w-[100px] shrink-0"
|
||||
>
|
||||
{SOURCE_LANGUAGES.map((lang) => (
|
||||
<option key={lang.value} value={lang.value}>
|
||||
{t(lang.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<div className="flex-1"></div>
|
||||
<LightButton
|
||||
selected={needIpa}
|
||||
@@ -359,35 +241,48 @@ export default function TranslatorPage() {
|
||||
></IconClick>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={targetContainerRef} className="option2 w-full flex gap-1 items-center overflow-x-auto">
|
||||
<div className="option2 w-full flex gap-1 items-center overflow-x-auto">
|
||||
<span className="shrink-0">{t("translateInto")}</span>
|
||||
{visibleTargetButtons.map((lang) => (
|
||||
<LightButton
|
||||
key={lang.value}
|
||||
selected={!customTargetLanguage && targetLanguage === lang.value}
|
||||
onClick={() => {
|
||||
setTargetLanguage(lang.value);
|
||||
setCustomTargetLanguage("");
|
||||
}}
|
||||
className="shrink-0"
|
||||
selected={targetLanguage === "Chinese"}
|
||||
onClick={() => setTargetLanguage("Chinese")}
|
||||
className="shrink-0 hidden lg:inline-flex"
|
||||
>
|
||||
{getLangLabel(t, lang.label)}
|
||||
{t("chinese")}
|
||||
</LightButton>
|
||||
))}
|
||||
<Input
|
||||
variant="bordered"
|
||||
<LightButton
|
||||
selected={targetLanguage === "English"}
|
||||
onClick={() => setTargetLanguage("English")}
|
||||
className="shrink-0 hidden lg:inline-flex"
|
||||
>
|
||||
{t("english")}
|
||||
</LightButton>
|
||||
<LightButton
|
||||
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"
|
||||
value={customTargetLanguage}
|
||||
onChange={(e) => setCustomTargetLanguage(e.target.value)}
|
||||
placeholder={t("customLanguage")}
|
||||
className="w-auto min-w-[120px] shrink-0"
|
||||
/>
|
||||
className="w-auto min-w-[100px] shrink-0"
|
||||
>
|
||||
{TARGET_LANGUAGES.map((lang) => (
|
||||
<option key={lang.value} value={lang.value}>
|
||||
{t(lang.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TranslateButton Component */}
|
||||
<div className="w-screen flex justify-center items-center gap-4">
|
||||
<div className="w-screen flex justify-center items-center">
|
||||
<PrimaryButton
|
||||
onClick={translate}
|
||||
disabled={processing}
|
||||
@@ -396,49 +291,7 @@ export default function TranslatorPage() {
|
||||
>
|
||||
{t("translate")}
|
||||
</PrimaryButton>
|
||||
{translationResult && session && decks.length > 0 && (
|
||||
<CircleButton
|
||||
onClick={() => setShowSaveModal(true)}
|
||||
title={t("saveAsCard")}
|
||||
>
|
||||
<Plus size={20} />
|
||||
</CircleButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSaveModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
||||
<h2 className="text-xl font-semibold mb-4">{t("saveAsCard")}</h2>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t("selectDeck")}
|
||||
</label>
|
||||
<Select id="deck-select-translator" className="w-full">
|
||||
{decks.map((deck) => (
|
||||
<option key={deck.id} value={deck.id}>
|
||||
{deck.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="mb-4 p-3 bg-gray-50 rounded text-sm">
|
||||
<div className="font-medium mb-1">{t("front")}:</div>
|
||||
<div className="text-gray-700 mb-2">{lastTranslation?.sourceText}</div>
|
||||
<div className="font-medium mb-1">{t("back")}:</div>
|
||||
<div className="text-gray-700">{translationResult?.translatedText}</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<LightButton onClick={() => setShowSaveModal(false)}>
|
||||
{t("cancel")}
|
||||
</LightButton>
|
||||
<PrimaryButton onClick={handleSaveCard} loading={isSaving}>
|
||||
{t("save")}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { LightButton, PrimaryButton } from "@/design-system/base/button";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { Select } from "@/design-system/base/select";
|
||||
import { Textarea } from "@/design-system/base/textarea";
|
||||
import { Modal } from "@/design-system/overlay/modal";
|
||||
import { VStack, HStack } from "@/design-system/layout/stack";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { actionCreateCard } from "@/modules/card/card-action";
|
||||
import type { CardType, CardMeaning } from "@/modules/card/card-action-dto";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const QUERY_LANGUAGE_LABELS = {
|
||||
english: "english",
|
||||
chinese: "chinese",
|
||||
japanese: "japanese",
|
||||
korean: "korean",
|
||||
} as const;
|
||||
|
||||
const QUERY_LANGUAGES = [
|
||||
{ value: "en", label: "english" as const },
|
||||
{ value: "zh", label: "chinese" as const },
|
||||
{ value: "ja", label: "japanese" as const },
|
||||
{ value: "ko", label: "korean" as const },
|
||||
] as const;
|
||||
|
||||
interface AddCardModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
deckId: number;
|
||||
onAdded: () => void;
|
||||
}
|
||||
|
||||
export function AddCardModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
deckId,
|
||||
onAdded,
|
||||
}: AddCardModalProps) {
|
||||
const t = useTranslations("deck_id");
|
||||
|
||||
const [cardType, setCardType] = useState<CardType>("WORD");
|
||||
const [word, setWord] = useState("");
|
||||
const [ipa, setIpa] = useState("");
|
||||
const [queryLang, setQueryLang] = useState("en");
|
||||
const [customQueryLang, setCustomQueryLang] = useState("");
|
||||
const [meanings, setMeanings] = useState<CardMeaning[]>([
|
||||
{ partOfSpeech: null, definition: "", example: null }
|
||||
]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const showIpa = cardType === "WORD" || cardType === "PHRASE";
|
||||
|
||||
const addMeaning = () => {
|
||||
setMeanings([...meanings, { partOfSpeech: null, definition: "", example: null }]);
|
||||
};
|
||||
|
||||
const removeMeaning = (index: number) => {
|
||||
if (meanings.length > 1) {
|
||||
setMeanings(meanings.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
const updateMeaning = (
|
||||
index: number,
|
||||
field: "partOfSpeech" | "definition" | "example",
|
||||
value: string
|
||||
) => {
|
||||
const updated = [...meanings];
|
||||
updated[index] = {
|
||||
...updated[index],
|
||||
[field]: value || null
|
||||
};
|
||||
setMeanings(updated);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setCardType("WORD");
|
||||
setWord("");
|
||||
setIpa("");
|
||||
setQueryLang("en");
|
||||
setCustomQueryLang("");
|
||||
setMeanings([{ partOfSpeech: null, definition: "", example: null }]);
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!word.trim()) {
|
||||
toast.error(t("wordRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
const validMeanings = meanings.filter(m => m.definition?.trim());
|
||||
if (validMeanings.length === 0) {
|
||||
toast.error(t("definitionRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
const effectiveQueryLang = customQueryLang.trim() || queryLang;
|
||||
|
||||
try {
|
||||
const cardResult = await actionCreateCard({
|
||||
deckId,
|
||||
word: word.trim(),
|
||||
ipa: showIpa && ipa.trim() ? ipa.trim() : null,
|
||||
queryLang: effectiveQueryLang,
|
||||
cardType,
|
||||
meanings: validMeanings.map(m => ({
|
||||
partOfSpeech: cardType === "SENTENCE" ? null : (m.partOfSpeech?.trim() || null),
|
||||
definition: m.definition!.trim(),
|
||||
example: m.example?.trim() || null,
|
||||
})),
|
||||
});
|
||||
|
||||
if (!cardResult.success) {
|
||||
throw new Error(cardResult.message || "Failed to create card");
|
||||
}
|
||||
|
||||
resetForm();
|
||||
onAdded();
|
||||
onClose();
|
||||
toast.success(t("cardAdded") || "Card added successfully");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} onClose={handleClose} size="md">
|
||||
<Modal.Header>
|
||||
<Modal.Title>{t("addNewCard")}</Modal.Title>
|
||||
<Modal.CloseButton onClick={handleClose} />
|
||||
</Modal.Header>
|
||||
|
||||
<Modal.Body className="space-y-4">
|
||||
<HStack gap={3}>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t("cardType")}
|
||||
</label>
|
||||
<Select
|
||||
value={cardType}
|
||||
onChange={(e) => setCardType(e.target.value as CardType)}
|
||||
className="w-full"
|
||||
>
|
||||
<option value="WORD">{t("wordCard")}</option>
|
||||
<option value="PHRASE">{t("phraseCard")}</option>
|
||||
<option value="SENTENCE">{t("sentenceCard")}</option>
|
||||
</Select>
|
||||
</div>
|
||||
</HStack>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t("queryLang")}
|
||||
</label>
|
||||
<HStack gap={2} className="flex-wrap">
|
||||
{QUERY_LANGUAGES.map((lang) => (
|
||||
<LightButton
|
||||
key={lang.value}
|
||||
selected={!customQueryLang && queryLang === lang.value}
|
||||
onClick={() => {
|
||||
setQueryLang(lang.value);
|
||||
setCustomQueryLang("");
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
{t(lang.label)}
|
||||
</LightButton>
|
||||
))}
|
||||
<Input
|
||||
value={customQueryLang}
|
||||
onChange={(e) => setCustomQueryLang(e.target.value)}
|
||||
placeholder={t("enterLanguageName")}
|
||||
className="w-auto min-w-[100px] flex-1"
|
||||
size="sm"
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{cardType === "SENTENCE" ? t("sentence") : t("word")} *
|
||||
</label>
|
||||
<Input
|
||||
value={word}
|
||||
onChange={(e) => setWord(e.target.value)}
|
||||
className="w-full"
|
||||
placeholder={cardType === "SENTENCE" ? t("sentencePlaceholder") : t("wordPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showIpa && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t("ipa")}
|
||||
</label>
|
||||
<Input
|
||||
value={ipa}
|
||||
onChange={(e) => setIpa(e.target.value)}
|
||||
className="w-full"
|
||||
placeholder={t("ipaPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<HStack justify="between" className="mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t("meanings")} *
|
||||
</label>
|
||||
<button
|
||||
onClick={addMeaning}
|
||||
className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("addMeaning")}
|
||||
</button>
|
||||
</HStack>
|
||||
|
||||
<VStack gap={4}>
|
||||
{meanings.map((meaning, index) => (
|
||||
<div key={index} className="p-3 bg-gray-50 rounded-lg space-y-2">
|
||||
<HStack gap={2}>
|
||||
{cardType !== "SENTENCE" && (
|
||||
<div className="w-28 shrink-0">
|
||||
<Input
|
||||
value={meaning.partOfSpeech || ""}
|
||||
onChange={(e) => updateMeaning(index, "partOfSpeech", e.target.value)}
|
||||
placeholder={t("partOfSpeech")}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={meaning.definition || ""}
|
||||
onChange={(e) => updateMeaning(index, "definition", e.target.value)}
|
||||
placeholder={t("definition")}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
{meanings.length > 1 && (
|
||||
<button
|
||||
onClick={() => removeMeaning(index)}
|
||||
className="p-2 text-gray-400 hover:text-red-500"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</HStack>
|
||||
<Textarea
|
||||
value={meaning.example || ""}
|
||||
onChange={(e) => updateMeaning(index, "example", e.target.value)}
|
||||
placeholder={t("examplePlaceholder")}
|
||||
className="w-full min-h-[40px] text-sm"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</VStack>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
<LightButton onClick={handleClose}>
|
||||
{t("cancel")}
|
||||
</LightButton>
|
||||
<PrimaryButton onClick={handleAdd} loading={isSubmitting}>
|
||||
{isSubmitting ? t("adding") : t("add")}
|
||||
</PrimaryButton>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { Trash2, Pencil } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { CircleButton } from "@/design-system/base/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { ActionOutputCard, CardType } from "@/modules/card/card-action-dto";
|
||||
import { toast } from "sonner";
|
||||
import { actionDeleteCard } from "@/modules/card/card-action";
|
||||
import { EditCardModal } from "./EditCardModal";
|
||||
|
||||
interface CardItemProps {
|
||||
card: ActionOutputCard;
|
||||
isReadOnly: boolean;
|
||||
onDel: () => void;
|
||||
onUpdated: () => void;
|
||||
}
|
||||
|
||||
const CARD_TYPE_LABELS: Record<CardType, string> = {
|
||||
WORD: "Word",
|
||||
PHRASE: "Phrase",
|
||||
SENTENCE: "Sentence",
|
||||
};
|
||||
|
||||
export function CardItem({
|
||||
card,
|
||||
isReadOnly,
|
||||
onDel,
|
||||
onUpdated,
|
||||
}: CardItemProps) {
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const t = useTranslations("deck_id");
|
||||
|
||||
const frontText = card.word;
|
||||
const backText = card.meanings.map((m) =>
|
||||
m.partOfSpeech ? `${m.partOfSpeech}: ${m.definition}` : m.definition
|
||||
).join("; ");
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
const result = await actionDeleteCard({ cardId: card.id });
|
||||
if (result.success) {
|
||||
toast.success(t("cardDeleted"));
|
||||
onDel();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||
}
|
||||
setShowDeleteConfirm(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="group border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
||||
{t("card")}
|
||||
</span>
|
||||
<span className="px-2 py-1 bg-blue-50 text-blue-600 rounded-md">
|
||||
{CARD_TYPE_LABELS[card.cardType]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-50 group-hover:opacity-100 transition-opacity">
|
||||
{!isReadOnly && (
|
||||
<>
|
||||
<CircleButton
|
||||
onClick={() => setShowEditModal(true)}
|
||||
title={t("edit")}
|
||||
className="text-gray-400 hover:text-blue-500 hover:bg-blue-50"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
title={t("delete")}
|
||||
className="text-gray-400 hover:text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</CircleButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-gray-900 grid grid-cols-2 gap-4 w-3/4">
|
||||
<div>
|
||||
{frontText.length > 30
|
||||
? frontText.substring(0, 30) + "..."
|
||||
: frontText}
|
||||
</div>
|
||||
<div>
|
||||
{backText.length > 30
|
||||
? backText.substring(0, 30) + "..."
|
||||
: backText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-4 max-w-sm mx-4">
|
||||
<p className="text-gray-700 mb-4">{t("deleteConfirm")}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="px-3 py-1 text-gray-600 hover:bg-gray-100 rounded"
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="px-3 py-1 text-red-600 hover:bg-red-50 rounded"
|
||||
>
|
||||
{t("delete")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EditCardModal
|
||||
isOpen={showEditModal}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
card={card}
|
||||
onUpdated={onUpdated}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Plus } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CardItem } from "./CardItem";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PrimaryButton, CircleButton, LinkButton } from "@/design-system/base/button";
|
||||
import { CardList } from "@/components/ui/CardList";
|
||||
import { VStack } from "@/design-system/layout/stack";
|
||||
import { Skeleton } from "@/design-system/feedback/skeleton";
|
||||
import { actionGetCardsByDeckId, actionDeleteCard } from "@/modules/card/card-action";
|
||||
import { actionGetDeckById } from "@/modules/deck/deck-action";
|
||||
import type { ActionOutputCard } from "@/modules/card/card-action-dto";
|
||||
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
||||
import { toast } from "sonner";
|
||||
import { AddCardModal } from "./AddCardModal";
|
||||
|
||||
export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boolean }) {
|
||||
const [cards, setCards] = useState<ActionOutputCard[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [openAddModal, setAddModal] = useState(false);
|
||||
const [deckInfo, setDeckInfo] = useState<ActionOutputDeck | null>(null);
|
||||
const router = useRouter();
|
||||
const t = useTranslations("deck_id");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCards = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [cardsResult, deckResult] = await Promise.all([
|
||||
actionGetCardsByDeckId({ deckId }),
|
||||
actionGetDeckById({ deckId }),
|
||||
]);
|
||||
|
||||
if (!cardsResult.success || !cardsResult.data) {
|
||||
throw new Error(cardsResult.message || "Failed to load cards");
|
||||
}
|
||||
setCards(cardsResult.data);
|
||||
|
||||
if (deckResult.success && deckResult.data) {
|
||||
setDeckInfo(deckResult.data);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchCards();
|
||||
}, [deckId]);
|
||||
|
||||
const refreshCards = async () => {
|
||||
const result = await actionGetCardsByDeckId({ deckId });
|
||||
if (result.success && result.data) {
|
||||
setCards(result.data);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCard = async (cardId: number) => {
|
||||
try {
|
||||
const result = await actionDeleteCard({ cardId });
|
||||
if (result.success) {
|
||||
toast.success(t("cardDeleted"));
|
||||
await refreshCards();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="mb-6">
|
||||
<LinkButton
|
||||
onClick={router.back}
|
||||
className="flex items-center gap-2 mb-4"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span className="text-sm">{t("back")}</span>
|
||||
</LinkButton>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-1">
|
||||
{deckInfo?.name || t("cards")}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("itemsCount", { count: cards.length })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<PrimaryButton
|
||||
onClick={() => {
|
||||
router.push(`/decks/${deckId}/learn`);
|
||||
}}
|
||||
>
|
||||
{t("memorize")}
|
||||
</PrimaryButton>
|
||||
{!isReadOnly && (
|
||||
<CircleButton
|
||||
onClick={() => {
|
||||
setAddModal(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={18} className="text-gray-700" />
|
||||
</CircleButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardList>
|
||||
{loading ? (
|
||||
<VStack align="center" className="p-8">
|
||||
<Skeleton variant="circular" className="w-8 h-8" />
|
||||
<p className="text-sm text-gray-500">{t("loadingCards")}</p>
|
||||
</VStack>
|
||||
) : cards.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<p className="text-sm text-gray-500 mb-2">{t("noCards")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{cards.map((card) => (
|
||||
<CardItem
|
||||
key={card.id}
|
||||
card={card}
|
||||
isReadOnly={isReadOnly}
|
||||
onDel={() => handleDeleteCard(card.id)}
|
||||
onUpdated={refreshCards}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardList>
|
||||
|
||||
<AddCardModal
|
||||
isOpen={openAddModal}
|
||||
onClose={() => setAddModal(false)}
|
||||
deckId={deckId}
|
||||
onAdded={refreshCards}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,468 +0,0 @@
|
||||
"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 };
|
||||
@@ -1,34 +0,0 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { InDeck } from "./InDeck";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { actionGetDeckById } from "@/modules/deck/deck-action";
|
||||
|
||||
export default async function DecksPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ deck_id: number; }>;
|
||||
}) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
const { deck_id } = await params;
|
||||
const t = await getTranslations("deck_id");
|
||||
|
||||
if (!deck_id) {
|
||||
redirect("/decks");
|
||||
}
|
||||
|
||||
const deckInfo = (await actionGetDeckById({ deckId: Number(deck_id) })).data;
|
||||
|
||||
if (!deckInfo) {
|
||||
redirect("/decks");
|
||||
}
|
||||
|
||||
const isOwner = session?.user?.id === deckInfo.userId;
|
||||
const isPublic = deckInfo.visibility === "PUBLIC";
|
||||
|
||||
if (!isOwner && !isPublic) {
|
||||
redirect("/decks");
|
||||
}
|
||||
|
||||
const isReadOnly = !isOwner;
|
||||
|
||||
return <InDeck deckId={Number(deck_id)} isReadOnly={isReadOnly} />;
|
||||
}
|
||||
@@ -2,16 +2,14 @@
|
||||
|
||||
import {
|
||||
ChevronRight,
|
||||
Layers,
|
||||
Pencil,
|
||||
Plus,
|
||||
Folder as Fd,
|
||||
FolderPen,
|
||||
FolderPlus,
|
||||
Globe,
|
||||
Lock,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { CircleButton, LightButton } from "@/design-system/base/button";
|
||||
import { Skeleton } from "@/design-system/feedback/skeleton";
|
||||
import { VStack } from "@/design-system/layout/stack";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -20,33 +18,30 @@ import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { CardList } from "@/components/ui/CardList";
|
||||
import {
|
||||
actionCreateDeck,
|
||||
actionDeleteDeck,
|
||||
actionGetDecksByUserId,
|
||||
actionUpdateDeck,
|
||||
actionGetDeckById,
|
||||
} from "@/modules/deck/deck-action";
|
||||
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
||||
actionCreateFolder,
|
||||
actionDeleteFolderById,
|
||||
actionGetFoldersWithTotalPairsByUserId,
|
||||
actionRenameFolderById,
|
||||
actionSetFolderVisibility,
|
||||
} from "@/modules/folder/folder-action";
|
||||
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
|
||||
|
||||
interface DeckCardProps {
|
||||
deck: ActionOutputDeck;
|
||||
onUpdateDeck: (deckId: number, updates: Partial<ActionOutputDeck>) => void;
|
||||
onDeleteDeck: (deckId: number) => void;
|
||||
interface FolderCardProps {
|
||||
folder: TSharedFolderWithTotalPairs;
|
||||
onUpdateFolder: (folderId: number, updates: Partial<TSharedFolderWithTotalPairs>) => void;
|
||||
onDeleteFolder: (folderId: number) => void;
|
||||
}
|
||||
|
||||
const DeckCard = ({ deck, onUpdateDeck, onDeleteDeck }: DeckCardProps) => {
|
||||
const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps) => {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("decks");
|
||||
const t = useTranslations("folders");
|
||||
|
||||
const handleToggleVisibility = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const newVisibility = deck.visibility === "PUBLIC" ? "PRIVATE" : "PUBLIC";
|
||||
const result = await actionUpdateDeck({
|
||||
deckId: deck.id,
|
||||
visibility: newVisibility,
|
||||
});
|
||||
const newVisibility = folder.visibility === "PUBLIC" ? "PRIVATE" : "PUBLIC";
|
||||
const result = await actionSetFolderVisibility(folder.id, newVisibility);
|
||||
if (result.success) {
|
||||
onUpdateDeck(deck.id, { visibility: newVisibility });
|
||||
onUpdateFolder(folder.id, { visibility: newVisibility });
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
@@ -56,12 +51,9 @@ const DeckCard = ({ deck, onUpdateDeck, onDeleteDeck }: DeckCardProps) => {
|
||||
e.stopPropagation();
|
||||
const newName = prompt(t("enterNewName"))?.trim();
|
||||
if (newName && newName.length > 0) {
|
||||
const result = await actionUpdateDeck({
|
||||
deckId: deck.id,
|
||||
name: newName,
|
||||
});
|
||||
const result = await actionRenameFolderById(folder.id, newName);
|
||||
if (result.success) {
|
||||
onUpdateDeck(deck.id, { name: newName });
|
||||
onUpdateFolder(folder.id, { name: newName });
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
@@ -70,11 +62,11 @@ const DeckCard = ({ deck, onUpdateDeck, onDeleteDeck }: DeckCardProps) => {
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const confirm = prompt(t("confirmDelete", { name: deck.name }));
|
||||
if (confirm === deck.name) {
|
||||
const result = await actionDeleteDeck({ deckId: deck.id });
|
||||
const confirm = prompt(t("confirmDelete", { name: folder.name }));
|
||||
if (confirm === folder.name) {
|
||||
const result = await actionDeleteFolderById(folder.id);
|
||||
if (result.success) {
|
||||
onDeleteDeck(deck.id);
|
||||
onDeleteFolder(folder.id);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
@@ -85,31 +77,31 @@ const DeckCard = ({ deck, onUpdateDeck, onDeleteDeck }: DeckCardProps) => {
|
||||
<div
|
||||
className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
onClick={() => {
|
||||
router.push(`/decks/${deck.id}`);
|
||||
router.push(`/folders/${folder.id}`);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="shrink-0 text-primary-500">
|
||||
<Layers size={24} />
|
||||
<Fd size={24} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{deck.name}</h3>
|
||||
<h3 className="font-semibold text-gray-900 truncate">{folder.name}</h3>
|
||||
<span className="flex items-center gap-1 text-xs text-gray-400">
|
||||
{deck.visibility === "PUBLIC" ? (
|
||||
{folder.visibility === "PUBLIC" ? (
|
||||
<Globe size={12} />
|
||||
) : (
|
||||
<Lock size={12} />
|
||||
)}
|
||||
{deck.visibility === "PUBLIC" ? t("public") : t("private")}
|
||||
{folder.visibility === "PUBLIC" ? t("public") : t("private")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{t("deckInfo", {
|
||||
id: deck.id,
|
||||
name: deck.name,
|
||||
totalCards: deck.cardCount ?? 0,
|
||||
{t("folderInfo", {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
totalPairs: folder.total,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
@@ -118,16 +110,16 @@ const DeckCard = ({ deck, onUpdateDeck, onDeleteDeck }: DeckCardProps) => {
|
||||
<div className="flex items-center gap-1 ml-4">
|
||||
<CircleButton
|
||||
onClick={handleToggleVisibility}
|
||||
title={deck.visibility === "PUBLIC" ? t("setPrivate") : t("setPublic")}
|
||||
title={folder.visibility === "PUBLIC" ? t("setPrivate") : t("setPublic")}
|
||||
>
|
||||
{deck.visibility === "PUBLIC" ? (
|
||||
{folder.visibility === "PUBLIC" ? (
|
||||
<Lock size={18} />
|
||||
) : (
|
||||
<Globe size={18} />
|
||||
)}
|
||||
</CircleButton>
|
||||
<CircleButton onClick={handleRename}>
|
||||
<Pencil size={18} />
|
||||
<FolderPen size={18} />
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
onClick={handleDelete}
|
||||
@@ -141,49 +133,46 @@ const DeckCard = ({ deck, onUpdateDeck, onDeleteDeck }: DeckCardProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
interface DecksClientProps {
|
||||
interface FoldersClientProps {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export function DecksClient({ userId }: DecksClientProps) {
|
||||
const t = useTranslations("decks");
|
||||
export function FoldersClient({ userId }: FoldersClientProps) {
|
||||
const t = useTranslations("folders");
|
||||
const router = useRouter();
|
||||
const [decks, setDecks] = useState<ActionOutputDeck[]>([]);
|
||||
const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadDecks = async () => {
|
||||
const loadFolders = async () => {
|
||||
setLoading(true);
|
||||
const result = await actionGetDecksByUserId(userId);
|
||||
const result = await actionGetFoldersWithTotalPairsByUserId(userId);
|
||||
if (result.success && result.data) {
|
||||
setDecks(result.data);
|
||||
setFolders(result.data);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadDecks();
|
||||
loadFolders();
|
||||
}, [userId]);
|
||||
|
||||
const handleUpdateDeck = (deckId: number, updates: Partial<ActionOutputDeck>) => {
|
||||
setDecks((prev) =>
|
||||
prev.map((d) => (d.id === deckId ? { ...d, ...updates } : d))
|
||||
const handleUpdateFolder = (folderId: number, updates: Partial<TSharedFolderWithTotalPairs>) => {
|
||||
setFolders((prev) =>
|
||||
prev.map((f) => (f.id === folderId ? { ...f, ...updates } : f))
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteDeck = (deckId: number) => {
|
||||
setDecks((prev) => prev.filter((d) => d.id !== deckId));
|
||||
const handleDeleteFolder = (folderId: number) => {
|
||||
setFolders((prev) => prev.filter((f) => f.id !== folderId));
|
||||
};
|
||||
|
||||
const handleCreateDeck = async () => {
|
||||
const deckName = prompt(t("enterDeckName"));
|
||||
if (!deckName?.trim()) return;
|
||||
const handleCreateFolder = async () => {
|
||||
const folderName = prompt(t("enterFolderName"));
|
||||
if (!folderName?.trim()) return;
|
||||
|
||||
const result = await actionCreateDeck({ name: deckName.trim() });
|
||||
if (result.success && result.deckId) {
|
||||
const deckResult = await actionGetDeckById({ deckId: result.deckId });
|
||||
if (deckResult.success && deckResult.data) {
|
||||
setDecks((prev) => [...prev, deckResult.data!]);
|
||||
}
|
||||
const result = await actionCreateFolder(userId, folderName.trim());
|
||||
if (result.success) {
|
||||
loadFolders();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
@@ -193,33 +182,33 @@ export function DecksClient({ userId }: DecksClientProps) {
|
||||
<PageLayout>
|
||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<div className="mb-4 flex gap-2">
|
||||
<LightButton onClick={handleCreateDeck}>
|
||||
<Plus size={18} />
|
||||
{t("newDeck")}
|
||||
<div className="mb-4">
|
||||
<LightButton onClick={handleCreateFolder}>
|
||||
<FolderPlus size={18} />
|
||||
{t("newFolder")}
|
||||
</LightButton>
|
||||
</div>
|
||||
|
||||
<CardList>
|
||||
{loading ? (
|
||||
<VStack align="center" className="p-8">
|
||||
<Skeleton variant="circular" className="w-8 h-8 mb-3" />
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
|
||||
<p className="text-sm text-gray-500">{t("loading")}</p>
|
||||
</VStack>
|
||||
) : decks.length === 0 ? (
|
||||
</div>
|
||||
) : folders.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<Layers size={24} className="text-gray-400" />
|
||||
<Fd size={24} className="text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm">{t("noDecksYet")}</p>
|
||||
<p className="text-sm">{t("noFoldersYet")}</p>
|
||||
</div>
|
||||
) : (
|
||||
decks.map((deck) => (
|
||||
<DeckCard
|
||||
key={deck.id}
|
||||
deck={deck}
|
||||
onUpdateDeck={handleUpdateDeck}
|
||||
onDeleteDeck={handleDeleteDeck}
|
||||
folders.map((folder) => (
|
||||
<FolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
onUpdateFolder={handleUpdateFolder}
|
||||
onDeleteFolder={handleDeleteFolder}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
99
src/app/folders/[folder_id]/AddTextPairModal.tsx
Normal file
99
src/app/folders/[folder_id]/AddTextPairModal.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { LocaleSelector } from "@/components/ui/LocaleSelector";
|
||||
import { X } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface AddTextPairModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onAdd: (
|
||||
text1: string,
|
||||
text2: string,
|
||||
language1: string,
|
||||
language2: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function AddTextPairModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onAdd,
|
||||
}: AddTextPairModalProps) {
|
||||
const t = useTranslations("folder_id");
|
||||
const input1Ref = useRef<HTMLInputElement>(null);
|
||||
const input2Ref = useRef<HTMLInputElement>(null);
|
||||
const [language1, setLanguage1] = useState("english");
|
||||
const [language2, setLanguage2] = useState("chinese");
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleAdd = () => {
|
||||
if (
|
||||
!input1Ref.current?.value ||
|
||||
!input2Ref.current?.value ||
|
||||
!language1 ||
|
||||
!language2
|
||||
)
|
||||
return;
|
||||
|
||||
const text1 = input1Ref.current.value;
|
||||
const text2 = input2Ref.current.value;
|
||||
|
||||
if (
|
||||
typeof text1 === "string" &&
|
||||
typeof text2 === "string" &&
|
||||
typeof language1 === "string" &&
|
||||
typeof language2 === "string" &&
|
||||
text1.trim() !== "" &&
|
||||
text2.trim() !== "" &&
|
||||
language1.trim() !== "" &&
|
||||
language2.trim() !== ""
|
||||
) {
|
||||
onAdd(text1, text2, language1, language2);
|
||||
input1Ref.current.value = "";
|
||||
input2Ref.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAdd();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="bg-white rounded-md p-6 w-full max-w-md mx-4">
|
||||
<div className="flex">
|
||||
<h2 className="flex-1 text-xl font-light mb-4 text-center">
|
||||
{t("addNewTextPair")}
|
||||
</h2>
|
||||
<X onClick={onClose} className="hover:cursor-pointer"></X>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
{t("text1")}
|
||||
<Input ref={input1Ref} className="w-full"></Input>
|
||||
</div>
|
||||
<div>
|
||||
{t("text2")}
|
||||
<Input ref={input2Ref} className="w-full"></Input>
|
||||
</div>
|
||||
<div>
|
||||
{t("language1")}
|
||||
<LocaleSelector value={language1} onChange={setLanguage1} />
|
||||
</div>
|
||||
<div>
|
||||
{t("language2")}
|
||||
<LocaleSelector value={language2} onChange={setLanguage2} />
|
||||
</div>
|
||||
</div>
|
||||
<LightButton onClick={handleAdd}>{t("add")}</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
src/app/folders/[folder_id]/InFolder.tsx
Normal file
165
src/app/folders/[folder_id]/InFolder.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Plus } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { redirect, useRouter } from "next/navigation";
|
||||
import { AddTextPairModal } from "./AddTextPairModal";
|
||||
import { TextPairCard } from "./TextPairCard";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PrimaryButton, CircleButton, LinkButton } from "@/design-system/base/button";
|
||||
import { CardList } from "@/components/ui/CardList";
|
||||
import { actionCreatePair, actionDeletePairById, actionGetPairsByFolderId } from "@/modules/folder/folder-action";
|
||||
import { TSharedPair } from "@/shared/folder-type";
|
||||
import { toast } from "sonner";
|
||||
|
||||
|
||||
export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnly: boolean; }) {
|
||||
const [textPairs, setTextPairs] = useState<TSharedPair[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [openAddModal, setAddModal] = useState(false);
|
||||
const router = useRouter();
|
||||
const t = useTranslations("folder_id");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTextPairs = async () => {
|
||||
setLoading(true);
|
||||
await actionGetPairsByFolderId(folderId)
|
||||
.then(result => {
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.message || "Failed to load text pairs");
|
||||
}
|
||||
return result.data;
|
||||
}).then(setTextPairs)
|
||||
.catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
fetchTextPairs();
|
||||
}, [folderId]);
|
||||
|
||||
const refreshTextPairs = async () => {
|
||||
await actionGetPairsByFolderId(folderId)
|
||||
.then(result => {
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.message || "Failed to refresh text pairs");
|
||||
}
|
||||
return result.data;
|
||||
}).then(setTextPairs)
|
||||
.catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 顶部导航和标题栏 */}
|
||||
<div className="mb-6">
|
||||
{/* 返回按钮 */}
|
||||
<LinkButton
|
||||
onClick={router.back}
|
||||
className="flex items-center gap-2 mb-4"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span className="text-sm">{t("back")}</span>
|
||||
</LinkButton>
|
||||
|
||||
{/* 页面标题和操作按钮 */}
|
||||
<div className="flex items-center justify-between">
|
||||
{/* 标题区域 */}
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-1">
|
||||
{t("textPairs")}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("itemsCount", { count: textPairs.length })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮区域 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<PrimaryButton
|
||||
onClick={() => {
|
||||
redirect(`/memorize?folder_id=${folderId}`);
|
||||
}}
|
||||
>
|
||||
{t("memorize")}
|
||||
</PrimaryButton>
|
||||
{!isReadOnly && (
|
||||
<CircleButton
|
||||
onClick={() => {
|
||||
setAddModal(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={18} className="text-gray-700" />
|
||||
</CircleButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文本对列表 */}
|
||||
<CardList>
|
||||
{loading ? (
|
||||
// 加载状态
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
|
||||
<p className="text-sm text-gray-500">{t("loadingTextPairs")}</p>
|
||||
</div>
|
||||
) : textPairs.length === 0 ? (
|
||||
// 空状态
|
||||
<div className="p-12 text-center">
|
||||
<p className="text-sm text-gray-500 mb-2">{t("noTextPairs")}</p>
|
||||
</div>
|
||||
) : (
|
||||
// 文本对卡片列表
|
||||
<div className="divide-y divide-gray-100">
|
||||
{textPairs
|
||||
.toSorted((a, b) => a.id - b.id)
|
||||
.map((textPair) => (
|
||||
<TextPairCard
|
||||
key={textPair.id}
|
||||
textPair={textPair}
|
||||
isReadOnly={isReadOnly}
|
||||
onDel={() => {
|
||||
actionDeletePairById(textPair.id)
|
||||
.then(result => {
|
||||
if (!result.success) throw new Error(result.message || "Delete failed");
|
||||
}).then(refreshTextPairs)
|
||||
.catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||
});
|
||||
}}
|
||||
refreshTextPairs={refreshTextPairs}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardList>
|
||||
|
||||
{/* 添加文本对模态框 */}
|
||||
<AddTextPairModal
|
||||
isOpen={openAddModal}
|
||||
onClose={() => setAddModal(false)}
|
||||
onAdd={async (
|
||||
text1: string,
|
||||
text2: string,
|
||||
language1: string,
|
||||
language2: string,
|
||||
) => {
|
||||
await actionCreatePair({
|
||||
text1: text1,
|
||||
text2: text2,
|
||||
language1: language1,
|
||||
language2: language2,
|
||||
folderId: folderId,
|
||||
});
|
||||
refreshTextPairs();
|
||||
}}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
86
src/app/folders/[folder_id]/TextPairCard.tsx
Normal file
86
src/app/folders/[folder_id]/TextPairCard.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Edit, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { CircleButton } from "@/design-system/base/button";
|
||||
import { UpdateTextPairModal } from "./UpdateTextPairModal";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { TSharedPair } from "@/shared/folder-type";
|
||||
import { actionUpdatePairById } from "@/modules/folder/folder-action";
|
||||
import { ActionInputUpdatePairById } from "@/modules/folder/folder-action-dto";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface TextPairCardProps {
|
||||
textPair: TSharedPair;
|
||||
isReadOnly: boolean;
|
||||
onDel: () => void;
|
||||
refreshTextPairs: () => void;
|
||||
}
|
||||
|
||||
export function TextPairCard({
|
||||
textPair,
|
||||
isReadOnly,
|
||||
onDel,
|
||||
refreshTextPairs,
|
||||
}: TextPairCardProps) {
|
||||
const [openUpdateModal, setOpenUpdateModal] = useState(false);
|
||||
const t = useTranslations("folder_id");
|
||||
return (
|
||||
<div className="group border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
||||
{textPair.language1.toUpperCase()}
|
||||
</span>
|
||||
<span>→</span>
|
||||
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
||||
{textPair.language2.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-50 group-hover:opacity-100 transition-opacity">
|
||||
{!isReadOnly && (
|
||||
<>
|
||||
<CircleButton
|
||||
onClick={() => setOpenUpdateModal(true)}
|
||||
title={t("edit")}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<Edit size={14} />
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
onClick={onDel}
|
||||
title={t("delete")}
|
||||
className="text-gray-400 hover:text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</CircleButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-gray-900 grid grid-cols-2 gap-4 w-3/4">
|
||||
<div>
|
||||
{textPair.text1.length > 30
|
||||
? textPair.text1.substring(0, 30) + "..."
|
||||
: textPair.text1}
|
||||
</div>
|
||||
<div>
|
||||
{textPair.text2.length > 30
|
||||
? textPair.text2.substring(0, 30) + "..."
|
||||
: textPair.text2}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UpdateTextPairModal
|
||||
isOpen={openUpdateModal}
|
||||
onClose={() => setOpenUpdateModal(false)}
|
||||
onUpdate={async (id: number, data: ActionInputUpdatePairById) => {
|
||||
await actionUpdatePairById(id, data).then(result => result.success ? toast.success(result.message) : toast.error(result.message));
|
||||
setOpenUpdateModal(false);
|
||||
refreshTextPairs();
|
||||
}}
|
||||
textPair={textPair}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
src/app/folders/[folder_id]/UpdateTextPairModal.tsx
Normal file
103
src/app/folders/[folder_id]/UpdateTextPairModal.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { LocaleSelector } from "@/components/ui/LocaleSelector";
|
||||
import { X } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { TSharedPair } from "@/shared/folder-type";
|
||||
import { ActionInputUpdatePairById } from "@/modules/folder/folder-action-dto";
|
||||
|
||||
interface UpdateTextPairModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
textPair: TSharedPair;
|
||||
onUpdate: (id: number, tp: ActionInputUpdatePairById) => void;
|
||||
}
|
||||
|
||||
export function UpdateTextPairModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onUpdate,
|
||||
textPair,
|
||||
}: UpdateTextPairModalProps) {
|
||||
const t = useTranslations("folder_id");
|
||||
const input1Ref = useRef<HTMLInputElement>(null);
|
||||
const input2Ref = useRef<HTMLInputElement>(null);
|
||||
const [language1, setLanguage1] = useState(textPair.language1);
|
||||
const [language2, setLanguage2] = useState(textPair.language2);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleUpdate = () => {
|
||||
if (
|
||||
!input1Ref.current?.value ||
|
||||
!input2Ref.current?.value ||
|
||||
!language1 ||
|
||||
!language2
|
||||
)
|
||||
return;
|
||||
|
||||
const text1 = input1Ref.current.value;
|
||||
const text2 = input2Ref.current.value;
|
||||
|
||||
if (
|
||||
typeof text1 === "string" &&
|
||||
typeof text2 === "string" &&
|
||||
typeof language1 === "string" &&
|
||||
typeof language2 === "string" &&
|
||||
text1.trim() !== "" &&
|
||||
text2.trim() !== "" &&
|
||||
language1.trim() !== "" &&
|
||||
language2.trim() !== ""
|
||||
) {
|
||||
onUpdate(textPair.id, { text1, text2, language1, language2 });
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleUpdate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="bg-white rounded-md p-6 w-full max-w-md mx-4">
|
||||
<div className="flex">
|
||||
<h2 className="flex-1 text-xl font-light mb-4 text-center">
|
||||
{t("updateTextPair")}
|
||||
</h2>
|
||||
<X onClick={onClose} className="hover:cursor-pointer"></X>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
{t("text1")}
|
||||
<Input
|
||||
defaultValue={textPair.text1}
|
||||
ref={input1Ref}
|
||||
className="w-full"
|
||||
></Input>
|
||||
</div>
|
||||
<div>
|
||||
{t("text2")}
|
||||
<Input
|
||||
defaultValue={textPair.text2}
|
||||
ref={input2Ref}
|
||||
className="w-full"
|
||||
></Input>
|
||||
</div>
|
||||
<div>
|
||||
{t("language1")}
|
||||
<LocaleSelector value={language1} onChange={setLanguage1} />
|
||||
</div>
|
||||
<div>
|
||||
{t("language2")}
|
||||
<LocaleSelector value={language2} onChange={setLanguage2} />
|
||||
</div>
|
||||
</div>
|
||||
<LightButton onClick={handleUpdate}>{t("update")}</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/app/folders/[folder_id]/page.tsx
Normal file
37
src/app/folders/[folder_id]/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { InFolder } from "./InFolder";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { actionGetFolderVisibility } from "@/modules/folder/folder-action";
|
||||
|
||||
export default async function FoldersPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ folder_id: number; }>;
|
||||
}) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
const { folder_id } = await params;
|
||||
const t = await getTranslations("folder_id");
|
||||
|
||||
if (!folder_id) {
|
||||
redirect("/folders");
|
||||
}
|
||||
|
||||
const folderInfo = (await actionGetFolderVisibility(Number(folder_id))).data;
|
||||
|
||||
if (!folderInfo) {
|
||||
redirect("/folders");
|
||||
}
|
||||
|
||||
const isOwner = session?.user?.id === folderInfo.userId;
|
||||
const isPublic = folderInfo.visibility === "PUBLIC";
|
||||
|
||||
if (!isOwner && !isPublic) {
|
||||
redirect("/folders");
|
||||
}
|
||||
|
||||
const isReadOnly = !isOwner;
|
||||
|
||||
return <InFolder folderId={Number(folder_id)} isReadOnly={isReadOnly} />;
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import { auth } from "@/auth";
|
||||
import { DecksClient } from "./DecksClient";
|
||||
import { FoldersClient } from "./FoldersClient";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function DecksPage() {
|
||||
export default async function FoldersPage() {
|
||||
const session = await auth.api.getSession(
|
||||
{ headers: await headers() }
|
||||
);
|
||||
|
||||
if (!session) {
|
||||
redirect("/login?redirect=/decks");
|
||||
redirect("/login?redirect=/folders");
|
||||
}
|
||||
|
||||
return <DecksClient userId={session.user.id} />;
|
||||
return <FoldersClient userId={session.user.id} />;
|
||||
}
|
||||
@@ -99,18 +99,18 @@
|
||||
* 定义全局 CSS 变量用于主题切换和动态样式
|
||||
*/
|
||||
:root {
|
||||
/* 主题色 - 默认 Mist */
|
||||
--primary-50: #f7f8fa;
|
||||
--primary-100: #eef1f5;
|
||||
--primary-200: #dce2eb;
|
||||
--primary-300: #c4cdd9;
|
||||
--primary-400: #a3b0c1;
|
||||
--primary-500: #8594a8;
|
||||
--primary-600: #6b7a8d;
|
||||
--primary-700: #596474;
|
||||
--primary-800: #4b5360;
|
||||
--primary-900: #414850;
|
||||
--primary-950: #22262b;
|
||||
/* 主题色 - 默认 Teal */
|
||||
--primary-50: #f0f9f8;
|
||||
--primary-100: #e0f2f0;
|
||||
--primary-200: #bce6e1;
|
||||
--primary-300: #8dd4cc;
|
||||
--primary-400: #5ec2b7;
|
||||
--primary-500: #35786f;
|
||||
--primary-600: #2a605b;
|
||||
--primary-700: #1f4844;
|
||||
--primary-800: #183835;
|
||||
--primary-900: #122826;
|
||||
--primary-950: #0a1413;
|
||||
|
||||
/* 基础颜色 */
|
||||
--background: #ffffff;
|
||||
@@ -126,7 +126,7 @@
|
||||
/* 边框 */
|
||||
--border: #d1d5db;
|
||||
--border-secondary: #e5e7eb;
|
||||
--border-focus: #8594a8;
|
||||
--border-focus: #35786f;
|
||||
|
||||
/* 圆角 - 更小的圆角 */
|
||||
--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-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||
--shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
--shadow-primary: 0 4px 14px 0 rgba(133, 148, 168, 0.39);
|
||||
--shadow-primary: 0 4px 14px 0 rgba(53, 120, 111, 0.39);
|
||||
|
||||
/* 间距 */
|
||||
--spacing-xs: 0.25rem;
|
||||
@@ -177,7 +177,7 @@ body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--primary-50);
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-geist-sans), -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
font-size: 1rem;
|
||||
|
||||
@@ -73,7 +73,7 @@ export default async function HomePage() {
|
||||
color="#dd7486"
|
||||
></LinkArea>
|
||||
<LinkArea
|
||||
href="/decks"
|
||||
href="/memorize"
|
||||
name={t("memorize.name")}
|
||||
description={t("memorize.description")}
|
||||
color="#cc9988"
|
||||
|
||||
40
src/auth.ts
40
src/auth.ts
@@ -4,10 +4,6 @@ import { nextCookies } from "better-auth/next-js";
|
||||
import { username } from "better-auth/plugins";
|
||||
import { createAuthMiddleware, APIError } from "better-auth/api";
|
||||
import { prisma } from "./lib/db";
|
||||
import { createLogger } from "./lib/logger";
|
||||
|
||||
const log = createLogger("auth");
|
||||
|
||||
import {
|
||||
sendEmail,
|
||||
generateVerificationEmailHtml,
|
||||
@@ -22,28 +18,21 @@ export const auth = betterAuth({
|
||||
enabled: true,
|
||||
requireEmailVerification: true,
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
const result = await sendEmail({
|
||||
void sendEmail({
|
||||
to: user.email,
|
||||
subject: "重置您的密码 - Learn Languages",
|
||||
html: generateResetPasswordEmailHtml(url, user.name || "用户"),
|
||||
});
|
||||
if (!result.success) {
|
||||
log.error("Failed to send reset password email", { error: result.error });
|
||||
}
|
||||
},
|
||||
},
|
||||
emailVerification: {
|
||||
sendOnSignUp: true,
|
||||
sendOnSignIn: true,
|
||||
sendVerificationEmail: async ({ user, url }) => {
|
||||
const result = await sendEmail({
|
||||
void sendEmail({
|
||||
to: user.email,
|
||||
subject: "验证您的邮箱 - Learn Languages",
|
||||
html: generateVerificationEmailHtml(url, user.name || "用户"),
|
||||
});
|
||||
if (!result.success) {
|
||||
log.error("Failed to send verification email", { error: result.error });
|
||||
}
|
||||
},
|
||||
},
|
||||
socialProviders: {
|
||||
@@ -55,35 +44,14 @@ export const auth = betterAuth({
|
||||
plugins: [nextCookies(), username()],
|
||||
hooks: {
|
||||
before: createAuthMiddleware(async (ctx) => {
|
||||
if (ctx.path === "/sign-up/email" || ctx.path === "/update-user") {
|
||||
if (ctx.path !== "/sign-up/email" && ctx.path !== "/update-user") return;
|
||||
|
||||
const body = ctx.body as { username?: string };
|
||||
if (!body.username || body.username.trim() === "") {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Username is required",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.path === "/sign-in/username") {
|
||||
const body = ctx.body as { username?: string };
|
||||
if (body.username) {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ username: body.username },
|
||||
{ email: body.username },
|
||||
],
|
||||
},
|
||||
select: { emailVerified: true },
|
||||
});
|
||||
|
||||
if (user && !user.emailVerified) {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message: "Please verify your email address before signing in",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Languages } from "lucide-react";
|
||||
import { cn } from "@/utils/cn";
|
||||
|
||||
@@ -17,7 +17,6 @@ const languages = [
|
||||
|
||||
export function LanguageSettings() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [pendingLocale, setPendingLocale] = useState<string | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -47,16 +46,10 @@ export function LanguageSettings() {
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingLocale) {
|
||||
document.cookie = `locale=${pendingLocale}; path=/`;
|
||||
const setLocale = async (locale: string) => {
|
||||
document.cookie = `locale=${locale}`;
|
||||
window.location.reload();
|
||||
}
|
||||
}, [pendingLocale]);
|
||||
|
||||
const setLocale = useCallback((locale: string) => {
|
||||
setPendingLocale(locale);
|
||||
}, []);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function Navbar() {
|
||||
});
|
||||
|
||||
const mobileMenuItems: NavigationItem[] = [
|
||||
{ label: t("folders"), href: "/decks", icon: <Folder size={18} /> },
|
||||
{ label: t("folders"), href: "/folders", icon: <Folder size={18} /> },
|
||||
{ label: t("explore"), href: "/explore", icon: <Compass size={18} /> },
|
||||
...(session ? [{ label: t("favorites"), href: "/favorites", icon: <Heart size={18} /> }] : []),
|
||||
{ label: t("sourceCode"), href: "https://github.com/GoddoNebianU/learn-languages", icon: <Github size={18} />, external: true },
|
||||
@@ -42,7 +42,7 @@ export async function Navbar() {
|
||||
</GhostLightButton>
|
||||
<div className="flex gap-0.5 justify-center items-center">
|
||||
<LanguageSettings />
|
||||
<GhostLightButton href="/decks" className="md:block! hidden!" size="md">
|
||||
<GhostLightButton href="/folders" className="md:block! hidden!" size="md">
|
||||
{t("folders")}
|
||||
</GhostLightButton>
|
||||
<GhostLightButton href="/explore" className="md:block! hidden!" size="md">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState, useMemo } from "react";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
THEME_PRESETS,
|
||||
DEFAULT_THEME,
|
||||
@@ -20,33 +20,26 @@ const ThemeContext = createContext<ThemeContextType | null>(null);
|
||||
|
||||
const STORAGE_KEY = "theme-preset";
|
||||
|
||||
function getInitialTheme(): string {
|
||||
if (typeof window === "undefined") return DEFAULT_THEME;
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
return saved && getThemePreset(saved) ? saved : DEFAULT_THEME;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [currentTheme, setCurrentTheme] = useState<string>(DEFAULT_THEME);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = getInitialTheme();
|
||||
if (savedTheme !== currentTheme) {
|
||||
setMounted(true);
|
||||
const savedTheme = localStorage.getItem(STORAGE_KEY);
|
||||
if (savedTheme && getThemePreset(savedTheme)) {
|
||||
setCurrentTheme(savedTheme);
|
||||
}
|
||||
setHydrated(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
if (!mounted) return;
|
||||
const preset = getThemePreset(currentTheme);
|
||||
if (preset) {
|
||||
applyThemeColors(preset);
|
||||
localStorage.setItem(STORAGE_KEY, currentTheme);
|
||||
}
|
||||
}, [currentTheme, hydrated]);
|
||||
}, [currentTheme, mounted]);
|
||||
|
||||
const setTheme = (themeId: string) => {
|
||||
if (getThemePreset(themeId)) {
|
||||
@@ -54,7 +47,11 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
};
|
||||
|
||||
const themePreset = useMemo(() => getThemePreset(currentTheme) || THEME_PRESETS[0], [currentTheme]);
|
||||
const themePreset = getThemePreset(currentTheme) || THEME_PRESETS[0];
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
|
||||
@@ -21,25 +21,7 @@ const COMMON_LANGUAGES = [
|
||||
{ label: "portuguese", value: "portuguese" },
|
||||
{ label: "russian", value: "russian" },
|
||||
{ label: "other", value: "other" },
|
||||
] as const;
|
||||
|
||||
type LocaleLabel = typeof COMMON_LANGUAGES[number]["label"];
|
||||
|
||||
function getLocaleLabel(t: (key: string) => string, label: LocaleLabel): string {
|
||||
switch (label) {
|
||||
case "chinese": return t("translator.chinese");
|
||||
case "english": return t("translator.english");
|
||||
case "italian": return t("translator.italian");
|
||||
case "japanese": return t("translator.japanese");
|
||||
case "korean": return t("translator.korean");
|
||||
case "french": return t("translator.french");
|
||||
case "german": return t("translator.german");
|
||||
case "spanish": return t("translator.spanish");
|
||||
case "portuguese": return t("translator.portuguese");
|
||||
case "russian": return t("translator.russian");
|
||||
case "other": return t("translator.other");
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
interface LocaleSelectorProps {
|
||||
value: string;
|
||||
@@ -80,7 +62,7 @@ export function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
|
||||
>
|
||||
{COMMON_LANGUAGES.map((lang) => (
|
||||
<option key={lang.value} value={lang.value}>
|
||||
{getLocaleLabel(t, lang.label)}
|
||||
{t(`translator.${lang.label}`)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
@@ -14,31 +14,17 @@ export async function generateEntries(
|
||||
const isWord = inputType === "word";
|
||||
|
||||
const prompt = `
|
||||
你是专业词典编纂专家。为词条"${standardForm}"(${queryLang})生成${definitionLang}释义。
|
||||
生成词典条目。词语:"${standardForm}"(${queryLang})。用${definitionLang}释义。
|
||||
|
||||
【核心要求】
|
||||
生成尽可能完整、全面的词典条目,包括:
|
||||
${isWord ? `- 所有常见词性(名词、动词、形容词、副词等)
|
||||
- 每个词性下的所有常用义项
|
||||
- 专业领域含义、口语含义、习语用法` : `- 所有常见含义和用法
|
||||
- 字面义和引申义
|
||||
- 不同语境下的解释`}
|
||||
返回 JSON:
|
||||
${isWord ? `{"entries":[{"ipa":"音标","partOfSpeech":"词性","definition":"释义","example":"例句"}]}` : `{"entries":[{"definition":"释义","example":"例句"}]}`}
|
||||
|
||||
【JSON格式】
|
||||
${isWord ? `{"entries":[{"ipa":"国际音标","partOfSpeech":"词性","definition":"详细释义","example":"自然例句"}]}` : `{"entries":[{"definition":"详细释义","example":"自然例句"}]}`}
|
||||
|
||||
【质量标准】
|
||||
- 条目数量:尽可能多,不要遗漏常用义项
|
||||
- 释义:准确、完整、符合母语者习惯
|
||||
- 例句:自然、地道、展示实际用法
|
||||
- IPA:使用标准国际音标(单词/短语必填)
|
||||
|
||||
只返回JSON,不要其他内容。
|
||||
只返回 JSON。
|
||||
`.trim();
|
||||
|
||||
try {
|
||||
const result = await getAnswer([
|
||||
{ role: "system", content: "专业词典编纂专家,返回完整JSON词典数据。" },
|
||||
{ role: "system", content: "词典条目生成器,只返回 JSON。" },
|
||||
{ role: "user", content: prompt },
|
||||
]).then(parseAIGeneratedJSON<EntriesGenerationResult>);
|
||||
|
||||
@@ -61,7 +47,6 @@ ${isWord ? `{"entries":[{"ipa":"国际音标","partOfSpeech":"词性","definitio
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Generated dictionary entries", { count: result.entries.length });
|
||||
return result;
|
||||
} catch (error) {
|
||||
log.error("Entries generation failed", { error: error instanceof Error ? error.message : String(error) });
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
"use server";
|
||||
|
||||
import OpenAI from "openai";
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.ZHIPU_API_KEY,
|
||||
baseURL: "https://open.bigmodel.cn/api/paas/v4",
|
||||
});
|
||||
|
||||
type Messages = Array<
|
||||
| { role: "system"; content: string }
|
||||
| { role: "user"; content: string }
|
||||
@@ -13,29 +20,13 @@ async function getAnswer(prompt: string | Messages): Promise<string> {
|
||||
? [{ role: "user", content: prompt }]
|
||||
: prompt;
|
||||
|
||||
const response = await fetch("https://open.bigmodel.cn/api/paas/v4/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${process.env.ZHIPU_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: process.env.ZHIPU_MODEL_NAME || "glm-4.6",
|
||||
messages,
|
||||
const response = await openai.chat.completions.create({
|
||||
model: process.env.ZHIPU_MODEL_NAME || "glm-4",
|
||||
messages: messages as OpenAI.Chat.Completions.ChatCompletionMessageParam[],
|
||||
temperature: 0.2,
|
||||
thinking: {
|
||||
type: "disabled"
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`AI API 请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as { choices?: Array<{ message?: { content?: string } }> };
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
|
||||
const content = response.choices[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error("AI API 返回空响应");
|
||||
}
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import OpenAI from "openai";
|
||||
import { parseAIGeneratedJSON } from "@/utils/json";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { OCRInput, OCROutput, OCRRawResponse } from "./types";
|
||||
|
||||
const log = createLogger("ocr-orchestrator");
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.ZHIPU_API_KEY,
|
||||
baseURL: "https://open.bigmodel.cn/api/paas/v4",
|
||||
});
|
||||
|
||||
/**
|
||||
* Executes OCR on an image to extract vocabulary word-definition pairs.
|
||||
*
|
||||
* Uses GLM-4.6V vision model to analyze vocabulary table images and
|
||||
* extract structured word-definition pairs.
|
||||
*
|
||||
* @param input - OCR input containing base64 image and optional language hints
|
||||
* @returns Structured output with extracted pairs and detected languages
|
||||
* @throws Error if OCR fails or response is malformed
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await executeOCR({
|
||||
* imageBase64: "iVBORw0KGgo...",
|
||||
* sourceLanguage: "English",
|
||||
* targetLanguage: "Chinese"
|
||||
* });
|
||||
* // result.pairs: [{ word: "hello", definition: "你好" }, ...]
|
||||
* ```
|
||||
*/
|
||||
export async function executeOCR(input: OCRInput): Promise<OCROutput> {
|
||||
const { imageBase64, sourceLanguage, targetLanguage } = input;
|
||||
|
||||
log.debug("Starting OCR", {
|
||||
hasSourceHint: !!sourceLanguage,
|
||||
hasTargetHint: !!targetLanguage,
|
||||
imageLength: imageBase64.length,
|
||||
});
|
||||
|
||||
const languageHints: string[] = [];
|
||||
if (sourceLanguage) {
|
||||
languageHints.push(`源语言提示: ${sourceLanguage}`);
|
||||
}
|
||||
if (targetLanguage) {
|
||||
languageHints.push(`目标语言提示: ${targetLanguage}`);
|
||||
}
|
||||
|
||||
const prompt = `
|
||||
你是一个专业的OCR识别助手,专门从词汇表截图中提取单词和释义。
|
||||
|
||||
${languageHints.length > 0 ? `语言提示:\n${languageHints.join("\n")}\n` : ""}
|
||||
|
||||
你的任务是分析图片中的词汇表,提取所有单词-释义对。
|
||||
|
||||
要求:
|
||||
1. 识别图片中的词汇表结构(可能是两列或多列)
|
||||
2. 提取每一行的单词和对应的释义/翻译
|
||||
3. 自动检测源语言和目标语言
|
||||
4. 保持原始大小写和拼写
|
||||
5. 如果图片模糊或不清晰,尽力识别并标注置信度较低的项目
|
||||
6. 忽略表头、页码等非词汇内容
|
||||
|
||||
返回 JSON 格式:
|
||||
{
|
||||
"pairs": [
|
||||
{ "word": "单词1", "definition": "释义1" },
|
||||
{ "word": "单词2", "definition": "释义2" }
|
||||
],
|
||||
"detectedSourceLanguage": "检测到的源语言",
|
||||
"detectedTargetLanguage": "检测到的目标语言"
|
||||
}
|
||||
|
||||
只返回 JSON,不要任何其他文字。
|
||||
`.trim();
|
||||
|
||||
try {
|
||||
const response = await openai.chat.completions.create({
|
||||
model: "glm-4.6v",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: imageBase64,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: prompt,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
temperature: 0.1,
|
||||
});
|
||||
|
||||
const content = response.choices[0]?.message?.content;
|
||||
|
||||
if (!content) {
|
||||
log.error("OCR returned empty response");
|
||||
throw new Error("OCR 返回空响应");
|
||||
}
|
||||
|
||||
log.debug("Received OCR response", { contentLength: content.length });
|
||||
|
||||
const parsed = parseAIGeneratedJSON<OCRRawResponse>(content);
|
||||
|
||||
if (!parsed.pairs || !Array.isArray(parsed.pairs)) {
|
||||
log.error("Invalid OCR response: missing or invalid pairs array", { parsed });
|
||||
throw new Error("OCR 响应格式无效:缺少 pairs 数组");
|
||||
}
|
||||
|
||||
const validPairs = parsed.pairs.filter((pair) => {
|
||||
const isValid = typeof pair.word === "string" && typeof pair.definition === "string";
|
||||
if (!isValid) {
|
||||
log.warn("Skipping invalid pair", { pair });
|
||||
}
|
||||
return isValid;
|
||||
});
|
||||
|
||||
if (validPairs.length === 0) {
|
||||
log.error("No valid pairs extracted from image");
|
||||
throw new Error("未能从图片中提取有效的词汇对");
|
||||
}
|
||||
|
||||
const result: OCROutput = {
|
||||
pairs: validPairs,
|
||||
detectedSourceLanguage: parsed.detectedSourceLanguage,
|
||||
detectedTargetLanguage: parsed.detectedTargetLanguage,
|
||||
};
|
||||
|
||||
log.info("OCR completed successfully", {
|
||||
pairCount: result.pairs.length,
|
||||
sourceLanguage: result.detectedSourceLanguage,
|
||||
targetLanguage: result.detectedTargetLanguage,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith("OCR")) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
log.error("OCR failed", { error });
|
||||
const errorMessage = error instanceof Error ? error.message : "未知错误";
|
||||
throw new Error(`OCR 处理失败: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* Input for OCR pipeline
|
||||
*/
|
||||
export interface OCRInput {
|
||||
/** Base64 encoded image (without data URL prefix) */
|
||||
imageBase64: string;
|
||||
/** Optional: hint about source language */
|
||||
sourceLanguage?: string;
|
||||
/** Optional: hint about target/translation language */
|
||||
targetLanguage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single word-definition pair extracted from image
|
||||
*/
|
||||
export interface VocabularyPair {
|
||||
/** The original word */
|
||||
word: string;
|
||||
/** The translation/definition */
|
||||
definition: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Output from OCR pipeline
|
||||
*/
|
||||
export interface OCROutput {
|
||||
/** Extracted word-definition pairs */
|
||||
pairs: VocabularyPair[];
|
||||
/** Detected source language */
|
||||
detectedSourceLanguage?: string;
|
||||
/** Detected target/translation language */
|
||||
detectedTargetLanguage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal structure for AI response parsing
|
||||
*/
|
||||
interface OCRRawResponse {
|
||||
pairs: Array<{ word: string; definition: string }>;
|
||||
detectedSourceLanguage?: string;
|
||||
detectedTargetLanguage?: string;
|
||||
}
|
||||
|
||||
export type { OCRRawResponse };
|
||||
@@ -168,12 +168,12 @@ export async function executeTranslation(
|
||||
let targetIpa: string | undefined;
|
||||
|
||||
if (needIpa) {
|
||||
log.debug("[Stage 3] Generating IPA in parallel");
|
||||
[sourceIpa, targetIpa] = await Promise.all([
|
||||
generateIPA(sourceText, detectedLanguage),
|
||||
generateIPA(translatedText, targetLanguage),
|
||||
]);
|
||||
log.debug("[Stage 3] IPA complete", { sourceIpa, targetIpa });
|
||||
log.debug("[Stage 3] Generating IPA");
|
||||
sourceIpa = await generateIPA(sourceText, detectedLanguage);
|
||||
log.debug("[Stage 3] Source IPA", { sourceIpa });
|
||||
|
||||
targetIpa = await generateIPA(translatedText, targetLanguage);
|
||||
log.debug("[Stage 3] Target IPA", { targetIpa });
|
||||
}
|
||||
|
||||
// Assemble final result
|
||||
|
||||
@@ -61,8 +61,3 @@ export type ActionOutputUserProfile = {
|
||||
updatedAt: Date;
|
||||
};
|
||||
};
|
||||
|
||||
export type ActionOutputDeleteAccount = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
ActionInputSignIn,
|
||||
ActionInputSignUp,
|
||||
ActionOutputAuth,
|
||||
ActionOutputDeleteAccount,
|
||||
ActionOutputUserProfile,
|
||||
validateActionInputGetUserProfileByUsername,
|
||||
validateActionInputSignIn,
|
||||
@@ -19,8 +18,7 @@ import {
|
||||
import {
|
||||
serviceGetUserProfileByUsername,
|
||||
serviceSignIn,
|
||||
serviceSignUp,
|
||||
serviceDeleteAccount
|
||||
serviceSignUp
|
||||
} from "./auth-service";
|
||||
|
||||
// Re-export types for use in components
|
||||
@@ -182,27 +180,3 @@ export async function actionGetUserProfileByUsername(dto: ActionInputGetUserProf
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete account action
|
||||
* Permanently deletes the current user and all associated data
|
||||
*/
|
||||
export async function actionDeleteAccount(): Promise<ActionOutputDeleteAccount> {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
|
||||
const result = await serviceDeleteAccount({ userId: session.user.id });
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, message: "Failed to delete account" };
|
||||
}
|
||||
|
||||
return { success: true, message: "Account deleted successfully" };
|
||||
} catch (e) {
|
||||
log.error("Delete account failed", { error: e });
|
||||
return { success: false, message: "Failed to delete account" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,12 +25,3 @@ export type RepoInputFindUserById = {
|
||||
export type RepoInputFindUserByEmail = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
// Delete user cascade types
|
||||
export type RepoInputDeleteUserCascade = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type RepoOutputDeleteUserCascade = {
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import {
|
||||
RepoInputFindUserByEmail,
|
||||
RepoInputFindUserById,
|
||||
RepoInputFindUserByUsername,
|
||||
RepoInputDeleteUserCascade,
|
||||
RepoOutputUserProfile,
|
||||
RepoOutputDeleteUserCascade
|
||||
RepoOutputUserProfile
|
||||
} from "./auth-repository-dto";
|
||||
|
||||
const log = createLogger("auth-repository");
|
||||
|
||||
export async function repoFindUserByUsername(dto: RepoInputFindUserByUsername): Promise<RepoOutputUserProfile> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { username: dto.username },
|
||||
@@ -67,48 +62,3 @@ export async function repoFindUserByEmail(dto: RepoInputFindUserByEmail): Promis
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function repoDeleteUserCascade(dto: RepoInputDeleteUserCascade): Promise<RepoOutputDeleteUserCascade> {
|
||||
const { userId } = dto;
|
||||
|
||||
log.info("Starting cascade delete for user", { userId });
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.card.deleteMany({
|
||||
where: { deck: { userId } }
|
||||
});
|
||||
|
||||
await tx.deckFavorite.deleteMany({
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
await tx.deck.deleteMany({
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
await tx.follow.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ followerId: userId },
|
||||
{ followingId: userId }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
await tx.session.deleteMany({
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
await tx.account.deleteMany({
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
await tx.user.delete({
|
||||
where: { id: userId }
|
||||
});
|
||||
});
|
||||
|
||||
log.info("Cascade delete completed for user", { userId });
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -38,11 +38,3 @@ export type ServiceOutputUserProfile = {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
} | null;
|
||||
|
||||
export type ServiceInputDeleteAccount = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceOutputDeleteAccount = {
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { auth } from "@/auth";
|
||||
import {
|
||||
repoFindUserByUsername,
|
||||
repoFindUserById,
|
||||
repoDeleteUserCascade
|
||||
repoFindUserById
|
||||
} from "./auth-repository";
|
||||
import {
|
||||
ServiceInputGetUserProfileByUsername,
|
||||
ServiceInputGetUserProfileById,
|
||||
ServiceInputSignIn,
|
||||
ServiceInputSignUp,
|
||||
ServiceInputDeleteAccount,
|
||||
ServiceOutputAuth,
|
||||
ServiceOutputUserProfile,
|
||||
ServiceOutputDeleteAccount
|
||||
ServiceOutputUserProfile
|
||||
} from "./auth-service-dto";
|
||||
|
||||
/**
|
||||
@@ -95,7 +92,3 @@ export async function serviceGetUserProfileByUsername(dto: ServiceInputGetUserPr
|
||||
export async function serviceGetUserProfileById(dto: ServiceInputGetUserProfileById): Promise<ServiceOutputUserProfile> {
|
||||
return await repoFindUserById(dto);
|
||||
}
|
||||
|
||||
export async function serviceDeleteAccount(dto: ServiceInputDeleteAccount): Promise<ServiceOutputDeleteAccount> {
|
||||
return await repoDeleteUserCascade({ userId: dto.userId });
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const schemaActionInputForgotPassword = z.object({
|
||||
email: z.string().email("请输入有效的邮箱地址"),
|
||||
});
|
||||
|
||||
export type ActionInputForgotPassword = z.infer<typeof schemaActionInputForgotPassword>;
|
||||
|
||||
export interface ActionOutputForgotPassword {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { validate } from "@/utils/validate";
|
||||
import { ValidateError } from "@/lib/errors";
|
||||
import {
|
||||
schemaActionInputForgotPassword,
|
||||
type ActionInputForgotPassword,
|
||||
type ActionOutputForgotPassword,
|
||||
} from "./forgot-password-action-dto";
|
||||
import { serviceRequestPasswordReset } from "./forgot-password-service";
|
||||
|
||||
const log = createLogger("forgot-password-action");
|
||||
|
||||
export async function actionRequestPasswordReset(
|
||||
input: unknown
|
||||
): Promise<ActionOutputForgotPassword> {
|
||||
try {
|
||||
const dto = validate(input, schemaActionInputForgotPassword) as ActionInputForgotPassword;
|
||||
|
||||
return await serviceRequestPasswordReset({ email: dto.email });
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return {
|
||||
success: false,
|
||||
message: e.message,
|
||||
};
|
||||
}
|
||||
log.error("Password reset request failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: "发送重置邮件失败,请稍后重试",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export type RepoInputFindUserByEmail = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type RepoOutputFindUserByEmail = {
|
||||
id: string;
|
||||
} | null;
|
||||
@@ -1,19 +0,0 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import {
|
||||
RepoInputFindUserByEmail,
|
||||
RepoOutputFindUserByEmail
|
||||
} from "./forgot-password-repository-dto";
|
||||
|
||||
const log = createLogger("forgot-password-repository");
|
||||
|
||||
export async function repoFindUserByEmail(dto: RepoInputFindUserByEmail): Promise<RepoOutputFindUserByEmail> {
|
||||
log.debug("Finding user by email", { email: dto.email });
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: dto.email },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export type ServiceInputRequestPasswordReset = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type ServiceOutputRequestPasswordReset = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import { auth } from "@/auth";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { repoFindUserByEmail } from "./forgot-password-repository";
|
||||
import {
|
||||
ServiceInputRequestPasswordReset,
|
||||
ServiceOutputRequestPasswordReset
|
||||
} from "./forgot-password-service-dto";
|
||||
|
||||
const log = createLogger("forgot-password-service");
|
||||
|
||||
export async function serviceRequestPasswordReset(dto: ServiceInputRequestPasswordReset): Promise<ServiceOutputRequestPasswordReset> {
|
||||
log.info("Processing password reset request", { email: dto.email });
|
||||
|
||||
const user = await repoFindUserByEmail({ email: dto.email });
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
message: "该邮箱未注册",
|
||||
};
|
||||
}
|
||||
|
||||
await auth.api.requestPasswordReset({
|
||||
body: {
|
||||
email: dto.email,
|
||||
redirectTo: "/reset-password",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "重置密码邮件已发送,请检查您的邮箱",
|
||||
};
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import z from "zod";
|
||||
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({
|
||||
deckId: z.number().int().positive(),
|
||||
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 const validateActionInputCreateCard = generateValidator(schemaActionInputCreateCard);
|
||||
|
||||
export const schemaActionInputUpdateCard = z.object({
|
||||
cardId: z.number().int().positive(),
|
||||
word: z.string().min(1).optional(),
|
||||
ipa: z.string().optional().nullable(),
|
||||
meanings: z.array(CardMeaningSchema).min(1).optional(),
|
||||
});
|
||||
export type ActionInputUpdateCard = z.infer<typeof schemaActionInputUpdateCard>;
|
||||
export const validateActionInputUpdateCard = generateValidator(schemaActionInputUpdateCard);
|
||||
|
||||
export const schemaActionInputDeleteCard = z.object({
|
||||
cardId: z.number().int().positive(),
|
||||
});
|
||||
export type ActionInputDeleteCard = z.infer<typeof schemaActionInputDeleteCard>;
|
||||
export const validateActionInputDeleteCard = generateValidator(schemaActionInputDeleteCard);
|
||||
|
||||
export const schemaActionInputGetCardsByDeckId = z.object({
|
||||
deckId: z.number().int().positive(),
|
||||
limit: z.number().int().min(1).max(100).optional(),
|
||||
offset: z.number().int().min(0).optional(),
|
||||
});
|
||||
export type ActionInputGetCardsByDeckId = z.infer<typeof schemaActionInputGetCardsByDeckId>;
|
||||
export const validateActionInputGetCardsByDeckId = generateValidator(schemaActionInputGetCardsByDeckId);
|
||||
|
||||
export const schemaActionInputGetRandomCard = z.object({
|
||||
deckId: z.number().int().positive(),
|
||||
excludeIds: z.array(z.number().int().positive()).optional(),
|
||||
});
|
||||
export type ActionInputGetRandomCard = z.infer<typeof schemaActionInputGetRandomCard>;
|
||||
export const validateActionInputGetRandomCard = generateValidator(schemaActionInputGetRandomCard);
|
||||
@@ -1,220 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { ValidateError } from "@/lib/errors";
|
||||
import {
|
||||
serviceCreateCard,
|
||||
serviceUpdateCard,
|
||||
serviceDeleteCard,
|
||||
serviceGetCardById,
|
||||
serviceGetCardsByDeckId,
|
||||
serviceGetRandomCard,
|
||||
serviceGetCardStats,
|
||||
serviceCheckDeckOwnership,
|
||||
} from "./card-service";
|
||||
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");
|
||||
|
||||
function mapCardToOutput(card: any): ActionOutputCard {
|
||||
return {
|
||||
id: card.id,
|
||||
deckId: card.deckId,
|
||||
word: card.word,
|
||||
ipa: card.ipa,
|
||||
queryLang: card.queryLang,
|
||||
cardType: card.cardType,
|
||||
meanings: card.meanings,
|
||||
createdAt: card.createdAt,
|
||||
updatedAt: card.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function getCurrentUserId(): Promise<string | null> {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
return session?.user?.id ?? null;
|
||||
}
|
||||
|
||||
async function checkDeckOwnership(deckId: number): Promise<boolean> {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return false;
|
||||
return serviceCheckDeckOwnership({ deckId, userId });
|
||||
}
|
||||
|
||||
export async function actionCreateCard(input: unknown) {
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
const validated = validateActionInputCreateCard(input);
|
||||
const isOwner = await checkDeckOwnership(validated.deckId);
|
||||
if (!isOwner) {
|
||||
return { success: false, message: "You do not have permission to add cards to this deck" };
|
||||
}
|
||||
const result = await serviceCreateCard(validated);
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to create card", { error: e instanceof Error ? e.message : String(e) });
|
||||
return { success: false, message: "Failed to create card" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionUpdateCard(input: unknown) {
|
||||
try {
|
||||
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 {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
const validated = validateActionInputGetCardsByDeckId(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 cards = await serviceGetCardsByDeckId(validated);
|
||||
return {
|
||||
success: true,
|
||||
message: "Cards fetched successfully",
|
||||
data: cards.map(mapCardToOutput),
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to get cards", { error: e instanceof Error ? e.message : String(e) });
|
||||
return { success: false, message: "Failed to get cards" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetCardById(cardId: number) {
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
const card = await serviceGetCardById(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 view this card" };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: "Card fetched successfully",
|
||||
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) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to get random card", { error: e instanceof Error ? e.message : String(e) });
|
||||
return { success: false, message: "Failed to get random card" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetCardStats(deckId: number) {
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
const isOwner = await checkDeckOwnership(deckId);
|
||||
if (!isOwner) {
|
||||
return { success: false, message: "You do not have permission to view stats for this deck" };
|
||||
}
|
||||
const stats = await serviceGetCardStats(deckId);
|
||||
return {
|
||||
success: true,
|
||||
message: "Card stats fetched successfully",
|
||||
data: stats,
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Failed to get card stats", { error: e instanceof Error ? e.message : String(e) });
|
||||
return { success: false, message: "Failed to get card stats" };
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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 {
|
||||
deckId: number;
|
||||
word: string;
|
||||
ipa?: string | null;
|
||||
queryLang: string;
|
||||
cardType: CardType;
|
||||
meanings: CardMeaning[];
|
||||
}
|
||||
|
||||
export interface RepoInputUpdateCard {
|
||||
cardId: number;
|
||||
word?: string;
|
||||
ipa?: string | null;
|
||||
meanings?: CardMeaning[];
|
||||
}
|
||||
|
||||
export interface RepoInputDeleteCard {
|
||||
cardId: number;
|
||||
}
|
||||
|
||||
export interface RepoInputGetCardsByDeckId {
|
||||
deckId: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface RepoInputGetRandomCard {
|
||||
deckId: number;
|
||||
excludeIds?: number[];
|
||||
}
|
||||
|
||||
export interface RepoInputCheckCardOwnership {
|
||||
cardId: number;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export type RepoOutputCard = {
|
||||
id: number;
|
||||
deckId: number;
|
||||
word: string;
|
||||
ipa: string | null;
|
||||
queryLang: string;
|
||||
cardType: CardType;
|
||||
meanings: CardMeaning[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type RepoOutputCardStats = {
|
||||
total: number;
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import {
|
||||
RepoInputCreateCard,
|
||||
RepoInputUpdateCard,
|
||||
RepoInputDeleteCard,
|
||||
RepoInputGetCardsByDeckId,
|
||||
RepoInputGetRandomCard,
|
||||
RepoInputCheckCardOwnership,
|
||||
RepoOutputCard,
|
||||
RepoOutputCardStats,
|
||||
CardMeaning,
|
||||
} from "./card-repository-dto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("card-repository");
|
||||
|
||||
export async function repoCreateCard(input: RepoInputCreateCard): Promise<number> {
|
||||
log.debug("Creating card", { deckId: input.deckId, word: input.word });
|
||||
const card = await prisma.card.create({
|
||||
data: {
|
||||
deckId: input.deckId,
|
||||
word: input.word,
|
||||
ipa: input.ipa,
|
||||
queryLang: input.queryLang,
|
||||
cardType: input.cardType,
|
||||
meanings: {
|
||||
create: input.meanings.map((m: CardMeaning) => ({
|
||||
partOfSpeech: m.partOfSpeech,
|
||||
definition: m.definition,
|
||||
example: m.example,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
log.info("Card created", { cardId: card.id });
|
||||
return card.id;
|
||||
}
|
||||
|
||||
export async function repoUpdateCard(input: RepoInputUpdateCard): Promise<void> {
|
||||
log.debug("Updating card", { cardId: input.cardId });
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (input.word !== undefined) {
|
||||
await tx.card.update({
|
||||
where: { id: input.cardId },
|
||||
data: { word: input.word },
|
||||
});
|
||||
}
|
||||
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: input.cardId });
|
||||
}
|
||||
|
||||
export async function repoDeleteCard(input: RepoInputDeleteCard): Promise<void> {
|
||||
log.debug("Deleting card", { cardId: input.cardId });
|
||||
await prisma.card.delete({
|
||||
where: { id: input.cardId },
|
||||
});
|
||||
log.info("Card deleted", { cardId: input.cardId });
|
||||
}
|
||||
|
||||
export async function repoGetCardById(cardId: number): Promise<RepoOutputCard | null> {
|
||||
const card = await prisma.card.findUnique({
|
||||
where: { id: cardId },
|
||||
include: { meanings: { orderBy: { createdAt: "asc" } } },
|
||||
});
|
||||
return card as RepoOutputCard | null;
|
||||
}
|
||||
|
||||
export async function repoGetCardsByDeckId(input: RepoInputGetCardsByDeckId): Promise<RepoOutputCard[]> {
|
||||
const { deckId, limit = 50, offset = 0 } = input;
|
||||
const cards = await prisma.card.findMany({
|
||||
where: { deckId },
|
||||
include: { meanings: { orderBy: { createdAt: "asc" } } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
log.debug("Fetched cards by deck", { deckId, count: cards.length });
|
||||
return cards as RepoOutputCard[];
|
||||
}
|
||||
|
||||
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({
|
||||
where: { id: cardId },
|
||||
include: {
|
||||
deck: {
|
||||
select: { userId: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
return card?.deck.userId ?? null;
|
||||
}
|
||||
|
||||
export async function repoCheckCardOwnership(input: RepoInputCheckCardOwnership): Promise<boolean> {
|
||||
const ownerId = await repoGetCardDeckOwnerId(input.cardId);
|
||||
return ownerId === input.userId;
|
||||
}
|
||||
|
||||
export async function repoGetCardStats(deckId: number): Promise<RepoOutputCardStats> {
|
||||
const total = await prisma.card.count({ where: { deckId } });
|
||||
return { total };
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import type { RepoOutputCard, RepoOutputCardStats } from "./card-repository-dto";
|
||||
|
||||
export type ServiceOutputCard = RepoOutputCard;
|
||||
export type ServiceOutputCardStats = RepoOutputCardStats;
|
||||
@@ -1,104 +0,0 @@
|
||||
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 {
|
||||
repoCreateCard,
|
||||
repoUpdateCard,
|
||||
repoDeleteCard,
|
||||
repoGetCardById,
|
||||
repoGetCardsByDeckId,
|
||||
repoGetRandomCard,
|
||||
repoGetCardStats,
|
||||
repoCheckCardOwnership,
|
||||
} from "./card-repository";
|
||||
|
||||
const log = createLogger("card-service");
|
||||
|
||||
export type { CardMeaning as ServiceCardMeaning, CardType as ServiceCardType };
|
||||
|
||||
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" };
|
||||
}
|
||||
|
||||
export async function serviceUpdateCard(input: ServiceInputUpdateCard): Promise<{ success: boolean; message: string }> {
|
||||
log.info("Updating card", { cardId: input.cardId });
|
||||
const card = await repoGetCardById(input.cardId);
|
||||
if (!card) {
|
||||
return { success: false, message: "Card not found" };
|
||||
}
|
||||
await repoUpdateCard(input);
|
||||
log.info("Card updated", { cardId: input.cardId });
|
||||
return { success: true, message: "Card updated successfully" };
|
||||
}
|
||||
|
||||
export async function serviceDeleteCard(input: ServiceInputDeleteCard): Promise<{ success: boolean; message: string }> {
|
||||
log.info("Deleting card", { cardId: input.cardId });
|
||||
const card = await repoGetCardById(input.cardId);
|
||||
if (!card) {
|
||||
return { success: false, message: "Card not found" };
|
||||
}
|
||||
await repoDeleteCard(input);
|
||||
log.info("Card deleted", { cardId: input.cardId });
|
||||
return { success: true, message: "Card deleted successfully" };
|
||||
}
|
||||
|
||||
export async function serviceGetCardById(cardId: number): Promise<ServiceOutputCard | null> {
|
||||
return repoGetCardById(cardId);
|
||||
}
|
||||
|
||||
export async function serviceGetCardsByDeckId(input: ServiceInputGetCardsByDeckId): Promise<ServiceOutputCard[]> {
|
||||
log.debug("Getting cards by deck", { deckId: input.deckId });
|
||||
return repoGetCardsByDeckId(input);
|
||||
}
|
||||
|
||||
export async function serviceGetRandomCard(input: ServiceInputGetRandomCard): Promise<ServiceOutputCard | null> {
|
||||
log.debug("Getting random card", { deckId: input.deckId });
|
||||
return repoGetRandomCard(input);
|
||||
}
|
||||
|
||||
export async function serviceGetCardStats(deckId: number): Promise<ServiceOutputCardStats> {
|
||||
log.debug("Getting card stats", { deckId });
|
||||
return repoGetCardStats(deckId);
|
||||
}
|
||||
|
||||
export async function serviceCheckCardOwnership(input: ServiceInputCheckCardOwnership): Promise<boolean> {
|
||||
log.debug("Checking card ownership", { cardId: input.cardId });
|
||||
return repoCheckCardOwnership(input);
|
||||
}
|
||||
|
||||
export async function serviceCheckDeckOwnership(input: ServiceInputCheckDeckOwnership): Promise<boolean> {
|
||||
log.debug("Checking deck ownership", { deckId: input.deckId });
|
||||
const ownerId = await repoGetUserIdByDeckId(input.deckId);
|
||||
return ownerId === input.userId;
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { generateValidator } from "@/utils/validate";
|
||||
import z from "zod";
|
||||
|
||||
export const schemaActionInputCreateDeck = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
desc: z.string().max(500).optional(),
|
||||
visibility: z.enum(["PRIVATE", "PUBLIC"]).optional(),
|
||||
});
|
||||
export type ActionInputCreateDeck = z.infer<typeof schemaActionInputCreateDeck>;
|
||||
export const validateActionInputCreateDeck = generateValidator(schemaActionInputCreateDeck);
|
||||
|
||||
export const schemaActionInputUpdateDeck = z.object({
|
||||
deckId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
desc: z.string().max(500).optional(),
|
||||
visibility: z.enum(["PRIVATE", "PUBLIC"]).optional(),
|
||||
});
|
||||
export type ActionInputUpdateDeck = z.infer<typeof schemaActionInputUpdateDeck>;
|
||||
export const validateActionInputUpdateDeck = generateValidator(schemaActionInputUpdateDeck);
|
||||
|
||||
export const schemaActionInputDeleteDeck = z.object({
|
||||
deckId: z.number().int().positive(),
|
||||
});
|
||||
export type ActionInputDeleteDeck = z.infer<typeof schemaActionInputDeleteDeck>;
|
||||
export const validateActionInputDeleteDeck = generateValidator(schemaActionInputDeleteDeck);
|
||||
|
||||
export const schemaActionInputGetDeckById = z.object({
|
||||
deckId: z.number().int().positive(),
|
||||
});
|
||||
export type ActionInputGetDeckById = z.infer<typeof schemaActionInputGetDeckById>;
|
||||
export const validateActionInputGetDeckById = generateValidator(schemaActionInputGetDeckById);
|
||||
|
||||
export const schemaActionInputGetPublicDecks = z.object({
|
||||
limit: z.number().int().positive().optional(),
|
||||
offset: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
export type ActionInputGetPublicDecks = z.infer<typeof schemaActionInputGetPublicDecks>;
|
||||
export const validateActionInputGetPublicDecks = generateValidator(schemaActionInputGetPublicDecks);
|
||||
|
||||
export type ActionOutputDeck = {
|
||||
id: number;
|
||||
name: string;
|
||||
desc: string;
|
||||
userId: string;
|
||||
visibility: "PRIVATE" | "PUBLIC";
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
cardCount?: number;
|
||||
};
|
||||
|
||||
export type ActionOutputPublicDeck = ActionOutputDeck & {
|
||||
userName: string | null;
|
||||
userUsername: string | null;
|
||||
favoriteCount: number;
|
||||
};
|
||||
|
||||
export type ActionOutputCreateDeck = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
deckId?: number;
|
||||
};
|
||||
|
||||
export type ActionOutputUpdateDeck = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
export type ActionOutputDeleteDeck = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
export type ActionOutputGetDeckById = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: ActionOutputDeck;
|
||||
};
|
||||
|
||||
export type ActionOutputGetDecksByUserId = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: ActionOutputDeck[];
|
||||
};
|
||||
|
||||
export type ActionOutputGetPublicDecks = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: ActionOutputPublicDeck[];
|
||||
};
|
||||
|
||||
export const schemaActionInputSearchPublicDecks = z.object({
|
||||
query: z.string().min(1),
|
||||
limit: z.number().int().positive().optional(),
|
||||
offset: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
export type ActionInputSearchPublicDecks = z.infer<typeof schemaActionInputSearchPublicDecks>;
|
||||
export const validateActionInputSearchPublicDecks = generateValidator(schemaActionInputSearchPublicDecks);
|
||||
|
||||
export const schemaActionInputGetPublicDeckById = z.object({
|
||||
deckId: z.number().int().positive(),
|
||||
});
|
||||
export type ActionInputGetPublicDeckById = z.infer<typeof schemaActionInputGetPublicDeckById>;
|
||||
export const validateActionInputGetPublicDeckById = generateValidator(schemaActionInputGetPublicDeckById);
|
||||
|
||||
export const schemaActionInputToggleDeckFavorite = z.object({
|
||||
deckId: z.number().int().positive(),
|
||||
});
|
||||
export type ActionInputToggleDeckFavorite = z.infer<typeof schemaActionInputToggleDeckFavorite>;
|
||||
export const validateActionInputToggleDeckFavorite = generateValidator(schemaActionInputToggleDeckFavorite);
|
||||
|
||||
export const schemaActionInputCheckDeckFavorite = z.object({
|
||||
deckId: z.number().int().positive(),
|
||||
});
|
||||
export type ActionInputCheckDeckFavorite = z.infer<typeof schemaActionInputCheckDeckFavorite>;
|
||||
export const validateActionInputCheckDeckFavorite = generateValidator(schemaActionInputCheckDeckFavorite);
|
||||
|
||||
export type ActionOutputDeckFavorite = {
|
||||
isFavorited: boolean;
|
||||
favoriteCount: number;
|
||||
};
|
||||
|
||||
export type ActionOutputSearchPublicDecks = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: ActionOutputPublicDeck[];
|
||||
};
|
||||
|
||||
export type ActionOutputGetPublicDeckById = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: ActionOutputPublicDeck;
|
||||
};
|
||||
|
||||
export type ActionOutputToggleDeckFavorite = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: ActionOutputDeckFavorite;
|
||||
};
|
||||
|
||||
export type ActionOutputCheckDeckFavorite = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: ActionOutputDeckFavorite;
|
||||
};
|
||||
|
||||
export type ActionOutputUserFavoriteDeck = ActionOutputPublicDeck & {
|
||||
favoritedAt: Date;
|
||||
};
|
||||
|
||||
export type ActionOutputGetUserFavoriteDecks = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: ActionOutputUserFavoriteDeck[];
|
||||
};
|
||||
@@ -1,326 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { ValidateError } from "@/lib/errors";
|
||||
import { Visibility } from "../../../generated/prisma/enums";
|
||||
import {
|
||||
ActionInputCreateDeck,
|
||||
ActionInputUpdateDeck,
|
||||
ActionInputDeleteDeck,
|
||||
ActionInputGetDeckById,
|
||||
ActionInputGetPublicDecks,
|
||||
ActionInputSearchPublicDecks,
|
||||
ActionInputGetPublicDeckById,
|
||||
ActionInputToggleDeckFavorite,
|
||||
ActionInputCheckDeckFavorite,
|
||||
ActionOutputCreateDeck,
|
||||
ActionOutputUpdateDeck,
|
||||
ActionOutputDeleteDeck,
|
||||
ActionOutputGetDeckById,
|
||||
ActionOutputGetDecksByUserId,
|
||||
ActionOutputGetPublicDecks,
|
||||
ActionOutputDeck,
|
||||
ActionOutputPublicDeck,
|
||||
ActionOutputSearchPublicDecks,
|
||||
ActionOutputGetPublicDeckById,
|
||||
ActionOutputToggleDeckFavorite,
|
||||
ActionOutputCheckDeckFavorite,
|
||||
ActionOutputGetUserFavoriteDecks,
|
||||
validateActionInputCreateDeck,
|
||||
validateActionInputUpdateDeck,
|
||||
validateActionInputDeleteDeck,
|
||||
validateActionInputGetDeckById,
|
||||
validateActionInputGetPublicDecks,
|
||||
validateActionInputSearchPublicDecks,
|
||||
validateActionInputGetPublicDeckById,
|
||||
validateActionInputToggleDeckFavorite,
|
||||
validateActionInputCheckDeckFavorite,
|
||||
} from "./deck-action-dto";
|
||||
import {
|
||||
serviceCreateDeck,
|
||||
serviceUpdateDeck,
|
||||
serviceDeleteDeck,
|
||||
serviceGetDeckById,
|
||||
serviceGetDecksByUserId,
|
||||
serviceGetPublicDecks,
|
||||
serviceCheckOwnership,
|
||||
serviceSearchPublicDecks,
|
||||
serviceGetPublicDeckById,
|
||||
serviceToggleDeckFavorite,
|
||||
serviceCheckDeckFavorite,
|
||||
serviceGetUserFavoriteDecks,
|
||||
} from "./deck-service";
|
||||
|
||||
const log = createLogger("deck-action");
|
||||
|
||||
async function checkDeckOwnership(deckId: number): Promise<boolean> {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) return false;
|
||||
return serviceCheckOwnership({ deckId, userId: session.user.id });
|
||||
}
|
||||
|
||||
export async function actionCreateDeck(input: ActionInputCreateDeck): Promise<ActionOutputCreateDeck> {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
|
||||
const validatedInput = validateActionInputCreateDeck(input);
|
||||
const result = await serviceCreateDeck({
|
||||
name: validatedInput.name,
|
||||
desc: validatedInput.desc,
|
||||
userId: session.user.id,
|
||||
visibility: validatedInput.visibility as Visibility | undefined,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to create deck", { error: e });
|
||||
return { success: false, message: "Unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionUpdateDeck(input: ActionInputUpdateDeck): Promise<ActionOutputUpdateDeck> {
|
||||
try {
|
||||
const validatedInput = validateActionInputUpdateDeck(input);
|
||||
|
||||
const isOwner = await checkDeckOwnership(validatedInput.deckId);
|
||||
if (!isOwner) {
|
||||
return { success: false, message: "You do not have permission to update this deck" };
|
||||
}
|
||||
|
||||
return serviceUpdateDeck({
|
||||
deckId: validatedInput.deckId,
|
||||
name: validatedInput.name,
|
||||
desc: validatedInput.desc,
|
||||
visibility: validatedInput.visibility as Visibility | undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to update deck", { error: e });
|
||||
return { success: false, message: "Unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionDeleteDeck(input: ActionInputDeleteDeck): Promise<ActionOutputDeleteDeck> {
|
||||
try {
|
||||
const validatedInput = validateActionInputDeleteDeck(input);
|
||||
|
||||
const isOwner = await checkDeckOwnership(validatedInput.deckId);
|
||||
if (!isOwner) {
|
||||
return { success: false, message: "You do not have permission to delete this deck" };
|
||||
}
|
||||
|
||||
return serviceDeleteDeck({ deckId: validatedInput.deckId });
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to delete deck", { error: e });
|
||||
return { success: false, message: "Unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetDeckById(input: ActionInputGetDeckById): Promise<ActionOutputGetDeckById> {
|
||||
try {
|
||||
const validatedInput = validateActionInputGetDeckById(input);
|
||||
const result = await serviceGetDeckById({ deckId: validatedInput.deckId });
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return { success: false, message: result.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: result.message,
|
||||
data: {
|
||||
...result.data,
|
||||
visibility: result.data.visibility as "PRIVATE" | "PUBLIC",
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Failed to get deck", { error: e });
|
||||
return { success: false, message: "Unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetDecksByUserId(userId: string): Promise<ActionOutputGetDecksByUserId> {
|
||||
try {
|
||||
const result = await serviceGetDecksByUserId({ userId });
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return { success: false, message: result.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: result.message,
|
||||
data: result.data.map((deck) => ({
|
||||
...deck,
|
||||
visibility: deck.visibility as "PRIVATE" | "PUBLIC",
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Failed to get decks", { error: e });
|
||||
return { success: false, message: "Unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetPublicDecks(input: ActionInputGetPublicDecks = {}): Promise<ActionOutputGetPublicDecks> {
|
||||
try {
|
||||
const validatedInput = validateActionInputGetPublicDecks(input);
|
||||
const result = await serviceGetPublicDecks(validatedInput);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return { success: false, message: result.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: result.message,
|
||||
data: result.data.map((deck) => ({
|
||||
...deck,
|
||||
visibility: deck.visibility as "PRIVATE" | "PUBLIC",
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to get public decks", { error: e });
|
||||
return { success: false, message: "Unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetPublicDeckById(input: ActionInputGetPublicDeckById): Promise<ActionOutputGetPublicDeckById> {
|
||||
try {
|
||||
const validatedInput = validateActionInputGetPublicDeckById(input);
|
||||
const result = await serviceGetPublicDeckById(validatedInput);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return { success: false, message: result.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: result.message,
|
||||
data: {
|
||||
...result.data,
|
||||
visibility: result.data.visibility as "PRIVATE" | "PUBLIC",
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to get public deck", { error: e });
|
||||
return { success: false, message: "Unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionSearchPublicDecks(input: ActionInputSearchPublicDecks): Promise<ActionOutputSearchPublicDecks> {
|
||||
try {
|
||||
const validatedInput = validateActionInputSearchPublicDecks(input);
|
||||
const result = await serviceSearchPublicDecks(validatedInput);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return { success: false, message: result.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: result.message,
|
||||
data: result.data.map((deck) => ({
|
||||
...deck,
|
||||
visibility: deck.visibility as "PRIVATE" | "PUBLIC",
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to search public decks", { error: e });
|
||||
return { success: false, message: "Unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionToggleDeckFavorite(input: ActionInputToggleDeckFavorite): Promise<ActionOutputToggleDeckFavorite> {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
|
||||
const validatedInput = validateActionInputToggleDeckFavorite(input);
|
||||
const result = await serviceToggleDeckFavorite({
|
||||
deckId: validatedInput.deckId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to toggle deck favorite", { error: e });
|
||||
return { success: false, message: "Unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionCheckDeckFavorite(input: ActionInputCheckDeckFavorite): Promise<ActionOutputCheckDeckFavorite> {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
return { success: true, message: "Not logged in", data: { isFavorited: false, favoriteCount: 0 } };
|
||||
}
|
||||
|
||||
const validatedInput = validateActionInputCheckDeckFavorite(input);
|
||||
const result = await serviceCheckDeckFavorite({
|
||||
deckId: validatedInput.deckId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to check deck favorite", { error: e });
|
||||
return { success: false, message: "Unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetUserFavoriteDecks(): Promise<ActionOutputGetUserFavoriteDecks> {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
|
||||
const result = await serviceGetUserFavoriteDecks(session.user.id);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return { success: false, message: result.message };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: result.message,
|
||||
data: result.data.map((deck) => ({
|
||||
...deck,
|
||||
visibility: deck.visibility as "PRIVATE" | "PUBLIC",
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Failed to get user favorite decks", { error: e });
|
||||
return { success: false, message: "Unknown error occurred" };
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { Visibility } from "../../../generated/prisma/enums";
|
||||
|
||||
import type { DictionaryItemWithEntries } from "@/shared/card-type";
|
||||
|
||||
export interface RepoInputCreateDeck {
|
||||
name: string;
|
||||
desc?: string;
|
||||
userId: string;
|
||||
visibility?: Visibility;
|
||||
}
|
||||
export interface RepoInputUpdateDeck {
|
||||
id: number;
|
||||
name?: string;
|
||||
desc?: string;
|
||||
visibility?: Visibility;
|
||||
}
|
||||
export interface RepoInputGetDeckById {
|
||||
id: number;
|
||||
}
|
||||
export interface RepoInputGetDecksByUserId {
|
||||
userId: string;
|
||||
}
|
||||
export interface RepoInputGetPublicDecks {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
orderBy?: "createdAt" | "name";
|
||||
}
|
||||
export interface RepoInputDeleteDeck {
|
||||
id: number;
|
||||
}
|
||||
export type RepoOutputDeck = {
|
||||
id: number;
|
||||
name: string;
|
||||
desc: string;
|
||||
userId: string;
|
||||
visibility: Visibility;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
cardCount?: number;
|
||||
};
|
||||
export type RepoOutputPublicDeck = RepoOutputDeck & {
|
||||
userName: string | null;
|
||||
userUsername: string | null;
|
||||
favoriteCount: number;
|
||||
};
|
||||
export type RepoOutputDeckOwnership = {
|
||||
userId: string;
|
||||
};
|
||||
export interface RepoInputToggleDeckFavorite {
|
||||
deckId: number;
|
||||
userId: string;
|
||||
}
|
||||
export interface RepoInputCheckDeckFavorite {
|
||||
deckId: number;
|
||||
userId: string;
|
||||
}
|
||||
export interface RepoInputSearchPublicDecks {
|
||||
query: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
export interface RepoInputGetPublicDeckById {
|
||||
deckId: number;
|
||||
}
|
||||
export type RepoOutputDeckFavorite = {
|
||||
isFavorited: boolean;
|
||||
favoriteCount: number;
|
||||
};
|
||||
export interface RepoInputGetUserFavoriteDecks {
|
||||
userId: string;
|
||||
}
|
||||
export type RepoOutputUserFavoriteDeck = RepoOutputPublicDeck & {
|
||||
favoritedAt: Date;
|
||||
};
|
||||
@@ -1,315 +0,0 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
RepoInputCreateDeck,
|
||||
RepoInputUpdateDeck,
|
||||
RepoInputGetDeckById,
|
||||
RepoInputGetDecksByUserId,
|
||||
RepoInputGetPublicDecks,
|
||||
RepoInputDeleteDeck,
|
||||
RepoOutputDeck,
|
||||
RepoOutputPublicDeck,
|
||||
RepoOutputDeckOwnership,
|
||||
RepoInputToggleDeckFavorite,
|
||||
RepoInputCheckDeckFavorite,
|
||||
RepoInputSearchPublicDecks,
|
||||
RepoInputGetPublicDeckById,
|
||||
RepoOutputDeckFavorite,
|
||||
RepoInputGetUserFavoriteDecks,
|
||||
RepoOutputUserFavoriteDeck,
|
||||
} from "./deck-repository-dto";
|
||||
import { Visibility } from "../../../generated/prisma/enums";
|
||||
|
||||
export async function repoCreateDeck(data: RepoInputCreateDeck): Promise<number> {
|
||||
const deck = await prisma.deck.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
desc: data.desc ?? "",
|
||||
userId: data.userId,
|
||||
visibility: data.visibility ?? Visibility.PRIVATE,
|
||||
},
|
||||
});
|
||||
return deck.id;
|
||||
}
|
||||
|
||||
export async function repoUpdateDeck(input: RepoInputUpdateDeck): Promise<void> {
|
||||
const { id, ...updateData } = input;
|
||||
await prisma.deck.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
export async function repoGetDeckById(input: RepoInputGetDeckById): Promise<RepoOutputDeck | null> {
|
||||
const deck = await prisma.deck.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
_count: {
|
||||
select: { cards: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!deck) return null;
|
||||
|
||||
return {
|
||||
id: deck.id,
|
||||
name: deck.name,
|
||||
desc: deck.desc,
|
||||
userId: deck.userId,
|
||||
visibility: deck.visibility,
|
||||
createdAt: deck.createdAt,
|
||||
updatedAt: deck.updatedAt,
|
||||
cardCount: deck._count?.cards ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function repoGetDecksByUserId(input: RepoInputGetDecksByUserId): Promise<RepoOutputDeck[]> {
|
||||
const decks = await prisma.deck.findMany({
|
||||
where: { userId: input.userId },
|
||||
include: {
|
||||
_count: {
|
||||
select: { cards: true },
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return decks.map((deck) => ({
|
||||
id: deck.id,
|
||||
name: deck.name,
|
||||
desc: deck.desc,
|
||||
userId: deck.userId,
|
||||
visibility: deck.visibility,
|
||||
createdAt: deck.createdAt,
|
||||
updatedAt: deck.updatedAt,
|
||||
cardCount: deck._count?.cards ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function repoGetPublicDecks(input: RepoInputGetPublicDecks = {}): Promise<RepoOutputPublicDeck[]> {
|
||||
const { limit = 50, offset = 0, orderBy = "createdAt" } = input;
|
||||
|
||||
const decks = await prisma.deck.findMany({
|
||||
where: { visibility: Visibility.PUBLIC },
|
||||
include: {
|
||||
_count: {
|
||||
select: { cards: true, favorites: true },
|
||||
},
|
||||
user: {
|
||||
select: { name: true, username: true },
|
||||
},
|
||||
},
|
||||
orderBy: { [orderBy]: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
return decks.map((deck) => ({
|
||||
id: deck.id,
|
||||
name: deck.name,
|
||||
desc: deck.desc,
|
||||
userId: deck.userId,
|
||||
visibility: deck.visibility,
|
||||
createdAt: deck.createdAt,
|
||||
updatedAt: deck.updatedAt,
|
||||
cardCount: deck._count?.cards ?? 0,
|
||||
userName: deck.user?.name ?? null,
|
||||
userUsername: deck.user?.username ?? null,
|
||||
favoriteCount: deck._count?.favorites ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function repoDeleteDeck(input: RepoInputDeleteDeck): Promise<void> {
|
||||
await prisma.deck.delete({
|
||||
where: { id: input.id },
|
||||
});
|
||||
}
|
||||
|
||||
export async function repoGetUserIdByDeckId(deckId: number): Promise<string | null> {
|
||||
const deck = await prisma.deck.findUnique({
|
||||
where: { id: deckId },
|
||||
select: { userId: true },
|
||||
});
|
||||
return deck?.userId ?? null;
|
||||
}
|
||||
|
||||
export async function repoGetDeckOwnership(deckId: number): Promise<RepoOutputDeckOwnership | null> {
|
||||
const deck = await prisma.deck.findUnique({
|
||||
where: { id: deckId },
|
||||
select: { userId: true },
|
||||
});
|
||||
return deck;
|
||||
}
|
||||
|
||||
export async function repoGetPublicDeckById(input: RepoInputGetPublicDeckById): Promise<RepoOutputPublicDeck | null> {
|
||||
const deck = await prisma.deck.findFirst({
|
||||
where: {
|
||||
id: input.deckId,
|
||||
visibility: Visibility.PUBLIC,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { cards: true, favorites: true },
|
||||
},
|
||||
user: {
|
||||
select: { name: true, username: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!deck) return null;
|
||||
|
||||
return {
|
||||
id: deck.id,
|
||||
name: deck.name,
|
||||
desc: deck.desc,
|
||||
userId: deck.userId,
|
||||
visibility: deck.visibility,
|
||||
createdAt: deck.createdAt,
|
||||
updatedAt: deck.updatedAt,
|
||||
cardCount: deck._count?.cards ?? 0,
|
||||
userName: deck.user?.name ?? null,
|
||||
userUsername: deck.user?.username ?? null,
|
||||
favoriteCount: deck._count?.favorites ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function repoToggleDeckFavorite(input: RepoInputToggleDeckFavorite): Promise<RepoOutputDeckFavorite> {
|
||||
const existing = await prisma.deckFavorite.findUnique({
|
||||
where: {
|
||||
userId_deckId: {
|
||||
userId: input.userId,
|
||||
deckId: input.deckId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.deckFavorite.delete({
|
||||
where: { id: existing.id },
|
||||
});
|
||||
} else {
|
||||
await prisma.deckFavorite.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
deckId: input.deckId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const deck = await prisma.deck.findUnique({
|
||||
where: { id: input.deckId },
|
||||
include: {
|
||||
_count: {
|
||||
select: { favorites: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
isFavorited: !existing,
|
||||
favoriteCount: deck?._count?.favorites ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function repoCheckDeckFavorite(input: RepoInputCheckDeckFavorite): Promise<RepoOutputDeckFavorite> {
|
||||
const favorite = await prisma.deckFavorite.findUnique({
|
||||
where: {
|
||||
userId_deckId: {
|
||||
userId: input.userId,
|
||||
deckId: input.deckId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const deck = await prisma.deck.findUnique({
|
||||
where: { id: input.deckId },
|
||||
include: {
|
||||
_count: {
|
||||
select: { favorites: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
isFavorited: !!favorite,
|
||||
favoriteCount: deck?._count?.favorites ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function repoSearchPublicDecks(input: RepoInputSearchPublicDecks): Promise<RepoOutputPublicDeck[]> {
|
||||
const { query, limit = 50, offset = 0 } = input;
|
||||
|
||||
const decks = await prisma.deck.findMany({
|
||||
where: {
|
||||
visibility: Visibility.PUBLIC,
|
||||
OR: [
|
||||
{ name: { contains: query, mode: "insensitive" } },
|
||||
{ desc: { contains: query, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { cards: true, favorites: true },
|
||||
},
|
||||
user: {
|
||||
select: { name: true, username: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
return decks.map((deck) => ({
|
||||
id: deck.id,
|
||||
name: deck.name,
|
||||
desc: deck.desc,
|
||||
userId: deck.userId,
|
||||
visibility: deck.visibility,
|
||||
createdAt: deck.createdAt,
|
||||
updatedAt: deck.updatedAt,
|
||||
cardCount: deck._count?.cards ?? 0,
|
||||
userName: deck.user?.name ?? null,
|
||||
userUsername: deck.user?.username ?? null,
|
||||
favoriteCount: deck._count?.favorites ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function repoGetUserFavoriteDecks(
|
||||
input: RepoInputGetUserFavoriteDecks,
|
||||
): Promise<RepoOutputUserFavoriteDeck[]> {
|
||||
const favorites = await prisma.deckFavorite.findMany({
|
||||
where: { userId: input.userId },
|
||||
include: {
|
||||
deck: {
|
||||
include: {
|
||||
_count: {
|
||||
select: { cards: true, favorites: true },
|
||||
},
|
||||
user: {
|
||||
select: { name: true, username: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return favorites.map((fav) => ({
|
||||
id: fav.deck.id,
|
||||
name: fav.deck.name,
|
||||
desc: fav.deck.desc,
|
||||
userId: fav.deck.userId,
|
||||
visibility: fav.deck.visibility,
|
||||
createdAt: fav.deck.createdAt,
|
||||
updatedAt: fav.deck.updatedAt,
|
||||
cardCount: fav.deck._count?.cards ?? 0,
|
||||
userName: fav.deck.user?.name ?? null,
|
||||
userUsername: fav.deck.user?.username ?? null,
|
||||
favoriteCount: fav.deck._count?.favorites ?? 0,
|
||||
favoritedAt: fav.createdAt,
|
||||
}));
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { Visibility } from "../../../generated/prisma/enums";
|
||||
|
||||
export type ServiceInputCreateDeck = {
|
||||
name: string;
|
||||
desc?: string;
|
||||
userId: string;
|
||||
visibility?: Visibility;
|
||||
};
|
||||
|
||||
export type ServiceInputUpdateDeck = {
|
||||
deckId: number;
|
||||
name?: string;
|
||||
desc?: string;
|
||||
visibility?: Visibility;
|
||||
};
|
||||
|
||||
export type ServiceInputDeleteDeck = {
|
||||
deckId: number;
|
||||
};
|
||||
|
||||
export type ServiceInputGetDeckById = {
|
||||
deckId: number;
|
||||
};
|
||||
|
||||
export type ServiceInputGetDecksByUserId = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceInputGetPublicDecks = {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type ServiceInputCheckOwnership = {
|
||||
deckId: number;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceOutputDeck = {
|
||||
id: number;
|
||||
name: string;
|
||||
desc: string;
|
||||
userId: string;
|
||||
visibility: Visibility;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
cardCount?: number;
|
||||
};
|
||||
|
||||
export type ServiceOutputPublicDeck = ServiceOutputDeck & {
|
||||
userName: string | null;
|
||||
userUsername: string | null;
|
||||
favoriteCount: number;
|
||||
};
|
||||
|
||||
export type ServiceInputToggleDeckFavorite = {
|
||||
deckId: number;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceInputCheckDeckFavorite = {
|
||||
deckId: number;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceInputSearchPublicDecks = {
|
||||
query: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type ServiceInputGetPublicDeckById = {
|
||||
deckId: number;
|
||||
};
|
||||
|
||||
export type ServiceOutputDeckFavorite = {
|
||||
isFavorited: boolean;
|
||||
favoriteCount: number;
|
||||
};
|
||||
|
||||
export type ServiceOutputUserFavoriteDeck = ServiceOutputPublicDeck & {
|
||||
favoritedAt: Date;
|
||||
};
|
||||
@@ -1,166 +0,0 @@
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import {
|
||||
ServiceInputCreateDeck,
|
||||
ServiceInputUpdateDeck,
|
||||
ServiceInputDeleteDeck,
|
||||
ServiceInputGetDeckById,
|
||||
ServiceInputGetDecksByUserId,
|
||||
ServiceInputGetPublicDecks,
|
||||
ServiceInputCheckOwnership,
|
||||
ServiceOutputDeck,
|
||||
ServiceOutputPublicDeck,
|
||||
ServiceInputToggleDeckFavorite,
|
||||
ServiceInputCheckDeckFavorite,
|
||||
ServiceInputSearchPublicDecks,
|
||||
ServiceInputGetPublicDeckById,
|
||||
ServiceOutputDeckFavorite,
|
||||
ServiceOutputUserFavoriteDeck,
|
||||
} from "./deck-service-dto";
|
||||
import {
|
||||
repoCreateDeck,
|
||||
repoUpdateDeck,
|
||||
repoGetDeckById,
|
||||
repoGetDecksByUserId,
|
||||
repoGetPublicDecks,
|
||||
repoDeleteDeck,
|
||||
repoGetUserIdByDeckId,
|
||||
repoToggleDeckFavorite,
|
||||
repoCheckDeckFavorite,
|
||||
repoSearchPublicDecks,
|
||||
repoGetPublicDeckById,
|
||||
repoGetUserFavoriteDecks,
|
||||
} from "./deck-repository";
|
||||
|
||||
const log = createLogger("deck-service");
|
||||
|
||||
export async function serviceCheckOwnership(input: ServiceInputCheckOwnership): Promise<boolean> {
|
||||
const ownerId = await repoGetUserIdByDeckId(input.deckId);
|
||||
return ownerId === input.userId;
|
||||
}
|
||||
|
||||
export async function serviceCreateDeck(input: ServiceInputCreateDeck): Promise<{ success: boolean; deckId?: number; message: string }> {
|
||||
try {
|
||||
log.info("Creating deck", { name: input.name, userId: input.userId });
|
||||
const deckId = await repoCreateDeck(input);
|
||||
log.info("Deck created successfully", { deckId });
|
||||
return { success: true, deckId, message: "Deck created successfully" };
|
||||
} catch (error) {
|
||||
log.error("Failed to create deck", { error });
|
||||
return { success: false, message: "Failed to create deck" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function serviceUpdateDeck(input: ServiceInputUpdateDeck): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
log.info("Updating deck", { deckId: input.deckId });
|
||||
await repoUpdateDeck({
|
||||
id: input.deckId,
|
||||
name: input.name,
|
||||
desc: input.desc,
|
||||
visibility: input.visibility,
|
||||
});
|
||||
log.info("Deck updated successfully", { deckId: input.deckId });
|
||||
return { success: true, message: "Deck updated successfully" };
|
||||
} catch (error) {
|
||||
log.error("Failed to update deck", { error, deckId: input.deckId });
|
||||
return { success: false, message: "Failed to update deck" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function serviceDeleteDeck(input: ServiceInputDeleteDeck): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
log.info("Deleting deck", { deckId: input.deckId });
|
||||
await repoDeleteDeck({ id: input.deckId });
|
||||
log.info("Deck deleted successfully", { deckId: input.deckId });
|
||||
return { success: true, message: "Deck deleted successfully" };
|
||||
} catch (error) {
|
||||
log.error("Failed to delete deck", { error, deckId: input.deckId });
|
||||
return { success: false, message: "Failed to delete deck" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function serviceGetDeckById(input: ServiceInputGetDeckById): Promise<{ success: boolean; data?: ServiceOutputDeck; message: string }> {
|
||||
try {
|
||||
const deck = await repoGetDeckById({ id: input.deckId });
|
||||
if (!deck) {
|
||||
return { success: false, message: "Deck not found" };
|
||||
}
|
||||
return { success: true, data: deck, message: "Deck retrieved successfully" };
|
||||
} catch (error) {
|
||||
log.error("Failed to get deck", { error, deckId: input.deckId });
|
||||
return { success: false, message: "Failed to get deck" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function serviceGetDecksByUserId(input: ServiceInputGetDecksByUserId): Promise<{ success: boolean; data?: ServiceOutputDeck[]; message: string }> {
|
||||
try {
|
||||
const decks = await repoGetDecksByUserId(input);
|
||||
return { success: true, data: decks, message: "Decks retrieved successfully" };
|
||||
} catch (error) {
|
||||
log.error("Failed to get decks", { error, userId: input.userId });
|
||||
return { success: false, message: "Failed to get decks" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function serviceGetPublicDecks(input: ServiceInputGetPublicDecks = {}): Promise<{ success: boolean; data?: ServiceOutputPublicDeck[]; message: string }> {
|
||||
try {
|
||||
const decks = await repoGetPublicDecks(input);
|
||||
return { success: true, data: decks, message: "Public decks retrieved successfully" };
|
||||
} catch (error) {
|
||||
log.error("Failed to get public decks", { error });
|
||||
return { success: false, message: "Failed to get public decks" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function serviceGetPublicDeckById(input: ServiceInputGetPublicDeckById): Promise<{ success: boolean; data?: ServiceOutputPublicDeck; message: string }> {
|
||||
try {
|
||||
const deck = await repoGetPublicDeckById(input);
|
||||
if (!deck) {
|
||||
return { success: false, message: "Deck not found or not public" };
|
||||
}
|
||||
return { success: true, data: deck, message: "Deck retrieved successfully" };
|
||||
} catch (error) {
|
||||
log.error("Failed to get public deck", { error, deckId: input.deckId });
|
||||
return { success: false, message: "Failed to get deck" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function serviceToggleDeckFavorite(input: ServiceInputToggleDeckFavorite): Promise<{ success: boolean; data?: ServiceOutputDeckFavorite; message: string }> {
|
||||
try {
|
||||
const result = await repoToggleDeckFavorite(input);
|
||||
return { success: true, data: result, message: "Favorite toggled successfully" };
|
||||
} catch (error) {
|
||||
log.error("Failed to toggle deck favorite", { error, deckId: input.deckId });
|
||||
return { success: false, message: "Failed to toggle favorite" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function serviceCheckDeckFavorite(input: ServiceInputCheckDeckFavorite): Promise<{ success: boolean; data?: ServiceOutputDeckFavorite; message: string }> {
|
||||
try {
|
||||
const result = await repoCheckDeckFavorite(input);
|
||||
return { success: true, data: result, message: "Favorite status retrieved" };
|
||||
} catch (error) {
|
||||
log.error("Failed to check deck favorite", { error, deckId: input.deckId });
|
||||
return { success: false, message: "Failed to check favorite status" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function serviceSearchPublicDecks(input: ServiceInputSearchPublicDecks): Promise<{ success: boolean; data?: ServiceOutputPublicDeck[]; message: string }> {
|
||||
try {
|
||||
const decks = await repoSearchPublicDecks(input);
|
||||
return { success: true, data: decks, message: "Search completed successfully" };
|
||||
} catch (error) {
|
||||
log.error("Failed to search public decks", { error, query: input.query });
|
||||
return { success: false, message: "Search failed" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function serviceGetUserFavoriteDecks(userId: string): Promise<{ success: boolean; data?: ServiceOutputUserFavoriteDeck[]; message: string }> {
|
||||
try {
|
||||
const favorites = await repoGetUserFavoriteDecks({ userId });
|
||||
return { success: true, data: favorites, message: "Favorite decks retrieved successfully" };
|
||||
} catch (error) {
|
||||
log.error("Failed to get user favorite decks", { error, userId });
|
||||
return { success: false, message: "Failed to get favorite decks" };
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,22 @@
|
||||
import z from "zod";
|
||||
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";
|
||||
|
||||
export const schemaActionLookUpDictionary = z.object({
|
||||
text: z.string().min(1),
|
||||
queryLang: z.string().min(1),
|
||||
definitionLang: z.string().min(1),
|
||||
const schemaActionInputLookUpDictionary = z.object({
|
||||
text: z.string().min(LENGTH_MIN_DICTIONARY_TEXT).max(LENGTH_MAX_DICTIONARY_TEXT),
|
||||
queryLang: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE),
|
||||
forceRelook: z.boolean(),
|
||||
definitionLang: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE),
|
||||
userId: z.string().optional()
|
||||
});
|
||||
|
||||
export type ActionInputLookUpDictionary = z.infer<typeof schemaActionLookUpDictionary>;
|
||||
export const validateActionInputLookUpDictionary = generateValidator(schemaActionLookUpDictionary);
|
||||
export type ActionInputLookUpDictionary = z.infer<typeof schemaActionInputLookUpDictionary>;
|
||||
|
||||
export const validateActionInputLookUpDictionary = generateValidator(schemaActionInputLookUpDictionary);
|
||||
|
||||
export type ActionOutputLookUpDictionary = {
|
||||
message: string,
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: {
|
||||
standardForm: string;
|
||||
entries: Array<{
|
||||
ipa?: string;
|
||||
definition: string;
|
||||
partOfSpeech?: string;
|
||||
example: string;
|
||||
}>;
|
||||
};
|
||||
data?: TSharedItem;
|
||||
};
|
||||
|
||||
@@ -1,38 +1,30 @@
|
||||
"use server";
|
||||
|
||||
import { executeDictionaryLookup } from "@/lib/bigmodel/dictionary/orchestrator";
|
||||
import { ActionInputLookUpDictionary, ActionOutputLookUpDictionary, validateActionInputLookUpDictionary } from "./dictionary-action-dto";
|
||||
import { ValidateError } from "@/lib/errors";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { LookUpError } from "@/lib/errors";
|
||||
import {
|
||||
ActionInputLookUpDictionary,
|
||||
ActionOutputLookUpDictionary,
|
||||
validateActionInputLookUpDictionary,
|
||||
} from "./dictionary-action-dto";
|
||||
import { serviceLookUp } from "./dictionary-service";
|
||||
|
||||
const log = createLogger("dictionary-action");
|
||||
|
||||
export async function actionLookUpDictionary(
|
||||
input: unknown,
|
||||
): Promise<ActionOutputLookUpDictionary> {
|
||||
export const actionLookUpDictionary = async (dto: ActionInputLookUpDictionary): Promise<ActionOutputLookUpDictionary> => {
|
||||
try {
|
||||
const validated = validateActionInputLookUpDictionary(input);
|
||||
|
||||
const result = await executeDictionaryLookup(
|
||||
validated.text,
|
||||
validated.queryLang,
|
||||
validated.definitionLang
|
||||
);
|
||||
|
||||
return {
|
||||
message: 'success',
|
||||
success: true,
|
||||
message: "Lookup successful",
|
||||
data: result,
|
||||
data: await serviceLookUp(validateActionInputLookUpDictionary(dto))
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof LookUpError) {
|
||||
return { success: false, message: e.message };
|
||||
if (e instanceof ValidateError) {
|
||||
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" };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
38
src/modules/dictionary/dictionary-repository-dto.ts
Normal file
38
src/modules/dictionary/dictionary-repository-dto.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { TSharedItem } from "@/shared/dictionary-type";
|
||||
|
||||
export type RepoInputCreateDictionaryLookUp = {
|
||||
userId?: string;
|
||||
text: string;
|
||||
queryLang: string;
|
||||
definitionLang: string;
|
||||
dictionaryItemId?: number;
|
||||
};
|
||||
|
||||
export type RepoOutputSelectLastLookUpResult = TSharedItem & {id: number} | 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;
|
||||
};
|
||||
86
src/modules/dictionary/dictionary-repository.ts
Normal file
86
src/modules/dictionary/dictionary-repository.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { stringNormalize } from "@/utils/string";
|
||||
import {
|
||||
RepoInputCreateDictionaryEntry,
|
||||
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 && result.dictionaryItem) {
|
||||
const item = result.dictionaryItem;
|
||||
return {
|
||||
id: item.id,
|
||||
standardForm: item.standardForm,
|
||||
entries: item.entries.filter(v => !!v).map(v => {
|
||||
return {
|
||||
ipa: v.ipa || undefined,
|
||||
definition: v.definition,
|
||||
partOfSpeech: v.partOfSpeech || undefined,
|
||||
example: v.example
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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,9 +1,11 @@
|
||||
export type ServiceOutputLookUp = {
|
||||
standardForm: string;
|
||||
entries: Array<{
|
||||
ipa?: string;
|
||||
definition: string;
|
||||
partOfSpeech?: string;
|
||||
example: string;
|
||||
}>;
|
||||
import { TSharedItem } from "@/shared/dictionary-type";
|
||||
|
||||
export type ServiceInputLookUp = {
|
||||
text: string,
|
||||
queryLang: string,
|
||||
definitionLang: string,
|
||||
forceRelook: boolean,
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export type ServiceOutputLookUp = TSharedItem;
|
||||
|
||||
64
src/modules/dictionary/dictionary-service.ts
Normal file
64
src/modules/dictionary/dictionary-service.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
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";
|
||||
|
||||
const log = createLogger("dictionary-service");
|
||||
|
||||
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 {
|
||||
repoCreateLookUp({
|
||||
userId: userId,
|
||||
text: text,
|
||||
queryLang: queryLang,
|
||||
definitionLang: definitionLang,
|
||||
dictionaryItemId: lastLookUpResult.id
|
||||
}).catch(error => {
|
||||
log.error("Failed to save dictionary data", { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
return {
|
||||
standardForm: lastLookUpResult.standardForm,
|
||||
entries: lastLookUpResult.entries
|
||||
};
|
||||
}
|
||||
};
|
||||
110
src/modules/folder/folder-action-dto.ts
Normal file
110
src/modules/folder/folder-action-dto.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { LENGTH_MAX_FOLDER_NAME, LENGTH_MAX_IPA, LENGTH_MAX_LANGUAGE, LENGTH_MAX_PAIR_TEXT, LENGTH_MIN_FOLDER_NAME, LENGTH_MIN_IPA, LENGTH_MIN_LANGUAGE, LENGTH_MIN_PAIR_TEXT } from "@/shared/constant";
|
||||
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
|
||||
import { generateValidator } from "@/utils/validate";
|
||||
import z from "zod";
|
||||
|
||||
export const schemaActionInputCreatePair = z.object({
|
||||
text1: z.string().min(LENGTH_MIN_PAIR_TEXT).max(LENGTH_MAX_PAIR_TEXT),
|
||||
text2: z.string().min(LENGTH_MIN_PAIR_TEXT).max(LENGTH_MAX_PAIR_TEXT),
|
||||
language1: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE),
|
||||
language2: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE),
|
||||
ipa1: z.string().min(LENGTH_MIN_IPA).max(LENGTH_MAX_IPA).optional(),
|
||||
ipa2: z.string().min(LENGTH_MIN_IPA).max(LENGTH_MAX_IPA).optional(),
|
||||
folderId: z.int()
|
||||
});
|
||||
export type ActionInputCreatePair = z.infer<typeof schemaActionInputCreatePair>;
|
||||
export const validateActionInputCreatePair = generateValidator(schemaActionInputCreatePair);
|
||||
|
||||
export const schemaActionInputUpdatePairById = z.object({
|
||||
text1: z.string().min(LENGTH_MIN_PAIR_TEXT).max(LENGTH_MAX_PAIR_TEXT).optional(),
|
||||
text2: z.string().min(LENGTH_MIN_PAIR_TEXT).max(LENGTH_MAX_PAIR_TEXT).optional(),
|
||||
language1: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE).optional(),
|
||||
language2: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE).optional(),
|
||||
ipa1: z.string().min(LENGTH_MIN_IPA).max(LENGTH_MAX_IPA).optional(),
|
||||
ipa2: z.string().min(LENGTH_MIN_IPA).max(LENGTH_MAX_IPA).optional(),
|
||||
folderId: z.int().optional()
|
||||
});
|
||||
export type ActionInputUpdatePairById = z.infer<typeof schemaActionInputUpdatePairById>;
|
||||
export const validateActionInputUpdatePairById = generateValidator(schemaActionInputUpdatePairById);
|
||||
|
||||
export type ActionOutputGetFoldersWithTotalPairsByUserId = {
|
||||
message: string,
|
||||
success: boolean,
|
||||
data?: TSharedFolderWithTotalPairs[];
|
||||
};
|
||||
|
||||
export const schemaActionInputSetFolderVisibility = z.object({
|
||||
folderId: z.number().int().positive(),
|
||||
visibility: z.enum(["PRIVATE", "PUBLIC"]),
|
||||
});
|
||||
export type ActionInputSetFolderVisibility = z.infer<typeof schemaActionInputSetFolderVisibility>;
|
||||
|
||||
export const schemaActionInputSearchPublicFolders = z.object({
|
||||
query: z.string().min(1).max(100),
|
||||
});
|
||||
export type ActionInputSearchPublicFolders = z.infer<typeof schemaActionInputSearchPublicFolders>;
|
||||
|
||||
export type ActionOutputPublicFolder = {
|
||||
id: number;
|
||||
name: string;
|
||||
visibility: "PRIVATE" | "PUBLIC";
|
||||
createdAt: Date;
|
||||
userId: string;
|
||||
userName: string | null;
|
||||
userUsername: string | null;
|
||||
totalPairs: number;
|
||||
favoriteCount: number;
|
||||
};
|
||||
|
||||
export type ActionOutputGetPublicFolders = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: ActionOutputPublicFolder[];
|
||||
};
|
||||
|
||||
export type ActionOutputGetPublicFolderById = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: ActionOutputPublicFolder;
|
||||
};
|
||||
|
||||
export type ActionOutputSetFolderVisibility = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
export type ActionOutputToggleFavorite = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: {
|
||||
isFavorited: boolean;
|
||||
favoriteCount: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ActionOutputCheckFavorite = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: {
|
||||
isFavorited: boolean;
|
||||
favoriteCount: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ActionOutputUserFavorite = {
|
||||
id: number;
|
||||
folderId: number;
|
||||
folderName: string;
|
||||
folderCreatedAt: Date;
|
||||
folderTotalPairs: number;
|
||||
folderOwnerId: string;
|
||||
folderOwnerName: string | null;
|
||||
folderOwnerUsername: string | null;
|
||||
favoritedAt: Date;
|
||||
};
|
||||
|
||||
export type ActionOutputGetUserFavorites = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: ActionOutputUserFavorite[];
|
||||
};
|
||||
527
src/modules/folder/folder-action.ts
Normal file
527
src/modules/folder/folder-action.ts
Normal file
@@ -0,0 +1,527 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { ValidateError } from "@/lib/errors";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("folder-action");
|
||||
import {
|
||||
ActionInputCreatePair,
|
||||
ActionInputUpdatePairById,
|
||||
ActionOutputGetFoldersWithTotalPairsByUserId,
|
||||
ActionOutputGetPublicFolders,
|
||||
ActionOutputGetPublicFolderById,
|
||||
ActionOutputSetFolderVisibility,
|
||||
ActionOutputToggleFavorite,
|
||||
ActionOutputCheckFavorite,
|
||||
ActionOutputGetUserFavorites,
|
||||
ActionOutputUserFavorite,
|
||||
validateActionInputCreatePair,
|
||||
validateActionInputUpdatePairById,
|
||||
} from "./folder-action-dto";
|
||||
import {
|
||||
repoCreateFolder,
|
||||
repoCreatePair,
|
||||
repoDeleteFolderById,
|
||||
repoDeletePairById,
|
||||
repoGetFolderIdByPairId,
|
||||
repoGetFolderVisibility,
|
||||
repoGetFoldersByUserId,
|
||||
repoGetFoldersWithTotalPairsByUserId,
|
||||
repoGetPairsByFolderId,
|
||||
repoGetPublicFolders,
|
||||
repoGetPublicFolderById,
|
||||
repoGetUserIdByFolderId,
|
||||
repoRenameFolderById,
|
||||
repoSearchPublicFolders,
|
||||
repoUpdateFolderVisibility,
|
||||
repoUpdatePairById,
|
||||
repoToggleFavorite,
|
||||
repoCheckFavorite,
|
||||
repoGetUserFavorites,
|
||||
} from "./folder-repository";
|
||||
import { validate } from "@/utils/validate";
|
||||
import z from "zod";
|
||||
import { LENGTH_MAX_FOLDER_NAME, LENGTH_MIN_FOLDER_NAME } from "@/shared/constant";
|
||||
import { Visibility } from "../../../generated/prisma/enums";
|
||||
|
||||
async function checkFolderOwnership(folderId: number): Promise<boolean> {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) return false;
|
||||
|
||||
const folderOwnerId = await repoGetUserIdByFolderId(folderId);
|
||||
return folderOwnerId === session.user.id;
|
||||
}
|
||||
|
||||
async function checkPairOwnership(pairId: number): Promise<boolean> {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) return false;
|
||||
|
||||
const folderId = await repoGetFolderIdByPairId(pairId);
|
||||
if (!folderId) return false;
|
||||
|
||||
const folderOwnerId = await repoGetUserIdByFolderId(folderId);
|
||||
return folderOwnerId === session.user.id;
|
||||
}
|
||||
|
||||
export async function actionGetPairsByFolderId(folderId: number) {
|
||||
try {
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: await repoGetPairsByFolderId(folderId)
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionUpdatePairById(id: number, dto: ActionInputUpdatePairById) {
|
||||
try {
|
||||
const isOwner = await checkPairOwnership(id);
|
||||
if (!isOwner) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'You do not have permission to update this item.',
|
||||
};
|
||||
}
|
||||
|
||||
const validatedDto = validateActionInputUpdatePairById(dto);
|
||||
await repoUpdatePairById(id, validatedDto);
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetUserIdByFolderId(folderId: number) {
|
||||
try {
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: await repoGetUserIdByFolderId(folderId)
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetFolderVisibility(folderId: number) {
|
||||
try {
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: await repoGetFolderVisibility(folderId)
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionDeleteFolderById(folderId: number) {
|
||||
try {
|
||||
const isOwner = await checkFolderOwnership(folderId);
|
||||
if (!isOwner) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'You do not have permission to delete this folder.',
|
||||
};
|
||||
}
|
||||
|
||||
await repoDeleteFolderById(folderId);
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionDeletePairById(id: number) {
|
||||
try {
|
||||
const isOwner = await checkPairOwnership(id);
|
||||
if (!isOwner) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'You do not have permission to delete this item.',
|
||||
};
|
||||
}
|
||||
|
||||
await repoDeletePairById(id);
|
||||
return {
|
||||
success: true,
|
||||
message: 'success'
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetFoldersWithTotalPairsByUserId(id: string): Promise<ActionOutputGetFoldersWithTotalPairsByUserId> {
|
||||
try {
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: await repoGetFoldersWithTotalPairsByUserId(id)
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetFoldersByUserId(userId: string) {
|
||||
try {
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: await repoGetFoldersByUserId(userId)
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionCreatePair(dto: ActionInputCreatePair) {
|
||||
try {
|
||||
const isOwner = await checkFolderOwnership(dto.folderId);
|
||||
if (!isOwner) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'You do not have permission to add items to this folder.',
|
||||
};
|
||||
}
|
||||
|
||||
const validatedDto = validateActionInputCreatePair(dto);
|
||||
await repoCreatePair(validatedDto);
|
||||
return {
|
||||
success: true,
|
||||
message: 'success'
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return {
|
||||
success: false,
|
||||
message: e.message
|
||||
};
|
||||
}
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionCreateFolder(userId: string, folderName: string) {
|
||||
try {
|
||||
const validatedFolderName = validate(folderName,
|
||||
z.string()
|
||||
.trim()
|
||||
.min(LENGTH_MIN_FOLDER_NAME)
|
||||
.max(LENGTH_MAX_FOLDER_NAME));
|
||||
await repoCreateFolder({
|
||||
name: validatedFolderName,
|
||||
userId: userId
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: 'success'
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return {
|
||||
success: false,
|
||||
message: e.message
|
||||
};
|
||||
}
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionRenameFolderById(id: number, newName: string) {
|
||||
try {
|
||||
const isOwner = await checkFolderOwnership(id);
|
||||
if (!isOwner) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'You do not have permission to rename this folder.',
|
||||
};
|
||||
}
|
||||
|
||||
const validatedNewName = validate(
|
||||
newName,
|
||||
z.string()
|
||||
.min(LENGTH_MIN_FOLDER_NAME)
|
||||
.max(LENGTH_MAX_FOLDER_NAME)
|
||||
.trim());
|
||||
await repoRenameFolderById(id, validatedNewName);
|
||||
return {
|
||||
success: true,
|
||||
message: 'success'
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return {
|
||||
success: false,
|
||||
message: e.message
|
||||
};
|
||||
}
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionSetFolderVisibility(
|
||||
folderId: number,
|
||||
visibility: "PRIVATE" | "PUBLIC",
|
||||
): Promise<ActionOutputSetFolderVisibility> {
|
||||
try {
|
||||
const isOwner = await checkFolderOwnership(folderId);
|
||||
if (!isOwner) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'You do not have permission to change this folder visibility.',
|
||||
};
|
||||
}
|
||||
|
||||
await repoUpdateFolderVisibility({
|
||||
folderId,
|
||||
visibility: visibility as Visibility,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetPublicFolders(): Promise<ActionOutputGetPublicFolders> {
|
||||
try {
|
||||
const data = await repoGetPublicFolders({});
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: data.map((folder) => ({
|
||||
...folder,
|
||||
visibility: folder.visibility as "PRIVATE" | "PUBLIC",
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionSearchPublicFolders(query: string): Promise<ActionOutputGetPublicFolders> {
|
||||
try {
|
||||
const data = await repoSearchPublicFolders({ query, limit: 50 });
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: data.map((folder) => ({
|
||||
...folder,
|
||||
visibility: folder.visibility as "PRIVATE" | "PUBLIC",
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetPublicFolderById(folderId: number): Promise<ActionOutputGetPublicFolderById> {
|
||||
try {
|
||||
const folder = await repoGetPublicFolderById(folderId);
|
||||
if (!folder) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Folder not found.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: {
|
||||
...folder,
|
||||
visibility: folder.visibility as "PRIVATE" | "PUBLIC",
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionToggleFavorite(
|
||||
folderId: number,
|
||||
): Promise<ActionOutputToggleFavorite> {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unauthorized',
|
||||
};
|
||||
}
|
||||
|
||||
const isFavorited = await repoToggleFavorite({
|
||||
folderId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
const { favoriteCount } = await repoCheckFavorite({
|
||||
folderId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: {
|
||||
isFavorited,
|
||||
favoriteCount,
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionCheckFavorite(
|
||||
folderId: number,
|
||||
): Promise<ActionOutputCheckFavorite> {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: {
|
||||
isFavorited: false,
|
||||
favoriteCount: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { isFavorited, favoriteCount } = await repoCheckFavorite({
|
||||
folderId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: {
|
||||
isFavorited,
|
||||
favoriteCount,
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetUserFavorites(): Promise<ActionOutputGetUserFavorites> {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unauthorized',
|
||||
};
|
||||
}
|
||||
|
||||
const favorites = await repoGetUserFavorites({
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: favorites.map((fav) => ({
|
||||
id: fav.id,
|
||||
folderId: fav.folderId,
|
||||
folderName: fav.folderName,
|
||||
folderCreatedAt: fav.folderCreatedAt,
|
||||
folderTotalPairs: fav.folderTotalPairs,
|
||||
folderOwnerId: fav.folderOwnerId,
|
||||
folderOwnerName: fav.folderOwnerName,
|
||||
folderOwnerUsername: fav.folderOwnerUsername,
|
||||
favoritedAt: fav.favoritedAt,
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.',
|
||||
};
|
||||
}
|
||||
}
|
||||
91
src/modules/folder/folder-repository-dto.ts
Normal file
91
src/modules/folder/folder-repository-dto.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Visibility } from "../../../generated/prisma/enums";
|
||||
|
||||
export interface RepoInputCreateFolder {
|
||||
name: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface RepoInputCreatePair {
|
||||
text1: string;
|
||||
text2: string;
|
||||
language1: string;
|
||||
language2: string;
|
||||
ipa1?: string;
|
||||
ipa2?: string;
|
||||
folderId: number;
|
||||
}
|
||||
|
||||
export interface RepoInputUpdatePair {
|
||||
text1?: string;
|
||||
text2?: string;
|
||||
language1?: string;
|
||||
language2?: string;
|
||||
ipa1?: string;
|
||||
ipa2?: string;
|
||||
}
|
||||
|
||||
export interface RepoInputUpdateFolderVisibility {
|
||||
folderId: number;
|
||||
visibility: Visibility;
|
||||
}
|
||||
|
||||
export interface RepoInputSearchPublicFolders {
|
||||
query: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface RepoInputGetPublicFolders {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
orderBy?: "createdAt" | "name";
|
||||
}
|
||||
|
||||
export type RepoOutputPublicFolder = {
|
||||
id: number;
|
||||
name: string;
|
||||
visibility: Visibility;
|
||||
createdAt: Date;
|
||||
userId: string;
|
||||
userName: string | null;
|
||||
userUsername: string | null;
|
||||
totalPairs: number;
|
||||
favoriteCount: number;
|
||||
};
|
||||
|
||||
export type RepoOutputFolderVisibility = {
|
||||
visibility: Visibility;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export interface RepoInputToggleFavorite {
|
||||
folderId: number;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface RepoInputCheckFavorite {
|
||||
folderId: number;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export type RepoOutputFavoriteStatus = {
|
||||
isFavorited: boolean;
|
||||
favoriteCount: number;
|
||||
};
|
||||
|
||||
export interface RepoInputGetUserFavorites {
|
||||
userId: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export type RepoOutputUserFavorite = {
|
||||
id: number;
|
||||
folderId: number;
|
||||
folderName: string;
|
||||
folderCreatedAt: Date;
|
||||
folderTotalPairs: number;
|
||||
folderOwnerId: string;
|
||||
folderOwnerName: string | null;
|
||||
folderOwnerUsername: string | null;
|
||||
favoritedAt: Date;
|
||||
};
|
||||
333
src/modules/folder/folder-repository.ts
Normal file
333
src/modules/folder/folder-repository.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
RepoInputCreateFolder,
|
||||
RepoInputCreatePair,
|
||||
RepoInputUpdatePair,
|
||||
RepoInputUpdateFolderVisibility,
|
||||
RepoInputSearchPublicFolders,
|
||||
RepoInputGetPublicFolders,
|
||||
RepoOutputPublicFolder,
|
||||
RepoOutputFolderVisibility,
|
||||
RepoInputToggleFavorite,
|
||||
RepoInputCheckFavorite,
|
||||
RepoOutputFavoriteStatus,
|
||||
RepoInputGetUserFavorites,
|
||||
RepoOutputUserFavorite,
|
||||
} from "./folder-repository-dto";
|
||||
import { Visibility } from "../../../generated/prisma/enums";
|
||||
|
||||
export async function repoCreatePair(data: RepoInputCreatePair) {
|
||||
return (await prisma.pair.create({
|
||||
data: data,
|
||||
})).id;
|
||||
}
|
||||
|
||||
export async function repoDeletePairById(id: number) {
|
||||
await prisma.pair.delete({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function repoUpdatePairById(
|
||||
id: number,
|
||||
data: RepoInputUpdatePair,
|
||||
) {
|
||||
await prisma.pair.update({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function repoGetPairCountByFolderId(folderId: number) {
|
||||
return prisma.pair.count({
|
||||
where: {
|
||||
folderId: folderId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function repoGetPairsByFolderId(folderId: number) {
|
||||
return (await prisma.pair.findMany({
|
||||
where: {
|
||||
folderId: folderId,
|
||||
},
|
||||
})).map(pair => {
|
||||
return {
|
||||
text1:pair.text1,
|
||||
text2: pair.text2,
|
||||
language1: pair.language1,
|
||||
language2: pair.language2,
|
||||
ipa1: pair.ipa1,
|
||||
ipa2: pair.ipa2,
|
||||
id: pair.id,
|
||||
folderId: pair.folderId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function repoGetFoldersByUserId(userId: string) {
|
||||
return (await prisma.folder.findMany({
|
||||
where: {
|
||||
userId: userId,
|
||||
},
|
||||
}))?.map(v => {
|
||||
return {
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
userId: v.userId,
|
||||
visibility: v.visibility,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function repoRenameFolderById(id: number, newName: string) {
|
||||
await prisma.folder.update({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
data: {
|
||||
name: newName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function repoGetFoldersWithTotalPairsByUserId(userId: string) {
|
||||
const folders = await prisma.folder.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
_count: {
|
||||
select: { pairs: true },
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
return folders.map(folder => ({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
userId: folder.userId,
|
||||
visibility: folder.visibility,
|
||||
total: folder._count?.pairs ?? 0,
|
||||
createdAt: folder.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function repoCreateFolder(folder: RepoInputCreateFolder) {
|
||||
await prisma.folder.create({
|
||||
data: folder,
|
||||
});
|
||||
}
|
||||
|
||||
export async function repoDeleteFolderById(id: number) {
|
||||
await prisma.folder.delete({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function repoGetUserIdByFolderId(id: number) {
|
||||
const folder = await prisma.folder.findUnique({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
});
|
||||
return folder?.userId;
|
||||
}
|
||||
|
||||
export async function repoGetFolderIdByPairId(pairId: number) {
|
||||
const pair = await prisma.pair.findUnique({
|
||||
where: {
|
||||
id: pairId,
|
||||
},
|
||||
select: {
|
||||
folderId: true,
|
||||
},
|
||||
});
|
||||
return pair?.folderId;
|
||||
}
|
||||
|
||||
export async function repoUpdateFolderVisibility(
|
||||
input: RepoInputUpdateFolderVisibility,
|
||||
): Promise<void> {
|
||||
await prisma.folder.update({
|
||||
where: { id: input.folderId },
|
||||
data: { visibility: input.visibility },
|
||||
});
|
||||
}
|
||||
|
||||
export async function repoGetFolderVisibility(
|
||||
folderId: number,
|
||||
): Promise<RepoOutputFolderVisibility | null> {
|
||||
const folder = await prisma.folder.findUnique({
|
||||
where: { id: folderId },
|
||||
select: { visibility: true, userId: true },
|
||||
});
|
||||
return folder;
|
||||
}
|
||||
|
||||
export async function repoGetPublicFolderById(
|
||||
folderId: number,
|
||||
): Promise<RepoOutputPublicFolder | null> {
|
||||
const folder = await prisma.folder.findUnique({
|
||||
where: { id: folderId, visibility: Visibility.PUBLIC },
|
||||
include: {
|
||||
_count: { select: { pairs: true, favorites: true } },
|
||||
user: { select: { name: true, username: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) return null;
|
||||
|
||||
return {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
visibility: folder.visibility,
|
||||
createdAt: folder.createdAt,
|
||||
userId: folder.userId,
|
||||
userName: folder.user?.name ?? "Unknown",
|
||||
userUsername: folder.user?.username ?? "unknown",
|
||||
totalPairs: folder._count.pairs,
|
||||
favoriteCount: folder._count.favorites,
|
||||
};
|
||||
}
|
||||
|
||||
export async function repoGetPublicFolders(
|
||||
input: RepoInputGetPublicFolders = {},
|
||||
): Promise<RepoOutputPublicFolder[]> {
|
||||
const { limit = 50, offset = 0, orderBy = "createdAt" } = input;
|
||||
|
||||
const folders = await prisma.folder.findMany({
|
||||
where: { visibility: Visibility.PUBLIC },
|
||||
include: {
|
||||
_count: { select: { pairs: true, favorites: true } },
|
||||
user: { select: { name: true, username: true } },
|
||||
},
|
||||
orderBy: { [orderBy]: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
return folders.map((folder) => ({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
visibility: folder.visibility,
|
||||
createdAt: folder.createdAt,
|
||||
userId: folder.userId,
|
||||
userName: folder.user?.name ?? "Unknown",
|
||||
userUsername: folder.user?.username ?? "unknown",
|
||||
totalPairs: folder._count.pairs,
|
||||
favoriteCount: folder._count.favorites,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function repoSearchPublicFolders(
|
||||
input: RepoInputSearchPublicFolders,
|
||||
): Promise<RepoOutputPublicFolder[]> {
|
||||
const { query, limit = 50 } = input;
|
||||
const folders = await prisma.folder.findMany({
|
||||
where: {
|
||||
visibility: Visibility.PUBLIC,
|
||||
name: { contains: query, mode: "insensitive" },
|
||||
},
|
||||
include: {
|
||||
_count: { select: { pairs: true, favorites: true } },
|
||||
user: { select: { name: true, username: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
return folders.map((folder) => ({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
visibility: folder.visibility,
|
||||
createdAt: folder.createdAt,
|
||||
userId: folder.userId,
|
||||
userName: folder.user?.name ?? "Unknown",
|
||||
userUsername: folder.user?.username ?? "unknown",
|
||||
totalPairs: folder._count.pairs,
|
||||
favoriteCount: folder._count.favorites,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function repoToggleFavorite(
|
||||
input: RepoInputToggleFavorite,
|
||||
): Promise<boolean> {
|
||||
const existing = await prisma.folderFavorite.findUnique({
|
||||
where: {
|
||||
userId_folderId: {
|
||||
userId: input.userId,
|
||||
folderId: input.folderId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
await prisma.folderFavorite.delete({
|
||||
where: { id: existing.id },
|
||||
});
|
||||
return false;
|
||||
} else {
|
||||
await prisma.folderFavorite.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
folderId: input.folderId,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function repoCheckFavorite(
|
||||
input: RepoInputCheckFavorite,
|
||||
): Promise<RepoOutputFavoriteStatus> {
|
||||
const favorite = await prisma.folderFavorite.findUnique({
|
||||
where: {
|
||||
userId_folderId: {
|
||||
userId: input.userId,
|
||||
folderId: input.folderId,
|
||||
},
|
||||
},
|
||||
});
|
||||
const count = await prisma.folderFavorite.count({
|
||||
where: { folderId: input.folderId },
|
||||
});
|
||||
return {
|
||||
isFavorited: !!favorite,
|
||||
favoriteCount: count,
|
||||
};
|
||||
}
|
||||
|
||||
export async function repoGetUserFavorites(input: RepoInputGetUserFavorites) {
|
||||
const { userId, limit = 50, offset = 0 } = input;
|
||||
|
||||
const favorites = await prisma.folderFavorite.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
folder: {
|
||||
include: {
|
||||
_count: { select: { pairs: true } },
|
||||
user: { select: { name: true, username: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
return favorites.map((fav) => ({
|
||||
id: fav.id,
|
||||
folderId: fav.folderId,
|
||||
folderName: fav.folder.name,
|
||||
folderCreatedAt: fav.folder.createdAt,
|
||||
folderTotalPairs: fav.folder._count.pairs,
|
||||
folderOwnerId: fav.folder.userId,
|
||||
folderOwnerName: fav.folder.user?.name ?? "Unknown",
|
||||
folderOwnerUsername: fav.folder.user?.username ?? "unknown",
|
||||
favoritedAt: fav.createdAt,
|
||||
}));
|
||||
}
|
||||
108
src/modules/folder/folder-service-dto.ts
Normal file
108
src/modules/folder/folder-service-dto.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Visibility } from "../../../generated/prisma/enums";
|
||||
|
||||
export type ServiceInputCreateFolder = {
|
||||
name: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceInputRenameFolder = {
|
||||
folderId: number;
|
||||
newName: string;
|
||||
};
|
||||
|
||||
export type ServiceInputDeleteFolder = {
|
||||
folderId: number;
|
||||
};
|
||||
|
||||
export type ServiceInputSetVisibility = {
|
||||
folderId: number;
|
||||
visibility: Visibility;
|
||||
};
|
||||
|
||||
export type ServiceInputCheckOwnership = {
|
||||
folderId: number;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceInputCheckPairOwnership = {
|
||||
pairId: number;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceInputCreatePair = {
|
||||
folderId: number;
|
||||
text1: string;
|
||||
text2: string;
|
||||
language1: string;
|
||||
language2: string;
|
||||
};
|
||||
|
||||
export type ServiceInputUpdatePair = {
|
||||
pairId: number;
|
||||
text1?: string;
|
||||
text2?: string;
|
||||
language1?: string;
|
||||
language2?: string;
|
||||
};
|
||||
|
||||
export type ServiceInputDeletePair = {
|
||||
pairId: number;
|
||||
};
|
||||
|
||||
export type ServiceInputGetPublicFolders = {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type ServiceInputSearchPublicFolders = {
|
||||
query: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type ServiceInputToggleFavorite = {
|
||||
folderId: number;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceInputCheckFavorite = {
|
||||
folderId: number;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceInputGetUserFavorites = {
|
||||
userId: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type ServiceOutputFolder = {
|
||||
id: number;
|
||||
name: string;
|
||||
visibility: Visibility;
|
||||
createdAt: Date;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceOutputFolderWithDetails = ServiceOutputFolder & {
|
||||
userName: string | null;
|
||||
userUsername: string | null;
|
||||
totalPairs: number;
|
||||
favoriteCount: number;
|
||||
};
|
||||
|
||||
export type ServiceOutputFavoriteStatus = {
|
||||
isFavorited: boolean;
|
||||
favoriteCount: number;
|
||||
};
|
||||
|
||||
export type ServiceOutputUserFavorite = {
|
||||
id: number;
|
||||
folderId: number;
|
||||
folderName: string;
|
||||
folderCreatedAt: Date;
|
||||
folderTotalPairs: number;
|
||||
folderOwnerId: string;
|
||||
folderOwnerName: string | null;
|
||||
folderOwnerUsername: string | null;
|
||||
favoritedAt: Date;
|
||||
};
|
||||
0
src/modules/folder/folder-service.ts
Normal file
0
src/modules/folder/folder-service.ts
Normal file
@@ -1,25 +0,0 @@
|
||||
"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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user