feat: add study modes to Memorize page

- Add 4 study modes: order-limited, order-infinite, random-limited, random-infinite
- Add mode selector buttons with icons
- Update progress display for infinite modes
- Add translations for all 8 locales
This commit is contained in:
2026-03-18 08:52:45 +08:00
parent c54376cbe6
commit 06012c43f2
9 changed files with 187 additions and 144 deletions

View File

@@ -233,32 +233,11 @@
"showAnswer": "Antwort zeigen", "showAnswer": "Antwort zeigen",
"nextCard": "Weiter", "nextCard": "Weiter",
"again": "Nochmal", "again": "Nochmal",
"hard": "Schwer", "modeOrderLimited": "Reihenfolge",
"good": "Gut", "modeOrderInfinite": "Schleife",
"easy": "Leicht", "modeRandomLimited": "Zufällig",
"now": "Jetzt", "modeRandomInfinite": "Zufällig Schleife",
"lessThanMinute": "weniger als 1 Minute", "restart": "Neustart"
"inMinutes": "in {n} Minute{n, plural, one {} other {n}}",
"inHours": "in {n} Stunde{n, plural, one {} other {n}}",
"inDays": "in {n} Tag{en}",
"inMonths": "in {n} Monat{en}",
"minutes": "Minuten",
"days": "Tage",
"months": "Monate",
"minAbbr": "min",
"dayAbbr": "d",
"cardTypeNew": "Neu",
"cardTypeLearning": "Lernen",
"cardTypeReview": "Wiederholen",
"cardTypeRelearning": "Neu lernen",
"reverse": "Umkehren",
"dictation": "Diktat",
"clickToPlay": "Klicken zum Abspielen",
"yourAnswer": "Ihre Antwort",
"typeWhatYouHear": "Schreiben Sie was Sie hören",
"correct": "Richtig!",
"incorrect": "Falsch",
"nextCard": "Nächste"
}, },
"page": { "page": {
"unauthorized": "Nicht autorisiert" "unauthorized": "Nicht autorisiert"

View File

@@ -296,6 +296,11 @@
"reverse": "Reverse", "reverse": "Reverse",
"dictation": "Dictation", "dictation": "Dictation",
"clickToPlay": "Click to play audio", "clickToPlay": "Click to play audio",
"modeOrderLimited": "Order",
"modeOrderInfinite": "Loop",
"modeRandomLimited": "Random",
"modeRandomInfinite": "Random Loop",
"restart": "Restart"
"yourAnswer": "Your answer", "yourAnswer": "Your answer",
"typeWhatYouHear": "Type what you hear...", "typeWhatYouHear": "Type what you hear...",
"correct": "Correct", "correct": "Correct",

View File

@@ -304,32 +304,11 @@
"showAnswer": "Montrer réponse", "showAnswer": "Montrer réponse",
"nextCard": "Suivant", "nextCard": "Suivant",
"again": "Encore", "again": "Encore",
"hard": "Difficile", "modeOrderLimited": "Ordre",
"good": "Bien", "modeOrderInfinite": "Boucle",
"easy": "Facile", "modeRandomLimited": "Aléatoire",
"now": "Maintenant", "modeRandomInfinite": "Aléatoire Boucle",
"lessThanMinute": "moins d'une minute", "restart": "Recommencer"
"inMinutes": "dans {n} minute{s}",
"inHours": "dans {n} heure{s}",
"inDays": "dans {n} jour{s}",
"inMonths": "dans {n} mois",
"minutes": "minutes",
"days": "jours",
"months": "mois",
"minAbbr": "min",
"dayAbbr": "j",
"cardTypeNew": "Nouveau",
"cardTypeLearning": "Apprentissage",
"cardTypeReview": "Révision",
"cardTypeRelearning": "Réapprentissage",
"reverse": "Inverser",
"dictation": "Dictée",
"clickToPlay": "Cliquer pour jouer",
"yourAnswer": "Votre réponse",
"typeWhatYouHear": "Tapez ce que vous entendez",
"correct": "Correct!",
"incorrect": "Incorrect",
"nextCard": "Suivant"
}, },
"page": { "page": {
"unauthorized": "Non autorisé" "unauthorized": "Non autorisé"

View File

@@ -304,6 +304,11 @@
"showAnswer": "Mostra risposta", "showAnswer": "Mostra risposta",
"nextCard": "Prossima", "nextCard": "Prossima",
"again": "Ancora", "again": "Ancora",
"modeOrderLimited": "Ordine",
"modeOrderInfinite": "Ciclo",
"modeRandomLimited": "Casuale",
"modeRandomInfinite": "Casuale Ciclo",
"restart": "Ricomincia",
"hard": "Difficile", "hard": "Difficile",
"good": "Buono", "good": "Buono",
"easy": "Facile", "easy": "Facile",

View File

@@ -297,7 +297,12 @@
"typeWhatYouHear": "聞こえた内容を入力", "typeWhatYouHear": "聞こえた内容を入力",
"correct": "正解", "correct": "正解",
"incorrect": "不正解", "incorrect": "不正解",
"nextCard": "次へ" "nextCard": "次へ",
"modeOrderLimited": "順序",
"modeOrderInfinite": "ループ",
"modeRandomLimited": "ランダム",
"modeRandomInfinite": "ランダムループ",
"restart": "最初から"
}, },
"page": { "page": {
"unauthorized": "このデッキにアクセスする権限がありません" "unauthorized": "このデッキにアクセスする権限がありません"

View File

@@ -304,32 +304,11 @@
"showAnswer": "정답 보기", "showAnswer": "정답 보기",
"nextCard": "다음", "nextCard": "다음",
"again": "다시", "again": "다시",
"hard": "어려움", "modeOrderLimited": "순서",
"good": "좋음", "modeOrderInfinite": "반복",
"easy": "쉬움", "modeRandomLimited": "무작위",
"now": "지금", "modeRandomInfinite": "무작위 반복",
"lessThanMinute": "1분 미만", "restart": "다시 시작"
"inMinutes": "{n}분 후",
"inHours": "{n}시간 후",
"inDays": "{n}일 후",
"inMonths": "{n}개월 후",
"minutes": "분",
"days": "일",
"months": "개월",
"minAbbr": "분",
"dayAbbr": "일",
"cardTypeNew": "새 카드",
"cardTypeLearning": "학습 중",
"cardTypeReview": "복습",
"cardTypeRelearning": "재학습",
"reverse": "반대로",
"dictation": "받아쓰기",
"clickToPlay": "클릭하여 재생",
"yourAnswer": "당신의 답",
"typeWhatYouHear": "들은 내용을 입력하세요",
"correct": "정답!",
"incorrect": "오답",
"nextCard": "다음"
}, },
"page": { "page": {
"unauthorized": "권한이 없습니다" "unauthorized": "권한이 없습니다"

View File

@@ -329,7 +329,11 @@
"typeWhatYouHear": "ئاڭلىغىنىڭىزنى يېزىڭ", "typeWhatYouHear": "ئاڭلىغىنىڭىزنى يېزىڭ",
"correct": "توغرا!", "correct": "توغرا!",
"incorrect": "خاتا", "incorrect": "خاتا",
"nextCard": "كېيىنكى" "modeOrderLimited": "تەرتىپ",
"modeOrderInfinite": "دەۋرىيە",
"modeRandomLimited": "ئىختىيارى",
"modeRandomInfinite": "ئىختىيارى دەۋرىيە",
"restart": "قايتا باشلا"
}, },
"page": { "page": {
"unauthorized": "ھوقۇقسىز" "unauthorized": "ھوقۇقسىز"
@@ -630,4 +634,4 @@
"noFollowers": "تېخى ئەگەشكۈچى يوق", "noFollowers": "تېخى ئەگەشكۈچى يوق",
"noFollowing": "تېخى ئەگىشىدىغان ئادەم يوق" "noFollowing": "تېخى ئەگىشىدىغان ئادەم يوق"
} }
} }

View File

@@ -297,7 +297,12 @@
"typeWhatYouHear": "输入你听到的内容", "typeWhatYouHear": "输入你听到的内容",
"correct": "正确", "correct": "正确",
"incorrect": "错误", "incorrect": "错误",
"nextCard": "下一张" "nextCard": "下一张",
"modeOrderLimited": "顺序",
"modeOrderInfinite": "循环",
"modeRandomLimited": "随机",
"modeRandomInfinite": "随机循环",
"restart": "重新开始"
}, },
"page": { "page": {
"unauthorized": "您无权访问该牌组" "unauthorized": "您无权访问该牌组"

View File

@@ -4,7 +4,7 @@ import { useState, useEffect, useTransition, useCallback, useRef } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import localFont from "next/font/local"; import localFont from "next/font/local";
import { Layers, Check, RotateCcw, Volume2, Headphones, ChevronLeft, ChevronRight } from "lucide-react"; import { Layers, Check, RotateCcw, Volume2, Headphones, ChevronLeft, ChevronRight, Shuffle, List, Repeat, Infinity } from "lucide-react";
import { actionGetCardsByDeckId } from "@/modules/card/card-action"; import { actionGetCardsByDeckId } from "@/modules/card/card-action";
import type { ActionOutputCard } from "@/modules/card/card-action-dto"; import type { ActionOutputCard } from "@/modules/card/card-action-dto";
import { PageLayout } from "@/components/ui/PageLayout"; import { PageLayout } from "@/components/ui/PageLayout";
@@ -19,6 +19,8 @@ const myFont = localFont({
src: "../../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf", src: "../../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
}); });
type StudyMode = "order-limited" | "order-infinite" | "random-limited" | "random-infinite";
interface MemorizeProps { interface MemorizeProps {
deckId: number; deckId: number;
deckName: string; deckName: string;
@@ -29,6 +31,7 @@ const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [originalCards, setOriginalCards] = useState<ActionOutputCard[]>([]);
const [cards, setCards] = useState<ActionOutputCard[]>([]); const [cards, setCards] = useState<ActionOutputCard[]>([]);
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const [showAnswer, setShowAnswer] = useState(false); const [showAnswer, setShowAnswer] = useState(false);
@@ -36,10 +39,20 @@ const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isReversed, setIsReversed] = useState(false); const [isReversed, setIsReversed] = useState(false);
const [isDictation, setIsDictation] = useState(false); const [isDictation, setIsDictation] = useState(false);
const [studyMode, setStudyMode] = useState<StudyMode>("order-limited");
const { play, stop, load } = useAudioPlayer(); const { play, stop, load } = useAudioPlayer();
const audioUrlRef = useRef<string | null>(null); const audioUrlRef = useRef<string | null>(null);
const [isAudioLoading, setIsAudioLoading] = useState(false); const [isAudioLoading, setIsAudioLoading] = useState(false);
const shuffleCards = useCallback((cardArray: ActionOutputCard[]): ActionOutputCard[] => {
const shuffled = [...cardArray];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}, []);
useEffect(() => { useEffect(() => {
let ignore = false; let ignore = false;
@@ -50,6 +63,7 @@ const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
const result = await actionGetCardsByDeckId({ deckId, limit: 100 }); const result = await actionGetCardsByDeckId({ deckId, limit: 100 });
if (!ignore) { if (!ignore) {
if (result.success && result.data) { if (result.success && result.data) {
setOriginalCards(result.data);
setCards(result.data); setCards(result.data);
setCurrentIndex(0); setCurrentIndex(0);
setShowAnswer(false); setShowAnswer(false);
@@ -70,6 +84,16 @@ const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
}; };
}, [deckId]); }, [deckId]);
useEffect(() => {
if (studyMode.startsWith("random")) {
setCards(shuffleCards(originalCards));
} else {
setCards(originalCards);
}
setCurrentIndex(0);
setShowAnswer(false);
}, [studyMode, originalCards, shuffleCards]);
const getCurrentCard = (): ActionOutputCard | null => { const getCurrentCard = (): ActionOutputCard | null => {
return cards[currentIndex] ?? null; return cards[currentIndex] ?? null;
}; };
@@ -108,25 +132,46 @@ const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
setShowAnswer(true); setShowAnswer(true);
}, []); }, []);
const isInfinite = studyMode.endsWith("infinite");
const handleNextCard = useCallback(() => { const handleNextCard = useCallback(() => {
if (currentIndex < cards.length - 1) { if (isInfinite) {
setCurrentIndex(currentIndex + 1); if (currentIndex >= cards.length - 1) {
setShowAnswer(false); if (studyMode.startsWith("random")) {
setIsReversed(false); setCards(shuffleCards(originalCards));
setIsDictation(false); }
cleanupAudio(); setCurrentIndex(0);
} else {
setCurrentIndex(currentIndex + 1);
}
} else {
if (currentIndex < cards.length - 1) {
setCurrentIndex(currentIndex + 1);
}
} }
}, [currentIndex, cards.length]); setShowAnswer(false);
setIsReversed(false);
setIsDictation(false);
cleanupAudio();
}, [currentIndex, cards.length, isInfinite, studyMode, originalCards, shuffleCards]);
const handlePrevCard = useCallback(() => { const handlePrevCard = useCallback(() => {
if (currentIndex > 0) { if (isInfinite) {
setCurrentIndex(currentIndex - 1); if (currentIndex <= 0) {
setShowAnswer(false); setCurrentIndex(cards.length - 1);
setIsReversed(false); } else {
setIsDictation(false); setCurrentIndex(currentIndex - 1);
cleanupAudio(); }
} else {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1);
}
} }
}, [currentIndex]); setShowAnswer(false);
setIsReversed(false);
setIsDictation(false);
cleanupAudio();
}, [currentIndex, cards.length, isInfinite]);
const cleanupAudio = useCallback(() => { const cleanupAudio = useCallback(() => {
if (audioUrlRef.current) { if (audioUrlRef.current) {
@@ -249,6 +294,14 @@ const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
const currentCard = getCurrentCard()!; const currentCard = getCurrentCard()!;
const displayFront = getFrontText(currentCard); const displayFront = getFrontText(currentCard);
const isFinished = !isInfinite && currentIndex === cards.length - 1 && showAnswer;
const studyModeOptions: { value: StudyMode; label: string; icon: React.ReactNode }[] = [
{ value: "order-limited", label: t("orderLimited"), icon: <List className="w-4 h-4" /> },
{ value: "order-infinite", label: t("orderInfinite"), icon: <Repeat className="w-4 h-4" /> },
{ value: "random-limited", label: t("randomLimited"), icon: <Shuffle className="w-4 h-4" /> },
{ value: "random-infinite", label: t("randomInfinite"), icon: <Infinity className="w-4 h-4" /> },
];
return ( return (
<PageLayout> <PageLayout>
@@ -257,54 +310,75 @@ const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
<Layers className="w-5 h-5" /> <Layers className="w-5 h-5" />
<span className="font-medium">{deckName}</span> <span className="font-medium">{deckName}</span>
</HStack> </HStack>
<span className="text-sm text-gray-500"> {!isInfinite && (
{t("progress", { current: currentIndex + 1, total: cards.length })} <span className="text-sm text-gray-500">
</span> {t("progress", { current: currentIndex + 1, total: cards.length })}
</span>
)}
</HStack> </HStack>
<Progress {!isInfinite && (
value={((currentIndex + 1) / cards.length) * 100} <Progress
showLabel={false} value={((currentIndex + 1) / cards.length) * 100}
animated={false} showLabel={false}
className="mb-6" animated={false}
/> className="mb-6"
/>
)}
<HStack justify="center" gap={2} className="mb-4"> <VStack gap={2} className="mb-4">
<LightButton <HStack justify="center" gap={1} className="flex-wrap">
onClick={() => { {studyModeOptions.map((option) => (
setIsReversed(!isReversed); <LightButton
setShowAnswer(false); key={option.value}
}} onClick={() => setStudyMode(option.value)}
selected={isReversed} selected={studyMode === option.value}
leftIcon={<RotateCcw className="w-4 h-4" />} leftIcon={option.icon}
size="sm" size="sm"
> >
{t("reverse")} {option.label}
</LightButton> </LightButton>
<LightButton ))}
onClick={() => { </HStack>
setIsDictation(!isDictation);
}} <HStack justify="center" gap={2}>
selected={isDictation} <LightButton
leftIcon={<Headphones className="w-4 h-4" />} onClick={() => {
size="sm" setIsReversed(!isReversed);
> setShowAnswer(false);
{t("dictation")} }}
</LightButton> selected={isReversed}
</HStack> leftIcon={<RotateCcw className="w-4 h-4" />}
size="sm"
>
{t("reverse")}
</LightButton>
<LightButton
onClick={() => {
setIsDictation(!isDictation);
}}
selected={isDictation}
leftIcon={<Headphones className="w-4 h-4" />}
size="sm"
>
{t("dictation")}
</LightButton>
</HStack>
</VStack>
<div className={`bg-white border border-gray-200 rounded-xl shadow-sm mb-6 ${myFont.className}`}> <div className={`bg-white border border-gray-200 rounded-xl shadow-sm mb-6 ${myFont.className}`}>
{isDictation ? ( {isDictation ? (
<> <>
<VStack align="center" justify="center" gap={4} className="p-8 min-h-[20dvh]"> <VStack align="center" justify="center" gap={4} className="p-8 min-h-[20dvh]">
<CircleButton {currentCard.ipa ? (
onClick={playCurrentCard} <div className="text-gray-700 text-2xl text-center font-mono">
disabled={isAudioLoading} {currentCard.ipa}
className="p-4 bg-blue-100 hover:bg-blue-200 text-blue-700 transition-colors disabled:opacity-50" </div>
> ) : (
<Volume2 className="w-8 h-8" /> <div className="text-gray-400 text-lg">
</CircleButton> {t("noIpa")}
<p className="text-gray-500 text-sm">{t("clickToPlay")}</p> </div>
)}
</VStack> </VStack>
{showAnswer && ( {showAnswer && (
@@ -314,11 +388,6 @@ const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
<div className="text-gray-900 text-xl md:text-2xl text-center whitespace-pre-line"> <div className="text-gray-900 text-xl md:text-2xl text-center whitespace-pre-line">
{displayFront} {displayFront}
</div> </div>
{currentCard.ipa && (
<div className="text-gray-500 text-sm mt-2">
{currentCard.ipa}
</div>
)}
{getBackContent(currentCard)} {getBackContent(currentCard)}
</VStack> </VStack>
</> </>
@@ -354,11 +423,25 @@ const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
{t("showAnswer")} {t("showAnswer")}
<span className="ml-2 text-xs opacity-60">Space</span> <span className="ml-2 text-xs opacity-60">Space</span>
</LightButton> </LightButton>
) : isFinished ? (
<VStack align="center" gap={4}>
<div className="text-green-500">
<Check className="w-12 h-12" />
</div>
<p className="text-gray-600">{t("allDoneDesc")}</p>
<HStack gap={2}>
<LightButton onClick={() => router.push("/decks")} className="px-4 py-2">
{t("backToDecks")}
</LightButton>
<LightButton onClick={() => setCurrentIndex(0)} className="px-4 py-2">
{t("restart")}
</LightButton>
</HStack>
</VStack>
) : ( ) : (
<HStack gap={4}> <HStack gap={4}>
<LightButton <LightButton
onClick={handlePrevCard} onClick={handlePrevCard}
disabled={currentIndex === 0}
className="px-4 py-2" className="px-4 py-2"
> >
<ChevronLeft className="w-5 h-5" /> <ChevronLeft className="w-5 h-5" />
@@ -369,7 +452,6 @@ const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
</span> </span>
<LightButton <LightButton
onClick={handleNextCard} onClick={handleNextCard}
disabled={currentIndex === cards.length - 1}
className="px-4 py-2" className="px-4 py-2"
> >
<ChevronRight className="w-5 h-5" /> <ChevronRight className="w-5 h-5" />