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:
parent
2cbbfe00c3
commit
3e37bf2128
13 changed files with 967 additions and 4 deletions
|
|
@ -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")
|
||||
|
|
|
|||
26
backend/app/models/flashcard.py
Normal file
26
backend/app/models/flashcard.py
Normal 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")
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
316
backend/app/routers/flashcards.py
Normal file
316
backend/app/routers/flashcards.py
Normal 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()
|
||||
29
backend/app/schemas/flashcard.py
Normal file
29
backend/app/schemas/flashcard.py
Normal 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] = []
|
||||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
] : []
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
188
frontend/src/pages/FlashcardStudyPage.jsx
Normal file
188
frontend/src/pages/FlashcardStudyPage.jsx
Normal 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 }}>🎉</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>
|
||||
)
|
||||
}
|
||||
162
frontend/src/pages/FlashcardsPage.jsx
Normal file
162
frontend/src/pages/FlashcardsPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue