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:
Daniel 2026-09-07 16:32:21 +02:00
parent 02be9a6899
commit d5269d7772
6 changed files with 146 additions and 13 deletions

View file

@ -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' },
] : []

View file

@ -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>

View file

@ -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)}

View file

@ -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 && (

View 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()
})
})

View file

@ -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>