diff --git a/messages/de-DE.json b/messages/de-DE.json
index 4d554fd..2163486 100644
--- a/messages/de-DE.json
+++ b/messages/de-DE.json
@@ -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"
diff --git a/messages/en-US.json b/messages/en-US.json
index cf29a59..ee8c8ee 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -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"
diff --git a/messages/fr-FR.json b/messages/fr-FR.json
index c2cee06..c23c7df 100644
--- a/messages/fr-FR.json
+++ b/messages/fr-FR.json
@@ -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"
diff --git a/messages/it-IT.json b/messages/it-IT.json
index e7ee044..489913c 100644
--- a/messages/it-IT.json
+++ b/messages/it-IT.json
@@ -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"
diff --git a/messages/ja-JP.json b/messages/ja-JP.json
index a5d227c..0f50803 100644
--- a/messages/ja-JP.json
+++ b/messages/ja-JP.json
@@ -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": "表示"
diff --git a/messages/ko-KR.json b/messages/ko-KR.json
index f3d2c9d..e5d9d9e 100644
--- a/messages/ko-KR.json
+++ b/messages/ko-KR.json
@@ -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": "보기"
diff --git a/messages/ug-CN.json b/messages/ug-CN.json
index 2adf755..5256c94 100644
--- a/messages/ug-CN.json
+++ b/messages/ug-CN.json
@@ -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": "كۆرۈش"
diff --git a/messages/zh-CN.json b/messages/zh-CN.json
index da5c3c8..956ac9f 100644
--- a/messages/zh-CN.json
+++ b/messages/zh-CN.json
@@ -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": "查看"
diff --git a/prisma/migrations/20260310111728_anki_refactor/migration.sql b/prisma/migrations/20260310111728_anki_refactor/migration.sql
new file mode 100644
index 0000000..7a3a475
--- /dev/null
+++ b/prisma/migrations/20260310111728_anki_refactor/migration.sql
@@ -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;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index d0282aa..ab6453d 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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 {
- id Int @id @default(autoincrement())
- name String
- 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[]
+/// 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")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ notes Note[]
+
+ @@index([userId])
+ @@map("note_types")
+}
+
+/// Deck (Anki: decks) - Container for cards
+model Deck {
+ id Int @id @default(autoincrement())
+ name String
+ desc String @default("")
+ userId String @map("user_id")
+ visibility Visibility @default(PRIVATE)
+ collapsed Boolean @default(false)
+ conf Json @default("{}")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ cards Card[]
+ favorites DeckFavorite[]
@@index([userId])
@@index([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)
+ user User @relation(fields: [userId], 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])
diff --git a/src/app/(auth)/users/[username]/page.tsx b/src/app/(auth)/users/[username]/page.tsx
index 2eb932d..55b3ed9 100644
--- a/src/app/(auth)/users/[username]/page.tsx
+++ b/src/app/(auth)/users/[username]/page.tsx
@@ -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) {
-
{t("folders.title")}
- {folders.length === 0 ? (
-
{t("folders.noFolders")}
+
{t("decks.title")}
+ {decks.length === 0 ? (
+
{t("decks.noDecks")}
) : (
|
- {t("folders.folderName")}
+ {t("decks.deckName")}
|
- {t("folders.totalPairs")}
+ {t("decks.totalCards")}
|
- {t("folders.createdAt")}
+ {t("decks.createdAt")}
|
- {t("folders.actions")}
+ {t("decks.actions")}
|
- {folders.map((folder) => (
-
+ {decks.map((deck) => (
+
|
- {folder.name}
- ID: {folder.id}
+ {deck.name}
+ ID: {deck.id}
|
- {folder.total}
+ {deck.cardCount ?? 0}
|
- {new Date(folder.createdAt).toLocaleDateString()}
+ {new Date(deck.createdAt).toLocaleDateString()}
|
-
+
- {t("folders.view")}
+ {t("decks.view")}
|
diff --git a/src/app/(features)/dictionary/DictionaryClient.tsx b/src/app/(features)/dictionary/DictionaryClient.tsx
index 5c77b6e..dd33364 100644
--- a/src/app/(features)/dictionary/DictionaryClient.tsx
+++ b/src/app/(features)/dictionary/DictionaryClient.tsx
@@ -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(initialFolders);
+ const [decks, setDecks] = useState(initialDecks);
+ const [defaultNoteTypeId, setDefaultNoteTypeId] = useState(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) {
) : query && !searchResult ? (
-
No results found
-
Try other words
+
{t("noResults")}
+
{t("tryOtherWords")}
) : searchResult ? (
@@ -186,14 +249,14 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
- {session && folders.length > 0 && (
+ {session && decks.length > 0 && (
@@ -201,7 +264,9 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
@@ -223,7 +288,7 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
loading={isSearching}
>
- Re-lookup
+ {t("relookup")}
diff --git a/src/app/(features)/dictionary/page.tsx b/src/app/(features)/dictionary/page.tsx
index baf9f6d..ba26ec6 100644
--- a/src/app/(features)/dictionary/page.tsx
+++ b/src/app/(features)/dictionary/page.tsx
@@ -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 ;
+ return ;
}
diff --git a/src/app/(features)/explore/ExploreClient.tsx b/src/app/(features)/explore/ExploreClient.tsx
index 9442e0f..63734e5 100644
--- a/src/app/(features)/explore/ExploreClient.tsx
+++ b/src/app/(features)/explore/ExploreClient.tsx
@@ -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
{
- router.push(`/explore/${folder.id}`);
+ router.push(`/explore/${deck.id}`);
}}
>
-
{folder.name}
+
{deck.name}
- {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,
})}
@@ -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
(initialPublicFolders);
+ const [publicDecks, setPublicDecks] = useState(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) {
{t("loading")}
- ) : sortedFolders.length === 0 ? (
+ ) : sortedDecks.length === 0 ? (
-
+
-
{t("noFolders")}
+
{t("noDecks")}
) : (
- {sortedFolders.map((folder) => (
-
(
+
diff --git a/src/app/(features)/explore/[id]/ExploreDetailClient.tsx b/src/app/(features)/explore/[id]/ExploreDetailClient.tsx
index a542633..e13670e 100644
--- a/src/app/(features)/explore/[id]/ExploreDetailClient.tsx
+++ b/src/app/(features)/explore/[id]/ExploreDetailClient.tsx
@@ -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) {
-
+
- {folder.name}
+ {deck.name}
{t("createdBy", {
- name: folder.userName ?? folder.userUsername ?? t("unknownUser"),
+ name: deck.userName ?? deck.userUsername ?? t("unknownUser"),
})}
@@ -104,13 +104,19 @@ export function ExploreDetailClient({ folder }: ExploreDetailClientProps) {
+ {deck.desc && (
+
+ {deck.desc}
+
+ )}
+
- {folder.totalPairs}
+ {deck.cardCount ?? 0}
- {t("totalPairs")}
+ {t("totalCards")}
@@ -124,7 +130,7 @@ export function ExploreDetailClient({ folder }: ExploreDetailClientProps) {
- {formatDate(folder.createdAt)}
+ {formatDate(deck.createdAt)}
{t("createdAt")}
@@ -133,7 +139,7 @@ export function ExploreDetailClient({ folder }: ExploreDetailClientProps) {
diff --git a/src/app/(features)/explore/[id]/page.tsx b/src/app/(features)/explore/[id]/page.tsx
index 38d95d1..201a84f 100644
--- a/src/app/(features)/explore/[id]/page.tsx
+++ b/src/app/(features)/explore/[id]/page.tsx
@@ -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
;
+ return
;
}
diff --git a/src/app/(features)/explore/page.tsx b/src/app/(features)/explore/page.tsx
index 030a55e..ecd5d7a 100644
--- a/src/app/(features)/explore/page.tsx
+++ b/src/app/(features)/explore/page.tsx
@@ -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
;
+ return
;
}
diff --git a/src/app/(features)/favorites/FavoritesClient.tsx b/src/app/(features)/favorites/FavoritesClient.tsx
index 0207c59..12e0cda 100644
--- a/src/app/(features)/favorites/FavoritesClient.tsx
+++ b/src/app/(features)/favorites/FavoritesClient.tsx
@@ -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) => {
{
- router.push(`/explore/${favorite.folderId}`);
+ router.push(`/explore/${favorite.id}`);
}}
>
-
+
-
{favorite.folderName}
+
{favorite.name}
{t("folderInfo", {
- userName: favorite.folderOwnerName ?? favorite.folderOwnerUsername ?? t("unknownUser"),
- totalPairs: favorite.folderTotalPairs,
+ userName: favorite.userName ?? favorite.userUsername ?? t("unknownUser"),
+ totalPairs: favorite.cardCount ?? 0,
})}
@@ -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
([]);
- const [loading, setLoading] = useState(true);
-
- useEffect(() => {
- loadFavorites();
- }, [userId]);
+ const [favorites, setFavorites] = useState(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 (
diff --git a/src/app/(features)/favorites/page.tsx b/src/app/(features)/favorites/page.tsx
index 53761bb..e0111c3 100644
--- a/src/app/(features)/favorites/page.tsx
+++ b/src/app/(features)/favorites/page.tsx
@@ -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 ;
+ let favorites: ActionOutputUserFavoriteDeck[] = [];
+ const result = await actionGetUserFavoriteDecks();
+ if (result.success && result.data) {
+ favorites = result.data;
+ }
+
+ return ;
}
diff --git a/src/app/(features)/memorize/DeckSelector.tsx b/src/app/(features)/memorize/DeckSelector.tsx
new file mode 100644
index 0000000..436792d
--- /dev/null
+++ b/src/app/(features)/memorize/DeckSelector.tsx
@@ -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;
+}
+
+const DeckSelector: React.FC = ({ 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 (
+
+ {decks.length === 0 ? (
+
+
+ {t("noDecks")}
+
+
+
+ {t("goToDecks")}
+
+
+
+ ) : (
+ <>
+
+ {t("selectDeck")}
+
+
+ {decks
+ .toSorted((a, b) => a.id - b.id)
+ .map((deck) => {
+ const stats = deckStats.get(deck.id);
+ const dueCount = getDueCount(deck.id);
+
+ return (
+
+ 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"
+ >
+
+
+
+
+
+ {deck.name}
+
+
+ {formatCardStats(stats)}
+
+
+ {dueCount > 0 && (
+
+ {dueCount}
+
+ )}
+
+
+ );
+ })}
+
+ >
+ )}
+
+ );
+};
+
+export { DeckSelector };
diff --git a/src/app/(features)/memorize/FolderSelector.tsx b/src/app/(features)/memorize/FolderSelector.tsx
deleted file mode 100644
index 91c1475..0000000
--- a/src/app/(features)/memorize/FolderSelector.tsx
+++ /dev/null
@@ -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 = ({ folders }) => {
- const t = useTranslations("memorize.folder_selector");
- const router = useRouter();
-
- return (
-
- {folders.length === 0 ? (
- // 空状态 - 显示提示和跳转按钮
-
-
- {t("noFolders")}
-
-
-
- Go to Folders
-
-
-
- ) : (
- <>
- {/* 页面标题 */}
-
- {t("selectFolder")}
-
- {/* 文件夹列表 */}
-
- {folders
- .toSorted((a, b) => a.id - b.id)
- .map((folder) => (
-
- 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"
- >
- {/* 文件夹图标 */}
-
-
-
- {/* 文件夹信息 */}
-
-
- {folder.name}
-
-
- {t("folderInfo", {
- id: folder.id,
- name: folder.name,
- count: folder.total,
- })}
-
-
- {/* 右箭头 */}
-
-
- ))}
-
- >
- )}
-
- );
-};
-
-export { FolderSelector };
diff --git a/src/app/(features)/memorize/Memorize.tsx b/src/app/(features)/memorize/Memorize.tsx
index 48846da..a11b0f7 100644
--- a/src/app/(features)/memorize/Memorize.tsx
+++ b/src/app/(features)/memorize/Memorize.tsx
@@ -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 = ({ 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 = ({ deckId, deckName }) => {
+ const t = useTranslations("memorize.review");
+ const router = useRouter();
+ const [isPending, startTransition] = useTransition();
+
+ const [cards, setCards] = useState([]);
+ const [currentIndex, setCurrentIndex] = useState(0);
+ const [showAnswer, setShowAnswer] = useState(false);
+ const [lastScheduled, setLastScheduled] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(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 (
- {t("noTextPairs")}
+
);
}
- 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 = {
- "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 (
-
- {text}
-
+
+
+
{error}
+
router.push("/memorize")} className="px-4 py-2">
+ {t("backToDecks")}
+
+
+
);
- };
+ }
- const [text1, text2] = reverse
- ? [getTextPairs()[index].text2, getTextPairs()[index].text1]
- : [getTextPairs()[index].text1, getTextPairs()[index].text2];
+ if (cards.length === 0) {
+ return (
+
+
+
+
+
+
{t("allDone")}
+
{t("allDoneDesc")}
+
router.push("/memorize")} className="px-4 py-2">
+ {t("backToDecks")}
+
+
+
+ );
+ }
+
+ const currentCard = getCurrentCard()!;
+ const fields = getNoteFields(currentCard);
+ const front = fields[0] ?? "";
+ const back = fields[1] ?? "";
return (
- {/* 进度指示器 */}
-
-
- {index + 1} / {getTextPairs().length}
-
+
+
+
+ {deckName}
+
+
+ {t("progress", { current: currentIndex + 1, total: cards.length + currentIndex })}
+
- {/* 文本显示区域 */}
-
- {(() => {
- if (dictation) {
- if (show === "question") {
- return (
-
- );
- } else {
- return (
-
- {createText(text1)}
-
- {createText(text2)}
-
- );
- }
- } else {
- if (show === "question") {
- return createText(text1);
- } else {
- return (
-
- {createText(text1)}
-
- {createText(text2)}
-
- );
- }
- }
- })()}
+
- {/* 底部按钮 */}
-
-
- {show === "question" ? t("answer") : t("next")}
-
-
- {t("previous")}
-
-
- {t("reverse")}
-
-
- {t("dictation")}
-
-
- {t("disorder")}
-
+ {lastScheduled && (
+
+
+
+
+ {t("nextReview")}: {formatNextReview(lastScheduled)}
+
+
+
+ )}
+
+
+
+
+ {showAnswer && (
+ <>
+
+
+ >
+ )}
+
+
+
+ {t("interval")}: {formatInterval(currentCard.ivl)}
+ •
+ {t("ease")}: {currentCard.factor / 10}%
+ •
+ {t("lapses")}: {currentCard.lapses}
+
+
+
+ {!showAnswer ? (
+
+ {t("showAnswer")}
+
+ ) : (
+
+
+
+
+
+
+
+
+
+ )}
);
diff --git a/src/app/(features)/memorize/page.tsx b/src/app/(features)/memorize/page.tsx
index 726603e..31c3fef 100644
--- a/src/app/(features)/memorize/page.tsx
+++ b/src/app/(features)/memorize/page.tsx
@@ -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");
+ 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
>["data"]>();
+ for (const deck of decks) {
+ const statsResult = await actionGetCardStats({ deckId: deck.id });
+ if (statsResult.success && statsResult.data) {
+ deckStats.set(deck.id, statsResult.data);
+ }
+ }
return (
-
);
}
- return ;
+ const decksResult = await actionGetDecksByUserId(session.user.id);
+ const deck = decksResult.data?.find(d => d.id === deckId);
+
+ if (!deck) {
+ redirect("/memorize");
+ }
+
+ return ;
}
diff --git a/src/app/(features)/ocr/OCRClient.tsx b/src/app/(features)/ocr/OCRClient.tsx
index 393d905..5fbccef 100644
--- a/src/app/(features)/ocr/OCRClient.tsx
+++ b/src/app/(features)/ocr/OCRClient.tsx
@@ -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(null);
+ const [decks, setDecks] = useState(initialDecks);
const [selectedFile, setSelectedFile] = useState(null);
const [previewUrl, setPreviewUrl] = useState(null);
- const [selectedFolderId, setSelectedFolderId] = useState(
- initialFolders.length > 0 ? initialFolders[0].id : null
+ const [selectedDeckId, setSelectedDeckId] = useState(
+ initialDecks.length > 0 ? initialDecks[0].id : null
);
const [sourceLanguage, setSourceLanguage] = useState("");
const [targetLanguage, setTargetLanguage] = useState("");
const [isProcessing, setIsProcessing] = useState(false);
- const [ocrResult, setOcrResult] = useState(null);
+ const [ocrResult, setOcrResult] = useState(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 (
-
-
-
{t("title")}
-
{t("description")}
+
+
+
+ {t("title")}
+
+
+ {t("description")}
+
-
-
-
-
-
- {t("uploadImage")}
-
-
+
+
+ {/* Upload Section */}
+
+
+ {t("uploadSection")}
+
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 ? (
-
+

-
- {
- e.stopPropagation();
- clearImage();
- }}
- >
- {t("uploadImage")}
-
-
+
{t("changeImage")}
) : (
-
-
-
{t("dragDropHint")}
-
{t("supportedFormats")}
+
+
+
{t("dropOrClick")}
+
{t("supportedFormats")}
)}
-
handleFileChange(e.target.files?.[0] || null)}
+
+
handleFileChange(e.target.files?.[0] || null)}
+ className="hidden"
+ />
+
+
+ {/* Deck Selection */}
+
+
+ {t("deckSelection")}
+
+
+
+
+ {/* Language Hints */}
+
-
-
-
-
{t("selectFolder")}
-
- {initialFolders.length > 0 ? (
-
- ) : (
-
{t("noFolders")}
- )}
+ {/* Process Button */}
+
+
+ {t("processButton")}
+
-
-
-
-
{t("languageHints")}
-
-
-
-
-
setSourceLanguage(e.target.value)}
- placeholder="English"
- />
+ {/* Results Preview */}
+ {ocrResult && ocrResult.data && (
+
+
+ {t("resultsPreview")}
+
+
+
+
+
{t("extractedPairs", { count: ocrResult.data.pairsCreated })}
+
+
+ {ocrResult.data.sourceLanguage && (
+
+ {t("detectedSourceLanguage")}: {ocrResult.data.sourceLanguage}
+
+ )}
+ {ocrResult.data.targetLanguage && (
+
+ {t("detectedTargetLanguage")}: {ocrResult.data.targetLanguage}
+
+ )}
-
-
-
setTargetLanguage(e.target.value)}
- placeholder="Chinese"
- />
+
+
+
+ {t("saveButton")}
+
-
-
-
-
-
- {isProcessing ? (
- <>
-
- {t("processing")}
- >
- ) : (
- t("process")
- )}
-
+ )}
-
+
);
}
diff --git a/src/app/(features)/ocr/page.tsx b/src/app/(features)/ocr/page.tsx
index fe18064..4ebd9ff 100644
--- a/src/app/(features)/ocr/page.tsx
+++ b/src/app/(features)/ocr/page.tsx
@@ -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
;
+ return
;
}
diff --git a/src/app/folders/FoldersClient.tsx b/src/app/decks/DecksClient.tsx
similarity index 54%
rename from src/app/folders/FoldersClient.tsx
rename to src/app/decks/DecksClient.tsx
index e3ce593..9551e29 100644
--- a/src/app/folders/FoldersClient.tsx
+++ b/src/app/decks/DecksClient.tsx
@@ -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
) => void;
- onDeleteFolder: (folderId: number) => void;
+interface DeckCardProps {
+ deck: ActionOutputDeck;
+ onUpdateDeck: (deckId: number, updates: Partial) => 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)
{
- router.push(`/folders/${folder.id}`);
+ router.push(`/decks/${deck.id}`);
}}
>
-
+
-
{folder.name}
+ {deck.name}
- {folder.visibility === "PUBLIC" ? (
+ {deck.visibility === "PUBLIC" ? (
) : (
)}
- {folder.visibility === "PUBLIC" ? t("public") : t("private")}
+ {deck.visibility === "PUBLIC" ? t("public") : t("private")}
- {t("folderInfo", {
- id: folder.id,
- name: folder.name,
- totalPairs: folder.total,
+ {t("deckInfo", {
+ id: deck.id,
+ name: deck.name,
+ totalCards: deck.cardCount ?? 0,
})}
@@ -110,16 +115,16 @@ const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps)
- {folder.visibility === "PUBLIC" ? (
+ {deck.visibility === "PUBLIC" ? (
) : (
)}
-
+
([]);
+ const [decks, setDecks] = useState([]);
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) => {
- setFolders((prev) =>
- prev.map((f) => (f.id === folderId ? { ...f, ...updates } : f))
+ const handleUpdateDeck = (deckId: number, updates: Partial) => {
+ 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) {
-
-
- {t("newFolder")}
+
+
+ {t("newDeck")}
@@ -195,20 +200,20 @@ export function FoldersClient({ userId }: FoldersClientProps) {
{t("loading")}
- ) : folders.length === 0 ? (
+ ) : decks.length === 0 ? (
-
+
-
{t("noFoldersYet")}
+
{t("noDecksYet")}
) : (
- folders.map((folder) => (
-
(
+
))
)}
diff --git a/src/app/decks/[deck_id]/AddCardModal.tsx b/src/app/decks/[deck_id]/AddCardModal.tsx
new file mode 100644
index 0000000..68f2d96
--- /dev/null
+++ b/src/app/decks/[deck_id]/AddCardModal.tsx
@@ -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(null);
+ const definitionRef = useRef(null);
+ const ipaRef = useRef(null);
+ const exampleRef = useRef(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 (
+ {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleAdd();
+ }
+ }}
+ >
+
+
+
+ {t("addNewCard")}
+
+
+
+
+
+
+ {isSubmitting ? t("adding") : t("add")}
+
+
+
+
+ );
+}
diff --git a/src/app/folders/[folder_id]/TextPairCard.tsx b/src/app/decks/[deck_id]/CardItem.tsx
similarity index 59%
rename from src/app/folders/[folder_id]/TextPairCard.tsx
rename to src/app/decks/[deck_id]/CardItem.tsx
index 1c8ca65..b96fd79 100644
--- a/src/app/folders/[folder_id]/TextPairCard.tsx
+++ b/src/app/decks/[deck_id]/CardItem.tsx
@@ -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 (
- {textPair.language1.toUpperCase()}
-
- →
-
- {textPair.language2.toUpperCase()}
+ {t("card")}
@@ -60,26 +59,25 @@ export function TextPairCard({
- {textPair.text1.length > 30
- ? textPair.text1.substring(0, 30) + "..."
- : textPair.text1}
+ {field1.length > 30
+ ? field1.substring(0, 30) + "..."
+ : field1}
- {textPair.text2.length > 30
- ? textPair.text2.substring(0, 30) + "..."
- : textPair.text2}
+ {field2.length > 30
+ ? field2.substring(0, 30) + "..."
+ : field2}
-
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}
/>
);
diff --git a/src/app/folders/[folder_id]/InFolder.tsx b/src/app/decks/[deck_id]/InDeck.tsx
similarity index 57%
rename from src/app/folders/[folder_id]/InFolder.tsx
rename to src/app/decks/[deck_id]/InDeck.tsx
index 5380e5c..177a6f8 100644
--- a/src/app/folders/[folder_id]/InFolder.tsx
+++ b/src/app/decks/[deck_id]/InDeck.tsx
@@ -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([]);
+export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boolean; }) {
+ const [cards, setCards] = useState([]);
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 (
- {/* 顶部导航和标题栏 */}
- {/* 返回按钮 */}
{t("back")}
- {/* 页面标题和操作按钮 */}
- {/* 标题区域 */}
- {t("textPairs")}
+ {t("cards")}
- {t("itemsCount", { count: textPairs.length })}
+ {t("itemsCount", { count: cards.length })}
- {/* 操作按钮区域 */}
{
- redirect(`/memorize?folder_id=${folderId}`);
+ redirect(`/memorize?deck_id=${deckId}`);
}}
>
{t("memorize")}
@@ -101,64 +96,46 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
- {/* 文本对列表 */}
{loading ? (
- // 加载状态
-
{t("loadingTextPairs")}
+
{t("loadingCards")}
- ) : textPairs.length === 0 ? (
- // 空状态
+ ) : cards.length === 0 ? (
-
{t("noTextPairs")}
+
{t("noCards")}
) : (
- // 文本对卡片列表
- {textPairs
- .toSorted((a, b) => a.id - b.id)
- .map((textPair) => (
- Number(BigInt(a.id) - BigInt(b.id)))
+ .map((card) => (
+ {
- 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}
/>
))}
)}
- {/* 添加文本对模态框 */}
-
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}
/>
);
diff --git a/src/app/decks/[deck_id]/UpdateCardModal.tsx b/src/app/decks/[deck_id]/UpdateCardModal.tsx
new file mode 100644
index 0000000..c22aabb
--- /dev/null
+++ b/src/app/decks/[deck_id]/UpdateCardModal.tsx
@@ -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(null);
+ const definitionRef = useRef(null);
+ const ipaRef = useRef(null);
+ const exampleRef = useRef(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 (
+ {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleUpdate();
+ }
+ }}
+ >
+
+
+
+ {t("updateCard")}
+
+
+
+
+
+
+ {isSubmitting ? t("updating") : t("update")}
+
+
+
+
+ );
+}
diff --git a/src/app/decks/[deck_id]/page.tsx b/src/app/decks/[deck_id]/page.tsx
new file mode 100644
index 0000000..7dfbbf2
--- /dev/null
+++ b/src/app/decks/[deck_id]/page.tsx
@@ -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 ;
+}
diff --git a/src/app/folders/page.tsx b/src/app/decks/page.tsx
similarity index 54%
rename from src/app/folders/page.tsx
rename to src/app/decks/page.tsx
index c160c42..0eaef49 100644
--- a/src/app/folders/page.tsx
+++ b/src/app/decks/page.tsx
@@ -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 ;
+ return ;
}
diff --git a/src/app/folders/[folder_id]/AddTextPairModal.tsx b/src/app/folders/[folder_id]/AddTextPairModal.tsx
deleted file mode 100644
index a76fe69..0000000
--- a/src/app/folders/[folder_id]/AddTextPairModal.tsx
+++ /dev/null
@@ -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(null);
- const input2Ref = useRef(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 (
- {
- if (e.key === "Enter") {
- e.preventDefault();
- handleAdd();
- }
- }}
- >
-
-
-
- {t("addNewTextPair")}
-
-
-
-
-
- {t("text1")}
-
-
-
- {t("text2")}
-
-
-
- {t("language1")}
-
-
-
- {t("language2")}
-
-
-
-
{t("add")}
-
-
- );
-}
diff --git a/src/app/folders/[folder_id]/UpdateTextPairModal.tsx b/src/app/folders/[folder_id]/UpdateTextPairModal.tsx
deleted file mode 100644
index cce3032..0000000
--- a/src/app/folders/[folder_id]/UpdateTextPairModal.tsx
+++ /dev/null
@@ -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(null);
- const input2Ref = useRef(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 (
- {
- if (e.key === "Enter") {
- e.preventDefault();
- handleUpdate();
- }
- }}
- >
-
-
-
- {t("updateTextPair")}
-
-
-
-
-
- {t("text1")}
-
-
-
- {t("text2")}
-
-
-
- {t("language1")}
-
-
-
- {t("language2")}
-
-
-
-
{t("update")}
-
-
- );
-}
diff --git a/src/app/folders/[folder_id]/page.tsx b/src/app/folders/[folder_id]/page.tsx
deleted file mode 100644
index 066b0b7..0000000
--- a/src/app/folders/[folder_id]/page.tsx
+++ /dev/null
@@ -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 ;
-}
diff --git a/src/modules/card/card-action-dto.ts b/src/modules/card/card-action-dto.ts
new file mode 100644
index 0000000..e76318e
--- /dev/null
+++ b/src/modules/card/card-action-dto.ts
@@ -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;
+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;
+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;
+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;
+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;
+export const validateActionInputGetCardsByDeckId = generateValidator(schemaActionInputGetCardsByDeckId);
+
+export const schemaActionInputGetCardStats = z.object({
+ deckId: z.number().int().positive(),
+});
+export type ActionInputGetCardStats = z.infer;
+export const validateActionInputGetCardStats = generateValidator(schemaActionInputGetCardStats);
+
+export const schemaActionInputDeleteCard = z.object({
+ cardId: z.bigint(),
+});
+export type ActionInputDeleteCard = z.infer;
+export const validateActionInputDeleteCard = generateValidator(schemaActionInputDeleteCard);
+
+export const schemaActionInputGetCardById = z.object({
+ cardId: z.bigint(),
+});
+export type ActionInputGetCardById = z.infer;
+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;
+};
diff --git a/src/modules/card/card-action.ts b/src/modules/card/card-action.ts
new file mode 100644
index 0000000..6fcd9e0
--- /dev/null
+++ b/src/modules/card/card-action.ts
@@ -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 {
+ 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 {
+ const session = await auth.api.getSession({ headers: await headers() });
+ return session?.user?.id ?? null;
+}
+
+export async function actionCreateCard(
+ input: unknown,
+): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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" };
+ }
+}
diff --git a/src/modules/card/card-repository-dto.ts b/src/modules/card/card-repository-dto.ts
new file mode 100644
index 0000000..ab82557
--- /dev/null
+++ b/src/modules/card/card-repository-dto.ts
@@ -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;
+};
diff --git a/src/modules/card/card-repository.ts b/src/modules/card/card-repository.ts
new file mode 100644
index 0000000..43faafd
--- /dev/null
+++ b/src/modules/card/card-repository.ts
@@ -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 {
+ 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 {
+ 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 {
+ const card = await prisma.card.findUnique({
+ where: { id },
+ });
+ return card;
+}
+
+export async function repoGetCardByIdWithNote(
+ id: bigint,
+): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ const cards = await prisma.card.findMany({
+ where: { noteId },
+ orderBy: { ord: "asc" },
+ });
+ return cards;
+}
diff --git a/src/modules/card/card-service-dto.ts b/src/modules/card/card-service-dto.ts
new file mode 100644
index 0000000..78f76bc
--- /dev/null
+++ b/src/modules/card/card-service-dto.ts
@@ -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;
diff --git a/src/modules/card/card-service.ts b/src/modules/card/card-service.ts
new file mode 100644
index 0000000..834100f
--- /dev/null
+++ b/src/modules/card/card-service.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ log.debug("Getting cards for review", { deckId: input.deckId });
+ return repoGetCardsForReview(input);
+}
+
+export async function serviceGetNewCards(
+ input: ServiceInputGetNewCards,
+): Promise {
+ log.debug("Getting new cards", { deckId: input.deckId });
+ return repoGetNewCards(input);
+}
+
+export async function serviceGetCardsByDeckId(
+ input: ServiceInputGetCardsByDeckId,
+): Promise {
+ 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 {
+ log.debug("Getting cards by deck with notes", { deckId: input.deckId });
+ return repoGetCardsByDeckIdWithNotes(input);
+}
+
+export async function serviceGetCardById(
+ cardId: bigint,
+): Promise {
+ const card = await repoGetCardById(cardId);
+ return card ? mapToServiceOutput(card) : null;
+}
+
+export async function serviceGetCardByIdWithNote(
+ cardId: bigint,
+): Promise {
+ return repoGetCardByIdWithNote(cardId);
+}
+
+export async function serviceGetCardStats(
+ input: ServiceInputGetCardStats,
+): Promise {
+ log.debug("Getting card stats", { deckId: input.deckId });
+ return repoGetCardStats(input.deckId);
+}
+
+export async function serviceDeleteCard(cardId: bigint): Promise {
+ log.info("Deleting card", { cardId: cardId.toString() });
+ await repoDeleteCard(cardId);
+}
diff --git a/src/modules/deck/deck-action-dto.ts b/src/modules/deck/deck-action-dto.ts
new file mode 100644
index 0000000..c4de5ba
--- /dev/null
+++ b/src/modules/deck/deck-action-dto.ts
@@ -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;
+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;
+export const validateActionInputUpdateDeck = generateValidator(schemaActionInputUpdateDeck);
+
+export const schemaActionInputDeleteDeck = z.object({
+ deckId: z.number().int().positive(),
+});
+export type ActionInputDeleteDeck = z.infer;
+export const validateActionInputDeleteDeck = generateValidator(schemaActionInputDeleteDeck);
+
+export const schemaActionInputGetDeckById = z.object({
+ deckId: z.number().int().positive(),
+});
+export type ActionInputGetDeckById = z.infer;
+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;
+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;
+export const validateActionInputSearchPublicDecks = generateValidator(schemaActionInputSearchPublicDecks);
+
+export const schemaActionInputGetPublicDeckById = z.object({
+ deckId: z.number().int().positive(),
+});
+export type ActionInputGetPublicDeckById = z.infer;
+export const validateActionInputGetPublicDeckById = generateValidator(schemaActionInputGetPublicDeckById);
+
+export const schemaActionInputToggleDeckFavorite = z.object({
+ deckId: z.number().int().positive(),
+});
+export type ActionInputToggleDeckFavorite = z.infer;
+export const validateActionInputToggleDeckFavorite = generateValidator(schemaActionInputToggleDeckFavorite);
+
+export const schemaActionInputCheckDeckFavorite = z.object({
+ deckId: z.number().int().positive(),
+});
+export type ActionInputCheckDeckFavorite = z.infer;
+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[];
+};
diff --git a/src/modules/deck/deck-action.ts b/src/modules/deck/deck-action.ts
new file mode 100644
index 0000000..fce1d0e
--- /dev/null
+++ b/src/modules/deck/deck-action.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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" };
+ }
+}
diff --git a/src/modules/deck/deck-repository-dto.ts b/src/modules/deck/deck-repository-dto.ts
new file mode 100644
index 0000000..bf35f20
--- /dev/null
+++ b/src/modules/deck/deck-repository-dto.ts
@@ -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;
+};
diff --git a/src/modules/deck/deck-repository.ts b/src/modules/deck/deck-repository.ts
new file mode 100644
index 0000000..d13e82b
--- /dev/null
+++ b/src/modules/deck/deck-repository.ts
@@ -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 {
+ 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 {
+ const { id, ...updateData } = input;
+ await prisma.deck.update({
+ where: { id },
+ data: updateData,
+ });
+}
+
+export async function repoGetDeckById(input: RepoInputGetDeckById): Promise {
+ 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 {
+ 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 {
+ 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 {
+ await prisma.deck.delete({
+ where: { id: input.id },
+ });
+}
+
+export async function repoGetUserIdByDeckId(deckId: number): Promise {
+ const deck = await prisma.deck.findUnique({
+ where: { id: deckId },
+ select: { userId: true },
+ });
+ return deck?.userId ?? null;
+}
+
+export async function repoGetDeckOwnership(deckId: number): Promise {
+ const deck = await prisma.deck.findUnique({
+ where: { id: deckId },
+ select: { userId: true },
+ });
+ return deck;
+}
+
+export async function repoGetPublicDeckById(input: RepoInputGetPublicDeckById): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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,
+ }));
+}
diff --git a/src/modules/deck/deck-service-dto.ts b/src/modules/deck/deck-service-dto.ts
new file mode 100644
index 0000000..e24559c
--- /dev/null
+++ b/src/modules/deck/deck-service-dto.ts
@@ -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;
+};
diff --git a/src/modules/deck/deck-service.ts b/src/modules/deck/deck-service.ts
new file mode 100644
index 0000000..a3a276a
--- /dev/null
+++ b/src/modules/deck/deck-service.ts
@@ -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 {
+ 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" };
+ }
+}
diff --git a/src/modules/folder/folder-action-dto.ts b/src/modules/folder/folder-action-dto.ts
deleted file mode 100644
index e1db997..0000000
--- a/src/modules/folder/folder-action-dto.ts
+++ /dev/null
@@ -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;
-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;
-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;
-
-export const schemaActionInputSearchPublicFolders = z.object({
- query: z.string().min(1).max(100),
-});
-export type ActionInputSearchPublicFolders = z.infer;
-
-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[];
-};
diff --git a/src/modules/folder/folder-action.ts b/src/modules/folder/folder-action.ts
deleted file mode 100644
index 1366793..0000000
--- a/src/modules/folder/folder-action.ts
+++ /dev/null
@@ -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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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.',
- };
- }
-}
diff --git a/src/modules/folder/folder-repository-dto.ts b/src/modules/folder/folder-repository-dto.ts
deleted file mode 100644
index c743514..0000000
--- a/src/modules/folder/folder-repository-dto.ts
+++ /dev/null
@@ -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;
-};
diff --git a/src/modules/folder/folder-repository.ts b/src/modules/folder/folder-repository.ts
deleted file mode 100644
index 71b34dc..0000000
--- a/src/modules/folder/folder-repository.ts
+++ /dev/null
@@ -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 {
- await prisma.folder.update({
- where: { id: input.folderId },
- data: { visibility: input.visibility },
- });
-}
-
-export async function repoGetFolderVisibility(
- folderId: number,
-): Promise {
- const folder = await prisma.folder.findUnique({
- where: { id: folderId },
- select: { visibility: true, userId: true },
- });
- return folder;
-}
-
-export async function repoGetPublicFolderById(
- folderId: number,
-): Promise {
- 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 {
- 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 {
- 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 {
- 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 {
- 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,
- }));
-}
diff --git a/src/modules/folder/folder-service-dto.ts b/src/modules/folder/folder-service-dto.ts
deleted file mode 100644
index af91b9e..0000000
--- a/src/modules/folder/folder-service-dto.ts
+++ /dev/null
@@ -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;
-};
diff --git a/src/modules/folder/folder-service.ts b/src/modules/folder/folder-service.ts
deleted file mode 100644
index e69de29..0000000
diff --git a/src/modules/note-type/note-type-action-dto.ts b/src/modules/note-type/note-type-action-dto.ts
new file mode 100644
index 0000000..459c93a
--- /dev/null
+++ b/src/modules/note-type/note-type-action-dto.ts
@@ -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;
+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;
+export const validateActionInputUpdateNoteType = generateValidator(schemaActionInputUpdateNoteType);
+
+export const schemaActionInputGetNoteTypeById = z.object({
+ id: z.number().int().positive(),
+});
+export type ActionInputGetNoteTypeById = z.infer;
+export const validateActionInputGetNoteTypeById = generateValidator(schemaActionInputGetNoteTypeById);
+
+export const schemaActionInputDeleteNoteType = z.object({
+ id: z.number().int().positive(),
+});
+export type ActionInputDeleteNoteType = z.infer;
+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;
+};
diff --git a/src/modules/note-type/note-type-action.ts b/src/modules/note-type/note-type-action.ts
new file mode 100644
index 0000000..6d43df4
--- /dev/null
+++ b/src/modules/note-type/note-type-action.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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,
+ };
+}
diff --git a/src/modules/note-type/note-type-repository-dto.ts b/src/modules/note-type/note-type-repository-dto.ts
new file mode 100644
index 0000000..1ba0e68
--- /dev/null
+++ b/src/modules/note-type/note-type-repository-dto.ts
@@ -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}}
{{IPA}}",
+ afmt: "{{FrontSide}}
{{Definition}}
{{Example}}",
+ },
+ {
+ name: "Definition → Word",
+ ord: 1,
+ qfmt: "{{Definition}}",
+ afmt: "{{FrontSide}}
{{Word}}
{{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}}
{{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;
+}`;
diff --git a/src/modules/note-type/note-type-repository.ts b/src/modules/note-type/note-type-repository.ts
new file mode 100644
index 0000000..b32d4be
--- /dev/null
+++ b/src/modules/note-type/note-type-repository.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ const noteType = await prisma.noteType.findUnique({
+ where: { id: noteTypeId },
+ select: { userId: true },
+ });
+
+ return noteType;
+}
+
+export async function repoDeleteNoteType(
+ input: RepoInputDeleteNoteType,
+): Promise {
+ await prisma.noteType.delete({
+ where: { id: input.id },
+ });
+
+ log.info("Deleted note type", { id: input.id });
+}
+
+export async function repoCheckNotesExist(
+ input: RepoInputCheckNotesExist,
+): Promise {
+ const count = await prisma.note.count({
+ where: { noteTypeId: input.noteTypeId },
+ });
+
+ return {
+ exists: count > 0,
+ count,
+ };
+}
+
+export async function repoGetNoteTypeNameById(
+ noteTypeId: number,
+): Promise {
+ const noteType = await prisma.noteType.findUnique({
+ where: { id: noteTypeId },
+ select: { name: true },
+ });
+
+ return noteType?.name ?? null;
+}
diff --git a/src/modules/note-type/note-type-service-dto.ts b/src/modules/note-type/note-type-service-dto.ts
new file mode 100644
index 0000000..aa5eae8
--- /dev/null
+++ b/src/modules/note-type/note-type-service-dto.ts
@@ -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[];
+};
diff --git a/src/modules/note-type/note-type-service.ts b/src/modules/note-type/note-type-service.ts
new file mode 100644
index 0000000..5edd904
--- /dev/null
+++ b/src/modules/note-type/note-type-service.ts
@@ -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();
+ const seenOrds = new Set();
+
+ 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();
+ const seenOrds = new Set();
+
+ 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 {
+ 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 {
+ 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 {
+ return repoGetNoteTypeById(input);
+}
+
+export async function serviceGetNoteTypesByUserId(
+ input: ServiceInputGetNoteTypesByUserId,
+): Promise {
+ return repoGetNoteTypesByUserId(input);
+}
+
+export async function serviceDeleteNoteType(
+ input: ServiceInputDeleteNoteType,
+): Promise {
+ 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 });
+}
diff --git a/src/modules/note/note-action-dto.ts b/src/modules/note/note-action-dto.ts
new file mode 100644
index 0000000..bd0ae5b
--- /dev/null
+++ b/src/modules/note/note-action-dto.ts
@@ -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;
+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;
+export const validateActionInputUpdateNote = generateValidator(
+ schemaActionInputUpdateNote,
+);
+
+export const schemaActionInputDeleteNote = z.object({
+ noteId: z.bigint(),
+});
+export type ActionInputDeleteNote = z.infer;
+export const validateActionInputDeleteNote = generateValidator(
+ schemaActionInputDeleteNote,
+);
+
+export const schemaActionInputGetNoteById = z.object({
+ noteId: z.bigint(),
+});
+export type ActionInputGetNoteById = z.infer;
+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;
+ };
+};
diff --git a/src/modules/note/note-action.ts b/src/modules/note/note-action.ts
new file mode 100644
index 0000000..e6288e1
--- /dev/null
+++ b/src/modules/note/note-action.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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" };
+ }
+}
diff --git a/src/modules/note/note-repository-dto.ts b/src/modules/note/note-repository-dto.ts
new file mode 100644
index 0000000..a98f0f8
--- /dev/null
+++ b/src/modules/note/note-repository-dto.ts
@@ -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 & {
+ 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[];
diff --git a/src/modules/note/note-repository.ts b/src/modules/note/note-repository.ts
new file mode 100644
index 0000000..be7eaf1
--- /dev/null
+++ b/src/modules/note/note-repository.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ return prisma.note.count({
+ where: { userId },
+ });
+}
+
+export async function repoCountNotesByNoteTypeId(
+ noteTypeId: number,
+): Promise {
+ return prisma.note.count({
+ where: { noteTypeId },
+ });
+}
diff --git a/src/modules/note/note-service-dto.ts b/src/modules/note/note-service-dto.ts
new file mode 100644
index 0000000..d93f09e
--- /dev/null
+++ b/src/modules/note/note-service-dto.ts
@@ -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;
+};
diff --git a/src/modules/note/note-service.ts b/src/modules/note/note-service.ts
new file mode 100644
index 0000000..27a6176
--- /dev/null
+++ b/src/modules/note/note-service.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ const count = await repoCountNotesByUserId(userId);
+ return { count };
+}
+
+export async function serviceCountNotesByNoteTypeId(
+ noteTypeId: number,
+): Promise {
+ const count = await repoCountNotesByNoteTypeId(noteTypeId);
+ return { count };
+}
diff --git a/src/modules/ocr/ocr-action-dto.ts b/src/modules/ocr/ocr-action-dto.ts
index 8aaae49..75f586b 100644
--- a/src/modules/ocr/ocr-action-dto.ts
+++ b/src/modules/ocr/ocr-action-dto.ts
@@ -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(),
});
diff --git a/src/modules/ocr/ocr-repository-dto.ts b/src/modules/ocr/ocr-repository-dto.ts
index 7a1f1b7..cb0ff5c 100644
--- a/src/modules/ocr/ocr-repository-dto.ts
+++ b/src/modules/ocr/ocr-repository-dto.ts
@@ -1 +1 @@
-export type { RepoInputCreatePair } from "@/modules/folder/folder-repository-dto";
+export {};
diff --git a/src/modules/ocr/ocr-repository.ts b/src/modules/ocr/ocr-repository.ts
index a4e1bad..cb0ff5c 100644
--- a/src/modules/ocr/ocr-repository.ts
+++ b/src/modules/ocr/ocr-repository.ts
@@ -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 {};
diff --git a/src/modules/ocr/ocr-service-dto.ts b/src/modules/ocr/ocr-service-dto.ts
index 2591c16..fd5e41e 100644
--- a/src/modules/ocr/ocr-service-dto.ts
+++ b/src/modules/ocr/ocr-service-dto.ts
@@ -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(),
});
diff --git a/src/modules/ocr/ocr-service.ts b/src/modules/ocr/ocr-service.ts
index 5aff329..fb1adfc 100644
--- a/src/modules/ocr/ocr-service.ts
+++ b/src/modules/ocr/ocr-service.ts
@@ -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 {
+ 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}}
{{Definition}}",
+ },
+ {
+ name: "Definition → Word",
+ ord: 1,
+ qfmt: "{{Definition}}",
+ afmt: "{{FrontSide}}
{{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 {
- 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
});
diff --git a/src/shared/anki-type.ts b/src/shared/anki-type.ts
new file mode 100644
index 0000000..ec600e5
--- /dev/null
+++ b/src/shared/anki-type.ts
@@ -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;
+ 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;
+}
diff --git a/src/shared/folder-type.ts b/src/shared/folder-type.ts
deleted file mode 100644
index afa15f4..0000000
--- a/src/shared/folder-type.ts
+++ /dev/null
@@ -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;
-};
\ No newline at end of file