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>
This commit is contained in:
Daniel 2026-04-04 22:59:21 +02:00
parent 2cbbfe00c3
commit 3e37bf2128
13 changed files with 967 additions and 4 deletions

View file

@ -7,7 +7,7 @@ from fastapi.staticfiles import StaticFiles
from app.config import settings
from app.database import engine, Base, SessionLocal
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards
from app.utils.auth import get_password_hash
from app.utils.scheduler import start_scheduler, stop_scheduler
@ -131,6 +131,7 @@ def setup_pgvector():
from sqlalchemy import text
# Import new models so create_all picks them up
from app.models import quiz_category, quiz_question_link, question_category, favorite # noqa
from app.models import flashcard # noqa
# Kill stale idle-in-transaction connections from previous killed startups.
# They hold DDL locks and cause ALTER TABLE below to hang indefinitely.
@ -275,6 +276,35 @@ def setup_pgvector():
PRIMARY KEY (question_id, tag_id)
)
"""))
# Flashcard decks and cards
conn.execute(text("""
CREATE TABLE IF NOT EXISTS flashcard_decks (
id SERIAL PRIMARY KEY,
title VARCHAR NOT NULL,
section_id INTEGER REFERENCES sections(id) ON DELETE SET NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
card_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS flashcards (
id SERIAL PRIMARY KEY,
deck_id INTEGER NOT NULL REFERENCES flashcard_decks(id) ON DELETE CASCADE,
front TEXT NOT NULL,
back TEXT NOT NULL,
page_reference INTEGER,
image_path VARCHAR,
created_at TIMESTAMP DEFAULT NOW()
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS flashcard_tag_links (
flashcard_id INTEGER NOT NULL REFERENCES flashcards(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES question_tags(id) ON DELETE CASCADE,
PRIMARY KEY (flashcard_id, tag_id)
)
"""))
conn.commit()
@ -380,6 +410,7 @@ app.include_router(favorites.router, prefix="/api/favorites", tags=["favorites"]
app.include_router(teach.router, prefix="/api/teach", tags=["teach"])
app.include_router(contact.router, prefix="/api/contact", tags=["contact"])
app.include_router(tags.router, prefix="/api/tags", tags=["tags"])
app.include_router(flashcards.router, prefix="/api/flashcards", tags=["flashcards"])
@app.get("/api/health")

View file

@ -0,0 +1,26 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base
class FlashcardDeck(Base):
__tablename__ = "flashcard_decks"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, nullable=False)
section_id = Column(Integer, ForeignKey("sections.id", ondelete="SET NULL"), nullable=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
card_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
cards = relationship("Flashcard", back_populates="deck", cascade="all, delete-orphan")
user = relationship("User")
class Flashcard(Base):
__tablename__ = "flashcards"
id = Column(Integer, primary_key=True, index=True)
deck_id = Column(Integer, ForeignKey("flashcard_decks.id", ondelete="CASCADE"), nullable=False)
front = Column(Text, nullable=False)
back = Column(Text, nullable=False)
page_reference = Column(Integer, nullable=True)
image_path = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
deck = relationship("FlashcardDeck", back_populates="cards")

View file

@ -187,7 +187,7 @@ def create_model(
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
if data.task not in ("extraction", "tts", "teach", "keyword"):
if data.task not in ("extraction", "tts", "teach", "keyword", "flashcard"):
raise HTTPException(status_code=400, detail="Task must be extraction, tts, teach, or keyword")
if data.is_default:

View file

@ -0,0 +1,316 @@
"""Flashcard decks and cards — generate, browse, edit, delete."""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import or_, text as sa_text
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.flashcard import FlashcardDeck, Flashcard
from app.models.section import Section
from app.models.user import User
from app.utils.auth import get_current_user, require_moderator
router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────
class FlashcardDeckCreate(BaseModel):
section_id: int
title: str
model_id: str | None = None
class FlashcardDeckResponse(BaseModel):
id: int
title: str
section_id: int | None
user_id: int
card_count: int
created_at: object
class Config:
from_attributes = True
class FlashcardCardResponse(BaseModel):
id: int
deck_id: int
front: str
back: str
page_reference: int | None = None
image_path: str | None = None
created_at: object
class Config:
from_attributes = True
class FlashcardDeckDetail(BaseModel):
id: int
title: str
section_id: int | None
user_id: int
card_count: int
created_at: object
cards: list[FlashcardCardResponse]
class Config:
from_attributes = True
class CardEdit(BaseModel):
front: str | None = None
back: str | None = None
# ── Deck endpoints ───────────────────────────────────────────────────
@router.post("/")
def create_flashcard_deck(
data: FlashcardDeckCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Start async flashcard generation from a section. Returns {job_id} immediately."""
import uuid
section = db.query(Section).filter(Section.id == data.section_id).first()
if not section:
raise HTTPException(status_code=404, detail="Section not found")
if not current_user.is_admin and section.document.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not your document")
job_id = str(uuid.uuid4())
try:
from app.tasks.quiz_tasks import generate_flashcard_deck
import redis as redis_lib
from app.config import settings
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
r.set(f"extraction:status:{job_id}", "pending", ex=3600)
r.lpush(f"extraction:user_jobs:{current_user.id}", job_id)
r.expire(f"extraction:user_jobs:{current_user.id}", 86400)
r.set(f"extraction:job_title:{job_id}", data.title, ex=3600)
generate_flashcard_deck.delay(
job_id=job_id,
user_id=current_user.id,
section_id=data.section_id,
title=data.title,
model_id=data.model_id,
)
except Exception:
raise HTTPException(status_code=503, detail="Task queue unavailable")
return {"job_id": job_id, "status": "pending"}
@router.get("/", response_model=list[FlashcardDeckResponse])
def list_flashcard_decks(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List flashcard decks. Moderators see all; regular users see own."""
q = db.query(FlashcardDeck)
if not current_user.is_moderator:
q = q.filter(FlashcardDeck.user_id == current_user.id)
return q.order_by(FlashcardDeck.created_at.desc()).all()
@router.get("/{deck_id}", response_model=FlashcardDeckDetail)
def get_flashcard_deck(
deck_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a deck with all its cards."""
deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == deck_id).first()
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
if not current_user.is_moderator and deck.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not your deck")
return deck
@router.delete("/{deck_id}", status_code=204)
def delete_flashcard_deck(
deck_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Delete a deck and all its cards."""
deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == deck_id).first()
if not deck:
raise HTTPException(status_code=404, detail="Deck not found")
db.delete(deck)
db.commit()
# ── Card browse endpoints ────────────────────────────────────────────
@router.get("/cards/browse")
def browse_flashcards(
q: str | None = Query(None),
deck_id: int | None = Query(None),
tag_ids: str | None = Query(None, description="Comma-separated tag IDs (AND filter)"),
limit: int = Query(50, le=200),
offset: int = Query(0),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Browse ALL flashcards across all decks (like question bank)."""
query = db.query(Flashcard)
# Restrict non-moderators to own decks
if not current_user.is_moderator:
own_deck_ids = [d.id for d in db.query(FlashcardDeck.id).filter(
FlashcardDeck.user_id == current_user.id
).all()]
if not own_deck_ids:
return {"total": 0, "cards": []}
query = query.filter(Flashcard.deck_id.in_(own_deck_ids))
if deck_id:
query = query.filter(Flashcard.deck_id == deck_id)
if q and q.strip():
phrase = q.strip()
query = query.filter(
or_(
Flashcard.front.ilike(f"%{phrase}%"),
Flashcard.back.ilike(f"%{phrase}%"),
)
)
# Tag filter: cards must have ALL specified tags
if tag_ids:
tag_id_list = [int(t.strip()) for t in tag_ids.split(",") if t.strip().isdigit()]
if tag_id_list:
matching_ids = list(db.execute(sa_text("""
SELECT flashcard_id FROM flashcard_tag_links
WHERE tag_id = ANY(:tag_ids)
GROUP BY flashcard_id
HAVING COUNT(DISTINCT tag_id) = :cnt
"""), {"tag_ids": tag_id_list, "cnt": len(tag_id_list)}).scalars())
if matching_ids:
query = query.filter(Flashcard.id.in_(matching_ids))
else:
return {"total": 0, "cards": []}
total = query.count()
cards = query.order_by(Flashcard.deck_id, Flashcard.id).offset(offset).limit(limit).all()
deck_cache: dict[int, str] = {}
result = []
for card in cards:
if card.deck_id not in deck_cache:
deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == card.deck_id).first()
deck_cache[card.deck_id] = deck.title if deck else f"Deck {card.deck_id}"
result.append({
"id": card.id,
"deck_id": card.deck_id,
"deck_title": deck_cache[card.deck_id],
"front": card.front,
"back": card.back,
"page_reference": card.page_reference,
"image_path": card.image_path,
"created_at": card.created_at,
})
return {"total": total, "cards": result}
@router.get("/cards/browse/ids")
def browse_flashcard_ids(
q: str | None = Query(None),
deck_id: int | None = Query(None),
tag_ids: str | None = Query(None, description="Comma-separated tag IDs (AND filter)"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return just IDs for matching cards (for select-all)."""
query = db.query(Flashcard.id)
if not current_user.is_moderator:
own_deck_ids = [d.id for d in db.query(FlashcardDeck.id).filter(
FlashcardDeck.user_id == current_user.id
).all()]
if not own_deck_ids:
return []
query = query.filter(Flashcard.deck_id.in_(own_deck_ids))
if deck_id:
query = query.filter(Flashcard.deck_id == deck_id)
if q and q.strip():
phrase = q.strip()
query = query.filter(
or_(
Flashcard.front.ilike(f"%{phrase}%"),
Flashcard.back.ilike(f"%{phrase}%"),
)
)
if tag_ids:
tag_id_list = [int(t.strip()) for t in tag_ids.split(",") if t.strip().isdigit()]
if tag_id_list:
matching_ids = list(db.execute(sa_text("""
SELECT flashcard_id FROM flashcard_tag_links
WHERE tag_id = ANY(:tag_ids)
GROUP BY flashcard_id
HAVING COUNT(DISTINCT tag_id) = :cnt
"""), {"tag_ids": tag_id_list, "cnt": len(tag_id_list)}).scalars())
if matching_ids:
query = query.filter(Flashcard.id.in_(matching_ids))
else:
return []
return [row[0] for row in query.all()]
# ── Single card endpoints ────────────────────────────────────────────
@router.put("/cards/{card_id}")
def update_flashcard(
card_id: int,
data: CardEdit,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Edit a single flashcard (front, back). Requires moderator."""
card = db.query(Flashcard).filter(Flashcard.id == card_id).first()
if not card:
raise HTTPException(status_code=404, detail="Card not found")
if data.front is not None:
card.front = data.front
if data.back is not None:
card.back = data.back
db.commit()
db.refresh(card)
return {
"id": card.id,
"deck_id": card.deck_id,
"front": card.front,
"back": card.back,
"page_reference": card.page_reference,
"image_path": card.image_path,
}
@router.delete("/cards/{card_id}", status_code=204)
def delete_flashcard(
card_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Delete a single flashcard. Requires moderator."""
card = db.query(Flashcard).filter(Flashcard.id == card_id).first()
if not card:
raise HTTPException(status_code=404, detail="Card not found")
deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == card.deck_id).first()
db.delete(card)
if deck and deck.card_count > 0:
deck.card_count -= 1
db.commit()

View file

@ -0,0 +1,29 @@
from datetime import datetime
from pydantic import BaseModel
class FlashcardDeckCreate(BaseModel):
section_id: int
title: str
model_id: str | None = None
class FlashcardResponse(BaseModel):
id: int
front: str
back: str
page_reference: int | None = None
image_path: str | None = None
class Config:
from_attributes = True
class FlashcardDeckResponse(BaseModel):
id: int
title: str
section_id: int | None = None
user_id: int
card_count: int
created_at: datetime
class Config:
from_attributes = True
class FlashcardDeckDetail(FlashcardDeckResponse):
cards: list[FlashcardResponse] = []

View file

@ -467,3 +467,69 @@ def ai_decide_strategy(
except Exception as e:
logger.warning(f"ai_decide failed: {e}, falling back to standard")
return "standard", "Fallback to standard due to analysis error"
# ─── FLASHCARD GENERATION ────────────────────────────────────────────────────
FLASHCARD_PROMPT = """You are a pediatric medical education expert. Read the text below and create {n} high-quality flashcards for studying.
Each flashcard has a FRONT (question, term, or concept prompt) and a BACK (answer, definition, or explanation).
Rules:
- Mix question-style fronts ("What is the most common cause of...") and term-style fronts ("Hyperbilirubinemia")
- FRONT should be concise one sentence or a few words
- BACK should be complete but not verbose 1-3 sentences with the key facts
- Focus on high-yield facts: diagnostic criteria, treatment protocols, age-specific norms, pathophysiology
- Do NOT repeat the same concept in multiple cards
- Spread cards across different parts of the text
- Each card must be directly supported by the text do not invent facts
Return ONLY valid JSON (no markdown, no preamble):
{{"cards": [
{{
"front": "<question or term>",
"back": "<answer or definition>",
"page_reference": {page_ref}
}}
]}}
Text (pages {page_info}):
{content}"""
FLASHCARDS_PER_CHUNK = 15
def generate_flashcards(content: str, page_info: str, page_ref: int | None,
model_id: str | None, api_key: str | None,
n: int = FLASHCARDS_PER_CHUNK) -> list[dict]:
"""Generate flashcards from text content using AI."""
content = ai_service._truncate_content(content)
prompt = FLASHCARD_PROMPT.format(
content=content, page_info=page_info,
page_ref=page_ref if page_ref else "null",
n=n,
)
for attempt in range(3):
try:
text = ai_service._call_model(prompt, model_id, api_key).strip()
if text.startswith("```"):
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
data = json.loads(text)
cards = data.get("cards", data) if isinstance(data, dict) else data
result = []
for c in cards:
if not c.get("front") or not c.get("back"):
continue
result.append({
"front": c["front"].strip(),
"back": c["back"].strip(),
"page_reference": c.get("page_reference"),
})
return result
except (json.JSONDecodeError, KeyError) as e:
logger.warning(f"Flashcard generation attempt {attempt + 1} failed: {e}")
continue
return []

View file

@ -497,3 +497,113 @@ def regenerate_embeddings(self, job_id: str, user_id: int):
raise
finally:
db.close()
@celery_app.task(name="generate_flashcard_deck", bind=True)
def generate_flashcard_deck(self, job_id: str, section_id: int, user_id: int,
title: str, model_id: str | None = None):
"""Generate flashcards from a document section using AI."""
r = _redis()
r.set(f"job:status:{job_id}", "running")
db = SessionLocal()
try:
from app.models.section import Section
from app.models.pdf_document import PDFDocument
from app.services import vector_service
from app.services import extraction_modes
section = db.query(Section).filter(Section.id == section_id).first()
if not section:
r.set(f"job:status:{job_id}", "failed")
_push_step(r, job_id, "error", "Section not found")
return
document = db.query(PDFDocument).filter(PDFDocument.id == section.document_id).first()
from app.services.ai_service import get_model_for_task
ai_model_id, ai_api_key = get_model_for_task(db, "flashcard")
if model_id:
ai_model_id = model_id
total_pages = section.end_page - section.start_page + 1
_push_step(r, job_id, "start", f"Generating flashcards from {total_pages} pages…")
all_cards = []
if total_pages <= CHUNK_PAGES:
content = vector_service.get_pages_text(section.document_id, section.start_page, section.end_page)
if content:
_push_step(r, job_id, "ai", f"Generating flashcards from pages {section.start_page}{section.end_page}")
cards = extraction_modes.generate_flashcards(
content, f"{section.start_page}{section.end_page}",
section.start_page, ai_model_id, ai_api_key,
)
all_cards.extend(cards)
_push_step(r, job_id, "ai", f"Generated {len(cards)} cards")
else:
n_chunks = (total_pages + CHUNK_PAGES - 1) // CHUNK_PAGES
_push_step(r, job_id, "ai", f"Large section: splitting into {n_chunks} chunks")
for chunk_idx in range(1, n_chunks + 1):
start_p = section.start_page + (chunk_idx - 1) * CHUNK_PAGES
end_p = min(start_p + CHUNK_PAGES - 1, section.end_page)
content = vector_service.get_pages_text(section.document_id, start_p, end_p)
if not content or len(content.strip()) < 100:
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: no text, skipping")
continue
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}{end_p}")
cards = extraction_modes.generate_flashcards(
content, f"{start_p}{end_p}", start_p, ai_model_id, ai_api_key,
)
all_cards.extend(cards)
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: {len(cards)} cards")
if not all_cards:
r.set(f"job:status:{job_id}", "failed")
_push_step(r, job_id, "error", "No flashcards could be generated")
return
# Refresh DB connection for save phase
from sqlalchemy import text as _text
try:
db.execute(_text("SELECT 1"))
except Exception:
db.rollback()
db.close()
db = SessionLocal()
_push_step(r, job_id, "save", f"Saving {len(all_cards)} flashcards…")
from app.models.flashcard import FlashcardDeck, Flashcard
deck = FlashcardDeck(
title=title,
section_id=section_id,
user_id=user_id,
card_count=len(all_cards),
)
db.add(deck)
db.flush()
for c in all_cards:
card = Flashcard(
deck_id=deck.id,
front=c["front"],
back=c["back"],
page_reference=c.get("page_reference"),
)
db.add(card)
db.commit()
r.set(f"job:status:{job_id}", "completed")
r.set(f"job:result:{job_id}", str(deck.id))
_push_step(r, job_id, "done", f"Created deck '{title}' with {len(all_cards)} cards")
except Exception as e:
logger.exception(f"Flashcard generation failed: {e}")
r.set(f"job:status:{job_id}", "failed")
r.set(f"job:error:{job_id}", str(e)[:500])
_push_step(r, job_id, "error", f"Failed: {str(e)[:200]}")
try:
db.rollback()
except Exception:
pass
finally:
db.close()

View file

@ -22,6 +22,8 @@ import ForgotPasswordPage from './pages/ForgotPasswordPage'
import ResetPasswordPage from './pages/ResetPasswordPage'
import NotFoundPage from './pages/NotFoundPage'
import LandingPage from './pages/LandingPage'
import FlashcardsPage from './pages/FlashcardsPage'
import FlashcardStudyPage from './pages/FlashcardStudyPage'
// Layout wrapper for authenticated app pages (Navbar + container + footer)
function AppLayout() {
@ -72,6 +74,8 @@ function AppRoutes() {
<Route path="/account" element={<AccountPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/question-bank" element={<QuestionBankPage />} />
<Route path="/flashcards" element={<FlashcardsPage />} />
<Route path="/flashcards/:deckId/study" element={<FlashcardStudyPage />} />
<Route path="/admin" element={<AdminPage />} />
</Route>
</Route>

View file

@ -92,6 +92,7 @@ export default function Navbar({ onSignIn, onRegister }) {
{ to: '/', label: 'Dashboard' },
{ to: '/quizzes', label: 'Quizzes' },
{ to: '/question-bank', label: 'Question Bank' },
{ to: '/flashcards', label: 'Flashcards' },
...(isModerator ? [{ to: '/upload', label: 'Upload PDF' }] : []),
{ to: '/settings', label: '⚙ Settings' },
] : []

View file

@ -5,7 +5,7 @@ import api from '../api/client'
import Dialog from '../components/Dialog'
import { useDialog } from '../hooks/useDialog'
const TASKS = ['extraction', 'tts', 'teach', 'keyword']
const TASKS = ['extraction', 'tts', 'teach', 'keyword', 'flashcard']
export default function AdminPage() {
const { user } = useAuth()

View file

@ -180,6 +180,29 @@ export default function DocumentDetailPage() {
}
}
const generateFlashcards = async (sectionId, sectionName) => {
setGenerating(sectionId)
setError('')
try {
const title = quizTitle || `Flashcards: ${sectionName}`
const res = await api.post('/flashcards/', {
section_id: sectionId,
title,
model_id: selectedModelId || null,
})
if (res.data.job_id) {
const stored = JSON.parse(localStorage.getItem('pedquiz_jobs') || '[]')
stored.unshift({ jobId: res.data.job_id, title, status: 'running', lastStep: 'Starting…', ts: Date.now() })
localStorage.setItem('pedquiz_jobs', JSON.stringify(stored.slice(0, 10)))
setActiveJob({ jobId: res.data.job_id, sectionName })
}
} catch (err) {
setError(err.response?.data?.detail || 'Failed to start flashcard generation. Check AI model config.')
} finally {
setGenerating(null)
}
}
const generateQuiz = async (sectionId, sectionName) => {
setGenerating(sectionId)
setError('')
@ -463,7 +486,14 @@ export default function DocumentDetailPage() {
>
{generating === section.id ? (
<><span className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }}></span> Extracting...</>
) : 'Extract & Create Quiz'}
) : 'Extract Quiz'}
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => generateFlashcards(section.id, section.name)}
disabled={generating === section.id}
>
Create Flashcards
</button>
<ConfirmButton
onConfirm={() => deleteSection(section.id)}

View file

@ -0,0 +1,188 @@
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>
)
}

View file

@ -0,0 +1,162 @@
import { useState, useEffect } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
export default function FlashcardsPage() {
const [tab, setTab] = useState('decks') // 'decks' | 'browse'
const [decks, setDecks] = useState([])
const [cards, setCards] = useState([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [filterDeckId, setFilterDeckId] = useState('')
const [offset, setOffset] = useState(0)
const [studyCard, setStudyCard] = useState(null)
const [flipped, setFlipped] = useState(false)
const { user } = useAuth()
const navigate = useNavigate()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
const LIMIT = 50
useEffect(() => {
api.get('/flashcards/').then(res => {
setDecks(res.data)
setLoading(false)
}).catch(() => setLoading(false))
}, [])
const loadCards = async (q = searchQuery, off = 0, deckId = filterDeckId) => {
setLoading(true)
try {
const params = { limit: LIMIT, offset: off }
if (q.trim()) params.q = q.trim()
if (deckId) params.deck_id = parseInt(deckId)
const res = await api.get('/flashcards/cards/browse', { params })
setCards(off === 0 ? res.data.cards : prev => [...prev, ...res.data.cards])
setTotal(res.data.total)
setOffset(off)
} catch { } finally { setLoading(false) }
}
useEffect(() => {
if (tab === 'browse') loadCards()
}, [tab])
useEffect(() => {
if (tab !== 'browse') return
const t = setTimeout(() => loadCards(searchQuery, 0, filterDeckId), 300)
return () => clearTimeout(t)
}, [searchQuery, filterDeckId])
const deleteDeck = async (deckId) => {
if (!window.confirm('Delete this deck and all its cards?')) return
try {
await api.delete(`/flashcards/${deckId}`)
setDecks(prev => prev.filter(d => d.id !== deckId))
} catch { }
}
if (loading && decks.length === 0 && cards.length === 0) return <div className="loading"><div className="spinner" /></div>
return (
<div>
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<h2 style={{ marginBottom: 0 }}>Flashcards</h2>
<div style={{ display: 'flex', gap: 4 }}>
<button className={`btn btn-sm ${tab === 'decks' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('decks')}>Decks</button>
<button className={`btn btn-sm ${tab === 'browse' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('browse')}>Browse Cards</button>
</div>
</div>
</div>
{/* Study card modal */}
{studyCard && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={e => e.target === e.currentTarget && (setStudyCard(null), setFlipped(false))}>
<div style={{ background: 'var(--card-bg)', borderRadius: 'var(--card-radius)', padding: 32, maxWidth: 500, width: '100%', textAlign: 'center', boxShadow: '0 20px 60px rgba(0,0,0,0.3)', cursor: 'pointer', minHeight: 200, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}
onClick={() => setFlipped(v => !v)}>
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginBottom: 12, textTransform: 'uppercase', fontWeight: 700 }}>
{flipped ? 'Answer' : 'Question'} tap to flip
</div>
<p style={{ fontSize: '1.1rem', lineHeight: 1.7 }}>
{flipped ? studyCard.back : studyCard.front}
</p>
<div style={{ marginTop: 20, display: 'flex', gap: 8, justifyContent: 'center' }} onClick={e => e.stopPropagation()}>
<button className="btn btn-secondary btn-sm" onClick={() => { setStudyCard(null); setFlipped(false) }}>Close</button>
</div>
</div>
</div>
)}
{tab === 'decks' && (
<>
{decks.length === 0 && !loading && (
<div className="card"><div className="empty-state">No flashcard decks yet. Create one from a document.</div></div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
{decks.map(deck => (
<div key={deck.id} className="card" style={{ padding: 20 }}>
<h3 style={{ fontSize: '1rem', marginBottom: 6 }}>{deck.title}</h3>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 12 }}>
{deck.card_count} cards · {new Date(deck.created_at).toLocaleDateString()}
</p>
<div style={{ display: 'flex', gap: 8 }}>
<Link to={`/flashcards/${deck.id}/study`} className="btn btn-primary btn-sm">Study</Link>
{isModerator && (
<button className="btn btn-danger btn-sm" onClick={() => deleteDeck(deck.id)}>Delete</button>
)}
</div>
</div>
))}
</div>
</>
)}
{tab === 'browse' && (
<>
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<div style={{ flex: 1, minWidth: 200, position: 'relative' }}>
<input type="text" placeholder="Search cards..." value={searchQuery} onChange={e => setSearchQuery(e.target.value)}
style={{ width: '100%', padding: '9px 13px', border: '1px solid var(--border)', borderRadius: 8, fontSize: '0.9rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
</div>
<select value={filterDeckId} onChange={e => setFilterDeckId(e.target.value)}
style={{ padding: '8px 12px', border: '1px solid var(--border)', borderRadius: 8, fontSize: '0.85rem', background: 'var(--input-bg)', color: 'var(--text)' }}>
<option value="">All decks</option>
{decks.map(d => <option key={d.id} value={d.id}>{d.title} ({d.card_count})</option>)}
</select>
</div>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 12 }}>{total} cards total</p>
{cards.map(card => (
<div key={card.id} style={{
background: 'var(--card-bg)', border: '1px solid var(--border)',
borderRadius: 'var(--card-radius)', padding: '14px 16px', marginBottom: 8,
cursor: 'pointer',
}} onClick={() => { setStudyCard(card); setFlipped(false) }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>{card.deck_title}</span>
</div>
<p style={{ fontSize: '0.9rem', fontWeight: 600, marginBottom: 4 }}>{card.front}</p>
<p style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>{card.back.slice(0, 120)}{card.back.length > 120 ? '...' : ''}</p>
</div>
))}
{!loading && cards.length === 0 && (
<div className="card"><div className="empty-state">No cards found.</div></div>
)}
{cards.length < total && (
<div style={{ textAlign: 'center', marginTop: 12 }}>
<button className="btn btn-secondary" onClick={() => loadCards(searchQuery, cards.length, filterDeckId)} disabled={loading}>
{loading ? 'Loading...' : `Load more (${total - cards.length} remaining)`}
</button>
</div>
)}
</>
)}
</div>
)
}