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
|
||||
.DS_Store
|
||||
*.swp
|
||||
backend/.env.save
|
||||
|
|
|
|||
|
|
@ -305,6 +305,8 @@ def setup_pgvector():
|
|||
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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ class FlashcardDeck(Base):
|
|||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
card_count = Column(Integer, default=0)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
deleted_at = Column(DateTime, nullable=True)
|
||||
cards = relationship("Flashcard", back_populates="deck", cascade="all, delete-orphan")
|
||||
user = relationship("User")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
"""Flashcard decks and cards — generate, browse, edit, delete."""
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import or_, text as sa_text
|
||||
|
|
@ -24,10 +26,11 @@ class FlashcardDeckCreate(BaseModel):
|
|||
class FlashcardDeckResponse(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
section_id: int | None
|
||||
section_id: int | None = None
|
||||
user_id: int
|
||||
card_count: int
|
||||
created_at: object
|
||||
deleted_at: object | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
@ -49,7 +52,7 @@ class FlashcardCardResponse(BaseModel):
|
|||
class FlashcardDeckDetail(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
section_id: int | None
|
||||
section_id: int | None = None
|
||||
user_id: int
|
||||
card_count: int
|
||||
created_at: object
|
||||
|
|
@ -64,6 +67,15 @@ class CardEdit(BaseModel):
|
|||
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 ───────────────────────────────────────────────────
|
||||
|
||||
@router.post("/")
|
||||
|
|
@ -78,8 +90,6 @@ def create_flashcard_deck(
|
|||
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())
|
||||
|
||||
|
|
@ -109,16 +119,29 @@ def create_flashcard_deck(
|
|||
|
||||
@router.get("/", response_model=list[FlashcardDeckResponse])
|
||||
def list_flashcard_decks(
|
||||
include_deleted: bool = Query(False),
|
||||
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)
|
||||
"""List flashcard decks belonging to current user."""
|
||||
q = db.query(FlashcardDeck).filter(FlashcardDeck.user_id == current_user.id)
|
||||
if not include_deleted:
|
||||
q = q.filter(FlashcardDeck.deleted_at.is_(None))
|
||||
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)
|
||||
def get_flashcard_deck(
|
||||
deck_id: int,
|
||||
|
|
@ -126,28 +149,40 @@ def get_flashcard_deck(
|
|||
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
|
||||
return _own_deck_or_404(deck_id, current_user, db)
|
||||
|
||||
|
||||
@router.delete("/{deck_id}", status_code=204)
|
||||
def delete_flashcard_deck(
|
||||
deck_id: int,
|
||||
permanent: bool = Query(False),
|
||||
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."""
|
||||
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)
|
||||
"""Soft-delete a deck (moves to trash). Use ?permanent=true to permanently delete."""
|
||||
deck = _own_deck_or_404(deck_id, current_user, db)
|
||||
if permanent:
|
||||
db.delete(deck)
|
||||
else:
|
||||
deck.deleted_at = datetime.utcnow()
|
||||
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 ────────────────────────────────────────────
|
||||
|
||||
@router.get("/cards/browse")
|
||||
|
|
@ -160,17 +195,16 @@ def browse_flashcards(
|
|||
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)
|
||||
"""Browse flashcards across user's own decks."""
|
||||
# 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
|
||||
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))
|
||||
query = db.query(Flashcard).filter(Flashcard.deck_id.in_(own_deck_ids))
|
||||
|
||||
if 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:
|
||||
tag_id_list = [int(t.strip()) for t in tag_ids.split(",") if t.strip().isdigit()]
|
||||
if tag_id_list:
|
||||
|
|
@ -226,33 +260,28 @@ def browse_flashcards(
|
|||
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)"),
|
||||
tag_ids: str | None = Query(None),
|
||||
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)
|
||||
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:
|
||||
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))
|
||||
query = db.query(Flashcard.id).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}%"),
|
||||
)
|
||||
)
|
||||
|
||||
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:
|
||||
|
|
@ -277,39 +306,37 @@ def update_flashcard(
|
|||
card_id: int,
|
||||
data: CardEdit,
|
||||
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()
|
||||
if not card:
|
||||
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:
|
||||
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,
|
||||
}
|
||||
return {"id": card.id, "deck_id": card.deck_id, "front": card.front, "back": card.back}
|
||||
|
||||
|
||||
@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),
|
||||
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()
|
||||
if not card:
|
||||
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")
|
||||
db.delete(card)
|
||||
if deck and deck.card_count > 0:
|
||||
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):
|
||||
"""Generate flashcards from a document section using AI."""
|
||||
r = _redis()
|
||||
r.set(f"job:status:{job_id}", "running")
|
||||
r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
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()
|
||||
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")
|
||||
return
|
||||
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")
|
||||
|
||||
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")
|
||||
return
|
||||
|
||||
|
|
@ -592,14 +592,14 @@ def generate_flashcard_deck(self, job_id: str, section_id: int, user_id: int,
|
|||
db.add(card)
|
||||
|
||||
db.commit()
|
||||
r.set(f"job:status:{job_id}", "completed")
|
||||
r.set(f"job:result:{job_id}", str(deck.id))
|
||||
r.set(f"extraction:status:{job_id}", "completed", ex=EXPIRE_SECONDS)
|
||||
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")
|
||||
|
||||
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])
|
||||
r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
|
||||
r.set(f"extraction:error:{job_id}", str(e)[:500], ex=EXPIRE_SECONDS)
|
||||
_push_step(r, job_id, "error", f"Failed: {str(e)[:200]}")
|
||||
try:
|
||||
db.rollback()
|
||||
|
|
|
|||
|
|
@ -4,27 +4,33 @@ import { useAuth } from '../context/AuthContext'
|
|||
import api from '../api/client'
|
||||
|
||||
export default function FlashcardsPage() {
|
||||
const [tab, setTab] = useState('decks') // 'decks' | 'browse'
|
||||
const [tab, setTab] = useState('decks') // 'decks' | 'browse' | 'trash'
|
||||
const [decks, setDecks] = useState([])
|
||||
const [trashedDecks, setTrashedDecks] = 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 [deletingCard, setDeletingCard] = useState(null)
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
|
||||
const LIMIT = 50
|
||||
|
||||
useEffect(() => {
|
||||
const loadDecks = () => {
|
||||
api.get('/flashcards/').then(res => {
|
||||
setDecks(res.data)
|
||||
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) => {
|
||||
setLoading(true)
|
||||
|
|
@ -35,12 +41,12 @@ export default function FlashcardsPage() {
|
|||
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()
|
||||
if (tab === 'trash') loadTrash()
|
||||
}, [tab])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -50,13 +56,36 @@ export default function FlashcardsPage() {
|
|||
}, [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 { }
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
return (
|
||||
|
|
@ -67,6 +96,7 @@ export default function FlashcardsPage() {
|
|||
<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>
|
||||
<button className={`btn btn-sm ${tab === 'trash' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('trash')}>Trash</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -90,6 +120,7 @@ export default function FlashcardsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* DECKS TAB */}
|
||||
{tab === 'decks' && (
|
||||
<>
|
||||
{decks.length === 0 && !loading && (
|
||||
|
|
@ -104,9 +135,7 @@ export default function FlashcardsPage() {
|
|||
</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>
|
||||
)}
|
||||
<button className="btn btn-danger btn-sm" onClick={() => deleteDeck(deck.id)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -114,10 +143,11 @@ export default function FlashcardsPage() {
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* BROWSE TAB */}
|
||||
{tab === 'browse' && (
|
||||
<>
|
||||
<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)}
|
||||
style={{ width: '100%', padding: '9px 13px', border: '1px solid var(--border)', borderRadius: 8, fontSize: '0.9rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
|
||||
</div>
|
||||
|
|
@ -128,19 +158,31 @@ export default function FlashcardsPage() {
|
|||
</select>
|
||||
</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 => (
|
||||
<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>
|
||||
display: 'flex', gap: 10, alignItems: 'flex-start',
|
||||
}}>
|
||||
<div style={{ flex: 1, cursor: 'pointer' }} onClick={() => { setStudyCard(card); setFlipped(false) }}>
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue