Compare commits

...

11 Commits

Author SHA1 Message Date
7ba31a37bd feat: 添加 Anki APKG 导入/导出功能
- 添加 APKG 解析器 (src/lib/anki/apkg-parser.ts)
- 添加 APKG 导出器 (src/lib/anki/apkg-exporter.ts)
- 添加导入/导出 Server Actions
- 添加导入/导出 UI 组件
- 集成到牌组页面
- 添加 i18n 翻译

同时修复断链:
- /folders → /decks (Navbar, signup, profile)
2026-03-11 10:37:23 +08:00
4d4062985d fix: 修复 React Compiler 严格模式下的 lint 错误
- Memorize: 将 loadCards 内联到 useEffect 中避免变量提升问题
- DecksClient: 修复 effect 中异步加载,创建 deck 后使用 actionGetDeckById
- LanguageSettings: 使用 effect 设置 cookie 避免 render 期间修改
- theme-provider: 修复 hydration 逻辑避免 render 期间访问 ref
2026-03-11 09:51:25 +08:00
804c28ada9 refactor: 修复 modules 三层架构违规
- auth: actionDeleteAccount 改用 service+repo,forgot-password 完整三层实现
- card: serviceCheckCardOwnership 替代直接调用 repository
- deck: 移除 service 层的 use server 指令
- dictionary: 数据转换逻辑从 repository 移到 service
- ocr: 认证移到 action 层,跨模块调用改用 service
- translator: genIPA/genLanguage 改用 service 层
2026-03-11 09:40:53 +08:00
e68e24a9fb style: 降低注销按钮的视觉显著性
- 改用 ghost 样式替代 error 样式
- 使用小尺寸
2026-03-10 20:21:38 +08:00
8099320e00 feat: 添加注销账号功能
- 在个人资料页面添加注销账号按钮
- 需要输入用户名确认才能删除
- 删除所有用户数据:牌组、卡片、笔记、关注等
- 添加 8 种语言翻译
2026-03-10 19:54:19 +08:00
db0b0ff348 fix: 强制 username 登录也需要邮箱验证
- 添加 sendOnSignIn: true 配置
- 在 hook 中拦截 /sign-in/username 请求
- 检查用户邮箱是否已验证,未验证返回 403
2026-03-10 19:41:30 +08:00
6f4b123a84 fix: 添加邮箱验证重发功能
- 登录时检测 403 错误(邮箱未验证)
- 显示重发验证邮件按钮
- 修复邮件发送失败时静默忽略的问题
- 添加 8 种语言的验证相关翻译
2026-03-10 19:38:54 +08:00
57ad1b8699 refactor: 完全重构为 Anki 兼容数据结构
- 用 Deck 替换 Folder
- 用 Note + Card 替换 Pair (双向复习)
- 添加 NoteType (卡片模板)
- 添加 Revlog (复习历史)
- 实现 SM-2 间隔重复算法
- 更新所有前端页面
- 添加数据库迁移
2026-03-10 19:20:46 +08:00
9b78fd5215 feat: 添加 OCR 词汇提取功能
新增 OCR 页面,用户可上传教材词汇表截图,使用 GLM-4.6V 视觉模型
提取单词-释义对并保存到指定文件夹。

- AI 管道: src/lib/bigmodel/ocr/ (orchestrator, types)
- 后端模块: src/modules/ocr/ (action-service-repository 架构)
- 前端页面: src/app/(features)/ocr/ (拖拽上传、folder 选择)
- i18n: 8 种语言翻译支持
2026-03-10 15:21:45 +08:00
683a4104ec feat: 添加用户关注功能
- 新增 Follow 表和 User.bio 字段 (Prisma schema)
- 创建 follow 模块 (action-service-repository)
- 新增 FollowButton/FollowStats/UserList 组件
- 用户页面显示 bio、粉丝/关注数、关注按钮
- 新增 /users/[username]/followers 和 following 页面
- 添加 en-US/zh-CN i18n 翻译

⚠️ 需要运行: prisma migrate dev --name add_follow_and_bio
2026-03-10 14:58:43 +08:00
abcae1b8d1 feat: 添加移动端下拉菜单和主题色设置
- 新增 MobileMenu 组件,小屏幕使用汉堡菜单替代多个按钮
- 重构 LanguageSettings 为统一下拉框样式
- 新增设置页面,支持主题色切换
- 翻译页添加源语言选择器
- 更新 8 种语言的 i18n 翻译
2026-03-10 13:44:52 +08:00
131 changed files with 12157 additions and 2657 deletions

View File

@@ -46,6 +46,15 @@
"unfavorite": "Aus Favoriten entfernen", "unfavorite": "Aus Favoriten entfernen",
"pleaseLogin": "Bitte melden Sie sich zuerst an" "pleaseLogin": "Bitte melden Sie sich zuerst an"
}, },
"decks": {
"title": "Decks",
"noDecks": "Noch keine Decks",
"deckName": "Deckname",
"totalCards": "Gesamtkarten",
"createdAt": "Erstellt am",
"actions": "Aktionen",
"view": "Anzeigen"
},
"folder_id": { "folder_id": {
"unauthorized": "Sie sind nicht der Besitzer dieses Ordners", "unauthorized": "Sie sind nicht der Besitzer dieses Ordners",
"back": "Zurück", "back": "Zurück",
@@ -157,6 +166,9 @@
"resetPasswordFailed": "Reset-E-Mail konnte nicht gesendet werden", "resetPasswordFailed": "Reset-E-Mail konnte nicht gesendet werden",
"resetPasswordEmailSent": "Reset-E-Mail erfolgreich gesendet", "resetPasswordEmailSent": "Reset-E-Mail erfolgreich gesendet",
"resetPasswordEmailSentHint": "Wir haben einen Link zum Zurücksetzen Ihres Passworts an Ihre E-Mail-Adresse gesendet. Bitte überprüfen Sie Ihren Posteingang.", "resetPasswordEmailSentHint": "Wir haben einen Link zum Zurücksetzen Ihres Passworts an Ihre E-Mail-Adresse gesendet. Bitte überprüfen Sie Ihren Posteingang.",
"verifyYourEmail": "E-Mail bestätigen",
"verificationEmailSent": "Bestätigungs-E-Mail gesendet",
"verificationEmailSentHint": "Wir haben eine Bestätigungs-E-Mail an {email} gesendet. Bitte klicken Sie auf den Link in der E-Mail, um Ihr Konto zu bestätigen.",
"checkYourEmail": "Überprüfen Sie Ihre E-Mail", "checkYourEmail": "Überprüfen Sie Ihre E-Mail",
"backToLogin": "Zurück zur Anmeldung", "backToLogin": "Zurück zur Anmeldung",
"resetPassword": "Passwort zurücksetzen", "resetPassword": "Passwort zurücksetzen",
@@ -166,25 +178,54 @@
"requestNewToken": "Neuen Reset-Link anfordern", "requestNewToken": "Neuen Reset-Link anfordern",
"resetPasswordSuccess": "Passwort erfolgreich zurückgesetzt", "resetPasswordSuccess": "Passwort erfolgreich zurückgesetzt",
"resetPasswordSuccessTitle": "Passwort-Zurücksetzung abgeschlossen", "resetPasswordSuccessTitle": "Passwort-Zurücksetzung abgeschlossen",
"resetPasswordSuccessHint": "Ihr Passwort wurde erfolgreich zurückgesetzt. Sie können sich jetzt mit Ihrem neuen Passwort anmelden." "resetPasswordSuccessHint": "Ihr Passwort wurde erfolgreich zurückgesetzt. Sie können sich jetzt mit Ihrem neuen Passwort anmelden.",
"emailNotVerified": "Bitte verifizieren Sie Ihre E-Mail-Adresse",
"emailNotVerifiedHint": "Ihre E-Mail-Adresse wurde nicht verifiziert. Bitte überprüfen Sie Ihren Posteingang oder fordern Sie eine neue Verifizierungs-E-Mail an.",
"resendVerification": "Verifizierungs-E-Mail erneut senden",
"resendSuccess": "Verifizierungs-E-Mail gesendet! Bitte überprüfen Sie Ihren Posteingang.",
"resendFailed": "Verifizierungs-E-Mail konnte nicht gesendet werden"
}, },
"memorize": { "memorize": {
"folder_selector": { "deck_selector": {
"selectFolder": "Wählen Sie einen Ordner", "selectDeck": "Deck auswählen",
"noFolders": "Keine Ordner gefunden", "noDecks": "Keine Decks gefunden",
"folderInfo": "{id}. {name} ({count})" "goToDecks": "Zu Decks",
"noCards": "Keine Karten",
"new": "Neu",
"learning": "Lernen",
"review": "Wiederholen",
"due": "Fällig"
}, },
"memorize": { "review": {
"answer": "Antwort", "loading": "Laden...",
"next": "Weiter", "backToDecks": "Zurück zu Decks",
"reverse": "Umkehren", "allDone": "Fertig!",
"dictation": "Diktat", "allDoneDesc": "Alle fälligen Karten wurden wiederholt.",
"noTextPairs": "Keine Textpaare verfügbar", "reviewedCount": "{count} Karten wiederholt",
"disorder": "Mischen", "progress": "{current} / {total}",
"previous": "Zurück" "nextReview": "Nächste Wiederholung",
"interval": "Intervall",
"ease": "Leichtigkeit",
"lapses": "Verlernungen",
"showAnswer": "Antwort zeigen",
"again": "Nochmal",
"hard": "Schwer",
"good": "Gut",
"easy": "Leicht",
"now": "jetzt",
"lessThanMinute": "<1 Min",
"inMinutes": "{count} Min",
"inHours": "{count} Std",
"inDays": "{count} Tage",
"inMonths": "{count} Monate",
"minutes": "<1 Min",
"days": "{count} Tage",
"months": "{count} Monate",
"minAbbr": "m",
"dayAbbr": "T"
}, },
"page": { "page": {
"unauthorized": "Sie sind nicht berechtigt, auf diesen Ordner zuzugreifen" "unauthorized": "Sie sind nicht berechtigt, auf dieses Deck zuzugreifen"
} }
}, },
"navbar": { "navbar": {
@@ -193,14 +234,49 @@
"sign_in": "Anmelden", "sign_in": "Anmelden",
"profile": "Profil", "profile": "Profil",
"folders": "Ordner", "folders": "Ordner",
"explore": "Entdecken", "explore": "Erkunden",
"favorites": "Favoriten" "favorites": "Favoriten",
"settings": "Einstellungen"
},
"ocr": {
"title": "OCR Vokabel-Extraktion",
"description": "Laden Sie Screenshots von Vokabeltabellen aus Lehrbüchern hoch, um Wort-Definition-Paare zu extrahieren",
"uploadImage": "Bild hochladen",
"dragDropHint": "Ziehen Sie ein Bild hierher oder klicken Sie zum Auswählen",
"supportedFormats": "Unterstüt: JPG, PNG, WebP",
"selectDeck": "Deck auswählen",
"chooseDeck": "Wählen Sie einen Deck zum Speichern der extrahierten Paare",
"noDecks": "Keine Decks verfügbar. Bitte create a deck first.",
"languageHints": "Sprachhinweise (Optional)",
"sourceLanguageHint": "Quellsprache (z.B. Englisch)",
"targetLanguageHint": "Ziel-/Übersetzungssprache (z.B. Chinesisch)",
"process": "Bild verarbeiten",
"processing": "Verarbeitung...",
"preview": "Vorschau",
"extractedPairs": "Extrahierte Paare",
"word": "Wort",
"definition": "Definition",
"pairsCount": "{count} Paare extrahiert",
"savePairs": "In Deck speichern",
"saving": "Speichern...",
"saved": "{count} Paare erfolgreich in {deck} gespeichert",
"saveFailed": "Speichern fehlgeschlagen",
"noImage": "Bitte laden Sie zuerst ein Bild hoch",
"noDeck": "Bitte select a deck",
"processingFailed": "OCR-Verarbeitung fehlgeschlagen",
"tryAgain": "Bitte try again with a clearer image",
"detectedLanguages": "Erkannt: {source} → {target}"
}, },
"profile": { "profile": {
"myProfile": "Mein Profil", "myProfile": "Mein Profil",
"email": "E-Mail: {email}", "email": "E-Mail: {email}",
"logout": "Abmelden" "logout": "Abmelden"
}, },
"settings": {
"title": "Einstellungen",
"themeColor": "Designfarbe",
"themeColorDescription": "Wählen Sie Ihre bevorzugte Designfarbe"
},
"srt_player": { "srt_player": {
"uploadVideo": "Video hochladen", "uploadVideo": "Video hochladen",
"uploadSubtitle": "Untertitel hochladen", "uploadSubtitle": "Untertitel hochladen",
@@ -239,6 +315,8 @@
}, },
"translator": { "translator": {
"detectLanguage": "Sprache erkennen", "detectLanguage": "Sprache erkennen",
"sourceLanguage": "Quellsprache",
"auto": "Automatisch",
"generateIPA": "IPA generieren", "generateIPA": "IPA generieren",
"translateInto": "übersetzen in", "translateInto": "übersetzen in",
"chinese": "Chinesisch", "chinese": "Chinesisch",
@@ -347,11 +425,26 @@
"notSet": "Nicht festgelegt", "notSet": "Nicht festgelegt",
"memberSince": "Mitglied seit", "memberSince": "Mitglied seit",
"logout": "Abmelden", "logout": "Abmelden",
"deleteAccount": {
"button": "Konto löschen",
"title": "Konto löschen",
"warning": "Diese Aktion ist unwiderruflich. Alle Ihre Daten werden dauerhaft gelöscht.",
"warningDecks": "Alle Ihre Decks und Karten",
"warningCards": "All Ihr Lernfortschritt",
"warningHistory": "All Ihr Übersetzungs- und Wörterbuchverlauf",
"warningPermanent": "Diese Aktion kann nicht rückgängig gemacht werden",
"confirmLabel": "Geben Sie Ihren Benutzernamen zur Bestätigung ein:",
"usernameMismatch": "Benutzername stimmt nicht überein",
"cancel": "Abbrechen",
"confirm": "Mein Konto löschen",
"success": "Konto erfolgreich gelöscht",
"failed": "Konto konnte nicht gelöscht werden"
},
"folders": { "folders": {
"title": "Ordner", "title": "Decks",
"noFolders": "Noch keine Ordner", "noFolders": "Noch keine Decks",
"folderName": "Ordnername", "folderName": "Deckname",
"totalPairs": "Gesamtpaare", "totalPairs": "Gesamtkarten",
"createdAt": "Erstellt am", "createdAt": "Erstellt am",
"actions": "Aktionen", "actions": "Aktionen",
"view": "Anzeigen" "view": "Anzeigen"

View File

@@ -157,6 +157,9 @@
"resetPasswordFailed": "Failed to send reset email", "resetPasswordFailed": "Failed to send reset email",
"resetPasswordEmailSent": "Reset email sent successfully", "resetPasswordEmailSent": "Reset email sent successfully",
"resetPasswordEmailSentHint": "We've sent a password reset link to your email address. Please check your inbox.", "resetPasswordEmailSentHint": "We've sent a password reset link to your email address. Please check your inbox.",
"verifyYourEmail": "Verify Your Email",
"verificationEmailSent": "Verification email sent",
"verificationEmailSentHint": "We've sent a verification email to {email}. Please click the link in the email to verify your account.",
"checkYourEmail": "Check Your Email", "checkYourEmail": "Check Your Email",
"backToLogin": "Back to Login", "backToLogin": "Back to Login",
"resetPassword": "Reset Password", "resetPassword": "Reset Password",
@@ -166,25 +169,54 @@
"requestNewToken": "Request New Reset Link", "requestNewToken": "Request New Reset Link",
"resetPasswordSuccess": "Password reset successfully", "resetPasswordSuccess": "Password reset successfully",
"resetPasswordSuccessTitle": "Password Reset Complete", "resetPasswordSuccessTitle": "Password Reset Complete",
"resetPasswordSuccessHint": "Your password has been reset successfully. You can now log in with your new password." "resetPasswordSuccessHint": "Your password has been reset successfully. You can now log in with your new password.",
"emailNotVerified": "Please verify your email address",
"emailNotVerifiedHint": "Your email has not been verified. Please check your inbox or request a new verification email.",
"resendVerification": "Resend Verification Email",
"resendSuccess": "Verification email sent! Please check your inbox.",
"resendFailed": "Failed to send verification email"
}, },
"memorize": { "memorize": {
"folder_selector": { "deck_selector": {
"selectFolder": "Select a folder", "selectDeck": "Select a deck",
"noFolders": "No folders found", "noDecks": "No decks found",
"folderInfo": "{id}. {name} ({count})" "goToDecks": "Go to Decks",
"noCards": "No cards",
"new": "New",
"learning": "Learning",
"review": "Review",
"due": "Due"
}, },
"memorize": { "review": {
"answer": "Answer", "loading": "Loading cards...",
"next": "Next", "backToDecks": "Back to Decks",
"reverse": "Reverse", "allDone": "All Done!",
"dictation": "Dictation", "allDoneDesc": "You've reviewed all due cards.",
"noTextPairs": "No text pairs available", "reviewedCount": "Reviewed {count} cards",
"disorder": "Disorder", "progress": "{current} / {total}",
"previous": "Previous" "nextReview": "Next review",
"interval": "Interval",
"ease": "Ease",
"lapses": "Lapses",
"showAnswer": "Show Answer",
"again": "Again",
"hard": "Hard",
"good": "Good",
"easy": "Easy",
"now": "now",
"lessThanMinute": "<1 min",
"inMinutes": "{count} min",
"inHours": "{count}h",
"inDays": "{count}d",
"inMonths": "{count}mo",
"minutes": "<1 min",
"days": "{count}d",
"months": "{count}mo",
"minAbbr": "m",
"dayAbbr": "d"
}, },
"page": { "page": {
"unauthorized": "You are not authorized to access this folder" "unauthorized": "You are not authorized to access this deck"
} }
}, },
"navbar": { "navbar": {
@@ -194,13 +226,62 @@
"profile": "Profile", "profile": "Profile",
"folders": "Folders", "folders": "Folders",
"explore": "Explore", "explore": "Explore",
"favorites": "Favorites" "favorites": "Favorites",
"settings": "Settings"
},
"ocr": {
"title": "OCR Vocabulary Extractor",
"description": "Upload vocabulary table screenshots from textbooks to extract word-definition pairs",
"uploadSection": "Upload Image",
"uploadImage": "Upload Image",
"dragDropHint": "Drag and drop an image here, or click to select",
"dropOrClick": "Drag and drop an image here, or click to select",
"changeImage": "Click to change image",
"supportedFormats": "Supports: JPG, PNG, WebP",
"deckSelection": "Select Deck",
"selectDeck": "Select a deck",
"chooseDeck": "Choose a deck to save extracted pairs",
"noDecks": "No decks available. Please create a deck first.",
"languageHints": "Language Hints (Optional)",
"sourceLanguageHint": "Source language (e.g., English)",
"targetLanguageHint": "Target/Translation language (e.g., Chinese)",
"sourceLanguagePlaceholder": "Source language (e.g., English)",
"targetLanguagePlaceholder": "Target/Translation language (e.g., Chinese)",
"process": "Process Image",
"processButton": "Process Image",
"processing": "Processing...",
"preview": "Preview",
"resultsPreview": "Results Preview",
"extractedPairs": "Extracted {count} pairs",
"word": "Word",
"definition": "Definition",
"pairsCount": "{count} pairs extracted",
"savePairs": "Save to Deck",
"saveButton": "Save",
"saving": "Saving...",
"saved": "Successfully saved {count} pairs to {deck}",
"ocrSuccess": "Successfully extracted {count} pairs to {deck}",
"savedToDeck": "Saved to {deckName}",
"saveFailed": "Failed to save pairs",
"noImage": "Please upload an image first",
"noDeck": "Please select a deck",
"noResultsToSave": "No results to save",
"processingFailed": "OCR processing failed",
"tryAgain": "Please try again with a clearer image",
"detectedLanguages": "Detected: {source} → {target}",
"detectedSourceLanguage": "Detected source language",
"detectedTargetLanguage": "Detected target language"
}, },
"profile": { "profile": {
"myProfile": "My Profile", "myProfile": "My Profile",
"email": "Email: {email}", "email": "Email: {email}",
"logout": "Logout" "logout": "Logout"
}, },
"settings": {
"title": "Settings",
"themeColor": "Theme Color",
"themeColorDescription": "Choose your preferred theme color"
},
"srt_player": { "srt_player": {
"uploadVideo": "Upload Video", "uploadVideo": "Upload Video",
"uploadSubtitle": "Upload Subtitle", "uploadSubtitle": "Upload Subtitle",
@@ -239,6 +320,8 @@
}, },
"translator": { "translator": {
"detectLanguage": "detect language", "detectLanguage": "detect language",
"sourceLanguage": "source language",
"auto": "Auto",
"generateIPA": "generate ipa", "generateIPA": "generate ipa",
"translateInto": "translate into", "translateInto": "translate into",
"chinese": "Chinese", "chinese": "Chinese",
@@ -301,11 +384,11 @@
}, },
"explore": { "explore": {
"title": "Explore", "title": "Explore",
"subtitle": "Discover public folders", "subtitle": "Discover public decks",
"searchPlaceholder": "Search public folders...", "searchPlaceholder": "Search public decks...",
"loading": "Loading...", "loading": "Loading...",
"noFolders": "No public folders found", "noDecks": "No public decks found",
"folderInfo": "{userName} • {totalPairs} pairs", "deckInfo": "{userName} • {cardCount} cards",
"unknownUser": "Unknown User", "unknownUser": "Unknown User",
"favorite": "Favorite", "favorite": "Favorite",
"unfavorite": "Unfavorite", "unfavorite": "Unfavorite",
@@ -314,10 +397,10 @@
"sortByFavoritesActive": "Undo sort by favorites" "sortByFavoritesActive": "Undo sort by favorites"
}, },
"exploreDetail": { "exploreDetail": {
"title": "Folder Details", "title": "Deck Details",
"createdBy": "Created by: {name}", "createdBy": "Created by: {name}",
"unknownUser": "Unknown User", "unknownUser": "Unknown User",
"totalPairs": "Total Pairs", "totalCards": "Total Cards",
"favorites": "Favorites", "favorites": "Favorites",
"createdAt": "Created At", "createdAt": "Created At",
"viewContent": "View Content", "viewContent": "View Content",
@@ -346,15 +429,94 @@
"displayName": "Display Name", "displayName": "Display Name",
"notSet": "Not Set", "notSet": "Not Set",
"memberSince": "Member Since", "memberSince": "Member Since",
"joined": "Joined",
"logout": "Logout", "logout": "Logout",
"folders": { "deleteAccount": {
"title": "Folders", "button": "Delete Account",
"noFolders": "No folders yet", "title": "Delete Account",
"folderName": "Folder Name", "warning": "This action is irreversible. All your data will be permanently deleted.",
"totalPairs": "Total Pairs", "warningDecks": "All your decks and cards",
"warningCards": "All your learning progress",
"warningHistory": "All your translation and dictionary history",
"warningPermanent": "This action cannot be undone",
"confirmLabel": "Type your username to confirm:",
"usernameMismatch": "Username does not match",
"cancel": "Cancel",
"confirm": "Delete My Account",
"success": "Account deleted successfully",
"failed": "Failed to delete account"
},
"decks": {
"title": "Decks",
"noDecks": "No decks yet",
"deckName": "Deck Name",
"totalCards": "Total Cards",
"createdAt": "Created At", "createdAt": "Created At",
"actions": "Actions", "actions": "Actions",
"view": "View" "view": "View"
} }
},
"decks": {
"title": "Decks",
"subtitle": "Manage your flashcard decks",
"newDeck": "New Deck",
"enterDeckName": "Enter deck name:",
"confirmDelete": "Type \"{name}\" to delete:",
"noDecksYet": "No decks yet",
"deckInfo": "ID: {id} • {totalCards} cards",
"loading": "Loading...",
"public": "Public",
"private": "Private",
"setPublic": "Set Public",
"setPrivate": "Set Private",
"enterNewName": "Enter new name:",
"importApkg": "Import APKG",
"exportApkg": "Export APKG",
"clickToUpload": "Click to upload .apkg file",
"apkgFilesOnly": "APKG files only",
"parsing": "Parsing file...",
"foundDecks": "Found {count} deck(s)",
"deckName": "Deck Name",
"back": "Back",
"import": "Import",
"importing": "Importing...",
"exportSuccess": "Deck exported successfully",
"goToDecks": "Go to Decks"
},
"decks": {
"title": "Decks",
"subtitle": "Manage your flashcard decks",
"newDeck": "New Deck",
"noDecksYet": "No decks yet",
"loading": "Loading...",
"deckInfo": "ID: {id} • {totalCards} cards",
"enterDeckName": "Enter deck name:",
"enterNewName": "Enter new name:",
"confirmDelete": "Type \"{name}\" to delete:",
"public": "Public",
"private": "Private",
"setPublic": "Set Public",
"setPrivate": "Set Private",
"importApkg": "Import APKG",
"exportApkg": "Export APKG",
"clickToUpload": "Click to upload an APKG file",
"apkgFilesOnly": "Only .apkg files are supported",
"parsing": "Parsing...",
"foundDecks": "Found {count} deck(s)",
"deckName": "Deck Name",
"back": "Back",
"import": "Import",
"importing": "Importing...",
"exportSuccess": "Deck exported successfully",
"goToDecks": "Go to Decks"
},
"follow": {
"follow": "Follow",
"following": "Following",
"followers": "Followers",
"followersOf": "{username}'s Followers",
"followingOf": "{username}'s Following",
"noFollowers": "No followers yet",
"noFollowing": "Not following anyone yet"
} }
} }

View File

@@ -46,6 +46,15 @@
"unfavorite": "Retirer des favoris", "unfavorite": "Retirer des favoris",
"pleaseLogin": "Veuillez vous connecter d'abord" "pleaseLogin": "Veuillez vous connecter d'abord"
}, },
"decks": {
"title": "Decks",
"noDecks": "Pas encore de decks",
"deckName": "Nom du deck",
"totalCards": "Total des cartes",
"createdAt": "Créé le",
"actions": "Actions",
"view": "Voir"
},
"folder_id": { "folder_id": {
"unauthorized": "Vous n'êtes pas le propriétaire de ce dossier", "unauthorized": "Vous n'êtes pas le propriétaire de ce dossier",
"back": "Retour", "back": "Retour",
@@ -157,6 +166,9 @@
"resetPasswordFailed": "Échec de l'envoi de l'e-mail de réinitialisation", "resetPasswordFailed": "Échec de l'envoi de l'e-mail de réinitialisation",
"resetPasswordEmailSent": "E-mail de réinitialisation envoyé avec succès", "resetPasswordEmailSent": "E-mail de réinitialisation envoyé avec succès",
"resetPasswordEmailSentHint": "Nous avons envoyé un lien de réinitialisation de mot de passe à votre adresse e-mail. Veuillez vérifier votre boîte de réception.", "resetPasswordEmailSentHint": "Nous avons envoyé un lien de réinitialisation de mot de passe à votre adresse e-mail. Veuillez vérifier votre boîte de réception.",
"verifyYourEmail": "Vérifier votre e-mail",
"verificationEmailSent": "E-mail de vérification envoyé",
"verificationEmailSentHint": "Nous avons envoyé un e-mail de vérification à {email}. Veuillez cliquer sur le lien dans l'e-mail pour vérifier votre compte.",
"checkYourEmail": "Vérifiez votre e-mail", "checkYourEmail": "Vérifiez votre e-mail",
"backToLogin": "Retour à la connexion", "backToLogin": "Retour à la connexion",
"resetPassword": "Réinitialiser le mot de passe", "resetPassword": "Réinitialiser le mot de passe",
@@ -166,41 +178,105 @@
"requestNewToken": "Demander un nouveau lien de réinitialisation", "requestNewToken": "Demander un nouveau lien de réinitialisation",
"resetPasswordSuccess": "Mot de passe réinitialisé avec succès", "resetPasswordSuccess": "Mot de passe réinitialisé avec succès",
"resetPasswordSuccessTitle": "Réinitialisation du mot de passe terminée", "resetPasswordSuccessTitle": "Réinitialisation du mot de passe terminée",
"resetPasswordSuccessHint": "Votre mot de passe a été réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe." "resetPasswordSuccessHint": "Votre mot de passe a été réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.",
"emailNotVerified": "Veuillez vérifier votre adresse e-mail",
"emailNotVerifiedHint": "Votre adresse e-mail n'a pas été vérifiée. Veuillez vérifier votre boîte de réception ou demander un nouvel e-mail de vérification.",
"resendVerification": "Renvoyer l'e-mail de vérification",
"resendSuccess": "E-mail de vérification envoyé ! Veuillez vérifier votre boîte de réception.",
"resendFailed": "Échec de l'envoi de l'e-mail de vérification"
}, },
"memorize": { "memorize": {
"folder_selector": { "deck_selector": {
"selectFolder": "Sélectionner un dossier", "selectDeck": "Sélectionner un deck",
"noFolders": "Aucun dossier trouvé", "noDecks": "Aucun deck trouvé",
"folderInfo": "{id}. {name} ({count})" "goToDecks": "Aller aux decks",
"noCards": "Aucune carte",
"new": "Nouveau",
"learning": "Apprentissage",
"review": "Révision",
"due": "À faire"
}, },
"memorize": { "review": {
"answer": "Réponse", "loading": "Chargement...",
"next": "Suivant", "backToDecks": "Retour aux decks",
"reverse": "Inverser", "allDone": "Terminé !",
"dictation": "Dictée", "allDoneDesc": "Vous avez révisé toutes les cartes dues.",
"noTextPairs": "Aucune paire de texte disponible", "reviewedCount": "{count} cartes révisées",
"disorder": "Désordre", "progress": "{current} / {total}",
"previous": "Précédent" "nextReview": "Prochaine révision",
"interval": "Intervalle",
"ease": "Facilité",
"lapses": "Oublis",
"showAnswer": "Afficher la réponse",
"again": "Encore",
"hard": "Difficile",
"good": "Bien",
"easy": "Facile",
"now": "maintenant",
"lessThanMinute": "<1 min",
"inMinutes": "{count} min",
"inHours": "{count}h",
"inDays": "{count}j",
"inMonths": "{count}mois",
"minutes": "<1 min",
"days": "{count}j",
"months": "{count}mois",
"minAbbr": "m",
"dayAbbr": "j"
}, },
"page": { "page": {
"unauthorized": "Vous n'êtes pas autorisé à accéder à ce dossier" "unauthorized": "Vous n'êtes pas autorisé à accéder à ce deck"
} }
}, },
"navbar": { "navbar": {
"title": "apprendre-langues", "title": "learn-languages",
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "Se connecter", "sign_in": "Connexion",
"profile": "Profil", "profile": "Profil",
"folders": "Dossiers", "folders": "Dossiers",
"explore": "Explorer", "explore": "Explorer",
"favorites": "Favoris" "favorites": "Favoris",
"settings": "Paramètres"
},
"ocr": {
"title": "Extraction OCR de vocabulaire",
"description": "Téléchargez des captures d'écran de tableaux de vocabulaire pour extraire les paires mot-définition",
"uploadImage": "Télécharger une image",
"dragDropHint": "Glissez-déposez une image ici, ou cliquez pour sélectionner",
"supportedFormats": "Supportés : JPG, PNG, WebP",
"selectDeck": "Sélectionner un deck",
"chooseDeck": "Choisissez a deck to save the extracted pairs",
"noDecks": "Aucun deck disponible. Please create a deck first.",
"languageHints": "Indices de langue (Optionnel)",
"sourceLanguageHint": "Langue source (ex : Anglais)",
"targetLanguageHint": "Langue cible/traduction (ex : Chinois)",
"process": "Traiter l'image",
"processing": "Traitement...",
"preview": "Aperçu",
"extractedPairs": "Paires extraites",
"word": "Mot",
"definition": "Définition",
"pairsCount": "{count} paires extraites",
"savePairs": "Sauvegarder dans le deck",
"saving": "Sauvegarde...",
"saved": "{count} paires sauvegardées dans {deck}",
"saveFailed": "Échec de la sauvegarde",
"noImage": "Veuillez first upload an image",
"noDeck": "Please select a deck",
"processingFailed": "Échec du traitement OCR",
"tryAgain": "Please try again with a clearer image",
"detectedLanguages": "Détecté : {source} → {target}"
}, },
"profile": { "profile": {
"myProfile": "Mon profil", "myProfile": "Mon profil",
"email": "E-mail : {email}", "email": "E-mail : {email}",
"logout": "Déconnexion" "logout": "Déconnexion"
}, },
"settings": {
"title": "Paramètres",
"themeColor": "Couleur du thème",
"themeColorDescription": "Choisissez votre couleur de thème préférée"
},
"srt_player": { "srt_player": {
"uploadVideo": "Télécharger la vidéo", "uploadVideo": "Télécharger la vidéo",
"uploadSubtitle": "Télécharger les sous-titres", "uploadSubtitle": "Télécharger les sous-titres",
@@ -239,6 +315,8 @@
}, },
"translator": { "translator": {
"detectLanguage": "détecter la langue", "detectLanguage": "détecter la langue",
"sourceLanguage": "langue source",
"auto": "Auto",
"generateIPA": "générer l'api", "generateIPA": "générer l'api",
"translateInto": "traduire en", "translateInto": "traduire en",
"chinese": "Chinois", "chinese": "Chinois",
@@ -347,11 +425,26 @@
"notSet": "Non défini", "notSet": "Non défini",
"memberSince": "Membre depuis", "memberSince": "Membre depuis",
"logout": "Déconnexion", "logout": "Déconnexion",
"folders": { "deleteAccount": {
"title": "Dossiers", "button": "Supprimer le compte",
"noFolders": "Pas encore de dossiers", "title": "Supprimer le compte",
"folderName": "Nom du dossier", "warning": "Cette action est irréversible. Toutes vos données seront définitivement supprimées.",
"totalPairs": "Total des paires", "warningDecks": "Tous vos decks et cartes",
"warningCards": "Tout votre progression d'apprentissage",
"warningHistory": "Tout votre historique de traduction et de dictionnaire",
"warningPermanent": "Cette action ne peut pas être annulée",
"confirmLabel": "Tapez votre nom d'utilisateur pour confirmer :",
"usernameMismatch": "Le nom d'utilisateur ne correspond pas",
"cancel": "Annuler",
"confirm": "Supprimer mon compte",
"success": "Compte supprimé avec succès",
"failed": "Échec de la suppression du compte"
},
"decks": {
"title": "Decks",
"noDecks": "Pas encore de decks",
"deckName": "Nom du deck",
"totalCards": "Total des cartes",
"createdAt": "Créé le", "createdAt": "Créé le",
"actions": "Actions", "actions": "Actions",
"view": "Voir" "view": "Voir"

View File

@@ -46,6 +46,15 @@
"unfavorite": "Rimuovi dai preferiti", "unfavorite": "Rimuovi dai preferiti",
"pleaseLogin": "Per favore accedi prima" "pleaseLogin": "Per favore accedi prima"
}, },
"decks": {
"title": "Mazzi",
"noDecks": "Nessun mazzo ancora",
"deckName": "Nome del mazzo",
"totalCards": "Totale carte",
"createdAt": "Creato il",
"actions": "Azioni",
"view": "Visualizza"
},
"folder_id": { "folder_id": {
"unauthorized": "Non sei il proprietario di questa cartella", "unauthorized": "Non sei il proprietario di questa cartella",
"back": "Indietro", "back": "Indietro",
@@ -157,6 +166,9 @@
"resetPasswordFailed": "Impossibile inviare email di reset", "resetPasswordFailed": "Impossibile inviare email di reset",
"resetPasswordEmailSent": "Email di reset inviata con successo", "resetPasswordEmailSent": "Email di reset inviata con successo",
"resetPasswordEmailSentHint": "Abbiamo inviato un link per reimpostare la password al tuo indirizzo email. Controlla la tua casella di posta.", "resetPasswordEmailSentHint": "Abbiamo inviato un link per reimpostare la password al tuo indirizzo email. Controlla la tua casella di posta.",
"verifyYourEmail": "Verifica la tua Email",
"verificationEmailSent": "Email di verifica inviata",
"verificationEmailSentHint": "Abbiamo inviato un'email di verifica a {email}. Clicca sul link nell'email per verificare il tuo account.",
"checkYourEmail": "Controlla la tua Email", "checkYourEmail": "Controlla la tua Email",
"backToLogin": "Torna al Login", "backToLogin": "Torna al Login",
"resetPassword": "Reimposta Password", "resetPassword": "Reimposta Password",
@@ -166,41 +178,105 @@
"requestNewToken": "Richiedi Nuovo Link di Reset", "requestNewToken": "Richiedi Nuovo Link di Reset",
"resetPasswordSuccess": "Password reimpostata con successo", "resetPasswordSuccess": "Password reimpostata con successo",
"resetPasswordSuccessTitle": "Reimpostazione Password Completata", "resetPasswordSuccessTitle": "Reimpostazione Password Completata",
"resetPasswordSuccessHint": "La tua password è stata reimpostata con successo. Ora puoi accedere con la tua nuova password." "resetPasswordSuccessHint": "La tua password è stata reimpostata con successo. Ora puoi accedere con la tua nuova password.",
"emailNotVerified": "Verifica il tuo indirizzo email",
"emailNotVerifiedHint": "Il tuo indirizzo email non è stato verificato. Controlla la tua casella di posta o richiedi una nuova email di verifica.",
"resendVerification": "Invia di nuovo email di verifica",
"resendSuccess": "Email di verifica inviata! Controlla la tua casella di posta.",
"resendFailed": "Impossibile inviare l'email di verifica"
}, },
"memorize": { "memorize": {
"folder_selector": { "deck_selector": {
"selectFolder": "Seleziona una cartella", "selectDeck": "Seleziona un mazzo",
"noFolders": "Nessuna cartella trovata", "noDecks": "Nessun mazzo trovato",
"folderInfo": "{id}. {name} ({count})" "goToDecks": "Vai ai mazzi",
"noCards": "Nessuna carta",
"new": "Nuove",
"learning": "In apprendimento",
"review": "Ripasso",
"due": "In scadenza"
}, },
"memorize": { "review": {
"answer": "Risposta", "loading": "Caricamento...",
"next": "Successivo", "backToDecks": "Torna ai mazzi",
"reverse": "Inverti", "allDone": "Fatto!",
"dictation": "Dettatura", "allDoneDesc": "Hai ripassato tutte le carte in scadenza.",
"noTextPairs": "Nessuna coppia di testo disponibile", "reviewedCount": "{count} carte ripassate",
"disorder": "Disordina", "progress": "{current} / {total}",
"previous": "Precedente" "nextReview": "Prossima revisione",
"interval": "Intervallo",
"ease": "Facilità",
"lapses": "Dimenticanze",
"showAnswer": "Mostra risposta",
"again": "Ancora",
"hard": "Difficile",
"good": "Bene",
"easy": "Facile",
"now": "ora",
"lessThanMinute": "<1 min",
"inMinutes": "{count} min",
"inHours": "{count}h",
"inDays": "{count}g",
"inMonths": "{count}mesi",
"minutes": "<1 min",
"days": "{count}g",
"months": "{count}mesi",
"minAbbr": "m",
"dayAbbr": "g"
}, },
"page": { "page": {
"unauthorized": "Non sei autorizzato ad accedere a questa cartella" "unauthorized": "Non sei autorizzato ad accedere a questo mazzo"
} }
}, },
"navbar": { "navbar": {
"title": "impara-lingue", "title": "learn-languages",
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "Accedi", "sign_in": "Accedi",
"profile": "Profilo", "profile": "Profilo",
"folders": "Cartelle", "folders": "Cartelle",
"explore": "Esplora", "explore": "Esplora",
"favorites": "Preferiti" "favorites": "Preferiti",
"settings": "Impostazioni"
},
"ocr": {
"title": "Estrazione vocaboli OCR",
"description": "Carica screenshot di tabelle di vocaboli per estrarre coppie parola-definizione",
"uploadImage": "Carica immagine",
"dragDropHint": "Trascina e rilascia un'immagine qui, o clicca per selezionare",
"supportedFormats": "Supportati: JPG, PNG, WebP",
"selectDeck": "Seleziona un mazzo",
"chooseDeck": "Scegli un mazzo per salvare le coppie estratte",
"noDecks": "Nessun mazzo disponibile. Creane prima un mazzo.",
"languageHints": "Suggerimenti lingua (Opzionale)",
"sourceLanguageHint": "Lingua sorgente (es: Inglese)",
"targetLanguageHint": "Lingua target/traduzione (es: Cinese)",
"process": "Elabora immagine",
"processing": "Elaborazione...",
"preview": "Anteprima",
"extractedPairs": "Coppie estratte",
"word": "Parola",
"definition": "Definizione",
"pairsCount": "{count} coppie estratte",
"savePairs": "Salva nel mazzo",
"saving": "Salvataggio...",
"saved": "{count} coppie salvate in {deck}",
"saveFailed": "Salvataggio fallito",
"noImage": "Carica prima un'immagine",
"noDeck": "Seleziona un mazzo",
"processingFailed": "Elaborazione OCR fallita",
"tryAgain": "Riprova con un'immagine più chiara",
"detectedLanguages": "Rilevato: {source} → {target}"
}, },
"profile": { "profile": {
"myProfile": "Il Mio Profilo", "myProfile": "Il Mio Profilo",
"email": "Email: {email}", "email": "Email: {email}",
"logout": "Esci" "logout": "Esci"
}, },
"settings": {
"title": "Impostazioni",
"themeColor": "Colore del tema",
"themeColorDescription": "Scegli il tuo colore del tema preferito"
},
"srt_player": { "srt_player": {
"uploadVideo": "Carica Video", "uploadVideo": "Carica Video",
"uploadSubtitle": "Carica Sottotitoli", "uploadSubtitle": "Carica Sottotitoli",
@@ -239,6 +315,8 @@
}, },
"translator": { "translator": {
"detectLanguage": "rileva lingua", "detectLanguage": "rileva lingua",
"sourceLanguage": "lingua di origine",
"auto": "Auto",
"generateIPA": "genera ipa", "generateIPA": "genera ipa",
"translateInto": "traduci in", "translateInto": "traduci in",
"chinese": "Cinese", "chinese": "Cinese",
@@ -347,11 +425,26 @@
"notSet": "Non Impostato", "notSet": "Non Impostato",
"memberSince": "Membro Dal", "memberSince": "Membro Dal",
"logout": "Esci", "logout": "Esci",
"folders": { "deleteAccount": {
"title": "Cartelle", "button": "Elimina Account",
"noFolders": "Nessuna cartella ancora", "title": "Elimina Account",
"folderName": "Nome Cartella", "warning": "Questa azione è irreversibile. Tutti i tuoi dati saranno eliminati definitivamente.",
"totalPairs": "Coppie Totali", "warningDecks": "Tutti i tuoi mazzi e le tue carte",
"warningCards": "Tutto il tuo progresso di apprendimento",
"warningHistory": "Tutto il tuo cronologia di traduzione e dizionario",
"warningPermanent": "Questa azione non può essere annullata",
"confirmLabel": "Digita il tuo nome utente per confermare:",
"usernameMismatch": "Il nome utente non corrisponde",
"cancel": "Annulla",
"confirm": "Elimina il mio account",
"success": "Account eliminato con successo",
"failed": "Impossibile eliminare l'account"
},
"decks": {
"title": "Mazzi",
"noDecks": "Nessun mazzo ancora",
"deckName": "Nome Mazzo",
"totalCards": "Carte Totali",
"createdAt": "Creata Il", "createdAt": "Creata Il",
"actions": "Azioni", "actions": "Azioni",
"view": "Visualizza" "view": "Visualizza"

View File

@@ -157,6 +157,9 @@
"resetPasswordFailed": "リセットメールの送信に失敗しました", "resetPasswordFailed": "リセットメールの送信に失敗しました",
"resetPasswordEmailSent": "リセットメールを送信しました", "resetPasswordEmailSent": "リセットメールを送信しました",
"resetPasswordEmailSentHint": "パスワードリセット用のリンクをメールでお送りしました。受信トレイをご確認ください。", "resetPasswordEmailSentHint": "パスワードリセット用のリンクをメールでお送りしました。受信トレイをご確認ください。",
"verifyYourEmail": "メールアドレスを確認",
"verificationEmailSent": "確認メールを送信しました",
"verificationEmailSentHint": "{email} に確認メールを送信しました。メール内のリンクをクリックしてアカウントを確認してください。",
"checkYourEmail": "メールをご確認ください", "checkYourEmail": "メールをご確認ください",
"backToLogin": "ログインに戻る", "backToLogin": "ログインに戻る",
"resetPassword": "パスワードをリセット", "resetPassword": "パスワードをリセット",
@@ -166,25 +169,54 @@
"requestNewToken": "新しいリセットリンクをリクエスト", "requestNewToken": "新しいリセットリンクをリクエスト",
"resetPasswordSuccess": "パスワードのリセットに成功しました", "resetPasswordSuccess": "パスワードのリセットに成功しました",
"resetPasswordSuccessTitle": "パスワードリセット完了", "resetPasswordSuccessTitle": "パスワードリセット完了",
"resetPasswordSuccessHint": "パスワードが正常にリセットされました。新しいパスワードでログインできます。" "resetPasswordSuccessHint": "パスワードが正常にリセットされました。新しいパスワードでログインできます。",
"emailNotVerified": "メールアドレスを確認してください",
"emailNotVerifiedHint": "メールアドレスが確認されていません。受信トレイをご確認いただくか、新しい確認メールをリクエストしてください。",
"resendVerification": "確認メールを再送信",
"resendSuccess": "確認メールを送信しました!受信トレイをご確認ください。",
"resendFailed": "確認メールの送信に失敗しました"
}, },
"memorize": { "memorize": {
"folder_selector": { "deck_selector": {
"selectFolder": "フォルダーを選択", "selectDeck": "デッキを選択",
"noFolders": "フォルダーが見つかりません", "noDecks": "デッキが見つかりません",
"folderInfo": "{id}. {name} ({count})" "goToDecks": "デッキへ移動",
"noCards": "カードなし",
"new": "新規",
"learning": "学習中",
"review": "復習",
"due": "予定"
}, },
"memorize": { "review": {
"answer": "答え", "loading": "読み込み中...",
"next": "次へ", "backToDecks": "デッキに戻る",
"reverse": "逆順", "allDone": "完了!",
"dictation": "書き取り", "allDoneDesc": "すべての復習カードが完了しました。",
"noTextPairs": "利用可能なテキストペアがありません", "reviewedCount": "{count} 枚のカードを復習",
"disorder": "シャッフル", "progress": "{current} / {total}",
"previous": "前へ" "nextReview": "次の復習",
"interval": "間隔",
"ease": "易しさ",
"lapses": "忘回数",
"showAnswer": "答えを表示",
"again": "もう一度",
"hard": "難しい",
"good": "普通",
"easy": "簡単",
"now": "今",
"lessThanMinute": "<1分",
"inMinutes": "{count}分",
"inHours": "{count}時間",
"inDays": "{count}日",
"inMonths": "{count}ヶ月",
"minutes": "<1分",
"days": "{count}日",
"months": "{count}ヶ月",
"minAbbr": "分",
"dayAbbr": "日"
}, },
"page": { "page": {
"unauthorized": "このフォルダーにアクセスする権限がありません" "unauthorized": "このデッキにアクセスする権限がありません"
} }
}, },
"navbar": { "navbar": {
@@ -194,13 +226,48 @@
"profile": "プロフィール", "profile": "プロフィール",
"folders": "フォルダー", "folders": "フォルダー",
"explore": "探索", "explore": "探索",
"favorites": "お気に入り" "favorites": "お気に入り",
"settings": "設定"
},
"ocr": {
"title": "OCR語彙抽出",
"description": "教科書の語彙表のスクリーンショットをアップロードして単語と定義のペアを抽出",
"uploadImage": "画像をアップロード",
"dragDropHint": "ここに画像をドラッグ&ドロップ、またはクリックして選択",
"supportedFormats": "対応形式JPG、PNG、WebP",
"selectDeck": "デッキを選択",
"chooseDeck": "抽出したペアを保存するデッキを選択",
"noDecks": "デッキがありません。まずデッキを作成してください。",
"languageHints": "言語ヒント(オプション)",
"sourceLanguageHint": "ソース言語(例:英語)",
"targetLanguageHint": "ターゲット/翻訳言語(例:中国語)",
"process": "画像を処理",
"processing": "処理中...",
"preview": "プレビュー",
"extractedPairs": "抽出されたペア",
"word": "単語",
"definition": "定義",
"pairsCount": "{count} ペアを抽出",
"savePairs": "デッキに保存",
"saving": "保存中...",
"saved": "{count} ペアを {deck} に保存しました",
"saveFailed": "保存に失敗しました",
"noImage": "先に画像をアップロードしてください",
"noDeck": "デッキを選択してください",
"processingFailed": "OCR処理に失敗しました",
"tryAgain": "より鮮明な画像でお試しください",
"detectedLanguages": "検出:{source} → {target}"
}, },
"profile": { "profile": {
"myProfile": "マイプロフィール", "myProfile": "マイプロフィール",
"email": "メール: {email}", "email": "メール: {email}",
"logout": "ログアウト" "logout": "ログアウト"
}, },
"settings": {
"title": "設定",
"themeColor": "テーマカラー",
"themeColorDescription": "お好みのテーマカラーを選択してください"
},
"srt_player": { "srt_player": {
"uploadVideo": "ビデオをアップロード", "uploadVideo": "ビデオをアップロード",
"uploadSubtitle": "字幕をアップロード", "uploadSubtitle": "字幕をアップロード",
@@ -239,6 +306,8 @@
}, },
"translator": { "translator": {
"detectLanguage": "言語を検出", "detectLanguage": "言語を検出",
"sourceLanguage": "ソース言語",
"auto": "自動",
"generateIPA": "ipaを生成", "generateIPA": "ipaを生成",
"translateInto": "翻訳先", "translateInto": "翻訳先",
"chinese": "中国語", "chinese": "中国語",
@@ -347,11 +416,26 @@
"notSet": "未設定", "notSet": "未設定",
"memberSince": "登録日", "memberSince": "登録日",
"logout": "ログアウト", "logout": "ログアウト",
"folders": { "deleteAccount": {
"title": "フォルダー", "button": "アカウント削除",
"noFolders": "まだフォルダーがありません", "title": "アカウント削除",
"folderName": "フォルダー名", "warning": "この操作は取り消せません。すべてのデータが完全に削除されます。",
"totalPairs": "合計ペア数", "warningDecks": "すべてのデッキとカード",
"warningCards": "すべての学習履歴",
"warningHistory": "すべての翻訳と辞書の履歴",
"warningPermanent": "この操作は取り消せません",
"confirmLabel": "確認のためユーザー名を入力してください:",
"usernameMismatch": "ユーザー名が一致しません",
"cancel": "キャンセル",
"confirm": "アカウントを削除する",
"success": "アカウントが正常に削除されました",
"failed": "アカウントの削除に失敗しました"
},
"decks": {
"title": "デッキ",
"noDecks": "まだデッキがありません",
"deckName": "デッキ名",
"totalCards": "合計カード数",
"createdAt": "作成日", "createdAt": "作成日",
"actions": "アクション", "actions": "アクション",
"view": "表示" "view": "表示"

View File

@@ -46,6 +46,15 @@
"unfavorite": "즐겨찾기 해제", "unfavorite": "즐겨찾기 해제",
"pleaseLogin": "먼저 로그인해주세요" "pleaseLogin": "먼저 로그인해주세요"
}, },
"decks": {
"title": "덱",
"noDecks": "덱이 없습니다",
"deckName": "덱 이름",
"totalCards": "총 카드",
"createdAt": "생성일",
"actions": "작업",
"view": "보기"
},
"folder_id": { "folder_id": {
"unauthorized": "이 폴더의 소유자가 아닙니다", "unauthorized": "이 폴더의 소유자가 아닙니다",
"back": "뒤로", "back": "뒤로",
@@ -157,6 +166,9 @@
"resetPasswordFailed": "재설정 이메일 전송 실패", "resetPasswordFailed": "재설정 이메일 전송 실패",
"resetPasswordEmailSent": "재설정 이메일이 전송되었습니다", "resetPasswordEmailSent": "재설정 이메일이 전송되었습니다",
"resetPasswordEmailSentHint": "비밀번호 재설정 링크를 이메일로 보냈습니다. 받은 편지함을 확인해주세요.", "resetPasswordEmailSentHint": "비밀번호 재설정 링크를 이메일로 보냈습니다. 받은 편지함을 확인해주세요.",
"verifyYourEmail": "이메일 인증",
"verificationEmailSent": "인증 이메일이 전송되었습니다",
"verificationEmailSentHint": "{email}로 인증 이메일을 보냈습니다. 이메일의 링크를 클릭하여 계정을 인증해주세요.",
"checkYourEmail": "이메일을 확인하세요", "checkYourEmail": "이메일을 확인하세요",
"backToLogin": "로그인으로 돌아가기", "backToLogin": "로그인으로 돌아가기",
"resetPassword": "비밀번호 재설정", "resetPassword": "비밀번호 재설정",
@@ -166,25 +178,54 @@
"requestNewToken": "새 재설정 링크 요청", "requestNewToken": "새 재설정 링크 요청",
"resetPasswordSuccess": "비밀번호 재설정 성공", "resetPasswordSuccess": "비밀번호 재설정 성공",
"resetPasswordSuccessTitle": "비밀번호 재설정 완료", "resetPasswordSuccessTitle": "비밀번호 재설정 완료",
"resetPasswordSuccessHint": "비밀번호가 성공적으로 재설정되었습니다. 새 비밀번호로 로그인할 수 있습니다." "resetPasswordSuccessHint": "비밀번호가 성공적으로 재설정되었습니다. 새 비밀번호로 로그인할 수 있습니다.",
"emailNotVerified": "이메일 주소를 인증해 주세요",
"emailNotVerifiedHint": "이메일이 인증되지 않았습니다. 받은 편지함을 확인하거나 새 인증 이메일을 요청해 주세요.",
"resendVerification": "인증 이메일 다시 보내기",
"resendSuccess": "인증 이메일이 발송되었습니다! 받은 편지함을 확인해 주세요.",
"resendFailed": "인증 이메일 발송에 실패했습니다"
}, },
"memorize": { "memorize": {
"folder_selector": { "deck_selector": {
"selectFolder": "폴더 선택", "selectDeck": " 선택",
"noFolders": "폴더를 찾을 수 없습니다", "noDecks": "덱을 찾을 수 없습니다",
"folderInfo": "{id}. {name} ({count})" "goToDecks": "덱으로 이동",
"noCards": "카드 없음",
"new": "새 카드",
"learning": "학습 중",
"review": "복습",
"due": "예정"
}, },
"memorize": { "review": {
"answer": "정답", "loading": "로딩 중...",
"next": "다음", "backToDecks": "덱으로 돌아가기",
"reverse": "반대", "allDone": "완료!",
"dictation": "받아쓰기", "allDoneDesc": "모든 복습 카드를 완료했습니다.",
"noTextPairs": "사용 가능한 텍스트 쌍이 없습니다", "reviewedCount": "{count}장의 카드 복습함",
"disorder": "무작위", "progress": "{current} / {total}",
"previous": "이전" "nextReview": "다음 복습",
"interval": "간격",
"ease": "난이도",
"lapses": "망각 횟수",
"showAnswer": "정답 보기",
"again": "다시",
"hard": "어려움",
"good": "보통",
"easy": "쉬움",
"now": "지금",
"lessThanMinute": "<1분",
"inMinutes": "{count}분",
"inHours": "{count}시간",
"inDays": "{count}일",
"inMonths": "{count}개월",
"minutes": "<1분",
"days": "{count}일",
"months": "{count}개월",
"minAbbr": "분",
"dayAbbr": "일"
}, },
"page": { "page": {
"unauthorized": "이 폴더에 접근할 권한이 없습니다" "unauthorized": "이 에 접근할 권한이 없습니다"
} }
}, },
"navbar": { "navbar": {
@@ -194,13 +235,48 @@
"profile": "프로필", "profile": "프로필",
"folders": "폴더", "folders": "폴더",
"explore": "탐색", "explore": "탐색",
"favorites": "즐겨찾기" "favorites": "즐겨찾기",
"settings": "설정"
},
"ocr": {
"title": "OCR 어휘 추출",
"description": "교과서 어휘표 스크린샷 only어업로드하여 단어-정의 쌍 추출",
"uploadImage": "이미지 업로드",
"dragDropHint": "이미지를 여기에 끌어다 놓거나 클릭하여 선택",
"supportedFormats": "지원 형식: JPG, PNG, WebP",
"selectDeck": "덱 선택",
"chooseDeck": "추출된 쌍을 저장할 덱 선택",
"noDecks": "덱이 없습니다. 먼저 덱을 만드세요.",
"languageHints": "언어 힌트 (선택사항)",
"sourceLanguageHint": "소스 언어 (예: 영어)",
"targetLanguageHint": "대상/번역 언어 (예: 중국어)",
"process": "이미지 처리",
"processing": "처리中...",
"preview": "미리보기",
"extractedPairs": "추출된 쌍",
"word": "단어",
"definition": "정의",
"pairsCount": "{count} 쌍 추출됨",
"savePairs": "덱에 저장",
"saving": "저장中...",
"saved": "{deck}에 {count} 쌍 저장 완료",
"saveFailed": "저장 실패",
"noImage": "먼저 이미지를 업로드하세요",
"noDeck": "덱을 선택하세요",
"processingFailed": "OCR 처리 실패",
"tryAgain": "더 선晰的图像로 다시 시도하세요",
"detectedLanguages": "감지됨: {source} → {target}"
}, },
"profile": { "profile": {
"myProfile": "내 프로필", "myProfile": "내 프로필",
"email": "이메일: {email}", "email": "이메일: {email}",
"logout": "로그아웃" "logout": "로그아웃"
}, },
"settings": {
"title": "설정",
"themeColor": "테마 색상",
"themeColorDescription": "원하는 테마 색상을 선택하세요"
},
"srt_player": { "srt_player": {
"uploadVideo": "비디오 업로드", "uploadVideo": "비디오 업로드",
"uploadSubtitle": "자막 업로드", "uploadSubtitle": "자막 업로드",
@@ -239,6 +315,8 @@
}, },
"translator": { "translator": {
"detectLanguage": "언어 감지", "detectLanguage": "언어 감지",
"sourceLanguage": "원본 언어",
"auto": "자동",
"generateIPA": "IPA 생성", "generateIPA": "IPA 생성",
"translateInto": "번역할 언어", "translateInto": "번역할 언어",
"chinese": "중국어", "chinese": "중국어",
@@ -347,11 +425,26 @@
"notSet": "설정되지 않음", "notSet": "설정되지 않음",
"memberSince": "가입일", "memberSince": "가입일",
"logout": "로그아웃", "logout": "로그아웃",
"deleteAccount": {
"button": "계정 삭제",
"title": "계정 삭제",
"warning": "이 작업은 되돌릴 수 없습니다. 모든 데이터가 영구적으로 삭제됩니다.",
"warningDecks": "모든 덱과 카드",
"warningCards": "모든 학습 진행 상황",
"warningHistory": "모든 번역 및 사전 기록",
"warningPermanent": "이 작업은 취소할 수 없습니다",
"confirmLabel": "확인을 위해 사용자명을 입력하세요:",
"usernameMismatch": "사용자명이 일치하지 않습니다",
"cancel": "취소",
"confirm": "내 계정 삭제",
"success": "계정이 성공적으로 삭제되었습니다",
"failed": "계정 삭제에 실패했습니다"
},
"folders": { "folders": {
"title": "폴더", "title": "",
"noFolders": "아직 폴더가 없습니다", "noFolders": "아직 덱이 없습니다",
"folderName": "폴더 이름", "folderName": " 이름",
"totalPairs": "총 ", "totalPairs": "총 카드 수",
"createdAt": "생성일", "createdAt": "생성일",
"actions": "작업", "actions": "작업",
"view": "보기" "view": "보기"

View File

@@ -46,6 +46,15 @@
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل", "unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ" "pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ"
}, },
"decks": {
"title": "دېكلار",
"noDecks": "تېخى دېك يوق",
"deckName": "دېك ئاتى",
"totalCards": "جەمئىي كارتا",
"createdAt": "قۇرۇلغان ۋاقتى",
"actions": "مەشغۇلاتلار",
"view": "كۆرۈش"
},
"folder_id": { "folder_id": {
"unauthorized": "بۇ قىسقۇچنىڭ ئىگىسى ئەمەسسىز", "unauthorized": "بۇ قىسقۇچنىڭ ئىگىسى ئەمەسسىز",
"back": "قايتىش", "back": "قايتىش",
@@ -157,6 +166,9 @@
"resetPasswordFailed": "ئەسلىگە قايتۇرۇش ئېلخېتى ئەۋەتىش مەغلۇپ بولدى", "resetPasswordFailed": "ئەسلىگە قايتۇرۇش ئېلخېتى ئەۋەتىش مەغلۇپ بولدى",
"resetPasswordEmailSent": "ئەسلىگە قايتۇرۇش ئېلخېتى مۇۋەپپەقىيەتلىك ئەۋەتىلدى", "resetPasswordEmailSent": "ئەسلىگە قايتۇرۇش ئېلخېتى مۇۋەپپەقىيەتلىك ئەۋەتىلدى",
"resetPasswordEmailSentHint": "پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسىنى ئېلخەت ئادرېسىڭىزغا ئەۋەتتۇق. ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ.", "resetPasswordEmailSentHint": "پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسىنى ئېلخەت ئادرېسىڭىزغا ئەۋەتتۇق. ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ.",
"verifyYourEmail": "ئېلخەتنى دەلىللەش",
"verificationEmailSent": "دەلىللەش ئېلخېتى ئەۋەتىلدى",
"verificationEmailSentHint": "{email} غا دەلىللەش ئېلخېتى ئەۋەتتۇق. ئېلخەتتىكى ئۇلانمىنى چېكىپ ھېساباتىڭىزنى دەلىللەڭ.",
"checkYourEmail": "ئېلخېتىڭىزنى تەكشۈرۈڭ", "checkYourEmail": "ئېلخېتىڭىزنى تەكشۈرۈڭ",
"backToLogin": "كىرىشكە قايتىش", "backToLogin": "كىرىشكە قايتىش",
"resetPassword": "پارولنى ئەسلىگە قايتۇرۇش", "resetPassword": "پارولنى ئەسلىگە قايتۇرۇش",
@@ -166,41 +178,105 @@
"requestNewToken": "يېڭى ئەسلىگە قايتۇرۇش ئۇلانمىسى سوراش", "requestNewToken": "يېڭى ئەسلىگە قايتۇرۇش ئۇلانمىسى سوراش",
"resetPasswordSuccess": "پارول مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى", "resetPasswordSuccess": "پارول مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى",
"resetPasswordSuccessTitle": "پارول ئەسلىگە قايتۇرۇش تاماملاندى", "resetPasswordSuccessTitle": "پارول ئەسلىگە قايتۇرۇش تاماملاندى",
"resetPasswordSuccessHint": "پارولىڭىز مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى. يېڭى پارول بىلەن كىرسىڭىز بولىدۇ." "resetPasswordSuccessHint": "پارولىڭىز مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى. يېڭى پارول بىلەن كىرسىڭىز بولىدۇ.",
"emailNotVerified": "ئېلخەت ئادرېسىڭىزنى دەلىللەڭ",
"emailNotVerifiedHint": "ئېلخەت ئادرېسىڭىز دەلىللەنمىگەن. ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ ياكى يېڭى دەلىللەش ئېلخېتى سوراڭ.",
"resendVerification": "دەلىللەش ئېلخېتىنى قايتا ئەۋەتىش",
"resendSuccess": "دەلىللەش ئېلخېتى ئەۋەتىلدى! ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ.",
"resendFailed": "دەلىللەش ئېلخېتى ئەۋەتىش مەغلۇپ بولدى"
}, },
"memorize": { "memorize": {
"folder_selector": { "deck_selector": {
"selectFolder": "بىر قىسقۇچ تاللاڭ", "selectDeck": "بىر دېك تاللاڭ",
"noFolders": "قىسقۇچ تېپىلمىدى", "noDecks": "دېك تېپىلمىدى",
"folderInfo": "{id}. {name} ({count})" "goToDecks": "دېكلارغا بېرىڭ",
"noCards": "كارتا يوق",
"new": "يېڭى",
"learning": "ئۆگىنىۋاتىدۇ",
"review": "تەكرار",
"due": "ۋاقتى كەلدى"
}, },
"memorize": { "review": {
"answer": "جاۋاب", "loading": "يۈكلىنىۋاتىدۇ...",
"next": "كېيىنكى", "backToDecks": "دېكلارغا قايتىڭ",
"reverse": ەتۈر", "allDone": امام!",
"dictation": "دىكتات", "allDoneDesc": "بارلىق تەكرارلاش كارتلىرى تاماملاندى.",
"noTextPairs": "تېكىست جۈپى يوق", "reviewedCount": "{count} كارتا تەكرارلاندى",
"disorder": "قالايمىقانلاشتۇرۇش", "progress": "{current} / {total}",
"previous": "ئالدىنقى" "nextReview": "كېيىنكى تەكرار",
"interval": "ئارىلىق",
"ease": "ئاسانلىق",
"lapses": "ئۇنتۇش سانى",
"showAnswer": "جاۋابنى كۆرسەت",
"again": "يەنە",
"hard": "قىيىن",
"good": "ياخشى",
"easy": "ئاسان",
"now": "ھازىر",
"lessThanMinute": "<1 مىنۇت",
"inMinutes": "{count} مىنۇت",
"inHours": "{count} سائەت",
"inDays": "{count} كۈن",
"inMonths": "{count} ئاي",
"minutes": "<1 مىنۇت",
"days": "{count} كۈن",
"months": "{count} ئاي",
"minAbbr": "م",
"dayAbbr": "ك"
}, },
"page": { "page": {
"unauthorized": "بۇ قىسقۇچنى زىيارەت قىلىش ھوقۇقىڭىز يوق" "unauthorized": "بۇ دېكنى زىيارەت قىلىش ھوقۇقىڭىز يوق"
} }
}, },
"navbar": { "navbar": {
"title": "تىل-ئۆگىنىش", "title": "learn-languages",
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "كىرىش", "sign_in": "كىرىش",
"profile": "شەخسىي ئۇچۇر", "profile": "شەخسىي ئۇچۇر",
"folders": "قىسقۇچلار", "folders": "قىسقۇچلار",
"explore": "ئىزدىنىش", "explore": "ئىزدىنىش",
"favorites": "يىغىپ ساقلانغانلار" "favorites": "يىغىپ ساقلاش",
"settings": "تەڭشەكلەر"
},
"ocr": {
"title": "OCR سۆز ئاستىرىش",
"description": "دەرىسلىك كىتابىدىكى سۆز جەدۋىلى سۈرەتلىرىنى يۈكلەپ سۆز-مەنا جۈپلىرىنى ئاستىرىڭ",
"uploadImage": "سۈرەت يۈكلەش",
"dragDropHint": "سۈرەتنى بۇ يەرگە سۆرەڭ ياكى چېكىپ تاللاڭ",
"supportedFormats": "قوللايدىغان فورماتلار: JPG، PNG، WebP",
"selectDeck": "دېك تاللاش",
"chooseDeck": "ئاستىرىلغان جۈپلەرنى ساقلاش ئۈچۈن دېك تاللاڭ",
"noDecks": "دېك يوق. ئاۋۋال دېك قۇرۇڭ.",
"languageHints": "تىل ئۇچۇرلىرى (ئىختىيارىي)",
"sourceLanguageHint": "مەنبە تىلى (مىسال: ئىنگىلىزچە)",
"targetLanguageHint": "نىشان/تەرجىمە تىلى (مىسال: خەنزۇچە)",
"process": "سۈرەتنى بىر تەرەپ قىلىش",
"processing": "بىر تەرەپ قىلىۋاتىدۇ...",
"preview": "ئالدىن كۆرۈش",
"extractedPairs": "ئاستىرىلغان جۈپلەر",
"word": "سۆز",
"definition": "مەنا",
"pairsCount": "{count} جۈپ ئاستىرىلدى",
"savePairs": "دېككە ساقلاش",
"saving": "ساقلاۋاتىدۇ...",
"saved": "{deck} غا {count} جۈپ ساقلاندى",
"saveFailed": "ساقلاش مەغلۇپ بولدى",
"noImage": "ئاۋۋال سۈرەت يۈكلەڭ",
"noDeck": "دېك تاللاڭ",
"processingFailed": "OCR بىر تەرەپ قىلىش مەغلۇپ بولدى",
"tryAgain": "تېخىمۇ ئېنىق سۈرەت بىلەن قايتا سىناڭ",
"detectedLanguages": "بايقالدى: {source} → {target}"
}, },
"profile": { "profile": {
"myProfile": "شەخسىي ئۇچۇرۇم", "myProfile": "شەخسىي ئۇچۇرۇم",
"email": "ئېلخەت: {email}", "email": "ئېلخەت: {email}",
"logout": "چىكىنىش" "logout": "چىكىنىش"
}, },
"settings": {
"title": "تەڭشەكلەر",
"themeColor": "تېما رەڭگى",
"themeColorDescription": "ياقتۇرىدىغان تېما رەڭگىڭىزنى تاللاڭ"
},
"srt_player": { "srt_player": {
"uploadVideo": "ۋىدېئو يۈكلەش", "uploadVideo": "ۋىدېئو يۈكلەش",
"uploadSubtitle": "تر پودكاست يۈكلەش", "uploadSubtitle": "تر پودكاست يۈكلەش",
@@ -239,6 +315,8 @@
}, },
"translator": { "translator": {
"detectLanguage": "تىلنى تونۇش", "detectLanguage": "تىلنى تونۇش",
"sourceLanguage": "مەنبە تىلى",
"auto": "ئاپتوماتىك",
"generateIPA": "ipa ھاسىل قىلىش", "generateIPA": "ipa ھاسىل قىلىش",
"translateInto": "تەرجىمە قىلىش", "translateInto": "تەرجىمە قىلىش",
"chinese": "خەنزۇچە", "chinese": "خەنزۇچە",
@@ -347,11 +425,26 @@
"notSet": "تەڭشەلمىگەن", "notSet": "تەڭشەلمىگەن",
"memberSince": "ئەزا بولغاندىن بېرى", "memberSince": "ئەزا بولغاندىن بېرى",
"logout": "چىكىنىش", "logout": "چىكىنىش",
"folders": { "deleteAccount": {
"title": "قىسقۇچلار", "button": "ھېساباتنى ئۆچۈرۈش",
"noFolders": "تېخى قىسقۇچ يوق", "title": "ھېساباتنى ئۆچۈرۈش",
"folderName": "قىسقۇچ ئاتى", "warning": "بۇ مەشغۇلاتنى ئەسلىگە قايتۇرغىلى بولمايدۇ. بارلىق سانلىق مەلۇماتلىرىڭىز مەڭگۈلۈك ئۆچۈرۈلىدۇ.",
"totalPairs": "جەمئىي جۈپ", "warningDecks": "بارلىق دېك ۋە كارتلىرىڭىز",
"warningCards": "بارلىق ئۆگىنىش ئىلگىرىلەشلىرىڭىز",
"warningHistory": "بارلىق تەرجىمە ۋە لۇغەت تارىخىڭىز",
"warningPermanent": "بۇ مەشغۇلاتنى بىكار قىلغىلى بولمايدۇ",
"confirmLabel": "جەزىملەش ئۈچۈن ئىشلەتكۈچى ئاتىڭىزنى كىرگۈزۈڭ:",
"usernameMismatch": "ئىشلەتكۈچى ئاتى ماس كەلمەيدۇ",
"cancel": "بىكار قىلىش",
"confirm": "ھېساباتىمنى ئۆچۈرۈش",
"success": "ھېسابات مۇۋەپپەقىيەتلىك ئۆچۈرۈلدى",
"failed": "ھېساباتنى ئۆچۈرۈش مەغلۇپ بولدى"
},
"decks": {
"title": "دېكلار",
"noDecks": "تېخى دېك يوق",
"deckName": "دېك ئاتى",
"totalCards": "جەمئىي كارتا",
"createdAt": "قۇرۇلغان ۋاقتى", "createdAt": "قۇرۇلغان ۋاقتى",
"actions": "مەشغۇلاتلار", "actions": "مەشغۇلاتلار",
"view": "كۆرۈش" "view": "كۆرۈش"

View File

@@ -157,6 +157,9 @@
"resetPasswordFailed": "发送重置邮件失败", "resetPasswordFailed": "发送重置邮件失败",
"resetPasswordEmailSent": "重置邮件已发送", "resetPasswordEmailSent": "重置邮件已发送",
"resetPasswordEmailSentHint": "我们已向您的邮箱发送了密码重置链接,请查收。", "resetPasswordEmailSentHint": "我们已向您的邮箱发送了密码重置链接,请查收。",
"verifyYourEmail": "验证您的邮箱",
"verificationEmailSent": "验证邮件已发送",
"verificationEmailSentHint": "我们已向 {email} 发送了验证邮件,请点击邮件中的链接完成验证。",
"checkYourEmail": "请查收邮件", "checkYourEmail": "请查收邮件",
"backToLogin": "返回登录", "backToLogin": "返回登录",
"resetPassword": "重置密码", "resetPassword": "重置密码",
@@ -166,25 +169,54 @@
"requestNewToken": "重新申请重置链接", "requestNewToken": "重新申请重置链接",
"resetPasswordSuccess": "密码重置成功", "resetPasswordSuccess": "密码重置成功",
"resetPasswordSuccessTitle": "密码重置完成", "resetPasswordSuccessTitle": "密码重置完成",
"resetPasswordSuccessHint": "您的密码已成功重置,现在可以使用新密码登录了。" "resetPasswordSuccessHint": "您的密码已成功重置,现在可以使用新密码登录了。",
"emailNotVerified": "请验证您的邮箱地址",
"emailNotVerifiedHint": "您的邮箱尚未验证。请检查收件箱或重新发送验证邮件。",
"resendVerification": "重新发送验证邮件",
"resendSuccess": "验证邮件已发送!请检查您的收件箱。",
"resendFailed": "发送验证邮件失败"
}, },
"memorize": { "memorize": {
"folder_selector": { "deck_selector": {
"selectFolder": "选择文件夹", "selectDeck": "选择牌组",
"noFolders": "未找到文件夹", "noDecks": "未找到牌组",
"folderInfo": "{id}. {name} ({count})" "goToDecks": "前往牌组",
"noCards": "无卡片",
"new": "新卡片",
"learning": "学习中",
"review": "复习",
"due": "待复习"
}, },
"memorize": { "review": {
"answer": "答案", "loading": "加载中...",
"next": "下一个", "backToDecks": "返回牌组",
"reverse": "反向", "allDone": "全部完成!",
"dictation": "听写", "allDoneDesc": "您已完成所有待复习卡片。",
"noTextPairs": "没有可用的文本对", "reviewedCount": "已复习 {count} 张卡片",
"disorder": "乱序", "progress": "{current} / {total}",
"previous": "上一个" "nextReview": "下次复习",
"interval": "间隔",
"ease": "难度系数",
"lapses": "遗忘次数",
"showAnswer": "显示答案",
"again": "重来",
"hard": "困难",
"good": "良好",
"easy": "简单",
"now": "现在",
"lessThanMinute": "<1 分钟",
"inMinutes": "{count} 分钟",
"inHours": "{count} 小时",
"inDays": "{count} 天",
"inMonths": "{count} 个月",
"minutes": "<1 分钟",
"days": "{count} 天",
"months": "{count} 个月",
"minAbbr": "分",
"dayAbbr": "天"
}, },
"page": { "page": {
"unauthorized": "您无权访问该文件夹" "unauthorized": "您无权访问该牌组"
} }
}, },
"navbar": { "navbar": {
@@ -194,13 +226,62 @@
"profile": "个人资料", "profile": "个人资料",
"folders": "文件夹", "folders": "文件夹",
"explore": "探索", "explore": "探索",
"favorites": "收藏" "favorites": "收藏",
"settings": "设置"
},
"ocr": {
"title": "OCR 词汇提取",
"description": "上传教材词汇表截图,提取单词-释义对",
"uploadSection": "上传图片",
"uploadImage": "上传图片",
"dragDropHint": "拖放图片到此处,或点击选择",
"dropOrClick": "拖放图片到此处,或点击选择",
"changeImage": "点击更换图片",
"supportedFormats": "支持格式JPG、PNG、WebP",
"deckSelection": "选择牌组",
"selectDeck": "选择牌组",
"chooseDeck": "选择保存提取词汇的牌组",
"noDecks": "暂无牌组,请先创建牌组",
"languageHints": "语言提示(可选)",
"sourceLanguageHint": "源语言(如:英语)",
"targetLanguageHint": "目标/翻译语言(如:中文)",
"sourceLanguagePlaceholder": "源语言(如:英语)",
"targetLanguagePlaceholder": "目标/翻译语言(如:中文)",
"process": "处理图片",
"processButton": "处理图片",
"processing": "处理中...",
"preview": "预览",
"resultsPreview": "结果预览",
"extractedPairs": "已提取 {count} 个词汇对",
"word": "单词",
"definition": "释义",
"pairsCount": "{count} 个词汇对",
"savePairs": "保存到牌组",
"saveButton": "保存",
"saving": "保存中...",
"saved": "成功将 {count} 个词汇对保存到 {deck}",
"ocrSuccess": "成功将 {count} 个词汇对保存到 {deck}",
"savedToDeck": "已保存到 {deckName}",
"saveFailed": "保存失败",
"noImage": "请先上传图片",
"noDeck": "请选择牌组",
"noResultsToSave": "没有可保存的结果",
"processingFailed": "OCR 处理失败",
"tryAgain": "请尝试上传更清晰的图片",
"detectedLanguages": "检测到:{source} → {target}",
"detectedSourceLanguage": "检测到的源语言",
"detectedTargetLanguage": "检测到的目标语言"
}, },
"profile": { "profile": {
"myProfile": "我的个人资料", "myProfile": "我的个人资料",
"email": "邮箱:{email}", "email": "邮箱:{email}",
"logout": "退出登录" "logout": "退出登录"
}, },
"settings": {
"title": "设置",
"themeColor": "主题色",
"themeColorDescription": "选择您喜欢的主题色"
},
"srt_player": { "srt_player": {
"upload": "上传", "upload": "上传",
"uploadVideo": "上传视频", "uploadVideo": "上传视频",
@@ -239,6 +320,8 @@
}, },
"translator": { "translator": {
"detectLanguage": "检测语言", "detectLanguage": "检测语言",
"sourceLanguage": "源语言",
"auto": "自动",
"generateIPA": "生成国际音标", "generateIPA": "生成国际音标",
"translateInto": "翻译为", "translateInto": "翻译为",
"chinese": "中文", "chinese": "中文",
@@ -301,11 +384,11 @@
}, },
"explore": { "explore": {
"title": "探索", "title": "探索",
"subtitle": "发现公开文件夹", "subtitle": "发现公开牌组",
"searchPlaceholder": "搜索公开文件夹...", "searchPlaceholder": "搜索公开牌组...",
"loading": "加载中...", "loading": "加载中...",
"noFolders": "没有找到公开文件夹", "noDecks": "没有找到公开牌组",
"folderInfo": "{userName} • {totalPairs} 个文本对", "deckInfo": "{userName} • {cardCount} 张卡片",
"unknownUser": "未知用户", "unknownUser": "未知用户",
"favorite": "收藏", "favorite": "收藏",
"unfavorite": "取消收藏", "unfavorite": "取消收藏",
@@ -314,10 +397,10 @@
"sortByFavoritesActive": "取消按收藏数排序" "sortByFavoritesActive": "取消按收藏数排序"
}, },
"exploreDetail": { "exploreDetail": {
"title": "文件夹详情", "title": "牌组详情",
"createdBy": "创建者:{name}", "createdBy": "创建者:{name}",
"unknownUser": "未知用户", "unknownUser": "未知用户",
"totalPairs": "词对数量", "totalCards": "卡片数量",
"favorites": "收藏数", "favorites": "收藏数",
"createdAt": "创建时间", "createdAt": "创建时间",
"viewContent": "查看内容", "viewContent": "查看内容",
@@ -346,15 +429,40 @@
"displayName": "显示名称", "displayName": "显示名称",
"notSet": "未设置", "notSet": "未设置",
"memberSince": "注册时间", "memberSince": "注册时间",
"joined": "加入于",
"logout": "登出", "logout": "登出",
"folders": { "deleteAccount": {
"title": "文件夹", "button": "注销账号",
"noFolders": "还没有文件夹", "title": "注销账号",
"folderName": "文件夹名称", "warning": "此操作不可逆,您的所有数据将被永久删除。",
"totalPairs": "文本对数量", "warningDecks": "您的所有牌组和卡片",
"warningCards": "您的所有学习进度",
"warningHistory": "您的所有翻译和词典历史",
"warningPermanent": "此操作无法撤销",
"confirmLabel": "输入您的用户名以确认:",
"usernameMismatch": "用户名不匹配",
"cancel": "取消",
"confirm": "注销我的账号",
"success": "账号已成功注销",
"failed": "注销账号失败"
},
"decks": {
"title": "牌组",
"noDecks": "还没有牌组",
"deckName": "牌组名称",
"totalCards": "卡片数量",
"createdAt": "创建时间", "createdAt": "创建时间",
"actions": "操作", "actions": "操作",
"view": "查看" "view": "查看"
} }
},
"follow": {
"follow": "关注",
"following": "已关注",
"followers": "粉丝",
"followersOf": "{username} 的粉丝",
"followingOf": "{username} 的关注",
"noFollowers": "还没有粉丝",
"noFollowing": "还没有关注任何人"
} }
} }

View File

@@ -18,6 +18,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"jszip": "^3.10.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"next": "16.1.1", "next": "16.1.1",
"next-intl": "^4.7.0", "next-intl": "^4.7.0",
@@ -27,6 +28,7 @@
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"sql.js": "^1.14.1",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"unstorage": "^1.17.3", "unstorage": "^1.17.3",
"winston": "^3.19.0", "winston": "^3.19.0",
@@ -41,6 +43,7 @@
"@types/nodemailer": "^7.0.11", "@types/nodemailer": "^7.0.11",
"@types/react": "19.2.7", "@types/react": "19.2.7",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@types/sql.js": "^1.4.9",
"@typescript-eslint/eslint-plugin": "^8.51.0", "@typescript-eslint/eslint-plugin": "^8.51.0",
"@typescript-eslint/parser": "^8.51.0", "@typescript-eslint/parser": "^8.51.0",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",

121
pnpm-lock.yaml generated
View File

@@ -23,7 +23,7 @@ importers:
version: 3.0.3 version: 3.0.3
better-auth: better-auth:
specifier: ^1.4.10 specifier: ^1.4.10
version: 1.4.10(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 1.4.10(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
class-variance-authority: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
@@ -33,6 +33,9 @@ importers:
dotenv: dotenv:
specifier: ^17.2.3 specifier: ^17.2.3
version: 17.2.3 version: 17.2.3
jszip:
specifier: ^3.10.1
version: 3.10.1
lucide-react: lucide-react:
specifier: ^0.562.0 specifier: ^0.562.0
version: 0.562.0(react@19.2.3) version: 0.562.0(react@19.2.3)
@@ -60,6 +63,9 @@ importers:
sonner: sonner:
specifier: ^2.0.7 specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
sql.js:
specifier: ^1.14.1
version: 1.14.1
tailwind-merge: tailwind-merge:
specifier: ^3.4.0 specifier: ^3.4.0
version: 3.4.0 version: 3.4.0
@@ -78,7 +84,7 @@ importers:
devDependencies: devDependencies:
'@better-auth/cli': '@better-auth/cli':
specifier: ^1.4.10 specifier: ^1.4.10
version: 1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@types/react@19.2.7)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(mysql2@3.15.3)(nanostores@1.1.0)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(mysql2@3.15.3)(nanostores@1.1.0)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sql.js@1.14.1)
'@eslint/eslintrc': '@eslint/eslintrc':
specifier: ^3.3.3 specifier: ^3.3.3
version: 3.3.3 version: 3.3.3
@@ -97,6 +103,9 @@ importers:
'@types/react-dom': '@types/react-dom':
specifier: 19.2.3 specifier: 19.2.3
version: 19.2.3(@types/react@19.2.7) version: 19.2.3(@types/react@19.2.7)
'@types/sql.js':
specifier: ^1.4.9
version: 1.4.9
'@typescript-eslint/eslint-plugin': '@typescript-eslint/eslint-plugin':
specifier: ^8.51.0 specifier: ^8.51.0
version: 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) version: 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
@@ -1046,6 +1055,9 @@ packages:
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
'@types/emscripten@1.41.5':
resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==}
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -1072,6 +1084,9 @@ packages:
'@types/react@19.2.7': '@types/react@19.2.7':
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
'@types/sql.js@1.4.9':
resolution: {integrity: sha512-ep8b36RKHlgWPqjNG9ToUrPiwkhwh0AEzy883mO5Xnd+cL6VBH1EvSjBAAuxLUFF2Vn/moE3Me6v9E1Lo+48GQ==}
'@types/triple-beam@1.3.5': '@types/triple-beam@1.3.5':
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
@@ -1620,6 +1635,9 @@ packages:
cookie-es@1.2.2: cookie-es@1.2.2:
resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==} resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -2218,6 +2236,9 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
import-fresh@3.3.1: import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -2366,6 +2387,9 @@ packages:
resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==}
engines: {node: '>=16'} engines: {node: '>=16'}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
isarray@2.0.5: isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
@@ -2417,6 +2441,9 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
keyv@4.5.4: keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -2442,6 +2469,9 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
lightningcss-android-arm64@1.30.2: lightningcss-android-arm64@1.30.2:
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@@ -2762,6 +2792,9 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'} engines: {node: '>=10'}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
parent-module@1.0.1: parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -2900,6 +2933,9 @@ packages:
typescript: typescript:
optional: true optional: true
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
prompts@2.4.2: prompts@2.4.2:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -2945,6 +2981,9 @@ packages:
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
readable-stream@3.6.2: readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -3005,6 +3044,9 @@ packages:
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
engines: {node: '>=0.4'} engines: {node: '>=0.4'}
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
safe-buffer@5.2.1: safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
@@ -3053,6 +3095,9 @@ packages:
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
sharp@0.34.5: sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -3111,6 +3156,9 @@ packages:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'} engines: {node: '>= 10.x'}
sql.js@1.14.1:
resolution: {integrity: sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==}
sqlstring@2.3.3: sqlstring@2.3.3:
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -3151,6 +3199,9 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
string_decoder@1.3.0: string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
@@ -3699,7 +3750,7 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
'@better-auth/cli@1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@types/react@19.2.7)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(mysql2@3.15.3)(nanostores@1.1.0)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': '@better-auth/cli@1.4.10(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.3.15)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(mysql2@3.15.3)(nanostores@1.1.0)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sql.js@1.14.1)':
dependencies: dependencies:
'@babel/core': 7.28.5 '@babel/core': 7.28.5
'@babel/preset-react': 7.28.5(@babel/core@7.28.5) '@babel/preset-react': 7.28.5(@babel/core@7.28.5)
@@ -3711,13 +3762,13 @@ snapshots:
'@mrleebo/prisma-ast': 0.13.1 '@mrleebo/prisma-ast': 0.13.1
'@prisma/client': 5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)) '@prisma/client': 5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))
'@types/pg': 8.15.6 '@types/pg': 8.15.6
better-auth: 1.4.10(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) better-auth: 1.4.10(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
better-sqlite3: 12.5.0 better-sqlite3: 12.5.0
c12: 3.3.2 c12: 3.3.2
chalk: 5.6.2 chalk: 5.6.2
commander: 12.1.0 commander: 12.1.0
dotenv: 17.2.3 dotenv: 17.2.3
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3) drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1)
open: 10.2.0 open: 10.2.0
pg: 8.16.3 pg: 8.16.3
prettier: 3.7.4 prettier: 3.7.4
@@ -4411,6 +4462,8 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@types/emscripten@1.41.5': {}
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
@@ -4439,6 +4492,11 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 csstype: 3.2.3
'@types/sql.js@1.4.9':
dependencies:
'@types/emscripten': 1.41.5
'@types/node': 25.0.3
'@types/triple-beam@1.3.5': {} '@types/triple-beam@1.3.5': {}
'@typescript-eslint/eslint-plugin@8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': '@typescript-eslint/eslint-plugin@8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
@@ -4810,7 +4868,7 @@ snapshots:
bcryptjs@3.0.3: {} bcryptjs@3.0.3: {}
better-auth@1.4.10(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): better-auth@1.4.10(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies: dependencies:
'@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0) '@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)
'@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)) '@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))
@@ -4827,7 +4885,7 @@ snapshots:
optionalDependencies: optionalDependencies:
'@prisma/client': 5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)) '@prisma/client': 5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))
better-sqlite3: 12.5.0 better-sqlite3: 12.5.0
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3) drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1)
mysql2: 3.15.3 mysql2: 3.15.3
next: 16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next: 16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
pg: 8.16.3 pg: 8.16.3
@@ -4835,7 +4893,7 @@ snapshots:
react: 19.2.3 react: 19.2.3
react-dom: 19.2.3(react@19.2.3) react-dom: 19.2.3(react@19.2.3)
better-auth@1.4.10(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): better-auth@1.4.10(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@5.22.0(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1))(mysql2@3.15.3)(next@16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies: dependencies:
'@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0) '@better-auth/core': 1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)
'@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0)) '@better-auth/telemetry': 1.4.10(@better-auth/core@1.4.10(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.3.5))(jose@6.1.3)(kysely@0.28.8)(nanostores@1.1.0))
@@ -4852,7 +4910,7 @@ snapshots:
optionalDependencies: optionalDependencies:
'@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3) '@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)
better-sqlite3: 12.5.0 better-sqlite3: 12.5.0
drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3) drizzle-orm: 0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1)
mysql2: 3.15.3 mysql2: 3.15.3
next: 16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next: 16.1.1(@babel/core@7.28.5)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
pg: 8.16.3 pg: 8.16.3
@@ -5034,6 +5092,8 @@ snapshots:
cookie-es@1.2.2: {} cookie-es@1.2.2: {}
core-util-is@1.0.3: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -5125,12 +5185,13 @@ snapshots:
dotenv@17.2.3: {} dotenv@17.2.3: {}
drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3): drizzle-orm@0.33.0(@electric-sql/pglite@0.3.15)(@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.6)(@types/react@19.2.7)(@types/sql.js@1.4.9)(better-sqlite3@12.5.0)(kysely@0.28.8)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(react@19.2.3)(sql.js@1.14.1):
optionalDependencies: optionalDependencies:
'@electric-sql/pglite': 0.3.15 '@electric-sql/pglite': 0.3.15
'@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3) '@prisma/client': 7.4.2(prisma@7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)
'@types/pg': 8.15.6 '@types/pg': 8.15.6
'@types/react': 19.2.7 '@types/react': 19.2.7
'@types/sql.js': 1.4.9
better-sqlite3: 12.5.0 better-sqlite3: 12.5.0
kysely: 0.28.8 kysely: 0.28.8
mysql2: 3.15.3 mysql2: 3.15.3
@@ -5138,6 +5199,7 @@ snapshots:
postgres: 3.4.7 postgres: 3.4.7
prisma: 7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) prisma: 7.4.2(@types/react@19.2.7)(better-sqlite3@12.5.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)
react: 19.2.3 react: 19.2.3
sql.js: 1.14.1
dunder-proto@1.0.1: dunder-proto@1.0.1:
dependencies: dependencies:
@@ -5685,6 +5747,8 @@ snapshots:
ignore@7.0.5: {} ignore@7.0.5: {}
immediate@3.0.6: {}
import-fresh@3.3.1: import-fresh@3.3.1:
dependencies: dependencies:
parent-module: 1.0.1 parent-module: 1.0.1
@@ -5837,6 +5901,8 @@ snapshots:
dependencies: dependencies:
is-inside-container: 1.0.0 is-inside-container: 1.0.0
isarray@1.0.0: {}
isarray@2.0.5: {} isarray@2.0.5: {}
isexe@2.0.0: {} isexe@2.0.0: {}
@@ -5881,6 +5947,13 @@ snapshots:
object.assign: 4.1.7 object.assign: 4.1.7
object.values: 1.2.1 object.values: 1.2.1
jszip@3.10.1:
dependencies:
lie: 3.3.0
pako: 1.0.11
readable-stream: 2.3.8
setimmediate: 1.0.5
keyv@4.5.4: keyv@4.5.4:
dependencies: dependencies:
json-buffer: 3.0.1 json-buffer: 3.0.1
@@ -5902,6 +5975,10 @@ snapshots:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
type-check: 0.4.0 type-check: 0.4.0
lie@3.3.0:
dependencies:
immediate: 3.0.6
lightningcss-android-arm64@1.30.2: lightningcss-android-arm64@1.30.2:
optional: true optional: true
@@ -6204,6 +6281,8 @@ snapshots:
dependencies: dependencies:
p-limit: 3.1.0 p-limit: 3.1.0
pako@1.0.11: {}
parent-module@1.0.1: parent-module@1.0.1:
dependencies: dependencies:
callsites: 3.1.0 callsites: 3.1.0
@@ -6333,6 +6412,8 @@ snapshots:
- react - react
- react-dom - react-dom
process-nextick-args@2.0.1: {}
prompts@2.4.2: prompts@2.4.2:
dependencies: dependencies:
kleur: 3.0.3 kleur: 3.0.3
@@ -6384,6 +6465,16 @@ snapshots:
react@19.2.3: {} react@19.2.3: {}
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
inherits: 2.0.4
isarray: 1.0.0
process-nextick-args: 2.0.1
safe-buffer: 5.1.2
string_decoder: 1.1.1
util-deprecate: 1.0.2
readable-stream@3.6.2: readable-stream@3.6.2:
dependencies: dependencies:
inherits: 2.0.4 inherits: 2.0.4
@@ -6452,6 +6543,8 @@ snapshots:
has-symbols: 1.1.0 has-symbols: 1.1.0
isarray: 2.0.5 isarray: 2.0.5
safe-buffer@5.1.2: {}
safe-buffer@5.2.1: {} safe-buffer@5.2.1: {}
safe-push-apply@1.0.0: safe-push-apply@1.0.0:
@@ -6501,6 +6594,8 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
es-object-atoms: 1.1.1 es-object-atoms: 1.1.1
setimmediate@1.0.5: {}
sharp@0.34.5: sharp@0.34.5:
dependencies: dependencies:
'@img/colour': 1.0.0 '@img/colour': 1.0.0
@@ -6590,6 +6685,8 @@ snapshots:
split2@4.2.0: {} split2@4.2.0: {}
sql.js@1.14.1: {}
sqlstring@2.3.3: {} sqlstring@2.3.3: {}
stable-hash@0.0.5: {} stable-hash@0.0.5: {}
@@ -6653,6 +6750,10 @@ snapshots:
define-properties: 1.2.1 define-properties: 1.2.1
es-object-atoms: 1.1.1 es-object-atoms: 1.1.1
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
string_decoder@1.3.0: string_decoder@1.3.0:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1

View File

@@ -0,0 +1,27 @@
-- AlterTable
ALTER TABLE "user" ADD COLUMN "bio" TEXT;
-- CreateTable
CREATE TABLE "follows" (
"id" TEXT NOT NULL,
"follower_id" TEXT NOT NULL,
"following_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "follows_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "follows_follower_id_idx" ON "follows"("follower_id");
-- CreateIndex
CREATE INDEX "follows_following_id_idx" ON "follows"("following_id");
-- CreateIndex
CREATE UNIQUE INDEX "follows_follower_id_following_id_key" ON "follows"("follower_id", "following_id");
-- AddForeignKey
ALTER TABLE "follows" ADD CONSTRAINT "follows_follower_id_fkey" FOREIGN KEY ("follower_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "follows" ADD CONSTRAINT "follows_following_id_fkey" FOREIGN KEY ("following_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;

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

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { authClient } from "@/lib/auth-client";
import Link from "next/link"; import Link from "next/link";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -9,6 +8,7 @@ import { Card, CardBody } from "@/design-system/base/card";
import { Input } from "@/design-system/base/input"; import { Input } from "@/design-system/base/input";
import { PrimaryButton } from "@/design-system/base/button"; import { PrimaryButton } from "@/design-system/base/button";
import { VStack } from "@/design-system/layout/stack"; import { VStack } from "@/design-system/layout/stack";
import { actionRequestPasswordReset } from "@/modules/auth/forgot-password-action";
export default function ForgotPasswordPage() { export default function ForgotPasswordPage() {
const t = useTranslations("auth"); const t = useTranslations("auth");
@@ -23,16 +23,13 @@ export default function ForgotPasswordPage() {
} }
setLoading(true); setLoading(true);
const { error } = await authClient.requestPasswordReset({ const result = await actionRequestPasswordReset({ email });
email,
redirectTo: "/reset-password",
});
if (error) { if (!result.success) {
toast.error(error.message ?? t("resetPasswordFailed")); toast.error(result.message);
} else { } else {
setSent(true); setSent(true);
toast.success(t("resetPasswordEmailSent")); toast.success(result.message);
} }
setLoading(false); setLoading(false);
}; };

View File

@@ -8,7 +8,7 @@ import { toast } from "sonner";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Card, CardBody } from "@/design-system/base/card"; import { Card, CardBody } from "@/design-system/base/card";
import { Input } from "@/design-system/base/input"; import { Input } from "@/design-system/base/input";
import { PrimaryButton } from "@/design-system/base/button"; import { PrimaryButton, LinkButton } from "@/design-system/base/button";
import { VStack } from "@/design-system/layout/stack"; import { VStack } from "@/design-system/layout/stack";
export default function LoginPage() { export default function LoginPage() {
@@ -16,6 +16,9 @@ export default function LoginPage() {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [resendLoading, setResendLoading] = useState(false);
const [showResendOption, setShowResendOption] = useState(false);
const [unverifiedEmail, setUnverifiedEmail] = useState("");
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const redirectTo = searchParams.get("redirect"); const redirectTo = searchParams.get("redirect");
@@ -25,10 +28,31 @@ export default function LoginPage() {
useEffect(() => { useEffect(() => {
if (!isPending && session?.user?.username && !redirectTo) { if (!isPending && session?.user?.username && !redirectTo) {
router.push("/folders"); router.push("/decks");
} }
}, [session, isPending, router, redirectTo]); }, [session, isPending, router, redirectTo]);
const handleResendVerification = async () => {
if (!unverifiedEmail) return;
setResendLoading(true);
try {
const { error } = await authClient.sendVerificationEmail({
email: unverifiedEmail,
callbackURL: "/login",
});
if (error) {
toast.error(t("resendFailed"));
} else {
toast.success(t("resendSuccess"));
setShowResendOption(false);
}
} finally {
setResendLoading(false);
}
};
const handleLogin = async () => { const handleLogin = async () => {
if (!username || !password) { if (!username || !password) {
toast.error(t("enterCredentials")); toast.error(t("enterCredentials"));
@@ -36,6 +60,7 @@ export default function LoginPage() {
} }
setLoading(true); setLoading(true);
setShowResendOption(false);
try { try {
if (username.includes("@")) { if (username.includes("@")) {
const { error } = await authClient.signIn.email({ const { error } = await authClient.signIn.email({
@@ -43,7 +68,13 @@ export default function LoginPage() {
password: password, password: password,
}); });
if (error) { if (error) {
toast.error(error.message ?? t("loginFailed")); if (error.status === 403) {
setUnverifiedEmail(username);
setShowResendOption(true);
toast.error(t("emailNotVerified"));
} else {
toast.error(error.message ?? t("loginFailed"));
}
return; return;
} }
} else { } else {
@@ -52,11 +83,15 @@ export default function LoginPage() {
password: password, password: password,
}); });
if (error) { if (error) {
toast.error(error.message ?? t("loginFailed")); if (error.status === 403) {
toast.error(t("emailNotVerified"));
} else {
toast.error(error.message ?? t("loginFailed"));
}
return; return;
} }
} }
router.push(redirectTo ?? "/folders"); router.push(redirectTo ?? "/decks");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -91,6 +126,21 @@ export default function LoginPage() {
{t("forgotPassword")} {t("forgotPassword")}
</Link> </Link>
{showResendOption && (
<div className="w-full p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg text-sm">
<p className="text-yellow-800 dark:text-yellow-200 mb-2">
{t("emailNotVerifiedHint")}
</p>
<LinkButton
onClick={handleResendVerification}
loading={resendLoading}
size="sm"
>
{t("resendVerification")}
</LinkButton>
</div>
)}
<PrimaryButton <PrimaryButton
onClick={handleLogin} onClick={handleLogin}
loading={loading} loading={loading}

View File

@@ -9,5 +9,5 @@ export default async function ProfilePage() {
redirect("/login?redirect=/profile"); redirect("/login?redirect=/profile");
} }
redirect(session.user.username ? `/users/${session.user.username}` : "/folders"); redirect(session.user.username ? `/users/${session.user.username}` : "/decks");
} }

View File

@@ -18,6 +18,7 @@ export default function SignUpPage() {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [verificationSent, setVerificationSent] = useState(false);
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const redirectTo = searchParams.get("redirect"); const redirectTo = searchParams.get("redirect");
@@ -26,10 +27,10 @@ export default function SignUpPage() {
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
if (!isPending && session?.user?.username && !redirectTo) { if (!isPending && session?.user?.username && !redirectTo && !verificationSent) {
router.push("/folders"); router.push("/decks");
} }
}, [session, isPending, router, redirectTo]); }, [session, isPending, router, redirectTo, verificationSent]);
const handleSignUp = async () => { const handleSignUp = async () => {
if (!username || !email || !password) { if (!username || !email || !password) {
@@ -49,12 +50,38 @@ export default function SignUpPage() {
toast.error(error.message ?? t("signUpFailed")); toast.error(error.message ?? t("signUpFailed"));
return; return;
} }
router.push(redirectTo ?? "/folders"); setVerificationSent(true);
toast.success(t("verificationEmailSent"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
if (verificationSent) {
return (
<div className="flex justify-center items-center min-h-screen">
<Card className="w-96">
<CardBody>
<VStack gap={4} align="center" justify="center">
<h1 className="text-2xl font-bold text-center w-full">
{t("verifyYourEmail")}
</h1>
<p className="text-center text-gray-600">
{t("verificationEmailSentHint", { email })}
</p>
<Link
href="/login"
className="text-primary-500 hover:underline"
>
{t("backToLogin")}
</Link>
</VStack>
</CardBody>
</Card>
</div>
);
}
return ( return (
<div className="flex justify-center items-center min-h-screen"> <div className="flex justify-center items-center min-h-screen">
<Card className="w-96"> <Card className="w-96">

View File

@@ -0,0 +1,103 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Button } from "@/design-system/base/button";
import { Modal } from "@/design-system/overlay/modal";
import { actionDeleteAccount } from "@/modules/auth/auth-action";
interface DeleteAccountButtonProps {
username: string;
}
export function DeleteAccountButton({ username }: DeleteAccountButtonProps) {
const t = useTranslations("user_profile");
const router = useRouter();
const [showModal, setShowModal] = useState(false);
const [confirmUsername, setConfirmUsername] = useState("");
const [loading, setLoading] = useState(false);
const handleDelete = async () => {
if (confirmUsername !== username) {
toast.error(t("deleteAccount.usernameMismatch"));
return;
}
setLoading(true);
try {
const result = await actionDeleteAccount();
if (result.success) {
toast.success(t("deleteAccount.success"));
router.push("/");
} else {
toast.error(result.message || t("deleteAccount.failed"));
}
} catch {
toast.error(t("deleteAccount.failed"));
} finally {
setLoading(false);
setShowModal(false);
}
};
return (
<>
<button
onClick={() => setShowModal(true)}
className="text-xs text-gray-400 hover:text-red-500 transition-colors"
>
{t("deleteAccount.button")}
</button>
<Modal open={showModal} onClose={() => setShowModal(false)}>
<div className="p-6">
<h2 className="text-xl font-bold text-red-600 mb-4">
{t("deleteAccount.title")}
</h2>
<div className="space-y-4">
<p className="text-gray-700">
{t("deleteAccount.warning")}
</p>
<ul className="list-disc list-inside text-gray-600 text-sm space-y-1">
<li>{t("deleteAccount.warningDecks")}</li>
<li>{t("deleteAccount.warningCards")}</li>
<li>{t("deleteAccount.warningHistory")}</li>
<li>{t("deleteAccount.warningPermanent")}</li>
</ul>
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
{t("deleteAccount.confirmLabel")} <span className="font-mono font-bold">{username}</span>
</label>
<input
type="text"
value={confirmUsername}
onChange={(e) => setConfirmUsername(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500"
placeholder={username}
/>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<Button variant="secondary" onClick={() => setShowModal(false)}>
{t("deleteAccount.cancel")}
</Button>
<Button
variant="error"
onClick={handleDelete}
loading={loading}
disabled={confirmUsername !== username}
>
{t("deleteAccount.confirm")}
</Button>
</div>
</div>
</Modal>
</>
);
}

View File

@@ -0,0 +1,44 @@
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { PageLayout } from "@/components/ui/PageLayout";
import { UserList } from "@/components/follow/UserList";
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
import { actionGetFollowers } from "@/modules/follow/follow-action";
interface FollowersPageProps {
params: Promise<{ username: string }>;
}
export default async function FollowersPage({ params }: FollowersPageProps) {
const { username } = await params;
const t = await getTranslations("follow");
const userResult = await actionGetUserProfileByUsername({ username });
if (!userResult.success || !userResult.data) {
notFound();
}
const user = userResult.data;
const followersResult = await actionGetFollowers({
userId: user.id,
page: 1,
limit: 50,
});
const followers = followersResult.success && followersResult.data
? followersResult.data.followers.map((f) => f.user)
: [];
return (
<PageLayout>
<div className="bg-white rounded-lg shadow-md p-6">
<h1 className="text-2xl font-bold text-gray-800 mb-6">
{t("followersOf", { username: user.displayUsername || user.username || "User" })}
</h1>
<UserList users={followers} emptyMessage={t("noFollowers")} />
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,44 @@
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { PageLayout } from "@/components/ui/PageLayout";
import { UserList } from "@/components/follow/UserList";
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
import { actionGetFollowing } from "@/modules/follow/follow-action";
interface FollowingPageProps {
params: Promise<{ username: string }>;
}
export default async function FollowingPage({ params }: FollowingPageProps) {
const { username } = await params;
const t = await getTranslations("follow");
const userResult = await actionGetUserProfileByUsername({ username });
if (!userResult.success || !userResult.data) {
notFound();
}
const user = userResult.data;
const followingResult = await actionGetFollowing({
userId: user.id,
page: 1,
limit: 50,
});
const following = followingResult.success && followingResult.data
? followingResult.data.following.map((f) => f.user)
: [];
return (
<PageLayout>
<div className="bg-white rounded-lg shadow-md p-6">
<h1 className="text-2xl font-bold text-gray-800 mb-6">
{t("followingOf", { username: user.displayUsername || user.username || "User" })}
</h1>
<UserList users={following} emptyMessage={t("noFollowing")} />
</div>
</PageLayout>
);
}

View File

@@ -1,14 +1,16 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { PageLayout } from "@/components/ui/PageLayout"; import { PageLayout } from "@/components/ui/PageLayout";
import { LightButton, LinkButton } from "@/design-system/base/button"; import { LinkButton } from "@/design-system/base/button";
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action"; import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
import { repoGetFoldersWithTotalPairsByUserId } from "@/modules/folder/folder-repository"; import { repoGetDecksByUserId } from "@/modules/deck/deck-repository";
import { actionGetFollowStatus } from "@/modules/follow/follow-action";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
// import { LogoutButton } from "./LogoutButton"; import { FollowStats } from "@/components/follow/FollowStats";
import { DeleteAccountButton } from "./DeleteAccountButton";
interface UserPageProps { interface UserPageProps {
params: Promise<{ username: string; }>; params: Promise<{ username: string; }>;
@@ -18,10 +20,8 @@ export default async function UserPage({ params }: UserPageProps) {
const { username } = await params; const { username } = await params;
const t = await getTranslations("user_profile"); const t = await getTranslations("user_profile");
// Get current session
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
// Get user profile
const result = await actionGetUserProfileByUsername({ username }); const result = await actionGetUserProfileByUsername({ username });
if (!result.success || !result.data) { if (!result.success || !result.data) {
@@ -30,150 +30,170 @@ export default async function UserPage({ params }: UserPageProps) {
const user = result.data; const user = result.data;
// Get user's folders const [decks, followStatus] = await Promise.all([
const folders = await repoGetFoldersWithTotalPairsByUserId(user.id); repoGetDecksByUserId({ userId: user.id }),
actionGetFollowStatus({ targetUserId: user.id }),
]);
// Check if viewing own profile
const isOwnProfile = session?.user?.username === username || session?.user?.email === username; const isOwnProfile = session?.user?.username === username || session?.user?.email === username;
const followersCount = followStatus.success && followStatus.data ? followStatus.data.followersCount : 0;
const followingCount = followStatus.success && followStatus.data ? followStatus.data.followingCount : 0;
const isFollowing = followStatus.success && followStatus.data ? followStatus.data.isFollowing : false;
return ( return (
<PageLayout> <PageLayout>
{/* Header */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6"> <div className="bg-white rounded-lg shadow-md p-6 mb-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div></div> <div></div>
{isOwnProfile && <LinkButton href="/logout">{t("logout")}</LinkButton>} <div className="flex items-center gap-3">
</div> {isOwnProfile && (
<div className="flex items-center space-x-6"> <>
{/* Avatar */} <LinkButton href="/logout">{t("logout")}</LinkButton>
{user.image ? ( <DeleteAccountButton username={username} />
<div className="relative w-24 h-24 rounded-full border-4 border-primary-500 overflow-hidden"> </>
<Image
src={user.image}
alt={user.displayUsername || user.username || user.email}
fill
className="object-cover"
unoptimized
/>
</div>
) : (
<div className="w-24 h-24 rounded-full bg-primary-500 border-4 border-primary-500 flex items-center justify-center">
<span className="text-3xl font-bold text-white">
{(user.displayUsername || user.username || user.email)[0].toUpperCase()}
</span>
</div>
)} )}
{/* User Info */}
<div className="flex-1">
<h1 className="text-3xl font-bold text-gray-800 mb-2">
{user.displayUsername || user.username || t("anonymous")}
</h1>
{user.username && (
<p className="text-gray-600 text-sm mb-1">
@{user.username}
</p>
)}
<p className="text-gray-600 text-sm mb-1">
{user.email}
</p>
<div className="flex items-center space-x-4 text-sm">
<span className="text-gray-500">
Joined: {new Date(user.createdAt).toLocaleDateString()}
</span>
{user.emailVerified && (
<span className="flex items-center text-green-600">
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.293 12.293a1 1 0 101.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
Verified
</span>
)}
</div>
</div>
</div> </div>
</div> </div>
<div className="flex flex-col sm:flex-row items-start sm:items-center space-y-4 sm:space-y-0 sm:space-x-6">
{/* Account Info */} {user.image ? (
<div className="bg-white rounded-lg shadow-md p-6 mb-6"> <div className="relative w-24 h-24 rounded-full border-4 border-primary-500 overflow-hidden flex-shrink-0">
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("accountInfo")}</h2> <Image
<dl className="grid grid-cols-1 gap-4 sm:grid-cols-2"> src={user.image}
<div> alt={user.displayUsername || user.username || user.email}
<dt className="text-sm font-medium text-gray-500">{t("userId")}</dt> fill
<dd className="mt-1 text-sm text-gray-900 font-mono break-all">{user.id}</dd> className="object-cover"
unoptimized
/>
</div> </div>
<div>
<dt className="text-sm font-medium text-gray-500">{t("username")}</dt>
<dd className="mt-1 text-sm text-gray-900">
{user.username || <span className="text-gray-400">{t("notSet")}</span>}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">{t("displayName")}</dt>
<dd className="mt-1 text-sm text-gray-900">
{user.displayUsername || <span className="text-gray-400">{t("notSet")}</span>}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">{t("memberSince")}</dt>
<dd className="mt-1 text-sm text-gray-900">
{new Date(user.createdAt).toLocaleDateString()}
</dd>
</div>
</dl>
</div>
{/* Folders Section */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("folders.title")}</h2>
{folders.length === 0 ? (
<p className="text-gray-500 text-center py-8">{t("folders.noFolders")}</p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="w-24 h-24 rounded-full bg-primary-500 border-4 border-primary-500 flex items-center justify-center flex-shrink-0">
<table className="min-w-full divide-y divide-gray-200"> <span className="text-3xl font-bold text-white">
<thead className="bg-gray-50"> {(user.displayUsername || user.username || user.email)[0].toUpperCase()}
<tr> </span>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t("folders.folderName")}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t("folders.totalPairs")}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t("folders.createdAt")}
</th>
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{t("folders.actions")}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{folders.map((folder) => (
<tr key={folder.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{folder.name}</div>
<div className="text-sm text-gray-500">ID: {folder.id}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{folder.total}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(folder.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Link href={`/folders/${folder.id}`}>
<LinkButton>
{t("folders.view")}
</LinkButton>
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div> </div>
)} )}
<div className="flex-1">
<h1 className="text-3xl font-bold text-gray-800 mb-2">
{user.displayUsername || user.username || t("anonymous")}
</h1>
{user.username && (
<p className="text-gray-600 text-sm mb-1">
@{user.username}
</p>
)}
{user.bio && (
<p className="text-gray-700 mt-2 mb-2">
{user.bio}
</p>
)}
<div className="flex flex-wrap items-center gap-4 text-sm mt-3">
<span className="text-gray-500">
{t("joined")}: {new Date(user.createdAt).toLocaleDateString()}
</span>
{user.emailVerified && (
<span className="flex items-center text-green-600">
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.293 12.293a1 1 0 101.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
{t("verified")}
</span>
)}
</div>
<div className="mt-3">
<FollowStats
userId={user.id}
initialFollowersCount={followersCount}
initialFollowingCount={followingCount}
initialIsFollowing={isFollowing}
currentUserId={session?.user?.id}
isOwnProfile={isOwnProfile}
username={user.username || user.id}
/>
</div>
</div>
</div> </div>
</div>
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("accountInfo")}</h2>
<dl className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-gray-500">{t("userId")}</dt>
<dd className="mt-1 text-sm text-gray-900 font-mono break-all">{user.id}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">{t("username")}</dt>
<dd className="mt-1 text-sm text-gray-900">
{user.username || <span className="text-gray-400">{t("notSet")}</span>}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">{t("displayName")}</dt>
<dd className="mt-1 text-sm text-gray-900">
{user.displayUsername || <span className="text-gray-400">{t("notSet")}</span>}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">{t("memberSince")}</dt>
<dd className="mt-1 text-sm text-gray-900">
{new Date(user.createdAt).toLocaleDateString()}
</dd>
</div>
</dl>
</div>
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("decks.title")}</h2>
{decks.length === 0 ? (
<p className="text-gray-500 text-center py-8">{t("decks.noDecks")}</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t("decks.deckName")}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t("decks.totalCards")}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t("decks.createdAt")}
</th>
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{t("decks.actions")}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{decks.map((deck) => (
<tr key={deck.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{deck.name}</div>
<div className="text-sm text-gray-500">ID: {deck.id}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{deck.cardCount ?? 0}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(deck.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Link href={`/decks/${deck.id}`}>
<LinkButton>
{t("decks.view")}
</LinkButton>
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</PageLayout> </PageLayout>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -0,0 +1,286 @@
"use client";
import { useState, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { PageLayout } from "@/components/ui/PageLayout";
import { PrimaryButton, LightButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { Select } from "@/design-system/base/select";
import { Card } from "@/design-system/base/card";
import { toast } from "sonner";
import { Upload, FileImage, Loader2 } from "lucide-react";
import { actionProcessOCR } from "@/modules/ocr/ocr-action";
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
interface ActionOutputProcessOCR {
success: boolean;
message: string;
data?: {
pairsCreated: number;
sourceLanguage?: string;
targetLanguage?: string;
};
}
interface OCRClientProps {
initialDecks: ActionOutputDeck[];
}
export function OCRClient({ initialDecks }: OCRClientProps) {
const t = useTranslations("ocr");
const fileInputRef = useRef<HTMLInputElement>(null);
const [decks, setDecks] = useState<ActionOutputDeck[]>(initialDecks);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [selectedDeckId, setSelectedDeckId] = useState<number | null>(
initialDecks.length > 0 ? initialDecks[0].id : null
);
const [sourceLanguage, setSourceLanguage] = useState<string>("");
const [targetLanguage, setTargetLanguage] = useState<string>("");
const [isProcessing, setIsProcessing] = useState(false);
const [ocrResult, setOcrResult] = useState<ActionOutputProcessOCR | null>(null);
const handleFileChange = useCallback((file: File | null) => {
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error(t("invalidFileType"));
return;
}
const url = URL.createObjectURL(file);
setPreviewUrl(url);
setSelectedFile(file);
setOcrResult(null);
}, [t]);
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
handleFileChange(file);
}, [handleFileChange]);
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
}, []);
const fileToBase64 = async (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
const base64 = result.split(",")[1];
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
const handleProcess = async () => {
if (!selectedFile) {
toast.error(t("noImage"));
return;
}
if (!selectedDeckId) {
toast.error(t("noDeck"));
return;
}
setIsProcessing(true);
setOcrResult(null);
try {
const base64 = await fileToBase64(selectedFile);
const result = await actionProcessOCR({
imageBase64: base64,
deckId: selectedDeckId,
sourceLanguage: sourceLanguage || undefined,
targetLanguage: targetLanguage || undefined,
});
if (result.success && result.data) {
setOcrResult(result);
const deckName = decks.find(d => d.id === selectedDeckId)?.name || "";
toast.success(t("ocrSuccess", { count: result.data.pairsCreated, deck: deckName }));
} else {
toast.error(result.message || t("ocrFailed"));
}
} catch {
toast.error(t("processingFailed"));
} finally {
setIsProcessing(false);
}
};
const handleSave = async () => {
if (!ocrResult || !selectedDeckId) {
toast.error(t("noResultsToSave"));
return;
}
try {
const deckName = decks.find(d => d.id === selectedDeckId)?.name || "Unknown";
toast.success(t("savedToDeck", { deckName }));
} catch (error) {
toast.error(t("saveFailed"));
}
};
const clearImage = () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setPreviewUrl(null);
setSelectedFile(null);
setOcrResult(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
return (
<PageLayout variant="centered-card">
<div className="text-center mb-8">
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
{t("title")}
</h1>
<p className="text-gray-700 text-lg">
{t("description")}
</p>
</div>
<Card variant="bordered" padding="lg">
<div className="space-y-6">
{/* Upload Section */}
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-4">
{t("uploadSection")}
</h2>
<div
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-primary-500 hover:bg-primary-50 transition-colors"
>
{previewUrl ? (
<div className="space-y-4">
<img
src={previewUrl}
alt="Preview"
className="mx-auto max-w-full h-64 object-contain rounded-lg"
/>
<p className="text-gray-600">{t("changeImage")}</p>
</div>
) : (
<div className="space-y-4">
<Upload className="mx-auto h-12 w-12 text-gray-400" />
<p className="text-gray-600">{t("dropOrClick")}</p>
<p className="text-sm text-gray-500">{t("supportedFormats")}</p>
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={(e) => handleFileChange(e.target.files?.[0] || null)}
className="hidden"
/>
</div>
{/* Deck Selection */}
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-4">
{t("deckSelection")}
</h2>
<Select
value={selectedDeckId?.toString() || ""}
onChange={(e) => setSelectedDeckId(Number(e.target.value))}
className="w-full"
>
<option value="">{t("selectDeck")}</option>
{decks.map((deck) => (
<option key={deck.id} value={deck.id}>
{deck.name}
</option>
))}
</Select>
</div>
{/* Language Hints */}
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-4">
{t("languageHints")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
type="text"
placeholder={t("sourceLanguagePlaceholder")}
value={sourceLanguage}
onChange={(e) => setSourceLanguage(e.target.value)}
className="w-full"
/>
<Input
type="text"
placeholder={t("targetLanguagePlaceholder")}
value={targetLanguage}
onChange={(e) => setTargetLanguage(e.target.value)}
className="w-full"
/>
</div>
</div>
{/* Process Button */}
<div className="flex justify-center">
<PrimaryButton
onClick={handleProcess}
disabled={!selectedFile || !selectedDeckId || isProcessing}
loading={isProcessing}
className="px-8 py-3 text-lg"
>
{t("processButton")}
</PrimaryButton>
</div>
{/* Results Preview */}
{ocrResult && ocrResult.data && (
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-4">
{t("resultsPreview")}
</h2>
<div className="bg-gray-50 rounded-lg p-4">
<div className="space-y-3">
<div className="text-center py-4">
<p className="text-gray-800">{t("extractedPairs", { count: ocrResult.data.pairsCreated })}</p>
</div>
</div>
{ocrResult.data.sourceLanguage && (
<div className="mt-4 text-sm text-gray-500">
{t("detectedSourceLanguage")}: {ocrResult.data.sourceLanguage}
</div>
)}
{ocrResult.data.targetLanguage && (
<div className="mt-1 text-sm text-gray-500">
{t("detectedTargetLanguage")}: {ocrResult.data.targetLanguage}
</div>
)}
</div>
<div className="mt-4 flex justify-center">
<LightButton
onClick={handleSave}
disabled={!selectedDeckId}
className="px-6 py-2"
>
{t("saveButton")}
</LightButton>
</div>
</div>
)}
</div>
</Card>
</PageLayout>
);
}

View File

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

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { LightButton, PrimaryButton, IconClick } from "@/design-system/base/button"; import { LightButton, PrimaryButton, IconClick } from "@/design-system/base/button";
import { Select } from "@/design-system/base/select";
import { IMAGES } from "@/config/images"; import { IMAGES } from "@/config/images";
import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -10,16 +11,45 @@ import { toast } from "sonner";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts"; import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
import { TSharedTranslationResult } from "@/shared/translator-type"; import { TSharedTranslationResult } from "@/shared/translator-type";
const SOURCE_LANGUAGES = [
{ value: "Auto", labelKey: "auto" },
{ value: "Chinese", labelKey: "chinese" },
{ value: "English", labelKey: "english" },
{ value: "Japanese", labelKey: "japanese" },
{ value: "Korean", labelKey: "korean" },
{ value: "French", labelKey: "french" },
{ value: "German", labelKey: "german" },
{ value: "Italian", labelKey: "italian" },
{ value: "Spanish", labelKey: "spanish" },
{ value: "Portuguese", labelKey: "portuguese" },
{ value: "Russian", labelKey: "russian" },
] as const;
const TARGET_LANGUAGES = [
{ value: "Chinese", labelKey: "chinese" },
{ value: "English", labelKey: "english" },
{ value: "Japanese", labelKey: "japanese" },
{ value: "Korean", labelKey: "korean" },
{ value: "French", labelKey: "french" },
{ value: "German", labelKey: "german" },
{ value: "Italian", labelKey: "italian" },
{ value: "Spanish", labelKey: "spanish" },
{ value: "Portuguese", labelKey: "portuguese" },
{ value: "Russian", labelKey: "russian" },
] as const;
export default function TranslatorPage() { export default function TranslatorPage() {
const t = useTranslations("translator"); const t = useTranslations("translator");
const taref = useRef<HTMLTextAreaElement>(null); const taref = useRef<HTMLTextAreaElement>(null);
const [sourceLanguage, setSourceLanguage] = useState<string>("Auto");
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese"); const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
const [translationResult, setTranslationResult] = useState<TSharedTranslationResult | null>(null); const [translationResult, setTranslationResult] = useState<TSharedTranslationResult | null>(null);
const [needIpa, setNeedIpa] = useState(true); const [needIpa, setNeedIpa] = useState(true);
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const [lastTranslation, setLastTranslation] = useState<{ const [lastTranslation, setLastTranslation] = useState<{
sourceText: string; sourceText: string;
sourceLanguage: string;
targetLanguage: string; targetLanguage: string;
} | null>(null); } | null>(null);
const { load, play } = useAudioPlayer(); const { load, play } = useAudioPlayer();
@@ -63,9 +93,10 @@ export default function TranslatorPage() {
const sourceText = taref.current.value; const sourceText = taref.current.value;
// 判断是否需要强制重新翻译 // 判断是否需要强制重新翻译
// 只有当源文本和目标语言都与上次相同时,才强制重新翻译 // 只有当源文本、源语言和目标语言都与上次相同时,才强制重新翻译
const forceRetranslate = const forceRetranslate =
lastTranslation?.sourceText === sourceText && lastTranslation?.sourceText === sourceText &&
lastTranslation?.sourceLanguage === sourceLanguage &&
lastTranslation?.targetLanguage === targetLanguage; lastTranslation?.targetLanguage === targetLanguage;
try { try {
@@ -74,12 +105,14 @@ export default function TranslatorPage() {
targetLanguage, targetLanguage,
forceRetranslate, forceRetranslate,
needIpa, needIpa,
sourceLanguage: sourceLanguage === "Auto" ? undefined : sourceLanguage,
}); });
if (result.success && result.data) { if (result.success && result.data) {
setTranslationResult(result.data); setTranslationResult(result.data);
setLastTranslation({ setLastTranslation({
sourceText, sourceText,
sourceLanguage,
targetLanguage, targetLanguage,
}); });
} else { } else {
@@ -132,11 +165,47 @@ export default function TranslatorPage() {
></IconClick> ></IconClick>
</div> </div>
</div> </div>
<div className="option1 w-full flex flex-row justify-between items-center"> <div className="option1 w-full flex gap-1 items-center overflow-x-auto">
<span>{t("detectLanguage")}</span> <span className="shrink-0">{t("sourceLanguage")}</span>
<LightButton
selected={sourceLanguage === "Auto"}
onClick={() => setSourceLanguage("Auto")}
className="shrink-0 hidden lg:inline-flex"
>
{t("auto")}
</LightButton>
<LightButton
selected={sourceLanguage === "Chinese"}
onClick={() => setSourceLanguage("Chinese")}
className="shrink-0 hidden lg:inline-flex"
>
{t("chinese")}
</LightButton>
<LightButton
selected={sourceLanguage === "English"}
onClick={() => setSourceLanguage("English")}
className="shrink-0 hidden xl:inline-flex"
>
{t("english")}
</LightButton>
<Select
value={sourceLanguage}
onChange={(e) => setSourceLanguage(e.target.value)}
variant="light"
size="sm"
className="w-auto min-w-[100px] shrink-0"
>
{SOURCE_LANGUAGES.map((lang) => (
<option key={lang.value} value={lang.value}>
{t(lang.labelKey)}
</option>
))}
</Select>
<div className="flex-1"></div>
<LightButton <LightButton
selected={needIpa} selected={needIpa}
onClick={() => setNeedIpa((prev) => !prev)} onClick={() => setNeedIpa((prev) => !prev)}
className="shrink-0"
> >
{t("generateIPA")} {t("generateIPA")}
</LightButton> </LightButton>
@@ -172,37 +241,42 @@ export default function TranslatorPage() {
></IconClick> ></IconClick>
</div> </div>
</div> </div>
<div className="option2 w-full flex gap-1 items-center flex-wrap"> <div className="option2 w-full flex gap-1 items-center overflow-x-auto">
<span>{t("translateInto")}</span> <span className="shrink-0">{t("translateInto")}</span>
<LightButton <LightButton
selected={targetLanguage === "Chinese"} selected={targetLanguage === "Chinese"}
onClick={() => setTargetLanguage("Chinese")} onClick={() => setTargetLanguage("Chinese")}
className="shrink-0 hidden lg:inline-flex"
> >
{t("chinese")} {t("chinese")}
</LightButton> </LightButton>
<LightButton <LightButton
selected={targetLanguage === "English"} selected={targetLanguage === "English"}
onClick={() => setTargetLanguage("English")} onClick={() => setTargetLanguage("English")}
className="shrink-0 hidden lg:inline-flex"
> >
{t("english")} {t("english")}
</LightButton> </LightButton>
<LightButton <LightButton
selected={targetLanguage === "Italian"} selected={targetLanguage === "Japanese"}
onClick={() => setTargetLanguage("Italian")} onClick={() => setTargetLanguage("Japanese")}
className="shrink-0 hidden xl:inline-flex"
> >
{t("italian")} {t("japanese")}
</LightButton> </LightButton>
<LightButton <Select
selected={!["Chinese", "English", "Italian"].includes(targetLanguage)} value={targetLanguage}
onClick={() => { onChange={(e) => setTargetLanguage(e.target.value)}
const newLang = prompt(t("enterLanguage")); variant="light"
if (newLang) { size="sm"
setTargetLanguage(newLang); className="w-auto min-w-[100px] shrink-0"
}
}}
> >
{t("other")} {TARGET_LANGUAGES.map((lang) => (
</LightButton> <option key={lang.value} value={lang.value}>
{t(lang.labelKey)}
</option>
))}
</Select>
</div> </div>
</div> </div>
</div> </div>

View File

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

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

View File

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

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

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

@@ -5,18 +5,17 @@
* 使用 @theme 指令定义主题变量 * 使用 @theme 指令定义主题变量
*/ */
@theme { @theme {
/* 主色 - Teal */ --color-primary-50: var(--primary-50);
--color-primary-50: #f0f9f8; --color-primary-100: var(--primary-100);
--color-primary-100: #e0f2f0; --color-primary-200: var(--primary-200);
--color-primary-200: #bce6e1; --color-primary-300: var(--primary-300);
--color-primary-300: #8dd4cc; --color-primary-400: var(--primary-400);
--color-primary-400: #5ec2b7; --color-primary-500: var(--primary-500);
--color-primary-500: #35786f; --color-primary-600: var(--primary-600);
--color-primary-600: #2a605b; --color-primary-700: var(--primary-700);
--color-primary-700: #1f4844; --color-primary-800: var(--primary-800);
--color-primary-800: #183835; --color-primary-900: var(--primary-900);
--color-primary-900: #122826; --color-primary-950: var(--primary-950);
--color-primary-950: #0a1413;
/* 中性色 */ /* 中性色 */
--color-gray-50: #f9fafb; --color-gray-50: #f9fafb;
@@ -100,6 +99,19 @@
* 定义全局 CSS 变量用于主题切换和动态样式 * 定义全局 CSS 变量用于主题切换和动态样式
*/ */
:root { :root {
/* 主题色 - 默认 Teal */
--primary-50: #f0f9f8;
--primary-100: #e0f2f0;
--primary-200: #bce6e1;
--primary-300: #8dd4cc;
--primary-400: #5ec2b7;
--primary-500: #35786f;
--primary-600: #2a605b;
--primary-700: #1f4844;
--primary-800: #183835;
--primary-900: #122826;
--primary-950: #0a1413;
/* 基础颜色 */ /* 基础颜色 */
--background: #ffffff; --background: #ffffff;
--foreground: #111827; --foreground: #111827;

View File

@@ -5,6 +5,7 @@ import { NextIntlClientProvider } from "next-intl";
import { Navbar } from "@/components/layout/Navbar"; import { Navbar } from "@/components/layout/Navbar";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { StrictMode } from "react"; import { StrictMode } from "react";
import { ThemeProvider } from "@/components/theme-provider";
export const viewport: Viewport = { export const viewport: Viewport = {
width: "device-width", width: "device-width",
@@ -25,11 +26,13 @@ export default async function RootLayout({
<html lang="en"> <html lang="en">
<body className={`antialiased`}> <body className={`antialiased`}>
<StrictMode> <StrictMode>
<NextIntlClientProvider> <ThemeProvider>
<Navbar></Navbar> <NextIntlClientProvider>
{children} <Navbar></Navbar>
<Toaster /> {children}
</NextIntlClientProvider> <Toaster />
</NextIntlClientProvider>
</ThemeProvider>
</StrictMode> </StrictMode>
</body> </body>
</html> </html>

57
src/app/settings/page.tsx Normal file
View File

@@ -0,0 +1,57 @@
"use client";
import { useTheme } from "@/components/theme-provider";
import { useTranslations } from "next-intl";
import { cn } from "@/utils/cn";
export default function SettingsPage() {
const t = useTranslations("settings");
const { currentTheme, setTheme, availableThemes } = useTheme();
return (
<div className="min-h-[calc(100vh-64px)] bg-white p-4 md:p-8">
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-6">
{t("title")}
</h1>
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-gray-800 mb-3">
{t("themeColor")}
</h2>
<p className="text-sm text-gray-600 mb-4">
{t("themeColorDescription")}
</p>
<div className="grid grid-cols-4 sm:grid-cols-8 gap-3">
{availableThemes.map((theme) => (
<button
key={theme.id}
onClick={() => setTheme(theme.id)}
className={cn(
"group relative flex flex-col items-center gap-2 p-2 rounded-lg transition-all",
currentTheme === theme.id
? "ring-2 ring-offset-2"
: "hover:bg-gray-50"
)}
style={{
["--tw-ring-color" as string]: theme.colors[500],
}}
>
<div
className="w-8 h-8 rounded-full shadow-md ring-1 ring-black/10"
style={{ backgroundColor: theme.colors[500] }}
/>
<span className="text-xs text-gray-600 group-hover:text-gray-900">
{theme.name}
</span>
</button>
))}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -18,21 +18,28 @@ export const auth = betterAuth({
enabled: true, enabled: true,
requireEmailVerification: true, requireEmailVerification: true,
sendResetPassword: async ({ user, url }) => { sendResetPassword: async ({ user, url }) => {
void sendEmail({ const result = await sendEmail({
to: user.email, to: user.email,
subject: "重置您的密码 - Learn Languages", subject: "重置您的密码 - Learn Languages",
html: generateResetPasswordEmailHtml(url, user.name || "用户"), html: generateResetPasswordEmailHtml(url, user.name || "用户"),
}); });
if (!result.success) {
console.error("[email] Failed to send reset password email:", result.error);
}
}, },
}, },
emailVerification: { emailVerification: {
sendOnSignUp: true, sendOnSignUp: true,
sendOnSignIn: true,
sendVerificationEmail: async ({ user, url }) => { sendVerificationEmail: async ({ user, url }) => {
void sendEmail({ const result = await sendEmail({
to: user.email, to: user.email,
subject: "验证您的邮箱 - Learn Languages", subject: "验证您的邮箱 - Learn Languages",
html: generateVerificationEmailHtml(url, user.name || "用户"), html: generateVerificationEmailHtml(url, user.name || "用户"),
}); });
if (!result.success) {
console.error("[email] Failed to send verification email:", result.error);
}
}, },
}, },
socialProviders: { socialProviders: {
@@ -44,13 +51,34 @@ export const auth = betterAuth({
plugins: [nextCookies(), username()], plugins: [nextCookies(), username()],
hooks: { hooks: {
before: createAuthMiddleware(async (ctx) => { before: createAuthMiddleware(async (ctx) => {
if (ctx.path !== "/sign-up/email" && ctx.path !== "/update-user") return; if (ctx.path === "/sign-up/email" || ctx.path === "/update-user") {
const body = ctx.body as { username?: string };
const body = ctx.body as { username?: string }; if (!body.username || body.username.trim() === "") {
if (!body.username || body.username.trim() === "") { throw new APIError("BAD_REQUEST", {
throw new APIError("BAD_REQUEST", { message: "Username is required",
message: "Username is required", });
}); }
}
if (ctx.path === "/sign-in/username") {
const body = ctx.body as { username?: string };
if (body.username) {
const user = await prisma.user.findFirst({
where: {
OR: [
{ username: body.username },
{ email: body.username },
],
},
select: { emailVerified: true },
});
if (user && !user.emailVerified) {
throw new APIError("FORBIDDEN", {
message: "Please verify your email address before signing in",
});
}
}
} }
}), }),
}, },

View File

@@ -0,0 +1,254 @@
"use client";
import { useState, useRef } from "react";
import { Upload, Download, FileUp, X, Check, Loader2 } from "lucide-react";
import { LightButton, PrimaryButton } from "@/design-system/base/button";
import { Modal } from "@/design-system/overlay/modal";
import { actionPreviewApkg, actionImportApkg } from "@/modules/import/import-action";
import { actionExportApkg } from "@/modules/export/export-action";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
interface ImportExportProps {
deckId?: number;
deckName?: string;
onImportComplete?: () => void;
}
interface PreviewDeck {
id: number;
name: string;
cardCount: number;
}
export function ImportButton({ onImportComplete }: ImportExportProps) {
const t = useTranslations("decks");
const [isModalOpen, setIsModalOpen] = useState(false);
const [step, setStep] = useState<"upload" | "select" | "importing">("upload");
const [file, setFile] = useState<File | null>(null);
const [decks, setDecks] = useState<PreviewDeck[]>([]);
const [selectedDeckId, setSelectedDeckId] = useState<number | null>(null);
const [deckName, setDeckName] = useState("");
const [loading, setLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (!selectedFile) return;
if (!selectedFile.name.endsWith(".apkg")) {
toast.error("Please select an .apkg file");
return;
}
setFile(selectedFile);
setLoading(true);
const formData = new FormData();
formData.append("file", selectedFile);
const result = await actionPreviewApkg(formData);
setLoading(false);
if (result.success && result.decks) {
setDecks(result.decks);
setStep("select");
if (result.decks.length === 1) {
setSelectedDeckId(result.decks[0].id);
setDeckName(result.decks[0].name);
}
} else {
toast.error(result.message);
}
};
const handleImport = async () => {
if (!file || selectedDeckId === null) {
toast.error("Please select a deck to import");
return;
}
setStep("importing");
const formData = new FormData();
formData.append("file", file);
formData.append("deckId", selectedDeckId.toString());
if (deckName) {
formData.append("deckName", deckName);
}
const result = await actionImportApkg(formData);
if (result.success) {
toast.success(result.message);
setIsModalOpen(false);
resetState();
onImportComplete?.();
} else {
toast.error(result.message);
setStep("select");
}
};
const resetState = () => {
setStep("upload");
setFile(null);
setDecks([]);
setSelectedDeckId(null);
setDeckName("");
setLoading(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleClose = () => {
setIsModalOpen(false);
resetState();
};
return (
<>
<LightButton onClick={() => setIsModalOpen(true)}>
<Upload size={18} />
{t("importApkg")}
</LightButton>
<Modal open={isModalOpen} onClose={handleClose}>
<div className="p-6 w-full max-w-md">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">{t("importApkg")}</h2>
<button onClick={handleClose} className="text-gray-400 hover:text-gray-600">
<X size={20} />
</button>
</div>
{step === "upload" && (
<div className="space-y-4">
<div
className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-primary-500 transition-colors"
onClick={() => fileInputRef.current?.click()}
>
<FileUp size={40} className="mx-auto text-gray-400 mb-2" />
<p className="text-gray-600">{t("clickToUpload")}</p>
<p className="text-sm text-gray-400">{t("apkgFilesOnly")}</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".apkg"
className="hidden"
onChange={handleFileSelect}
/>
{loading && (
<div className="flex items-center justify-center gap-2 text-gray-500">
<Loader2 size={20} className="animate-spin" />
<span>{t("parsing")}</span>
</div>
)}
</div>
)}
{step === "select" && (
<div className="space-y-4">
<p className="text-sm text-gray-600">{t("foundDecks", { count: decks.length })}</p>
<div className="space-y-2 max-h-48 overflow-y-auto">
{decks.map((deck) => (
<div
key={deck.id}
className={`p-3 border rounded-lg cursor-pointer transition-colors ${
selectedDeckId === deck.id
? "border-primary-500 bg-primary-50"
: "border-gray-200 hover:border-gray-300"
}`}
onClick={() => {
setSelectedDeckId(deck.id);
setDeckName(deck.name);
}}
>
<div className="flex items-center justify-between">
<span className="font-medium">{deck.name}</span>
<span className="text-sm text-gray-500">{deck.cardCount} cards</span>
</div>
</div>
))}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("deckName")}
</label>
<input
type="text"
value={deckName}
onChange={(e) => setDeckName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder={t("enterDeckName")}
/>
</div>
<div className="flex gap-2">
<LightButton onClick={() => setStep("upload")} className="flex-1">
{t("back")}
</LightButton>
<PrimaryButton
onClick={handleImport}
disabled={selectedDeckId === null}
className="flex-1"
>
{t("import")}
</PrimaryButton>
</div>
</div>
)}
{step === "importing" && (
<div className="flex flex-col items-center justify-center py-8">
<Loader2 size={40} className="animate-spin text-primary-500 mb-4" />
<p className="text-gray-600">{t("importing")}</p>
</div>
)}
</div>
</Modal>
</>
);
}
export function ExportButton({ deckId, deckName }: ImportExportProps) {
const t = useTranslations("decks");
const [loading, setLoading] = useState(false);
const handleExport = async () => {
if (!deckId) return;
setLoading(true);
const result = await actionExportApkg(deckId);
setLoading(false);
if (result.success && result.data && result.filename) {
const blob = new Blob([result.data], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = result.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(t("exportSuccess"));
} else {
toast.error(result.message);
}
};
return (
<LightButton onClick={handleExport} disabled={loading}>
{loading ? <Loader2 size={18} className="animate-spin" /> : <Download size={18} />}
{t("exportApkg")}
</LightButton>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import { useState, useTransition } from "react";
import { PrimaryButton, LightButton } from "@/design-system/base/button";
import { actionToggleFollow } from "@/modules/follow/follow-action";
import { toast } from "sonner";
interface FollowButtonProps {
targetUserId: string;
initialIsFollowing: boolean;
onFollowChange?: (isFollowing: boolean, followersCount: number) => void;
}
export function FollowButton({
targetUserId,
initialIsFollowing,
onFollowChange,
}: FollowButtonProps) {
const [isFollowing, setIsFollowing] = useState(initialIsFollowing);
const [isPending, startTransition] = useTransition();
const handleToggleFollow = () => {
startTransition(async () => {
const result = await actionToggleFollow({ targetUserId });
if (result.success && result.data) {
setIsFollowing(result.data.isFollowing);
onFollowChange?.(result.data.isFollowing, result.data.followersCount);
} else {
toast.error(result.message || "Failed to update follow status");
}
});
};
if (isFollowing) {
return (
<LightButton onClick={handleToggleFollow} disabled={isPending}>
{isPending ? "..." : "Following"}
</LightButton>
);
}
return (
<PrimaryButton onClick={handleToggleFollow} disabled={isPending}>
{isPending ? "..." : "Follow"}
</PrimaryButton>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import { useState } from "react";
import { FollowButton } from "./FollowButton";
interface FollowStatsProps {
userId: string;
initialFollowersCount: number;
initialFollowingCount: number;
initialIsFollowing: boolean;
currentUserId?: string;
isOwnProfile: boolean;
username: string;
}
export function FollowStats({
userId,
initialFollowersCount,
initialFollowingCount,
initialIsFollowing,
currentUserId,
isOwnProfile,
username,
}: FollowStatsProps) {
const [followersCount, setFollowersCount] = useState(initialFollowersCount);
const handleFollowChange = (isFollowing: boolean, count: number) => {
setFollowersCount(count);
};
return (
<div className="flex items-center gap-4">
<a
href={`/users/${username}/followers`}
className="text-sm text-gray-600 hover:text-primary-500 transition-colors"
>
<span className="font-semibold text-gray-900">{followersCount}</span> followers
</a>
<a
href={`/users/${username}/following`}
className="text-sm text-gray-600 hover:text-primary-500 transition-colors"
>
<span className="font-semibold text-gray-900">{initialFollowingCount}</span> following
</a>
{currentUserId && !isOwnProfile && (
<FollowButton
targetUserId={userId}
initialIsFollowing={initialIsFollowing}
onFollowChange={handleFollowChange}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,68 @@
import Image from "next/image";
import Link from "next/link";
interface UserItem {
id: string;
username: string | null;
displayUsername: string | null;
image: string | null;
bio: string | null;
}
interface UserListProps {
users: UserItem[];
emptyMessage: string;
}
export function UserList({ users, emptyMessage }: UserListProps) {
if (users.length === 0) {
return (
<div className="text-center py-12 text-gray-500">
{emptyMessage}
</div>
);
}
return (
<div className="space-y-4">
{users.map((user) => (
<Link
key={user.id}
href={`/users/${user.username || user.id}`}
className="flex items-center gap-4 p-4 bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow"
>
{user.image ? (
<div className="relative w-12 h-12 rounded-full overflow-hidden flex-shrink-0">
<Image
src={user.image}
alt={user.displayUsername || user.username || "User"}
fill
className="object-cover"
unoptimized
/>
</div>
) : (
<div className="w-12 h-12 rounded-full bg-primary-500 flex items-center justify-center flex-shrink-0">
<span className="text-lg font-bold text-white">
{(user.displayUsername || user.username || "U")[0].toUpperCase()}
</span>
</div>
)}
<div className="flex-1 min-w-0">
<div className="font-semibold text-gray-900 truncate">
{user.displayUsername || user.username || "Anonymous"}
</div>
{user.username && (
<div className="text-sm text-gray-500">@{user.username}</div>
)}
{user.bio && (
<div className="text-sm text-gray-600 truncate mt-1">
{user.bio}
</div>
)}
</div>
</Link>
))}
</div>
);
}

View File

@@ -1,81 +1,104 @@
"use client"; "use client";
import { GhostLightButton } from "@/design-system/base/button"; import { useState, useEffect, useRef, useCallback } from "react";
import { useState } from "react";
import { Languages } from "lucide-react"; import { Languages } from "lucide-react";
import { cn } from "@/utils/cn";
const languages = [
{ code: "en-US", label: "English" },
{ code: "zh-CN", label: "中文" },
{ code: "ja-JP", label: "日本語" },
{ code: "ko-KR", label: "한국어" },
{ code: "de-DE", label: "Deutsch" },
{ code: "fr-FR", label: "Français" },
{ code: "it-IT", label: "Italiano" },
{ code: "ug-CN", label: "ئۇيغۇرچە" },
];
export function LanguageSettings() { export function LanguageSettings() {
const [showLanguageMenu, setShowLanguageMenu] = useState(false); const [isOpen, setIsOpen] = useState(false);
const handleLanguageClick = () => { const [pendingLocale, setPendingLocale] = useState<string | null>(null);
setShowLanguageMenu((prev) => !prev); const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}; };
const setLocale = async (locale: string) => {
document.cookie = `locale=${locale}`; if (isOpen) {
window.location.reload(); document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
}; };
return ( }, [isOpen]);
<>
<GhostLightButton useEffect(() => {
size="md" const handleEscape = (e: KeyboardEvent) => {
onClick={handleLanguageClick} if (e.key === "Escape") setIsOpen(false);
};
if (isOpen) {
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}
}, [isOpen]);
useEffect(() => {
if (pendingLocale) {
document.cookie = `locale=${pendingLocale}; path=/`;
window.location.reload();
}
}, [pendingLocale]);
const setLocale = useCallback((locale: string) => {
setPendingLocale(locale);
}, []);
return (
<div className="relative" ref={menuRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-center p-2 rounded-md text-white hover:bg-white/10 transition-colors"
aria-label="切换语言"
aria-expanded={isOpen}
>
<Languages size={20} />
</button>
<div
className={cn(
"absolute right-0 top-full mt-2 w-40 rounded-lg bg-white shadow-lg ring-1 ring-black/5 overflow-hidden transition-all duration-200 origin-top-right z-50",
isOpen
? "opacity-100 scale-100"
: "opacity-0 scale-95 pointer-events-none"
)}
role="menu"
>
<div className="py-1">
{languages.map((lang) => (
<button
key={lang.code}
onClick={() => setLocale(lang.code)}
className="w-full flex items-center px-4 py-2.5 text-gray-700 hover:bg-gray-50 hover:text-gray-900 transition-colors text-left"
role="menuitem"
> >
<Languages size={20} /> {lang.label}
</GhostLightButton> </button>
<div className="relative"> ))}
{showLanguageMenu && ( </div>
<div> </div>
<div className="absolute top-10 right-0 rounded-md shadow-md flex flex-col gap-2">
<GhostLightButton {isOpen && (
className="w-full bg-primary-500" <div
onClick={() => setLocale("en-US")} className="fixed inset-0 z-40"
> onClick={() => setIsOpen(false)}
English aria-hidden="true"
</GhostLightButton> />
<GhostLightButton )}
className="w-full bg-primary-500" </div>
onClick={() => setLocale("zh-CN")} );
>
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("ja-JP")}
>
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("ko-KR")}
>
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("de-DE")}
>
Deutsch
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("fr-FR")}
>
Français
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("it-IT")}
>
Italiano
</GhostLightButton>
<GhostLightButton
className="w-full bg-primary-500"
onClick={() => setLocale("ug-CN")}
>
ئۇيغۇرچە
</GhostLightButton>
</div>
</div>
)}
</div></>
);
} }

View File

@@ -0,0 +1,92 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Menu, X } from "lucide-react";
import { cn } from "@/utils/cn";
import type { NavigationItem } from "./Navbar";
interface MobileMenuProps {
items: NavigationItem[];
}
export function MobileMenu({ items }: MobileMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
document.body.style.overflow = "hidden";
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.body.style.overflow = "";
};
}, [isOpen]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsOpen(false);
};
if (isOpen) {
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}
}, [isOpen]);
return (
<div className="relative" ref={menuRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-center p-2 rounded-md text-white hover:bg-white/10 transition-colors"
aria-label={isOpen ? "关闭菜单" : "打开菜单"}
aria-expanded={isOpen}
>
{isOpen ? <X size={24} /> : <Menu size={24} />}
</button>
<div
className={cn(
"absolute right-0 top-full mt-2 w-56 rounded-lg bg-white shadow-lg ring-1 ring-black/5 overflow-hidden transition-all duration-200 origin-top-right z-50",
isOpen
? "opacity-100 scale-100"
: "opacity-0 scale-95 pointer-events-none"
)}
role="menu"
>
<div className="py-1">
{items.map((item, index) => (
<a
key={index}
href={item.href}
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-gray-50 hover:text-gray-900 transition-colors"
role="menuitem"
onClick={() => setIsOpen(false)}
target={item.external ? "_blank" : undefined}
rel={item.external ? "noopener noreferrer" : undefined}
>
{item.icon && <span className="shrink-0 text-gray-500">{item.icon}</span>}
<span>{item.label}</span>
</a>
))}
</div>
</div>
{isOpen && (
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
)}
</div>
);
}

View File

@@ -1,11 +1,18 @@
import Image from "next/image"; import { Compass, Folder, Heart, Home, Settings, User, Github } from "lucide-react";
import { IMAGES } from "@/config/images";
import { Compass, Folder, Heart, Home, User } from "lucide-react";
import { LanguageSettings } from "./LanguageSettings"; import { LanguageSettings } from "./LanguageSettings";
import { MobileMenu } from "./MobileMenu";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { GhostLightButton } from "@/design-system/base/button"; import { GhostLightButton } from "@/design-system/base/button";
import type { ReactNode } from "react";
export interface NavigationItem {
label: string;
href: string;
icon?: ReactNode;
external?: boolean;
}
export async function Navbar() { export async function Navbar() {
const t = await getTranslations("navbar"); const t = await getTranslations("navbar");
@@ -13,49 +20,38 @@ export async function Navbar() {
headers: await headers() headers: await headers()
}); });
const mobileMenuItems: NavigationItem[] = [
{ label: t("folders"), href: "/decks", icon: <Folder size={18} /> },
{ label: t("explore"), href: "/explore", icon: <Compass size={18} /> },
...(session ? [{ label: t("favorites"), href: "/favorites", icon: <Heart size={18} /> }] : []),
{ label: t("sourceCode"), href: "https://github.com/GoddoNebianU/learn-languages", icon: <Github size={18} />, external: true },
{ label: t("settings"), href: "/settings", icon: <Settings size={18} /> },
...(session
? [{ label: t("profile"), href: "/profile", icon: <User size={18} /> }]
: [{ label: t("sign_in"), href: "/login", icon: <User size={18} /> }]
),
];
return ( return (
<div className="flex justify-between items-center w-full h-16 px-4 md:px-8 bg-primary-500 text-white"> <div className="flex justify-between items-center w-full h-16 px-4 md:px-8 bg-primary-500 text-white">
<GhostLightButton href="/" className="border-b hidden! md:block!" size="md"> <GhostLightButton href="/" className="border-b hidden! md:block!" size="md">
{t("title")} {t("title")}
</GhostLightButton> </GhostLightButton>
<GhostLightButton className="block! md:hidden!" size="md" href={"/"}> <GhostLightButton className="block! md:hidden!" size="md" href="/">
<Home size={20} /> <Home size={20} />
</GhostLightButton> </GhostLightButton>
<div className="flex gap-0.5 justify-center items-center"> <div className="flex gap-0.5 justify-center items-center">
<LanguageSettings /> <LanguageSettings />
<GhostLightButton <GhostLightButton href="/decks" className="md:block! hidden!" size="md">
className="md:hidden! block!"
size="md"
href="https://github.com/GoddoNebianU/learn-languages"
>
<Image
src={IMAGES.github_mark_white}
alt="GitHub"
width={20}
height={20}
/>
</GhostLightButton>
<GhostLightButton href="/folders" className="md:block! hidden!" size="md">
{t("folders")} {t("folders")}
</GhostLightButton> </GhostLightButton>
<GhostLightButton href="/folders" className="md:hidden! block!" size="md">
<Folder size={20} />
</GhostLightButton>
<GhostLightButton href="/explore" className="md:block! hidden!" size="md"> <GhostLightButton href="/explore" className="md:block! hidden!" size="md">
{t("explore")} {t("explore")}
</GhostLightButton> </GhostLightButton>
<GhostLightButton href="/explore" className="md:hidden! block!" size="md">
<Compass size={20} />
</GhostLightButton>
{session && ( {session && (
<> <GhostLightButton href="/favorites" className="md:block! hidden!" size="md">
<GhostLightButton href="/favorites" className="md:block! hidden!" size="md"> {t("favorites")}
{t("favorites")} </GhostLightButton>
</GhostLightButton>
<GhostLightButton href="/favorites" className="md:hidden! block!" size="md">
<Heart size={20} />
</GhostLightButton>
</>
)} )}
<GhostLightButton <GhostLightButton
className="hidden! md:block!" className="hidden! md:block!"
@@ -64,23 +60,21 @@ export async function Navbar() {
> >
{t("sourceCode")} {t("sourceCode")}
</GhostLightButton> </GhostLightButton>
{ <GhostLightButton href="/settings" className="hidden! md:block!" size="md">
(() => { {t("settings")}
return session && </GhostLightButton>
<> {session ? (
<GhostLightButton href="/profile" className="hidden! md:block!" size="md">{t("profile")}</GhostLightButton> <GhostLightButton href="/profile" className="hidden! md:block!" size="md">
<GhostLightButton href="/profile" className="md:hidden! block!" size="md"> {t("profile")}
<User size={20} /> </GhostLightButton>
</GhostLightButton> ) : (
</> <GhostLightButton href="/login" className="hidden! md:block!" size="md">
|| <> {t("sign_in")}
<GhostLightButton href="/login" className="hidden! md:block!" size="md">{t("sign_in")}</GhostLightButton> </GhostLightButton>
<GhostLightButton href="/login" className="md:hidden! block!" size="md"> )}
<User size={20} /> <div className="md:hidden!">
</GhostLightButton> <MobileMenu items={mobileMenuItems} />
</>; </div>
})()
}
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,79 @@
"use client";
import { createContext, useContext, useEffect, useState, useMemo } from "react";
import {
THEME_PRESETS,
DEFAULT_THEME,
getThemePreset,
applyThemeColors,
type ThemePreset,
} from "@/shared/theme-presets";
type ThemeContextType = {
currentTheme: string;
themePreset: ThemePreset;
setTheme: (themeId: string) => void;
availableThemes: ThemePreset[];
};
const ThemeContext = createContext<ThemeContextType | null>(null);
const STORAGE_KEY = "theme-preset";
function getInitialTheme(): string {
if (typeof window === "undefined") return DEFAULT_THEME;
const saved = localStorage.getItem(STORAGE_KEY);
return saved && getThemePreset(saved) ? saved : DEFAULT_THEME;
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [currentTheme, setCurrentTheme] = useState<string>(DEFAULT_THEME);
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
const savedTheme = getInitialTheme();
if (savedTheme !== currentTheme) {
setCurrentTheme(savedTheme);
}
setHydrated(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!hydrated) return;
const preset = getThemePreset(currentTheme);
if (preset) {
applyThemeColors(preset);
localStorage.setItem(STORAGE_KEY, currentTheme);
}
}, [currentTheme, hydrated]);
const setTheme = (themeId: string) => {
if (getThemePreset(themeId)) {
setCurrentTheme(themeId);
}
};
const themePreset = useMemo(() => getThemePreset(currentTheme) || THEME_PRESETS[0], [currentTheme]);
return (
<ThemeContext.Provider
value={{
currentTheme,
themePreset,
setTheme,
availableThemes: THEME_PRESETS,
}}
>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}

View File

@@ -31,6 +31,7 @@ const selectVariants = cva(
default: "border-b-2 border-gray-300 bg-transparent rounded-t-md", default: "border-b-2 border-gray-300 bg-transparent rounded-t-md",
bordered: "border-gray-300 bg-white", bordered: "border-gray-300 bg-white",
filled: "border-transparent bg-gray-100", filled: "border-transparent bg-gray-100",
light: "border-transparent bg-gray-100 shadow-sm hover:bg-gray-200 font-semibold cursor-pointer",
}, },
size: { size: {
sm: "h-9 px-3 text-sm", sm: "h-9 px-3 text-sm",
@@ -48,6 +49,11 @@ const selectVariants = cva(
error: true, error: true,
className: "bg-error-50", className: "bg-error-50",
}, },
{
variant: "light",
error: true,
className: "bg-error-50 hover:bg-error-100",
},
], ],
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",

View File

@@ -0,0 +1,419 @@
import JSZip from "jszip";
import initSqlJs from "sql.js";
import type { Database } from "sql.js";
import type {
AnkiDeck,
AnkiNoteType,
AnkiDeckConfig,
AnkiNoteRow,
AnkiCardRow,
AnkiRevlogRow,
} from "./types";
const FIELD_SEPARATOR = "\x1f";
const BASE91_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&()*+,-./:;<=>?@[]^_`{|}~";
function generateGuid(): string {
let result = "";
const id = Date.now() ^ (Math.random() * 0xffffffff);
let num = BigInt(id);
for (let i = 0; i < 10; i++) {
result = BASE91_CHARS[Number(num % 91n)] + result;
num = num / 91n;
}
return result;
}
function checksum(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash) % 100000000;
}
function createCollectionSql(): string {
return `
CREATE TABLE col (
id INTEGER PRIMARY KEY,
crt INTEGER NOT NULL,
mod INTEGER NOT NULL,
scm INTEGER NOT NULL,
ver INTEGER NOT NULL DEFAULT 11,
dty INTEGER NOT NULL DEFAULT 0,
usn INTEGER NOT NULL DEFAULT 0,
ls INTEGER NOT NULL DEFAULT 0,
conf TEXT NOT NULL,
models TEXT NOT NULL,
decks TEXT NOT NULL,
dconf TEXT NOT NULL,
tags TEXT NOT NULL
);
CREATE TABLE notes (
id INTEGER PRIMARY KEY,
guid TEXT NOT NULL,
mid INTEGER NOT NULL,
mod INTEGER NOT NULL,
usn INTEGER NOT NULL,
tags TEXT NOT NULL,
flds TEXT NOT NULL,
sfld TEXT NOT NULL,
csum INTEGER NOT NULL,
flags INTEGER NOT NULL DEFAULT 0,
data TEXT NOT NULL DEFAULT ''
);
CREATE TABLE cards (
id INTEGER PRIMARY KEY,
nid INTEGER NOT NULL,
did INTEGER NOT NULL,
ord INTEGER NOT NULL,
mod INTEGER NOT NULL,
usn INTEGER NOT NULL,
type INTEGER NOT NULL,
queue INTEGER NOT NULL,
due INTEGER NOT NULL,
ivl INTEGER NOT NULL,
factor INTEGER NOT NULL,
reps INTEGER NOT NULL,
lapses INTEGER NOT NULL,
left INTEGER NOT NULL,
odue INTEGER NOT NULL DEFAULT 0,
odid INTEGER NOT NULL DEFAULT 0,
flags INTEGER NOT NULL DEFAULT 0,
data TEXT NOT NULL DEFAULT ''
);
CREATE TABLE revlog (
id INTEGER PRIMARY KEY,
cid INTEGER NOT NULL,
usn INTEGER NOT NULL,
ease INTEGER NOT NULL,
ivl INTEGER NOT NULL,
lastIvl INTEGER NOT NULL,
factor INTEGER NOT NULL,
time INTEGER NOT NULL,
type INTEGER NOT NULL
);
CREATE TABLE graves (
usn INTEGER NOT NULL,
oid INTEGER NOT NULL,
type INTEGER NOT NULL
);
CREATE INDEX ix_cards_nid ON cards (nid);
CREATE INDEX ix_cards_sched ON cards (did, queue, due);
CREATE INDEX ix_cards_usn ON cards (usn);
CREATE INDEX ix_notes_csum ON notes (csum);
CREATE INDEX ix_notes_usn ON notes (usn);
CREATE INDEX ix_revlog_cid ON revlog (cid);
CREATE INDEX ix_revlog_usn ON revlog (usn);
`;
}
function mapCardType(type: string): number {
switch (type) {
case "NEW": return 0;
case "LEARNING": return 1;
case "REVIEW": return 2;
case "RELEARNING": return 3;
default: return 0;
}
}
function mapCardQueue(queue: string): number {
switch (queue) {
case "USER_BURIED": return -3;
case "SCHED_BURIED": return -2;
case "SUSPENDED": return -1;
case "NEW": return 0;
case "LEARNING": return 1;
case "REVIEW": return 2;
case "IN_LEARNING": return 3;
case "PREVIEW": return 4;
default: return 0;
}
}
export interface ExportDeckData {
deck: {
id: number;
name: string;
desc: string;
collapsed: boolean;
conf: Record<string, unknown>;
};
noteType: {
id: number;
name: string;
kind: "STANDARD" | "CLOZE";
css: string;
fields: { name: string; ord: number }[];
templates: { name: string; ord: number; qfmt: string; afmt: string }[];
};
notes: {
id: bigint;
guid: string;
tags: string;
flds: string;
sfld: string;
csum: number;
}[];
cards: {
id: bigint;
noteId: bigint;
ord: number;
type: string;
queue: string;
due: number;
ivl: number;
factor: number;
reps: number;
lapses: number;
left: number;
}[];
revlogs: {
id: bigint;
cardId: bigint;
ease: number;
ivl: number;
lastIvl: number;
factor: number;
time: number;
type: number;
}[];
media: Map<string, Buffer>;
}
async function createDatabase(data: ExportDeckData): Promise<Uint8Array> {
const SQL = await initSqlJs({
locateFile: (file: string) => `https://sql.js.org/dist/${file}`,
});
const db = new SQL.Database();
db.run(createCollectionSql());
const now = Date.now();
const nowSeconds = Math.floor(now / 1000);
const defaultConfig = {
dueCounts: true,
estTimes: true,
newSpread: 0,
curDeck: data.deck.id,
curModel: data.noteType.id,
};
const deckJson: Record<string, AnkiDeck> = {
[data.deck.id.toString()]: {
id: data.deck.id,
mod: nowSeconds,
name: data.deck.name,
usn: -1,
lrnToday: [0, 0],
revToday: [0, 0],
newToday: [0, 0],
timeToday: [0, 0],
collapsed: data.deck.collapsed,
browserCollapsed: false,
desc: data.deck.desc,
dyn: 0,
conf: 1,
extendNew: 0,
extendRev: 0,
},
"1": {
id: 1,
mod: nowSeconds,
name: "Default",
usn: -1,
lrnToday: [0, 0],
revToday: [0, 0],
newToday: [0, 0],
timeToday: [0, 0],
collapsed: false,
browserCollapsed: false,
desc: "",
dyn: 0,
conf: 1,
extendNew: 0,
extendRev: 0,
},
};
const noteTypeJson: Record<string, AnkiNoteType> = {
[data.noteType.id.toString()]: {
id: data.noteType.id,
name: data.noteType.name,
type: data.noteType.kind === "CLOZE" ? 1 : 0,
mod: nowSeconds,
usn: -1,
sortf: 0,
did: data.deck.id,
flds: data.noteType.fields.map((f, i) => ({
id: now + i,
name: f.name,
ord: f.ord,
sticky: false,
rtl: false,
font: "Arial",
size: 20,
media: [],
})),
tmpls: data.noteType.templates.map((t, i) => ({
id: now + i + 100,
name: t.name,
ord: t.ord,
qfmt: t.qfmt,
afmt: t.afmt,
did: null,
})),
css: data.noteType.css,
latexPre: "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n",
latexPost: "\\end{document}",
latexsvg: false,
req: [],
},
};
const deckConfigJson: Record<string, AnkiDeckConfig> = {
"1": {
id: 1,
mod: nowSeconds,
name: "Default",
usn: -1,
maxTaken: 60,
autoplay: true,
timer: 0,
replayq: true,
new: {
bury: true,
delays: [1, 10],
initialFactor: 2500,
ints: [1, 4, 7],
order: 1,
perDay: 20,
},
rev: {
bury: true,
ease4: 1.3,
ivlFct: 1,
maxIvl: 36500,
perDay: 200,
hardFactor: 1.2,
},
lapse: {
delays: [10],
leechAction: 0,
leechFails: 8,
minInt: 1,
mult: 0,
},
dyn: false,
},
};
db.run(
`INSERT INTO col (id, crt, mod, scm, ver, dty, usn, ls, conf, models, decks, dconf, tags)
VALUES (1, ?, ?, ?, 11, 0, 0, 0, ?, ?, ?, ?, '{}')`,
[
nowSeconds,
now,
now,
JSON.stringify(defaultConfig),
JSON.stringify(noteTypeJson),
JSON.stringify(deckJson),
JSON.stringify(deckConfigJson),
]
);
for (const note of data.notes) {
db.run(
`INSERT INTO notes (id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, '')`,
[
Number(note.id),
note.guid || generateGuid(),
data.noteType.id,
nowSeconds,
-1,
note.tags || " ",
note.flds,
note.sfld,
note.csum || checksum(note.sfld),
]
);
}
for (const card of data.cards) {
db.run(
`INSERT INTO cards (id, nid, did, ord, mod, usn, type, queue, due, ivl, factor, reps, lapses, left, odue, odid, flags, data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, '')`,
[
Number(card.id),
Number(card.noteId),
data.deck.id,
card.ord,
nowSeconds,
-1,
mapCardType(card.type),
mapCardQueue(card.queue),
card.due,
card.ivl,
card.factor,
card.reps,
card.lapses,
card.left,
]
);
}
for (const revlog of data.revlogs) {
db.run(
`INSERT INTO revlog (id, cid, usn, ease, ivl, lastIvl, factor, time, type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
Number(revlog.id),
Number(revlog.cardId),
-1,
revlog.ease,
revlog.ivl,
revlog.lastIvl,
revlog.factor,
revlog.time,
revlog.type,
]
);
}
const dbData = db.export();
db.close();
return dbData;
}
export async function exportApkg(data: ExportDeckData): Promise<Buffer> {
const zip = new JSZip();
const dbData = await createDatabase(data);
zip.file("collection.anki21", dbData);
const mediaMapping: Record<string, string> = {};
const mediaEntries = Array.from(data.media.entries());
mediaEntries.forEach(([filename, buffer], index) => {
mediaMapping[index.toString()] = filename;
zip.file(index.toString(), buffer);
});
zip.file("media", JSON.stringify(mediaMapping));
return zip.generateAsync({ type: "nodebuffer" });
}

174
src/lib/anki/apkg-parser.ts Normal file
View File

@@ -0,0 +1,174 @@
import JSZip from "jszip";
import initSqlJs from "sql.js";
import type { Database, SqlValue } from "sql.js";
import {
type AnkiDeck,
type AnkiNoteType,
type AnkiDeckConfig,
type AnkiNoteRow,
type AnkiCardRow,
type AnkiRevlogRow,
type ParsedApkg,
} from "./types";
async function openDatabase(zip: JSZip): Promise<Database | null> {
const SQL = await initSqlJs({
locateFile: (file: string) => `https://sql.js.org/dist/${file}`,
});
const anki21b = zip.file("collection.anki21b");
const anki21 = zip.file("collection.anki21");
const anki2 = zip.file("collection.anki2");
let dbFile = anki21b || anki21 || anki2;
if (!dbFile) return null;
const dbData = await dbFile.async("uint8array");
return new SQL.Database(dbData);
}
function parseJsonField<T>(jsonStr: string): T {
try {
return JSON.parse(jsonStr);
} catch {
return {} as T;
}
}
function queryAll<T>(db: Database, sql: string, params: SqlValue[] = []): T[] {
const results: T[] = [];
const stmt = db.prepare(sql);
stmt.bind(params);
while (stmt.step()) {
const row = stmt.getAsObject();
results.push(row as T);
}
stmt.free();
return results;
}
function queryOne<T>(db: Database, sql: string, params: SqlValue[] = []): T | null {
const results = queryAll<T>(db, sql, params);
return results[0] ?? null;
}
export async function parseApkg(buffer: Buffer): Promise<ParsedApkg> {
const zip = await JSZip.loadAsync(buffer);
const db = await openDatabase(zip);
if (!db) {
throw new Error("No valid Anki database found in APKG file");
}
const col = queryOne<{
crt: number;
mod: number;
ver: number;
conf: string;
models: string;
decks: string;
dconf: string;
tags: string;
}>(db, "SELECT crt, mod, ver, conf, models, decks, dconf, tags FROM col WHERE id = 1");
if (!col) {
db.close();
throw new Error("Invalid APKG: no collection row found");
}
const decksMap = new Map<number, AnkiDeck>();
const decksJson = parseJsonField<Record<string, AnkiDeck>>(col.decks);
for (const [id, deck] of Object.entries(decksJson)) {
decksMap.set(parseInt(id, 10), deck);
}
const noteTypesMap = new Map<number, AnkiNoteType>();
const modelsJson = parseJsonField<Record<string, AnkiNoteType>>(col.models);
for (const [id, model] of Object.entries(modelsJson)) {
noteTypesMap.set(parseInt(id, 10), model);
}
const deckConfigsMap = new Map<number, AnkiDeckConfig>();
const dconfJson = parseJsonField<Record<string, AnkiDeckConfig>>(col.dconf);
for (const [id, config] of Object.entries(dconfJson)) {
deckConfigsMap.set(parseInt(id, 10), config);
}
const notes = queryAll<AnkiNoteRow>(
db,
"SELECT id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data FROM notes"
);
const cards = queryAll<AnkiCardRow>(
db,
"SELECT id, nid, did, ord, mod, usn, type, queue, due, ivl, factor, reps, lapses, left, odue, odid, flags, data FROM cards"
);
const revlogs = queryAll<AnkiRevlogRow>(
db,
"SELECT id, cid, usn, ease, ivl, lastIvl, factor, time, type FROM revlog"
);
const mediaMap = new Map<string, Buffer>();
const mediaFile = zip.file("media");
if (mediaFile) {
const mediaJson = parseJsonField<Record<string, string>>(await mediaFile.async("text"));
for (const [num, filename] of Object.entries(mediaJson)) {
const mediaData = zip.file(num);
if (mediaData) {
const data = await mediaData.async("nodebuffer");
mediaMap.set(filename, data);
}
}
}
db.close();
return {
decks: decksMap,
noteTypes: noteTypesMap,
deckConfigs: deckConfigsMap,
notes,
cards,
revlogs,
media: mediaMap,
collectionMeta: {
crt: col.crt,
mod: col.mod,
ver: col.ver,
},
};
}
export function getDeckNotesAndCards(
parsed: ParsedApkg,
deckId: number
): { notes: AnkiNoteRow[]; cards: AnkiCardRow[] } {
const deckCards = parsed.cards.filter(c => c.did === deckId);
const noteIds = new Set(deckCards.map(c => c.nid));
const deckNotes = parsed.notes.filter(n => noteIds.has(n.id));
return { notes: deckNotes, cards: deckCards };
}
export function getDeckNames(parsed: ParsedApkg): { id: number; name: string; cardCount: number }[] {
const cardCounts = new Map<number, number>();
for (const card of parsed.cards) {
cardCounts.set(card.did, (cardCounts.get(card.did) ?? 0) + 1);
}
const result: { id: number; name: string; cardCount: number }[] = [];
for (const [id, deck] of parsed.decks) {
if (deck.dyn === 0) {
result.push({
id,
name: deck.name,
cardCount: cardCounts.get(id) ?? 0,
});
}
}
return result.sort((a, b) => a.name.localeCompare(b.name));
}

193
src/lib/anki/types.ts Normal file
View File

@@ -0,0 +1,193 @@
/**
* Anki APKG format types
* Based on Anki's official database schema
*/
// ============================================
// APKG JSON Configuration Types
// ============================================
export interface AnkiField {
id: number;
name: string;
ord: number;
sticky: boolean;
rtl: boolean;
font: string;
size: number;
media: string[];
description?: string;
plainText?: boolean;
collapsed?: boolean;
excludeFromSearch?: boolean;
tag?: number;
preventDeletion?: boolean;
}
export interface AnkiTemplate {
id: number | null;
name: string;
ord: number;
qfmt: string;
afmt: string;
bqfmt?: string;
bafmt?: string;
did?: number | null;
bfont?: string;
bsize?: number;
}
export interface AnkiNoteType {
id: number;
name: string;
type: 0 | 1; // 0=standard, 1=cloze
mod: number;
usn: number;
sortf: number;
did: number | null;
tmpls: AnkiTemplate[];
flds: AnkiField[];
css: string;
latexPre: string;
latexPost: string;
latexsvg: boolean | null;
req: [number, string, number[]][];
originalStockKind?: number;
}
export interface AnkiDeckConfig {
id: number;
mod: number;
name: string;
usn: number;
maxTaken: number;
autoplay: boolean;
timer: 0 | 1;
replayq: boolean;
new: {
bury: boolean;
delays: number[];
initialFactor: number;
ints: [number, number, number];
order: number;
perDay: number;
};
rev: {
bury: boolean;
ease4: number;
ivlFct: number;
maxIvl: number;
perDay: number;
hardFactor: number;
};
lapse: {
delays: number[];
leechAction: 0 | 1;
leechFails: number;
minInt: number;
mult: number;
};
dyn: boolean;
}
export interface AnkiDeck {
id: number;
mod: number;
name: string;
usn: number;
lrnToday: [number, number];
revToday: [number, number];
newToday: [number, number];
timeToday: [number, number];
collapsed: boolean;
browserCollapsed: boolean;
desc: string;
dyn: 0 | 1;
conf: number;
extendNew: number;
extendRev: number;
reviewLimit?: number | null;
newLimit?: number | null;
reviewLimitToday?: number | null;
newLimitToday?: number | null;
md?: boolean;
}
// ============================================
// APKG Database Row Types
// ============================================
export interface AnkiNoteRow {
id: number;
guid: string;
mid: number;
mod: number;
usn: number;
tags: string;
flds: string;
sfld: string;
csum: number;
flags: number;
data: string;
}
export interface AnkiCardRow {
id: number;
nid: number;
did: number;
ord: number;
mod: number;
usn: number;
type: number; // 0=new, 1=learning, 2=review, 3=relearning
queue: number; // -3=buried(user), -2=buried(sched), -1=suspended, 0=new, 1=learning, 2=review, 3=day learning, 4=preview
due: number;
ivl: number;
factor: number;
reps: number;
lapses: number;
left: number;
odue: number;
odid: number;
flags: number;
data: string;
}
export interface AnkiRevlogRow {
id: number;
cid: number;
usn: number;
ease: number;
ivl: number;
lastIvl: number;
factor: number;
time: number;
type: number;
}
// ============================================
// Parsed APKG Types
// ============================================
export interface ParsedApkg {
decks: Map<number, AnkiDeck>;
noteTypes: Map<number, AnkiNoteType>;
deckConfigs: Map<number, AnkiDeckConfig>;
notes: AnkiNoteRow[];
cards: AnkiCardRow[];
revlogs: AnkiRevlogRow[];
media: Map<string, Buffer>;
collectionMeta: {
crt: number;
mod: number;
ver: number;
};
}
export interface ApkgImportResult {
success: boolean;
deckName: string;
noteCount: number;
cardCount: number;
mediaCount: number;
errors: string[];
}

View File

@@ -0,0 +1,152 @@
import OpenAI from "openai";
import { parseAIGeneratedJSON } from "@/utils/json";
import { createLogger } from "@/lib/logger";
import { OCRInput, OCROutput, OCRRawResponse } from "./types";
const log = createLogger("ocr-orchestrator");
const openai = new OpenAI({
apiKey: process.env.ZHIPU_API_KEY,
baseURL: "https://open.bigmodel.cn/api/paas/v4",
});
/**
* Executes OCR on an image to extract vocabulary word-definition pairs.
*
* Uses GLM-4.6V vision model to analyze vocabulary table images and
* extract structured word-definition pairs.
*
* @param input - OCR input containing base64 image and optional language hints
* @returns Structured output with extracted pairs and detected languages
* @throws Error if OCR fails or response is malformed
*
* @example
* ```typescript
* const result = await executeOCR({
* imageBase64: "iVBORw0KGgo...",
* sourceLanguage: "English",
* targetLanguage: "Chinese"
* });
* // result.pairs: [{ word: "hello", definition: "你好" }, ...]
* ```
*/
export async function executeOCR(input: OCRInput): Promise<OCROutput> {
const { imageBase64, sourceLanguage, targetLanguage } = input;
log.debug("Starting OCR", {
hasSourceHint: !!sourceLanguage,
hasTargetHint: !!targetLanguage,
imageLength: imageBase64.length,
});
const languageHints: string[] = [];
if (sourceLanguage) {
languageHints.push(`源语言提示: ${sourceLanguage}`);
}
if (targetLanguage) {
languageHints.push(`目标语言提示: ${targetLanguage}`);
}
const prompt = `
你是一个专业的OCR识别助手专门从词汇表截图中提取单词和释义。
${languageHints.length > 0 ? `语言提示:\n${languageHints.join("\n")}\n` : ""}
你的任务是分析图片中的词汇表,提取所有单词-释义对。
要求:
1. 识别图片中的词汇表结构(可能是两列或多列)
2. 提取每一行的单词和对应的释义/翻译
3. 自动检测源语言和目标语言
4. 保持原始大小写和拼写
5. 如果图片模糊或不清晰,尽力识别并标注置信度较低的项目
6. 忽略表头、页码等非词汇内容
返回 JSON 格式:
{
"pairs": [
{ "word": "单词1", "definition": "释义1" },
{ "word": "单词2", "definition": "释义2" }
],
"detectedSourceLanguage": "检测到的源语言",
"detectedTargetLanguage": "检测到的目标语言"
}
只返回 JSON不要任何其他文字。
`.trim();
try {
const response = await openai.chat.completions.create({
model: "glm-4.6v",
messages: [
{
role: "user",
content: [
{
type: "image_url",
image_url: {
url: imageBase64,
},
},
{
type: "text",
text: prompt,
},
],
},
],
temperature: 0.1,
});
const content = response.choices[0]?.message?.content;
if (!content) {
log.error("OCR returned empty response");
throw new Error("OCR 返回空响应");
}
log.debug("Received OCR response", { contentLength: content.length });
const parsed = parseAIGeneratedJSON<OCRRawResponse>(content);
if (!parsed.pairs || !Array.isArray(parsed.pairs)) {
log.error("Invalid OCR response: missing or invalid pairs array", { parsed });
throw new Error("OCR 响应格式无效:缺少 pairs 数组");
}
const validPairs = parsed.pairs.filter((pair) => {
const isValid = typeof pair.word === "string" && typeof pair.definition === "string";
if (!isValid) {
log.warn("Skipping invalid pair", { pair });
}
return isValid;
});
if (validPairs.length === 0) {
log.error("No valid pairs extracted from image");
throw new Error("未能从图片中提取有效的词汇对");
}
const result: OCROutput = {
pairs: validPairs,
detectedSourceLanguage: parsed.detectedSourceLanguage,
detectedTargetLanguage: parsed.detectedTargetLanguage,
};
log.info("OCR completed successfully", {
pairCount: result.pairs.length,
sourceLanguage: result.detectedSourceLanguage,
targetLanguage: result.detectedTargetLanguage,
});
return result;
} catch (error) {
if (error instanceof Error && error.message.startsWith("OCR")) {
throw error;
}
log.error("OCR failed", { error });
const errorMessage = error instanceof Error ? error.message : "未知错误";
throw new Error(`OCR 处理失败: ${errorMessage}`);
}
}

View File

@@ -0,0 +1,44 @@
/**
* Input for OCR pipeline
*/
export interface OCRInput {
/** Base64 encoded image (without data URL prefix) */
imageBase64: string;
/** Optional: hint about source language */
sourceLanguage?: string;
/** Optional: hint about target/translation language */
targetLanguage?: string;
}
/**
* Single word-definition pair extracted from image
*/
export interface VocabularyPair {
/** The original word */
word: string;
/** The translation/definition */
definition: string;
}
/**
* Output from OCR pipeline
*/
export interface OCROutput {
/** Extracted word-definition pairs */
pairs: VocabularyPair[];
/** Detected source language */
detectedSourceLanguage?: string;
/** Detected target/translation language */
detectedTargetLanguage?: string;
}
/**
* Internal structure for AI response parsing
*/
interface OCRRawResponse {
pairs: Array<{ word: string; definition: string }>;
detectedSourceLanguage?: string;
detectedTargetLanguage?: string;
}
export type { OCRRawResponse };

View File

@@ -132,19 +132,28 @@ async function generateIPA(
export async function executeTranslation( export async function executeTranslation(
sourceText: string, sourceText: string,
targetLanguage: string, targetLanguage: string,
needIpa: boolean needIpa: boolean,
sourceLanguage?: string
): Promise<TranslationLLMResponse> { ): Promise<TranslationLLMResponse> {
try { try {
log.debug("Starting translation", { sourceText, targetLanguage, needIpa }); log.debug("Starting translation", { sourceText, targetLanguage, needIpa, sourceLanguage });
log.debug("[Stage 1] Detecting source language"); let detectedLanguage: string;
const detectionResult = await detectLanguage(sourceText);
log.debug("[Stage 1] Detection result", { detectionResult }); if (sourceLanguage) {
log.debug("[Stage 1] Using provided source language", { sourceLanguage });
detectedLanguage = sourceLanguage;
} else {
log.debug("[Stage 1] Detecting source language");
const detectionResult = await detectLanguage(sourceText);
log.debug("[Stage 1] Detection result", { detectionResult });
detectedLanguage = detectionResult.sourceLanguage;
}
log.debug("[Stage 2] Performing translation"); log.debug("[Stage 2] Performing translation");
const translatedText = await performTranslation( const translatedText = await performTranslation(
sourceText, sourceText,
detectionResult.sourceLanguage, detectedLanguage,
targetLanguage targetLanguage
); );
log.debug("[Stage 2] Translation complete", { translatedText }); log.debug("[Stage 2] Translation complete", { translatedText });
@@ -160,7 +169,7 @@ export async function executeTranslation(
if (needIpa) { if (needIpa) {
log.debug("[Stage 3] Generating IPA"); log.debug("[Stage 3] Generating IPA");
sourceIpa = await generateIPA(sourceText, detectionResult.sourceLanguage); sourceIpa = await generateIPA(sourceText, detectedLanguage);
log.debug("[Stage 3] Source IPA", { sourceIpa }); log.debug("[Stage 3] Source IPA", { sourceIpa });
targetIpa = await generateIPA(translatedText, targetLanguage); targetIpa = await generateIPA(translatedText, targetLanguage);
@@ -171,7 +180,7 @@ export async function executeTranslation(
const finalResult: TranslationLLMResponse = { const finalResult: TranslationLLMResponse = {
sourceText, sourceText,
translatedText, translatedText,
sourceLanguage: detectionResult.sourceLanguage, sourceLanguage: detectedLanguage,
targetLanguage, targetLanguage,
sourceIpa, sourceIpa,
targetIpa, targetIpa,

View File

@@ -56,7 +56,13 @@ export type ActionOutputUserProfile = {
username: string | null; username: string | null;
displayUsername: string | null; displayUsername: string | null;
image: string | null; image: string | null;
bio: string | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
}; };
}; };
export type ActionOutputDeleteAccount = {
success: boolean;
message: string;
};

View File

@@ -10,6 +10,7 @@ import {
ActionInputSignIn, ActionInputSignIn,
ActionInputSignUp, ActionInputSignUp,
ActionOutputAuth, ActionOutputAuth,
ActionOutputDeleteAccount,
ActionOutputUserProfile, ActionOutputUserProfile,
validateActionInputGetUserProfileByUsername, validateActionInputGetUserProfileByUsername,
validateActionInputSignIn, validateActionInputSignIn,
@@ -18,7 +19,8 @@ import {
import { import {
serviceGetUserProfileByUsername, serviceGetUserProfileByUsername,
serviceSignIn, serviceSignIn,
serviceSignUp serviceSignUp,
serviceDeleteAccount
} from "./auth-service"; } from "./auth-service";
// Re-export types for use in components // Re-export types for use in components
@@ -180,3 +182,27 @@ export async function actionGetUserProfileByUsername(dto: ActionInputGetUserProf
}; };
} }
} }
/**
* Delete account action
* Permanently deletes the current user and all associated data
*/
export async function actionDeleteAccount(): Promise<ActionOutputDeleteAccount> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return { success: false, message: "Unauthorized" };
}
const result = await serviceDeleteAccount({ userId: session.user.id });
if (!result.success) {
return { success: false, message: "Failed to delete account" };
}
return { success: true, message: "Account deleted successfully" };
} catch (e) {
log.error("Delete account failed", { error: e });
return { success: false, message: "Failed to delete account" };
}
}

View File

@@ -8,6 +8,7 @@ export type RepoOutputUserProfile = {
username: string | null; username: string | null;
displayUsername: string | null; displayUsername: string | null;
image: string | null; image: string | null;
bio: string | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} | null; } | null;
@@ -24,3 +25,12 @@ export type RepoInputFindUserById = {
export type RepoInputFindUserByEmail = { export type RepoInputFindUserByEmail = {
email: string; email: string;
}; };
// Delete user cascade types
export type RepoInputDeleteUserCascade = {
userId: string;
};
export type RepoOutputDeleteUserCascade = {
success: boolean;
};

View File

@@ -1,14 +1,16 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { createLogger } from "@/lib/logger";
import { import {
RepoInputFindUserByEmail, RepoInputFindUserByEmail,
RepoInputFindUserById, RepoInputFindUserById,
RepoInputFindUserByUsername, RepoInputFindUserByUsername,
RepoOutputUserProfile RepoInputDeleteUserCascade,
RepoOutputUserProfile,
RepoOutputDeleteUserCascade
} from "./auth-repository-dto"; } from "./auth-repository-dto";
/** const log = createLogger("auth-repository");
* Find user by username
*/
export async function repoFindUserByUsername(dto: RepoInputFindUserByUsername): Promise<RepoOutputUserProfile> { export async function repoFindUserByUsername(dto: RepoInputFindUserByUsername): Promise<RepoOutputUserProfile> {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { username: dto.username }, where: { username: dto.username },
@@ -19,6 +21,7 @@ export async function repoFindUserByUsername(dto: RepoInputFindUserByUsername):
username: true, username: true,
displayUsername: true, displayUsername: true,
image: true, image: true,
bio: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
} }
@@ -27,9 +30,6 @@ export async function repoFindUserByUsername(dto: RepoInputFindUserByUsername):
return user; return user;
} }
/**
* Find user by ID
*/
export async function repoFindUserById(dto: RepoInputFindUserById): Promise<RepoOutputUserProfile> { export async function repoFindUserById(dto: RepoInputFindUserById): Promise<RepoOutputUserProfile> {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: dto.id }, where: { id: dto.id },
@@ -40,6 +40,7 @@ export async function repoFindUserById(dto: RepoInputFindUserById): Promise<Repo
username: true, username: true,
displayUsername: true, displayUsername: true,
image: true, image: true,
bio: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
} }
@@ -48,9 +49,6 @@ export async function repoFindUserById(dto: RepoInputFindUserById): Promise<Repo
return user; return user;
} }
/**
* Find user by email
*/
export async function repoFindUserByEmail(dto: RepoInputFindUserByEmail): Promise<RepoOutputUserProfile> { export async function repoFindUserByEmail(dto: RepoInputFindUserByEmail): Promise<RepoOutputUserProfile> {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { email: dto.email }, where: { email: dto.email },
@@ -61,6 +59,7 @@ export async function repoFindUserByEmail(dto: RepoInputFindUserByEmail): Promis
username: true, username: true,
displayUsername: true, displayUsername: true,
image: true, image: true,
bio: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
} }
@@ -68,3 +67,68 @@ export async function repoFindUserByEmail(dto: RepoInputFindUserByEmail): Promis
return user; return user;
} }
export async function repoDeleteUserCascade(dto: RepoInputDeleteUserCascade): Promise<RepoOutputDeleteUserCascade> {
const { userId } = dto;
log.info("Starting cascade delete for user", { userId });
await prisma.$transaction(async (tx) => {
await tx.revlog.deleteMany({
where: { card: { note: { userId } } }
});
await tx.card.deleteMany({
where: { note: { userId } }
});
await tx.note.deleteMany({
where: { userId }
});
await tx.noteType.deleteMany({
where: { userId }
});
await tx.deckFavorite.deleteMany({
where: { userId }
});
await tx.deck.deleteMany({
where: { userId }
});
await tx.follow.deleteMany({
where: {
OR: [
{ followerId: userId },
{ followingId: userId }
]
}
});
await tx.dictionaryLookUp.deleteMany({
where: { userId }
});
await tx.translationHistory.deleteMany({
where: { userId }
});
await tx.session.deleteMany({
where: { userId }
});
await tx.account.deleteMany({
where: { userId }
});
await tx.user.delete({
where: { id: userId }
});
});
log.info("Cascade delete completed for user", { userId });
return { success: true };
}

View File

@@ -34,6 +34,15 @@ export type ServiceOutputUserProfile = {
username: string | null; username: string | null;
displayUsername: string | null; displayUsername: string | null;
image: string | null; image: string | null;
bio: string | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} | null; } | null;
export type ServiceInputDeleteAccount = {
userId: string;
};
export type ServiceOutputDeleteAccount = {
success: boolean;
};

View File

@@ -1,15 +1,18 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { import {
repoFindUserByUsername, repoFindUserByUsername,
repoFindUserById repoFindUserById,
repoDeleteUserCascade
} from "./auth-repository"; } from "./auth-repository";
import { import {
ServiceInputGetUserProfileByUsername, ServiceInputGetUserProfileByUsername,
ServiceInputGetUserProfileById, ServiceInputGetUserProfileById,
ServiceInputSignIn, ServiceInputSignIn,
ServiceInputSignUp, ServiceInputSignUp,
ServiceInputDeleteAccount,
ServiceOutputAuth, ServiceOutputAuth,
ServiceOutputUserProfile ServiceOutputUserProfile,
ServiceOutputDeleteAccount
} from "./auth-service-dto"; } from "./auth-service-dto";
/** /**
@@ -92,3 +95,7 @@ export async function serviceGetUserProfileByUsername(dto: ServiceInputGetUserPr
export async function serviceGetUserProfileById(dto: ServiceInputGetUserProfileById): Promise<ServiceOutputUserProfile> { export async function serviceGetUserProfileById(dto: ServiceInputGetUserProfileById): Promise<ServiceOutputUserProfile> {
return await repoFindUserById(dto); return await repoFindUserById(dto);
} }
export async function serviceDeleteAccount(dto: ServiceInputDeleteAccount): Promise<ServiceOutputDeleteAccount> {
return await repoDeleteUserCascade({ userId: dto.userId });
}

View File

@@ -0,0 +1,12 @@
import { z } from "zod";
export const schemaActionInputForgotPassword = z.object({
email: z.string().email("请输入有效的邮箱地址"),
});
export type ActionInputForgotPassword = z.infer<typeof schemaActionInputForgotPassword>;
export interface ActionOutputForgotPassword {
success: boolean;
message: string;
}

View File

@@ -0,0 +1,35 @@
"use server";
import { createLogger } from "@/lib/logger";
import { validate } from "@/utils/validate";
import { ValidateError } from "@/lib/errors";
import {
schemaActionInputForgotPassword,
type ActionInputForgotPassword,
type ActionOutputForgotPassword,
} from "./forgot-password-action-dto";
import { serviceRequestPasswordReset } from "./forgot-password-service";
const log = createLogger("forgot-password-action");
export async function actionRequestPasswordReset(
input: unknown
): Promise<ActionOutputForgotPassword> {
try {
const dto = validate(input, schemaActionInputForgotPassword) as ActionInputForgotPassword;
return await serviceRequestPasswordReset({ email: dto.email });
} catch (e) {
if (e instanceof ValidateError) {
return {
success: false,
message: e.message,
};
}
log.error("Password reset request failed", { error: e });
return {
success: false,
message: "发送重置邮件失败,请稍后重试",
};
}
}

View File

@@ -0,0 +1,7 @@
export type RepoInputFindUserByEmail = {
email: string;
};
export type RepoOutputFindUserByEmail = {
id: string;
} | null;

View File

@@ -0,0 +1,19 @@
import { prisma } from "@/lib/db";
import { createLogger } from "@/lib/logger";
import {
RepoInputFindUserByEmail,
RepoOutputFindUserByEmail
} from "./forgot-password-repository-dto";
const log = createLogger("forgot-password-repository");
export async function repoFindUserByEmail(dto: RepoInputFindUserByEmail): Promise<RepoOutputFindUserByEmail> {
log.debug("Finding user by email", { email: dto.email });
const user = await prisma.user.findUnique({
where: { email: dto.email },
select: { id: true },
});
return user;
}

View File

@@ -0,0 +1,8 @@
export type ServiceInputRequestPasswordReset = {
email: string;
};
export type ServiceOutputRequestPasswordReset = {
success: boolean;
message: string;
};

View File

@@ -0,0 +1,34 @@
import { auth } from "@/auth";
import { createLogger } from "@/lib/logger";
import { repoFindUserByEmail } from "./forgot-password-repository";
import {
ServiceInputRequestPasswordReset,
ServiceOutputRequestPasswordReset
} from "./forgot-password-service-dto";
const log = createLogger("forgot-password-service");
export async function serviceRequestPasswordReset(dto: ServiceInputRequestPasswordReset): Promise<ServiceOutputRequestPasswordReset> {
log.info("Processing password reset request", { email: dto.email });
const user = await repoFindUserByEmail({ email: dto.email });
if (!user) {
return {
success: false,
message: "该邮箱未注册",
};
}
await auth.api.requestPasswordReset({
body: {
email: dto.email,
redirectTo: "/reset-password",
},
});
return {
success: true,
message: "重置密码邮件已发送,请检查您的邮箱",
};
}

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

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

View File

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

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

View File

@@ -1,5 +1,3 @@
import { TSharedItem } from "@/shared/dictionary-type";
export type RepoInputCreateDictionaryLookUp = { export type RepoInputCreateDictionaryLookUp = {
userId?: string; userId?: string;
text: string; text: string;
@@ -8,7 +6,29 @@ export type RepoInputCreateDictionaryLookUp = {
dictionaryItemId?: number; dictionaryItemId?: number;
}; };
export type RepoOutputSelectLastLookUpResult = TSharedItem & {id: number} | null; export type RepoOutputSelectLastLookUpResultEntry = {
id: number;
itemId: number;
ipa: string | null;
definition: string;
partOfSpeech: string | null;
example: string;
createdAt: Date;
updatedAt: Date;
};
export type RepoOutputSelectLastLookUpResultItem = {
id: number;
frequency: number;
standardForm: string;
queryLang: string;
definitionLang: string;
createdAt: Date;
updatedAt: Date;
entries: RepoOutputSelectLastLookUpResultEntry[];
};
export type RepoOutputSelectLastLookUpResult = RepoOutputSelectLastLookUpResultItem | null;
export type RepoInputCreateDictionaryItem = { export type RepoInputCreateDictionaryItem = {
standardForm: string; standardForm: string;

View File

@@ -1,6 +1,5 @@
import { stringNormalize } from "@/utils/string"; import { stringNormalize } from "@/utils/string";
import { import {
RepoInputCreateDictionaryEntry,
RepoInputCreateDictionaryEntryWithoutItemId, RepoInputCreateDictionaryEntryWithoutItemId,
RepoInputCreateDictionaryItem, RepoInputCreateDictionaryItem,
RepoInputCreateDictionaryLookUp, RepoInputCreateDictionaryLookUp,
@@ -30,22 +29,12 @@ export async function repoSelectLastLookUpResult(dto: RepoInputSelectLastLookUpR
createdAt: 'desc' createdAt: 'desc'
} }
}); });
if (result && result.dictionaryItem) {
const item = result.dictionaryItem; if (!result?.dictionaryItem) {
return { return null;
id: item.id,
standardForm: item.standardForm,
entries: item.entries.filter(v => !!v).map(v => {
return {
ipa: v.ipa || undefined,
definition: v.definition,
partOfSpeech: v.partOfSpeech || undefined,
example: v.example
};
})
};
} }
return null;
return result.dictionaryItem;
} }
export async function repoCreateLookUp(content: RepoInputCreateDictionaryLookUp) { export async function repoCreateLookUp(content: RepoInputCreateDictionaryLookUp) {

View File

@@ -2,9 +2,23 @@ import { executeDictionaryLookup } from "@/lib/bigmodel/dictionary/orchestrator"
import { repoCreateLookUp, repoCreateLookUpWithItemAndEntries, repoSelectLastLookUpResult } from "./dictionary-repository"; import { repoCreateLookUp, repoCreateLookUpWithItemAndEntries, repoSelectLastLookUpResult } from "./dictionary-repository";
import { ServiceInputLookUp } from "./dictionary-service-dto"; import { ServiceInputLookUp } from "./dictionary-service-dto";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
import { RepoOutputSelectLastLookUpResultItem } from "./dictionary-repository-dto";
const log = createLogger("dictionary-service"); const log = createLogger("dictionary-service");
function transformRawItemToSharedItem(rawItem: RepoOutputSelectLastLookUpResultItem) {
return {
id: rawItem.id,
standardForm: rawItem.standardForm,
entries: rawItem.entries.map(entry => ({
ipa: entry.ipa ?? undefined,
definition: entry.definition,
partOfSpeech: entry.partOfSpeech ?? undefined,
example: entry.example
}))
};
}
export const serviceLookUp = async (dto: ServiceInputLookUp) => { export const serviceLookUp = async (dto: ServiceInputLookUp) => {
const { const {
text, text,
@@ -27,7 +41,6 @@ export const serviceLookUp = async (dto: ServiceInputLookUp) => {
definitionLang definitionLang
); );
// 使用事务确保数据一致性
repoCreateLookUpWithItemAndEntries( repoCreateLookUpWithItemAndEntries(
{ {
standardForm: response.standardForm, standardForm: response.standardForm,
@@ -47,18 +60,20 @@ export const serviceLookUp = async (dto: ServiceInputLookUp) => {
return response; return response;
} else { } else {
const transformedResult = transformRawItemToSharedItem(lastLookUpResult);
repoCreateLookUp({ repoCreateLookUp({
userId: userId, userId: userId,
text: text, text: text,
queryLang: queryLang, queryLang: queryLang,
definitionLang: definitionLang, definitionLang: definitionLang,
dictionaryItemId: lastLookUpResult.id dictionaryItemId: transformedResult.id
}).catch(error => { }).catch(error => {
log.error("Failed to save dictionary data", { error: error instanceof Error ? error.message : String(error) }); log.error("Failed to save dictionary data", { error: error instanceof Error ? error.message : String(error) });
}); });
return { return {
standardForm: lastLookUpResult.standardForm, standardForm: transformedResult.standardForm,
entries: lastLookUpResult.entries entries: transformedResult.entries
}; };
} }
}; };

View File

@@ -0,0 +1,134 @@
"use server";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { prisma } from "@/lib/db";
import { exportApkg, type ExportDeckData } from "@/lib/anki/apkg-exporter";
import { createLogger } from "@/lib/logger";
const log = createLogger("export-action");
export interface ActionOutputExportApkg {
success: boolean;
message: string;
data?: ArrayBuffer;
filename?: string;
}
export async function actionExportApkg(deckId: number): Promise<ActionOutputExportApkg> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return { success: false, message: "Unauthorized" };
}
try {
const deck = await prisma.deck.findFirst({
where: { id: deckId, userId: session.user.id },
include: {
cards: {
include: {
note: {
include: {
noteType: true,
},
},
},
},
},
});
if (!deck) {
return { success: false, message: "Deck not found or access denied" };
}
if (deck.cards.length === 0) {
return { success: false, message: "Deck has no cards to export" };
}
const firstCard = deck.cards[0];
if (!firstCard?.note?.noteType) {
return { success: false, message: "Deck has invalid card data" };
}
const noteType = firstCard.note.noteType;
const revlogs = await prisma.revlog.findMany({
where: {
cardId: { in: deck.cards.map(c => c.id) },
},
});
const exportData: ExportDeckData = {
deck: {
id: deck.id,
name: deck.name,
desc: deck.desc,
collapsed: deck.collapsed,
conf: deck.conf as Record<string, unknown>,
},
noteType: {
id: noteType.id,
name: noteType.name,
kind: noteType.kind,
css: noteType.css,
fields: (noteType.fields as { name: string; ord: number }[]) ?? [],
templates: (noteType.templates as { name: string; ord: number; qfmt: string; afmt: string }[]) ?? [],
},
notes: deck.cards.map((card) => ({
id: card.note.id,
guid: card.note.guid,
tags: card.note.tags,
flds: card.note.flds,
sfld: card.note.sfld,
csum: card.note.csum,
})),
cards: deck.cards.map((card) => ({
id: card.id,
noteId: card.noteId,
ord: card.ord,
type: card.type,
queue: card.queue,
due: card.due,
ivl: card.ivl,
factor: card.factor,
reps: card.reps,
lapses: card.lapses,
left: card.left,
})),
revlogs: revlogs.map((r) => ({
id: r.id,
cardId: r.cardId,
ease: r.ease,
ivl: r.ivl,
lastIvl: r.lastIvl,
factor: r.factor,
time: r.time,
type: r.type,
})),
media: new Map(),
};
const apkgBuffer = await exportApkg(exportData);
log.info("APKG exported successfully", {
userId: session.user.id,
deckId: deck.id,
cardCount: deck.cards.length,
});
const safeDeckName = deck.name.replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]/g, "_");
return {
success: true,
message: "Deck exported successfully",
data: apkgBuffer.buffer.slice(apkgBuffer.byteOffset, apkgBuffer.byteOffset + apkgBuffer.byteLength) as ArrayBuffer,
filename: `${safeDeckName}.apkg`,
};
} catch (error) {
log.error("Failed to export APKG", { error, deckId });
return {
success: false,
message: error instanceof Error ? error.message : "Failed to export deck",
};
}
}

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,33 @@
import { z } from "zod";
const schemaActionInputToggleFollow = z.object({
targetUserId: z.string().min(1),
});
const schemaActionInputGetFollowers = z.object({
userId: z.string().min(1),
page: z.number().int().min(1).optional().default(1),
limit: z.number().int().min(1).max(100).optional().default(20),
});
const schemaActionInputGetFollowing = z.object({
userId: z.string().min(1),
page: z.number().int().min(1).optional().default(1),
limit: z.number().int().min(1).max(100).optional().default(20),
});
const schemaActionInputGetFollowStatus = z.object({
targetUserId: z.string().min(1),
});
export type ActionInputToggleFollow = z.infer<typeof schemaActionInputToggleFollow>;
export type ActionInputGetFollowers = z.infer<typeof schemaActionInputGetFollowers>;
export type ActionInputGetFollowing = z.infer<typeof schemaActionInputGetFollowing>;
export type ActionInputGetFollowStatus = z.infer<typeof schemaActionInputGetFollowStatus>;
export {
schemaActionInputGetFollowers,
schemaActionInputGetFollowing,
schemaActionInputGetFollowStatus,
schemaActionInputToggleFollow,
};

Some files were not shown because too many files have changed in this diff Show More