pdf-quiz-generator/frontend/src/pages/FlashcardStudyPage.jsx
Daniel 3e37bf2128 Add AI-powered flashcard generation system
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>
2026-04-04 22:59:21 +02:00

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 }}>&#127881;</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}>&#8592; Prev</button>
{flipped && (
<>
<button className="btn btn-primary" onClick={markKnown} style={{ background: '#22c55e', borderColor: '#22c55e' }}>Got it &#10003;</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 &#8594;</button>
</div>
<div style={{ textAlign: 'center', marginTop: 8, fontSize: '0.75rem', color: 'var(--text-muted)' }}>
Keyboard: Space/Enter=flip, &#8592;&#8594;=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>
)
}