From c525bd45913b18177a10ec84aee96ca99b63f7e6 Mon Sep 17 00:00:00 2001 From: goddonebianu Date: Sat, 14 Mar 2026 11:52:56 +0800 Subject: [PATCH] feat(learn): add reverse and dictation modes for card review - Add reverse mode to swap card front/back - Add dictation mode with TTS audio playback and answer verification - Add i18n translations for new features in all 8 languages - Integrate useAudioPlayer hook for TTS playback --- messages/de-DE.json | 9 +- messages/en-US.json | 9 +- messages/fr-FR.json | 9 +- messages/it-IT.json | 9 +- messages/ja-JP.json | 9 +- messages/ko-KR.json | 9 +- messages/ug-CN.json | 9 +- messages/zh-CN.json | 9 +- src/app/decks/[deck_id]/learn/Memorize.tsx | 201 +++++++++++++++++++-- 9 files changed, 251 insertions(+), 22 deletions(-) diff --git a/messages/de-DE.json b/messages/de-DE.json index 496c02b..80e5ff0 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -226,7 +226,14 @@ "cardTypeNew": "Neu", "cardTypeLearning": "Lernen", "cardTypeReview": "Wiederholung", - "cardTypeRelearning": "Neu lernen" + "cardTypeRelearning": "Neu lernen", + "reverse": "Umkehren", + "dictation": "Diktat", + "clickToPlay": "Klicken zum Abspielen", + "yourAnswer": "Ihre Antwort", + "typeWhatYouHear": "Geben Sie ein, was Sie hören", + "correct": "Richtig", + "incorrect": "Falsch" }, "page": { "unauthorized": "Sie sind nicht berechtigt, auf dieses Deck zuzugreifen" diff --git a/messages/en-US.json b/messages/en-US.json index 3b99db8..e2b611e 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -252,7 +252,14 @@ "cardTypeNew": "New", "cardTypeLearning": "Learning", "cardTypeReview": "Review", - "cardTypeRelearning": "Relearning" + "cardTypeRelearning": "Relearning", + "reverse": "Reverse", + "dictation": "Dictation", + "clickToPlay": "Click to play audio", + "yourAnswer": "Your answer", + "typeWhatYouHear": "Type what you hear...", + "correct": "Correct", + "incorrect": "Incorrect" }, "page": { "unauthorized": "You are not authorized to access this deck" diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 7ef7df2..04ed277 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -295,7 +295,14 @@ "cardTypeNew": "Nouveau", "cardTypeLearning": "Apprentissage", "cardTypeReview": "Révision", - "cardTypeRelearning": "Réapprentissage" + "cardTypeRelearning": "Réapprentissage", + "reverse": "Inverser", + "dictation": "Dictée", + "clickToPlay": "Cliquez pour jouer", + "yourAnswer": "Votre réponse", + "typeWhatYouHear": "Tapez ce que vous entendez", + "correct": "Correct", + "incorrect": "Incorrect" }, "page": { "unauthorized": "Vous n'êtes pas autorisé à accéder à ce deck" diff --git a/messages/it-IT.json b/messages/it-IT.json index 3d67449..7e30022 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -260,7 +260,14 @@ "cardTypeNew": "Nuovo", "cardTypeLearning": "Apprendimento", "cardTypeReview": "Ripasso", - "cardTypeRelearning": "Riapprendimento" + "cardTypeRelearning": "Riapprendimento", + "reverse": "Inverti", + "dictation": "Dettato", + "clickToPlay": "Clicca per riprodurre", + "yourAnswer": "La tua risposta", + "typeWhatYouHear": "Scrivi ciò che senti", + "correct": "Corretto", + "incorrect": "Errato" }, "page": { "unauthorized": "Non sei autorizzato ad accedere a questo mazzo" diff --git a/messages/ja-JP.json b/messages/ja-JP.json index 31f7a68..c8e26f9 100644 --- a/messages/ja-JP.json +++ b/messages/ja-JP.json @@ -252,7 +252,14 @@ "cardTypeNew": "新規", "cardTypeLearning": "学習中", "cardTypeReview": "復習", - "cardTypeRelearning": "再学習" + "cardTypeRelearning": "再学習", + "reverse": "反転", + "dictation": "聴き取り", + "clickToPlay": "クリックして再生", + "yourAnswer": "あなたの答え", + "typeWhatYouHear": "聞こえた内容を入力", + "correct": "正解", + "incorrect": "不正解" }, "page": { "unauthorized": "このデッキにアクセスする権限がありません" diff --git a/messages/ko-KR.json b/messages/ko-KR.json index f9b0b10..141f510 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -226,7 +226,14 @@ "cardTypeNew": "새 카드", "cardTypeLearning": "학습 중", "cardTypeReview": "복습 중", - "cardTypeRelearning": "재학습 중" + "cardTypeRelearning": "재학습 중", + "reverse": "반전", + "dictation": "받아쓰기", + "clickToPlay": "클릭하여 재생", + "yourAnswer": "당신의 답변", + "typeWhatYouHear": "들은 내용을 입력하세요", + "correct": "정답", + "incorrect": "오답" }, "page": { "unauthorized": "이 덱에 접근할 권한이 없습니다" diff --git a/messages/ug-CN.json b/messages/ug-CN.json index 16c0830..ac58538 100644 --- a/messages/ug-CN.json +++ b/messages/ug-CN.json @@ -226,7 +226,14 @@ "cardTypeNew": "يېڭى", "cardTypeLearning": "ئۆگىنىۋاتىدۇ", "cardTypeReview": "تەكرارلاش", - "cardTypeRelearning": "قايتا ئۆگىنىش" + "cardTypeRelearning": "قايتا ئۆگىنىش", + "reverse": "ئەكسى", + "dictation": "ئاڭلاپ يېزىش", + "clickToPlay": "چېكىپ قويۇش", + "yourAnswer": "سىزنىڭ جاۋابىڭىز", + "typeWhatYouHear": "ئاڭلىغىنىڭىزنى كىرگۈزۈڭ", + "correct": "توغرا", + "incorrect": "خاتا" }, "page": { "unauthorized": "بۇ دېكنى زىيارەت قىلىش ھوقۇقىڭىز يوق" diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 71b2792..b5105ce 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -252,7 +252,14 @@ "cardTypeNew": "新卡片", "cardTypeLearning": "学习中", "cardTypeReview": "复习中", - "cardTypeRelearning": "重学中" + "cardTypeRelearning": "重学中", + "reverse": "反向", + "dictation": "听写", + "clickToPlay": "点击播放", + "yourAnswer": "你的答案", + "typeWhatYouHear": "输入你听到的内容", + "correct": "正确", + "incorrect": "错误" }, "page": { "unauthorized": "您无权访问该牌组" diff --git a/src/app/decks/[deck_id]/learn/Memorize.tsx b/src/app/decks/[deck_id]/learn/Memorize.tsx index 9912865..9f5f65f 100644 --- a/src/app/decks/[deck_id]/learn/Memorize.tsx +++ b/src/app/decks/[deck_id]/learn/Memorize.tsx @@ -1,16 +1,18 @@ "use client"; -import { useState, useEffect, useTransition, useCallback } from "react"; +import { useState, useEffect, useTransition, useCallback, useRef } from "react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import localFont from "next/font/local"; -import { Layers, Check, Clock, Sparkles } from "lucide-react"; +import { Layers, Check, Clock, Sparkles, RotateCcw, Volume2, Headphones } from "lucide-react"; import type { ActionOutputCardWithNote, ActionOutputScheduledCard } from "@/modules/card/card-action-dto"; import { actionGetCardsForReview, actionAnswerCard } from "@/modules/card/card-action"; import { PageLayout } from "@/components/ui/PageLayout"; import { LightButton } from "@/design-system/base/button"; import { CardType } from "../../../../../generated/prisma/enums"; import { calculatePreviewIntervals, formatPreviewInterval, type CardPreview } from "./interval-preview"; +import { useAudioPlayer } from "@/hooks/useAudioPlayer"; +import { getTTSUrl, type TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts"; const myFont = localFont({ src: "../../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf", @@ -34,6 +36,13 @@ const Memorize: React.FC = ({ deckId, deckName }) => { const [lastScheduled, setLastScheduled] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [isReversed, setIsReversed] = useState(false); + const [isDictation, setIsDictation] = useState(false); + const [dictationInput, setDictationInput] = useState(""); + const [dictationResult, setDictationResult] = useState<"correct" | "incorrect" | null>(null); + const { play, stop, load } = useAudioPlayer(); + const audioUrlRef = useRef(null); + const [isAudioLoading, setIsAudioLoading] = useState(false); useEffect(() => { let ignore = false; @@ -49,6 +58,10 @@ const Memorize: React.FC = ({ deckId, deckName }) => { setCurrentIndex(0); setShowAnswer(false); setLastScheduled(null); + setIsReversed(false); + setIsDictation(false); + setDictationInput(""); + setDictationResult(null); } else { setError(result.message); } @@ -74,7 +87,18 @@ const Memorize: React.FC = ({ deckId, deckName }) => { const handleShowAnswer = useCallback(() => { setShowAnswer(true); - }, []); + + if (isDictation) { + const currentCard = getCurrentCard(); + if (currentCard) { + const fields = getNoteFields(currentCard); + const answer = isReversed ? (fields[0] ?? "") : (fields[1] ?? ""); + const normalizedInput = dictationInput.trim().toLowerCase(); + const normalizedAnswer = answer.trim().toLowerCase(); + setDictationResult(normalizedInput === normalizedAnswer ? "correct" : "incorrect"); + } + } + }, [isDictation, dictationInput, isReversed]); const handleAnswer = useCallback((ease: ReviewEase) => { const card = getCurrentCard(); @@ -99,11 +123,62 @@ const Memorize: React.FC = ({ deckId, deckName }) => { } setShowAnswer(false); + setIsReversed(false); + setIsDictation(false); + setDictationInput(""); + setDictationResult(null); + + if (audioUrlRef.current) { + URL.revokeObjectURL(audioUrlRef.current); + audioUrlRef.current = null; + } + stop(); } else { setError(result.message); } }); - }, [cards, currentIndex]); + }, [cards, currentIndex, stop]); + + const playTTS = useCallback(async (text: string) => { + if (isAudioLoading) return; + + setIsAudioLoading(true); + try { + const hasChinese = /[\u4e00-\u9fff]/.test(text); + const hasJapanese = /[\u3040-\u309f\u30a0-\u30ff]/.test(text); + const hasKorean = /[\uac00-\ud7af]/.test(text); + + let lang: TTS_SUPPORTED_LANGUAGES = "Auto"; + if (hasChinese) lang = "Chinese"; + else if (hasJapanese) lang = "Japanese"; + else if (hasKorean) lang = "Korean"; + else if (/^[a-zA-Z\s]/.test(text)) lang = "English"; + + const audioUrl = await getTTSUrl(text, lang); + + if (audioUrl && audioUrl !== "error") { + audioUrlRef.current = audioUrl; + await load(audioUrl); + play(); + } + } catch (e) { + console.error("TTS playback failed", e); + } finally { + setIsAudioLoading(false); + } + }, [isAudioLoading, load, play]); + + const playCurrentCard = useCallback(() => { + const currentCard = getCurrentCard(); + if (!currentCard) return; + + const fields = getNoteFields(currentCard); + const text = isReversed ? (fields[1] ?? "") : (fields[0] ?? ""); + + if (text) { + playTTS(text); + } + }, [isReversed, playTTS]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -236,6 +311,9 @@ const Memorize: React.FC = ({ deckId, deckName }) => { const fields = getNoteFields(currentCard); const front = fields[0] ?? ""; const back = fields[1] ?? ""; + + const displayFront = isReversed ? back : front; + const displayBack = isReversed ? front : back; const cardPreview: CardPreview = { type: currentCard.type, @@ -280,21 +358,116 @@ const Memorize: React.FC = ({ deckId, deckName }) => { )} +
+ + +
+
-
-
- {front} -
-
- - {showAnswer && ( + {isDictation ? ( <> -
-
+
+ +

{t("clickToPlay")}

+
+ + {showAnswer ? ( + <> +
+
+
+ + setDictationInput(e.target.value)} + className={`w-full p-3 border rounded-lg text-lg ${ + dictationResult === "correct" + ? "border-green-500 bg-green-50" + : dictationResult === "incorrect" + ? "border-red-500 bg-red-50" + : "border-gray-300" + }`} + readOnly + /> +
+
+

+ {dictationResult === "correct" ? "✓ " + t("correct") : "✗ " + t("incorrect")} +

+ {dictationResult === "incorrect" && ( +

{displayBack}

+ )} +
+
+ + ) : ( +
+ setDictationInput(e.target.value)} + placeholder={t("typeWhatYouHear")} + className="w-full p-3 border border-gray-300 rounded-lg text-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + autoFocus + /> +
+ )} + + ) : ( + <> +
- {back} + {displayFront}
+ + {showAnswer && ( + <> +
+
+
+ {displayBack} +
+
+ + )} )}