Compare commits

..

1 Commits

Author SHA1 Message Date
5898a6ba65 feat: 添加用户关注功能
- 新增 Follow 表和 User.bio 字段 (Prisma schema)
- 创建 follow 模块 (action-service-repository)
- 新增 FollowButton/FollowStats/UserList 组件
- 用户页面显示 bio、粉丝/关注数、关注按钮
- 新增 /users/[username]/followers 和 following 页面
- 添加 en-US/zh-CN i18n 翻译

⚠️ 需要运行: prisma migrate dev --name add_follow_and_bio
2026-03-10 14:56:06 +08:00
110 changed files with 3312 additions and 7830 deletions

View File

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

View File

@@ -46,38 +46,6 @@
"unfavorite": "Aus Favoriten entfernen", "unfavorite": "Aus Favoriten entfernen",
"pleaseLogin": "Bitte melden Sie sich zuerst an" "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": { "folder_id": {
"unauthorized": "Sie sind nicht der Besitzer dieses Ordners", "unauthorized": "Sie sind nicht der Besitzer dieses Ordners",
"back": "Zurück", "back": "Zurück",
@@ -189,9 +157,6 @@
"resetPasswordFailed": "Reset-E-Mail konnte nicht gesendet werden", "resetPasswordFailed": "Reset-E-Mail konnte nicht gesendet werden",
"resetPasswordEmailSent": "Reset-E-Mail erfolgreich gesendet", "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.", "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", "checkYourEmail": "Überprüfen Sie Ihre E-Mail",
"backToLogin": "Zurück zur Anmeldung", "backToLogin": "Zurück zur Anmeldung",
"resetPassword": "Passwort zurücksetzen", "resetPassword": "Passwort zurücksetzen",
@@ -201,47 +166,25 @@
"requestNewToken": "Neuen Reset-Link anfordern", "requestNewToken": "Neuen Reset-Link anfordern",
"resetPasswordSuccess": "Passwort erfolgreich zurückgesetzt", "resetPasswordSuccess": "Passwort erfolgreich zurückgesetzt",
"resetPasswordSuccessTitle": "Passwort-Zurücksetzung abgeschlossen", "resetPasswordSuccessTitle": "Passwort-Zurücksetzung abgeschlossen",
"resetPasswordSuccessHint": "Ihr Passwort wurde erfolgreich zurückgesetzt. Sie können sich jetzt mit Ihrem neuen Passwort anmelden.", "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"
}, },
"memorize": { "memorize": {
"deck_selector": { "folder_selector": {
"selectDeck": "Deck wählen", "selectFolder": "Wählen Sie einen Ordner",
"noDecks": "Keine Decks", "noFolders": "Keine Ordner gefunden",
"goToDecks": "Zu Decks", "folderInfo": "{id}. {name} ({count})"
"noCards": "Keine Karten",
"new": "Neu",
"learning": "Lernen",
"review": "Wiederholen",
"due": "Fällig"
}, },
"review": { "memorize": {
"loading": "Laden...", "answer": "Antwort",
"backToDecks": "Zurück zu Decks", "next": "Weiter",
"allDone": "Alles erledigt!", "reverse": "Umkehren",
"allDoneDesc": "Lernen für heute abgeschlossen!", "dictation": "Diktat",
"reviewedCount": "{count} Karten wiederholt", "noTextPairs": "Keine Textpaare verfügbar",
"progress": "{current} / {total}", "disorder": "Mischen",
"nextReview": "Nächste Wiederholung", "previous": "Zurück"
"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"
}, },
"page": { "page": {
"unauthorized": "Nicht autorisiert" "unauthorized": "Sie sind nicht berechtigt, auf diesen Ordner zuzugreifen"
} }
}, },
"navbar": { "navbar": {
@@ -249,56 +192,11 @@
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "Anmelden", "sign_in": "Anmelden",
"profile": "Profil", "profile": "Profil",
"folders": "Decks", "folders": "Ordner",
"explore": "Erkunden", "explore": "Erkunden",
"favorites": "Favoriten", "favorites": "Favoriten",
"settings": "Einstellungen" "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": { "profile": {
"myProfile": "Mein Profil", "myProfile": "Mein Profil",
"email": "E-Mail: {email}", "email": "E-Mail: {email}",
@@ -338,43 +236,12 @@
"videoUploadFailed": "Video-Upload fehlgeschlagen", "videoUploadFailed": "Video-Upload fehlgeschlagen",
"subtitleUploadFailed": "Untertitel-Upload fehlgeschlagen", "subtitleUploadFailed": "Untertitel-Upload fehlgeschlagen",
"subtitleLoadSuccess": "Untertitel erfolgreich geladen", "subtitleLoadSuccess": "Untertitel erfolgreich geladen",
"subtitleLoadFailed": "Laden der Untertitel fehlgeschlagen", "subtitleLoadFailed": "Laden der Untertitel fehlgeschlagen"
"settings": "Einstellungen",
"shortcuts": "Tastenkürzel",
"keyboardShortcuts": "Tastaturkürzel",
"playPause": "Wiedergabe/Pause",
"autoPauseToggle": "Auto-Pause",
"subtitleSettings": "Untertiteleinstellungen",
"fontSize": "Schriftgröße",
"textColor": "Textfarbe",
"backgroundColor": "Hintergrundfarbe",
"position": "Position",
"opacity": "Deckkraft",
"top": "Oben",
"center": "Mitte",
"bottom": "Unten"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "IPA generieren", "generateIPA": "IPA generieren",
"viewSavedItems": "Gespeicherte Einträge anzeigen", "viewSavedItems": "Gespeicherte Einträge anzeigen",
"confirmDeleteAll": "Sind Sie sicher, dass Sie alles löschen möchten? (J/N)", "confirmDeleteAll": "Sind Sie sicher, dass Sie alles löschen möchten? (J/N)"
"saved": "Gespeichert",
"clearAll": "Alles löschen",
"language": "Sprache",
"customLanguage": "oder Sprache eingeben...",
"languages": {
"auto": "Automatisch",
"chinese": "Chinesisch",
"english": "Englisch",
"japanese": "Japanisch",
"korean": "Koreanisch",
"french": "Französisch",
"german": "Deutsch",
"italian": "Italienisch",
"spanish": "Spanisch",
"portuguese": "Portugiesisch",
"russian": "Russisch"
}
}, },
"translator": { "translator": {
"detectLanguage": "Sprache erkennen", "detectLanguage": "Sprache erkennen",
@@ -407,20 +274,7 @@
"success": "Textpaar zum Ordner hinzugefügt", "success": "Textpaar zum Ordner hinzugefügt",
"error": "Fehler beim Hinzufügen des Textpaars zum Ordner" "error": "Fehler beim Hinzufügen des Textpaars zum Ordner"
}, },
"autoSave": "Autom. Speichern", "autoSave": "Autom. Speichern"
"customLanguage": "oder Sprache eingeben...",
"pleaseLogin": "Bitte anmelden um Karten zu speichern",
"pleaseCreateDeck": "Bitte erst zuerst ein Deck",
"noTranslationToSave": "Keine Übersetzung zum Speichern",
"noDeckSelected": "Kein Deck ausgewählt",
"saveAsCard": "Als Karte speichern",
"selectDeck": "Deck wählen",
"front": "Vorderseite",
"back": "Rückseite",
"cancel": "Abbrechen",
"save": "Speichern",
"savedToDeck": "Karte in {deckName} gespeichert",
"saveFailed": "Karte speichern fehlgeschlagen"
}, },
"dictionary": { "dictionary": {
"title": "Wörterbuch", "title": "Wörterbuch",
@@ -465,9 +319,7 @@
"unfavorite": "Aus Favoriten entfernen", "unfavorite": "Aus Favoriten entfernen",
"pleaseLogin": "Bitte melden Sie sich zuerst an", "pleaseLogin": "Bitte melden Sie sich zuerst an",
"sortByFavorites": "Nach Favoriten sortieren", "sortByFavorites": "Nach Favoriten sortieren",
"sortByFavoritesActive": "Sortierung nach Favoriten aufheben", "sortByFavoritesActive": "Sortierung nach Favoriten aufheben"
"noDecks": "Keine öffentlichen Decks",
"deckInfo": "{userName} · {totalCards} Karten"
}, },
"exploreDetail": { "exploreDetail": {
"title": "Ordnerdetails", "title": "Ordnerdetails",
@@ -481,8 +333,7 @@
"unfavorite": "Aus Favoriten entfernen", "unfavorite": "Aus Favoriten entfernen",
"favorited": "Favorisiert", "favorited": "Favorisiert",
"unfavorited": "Aus Favoriten entfernt", "unfavorited": "Aus Favoriten entfernt",
"pleaseLogin": "Bitte melden Sie sich zuerst an", "pleaseLogin": "Bitte melden Sie sich zuerst an"
"totalCards": "{count} Karten"
}, },
"favorites": { "favorites": {
"title": "Meine Favoriten", "title": "Meine Favoriten",
@@ -504,119 +355,14 @@
"notSet": "Nicht festgelegt", "notSet": "Nicht festgelegt",
"memberSince": "Mitglied seit", "memberSince": "Mitglied seit",
"logout": "Abmelden", "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": { "folders": {
"title": "Decks", "title": "Ordner",
"noFolders": "Noch keine Decks", "noFolders": "Noch keine Ordner",
"folderName": "Deckname", "folderName": "Ordnername",
"totalPairs": "Gesamtkarten", "totalPairs": "Gesamtpaare",
"createdAt": "Erstellt am", "createdAt": "Erstellt am",
"actions": "Aktionen", "actions": "Aktionen",
"view": "Anzeigen" "view": "Anzeigen"
},
"joined": "Beigetreten",
"decks": {
"title": "Meine Decks",
"noDecks": "Keine Decks",
"deckName": "Deck-Name",
"totalCards": "Gesamtkarten",
"createdAt": "Erstellt am",
"actions": "Aktionen",
"view": "Ansehen"
} }
},
"follow": {
"follow": "Folgen",
"following": "Folge ich",
"followers": "Follower",
"followersOf": "{username}s Follower",
"followingOf": "{username} folgt",
"noFollowers": "Noch keine Follower",
"noFollowing": "Folgt noch niemandem"
},
"deck_id": {
"unauthorized": "Sie sind nicht der Besitzer dieses Decks",
"back": "Zurück",
"cards": "Karten",
"itemsCount": "{count} Elemente",
"memorize": "Auswendig lernen",
"loadingCards": "Karten werden geladen...",
"noCards": "Keine Karten in diesem Deck",
"card": "Karte",
"addNewCard": "Neue Karte hinzufügen",
"add": "Hinzufügen",
"adding": "Wird hinzugefügt...",
"updateCard": "Karte aktualisieren",
"update": "Aktualisieren",
"updating": "Wird aktualisiert...",
"word": "Wort",
"definition": "Definition",
"ipa": "IPA",
"example": "Beispiel",
"wordAndDefinitionRequired": "Wort und Definition sind erforderlich",
"edit": "Bearbeiten",
"delete": "Löschen",
"permissionDenied": "Sie haben keine Berechtigung für diese Aktion",
"resetProgress": "Fortschritt zurücksetzen",
"resetProgressTitle": "Lernfortschritt zurücksetzen",
"resetProgressConfirm": "Fortschritt wirklich zurücksetzen?",
"resetSuccess": "Fortschritt zurückgesetzt",
"resetting": "Zurücksetzen...",
"cancel": "Abbrechen",
"settings": "Einstellungen",
"settingsTitle": "Deck-Einstellungen",
"newPerDay": "Neue pro Tag",
"newPerDayHint": "Neue Karten pro Tag",
"revPerDay": "Wiederholungen pro Tag",
"revPerDayHint": "Wiederholungen pro Tag",
"save": "Speichern",
"saving": "Speichern...",
"settingsSaved": "Einstellungen gespeichert",
"todayNew": "Heute neu",
"todayReview": "Heute wiederholen",
"todayLearning": "Lernen",
"error": {
"update": "Keine Berechtigung zum Aktualisieren",
"delete": "Keine Berechtigung zum Löschen",
"add": "Keine Berechtigung zum Hinzufügen"
},
"ipaPlaceholder": "IPA eingeben",
"examplePlaceholder": "Beispiel eingeben",
"wordRequired": "Bitte Wort eingeben",
"definitionRequired": "Bitte Definition eingeben",
"cardAdded": "Karte hinzugefügt",
"cardType": "Kartentyp",
"wordCard": "Wortkarte",
"phraseCard": "Phrasenkarte",
"sentenceCard": "Satzkarte",
"sentence": "Satz",
"sentencePlaceholder": "Satz eingeben",
"wordPlaceholder": "Wort eingeben",
"queryLang": "Abfragesprache",
"enterLanguageName": "Bitte Sprachnamen eingeben",
"english": "Englisch",
"chinese": "Chinesisch",
"japanese": "Japanisch",
"korean": "Koreanisch",
"meanings": "Bedeutungen",
"addMeaning": "Bedeutung hinzufügen",
"partOfSpeech": "Wortart",
"deleteConfirm": "Karte wirklich löschen?",
"cardDeleted": "Karte gelöscht",
"cardUpdated": "Karte aktualisiert"
} }
} }

View File

@@ -74,77 +74,6 @@
"deleteFolder": "You do not have permission to delete this folder." "deleteFolder": "You do not have permission to delete this folder."
} }
}, },
"deck_id": {
"unauthorized": "You are not the owner of this deck",
"back": "Back",
"cards": "Cards",
"itemsCount": "{count} items",
"memorize": "Memorize",
"loadingCards": "Loading cards...",
"noCards": "No cards in this deck",
"card": "Card",
"addNewCard": "Add New Card",
"add": "Add",
"adding": "Adding...",
"updateCard": "Update Card",
"update": "Update",
"updating": "Updating...",
"word": "Word",
"definition": "Definition",
"ipa": "IPA",
"ipaPlaceholder": "Enter IPA pronunciation",
"example": "Example",
"examplePlaceholder": "Enter an example sentence",
"wordAndDefinitionRequired": "Word and definition are required",
"wordRequired": "Word is required",
"definitionRequired": "At least one definition is required",
"cardAdded": "Card added successfully",
"cardType": "Card Type",
"wordCard": "Word",
"phraseCard": "Phrase",
"sentenceCard": "Sentence",
"sentence": "Sentence",
"sentencePlaceholder": "Enter a sentence",
"wordPlaceholder": "Enter a word",
"queryLang": "Language",
"enterLanguageName": "Please enter language name",
"english": "English",
"chinese": "Chinese",
"japanese": "Japanese",
"korean": "Korean",
"meanings": "Meanings",
"addMeaning": "Add Meaning",
"partOfSpeech": "Part of Speech",
"edit": "Edit",
"delete": "Delete",
"deleteConfirm": "Are you sure you want to delete this card?",
"cardDeleted": "Card deleted",
"permissionDenied": "You do not have permission to perform this action",
"resetProgress": "Reset",
"resetProgressTitle": "Reset Deck Progress",
"resetProgressConfirm": "This will reset all cards in this deck to new state. Your learning progress will be lost. Are you sure?",
"resetSuccess": "Successfully reset {count} cards",
"resetting": "Resetting...",
"cancel": "Cancel",
"settings": "Settings",
"settingsTitle": "Deck Settings",
"newPerDay": "New Cards Per Day",
"newPerDayHint": "Maximum new cards to learn each day",
"revPerDay": "Review Cards Per Day",
"revPerDayHint": "Maximum review cards each day",
"save": "Save",
"saving": "Saving...",
"settingsSaved": "Settings saved",
"todayNew": "New",
"todayReview": "Review",
"todayLearning": "Learning",
"cardUpdated": "Card updated",
"error": {
"update": "You do not have permission to update this card.",
"delete": "You do not have permission to delete this card.",
"add": "You do not have permission to add cards to this deck."
}
},
"home": { "home": {
"title": "Learn Languages", "title": "Learn Languages",
"description": "Here is a very useful website to help you learn almost every language in the world, including constructed ones.", "description": "Here is a very useful website to help you learn almost every language in the world, including constructed ones.",
@@ -228,9 +157,6 @@
"resetPasswordFailed": "Failed to send reset email", "resetPasswordFailed": "Failed to send reset email",
"resetPasswordEmailSent": "Reset email sent successfully", "resetPasswordEmailSent": "Reset email sent successfully",
"resetPasswordEmailSentHint": "We've sent a password reset link to your email address. Please check your inbox.", "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", "checkYourEmail": "Check Your Email",
"backToLogin": "Back to Login", "backToLogin": "Back to Login",
"resetPassword": "Reset Password", "resetPassword": "Reset Password",
@@ -240,72 +166,25 @@
"requestNewToken": "Request New Reset Link", "requestNewToken": "Request New Reset Link",
"resetPasswordSuccess": "Password reset successfully", "resetPasswordSuccess": "Password reset successfully",
"resetPasswordSuccessTitle": "Password Reset Complete", "resetPasswordSuccessTitle": "Password Reset Complete",
"resetPasswordSuccessHint": "Your password has been reset successfully. You can now log in with your new password.", "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"
}, },
"memorize": { "memorize": {
"deck_selector": { "folder_selector": {
"selectDeck": "Select a deck", "selectFolder": "Select a folder",
"noDecks": "No decks found", "noFolders": "No folders found",
"goToDecks": "Go to Decks", "folderInfo": "{id}. {name} ({count})"
"noCards": "No cards",
"new": "New",
"learning": "Learning",
"review": "Review",
"due": "Due"
}, },
"review": { "memorize": {
"loading": "Loading cards...", "answer": "Answer",
"backToDecks": "Back to Decks", "next": "Next",
"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",
"reverse": "Reverse", "reverse": "Reverse",
"dictation": "Dictation", "dictation": "Dictation",
"clickToPlay": "Click to play audio", "noTextPairs": "No text pairs available",
"restart": "Restart", "disorder": "Disorder",
"yourAnswer": "Your answer", "previous": "Previous"
"typeWhatYouHear": "Type what you hear...",
"correct": "Correct",
"incorrect": "Incorrect",
"orderLimited": "Order",
"orderInfinite": "Loop",
"randomLimited": "Random",
"randomInfinite": "Random Loop",
"noIpa": "No IPA available"
}, },
"page": { "page": {
"unauthorized": "You are not authorized to access this deck" "unauthorized": "You are not authorized to access this folder"
} }
}, },
"navbar": { "navbar": {
@@ -313,56 +192,11 @@
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "Sign In", "sign_in": "Sign In",
"profile": "Profile", "profile": "Profile",
"folders": "Decks", "folders": "Folders",
"explore": "Explore", "explore": "Explore",
"favorites": "Favorites", "favorites": "Favorites",
"settings": "Settings" "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": { "profile": {
"myProfile": "My Profile", "myProfile": "My Profile",
"email": "Email: {email}", "email": "Email: {email}",
@@ -402,43 +236,12 @@
"videoUploadFailed": "Video upload failed", "videoUploadFailed": "Video upload failed",
"subtitleUploadFailed": "Subtitle upload failed", "subtitleUploadFailed": "Subtitle upload failed",
"subtitleLoadSuccess": "Subtitle loaded successfully", "subtitleLoadSuccess": "Subtitle loaded successfully",
"subtitleLoadFailed": "Subtitle load failed", "subtitleLoadFailed": "Subtitle load failed"
"settings": "Settings",
"shortcuts": "Shortcuts",
"keyboardShortcuts": "Keyboard Shortcuts",
"playPause": "Play/Pause",
"autoPauseToggle": "Toggle Auto Pause",
"subtitleSettings": "Subtitle Settings",
"fontSize": "Font Size",
"textColor": "Text Color",
"backgroundColor": "Background Color",
"position": "Position",
"opacity": "Opacity",
"top": "Top",
"center": "Center",
"bottom": "Bottom"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "Generate IPA", "generateIPA": "Generate IPA",
"viewSavedItems": "View Saved Items", "viewSavedItems": "View Saved Items",
"confirmDeleteAll": "Are you sure you want to delete everything? (Y/N)", "confirmDeleteAll": "Are you sure you want to delete everything? (Y/N)"
"saved": "Saved",
"clearAll": "Clear All",
"language": "Language",
"customLanguage": "or type language...",
"languages": {
"auto": "Auto",
"chinese": "Chinese",
"english": "English",
"japanese": "Japanese",
"korean": "Korean",
"french": "French",
"german": "German",
"italian": "Italian",
"spanish": "Spanish",
"portuguese": "Portuguese",
"russian": "Russian"
}
}, },
"translator": { "translator": {
"detectLanguage": "detect language", "detectLanguage": "detect language",
@@ -446,7 +249,6 @@
"auto": "Auto", "auto": "Auto",
"generateIPA": "generate ipa", "generateIPA": "generate ipa",
"translateInto": "translate into", "translateInto": "translate into",
"customLanguage": "or type language...",
"chinese": "Chinese", "chinese": "Chinese",
"english": "English", "english": "English",
"french": "French", "french": "French",
@@ -472,19 +274,7 @@
"success": "Text pair added to folder", "success": "Text pair added to folder",
"error": "Failed to add text pair to folder" "error": "Failed to add text pair to folder"
}, },
"autoSave": "Auto Save", "autoSave": "Auto Save"
"pleaseLogin": "Please login to save cards",
"pleaseCreateDeck": "Please create a deck first",
"noTranslationToSave": "No translation to save",
"noDeckSelected": "No deck selected",
"saveAsCard": "Save as Card",
"selectDeck": "Select Deck",
"front": "Front",
"back": "Back",
"cancel": "Cancel",
"save": "Save",
"savedToDeck": "Card saved to {deckName}",
"saveFailed": "Failed to save card"
}, },
"dictionary": { "dictionary": {
"title": "Dictionary", "title": "Dictionary",
@@ -519,11 +309,11 @@
}, },
"explore": { "explore": {
"title": "Explore", "title": "Explore",
"subtitle": "Discover public decks", "subtitle": "Discover public folders",
"searchPlaceholder": "Search public decks...", "searchPlaceholder": "Search public folders...",
"loading": "Loading...", "loading": "Loading...",
"noDecks": "No public decks found", "noFolders": "No public folders found",
"deckInfo": "{userName} • {cardCount} cards", "folderInfo": "{userName} • {totalPairs} pairs",
"unknownUser": "Unknown User", "unknownUser": "Unknown User",
"favorite": "Favorite", "favorite": "Favorite",
"unfavorite": "Unfavorite", "unfavorite": "Unfavorite",
@@ -532,10 +322,10 @@
"sortByFavoritesActive": "Undo sort by favorites" "sortByFavoritesActive": "Undo sort by favorites"
}, },
"exploreDetail": { "exploreDetail": {
"title": "Deck Details", "title": "Folder Details",
"createdBy": "Created by: {name}", "createdBy": "Created by: {name}",
"unknownUser": "Unknown User", "unknownUser": "Unknown User",
"totalCards": "Total Cards", "totalPairs": "Total Pairs",
"favorites": "Favorites", "favorites": "Favorites",
"createdAt": "Created At", "createdAt": "Created At",
"viewContent": "View Content", "viewContent": "View Content",
@@ -566,58 +356,16 @@
"memberSince": "Member Since", "memberSince": "Member Since",
"joined": "Joined", "joined": "Joined",
"logout": "Logout", "logout": "Logout",
"deleteAccount": { "folders": {
"button": "Delete Account", "title": "Folders",
"title": "Delete Account", "noFolders": "No folders yet",
"warning": "This action is irreversible. All your data will be permanently deleted.", "folderName": "Folder Name",
"warningDecks": "All your decks and cards", "totalPairs": "Total Pairs",
"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",
"createdAt": "Created At", "createdAt": "Created At",
"actions": "Actions", "actions": "Actions",
"view": "View" "view": "View"
} }
}, },
"decks": {
"title": "Decks",
"subtitle": "Manage your flashcard decks",
"newDeck": "New Deck",
"noDecksYet": "No decks yet",
"loading": "Loading...",
"deckInfo": "ID: {id} • {totalCards} cards",
"enterDeckName": "Enter deck name:",
"enterNewName": "Enter new name:",
"confirmDelete": "Type \"{name}\" to delete:",
"public": "Public",
"private": "Private",
"setPublic": "Set Public",
"setPrivate": "Set Private",
"importApkg": "Import APKG",
"exportApkg": "Export APKG",
"clickToUpload": "Click to upload an APKG file",
"apkgFilesOnly": "Only .apkg files are supported",
"parsing": "Parsing...",
"foundDecks": "Found {count} deck(s)",
"deckName": "Deck Name",
"back": "Back",
"import": "Import",
"importing": "Importing...",
"exportSuccess": "Deck exported successfully",
"goToDecks": "Go to Decks"
},
"follow": { "follow": {
"follow": "Follow", "follow": "Follow",
"following": "Following", "following": "Following",

View File

@@ -46,38 +46,6 @@
"unfavorite": "Retirer des favoris", "unfavorite": "Retirer des favoris",
"pleaseLogin": "Veuillez vous connecter d'abord" "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": { "folder_id": {
"unauthorized": "Vous n'êtes pas le propriétaire de ce dossier", "unauthorized": "Vous n'êtes pas le propriétaire de ce dossier",
"back": "Retour", "back": "Retour",
@@ -106,77 +74,6 @@
"deleteFolder": "Vous n'avez pas la permission de supprimer ce dossier." "deleteFolder": "Vous n'avez pas la permission de supprimer ce dossier."
} }
}, },
"deck_id": {
"unauthorized": "Vous n'êtes pas le propriétaire de ce deck",
"back": "Retour",
"cards": "Cartes",
"itemsCount": "{count} éléments",
"memorize": "Mémoriser",
"loadingCards": "Chargement des cartes...",
"noCards": "Aucune carte dans ce deck",
"card": "Carte",
"addNewCard": "Ajouter une nouvelle carte",
"add": "Ajouter",
"adding": "Ajout en cours...",
"updateCard": "Mettre à jour la carte",
"update": "Mettre à jour",
"updating": "Mise à jour en cours...",
"word": "Mot",
"definition": "Définition",
"ipa": "IPA",
"example": "Exemple",
"wordAndDefinitionRequired": "Le mot et la définition sont requis",
"edit": "Modifier",
"delete": "Supprimer",
"permissionDenied": "Vous n'avez pas la permission d'effectuer cette action",
"resetProgress": "Réinitialiser progression",
"resetProgressTitle": "Réinitialiser la progression",
"resetProgressConfirm": "Réinitialiser la progression?",
"resetSuccess": "Progression réinitialisée",
"resetting": "Réinitialisation...",
"cancel": "Annuler",
"settings": "Paramètres",
"settingsTitle": "Paramètres du deck",
"newPerDay": "Nouvelles par jour",
"newPerDayHint": "Nouvelles cartes par jour",
"revPerDay": "Révisions par jour",
"revPerDayHint": "Révisions par jour",
"save": "Enregistrer",
"saving": "Enregistrement...",
"settingsSaved": "Paramètres enregistrés",
"todayNew": "Nouvelles aujourd'hui",
"todayReview": "Révisions aujourd'hui",
"todayLearning": "En apprentissage",
"error": {
"update": "Pas autorisé à modifier",
"delete": "Pas autorisé à supprimer",
"add": "Pas autorisé à ajouter"
},
"ipaPlaceholder": "Entrer IPA",
"examplePlaceholder": "Entrer exemple",
"wordRequired": "Veuillez entrer un mot",
"definitionRequired": "Veuillez entrer une définition",
"cardAdded": "Carte ajoutée",
"cardType": "Type de carte",
"wordCard": "Carte mot",
"phraseCard": "Carte phrase",
"sentenceCard": "Carte phrase",
"sentence": "Phrase",
"sentencePlaceholder": "Entrer phrase",
"wordPlaceholder": "Entrer mot",
"queryLang": "Langue de requête",
"enterLanguageName": "Veuillez entrer le nom de la langue",
"english": "Anglais",
"chinese": "Chinois",
"japanese": "Japonais",
"korean": "Coréen",
"meanings": "Significations",
"addMeaning": "Ajouter signification",
"partOfSpeech": "Partie du discours",
"deleteConfirm": "Supprimer cette carte?",
"cardDeleted": "Carte supprimée",
"cardUpdated": "Carte mise à jour"
},
"home": { "home": {
"title": "Apprendre les langues", "title": "Apprendre les langues",
"description": "Voici un site Web très utile pour vous aider à apprendre presque toutes les langues du monde, y compris les langues construites.", "description": "Voici un site Web très utile pour vous aider à apprendre presque toutes les langues du monde, y compris les langues construites.",
@@ -260,9 +157,6 @@
"resetPasswordFailed": "Échec de l'envoi de l'e-mail de réinitialisation", "resetPasswordFailed": "Échec de l'envoi de l'e-mail de réinitialisation",
"resetPasswordEmailSent": "E-mail de réinitialisation envoyé avec succès", "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.", "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", "checkYourEmail": "Vérifiez votre e-mail",
"backToLogin": "Retour à la connexion", "backToLogin": "Retour à la connexion",
"resetPassword": "Réinitialiser le mot de passe", "resetPassword": "Réinitialiser le mot de passe",
@@ -272,47 +166,25 @@
"requestNewToken": "Demander un nouveau lien de réinitialisation", "requestNewToken": "Demander un nouveau lien de réinitialisation",
"resetPasswordSuccess": "Mot de passe réinitialisé avec succès", "resetPasswordSuccess": "Mot de passe réinitialisé avec succès",
"resetPasswordSuccessTitle": "Réinitialisation du mot de passe terminée", "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.", "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"
}, },
"memorize": { "memorize": {
"deck_selector": { "folder_selector": {
"selectDeck": "Choisir deck", "selectFolder": "Sélectionner un dossier",
"noDecks": "Pas de decks", "noFolders": "Aucun dossier trouvé",
"goToDecks": "Aller aux decks", "folderInfo": "{id}. {name} ({count})"
"noCards": "Pas de cartes",
"new": "Nouveau",
"learning": "Apprentissage",
"review": "Révision",
"due": "À faire"
}, },
"review": { "memorize": {
"loading": "Chargement...", "answer": "Réponse",
"backToDecks": "Retour aux decks", "next": "Suivant",
"allDone": "Tout terminé!", "reverse": "Inverser",
"allDoneDesc": "Apprentissage terminé pour aujourd'hui!", "dictation": "Dictée",
"reviewedCount": "{count} cartes révisées", "noTextPairs": "Aucune paire de texte disponible",
"progress": "{current} / {total}", "disorder": "Désordre",
"nextReview": "Prochaine révision", "previous": "Précédent"
"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"
}, },
"page": { "page": {
"unauthorized": "Non autorisé" "unauthorized": "Vous n'êtes pas autorisé à accéder à ce dossier"
} }
}, },
"navbar": { "navbar": {
@@ -320,56 +192,11 @@
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "Connexion", "sign_in": "Connexion",
"profile": "Profil", "profile": "Profil",
"folders": "Decks", "folders": "Dossiers",
"explore": "Explorer", "explore": "Explorer",
"favorites": "Favoris", "favorites": "Favoris",
"settings": "Paramètres" "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": { "profile": {
"myProfile": "Mon profil", "myProfile": "Mon profil",
"email": "E-mail : {email}", "email": "E-mail : {email}",
@@ -409,43 +236,12 @@
"videoUploadFailed": "Échec du téléchargement de la vidéo", "videoUploadFailed": "Échec du téléchargement de la vidéo",
"subtitleUploadFailed": "Échec du téléchargement des sous-titres", "subtitleUploadFailed": "Échec du téléchargement des sous-titres",
"subtitleLoadSuccess": "Sous-titres chargés avec succès", "subtitleLoadSuccess": "Sous-titres chargés avec succès",
"subtitleLoadFailed": "Échec du chargement des sous-titres", "subtitleLoadFailed": "Échec du chargement des sous-titres"
"settings": "Paramètres",
"shortcuts": "Raccourcis",
"keyboardShortcuts": "Raccourcis clavier",
"playPause": "Lecture/Pause",
"autoPauseToggle": "Pause auto",
"subtitleSettings": "Paramètres sous-titres",
"fontSize": "Taille police",
"textColor": "Couleur texte",
"backgroundColor": "Couleur fond",
"position": "Position",
"opacity": "Opacité",
"top": "Haut",
"center": "Centre",
"bottom": "Bas"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "Générer l'API", "generateIPA": "Générer l'API",
"viewSavedItems": "Voir les éléments enregistrés", "viewSavedItems": "Voir les éléments enregistrés",
"confirmDeleteAll": "Êtes-vous sûr de vouloir tout supprimer ? (O/N)", "confirmDeleteAll": "Êtes-vous sûr de vouloir tout supprimer ? (O/N)"
"saved": "Enregistré",
"clearAll": "Tout effacer",
"language": "Langue",
"customLanguage": "ou entrer une langue...",
"languages": {
"auto": "Auto",
"chinese": "Chinois",
"english": "Anglais",
"japanese": "Japonais",
"korean": "Coréen",
"french": "Français",
"german": "Allemand",
"italian": "Italien",
"spanish": "Espagnol",
"portuguese": "Portugais",
"russian": "Russe"
}
}, },
"translator": { "translator": {
"detectLanguage": "détecter la langue", "detectLanguage": "détecter la langue",
@@ -478,20 +274,7 @@
"success": "Paire de texte ajoutée au dossier", "success": "Paire de texte ajoutée au dossier",
"error": "Échec de l'ajout de la paire de texte au dossier" "error": "Échec de l'ajout de la paire de texte au dossier"
}, },
"autoSave": "Sauvegarde automatique", "autoSave": "Sauvegarde automatique"
"customLanguage": "ou tapez la langue...",
"pleaseLogin": "Connectez-vous pour sauvegarder",
"pleaseCreateDeck": "Créez d'abord un deck",
"noTranslationToSave": "Pas de traduction à sauvegarder",
"noDeckSelected": "Aucun deck sélectionné",
"saveAsCard": "Sauvegarder comme carte",
"selectDeck": "Sélectionner deck",
"front": "Recto",
"back": "Verso",
"cancel": "Annuler",
"save": "Sauvegarder",
"savedToDeck": "Carte sauvegardée dans {deckName}",
"saveFailed": "Échec de la sauvegarde"
}, },
"dictionary": { "dictionary": {
"title": "Dictionnaire", "title": "Dictionnaire",
@@ -536,9 +319,7 @@
"unfavorite": "Retirer des favoris", "unfavorite": "Retirer des favoris",
"pleaseLogin": "Veuillez vous connecter d'abord", "pleaseLogin": "Veuillez vous connecter d'abord",
"sortByFavorites": "Trier par favoris", "sortByFavorites": "Trier par favoris",
"sortByFavoritesActive": "Annuler le tri par favoris", "sortByFavoritesActive": "Annuler le tri par favoris"
"noDecks": "Pas de decks publics",
"deckInfo": "{userName} · {totalCards} cartes"
}, },
"exploreDetail": { "exploreDetail": {
"title": "Détails du dossier", "title": "Détails du dossier",
@@ -552,8 +333,7 @@
"unfavorite": "Retirer des favoris", "unfavorite": "Retirer des favoris",
"favorited": "Ajouté aux favoris", "favorited": "Ajouté aux favoris",
"unfavorited": "Retiré des favoris", "unfavorited": "Retiré des favoris",
"pleaseLogin": "Veuillez vous connecter d'abord", "pleaseLogin": "Veuillez vous connecter d'abord"
"totalCards": "{count} cartes"
}, },
"favorites": { "favorites": {
"title": "Mes favoris", "title": "Mes favoris",
@@ -575,39 +355,14 @@
"notSet": "Non défini", "notSet": "Non défini",
"memberSince": "Membre depuis", "memberSince": "Membre depuis",
"logout": "Déconnexion", "logout": "Déconnexion",
"deleteAccount": { "folders": {
"button": "Supprimer le compte", "title": "Dossiers",
"title": "Supprimer le compte", "noFolders": "Pas encore de dossiers",
"warning": "Cette action est irréversible. Toutes vos données seront définitivement supprimées.", "folderName": "Nom du dossier",
"warningDecks": "Tous vos decks et cartes", "totalPairs": "Total des paires",
"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",
"createdAt": "Créé le", "createdAt": "Créé le",
"actions": "Actions", "actions": "Actions",
"view": "Voir" "view": "Voir"
}, }
"joined": "Inscrit le"
},
"follow": {
"follow": "Suivre",
"following": "Abonné",
"followers": "Abonnés",
"followersOf": "Abonnés de {username}",
"followingOf": "Abonnements de {username}",
"noFollowers": "Pas encore d'abonnés",
"noFollowing": "Ne suit personne"
} }
} }

View File

@@ -46,38 +46,6 @@
"unfavorite": "Rimuovi dai preferiti", "unfavorite": "Rimuovi dai preferiti",
"pleaseLogin": "Per favore accedi prima" "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": { "folder_id": {
"unauthorized": "Non sei il proprietario di questa cartella", "unauthorized": "Non sei il proprietario di questa cartella",
"back": "Indietro", "back": "Indietro",
@@ -106,77 +74,6 @@
"deleteFolder": "Non hai il permesso di eliminare questa cartella." "deleteFolder": "Non hai il permesso di eliminare questa cartella."
} }
}, },
"deck_id": {
"unauthorized": "Non sei il proprietario di questo deck",
"back": "Indietro",
"cards": "Schede",
"itemsCount": "{count} elementi",
"memorize": "Memorizza",
"loadingCards": "Caricamento schede...",
"noCards": "Nessuna scheda in questo deck",
"card": "Scheda",
"addNewCard": "Aggiungi nuova scheda",
"add": "Aggiungi",
"adding": "Aggiunta in corso...",
"updateCard": "Aggiorna scheda",
"update": "Aggiorna",
"updating": "Aggiornamento in corso...",
"word": "Parola",
"definition": "Definizione",
"ipa": "IPA",
"example": "Esempio",
"wordAndDefinitionRequired": "Parola e definizione sono obbligatori",
"edit": "Modifica",
"delete": "Elimina",
"permissionDenied": "Non hai il permesso per questa accion",
"resetProgress": "Reimposta progresso",
"resetProgressTitle": "Reimposta progresso di apprendimento",
"resetProgressConfirm": "Reimpostare il progresso?",
"resetSuccess": "Progresso reimpostato",
"resetting": "Reimpostazione...",
"cancel": "Annulla",
"settings": "Impostazioni",
"settingsTitle": "Impostazioni deck",
"newPerDay": "Nuove al giorno",
"newPerDayHint": "Nuove carte al giorno",
"revPerDay": "Ripassate al giorno",
"revPerDayHint": "Ripassi al giorno",
"save": "Salva",
"saving": "Salvataggio...",
"settingsSaved": "Impostazioni salvate",
"todayNew": "Oggi nuove",
"todayReview": "Oggi ripasso",
"todayLearning": "In apprendimento",
"error": {
"update": "Nessun permesso di aggiornare",
"delete": "Nessun permesso di eliminare",
"add": "Nessun permesso di aggiungere"
},
"ipaPlaceholder": "Inserisci IPA",
"examplePlaceholder": "Inserisci esempio",
"wordRequired": "Inserisci una parola",
"definitionRequired": "Inserisci una definizione",
"cardAdded": "Carta aggiunta",
"cardType": "Tipo di carta",
"wordCard": "Carta parola",
"phraseCard": "Carta frase",
"sentenceCard": "Carta frase",
"sentence": "Frase",
"sentencePlaceholder": "Inserisci frase",
"wordPlaceholder": "Inserisci parola",
"queryLang": "Lingua di query",
"enterLanguageName": "Inserisci il nome della lingua",
"english": "Inglese",
"chinese": "Cinese",
"japanese": "Giapponese",
"korean": "Coreano",
"meanings": "Significati",
"addMeaning": "Aggiungi significato",
"partOfSpeech": "Parte del discorso",
"deleteConfirm": "Eliminare questa carta?",
"cardDeleted": "Carta eliminata",
"cardUpdated": "Carta aggiornata"
},
"home": { "home": {
"title": "Impara le Lingue", "title": "Impara le Lingue",
"description": "Ecco un sito molto utile per aiutarti a imparare quasi tutte le lingue del mondo, incluse quelle costruite.", "description": "Ecco un sito molto utile per aiutarti a imparare quasi tutte le lingue del mondo, incluse quelle costruite.",
@@ -260,9 +157,6 @@
"resetPasswordFailed": "Impossibile inviare email di reset", "resetPasswordFailed": "Impossibile inviare email di reset",
"resetPasswordEmailSent": "Email di reset inviata con successo", "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.", "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", "checkYourEmail": "Controlla la tua Email",
"backToLogin": "Torna al Login", "backToLogin": "Torna al Login",
"resetPassword": "Reimposta Password", "resetPassword": "Reimposta Password",
@@ -272,72 +166,25 @@
"requestNewToken": "Richiedi Nuovo Link di Reset", "requestNewToken": "Richiedi Nuovo Link di Reset",
"resetPasswordSuccess": "Password reimpostata con successo", "resetPasswordSuccess": "Password reimpostata con successo",
"resetPasswordSuccessTitle": "Reimpostazione Password Completata", "resetPasswordSuccessTitle": "Reimpostazione Password Completata",
"resetPasswordSuccessHint": "La tua password è stata reimpostata con successo. Ora puoi accedere con la tua nuova password.", "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"
}, },
"memorize": { "memorize": {
"deck_selector": { "folder_selector": {
"selectDeck": "Seleziona deck", "selectFolder": "Seleziona una cartella",
"noDecks": "Nessun deck", "noFolders": "Nessuna cartella trovata",
"goToDecks": "Vai ai deck", "folderInfo": "{id}. {name} ({count})"
"noCards": "Nessuna carta",
"new": "Nuovo",
"learning": "Apprendimento",
"review": "Ripasso",
"due": "In scadenza"
}, },
"review": { "memorize": {
"loading": "Caricamento...", "answer": "Risposta",
"backToDecks": "Torna ai deck", "next": "Successivo",
"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",
"reverse": "Inverti", "reverse": "Inverti",
"dictation": "Dettato", "dictation": "Dettatura",
"clickToPlay": "Clicca per riprodurre", "noTextPairs": "Nessuna coppia di testo disponibile",
"yourAnswer": "La tua risposta", "disorder": "Disordina",
"typeWhatYouHear": "Scrivi cosa senti", "previous": "Precedente"
"correct": "Corretto!",
"incorrect": "Errato",
"orderLimited": "Ordine limitato",
"orderInfinite": "Ordine infinito",
"randomLimited": "Casuale limitato",
"randomInfinite": "Casuale infinito",
"noIpa": "Nessun IPA disponibile"
}, },
"page": { "page": {
"unauthorized": "Non autorizzato" "unauthorized": "Non sei autorizzato ad accedere a questa cartella"
} }
}, },
"navbar": { "navbar": {
@@ -345,56 +192,11 @@
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "Accedi", "sign_in": "Accedi",
"profile": "Profilo", "profile": "Profilo",
"folders": "Mazzi", "folders": "Cartelle",
"explore": "Esplora", "explore": "Esplora",
"favorites": "Preferiti", "favorites": "Preferiti",
"settings": "Impostazioni" "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": { "profile": {
"myProfile": "Il Mio Profilo", "myProfile": "Il Mio Profilo",
"email": "Email: {email}", "email": "Email: {email}",
@@ -434,43 +236,12 @@
"videoUploadFailed": "Caricamento video fallito", "videoUploadFailed": "Caricamento video fallito",
"subtitleUploadFailed": "Caricamento sottotitoli fallito", "subtitleUploadFailed": "Caricamento sottotitoli fallito",
"subtitleLoadSuccess": "Sottotitoli caricati con successo", "subtitleLoadSuccess": "Sottotitoli caricati con successo",
"subtitleLoadFailed": "Caricamento sottotitoli fallito", "subtitleLoadFailed": "Caricamento sottotitoli fallito"
"settings": "Impostazioni",
"shortcuts": "Scorciatoie",
"keyboardShortcuts": "Scorciatoie tastiera",
"playPause": "Riproduci/Pausa",
"autoPauseToggle": "Auto-pausa",
"subtitleSettings": "Impostazioni sottotitoli",
"fontSize": "Dimensione carattere",
"textColor": "Colore testo",
"backgroundColor": "Colore sfondo",
"position": "Posizione",
"opacity": "Opacità",
"top": "Alto",
"center": "Centro",
"bottom": "Basso"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "Genera IPA", "generateIPA": "Genera IPA",
"viewSavedItems": "Visualizza Elementi Salvati", "viewSavedItems": "Visualizza Elementi Salvati",
"confirmDeleteAll": "Sei sicuro di voler eliminare tutto? (S/N)", "confirmDeleteAll": "Sei sicuro di voler eliminare tutto? (S/N)"
"saved": "Salvato",
"clearAll": "Cancella tutto",
"language": "Lingua",
"customLanguage": "o inserisci lingua...",
"languages": {
"auto": "Auto",
"chinese": "Cinese",
"english": "Inglese",
"japanese": "Giapponese",
"korean": "Coreano",
"french": "Francese",
"german": "Tedesco",
"italian": "Italiano",
"spanish": "Spagnolo",
"portuguese": "Portoghese",
"russian": "Russo"
}
}, },
"translator": { "translator": {
"detectLanguage": "rileva lingua", "detectLanguage": "rileva lingua",
@@ -503,20 +274,7 @@
"success": "Coppia di testo aggiunta alla cartella", "success": "Coppia di testo aggiunta alla cartella",
"error": "Impossibile aggiungere coppia di testo alla cartella" "error": "Impossibile aggiungere coppia di testo alla cartella"
}, },
"autoSave": "Salvataggio Automatico", "autoSave": "Salvataggio Automatico"
"customLanguage": "o digita lingua...",
"pleaseLogin": "Accedi per salvare le carte",
"pleaseCreateDeck": "Crea prima un deck",
"noTranslationToSave": "Nessuna traduzione da salvare",
"noDeckSelected": "Nessun deck selezionato",
"saveAsCard": "Salva come carta",
"selectDeck": "Seleziona deck",
"front": "Fronte",
"back": "Retro",
"cancel": "Annulla",
"save": "Salva",
"savedToDeck": "Carta salvata in {deckName}",
"saveFailed": "Salvataggio fallito"
}, },
"dictionary": { "dictionary": {
"title": "Dizionario", "title": "Dizionario",
@@ -561,9 +319,7 @@
"unfavorite": "Rimuovi dai preferiti", "unfavorite": "Rimuovi dai preferiti",
"pleaseLogin": "Per favore accedi prima", "pleaseLogin": "Per favore accedi prima",
"sortByFavorites": "Ordina per preferiti", "sortByFavorites": "Ordina per preferiti",
"sortByFavoritesActive": "Annulla ordinamento per preferiti", "sortByFavoritesActive": "Annulla ordinamento per preferiti"
"noDecks": "Nessun deck pubblico",
"deckInfo": "{userName} · {totalCards} carte"
}, },
"exploreDetail": { "exploreDetail": {
"title": "Dettagli Cartella", "title": "Dettagli Cartella",
@@ -577,8 +333,7 @@
"unfavorite": "Rimuovi dai preferiti", "unfavorite": "Rimuovi dai preferiti",
"favorited": "Aggiunto ai preferiti", "favorited": "Aggiunto ai preferiti",
"unfavorited": "Rimosso dai preferiti", "unfavorited": "Rimosso dai preferiti",
"pleaseLogin": "Per favore accedi prima", "pleaseLogin": "Per favore accedi prima"
"totalCards": "{count} carte"
}, },
"favorites": { "favorites": {
"title": "I Miei Preferiti", "title": "I Miei Preferiti",
@@ -600,39 +355,14 @@
"notSet": "Non Impostato", "notSet": "Non Impostato",
"memberSince": "Membro Dal", "memberSince": "Membro Dal",
"logout": "Esci", "logout": "Esci",
"deleteAccount": { "folders": {
"button": "Elimina Account", "title": "Cartelle",
"title": "Elimina Account", "noFolders": "Nessuna cartella ancora",
"warning": "Questa azione è irreversibile. Tutti i tuoi dati saranno eliminati definitivamente.", "folderName": "Nome Cartella",
"warningDecks": "Tutti i tuoi mazzi e le tue carte", "totalPairs": "Coppie Totali",
"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",
"createdAt": "Creata Il", "createdAt": "Creata Il",
"actions": "Azioni", "actions": "Azioni",
"view": "Visualizza" "view": "Visualizza"
}, }
"joined": "Iscritto il"
},
"follow": {
"follow": "Segui",
"following": "Stai seguendo",
"followers": "Seguaci",
"followersOf": "Seguaci di {username}",
"followingOf": "Seguiti da {username}",
"noFollowers": "Nessun seguace ancora",
"noFollowing": "Non segui ancora nessuno"
} }
} }

View File

@@ -74,77 +74,6 @@
"deleteFolder": "このフォルダーを削除する権限がありません。" "deleteFolder": "このフォルダーを削除する権限がありません。"
} }
}, },
"deck_id": {
"unauthorized": "このデッキの所有者ではありません",
"back": "戻る",
"cards": "カード",
"itemsCount": "{count}件",
"memorize": "暗記",
"loadingCards": "カードを読み込み中...",
"noCards": "このデッキにはカードがありません",
"card": "カード",
"addNewCard": "新しいカードを追加",
"add": "追加",
"adding": "追加中...",
"updateCard": "カードを更新",
"update": "更新",
"updating": "更新中...",
"word": "単語",
"definition": "定義",
"ipa": "発音記号",
"example": "例文",
"wordAndDefinitionRequired": "単語と定義は必須です",
"edit": "編集",
"delete": "削除",
"permissionDenied": "この操作を実行する権限がありません",
"resetProgress": "進捗をリセット",
"resetProgressTitle": "学習進捗をリセット",
"resetProgressConfirm": "このデッキの学習進捗をリセットしますか?",
"resetSuccess": "リセットしました",
"resetting": "リセット中...",
"cancel": "キャンセル",
"settings": "設定",
"settingsTitle": "デッキ設定",
"newPerDay": "1日の新規カード",
"newPerDayHint": "毎日の新規カード数",
"revPerDay": "1日の復習",
"revPerDayHint": "毎日の復習数",
"save": "保存",
"saving": "保存中...",
"settingsSaved": "設定を保存しました",
"todayNew": "今日の新規",
"todayReview": "今日の復習",
"todayLearning": "学習中",
"error": {
"update": "更新する権限がありません",
"delete": "削除する権限がありません",
"add": "追加する権限がありません"
},
"ipaPlaceholder": "IPAを入力",
"examplePlaceholder": "例文を入力",
"wordRequired": "単語を入力してください",
"definitionRequired": "定義を入力してください",
"cardAdded": "カードを追加しました",
"cardType": "カードタイプ",
"wordCard": "単語カード",
"phraseCard": "フレーズカード",
"sentenceCard": "文章カード",
"sentence": "文章",
"sentencePlaceholder": "文章を入力",
"wordPlaceholder": "単語を入力",
"queryLang": "検索言語",
"enterLanguageName": "言語名を入力してください",
"english": "英語",
"chinese": "中国語",
"japanese": "日本語",
"korean": "韓国語",
"meanings": "意味",
"addMeaning": "意味を追加",
"partOfSpeech": "品詞",
"deleteConfirm": "このカードを削除しますか?",
"cardDeleted": "カードを削除しました",
"cardUpdated": "カードを更新しました"
},
"home": { "home": {
"title": "言語を学ぶ", "title": "言語を学ぶ",
"description": "ここは世界のほぼすべての言語(人工言語を含む)を学ぶのに役立つ非常に便利なウェブサイトです。", "description": "ここは世界のほぼすべての言語(人工言語を含む)を学ぶのに役立つ非常に便利なウェブサイトです。",
@@ -228,9 +157,6 @@
"resetPasswordFailed": "リセットメールの送信に失敗しました", "resetPasswordFailed": "リセットメールの送信に失敗しました",
"resetPasswordEmailSent": "リセットメールを送信しました", "resetPasswordEmailSent": "リセットメールを送信しました",
"resetPasswordEmailSentHint": "パスワードリセット用のリンクをメールでお送りしました。受信トレイをご確認ください。", "resetPasswordEmailSentHint": "パスワードリセット用のリンクをメールでお送りしました。受信トレイをご確認ください。",
"verifyYourEmail": "メールアドレスを確認",
"verificationEmailSent": "確認メールを送信しました",
"verificationEmailSentHint": "{email} に確認メールを送信しました。メール内のリンクをクリックしてアカウントを確認してください。",
"checkYourEmail": "メールをご確認ください", "checkYourEmail": "メールをご確認ください",
"backToLogin": "ログインに戻る", "backToLogin": "ログインに戻る",
"resetPassword": "パスワードをリセット", "resetPassword": "パスワードをリセット",
@@ -240,72 +166,25 @@
"requestNewToken": "新しいリセットリンクをリクエスト", "requestNewToken": "新しいリセットリンクをリクエスト",
"resetPasswordSuccess": "パスワードのリセットに成功しました", "resetPasswordSuccess": "パスワードのリセットに成功しました",
"resetPasswordSuccessTitle": "パスワードリセット完了", "resetPasswordSuccessTitle": "パスワードリセット完了",
"resetPasswordSuccessHint": "パスワードが正常にリセットされました。新しいパスワードでログインできます。", "resetPasswordSuccessHint": "パスワードが正常にリセットされました。新しいパスワードでログインできます。"
"emailNotVerified": "メールアドレスを確認してください",
"emailNotVerifiedHint": "メールアドレスが確認されていません。受信トレイをご確認いただくか、新しい確認メールをリクエストしてください。",
"resendVerification": "確認メールを再送信",
"resendSuccess": "確認メールを送信しました!受信トレイをご確認ください。",
"resendFailed": "確認メールの送信に失敗しました"
}, },
"memorize": { "memorize": {
"deck_selector": { "folder_selector": {
"selectDeck": "デッキを選択", "selectFolder": "フォルダーを選択",
"noDecks": "デッキが見つかりません", "noFolders": "フォルダーが見つかりません",
"goToDecks": "デッキへ移動", "folderInfo": "{id}. {name} ({count})"
"noCards": "カードなし",
"new": "新規",
"learning": "学習中",
"review": "復習",
"due": "予定"
}, },
"review": { "memorize": {
"loading": "読み込み中...", "answer": "答え",
"backToDecks": "デッキに戻る", "next": "次へ",
"allDone": "完了!", "reverse": "逆順",
"allDoneDesc": "すべての復習カードが完了しました。", "dictation": "書き取り",
"reviewedCount": "{count} 枚のカードを復習", "noTextPairs": "利用可能なテキストペアがありません",
"progress": "{current} / {total}", "disorder": "シャッフル",
"nextReview": "次の復習", "previous": "前へ"
"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なし"
}, },
"page": { "page": {
"unauthorized": "このデッキにアクセスする権限がありません" "unauthorized": "このフォルダーにアクセスする権限がありません"
} }
}, },
"navbar": { "navbar": {
@@ -313,56 +192,11 @@
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "サインイン", "sign_in": "サインイン",
"profile": "プロフィール", "profile": "プロフィール",
"folders": "デッキ", "folders": "フォルダー",
"explore": "探索", "explore": "探索",
"favorites": "お気に入り", "favorites": "お気に入り",
"settings": "設定" "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": { "profile": {
"myProfile": "マイプロフィール", "myProfile": "マイプロフィール",
"email": "メール: {email}", "email": "メール: {email}",
@@ -402,43 +236,12 @@
"videoUploadFailed": "ビデオのアップロードに失敗しました", "videoUploadFailed": "ビデオのアップロードに失敗しました",
"subtitleUploadFailed": "字幕のアップロードに失敗しました", "subtitleUploadFailed": "字幕のアップロードに失敗しました",
"subtitleLoadSuccess": "字幕の読み込みに成功しました", "subtitleLoadSuccess": "字幕の読み込みに成功しました",
"subtitleLoadFailed": "字幕の読み込みに失敗しました", "subtitleLoadFailed": "字幕の読み込みに失敗しました"
"settings": "設定",
"shortcuts": "ショートカット",
"keyboardShortcuts": "キーボードショートカット",
"playPause": "再生/一時停止",
"autoPauseToggle": "自動一時停止",
"subtitleSettings": "字幕設定",
"fontSize": "フォントサイズ",
"textColor": "文字色",
"backgroundColor": "背景色",
"position": "位置",
"opacity": "不透明度",
"top": "上",
"center": "中央",
"bottom": "下"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "IPAを生成", "generateIPA": "IPAを生成",
"viewSavedItems": "保存済み項目を表示", "viewSavedItems": "保存済み項目を表示",
"confirmDeleteAll": "すべて削除してもよろしいですか? (Y/N)", "confirmDeleteAll": "すべて削除してもよろしいですか? (Y/N)"
"saved": "保存済み",
"clearAll": "すべてクリア",
"language": "言語",
"customLanguage": "または言語を入力...",
"languages": {
"auto": "自動",
"chinese": "中国語",
"english": "英語",
"japanese": "日本語",
"korean": "韓国語",
"french": "フランス語",
"german": "ドイツ語",
"italian": "イタリア語",
"spanish": "スペイン語",
"portuguese": "ポルトガル語",
"russian": "ロシア語"
}
}, },
"translator": { "translator": {
"detectLanguage": "言語を検出", "detectLanguage": "言語を検出",
@@ -471,20 +274,7 @@
"success": "テキストペアがフォルダーに追加されました", "success": "テキストペアがフォルダーに追加されました",
"error": "テキストペアをフォルダーに追加できませんでした" "error": "テキストペアをフォルダーに追加できませんでした"
}, },
"autoSave": "自動保存", "autoSave": "自動保存"
"customLanguage": "または言語を入力...",
"pleaseLogin": "ログインしてカードを保存",
"pleaseCreateDeck": "先にデッキを作成",
"noTranslationToSave": "保存する翻訳なし",
"noDeckSelected": "デッキ未選択",
"saveAsCard": "カードとして保存",
"selectDeck": "デッキ選択",
"front": "表面",
"back": "裏面",
"cancel": "キャンセル",
"save": "保存",
"savedToDeck": "{deckName}に保存",
"saveFailed": "保存失敗"
}, },
"dictionary": { "dictionary": {
"title": "辞書", "title": "辞書",
@@ -529,9 +319,7 @@
"unfavorite": "お気に入り解除", "unfavorite": "お気に入り解除",
"pleaseLogin": "まずログインしてください", "pleaseLogin": "まずログインしてください",
"sortByFavorites": "お気に入り順に並べ替え", "sortByFavorites": "お気に入り順に並べ替え",
"sortByFavoritesActive": "お気に入り順の並べ替えを解除", "sortByFavoritesActive": "お気に入り順の並べ替えを解除"
"noDecks": "公開デッキなし",
"deckInfo": "{userName} · {totalCards}枚"
}, },
"exploreDetail": { "exploreDetail": {
"title": "フォルダー詳細", "title": "フォルダー詳細",
@@ -545,8 +333,7 @@
"unfavorite": "お気に入り解除", "unfavorite": "お気に入り解除",
"favorited": "お気に入りに追加しました", "favorited": "お気に入りに追加しました",
"unfavorited": "お気に入りから削除しました", "unfavorited": "お気に入りから削除しました",
"pleaseLogin": "まずログインしてください", "pleaseLogin": "まずログインしてください"
"totalCards": "{count}枚"
}, },
"favorites": { "favorites": {
"title": "マイお気に入り", "title": "マイお気に入り",
@@ -568,66 +355,14 @@
"notSet": "未設定", "notSet": "未設定",
"memberSince": "登録日", "memberSince": "登録日",
"logout": "ログアウト", "logout": "ログアウト",
"deleteAccount": { "folders": {
"button": "アカウント削除", "title": "フォルダー",
"title": "アカウント削除", "noFolders": "まだフォルダーがありません",
"warning": "この操作は取り消せません。すべてのデータが完全に削除されます。", "folderName": "フォルダー名",
"warningDecks": "すべてのデッキとカード", "totalPairs": "合計ペア数",
"warningCards": "すべての学習履歴",
"warningHistory": "すべての翻訳と辞書の履歴",
"warningPermanent": "この操作は取り消せません",
"confirmLabel": "確認のためユーザー名を入力してください:",
"usernameMismatch": "ユーザー名が一致しません",
"cancel": "キャンセル",
"confirm": "アカウントを削除する",
"success": "アカウントが正常に削除されました",
"failed": "アカウントの削除に失敗しました"
},
"decks": {
"title": "デッキ",
"noDecks": "まだデッキがありません",
"deckName": "デッキ名",
"totalCards": "合計カード数",
"createdAt": "作成日", "createdAt": "作成日",
"actions": "アクション", "actions": "アクション",
"view": "表示" "view": "表示"
}, }
"joined": "登録日"
},
"decks": {
"title": "デッキ",
"subtitle": "学習デッキを管理",
"newDeck": "新規デッキ",
"noDecksYet": "デッキなし",
"loading": "読込中...",
"deckInfo": "ID: {id} · {totalCards}枚",
"enterDeckName": "デッキ名:",
"enterNewName": "新しい名前:",
"confirmDelete": "削除確認:「{name}」を入力",
"public": "公開",
"private": "非公開",
"setPublic": "公開に設定",
"setPrivate": "非公開に設定",
"importApkg": "APKGインポート",
"exportApkg": "APKGエクスポート",
"clickToUpload": "クリックでアップロード",
"apkgFilesOnly": ".apkgのみ",
"parsing": "解析中...",
"foundDecks": "{count}デッキ発見",
"deckName": "デッキ名",
"back": "戻る",
"import": "インポート",
"importing": "インポート中...",
"exportSuccess": "エクスポート成功",
"goToDecks": "デッキへ"
},
"follow": {
"follow": "フォロー",
"following": "フォロー中",
"followers": "フォロワー",
"followersOf": "{username}のフォロワー",
"followingOf": "{username}のフォロー中",
"noFollowers": "まだフォロワーがいません",
"noFollowing": "まだ誰もフォローしていません"
} }
} }

View File

@@ -46,38 +46,6 @@
"unfavorite": "즐겨찾기 해제", "unfavorite": "즐겨찾기 해제",
"pleaseLogin": "먼저 로그인해주세요" "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": { "folder_id": {
"unauthorized": "이 폴더의 소유자가 아닙니다", "unauthorized": "이 폴더의 소유자가 아닙니다",
"back": "뒤로", "back": "뒤로",
@@ -106,77 +74,6 @@
"deleteFolder": "이 폴더를 삭제할 권한이 없습니다." "deleteFolder": "이 폴더를 삭제할 권한이 없습니다."
} }
}, },
"deck_id": {
"unauthorized": "이 덱의 소유자가 아닙니다",
"back": "뒤로",
"cards": "카드",
"itemsCount": "{count}개",
"memorize": "암기",
"loadingCards": "카드 불러오는 중...",
"noCards": "이 덱에 카드가 없습니다",
"card": "카드",
"addNewCard": "새 카드 추가",
"add": "추가",
"adding": "추가 중...",
"updateCard": "카드 업데이트",
"update": "업데이트",
"updating": "업데이트 중...",
"word": "단어",
"definition": "정의",
"ipa": "IPA",
"example": "예문",
"wordAndDefinitionRequired": "단어와 정의는 필수입니다",
"edit": "편집",
"delete": "삭제",
"permissionDenied": "이 작업을 수행할 권한이 없습니다",
"resetProgress": "진행 초기화",
"resetProgressTitle": "학습 진행 초기화",
"resetProgressConfirm": "이 덱의 학습 진행을 초기화하시겠습니까?",
"resetSuccess": "초기화됨",
"resetting": "초기화 중...",
"cancel": "취소",
"settings": "설정",
"settingsTitle": "덱 설정",
"newPerDay": "일일 새 카드",
"newPerDayHint": "매일 학습할 새 카드 수",
"revPerDay": "일일 복습",
"revPerDayHint": "매일 복습할 카드 수",
"save": "저장",
"saving": "저장 중...",
"settingsSaved": "설정 저장됨",
"todayNew": "오늘 새 카드",
"todayReview": "오늘 복습",
"todayLearning": "학습 중",
"error": {
"update": "업데이트 권한이 없습니다",
"delete": "삭제 권한이 없습니다",
"add": "추가 권한이 없습니다"
},
"ipaPlaceholder": "IPA 입력",
"examplePlaceholder": "예문 입력",
"wordRequired": "단어를 입력하세요",
"definitionRequired": "정의를 입력하세요",
"cardAdded": "카드 추가됨",
"cardType": "카드 유형",
"wordCard": "단어 카드",
"phraseCard": "구문 카드",
"sentenceCard": "문장 카드",
"sentence": "문장",
"sentencePlaceholder": "문장 입력",
"wordPlaceholder": "단어 입력",
"queryLang": "검색 언어",
"enterLanguageName": "언어 이름을 입력하세요",
"english": "영어",
"chinese": "중국어",
"japanese": "일본어",
"korean": "한국어",
"meanings": "의미",
"addMeaning": "의미 추가",
"partOfSpeech": "품사",
"deleteConfirm": "이 카드를 삭제하시겠습니까?",
"cardDeleted": "카드 삭제됨",
"cardUpdated": "카드 업데이트됨"
},
"home": { "home": {
"title": "언어 배우기", "title": "언어 배우기",
"description": "세계의 거의 모든 언어(인공어 포함)를 배우는 데 도움이 되는 매우 유용한 웹사이트입니다.", "description": "세계의 거의 모든 언어(인공어 포함)를 배우는 데 도움이 되는 매우 유용한 웹사이트입니다.",
@@ -260,9 +157,6 @@
"resetPasswordFailed": "재설정 이메일 전송 실패", "resetPasswordFailed": "재설정 이메일 전송 실패",
"resetPasswordEmailSent": "재설정 이메일이 전송되었습니다", "resetPasswordEmailSent": "재설정 이메일이 전송되었습니다",
"resetPasswordEmailSentHint": "비밀번호 재설정 링크를 이메일로 보냈습니다. 받은 편지함을 확인해주세요.", "resetPasswordEmailSentHint": "비밀번호 재설정 링크를 이메일로 보냈습니다. 받은 편지함을 확인해주세요.",
"verifyYourEmail": "이메일 인증",
"verificationEmailSent": "인증 이메일이 전송되었습니다",
"verificationEmailSentHint": "{email}로 인증 이메일을 보냈습니다. 이메일의 링크를 클릭하여 계정을 인증해주세요.",
"checkYourEmail": "이메일을 확인하세요", "checkYourEmail": "이메일을 확인하세요",
"backToLogin": "로그인으로 돌아가기", "backToLogin": "로그인으로 돌아가기",
"resetPassword": "비밀번호 재설정", "resetPassword": "비밀번호 재설정",
@@ -272,47 +166,25 @@
"requestNewToken": "새 재설정 링크 요청", "requestNewToken": "새 재설정 링크 요청",
"resetPasswordSuccess": "비밀번호 재설정 성공", "resetPasswordSuccess": "비밀번호 재설정 성공",
"resetPasswordSuccessTitle": "비밀번호 재설정 완료", "resetPasswordSuccessTitle": "비밀번호 재설정 완료",
"resetPasswordSuccessHint": "비밀번호가 성공적으로 재설정되었습니다. 새 비밀번호로 로그인할 수 있습니다.", "resetPasswordSuccessHint": "비밀번호가 성공적으로 재설정되었습니다. 새 비밀번호로 로그인할 수 있습니다."
"emailNotVerified": "이메일 주소를 인증해 주세요",
"emailNotVerifiedHint": "이메일이 인증되지 않았습니다. 받은 편지함을 확인하거나 새 인증 이메일을 요청해 주세요.",
"resendVerification": "인증 이메일 다시 보내기",
"resendSuccess": "인증 이메일이 발송되었습니다! 받은 편지함을 확인해 주세요.",
"resendFailed": "인증 이메일 발송에 실패했습니다"
}, },
"memorize": { "memorize": {
"deck_selector": { "folder_selector": {
"selectDeck": " 선택", "selectFolder": "폴더 선택",
"noDecks": "덱이 없습니다", "noFolders": "폴더를 찾을 수 없습니다",
"goToDecks": "덱으로 이동", "folderInfo": "{id}. {name} ({count})"
"noCards": "카드가 없습니다",
"new": "새로",
"learning": "학습 중",
"review": "복습",
"due": "예정"
}, },
"review": { "memorize": {
"loading": "로딩 중...", "answer": "정답",
"backToDecks": "덱으로 돌아가기", "next": "다음",
"allDone": "모두 완료!", "reverse": "반대",
"allDoneDesc": "오늘의 학습을 완료했습니다!", "dictation": "받아쓰기",
"reviewedCount": "{count}장 복습 완료", "noTextPairs": "사용 가능한 텍스트 쌍이 없습니다",
"progress": "{current} / {total}", "disorder": "무작위",
"nextReview": "다음 복습", "previous": "이전"
"interval": "간격",
"ease": "난이도",
"lapses": "실패 횟수",
"showAnswer": "정답 보기",
"nextCard": "다음",
"again": "다시",
"restart": "다시 시작",
"orderLimited": "순서 제한",
"orderInfinite": "순서 무제한",
"randomLimited": "무작위 제한",
"randomInfinite": "무작위 무제한",
"noIpa": "IPA 없음"
}, },
"page": { "page": {
"unauthorized": "권한이 없습니다" "unauthorized": "이 폴더에 접근할 권한이 없습니다"
} }
}, },
"navbar": { "navbar": {
@@ -320,56 +192,11 @@
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "로그인", "sign_in": "로그인",
"profile": "프로필", "profile": "프로필",
"folders": "", "folders": "폴더",
"explore": "탐색", "explore": "탐색",
"favorites": "즐겨찾기", "favorites": "즐겨찾기",
"settings": "설정" "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": { "profile": {
"myProfile": "내 프로필", "myProfile": "내 프로필",
"email": "이메일: {email}", "email": "이메일: {email}",
@@ -409,43 +236,12 @@
"videoUploadFailed": "비디오 업로드 실패", "videoUploadFailed": "비디오 업로드 실패",
"subtitleUploadFailed": "자막 업로드 실패", "subtitleUploadFailed": "자막 업로드 실패",
"subtitleLoadSuccess": "자막 로드 성공", "subtitleLoadSuccess": "자막 로드 성공",
"subtitleLoadFailed": "자막 로드 실패", "subtitleLoadFailed": "자막 로드 실패"
"settings": "설정",
"shortcuts": "단축키",
"keyboardShortcuts": "키보드 단축키",
"playPause": "재생/일시정지",
"autoPauseToggle": "자동 일시정지",
"subtitleSettings": "자막 설정",
"fontSize": "글꼴 크기",
"textColor": "글자 색",
"backgroundColor": "배경색",
"position": "위치",
"opacity": "불투명도",
"top": "위",
"center": "중앙",
"bottom": "아래"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "IPA 생성", "generateIPA": "IPA 생성",
"viewSavedItems": "저장된 항목 보기", "viewSavedItems": "저장된 항목 보기",
"confirmDeleteAll": "모든 것을 삭제하시겠습니까? (Y/N)", "confirmDeleteAll": "모든 것을 삭제하시겠습니까? (Y/N)"
"saved": "저장됨",
"clearAll": "모두 지우기",
"language": "언어",
"customLanguage": "또는 언어 입력...",
"languages": {
"auto": "자동",
"chinese": "중국어",
"english": "영어",
"japanese": "일본어",
"korean": "한국어",
"french": "프랑스어",
"german": "독일어",
"italian": "이탈리아어",
"spanish": "스페인어",
"portuguese": "포르투갈어",
"russian": "러시아어"
}
}, },
"translator": { "translator": {
"detectLanguage": "언어 감지", "detectLanguage": "언어 감지",
@@ -478,20 +274,7 @@
"success": "텍스트 쌍이 폴더에 추가됨", "success": "텍스트 쌍이 폴더에 추가됨",
"error": "폴더에 텍스트 쌍 추가 실패" "error": "폴더에 텍스트 쌍 추가 실패"
}, },
"autoSave": "자동 저장", "autoSave": "자동 저장"
"customLanguage": "또는 언어 입력...",
"pleaseLogin": "카드를 저장하려면 로그인하세요",
"pleaseCreateDeck": "먼저 덱을 만드세요",
"noTranslationToSave": "저장할 번역이 없습니다",
"noDeckSelected": "덱이 선택되지 않았습니다",
"saveAsCard": "카드로 저장",
"selectDeck": "덱 선택",
"front": "앞면",
"back": "뒷면",
"cancel": "취소",
"save": "저장",
"savedToDeck": "{deckName}에 카드 저장됨",
"saveFailed": "카드 저장 실패"
}, },
"dictionary": { "dictionary": {
"title": "사전", "title": "사전",
@@ -536,9 +319,7 @@
"unfavorite": "즐겨찾기 해제", "unfavorite": "즐겨찾기 해제",
"pleaseLogin": "먼저 로그인해주세요", "pleaseLogin": "먼저 로그인해주세요",
"sortByFavorites": "즐겨찾기순 정렬", "sortByFavorites": "즐겨찾기순 정렬",
"sortByFavoritesActive": "즐겨찾기순 정렬 해제", "sortByFavoritesActive": "즐겨찾기순 정렬 해제"
"noDecks": "공개 덱 없음",
"deckInfo": "{userName} · {totalCards}장"
}, },
"exploreDetail": { "exploreDetail": {
"title": "폴더 상세", "title": "폴더 상세",
@@ -552,8 +333,7 @@
"unfavorite": "즐겨찾기 해제", "unfavorite": "즐겨찾기 해제",
"favorited": "즐겨찾기됨", "favorited": "즐겨찾기됨",
"unfavorited": "즐겨찾기 해제됨", "unfavorited": "즐겨찾기 해제됨",
"pleaseLogin": "먼저 로그인해주세요", "pleaseLogin": "먼저 로그인해주세요"
"totalCards": "총 {count}장"
}, },
"favorites": { "favorites": {
"title": "내 즐겨찾기", "title": "내 즐겨찾기",
@@ -575,48 +355,14 @@
"notSet": "설정되지 않음", "notSet": "설정되지 않음",
"memberSince": "가입일", "memberSince": "가입일",
"logout": "로그아웃", "logout": "로그아웃",
"deleteAccount": {
"button": "계정 삭제",
"title": "계정 삭제",
"warning": "이 작업은 되돌릴 수 없습니다. 모든 데이터가 영구적으로 삭제됩니다.",
"warningDecks": "모든 덱과 카드",
"warningCards": "모든 학습 진행 상황",
"warningHistory": "모든 번역 및 사전 기록",
"warningPermanent": "이 작업은 취소할 수 없습니다",
"confirmLabel": "확인을 위해 사용자명을 입력하세요:",
"usernameMismatch": "사용자명이 일치하지 않습니다",
"cancel": "취소",
"confirm": "내 계정 삭제",
"success": "계정이 성공적으로 삭제되었습니다",
"failed": "계정 삭제에 실패했습니다"
},
"folders": { "folders": {
"title": "", "title": "폴더",
"noFolders": "아직 덱이 없습니다", "noFolders": "아직 폴더가 없습니다",
"folderName": " 이름", "folderName": "폴더 이름",
"totalPairs": "총 카드 수", "totalPairs": "총 ",
"createdAt": "생성일",
"actions": "작업",
"view": "보기"
},
"joined": "가입일",
"decks": {
"title": "내 덱",
"noDecks": "덱이 없습니다",
"deckName": "덱 이름",
"totalCards": "총 카드",
"createdAt": "생성일", "createdAt": "생성일",
"actions": "작업", "actions": "작업",
"view": "보기" "view": "보기"
} }
},
"follow": {
"follow": "팔로우",
"following": "팔로잉",
"followers": "팔로워",
"followersOf": "{username}의 팔로워",
"followingOf": "{username}의 팔로잉",
"noFollowers": "아직 팔로워가 없습니다",
"noFollowing": "아직 팔로우하는 사람이 없습니다"
} }
} }

View File

@@ -46,38 +46,6 @@
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل", "unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ" "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": { "folder_id": {
"unauthorized": "بۇ قىسقۇچنىڭ ئىگىسى ئەمەسسىز", "unauthorized": "بۇ قىسقۇچنىڭ ئىگىسى ئەمەسسىز",
"back": "قايتىش", "back": "قايتىش",
@@ -106,77 +74,6 @@
"deleteFolder": "بۇ قىسقۇچنى ئۆچۈرۈش ھوقۇقىڭىز يوق." "deleteFolder": "بۇ قىسقۇچنى ئۆچۈرۈش ھوقۇقىڭىز يوق."
} }
}, },
"deck_id": {
"unauthorized": "بۇ دېكنىڭ ئىگىسى ئەمەس",
"back": "قايتىش",
"cards": "كارتلار",
"itemsCount": "{count} تۈر",
"memorize": "يادلاش",
"loadingCards": "كارتلار يۈكلىنىۋاتىدۇ...",
"noCards": "بۇ دېكتا كارت يوق",
"card": "كارتا",
"addNewCard": "يېڭى كارتا قوشۇش",
"add": "قوشۇش",
"adding": "قوشۇلىۋاتىدۇ...",
"updateCard": "كارتىنى يېڭىلاش",
"update": "يېڭىلاش",
"updating": "يېڭىلىنىۋاتىدۇ...",
"word": "سۆز",
"definition": "ئېنىقلىما",
"ipa": "IPA",
"example": "مىسال",
"wordAndDefinitionRequired": "سۆز ۋە ئېنىقلىما زۆرۈر",
"edit": "تەھرىرلەش",
"delete": "ئۆچۈرۈش",
"permissionDenied": "بۇ مەشغۇلاتنى ئېلىپ بېرىش ھوقۇقىڭىز يوق",
"resetProgress": "ئىلگىرىلەشنى ئەسلىگە قايتۇرۇش",
"resetProgressTitle": "ئۆگىنىش ئىلگىرىلەشىنى ئەسلىگە قايتۇرۇش",
"resetProgressConfirm": "ئىلگىرىلەشنى ئەسلىگە قايتۇرامسىز؟",
"resetSuccess": "ئەسلىگە قايتۇرۇلدى",
"resetting": "ئەسلىگە قايتۇرۇۋاتىدۇ...",
"cancel": "بىكار قىلىش",
"settings": "تەڭشەكلەر",
"settingsTitle": "دېك تەڭشەكلىرى",
"newPerDay": "كۈندىلىك يېڭى",
"newPerDayHint": "كۈندە يېڭى كارتا سانى",
"revPerDay": "كۈندىلىك تەكرار",
"revPerDayHint": "كۈندە تەكرار سانى",
"save": "ساقلاش",
"saving": "ساقلاۋاتىدۇ...",
"settingsSaved": "تەڭشەكلەر ساقلاندى",
"todayNew": "بۈگۈنكى يېڭى",
"todayReview": "بۈگۈنكى تەكرار",
"todayLearning": "ئۆگىنىۋاتىدۇ",
"error": {
"update": "يېڭىلاش ھوقۇقى يوق",
"delete": "ئۆچۈرۈش ھوقۇقى يوق",
"add": "قوشۇش ھوقۇقى يوق"
},
"ipaPlaceholder": "IPA كىرگۈزۈڭ",
"examplePlaceholder": "مىسال كىرگۈزۈڭ",
"wordRequired": "سۆز كىرگۈزۈڭ",
"definitionRequired": "ئېنىقلىما كىرگۈزۈڭ",
"cardAdded": "كارتا قوشۇلدى",
"cardType": "كارتا تىپى",
"wordCard": "سۆز كارتىسى",
"phraseCard": "جۈملە كارتىسى",
"sentenceCard": "جۈملە كارتىسى",
"sentence": "جۈملە",
"sentencePlaceholder": "جۈملە كىرگۈزۈڭ",
"wordPlaceholder": "سۆز كىرگۈزۈڭ",
"queryLang": "سۈرۈشتۈرۈش تىلى",
"enterLanguageName": "تىل ئاتىنى كىرگۈزۈڭ",
"english": "ئىنگىلىزچە",
"chinese": "خەنزۇچە",
"japanese": "ياپونچە",
"korean": "كورىيەچە",
"meanings": "مەنىلىرى",
"addMeaning": "مەنا قوشۇش",
"partOfSpeech": "سۆز بۆلىكى",
"deleteConfirm": "بۇ كارتىنى ئۆچۈرەمسىز؟",
"cardDeleted": "كارتا ئۆچۈرۈلدى",
"cardUpdated": "كارتا يېڭىلاندى"
},
"home": { "home": {
"title": "تىل ئۆگىنىش", "title": "تىل ئۆگىنىش",
"description": "بۇ دۇنيادىكى almost ھەر بىر تىلنى، جۈملىدىن سۈنئىي تىللارنى ئۆگىنىشىڭىزگە ياردەم بېرىدىغان ئىنتايىن قوللىنىشلىق تور بېكەت.", "description": "بۇ دۇنيادىكى almost ھەر بىر تىلنى، جۈملىدىن سۈنئىي تىللارنى ئۆگىنىشىڭىزگە ياردەم بېرىدىغان ئىنتايىن قوللىنىشلىق تور بېكەت.",
@@ -260,9 +157,6 @@
"resetPasswordFailed": "ئەسلىگە قايتۇرۇش ئېلخېتى ئەۋەتىش مەغلۇپ بولدى", "resetPasswordFailed": "ئەسلىگە قايتۇرۇش ئېلخېتى ئەۋەتىش مەغلۇپ بولدى",
"resetPasswordEmailSent": "ئەسلىگە قايتۇرۇش ئېلخېتى مۇۋەپپەقىيەتلىك ئەۋەتىلدى", "resetPasswordEmailSent": "ئەسلىگە قايتۇرۇش ئېلخېتى مۇۋەپپەقىيەتلىك ئەۋەتىلدى",
"resetPasswordEmailSentHint": "پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسىنى ئېلخەت ئادرېسىڭىزغا ئەۋەتتۇق. ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ.", "resetPasswordEmailSentHint": "پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسىنى ئېلخەت ئادرېسىڭىزغا ئەۋەتتۇق. ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ.",
"verifyYourEmail": "ئېلخەتنى دەلىللەش",
"verificationEmailSent": "دەلىللەش ئېلخېتى ئەۋەتىلدى",
"verificationEmailSentHint": "{email} غا دەلىللەش ئېلخېتى ئەۋەتتۇق. ئېلخەتتىكى ئۇلانمىنى چېكىپ ھېساباتىڭىزنى دەلىللەڭ.",
"checkYourEmail": "ئېلخېتىڭىزنى تەكشۈرۈڭ", "checkYourEmail": "ئېلخېتىڭىزنى تەكشۈرۈڭ",
"backToLogin": "كىرىشكە قايتىش", "backToLogin": "كىرىشكە قايتىش",
"resetPassword": "پارولنى ئەسلىگە قايتۇرۇش", "resetPassword": "پارولنى ئەسلىگە قايتۇرۇش",
@@ -272,72 +166,25 @@
"requestNewToken": "يېڭى ئەسلىگە قايتۇرۇش ئۇلانمىسى سوراش", "requestNewToken": "يېڭى ئەسلىگە قايتۇرۇش ئۇلانمىسى سوراش",
"resetPasswordSuccess": "پارول مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى", "resetPasswordSuccess": "پارول مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى",
"resetPasswordSuccessTitle": "پارول ئەسلىگە قايتۇرۇش تاماملاندى", "resetPasswordSuccessTitle": "پارول ئەسلىگە قايتۇرۇش تاماملاندى",
"resetPasswordSuccessHint": "پارولىڭىز مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى. يېڭى پارول بىلەن كىرسىڭىز بولىدۇ.", "resetPasswordSuccessHint": "پارولىڭىز مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى. يېڭى پارول بىلەن كىرسىڭىز بولىدۇ."
"emailNotVerified": "ئېلخەت ئادرېسىڭىزنى دەلىللەڭ",
"emailNotVerifiedHint": "ئېلخەت ئادرېسىڭىز دەلىللەنمىگەن. ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ ياكى يېڭى دەلىللەش ئېلخېتى سوراڭ.",
"resendVerification": "دەلىللەش ئېلخېتىنى قايتا ئەۋەتىش",
"resendSuccess": "دەلىللەش ئېلخېتى ئەۋەتىلدى! ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ.",
"resendFailed": "دەلىللەش ئېلخېتى ئەۋەتىش مەغلۇپ بولدى"
}, },
"memorize": { "memorize": {
"deck_selector": { "folder_selector": {
"selectDeck": "دېك تاللاش", "selectFolder": "بىر قىسقۇچ تاللاڭ",
"noDecks": "دېك يوق", "noFolders": "قىسقۇچ تېپىلمىدى",
"goToDecks": "دېكلەرگە بار", "folderInfo": "{id}. {name} ({count})"
"noCards": "كارتا يوق",
"new": "يېڭى",
"learning": "ئۆگىنىش",
"review": "تەكرار",
"due": "ۋاقتى كەلدى"
}, },
"review": { "memorize": {
"loading": "يۈكلىنىۋاتىدۇ...", "answer": "جاۋاب",
"backToDecks": "دېكلەرگە قايتىش", "next": "كېيىنكى",
"allDone": "ھەممىسى تامام!", "reverse": "تەتۈر",
"allDoneDesc": "بۈگۈنكى ئۆگىنىش تامام!", "dictation": "دىكتات",
"reviewedCount": "{count} كارتا تەكرارلاندى", "noTextPairs": "تېكىست جۈپى يوق",
"progress": "{current} / {total}", "disorder": "قالايمىقانلاشتۇرۇش",
"nextReview": "كېيىنكى تەكرار", "previous": "ئالدىنقى"
"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 يوق"
}, },
"page": { "page": {
"unauthorized": "ھوقۇقسىز" "unauthorized": "بۇ قىسقۇچنى زىيارەت قىلىش ھوقۇقىڭىز يوق"
} }
}, },
"navbar": { "navbar": {
@@ -345,56 +192,11 @@
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "كىرىش", "sign_in": "كىرىش",
"profile": "شەخسىي ئۇچۇر", "profile": "شەخسىي ئۇچۇر",
"folders": "دېكلار", "folders": "قىسقۇچلار",
"explore": "ئىزدىنىش", "explore": "ئىزدىنىش",
"favorites": "يىغىپ ساقلاش", "favorites": "يىغىپ ساقلاش",
"settings": "تەڭشەكلەر" "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": { "profile": {
"myProfile": "شەخسىي ئۇچۇرۇم", "myProfile": "شەخسىي ئۇچۇرۇم",
"email": "ئېلخەت: {email}", "email": "ئېلخەت: {email}",
@@ -434,43 +236,12 @@
"videoUploadFailed": "ۋىدېئو يۈكلەش مەغلۇپ بولدى", "videoUploadFailed": "ۋىدېئو يۈكلەش مەغلۇپ بولدى",
"subtitleUploadFailed": "تر پودكاست يۈكلەش مەغلۇپ بولدى", "subtitleUploadFailed": "تر پودكاست يۈكلەش مەغلۇپ بولدى",
"subtitleLoadSuccess": "تر پودكاست مۇۋەپپەقىيەتلىك يۈكلەندى", "subtitleLoadSuccess": "تر پودكاست مۇۋەپپەقىيەتلىك يۈكلەندى",
"subtitleLoadFailed": "تر پودكاست يۈكلەش مەغلۇپ بولدى", "subtitleLoadFailed": "تر پودكاست يۈكلەش مەغلۇپ بولدى"
"settings": "تەڭشەكلەر",
"shortcuts": "تېزلەتمەلەر",
"keyboardShortcuts": "كۇنۇپكا تاختىسى تېزلەتمەلىرى",
"playPause": "قويۇش/توختىتىش",
"autoPauseToggle": "ئاپتوماتىك توختىتىش",
"subtitleSettings": "ئاستى سىزىق تەڭشەكلىرى",
"fontSize": "خەت چوڭلۇقى",
"textColor": "خەت رەڭگى",
"backgroundColor": "تەگلىك رەڭگى",
"position": "ئورنى",
"opacity": "سۈزۈكلۈك",
"top": "ئۈستى",
"center": "ئوتتۇرا",
"bottom": "ئاستى"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "IPA ھاسىل قىلىش", "generateIPA": "IPA ھاسىل قىلىش",
"viewSavedItems": "ساقلانغان تۈرلەرنى كۆرۈش", "viewSavedItems": "ساقلانغان تۈرلەرنى كۆرۈش",
"confirmDeleteAll": "ھەممىنى ئۆچۈرەمسىز؟ (Y/N)", "confirmDeleteAll": "ھەممىنى ئۆچۈرەمسىز؟ (Y/N)"
"saved": "ساقلاندى",
"clearAll": "ھەممىنى تازىلاش",
"language": "تىل",
"customLanguage": "ياكى تىل كىرگۈزۈڭ...",
"languages": {
"auto": "ئاپتوماتىك",
"chinese": "خەنزۇچە",
"english": "ئىنگلىزچە",
"japanese": "ياپونچە",
"korean": "كورېيەچە",
"french": "فرانسۇزچە",
"german": "گېرمانچە",
"italian": "ئىتاليانچە",
"spanish": "ئىسپانچە",
"portuguese": "پورتۇگالچە",
"russian": "رۇسچە"
}
}, },
"translator": { "translator": {
"detectLanguage": "تىلنى تونۇش", "detectLanguage": "تىلنى تونۇش",
@@ -503,20 +274,7 @@
"success": "تېكىست جۈپى قىسقۇچقا قوشۇلدى", "success": "تېكىست جۈپى قىسقۇچقا قوشۇلدى",
"error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇپ بولدى" "error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇپ بولدى"
}, },
"autoSave": "ئاپتوماتىك ساقلاش", "autoSave": "ئاپتوماتىك ساقلاش"
"customLanguage": "ياكى تىل تىل كىرۇڭ...",
"pleaseLogin": "كارتا ساقلاش ئۈچۈن كىرىڭ",
"pleaseCreateDeck": "ئاۋۋال دېك قۇرۇڭ",
"noTranslationToSave": "ساقلايدىغان تەرجىمە يوق",
"noDeckSelected": "دېك تاللانمىدى",
"saveAsCard": "كارتا ساقلاش",
"selectDeck": "دېك تاللاش",
"front": "ئالدى",
"back": "كەينى",
"cancel": "بىكار قىلىش",
"save": "ساقلاش",
"savedToDeck": "{deckName} غا ساقلاندى",
"saveFailed": "ساقلاش مەغلۇپ"
}, },
"dictionary": { "dictionary": {
"title": "لۇغەت", "title": "لۇغەت",
@@ -561,9 +319,7 @@
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل", "unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ", "pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ",
"sortByFavorites": "يىغىپ ساقلاش بويىچە تەرتىپلەش", "sortByFavorites": "يىغىپ ساقلاش بويىچە تەرتىپلەش",
"sortByFavoritesActive": "يىغىپ ساقلاش بويىچە تەرتىپلەشنى بىكار قىلىش", "sortByFavoritesActive": "يىغىپ ساقلاش بويىچە تەرتىپلەشنى بىكار قىلىش"
"noDecks": "ئاممىۋىي دېك يوق",
"deckInfo": "{userName} · {totalCards} كارتا"
}, },
"exploreDetail": { "exploreDetail": {
"title": "قىسقۇچ تەپسىلاتلىرى", "title": "قىسقۇچ تەپسىلاتلىرى",
@@ -577,8 +333,7 @@
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل", "unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
"favorited": "يىغىپ ساقلاندى", "favorited": "يىغىپ ساقلاندى",
"unfavorited": "يىغىپ ساقلاش بىكار قىلىندى", "unfavorited": "يىغىپ ساقلاش بىكار قىلىندى",
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ", "pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ"
"totalCards": "{count} كارتا"
}, },
"favorites": { "favorites": {
"title": "يىغىپ ساقلىغانلىرىم", "title": "يىغىپ ساقلىغانلىرىم",
@@ -600,39 +355,14 @@
"notSet": "تەڭشەلمىگەن", "notSet": "تەڭشەلمىگەن",
"memberSince": "ئەزا بولغاندىن بېرى", "memberSince": "ئەزا بولغاندىن بېرى",
"logout": "چىكىنىش", "logout": "چىكىنىش",
"deleteAccount": { "folders": {
"button": "ھېساباتنى ئۆچۈرۈش", "title": "قىسقۇچلار",
"title": "ھېساباتنى ئۆچۈرۈش", "noFolders": "تېخى قىسقۇچ يوق",
"warning": "بۇ مەشغۇلاتنى ئەسلىگە قايتۇرغىلى بولمايدۇ. بارلىق سانلىق مەلۇماتلىرىڭىز مەڭگۈلۈك ئۆچۈرۈلىدۇ.", "folderName": "قىسقۇچ ئاتى",
"warningDecks": "بارلىق دېك ۋە كارتلىرىڭىز", "totalPairs": "جەمئىي جۈپ",
"warningCards": "بارلىق ئۆگىنىش ئىلگىرىلەشلىرىڭىز",
"warningHistory": "بارلىق تەرجىمە ۋە لۇغەت تارىخىڭىز",
"warningPermanent": "بۇ مەشغۇلاتنى بىكار قىلغىلى بولمايدۇ",
"confirmLabel": "جەزىملەش ئۈچۈن ئىشلەتكۈچى ئاتىڭىزنى كىرگۈزۈڭ:",
"usernameMismatch": "ئىشلەتكۈچى ئاتى ماس كەلمەيدۇ",
"cancel": "بىكار قىلىش",
"confirm": "ھېساباتىمنى ئۆچۈرۈش",
"success": "ھېسابات مۇۋەپپەقىيەتلىك ئۆچۈرۈلدى",
"failed": "ھېساباتنى ئۆچۈرۈش مەغلۇپ بولدى"
},
"decks": {
"title": "دېكلار",
"noDecks": "تېخى دېك يوق",
"deckName": "دېك ئاتى",
"totalCards": "جەمئىي كارتا",
"createdAt": "قۇرۇلغان ۋاقتى", "createdAt": "قۇرۇلغان ۋاقتى",
"actions": "مەشغۇلاتلار", "actions": "مەشغۇلاتلار",
"view": "كۆرۈش" "view": "كۆرۈش"
}, }
"joined": "قوشۇلدى"
},
"follow": {
"follow": "ئەگىشىش",
"following": "ئەگىشىۋاتىدۇ",
"followers": "ئەگەشكۈچىلەر",
"followersOf": "{username} نىڭ ئەگەشكۈچىلىرى",
"followingOf": "{username} نىڭ ئەگىشىدىغانلىرى",
"noFollowers": "تېخى ئەگەشكۈچى يوق",
"noFollowing": "تېخى ئەگىشىدىغان ئادەم يوق"
} }
} }

View File

@@ -74,77 +74,6 @@
"deleteFolder": "您没有权限删除此文件夹" "deleteFolder": "您没有权限删除此文件夹"
} }
}, },
"deck_id": {
"unauthorized": "您不是此牌组的所有者",
"back": "返回",
"cards": "卡片",
"itemsCount": "{count} 个",
"memorize": "记忆",
"loadingCards": "加载卡片中...",
"noCards": "此牌组中没有卡片",
"card": "卡片",
"addNewCard": "添加新卡片",
"add": "添加",
"adding": "添加中...",
"updateCard": "更新卡片",
"update": "更新",
"updating": "更新中...",
"word": "单词",
"definition": "释义",
"ipa": "音标",
"example": "例句",
"wordAndDefinitionRequired": "单词和释义都是必需的",
"edit": "编辑",
"delete": "删除",
"permissionDenied": "您没有权限执行此操作",
"resetProgress": "重置进度",
"resetProgressTitle": "重置学习进度",
"resetProgressConfirm": "确定要重置这个卡组的学习进度吗?",
"resetSuccess": "进度已重置",
"resetting": "重置中...",
"cancel": "取消",
"settings": "设置",
"settingsTitle": "卡组设置",
"newPerDay": "每日新卡",
"newPerDayHint": "每天学习的新卡片数量",
"revPerDay": "每日复习",
"revPerDayHint": "每天复习的卡片数量",
"save": "保存",
"saving": "保存中...",
"settingsSaved": "设置已保存",
"todayNew": "今日新卡",
"todayReview": "今日复习",
"todayLearning": "学习中",
"error": {
"update": "您没有权限更新此卡片",
"delete": "您没有权限删除此卡片",
"add": "您没有权限向此牌组添加卡片"
},
"ipaPlaceholder": "输入IPA音标",
"examplePlaceholder": "输入例句",
"wordRequired": "请输入单词",
"definitionRequired": "请输入至少一个释义",
"cardAdded": "卡片已添加",
"cardType": "卡片类型",
"wordCard": "单词卡",
"phraseCard": "短语卡",
"sentenceCard": "句子卡",
"sentence": "句子",
"sentencePlaceholder": "输入句子",
"wordPlaceholder": "输入单词",
"queryLang": "查询语言",
"enterLanguageName": "请输入语言名称",
"english": "英语",
"chinese": "中文",
"japanese": "日语",
"korean": "韩语",
"meanings": "释义",
"addMeaning": "添加释义",
"partOfSpeech": "词性",
"deleteConfirm": "确定删除这张卡片吗?",
"cardDeleted": "卡片已删除",
"cardUpdated": "卡片已更新"
},
"home": { "home": {
"title": "学语言", "title": "学语言",
"description": "这里是一个非常有用的网站,可以帮助您学习世界上几乎每一种语言,包括人造语言。", "description": "这里是一个非常有用的网站,可以帮助您学习世界上几乎每一种语言,包括人造语言。",
@@ -228,9 +157,6 @@
"resetPasswordFailed": "发送重置邮件失败", "resetPasswordFailed": "发送重置邮件失败",
"resetPasswordEmailSent": "重置邮件已发送", "resetPasswordEmailSent": "重置邮件已发送",
"resetPasswordEmailSentHint": "我们已向您的邮箱发送了密码重置链接,请查收。", "resetPasswordEmailSentHint": "我们已向您的邮箱发送了密码重置链接,请查收。",
"verifyYourEmail": "验证您的邮箱",
"verificationEmailSent": "验证邮件已发送",
"verificationEmailSentHint": "我们已向 {email} 发送了验证邮件,请点击邮件中的链接完成验证。",
"checkYourEmail": "请查收邮件", "checkYourEmail": "请查收邮件",
"backToLogin": "返回登录", "backToLogin": "返回登录",
"resetPassword": "重置密码", "resetPassword": "重置密码",
@@ -240,72 +166,25 @@
"requestNewToken": "重新申请重置链接", "requestNewToken": "重新申请重置链接",
"resetPasswordSuccess": "密码重置成功", "resetPasswordSuccess": "密码重置成功",
"resetPasswordSuccessTitle": "密码重置完成", "resetPasswordSuccessTitle": "密码重置完成",
"resetPasswordSuccessHint": "您的密码已成功重置,现在可以使用新密码登录了。", "resetPasswordSuccessHint": "您的密码已成功重置,现在可以使用新密码登录了。"
"emailNotVerified": "请验证您的邮箱地址",
"emailNotVerifiedHint": "您的邮箱尚未验证。请检查收件箱或重新发送验证邮件。",
"resendVerification": "重新发送验证邮件",
"resendSuccess": "验证邮件已发送!请检查您的收件箱。",
"resendFailed": "发送验证邮件失败"
}, },
"memorize": { "memorize": {
"deck_selector": { "folder_selector": {
"selectDeck": "选择牌组", "selectFolder": "选择文件夹",
"noDecks": "未找到牌组", "noFolders": "未找到文件夹",
"goToDecks": "前往牌组", "folderInfo": "{id}. {name} ({count})"
"noCards": "无卡片",
"new": "新卡片",
"learning": "学习中",
"review": "复习",
"due": "待复习"
}, },
"review": { "memorize": {
"loading": "加载中...", "answer": "答案",
"backToDecks": "返回牌组", "next": "下一个",
"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": "反向", "reverse": "反向",
"dictation": "听写", "dictation": "听写",
"clickToPlay": "点击播放", "noTextPairs": "没有可用的文本对",
"yourAnswer": "你的答案", "disorder": "乱序",
"typeWhatYouHear": "输入你听到的内容", "previous": "上一个"
"correct": "正确",
"incorrect": "错误",
"restart": "重新开始",
"orderLimited": "顺序有限",
"orderInfinite": "顺序无限",
"randomLimited": "随机有限",
"randomInfinite": "随机无限",
"noIpa": "无音标"
}, },
"page": { "page": {
"unauthorized": "您无权访问该牌组" "unauthorized": "您无权访问该文件夹"
} }
}, },
"navbar": { "navbar": {
@@ -313,56 +192,11 @@
"sourceCode": "源码", "sourceCode": "源码",
"sign_in": "登录", "sign_in": "登录",
"profile": "个人资料", "profile": "个人资料",
"folders": "牌组", "folders": "文件夹",
"explore": "探索", "explore": "探索",
"favorites": "收藏", "favorites": "收藏",
"settings": "设置" "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": { "profile": {
"myProfile": "我的个人资料", "myProfile": "我的个人资料",
"email": "邮箱:{email}", "email": "邮箱:{email}",
@@ -402,43 +236,12 @@
"videoUploadFailed": "视频上传失败", "videoUploadFailed": "视频上传失败",
"subtitleUploadFailed": "字幕上传失败", "subtitleUploadFailed": "字幕上传失败",
"subtitleLoadSuccess": "字幕加载成功", "subtitleLoadSuccess": "字幕加载成功",
"subtitleLoadFailed": "字幕加载失败", "subtitleLoadFailed": "字幕加载失败"
"settings": "设置",
"shortcuts": "快捷键",
"keyboardShortcuts": "键盘快捷键",
"playPause": "播放/暂停",
"autoPauseToggle": "自动暂停开关",
"subtitleSettings": "字幕设置",
"fontSize": "字体大小",
"textColor": "文字颜色",
"backgroundColor": "背景颜色",
"position": "位置",
"opacity": "透明度",
"top": "顶部",
"center": "居中",
"bottom": "底部"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "生成IPA", "generateIPA": "生成IPA",
"viewSavedItems": "查看保存项", "viewSavedItems": "查看保存项",
"confirmDeleteAll": "确定删光吗?(Y/N)", "confirmDeleteAll": "确定删光吗?(Y/N)"
"saved": "已保存",
"clearAll": "清空全部",
"language": "语言",
"customLanguage": "或输入语言...",
"languages": {
"auto": "自动",
"chinese": "中文",
"english": "英语",
"japanese": "日语",
"korean": "韩语",
"french": "法语",
"german": "德语",
"italian": "意大利语",
"spanish": "西班牙语",
"portuguese": "葡萄牙语",
"russian": "俄语"
}
}, },
"translator": { "translator": {
"detectLanguage": "检测语言", "detectLanguage": "检测语言",
@@ -471,20 +274,7 @@
"success": "文本对已添加到文件夹", "success": "文本对已添加到文件夹",
"error": "添加文本对到文件夹失败" "error": "添加文本对到文件夹失败"
}, },
"autoSave": "自动保存", "autoSave": "自动保存"
"customLanguage": "或输入语言...",
"pleaseLogin": "请登录后保存卡片",
"pleaseCreateDeck": "请先创建卡组",
"noTranslationToSave": "没有可保存的翻译",
"noDeckSelected": "未选择卡组",
"saveAsCard": "保存为卡片",
"selectDeck": "选择卡组",
"front": "正面",
"back": "背面",
"cancel": "取消",
"save": "保存",
"savedToDeck": "已保存到 {deckName}",
"saveFailed": "保存失败"
}, },
"dictionary": { "dictionary": {
"title": "词典", "title": "词典",
@@ -519,11 +309,11 @@
}, },
"explore": { "explore": {
"title": "探索", "title": "探索",
"subtitle": "发现公开牌组", "subtitle": "发现公开文件夹",
"searchPlaceholder": "搜索公开牌组...", "searchPlaceholder": "搜索公开文件夹...",
"loading": "加载中...", "loading": "加载中...",
"noDecks": "暂无公开卡组", "noFolders": "没有找到公开文件夹",
"deckInfo": "{userName} · {totalCards} 张", "folderInfo": "{userName} {totalPairs} 个文本对",
"unknownUser": "未知用户", "unknownUser": "未知用户",
"favorite": "收藏", "favorite": "收藏",
"unfavorite": "取消收藏", "unfavorite": "取消收藏",
@@ -532,10 +322,10 @@
"sortByFavoritesActive": "取消按收藏数排序" "sortByFavoritesActive": "取消按收藏数排序"
}, },
"exploreDetail": { "exploreDetail": {
"title": "牌组详情", "title": "文件夹详情",
"createdBy": "创建者:{name}", "createdBy": "创建者:{name}",
"unknownUser": "未知用户", "unknownUser": "未知用户",
"totalCards": "共 {count} 张", "totalPairs": "词对数量",
"favorites": "收藏数", "favorites": "收藏数",
"createdAt": "创建时间", "createdAt": "创建时间",
"viewContent": "查看内容", "viewContent": "查看内容",
@@ -564,60 +354,18 @@
"displayName": "显示名称", "displayName": "显示名称",
"notSet": "未设置", "notSet": "未设置",
"memberSince": "注册时间", "memberSince": "注册时间",
"joined": "注册于", "joined": "加入于",
"logout": "登出", "logout": "登出",
"deleteAccount": { "folders": {
"button": "注销账号", "title": "文件夹",
"title": "注销账号", "noFolders": "还没有文件夹",
"warning": "此操作不可逆,您的所有数据将被永久删除。", "folderName": "文件夹名称",
"warningDecks": "您的所有牌组和卡片", "totalPairs": "文本对数量",
"warningCards": "您的所有学习进度",
"warningHistory": "您的所有翻译和词典历史",
"warningPermanent": "此操作无法撤销",
"confirmLabel": "输入您的用户名以确认:",
"usernameMismatch": "用户名不匹配",
"cancel": "取消",
"confirm": "注销我的账号",
"success": "账号已成功注销",
"failed": "注销账号失败"
},
"decks": {
"title": "牌组",
"noDecks": "还没有牌组",
"deckName": "牌组名称",
"totalCards": "卡片数量",
"createdAt": "创建时间", "createdAt": "创建时间",
"actions": "操作", "actions": "操作",
"view": "查看" "view": "查看"
} }
}, },
"decks": {
"title": "牌组",
"subtitle": "管理你的学习卡组",
"newDeck": "新建卡组",
"noDecksYet": "暂无卡组",
"loading": "加载中...",
"deckInfo": "ID: {id} · {totalCards} 张",
"enterDeckName": "输入卡组名称:",
"enterNewName": "输入新名称:",
"confirmDelete": "输入 \"{name}\" 确认删除:",
"public": "公开",
"private": "私有",
"setPublic": "设为公开",
"setPrivate": "设为私有",
"importApkg": "导入 APKG",
"exportApkg": "导出 APKG",
"clickToUpload": "点击上传",
"apkgFilesOnly": "仅支持 .apkg 文件",
"parsing": "解析中...",
"foundDecks": "发现 {count} 个卡组",
"deckName": "卡组名称",
"back": "返回",
"import": "导入",
"importing": "导入中...",
"exportSuccess": "导出成功",
"goToDecks": "前往卡组"
},
"follow": { "follow": {
"follow": "关注", "follow": "关注",
"following": "已关注", "following": "已关注",

View File

@@ -18,7 +18,6 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"jszip": "^3.10.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"next": "16.1.1", "next": "16.1.1",
"next-intl": "^4.7.0", "next-intl": "^4.7.0",
@@ -28,7 +27,6 @@
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"sql.js": "^1.14.1",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"unstorage": "^1.17.3", "unstorage": "^1.17.3",
"winston": "^3.19.0", "winston": "^3.19.0",
@@ -43,7 +41,6 @@
"@types/nodemailer": "^7.0.11", "@types/nodemailer": "^7.0.11",
"@types/react": "19.2.7", "@types/react": "19.2.7",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@types/sql.js": "^1.4.9",
"@typescript-eslint/eslint-plugin": "^8.51.0", "@typescript-eslint/eslint-plugin": "^8.51.0",
"@typescript-eslint/parser": "^8.51.0", "@typescript-eslint/parser": "^8.51.0",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",

121
pnpm-lock.yaml generated
View File

@@ -23,7 +23,7 @@ importers:
version: 3.0.3 version: 3.0.3
better-auth: better-auth:
specifier: ^1.4.10 specifier: ^1.4.10
version: 1.4.10(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(@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: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
@@ -33,9 +33,6 @@ importers:
dotenv: dotenv:
specifier: ^17.2.3 specifier: ^17.2.3
version: 17.2.3 version: 17.2.3
jszip:
specifier: ^3.10.1
version: 3.10.1
lucide-react: lucide-react:
specifier: ^0.562.0 specifier: ^0.562.0
version: 0.562.0(react@19.2.3) version: 0.562.0(react@19.2.3)
@@ -63,9 +60,6 @@ importers:
sonner: sonner:
specifier: ^2.0.7 specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
sql.js:
specifier: ^1.14.1
version: 1.14.1
tailwind-merge: tailwind-merge:
specifier: ^3.4.0 specifier: ^3.4.0
version: 3.4.0 version: 3.4.0
@@ -84,7 +78,7 @@ importers:
devDependencies: devDependencies:
'@better-auth/cli': '@better-auth/cli':
specifier: ^1.4.10 specifier: ^1.4.10
version: 1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@types/react@19.2.7)(@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': '@eslint/eslintrc':
specifier: ^3.3.3 specifier: ^3.3.3
version: 3.3.3 version: 3.3.3
@@ -103,9 +97,6 @@ importers:
'@types/react-dom': '@types/react-dom':
specifier: 19.2.3 specifier: 19.2.3
version: 19.2.3(@types/react@19.2.7) version: 19.2.3(@types/react@19.2.7)
'@types/sql.js':
specifier: ^1.4.9
version: 1.4.9
'@typescript-eslint/eslint-plugin': '@typescript-eslint/eslint-plugin':
specifier: ^8.51.0 specifier: ^8.51.0
version: 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) version: 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
@@ -1055,9 +1046,6 @@ packages:
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
'@types/emscripten@1.41.5':
resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==}
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -1084,9 +1072,6 @@ packages:
'@types/react@19.2.7': '@types/react@19.2.7':
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
'@types/sql.js@1.4.9':
resolution: {integrity: sha512-ep8b36RKHlgWPqjNG9ToUrPiwkhwh0AEzy883mO5Xnd+cL6VBH1EvSjBAAuxLUFF2Vn/moE3Me6v9E1Lo+48GQ==}
'@types/triple-beam@1.3.5': '@types/triple-beam@1.3.5':
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
@@ -1635,9 +1620,6 @@ packages:
cookie-es@1.2.2: cookie-es@1.2.2:
resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==} resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -2236,9 +2218,6 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
import-fresh@3.3.1: import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -2387,9 +2366,6 @@ packages:
resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==}
engines: {node: '>=16'} engines: {node: '>=16'}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
isarray@2.0.5: isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
@@ -2441,9 +2417,6 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
keyv@4.5.4: keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -2469,9 +2442,6 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
lightningcss-android-arm64@1.30.2: lightningcss-android-arm64@1.30.2:
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@@ -2792,9 +2762,6 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'} engines: {node: '>=10'}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
parent-module@1.0.1: parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -2933,9 +2900,6 @@ packages:
typescript: typescript:
optional: true optional: true
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
prompts@2.4.2: prompts@2.4.2:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -2981,9 +2945,6 @@ packages:
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
readable-stream@3.6.2: readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -3044,9 +3005,6 @@ packages:
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
engines: {node: '>=0.4'} engines: {node: '>=0.4'}
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
safe-buffer@5.2.1: safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
@@ -3095,9 +3053,6 @@ packages:
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
sharp@0.34.5: sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -3156,9 +3111,6 @@ packages:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'} engines: {node: '>= 10.x'}
sql.js@1.14.1:
resolution: {integrity: sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==}
sqlstring@2.3.3: sqlstring@2.3.3:
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -3199,9 +3151,6 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
string_decoder@1.3.0: string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
@@ -3750,7 +3699,7 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
'@better-auth/cli@1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@types/react@19.2.7)(@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: dependencies:
'@babel/core': 7.28.5 '@babel/core': 7.28.5
'@babel/preset-react': 7.28.5(@babel/core@7.28.5) '@babel/preset-react': 7.28.5(@babel/core@7.28.5)
@@ -3762,13 +3711,13 @@ snapshots:
'@mrleebo/prisma-ast': 0.13.1 '@mrleebo/prisma-ast': 0.13.1
'@prisma/client': 5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)) '@prisma/client': 5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))
'@types/pg': 8.15.6 '@types/pg': 8.15.6
better-auth: 1.4.10(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(@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 better-sqlite3: 12.5.0
c12: 3.3.2 c12: 3.3.2
chalk: 5.6.2 chalk: 5.6.2
commander: 12.1.0 commander: 12.1.0
dotenv: 17.2.3 dotenv: 17.2.3
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(@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 open: 10.2.0
pg: 8.16.3 pg: 8.16.3
prettier: 3.7.4 prettier: 3.7.4
@@ -4462,8 +4411,6 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@types/emscripten@1.41.5': {}
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
@@ -4492,11 +4439,6 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 csstype: 3.2.3
'@types/sql.js@1.4.9':
dependencies:
'@types/emscripten': 1.41.5
'@types/node': 25.0.3
'@types/triple-beam@1.3.5': {} '@types/triple-beam@1.3.5': {}
'@typescript-eslint/eslint-plugin@8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': '@typescript-eslint/eslint-plugin@8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
@@ -4868,7 +4810,7 @@ snapshots:
bcryptjs@3.0.3: {} bcryptjs@3.0.3: {}
better-auth@1.4.10(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(@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: dependencies:
'@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0) '@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)
'@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)) '@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))
@@ -4885,7 +4827,7 @@ snapshots:
optionalDependencies: optionalDependencies:
'@prisma/client': 5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)) '@prisma/client': 5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))
better-sqlite3: 12.5.0 better-sqlite3: 12.5.0
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(@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 mysql2: 3.15.3
next: 16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next: 16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
pg: 8.16.3 pg: 8.16.3
@@ -4893,7 +4835,7 @@ snapshots:
react: 19.2.3 react: 19.2.3
react-dom: 19.2.3(react@19.2.3) react-dom: 19.2.3(react@19.2.3)
better-auth@1.4.10(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(@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: dependencies:
'@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0) '@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)
'@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)) '@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))
@@ -4910,7 +4852,7 @@ snapshots:
optionalDependencies: optionalDependencies:
'@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3) '@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)
better-sqlite3: 12.5.0 better-sqlite3: 12.5.0
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(@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 mysql2: 3.15.3
next: 16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next: 16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
pg: 8.16.3 pg: 8.16.3
@@ -5092,8 +5034,6 @@ snapshots:
cookie-es@1.2.2: {} cookie-es@1.2.2: {}
core-util-is@1.0.3: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -5185,13 +5125,12 @@ snapshots:
dotenv@17.2.3: {} dotenv@17.2.3: {}
drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(@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: optionalDependencies:
'@electric-sql/pglite': 0.3.15 '@electric-sql/pglite': 0.3.15
'@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3) '@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)
'@types/pg': 8.15.6 '@types/pg': 8.15.6
'@types/react': 19.2.7 '@types/react': 19.2.7
'@types/sql.js': 1.4.9
better-sqlite3: 12.5.0 better-sqlite3: 12.5.0
kysely: 0.28.8 kysely: 0.28.8
mysql2: 3.15.3 mysql2: 3.15.3
@@ -5199,7 +5138,6 @@ snapshots:
postgres: 3.4.7 postgres: 3.4.7
prisma: 7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) prisma: 7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)
react: 19.2.3 react: 19.2.3
sql.js: 1.14.1
dunder-proto@1.0.1: dunder-proto@1.0.1:
dependencies: dependencies:
@@ -5747,8 +5685,6 @@ snapshots:
ignore@7.0.5: {} ignore@7.0.5: {}
immediate@3.0.6: {}
import-fresh@3.3.1: import-fresh@3.3.1:
dependencies: dependencies:
parent-module: 1.0.1 parent-module: 1.0.1
@@ -5901,8 +5837,6 @@ snapshots:
dependencies: dependencies:
is-inside-container: 1.0.0 is-inside-container: 1.0.0
isarray@1.0.0: {}
isarray@2.0.5: {} isarray@2.0.5: {}
isexe@2.0.0: {} isexe@2.0.0: {}
@@ -5947,13 +5881,6 @@ snapshots:
object.assign: 4.1.7 object.assign: 4.1.7
object.values: 1.2.1 object.values: 1.2.1
jszip@3.10.1:
dependencies:
lie: 3.3.0
pako: 1.0.11
readable-stream: 2.3.8
setimmediate: 1.0.5
keyv@4.5.4: keyv@4.5.4:
dependencies: dependencies:
json-buffer: 3.0.1 json-buffer: 3.0.1
@@ -5975,10 +5902,6 @@ snapshots:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
type-check: 0.4.0 type-check: 0.4.0
lie@3.3.0:
dependencies:
immediate: 3.0.6
lightningcss-android-arm64@1.30.2: lightningcss-android-arm64@1.30.2:
optional: true optional: true
@@ -6281,8 +6204,6 @@ snapshots:
dependencies: dependencies:
p-limit: 3.1.0 p-limit: 3.1.0
pako@1.0.11: {}
parent-module@1.0.1: parent-module@1.0.1:
dependencies: dependencies:
callsites: 3.1.0 callsites: 3.1.0
@@ -6412,8 +6333,6 @@ snapshots:
- react - react
- react-dom - react-dom
process-nextick-args@2.0.1: {}
prompts@2.4.2: prompts@2.4.2:
dependencies: dependencies:
kleur: 3.0.3 kleur: 3.0.3
@@ -6465,16 +6384,6 @@ snapshots:
react@19.2.3: {} react@19.2.3: {}
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
inherits: 2.0.4
isarray: 1.0.0
process-nextick-args: 2.0.1
safe-buffer: 5.1.2
string_decoder: 1.1.1
util-deprecate: 1.0.2
readable-stream@3.6.2: readable-stream@3.6.2:
dependencies: dependencies:
inherits: 2.0.4 inherits: 2.0.4
@@ -6543,8 +6452,6 @@ snapshots:
has-symbols: 1.1.0 has-symbols: 1.1.0
isarray: 2.0.5 isarray: 2.0.5
safe-buffer@5.1.2: {}
safe-buffer@5.2.1: {} safe-buffer@5.2.1: {}
safe-push-apply@1.0.0: safe-push-apply@1.0.0:
@@ -6594,8 +6501,6 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
es-object-atoms: 1.1.1 es-object-atoms: 1.1.1
setimmediate@1.0.5: {}
sharp@0.34.5: sharp@0.34.5:
dependencies: dependencies:
'@img/colour': 1.0.0 '@img/colour': 1.0.0
@@ -6685,8 +6590,6 @@ snapshots:
split2@4.2.0: {} split2@4.2.0: {}
sql.js@1.14.1: {}
sqlstring@2.3.3: {} sqlstring@2.3.3: {}
stable-hash@0.0.5: {} stable-hash@0.0.5: {}
@@ -6750,10 +6653,6 @@ snapshots:
define-properties: 1.2.1 define-properties: 1.2.1
es-object-atoms: 1.1.1 es-object-atoms: 1.1.1
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
string_decoder@1.3.0: string_decoder@1.3.0:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -7,27 +7,25 @@ datasource db {
provider = "postgresql" provider = "postgresql"
} }
// ============================================
// User & Auth
// ============================================
model User { model User {
id String @id id String @id
name String name String
email String @unique email String @unique
emailVerified Boolean @default(false) emailVerified Boolean @default(false)
image String? image String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
displayUsername String? displayUsername String?
username String @unique username String @unique
bio String? bio String?
accounts Account[] accounts Account[]
decks Deck[] dictionaryLookUps DictionaryLookUp[]
deckFavorites DeckFavorite[] folders Folder[]
sessions Session[] folderFavorites FolderFavorite[]
followers Follow[] @relation("UserFollowers") sessions Session[]
following Follow[] @relation("UserFollowing") translationHistories TranslationHistory[]
followers Follow[] @relation("UserFollowers")
following Follow[] @relation("UserFollowing")
@@map("user") @@map("user")
} }
@@ -79,86 +77,130 @@ model Verification {
@@map("verification") @@map("verification")
} }
// ============================================ model Pair {
// Deck & Card 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 { enum Visibility {
PUBLIC
PRIVATE PRIVATE
PUBLIC
} }
enum CardType { model Folder {
WORD id Int @id @default(autoincrement())
PHRASE
SENTENCE
}
model Deck {
id Int @id @default(autoincrement())
name String name String
desc String @db.Text @default("") userId String @map("user_id")
userId String visibility Visibility @default(PRIVATE)
visibility Visibility @default(PRIVATE) createdAt DateTime @default(now()) @map("created_at")
createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @map("updated_at")
updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) pairs Pair[]
cards Card[] favorites FolderFavorite[]
favorites DeckFavorite[]
@@index([userId]) @@index([userId])
@@index([visibility]) @@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[]
@@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()) id Int @id @default(autoincrement())
userId String userId String @map("user_id")
deckId Int folderId Int @map("folder_id")
createdAt DateTime @default(now()) createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
@@unique([userId, deckId]) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
@@unique([userId, folderId])
@@index([userId]) @@index([userId])
@@index([deckId]) @@index([folderId])
@@map("deck_favorites") @@map("folder_favorites")
} }
// ============================================ model DictionaryLookUp {
// Social 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 { model Follow {
id String @id @default(cuid()) id String @id @default(cuid())

View File

@@ -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();

View File

@@ -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();

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { authClient } from "@/lib/auth-client";
import Link from "next/link"; import Link from "next/link";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslations } from "next-intl"; 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 { Input } from "@/design-system/base/input";
import { PrimaryButton } from "@/design-system/base/button"; import { PrimaryButton } from "@/design-system/base/button";
import { VStack } from "@/design-system/layout/stack"; import { VStack } from "@/design-system/layout/stack";
import { actionRequestPasswordReset } from "@/modules/auth/forgot-password-action";
export default function ForgotPasswordPage() { export default function ForgotPasswordPage() {
const t = useTranslations("auth"); const t = useTranslations("auth");
@@ -23,13 +23,16 @@ export default function ForgotPasswordPage() {
} }
setLoading(true); setLoading(true);
const result = await actionRequestPasswordReset({ email }); const { error } = await authClient.requestPasswordReset({
email,
redirectTo: "/reset-password",
});
if (!result.success) { if (error) {
toast.error(result.message); toast.error(error.message ?? t("resetPasswordFailed"));
} else { } else {
setSent(true); setSent(true);
toast.success(result.message); toast.success(t("resetPasswordEmailSent"));
} }
setLoading(false); setLoading(false);
}; };

View File

@@ -8,7 +8,7 @@ import { toast } from "sonner";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Card, CardBody } from "@/design-system/base/card"; import { Card, CardBody } from "@/design-system/base/card";
import { Input } from "@/design-system/base/input"; import { Input } from "@/design-system/base/input";
import { PrimaryButton, LinkButton } from "@/design-system/base/button"; import { PrimaryButton } from "@/design-system/base/button";
import { VStack } from "@/design-system/layout/stack"; import { VStack } from "@/design-system/layout/stack";
export default function LoginPage() { export default function LoginPage() {
@@ -16,9 +16,6 @@ export default function LoginPage() {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [resendLoading, setResendLoading] = useState(false);
const [showResendOption, setShowResendOption] = useState(false);
const [unverifiedEmail, setUnverifiedEmail] = useState("");
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const redirectTo = searchParams.get("redirect"); const redirectTo = searchParams.get("redirect");
@@ -28,31 +25,10 @@ export default function LoginPage() {
useEffect(() => { useEffect(() => {
if (!isPending && session?.user?.username && !redirectTo) { if (!isPending && session?.user?.username && !redirectTo) {
router.push("/decks"); router.push("/folders");
} }
}, [session, isPending, router, redirectTo]); }, [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 () => { const handleLogin = async () => {
if (!username || !password) { if (!username || !password) {
toast.error(t("enterCredentials")); toast.error(t("enterCredentials"));
@@ -60,7 +36,6 @@ export default function LoginPage() {
} }
setLoading(true); setLoading(true);
setShowResendOption(false);
try { try {
if (username.includes("@")) { if (username.includes("@")) {
const { error } = await authClient.signIn.email({ const { error } = await authClient.signIn.email({
@@ -68,13 +43,7 @@ export default function LoginPage() {
password: password, password: password,
}); });
if (error) { if (error) {
if (error.status === 403) { toast.error(error.message ?? t("loginFailed"));
setUnverifiedEmail(username);
setShowResendOption(true);
toast.error(t("emailNotVerified"));
} else {
toast.error(error.message ?? t("loginFailed"));
}
return; return;
} }
} else { } else {
@@ -83,15 +52,11 @@ export default function LoginPage() {
password: password, password: password,
}); });
if (error) { if (error) {
if (error.status === 403) { toast.error(error.message ?? t("loginFailed"));
toast.error(t("emailNotVerified"));
} else {
toast.error(error.message ?? t("loginFailed"));
}
return; return;
} }
} }
router.push(redirectTo ?? "/decks"); router.push(redirectTo ?? "/folders");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -126,21 +91,6 @@ export default function LoginPage() {
{t("forgotPassword")} {t("forgotPassword")}
</Link> </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 <PrimaryButton
onClick={handleLogin} onClick={handleLogin}
loading={loading} loading={loading}

View File

@@ -9,5 +9,5 @@ export default async function ProfilePage() {
redirect("/login?redirect=/profile"); redirect("/login?redirect=/profile");
} }
redirect(session.user.username ? `/users/${session.user.username}` : "/decks"); redirect(session.user.username ? `/users/${session.user.username}` : "/folders");
} }

View File

@@ -18,7 +18,6 @@ export default function SignUpPage() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [verificationSent, setVerificationSent] = useState(false);
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const redirectTo = searchParams.get("redirect"); const redirectTo = searchParams.get("redirect");
@@ -27,10 +26,10 @@ export default function SignUpPage() {
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
if (!isPending && session?.user?.username && !redirectTo && !verificationSent) { if (!isPending && session?.user?.username && !redirectTo) {
router.push("/decks"); router.push("/folders");
} }
}, [session, isPending, router, redirectTo, verificationSent]); }, [session, isPending, router, redirectTo]);
const handleSignUp = async () => { const handleSignUp = async () => {
if (!username || !email || !password) { if (!username || !email || !password) {
@@ -50,38 +49,12 @@ export default function SignUpPage() {
toast.error(error.message ?? t("signUpFailed")); toast.error(error.message ?? t("signUpFailed"));
return; return;
} }
setVerificationSent(true); router.push(redirectTo ?? "/folders");
toast.success(t("verificationEmailSent"));
} finally { } finally {
setLoading(false); 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 ( return (
<div className="flex justify-center items-center min-h-screen"> <div className="flex justify-center items-center min-h-screen">
<Card className="w-96"> <Card className="w-96">

View File

@@ -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>
</>
);
}

View File

@@ -3,14 +3,13 @@ import Link from "next/link";
import { PageLayout } from "@/components/ui/PageLayout"; import { PageLayout } from "@/components/ui/PageLayout";
import { LinkButton } from "@/design-system/base/button"; import { LinkButton } from "@/design-system/base/button";
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action"; 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 { actionGetFollowStatus } from "@/modules/follow/follow-action";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { FollowStats } from "@/components/follow/FollowStats"; import { FollowStats } from "@/components/follow/FollowStats";
import { DeleteAccountButton } from "./DeleteAccountButton";
interface UserPageProps { interface UserPageProps {
params: Promise<{ username: string; }>; params: Promise<{ username: string; }>;
@@ -30,8 +29,8 @@ export default async function UserPage({ params }: UserPageProps) {
const user = result.data; const user = result.data;
const [decks, followStatus] = await Promise.all([ const [folders, followStatus] = await Promise.all([
repoGetDecksByUserId({ userId: user.id }), repoGetFoldersWithTotalPairsByUserId(user.id),
actionGetFollowStatus({ targetUserId: 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="bg-white rounded-lg shadow-md p-6 mb-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div></div> <div></div>
<div className="flex items-center gap-3"> {isOwnProfile && <LinkButton href="/logout">{t("logout")}</LinkButton>}
{isOwnProfile && (
<>
<LinkButton href="/logout">{t("logout")}</LinkButton>
<DeleteAccountButton username={username} />
</>
)}
</div>
</div> </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"> <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 ? ( {user.image ? (
@@ -145,45 +137,45 @@ export default async function UserPage({ params }: UserPageProps) {
</div> </div>
<div className="bg-white rounded-lg shadow-md p-6"> <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> <h2 className="text-xl font-semibold text-gray-800 mb-4">{t("folders.title")}</h2>
{decks.length === 0 ? ( {folders.length === 0 ? (
<p className="text-gray-500 text-center py-8">{t("decks.noDecks")}</p> <p className="text-gray-500 text-center py-8">{t("folders.noFolders")}</p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <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>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <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>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <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>
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"> <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> </th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
{decks.map((deck) => ( {folders.map((folder) => (
<tr key={deck.id} className="hover:bg-gray-50"> <tr key={folder.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{deck.name}</div> <div className="text-sm font-medium text-gray-900">{folder.name}</div>
<div className="text-sm text-gray-500">ID: {deck.id}</div> <div className="text-sm text-gray-500">ID: {folder.id}</div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <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>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <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>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <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> <LinkButton>
{t("decks.view")} {t("folders.view")}
</LinkButton> </LinkButton>
</Link> </Link>
</td> </td>

View File

@@ -7,25 +7,19 @@ import { useDictionaryStore } from "./stores/dictionaryStore";
import { PageLayout } from "@/components/ui/PageLayout"; import { PageLayout } from "@/components/ui/PageLayout";
import { LightButton } from "@/design-system/base/button"; import { LightButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input"; import { Input } from "@/design-system/base/input";
import { Select } from "@/design-system/base/select";
import { Skeleton } from "@/design-system/feedback/skeleton";
import { HStack, VStack } from "@/design-system/layout/stack";
import { Plus, RefreshCw } from "lucide-react"; import { Plus, RefreshCw } from "lucide-react";
import { DictionaryEntry } from "./DictionaryEntry"; import { DictionaryEntry } from "./DictionaryEntry";
import { LanguageSelector } from "./LanguageSelector"; import { LanguageSelector } from "./LanguageSelector";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { actionGetDecksByUserId } from "@/modules/deck/deck-action"; import { actionGetFoldersByUserId, actionCreatePair } from "@/modules/folder/folder-action";
import { actionCreateCard } from "@/modules/card/card-action"; import { TSharedFolder } from "@/shared/folder-type";
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
import type { CardType } from "@/modules/card/card-action-dto";
import { toast } from "sonner"; import { toast } from "sonner";
import { getNativeName } from "./stores/dictionaryStore";
interface DictionaryClientProps { interface DictionaryClientProps {
initialDecks: ActionOutputDeck[]; initialFolders: TSharedFolder[];
} }
export function DictionaryClient({ initialDecks }: DictionaryClientProps) { export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
const t = useTranslations("dictionary"); const t = useTranslations("dictionary");
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -45,8 +39,7 @@ export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
} = useDictionaryStore(); } = useDictionaryStore();
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
const [decks, setDecks] = useState<ActionOutputDeck[]>(initialDecks); const [folders, setFolders] = useState<TSharedFolder[]>(initialFolders);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => { useEffect(() => {
const q = searchParams.get("q") || undefined; const q = searchParams.get("q") || undefined;
@@ -62,9 +55,9 @@ export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
useEffect(() => { useEffect(() => {
if (session?.user?.id) { if (session?.user?.id) {
actionGetDecksByUserId(session.user.id).then((result) => { actionGetFoldersByUserId(session.user.id).then((result) => {
if (result.success && result.data) { if (result.success && result.data) {
setDecks(result.data); setFolders(result.data);
} }
}); });
} }
@@ -86,67 +79,37 @@ export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
const handleSave = async () => { const handleSave = async () => {
if (!session) { if (!session) {
toast.error(t("pleaseLogin")); toast.error("Please login first");
return; return;
} }
if (decks.length === 0) { if (folders.length === 0) {
toast.error(t("pleaseCreateFolder")); toast.error("Please create a folder first");
return;
}
if (!searchResult?.entries?.length) {
toast.error("No dictionary item to save. Please search first.");
return; return;
} }
const deckSelect = document.getElementById("deck-select") as HTMLSelectElement; const folderSelect = document.getElementById("folder-select") as HTMLSelectElement;
const deckId = deckSelect?.value ? Number(deckSelect.value) : decks[0]?.id; const folderId = folderSelect?.value ? Number(folderSelect.value) : folders[0]?.id;
if (!deckId) { if (!searchResult?.entries?.length) return;
toast.error("No deck selected");
return;
}
setIsSaving(true); const definition = searchResult.entries
.map((e) => e.definition)
.join(" | ");
try { try {
const hasIpa = searchResult.entries.some((e) => e.ipa); await actionCreatePair({
const hasSpaces = searchResult.standardForm.includes(" "); text1: searchResult.standardForm,
let cardType: CardType = "WORD"; text2: definition,
if (!hasIpa) { language1: queryLang,
cardType = "SENTENCE"; language2: definitionLang,
} else if (hasSpaces) { ipa1: searchResult.entries[0]?.ipa,
cardType = "PHRASE"; folderId: folderId,
}
const ipa = searchResult.entries.find((e) => e.ipa)?.ipa || null;
const meanings = searchResult.entries.map((e) => ({
partOfSpeech: e.partOfSpeech || null,
definition: e.definition,
example: e.example || null,
}));
const cardResult = await actionCreateCard({
deckId,
word: searchResult.standardForm,
ipa,
queryLang: getNativeName(queryLang),
cardType,
meanings,
}); });
if (!cardResult.success) { const folderName = folders.find((f) => f.id === folderId)?.name || "Unknown";
toast.error(cardResult.message || t("saveFailed")); toast.success(`Saved to ${folderName}`);
setIsSaving(false);
return;
}
const deckName = decks.find((d) => d.id === deckId)?.name || "Unknown";
toast.success(t("savedToFolder", { folderName: deckName }));
} catch (error) { } catch (error) {
console.error("Save error:", error); toast.error("Save failed");
toast.error(t("saveFailed"));
} finally {
setIsSaving(false);
} }
}; };
@@ -205,14 +168,14 @@ export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
<div className="mt-8"> <div className="mt-8">
{isSearching ? ( {isSearching ? (
<VStack align="center" className="py-12"> <div className="text-center py-12">
<Skeleton variant="circular" className="w-8 h-8 mb-3" /> <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> <p className="text-gray-600">{t("searching")}</p>
</VStack> </div>
) : query && !searchResult ? ( ) : query && !searchResult ? (
<div className="text-center py-12 bg-white/20 rounded-lg"> <div className="text-center py-12 bg-white/20 rounded-lg">
<p className="text-gray-800 text-xl">{t("noResults")}</p> <p className="text-gray-800 text-xl">No results found</p>
<p className="text-gray-600 mt-2">{t("tryOtherWords")}</p> <p className="text-gray-600 mt-2">Try other words</p>
</div> </div>
) : searchResult ? ( ) : searchResult ? (
<div className="bg-white rounded-lg p-6 shadow-lg"> <div className="bg-white rounded-lg p-6 shadow-lg">
@@ -222,30 +185,27 @@ export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
{searchResult.standardForm} {searchResult.standardForm}
</h2> </h2>
</div> </div>
<HStack align="center" gap={2} className="ml-4"> <div className="flex items-center gap-2 ml-4">
{session && decks.length > 0 && ( {session && folders.length > 0 && (
<Select <select
id="deck-select" id="folder-select"
variant="bordered" className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[#35786f]"
size="sm"
> >
{decks.map((deck) => ( {folders.map((folder) => (
<option key={deck.id} value={deck.id}> <option key={folder.id} value={folder.id}>
{deck.name} {folder.name}
</option> </option>
))} ))}
</Select> </select>
)} )}
<LightButton <LightButton
onClick={handleSave} onClick={handleSave}
className="w-10 h-10 shrink-0" className="w-10 h-10 shrink-0"
title={t("saveToFolder")} title="Save to folder"
loading={isSaving}
disabled={isSaving}
> >
<Plus /> <Plus />
</LightButton> </LightButton>
</HStack> </div>
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
@@ -263,7 +223,7 @@ export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
loading={isSearching} loading={isSearching}
> >
<RefreshCw className="w-4 h-4" /> <RefreshCw className="w-4 h-4" />
{t("relookup")} Re-lookup
</LightButton> </LightButton>
</div> </div>
</div> </div>

View File

@@ -1,20 +1,20 @@
import { DictionaryClient } from "./DictionaryClient"; import { DictionaryClient } from "./DictionaryClient";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { actionGetDecksByUserId } from "@/modules/deck/deck-action"; import { actionGetFoldersByUserId } from "@/modules/folder/folder-action";
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto"; import { TSharedFolder } from "@/shared/folder-type";
export default async function DictionaryPage() { export default async function DictionaryPage() {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
let decks: ActionOutputDeck[] = []; let folders: TSharedFolder[] = [];
if (session?.user?.id) { 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) { if (result.success && result.data) {
decks = result.data; folders = result.data;
} }
} }
return <DictionaryClient initialDecks={decks} />; return <DictionaryClient initialFolders={folders} />;
} }

View File

@@ -1,15 +1,12 @@
"use client"; "use client";
import { import {
Layers, Folder as Fd,
Heart, Heart,
Search, Search,
ArrowUpDown, ArrowUpDown,
} from "lucide-react"; } from "lucide-react";
import { CircleButton } from "@/design-system/base/button"; import { CircleButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { Skeleton } from "@/design-system/feedback/skeleton";
import { HStack } from "@/design-system/layout/stack";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -17,35 +14,35 @@ import { toast } from "sonner";
import { PageLayout } from "@/components/ui/PageLayout"; import { PageLayout } from "@/components/ui/PageLayout";
import { PageHeader } from "@/components/ui/PageHeader"; import { PageHeader } from "@/components/ui/PageHeader";
import { import {
actionSearchPublicDecks, actionSearchPublicFolders,
actionToggleDeckFavorite, actionToggleFavorite,
actionCheckDeckFavorite, actionCheckFavorite,
} from "@/modules/deck/deck-action"; } from "@/modules/folder/folder-action";
import type { ActionOutputPublicDeck } from "@/modules/deck/deck-action-dto"; import { TPublicFolder } from "@/shared/folder-type";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
interface PublicDeckCardProps { interface PublicFolderCardProps {
deck: ActionOutputPublicDeck; folder: TPublicFolder;
currentUserId?: string; 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 router = useRouter();
const t = useTranslations("explore"); const t = useTranslations("explore");
const [isFavorited, setIsFavorited] = useState(false); const [isFavorited, setIsFavorited] = useState(false);
const [favoriteCount, setFavoriteCount] = useState(deck.favoriteCount); const [favoriteCount, setFavoriteCount] = useState(folder.favoriteCount);
useEffect(() => { useEffect(() => {
if (currentUserId) { if (currentUserId) {
actionCheckDeckFavorite({ deckId: deck.id }).then((result) => { actionCheckFavorite(folder.id).then((result) => {
if (result.success && result.data) { if (result.success && result.data) {
setIsFavorited(result.data.isFavorited); setIsFavorited(result.data.isFavorited);
setFavoriteCount(result.data.favoriteCount); setFavoriteCount(result.data.favoriteCount);
} }
}); });
} }
}, [deck.id, currentUserId]); }, [folder.id, currentUserId]);
const handleToggleFavorite = async (e: React.MouseEvent) => { const handleToggleFavorite = async (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
@@ -53,11 +50,11 @@ const PublicDeckCard = ({ deck, currentUserId, onUpdateFavorite }: PublicDeckCar
toast.error(t("pleaseLogin")); toast.error(t("pleaseLogin"));
return; return;
} }
const result = await actionToggleDeckFavorite({ deckId: deck.id }); const result = await actionToggleFavorite(folder.id);
if (result.success && result.data) { if (result.success && result.data) {
setIsFavorited(result.data.isFavorited); setIsFavorited(result.data.isFavorited);
setFavoriteCount(result.data.favoriteCount); setFavoriteCount(result.data.favoriteCount);
onUpdateFavorite(deck.id, result.data.isFavorited, result.data.favoriteCount); onUpdateFavorite(folder.id, result.data.isFavorited, result.data.favoriteCount);
} else { } else {
toast.error(result.message); toast.error(result.message);
} }
@@ -67,13 +64,13 @@ const PublicDeckCard = ({ deck, currentUserId, onUpdateFavorite }: PublicDeckCar
<div <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" className="group bg-white border border-gray-200 sm:border-2 rounded-lg p-3 sm:p-5 hover:border-primary-300 hover:shadow-md cursor-pointer transition-all overflow-hidden"
onClick={() => { onClick={() => {
router.push(`/explore/${deck.id}`); router.push(`/explore/${folder.id}`);
}} }}
> >
<div className="flex items-start justify-between mb-2 sm:mb-3"> <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"> <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" /> <Fd size={18} className="sm:hidden" />
<Layers size={22} className="hidden sm:block" /> <Fd size={22} className="hidden sm:block" />
</div> </div>
<CircleButton <CircleButton
onClick={handleToggleFavorite} onClick={handleToggleFavorite}
@@ -86,12 +83,12 @@ const PublicDeckCard = ({ deck, currentUserId, onUpdateFavorite }: PublicDeckCar
</CircleButton> </CircleButton>
</div> </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"> <p className="text-xs sm:text-sm text-gray-500 mb-2 sm:mb-3 line-clamp-2">
{t("deckInfo", { {t("folderInfo", {
userName: deck.userName ?? deck.userUsername ?? t("unknownUser"), userName: folder.userName ?? folder.userUsername ?? t("unknownUser"),
cardCount: deck.cardCount ?? 0, totalPairs: folder.totalPairs,
})} })}
</p> </p>
@@ -104,13 +101,13 @@ const PublicDeckCard = ({ deck, currentUserId, onUpdateFavorite }: PublicDeckCar
}; };
interface ExploreClientProps { interface ExploreClientProps {
initialPublicDecks: ActionOutputPublicDeck[]; initialPublicFolders: TPublicFolder[];
} }
export function ExploreClient({ initialPublicDecks }: ExploreClientProps) { export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
const t = useTranslations("explore"); const t = useTranslations("explore");
const router = useRouter(); const router = useRouter();
const [publicDecks, setPublicDecks] = useState<ActionOutputPublicDeck[]>(initialPublicDecks); const [publicFolders, setPublicFolders] = useState<TPublicFolder[]>(initialPublicFolders);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [sortByFavorites, setSortByFavorites] = useState(false); const [sortByFavorites, setSortByFavorites] = useState(false);
@@ -120,13 +117,13 @@ export function ExploreClient({ initialPublicDecks }: ExploreClientProps) {
const handleSearch = async () => { const handleSearch = async () => {
if (!searchQuery.trim()) { if (!searchQuery.trim()) {
setPublicDecks(initialPublicDecks); setPublicFolders(initialPublicFolders);
return; return;
} }
setLoading(true); setLoading(true);
const result = await actionSearchPublicDecks({ query: searchQuery.trim() }); const result = await actionSearchPublicFolders(searchQuery.trim());
if (result.success && result.data) { if (result.success && result.data) {
setPublicDecks(result.data); setPublicFolders(result.data);
} }
setLoading(false); setLoading(false);
}; };
@@ -135,14 +132,14 @@ export function ExploreClient({ initialPublicDecks }: ExploreClientProps) {
setSortByFavorites((prev) => !prev); setSortByFavorites((prev) => !prev);
}; };
const sortedDecks = sortByFavorites const sortedFolders = sortByFavorites
? [...publicDecks].sort((a, b) => b.favoriteCount - a.favoriteCount) ? [...publicFolders].sort((a, b) => b.favoriteCount - a.favoriteCount)
: publicDecks; : publicFolders;
const handleUpdateFavorite = (deckId: number, _isFavorited: boolean, favoriteCount: number) => { const handleUpdateFavorite = (folderId: number, _isFavorited: boolean, favoriteCount: number) => {
setPublicDecks((prev) => setPublicFolders((prev) =>
prev.map((d) => prev.map((f) =>
d.id === deckId ? { ...d, favoriteCount } : d f.id === folderId ? { ...f, favoriteCount } : f
) )
); );
}; };
@@ -151,16 +148,18 @@ export function ExploreClient({ initialPublicDecks }: ExploreClientProps) {
<PageLayout> <PageLayout>
<PageHeader title={t("title")} subtitle={t("subtitle")} /> <PageHeader title={t("title")} subtitle={t("subtitle")} />
<HStack align="center" gap={2} className="mb-6"> <div className="flex items-center gap-2 mb-6">
<Input <div className="relative flex-1">
variant="bordered" <Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
value={searchQuery} <input
onChange={(e) => setSearchQuery(e.target.value)} type="text"
onKeyDown={(e) => e.key === "Enter" && handleSearch()} value={searchQuery}
placeholder={t("searchPlaceholder")} onChange={(e) => setSearchQuery(e.target.value)}
leftIcon={<Search size={18} />} onKeyDown={(e) => e.key === "Enter" && handleSearch()}
containerClassName="flex-1" placeholder={t("searchPlaceholder")}
/> className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<CircleButton <CircleButton
onClick={handleToggleSort} onClick={handleToggleSort}
title={sortByFavorites ? t("sortByFavoritesActive") : t("sortByFavorites")} title={sortByFavorites ? t("sortByFavoritesActive") : t("sortByFavorites")}
@@ -171,26 +170,26 @@ export function ExploreClient({ initialPublicDecks }: ExploreClientProps) {
<CircleButton onClick={handleSearch}> <CircleButton onClick={handleSearch}>
<Search size={18} /> <Search size={18} />
</CircleButton> </CircleButton>
</HStack> </div>
{loading ? ( {loading ? (
<div className="p-8 text-center"> <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> <p className="text-sm text-gray-500">{t("loading")}</p>
</div> </div>
) : sortedDecks.length === 0 ? ( ) : sortedFolders.length === 0 ? (
<div className="text-center py-12 text-gray-400"> <div className="text-center py-12 text-gray-400">
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center"> <div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
<Layers size={24} className="text-gray-400" /> <Fd size={24} className="text-gray-400" />
</div> </div>
<p className="text-sm">{t("noDecks")}</p> <p className="text-sm">{t("noFolders")}</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{sortedDecks.map((deck) => ( {sortedFolders.map((folder) => (
<PublicDeckCard <PublicFolderCard
key={deck.id} key={folder.id}
deck={deck} folder={folder}
currentUserId={currentUserId} currentUserId={currentUserId}
onUpdateFavorite={handleUpdateFavorite} onUpdateFavorite={handleUpdateFavorite}
/> />

View File

@@ -1,6 +1,6 @@
"use client"; "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 { CircleButton } from "@/design-system/base/button";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -8,42 +8,42 @@ import { useTranslations } from "next-intl";
import { toast } from "sonner"; import { toast } from "sonner";
import Link from "next/link"; import Link from "next/link";
import { import {
actionToggleDeckFavorite, actionToggleFavorite,
actionCheckDeckFavorite, actionCheckFavorite,
} from "@/modules/deck/deck-action"; } from "@/modules/folder/folder-action";
import type { ActionOutputPublicDeck } from "@/modules/deck/deck-action-dto"; import { ActionOutputPublicFolder } from "@/modules/folder/folder-action-dto";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
interface ExploreDetailClientProps { interface ExploreDetailClientProps {
deck: ActionOutputPublicDeck; folder: ActionOutputPublicFolder;
} }
export function ExploreDetailClient({ deck }: ExploreDetailClientProps) { export function ExploreDetailClient({ folder }: ExploreDetailClientProps) {
const router = useRouter(); const router = useRouter();
const t = useTranslations("exploreDetail"); const t = useTranslations("exploreDetail");
const [isFavorited, setIsFavorited] = useState(false); const [isFavorited, setIsFavorited] = useState(false);
const [favoriteCount, setFavoriteCount] = useState(deck.favoriteCount); const [favoriteCount, setFavoriteCount] = useState(folder.favoriteCount);
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
const currentUserId = session?.user?.id; const currentUserId = session?.user?.id;
useEffect(() => { useEffect(() => {
if (currentUserId) { if (currentUserId) {
actionCheckDeckFavorite({ deckId: deck.id }).then((result) => { actionCheckFavorite(folder.id).then((result) => {
if (result.success && result.data) { if (result.success && result.data) {
setIsFavorited(result.data.isFavorited); setIsFavorited(result.data.isFavorited);
setFavoriteCount(result.data.favoriteCount); setFavoriteCount(result.data.favoriteCount);
} }
}); });
} }
}, [deck.id, currentUserId]); }, [folder.id, currentUserId]);
const handleToggleFavorite = async () => { const handleToggleFavorite = async () => {
if (!currentUserId) { if (!currentUserId) {
toast.error(t("pleaseLogin")); toast.error(t("pleaseLogin"));
return; return;
} }
const result = await actionToggleDeckFavorite({ deckId: deck.id }); const result = await actionToggleFavorite(folder.id);
if (result.success && result.data) { if (result.success && result.data) {
setIsFavorited(result.data.isFavorited); setIsFavorited(result.data.isFavorited);
setFavoriteCount(result.data.favoriteCount); setFavoriteCount(result.data.favoriteCount);
@@ -79,15 +79,15 @@ export function ExploreDetailClient({ deck }: ExploreDetailClientProps) {
<div className="flex items-start justify-between mb-6"> <div className="flex items-start justify-between mb-6">
<div className="flex items-center gap-4"> <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"> <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>
<div> <div>
<h2 className="text-xl sm:text-2xl font-bold text-gray-900"> <h2 className="text-xl sm:text-2xl font-bold text-gray-900">
{deck.name} {folder.name}
</h2> </h2>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">
{t("createdBy", { {t("createdBy", {
name: deck.userName ?? deck.userUsername ?? t("unknownUser"), name: folder.userName ?? folder.userUsername ?? t("unknownUser"),
})} })}
</p> </p>
</div> </div>
@@ -104,19 +104,13 @@ export function ExploreDetailClient({ deck }: ExploreDetailClientProps) {
</CircleButton> </CircleButton>
</div> </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="grid grid-cols-3 gap-4 mb-6 py-4 border-y border-gray-100">
<div className="text-center"> <div className="text-center">
<div className="text-2xl sm:text-3xl font-bold text-primary-600"> <div className="text-2xl sm:text-3xl font-bold text-primary-600">
{deck.cardCount ?? 0} {folder.totalPairs}
</div> </div>
<div className="text-xs sm:text-sm text-gray-500 mt-1"> <div className="text-xs sm:text-sm text-gray-500 mt-1">
{t("totalCards")} {t("totalPairs")}
</div> </div>
</div> </div>
<div className="text-center border-x border-gray-100"> <div className="text-center border-x border-gray-100">
@@ -130,7 +124,7 @@ export function ExploreDetailClient({ deck }: ExploreDetailClientProps) {
</div> </div>
<div className="text-center"> <div className="text-center">
<div className="text-lg sm:text-xl font-semibold text-gray-700"> <div className="text-lg sm:text-xl font-semibold text-gray-700">
{formatDate(deck.createdAt)} {formatDate(folder.createdAt)}
</div> </div>
<div className="text-xs sm:text-sm text-gray-500 mt-1"> <div className="text-xs sm:text-sm text-gray-500 mt-1">
{t("createdAt")} {t("createdAt")}
@@ -139,7 +133,7 @@ export function ExploreDetailClient({ deck }: ExploreDetailClientProps) {
</div> </div>
<Link <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" 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} /> <ExternalLink size={18} />

View File

@@ -1,8 +1,8 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ExploreDetailClient } from "./ExploreDetailClient"; 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,
}: { }: {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -13,11 +13,11 @@ export default async function ExploreDeckPage({
redirect("/explore"); redirect("/explore");
} }
const result = await actionGetPublicDeckById({ deckId: Number(id) }); const result = await actionGetPublicFolderById(Number(id));
if (!result.success || !result.data) { if (!result.success || !result.data) {
redirect("/explore"); redirect("/explore");
} }
return <ExploreDetailClient deck={result.data} />; return <ExploreDetailClient folder={result.data} />;
} }

View File

@@ -1,9 +1,9 @@
import { ExploreClient } from "./ExploreClient"; import { ExploreClient } from "./ExploreClient";
import { actionGetPublicDecks } from "@/modules/deck/deck-action"; import { actionGetPublicFolders } from "@/modules/folder/folder-action";
export default async function ExplorePage() { export default async function ExplorePage() {
const publicDecksResult = await actionGetPublicDecks(); const publicFoldersResult = await actionGetPublicFolders();
const publicDecks = publicDecksResult.success ? publicDecksResult.data ?? [] : []; const publicFolders = publicFoldersResult.success ? publicFoldersResult.data ?? [] : [];
return <ExploreClient initialPublicDecks={publicDecks} />; return <ExploreClient initialPublicFolders={publicFolders} />;
} }

View File

@@ -2,24 +2,33 @@
import { import {
ChevronRight, ChevronRight,
Layers as DeckIcon, Folder as Fd,
Heart, Heart,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { toast } from "sonner"; import { toast } from "sonner";
import { PageLayout } from "@/components/ui/PageLayout"; import { PageLayout } from "@/components/ui/PageLayout";
import { PageHeader } from "@/components/ui/PageHeader"; import { PageHeader } from "@/components/ui/PageHeader";
import { CardList } from "@/components/ui/CardList"; import { CardList } from "@/components/ui/CardList";
import { VStack } from "@/design-system/layout/stack"; import { actionGetUserFavorites, actionToggleFavorite } from "@/modules/folder/folder-action";
import { Skeleton } from "@/design-system/feedback/skeleton";
import { actionGetUserFavoriteDecks, actionToggleDeckFavorite } from "@/modules/deck/deck-action"; type UserFavorite = {
import type { ActionOutputUserFavoriteDeck } from "@/modules/deck/deck-action-dto"; id: number;
folderId: number;
folderName: string;
folderCreatedAt: Date;
folderTotalPairs: number;
folderOwnerId: string;
folderOwnerName: string | null;
folderOwnerUsername: string | null;
favoritedAt: Date;
};
interface FavoriteCardProps { interface FavoriteCardProps {
favorite: ActionOutputUserFavoriteDeck; favorite: UserFavorite;
onRemoveFavorite: (deckId: number) => void; onRemoveFavorite: (folderId: number) => void;
} }
const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => { const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
@@ -32,9 +41,9 @@ const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
if (isRemoving) return; if (isRemoving) return;
setIsRemoving(true); setIsRemoving(true);
const result = await actionToggleDeckFavorite({ deckId: favorite.id }); const result = await actionToggleFavorite(favorite.folderId);
if (result.success) { if (result.success) {
onRemoveFavorite(favorite.id); onRemoveFavorite(favorite.folderId);
} else { } else {
toast.error(result.message); toast.error(result.message);
} }
@@ -45,20 +54,20 @@ const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
<div <div
className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors" className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() => { onClick={() => {
router.push(`/explore/${favorite.id}`); router.push(`/explore/${favorite.folderId}`);
}} }}
> >
<div className="flex items-center gap-4 flex-1"> <div className="flex items-center gap-4 flex-1">
<div className="shrink-0 text-primary-500"> <div className="shrink-0 text-primary-500">
<DeckIcon size={24} /> <Fd size={24} />
</div> </div>
<div className="flex-1 min-w-0"> <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"> <p className="text-sm text-gray-500 mt-0.5">
{t("folderInfo", { {t("folderInfo", {
userName: favorite.userName ?? favorite.userUsername ?? t("unknownUser"), userName: favorite.folderOwnerName ?? favorite.folderOwnerUsername ?? t("unknownUser"),
totalPairs: favorite.cardCount ?? 0, totalPairs: favorite.folderTotalPairs,
})} })}
</p> </p>
</div> </div>
@@ -77,25 +86,29 @@ const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
}; };
interface FavoritesClientProps { interface FavoritesClientProps {
initialFavorites: ActionOutputUserFavoriteDeck[]; userId: string;
} }
export function FavoritesClient({ initialFavorites }: FavoritesClientProps) { export function FavoritesClient({ userId }: FavoritesClientProps) {
const t = useTranslations("favorites"); const t = useTranslations("favorites");
const [favorites, setFavorites] = useState<ActionOutputUserFavoriteDeck[]>(initialFavorites); const [favorites, setFavorites] = useState<UserFavorite[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(true);
useEffect(() => {
loadFavorites();
}, [userId]);
const loadFavorites = async () => { const loadFavorites = async () => {
setLoading(true); setLoading(true);
const result = await actionGetUserFavoriteDecks(); const result = await actionGetUserFavorites();
if (result.success && result.data) { if (result.success && result.data) {
setFavorites(result.data); setFavorites(result.data);
} }
setLoading(false); setLoading(false);
}; };
const handleRemoveFavorite = (deckId: number) => { const handleRemoveFavorite = (folderId: number) => {
setFavorites((prev) => prev.filter((f) => f.id !== deckId)); setFavorites((prev) => prev.filter((f) => f.folderId !== folderId));
}; };
return ( return (
@@ -104,10 +117,10 @@ export function FavoritesClient({ initialFavorites }: FavoritesClientProps) {
<CardList> <CardList>
{loading ? ( {loading ? (
<VStack align="center" className="p-8"> <div className="p-8 text-center">
<Skeleton variant="circular" className="w-8 h-8" /> <div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
<p className="text-sm text-gray-500">{t("loading")}</p> <p className="text-sm text-gray-500">{t("loading")}</p>
</VStack> </div>
) : favorites.length === 0 ? ( ) : favorites.length === 0 ? (
<div className="text-center py-12 text-gray-400"> <div className="text-center py-12 text-gray-400">
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center"> <div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">

View File

@@ -2,8 +2,6 @@ import { auth } from "@/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { FavoritesClient } from "./FavoritesClient"; 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() { export default async function FavoritesPage() {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
@@ -12,11 +10,5 @@ export default async function FavoritesPage() {
redirect("/login?redirect=/favorites"); redirect("/login?redirect=/favorites");
} }
let favorites: ActionOutputUserFavoriteDeck[] = []; return <FavoritesClient userId={session.user.id} />;
const result = await actionGetUserFavoriteDecks();
if (result.success && result.data) {
favorites = result.data;
}
return <FavoritesClient initialFavorites={favorites} />;
} }

View 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 };

View 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 };

View 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!} />;
}

View File

@@ -70,7 +70,7 @@ export default function SrtPlayerPage() {
uploadVideo((url) => { uploadVideo((url) => {
setVideoUrl(url); setVideoUrl(url);
}, (error) => { }, (error) => {
toast.error(srtT('videoUploadFailed') + ': ' + error.message); toast.error(t('videoUploadFailed') + ': ' + error.message);
}); });
}; };
@@ -78,7 +78,7 @@ export default function SrtPlayerPage() {
uploadSubtitle((url) => { uploadSubtitle((url) => {
setSubtitleUrl(url); setSubtitleUrl(url);
}, (error) => { }, (error) => {
toast.error(srtT('subtitleUploadFailed') + ': ' + error.message); toast.error(t('subtitleUploadFailed') + ': ' + error.message);
}); });
}; };

View File

@@ -1,8 +1,7 @@
"use client"; "use client";
import { LightButton, IconClick } from "@/design-system/base/button"; import { LightButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input"; import { IconClick } from "@/design-system/base/button";
import { Textarea } from "@/design-system/base/textarea";
import { IMAGES } from "@/config/images"; import { IMAGES } from "@/config/images";
import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { import {
@@ -19,38 +18,6 @@ import { genIPA, genLanguage } from "@/modules/translator/translator-action";
import { PageLayout } from "@/components/ui/PageLayout"; import { PageLayout } from "@/components/ui/PageLayout";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts"; import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
const TTS_LANGUAGES = [
{ value: "Auto", label: "auto" },
{ value: "Chinese", label: "chinese" },
{ value: "English", label: "english" },
{ value: "Japanese", label: "japanese" },
{ value: "Korean", label: "korean" },
{ value: "French", label: "french" },
{ value: "German", label: "german" },
{ value: "Italian", label: "italian" },
{ value: "Spanish", label: "spanish" },
{ value: "Portuguese", label: "portuguese" },
{ value: "Russian", label: "russian" },
] as const;
type TTSLabel = typeof TTS_LANGUAGES[number]["label"];
function getLanguageLabel(t: (key: string) => string, label: TTSLabel): string {
switch (label) {
case "auto": return t("languages.auto");
case "chinese": return t("languages.chinese");
case "english": return t("languages.english");
case "japanese": return t("languages.japanese");
case "korean": return t("languages.korean");
case "french": return t("languages.french");
case "german": return t("languages.german");
case "italian": return t("languages.italian");
case "spanish": return t("languages.spanish");
case "portuguese": return t("languages.portuguese");
case "russian": return t("languages.russian");
}
}
export default function TextSpeakerPage() { export default function TextSpeakerPage() {
const t = useTranslations("text_speaker"); const t = useTranslations("text_speaker");
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -63,8 +30,6 @@ export default function TextSpeakerPage() {
const [autopause, setAutopause] = useState(true); const [autopause, setAutopause] = useState(true);
const textRef = useRef(""); const textRef = useRef("");
const [language, setLanguage] = useState<string | null>(null); const [language, setLanguage] = useState<string | null>(null);
const [selectedLanguage, setSelectedLanguage] = useState<string>("Auto");
const [customLanguage, setCustomLanguage] = useState<string>("");
const [ipa, setIPA] = useState<string>(""); const [ipa, setIPA] = useState<string>("");
const objurlRef = useRef<string | null>(null); const objurlRef = useRef<string | null>(null);
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
@@ -128,15 +93,8 @@ export default function TextSpeakerPage() {
} else { } else {
// 第一次播放 // 第一次播放
try { try {
let theLanguage: string; let theLanguage = language;
if (!theLanguage) {
if (customLanguage.trim()) {
theLanguage = customLanguage.trim();
} else if (selectedLanguage !== "Auto") {
theLanguage = selectedLanguage;
} else if (language) {
theLanguage = language;
} else {
const tmp_language = await genLanguage(textRef.current.slice(0, 30)); const tmp_language = await genLanguage(textRef.current.slice(0, 30));
setLanguage(tmp_language); setLanguage(tmp_language);
theLanguage = tmp_language; theLanguage = tmp_language;
@@ -144,6 +102,7 @@ export default function TextSpeakerPage() {
theLanguage = theLanguage.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase()); theLanguage = theLanguage.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
// 检查语言是否在 TTS 支持列表中
const supportedLanguages: TTS_SUPPORTED_LANGUAGES[] = [ const supportedLanguages: TTS_SUPPORTED_LANGUAGES[] = [
"Auto", "Chinese", "English", "German", "Italian", "Portuguese", "Auto", "Chinese", "English", "German", "Italian", "Portuguese",
"Spanish", "Japanese", "Korean", "French", "Russian" "Spanish", "Japanese", "Korean", "French", "Russian"
@@ -179,8 +138,6 @@ export default function TextSpeakerPage() {
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => { const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
textRef.current = e.target.value.trim(); textRef.current = e.target.value.trim();
setLanguage(null); setLanguage(null);
setSelectedLanguage("Auto");
setCustomLanguage("");
setIPA(""); setIPA("");
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current); if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
objurlRef.current = null; objurlRef.current = null;
@@ -269,12 +226,11 @@ export default function TextSpeakerPage() {
style={{ fontFamily: "Times New Roman, serif" }} style={{ fontFamily: "Times New Roman, serif" }}
> >
{/* 文本输入框 */} {/* 文本输入框 */}
<Textarea <textarea
variant="bordered" className="text-2xl resize-none focus:outline-0 min-h-64 w-full border-gray-200 border-b p-4"
className="text-2xl min-h-64"
onChange={handleInputChange} onChange={handleInputChange}
ref={textareaRef} ref={textareaRef}
/> ></textarea>
{/* IPA 显示区域 */} {/* IPA 显示区域 */}
{(ipa.length !== 0 && ( {(ipa.length !== 0 && (
<div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b px-4"> <div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b px-4">
@@ -361,40 +317,6 @@ export default function TextSpeakerPage() {
alt="save" alt="save"
className={`${saving ? "bg-gray-200" : ""}`} className={`${saving ? "bg-gray-200" : ""}`}
></IconClick> ></IconClick>
{/* 语言选择器 */}
<div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center">
<span className="text-sm text-gray-600">{t("language")}</span>
{TTS_LANGUAGES.slice(0, 6).map((lang) => (
<LightButton
key={lang.value}
selected={!customLanguage && selectedLanguage === lang.value}
onClick={() => {
setSelectedLanguage(lang.value);
setCustomLanguage("");
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
objurlRef.current = null;
setPause(true);
}}
size="sm"
>
{getLanguageLabel(t, lang.label)}
</LightButton>
))}
<Input
variant="bordered"
size="sm"
value={customLanguage}
onChange={(e) => {
setCustomLanguage(e.target.value);
setSelectedLanguage("Auto");
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
objurlRef.current = null;
setPause(true);
}}
placeholder={t("customLanguage")}
className="w-auto min-w-[120px]"
/>
</div>
{/* 功能开关按钮 */} {/* 功能开关按钮 */}
<div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center"> <div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center">
<LightButton <LightButton

View File

@@ -1,85 +1,49 @@
"use client"; "use client";
import { LightButton, PrimaryButton, IconClick, CircleButton } from "@/design-system/base/button"; import { LightButton, PrimaryButton, IconClick } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { Textarea } from "@/design-system/base/textarea";
import { Select } from "@/design-system/base/select"; import { Select } from "@/design-system/base/select";
import { IMAGES } from "@/config/images"; import { IMAGES } from "@/config/images";
import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useRef, useState } from "react"; import { useRef, useState } from "react";
import { actionTranslateText } from "@/modules/translator/translator-action"; import { actionTranslateText } from "@/modules/translator/translator-action";
import { actionCreateCard } from "@/modules/card/card-action";
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
import type { CardType } from "@/modules/card/card-action-dto";
import { toast } from "sonner"; import { toast } from "sonner";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts"; import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
import { TSharedTranslationResult } from "@/shared/translator-type"; import { TSharedTranslationResult } from "@/shared/translator-type";
import { Plus } from "lucide-react";
import { authClient } from "@/lib/auth-client";
const SOURCE_LANGUAGES = [ const SOURCE_LANGUAGES = [
{ value: "Auto", label: "auto" }, { value: "Auto", labelKey: "auto" },
{ value: "Chinese", label: "chinese" }, { value: "Chinese", labelKey: "chinese" },
{ value: "English", label: "english" }, { value: "English", labelKey: "english" },
{ value: "Japanese", label: "japanese" }, { value: "Japanese", labelKey: "japanese" },
{ value: "Korean", label: "korean" }, { value: "Korean", labelKey: "korean" },
{ value: "French", label: "french" }, { value: "French", labelKey: "french" },
{ value: "German", label: "german" }, { value: "German", labelKey: "german" },
{ value: "Italian", label: "italian" }, { value: "Italian", labelKey: "italian" },
{ value: "Spanish", label: "spanish" }, { value: "Spanish", labelKey: "spanish" },
{ value: "Portuguese", label: "portuguese" }, { value: "Portuguese", labelKey: "portuguese" },
{ value: "Russian", label: "russian" }, { value: "Russian", labelKey: "russian" },
] as const; ] as const;
const TARGET_LANGUAGES = [ const TARGET_LANGUAGES = [
{ value: "Chinese", label: "chinese" }, { value: "Chinese", labelKey: "chinese" },
{ value: "English", label: "english" }, { value: "English", labelKey: "english" },
{ value: "Japanese", label: "japanese" }, { value: "Japanese", labelKey: "japanese" },
{ value: "Korean", label: "korean" }, { value: "Korean", labelKey: "korean" },
{ value: "French", label: "french" }, { value: "French", labelKey: "french" },
{ value: "German", label: "german" }, { value: "German", labelKey: "german" },
{ value: "Italian", label: "italian" }, { value: "Italian", labelKey: "italian" },
{ value: "Spanish", label: "spanish" }, { value: "Spanish", labelKey: "spanish" },
{ value: "Portuguese", label: "portuguese" }, { value: "Portuguese", labelKey: "portuguese" },
{ value: "Russian", label: "russian" }, { value: "Russian", labelKey: "russian" },
] as const; ] as const;
type LangLabel = typeof SOURCE_LANGUAGES[number]["label"];
function getLangLabel(t: (key: string) => string, label: LangLabel): string {
switch (label) {
case "auto": return t("auto");
case "chinese": return t("chinese");
case "english": return t("english");
case "japanese": return t("japanese");
case "korean": return t("korean");
case "french": return t("french");
case "german": return t("german");
case "italian": return t("italian");
case "spanish": return t("spanish");
case "portuguese": return t("portuguese");
case "russian": return t("russian");
}
}
// Estimated button width in pixels (including gap)
const BUTTON_WIDTH = 80;
const LABEL_WIDTH = 100;
const INPUT_WIDTH = 140;
const IPA_BUTTON_WIDTH = 100;
export default function TranslatorPage() { export default function TranslatorPage() {
const t = useTranslations("translator"); const t = useTranslations("translator");
const taref = useRef<HTMLTextAreaElement>(null); const taref = useRef<HTMLTextAreaElement>(null);
const sourceContainerRef = useRef<HTMLDivElement>(null);
const targetContainerRef = useRef<HTMLDivElement>(null);
const [sourceLanguage, setSourceLanguage] = useState<string>("Auto"); const [sourceLanguage, setSourceLanguage] = useState<string>("Auto");
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese"); const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
const [customSourceLanguage, setCustomSourceLanguage] = useState<string>("");
const [customTargetLanguage, setCustomTargetLanguage] = useState<string>("");
const [translationResult, setTranslationResult] = useState<TSharedTranslationResult | null>(null); const [translationResult, setTranslationResult] = useState<TSharedTranslationResult | null>(null);
const [needIpa, setNeedIpa] = useState(true); const [needIpa, setNeedIpa] = useState(true);
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
@@ -88,72 +52,38 @@ export default function TranslatorPage() {
sourceLanguage: string; sourceLanguage: string;
targetLanguage: string; targetLanguage: string;
} | null>(null); } | null>(null);
const [sourceButtonCount, setSourceButtonCount] = useState(2);
const [targetButtonCount, setTargetButtonCount] = useState(2);
const { load, play } = useAudioPlayer(); const { load, play } = useAudioPlayer();
const lastTTS = useRef({
text: "",
url: "",
});
const { data: session } = authClient.useSession(); const tts = async (text: string, locale: string) => {
const [decks, setDecks] = useState<ActionOutputDeck[]>([]); if (lastTTS.current.text !== text) {
const [showSaveModal, setShowSaveModal] = useState(false); try {
const [isSaving, setIsSaving] = useState(false); // Map language name to TTS format
let theLanguage = locale.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
useEffect(() => { // Check if language is in TTS supported list
if (session?.user?.id) { const supportedLanguages: TTS_SUPPORTED_LANGUAGES[] = [
actionGetDecksByUserId(session.user.id).then((result) => { "Auto", "Chinese", "English", "German", "Italian", "Portuguese",
if (result.success && result.data) { "Spanish", "Japanese", "Korean", "French", "Russian"
setDecks(result.data); ];
if (!supportedLanguages.includes(theLanguage as TTS_SUPPORTED_LANGUAGES)) {
theLanguage = "Auto";
} }
});
const url = await getTTSUrl(text, theLanguage as TTS_SUPPORTED_LANGUAGES);
await load(url);
await play();
lastTTS.current.text = text;
lastTTS.current.url = url;
} catch (error) {
toast.error("Failed to generate audio");
}
} }
}, [session?.user?.id]); };
// Calculate how many buttons to show based on container width
const calculateButtonCount = useCallback((containerWidth: number, hasIpa: boolean) => {
// Reserve space for label, input, and IPA button (for source)
const reservedWidth = LABEL_WIDTH + INPUT_WIDTH + (hasIpa ? IPA_BUTTON_WIDTH : 0);
const availableWidth = containerWidth - reservedWidth;
return Math.max(0, Math.floor(availableWidth / BUTTON_WIDTH));
}, []);
useEffect(() => {
const updateButtonCounts = () => {
if (sourceContainerRef.current) {
const width = sourceContainerRef.current.offsetWidth;
setSourceButtonCount(calculateButtonCount(width, true));
}
if (targetContainerRef.current) {
const width = targetContainerRef.current.offsetWidth;
setTargetButtonCount(calculateButtonCount(width, false));
}
};
updateButtonCounts();
window.addEventListener("resize", updateButtonCounts);
return () => window.removeEventListener("resize", updateButtonCounts);
}, [calculateButtonCount]);
const tts = useCallback(async (text: string, locale: string) => {
try {
// Map language name to TTS format
let theLanguage = locale.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
// Check if language is in TTS supported list
const supportedLanguages: TTS_SUPPORTED_LANGUAGES[] = [
"Auto", "Chinese", "English", "German", "Italian", "Portuguese",
"Spanish", "Japanese", "Korean", "French", "Russian"
];
if (!supportedLanguages.includes(theLanguage as TTS_SUPPORTED_LANGUAGES)) {
theLanguage = "Auto";
}
const url = await getTTSUrl(text, theLanguage as TTS_SUPPORTED_LANGUAGES);
await load(url);
await play();
} catch (error) {
toast.error("Failed to generate audio");
}
}, [load, play]);
const translate = async () => { const translate = async () => {
if (!taref.current || processing) return; if (!taref.current || processing) return;
@@ -161,30 +91,29 @@ export default function TranslatorPage() {
setProcessing(true); setProcessing(true);
const sourceText = taref.current.value; const sourceText = taref.current.value;
const effectiveSourceLanguage = customSourceLanguage.trim() || sourceLanguage;
const effectiveTargetLanguage = customTargetLanguage.trim() || targetLanguage;
// 判断是否需要强制重新翻译 // 判断是否需要强制重新翻译
// 只有当源文本、源语言和目标语言都与上次相同时,才强制重新翻译
const forceRetranslate = const forceRetranslate =
lastTranslation?.sourceText === sourceText && lastTranslation?.sourceText === sourceText &&
lastTranslation?.sourceLanguage === effectiveSourceLanguage && lastTranslation?.sourceLanguage === sourceLanguage &&
lastTranslation?.targetLanguage === effectiveTargetLanguage; lastTranslation?.targetLanguage === targetLanguage;
try { try {
const result = await actionTranslateText({ const result = await actionTranslateText({
sourceText, sourceText,
targetLanguage: effectiveTargetLanguage, targetLanguage,
forceRetranslate, forceRetranslate,
needIpa, needIpa,
sourceLanguage: effectiveSourceLanguage === "Auto" ? undefined : effectiveSourceLanguage, sourceLanguage: sourceLanguage === "Auto" ? undefined : sourceLanguage,
}); });
if (result.success && result.data) { if (result.success && result.data) {
setTranslationResult(result.data); setTranslationResult(result.data);
setLastTranslation({ setLastTranslation({
sourceText, sourceText,
sourceLanguage: effectiveSourceLanguage, sourceLanguage,
targetLanguage: effectiveTargetLanguage, targetLanguage,
}); });
} else { } else {
toast.error(result.message || "翻译失败,请重试"); 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 ( return (
<div className="min-h-[calc(100vh-64px)] bg-white"> <div className="min-h-[calc(100vh-64px)] bg-white">
{/* TCard Component */} {/* TCard Component */}
@@ -265,13 +134,13 @@ export default function TranslatorPage() {
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2"> <div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
{/* ICard1 Component */} {/* ICard1 Component */}
<div className="border border-gray-200 rounded-lg w-full h-64 p-2"> <div className="border border-gray-200 rounded-lg w-full h-64 p-2">
<Textarea <textarea
className="resize-none h-8/12 w-full" className="resize-none h-8/12 w-full focus:outline-0"
ref={taref} ref={taref}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.ctrlKey && e.key === "Enter") translate(); if (e.ctrlKey && e.key === "Enter") translate();
}} }}
/> ></textarea>
<div className="ipa w-full h-2/12 overflow-auto text-gray-600"> <div className="ipa w-full h-2/12 overflow-auto text-gray-600">
{translationResult?.sourceIpa || ""} {translationResult?.sourceIpa || ""}
</div> </div>
@@ -289,36 +158,49 @@ export default function TranslatorPage() {
src={IMAGES.play_arrow} src={IMAGES.play_arrow}
alt="play" alt="play"
onClick={() => { onClick={() => {
const text = taref.current?.value; const t = taref.current?.value;
if (!text) return; if (!t) return;
tts(text, translationResult?.sourceLanguage || ""); tts(t, translationResult?.sourceLanguage || "");
}} }}
></IconClick> ></IconClick>
</div> </div>
</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> <span className="shrink-0">{t("sourceLanguage")}</span>
{visibleSourceButtons.map((lang) => ( <LightButton
<LightButton selected={sourceLanguage === "Auto"}
key={lang.value} onClick={() => setSourceLanguage("Auto")}
selected={!customSourceLanguage && sourceLanguage === lang.value} className="shrink-0 hidden lg:inline-flex"
onClick={() => { >
setSourceLanguage(lang.value); {t("auto")}
setCustomSourceLanguage(""); </LightButton>
}} <LightButton
className="shrink-0" selected={sourceLanguage === "Chinese"}
> onClick={() => setSourceLanguage("Chinese")}
{getLangLabel(t, lang.label)} className="shrink-0 hidden lg:inline-flex"
</LightButton> >
))} {t("chinese")}
<Input </LightButton>
variant="bordered" <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" size="sm"
value={customSourceLanguage} className="w-auto min-w-[100px] shrink-0"
onChange={(e) => setCustomSourceLanguage(e.target.value)} >
placeholder={t("customLanguage")} {SOURCE_LANGUAGES.map((lang) => (
className="w-auto min-w-[120px] shrink-0" <option key={lang.value} value={lang.value}>
/> {t(lang.labelKey)}
</option>
))}
</Select>
<div className="flex-1"></div> <div className="flex-1"></div>
<LightButton <LightButton
selected={needIpa} selected={needIpa}
@@ -359,35 +241,48 @@ export default function TranslatorPage() {
></IconClick> ></IconClick>
</div> </div>
</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> <span className="shrink-0">{t("translateInto")}</span>
{visibleTargetButtons.map((lang) => ( <LightButton
<LightButton selected={targetLanguage === "Chinese"}
key={lang.value} onClick={() => setTargetLanguage("Chinese")}
selected={!customTargetLanguage && targetLanguage === lang.value} className="shrink-0 hidden lg:inline-flex"
onClick={() => { >
setTargetLanguage(lang.value); {t("chinese")}
setCustomTargetLanguage(""); </LightButton>
}} <LightButton
className="shrink-0" selected={targetLanguage === "English"}
> onClick={() => setTargetLanguage("English")}
{getLangLabel(t, lang.label)} className="shrink-0 hidden lg:inline-flex"
</LightButton> >
))} {t("english")}
<Input </LightButton>
variant="bordered" <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" size="sm"
value={customTargetLanguage} className="w-auto min-w-[100px] shrink-0"
onChange={(e) => setCustomTargetLanguage(e.target.value)} >
placeholder={t("customLanguage")} {TARGET_LANGUAGES.map((lang) => (
className="w-auto min-w-[120px] shrink-0" <option key={lang.value} value={lang.value}>
/> {t(lang.labelKey)}
</option>
))}
</Select>
</div> </div>
</div> </div>
</div> </div>
{/* TranslateButton Component */} {/* TranslateButton Component */}
<div className="w-screen flex justify-center items-center gap-4"> <div className="w-screen flex justify-center items-center">
<PrimaryButton <PrimaryButton
onClick={translate} onClick={translate}
disabled={processing} disabled={processing}
@@ -396,49 +291,7 @@ export default function TranslatorPage() {
> >
{t("translate")} {t("translate")}
</PrimaryButton> </PrimaryButton>
{translationResult && session && decks.length > 0 && (
<CircleButton
onClick={() => setShowSaveModal(true)}
title={t("saveAsCard")}
>
<Plus size={20} />
</CircleButton>
)}
</div> </div>
{showSaveModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
<h2 className="text-xl font-semibold mb-4">{t("saveAsCard")}</h2>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("selectDeck")}
</label>
<Select id="deck-select-translator" className="w-full">
{decks.map((deck) => (
<option key={deck.id} value={deck.id}>
{deck.name}
</option>
))}
</Select>
</div>
<div className="mb-4 p-3 bg-gray-50 rounded text-sm">
<div className="font-medium mb-1">{t("front")}:</div>
<div className="text-gray-700 mb-2">{lastTranslation?.sourceText}</div>
<div className="font-medium mb-1">{t("back")}:</div>
<div className="text-gray-700">{translationResult?.translatedText}</div>
</div>
<div className="flex justify-end gap-2">
<LightButton onClick={() => setShowSaveModal(false)}>
{t("cancel")}
</LightButton>
<PrimaryButton onClick={handleSaveCard} loading={isSaving}>
{t("save")}
</PrimaryButton>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -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>
);
}

View File

@@ -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}
/>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 };

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -2,16 +2,14 @@
import { import {
ChevronRight, ChevronRight,
Layers, Folder as Fd,
Pencil, FolderPen,
Plus, FolderPlus,
Globe, Globe,
Lock, Lock,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { CircleButton, LightButton } from "@/design-system/base/button"; import { CircleButton, LightButton } from "@/design-system/base/button";
import { Skeleton } from "@/design-system/feedback/skeleton";
import { VStack } from "@/design-system/layout/stack";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -20,33 +18,30 @@ import { PageLayout } from "@/components/ui/PageLayout";
import { PageHeader } from "@/components/ui/PageHeader"; import { PageHeader } from "@/components/ui/PageHeader";
import { CardList } from "@/components/ui/CardList"; import { CardList } from "@/components/ui/CardList";
import { import {
actionCreateDeck, actionCreateFolder,
actionDeleteDeck, actionDeleteFolderById,
actionGetDecksByUserId, actionGetFoldersWithTotalPairsByUserId,
actionUpdateDeck, actionRenameFolderById,
actionGetDeckById, actionSetFolderVisibility,
} from "@/modules/deck/deck-action"; } from "@/modules/folder/folder-action";
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto"; import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
interface DeckCardProps { interface FolderCardProps {
deck: ActionOutputDeck; folder: TSharedFolderWithTotalPairs;
onUpdateDeck: (deckId: number, updates: Partial<ActionOutputDeck>) => void; onUpdateFolder: (folderId: number, updates: Partial<TSharedFolderWithTotalPairs>) => void;
onDeleteDeck: (deckId: number) => void; onDeleteFolder: (folderId: number) => void;
} }
const DeckCard = ({ deck, onUpdateDeck, onDeleteDeck }: DeckCardProps) => { const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps) => {
const router = useRouter(); const router = useRouter();
const t = useTranslations("decks"); const t = useTranslations("folders");
const handleToggleVisibility = async (e: React.MouseEvent) => { const handleToggleVisibility = async (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
const newVisibility = deck.visibility === "PUBLIC" ? "PRIVATE" : "PUBLIC"; const newVisibility = folder.visibility === "PUBLIC" ? "PRIVATE" : "PUBLIC";
const result = await actionUpdateDeck({ const result = await actionSetFolderVisibility(folder.id, newVisibility);
deckId: deck.id,
visibility: newVisibility,
});
if (result.success) { if (result.success) {
onUpdateDeck(deck.id, { visibility: newVisibility }); onUpdateFolder(folder.id, { visibility: newVisibility });
} else { } else {
toast.error(result.message); toast.error(result.message);
} }
@@ -56,12 +51,9 @@ const DeckCard = ({ deck, onUpdateDeck, onDeleteDeck }: DeckCardProps) => {
e.stopPropagation(); e.stopPropagation();
const newName = prompt(t("enterNewName"))?.trim(); const newName = prompt(t("enterNewName"))?.trim();
if (newName && newName.length > 0) { if (newName && newName.length > 0) {
const result = await actionUpdateDeck({ const result = await actionRenameFolderById(folder.id, newName);
deckId: deck.id,
name: newName,
});
if (result.success) { if (result.success) {
onUpdateDeck(deck.id, { name: newName }); onUpdateFolder(folder.id, { name: newName });
} else { } else {
toast.error(result.message); toast.error(result.message);
} }
@@ -70,11 +62,11 @@ const DeckCard = ({ deck, onUpdateDeck, onDeleteDeck }: DeckCardProps) => {
const handleDelete = async (e: React.MouseEvent) => { const handleDelete = async (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
const confirm = prompt(t("confirmDelete", { name: deck.name })); const confirm = prompt(t("confirmDelete", { name: folder.name }));
if (confirm === deck.name) { if (confirm === folder.name) {
const result = await actionDeleteDeck({ deckId: deck.id }); const result = await actionDeleteFolderById(folder.id);
if (result.success) { if (result.success) {
onDeleteDeck(deck.id); onDeleteFolder(folder.id);
} else { } else {
toast.error(result.message); toast.error(result.message);
} }
@@ -85,31 +77,31 @@ const DeckCard = ({ deck, onUpdateDeck, onDeleteDeck }: DeckCardProps) => {
<div <div
className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors" className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() => { onClick={() => {
router.push(`/decks/${deck.id}`); router.push(`/folders/${folder.id}`);
}} }}
> >
<div className="flex items-center gap-4 flex-1"> <div className="flex items-center gap-4 flex-1">
<div className="shrink-0 text-primary-500"> <div className="shrink-0 text-primary-500">
<Layers size={24} /> <Fd size={24} />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <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"> <span className="flex items-center gap-1 text-xs text-gray-400">
{deck.visibility === "PUBLIC" ? ( {folder.visibility === "PUBLIC" ? (
<Globe size={12} /> <Globe size={12} />
) : ( ) : (
<Lock size={12} /> <Lock size={12} />
)} )}
{deck.visibility === "PUBLIC" ? t("public") : t("private")} {folder.visibility === "PUBLIC" ? t("public") : t("private")}
</span> </span>
</div> </div>
<p className="text-sm text-gray-500 mt-0.5"> <p className="text-sm text-gray-500 mt-0.5">
{t("deckInfo", { {t("folderInfo", {
id: deck.id, id: folder.id,
name: deck.name, name: folder.name,
totalCards: deck.cardCount ?? 0, totalPairs: folder.total,
})} })}
</p> </p>
</div> </div>
@@ -118,16 +110,16 @@ const DeckCard = ({ deck, onUpdateDeck, onDeleteDeck }: DeckCardProps) => {
<div className="flex items-center gap-1 ml-4"> <div className="flex items-center gap-1 ml-4">
<CircleButton <CircleButton
onClick={handleToggleVisibility} 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} /> <Lock size={18} />
) : ( ) : (
<Globe size={18} /> <Globe size={18} />
)} )}
</CircleButton> </CircleButton>
<CircleButton onClick={handleRename}> <CircleButton onClick={handleRename}>
<Pencil size={18} /> <FolderPen size={18} />
</CircleButton> </CircleButton>
<CircleButton <CircleButton
onClick={handleDelete} onClick={handleDelete}
@@ -141,49 +133,46 @@ const DeckCard = ({ deck, onUpdateDeck, onDeleteDeck }: DeckCardProps) => {
); );
}; };
interface DecksClientProps { interface FoldersClientProps {
userId: string; userId: string;
} }
export function DecksClient({ userId }: DecksClientProps) { export function FoldersClient({ userId }: FoldersClientProps) {
const t = useTranslations("decks"); const t = useTranslations("folders");
const router = useRouter(); const router = useRouter();
const [decks, setDecks] = useState<ActionOutputDeck[]>([]); const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const loadDecks = async () => { const loadFolders = async () => {
setLoading(true); setLoading(true);
const result = await actionGetDecksByUserId(userId); const result = await actionGetFoldersWithTotalPairsByUserId(userId);
if (result.success && result.data) { if (result.success && result.data) {
setDecks(result.data); setFolders(result.data);
} }
setLoading(false); setLoading(false);
}; };
useEffect(() => { useEffect(() => {
loadDecks(); loadFolders();
}, [userId]); }, [userId]);
const handleUpdateDeck = (deckId: number, updates: Partial<ActionOutputDeck>) => { const handleUpdateFolder = (folderId: number, updates: Partial<TSharedFolderWithTotalPairs>) => {
setDecks((prev) => setFolders((prev) =>
prev.map((d) => (d.id === deckId ? { ...d, ...updates } : d)) prev.map((f) => (f.id === folderId ? { ...f, ...updates } : f))
); );
}; };
const handleDeleteDeck = (deckId: number) => { const handleDeleteFolder = (folderId: number) => {
setDecks((prev) => prev.filter((d) => d.id !== deckId)); setFolders((prev) => prev.filter((f) => f.id !== folderId));
}; };
const handleCreateDeck = async () => { const handleCreateFolder = async () => {
const deckName = prompt(t("enterDeckName")); const folderName = prompt(t("enterFolderName"));
if (!deckName?.trim()) return; if (!folderName?.trim()) return;
const result = await actionCreateDeck({ name: deckName.trim() }); const result = await actionCreateFolder(userId, folderName.trim());
if (result.success && result.deckId) { if (result.success) {
const deckResult = await actionGetDeckById({ deckId: result.deckId }); loadFolders();
if (deckResult.success && deckResult.data) {
setDecks((prev) => [...prev, deckResult.data!]);
}
} else { } else {
toast.error(result.message); toast.error(result.message);
} }
@@ -193,33 +182,33 @@ export function DecksClient({ userId }: DecksClientProps) {
<PageLayout> <PageLayout>
<PageHeader title={t("title")} subtitle={t("subtitle")} /> <PageHeader title={t("title")} subtitle={t("subtitle")} />
<div className="mb-4 flex gap-2"> <div className="mb-4">
<LightButton onClick={handleCreateDeck}> <LightButton onClick={handleCreateFolder}>
<Plus size={18} /> <FolderPlus size={18} />
{t("newDeck")} {t("newFolder")}
</LightButton> </LightButton>
</div> </div>
<CardList> <CardList>
{loading ? ( {loading ? (
<VStack align="center" className="p-8"> <div className="p-8 text-center">
<Skeleton variant="circular" className="w-8 h-8 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> <p className="text-sm text-gray-500">{t("loading")}</p>
</VStack> </div>
) : decks.length === 0 ? ( ) : folders.length === 0 ? (
<div className="text-center py-12 text-gray-400"> <div className="text-center py-12 text-gray-400">
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center"> <div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
<Layers size={24} className="text-gray-400" /> <Fd size={24} className="text-gray-400" />
</div> </div>
<p className="text-sm">{t("noDecksYet")}</p> <p className="text-sm">{t("noFoldersYet")}</p>
</div> </div>
) : ( ) : (
decks.map((deck) => ( folders.map((folder) => (
<DeckCard <FolderCard
key={deck.id} key={folder.id}
deck={deck} folder={folder}
onUpdateDeck={handleUpdateDeck} onUpdateFolder={handleUpdateFolder}
onDeleteDeck={handleDeleteDeck} onDeleteFolder={handleDeleteFolder}
/> />
)) ))
)} )}

View 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>
);
}

View 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>
);
};

View 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>
);
}

View 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>
);
}

View 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} />;
}

View File

@@ -1,16 +1,16 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { DecksClient } from "./DecksClient"; import { FoldersClient } from "./FoldersClient";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export default async function DecksPage() { export default async function FoldersPage() {
const session = await auth.api.getSession( const session = await auth.api.getSession(
{ headers: await headers() } { headers: await headers() }
); );
if (!session) { if (!session) {
redirect("/login?redirect=/decks"); redirect("/login?redirect=/folders");
} }
return <DecksClient userId={session.user.id} />; return <FoldersClient userId={session.user.id} />;
} }

View File

@@ -99,18 +99,18 @@
* 定义全局 CSS 变量用于主题切换和动态样式 * 定义全局 CSS 变量用于主题切换和动态样式
*/ */
:root { :root {
/* 主题色 - 默认 Mist */ /* 主题色 - 默认 Teal */
--primary-50: #f7f8fa; --primary-50: #f0f9f8;
--primary-100: #eef1f5; --primary-100: #e0f2f0;
--primary-200: #dce2eb; --primary-200: #bce6e1;
--primary-300: #c4cdd9; --primary-300: #8dd4cc;
--primary-400: #a3b0c1; --primary-400: #5ec2b7;
--primary-500: #8594a8; --primary-500: #35786f;
--primary-600: #6b7a8d; --primary-600: #2a605b;
--primary-700: #596474; --primary-700: #1f4844;
--primary-800: #4b5360; --primary-800: #183835;
--primary-900: #414850; --primary-900: #122826;
--primary-950: #22262b; --primary-950: #0a1413;
/* 基础颜色 */ /* 基础颜色 */
--background: #ffffff; --background: #ffffff;
@@ -126,7 +126,7 @@
/* 边框 */ /* 边框 */
--border: #d1d5db; --border: #d1d5db;
--border-secondary: #e5e7eb; --border-secondary: #e5e7eb;
--border-focus: #8594a8; --border-focus: #35786f;
/* 圆角 - 更小的圆角 */ /* 圆角 - 更小的圆角 */
--radius-xs: 0.125rem; --radius-xs: 0.125rem;
@@ -144,7 +144,7 @@
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
--shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25); --shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
--shadow-primary: 0 4px 14px 0 rgba(133, 148, 168, 0.39); --shadow-primary: 0 4px 14px 0 rgba(53, 120, 111, 0.39);
/* 间距 */ /* 间距 */
--spacing-xs: 0.25rem; --spacing-xs: 0.25rem;
@@ -177,7 +177,7 @@ body {
height: 100%; height: 100%;
margin: 0; margin: 0;
padding: 0; padding: 0;
background: var(--primary-50); background: var(--background);
color: var(--foreground); color: var(--foreground);
font-family: var(--font-geist-sans), -apple-system, BlinkMacSystemFont, system-ui, sans-serif; font-family: var(--font-geist-sans), -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
font-size: 1rem; font-size: 1rem;

View File

@@ -73,7 +73,7 @@ export default async function HomePage() {
color="#dd7486" color="#dd7486"
></LinkArea> ></LinkArea>
<LinkArea <LinkArea
href="/decks" href="/memorize"
name={t("memorize.name")} name={t("memorize.name")}
description={t("memorize.description")} description={t("memorize.description")}
color="#cc9988" color="#cc9988"

View File

@@ -4,10 +4,6 @@ import { nextCookies } from "better-auth/next-js";
import { username } from "better-auth/plugins"; import { username } from "better-auth/plugins";
import { createAuthMiddleware, APIError } from "better-auth/api"; import { createAuthMiddleware, APIError } from "better-auth/api";
import { prisma } from "./lib/db"; import { prisma } from "./lib/db";
import { createLogger } from "./lib/logger";
const log = createLogger("auth");
import { import {
sendEmail, sendEmail,
generateVerificationEmailHtml, generateVerificationEmailHtml,
@@ -22,28 +18,21 @@ export const auth = betterAuth({
enabled: true, enabled: true,
requireEmailVerification: true, requireEmailVerification: true,
sendResetPassword: async ({ user, url }) => { sendResetPassword: async ({ user, url }) => {
const result = await sendEmail({ void sendEmail({
to: user.email, to: user.email,
subject: "重置您的密码 - Learn Languages", subject: "重置您的密码 - Learn Languages",
html: generateResetPasswordEmailHtml(url, user.name || "用户"), html: generateResetPasswordEmailHtml(url, user.name || "用户"),
}); });
if (!result.success) {
log.error("Failed to send reset password email", { error: result.error });
}
}, },
}, },
emailVerification: { emailVerification: {
sendOnSignUp: true, sendOnSignUp: true,
sendOnSignIn: true,
sendVerificationEmail: async ({ user, url }) => { sendVerificationEmail: async ({ user, url }) => {
const result = await sendEmail({ void sendEmail({
to: user.email, to: user.email,
subject: "验证您的邮箱 - Learn Languages", subject: "验证您的邮箱 - Learn Languages",
html: generateVerificationEmailHtml(url, user.name || "用户"), html: generateVerificationEmailHtml(url, user.name || "用户"),
}); });
if (!result.success) {
log.error("Failed to send verification email", { error: result.error });
}
}, },
}, },
socialProviders: { socialProviders: {
@@ -55,34 +44,13 @@ export const auth = betterAuth({
plugins: [nextCookies(), username()], plugins: [nextCookies(), username()],
hooks: { hooks: {
before: createAuthMiddleware(async (ctx) => { 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 };
const body = ctx.body as { username?: string }; if (!body.username || body.username.trim() === "") {
if (body.username) { throw new APIError("BAD_REQUEST", {
const user = await prisma.user.findFirst({ message: "Username is required",
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",
});
}
}
} }
}), }),
}, },

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef } from "react";
import { Languages } from "lucide-react"; import { Languages } from "lucide-react";
import { cn } from "@/utils/cn"; import { cn } from "@/utils/cn";
@@ -17,7 +17,6 @@ const languages = [
export function LanguageSettings() { export function LanguageSettings() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [pendingLocale, setPendingLocale] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@@ -47,16 +46,10 @@ export function LanguageSettings() {
} }
}, [isOpen]); }, [isOpen]);
useEffect(() => { const setLocale = async (locale: string) => {
if (pendingLocale) { document.cookie = `locale=${locale}`;
document.cookie = `locale=${pendingLocale}; path=/`; window.location.reload();
window.location.reload(); };
}
}, [pendingLocale]);
const setLocale = useCallback((locale: string) => {
setPendingLocale(locale);
}, []);
return ( return (
<div className="relative" ref={menuRef}> <div className="relative" ref={menuRef}>

View File

@@ -21,7 +21,7 @@ export async function Navbar() {
}); });
const mobileMenuItems: NavigationItem[] = [ 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} /> }, { label: t("explore"), href: "/explore", icon: <Compass size={18} /> },
...(session ? [{ label: t("favorites"), href: "/favorites", icon: <Heart size={18} /> }] : []), ...(session ? [{ label: t("favorites"), href: "/favorites", icon: <Heart size={18} /> }] : []),
{ label: t("sourceCode"), href: "https://github.com/GoddoNebianU/learn-languages", icon: <Github size={18} />, external: true }, { label: t("sourceCode"), href: "https://github.com/GoddoNebianU/learn-languages", icon: <Github size={18} />, external: true },
@@ -42,7 +42,7 @@ export async function Navbar() {
</GhostLightButton> </GhostLightButton>
<div className="flex gap-0.5 justify-center items-center"> <div className="flex gap-0.5 justify-center items-center">
<LanguageSettings /> <LanguageSettings />
<GhostLightButton href="/decks" className="md:block! hidden!" size="md"> <GhostLightButton href="/folders" className="md:block! hidden!" size="md">
{t("folders")} {t("folders")}
</GhostLightButton> </GhostLightButton>
<GhostLightButton href="/explore" className="md:block! hidden!" size="md"> <GhostLightButton href="/explore" className="md:block! hidden!" size="md">

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { createContext, useContext, useEffect, useState, useMemo } from "react"; import { createContext, useContext, useEffect, useState } from "react";
import { import {
THEME_PRESETS, THEME_PRESETS,
DEFAULT_THEME, DEFAULT_THEME,
@@ -20,33 +20,26 @@ const ThemeContext = createContext<ThemeContextType | null>(null);
const STORAGE_KEY = "theme-preset"; const STORAGE_KEY = "theme-preset";
function getInitialTheme(): string {
if (typeof window === "undefined") return DEFAULT_THEME;
const saved = localStorage.getItem(STORAGE_KEY);
return saved && getThemePreset(saved) ? saved : DEFAULT_THEME;
}
export function ThemeProvider({ children }: { children: React.ReactNode }) { export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [currentTheme, setCurrentTheme] = useState<string>(DEFAULT_THEME); const [currentTheme, setCurrentTheme] = useState<string>(DEFAULT_THEME);
const [hydrated, setHydrated] = useState(false); const [mounted, setMounted] = useState(false);
useEffect(() => { useEffect(() => {
const savedTheme = getInitialTheme(); setMounted(true);
if (savedTheme !== currentTheme) { const savedTheme = localStorage.getItem(STORAGE_KEY);
if (savedTheme && getThemePreset(savedTheme)) {
setCurrentTheme(savedTheme); setCurrentTheme(savedTheme);
} }
setHydrated(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!hydrated) return; if (!mounted) return;
const preset = getThemePreset(currentTheme); const preset = getThemePreset(currentTheme);
if (preset) { if (preset) {
applyThemeColors(preset); applyThemeColors(preset);
localStorage.setItem(STORAGE_KEY, currentTheme); localStorage.setItem(STORAGE_KEY, currentTheme);
} }
}, [currentTheme, hydrated]); }, [currentTheme, mounted]);
const setTheme = (themeId: string) => { const setTheme = (themeId: string) => {
if (getThemePreset(themeId)) { 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 ( return (
<ThemeContext.Provider <ThemeContext.Provider

View File

@@ -21,25 +21,7 @@ const COMMON_LANGUAGES = [
{ label: "portuguese", value: "portuguese" }, { label: "portuguese", value: "portuguese" },
{ label: "russian", value: "russian" }, { label: "russian", value: "russian" },
{ label: "other", value: "other" }, { label: "other", value: "other" },
] as const; ];
type LocaleLabel = typeof COMMON_LANGUAGES[number]["label"];
function getLocaleLabel(t: (key: string) => string, label: LocaleLabel): string {
switch (label) {
case "chinese": return t("translator.chinese");
case "english": return t("translator.english");
case "italian": return t("translator.italian");
case "japanese": return t("translator.japanese");
case "korean": return t("translator.korean");
case "french": return t("translator.french");
case "german": return t("translator.german");
case "spanish": return t("translator.spanish");
case "portuguese": return t("translator.portuguese");
case "russian": return t("translator.russian");
case "other": return t("translator.other");
}
}
interface LocaleSelectorProps { interface LocaleSelectorProps {
value: string; value: string;
@@ -80,7 +62,7 @@ export function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
> >
{COMMON_LANGUAGES.map((lang) => ( {COMMON_LANGUAGES.map((lang) => (
<option key={lang.value} value={lang.value}> <option key={lang.value} value={lang.value}>
{getLocaleLabel(t, lang.label)} {t(`translator.${lang.label}`)}
</option> </option>
))} ))}
</Select> </Select>

View File

@@ -14,31 +14,17 @@ export async function generateEntries(
const isWord = inputType === "word"; const isWord = inputType === "word";
const prompt = ` const prompt = `
你是专业词典编纂专家。为词条"${standardForm}"${queryLang}生成${definitionLang}释义。 生成词典条目。词语:"${standardForm}"${queryLang}。用${definitionLang}释义。
【核心要求】 返回 JSON
生成尽可能完整、全面的词典条目,包括: ${isWord ? `{"entries":[{"ipa":"音标","partOfSpeech":"词性","definition":"释义","example":"例句"}]}` : `{"entries":[{"definition":"释义","example":"例句"}]}`}
${isWord ? `- 所有常见词性(名词、动词、形容词、副词等)
- 每个词性下的所有常用义项
- 专业领域含义、口语含义、习语用法` : `- 所有常见含义和用法
- 字面义和引申义
- 不同语境下的解释`}
JSON格式】 只返回 JSON
${isWord ? `{"entries":[{"ipa":"国际音标","partOfSpeech":"词性","definition":"详细释义","example":"自然例句"}]}` : `{"entries":[{"definition":"详细释义","example":"自然例句"}]}`}
【质量标准】
- 条目数量:尽可能多,不要遗漏常用义项
- 释义:准确、完整、符合母语者习惯
- 例句:自然、地道、展示实际用法
- IPA使用标准国际音标单词/短语必填)
只返回JSON不要其他内容。
`.trim(); `.trim();
try { try {
const result = await getAnswer([ const result = await getAnswer([
{ role: "system", content: "专业词典编纂专家返回完整JSON词典数据。" }, { role: "system", content: "词典条目生成器,只返回 JSON。" },
{ role: "user", content: prompt }, { role: "user", content: prompt },
]).then(parseAIGeneratedJSON<EntriesGenerationResult>); ]).then(parseAIGeneratedJSON<EntriesGenerationResult>);
@@ -61,7 +47,6 @@ ${isWord ? `{"entries":[{"ipa":"国际音标","partOfSpeech":"词性","definitio
} }
} }
log.info("Generated dictionary entries", { count: result.entries.length });
return result; return result;
} catch (error) { } catch (error) {
log.error("Entries generation failed", { error: error instanceof Error ? error.message : String(error) }); log.error("Entries generation failed", { error: error instanceof Error ? error.message : String(error) });

View File

@@ -1,5 +1,12 @@
"use server"; "use server";
import OpenAI from "openai";
const openai = new OpenAI({
apiKey: process.env.ZHIPU_API_KEY,
baseURL: "https://open.bigmodel.cn/api/paas/v4",
});
type Messages = Array< type Messages = Array<
| { role: "system"; content: string } | { role: "system"; content: string }
| { role: "user"; content: string } | { role: "user"; content: string }
@@ -13,29 +20,13 @@ async function getAnswer(prompt: string | Messages): Promise<string> {
? [{ role: "user", content: prompt }] ? [{ role: "user", content: prompt }]
: prompt; : prompt;
const response = await fetch("https://open.bigmodel.cn/api/paas/v4/chat/completions", { const response = await openai.chat.completions.create({
method: "POST", model: process.env.ZHIPU_MODEL_NAME || "glm-4",
headers: { messages: messages as OpenAI.Chat.Completions.ChatCompletionMessageParam[],
"Content-Type": "application/json", temperature: 0.2,
"Authorization": `Bearer ${process.env.ZHIPU_API_KEY}`,
},
body: JSON.stringify({
model: process.env.ZHIPU_MODEL_NAME || "glm-4.6",
messages,
temperature: 0.2,
thinking: {
type: "disabled"
}
}),
}); });
if (!response.ok) { const content = response.choices[0]?.message?.content;
throw new Error(`AI API 请求失败: ${response.status}`);
}
const data = await response.json() as { choices?: Array<{ message?: { content?: string } }> };
const content = data.choices?.[0]?.message?.content;
if (!content) { if (!content) {
throw new Error("AI API 返回空响应"); throw new Error("AI API 返回空响应");
} }

View File

@@ -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}`);
}
}

View File

@@ -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 };

View File

@@ -168,12 +168,12 @@ export async function executeTranslation(
let targetIpa: string | undefined; let targetIpa: string | undefined;
if (needIpa) { if (needIpa) {
log.debug("[Stage 3] Generating IPA in parallel"); log.debug("[Stage 3] Generating IPA");
[sourceIpa, targetIpa] = await Promise.all([ sourceIpa = await generateIPA(sourceText, detectedLanguage);
generateIPA(sourceText, detectedLanguage), log.debug("[Stage 3] Source IPA", { sourceIpa });
generateIPA(translatedText, targetLanguage),
]); targetIpa = await generateIPA(translatedText, targetLanguage);
log.debug("[Stage 3] IPA complete", { sourceIpa, targetIpa }); log.debug("[Stage 3] Target IPA", { targetIpa });
} }
// Assemble final result // Assemble final result

View File

@@ -61,8 +61,3 @@ export type ActionOutputUserProfile = {
updatedAt: Date; updatedAt: Date;
}; };
}; };
export type ActionOutputDeleteAccount = {
success: boolean;
message: string;
};

View File

@@ -10,7 +10,6 @@ import {
ActionInputSignIn, ActionInputSignIn,
ActionInputSignUp, ActionInputSignUp,
ActionOutputAuth, ActionOutputAuth,
ActionOutputDeleteAccount,
ActionOutputUserProfile, ActionOutputUserProfile,
validateActionInputGetUserProfileByUsername, validateActionInputGetUserProfileByUsername,
validateActionInputSignIn, validateActionInputSignIn,
@@ -19,8 +18,7 @@ import {
import { import {
serviceGetUserProfileByUsername, serviceGetUserProfileByUsername,
serviceSignIn, serviceSignIn,
serviceSignUp, serviceSignUp
serviceDeleteAccount
} from "./auth-service"; } from "./auth-service";
// Re-export types for use in components // 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" };
}
}

View File

@@ -25,12 +25,3 @@ export type RepoInputFindUserById = {
export type RepoInputFindUserByEmail = { export type RepoInputFindUserByEmail = {
email: string; email: string;
}; };
// Delete user cascade types
export type RepoInputDeleteUserCascade = {
userId: string;
};
export type RepoOutputDeleteUserCascade = {
success: boolean;
};

View File

@@ -1,16 +1,11 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { createLogger } from "@/lib/logger";
import { import {
RepoInputFindUserByEmail, RepoInputFindUserByEmail,
RepoInputFindUserById, RepoInputFindUserById,
RepoInputFindUserByUsername, RepoInputFindUserByUsername,
RepoInputDeleteUserCascade, RepoOutputUserProfile
RepoOutputUserProfile,
RepoOutputDeleteUserCascade
} from "./auth-repository-dto"; } from "./auth-repository-dto";
const log = createLogger("auth-repository");
export async function repoFindUserByUsername(dto: RepoInputFindUserByUsername): Promise<RepoOutputUserProfile> { export async function repoFindUserByUsername(dto: RepoInputFindUserByUsername): Promise<RepoOutputUserProfile> {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { username: dto.username }, where: { username: dto.username },
@@ -67,48 +62,3 @@ export async function repoFindUserByEmail(dto: RepoInputFindUserByEmail): Promis
return user; 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 };
}

View File

@@ -38,11 +38,3 @@ export type ServiceOutputUserProfile = {
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} | null; } | null;
export type ServiceInputDeleteAccount = {
userId: string;
};
export type ServiceOutputDeleteAccount = {
success: boolean;
};

View File

@@ -1,18 +1,15 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { import {
repoFindUserByUsername, repoFindUserByUsername,
repoFindUserById, repoFindUserById
repoDeleteUserCascade
} from "./auth-repository"; } from "./auth-repository";
import { import {
ServiceInputGetUserProfileByUsername, ServiceInputGetUserProfileByUsername,
ServiceInputGetUserProfileById, ServiceInputGetUserProfileById,
ServiceInputSignIn, ServiceInputSignIn,
ServiceInputSignUp, ServiceInputSignUp,
ServiceInputDeleteAccount,
ServiceOutputAuth, ServiceOutputAuth,
ServiceOutputUserProfile, ServiceOutputUserProfile
ServiceOutputDeleteAccount
} from "./auth-service-dto"; } from "./auth-service-dto";
/** /**
@@ -95,7 +92,3 @@ export async function serviceGetUserProfileByUsername(dto: ServiceInputGetUserPr
export async function serviceGetUserProfileById(dto: ServiceInputGetUserProfileById): Promise<ServiceOutputUserProfile> { export async function serviceGetUserProfileById(dto: ServiceInputGetUserProfileById): Promise<ServiceOutputUserProfile> {
return await repoFindUserById(dto); return await repoFindUserById(dto);
} }
export async function serviceDeleteAccount(dto: ServiceInputDeleteAccount): Promise<ServiceOutputDeleteAccount> {
return await repoDeleteUserCascade({ userId: dto.userId });
}

View File

@@ -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;
}

View File

@@ -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: "发送重置邮件失败,请稍后重试",
};
}
}

View File

@@ -1,7 +0,0 @@
export type RepoInputFindUserByEmail = {
email: string;
};
export type RepoOutputFindUserByEmail = {
id: string;
} | null;

View File

@@ -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;
}

View File

@@ -1,8 +0,0 @@
export type ServiceInputRequestPasswordReset = {
email: string;
};
export type ServiceOutputRequestPasswordReset = {
success: boolean;
message: string;
};

View File

@@ -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: "重置密码邮件已发送,请检查您的邮箱",
};
}

View File

@@ -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);

View File

@@ -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" };
}
}

View File

@@ -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;
}

View File

@@ -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 };
}

View File

@@ -1,4 +0,0 @@
import type { RepoOutputCard, RepoOutputCardStats } from "./card-repository-dto";
export type ServiceOutputCard = RepoOutputCard;
export type ServiceOutputCardStats = RepoOutputCardStats;

View File

@@ -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;
}

View File

@@ -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[];
};

View File

@@ -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" };
}
}

View File

@@ -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;
};

View File

@@ -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,
}));
}

View File

@@ -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;
};

View File

@@ -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" };
}
}

View File

@@ -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 { generateValidator } from "@/utils/validate";
import z from "zod";
export const schemaActionLookUpDictionary = z.object({ const schemaActionInputLookUpDictionary = z.object({
text: z.string().min(1), text: z.string().min(LENGTH_MIN_DICTIONARY_TEXT).max(LENGTH_MAX_DICTIONARY_TEXT),
queryLang: z.string().min(1), queryLang: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE),
definitionLang: z.string().min(1), 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 type ActionInputLookUpDictionary = z.infer<typeof schemaActionInputLookUpDictionary>;
export const validateActionInputLookUpDictionary = generateValidator(schemaActionLookUpDictionary);
export const validateActionInputLookUpDictionary = generateValidator(schemaActionInputLookUpDictionary);
export type ActionOutputLookUpDictionary = { export type ActionOutputLookUpDictionary = {
success: boolean; message: string,
message: string; success: boolean;
data?: { data?: TSharedItem;
standardForm: string;
entries: Array<{
ipa?: string;
definition: string;
partOfSpeech?: string;
example: string;
}>;
};
}; };

View File

@@ -1,38 +1,30 @@
"use server"; "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 { createLogger } from "@/lib/logger";
import { LookUpError } from "@/lib/errors"; import { serviceLookUp } from "./dictionary-service";
import {
ActionInputLookUpDictionary,
ActionOutputLookUpDictionary,
validateActionInputLookUpDictionary,
} from "./dictionary-action-dto";
const log = createLogger("dictionary-action"); const log = createLogger("dictionary-action");
export async function actionLookUpDictionary( export const actionLookUpDictionary = async (dto: ActionInputLookUpDictionary): Promise<ActionOutputLookUpDictionary> => {
input: unknown, try {
): Promise<ActionOutputLookUpDictionary> { return {
try { message: 'success',
const validated = validateActionInputLookUpDictionary(input); success: true,
data: await serviceLookUp(validateActionInputLookUpDictionary(dto))
const result = await executeDictionaryLookup( };
validated.text, } catch (e) {
validated.queryLang, if (e instanceof ValidateError) {
validated.definitionLang return {
); success: false,
message: e.message
return { };
success: true, }
message: "Lookup successful", log.error("Dictionary lookup failed", { error: e instanceof Error ? e.message : String(e) });
data: result, return {
}; success: false,
} catch (e) { message: 'Unknown error occured.'
if (e instanceof LookUpError) { };
return { success: false, message: e.message };
} }
log.error("Dictionary lookup failed", { error: e instanceof Error ? e.message : String(e) }); };
return { success: false, message: "Lookup failed" };
}
}

View 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;
};

View 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;
});
}

View File

@@ -1,9 +1,11 @@
export type ServiceOutputLookUp = { import { TSharedItem } from "@/shared/dictionary-type";
standardForm: string;
entries: Array<{ export type ServiceInputLookUp = {
ipa?: string; text: string,
definition: string; queryLang: string,
partOfSpeech?: string; definitionLang: string,
example: string; forceRelook: boolean,
}>; userId?: string;
}; };
export type ServiceOutputLookUp = TSharedItem;

View 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
};
}
};

View 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[];
};

View 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.',
};
}
}

View 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;
};

View 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,
}));
}

View File

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

View File

View 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