pdf-quiz-generator/frontend/src/pages/FlashcardsPage.jsx
Daniel 800de50fc9 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>
2026-04-04 23:24:48 +02:00

229 lines
11 KiB
JavaScript

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' | '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 [studyCard, setStudyCard] = useState(null)
const [flipped, setFlipped] = useState(false)
const [deletingCard, setDeletingCard] = useState(null)
const { user } = useAuth()
const navigate = useNavigate()
const LIMIT = 50
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)
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)
} catch { } finally { setLoading(false) }
}
useEffect(() => {
if (tab === 'browse') loadCards()
if (tab === 'trash') loadTrash()
}, [tab])
useEffect(() => {
if (tab !== 'browse') return
const t = setTimeout(() => loadCards(searchQuery, 0, filterDeckId), 300)
return () => clearTimeout(t)
}, [searchQuery, filterDeckId])
const deleteDeck = async (deckId) => {
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 (
<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>
<button className={`btn btn-sm ${tab === 'trash' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('trash')}>Trash</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>
)}
{/* DECKS TAB */}
{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>
<button className="btn btn-danger btn-sm" onClick={() => deleteDeck(deck.id)}>Delete</button>
</div>
</div>
))}
</div>
</>
)}
{/* BROWSE TAB */}
{tab === 'browse' && (
<>
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<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>
<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</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,
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>
</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>
)}
</>
)}
{/* 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>
)
}