Compare commits
18 Commits
main
...
7c71ffcf31
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c71ffcf31 | |||
| 4243cdc68b | |||
| cbb9326f84 | |||
| 49ad953add | |||
| f1eafa8015 | |||
| 12e502313b | |||
| 13e8f51ada | |||
| 7ba31a37bd | |||
| 4d4062985d | |||
| 804c28ada9 | |||
| e68e24a9fb | |||
| 8099320e00 | |||
| db0b0ff348 | |||
| 6f4b123a84 | |||
| 57ad1b8699 | |||
| 9b78fd5215 | |||
| 683a4104ec | |||
| abcae1b8d1 |
@@ -46,6 +46,15 @@
|
|||||||
"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"
|
||||||
|
},
|
||||||
"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",
|
||||||
@@ -157,6 +166,9 @@
|
|||||||
"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",
|
||||||
@@ -166,25 +178,58 @@
|
|||||||
"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": {
|
||||||
"folder_selector": {
|
"deck_selector": {
|
||||||
"selectFolder": "Wählen Sie einen Ordner",
|
"selectDeck": "Deck auswählen",
|
||||||
"noFolders": "Keine Ordner gefunden",
|
"noDecks": "Keine Decks gefunden",
|
||||||
"folderInfo": "{id}. {name} ({count})"
|
"goToDecks": "Zu Decks",
|
||||||
|
"noCards": "Keine Karten",
|
||||||
|
"new": "Neu",
|
||||||
|
"learning": "Lernen",
|
||||||
|
"review": "Wiederholen",
|
||||||
|
"due": "Fällig"
|
||||||
},
|
},
|
||||||
"memorize": {
|
"review": {
|
||||||
"answer": "Antwort",
|
"loading": "Laden...",
|
||||||
"next": "Weiter",
|
"backToDecks": "Zurück zu Decks",
|
||||||
"reverse": "Umkehren",
|
"allDone": "Fertig!",
|
||||||
"dictation": "Diktat",
|
"allDoneDesc": "Alle fälligen Karten wurden wiederholt.",
|
||||||
"noTextPairs": "Keine Textpaare verfügbar",
|
"reviewedCount": "{count} Karten wiederholt",
|
||||||
"disorder": "Mischen",
|
"progress": "{current} / {total}",
|
||||||
"previous": "Zurück"
|
"nextReview": "Nächste Wiederholung",
|
||||||
|
"interval": "Intervall",
|
||||||
|
"ease": "Leichtigkeit",
|
||||||
|
"lapses": "Verlernungen",
|
||||||
|
"showAnswer": "Antwort zeigen",
|
||||||
|
"again": "Nochmal",
|
||||||
|
"hard": "Schwer",
|
||||||
|
"good": "Gut",
|
||||||
|
"easy": "Leicht",
|
||||||
|
"now": "jetzt",
|
||||||
|
"lessThanMinute": "<1 Min",
|
||||||
|
"inMinutes": "{count} Min",
|
||||||
|
"inHours": "{count} Std",
|
||||||
|
"inDays": "{count} Tage",
|
||||||
|
"inMonths": "{count} Monate",
|
||||||
|
"minutes": "<1 Min",
|
||||||
|
"days": "{count} Tage",
|
||||||
|
"months": "{count} Monate",
|
||||||
|
"minAbbr": "m",
|
||||||
|
"dayAbbr": "T",
|
||||||
|
"cardTypeNew": "Neu",
|
||||||
|
"cardTypeLearning": "Lernen",
|
||||||
|
"cardTypeReview": "Wiederholung",
|
||||||
|
"cardTypeRelearning": "Neu lernen"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "Sie sind nicht berechtigt, auf diesen Ordner zuzugreifen"
|
"unauthorized": "Sie sind nicht berechtigt, auf dieses Deck zuzugreifen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navbar": {
|
"navbar": {
|
||||||
@@ -193,14 +238,49 @@
|
|||||||
"sign_in": "Anmelden",
|
"sign_in": "Anmelden",
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"folders": "Ordner",
|
"folders": "Ordner",
|
||||||
"explore": "Entdecken",
|
"explore": "Erkunden",
|
||||||
"favorites": "Favoriten"
|
"favorites": "Favoriten",
|
||||||
|
"settings": "Einstellungen"
|
||||||
|
},
|
||||||
|
"ocr": {
|
||||||
|
"title": "OCR Vokabel-Extraktion",
|
||||||
|
"description": "Laden Sie Screenshots von Vokabeltabellen aus Lehrbüchern hoch, um Wort-Definition-Paare zu extrahieren",
|
||||||
|
"uploadImage": "Bild hochladen",
|
||||||
|
"dragDropHint": "Ziehen Sie ein Bild hierher oder klicken Sie zum Auswählen",
|
||||||
|
"supportedFormats": "Unterstüt: JPG, PNG, WebP",
|
||||||
|
"selectDeck": "Deck auswählen",
|
||||||
|
"chooseDeck": "Wählen Sie einen Deck zum Speichern der extrahierten Paare",
|
||||||
|
"noDecks": "Keine Decks verfügbar. Bitte create a deck first.",
|
||||||
|
"languageHints": "Sprachhinweise (Optional)",
|
||||||
|
"sourceLanguageHint": "Quellsprache (z.B. Englisch)",
|
||||||
|
"targetLanguageHint": "Ziel-/Übersetzungssprache (z.B. Chinesisch)",
|
||||||
|
"process": "Bild verarbeiten",
|
||||||
|
"processing": "Verarbeitung...",
|
||||||
|
"preview": "Vorschau",
|
||||||
|
"extractedPairs": "Extrahierte Paare",
|
||||||
|
"word": "Wort",
|
||||||
|
"definition": "Definition",
|
||||||
|
"pairsCount": "{count} Paare extrahiert",
|
||||||
|
"savePairs": "In Deck speichern",
|
||||||
|
"saving": "Speichern...",
|
||||||
|
"saved": "{count} Paare erfolgreich in {deck} gespeichert",
|
||||||
|
"saveFailed": "Speichern fehlgeschlagen",
|
||||||
|
"noImage": "Bitte laden Sie zuerst ein Bild hoch",
|
||||||
|
"noDeck": "Bitte select a deck",
|
||||||
|
"processingFailed": "OCR-Verarbeitung fehlgeschlagen",
|
||||||
|
"tryAgain": "Bitte try again with a clearer image",
|
||||||
|
"detectedLanguages": "Erkannt: {source} → {target}"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"myProfile": "Mein Profil",
|
"myProfile": "Mein Profil",
|
||||||
"email": "E-Mail: {email}",
|
"email": "E-Mail: {email}",
|
||||||
"logout": "Abmelden"
|
"logout": "Abmelden"
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Einstellungen",
|
||||||
|
"themeColor": "Designfarbe",
|
||||||
|
"themeColorDescription": "Wählen Sie Ihre bevorzugte Designfarbe"
|
||||||
|
},
|
||||||
"srt_player": {
|
"srt_player": {
|
||||||
"uploadVideo": "Video hochladen",
|
"uploadVideo": "Video hochladen",
|
||||||
"uploadSubtitle": "Untertitel hochladen",
|
"uploadSubtitle": "Untertitel hochladen",
|
||||||
@@ -239,6 +319,8 @@
|
|||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"detectLanguage": "Sprache erkennen",
|
"detectLanguage": "Sprache erkennen",
|
||||||
|
"sourceLanguage": "Quellsprache",
|
||||||
|
"auto": "Automatisch",
|
||||||
"generateIPA": "IPA generieren",
|
"generateIPA": "IPA generieren",
|
||||||
"translateInto": "übersetzen in",
|
"translateInto": "übersetzen in",
|
||||||
"chinese": "Chinesisch",
|
"chinese": "Chinesisch",
|
||||||
@@ -347,11 +429,26 @@
|
|||||||
"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": "Ordner",
|
"title": "Decks",
|
||||||
"noFolders": "Noch keine Ordner",
|
"noFolders": "Noch keine Decks",
|
||||||
"folderName": "Ordnername",
|
"folderName": "Deckname",
|
||||||
"totalPairs": "Gesamtpaare",
|
"totalPairs": "Gesamtkarten",
|
||||||
"createdAt": "Erstellt am",
|
"createdAt": "Erstellt am",
|
||||||
"actions": "Aktionen",
|
"actions": "Aktionen",
|
||||||
"view": "Anzeigen"
|
"view": "Anzeigen"
|
||||||
|
|||||||
@@ -157,6 +157,9 @@
|
|||||||
"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",
|
||||||
@@ -166,25 +169,58 @@
|
|||||||
"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": {
|
||||||
"folder_selector": {
|
"deck_selector": {
|
||||||
"selectFolder": "Select a folder",
|
"selectDeck": "Select a deck",
|
||||||
"noFolders": "No folders found",
|
"noDecks": "No decks found",
|
||||||
"folderInfo": "{id}. {name} ({count})"
|
"goToDecks": "Go to Decks",
|
||||||
|
"noCards": "No cards",
|
||||||
|
"new": "New",
|
||||||
|
"learning": "Learning",
|
||||||
|
"review": "Review",
|
||||||
|
"due": "Due"
|
||||||
},
|
},
|
||||||
"memorize": {
|
"review": {
|
||||||
"answer": "Answer",
|
"loading": "Loading cards...",
|
||||||
"next": "Next",
|
"backToDecks": "Back to Decks",
|
||||||
"reverse": "Reverse",
|
"allDone": "All Done!",
|
||||||
"dictation": "Dictation",
|
"allDoneDesc": "You've reviewed all due cards.",
|
||||||
"noTextPairs": "No text pairs available",
|
"reviewedCount": "Reviewed {count} cards",
|
||||||
"disorder": "Disorder",
|
"progress": "{current} / {total}",
|
||||||
"previous": "Previous"
|
"nextReview": "Next review",
|
||||||
|
"interval": "Interval",
|
||||||
|
"ease": "Ease",
|
||||||
|
"lapses": "Lapses",
|
||||||
|
"showAnswer": "Show Answer",
|
||||||
|
"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"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "You are not authorized to access this folder"
|
"unauthorized": "You are not authorized to access this deck"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navbar": {
|
"navbar": {
|
||||||
@@ -194,13 +230,62 @@
|
|||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"folders": "Folders",
|
"folders": "Folders",
|
||||||
"explore": "Explore",
|
"explore": "Explore",
|
||||||
"favorites": "Favorites"
|
"favorites": "Favorites",
|
||||||
|
"settings": "Settings"
|
||||||
|
},
|
||||||
|
"ocr": {
|
||||||
|
"title": "OCR Vocabulary Extractor",
|
||||||
|
"description": "Upload vocabulary table screenshots from textbooks to extract word-definition pairs",
|
||||||
|
"uploadSection": "Upload Image",
|
||||||
|
"uploadImage": "Upload Image",
|
||||||
|
"dragDropHint": "Drag and drop an image here, or click to select",
|
||||||
|
"dropOrClick": "Drag and drop an image here, or click to select",
|
||||||
|
"changeImage": "Click to change image",
|
||||||
|
"supportedFormats": "Supports: JPG, PNG, WebP",
|
||||||
|
"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}",
|
||||||
|
"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}",
|
||||||
"logout": "Logout"
|
"logout": "Logout"
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Settings",
|
||||||
|
"themeColor": "Theme Color",
|
||||||
|
"themeColorDescription": "Choose your preferred theme color"
|
||||||
|
},
|
||||||
"srt_player": {
|
"srt_player": {
|
||||||
"uploadVideo": "Upload Video",
|
"uploadVideo": "Upload Video",
|
||||||
"uploadSubtitle": "Upload Subtitle",
|
"uploadSubtitle": "Upload Subtitle",
|
||||||
@@ -239,6 +324,8 @@
|
|||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"detectLanguage": "detect language",
|
"detectLanguage": "detect language",
|
||||||
|
"sourceLanguage": "source language",
|
||||||
|
"auto": "Auto",
|
||||||
"generateIPA": "generate ipa",
|
"generateIPA": "generate ipa",
|
||||||
"translateInto": "translate into",
|
"translateInto": "translate into",
|
||||||
"chinese": "Chinese",
|
"chinese": "Chinese",
|
||||||
@@ -301,11 +388,11 @@
|
|||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
"title": "Explore",
|
"title": "Explore",
|
||||||
"subtitle": "Discover public folders",
|
"subtitle": "Discover public decks",
|
||||||
"searchPlaceholder": "Search public folders...",
|
"searchPlaceholder": "Search public decks...",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"noFolders": "No public folders found",
|
"noDecks": "No public decks found",
|
||||||
"folderInfo": "{userName} • {totalPairs} pairs",
|
"deckInfo": "{userName} • {cardCount} cards",
|
||||||
"unknownUser": "Unknown User",
|
"unknownUser": "Unknown User",
|
||||||
"favorite": "Favorite",
|
"favorite": "Favorite",
|
||||||
"unfavorite": "Unfavorite",
|
"unfavorite": "Unfavorite",
|
||||||
@@ -314,10 +401,10 @@
|
|||||||
"sortByFavoritesActive": "Undo sort by favorites"
|
"sortByFavoritesActive": "Undo sort by favorites"
|
||||||
},
|
},
|
||||||
"exploreDetail": {
|
"exploreDetail": {
|
||||||
"title": "Folder Details",
|
"title": "Deck Details",
|
||||||
"createdBy": "Created by: {name}",
|
"createdBy": "Created by: {name}",
|
||||||
"unknownUser": "Unknown User",
|
"unknownUser": "Unknown User",
|
||||||
"totalPairs": "Total Pairs",
|
"totalCards": "Total Cards",
|
||||||
"favorites": "Favorites",
|
"favorites": "Favorites",
|
||||||
"createdAt": "Created At",
|
"createdAt": "Created At",
|
||||||
"viewContent": "View Content",
|
"viewContent": "View Content",
|
||||||
@@ -346,15 +433,94 @@
|
|||||||
"displayName": "Display Name",
|
"displayName": "Display Name",
|
||||||
"notSet": "Not Set",
|
"notSet": "Not Set",
|
||||||
"memberSince": "Member Since",
|
"memberSince": "Member Since",
|
||||||
|
"joined": "Joined",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"folders": {
|
"deleteAccount": {
|
||||||
"title": "Folders",
|
"button": "Delete Account",
|
||||||
"noFolders": "No folders yet",
|
"title": "Delete Account",
|
||||||
"folderName": "Folder Name",
|
"warning": "This action is irreversible. All your data will be permanently deleted.",
|
||||||
"totalPairs": "Total Pairs",
|
"warningDecks": "All your decks and cards",
|
||||||
|
"warningCards": "All your learning progress",
|
||||||
|
"warningHistory": "All your translation and dictionary history",
|
||||||
|
"warningPermanent": "This action cannot be undone",
|
||||||
|
"confirmLabel": "Type your username to confirm:",
|
||||||
|
"usernameMismatch": "Username does not match",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Delete My Account",
|
||||||
|
"success": "Account deleted successfully",
|
||||||
|
"failed": "Failed to delete account"
|
||||||
|
},
|
||||||
|
"decks": {
|
||||||
|
"title": "Decks",
|
||||||
|
"noDecks": "No decks yet",
|
||||||
|
"deckName": "Deck Name",
|
||||||
|
"totalCards": "Total Cards",
|
||||||
"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",
|
||||||
|
"enterDeckName": "Enter deck name:",
|
||||||
|
"confirmDelete": "Type \"{name}\" to delete:",
|
||||||
|
"noDecksYet": "No decks yet",
|
||||||
|
"deckInfo": "ID: {id} • {totalCards} cards",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"public": "Public",
|
||||||
|
"private": "Private",
|
||||||
|
"setPublic": "Set Public",
|
||||||
|
"setPrivate": "Set Private",
|
||||||
|
"enterNewName": "Enter new name:",
|
||||||
|
"importApkg": "Import APKG",
|
||||||
|
"exportApkg": "Export APKG",
|
||||||
|
"clickToUpload": "Click to upload .apkg file",
|
||||||
|
"apkgFilesOnly": "APKG files only",
|
||||||
|
"parsing": "Parsing file...",
|
||||||
|
"foundDecks": "Found {count} deck(s)",
|
||||||
|
"deckName": "Deck Name",
|
||||||
|
"back": "Back",
|
||||||
|
"import": "Import",
|
||||||
|
"importing": "Importing...",
|
||||||
|
"exportSuccess": "Deck exported successfully",
|
||||||
|
"goToDecks": "Go to Decks"
|
||||||
|
},
|
||||||
|
"decks": {
|
||||||
|
"title": "Decks",
|
||||||
|
"subtitle": "Manage your flashcard decks",
|
||||||
|
"newDeck": "New Deck",
|
||||||
|
"noDecksYet": "No decks yet",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"deckInfo": "ID: {id} • {totalCards} cards",
|
||||||
|
"enterDeckName": "Enter deck name:",
|
||||||
|
"enterNewName": "Enter new name:",
|
||||||
|
"confirmDelete": "Type \"{name}\" to delete:",
|
||||||
|
"public": "Public",
|
||||||
|
"private": "Private",
|
||||||
|
"setPublic": "Set Public",
|
||||||
|
"setPrivate": "Set Private",
|
||||||
|
"importApkg": "Import APKG",
|
||||||
|
"exportApkg": "Export APKG",
|
||||||
|
"clickToUpload": "Click to upload an APKG file",
|
||||||
|
"apkgFilesOnly": "Only .apkg files are supported",
|
||||||
|
"parsing": "Parsing...",
|
||||||
|
"foundDecks": "Found {count} deck(s)",
|
||||||
|
"deckName": "Deck Name",
|
||||||
|
"back": "Back",
|
||||||
|
"import": "Import",
|
||||||
|
"importing": "Importing...",
|
||||||
|
"exportSuccess": "Deck exported successfully",
|
||||||
|
"goToDecks": "Go to Decks"
|
||||||
|
},
|
||||||
|
"follow": {
|
||||||
|
"follow": "Follow",
|
||||||
|
"following": "Following",
|
||||||
|
"followers": "Followers",
|
||||||
|
"followersOf": "{username}'s Followers",
|
||||||
|
"followingOf": "{username}'s Following",
|
||||||
|
"noFollowers": "No followers yet",
|
||||||
|
"noFollowing": "Not following anyone yet"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,15 @@
|
|||||||
"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"
|
||||||
|
},
|
||||||
"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",
|
||||||
@@ -157,6 +166,9 @@
|
|||||||
"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",
|
||||||
@@ -166,41 +178,109 @@
|
|||||||
"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": {
|
||||||
"folder_selector": {
|
"deck_selector": {
|
||||||
"selectFolder": "Sélectionner un dossier",
|
"selectDeck": "Sélectionner un deck",
|
||||||
"noFolders": "Aucun dossier trouvé",
|
"noDecks": "Aucun deck trouvé",
|
||||||
"folderInfo": "{id}. {name} ({count})"
|
"goToDecks": "Aller aux decks",
|
||||||
|
"noCards": "Aucune carte",
|
||||||
|
"new": "Nouveau",
|
||||||
|
"learning": "Apprentissage",
|
||||||
|
"review": "Révision",
|
||||||
|
"due": "À faire"
|
||||||
},
|
},
|
||||||
"memorize": {
|
"review": {
|
||||||
"answer": "Réponse",
|
"loading": "Chargement...",
|
||||||
"next": "Suivant",
|
"backToDecks": "Retour aux decks",
|
||||||
"reverse": "Inverser",
|
"allDone": "Terminé !",
|
||||||
"dictation": "Dictée",
|
"allDoneDesc": "Vous avez révisé toutes les cartes dues.",
|
||||||
"noTextPairs": "Aucune paire de texte disponible",
|
"reviewedCount": "{count} cartes révisées",
|
||||||
"disorder": "Désordre",
|
"progress": "{current} / {total}",
|
||||||
"previous": "Précédent"
|
"nextReview": "Prochaine révision",
|
||||||
|
"interval": "Intervalle",
|
||||||
|
"ease": "Facilité",
|
||||||
|
"lapses": "Oublis",
|
||||||
|
"showAnswer": "Afficher la réponse",
|
||||||
|
"again": "Encore",
|
||||||
|
"hard": "Difficile",
|
||||||
|
"good": "Bien",
|
||||||
|
"easy": "Facile",
|
||||||
|
"now": "maintenant",
|
||||||
|
"lessThanMinute": "<1 min",
|
||||||
|
"inMinutes": "{count} min",
|
||||||
|
"inHours": "{count}h",
|
||||||
|
"inDays": "{count}j",
|
||||||
|
"inMonths": "{count}mois",
|
||||||
|
"minutes": "<1 min",
|
||||||
|
"days": "{count}j",
|
||||||
|
"months": "{count}mois",
|
||||||
|
"minAbbr": "m",
|
||||||
|
"dayAbbr": "j",
|
||||||
|
"cardTypeNew": "Nouveau",
|
||||||
|
"cardTypeLearning": "Apprentissage",
|
||||||
|
"cardTypeReview": "Révision",
|
||||||
|
"cardTypeRelearning": "Réapprentissage"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "Vous n'êtes pas autorisé à accéder à ce dossier"
|
"unauthorized": "Vous n'êtes pas autorisé à accéder à ce deck"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navbar": {
|
"navbar": {
|
||||||
"title": "apprendre-langues",
|
"title": "learn-languages",
|
||||||
"sourceCode": "GitHub",
|
"sourceCode": "GitHub",
|
||||||
"sign_in": "Se connecter",
|
"sign_in": "Connexion",
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"folders": "Dossiers",
|
"folders": "Dossiers",
|
||||||
"explore": "Explorer",
|
"explore": "Explorer",
|
||||||
"favorites": "Favoris"
|
"favorites": "Favoris",
|
||||||
|
"settings": "Paramètres"
|
||||||
|
},
|
||||||
|
"ocr": {
|
||||||
|
"title": "Extraction OCR de vocabulaire",
|
||||||
|
"description": "Téléchargez des captures d'écran de tableaux de vocabulaire pour extraire les paires mot-définition",
|
||||||
|
"uploadImage": "Télécharger une image",
|
||||||
|
"dragDropHint": "Glissez-déposez une image ici, ou cliquez pour sélectionner",
|
||||||
|
"supportedFormats": "Supportés : JPG, PNG, WebP",
|
||||||
|
"selectDeck": "Sélectionner un deck",
|
||||||
|
"chooseDeck": "Choisissez a deck to save the extracted pairs",
|
||||||
|
"noDecks": "Aucun deck disponible. Please create a deck first.",
|
||||||
|
"languageHints": "Indices de langue (Optionnel)",
|
||||||
|
"sourceLanguageHint": "Langue source (ex : Anglais)",
|
||||||
|
"targetLanguageHint": "Langue cible/traduction (ex : Chinois)",
|
||||||
|
"process": "Traiter l'image",
|
||||||
|
"processing": "Traitement...",
|
||||||
|
"preview": "Aperçu",
|
||||||
|
"extractedPairs": "Paires extraites",
|
||||||
|
"word": "Mot",
|
||||||
|
"definition": "Définition",
|
||||||
|
"pairsCount": "{count} paires extraites",
|
||||||
|
"savePairs": "Sauvegarder dans le deck",
|
||||||
|
"saving": "Sauvegarde...",
|
||||||
|
"saved": "{count} paires sauvegardées dans {deck}",
|
||||||
|
"saveFailed": "Échec de la sauvegarde",
|
||||||
|
"noImage": "Veuillez first upload an image",
|
||||||
|
"noDeck": "Please select a deck",
|
||||||
|
"processingFailed": "Échec du traitement OCR",
|
||||||
|
"tryAgain": "Please try again with a clearer image",
|
||||||
|
"detectedLanguages": "Détecté : {source} → {target}"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"myProfile": "Mon profil",
|
"myProfile": "Mon profil",
|
||||||
"email": "E-mail : {email}",
|
"email": "E-mail : {email}",
|
||||||
"logout": "Déconnexion"
|
"logout": "Déconnexion"
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Paramètres",
|
||||||
|
"themeColor": "Couleur du thème",
|
||||||
|
"themeColorDescription": "Choisissez votre couleur de thème préférée"
|
||||||
|
},
|
||||||
"srt_player": {
|
"srt_player": {
|
||||||
"uploadVideo": "Télécharger la vidéo",
|
"uploadVideo": "Télécharger la vidéo",
|
||||||
"uploadSubtitle": "Télécharger les sous-titres",
|
"uploadSubtitle": "Télécharger les sous-titres",
|
||||||
@@ -239,6 +319,8 @@
|
|||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"detectLanguage": "détecter la langue",
|
"detectLanguage": "détecter la langue",
|
||||||
|
"sourceLanguage": "langue source",
|
||||||
|
"auto": "Auto",
|
||||||
"generateIPA": "générer l'api",
|
"generateIPA": "générer l'api",
|
||||||
"translateInto": "traduire en",
|
"translateInto": "traduire en",
|
||||||
"chinese": "Chinois",
|
"chinese": "Chinois",
|
||||||
@@ -347,11 +429,26 @@
|
|||||||
"notSet": "Non défini",
|
"notSet": "Non défini",
|
||||||
"memberSince": "Membre depuis",
|
"memberSince": "Membre depuis",
|
||||||
"logout": "Déconnexion",
|
"logout": "Déconnexion",
|
||||||
"folders": {
|
"deleteAccount": {
|
||||||
"title": "Dossiers",
|
"button": "Supprimer le compte",
|
||||||
"noFolders": "Pas encore de dossiers",
|
"title": "Supprimer le compte",
|
||||||
"folderName": "Nom du dossier",
|
"warning": "Cette action est irréversible. Toutes vos données seront définitivement supprimées.",
|
||||||
"totalPairs": "Total des paires",
|
"warningDecks": "Tous vos decks et cartes",
|
||||||
|
"warningCards": "Tout votre progression d'apprentissage",
|
||||||
|
"warningHistory": "Tout votre historique de traduction et de dictionnaire",
|
||||||
|
"warningPermanent": "Cette action ne peut pas être annulée",
|
||||||
|
"confirmLabel": "Tapez votre nom d'utilisateur pour confirmer :",
|
||||||
|
"usernameMismatch": "Le nom d'utilisateur ne correspond pas",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"confirm": "Supprimer mon compte",
|
||||||
|
"success": "Compte supprimé avec succès",
|
||||||
|
"failed": "Échec de la suppression du compte"
|
||||||
|
},
|
||||||
|
"decks": {
|
||||||
|
"title": "Decks",
|
||||||
|
"noDecks": "Pas encore de decks",
|
||||||
|
"deckName": "Nom du deck",
|
||||||
|
"totalCards": "Total des cartes",
|
||||||
"createdAt": "Créé le",
|
"createdAt": "Créé le",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"view": "Voir"
|
"view": "Voir"
|
||||||
|
|||||||
@@ -46,6 +46,15 @@
|
|||||||
"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"
|
||||||
|
},
|
||||||
"folder_id": {
|
"folder_id": {
|
||||||
"unauthorized": "Non sei il proprietario di questa cartella",
|
"unauthorized": "Non sei il proprietario di questa cartella",
|
||||||
"back": "Indietro",
|
"back": "Indietro",
|
||||||
@@ -157,6 +166,9 @@
|
|||||||
"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",
|
||||||
@@ -166,41 +178,109 @@
|
|||||||
"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": {
|
||||||
"folder_selector": {
|
"deck_selector": {
|
||||||
"selectFolder": "Seleziona una cartella",
|
"selectDeck": "Seleziona un mazzo",
|
||||||
"noFolders": "Nessuna cartella trovata",
|
"noDecks": "Nessun mazzo trovato",
|
||||||
"folderInfo": "{id}. {name} ({count})"
|
"goToDecks": "Vai ai mazzi",
|
||||||
|
"noCards": "Nessuna carta",
|
||||||
|
"new": "Nuove",
|
||||||
|
"learning": "In apprendimento",
|
||||||
|
"review": "Ripasso",
|
||||||
|
"due": "In scadenza"
|
||||||
},
|
},
|
||||||
"memorize": {
|
"review": {
|
||||||
"answer": "Risposta",
|
"loading": "Caricamento...",
|
||||||
"next": "Successivo",
|
"backToDecks": "Torna ai mazzi",
|
||||||
"reverse": "Inverti",
|
"allDone": "Fatto!",
|
||||||
"dictation": "Dettatura",
|
"allDoneDesc": "Hai ripassato tutte le carte in scadenza.",
|
||||||
"noTextPairs": "Nessuna coppia di testo disponibile",
|
"reviewedCount": "{count} carte ripassate",
|
||||||
"disorder": "Disordina",
|
"progress": "{current} / {total}",
|
||||||
"previous": "Precedente"
|
"nextReview": "Prossima revisione",
|
||||||
|
"interval": "Intervallo",
|
||||||
|
"ease": "Facilità",
|
||||||
|
"lapses": "Dimenticanze",
|
||||||
|
"showAnswer": "Mostra risposta",
|
||||||
|
"again": "Ancora",
|
||||||
|
"hard": "Difficile",
|
||||||
|
"good": "Bene",
|
||||||
|
"easy": "Facile",
|
||||||
|
"now": "ora",
|
||||||
|
"lessThanMinute": "<1 min",
|
||||||
|
"inMinutes": "{count} min",
|
||||||
|
"inHours": "{count}h",
|
||||||
|
"inDays": "{count}g",
|
||||||
|
"inMonths": "{count}mesi",
|
||||||
|
"minutes": "<1 min",
|
||||||
|
"days": "{count}g",
|
||||||
|
"months": "{count}mesi",
|
||||||
|
"minAbbr": "m",
|
||||||
|
"dayAbbr": "g",
|
||||||
|
"cardTypeNew": "Nuovo",
|
||||||
|
"cardTypeLearning": "Apprendimento",
|
||||||
|
"cardTypeReview": "Ripasso",
|
||||||
|
"cardTypeRelearning": "Riapprendimento"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "Non sei autorizzato ad accedere a questa cartella"
|
"unauthorized": "Non sei autorizzato ad accedere a questo mazzo"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navbar": {
|
"navbar": {
|
||||||
"title": "impara-lingue",
|
"title": "learn-languages",
|
||||||
"sourceCode": "GitHub",
|
"sourceCode": "GitHub",
|
||||||
"sign_in": "Accedi",
|
"sign_in": "Accedi",
|
||||||
"profile": "Profilo",
|
"profile": "Profilo",
|
||||||
"folders": "Cartelle",
|
"folders": "Cartelle",
|
||||||
"explore": "Esplora",
|
"explore": "Esplora",
|
||||||
"favorites": "Preferiti"
|
"favorites": "Preferiti",
|
||||||
|
"settings": "Impostazioni"
|
||||||
|
},
|
||||||
|
"ocr": {
|
||||||
|
"title": "Estrazione vocaboli OCR",
|
||||||
|
"description": "Carica screenshot di tabelle di vocaboli per estrarre coppie parola-definizione",
|
||||||
|
"uploadImage": "Carica immagine",
|
||||||
|
"dragDropHint": "Trascina e rilascia un'immagine qui, o clicca per selezionare",
|
||||||
|
"supportedFormats": "Supportati: JPG, PNG, WebP",
|
||||||
|
"selectDeck": "Seleziona un mazzo",
|
||||||
|
"chooseDeck": "Scegli un mazzo per salvare le coppie estratte",
|
||||||
|
"noDecks": "Nessun mazzo disponibile. Creane prima un mazzo.",
|
||||||
|
"languageHints": "Suggerimenti lingua (Opzionale)",
|
||||||
|
"sourceLanguageHint": "Lingua sorgente (es: Inglese)",
|
||||||
|
"targetLanguageHint": "Lingua target/traduzione (es: Cinese)",
|
||||||
|
"process": "Elabora immagine",
|
||||||
|
"processing": "Elaborazione...",
|
||||||
|
"preview": "Anteprima",
|
||||||
|
"extractedPairs": "Coppie estratte",
|
||||||
|
"word": "Parola",
|
||||||
|
"definition": "Definizione",
|
||||||
|
"pairsCount": "{count} coppie estratte",
|
||||||
|
"savePairs": "Salva nel mazzo",
|
||||||
|
"saving": "Salvataggio...",
|
||||||
|
"saved": "{count} coppie salvate in {deck}",
|
||||||
|
"saveFailed": "Salvataggio fallito",
|
||||||
|
"noImage": "Carica prima un'immagine",
|
||||||
|
"noDeck": "Seleziona un mazzo",
|
||||||
|
"processingFailed": "Elaborazione OCR fallita",
|
||||||
|
"tryAgain": "Riprova con un'immagine più chiara",
|
||||||
|
"detectedLanguages": "Rilevato: {source} → {target}"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"myProfile": "Il Mio Profilo",
|
"myProfile": "Il Mio Profilo",
|
||||||
"email": "Email: {email}",
|
"email": "Email: {email}",
|
||||||
"logout": "Esci"
|
"logout": "Esci"
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Impostazioni",
|
||||||
|
"themeColor": "Colore del tema",
|
||||||
|
"themeColorDescription": "Scegli il tuo colore del tema preferito"
|
||||||
|
},
|
||||||
"srt_player": {
|
"srt_player": {
|
||||||
"uploadVideo": "Carica Video",
|
"uploadVideo": "Carica Video",
|
||||||
"uploadSubtitle": "Carica Sottotitoli",
|
"uploadSubtitle": "Carica Sottotitoli",
|
||||||
@@ -239,6 +319,8 @@
|
|||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"detectLanguage": "rileva lingua",
|
"detectLanguage": "rileva lingua",
|
||||||
|
"sourceLanguage": "lingua di origine",
|
||||||
|
"auto": "Auto",
|
||||||
"generateIPA": "genera ipa",
|
"generateIPA": "genera ipa",
|
||||||
"translateInto": "traduci in",
|
"translateInto": "traduci in",
|
||||||
"chinese": "Cinese",
|
"chinese": "Cinese",
|
||||||
@@ -347,11 +429,26 @@
|
|||||||
"notSet": "Non Impostato",
|
"notSet": "Non Impostato",
|
||||||
"memberSince": "Membro Dal",
|
"memberSince": "Membro Dal",
|
||||||
"logout": "Esci",
|
"logout": "Esci",
|
||||||
"folders": {
|
"deleteAccount": {
|
||||||
"title": "Cartelle",
|
"button": "Elimina Account",
|
||||||
"noFolders": "Nessuna cartella ancora",
|
"title": "Elimina Account",
|
||||||
"folderName": "Nome Cartella",
|
"warning": "Questa azione è irreversibile. Tutti i tuoi dati saranno eliminati definitivamente.",
|
||||||
"totalPairs": "Coppie Totali",
|
"warningDecks": "Tutti i tuoi mazzi e le tue carte",
|
||||||
|
"warningCards": "Tutto il tuo progresso di apprendimento",
|
||||||
|
"warningHistory": "Tutto il tuo cronologia di traduzione e dizionario",
|
||||||
|
"warningPermanent": "Questa azione non può essere annullata",
|
||||||
|
"confirmLabel": "Digita il tuo nome utente per confermare:",
|
||||||
|
"usernameMismatch": "Il nome utente non corrisponde",
|
||||||
|
"cancel": "Annulla",
|
||||||
|
"confirm": "Elimina il mio account",
|
||||||
|
"success": "Account eliminato con successo",
|
||||||
|
"failed": "Impossibile eliminare l'account"
|
||||||
|
},
|
||||||
|
"decks": {
|
||||||
|
"title": "Mazzi",
|
||||||
|
"noDecks": "Nessun mazzo ancora",
|
||||||
|
"deckName": "Nome Mazzo",
|
||||||
|
"totalCards": "Carte Totali",
|
||||||
"createdAt": "Creata Il",
|
"createdAt": "Creata Il",
|
||||||
"actions": "Azioni",
|
"actions": "Azioni",
|
||||||
"view": "Visualizza"
|
"view": "Visualizza"
|
||||||
|
|||||||
@@ -157,6 +157,9 @@
|
|||||||
"resetPasswordFailed": "リセットメールの送信に失敗しました",
|
"resetPasswordFailed": "リセットメールの送信に失敗しました",
|
||||||
"resetPasswordEmailSent": "リセットメールを送信しました",
|
"resetPasswordEmailSent": "リセットメールを送信しました",
|
||||||
"resetPasswordEmailSentHint": "パスワードリセット用のリンクをメールでお送りしました。受信トレイをご確認ください。",
|
"resetPasswordEmailSentHint": "パスワードリセット用のリンクをメールでお送りしました。受信トレイをご確認ください。",
|
||||||
|
"verifyYourEmail": "メールアドレスを確認",
|
||||||
|
"verificationEmailSent": "確認メールを送信しました",
|
||||||
|
"verificationEmailSentHint": "{email} に確認メールを送信しました。メール内のリンクをクリックしてアカウントを確認してください。",
|
||||||
"checkYourEmail": "メールをご確認ください",
|
"checkYourEmail": "メールをご確認ください",
|
||||||
"backToLogin": "ログインに戻る",
|
"backToLogin": "ログインに戻る",
|
||||||
"resetPassword": "パスワードをリセット",
|
"resetPassword": "パスワードをリセット",
|
||||||
@@ -166,25 +169,58 @@
|
|||||||
"requestNewToken": "新しいリセットリンクをリクエスト",
|
"requestNewToken": "新しいリセットリンクをリクエスト",
|
||||||
"resetPasswordSuccess": "パスワードのリセットに成功しました",
|
"resetPasswordSuccess": "パスワードのリセットに成功しました",
|
||||||
"resetPasswordSuccessTitle": "パスワードリセット完了",
|
"resetPasswordSuccessTitle": "パスワードリセット完了",
|
||||||
"resetPasswordSuccessHint": "パスワードが正常にリセットされました。新しいパスワードでログインできます。"
|
"resetPasswordSuccessHint": "パスワードが正常にリセットされました。新しいパスワードでログインできます。",
|
||||||
|
"emailNotVerified": "メールアドレスを確認してください",
|
||||||
|
"emailNotVerifiedHint": "メールアドレスが確認されていません。受信トレイをご確認いただくか、新しい確認メールをリクエストしてください。",
|
||||||
|
"resendVerification": "確認メールを再送信",
|
||||||
|
"resendSuccess": "確認メールを送信しました!受信トレイをご確認ください。",
|
||||||
|
"resendFailed": "確認メールの送信に失敗しました"
|
||||||
},
|
},
|
||||||
"memorize": {
|
"memorize": {
|
||||||
"folder_selector": {
|
"deck_selector": {
|
||||||
"selectFolder": "フォルダーを選択",
|
"selectDeck": "デッキを選択",
|
||||||
"noFolders": "フォルダーが見つかりません",
|
"noDecks": "デッキが見つかりません",
|
||||||
"folderInfo": "{id}. {name} ({count})"
|
"goToDecks": "デッキへ移動",
|
||||||
|
"noCards": "カードなし",
|
||||||
|
"new": "新規",
|
||||||
|
"learning": "学習中",
|
||||||
|
"review": "復習",
|
||||||
|
"due": "予定"
|
||||||
},
|
},
|
||||||
"memorize": {
|
"review": {
|
||||||
"answer": "答え",
|
"loading": "読み込み中...",
|
||||||
"next": "次へ",
|
"backToDecks": "デッキに戻る",
|
||||||
"reverse": "逆順",
|
"allDone": "完了!",
|
||||||
"dictation": "書き取り",
|
"allDoneDesc": "すべての復習カードが完了しました。",
|
||||||
"noTextPairs": "利用可能なテキストペアがありません",
|
"reviewedCount": "{count} 枚のカードを復習",
|
||||||
"disorder": "シャッフル",
|
"progress": "{current} / {total}",
|
||||||
"previous": "前へ"
|
"nextReview": "次の復習",
|
||||||
|
"interval": "間隔",
|
||||||
|
"ease": "易しさ",
|
||||||
|
"lapses": "忘回数",
|
||||||
|
"showAnswer": "答えを表示",
|
||||||
|
"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": "再学習"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "このフォルダーにアクセスする権限がありません"
|
"unauthorized": "このデッキにアクセスする権限がありません"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navbar": {
|
"navbar": {
|
||||||
@@ -194,13 +230,48 @@
|
|||||||
"profile": "プロフィール",
|
"profile": "プロフィール",
|
||||||
"folders": "フォルダー",
|
"folders": "フォルダー",
|
||||||
"explore": "探索",
|
"explore": "探索",
|
||||||
"favorites": "お気に入り"
|
"favorites": "お気に入り",
|
||||||
|
"settings": "設定"
|
||||||
|
},
|
||||||
|
"ocr": {
|
||||||
|
"title": "OCR語彙抽出",
|
||||||
|
"description": "教科書の語彙表のスクリーンショットをアップロードして単語と定義のペアを抽出",
|
||||||
|
"uploadImage": "画像をアップロード",
|
||||||
|
"dragDropHint": "ここに画像をドラッグ&ドロップ、またはクリックして選択",
|
||||||
|
"supportedFormats": "対応形式:JPG、PNG、WebP",
|
||||||
|
"selectDeck": "デッキを選択",
|
||||||
|
"chooseDeck": "抽出したペアを保存するデッキを選択",
|
||||||
|
"noDecks": "デッキがありません。まずデッキを作成してください。",
|
||||||
|
"languageHints": "言語ヒント(オプション)",
|
||||||
|
"sourceLanguageHint": "ソース言語(例:英語)",
|
||||||
|
"targetLanguageHint": "ターゲット/翻訳言語(例:中国語)",
|
||||||
|
"process": "画像を処理",
|
||||||
|
"processing": "処理中...",
|
||||||
|
"preview": "プレビュー",
|
||||||
|
"extractedPairs": "抽出されたペア",
|
||||||
|
"word": "単語",
|
||||||
|
"definition": "定義",
|
||||||
|
"pairsCount": "{count} ペアを抽出",
|
||||||
|
"savePairs": "デッキに保存",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"saved": "{count} ペアを {deck} に保存しました",
|
||||||
|
"saveFailed": "保存に失敗しました",
|
||||||
|
"noImage": "先に画像をアップロードしてください",
|
||||||
|
"noDeck": "デッキを選択してください",
|
||||||
|
"processingFailed": "OCR処理に失敗しました",
|
||||||
|
"tryAgain": "より鮮明な画像でお試しください",
|
||||||
|
"detectedLanguages": "検出:{source} → {target}"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"myProfile": "マイプロフィール",
|
"myProfile": "マイプロフィール",
|
||||||
"email": "メール: {email}",
|
"email": "メール: {email}",
|
||||||
"logout": "ログアウト"
|
"logout": "ログアウト"
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "設定",
|
||||||
|
"themeColor": "テーマカラー",
|
||||||
|
"themeColorDescription": "お好みのテーマカラーを選択してください"
|
||||||
|
},
|
||||||
"srt_player": {
|
"srt_player": {
|
||||||
"uploadVideo": "ビデオをアップロード",
|
"uploadVideo": "ビデオをアップロード",
|
||||||
"uploadSubtitle": "字幕をアップロード",
|
"uploadSubtitle": "字幕をアップロード",
|
||||||
@@ -239,6 +310,8 @@
|
|||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"detectLanguage": "言語を検出",
|
"detectLanguage": "言語を検出",
|
||||||
|
"sourceLanguage": "ソース言語",
|
||||||
|
"auto": "自動",
|
||||||
"generateIPA": "ipaを生成",
|
"generateIPA": "ipaを生成",
|
||||||
"translateInto": "翻訳先",
|
"translateInto": "翻訳先",
|
||||||
"chinese": "中国語",
|
"chinese": "中国語",
|
||||||
@@ -347,11 +420,26 @@
|
|||||||
"notSet": "未設定",
|
"notSet": "未設定",
|
||||||
"memberSince": "登録日",
|
"memberSince": "登録日",
|
||||||
"logout": "ログアウト",
|
"logout": "ログアウト",
|
||||||
"folders": {
|
"deleteAccount": {
|
||||||
"title": "フォルダー",
|
"button": "アカウント削除",
|
||||||
"noFolders": "まだフォルダーがありません",
|
"title": "アカウント削除",
|
||||||
"folderName": "フォルダー名",
|
"warning": "この操作は取り消せません。すべてのデータが完全に削除されます。",
|
||||||
"totalPairs": "合計ペア数",
|
"warningDecks": "すべてのデッキとカード",
|
||||||
|
"warningCards": "すべての学習履歴",
|
||||||
|
"warningHistory": "すべての翻訳と辞書の履歴",
|
||||||
|
"warningPermanent": "この操作は取り消せません",
|
||||||
|
"confirmLabel": "確認のためユーザー名を入力してください:",
|
||||||
|
"usernameMismatch": "ユーザー名が一致しません",
|
||||||
|
"cancel": "キャンセル",
|
||||||
|
"confirm": "アカウントを削除する",
|
||||||
|
"success": "アカウントが正常に削除されました",
|
||||||
|
"failed": "アカウントの削除に失敗しました"
|
||||||
|
},
|
||||||
|
"decks": {
|
||||||
|
"title": "デッキ",
|
||||||
|
"noDecks": "まだデッキがありません",
|
||||||
|
"deckName": "デッキ名",
|
||||||
|
"totalCards": "合計カード数",
|
||||||
"createdAt": "作成日",
|
"createdAt": "作成日",
|
||||||
"actions": "アクション",
|
"actions": "アクション",
|
||||||
"view": "表示"
|
"view": "表示"
|
||||||
|
|||||||
@@ -46,6 +46,15 @@
|
|||||||
"unfavorite": "즐겨찾기 해제",
|
"unfavorite": "즐겨찾기 해제",
|
||||||
"pleaseLogin": "먼저 로그인해주세요"
|
"pleaseLogin": "먼저 로그인해주세요"
|
||||||
},
|
},
|
||||||
|
"decks": {
|
||||||
|
"title": "덱",
|
||||||
|
"noDecks": "덱이 없습니다",
|
||||||
|
"deckName": "덱 이름",
|
||||||
|
"totalCards": "총 카드",
|
||||||
|
"createdAt": "생성일",
|
||||||
|
"actions": "작업",
|
||||||
|
"view": "보기"
|
||||||
|
},
|
||||||
"folder_id": {
|
"folder_id": {
|
||||||
"unauthorized": "이 폴더의 소유자가 아닙니다",
|
"unauthorized": "이 폴더의 소유자가 아닙니다",
|
||||||
"back": "뒤로",
|
"back": "뒤로",
|
||||||
@@ -157,6 +166,9 @@
|
|||||||
"resetPasswordFailed": "재설정 이메일 전송 실패",
|
"resetPasswordFailed": "재설정 이메일 전송 실패",
|
||||||
"resetPasswordEmailSent": "재설정 이메일이 전송되었습니다",
|
"resetPasswordEmailSent": "재설정 이메일이 전송되었습니다",
|
||||||
"resetPasswordEmailSentHint": "비밀번호 재설정 링크를 이메일로 보냈습니다. 받은 편지함을 확인해주세요.",
|
"resetPasswordEmailSentHint": "비밀번호 재설정 링크를 이메일로 보냈습니다. 받은 편지함을 확인해주세요.",
|
||||||
|
"verifyYourEmail": "이메일 인증",
|
||||||
|
"verificationEmailSent": "인증 이메일이 전송되었습니다",
|
||||||
|
"verificationEmailSentHint": "{email}로 인증 이메일을 보냈습니다. 이메일의 링크를 클릭하여 계정을 인증해주세요.",
|
||||||
"checkYourEmail": "이메일을 확인하세요",
|
"checkYourEmail": "이메일을 확인하세요",
|
||||||
"backToLogin": "로그인으로 돌아가기",
|
"backToLogin": "로그인으로 돌아가기",
|
||||||
"resetPassword": "비밀번호 재설정",
|
"resetPassword": "비밀번호 재설정",
|
||||||
@@ -166,25 +178,58 @@
|
|||||||
"requestNewToken": "새 재설정 링크 요청",
|
"requestNewToken": "새 재설정 링크 요청",
|
||||||
"resetPasswordSuccess": "비밀번호 재설정 성공",
|
"resetPasswordSuccess": "비밀번호 재설정 성공",
|
||||||
"resetPasswordSuccessTitle": "비밀번호 재설정 완료",
|
"resetPasswordSuccessTitle": "비밀번호 재설정 완료",
|
||||||
"resetPasswordSuccessHint": "비밀번호가 성공적으로 재설정되었습니다. 새 비밀번호로 로그인할 수 있습니다."
|
"resetPasswordSuccessHint": "비밀번호가 성공적으로 재설정되었습니다. 새 비밀번호로 로그인할 수 있습니다.",
|
||||||
|
"emailNotVerified": "이메일 주소를 인증해 주세요",
|
||||||
|
"emailNotVerifiedHint": "이메일이 인증되지 않았습니다. 받은 편지함을 확인하거나 새 인증 이메일을 요청해 주세요.",
|
||||||
|
"resendVerification": "인증 이메일 다시 보내기",
|
||||||
|
"resendSuccess": "인증 이메일이 발송되었습니다! 받은 편지함을 확인해 주세요.",
|
||||||
|
"resendFailed": "인증 이메일 발송에 실패했습니다"
|
||||||
},
|
},
|
||||||
"memorize": {
|
"memorize": {
|
||||||
"folder_selector": {
|
"deck_selector": {
|
||||||
"selectFolder": "폴더 선택",
|
"selectDeck": "덱 선택",
|
||||||
"noFolders": "폴더를 찾을 수 없습니다",
|
"noDecks": "덱을 찾을 수 없습니다",
|
||||||
"folderInfo": "{id}. {name} ({count})"
|
"goToDecks": "덱으로 이동",
|
||||||
|
"noCards": "카드 없음",
|
||||||
|
"new": "새 카드",
|
||||||
|
"learning": "학습 중",
|
||||||
|
"review": "복습",
|
||||||
|
"due": "예정"
|
||||||
},
|
},
|
||||||
"memorize": {
|
"review": {
|
||||||
"answer": "정답",
|
"loading": "로딩 중...",
|
||||||
"next": "다음",
|
"backToDecks": "덱으로 돌아가기",
|
||||||
"reverse": "반대",
|
"allDone": "완료!",
|
||||||
"dictation": "받아쓰기",
|
"allDoneDesc": "모든 복습 카드를 완료했습니다.",
|
||||||
"noTextPairs": "사용 가능한 텍스트 쌍이 없습니다",
|
"reviewedCount": "{count}장의 카드 복습함",
|
||||||
"disorder": "무작위",
|
"progress": "{current} / {total}",
|
||||||
"previous": "이전"
|
"nextReview": "다음 복습",
|
||||||
|
"interval": "간격",
|
||||||
|
"ease": "난이도",
|
||||||
|
"lapses": "망각 횟수",
|
||||||
|
"showAnswer": "정답 보기",
|
||||||
|
"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": "재학습 중"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "이 폴더에 접근할 권한이 없습니다"
|
"unauthorized": "이 덱에 접근할 권한이 없습니다"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navbar": {
|
"navbar": {
|
||||||
@@ -194,13 +239,48 @@
|
|||||||
"profile": "프로필",
|
"profile": "프로필",
|
||||||
"folders": "폴더",
|
"folders": "폴더",
|
||||||
"explore": "탐색",
|
"explore": "탐색",
|
||||||
"favorites": "즐겨찾기"
|
"favorites": "즐겨찾기",
|
||||||
|
"settings": "설정"
|
||||||
|
},
|
||||||
|
"ocr": {
|
||||||
|
"title": "OCR 어휘 추출",
|
||||||
|
"description": "교과서 어휘표 스크린샷 only어업로드하여 단어-정의 쌍 추출",
|
||||||
|
"uploadImage": "이미지 업로드",
|
||||||
|
"dragDropHint": "이미지를 여기에 끌어다 놓거나 클릭하여 선택",
|
||||||
|
"supportedFormats": "지원 형식: JPG, PNG, WebP",
|
||||||
|
"selectDeck": "덱 선택",
|
||||||
|
"chooseDeck": "추출된 쌍을 저장할 덱 선택",
|
||||||
|
"noDecks": "덱이 없습니다. 먼저 덱을 만드세요.",
|
||||||
|
"languageHints": "언어 힌트 (선택사항)",
|
||||||
|
"sourceLanguageHint": "소스 언어 (예: 영어)",
|
||||||
|
"targetLanguageHint": "대상/번역 언어 (예: 중국어)",
|
||||||
|
"process": "이미지 처리",
|
||||||
|
"processing": "처리中...",
|
||||||
|
"preview": "미리보기",
|
||||||
|
"extractedPairs": "추출된 쌍",
|
||||||
|
"word": "단어",
|
||||||
|
"definition": "정의",
|
||||||
|
"pairsCount": "{count} 쌍 추출됨",
|
||||||
|
"savePairs": "덱에 저장",
|
||||||
|
"saving": "저장中...",
|
||||||
|
"saved": "{deck}에 {count} 쌍 저장 완료",
|
||||||
|
"saveFailed": "저장 실패",
|
||||||
|
"noImage": "먼저 이미지를 업로드하세요",
|
||||||
|
"noDeck": "덱을 선택하세요",
|
||||||
|
"processingFailed": "OCR 처리 실패",
|
||||||
|
"tryAgain": "더 선晰的图像로 다시 시도하세요",
|
||||||
|
"detectedLanguages": "감지됨: {source} → {target}"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"myProfile": "내 프로필",
|
"myProfile": "내 프로필",
|
||||||
"email": "이메일: {email}",
|
"email": "이메일: {email}",
|
||||||
"logout": "로그아웃"
|
"logout": "로그아웃"
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "설정",
|
||||||
|
"themeColor": "테마 색상",
|
||||||
|
"themeColorDescription": "원하는 테마 색상을 선택하세요"
|
||||||
|
},
|
||||||
"srt_player": {
|
"srt_player": {
|
||||||
"uploadVideo": "비디오 업로드",
|
"uploadVideo": "비디오 업로드",
|
||||||
"uploadSubtitle": "자막 업로드",
|
"uploadSubtitle": "자막 업로드",
|
||||||
@@ -239,6 +319,8 @@
|
|||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"detectLanguage": "언어 감지",
|
"detectLanguage": "언어 감지",
|
||||||
|
"sourceLanguage": "원본 언어",
|
||||||
|
"auto": "자동",
|
||||||
"generateIPA": "IPA 생성",
|
"generateIPA": "IPA 생성",
|
||||||
"translateInto": "번역할 언어",
|
"translateInto": "번역할 언어",
|
||||||
"chinese": "중국어",
|
"chinese": "중국어",
|
||||||
@@ -347,11 +429,26 @@
|
|||||||
"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": "생성일",
|
"createdAt": "생성일",
|
||||||
"actions": "작업",
|
"actions": "작업",
|
||||||
"view": "보기"
|
"view": "보기"
|
||||||
|
|||||||
@@ -46,6 +46,15 @@
|
|||||||
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
|
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
|
||||||
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ"
|
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ"
|
||||||
},
|
},
|
||||||
|
"decks": {
|
||||||
|
"title": "دېكلار",
|
||||||
|
"noDecks": "تېخى دېك يوق",
|
||||||
|
"deckName": "دېك ئاتى",
|
||||||
|
"totalCards": "جەمئىي كارتا",
|
||||||
|
"createdAt": "قۇرۇلغان ۋاقتى",
|
||||||
|
"actions": "مەشغۇلاتلار",
|
||||||
|
"view": "كۆرۈش"
|
||||||
|
},
|
||||||
"folder_id": {
|
"folder_id": {
|
||||||
"unauthorized": "بۇ قىسقۇچنىڭ ئىگىسى ئەمەسسىز",
|
"unauthorized": "بۇ قىسقۇچنىڭ ئىگىسى ئەمەسسىز",
|
||||||
"back": "قايتىش",
|
"back": "قايتىش",
|
||||||
@@ -157,6 +166,9 @@
|
|||||||
"resetPasswordFailed": "ئەسلىگە قايتۇرۇش ئېلخېتى ئەۋەتىش مەغلۇپ بولدى",
|
"resetPasswordFailed": "ئەسلىگە قايتۇرۇش ئېلخېتى ئەۋەتىش مەغلۇپ بولدى",
|
||||||
"resetPasswordEmailSent": "ئەسلىگە قايتۇرۇش ئېلخېتى مۇۋەپپەقىيەتلىك ئەۋەتىلدى",
|
"resetPasswordEmailSent": "ئەسلىگە قايتۇرۇش ئېلخېتى مۇۋەپپەقىيەتلىك ئەۋەتىلدى",
|
||||||
"resetPasswordEmailSentHint": "پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسىنى ئېلخەت ئادرېسىڭىزغا ئەۋەتتۇق. ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ.",
|
"resetPasswordEmailSentHint": "پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسىنى ئېلخەت ئادرېسىڭىزغا ئەۋەتتۇق. ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ.",
|
||||||
|
"verifyYourEmail": "ئېلخەتنى دەلىللەش",
|
||||||
|
"verificationEmailSent": "دەلىللەش ئېلخېتى ئەۋەتىلدى",
|
||||||
|
"verificationEmailSentHint": "{email} غا دەلىللەش ئېلخېتى ئەۋەتتۇق. ئېلخەتتىكى ئۇلانمىنى چېكىپ ھېساباتىڭىزنى دەلىللەڭ.",
|
||||||
"checkYourEmail": "ئېلخېتىڭىزنى تەكشۈرۈڭ",
|
"checkYourEmail": "ئېلخېتىڭىزنى تەكشۈرۈڭ",
|
||||||
"backToLogin": "كىرىشكە قايتىش",
|
"backToLogin": "كىرىشكە قايتىش",
|
||||||
"resetPassword": "پارولنى ئەسلىگە قايتۇرۇش",
|
"resetPassword": "پارولنى ئەسلىگە قايتۇرۇش",
|
||||||
@@ -166,41 +178,109 @@
|
|||||||
"requestNewToken": "يېڭى ئەسلىگە قايتۇرۇش ئۇلانمىسى سوراش",
|
"requestNewToken": "يېڭى ئەسلىگە قايتۇرۇش ئۇلانمىسى سوراش",
|
||||||
"resetPasswordSuccess": "پارول مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى",
|
"resetPasswordSuccess": "پارول مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى",
|
||||||
"resetPasswordSuccessTitle": "پارول ئەسلىگە قايتۇرۇش تاماملاندى",
|
"resetPasswordSuccessTitle": "پارول ئەسلىگە قايتۇرۇش تاماملاندى",
|
||||||
"resetPasswordSuccessHint": "پارولىڭىز مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى. يېڭى پارول بىلەن كىرسىڭىز بولىدۇ."
|
"resetPasswordSuccessHint": "پارولىڭىز مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى. يېڭى پارول بىلەن كىرسىڭىز بولىدۇ.",
|
||||||
|
"emailNotVerified": "ئېلخەت ئادرېسىڭىزنى دەلىللەڭ",
|
||||||
|
"emailNotVerifiedHint": "ئېلخەت ئادرېسىڭىز دەلىللەنمىگەن. ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ ياكى يېڭى دەلىللەش ئېلخېتى سوراڭ.",
|
||||||
|
"resendVerification": "دەلىللەش ئېلخېتىنى قايتا ئەۋەتىش",
|
||||||
|
"resendSuccess": "دەلىللەش ئېلخېتى ئەۋەتىلدى! ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ.",
|
||||||
|
"resendFailed": "دەلىللەش ئېلخېتى ئەۋەتىش مەغلۇپ بولدى"
|
||||||
},
|
},
|
||||||
"memorize": {
|
"memorize": {
|
||||||
"folder_selector": {
|
"deck_selector": {
|
||||||
"selectFolder": "بىر قىسقۇچ تاللاڭ",
|
"selectDeck": "بىر دېك تاللاڭ",
|
||||||
"noFolders": "قىسقۇچ تېپىلمىدى",
|
"noDecks": "دېك تېپىلمىدى",
|
||||||
"folderInfo": "{id}. {name} ({count})"
|
"goToDecks": "دېكلارغا بېرىڭ",
|
||||||
|
"noCards": "كارتا يوق",
|
||||||
|
"new": "يېڭى",
|
||||||
|
"learning": "ئۆگىنىۋاتىدۇ",
|
||||||
|
"review": "تەكرار",
|
||||||
|
"due": "ۋاقتى كەلدى"
|
||||||
},
|
},
|
||||||
"memorize": {
|
"review": {
|
||||||
"answer": "جاۋاب",
|
"loading": "يۈكلىنىۋاتىدۇ...",
|
||||||
"next": "كېيىنكى",
|
"backToDecks": "دېكلارغا قايتىڭ",
|
||||||
"reverse": "تەتۈر",
|
"allDone": "تامام!",
|
||||||
"dictation": "دىكتات",
|
"allDoneDesc": "بارلىق تەكرارلاش كارتلىرى تاماملاندى.",
|
||||||
"noTextPairs": "تېكىست جۈپى يوق",
|
"reviewedCount": "{count} كارتا تەكرارلاندى",
|
||||||
"disorder": "قالايمىقانلاشتۇرۇش",
|
"progress": "{current} / {total}",
|
||||||
"previous": "ئالدىنقى"
|
"nextReview": "كېيىنكى تەكرار",
|
||||||
|
"interval": "ئارىلىق",
|
||||||
|
"ease": "ئاسانلىق",
|
||||||
|
"lapses": "ئۇنتۇش سانى",
|
||||||
|
"showAnswer": "جاۋابنى كۆرسەت",
|
||||||
|
"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": "قايتا ئۆگىنىش"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "بۇ قىسقۇچنى زىيارەت قىلىش ھوقۇقىڭىز يوق"
|
"unauthorized": "بۇ دېكنى زىيارەت قىلىش ھوقۇقىڭىز يوق"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navbar": {
|
"navbar": {
|
||||||
"title": "تىل-ئۆگىنىش",
|
"title": "learn-languages",
|
||||||
"sourceCode": "GitHub",
|
"sourceCode": "GitHub",
|
||||||
"sign_in": "كىرىش",
|
"sign_in": "كىرىش",
|
||||||
"profile": "شەخسىي ئۇچۇر",
|
"profile": "شەخسىي ئۇچۇر",
|
||||||
"folders": "قىسقۇچلار",
|
"folders": "قىسقۇچلار",
|
||||||
"explore": "ئىزدىنىش",
|
"explore": "ئىزدىنىش",
|
||||||
"favorites": "يىغىپ ساقلانغانلار"
|
"favorites": "يىغىپ ساقلاش",
|
||||||
|
"settings": "تەڭشەكلەر"
|
||||||
|
},
|
||||||
|
"ocr": {
|
||||||
|
"title": "OCR سۆز ئاستىرىش",
|
||||||
|
"description": "دەرىسلىك كىتابىدىكى سۆز جەدۋىلى سۈرەتلىرىنى يۈكلەپ سۆز-مەنا جۈپلىرىنى ئاستىرىڭ",
|
||||||
|
"uploadImage": "سۈرەت يۈكلەش",
|
||||||
|
"dragDropHint": "سۈرەتنى بۇ يەرگە سۆرەڭ ياكى چېكىپ تاللاڭ",
|
||||||
|
"supportedFormats": "قوللايدىغان فورماتلار: JPG، PNG، WebP",
|
||||||
|
"selectDeck": "دېك تاللاش",
|
||||||
|
"chooseDeck": "ئاستىرىلغان جۈپلەرنى ساقلاش ئۈچۈن دېك تاللاڭ",
|
||||||
|
"noDecks": "دېك يوق. ئاۋۋال دېك قۇرۇڭ.",
|
||||||
|
"languageHints": "تىل ئۇچۇرلىرى (ئىختىيارىي)",
|
||||||
|
"sourceLanguageHint": "مەنبە تىلى (مىسال: ئىنگىلىزچە)",
|
||||||
|
"targetLanguageHint": "نىشان/تەرجىمە تىلى (مىسال: خەنزۇچە)",
|
||||||
|
"process": "سۈرەتنى بىر تەرەپ قىلىش",
|
||||||
|
"processing": "بىر تەرەپ قىلىۋاتىدۇ...",
|
||||||
|
"preview": "ئالدىن كۆرۈش",
|
||||||
|
"extractedPairs": "ئاستىرىلغان جۈپلەر",
|
||||||
|
"word": "سۆز",
|
||||||
|
"definition": "مەنا",
|
||||||
|
"pairsCount": "{count} جۈپ ئاستىرىلدى",
|
||||||
|
"savePairs": "دېككە ساقلاش",
|
||||||
|
"saving": "ساقلاۋاتىدۇ...",
|
||||||
|
"saved": "{deck} غا {count} جۈپ ساقلاندى",
|
||||||
|
"saveFailed": "ساقلاش مەغلۇپ بولدى",
|
||||||
|
"noImage": "ئاۋۋال سۈرەت يۈكلەڭ",
|
||||||
|
"noDeck": "دېك تاللاڭ",
|
||||||
|
"processingFailed": "OCR بىر تەرەپ قىلىش مەغلۇپ بولدى",
|
||||||
|
"tryAgain": "تېخىمۇ ئېنىق سۈرەت بىلەن قايتا سىناڭ",
|
||||||
|
"detectedLanguages": "بايقالدى: {source} → {target}"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"myProfile": "شەخسىي ئۇچۇرۇم",
|
"myProfile": "شەخسىي ئۇچۇرۇم",
|
||||||
"email": "ئېلخەت: {email}",
|
"email": "ئېلخەت: {email}",
|
||||||
"logout": "چىكىنىش"
|
"logout": "چىكىنىش"
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "تەڭشەكلەر",
|
||||||
|
"themeColor": "تېما رەڭگى",
|
||||||
|
"themeColorDescription": "ياقتۇرىدىغان تېما رەڭگىڭىزنى تاللاڭ"
|
||||||
|
},
|
||||||
"srt_player": {
|
"srt_player": {
|
||||||
"uploadVideo": "ۋىدېئو يۈكلەش",
|
"uploadVideo": "ۋىدېئو يۈكلەش",
|
||||||
"uploadSubtitle": "تر پودكاست يۈكلەش",
|
"uploadSubtitle": "تر پودكاست يۈكلەش",
|
||||||
@@ -239,6 +319,8 @@
|
|||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"detectLanguage": "تىلنى تونۇش",
|
"detectLanguage": "تىلنى تونۇش",
|
||||||
|
"sourceLanguage": "مەنبە تىلى",
|
||||||
|
"auto": "ئاپتوماتىك",
|
||||||
"generateIPA": "ipa ھاسىل قىلىش",
|
"generateIPA": "ipa ھاسىل قىلىش",
|
||||||
"translateInto": "تەرجىمە قىلىش",
|
"translateInto": "تەرجىمە قىلىش",
|
||||||
"chinese": "خەنزۇچە",
|
"chinese": "خەنزۇچە",
|
||||||
@@ -347,11 +429,26 @@
|
|||||||
"notSet": "تەڭشەلمىگەن",
|
"notSet": "تەڭشەلمىگەن",
|
||||||
"memberSince": "ئەزا بولغاندىن بېرى",
|
"memberSince": "ئەزا بولغاندىن بېرى",
|
||||||
"logout": "چىكىنىش",
|
"logout": "چىكىنىش",
|
||||||
"folders": {
|
"deleteAccount": {
|
||||||
"title": "قىسقۇچلار",
|
"button": "ھېساباتنى ئۆچۈرۈش",
|
||||||
"noFolders": "تېخى قىسقۇچ يوق",
|
"title": "ھېساباتنى ئۆچۈرۈش",
|
||||||
"folderName": "قىسقۇچ ئاتى",
|
"warning": "بۇ مەشغۇلاتنى ئەسلىگە قايتۇرغىلى بولمايدۇ. بارلىق سانلىق مەلۇماتلىرىڭىز مەڭگۈلۈك ئۆچۈرۈلىدۇ.",
|
||||||
"totalPairs": "جەمئىي جۈپ",
|
"warningDecks": "بارلىق دېك ۋە كارتلىرىڭىز",
|
||||||
|
"warningCards": "بارلىق ئۆگىنىش ئىلگىرىلەشلىرىڭىز",
|
||||||
|
"warningHistory": "بارلىق تەرجىمە ۋە لۇغەت تارىخىڭىز",
|
||||||
|
"warningPermanent": "بۇ مەشغۇلاتنى بىكار قىلغىلى بولمايدۇ",
|
||||||
|
"confirmLabel": "جەزىملەش ئۈچۈن ئىشلەتكۈچى ئاتىڭىزنى كىرگۈزۈڭ:",
|
||||||
|
"usernameMismatch": "ئىشلەتكۈچى ئاتى ماس كەلمەيدۇ",
|
||||||
|
"cancel": "بىكار قىلىش",
|
||||||
|
"confirm": "ھېساباتىمنى ئۆچۈرۈش",
|
||||||
|
"success": "ھېسابات مۇۋەپپەقىيەتلىك ئۆچۈرۈلدى",
|
||||||
|
"failed": "ھېساباتنى ئۆچۈرۈش مەغلۇپ بولدى"
|
||||||
|
},
|
||||||
|
"decks": {
|
||||||
|
"title": "دېكلار",
|
||||||
|
"noDecks": "تېخى دېك يوق",
|
||||||
|
"deckName": "دېك ئاتى",
|
||||||
|
"totalCards": "جەمئىي كارتا",
|
||||||
"createdAt": "قۇرۇلغان ۋاقتى",
|
"createdAt": "قۇرۇلغان ۋاقتى",
|
||||||
"actions": "مەشغۇلاتلار",
|
"actions": "مەشغۇلاتلار",
|
||||||
"view": "كۆرۈش"
|
"view": "كۆرۈش"
|
||||||
|
|||||||
@@ -157,6 +157,9 @@
|
|||||||
"resetPasswordFailed": "发送重置邮件失败",
|
"resetPasswordFailed": "发送重置邮件失败",
|
||||||
"resetPasswordEmailSent": "重置邮件已发送",
|
"resetPasswordEmailSent": "重置邮件已发送",
|
||||||
"resetPasswordEmailSentHint": "我们已向您的邮箱发送了密码重置链接,请查收。",
|
"resetPasswordEmailSentHint": "我们已向您的邮箱发送了密码重置链接,请查收。",
|
||||||
|
"verifyYourEmail": "验证您的邮箱",
|
||||||
|
"verificationEmailSent": "验证邮件已发送",
|
||||||
|
"verificationEmailSentHint": "我们已向 {email} 发送了验证邮件,请点击邮件中的链接完成验证。",
|
||||||
"checkYourEmail": "请查收邮件",
|
"checkYourEmail": "请查收邮件",
|
||||||
"backToLogin": "返回登录",
|
"backToLogin": "返回登录",
|
||||||
"resetPassword": "重置密码",
|
"resetPassword": "重置密码",
|
||||||
@@ -166,25 +169,58 @@
|
|||||||
"requestNewToken": "重新申请重置链接",
|
"requestNewToken": "重新申请重置链接",
|
||||||
"resetPasswordSuccess": "密码重置成功",
|
"resetPasswordSuccess": "密码重置成功",
|
||||||
"resetPasswordSuccessTitle": "密码重置完成",
|
"resetPasswordSuccessTitle": "密码重置完成",
|
||||||
"resetPasswordSuccessHint": "您的密码已成功重置,现在可以使用新密码登录了。"
|
"resetPasswordSuccessHint": "您的密码已成功重置,现在可以使用新密码登录了。",
|
||||||
|
"emailNotVerified": "请验证您的邮箱地址",
|
||||||
|
"emailNotVerifiedHint": "您的邮箱尚未验证。请检查收件箱或重新发送验证邮件。",
|
||||||
|
"resendVerification": "重新发送验证邮件",
|
||||||
|
"resendSuccess": "验证邮件已发送!请检查您的收件箱。",
|
||||||
|
"resendFailed": "发送验证邮件失败"
|
||||||
},
|
},
|
||||||
"memorize": {
|
"memorize": {
|
||||||
"folder_selector": {
|
"deck_selector": {
|
||||||
"selectFolder": "选择文件夹",
|
"selectDeck": "选择牌组",
|
||||||
"noFolders": "未找到文件夹",
|
"noDecks": "未找到牌组",
|
||||||
"folderInfo": "{id}. {name} ({count})"
|
"goToDecks": "前往牌组",
|
||||||
|
"noCards": "无卡片",
|
||||||
|
"new": "新卡片",
|
||||||
|
"learning": "学习中",
|
||||||
|
"review": "复习",
|
||||||
|
"due": "待复习"
|
||||||
},
|
},
|
||||||
"memorize": {
|
"review": {
|
||||||
"answer": "答案",
|
"loading": "加载中...",
|
||||||
"next": "下一个",
|
"backToDecks": "返回牌组",
|
||||||
"reverse": "反向",
|
"allDone": "全部完成!",
|
||||||
"dictation": "听写",
|
"allDoneDesc": "您已完成所有待复习卡片。",
|
||||||
"noTextPairs": "没有可用的文本对",
|
"reviewedCount": "已复习 {count} 张卡片",
|
||||||
"disorder": "乱序",
|
"progress": "{current} / {total}",
|
||||||
"previous": "上一个"
|
"nextReview": "下次复习",
|
||||||
|
"interval": "间隔",
|
||||||
|
"ease": "难度系数",
|
||||||
|
"lapses": "遗忘次数",
|
||||||
|
"showAnswer": "显示答案",
|
||||||
|
"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": "重学中"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "您无权访问该文件夹"
|
"unauthorized": "您无权访问该牌组"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navbar": {
|
"navbar": {
|
||||||
@@ -194,13 +230,62 @@
|
|||||||
"profile": "个人资料",
|
"profile": "个人资料",
|
||||||
"folders": "文件夹",
|
"folders": "文件夹",
|
||||||
"explore": "探索",
|
"explore": "探索",
|
||||||
"favorites": "收藏"
|
"favorites": "收藏",
|
||||||
|
"settings": "设置"
|
||||||
|
},
|
||||||
|
"ocr": {
|
||||||
|
"title": "OCR 词汇提取",
|
||||||
|
"description": "上传教材词汇表截图,提取单词-释义对",
|
||||||
|
"uploadSection": "上传图片",
|
||||||
|
"uploadImage": "上传图片",
|
||||||
|
"dragDropHint": "拖放图片到此处,或点击选择",
|
||||||
|
"dropOrClick": "拖放图片到此处,或点击选择",
|
||||||
|
"changeImage": "点击更换图片",
|
||||||
|
"supportedFormats": "支持格式:JPG、PNG、WebP",
|
||||||
|
"deckSelection": "选择牌组",
|
||||||
|
"selectDeck": "选择牌组",
|
||||||
|
"chooseDeck": "选择保存提取词汇的牌组",
|
||||||
|
"noDecks": "暂无牌组,请先创建牌组",
|
||||||
|
"languageHints": "语言提示(可选)",
|
||||||
|
"sourceLanguageHint": "源语言(如:英语)",
|
||||||
|
"targetLanguageHint": "目标/翻译语言(如:中文)",
|
||||||
|
"sourceLanguagePlaceholder": "源语言(如:英语)",
|
||||||
|
"targetLanguagePlaceholder": "目标/翻译语言(如:中文)",
|
||||||
|
"process": "处理图片",
|
||||||
|
"processButton": "处理图片",
|
||||||
|
"processing": "处理中...",
|
||||||
|
"preview": "预览",
|
||||||
|
"resultsPreview": "结果预览",
|
||||||
|
"extractedPairs": "已提取 {count} 个词汇对",
|
||||||
|
"word": "单词",
|
||||||
|
"definition": "释义",
|
||||||
|
"pairsCount": "{count} 个词汇对",
|
||||||
|
"savePairs": "保存到牌组",
|
||||||
|
"saveButton": "保存",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"saved": "成功将 {count} 个词汇对保存到 {deck}",
|
||||||
|
"ocrSuccess": "成功将 {count} 个词汇对保存到 {deck}",
|
||||||
|
"savedToDeck": "已保存到 {deckName}",
|
||||||
|
"saveFailed": "保存失败",
|
||||||
|
"noImage": "请先上传图片",
|
||||||
|
"noDeck": "请选择牌组",
|
||||||
|
"noResultsToSave": "没有可保存的结果",
|
||||||
|
"processingFailed": "OCR 处理失败",
|
||||||
|
"tryAgain": "请尝试上传更清晰的图片",
|
||||||
|
"detectedLanguages": "检测到:{source} → {target}",
|
||||||
|
"detectedSourceLanguage": "检测到的源语言",
|
||||||
|
"detectedTargetLanguage": "检测到的目标语言"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"myProfile": "我的个人资料",
|
"myProfile": "我的个人资料",
|
||||||
"email": "邮箱:{email}",
|
"email": "邮箱:{email}",
|
||||||
"logout": "退出登录"
|
"logout": "退出登录"
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "设置",
|
||||||
|
"themeColor": "主题色",
|
||||||
|
"themeColorDescription": "选择您喜欢的主题色"
|
||||||
|
},
|
||||||
"srt_player": {
|
"srt_player": {
|
||||||
"upload": "上传",
|
"upload": "上传",
|
||||||
"uploadVideo": "上传视频",
|
"uploadVideo": "上传视频",
|
||||||
@@ -239,6 +324,8 @@
|
|||||||
},
|
},
|
||||||
"translator": {
|
"translator": {
|
||||||
"detectLanguage": "检测语言",
|
"detectLanguage": "检测语言",
|
||||||
|
"sourceLanguage": "源语言",
|
||||||
|
"auto": "自动",
|
||||||
"generateIPA": "生成国际音标",
|
"generateIPA": "生成国际音标",
|
||||||
"translateInto": "翻译为",
|
"translateInto": "翻译为",
|
||||||
"chinese": "中文",
|
"chinese": "中文",
|
||||||
@@ -301,11 +388,11 @@
|
|||||||
},
|
},
|
||||||
"explore": {
|
"explore": {
|
||||||
"title": "探索",
|
"title": "探索",
|
||||||
"subtitle": "发现公开文件夹",
|
"subtitle": "发现公开牌组",
|
||||||
"searchPlaceholder": "搜索公开文件夹...",
|
"searchPlaceholder": "搜索公开牌组...",
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
"noFolders": "没有找到公开文件夹",
|
"noDecks": "没有找到公开牌组",
|
||||||
"folderInfo": "{userName} • {totalPairs} 个文本对",
|
"deckInfo": "{userName} • {cardCount} 张卡片",
|
||||||
"unknownUser": "未知用户",
|
"unknownUser": "未知用户",
|
||||||
"favorite": "收藏",
|
"favorite": "收藏",
|
||||||
"unfavorite": "取消收藏",
|
"unfavorite": "取消收藏",
|
||||||
@@ -314,10 +401,10 @@
|
|||||||
"sortByFavoritesActive": "取消按收藏数排序"
|
"sortByFavoritesActive": "取消按收藏数排序"
|
||||||
},
|
},
|
||||||
"exploreDetail": {
|
"exploreDetail": {
|
||||||
"title": "文件夹详情",
|
"title": "牌组详情",
|
||||||
"createdBy": "创建者:{name}",
|
"createdBy": "创建者:{name}",
|
||||||
"unknownUser": "未知用户",
|
"unknownUser": "未知用户",
|
||||||
"totalPairs": "词对数量",
|
"totalCards": "卡片数量",
|
||||||
"favorites": "收藏数",
|
"favorites": "收藏数",
|
||||||
"createdAt": "创建时间",
|
"createdAt": "创建时间",
|
||||||
"viewContent": "查看内容",
|
"viewContent": "查看内容",
|
||||||
@@ -346,15 +433,40 @@
|
|||||||
"displayName": "显示名称",
|
"displayName": "显示名称",
|
||||||
"notSet": "未设置",
|
"notSet": "未设置",
|
||||||
"memberSince": "注册时间",
|
"memberSince": "注册时间",
|
||||||
|
"joined": "加入于",
|
||||||
"logout": "登出",
|
"logout": "登出",
|
||||||
"folders": {
|
"deleteAccount": {
|
||||||
"title": "文件夹",
|
"button": "注销账号",
|
||||||
"noFolders": "还没有文件夹",
|
"title": "注销账号",
|
||||||
"folderName": "文件夹名称",
|
"warning": "此操作不可逆,您的所有数据将被永久删除。",
|
||||||
"totalPairs": "文本对数量",
|
"warningDecks": "您的所有牌组和卡片",
|
||||||
|
"warningCards": "您的所有学习进度",
|
||||||
|
"warningHistory": "您的所有翻译和词典历史",
|
||||||
|
"warningPermanent": "此操作无法撤销",
|
||||||
|
"confirmLabel": "输入您的用户名以确认:",
|
||||||
|
"usernameMismatch": "用户名不匹配",
|
||||||
|
"cancel": "取消",
|
||||||
|
"confirm": "注销我的账号",
|
||||||
|
"success": "账号已成功注销",
|
||||||
|
"failed": "注销账号失败"
|
||||||
|
},
|
||||||
|
"decks": {
|
||||||
|
"title": "牌组",
|
||||||
|
"noDecks": "还没有牌组",
|
||||||
|
"deckName": "牌组名称",
|
||||||
|
"totalCards": "卡片数量",
|
||||||
"createdAt": "创建时间",
|
"createdAt": "创建时间",
|
||||||
"actions": "操作",
|
"actions": "操作",
|
||||||
"view": "查看"
|
"view": "查看"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"follow": {
|
||||||
|
"follow": "关注",
|
||||||
|
"following": "已关注",
|
||||||
|
"followers": "粉丝",
|
||||||
|
"followersOf": "{username} 的粉丝",
|
||||||
|
"followingOf": "{username} 的关注",
|
||||||
|
"noFollowers": "还没有粉丝",
|
||||||
|
"noFollowing": "还没有关注任何人"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
"next-intl": "^4.7.0",
|
"next-intl": "^4.7.0",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
"sql.js": "^1.14.1",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"unstorage": "^1.17.3",
|
"unstorage": "^1.17.3",
|
||||||
"winston": "^3.19.0",
|
"winston": "^3.19.0",
|
||||||
@@ -41,6 +43,7 @@
|
|||||||
"@types/nodemailer": "^7.0.11",
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/react": "19.2.7",
|
"@types/react": "19.2.7",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
|
"@types/sql.js": "^1.4.9",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.51.0",
|
"@typescript-eslint/eslint-plugin": "^8.51.0",
|
||||||
"@typescript-eslint/parser": "^8.51.0",
|
"@typescript-eslint/parser": "^8.51.0",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
|
|||||||
121
pnpm-lock.yaml
generated
121
pnpm-lock.yaml
generated
@@ -23,7 +23,7 @@ importers:
|
|||||||
version: 3.0.3
|
version: 3.0.3
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.4.10
|
specifier: ^1.4.10
|
||||||
version: 1.4.10(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 1.4.10(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -33,6 +33,9 @@ importers:
|
|||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^17.2.3
|
specifier: ^17.2.3
|
||||||
version: 17.2.3
|
version: 17.2.3
|
||||||
|
jszip:
|
||||||
|
specifier: ^3.10.1
|
||||||
|
version: 3.10.1
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.562.0
|
specifier: ^0.562.0
|
||||||
version: 0.562.0(react@19.2.3)
|
version: 0.562.0(react@19.2.3)
|
||||||
@@ -60,6 +63,9 @@ importers:
|
|||||||
sonner:
|
sonner:
|
||||||
specifier: ^2.0.7
|
specifier: ^2.0.7
|
||||||
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
sql.js:
|
||||||
|
specifier: ^1.14.1
|
||||||
|
version: 1.14.1
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.4.0
|
specifier: ^3.4.0
|
||||||
version: 3.4.0
|
version: 3.4.0
|
||||||
@@ -78,7 +84,7 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
'@better-auth/cli':
|
'@better-auth/cli':
|
||||||
specifier: ^1.4.10
|
specifier: ^1.4.10
|
||||||
version: 1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@types/react@19.2.7)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(mysql2@3.15.3)(nanostores@1.1.0)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(mysql2@3.15.3)(nanostores@1.1.0)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sql.js@1.14.1)
|
||||||
'@eslint/eslintrc':
|
'@eslint/eslintrc':
|
||||||
specifier: ^3.3.3
|
specifier: ^3.3.3
|
||||||
version: 3.3.3
|
version: 3.3.3
|
||||||
@@ -97,6 +103,9 @@ importers:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: 19.2.3
|
specifier: 19.2.3
|
||||||
version: 19.2.3(@types/react@19.2.7)
|
version: 19.2.3(@types/react@19.2.7)
|
||||||
|
'@types/sql.js':
|
||||||
|
specifier: ^1.4.9
|
||||||
|
version: 1.4.9
|
||||||
'@typescript-eslint/eslint-plugin':
|
'@typescript-eslint/eslint-plugin':
|
||||||
specifier: ^8.51.0
|
specifier: ^8.51.0
|
||||||
version: 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
version: 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||||
@@ -1046,6 +1055,9 @@ packages:
|
|||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||||
|
|
||||||
|
'@types/emscripten@1.41.5':
|
||||||
|
resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==}
|
||||||
|
|
||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
@@ -1072,6 +1084,9 @@ packages:
|
|||||||
'@types/react@19.2.7':
|
'@types/react@19.2.7':
|
||||||
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
|
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
|
||||||
|
|
||||||
|
'@types/sql.js@1.4.9':
|
||||||
|
resolution: {integrity: sha512-ep8b36RKHlgWPqjNG9ToUrPiwkhwh0AEzy883mO5Xnd+cL6VBH1EvSjBAAuxLUFF2Vn/moE3Me6v9E1Lo+48GQ==}
|
||||||
|
|
||||||
'@types/triple-beam@1.3.5':
|
'@types/triple-beam@1.3.5':
|
||||||
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
|
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
|
||||||
|
|
||||||
@@ -1620,6 +1635,9 @@ packages:
|
|||||||
cookie-es@1.2.2:
|
cookie-es@1.2.2:
|
||||||
resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==}
|
resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==}
|
||||||
|
|
||||||
|
core-util-is@1.0.3:
|
||||||
|
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -2218,6 +2236,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
|
immediate@3.0.6:
|
||||||
|
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -2366,6 +2387,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==}
|
resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
|
isarray@1.0.0:
|
||||||
|
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||||
|
|
||||||
isarray@2.0.5:
|
isarray@2.0.5:
|
||||||
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
|
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
|
||||||
|
|
||||||
@@ -2417,6 +2441,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
|
|
||||||
|
jszip@3.10.1:
|
||||||
|
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
@@ -2442,6 +2469,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
lie@3.3.0:
|
||||||
|
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||||
|
|
||||||
lightningcss-android-arm64@1.30.2:
|
lightningcss-android-arm64@1.30.2:
|
||||||
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
|
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -2762,6 +2792,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
pako@1.0.11:
|
||||||
|
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -2900,6 +2933,9 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
process-nextick-args@2.0.1:
|
||||||
|
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||||
|
|
||||||
prompts@2.4.2:
|
prompts@2.4.2:
|
||||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -2945,6 +2981,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
|
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
readable-stream@2.3.8:
|
||||||
|
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||||
|
|
||||||
readable-stream@3.6.2:
|
readable-stream@3.6.2:
|
||||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -3005,6 +3044,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
|
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
|
||||||
engines: {node: '>=0.4'}
|
engines: {node: '>=0.4'}
|
||||||
|
|
||||||
|
safe-buffer@5.1.2:
|
||||||
|
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||||
|
|
||||||
safe-buffer@5.2.1:
|
safe-buffer@5.2.1:
|
||||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||||
|
|
||||||
@@ -3053,6 +3095,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
setimmediate@1.0.5:
|
||||||
|
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||||
|
|
||||||
sharp@0.34.5:
|
sharp@0.34.5:
|
||||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
@@ -3111,6 +3156,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||||
engines: {node: '>= 10.x'}
|
engines: {node: '>= 10.x'}
|
||||||
|
|
||||||
|
sql.js@1.14.1:
|
||||||
|
resolution: {integrity: sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==}
|
||||||
|
|
||||||
sqlstring@2.3.3:
|
sqlstring@2.3.3:
|
||||||
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
|
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -3151,6 +3199,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
|
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
string_decoder@1.1.1:
|
||||||
|
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||||
|
|
||||||
string_decoder@1.3.0:
|
string_decoder@1.3.0:
|
||||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||||
|
|
||||||
@@ -3699,7 +3750,7 @@ snapshots:
|
|||||||
'@babel/helper-string-parser': 7.27.1
|
'@babel/helper-string-parser': 7.27.1
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
|
|
||||||
'@better-auth/cli@1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@types/react@19.2.7)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(mysql2@3.15.3)(nanostores@1.1.0)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
'@better-auth/cli@1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(mysql2@3.15.3)(nanostores@1.1.0)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sql.js@1.14.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.28.5
|
||||||
'@babel/preset-react': 7.28.5(@babel/core@7.28.5)
|
'@babel/preset-react': 7.28.5(@babel/core@7.28.5)
|
||||||
@@ -3711,13 +3762,13 @@ snapshots:
|
|||||||
'@mrleebo/prisma-ast': 0.13.1
|
'@mrleebo/prisma-ast': 0.13.1
|
||||||
'@prisma/client': 5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))
|
'@prisma/client': 5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))
|
||||||
'@types/pg': 8.15.6
|
'@types/pg': 8.15.6
|
||||||
better-auth: 1.4.10(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
better-auth: 1.4.10(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
better-sqlite3: 12.5.0
|
better-sqlite3: 12.5.0
|
||||||
c12: 3.3.2
|
c12: 3.3.2
|
||||||
chalk: 5.6.2
|
chalk: 5.6.2
|
||||||
commander: 12.1.0
|
commander: 12.1.0
|
||||||
dotenv: 17.2.3
|
dotenv: 17.2.3
|
||||||
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)
|
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1)
|
||||||
open: 10.2.0
|
open: 10.2.0
|
||||||
pg: 8.16.3
|
pg: 8.16.3
|
||||||
prettier: 3.7.4
|
prettier: 3.7.4
|
||||||
@@ -4411,6 +4462,8 @@ snapshots:
|
|||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@types/emscripten@1.41.5': {}
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
@@ -4439,6 +4492,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@types/sql.js@1.4.9':
|
||||||
|
dependencies:
|
||||||
|
'@types/emscripten': 1.41.5
|
||||||
|
'@types/node': 25.0.3
|
||||||
|
|
||||||
'@types/triple-beam@1.3.5': {}
|
'@types/triple-beam@1.3.5': {}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
'@typescript-eslint/eslint-plugin@8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||||
@@ -4810,7 +4868,7 @@ snapshots:
|
|||||||
|
|
||||||
bcryptjs@3.0.3: {}
|
bcryptjs@3.0.3: {}
|
||||||
|
|
||||||
better-auth@1.4.10(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
better-auth@1.4.10(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)
|
'@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)
|
||||||
'@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))
|
'@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))
|
||||||
@@ -4827,7 +4885,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@prisma/client': 5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))
|
'@prisma/client': 5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))
|
||||||
better-sqlite3: 12.5.0
|
better-sqlite3: 12.5.0
|
||||||
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)
|
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1)
|
||||||
mysql2: 3.15.3
|
mysql2: 3.15.3
|
||||||
next: 16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
next: 16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
pg: 8.16.3
|
pg: 8.16.3
|
||||||
@@ -4835,7 +4893,7 @@ snapshots:
|
|||||||
react: 19.2.3
|
react: 19.2.3
|
||||||
react-dom: 19.2.3(react@19.2.3)
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
|
||||||
better-auth@1.4.10(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
better-auth@1.4.10(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)
|
'@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)
|
||||||
'@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))
|
'@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))
|
||||||
@@ -4852,7 +4910,7 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)
|
'@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)
|
||||||
better-sqlite3: 12.5.0
|
better-sqlite3: 12.5.0
|
||||||
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)
|
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1)
|
||||||
mysql2: 3.15.3
|
mysql2: 3.15.3
|
||||||
next: 16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
next: 16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
pg: 8.16.3
|
pg: 8.16.3
|
||||||
@@ -5034,6 +5092,8 @@ snapshots:
|
|||||||
|
|
||||||
cookie-es@1.2.2: {}
|
cookie-es@1.2.2: {}
|
||||||
|
|
||||||
|
core-util-is@1.0.3: {}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
@@ -5125,12 +5185,13 @@ snapshots:
|
|||||||
|
|
||||||
dotenv@17.2.3: {}
|
dotenv@17.2.3: {}
|
||||||
|
|
||||||
drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3):
|
drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@electric-sql/pglite': 0.3.15
|
'@electric-sql/pglite': 0.3.15
|
||||||
'@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)
|
'@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)
|
||||||
'@types/pg': 8.15.6
|
'@types/pg': 8.15.6
|
||||||
'@types/react': 19.2.7
|
'@types/react': 19.2.7
|
||||||
|
'@types/sql.js': 1.4.9
|
||||||
better-sqlite3: 12.5.0
|
better-sqlite3: 12.5.0
|
||||||
kysely: 0.28.8
|
kysely: 0.28.8
|
||||||
mysql2: 3.15.3
|
mysql2: 3.15.3
|
||||||
@@ -5138,6 +5199,7 @@ snapshots:
|
|||||||
postgres: 3.4.7
|
postgres: 3.4.7
|
||||||
prisma: 7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)
|
prisma: 7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)
|
||||||
react: 19.2.3
|
react: 19.2.3
|
||||||
|
sql.js: 1.14.1
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5685,6 +5747,8 @@ snapshots:
|
|||||||
|
|
||||||
ignore@7.0.5: {}
|
ignore@7.0.5: {}
|
||||||
|
|
||||||
|
immediate@3.0.6: {}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
parent-module: 1.0.1
|
parent-module: 1.0.1
|
||||||
@@ -5837,6 +5901,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-inside-container: 1.0.0
|
is-inside-container: 1.0.0
|
||||||
|
|
||||||
|
isarray@1.0.0: {}
|
||||||
|
|
||||||
isarray@2.0.5: {}
|
isarray@2.0.5: {}
|
||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
@@ -5881,6 +5947,13 @@ snapshots:
|
|||||||
object.assign: 4.1.7
|
object.assign: 4.1.7
|
||||||
object.values: 1.2.1
|
object.values: 1.2.1
|
||||||
|
|
||||||
|
jszip@3.10.1:
|
||||||
|
dependencies:
|
||||||
|
lie: 3.3.0
|
||||||
|
pako: 1.0.11
|
||||||
|
readable-stream: 2.3.8
|
||||||
|
setimmediate: 1.0.5
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
@@ -5902,6 +5975,10 @@ snapshots:
|
|||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
|
|
||||||
|
lie@3.3.0:
|
||||||
|
dependencies:
|
||||||
|
immediate: 3.0.6
|
||||||
|
|
||||||
lightningcss-android-arm64@1.30.2:
|
lightningcss-android-arm64@1.30.2:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -6204,6 +6281,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-limit: 3.1.0
|
p-limit: 3.1.0
|
||||||
|
|
||||||
|
pako@1.0.11: {}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
callsites: 3.1.0
|
callsites: 3.1.0
|
||||||
@@ -6333,6 +6412,8 @@ snapshots:
|
|||||||
- react
|
- react
|
||||||
- react-dom
|
- react-dom
|
||||||
|
|
||||||
|
process-nextick-args@2.0.1: {}
|
||||||
|
|
||||||
prompts@2.4.2:
|
prompts@2.4.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
kleur: 3.0.3
|
kleur: 3.0.3
|
||||||
@@ -6384,6 +6465,16 @@ snapshots:
|
|||||||
|
|
||||||
react@19.2.3: {}
|
react@19.2.3: {}
|
||||||
|
|
||||||
|
readable-stream@2.3.8:
|
||||||
|
dependencies:
|
||||||
|
core-util-is: 1.0.3
|
||||||
|
inherits: 2.0.4
|
||||||
|
isarray: 1.0.0
|
||||||
|
process-nextick-args: 2.0.1
|
||||||
|
safe-buffer: 5.1.2
|
||||||
|
string_decoder: 1.1.1
|
||||||
|
util-deprecate: 1.0.2
|
||||||
|
|
||||||
readable-stream@3.6.2:
|
readable-stream@3.6.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
inherits: 2.0.4
|
inherits: 2.0.4
|
||||||
@@ -6452,6 +6543,8 @@ snapshots:
|
|||||||
has-symbols: 1.1.0
|
has-symbols: 1.1.0
|
||||||
isarray: 2.0.5
|
isarray: 2.0.5
|
||||||
|
|
||||||
|
safe-buffer@5.1.2: {}
|
||||||
|
|
||||||
safe-buffer@5.2.1: {}
|
safe-buffer@5.2.1: {}
|
||||||
|
|
||||||
safe-push-apply@1.0.0:
|
safe-push-apply@1.0.0:
|
||||||
@@ -6501,6 +6594,8 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
es-object-atoms: 1.1.1
|
es-object-atoms: 1.1.1
|
||||||
|
|
||||||
|
setimmediate@1.0.5: {}
|
||||||
|
|
||||||
sharp@0.34.5:
|
sharp@0.34.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@img/colour': 1.0.0
|
'@img/colour': 1.0.0
|
||||||
@@ -6590,6 +6685,8 @@ snapshots:
|
|||||||
|
|
||||||
split2@4.2.0: {}
|
split2@4.2.0: {}
|
||||||
|
|
||||||
|
sql.js@1.14.1: {}
|
||||||
|
|
||||||
sqlstring@2.3.3: {}
|
sqlstring@2.3.3: {}
|
||||||
|
|
||||||
stable-hash@0.0.5: {}
|
stable-hash@0.0.5: {}
|
||||||
@@ -6653,6 +6750,10 @@ snapshots:
|
|||||||
define-properties: 1.2.1
|
define-properties: 1.2.1
|
||||||
es-object-atoms: 1.1.1
|
es-object-atoms: 1.1.1
|
||||||
|
|
||||||
|
string_decoder@1.1.1:
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: 5.1.2
|
||||||
|
|
||||||
string_decoder@1.3.0:
|
string_decoder@1.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- 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;
|
||||||
207
prisma/migrations/20260310111728_anki_refactor/migration.sql
Normal file
207
prisma/migrations/20260310111728_anki_refactor/migration.sql
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/*
|
||||||
|
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;
|
||||||
@@ -7,6 +7,10 @@ datasource db {
|
|||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// User & Auth
|
||||||
|
// ============================================
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id
|
id String @id
|
||||||
name String
|
name String
|
||||||
@@ -17,12 +21,18 @@ model User {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
displayUsername String?
|
displayUsername String?
|
||||||
username String @unique
|
username String @unique
|
||||||
|
bio String?
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
dictionaryLookUps DictionaryLookUp[]
|
dictionaryLookUps DictionaryLookUp[]
|
||||||
folders Folder[]
|
// Anki-compatible relations
|
||||||
folderFavorites FolderFavorite[]
|
decks Deck[]
|
||||||
|
deckFavorites DeckFavorite[]
|
||||||
|
noteTypes NoteType[]
|
||||||
|
notes Note[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
translationHistories TranslationHistory[]
|
translationHistories TranslationHistory[]
|
||||||
|
followers Follow[] @relation("UserFollowers")
|
||||||
|
following Follow[] @relation("UserFollowing")
|
||||||
|
|
||||||
@@map("user")
|
@@map("user")
|
||||||
}
|
}
|
||||||
@@ -74,60 +84,175 @@ model Verification {
|
|||||||
@@map("verification")
|
@@map("verification")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Pair {
|
// ============================================
|
||||||
id Int @id @default(autoincrement())
|
// Anki-compatible Models
|
||||||
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])
|
/// Card type: 0=new, 1=learning, 2=review, 3=relearning
|
||||||
@@index([folderId])
|
enum CardType {
|
||||||
@@map("pairs")
|
NEW
|
||||||
|
LEARNING
|
||||||
|
REVIEW
|
||||||
|
RELEARNING
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Card queue: -3=user buried, -2=sched buried, -1=suspended, 0=new, 1=learning, 2=review, 3=in learning, 4=preview
|
||||||
|
enum CardQueue {
|
||||||
|
USER_BURIED
|
||||||
|
SCHED_BURIED
|
||||||
|
SUSPENDED
|
||||||
|
NEW
|
||||||
|
LEARNING
|
||||||
|
REVIEW
|
||||||
|
IN_LEARNING
|
||||||
|
PREVIEW
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Note type: 0=standard, 1=cloze
|
||||||
|
enum NoteKind {
|
||||||
|
STANDARD
|
||||||
|
CLOZE
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deck visibility (our extension, not in Anki)
|
||||||
enum Visibility {
|
enum Visibility {
|
||||||
PRIVATE
|
PRIVATE
|
||||||
PUBLIC
|
PUBLIC
|
||||||
}
|
}
|
||||||
|
|
||||||
model Folder {
|
/// NoteType (Anki: models) - Defines fields and templates for notes
|
||||||
id Int @id @default(autoincrement())
|
model NoteType {
|
||||||
name String
|
id Int @id @default(autoincrement())
|
||||||
userId String @map("user_id")
|
name String
|
||||||
visibility Visibility @default(PRIVATE)
|
kind NoteKind @default(STANDARD)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
css String @default("")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
fields Json @default("[]")
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
templates Json @default("[]")
|
||||||
pairs Pair[]
|
userId String @map("user_id")
|
||||||
favorites FolderFavorite[]
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
notes Note[]
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@map("note_types")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deck (Anki: decks) - Container for cards
|
||||||
|
model Deck {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String
|
||||||
|
desc String @default("")
|
||||||
|
userId String @map("user_id")
|
||||||
|
visibility Visibility @default(PRIVATE)
|
||||||
|
collapsed Boolean @default(false)
|
||||||
|
conf Json @default("{}")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
cards Card[]
|
||||||
|
favorites DeckFavorite[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([visibility])
|
@@index([visibility])
|
||||||
@@map("folders")
|
@@map("decks")
|
||||||
}
|
}
|
||||||
|
|
||||||
model FolderFavorite {
|
/// DeckFavorite - Users can favorite public decks
|
||||||
|
model DeckFavorite {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
folderId Int @map("folder_id")
|
deckId Int @map("deck_id")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
|
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([userId, folderId])
|
@@unique([userId, deckId])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([folderId])
|
@@index([deckId])
|
||||||
@@map("folder_favorites")
|
@@map("deck_favorites")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Note (Anki: notes) - Contains field data, one note can have multiple cards
|
||||||
|
model Note {
|
||||||
|
id BigInt @id
|
||||||
|
guid String @unique
|
||||||
|
noteTypeId Int @map("note_type_id")
|
||||||
|
mod Int
|
||||||
|
usn Int @default(-1)
|
||||||
|
tags String @default(" ")
|
||||||
|
flds String
|
||||||
|
sfld String
|
||||||
|
csum Int
|
||||||
|
flags Int @default(0)
|
||||||
|
data String @default("")
|
||||||
|
userId String @map("user_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
noteType NoteType @relation(fields: [noteTypeId], references: [id], onDelete: Cascade)
|
||||||
|
cards Card[]
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([noteTypeId])
|
||||||
|
@@index([csum])
|
||||||
|
@@map("notes")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Card (Anki: cards) - Scheduling information, what you review
|
||||||
|
model Card {
|
||||||
|
id BigInt @id
|
||||||
|
noteId BigInt @map("note_id")
|
||||||
|
deckId Int @map("deck_id")
|
||||||
|
ord Int
|
||||||
|
mod Int
|
||||||
|
usn Int @default(-1)
|
||||||
|
type CardType @default(NEW)
|
||||||
|
queue CardQueue @default(NEW)
|
||||||
|
due Int
|
||||||
|
ivl Int @default(0)
|
||||||
|
factor Int @default(2500)
|
||||||
|
reps Int @default(0)
|
||||||
|
lapses Int @default(0)
|
||||||
|
left Int @default(0)
|
||||||
|
odue Int @default(0)
|
||||||
|
odid Int @default(0)
|
||||||
|
flags Int @default(0)
|
||||||
|
data String @default("")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
||||||
|
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
|
||||||
|
revlogs Revlog[]
|
||||||
|
|
||||||
|
@@index([noteId])
|
||||||
|
@@index([deckId])
|
||||||
|
@@index([deckId, queue, due])
|
||||||
|
@@map("cards")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revlog (Anki: revlog) - Review history
|
||||||
|
model Revlog {
|
||||||
|
id BigInt @id
|
||||||
|
cardId BigInt @map("card_id")
|
||||||
|
usn Int @default(-1)
|
||||||
|
ease Int
|
||||||
|
ivl Int
|
||||||
|
lastIvl Int
|
||||||
|
factor Int
|
||||||
|
time Int
|
||||||
|
type Int
|
||||||
|
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([cardId])
|
||||||
|
@@map("revlogs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Other Models
|
||||||
|
// ============================================
|
||||||
|
|
||||||
model DictionaryLookUp {
|
model DictionaryLookUp {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId String? @map("user_id")
|
userId String? @map("user_id")
|
||||||
@@ -137,8 +262,8 @@ model DictionaryLookUp {
|
|||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
dictionaryItemId Int? @map("dictionary_item_id")
|
dictionaryItemId Int? @map("dictionary_item_id")
|
||||||
normalizedText String @default("") @map("normalized_text")
|
normalizedText String @default("") @map("normalized_text")
|
||||||
dictionaryItem DictionaryItem? @relation(fields: [dictionaryItemId], references: [id])
|
dictionaryItem DictionaryItem? @relation(fields: [dictionaryItemId], references: [id], onDelete: SetNull)
|
||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@ -198,3 +323,18 @@ model TranslationHistory {
|
|||||||
@@index([translatedText, sourceLanguage, targetLanguage])
|
@@index([translatedText, sourceLanguage, targetLanguage])
|
||||||
@@map("translation_history")
|
@@map("translation_history")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Follow {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
followerId String @map("follower_id")
|
||||||
|
followingId String @map("following_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
follower User @relation("UserFollowers", fields: [followerId], references: [id], onDelete: Cascade)
|
||||||
|
following User @relation("UserFollowing", fields: [followingId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([followerId, followingId])
|
||||||
|
@@index([followerId])
|
||||||
|
@@index([followingId])
|
||||||
|
@@map("follows")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"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";
|
||||||
@@ -9,6 +8,7 @@ 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,16 +23,13 @@ export default function ForgotPasswordPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const { error } = await authClient.requestPasswordReset({
|
const result = await actionRequestPasswordReset({ email });
|
||||||
email,
|
|
||||||
redirectTo: "/reset-password",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
if (!result.success) {
|
||||||
toast.error(error.message ?? t("resetPasswordFailed"));
|
toast.error(result.message);
|
||||||
} else {
|
} else {
|
||||||
setSent(true);
|
setSent(true);
|
||||||
toast.success(t("resetPasswordEmailSent"));
|
toast.success(result.message);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 } from "@/design-system/base/button";
|
import { PrimaryButton, LinkButton } 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,6 +16,9 @@ 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");
|
||||||
@@ -25,10 +28,31 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPending && session?.user?.username && !redirectTo) {
|
if (!isPending && session?.user?.username && !redirectTo) {
|
||||||
router.push("/folders");
|
router.push("/decks");
|
||||||
}
|
}
|
||||||
}, [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"));
|
||||||
@@ -36,6 +60,7 @@ 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({
|
||||||
@@ -43,7 +68,13 @@ export default function LoginPage() {
|
|||||||
password: password,
|
password: password,
|
||||||
});
|
});
|
||||||
if (error) {
|
if (error) {
|
||||||
toast.error(error.message ?? t("loginFailed"));
|
if (error.status === 403) {
|
||||||
|
setUnverifiedEmail(username);
|
||||||
|
setShowResendOption(true);
|
||||||
|
toast.error(t("emailNotVerified"));
|
||||||
|
} else {
|
||||||
|
toast.error(error.message ?? t("loginFailed"));
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -52,11 +83,15 @@ export default function LoginPage() {
|
|||||||
password: password,
|
password: password,
|
||||||
});
|
});
|
||||||
if (error) {
|
if (error) {
|
||||||
toast.error(error.message ?? t("loginFailed"));
|
if (error.status === 403) {
|
||||||
|
toast.error(t("emailNotVerified"));
|
||||||
|
} else {
|
||||||
|
toast.error(error.message ?? t("loginFailed"));
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
router.push(redirectTo ?? "/folders");
|
router.push(redirectTo ?? "/decks");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -91,6 +126,21 @@ 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}
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ export default async function ProfilePage() {
|
|||||||
redirect("/login?redirect=/profile");
|
redirect("/login?redirect=/profile");
|
||||||
}
|
}
|
||||||
|
|
||||||
redirect(session.user.username ? `/users/${session.user.username}` : "/folders");
|
redirect(session.user.username ? `/users/${session.user.username}` : "/decks");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ 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");
|
||||||
@@ -26,10 +27,10 @@ export default function SignUpPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPending && session?.user?.username && !redirectTo) {
|
if (!isPending && session?.user?.username && !redirectTo && !verificationSent) {
|
||||||
router.push("/folders");
|
router.push("/decks");
|
||||||
}
|
}
|
||||||
}, [session, isPending, router, redirectTo]);
|
}, [session, isPending, router, redirectTo, verificationSent]);
|
||||||
|
|
||||||
const handleSignUp = async () => {
|
const handleSignUp = async () => {
|
||||||
if (!username || !email || !password) {
|
if (!username || !email || !password) {
|
||||||
@@ -49,12 +50,38 @@ export default function SignUpPage() {
|
|||||||
toast.error(error.message ?? t("signUpFailed"));
|
toast.error(error.message ?? t("signUpFailed"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(redirectTo ?? "/folders");
|
setVerificationSent(true);
|
||||||
|
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">
|
||||||
|
|||||||
103
src/app/(auth)/users/[username]/DeleteAccountButton.tsx
Normal file
103
src/app/(auth)/users/[username]/DeleteAccountButton.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
src/app/(auth)/users/[username]/followers/page.tsx
Normal file
44
src/app/(auth)/users/[username]/followers/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
|
import { UserList } from "@/components/follow/UserList";
|
||||||
|
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
|
||||||
|
import { actionGetFollowers } from "@/modules/follow/follow-action";
|
||||||
|
|
||||||
|
interface FollowersPageProps {
|
||||||
|
params: Promise<{ username: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function FollowersPage({ params }: FollowersPageProps) {
|
||||||
|
const { username } = await params;
|
||||||
|
const t = await getTranslations("follow");
|
||||||
|
|
||||||
|
const userResult = await actionGetUserProfileByUsername({ username });
|
||||||
|
|
||||||
|
if (!userResult.success || !userResult.data) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userResult.data;
|
||||||
|
|
||||||
|
const followersResult = await actionGetFollowers({
|
||||||
|
userId: user.id,
|
||||||
|
page: 1,
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const followers = followersResult.success && followersResult.data
|
||||||
|
? followersResult.data.followers.map((f) => f.user)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800 mb-6">
|
||||||
|
{t("followersOf", { username: user.displayUsername || user.username || "User" })}
|
||||||
|
</h1>
|
||||||
|
<UserList users={followers} emptyMessage={t("noFollowers")} />
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
src/app/(auth)/users/[username]/following/page.tsx
Normal file
44
src/app/(auth)/users/[username]/following/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
|
import { UserList } from "@/components/follow/UserList";
|
||||||
|
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
|
||||||
|
import { actionGetFollowing } from "@/modules/follow/follow-action";
|
||||||
|
|
||||||
|
interface FollowingPageProps {
|
||||||
|
params: Promise<{ username: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function FollowingPage({ params }: FollowingPageProps) {
|
||||||
|
const { username } = await params;
|
||||||
|
const t = await getTranslations("follow");
|
||||||
|
|
||||||
|
const userResult = await actionGetUserProfileByUsername({ username });
|
||||||
|
|
||||||
|
if (!userResult.success || !userResult.data) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userResult.data;
|
||||||
|
|
||||||
|
const followingResult = await actionGetFollowing({
|
||||||
|
userId: user.id,
|
||||||
|
page: 1,
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const following = followingResult.success && followingResult.data
|
||||||
|
? followingResult.data.following.map((f) => f.user)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800 mb-6">
|
||||||
|
{t("followingOf", { username: user.displayUsername || user.username || "User" })}
|
||||||
|
</h1>
|
||||||
|
<UserList users={following} emptyMessage={t("noFollowing")} />
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { PageLayout } from "@/components/ui/PageLayout";
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
import { LightButton, 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 { repoGetFoldersWithTotalPairsByUserId } from "@/modules/folder/folder-repository";
|
import { repoGetDecksByUserId } from "@/modules/deck/deck-repository";
|
||||||
|
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 { LogoutButton } from "./LogoutButton";
|
import { FollowStats } from "@/components/follow/FollowStats";
|
||||||
|
import { DeleteAccountButton } from "./DeleteAccountButton";
|
||||||
|
|
||||||
interface UserPageProps {
|
interface UserPageProps {
|
||||||
params: Promise<{ username: string; }>;
|
params: Promise<{ username: string; }>;
|
||||||
@@ -18,10 +20,8 @@ export default async function UserPage({ params }: UserPageProps) {
|
|||||||
const { username } = await params;
|
const { username } = await params;
|
||||||
const t = await getTranslations("user_profile");
|
const t = await getTranslations("user_profile");
|
||||||
|
|
||||||
// Get current session
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
|
||||||
// Get user profile
|
|
||||||
const result = await actionGetUserProfileByUsername({ username });
|
const result = await actionGetUserProfileByUsername({ username });
|
||||||
|
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
@@ -30,150 +30,170 @@ export default async function UserPage({ params }: UserPageProps) {
|
|||||||
|
|
||||||
const user = result.data;
|
const user = result.data;
|
||||||
|
|
||||||
// Get user's folders
|
const [decks, followStatus] = await Promise.all([
|
||||||
const folders = await repoGetFoldersWithTotalPairsByUserId(user.id);
|
repoGetDecksByUserId({ userId: user.id }),
|
||||||
|
actionGetFollowStatus({ targetUserId: user.id }),
|
||||||
|
]);
|
||||||
|
|
||||||
// Check if viewing own profile
|
|
||||||
const isOwnProfile = session?.user?.username === username || session?.user?.email === username;
|
const isOwnProfile = session?.user?.username === username || session?.user?.email === username;
|
||||||
|
|
||||||
|
const followersCount = followStatus.success && followStatus.data ? followStatus.data.followersCount : 0;
|
||||||
|
const followingCount = followStatus.success && followStatus.data ? followStatus.data.followingCount : 0;
|
||||||
|
const isFollowing = followStatus.success && followStatus.data ? followStatus.data.isFollowing : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
{/* Header */}
|
|
||||||
<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>
|
||||||
{isOwnProfile && <LinkButton href="/logout">{t("logout")}</LinkButton>}
|
<div className="flex items-center gap-3">
|
||||||
</div>
|
{isOwnProfile && (
|
||||||
<div className="flex items-center space-x-6">
|
<>
|
||||||
{/* Avatar */}
|
<LinkButton href="/logout">{t("logout")}</LinkButton>
|
||||||
{user.image ? (
|
<DeleteAccountButton username={username} />
|
||||||
<div className="relative w-24 h-24 rounded-full border-4 border-primary-500 overflow-hidden">
|
</>
|
||||||
<Image
|
|
||||||
src={user.image}
|
|
||||||
alt={user.displayUsername || user.username || user.email}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
unoptimized
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-24 h-24 rounded-full bg-primary-500 border-4 border-primary-500 flex items-center justify-center">
|
|
||||||
<span className="text-3xl font-bold text-white">
|
|
||||||
{(user.displayUsername || user.username || user.email)[0].toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* User Info */}
|
|
||||||
<div className="flex-1">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">
|
|
||||||
{user.displayUsername || user.username || t("anonymous")}
|
|
||||||
</h1>
|
|
||||||
{user.username && (
|
|
||||||
<p className="text-gray-600 text-sm mb-1">
|
|
||||||
@{user.username}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="text-gray-600 text-sm mb-1">
|
|
||||||
{user.email}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center space-x-4 text-sm">
|
|
||||||
<span className="text-gray-500">
|
|
||||||
Joined: {new Date(user.createdAt).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
{user.emailVerified && (
|
|
||||||
<span className="flex items-center text-green-600">
|
|
||||||
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.293 12.293a1 1 0 101.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
Verified
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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">
|
||||||
{/* Account Info */}
|
{user.image ? (
|
||||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
<div className="relative w-24 h-24 rounded-full border-4 border-primary-500 overflow-hidden flex-shrink-0">
|
||||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("accountInfo")}</h2>
|
<Image
|
||||||
<dl className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
src={user.image}
|
||||||
<div>
|
alt={user.displayUsername || user.username || user.email}
|
||||||
<dt className="text-sm font-medium text-gray-500">{t("userId")}</dt>
|
fill
|
||||||
<dd className="mt-1 text-sm text-gray-900 font-mono break-all">{user.id}</dd>
|
className="object-cover"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-gray-500">{t("username")}</dt>
|
|
||||||
<dd className="mt-1 text-sm text-gray-900">
|
|
||||||
{user.username || <span className="text-gray-400">{t("notSet")}</span>}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-gray-500">{t("displayName")}</dt>
|
|
||||||
<dd className="mt-1 text-sm text-gray-900">
|
|
||||||
{user.displayUsername || <span className="text-gray-400">{t("notSet")}</span>}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium text-gray-500">{t("memberSince")}</dt>
|
|
||||||
<dd className="mt-1 text-sm text-gray-900">
|
|
||||||
{new Date(user.createdAt).toLocaleDateString()}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Folders Section */}
|
|
||||||
<div className="bg-white rounded-lg shadow-md p-6">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("folders.title")}</h2>
|
|
||||||
{folders.length === 0 ? (
|
|
||||||
<p className="text-gray-500 text-center py-8">{t("folders.noFolders")}</p>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="w-24 h-24 rounded-full bg-primary-500 border-4 border-primary-500 flex items-center justify-center flex-shrink-0">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<span className="text-3xl font-bold text-white">
|
||||||
<thead className="bg-gray-50">
|
{(user.displayUsername || user.username || user.email)[0].toUpperCase()}
|
||||||
<tr>
|
</span>
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
{t("folders.folderName")}
|
|
||||||
</th>
|
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
{t("folders.totalPairs")}
|
|
||||||
</th>
|
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
{t("folders.createdAt")}
|
|
||||||
</th>
|
|
||||||
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
{t("folders.actions")}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{folders.map((folder) => (
|
|
||||||
<tr key={folder.id} className="hover:bg-gray-50">
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm font-medium text-gray-900">{folder.name}</div>
|
|
||||||
<div className="text-sm text-gray-500">ID: {folder.id}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm text-gray-900">{folder.total}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
||||||
{new Date(folder.createdAt).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
||||||
<Link href={`/folders/${folder.id}`}>
|
|
||||||
<LinkButton>
|
|
||||||
{t("folders.view")}
|
|
||||||
</LinkButton>
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-800 mb-2">
|
||||||
|
{user.displayUsername || user.username || t("anonymous")}
|
||||||
|
</h1>
|
||||||
|
{user.username && (
|
||||||
|
<p className="text-gray-600 text-sm mb-1">
|
||||||
|
@{user.username}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{user.bio && (
|
||||||
|
<p className="text-gray-700 mt-2 mb-2">
|
||||||
|
{user.bio}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap items-center gap-4 text-sm mt-3">
|
||||||
|
<span className="text-gray-500">
|
||||||
|
{t("joined")}: {new Date(user.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
{user.emailVerified && (
|
||||||
|
<span className="flex items-center text-green-600">
|
||||||
|
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.293 12.293a1 1 0 101.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{t("verified")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<FollowStats
|
||||||
|
userId={user.id}
|
||||||
|
initialFollowersCount={followersCount}
|
||||||
|
initialFollowingCount={followingCount}
|
||||||
|
initialIsFollowing={isFollowing}
|
||||||
|
currentUserId={session?.user?.id}
|
||||||
|
isOwnProfile={isOwnProfile}
|
||||||
|
username={user.username || user.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("accountInfo")}</h2>
|
||||||
|
<dl className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-gray-500">{t("userId")}</dt>
|
||||||
|
<dd className="mt-1 text-sm text-gray-900 font-mono break-all">{user.id}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-gray-500">{t("username")}</dt>
|
||||||
|
<dd className="mt-1 text-sm text-gray-900">
|
||||||
|
{user.username || <span className="text-gray-400">{t("notSet")}</span>}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-gray-500">{t("displayName")}</dt>
|
||||||
|
<dd className="mt-1 text-sm text-gray-900">
|
||||||
|
{user.displayUsername || <span className="text-gray-400">{t("notSet")}</span>}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-gray-500">{t("memberSince")}</dt>
|
||||||
|
<dd className="mt-1 text-sm text-gray-900">
|
||||||
|
{new Date(user.createdAt).toLocaleDateString()}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("decks.title")}</h2>
|
||||||
|
{decks.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-center py-8">{t("decks.noDecks")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t("decks.deckName")}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t("decks.totalCards")}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t("decks.createdAt")}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t("decks.actions")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{decks.map((deck) => (
|
||||||
|
<tr key={deck.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm font-medium text-gray-900">{deck.name}</div>
|
||||||
|
<div className="text-sm text-gray-500">ID: {deck.id}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-900">{deck.cardCount ?? 0}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{new Date(deck.createdAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<Link href={`/decks/${deck.id}`}>
|
||||||
|
<LinkButton>
|
||||||
|
{t("decks.view")}
|
||||||
|
</LinkButton>
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,15 +11,18 @@ 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 { actionGetFoldersByUserId, actionCreatePair } from "@/modules/folder/folder-action";
|
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
|
||||||
import { TSharedFolder } from "@/shared/folder-type";
|
import { actionCreateNote } from "@/modules/note/note-action";
|
||||||
|
import { actionCreateCard } from "@/modules/card/card-action";
|
||||||
|
import { actionGetNoteTypesByUserId, actionCreateDefaultBasicNoteType } from "@/modules/note-type/note-type-action";
|
||||||
|
import type { TSharedDeck } from "@/shared/anki-type";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface DictionaryClientProps {
|
interface DictionaryClientProps {
|
||||||
initialFolders: TSharedFolder[];
|
initialDecks: TSharedDeck[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
|
export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
|
||||||
const t = useTranslations("dictionary");
|
const t = useTranslations("dictionary");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -39,7 +42,9 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
|
|||||||
} = useDictionaryStore();
|
} = useDictionaryStore();
|
||||||
|
|
||||||
const { data: session } = authClient.useSession();
|
const { data: session } = authClient.useSession();
|
||||||
const [folders, setFolders] = useState<TSharedFolder[]>(initialFolders);
|
const [decks, setDecks] = useState<TSharedDeck[]>(initialDecks);
|
||||||
|
const [defaultNoteTypeId, setDefaultNoteTypeId] = useState<number | null>(null);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = searchParams.get("q") || undefined;
|
const q = searchParams.get("q") || undefined;
|
||||||
@@ -55,9 +60,31 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session?.user?.id) {
|
if (session?.user?.id) {
|
||||||
actionGetFoldersByUserId(session.user.id).then((result) => {
|
actionGetDecksByUserId(session.user.id).then((result) => {
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setFolders(result.data);
|
setDecks(result.data as TSharedDeck[]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [session?.user?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session?.user?.id) {
|
||||||
|
actionGetNoteTypesByUserId().then(async (result) => {
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const basicNoteType = result.data.find(
|
||||||
|
(nt) => nt.name === "Basic Vocabulary"
|
||||||
|
);
|
||||||
|
if (basicNoteType) {
|
||||||
|
setDefaultNoteTypeId(basicNoteType.id);
|
||||||
|
} else if (result.data.length > 0) {
|
||||||
|
setDefaultNoteTypeId(result.data[0].id);
|
||||||
|
} else {
|
||||||
|
const createResult = await actionCreateDefaultBasicNoteType();
|
||||||
|
if (createResult.success && createResult.data) {
|
||||||
|
setDefaultNoteTypeId(createResult.data.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -79,37 +106,73 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!session) {
|
if (!session) {
|
||||||
toast.error("Please login first");
|
toast.error(t("pleaseLogin"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (folders.length === 0) {
|
if (decks.length === 0) {
|
||||||
toast.error("Please create a folder first");
|
toast.error(t("pleaseCreateFolder"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!defaultNoteTypeId) {
|
||||||
|
toast.error("No note type available. Please try again.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderSelect = document.getElementById("folder-select") as HTMLSelectElement;
|
|
||||||
const folderId = folderSelect?.value ? Number(folderSelect.value) : folders[0]?.id;
|
|
||||||
|
|
||||||
if (!searchResult?.entries?.length) return;
|
if (!searchResult?.entries?.length) return;
|
||||||
|
|
||||||
|
const deckSelect = document.getElementById("deck-select") as HTMLSelectElement;
|
||||||
|
const deckId = deckSelect?.value ? Number(deckSelect.value) : decks[0]?.id;
|
||||||
|
|
||||||
|
if (!deckId) {
|
||||||
|
toast.error("No deck selected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
const definition = searchResult.entries
|
const definition = searchResult.entries
|
||||||
.map((e) => e.definition)
|
.map((e) => e.definition)
|
||||||
.join(" | ");
|
.join(" | ");
|
||||||
|
|
||||||
|
const ipa = searchResult.entries[0]?.ipa || "";
|
||||||
|
const example = searchResult.entries
|
||||||
|
.map((e) => e.example)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" | ") || "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await actionCreatePair({
|
const noteResult = await actionCreateNote({
|
||||||
text1: searchResult.standardForm,
|
noteTypeId: defaultNoteTypeId,
|
||||||
text2: definition,
|
fields: [searchResult.standardForm, definition, ipa, example],
|
||||||
language1: queryLang,
|
tags: ["dictionary"],
|
||||||
language2: definitionLang,
|
|
||||||
ipa1: searchResult.entries[0]?.ipa,
|
|
||||||
folderId: folderId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const folderName = folders.find((f) => f.id === folderId)?.name || "Unknown";
|
if (!noteResult.success || !noteResult.data) {
|
||||||
toast.success(`Saved to ${folderName}`);
|
toast.error(t("saveFailed"));
|
||||||
|
setIsSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noteId = BigInt(noteResult.data.id);
|
||||||
|
|
||||||
|
await actionCreateCard({
|
||||||
|
noteId,
|
||||||
|
deckId,
|
||||||
|
ord: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await actionCreateCard({
|
||||||
|
noteId,
|
||||||
|
deckId,
|
||||||
|
ord: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deckName = decks.find((d) => d.id === deckId)?.name || "Unknown";
|
||||||
|
toast.success(t("savedToFolder", { folderName: deckName }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Save failed");
|
console.error("Save error:", error);
|
||||||
|
toast.error(t("saveFailed"));
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -174,8 +237,8 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
|
|||||||
</div>
|
</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">No results found</p>
|
<p className="text-gray-800 text-xl">{t("noResults")}</p>
|
||||||
<p className="text-gray-600 mt-2">Try other words</p>
|
<p className="text-gray-600 mt-2">{t("tryOtherWords")}</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">
|
||||||
@@ -186,14 +249,14 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 ml-4">
|
<div className="flex items-center gap-2 ml-4">
|
||||||
{session && folders.length > 0 && (
|
{session && decks.length > 0 && (
|
||||||
<select
|
<select
|
||||||
id="folder-select"
|
id="deck-select"
|
||||||
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[#35786f]"
|
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[#35786f]"
|
||||||
>
|
>
|
||||||
{folders.map((folder) => (
|
{decks.map((deck) => (
|
||||||
<option key={folder.id} value={folder.id}>
|
<option key={deck.id} value={deck.id}>
|
||||||
{folder.name}
|
{deck.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -201,7 +264,9 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
|
|||||||
<LightButton
|
<LightButton
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
className="w-10 h-10 shrink-0"
|
className="w-10 h-10 shrink-0"
|
||||||
title="Save to folder"
|
title={t("saveToFolder")}
|
||||||
|
loading={isSaving}
|
||||||
|
disabled={isSaving}
|
||||||
>
|
>
|
||||||
<Plus />
|
<Plus />
|
||||||
</LightButton>
|
</LightButton>
|
||||||
@@ -223,7 +288,7 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
|
|||||||
loading={isSearching}
|
loading={isSearching}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
Re-lookup
|
{t("relookup")}
|
||||||
</LightButton>
|
</LightButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 { actionGetFoldersByUserId } from "@/modules/folder/folder-action";
|
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
|
||||||
import { TSharedFolder } from "@/shared/folder-type";
|
import type { TSharedDeck } from "@/shared/anki-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 folders: TSharedFolder[] = [];
|
let decks: TSharedDeck[] = [];
|
||||||
|
|
||||||
if (session?.user?.id) {
|
if (session?.user?.id) {
|
||||||
const result = await actionGetFoldersByUserId(session.user.id as string);
|
const result = await actionGetDecksByUserId(session.user.id as string);
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
folders = result.data;
|
decks = result.data as TSharedDeck[];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <DictionaryClient initialFolders={folders} />;
|
return <DictionaryClient initialDecks={decks} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Folder as Fd,
|
Layers,
|
||||||
Heart,
|
Heart,
|
||||||
Search,
|
Search,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
@@ -14,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 {
|
||||||
actionSearchPublicFolders,
|
actionSearchPublicDecks,
|
||||||
actionToggleFavorite,
|
actionToggleDeckFavorite,
|
||||||
actionCheckFavorite,
|
actionCheckDeckFavorite,
|
||||||
} from "@/modules/folder/folder-action";
|
} from "@/modules/deck/deck-action";
|
||||||
import { TPublicFolder } from "@/shared/folder-type";
|
import type { ActionOutputPublicDeck } from "@/modules/deck/deck-action-dto";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
interface PublicFolderCardProps {
|
interface PublicDeckCardProps {
|
||||||
folder: TPublicFolder;
|
deck: ActionOutputPublicDeck;
|
||||||
currentUserId?: string;
|
currentUserId?: string;
|
||||||
onUpdateFavorite: (folderId: number, isFavorited: boolean, favoriteCount: number) => void;
|
onUpdateFavorite: (deckId: number, isFavorited: boolean, favoriteCount: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFolderCardProps) => {
|
const PublicDeckCard = ({ deck, currentUserId, onUpdateFavorite }: PublicDeckCardProps) => {
|
||||||
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(folder.favoriteCount);
|
const [favoriteCount, setFavoriteCount] = useState(deck.favoriteCount);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentUserId) {
|
if (currentUserId) {
|
||||||
actionCheckFavorite(folder.id).then((result) => {
|
actionCheckDeckFavorite({ deckId: deck.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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [folder.id, currentUserId]);
|
}, [deck.id, currentUserId]);
|
||||||
|
|
||||||
const handleToggleFavorite = async (e: React.MouseEvent) => {
|
const handleToggleFavorite = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -50,11 +50,11 @@ const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFol
|
|||||||
toast.error(t("pleaseLogin"));
|
toast.error(t("pleaseLogin"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await actionToggleFavorite(folder.id);
|
const result = await actionToggleDeckFavorite({ deckId: deck.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(folder.id, result.data.isFavorited, result.data.favoriteCount);
|
onUpdateFavorite(deck.id, result.data.isFavorited, result.data.favoriteCount);
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message);
|
toast.error(result.message);
|
||||||
}
|
}
|
||||||
@@ -64,13 +64,13 @@ const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFol
|
|||||||
<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/${folder.id}`);
|
router.push(`/explore/${deck.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">
|
||||||
<Fd size={18} className="sm:hidden" />
|
<Layers size={18} className="sm:hidden" />
|
||||||
<Fd size={22} className="hidden sm:block" />
|
<Layers size={22} className="hidden sm:block" />
|
||||||
</div>
|
</div>
|
||||||
<CircleButton
|
<CircleButton
|
||||||
onClick={handleToggleFavorite}
|
onClick={handleToggleFavorite}
|
||||||
@@ -83,12 +83,12 @@ const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFol
|
|||||||
</CircleButton>
|
</CircleButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="font-semibold text-gray-900 truncate text-sm sm:text-base mb-1 sm:mb-2">{folder.name}</h3>
|
<h3 className="font-semibold text-gray-900 truncate text-sm sm:text-base mb-1 sm:mb-2">{deck.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("folderInfo", {
|
{t("deckInfo", {
|
||||||
userName: folder.userName ?? folder.userUsername ?? t("unknownUser"),
|
userName: deck.userName ?? deck.userUsername ?? t("unknownUser"),
|
||||||
totalPairs: folder.totalPairs,
|
cardCount: deck.cardCount ?? 0,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -101,13 +101,13 @@ const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFol
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface ExploreClientProps {
|
interface ExploreClientProps {
|
||||||
initialPublicFolders: TPublicFolder[];
|
initialPublicDecks: ActionOutputPublicDeck[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
|
export function ExploreClient({ initialPublicDecks }: ExploreClientProps) {
|
||||||
const t = useTranslations("explore");
|
const t = useTranslations("explore");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [publicFolders, setPublicFolders] = useState<TPublicFolder[]>(initialPublicFolders);
|
const [publicDecks, setPublicDecks] = useState<ActionOutputPublicDeck[]>(initialPublicDecks);
|
||||||
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);
|
||||||
@@ -117,13 +117,13 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
|
|||||||
|
|
||||||
const handleSearch = async () => {
|
const handleSearch = async () => {
|
||||||
if (!searchQuery.trim()) {
|
if (!searchQuery.trim()) {
|
||||||
setPublicFolders(initialPublicFolders);
|
setPublicDecks(initialPublicDecks);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const result = await actionSearchPublicFolders(searchQuery.trim());
|
const result = await actionSearchPublicDecks({ query: searchQuery.trim() });
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setPublicFolders(result.data);
|
setPublicDecks(result.data);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
@@ -132,14 +132,14 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
|
|||||||
setSortByFavorites((prev) => !prev);
|
setSortByFavorites((prev) => !prev);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortedFolders = sortByFavorites
|
const sortedDecks = sortByFavorites
|
||||||
? [...publicFolders].sort((a, b) => b.favoriteCount - a.favoriteCount)
|
? [...publicDecks].sort((a, b) => b.favoriteCount - a.favoriteCount)
|
||||||
: publicFolders;
|
: publicDecks;
|
||||||
|
|
||||||
const handleUpdateFavorite = (folderId: number, _isFavorited: boolean, favoriteCount: number) => {
|
const handleUpdateFavorite = (deckId: number, _isFavorited: boolean, favoriteCount: number) => {
|
||||||
setPublicFolders((prev) =>
|
setPublicDecks((prev) =>
|
||||||
prev.map((f) =>
|
prev.map((d) =>
|
||||||
f.id === folderId ? { ...f, favoriteCount } : f
|
d.id === deckId ? { ...d, favoriteCount } : d
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -177,19 +177,19 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
|
|||||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
|
<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>
|
||||||
) : sortedFolders.length === 0 ? (
|
) : sortedDecks.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">
|
||||||
<Fd size={24} className="text-gray-400" />
|
<Layers size={24} className="text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm">{t("noFolders")}</p>
|
<p className="text-sm">{t("noDecks")}</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">
|
||||||
{sortedFolders.map((folder) => (
|
{sortedDecks.map((deck) => (
|
||||||
<PublicFolderCard
|
<PublicDeckCard
|
||||||
key={folder.id}
|
key={deck.id}
|
||||||
folder={folder}
|
deck={deck}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
onUpdateFavorite={handleUpdateFavorite}
|
onUpdateFavorite={handleUpdateFavorite}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Folder as Fd, Heart, ExternalLink, ArrowLeft } from "lucide-react";
|
import { Layers, 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 {
|
||||||
actionToggleFavorite,
|
actionToggleDeckFavorite,
|
||||||
actionCheckFavorite,
|
actionCheckDeckFavorite,
|
||||||
} from "@/modules/folder/folder-action";
|
} from "@/modules/deck/deck-action";
|
||||||
import { ActionOutputPublicFolder } from "@/modules/folder/folder-action-dto";
|
import type { ActionOutputPublicDeck } from "@/modules/deck/deck-action-dto";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
interface ExploreDetailClientProps {
|
interface ExploreDetailClientProps {
|
||||||
folder: ActionOutputPublicFolder;
|
deck: ActionOutputPublicDeck;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExploreDetailClient({ folder }: ExploreDetailClientProps) {
|
export function ExploreDetailClient({ deck }: 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(folder.favoriteCount);
|
const [favoriteCount, setFavoriteCount] = useState(deck.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) {
|
||||||
actionCheckFavorite(folder.id).then((result) => {
|
actionCheckDeckFavorite({ deckId: deck.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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [folder.id, currentUserId]);
|
}, [deck.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 actionToggleFavorite(folder.id);
|
const result = await actionToggleDeckFavorite({ deckId: deck.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({ folder }: 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">
|
||||||
<Fd size={28} className="sm:w-8 sm:h-8" />
|
<Layers 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">
|
||||||
{folder.name}
|
{deck.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: folder.userName ?? folder.userUsername ?? t("unknownUser"),
|
name: deck.userName ?? deck.userUsername ?? t("unknownUser"),
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,13 +104,19 @@ export function ExploreDetailClient({ folder }: 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">
|
||||||
{folder.totalPairs}
|
{deck.cardCount ?? 0}
|
||||||
</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("totalPairs")}
|
{t("totalCards")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center border-x border-gray-100">
|
<div className="text-center border-x border-gray-100">
|
||||||
@@ -124,7 +130,7 @@ export function ExploreDetailClient({ folder }: 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(folder.createdAt)}
|
{formatDate(deck.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")}
|
||||||
@@ -133,7 +139,7 @@ export function ExploreDetailClient({ folder }: ExploreDetailClientProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href={`/folders/${folder.id}`}
|
href={`/decks/${deck.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} />
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { ExploreDetailClient } from "./ExploreDetailClient";
|
import { ExploreDetailClient } from "./ExploreDetailClient";
|
||||||
import { actionGetPublicFolderById } from "@/modules/folder/folder-action";
|
import { actionGetPublicDeckById } from "@/modules/deck/deck-action";
|
||||||
|
|
||||||
export default async function ExploreFolderPage({
|
export default async function ExploreDeckPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -13,11 +13,11 @@ export default async function ExploreFolderPage({
|
|||||||
redirect("/explore");
|
redirect("/explore");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await actionGetPublicFolderById(Number(id));
|
const result = await actionGetPublicDeckById({ deckId: Number(id) });
|
||||||
|
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
redirect("/explore");
|
redirect("/explore");
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ExploreDetailClient folder={result.data} />;
|
return <ExploreDetailClient deck={result.data} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { ExploreClient } from "./ExploreClient";
|
import { ExploreClient } from "./ExploreClient";
|
||||||
import { actionGetPublicFolders } from "@/modules/folder/folder-action";
|
import { actionGetPublicDecks } from "@/modules/deck/deck-action";
|
||||||
|
|
||||||
export default async function ExplorePage() {
|
export default async function ExplorePage() {
|
||||||
const publicFoldersResult = await actionGetPublicFolders();
|
const publicDecksResult = await actionGetPublicDecks();
|
||||||
const publicFolders = publicFoldersResult.success ? publicFoldersResult.data ?? [] : [];
|
const publicDecks = publicDecksResult.success ? publicDecksResult.data ?? [] : [];
|
||||||
|
|
||||||
return <ExploreClient initialPublicFolders={publicFolders} />;
|
return <ExploreClient initialPublicDecks={publicDecks} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,33 +2,22 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Folder as Fd,
|
Layers as DeckIcon,
|
||||||
Heart,
|
Heart,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { 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 { actionGetUserFavorites, actionToggleFavorite } from "@/modules/folder/folder-action";
|
import { actionGetUserFavoriteDecks, actionToggleDeckFavorite } from "@/modules/deck/deck-action";
|
||||||
|
import type { ActionOutputUserFavoriteDeck } from "@/modules/deck/deck-action-dto";
|
||||||
type UserFavorite = {
|
|
||||||
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: UserFavorite;
|
favorite: ActionOutputUserFavoriteDeck;
|
||||||
onRemoveFavorite: (folderId: number) => void;
|
onRemoveFavorite: (deckId: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
|
const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
|
||||||
@@ -41,9 +30,9 @@ const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
|
|||||||
if (isRemoving) return;
|
if (isRemoving) return;
|
||||||
|
|
||||||
setIsRemoving(true);
|
setIsRemoving(true);
|
||||||
const result = await actionToggleFavorite(favorite.folderId);
|
const result = await actionToggleDeckFavorite({ deckId: favorite.id });
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
onRemoveFavorite(favorite.folderId);
|
onRemoveFavorite(favorite.id);
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message);
|
toast.error(result.message);
|
||||||
}
|
}
|
||||||
@@ -54,20 +43,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.folderId}`);
|
router.push(`/explore/${favorite.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">
|
||||||
<Fd size={24} />
|
<DeckIcon 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.folderName}</h3>
|
<h3 className="font-semibold text-gray-900 truncate">{favorite.name}</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.folderOwnerName ?? favorite.folderOwnerUsername ?? t("unknownUser"),
|
userName: favorite.userName ?? favorite.userUsername ?? t("unknownUser"),
|
||||||
totalPairs: favorite.folderTotalPairs,
|
totalPairs: favorite.cardCount ?? 0,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,29 +75,25 @@ const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface FavoritesClientProps {
|
interface FavoritesClientProps {
|
||||||
userId: string;
|
initialFavorites: ActionOutputUserFavoriteDeck[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FavoritesClient({ userId }: FavoritesClientProps) {
|
export function FavoritesClient({ initialFavorites }: FavoritesClientProps) {
|
||||||
const t = useTranslations("favorites");
|
const t = useTranslations("favorites");
|
||||||
const [favorites, setFavorites] = useState<UserFavorite[]>([]);
|
const [favorites, setFavorites] = useState<ActionOutputUserFavoriteDeck[]>(initialFavorites);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadFavorites();
|
|
||||||
}, [userId]);
|
|
||||||
|
|
||||||
const loadFavorites = async () => {
|
const loadFavorites = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const result = await actionGetUserFavorites();
|
const result = await actionGetUserFavoriteDecks();
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setFavorites(result.data);
|
setFavorites(result.data);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveFavorite = (folderId: number) => {
|
const handleRemoveFavorite = (deckId: number) => {
|
||||||
setFavorites((prev) => prev.filter((f) => f.folderId !== folderId));
|
setFavorites((prev) => prev.filter((f) => f.id !== deckId));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ 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() });
|
||||||
@@ -10,5 +12,11 @@ export default async function FavoritesPage() {
|
|||||||
redirect("/login?redirect=/favorites");
|
redirect("/login?redirect=/favorites");
|
||||||
}
|
}
|
||||||
|
|
||||||
return <FavoritesClient userId={session.user.id} />;
|
let favorites: ActionOutputUserFavoriteDeck[] = [];
|
||||||
|
const result = await actionGetUserFavoriteDecks();
|
||||||
|
if (result.success && result.data) {
|
||||||
|
favorites = result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <FavoritesClient initialFavorites={favorites} />;
|
||||||
}
|
}
|
||||||
|
|||||||
114
src/app/(features)/memorize/DeckSelector.tsx
Normal file
114
src/app/(features)/memorize/DeckSelector.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Layers } from "lucide-react";
|
||||||
|
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
||||||
|
import type { ActionOutputCardStats } from "@/modules/card/card-action-dto";
|
||||||
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
|
import { PrimaryButton } from "@/design-system/base/button";
|
||||||
|
|
||||||
|
interface DeckWithStats extends ActionOutputDeck {
|
||||||
|
stats?: ActionOutputCardStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeckSelectorProps {
|
||||||
|
decks: ActionOutputDeck[];
|
||||||
|
deckStats: Map<number, ActionOutputCardStats | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeckSelector: React.FC<DeckSelectorProps> = ({ decks, deckStats }) => {
|
||||||
|
const t = useTranslations("memorize.deck_selector");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const formatCardStats = (stats: ActionOutputCardStats | undefined): string => {
|
||||||
|
if (!stats) return t("noCards");
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (stats.new > 0) parts.push(`${t("new")}: ${stats.new}`);
|
||||||
|
if (stats.learning > 0) parts.push(`${t("learning")}: ${stats.learning}`);
|
||||||
|
if (stats.review > 0) parts.push(`${t("review")}: ${stats.review}`);
|
||||||
|
if (stats.due > 0) parts.push(`${t("due")}: ${stats.due}`);
|
||||||
|
return parts.length > 0 ? parts.join(" • ") : t("noCards");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDueCount = (deckId: number): number => {
|
||||||
|
const stats = deckStats.get(deckId);
|
||||||
|
return stats?.due ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
{decks.length === 0 ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-4">
|
||||||
|
{t("noDecks")}
|
||||||
|
</h1>
|
||||||
|
<Link href="/decks">
|
||||||
|
<PrimaryButton className="px-6 py-2">
|
||||||
|
{t("goToDecks")}
|
||||||
|
</PrimaryButton>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-6">
|
||||||
|
{t("selectDeck")}
|
||||||
|
</h1>
|
||||||
|
<div className="border border-gray-200 rounded-lg max-h-96 overflow-y-auto">
|
||||||
|
{decks
|
||||||
|
.toSorted((a, b) => a.id - b.id)
|
||||||
|
.map((deck) => {
|
||||||
|
const stats = deckStats.get(deck.id);
|
||||||
|
const dueCount = getDueCount(deck.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={deck.id}
|
||||||
|
onClick={() =>
|
||||||
|
router.push(`/memorize?deck_id=${deck.id}`)
|
||||||
|
}
|
||||||
|
className="flex flex-row items-center p-4 gap-3 hover:cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0"
|
||||||
|
>
|
||||||
|
<div className="shrink-0">
|
||||||
|
<Layers className="text-gray-600 w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-gray-900">
|
||||||
|
{deck.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{formatCardStats(stats)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{dueCount > 0 && (
|
||||||
|
<div className="bg-blue-500 text-white text-xs font-bold px-2 py-1 rounded-full">
|
||||||
|
{dueCount}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-gray-400">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { DeckSelector };
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
"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 };
|
|
||||||
@@ -1,192 +1,368 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect, useTransition, useCallback } 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 { useTranslations } from "next-intl";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
import { isNonNegativeInteger, SeededRandom } from "@/utils/random";
|
import { Layers, Check, Clock, Sparkles } from "lucide-react";
|
||||||
import { TSharedPair } from "@/shared/folder-type";
|
import type { ActionOutputCardWithNote, ActionOutputScheduledCard } from "@/modules/card/card-action-dto";
|
||||||
|
import { actionGetCardsForReview, actionAnswerCard } from "@/modules/card/card-action";
|
||||||
import { PageLayout } from "@/components/ui/PageLayout";
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
|
import { LightButton } from "@/design-system/base/button";
|
||||||
|
import { CardType } from "../../../../generated/prisma/enums";
|
||||||
|
import { calculatePreviewIntervals, formatPreviewInterval, type CardPreview } from "./interval-preview";
|
||||||
|
|
||||||
const myFont = localFont({
|
const myFont = localFont({
|
||||||
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
|
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
|
||||||
});
|
});
|
||||||
|
|
||||||
interface MemorizeProps {
|
interface MemorizeProps {
|
||||||
textPairs: TSharedPair[];
|
deckId: number;
|
||||||
|
deckName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
type ReviewEase = 1 | 2 | 3 | 4;
|
||||||
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) {
|
const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
|
||||||
|
const t = useTranslations("memorize.review");
|
||||||
|
const router = useRouter();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const [cards, setCards] = useState<ActionOutputCardWithNote[]>([]);
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [showAnswer, setShowAnswer] = useState(false);
|
||||||
|
const [lastScheduled, setLastScheduled] = useState<ActionOutputScheduledCard | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let ignore = false;
|
||||||
|
|
||||||
|
const loadCards = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await actionGetCardsForReview({ deckId, limit: 50 });
|
||||||
|
if (!ignore) {
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setCards(result.data);
|
||||||
|
setCurrentIndex(0);
|
||||||
|
setShowAnswer(false);
|
||||||
|
setLastScheduled(null);
|
||||||
|
} else {
|
||||||
|
setError(result.message);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCards();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ignore = true;
|
||||||
|
};
|
||||||
|
}, [deckId]);
|
||||||
|
|
||||||
|
const getCurrentCard = (): ActionOutputCardWithNote | null => {
|
||||||
|
return cards[currentIndex] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNoteFields = (card: ActionOutputCardWithNote): string[] => {
|
||||||
|
return card.note.flds.split('\x1f');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowAnswer = useCallback(() => {
|
||||||
|
setShowAnswer(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAnswer = useCallback((ease: ReviewEase) => {
|
||||||
|
const card = getCurrentCard();
|
||||||
|
if (!card) return;
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await actionAnswerCard({
|
||||||
|
cardId: BigInt(card.id),
|
||||||
|
ease,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setLastScheduled(result.data.scheduled);
|
||||||
|
|
||||||
|
const remainingCards = cards.filter((_, idx) => idx !== currentIndex);
|
||||||
|
setCards(remainingCards);
|
||||||
|
|
||||||
|
if (remainingCards.length === 0) {
|
||||||
|
setCurrentIndex(0);
|
||||||
|
} else if (currentIndex >= remainingCards.length) {
|
||||||
|
setCurrentIndex(remainingCards.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowAnswer(false);
|
||||||
|
} else {
|
||||||
|
setError(result.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [cards, currentIndex]);
|
||||||
|
|
||||||
|
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 === "1") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAnswer(1);
|
||||||
|
} else if (e.key === "2") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAnswer(2);
|
||||||
|
} else if (e.key === "3" || e.key === " " || e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAnswer(3);
|
||||||
|
} else if (e.key === "4") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAnswer(4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [showAnswer, handleShowAnswer, handleAnswer]);
|
||||||
|
|
||||||
|
const formatNextReview = (scheduled: ActionOutputScheduledCard): string => {
|
||||||
|
const now = new Date();
|
||||||
|
const nextReview = new Date(scheduled.nextReviewDate);
|
||||||
|
const diffMs = nextReview.getTime() - now.getTime();
|
||||||
|
|
||||||
|
if (diffMs < 0) return t("now");
|
||||||
|
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return t("lessThanMinute");
|
||||||
|
if (diffMins < 60) return t("inMinutes", { count: diffMins });
|
||||||
|
if (diffHours < 24) return t("inHours", { count: diffHours });
|
||||||
|
if (diffDays < 30) return t("inDays", { count: diffDays });
|
||||||
|
return t("inMonths", { count: Math.floor(diffDays / 30) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatInterval = (ivl: number): string => {
|
||||||
|
if (ivl < 1) return t("minutes");
|
||||||
|
if (ivl < 30) return t("days", { count: ivl });
|
||||||
|
return t("months", { count: Math.floor(ivl / 30) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCardTypeLabel = (type: CardType): string => {
|
||||||
|
switch (type) {
|
||||||
|
case CardType.NEW:
|
||||||
|
return t("cardTypeNew");
|
||||||
|
case CardType.LEARNING:
|
||||||
|
return t("cardTypeLearning");
|
||||||
|
case CardType.REVIEW:
|
||||||
|
return t("cardTypeReview");
|
||||||
|
case CardType.RELEARNING:
|
||||||
|
return t("cardTypeRelearning");
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCardTypeColor = (type: CardType): string => {
|
||||||
|
switch (type) {
|
||||||
|
case CardType.NEW:
|
||||||
|
return "bg-blue-100 text-blue-700";
|
||||||
|
case CardType.LEARNING:
|
||||||
|
return "bg-yellow-100 text-yellow-700";
|
||||||
|
case CardType.REVIEW:
|
||||||
|
return "bg-green-100 text-green-700";
|
||||||
|
case CardType.RELEARNING:
|
||||||
|
return "bg-purple-100 text-purple-700";
|
||||||
|
default:
|
||||||
|
return "bg-gray-100 text-gray-700";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<p className="text-gray-700 text-center">{t("noTextPairs")}</p>
|
<div className="text-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-600">{t("loading")}</p>
|
||||||
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rng = new SeededRandom(textPairs[0].folderId);
|
if (error) {
|
||||||
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 (
|
return (
|
||||||
<div className="text-gray-900 text-xl md:text-2xl p-6 h-[20dvh] overflow-y-auto text-center">
|
<PageLayout>
|
||||||
{text}
|
<div className="text-center py-12">
|
||||||
</div>
|
<p className="text-red-600 mb-4">{error}</p>
|
||||||
|
<LightButton onClick={() => router.push("/memorize")} className="px-4 py-2">
|
||||||
|
{t("backToDecks")}
|
||||||
|
</LightButton>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
const [text1, text2] = reverse
|
if (cards.length === 0) {
|
||||||
? [getTextPairs()[index].text2, getTextPairs()[index].text1]
|
return (
|
||||||
: [getTextPairs()[index].text1, getTextPairs()[index].text2];
|
<PageLayout>
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-green-500 mb-4">
|
||||||
|
<Check className="w-16 h-16 mx-auto" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-800 mb-2">{t("allDone")}</h2>
|
||||||
|
<p className="text-gray-600 mb-6">{t("allDoneDesc")}</p>
|
||||||
|
<LightButton onClick={() => router.push("/memorize")} className="px-4 py-2">
|
||||||
|
{t("backToDecks")}
|
||||||
|
</LightButton>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCard = getCurrentCard()!;
|
||||||
|
const fields = getNoteFields(currentCard);
|
||||||
|
const front = fields[0] ?? "";
|
||||||
|
const back = fields[1] ?? "";
|
||||||
|
|
||||||
|
const cardPreview: CardPreview = {
|
||||||
|
type: currentCard.type,
|
||||||
|
ivl: currentCard.ivl,
|
||||||
|
factor: currentCard.factor,
|
||||||
|
left: currentCard.left,
|
||||||
|
};
|
||||||
|
const previewIntervals = calculatePreviewIntervals(cardPreview);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
{/* 进度指示器 */}
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex justify-center mb-4">
|
<div className="flex items-center gap-2 text-gray-600">
|
||||||
<LinkButton onClick={handleIndexClick} className="text-sm">
|
<Layers className="w-5 h-5" />
|
||||||
{index + 1} / {getTextPairs().length}
|
<span className="font-medium">{deckName}</span>
|
||||||
</LinkButton>
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full ${getCardTypeColor(currentCard.type)}`}>
|
||||||
|
{getCardTypeLabel(currentCard.type)}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{t("progress", { current: currentIndex + 1, total: cards.length + currentIndex })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 文本显示区域 */}
|
<div className="w-full bg-gray-200 rounded-full h-2 mb-6">
|
||||||
<div className={`h-[40dvh] ${myFont.className} mb-4`}>
|
<div
|
||||||
{(() => {
|
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||||
if (dictation) {
|
style={{ width: `${Math.max(0, ((currentIndex) / (cards.length + currentIndex)) * 100)}%` }}
|
||||||
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>
|
||||||
|
|
||||||
{/* 底部按钮 */}
|
{lastScheduled && (
|
||||||
<div className="flex flex-row gap-2 items-center justify-center flex-wrap">
|
<div className="mb-4 p-3 bg-gray-50 rounded-lg text-sm text-gray-600">
|
||||||
<LightButton
|
<div className="flex items-center gap-2">
|
||||||
onClick={handleNext}
|
<Clock className="w-4 h-4" />
|
||||||
className="px-4 py-2 rounded-full text-sm"
|
<span>
|
||||||
>
|
{t("nextReview")}: {formatNextReview(lastScheduled)}
|
||||||
{show === "question" ? t("answer") : t("next")}
|
</span>
|
||||||
</LightButton>
|
</div>
|
||||||
<LightButton
|
</div>
|
||||||
onClick={handlePrevious}
|
)}
|
||||||
className="px-4 py-2 rounded-full text-sm"
|
|
||||||
>
|
<div className={`bg-white border border-gray-200 rounded-xl shadow-sm mb-6 ${myFont.className}`}>
|
||||||
{t("previous")}
|
<div className="p-8 min-h-[20dvh] flex items-center justify-center">
|
||||||
</LightButton>
|
<div className="text-gray-900 text-xl md:text-2xl text-center">
|
||||||
<CircleToggleButton
|
{front}
|
||||||
selected={reverse}
|
</div>
|
||||||
onClick={toggleReverse}
|
</div>
|
||||||
>
|
|
||||||
{t("reverse")}
|
{showAnswer && (
|
||||||
</CircleToggleButton>
|
<>
|
||||||
<CircleToggleButton
|
<div className="border-t border-gray-200" />
|
||||||
selected={dictation}
|
<div className="p-8 min-h-[20dvh] flex items-center justify-center bg-gray-50 rounded-b-xl">
|
||||||
onClick={toggleDictation}
|
<div className="text-gray-900 text-xl md:text-2xl text-center">
|
||||||
>
|
{back}
|
||||||
{t("dictation")}
|
</div>
|
||||||
</CircleToggleButton>
|
</div>
|
||||||
<CircleToggleButton
|
</>
|
||||||
selected={disorder}
|
)}
|
||||||
onClick={toggleDisorder}
|
</div>
|
||||||
>
|
|
||||||
{t("disorder")}
|
<div className="flex justify-center gap-4 mb-6 text-sm text-gray-500">
|
||||||
</CircleToggleButton>
|
<span>{t("interval")}: {formatInterval(currentCard.ivl)}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{t("ease")}: {currentCard.factor / 10}%</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{t("lapses")}: {currentCard.lapses}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center">
|
||||||
|
{!showAnswer ? (
|
||||||
|
<LightButton
|
||||||
|
onClick={handleShowAnswer}
|
||||||
|
disabled={isPending}
|
||||||
|
className="px-8 py-3 text-lg rounded-full"
|
||||||
|
>
|
||||||
|
{t("showAnswer")}
|
||||||
|
<span className="ml-2 text-xs opacity-60">Space</span>
|
||||||
|
</LightButton>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleAnswer(1)}
|
||||||
|
disabled={isPending}
|
||||||
|
className="flex flex-col items-center px-5 py-3 rounded-xl bg-red-100 hover:bg-red-200 text-red-700 transition-colors disabled:opacity-50 min-w-[80px]"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{t("again")}</span>
|
||||||
|
<span className="text-xs opacity-75">{formatPreviewInterval(previewIntervals.again)}</span>
|
||||||
|
<span className="text-xs opacity-50 mt-1">1</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleAnswer(2)}
|
||||||
|
disabled={isPending}
|
||||||
|
className="flex flex-col items-center px-5 py-3 rounded-xl bg-orange-100 hover:bg-orange-200 text-orange-700 transition-colors disabled:opacity-50 min-w-[80px]"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{t("hard")}</span>
|
||||||
|
<span className="text-xs opacity-75">{formatPreviewInterval(previewIntervals.hard)}</span>
|
||||||
|
<span className="text-xs opacity-50 mt-1">2</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleAnswer(3)}
|
||||||
|
disabled={isPending}
|
||||||
|
className="flex flex-col items-center px-5 py-3 rounded-xl bg-green-100 hover:bg-green-200 text-green-700 transition-colors disabled:opacity-50 min-w-[80px] ring-2 ring-green-300"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="font-medium">{t("good")}</span>
|
||||||
|
<Sparkles className="w-3 h-3 opacity-60" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs opacity-75">{formatPreviewInterval(previewIntervals.good)}</span>
|
||||||
|
<span className="text-xs opacity-50 mt-1">3/Space</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleAnswer(4)}
|
||||||
|
disabled={isPending}
|
||||||
|
className="flex flex-col items-center px-5 py-3 rounded-xl bg-blue-100 hover:bg-blue-200 text-blue-700 transition-colors disabled:opacity-50 min-w-[80px]"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{t("easy")}</span>
|
||||||
|
<span className="text-xs opacity-75">{formatPreviewInterval(previewIntervals.easy)}</span>
|
||||||
|
<span className="text-xs opacity-50 mt-1">4</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
85
src/app/(features)/memorize/interval-preview.ts
Normal file
85
src/app/(features)/memorize/interval-preview.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { CardType } from "../../../../generated/prisma/enums";
|
||||||
|
import { SM2_CONFIG } from "@/modules/card/card-service-dto";
|
||||||
|
|
||||||
|
export interface CardPreview {
|
||||||
|
type: CardType;
|
||||||
|
ivl: number;
|
||||||
|
factor: number;
|
||||||
|
left: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewIntervals {
|
||||||
|
again: number;
|
||||||
|
hard: number;
|
||||||
|
good: number;
|
||||||
|
easy: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateReviewIntervals(ivl: number, factor: number): PreviewIntervals {
|
||||||
|
const MINUTES_PER_DAY = 1440;
|
||||||
|
return {
|
||||||
|
again: Math.max(1, Math.floor(ivl * SM2_CONFIG.NEW_INTERVAL)) * MINUTES_PER_DAY,
|
||||||
|
hard: Math.floor(ivl * SM2_CONFIG.HARD_INTERVAL * SM2_CONFIG.INTERVAL_MODIFIER) * MINUTES_PER_DAY,
|
||||||
|
good: Math.floor(ivl * (factor / 1000) * SM2_CONFIG.INTERVAL_MODIFIER) * MINUTES_PER_DAY,
|
||||||
|
easy: Math.floor(ivl * (factor / 1000) * SM2_CONFIG.EASY_BONUS * SM2_CONFIG.INTERVAL_MODIFIER) * MINUTES_PER_DAY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateNewCardIntervals(): PreviewIntervals {
|
||||||
|
const steps = SM2_CONFIG.LEARNING_STEPS;
|
||||||
|
|
||||||
|
return {
|
||||||
|
again: steps[0],
|
||||||
|
hard: steps.length >= 2 ? (steps[0] + steps[1]) / 2 : steps[0],
|
||||||
|
good: steps.length >= 2 ? steps[1] : SM2_CONFIG.GRADUATING_INTERVAL_GOOD * 1440,
|
||||||
|
easy: SM2_CONFIG.EASY_INTERVAL * 1440,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateLearningIntervals(left: number, isRelearning: boolean): PreviewIntervals {
|
||||||
|
const steps = isRelearning ? SM2_CONFIG.RELEARNING_STEPS : SM2_CONFIG.LEARNING_STEPS;
|
||||||
|
const stepIndex = Math.floor(left % 1000);
|
||||||
|
|
||||||
|
const again = steps[0] ?? 1;
|
||||||
|
|
||||||
|
let hard: number;
|
||||||
|
if (stepIndex === 0 && steps.length >= 2) {
|
||||||
|
const step0 = steps[0] ?? 1;
|
||||||
|
const step1 = steps[1] ?? step0;
|
||||||
|
hard = (step0 + step1) / 2;
|
||||||
|
} else {
|
||||||
|
hard = steps[stepIndex] ?? steps[0] ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let good: number;
|
||||||
|
if (stepIndex < steps.length - 1) {
|
||||||
|
good = steps[stepIndex + 1] ?? steps[0] ?? 1;
|
||||||
|
} else {
|
||||||
|
good = SM2_CONFIG.GRADUATING_INTERVAL_GOOD * 1440;
|
||||||
|
}
|
||||||
|
|
||||||
|
const easy = SM2_CONFIG.GRADUATING_INTERVAL_EASY * 1440;
|
||||||
|
|
||||||
|
return { again, hard, good, easy };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculatePreviewIntervals(card: CardPreview): PreviewIntervals {
|
||||||
|
switch (card.type) {
|
||||||
|
case CardType.NEW:
|
||||||
|
return calculateNewCardIntervals();
|
||||||
|
case CardType.LEARNING:
|
||||||
|
return calculateLearningIntervals(card.left, false);
|
||||||
|
case CardType.RELEARNING:
|
||||||
|
return calculateLearningIntervals(card.left, true);
|
||||||
|
case CardType.REVIEW:
|
||||||
|
default:
|
||||||
|
return calculateReviewIntervals(card.ivl, card.factor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPreviewInterval(minutes: number): string {
|
||||||
|
if (minutes < 1) return "<1";
|
||||||
|
if (minutes < 60) return `${Math.round(minutes)}`;
|
||||||
|
if (minutes < 1440) return `${Math.round(minutes / 60)}h`;
|
||||||
|
return `${Math.round(minutes / 1440)}d`;
|
||||||
|
}
|
||||||
@@ -1,37 +1,57 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { isNonNegativeInteger } from "@/utils/random";
|
import { isNonNegativeInteger } from "@/utils/random";
|
||||||
import { FolderSelector } from "./FolderSelector";
|
import { DeckSelector } from "./DeckSelector";
|
||||||
import { Memorize } from "./Memorize";
|
import { Memorize } from "./Memorize";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { actionGetFoldersWithTotalPairsByUserId, actionGetPairsByFolderId } from "@/modules/folder/folder-action";
|
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
|
||||||
|
import { actionGetCardStats } from "@/modules/card/card-action";
|
||||||
|
|
||||||
export default async function MemorizePage({
|
export default async function MemorizePage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ folder_id?: string; }>;
|
searchParams: Promise<{ deck_id?: string; }>;
|
||||||
}) {
|
}) {
|
||||||
const tParam = (await searchParams).folder_id;
|
const deckIdParam = (await searchParams).deck_id;
|
||||||
|
|
||||||
const t = await getTranslations("memorize.page");
|
const t = await getTranslations("memorize.page");
|
||||||
|
|
||||||
const folder_id = tParam
|
const deckId = deckIdParam
|
||||||
? isNonNegativeInteger(tParam)
|
? isNonNegativeInteger(deckIdParam)
|
||||||
? parseInt(tParam)
|
? parseInt(deckIdParam)
|
||||||
: null
|
: null
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!folder_id) {
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
if (!session) redirect("/login?redirect=/memorize");
|
||||||
if (!session) redirect("/login?redirect=/memorize");
|
|
||||||
|
if (!deckId) {
|
||||||
|
const decksResult = await actionGetDecksByUserId(session.user.id);
|
||||||
|
const decks = decksResult.data ?? [];
|
||||||
|
|
||||||
|
const deckStats = new Map<number, Awaited<ReturnType<typeof actionGetCardStats>>["data"]>();
|
||||||
|
for (const deck of decks) {
|
||||||
|
const statsResult = await actionGetCardStats({ deckId: deck.id });
|
||||||
|
if (statsResult.success && statsResult.data) {
|
||||||
|
deckStats.set(deck.id, statsResult.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FolderSelector
|
<DeckSelector
|
||||||
folders={(await actionGetFoldersWithTotalPairsByUserId(session.user.id)).data!}
|
decks={decks}
|
||||||
|
deckStats={deckStats}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Memorize textPairs={(await actionGetPairsByFolderId(folder_id)).data!} />;
|
const decksResult = await actionGetDecksByUserId(session.user.id);
|
||||||
|
const deck = decksResult.data?.find(d => d.id === deckId);
|
||||||
|
|
||||||
|
if (!deck) {
|
||||||
|
redirect("/memorize");
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Memorize deckId={deckId} deckName={deck.name} />;
|
||||||
}
|
}
|
||||||
|
|||||||
286
src/app/(features)/ocr/OCRClient.tsx
Normal file
286
src/app/(features)/ocr/OCRClient.tsx
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
|
import { PrimaryButton, LightButton } from "@/design-system/base/button";
|
||||||
|
import { Input } from "@/design-system/base/input";
|
||||||
|
import { Select } from "@/design-system/base/select";
|
||||||
|
import { Card } from "@/design-system/base/card";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Upload, FileImage, Loader2 } from "lucide-react";
|
||||||
|
import { actionProcessOCR } from "@/modules/ocr/ocr-action";
|
||||||
|
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
||||||
|
|
||||||
|
interface ActionOutputProcessOCR {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data?: {
|
||||||
|
pairsCreated: number;
|
||||||
|
sourceLanguage?: string;
|
||||||
|
targetLanguage?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OCRClientProps {
|
||||||
|
initialDecks: ActionOutputDeck[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OCRClient({ initialDecks }: OCRClientProps) {
|
||||||
|
const t = useTranslations("ocr");
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [decks, setDecks] = useState<ActionOutputDeck[]>(initialDecks);
|
||||||
|
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [selectedDeckId, setSelectedDeckId] = useState<number | null>(
|
||||||
|
initialDecks.length > 0 ? initialDecks[0].id : null
|
||||||
|
);
|
||||||
|
const [sourceLanguage, setSourceLanguage] = useState<string>("");
|
||||||
|
const [targetLanguage, setTargetLanguage] = useState<string>("");
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [ocrResult, setOcrResult] = useState<ActionOutputProcessOCR | null>(null);
|
||||||
|
|
||||||
|
const handleFileChange = useCallback((file: File | null) => {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
toast.error(t("invalidFileType"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
setPreviewUrl(url);
|
||||||
|
setSelectedFile(file);
|
||||||
|
setOcrResult(null);
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
handleFileChange(file);
|
||||||
|
}, [handleFileChange]);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fileToBase64 = async (file: File): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result as string;
|
||||||
|
const base64 = result.split(",")[1];
|
||||||
|
resolve(base64);
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProcess = async () => {
|
||||||
|
if (!selectedFile) {
|
||||||
|
toast.error(t("noImage"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedDeckId) {
|
||||||
|
toast.error(t("noDeck"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
setOcrResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base64 = await fileToBase64(selectedFile);
|
||||||
|
|
||||||
|
const result = await actionProcessOCR({
|
||||||
|
imageBase64: base64,
|
||||||
|
deckId: selectedDeckId,
|
||||||
|
sourceLanguage: sourceLanguage || undefined,
|
||||||
|
targetLanguage: targetLanguage || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setOcrResult(result);
|
||||||
|
const deckName = decks.find(d => d.id === selectedDeckId)?.name || "";
|
||||||
|
toast.success(t("ocrSuccess", { count: result.data.pairsCreated, deck: deckName }));
|
||||||
|
} else {
|
||||||
|
toast.error(result.message || t("ocrFailed"));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("processingFailed"));
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!ocrResult || !selectedDeckId) {
|
||||||
|
toast.error(t("noResultsToSave"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deckName = decks.find(d => d.id === selectedDeckId)?.name || "Unknown";
|
||||||
|
toast.success(t("savedToDeck", { deckName }));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("saveFailed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearImage = () => {
|
||||||
|
if (previewUrl) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
}
|
||||||
|
setPreviewUrl(null);
|
||||||
|
setSelectedFile(null);
|
||||||
|
setOcrResult(null);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout variant="centered-card">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-700 text-lg">
|
||||||
|
{t("description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card variant="bordered" padding="lg">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Upload Section */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
||||||
|
{t("uploadSection")}
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-primary-500 hover:bg-primary-50 transition-colors"
|
||||||
|
>
|
||||||
|
{previewUrl ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="Preview"
|
||||||
|
className="mx-auto max-w-full h-64 object-contain rounded-lg"
|
||||||
|
/>
|
||||||
|
<p className="text-gray-600">{t("changeImage")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Upload className="mx-auto h-12 w-12 text-gray-400" />
|
||||||
|
<p className="text-gray-600">{t("dropOrClick")}</p>
|
||||||
|
<p className="text-sm text-gray-500">{t("supportedFormats")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => handleFileChange(e.target.files?.[0] || null)}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deck Selection */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
||||||
|
{t("deckSelection")}
|
||||||
|
</h2>
|
||||||
|
<Select
|
||||||
|
value={selectedDeckId?.toString() || ""}
|
||||||
|
onChange={(e) => setSelectedDeckId(Number(e.target.value))}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<option value="">{t("selectDeck")}</option>
|
||||||
|
{decks.map((deck) => (
|
||||||
|
<option key={deck.id} value={deck.id}>
|
||||||
|
{deck.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Language Hints */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
||||||
|
{t("languageHints")}
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("sourceLanguagePlaceholder")}
|
||||||
|
value={sourceLanguage}
|
||||||
|
onChange={(e) => setSourceLanguage(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("targetLanguagePlaceholder")}
|
||||||
|
value={targetLanguage}
|
||||||
|
onChange={(e) => setTargetLanguage(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Process Button */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={handleProcess}
|
||||||
|
disabled={!selectedFile || !selectedDeckId || isProcessing}
|
||||||
|
loading={isProcessing}
|
||||||
|
className="px-8 py-3 text-lg"
|
||||||
|
>
|
||||||
|
{t("processButton")}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results Preview */}
|
||||||
|
{ocrResult && ocrResult.data && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
||||||
|
{t("resultsPreview")}
|
||||||
|
</h2>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<p className="text-gray-800">{t("extractedPairs", { count: ocrResult.data.pairsCreated })}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{ocrResult.data.sourceLanguage && (
|
||||||
|
<div className="mt-4 text-sm text-gray-500">
|
||||||
|
{t("detectedSourceLanguage")}: {ocrResult.data.sourceLanguage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ocrResult.data.targetLanguage && (
|
||||||
|
<div className="mt-1 text-sm text-gray-500">
|
||||||
|
{t("detectedTargetLanguage")}: {ocrResult.data.targetLanguage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex justify-center">
|
||||||
|
<LightButton
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!selectedDeckId}
|
||||||
|
className="px-6 py-2"
|
||||||
|
>
|
||||||
|
{t("saveButton")}
|
||||||
|
</LightButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/app/(features)/ocr/page.tsx
Normal file
20
src/app/(features)/ocr/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { OCRClient } from "./OCRClient";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
|
||||||
|
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
||||||
|
|
||||||
|
export default async function OCRPage() {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
|
||||||
|
let decks: ActionOutputDeck[] = [];
|
||||||
|
|
||||||
|
if (session?.user?.id) {
|
||||||
|
const result = await actionGetDecksByUserId(session.user.id as string);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
decks = result.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <OCRClient initialDecks={decks} />;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LightButton, PrimaryButton, IconClick } from "@/design-system/base/button";
|
import { LightButton, PrimaryButton, IconClick } from "@/design-system/base/button";
|
||||||
|
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";
|
||||||
@@ -10,16 +11,45 @@ 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";
|
||||||
|
|
||||||
|
const SOURCE_LANGUAGES = [
|
||||||
|
{ value: "Auto", labelKey: "auto" },
|
||||||
|
{ value: "Chinese", labelKey: "chinese" },
|
||||||
|
{ value: "English", labelKey: "english" },
|
||||||
|
{ value: "Japanese", labelKey: "japanese" },
|
||||||
|
{ value: "Korean", labelKey: "korean" },
|
||||||
|
{ value: "French", labelKey: "french" },
|
||||||
|
{ value: "German", labelKey: "german" },
|
||||||
|
{ value: "Italian", labelKey: "italian" },
|
||||||
|
{ value: "Spanish", labelKey: "spanish" },
|
||||||
|
{ value: "Portuguese", labelKey: "portuguese" },
|
||||||
|
{ value: "Russian", labelKey: "russian" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TARGET_LANGUAGES = [
|
||||||
|
{ value: "Chinese", labelKey: "chinese" },
|
||||||
|
{ value: "English", labelKey: "english" },
|
||||||
|
{ value: "Japanese", labelKey: "japanese" },
|
||||||
|
{ value: "Korean", labelKey: "korean" },
|
||||||
|
{ value: "French", labelKey: "french" },
|
||||||
|
{ value: "German", labelKey: "german" },
|
||||||
|
{ value: "Italian", labelKey: "italian" },
|
||||||
|
{ value: "Spanish", labelKey: "spanish" },
|
||||||
|
{ value: "Portuguese", labelKey: "portuguese" },
|
||||||
|
{ value: "Russian", labelKey: "russian" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
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 [sourceLanguage, setSourceLanguage] = useState<string>("Auto");
|
||||||
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
|
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
|
||||||
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);
|
||||||
const [lastTranslation, setLastTranslation] = useState<{
|
const [lastTranslation, setLastTranslation] = useState<{
|
||||||
sourceText: string;
|
sourceText: string;
|
||||||
|
sourceLanguage: string;
|
||||||
targetLanguage: string;
|
targetLanguage: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const { load, play } = useAudioPlayer();
|
const { load, play } = useAudioPlayer();
|
||||||
@@ -63,9 +93,10 @@ export default function TranslatorPage() {
|
|||||||
const sourceText = taref.current.value;
|
const sourceText = taref.current.value;
|
||||||
|
|
||||||
// 判断是否需要强制重新翻译
|
// 判断是否需要强制重新翻译
|
||||||
// 只有当源文本和目标语言都与上次相同时,才强制重新翻译
|
// 只有当源文本、源语言和目标语言都与上次相同时,才强制重新翻译
|
||||||
const forceRetranslate =
|
const forceRetranslate =
|
||||||
lastTranslation?.sourceText === sourceText &&
|
lastTranslation?.sourceText === sourceText &&
|
||||||
|
lastTranslation?.sourceLanguage === sourceLanguage &&
|
||||||
lastTranslation?.targetLanguage === targetLanguage;
|
lastTranslation?.targetLanguage === targetLanguage;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -74,12 +105,14 @@ export default function TranslatorPage() {
|
|||||||
targetLanguage,
|
targetLanguage,
|
||||||
forceRetranslate,
|
forceRetranslate,
|
||||||
needIpa,
|
needIpa,
|
||||||
|
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,
|
||||||
targetLanguage,
|
targetLanguage,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -132,11 +165,47 @@ export default function TranslatorPage() {
|
|||||||
></IconClick>
|
></IconClick>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="option1 w-full flex flex-row justify-between items-center">
|
<div className="option1 w-full flex gap-1 items-center overflow-x-auto">
|
||||||
<span>{t("detectLanguage")}</span>
|
<span className="shrink-0">{t("sourceLanguage")}</span>
|
||||||
|
<LightButton
|
||||||
|
selected={sourceLanguage === "Auto"}
|
||||||
|
onClick={() => setSourceLanguage("Auto")}
|
||||||
|
className="shrink-0 hidden lg:inline-flex"
|
||||||
|
>
|
||||||
|
{t("auto")}
|
||||||
|
</LightButton>
|
||||||
|
<LightButton
|
||||||
|
selected={sourceLanguage === "Chinese"}
|
||||||
|
onClick={() => setSourceLanguage("Chinese")}
|
||||||
|
className="shrink-0 hidden lg:inline-flex"
|
||||||
|
>
|
||||||
|
{t("chinese")}
|
||||||
|
</LightButton>
|
||||||
|
<LightButton
|
||||||
|
selected={sourceLanguage === "English"}
|
||||||
|
onClick={() => setSourceLanguage("English")}
|
||||||
|
className="shrink-0 hidden xl:inline-flex"
|
||||||
|
>
|
||||||
|
{t("english")}
|
||||||
|
</LightButton>
|
||||||
|
<Select
|
||||||
|
value={sourceLanguage}
|
||||||
|
onChange={(e) => setSourceLanguage(e.target.value)}
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
className="w-auto min-w-[100px] shrink-0"
|
||||||
|
>
|
||||||
|
{SOURCE_LANGUAGES.map((lang) => (
|
||||||
|
<option key={lang.value} value={lang.value}>
|
||||||
|
{t(lang.labelKey)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<div className="flex-1"></div>
|
||||||
<LightButton
|
<LightButton
|
||||||
selected={needIpa}
|
selected={needIpa}
|
||||||
onClick={() => setNeedIpa((prev) => !prev)}
|
onClick={() => setNeedIpa((prev) => !prev)}
|
||||||
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
{t("generateIPA")}
|
{t("generateIPA")}
|
||||||
</LightButton>
|
</LightButton>
|
||||||
@@ -172,37 +241,42 @@ export default function TranslatorPage() {
|
|||||||
></IconClick>
|
></IconClick>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="option2 w-full flex gap-1 items-center flex-wrap">
|
<div className="option2 w-full flex gap-1 items-center overflow-x-auto">
|
||||||
<span>{t("translateInto")}</span>
|
<span className="shrink-0">{t("translateInto")}</span>
|
||||||
<LightButton
|
<LightButton
|
||||||
selected={targetLanguage === "Chinese"}
|
selected={targetLanguage === "Chinese"}
|
||||||
onClick={() => setTargetLanguage("Chinese")}
|
onClick={() => setTargetLanguage("Chinese")}
|
||||||
|
className="shrink-0 hidden lg:inline-flex"
|
||||||
>
|
>
|
||||||
{t("chinese")}
|
{t("chinese")}
|
||||||
</LightButton>
|
</LightButton>
|
||||||
<LightButton
|
<LightButton
|
||||||
selected={targetLanguage === "English"}
|
selected={targetLanguage === "English"}
|
||||||
onClick={() => setTargetLanguage("English")}
|
onClick={() => setTargetLanguage("English")}
|
||||||
|
className="shrink-0 hidden lg:inline-flex"
|
||||||
>
|
>
|
||||||
{t("english")}
|
{t("english")}
|
||||||
</LightButton>
|
</LightButton>
|
||||||
<LightButton
|
<LightButton
|
||||||
selected={targetLanguage === "Italian"}
|
selected={targetLanguage === "Japanese"}
|
||||||
onClick={() => setTargetLanguage("Italian")}
|
onClick={() => setTargetLanguage("Japanese")}
|
||||||
|
className="shrink-0 hidden xl:inline-flex"
|
||||||
>
|
>
|
||||||
{t("italian")}
|
{t("japanese")}
|
||||||
</LightButton>
|
</LightButton>
|
||||||
<LightButton
|
<Select
|
||||||
selected={!["Chinese", "English", "Italian"].includes(targetLanguage)}
|
value={targetLanguage}
|
||||||
onClick={() => {
|
onChange={(e) => setTargetLanguage(e.target.value)}
|
||||||
const newLang = prompt(t("enterLanguage"));
|
variant="light"
|
||||||
if (newLang) {
|
size="sm"
|
||||||
setTargetLanguage(newLang);
|
className="w-auto min-w-[100px] shrink-0"
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t("other")}
|
{TARGET_LANGUAGES.map((lang) => (
|
||||||
</LightButton>
|
<option key={lang.value} value={lang.value}>
|
||||||
|
{t(lang.labelKey)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Folder as Fd,
|
Layers,
|
||||||
FolderPen,
|
Pencil,
|
||||||
FolderPlus,
|
Plus,
|
||||||
Globe,
|
Globe,
|
||||||
Lock,
|
Lock,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -18,30 +18,34 @@ 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 {
|
||||||
actionCreateFolder,
|
actionCreateDeck,
|
||||||
actionDeleteFolderById,
|
actionDeleteDeck,
|
||||||
actionGetFoldersWithTotalPairsByUserId,
|
actionGetDecksByUserId,
|
||||||
actionRenameFolderById,
|
actionUpdateDeck,
|
||||||
actionSetFolderVisibility,
|
actionGetDeckById,
|
||||||
} from "@/modules/folder/folder-action";
|
} from "@/modules/deck/deck-action";
|
||||||
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
|
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
||||||
|
import { ImportButton, ExportButton } from "@/components/deck/ImportExport";
|
||||||
|
|
||||||
interface FolderCardProps {
|
interface DeckCardProps {
|
||||||
folder: TSharedFolderWithTotalPairs;
|
deck: ActionOutputDeck;
|
||||||
onUpdateFolder: (folderId: number, updates: Partial<TSharedFolderWithTotalPairs>) => void;
|
onUpdateDeck: (deckId: number, updates: Partial<ActionOutputDeck>) => void;
|
||||||
onDeleteFolder: (folderId: number) => void;
|
onDeleteDeck: (deckId: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps) => {
|
const DeckCard = ({ deck, onUpdateDeck, onDeleteDeck }: DeckCardProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations("folders");
|
const t = useTranslations("decks");
|
||||||
|
|
||||||
const handleToggleVisibility = async (e: React.MouseEvent) => {
|
const handleToggleVisibility = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const newVisibility = folder.visibility === "PUBLIC" ? "PRIVATE" : "PUBLIC";
|
const newVisibility = deck.visibility === "PUBLIC" ? "PRIVATE" : "PUBLIC";
|
||||||
const result = await actionSetFolderVisibility(folder.id, newVisibility);
|
const result = await actionUpdateDeck({
|
||||||
|
deckId: deck.id,
|
||||||
|
visibility: newVisibility,
|
||||||
|
});
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
onUpdateFolder(folder.id, { visibility: newVisibility });
|
onUpdateDeck(deck.id, { visibility: newVisibility });
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message);
|
toast.error(result.message);
|
||||||
}
|
}
|
||||||
@@ -51,9 +55,12 @@ const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps)
|
|||||||
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 actionRenameFolderById(folder.id, newName);
|
const result = await actionUpdateDeck({
|
||||||
|
deckId: deck.id,
|
||||||
|
name: newName,
|
||||||
|
});
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
onUpdateFolder(folder.id, { name: newName });
|
onUpdateDeck(deck.id, { name: newName });
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message);
|
toast.error(result.message);
|
||||||
}
|
}
|
||||||
@@ -62,11 +69,11 @@ const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps)
|
|||||||
|
|
||||||
const handleDelete = async (e: React.MouseEvent) => {
|
const handleDelete = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const confirm = prompt(t("confirmDelete", { name: folder.name }));
|
const confirm = prompt(t("confirmDelete", { name: deck.name }));
|
||||||
if (confirm === folder.name) {
|
if (confirm === deck.name) {
|
||||||
const result = await actionDeleteFolderById(folder.id);
|
const result = await actionDeleteDeck({ deckId: deck.id });
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
onDeleteFolder(folder.id);
|
onDeleteDeck(deck.id);
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message);
|
toast.error(result.message);
|
||||||
}
|
}
|
||||||
@@ -77,31 +84,31 @@ const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps)
|
|||||||
<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(`/folders/${folder.id}`);
|
router.push(`/decks/${deck.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">
|
||||||
<Fd size={24} />
|
<Layers 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">{folder.name}</h3>
|
<h3 className="font-semibold text-gray-900 truncate">{deck.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">
|
||||||
{folder.visibility === "PUBLIC" ? (
|
{deck.visibility === "PUBLIC" ? (
|
||||||
<Globe size={12} />
|
<Globe size={12} />
|
||||||
) : (
|
) : (
|
||||||
<Lock size={12} />
|
<Lock size={12} />
|
||||||
)}
|
)}
|
||||||
{folder.visibility === "PUBLIC" ? t("public") : t("private")}
|
{deck.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("folderInfo", {
|
{t("deckInfo", {
|
||||||
id: folder.id,
|
id: deck.id,
|
||||||
name: folder.name,
|
name: deck.name,
|
||||||
totalPairs: folder.total,
|
totalCards: deck.cardCount ?? 0,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,16 +117,16 @@ const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps)
|
|||||||
<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={folder.visibility === "PUBLIC" ? t("setPrivate") : t("setPublic")}
|
title={deck.visibility === "PUBLIC" ? t("setPrivate") : t("setPublic")}
|
||||||
>
|
>
|
||||||
{folder.visibility === "PUBLIC" ? (
|
{deck.visibility === "PUBLIC" ? (
|
||||||
<Lock size={18} />
|
<Lock size={18} />
|
||||||
) : (
|
) : (
|
||||||
<Globe size={18} />
|
<Globe size={18} />
|
||||||
)}
|
)}
|
||||||
</CircleButton>
|
</CircleButton>
|
||||||
<CircleButton onClick={handleRename}>
|
<CircleButton onClick={handleRename}>
|
||||||
<FolderPen size={18} />
|
<Pencil size={18} />
|
||||||
</CircleButton>
|
</CircleButton>
|
||||||
<CircleButton
|
<CircleButton
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
@@ -133,46 +140,49 @@ const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps)
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface FoldersClientProps {
|
interface DecksClientProps {
|
||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FoldersClient({ userId }: FoldersClientProps) {
|
export function DecksClient({ userId }: DecksClientProps) {
|
||||||
const t = useTranslations("folders");
|
const t = useTranslations("decks");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>([]);
|
const [decks, setDecks] = useState<ActionOutputDeck[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const loadFolders = async () => {
|
const loadDecks = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const result = await actionGetFoldersWithTotalPairsByUserId(userId);
|
const result = await actionGetDecksByUserId(userId);
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setFolders(result.data);
|
setDecks(result.data);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadFolders();
|
loadDecks();
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
const handleUpdateFolder = (folderId: number, updates: Partial<TSharedFolderWithTotalPairs>) => {
|
const handleUpdateDeck = (deckId: number, updates: Partial<ActionOutputDeck>) => {
|
||||||
setFolders((prev) =>
|
setDecks((prev) =>
|
||||||
prev.map((f) => (f.id === folderId ? { ...f, ...updates } : f))
|
prev.map((d) => (d.id === deckId ? { ...d, ...updates } : d))
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteFolder = (folderId: number) => {
|
const handleDeleteDeck = (deckId: number) => {
|
||||||
setFolders((prev) => prev.filter((f) => f.id !== folderId));
|
setDecks((prev) => prev.filter((d) => d.id !== deckId));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateFolder = async () => {
|
const handleCreateDeck = async () => {
|
||||||
const folderName = prompt(t("enterFolderName"));
|
const deckName = prompt(t("enterDeckName"));
|
||||||
if (!folderName?.trim()) return;
|
if (!deckName?.trim()) return;
|
||||||
|
|
||||||
const result = await actionCreateFolder(userId, folderName.trim());
|
const result = await actionCreateDeck({ name: deckName.trim() });
|
||||||
if (result.success) {
|
if (result.success && result.deckId) {
|
||||||
loadFolders();
|
const deckResult = await actionGetDeckById({ deckId: result.deckId });
|
||||||
|
if (deckResult.success && deckResult.data) {
|
||||||
|
setDecks((prev) => [...prev, deckResult.data!]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message);
|
toast.error(result.message);
|
||||||
}
|
}
|
||||||
@@ -182,11 +192,12 @@ export function FoldersClient({ userId }: FoldersClientProps) {
|
|||||||
<PageLayout>
|
<PageLayout>
|
||||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4 flex gap-2">
|
||||||
<LightButton onClick={handleCreateFolder}>
|
<LightButton onClick={handleCreateDeck}>
|
||||||
<FolderPlus size={18} />
|
<Plus size={18} />
|
||||||
{t("newFolder")}
|
{t("newDeck")}
|
||||||
</LightButton>
|
</LightButton>
|
||||||
|
<ImportButton onImportComplete={loadDecks} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardList>
|
<CardList>
|
||||||
@@ -195,20 +206,20 @@ export function FoldersClient({ userId }: FoldersClientProps) {
|
|||||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
|
<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>
|
||||||
) : folders.length === 0 ? (
|
) : decks.length === 0 ? (
|
||||||
<div className="text-center py-12 text-gray-400">
|
<div className="text-center py-12 text-gray-400">
|
||||||
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
|
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
|
||||||
<Fd size={24} className="text-gray-400" />
|
<Layers size={24} className="text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm">{t("noFoldersYet")}</p>
|
<p className="text-sm">{t("noDecksYet")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
folders.map((folder) => (
|
decks.map((deck) => (
|
||||||
<FolderCard
|
<DeckCard
|
||||||
key={folder.id}
|
key={deck.id}
|
||||||
folder={folder}
|
deck={deck}
|
||||||
onUpdateFolder={handleUpdateFolder}
|
onUpdateDeck={handleUpdateDeck}
|
||||||
onDeleteFolder={handleDeleteFolder}
|
onDeleteDeck={handleDeleteDeck}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
154
src/app/decks/[deck_id]/AddCardModal.tsx
Normal file
154
src/app/decks/[deck_id]/AddCardModal.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LightButton } from "@/design-system/base/button";
|
||||||
|
import { Input } from "@/design-system/base/input";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { actionCreateNote } from "@/modules/note/note-action";
|
||||||
|
import { actionCreateCard } from "@/modules/card/card-action";
|
||||||
|
import { actionGetNoteTypesByUserId, actionCreateDefaultBasicNoteType } from "@/modules/note-type/note-type-action";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface AddCardModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
deckId: number;
|
||||||
|
onAdded: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddCardModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
deckId,
|
||||||
|
onAdded,
|
||||||
|
}: AddCardModalProps) {
|
||||||
|
const t = useTranslations("deck_id");
|
||||||
|
const wordRef = useRef<HTMLInputElement>(null);
|
||||||
|
const definitionRef = useRef<HTMLInputElement>(null);
|
||||||
|
const ipaRef = useRef<HTMLInputElement>(null);
|
||||||
|
const exampleRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
const word = wordRef.current?.value?.trim();
|
||||||
|
const definition = definitionRef.current?.value?.trim();
|
||||||
|
|
||||||
|
if (!word || !definition) {
|
||||||
|
toast.error(t("wordAndDefinitionRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let noteTypesResult = await actionGetNoteTypesByUserId();
|
||||||
|
|
||||||
|
if (!noteTypesResult.success || !noteTypesResult.data || noteTypesResult.data.length === 0) {
|
||||||
|
const createResult = await actionCreateDefaultBasicNoteType();
|
||||||
|
if (!createResult.success || !createResult.data) {
|
||||||
|
throw new Error(createResult.message || "Failed to create note type");
|
||||||
|
}
|
||||||
|
noteTypesResult = await actionGetNoteTypesByUserId();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!noteTypesResult.success || !noteTypesResult.data || noteTypesResult.data.length === 0) {
|
||||||
|
throw new Error("No note type available");
|
||||||
|
}
|
||||||
|
|
||||||
|
const noteTypeId = noteTypesResult.data[0].id;
|
||||||
|
|
||||||
|
const fields = [
|
||||||
|
word,
|
||||||
|
definition,
|
||||||
|
ipaRef.current?.value?.trim() || "",
|
||||||
|
exampleRef.current?.value?.trim() || "",
|
||||||
|
];
|
||||||
|
|
||||||
|
const noteResult = await actionCreateNote({
|
||||||
|
noteTypeId,
|
||||||
|
fields,
|
||||||
|
tags: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!noteResult.success || !noteResult.data) {
|
||||||
|
throw new Error(noteResult.message || "Failed to create note");
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardResult = await actionCreateCard({
|
||||||
|
noteId: BigInt(noteResult.data.id),
|
||||||
|
deckId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cardResult.success) {
|
||||||
|
throw new Error(cardResult.message || "Failed to create card");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wordRef.current) wordRef.current.value = "";
|
||||||
|
if (definitionRef.current) definitionRef.current.value = "";
|
||||||
|
if (ipaRef.current) ipaRef.current.value = "";
|
||||||
|
if (exampleRef.current) exampleRef.current.value = "";
|
||||||
|
|
||||||
|
onAdded();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
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("addNewCard")}
|
||||||
|
</h2>
|
||||||
|
<X onClick={onClose} className="hover:cursor-pointer"></X>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t("word")} *
|
||||||
|
</label>
|
||||||
|
<Input ref={wordRef} className="w-full"></Input>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t("definition")} *
|
||||||
|
</label>
|
||||||
|
<Input ref={definitionRef} className="w-full"></Input>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t("ipa")}
|
||||||
|
</label>
|
||||||
|
<Input ref={ipaRef} className="w-full"></Input>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t("example")}
|
||||||
|
</label>
|
||||||
|
<Input ref={exampleRef} className="w-full"></Input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<LightButton onClick={handleAdd} disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? t("adding") : t("add")}
|
||||||
|
</LightButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,39 +1,38 @@
|
|||||||
import { Edit, Trash2 } from "lucide-react";
|
import { Edit, Trash2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { CircleButton } from "@/design-system/base/button";
|
import { CircleButton } from "@/design-system/base/button";
|
||||||
import { UpdateTextPairModal } from "./UpdateTextPairModal";
|
import { UpdateCardModal } from "./UpdateCardModal";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { TSharedPair } from "@/shared/folder-type";
|
import type { ActionOutputCardWithNote } from "@/modules/card/card-action-dto";
|
||||||
import { actionUpdatePairById } from "@/modules/folder/folder-action";
|
|
||||||
import { ActionInputUpdatePairById } from "@/modules/folder/folder-action-dto";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface TextPairCardProps {
|
interface CardItemProps {
|
||||||
textPair: TSharedPair;
|
card: ActionOutputCardWithNote;
|
||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
onDel: () => void;
|
onDel: () => void;
|
||||||
refreshTextPairs: () => void;
|
refreshCards: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TextPairCard({
|
export function CardItem({
|
||||||
textPair,
|
card,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
onDel,
|
onDel,
|
||||||
refreshTextPairs,
|
refreshCards,
|
||||||
}: TextPairCardProps) {
|
}: CardItemProps) {
|
||||||
const [openUpdateModal, setOpenUpdateModal] = useState(false);
|
const [openUpdateModal, setOpenUpdateModal] = useState(false);
|
||||||
const t = useTranslations("folder_id");
|
const t = useTranslations("deck_id");
|
||||||
|
|
||||||
|
const fields = card.note.flds.split('\x1f');
|
||||||
|
const field1 = fields[0] || "";
|
||||||
|
const field2 = fields[1] || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
<div className="group border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||||
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
||||||
{textPair.language1.toUpperCase()}
|
{t("card")}
|
||||||
</span>
|
|
||||||
<span>→</span>
|
|
||||||
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
|
||||||
{textPair.language2.toUpperCase()}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -60,26 +59,25 @@ export function TextPairCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-gray-900 grid grid-cols-2 gap-4 w-3/4">
|
<div className="text-gray-900 grid grid-cols-2 gap-4 w-3/4">
|
||||||
<div>
|
<div>
|
||||||
{textPair.text1.length > 30
|
{field1.length > 30
|
||||||
? textPair.text1.substring(0, 30) + "..."
|
? field1.substring(0, 30) + "..."
|
||||||
: textPair.text1}
|
: field1}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{textPair.text2.length > 30
|
{field2.length > 30
|
||||||
? textPair.text2.substring(0, 30) + "..."
|
? field2.substring(0, 30) + "..."
|
||||||
: textPair.text2}
|
: field2}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<UpdateTextPairModal
|
<UpdateCardModal
|
||||||
isOpen={openUpdateModal}
|
isOpen={openUpdateModal}
|
||||||
onClose={() => setOpenUpdateModal(false)}
|
onClose={() => setOpenUpdateModal(false)}
|
||||||
onUpdate={async (id: number, data: ActionInputUpdatePairById) => {
|
card={card}
|
||||||
await actionUpdatePairById(id, data).then(result => result.success ? toast.success(result.message) : toast.error(result.message));
|
onUpdated={() => {
|
||||||
setOpenUpdateModal(false);
|
setOpenUpdateModal(false);
|
||||||
refreshTextPairs();
|
refreshCards();
|
||||||
}}
|
}}
|
||||||
textPair={textPair}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -3,34 +3,34 @@
|
|||||||
import { ArrowLeft, Plus } from "lucide-react";
|
import { ArrowLeft, Plus } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { redirect, useRouter } from "next/navigation";
|
import { redirect, useRouter } from "next/navigation";
|
||||||
import { AddTextPairModal } from "./AddTextPairModal";
|
import { AddCardModal } from "./AddCardModal";
|
||||||
import { TextPairCard } from "./TextPairCard";
|
import { CardItem } from "./CardItem";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { PageLayout } from "@/components/ui/PageLayout";
|
import { PageLayout } from "@/components/ui/PageLayout";
|
||||||
import { PrimaryButton, CircleButton, LinkButton } from "@/design-system/base/button";
|
import { PrimaryButton, CircleButton, LinkButton } from "@/design-system/base/button";
|
||||||
import { CardList } from "@/components/ui/CardList";
|
import { CardList } from "@/components/ui/CardList";
|
||||||
import { actionCreatePair, actionDeletePairById, actionGetPairsByFolderId } from "@/modules/folder/folder-action";
|
import { actionGetCardsByDeckIdWithNotes, actionDeleteCard } from "@/modules/card/card-action";
|
||||||
import { TSharedPair } from "@/shared/folder-type";
|
import type { ActionOutputCardWithNote } from "@/modules/card/card-action-dto";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
|
||||||
export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnly: boolean; }) {
|
export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boolean; }) {
|
||||||
const [textPairs, setTextPairs] = useState<TSharedPair[]>([]);
|
const [cards, setCards] = useState<ActionOutputCardWithNote[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [openAddModal, setAddModal] = useState(false);
|
const [openAddModal, setAddModal] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations("folder_id");
|
const t = useTranslations("deck_id");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTextPairs = async () => {
|
const fetchCards = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await actionGetPairsByFolderId(folderId)
|
await actionGetCardsByDeckIdWithNotes({ deckId })
|
||||||
.then(result => {
|
.then(result => {
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
throw new Error(result.message || "Failed to load text pairs");
|
throw new Error(result.message || "Failed to load cards");
|
||||||
}
|
}
|
||||||
return result.data;
|
return result.data;
|
||||||
}).then(setTextPairs)
|
}).then(setCards)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||||
})
|
})
|
||||||
@@ -38,17 +38,17 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
fetchTextPairs();
|
fetchCards();
|
||||||
}, [folderId]);
|
}, [deckId]);
|
||||||
|
|
||||||
const refreshTextPairs = async () => {
|
const refreshCards = async () => {
|
||||||
await actionGetPairsByFolderId(folderId)
|
await actionGetCardsByDeckIdWithNotes({ deckId })
|
||||||
.then(result => {
|
.then(result => {
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
throw new Error(result.message || "Failed to refresh text pairs");
|
throw new Error(result.message || "Failed to refresh cards");
|
||||||
}
|
}
|
||||||
return result.data;
|
return result.data;
|
||||||
}).then(setTextPairs)
|
}).then(setCards)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||||
});
|
});
|
||||||
@@ -56,9 +56,7 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
{/* 顶部导航和标题栏 */}
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
{/* 返回按钮 */}
|
|
||||||
<LinkButton
|
<LinkButton
|
||||||
onClick={router.back}
|
onClick={router.back}
|
||||||
className="flex items-center gap-2 mb-4"
|
className="flex items-center gap-2 mb-4"
|
||||||
@@ -67,23 +65,20 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
|
|||||||
<span className="text-sm">{t("back")}</span>
|
<span className="text-sm">{t("back")}</span>
|
||||||
</LinkButton>
|
</LinkButton>
|
||||||
|
|
||||||
{/* 页面标题和操作按钮 */}
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
{/* 标题区域 */}
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-1">
|
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-1">
|
||||||
{t("textPairs")}
|
{t("cards")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
{t("itemsCount", { count: textPairs.length })}
|
{t("itemsCount", { count: cards.length })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作按钮区域 */}
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
redirect(`/memorize?folder_id=${folderId}`);
|
redirect(`/memorize?deck_id=${deckId}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("memorize")}
|
{t("memorize")}
|
||||||
@@ -101,64 +96,46 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 文本对列表 */}
|
|
||||||
<CardList>
|
<CardList>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
// 加载状态
|
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
|
<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>
|
<p className="text-sm text-gray-500">{t("loadingCards")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : textPairs.length === 0 ? (
|
) : cards.length === 0 ? (
|
||||||
// 空状态
|
|
||||||
<div className="p-12 text-center">
|
<div className="p-12 text-center">
|
||||||
<p className="text-sm text-gray-500 mb-2">{t("noTextPairs")}</p>
|
<p className="text-sm text-gray-500 mb-2">{t("noCards")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// 文本对卡片列表
|
|
||||||
<div className="divide-y divide-gray-100">
|
<div className="divide-y divide-gray-100">
|
||||||
{textPairs
|
{cards
|
||||||
.toSorted((a, b) => a.id - b.id)
|
.toSorted((a, b) => Number(BigInt(a.id) - BigInt(b.id)))
|
||||||
.map((textPair) => (
|
.map((card) => (
|
||||||
<TextPairCard
|
<CardItem
|
||||||
key={textPair.id}
|
key={card.id}
|
||||||
textPair={textPair}
|
card={card}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
onDel={() => {
|
onDel={() => {
|
||||||
actionDeletePairById(textPair.id)
|
actionDeleteCard({ cardId: BigInt(card.id) })
|
||||||
.then(result => {
|
.then(result => {
|
||||||
if (!result.success) throw new Error(result.message || "Delete failed");
|
if (!result.success) throw new Error(result.message || "Delete failed");
|
||||||
}).then(refreshTextPairs)
|
}).then(refreshCards)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
refreshTextPairs={refreshTextPairs}
|
refreshCards={refreshCards}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardList>
|
</CardList>
|
||||||
|
|
||||||
{/* 添加文本对模态框 */}
|
<AddCardModal
|
||||||
<AddTextPairModal
|
|
||||||
isOpen={openAddModal}
|
isOpen={openAddModal}
|
||||||
onClose={() => setAddModal(false)}
|
onClose={() => setAddModal(false)}
|
||||||
onAdd={async (
|
deckId={deckId}
|
||||||
text1: string,
|
onAdded={refreshCards}
|
||||||
text2: string,
|
|
||||||
language1: string,
|
|
||||||
language2: string,
|
|
||||||
) => {
|
|
||||||
await actionCreatePair({
|
|
||||||
text1: text1,
|
|
||||||
text2: text2,
|
|
||||||
language1: language1,
|
|
||||||
language2: language2,
|
|
||||||
folderId: folderId,
|
|
||||||
});
|
|
||||||
refreshTextPairs();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
132
src/app/decks/[deck_id]/UpdateCardModal.tsx
Normal file
132
src/app/decks/[deck_id]/UpdateCardModal.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LightButton } from "@/design-system/base/button";
|
||||||
|
import { Input } from "@/design-system/base/input";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { useRef, useState, useEffect } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { actionUpdateNote } from "@/modules/note/note-action";
|
||||||
|
import type { ActionOutputCardWithNote } from "@/modules/card/card-action-dto";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface UpdateCardModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
card: ActionOutputCardWithNote;
|
||||||
|
onUpdated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpdateCardModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
card,
|
||||||
|
onUpdated,
|
||||||
|
}: UpdateCardModalProps) {
|
||||||
|
const t = useTranslations("deck_id");
|
||||||
|
const wordRef = useRef<HTMLInputElement>(null);
|
||||||
|
const definitionRef = useRef<HTMLInputElement>(null);
|
||||||
|
const ipaRef = useRef<HTMLInputElement>(null);
|
||||||
|
const exampleRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && card) {
|
||||||
|
const fields = card.note.flds.split('\x1f');
|
||||||
|
if (wordRef.current) wordRef.current.value = fields[0] || "";
|
||||||
|
if (definitionRef.current) definitionRef.current.value = fields[1] || "";
|
||||||
|
if (ipaRef.current) ipaRef.current.value = fields[2] || "";
|
||||||
|
if (exampleRef.current) exampleRef.current.value = fields[3] || "";
|
||||||
|
}
|
||||||
|
}, [isOpen, card]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
const word = wordRef.current?.value?.trim();
|
||||||
|
const definition = definitionRef.current?.value?.trim();
|
||||||
|
|
||||||
|
if (!word || !definition) {
|
||||||
|
toast.error(t("wordAndDefinitionRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fields = [
|
||||||
|
word,
|
||||||
|
definition,
|
||||||
|
ipaRef.current?.value?.trim() || "",
|
||||||
|
exampleRef.current?.value?.trim() || "",
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await actionUpdateNote({
|
||||||
|
noteId: BigInt(card.note.id),
|
||||||
|
fields,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.message || "Failed to update note");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(result.message);
|
||||||
|
onUpdated();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleUpdate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-md p-6 w-full max-w-md mx-4">
|
||||||
|
<div className="flex">
|
||||||
|
<h2 className="flex-1 text-xl font-light mb-4 text-center">
|
||||||
|
{t("updateCard")}
|
||||||
|
</h2>
|
||||||
|
<X onClick={onClose} className="hover:cursor-pointer"></X>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t("word")} *
|
||||||
|
</label>
|
||||||
|
<Input ref={wordRef} className="w-full"></Input>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t("definition")} *
|
||||||
|
</label>
|
||||||
|
<Input ref={definitionRef} className="w-full"></Input>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t("ipa")}
|
||||||
|
</label>
|
||||||
|
<Input ref={ipaRef} className="w-full"></Input>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t("example")}
|
||||||
|
</label>
|
||||||
|
<Input ref={exampleRef} className="w-full"></Input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<LightButton onClick={handleUpdate} disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? t("updating") : t("update")}
|
||||||
|
</LightButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/app/decks/[deck_id]/page.tsx
Normal file
37
src/app/decks/[deck_id]/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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} />;
|
||||||
|
}
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { FoldersClient } from "./FoldersClient";
|
import { DecksClient } from "./DecksClient";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default async function FoldersPage() {
|
export default async function DecksPage() {
|
||||||
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=/folders");
|
redirect("/login?redirect=/decks");
|
||||||
}
|
}
|
||||||
|
|
||||||
return <FoldersClient userId={session.user.id} />;
|
return <DecksClient userId={session.user.id} />;
|
||||||
}
|
}
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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} />;
|
|
||||||
}
|
|
||||||
@@ -5,18 +5,17 @@
|
|||||||
* 使用 @theme 指令定义主题变量
|
* 使用 @theme 指令定义主题变量
|
||||||
*/
|
*/
|
||||||
@theme {
|
@theme {
|
||||||
/* 主色 - Teal */
|
--color-primary-50: var(--primary-50);
|
||||||
--color-primary-50: #f0f9f8;
|
--color-primary-100: var(--primary-100);
|
||||||
--color-primary-100: #e0f2f0;
|
--color-primary-200: var(--primary-200);
|
||||||
--color-primary-200: #bce6e1;
|
--color-primary-300: var(--primary-300);
|
||||||
--color-primary-300: #8dd4cc;
|
--color-primary-400: var(--primary-400);
|
||||||
--color-primary-400: #5ec2b7;
|
--color-primary-500: var(--primary-500);
|
||||||
--color-primary-500: #35786f;
|
--color-primary-600: var(--primary-600);
|
||||||
--color-primary-600: #2a605b;
|
--color-primary-700: var(--primary-700);
|
||||||
--color-primary-700: #1f4844;
|
--color-primary-800: var(--primary-800);
|
||||||
--color-primary-800: #183835;
|
--color-primary-900: var(--primary-900);
|
||||||
--color-primary-900: #122826;
|
--color-primary-950: var(--primary-950);
|
||||||
--color-primary-950: #0a1413;
|
|
||||||
|
|
||||||
/* 中性色 */
|
/* 中性色 */
|
||||||
--color-gray-50: #f9fafb;
|
--color-gray-50: #f9fafb;
|
||||||
@@ -100,6 +99,19 @@
|
|||||||
* 定义全局 CSS 变量用于主题切换和动态样式
|
* 定义全局 CSS 变量用于主题切换和动态样式
|
||||||
*/
|
*/
|
||||||
:root {
|
:root {
|
||||||
|
/* 主题色 - 默认 Teal */
|
||||||
|
--primary-50: #f0f9f8;
|
||||||
|
--primary-100: #e0f2f0;
|
||||||
|
--primary-200: #bce6e1;
|
||||||
|
--primary-300: #8dd4cc;
|
||||||
|
--primary-400: #5ec2b7;
|
||||||
|
--primary-500: #35786f;
|
||||||
|
--primary-600: #2a605b;
|
||||||
|
--primary-700: #1f4844;
|
||||||
|
--primary-800: #183835;
|
||||||
|
--primary-900: #122826;
|
||||||
|
--primary-950: #0a1413;
|
||||||
|
|
||||||
/* 基础颜色 */
|
/* 基础颜色 */
|
||||||
--background: #ffffff;
|
--background: #ffffff;
|
||||||
--foreground: #111827;
|
--foreground: #111827;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { NextIntlClientProvider } from "next-intl";
|
|||||||
import { Navbar } from "@/components/layout/Navbar";
|
import { Navbar } from "@/components/layout/Navbar";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
width: "device-width",
|
width: "device-width",
|
||||||
@@ -25,11 +26,13 @@ export default async function RootLayout({
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={`antialiased`}>
|
<body className={`antialiased`}>
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<NextIntlClientProvider>
|
<ThemeProvider>
|
||||||
<Navbar></Navbar>
|
<NextIntlClientProvider>
|
||||||
{children}
|
<Navbar></Navbar>
|
||||||
<Toaster />
|
{children}
|
||||||
</NextIntlClientProvider>
|
<Toaster />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
57
src/app/settings/page.tsx
Normal file
57
src/app/settings/page.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTheme } from "@/components/theme-provider";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { cn } from "@/utils/cn";
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const t = useTranslations("settings");
|
||||||
|
const { currentTheme, setTheme, availableThemes } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[calc(100vh-64px)] bg-white p-4 md:p-8">
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-6">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-3">
|
||||||
|
{t("themeColor")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
{t("themeColorDescription")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 sm:grid-cols-8 gap-3">
|
||||||
|
{availableThemes.map((theme) => (
|
||||||
|
<button
|
||||||
|
key={theme.id}
|
||||||
|
onClick={() => setTheme(theme.id)}
|
||||||
|
className={cn(
|
||||||
|
"group relative flex flex-col items-center gap-2 p-2 rounded-lg transition-all",
|
||||||
|
currentTheme === theme.id
|
||||||
|
? "ring-2 ring-offset-2"
|
||||||
|
: "hover:bg-gray-50"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
["--tw-ring-color" as string]: theme.colors[500],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-full shadow-md ring-1 ring-black/10"
|
||||||
|
style={{ backgroundColor: theme.colors[500] }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-600 group-hover:text-gray-900">
|
||||||
|
{theme.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/auth.ts
46
src/auth.ts
@@ -18,21 +18,28 @@ export const auth = betterAuth({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
requireEmailVerification: true,
|
requireEmailVerification: true,
|
||||||
sendResetPassword: async ({ user, url }) => {
|
sendResetPassword: async ({ user, url }) => {
|
||||||
void sendEmail({
|
const result = await 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) {
|
||||||
|
console.error("[email] Failed to send reset password email:", result.error);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
emailVerification: {
|
emailVerification: {
|
||||||
sendOnSignUp: true,
|
sendOnSignUp: true,
|
||||||
|
sendOnSignIn: true,
|
||||||
sendVerificationEmail: async ({ user, url }) => {
|
sendVerificationEmail: async ({ user, url }) => {
|
||||||
void sendEmail({
|
const result = await 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) {
|
||||||
|
console.error("[email] Failed to send verification email:", result.error);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
socialProviders: {
|
socialProviders: {
|
||||||
@@ -44,13 +51,34 @@ 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") return;
|
if (ctx.path === "/sign-up/email" || ctx.path === "/update-user") {
|
||||||
|
const body = ctx.body as { username?: string };
|
||||||
const body = ctx.body as { username?: string };
|
if (!body.username || body.username.trim() === "") {
|
||||||
if (!body.username || body.username.trim() === "") {
|
throw new APIError("BAD_REQUEST", {
|
||||||
throw new APIError("BAD_REQUEST", {
|
message: "Username is required",
|
||||||
message: "Username is required",
|
});
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.path === "/sign-in/username") {
|
||||||
|
const body = ctx.body as { username?: string };
|
||||||
|
if (body.username) {
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ username: body.username },
|
||||||
|
{ email: body.username },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { emailVerified: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user && !user.emailVerified) {
|
||||||
|
throw new APIError("FORBIDDEN", {
|
||||||
|
message: "Please verify your email address before signing in",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
254
src/components/deck/ImportExport.tsx
Normal file
254
src/components/deck/ImportExport.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { Upload, Download, FileUp, X, Check, Loader2 } from "lucide-react";
|
||||||
|
import { LightButton, PrimaryButton } from "@/design-system/base/button";
|
||||||
|
import { Modal } from "@/design-system/overlay/modal";
|
||||||
|
import { actionPreviewApkg, actionImportApkg } from "@/modules/import/import-action";
|
||||||
|
import { actionExportApkg } from "@/modules/export/export-action";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
interface ImportExportProps {
|
||||||
|
deckId?: number;
|
||||||
|
deckName?: string;
|
||||||
|
onImportComplete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreviewDeck {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
cardCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportButton({ onImportComplete }: ImportExportProps) {
|
||||||
|
const t = useTranslations("decks");
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [step, setStep] = useState<"upload" | "select" | "importing">("upload");
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [decks, setDecks] = useState<PreviewDeck[]>([]);
|
||||||
|
const [selectedDeckId, setSelectedDeckId] = useState<number | null>(null);
|
||||||
|
const [deckName, setDeckName] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selectedFile = e.target.files?.[0];
|
||||||
|
if (!selectedFile) return;
|
||||||
|
|
||||||
|
if (!selectedFile.name.endsWith(".apkg")) {
|
||||||
|
toast.error("Please select an .apkg file");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFile(selectedFile);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", selectedFile);
|
||||||
|
|
||||||
|
const result = await actionPreviewApkg(formData);
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if (result.success && result.decks) {
|
||||||
|
setDecks(result.decks);
|
||||||
|
setStep("select");
|
||||||
|
if (result.decks.length === 1) {
|
||||||
|
setSelectedDeckId(result.decks[0].id);
|
||||||
|
setDeckName(result.decks[0].name);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error(result.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
if (!file || selectedDeckId === null) {
|
||||||
|
toast.error("Please select a deck to import");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStep("importing");
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("deckId", selectedDeckId.toString());
|
||||||
|
if (deckName) {
|
||||||
|
formData.append("deckName", deckName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await actionImportApkg(formData);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message);
|
||||||
|
setIsModalOpen(false);
|
||||||
|
resetState();
|
||||||
|
onImportComplete?.();
|
||||||
|
} else {
|
||||||
|
toast.error(result.message);
|
||||||
|
setStep("select");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetState = () => {
|
||||||
|
setStep("upload");
|
||||||
|
setFile(null);
|
||||||
|
setDecks([]);
|
||||||
|
setSelectedDeckId(null);
|
||||||
|
setDeckName("");
|
||||||
|
setLoading(false);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
resetState();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LightButton onClick={() => setIsModalOpen(true)}>
|
||||||
|
<Upload size={18} />
|
||||||
|
{t("importApkg")}
|
||||||
|
</LightButton>
|
||||||
|
|
||||||
|
<Modal open={isModalOpen} onClose={handleClose}>
|
||||||
|
<div className="p-6 w-full max-w-md">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-xl font-bold">{t("importApkg")}</h2>
|
||||||
|
<button onClick={handleClose} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === "upload" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div
|
||||||
|
className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-primary-500 transition-colors"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<FileUp size={40} className="mx-auto text-gray-400 mb-2" />
|
||||||
|
<p className="text-gray-600">{t("clickToUpload")}</p>
|
||||||
|
<p className="text-sm text-gray-400">{t("apkgFilesOnly")}</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".apkg"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
/>
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center gap-2 text-gray-500">
|
||||||
|
<Loader2 size={20} className="animate-spin" />
|
||||||
|
<span>{t("parsing")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "select" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-600">{t("foundDecks", { count: decks.length })}</p>
|
||||||
|
|
||||||
|
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||||
|
{decks.map((deck) => (
|
||||||
|
<div
|
||||||
|
key={deck.id}
|
||||||
|
className={`p-3 border rounded-lg cursor-pointer transition-colors ${
|
||||||
|
selectedDeckId === deck.id
|
||||||
|
? "border-primary-500 bg-primary-50"
|
||||||
|
: "border-gray-200 hover:border-gray-300"
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedDeckId(deck.id);
|
||||||
|
setDeckName(deck.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium">{deck.name}</span>
|
||||||
|
<span className="text-sm text-gray-500">{deck.cardCount} cards</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{t("deckName")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={deckName}
|
||||||
|
onChange={(e) => setDeckName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder={t("enterDeckName")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<LightButton onClick={() => setStep("upload")} className="flex-1">
|
||||||
|
{t("back")}
|
||||||
|
</LightButton>
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={selectedDeckId === null}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{t("import")}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "importing" && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8">
|
||||||
|
<Loader2 size={40} className="animate-spin text-primary-500 mb-4" />
|
||||||
|
<p className="text-gray-600">{t("importing")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportButton({ deckId, deckName }: ImportExportProps) {
|
||||||
|
const t = useTranslations("decks");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
if (!deckId) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const result = await actionExportApkg(deckId);
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if (result.success && result.data && result.filename) {
|
||||||
|
const blob = new Blob([result.data], { type: "application/octet-stream" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = result.filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toast.success(t("exportSuccess"));
|
||||||
|
} else {
|
||||||
|
toast.error(result.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LightButton onClick={handleExport} disabled={loading}>
|
||||||
|
{loading ? <Loader2 size={18} className="animate-spin" /> : <Download size={18} />}
|
||||||
|
{t("exportApkg")}
|
||||||
|
</LightButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/components/follow/FollowButton.tsx
Normal file
47
src/components/follow/FollowButton.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { PrimaryButton, LightButton } from "@/design-system/base/button";
|
||||||
|
import { actionToggleFollow } from "@/modules/follow/follow-action";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface FollowButtonProps {
|
||||||
|
targetUserId: string;
|
||||||
|
initialIsFollowing: boolean;
|
||||||
|
onFollowChange?: (isFollowing: boolean, followersCount: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FollowButton({
|
||||||
|
targetUserId,
|
||||||
|
initialIsFollowing,
|
||||||
|
onFollowChange,
|
||||||
|
}: FollowButtonProps) {
|
||||||
|
const [isFollowing, setIsFollowing] = useState(initialIsFollowing);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleToggleFollow = () => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await actionToggleFollow({ targetUserId });
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setIsFollowing(result.data.isFollowing);
|
||||||
|
onFollowChange?.(result.data.isFollowing, result.data.followersCount);
|
||||||
|
} else {
|
||||||
|
toast.error(result.message || "Failed to update follow status");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isFollowing) {
|
||||||
|
return (
|
||||||
|
<LightButton onClick={handleToggleFollow} disabled={isPending}>
|
||||||
|
{isPending ? "..." : "Following"}
|
||||||
|
</LightButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PrimaryButton onClick={handleToggleFollow} disabled={isPending}>
|
||||||
|
{isPending ? "..." : "Follow"}
|
||||||
|
</PrimaryButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/components/follow/FollowStats.tsx
Normal file
54
src/components/follow/FollowStats.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { FollowButton } from "./FollowButton";
|
||||||
|
|
||||||
|
interface FollowStatsProps {
|
||||||
|
userId: string;
|
||||||
|
initialFollowersCount: number;
|
||||||
|
initialFollowingCount: number;
|
||||||
|
initialIsFollowing: boolean;
|
||||||
|
currentUserId?: string;
|
||||||
|
isOwnProfile: boolean;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FollowStats({
|
||||||
|
userId,
|
||||||
|
initialFollowersCount,
|
||||||
|
initialFollowingCount,
|
||||||
|
initialIsFollowing,
|
||||||
|
currentUserId,
|
||||||
|
isOwnProfile,
|
||||||
|
username,
|
||||||
|
}: FollowStatsProps) {
|
||||||
|
const [followersCount, setFollowersCount] = useState(initialFollowersCount);
|
||||||
|
|
||||||
|
const handleFollowChange = (isFollowing: boolean, count: number) => {
|
||||||
|
setFollowersCount(count);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<a
|
||||||
|
href={`/users/${username}/followers`}
|
||||||
|
className="text-sm text-gray-600 hover:text-primary-500 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="font-semibold text-gray-900">{followersCount}</span> followers
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`/users/${username}/following`}
|
||||||
|
className="text-sm text-gray-600 hover:text-primary-500 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="font-semibold text-gray-900">{initialFollowingCount}</span> following
|
||||||
|
</a>
|
||||||
|
{currentUserId && !isOwnProfile && (
|
||||||
|
<FollowButton
|
||||||
|
targetUserId={userId}
|
||||||
|
initialIsFollowing={initialIsFollowing}
|
||||||
|
onFollowChange={handleFollowChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
src/components/follow/UserList.tsx
Normal file
68
src/components/follow/UserList.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface UserItem {
|
||||||
|
id: string;
|
||||||
|
username: string | null;
|
||||||
|
displayUsername: string | null;
|
||||||
|
image: string | null;
|
||||||
|
bio: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserListProps {
|
||||||
|
users: UserItem[];
|
||||||
|
emptyMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserList({ users, emptyMessage }: UserListProps) {
|
||||||
|
if (users.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
{emptyMessage}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{users.map((user) => (
|
||||||
|
<Link
|
||||||
|
key={user.id}
|
||||||
|
href={`/users/${user.username || user.id}`}
|
||||||
|
className="flex items-center gap-4 p-4 bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
{user.image ? (
|
||||||
|
<div className="relative w-12 h-12 rounded-full overflow-hidden flex-shrink-0">
|
||||||
|
<Image
|
||||||
|
src={user.image}
|
||||||
|
alt={user.displayUsername || user.username || "User"}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 rounded-full bg-primary-500 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-lg font-bold text-white">
|
||||||
|
{(user.displayUsername || user.username || "U")[0].toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-semibold text-gray-900 truncate">
|
||||||
|
{user.displayUsername || user.username || "Anonymous"}
|
||||||
|
</div>
|
||||||
|
{user.username && (
|
||||||
|
<div className="text-sm text-gray-500">@{user.username}</div>
|
||||||
|
)}
|
||||||
|
{user.bio && (
|
||||||
|
<div className="text-sm text-gray-600 truncate mt-1">
|
||||||
|
{user.bio}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,81 +1,104 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { GhostLightButton } from "@/design-system/base/button";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { useState } from "react";
|
|
||||||
import { Languages } from "lucide-react";
|
import { Languages } from "lucide-react";
|
||||||
|
import { cn } from "@/utils/cn";
|
||||||
|
|
||||||
|
const languages = [
|
||||||
|
{ code: "en-US", label: "English" },
|
||||||
|
{ code: "zh-CN", label: "中文" },
|
||||||
|
{ code: "ja-JP", label: "日本語" },
|
||||||
|
{ code: "ko-KR", label: "한국어" },
|
||||||
|
{ code: "de-DE", label: "Deutsch" },
|
||||||
|
{ code: "fr-FR", label: "Français" },
|
||||||
|
{ code: "it-IT", label: "Italiano" },
|
||||||
|
{ code: "ug-CN", label: "ئۇيغۇرچە" },
|
||||||
|
];
|
||||||
|
|
||||||
export function LanguageSettings() {
|
export function LanguageSettings() {
|
||||||
const [showLanguageMenu, setShowLanguageMenu] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const handleLanguageClick = () => {
|
const [pendingLocale, setPendingLocale] = useState<string | null>(null);
|
||||||
setShowLanguageMenu((prev) => !prev);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const setLocale = async (locale: string) => {
|
|
||||||
document.cookie = `locale=${locale}`;
|
if (isOpen) {
|
||||||
window.location.reload();
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
};
|
};
|
||||||
return (
|
}, [isOpen]);
|
||||||
<>
|
|
||||||
<GhostLightButton
|
useEffect(() => {
|
||||||
size="md"
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
onClick={handleLanguageClick}
|
if (e.key === "Escape") setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingLocale) {
|
||||||
|
document.cookie = `locale=${pendingLocale}; path=/`;
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}, [pendingLocale]);
|
||||||
|
|
||||||
|
const setLocale = useCallback((locale: string) => {
|
||||||
|
setPendingLocale(locale);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="flex items-center justify-center p-2 rounded-md text-white hover:bg-white/10 transition-colors"
|
||||||
|
aria-label="切换语言"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
>
|
||||||
|
<Languages size={20} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute right-0 top-full mt-2 w-40 rounded-lg bg-white shadow-lg ring-1 ring-black/5 overflow-hidden transition-all duration-200 origin-top-right z-50",
|
||||||
|
isOpen
|
||||||
|
? "opacity-100 scale-100"
|
||||||
|
: "opacity-0 scale-95 pointer-events-none"
|
||||||
|
)}
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<div className="py-1">
|
||||||
|
{languages.map((lang) => (
|
||||||
|
<button
|
||||||
|
key={lang.code}
|
||||||
|
onClick={() => setLocale(lang.code)}
|
||||||
|
className="w-full flex items-center px-4 py-2.5 text-gray-700 hover:bg-gray-50 hover:text-gray-900 transition-colors text-left"
|
||||||
|
role="menuitem"
|
||||||
>
|
>
|
||||||
<Languages size={20} />
|
{lang.label}
|
||||||
</GhostLightButton>
|
</button>
|
||||||
<div className="relative">
|
))}
|
||||||
{showLanguageMenu && (
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<div className="absolute top-10 right-0 rounded-md shadow-md flex flex-col gap-2">
|
|
||||||
<GhostLightButton
|
{isOpen && (
|
||||||
className="w-full bg-primary-500"
|
<div
|
||||||
onClick={() => setLocale("en-US")}
|
className="fixed inset-0 z-40"
|
||||||
>
|
onClick={() => setIsOpen(false)}
|
||||||
English
|
aria-hidden="true"
|
||||||
</GhostLightButton>
|
/>
|
||||||
<GhostLightButton
|
)}
|
||||||
className="w-full bg-primary-500"
|
</div>
|
||||||
onClick={() => setLocale("zh-CN")}
|
);
|
||||||
>
|
|
||||||
中文
|
|
||||||
</GhostLightButton>
|
|
||||||
<GhostLightButton
|
|
||||||
className="w-full bg-primary-500"
|
|
||||||
onClick={() => setLocale("ja-JP")}
|
|
||||||
>
|
|
||||||
日本語
|
|
||||||
</GhostLightButton>
|
|
||||||
<GhostLightButton
|
|
||||||
className="w-full bg-primary-500"
|
|
||||||
onClick={() => setLocale("ko-KR")}
|
|
||||||
>
|
|
||||||
한국어
|
|
||||||
</GhostLightButton>
|
|
||||||
<GhostLightButton
|
|
||||||
className="w-full bg-primary-500"
|
|
||||||
onClick={() => setLocale("de-DE")}
|
|
||||||
>
|
|
||||||
Deutsch
|
|
||||||
</GhostLightButton>
|
|
||||||
<GhostLightButton
|
|
||||||
className="w-full bg-primary-500"
|
|
||||||
onClick={() => setLocale("fr-FR")}
|
|
||||||
>
|
|
||||||
Français
|
|
||||||
</GhostLightButton>
|
|
||||||
<GhostLightButton
|
|
||||||
className="w-full bg-primary-500"
|
|
||||||
onClick={() => setLocale("it-IT")}
|
|
||||||
>
|
|
||||||
Italiano
|
|
||||||
</GhostLightButton>
|
|
||||||
<GhostLightButton
|
|
||||||
className="w-full bg-primary-500"
|
|
||||||
onClick={() => setLocale("ug-CN")}
|
|
||||||
>
|
|
||||||
ئۇيغۇرچە
|
|
||||||
</GhostLightButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div></>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
92
src/components/layout/MobileMenu.tsx
Normal file
92
src/components/layout/MobileMenu.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { Menu, X } from "lucide-react";
|
||||||
|
import { cn } from "@/utils/cn";
|
||||||
|
import type { NavigationItem } from "./Navbar";
|
||||||
|
|
||||||
|
interface MobileMenuProps {
|
||||||
|
items: NavigationItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileMenu({ items }: MobileMenuProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="flex items-center justify-center p-2 rounded-md text-white hover:bg-white/10 transition-colors"
|
||||||
|
aria-label={isOpen ? "关闭菜单" : "打开菜单"}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
>
|
||||||
|
{isOpen ? <X size={24} /> : <Menu size={24} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute right-0 top-full mt-2 w-56 rounded-lg bg-white shadow-lg ring-1 ring-black/5 overflow-hidden transition-all duration-200 origin-top-right z-50",
|
||||||
|
isOpen
|
||||||
|
? "opacity-100 scale-100"
|
||||||
|
: "opacity-0 scale-95 pointer-events-none"
|
||||||
|
)}
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<div className="py-1">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<a
|
||||||
|
key={index}
|
||||||
|
href={item.href}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-gray-50 hover:text-gray-900 transition-colors"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
target={item.external ? "_blank" : undefined}
|
||||||
|
rel={item.external ? "noopener noreferrer" : undefined}
|
||||||
|
>
|
||||||
|
{item.icon && <span className="shrink-0 text-gray-500">{item.icon}</span>}
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
import Image from "next/image";
|
import { Compass, Folder, Heart, Home, Settings, User, Github } from "lucide-react";
|
||||||
import { IMAGES } from "@/config/images";
|
|
||||||
import { Compass, Folder, Heart, Home, User } from "lucide-react";
|
|
||||||
import { LanguageSettings } from "./LanguageSettings";
|
import { LanguageSettings } from "./LanguageSettings";
|
||||||
|
import { MobileMenu } from "./MobileMenu";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { GhostLightButton } from "@/design-system/base/button";
|
import { GhostLightButton } from "@/design-system/base/button";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface NavigationItem {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
external?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export async function Navbar() {
|
export async function Navbar() {
|
||||||
const t = await getTranslations("navbar");
|
const t = await getTranslations("navbar");
|
||||||
@@ -13,49 +20,38 @@ export async function Navbar() {
|
|||||||
headers: await headers()
|
headers: await headers()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mobileMenuItems: NavigationItem[] = [
|
||||||
|
{ label: t("folders"), href: "/decks", icon: <Folder size={18} /> },
|
||||||
|
{ label: t("explore"), href: "/explore", icon: <Compass size={18} /> },
|
||||||
|
...(session ? [{ label: t("favorites"), href: "/favorites", icon: <Heart size={18} /> }] : []),
|
||||||
|
{ label: t("sourceCode"), href: "https://github.com/GoddoNebianU/learn-languages", icon: <Github size={18} />, external: true },
|
||||||
|
{ label: t("settings"), href: "/settings", icon: <Settings size={18} /> },
|
||||||
|
...(session
|
||||||
|
? [{ label: t("profile"), href: "/profile", icon: <User size={18} /> }]
|
||||||
|
: [{ label: t("sign_in"), href: "/login", icon: <User size={18} /> }]
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between items-center w-full h-16 px-4 md:px-8 bg-primary-500 text-white">
|
<div className="flex justify-between items-center w-full h-16 px-4 md:px-8 bg-primary-500 text-white">
|
||||||
<GhostLightButton href="/" className="border-b hidden! md:block!" size="md">
|
<GhostLightButton href="/" className="border-b hidden! md:block!" size="md">
|
||||||
{t("title")}
|
{t("title")}
|
||||||
</GhostLightButton>
|
</GhostLightButton>
|
||||||
<GhostLightButton className="block! md:hidden!" size="md" href={"/"}>
|
<GhostLightButton className="block! md:hidden!" size="md" href="/">
|
||||||
<Home size={20} />
|
<Home size={20} />
|
||||||
</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
|
<GhostLightButton href="/decks" className="md:block! hidden!" size="md">
|
||||||
className="md:hidden! block!"
|
|
||||||
size="md"
|
|
||||||
href="https://github.com/GoddoNebianU/learn-languages"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={IMAGES.github_mark_white}
|
|
||||||
alt="GitHub"
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
/>
|
|
||||||
</GhostLightButton>
|
|
||||||
<GhostLightButton href="/folders" className="md:block! hidden!" size="md">
|
|
||||||
{t("folders")}
|
{t("folders")}
|
||||||
</GhostLightButton>
|
</GhostLightButton>
|
||||||
<GhostLightButton href="/folders" className="md:hidden! block!" size="md">
|
|
||||||
<Folder size={20} />
|
|
||||||
</GhostLightButton>
|
|
||||||
<GhostLightButton href="/explore" className="md:block! hidden!" size="md">
|
<GhostLightButton href="/explore" className="md:block! hidden!" size="md">
|
||||||
{t("explore")}
|
{t("explore")}
|
||||||
</GhostLightButton>
|
</GhostLightButton>
|
||||||
<GhostLightButton href="/explore" className="md:hidden! block!" size="md">
|
|
||||||
<Compass size={20} />
|
|
||||||
</GhostLightButton>
|
|
||||||
{session && (
|
{session && (
|
||||||
<>
|
<GhostLightButton href="/favorites" className="md:block! hidden!" size="md">
|
||||||
<GhostLightButton href="/favorites" className="md:block! hidden!" size="md">
|
{t("favorites")}
|
||||||
{t("favorites")}
|
</GhostLightButton>
|
||||||
</GhostLightButton>
|
|
||||||
<GhostLightButton href="/favorites" className="md:hidden! block!" size="md">
|
|
||||||
<Heart size={20} />
|
|
||||||
</GhostLightButton>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<GhostLightButton
|
<GhostLightButton
|
||||||
className="hidden! md:block!"
|
className="hidden! md:block!"
|
||||||
@@ -64,23 +60,21 @@ export async function Navbar() {
|
|||||||
>
|
>
|
||||||
{t("sourceCode")}
|
{t("sourceCode")}
|
||||||
</GhostLightButton>
|
</GhostLightButton>
|
||||||
{
|
<GhostLightButton href="/settings" className="hidden! md:block!" size="md">
|
||||||
(() => {
|
{t("settings")}
|
||||||
return session &&
|
</GhostLightButton>
|
||||||
<>
|
{session ? (
|
||||||
<GhostLightButton href="/profile" className="hidden! md:block!" size="md">{t("profile")}</GhostLightButton>
|
<GhostLightButton href="/profile" className="hidden! md:block!" size="md">
|
||||||
<GhostLightButton href="/profile" className="md:hidden! block!" size="md">
|
{t("profile")}
|
||||||
<User size={20} />
|
</GhostLightButton>
|
||||||
</GhostLightButton>
|
) : (
|
||||||
</>
|
<GhostLightButton href="/login" className="hidden! md:block!" size="md">
|
||||||
|| <>
|
{t("sign_in")}
|
||||||
<GhostLightButton href="/login" className="hidden! md:block!" size="md">{t("sign_in")}</GhostLightButton>
|
</GhostLightButton>
|
||||||
<GhostLightButton href="/login" className="md:hidden! block!" size="md">
|
)}
|
||||||
<User size={20} />
|
<div className="md:hidden!">
|
||||||
</GhostLightButton>
|
<MobileMenu items={mobileMenuItems} />
|
||||||
</>;
|
</div>
|
||||||
})()
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
79
src/components/theme-provider.tsx
Normal file
79
src/components/theme-provider.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useState, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
THEME_PRESETS,
|
||||||
|
DEFAULT_THEME,
|
||||||
|
getThemePreset,
|
||||||
|
applyThemeColors,
|
||||||
|
type ThemePreset,
|
||||||
|
} from "@/shared/theme-presets";
|
||||||
|
|
||||||
|
type ThemeContextType = {
|
||||||
|
currentTheme: string;
|
||||||
|
themePreset: ThemePreset;
|
||||||
|
setTheme: (themeId: string) => void;
|
||||||
|
availableThemes: ThemePreset[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextType | null>(null);
|
||||||
|
|
||||||
|
const STORAGE_KEY = "theme-preset";
|
||||||
|
|
||||||
|
function getInitialTheme(): string {
|
||||||
|
if (typeof window === "undefined") return DEFAULT_THEME;
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return saved && getThemePreset(saved) ? saved : DEFAULT_THEME;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [currentTheme, setCurrentTheme] = useState<string>(DEFAULT_THEME);
|
||||||
|
const [hydrated, setHydrated] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const savedTheme = getInitialTheme();
|
||||||
|
if (savedTheme !== currentTheme) {
|
||||||
|
setCurrentTheme(savedTheme);
|
||||||
|
}
|
||||||
|
setHydrated(true);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hydrated) return;
|
||||||
|
const preset = getThemePreset(currentTheme);
|
||||||
|
if (preset) {
|
||||||
|
applyThemeColors(preset);
|
||||||
|
localStorage.setItem(STORAGE_KEY, currentTheme);
|
||||||
|
}
|
||||||
|
}, [currentTheme, hydrated]);
|
||||||
|
|
||||||
|
const setTheme = (themeId: string) => {
|
||||||
|
if (getThemePreset(themeId)) {
|
||||||
|
setCurrentTheme(themeId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const themePreset = useMemo(() => getThemePreset(currentTheme) || THEME_PRESETS[0], [currentTheme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider
|
||||||
|
value={{
|
||||||
|
currentTheme,
|
||||||
|
themePreset,
|
||||||
|
setTheme,
|
||||||
|
availableThemes: THEME_PRESETS,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useTheme must be used within a ThemeProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ const selectVariants = cva(
|
|||||||
default: "border-b-2 border-gray-300 bg-transparent rounded-t-md",
|
default: "border-b-2 border-gray-300 bg-transparent rounded-t-md",
|
||||||
bordered: "border-gray-300 bg-white",
|
bordered: "border-gray-300 bg-white",
|
||||||
filled: "border-transparent bg-gray-100",
|
filled: "border-transparent bg-gray-100",
|
||||||
|
light: "border-transparent bg-gray-100 shadow-sm hover:bg-gray-200 font-semibold cursor-pointer",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
sm: "h-9 px-3 text-sm",
|
sm: "h-9 px-3 text-sm",
|
||||||
@@ -48,6 +49,11 @@ const selectVariants = cva(
|
|||||||
error: true,
|
error: true,
|
||||||
className: "bg-error-50",
|
className: "bg-error-50",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
variant: "light",
|
||||||
|
error: true,
|
||||||
|
className: "bg-error-50 hover:bg-error-100",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
|
|||||||
414
src/lib/anki/apkg-exporter.ts
Normal file
414
src/lib/anki/apkg-exporter.ts
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
import JSZip from "jszip";
|
||||||
|
import initSqlJs from "sql.js";
|
||||||
|
import type { Database } from "sql.js";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import type {
|
||||||
|
AnkiDeck,
|
||||||
|
AnkiNoteType,
|
||||||
|
AnkiDeckConfig,
|
||||||
|
AnkiNoteRow,
|
||||||
|
AnkiCardRow,
|
||||||
|
AnkiRevlogRow,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const BASE91_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&()*+,-./:;<=>?@[]^_`{|}~";
|
||||||
|
|
||||||
|
function generateGuid(): string {
|
||||||
|
let guid = "";
|
||||||
|
const bytes = new Uint8Array(10);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
guid += BASE91_CHARS[bytes[i] % BASE91_CHARS.length];
|
||||||
|
}
|
||||||
|
return guid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checksum(text: string): number {
|
||||||
|
const hash = createHash("sha1").update(text.normalize("NFC")).digest("hex");
|
||||||
|
return parseInt(hash.substring(0, 8), 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCollectionSql(): string {
|
||||||
|
return `
|
||||||
|
CREATE TABLE col (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
crt INTEGER NOT NULL,
|
||||||
|
mod INTEGER NOT NULL,
|
||||||
|
scm INTEGER NOT NULL,
|
||||||
|
ver INTEGER NOT NULL DEFAULT 11,
|
||||||
|
dty INTEGER NOT NULL DEFAULT 0,
|
||||||
|
usn INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
conf TEXT NOT NULL,
|
||||||
|
models TEXT NOT NULL,
|
||||||
|
decks TEXT NOT NULL,
|
||||||
|
dconf TEXT NOT NULL,
|
||||||
|
tags TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE notes (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
guid TEXT NOT NULL,
|
||||||
|
mid INTEGER NOT NULL,
|
||||||
|
mod INTEGER NOT NULL,
|
||||||
|
usn INTEGER NOT NULL,
|
||||||
|
tags TEXT NOT NULL,
|
||||||
|
flds TEXT NOT NULL,
|
||||||
|
sfld TEXT NOT NULL,
|
||||||
|
csum INTEGER NOT NULL,
|
||||||
|
flags INTEGER NOT NULL DEFAULT 0,
|
||||||
|
data TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE cards (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
nid INTEGER NOT NULL,
|
||||||
|
did INTEGER NOT NULL,
|
||||||
|
ord INTEGER NOT NULL,
|
||||||
|
mod INTEGER NOT NULL,
|
||||||
|
usn INTEGER NOT NULL,
|
||||||
|
type INTEGER NOT NULL,
|
||||||
|
queue INTEGER NOT NULL,
|
||||||
|
due INTEGER NOT NULL,
|
||||||
|
ivl INTEGER NOT NULL,
|
||||||
|
factor INTEGER NOT NULL,
|
||||||
|
reps INTEGER NOT NULL,
|
||||||
|
lapses INTEGER NOT NULL,
|
||||||
|
left INTEGER NOT NULL,
|
||||||
|
odue INTEGER NOT NULL DEFAULT 0,
|
||||||
|
odid INTEGER NOT NULL DEFAULT 0,
|
||||||
|
flags INTEGER NOT NULL DEFAULT 0,
|
||||||
|
data TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE revlog (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
cid INTEGER NOT NULL,
|
||||||
|
usn INTEGER NOT NULL,
|
||||||
|
ease INTEGER NOT NULL,
|
||||||
|
ivl INTEGER NOT NULL,
|
||||||
|
lastIvl INTEGER NOT NULL,
|
||||||
|
factor INTEGER NOT NULL,
|
||||||
|
time INTEGER NOT NULL,
|
||||||
|
type INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE graves (
|
||||||
|
usn INTEGER NOT NULL,
|
||||||
|
oid INTEGER NOT NULL,
|
||||||
|
type INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_cards_nid ON cards (nid);
|
||||||
|
CREATE INDEX ix_cards_sched ON cards (did, queue, due);
|
||||||
|
CREATE INDEX ix_cards_usn ON cards (usn);
|
||||||
|
CREATE INDEX ix_notes_csum ON notes (csum);
|
||||||
|
CREATE INDEX ix_notes_usn ON notes (usn);
|
||||||
|
CREATE INDEX ix_revlog_cid ON revlog (cid);
|
||||||
|
CREATE INDEX ix_revlog_usn ON revlog (usn);
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCardType(type: string): number {
|
||||||
|
switch (type) {
|
||||||
|
case "NEW": return 0;
|
||||||
|
case "LEARNING": return 1;
|
||||||
|
case "REVIEW": return 2;
|
||||||
|
case "RELEARNING": return 3;
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCardQueue(queue: string): number {
|
||||||
|
switch (queue) {
|
||||||
|
case "USER_BURIED": return -3;
|
||||||
|
case "SCHED_BURIED": return -2;
|
||||||
|
case "SUSPENDED": return -1;
|
||||||
|
case "NEW": return 0;
|
||||||
|
case "LEARNING": return 1;
|
||||||
|
case "REVIEW": return 2;
|
||||||
|
case "IN_LEARNING": return 3;
|
||||||
|
case "PREVIEW": return 4;
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportDeckData {
|
||||||
|
deck: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
desc: string;
|
||||||
|
collapsed: boolean;
|
||||||
|
conf: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
noteType: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
kind: "STANDARD" | "CLOZE";
|
||||||
|
css: string;
|
||||||
|
fields: { name: string; ord: number }[];
|
||||||
|
templates: { name: string; ord: number; qfmt: string; afmt: string }[];
|
||||||
|
};
|
||||||
|
notes: {
|
||||||
|
id: bigint;
|
||||||
|
guid: string;
|
||||||
|
tags: string;
|
||||||
|
flds: string;
|
||||||
|
sfld: string;
|
||||||
|
csum: number;
|
||||||
|
}[];
|
||||||
|
cards: {
|
||||||
|
id: bigint;
|
||||||
|
noteId: bigint;
|
||||||
|
ord: number;
|
||||||
|
type: string;
|
||||||
|
queue: string;
|
||||||
|
due: number;
|
||||||
|
ivl: number;
|
||||||
|
factor: number;
|
||||||
|
reps: number;
|
||||||
|
lapses: number;
|
||||||
|
left: number;
|
||||||
|
}[];
|
||||||
|
revlogs: {
|
||||||
|
id: bigint;
|
||||||
|
cardId: bigint;
|
||||||
|
ease: number;
|
||||||
|
ivl: number;
|
||||||
|
lastIvl: number;
|
||||||
|
factor: number;
|
||||||
|
time: number;
|
||||||
|
type: number;
|
||||||
|
}[];
|
||||||
|
media: Map<string, Buffer>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDatabase(data: ExportDeckData): Promise<Uint8Array> {
|
||||||
|
const SQL = await initSqlJs({
|
||||||
|
locateFile: (file: string) => `https://sql.js.org/dist/${file}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const db = new SQL.Database();
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.run(createCollectionSql());
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const nowSeconds = Math.floor(now / 1000);
|
||||||
|
|
||||||
|
const defaultConfig = {
|
||||||
|
dueCounts: true,
|
||||||
|
estTimes: true,
|
||||||
|
newSpread: 0,
|
||||||
|
curDeck: data.deck.id,
|
||||||
|
curModel: data.noteType.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const deckJson: Record<string, AnkiDeck> = {
|
||||||
|
[data.deck.id.toString()]: {
|
||||||
|
id: data.deck.id,
|
||||||
|
mod: nowSeconds,
|
||||||
|
name: data.deck.name,
|
||||||
|
usn: -1,
|
||||||
|
lrnToday: [0, 0],
|
||||||
|
revToday: [0, 0],
|
||||||
|
newToday: [0, 0],
|
||||||
|
timeToday: [0, 0],
|
||||||
|
collapsed: data.deck.collapsed,
|
||||||
|
browserCollapsed: false,
|
||||||
|
desc: data.deck.desc,
|
||||||
|
dyn: 0,
|
||||||
|
conf: 1,
|
||||||
|
extendNew: 0,
|
||||||
|
extendRev: 0,
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
id: 1,
|
||||||
|
mod: nowSeconds,
|
||||||
|
name: "Default",
|
||||||
|
usn: -1,
|
||||||
|
lrnToday: [0, 0],
|
||||||
|
revToday: [0, 0],
|
||||||
|
newToday: [0, 0],
|
||||||
|
timeToday: [0, 0],
|
||||||
|
collapsed: false,
|
||||||
|
browserCollapsed: false,
|
||||||
|
desc: "",
|
||||||
|
dyn: 0,
|
||||||
|
conf: 1,
|
||||||
|
extendNew: 0,
|
||||||
|
extendRev: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const noteTypeJson: Record<string, AnkiNoteType> = {
|
||||||
|
[data.noteType.id.toString()]: {
|
||||||
|
id: data.noteType.id,
|
||||||
|
name: data.noteType.name,
|
||||||
|
type: data.noteType.kind === "CLOZE" ? 1 : 0,
|
||||||
|
mod: nowSeconds,
|
||||||
|
usn: -1,
|
||||||
|
sortf: 0,
|
||||||
|
did: data.deck.id,
|
||||||
|
flds: data.noteType.fields.map((f, i) => ({
|
||||||
|
id: now + i,
|
||||||
|
name: f.name,
|
||||||
|
ord: f.ord,
|
||||||
|
sticky: false,
|
||||||
|
rtl: false,
|
||||||
|
font: "Arial",
|
||||||
|
size: 20,
|
||||||
|
media: [],
|
||||||
|
})),
|
||||||
|
tmpls: data.noteType.templates.map((t, i) => ({
|
||||||
|
id: now + i + 100,
|
||||||
|
name: t.name,
|
||||||
|
ord: t.ord,
|
||||||
|
qfmt: t.qfmt,
|
||||||
|
afmt: t.afmt,
|
||||||
|
bqfmt: "",
|
||||||
|
bafmt: "",
|
||||||
|
did: null,
|
||||||
|
})),
|
||||||
|
css: data.noteType.css,
|
||||||
|
latexPre: "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n",
|
||||||
|
latexPost: "\\end{document}",
|
||||||
|
latexsvg: false,
|
||||||
|
req: [[0, "any", [0]]],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const deckConfigJson: Record<string, AnkiDeckConfig> = {
|
||||||
|
"1": {
|
||||||
|
id: 1,
|
||||||
|
mod: nowSeconds,
|
||||||
|
name: "Default",
|
||||||
|
usn: -1,
|
||||||
|
maxTaken: 60,
|
||||||
|
autoplay: true,
|
||||||
|
timer: 0,
|
||||||
|
replayq: true,
|
||||||
|
new: {
|
||||||
|
bury: true,
|
||||||
|
delays: [1, 10],
|
||||||
|
initialFactor: 2500,
|
||||||
|
ints: [1, 4, 7],
|
||||||
|
order: 1,
|
||||||
|
perDay: 20,
|
||||||
|
},
|
||||||
|
rev: {
|
||||||
|
bury: true,
|
||||||
|
ease4: 1.3,
|
||||||
|
ivlFct: 1,
|
||||||
|
maxIvl: 36500,
|
||||||
|
perDay: 200,
|
||||||
|
hardFactor: 1.2,
|
||||||
|
},
|
||||||
|
lapse: {
|
||||||
|
delays: [10],
|
||||||
|
leechAction: 0,
|
||||||
|
leechFails: 8,
|
||||||
|
minInt: 1,
|
||||||
|
mult: 0,
|
||||||
|
},
|
||||||
|
dyn: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO col (id, crt, mod, scm, ver, dty, usn, ls, conf, models, decks, dconf, tags)
|
||||||
|
VALUES (1, ?, ?, ?, 11, 0, 0, 0, ?, ?, ?, ?, '{}')`,
|
||||||
|
[
|
||||||
|
nowSeconds,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
JSON.stringify(defaultConfig),
|
||||||
|
JSON.stringify(noteTypeJson),
|
||||||
|
JSON.stringify(deckJson),
|
||||||
|
JSON.stringify(deckConfigJson),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const note of data.notes) {
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO notes (id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, '')`,
|
||||||
|
[
|
||||||
|
Number(note.id),
|
||||||
|
note.guid || generateGuid(),
|
||||||
|
data.noteType.id,
|
||||||
|
nowSeconds,
|
||||||
|
-1,
|
||||||
|
note.tags || " ",
|
||||||
|
note.flds,
|
||||||
|
note.sfld,
|
||||||
|
note.csum || checksum(note.sfld),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const card of data.cards) {
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO cards (id, nid, did, ord, mod, usn, type, queue, due, ivl, factor, reps, lapses, left, odue, odid, flags, data)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, '')`,
|
||||||
|
[
|
||||||
|
Number(card.id),
|
||||||
|
Number(card.noteId),
|
||||||
|
data.deck.id,
|
||||||
|
card.ord,
|
||||||
|
nowSeconds,
|
||||||
|
-1,
|
||||||
|
mapCardType(card.type),
|
||||||
|
mapCardQueue(card.queue),
|
||||||
|
card.due,
|
||||||
|
card.ivl,
|
||||||
|
card.factor,
|
||||||
|
card.reps,
|
||||||
|
card.lapses,
|
||||||
|
card.left,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const revlog of data.revlogs) {
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO revlog (id, cid, usn, ease, ivl, lastIvl, factor, time, type)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
Number(revlog.id),
|
||||||
|
Number(revlog.cardId),
|
||||||
|
-1,
|
||||||
|
revlog.ease,
|
||||||
|
revlog.ivl,
|
||||||
|
revlog.lastIvl,
|
||||||
|
revlog.factor,
|
||||||
|
revlog.time,
|
||||||
|
revlog.type,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.export();
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportApkg(data: ExportDeckData): Promise<Buffer> {
|
||||||
|
const zip = new JSZip();
|
||||||
|
|
||||||
|
const dbData = await createDatabase(data);
|
||||||
|
zip.file("collection.anki21", dbData);
|
||||||
|
|
||||||
|
const mediaMapping: Record<string, string> = {};
|
||||||
|
const mediaEntries = Array.from(data.media.entries());
|
||||||
|
|
||||||
|
mediaEntries.forEach(([filename, buffer], index) => {
|
||||||
|
mediaMapping[index.toString()] = filename;
|
||||||
|
zip.file(index.toString(), buffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
zip.file("media", JSON.stringify(mediaMapping));
|
||||||
|
|
||||||
|
return zip.generateAsync({ type: "nodebuffer" });
|
||||||
|
}
|
||||||
175
src/lib/anki/apkg-parser.ts
Normal file
175
src/lib/anki/apkg-parser.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import JSZip from "jszip";
|
||||||
|
import initSqlJs from "sql.js";
|
||||||
|
import type { Database, SqlValue } from "sql.js";
|
||||||
|
import {
|
||||||
|
type AnkiDeck,
|
||||||
|
type AnkiNoteType,
|
||||||
|
type AnkiDeckConfig,
|
||||||
|
type AnkiNoteRow,
|
||||||
|
type AnkiCardRow,
|
||||||
|
type AnkiRevlogRow,
|
||||||
|
type ParsedApkg,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
async function openDatabase(zip: JSZip): Promise<Database | null> {
|
||||||
|
const SQL = await initSqlJs({
|
||||||
|
locateFile: (file: string) => `https://sql.js.org/dist/${file}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const anki21b = zip.file("collection.anki21b");
|
||||||
|
const anki21 = zip.file("collection.anki21");
|
||||||
|
const anki2 = zip.file("collection.anki2");
|
||||||
|
|
||||||
|
const dbFile = anki21b || anki21 || anki2;
|
||||||
|
if (!dbFile) return null;
|
||||||
|
|
||||||
|
const dbData = await dbFile.async("uint8array");
|
||||||
|
return new SQL.Database(dbData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonField<T>(jsonStr: string): T {
|
||||||
|
try {
|
||||||
|
return JSON.parse(jsonStr);
|
||||||
|
} catch {
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryAll<T>(db: Database, sql: string, params: SqlValue[] = []): T[] {
|
||||||
|
const stmt = db.prepare(sql);
|
||||||
|
try {
|
||||||
|
stmt.bind(params);
|
||||||
|
const results: T[] = [];
|
||||||
|
while (stmt.step()) {
|
||||||
|
results.push(stmt.getAsObject() as T);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
} finally {
|
||||||
|
stmt.free();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryOne<T>(db: Database, sql: string, params: SqlValue[] = []): T | null {
|
||||||
|
const results = queryAll<T>(db, sql, params);
|
||||||
|
return results[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseApkg(buffer: Buffer): Promise<ParsedApkg> {
|
||||||
|
const zip = await JSZip.loadAsync(buffer);
|
||||||
|
const db = await openDatabase(zip);
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
|
throw new Error("No valid Anki database found in APKG file");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const col = queryOne<{
|
||||||
|
crt: number;
|
||||||
|
mod: number;
|
||||||
|
ver: number;
|
||||||
|
conf: string;
|
||||||
|
models: string;
|
||||||
|
decks: string;
|
||||||
|
dconf: string;
|
||||||
|
tags: string;
|
||||||
|
}>(db, "SELECT crt, mod, ver, conf, models, decks, dconf, tags FROM col WHERE id = 1");
|
||||||
|
|
||||||
|
if (!col) {
|
||||||
|
throw new Error("Invalid APKG: no collection row found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const decksMap = new Map<number, AnkiDeck>();
|
||||||
|
const decksJson = parseJsonField<Record<string, AnkiDeck>>(col.decks);
|
||||||
|
for (const [id, deck] of Object.entries(decksJson)) {
|
||||||
|
decksMap.set(parseInt(id, 10), deck);
|
||||||
|
}
|
||||||
|
|
||||||
|
const noteTypesMap = new Map<number, AnkiNoteType>();
|
||||||
|
const modelsJson = parseJsonField<Record<string, AnkiNoteType>>(col.models);
|
||||||
|
for (const [id, model] of Object.entries(modelsJson)) {
|
||||||
|
noteTypesMap.set(parseInt(id, 10), model);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deckConfigsMap = new Map<number, AnkiDeckConfig>();
|
||||||
|
const dconfJson = parseJsonField<Record<string, AnkiDeckConfig>>(col.dconf);
|
||||||
|
for (const [id, config] of Object.entries(dconfJson)) {
|
||||||
|
deckConfigsMap.set(parseInt(id, 10), config);
|
||||||
|
}
|
||||||
|
|
||||||
|
const notes = queryAll<AnkiNoteRow>(
|
||||||
|
db,
|
||||||
|
"SELECT id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data FROM notes"
|
||||||
|
);
|
||||||
|
|
||||||
|
const cards = queryAll<AnkiCardRow>(
|
||||||
|
db,
|
||||||
|
"SELECT id, nid, did, ord, mod, usn, type, queue, due, ivl, factor, reps, lapses, left, odue, odid, flags, data FROM cards"
|
||||||
|
);
|
||||||
|
|
||||||
|
const revlogs = queryAll<AnkiRevlogRow>(
|
||||||
|
db,
|
||||||
|
"SELECT id, cid, usn, ease, ivl, lastIvl, factor, time, type FROM revlog"
|
||||||
|
);
|
||||||
|
|
||||||
|
const mediaMap = new Map<string, Buffer>();
|
||||||
|
const mediaFile = zip.file("media");
|
||||||
|
if (mediaFile) {
|
||||||
|
const mediaJson = parseJsonField<Record<string, string>>(await mediaFile.async("text"));
|
||||||
|
for (const [num, filename] of Object.entries(mediaJson)) {
|
||||||
|
const mediaData = zip.file(num);
|
||||||
|
if (mediaData) {
|
||||||
|
const data = await mediaData.async("nodebuffer");
|
||||||
|
mediaMap.set(filename, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
decks: decksMap,
|
||||||
|
noteTypes: noteTypesMap,
|
||||||
|
deckConfigs: deckConfigsMap,
|
||||||
|
notes,
|
||||||
|
cards,
|
||||||
|
revlogs,
|
||||||
|
media: mediaMap,
|
||||||
|
collectionMeta: {
|
||||||
|
crt: col.crt,
|
||||||
|
mod: col.mod,
|
||||||
|
ver: col.ver,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDeckNotesAndCards(
|
||||||
|
parsed: ParsedApkg,
|
||||||
|
deckId: number
|
||||||
|
): { notes: AnkiNoteRow[]; cards: AnkiCardRow[] } {
|
||||||
|
const deckCards = parsed.cards.filter(c => c.did === deckId);
|
||||||
|
const noteIds = new Set(deckCards.map(c => c.nid));
|
||||||
|
const deckNotes = parsed.notes.filter(n => noteIds.has(n.id));
|
||||||
|
|
||||||
|
return { notes: deckNotes, cards: deckCards };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDeckNames(parsed: ParsedApkg): { id: number; name: string; cardCount: number }[] {
|
||||||
|
const cardCounts = new Map<number, number>();
|
||||||
|
for (const card of parsed.cards) {
|
||||||
|
cardCounts.set(card.did, (cardCounts.get(card.did) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: { id: number; name: string; cardCount: number }[] = [];
|
||||||
|
for (const [id, deck] of parsed.decks) {
|
||||||
|
if (deck.dyn === 0) {
|
||||||
|
result.push({
|
||||||
|
id,
|
||||||
|
name: deck.name,
|
||||||
|
cardCount: cardCounts.get(id) ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
193
src/lib/anki/types.ts
Normal file
193
src/lib/anki/types.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* Anki APKG format types
|
||||||
|
* Based on Anki's official database schema
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// APKG JSON Configuration Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface AnkiField {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ord: number;
|
||||||
|
sticky: boolean;
|
||||||
|
rtl: boolean;
|
||||||
|
font: string;
|
||||||
|
size: number;
|
||||||
|
media: string[];
|
||||||
|
description?: string;
|
||||||
|
plainText?: boolean;
|
||||||
|
collapsed?: boolean;
|
||||||
|
excludeFromSearch?: boolean;
|
||||||
|
tag?: number;
|
||||||
|
preventDeletion?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnkiTemplate {
|
||||||
|
id: number | null;
|
||||||
|
name: string;
|
||||||
|
ord: number;
|
||||||
|
qfmt: string;
|
||||||
|
afmt: string;
|
||||||
|
bqfmt?: string;
|
||||||
|
bafmt?: string;
|
||||||
|
did?: number | null;
|
||||||
|
bfont?: string;
|
||||||
|
bsize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnkiNoteType {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: 0 | 1; // 0=standard, 1=cloze
|
||||||
|
mod: number;
|
||||||
|
usn: number;
|
||||||
|
sortf: number;
|
||||||
|
did: number | null;
|
||||||
|
tmpls: AnkiTemplate[];
|
||||||
|
flds: AnkiField[];
|
||||||
|
css: string;
|
||||||
|
latexPre: string;
|
||||||
|
latexPost: string;
|
||||||
|
latexsvg: boolean | null;
|
||||||
|
req: [number, string, number[]][];
|
||||||
|
originalStockKind?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnkiDeckConfig {
|
||||||
|
id: number;
|
||||||
|
mod: number;
|
||||||
|
name: string;
|
||||||
|
usn: number;
|
||||||
|
maxTaken: number;
|
||||||
|
autoplay: boolean;
|
||||||
|
timer: 0 | 1;
|
||||||
|
replayq: boolean;
|
||||||
|
new: {
|
||||||
|
bury: boolean;
|
||||||
|
delays: number[];
|
||||||
|
initialFactor: number;
|
||||||
|
ints: [number, number, number];
|
||||||
|
order: number;
|
||||||
|
perDay: number;
|
||||||
|
};
|
||||||
|
rev: {
|
||||||
|
bury: boolean;
|
||||||
|
ease4: number;
|
||||||
|
ivlFct: number;
|
||||||
|
maxIvl: number;
|
||||||
|
perDay: number;
|
||||||
|
hardFactor: number;
|
||||||
|
};
|
||||||
|
lapse: {
|
||||||
|
delays: number[];
|
||||||
|
leechAction: 0 | 1;
|
||||||
|
leechFails: number;
|
||||||
|
minInt: number;
|
||||||
|
mult: number;
|
||||||
|
};
|
||||||
|
dyn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnkiDeck {
|
||||||
|
id: number;
|
||||||
|
mod: number;
|
||||||
|
name: string;
|
||||||
|
usn: number;
|
||||||
|
lrnToday: [number, number];
|
||||||
|
revToday: [number, number];
|
||||||
|
newToday: [number, number];
|
||||||
|
timeToday: [number, number];
|
||||||
|
collapsed: boolean;
|
||||||
|
browserCollapsed: boolean;
|
||||||
|
desc: string;
|
||||||
|
dyn: 0 | 1;
|
||||||
|
conf: number;
|
||||||
|
extendNew: number;
|
||||||
|
extendRev: number;
|
||||||
|
reviewLimit?: number | null;
|
||||||
|
newLimit?: number | null;
|
||||||
|
reviewLimitToday?: number | null;
|
||||||
|
newLimitToday?: number | null;
|
||||||
|
md?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// APKG Database Row Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface AnkiNoteRow {
|
||||||
|
id: number;
|
||||||
|
guid: string;
|
||||||
|
mid: number;
|
||||||
|
mod: number;
|
||||||
|
usn: number;
|
||||||
|
tags: string;
|
||||||
|
flds: string;
|
||||||
|
sfld: string;
|
||||||
|
csum: number;
|
||||||
|
flags: number;
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnkiCardRow {
|
||||||
|
id: number;
|
||||||
|
nid: number;
|
||||||
|
did: number;
|
||||||
|
ord: number;
|
||||||
|
mod: number;
|
||||||
|
usn: number;
|
||||||
|
type: number; // 0=new, 1=learning, 2=review, 3=relearning
|
||||||
|
queue: number; // -3=buried(user), -2=buried(sched), -1=suspended, 0=new, 1=learning, 2=review, 3=day learning, 4=preview
|
||||||
|
due: number;
|
||||||
|
ivl: number;
|
||||||
|
factor: number;
|
||||||
|
reps: number;
|
||||||
|
lapses: number;
|
||||||
|
left: number;
|
||||||
|
odue: number;
|
||||||
|
odid: number;
|
||||||
|
flags: number;
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnkiRevlogRow {
|
||||||
|
id: number;
|
||||||
|
cid: number;
|
||||||
|
usn: number;
|
||||||
|
ease: number;
|
||||||
|
ivl: number;
|
||||||
|
lastIvl: number;
|
||||||
|
factor: number;
|
||||||
|
time: number;
|
||||||
|
type: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Parsed APKG Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface ParsedApkg {
|
||||||
|
decks: Map<number, AnkiDeck>;
|
||||||
|
noteTypes: Map<number, AnkiNoteType>;
|
||||||
|
deckConfigs: Map<number, AnkiDeckConfig>;
|
||||||
|
notes: AnkiNoteRow[];
|
||||||
|
cards: AnkiCardRow[];
|
||||||
|
revlogs: AnkiRevlogRow[];
|
||||||
|
media: Map<string, Buffer>;
|
||||||
|
collectionMeta: {
|
||||||
|
crt: number;
|
||||||
|
mod: number;
|
||||||
|
ver: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApkgImportResult {
|
||||||
|
success: boolean;
|
||||||
|
deckName: string;
|
||||||
|
noteCount: number;
|
||||||
|
cardCount: number;
|
||||||
|
mediaCount: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
152
src/lib/bigmodel/ocr/orchestrator.ts
Normal file
152
src/lib/bigmodel/ocr/orchestrator.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/lib/bigmodel/ocr/types.ts
Normal file
44
src/lib/bigmodel/ocr/types.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 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 };
|
||||||
@@ -132,19 +132,28 @@ async function generateIPA(
|
|||||||
export async function executeTranslation(
|
export async function executeTranslation(
|
||||||
sourceText: string,
|
sourceText: string,
|
||||||
targetLanguage: string,
|
targetLanguage: string,
|
||||||
needIpa: boolean
|
needIpa: boolean,
|
||||||
|
sourceLanguage?: string
|
||||||
): Promise<TranslationLLMResponse> {
|
): Promise<TranslationLLMResponse> {
|
||||||
try {
|
try {
|
||||||
log.debug("Starting translation", { sourceText, targetLanguage, needIpa });
|
log.debug("Starting translation", { sourceText, targetLanguage, needIpa, sourceLanguage });
|
||||||
|
|
||||||
log.debug("[Stage 1] Detecting source language");
|
let detectedLanguage: string;
|
||||||
const detectionResult = await detectLanguage(sourceText);
|
|
||||||
log.debug("[Stage 1] Detection result", { detectionResult });
|
if (sourceLanguage) {
|
||||||
|
log.debug("[Stage 1] Using provided source language", { sourceLanguage });
|
||||||
|
detectedLanguage = sourceLanguage;
|
||||||
|
} else {
|
||||||
|
log.debug("[Stage 1] Detecting source language");
|
||||||
|
const detectionResult = await detectLanguage(sourceText);
|
||||||
|
log.debug("[Stage 1] Detection result", { detectionResult });
|
||||||
|
detectedLanguage = detectionResult.sourceLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
log.debug("[Stage 2] Performing translation");
|
log.debug("[Stage 2] Performing translation");
|
||||||
const translatedText = await performTranslation(
|
const translatedText = await performTranslation(
|
||||||
sourceText,
|
sourceText,
|
||||||
detectionResult.sourceLanguage,
|
detectedLanguage,
|
||||||
targetLanguage
|
targetLanguage
|
||||||
);
|
);
|
||||||
log.debug("[Stage 2] Translation complete", { translatedText });
|
log.debug("[Stage 2] Translation complete", { translatedText });
|
||||||
@@ -160,7 +169,7 @@ export async function executeTranslation(
|
|||||||
|
|
||||||
if (needIpa) {
|
if (needIpa) {
|
||||||
log.debug("[Stage 3] Generating IPA");
|
log.debug("[Stage 3] Generating IPA");
|
||||||
sourceIpa = await generateIPA(sourceText, detectionResult.sourceLanguage);
|
sourceIpa = await generateIPA(sourceText, detectedLanguage);
|
||||||
log.debug("[Stage 3] Source IPA", { sourceIpa });
|
log.debug("[Stage 3] Source IPA", { sourceIpa });
|
||||||
|
|
||||||
targetIpa = await generateIPA(translatedText, targetLanguage);
|
targetIpa = await generateIPA(translatedText, targetLanguage);
|
||||||
@@ -171,7 +180,7 @@ export async function executeTranslation(
|
|||||||
const finalResult: TranslationLLMResponse = {
|
const finalResult: TranslationLLMResponse = {
|
||||||
sourceText,
|
sourceText,
|
||||||
translatedText,
|
translatedText,
|
||||||
sourceLanguage: detectionResult.sourceLanguage,
|
sourceLanguage: detectedLanguage,
|
||||||
targetLanguage,
|
targetLanguage,
|
||||||
sourceIpa,
|
sourceIpa,
|
||||||
targetIpa,
|
targetIpa,
|
||||||
|
|||||||
@@ -56,7 +56,13 @@ export type ActionOutputUserProfile = {
|
|||||||
username: string | null;
|
username: string | null;
|
||||||
displayUsername: string | null;
|
displayUsername: string | null;
|
||||||
image: string | null;
|
image: string | null;
|
||||||
|
bio: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ActionOutputDeleteAccount = {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ActionInputSignIn,
|
ActionInputSignIn,
|
||||||
ActionInputSignUp,
|
ActionInputSignUp,
|
||||||
ActionOutputAuth,
|
ActionOutputAuth,
|
||||||
|
ActionOutputDeleteAccount,
|
||||||
ActionOutputUserProfile,
|
ActionOutputUserProfile,
|
||||||
validateActionInputGetUserProfileByUsername,
|
validateActionInputGetUserProfileByUsername,
|
||||||
validateActionInputSignIn,
|
validateActionInputSignIn,
|
||||||
@@ -18,7 +19,8 @@ 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
|
||||||
@@ -180,3 +182,27 @@ 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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type RepoOutputUserProfile = {
|
|||||||
username: string | null;
|
username: string | null;
|
||||||
displayUsername: string | null;
|
displayUsername: string | null;
|
||||||
image: string | null;
|
image: string | null;
|
||||||
|
bio: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
} | null;
|
} | null;
|
||||||
@@ -24,3 +25,12 @@ 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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { createLogger } from "@/lib/logger";
|
||||||
import {
|
import {
|
||||||
RepoInputFindUserByEmail,
|
RepoInputFindUserByEmail,
|
||||||
RepoInputFindUserById,
|
RepoInputFindUserById,
|
||||||
RepoInputFindUserByUsername,
|
RepoInputFindUserByUsername,
|
||||||
RepoOutputUserProfile
|
RepoInputDeleteUserCascade,
|
||||||
|
RepoOutputUserProfile,
|
||||||
|
RepoOutputDeleteUserCascade
|
||||||
} from "./auth-repository-dto";
|
} from "./auth-repository-dto";
|
||||||
|
|
||||||
/**
|
const log = createLogger("auth-repository");
|
||||||
* Find user by username
|
|
||||||
*/
|
|
||||||
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 },
|
||||||
@@ -19,6 +21,7 @@ export async function repoFindUserByUsername(dto: RepoInputFindUserByUsername):
|
|||||||
username: true,
|
username: true,
|
||||||
displayUsername: true,
|
displayUsername: true,
|
||||||
image: true,
|
image: true,
|
||||||
|
bio: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
}
|
}
|
||||||
@@ -27,9 +30,6 @@ export async function repoFindUserByUsername(dto: RepoInputFindUserByUsername):
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Find user by ID
|
|
||||||
*/
|
|
||||||
export async function repoFindUserById(dto: RepoInputFindUserById): Promise<RepoOutputUserProfile> {
|
export async function repoFindUserById(dto: RepoInputFindUserById): Promise<RepoOutputUserProfile> {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: dto.id },
|
where: { id: dto.id },
|
||||||
@@ -40,6 +40,7 @@ export async function repoFindUserById(dto: RepoInputFindUserById): Promise<Repo
|
|||||||
username: true,
|
username: true,
|
||||||
displayUsername: true,
|
displayUsername: true,
|
||||||
image: true,
|
image: true,
|
||||||
|
bio: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
}
|
}
|
||||||
@@ -48,9 +49,6 @@ export async function repoFindUserById(dto: RepoInputFindUserById): Promise<Repo
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Find user by email
|
|
||||||
*/
|
|
||||||
export async function repoFindUserByEmail(dto: RepoInputFindUserByEmail): Promise<RepoOutputUserProfile> {
|
export async function repoFindUserByEmail(dto: RepoInputFindUserByEmail): Promise<RepoOutputUserProfile> {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { email: dto.email },
|
where: { email: dto.email },
|
||||||
@@ -61,6 +59,7 @@ export async function repoFindUserByEmail(dto: RepoInputFindUserByEmail): Promis
|
|||||||
username: true,
|
username: true,
|
||||||
displayUsername: true,
|
displayUsername: true,
|
||||||
image: true,
|
image: true,
|
||||||
|
bio: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
}
|
}
|
||||||
@@ -68,3 +67,68 @@ 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.revlog.deleteMany({
|
||||||
|
where: { card: { note: { userId } } }
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.card.deleteMany({
|
||||||
|
where: { note: { userId } }
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.note.deleteMany({
|
||||||
|
where: { userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.noteType.deleteMany({
|
||||||
|
where: { 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.dictionaryLookUp.deleteMany({
|
||||||
|
where: { userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.translationHistory.deleteMany({
|
||||||
|
where: { 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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ export type ServiceOutputUserProfile = {
|
|||||||
username: string | null;
|
username: string | null;
|
||||||
displayUsername: string | null;
|
displayUsername: string | null;
|
||||||
image: string | null;
|
image: string | null;
|
||||||
|
bio: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
|
export type ServiceInputDeleteAccount = {
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceOutputDeleteAccount = {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,3 +95,7 @@ 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 });
|
||||||
|
}
|
||||||
|
|||||||
12
src/modules/auth/forgot-password-action-dto.ts
Normal file
12
src/modules/auth/forgot-password-action-dto.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
35
src/modules/auth/forgot-password-action.ts
Normal file
35
src/modules/auth/forgot-password-action.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"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: "发送重置邮件失败,请稍后重试",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/modules/auth/forgot-password-repository-dto.ts
Normal file
7
src/modules/auth/forgot-password-repository-dto.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export type RepoInputFindUserByEmail = {
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RepoOutputFindUserByEmail = {
|
||||||
|
id: string;
|
||||||
|
} | null;
|
||||||
19
src/modules/auth/forgot-password-repository.ts
Normal file
19
src/modules/auth/forgot-password-repository.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
8
src/modules/auth/forgot-password-service-dto.ts
Normal file
8
src/modules/auth/forgot-password-service-dto.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export type ServiceInputRequestPasswordReset = {
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceOutputRequestPasswordReset = {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
34
src/modules/auth/forgot-password-service.ts
Normal file
34
src/modules/auth/forgot-password-service.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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: "重置密码邮件已发送,请检查您的邮箱",
|
||||||
|
};
|
||||||
|
}
|
||||||
164
src/modules/card/card-action-dto.ts
Normal file
164
src/modules/card/card-action-dto.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import z from "zod";
|
||||||
|
import { generateValidator } from "@/utils/validate";
|
||||||
|
|
||||||
|
export const schemaActionInputCreateCard = z.object({
|
||||||
|
noteId: z.bigint(),
|
||||||
|
deckId: z.number().int().positive(),
|
||||||
|
ord: z.number().int().min(0).optional(),
|
||||||
|
});
|
||||||
|
export type ActionInputCreateCard = z.infer<typeof schemaActionInputCreateCard>;
|
||||||
|
export const validateActionInputCreateCard = generateValidator(schemaActionInputCreateCard);
|
||||||
|
|
||||||
|
export const schemaActionInputAnswerCard = z.object({
|
||||||
|
cardId: z.bigint(),
|
||||||
|
ease: z.union([
|
||||||
|
z.literal(1),
|
||||||
|
z.literal(2),
|
||||||
|
z.literal(3),
|
||||||
|
z.literal(4),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
export type ActionInputAnswerCard = z.infer<typeof schemaActionInputAnswerCard>;
|
||||||
|
export const validateActionInputAnswerCard = generateValidator(schemaActionInputAnswerCard);
|
||||||
|
|
||||||
|
export const schemaActionInputGetCardsForReview = z.object({
|
||||||
|
deckId: z.number().int().positive(),
|
||||||
|
limit: z.number().int().min(1).max(100).optional(),
|
||||||
|
});
|
||||||
|
export type ActionInputGetCardsForReview = z.infer<typeof schemaActionInputGetCardsForReview>;
|
||||||
|
export const validateActionInputGetCardsForReview = generateValidator(schemaActionInputGetCardsForReview);
|
||||||
|
|
||||||
|
export const schemaActionInputGetNewCards = z.object({
|
||||||
|
deckId: z.number().int().positive(),
|
||||||
|
limit: z.number().int().min(1).max(100).optional(),
|
||||||
|
});
|
||||||
|
export type ActionInputGetNewCards = z.infer<typeof schemaActionInputGetNewCards>;
|
||||||
|
export const validateActionInputGetNewCards = generateValidator(schemaActionInputGetNewCards);
|
||||||
|
|
||||||
|
export const schemaActionInputGetCardsByDeckId = z.object({
|
||||||
|
deckId: z.number().int().positive(),
|
||||||
|
limit: z.number().int().min(1).max(100).optional(),
|
||||||
|
offset: z.number().int().min(0).optional(),
|
||||||
|
queue: z.union([
|
||||||
|
z.enum(["USER_BURIED", "SCHED_BURIED", "SUSPENDED", "NEW", "LEARNING", "REVIEW", "IN_LEARNING", "PREVIEW"]),
|
||||||
|
z.array(z.enum(["USER_BURIED", "SCHED_BURIED", "SUSPENDED", "NEW", "LEARNING", "REVIEW", "IN_LEARNING", "PREVIEW"])),
|
||||||
|
]).optional(),
|
||||||
|
});
|
||||||
|
export type ActionInputGetCardsByDeckId = z.infer<typeof schemaActionInputGetCardsByDeckId>;
|
||||||
|
export const validateActionInputGetCardsByDeckId = generateValidator(schemaActionInputGetCardsByDeckId);
|
||||||
|
|
||||||
|
export const schemaActionInputGetCardStats = z.object({
|
||||||
|
deckId: z.number().int().positive(),
|
||||||
|
});
|
||||||
|
export type ActionInputGetCardStats = z.infer<typeof schemaActionInputGetCardStats>;
|
||||||
|
export const validateActionInputGetCardStats = generateValidator(schemaActionInputGetCardStats);
|
||||||
|
|
||||||
|
export const schemaActionInputDeleteCard = z.object({
|
||||||
|
cardId: z.bigint(),
|
||||||
|
});
|
||||||
|
export type ActionInputDeleteCard = z.infer<typeof schemaActionInputDeleteCard>;
|
||||||
|
export const validateActionInputDeleteCard = generateValidator(schemaActionInputDeleteCard);
|
||||||
|
|
||||||
|
export const schemaActionInputGetCardById = z.object({
|
||||||
|
cardId: z.bigint(),
|
||||||
|
});
|
||||||
|
export type ActionInputGetCardById = z.infer<typeof schemaActionInputGetCardById>;
|
||||||
|
export const validateActionInputGetCardById = generateValidator(schemaActionInputGetCardById);
|
||||||
|
|
||||||
|
export type ActionOutputCard = {
|
||||||
|
id: string;
|
||||||
|
noteId: string;
|
||||||
|
deckId: number;
|
||||||
|
ord: number;
|
||||||
|
mod: number;
|
||||||
|
usn: number;
|
||||||
|
type: "NEW" | "LEARNING" | "REVIEW" | "RELEARNING";
|
||||||
|
queue: "USER_BURIED" | "SCHED_BURIED" | "SUSPENDED" | "NEW" | "LEARNING" | "REVIEW" | "IN_LEARNING" | "PREVIEW";
|
||||||
|
due: number;
|
||||||
|
ivl: number;
|
||||||
|
factor: number;
|
||||||
|
reps: number;
|
||||||
|
lapses: number;
|
||||||
|
left: number;
|
||||||
|
odue: number;
|
||||||
|
odid: number;
|
||||||
|
flags: number;
|
||||||
|
data: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActionOutputCardWithNote = ActionOutputCard & {
|
||||||
|
note: {
|
||||||
|
id: string;
|
||||||
|
flds: string;
|
||||||
|
sfld: string;
|
||||||
|
tags: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActionOutputCardStats = {
|
||||||
|
total: number;
|
||||||
|
new: number;
|
||||||
|
learning: number;
|
||||||
|
review: number;
|
||||||
|
due: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActionOutputScheduledCard = {
|
||||||
|
cardId: string;
|
||||||
|
newType: "NEW" | "LEARNING" | "REVIEW" | "RELEARNING";
|
||||||
|
newQueue: "USER_BURIED" | "SCHED_BURIED" | "SUSPENDED" | "NEW" | "LEARNING" | "REVIEW" | "IN_LEARNING" | "PREVIEW";
|
||||||
|
newDue: number;
|
||||||
|
newIvl: number;
|
||||||
|
newFactor: number;
|
||||||
|
newReps: number;
|
||||||
|
newLapses: number;
|
||||||
|
nextReviewDate: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActionOutputCreateCard = {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data?: {
|
||||||
|
cardId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActionOutputAnswerCard = {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data?: {
|
||||||
|
card: ActionOutputCard;
|
||||||
|
scheduled: ActionOutputScheduledCard;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActionOutputGetCards = {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data?: ActionOutputCard[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActionOutputGetCardsWithNote = {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data?: ActionOutputCardWithNote[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActionOutputGetCardStats = {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data?: ActionOutputCardStats;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActionOutputDeleteCard = {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActionOutputGetCardById = {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data?: ActionOutputCardWithNote;
|
||||||
|
};
|
||||||
427
src/modules/card/card-action.ts
Normal file
427
src/modules/card/card-action.ts
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { createLogger } from "@/lib/logger";
|
||||||
|
import { ValidateError } from "@/lib/errors";
|
||||||
|
import {
|
||||||
|
ActionInputCreateCard,
|
||||||
|
ActionInputAnswerCard,
|
||||||
|
ActionInputGetCardsForReview,
|
||||||
|
ActionInputGetNewCards,
|
||||||
|
ActionInputGetCardsByDeckId,
|
||||||
|
ActionInputGetCardStats,
|
||||||
|
ActionInputDeleteCard,
|
||||||
|
ActionInputGetCardById,
|
||||||
|
ActionOutputCreateCard,
|
||||||
|
ActionOutputAnswerCard,
|
||||||
|
ActionOutputGetCards,
|
||||||
|
ActionOutputGetCardsWithNote,
|
||||||
|
ActionOutputGetCardStats,
|
||||||
|
ActionOutputDeleteCard,
|
||||||
|
ActionOutputGetCardById,
|
||||||
|
ActionOutputCard,
|
||||||
|
ActionOutputCardWithNote,
|
||||||
|
ActionOutputScheduledCard,
|
||||||
|
validateActionInputCreateCard,
|
||||||
|
validateActionInputAnswerCard,
|
||||||
|
validateActionInputGetCardsForReview,
|
||||||
|
validateActionInputGetNewCards,
|
||||||
|
validateActionInputGetCardsByDeckId,
|
||||||
|
validateActionInputGetCardStats,
|
||||||
|
validateActionInputDeleteCard,
|
||||||
|
validateActionInputGetCardById,
|
||||||
|
} from "./card-action-dto";
|
||||||
|
import {
|
||||||
|
serviceCreateCard,
|
||||||
|
serviceAnswerCard,
|
||||||
|
serviceGetCardsForReview,
|
||||||
|
serviceGetNewCards,
|
||||||
|
serviceGetCardsByDeckId,
|
||||||
|
serviceGetCardsByDeckIdWithNotes,
|
||||||
|
serviceGetCardStats,
|
||||||
|
serviceDeleteCard,
|
||||||
|
serviceGetCardByIdWithNote,
|
||||||
|
serviceCheckCardOwnership,
|
||||||
|
} from "./card-service";
|
||||||
|
import { CardQueue } from "../../../generated/prisma/enums";
|
||||||
|
|
||||||
|
const log = createLogger("card-action");
|
||||||
|
|
||||||
|
function mapCardToOutput(card: {
|
||||||
|
id: bigint;
|
||||||
|
noteId: bigint;
|
||||||
|
deckId: number;
|
||||||
|
ord: number;
|
||||||
|
mod: number;
|
||||||
|
usn: number;
|
||||||
|
type: string;
|
||||||
|
queue: string;
|
||||||
|
due: number;
|
||||||
|
ivl: number;
|
||||||
|
factor: number;
|
||||||
|
reps: number;
|
||||||
|
lapses: number;
|
||||||
|
left: number;
|
||||||
|
odue: number;
|
||||||
|
odid: number;
|
||||||
|
flags: number;
|
||||||
|
data: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}): ActionOutputCard {
|
||||||
|
return {
|
||||||
|
id: card.id.toString(),
|
||||||
|
noteId: card.noteId.toString(),
|
||||||
|
deckId: card.deckId,
|
||||||
|
ord: card.ord,
|
||||||
|
mod: card.mod,
|
||||||
|
usn: card.usn,
|
||||||
|
type: card.type as ActionOutputCard["type"],
|
||||||
|
queue: card.queue as ActionOutputCard["queue"],
|
||||||
|
due: card.due,
|
||||||
|
ivl: card.ivl,
|
||||||
|
factor: card.factor,
|
||||||
|
reps: card.reps,
|
||||||
|
lapses: card.lapses,
|
||||||
|
left: card.left,
|
||||||
|
odue: card.odue,
|
||||||
|
odid: card.odid,
|
||||||
|
flags: card.flags,
|
||||||
|
data: card.data,
|
||||||
|
createdAt: card.createdAt,
|
||||||
|
updatedAt: card.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCardWithNoteToOutput(card: {
|
||||||
|
id: bigint;
|
||||||
|
noteId: bigint;
|
||||||
|
deckId: number;
|
||||||
|
ord: number;
|
||||||
|
mod: number;
|
||||||
|
usn: number;
|
||||||
|
type: string;
|
||||||
|
queue: string;
|
||||||
|
due: number;
|
||||||
|
ivl: number;
|
||||||
|
factor: number;
|
||||||
|
reps: number;
|
||||||
|
lapses: number;
|
||||||
|
left: number;
|
||||||
|
odue: number;
|
||||||
|
odid: number;
|
||||||
|
flags: number;
|
||||||
|
data: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
note: {
|
||||||
|
id: bigint;
|
||||||
|
flds: string;
|
||||||
|
sfld: string;
|
||||||
|
tags: string;
|
||||||
|
};
|
||||||
|
}): ActionOutputCardWithNote {
|
||||||
|
return {
|
||||||
|
...mapCardToOutput(card),
|
||||||
|
note: {
|
||||||
|
id: card.note.id.toString(),
|
||||||
|
flds: card.note.flds,
|
||||||
|
sfld: card.note.sfld,
|
||||||
|
tags: card.note.tags,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapScheduledToOutput(scheduled: {
|
||||||
|
cardId: bigint;
|
||||||
|
newType: string;
|
||||||
|
newQueue: string;
|
||||||
|
newDue: number;
|
||||||
|
newIvl: number;
|
||||||
|
newFactor: number;
|
||||||
|
newReps: number;
|
||||||
|
newLapses: number;
|
||||||
|
nextReviewDate: Date;
|
||||||
|
}): ActionOutputScheduledCard {
|
||||||
|
return {
|
||||||
|
cardId: scheduled.cardId.toString(),
|
||||||
|
newType: scheduled.newType as ActionOutputScheduledCard["newType"],
|
||||||
|
newQueue: scheduled.newQueue as ActionOutputScheduledCard["newQueue"],
|
||||||
|
newDue: scheduled.newDue,
|
||||||
|
newIvl: scheduled.newIvl,
|
||||||
|
newFactor: scheduled.newFactor,
|
||||||
|
newReps: scheduled.newReps,
|
||||||
|
newLapses: scheduled.newLapses,
|
||||||
|
nextReviewDate: scheduled.nextReviewDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkCardOwnership(cardId: bigint): Promise<boolean> {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session?.user?.id) return false;
|
||||||
|
|
||||||
|
return serviceCheckCardOwnership({ cardId, userId: session.user.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCurrentUserId(): Promise<string | null> {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
return session?.user?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function actionCreateCard(
|
||||||
|
input: unknown,
|
||||||
|
): Promise<ActionOutputCreateCard> {
|
||||||
|
try {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return { success: false, message: "Unauthorized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validated = validateActionInputCreateCard(input);
|
||||||
|
const cardId = await serviceCreateCard(validated);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Card created successfully",
|
||||||
|
data: { cardId: cardId.toString() },
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ValidateError) {
|
||||||
|
return { success: false, message: e.message };
|
||||||
|
}
|
||||||
|
log.error("Failed to create card", { error: e });
|
||||||
|
return { success: false, message: "An error occurred while creating the card" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function actionAnswerCard(
|
||||||
|
input: unknown,
|
||||||
|
): Promise<ActionOutputAnswerCard> {
|
||||||
|
try {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return { success: false, message: "Unauthorized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validated = validateActionInputAnswerCard(input);
|
||||||
|
|
||||||
|
const isOwner = await checkCardOwnership(validated.cardId);
|
||||||
|
if (!isOwner) {
|
||||||
|
return { success: false, message: "You do not have permission to answer this card" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await serviceAnswerCard(validated);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Card answered successfully",
|
||||||
|
data: {
|
||||||
|
card: mapCardToOutput(result.card),
|
||||||
|
scheduled: mapScheduledToOutput(result.scheduled),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ValidateError) {
|
||||||
|
return { success: false, message: e.message };
|
||||||
|
}
|
||||||
|
log.error("Failed to answer card", { error: e });
|
||||||
|
return { success: false, message: "An error occurred while answering the card" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function actionGetCardsForReview(
|
||||||
|
input: unknown,
|
||||||
|
): Promise<ActionOutputGetCardsWithNote> {
|
||||||
|
try {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return { success: false, message: "Unauthorized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validated = validateActionInputGetCardsForReview(input);
|
||||||
|
const cards = await serviceGetCardsForReview(validated);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Cards fetched successfully",
|
||||||
|
data: cards.map(mapCardWithNoteToOutput),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ValidateError) {
|
||||||
|
return { success: false, message: e.message };
|
||||||
|
}
|
||||||
|
log.error("Failed to get cards for review", { error: e });
|
||||||
|
return { success: false, message: "An error occurred while fetching cards" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function actionGetNewCards(
|
||||||
|
input: unknown,
|
||||||
|
): Promise<ActionOutputGetCardsWithNote> {
|
||||||
|
try {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return { success: false, message: "Unauthorized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validated = validateActionInputGetNewCards(input);
|
||||||
|
const cards = await serviceGetNewCards(validated);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "New cards fetched successfully",
|
||||||
|
data: cards.map(mapCardWithNoteToOutput),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ValidateError) {
|
||||||
|
return { success: false, message: e.message };
|
||||||
|
}
|
||||||
|
log.error("Failed to get new cards", { error: e });
|
||||||
|
return { success: false, message: "An error occurred while fetching new cards" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function actionGetCardsByDeckId(
|
||||||
|
input: unknown,
|
||||||
|
): Promise<ActionOutputGetCards> {
|
||||||
|
try {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return { success: false, message: "Unauthorized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validated = validateActionInputGetCardsByDeckId(input);
|
||||||
|
const queue = validated.queue as CardQueue | CardQueue[] | undefined;
|
||||||
|
const cards = await serviceGetCardsByDeckId({
|
||||||
|
...validated,
|
||||||
|
queue,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 by deck", { error: e });
|
||||||
|
return { success: false, message: "An error occurred while fetching cards" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function actionGetCardsByDeckIdWithNotes(
|
||||||
|
input: unknown,
|
||||||
|
): Promise<ActionOutputGetCardsWithNote> {
|
||||||
|
try {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return { success: false, message: "Unauthorized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validated = validateActionInputGetCardsByDeckId(input);
|
||||||
|
const queue = validated.queue as CardQueue | CardQueue[] | undefined;
|
||||||
|
const cards = await serviceGetCardsByDeckIdWithNotes({
|
||||||
|
...validated,
|
||||||
|
queue,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Cards fetched successfully",
|
||||||
|
data: cards.map(mapCardWithNoteToOutput),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ValidateError) {
|
||||||
|
return { success: false, message: e.message };
|
||||||
|
}
|
||||||
|
log.error("Failed to get cards by deck with notes", { error: e });
|
||||||
|
return { success: false, message: "An error occurred while fetching cards" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function actionGetCardStats(
|
||||||
|
input: unknown,
|
||||||
|
): Promise<ActionOutputGetCardStats> {
|
||||||
|
try {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return { success: false, message: "Unauthorized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validated = validateActionInputGetCardStats(input);
|
||||||
|
const stats = await serviceGetCardStats(validated);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Card stats fetched successfully",
|
||||||
|
data: stats,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ValidateError) {
|
||||||
|
return { success: false, message: e.message };
|
||||||
|
}
|
||||||
|
log.error("Failed to get card stats", { error: e });
|
||||||
|
return { success: false, message: "An error occurred while fetching card stats" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function actionDeleteCard(
|
||||||
|
input: unknown,
|
||||||
|
): Promise<ActionOutputDeleteCard> {
|
||||||
|
try {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return { success: false, message: "Unauthorized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validated = validateActionInputDeleteCard(input);
|
||||||
|
|
||||||
|
const isOwner = await checkCardOwnership(validated.cardId);
|
||||||
|
if (!isOwner) {
|
||||||
|
return { success: false, message: "You do not have permission to delete this card" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await serviceDeleteCard(validated.cardId);
|
||||||
|
|
||||||
|
return { success: true, message: "Card deleted successfully" };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ValidateError) {
|
||||||
|
return { success: false, message: e.message };
|
||||||
|
}
|
||||||
|
log.error("Failed to delete card", { error: e });
|
||||||
|
return { success: false, message: "An error occurred while deleting the card" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function actionGetCardById(
|
||||||
|
input: unknown,
|
||||||
|
): Promise<ActionOutputGetCardById> {
|
||||||
|
try {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return { success: false, message: "Unauthorized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validated = validateActionInputGetCardById(input);
|
||||||
|
const card = await serviceGetCardByIdWithNote(validated.cardId);
|
||||||
|
|
||||||
|
if (!card) {
|
||||||
|
return { success: false, message: "Card not found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Card fetched successfully",
|
||||||
|
data: mapCardWithNoteToOutput(card),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ValidateError) {
|
||||||
|
return { success: false, message: e.message };
|
||||||
|
}
|
||||||
|
log.error("Failed to get card by id", { error: e });
|
||||||
|
return { success: false, message: "An error occurred while fetching the card" };
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/modules/card/card-repository-dto.ts
Normal file
104
src/modules/card/card-repository-dto.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { CardType, CardQueue } from "../../../generated/prisma/enums";
|
||||||
|
|
||||||
|
export interface RepoInputCreateCard {
|
||||||
|
id: bigint;
|
||||||
|
noteId: bigint;
|
||||||
|
deckId: number;
|
||||||
|
ord: number;
|
||||||
|
due: number;
|
||||||
|
type?: CardType;
|
||||||
|
queue?: CardQueue;
|
||||||
|
ivl?: number;
|
||||||
|
factor?: number;
|
||||||
|
reps?: number;
|
||||||
|
lapses?: number;
|
||||||
|
left?: number;
|
||||||
|
odue?: number;
|
||||||
|
odid?: number;
|
||||||
|
flags?: number;
|
||||||
|
data?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepoInputUpdateCard {
|
||||||
|
ord?: number;
|
||||||
|
mod?: number;
|
||||||
|
usn?: number;
|
||||||
|
type?: CardType;
|
||||||
|
queue?: CardQueue;
|
||||||
|
due?: number;
|
||||||
|
ivl?: number;
|
||||||
|
factor?: number;
|
||||||
|
reps?: number;
|
||||||
|
lapses?: number;
|
||||||
|
left?: number;
|
||||||
|
odue?: number;
|
||||||
|
odid?: number;
|
||||||
|
flags?: number;
|
||||||
|
data?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepoInputGetCardsByDeckId {
|
||||||
|
deckId: number;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
queue?: CardQueue | CardQueue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepoInputGetCardsForReview {
|
||||||
|
deckId: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepoInputGetNewCards {
|
||||||
|
deckId: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepoInputBulkUpdateCard {
|
||||||
|
id: bigint;
|
||||||
|
data: RepoInputUpdateCard;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepoInputBulkUpdateCards {
|
||||||
|
cards: RepoInputBulkUpdateCard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RepoOutputCard = {
|
||||||
|
id: bigint;
|
||||||
|
noteId: bigint;
|
||||||
|
deckId: number;
|
||||||
|
ord: number;
|
||||||
|
mod: number;
|
||||||
|
usn: number;
|
||||||
|
type: CardType;
|
||||||
|
queue: CardQueue;
|
||||||
|
due: number;
|
||||||
|
ivl: number;
|
||||||
|
factor: number;
|
||||||
|
reps: number;
|
||||||
|
lapses: number;
|
||||||
|
left: number;
|
||||||
|
odue: number;
|
||||||
|
odid: number;
|
||||||
|
flags: number;
|
||||||
|
data: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RepoOutputCardWithNote = RepoOutputCard & {
|
||||||
|
note: {
|
||||||
|
id: bigint;
|
||||||
|
flds: string;
|
||||||
|
sfld: string;
|
||||||
|
tags: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RepoOutputCardStats = {
|
||||||
|
total: number;
|
||||||
|
new: number;
|
||||||
|
learning: number;
|
||||||
|
review: number;
|
||||||
|
due: number;
|
||||||
|
};
|
||||||
309
src/modules/card/card-repository.ts
Normal file
309
src/modules/card/card-repository.ts
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { createLogger } from "@/lib/logger";
|
||||||
|
import {
|
||||||
|
RepoInputCreateCard,
|
||||||
|
RepoInputUpdateCard,
|
||||||
|
RepoInputGetCardsByDeckId,
|
||||||
|
RepoInputGetCardsForReview,
|
||||||
|
RepoInputGetNewCards,
|
||||||
|
RepoInputBulkUpdateCards,
|
||||||
|
RepoOutputCard,
|
||||||
|
RepoOutputCardWithNote,
|
||||||
|
RepoOutputCardStats,
|
||||||
|
} from "./card-repository-dto";
|
||||||
|
import { CardType, CardQueue } from "../../../generated/prisma/enums";
|
||||||
|
|
||||||
|
const log = createLogger("card-repository");
|
||||||
|
|
||||||
|
export async function repoCreateCard(
|
||||||
|
input: RepoInputCreateCard,
|
||||||
|
): Promise<bigint> {
|
||||||
|
log.debug("Creating card", { noteId: input.noteId.toString(), deckId: input.deckId });
|
||||||
|
const card = await prisma.card.create({
|
||||||
|
data: {
|
||||||
|
id: input.id,
|
||||||
|
noteId: input.noteId,
|
||||||
|
deckId: input.deckId,
|
||||||
|
ord: input.ord,
|
||||||
|
due: input.due,
|
||||||
|
mod: Math.floor(Date.now() / 1000),
|
||||||
|
type: input.type ?? CardType.NEW,
|
||||||
|
queue: input.queue ?? CardQueue.NEW,
|
||||||
|
ivl: input.ivl ?? 0,
|
||||||
|
factor: input.factor ?? 2500,
|
||||||
|
reps: input.reps ?? 0,
|
||||||
|
lapses: input.lapses ?? 0,
|
||||||
|
left: input.left ?? 0,
|
||||||
|
odue: input.odue ?? 0,
|
||||||
|
odid: input.odid ?? 0,
|
||||||
|
flags: input.flags ?? 0,
|
||||||
|
data: input.data ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
log.info("Card created", { cardId: card.id.toString() });
|
||||||
|
return card.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repoUpdateCard(
|
||||||
|
id: bigint,
|
||||||
|
input: RepoInputUpdateCard,
|
||||||
|
): Promise<void> {
|
||||||
|
log.debug("Updating card", { cardId: id.toString() });
|
||||||
|
await prisma.card.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...input,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
log.info("Card updated", { cardId: id.toString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repoGetCardById(id: bigint): Promise<RepoOutputCard | null> {
|
||||||
|
const card = await prisma.card.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repoGetCardByIdWithNote(
|
||||||
|
id: bigint,
|
||||||
|
): Promise<RepoOutputCardWithNote | null> {
|
||||||
|
const card = await prisma.card.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
note: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
flds: true,
|
||||||
|
sfld: true,
|
||||||
|
tags: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repoGetCardsByDeckId(
|
||||||
|
input: RepoInputGetCardsByDeckId,
|
||||||
|
): Promise<RepoOutputCard[]> {
|
||||||
|
const { deckId, limit = 50, offset = 0, queue } = input;
|
||||||
|
|
||||||
|
const queueFilter = queue
|
||||||
|
? Array.isArray(queue)
|
||||||
|
? { in: queue }
|
||||||
|
: queue
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const cards = await prisma.card.findMany({
|
||||||
|
where: {
|
||||||
|
deckId,
|
||||||
|
queue: queueFilter,
|
||||||
|
},
|
||||||
|
orderBy: { due: "asc" },
|
||||||
|
take: limit,
|
||||||
|
skip: offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
log.debug("Fetched cards by deck", { deckId, count: cards.length });
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repoGetCardsByDeckIdWithNotes(
|
||||||
|
input: RepoInputGetCardsByDeckId,
|
||||||
|
): Promise<RepoOutputCardWithNote[]> {
|
||||||
|
const { deckId, limit = 100, offset = 0, queue } = input;
|
||||||
|
|
||||||
|
const queueFilter = queue
|
||||||
|
? Array.isArray(queue)
|
||||||
|
? { in: queue }
|
||||||
|
: queue
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const cards = await prisma.card.findMany({
|
||||||
|
where: {
|
||||||
|
deckId,
|
||||||
|
queue: queueFilter,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
note: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
flds: true,
|
||||||
|
sfld: true,
|
||||||
|
tags: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { id: "asc" },
|
||||||
|
take: limit,
|
||||||
|
skip: offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
log.debug("Fetched cards by deck with notes", { deckId, count: cards.length });
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repoGetCardsForReview(
|
||||||
|
input: RepoInputGetCardsForReview,
|
||||||
|
): Promise<RepoOutputCardWithNote[]> {
|
||||||
|
const { deckId, limit = 20 } = input;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const todayDays = Math.floor(now / 86400);
|
||||||
|
|
||||||
|
const cards = await prisma.card.findMany({
|
||||||
|
where: {
|
||||||
|
deckId,
|
||||||
|
queue: { in: [CardQueue.NEW, CardQueue.LEARNING, CardQueue.REVIEW] },
|
||||||
|
OR: [
|
||||||
|
{ type: CardType.NEW },
|
||||||
|
{
|
||||||
|
type: { in: [CardType.LEARNING, CardType.REVIEW] },
|
||||||
|
due: { lte: todayDays },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
note: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
flds: true,
|
||||||
|
sfld: true,
|
||||||
|
tags: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ type: "asc" },
|
||||||
|
{ due: "asc" },
|
||||||
|
],
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
log.debug("Fetched cards for review", { deckId, count: cards.length });
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repoGetNewCards(
|
||||||
|
input: RepoInputGetNewCards,
|
||||||
|
): Promise<RepoOutputCardWithNote[]> {
|
||||||
|
const { deckId, limit = 20 } = input;
|
||||||
|
|
||||||
|
const cards = await prisma.card.findMany({
|
||||||
|
where: {
|
||||||
|
deckId,
|
||||||
|
type: CardType.NEW,
|
||||||
|
queue: CardQueue.NEW,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
note: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
flds: true,
|
||||||
|
sfld: true,
|
||||||
|
tags: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { due: "asc" },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
log.debug("Fetched new cards", { deckId, count: cards.length });
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repoDeleteCard(id: bigint): Promise<void> {
|
||||||
|
log.debug("Deleting card", { cardId: id.toString() });
|
||||||
|
await prisma.card.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
log.info("Card deleted", { cardId: id.toString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repoBulkUpdateCards(
|
||||||
|
input: RepoInputBulkUpdateCards,
|
||||||
|
): Promise<void> {
|
||||||
|
log.debug("Bulk updating cards", { count: input.cards.length });
|
||||||
|
|
||||||
|
await prisma.$transaction(
|
||||||
|
input.cards.map((item) =>
|
||||||
|
prisma.card.update({
|
||||||
|
where: { id: item.id },
|
||||||
|
data: {
|
||||||
|
...item.data,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
log.info("Bulk update completed", { count: input.cards.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repoGetCardStats(deckId: number): Promise<RepoOutputCardStats> {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const todayDays = Math.floor(now / 86400);
|
||||||
|
|
||||||
|
const [total, newCards, learning, review, due] = await Promise.all([
|
||||||
|
prisma.card.count({ where: { deckId } }),
|
||||||
|
prisma.card.count({ where: { deckId, type: CardType.NEW } }),
|
||||||
|
prisma.card.count({ where: { deckId, type: CardType.LEARNING } }),
|
||||||
|
prisma.card.count({ where: { deckId, type: CardType.REVIEW } }),
|
||||||
|
prisma.card.count({
|
||||||
|
where: {
|
||||||
|
deckId,
|
||||||
|
type: { in: [CardType.LEARNING, CardType.REVIEW] },
|
||||||
|
due: { lte: todayDays },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { total, new: newCards, learning, review, due };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repoGetCardDeckOwnerId(cardId: bigint): Promise<string | null> {
|
||||||
|
const card = await prisma.card.findUnique({
|
||||||
|
where: { id: cardId },
|
||||||
|
include: {
|
||||||
|
deck: {
|
||||||
|
select: { userId: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return card?.deck.userId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repoGetNextDueCard(deckId: number): Promise<RepoOutputCard | null> {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const todayDays = Math.floor(now / 86400);
|
||||||
|
|
||||||
|
const card = await prisma.card.findFirst({
|
||||||
|
where: {
|
||||||
|
deckId,
|
||||||
|
queue: { in: [CardQueue.NEW, CardQueue.LEARNING, CardQueue.REVIEW] },
|
||||||
|
OR: [
|
||||||
|
{ type: CardType.NEW },
|
||||||
|
{
|
||||||
|
type: { in: [CardType.LEARNING, CardType.REVIEW] },
|
||||||
|
due: { lte: todayDays },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ type: "asc" },
|
||||||
|
{ due: "asc" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repoGetCardsByNoteId(noteId: bigint): Promise<RepoOutputCard[]> {
|
||||||
|
const cards = await prisma.card.findMany({
|
||||||
|
where: { noteId },
|
||||||
|
orderBy: { ord: "asc" },
|
||||||
|
});
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
126
src/modules/card/card-service-dto.ts
Normal file
126
src/modules/card/card-service-dto.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { CardType, CardQueue } from "../../../generated/prisma/enums";
|
||||||
|
|
||||||
|
export type ReviewEase = 1 | 2 | 3 | 4;
|
||||||
|
|
||||||
|
export interface ServiceInputCreateCard {
|
||||||
|
noteId: bigint;
|
||||||
|
deckId: number;
|
||||||
|
ord?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceInputAnswerCard {
|
||||||
|
cardId: bigint;
|
||||||
|
ease: ReviewEase;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceInputGetCardsForReview {
|
||||||
|
deckId: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceInputGetNewCards {
|
||||||
|
deckId: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceInputGetCardsByDeckId {
|
||||||
|
deckId: number;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
queue?: CardQueue | CardQueue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceInputGetCardStats {
|
||||||
|
deckId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceInputCheckCardOwnership {
|
||||||
|
cardId: bigint;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServiceOutputCheckCardOwnership = boolean;
|
||||||
|
|
||||||
|
export type ServiceOutputCard = {
|
||||||
|
id: bigint;
|
||||||
|
noteId: bigint;
|
||||||
|
deckId: number;
|
||||||
|
ord: number;
|
||||||
|
mod: number;
|
||||||
|
usn: number;
|
||||||
|
type: CardType;
|
||||||
|
queue: CardQueue;
|
||||||
|
due: number;
|
||||||
|
ivl: number;
|
||||||
|
factor: number;
|
||||||
|
reps: number;
|
||||||
|
lapses: number;
|
||||||
|
left: number;
|
||||||
|
odue: number;
|
||||||
|
odid: number;
|
||||||
|
flags: number;
|
||||||
|
data: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceOutputCardWithNote = ServiceOutputCard & {
|
||||||
|
note: {
|
||||||
|
id: bigint;
|
||||||
|
flds: string;
|
||||||
|
sfld: string;
|
||||||
|
tags: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceOutputCardStats = {
|
||||||
|
total: number;
|
||||||
|
new: number;
|
||||||
|
learning: number;
|
||||||
|
review: number;
|
||||||
|
due: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceOutputScheduledCard = {
|
||||||
|
cardId: bigint;
|
||||||
|
newType: CardType;
|
||||||
|
newQueue: CardQueue;
|
||||||
|
newDue: number;
|
||||||
|
newIvl: number;
|
||||||
|
newFactor: number;
|
||||||
|
newReps: number;
|
||||||
|
newLapses: number;
|
||||||
|
nextReviewDate: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceOutputReviewResult = {
|
||||||
|
success: boolean;
|
||||||
|
card: ServiceOutputCard;
|
||||||
|
scheduled: ServiceOutputScheduledCard;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SM2_CONFIG = {
|
||||||
|
LEARNING_STEPS: [1, 10],
|
||||||
|
RELEARNING_STEPS: [10],
|
||||||
|
GRADUATING_INTERVAL_GOOD: 1,
|
||||||
|
GRADUATING_INTERVAL_EASY: 4,
|
||||||
|
EASY_INTERVAL: 4,
|
||||||
|
MINIMUM_FACTOR: 1300,
|
||||||
|
DEFAULT_FACTOR: 2500,
|
||||||
|
MAXIMUM_INTERVAL: 36500,
|
||||||
|
EASY_BONUS: 1.3,
|
||||||
|
HARD_INTERVAL: 1.2,
|
||||||
|
NEW_INTERVAL: 0.0,
|
||||||
|
INTERVAL_MODIFIER: 1.0,
|
||||||
|
FACTOR_ADJUSTMENTS: {
|
||||||
|
1: -200,
|
||||||
|
2: -150,
|
||||||
|
3: 0,
|
||||||
|
4: 150,
|
||||||
|
},
|
||||||
|
INITIAL_INTERVALS: {
|
||||||
|
2: 1,
|
||||||
|
3: 3,
|
||||||
|
4: 4,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
497
src/modules/card/card-service.ts
Normal file
497
src/modules/card/card-service.ts
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
import { createLogger } from "@/lib/logger";
|
||||||
|
import {
|
||||||
|
repoCreateCard,
|
||||||
|
repoUpdateCard,
|
||||||
|
repoGetCardById,
|
||||||
|
repoGetCardByIdWithNote,
|
||||||
|
repoGetCardsByDeckId,
|
||||||
|
repoGetCardsByDeckIdWithNotes,
|
||||||
|
repoGetCardsForReview,
|
||||||
|
repoGetNewCards,
|
||||||
|
repoGetCardStats,
|
||||||
|
repoDeleteCard,
|
||||||
|
repoGetCardsByNoteId,
|
||||||
|
repoGetCardDeckOwnerId,
|
||||||
|
} from "./card-repository";
|
||||||
|
import {
|
||||||
|
RepoInputUpdateCard,
|
||||||
|
RepoOutputCard,
|
||||||
|
} from "./card-repository-dto";
|
||||||
|
import {
|
||||||
|
ServiceInputCreateCard,
|
||||||
|
ServiceInputAnswerCard,
|
||||||
|
ServiceInputGetCardsForReview,
|
||||||
|
ServiceInputGetNewCards,
|
||||||
|
ServiceInputGetCardsByDeckId,
|
||||||
|
ServiceInputGetCardStats,
|
||||||
|
ServiceInputCheckCardOwnership,
|
||||||
|
ServiceOutputCard,
|
||||||
|
ServiceOutputCardWithNote,
|
||||||
|
ServiceOutputCardStats,
|
||||||
|
ServiceOutputScheduledCard,
|
||||||
|
ServiceOutputReviewResult,
|
||||||
|
ServiceOutputCheckCardOwnership,
|
||||||
|
ReviewEase,
|
||||||
|
SM2_CONFIG,
|
||||||
|
} from "./card-service-dto";
|
||||||
|
import { CardType, CardQueue } from "../../../generated/prisma/enums";
|
||||||
|
|
||||||
|
const log = createLogger("card-service");
|
||||||
|
|
||||||
|
function generateCardId(): bigint {
|
||||||
|
return BigInt(Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateDueDate(intervalDays: number): number {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const todayStart = Math.floor(now / 86400) * 86400;
|
||||||
|
return Math.floor(todayStart / 86400) + intervalDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateNextReviewTime(intervalDays: number): Date {
|
||||||
|
const now = Date.now();
|
||||||
|
return new Date(now + intervalDays * 86400 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampInterval(interval: number): number {
|
||||||
|
return Math.min(Math.max(1, interval), SM2_CONFIG.MAXIMUM_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleNewCard(ease: ReviewEase, currentFactor: number): {
|
||||||
|
type: CardType;
|
||||||
|
queue: CardQueue;
|
||||||
|
ivl: number;
|
||||||
|
due: number;
|
||||||
|
newFactor: number;
|
||||||
|
} {
|
||||||
|
if (ease === 1) {
|
||||||
|
return {
|
||||||
|
type: CardType.LEARNING,
|
||||||
|
queue: CardQueue.LEARNING,
|
||||||
|
ivl: 0,
|
||||||
|
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60,
|
||||||
|
newFactor: currentFactor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ease === 2) {
|
||||||
|
if (SM2_CONFIG.LEARNING_STEPS.length >= 2) {
|
||||||
|
const avgStep = (SM2_CONFIG.LEARNING_STEPS[0] + SM2_CONFIG.LEARNING_STEPS[1]) / 2;
|
||||||
|
return {
|
||||||
|
type: CardType.LEARNING,
|
||||||
|
queue: CardQueue.LEARNING,
|
||||||
|
ivl: 0,
|
||||||
|
due: Math.floor(Date.now() / 1000) + avgStep * 60,
|
||||||
|
newFactor: currentFactor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: CardType.LEARNING,
|
||||||
|
queue: CardQueue.LEARNING,
|
||||||
|
ivl: 0,
|
||||||
|
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60,
|
||||||
|
newFactor: currentFactor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ease === 3) {
|
||||||
|
if (SM2_CONFIG.LEARNING_STEPS.length >= 2) {
|
||||||
|
return {
|
||||||
|
type: CardType.LEARNING,
|
||||||
|
queue: CardQueue.LEARNING,
|
||||||
|
ivl: 0,
|
||||||
|
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[1] * 60,
|
||||||
|
newFactor: currentFactor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const ivl = SM2_CONFIG.GRADUATING_INTERVAL_GOOD;
|
||||||
|
return {
|
||||||
|
type: CardType.REVIEW,
|
||||||
|
queue: CardQueue.REVIEW,
|
||||||
|
ivl,
|
||||||
|
due: calculateDueDate(ivl),
|
||||||
|
newFactor: SM2_CONFIG.DEFAULT_FACTOR,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ivl = SM2_CONFIG.EASY_INTERVAL;
|
||||||
|
const newFactor = SM2_CONFIG.DEFAULT_FACTOR + SM2_CONFIG.FACTOR_ADJUSTMENTS[4];
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: CardType.REVIEW,
|
||||||
|
queue: CardQueue.REVIEW,
|
||||||
|
ivl,
|
||||||
|
due: calculateDueDate(ivl),
|
||||||
|
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, newFactor),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleLearningCard(ease: ReviewEase, currentFactor: number, left: number, isRelearning: boolean): {
|
||||||
|
type: CardType;
|
||||||
|
queue: CardQueue;
|
||||||
|
ivl: number;
|
||||||
|
due: number;
|
||||||
|
newFactor: number;
|
||||||
|
newLeft: number;
|
||||||
|
} {
|
||||||
|
const steps = isRelearning ? SM2_CONFIG.RELEARNING_STEPS : SM2_CONFIG.LEARNING_STEPS;
|
||||||
|
const totalSteps = steps.length;
|
||||||
|
const cardType = isRelearning ? CardType.RELEARNING : CardType.LEARNING;
|
||||||
|
|
||||||
|
if (ease === 1) {
|
||||||
|
return {
|
||||||
|
type: cardType,
|
||||||
|
queue: CardQueue.LEARNING,
|
||||||
|
ivl: 0,
|
||||||
|
due: Math.floor(Date.now() / 1000) + steps[0] * 60,
|
||||||
|
newFactor: currentFactor,
|
||||||
|
newLeft: totalSteps * 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepIndex = Math.floor(left % 1000);
|
||||||
|
|
||||||
|
if (ease === 2) {
|
||||||
|
if (stepIndex === 0 && steps.length >= 2) {
|
||||||
|
const step0 = steps[0] ?? 1;
|
||||||
|
const step1 = steps[1] ?? step0;
|
||||||
|
const avgStep = (step0 + step1) / 2;
|
||||||
|
return {
|
||||||
|
type: cardType,
|
||||||
|
queue: CardQueue.LEARNING,
|
||||||
|
ivl: 0,
|
||||||
|
due: Math.floor(Date.now() / 1000) + avgStep * 60,
|
||||||
|
newFactor: currentFactor,
|
||||||
|
newLeft: left,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const currentStepDelay = steps[stepIndex] ?? steps[0] ?? 1;
|
||||||
|
return {
|
||||||
|
type: cardType,
|
||||||
|
queue: CardQueue.LEARNING,
|
||||||
|
ivl: 0,
|
||||||
|
due: Math.floor(Date.now() / 1000) + currentStepDelay * 60,
|
||||||
|
newFactor: currentFactor,
|
||||||
|
newLeft: left,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ease === 3) {
|
||||||
|
if (stepIndex < steps.length - 1) {
|
||||||
|
const nextStep = stepIndex + 1;
|
||||||
|
const nextStepDelay = steps[nextStep] ?? steps[0];
|
||||||
|
return {
|
||||||
|
type: cardType,
|
||||||
|
queue: CardQueue.LEARNING,
|
||||||
|
ivl: 0,
|
||||||
|
due: Math.floor(Date.now() / 1000) + nextStepDelay * 60,
|
||||||
|
newFactor: currentFactor,
|
||||||
|
newLeft: nextStep * 1000 + (totalSteps - nextStep),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ivl = SM2_CONFIG.GRADUATING_INTERVAL_GOOD;
|
||||||
|
return {
|
||||||
|
type: CardType.REVIEW,
|
||||||
|
queue: CardQueue.REVIEW,
|
||||||
|
ivl,
|
||||||
|
due: calculateDueDate(ivl),
|
||||||
|
newFactor: SM2_CONFIG.DEFAULT_FACTOR,
|
||||||
|
newLeft: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ivl = SM2_CONFIG.GRADUATING_INTERVAL_EASY;
|
||||||
|
const newFactor = SM2_CONFIG.DEFAULT_FACTOR + SM2_CONFIG.FACTOR_ADJUSTMENTS[4];
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: CardType.REVIEW,
|
||||||
|
queue: CardQueue.REVIEW,
|
||||||
|
ivl,
|
||||||
|
due: calculateDueDate(ivl),
|
||||||
|
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, newFactor),
|
||||||
|
newLeft: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReviewCard(
|
||||||
|
ease: ReviewEase,
|
||||||
|
currentIvl: number,
|
||||||
|
currentFactor: number,
|
||||||
|
lapses: number,
|
||||||
|
): {
|
||||||
|
type: CardType;
|
||||||
|
queue: CardQueue;
|
||||||
|
ivl: number;
|
||||||
|
due: number;
|
||||||
|
newFactor: number;
|
||||||
|
newLapses: number;
|
||||||
|
} {
|
||||||
|
if (ease === 1) {
|
||||||
|
const newFactor = Math.max(SM2_CONFIG.MINIMUM_FACTOR, currentFactor + SM2_CONFIG.FACTOR_ADJUSTMENTS[1]);
|
||||||
|
const newIvl = Math.max(1, Math.floor(currentIvl * SM2_CONFIG.NEW_INTERVAL));
|
||||||
|
return {
|
||||||
|
type: CardType.RELEARNING,
|
||||||
|
queue: CardQueue.LEARNING,
|
||||||
|
ivl: newIvl,
|
||||||
|
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.RELEARNING_STEPS[0] * 60,
|
||||||
|
newFactor,
|
||||||
|
newLapses: lapses + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let newFactor: number;
|
||||||
|
let newIvl: number;
|
||||||
|
|
||||||
|
if (ease === 2) {
|
||||||
|
newFactor = Math.max(SM2_CONFIG.MINIMUM_FACTOR, currentFactor + SM2_CONFIG.FACTOR_ADJUSTMENTS[2]);
|
||||||
|
newIvl = Math.floor(currentIvl * SM2_CONFIG.HARD_INTERVAL * SM2_CONFIG.INTERVAL_MODIFIER);
|
||||||
|
} else if (ease === 3) {
|
||||||
|
newFactor = currentFactor;
|
||||||
|
newIvl = Math.floor(currentIvl * (currentFactor / 1000) * SM2_CONFIG.INTERVAL_MODIFIER);
|
||||||
|
} else {
|
||||||
|
newIvl = Math.floor(currentIvl * (currentFactor / 1000) * SM2_CONFIG.EASY_BONUS * SM2_CONFIG.INTERVAL_MODIFIER);
|
||||||
|
newFactor = Math.max(SM2_CONFIG.MINIMUM_FACTOR, currentFactor + SM2_CONFIG.FACTOR_ADJUSTMENTS[4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
newIvl = clampInterval(newIvl);
|
||||||
|
newIvl = Math.max(currentIvl + 1, newIvl);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: CardType.REVIEW,
|
||||||
|
queue: CardQueue.REVIEW,
|
||||||
|
ivl: newIvl,
|
||||||
|
due: calculateDueDate(newIvl),
|
||||||
|
newFactor,
|
||||||
|
newLapses: lapses,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapToServiceOutput(card: RepoOutputCard): ServiceOutputCard {
|
||||||
|
return {
|
||||||
|
id: card.id,
|
||||||
|
noteId: card.noteId,
|
||||||
|
deckId: card.deckId,
|
||||||
|
ord: card.ord,
|
||||||
|
mod: card.mod,
|
||||||
|
usn: card.usn,
|
||||||
|
type: card.type,
|
||||||
|
queue: card.queue,
|
||||||
|
due: card.due,
|
||||||
|
ivl: card.ivl,
|
||||||
|
factor: card.factor,
|
||||||
|
reps: card.reps,
|
||||||
|
lapses: card.lapses,
|
||||||
|
left: card.left,
|
||||||
|
odue: card.odue,
|
||||||
|
odid: card.odid,
|
||||||
|
flags: card.flags,
|
||||||
|
data: card.data,
|
||||||
|
createdAt: card.createdAt,
|
||||||
|
updatedAt: card.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceCreateCard(
|
||||||
|
input: ServiceInputCreateCard,
|
||||||
|
): Promise<bigint> {
|
||||||
|
log.info("Creating card from note", { noteId: input.noteId.toString(), deckId: input.deckId });
|
||||||
|
|
||||||
|
const existingCards = await repoGetCardsByNoteId(input.noteId);
|
||||||
|
const maxOrd = existingCards.reduce((max, c) => Math.max(max, c.ord), -1);
|
||||||
|
const ord = input.ord ?? maxOrd + 1;
|
||||||
|
|
||||||
|
const cardId = await repoCreateCard({
|
||||||
|
id: generateCardId(),
|
||||||
|
noteId: input.noteId,
|
||||||
|
deckId: input.deckId,
|
||||||
|
ord,
|
||||||
|
due: ord,
|
||||||
|
type: CardType.NEW,
|
||||||
|
queue: CardQueue.NEW,
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info("Card created", { cardId: cardId.toString() });
|
||||||
|
return cardId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceAnswerCard(
|
||||||
|
input: ServiceInputAnswerCard,
|
||||||
|
): Promise<ServiceOutputReviewResult> {
|
||||||
|
log.info("Answering card", { cardId: input.cardId.toString(), ease: input.ease });
|
||||||
|
|
||||||
|
const card = await repoGetCardById(input.cardId);
|
||||||
|
if (!card) {
|
||||||
|
throw new Error(`Card not found: ${input.cardId.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ease } = input;
|
||||||
|
let updateData: RepoInputUpdateCard;
|
||||||
|
let scheduled: ServiceOutputScheduledCard;
|
||||||
|
|
||||||
|
if (card.type === CardType.NEW) {
|
||||||
|
const result = scheduleNewCard(ease, card.factor);
|
||||||
|
updateData = {
|
||||||
|
type: result.type,
|
||||||
|
queue: result.queue,
|
||||||
|
ivl: result.ivl,
|
||||||
|
due: result.due,
|
||||||
|
factor: result.newFactor,
|
||||||
|
reps: card.reps + 1,
|
||||||
|
left: result.type === CardType.LEARNING
|
||||||
|
? SM2_CONFIG.LEARNING_STEPS.length * 1000
|
||||||
|
: 0,
|
||||||
|
mod: Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
scheduled = {
|
||||||
|
cardId: card.id,
|
||||||
|
newType: result.type,
|
||||||
|
newQueue: result.queue,
|
||||||
|
newDue: result.due,
|
||||||
|
newIvl: result.ivl,
|
||||||
|
newFactor: result.newFactor,
|
||||||
|
newReps: card.reps + 1,
|
||||||
|
newLapses: card.lapses,
|
||||||
|
nextReviewDate: calculateNextReviewTime(result.ivl),
|
||||||
|
};
|
||||||
|
} else if (card.type === CardType.LEARNING || card.type === CardType.RELEARNING) {
|
||||||
|
const result = scheduleLearningCard(ease, card.factor, card.left, card.type === CardType.RELEARNING);
|
||||||
|
updateData = {
|
||||||
|
type: result.type,
|
||||||
|
queue: result.queue,
|
||||||
|
ivl: result.ivl,
|
||||||
|
due: result.due,
|
||||||
|
factor: result.newFactor,
|
||||||
|
reps: card.reps + 1,
|
||||||
|
left: result.newLeft,
|
||||||
|
mod: Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
scheduled = {
|
||||||
|
cardId: card.id,
|
||||||
|
newType: result.type,
|
||||||
|
newQueue: result.queue,
|
||||||
|
newDue: result.due,
|
||||||
|
newIvl: result.ivl,
|
||||||
|
newFactor: result.newFactor,
|
||||||
|
newReps: card.reps + 1,
|
||||||
|
newLapses: card.lapses,
|
||||||
|
nextReviewDate: calculateNextReviewTime(result.ivl),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const result = scheduleReviewCard(ease, card.ivl, card.factor, card.lapses);
|
||||||
|
updateData = {
|
||||||
|
type: result.type,
|
||||||
|
queue: result.queue,
|
||||||
|
ivl: result.ivl,
|
||||||
|
due: result.due,
|
||||||
|
factor: result.newFactor,
|
||||||
|
reps: card.reps + 1,
|
||||||
|
lapses: result.newLapses,
|
||||||
|
left: result.type === CardType.RELEARNING
|
||||||
|
? SM2_CONFIG.RELEARNING_STEPS.length * 1000
|
||||||
|
: 0,
|
||||||
|
mod: Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
scheduled = {
|
||||||
|
cardId: card.id,
|
||||||
|
newType: result.type,
|
||||||
|
newQueue: result.queue,
|
||||||
|
newDue: result.due,
|
||||||
|
newIvl: result.ivl,
|
||||||
|
newFactor: result.newFactor,
|
||||||
|
newReps: card.reps + 1,
|
||||||
|
newLapses: result.newLapses,
|
||||||
|
nextReviewDate: calculateNextReviewTime(result.ivl),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await repoUpdateCard(input.cardId, updateData);
|
||||||
|
|
||||||
|
const updatedCard = await repoGetCardById(input.cardId);
|
||||||
|
if (!updatedCard) {
|
||||||
|
throw new Error(`Card not found after update: ${input.cardId.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Card answered and scheduled", {
|
||||||
|
cardId: input.cardId.toString(),
|
||||||
|
newType: scheduled.newType,
|
||||||
|
newIvl: scheduled.newIvl,
|
||||||
|
nextReview: scheduled.nextReviewDate.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
card: mapToServiceOutput(updatedCard),
|
||||||
|
scheduled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceGetNextCardForReview(
|
||||||
|
deckId: number,
|
||||||
|
): Promise<ServiceOutputCardWithNote | null> {
|
||||||
|
log.debug("Getting next card for review", { deckId });
|
||||||
|
const cards = await repoGetCardsForReview({ deckId, limit: 1 });
|
||||||
|
return cards[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceGetCardsForReview(
|
||||||
|
input: ServiceInputGetCardsForReview,
|
||||||
|
): Promise<ServiceOutputCardWithNote[]> {
|
||||||
|
log.debug("Getting cards for review", { deckId: input.deckId });
|
||||||
|
return repoGetCardsForReview(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceGetNewCards(
|
||||||
|
input: ServiceInputGetNewCards,
|
||||||
|
): Promise<ServiceOutputCardWithNote[]> {
|
||||||
|
log.debug("Getting new cards", { deckId: input.deckId });
|
||||||
|
return repoGetNewCards(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceGetCardsByDeckId(
|
||||||
|
input: ServiceInputGetCardsByDeckId,
|
||||||
|
): Promise<ServiceOutputCard[]> {
|
||||||
|
log.debug("Getting cards by deck", { deckId: input.deckId });
|
||||||
|
const cards = await repoGetCardsByDeckId(input);
|
||||||
|
return cards.map(mapToServiceOutput);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceGetCardsByDeckIdWithNotes(
|
||||||
|
input: ServiceInputGetCardsByDeckId,
|
||||||
|
): Promise<ServiceOutputCardWithNote[]> {
|
||||||
|
log.debug("Getting cards by deck with notes", { deckId: input.deckId });
|
||||||
|
return repoGetCardsByDeckIdWithNotes(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceGetCardById(
|
||||||
|
cardId: bigint,
|
||||||
|
): Promise<ServiceOutputCard | null> {
|
||||||
|
const card = await repoGetCardById(cardId);
|
||||||
|
return card ? mapToServiceOutput(card) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceGetCardByIdWithNote(
|
||||||
|
cardId: bigint,
|
||||||
|
): Promise<ServiceOutputCardWithNote | null> {
|
||||||
|
return repoGetCardByIdWithNote(cardId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceGetCardStats(
|
||||||
|
input: ServiceInputGetCardStats,
|
||||||
|
): Promise<ServiceOutputCardStats> {
|
||||||
|
log.debug("Getting card stats", { deckId: input.deckId });
|
||||||
|
return repoGetCardStats(input.deckId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceDeleteCard(cardId: bigint): Promise<void> {
|
||||||
|
log.info("Deleting card", { cardId: cardId.toString() });
|
||||||
|
await repoDeleteCard(cardId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceCheckCardOwnership(
|
||||||
|
input: ServiceInputCheckCardOwnership,
|
||||||
|
): Promise<ServiceOutputCheckCardOwnership> {
|
||||||
|
log.debug("Checking card ownership", { cardId: input.cardId.toString() });
|
||||||
|
const ownerId = await repoGetCardDeckOwnerId(input.cardId);
|
||||||
|
return ownerId === input.userId;
|
||||||
|
}
|
||||||
157
src/modules/deck/deck-action-dto.ts
Normal file
157
src/modules/deck/deck-action-dto.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
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(),
|
||||||
|
collapsed: z.boolean().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";
|
||||||
|
collapsed: boolean;
|
||||||
|
conf: unknown;
|
||||||
|
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[];
|
||||||
|
};
|
||||||
327
src/modules/deck/deck-action.ts
Normal file
327
src/modules/deck/deck-action.ts
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
"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,
|
||||||
|
collapsed: validatedInput.collapsed,
|
||||||
|
});
|
||||||
|
} 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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/modules/deck/deck-repository-dto.ts
Normal file
90
src/modules/deck/deck-repository-dto.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { Visibility } from "../../../generated/prisma/enums";
|
||||||
|
|
||||||
|
export interface RepoInputCreateDeck {
|
||||||
|
name: string;
|
||||||
|
desc?: string;
|
||||||
|
userId: string;
|
||||||
|
visibility?: Visibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepoInputUpdateDeck {
|
||||||
|
id: number;
|
||||||
|
name?: string;
|
||||||
|
desc?: string;
|
||||||
|
visibility?: Visibility;
|
||||||
|
collapsed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
collapsed: boolean;
|
||||||
|
conf: unknown;
|
||||||
|
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;
|
||||||
|
};
|
||||||
327
src/modules/deck/deck-repository.ts
Normal file
327
src/modules/deck/deck-repository.ts
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
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,
|
||||||
|
collapsed: deck.collapsed,
|
||||||
|
conf: deck.conf,
|
||||||
|
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,
|
||||||
|
collapsed: deck.collapsed,
|
||||||
|
conf: deck.conf,
|
||||||
|
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,
|
||||||
|
collapsed: deck.collapsed,
|
||||||
|
conf: deck.conf,
|
||||||
|
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,
|
||||||
|
collapsed: deck.collapsed,
|
||||||
|
conf: deck.conf,
|
||||||
|
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,
|
||||||
|
collapsed: deck.collapsed,
|
||||||
|
conf: deck.conf,
|
||||||
|
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,
|
||||||
|
collapsed: fav.deck.collapsed,
|
||||||
|
conf: fav.deck.conf,
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
86
src/modules/deck/deck-service-dto.ts
Normal file
86
src/modules/deck/deck-service-dto.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
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;
|
||||||
|
collapsed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
collapsed: boolean;
|
||||||
|
conf: unknown;
|
||||||
|
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;
|
||||||
|
};
|
||||||
167
src/modules/deck/deck-service.ts
Normal file
167
src/modules/deck/deck-service.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
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,
|
||||||
|
collapsed: input.collapsed,
|
||||||
|
});
|
||||||
|
log.info("Deck updated successfully", { deckId: input.deckId });
|
||||||
|
return { success: true, message: "Deck updated successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to update deck", { error, deckId: input.deckId });
|
||||||
|
return { success: false, message: "Failed to update deck" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceDeleteDeck(input: ServiceInputDeleteDeck): Promise<{ success: boolean; message: string }> {
|
||||||
|
try {
|
||||||
|
log.info("Deleting deck", { deckId: input.deckId });
|
||||||
|
await repoDeleteDeck({ id: input.deckId });
|
||||||
|
log.info("Deck deleted successfully", { deckId: input.deckId });
|
||||||
|
return { success: true, message: "Deck deleted successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to delete deck", { error, deckId: input.deckId });
|
||||||
|
return { success: false, message: "Failed to delete deck" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceGetDeckById(input: ServiceInputGetDeckById): Promise<{ success: boolean; data?: ServiceOutputDeck; message: string }> {
|
||||||
|
try {
|
||||||
|
const deck = await repoGetDeckById({ id: input.deckId });
|
||||||
|
if (!deck) {
|
||||||
|
return { success: false, message: "Deck not found" };
|
||||||
|
}
|
||||||
|
return { success: true, data: deck, message: "Deck retrieved successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to get deck", { error, deckId: input.deckId });
|
||||||
|
return { success: false, message: "Failed to get deck" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceGetDecksByUserId(input: ServiceInputGetDecksByUserId): Promise<{ success: boolean; data?: ServiceOutputDeck[]; message: string }> {
|
||||||
|
try {
|
||||||
|
const decks = await repoGetDecksByUserId(input);
|
||||||
|
return { success: true, data: decks, message: "Decks retrieved successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to get decks", { error, userId: input.userId });
|
||||||
|
return { success: false, message: "Failed to get decks" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceGetPublicDecks(input: ServiceInputGetPublicDecks = {}): Promise<{ success: boolean; data?: ServiceOutputPublicDeck[]; message: string }> {
|
||||||
|
try {
|
||||||
|
const decks = await repoGetPublicDecks(input);
|
||||||
|
return { success: true, data: decks, message: "Public decks retrieved successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to get public decks", { error });
|
||||||
|
return { success: false, message: "Failed to get public decks" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceGetPublicDeckById(input: ServiceInputGetPublicDeckById): Promise<{ success: boolean; data?: ServiceOutputPublicDeck; message: string }> {
|
||||||
|
try {
|
||||||
|
const deck = await repoGetPublicDeckById(input);
|
||||||
|
if (!deck) {
|
||||||
|
return { success: false, message: "Deck not found or not public" };
|
||||||
|
}
|
||||||
|
return { success: true, data: deck, message: "Deck retrieved successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to get public deck", { error, deckId: input.deckId });
|
||||||
|
return { success: false, message: "Failed to get deck" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceToggleDeckFavorite(input: ServiceInputToggleDeckFavorite): Promise<{ success: boolean; data?: ServiceOutputDeckFavorite; message: string }> {
|
||||||
|
try {
|
||||||
|
const result = await repoToggleDeckFavorite(input);
|
||||||
|
return { success: true, data: result, message: "Favorite toggled successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to toggle deck favorite", { error, deckId: input.deckId });
|
||||||
|
return { success: false, message: "Failed to toggle favorite" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceCheckDeckFavorite(input: ServiceInputCheckDeckFavorite): Promise<{ success: boolean; data?: ServiceOutputDeckFavorite; message: string }> {
|
||||||
|
try {
|
||||||
|
const result = await repoCheckDeckFavorite(input);
|
||||||
|
return { success: true, data: result, message: "Favorite status retrieved" };
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to check deck favorite", { error, deckId: input.deckId });
|
||||||
|
return { success: false, message: "Failed to check favorite status" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceSearchPublicDecks(input: ServiceInputSearchPublicDecks): Promise<{ success: boolean; data?: ServiceOutputPublicDeck[]; message: string }> {
|
||||||
|
try {
|
||||||
|
const decks = await repoSearchPublicDecks(input);
|
||||||
|
return { success: true, data: decks, message: "Search completed successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to search public decks", { error, query: input.query });
|
||||||
|
return { success: false, message: "Search failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serviceGetUserFavoriteDecks(userId: string): Promise<{ success: boolean; data?: ServiceOutputUserFavoriteDeck[]; message: string }> {
|
||||||
|
try {
|
||||||
|
const favorites = await repoGetUserFavoriteDecks({ userId });
|
||||||
|
return { success: true, data: favorites, message: "Favorite decks retrieved successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to get user favorite decks", { error, userId });
|
||||||
|
return { success: false, message: "Failed to get favorite decks" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { TSharedItem } from "@/shared/dictionary-type";
|
|
||||||
|
|
||||||
export type RepoInputCreateDictionaryLookUp = {
|
export type RepoInputCreateDictionaryLookUp = {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -8,7 +6,29 @@ export type RepoInputCreateDictionaryLookUp = {
|
|||||||
dictionaryItemId?: number;
|
dictionaryItemId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RepoOutputSelectLastLookUpResult = TSharedItem & {id: number} | null;
|
export type RepoOutputSelectLastLookUpResultEntry = {
|
||||||
|
id: number;
|
||||||
|
itemId: number;
|
||||||
|
ipa: string | null;
|
||||||
|
definition: string;
|
||||||
|
partOfSpeech: string | null;
|
||||||
|
example: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RepoOutputSelectLastLookUpResultItem = {
|
||||||
|
id: number;
|
||||||
|
frequency: number;
|
||||||
|
standardForm: string;
|
||||||
|
queryLang: string;
|
||||||
|
definitionLang: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
entries: RepoOutputSelectLastLookUpResultEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RepoOutputSelectLastLookUpResult = RepoOutputSelectLastLookUpResultItem | null;
|
||||||
|
|
||||||
export type RepoInputCreateDictionaryItem = {
|
export type RepoInputCreateDictionaryItem = {
|
||||||
standardForm: string;
|
standardForm: string;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { stringNormalize } from "@/utils/string";
|
import { stringNormalize } from "@/utils/string";
|
||||||
import {
|
import {
|
||||||
RepoInputCreateDictionaryEntry,
|
|
||||||
RepoInputCreateDictionaryEntryWithoutItemId,
|
RepoInputCreateDictionaryEntryWithoutItemId,
|
||||||
RepoInputCreateDictionaryItem,
|
RepoInputCreateDictionaryItem,
|
||||||
RepoInputCreateDictionaryLookUp,
|
RepoInputCreateDictionaryLookUp,
|
||||||
@@ -30,22 +29,12 @@ export async function repoSelectLastLookUpResult(dto: RepoInputSelectLastLookUpR
|
|||||||
createdAt: 'desc'
|
createdAt: 'desc'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (result && result.dictionaryItem) {
|
|
||||||
const item = result.dictionaryItem;
|
if (!result?.dictionaryItem) {
|
||||||
return {
|
return null;
|
||||||
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;
|
|
||||||
|
return result.dictionaryItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function repoCreateLookUp(content: RepoInputCreateDictionaryLookUp) {
|
export async function repoCreateLookUp(content: RepoInputCreateDictionaryLookUp) {
|
||||||
|
|||||||
@@ -2,9 +2,23 @@ import { executeDictionaryLookup } from "@/lib/bigmodel/dictionary/orchestrator"
|
|||||||
import { repoCreateLookUp, repoCreateLookUpWithItemAndEntries, repoSelectLastLookUpResult } from "./dictionary-repository";
|
import { repoCreateLookUp, repoCreateLookUpWithItemAndEntries, repoSelectLastLookUpResult } from "./dictionary-repository";
|
||||||
import { ServiceInputLookUp } from "./dictionary-service-dto";
|
import { ServiceInputLookUp } from "./dictionary-service-dto";
|
||||||
import { createLogger } from "@/lib/logger";
|
import { createLogger } from "@/lib/logger";
|
||||||
|
import { RepoOutputSelectLastLookUpResultItem } from "./dictionary-repository-dto";
|
||||||
|
|
||||||
const log = createLogger("dictionary-service");
|
const log = createLogger("dictionary-service");
|
||||||
|
|
||||||
|
function transformRawItemToSharedItem(rawItem: RepoOutputSelectLastLookUpResultItem) {
|
||||||
|
return {
|
||||||
|
id: rawItem.id,
|
||||||
|
standardForm: rawItem.standardForm,
|
||||||
|
entries: rawItem.entries.map(entry => ({
|
||||||
|
ipa: entry.ipa ?? undefined,
|
||||||
|
definition: entry.definition,
|
||||||
|
partOfSpeech: entry.partOfSpeech ?? undefined,
|
||||||
|
example: entry.example
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const serviceLookUp = async (dto: ServiceInputLookUp) => {
|
export const serviceLookUp = async (dto: ServiceInputLookUp) => {
|
||||||
const {
|
const {
|
||||||
text,
|
text,
|
||||||
@@ -27,7 +41,6 @@ export const serviceLookUp = async (dto: ServiceInputLookUp) => {
|
|||||||
definitionLang
|
definitionLang
|
||||||
);
|
);
|
||||||
|
|
||||||
// 使用事务确保数据一致性
|
|
||||||
repoCreateLookUpWithItemAndEntries(
|
repoCreateLookUpWithItemAndEntries(
|
||||||
{
|
{
|
||||||
standardForm: response.standardForm,
|
standardForm: response.standardForm,
|
||||||
@@ -47,18 +60,20 @@ export const serviceLookUp = async (dto: ServiceInputLookUp) => {
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
} else {
|
} else {
|
||||||
|
const transformedResult = transformRawItemToSharedItem(lastLookUpResult);
|
||||||
|
|
||||||
repoCreateLookUp({
|
repoCreateLookUp({
|
||||||
userId: userId,
|
userId: userId,
|
||||||
text: text,
|
text: text,
|
||||||
queryLang: queryLang,
|
queryLang: queryLang,
|
||||||
definitionLang: definitionLang,
|
definitionLang: definitionLang,
|
||||||
dictionaryItemId: lastLookUpResult.id
|
dictionaryItemId: transformedResult.id
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
log.error("Failed to save dictionary data", { error: error instanceof Error ? error.message : String(error) });
|
log.error("Failed to save dictionary data", { error: error instanceof Error ? error.message : String(error) });
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
standardForm: lastLookUpResult.standardForm,
|
standardForm: transformedResult.standardForm,
|
||||||
entries: lastLookUpResult.entries
|
entries: transformedResult.entries
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
134
src/modules/export/export-action.ts
Normal file
134
src/modules/export/export-action.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { exportApkg, type ExportDeckData } from "@/lib/anki/apkg-exporter";
|
||||||
|
import { createLogger } from "@/lib/logger";
|
||||||
|
|
||||||
|
const log = createLogger("export-action");
|
||||||
|
|
||||||
|
export interface ActionOutputExportApkg {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data?: ArrayBuffer;
|
||||||
|
filename?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function actionExportApkg(deckId: number): Promise<ActionOutputExportApkg> {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, message: "Unauthorized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deck = await prisma.deck.findFirst({
|
||||||
|
where: { id: deckId, userId: session.user.id },
|
||||||
|
include: {
|
||||||
|
cards: {
|
||||||
|
include: {
|
||||||
|
note: {
|
||||||
|
include: {
|
||||||
|
noteType: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!deck) {
|
||||||
|
return { success: false, message: "Deck not found or access denied" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deck.cards.length === 0) {
|
||||||
|
return { success: false, message: "Deck has no cards to export" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstCard = deck.cards[0];
|
||||||
|
if (!firstCard?.note?.noteType) {
|
||||||
|
return { success: false, message: "Deck has invalid card data" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const noteType = firstCard.note.noteType;
|
||||||
|
|
||||||
|
const revlogs = await prisma.revlog.findMany({
|
||||||
|
where: {
|
||||||
|
cardId: { in: deck.cards.map(c => c.id) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportData: ExportDeckData = {
|
||||||
|
deck: {
|
||||||
|
id: deck.id,
|
||||||
|
name: deck.name,
|
||||||
|
desc: deck.desc,
|
||||||
|
collapsed: deck.collapsed,
|
||||||
|
conf: deck.conf as Record<string, unknown>,
|
||||||
|
},
|
||||||
|
noteType: {
|
||||||
|
id: noteType.id,
|
||||||
|
name: noteType.name,
|
||||||
|
kind: noteType.kind,
|
||||||
|
css: noteType.css,
|
||||||
|
fields: (noteType.fields as { name: string; ord: number }[]) ?? [],
|
||||||
|
templates: (noteType.templates as { name: string; ord: number; qfmt: string; afmt: string }[]) ?? [],
|
||||||
|
},
|
||||||
|
notes: deck.cards.map((card) => ({
|
||||||
|
id: card.note.id,
|
||||||
|
guid: card.note.guid,
|
||||||
|
tags: card.note.tags,
|
||||||
|
flds: card.note.flds,
|
||||||
|
sfld: card.note.sfld,
|
||||||
|
csum: card.note.csum,
|
||||||
|
})),
|
||||||
|
cards: deck.cards.map((card) => ({
|
||||||
|
id: card.id,
|
||||||
|
noteId: card.noteId,
|
||||||
|
ord: card.ord,
|
||||||
|
type: card.type,
|
||||||
|
queue: card.queue,
|
||||||
|
due: card.due,
|
||||||
|
ivl: card.ivl,
|
||||||
|
factor: card.factor,
|
||||||
|
reps: card.reps,
|
||||||
|
lapses: card.lapses,
|
||||||
|
left: card.left,
|
||||||
|
})),
|
||||||
|
revlogs: revlogs.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
cardId: r.cardId,
|
||||||
|
ease: r.ease,
|
||||||
|
ivl: r.ivl,
|
||||||
|
lastIvl: r.lastIvl,
|
||||||
|
factor: r.factor,
|
||||||
|
time: r.time,
|
||||||
|
type: r.type,
|
||||||
|
})),
|
||||||
|
media: new Map(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const apkgBuffer = await exportApkg(exportData);
|
||||||
|
|
||||||
|
log.info("APKG exported successfully", {
|
||||||
|
userId: session.user.id,
|
||||||
|
deckId: deck.id,
|
||||||
|
cardCount: deck.cards.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const safeDeckName = deck.name.replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]/g, "_");
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Deck exported successfully",
|
||||||
|
data: apkgBuffer.buffer.slice(apkgBuffer.byteOffset, apkgBuffer.byteOffset + apkgBuffer.byteLength) as ArrayBuffer,
|
||||||
|
filename: `${safeDeckName}.apkg`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to export APKG", { error, deckId });
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : "Failed to export deck",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
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[];
|
|
||||||
};
|
|
||||||
@@ -1,527 +0,0 @@
|
|||||||
"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.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
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,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user