"use client"; 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, 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, CircleButton } from "@/design-system/base/button"; import { Badge } from "@/design-system/data-display/badge"; import { Progress } from "@/design-system/feedback/progress"; import { Skeleton } from "@/design-system/feedback/skeleton"; import { HStack, VStack } from "@/design-system/layout/stack"; 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", }); interface MemorizeProps { deckId: number; deckName: string; } type ReviewEase = 1 | 2 | 3 | 4; const Memorize: React.FC = ({ deckId, deckName }) => { const t = useTranslations("memorize.review"); const router = useRouter(); const [isPending, startTransition] = useTransition(); const [cards, setCards] = useState([]); const [currentIndex, setCurrentIndex] = useState(0); const [showAnswer, setShowAnswer] = useState(false); 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 { play, stop, load } = useAudioPlayer(); const audioUrlRef = useRef(null); const [isAudioLoading, setIsAudioLoading] = useState(false); 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); setIsReversed(false); setIsDictation(false); } 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 = useCallback(() => { setShowAnswer(true); }, []); const handleAnswer = useCallback((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); setIsReversed(false); setIsDictation(false); if (audioUrlRef.current) { URL.revokeObjectURL(audioUrlRef.current); audioUrlRef.current = null; } stop(); } else { setError(result.message); } }); }, [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) => { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { return; } if (!showAnswer) { if (e.key === " " || e.key === "Enter") { e.preventDefault(); handleShowAnswer(); } } else { if (e.key === "1") { e.preventDefault(); handleAnswer(1); } else if (e.key === "2") { e.preventDefault(); handleAnswer(2); } else if (e.key === "3" || e.key === " " || e.key === "Enter") { e.preventDefault(); handleAnswer(3); } else if (e.key === "4") { e.preventDefault(); handleAnswer(4); } } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [showAnswer, handleShowAnswer, handleAnswer]); 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) }); }; const getCardTypeLabel = (type: CardType): string => { switch (type) { case CardType.NEW: return t("cardTypeNew"); case CardType.LEARNING: return t("cardTypeLearning"); case CardType.REVIEW: return t("cardTypeReview"); case CardType.RELEARNING: return t("cardTypeRelearning"); default: return ""; } }; const getCardTypeVariant = (type: CardType): "info" | "warning" | "success" | "primary" => { switch (type) { case CardType.NEW: return "info"; case CardType.LEARNING: return "warning"; case CardType.REVIEW: return "success"; case CardType.RELEARNING: return "primary"; default: return "info"; } }; if (isLoading) { return (

{t("loading")}

); } if (error) { return (
{error}
router.push("/decks")} className="px-4 py-2"> {t("backToDecks")}
); } if (cards.length === 0) { return (

{t("allDone")}

{t("allDoneDesc")}

router.push("/decks")} className="px-4 py-2"> {t("backToDecks")}
); } const currentCard = getCurrentCard()!; 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, ivl: currentCard.ivl, factor: currentCard.factor, left: currentCard.left, }; const previewIntervals = calculatePreviewIntervals(cardPreview); return ( {deckName} {getCardTypeLabel(currentCard.type)} {t("progress", { current: currentIndex + 1, total: cards.length + currentIndex })} {lastScheduled && (
{t("nextReview")}: {formatNextReview(lastScheduled)}
)} { setIsReversed(!isReversed); }} selected={isReversed} leftIcon={} size="sm" > {t("reverse")} { setIsDictation(!isDictation); }} selected={isDictation} leftIcon={} size="sm" > {t("dictation")}
{isDictation ? ( <>

{t("clickToPlay")}

{showAnswer && ( <>
{displayBack}
)} ) : ( <>
{displayFront}
{showAnswer && ( <>
{displayBack}
)} )}
{t("interval")}: {formatInterval(currentCard.ivl)} {t("ease")}: {currentCard.factor / 10}% {t("lapses")}: {currentCard.lapses} {!showAnswer ? ( {t("showAnswer")} Space ) : ( )} ); }; export { Memorize };