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
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -252,7 +252,14 @@
|
||||
"cardTypeNew": "新規",
|
||||
"cardTypeLearning": "学習中",
|
||||
"cardTypeReview": "復習",
|
||||
"cardTypeRelearning": "再学習"
|
||||
"cardTypeRelearning": "再学習",
|
||||
"reverse": "反転",
|
||||
"dictation": "聴き取り",
|
||||
"clickToPlay": "クリックして再生",
|
||||
"yourAnswer": "あなたの答え",
|
||||
"typeWhatYouHear": "聞こえた内容を入力",
|
||||
"correct": "正解",
|
||||
"incorrect": "不正解"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "このデッキにアクセスする権限がありません"
|
||||
|
||||
@@ -226,7 +226,14 @@
|
||||
"cardTypeNew": "새 카드",
|
||||
"cardTypeLearning": "학습 중",
|
||||
"cardTypeReview": "복습 중",
|
||||
"cardTypeRelearning": "재학습 중"
|
||||
"cardTypeRelearning": "재학습 중",
|
||||
"reverse": "반전",
|
||||
"dictation": "받아쓰기",
|
||||
"clickToPlay": "클릭하여 재생",
|
||||
"yourAnswer": "당신의 답변",
|
||||
"typeWhatYouHear": "들은 내용을 입력하세요",
|
||||
"correct": "정답",
|
||||
"incorrect": "오답"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "이 덱에 접근할 권한이 없습니다"
|
||||
|
||||
@@ -226,7 +226,14 @@
|
||||
"cardTypeNew": "يېڭى",
|
||||
"cardTypeLearning": "ئۆگىنىۋاتىدۇ",
|
||||
"cardTypeReview": "تەكرارلاش",
|
||||
"cardTypeRelearning": "قايتا ئۆگىنىش"
|
||||
"cardTypeRelearning": "قايتا ئۆگىنىش",
|
||||
"reverse": "ئەكسى",
|
||||
"dictation": "ئاڭلاپ يېزىش",
|
||||
"clickToPlay": "چېكىپ قويۇش",
|
||||
"yourAnswer": "سىزنىڭ جاۋابىڭىز",
|
||||
"typeWhatYouHear": "ئاڭلىغىنىڭىزنى كىرگۈزۈڭ",
|
||||
"correct": "توغرا",
|
||||
"incorrect": "خاتا"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "بۇ دېكنى زىيارەت قىلىش ھوقۇقىڭىز يوق"
|
||||
|
||||
@@ -252,7 +252,14 @@
|
||||
"cardTypeNew": "新卡片",
|
||||
"cardTypeLearning": "学习中",
|
||||
"cardTypeReview": "复习中",
|
||||
"cardTypeRelearning": "重学中"
|
||||
"cardTypeRelearning": "重学中",
|
||||
"reverse": "反向",
|
||||
"dictation": "听写",
|
||||
"clickToPlay": "点击播放",
|
||||
"yourAnswer": "你的答案",
|
||||
"typeWhatYouHear": "输入你听到的内容",
|
||||
"correct": "正确",
|
||||
"incorrect": "错误"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "您无权访问该牌组"
|
||||
|
||||
@@ -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<MemorizeProps> = ({ deckId, deckName }) => {
|
||||
const [lastScheduled, setLastScheduled] = useState<ActionOutputScheduledCard | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<string | null>(null);
|
||||
const [isAudioLoading, setIsAudioLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
@@ -49,6 +58,10 @@ const Memorize: React.FC<MemorizeProps> = ({ 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<MemorizeProps> = ({ 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<MemorizeProps> = ({ 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<MemorizeProps> = ({ 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<MemorizeProps> = ({ deckId, deckName }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsReversed(!isReversed);
|
||||
setDictationInput("");
|
||||
setDictationResult(null);
|
||||
}}
|
||||
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm transition-colors ${
|
||||
isReversed
|
||||
? "bg-indigo-100 text-indigo-700"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
{t("reverse")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDictation(!isDictation);
|
||||
setDictationInput("");
|
||||
setDictationResult(null);
|
||||
}}
|
||||
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm transition-colors ${
|
||||
isDictation
|
||||
? "bg-purple-100 text-purple-700"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
<Headphones className="w-4 h-4" />
|
||||
{t("dictation")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`bg-white border border-gray-200 rounded-xl shadow-sm mb-6 ${myFont.className}`}>
|
||||
<div className="p-8 min-h-[20dvh] flex items-center justify-center">
|
||||
<div className="text-gray-900 text-xl md:text-2xl text-center">
|
||||
{front}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAnswer && (
|
||||
{isDictation ? (
|
||||
<>
|
||||
<div className="border-t border-gray-200" />
|
||||
<div className="p-8 min-h-[20dvh] flex items-center justify-center bg-gray-50 rounded-b-xl">
|
||||
<div className="p-8 min-h-[20dvh] flex flex-col items-center justify-center gap-4">
|
||||
<button
|
||||
onClick={playCurrentCard}
|
||||
disabled={isAudioLoading}
|
||||
className="p-4 rounded-full bg-blue-100 hover:bg-blue-200 text-blue-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Volume2 className="w-8 h-8" />
|
||||
</button>
|
||||
<p className="text-gray-500 text-sm">{t("clickToPlay")}</p>
|
||||
</div>
|
||||
|
||||
{showAnswer ? (
|
||||
<>
|
||||
<div className="border-t border-gray-200" />
|
||||
<div className="p-8 min-h-[20dvh] flex flex-col items-center justify-center bg-gray-50 rounded-b-xl gap-4">
|
||||
<div className="w-full max-w-md">
|
||||
<label className="block text-sm text-gray-600 mb-2">{t("yourAnswer")}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={dictationInput}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className={`text-lg font-medium ${
|
||||
dictationResult === "correct" ? "text-green-600" : "text-red-600"
|
||||
}`}>
|
||||
{dictationResult === "correct" ? "✓ " + t("correct") : "✗ " + t("incorrect")}
|
||||
</p>
|
||||
{dictationResult === "incorrect" && (
|
||||
<p className="text-gray-900 text-xl mt-2">{displayBack}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="border-t border-gray-200 p-6 bg-gray-50 rounded-b-xl">
|
||||
<input
|
||||
type="text"
|
||||
value={dictationInput}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="p-8 min-h-[20dvh] flex items-center justify-center">
|
||||
<div className="text-gray-900 text-xl md:text-2xl text-center">
|
||||
{back}
|
||||
{displayFront}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAnswer && (
|
||||
<>
|
||||
<div className="border-t border-gray-200" />
|
||||
<div className="p-8 min-h-[20dvh] flex items-center justify-center bg-gray-50 rounded-b-xl">
|
||||
<div className="text-gray-900 text-xl md:text-2xl text-center">
|
||||
{displayBack}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user