import { useState, useCallback, useEffect, useRef, useMemo } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useNavigate } from '@tanstack/react-router' import { cn } from '@/lib/utils' import { useAuthStore } from '@/store/auth-store' import { fetchFlashcardTerms, fetchUserProgress, upsertTermProgress, startSession, endSession, logReview, fetchFlashcardLists, } from '../api/flashcard-api' import type { FlashcardTerm, UserProgress } from '../api/flashcard-api' import { EASE, type EaseKey } from '../lib/srs-intervals' interface Props { listId: number } type SessionStats = { known: number; learning: number; ignored: number } function speak(word: string) { try { const u = new SpeechSynthesisUtterance(word) u.lang = 'en-US' u.rate = 0.9 speechSynthesis.cancel() speechSynthesis.speak(u) } catch { /* noop */ } } export function FlashCardLearnPage({ listId }: Props) { const navigate = useNavigate() const user = useAuthStore(s => s.user) const queryClient = useQueryClient() const [isFlipped, setIsFlipped] = useState(false) const [currentIdx, setCurrentIdx] = useState(0) const [sessionStats, setSessionStats] = useState({ known: 0, learning: 0, ignored: 0 }) const [isDone, setIsDone] = useState(false) const [fx, setFx] = useState<'known' | 'review' | null>(null) // Bookmarks — per-list, persisted in localStorage const bookmarkKey = `flashcard-bookmarks-${listId}` const [bookmarks, setBookmarks] = useState>(() => { try { const raw = localStorage.getItem(bookmarkKey) return raw ? new Set(JSON.parse(raw)) : new Set() } catch { return new Set() } }) const toggleBookmark = useCallback((termId: number) => { setBookmarks(prev => { const next = new Set(prev) if (next.has(termId)) next.delete(termId) else next.add(termId) try { localStorage.setItem(bookmarkKey, JSON.stringify([...next])) } catch { /* noop */ } return next }) }, [bookmarkKey]) // Refs for unmount cleanup so effects see fresh values const sessionIdRef = useRef(null) const statsRef = useRef(sessionStats) const isDoneRef = useRef(false) const newTermIdsAtStartRef = useRef>(new Set()) const answeredNewIdsRef = useRef>(new Set()) useEffect(() => { statsRef.current = sessionStats }, [sessionStats]) useEffect(() => { isDoneRef.current = isDone }, [isDone]) const { data: terms = [], isLoading: loadingTerms } = useQuery({ queryKey: ['flashcard-terms', listId], queryFn: () => fetchFlashcardTerms(listId), }) const { data: lists = [] } = useQuery({ queryKey: ['flashcard-lists'], queryFn: fetchFlashcardLists, staleTime: 5 * 60 * 1000, }) const currentList = lists.find(l => l.id === listId) const { data: progress = [] } = useQuery({ queryKey: ['flashcard-progress', user?.id, listId], queryFn: () => fetchUserProgress(user!.id, listId), enabled: !!user, }) const progressMap = useMemo(() => { const m: Record = {} progress.forEach(p => { m[p.term_id] = p }) return m }, [progress]) // Session term ordering: prioritise due-for-review, then new, then known const sessionTerms: FlashcardTerm[] = useMemo(() => { if (!terms.length) return [] const now = Date.now() const due: FlashcardTerm[] = [] const fresh: FlashcardTerm[] = [] const known: FlashcardTerm[] = [] for (const t of terms) { const p = progressMap[t.id] if (p?.status === 'ignored') continue if (!p) { fresh.push(t); continue } if (!p.next_review_at) { fresh.push(t); continue } if (new Date(p.next_review_at).getTime() <= now) { due.push(t); continue } if (p.status === 'known') known.push(t) else fresh.push(t) } return [...due, ...fresh, ...known] }, [terms, progressMap]) // Snapshot "new" term IDs at session start (runs once when data is loaded) useEffect(() => { if (newTermIdsAtStartRef.current.size === 0 && terms.length > 0) { const newIds = new Set() for (const t of terms) { const s = progressMap[t.id]?.status ?? 'new' if (s === 'new') newIds.add(t.id) } newTermIdsAtStartRef.current = newIds } }, [terms, progressMap]) // Start session on mount (guarded against StrictMode double-invoke) useEffect(() => { if (!user || sessionIdRef.current !== null) return let cancelled = false startSession(user.id, listId) .then(s => { if (!cancelled) sessionIdRef.current = s.id }) .catch(err => console.error('startSession failed:', err)) return () => { cancelled = true } }, [user, listId]) // End session on unmount (if not already ended via done-screen effect) useEffect(() => { return () => { const sid = sessionIdRef.current if (sid === null || isDoneRef.current) return const s = statsRef.current const reviewed = s.known + s.learning + s.ignored endSession(sid, reviewed, answeredNewIdsRef.current.size) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // End session when reaching done screen useEffect(() => { if (!isDone) return const sid = sessionIdRef.current if (sid === null) return const s = statsRef.current const reviewed = s.known + s.learning + s.ignored endSession(sid, reviewed, answeredNewIdsRef.current.size) }, [isDone]) const { mutate: saveAnswer } = useMutation({ mutationFn: async ({ termId, easeKey, reviewCount }: { termId: number easeKey: EaseKey reviewCount: number }) => { if (!user) return const sid = sessionIdRef.current await Promise.all([ upsertTermProgress(user.id, termId, listId, easeKey, reviewCount), sid !== null ? logReview(sid, user.id, termId, EASE[easeKey]) : Promise.resolve(), ]) }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['flashcard-progress', user?.id, listId] }) }, }) const advance = useCallback(() => { if (currentIdx + 1 >= sessionTerms.length) { setIsDone(true) } else { setCurrentIdx(i => i + 1) setIsFlipped(false) } setFx(null) }, [currentIdx, sessionTerms.length]) const handleAnswer = useCallback((key: EaseKey) => { const term = sessionTerms[currentIdx] if (!term || !user) return const currentProgress = progressMap[term.id] const reviewCount = currentProgress?.review_count ?? 0 saveAnswer({ termId: term.id, easeKey: key, reviewCount }) if (newTermIdsAtStartRef.current.has(term.id)) { answeredNewIdsRef.current.add(term.id) } setSessionStats(prev => ({ known: prev.known + (key === 'known' ? 1 : 0), learning: prev.learning + (key === 'easy' || key === 'hard' ? 1 : 0), ignored: prev.ignored + (key === 'ignored' ? 1 : 0), })) // Visual feedback: known swipes right, hard/ignored swipes left if (key === 'known' || key === 'easy') { setFx('known') } else { setFx('review') } setTimeout(advance, 450) }, [currentIdx, sessionTerms, user, saveAnswer, progressMap, advance]) // Keyboard shortcuts useEffect(() => { function onKey(e: KeyboardEvent) { if (isDone || !sessionTerms[currentIdx]) return if (e.key === ' ' || e.key === 'Enter') { e.preventDefault() setIsFlipped(v => !v) return } if (!isFlipped) return if (e.key.toLowerCase() === 'j') handleAnswer('known') else if (e.key.toLowerCase() === 'k') handleAnswer('hard') else if (e.key.toLowerCase() === 'i') handleAnswer('ignored') } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [isDone, isFlipped, currentIdx, sessionTerms, handleAnswer]) const total = sessionTerms.length const progressPct = total > 0 ? Math.round((currentIdx / total) * 100) : 0 const current = sessionTerms[currentIdx] if (loadingTerms) { return (
) } if (sessionTerms.length === 0) { return (
All clear.

Không có thẻ nào cần học ngay bây giờ. Quay lại sau khi đến lịch ôn tập.

) } if (isDone) { return (
Bravo.

Hoàn thành phiên học

Bạn đã ôn xong {total} thẻ trong phiên này

{sessionStats.known}
Đã biết
{sessionStats.learning}
Đang học
{sessionStats.ignored}
Bỏ qua
) } // Jump to a specific card in the deck (no progress write — just navigate) const jumpTo = (idx: number) => { setCurrentIdx(idx) setIsFlipped(false) } return (
{/* Header row: breadcrumb + serif title on left, actions on right */}
/

Thẻ {currentIdx + 1} / {total}

{/* Body: card column + sidebar */}
{/* Main: card + actions + progress */}
{/* Card */} {current && (
setIsFlipped(v => !v)} role="button" tabIndex={0} aria-label={isFlipped ? 'Lật để xem từ' : 'Lật để xem nghĩa'} > {/* FRONT */}
{current.part_of_speech?.toUpperCase() ?? 'TỪ VỰNG'}
{current.word}
{(current.phonetic || current.part_of_speech) && (
{current.phonetic} {current.part_of_speech && ( · {current.part_of_speech} )}
)}
Space để lật thẻ
{/* BACK */}
NGHĨA
{current.definition ?? '—'}
{current.example && (
"{current.example}"
)}
lật lại
)} {/* Actions */}
{/* Progress */}
{currentIdx + 1} / {total} ·{' '} {sessionStats.known} biết · {sessionStats.learning} học · {sessionStats.ignored} bỏ {progressPct}%
{/* Right sidebar */}
) }