refactor: 完全重构为 Anki 兼容数据结构
- 用 Deck 替换 Folder - 用 Note + Card 替换 Pair (双向复习) - 添加 NoteType (卡片模板) - 添加 Revlog (复习历史) - 实现 SM-2 间隔重复算法 - 更新所有前端页面 - 添加数据库迁移
This commit is contained in:
@@ -46,6 +46,15 @@
|
||||
"unfavorite": "Aus Favoriten entfernen",
|
||||
"pleaseLogin": "Bitte melden Sie sich zuerst an"
|
||||
},
|
||||
"decks": {
|
||||
"title": "Decks",
|
||||
"noDecks": "Noch keine Decks",
|
||||
"deckName": "Deckname",
|
||||
"totalCards": "Gesamtkarten",
|
||||
"createdAt": "Erstellt am",
|
||||
"actions": "Aktionen",
|
||||
"view": "Anzeigen"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "Sie sind nicht der Besitzer dieses Ordners",
|
||||
"back": "Zurück",
|
||||
@@ -169,22 +178,46 @@
|
||||
"resetPasswordSuccessHint": "Ihr Passwort wurde erfolgreich zurückgesetzt. Sie können sich jetzt mit Ihrem neuen Passwort anmelden."
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
"selectFolder": "Wählen Sie einen Ordner",
|
||||
"noFolders": "Keine Ordner gefunden",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
"deck_selector": {
|
||||
"selectDeck": "Deck auswählen",
|
||||
"noDecks": "Keine Decks gefunden",
|
||||
"goToDecks": "Zu Decks",
|
||||
"noCards": "Keine Karten",
|
||||
"new": "Neu",
|
||||
"learning": "Lernen",
|
||||
"review": "Wiederholen",
|
||||
"due": "Fällig"
|
||||
},
|
||||
"memorize": {
|
||||
"answer": "Antwort",
|
||||
"next": "Weiter",
|
||||
"reverse": "Umkehren",
|
||||
"dictation": "Diktat",
|
||||
"noTextPairs": "Keine Textpaare verfügbar",
|
||||
"disorder": "Mischen",
|
||||
"previous": "Zurück"
|
||||
"review": {
|
||||
"loading": "Laden...",
|
||||
"backToDecks": "Zurück zu Decks",
|
||||
"allDone": "Fertig!",
|
||||
"allDoneDesc": "Alle fälligen Karten wurden wiederholt.",
|
||||
"reviewedCount": "{count} Karten wiederholt",
|
||||
"progress": "{current} / {total}",
|
||||
"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"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "Sie sind nicht berechtigt, auf diesen Ordner zuzugreifen"
|
||||
"unauthorized": "Sie sind nicht berechtigt, auf dieses Deck zuzugreifen"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
@@ -202,10 +235,10 @@
|
||||
"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ützt: JPG, PNG, WebP",
|
||||
"selectFolder": "Ordner auswählen",
|
||||
"chooseFolder": "Wählen Sie einen Ordner zum Speichern der extrahierten Paare",
|
||||
"noFolders": "Keine Ordner verfügbar. Bitte erstellen Sie zuerst einen Ordner.",
|
||||
"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)",
|
||||
@@ -216,14 +249,14 @@
|
||||
"word": "Wort",
|
||||
"definition": "Definition",
|
||||
"pairsCount": "{count} Paare extrahiert",
|
||||
"savePairs": "In Ordner speichern",
|
||||
"savePairs": "In Deck speichern",
|
||||
"saving": "Speichern...",
|
||||
"saved": "{count} Paare erfolgreich in {folder} gespeichert",
|
||||
"saved": "{count} Paare erfolgreich in {deck} gespeichert",
|
||||
"saveFailed": "Speichern fehlgeschlagen",
|
||||
"noImage": "Bitte laden Sie zuerst ein Bild hoch",
|
||||
"noFolder": "Bitte wählen Sie einen Ordner",
|
||||
"noDeck": "Bitte select a deck",
|
||||
"processingFailed": "OCR-Verarbeitung fehlgeschlagen",
|
||||
"tryAgain": "Bitte versuchen Sie es mit einem klareren Bild",
|
||||
"tryAgain": "Bitte try again with a clearer image",
|
||||
"detectedLanguages": "Erkannt: {source} → {target}"
|
||||
},
|
||||
"profile": {
|
||||
@@ -385,10 +418,10 @@
|
||||
"memberSince": "Mitglied seit",
|
||||
"logout": "Abmelden",
|
||||
"folders": {
|
||||
"title": "Ordner",
|
||||
"noFolders": "Noch keine Ordner",
|
||||
"folderName": "Ordnername",
|
||||
"totalPairs": "Gesamtpaare",
|
||||
"title": "Decks",
|
||||
"noFolders": "Noch keine Decks",
|
||||
"folderName": "Deckname",
|
||||
"totalPairs": "Gesamtkarten",
|
||||
"createdAt": "Erstellt am",
|
||||
"actions": "Aktionen",
|
||||
"view": "Anzeigen"
|
||||
|
||||
@@ -169,22 +169,46 @@
|
||||
"resetPasswordSuccessHint": "Your password has been reset successfully. You can now log in with your new password."
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
"selectFolder": "Select a folder",
|
||||
"noFolders": "No folders found",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
"deck_selector": {
|
||||
"selectDeck": "Select a deck",
|
||||
"noDecks": "No decks found",
|
||||
"goToDecks": "Go to Decks",
|
||||
"noCards": "No cards",
|
||||
"new": "New",
|
||||
"learning": "Learning",
|
||||
"review": "Review",
|
||||
"due": "Due"
|
||||
},
|
||||
"memorize": {
|
||||
"answer": "Answer",
|
||||
"next": "Next",
|
||||
"reverse": "Reverse",
|
||||
"dictation": "Dictation",
|
||||
"noTextPairs": "No text pairs available",
|
||||
"disorder": "Disorder",
|
||||
"previous": "Previous"
|
||||
"review": {
|
||||
"loading": "Loading cards...",
|
||||
"backToDecks": "Back to Decks",
|
||||
"allDone": "All Done!",
|
||||
"allDoneDesc": "You've reviewed all due cards.",
|
||||
"reviewedCount": "Reviewed {count} cards",
|
||||
"progress": "{current} / {total}",
|
||||
"nextReview": "Next review",
|
||||
"interval": "Interval",
|
||||
"ease": "Ease",
|
||||
"lapses": "Lapses",
|
||||
"showAnswer": "Show Answer",
|
||||
"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"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "You are not authorized to access this folder"
|
||||
"unauthorized": "You are not authorized to access this deck"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
@@ -200,31 +224,45 @@
|
||||
"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",
|
||||
"selectFolder": "Select Folder",
|
||||
"chooseFolder": "Choose a folder to save extracted pairs",
|
||||
"noFolders": "No folders available. Please create a folder first.",
|
||||
"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",
|
||||
"extractedPairs": "Extracted Pairs",
|
||||
"resultsPreview": "Results Preview",
|
||||
"extractedPairs": "Extracted {count} pairs",
|
||||
"word": "Word",
|
||||
"definition": "Definition",
|
||||
"pairsCount": "{count} pairs extracted",
|
||||
"savePairs": "Save to Folder",
|
||||
"savePairs": "Save to Deck",
|
||||
"saveButton": "Save",
|
||||
"saving": "Saving...",
|
||||
"saved": "Successfully saved {count} pairs to {folder}",
|
||||
"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",
|
||||
"noFolder": "Please select a folder",
|
||||
"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}"
|
||||
"detectedLanguages": "Detected: {source} → {target}",
|
||||
"detectedSourceLanguage": "Detected source language",
|
||||
"detectedTargetLanguage": "Detected target language"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "My Profile",
|
||||
@@ -338,11 +376,11 @@
|
||||
},
|
||||
"explore": {
|
||||
"title": "Explore",
|
||||
"subtitle": "Discover public folders",
|
||||
"searchPlaceholder": "Search public folders...",
|
||||
"subtitle": "Discover public decks",
|
||||
"searchPlaceholder": "Search public decks...",
|
||||
"loading": "Loading...",
|
||||
"noFolders": "No public folders found",
|
||||
"folderInfo": "{userName} • {totalPairs} pairs",
|
||||
"noDecks": "No public decks found",
|
||||
"deckInfo": "{userName} • {cardCount} cards",
|
||||
"unknownUser": "Unknown User",
|
||||
"favorite": "Favorite",
|
||||
"unfavorite": "Unfavorite",
|
||||
@@ -351,10 +389,10 @@
|
||||
"sortByFavoritesActive": "Undo sort by favorites"
|
||||
},
|
||||
"exploreDetail": {
|
||||
"title": "Folder Details",
|
||||
"title": "Deck Details",
|
||||
"createdBy": "Created by: {name}",
|
||||
"unknownUser": "Unknown User",
|
||||
"totalPairs": "Total Pairs",
|
||||
"totalCards": "Total Cards",
|
||||
"favorites": "Favorites",
|
||||
"createdAt": "Created At",
|
||||
"viewContent": "View Content",
|
||||
@@ -385,11 +423,11 @@
|
||||
"memberSince": "Member Since",
|
||||
"joined": "Joined",
|
||||
"logout": "Logout",
|
||||
"folders": {
|
||||
"title": "Folders",
|
||||
"noFolders": "No folders yet",
|
||||
"folderName": "Folder Name",
|
||||
"totalPairs": "Total Pairs",
|
||||
"decks": {
|
||||
"title": "Decks",
|
||||
"noDecks": "No decks yet",
|
||||
"deckName": "Deck Name",
|
||||
"totalCards": "Total Cards",
|
||||
"createdAt": "Created At",
|
||||
"actions": "Actions",
|
||||
"view": "View"
|
||||
|
||||
@@ -46,6 +46,15 @@
|
||||
"unfavorite": "Retirer des favoris",
|
||||
"pleaseLogin": "Veuillez vous connecter d'abord"
|
||||
},
|
||||
"decks": {
|
||||
"title": "Decks",
|
||||
"noDecks": "Pas encore de decks",
|
||||
"deckName": "Nom du deck",
|
||||
"totalCards": "Total des cartes",
|
||||
"createdAt": "Créé le",
|
||||
"actions": "Actions",
|
||||
"view": "Voir"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "Vous n'êtes pas le propriétaire de ce dossier",
|
||||
"back": "Retour",
|
||||
@@ -169,22 +178,46 @@
|
||||
"resetPasswordSuccessHint": "Votre mot de passe a été réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe."
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
"selectFolder": "Sélectionner un dossier",
|
||||
"noFolders": "Aucun dossier trouvé",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
"deck_selector": {
|
||||
"selectDeck": "Sélectionner un deck",
|
||||
"noDecks": "Aucun deck trouvé",
|
||||
"goToDecks": "Aller aux decks",
|
||||
"noCards": "Aucune carte",
|
||||
"new": "Nouveau",
|
||||
"learning": "Apprentissage",
|
||||
"review": "Révision",
|
||||
"due": "À faire"
|
||||
},
|
||||
"memorize": {
|
||||
"answer": "Réponse",
|
||||
"next": "Suivant",
|
||||
"reverse": "Inverser",
|
||||
"dictation": "Dictée",
|
||||
"noTextPairs": "Aucune paire de texte disponible",
|
||||
"disorder": "Désordre",
|
||||
"previous": "Précédent"
|
||||
"review": {
|
||||
"loading": "Chargement...",
|
||||
"backToDecks": "Retour aux decks",
|
||||
"allDone": "Terminé !",
|
||||
"allDoneDesc": "Vous avez révisé toutes les cartes dues.",
|
||||
"reviewedCount": "{count} cartes révisées",
|
||||
"progress": "{current} / {total}",
|
||||
"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"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "Vous n'êtes pas autorisé à accéder à ce dossier"
|
||||
"unauthorized": "Vous n'êtes pas autorisé à accéder à ce deck"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
@@ -203,9 +236,9 @@
|
||||
"uploadImage": "Télécharger une image",
|
||||
"dragDropHint": "Glissez-déposez une image ici, ou cliquez pour sélectionner",
|
||||
"supportedFormats": "Supportés : JPG, PNG, WebP",
|
||||
"selectFolder": "Sélectionner un dossier",
|
||||
"chooseFolder": "Choisissez un dossier pour sauvegarder les paires extraites",
|
||||
"noFolders": "Aucun dossier disponible. Veuillez d'abord créer un dossier.",
|
||||
"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)",
|
||||
@@ -216,14 +249,14 @@
|
||||
"word": "Mot",
|
||||
"definition": "Définition",
|
||||
"pairsCount": "{count} paires extraites",
|
||||
"savePairs": "Sauvegarder dans le dossier",
|
||||
"savePairs": "Sauvegarder dans le deck",
|
||||
"saving": "Sauvegarde...",
|
||||
"saved": "{count} paires sauvegardées dans {folder}",
|
||||
"saved": "{count} paires sauvegardées dans {deck}",
|
||||
"saveFailed": "Échec de la sauvegarde",
|
||||
"noImage": "Veuillez d'abord télécharger une image",
|
||||
"noFolder": "Veuillez sélectionner un dossier",
|
||||
"noImage": "Veuillez first upload an image",
|
||||
"noDeck": "Please select a deck",
|
||||
"processingFailed": "Échec du traitement OCR",
|
||||
"tryAgain": "Veuillez réessayer avec une image plus claire",
|
||||
"tryAgain": "Please try again with a clearer image",
|
||||
"detectedLanguages": "Détecté : {source} → {target}"
|
||||
},
|
||||
"profile": {
|
||||
@@ -384,11 +417,11 @@
|
||||
"notSet": "Non défini",
|
||||
"memberSince": "Membre depuis",
|
||||
"logout": "Déconnexion",
|
||||
"folders": {
|
||||
"title": "Dossiers",
|
||||
"noFolders": "Pas encore de dossiers",
|
||||
"folderName": "Nom du dossier",
|
||||
"totalPairs": "Total des paires",
|
||||
"decks": {
|
||||
"title": "Decks",
|
||||
"noDecks": "Pas encore de decks",
|
||||
"deckName": "Nom du deck",
|
||||
"totalCards": "Total des cartes",
|
||||
"createdAt": "Créé le",
|
||||
"actions": "Actions",
|
||||
"view": "Voir"
|
||||
|
||||
@@ -46,6 +46,15 @@
|
||||
"unfavorite": "Rimuovi dai preferiti",
|
||||
"pleaseLogin": "Per favore accedi prima"
|
||||
},
|
||||
"decks": {
|
||||
"title": "Mazzi",
|
||||
"noDecks": "Nessun mazzo ancora",
|
||||
"deckName": "Nome del mazzo",
|
||||
"totalCards": "Totale carte",
|
||||
"createdAt": "Creato il",
|
||||
"actions": "Azioni",
|
||||
"view": "Visualizza"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "Non sei il proprietario di questa cartella",
|
||||
"back": "Indietro",
|
||||
@@ -169,22 +178,46 @@
|
||||
"resetPasswordSuccessHint": "La tua password è stata reimpostata con successo. Ora puoi accedere con la tua nuova password."
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
"selectFolder": "Seleziona una cartella",
|
||||
"noFolders": "Nessuna cartella trovata",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
"deck_selector": {
|
||||
"selectDeck": "Seleziona un mazzo",
|
||||
"noDecks": "Nessun mazzo trovato",
|
||||
"goToDecks": "Vai ai mazzi",
|
||||
"noCards": "Nessuna carta",
|
||||
"new": "Nuove",
|
||||
"learning": "In apprendimento",
|
||||
"review": "Ripasso",
|
||||
"due": "In scadenza"
|
||||
},
|
||||
"memorize": {
|
||||
"answer": "Risposta",
|
||||
"next": "Successivo",
|
||||
"reverse": "Inverti",
|
||||
"dictation": "Dettatura",
|
||||
"noTextPairs": "Nessuna coppia di testo disponibile",
|
||||
"disorder": "Disordina",
|
||||
"previous": "Precedente"
|
||||
"review": {
|
||||
"loading": "Caricamento...",
|
||||
"backToDecks": "Torna ai mazzi",
|
||||
"allDone": "Fatto!",
|
||||
"allDoneDesc": "Hai ripassato tutte le carte in scadenza.",
|
||||
"reviewedCount": "{count} carte ripassate",
|
||||
"progress": "{current} / {total}",
|
||||
"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"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "Non sei autorizzato ad accedere a questa cartella"
|
||||
"unauthorized": "Non sei autorizzato ad accedere a questo mazzo"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
@@ -203,9 +236,9 @@
|
||||
"uploadImage": "Carica immagine",
|
||||
"dragDropHint": "Trascina e rilascia un'immagine qui, o clicca per selezionare",
|
||||
"supportedFormats": "Supportati: JPG, PNG, WebP",
|
||||
"selectFolder": "Seleziona cartella",
|
||||
"chooseFolder": "Scegli una cartella per salvare le coppie estratte",
|
||||
"noFolders": "Nessuna cartella disponibile. Crea prima una cartella.",
|
||||
"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)",
|
||||
@@ -216,12 +249,12 @@
|
||||
"word": "Parola",
|
||||
"definition": "Definizione",
|
||||
"pairsCount": "{count} coppie estratte",
|
||||
"savePairs": "Salva nella cartella",
|
||||
"savePairs": "Salva nel mazzo",
|
||||
"saving": "Salvataggio...",
|
||||
"saved": "{count} coppie salvate in {folder}",
|
||||
"saved": "{count} coppie salvate in {deck}",
|
||||
"saveFailed": "Salvataggio fallito",
|
||||
"noImage": "Carica prima un'immagine",
|
||||
"noFolder": "Seleziona una cartella",
|
||||
"noDeck": "Seleziona un mazzo",
|
||||
"processingFailed": "Elaborazione OCR fallita",
|
||||
"tryAgain": "Riprova con un'immagine più chiara",
|
||||
"detectedLanguages": "Rilevato: {source} → {target}"
|
||||
@@ -384,11 +417,11 @@
|
||||
"notSet": "Non Impostato",
|
||||
"memberSince": "Membro Dal",
|
||||
"logout": "Esci",
|
||||
"folders": {
|
||||
"title": "Cartelle",
|
||||
"noFolders": "Nessuna cartella ancora",
|
||||
"folderName": "Nome Cartella",
|
||||
"totalPairs": "Coppie Totali",
|
||||
"decks": {
|
||||
"title": "Mazzi",
|
||||
"noDecks": "Nessun mazzo ancora",
|
||||
"deckName": "Nome Mazzo",
|
||||
"totalCards": "Carte Totali",
|
||||
"createdAt": "Creata Il",
|
||||
"actions": "Azioni",
|
||||
"view": "Visualizza"
|
||||
|
||||
@@ -169,22 +169,46 @@
|
||||
"resetPasswordSuccessHint": "パスワードが正常にリセットされました。新しいパスワードでログインできます。"
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
"selectFolder": "フォルダーを選択",
|
||||
"noFolders": "フォルダーが見つかりません",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
"deck_selector": {
|
||||
"selectDeck": "デッキを選択",
|
||||
"noDecks": "デッキが見つかりません",
|
||||
"goToDecks": "デッキへ移動",
|
||||
"noCards": "カードなし",
|
||||
"new": "新規",
|
||||
"learning": "学習中",
|
||||
"review": "復習",
|
||||
"due": "予定"
|
||||
},
|
||||
"memorize": {
|
||||
"answer": "答え",
|
||||
"next": "次へ",
|
||||
"reverse": "逆順",
|
||||
"dictation": "書き取り",
|
||||
"noTextPairs": "利用可能なテキストペアがありません",
|
||||
"disorder": "シャッフル",
|
||||
"previous": "前へ"
|
||||
"review": {
|
||||
"loading": "読み込み中...",
|
||||
"backToDecks": "デッキに戻る",
|
||||
"allDone": "完了!",
|
||||
"allDoneDesc": "すべての復習カードが完了しました。",
|
||||
"reviewedCount": "{count} 枚のカードを復習",
|
||||
"progress": "{current} / {total}",
|
||||
"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": "日"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "このフォルダーにアクセスする権限がありません"
|
||||
"unauthorized": "このデッキにアクセスする権限がありません"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
@@ -203,9 +227,9 @@
|
||||
"uploadImage": "画像をアップロード",
|
||||
"dragDropHint": "ここに画像をドラッグ&ドロップ、またはクリックして選択",
|
||||
"supportedFormats": "対応形式:JPG、PNG、WebP",
|
||||
"selectFolder": "フォルダを選択",
|
||||
"chooseFolder": "抽出したペアを保存するフォルダを選択",
|
||||
"noFolders": "フォルダがありません。まずフォルダを作成してください。",
|
||||
"selectDeck": "デッキを選択",
|
||||
"chooseDeck": "抽出したペアを保存するデッキを選択",
|
||||
"noDecks": "デッキがありません。まずデッキを作成してください。",
|
||||
"languageHints": "言語ヒント(オプション)",
|
||||
"sourceLanguageHint": "ソース言語(例:英語)",
|
||||
"targetLanguageHint": "ターゲット/翻訳言語(例:中国語)",
|
||||
@@ -216,12 +240,12 @@
|
||||
"word": "単語",
|
||||
"definition": "定義",
|
||||
"pairsCount": "{count} ペアを抽出",
|
||||
"savePairs": "フォルダに保存",
|
||||
"savePairs": "デッキに保存",
|
||||
"saving": "保存中...",
|
||||
"saved": "{count} ペアを {folder} に保存しました",
|
||||
"saved": "{count} ペアを {deck} に保存しました",
|
||||
"saveFailed": "保存に失敗しました",
|
||||
"noImage": "先に画像をアップロードしてください",
|
||||
"noFolder": "フォルダを選択してください",
|
||||
"noDeck": "デッキを選択してください",
|
||||
"processingFailed": "OCR処理に失敗しました",
|
||||
"tryAgain": "より鮮明な画像でお試しください",
|
||||
"detectedLanguages": "検出:{source} → {target}"
|
||||
@@ -384,11 +408,11 @@
|
||||
"notSet": "未設定",
|
||||
"memberSince": "登録日",
|
||||
"logout": "ログアウト",
|
||||
"folders": {
|
||||
"title": "フォルダー",
|
||||
"noFolders": "まだフォルダーがありません",
|
||||
"folderName": "フォルダー名",
|
||||
"totalPairs": "合計ペア数",
|
||||
"decks": {
|
||||
"title": "デッキ",
|
||||
"noDecks": "まだデッキがありません",
|
||||
"deckName": "デッキ名",
|
||||
"totalCards": "合計カード数",
|
||||
"createdAt": "作成日",
|
||||
"actions": "アクション",
|
||||
"view": "表示"
|
||||
|
||||
@@ -46,6 +46,15 @@
|
||||
"unfavorite": "즐겨찾기 해제",
|
||||
"pleaseLogin": "먼저 로그인해주세요"
|
||||
},
|
||||
"decks": {
|
||||
"title": "덱",
|
||||
"noDecks": "덱이 없습니다",
|
||||
"deckName": "덱 이름",
|
||||
"totalCards": "총 카드",
|
||||
"createdAt": "생성일",
|
||||
"actions": "작업",
|
||||
"view": "보기"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "이 폴더의 소유자가 아닙니다",
|
||||
"back": "뒤로",
|
||||
@@ -169,22 +178,46 @@
|
||||
"resetPasswordSuccessHint": "비밀번호가 성공적으로 재설정되었습니다. 새 비밀번호로 로그인할 수 있습니다."
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
"selectFolder": "폴더 선택",
|
||||
"noFolders": "폴더를 찾을 수 없습니다",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
"deck_selector": {
|
||||
"selectDeck": "덱 선택",
|
||||
"noDecks": "덱을 찾을 수 없습니다",
|
||||
"goToDecks": "덱으로 이동",
|
||||
"noCards": "카드 없음",
|
||||
"new": "새 카드",
|
||||
"learning": "학습 중",
|
||||
"review": "복습",
|
||||
"due": "예정"
|
||||
},
|
||||
"memorize": {
|
||||
"answer": "정답",
|
||||
"next": "다음",
|
||||
"reverse": "반대",
|
||||
"dictation": "받아쓰기",
|
||||
"noTextPairs": "사용 가능한 텍스트 쌍이 없습니다",
|
||||
"disorder": "무작위",
|
||||
"previous": "이전"
|
||||
"review": {
|
||||
"loading": "로딩 중...",
|
||||
"backToDecks": "덱으로 돌아가기",
|
||||
"allDone": "완료!",
|
||||
"allDoneDesc": "모든 복습 카드를 완료했습니다.",
|
||||
"reviewedCount": "{count}장의 카드 복습함",
|
||||
"progress": "{current} / {total}",
|
||||
"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": "일"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "이 폴더에 접근할 권한이 없습니다"
|
||||
"unauthorized": "이 덱에 접근할 권한이 없습니다"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
@@ -199,31 +232,31 @@
|
||||
},
|
||||
"ocr": {
|
||||
"title": "OCR 어휘 추출",
|
||||
"description": "교과서 어휘표 스크린샷을 업로드하여 단어-정의 쌍 추출",
|
||||
"description": "교과서 어휘표 스크린샷 only어업로드하여 단어-정의 쌍 추출",
|
||||
"uploadImage": "이미지 업로드",
|
||||
"dragDropHint": "이미지를 여기에 끌어다 놓거나 클릭하여 선택",
|
||||
"supportedFormats": "지원 형식: JPG, PNG, WebP",
|
||||
"selectFolder": "폴더 선택",
|
||||
"chooseFolder": "추출된 쌍을 저장할 폴더 선택",
|
||||
"noFolders": "폴더가 없습니다. 먼저 폴더를 만드세요.",
|
||||
"selectDeck": "덱 선택",
|
||||
"chooseDeck": "추출된 쌍을 저장할 덱 선택",
|
||||
"noDecks": "덱이 없습니다. 먼저 덱을 만드세요.",
|
||||
"languageHints": "언어 힌트 (선택사항)",
|
||||
"sourceLanguageHint": "소스 언어 (예: 영어)",
|
||||
"targetLanguageHint": "대상/번역 언어 (예: 중국어)",
|
||||
"process": "이미지 처리",
|
||||
"processing": "처리 중...",
|
||||
"processing": "처리中...",
|
||||
"preview": "미리보기",
|
||||
"extractedPairs": "추출된 쌍",
|
||||
"word": "단어",
|
||||
"definition": "정의",
|
||||
"pairsCount": "{count} 쌍 추출됨",
|
||||
"savePairs": "폴더에 저장",
|
||||
"saving": "저장 중...",
|
||||
"saved": "{folder}에 {count} 쌍 저장 완료",
|
||||
"savePairs": "덱에 저장",
|
||||
"saving": "저장中...",
|
||||
"saved": "{deck}에 {count} 쌍 저장 완료",
|
||||
"saveFailed": "저장 실패",
|
||||
"noImage": "먼저 이미지를 업로드하세요",
|
||||
"noFolder": "폴더를 선택하세요",
|
||||
"noDeck": "덱을 선택하세요",
|
||||
"processingFailed": "OCR 처리 실패",
|
||||
"tryAgain": "더 선명한 이미지로 다시 시도하세요",
|
||||
"tryAgain": "더 선晰的图像로 다시 시도하세요",
|
||||
"detectedLanguages": "감지됨: {source} → {target}"
|
||||
},
|
||||
"profile": {
|
||||
@@ -385,10 +418,10 @@
|
||||
"memberSince": "가입일",
|
||||
"logout": "로그아웃",
|
||||
"folders": {
|
||||
"title": "폴더",
|
||||
"noFolders": "아직 폴더가 없습니다",
|
||||
"folderName": "폴더 이름",
|
||||
"totalPairs": "총 쌍",
|
||||
"title": "덱",
|
||||
"noFolders": "아직 덱이 없습니다",
|
||||
"folderName": "덱 이름",
|
||||
"totalPairs": "총 카드 수",
|
||||
"createdAt": "생성일",
|
||||
"actions": "작업",
|
||||
"view": "보기"
|
||||
|
||||
@@ -46,6 +46,15 @@
|
||||
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
|
||||
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ"
|
||||
},
|
||||
"decks": {
|
||||
"title": "دېكلار",
|
||||
"noDecks": "تېخى دېك يوق",
|
||||
"deckName": "دېك ئاتى",
|
||||
"totalCards": "جەمئىي كارتا",
|
||||
"createdAt": "قۇرۇلغان ۋاقتى",
|
||||
"actions": "مەشغۇلاتلار",
|
||||
"view": "كۆرۈش"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "بۇ قىسقۇچنىڭ ئىگىسى ئەمەسسىز",
|
||||
"back": "قايتىش",
|
||||
@@ -169,22 +178,46 @@
|
||||
"resetPasswordSuccessHint": "پارولىڭىز مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى. يېڭى پارول بىلەن كىرسىڭىز بولىدۇ."
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
"selectFolder": "بىر قىسقۇچ تاللاڭ",
|
||||
"noFolders": "قىسقۇچ تېپىلمىدى",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
"deck_selector": {
|
||||
"selectDeck": "بىر دېك تاللاڭ",
|
||||
"noDecks": "دېك تېپىلمىدى",
|
||||
"goToDecks": "دېكلارغا بېرىڭ",
|
||||
"noCards": "كارتا يوق",
|
||||
"new": "يېڭى",
|
||||
"learning": "ئۆگىنىۋاتىدۇ",
|
||||
"review": "تەكرار",
|
||||
"due": "ۋاقتى كەلدى"
|
||||
},
|
||||
"memorize": {
|
||||
"answer": "جاۋاب",
|
||||
"next": "كېيىنكى",
|
||||
"reverse": "تەتۈر",
|
||||
"dictation": "دىكتات",
|
||||
"noTextPairs": "تېكىست جۈپى يوق",
|
||||
"disorder": "قالايمىقانلاشتۇرۇش",
|
||||
"previous": "ئالدىنقى"
|
||||
"review": {
|
||||
"loading": "يۈكلىنىۋاتىدۇ...",
|
||||
"backToDecks": "دېكلارغا قايتىڭ",
|
||||
"allDone": "تامام!",
|
||||
"allDoneDesc": "بارلىق تەكرارلاش كارتلىرى تاماملاندى.",
|
||||
"reviewedCount": "{count} كارتا تەكرارلاندى",
|
||||
"progress": "{current} / {total}",
|
||||
"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": "ك"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "بۇ قىسقۇچنى زىيارەت قىلىش ھوقۇقىڭىز يوق"
|
||||
"unauthorized": "بۇ دېكنى زىيارەت قىلىش ھوقۇقىڭىز يوق"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
@@ -203,9 +236,9 @@
|
||||
"uploadImage": "سۈرەت يۈكلەش",
|
||||
"dragDropHint": "سۈرەتنى بۇ يەرگە سۆرەڭ ياكى چېكىپ تاللاڭ",
|
||||
"supportedFormats": "قوللايدىغان فورماتلار: JPG، PNG، WebP",
|
||||
"selectFolder": "قىسقۇچ تاللاش",
|
||||
"chooseFolder": "ئاستىرىلغان جۈپلەرنى ساقلاش ئۈچۈن قىسقۇچ تاللاڭ",
|
||||
"noFolders": "قىسقۇچ يوق. ئاۋۋال قىسقۇچ قۇرۇڭ.",
|
||||
"selectDeck": "دېك تاللاش",
|
||||
"chooseDeck": "ئاستىرىلغان جۈپلەرنى ساقلاش ئۈچۈن دېك تاللاڭ",
|
||||
"noDecks": "دېك يوق. ئاۋۋال دېك قۇرۇڭ.",
|
||||
"languageHints": "تىل ئۇچۇرلىرى (ئىختىيارىي)",
|
||||
"sourceLanguageHint": "مەنبە تىلى (مىسال: ئىنگىلىزچە)",
|
||||
"targetLanguageHint": "نىشان/تەرجىمە تىلى (مىسال: خەنزۇچە)",
|
||||
@@ -216,12 +249,12 @@
|
||||
"word": "سۆز",
|
||||
"definition": "مەنا",
|
||||
"pairsCount": "{count} جۈپ ئاستىرىلدى",
|
||||
"savePairs": "قىسقۇچقا ساقلاش",
|
||||
"savePairs": "دېككە ساقلاش",
|
||||
"saving": "ساقلاۋاتىدۇ...",
|
||||
"saved": "{folder} غا {count} جۈپ ساقلاندى",
|
||||
"saved": "{deck} غا {count} جۈپ ساقلاندى",
|
||||
"saveFailed": "ساقلاش مەغلۇپ بولدى",
|
||||
"noImage": "ئاۋۋال سۈرەت يۈكلەڭ",
|
||||
"noFolder": "قىسقۇچ تاللاڭ",
|
||||
"noDeck": "دېك تاللاڭ",
|
||||
"processingFailed": "OCR بىر تەرەپ قىلىش مەغلۇپ بولدى",
|
||||
"tryAgain": "تېخىمۇ ئېنىق سۈرەت بىلەن قايتا سىناڭ",
|
||||
"detectedLanguages": "بايقالدى: {source} → {target}"
|
||||
@@ -384,11 +417,11 @@
|
||||
"notSet": "تەڭشەلمىگەن",
|
||||
"memberSince": "ئەزا بولغاندىن بېرى",
|
||||
"logout": "چىكىنىش",
|
||||
"folders": {
|
||||
"title": "قىسقۇچلار",
|
||||
"noFolders": "تېخى قىسقۇچ يوق",
|
||||
"folderName": "قىسقۇچ ئاتى",
|
||||
"totalPairs": "جەمئىي جۈپ",
|
||||
"decks": {
|
||||
"title": "دېكلار",
|
||||
"noDecks": "تېخى دېك يوق",
|
||||
"deckName": "دېك ئاتى",
|
||||
"totalCards": "جەمئىي كارتا",
|
||||
"createdAt": "قۇرۇلغان ۋاقتى",
|
||||
"actions": "مەشغۇلاتلار",
|
||||
"view": "كۆرۈش"
|
||||
|
||||
@@ -169,22 +169,46 @@
|
||||
"resetPasswordSuccessHint": "您的密码已成功重置,现在可以使用新密码登录了。"
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
"selectFolder": "选择文件夹",
|
||||
"noFolders": "未找到文件夹",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
"deck_selector": {
|
||||
"selectDeck": "选择牌组",
|
||||
"noDecks": "未找到牌组",
|
||||
"goToDecks": "前往牌组",
|
||||
"noCards": "无卡片",
|
||||
"new": "新卡片",
|
||||
"learning": "学习中",
|
||||
"review": "复习",
|
||||
"due": "待复习"
|
||||
},
|
||||
"memorize": {
|
||||
"answer": "答案",
|
||||
"next": "下一个",
|
||||
"reverse": "反向",
|
||||
"dictation": "听写",
|
||||
"noTextPairs": "没有可用的文本对",
|
||||
"disorder": "乱序",
|
||||
"previous": "上一个"
|
||||
"review": {
|
||||
"loading": "加载中...",
|
||||
"backToDecks": "返回牌组",
|
||||
"allDone": "全部完成!",
|
||||
"allDoneDesc": "您已完成所有待复习卡片。",
|
||||
"reviewedCount": "已复习 {count} 张卡片",
|
||||
"progress": "{current} / {total}",
|
||||
"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": "天"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "您无权访问该文件夹"
|
||||
"unauthorized": "您无权访问该牌组"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
@@ -200,31 +224,45 @@
|
||||
"ocr": {
|
||||
"title": "OCR 词汇提取",
|
||||
"description": "上传教材词汇表截图,提取单词-释义对",
|
||||
"uploadSection": "上传图片",
|
||||
"uploadImage": "上传图片",
|
||||
"dragDropHint": "拖放图片到此处,或点击选择",
|
||||
"dropOrClick": "拖放图片到此处,或点击选择",
|
||||
"changeImage": "点击更换图片",
|
||||
"supportedFormats": "支持格式:JPG、PNG、WebP",
|
||||
"selectFolder": "选择文件夹",
|
||||
"chooseFolder": "选择保存提取词汇的文件夹",
|
||||
"noFolders": "暂无文件夹,请先创建文件夹",
|
||||
"deckSelection": "选择牌组",
|
||||
"selectDeck": "选择牌组",
|
||||
"chooseDeck": "选择保存提取词汇的牌组",
|
||||
"noDecks": "暂无牌组,请先创建牌组",
|
||||
"languageHints": "语言提示(可选)",
|
||||
"sourceLanguageHint": "源语言(如:英语)",
|
||||
"targetLanguageHint": "目标/翻译语言(如:中文)",
|
||||
"sourceLanguagePlaceholder": "源语言(如:英语)",
|
||||
"targetLanguagePlaceholder": "目标/翻译语言(如:中文)",
|
||||
"process": "处理图片",
|
||||
"processButton": "处理图片",
|
||||
"processing": "处理中...",
|
||||
"preview": "预览",
|
||||
"extractedPairs": "提取的词汇对",
|
||||
"resultsPreview": "结果预览",
|
||||
"extractedPairs": "已提取 {count} 个词汇对",
|
||||
"word": "单词",
|
||||
"definition": "释义",
|
||||
"pairsCount": "已提取 {count} 个词汇对",
|
||||
"savePairs": "保存到文件夹",
|
||||
"pairsCount": "{count} 个词汇对",
|
||||
"savePairs": "保存到牌组",
|
||||
"saveButton": "保存",
|
||||
"saving": "保存中...",
|
||||
"saved": "成功将 {count} 个词汇对保存到 {folder}",
|
||||
"saved": "成功将 {count} 个词汇对保存到 {deck}",
|
||||
"ocrSuccess": "成功将 {count} 个词汇对保存到 {deck}",
|
||||
"savedToDeck": "已保存到 {deckName}",
|
||||
"saveFailed": "保存失败",
|
||||
"noImage": "请先上传图片",
|
||||
"noFolder": "请选择文件夹",
|
||||
"noDeck": "请选择牌组",
|
||||
"noResultsToSave": "没有可保存的结果",
|
||||
"processingFailed": "OCR 处理失败",
|
||||
"tryAgain": "请尝试上传更清晰的图片",
|
||||
"detectedLanguages": "检测到:{source} → {target}"
|
||||
"detectedLanguages": "检测到:{source} → {target}",
|
||||
"detectedSourceLanguage": "检测到的源语言",
|
||||
"detectedTargetLanguage": "检测到的目标语言"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "我的个人资料",
|
||||
@@ -338,11 +376,11 @@
|
||||
},
|
||||
"explore": {
|
||||
"title": "探索",
|
||||
"subtitle": "发现公开文件夹",
|
||||
"searchPlaceholder": "搜索公开文件夹...",
|
||||
"subtitle": "发现公开牌组",
|
||||
"searchPlaceholder": "搜索公开牌组...",
|
||||
"loading": "加载中...",
|
||||
"noFolders": "没有找到公开文件夹",
|
||||
"folderInfo": "{userName} • {totalPairs} 个文本对",
|
||||
"noDecks": "没有找到公开牌组",
|
||||
"deckInfo": "{userName} • {cardCount} 张卡片",
|
||||
"unknownUser": "未知用户",
|
||||
"favorite": "收藏",
|
||||
"unfavorite": "取消收藏",
|
||||
@@ -351,10 +389,10 @@
|
||||
"sortByFavoritesActive": "取消按收藏数排序"
|
||||
},
|
||||
"exploreDetail": {
|
||||
"title": "文件夹详情",
|
||||
"title": "牌组详情",
|
||||
"createdBy": "创建者:{name}",
|
||||
"unknownUser": "未知用户",
|
||||
"totalPairs": "词对数量",
|
||||
"totalCards": "卡片数量",
|
||||
"favorites": "收藏数",
|
||||
"createdAt": "创建时间",
|
||||
"viewContent": "查看内容",
|
||||
@@ -385,11 +423,11 @@
|
||||
"memberSince": "注册时间",
|
||||
"joined": "加入于",
|
||||
"logout": "登出",
|
||||
"folders": {
|
||||
"title": "文件夹",
|
||||
"noFolders": "还没有文件夹",
|
||||
"folderName": "文件夹名称",
|
||||
"totalPairs": "文本对数量",
|
||||
"decks": {
|
||||
"title": "牌组",
|
||||
"noDecks": "还没有牌组",
|
||||
"deckName": "牌组名称",
|
||||
"totalCards": "卡片数量",
|
||||
"createdAt": "创建时间",
|
||||
"actions": "操作",
|
||||
"view": "查看"
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// User & Auth
|
||||
// ============================================
|
||||
|
||||
model User {
|
||||
id String @id
|
||||
name String
|
||||
@@ -20,8 +24,11 @@ model User {
|
||||
bio String?
|
||||
accounts Account[]
|
||||
dictionaryLookUps DictionaryLookUp[]
|
||||
folders Folder[]
|
||||
folderFavorites FolderFavorite[]
|
||||
// Anki-compatible relations
|
||||
decks Deck[]
|
||||
deckFavorites DeckFavorite[]
|
||||
noteTypes NoteType[]
|
||||
notes Note[]
|
||||
sessions Session[]
|
||||
translationHistories TranslationHistory[]
|
||||
followers Follow[] @relation("UserFollowers")
|
||||
@@ -77,60 +84,175 @@ model Verification {
|
||||
@@map("verification")
|
||||
}
|
||||
|
||||
model Pair {
|
||||
id Int @id @default(autoincrement())
|
||||
language1 String
|
||||
language2 String
|
||||
text1 String
|
||||
text2 String
|
||||
ipa1 String?
|
||||
ipa2 String?
|
||||
folderId Int @map("folder_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
|
||||
// ============================================
|
||||
// Anki-compatible Models
|
||||
// ============================================
|
||||
|
||||
@@unique([folderId, language1, language2, text1, text2])
|
||||
@@index([folderId])
|
||||
@@map("pairs")
|
||||
/// Card type: 0=new, 1=learning, 2=review, 3=relearning
|
||||
enum CardType {
|
||||
NEW
|
||||
LEARNING
|
||||
REVIEW
|
||||
RELEARNING
|
||||
}
|
||||
|
||||
/// Card queue: -3=user buried, -2=sched buried, -1=suspended, 0=new, 1=learning, 2=review, 3=in learning, 4=preview
|
||||
enum CardQueue {
|
||||
USER_BURIED
|
||||
SCHED_BURIED
|
||||
SUSPENDED
|
||||
NEW
|
||||
LEARNING
|
||||
REVIEW
|
||||
IN_LEARNING
|
||||
PREVIEW
|
||||
}
|
||||
|
||||
/// Note type: 0=standard, 1=cloze
|
||||
enum NoteKind {
|
||||
STANDARD
|
||||
CLOZE
|
||||
}
|
||||
|
||||
/// Deck visibility (our extension, not in Anki)
|
||||
enum Visibility {
|
||||
PRIVATE
|
||||
PUBLIC
|
||||
}
|
||||
|
||||
model Folder {
|
||||
/// NoteType (Anki: models) - Defines fields and templates for notes
|
||||
model NoteType {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
kind NoteKind @default(STANDARD)
|
||||
css String @default("")
|
||||
fields Json @default("[]")
|
||||
templates Json @default("[]")
|
||||
userId String @map("user_id")
|
||||
visibility Visibility @default(PRIVATE)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
pairs Pair[]
|
||||
favorites FolderFavorite[]
|
||||
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([visibility])
|
||||
@@map("folders")
|
||||
@@map("decks")
|
||||
}
|
||||
|
||||
model FolderFavorite {
|
||||
/// DeckFavorite - Users can favorite public decks
|
||||
model DeckFavorite {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String @map("user_id")
|
||||
folderId Int @map("folder_id")
|
||||
deckId Int @map("deck_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
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([folderId])
|
||||
@@map("folder_favorites")
|
||||
@@index([deckId])
|
||||
@@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 {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String? @map("user_id")
|
||||
@@ -140,8 +262,8 @@ model DictionaryLookUp {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
dictionaryItemId Int? @map("dictionary_item_id")
|
||||
normalizedText String @default("") @map("normalized_text")
|
||||
dictionaryItem DictionaryItem? @relation(fields: [dictionaryItemId], references: [id])
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
dictionaryItem DictionaryItem? @relation(fields: [dictionaryItemId], references: [id], onDelete: SetNull)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId])
|
||||
@@index([createdAt])
|
||||
|
||||
@@ -3,7 +3,7 @@ import Link from "next/link";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { LinkButton } from "@/design-system/base/button";
|
||||
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
|
||||
import { 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 { getTranslations } from "next-intl/server";
|
||||
@@ -29,8 +29,8 @@ export default async function UserPage({ params }: UserPageProps) {
|
||||
|
||||
const user = result.data;
|
||||
|
||||
const [folders, followStatus] = await Promise.all([
|
||||
repoGetFoldersWithTotalPairsByUserId(user.id),
|
||||
const [decks, followStatus] = await Promise.all([
|
||||
repoGetDecksByUserId({ userId: user.id }),
|
||||
actionGetFollowStatus({ targetUserId: user.id }),
|
||||
]);
|
||||
|
||||
@@ -137,45 +137,45 @@ export default async function UserPage({ params }: UserPageProps) {
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("folders.title")}</h2>
|
||||
{folders.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">{t("folders.noFolders")}</p>
|
||||
<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("folders.folderName")}
|
||||
{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("folders.totalPairs")}
|
||||
{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("folders.createdAt")}
|
||||
{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("folders.actions")}
|
||||
{t("decks.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">
|
||||
{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">{folder.name}</div>
|
||||
<div className="text-sm text-gray-500">ID: {folder.id}</div>
|
||||
<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">{folder.total}</div>
|
||||
<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(folder.createdAt).toLocaleDateString()}
|
||||
{new Date(deck.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Link href={`/folders/${folder.id}`}>
|
||||
<Link href={`/decks/${deck.id}`}>
|
||||
<LinkButton>
|
||||
{t("folders.view")}
|
||||
{t("decks.view")}
|
||||
</LinkButton>
|
||||
</Link>
|
||||
</td>
|
||||
|
||||
@@ -11,15 +11,18 @@ import { Plus, RefreshCw } from "lucide-react";
|
||||
import { DictionaryEntry } from "./DictionaryEntry";
|
||||
import { LanguageSelector } from "./LanguageSelector";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { actionGetFoldersByUserId, actionCreatePair } from "@/modules/folder/folder-action";
|
||||
import { TSharedFolder } from "@/shared/folder-type";
|
||||
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
|
||||
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";
|
||||
|
||||
interface DictionaryClientProps {
|
||||
initialFolders: TSharedFolder[];
|
||||
initialDecks: TSharedDeck[];
|
||||
}
|
||||
|
||||
export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
|
||||
export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
|
||||
const t = useTranslations("dictionary");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -39,7 +42,9 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
|
||||
} = useDictionaryStore();
|
||||
|
||||
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(() => {
|
||||
const q = searchParams.get("q") || undefined;
|
||||
@@ -55,9 +60,31 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user?.id) {
|
||||
actionGetFoldersByUserId(session.user.id).then((result) => {
|
||||
actionGetDecksByUserId(session.user.id).then((result) => {
|
||||
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 () => {
|
||||
if (!session) {
|
||||
toast.error("Please login first");
|
||||
toast.error(t("pleaseLogin"));
|
||||
return;
|
||||
}
|
||||
if (folders.length === 0) {
|
||||
toast.error("Please create a folder first");
|
||||
if (decks.length === 0) {
|
||||
toast.error(t("pleaseCreateFolder"));
|
||||
return;
|
||||
}
|
||||
if (!defaultNoteTypeId) {
|
||||
toast.error("No note type available. Please try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
const folderSelect = document.getElementById("folder-select") as HTMLSelectElement;
|
||||
const folderId = folderSelect?.value ? Number(folderSelect.value) : folders[0]?.id;
|
||||
|
||||
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
|
||||
.map((e) => e.definition)
|
||||
.join(" | ");
|
||||
|
||||
const ipa = searchResult.entries[0]?.ipa || "";
|
||||
const example = searchResult.entries
|
||||
.map((e) => e.example)
|
||||
.filter(Boolean)
|
||||
.join(" | ") || "";
|
||||
|
||||
try {
|
||||
await actionCreatePair({
|
||||
text1: searchResult.standardForm,
|
||||
text2: definition,
|
||||
language1: queryLang,
|
||||
language2: definitionLang,
|
||||
ipa1: searchResult.entries[0]?.ipa,
|
||||
folderId: folderId,
|
||||
const noteResult = await actionCreateNote({
|
||||
noteTypeId: defaultNoteTypeId,
|
||||
fields: [searchResult.standardForm, definition, ipa, example],
|
||||
tags: ["dictionary"],
|
||||
});
|
||||
|
||||
const folderName = folders.find((f) => f.id === folderId)?.name || "Unknown";
|
||||
toast.success(`Saved to ${folderName}`);
|
||||
if (!noteResult.success || !noteResult.data) {
|
||||
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) {
|
||||
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>
|
||||
) : query && !searchResult ? (
|
||||
<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-600 mt-2">Try other words</p>
|
||||
<p className="text-gray-800 text-xl">{t("noResults")}</p>
|
||||
<p className="text-gray-600 mt-2">{t("tryOtherWords")}</p>
|
||||
</div>
|
||||
) : searchResult ? (
|
||||
<div className="bg-white rounded-lg p-6 shadow-lg">
|
||||
@@ -186,14 +249,14 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{session && folders.length > 0 && (
|
||||
{session && decks.length > 0 && (
|
||||
<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]"
|
||||
>
|
||||
{folders.map((folder) => (
|
||||
<option key={folder.id} value={folder.id}>
|
||||
{folder.name}
|
||||
{decks.map((deck) => (
|
||||
<option key={deck.id} value={deck.id}>
|
||||
{deck.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -201,7 +264,9 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
|
||||
<LightButton
|
||||
onClick={handleSave}
|
||||
className="w-10 h-10 shrink-0"
|
||||
title="Save to folder"
|
||||
title={t("saveToFolder")}
|
||||
loading={isSaving}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<Plus />
|
||||
</LightButton>
|
||||
@@ -223,7 +288,7 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
|
||||
loading={isSearching}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Re-lookup
|
||||
{t("relookup")}
|
||||
</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { DictionaryClient } from "./DictionaryClient";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { actionGetFoldersByUserId } from "@/modules/folder/folder-action";
|
||||
import { TSharedFolder } from "@/shared/folder-type";
|
||||
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
|
||||
import type { TSharedDeck } from "@/shared/anki-type";
|
||||
|
||||
export default async function DictionaryPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
let folders: TSharedFolder[] = [];
|
||||
let decks: TSharedDeck[] = [];
|
||||
|
||||
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) {
|
||||
folders = result.data;
|
||||
decks = result.data as TSharedDeck[];
|
||||
}
|
||||
}
|
||||
|
||||
return <DictionaryClient initialFolders={folders} />;
|
||||
return <DictionaryClient initialDecks={decks} />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Folder as Fd,
|
||||
Layers,
|
||||
Heart,
|
||||
Search,
|
||||
ArrowUpDown,
|
||||
@@ -14,35 +14,35 @@ import { toast } from "sonner";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import {
|
||||
actionSearchPublicFolders,
|
||||
actionToggleFavorite,
|
||||
actionCheckFavorite,
|
||||
} from "@/modules/folder/folder-action";
|
||||
import { TPublicFolder } from "@/shared/folder-type";
|
||||
actionSearchPublicDecks,
|
||||
actionToggleDeckFavorite,
|
||||
actionCheckDeckFavorite,
|
||||
} from "@/modules/deck/deck-action";
|
||||
import type { ActionOutputPublicDeck } from "@/modules/deck/deck-action-dto";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
interface PublicFolderCardProps {
|
||||
folder: TPublicFolder;
|
||||
interface PublicDeckCardProps {
|
||||
deck: ActionOutputPublicDeck;
|
||||
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 t = useTranslations("explore");
|
||||
const [isFavorited, setIsFavorited] = useState(false);
|
||||
const [favoriteCount, setFavoriteCount] = useState(folder.favoriteCount);
|
||||
const [favoriteCount, setFavoriteCount] = useState(deck.favoriteCount);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUserId) {
|
||||
actionCheckFavorite(folder.id).then((result) => {
|
||||
actionCheckDeckFavorite({ deckId: deck.id }).then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setIsFavorited(result.data.isFavorited);
|
||||
setFavoriteCount(result.data.favoriteCount);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [folder.id, currentUserId]);
|
||||
}, [deck.id, currentUserId]);
|
||||
|
||||
const handleToggleFavorite = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -50,11 +50,11 @@ const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFol
|
||||
toast.error(t("pleaseLogin"));
|
||||
return;
|
||||
}
|
||||
const result = await actionToggleFavorite(folder.id);
|
||||
const result = await actionToggleDeckFavorite({ deckId: deck.id });
|
||||
if (result.success && result.data) {
|
||||
setIsFavorited(result.data.isFavorited);
|
||||
setFavoriteCount(result.data.favoriteCount);
|
||||
onUpdateFavorite(folder.id, result.data.isFavorited, result.data.favoriteCount);
|
||||
onUpdateFavorite(deck.id, result.data.isFavorited, result.data.favoriteCount);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
@@ -64,13 +64,13 @@ const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFol
|
||||
<div
|
||||
className="group bg-white border border-gray-200 sm:border-2 rounded-lg p-3 sm:p-5 hover:border-primary-300 hover:shadow-md cursor-pointer transition-all overflow-hidden"
|
||||
onClick={() => {
|
||||
router.push(`/explore/${folder.id}`);
|
||||
router.push(`/explore/${deck.id}`);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2 sm:mb-3">
|
||||
<div className="shrink-0 w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-primary-50 flex items-center justify-center text-primary-500">
|
||||
<Fd size={18} className="sm:hidden" />
|
||||
<Fd size={22} className="hidden sm:block" />
|
||||
<Layers size={18} className="sm:hidden" />
|
||||
<Layers size={22} className="hidden sm:block" />
|
||||
</div>
|
||||
<CircleButton
|
||||
onClick={handleToggleFavorite}
|
||||
@@ -83,12 +83,12 @@ const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFol
|
||||
</CircleButton>
|
||||
</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">
|
||||
{t("folderInfo", {
|
||||
userName: folder.userName ?? folder.userUsername ?? t("unknownUser"),
|
||||
totalPairs: folder.totalPairs,
|
||||
{t("deckInfo", {
|
||||
userName: deck.userName ?? deck.userUsername ?? t("unknownUser"),
|
||||
cardCount: deck.cardCount ?? 0,
|
||||
})}
|
||||
</p>
|
||||
|
||||
@@ -101,13 +101,13 @@ const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFol
|
||||
};
|
||||
|
||||
interface ExploreClientProps {
|
||||
initialPublicFolders: TPublicFolder[];
|
||||
initialPublicDecks: ActionOutputPublicDeck[];
|
||||
}
|
||||
|
||||
export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
|
||||
export function ExploreClient({ initialPublicDecks }: ExploreClientProps) {
|
||||
const t = useTranslations("explore");
|
||||
const router = useRouter();
|
||||
const [publicFolders, setPublicFolders] = useState<TPublicFolder[]>(initialPublicFolders);
|
||||
const [publicDecks, setPublicDecks] = useState<ActionOutputPublicDeck[]>(initialPublicDecks);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [sortByFavorites, setSortByFavorites] = useState(false);
|
||||
@@ -117,13 +117,13 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
setPublicFolders(initialPublicFolders);
|
||||
setPublicDecks(initialPublicDecks);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const result = await actionSearchPublicFolders(searchQuery.trim());
|
||||
const result = await actionSearchPublicDecks({ query: searchQuery.trim() });
|
||||
if (result.success && result.data) {
|
||||
setPublicFolders(result.data);
|
||||
setPublicDecks(result.data);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
@@ -132,14 +132,14 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
|
||||
setSortByFavorites((prev) => !prev);
|
||||
};
|
||||
|
||||
const sortedFolders = sortByFavorites
|
||||
? [...publicFolders].sort((a, b) => b.favoriteCount - a.favoriteCount)
|
||||
: publicFolders;
|
||||
const sortedDecks = sortByFavorites
|
||||
? [...publicDecks].sort((a, b) => b.favoriteCount - a.favoriteCount)
|
||||
: publicDecks;
|
||||
|
||||
const handleUpdateFavorite = (folderId: number, _isFavorited: boolean, favoriteCount: number) => {
|
||||
setPublicFolders((prev) =>
|
||||
prev.map((f) =>
|
||||
f.id === folderId ? { ...f, favoriteCount } : f
|
||||
const handleUpdateFavorite = (deckId: number, _isFavorited: boolean, favoriteCount: number) => {
|
||||
setPublicDecks((prev) =>
|
||||
prev.map((d) =>
|
||||
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>
|
||||
<p className="text-sm text-gray-500">{t("loading")}</p>
|
||||
</div>
|
||||
) : sortedFolders.length === 0 ? (
|
||||
) : sortedDecks.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<Fd size={24} className="text-gray-400" />
|
||||
<Layers size={24} className="text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm">{t("noFolders")}</p>
|
||||
<p className="text-sm">{t("noDecks")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{sortedFolders.map((folder) => (
|
||||
<PublicFolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
{sortedDecks.map((deck) => (
|
||||
<PublicDeckCard
|
||||
key={deck.id}
|
||||
deck={deck}
|
||||
currentUserId={currentUserId}
|
||||
onUpdateFavorite={handleUpdateFavorite}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"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 { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -8,42 +8,42 @@ import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
actionToggleFavorite,
|
||||
actionCheckFavorite,
|
||||
} from "@/modules/folder/folder-action";
|
||||
import { ActionOutputPublicFolder } from "@/modules/folder/folder-action-dto";
|
||||
actionToggleDeckFavorite,
|
||||
actionCheckDeckFavorite,
|
||||
} from "@/modules/deck/deck-action";
|
||||
import type { ActionOutputPublicDeck } from "@/modules/deck/deck-action-dto";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
interface ExploreDetailClientProps {
|
||||
folder: ActionOutputPublicFolder;
|
||||
deck: ActionOutputPublicDeck;
|
||||
}
|
||||
|
||||
export function ExploreDetailClient({ folder }: ExploreDetailClientProps) {
|
||||
export function ExploreDetailClient({ deck }: ExploreDetailClientProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("exploreDetail");
|
||||
const [isFavorited, setIsFavorited] = useState(false);
|
||||
const [favoriteCount, setFavoriteCount] = useState(folder.favoriteCount);
|
||||
const [favoriteCount, setFavoriteCount] = useState(deck.favoriteCount);
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
const currentUserId = session?.user?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUserId) {
|
||||
actionCheckFavorite(folder.id).then((result) => {
|
||||
actionCheckDeckFavorite({ deckId: deck.id }).then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setIsFavorited(result.data.isFavorited);
|
||||
setFavoriteCount(result.data.favoriteCount);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [folder.id, currentUserId]);
|
||||
}, [deck.id, currentUserId]);
|
||||
|
||||
const handleToggleFavorite = async () => {
|
||||
if (!currentUserId) {
|
||||
toast.error(t("pleaseLogin"));
|
||||
return;
|
||||
}
|
||||
const result = await actionToggleFavorite(folder.id);
|
||||
const result = await actionToggleDeckFavorite({ deckId: deck.id });
|
||||
if (result.success && result.data) {
|
||||
setIsFavorited(result.data.isFavorited);
|
||||
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-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">
|
||||
<Fd size={28} className="sm:w-8 sm:h-8" />
|
||||
<Layers size={28} className="sm:w-8 sm:h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900">
|
||||
{folder.name}
|
||||
{deck.name}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{t("createdBy", {
|
||||
name: folder.userName ?? folder.userUsername ?? t("unknownUser"),
|
||||
name: deck.userName ?? deck.userUsername ?? t("unknownUser"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
@@ -104,13 +104,19 @@ export function ExploreDetailClient({ folder }: ExploreDetailClientProps) {
|
||||
</CircleButton>
|
||||
</div>
|
||||
|
||||
{deck.desc && (
|
||||
<p className="text-gray-600 mb-6 text-sm sm:text-base">
|
||||
{deck.desc}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-6 py-4 border-y border-gray-100">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl sm:text-3xl font-bold text-primary-600">
|
||||
{folder.totalPairs}
|
||||
{deck.cardCount ?? 0}
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm text-gray-500 mt-1">
|
||||
{t("totalPairs")}
|
||||
{t("totalCards")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center border-x border-gray-100">
|
||||
@@ -124,7 +130,7 @@ export function ExploreDetailClient({ folder }: ExploreDetailClientProps) {
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg sm:text-xl font-semibold text-gray-700">
|
||||
{formatDate(folder.createdAt)}
|
||||
{formatDate(deck.createdAt)}
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm text-gray-500 mt-1">
|
||||
{t("createdAt")}
|
||||
@@ -133,7 +139,7 @@ export function ExploreDetailClient({ folder }: ExploreDetailClientProps) {
|
||||
</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
<ExternalLink size={18} />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { redirect } from "next/navigation";
|
||||
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: Promise<{ id: string }>;
|
||||
@@ -13,11 +13,11 @@ export default async function ExploreFolderPage({
|
||||
redirect("/explore");
|
||||
}
|
||||
|
||||
const result = await actionGetPublicFolderById(Number(id));
|
||||
const result = await actionGetPublicDeckById({ deckId: Number(id) });
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
redirect("/explore");
|
||||
}
|
||||
|
||||
return <ExploreDetailClient folder={result.data} />;
|
||||
return <ExploreDetailClient deck={result.data} />;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ExploreClient } from "./ExploreClient";
|
||||
import { actionGetPublicFolders } from "@/modules/folder/folder-action";
|
||||
import { actionGetPublicDecks } from "@/modules/deck/deck-action";
|
||||
|
||||
export default async function ExplorePage() {
|
||||
const publicFoldersResult = await actionGetPublicFolders();
|
||||
const publicFolders = publicFoldersResult.success ? publicFoldersResult.data ?? [] : [];
|
||||
const publicDecksResult = await actionGetPublicDecks();
|
||||
const publicDecks = publicDecksResult.success ? publicDecksResult.data ?? [] : [];
|
||||
|
||||
return <ExploreClient initialPublicFolders={publicFolders} />;
|
||||
return <ExploreClient initialPublicDecks={publicDecks} />;
|
||||
}
|
||||
|
||||
@@ -2,33 +2,22 @@
|
||||
|
||||
import {
|
||||
ChevronRight,
|
||||
Folder as Fd,
|
||||
Layers as DeckIcon,
|
||||
Heart,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { CardList } from "@/components/ui/CardList";
|
||||
import { actionGetUserFavorites, actionToggleFavorite } from "@/modules/folder/folder-action";
|
||||
|
||||
type UserFavorite = {
|
||||
id: number;
|
||||
folderId: number;
|
||||
folderName: string;
|
||||
folderCreatedAt: Date;
|
||||
folderTotalPairs: number;
|
||||
folderOwnerId: string;
|
||||
folderOwnerName: string | null;
|
||||
folderOwnerUsername: string | null;
|
||||
favoritedAt: Date;
|
||||
};
|
||||
import { actionGetUserFavoriteDecks, actionToggleDeckFavorite } from "@/modules/deck/deck-action";
|
||||
import type { ActionOutputUserFavoriteDeck } from "@/modules/deck/deck-action-dto";
|
||||
|
||||
interface FavoriteCardProps {
|
||||
favorite: UserFavorite;
|
||||
onRemoveFavorite: (folderId: number) => void;
|
||||
favorite: ActionOutputUserFavoriteDeck;
|
||||
onRemoveFavorite: (deckId: number) => void;
|
||||
}
|
||||
|
||||
const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
|
||||
@@ -41,9 +30,9 @@ const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
|
||||
if (isRemoving) return;
|
||||
|
||||
setIsRemoving(true);
|
||||
const result = await actionToggleFavorite(favorite.folderId);
|
||||
const result = await actionToggleDeckFavorite({ deckId: favorite.id });
|
||||
if (result.success) {
|
||||
onRemoveFavorite(favorite.folderId);
|
||||
onRemoveFavorite(favorite.id);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
@@ -54,20 +43,20 @@ const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
|
||||
<div
|
||||
className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
onClick={() => {
|
||||
router.push(`/explore/${favorite.folderId}`);
|
||||
router.push(`/explore/${favorite.id}`);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="shrink-0 text-primary-500">
|
||||
<Fd size={24} />
|
||||
<DeckIcon size={24} />
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{t("folderInfo", {
|
||||
userName: favorite.folderOwnerName ?? favorite.folderOwnerUsername ?? t("unknownUser"),
|
||||
totalPairs: favorite.folderTotalPairs,
|
||||
userName: favorite.userName ?? favorite.userUsername ?? t("unknownUser"),
|
||||
totalPairs: favorite.cardCount ?? 0,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
@@ -86,29 +75,25 @@ const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
|
||||
};
|
||||
|
||||
interface FavoritesClientProps {
|
||||
userId: string;
|
||||
initialFavorites: ActionOutputUserFavoriteDeck[];
|
||||
}
|
||||
|
||||
export function FavoritesClient({ userId }: FavoritesClientProps) {
|
||||
export function FavoritesClient({ initialFavorites }: FavoritesClientProps) {
|
||||
const t = useTranslations("favorites");
|
||||
const [favorites, setFavorites] = useState<UserFavorite[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadFavorites();
|
||||
}, [userId]);
|
||||
const [favorites, setFavorites] = useState<ActionOutputUserFavoriteDeck[]>(initialFavorites);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadFavorites = async () => {
|
||||
setLoading(true);
|
||||
const result = await actionGetUserFavorites();
|
||||
const result = await actionGetUserFavoriteDecks();
|
||||
if (result.success && result.data) {
|
||||
setFavorites(result.data);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleRemoveFavorite = (folderId: number) => {
|
||||
setFavorites((prev) => prev.filter((f) => f.folderId !== folderId));
|
||||
const handleRemoveFavorite = (deckId: number) => {
|
||||
setFavorites((prev) => prev.filter((f) => f.id !== deckId));
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,8 @@ import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { FavoritesClient } from "./FavoritesClient";
|
||||
import { actionGetUserFavoriteDecks } from "@/modules/deck/deck-action";
|
||||
import type { ActionOutputUserFavoriteDeck } from "@/modules/deck/deck-action-dto";
|
||||
|
||||
export default async function FavoritesPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
@@ -10,5 +12,11 @@ export default async function FavoritesPage() {
|
||||
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,275 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { LinkButton, CircleToggleButton, LightButton } from "@/design-system/base/button";
|
||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
||||
import { useState, useEffect, useTransition } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import localFont from "next/font/local";
|
||||
import { isNonNegativeInteger, SeededRandom } from "@/utils/random";
|
||||
import { TSharedPair } from "@/shared/folder-type";
|
||||
import { Layers, Check, Clock } from "lucide-react";
|
||||
import type { ActionOutputCardWithNote, ActionOutputScheduledCard } from "@/modules/card/card-action-dto";
|
||||
import { actionGetCardsForReview, actionAnswerCard } from "@/modules/card/card-action";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
|
||||
const myFont = localFont({
|
||||
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
|
||||
});
|
||||
|
||||
interface MemorizeProps {
|
||||
textPairs: TSharedPair[];
|
||||
deckId: number;
|
||||
deckName: string;
|
||||
}
|
||||
|
||||
const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
|
||||
const t = useTranslations("memorize.memorize");
|
||||
const [reverse, setReverse] = useState(false);
|
||||
const [dictation, setDictation] = useState(false);
|
||||
const [disorder, setDisorder] = useState(false);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [show, setShow] = useState<"question" | "answer">("question");
|
||||
const { load, play } = useAudioPlayer();
|
||||
type ReviewEase = 1 | 2 | 3 | 4;
|
||||
|
||||
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(() => {
|
||||
loadCards();
|
||||
}, [deckId]);
|
||||
|
||||
const loadCards = () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await actionGetCardsForReview({ deckId, limit: 50 });
|
||||
if (result.success && result.data) {
|
||||
setCards(result.data);
|
||||
setCurrentIndex(0);
|
||||
setShowAnswer(false);
|
||||
setLastScheduled(null);
|
||||
} else {
|
||||
setError(result.message);
|
||||
}
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const getCurrentCard = (): ActionOutputCardWithNote | null => {
|
||||
return cards[currentIndex] ?? null;
|
||||
};
|
||||
|
||||
const getNoteFields = (card: ActionOutputCardWithNote): string[] => {
|
||||
return card.note.flds.split('\x1f');
|
||||
};
|
||||
|
||||
const handleShowAnswer = () => {
|
||||
setShowAnswer(true);
|
||||
};
|
||||
|
||||
const handleAnswer = (ease: ReviewEase) => {
|
||||
const card = getCurrentCard();
|
||||
if (!card) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await actionAnswerCard({
|
||||
cardId: BigInt(card.id),
|
||||
ease,
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
setLastScheduled(result.data.scheduled);
|
||||
|
||||
const remainingCards = cards.filter((_, idx) => idx !== currentIndex);
|
||||
setCards(remainingCards);
|
||||
|
||||
if (remainingCards.length === 0) {
|
||||
setCurrentIndex(0);
|
||||
} else if (currentIndex >= remainingCards.length) {
|
||||
setCurrentIndex(remainingCards.length - 1);
|
||||
}
|
||||
|
||||
setShowAnswer(false);
|
||||
} else {
|
||||
setError(result.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const formatNextReview = (scheduled: ActionOutputScheduledCard): string => {
|
||||
const now = new Date();
|
||||
const nextReview = new Date(scheduled.nextReviewDate);
|
||||
const diffMs = nextReview.getTime() - now.getTime();
|
||||
|
||||
if (diffMs < 0) return t("now");
|
||||
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return t("lessThanMinute");
|
||||
if (diffMins < 60) return t("inMinutes", { count: diffMins });
|
||||
if (diffHours < 24) return t("inHours", { count: diffHours });
|
||||
if (diffDays < 30) return t("inDays", { count: diffDays });
|
||||
return t("inMonths", { count: Math.floor(diffDays / 30) });
|
||||
};
|
||||
|
||||
const formatInterval = (ivl: number): string => {
|
||||
if (ivl < 1) return t("minutes");
|
||||
if (ivl < 30) return t("days", { count: ivl });
|
||||
return t("months", { count: Math.floor(ivl / 30) });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const rng = new SeededRandom(textPairs[0].folderId);
|
||||
const disorderedTextPairs = textPairs.toSorted(() => rng.next() - 0.5);
|
||||
|
||||
textPairs.sort((a, b) => a.id - b.id);
|
||||
|
||||
const getTextPairs = () => disorder ? disorderedTextPairs : textPairs;
|
||||
|
||||
const handleIndexClick = () => {
|
||||
const newIndex = prompt("Input a index number.")?.trim();
|
||||
if (
|
||||
newIndex &&
|
||||
isNonNegativeInteger(newIndex) &&
|
||||
parseInt(newIndex) <= textPairs.length &&
|
||||
parseInt(newIndex) > 0
|
||||
) {
|
||||
setIndex(parseInt(newIndex) - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
if (show === "answer") {
|
||||
const newIndex = (index + 1) % getTextPairs().length;
|
||||
setIndex(newIndex);
|
||||
if (dictation) {
|
||||
const textPair = getTextPairs()[newIndex];
|
||||
const language = textPair[reverse ? "language2" : "language1"];
|
||||
const text = textPair[reverse ? "text2" : "text1"];
|
||||
|
||||
// 映射语言到 TTS 支持的格式
|
||||
const languageMap: Record<string, TTS_SUPPORTED_LANGUAGES> = {
|
||||
"chinese": "Chinese",
|
||||
"english": "English",
|
||||
"japanese": "Japanese",
|
||||
"korean": "Korean",
|
||||
"french": "French",
|
||||
"german": "German",
|
||||
"italian": "Italian",
|
||||
"portuguese": "Portuguese",
|
||||
"spanish": "Spanish",
|
||||
"russian": "Russian",
|
||||
};
|
||||
|
||||
const ttsLanguage = languageMap[language?.toLowerCase()] || "Auto";
|
||||
|
||||
getTTSUrl(text, ttsLanguage).then((url) => {
|
||||
load(url);
|
||||
play();
|
||||
});
|
||||
}
|
||||
}
|
||||
setShow(show === "question" ? "answer" : "question");
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
setIndex(
|
||||
(index - 1 + getTextPairs().length) % getTextPairs().length,
|
||||
);
|
||||
setShow("question");
|
||||
};
|
||||
|
||||
const toggleReverse = () => setReverse(!reverse);
|
||||
const toggleDictation = () => setDictation(!dictation);
|
||||
const toggleDisorder = () => setDisorder(!disorder);
|
||||
|
||||
const createText = (text: string) => {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-gray-900 text-xl md:text-2xl p-6 h-[20dvh] overflow-y-auto text-center">
|
||||
{text}
|
||||
<PageLayout>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600 mb-4">{error}</p>
|
||||
<LightButton onClick={() => router.push("/memorize")} className="px-4 py-2">
|
||||
{t("backToDecks")}
|
||||
</LightButton>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const [text1, text2] = reverse
|
||||
? [getTextPairs()[index].text2, getTextPairs()[index].text1]
|
||||
: [getTextPairs()[index].text1, getTextPairs()[index].text2];
|
||||
if (cards.length === 0) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="text-center py-12">
|
||||
<div className="text-green-500 mb-4">
|
||||
<Check className="w-16 h-16 mx-auto" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-2">{t("allDone")}</h2>
|
||||
<p className="text-gray-600 mb-6">{t("allDoneDesc")}</p>
|
||||
<LightButton onClick={() => router.push("/memorize")} className="px-4 py-2">
|
||||
{t("backToDecks")}
|
||||
</LightButton>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const currentCard = getCurrentCard()!;
|
||||
const fields = getNoteFields(currentCard);
|
||||
const front = fields[0] ?? "";
|
||||
const back = fields[1] ?? "";
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 进度指示器 */}
|
||||
<div className="flex justify-center mb-4">
|
||||
<LinkButton onClick={handleIndexClick} className="text-sm">
|
||||
{index + 1} / {getTextPairs().length}
|
||||
</LinkButton>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<Layers className="w-5 h-5" />
|
||||
<span className="font-medium">{deckName}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{t("progress", { current: currentIndex + 1, total: cards.length + currentIndex })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文本显示区域 */}
|
||||
<div className={`h-[40dvh] ${myFont.className} mb-4`}>
|
||||
{(() => {
|
||||
if (dictation) {
|
||||
if (show === "question") {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-gray-400 text-4xl">?</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{createText(text1)}
|
||||
<div className="border-t border-gray-200"></div>
|
||||
{createText(text2)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (show === "question") {
|
||||
return createText(text1);
|
||||
} else {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{createText(text1)}
|
||||
<div className="border-t border-gray-200"></div>
|
||||
{createText(text2)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
})()}
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mb-6">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.max(0, ((currentIndex) / (cards.length + currentIndex)) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="flex flex-row gap-2 items-center justify-center flex-wrap">
|
||||
{lastScheduled && (
|
||||
<div className="mb-4 p-3 bg-gray-50 rounded-lg text-sm text-gray-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>
|
||||
{t("nextReview")}: {formatNextReview(lastScheduled)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`bg-white border border-gray-200 rounded-xl shadow-sm mb-6 ${myFont.className}`}>
|
||||
<div className="p-8 min-h-[20dvh] flex items-center justify-center">
|
||||
<div className="text-gray-900 text-xl md:text-2xl text-center">
|
||||
{front}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAnswer && (
|
||||
<>
|
||||
<div className="border-t border-gray-200" />
|
||||
<div className="p-8 min-h-[20dvh] flex items-center justify-center bg-gray-50 rounded-b-xl">
|
||||
<div className="text-gray-900 text-xl md:text-2xl text-center">
|
||||
{back}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-4 mb-6 text-sm text-gray-500">
|
||||
<span>{t("interval")}: {formatInterval(currentCard.ivl)}</span>
|
||||
<span>•</span>
|
||||
<span>{t("ease")}: {currentCard.factor / 10}%</span>
|
||||
<span>•</span>
|
||||
<span>{t("lapses")}: {currentCard.lapses}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
{!showAnswer ? (
|
||||
<LightButton
|
||||
onClick={handleNext}
|
||||
className="px-4 py-2 rounded-full text-sm"
|
||||
onClick={handleShowAnswer}
|
||||
disabled={isPending}
|
||||
className="px-8 py-3 text-lg rounded-full"
|
||||
>
|
||||
{show === "question" ? t("answer") : t("next")}
|
||||
{t("showAnswer")}
|
||||
</LightButton>
|
||||
<LightButton
|
||||
onClick={handlePrevious}
|
||||
className="px-4 py-2 rounded-full text-sm"
|
||||
) : (
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
<button
|
||||
onClick={() => handleAnswer(1)}
|
||||
disabled={isPending}
|
||||
className="flex flex-col items-center px-6 py-3 rounded-xl bg-red-100 hover:bg-red-200 text-red-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{t("previous")}
|
||||
</LightButton>
|
||||
<CircleToggleButton
|
||||
selected={reverse}
|
||||
onClick={toggleReverse}
|
||||
<span className="font-medium">{t("again")}</span>
|
||||
<span className="text-xs opacity-75"><1{t("minAbbr")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleAnswer(2)}
|
||||
disabled={isPending}
|
||||
className="flex flex-col items-center px-6 py-3 rounded-xl bg-orange-100 hover:bg-orange-200 text-orange-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{t("reverse")}
|
||||
</CircleToggleButton>
|
||||
<CircleToggleButton
|
||||
selected={dictation}
|
||||
onClick={toggleDictation}
|
||||
<span className="font-medium">{t("hard")}</span>
|
||||
<span className="text-xs opacity-75">6{t("minAbbr")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleAnswer(3)}
|
||||
disabled={isPending}
|
||||
className="flex flex-col items-center px-6 py-3 rounded-xl bg-green-100 hover:bg-green-200 text-green-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{t("dictation")}
|
||||
</CircleToggleButton>
|
||||
<CircleToggleButton
|
||||
selected={disorder}
|
||||
onClick={toggleDisorder}
|
||||
<span className="font-medium">{t("good")}</span>
|
||||
<span className="text-xs opacity-75">10{t("minAbbr")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleAnswer(4)}
|
||||
disabled={isPending}
|
||||
className="flex flex-col items-center px-6 py-3 rounded-xl bg-blue-100 hover:bg-blue-200 text-blue-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{t("disorder")}
|
||||
</CircleToggleButton>
|
||||
<span className="font-medium">{t("easy")}</span>
|
||||
<span className="text-xs opacity-75">4{t("dayAbbr")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
@@ -1,37 +1,57 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { isNonNegativeInteger } from "@/utils/random";
|
||||
import { FolderSelector } from "./FolderSelector";
|
||||
import { DeckSelector } from "./DeckSelector";
|
||||
import { Memorize } from "./Memorize";
|
||||
import { auth } from "@/auth";
|
||||
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({
|
||||
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 folder_id = tParam
|
||||
? isNonNegativeInteger(tParam)
|
||||
? parseInt(tParam)
|
||||
const deckId = deckIdParam
|
||||
? isNonNegativeInteger(deckIdParam)
|
||||
? parseInt(deckIdParam)
|
||||
: null
|
||||
: null;
|
||||
|
||||
if (!folder_id) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) redirect("/login?redirect=/memorize");
|
||||
|
||||
if (!deckId) {
|
||||
const decksResult = await actionGetDecksByUserId(session.user.id);
|
||||
const decks = decksResult.data ?? [];
|
||||
|
||||
const deckStats = new Map<number, Awaited<ReturnType<typeof actionGetCardStats>>["data"]>();
|
||||
for (const deck of decks) {
|
||||
const statsResult = await actionGetCardStats({ deckId: deck.id });
|
||||
if (statsResult.success && statsResult.data) {
|
||||
deckStats.set(deck.id, statsResult.data);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FolderSelector
|
||||
folders={(await actionGetFoldersWithTotalPairsByUserId(session.user.id)).data!}
|
||||
<DeckSelector
|
||||
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} />;
|
||||
}
|
||||
|
||||
@@ -10,32 +10,42 @@ 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 { TSharedFolder } from "@/shared/folder-type";
|
||||
import { OCROutput } from "@/lib/bigmodel/ocr/types";
|
||||
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
||||
|
||||
interface OCRClientProps {
|
||||
initialFolders: TSharedFolder[];
|
||||
interface ActionOutputProcessOCR {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: {
|
||||
pairsCreated: number;
|
||||
sourceLanguage?: string;
|
||||
targetLanguage?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function OCRClient({ initialFolders }: OCRClientProps) {
|
||||
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 [selectedFolderId, setSelectedFolderId] = useState<number | null>(
|
||||
initialFolders.length > 0 ? initialFolders[0].id : 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<OCROutput | null>(null);
|
||||
const [ocrResult, setOcrResult] = useState<ActionOutputProcessOCR | null>(null);
|
||||
|
||||
const handleFileChange = useCallback((file: File | null) => {
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast.error(t("processingFailed"));
|
||||
toast.error(t("invalidFileType"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -74,8 +84,8 @@ export function OCRClient({ initialFolders }: OCRClientProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedFolderId) {
|
||||
toast.error(t("noFolder"));
|
||||
if (!selectedDeckId) {
|
||||
toast.error(t("noDeck"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -87,16 +97,17 @@ export function OCRClient({ initialFolders }: OCRClientProps) {
|
||||
|
||||
const result = await actionProcessOCR({
|
||||
imageBase64: base64,
|
||||
folderId: selectedFolderId,
|
||||
deckId: selectedDeckId,
|
||||
sourceLanguage: sourceLanguage || undefined,
|
||||
targetLanguage: targetLanguage || undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
const folderName = initialFolders.find(f => f.id === selectedFolderId)?.name || "";
|
||||
toast.success(t("saved", { count: result.data?.pairsCreated ?? 0, folder: folderName }));
|
||||
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("processingFailed"));
|
||||
toast.error(result.message || t("ocrFailed"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("processingFailed"));
|
||||
@@ -105,6 +116,20 @@ export function OCRClient({ initialFolders }: OCRClientProps) {
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -118,136 +143,144 @@ export function OCRClient({ initialFolders }: OCRClientProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">{t("title")}</h1>
|
||||
<p className="text-gray-600">{t("description")}</p>
|
||||
<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>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card variant="bordered" padding="lg">
|
||||
<div className="space-y-4">
|
||||
<div className="font-semibold text-gray-800 flex items-center gap-2">
|
||||
<Upload className="w-5 h-5" />
|
||||
{t("uploadImage")}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Upload Section */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
||||
{t("uploadSection")}
|
||||
</h2>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
|
||||
previewUrl
|
||||
? "border-primary-300 bg-primary-50"
|
||||
: "border-gray-300 hover:border-primary-400 hover:bg-gray-50"
|
||||
}`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
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-3">
|
||||
<div className="space-y-4">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
className="max-h-64 mx-auto rounded-lg shadow-md"
|
||||
className="mx-auto max-w-full h-64 object-contain rounded-lg"
|
||||
/>
|
||||
<div className="flex justify-center gap-2">
|
||||
<LightButton
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearImage();
|
||||
}}
|
||||
>
|
||||
{t("uploadImage")}
|
||||
</LightButton>
|
||||
</div>
|
||||
<p className="text-gray-600">{t("changeImage")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 text-gray-500">
|
||||
<FileImage className="w-12 h-12 mx-auto text-gray-400" />
|
||||
<p>{t("dragDropHint")}</p>
|
||||
<p className="text-sm">{t("supportedFormats")}</p>
|
||||
<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/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleFileChange(e.target.files?.[0] || null)}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card variant="bordered" padding="lg">
|
||||
<div className="space-y-4">
|
||||
<div className="font-semibold text-gray-800">{t("selectFolder")}</div>
|
||||
|
||||
{initialFolders.length > 0 ? (
|
||||
{/* Deck Selection */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
||||
{t("deckSelection")}
|
||||
</h2>
|
||||
<Select
|
||||
value={selectedFolderId?.toString() || ""}
|
||||
onChange={(e) => setSelectedFolderId(Number(e.target.value))}
|
||||
value={selectedDeckId?.toString() || ""}
|
||||
onChange={(e) => setSelectedDeckId(Number(e.target.value))}
|
||||
className="w-full"
|
||||
>
|
||||
{initialFolders.map((folder) => (
|
||||
<option key={folder.id} value={folder.id}>
|
||||
{folder.name}
|
||||
<option value="">{t("selectDeck")}</option>
|
||||
{decks.map((deck) => (
|
||||
<option key={deck.id} value={deck.id}>
|
||||
{deck.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">{t("noFolders")}</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card variant="bordered" padding="lg">
|
||||
<div className="space-y-4">
|
||||
<div className="font-semibold text-gray-800">{t("languageHints")}</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Language Hints */}
|
||||
<div>
|
||||
<label className="text-sm text-gray-600 block mb-1">
|
||||
{t("sourceLanguageHint")}
|
||||
</label>
|
||||
<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)}
|
||||
placeholder="English"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-600 block mb-1">
|
||||
{t("targetLanguageHint")}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("targetLanguagePlaceholder")}
|
||||
value={targetLanguage}
|
||||
onChange={(e) => setTargetLanguage(e.target.value)}
|
||||
placeholder="Chinese"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Process Button */}
|
||||
<div className="flex justify-center">
|
||||
<PrimaryButton
|
||||
onClick={handleProcess}
|
||||
disabled={isProcessing || !selectedFile || !selectedFolderId}
|
||||
size="lg"
|
||||
className="px-8"
|
||||
disabled={!selectedFile || !selectedDeckId || isProcessing}
|
||||
loading={isProcessing}
|
||||
className="px-8 py-3 text-lg"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
||||
{t("processing")}
|
||||
</>
|
||||
) : (
|
||||
t("process")
|
||||
)}
|
||||
{t("processButton")}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
{/* Results Preview */}
|
||||
{ocrResult && ocrResult.data && (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
||||
{t("resultsPreview")}
|
||||
</h2>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="text-center py-4">
|
||||
<p className="text-gray-800">{t("extractedPairs", { count: ocrResult.data.pairsCreated })}</p>
|
||||
</div>
|
||||
</div>
|
||||
{ocrResult.data.sourceLanguage && (
|
||||
<div className="mt-4 text-sm text-gray-500">
|
||||
{t("detectedSourceLanguage")}: {ocrResult.data.sourceLanguage}
|
||||
</div>
|
||||
)}
|
||||
{ocrResult.data.targetLanguage && (
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
{t("detectedTargetLanguage")}: {ocrResult.data.targetLanguage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-center">
|
||||
<LightButton
|
||||
onClick={handleSave}
|
||||
disabled={!selectedDeckId}
|
||||
className="px-6 py-2"
|
||||
>
|
||||
{t("saveButton")}
|
||||
</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { OCRClient } from "./OCRClient";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { actionGetFoldersByUserId } from "@/modules/folder/folder-action";
|
||||
import { TSharedFolder } from "@/shared/folder-type";
|
||||
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 folders: TSharedFolder[] = [];
|
||||
let decks: ActionOutputDeck[] = [];
|
||||
|
||||
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) {
|
||||
folders = result.data;
|
||||
decks = result.data;
|
||||
}
|
||||
}
|
||||
|
||||
return <OCRClient initialFolders={folders} />;
|
||||
return <OCRClient initialDecks={decks} />;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import {
|
||||
ChevronRight,
|
||||
Folder as Fd,
|
||||
FolderPen,
|
||||
FolderPlus,
|
||||
Layers,
|
||||
Pencil,
|
||||
Plus,
|
||||
Globe,
|
||||
Lock,
|
||||
Trash2,
|
||||
@@ -18,30 +18,32 @@ import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { CardList } from "@/components/ui/CardList";
|
||||
import {
|
||||
actionCreateFolder,
|
||||
actionDeleteFolderById,
|
||||
actionGetFoldersWithTotalPairsByUserId,
|
||||
actionRenameFolderById,
|
||||
actionSetFolderVisibility,
|
||||
} from "@/modules/folder/folder-action";
|
||||
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
|
||||
actionCreateDeck,
|
||||
actionDeleteDeck,
|
||||
actionGetDecksByUserId,
|
||||
actionUpdateDeck,
|
||||
} from "@/modules/deck/deck-action";
|
||||
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
|
||||
|
||||
interface FolderCardProps {
|
||||
folder: TSharedFolderWithTotalPairs;
|
||||
onUpdateFolder: (folderId: number, updates: Partial<TSharedFolderWithTotalPairs>) => void;
|
||||
onDeleteFolder: (folderId: number) => void;
|
||||
interface DeckCardProps {
|
||||
deck: ActionOutputDeck;
|
||||
onUpdateDeck: (deckId: number, updates: Partial<ActionOutputDeck>) => void;
|
||||
onDeleteDeck: (deckId: number) => void;
|
||||
}
|
||||
|
||||
const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps) => {
|
||||
const DeckCard = ({ deck, onUpdateDeck, onDeleteDeck }: DeckCardProps) => {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("folders");
|
||||
const t = useTranslations("decks");
|
||||
|
||||
const handleToggleVisibility = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const newVisibility = folder.visibility === "PUBLIC" ? "PRIVATE" : "PUBLIC";
|
||||
const result = await actionSetFolderVisibility(folder.id, newVisibility);
|
||||
const newVisibility = deck.visibility === "PUBLIC" ? "PRIVATE" : "PUBLIC";
|
||||
const result = await actionUpdateDeck({
|
||||
deckId: deck.id,
|
||||
visibility: newVisibility,
|
||||
});
|
||||
if (result.success) {
|
||||
onUpdateFolder(folder.id, { visibility: newVisibility });
|
||||
onUpdateDeck(deck.id, { visibility: newVisibility });
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
@@ -51,9 +53,12 @@ const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps)
|
||||
e.stopPropagation();
|
||||
const newName = prompt(t("enterNewName"))?.trim();
|
||||
if (newName && newName.length > 0) {
|
||||
const result = await actionRenameFolderById(folder.id, newName);
|
||||
const result = await actionUpdateDeck({
|
||||
deckId: deck.id,
|
||||
name: newName,
|
||||
});
|
||||
if (result.success) {
|
||||
onUpdateFolder(folder.id, { name: newName });
|
||||
onUpdateDeck(deck.id, { name: newName });
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
@@ -62,11 +67,11 @@ const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps)
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const confirm = prompt(t("confirmDelete", { name: folder.name }));
|
||||
if (confirm === folder.name) {
|
||||
const result = await actionDeleteFolderById(folder.id);
|
||||
const confirm = prompt(t("confirmDelete", { name: deck.name }));
|
||||
if (confirm === deck.name) {
|
||||
const result = await actionDeleteDeck({ deckId: deck.id });
|
||||
if (result.success) {
|
||||
onDeleteFolder(folder.id);
|
||||
onDeleteDeck(deck.id);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
@@ -77,31 +82,31 @@ const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps)
|
||||
<div
|
||||
className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
onClick={() => {
|
||||
router.push(`/folders/${folder.id}`);
|
||||
router.push(`/decks/${deck.id}`);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="shrink-0 text-primary-500">
|
||||
<Fd size={24} />
|
||||
<Layers size={24} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{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">
|
||||
{folder.visibility === "PUBLIC" ? (
|
||||
{deck.visibility === "PUBLIC" ? (
|
||||
<Globe size={12} />
|
||||
) : (
|
||||
<Lock size={12} />
|
||||
)}
|
||||
{folder.visibility === "PUBLIC" ? t("public") : t("private")}
|
||||
{deck.visibility === "PUBLIC" ? t("public") : t("private")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{t("folderInfo", {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
totalPairs: folder.total,
|
||||
{t("deckInfo", {
|
||||
id: deck.id,
|
||||
name: deck.name,
|
||||
totalCards: deck.cardCount ?? 0,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
@@ -110,16 +115,16 @@ const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps)
|
||||
<div className="flex items-center gap-1 ml-4">
|
||||
<CircleButton
|
||||
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} />
|
||||
) : (
|
||||
<Globe size={18} />
|
||||
)}
|
||||
</CircleButton>
|
||||
<CircleButton onClick={handleRename}>
|
||||
<FolderPen size={18} />
|
||||
<Pencil size={18} />
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
onClick={handleDelete}
|
||||
@@ -133,46 +138,46 @@ const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps)
|
||||
);
|
||||
};
|
||||
|
||||
interface FoldersClientProps {
|
||||
interface DecksClientProps {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export function FoldersClient({ userId }: FoldersClientProps) {
|
||||
const t = useTranslations("folders");
|
||||
export function DecksClient({ userId }: DecksClientProps) {
|
||||
const t = useTranslations("decks");
|
||||
const router = useRouter();
|
||||
const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>([]);
|
||||
const [decks, setDecks] = useState<ActionOutputDeck[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadFolders = async () => {
|
||||
const loadDecks = async () => {
|
||||
setLoading(true);
|
||||
const result = await actionGetFoldersWithTotalPairsByUserId(userId);
|
||||
const result = await actionGetDecksByUserId(userId);
|
||||
if (result.success && result.data) {
|
||||
setFolders(result.data);
|
||||
setDecks(result.data);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadFolders();
|
||||
loadDecks();
|
||||
}, [userId]);
|
||||
|
||||
const handleUpdateFolder = (folderId: number, updates: Partial<TSharedFolderWithTotalPairs>) => {
|
||||
setFolders((prev) =>
|
||||
prev.map((f) => (f.id === folderId ? { ...f, ...updates } : f))
|
||||
const handleUpdateDeck = (deckId: number, updates: Partial<ActionOutputDeck>) => {
|
||||
setDecks((prev) =>
|
||||
prev.map((d) => (d.id === deckId ? { ...d, ...updates } : d))
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteFolder = (folderId: number) => {
|
||||
setFolders((prev) => prev.filter((f) => f.id !== folderId));
|
||||
const handleDeleteDeck = (deckId: number) => {
|
||||
setDecks((prev) => prev.filter((d) => d.id !== deckId));
|
||||
};
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
const folderName = prompt(t("enterFolderName"));
|
||||
if (!folderName?.trim()) return;
|
||||
const handleCreateDeck = async () => {
|
||||
const deckName = prompt(t("enterDeckName"));
|
||||
if (!deckName?.trim()) return;
|
||||
|
||||
const result = await actionCreateFolder(userId, folderName.trim());
|
||||
const result = await actionCreateDeck({ name: deckName.trim() });
|
||||
if (result.success) {
|
||||
loadFolders();
|
||||
loadDecks();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
@@ -183,9 +188,9 @@ export function FoldersClient({ userId }: FoldersClientProps) {
|
||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<div className="mb-4">
|
||||
<LightButton onClick={handleCreateFolder}>
|
||||
<FolderPlus size={18} />
|
||||
{t("newFolder")}
|
||||
<LightButton onClick={handleCreateDeck}>
|
||||
<Plus size={18} />
|
||||
{t("newDeck")}
|
||||
</LightButton>
|
||||
</div>
|
||||
|
||||
@@ -195,20 +200,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>
|
||||
<p className="text-sm text-gray-500">{t("loading")}</p>
|
||||
</div>
|
||||
) : folders.length === 0 ? (
|
||||
) : decks.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<Fd size={24} className="text-gray-400" />
|
||||
<Layers size={24} className="text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm">{t("noFoldersYet")}</p>
|
||||
<p className="text-sm">{t("noDecksYet")}</p>
|
||||
</div>
|
||||
) : (
|
||||
folders.map((folder) => (
|
||||
<FolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
onUpdateFolder={handleUpdateFolder}
|
||||
onDeleteFolder={handleDeleteFolder}
|
||||
decks.map((deck) => (
|
||||
<DeckCard
|
||||
key={deck.id}
|
||||
deck={deck}
|
||||
onUpdateDeck={handleUpdateDeck}
|
||||
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 { useState } from "react";
|
||||
import { CircleButton } from "@/design-system/base/button";
|
||||
import { UpdateTextPairModal } from "./UpdateTextPairModal";
|
||||
import { UpdateCardModal } from "./UpdateCardModal";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { TSharedPair } from "@/shared/folder-type";
|
||||
import { actionUpdatePairById } from "@/modules/folder/folder-action";
|
||||
import { ActionInputUpdatePairById } from "@/modules/folder/folder-action-dto";
|
||||
import type { ActionOutputCardWithNote } from "@/modules/card/card-action-dto";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface TextPairCardProps {
|
||||
textPair: TSharedPair;
|
||||
interface CardItemProps {
|
||||
card: ActionOutputCardWithNote;
|
||||
isReadOnly: boolean;
|
||||
onDel: () => void;
|
||||
refreshTextPairs: () => void;
|
||||
refreshCards: () => void;
|
||||
}
|
||||
|
||||
export function TextPairCard({
|
||||
textPair,
|
||||
export function CardItem({
|
||||
card,
|
||||
isReadOnly,
|
||||
onDel,
|
||||
refreshTextPairs,
|
||||
}: TextPairCardProps) {
|
||||
refreshCards,
|
||||
}: CardItemProps) {
|
||||
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 (
|
||||
<div className="group border-b border-gray-100 hover:bg-gray-50 transition-colors">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
||||
{textPair.language1.toUpperCase()}
|
||||
</span>
|
||||
<span>→</span>
|
||||
<span className="px-2 py-1 bg-gray-100 rounded-md">
|
||||
{textPair.language2.toUpperCase()}
|
||||
{t("card")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -60,26 +59,25 @@ export function TextPairCard({
|
||||
</div>
|
||||
<div className="text-gray-900 grid grid-cols-2 gap-4 w-3/4">
|
||||
<div>
|
||||
{textPair.text1.length > 30
|
||||
? textPair.text1.substring(0, 30) + "..."
|
||||
: textPair.text1}
|
||||
{field1.length > 30
|
||||
? field1.substring(0, 30) + "..."
|
||||
: field1}
|
||||
</div>
|
||||
<div>
|
||||
{textPair.text2.length > 30
|
||||
? textPair.text2.substring(0, 30) + "..."
|
||||
: textPair.text2}
|
||||
{field2.length > 30
|
||||
? field2.substring(0, 30) + "..."
|
||||
: field2}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UpdateTextPairModal
|
||||
<UpdateCardModal
|
||||
isOpen={openUpdateModal}
|
||||
onClose={() => setOpenUpdateModal(false)}
|
||||
onUpdate={async (id: number, data: ActionInputUpdatePairById) => {
|
||||
await actionUpdatePairById(id, data).then(result => result.success ? toast.success(result.message) : toast.error(result.message));
|
||||
card={card}
|
||||
onUpdated={() => {
|
||||
setOpenUpdateModal(false);
|
||||
refreshTextPairs();
|
||||
refreshCards();
|
||||
}}
|
||||
textPair={textPair}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -3,34 +3,34 @@
|
||||
import { ArrowLeft, Plus } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { redirect, useRouter } from "next/navigation";
|
||||
import { AddTextPairModal } from "./AddTextPairModal";
|
||||
import { TextPairCard } from "./TextPairCard";
|
||||
import { AddCardModal } from "./AddCardModal";
|
||||
import { CardItem } from "./CardItem";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PrimaryButton, CircleButton, LinkButton } from "@/design-system/base/button";
|
||||
import { CardList } from "@/components/ui/CardList";
|
||||
import { actionCreatePair, actionDeletePairById, actionGetPairsByFolderId } from "@/modules/folder/folder-action";
|
||||
import { TSharedPair } from "@/shared/folder-type";
|
||||
import { actionGetCardsByDeckIdWithNotes, actionDeleteCard } from "@/modules/card/card-action";
|
||||
import type { ActionOutputCardWithNote } from "@/modules/card/card-action-dto";
|
||||
import { toast } from "sonner";
|
||||
|
||||
|
||||
export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnly: boolean; }) {
|
||||
const [textPairs, setTextPairs] = useState<TSharedPair[]>([]);
|
||||
export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boolean; }) {
|
||||
const [cards, setCards] = useState<ActionOutputCardWithNote[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [openAddModal, setAddModal] = useState(false);
|
||||
const router = useRouter();
|
||||
const t = useTranslations("folder_id");
|
||||
const t = useTranslations("deck_id");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTextPairs = async () => {
|
||||
const fetchCards = async () => {
|
||||
setLoading(true);
|
||||
await actionGetPairsByFolderId(folderId)
|
||||
await actionGetCardsByDeckIdWithNotes({ deckId })
|
||||
.then(result => {
|
||||
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;
|
||||
}).then(setTextPairs)
|
||||
}).then(setCards)
|
||||
.catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||
})
|
||||
@@ -38,17 +38,17 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
fetchTextPairs();
|
||||
}, [folderId]);
|
||||
fetchCards();
|
||||
}, [deckId]);
|
||||
|
||||
const refreshTextPairs = async () => {
|
||||
await actionGetPairsByFolderId(folderId)
|
||||
const refreshCards = async () => {
|
||||
await actionGetCardsByDeckIdWithNotes({ deckId })
|
||||
.then(result => {
|
||||
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;
|
||||
}).then(setTextPairs)
|
||||
}).then(setCards)
|
||||
.catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||
});
|
||||
@@ -56,9 +56,7 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 顶部导航和标题栏 */}
|
||||
<div className="mb-6">
|
||||
{/* 返回按钮 */}
|
||||
<LinkButton
|
||||
onClick={router.back}
|
||||
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>
|
||||
</LinkButton>
|
||||
|
||||
{/* 页面标题和操作按钮 */}
|
||||
<div className="flex items-center justify-between">
|
||||
{/* 标题区域 */}
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-1">
|
||||
{t("textPairs")}
|
||||
{t("cards")}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("itemsCount", { count: textPairs.length })}
|
||||
{t("itemsCount", { count: cards.length })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮区域 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<PrimaryButton
|
||||
onClick={() => {
|
||||
redirect(`/memorize?folder_id=${folderId}`);
|
||||
redirect(`/memorize?deck_id=${deckId}`);
|
||||
}}
|
||||
>
|
||||
{t("memorize")}
|
||||
@@ -101,64 +96,46 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文本对列表 */}
|
||||
<CardList>
|
||||
{loading ? (
|
||||
// 加载状态
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
|
||||
<p className="text-sm text-gray-500">{t("loadingTextPairs")}</p>
|
||||
<p className="text-sm text-gray-500">{t("loadingCards")}</p>
|
||||
</div>
|
||||
) : textPairs.length === 0 ? (
|
||||
// 空状态
|
||||
) : cards.length === 0 ? (
|
||||
<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 className="divide-y divide-gray-100">
|
||||
{textPairs
|
||||
.toSorted((a, b) => a.id - b.id)
|
||||
.map((textPair) => (
|
||||
<TextPairCard
|
||||
key={textPair.id}
|
||||
textPair={textPair}
|
||||
{cards
|
||||
.toSorted((a, b) => Number(BigInt(a.id) - BigInt(b.id)))
|
||||
.map((card) => (
|
||||
<CardItem
|
||||
key={card.id}
|
||||
card={card}
|
||||
isReadOnly={isReadOnly}
|
||||
onDel={() => {
|
||||
actionDeletePairById(textPair.id)
|
||||
actionDeleteCard({ cardId: BigInt(card.id) })
|
||||
.then(result => {
|
||||
if (!result.success) throw new Error(result.message || "Delete failed");
|
||||
}).then(refreshTextPairs)
|
||||
}).then(refreshCards)
|
||||
.catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||
});
|
||||
}}
|
||||
refreshTextPairs={refreshTextPairs}
|
||||
refreshCards={refreshCards}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardList>
|
||||
|
||||
{/* 添加文本对模态框 */}
|
||||
<AddTextPairModal
|
||||
<AddCardModal
|
||||
isOpen={openAddModal}
|
||||
onClose={() => setAddModal(false)}
|
||||
onAdd={async (
|
||||
text1: string,
|
||||
text2: string,
|
||||
language1: string,
|
||||
language2: string,
|
||||
) => {
|
||||
await actionCreatePair({
|
||||
text1: text1,
|
||||
text2: text2,
|
||||
language1: language1,
|
||||
language2: language2,
|
||||
folderId: folderId,
|
||||
});
|
||||
refreshTextPairs();
|
||||
}}
|
||||
deckId={deckId}
|
||||
onAdded={refreshCards}
|
||||
/>
|
||||
</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 { FoldersClient } from "./FoldersClient";
|
||||
import { DecksClient } from "./DecksClient";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function FoldersPage() {
|
||||
export default async function DecksPage() {
|
||||
const session = await auth.api.getSession(
|
||||
{ headers: await headers() }
|
||||
);
|
||||
|
||||
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} />;
|
||||
}
|
||||
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;
|
||||
};
|
||||
428
src/modules/card/card-action.ts
Normal file
428
src/modules/card/card-action.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
"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,
|
||||
} from "./card-service";
|
||||
import { repoGetCardDeckOwnerId } from "./card-repository";
|
||||
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;
|
||||
|
||||
const ownerId = await repoGetCardDeckOwnerId(cardId);
|
||||
return ownerId === 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;
|
||||
}
|
||||
113
src/modules/card/card-service-dto.ts
Normal file
113
src/modules/card/card-service-dto.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
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 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],
|
||||
GRADUATING_INTERVAL_GOOD: 1,
|
||||
GRADUATING_INTERVAL_EASY: 4,
|
||||
EASY_INTERVAL: 4,
|
||||
MINIMUM_FACTOR: 1300,
|
||||
DEFAULT_FACTOR: 2500,
|
||||
FACTOR_ADJUSTMENTS: {
|
||||
1: -200,
|
||||
2: -150,
|
||||
3: 0,
|
||||
4: 150,
|
||||
},
|
||||
INITIAL_INTERVALS: {
|
||||
2: 1,
|
||||
3: 3,
|
||||
4: 4,
|
||||
},
|
||||
} as const;
|
||||
384
src/modules/card/card-service.ts
Normal file
384
src/modules/card/card-service.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import {
|
||||
repoCreateCard,
|
||||
repoUpdateCard,
|
||||
repoGetCardById,
|
||||
repoGetCardByIdWithNote,
|
||||
repoGetCardsByDeckId,
|
||||
repoGetCardsByDeckIdWithNotes,
|
||||
repoGetCardsForReview,
|
||||
repoGetNewCards,
|
||||
repoGetCardStats,
|
||||
repoDeleteCard,
|
||||
repoGetCardsByNoteId,
|
||||
} from "./card-repository";
|
||||
import {
|
||||
RepoInputUpdateCard,
|
||||
RepoOutputCard,
|
||||
} from "./card-repository-dto";
|
||||
import {
|
||||
ServiceInputCreateCard,
|
||||
ServiceInputAnswerCard,
|
||||
ServiceInputGetCardsForReview,
|
||||
ServiceInputGetNewCards,
|
||||
ServiceInputGetCardsByDeckId,
|
||||
ServiceInputGetCardStats,
|
||||
ServiceOutputCard,
|
||||
ServiceOutputCardWithNote,
|
||||
ServiceOutputCardStats,
|
||||
ServiceOutputScheduledCard,
|
||||
ServiceOutputReviewResult,
|
||||
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 scheduleNewCard(ease: ReviewEase, factor: 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: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[1]),
|
||||
};
|
||||
}
|
||||
|
||||
const ivl = SM2_CONFIG.INITIAL_INTERVALS[ease];
|
||||
return {
|
||||
type: CardType.REVIEW,
|
||||
queue: CardQueue.REVIEW,
|
||||
ivl,
|
||||
due: calculateDueDate(ivl),
|
||||
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[ease]),
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleLearningCard(ease: ReviewEase, factor: number, left: number): {
|
||||
type: CardType;
|
||||
queue: CardQueue;
|
||||
ivl: number;
|
||||
due: number;
|
||||
newFactor: number;
|
||||
newLeft: 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: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[1]),
|
||||
newLeft: SM2_CONFIG.LEARNING_STEPS.length * 1000 + SM2_CONFIG.LEARNING_STEPS.length,
|
||||
};
|
||||
}
|
||||
|
||||
const stepIndex = Math.floor(left % 1000);
|
||||
if (ease === 2 && stepIndex < SM2_CONFIG.LEARNING_STEPS.length - 1) {
|
||||
const nextStep = stepIndex + 1;
|
||||
return {
|
||||
type: CardType.LEARNING,
|
||||
queue: CardQueue.LEARNING,
|
||||
ivl: 0,
|
||||
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[nextStep] * 60,
|
||||
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[2]),
|
||||
newLeft: nextStep * 1000 + (SM2_CONFIG.LEARNING_STEPS.length - nextStep),
|
||||
};
|
||||
}
|
||||
|
||||
const ivl = ease === 4 ? SM2_CONFIG.GRADUATING_INTERVAL_EASY : SM2_CONFIG.GRADUATING_INTERVAL_GOOD;
|
||||
return {
|
||||
type: CardType.REVIEW,
|
||||
queue: CardQueue.REVIEW,
|
||||
ivl,
|
||||
due: calculateDueDate(ivl),
|
||||
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[ease]),
|
||||
newLeft: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleReviewCard(
|
||||
ease: ReviewEase,
|
||||
ivl: number,
|
||||
factor: number,
|
||||
lapses: number,
|
||||
): {
|
||||
type: CardType;
|
||||
queue: CardQueue;
|
||||
ivl: number;
|
||||
due: number;
|
||||
newFactor: number;
|
||||
newLapses: number;
|
||||
} {
|
||||
if (ease === 1) {
|
||||
return {
|
||||
type: CardType.RELEARNING,
|
||||
queue: CardQueue.LEARNING,
|
||||
ivl: 0,
|
||||
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60,
|
||||
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[1]),
|
||||
newLapses: lapses + 1,
|
||||
};
|
||||
}
|
||||
|
||||
const newFactor = Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[ease]);
|
||||
const factorMultiplier = newFactor / 1000;
|
||||
let newIvl = Math.floor(ivl * factorMultiplier);
|
||||
|
||||
if (ease === 2) {
|
||||
newIvl = Math.max(1, Math.floor(newIvl * 1.2));
|
||||
} else if (ease === 4) {
|
||||
newIvl = Math.floor(newIvl * 1.3);
|
||||
}
|
||||
|
||||
newIvl = Math.max(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 + SM2_CONFIG.LEARNING_STEPS.length : 0,
|
||||
mod: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
scheduled = {
|
||||
cardId: card.id,
|
||||
newType: result.type,
|
||||
newQueue: result.queue,
|
||||
newDue: result.due,
|
||||
newIvl: result.ivl,
|
||||
newFactor: result.newFactor,
|
||||
newReps: card.reps + 1,
|
||||
newLapses: card.lapses,
|
||||
nextReviewDate: calculateNextReviewTime(result.ivl),
|
||||
};
|
||||
} else if (card.type === CardType.LEARNING || card.type === CardType.RELEARNING) {
|
||||
const result = scheduleLearningCard(ease, card.factor, card.left);
|
||||
updateData = {
|
||||
type: result.type,
|
||||
queue: result.queue,
|
||||
ivl: result.ivl,
|
||||
due: result.due,
|
||||
factor: result.newFactor,
|
||||
reps: card.reps + 1,
|
||||
left: result.newLeft,
|
||||
mod: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
scheduled = {
|
||||
cardId: card.id,
|
||||
newType: result.type,
|
||||
newQueue: result.queue,
|
||||
newDue: result.due,
|
||||
newIvl: result.ivl,
|
||||
newFactor: result.newFactor,
|
||||
newReps: card.reps + 1,
|
||||
newLapses: card.lapses,
|
||||
nextReviewDate: calculateNextReviewTime(result.ivl),
|
||||
};
|
||||
} else {
|
||||
const result = scheduleReviewCard(ease, card.ivl, card.factor, card.lapses);
|
||||
updateData = {
|
||||
type: result.type,
|
||||
queue: result.queue,
|
||||
ivl: result.ivl,
|
||||
due: result.due,
|
||||
factor: result.newFactor,
|
||||
reps: card.reps + 1,
|
||||
lapses: result.newLapses,
|
||||
left: result.type === CardType.RELEARNING ? SM2_CONFIG.LEARNING_STEPS.length * 1000 + SM2_CONFIG.LEARNING_STEPS.length : 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);
|
||||
}
|
||||
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;
|
||||
};
|
||||
169
src/modules/deck/deck-service.ts
Normal file
169
src/modules/deck/deck-service.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
"use server";
|
||||
|
||||
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,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;
|
||||
};
|
||||
106
src/modules/note-type/note-type-action-dto.ts
Normal file
106
src/modules/note-type/note-type-action-dto.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import z from "zod";
|
||||
import { generateValidator } from "@/utils/validate";
|
||||
import { NoteKind } from "../../../generated/prisma/enums";
|
||||
import {
|
||||
schemaNoteTypeField,
|
||||
schemaNoteTypeTemplate,
|
||||
NoteTypeField,
|
||||
NoteTypeTemplate,
|
||||
} from "./note-type-repository-dto";
|
||||
|
||||
export const LENGTH_MIN_NOTE_TYPE_NAME = 1;
|
||||
export const LENGTH_MAX_NOTE_TYPE_NAME = 100;
|
||||
export const LENGTH_MAX_CSS = 50000;
|
||||
|
||||
const schemaNoteTypeFieldAction = z.object({
|
||||
name: z.string().min(1).max(schemaNoteTypeField.name.maxLength),
|
||||
ord: z.number().int(),
|
||||
sticky: z.boolean(),
|
||||
rtl: z.boolean(),
|
||||
font: z.string().max(schemaNoteTypeField.font.maxLength).optional(),
|
||||
size: z.number().int().min(schemaNoteTypeField.size.min).max(schemaNoteTypeField.size.max).optional(),
|
||||
media: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const schemaNoteTypeTemplateAction = z.object({
|
||||
name: z.string().min(1).max(schemaNoteTypeTemplate.name.maxLength),
|
||||
ord: z.number().int(),
|
||||
qfmt: z.string().min(1).max(schemaNoteTypeTemplate.qfmt.maxLength),
|
||||
afmt: z.string().min(1).max(schemaNoteTypeTemplate.afmt.maxLength),
|
||||
bqfmt: z.string().max(schemaNoteTypeTemplate.bqfmt.maxLength).optional(),
|
||||
bafmt: z.string().max(schemaNoteTypeTemplate.bafmt.maxLength).optional(),
|
||||
did: z.number().int().optional(),
|
||||
});
|
||||
|
||||
export const schemaActionInputCreateNoteType = z.object({
|
||||
name: z.string().min(LENGTH_MIN_NOTE_TYPE_NAME).max(LENGTH_MAX_NOTE_TYPE_NAME),
|
||||
kind: z.enum(["STANDARD", "CLOZE"]).optional(),
|
||||
css: z.string().max(LENGTH_MAX_CSS).optional(),
|
||||
fields: z.array(schemaNoteTypeFieldAction).min(1),
|
||||
templates: z.array(schemaNoteTypeTemplateAction).min(1),
|
||||
});
|
||||
export type ActionInputCreateNoteType = z.infer<typeof schemaActionInputCreateNoteType>;
|
||||
export const validateActionInputCreateNoteType = generateValidator(schemaActionInputCreateNoteType);
|
||||
|
||||
export const schemaActionInputUpdateNoteType = z.object({
|
||||
id: z.number().int().positive(),
|
||||
name: z.string().min(LENGTH_MIN_NOTE_TYPE_NAME).max(LENGTH_MAX_NOTE_TYPE_NAME).optional(),
|
||||
kind: z.enum(["STANDARD", "CLOZE"]).optional(),
|
||||
css: z.string().max(LENGTH_MAX_CSS).optional(),
|
||||
fields: z.array(schemaNoteTypeFieldAction).min(1).optional(),
|
||||
templates: z.array(schemaNoteTypeTemplateAction).min(1).optional(),
|
||||
});
|
||||
export type ActionInputUpdateNoteType = z.infer<typeof schemaActionInputUpdateNoteType>;
|
||||
export const validateActionInputUpdateNoteType = generateValidator(schemaActionInputUpdateNoteType);
|
||||
|
||||
export const schemaActionInputGetNoteTypeById = z.object({
|
||||
id: z.number().int().positive(),
|
||||
});
|
||||
export type ActionInputGetNoteTypeById = z.infer<typeof schemaActionInputGetNoteTypeById>;
|
||||
export const validateActionInputGetNoteTypeById = generateValidator(schemaActionInputGetNoteTypeById);
|
||||
|
||||
export const schemaActionInputDeleteNoteType = z.object({
|
||||
id: z.number().int().positive(),
|
||||
});
|
||||
export type ActionInputDeleteNoteType = z.infer<typeof schemaActionInputDeleteNoteType>;
|
||||
export const validateActionInputDeleteNoteType = generateValidator(schemaActionInputDeleteNoteType);
|
||||
|
||||
export type ActionOutputNoteType = {
|
||||
id: number;
|
||||
name: string;
|
||||
kind: NoteKind;
|
||||
css: string;
|
||||
fields: NoteTypeField[];
|
||||
templates: NoteTypeTemplate[];
|
||||
userId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export type ActionOutputCreateNoteType = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: { id: number };
|
||||
};
|
||||
|
||||
export type ActionOutputUpdateNoteType = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ActionOutputGetNoteTypeById = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: ActionOutputNoteType;
|
||||
};
|
||||
|
||||
export type ActionOutputGetNoteTypesByUserId = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: ActionOutputNoteType[];
|
||||
};
|
||||
|
||||
export type ActionOutputDeleteNoteType = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
255
src/modules/note-type/note-type-action.ts
Normal file
255
src/modules/note-type/note-type-action.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { ValidateError } from "@/lib/errors";
|
||||
import {
|
||||
ActionInputCreateNoteType,
|
||||
ActionInputUpdateNoteType,
|
||||
ActionInputDeleteNoteType,
|
||||
ActionOutputCreateNoteType,
|
||||
ActionOutputUpdateNoteType,
|
||||
ActionOutputGetNoteTypeById,
|
||||
ActionOutputGetNoteTypesByUserId,
|
||||
ActionOutputDeleteNoteType,
|
||||
validateActionInputCreateNoteType,
|
||||
validateActionInputUpdateNoteType,
|
||||
validateActionInputDeleteNoteType,
|
||||
} from "./note-type-action-dto";
|
||||
import {
|
||||
serviceCreateNoteType,
|
||||
serviceUpdateNoteType,
|
||||
serviceGetNoteTypeById,
|
||||
serviceGetNoteTypesByUserId,
|
||||
serviceDeleteNoteType,
|
||||
} from "./note-type-service";
|
||||
import {
|
||||
DEFAULT_BASIC_NOTE_TYPE_FIELDS,
|
||||
DEFAULT_BASIC_NOTE_TYPE_TEMPLATES,
|
||||
DEFAULT_BASIC_NOTE_TYPE_CSS,
|
||||
DEFAULT_CLOZE_NOTE_TYPE_FIELDS,
|
||||
DEFAULT_CLOZE_NOTE_TYPE_TEMPLATES,
|
||||
DEFAULT_CLOZE_NOTE_TYPE_CSS,
|
||||
} from "./note-type-repository-dto";
|
||||
|
||||
const log = createLogger("note-type-action");
|
||||
|
||||
export async function actionCreateNoteType(
|
||||
input: ActionInputCreateNoteType,
|
||||
): Promise<ActionOutputCreateNoteType> {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
const validated = validateActionInputCreateNoteType(input);
|
||||
|
||||
const id = await serviceCreateNoteType({
|
||||
name: validated.name,
|
||||
kind: validated.kind,
|
||||
css: validated.css,
|
||||
fields: validated.fields,
|
||||
templates: validated.templates,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Note type created successfully",
|
||||
data: { id },
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return {
|
||||
success: false,
|
||||
message: e.message,
|
||||
};
|
||||
}
|
||||
log.error("Create note type failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to create note type",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionUpdateNoteType(
|
||||
input: ActionInputUpdateNoteType,
|
||||
): Promise<ActionOutputUpdateNoteType> {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
const validated = validateActionInputUpdateNoteType(input);
|
||||
|
||||
await serviceUpdateNoteType({
|
||||
id: validated.id,
|
||||
name: validated.name,
|
||||
kind: validated.kind,
|
||||
css: validated.css,
|
||||
fields: validated.fields,
|
||||
templates: validated.templates,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Note type updated successfully",
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return {
|
||||
success: false,
|
||||
message: e.message,
|
||||
};
|
||||
}
|
||||
log.error("Update note type failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to update note type",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetNoteTypeById(
|
||||
id: number,
|
||||
): Promise<ActionOutputGetNoteTypeById> {
|
||||
try {
|
||||
const noteType = await serviceGetNoteTypeById({ id });
|
||||
|
||||
if (!noteType) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Note type not found",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Note type retrieved successfully",
|
||||
data: noteType,
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Get note type failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to retrieve note type",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetNoteTypesByUserId(): Promise<ActionOutputGetNoteTypesByUserId> {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
const noteTypes = await serviceGetNoteTypesByUserId({
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Note types retrieved successfully",
|
||||
data: noteTypes,
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Get note types failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to retrieve note types",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionDeleteNoteType(
|
||||
input: ActionInputDeleteNoteType,
|
||||
): Promise<ActionOutputDeleteNoteType> {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
const validated = validateActionInputDeleteNoteType(input);
|
||||
|
||||
await serviceDeleteNoteType({
|
||||
id: validated.id,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Note type deleted successfully",
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return {
|
||||
success: false,
|
||||
message: e.message,
|
||||
};
|
||||
}
|
||||
log.error("Delete note type failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to delete note type",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionCreateDefaultBasicNoteType(): Promise<ActionOutputCreateNoteType> {
|
||||
return actionCreateNoteType({
|
||||
name: "Basic Vocabulary",
|
||||
kind: "STANDARD",
|
||||
css: DEFAULT_BASIC_NOTE_TYPE_CSS,
|
||||
fields: DEFAULT_BASIC_NOTE_TYPE_FIELDS,
|
||||
templates: DEFAULT_BASIC_NOTE_TYPE_TEMPLATES,
|
||||
});
|
||||
}
|
||||
|
||||
export async function actionCreateDefaultClozeNoteType(): Promise<ActionOutputCreateNoteType> {
|
||||
return actionCreateNoteType({
|
||||
name: "Cloze",
|
||||
kind: "CLOZE",
|
||||
css: DEFAULT_CLOZE_NOTE_TYPE_CSS,
|
||||
fields: DEFAULT_CLOZE_NOTE_TYPE_FIELDS,
|
||||
templates: DEFAULT_CLOZE_NOTE_TYPE_TEMPLATES,
|
||||
});
|
||||
}
|
||||
|
||||
export async function actionGetDefaultBasicNoteTypeTemplate() {
|
||||
return {
|
||||
name: "Basic Vocabulary",
|
||||
kind: "STANDARD" as const,
|
||||
css: DEFAULT_BASIC_NOTE_TYPE_CSS,
|
||||
fields: DEFAULT_BASIC_NOTE_TYPE_FIELDS,
|
||||
templates: DEFAULT_BASIC_NOTE_TYPE_TEMPLATES,
|
||||
};
|
||||
}
|
||||
|
||||
export async function actionGetDefaultClozeNoteTypeTemplate() {
|
||||
return {
|
||||
name: "Cloze",
|
||||
kind: "CLOZE" as const,
|
||||
css: DEFAULT_CLOZE_NOTE_TYPE_CSS,
|
||||
fields: DEFAULT_CLOZE_NOTE_TYPE_FIELDS,
|
||||
templates: DEFAULT_CLOZE_NOTE_TYPE_TEMPLATES,
|
||||
};
|
||||
}
|
||||
181
src/modules/note-type/note-type-repository-dto.ts
Normal file
181
src/modules/note-type/note-type-repository-dto.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { NoteKind } from "../../../generated/prisma/enums";
|
||||
|
||||
// ============================================
|
||||
// Field Schema (Anki flds structure)
|
||||
// ============================================
|
||||
|
||||
export interface NoteTypeField {
|
||||
name: string;
|
||||
ord: number;
|
||||
sticky: boolean;
|
||||
rtl: boolean;
|
||||
font?: string;
|
||||
size?: number;
|
||||
media?: string[];
|
||||
}
|
||||
|
||||
export const schemaNoteTypeField = {
|
||||
name: { minLength: 1, maxLength: 50 },
|
||||
font: { maxLength: 100 },
|
||||
size: { min: 8, max: 72 },
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Template Schema (Anki tmpls structure)
|
||||
// ============================================
|
||||
|
||||
export interface NoteTypeTemplate {
|
||||
name: string;
|
||||
ord: number;
|
||||
qfmt: string;
|
||||
afmt: string;
|
||||
bqfmt?: string;
|
||||
bafmt?: string;
|
||||
did?: number;
|
||||
}
|
||||
|
||||
export const schemaNoteTypeTemplate = {
|
||||
name: { minLength: 1, maxLength: 100 },
|
||||
qfmt: { maxLength: 10000 },
|
||||
afmt: { maxLength: 10000 },
|
||||
bqfmt: { maxLength: 10000 },
|
||||
bafmt: { maxLength: 10000 },
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Repository Input Types
|
||||
// ============================================
|
||||
|
||||
export interface RepoInputCreateNoteType {
|
||||
name: string;
|
||||
kind?: NoteKind;
|
||||
css?: string;
|
||||
fields: NoteTypeField[];
|
||||
templates: NoteTypeTemplate[];
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface RepoInputUpdateNoteType {
|
||||
id: number;
|
||||
name?: string;
|
||||
kind?: NoteKind;
|
||||
css?: string;
|
||||
fields?: NoteTypeField[];
|
||||
templates?: NoteTypeTemplate[];
|
||||
}
|
||||
|
||||
export interface RepoInputGetNoteTypeById {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface RepoInputGetNoteTypesByUserId {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface RepoInputDeleteNoteType {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface RepoInputCheckNotesExist {
|
||||
noteTypeId: number;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Repository Output Types
|
||||
// ============================================
|
||||
|
||||
export type RepoOutputNoteType = {
|
||||
id: number;
|
||||
name: string;
|
||||
kind: NoteKind;
|
||||
css: string;
|
||||
fields: NoteTypeField[];
|
||||
templates: NoteTypeTemplate[];
|
||||
userId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export type RepoOutputNoteTypeOwnership = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type RepoOutputNotesExistCheck = {
|
||||
exists: boolean;
|
||||
count: number;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Default Note Types
|
||||
// ============================================
|
||||
|
||||
export const DEFAULT_BASIC_NOTE_TYPE_FIELDS: NoteTypeField[] = [
|
||||
{ name: "Word", ord: 0, sticky: false, rtl: false, font: "Arial", size: 20 },
|
||||
{ name: "Definition", ord: 1, sticky: false, rtl: false, font: "Arial", size: 20 },
|
||||
{ name: "IPA", ord: 2, sticky: false, rtl: false, font: "Arial", size: 20 },
|
||||
{ name: "Example", ord: 3, sticky: false, rtl: false, font: "Arial", size: 20 },
|
||||
];
|
||||
|
||||
export const DEFAULT_BASIC_NOTE_TYPE_TEMPLATES: NoteTypeTemplate[] = [
|
||||
{
|
||||
name: "Word → Definition",
|
||||
ord: 0,
|
||||
qfmt: "{{Word}}<br>{{IPA}}",
|
||||
afmt: "{{FrontSide}}<hr id=answer>{{Definition}}<br><br>{{Example}}",
|
||||
},
|
||||
{
|
||||
name: "Definition → Word",
|
||||
ord: 1,
|
||||
qfmt: "{{Definition}}",
|
||||
afmt: "{{FrontSide}}<hr id=answer>{{Word}}<br>{{IPA}}",
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_BASIC_NOTE_TYPE_CSS = `.card {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 20px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.card1 {
|
||||
background-color: #e8f4f8;
|
||||
}
|
||||
|
||||
.card2 {
|
||||
background-color: #f8f4e8;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid #ccc;
|
||||
margin: 20px 0;
|
||||
}`;
|
||||
|
||||
export const DEFAULT_CLOZE_NOTE_TYPE_FIELDS: NoteTypeField[] = [
|
||||
{ name: "Text", ord: 0, sticky: false, rtl: false, font: "Arial", size: 20 },
|
||||
{ name: "Extra", ord: 1, sticky: false, rtl: false, font: "Arial", size: 20 },
|
||||
];
|
||||
|
||||
export const DEFAULT_CLOZE_NOTE_TYPE_TEMPLATES: NoteTypeTemplate[] = [
|
||||
{
|
||||
name: "Cloze",
|
||||
ord: 0,
|
||||
qfmt: "{{cloze:Text}}",
|
||||
afmt: "{{cloze:Text}}<br><br>{{Extra}}",
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_CLOZE_NOTE_TYPE_CSS = `.card {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 20px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.cloze {
|
||||
font-weight: bold;
|
||||
color: #0066cc;
|
||||
}`;
|
||||
151
src/modules/note-type/note-type-repository.ts
Normal file
151
src/modules/note-type/note-type-repository.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import {
|
||||
RepoInputCreateNoteType,
|
||||
RepoInputUpdateNoteType,
|
||||
RepoInputGetNoteTypeById,
|
||||
RepoInputGetNoteTypesByUserId,
|
||||
RepoInputDeleteNoteType,
|
||||
RepoInputCheckNotesExist,
|
||||
RepoOutputNoteType,
|
||||
RepoOutputNoteTypeOwnership,
|
||||
RepoOutputNotesExistCheck,
|
||||
NoteTypeField,
|
||||
NoteTypeTemplate,
|
||||
} from "./note-type-repository-dto";
|
||||
import { NoteKind } from "../../../generated/prisma/enums";
|
||||
|
||||
const log = createLogger("note-type-repository");
|
||||
|
||||
export async function repoCreateNoteType(
|
||||
input: RepoInputCreateNoteType,
|
||||
): Promise<number> {
|
||||
const noteType = await prisma.noteType.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
kind: input.kind ?? NoteKind.STANDARD,
|
||||
css: input.css ?? "",
|
||||
fields: input.fields as unknown as object,
|
||||
templates: input.templates as unknown as object,
|
||||
userId: input.userId,
|
||||
},
|
||||
});
|
||||
|
||||
log.info("Created note type", { id: noteType.id, name: noteType.name });
|
||||
return noteType.id;
|
||||
}
|
||||
|
||||
export async function repoUpdateNoteType(
|
||||
input: RepoInputUpdateNoteType,
|
||||
): Promise<void> {
|
||||
const updateData: {
|
||||
name?: string;
|
||||
kind?: NoteKind;
|
||||
css?: string;
|
||||
fields?: object;
|
||||
templates?: object;
|
||||
} = {};
|
||||
|
||||
if (input.name !== undefined) updateData.name = input.name;
|
||||
if (input.kind !== undefined) updateData.kind = input.kind;
|
||||
if (input.css !== undefined) updateData.css = input.css;
|
||||
if (input.fields !== undefined)
|
||||
updateData.fields = input.fields as unknown as object;
|
||||
if (input.templates !== undefined)
|
||||
updateData.templates = input.templates as unknown as object;
|
||||
|
||||
await prisma.noteType.update({
|
||||
where: { id: input.id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
log.info("Updated note type", { id: input.id });
|
||||
}
|
||||
|
||||
export async function repoGetNoteTypeById(
|
||||
input: RepoInputGetNoteTypeById,
|
||||
): Promise<RepoOutputNoteType | null> {
|
||||
const noteType = await prisma.noteType.findUnique({
|
||||
where: { id: input.id },
|
||||
});
|
||||
|
||||
if (!noteType) return null;
|
||||
|
||||
return {
|
||||
id: noteType.id,
|
||||
name: noteType.name,
|
||||
kind: noteType.kind,
|
||||
css: noteType.css,
|
||||
fields: noteType.fields as unknown as NoteTypeField[],
|
||||
templates: noteType.templates as unknown as NoteTypeTemplate[],
|
||||
userId: noteType.userId,
|
||||
createdAt: noteType.createdAt,
|
||||
updatedAt: noteType.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function repoGetNoteTypesByUserId(
|
||||
input: RepoInputGetNoteTypesByUserId,
|
||||
): Promise<RepoOutputNoteType[]> {
|
||||
const noteTypes = await prisma.noteType.findMany({
|
||||
where: { userId: input.userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return noteTypes.map((nt) => ({
|
||||
id: nt.id,
|
||||
name: nt.name,
|
||||
kind: nt.kind,
|
||||
css: nt.css,
|
||||
fields: nt.fields as unknown as NoteTypeField[],
|
||||
templates: nt.templates as unknown as NoteTypeTemplate[],
|
||||
userId: nt.userId,
|
||||
createdAt: nt.createdAt,
|
||||
updatedAt: nt.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function repoGetNoteTypeOwnership(
|
||||
noteTypeId: number,
|
||||
): Promise<RepoOutputNoteTypeOwnership | null> {
|
||||
const noteType = await prisma.noteType.findUnique({
|
||||
where: { id: noteTypeId },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
return noteType;
|
||||
}
|
||||
|
||||
export async function repoDeleteNoteType(
|
||||
input: RepoInputDeleteNoteType,
|
||||
): Promise<void> {
|
||||
await prisma.noteType.delete({
|
||||
where: { id: input.id },
|
||||
});
|
||||
|
||||
log.info("Deleted note type", { id: input.id });
|
||||
}
|
||||
|
||||
export async function repoCheckNotesExist(
|
||||
input: RepoInputCheckNotesExist,
|
||||
): Promise<RepoOutputNotesExistCheck> {
|
||||
const count = await prisma.note.count({
|
||||
where: { noteTypeId: input.noteTypeId },
|
||||
});
|
||||
|
||||
return {
|
||||
exists: count > 0,
|
||||
count,
|
||||
};
|
||||
}
|
||||
|
||||
export async function repoGetNoteTypeNameById(
|
||||
noteTypeId: number,
|
||||
): Promise<string | null> {
|
||||
const noteType = await prisma.noteType.findUnique({
|
||||
where: { id: noteTypeId },
|
||||
select: { name: true },
|
||||
});
|
||||
|
||||
return noteType?.name ?? null;
|
||||
}
|
||||
60
src/modules/note-type/note-type-service-dto.ts
Normal file
60
src/modules/note-type/note-type-service-dto.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { NoteKind } from "../../../generated/prisma/enums";
|
||||
import { NoteTypeField, NoteTypeTemplate } from "./note-type-repository-dto";
|
||||
|
||||
export type ServiceInputCreateNoteType = {
|
||||
name: string;
|
||||
kind?: NoteKind;
|
||||
css?: string;
|
||||
fields: NoteTypeField[];
|
||||
templates: NoteTypeTemplate[];
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceInputUpdateNoteType = {
|
||||
id: number;
|
||||
name?: string;
|
||||
kind?: NoteKind;
|
||||
css?: string;
|
||||
fields?: NoteTypeField[];
|
||||
templates?: NoteTypeTemplate[];
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceInputGetNoteTypeById = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
export type ServiceInputGetNoteTypesByUserId = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceInputDeleteNoteType = {
|
||||
id: number;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceInputValidateFields = {
|
||||
fields: NoteTypeField[];
|
||||
};
|
||||
|
||||
export type ServiceInputValidateTemplates = {
|
||||
templates: NoteTypeTemplate[];
|
||||
fields: NoteTypeField[];
|
||||
};
|
||||
|
||||
export type ServiceOutputNoteType = {
|
||||
id: number;
|
||||
name: string;
|
||||
kind: NoteKind;
|
||||
css: string;
|
||||
fields: NoteTypeField[];
|
||||
templates: NoteTypeTemplate[];
|
||||
userId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export type ServiceOutputValidation = {
|
||||
success: boolean;
|
||||
errors: string[];
|
||||
};
|
||||
272
src/modules/note-type/note-type-service.ts
Normal file
272
src/modules/note-type/note-type-service.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { ValidateError } from "@/lib/errors";
|
||||
import {
|
||||
repoCreateNoteType,
|
||||
repoGetNoteTypeById,
|
||||
repoGetNoteTypesByUserId,
|
||||
repoUpdateNoteType,
|
||||
repoDeleteNoteType,
|
||||
repoGetNoteTypeOwnership,
|
||||
repoCheckNotesExist,
|
||||
} from "./note-type-repository";
|
||||
import {
|
||||
ServiceInputCreateNoteType,
|
||||
ServiceInputUpdateNoteType,
|
||||
ServiceInputGetNoteTypeById,
|
||||
ServiceInputGetNoteTypesByUserId,
|
||||
ServiceInputDeleteNoteType,
|
||||
ServiceInputValidateFields,
|
||||
ServiceInputValidateTemplates,
|
||||
ServiceOutputNoteType,
|
||||
ServiceOutputValidation,
|
||||
} from "./note-type-service-dto";
|
||||
import { schemaNoteTypeField, schemaNoteTypeTemplate } from "./note-type-repository-dto";
|
||||
|
||||
const log = createLogger("note-type-service");
|
||||
|
||||
export function serviceValidateFields(
|
||||
input: ServiceInputValidateFields,
|
||||
): ServiceOutputValidation {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!Array.isArray(input.fields) || input.fields.length === 0) {
|
||||
errors.push("Fields must be a non-empty array");
|
||||
return { success: false, errors };
|
||||
}
|
||||
|
||||
const seenNames = new Set<string>();
|
||||
const seenOrds = new Set<number>();
|
||||
|
||||
for (let i = 0; i < input.fields.length; i++) {
|
||||
const field = input.fields[i];
|
||||
|
||||
if (!field.name || field.name.trim().length === 0) {
|
||||
errors.push(`Field ${i}: name is required`);
|
||||
} else if (field.name.length > schemaNoteTypeField.name.maxLength) {
|
||||
errors.push(`Field ${i}: name exceeds maximum length of ${schemaNoteTypeField.name.maxLength}`);
|
||||
}
|
||||
|
||||
if (seenNames.has(field.name)) {
|
||||
errors.push(`Field ${i}: duplicate field name "${field.name}"`);
|
||||
}
|
||||
seenNames.add(field.name);
|
||||
|
||||
if (typeof field.ord !== "number") {
|
||||
errors.push(`Field ${i}: ord must be a number`);
|
||||
} else if (seenOrds.has(field.ord)) {
|
||||
errors.push(`Field ${i}: duplicate ordinal ${field.ord}`);
|
||||
}
|
||||
seenOrds.add(field.ord);
|
||||
|
||||
if (typeof field.sticky !== "boolean") {
|
||||
errors.push(`Field ${i}: sticky must be a boolean`);
|
||||
}
|
||||
|
||||
if (typeof field.rtl !== "boolean") {
|
||||
errors.push(`Field ${i}: rtl must be a boolean`);
|
||||
}
|
||||
|
||||
if (field.font && field.font.length > schemaNoteTypeField.font.maxLength) {
|
||||
errors.push(`Field ${i}: font exceeds maximum length`);
|
||||
}
|
||||
|
||||
if (field.size !== undefined && (field.size < schemaNoteTypeField.size.min || field.size > schemaNoteTypeField.size.max)) {
|
||||
errors.push(`Field ${i}: size must be between ${schemaNoteTypeField.size.min} and ${schemaNoteTypeField.size.max}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
export function serviceValidateTemplates(
|
||||
input: ServiceInputValidateTemplates,
|
||||
): ServiceOutputValidation {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!Array.isArray(input.templates) || input.templates.length === 0) {
|
||||
errors.push("Templates must be a non-empty array");
|
||||
return { success: false, errors };
|
||||
}
|
||||
|
||||
const fieldNames = new Set(input.fields.map((f) => f.name));
|
||||
const seenNames = new Set<string>();
|
||||
const seenOrds = new Set<number>();
|
||||
|
||||
const mustachePattern = /\{\{([^}]+)\}\}/g;
|
||||
|
||||
for (let i = 0; i < input.templates.length; i++) {
|
||||
const template = input.templates[i];
|
||||
|
||||
if (!template.name || template.name.trim().length === 0) {
|
||||
errors.push(`Template ${i}: name is required`);
|
||||
} else if (template.name.length > schemaNoteTypeTemplate.name.maxLength) {
|
||||
errors.push(`Template ${i}: name exceeds maximum length`);
|
||||
}
|
||||
|
||||
if (seenNames.has(template.name)) {
|
||||
errors.push(`Template ${i}: duplicate template name "${template.name}"`);
|
||||
}
|
||||
seenNames.add(template.name);
|
||||
|
||||
if (typeof template.ord !== "number") {
|
||||
errors.push(`Template ${i}: ord must be a number`);
|
||||
} else if (seenOrds.has(template.ord)) {
|
||||
errors.push(`Template ${i}: duplicate ordinal ${template.ord}`);
|
||||
}
|
||||
seenOrds.add(template.ord);
|
||||
|
||||
if (!template.qfmt || template.qfmt.trim().length === 0) {
|
||||
errors.push(`Template ${i}: qfmt (question format) is required`);
|
||||
} else if (template.qfmt.length > schemaNoteTypeTemplate.qfmt.maxLength) {
|
||||
errors.push(`Template ${i}: qfmt exceeds maximum length`);
|
||||
}
|
||||
|
||||
if (!template.afmt || template.afmt.trim().length === 0) {
|
||||
errors.push(`Template ${i}: afmt (answer format) is required`);
|
||||
} else if (template.afmt.length > schemaNoteTypeTemplate.afmt.maxLength) {
|
||||
errors.push(`Template ${i}: afmt exceeds maximum length`);
|
||||
}
|
||||
|
||||
const qfmtMatches = template.qfmt.match(mustachePattern) || [];
|
||||
const afmtMatches = template.afmt.match(mustachePattern) || [];
|
||||
const allMatches = [...qfmtMatches, ...afmtMatches];
|
||||
|
||||
for (const match of allMatches) {
|
||||
const content = match.slice(2, -2).trim();
|
||||
|
||||
if (content.startsWith("cloze:")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content === "FrontSide") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content.startsWith("type:")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fieldNames.has(content)) {
|
||||
log.warn(`Template ${i}: unknown field reference "{{${content}}}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
export async function serviceCreateNoteType(
|
||||
input: ServiceInputCreateNoteType,
|
||||
): Promise<number> {
|
||||
const fieldsValidation = serviceValidateFields({ fields: input.fields });
|
||||
if (!fieldsValidation.success) {
|
||||
throw new ValidateError(`Invalid fields: ${fieldsValidation.errors.join("; ")}`);
|
||||
}
|
||||
|
||||
const templatesValidation = serviceValidateTemplates({
|
||||
templates: input.templates,
|
||||
fields: input.fields,
|
||||
});
|
||||
if (!templatesValidation.success) {
|
||||
throw new ValidateError(`Invalid templates: ${templatesValidation.errors.join("; ")}`);
|
||||
}
|
||||
|
||||
log.info("Creating note type", { name: input.name, userId: input.userId });
|
||||
|
||||
return repoCreateNoteType({
|
||||
name: input.name,
|
||||
kind: input.kind,
|
||||
css: input.css,
|
||||
fields: input.fields,
|
||||
templates: input.templates,
|
||||
userId: input.userId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function serviceUpdateNoteType(
|
||||
input: ServiceInputUpdateNoteType,
|
||||
): Promise<void> {
|
||||
const ownership = await repoGetNoteTypeOwnership(input.id);
|
||||
if (!ownership) {
|
||||
throw new ValidateError("Note type not found");
|
||||
}
|
||||
|
||||
if (ownership.userId !== input.userId) {
|
||||
throw new ValidateError("You do not have permission to update this note type");
|
||||
}
|
||||
|
||||
if (input.fields) {
|
||||
const fieldsValidation = serviceValidateFields({ fields: input.fields });
|
||||
if (!fieldsValidation.success) {
|
||||
throw new ValidateError(`Invalid fields: ${fieldsValidation.errors.join("; ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.templates && input.fields) {
|
||||
const templatesValidation = serviceValidateTemplates({
|
||||
templates: input.templates,
|
||||
fields: input.fields,
|
||||
});
|
||||
if (!templatesValidation.success) {
|
||||
throw new ValidateError(`Invalid templates: ${templatesValidation.errors.join("; ")}`);
|
||||
}
|
||||
} else if (input.templates) {
|
||||
const existing = await repoGetNoteTypeById({ id: input.id });
|
||||
if (existing) {
|
||||
const templatesValidation = serviceValidateTemplates({
|
||||
templates: input.templates,
|
||||
fields: existing.fields,
|
||||
});
|
||||
if (!templatesValidation.success) {
|
||||
throw new ValidateError(`Invalid templates: ${templatesValidation.errors.join("; ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Updating note type", { id: input.id });
|
||||
|
||||
await repoUpdateNoteType({
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
kind: input.kind,
|
||||
css: input.css,
|
||||
fields: input.fields,
|
||||
templates: input.templates,
|
||||
});
|
||||
}
|
||||
|
||||
export async function serviceGetNoteTypeById(
|
||||
input: ServiceInputGetNoteTypeById,
|
||||
): Promise<ServiceOutputNoteType | null> {
|
||||
return repoGetNoteTypeById(input);
|
||||
}
|
||||
|
||||
export async function serviceGetNoteTypesByUserId(
|
||||
input: ServiceInputGetNoteTypesByUserId,
|
||||
): Promise<ServiceOutputNoteType[]> {
|
||||
return repoGetNoteTypesByUserId(input);
|
||||
}
|
||||
|
||||
export async function serviceDeleteNoteType(
|
||||
input: ServiceInputDeleteNoteType,
|
||||
): Promise<void> {
|
||||
const ownership = await repoGetNoteTypeOwnership(input.id);
|
||||
if (!ownership) {
|
||||
throw new ValidateError("Note type not found");
|
||||
}
|
||||
|
||||
if (ownership.userId !== input.userId) {
|
||||
throw new ValidateError("You do not have permission to delete this note type");
|
||||
}
|
||||
|
||||
const notesCheck = await repoCheckNotesExist({ noteTypeId: input.id });
|
||||
if (notesCheck.exists) {
|
||||
throw new ValidateError(
|
||||
`Cannot delete note type: ${notesCheck.count} notes are using this type`,
|
||||
);
|
||||
}
|
||||
|
||||
log.info("Deleting note type", { id: input.id });
|
||||
|
||||
await repoDeleteNoteType({ id: input.id });
|
||||
}
|
||||
131
src/modules/note/note-action-dto.ts
Normal file
131
src/modules/note/note-action-dto.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { generateValidator } from "@/utils/validate";
|
||||
import z from "zod";
|
||||
|
||||
export const LENGTH_MAX_NOTE_FIELD = 65535;
|
||||
export const LENGTH_MIN_NOTE_FIELD = 0;
|
||||
export const LENGTH_MAX_TAG = 100;
|
||||
export const MAX_FIELDS = 100;
|
||||
export const MAX_TAGS = 100;
|
||||
|
||||
export const schemaActionInputCreateNote = z.object({
|
||||
noteTypeId: z.number().int().positive(),
|
||||
fields: z
|
||||
.array(z.string().max(LENGTH_MAX_NOTE_FIELD))
|
||||
.min(1)
|
||||
.max(MAX_FIELDS),
|
||||
tags: z.array(z.string().max(LENGTH_MAX_TAG)).max(MAX_TAGS).optional(),
|
||||
});
|
||||
export type ActionInputCreateNote = z.infer<typeof schemaActionInputCreateNote>;
|
||||
export const validateActionInputCreateNote = generateValidator(
|
||||
schemaActionInputCreateNote,
|
||||
);
|
||||
|
||||
export const schemaActionInputUpdateNote = z.object({
|
||||
noteId: z.bigint(),
|
||||
fields: z
|
||||
.array(z.string().max(LENGTH_MAX_NOTE_FIELD))
|
||||
.min(1)
|
||||
.max(MAX_FIELDS)
|
||||
.optional(),
|
||||
tags: z.array(z.string().max(LENGTH_MAX_TAG)).max(MAX_TAGS).optional(),
|
||||
});
|
||||
export type ActionInputUpdateNote = z.infer<typeof schemaActionInputUpdateNote>;
|
||||
export const validateActionInputUpdateNote = generateValidator(
|
||||
schemaActionInputUpdateNote,
|
||||
);
|
||||
|
||||
export const schemaActionInputDeleteNote = z.object({
|
||||
noteId: z.bigint(),
|
||||
});
|
||||
export type ActionInputDeleteNote = z.infer<typeof schemaActionInputDeleteNote>;
|
||||
export const validateActionInputDeleteNote = generateValidator(
|
||||
schemaActionInputDeleteNote,
|
||||
);
|
||||
|
||||
export const schemaActionInputGetNoteById = z.object({
|
||||
noteId: z.bigint(),
|
||||
});
|
||||
export type ActionInputGetNoteById = z.infer<typeof schemaActionInputGetNoteById>;
|
||||
export const validateActionInputGetNoteById = generateValidator(
|
||||
schemaActionInputGetNoteById,
|
||||
);
|
||||
|
||||
export const schemaActionInputGetNotesByNoteTypeId = z.object({
|
||||
noteTypeId: z.number().int().positive(),
|
||||
limit: z.number().int().positive().max(1000).optional(),
|
||||
offset: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
export type ActionInputGetNotesByNoteTypeId = z.infer<
|
||||
typeof schemaActionInputGetNotesByNoteTypeId
|
||||
>;
|
||||
export const validateActionInputGetNotesByNoteTypeId = generateValidator(
|
||||
schemaActionInputGetNotesByNoteTypeId,
|
||||
);
|
||||
|
||||
export const schemaActionInputGetNotesByUserId = z.object({
|
||||
userId: z.string().min(1),
|
||||
limit: z.number().int().positive().max(1000).optional(),
|
||||
offset: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
export type ActionInputGetNotesByUserId = z.infer<
|
||||
typeof schemaActionInputGetNotesByUserId
|
||||
>;
|
||||
export const validateActionInputGetNotesByUserId = generateValidator(
|
||||
schemaActionInputGetNotesByUserId,
|
||||
);
|
||||
|
||||
export type ActionOutputNote = {
|
||||
id: string;
|
||||
guid: string;
|
||||
noteTypeId: number;
|
||||
mod: number;
|
||||
usn: number;
|
||||
tags: string[];
|
||||
fields: string[];
|
||||
sfld: string;
|
||||
csum: number;
|
||||
flags: number;
|
||||
data: string;
|
||||
userId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export type ActionOutputCreateNote = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: {
|
||||
id: string;
|
||||
guid: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ActionOutputUpdateNote = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
export type ActionOutputDeleteNote = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
export type ActionOutputGetNoteById = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: ActionOutputNote;
|
||||
};
|
||||
|
||||
export type ActionOutputGetNotes = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: ActionOutputNote[];
|
||||
};
|
||||
|
||||
export type ActionOutputNoteCount = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: {
|
||||
count: number;
|
||||
};
|
||||
};
|
||||
344
src/modules/note/note-action.ts
Normal file
344
src/modules/note/note-action.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { ValidateError } from "@/lib/errors";
|
||||
import {
|
||||
ActionInputCreateNote,
|
||||
ActionInputUpdateNote,
|
||||
ActionInputDeleteNote,
|
||||
ActionInputGetNoteById,
|
||||
ActionInputGetNotesByNoteTypeId,
|
||||
ActionInputGetNotesByUserId,
|
||||
ActionOutputCreateNote,
|
||||
ActionOutputUpdateNote,
|
||||
ActionOutputDeleteNote,
|
||||
ActionOutputGetNoteById,
|
||||
ActionOutputGetNotes,
|
||||
ActionOutputNoteCount,
|
||||
ActionOutputNote,
|
||||
validateActionInputCreateNote,
|
||||
validateActionInputUpdateNote,
|
||||
validateActionInputDeleteNote,
|
||||
validateActionInputGetNoteById,
|
||||
validateActionInputGetNotesByNoteTypeId,
|
||||
validateActionInputGetNotesByUserId,
|
||||
} from "./note-action-dto";
|
||||
import {
|
||||
serviceCreateNote,
|
||||
serviceUpdateNote,
|
||||
serviceDeleteNote,
|
||||
serviceGetNoteById,
|
||||
serviceGetNotesByNoteTypeId,
|
||||
serviceGetNotesByUserId,
|
||||
serviceCountNotesByUserId,
|
||||
serviceCountNotesByNoteTypeId,
|
||||
NoteNotFoundError,
|
||||
NoteOwnershipError,
|
||||
} from "./note-service";
|
||||
|
||||
const log = createLogger("note-action");
|
||||
|
||||
function mapNoteToOutput(note: {
|
||||
id: bigint;
|
||||
guid: string;
|
||||
noteTypeId: number;
|
||||
mod: number;
|
||||
usn: number;
|
||||
tags: string[];
|
||||
fields: string[];
|
||||
sfld: string;
|
||||
csum: number;
|
||||
flags: number;
|
||||
data: string;
|
||||
userId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): ActionOutputNote {
|
||||
return {
|
||||
id: note.id.toString(),
|
||||
guid: note.guid,
|
||||
noteTypeId: note.noteTypeId,
|
||||
mod: note.mod,
|
||||
usn: note.usn,
|
||||
tags: note.tags,
|
||||
fields: note.fields,
|
||||
sfld: note.sfld,
|
||||
csum: note.csum,
|
||||
flags: note.flags,
|
||||
data: note.data,
|
||||
userId: note.userId,
|
||||
createdAt: note.createdAt,
|
||||
updatedAt: note.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function requireAuth(): Promise<string> {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
return session.user.id;
|
||||
}
|
||||
|
||||
export async function actionCreateNote(
|
||||
input: unknown,
|
||||
): Promise<ActionOutputCreateNote> {
|
||||
try {
|
||||
const userId = await requireAuth();
|
||||
const validated = validateActionInputCreateNote(input);
|
||||
|
||||
log.debug("Creating note", { userId, noteTypeId: validated.noteTypeId });
|
||||
|
||||
const result = await serviceCreateNote({
|
||||
...validated,
|
||||
userId,
|
||||
});
|
||||
|
||||
log.info("Note created", { id: result.id.toString(), guid: result.guid });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Note created successfully",
|
||||
data: {
|
||||
id: result.id.toString(),
|
||||
guid: result.guid,
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
if (e instanceof Error && e.message === "Unauthorized") {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
log.error("Failed to create note", { error: e });
|
||||
return { success: false, message: "An unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionUpdateNote(
|
||||
input: unknown,
|
||||
): Promise<ActionOutputUpdateNote> {
|
||||
try {
|
||||
const userId = await requireAuth();
|
||||
const validated = validateActionInputUpdateNote(input);
|
||||
|
||||
log.debug("Updating note", { noteId: validated.noteId.toString() });
|
||||
|
||||
await serviceUpdateNote({
|
||||
...validated,
|
||||
userId,
|
||||
});
|
||||
|
||||
log.info("Note updated", { noteId: validated.noteId.toString() });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Note updated successfully",
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
if (e instanceof NoteNotFoundError) {
|
||||
return { success: false, message: "Note not found" };
|
||||
}
|
||||
if (e instanceof NoteOwnershipError) {
|
||||
return { success: false, message: "You do not have permission to update this note" };
|
||||
}
|
||||
if (e instanceof Error && e.message === "Unauthorized") {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
log.error("Failed to update note", { error: e });
|
||||
return { success: false, message: "An unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionDeleteNote(
|
||||
input: unknown,
|
||||
): Promise<ActionOutputDeleteNote> {
|
||||
try {
|
||||
const userId = await requireAuth();
|
||||
const validated = validateActionInputDeleteNote(input);
|
||||
|
||||
log.debug("Deleting note", { noteId: validated.noteId.toString() });
|
||||
|
||||
await serviceDeleteNote({
|
||||
...validated,
|
||||
userId,
|
||||
});
|
||||
|
||||
log.info("Note deleted", { noteId: validated.noteId.toString() });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Note deleted successfully",
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
if (e instanceof NoteNotFoundError) {
|
||||
return { success: false, message: "Note not found" };
|
||||
}
|
||||
if (e instanceof NoteOwnershipError) {
|
||||
return { success: false, message: "You do not have permission to delete this note" };
|
||||
}
|
||||
if (e instanceof Error && e.message === "Unauthorized") {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
log.error("Failed to delete note", { error: e });
|
||||
return { success: false, message: "An unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetNoteById(
|
||||
input: unknown,
|
||||
): Promise<ActionOutputGetNoteById> {
|
||||
try {
|
||||
const validated = validateActionInputGetNoteById(input);
|
||||
|
||||
log.debug("Fetching note", { noteId: validated.noteId.toString() });
|
||||
|
||||
const note = await serviceGetNoteById(validated);
|
||||
|
||||
if (!note) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Note not found",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Note retrieved successfully",
|
||||
data: mapNoteToOutput(note),
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to get note", { error: e });
|
||||
return { success: false, message: "An unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetNotesByNoteTypeId(
|
||||
input: unknown,
|
||||
): Promise<ActionOutputGetNotes> {
|
||||
try {
|
||||
const validated = validateActionInputGetNotesByNoteTypeId(input);
|
||||
|
||||
log.debug("Fetching notes by note type", { noteTypeId: validated.noteTypeId });
|
||||
|
||||
const notes = await serviceGetNotesByNoteTypeId(validated);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Notes retrieved successfully",
|
||||
data: notes.map(mapNoteToOutput),
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to get notes by note type", { error: e });
|
||||
return { success: false, message: "An unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetNotesByUserId(
|
||||
input: unknown,
|
||||
): Promise<ActionOutputGetNotes> {
|
||||
try {
|
||||
const validated = validateActionInputGetNotesByUserId(input);
|
||||
|
||||
log.debug("Fetching notes by user", { userId: validated.userId });
|
||||
|
||||
const notes = await serviceGetNotesByUserId(validated);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Notes retrieved successfully",
|
||||
data: notes.map(mapNoteToOutput),
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
log.error("Failed to get notes by user", { error: e });
|
||||
return { success: false, message: "An unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetMyNotes(
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
): Promise<ActionOutputGetNotes> {
|
||||
try {
|
||||
const userId = await requireAuth();
|
||||
|
||||
log.debug("Fetching current user's notes", { userId, limit, offset });
|
||||
|
||||
const notes = await serviceGetNotesByUserId({
|
||||
userId,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Notes retrieved successfully",
|
||||
data: notes.map(mapNoteToOutput),
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "Unauthorized") {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
log.error("Failed to get user's notes", { error: e });
|
||||
return { success: false, message: "An unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetMyNoteCount(): Promise<ActionOutputNoteCount> {
|
||||
try {
|
||||
const userId = await requireAuth();
|
||||
|
||||
log.debug("Counting current user's notes", { userId });
|
||||
|
||||
const result = await serviceCountNotesByUserId(userId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Note count retrieved successfully",
|
||||
data: { count: result.count },
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "Unauthorized") {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
log.error("Failed to count user's notes", { error: e });
|
||||
return { success: false, message: "An unknown error occurred" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetNoteCountByNoteType(
|
||||
noteTypeId: number,
|
||||
): Promise<ActionOutputNoteCount> {
|
||||
try {
|
||||
log.debug("Counting notes by note type", { noteTypeId });
|
||||
|
||||
const result = await serviceCountNotesByNoteTypeId(noteTypeId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Note count retrieved successfully",
|
||||
data: { count: result.count },
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Failed to count notes by note type", { error: e });
|
||||
return { success: false, message: "An unknown error occurred" };
|
||||
}
|
||||
}
|
||||
72
src/modules/note/note-repository-dto.ts
Normal file
72
src/modules/note/note-repository-dto.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// Repository layer DTOs for Note module
|
||||
// Follows Anki-compatible note structure with BigInt IDs
|
||||
|
||||
export interface RepoInputCreateNote {
|
||||
noteTypeId: number;
|
||||
fields: string[];
|
||||
tags?: string[];
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface RepoInputUpdateNote {
|
||||
id: bigint;
|
||||
fields?: string[];
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface RepoInputGetNoteById {
|
||||
id: bigint;
|
||||
}
|
||||
|
||||
export interface RepoInputGetNotesByNoteTypeId {
|
||||
noteTypeId: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface RepoInputGetNotesByUserId {
|
||||
userId: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface RepoInputDeleteNote {
|
||||
id: bigint;
|
||||
}
|
||||
|
||||
export interface RepoInputCheckNoteOwnership {
|
||||
noteId: bigint;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export type RepoOutputNote = {
|
||||
id: bigint;
|
||||
guid: string;
|
||||
noteTypeId: number;
|
||||
mod: number;
|
||||
usn: number;
|
||||
tags: string;
|
||||
flds: string;
|
||||
sfld: string;
|
||||
csum: number;
|
||||
flags: number;
|
||||
data: string;
|
||||
userId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export type RepoOutputNoteWithFields = Omit<RepoOutputNote, "flds" | "tags"> & {
|
||||
fields: string[];
|
||||
tagsArray: string[];
|
||||
};
|
||||
|
||||
export type RepoOutputNoteOwnership = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
// Helper function types
|
||||
export type RepoHelperGenerateGuid = () => string;
|
||||
export type RepoHelperCalculateCsum = (text: string) => number;
|
||||
export type RepoHelperJoinFields = (fields: string[]) => string;
|
||||
export type RepoHelperSplitFields = (flds: string) => string[];
|
||||
283
src/modules/note/note-repository.ts
Normal file
283
src/modules/note/note-repository.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { createHash } from "crypto";
|
||||
import {
|
||||
RepoInputCreateNote,
|
||||
RepoInputUpdateNote,
|
||||
RepoInputGetNoteById,
|
||||
RepoInputGetNotesByNoteTypeId,
|
||||
RepoInputGetNotesByUserId,
|
||||
RepoInputDeleteNote,
|
||||
RepoInputCheckNoteOwnership,
|
||||
RepoOutputNote,
|
||||
RepoOutputNoteWithFields,
|
||||
RepoOutputNoteOwnership,
|
||||
} from "./note-repository-dto";
|
||||
|
||||
const log = createLogger("note-repository");
|
||||
|
||||
const FIELD_SEPARATOR = "\x1f";
|
||||
const BASE91_CHARS =
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&()*+,-./:;<=>?@[]^_`{|}~";
|
||||
|
||||
export function repoGenerateGuid(): string {
|
||||
let guid = "";
|
||||
const bytes = new Uint8Array(10);
|
||||
crypto.getRandomValues(bytes);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
guid += BASE91_CHARS[bytes[i] % BASE91_CHARS.length];
|
||||
}
|
||||
return guid;
|
||||
}
|
||||
|
||||
export function repoCalculateCsum(text: string): number {
|
||||
const hash = createHash("sha1").update(text.normalize("NFC")).digest("hex");
|
||||
return parseInt(hash.substring(0, 8), 16);
|
||||
}
|
||||
|
||||
export function repoJoinFields(fields: string[]): string {
|
||||
return fields.join(FIELD_SEPARATOR);
|
||||
}
|
||||
|
||||
export function repoSplitFields(flds: string): string[] {
|
||||
return flds.split(FIELD_SEPARATOR);
|
||||
}
|
||||
|
||||
export async function repoCreateNote(
|
||||
input: RepoInputCreateNote,
|
||||
): Promise<bigint> {
|
||||
const now = Date.now();
|
||||
const id = BigInt(now);
|
||||
const guid = repoGenerateGuid();
|
||||
const flds = repoJoinFields(input.fields);
|
||||
const sfld = input.fields[0] ?? "";
|
||||
const csum = repoCalculateCsum(sfld);
|
||||
const tags = input.tags?.join(" ") ?? " ";
|
||||
|
||||
log.debug("Creating note", { id: id.toString(), guid, noteTypeId: input.noteTypeId });
|
||||
|
||||
await prisma.note.create({
|
||||
data: {
|
||||
id,
|
||||
guid,
|
||||
noteTypeId: input.noteTypeId,
|
||||
mod: Math.floor(now / 1000),
|
||||
usn: -1,
|
||||
tags,
|
||||
flds,
|
||||
sfld,
|
||||
csum,
|
||||
flags: 0,
|
||||
data: "",
|
||||
userId: input.userId,
|
||||
},
|
||||
});
|
||||
|
||||
log.info("Note created", { id: id.toString(), guid });
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function repoUpdateNote(input: RepoInputUpdateNote): Promise<void> {
|
||||
const now = Date.now();
|
||||
const updateData: {
|
||||
mod?: number;
|
||||
usn?: number;
|
||||
flds?: string;
|
||||
sfld?: string;
|
||||
csum?: number;
|
||||
tags?: string;
|
||||
} = {
|
||||
mod: Math.floor(now / 1000),
|
||||
usn: -1,
|
||||
};
|
||||
|
||||
if (input.fields) {
|
||||
updateData.flds = repoJoinFields(input.fields);
|
||||
updateData.sfld = input.fields[0] ?? "";
|
||||
updateData.csum = repoCalculateCsum(updateData.sfld);
|
||||
}
|
||||
|
||||
if (input.tags) {
|
||||
updateData.tags = input.tags.join(" ");
|
||||
}
|
||||
|
||||
log.debug("Updating note", { id: input.id.toString() });
|
||||
|
||||
await prisma.note.update({
|
||||
where: { id: input.id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
log.info("Note updated", { id: input.id.toString() });
|
||||
}
|
||||
|
||||
export async function repoGetNoteById(
|
||||
input: RepoInputGetNoteById,
|
||||
): Promise<RepoOutputNote | null> {
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: input.id },
|
||||
});
|
||||
|
||||
if (!note) {
|
||||
log.debug("Note not found", { id: input.id.toString() });
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: note.id,
|
||||
guid: note.guid,
|
||||
noteTypeId: note.noteTypeId,
|
||||
mod: note.mod,
|
||||
usn: note.usn,
|
||||
tags: note.tags,
|
||||
flds: note.flds,
|
||||
sfld: note.sfld,
|
||||
csum: note.csum,
|
||||
flags: note.flags,
|
||||
data: note.data,
|
||||
userId: note.userId,
|
||||
createdAt: note.createdAt,
|
||||
updatedAt: note.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function repoGetNoteByIdWithFields(
|
||||
input: RepoInputGetNoteById,
|
||||
): Promise<RepoOutputNoteWithFields | null> {
|
||||
const note = await repoGetNoteById(input);
|
||||
if (!note) return null;
|
||||
|
||||
return {
|
||||
...note,
|
||||
fields: repoSplitFields(note.flds),
|
||||
tagsArray: note.tags.trim() === "" ? [] : note.tags.trim().split(" "),
|
||||
};
|
||||
}
|
||||
|
||||
export async function repoGetNotesByNoteTypeId(
|
||||
input: RepoInputGetNotesByNoteTypeId,
|
||||
): Promise<RepoOutputNote[]> {
|
||||
const { noteTypeId, limit = 50, offset = 0 } = input;
|
||||
|
||||
log.debug("Fetching notes by note type", { noteTypeId, limit, offset });
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { noteTypeId },
|
||||
orderBy: { id: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
log.info("Fetched notes by note type", { noteTypeId, count: notes.length });
|
||||
|
||||
return notes.map((note) => ({
|
||||
id: note.id,
|
||||
guid: note.guid,
|
||||
noteTypeId: note.noteTypeId,
|
||||
mod: note.mod,
|
||||
usn: note.usn,
|
||||
tags: note.tags,
|
||||
flds: note.flds,
|
||||
sfld: note.sfld,
|
||||
csum: note.csum,
|
||||
flags: note.flags,
|
||||
data: note.data,
|
||||
userId: note.userId,
|
||||
createdAt: note.createdAt,
|
||||
updatedAt: note.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function repoGetNotesByUserId(
|
||||
input: RepoInputGetNotesByUserId,
|
||||
): Promise<RepoOutputNote[]> {
|
||||
const { userId, limit = 50, offset = 0 } = input;
|
||||
|
||||
log.debug("Fetching notes by user", { userId, limit, offset });
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { userId },
|
||||
orderBy: { id: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
log.info("Fetched notes by user", { userId, count: notes.length });
|
||||
|
||||
return notes.map((note) => ({
|
||||
id: note.id,
|
||||
guid: note.guid,
|
||||
noteTypeId: note.noteTypeId,
|
||||
mod: note.mod,
|
||||
usn: note.usn,
|
||||
tags: note.tags,
|
||||
flds: note.flds,
|
||||
sfld: note.sfld,
|
||||
csum: note.csum,
|
||||
flags: note.flags,
|
||||
data: note.data,
|
||||
userId: note.userId,
|
||||
createdAt: note.createdAt,
|
||||
updatedAt: note.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function repoGetNotesByUserIdWithFields(
|
||||
input: RepoInputGetNotesByUserId,
|
||||
): Promise<RepoOutputNoteWithFields[]> {
|
||||
const notes = await repoGetNotesByUserId(input);
|
||||
|
||||
return notes.map((note) => ({
|
||||
...note,
|
||||
fields: repoSplitFields(note.flds),
|
||||
tagsArray: note.tags.trim() === "" ? [] : note.tags.trim().split(" "),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function repoDeleteNote(input: RepoInputDeleteNote): Promise<void> {
|
||||
log.debug("Deleting note", { id: input.id.toString() });
|
||||
|
||||
await prisma.note.delete({
|
||||
where: { id: input.id },
|
||||
});
|
||||
|
||||
log.info("Note deleted", { id: input.id.toString() });
|
||||
}
|
||||
|
||||
export async function repoCheckNoteOwnership(
|
||||
input: RepoInputCheckNoteOwnership,
|
||||
): Promise<boolean> {
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: input.noteId },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
return note?.userId === input.userId;
|
||||
}
|
||||
|
||||
export async function repoGetNoteOwnership(
|
||||
input: RepoInputGetNoteById,
|
||||
): Promise<RepoOutputNoteOwnership | null> {
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: input.id },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
if (!note) return null;
|
||||
|
||||
return { userId: note.userId };
|
||||
}
|
||||
|
||||
export async function repoCountNotesByUserId(userId: string): Promise<number> {
|
||||
return prisma.note.count({
|
||||
where: { userId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function repoCountNotesByNoteTypeId(
|
||||
noteTypeId: number,
|
||||
): Promise<number> {
|
||||
return prisma.note.count({
|
||||
where: { noteTypeId },
|
||||
});
|
||||
}
|
||||
60
src/modules/note/note-service-dto.ts
Normal file
60
src/modules/note/note-service-dto.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export type ServiceInputCreateNote = {
|
||||
noteTypeId: number;
|
||||
fields: string[];
|
||||
tags?: string[];
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceInputUpdateNote = {
|
||||
noteId: bigint;
|
||||
fields?: string[];
|
||||
tags?: string[];
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceInputDeleteNote = {
|
||||
noteId: bigint;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type ServiceInputGetNoteById = {
|
||||
noteId: bigint;
|
||||
};
|
||||
|
||||
export type ServiceInputGetNotesByNoteTypeId = {
|
||||
noteTypeId: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type ServiceInputGetNotesByUserId = {
|
||||
userId: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type ServiceOutputNote = {
|
||||
id: bigint;
|
||||
guid: string;
|
||||
noteTypeId: number;
|
||||
mod: number;
|
||||
usn: number;
|
||||
tags: string[];
|
||||
fields: string[];
|
||||
sfld: string;
|
||||
csum: number;
|
||||
flags: number;
|
||||
data: string;
|
||||
userId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export type ServiceOutputCreateNote = {
|
||||
id: bigint;
|
||||
guid: string;
|
||||
};
|
||||
|
||||
export type ServiceOutputNoteCount = {
|
||||
count: number;
|
||||
};
|
||||
200
src/modules/note/note-service.ts
Normal file
200
src/modules/note/note-service.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import {
|
||||
repoCreateNote,
|
||||
repoUpdateNote,
|
||||
repoGetNoteByIdWithFields,
|
||||
repoGetNotesByNoteTypeId,
|
||||
repoGetNotesByUserIdWithFields,
|
||||
repoDeleteNote,
|
||||
repoCheckNoteOwnership,
|
||||
repoCountNotesByUserId,
|
||||
repoCountNotesByNoteTypeId,
|
||||
} from "./note-repository";
|
||||
import {
|
||||
ServiceInputCreateNote,
|
||||
ServiceInputUpdateNote,
|
||||
ServiceInputDeleteNote,
|
||||
ServiceInputGetNoteById,
|
||||
ServiceInputGetNotesByNoteTypeId,
|
||||
ServiceInputGetNotesByUserId,
|
||||
ServiceOutputNote,
|
||||
ServiceOutputCreateNote,
|
||||
ServiceOutputNoteCount,
|
||||
} from "./note-service-dto";
|
||||
|
||||
const log = createLogger("note-service");
|
||||
|
||||
export class NoteNotFoundError extends Error {
|
||||
constructor(noteId: bigint) {
|
||||
super(`Note not found: ${noteId.toString()}`);
|
||||
this.name = "NoteNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class NoteOwnershipError extends Error {
|
||||
constructor() {
|
||||
super("You do not have permission to access this note");
|
||||
this.name = "NoteOwnershipError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function serviceCreateNote(
|
||||
input: ServiceInputCreateNote,
|
||||
): Promise<ServiceOutputCreateNote> {
|
||||
log.debug("Creating note", { userId: input.userId, noteTypeId: input.noteTypeId });
|
||||
|
||||
const id = await repoCreateNote({
|
||||
noteTypeId: input.noteTypeId,
|
||||
fields: input.fields,
|
||||
tags: input.tags,
|
||||
userId: input.userId,
|
||||
});
|
||||
|
||||
const note = await repoGetNoteByIdWithFields({ id });
|
||||
if (!note) {
|
||||
throw new NoteNotFoundError(id);
|
||||
}
|
||||
|
||||
log.info("Note created successfully", { id: id.toString(), guid: note.guid });
|
||||
|
||||
return {
|
||||
id,
|
||||
guid: note.guid,
|
||||
};
|
||||
}
|
||||
|
||||
export async function serviceUpdateNote(
|
||||
input: ServiceInputUpdateNote,
|
||||
): Promise<void> {
|
||||
log.debug("Updating note", { noteId: input.noteId.toString() });
|
||||
|
||||
const isOwner = await repoCheckNoteOwnership({
|
||||
noteId: input.noteId,
|
||||
userId: input.userId,
|
||||
});
|
||||
|
||||
if (!isOwner) {
|
||||
throw new NoteOwnershipError();
|
||||
}
|
||||
|
||||
await repoUpdateNote({
|
||||
id: input.noteId,
|
||||
fields: input.fields,
|
||||
tags: input.tags,
|
||||
});
|
||||
|
||||
log.info("Note updated successfully", { noteId: input.noteId.toString() });
|
||||
}
|
||||
|
||||
export async function serviceDeleteNote(
|
||||
input: ServiceInputDeleteNote,
|
||||
): Promise<void> {
|
||||
log.debug("Deleting note", { noteId: input.noteId.toString() });
|
||||
|
||||
const isOwner = await repoCheckNoteOwnership({
|
||||
noteId: input.noteId,
|
||||
userId: input.userId,
|
||||
});
|
||||
|
||||
if (!isOwner) {
|
||||
throw new NoteOwnershipError();
|
||||
}
|
||||
|
||||
await repoDeleteNote({ id: input.noteId });
|
||||
|
||||
log.info("Note deleted successfully", { noteId: input.noteId.toString() });
|
||||
}
|
||||
|
||||
export async function serviceGetNoteById(
|
||||
input: ServiceInputGetNoteById,
|
||||
): Promise<ServiceOutputNote | null> {
|
||||
log.debug("Fetching note by id", { noteId: input.noteId.toString() });
|
||||
|
||||
const note = await repoGetNoteByIdWithFields({ id: input.noteId });
|
||||
|
||||
if (!note) {
|
||||
log.debug("Note not found", { noteId: input.noteId.toString() });
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: note.id,
|
||||
guid: note.guid,
|
||||
noteTypeId: note.noteTypeId,
|
||||
mod: note.mod,
|
||||
usn: note.usn,
|
||||
tags: note.tagsArray,
|
||||
fields: note.fields,
|
||||
sfld: note.sfld,
|
||||
csum: note.csum,
|
||||
flags: note.flags,
|
||||
data: note.data,
|
||||
userId: note.userId,
|
||||
createdAt: note.createdAt,
|
||||
updatedAt: note.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function serviceGetNotesByNoteTypeId(
|
||||
input: ServiceInputGetNotesByNoteTypeId,
|
||||
): Promise<ServiceOutputNote[]> {
|
||||
log.debug("Fetching notes by note type", { noteTypeId: input.noteTypeId });
|
||||
|
||||
const notes = await repoGetNotesByNoteTypeId(input);
|
||||
|
||||
return notes.map((note) => ({
|
||||
id: note.id,
|
||||
guid: note.guid,
|
||||
noteTypeId: note.noteTypeId,
|
||||
mod: note.mod,
|
||||
usn: note.usn,
|
||||
tags: note.tags.trim() === "" ? [] : note.tags.trim().split(" "),
|
||||
fields: note.flds.split("\x1f"),
|
||||
sfld: note.sfld,
|
||||
csum: note.csum,
|
||||
flags: note.flags,
|
||||
data: note.data,
|
||||
userId: note.userId,
|
||||
createdAt: note.createdAt,
|
||||
updatedAt: note.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function serviceGetNotesByUserId(
|
||||
input: ServiceInputGetNotesByUserId,
|
||||
): Promise<ServiceOutputNote[]> {
|
||||
log.debug("Fetching notes by user", { userId: input.userId });
|
||||
|
||||
const notes = await repoGetNotesByUserIdWithFields(input);
|
||||
|
||||
return notes.map((note) => ({
|
||||
id: note.id,
|
||||
guid: note.guid,
|
||||
noteTypeId: note.noteTypeId,
|
||||
mod: note.mod,
|
||||
usn: note.usn,
|
||||
tags: note.tagsArray,
|
||||
fields: note.fields,
|
||||
sfld: note.sfld,
|
||||
csum: note.csum,
|
||||
flags: note.flags,
|
||||
data: note.data,
|
||||
userId: note.userId,
|
||||
createdAt: note.createdAt,
|
||||
updatedAt: note.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function serviceCountNotesByUserId(
|
||||
userId: string,
|
||||
): Promise<ServiceOutputNoteCount> {
|
||||
const count = await repoCountNotesByUserId(userId);
|
||||
return { count };
|
||||
}
|
||||
|
||||
export async function serviceCountNotesByNoteTypeId(
|
||||
noteTypeId: number,
|
||||
): Promise<ServiceOutputNoteCount> {
|
||||
const count = await repoCountNotesByNoteTypeId(noteTypeId);
|
||||
return { count };
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { z } from "zod";
|
||||
|
||||
export const schemaActionInputProcessOCR = z.object({
|
||||
imageBase64: z.string().min(1, "Image is required"),
|
||||
folderId: z.number().int().positive("Folder ID must be positive"),
|
||||
deckId: z.number().int().positive("Deck ID must be positive"),
|
||||
sourceLanguage: z.string().optional(),
|
||||
targetLanguage: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -1 +1 @@
|
||||
export type { RepoInputCreatePair } from "@/modules/folder/folder-repository-dto";
|
||||
export {};
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
import { repoCreatePair, repoGetUserIdByFolderId } from "@/modules/folder/folder-repository";
|
||||
import type { RepoInputCreatePair } from "./ocr-repository-dto";
|
||||
|
||||
export { repoCreatePair, repoGetUserIdByFolderId };
|
||||
export type { RepoInputCreatePair };
|
||||
export {};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from "zod";
|
||||
|
||||
export const schemaServiceInputProcessOCR = z.object({
|
||||
imageBase64: z.string().min(1, "Image is required"),
|
||||
folderId: z.number().int().positive("Folder ID must be positive"),
|
||||
deckId: z.number().int().positive("Deck ID must be positive"),
|
||||
sourceLanguage: z.string().optional(),
|
||||
targetLanguage: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -1,18 +1,69 @@
|
||||
"use server";
|
||||
|
||||
import { executeOCR } from "@/lib/bigmodel/ocr/orchestrator";
|
||||
import { repoCreatePair, repoGetUserIdByFolderId } from "@/modules/folder/folder-repository";
|
||||
import { repoGetUserIdByDeckId } from "@/modules/deck/deck-repository";
|
||||
import { repoCreateNote, repoJoinFields } from "@/modules/note/note-repository";
|
||||
import { repoCreateCard } from "@/modules/card/card-repository";
|
||||
import { repoGetNoteTypesByUserId, repoCreateNoteType } from "@/modules/note-type/note-type-repository";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import type { ServiceInputProcessOCR, ServiceOutputProcessOCR } from "./ocr-service-dto";
|
||||
import { NoteKind } from "../../../generated/prisma/enums";
|
||||
|
||||
const log = createLogger("ocr-service");
|
||||
|
||||
const VOCABULARY_NOTE_TYPE_NAME = "Vocabulary (OCR)";
|
||||
|
||||
async function getOrCreateVocabularyNoteType(userId: string): Promise<number> {
|
||||
const existingTypes = await repoGetNoteTypesByUserId({ userId });
|
||||
const existing = existingTypes.find((nt) => nt.name === VOCABULARY_NOTE_TYPE_NAME);
|
||||
|
||||
if (existing) {
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const fields = [
|
||||
{ name: "Word", ord: 0, sticky: false, rtl: false, font: "Arial", size: 20, media: [] },
|
||||
{ name: "Definition", ord: 1, sticky: false, rtl: false, font: "Arial", size: 20, media: [] },
|
||||
{ name: "Source Language", ord: 2, sticky: false, rtl: false, font: "Arial", size: 16, media: [] },
|
||||
{ name: "Target Language", ord: 3, sticky: false, rtl: false, font: "Arial", size: 16, media: [] },
|
||||
];
|
||||
|
||||
const templates = [
|
||||
{
|
||||
name: "Word → Definition",
|
||||
ord: 0,
|
||||
qfmt: "{{Word}}",
|
||||
afmt: "{{FrontSide}}<hr id=answer>{{Definition}}",
|
||||
},
|
||||
{
|
||||
name: "Definition → Word",
|
||||
ord: 1,
|
||||
qfmt: "{{Definition}}",
|
||||
afmt: "{{FrontSide}}<hr id=answer>{{Word}}",
|
||||
},
|
||||
];
|
||||
|
||||
const css = ".card { font-family: Arial; font-size: 20px; text-align: center; color: black; background-color: white; }";
|
||||
|
||||
const noteTypeId = await repoCreateNoteType({
|
||||
name: VOCABULARY_NOTE_TYPE_NAME,
|
||||
kind: NoteKind.STANDARD,
|
||||
css,
|
||||
fields,
|
||||
templates,
|
||||
userId,
|
||||
});
|
||||
|
||||
log.info("Created vocabulary note type", { noteTypeId, userId });
|
||||
return noteTypeId;
|
||||
}
|
||||
|
||||
export async function serviceProcessOCR(
|
||||
input: ServiceInputProcessOCR
|
||||
): Promise<ServiceOutputProcessOCR> {
|
||||
log.info("Processing OCR request", { folderId: input.folderId });
|
||||
log.info("Processing OCR request", { deckId: input.deckId });
|
||||
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
@@ -20,15 +71,15 @@ export async function serviceProcessOCR(
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
|
||||
const folderOwner = await repoGetUserIdByFolderId(input.folderId);
|
||||
if (folderOwner !== session.user.id) {
|
||||
log.warn("Folder ownership mismatch", {
|
||||
folderId: input.folderId,
|
||||
const deckOwner = await repoGetUserIdByDeckId(input.deckId);
|
||||
if (deckOwner !== session.user.id) {
|
||||
log.warn("Deck ownership mismatch", {
|
||||
deckId: input.deckId,
|
||||
userId: session.user.id
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
message: "You don't have permission to modify this folder"
|
||||
message: "You don't have permission to modify this deck"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,19 +110,38 @@ export async function serviceProcessOCR(
|
||||
const sourceLanguage = ocrResult.detectedSourceLanguage || input.sourceLanguage || "Unknown";
|
||||
const targetLanguage = ocrResult.detectedTargetLanguage || input.targetLanguage || "Unknown";
|
||||
|
||||
const noteTypeId = await getOrCreateVocabularyNoteType(session.user.id);
|
||||
|
||||
let pairsCreated = 0;
|
||||
for (const pair of ocrResult.pairs) {
|
||||
try {
|
||||
await repoCreatePair({
|
||||
folderId: input.folderId,
|
||||
language1: sourceLanguage,
|
||||
language2: targetLanguage,
|
||||
text1: pair.word,
|
||||
text2: pair.definition,
|
||||
const now = Date.now();
|
||||
const noteId = await repoCreateNote({
|
||||
noteTypeId,
|
||||
userId: session.user.id,
|
||||
fields: [pair.word, pair.definition, sourceLanguage, targetLanguage],
|
||||
tags: ["ocr"],
|
||||
});
|
||||
|
||||
await repoCreateCard({
|
||||
id: BigInt(now + pairsCreated),
|
||||
noteId,
|
||||
deckId: input.deckId,
|
||||
ord: 0,
|
||||
due: pairsCreated + 1,
|
||||
});
|
||||
|
||||
await repoCreateCard({
|
||||
id: BigInt(now + pairsCreated + 10000),
|
||||
noteId,
|
||||
deckId: input.deckId,
|
||||
ord: 1,
|
||||
due: pairsCreated + 1,
|
||||
});
|
||||
|
||||
pairsCreated++;
|
||||
} catch (error) {
|
||||
log.error("Failed to create pair", {
|
||||
log.error("Failed to create note/card", {
|
||||
word: pair.word,
|
||||
error
|
||||
});
|
||||
|
||||
165
src/shared/anki-type.ts
Normal file
165
src/shared/anki-type.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Shared types for Anki-compatible data structures
|
||||
* Based on Anki's official database schema
|
||||
*/
|
||||
|
||||
import type { CardType, CardQueue, NoteKind, Visibility } from "../../generated/prisma/enums";
|
||||
|
||||
// ============================================
|
||||
// NoteType (Anki: models)
|
||||
// ============================================
|
||||
|
||||
export interface NoteTypeField {
|
||||
name: string;
|
||||
ord: number;
|
||||
sticky: boolean;
|
||||
rtl: boolean;
|
||||
font: string;
|
||||
size: number;
|
||||
media: string[];
|
||||
}
|
||||
|
||||
export interface NoteTypeTemplate {
|
||||
name: string;
|
||||
ord: number;
|
||||
qfmt: string; // Question format (Mustache template)
|
||||
afmt: string; // Answer format (Mustache template)
|
||||
bqfmt?: string; // Browser question format
|
||||
bafmt?: string; // Browser answer format
|
||||
did?: number; // Deck override
|
||||
}
|
||||
|
||||
export interface TSharedNoteType {
|
||||
id: number;
|
||||
name: string;
|
||||
kind: NoteKind;
|
||||
css: string;
|
||||
fields: NoteTypeField[];
|
||||
templates: NoteTypeTemplate[];
|
||||
userId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Deck (Anki: decks) - replaces Folder
|
||||
// ============================================
|
||||
|
||||
export interface TSharedDeck {
|
||||
id: number;
|
||||
name: string;
|
||||
desc: string;
|
||||
userId: string;
|
||||
visibility: Visibility;
|
||||
collapsed: boolean;
|
||||
conf: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
cardCount?: number;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Note (Anki: notes)
|
||||
// ============================================
|
||||
|
||||
export interface TSharedNote {
|
||||
id: bigint;
|
||||
guid: string;
|
||||
noteTypeId: number;
|
||||
mod: number;
|
||||
usn: number;
|
||||
tags: string; // Space-separated
|
||||
flds: string; // Field values separated by 0x1f
|
||||
sfld: string; // Sort field
|
||||
csum: number; // Checksum of first field
|
||||
flags: number;
|
||||
data: string;
|
||||
userId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Helper to get fields as array
|
||||
export function getNoteFields(note: TSharedNote): string[] {
|
||||
return note.flds.split('\x1f');
|
||||
}
|
||||
|
||||
// Helper to set fields from array
|
||||
export function setNoteFields(fields: string[]): string {
|
||||
return fields.join('\x1f');
|
||||
}
|
||||
|
||||
// Helper to get tags as array
|
||||
export function getNoteTags(note: TSharedNote): string[] {
|
||||
return note.tags.trim().split(' ').filter(Boolean);
|
||||
}
|
||||
|
||||
// Helper to set tags from array
|
||||
export function setNoteTags(tags: string[]): string {
|
||||
return ` ${tags.join(' ')} `;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Card (Anki: cards)
|
||||
// ============================================
|
||||
|
||||
export interface TSharedCard {
|
||||
id: bigint;
|
||||
noteId: bigint;
|
||||
deckId: number;
|
||||
ord: number;
|
||||
mod: number;
|
||||
usn: number;
|
||||
type: CardType;
|
||||
queue: CardQueue;
|
||||
due: number;
|
||||
ivl: number;
|
||||
factor: number;
|
||||
reps: number;
|
||||
lapses: number;
|
||||
left: number;
|
||||
odue: number;
|
||||
odid: number;
|
||||
flags: number;
|
||||
data: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Card for review (with note data)
|
||||
export interface TCardForReview extends TSharedCard {
|
||||
note: TSharedNote & {
|
||||
noteType: TSharedNoteType;
|
||||
};
|
||||
deck: TSharedDeck;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Review
|
||||
// ============================================
|
||||
|
||||
export type ReviewEase = 1 | 2 | 3 | 4; // Again, Hard, Good, Easy
|
||||
|
||||
export interface TSharedRevlog {
|
||||
id: bigint;
|
||||
cardId: bigint;
|
||||
usn: number;
|
||||
ease: number;
|
||||
ivl: number;
|
||||
lastIvl: number;
|
||||
factor: number;
|
||||
time: number; // Review time in ms
|
||||
type: number;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Deck Favorites
|
||||
// ============================================
|
||||
|
||||
export interface TSharedDeckFavorite {
|
||||
id: number;
|
||||
userId: string;
|
||||
deckId: number;
|
||||
createdAt: Date;
|
||||
deck?: TSharedDeck;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
export type TSharedFolder = {
|
||||
id: number,
|
||||
name: string,
|
||||
userId: string;
|
||||
visibility: "PRIVATE" | "PUBLIC";
|
||||
};
|
||||
|
||||
export type TSharedFolderWithTotalPairs = {
|
||||
id: number,
|
||||
name: string,
|
||||
userId: string,
|
||||
visibility: "PRIVATE" | "PUBLIC";
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type TSharedPair = {
|
||||
text1: string;
|
||||
text2: string;
|
||||
language1: string;
|
||||
language2: string;
|
||||
ipa1: string | null;
|
||||
ipa2: string | null;
|
||||
id: number;
|
||||
folderId: number;
|
||||
};
|
||||
|
||||
export type TPublicFolder = {
|
||||
id: number;
|
||||
name: string;
|
||||
visibility: "PRIVATE" | "PUBLIC";
|
||||
createdAt: Date;
|
||||
userId: string;
|
||||
userName: string | null;
|
||||
userUsername: string | null;
|
||||
totalPairs: number;
|
||||
favoriteCount: number;
|
||||
};
|
||||
Reference in New Issue
Block a user