From 57ad1b869987d99dd9172cbdfcbba9566b5cafbf Mon Sep 17 00:00:00 2001 From: goddonebianu Date: Tue, 10 Mar 2026 19:20:46 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=AE=8C=E5=85=A8=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E4=B8=BA=20Anki=20=E5=85=BC=E5=AE=B9=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 用 Deck 替换 Folder - 用 Note + Card 替换 Pair (双向复习) - 添加 NoteType (卡片模板) - 添加 Revlog (复习历史) - 实现 SM-2 间隔重复算法 - 更新所有前端页面 - 添加数据库迁移 --- messages/de-DE.json | 83 ++- messages/en-US.json | 102 ++-- messages/fr-FR.json | 85 ++- messages/it-IT.json | 81 ++- messages/ja-JP.json | 72 ++- messages/ko-KR.json | 87 ++- messages/ug-CN.json | 81 ++- messages/zh-CN.json | 104 ++-- .../migration.sql | 207 +++++++ prisma/schema.prisma | 196 +++++-- src/app/(auth)/users/[username]/page.tsx | 36 +- .../dictionary/DictionaryClient.tsx | 131 +++-- src/app/(features)/dictionary/page.tsx | 12 +- src/app/(features)/explore/ExploreClient.tsx | 84 +-- .../explore/[id]/ExploreDetailClient.tsx | 42 +- src/app/(features)/explore/[id]/page.tsx | 8 +- src/app/(features)/explore/page.tsx | 8 +- .../(features)/favorites/FavoritesClient.tsx | 55 +- src/app/(features)/favorites/page.tsx | 10 +- src/app/(features)/memorize/DeckSelector.tsx | 114 ++++ .../(features)/memorize/FolderSelector.tsx | 93 ---- src/app/(features)/memorize/Memorize.tsx | 401 +++++++------ src/app/(features)/memorize/page.tsx | 46 +- src/app/(features)/ocr/OCRClient.tsx | 277 +++++---- src/app/(features)/ocr/page.tsx | 12 +- .../DecksClient.tsx} | 139 ++--- src/app/decks/[deck_id]/AddCardModal.tsx | 154 +++++ .../[deck_id]/CardItem.tsx} | 54 +- .../[deck_id]/InDeck.tsx} | 93 ++-- src/app/decks/[deck_id]/UpdateCardModal.tsx | 132 +++++ src/app/decks/[deck_id]/page.tsx | 37 ++ src/app/{folders => decks}/page.tsx | 8 +- .../folders/[folder_id]/AddTextPairModal.tsx | 99 ---- .../[folder_id]/UpdateTextPairModal.tsx | 103 ---- src/app/folders/[folder_id]/page.tsx | 37 -- src/modules/card/card-action-dto.ts | 164 ++++++ src/modules/card/card-action.ts | 428 ++++++++++++++ src/modules/card/card-repository-dto.ts | 104 ++++ src/modules/card/card-repository.ts | 309 ++++++++++ src/modules/card/card-service-dto.ts | 113 ++++ src/modules/card/card-service.ts | 384 +++++++++++++ src/modules/deck/deck-action-dto.ts | 157 ++++++ src/modules/deck/deck-action.ts | 327 +++++++++++ src/modules/deck/deck-repository-dto.ts | 90 +++ src/modules/deck/deck-repository.ts | 327 +++++++++++ src/modules/deck/deck-service-dto.ts | 86 +++ src/modules/deck/deck-service.ts | 169 ++++++ src/modules/folder/folder-action-dto.ts | 110 ---- src/modules/folder/folder-action.ts | 527 ------------------ src/modules/folder/folder-repository-dto.ts | 91 --- src/modules/folder/folder-repository.ts | 333 ----------- src/modules/folder/folder-service-dto.ts | 108 ---- src/modules/folder/folder-service.ts | 0 src/modules/note-type/note-type-action-dto.ts | 106 ++++ src/modules/note-type/note-type-action.ts | 255 +++++++++ .../note-type/note-type-repository-dto.ts | 181 ++++++ src/modules/note-type/note-type-repository.ts | 151 +++++ .../note-type/note-type-service-dto.ts | 60 ++ src/modules/note-type/note-type-service.ts | 272 +++++++++ src/modules/note/note-action-dto.ts | 131 +++++ src/modules/note/note-action.ts | 344 ++++++++++++ src/modules/note/note-repository-dto.ts | 72 +++ src/modules/note/note-repository.ts | 283 ++++++++++ src/modules/note/note-service-dto.ts | 60 ++ src/modules/note/note-service.ts | 200 +++++++ src/modules/ocr/ocr-action-dto.ts | 2 +- src/modules/ocr/ocr-repository-dto.ts | 2 +- src/modules/ocr/ocr-repository.ts | 6 +- src/modules/ocr/ocr-service-dto.ts | 2 +- src/modules/ocr/ocr-service.ts | 98 +++- src/shared/anki-type.ts | 165 ++++++ src/shared/folder-type.ts | 37 -- 72 files changed, 7107 insertions(+), 2430 deletions(-) create mode 100644 prisma/migrations/20260310111728_anki_refactor/migration.sql create mode 100644 src/app/(features)/memorize/DeckSelector.tsx delete mode 100644 src/app/(features)/memorize/FolderSelector.tsx rename src/app/{folders/FoldersClient.tsx => decks/DecksClient.tsx} (54%) create mode 100644 src/app/decks/[deck_id]/AddCardModal.tsx rename src/app/{folders/[folder_id]/TextPairCard.tsx => decks/[deck_id]/CardItem.tsx} (59%) rename src/app/{folders/[folder_id]/InFolder.tsx => decks/[deck_id]/InDeck.tsx} (57%) create mode 100644 src/app/decks/[deck_id]/UpdateCardModal.tsx create mode 100644 src/app/decks/[deck_id]/page.tsx rename src/app/{folders => decks}/page.tsx (54%) delete mode 100644 src/app/folders/[folder_id]/AddTextPairModal.tsx delete mode 100644 src/app/folders/[folder_id]/UpdateTextPairModal.tsx delete mode 100644 src/app/folders/[folder_id]/page.tsx create mode 100644 src/modules/card/card-action-dto.ts create mode 100644 src/modules/card/card-action.ts create mode 100644 src/modules/card/card-repository-dto.ts create mode 100644 src/modules/card/card-repository.ts create mode 100644 src/modules/card/card-service-dto.ts create mode 100644 src/modules/card/card-service.ts create mode 100644 src/modules/deck/deck-action-dto.ts create mode 100644 src/modules/deck/deck-action.ts create mode 100644 src/modules/deck/deck-repository-dto.ts create mode 100644 src/modules/deck/deck-repository.ts create mode 100644 src/modules/deck/deck-service-dto.ts create mode 100644 src/modules/deck/deck-service.ts delete mode 100644 src/modules/folder/folder-action-dto.ts delete mode 100644 src/modules/folder/folder-action.ts delete mode 100644 src/modules/folder/folder-repository-dto.ts delete mode 100644 src/modules/folder/folder-repository.ts delete mode 100644 src/modules/folder/folder-service-dto.ts delete mode 100644 src/modules/folder/folder-service.ts create mode 100644 src/modules/note-type/note-type-action-dto.ts create mode 100644 src/modules/note-type/note-type-action.ts create mode 100644 src/modules/note-type/note-type-repository-dto.ts create mode 100644 src/modules/note-type/note-type-repository.ts create mode 100644 src/modules/note-type/note-type-service-dto.ts create mode 100644 src/modules/note-type/note-type-service.ts create mode 100644 src/modules/note/note-action-dto.ts create mode 100644 src/modules/note/note-action.ts create mode 100644 src/modules/note/note-repository-dto.ts create mode 100644 src/modules/note/note-repository.ts create mode 100644 src/modules/note/note-service-dto.ts create mode 100644 src/modules/note/note-service.ts create mode 100644 src/shared/anki-type.ts delete mode 100644 src/shared/folder-type.ts 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")}

) : (
- {folders.map((folder) => ( - + {decks.map((deck) => ( + 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")}

+
+
+

{t("loading")}

+
); } - 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)} + +
+
+ )} + +
+
+
+ {front} +
+
+ + {showAnswer && ( + <> +
+
+
+ {back} +
+
+ + )} +
+ +
+ {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 ? ( -
+
Preview -
- { - 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("languageHints")} +

+
+ setSourceLanguage(e.target.value)} + className="w-full" + /> + setTargetLanguage(e.target.value)} + className="w-full" />
- - -
-
{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
- {t("folders.folderName")} + {t("decks.deckName")} - {t("folders.totalPairs")} + {t("decks.totalCards")} - {t("folders.createdAt")} + {t("decks.createdAt")} - {t("folders.actions")} + {t("decks.actions")}
-
{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")}