New feature: generate flashcards from PDF sections using AI, completely separate from the existing quiz system. Backend: - FlashcardDeck + Flashcard models with cascade deletes - flashcard_tag_links table for tag classification (reuses question_tags) - /api/flashcards/ router: CRUD for decks, browse/search cards, tag filtering - generate_flashcard_deck Celery task with chunked processing + progress - FLASHCARD_PROMPT in extraction_modes.py (15 cards per chunk) - "flashcard" added to admin model task types Frontend: - FlashcardsPage: deck grid + card browser with search/filter - FlashcardStudyPage: flip cards, mark known/review, keyboard nav, shuffle, progress bar, completion screen - DocumentDetailPage: "Create Flashcards" button alongside "Extract Quiz" - Navbar: Flashcards link - AdminPage: flashcard in model task dropdown Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
188 lines
7.8 KiB
JavaScript
188 lines
7.8 KiB
JavaScript
import { useState, useEffect, useCallback } from 'react'
|
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
import api from '../api/client'
|
|
|
|
export default function FlashcardStudyPage() {
|
|
const { deckId } = useParams()
|
|
const navigate = useNavigate()
|
|
const [deck, setDeck] = useState(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [currentIdx, setCurrentIdx] = useState(0)
|
|
const [flipped, setFlipped] = useState(false)
|
|
const [known, setKnown] = useState(new Set())
|
|
const [review, setReview] = useState(new Set())
|
|
const [mode, setMode] = useState('all') // 'all' | 'review'
|
|
const [shuffled, setShuffled] = useState(false)
|
|
const [cardOrder, setCardOrder] = useState([])
|
|
|
|
useEffect(() => {
|
|
api.get(`/flashcards/${deckId}`).then(res => {
|
|
setDeck(res.data)
|
|
setCardOrder(res.data.cards.map((_, i) => i))
|
|
setLoading(false)
|
|
}).catch(() => navigate('/flashcards'))
|
|
}, [deckId])
|
|
|
|
const cards = deck?.cards || []
|
|
const activeIndices = mode === 'review' ? cardOrder.filter(i => review.has(cards[i]?.id)) : cardOrder
|
|
const currentCard = cards[activeIndices[currentIdx]]
|
|
const total = activeIndices.length
|
|
const progress = total > 0 ? Math.round(((known.size) / cards.length) * 100) : 0
|
|
|
|
const next = useCallback(() => {
|
|
setFlipped(false)
|
|
if (currentIdx < total - 1) setCurrentIdx(i => i + 1)
|
|
}, [currentIdx, total])
|
|
|
|
const prev = useCallback(() => {
|
|
setFlipped(false)
|
|
if (currentIdx > 0) setCurrentIdx(i => i - 1)
|
|
}, [currentIdx])
|
|
|
|
const markKnown = () => {
|
|
if (currentCard) {
|
|
setKnown(s => { const n = new Set(s); n.add(currentCard.id); return n })
|
|
setReview(s => { const n = new Set(s); n.delete(currentCard.id); return n })
|
|
}
|
|
next()
|
|
}
|
|
|
|
const markReview = () => {
|
|
if (currentCard) {
|
|
setReview(s => { const n = new Set(s); n.add(currentCard.id); return n })
|
|
setKnown(s => { const n = new Set(s); n.delete(currentCard.id); return n })
|
|
}
|
|
next()
|
|
}
|
|
|
|
const shuffle = () => {
|
|
setCardOrder(prev => {
|
|
const arr = [...prev]
|
|
for (let i = arr.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[arr[i], arr[j]] = [arr[j], arr[i]]
|
|
}
|
|
return arr
|
|
})
|
|
setCurrentIdx(0)
|
|
setFlipped(false)
|
|
setShuffled(true)
|
|
}
|
|
|
|
const reset = () => {
|
|
setKnown(new Set())
|
|
setReview(new Set())
|
|
setCurrentIdx(0)
|
|
setFlipped(false)
|
|
setMode('all')
|
|
}
|
|
|
|
// Keyboard navigation
|
|
useEffect(() => {
|
|
const handler = (e) => {
|
|
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setFlipped(v => !v) }
|
|
if (e.key === 'ArrowRight') next()
|
|
if (e.key === 'ArrowLeft') prev()
|
|
if (e.key === '1') markKnown()
|
|
if (e.key === '2') markReview()
|
|
}
|
|
window.addEventListener('keydown', handler)
|
|
return () => window.removeEventListener('keydown', handler)
|
|
}, [next, prev, currentCard])
|
|
|
|
if (loading) return <div className="loading"><div className="spinner" /></div>
|
|
if (!deck) return null
|
|
|
|
const allDone = known.size === cards.length
|
|
|
|
return (
|
|
<div>
|
|
{/* Header */}
|
|
<div className="card" style={{ marginBottom: 16 }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
|
|
<div>
|
|
<h2 style={{ marginBottom: 4 }}>{deck.title}</h2>
|
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
|
{cards.length} cards · {known.size} known · {review.size} to review
|
|
</p>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
|
<button className={`btn btn-sm ${mode === 'all' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => { setMode('all'); setCurrentIdx(0); setFlipped(false) }}>All ({cards.length})</button>
|
|
{review.size > 0 && (
|
|
<button className={`btn btn-sm ${mode === 'review' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => { setMode('review'); setCurrentIdx(0); setFlipped(false) }}>Review ({review.size})</button>
|
|
)}
|
|
<button className="btn btn-sm btn-secondary" onClick={shuffle}>Shuffle</button>
|
|
<button className="btn btn-sm btn-secondary" onClick={reset}>Reset</button>
|
|
<button className="btn btn-sm btn-secondary" onClick={() => navigate('/flashcards')}>Back</button>
|
|
</div>
|
|
</div>
|
|
{/* Progress bar */}
|
|
<div style={{ marginTop: 12 }}>
|
|
<div className="progress-bar" style={{ height: 6 }}>
|
|
<div className="fill" style={{ width: `${progress}%`, transition: 'width 0.3s' }} />
|
|
</div>
|
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: 4 }}>{progress}% mastered</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Completion screen */}
|
|
{allDone && (
|
|
<div className="card" style={{ textAlign: 'center', padding: 40 }}>
|
|
<div style={{ fontSize: '2.5rem', marginBottom: 12 }}>🎉</div>
|
|
<h2>All cards mastered!</h2>
|
|
<p style={{ color: 'var(--text-muted)', marginBottom: 16 }}>You marked all {cards.length} cards as known.</p>
|
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
|
<button className="btn btn-primary" onClick={reset}>Study again</button>
|
|
<button className="btn btn-secondary" onClick={() => navigate('/flashcards')}>Back to decks</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Card */}
|
|
{!allDone && total > 0 && currentCard && (
|
|
<>
|
|
<div
|
|
style={{
|
|
background: 'var(--card-bg)', borderRadius: 'var(--card-radius)',
|
|
padding: 40, minHeight: 250, display: 'flex', flexDirection: 'column',
|
|
alignItems: 'center', justifyContent: 'center', textAlign: 'center',
|
|
cursor: 'pointer', border: `2px solid ${flipped ? 'var(--primary)' : 'var(--border)'}`,
|
|
boxShadow: '0 4px 20px rgba(0,0,0,0.08)', transition: 'border-color 0.2s',
|
|
marginBottom: 16,
|
|
}}
|
|
onClick={() => setFlipped(v => !v)}
|
|
>
|
|
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginBottom: 8, textTransform: 'uppercase', fontWeight: 700, letterSpacing: '0.05em' }}>
|
|
{flipped ? 'Back' : 'Front'} · Card {currentIdx + 1} of {total}
|
|
</div>
|
|
<p style={{ fontSize: flipped ? '1rem' : '1.15rem', lineHeight: 1.7, fontWeight: flipped ? 400 : 600, maxWidth: 500 }}>
|
|
{flipped ? currentCard.back : currentCard.front}
|
|
</p>
|
|
{!flipped && (
|
|
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 16 }}>Tap to reveal answer</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
|
|
<button className="btn btn-secondary" onClick={prev} disabled={currentIdx === 0}>← Prev</button>
|
|
{flipped && (
|
|
<>
|
|
<button className="btn btn-primary" onClick={markKnown} style={{ background: '#22c55e', borderColor: '#22c55e' }}>Got it ✓</button>
|
|
<button className="btn btn-secondary" onClick={markReview} style={{ color: '#ef4444', borderColor: '#ef4444' }}>Review again</button>
|
|
</>
|
|
)}
|
|
<button className="btn btn-secondary" onClick={next} disabled={currentIdx >= total - 1}>Next →</button>
|
|
</div>
|
|
<div style={{ textAlign: 'center', marginTop: 8, fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
|
Keyboard: Space/Enter=flip, ←→=navigate, 1=got it, 2=review
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{!allDone && total === 0 && (
|
|
<div className="card"><div className="empty-state">No cards to show. {mode === 'review' ? 'No cards marked for review.' : ''}</div></div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|