Flashcard ownership, trash, delete cards, fix job status
- Fix: flashcard task used wrong Redis key prefix (job: vs extraction:) causing progress to stay on "pending" after completion - Flashcards are now user-scoped: each user sees only their own decks - Soft-delete decks: DELETE moves to trash, ?permanent=true to destroy - Trash tab on flashcards page: restore or permanently delete decks - Delete individual cards with inline confirmation in browse view - Card edit/delete now allowed for deck owner (not just moderators) - ExtractionProgress label prop: shows "Generating Flashcards" not "Extracting Questions" for flashcard jobs - Added deleted_at column to flashcard_decks Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
03f53d4190
commit
800de50fc9
6 changed files with 183 additions and 85 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -21,3 +21,4 @@ dist/
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.swp
|
*.swp
|
||||||
|
backend/.env.save
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,8 @@ def setup_pgvector():
|
||||||
PRIMARY KEY (flashcard_id, tag_id)
|
PRIMARY KEY (flashcard_id, tag_id)
|
||||||
)
|
)
|
||||||
"""))
|
"""))
|
||||||
|
# Soft-delete for flashcard decks
|
||||||
|
conn.execute(text("ALTER TABLE flashcard_decks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP"))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ class FlashcardDeck(Base):
|
||||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
card_count = Column(Integer, default=0)
|
card_count = Column(Integer, default=0)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
deleted_at = Column(DateTime, nullable=True)
|
||||||
cards = relationship("Flashcard", back_populates="deck", cascade="all, delete-orphan")
|
cards = relationship("Flashcard", back_populates="deck", cascade="all, delete-orphan")
|
||||||
user = relationship("User")
|
user = relationship("User")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
"""Flashcard decks and cards — generate, browse, edit, delete."""
|
"""Flashcard decks and cards — generate, browse, edit, delete."""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import or_, text as sa_text
|
from sqlalchemy import or_, text as sa_text
|
||||||
|
|
@ -24,10 +26,11 @@ class FlashcardDeckCreate(BaseModel):
|
||||||
class FlashcardDeckResponse(BaseModel):
|
class FlashcardDeckResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
section_id: int | None
|
section_id: int | None = None
|
||||||
user_id: int
|
user_id: int
|
||||||
card_count: int
|
card_count: int
|
||||||
created_at: object
|
created_at: object
|
||||||
|
deleted_at: object | None = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
@ -49,7 +52,7 @@ class FlashcardCardResponse(BaseModel):
|
||||||
class FlashcardDeckDetail(BaseModel):
|
class FlashcardDeckDetail(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
section_id: int | None
|
section_id: int | None = None
|
||||||
user_id: int
|
user_id: int
|
||||||
card_count: int
|
card_count: int
|
||||||
created_at: object
|
created_at: object
|
||||||
|
|
@ -64,6 +67,15 @@ class CardEdit(BaseModel):
|
||||||
back: str | None = None
|
back: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _own_deck_or_404(deck_id: int, current_user: User, db: Session) -> FlashcardDeck:
|
||||||
|
deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == deck_id).first()
|
||||||
|
if not deck:
|
||||||
|
raise HTTPException(status_code=404, detail="Deck not found")
|
||||||
|
if deck.user_id != current_user.id and not current_user.is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="Not your deck")
|
||||||
|
return deck
|
||||||
|
|
||||||
|
|
||||||
# ── Deck endpoints ───────────────────────────────────────────────────
|
# ── Deck endpoints ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.post("/")
|
@router.post("/")
|
||||||
|
|
@ -78,8 +90,6 @@ def create_flashcard_deck(
|
||||||
section = db.query(Section).filter(Section.id == data.section_id).first()
|
section = db.query(Section).filter(Section.id == data.section_id).first()
|
||||||
if not section:
|
if not section:
|
||||||
raise HTTPException(status_code=404, detail="Section not found")
|
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())
|
job_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
@ -109,16 +119,29 @@ def create_flashcard_deck(
|
||||||
|
|
||||||
@router.get("/", response_model=list[FlashcardDeckResponse])
|
@router.get("/", response_model=list[FlashcardDeckResponse])
|
||||||
def list_flashcard_decks(
|
def list_flashcard_decks(
|
||||||
|
include_deleted: bool = Query(False),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""List flashcard decks. Moderators see all; regular users see own."""
|
"""List flashcard decks belonging to current user."""
|
||||||
q = db.query(FlashcardDeck)
|
q = db.query(FlashcardDeck).filter(FlashcardDeck.user_id == current_user.id)
|
||||||
if not current_user.is_moderator:
|
if not include_deleted:
|
||||||
q = q.filter(FlashcardDeck.user_id == current_user.id)
|
q = q.filter(FlashcardDeck.deleted_at.is_(None))
|
||||||
return q.order_by(FlashcardDeck.created_at.desc()).all()
|
return q.order_by(FlashcardDeck.created_at.desc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trash", response_model=list[FlashcardDeckResponse])
|
||||||
|
def list_trashed_decks(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List soft-deleted decks for current user's trash."""
|
||||||
|
return db.query(FlashcardDeck).filter(
|
||||||
|
FlashcardDeck.user_id == current_user.id,
|
||||||
|
FlashcardDeck.deleted_at.isnot(None),
|
||||||
|
).order_by(FlashcardDeck.deleted_at.desc()).all()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{deck_id}", response_model=FlashcardDeckDetail)
|
@router.get("/{deck_id}", response_model=FlashcardDeckDetail)
|
||||||
def get_flashcard_deck(
|
def get_flashcard_deck(
|
||||||
deck_id: int,
|
deck_id: int,
|
||||||
|
|
@ -126,28 +149,40 @@ def get_flashcard_deck(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Get a deck with all its cards."""
|
"""Get a deck with all its cards."""
|
||||||
deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == deck_id).first()
|
return _own_deck_or_404(deck_id, current_user, db)
|
||||||
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)
|
@router.delete("/{deck_id}", status_code=204)
|
||||||
def delete_flashcard_deck(
|
def delete_flashcard_deck(
|
||||||
deck_id: int,
|
deck_id: int,
|
||||||
|
permanent: bool = Query(False),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_moderator),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Delete a deck and all its cards."""
|
"""Soft-delete a deck (moves to trash). Use ?permanent=true to permanently delete."""
|
||||||
deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == deck_id).first()
|
deck = _own_deck_or_404(deck_id, current_user, db)
|
||||||
if not deck:
|
if permanent:
|
||||||
raise HTTPException(status_code=404, detail="Deck not found")
|
db.delete(deck)
|
||||||
db.delete(deck)
|
else:
|
||||||
|
deck.deleted_at = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{deck_id}/restore", status_code=200)
|
||||||
|
def restore_flashcard_deck(
|
||||||
|
deck_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Restore a soft-deleted deck from trash."""
|
||||||
|
deck = _own_deck_or_404(deck_id, current_user, db)
|
||||||
|
if not deck.deleted_at:
|
||||||
|
raise HTTPException(status_code=400, detail="Deck is not in trash")
|
||||||
|
deck.deleted_at = None
|
||||||
|
db.commit()
|
||||||
|
return {"id": deck.id, "title": deck.title, "restored": True}
|
||||||
|
|
||||||
|
|
||||||
# ── Card browse endpoints ────────────────────────────────────────────
|
# ── Card browse endpoints ────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/cards/browse")
|
@router.get("/cards/browse")
|
||||||
|
|
@ -160,17 +195,16 @@ def browse_flashcards(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Browse ALL flashcards across all decks (like question bank)."""
|
"""Browse flashcards across user's own decks."""
|
||||||
query = db.query(Flashcard)
|
# Get user's active (non-deleted) deck IDs
|
||||||
|
own_deck_ids = [d.id for d in db.query(FlashcardDeck.id).filter(
|
||||||
|
FlashcardDeck.user_id == current_user.id,
|
||||||
|
FlashcardDeck.deleted_at.is_(None),
|
||||||
|
).all()]
|
||||||
|
if not own_deck_ids:
|
||||||
|
return {"total": 0, "cards": []}
|
||||||
|
|
||||||
# Restrict non-moderators to own decks
|
query = db.query(Flashcard).filter(Flashcard.deck_id.in_(own_deck_ids))
|
||||||
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:
|
if deck_id:
|
||||||
query = query.filter(Flashcard.deck_id == deck_id)
|
query = query.filter(Flashcard.deck_id == deck_id)
|
||||||
|
|
@ -184,7 +218,7 @@ def browse_flashcards(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Tag filter: cards must have ALL specified tags
|
# Tag filter
|
||||||
if tag_ids:
|
if tag_ids:
|
||||||
tag_id_list = [int(t.strip()) for t in tag_ids.split(",") if t.strip().isdigit()]
|
tag_id_list = [int(t.strip()) for t in tag_ids.split(",") if t.strip().isdigit()]
|
||||||
if tag_id_list:
|
if tag_id_list:
|
||||||
|
|
@ -226,33 +260,28 @@ def browse_flashcards(
|
||||||
def browse_flashcard_ids(
|
def browse_flashcard_ids(
|
||||||
q: str | None = Query(None),
|
q: str | None = Query(None),
|
||||||
deck_id: int | None = Query(None),
|
deck_id: int | None = Query(None),
|
||||||
tag_ids: str | None = Query(None, description="Comma-separated tag IDs (AND filter)"),
|
tag_ids: str | None = Query(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Return just IDs for matching cards (for select-all)."""
|
"""Return just IDs for matching cards (for select-all)."""
|
||||||
query = db.query(Flashcard.id)
|
own_deck_ids = [d.id for d in db.query(FlashcardDeck.id).filter(
|
||||||
|
FlashcardDeck.user_id == current_user.id,
|
||||||
|
FlashcardDeck.deleted_at.is_(None),
|
||||||
|
).all()]
|
||||||
|
if not own_deck_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
if not current_user.is_moderator:
|
query = db.query(Flashcard.id).filter(Flashcard.deck_id.in_(own_deck_ids))
|
||||||
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:
|
if deck_id:
|
||||||
query = query.filter(Flashcard.deck_id == deck_id)
|
query = query.filter(Flashcard.deck_id == deck_id)
|
||||||
|
|
||||||
if q and q.strip():
|
if q and q.strip():
|
||||||
phrase = q.strip()
|
phrase = q.strip()
|
||||||
query = query.filter(
|
query = query.filter(or_(
|
||||||
or_(
|
Flashcard.front.ilike(f"%{phrase}%"),
|
||||||
Flashcard.front.ilike(f"%{phrase}%"),
|
Flashcard.back.ilike(f"%{phrase}%"),
|
||||||
Flashcard.back.ilike(f"%{phrase}%"),
|
))
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if tag_ids:
|
if tag_ids:
|
||||||
tag_id_list = [int(t.strip()) for t in tag_ids.split(",") if t.strip().isdigit()]
|
tag_id_list = [int(t.strip()) for t in tag_ids.split(",") if t.strip().isdigit()]
|
||||||
if tag_id_list:
|
if tag_id_list:
|
||||||
|
|
@ -277,39 +306,37 @@ def update_flashcard(
|
||||||
card_id: int,
|
card_id: int,
|
||||||
data: CardEdit,
|
data: CardEdit,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_moderator),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Edit a single flashcard (front, back). Requires moderator."""
|
"""Edit a single flashcard (front, back). Owner or admin."""
|
||||||
card = db.query(Flashcard).filter(Flashcard.id == card_id).first()
|
card = db.query(Flashcard).filter(Flashcard.id == card_id).first()
|
||||||
if not card:
|
if not card:
|
||||||
raise HTTPException(status_code=404, detail="Card not found")
|
raise HTTPException(status_code=404, detail="Card not found")
|
||||||
|
deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == card.deck_id).first()
|
||||||
|
if deck and deck.user_id != current_user.id and not current_user.is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="Not your card")
|
||||||
if data.front is not None:
|
if data.front is not None:
|
||||||
card.front = data.front
|
card.front = data.front
|
||||||
if data.back is not None:
|
if data.back is not None:
|
||||||
card.back = data.back
|
card.back = data.back
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(card)
|
db.refresh(card)
|
||||||
return {
|
return {"id": card.id, "deck_id": card.deck_id, "front": card.front, "back": card.back}
|
||||||
"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)
|
@router.delete("/cards/{card_id}", status_code=204)
|
||||||
def delete_flashcard(
|
def delete_flashcard(
|
||||||
card_id: int,
|
card_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_moderator),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Delete a single flashcard. Requires moderator."""
|
"""Delete a single flashcard. Owner or admin."""
|
||||||
card = db.query(Flashcard).filter(Flashcard.id == card_id).first()
|
card = db.query(Flashcard).filter(Flashcard.id == card_id).first()
|
||||||
if not card:
|
if not card:
|
||||||
raise HTTPException(status_code=404, detail="Card not found")
|
raise HTTPException(status_code=404, detail="Card not found")
|
||||||
deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == card.deck_id).first()
|
deck = db.query(FlashcardDeck).filter(FlashcardDeck.id == card.deck_id).first()
|
||||||
|
if deck and deck.user_id != current_user.id and not current_user.is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="Not your card")
|
||||||
db.delete(card)
|
db.delete(card)
|
||||||
if deck and deck.card_count > 0:
|
if deck and deck.card_count > 0:
|
||||||
deck.card_count -= 1
|
deck.card_count -= 1
|
||||||
|
|
|
||||||
|
|
@ -504,7 +504,7 @@ def generate_flashcard_deck(self, job_id: str, section_id: int, user_id: int,
|
||||||
title: str, model_id: str | None = None):
|
title: str, model_id: str | None = None):
|
||||||
"""Generate flashcards from a document section using AI."""
|
"""Generate flashcards from a document section using AI."""
|
||||||
r = _redis()
|
r = _redis()
|
||||||
r.set(f"job:status:{job_id}", "running")
|
r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
from app.models.section import Section
|
from app.models.section import Section
|
||||||
|
|
@ -514,7 +514,7 @@ def generate_flashcard_deck(self, job_id: str, section_id: int, user_id: int,
|
||||||
|
|
||||||
section = db.query(Section).filter(Section.id == section_id).first()
|
section = db.query(Section).filter(Section.id == section_id).first()
|
||||||
if not section:
|
if not section:
|
||||||
r.set(f"job:status:{job_id}", "failed")
|
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
|
||||||
_push_step(r, job_id, "error", "Section not found")
|
_push_step(r, job_id, "error", "Section not found")
|
||||||
return
|
return
|
||||||
document = db.query(PDFDocument).filter(PDFDocument.id == section.document_id).first()
|
document = db.query(PDFDocument).filter(PDFDocument.id == section.document_id).first()
|
||||||
|
|
@ -557,7 +557,7 @@ def generate_flashcard_deck(self, job_id: str, section_id: int, user_id: int,
|
||||||
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: {len(cards)} cards")
|
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: {len(cards)} cards")
|
||||||
|
|
||||||
if not all_cards:
|
if not all_cards:
|
||||||
r.set(f"job:status:{job_id}", "failed")
|
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
|
||||||
_push_step(r, job_id, "error", "No flashcards could be generated")
|
_push_step(r, job_id, "error", "No flashcards could be generated")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -592,14 +592,14 @@ def generate_flashcard_deck(self, job_id: str, section_id: int, user_id: int,
|
||||||
db.add(card)
|
db.add(card)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
r.set(f"job:status:{job_id}", "completed")
|
r.set(f"extraction:status:{job_id}", "completed", ex=EXPIRE_SECONDS)
|
||||||
r.set(f"job:result:{job_id}", str(deck.id))
|
r.set(f"extraction:deck_id:{job_id}", str(deck.id), ex=EXPIRE_SECONDS)
|
||||||
_push_step(r, job_id, "done", f"Created deck '{title}' with {len(all_cards)} cards")
|
_push_step(r, job_id, "done", f"Created deck '{title}' with {len(all_cards)} cards")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Flashcard generation failed: {e}")
|
logger.exception(f"Flashcard generation failed: {e}")
|
||||||
r.set(f"job:status:{job_id}", "failed")
|
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
|
||||||
r.set(f"job:error:{job_id}", str(e)[:500])
|
r.set(f"extraction:error:{job_id}", str(e)[:500], ex=EXPIRE_SECONDS)
|
||||||
_push_step(r, job_id, "error", f"Failed: {str(e)[:200]}")
|
_push_step(r, job_id, "error", f"Failed: {str(e)[:200]}")
|
||||||
try:
|
try:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
|
|
||||||
|
|
@ -4,27 +4,33 @@ import { useAuth } from '../context/AuthContext'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
|
|
||||||
export default function FlashcardsPage() {
|
export default function FlashcardsPage() {
|
||||||
const [tab, setTab] = useState('decks') // 'decks' | 'browse'
|
const [tab, setTab] = useState('decks') // 'decks' | 'browse' | 'trash'
|
||||||
const [decks, setDecks] = useState([])
|
const [decks, setDecks] = useState([])
|
||||||
|
const [trashedDecks, setTrashedDecks] = useState([])
|
||||||
const [cards, setCards] = useState([])
|
const [cards, setCards] = useState([])
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [filterDeckId, setFilterDeckId] = useState('')
|
const [filterDeckId, setFilterDeckId] = useState('')
|
||||||
const [offset, setOffset] = useState(0)
|
|
||||||
const [studyCard, setStudyCard] = useState(null)
|
const [studyCard, setStudyCard] = useState(null)
|
||||||
const [flipped, setFlipped] = useState(false)
|
const [flipped, setFlipped] = useState(false)
|
||||||
|
const [deletingCard, setDeletingCard] = useState(null)
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
|
||||||
const LIMIT = 50
|
const LIMIT = 50
|
||||||
|
|
||||||
useEffect(() => {
|
const loadDecks = () => {
|
||||||
api.get('/flashcards/').then(res => {
|
api.get('/flashcards/').then(res => {
|
||||||
setDecks(res.data)
|
setDecks(res.data)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}).catch(() => setLoading(false))
|
}).catch(() => setLoading(false))
|
||||||
}, [])
|
}
|
||||||
|
|
||||||
|
const loadTrash = () => {
|
||||||
|
api.get('/flashcards/trash').then(res => setTrashedDecks(res.data)).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { loadDecks() }, [])
|
||||||
|
|
||||||
const loadCards = async (q = searchQuery, off = 0, deckId = filterDeckId) => {
|
const loadCards = async (q = searchQuery, off = 0, deckId = filterDeckId) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -35,12 +41,12 @@ export default function FlashcardsPage() {
|
||||||
const res = await api.get('/flashcards/cards/browse', { params })
|
const res = await api.get('/flashcards/cards/browse', { params })
|
||||||
setCards(off === 0 ? res.data.cards : prev => [...prev, ...res.data.cards])
|
setCards(off === 0 ? res.data.cards : prev => [...prev, ...res.data.cards])
|
||||||
setTotal(res.data.total)
|
setTotal(res.data.total)
|
||||||
setOffset(off)
|
|
||||||
} catch { } finally { setLoading(false) }
|
} catch { } finally { setLoading(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tab === 'browse') loadCards()
|
if (tab === 'browse') loadCards()
|
||||||
|
if (tab === 'trash') loadTrash()
|
||||||
}, [tab])
|
}, [tab])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -50,13 +56,36 @@ export default function FlashcardsPage() {
|
||||||
}, [searchQuery, filterDeckId])
|
}, [searchQuery, filterDeckId])
|
||||||
|
|
||||||
const deleteDeck = async (deckId) => {
|
const deleteDeck = async (deckId) => {
|
||||||
if (!window.confirm('Delete this deck and all its cards?')) return
|
|
||||||
try {
|
try {
|
||||||
await api.delete(`/flashcards/${deckId}`)
|
await api.delete(`/flashcards/${deckId}`)
|
||||||
setDecks(prev => prev.filter(d => d.id !== deckId))
|
setDecks(prev => prev.filter(d => d.id !== deckId))
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const permanentDeleteDeck = async (deckId) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/flashcards/${deckId}?permanent=true`)
|
||||||
|
setTrashedDecks(prev => prev.filter(d => d.id !== deckId))
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreDeck = async (deckId) => {
|
||||||
|
try {
|
||||||
|
await api.post(`/flashcards/${deckId}/restore`)
|
||||||
|
setTrashedDecks(prev => prev.filter(d => d.id !== deckId))
|
||||||
|
loadDecks()
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteCard = async (cardId) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/flashcards/cards/${cardId}`)
|
||||||
|
setCards(prev => prev.filter(c => c.id !== cardId))
|
||||||
|
setTotal(t => t - 1)
|
||||||
|
setDeletingCard(null)
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
if (loading && decks.length === 0 && cards.length === 0) return <div className="loading"><div className="spinner" /></div>
|
if (loading && decks.length === 0 && cards.length === 0) return <div className="loading"><div className="spinner" /></div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -67,6 +96,7 @@ export default function FlashcardsPage() {
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
<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 === '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>
|
<button className={`btn btn-sm ${tab === 'browse' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('browse')}>Browse Cards</button>
|
||||||
|
<button className={`btn btn-sm ${tab === 'trash' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('trash')}>Trash</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -90,6 +120,7 @@ export default function FlashcardsPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* DECKS TAB */}
|
||||||
{tab === 'decks' && (
|
{tab === 'decks' && (
|
||||||
<>
|
<>
|
||||||
{decks.length === 0 && !loading && (
|
{decks.length === 0 && !loading && (
|
||||||
|
|
@ -104,9 +135,7 @@ export default function FlashcardsPage() {
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<Link to={`/flashcards/${deck.id}/study`} className="btn btn-primary btn-sm">Study</Link>
|
<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>
|
||||||
<button className="btn btn-danger btn-sm" onClick={() => deleteDeck(deck.id)}>Delete</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -114,10 +143,11 @@ export default function FlashcardsPage() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* BROWSE TAB */}
|
||||||
{tab === 'browse' && (
|
{tab === 'browse' && (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
<div style={{ flex: 1, minWidth: 200, position: 'relative' }}>
|
<div style={{ flex: 1, minWidth: 200 }}>
|
||||||
<input type="text" placeholder="Search cards..." value={searchQuery} onChange={e => setSearchQuery(e.target.value)}
|
<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)' }} />
|
style={{ width: '100%', padding: '9px 13px', border: '1px solid var(--border)', borderRadius: 8, fontSize: '0.9rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -128,19 +158,31 @@ export default function FlashcardsPage() {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 12 }}>{total} cards total</p>
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 12 }}>{total} cards</p>
|
||||||
|
|
||||||
{cards.map(card => (
|
{cards.map(card => (
|
||||||
<div key={card.id} style={{
|
<div key={card.id} style={{
|
||||||
background: 'var(--card-bg)', border: '1px solid var(--border)',
|
background: 'var(--card-bg)', border: '1px solid var(--border)',
|
||||||
borderRadius: 'var(--card-radius)', padding: '14px 16px', marginBottom: 8,
|
borderRadius: 'var(--card-radius)', padding: '14px 16px', marginBottom: 8,
|
||||||
cursor: 'pointer',
|
display: 'flex', gap: 10, alignItems: 'flex-start',
|
||||||
}} onClick={() => { setStudyCard(card); setFlipped(false) }}>
|
}}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
<div style={{ flex: 1, cursor: 'pointer' }} onClick={() => { setStudyCard(card); setFlipped(false) }}>
|
||||||
<span style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>{card.deck_title}</span>
|
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginBottom: 4 }}>{card.deck_title}</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>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flexShrink: 0 }}>
|
||||||
|
<button className="btn btn-sm btn-secondary" onClick={() => { setStudyCard(card); setFlipped(false) }}>View</button>
|
||||||
|
{deletingCard === card.id ? (
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
<button className="btn btn-sm btn-danger" onClick={() => deleteCard(card.id)}>Yes</button>
|
||||||
|
<button className="btn btn-sm btn-secondary" onClick={() => setDeletingCard(null)}>No</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn-sm btn-secondary" onClick={() => setDeletingCard(card.id)}
|
||||||
|
style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>Delete</button>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
@ -157,6 +199,31 @@ export default function FlashcardsPage() {
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* TRASH TAB */}
|
||||||
|
{tab === 'trash' && (
|
||||||
|
<>
|
||||||
|
{trashedDecks.length === 0 && (
|
||||||
|
<div className="card"><div className="empty-state">Trash is empty.</div></div>
|
||||||
|
)}
|
||||||
|
{trashedDecks.map(deck => (
|
||||||
|
<div key={deck.id} className="card" style={{ padding: '14px 16px', marginBottom: 8, opacity: 0.7 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
<div>
|
||||||
|
<strong>{deck.title}</strong>
|
||||||
|
<div style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>
|
||||||
|
{deck.card_count} cards · Deleted {new Date(deck.deleted_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={() => restoreDeck(deck.id)}>Restore</button>
|
||||||
|
<button className="btn btn-danger btn-sm" onClick={() => permanentDeleteDeck(deck.id)}>Delete forever</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue