feat: rename Flashcards to Cards and add card-question linking UI
Orthobullets-style Cards naming, per-card link panel to attach/unlink bank questions, article linked-content counts. New FlashcardsPage test suite; 75 frontend tests pass with build.
This commit is contained in:
parent
02be9a6899
commit
d5269d7772
6 changed files with 146 additions and 13 deletions
|
|
@ -93,7 +93,7 @@ export default function Navbar({ onSignIn, onRegister }) {
|
|||
{ to: '/quizzes', label: 'Quizzes' },
|
||||
{ to: '/question-bank', label: 'Question Bank' },
|
||||
{ to: '/articles', label: 'Reading' },
|
||||
{ to: '/flashcards', label: 'Flashcards' },
|
||||
{ to: '/flashcards', label: 'Cards' },
|
||||
{ to: '/courses', label: 'Courses' },
|
||||
{ to: '/settings', label: '⚙ Settings' },
|
||||
] : []
|
||||
|
|
|
|||
|
|
@ -195,7 +195,10 @@ export function ArticlePage() {
|
|||
<span> / {article.title}</span>
|
||||
</nav>
|
||||
<div className="article-header">
|
||||
<h1>{article.title} <DraftBadge status={article.status} /></h1>
|
||||
<div>
|
||||
<h1>{article.title} <DraftBadge status={article.status} /></h1>
|
||||
<p className="articles-subtitle">{questions.length} linked questions · {cards.length} linked cards</p>
|
||||
</div>
|
||||
<div className="article-header-actions">
|
||||
{canEdit && !editing && (
|
||||
<>
|
||||
|
|
@ -307,7 +310,7 @@ export function ArticlePage() {
|
|||
)}
|
||||
{cards.length > 0 && (
|
||||
<div className="article-linked">
|
||||
<h3>Related flashcards</h3>
|
||||
<h3>Related cards</h3>
|
||||
{cards.map(card => (
|
||||
<div key={card.card_id} className="linked-card">
|
||||
<div><strong>{card.front}</strong> <span className="article-card-meta">→ {card.back}</span></div>
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ export default function DocumentDetailPage() {
|
|||
setGenerating(sectionId)
|
||||
setError('')
|
||||
try {
|
||||
const title = quizTitle || `Flashcards: ${sectionName}`
|
||||
const title = quizTitle || `Cards: ${sectionName}`
|
||||
const res = await api.post('/flashcards/', {
|
||||
section_id: sectionId,
|
||||
title,
|
||||
|
|
@ -197,7 +197,7 @@ export default function DocumentDetailPage() {
|
|||
setActiveJob({ jobId: res.data.job_id, sectionName, type: 'flashcard' })
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to start flashcard generation. Check AI model config.')
|
||||
setError(err.response?.data?.detail || 'Failed to start card generation. Check AI model config.')
|
||||
} finally {
|
||||
setGenerating(null)
|
||||
}
|
||||
|
|
@ -284,7 +284,7 @@ export default function DocumentDetailPage() {
|
|||
{activeJob && (
|
||||
<ExtractionProgress
|
||||
jobId={activeJob.jobId}
|
||||
label={activeJob.type === 'flashcard' ? 'Generating Flashcards' : 'Extracting Questions'}
|
||||
label={activeJob.type === 'flashcard' ? 'Generating Cards' : 'Extracting Questions'}
|
||||
onDone={(quizId) => { setActiveJob(null); if (activeJob.type === 'flashcard') navigate('/flashcards'); else navigate(`/quizzes/${quizId}`) }}
|
||||
onClose={() => setActiveJob(null)}
|
||||
/>
|
||||
|
|
@ -496,7 +496,7 @@ export default function DocumentDetailPage() {
|
|||
onClick={() => generateFlashcards(section.id, section.name)}
|
||||
disabled={generating === section.id}
|
||||
>
|
||||
Create Flashcards
|
||||
Create Cards
|
||||
</button>
|
||||
<ConfirmButton
|
||||
onConfirm={() => deleteSection(section.id)}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@ export default function FlashcardsPage() {
|
|||
const [newFront, setNewFront] = useState('')
|
||||
const [newBack, setNewBack] = useState('')
|
||||
const [cardError, setCardError] = useState('')
|
||||
const [linkCardId, setLinkCardId] = useState(null)
|
||||
const [linkQuestionId, setLinkQuestionId] = useState('')
|
||||
const [linkError, setLinkError] = useState('')
|
||||
const [cardLinks, setCardLinks] = useState({})
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const isAdmin = user?.role === 'admin'
|
||||
|
|
@ -160,6 +164,38 @@ export default function FlashcardsPage() {
|
|||
} catch { }
|
||||
}
|
||||
|
||||
const loadCardLinks = async (cardId) => {
|
||||
setLinkError('')
|
||||
try {
|
||||
const res = await api.get(`/flashcards/cards/${cardId}/links`)
|
||||
setCardLinks(prev => ({ ...prev, [cardId]: res.data }))
|
||||
} catch (err) { setLinkError(err.response?.data?.detail || 'Could not load links') }
|
||||
}
|
||||
|
||||
const openLinks = (card) => {
|
||||
setLinkCardId(linkCardId === card.id ? null : card.id)
|
||||
if (linkCardId !== card.id) { setCardLinks(prev => ({ ...prev, [card.id]: null })); loadCardLinks(card.id) }
|
||||
}
|
||||
|
||||
const addQuestionLink = async (cardId) => {
|
||||
setLinkError('')
|
||||
const questionId = parseInt(linkQuestionId, 10)
|
||||
if (!questionId) { setLinkError('Enter a question ID'); return }
|
||||
try {
|
||||
await api.put(`/flashcards/cards/${cardId}/links/question`, { question_id: questionId })
|
||||
setLinkQuestionId('')
|
||||
loadCardLinks(cardId)
|
||||
} catch (err) { setLinkError(typeof err.response?.data?.detail === 'string' ? err.response.data.detail : 'Could not link question') }
|
||||
}
|
||||
|
||||
const removeQuestionLink = async (cardId, questionId) => {
|
||||
setLinkError('')
|
||||
try {
|
||||
await api.delete(`/flashcards/cards/${cardId}/links/question/${questionId}`)
|
||||
loadCardLinks(cardId)
|
||||
} catch (err) { setLinkError(typeof err.response?.data?.detail === 'string' ? err.response.data.detail : 'Could not remove link') }
|
||||
}
|
||||
|
||||
const addCard = async (deckId) => {
|
||||
setCardError('')
|
||||
if (!newFront.trim() || !newBack.trim()) { setCardError('Both sides are required'); return }
|
||||
|
|
@ -176,7 +212,7 @@ export default function FlashcardsPage() {
|
|||
<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>
|
||||
<h2 style={{ marginBottom: 0 }}>Cards</h2>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button className={`btn btn-sm ${tab === 'decks' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('decks')}>My Decks</button>
|
||||
<button className={`btn btn-sm ${tab === 'shared' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('shared')}>Shared</button>
|
||||
|
|
@ -209,7 +245,7 @@ export default function FlashcardsPage() {
|
|||
{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 className="card"><div className="empty-state">No card 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 => (
|
||||
|
|
@ -332,7 +368,8 @@ export default function FlashcardsPage() {
|
|||
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 12 }}>{total} cards</p>
|
||||
|
||||
{cards.map(card => (
|
||||
<div key={card.id} style={{
|
||||
<div key={card.id}>
|
||||
<div 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',
|
||||
|
|
@ -350,11 +387,43 @@ export default function FlashcardsPage() {
|
|||
<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>
|
||||
<>
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => openLinks(card)}
|
||||
aria-expanded={linkCardId === card.id}>Links</button>
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => setDeletingCard(card.id)}
|
||||
style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>Delete</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{linkCardId === card.id && (
|
||||
<div style={{ background: 'var(--input-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '10px 14px', marginBottom: 8 }}>
|
||||
<strong style={{ fontSize: '0.85rem' }}>Linked questions</strong>
|
||||
{cardLinks[card.id] === null && <p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Loading…</p>}
|
||||
{cardLinks[card.id]?.questions?.length > 0 ? (
|
||||
<ul style={{ margin: '6px 0', paddingLeft: 18, fontSize: '0.82rem' }}>
|
||||
{cardLinks[card.id].questions.map(q => (
|
||||
<li key={q.id} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 3 }}>
|
||||
<span style={{ flex: 1 }}>{q.question_text}</span>
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => removeQuestionLink(card.id, q.id)}>Unlink</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : cardLinks[card.id] ? <p style={{ fontSize: '0.8rem', color: 'var(--text-muted)', margin: '4px 0' }}>No questions linked yet.</p> : null}
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 6 }}>
|
||||
<input className="input" style={{ maxWidth: 150 }} value={linkQuestionId} onChange={e => setLinkQuestionId(e.target.value)}
|
||||
placeholder="Question ID" aria-label="Question ID to link" />
|
||||
<button className="btn btn-sm btn-primary" onClick={() => addQuestionLink(card.id)}>Link question</button>
|
||||
</div>
|
||||
{cardLinks[card.id]?.articles?.length > 0 && (
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginTop: 6 }}>
|
||||
Linked articles: {cardLinks[card.id].articles.map(a => a.title).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{linkError && <div className="form-error" role="alert">{linkError}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!loading && cards.length === 0 && (
|
||||
|
|
|
|||
61
frontend/src/pages/FlashcardsPage.test.jsx
Normal file
61
frontend/src/pages/FlashcardsPage.test.jsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import FlashcardsPage from './FlashcardsPage'
|
||||
import api from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn() } }))
|
||||
vi.mock('../context/AuthContext', () => ({ useAuth: () => ({ user: { id: 1, name: 'Learner', role: 'user' } }) }))
|
||||
|
||||
const card = { id: 7, deck_id: 2, deck_title: 'Neonatology', front: 'Front text', back: 'Back text' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
api.get.mockImplementation(url => {
|
||||
if (url === '/flashcards/') return Promise.resolve({ data: [] })
|
||||
if (url === '/flashcards/trash') return Promise.resolve({ data: [] })
|
||||
if (url === '/flashcards/shared') return Promise.resolve({ data: { total: 0, decks: [] } })
|
||||
if (url === '/flashcards/cards/browse') return Promise.resolve({ data: { total: 1, cards: [card] } })
|
||||
if (url === '/flashcards/cards/7/links') return Promise.resolve({ data: { questions: [{ id: 12, question_text: 'Linked question text' }], articles: [] } })
|
||||
return Promise.resolve({ data: [] })
|
||||
})
|
||||
api.put.mockResolvedValue({ data: { linked: true } })
|
||||
api.delete.mockResolvedValue({ data: null })
|
||||
})
|
||||
|
||||
function renderPage() {
|
||||
return render(<MemoryRouter><FlashcardsPage /></MemoryRouter>)
|
||||
}
|
||||
|
||||
describe('cards page', () => {
|
||||
it('uses the Cards label and honest empty deck state', async () => {
|
||||
renderPage()
|
||||
expect(await screen.findByRole('heading', { name: 'Cards' })).toBeInTheDocument()
|
||||
expect(await screen.findByText('No card decks yet. Create one from a document.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('links a card to a question and unlinks it', async () => {
|
||||
renderPage()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Browse Cards' }))
|
||||
expect(await screen.findByText('Front text')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Links' }))
|
||||
expect(await screen.findByText('Linked question text')).toBeInTheDocument()
|
||||
await userEvent.type(screen.getByLabelText('Question ID to link'), '42')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Link question' }))
|
||||
await waitFor(() => expect(api.put).toHaveBeenCalledWith('/flashcards/cards/7/links/question', { question_id: 42 }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Unlink' }))
|
||||
await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/flashcards/cards/7/links/question/12'))
|
||||
})
|
||||
|
||||
it('shows an empty linked state honestly', async () => {
|
||||
const original = api.get.getMockImplementation()
|
||||
api.get.mockImplementation(url => url === '/flashcards/cards/7/links'
|
||||
? Promise.resolve({ data: { questions: [], articles: [] } })
|
||||
: original(url))
|
||||
renderPage()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Browse Cards' }))
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Links' }))
|
||||
expect(await screen.findByText('No questions linked yet.')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -288,7 +288,7 @@ function DocumentsSection() {
|
|||
<Section title="Documents">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', margin: 0 }}>
|
||||
Your uploaded PDFs for quiz and flashcard generation.
|
||||
Your uploaded PDFs for quiz and card generation.
|
||||
</p>
|
||||
<Link to="/upload" className="btn btn-primary btn-sm">Upload PDF</Link>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue