'use client' import { useCallback, useEffect, useMemo, useState } from 'react' import { motion, AnimatePresence } from 'motion/react' import { GraduationCap, Layers, ArrowLeft, ChevronLeft, ChevronRight, Calendar, Plus, BarChart3, Loader2, BookOpen, } from 'lucide-react' import { useSearchParams } from 'next/navigation' import { useLanguage } from '@/lib/i18n' import { cn } from '@/lib/utils' import { NoteChart } from '@/components/note-chart' import { RevisionHeatmap } from './revision-heatmap' import { toast } from 'sonner' interface DeckSummary { id: string name: string notebookId: string | null totalCards: number dueCount: number masteredCount: number lastReviewedAt: string | null } interface FlashcardItem { id: string front: string back: string type: string due: boolean mastered: boolean } interface StatsResponse { heatmap: { date: string; count: number }[] retentionRate: number retentionByWeek: { week: string; rate: number }[] difficultCards: { id: string; front: string; easinessFactor: number; deckName: string }[] } type PageTab = 'decks' | 'progress' type SessionGrade = 1 | 2 | 3 | 4 export function RevisionView() { const { t } = useLanguage() const searchParams = useSearchParams() const initialDeckId = searchParams.get('deckId') const [tab, setTab] = useState('decks') const [decks, setDecks] = useState([]) const [loadingDecks, setLoadingDecks] = useState(true) const [stats, setStats] = useState(null) const [loadingStats, setLoadingStats] = useState(false) const [activeDeckId, setActiveDeckId] = useState(null) const [deckCards, setDeckCards] = useState([]) const [deckStats, setDeckStats] = useState({ total: 0, due: 0, mastered: 0 }) const [loadingDeck, setLoadingDeck] = useState(false) const [isSessionActive, setIsSessionActive] = useState(false) const [isSessionFinished, setIsSessionFinished] = useState(false) const [sessionCards, setSessionCards] = useState([]) const [currentIndex, setCurrentIndex] = useState(0) const [isFlipped, setIsFlipped] = useState(false) const [sessionGrades, setSessionGrades] = useState>({}) const [reviewing, setReviewing] = useState(false) const [newDeckName, setNewDeckName] = useState('') const [creatingDeck, setCreatingDeck] = useState(false) const loadDecks = useCallback(async () => { setLoadingDecks(true) try { const res = await fetch('/api/flashcards/decks') const data = await res.json() if (res.ok) setDecks(data.decks || []) } finally { setLoadingDecks(false) } }, []) const loadStats = useCallback(async () => { setLoadingStats(true) try { const res = await fetch('/api/flashcards/stats') const data = await res.json() if (res.ok) setStats(data) } finally { setLoadingStats(false) } }, []) const loadDeck = useCallback(async (deckId: string) => { setLoadingDeck(true) try { const res = await fetch(`/api/flashcards/decks/${deckId}`) const data = await res.json() if (!res.ok) { toast.error(data.error || t('flashcards.loadDeckFailed')) return } setActiveDeckId(deckId) setDeckCards(data.deck.cards || []) setDeckStats(data.stats) } finally { setLoadingDeck(false) } }, [t]) useEffect(() => { loadDecks() }, [loadDecks]) useEffect(() => { if (tab === 'progress') loadStats() }, [tab, loadStats]) useEffect(() => { if (initialDeckId && !activeDeckId) { loadDeck(initialDeckId) } }, [initialDeckId, activeDeckId, loadDeck]) const activeDeck = useMemo( () => decks.find((d) => d.id === activeDeckId), [decks, activeDeckId], ) const sessionChartData = useMemo(() => { const counts = { 1: 0, 2: 0, 3: 0, 4: 0 } Object.values(sessionGrades).forEach((g) => { counts[g] += 1 }) return [ { label: t('flashcards.grade.hard'), value: counts[1] }, { label: t('flashcards.grade.difficult'), value: counts[2] }, { label: t('flashcards.grade.good'), value: counts[3] }, { label: t('flashcards.grade.easy'), value: counts[4] }, ] }, [sessionGrades, t]) const startSession = useCallback((deckId: string, cardsInput?: FlashcardItem[], dueOnly = true) => { const cards = cardsInput ?? (activeDeckId === deckId ? deckCards : []) if (cards.length === 0) { void loadDeck(deckId) return } let toReview = dueOnly ? cards.filter((c) => c.due) : cards if (toReview.length === 0) toReview = cards const shuffled = [...toReview].sort(() => Math.random() - 0.5) setSessionCards(shuffled) setCurrentIndex(0) setIsFlipped(false) setSessionGrades({}) setIsSessionActive(true) setIsSessionFinished(false) setActiveDeckId(deckId) }, [activeDeckId, deckCards, loadDeck]) useEffect(() => { if (!initialDeckId) return let cancelled = false ;(async () => { const res = await fetch(`/api/flashcards/decks/${initialDeckId}`) const data = await res.json() if (cancelled || !res.ok) return const cards: FlashcardItem[] = data.deck?.cards || [] setActiveDeckId(initialDeckId) setDeckCards(cards) setDeckStats(data.stats || { total: 0, due: 0, mastered: 0 }) startSession(initialDeckId, cards) })() return () => { cancelled = true } }, [initialDeckId, startSession]) const handleEvaluate = async (grade: SessionGrade) => { const card = sessionCards[currentIndex] if (!card || reviewing) return setReviewing(true) setSessionGrades((prev) => ({ ...prev, [card.id]: grade })) try { await fetch(`/api/flashcards/${card.id}/review`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grade }), }) } catch { toast.error(t('flashcards.reviewFailed')) } finally { setReviewing(false) } if (currentIndex < sessionCards.length - 1) { setTimeout(() => { setCurrentIndex((i) => i + 1) setIsFlipped(false) }, 250) } else { setTimeout(() => setIsSessionFinished(true), 250) } } useEffect(() => { if (!isSessionActive || isSessionFinished) return const onKey = (e: KeyboardEvent) => { if (e.code === 'Space') { e.preventDefault() setIsFlipped((f) => !f) } else if (isFlipped) { if (e.key === '1') handleEvaluate(1) if (e.key === '2') handleEvaluate(2) if (e.key === '3') handleEvaluate(3) if (e.key === '4') handleEvaluate(4) } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [isSessionActive, isSessionFinished, isFlipped, currentIndex, reviewing]) const exitSession = () => { setIsSessionActive(false) setIsSessionFinished(false) if (activeDeckId) { loadDeck(activeDeckId) loadDecks() } } const createDeck = async () => { const name = newDeckName.trim() if (!name) return setCreatingDeck(true) try { const res = await fetch('/api/flashcards/decks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }), }) const data = await res.json() if (res.ok) { setNewDeckName('') await loadDecks() toast.success(t('flashcards.deckCreated')) if (data.deck?.id) setActiveDeckId(data.deck.id) } } finally { setCreatingDeck(false) } } const formatDue = (dueCount: number) => { if (dueCount > 0) return t('flashcards.dueCount', { count: dueCount }) return t('flashcards.upToDate') } return (
{isSessionActive ? ( ) : (

{t('nav.revision')}

)}
{!isSessionActive && (
{(['decks', 'progress'] as const).map((id) => ( ))}
)}
{isSessionActive && !isSessionFinished && sessionCards[currentIndex] && (
{currentIndex + 1} / {sessionCards.length}
{isFlipped && (
{([ [1, t('flashcards.grade.hard'), 'bg-red-500/10 text-red-600 border-red-500/20'], [2, t('flashcards.grade.difficult'), 'bg-amber-500/10 text-amber-700 border-amber-500/20'], [3, t('flashcards.grade.good'), 'bg-emerald-500/10 text-emerald-700 border-emerald-500/20'], [4, t('flashcards.grade.easy'), 'bg-brand-accent/10 text-brand-accent border-brand-accent/30'], ] as const).map(([grade, label, cls]) => ( ))}
)}
)} {isSessionFinished && (

{t('flashcards.sessionComplete')}

)} {!isSessionActive && tab === 'decks' && ( {activeDeckId && deckCards.length > 0 && (

{activeDeck?.name || t('flashcards.activeDeck')}

{t('flashcards.statTotal', { count: deckStats.total })} {t('flashcards.statDue', { count: deckStats.due })} {t('flashcards.statMastered', { count: deckStats.mastered })}
)}
setNewDeckName(e.target.value)} placeholder={t('flashcards.newDeckPlaceholder')} className="flex-1 min-w-[180px] px-3 py-2 rounded-lg border border-border text-sm bg-transparent" />
{loadingDecks ? (
) : decks.length === 0 ? (

{t('flashcards.emptyDecks')}

{t('flashcards.emptyDecksHint')}

) : (
{decks.map((deck) => (

{deck.name}

{t('flashcards.cardCountLabel', { count: deck.totalCards })}

{deck.dueCount > 0 ? ( {formatDue(deck.dueCount)} ) : ( {t('flashcards.upToDate')} )} {deck.masteredCount}/{deck.totalCards} {t('flashcards.masteredShort')}
))}
)}
)} {!isSessionActive && tab === 'progress' && ( {loadingStats ? (
) : stats ? ( <>

{t('flashcards.retentionRate')}

{stats.retentionRate}%

{t('flashcards.retentionCurve')}

({ label: w.week.slice(5), value: w.rate, }))} height={120} />
{stats.difficultCards.length > 0 && (

{t('flashcards.difficultCards')}

    {stats.difficultCards.map((c) => (
  • {c.front} EF {c.easinessFactor.toFixed(2)}
  • ))}
)} ) : null}
)}
) }