refactor: 完全重构为 Anki 兼容数据结构

- 用 Deck 替换 Folder
- 用 Note + Card 替换 Pair (双向复习)
- 添加 NoteType (卡片模板)
- 添加 Revlog (复习历史)
- 实现 SM-2 间隔重复算法
- 更新所有前端页面
- 添加数据库迁移
This commit is contained in:
2026-03-10 19:20:46 +08:00
parent 9b78fd5215
commit 57ad1b8699
72 changed files with 7107 additions and 2430 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "表示"

View File

@@ -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": "보기"

View File

@@ -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": "كۆرۈش"

View File

@@ -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": "查看"

View File

@@ -0,0 +1,207 @@
/*
Warnings:
- You are about to drop the `folder_favorites` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `folders` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `pairs` table. If the table is not empty, all the data it contains will be lost.
*/
-- CreateEnum
CREATE TYPE "CardType" AS ENUM ('NEW', 'LEARNING', 'REVIEW', 'RELEARNING');
-- CreateEnum
CREATE TYPE "CardQueue" AS ENUM ('USER_BURIED', 'SCHED_BURIED', 'SUSPENDED', 'NEW', 'LEARNING', 'REVIEW', 'IN_LEARNING', 'PREVIEW');
-- CreateEnum
CREATE TYPE "NoteKind" AS ENUM ('STANDARD', 'CLOZE');
-- DropForeignKey
ALTER TABLE "folder_favorites" DROP CONSTRAINT "folder_favorites_folder_id_fkey";
-- DropForeignKey
ALTER TABLE "folder_favorites" DROP CONSTRAINT "folder_favorites_user_id_fkey";
-- DropForeignKey
ALTER TABLE "folders" DROP CONSTRAINT "folders_user_id_fkey";
-- DropForeignKey
ALTER TABLE "pairs" DROP CONSTRAINT "pairs_folder_id_fkey";
-- DropTable
DROP TABLE "folder_favorites";
-- DropTable
DROP TABLE "folders";
-- DropTable
DROP TABLE "pairs";
-- CreateTable
CREATE TABLE "note_types" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"kind" "NoteKind" NOT NULL DEFAULT 'STANDARD',
"css" TEXT NOT NULL DEFAULT '',
"fields" JSONB NOT NULL DEFAULT '[]',
"templates" JSONB NOT NULL DEFAULT '[]',
"user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "note_types_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "decks" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"desc" TEXT NOT NULL DEFAULT '',
"user_id" TEXT NOT NULL,
"visibility" "Visibility" NOT NULL DEFAULT 'PRIVATE',
"collapsed" BOOLEAN NOT NULL DEFAULT false,
"conf" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "decks_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "deck_favorites" (
"id" SERIAL NOT NULL,
"user_id" TEXT NOT NULL,
"deck_id" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "deck_favorites_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notes" (
"id" BIGINT NOT NULL,
"guid" TEXT NOT NULL,
"note_type_id" INTEGER NOT NULL,
"mod" INTEGER NOT NULL,
"usn" INTEGER NOT NULL DEFAULT -1,
"tags" TEXT NOT NULL DEFAULT ' ',
"flds" TEXT NOT NULL,
"sfld" TEXT NOT NULL,
"csum" INTEGER NOT NULL,
"flags" INTEGER NOT NULL DEFAULT 0,
"data" TEXT NOT NULL DEFAULT '',
"user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cards" (
"id" BIGINT NOT NULL,
"note_id" BIGINT NOT NULL,
"deck_id" INTEGER NOT NULL,
"ord" INTEGER NOT NULL,
"mod" INTEGER NOT NULL,
"usn" INTEGER NOT NULL DEFAULT -1,
"type" "CardType" NOT NULL DEFAULT 'NEW',
"queue" "CardQueue" NOT NULL DEFAULT 'NEW',
"due" INTEGER NOT NULL,
"ivl" INTEGER NOT NULL DEFAULT 0,
"factor" INTEGER NOT NULL DEFAULT 2500,
"reps" INTEGER NOT NULL DEFAULT 0,
"lapses" INTEGER NOT NULL DEFAULT 0,
"left" INTEGER NOT NULL DEFAULT 0,
"odue" INTEGER NOT NULL DEFAULT 0,
"odid" INTEGER NOT NULL DEFAULT 0,
"flags" INTEGER NOT NULL DEFAULT 0,
"data" TEXT NOT NULL DEFAULT '',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "cards_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "revlogs" (
"id" BIGINT NOT NULL,
"card_id" BIGINT NOT NULL,
"usn" INTEGER NOT NULL DEFAULT -1,
"ease" INTEGER NOT NULL,
"ivl" INTEGER NOT NULL,
"lastIvl" INTEGER NOT NULL,
"factor" INTEGER NOT NULL,
"time" INTEGER NOT NULL,
"type" INTEGER NOT NULL,
CONSTRAINT "revlogs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "note_types_user_id_idx" ON "note_types"("user_id");
-- CreateIndex
CREATE INDEX "decks_user_id_idx" ON "decks"("user_id");
-- CreateIndex
CREATE INDEX "decks_visibility_idx" ON "decks"("visibility");
-- CreateIndex
CREATE INDEX "deck_favorites_user_id_idx" ON "deck_favorites"("user_id");
-- CreateIndex
CREATE INDEX "deck_favorites_deck_id_idx" ON "deck_favorites"("deck_id");
-- CreateIndex
CREATE UNIQUE INDEX "deck_favorites_user_id_deck_id_key" ON "deck_favorites"("user_id", "deck_id");
-- CreateIndex
CREATE UNIQUE INDEX "notes_guid_key" ON "notes"("guid");
-- CreateIndex
CREATE INDEX "notes_user_id_idx" ON "notes"("user_id");
-- CreateIndex
CREATE INDEX "notes_note_type_id_idx" ON "notes"("note_type_id");
-- CreateIndex
CREATE INDEX "notes_csum_idx" ON "notes"("csum");
-- CreateIndex
CREATE INDEX "cards_note_id_idx" ON "cards"("note_id");
-- CreateIndex
CREATE INDEX "cards_deck_id_idx" ON "cards"("deck_id");
-- CreateIndex
CREATE INDEX "cards_deck_id_queue_due_idx" ON "cards"("deck_id", "queue", "due");
-- CreateIndex
CREATE INDEX "revlogs_card_id_idx" ON "revlogs"("card_id");
-- AddForeignKey
ALTER TABLE "note_types" ADD CONSTRAINT "note_types_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "decks" ADD CONSTRAINT "decks_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "deck_favorites" ADD CONSTRAINT "deck_favorites_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "deck_favorites" ADD CONSTRAINT "deck_favorites_deck_id_fkey" FOREIGN KEY ("deck_id") REFERENCES "decks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notes" ADD CONSTRAINT "notes_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notes" ADD CONSTRAINT "notes_note_type_id_fkey" FOREIGN KEY ("note_type_id") REFERENCES "note_types"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "cards" ADD CONSTRAINT "cards_note_id_fkey" FOREIGN KEY ("note_id") REFERENCES "notes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "cards" ADD CONSTRAINT "cards_deck_id_fkey" FOREIGN KEY ("deck_id") REFERENCES "decks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "revlogs" ADD CONSTRAINT "revlogs_card_id_fkey" FOREIGN KEY ("card_id") REFERENCES "cards"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -7,6 +7,10 @@ datasource db {
provider = "postgresql"
}
// ============================================
// User & Auth
// ============================================
model User {
id String @id
name String
@@ -20,8 +24,11 @@ model User {
bio String?
accounts Account[]
dictionaryLookUps DictionaryLookUp[]
folders Folder[]
folderFavorites FolderFavorite[]
// Anki-compatible relations
decks Deck[]
deckFavorites DeckFavorite[]
noteTypes NoteType[]
notes Note[]
sessions Session[]
translationHistories TranslationHistory[]
followers Follow[] @relation("UserFollowers")
@@ -77,60 +84,175 @@ model Verification {
@@map("verification")
}
model Pair {
id Int @id @default(autoincrement())
language1 String
language2 String
text1 String
text2 String
ipa1 String?
ipa2 String?
folderId Int @map("folder_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
// ============================================
// Anki-compatible Models
// ============================================
@@unique([folderId, language1, language2, text1, text2])
@@index([folderId])
@@map("pairs")
/// Card type: 0=new, 1=learning, 2=review, 3=relearning
enum CardType {
NEW
LEARNING
REVIEW
RELEARNING
}
/// Card queue: -3=user buried, -2=sched buried, -1=suspended, 0=new, 1=learning, 2=review, 3=in learning, 4=preview
enum CardQueue {
USER_BURIED
SCHED_BURIED
SUSPENDED
NEW
LEARNING
REVIEW
IN_LEARNING
PREVIEW
}
/// Note type: 0=standard, 1=cloze
enum NoteKind {
STANDARD
CLOZE
}
/// Deck visibility (our extension, not in Anki)
enum Visibility {
PRIVATE
PUBLIC
}
model Folder {
/// NoteType (Anki: models) - Defines fields and templates for notes
model NoteType {
id Int @id @default(autoincrement())
name String
kind NoteKind @default(STANDARD)
css String @default("")
fields Json @default("[]")
templates Json @default("[]")
userId String @map("user_id")
visibility Visibility @default(PRIVATE)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
pairs Pair[]
favorites FolderFavorite[]
notes Note[]
@@index([userId])
@@map("note_types")
}
/// Deck (Anki: decks) - Container for cards
model Deck {
id Int @id @default(autoincrement())
name String
desc String @default("")
userId String @map("user_id")
visibility Visibility @default(PRIVATE)
collapsed Boolean @default(false)
conf Json @default("{}")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
cards Card[]
favorites DeckFavorite[]
@@index([userId])
@@index([visibility])
@@map("folders")
@@map("decks")
}
model FolderFavorite {
/// DeckFavorite - Users can favorite public decks
model DeckFavorite {
id Int @id @default(autoincrement())
userId String @map("user_id")
folderId Int @map("folder_id")
deckId Int @map("deck_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
@@unique([userId, folderId])
@@unique([userId, deckId])
@@index([userId])
@@index([folderId])
@@map("folder_favorites")
@@index([deckId])
@@map("deck_favorites")
}
/// Note (Anki: notes) - Contains field data, one note can have multiple cards
model Note {
id BigInt @id
guid String @unique
noteTypeId Int @map("note_type_id")
mod Int
usn Int @default(-1)
tags String @default(" ")
flds String
sfld String
csum Int
flags Int @default(0)
data String @default("")
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
noteType NoteType @relation(fields: [noteTypeId], references: [id], onDelete: Cascade)
cards Card[]
@@index([userId])
@@index([noteTypeId])
@@index([csum])
@@map("notes")
}
/// Card (Anki: cards) - Scheduling information, what you review
model Card {
id BigInt @id
noteId BigInt @map("note_id")
deckId Int @map("deck_id")
ord Int
mod Int
usn Int @default(-1)
type CardType @default(NEW)
queue CardQueue @default(NEW)
due Int
ivl Int @default(0)
factor Int @default(2500)
reps Int @default(0)
lapses Int @default(0)
left Int @default(0)
odue Int @default(0)
odid Int @default(0)
flags Int @default(0)
data String @default("")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
revlogs Revlog[]
@@index([noteId])
@@index([deckId])
@@index([deckId, queue, due])
@@map("cards")
}
/// Revlog (Anki: revlog) - Review history
model Revlog {
id BigInt @id
cardId BigInt @map("card_id")
usn Int @default(-1)
ease Int
ivl Int
lastIvl Int
factor Int
time Int
type Int
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
@@index([cardId])
@@map("revlogs")
}
// ============================================
// Other Models
// ============================================
model DictionaryLookUp {
id Int @id @default(autoincrement())
userId String? @map("user_id")
@@ -140,8 +262,8 @@ model DictionaryLookUp {
createdAt DateTime @default(now()) @map("created_at")
dictionaryItemId Int? @map("dictionary_item_id")
normalizedText String @default("") @map("normalized_text")
dictionaryItem DictionaryItem? @relation(fields: [dictionaryItemId], references: [id])
user User? @relation(fields: [userId], references: [id])
dictionaryItem DictionaryItem? @relation(fields: [dictionaryItemId], references: [id], onDelete: SetNull)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([userId])
@@index([createdAt])

View File

@@ -3,7 +3,7 @@ import Link from "next/link";
import { PageLayout } from "@/components/ui/PageLayout";
import { LinkButton } from "@/design-system/base/button";
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
import { repoGetFoldersWithTotalPairsByUserId } from "@/modules/folder/folder-repository";
import { repoGetDecksByUserId } from "@/modules/deck/deck-repository";
import { actionGetFollowStatus } from "@/modules/follow/follow-action";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
@@ -29,8 +29,8 @@ export default async function UserPage({ params }: UserPageProps) {
const user = result.data;
const [folders, followStatus] = await Promise.all([
repoGetFoldersWithTotalPairsByUserId(user.id),
const [decks, followStatus] = await Promise.all([
repoGetDecksByUserId({ userId: user.id }),
actionGetFollowStatus({ targetUserId: user.id }),
]);
@@ -137,45 +137,45 @@ export default async function UserPage({ params }: UserPageProps) {
</div>
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("folders.title")}</h2>
{folders.length === 0 ? (
<p className="text-gray-500 text-center py-8">{t("folders.noFolders")}</p>
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("decks.title")}</h2>
{decks.length === 0 ? (
<p className="text-gray-500 text-center py-8">{t("decks.noDecks")}</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t("folders.folderName")}
{t("decks.deckName")}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t("folders.totalPairs")}
{t("decks.totalCards")}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t("folders.createdAt")}
{t("decks.createdAt")}
</th>
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{t("folders.actions")}
{t("decks.actions")}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{folders.map((folder) => (
<tr key={folder.id} className="hover:bg-gray-50">
{decks.map((deck) => (
<tr key={deck.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{folder.name}</div>
<div className="text-sm text-gray-500">ID: {folder.id}</div>
<div className="text-sm font-medium text-gray-900">{deck.name}</div>
<div className="text-sm text-gray-500">ID: {deck.id}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{folder.total}</div>
<div className="text-sm text-gray-900">{deck.cardCount ?? 0}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(folder.createdAt).toLocaleDateString()}
{new Date(deck.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Link href={`/folders/${folder.id}`}>
<Link href={`/decks/${deck.id}`}>
<LinkButton>
{t("folders.view")}
{t("decks.view")}
</LinkButton>
</Link>
</td>

View File

@@ -11,15 +11,18 @@ import { Plus, RefreshCw } from "lucide-react";
import { DictionaryEntry } from "./DictionaryEntry";
import { LanguageSelector } from "./LanguageSelector";
import { authClient } from "@/lib/auth-client";
import { actionGetFoldersByUserId, actionCreatePair } from "@/modules/folder/folder-action";
import { TSharedFolder } from "@/shared/folder-type";
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
import { actionCreateNote } from "@/modules/note/note-action";
import { actionCreateCard } from "@/modules/card/card-action";
import { actionGetNoteTypesByUserId, actionCreateDefaultBasicNoteType } from "@/modules/note-type/note-type-action";
import type { TSharedDeck } from "@/shared/anki-type";
import { toast } from "sonner";
interface DictionaryClientProps {
initialFolders: TSharedFolder[];
initialDecks: TSharedDeck[];
}
export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
const t = useTranslations("dictionary");
const router = useRouter();
const searchParams = useSearchParams();
@@ -39,7 +42,9 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
} = useDictionaryStore();
const { data: session } = authClient.useSession();
const [folders, setFolders] = useState<TSharedFolder[]>(initialFolders);
const [decks, setDecks] = useState<TSharedDeck[]>(initialDecks);
const [defaultNoteTypeId, setDefaultNoteTypeId] = useState<number | null>(null);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
const q = searchParams.get("q") || undefined;
@@ -55,9 +60,31 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
useEffect(() => {
if (session?.user?.id) {
actionGetFoldersByUserId(session.user.id).then((result) => {
actionGetDecksByUserId(session.user.id).then((result) => {
if (result.success && result.data) {
setFolders(result.data);
setDecks(result.data as TSharedDeck[]);
}
});
}
}, [session?.user?.id]);
useEffect(() => {
if (session?.user?.id) {
actionGetNoteTypesByUserId().then(async (result) => {
if (result.success && result.data) {
const basicNoteType = result.data.find(
(nt) => nt.name === "Basic Vocabulary"
);
if (basicNoteType) {
setDefaultNoteTypeId(basicNoteType.id);
} else if (result.data.length > 0) {
setDefaultNoteTypeId(result.data[0].id);
} else {
const createResult = await actionCreateDefaultBasicNoteType();
if (createResult.success && createResult.data) {
setDefaultNoteTypeId(createResult.data.id);
}
}
}
});
}
@@ -79,37 +106,73 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
const handleSave = async () => {
if (!session) {
toast.error("Please login first");
toast.error(t("pleaseLogin"));
return;
}
if (folders.length === 0) {
toast.error("Please create a folder first");
if (decks.length === 0) {
toast.error(t("pleaseCreateFolder"));
return;
}
if (!defaultNoteTypeId) {
toast.error("No note type available. Please try again.");
return;
}
const folderSelect = document.getElementById("folder-select") as HTMLSelectElement;
const folderId = folderSelect?.value ? Number(folderSelect.value) : folders[0]?.id;
if (!searchResult?.entries?.length) return;
const deckSelect = document.getElementById("deck-select") as HTMLSelectElement;
const deckId = deckSelect?.value ? Number(deckSelect.value) : decks[0]?.id;
if (!deckId) {
toast.error("No deck selected");
return;
}
setIsSaving(true);
const definition = searchResult.entries
.map((e) => e.definition)
.join(" | ");
const ipa = searchResult.entries[0]?.ipa || "";
const example = searchResult.entries
.map((e) => e.example)
.filter(Boolean)
.join(" | ") || "";
try {
await actionCreatePair({
text1: searchResult.standardForm,
text2: definition,
language1: queryLang,
language2: definitionLang,
ipa1: searchResult.entries[0]?.ipa,
folderId: folderId,
const noteResult = await actionCreateNote({
noteTypeId: defaultNoteTypeId,
fields: [searchResult.standardForm, definition, ipa, example],
tags: ["dictionary"],
});
const folderName = folders.find((f) => f.id === folderId)?.name || "Unknown";
toast.success(`Saved to ${folderName}`);
if (!noteResult.success || !noteResult.data) {
toast.error(t("saveFailed"));
setIsSaving(false);
return;
}
const noteId = BigInt(noteResult.data.id);
await actionCreateCard({
noteId,
deckId,
ord: 0,
});
await actionCreateCard({
noteId,
deckId,
ord: 1,
});
const deckName = decks.find((d) => d.id === deckId)?.name || "Unknown";
toast.success(t("savedToFolder", { folderName: deckName }));
} catch (error) {
toast.error("Save failed");
console.error("Save error:", error);
toast.error(t("saveFailed"));
} finally {
setIsSaving(false);
}
};
@@ -174,8 +237,8 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
</div>
) : query && !searchResult ? (
<div className="text-center py-12 bg-white/20 rounded-lg">
<p className="text-gray-800 text-xl">No results found</p>
<p className="text-gray-600 mt-2">Try other words</p>
<p className="text-gray-800 text-xl">{t("noResults")}</p>
<p className="text-gray-600 mt-2">{t("tryOtherWords")}</p>
</div>
) : searchResult ? (
<div className="bg-white rounded-lg p-6 shadow-lg">
@@ -186,14 +249,14 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
</h2>
</div>
<div className="flex items-center gap-2 ml-4">
{session && folders.length > 0 && (
{session && decks.length > 0 && (
<select
id="folder-select"
id="deck-select"
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[#35786f]"
>
{folders.map((folder) => (
<option key={folder.id} value={folder.id}>
{folder.name}
{decks.map((deck) => (
<option key={deck.id} value={deck.id}>
{deck.name}
</option>
))}
</select>
@@ -201,7 +264,9 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
<LightButton
onClick={handleSave}
className="w-10 h-10 shrink-0"
title="Save to folder"
title={t("saveToFolder")}
loading={isSaving}
disabled={isSaving}
>
<Plus />
</LightButton>
@@ -223,7 +288,7 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
loading={isSearching}
>
<RefreshCw className="w-4 h-4" />
Re-lookup
{t("relookup")}
</LightButton>
</div>
</div>

View File

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

View File

@@ -1,7 +1,7 @@
"use client";
import {
Folder as Fd,
Layers,
Heart,
Search,
ArrowUpDown,
@@ -14,35 +14,35 @@ import { toast } from "sonner";
import { PageLayout } from "@/components/ui/PageLayout";
import { PageHeader } from "@/components/ui/PageHeader";
import {
actionSearchPublicFolders,
actionToggleFavorite,
actionCheckFavorite,
} from "@/modules/folder/folder-action";
import { TPublicFolder } from "@/shared/folder-type";
actionSearchPublicDecks,
actionToggleDeckFavorite,
actionCheckDeckFavorite,
} from "@/modules/deck/deck-action";
import type { ActionOutputPublicDeck } from "@/modules/deck/deck-action-dto";
import { authClient } from "@/lib/auth-client";
interface PublicFolderCardProps {
folder: TPublicFolder;
interface PublicDeckCardProps {
deck: ActionOutputPublicDeck;
currentUserId?: string;
onUpdateFavorite: (folderId: number, isFavorited: boolean, favoriteCount: number) => void;
onUpdateFavorite: (deckId: number, isFavorited: boolean, favoriteCount: number) => void;
}
const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFolderCardProps) => {
const PublicDeckCard = ({ deck, currentUserId, onUpdateFavorite }: PublicDeckCardProps) => {
const router = useRouter();
const t = useTranslations("explore");
const [isFavorited, setIsFavorited] = useState(false);
const [favoriteCount, setFavoriteCount] = useState(folder.favoriteCount);
const [favoriteCount, setFavoriteCount] = useState(deck.favoriteCount);
useEffect(() => {
if (currentUserId) {
actionCheckFavorite(folder.id).then((result) => {
actionCheckDeckFavorite({ deckId: deck.id }).then((result) => {
if (result.success && result.data) {
setIsFavorited(result.data.isFavorited);
setFavoriteCount(result.data.favoriteCount);
}
});
}
}, [folder.id, currentUserId]);
}, [deck.id, currentUserId]);
const handleToggleFavorite = async (e: React.MouseEvent) => {
e.stopPropagation();
@@ -50,11 +50,11 @@ const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFol
toast.error(t("pleaseLogin"));
return;
}
const result = await actionToggleFavorite(folder.id);
const result = await actionToggleDeckFavorite({ deckId: deck.id });
if (result.success && result.data) {
setIsFavorited(result.data.isFavorited);
setFavoriteCount(result.data.favoriteCount);
onUpdateFavorite(folder.id, result.data.isFavorited, result.data.favoriteCount);
onUpdateFavorite(deck.id, result.data.isFavorited, result.data.favoriteCount);
} else {
toast.error(result.message);
}
@@ -64,13 +64,13 @@ const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFol
<div
className="group bg-white border border-gray-200 sm:border-2 rounded-lg p-3 sm:p-5 hover:border-primary-300 hover:shadow-md cursor-pointer transition-all overflow-hidden"
onClick={() => {
router.push(`/explore/${folder.id}`);
router.push(`/explore/${deck.id}`);
}}
>
<div className="flex items-start justify-between mb-2 sm:mb-3">
<div className="shrink-0 w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-primary-50 flex items-center justify-center text-primary-500">
<Fd size={18} className="sm:hidden" />
<Fd size={22} className="hidden sm:block" />
<Layers size={18} className="sm:hidden" />
<Layers size={22} className="hidden sm:block" />
</div>
<CircleButton
onClick={handleToggleFavorite}
@@ -83,12 +83,12 @@ const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFol
</CircleButton>
</div>
<h3 className="font-semibold text-gray-900 truncate text-sm sm:text-base mb-1 sm:mb-2">{folder.name}</h3>
<h3 className="font-semibold text-gray-900 truncate text-sm sm:text-base mb-1 sm:mb-2">{deck.name}</h3>
<p className="text-xs sm:text-sm text-gray-500 mb-2 sm:mb-3 line-clamp-2">
{t("folderInfo", {
userName: folder.userName ?? folder.userUsername ?? t("unknownUser"),
totalPairs: folder.totalPairs,
{t("deckInfo", {
userName: deck.userName ?? deck.userUsername ?? t("unknownUser"),
cardCount: deck.cardCount ?? 0,
})}
</p>
@@ -101,13 +101,13 @@ const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFol
};
interface ExploreClientProps {
initialPublicFolders: TPublicFolder[];
initialPublicDecks: ActionOutputPublicDeck[];
}
export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
export function ExploreClient({ initialPublicDecks }: ExploreClientProps) {
const t = useTranslations("explore");
const router = useRouter();
const [publicFolders, setPublicFolders] = useState<TPublicFolder[]>(initialPublicFolders);
const [publicDecks, setPublicDecks] = useState<ActionOutputPublicDeck[]>(initialPublicDecks);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [sortByFavorites, setSortByFavorites] = useState(false);
@@ -117,13 +117,13 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
const handleSearch = async () => {
if (!searchQuery.trim()) {
setPublicFolders(initialPublicFolders);
setPublicDecks(initialPublicDecks);
return;
}
setLoading(true);
const result = await actionSearchPublicFolders(searchQuery.trim());
const result = await actionSearchPublicDecks({ query: searchQuery.trim() });
if (result.success && result.data) {
setPublicFolders(result.data);
setPublicDecks(result.data);
}
setLoading(false);
};
@@ -132,14 +132,14 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
setSortByFavorites((prev) => !prev);
};
const sortedFolders = sortByFavorites
? [...publicFolders].sort((a, b) => b.favoriteCount - a.favoriteCount)
: publicFolders;
const sortedDecks = sortByFavorites
? [...publicDecks].sort((a, b) => b.favoriteCount - a.favoriteCount)
: publicDecks;
const handleUpdateFavorite = (folderId: number, _isFavorited: boolean, favoriteCount: number) => {
setPublicFolders((prev) =>
prev.map((f) =>
f.id === folderId ? { ...f, favoriteCount } : f
const handleUpdateFavorite = (deckId: number, _isFavorited: boolean, favoriteCount: number) => {
setPublicDecks((prev) =>
prev.map((d) =>
d.id === deckId ? { ...d, favoriteCount } : d
)
);
};
@@ -177,19 +177,19 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
<p className="text-sm text-gray-500">{t("loading")}</p>
</div>
) : sortedFolders.length === 0 ? (
) : sortedDecks.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
<Fd size={24} className="text-gray-400" />
<Layers size={24} className="text-gray-400" />
</div>
<p className="text-sm">{t("noFolders")}</p>
<p className="text-sm">{t("noDecks")}</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{sortedFolders.map((folder) => (
<PublicFolderCard
key={folder.id}
folder={folder}
{sortedDecks.map((deck) => (
<PublicDeckCard
key={deck.id}
deck={deck}
currentUserId={currentUserId}
onUpdateFavorite={handleUpdateFavorite}
/>

View File

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

View File

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

View File

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

View File

@@ -2,33 +2,22 @@
import {
ChevronRight,
Folder as Fd,
Layers as DeckIcon,
Heart,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { PageLayout } from "@/components/ui/PageLayout";
import { PageHeader } from "@/components/ui/PageHeader";
import { CardList } from "@/components/ui/CardList";
import { actionGetUserFavorites, actionToggleFavorite } from "@/modules/folder/folder-action";
type UserFavorite = {
id: number;
folderId: number;
folderName: string;
folderCreatedAt: Date;
folderTotalPairs: number;
folderOwnerId: string;
folderOwnerName: string | null;
folderOwnerUsername: string | null;
favoritedAt: Date;
};
import { actionGetUserFavoriteDecks, actionToggleDeckFavorite } from "@/modules/deck/deck-action";
import type { ActionOutputUserFavoriteDeck } from "@/modules/deck/deck-action-dto";
interface FavoriteCardProps {
favorite: UserFavorite;
onRemoveFavorite: (folderId: number) => void;
favorite: ActionOutputUserFavoriteDeck;
onRemoveFavorite: (deckId: number) => void;
}
const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
@@ -41,9 +30,9 @@ const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
if (isRemoving) return;
setIsRemoving(true);
const result = await actionToggleFavorite(favorite.folderId);
const result = await actionToggleDeckFavorite({ deckId: favorite.id });
if (result.success) {
onRemoveFavorite(favorite.folderId);
onRemoveFavorite(favorite.id);
} else {
toast.error(result.message);
}
@@ -54,20 +43,20 @@ const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
<div
className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() => {
router.push(`/explore/${favorite.folderId}`);
router.push(`/explore/${favorite.id}`);
}}
>
<div className="flex items-center gap-4 flex-1">
<div className="shrink-0 text-primary-500">
<Fd size={24} />
<DeckIcon size={24} />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{favorite.folderName}</h3>
<h3 className="font-semibold text-gray-900 truncate">{favorite.name}</h3>
<p className="text-sm text-gray-500 mt-0.5">
{t("folderInfo", {
userName: favorite.folderOwnerName ?? favorite.folderOwnerUsername ?? t("unknownUser"),
totalPairs: favorite.folderTotalPairs,
userName: favorite.userName ?? favorite.userUsername ?? t("unknownUser"),
totalPairs: favorite.cardCount ?? 0,
})}
</p>
</div>
@@ -86,29 +75,25 @@ const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
};
interface FavoritesClientProps {
userId: string;
initialFavorites: ActionOutputUserFavoriteDeck[];
}
export function FavoritesClient({ userId }: FavoritesClientProps) {
export function FavoritesClient({ initialFavorites }: FavoritesClientProps) {
const t = useTranslations("favorites");
const [favorites, setFavorites] = useState<UserFavorite[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadFavorites();
}, [userId]);
const [favorites, setFavorites] = useState<ActionOutputUserFavoriteDeck[]>(initialFavorites);
const [loading, setLoading] = useState(false);
const loadFavorites = async () => {
setLoading(true);
const result = await actionGetUserFavorites();
const result = await actionGetUserFavoriteDecks();
if (result.success && result.data) {
setFavorites(result.data);
}
setLoading(false);
};
const handleRemoveFavorite = (folderId: number) => {
setFavorites((prev) => prev.filter((f) => f.folderId !== folderId));
const handleRemoveFavorite = (deckId: number) => {
setFavorites((prev) => prev.filter((f) => f.id !== deckId));
};
return (

View File

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

View File

@@ -0,0 +1,114 @@
"use client";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { Layers } from "lucide-react";
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
import type { ActionOutputCardStats } from "@/modules/card/card-action-dto";
import { PageLayout } from "@/components/ui/PageLayout";
import { PrimaryButton } from "@/design-system/base/button";
interface DeckWithStats extends ActionOutputDeck {
stats?: ActionOutputCardStats;
}
interface DeckSelectorProps {
decks: ActionOutputDeck[];
deckStats: Map<number, ActionOutputCardStats | undefined>;
}
const DeckSelector: React.FC<DeckSelectorProps> = ({ decks, deckStats }) => {
const t = useTranslations("memorize.deck_selector");
const router = useRouter();
const formatCardStats = (stats: ActionOutputCardStats | undefined): string => {
if (!stats) return t("noCards");
const parts: string[] = [];
if (stats.new > 0) parts.push(`${t("new")}: ${stats.new}`);
if (stats.learning > 0) parts.push(`${t("learning")}: ${stats.learning}`);
if (stats.review > 0) parts.push(`${t("review")}: ${stats.review}`);
if (stats.due > 0) parts.push(`${t("due")}: ${stats.due}`);
return parts.length > 0 ? parts.join(" • ") : t("noCards");
};
const getDueCount = (deckId: number): number => {
const stats = deckStats.get(deckId);
return stats?.due ?? 0;
};
return (
<PageLayout>
{decks.length === 0 ? (
<div className="text-center">
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-4">
{t("noDecks")}
</h1>
<Link href="/decks">
<PrimaryButton className="px-6 py-2">
{t("goToDecks")}
</PrimaryButton>
</Link>
</div>
) : (
<>
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-6">
{t("selectDeck")}
</h1>
<div className="border border-gray-200 rounded-lg max-h-96 overflow-y-auto">
{decks
.toSorted((a, b) => a.id - b.id)
.map((deck) => {
const stats = deckStats.get(deck.id);
const dueCount = getDueCount(deck.id);
return (
<div
key={deck.id}
onClick={() =>
router.push(`/memorize?deck_id=${deck.id}`)
}
className="flex flex-row items-center p-4 gap-3 hover:cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0"
>
<div className="shrink-0">
<Layers className="text-gray-600 w-5 h-5" />
</div>
<div className="flex-1">
<div className="font-medium text-gray-900">
{deck.name}
</div>
<div className="text-sm text-gray-500">
{formatCardStats(stats)}
</div>
</div>
{dueCount > 0 && (
<div className="bg-blue-500 text-white text-xs font-bold px-2 py-1 rounded-full">
{dueCount}
</div>
)}
<div className="text-gray-400">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>
);
})}
</div>
</>
)}
</PageLayout>
);
};
export { DeckSelector };

View File

@@ -1,93 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { Folder as Fd } from "lucide-react";
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
import { PageLayout } from "@/components/ui/PageLayout";
import { PrimaryButton } from "@/design-system/base/button";
interface FolderSelectorProps {
folders: TSharedFolderWithTotalPairs[];
}
const FolderSelector: React.FC<FolderSelectorProps> = ({ folders }) => {
const t = useTranslations("memorize.folder_selector");
const router = useRouter();
return (
<PageLayout>
{folders.length === 0 ? (
// 空状态 - 显示提示和跳转按钮
<div className="text-center">
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-4">
{t("noFolders")}
</h1>
<Link href="/folders">
<PrimaryButton className="px-6 py-2">
Go to Folders
</PrimaryButton>
</Link>
</div>
) : (
<>
{/* 页面标题 */}
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-6">
{t("selectFolder")}
</h1>
{/* 文件夹列表 */}
<div className="border border-gray-200 rounded-lg max-h-96 overflow-y-auto">
{folders
.toSorted((a, b) => a.id - b.id)
.map((folder) => (
<div
key={folder.id}
onClick={() =>
router.push(`/memorize?folder_id=${folder.id}`)
}
className="flex flex-row items-center p-4 gap-3 hover:cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0"
>
{/* 文件夹图标 */}
<div className="shrink-0">
<Fd className="text-gray-600" size="md" />
</div>
{/* 文件夹信息 */}
<div className="flex-1">
<div className="font-medium text-gray-900">
{folder.name}
</div>
<div className="text-sm text-gray-500">
{t("folderInfo", {
id: folder.id,
name: folder.name,
count: folder.total,
})}
</div>
</div>
{/* 右箭头 */}
<div className="text-gray-400">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>
))}
</div>
</>
)}
</PageLayout>
);
};
export { FolderSelector };

View File

@@ -1,192 +1,275 @@
"use client";
import { useState } from "react";
import { LinkButton, CircleToggleButton, LightButton } from "@/design-system/base/button";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
import { useState, useEffect, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import localFont from "next/font/local";
import { isNonNegativeInteger, SeededRandom } from "@/utils/random";
import { TSharedPair } from "@/shared/folder-type";
import { Layers, Check, Clock } from "lucide-react";
import type { ActionOutputCardWithNote, ActionOutputScheduledCard } from "@/modules/card/card-action-dto";
import { actionGetCardsForReview, actionAnswerCard } from "@/modules/card/card-action";
import { PageLayout } from "@/components/ui/PageLayout";
import { LightButton } from "@/design-system/base/button";
const myFont = localFont({
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
});
interface MemorizeProps {
textPairs: TSharedPair[];
deckId: number;
deckName: string;
}
const Memorize: React.FC<MemorizeProps> = ({ textPairs }) => {
const t = useTranslations("memorize.memorize");
const [reverse, setReverse] = useState(false);
const [dictation, setDictation] = useState(false);
const [disorder, setDisorder] = useState(false);
const [index, setIndex] = useState(0);
const [show, setShow] = useState<"question" | "answer">("question");
const { load, play } = useAudioPlayer();
type ReviewEase = 1 | 2 | 3 | 4;
if (textPairs.length === 0) {
const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
const t = useTranslations("memorize.review");
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [cards, setCards] = useState<ActionOutputCardWithNote[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [showAnswer, setShowAnswer] = useState(false);
const [lastScheduled, setLastScheduled] = useState<ActionOutputScheduledCard | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadCards();
}, [deckId]);
const loadCards = () => {
setIsLoading(true);
setError(null);
startTransition(async () => {
const result = await actionGetCardsForReview({ deckId, limit: 50 });
if (result.success && result.data) {
setCards(result.data);
setCurrentIndex(0);
setShowAnswer(false);
setLastScheduled(null);
} else {
setError(result.message);
}
setIsLoading(false);
});
};
const getCurrentCard = (): ActionOutputCardWithNote | null => {
return cards[currentIndex] ?? null;
};
const getNoteFields = (card: ActionOutputCardWithNote): string[] => {
return card.note.flds.split('\x1f');
};
const handleShowAnswer = () => {
setShowAnswer(true);
};
const handleAnswer = (ease: ReviewEase) => {
const card = getCurrentCard();
if (!card) return;
startTransition(async () => {
const result = await actionAnswerCard({
cardId: BigInt(card.id),
ease,
});
if (result.success && result.data) {
setLastScheduled(result.data.scheduled);
const remainingCards = cards.filter((_, idx) => idx !== currentIndex);
setCards(remainingCards);
if (remainingCards.length === 0) {
setCurrentIndex(0);
} else if (currentIndex >= remainingCards.length) {
setCurrentIndex(remainingCards.length - 1);
}
setShowAnswer(false);
} else {
setError(result.message);
}
});
};
const formatNextReview = (scheduled: ActionOutputScheduledCard): string => {
const now = new Date();
const nextReview = new Date(scheduled.nextReviewDate);
const diffMs = nextReview.getTime() - now.getTime();
if (diffMs < 0) return t("now");
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return t("lessThanMinute");
if (diffMins < 60) return t("inMinutes", { count: diffMins });
if (diffHours < 24) return t("inHours", { count: diffHours });
if (diffDays < 30) return t("inDays", { count: diffDays });
return t("inMonths", { count: Math.floor(diffDays / 30) });
};
const formatInterval = (ivl: number): string => {
if (ivl < 1) return t("minutes");
if (ivl < 30) return t("days", { count: ivl });
return t("months", { count: Math.floor(ivl / 30) });
};
if (isLoading) {
return (
<PageLayout>
<p className="text-gray-700 text-center">{t("noTextPairs")}</p>
<div className="text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 mx-auto mb-4"></div>
<p className="text-gray-600">{t("loading")}</p>
</div>
</PageLayout>
);
}
const rng = new SeededRandom(textPairs[0].folderId);
const disorderedTextPairs = textPairs.toSorted(() => rng.next() - 0.5);
textPairs.sort((a, b) => a.id - b.id);
const getTextPairs = () => disorder ? disorderedTextPairs : textPairs;
const handleIndexClick = () => {
const newIndex = prompt("Input a index number.")?.trim();
if (
newIndex &&
isNonNegativeInteger(newIndex) &&
parseInt(newIndex) <= textPairs.length &&
parseInt(newIndex) > 0
) {
setIndex(parseInt(newIndex) - 1);
}
};
const handleNext = async () => {
if (show === "answer") {
const newIndex = (index + 1) % getTextPairs().length;
setIndex(newIndex);
if (dictation) {
const textPair = getTextPairs()[newIndex];
const language = textPair[reverse ? "language2" : "language1"];
const text = textPair[reverse ? "text2" : "text1"];
// 映射语言到 TTS 支持的格式
const languageMap: Record<string, TTS_SUPPORTED_LANGUAGES> = {
"chinese": "Chinese",
"english": "English",
"japanese": "Japanese",
"korean": "Korean",
"french": "French",
"german": "German",
"italian": "Italian",
"portuguese": "Portuguese",
"spanish": "Spanish",
"russian": "Russian",
};
const ttsLanguage = languageMap[language?.toLowerCase()] || "Auto";
getTTSUrl(text, ttsLanguage).then((url) => {
load(url);
play();
});
}
}
setShow(show === "question" ? "answer" : "question");
};
const handlePrevious = () => {
setIndex(
(index - 1 + getTextPairs().length) % getTextPairs().length,
);
setShow("question");
};
const toggleReverse = () => setReverse(!reverse);
const toggleDictation = () => setDictation(!dictation);
const toggleDisorder = () => setDisorder(!disorder);
const createText = (text: string) => {
if (error) {
return (
<div className="text-gray-900 text-xl md:text-2xl p-6 h-[20dvh] overflow-y-auto text-center">
{text}
<PageLayout>
<div className="text-center py-12">
<p className="text-red-600 mb-4">{error}</p>
<LightButton onClick={() => router.push("/memorize")} className="px-4 py-2">
{t("backToDecks")}
</LightButton>
</div>
</PageLayout>
);
};
}
const [text1, text2] = reverse
? [getTextPairs()[index].text2, getTextPairs()[index].text1]
: [getTextPairs()[index].text1, getTextPairs()[index].text2];
if (cards.length === 0) {
return (
<PageLayout>
<div className="text-center py-12">
<div className="text-green-500 mb-4">
<Check className="w-16 h-16 mx-auto" />
</div>
<h2 className="text-2xl font-bold text-gray-800 mb-2">{t("allDone")}</h2>
<p className="text-gray-600 mb-6">{t("allDoneDesc")}</p>
<LightButton onClick={() => router.push("/memorize")} className="px-4 py-2">
{t("backToDecks")}
</LightButton>
</div>
</PageLayout>
);
}
const currentCard = getCurrentCard()!;
const fields = getNoteFields(currentCard);
const front = fields[0] ?? "";
const back = fields[1] ?? "";
return (
<PageLayout>
{/* 进度指示器 */}
<div className="flex justify-center mb-4">
<LinkButton onClick={handleIndexClick} className="text-sm">
{index + 1} / {getTextPairs().length}
</LinkButton>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2 text-gray-600">
<Layers className="w-5 h-5" />
<span className="font-medium">{deckName}</span>
</div>
<div className="text-sm text-gray-500">
{t("progress", { current: currentIndex + 1, total: cards.length + currentIndex })}
</div>
</div>
{/* 文本显示区域 */}
<div className={`h-[40dvh] ${myFont.className} mb-4`}>
{(() => {
if (dictation) {
if (show === "question") {
return (
<div className="h-full flex items-center justify-center">
<div className="text-gray-400 text-4xl">?</div>
</div>
);
} else {
return (
<div className="space-y-2">
{createText(text1)}
<div className="border-t border-gray-200"></div>
{createText(text2)}
</div>
);
}
} else {
if (show === "question") {
return createText(text1);
} else {
return (
<div className="space-y-2">
{createText(text1)}
<div className="border-t border-gray-200"></div>
{createText(text2)}
</div>
);
}
}
})()}
<div className="w-full bg-gray-200 rounded-full h-2 mb-6">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${Math.max(0, ((currentIndex) / (cards.length + currentIndex)) * 100)}%` }}
/>
</div>
{/* 底部按钮 */}
<div className="flex flex-row gap-2 items-center justify-center flex-wrap">
{lastScheduled && (
<div className="mb-4 p-3 bg-gray-50 rounded-lg text-sm text-gray-600">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
<span>
{t("nextReview")}: {formatNextReview(lastScheduled)}
</span>
</div>
</div>
)}
<div className={`bg-white border border-gray-200 rounded-xl shadow-sm mb-6 ${myFont.className}`}>
<div className="p-8 min-h-[20dvh] flex items-center justify-center">
<div className="text-gray-900 text-xl md:text-2xl text-center">
{front}
</div>
</div>
{showAnswer && (
<>
<div className="border-t border-gray-200" />
<div className="p-8 min-h-[20dvh] flex items-center justify-center bg-gray-50 rounded-b-xl">
<div className="text-gray-900 text-xl md:text-2xl text-center">
{back}
</div>
</div>
</>
)}
</div>
<div className="flex justify-center gap-4 mb-6 text-sm text-gray-500">
<span>{t("interval")}: {formatInterval(currentCard.ivl)}</span>
<span></span>
<span>{t("ease")}: {currentCard.factor / 10}%</span>
<span></span>
<span>{t("lapses")}: {currentCard.lapses}</span>
</div>
<div className="flex justify-center">
{!showAnswer ? (
<LightButton
onClick={handleNext}
className="px-4 py-2 rounded-full text-sm"
onClick={handleShowAnswer}
disabled={isPending}
className="px-8 py-3 text-lg rounded-full"
>
{show === "question" ? t("answer") : t("next")}
{t("showAnswer")}
</LightButton>
<LightButton
onClick={handlePrevious}
className="px-4 py-2 rounded-full text-sm"
) : (
<div className="flex flex-wrap justify-center gap-3">
<button
onClick={() => handleAnswer(1)}
disabled={isPending}
className="flex flex-col items-center px-6 py-3 rounded-xl bg-red-100 hover:bg-red-200 text-red-700 transition-colors disabled:opacity-50"
>
{t("previous")}
</LightButton>
<CircleToggleButton
selected={reverse}
onClick={toggleReverse}
<span className="font-medium">{t("again")}</span>
<span className="text-xs opacity-75">&lt;1{t("minAbbr")}</span>
</button>
<button
onClick={() => handleAnswer(2)}
disabled={isPending}
className="flex flex-col items-center px-6 py-3 rounded-xl bg-orange-100 hover:bg-orange-200 text-orange-700 transition-colors disabled:opacity-50"
>
{t("reverse")}
</CircleToggleButton>
<CircleToggleButton
selected={dictation}
onClick={toggleDictation}
<span className="font-medium">{t("hard")}</span>
<span className="text-xs opacity-75">6{t("minAbbr")}</span>
</button>
<button
onClick={() => handleAnswer(3)}
disabled={isPending}
className="flex flex-col items-center px-6 py-3 rounded-xl bg-green-100 hover:bg-green-200 text-green-700 transition-colors disabled:opacity-50"
>
{t("dictation")}
</CircleToggleButton>
<CircleToggleButton
selected={disorder}
onClick={toggleDisorder}
<span className="font-medium">{t("good")}</span>
<span className="text-xs opacity-75">10{t("minAbbr")}</span>
</button>
<button
onClick={() => handleAnswer(4)}
disabled={isPending}
className="flex flex-col items-center px-6 py-3 rounded-xl bg-blue-100 hover:bg-blue-200 text-blue-700 transition-colors disabled:opacity-50"
>
{t("disorder")}
</CircleToggleButton>
<span className="font-medium">{t("easy")}</span>
<span className="text-xs opacity-75">4{t("dayAbbr")}</span>
</button>
</div>
)}
</div>
</PageLayout>
);

View File

@@ -1,37 +1,57 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { isNonNegativeInteger } from "@/utils/random";
import { FolderSelector } from "./FolderSelector";
import { DeckSelector } from "./DeckSelector";
import { Memorize } from "./Memorize";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { actionGetFoldersWithTotalPairsByUserId, actionGetPairsByFolderId } from "@/modules/folder/folder-action";
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
import { actionGetCardStats } from "@/modules/card/card-action";
export default async function MemorizePage({
searchParams,
}: {
searchParams: Promise<{ folder_id?: string; }>;
searchParams: Promise<{ deck_id?: string; }>;
}) {
const tParam = (await searchParams).folder_id;
const deckIdParam = (await searchParams).deck_id;
const t = await getTranslations("memorize.page");
const folder_id = tParam
? isNonNegativeInteger(tParam)
? parseInt(tParam)
const deckId = deckIdParam
? isNonNegativeInteger(deckIdParam)
? parseInt(deckIdParam)
: null
: null;
if (!folder_id) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login?redirect=/memorize");
if (!deckId) {
const decksResult = await actionGetDecksByUserId(session.user.id);
const decks = decksResult.data ?? [];
const deckStats = new Map<number, Awaited<ReturnType<typeof actionGetCardStats>>["data"]>();
for (const deck of decks) {
const statsResult = await actionGetCardStats({ deckId: deck.id });
if (statsResult.success && statsResult.data) {
deckStats.set(deck.id, statsResult.data);
}
}
return (
<FolderSelector
folders={(await actionGetFoldersWithTotalPairsByUserId(session.user.id)).data!}
<DeckSelector
decks={decks}
deckStats={deckStats}
/>
);
}
return <Memorize textPairs={(await actionGetPairsByFolderId(folder_id)).data!} />;
const decksResult = await actionGetDecksByUserId(session.user.id);
const deck = decksResult.data?.find(d => d.id === deckId);
if (!deck) {
redirect("/memorize");
}
return <Memorize deckId={deckId} deckName={deck.name} />;
}

View File

@@ -10,32 +10,42 @@ import { Card } from "@/design-system/base/card";
import { toast } from "sonner";
import { Upload, FileImage, Loader2 } from "lucide-react";
import { actionProcessOCR } from "@/modules/ocr/ocr-action";
import { TSharedFolder } from "@/shared/folder-type";
import { OCROutput } from "@/lib/bigmodel/ocr/types";
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
interface OCRClientProps {
initialFolders: TSharedFolder[];
interface ActionOutputProcessOCR {
success: boolean;
message: string;
data?: {
pairsCreated: number;
sourceLanguage?: string;
targetLanguage?: string;
};
}
export function OCRClient({ initialFolders }: OCRClientProps) {
interface OCRClientProps {
initialDecks: ActionOutputDeck[];
}
export function OCRClient({ initialDecks }: OCRClientProps) {
const t = useTranslations("ocr");
const fileInputRef = useRef<HTMLInputElement>(null);
const [decks, setDecks] = useState<ActionOutputDeck[]>(initialDecks);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [selectedFolderId, setSelectedFolderId] = useState<number | null>(
initialFolders.length > 0 ? initialFolders[0].id : null
const [selectedDeckId, setSelectedDeckId] = useState<number | null>(
initialDecks.length > 0 ? initialDecks[0].id : null
);
const [sourceLanguage, setSourceLanguage] = useState<string>("");
const [targetLanguage, setTargetLanguage] = useState<string>("");
const [isProcessing, setIsProcessing] = useState(false);
const [ocrResult, setOcrResult] = useState<OCROutput | null>(null);
const [ocrResult, setOcrResult] = useState<ActionOutputProcessOCR | null>(null);
const handleFileChange = useCallback((file: File | null) => {
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error(t("processingFailed"));
toast.error(t("invalidFileType"));
return;
}
@@ -74,8 +84,8 @@ export function OCRClient({ initialFolders }: OCRClientProps) {
return;
}
if (!selectedFolderId) {
toast.error(t("noFolder"));
if (!selectedDeckId) {
toast.error(t("noDeck"));
return;
}
@@ -87,16 +97,17 @@ export function OCRClient({ initialFolders }: OCRClientProps) {
const result = await actionProcessOCR({
imageBase64: base64,
folderId: selectedFolderId,
deckId: selectedDeckId,
sourceLanguage: sourceLanguage || undefined,
targetLanguage: targetLanguage || undefined,
});
if (result.success) {
const folderName = initialFolders.find(f => f.id === selectedFolderId)?.name || "";
toast.success(t("saved", { count: result.data?.pairsCreated ?? 0, folder: folderName }));
if (result.success && result.data) {
setOcrResult(result);
const deckName = decks.find(d => d.id === selectedDeckId)?.name || "";
toast.success(t("ocrSuccess", { count: result.data.pairsCreated, deck: deckName }));
} else {
toast.error(result.message || t("processingFailed"));
toast.error(result.message || t("ocrFailed"));
}
} catch {
toast.error(t("processingFailed"));
@@ -105,6 +116,20 @@ export function OCRClient({ initialFolders }: OCRClientProps) {
}
};
const handleSave = async () => {
if (!ocrResult || !selectedDeckId) {
toast.error(t("noResultsToSave"));
return;
}
try {
const deckName = decks.find(d => d.id === selectedDeckId)?.name || "Unknown";
toast.success(t("savedToDeck", { deckName }));
} catch (error) {
toast.error(t("saveFailed"));
}
};
const clearImage = () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
@@ -118,136 +143,144 @@ export function OCRClient({ initialFolders }: OCRClientProps) {
};
return (
<PageLayout>
<div className="text-center mb-6">
<h1 className="text-3xl font-bold text-gray-800 mb-2">{t("title")}</h1>
<p className="text-gray-600">{t("description")}</p>
<PageLayout variant="centered-card">
<div className="text-center mb-8">
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
{t("title")}
</h1>
<p className="text-gray-700 text-lg">
{t("description")}
</p>
</div>
<div className="space-y-6">
<Card variant="bordered" padding="lg">
<div className="space-y-4">
<div className="font-semibold text-gray-800 flex items-center gap-2">
<Upload className="w-5 h-5" />
{t("uploadImage")}
</div>
<div className="space-y-6">
{/* Upload Section */}
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-4">
{t("uploadSection")}
</h2>
<div
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
previewUrl
? "border-primary-300 bg-primary-50"
: "border-gray-300 hover:border-primary-400 hover:bg-gray-50"
}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-primary-500 hover:bg-primary-50 transition-colors"
>
{previewUrl ? (
<div className="space-y-3">
<div className="space-y-4">
<img
src={previewUrl}
alt="Preview"
className="max-h-64 mx-auto rounded-lg shadow-md"
className="mx-auto max-w-full h-64 object-contain rounded-lg"
/>
<div className="flex justify-center gap-2">
<LightButton
type="button"
onClick={(e) => {
e.stopPropagation();
clearImage();
}}
>
{t("uploadImage")}
</LightButton>
</div>
<p className="text-gray-600">{t("changeImage")}</p>
</div>
) : (
<div className="space-y-3 text-gray-500">
<FileImage className="w-12 h-12 mx-auto text-gray-400" />
<p>{t("dragDropHint")}</p>
<p className="text-sm">{t("supportedFormats")}</p>
<div className="space-y-4">
<Upload className="mx-auto h-12 w-12 text-gray-400" />
<p className="text-gray-600">{t("dropOrClick")}</p>
<p className="text-sm text-gray-500">{t("supportedFormats")}</p>
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleFileChange(e.target.files?.[0] || null)}
className="hidden"
/>
</div>
</div>
</Card>
<Card variant="bordered" padding="lg">
<div className="space-y-4">
<div className="font-semibold text-gray-800">{t("selectFolder")}</div>
{initialFolders.length > 0 ? (
{/* Deck Selection */}
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-4">
{t("deckSelection")}
</h2>
<Select
value={selectedFolderId?.toString() || ""}
onChange={(e) => setSelectedFolderId(Number(e.target.value))}
value={selectedDeckId?.toString() || ""}
onChange={(e) => setSelectedDeckId(Number(e.target.value))}
className="w-full"
>
{initialFolders.map((folder) => (
<option key={folder.id} value={folder.id}>
{folder.name}
<option value="">{t("selectDeck")}</option>
{decks.map((deck) => (
<option key={deck.id} value={deck.id}>
{deck.name}
</option>
))}
</Select>
) : (
<p className="text-gray-500 text-sm">{t("noFolders")}</p>
)}
</div>
</Card>
<Card variant="bordered" padding="lg">
<div className="space-y-4">
<div className="font-semibold text-gray-800">{t("languageHints")}</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Language Hints */}
<div>
<label className="text-sm text-gray-600 block mb-1">
{t("sourceLanguageHint")}
</label>
<h2 className="text-xl font-semibold text-gray-800 mb-4">
{t("languageHints")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
type="text"
placeholder={t("sourceLanguagePlaceholder")}
value={sourceLanguage}
onChange={(e) => setSourceLanguage(e.target.value)}
placeholder="English"
className="w-full"
/>
</div>
<div>
<label className="text-sm text-gray-600 block mb-1">
{t("targetLanguageHint")}
</label>
<Input
type="text"
placeholder={t("targetLanguagePlaceholder")}
value={targetLanguage}
onChange={(e) => setTargetLanguage(e.target.value)}
placeholder="Chinese"
className="w-full"
/>
</div>
</div>
</div>
</Card>
{/* Process Button */}
<div className="flex justify-center">
<PrimaryButton
onClick={handleProcess}
disabled={isProcessing || !selectedFile || !selectedFolderId}
size="lg"
className="px-8"
disabled={!selectedFile || !selectedDeckId || isProcessing}
loading={isProcessing}
className="px-8 py-3 text-lg"
>
{isProcessing ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
{t("processing")}
</>
) : (
t("process")
)}
{t("processButton")}
</PrimaryButton>
</div>
{/* Results Preview */}
{ocrResult && ocrResult.data && (
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-4">
{t("resultsPreview")}
</h2>
<div className="bg-gray-50 rounded-lg p-4">
<div className="space-y-3">
<div className="text-center py-4">
<p className="text-gray-800">{t("extractedPairs", { count: ocrResult.data.pairsCreated })}</p>
</div>
</div>
{ocrResult.data.sourceLanguage && (
<div className="mt-4 text-sm text-gray-500">
{t("detectedSourceLanguage")}: {ocrResult.data.sourceLanguage}
</div>
)}
{ocrResult.data.targetLanguage && (
<div className="mt-1 text-sm text-gray-500">
{t("detectedTargetLanguage")}: {ocrResult.data.targetLanguage}
</div>
)}
</div>
<div className="mt-4 flex justify-center">
<LightButton
onClick={handleSave}
disabled={!selectedDeckId}
className="px-6 py-2"
>
{t("saveButton")}
</LightButton>
</div>
</div>
)}
</div>
</Card>
</PageLayout>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,154 @@
"use client";
import { LightButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { X } from "lucide-react";
import { useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { actionCreateNote } from "@/modules/note/note-action";
import { actionCreateCard } from "@/modules/card/card-action";
import { actionGetNoteTypesByUserId, actionCreateDefaultBasicNoteType } from "@/modules/note-type/note-type-action";
import { toast } from "sonner";
interface AddCardModalProps {
isOpen: boolean;
onClose: () => void;
deckId: number;
onAdded: () => void;
}
export function AddCardModal({
isOpen,
onClose,
deckId,
onAdded,
}: AddCardModalProps) {
const t = useTranslations("deck_id");
const wordRef = useRef<HTMLInputElement>(null);
const definitionRef = useRef<HTMLInputElement>(null);
const ipaRef = useRef<HTMLInputElement>(null);
const exampleRef = useRef<HTMLInputElement>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
if (!isOpen) return null;
const handleAdd = async () => {
const word = wordRef.current?.value?.trim();
const definition = definitionRef.current?.value?.trim();
if (!word || !definition) {
toast.error(t("wordAndDefinitionRequired"));
return;
}
setIsSubmitting(true);
try {
let noteTypesResult = await actionGetNoteTypesByUserId();
if (!noteTypesResult.success || !noteTypesResult.data || noteTypesResult.data.length === 0) {
const createResult = await actionCreateDefaultBasicNoteType();
if (!createResult.success || !createResult.data) {
throw new Error(createResult.message || "Failed to create note type");
}
noteTypesResult = await actionGetNoteTypesByUserId();
}
if (!noteTypesResult.success || !noteTypesResult.data || noteTypesResult.data.length === 0) {
throw new Error("No note type available");
}
const noteTypeId = noteTypesResult.data[0].id;
const fields = [
word,
definition,
ipaRef.current?.value?.trim() || "",
exampleRef.current?.value?.trim() || "",
];
const noteResult = await actionCreateNote({
noteTypeId,
fields,
tags: [],
});
if (!noteResult.success || !noteResult.data) {
throw new Error(noteResult.message || "Failed to create note");
}
const cardResult = await actionCreateCard({
noteId: BigInt(noteResult.data.id),
deckId,
});
if (!cardResult.success) {
throw new Error(cardResult.message || "Failed to create card");
}
if (wordRef.current) wordRef.current.value = "";
if (definitionRef.current) definitionRef.current.value = "";
if (ipaRef.current) ipaRef.current.value = "";
if (exampleRef.current) exampleRef.current.value = "";
onAdded();
onClose();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Unknown error");
} finally {
setIsSubmitting(false);
}
};
return (
<div
className="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAdd();
}
}}
>
<div className="bg-white rounded-md p-6 w-full max-w-md mx-4">
<div className="flex">
<h2 className="flex-1 text-xl font-light mb-4 text-center">
{t("addNewCard")}
</h2>
<X onClick={onClose} className="hover:cursor-pointer"></X>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("word")} *
</label>
<Input ref={wordRef} className="w-full"></Input>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("definition")} *
</label>
<Input ref={definitionRef} className="w-full"></Input>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("ipa")}
</label>
<Input ref={ipaRef} className="w-full"></Input>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("example")}
</label>
<Input ref={exampleRef} className="w-full"></Input>
</div>
</div>
<div className="mt-4">
<LightButton onClick={handleAdd} disabled={isSubmitting}>
{isSubmitting ? t("adding") : t("add")}
</LightButton>
</div>
</div>
</div>
);
}

View File

@@ -1,39 +1,38 @@
import { Edit, Trash2 } from "lucide-react";
import { useState } from "react";
import { CircleButton } from "@/design-system/base/button";
import { UpdateTextPairModal } from "./UpdateTextPairModal";
import { UpdateCardModal } from "./UpdateCardModal";
import { useTranslations } from "next-intl";
import { TSharedPair } from "@/shared/folder-type";
import { actionUpdatePairById } from "@/modules/folder/folder-action";
import { ActionInputUpdatePairById } from "@/modules/folder/folder-action-dto";
import type { ActionOutputCardWithNote } from "@/modules/card/card-action-dto";
import { toast } from "sonner";
interface TextPairCardProps {
textPair: TSharedPair;
interface CardItemProps {
card: ActionOutputCardWithNote;
isReadOnly: boolean;
onDel: () => void;
refreshTextPairs: () => void;
refreshCards: () => void;
}
export function TextPairCard({
textPair,
export function CardItem({
card,
isReadOnly,
onDel,
refreshTextPairs,
}: TextPairCardProps) {
refreshCards,
}: CardItemProps) {
const [openUpdateModal, setOpenUpdateModal] = useState(false);
const t = useTranslations("folder_id");
const t = useTranslations("deck_id");
const fields = card.note.flds.split('\x1f');
const field1 = fields[0] || "";
const field2 = fields[1] || "";
return (
<div className="group border-b border-gray-100 hover:bg-gray-50 transition-colors">
<div className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className="px-2 py-1 bg-gray-100 rounded-md">
{textPair.language1.toUpperCase()}
</span>
<span></span>
<span className="px-2 py-1 bg-gray-100 rounded-md">
{textPair.language2.toUpperCase()}
{t("card")}
</span>
</div>
@@ -60,26 +59,25 @@ export function TextPairCard({
</div>
<div className="text-gray-900 grid grid-cols-2 gap-4 w-3/4">
<div>
{textPair.text1.length > 30
? textPair.text1.substring(0, 30) + "..."
: textPair.text1}
{field1.length > 30
? field1.substring(0, 30) + "..."
: field1}
</div>
<div>
{textPair.text2.length > 30
? textPair.text2.substring(0, 30) + "..."
: textPair.text2}
{field2.length > 30
? field2.substring(0, 30) + "..."
: field2}
</div>
</div>
</div>
<UpdateTextPairModal
<UpdateCardModal
isOpen={openUpdateModal}
onClose={() => setOpenUpdateModal(false)}
onUpdate={async (id: number, data: ActionInputUpdatePairById) => {
await actionUpdatePairById(id, data).then(result => result.success ? toast.success(result.message) : toast.error(result.message));
card={card}
onUpdated={() => {
setOpenUpdateModal(false);
refreshTextPairs();
refreshCards();
}}
textPair={textPair}
/>
</div>
);

View File

@@ -3,34 +3,34 @@
import { ArrowLeft, Plus } from "lucide-react";
import { useEffect, useState } from "react";
import { redirect, useRouter } from "next/navigation";
import { AddTextPairModal } from "./AddTextPairModal";
import { TextPairCard } from "./TextPairCard";
import { AddCardModal } from "./AddCardModal";
import { CardItem } from "./CardItem";
import { useTranslations } from "next-intl";
import { PageLayout } from "@/components/ui/PageLayout";
import { PrimaryButton, CircleButton, LinkButton } from "@/design-system/base/button";
import { CardList } from "@/components/ui/CardList";
import { actionCreatePair, actionDeletePairById, actionGetPairsByFolderId } from "@/modules/folder/folder-action";
import { TSharedPair } from "@/shared/folder-type";
import { actionGetCardsByDeckIdWithNotes, actionDeleteCard } from "@/modules/card/card-action";
import type { ActionOutputCardWithNote } from "@/modules/card/card-action-dto";
import { toast } from "sonner";
export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnly: boolean; }) {
const [textPairs, setTextPairs] = useState<TSharedPair[]>([]);
export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boolean; }) {
const [cards, setCards] = useState<ActionOutputCardWithNote[]>([]);
const [loading, setLoading] = useState(true);
const [openAddModal, setAddModal] = useState(false);
const router = useRouter();
const t = useTranslations("folder_id");
const t = useTranslations("deck_id");
useEffect(() => {
const fetchTextPairs = async () => {
const fetchCards = async () => {
setLoading(true);
await actionGetPairsByFolderId(folderId)
await actionGetCardsByDeckIdWithNotes({ deckId })
.then(result => {
if (!result.success || !result.data) {
throw new Error(result.message || "Failed to load text pairs");
throw new Error(result.message || "Failed to load cards");
}
return result.data;
}).then(setTextPairs)
}).then(setCards)
.catch((error) => {
toast.error(error instanceof Error ? error.message : "Unknown error");
})
@@ -38,17 +38,17 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
setLoading(false);
});
};
fetchTextPairs();
}, [folderId]);
fetchCards();
}, [deckId]);
const refreshTextPairs = async () => {
await actionGetPairsByFolderId(folderId)
const refreshCards = async () => {
await actionGetCardsByDeckIdWithNotes({ deckId })
.then(result => {
if (!result.success || !result.data) {
throw new Error(result.message || "Failed to refresh text pairs");
throw new Error(result.message || "Failed to refresh cards");
}
return result.data;
}).then(setTextPairs)
}).then(setCards)
.catch((error) => {
toast.error(error instanceof Error ? error.message : "Unknown error");
});
@@ -56,9 +56,7 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
return (
<PageLayout>
{/* 顶部导航和标题栏 */}
<div className="mb-6">
{/* 返回按钮 */}
<LinkButton
onClick={router.back}
className="flex items-center gap-2 mb-4"
@@ -67,23 +65,20 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
<span className="text-sm">{t("back")}</span>
</LinkButton>
{/* 页面标题和操作按钮 */}
<div className="flex items-center justify-between">
{/* 标题区域 */}
<div>
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-1">
{t("textPairs")}
{t("cards")}
</h1>
<p className="text-sm text-gray-500">
{t("itemsCount", { count: textPairs.length })}
{t("itemsCount", { count: cards.length })}
</p>
</div>
{/* 操作按钮区域 */}
<div className="flex items-center gap-2">
<PrimaryButton
onClick={() => {
redirect(`/memorize?folder_id=${folderId}`);
redirect(`/memorize?deck_id=${deckId}`);
}}
>
{t("memorize")}
@@ -101,64 +96,46 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
</div>
</div>
{/* 文本对列表 */}
<CardList>
{loading ? (
// 加载状态
<div className="p-8 text-center">
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
<p className="text-sm text-gray-500">{t("loadingTextPairs")}</p>
<p className="text-sm text-gray-500">{t("loadingCards")}</p>
</div>
) : textPairs.length === 0 ? (
// 空状态
) : cards.length === 0 ? (
<div className="p-12 text-center">
<p className="text-sm text-gray-500 mb-2">{t("noTextPairs")}</p>
<p className="text-sm text-gray-500 mb-2">{t("noCards")}</p>
</div>
) : (
// 文本对卡片列表
<div className="divide-y divide-gray-100">
{textPairs
.toSorted((a, b) => a.id - b.id)
.map((textPair) => (
<TextPairCard
key={textPair.id}
textPair={textPair}
{cards
.toSorted((a, b) => Number(BigInt(a.id) - BigInt(b.id)))
.map((card) => (
<CardItem
key={card.id}
card={card}
isReadOnly={isReadOnly}
onDel={() => {
actionDeletePairById(textPair.id)
actionDeleteCard({ cardId: BigInt(card.id) })
.then(result => {
if (!result.success) throw new Error(result.message || "Delete failed");
}).then(refreshTextPairs)
}).then(refreshCards)
.catch((error) => {
toast.error(error instanceof Error ? error.message : "Unknown error");
});
}}
refreshTextPairs={refreshTextPairs}
refreshCards={refreshCards}
/>
))}
</div>
)}
</CardList>
{/* 添加文本对模态框 */}
<AddTextPairModal
<AddCardModal
isOpen={openAddModal}
onClose={() => setAddModal(false)}
onAdd={async (
text1: string,
text2: string,
language1: string,
language2: string,
) => {
await actionCreatePair({
text1: text1,
text2: text2,
language1: language1,
language2: language2,
folderId: folderId,
});
refreshTextPairs();
}}
deckId={deckId}
onAdded={refreshCards}
/>
</PageLayout>
);

View File

@@ -0,0 +1,132 @@
"use client";
import { LightButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { X } from "lucide-react";
import { useRef, useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { actionUpdateNote } from "@/modules/note/note-action";
import type { ActionOutputCardWithNote } from "@/modules/card/card-action-dto";
import { toast } from "sonner";
interface UpdateCardModalProps {
isOpen: boolean;
onClose: () => void;
card: ActionOutputCardWithNote;
onUpdated: () => void;
}
export function UpdateCardModal({
isOpen,
onClose,
card,
onUpdated,
}: UpdateCardModalProps) {
const t = useTranslations("deck_id");
const wordRef = useRef<HTMLInputElement>(null);
const definitionRef = useRef<HTMLInputElement>(null);
const ipaRef = useRef<HTMLInputElement>(null);
const exampleRef = useRef<HTMLInputElement>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (isOpen && card) {
const fields = card.note.flds.split('\x1f');
if (wordRef.current) wordRef.current.value = fields[0] || "";
if (definitionRef.current) definitionRef.current.value = fields[1] || "";
if (ipaRef.current) ipaRef.current.value = fields[2] || "";
if (exampleRef.current) exampleRef.current.value = fields[3] || "";
}
}, [isOpen, card]);
if (!isOpen) return null;
const handleUpdate = async () => {
const word = wordRef.current?.value?.trim();
const definition = definitionRef.current?.value?.trim();
if (!word || !definition) {
toast.error(t("wordAndDefinitionRequired"));
return;
}
setIsSubmitting(true);
try {
const fields = [
word,
definition,
ipaRef.current?.value?.trim() || "",
exampleRef.current?.value?.trim() || "",
];
const result = await actionUpdateNote({
noteId: BigInt(card.note.id),
fields,
});
if (!result.success) {
throw new Error(result.message || "Failed to update note");
}
toast.success(result.message);
onUpdated();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Unknown error");
} finally {
setIsSubmitting(false);
}
};
return (
<div
className="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleUpdate();
}
}}
>
<div className="bg-white rounded-md p-6 w-full max-w-md mx-4">
<div className="flex">
<h2 className="flex-1 text-xl font-light mb-4 text-center">
{t("updateCard")}
</h2>
<X onClick={onClose} className="hover:cursor-pointer"></X>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("word")} *
</label>
<Input ref={wordRef} className="w-full"></Input>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("definition")} *
</label>
<Input ref={definitionRef} className="w-full"></Input>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("ipa")}
</label>
<Input ref={ipaRef} className="w-full"></Input>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("example")}
</label>
<Input ref={exampleRef} className="w-full"></Input>
</div>
</div>
<div className="mt-4">
<LightButton onClick={handleUpdate} disabled={isSubmitting}>
{isSubmitting ? t("updating") : t("update")}
</LightButton>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { InDeck } from "./InDeck";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { actionGetDeckById } from "@/modules/deck/deck-action";
export default async function DecksPage({
params,
}: {
params: Promise<{ deck_id: number; }>;
}) {
const session = await auth.api.getSession({ headers: await headers() });
const { deck_id } = await params;
const t = await getTranslations("deck_id");
if (!deck_id) {
redirect("/decks");
}
const deckInfo = (await actionGetDeckById({ deckId: Number(deck_id) })).data;
if (!deckInfo) {
redirect("/decks");
}
const isOwner = session?.user?.id === deckInfo.userId;
const isPublic = deckInfo.visibility === "PUBLIC";
if (!isOwner && !isPublic) {
redirect("/decks");
}
const isReadOnly = !isOwner;
return <InDeck deckId={Number(deck_id)} isReadOnly={isReadOnly} />;
}

View File

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

View File

@@ -1,99 +0,0 @@
import { LightButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { LocaleSelector } from "@/components/ui/LocaleSelector";
import { X } from "lucide-react";
import { useRef, useState } from "react";
import { useTranslations } from "next-intl";
interface AddTextPairModalProps {
isOpen: boolean;
onClose: () => void;
onAdd: (
text1: string,
text2: string,
language1: string,
language2: string,
) => void;
}
export function AddTextPairModal({
isOpen,
onClose,
onAdd,
}: AddTextPairModalProps) {
const t = useTranslations("folder_id");
const input1Ref = useRef<HTMLInputElement>(null);
const input2Ref = useRef<HTMLInputElement>(null);
const [language1, setLanguage1] = useState("english");
const [language2, setLanguage2] = useState("chinese");
if (!isOpen) return null;
const handleAdd = () => {
if (
!input1Ref.current?.value ||
!input2Ref.current?.value ||
!language1 ||
!language2
)
return;
const text1 = input1Ref.current.value;
const text2 = input2Ref.current.value;
if (
typeof text1 === "string" &&
typeof text2 === "string" &&
typeof language1 === "string" &&
typeof language2 === "string" &&
text1.trim() !== "" &&
text2.trim() !== "" &&
language1.trim() !== "" &&
language2.trim() !== ""
) {
onAdd(text1, text2, language1, language2);
input1Ref.current.value = "";
input2Ref.current.value = "";
}
};
return (
<div
className="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAdd();
}
}}
>
<div className="bg-white rounded-md p-6 w-full max-w-md mx-4">
<div className="flex">
<h2 className="flex-1 text-xl font-light mb-4 text-center">
{t("addNewTextPair")}
</h2>
<X onClick={onClose} className="hover:cursor-pointer"></X>
</div>
<div>
<div>
{t("text1")}
<Input ref={input1Ref} className="w-full"></Input>
</div>
<div>
{t("text2")}
<Input ref={input2Ref} className="w-full"></Input>
</div>
<div>
{t("language1")}
<LocaleSelector value={language1} onChange={setLanguage1} />
</div>
<div>
{t("language2")}
<LocaleSelector value={language2} onChange={setLanguage2} />
</div>
</div>
<LightButton onClick={handleAdd}>{t("add")}</LightButton>
</div>
</div>
);
}

View File

@@ -1,103 +0,0 @@
import { LightButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { LocaleSelector } from "@/components/ui/LocaleSelector";
import { X } from "lucide-react";
import { useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { TSharedPair } from "@/shared/folder-type";
import { ActionInputUpdatePairById } from "@/modules/folder/folder-action-dto";
interface UpdateTextPairModalProps {
isOpen: boolean;
onClose: () => void;
textPair: TSharedPair;
onUpdate: (id: number, tp: ActionInputUpdatePairById) => void;
}
export function UpdateTextPairModal({
isOpen,
onClose,
onUpdate,
textPair,
}: UpdateTextPairModalProps) {
const t = useTranslations("folder_id");
const input1Ref = useRef<HTMLInputElement>(null);
const input2Ref = useRef<HTMLInputElement>(null);
const [language1, setLanguage1] = useState(textPair.language1);
const [language2, setLanguage2] = useState(textPair.language2);
if (!isOpen) return null;
const handleUpdate = () => {
if (
!input1Ref.current?.value ||
!input2Ref.current?.value ||
!language1 ||
!language2
)
return;
const text1 = input1Ref.current.value;
const text2 = input2Ref.current.value;
if (
typeof text1 === "string" &&
typeof text2 === "string" &&
typeof language1 === "string" &&
typeof language2 === "string" &&
text1.trim() !== "" &&
text2.trim() !== "" &&
language1.trim() !== "" &&
language2.trim() !== ""
) {
onUpdate(textPair.id, { text1, text2, language1, language2 });
}
};
return (
<div
className="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleUpdate();
}
}}
>
<div className="bg-white rounded-md p-6 w-full max-w-md mx-4">
<div className="flex">
<h2 className="flex-1 text-xl font-light mb-4 text-center">
{t("updateTextPair")}
</h2>
<X onClick={onClose} className="hover:cursor-pointer"></X>
</div>
<div>
<div>
{t("text1")}
<Input
defaultValue={textPair.text1}
ref={input1Ref}
className="w-full"
></Input>
</div>
<div>
{t("text2")}
<Input
defaultValue={textPair.text2}
ref={input2Ref}
className="w-full"
></Input>
</div>
<div>
{t("language1")}
<LocaleSelector value={language1} onChange={setLanguage1} />
</div>
<div>
{t("language2")}
<LocaleSelector value={language2} onChange={setLanguage2} />
</div>
</div>
<LightButton onClick={handleUpdate}>{t("update")}</LightButton>
</div>
</div>
);
}

View File

@@ -1,37 +0,0 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { InFolder } from "./InFolder";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { actionGetFolderVisibility } from "@/modules/folder/folder-action";
export default async function FoldersPage({
params,
}: {
params: Promise<{ folder_id: number; }>;
}) {
const session = await auth.api.getSession({ headers: await headers() });
const { folder_id } = await params;
const t = await getTranslations("folder_id");
if (!folder_id) {
redirect("/folders");
}
const folderInfo = (await actionGetFolderVisibility(Number(folder_id))).data;
if (!folderInfo) {
redirect("/folders");
}
const isOwner = session?.user?.id === folderInfo.userId;
const isPublic = folderInfo.visibility === "PUBLIC";
if (!isOwner && !isPublic) {
redirect("/folders");
}
const isReadOnly = !isOwner;
return <InFolder folderId={Number(folder_id)} isReadOnly={isReadOnly} />;
}

View File

@@ -0,0 +1,164 @@
import z from "zod";
import { generateValidator } from "@/utils/validate";
export const schemaActionInputCreateCard = z.object({
noteId: z.bigint(),
deckId: z.number().int().positive(),
ord: z.number().int().min(0).optional(),
});
export type ActionInputCreateCard = z.infer<typeof schemaActionInputCreateCard>;
export const validateActionInputCreateCard = generateValidator(schemaActionInputCreateCard);
export const schemaActionInputAnswerCard = z.object({
cardId: z.bigint(),
ease: z.union([
z.literal(1),
z.literal(2),
z.literal(3),
z.literal(4),
]),
});
export type ActionInputAnswerCard = z.infer<typeof schemaActionInputAnswerCard>;
export const validateActionInputAnswerCard = generateValidator(schemaActionInputAnswerCard);
export const schemaActionInputGetCardsForReview = z.object({
deckId: z.number().int().positive(),
limit: z.number().int().min(1).max(100).optional(),
});
export type ActionInputGetCardsForReview = z.infer<typeof schemaActionInputGetCardsForReview>;
export const validateActionInputGetCardsForReview = generateValidator(schemaActionInputGetCardsForReview);
export const schemaActionInputGetNewCards = z.object({
deckId: z.number().int().positive(),
limit: z.number().int().min(1).max(100).optional(),
});
export type ActionInputGetNewCards = z.infer<typeof schemaActionInputGetNewCards>;
export const validateActionInputGetNewCards = generateValidator(schemaActionInputGetNewCards);
export const schemaActionInputGetCardsByDeckId = z.object({
deckId: z.number().int().positive(),
limit: z.number().int().min(1).max(100).optional(),
offset: z.number().int().min(0).optional(),
queue: z.union([
z.enum(["USER_BURIED", "SCHED_BURIED", "SUSPENDED", "NEW", "LEARNING", "REVIEW", "IN_LEARNING", "PREVIEW"]),
z.array(z.enum(["USER_BURIED", "SCHED_BURIED", "SUSPENDED", "NEW", "LEARNING", "REVIEW", "IN_LEARNING", "PREVIEW"])),
]).optional(),
});
export type ActionInputGetCardsByDeckId = z.infer<typeof schemaActionInputGetCardsByDeckId>;
export const validateActionInputGetCardsByDeckId = generateValidator(schemaActionInputGetCardsByDeckId);
export const schemaActionInputGetCardStats = z.object({
deckId: z.number().int().positive(),
});
export type ActionInputGetCardStats = z.infer<typeof schemaActionInputGetCardStats>;
export const validateActionInputGetCardStats = generateValidator(schemaActionInputGetCardStats);
export const schemaActionInputDeleteCard = z.object({
cardId: z.bigint(),
});
export type ActionInputDeleteCard = z.infer<typeof schemaActionInputDeleteCard>;
export const validateActionInputDeleteCard = generateValidator(schemaActionInputDeleteCard);
export const schemaActionInputGetCardById = z.object({
cardId: z.bigint(),
});
export type ActionInputGetCardById = z.infer<typeof schemaActionInputGetCardById>;
export const validateActionInputGetCardById = generateValidator(schemaActionInputGetCardById);
export type ActionOutputCard = {
id: string;
noteId: string;
deckId: number;
ord: number;
mod: number;
usn: number;
type: "NEW" | "LEARNING" | "REVIEW" | "RELEARNING";
queue: "USER_BURIED" | "SCHED_BURIED" | "SUSPENDED" | "NEW" | "LEARNING" | "REVIEW" | "IN_LEARNING" | "PREVIEW";
due: number;
ivl: number;
factor: number;
reps: number;
lapses: number;
left: number;
odue: number;
odid: number;
flags: number;
data: string;
createdAt: Date;
updatedAt: Date;
};
export type ActionOutputCardWithNote = ActionOutputCard & {
note: {
id: string;
flds: string;
sfld: string;
tags: string;
};
};
export type ActionOutputCardStats = {
total: number;
new: number;
learning: number;
review: number;
due: number;
};
export type ActionOutputScheduledCard = {
cardId: string;
newType: "NEW" | "LEARNING" | "REVIEW" | "RELEARNING";
newQueue: "USER_BURIED" | "SCHED_BURIED" | "SUSPENDED" | "NEW" | "LEARNING" | "REVIEW" | "IN_LEARNING" | "PREVIEW";
newDue: number;
newIvl: number;
newFactor: number;
newReps: number;
newLapses: number;
nextReviewDate: Date;
};
export type ActionOutputCreateCard = {
success: boolean;
message: string;
data?: {
cardId: string;
};
};
export type ActionOutputAnswerCard = {
success: boolean;
message: string;
data?: {
card: ActionOutputCard;
scheduled: ActionOutputScheduledCard;
};
};
export type ActionOutputGetCards = {
success: boolean;
message: string;
data?: ActionOutputCard[];
};
export type ActionOutputGetCardsWithNote = {
success: boolean;
message: string;
data?: ActionOutputCardWithNote[];
};
export type ActionOutputGetCardStats = {
success: boolean;
message: string;
data?: ActionOutputCardStats;
};
export type ActionOutputDeleteCard = {
success: boolean;
message: string;
};
export type ActionOutputGetCardById = {
success: boolean;
message: string;
data?: ActionOutputCardWithNote;
};

View File

@@ -0,0 +1,428 @@
"use server";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { createLogger } from "@/lib/logger";
import { ValidateError } from "@/lib/errors";
import {
ActionInputCreateCard,
ActionInputAnswerCard,
ActionInputGetCardsForReview,
ActionInputGetNewCards,
ActionInputGetCardsByDeckId,
ActionInputGetCardStats,
ActionInputDeleteCard,
ActionInputGetCardById,
ActionOutputCreateCard,
ActionOutputAnswerCard,
ActionOutputGetCards,
ActionOutputGetCardsWithNote,
ActionOutputGetCardStats,
ActionOutputDeleteCard,
ActionOutputGetCardById,
ActionOutputCard,
ActionOutputCardWithNote,
ActionOutputScheduledCard,
validateActionInputCreateCard,
validateActionInputAnswerCard,
validateActionInputGetCardsForReview,
validateActionInputGetNewCards,
validateActionInputGetCardsByDeckId,
validateActionInputGetCardStats,
validateActionInputDeleteCard,
validateActionInputGetCardById,
} from "./card-action-dto";
import {
serviceCreateCard,
serviceAnswerCard,
serviceGetCardsForReview,
serviceGetNewCards,
serviceGetCardsByDeckId,
serviceGetCardsByDeckIdWithNotes,
serviceGetCardStats,
serviceDeleteCard,
serviceGetCardByIdWithNote,
} from "./card-service";
import { repoGetCardDeckOwnerId } from "./card-repository";
import { CardQueue } from "../../../generated/prisma/enums";
const log = createLogger("card-action");
function mapCardToOutput(card: {
id: bigint;
noteId: bigint;
deckId: number;
ord: number;
mod: number;
usn: number;
type: string;
queue: string;
due: number;
ivl: number;
factor: number;
reps: number;
lapses: number;
left: number;
odue: number;
odid: number;
flags: number;
data: string;
createdAt: Date;
updatedAt: Date;
}): ActionOutputCard {
return {
id: card.id.toString(),
noteId: card.noteId.toString(),
deckId: card.deckId,
ord: card.ord,
mod: card.mod,
usn: card.usn,
type: card.type as ActionOutputCard["type"],
queue: card.queue as ActionOutputCard["queue"],
due: card.due,
ivl: card.ivl,
factor: card.factor,
reps: card.reps,
lapses: card.lapses,
left: card.left,
odue: card.odue,
odid: card.odid,
flags: card.flags,
data: card.data,
createdAt: card.createdAt,
updatedAt: card.updatedAt,
};
}
function mapCardWithNoteToOutput(card: {
id: bigint;
noteId: bigint;
deckId: number;
ord: number;
mod: number;
usn: number;
type: string;
queue: string;
due: number;
ivl: number;
factor: number;
reps: number;
lapses: number;
left: number;
odue: number;
odid: number;
flags: number;
data: string;
createdAt: Date;
updatedAt: Date;
note: {
id: bigint;
flds: string;
sfld: string;
tags: string;
};
}): ActionOutputCardWithNote {
return {
...mapCardToOutput(card),
note: {
id: card.note.id.toString(),
flds: card.note.flds,
sfld: card.note.sfld,
tags: card.note.tags,
},
};
}
function mapScheduledToOutput(scheduled: {
cardId: bigint;
newType: string;
newQueue: string;
newDue: number;
newIvl: number;
newFactor: number;
newReps: number;
newLapses: number;
nextReviewDate: Date;
}): ActionOutputScheduledCard {
return {
cardId: scheduled.cardId.toString(),
newType: scheduled.newType as ActionOutputScheduledCard["newType"],
newQueue: scheduled.newQueue as ActionOutputScheduledCard["newQueue"],
newDue: scheduled.newDue,
newIvl: scheduled.newIvl,
newFactor: scheduled.newFactor,
newReps: scheduled.newReps,
newLapses: scheduled.newLapses,
nextReviewDate: scheduled.nextReviewDate,
};
}
async function checkCardOwnership(cardId: bigint): Promise<boolean> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) return false;
const ownerId = await repoGetCardDeckOwnerId(cardId);
return ownerId === session.user.id;
}
async function getCurrentUserId(): Promise<string | null> {
const session = await auth.api.getSession({ headers: await headers() });
return session?.user?.id ?? null;
}
export async function actionCreateCard(
input: unknown,
): Promise<ActionOutputCreateCard> {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
}
const validated = validateActionInputCreateCard(input);
const cardId = await serviceCreateCard(validated);
return {
success: true,
message: "Card created successfully",
data: { cardId: cardId.toString() },
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to create card", { error: e });
return { success: false, message: "An error occurred while creating the card" };
}
}
export async function actionAnswerCard(
input: unknown,
): Promise<ActionOutputAnswerCard> {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
}
const validated = validateActionInputAnswerCard(input);
const isOwner = await checkCardOwnership(validated.cardId);
if (!isOwner) {
return { success: false, message: "You do not have permission to answer this card" };
}
const result = await serviceAnswerCard(validated);
return {
success: true,
message: "Card answered successfully",
data: {
card: mapCardToOutput(result.card),
scheduled: mapScheduledToOutput(result.scheduled),
},
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to answer card", { error: e });
return { success: false, message: "An error occurred while answering the card" };
}
}
export async function actionGetCardsForReview(
input: unknown,
): Promise<ActionOutputGetCardsWithNote> {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
}
const validated = validateActionInputGetCardsForReview(input);
const cards = await serviceGetCardsForReview(validated);
return {
success: true,
message: "Cards fetched successfully",
data: cards.map(mapCardWithNoteToOutput),
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get cards for review", { error: e });
return { success: false, message: "An error occurred while fetching cards" };
}
}
export async function actionGetNewCards(
input: unknown,
): Promise<ActionOutputGetCardsWithNote> {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
}
const validated = validateActionInputGetNewCards(input);
const cards = await serviceGetNewCards(validated);
return {
success: true,
message: "New cards fetched successfully",
data: cards.map(mapCardWithNoteToOutput),
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get new cards", { error: e });
return { success: false, message: "An error occurred while fetching new cards" };
}
}
export async function actionGetCardsByDeckId(
input: unknown,
): Promise<ActionOutputGetCards> {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
}
const validated = validateActionInputGetCardsByDeckId(input);
const queue = validated.queue as CardQueue | CardQueue[] | undefined;
const cards = await serviceGetCardsByDeckId({
...validated,
queue,
});
return {
success: true,
message: "Cards fetched successfully",
data: cards.map(mapCardToOutput),
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get cards by deck", { error: e });
return { success: false, message: "An error occurred while fetching cards" };
}
}
export async function actionGetCardsByDeckIdWithNotes(
input: unknown,
): Promise<ActionOutputGetCardsWithNote> {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
}
const validated = validateActionInputGetCardsByDeckId(input);
const queue = validated.queue as CardQueue | CardQueue[] | undefined;
const cards = await serviceGetCardsByDeckIdWithNotes({
...validated,
queue,
});
return {
success: true,
message: "Cards fetched successfully",
data: cards.map(mapCardWithNoteToOutput),
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get cards by deck with notes", { error: e });
return { success: false, message: "An error occurred while fetching cards" };
}
}
export async function actionGetCardStats(
input: unknown,
): Promise<ActionOutputGetCardStats> {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
}
const validated = validateActionInputGetCardStats(input);
const stats = await serviceGetCardStats(validated);
return {
success: true,
message: "Card stats fetched successfully",
data: stats,
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get card stats", { error: e });
return { success: false, message: "An error occurred while fetching card stats" };
}
}
export async function actionDeleteCard(
input: unknown,
): Promise<ActionOutputDeleteCard> {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
}
const validated = validateActionInputDeleteCard(input);
const isOwner = await checkCardOwnership(validated.cardId);
if (!isOwner) {
return { success: false, message: "You do not have permission to delete this card" };
}
await serviceDeleteCard(validated.cardId);
return { success: true, message: "Card deleted successfully" };
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to delete card", { error: e });
return { success: false, message: "An error occurred while deleting the card" };
}
}
export async function actionGetCardById(
input: unknown,
): Promise<ActionOutputGetCardById> {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
}
const validated = validateActionInputGetCardById(input);
const card = await serviceGetCardByIdWithNote(validated.cardId);
if (!card) {
return { success: false, message: "Card not found" };
}
return {
success: true,
message: "Card fetched successfully",
data: mapCardWithNoteToOutput(card),
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get card by id", { error: e });
return { success: false, message: "An error occurred while fetching the card" };
}
}

View File

@@ -0,0 +1,104 @@
import { CardType, CardQueue } from "../../../generated/prisma/enums";
export interface RepoInputCreateCard {
id: bigint;
noteId: bigint;
deckId: number;
ord: number;
due: number;
type?: CardType;
queue?: CardQueue;
ivl?: number;
factor?: number;
reps?: number;
lapses?: number;
left?: number;
odue?: number;
odid?: number;
flags?: number;
data?: string;
}
export interface RepoInputUpdateCard {
ord?: number;
mod?: number;
usn?: number;
type?: CardType;
queue?: CardQueue;
due?: number;
ivl?: number;
factor?: number;
reps?: number;
lapses?: number;
left?: number;
odue?: number;
odid?: number;
flags?: number;
data?: string;
}
export interface RepoInputGetCardsByDeckId {
deckId: number;
limit?: number;
offset?: number;
queue?: CardQueue | CardQueue[];
}
export interface RepoInputGetCardsForReview {
deckId: number;
limit?: number;
}
export interface RepoInputGetNewCards {
deckId: number;
limit?: number;
}
export interface RepoInputBulkUpdateCard {
id: bigint;
data: RepoInputUpdateCard;
}
export interface RepoInputBulkUpdateCards {
cards: RepoInputBulkUpdateCard[];
}
export type RepoOutputCard = {
id: bigint;
noteId: bigint;
deckId: number;
ord: number;
mod: number;
usn: number;
type: CardType;
queue: CardQueue;
due: number;
ivl: number;
factor: number;
reps: number;
lapses: number;
left: number;
odue: number;
odid: number;
flags: number;
data: string;
createdAt: Date;
updatedAt: Date;
};
export type RepoOutputCardWithNote = RepoOutputCard & {
note: {
id: bigint;
flds: string;
sfld: string;
tags: string;
};
};
export type RepoOutputCardStats = {
total: number;
new: number;
learning: number;
review: number;
due: number;
};

View File

@@ -0,0 +1,309 @@
import { prisma } from "@/lib/db";
import { createLogger } from "@/lib/logger";
import {
RepoInputCreateCard,
RepoInputUpdateCard,
RepoInputGetCardsByDeckId,
RepoInputGetCardsForReview,
RepoInputGetNewCards,
RepoInputBulkUpdateCards,
RepoOutputCard,
RepoOutputCardWithNote,
RepoOutputCardStats,
} from "./card-repository-dto";
import { CardType, CardQueue } from "../../../generated/prisma/enums";
const log = createLogger("card-repository");
export async function repoCreateCard(
input: RepoInputCreateCard,
): Promise<bigint> {
log.debug("Creating card", { noteId: input.noteId.toString(), deckId: input.deckId });
const card = await prisma.card.create({
data: {
id: input.id,
noteId: input.noteId,
deckId: input.deckId,
ord: input.ord,
due: input.due,
mod: Math.floor(Date.now() / 1000),
type: input.type ?? CardType.NEW,
queue: input.queue ?? CardQueue.NEW,
ivl: input.ivl ?? 0,
factor: input.factor ?? 2500,
reps: input.reps ?? 0,
lapses: input.lapses ?? 0,
left: input.left ?? 0,
odue: input.odue ?? 0,
odid: input.odid ?? 0,
flags: input.flags ?? 0,
data: input.data ?? "",
},
});
log.info("Card created", { cardId: card.id.toString() });
return card.id;
}
export async function repoUpdateCard(
id: bigint,
input: RepoInputUpdateCard,
): Promise<void> {
log.debug("Updating card", { cardId: id.toString() });
await prisma.card.update({
where: { id },
data: {
...input,
updatedAt: new Date(),
},
});
log.info("Card updated", { cardId: id.toString() });
}
export async function repoGetCardById(id: bigint): Promise<RepoOutputCard | null> {
const card = await prisma.card.findUnique({
where: { id },
});
return card;
}
export async function repoGetCardByIdWithNote(
id: bigint,
): Promise<RepoOutputCardWithNote | null> {
const card = await prisma.card.findUnique({
where: { id },
include: {
note: {
select: {
id: true,
flds: true,
sfld: true,
tags: true,
},
},
},
});
return card;
}
export async function repoGetCardsByDeckId(
input: RepoInputGetCardsByDeckId,
): Promise<RepoOutputCard[]> {
const { deckId, limit = 50, offset = 0, queue } = input;
const queueFilter = queue
? Array.isArray(queue)
? { in: queue }
: queue
: undefined;
const cards = await prisma.card.findMany({
where: {
deckId,
queue: queueFilter,
},
orderBy: { due: "asc" },
take: limit,
skip: offset,
});
log.debug("Fetched cards by deck", { deckId, count: cards.length });
return cards;
}
export async function repoGetCardsByDeckIdWithNotes(
input: RepoInputGetCardsByDeckId,
): Promise<RepoOutputCardWithNote[]> {
const { deckId, limit = 100, offset = 0, queue } = input;
const queueFilter = queue
? Array.isArray(queue)
? { in: queue }
: queue
: undefined;
const cards = await prisma.card.findMany({
where: {
deckId,
queue: queueFilter,
},
include: {
note: {
select: {
id: true,
flds: true,
sfld: true,
tags: true,
},
},
},
orderBy: { id: "asc" },
take: limit,
skip: offset,
});
log.debug("Fetched cards by deck with notes", { deckId, count: cards.length });
return cards;
}
export async function repoGetCardsForReview(
input: RepoInputGetCardsForReview,
): Promise<RepoOutputCardWithNote[]> {
const { deckId, limit = 20 } = input;
const now = Math.floor(Date.now() / 1000);
const todayDays = Math.floor(now / 86400);
const cards = await prisma.card.findMany({
where: {
deckId,
queue: { in: [CardQueue.NEW, CardQueue.LEARNING, CardQueue.REVIEW] },
OR: [
{ type: CardType.NEW },
{
type: { in: [CardType.LEARNING, CardType.REVIEW] },
due: { lte: todayDays },
},
],
},
include: {
note: {
select: {
id: true,
flds: true,
sfld: true,
tags: true,
},
},
},
orderBy: [
{ type: "asc" },
{ due: "asc" },
],
take: limit,
});
log.debug("Fetched cards for review", { deckId, count: cards.length });
return cards;
}
export async function repoGetNewCards(
input: RepoInputGetNewCards,
): Promise<RepoOutputCardWithNote[]> {
const { deckId, limit = 20 } = input;
const cards = await prisma.card.findMany({
where: {
deckId,
type: CardType.NEW,
queue: CardQueue.NEW,
},
include: {
note: {
select: {
id: true,
flds: true,
sfld: true,
tags: true,
},
},
},
orderBy: { due: "asc" },
take: limit,
});
log.debug("Fetched new cards", { deckId, count: cards.length });
return cards;
}
export async function repoDeleteCard(id: bigint): Promise<void> {
log.debug("Deleting card", { cardId: id.toString() });
await prisma.card.delete({
where: { id },
});
log.info("Card deleted", { cardId: id.toString() });
}
export async function repoBulkUpdateCards(
input: RepoInputBulkUpdateCards,
): Promise<void> {
log.debug("Bulk updating cards", { count: input.cards.length });
await prisma.$transaction(
input.cards.map((item) =>
prisma.card.update({
where: { id: item.id },
data: {
...item.data,
updatedAt: new Date(),
},
}),
),
);
log.info("Bulk update completed", { count: input.cards.length });
}
export async function repoGetCardStats(deckId: number): Promise<RepoOutputCardStats> {
const now = Math.floor(Date.now() / 1000);
const todayDays = Math.floor(now / 86400);
const [total, newCards, learning, review, due] = await Promise.all([
prisma.card.count({ where: { deckId } }),
prisma.card.count({ where: { deckId, type: CardType.NEW } }),
prisma.card.count({ where: { deckId, type: CardType.LEARNING } }),
prisma.card.count({ where: { deckId, type: CardType.REVIEW } }),
prisma.card.count({
where: {
deckId,
type: { in: [CardType.LEARNING, CardType.REVIEW] },
due: { lte: todayDays },
},
}),
]);
return { total, new: newCards, learning, review, due };
}
export async function repoGetCardDeckOwnerId(cardId: bigint): Promise<string | null> {
const card = await prisma.card.findUnique({
where: { id: cardId },
include: {
deck: {
select: { userId: true },
},
},
});
return card?.deck.userId ?? null;
}
export async function repoGetNextDueCard(deckId: number): Promise<RepoOutputCard | null> {
const now = Math.floor(Date.now() / 1000);
const todayDays = Math.floor(now / 86400);
const card = await prisma.card.findFirst({
where: {
deckId,
queue: { in: [CardQueue.NEW, CardQueue.LEARNING, CardQueue.REVIEW] },
OR: [
{ type: CardType.NEW },
{
type: { in: [CardType.LEARNING, CardType.REVIEW] },
due: { lte: todayDays },
},
],
},
orderBy: [
{ type: "asc" },
{ due: "asc" },
],
});
return card;
}
export async function repoGetCardsByNoteId(noteId: bigint): Promise<RepoOutputCard[]> {
const cards = await prisma.card.findMany({
where: { noteId },
orderBy: { ord: "asc" },
});
return cards;
}

View File

@@ -0,0 +1,113 @@
import { CardType, CardQueue } from "../../../generated/prisma/enums";
export type ReviewEase = 1 | 2 | 3 | 4;
export interface ServiceInputCreateCard {
noteId: bigint;
deckId: number;
ord?: number;
}
export interface ServiceInputAnswerCard {
cardId: bigint;
ease: ReviewEase;
}
export interface ServiceInputGetCardsForReview {
deckId: number;
limit?: number;
}
export interface ServiceInputGetNewCards {
deckId: number;
limit?: number;
}
export interface ServiceInputGetCardsByDeckId {
deckId: number;
limit?: number;
offset?: number;
queue?: CardQueue | CardQueue[];
}
export interface ServiceInputGetCardStats {
deckId: number;
}
export type ServiceOutputCard = {
id: bigint;
noteId: bigint;
deckId: number;
ord: number;
mod: number;
usn: number;
type: CardType;
queue: CardQueue;
due: number;
ivl: number;
factor: number;
reps: number;
lapses: number;
left: number;
odue: number;
odid: number;
flags: number;
data: string;
createdAt: Date;
updatedAt: Date;
};
export type ServiceOutputCardWithNote = ServiceOutputCard & {
note: {
id: bigint;
flds: string;
sfld: string;
tags: string;
};
};
export type ServiceOutputCardStats = {
total: number;
new: number;
learning: number;
review: number;
due: number;
};
export type ServiceOutputScheduledCard = {
cardId: bigint;
newType: CardType;
newQueue: CardQueue;
newDue: number;
newIvl: number;
newFactor: number;
newReps: number;
newLapses: number;
nextReviewDate: Date;
};
export type ServiceOutputReviewResult = {
success: boolean;
card: ServiceOutputCard;
scheduled: ServiceOutputScheduledCard;
};
export const SM2_CONFIG = {
LEARNING_STEPS: [1, 10],
GRADUATING_INTERVAL_GOOD: 1,
GRADUATING_INTERVAL_EASY: 4,
EASY_INTERVAL: 4,
MINIMUM_FACTOR: 1300,
DEFAULT_FACTOR: 2500,
FACTOR_ADJUSTMENTS: {
1: -200,
2: -150,
3: 0,
4: 150,
},
INITIAL_INTERVALS: {
2: 1,
3: 3,
4: 4,
},
} as const;

View File

@@ -0,0 +1,384 @@
import { createLogger } from "@/lib/logger";
import {
repoCreateCard,
repoUpdateCard,
repoGetCardById,
repoGetCardByIdWithNote,
repoGetCardsByDeckId,
repoGetCardsByDeckIdWithNotes,
repoGetCardsForReview,
repoGetNewCards,
repoGetCardStats,
repoDeleteCard,
repoGetCardsByNoteId,
} from "./card-repository";
import {
RepoInputUpdateCard,
RepoOutputCard,
} from "./card-repository-dto";
import {
ServiceInputCreateCard,
ServiceInputAnswerCard,
ServiceInputGetCardsForReview,
ServiceInputGetNewCards,
ServiceInputGetCardsByDeckId,
ServiceInputGetCardStats,
ServiceOutputCard,
ServiceOutputCardWithNote,
ServiceOutputCardStats,
ServiceOutputScheduledCard,
ServiceOutputReviewResult,
ReviewEase,
SM2_CONFIG,
} from "./card-service-dto";
import { CardType, CardQueue } from "../../../generated/prisma/enums";
const log = createLogger("card-service");
function generateCardId(): bigint {
return BigInt(Date.now());
}
function calculateDueDate(intervalDays: number): number {
const now = Math.floor(Date.now() / 1000);
const todayStart = Math.floor(now / 86400) * 86400;
return Math.floor(todayStart / 86400) + intervalDays;
}
function calculateNextReviewTime(intervalDays: number): Date {
const now = Date.now();
return new Date(now + intervalDays * 86400 * 1000);
}
function scheduleNewCard(ease: ReviewEase, factor: number): {
type: CardType;
queue: CardQueue;
ivl: number;
due: number;
newFactor: number;
} {
if (ease === 1) {
return {
type: CardType.LEARNING,
queue: CardQueue.LEARNING,
ivl: 0,
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60,
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[1]),
};
}
const ivl = SM2_CONFIG.INITIAL_INTERVALS[ease];
return {
type: CardType.REVIEW,
queue: CardQueue.REVIEW,
ivl,
due: calculateDueDate(ivl),
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[ease]),
};
}
function scheduleLearningCard(ease: ReviewEase, factor: number, left: number): {
type: CardType;
queue: CardQueue;
ivl: number;
due: number;
newFactor: number;
newLeft: number;
} {
if (ease === 1) {
return {
type: CardType.LEARNING,
queue: CardQueue.LEARNING,
ivl: 0,
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60,
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[1]),
newLeft: SM2_CONFIG.LEARNING_STEPS.length * 1000 + SM2_CONFIG.LEARNING_STEPS.length,
};
}
const stepIndex = Math.floor(left % 1000);
if (ease === 2 && stepIndex < SM2_CONFIG.LEARNING_STEPS.length - 1) {
const nextStep = stepIndex + 1;
return {
type: CardType.LEARNING,
queue: CardQueue.LEARNING,
ivl: 0,
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[nextStep] * 60,
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[2]),
newLeft: nextStep * 1000 + (SM2_CONFIG.LEARNING_STEPS.length - nextStep),
};
}
const ivl = ease === 4 ? SM2_CONFIG.GRADUATING_INTERVAL_EASY : SM2_CONFIG.GRADUATING_INTERVAL_GOOD;
return {
type: CardType.REVIEW,
queue: CardQueue.REVIEW,
ivl,
due: calculateDueDate(ivl),
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[ease]),
newLeft: 0,
};
}
function scheduleReviewCard(
ease: ReviewEase,
ivl: number,
factor: number,
lapses: number,
): {
type: CardType;
queue: CardQueue;
ivl: number;
due: number;
newFactor: number;
newLapses: number;
} {
if (ease === 1) {
return {
type: CardType.RELEARNING,
queue: CardQueue.LEARNING,
ivl: 0,
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60,
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[1]),
newLapses: lapses + 1,
};
}
const newFactor = Math.max(SM2_CONFIG.MINIMUM_FACTOR, factor + SM2_CONFIG.FACTOR_ADJUSTMENTS[ease]);
const factorMultiplier = newFactor / 1000;
let newIvl = Math.floor(ivl * factorMultiplier);
if (ease === 2) {
newIvl = Math.max(1, Math.floor(newIvl * 1.2));
} else if (ease === 4) {
newIvl = Math.floor(newIvl * 1.3);
}
newIvl = Math.max(1, newIvl);
return {
type: CardType.REVIEW,
queue: CardQueue.REVIEW,
ivl: newIvl,
due: calculateDueDate(newIvl),
newFactor,
newLapses: lapses,
};
}
function mapToServiceOutput(card: RepoOutputCard): ServiceOutputCard {
return {
id: card.id,
noteId: card.noteId,
deckId: card.deckId,
ord: card.ord,
mod: card.mod,
usn: card.usn,
type: card.type,
queue: card.queue,
due: card.due,
ivl: card.ivl,
factor: card.factor,
reps: card.reps,
lapses: card.lapses,
left: card.left,
odue: card.odue,
odid: card.odid,
flags: card.flags,
data: card.data,
createdAt: card.createdAt,
updatedAt: card.updatedAt,
};
}
export async function serviceCreateCard(
input: ServiceInputCreateCard,
): Promise<bigint> {
log.info("Creating card from note", { noteId: input.noteId.toString(), deckId: input.deckId });
const existingCards = await repoGetCardsByNoteId(input.noteId);
const maxOrd = existingCards.reduce((max, c) => Math.max(max, c.ord), -1);
const ord = input.ord ?? maxOrd + 1;
const cardId = await repoCreateCard({
id: generateCardId(),
noteId: input.noteId,
deckId: input.deckId,
ord,
due: ord,
type: CardType.NEW,
queue: CardQueue.NEW,
});
log.info("Card created", { cardId: cardId.toString() });
return cardId;
}
export async function serviceAnswerCard(
input: ServiceInputAnswerCard,
): Promise<ServiceOutputReviewResult> {
log.info("Answering card", { cardId: input.cardId.toString(), ease: input.ease });
const card = await repoGetCardById(input.cardId);
if (!card) {
throw new Error(`Card not found: ${input.cardId.toString()}`);
}
const { ease } = input;
let updateData: RepoInputUpdateCard;
let scheduled: ServiceOutputScheduledCard;
if (card.type === CardType.NEW) {
const result = scheduleNewCard(ease, card.factor);
updateData = {
type: result.type,
queue: result.queue,
ivl: result.ivl,
due: result.due,
factor: result.newFactor,
reps: card.reps + 1,
left: result.type === CardType.LEARNING ? SM2_CONFIG.LEARNING_STEPS.length * 1000 + SM2_CONFIG.LEARNING_STEPS.length : 0,
mod: Math.floor(Date.now() / 1000),
};
scheduled = {
cardId: card.id,
newType: result.type,
newQueue: result.queue,
newDue: result.due,
newIvl: result.ivl,
newFactor: result.newFactor,
newReps: card.reps + 1,
newLapses: card.lapses,
nextReviewDate: calculateNextReviewTime(result.ivl),
};
} else if (card.type === CardType.LEARNING || card.type === CardType.RELEARNING) {
const result = scheduleLearningCard(ease, card.factor, card.left);
updateData = {
type: result.type,
queue: result.queue,
ivl: result.ivl,
due: result.due,
factor: result.newFactor,
reps: card.reps + 1,
left: result.newLeft,
mod: Math.floor(Date.now() / 1000),
};
scheduled = {
cardId: card.id,
newType: result.type,
newQueue: result.queue,
newDue: result.due,
newIvl: result.ivl,
newFactor: result.newFactor,
newReps: card.reps + 1,
newLapses: card.lapses,
nextReviewDate: calculateNextReviewTime(result.ivl),
};
} else {
const result = scheduleReviewCard(ease, card.ivl, card.factor, card.lapses);
updateData = {
type: result.type,
queue: result.queue,
ivl: result.ivl,
due: result.due,
factor: result.newFactor,
reps: card.reps + 1,
lapses: result.newLapses,
left: result.type === CardType.RELEARNING ? SM2_CONFIG.LEARNING_STEPS.length * 1000 + SM2_CONFIG.LEARNING_STEPS.length : 0,
mod: Math.floor(Date.now() / 1000),
};
scheduled = {
cardId: card.id,
newType: result.type,
newQueue: result.queue,
newDue: result.due,
newIvl: result.ivl,
newFactor: result.newFactor,
newReps: card.reps + 1,
newLapses: result.newLapses,
nextReviewDate: calculateNextReviewTime(result.ivl),
};
}
await repoUpdateCard(input.cardId, updateData);
const updatedCard = await repoGetCardById(input.cardId);
if (!updatedCard) {
throw new Error(`Card not found after update: ${input.cardId.toString()}`);
}
log.info("Card answered and scheduled", {
cardId: input.cardId.toString(),
newType: scheduled.newType,
newIvl: scheduled.newIvl,
nextReview: scheduled.nextReviewDate.toISOString(),
});
return {
success: true,
card: mapToServiceOutput(updatedCard),
scheduled,
};
}
export async function serviceGetNextCardForReview(
deckId: number,
): Promise<ServiceOutputCardWithNote | null> {
log.debug("Getting next card for review", { deckId });
const cards = await repoGetCardsForReview({ deckId, limit: 1 });
return cards[0] ?? null;
}
export async function serviceGetCardsForReview(
input: ServiceInputGetCardsForReview,
): Promise<ServiceOutputCardWithNote[]> {
log.debug("Getting cards for review", { deckId: input.deckId });
return repoGetCardsForReview(input);
}
export async function serviceGetNewCards(
input: ServiceInputGetNewCards,
): Promise<ServiceOutputCardWithNote[]> {
log.debug("Getting new cards", { deckId: input.deckId });
return repoGetNewCards(input);
}
export async function serviceGetCardsByDeckId(
input: ServiceInputGetCardsByDeckId,
): Promise<ServiceOutputCard[]> {
log.debug("Getting cards by deck", { deckId: input.deckId });
const cards = await repoGetCardsByDeckId(input);
return cards.map(mapToServiceOutput);
}
export async function serviceGetCardsByDeckIdWithNotes(
input: ServiceInputGetCardsByDeckId,
): Promise<ServiceOutputCardWithNote[]> {
log.debug("Getting cards by deck with notes", { deckId: input.deckId });
return repoGetCardsByDeckIdWithNotes(input);
}
export async function serviceGetCardById(
cardId: bigint,
): Promise<ServiceOutputCard | null> {
const card = await repoGetCardById(cardId);
return card ? mapToServiceOutput(card) : null;
}
export async function serviceGetCardByIdWithNote(
cardId: bigint,
): Promise<ServiceOutputCardWithNote | null> {
return repoGetCardByIdWithNote(cardId);
}
export async function serviceGetCardStats(
input: ServiceInputGetCardStats,
): Promise<ServiceOutputCardStats> {
log.debug("Getting card stats", { deckId: input.deckId });
return repoGetCardStats(input.deckId);
}
export async function serviceDeleteCard(cardId: bigint): Promise<void> {
log.info("Deleting card", { cardId: cardId.toString() });
await repoDeleteCard(cardId);
}

View File

@@ -0,0 +1,157 @@
import { generateValidator } from "@/utils/validate";
import z from "zod";
export const schemaActionInputCreateDeck = z.object({
name: z.string().min(1).max(100),
desc: z.string().max(500).optional(),
visibility: z.enum(["PRIVATE", "PUBLIC"]).optional(),
});
export type ActionInputCreateDeck = z.infer<typeof schemaActionInputCreateDeck>;
export const validateActionInputCreateDeck = generateValidator(schemaActionInputCreateDeck);
export const schemaActionInputUpdateDeck = z.object({
deckId: z.number().int().positive(),
name: z.string().min(1).max(100).optional(),
desc: z.string().max(500).optional(),
visibility: z.enum(["PRIVATE", "PUBLIC"]).optional(),
collapsed: z.boolean().optional(),
});
export type ActionInputUpdateDeck = z.infer<typeof schemaActionInputUpdateDeck>;
export const validateActionInputUpdateDeck = generateValidator(schemaActionInputUpdateDeck);
export const schemaActionInputDeleteDeck = z.object({
deckId: z.number().int().positive(),
});
export type ActionInputDeleteDeck = z.infer<typeof schemaActionInputDeleteDeck>;
export const validateActionInputDeleteDeck = generateValidator(schemaActionInputDeleteDeck);
export const schemaActionInputGetDeckById = z.object({
deckId: z.number().int().positive(),
});
export type ActionInputGetDeckById = z.infer<typeof schemaActionInputGetDeckById>;
export const validateActionInputGetDeckById = generateValidator(schemaActionInputGetDeckById);
export const schemaActionInputGetPublicDecks = z.object({
limit: z.number().int().positive().optional(),
offset: z.number().int().nonnegative().optional(),
});
export type ActionInputGetPublicDecks = z.infer<typeof schemaActionInputGetPublicDecks>;
export const validateActionInputGetPublicDecks = generateValidator(schemaActionInputGetPublicDecks);
export type ActionOutputDeck = {
id: number;
name: string;
desc: string;
userId: string;
visibility: "PRIVATE" | "PUBLIC";
collapsed: boolean;
conf: unknown;
createdAt: Date;
updatedAt: Date;
cardCount?: number;
};
export type ActionOutputPublicDeck = ActionOutputDeck & {
userName: string | null;
userUsername: string | null;
favoriteCount: number;
};
export type ActionOutputCreateDeck = {
message: string;
success: boolean;
deckId?: number;
};
export type ActionOutputUpdateDeck = {
message: string;
success: boolean;
};
export type ActionOutputDeleteDeck = {
message: string;
success: boolean;
};
export type ActionOutputGetDeckById = {
message: string;
success: boolean;
data?: ActionOutputDeck;
};
export type ActionOutputGetDecksByUserId = {
message: string;
success: boolean;
data?: ActionOutputDeck[];
};
export type ActionOutputGetPublicDecks = {
message: string;
success: boolean;
data?: ActionOutputPublicDeck[];
};
export const schemaActionInputSearchPublicDecks = z.object({
query: z.string().min(1),
limit: z.number().int().positive().optional(),
offset: z.number().int().nonnegative().optional(),
});
export type ActionInputSearchPublicDecks = z.infer<typeof schemaActionInputSearchPublicDecks>;
export const validateActionInputSearchPublicDecks = generateValidator(schemaActionInputSearchPublicDecks);
export const schemaActionInputGetPublicDeckById = z.object({
deckId: z.number().int().positive(),
});
export type ActionInputGetPublicDeckById = z.infer<typeof schemaActionInputGetPublicDeckById>;
export const validateActionInputGetPublicDeckById = generateValidator(schemaActionInputGetPublicDeckById);
export const schemaActionInputToggleDeckFavorite = z.object({
deckId: z.number().int().positive(),
});
export type ActionInputToggleDeckFavorite = z.infer<typeof schemaActionInputToggleDeckFavorite>;
export const validateActionInputToggleDeckFavorite = generateValidator(schemaActionInputToggleDeckFavorite);
export const schemaActionInputCheckDeckFavorite = z.object({
deckId: z.number().int().positive(),
});
export type ActionInputCheckDeckFavorite = z.infer<typeof schemaActionInputCheckDeckFavorite>;
export const validateActionInputCheckDeckFavorite = generateValidator(schemaActionInputCheckDeckFavorite);
export type ActionOutputDeckFavorite = {
isFavorited: boolean;
favoriteCount: number;
};
export type ActionOutputSearchPublicDecks = {
message: string;
success: boolean;
data?: ActionOutputPublicDeck[];
};
export type ActionOutputGetPublicDeckById = {
message: string;
success: boolean;
data?: ActionOutputPublicDeck;
};
export type ActionOutputToggleDeckFavorite = {
message: string;
success: boolean;
data?: ActionOutputDeckFavorite;
};
export type ActionOutputCheckDeckFavorite = {
message: string;
success: boolean;
data?: ActionOutputDeckFavorite;
};
export type ActionOutputUserFavoriteDeck = ActionOutputPublicDeck & {
favoritedAt: Date;
};
export type ActionOutputGetUserFavoriteDecks = {
message: string;
success: boolean;
data?: ActionOutputUserFavoriteDeck[];
};

View File

@@ -0,0 +1,327 @@
"use server";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { createLogger } from "@/lib/logger";
import { ValidateError } from "@/lib/errors";
import { Visibility } from "../../../generated/prisma/enums";
import {
ActionInputCreateDeck,
ActionInputUpdateDeck,
ActionInputDeleteDeck,
ActionInputGetDeckById,
ActionInputGetPublicDecks,
ActionInputSearchPublicDecks,
ActionInputGetPublicDeckById,
ActionInputToggleDeckFavorite,
ActionInputCheckDeckFavorite,
ActionOutputCreateDeck,
ActionOutputUpdateDeck,
ActionOutputDeleteDeck,
ActionOutputGetDeckById,
ActionOutputGetDecksByUserId,
ActionOutputGetPublicDecks,
ActionOutputDeck,
ActionOutputPublicDeck,
ActionOutputSearchPublicDecks,
ActionOutputGetPublicDeckById,
ActionOutputToggleDeckFavorite,
ActionOutputCheckDeckFavorite,
ActionOutputGetUserFavoriteDecks,
validateActionInputCreateDeck,
validateActionInputUpdateDeck,
validateActionInputDeleteDeck,
validateActionInputGetDeckById,
validateActionInputGetPublicDecks,
validateActionInputSearchPublicDecks,
validateActionInputGetPublicDeckById,
validateActionInputToggleDeckFavorite,
validateActionInputCheckDeckFavorite,
} from "./deck-action-dto";
import {
serviceCreateDeck,
serviceUpdateDeck,
serviceDeleteDeck,
serviceGetDeckById,
serviceGetDecksByUserId,
serviceGetPublicDecks,
serviceCheckOwnership,
serviceSearchPublicDecks,
serviceGetPublicDeckById,
serviceToggleDeckFavorite,
serviceCheckDeckFavorite,
serviceGetUserFavoriteDecks,
} from "./deck-service";
const log = createLogger("deck-action");
async function checkDeckOwnership(deckId: number): Promise<boolean> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) return false;
return serviceCheckOwnership({ deckId, userId: session.user.id });
}
export async function actionCreateDeck(input: ActionInputCreateDeck): Promise<ActionOutputCreateDeck> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return { success: false, message: "Unauthorized" };
}
const validatedInput = validateActionInputCreateDeck(input);
const result = await serviceCreateDeck({
name: validatedInput.name,
desc: validatedInput.desc,
userId: session.user.id,
visibility: validatedInput.visibility as Visibility | undefined,
});
return result;
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to create deck", { error: e });
return { success: false, message: "Unknown error occurred" };
}
}
export async function actionUpdateDeck(input: ActionInputUpdateDeck): Promise<ActionOutputUpdateDeck> {
try {
const validatedInput = validateActionInputUpdateDeck(input);
const isOwner = await checkDeckOwnership(validatedInput.deckId);
if (!isOwner) {
return { success: false, message: "You do not have permission to update this deck" };
}
return serviceUpdateDeck({
deckId: validatedInput.deckId,
name: validatedInput.name,
desc: validatedInput.desc,
visibility: validatedInput.visibility as Visibility | undefined,
collapsed: validatedInput.collapsed,
});
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to update deck", { error: e });
return { success: false, message: "Unknown error occurred" };
}
}
export async function actionDeleteDeck(input: ActionInputDeleteDeck): Promise<ActionOutputDeleteDeck> {
try {
const validatedInput = validateActionInputDeleteDeck(input);
const isOwner = await checkDeckOwnership(validatedInput.deckId);
if (!isOwner) {
return { success: false, message: "You do not have permission to delete this deck" };
}
return serviceDeleteDeck({ deckId: validatedInput.deckId });
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to delete deck", { error: e });
return { success: false, message: "Unknown error occurred" };
}
}
export async function actionGetDeckById(input: ActionInputGetDeckById): Promise<ActionOutputGetDeckById> {
try {
const validatedInput = validateActionInputGetDeckById(input);
const result = await serviceGetDeckById({ deckId: validatedInput.deckId });
if (!result.success || !result.data) {
return { success: false, message: result.message };
}
return {
success: true,
message: result.message,
data: {
...result.data,
visibility: result.data.visibility as "PRIVATE" | "PUBLIC",
},
};
} catch (e) {
log.error("Failed to get deck", { error: e });
return { success: false, message: "Unknown error occurred" };
}
}
export async function actionGetDecksByUserId(userId: string): Promise<ActionOutputGetDecksByUserId> {
try {
const result = await serviceGetDecksByUserId({ userId });
if (!result.success || !result.data) {
return { success: false, message: result.message };
}
return {
success: true,
message: result.message,
data: result.data.map((deck) => ({
...deck,
visibility: deck.visibility as "PRIVATE" | "PUBLIC",
})),
};
} catch (e) {
log.error("Failed to get decks", { error: e });
return { success: false, message: "Unknown error occurred" };
}
}
export async function actionGetPublicDecks(input: ActionInputGetPublicDecks = {}): Promise<ActionOutputGetPublicDecks> {
try {
const validatedInput = validateActionInputGetPublicDecks(input);
const result = await serviceGetPublicDecks(validatedInput);
if (!result.success || !result.data) {
return { success: false, message: result.message };
}
return {
success: true,
message: result.message,
data: result.data.map((deck) => ({
...deck,
visibility: deck.visibility as "PRIVATE" | "PUBLIC",
})),
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get public decks", { error: e });
return { success: false, message: "Unknown error occurred" };
}
}
export async function actionGetPublicDeckById(input: ActionInputGetPublicDeckById): Promise<ActionOutputGetPublicDeckById> {
try {
const validatedInput = validateActionInputGetPublicDeckById(input);
const result = await serviceGetPublicDeckById(validatedInput);
if (!result.success || !result.data) {
return { success: false, message: result.message };
}
return {
success: true,
message: result.message,
data: {
...result.data,
visibility: result.data.visibility as "PRIVATE" | "PUBLIC",
},
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get public deck", { error: e });
return { success: false, message: "Unknown error occurred" };
}
}
export async function actionSearchPublicDecks(input: ActionInputSearchPublicDecks): Promise<ActionOutputSearchPublicDecks> {
try {
const validatedInput = validateActionInputSearchPublicDecks(input);
const result = await serviceSearchPublicDecks(validatedInput);
if (!result.success || !result.data) {
return { success: false, message: result.message };
}
return {
success: true,
message: result.message,
data: result.data.map((deck) => ({
...deck,
visibility: deck.visibility as "PRIVATE" | "PUBLIC",
})),
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to search public decks", { error: e });
return { success: false, message: "Unknown error occurred" };
}
}
export async function actionToggleDeckFavorite(input: ActionInputToggleDeckFavorite): Promise<ActionOutputToggleDeckFavorite> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return { success: false, message: "Unauthorized" };
}
const validatedInput = validateActionInputToggleDeckFavorite(input);
const result = await serviceToggleDeckFavorite({
deckId: validatedInput.deckId,
userId: session.user.id,
});
return result;
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to toggle deck favorite", { error: e });
return { success: false, message: "Unknown error occurred" };
}
}
export async function actionCheckDeckFavorite(input: ActionInputCheckDeckFavorite): Promise<ActionOutputCheckDeckFavorite> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return { success: true, message: "Not logged in", data: { isFavorited: false, favoriteCount: 0 } };
}
const validatedInput = validateActionInputCheckDeckFavorite(input);
const result = await serviceCheckDeckFavorite({
deckId: validatedInput.deckId,
userId: session.user.id,
});
return result;
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to check deck favorite", { error: e });
return { success: false, message: "Unknown error occurred" };
}
}
export async function actionGetUserFavoriteDecks(): Promise<ActionOutputGetUserFavoriteDecks> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return { success: false, message: "Unauthorized" };
}
const result = await serviceGetUserFavoriteDecks(session.user.id);
if (!result.success || !result.data) {
return { success: false, message: result.message };
}
return {
success: true,
message: result.message,
data: result.data.map((deck) => ({
...deck,
visibility: deck.visibility as "PRIVATE" | "PUBLIC",
})),
};
} catch (e) {
log.error("Failed to get user favorite decks", { error: e });
return { success: false, message: "Unknown error occurred" };
}
}

View File

@@ -0,0 +1,90 @@
import { Visibility } from "../../../generated/prisma/enums";
export interface RepoInputCreateDeck {
name: string;
desc?: string;
userId: string;
visibility?: Visibility;
}
export interface RepoInputUpdateDeck {
id: number;
name?: string;
desc?: string;
visibility?: Visibility;
collapsed?: boolean;
}
export interface RepoInputGetDeckById {
id: number;
}
export interface RepoInputGetDecksByUserId {
userId: string;
}
export interface RepoInputGetPublicDecks {
limit?: number;
offset?: number;
orderBy?: "createdAt" | "name";
}
export interface RepoInputDeleteDeck {
id: number;
}
export type RepoOutputDeck = {
id: number;
name: string;
desc: string;
userId: string;
visibility: Visibility;
collapsed: boolean;
conf: unknown;
createdAt: Date;
updatedAt: Date;
cardCount?: number;
};
export type RepoOutputPublicDeck = RepoOutputDeck & {
userName: string | null;
userUsername: string | null;
favoriteCount: number;
};
export type RepoOutputDeckOwnership = {
userId: string;
};
export interface RepoInputToggleDeckFavorite {
deckId: number;
userId: string;
}
export interface RepoInputCheckDeckFavorite {
deckId: number;
userId: string;
}
export interface RepoInputSearchPublicDecks {
query: string;
limit?: number;
offset?: number;
}
export interface RepoInputGetPublicDeckById {
deckId: number;
}
export type RepoOutputDeckFavorite = {
isFavorited: boolean;
favoriteCount: number;
};
export interface RepoInputGetUserFavoriteDecks {
userId: string;
}
export type RepoOutputUserFavoriteDeck = RepoOutputPublicDeck & {
favoritedAt: Date;
};

View File

@@ -0,0 +1,327 @@
import { prisma } from "@/lib/db";
import {
RepoInputCreateDeck,
RepoInputUpdateDeck,
RepoInputGetDeckById,
RepoInputGetDecksByUserId,
RepoInputGetPublicDecks,
RepoInputDeleteDeck,
RepoOutputDeck,
RepoOutputPublicDeck,
RepoOutputDeckOwnership,
RepoInputToggleDeckFavorite,
RepoInputCheckDeckFavorite,
RepoInputSearchPublicDecks,
RepoInputGetPublicDeckById,
RepoOutputDeckFavorite,
RepoInputGetUserFavoriteDecks,
RepoOutputUserFavoriteDeck,
} from "./deck-repository-dto";
import { Visibility } from "../../../generated/prisma/enums";
export async function repoCreateDeck(data: RepoInputCreateDeck): Promise<number> {
const deck = await prisma.deck.create({
data: {
name: data.name,
desc: data.desc ?? "",
userId: data.userId,
visibility: data.visibility ?? Visibility.PRIVATE,
},
});
return deck.id;
}
export async function repoUpdateDeck(input: RepoInputUpdateDeck): Promise<void> {
const { id, ...updateData } = input;
await prisma.deck.update({
where: { id },
data: updateData,
});
}
export async function repoGetDeckById(input: RepoInputGetDeckById): Promise<RepoOutputDeck | null> {
const deck = await prisma.deck.findUnique({
where: { id: input.id },
include: {
_count: {
select: { cards: true },
},
},
});
if (!deck) return null;
return {
id: deck.id,
name: deck.name,
desc: deck.desc,
userId: deck.userId,
visibility: deck.visibility,
collapsed: deck.collapsed,
conf: deck.conf,
createdAt: deck.createdAt,
updatedAt: deck.updatedAt,
cardCount: deck._count?.cards ?? 0,
};
}
export async function repoGetDecksByUserId(input: RepoInputGetDecksByUserId): Promise<RepoOutputDeck[]> {
const decks = await prisma.deck.findMany({
where: { userId: input.userId },
include: {
_count: {
select: { cards: true },
},
},
orderBy: {
createdAt: "desc",
},
});
return decks.map((deck) => ({
id: deck.id,
name: deck.name,
desc: deck.desc,
userId: deck.userId,
visibility: deck.visibility,
collapsed: deck.collapsed,
conf: deck.conf,
createdAt: deck.createdAt,
updatedAt: deck.updatedAt,
cardCount: deck._count?.cards ?? 0,
}));
}
export async function repoGetPublicDecks(input: RepoInputGetPublicDecks = {}): Promise<RepoOutputPublicDeck[]> {
const { limit = 50, offset = 0, orderBy = "createdAt" } = input;
const decks = await prisma.deck.findMany({
where: { visibility: Visibility.PUBLIC },
include: {
_count: {
select: { cards: true, favorites: true },
},
user: {
select: { name: true, username: true },
},
},
orderBy: { [orderBy]: "desc" },
take: limit,
skip: offset,
});
return decks.map((deck) => ({
id: deck.id,
name: deck.name,
desc: deck.desc,
userId: deck.userId,
visibility: deck.visibility,
collapsed: deck.collapsed,
conf: deck.conf,
createdAt: deck.createdAt,
updatedAt: deck.updatedAt,
cardCount: deck._count?.cards ?? 0,
userName: deck.user?.name ?? null,
userUsername: deck.user?.username ?? null,
favoriteCount: deck._count?.favorites ?? 0,
}));
}
export async function repoDeleteDeck(input: RepoInputDeleteDeck): Promise<void> {
await prisma.deck.delete({
where: { id: input.id },
});
}
export async function repoGetUserIdByDeckId(deckId: number): Promise<string | null> {
const deck = await prisma.deck.findUnique({
where: { id: deckId },
select: { userId: true },
});
return deck?.userId ?? null;
}
export async function repoGetDeckOwnership(deckId: number): Promise<RepoOutputDeckOwnership | null> {
const deck = await prisma.deck.findUnique({
where: { id: deckId },
select: { userId: true },
});
return deck;
}
export async function repoGetPublicDeckById(input: RepoInputGetPublicDeckById): Promise<RepoOutputPublicDeck | null> {
const deck = await prisma.deck.findFirst({
where: {
id: input.deckId,
visibility: Visibility.PUBLIC,
},
include: {
_count: {
select: { cards: true, favorites: true },
},
user: {
select: { name: true, username: true },
},
},
});
if (!deck) return null;
return {
id: deck.id,
name: deck.name,
desc: deck.desc,
userId: deck.userId,
visibility: deck.visibility,
collapsed: deck.collapsed,
conf: deck.conf,
createdAt: deck.createdAt,
updatedAt: deck.updatedAt,
cardCount: deck._count?.cards ?? 0,
userName: deck.user?.name ?? null,
userUsername: deck.user?.username ?? null,
favoriteCount: deck._count?.favorites ?? 0,
};
}
export async function repoToggleDeckFavorite(input: RepoInputToggleDeckFavorite): Promise<RepoOutputDeckFavorite> {
const existing = await prisma.deckFavorite.findUnique({
where: {
userId_deckId: {
userId: input.userId,
deckId: input.deckId,
},
},
});
if (existing) {
await prisma.deckFavorite.delete({
where: { id: existing.id },
});
} else {
await prisma.deckFavorite.create({
data: {
userId: input.userId,
deckId: input.deckId,
},
});
}
const deck = await prisma.deck.findUnique({
where: { id: input.deckId },
include: {
_count: {
select: { favorites: true },
},
},
});
return {
isFavorited: !existing,
favoriteCount: deck?._count?.favorites ?? 0,
};
}
export async function repoCheckDeckFavorite(input: RepoInputCheckDeckFavorite): Promise<RepoOutputDeckFavorite> {
const favorite = await prisma.deckFavorite.findUnique({
where: {
userId_deckId: {
userId: input.userId,
deckId: input.deckId,
},
},
});
const deck = await prisma.deck.findUnique({
where: { id: input.deckId },
include: {
_count: {
select: { favorites: true },
},
},
});
return {
isFavorited: !!favorite,
favoriteCount: deck?._count?.favorites ?? 0,
};
}
export async function repoSearchPublicDecks(input: RepoInputSearchPublicDecks): Promise<RepoOutputPublicDeck[]> {
const { query, limit = 50, offset = 0 } = input;
const decks = await prisma.deck.findMany({
where: {
visibility: Visibility.PUBLIC,
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ desc: { contains: query, mode: "insensitive" } },
],
},
include: {
_count: {
select: { cards: true, favorites: true },
},
user: {
select: { name: true, username: true },
},
},
orderBy: { createdAt: "desc" },
take: limit,
skip: offset,
});
return decks.map((deck) => ({
id: deck.id,
name: deck.name,
desc: deck.desc,
userId: deck.userId,
visibility: deck.visibility,
collapsed: deck.collapsed,
conf: deck.conf,
createdAt: deck.createdAt,
updatedAt: deck.updatedAt,
cardCount: deck._count?.cards ?? 0,
userName: deck.user?.name ?? null,
userUsername: deck.user?.username ?? null,
favoriteCount: deck._count?.favorites ?? 0,
}));
}
export async function repoGetUserFavoriteDecks(
input: RepoInputGetUserFavoriteDecks,
): Promise<RepoOutputUserFavoriteDeck[]> {
const favorites = await prisma.deckFavorite.findMany({
where: { userId: input.userId },
include: {
deck: {
include: {
_count: {
select: { cards: true, favorites: true },
},
user: {
select: { name: true, username: true },
},
},
},
},
orderBy: { createdAt: "desc" },
});
return favorites.map((fav) => ({
id: fav.deck.id,
name: fav.deck.name,
desc: fav.deck.desc,
userId: fav.deck.userId,
visibility: fav.deck.visibility,
collapsed: fav.deck.collapsed,
conf: fav.deck.conf,
createdAt: fav.deck.createdAt,
updatedAt: fav.deck.updatedAt,
cardCount: fav.deck._count?.cards ?? 0,
userName: fav.deck.user?.name ?? null,
userUsername: fav.deck.user?.username ?? null,
favoriteCount: fav.deck._count?.favorites ?? 0,
favoritedAt: fav.createdAt,
}));
}

View File

@@ -0,0 +1,86 @@
import { Visibility } from "../../../generated/prisma/enums";
export type ServiceInputCreateDeck = {
name: string;
desc?: string;
userId: string;
visibility?: Visibility;
};
export type ServiceInputUpdateDeck = {
deckId: number;
name?: string;
desc?: string;
visibility?: Visibility;
collapsed?: boolean;
};
export type ServiceInputDeleteDeck = {
deckId: number;
};
export type ServiceInputGetDeckById = {
deckId: number;
};
export type ServiceInputGetDecksByUserId = {
userId: string;
};
export type ServiceInputGetPublicDecks = {
limit?: number;
offset?: number;
};
export type ServiceInputCheckOwnership = {
deckId: number;
userId: string;
};
export type ServiceOutputDeck = {
id: number;
name: string;
desc: string;
userId: string;
visibility: Visibility;
collapsed: boolean;
conf: unknown;
createdAt: Date;
updatedAt: Date;
cardCount?: number;
};
export type ServiceOutputPublicDeck = ServiceOutputDeck & {
userName: string | null;
userUsername: string | null;
favoriteCount: number;
};
export type ServiceInputToggleDeckFavorite = {
deckId: number;
userId: string;
};
export type ServiceInputCheckDeckFavorite = {
deckId: number;
userId: string;
};
export type ServiceInputSearchPublicDecks = {
query: string;
limit?: number;
offset?: number;
};
export type ServiceInputGetPublicDeckById = {
deckId: number;
};
export type ServiceOutputDeckFavorite = {
isFavorited: boolean;
favoriteCount: number;
};
export type ServiceOutputUserFavoriteDeck = ServiceOutputPublicDeck & {
favoritedAt: Date;
};

View File

@@ -0,0 +1,169 @@
"use server";
import { createLogger } from "@/lib/logger";
import {
ServiceInputCreateDeck,
ServiceInputUpdateDeck,
ServiceInputDeleteDeck,
ServiceInputGetDeckById,
ServiceInputGetDecksByUserId,
ServiceInputGetPublicDecks,
ServiceInputCheckOwnership,
ServiceOutputDeck,
ServiceOutputPublicDeck,
ServiceInputToggleDeckFavorite,
ServiceInputCheckDeckFavorite,
ServiceInputSearchPublicDecks,
ServiceInputGetPublicDeckById,
ServiceOutputDeckFavorite,
ServiceOutputUserFavoriteDeck,
} from "./deck-service-dto";
import {
repoCreateDeck,
repoUpdateDeck,
repoGetDeckById,
repoGetDecksByUserId,
repoGetPublicDecks,
repoDeleteDeck,
repoGetUserIdByDeckId,
repoToggleDeckFavorite,
repoCheckDeckFavorite,
repoSearchPublicDecks,
repoGetPublicDeckById,
repoGetUserFavoriteDecks,
} from "./deck-repository";
const log = createLogger("deck-service");
export async function serviceCheckOwnership(input: ServiceInputCheckOwnership): Promise<boolean> {
const ownerId = await repoGetUserIdByDeckId(input.deckId);
return ownerId === input.userId;
}
export async function serviceCreateDeck(input: ServiceInputCreateDeck): Promise<{ success: boolean; deckId?: number; message: string }> {
try {
log.info("Creating deck", { name: input.name, userId: input.userId });
const deckId = await repoCreateDeck(input);
log.info("Deck created successfully", { deckId });
return { success: true, deckId, message: "Deck created successfully" };
} catch (error) {
log.error("Failed to create deck", { error });
return { success: false, message: "Failed to create deck" };
}
}
export async function serviceUpdateDeck(input: ServiceInputUpdateDeck): Promise<{ success: boolean; message: string }> {
try {
log.info("Updating deck", { deckId: input.deckId });
await repoUpdateDeck({
id: input.deckId,
name: input.name,
desc: input.desc,
visibility: input.visibility,
collapsed: input.collapsed,
});
log.info("Deck updated successfully", { deckId: input.deckId });
return { success: true, message: "Deck updated successfully" };
} catch (error) {
log.error("Failed to update deck", { error, deckId: input.deckId });
return { success: false, message: "Failed to update deck" };
}
}
export async function serviceDeleteDeck(input: ServiceInputDeleteDeck): Promise<{ success: boolean; message: string }> {
try {
log.info("Deleting deck", { deckId: input.deckId });
await repoDeleteDeck({ id: input.deckId });
log.info("Deck deleted successfully", { deckId: input.deckId });
return { success: true, message: "Deck deleted successfully" };
} catch (error) {
log.error("Failed to delete deck", { error, deckId: input.deckId });
return { success: false, message: "Failed to delete deck" };
}
}
export async function serviceGetDeckById(input: ServiceInputGetDeckById): Promise<{ success: boolean; data?: ServiceOutputDeck; message: string }> {
try {
const deck = await repoGetDeckById({ id: input.deckId });
if (!deck) {
return { success: false, message: "Deck not found" };
}
return { success: true, data: deck, message: "Deck retrieved successfully" };
} catch (error) {
log.error("Failed to get deck", { error, deckId: input.deckId });
return { success: false, message: "Failed to get deck" };
}
}
export async function serviceGetDecksByUserId(input: ServiceInputGetDecksByUserId): Promise<{ success: boolean; data?: ServiceOutputDeck[]; message: string }> {
try {
const decks = await repoGetDecksByUserId(input);
return { success: true, data: decks, message: "Decks retrieved successfully" };
} catch (error) {
log.error("Failed to get decks", { error, userId: input.userId });
return { success: false, message: "Failed to get decks" };
}
}
export async function serviceGetPublicDecks(input: ServiceInputGetPublicDecks = {}): Promise<{ success: boolean; data?: ServiceOutputPublicDeck[]; message: string }> {
try {
const decks = await repoGetPublicDecks(input);
return { success: true, data: decks, message: "Public decks retrieved successfully" };
} catch (error) {
log.error("Failed to get public decks", { error });
return { success: false, message: "Failed to get public decks" };
}
}
export async function serviceGetPublicDeckById(input: ServiceInputGetPublicDeckById): Promise<{ success: boolean; data?: ServiceOutputPublicDeck; message: string }> {
try {
const deck = await repoGetPublicDeckById(input);
if (!deck) {
return { success: false, message: "Deck not found or not public" };
}
return { success: true, data: deck, message: "Deck retrieved successfully" };
} catch (error) {
log.error("Failed to get public deck", { error, deckId: input.deckId });
return { success: false, message: "Failed to get deck" };
}
}
export async function serviceToggleDeckFavorite(input: ServiceInputToggleDeckFavorite): Promise<{ success: boolean; data?: ServiceOutputDeckFavorite; message: string }> {
try {
const result = await repoToggleDeckFavorite(input);
return { success: true, data: result, message: "Favorite toggled successfully" };
} catch (error) {
log.error("Failed to toggle deck favorite", { error, deckId: input.deckId });
return { success: false, message: "Failed to toggle favorite" };
}
}
export async function serviceCheckDeckFavorite(input: ServiceInputCheckDeckFavorite): Promise<{ success: boolean; data?: ServiceOutputDeckFavorite; message: string }> {
try {
const result = await repoCheckDeckFavorite(input);
return { success: true, data: result, message: "Favorite status retrieved" };
} catch (error) {
log.error("Failed to check deck favorite", { error, deckId: input.deckId });
return { success: false, message: "Failed to check favorite status" };
}
}
export async function serviceSearchPublicDecks(input: ServiceInputSearchPublicDecks): Promise<{ success: boolean; data?: ServiceOutputPublicDeck[]; message: string }> {
try {
const decks = await repoSearchPublicDecks(input);
return { success: true, data: decks, message: "Search completed successfully" };
} catch (error) {
log.error("Failed to search public decks", { error, query: input.query });
return { success: false, message: "Search failed" };
}
}
export async function serviceGetUserFavoriteDecks(userId: string): Promise<{ success: boolean; data?: ServiceOutputUserFavoriteDeck[]; message: string }> {
try {
const favorites = await repoGetUserFavoriteDecks({ userId });
return { success: true, data: favorites, message: "Favorite decks retrieved successfully" };
} catch (error) {
log.error("Failed to get user favorite decks", { error, userId });
return { success: false, message: "Failed to get favorite decks" };
}
}

View File

@@ -1,110 +0,0 @@
import { LENGTH_MAX_FOLDER_NAME, LENGTH_MAX_IPA, LENGTH_MAX_LANGUAGE, LENGTH_MAX_PAIR_TEXT, LENGTH_MIN_FOLDER_NAME, LENGTH_MIN_IPA, LENGTH_MIN_LANGUAGE, LENGTH_MIN_PAIR_TEXT } from "@/shared/constant";
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
import { generateValidator } from "@/utils/validate";
import z from "zod";
export const schemaActionInputCreatePair = z.object({
text1: z.string().min(LENGTH_MIN_PAIR_TEXT).max(LENGTH_MAX_PAIR_TEXT),
text2: z.string().min(LENGTH_MIN_PAIR_TEXT).max(LENGTH_MAX_PAIR_TEXT),
language1: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE),
language2: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE),
ipa1: z.string().min(LENGTH_MIN_IPA).max(LENGTH_MAX_IPA).optional(),
ipa2: z.string().min(LENGTH_MIN_IPA).max(LENGTH_MAX_IPA).optional(),
folderId: z.int()
});
export type ActionInputCreatePair = z.infer<typeof schemaActionInputCreatePair>;
export const validateActionInputCreatePair = generateValidator(schemaActionInputCreatePair);
export const schemaActionInputUpdatePairById = z.object({
text1: z.string().min(LENGTH_MIN_PAIR_TEXT).max(LENGTH_MAX_PAIR_TEXT).optional(),
text2: z.string().min(LENGTH_MIN_PAIR_TEXT).max(LENGTH_MAX_PAIR_TEXT).optional(),
language1: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE).optional(),
language2: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE).optional(),
ipa1: z.string().min(LENGTH_MIN_IPA).max(LENGTH_MAX_IPA).optional(),
ipa2: z.string().min(LENGTH_MIN_IPA).max(LENGTH_MAX_IPA).optional(),
folderId: z.int().optional()
});
export type ActionInputUpdatePairById = z.infer<typeof schemaActionInputUpdatePairById>;
export const validateActionInputUpdatePairById = generateValidator(schemaActionInputUpdatePairById);
export type ActionOutputGetFoldersWithTotalPairsByUserId = {
message: string,
success: boolean,
data?: TSharedFolderWithTotalPairs[];
};
export const schemaActionInputSetFolderVisibility = z.object({
folderId: z.number().int().positive(),
visibility: z.enum(["PRIVATE", "PUBLIC"]),
});
export type ActionInputSetFolderVisibility = z.infer<typeof schemaActionInputSetFolderVisibility>;
export const schemaActionInputSearchPublicFolders = z.object({
query: z.string().min(1).max(100),
});
export type ActionInputSearchPublicFolders = z.infer<typeof schemaActionInputSearchPublicFolders>;
export type ActionOutputPublicFolder = {
id: number;
name: string;
visibility: "PRIVATE" | "PUBLIC";
createdAt: Date;
userId: string;
userName: string | null;
userUsername: string | null;
totalPairs: number;
favoriteCount: number;
};
export type ActionOutputGetPublicFolders = {
message: string;
success: boolean;
data?: ActionOutputPublicFolder[];
};
export type ActionOutputGetPublicFolderById = {
message: string;
success: boolean;
data?: ActionOutputPublicFolder;
};
export type ActionOutputSetFolderVisibility = {
message: string;
success: boolean;
};
export type ActionOutputToggleFavorite = {
message: string;
success: boolean;
data?: {
isFavorited: boolean;
favoriteCount: number;
};
};
export type ActionOutputCheckFavorite = {
message: string;
success: boolean;
data?: {
isFavorited: boolean;
favoriteCount: number;
};
};
export type ActionOutputUserFavorite = {
id: number;
folderId: number;
folderName: string;
folderCreatedAt: Date;
folderTotalPairs: number;
folderOwnerId: string;
folderOwnerName: string | null;
folderOwnerUsername: string | null;
favoritedAt: Date;
};
export type ActionOutputGetUserFavorites = {
message: string;
success: boolean;
data?: ActionOutputUserFavorite[];
};

View File

@@ -1,527 +0,0 @@
"use server";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { ValidateError } from "@/lib/errors";
import { createLogger } from "@/lib/logger";
const log = createLogger("folder-action");
import {
ActionInputCreatePair,
ActionInputUpdatePairById,
ActionOutputGetFoldersWithTotalPairsByUserId,
ActionOutputGetPublicFolders,
ActionOutputGetPublicFolderById,
ActionOutputSetFolderVisibility,
ActionOutputToggleFavorite,
ActionOutputCheckFavorite,
ActionOutputGetUserFavorites,
ActionOutputUserFavorite,
validateActionInputCreatePair,
validateActionInputUpdatePairById,
} from "./folder-action-dto";
import {
repoCreateFolder,
repoCreatePair,
repoDeleteFolderById,
repoDeletePairById,
repoGetFolderIdByPairId,
repoGetFolderVisibility,
repoGetFoldersByUserId,
repoGetFoldersWithTotalPairsByUserId,
repoGetPairsByFolderId,
repoGetPublicFolders,
repoGetPublicFolderById,
repoGetUserIdByFolderId,
repoRenameFolderById,
repoSearchPublicFolders,
repoUpdateFolderVisibility,
repoUpdatePairById,
repoToggleFavorite,
repoCheckFavorite,
repoGetUserFavorites,
} from "./folder-repository";
import { validate } from "@/utils/validate";
import z from "zod";
import { LENGTH_MAX_FOLDER_NAME, LENGTH_MIN_FOLDER_NAME } from "@/shared/constant";
import { Visibility } from "../../../generated/prisma/enums";
async function checkFolderOwnership(folderId: number): Promise<boolean> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) return false;
const folderOwnerId = await repoGetUserIdByFolderId(folderId);
return folderOwnerId === session.user.id;
}
async function checkPairOwnership(pairId: number): Promise<boolean> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) return false;
const folderId = await repoGetFolderIdByPairId(pairId);
if (!folderId) return false;
const folderOwnerId = await repoGetUserIdByFolderId(folderId);
return folderOwnerId === session.user.id;
}
export async function actionGetPairsByFolderId(folderId: number) {
try {
return {
success: true,
message: 'success',
data: await repoGetPairsByFolderId(folderId)
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionUpdatePairById(id: number, dto: ActionInputUpdatePairById) {
try {
const isOwner = await checkPairOwnership(id);
if (!isOwner) {
return {
success: false,
message: 'You do not have permission to update this item.',
};
}
const validatedDto = validateActionInputUpdatePairById(dto);
await repoUpdatePairById(id, validatedDto);
return {
success: true,
message: 'success',
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionGetUserIdByFolderId(folderId: number) {
try {
return {
success: true,
message: 'success',
data: await repoGetUserIdByFolderId(folderId)
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionGetFolderVisibility(folderId: number) {
try {
return {
success: true,
message: 'success',
data: await repoGetFolderVisibility(folderId)
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionDeleteFolderById(folderId: number) {
try {
const isOwner = await checkFolderOwnership(folderId);
if (!isOwner) {
return {
success: false,
message: 'You do not have permission to delete this folder.',
};
}
await repoDeleteFolderById(folderId);
return {
success: true,
message: 'success',
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionDeletePairById(id: number) {
try {
const isOwner = await checkPairOwnership(id);
if (!isOwner) {
return {
success: false,
message: 'You do not have permission to delete this item.',
};
}
await repoDeletePairById(id);
return {
success: true,
message: 'success'
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionGetFoldersWithTotalPairsByUserId(id: string): Promise<ActionOutputGetFoldersWithTotalPairsByUserId> {
try {
return {
success: true,
message: 'success',
data: await repoGetFoldersWithTotalPairsByUserId(id)
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionGetFoldersByUserId(userId: string) {
try {
return {
success: true,
message: 'success',
data: await repoGetFoldersByUserId(userId)
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionCreatePair(dto: ActionInputCreatePair) {
try {
const isOwner = await checkFolderOwnership(dto.folderId);
if (!isOwner) {
return {
success: false,
message: 'You do not have permission to add items to this folder.',
};
}
const validatedDto = validateActionInputCreatePair(dto);
await repoCreatePair(validatedDto);
return {
success: true,
message: 'success'
};
} catch (e) {
if (e instanceof ValidateError) {
return {
success: false,
message: e.message
};
}
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionCreateFolder(userId: string, folderName: string) {
try {
const validatedFolderName = validate(folderName,
z.string()
.trim()
.min(LENGTH_MIN_FOLDER_NAME)
.max(LENGTH_MAX_FOLDER_NAME));
await repoCreateFolder({
name: validatedFolderName,
userId: userId
});
return {
success: true,
message: 'success'
};
} catch (e) {
if (e instanceof ValidateError) {
return {
success: false,
message: e.message
};
}
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionRenameFolderById(id: number, newName: string) {
try {
const isOwner = await checkFolderOwnership(id);
if (!isOwner) {
return {
success: false,
message: 'You do not have permission to rename this folder.',
};
}
const validatedNewName = validate(
newName,
z.string()
.min(LENGTH_MIN_FOLDER_NAME)
.max(LENGTH_MAX_FOLDER_NAME)
.trim());
await repoRenameFolderById(id, validatedNewName);
return {
success: true,
message: 'success'
};
} catch (e) {
if (e instanceof ValidateError) {
return {
success: false,
message: e.message
};
}
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionSetFolderVisibility(
folderId: number,
visibility: "PRIVATE" | "PUBLIC",
): Promise<ActionOutputSetFolderVisibility> {
try {
const isOwner = await checkFolderOwnership(folderId);
if (!isOwner) {
return {
success: false,
message: 'You do not have permission to change this folder visibility.',
};
}
await repoUpdateFolderVisibility({
folderId,
visibility: visibility as Visibility,
});
return {
success: true,
message: 'success',
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.',
};
}
}
export async function actionGetPublicFolders(): Promise<ActionOutputGetPublicFolders> {
try {
const data = await repoGetPublicFolders({});
return {
success: true,
message: 'success',
data: data.map((folder) => ({
...folder,
visibility: folder.visibility as "PRIVATE" | "PUBLIC",
})),
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.',
};
}
}
export async function actionSearchPublicFolders(query: string): Promise<ActionOutputGetPublicFolders> {
try {
const data = await repoSearchPublicFolders({ query, limit: 50 });
return {
success: true,
message: 'success',
data: data.map((folder) => ({
...folder,
visibility: folder.visibility as "PRIVATE" | "PUBLIC",
})),
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.',
};
}
}
export async function actionGetPublicFolderById(folderId: number): Promise<ActionOutputGetPublicFolderById> {
try {
const folder = await repoGetPublicFolderById(folderId);
if (!folder) {
return {
success: false,
message: 'Folder not found.',
};
}
return {
success: true,
message: 'success',
data: {
...folder,
visibility: folder.visibility as "PRIVATE" | "PUBLIC",
},
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.',
};
}
}
export async function actionToggleFavorite(
folderId: number,
): Promise<ActionOutputToggleFavorite> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return {
success: false,
message: 'Unauthorized',
};
}
const isFavorited = await repoToggleFavorite({
folderId,
userId: session.user.id,
});
const { favoriteCount } = await repoCheckFavorite({
folderId,
userId: session.user.id,
});
return {
success: true,
message: 'success',
data: {
isFavorited,
favoriteCount,
},
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.',
};
}
}
export async function actionCheckFavorite(
folderId: number,
): Promise<ActionOutputCheckFavorite> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return {
success: true,
message: 'success',
data: {
isFavorited: false,
favoriteCount: 0,
},
};
}
const { isFavorited, favoriteCount } = await repoCheckFavorite({
folderId,
userId: session.user.id,
});
return {
success: true,
message: 'success',
data: {
isFavorited,
favoriteCount,
},
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.',
};
}
}
export async function actionGetUserFavorites(): Promise<ActionOutputGetUserFavorites> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return {
success: false,
message: 'Unauthorized',
};
}
const favorites = await repoGetUserFavorites({
userId: session.user.id,
});
return {
success: true,
message: 'success',
data: favorites.map((fav) => ({
id: fav.id,
folderId: fav.folderId,
folderName: fav.folderName,
folderCreatedAt: fav.folderCreatedAt,
folderTotalPairs: fav.folderTotalPairs,
folderOwnerId: fav.folderOwnerId,
folderOwnerName: fav.folderOwnerName,
folderOwnerUsername: fav.folderOwnerUsername,
favoritedAt: fav.favoritedAt,
})),
};
} catch (e) {
log.error("Operation failed", { error: e });
return {
success: false,
message: 'Unknown error occured.',
};
}
}

View File

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

View File

@@ -1,333 +0,0 @@
import { prisma } from "@/lib/db";
import {
RepoInputCreateFolder,
RepoInputCreatePair,
RepoInputUpdatePair,
RepoInputUpdateFolderVisibility,
RepoInputSearchPublicFolders,
RepoInputGetPublicFolders,
RepoOutputPublicFolder,
RepoOutputFolderVisibility,
RepoInputToggleFavorite,
RepoInputCheckFavorite,
RepoOutputFavoriteStatus,
RepoInputGetUserFavorites,
RepoOutputUserFavorite,
} from "./folder-repository-dto";
import { Visibility } from "../../../generated/prisma/enums";
export async function repoCreatePair(data: RepoInputCreatePair) {
return (await prisma.pair.create({
data: data,
})).id;
}
export async function repoDeletePairById(id: number) {
await prisma.pair.delete({
where: {
id: id,
},
});
}
export async function repoUpdatePairById(
id: number,
data: RepoInputUpdatePair,
) {
await prisma.pair.update({
where: {
id: id,
},
data: data,
});
}
export async function repoGetPairCountByFolderId(folderId: number) {
return prisma.pair.count({
where: {
folderId: folderId,
},
});
}
export async function repoGetPairsByFolderId(folderId: number) {
return (await prisma.pair.findMany({
where: {
folderId: folderId,
},
})).map(pair => {
return {
text1:pair.text1,
text2: pair.text2,
language1: pair.language1,
language2: pair.language2,
ipa1: pair.ipa1,
ipa2: pair.ipa2,
id: pair.id,
folderId: pair.folderId
}
});
}
export async function repoGetFoldersByUserId(userId: string) {
return (await prisma.folder.findMany({
where: {
userId: userId,
},
}))?.map(v => {
return {
id: v.id,
name: v.name,
userId: v.userId,
visibility: v.visibility,
};
});
}
export async function repoRenameFolderById(id: number, newName: string) {
await prisma.folder.update({
where: {
id: id,
},
data: {
name: newName,
},
});
}
export async function repoGetFoldersWithTotalPairsByUserId(userId: string) {
const folders = await prisma.folder.findMany({
where: { userId },
include: {
_count: {
select: { pairs: true },
},
},
orderBy: {
createdAt: 'desc',
},
});
return folders.map(folder => ({
id: folder.id,
name: folder.name,
userId: folder.userId,
visibility: folder.visibility,
total: folder._count?.pairs ?? 0,
createdAt: folder.createdAt,
}));
}
export async function repoCreateFolder(folder: RepoInputCreateFolder) {
await prisma.folder.create({
data: folder,
});
}
export async function repoDeleteFolderById(id: number) {
await prisma.folder.delete({
where: {
id: id,
},
});
}
export async function repoGetUserIdByFolderId(id: number) {
const folder = await prisma.folder.findUnique({
where: {
id: id,
},
});
return folder?.userId;
}
export async function repoGetFolderIdByPairId(pairId: number) {
const pair = await prisma.pair.findUnique({
where: {
id: pairId,
},
select: {
folderId: true,
},
});
return pair?.folderId;
}
export async function repoUpdateFolderVisibility(
input: RepoInputUpdateFolderVisibility,
): Promise<void> {
await prisma.folder.update({
where: { id: input.folderId },
data: { visibility: input.visibility },
});
}
export async function repoGetFolderVisibility(
folderId: number,
): Promise<RepoOutputFolderVisibility | null> {
const folder = await prisma.folder.findUnique({
where: { id: folderId },
select: { visibility: true, userId: true },
});
return folder;
}
export async function repoGetPublicFolderById(
folderId: number,
): Promise<RepoOutputPublicFolder | null> {
const folder = await prisma.folder.findUnique({
where: { id: folderId, visibility: Visibility.PUBLIC },
include: {
_count: { select: { pairs: true, favorites: true } },
user: { select: { name: true, username: true } },
},
});
if (!folder) return null;
return {
id: folder.id,
name: folder.name,
visibility: folder.visibility,
createdAt: folder.createdAt,
userId: folder.userId,
userName: folder.user?.name ?? "Unknown",
userUsername: folder.user?.username ?? "unknown",
totalPairs: folder._count.pairs,
favoriteCount: folder._count.favorites,
};
}
export async function repoGetPublicFolders(
input: RepoInputGetPublicFolders = {},
): Promise<RepoOutputPublicFolder[]> {
const { limit = 50, offset = 0, orderBy = "createdAt" } = input;
const folders = await prisma.folder.findMany({
where: { visibility: Visibility.PUBLIC },
include: {
_count: { select: { pairs: true, favorites: true } },
user: { select: { name: true, username: true } },
},
orderBy: { [orderBy]: "desc" },
take: limit,
skip: offset,
});
return folders.map((folder) => ({
id: folder.id,
name: folder.name,
visibility: folder.visibility,
createdAt: folder.createdAt,
userId: folder.userId,
userName: folder.user?.name ?? "Unknown",
userUsername: folder.user?.username ?? "unknown",
totalPairs: folder._count.pairs,
favoriteCount: folder._count.favorites,
}));
}
export async function repoSearchPublicFolders(
input: RepoInputSearchPublicFolders,
): Promise<RepoOutputPublicFolder[]> {
const { query, limit = 50 } = input;
const folders = await prisma.folder.findMany({
where: {
visibility: Visibility.PUBLIC,
name: { contains: query, mode: "insensitive" },
},
include: {
_count: { select: { pairs: true, favorites: true } },
user: { select: { name: true, username: true } },
},
orderBy: { createdAt: "desc" },
take: limit,
});
return folders.map((folder) => ({
id: folder.id,
name: folder.name,
visibility: folder.visibility,
createdAt: folder.createdAt,
userId: folder.userId,
userName: folder.user?.name ?? "Unknown",
userUsername: folder.user?.username ?? "unknown",
totalPairs: folder._count.pairs,
favoriteCount: folder._count.favorites,
}));
}
export async function repoToggleFavorite(
input: RepoInputToggleFavorite,
): Promise<boolean> {
const existing = await prisma.folderFavorite.findUnique({
where: {
userId_folderId: {
userId: input.userId,
folderId: input.folderId,
},
},
});
if (existing) {
await prisma.folderFavorite.delete({
where: { id: existing.id },
});
return false;
} else {
await prisma.folderFavorite.create({
data: {
userId: input.userId,
folderId: input.folderId,
},
});
return true;
}
}
export async function repoCheckFavorite(
input: RepoInputCheckFavorite,
): Promise<RepoOutputFavoriteStatus> {
const favorite = await prisma.folderFavorite.findUnique({
where: {
userId_folderId: {
userId: input.userId,
folderId: input.folderId,
},
},
});
const count = await prisma.folderFavorite.count({
where: { folderId: input.folderId },
});
return {
isFavorited: !!favorite,
favoriteCount: count,
};
}
export async function repoGetUserFavorites(input: RepoInputGetUserFavorites) {
const { userId, limit = 50, offset = 0 } = input;
const favorites = await prisma.folderFavorite.findMany({
where: { userId },
include: {
folder: {
include: {
_count: { select: { pairs: true } },
user: { select: { name: true, username: true } },
},
},
},
orderBy: { createdAt: "desc" },
take: limit,
skip: offset,
});
return favorites.map((fav) => ({
id: fav.id,
folderId: fav.folderId,
folderName: fav.folder.name,
folderCreatedAt: fav.folder.createdAt,
folderTotalPairs: fav.folder._count.pairs,
folderOwnerId: fav.folder.userId,
folderOwnerName: fav.folder.user?.name ?? "Unknown",
folderOwnerUsername: fav.folder.user?.username ?? "unknown",
favoritedAt: fav.createdAt,
}));
}

View File

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

View File

@@ -0,0 +1,106 @@
import z from "zod";
import { generateValidator } from "@/utils/validate";
import { NoteKind } from "../../../generated/prisma/enums";
import {
schemaNoteTypeField,
schemaNoteTypeTemplate,
NoteTypeField,
NoteTypeTemplate,
} from "./note-type-repository-dto";
export const LENGTH_MIN_NOTE_TYPE_NAME = 1;
export const LENGTH_MAX_NOTE_TYPE_NAME = 100;
export const LENGTH_MAX_CSS = 50000;
const schemaNoteTypeFieldAction = z.object({
name: z.string().min(1).max(schemaNoteTypeField.name.maxLength),
ord: z.number().int(),
sticky: z.boolean(),
rtl: z.boolean(),
font: z.string().max(schemaNoteTypeField.font.maxLength).optional(),
size: z.number().int().min(schemaNoteTypeField.size.min).max(schemaNoteTypeField.size.max).optional(),
media: z.array(z.string()).optional(),
});
const schemaNoteTypeTemplateAction = z.object({
name: z.string().min(1).max(schemaNoteTypeTemplate.name.maxLength),
ord: z.number().int(),
qfmt: z.string().min(1).max(schemaNoteTypeTemplate.qfmt.maxLength),
afmt: z.string().min(1).max(schemaNoteTypeTemplate.afmt.maxLength),
bqfmt: z.string().max(schemaNoteTypeTemplate.bqfmt.maxLength).optional(),
bafmt: z.string().max(schemaNoteTypeTemplate.bafmt.maxLength).optional(),
did: z.number().int().optional(),
});
export const schemaActionInputCreateNoteType = z.object({
name: z.string().min(LENGTH_MIN_NOTE_TYPE_NAME).max(LENGTH_MAX_NOTE_TYPE_NAME),
kind: z.enum(["STANDARD", "CLOZE"]).optional(),
css: z.string().max(LENGTH_MAX_CSS).optional(),
fields: z.array(schemaNoteTypeFieldAction).min(1),
templates: z.array(schemaNoteTypeTemplateAction).min(1),
});
export type ActionInputCreateNoteType = z.infer<typeof schemaActionInputCreateNoteType>;
export const validateActionInputCreateNoteType = generateValidator(schemaActionInputCreateNoteType);
export const schemaActionInputUpdateNoteType = z.object({
id: z.number().int().positive(),
name: z.string().min(LENGTH_MIN_NOTE_TYPE_NAME).max(LENGTH_MAX_NOTE_TYPE_NAME).optional(),
kind: z.enum(["STANDARD", "CLOZE"]).optional(),
css: z.string().max(LENGTH_MAX_CSS).optional(),
fields: z.array(schemaNoteTypeFieldAction).min(1).optional(),
templates: z.array(schemaNoteTypeTemplateAction).min(1).optional(),
});
export type ActionInputUpdateNoteType = z.infer<typeof schemaActionInputUpdateNoteType>;
export const validateActionInputUpdateNoteType = generateValidator(schemaActionInputUpdateNoteType);
export const schemaActionInputGetNoteTypeById = z.object({
id: z.number().int().positive(),
});
export type ActionInputGetNoteTypeById = z.infer<typeof schemaActionInputGetNoteTypeById>;
export const validateActionInputGetNoteTypeById = generateValidator(schemaActionInputGetNoteTypeById);
export const schemaActionInputDeleteNoteType = z.object({
id: z.number().int().positive(),
});
export type ActionInputDeleteNoteType = z.infer<typeof schemaActionInputDeleteNoteType>;
export const validateActionInputDeleteNoteType = generateValidator(schemaActionInputDeleteNoteType);
export type ActionOutputNoteType = {
id: number;
name: string;
kind: NoteKind;
css: string;
fields: NoteTypeField[];
templates: NoteTypeTemplate[];
userId: string;
createdAt: Date;
updatedAt: Date;
};
export type ActionOutputCreateNoteType = {
success: boolean;
message: string;
data?: { id: number };
};
export type ActionOutputUpdateNoteType = {
success: boolean;
message: string;
};
export type ActionOutputGetNoteTypeById = {
success: boolean;
message: string;
data?: ActionOutputNoteType;
};
export type ActionOutputGetNoteTypesByUserId = {
success: boolean;
message: string;
data?: ActionOutputNoteType[];
};
export type ActionOutputDeleteNoteType = {
success: boolean;
message: string;
};

View File

@@ -0,0 +1,255 @@
"use server";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { createLogger } from "@/lib/logger";
import { ValidateError } from "@/lib/errors";
import {
ActionInputCreateNoteType,
ActionInputUpdateNoteType,
ActionInputDeleteNoteType,
ActionOutputCreateNoteType,
ActionOutputUpdateNoteType,
ActionOutputGetNoteTypeById,
ActionOutputGetNoteTypesByUserId,
ActionOutputDeleteNoteType,
validateActionInputCreateNoteType,
validateActionInputUpdateNoteType,
validateActionInputDeleteNoteType,
} from "./note-type-action-dto";
import {
serviceCreateNoteType,
serviceUpdateNoteType,
serviceGetNoteTypeById,
serviceGetNoteTypesByUserId,
serviceDeleteNoteType,
} from "./note-type-service";
import {
DEFAULT_BASIC_NOTE_TYPE_FIELDS,
DEFAULT_BASIC_NOTE_TYPE_TEMPLATES,
DEFAULT_BASIC_NOTE_TYPE_CSS,
DEFAULT_CLOZE_NOTE_TYPE_FIELDS,
DEFAULT_CLOZE_NOTE_TYPE_TEMPLATES,
DEFAULT_CLOZE_NOTE_TYPE_CSS,
} from "./note-type-repository-dto";
const log = createLogger("note-type-action");
export async function actionCreateNoteType(
input: ActionInputCreateNoteType,
): Promise<ActionOutputCreateNoteType> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return {
success: false,
message: "Unauthorized",
};
}
const validated = validateActionInputCreateNoteType(input);
const id = await serviceCreateNoteType({
name: validated.name,
kind: validated.kind,
css: validated.css,
fields: validated.fields,
templates: validated.templates,
userId: session.user.id,
});
return {
success: true,
message: "Note type created successfully",
data: { id },
};
} catch (e) {
if (e instanceof ValidateError) {
return {
success: false,
message: e.message,
};
}
log.error("Create note type failed", { error: e });
return {
success: false,
message: "Failed to create note type",
};
}
}
export async function actionUpdateNoteType(
input: ActionInputUpdateNoteType,
): Promise<ActionOutputUpdateNoteType> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return {
success: false,
message: "Unauthorized",
};
}
const validated = validateActionInputUpdateNoteType(input);
await serviceUpdateNoteType({
id: validated.id,
name: validated.name,
kind: validated.kind,
css: validated.css,
fields: validated.fields,
templates: validated.templates,
userId: session.user.id,
});
return {
success: true,
message: "Note type updated successfully",
};
} catch (e) {
if (e instanceof ValidateError) {
return {
success: false,
message: e.message,
};
}
log.error("Update note type failed", { error: e });
return {
success: false,
message: "Failed to update note type",
};
}
}
export async function actionGetNoteTypeById(
id: number,
): Promise<ActionOutputGetNoteTypeById> {
try {
const noteType = await serviceGetNoteTypeById({ id });
if (!noteType) {
return {
success: false,
message: "Note type not found",
};
}
return {
success: true,
message: "Note type retrieved successfully",
data: noteType,
};
} catch (e) {
log.error("Get note type failed", { error: e });
return {
success: false,
message: "Failed to retrieve note type",
};
}
}
export async function actionGetNoteTypesByUserId(): Promise<ActionOutputGetNoteTypesByUserId> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return {
success: false,
message: "Unauthorized",
};
}
const noteTypes = await serviceGetNoteTypesByUserId({
userId: session.user.id,
});
return {
success: true,
message: "Note types retrieved successfully",
data: noteTypes,
};
} catch (e) {
log.error("Get note types failed", { error: e });
return {
success: false,
message: "Failed to retrieve note types",
};
}
}
export async function actionDeleteNoteType(
input: ActionInputDeleteNoteType,
): Promise<ActionOutputDeleteNoteType> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return {
success: false,
message: "Unauthorized",
};
}
const validated = validateActionInputDeleteNoteType(input);
await serviceDeleteNoteType({
id: validated.id,
userId: session.user.id,
});
return {
success: true,
message: "Note type deleted successfully",
};
} catch (e) {
if (e instanceof ValidateError) {
return {
success: false,
message: e.message,
};
}
log.error("Delete note type failed", { error: e });
return {
success: false,
message: "Failed to delete note type",
};
}
}
export async function actionCreateDefaultBasicNoteType(): Promise<ActionOutputCreateNoteType> {
return actionCreateNoteType({
name: "Basic Vocabulary",
kind: "STANDARD",
css: DEFAULT_BASIC_NOTE_TYPE_CSS,
fields: DEFAULT_BASIC_NOTE_TYPE_FIELDS,
templates: DEFAULT_BASIC_NOTE_TYPE_TEMPLATES,
});
}
export async function actionCreateDefaultClozeNoteType(): Promise<ActionOutputCreateNoteType> {
return actionCreateNoteType({
name: "Cloze",
kind: "CLOZE",
css: DEFAULT_CLOZE_NOTE_TYPE_CSS,
fields: DEFAULT_CLOZE_NOTE_TYPE_FIELDS,
templates: DEFAULT_CLOZE_NOTE_TYPE_TEMPLATES,
});
}
export async function actionGetDefaultBasicNoteTypeTemplate() {
return {
name: "Basic Vocabulary",
kind: "STANDARD" as const,
css: DEFAULT_BASIC_NOTE_TYPE_CSS,
fields: DEFAULT_BASIC_NOTE_TYPE_FIELDS,
templates: DEFAULT_BASIC_NOTE_TYPE_TEMPLATES,
};
}
export async function actionGetDefaultClozeNoteTypeTemplate() {
return {
name: "Cloze",
kind: "CLOZE" as const,
css: DEFAULT_CLOZE_NOTE_TYPE_CSS,
fields: DEFAULT_CLOZE_NOTE_TYPE_FIELDS,
templates: DEFAULT_CLOZE_NOTE_TYPE_TEMPLATES,
};
}

View File

@@ -0,0 +1,181 @@
import { NoteKind } from "../../../generated/prisma/enums";
// ============================================
// Field Schema (Anki flds structure)
// ============================================
export interface NoteTypeField {
name: string;
ord: number;
sticky: boolean;
rtl: boolean;
font?: string;
size?: number;
media?: string[];
}
export const schemaNoteTypeField = {
name: { minLength: 1, maxLength: 50 },
font: { maxLength: 100 },
size: { min: 8, max: 72 },
};
// ============================================
// Template Schema (Anki tmpls structure)
// ============================================
export interface NoteTypeTemplate {
name: string;
ord: number;
qfmt: string;
afmt: string;
bqfmt?: string;
bafmt?: string;
did?: number;
}
export const schemaNoteTypeTemplate = {
name: { minLength: 1, maxLength: 100 },
qfmt: { maxLength: 10000 },
afmt: { maxLength: 10000 },
bqfmt: { maxLength: 10000 },
bafmt: { maxLength: 10000 },
};
// ============================================
// Repository Input Types
// ============================================
export interface RepoInputCreateNoteType {
name: string;
kind?: NoteKind;
css?: string;
fields: NoteTypeField[];
templates: NoteTypeTemplate[];
userId: string;
}
export interface RepoInputUpdateNoteType {
id: number;
name?: string;
kind?: NoteKind;
css?: string;
fields?: NoteTypeField[];
templates?: NoteTypeTemplate[];
}
export interface RepoInputGetNoteTypeById {
id: number;
}
export interface RepoInputGetNoteTypesByUserId {
userId: string;
}
export interface RepoInputDeleteNoteType {
id: number;
}
export interface RepoInputCheckNotesExist {
noteTypeId: number;
}
// ============================================
// Repository Output Types
// ============================================
export type RepoOutputNoteType = {
id: number;
name: string;
kind: NoteKind;
css: string;
fields: NoteTypeField[];
templates: NoteTypeTemplate[];
userId: string;
createdAt: Date;
updatedAt: Date;
};
export type RepoOutputNoteTypeOwnership = {
userId: string;
};
export type RepoOutputNotesExistCheck = {
exists: boolean;
count: number;
};
// ============================================
// Default Note Types
// ============================================
export const DEFAULT_BASIC_NOTE_TYPE_FIELDS: NoteTypeField[] = [
{ name: "Word", ord: 0, sticky: false, rtl: false, font: "Arial", size: 20 },
{ name: "Definition", ord: 1, sticky: false, rtl: false, font: "Arial", size: 20 },
{ name: "IPA", ord: 2, sticky: false, rtl: false, font: "Arial", size: 20 },
{ name: "Example", ord: 3, sticky: false, rtl: false, font: "Arial", size: 20 },
];
export const DEFAULT_BASIC_NOTE_TYPE_TEMPLATES: NoteTypeTemplate[] = [
{
name: "Word → Definition",
ord: 0,
qfmt: "{{Word}}<br>{{IPA}}",
afmt: "{{FrontSide}}<hr id=answer>{{Definition}}<br><br>{{Example}}",
},
{
name: "Definition → Word",
ord: 1,
qfmt: "{{Definition}}",
afmt: "{{FrontSide}}<hr id=answer>{{Word}}<br>{{IPA}}",
},
];
export const DEFAULT_BASIC_NOTE_TYPE_CSS = `.card {
font-family: Arial, sans-serif;
font-size: 20px;
text-align: center;
color: #333;
background-color: #fff;
}
.card1 {
background-color: #e8f4f8;
}
.card2 {
background-color: #f8f4e8;
}
hr {
border: none;
border-top: 1px solid #ccc;
margin: 20px 0;
}`;
export const DEFAULT_CLOZE_NOTE_TYPE_FIELDS: NoteTypeField[] = [
{ name: "Text", ord: 0, sticky: false, rtl: false, font: "Arial", size: 20 },
{ name: "Extra", ord: 1, sticky: false, rtl: false, font: "Arial", size: 20 },
];
export const DEFAULT_CLOZE_NOTE_TYPE_TEMPLATES: NoteTypeTemplate[] = [
{
name: "Cloze",
ord: 0,
qfmt: "{{cloze:Text}}",
afmt: "{{cloze:Text}}<br><br>{{Extra}}",
},
];
export const DEFAULT_CLOZE_NOTE_TYPE_CSS = `.card {
font-family: Arial, sans-serif;
font-size: 20px;
text-align: center;
color: #333;
background-color: #fff;
}
.cloze {
font-weight: bold;
color: #0066cc;
}`;

View File

@@ -0,0 +1,151 @@
import { prisma } from "@/lib/db";
import { createLogger } from "@/lib/logger";
import {
RepoInputCreateNoteType,
RepoInputUpdateNoteType,
RepoInputGetNoteTypeById,
RepoInputGetNoteTypesByUserId,
RepoInputDeleteNoteType,
RepoInputCheckNotesExist,
RepoOutputNoteType,
RepoOutputNoteTypeOwnership,
RepoOutputNotesExistCheck,
NoteTypeField,
NoteTypeTemplate,
} from "./note-type-repository-dto";
import { NoteKind } from "../../../generated/prisma/enums";
const log = createLogger("note-type-repository");
export async function repoCreateNoteType(
input: RepoInputCreateNoteType,
): Promise<number> {
const noteType = await prisma.noteType.create({
data: {
name: input.name,
kind: input.kind ?? NoteKind.STANDARD,
css: input.css ?? "",
fields: input.fields as unknown as object,
templates: input.templates as unknown as object,
userId: input.userId,
},
});
log.info("Created note type", { id: noteType.id, name: noteType.name });
return noteType.id;
}
export async function repoUpdateNoteType(
input: RepoInputUpdateNoteType,
): Promise<void> {
const updateData: {
name?: string;
kind?: NoteKind;
css?: string;
fields?: object;
templates?: object;
} = {};
if (input.name !== undefined) updateData.name = input.name;
if (input.kind !== undefined) updateData.kind = input.kind;
if (input.css !== undefined) updateData.css = input.css;
if (input.fields !== undefined)
updateData.fields = input.fields as unknown as object;
if (input.templates !== undefined)
updateData.templates = input.templates as unknown as object;
await prisma.noteType.update({
where: { id: input.id },
data: updateData,
});
log.info("Updated note type", { id: input.id });
}
export async function repoGetNoteTypeById(
input: RepoInputGetNoteTypeById,
): Promise<RepoOutputNoteType | null> {
const noteType = await prisma.noteType.findUnique({
where: { id: input.id },
});
if (!noteType) return null;
return {
id: noteType.id,
name: noteType.name,
kind: noteType.kind,
css: noteType.css,
fields: noteType.fields as unknown as NoteTypeField[],
templates: noteType.templates as unknown as NoteTypeTemplate[],
userId: noteType.userId,
createdAt: noteType.createdAt,
updatedAt: noteType.updatedAt,
};
}
export async function repoGetNoteTypesByUserId(
input: RepoInputGetNoteTypesByUserId,
): Promise<RepoOutputNoteType[]> {
const noteTypes = await prisma.noteType.findMany({
where: { userId: input.userId },
orderBy: { createdAt: "desc" },
});
return noteTypes.map((nt) => ({
id: nt.id,
name: nt.name,
kind: nt.kind,
css: nt.css,
fields: nt.fields as unknown as NoteTypeField[],
templates: nt.templates as unknown as NoteTypeTemplate[],
userId: nt.userId,
createdAt: nt.createdAt,
updatedAt: nt.updatedAt,
}));
}
export async function repoGetNoteTypeOwnership(
noteTypeId: number,
): Promise<RepoOutputNoteTypeOwnership | null> {
const noteType = await prisma.noteType.findUnique({
where: { id: noteTypeId },
select: { userId: true },
});
return noteType;
}
export async function repoDeleteNoteType(
input: RepoInputDeleteNoteType,
): Promise<void> {
await prisma.noteType.delete({
where: { id: input.id },
});
log.info("Deleted note type", { id: input.id });
}
export async function repoCheckNotesExist(
input: RepoInputCheckNotesExist,
): Promise<RepoOutputNotesExistCheck> {
const count = await prisma.note.count({
where: { noteTypeId: input.noteTypeId },
});
return {
exists: count > 0,
count,
};
}
export async function repoGetNoteTypeNameById(
noteTypeId: number,
): Promise<string | null> {
const noteType = await prisma.noteType.findUnique({
where: { id: noteTypeId },
select: { name: true },
});
return noteType?.name ?? null;
}

View File

@@ -0,0 +1,60 @@
import { NoteKind } from "../../../generated/prisma/enums";
import { NoteTypeField, NoteTypeTemplate } from "./note-type-repository-dto";
export type ServiceInputCreateNoteType = {
name: string;
kind?: NoteKind;
css?: string;
fields: NoteTypeField[];
templates: NoteTypeTemplate[];
userId: string;
};
export type ServiceInputUpdateNoteType = {
id: number;
name?: string;
kind?: NoteKind;
css?: string;
fields?: NoteTypeField[];
templates?: NoteTypeTemplate[];
userId: string;
};
export type ServiceInputGetNoteTypeById = {
id: number;
};
export type ServiceInputGetNoteTypesByUserId = {
userId: string;
};
export type ServiceInputDeleteNoteType = {
id: number;
userId: string;
};
export type ServiceInputValidateFields = {
fields: NoteTypeField[];
};
export type ServiceInputValidateTemplates = {
templates: NoteTypeTemplate[];
fields: NoteTypeField[];
};
export type ServiceOutputNoteType = {
id: number;
name: string;
kind: NoteKind;
css: string;
fields: NoteTypeField[];
templates: NoteTypeTemplate[];
userId: string;
createdAt: Date;
updatedAt: Date;
};
export type ServiceOutputValidation = {
success: boolean;
errors: string[];
};

View File

@@ -0,0 +1,272 @@
import { createLogger } from "@/lib/logger";
import { ValidateError } from "@/lib/errors";
import {
repoCreateNoteType,
repoGetNoteTypeById,
repoGetNoteTypesByUserId,
repoUpdateNoteType,
repoDeleteNoteType,
repoGetNoteTypeOwnership,
repoCheckNotesExist,
} from "./note-type-repository";
import {
ServiceInputCreateNoteType,
ServiceInputUpdateNoteType,
ServiceInputGetNoteTypeById,
ServiceInputGetNoteTypesByUserId,
ServiceInputDeleteNoteType,
ServiceInputValidateFields,
ServiceInputValidateTemplates,
ServiceOutputNoteType,
ServiceOutputValidation,
} from "./note-type-service-dto";
import { schemaNoteTypeField, schemaNoteTypeTemplate } from "./note-type-repository-dto";
const log = createLogger("note-type-service");
export function serviceValidateFields(
input: ServiceInputValidateFields,
): ServiceOutputValidation {
const errors: string[] = [];
if (!Array.isArray(input.fields) || input.fields.length === 0) {
errors.push("Fields must be a non-empty array");
return { success: false, errors };
}
const seenNames = new Set<string>();
const seenOrds = new Set<number>();
for (let i = 0; i < input.fields.length; i++) {
const field = input.fields[i];
if (!field.name || field.name.trim().length === 0) {
errors.push(`Field ${i}: name is required`);
} else if (field.name.length > schemaNoteTypeField.name.maxLength) {
errors.push(`Field ${i}: name exceeds maximum length of ${schemaNoteTypeField.name.maxLength}`);
}
if (seenNames.has(field.name)) {
errors.push(`Field ${i}: duplicate field name "${field.name}"`);
}
seenNames.add(field.name);
if (typeof field.ord !== "number") {
errors.push(`Field ${i}: ord must be a number`);
} else if (seenOrds.has(field.ord)) {
errors.push(`Field ${i}: duplicate ordinal ${field.ord}`);
}
seenOrds.add(field.ord);
if (typeof field.sticky !== "boolean") {
errors.push(`Field ${i}: sticky must be a boolean`);
}
if (typeof field.rtl !== "boolean") {
errors.push(`Field ${i}: rtl must be a boolean`);
}
if (field.font && field.font.length > schemaNoteTypeField.font.maxLength) {
errors.push(`Field ${i}: font exceeds maximum length`);
}
if (field.size !== undefined && (field.size < schemaNoteTypeField.size.min || field.size > schemaNoteTypeField.size.max)) {
errors.push(`Field ${i}: size must be between ${schemaNoteTypeField.size.min} and ${schemaNoteTypeField.size.max}`);
}
}
return { success: errors.length === 0, errors };
}
export function serviceValidateTemplates(
input: ServiceInputValidateTemplates,
): ServiceOutputValidation {
const errors: string[] = [];
if (!Array.isArray(input.templates) || input.templates.length === 0) {
errors.push("Templates must be a non-empty array");
return { success: false, errors };
}
const fieldNames = new Set(input.fields.map((f) => f.name));
const seenNames = new Set<string>();
const seenOrds = new Set<number>();
const mustachePattern = /\{\{([^}]+)\}\}/g;
for (let i = 0; i < input.templates.length; i++) {
const template = input.templates[i];
if (!template.name || template.name.trim().length === 0) {
errors.push(`Template ${i}: name is required`);
} else if (template.name.length > schemaNoteTypeTemplate.name.maxLength) {
errors.push(`Template ${i}: name exceeds maximum length`);
}
if (seenNames.has(template.name)) {
errors.push(`Template ${i}: duplicate template name "${template.name}"`);
}
seenNames.add(template.name);
if (typeof template.ord !== "number") {
errors.push(`Template ${i}: ord must be a number`);
} else if (seenOrds.has(template.ord)) {
errors.push(`Template ${i}: duplicate ordinal ${template.ord}`);
}
seenOrds.add(template.ord);
if (!template.qfmt || template.qfmt.trim().length === 0) {
errors.push(`Template ${i}: qfmt (question format) is required`);
} else if (template.qfmt.length > schemaNoteTypeTemplate.qfmt.maxLength) {
errors.push(`Template ${i}: qfmt exceeds maximum length`);
}
if (!template.afmt || template.afmt.trim().length === 0) {
errors.push(`Template ${i}: afmt (answer format) is required`);
} else if (template.afmt.length > schemaNoteTypeTemplate.afmt.maxLength) {
errors.push(`Template ${i}: afmt exceeds maximum length`);
}
const qfmtMatches = template.qfmt.match(mustachePattern) || [];
const afmtMatches = template.afmt.match(mustachePattern) || [];
const allMatches = [...qfmtMatches, ...afmtMatches];
for (const match of allMatches) {
const content = match.slice(2, -2).trim();
if (content.startsWith("cloze:")) {
continue;
}
if (content === "FrontSide") {
continue;
}
if (content.startsWith("type:")) {
continue;
}
if (!fieldNames.has(content)) {
log.warn(`Template ${i}: unknown field reference "{{${content}}}"`);
}
}
}
return { success: errors.length === 0, errors };
}
export async function serviceCreateNoteType(
input: ServiceInputCreateNoteType,
): Promise<number> {
const fieldsValidation = serviceValidateFields({ fields: input.fields });
if (!fieldsValidation.success) {
throw new ValidateError(`Invalid fields: ${fieldsValidation.errors.join("; ")}`);
}
const templatesValidation = serviceValidateTemplates({
templates: input.templates,
fields: input.fields,
});
if (!templatesValidation.success) {
throw new ValidateError(`Invalid templates: ${templatesValidation.errors.join("; ")}`);
}
log.info("Creating note type", { name: input.name, userId: input.userId });
return repoCreateNoteType({
name: input.name,
kind: input.kind,
css: input.css,
fields: input.fields,
templates: input.templates,
userId: input.userId,
});
}
export async function serviceUpdateNoteType(
input: ServiceInputUpdateNoteType,
): Promise<void> {
const ownership = await repoGetNoteTypeOwnership(input.id);
if (!ownership) {
throw new ValidateError("Note type not found");
}
if (ownership.userId !== input.userId) {
throw new ValidateError("You do not have permission to update this note type");
}
if (input.fields) {
const fieldsValidation = serviceValidateFields({ fields: input.fields });
if (!fieldsValidation.success) {
throw new ValidateError(`Invalid fields: ${fieldsValidation.errors.join("; ")}`);
}
}
if (input.templates && input.fields) {
const templatesValidation = serviceValidateTemplates({
templates: input.templates,
fields: input.fields,
});
if (!templatesValidation.success) {
throw new ValidateError(`Invalid templates: ${templatesValidation.errors.join("; ")}`);
}
} else if (input.templates) {
const existing = await repoGetNoteTypeById({ id: input.id });
if (existing) {
const templatesValidation = serviceValidateTemplates({
templates: input.templates,
fields: existing.fields,
});
if (!templatesValidation.success) {
throw new ValidateError(`Invalid templates: ${templatesValidation.errors.join("; ")}`);
}
}
}
log.info("Updating note type", { id: input.id });
await repoUpdateNoteType({
id: input.id,
name: input.name,
kind: input.kind,
css: input.css,
fields: input.fields,
templates: input.templates,
});
}
export async function serviceGetNoteTypeById(
input: ServiceInputGetNoteTypeById,
): Promise<ServiceOutputNoteType | null> {
return repoGetNoteTypeById(input);
}
export async function serviceGetNoteTypesByUserId(
input: ServiceInputGetNoteTypesByUserId,
): Promise<ServiceOutputNoteType[]> {
return repoGetNoteTypesByUserId(input);
}
export async function serviceDeleteNoteType(
input: ServiceInputDeleteNoteType,
): Promise<void> {
const ownership = await repoGetNoteTypeOwnership(input.id);
if (!ownership) {
throw new ValidateError("Note type not found");
}
if (ownership.userId !== input.userId) {
throw new ValidateError("You do not have permission to delete this note type");
}
const notesCheck = await repoCheckNotesExist({ noteTypeId: input.id });
if (notesCheck.exists) {
throw new ValidateError(
`Cannot delete note type: ${notesCheck.count} notes are using this type`,
);
}
log.info("Deleting note type", { id: input.id });
await repoDeleteNoteType({ id: input.id });
}

View File

@@ -0,0 +1,131 @@
import { generateValidator } from "@/utils/validate";
import z from "zod";
export const LENGTH_MAX_NOTE_FIELD = 65535;
export const LENGTH_MIN_NOTE_FIELD = 0;
export const LENGTH_MAX_TAG = 100;
export const MAX_FIELDS = 100;
export const MAX_TAGS = 100;
export const schemaActionInputCreateNote = z.object({
noteTypeId: z.number().int().positive(),
fields: z
.array(z.string().max(LENGTH_MAX_NOTE_FIELD))
.min(1)
.max(MAX_FIELDS),
tags: z.array(z.string().max(LENGTH_MAX_TAG)).max(MAX_TAGS).optional(),
});
export type ActionInputCreateNote = z.infer<typeof schemaActionInputCreateNote>;
export const validateActionInputCreateNote = generateValidator(
schemaActionInputCreateNote,
);
export const schemaActionInputUpdateNote = z.object({
noteId: z.bigint(),
fields: z
.array(z.string().max(LENGTH_MAX_NOTE_FIELD))
.min(1)
.max(MAX_FIELDS)
.optional(),
tags: z.array(z.string().max(LENGTH_MAX_TAG)).max(MAX_TAGS).optional(),
});
export type ActionInputUpdateNote = z.infer<typeof schemaActionInputUpdateNote>;
export const validateActionInputUpdateNote = generateValidator(
schemaActionInputUpdateNote,
);
export const schemaActionInputDeleteNote = z.object({
noteId: z.bigint(),
});
export type ActionInputDeleteNote = z.infer<typeof schemaActionInputDeleteNote>;
export const validateActionInputDeleteNote = generateValidator(
schemaActionInputDeleteNote,
);
export const schemaActionInputGetNoteById = z.object({
noteId: z.bigint(),
});
export type ActionInputGetNoteById = z.infer<typeof schemaActionInputGetNoteById>;
export const validateActionInputGetNoteById = generateValidator(
schemaActionInputGetNoteById,
);
export const schemaActionInputGetNotesByNoteTypeId = z.object({
noteTypeId: z.number().int().positive(),
limit: z.number().int().positive().max(1000).optional(),
offset: z.number().int().nonnegative().optional(),
});
export type ActionInputGetNotesByNoteTypeId = z.infer<
typeof schemaActionInputGetNotesByNoteTypeId
>;
export const validateActionInputGetNotesByNoteTypeId = generateValidator(
schemaActionInputGetNotesByNoteTypeId,
);
export const schemaActionInputGetNotesByUserId = z.object({
userId: z.string().min(1),
limit: z.number().int().positive().max(1000).optional(),
offset: z.number().int().nonnegative().optional(),
});
export type ActionInputGetNotesByUserId = z.infer<
typeof schemaActionInputGetNotesByUserId
>;
export const validateActionInputGetNotesByUserId = generateValidator(
schemaActionInputGetNotesByUserId,
);
export type ActionOutputNote = {
id: string;
guid: string;
noteTypeId: number;
mod: number;
usn: number;
tags: string[];
fields: string[];
sfld: string;
csum: number;
flags: number;
data: string;
userId: string;
createdAt: Date;
updatedAt: Date;
};
export type ActionOutputCreateNote = {
message: string;
success: boolean;
data?: {
id: string;
guid: string;
};
};
export type ActionOutputUpdateNote = {
message: string;
success: boolean;
};
export type ActionOutputDeleteNote = {
message: string;
success: boolean;
};
export type ActionOutputGetNoteById = {
message: string;
success: boolean;
data?: ActionOutputNote;
};
export type ActionOutputGetNotes = {
message: string;
success: boolean;
data?: ActionOutputNote[];
};
export type ActionOutputNoteCount = {
message: string;
success: boolean;
data?: {
count: number;
};
};

View File

@@ -0,0 +1,344 @@
"use server";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { createLogger } from "@/lib/logger";
import { ValidateError } from "@/lib/errors";
import {
ActionInputCreateNote,
ActionInputUpdateNote,
ActionInputDeleteNote,
ActionInputGetNoteById,
ActionInputGetNotesByNoteTypeId,
ActionInputGetNotesByUserId,
ActionOutputCreateNote,
ActionOutputUpdateNote,
ActionOutputDeleteNote,
ActionOutputGetNoteById,
ActionOutputGetNotes,
ActionOutputNoteCount,
ActionOutputNote,
validateActionInputCreateNote,
validateActionInputUpdateNote,
validateActionInputDeleteNote,
validateActionInputGetNoteById,
validateActionInputGetNotesByNoteTypeId,
validateActionInputGetNotesByUserId,
} from "./note-action-dto";
import {
serviceCreateNote,
serviceUpdateNote,
serviceDeleteNote,
serviceGetNoteById,
serviceGetNotesByNoteTypeId,
serviceGetNotesByUserId,
serviceCountNotesByUserId,
serviceCountNotesByNoteTypeId,
NoteNotFoundError,
NoteOwnershipError,
} from "./note-service";
const log = createLogger("note-action");
function mapNoteToOutput(note: {
id: bigint;
guid: string;
noteTypeId: number;
mod: number;
usn: number;
tags: string[];
fields: string[];
sfld: string;
csum: number;
flags: number;
data: string;
userId: string;
createdAt: Date;
updatedAt: Date;
}): ActionOutputNote {
return {
id: note.id.toString(),
guid: note.guid,
noteTypeId: note.noteTypeId,
mod: note.mod,
usn: note.usn,
tags: note.tags,
fields: note.fields,
sfld: note.sfld,
csum: note.csum,
flags: note.flags,
data: note.data,
userId: note.userId,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
};
}
async function requireAuth(): Promise<string> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
throw new Error("Unauthorized");
}
return session.user.id;
}
export async function actionCreateNote(
input: unknown,
): Promise<ActionOutputCreateNote> {
try {
const userId = await requireAuth();
const validated = validateActionInputCreateNote(input);
log.debug("Creating note", { userId, noteTypeId: validated.noteTypeId });
const result = await serviceCreateNote({
...validated,
userId,
});
log.info("Note created", { id: result.id.toString(), guid: result.guid });
return {
success: true,
message: "Note created successfully",
data: {
id: result.id.toString(),
guid: result.guid,
},
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
if (e instanceof Error && e.message === "Unauthorized") {
return { success: false, message: "Unauthorized" };
}
log.error("Failed to create note", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}
export async function actionUpdateNote(
input: unknown,
): Promise<ActionOutputUpdateNote> {
try {
const userId = await requireAuth();
const validated = validateActionInputUpdateNote(input);
log.debug("Updating note", { noteId: validated.noteId.toString() });
await serviceUpdateNote({
...validated,
userId,
});
log.info("Note updated", { noteId: validated.noteId.toString() });
return {
success: true,
message: "Note updated successfully",
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
if (e instanceof NoteNotFoundError) {
return { success: false, message: "Note not found" };
}
if (e instanceof NoteOwnershipError) {
return { success: false, message: "You do not have permission to update this note" };
}
if (e instanceof Error && e.message === "Unauthorized") {
return { success: false, message: "Unauthorized" };
}
log.error("Failed to update note", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}
export async function actionDeleteNote(
input: unknown,
): Promise<ActionOutputDeleteNote> {
try {
const userId = await requireAuth();
const validated = validateActionInputDeleteNote(input);
log.debug("Deleting note", { noteId: validated.noteId.toString() });
await serviceDeleteNote({
...validated,
userId,
});
log.info("Note deleted", { noteId: validated.noteId.toString() });
return {
success: true,
message: "Note deleted successfully",
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
if (e instanceof NoteNotFoundError) {
return { success: false, message: "Note not found" };
}
if (e instanceof NoteOwnershipError) {
return { success: false, message: "You do not have permission to delete this note" };
}
if (e instanceof Error && e.message === "Unauthorized") {
return { success: false, message: "Unauthorized" };
}
log.error("Failed to delete note", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}
export async function actionGetNoteById(
input: unknown,
): Promise<ActionOutputGetNoteById> {
try {
const validated = validateActionInputGetNoteById(input);
log.debug("Fetching note", { noteId: validated.noteId.toString() });
const note = await serviceGetNoteById(validated);
if (!note) {
return {
success: false,
message: "Note not found",
};
}
return {
success: true,
message: "Note retrieved successfully",
data: mapNoteToOutput(note),
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get note", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}
export async function actionGetNotesByNoteTypeId(
input: unknown,
): Promise<ActionOutputGetNotes> {
try {
const validated = validateActionInputGetNotesByNoteTypeId(input);
log.debug("Fetching notes by note type", { noteTypeId: validated.noteTypeId });
const notes = await serviceGetNotesByNoteTypeId(validated);
return {
success: true,
message: "Notes retrieved successfully",
data: notes.map(mapNoteToOutput),
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get notes by note type", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}
export async function actionGetNotesByUserId(
input: unknown,
): Promise<ActionOutputGetNotes> {
try {
const validated = validateActionInputGetNotesByUserId(input);
log.debug("Fetching notes by user", { userId: validated.userId });
const notes = await serviceGetNotesByUserId(validated);
return {
success: true,
message: "Notes retrieved successfully",
data: notes.map(mapNoteToOutput),
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get notes by user", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}
export async function actionGetMyNotes(
limit?: number,
offset?: number,
): Promise<ActionOutputGetNotes> {
try {
const userId = await requireAuth();
log.debug("Fetching current user's notes", { userId, limit, offset });
const notes = await serviceGetNotesByUserId({
userId,
limit,
offset,
});
return {
success: true,
message: "Notes retrieved successfully",
data: notes.map(mapNoteToOutput),
};
} catch (e) {
if (e instanceof Error && e.message === "Unauthorized") {
return { success: false, message: "Unauthorized" };
}
log.error("Failed to get user's notes", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}
export async function actionGetMyNoteCount(): Promise<ActionOutputNoteCount> {
try {
const userId = await requireAuth();
log.debug("Counting current user's notes", { userId });
const result = await serviceCountNotesByUserId(userId);
return {
success: true,
message: "Note count retrieved successfully",
data: { count: result.count },
};
} catch (e) {
if (e instanceof Error && e.message === "Unauthorized") {
return { success: false, message: "Unauthorized" };
}
log.error("Failed to count user's notes", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}
export async function actionGetNoteCountByNoteType(
noteTypeId: number,
): Promise<ActionOutputNoteCount> {
try {
log.debug("Counting notes by note type", { noteTypeId });
const result = await serviceCountNotesByNoteTypeId(noteTypeId);
return {
success: true,
message: "Note count retrieved successfully",
data: { count: result.count },
};
} catch (e) {
log.error("Failed to count notes by note type", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}

View File

@@ -0,0 +1,72 @@
// Repository layer DTOs for Note module
// Follows Anki-compatible note structure with BigInt IDs
export interface RepoInputCreateNote {
noteTypeId: number;
fields: string[];
tags?: string[];
userId: string;
}
export interface RepoInputUpdateNote {
id: bigint;
fields?: string[];
tags?: string[];
}
export interface RepoInputGetNoteById {
id: bigint;
}
export interface RepoInputGetNotesByNoteTypeId {
noteTypeId: number;
limit?: number;
offset?: number;
}
export interface RepoInputGetNotesByUserId {
userId: string;
limit?: number;
offset?: number;
}
export interface RepoInputDeleteNote {
id: bigint;
}
export interface RepoInputCheckNoteOwnership {
noteId: bigint;
userId: string;
}
export type RepoOutputNote = {
id: bigint;
guid: string;
noteTypeId: number;
mod: number;
usn: number;
tags: string;
flds: string;
sfld: string;
csum: number;
flags: number;
data: string;
userId: string;
createdAt: Date;
updatedAt: Date;
};
export type RepoOutputNoteWithFields = Omit<RepoOutputNote, "flds" | "tags"> & {
fields: string[];
tagsArray: string[];
};
export type RepoOutputNoteOwnership = {
userId: string;
};
// Helper function types
export type RepoHelperGenerateGuid = () => string;
export type RepoHelperCalculateCsum = (text: string) => number;
export type RepoHelperJoinFields = (fields: string[]) => string;
export type RepoHelperSplitFields = (flds: string) => string[];

View File

@@ -0,0 +1,283 @@
import { prisma } from "@/lib/db";
import { createLogger } from "@/lib/logger";
import { createHash } from "crypto";
import {
RepoInputCreateNote,
RepoInputUpdateNote,
RepoInputGetNoteById,
RepoInputGetNotesByNoteTypeId,
RepoInputGetNotesByUserId,
RepoInputDeleteNote,
RepoInputCheckNoteOwnership,
RepoOutputNote,
RepoOutputNoteWithFields,
RepoOutputNoteOwnership,
} from "./note-repository-dto";
const log = createLogger("note-repository");
const FIELD_SEPARATOR = "\x1f";
const BASE91_CHARS =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&()*+,-./:;<=>?@[]^_`{|}~";
export function repoGenerateGuid(): string {
let guid = "";
const bytes = new Uint8Array(10);
crypto.getRandomValues(bytes);
for (let i = 0; i < 10; i++) {
guid += BASE91_CHARS[bytes[i] % BASE91_CHARS.length];
}
return guid;
}
export function repoCalculateCsum(text: string): number {
const hash = createHash("sha1").update(text.normalize("NFC")).digest("hex");
return parseInt(hash.substring(0, 8), 16);
}
export function repoJoinFields(fields: string[]): string {
return fields.join(FIELD_SEPARATOR);
}
export function repoSplitFields(flds: string): string[] {
return flds.split(FIELD_SEPARATOR);
}
export async function repoCreateNote(
input: RepoInputCreateNote,
): Promise<bigint> {
const now = Date.now();
const id = BigInt(now);
const guid = repoGenerateGuid();
const flds = repoJoinFields(input.fields);
const sfld = input.fields[0] ?? "";
const csum = repoCalculateCsum(sfld);
const tags = input.tags?.join(" ") ?? " ";
log.debug("Creating note", { id: id.toString(), guid, noteTypeId: input.noteTypeId });
await prisma.note.create({
data: {
id,
guid,
noteTypeId: input.noteTypeId,
mod: Math.floor(now / 1000),
usn: -1,
tags,
flds,
sfld,
csum,
flags: 0,
data: "",
userId: input.userId,
},
});
log.info("Note created", { id: id.toString(), guid });
return id;
}
export async function repoUpdateNote(input: RepoInputUpdateNote): Promise<void> {
const now = Date.now();
const updateData: {
mod?: number;
usn?: number;
flds?: string;
sfld?: string;
csum?: number;
tags?: string;
} = {
mod: Math.floor(now / 1000),
usn: -1,
};
if (input.fields) {
updateData.flds = repoJoinFields(input.fields);
updateData.sfld = input.fields[0] ?? "";
updateData.csum = repoCalculateCsum(updateData.sfld);
}
if (input.tags) {
updateData.tags = input.tags.join(" ");
}
log.debug("Updating note", { id: input.id.toString() });
await prisma.note.update({
where: { id: input.id },
data: updateData,
});
log.info("Note updated", { id: input.id.toString() });
}
export async function repoGetNoteById(
input: RepoInputGetNoteById,
): Promise<RepoOutputNote | null> {
const note = await prisma.note.findUnique({
where: { id: input.id },
});
if (!note) {
log.debug("Note not found", { id: input.id.toString() });
return null;
}
return {
id: note.id,
guid: note.guid,
noteTypeId: note.noteTypeId,
mod: note.mod,
usn: note.usn,
tags: note.tags,
flds: note.flds,
sfld: note.sfld,
csum: note.csum,
flags: note.flags,
data: note.data,
userId: note.userId,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
};
}
export async function repoGetNoteByIdWithFields(
input: RepoInputGetNoteById,
): Promise<RepoOutputNoteWithFields | null> {
const note = await repoGetNoteById(input);
if (!note) return null;
return {
...note,
fields: repoSplitFields(note.flds),
tagsArray: note.tags.trim() === "" ? [] : note.tags.trim().split(" "),
};
}
export async function repoGetNotesByNoteTypeId(
input: RepoInputGetNotesByNoteTypeId,
): Promise<RepoOutputNote[]> {
const { noteTypeId, limit = 50, offset = 0 } = input;
log.debug("Fetching notes by note type", { noteTypeId, limit, offset });
const notes = await prisma.note.findMany({
where: { noteTypeId },
orderBy: { id: "desc" },
take: limit,
skip: offset,
});
log.info("Fetched notes by note type", { noteTypeId, count: notes.length });
return notes.map((note) => ({
id: note.id,
guid: note.guid,
noteTypeId: note.noteTypeId,
mod: note.mod,
usn: note.usn,
tags: note.tags,
flds: note.flds,
sfld: note.sfld,
csum: note.csum,
flags: note.flags,
data: note.data,
userId: note.userId,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
}));
}
export async function repoGetNotesByUserId(
input: RepoInputGetNotesByUserId,
): Promise<RepoOutputNote[]> {
const { userId, limit = 50, offset = 0 } = input;
log.debug("Fetching notes by user", { userId, limit, offset });
const notes = await prisma.note.findMany({
where: { userId },
orderBy: { id: "desc" },
take: limit,
skip: offset,
});
log.info("Fetched notes by user", { userId, count: notes.length });
return notes.map((note) => ({
id: note.id,
guid: note.guid,
noteTypeId: note.noteTypeId,
mod: note.mod,
usn: note.usn,
tags: note.tags,
flds: note.flds,
sfld: note.sfld,
csum: note.csum,
flags: note.flags,
data: note.data,
userId: note.userId,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
}));
}
export async function repoGetNotesByUserIdWithFields(
input: RepoInputGetNotesByUserId,
): Promise<RepoOutputNoteWithFields[]> {
const notes = await repoGetNotesByUserId(input);
return notes.map((note) => ({
...note,
fields: repoSplitFields(note.flds),
tagsArray: note.tags.trim() === "" ? [] : note.tags.trim().split(" "),
}));
}
export async function repoDeleteNote(input: RepoInputDeleteNote): Promise<void> {
log.debug("Deleting note", { id: input.id.toString() });
await prisma.note.delete({
where: { id: input.id },
});
log.info("Note deleted", { id: input.id.toString() });
}
export async function repoCheckNoteOwnership(
input: RepoInputCheckNoteOwnership,
): Promise<boolean> {
const note = await prisma.note.findUnique({
where: { id: input.noteId },
select: { userId: true },
});
return note?.userId === input.userId;
}
export async function repoGetNoteOwnership(
input: RepoInputGetNoteById,
): Promise<RepoOutputNoteOwnership | null> {
const note = await prisma.note.findUnique({
where: { id: input.id },
select: { userId: true },
});
if (!note) return null;
return { userId: note.userId };
}
export async function repoCountNotesByUserId(userId: string): Promise<number> {
return prisma.note.count({
where: { userId },
});
}
export async function repoCountNotesByNoteTypeId(
noteTypeId: number,
): Promise<number> {
return prisma.note.count({
where: { noteTypeId },
});
}

View File

@@ -0,0 +1,60 @@
export type ServiceInputCreateNote = {
noteTypeId: number;
fields: string[];
tags?: string[];
userId: string;
};
export type ServiceInputUpdateNote = {
noteId: bigint;
fields?: string[];
tags?: string[];
userId: string;
};
export type ServiceInputDeleteNote = {
noteId: bigint;
userId: string;
};
export type ServiceInputGetNoteById = {
noteId: bigint;
};
export type ServiceInputGetNotesByNoteTypeId = {
noteTypeId: number;
limit?: number;
offset?: number;
};
export type ServiceInputGetNotesByUserId = {
userId: string;
limit?: number;
offset?: number;
};
export type ServiceOutputNote = {
id: bigint;
guid: string;
noteTypeId: number;
mod: number;
usn: number;
tags: string[];
fields: string[];
sfld: string;
csum: number;
flags: number;
data: string;
userId: string;
createdAt: Date;
updatedAt: Date;
};
export type ServiceOutputCreateNote = {
id: bigint;
guid: string;
};
export type ServiceOutputNoteCount = {
count: number;
};

View File

@@ -0,0 +1,200 @@
import { createLogger } from "@/lib/logger";
import {
repoCreateNote,
repoUpdateNote,
repoGetNoteByIdWithFields,
repoGetNotesByNoteTypeId,
repoGetNotesByUserIdWithFields,
repoDeleteNote,
repoCheckNoteOwnership,
repoCountNotesByUserId,
repoCountNotesByNoteTypeId,
} from "./note-repository";
import {
ServiceInputCreateNote,
ServiceInputUpdateNote,
ServiceInputDeleteNote,
ServiceInputGetNoteById,
ServiceInputGetNotesByNoteTypeId,
ServiceInputGetNotesByUserId,
ServiceOutputNote,
ServiceOutputCreateNote,
ServiceOutputNoteCount,
} from "./note-service-dto";
const log = createLogger("note-service");
export class NoteNotFoundError extends Error {
constructor(noteId: bigint) {
super(`Note not found: ${noteId.toString()}`);
this.name = "NoteNotFoundError";
}
}
export class NoteOwnershipError extends Error {
constructor() {
super("You do not have permission to access this note");
this.name = "NoteOwnershipError";
}
}
export async function serviceCreateNote(
input: ServiceInputCreateNote,
): Promise<ServiceOutputCreateNote> {
log.debug("Creating note", { userId: input.userId, noteTypeId: input.noteTypeId });
const id = await repoCreateNote({
noteTypeId: input.noteTypeId,
fields: input.fields,
tags: input.tags,
userId: input.userId,
});
const note = await repoGetNoteByIdWithFields({ id });
if (!note) {
throw new NoteNotFoundError(id);
}
log.info("Note created successfully", { id: id.toString(), guid: note.guid });
return {
id,
guid: note.guid,
};
}
export async function serviceUpdateNote(
input: ServiceInputUpdateNote,
): Promise<void> {
log.debug("Updating note", { noteId: input.noteId.toString() });
const isOwner = await repoCheckNoteOwnership({
noteId: input.noteId,
userId: input.userId,
});
if (!isOwner) {
throw new NoteOwnershipError();
}
await repoUpdateNote({
id: input.noteId,
fields: input.fields,
tags: input.tags,
});
log.info("Note updated successfully", { noteId: input.noteId.toString() });
}
export async function serviceDeleteNote(
input: ServiceInputDeleteNote,
): Promise<void> {
log.debug("Deleting note", { noteId: input.noteId.toString() });
const isOwner = await repoCheckNoteOwnership({
noteId: input.noteId,
userId: input.userId,
});
if (!isOwner) {
throw new NoteOwnershipError();
}
await repoDeleteNote({ id: input.noteId });
log.info("Note deleted successfully", { noteId: input.noteId.toString() });
}
export async function serviceGetNoteById(
input: ServiceInputGetNoteById,
): Promise<ServiceOutputNote | null> {
log.debug("Fetching note by id", { noteId: input.noteId.toString() });
const note = await repoGetNoteByIdWithFields({ id: input.noteId });
if (!note) {
log.debug("Note not found", { noteId: input.noteId.toString() });
return null;
}
return {
id: note.id,
guid: note.guid,
noteTypeId: note.noteTypeId,
mod: note.mod,
usn: note.usn,
tags: note.tagsArray,
fields: note.fields,
sfld: note.sfld,
csum: note.csum,
flags: note.flags,
data: note.data,
userId: note.userId,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
};
}
export async function serviceGetNotesByNoteTypeId(
input: ServiceInputGetNotesByNoteTypeId,
): Promise<ServiceOutputNote[]> {
log.debug("Fetching notes by note type", { noteTypeId: input.noteTypeId });
const notes = await repoGetNotesByNoteTypeId(input);
return notes.map((note) => ({
id: note.id,
guid: note.guid,
noteTypeId: note.noteTypeId,
mod: note.mod,
usn: note.usn,
tags: note.tags.trim() === "" ? [] : note.tags.trim().split(" "),
fields: note.flds.split("\x1f"),
sfld: note.sfld,
csum: note.csum,
flags: note.flags,
data: note.data,
userId: note.userId,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
}));
}
export async function serviceGetNotesByUserId(
input: ServiceInputGetNotesByUserId,
): Promise<ServiceOutputNote[]> {
log.debug("Fetching notes by user", { userId: input.userId });
const notes = await repoGetNotesByUserIdWithFields(input);
return notes.map((note) => ({
id: note.id,
guid: note.guid,
noteTypeId: note.noteTypeId,
mod: note.mod,
usn: note.usn,
tags: note.tagsArray,
fields: note.fields,
sfld: note.sfld,
csum: note.csum,
flags: note.flags,
data: note.data,
userId: note.userId,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
}));
}
export async function serviceCountNotesByUserId(
userId: string,
): Promise<ServiceOutputNoteCount> {
const count = await repoCountNotesByUserId(userId);
return { count };
}
export async function serviceCountNotesByNoteTypeId(
noteTypeId: number,
): Promise<ServiceOutputNoteCount> {
const count = await repoCountNotesByNoteTypeId(noteTypeId);
return { count };
}

View File

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

View File

@@ -1 +1 @@
export type { RepoInputCreatePair } from "@/modules/folder/folder-repository-dto";
export {};

View File

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

View File

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

View File

@@ -1,18 +1,69 @@
"use server";
import { executeOCR } from "@/lib/bigmodel/ocr/orchestrator";
import { repoCreatePair, repoGetUserIdByFolderId } from "@/modules/folder/folder-repository";
import { repoGetUserIdByDeckId } from "@/modules/deck/deck-repository";
import { repoCreateNote, repoJoinFields } from "@/modules/note/note-repository";
import { repoCreateCard } from "@/modules/card/card-repository";
import { repoGetNoteTypesByUserId, repoCreateNoteType } from "@/modules/note-type/note-type-repository";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { createLogger } from "@/lib/logger";
import type { ServiceInputProcessOCR, ServiceOutputProcessOCR } from "./ocr-service-dto";
import { NoteKind } from "../../../generated/prisma/enums";
const log = createLogger("ocr-service");
const VOCABULARY_NOTE_TYPE_NAME = "Vocabulary (OCR)";
async function getOrCreateVocabularyNoteType(userId: string): Promise<number> {
const existingTypes = await repoGetNoteTypesByUserId({ userId });
const existing = existingTypes.find((nt) => nt.name === VOCABULARY_NOTE_TYPE_NAME);
if (existing) {
return existing.id;
}
const fields = [
{ name: "Word", ord: 0, sticky: false, rtl: false, font: "Arial", size: 20, media: [] },
{ name: "Definition", ord: 1, sticky: false, rtl: false, font: "Arial", size: 20, media: [] },
{ name: "Source Language", ord: 2, sticky: false, rtl: false, font: "Arial", size: 16, media: [] },
{ name: "Target Language", ord: 3, sticky: false, rtl: false, font: "Arial", size: 16, media: [] },
];
const templates = [
{
name: "Word → Definition",
ord: 0,
qfmt: "{{Word}}",
afmt: "{{FrontSide}}<hr id=answer>{{Definition}}",
},
{
name: "Definition → Word",
ord: 1,
qfmt: "{{Definition}}",
afmt: "{{FrontSide}}<hr id=answer>{{Word}}",
},
];
const css = ".card { font-family: Arial; font-size: 20px; text-align: center; color: black; background-color: white; }";
const noteTypeId = await repoCreateNoteType({
name: VOCABULARY_NOTE_TYPE_NAME,
kind: NoteKind.STANDARD,
css,
fields,
templates,
userId,
});
log.info("Created vocabulary note type", { noteTypeId, userId });
return noteTypeId;
}
export async function serviceProcessOCR(
input: ServiceInputProcessOCR
): Promise<ServiceOutputProcessOCR> {
log.info("Processing OCR request", { folderId: input.folderId });
log.info("Processing OCR request", { deckId: input.deckId });
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
@@ -20,15 +71,15 @@ export async function serviceProcessOCR(
return { success: false, message: "Unauthorized" };
}
const folderOwner = await repoGetUserIdByFolderId(input.folderId);
if (folderOwner !== session.user.id) {
log.warn("Folder ownership mismatch", {
folderId: input.folderId,
const deckOwner = await repoGetUserIdByDeckId(input.deckId);
if (deckOwner !== session.user.id) {
log.warn("Deck ownership mismatch", {
deckId: input.deckId,
userId: session.user.id
});
return {
success: false,
message: "You don't have permission to modify this folder"
message: "You don't have permission to modify this deck"
};
}
@@ -59,19 +110,38 @@ export async function serviceProcessOCR(
const sourceLanguage = ocrResult.detectedSourceLanguage || input.sourceLanguage || "Unknown";
const targetLanguage = ocrResult.detectedTargetLanguage || input.targetLanguage || "Unknown";
const noteTypeId = await getOrCreateVocabularyNoteType(session.user.id);
let pairsCreated = 0;
for (const pair of ocrResult.pairs) {
try {
await repoCreatePair({
folderId: input.folderId,
language1: sourceLanguage,
language2: targetLanguage,
text1: pair.word,
text2: pair.definition,
const now = Date.now();
const noteId = await repoCreateNote({
noteTypeId,
userId: session.user.id,
fields: [pair.word, pair.definition, sourceLanguage, targetLanguage],
tags: ["ocr"],
});
await repoCreateCard({
id: BigInt(now + pairsCreated),
noteId,
deckId: input.deckId,
ord: 0,
due: pairsCreated + 1,
});
await repoCreateCard({
id: BigInt(now + pairsCreated + 10000),
noteId,
deckId: input.deckId,
ord: 1,
due: pairsCreated + 1,
});
pairsCreated++;
} catch (error) {
log.error("Failed to create pair", {
log.error("Failed to create note/card", {
word: pair.word,
error
});

165
src/shared/anki-type.ts Normal file
View File

@@ -0,0 +1,165 @@
/**
* Shared types for Anki-compatible data structures
* Based on Anki's official database schema
*/
import type { CardType, CardQueue, NoteKind, Visibility } from "../../generated/prisma/enums";
// ============================================
// NoteType (Anki: models)
// ============================================
export interface NoteTypeField {
name: string;
ord: number;
sticky: boolean;
rtl: boolean;
font: string;
size: number;
media: string[];
}
export interface NoteTypeTemplate {
name: string;
ord: number;
qfmt: string; // Question format (Mustache template)
afmt: string; // Answer format (Mustache template)
bqfmt?: string; // Browser question format
bafmt?: string; // Browser answer format
did?: number; // Deck override
}
export interface TSharedNoteType {
id: number;
name: string;
kind: NoteKind;
css: string;
fields: NoteTypeField[];
templates: NoteTypeTemplate[];
userId: string;
createdAt: Date;
updatedAt: Date;
}
// ============================================
// Deck (Anki: decks) - replaces Folder
// ============================================
export interface TSharedDeck {
id: number;
name: string;
desc: string;
userId: string;
visibility: Visibility;
collapsed: boolean;
conf: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
cardCount?: number;
}
// ============================================
// Note (Anki: notes)
// ============================================
export interface TSharedNote {
id: bigint;
guid: string;
noteTypeId: number;
mod: number;
usn: number;
tags: string; // Space-separated
flds: string; // Field values separated by 0x1f
sfld: string; // Sort field
csum: number; // Checksum of first field
flags: number;
data: string;
userId: string;
createdAt: Date;
updatedAt: Date;
}
// Helper to get fields as array
export function getNoteFields(note: TSharedNote): string[] {
return note.flds.split('\x1f');
}
// Helper to set fields from array
export function setNoteFields(fields: string[]): string {
return fields.join('\x1f');
}
// Helper to get tags as array
export function getNoteTags(note: TSharedNote): string[] {
return note.tags.trim().split(' ').filter(Boolean);
}
// Helper to set tags from array
export function setNoteTags(tags: string[]): string {
return ` ${tags.join(' ')} `;
}
// ============================================
// Card (Anki: cards)
// ============================================
export interface TSharedCard {
id: bigint;
noteId: bigint;
deckId: number;
ord: number;
mod: number;
usn: number;
type: CardType;
queue: CardQueue;
due: number;
ivl: number;
factor: number;
reps: number;
lapses: number;
left: number;
odue: number;
odid: number;
flags: number;
data: string;
createdAt: Date;
updatedAt: Date;
}
// Card for review (with note data)
export interface TCardForReview extends TSharedCard {
note: TSharedNote & {
noteType: TSharedNoteType;
};
deck: TSharedDeck;
}
// ============================================
// Review
// ============================================
export type ReviewEase = 1 | 2 | 3 | 4; // Again, Hard, Good, Easy
export interface TSharedRevlog {
id: bigint;
cardId: bigint;
usn: number;
ease: number;
ivl: number;
lastIvl: number;
factor: number;
time: number; // Review time in ms
type: number;
}
// ============================================
// Deck Favorites
// ============================================
export interface TSharedDeckFavorite {
id: number;
userId: string;
deckId: number;
createdAt: Date;
deck?: TSharedDeck;
}

View File

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