- 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>
229 lines
11 KiB
JavaScript
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>
|
|
)
|
|
}
|