feat: an image bank you can actually use

The media API has existed since the image-libraries work with nothing to call
it, so images could be uploaded but never described, tagged, or found again. Two
screens close that.

/media browses the bank by library, searches by what an image shows, and edits
title, caption, alt text, tags and library in place. The id sits on every
thumbnail rather than appearing on hover, because the id is what a question
refers to and you should not have to go looking for it. An image with no caption
says so — captions are what the search index is built from, so an undescribed
image is one nobody will find, and that is worth saying on the card rather than
in documentation.

The question editor asked for a filename typed from memory, which meant keeping
a second tab open or guessing. It now opens a picker over the same bank, showing
caption and tags on every result: two chest films are identical at thumbnail
size and the caption is the only thing that tells them apart. An image uploaded
from the picker goes straight onto the question — coming here to add a picture
and then having to find it again is a step that exists for nobody's benefit. The
"Browse image bank" link pointed at /images, a route that does not exist.

Permissions follow the API rather than reimplementing it: library creation and
deletion stay with moderators, describing an image does not, and an upload goes
into the library being browsed because a non-moderator must name one.

177 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeFQJXJTfHKTfbfsdxv57Z
This commit is contained in:
Daniel 2026-09-10 12:04:06 +02:00
parent 885e8be417
commit 50ddcafd82
11 changed files with 738 additions and 23 deletions

View file

@ -36,8 +36,9 @@ Updated 2026-09-10.
- [x] **Newly created categories now appear** — the bare path 307-redirected to
http://, which the browser blocks as mixed content, so the call failed
silently. Trailing slash added.
- [ ] **Image/media page** — tags on images, shown when attaching to a question;
an image library page; ids visible on hover.
- [x] **Image/media page** — done 2026-09-10. Tags and captions show in the
picker as well as the bank, libraries have their own rail, and ids sit on
every thumbnail rather than only on hover.
## Content and editing
@ -48,8 +49,13 @@ Updated 2026-09-10.
- [ ] **Admin settings page revamp** — currently ugly; needs restructuring.
- [x] **Image libraries** — done 2026-09-10. Libraries, per-library grants, tags
on the shared vocabulary, and MinIO behind a storage service.
- [ ] **Media management page (frontend)** — the API exists; the browse/edit
screen and the picker shown when attaching an image to a question do not.
- [x] **Media management page (frontend)** — done 2026-09-10. `/media` browses
the bank by library, searches by what an image shows, and edits title,
caption, alt text, tags and library in place; ids are on every thumbnail
because that is what a question refers to, and an image with no caption is
called out as one nobody will find. `ImagePicker` replaces the
type-a-filename field on the question editor, and an image uploaded from
it lands on the question directly.
- [ ] **Question folders** — collect questions into folders for assignment and
access, alongside category grants.

View file

@ -32,6 +32,7 @@ const LandingPage = lazy(() => import('./pages/LandingPage'))
const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage'))
const ArticlesPage = lazy(() => import('./pages/ArticlesPage'))
const SearchPage = lazy(() => import('./pages/SearchPage'))
const MediaPage = lazy(() => import('./pages/MediaPage'))
const ArticlePage = lazy(() => import('./pages/ArticlesPage').then(m => ({ default: m.ArticlePage })))
const PublicQuizPage = lazy(() => import('./pages/PublicQuizPage'))
const FlashcardStudyPage = lazy(() => import('./pages/FlashcardStudyPage'))
@ -101,6 +102,7 @@ function AppRoutes() {
<Route path="/questions/manage" element={<QuestionManagerPage />} />
<Route path="/flashcards" element={<FlashcardsPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/media" element={<MediaPage />} />
<Route path="/articles" element={<ArticlesPage />} />
<Route path="/articles/:id" element={<ArticlePage />} />
{/* Cross-references in article prose address a topic by slug, which

View file

@ -0,0 +1,55 @@
/* Picking an image without leaving the question you are editing. */
.ip-overlay {
position: fixed; inset: 0; z-index: 1100;
background: rgba(15, 23, 42, 0.42);
display: flex; align-items: center; justify-content: center; padding: 20px;
}
.ip-panel {
background: var(--card-bg); border: 1px solid var(--border); border-radius: 14px;
width: min(860px, 100%); max-height: 86vh; display: flex; flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.28); overflow: hidden;
}
.ip-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--border); }
.ip-head h2 { margin: 0; font-size: 1.02rem; }
.ip-close { background: none; border: 0; font-size: 1.05rem; color: var(--text-muted); cursor: pointer; padding: 6px 8px; }
.ip-tools { display: flex; gap: 8px; padding: 12px 18px; flex-wrap: wrap; }
.ip-search {
flex: 1; min-width: 180px; min-height: 40px; padding: 9px 13px;
border: 1px solid var(--border); border-radius: 9px;
background: var(--input-bg); color: var(--text); font-size: 0.88rem;
}
.ip-error { color: var(--wrong-fg); font-size: 0.84rem; margin: 0 18px; }
.ip-empty { color: var(--text-muted); font-size: 0.88rem; padding: 24px 18px; text-align: center; }
.ip-grid {
list-style: none; margin: 0; padding: 0 18px 18px; overflow-y: auto;
display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
}
.ip-item {
display: flex; flex-direction: column; gap: 4px; width: 100%; padding: 8px;
background: none; border: 1px solid var(--border); border-radius: 10px;
font: inherit; text-align: left; cursor: pointer; color: var(--text);
}
.ip-item:hover { border-color: var(--primary); background: var(--bg); }
.ip-item:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; }
.ip-thumb { position: relative; display: flex; align-items: center; justify-content: center; background: var(--bg); border-radius: 7px; aspect-ratio: 4 / 3; overflow: hidden; }
.ip-thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
.ip-id {
position: absolute; left: 6px; bottom: 6px; padding: 1px 7px; border-radius: 9px;
background: rgba(15, 23, 42, 0.72); color: #fff; font-size: 0.66rem; font-weight: 700;
}
.ip-title { font-size: 0.83rem; font-weight: 650; overflow-wrap: anywhere; }
/* Two chest films look identical at thumbnail size; the caption is what tells
them apart, so it is not truncated to one line. */
.ip-caption { font-size: 0.75rem; line-height: 1.45; color: var(--text-muted); }
.ip-tags { display: flex; flex-wrap: wrap; gap: 3px; }
.ip-tag { font-size: 0.64rem; padding: 1px 6px; border-radius: 9px; background: var(--bg); color: var(--text-muted); border: 1px solid var(--border); }
@media (max-width: 640px) {
.ip-overlay { padding: 0; align-items: flex-end; }
.ip-panel { max-height: 92vh; border-radius: 14px 14px 0 0; }
.ip-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
}

View file

@ -0,0 +1,111 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import api from '../api/client'
import { uploadUrl } from '../utils/uploads'
import './ImagePicker.css'
/**
* Choose an image from the bank, or add one, without leaving the question.
*
* The field this replaces asked for a filename typed from memory, which meant
* either keeping a second tab open or guessing. Search here is the same hybrid
* retrieval the bank uses, so an image can be found by what it shows.
*
* Tags and captions are shown on every result: two chest films look identical at
* thumbnail size, and the caption is the only thing that says which is which.
*/
export default function ImagePicker({ open, onPick, onClose, title = 'Choose an image' }) {
const [query, setQuery] = useState('')
const [images, setImages] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const fileInput = useRef(null)
const load = useCallback(() => {
setLoading(true)
api.get('/media/', { params: query.trim() ? { q: query.trim(), limit: 40 } : { limit: 40 } })
.then(res => setImages(res.data?.images || []))
.catch(() => setError('Could not load the image bank'))
.finally(() => setLoading(false))
}, [query])
useEffect(() => {
if (!open) return
const timer = setTimeout(load, query ? 250 : 0)
return () => clearTimeout(timer)
}, [open, load, query])
useEffect(() => {
if (!open) return
const onKey = (event) => { if (event.key === 'Escape') onClose() }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [open, onClose])
const upload = async (event) => {
const file = event.target.files?.[0]
if (!file) return
setBusy(true); setError('')
try {
const body = new FormData()
body.append('file', file)
const res = await api.post('/media/upload', body)
// Straight onto the question: coming here to add an image and then having
// to find it again is a step that exists for no one's benefit.
onPick(res.data.path, res.data)
} catch (err) {
const detail = err?.response?.data?.detail
setError(typeof detail === 'string' ? detail : 'Could not upload that image')
} finally { setBusy(false); if (fileInput.current) fileInput.current.value = '' }
}
if (!open) return null
return (
<div className="ip-overlay" role="dialog" aria-modal="true" aria-label={title}
onMouseDown={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="ip-panel">
<div className="ip-head">
<h2>{title}</h2>
<button className="ip-close" onClick={onClose} aria-label="Close image picker"></button>
</div>
<div className="ip-tools">
<input className="ip-search" value={query} autoFocus
onChange={e => setQuery(e.target.value)}
placeholder="Search by what the image shows" aria-label="Search images" />
<input type="file" ref={fileInput} accept="image/*" onChange={upload} hidden aria-label="Image file" />
<button className="btn btn-secondary btn-sm" disabled={busy}
onClick={() => fileInput.current?.click()}>Upload new</button>
</div>
{error && <p className="ip-error" role="alert">{error}</p>}
{loading ? <div className="loading"><div className="spinner" /></div>
: images.length === 0 ? (
<p className="ip-empty">{query ? `Nothing matches “${query}”.` : 'The image bank is empty.'}</p>
) : (
<ul className="ip-grid">
{images.map(image => (
<li key={image.id}>
<button type="button" className="ip-item" onClick={() => onPick(image.path, image)}>
<span className="ip-thumb">
<img src={uploadUrl(image.path)} alt={image.alt_text || image.title || ''} loading="lazy" />
<span className="ip-id">#{image.id}</span>
</span>
<span className="ip-title">{image.title || 'Untitled'}</span>
{image.caption && <span className="ip-caption">{image.caption}</span>}
{(image.tags || []).length > 0 && (
<span className="ip-tags">{image.tags.slice(0, 3).map(t => (
<span key={t} className="ip-tag">{t}</span>
))}</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ImagePicker from './ImagePicker'
import api from '../api/client'
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
const images = [
{ id: 11, path: 'media/a.png', title: 'Chest film', caption: 'Lobar consolidation', tags: ['pneumonia'] },
{ id: 12, path: 'media/b.png', title: 'ECG', caption: 'Sinus tachycardia', tags: [] },
]
const mount = (props = {}) => {
const onPick = vi.fn(), onClose = vi.fn()
render(<ImagePicker open onPick={onPick} onClose={onClose} {...props} />)
return { onPick, onClose }
}
describe('image picker', () => {
beforeEach(() => {
vi.clearAllMocks()
api.get.mockResolvedValue({ data: { images } })
})
it('draws nothing at all when closed', () => {
render(<ImagePicker open={false} onPick={() => {}} onClose={() => {}} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(api.get).not.toHaveBeenCalled()
})
it('shows the caption and tags, since thumbnails of two films look alike', async () => {
mount()
expect(await screen.findByText('Chest film')).toBeInTheDocument()
expect(screen.getByText('Lobar consolidation')).toBeInTheDocument()
expect(screen.getByText('pneumonia')).toBeInTheDocument()
expect(screen.getByText('#11')).toBeInTheDocument()
})
it('returns the path of the image you pick', async () => {
const { onPick } = mount()
await screen.findByText('Chest film')
await userEvent.click(screen.getByText('Chest film').closest('button'))
expect(onPick).toHaveBeenCalledWith('media/a.png', images[0])
})
it('searches the bank by what an image shows', async () => {
mount()
await screen.findByText('Chest film')
await userEvent.type(screen.getByLabelText('Search images'), 'consolidation')
await waitFor(() => expect(api.get).toHaveBeenCalledWith('/media/',
{ params: { q: 'consolidation', limit: 40 } }))
})
it('puts a freshly uploaded image straight onto the question', async () => {
const { onPick } = mount()
await screen.findByText('Chest film')
api.post.mockResolvedValue({ data: { id: 30, path: 'media/new.png' } })
await userEvent.upload(screen.getByLabelText('Image file'),
new File(['x'], 'scan.png', { type: 'image/png' }))
// Coming here to add an image and then having to find it again is a step
// that exists for nobody's benefit.
await waitFor(() => expect(onPick).toHaveBeenCalledWith('media/new.png', { id: 30, path: 'media/new.png' }))
})
it('says why an upload was refused instead of closing silently', async () => {
const { onPick } = mount()
await screen.findByText('Chest film')
api.post.mockRejectedValue({ response: { data: { detail: 'Choose one of your libraries for this image' } } })
await userEvent.upload(screen.getByLabelText('Image file'),
new File(['x'], 'scan.png', { type: 'image/png' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Choose one of your libraries')
expect(onPick).not.toHaveBeenCalled()
})
it('closes on Escape and on a click outside the panel', async () => {
const { onClose } = mount()
await screen.findByText('Chest film')
await userEvent.keyboard('{Escape}')
expect(onClose).toHaveBeenCalled()
})
})

View file

@ -105,7 +105,8 @@ export default function Navbar({ onSignIn, onRegister }) {
{ to: '/quizzes', label: 'Quizzes' },
{ to: '/analysis', label: 'Analysis' },
{ to: '/question-bank', label: 'Question Bank' },
...(canManageQuestions ? [{ to: '/questions/manage', label: 'Manage Qs' }] : []),
...(canManageQuestions ? [{ to: '/questions/manage', label: 'Manage Qs' },
{ to: '/media', label: 'Images' }] : []),
{ to: '/articles', label: 'Reading' },
{ to: '/flashcards', label: 'Cards' },
{ to: '/courses', label: 'Courses' },

View file

@ -0,0 +1,79 @@
/* The image bank: a rail of libraries beside a grid of images. */
.media-page { max-width: 1100px; margin: 0 auto; padding-bottom: 48px; }
.media-header { display: flex; justify-content: space-between; align-items: flex-end; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
.media-header h1 { margin: 0 0 4px; font-size: 1.35rem; }
.media-header p { margin: 0; color: var(--text-muted); font-size: 0.87rem; }
.media-header-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.media-body { display: grid; grid-template-columns: 210px 1fr; gap: 20px; align-items: start; }
.media-rail { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 12px; position: sticky; top: 76px; }
.media-rail h2 { margin: 0 0 8px; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-subtle); }
.media-rail ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.media-lib {
display: flex; align-items: center; gap: 8px; width: 100%; min-height: 40px;
padding: 8px 10px; border: 0; border-radius: 7px; background: none;
font: inherit; font-size: 0.86rem; color: var(--text); text-align: left; cursor: pointer;
}
.media-lib span:first-child { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.media-lib:hover { background: var(--bg); }
.media-lib.is-active { background: var(--option-sel-bg); color: var(--primary); font-weight: 650; }
.media-lib-count { font-size: 0.74rem; color: var(--text-subtle); font-variant-numeric: tabular-nums; }
.media-lib-add { margin-top: 10px; width: 100%; }
.media-lib-form { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
.media-lib-form input {
padding: 7px 10px; border: 1px solid var(--border); border-radius: 8px;
background: var(--input-bg); color: var(--text); font-size: 0.85rem;
}
.media-search {
width: 100%; min-height: 42px; padding: 10px 14px; margin-bottom: 12px;
border: 1px solid var(--border); border-radius: 10px;
background: var(--input-bg); color: var(--text); font-size: 0.9rem;
}
.media-error { color: var(--wrong-fg); font-size: 0.85rem; margin: 0 0 10px; }
.media-notice { color: var(--primary); font-size: 0.85rem; margin: 0 0 10px; }
.media-empty { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 34px; text-align: center; color: var(--text-muted); }
.media-grid { list-style: none; margin: 0; padding: 0; display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); }
.media-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; display: flex; flex-direction: column; }
.media-card.is-editing { border-color: var(--primary); }
.media-thumb { position: relative; background: var(--bg); aspect-ratio: 4 / 3; display: flex; align-items: center; justify-content: center; overflow: hidden; }
.media-thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
/* The id is what you type into a question, so it is legible without hovering
and highlighted when you do. */
.media-id {
position: absolute; left: 8px; bottom: 8px; padding: 2px 8px; border-radius: 10px;
background: rgba(15, 23, 42, 0.72); color: #fff; font-size: 0.7rem; font-weight: 700;
font-variant-numeric: tabular-nums;
}
.media-card:hover .media-id { background: var(--primary); }
.media-meta { padding: 10px 12px 12px; display: flex; flex-direction: column; gap: 6px; flex: 1; }
.media-meta strong { font-size: 0.9rem; overflow-wrap: anywhere; }
.media-meta p { margin: 0; font-size: 0.81rem; line-height: 1.5; color: var(--text-muted); }
.media-undescribed { color: #b45309; }
.media-tags { display: flex; flex-wrap: wrap; gap: 4px; }
.media-tag { font-size: 0.68rem; padding: 1px 8px; border-radius: 10px; background: var(--bg); color: var(--text-muted); border: 1px solid var(--border); }
.media-actions { display: flex; gap: 6px; margin-top: auto; padding-top: 6px; }
.media-edit { padding: 0 12px 12px; display: flex; flex-direction: column; gap: 8px; border-top: 1px solid var(--border); padding-top: 12px; }
.media-edit label { display: flex; flex-direction: column; gap: 4px; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-subtle); }
.media-edit input, .media-edit textarea, .media-edit select {
padding: 7px 10px; border: 1px solid var(--border); border-radius: 8px;
background: var(--input-bg); color: var(--text); font: inherit; font-size: 0.85rem;
text-transform: none; letter-spacing: normal; font-weight: 400;
}
.media-edit-actions { display: flex; gap: 8px; }
.media-confirm { padding: 10px 12px; border-top: 1px solid var(--border); display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 0.82rem; color: var(--wrong-fg); }
@media (max-width: 720px) {
.media-body { grid-template-columns: 1fr; }
.media-rail { position: static; }
.media-rail ul { flex-direction: row; overflow-x: auto; gap: 6px; }
.media-lib { white-space: nowrap; }
.media-header-actions { width: 100%; }
.media-header-actions .btn { flex: 1; }
}

View file

@ -0,0 +1,248 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import api from '../api/client'
import { useAuth } from '../context/AuthContext'
import { uploadUrl } from '../utils/uploads'
import './MediaPage.css'
const apiError = (err, fallback) => {
const detail = err?.response?.data?.detail
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) return detail.map(d => d?.msg).filter(Boolean).join('; ') || fallback
return fallback
}
/**
* The image bank: browse, describe, tag, and move images between libraries.
*
* Images are found by what they show, not by filename, which is why the caption
* and alt text are the point of this screen rather than an afterthought they
* are what the search index is built from. An image nobody described is an image
* nobody will find.
*/
export default function MediaPage() {
const { user } = useAuth()
const [libraries, setLibraries] = useState([])
const [images, setImages] = useState([])
const [total, setTotal] = useState(0)
const [libraryId, setLibraryId] = useState(null)
const [query, setQuery] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [editing, setEditing] = useState(null)
const [draft, setDraft] = useState(null)
const [busy, setBusy] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(null)
const [newLibrary, setNewLibrary] = useState('')
const [showLibraryForm, setShowLibraryForm] = useState(false)
const fileInput = useRef(null)
const loadLibraries = useCallback(() => {
api.get('/media/libraries').then(res => setLibraries(res.data || [])).catch(() => setLibraries([]))
}, [])
const load = useCallback(() => {
setLoading(true)
const params = {}
if (libraryId != null) params.library_id = libraryId
if (query.trim()) params.q = query.trim()
api.get('/media/', { params })
.then(res => { setImages(res.data?.images || []); setTotal(res.data?.total || 0) })
.catch(err => setError(apiError(err, 'Could not load the image bank')))
.finally(() => setLoading(false))
}, [libraryId, query])
useEffect(() => { loadLibraries() }, [loadLibraries])
useEffect(() => {
const timer = setTimeout(load, query ? 250 : 0)
return () => clearTimeout(timer)
}, [load, query])
const startEdit = (image) => {
setConfirmDelete(null)
setEditing(image.id)
setDraft({
title: image.title || '', caption: image.caption || '', alt_text: image.alt_text || '',
library_id: image.library_id ?? '', tags: (image.tags || []).join(', '),
})
}
const save = async (image) => {
setBusy(true); setError('')
try {
await api.patch(`/media/${image.id}`, {
title: draft.title || null, caption: draft.caption || null, alt_text: draft.alt_text || null,
library_id: draft.library_id === '' ? null : Number(draft.library_id),
tags: draft.tags.split(',').map(t => t.trim()).filter(Boolean),
})
setEditing(null); setNotice('Saved.'); load()
} catch (err) { setError(apiError(err, 'Could not save this image')) }
finally { setBusy(false) }
}
const remove = async (image) => {
setBusy(true); setError('')
try {
await api.delete(`/media/${image.id}`)
setConfirmDelete(null); setEditing(null); setNotice('Deleted.'); load(); loadLibraries()
} catch (err) { setError(apiError(err, 'Could not delete this image')) }
finally { setBusy(false) }
}
const upload = async (event) => {
const file = event.target.files?.[0]
if (!file) return
setBusy(true); setError('')
try {
const body = new FormData()
body.append('file', file)
// A library is required unless you are a moderator, so send the one being
// browsed rather than uploading into nowhere.
if (libraryId != null) body.append('library_id', libraryId)
const res = await api.post('/media/upload', body)
setNotice(`Uploaded. Describe it so it can be found — image #${res.data.id}.`)
load(); loadLibraries()
startEdit(res.data)
} catch (err) { setError(apiError(err, 'Could not upload that image')) }
finally { setBusy(false); if (fileInput.current) fileInput.current.value = '' }
}
const createLibrary = async () => {
if (!newLibrary.trim()) return
setBusy(true); setError('')
try {
await api.post('/media/libraries', { name: newLibrary.trim() })
setNewLibrary(''); setShowLibraryForm(false); loadLibraries()
} catch (err) { setError(apiError(err, 'Could not create that library')) }
finally { setBusy(false) }
}
return (
<div className="media-page">
<div className="media-header">
<div>
<h1>Images</h1>
<p>Images are found by what they show. A caption is not decoration it is the index.</p>
</div>
<div className="media-header-actions">
<input type="file" ref={fileInput} accept="image/*" onChange={upload} hidden aria-label="Image file" />
<button className="btn btn-primary" disabled={busy} onClick={() => fileInput.current?.click()}>Upload image</button>
</div>
</div>
<div className="media-body">
<aside className="media-rail">
<h2>Libraries</h2>
<ul>
<li>
<button className={`media-lib${libraryId == null ? ' is-active' : ''}`}
onClick={() => setLibraryId(null)}>
<span>All images</span>
<span className="media-lib-count">{total}</span>
</button>
</li>
{libraries.map(lib => (
<li key={lib.id}>
<button className={`media-lib${libraryId === lib.id ? ' is-active' : ''}`}
onClick={() => setLibraryId(lib.id)}>
<span>{lib.name}</span>
<span className="media-lib-count">{lib.image_count}</span>
</button>
</li>
))}
</ul>
{user?.is_moderator && (showLibraryForm ? (
<div className="media-lib-form">
<input value={newLibrary} autoFocus placeholder="Library name…" aria-label="New library name"
onChange={e => setNewLibrary(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') createLibrary() }} />
<button className="btn btn-primary btn-sm" disabled={busy} onClick={createLibrary}>Create</button>
<button className="btn btn-secondary btn-sm" onClick={() => setShowLibraryForm(false)}>Cancel</button>
</div>
) : (
<button className="btn btn-secondary btn-sm media-lib-add" onClick={() => setShowLibraryForm(true)}>
+ New library
</button>
))}
</aside>
<div className="media-main">
<input className="media-search" value={query} onChange={e => setQuery(e.target.value)}
placeholder="Search captions, alt text and titles" aria-label="Search images" />
{error && <p className="media-error" role="alert">{error}</p>}
{notice && <p className="media-notice" role="status">{notice}</p>}
{loading ? <div className="loading"><div className="spinner" /></div>
: images.length === 0 ? (
<div className="media-empty">
{query ? `Nothing matches “${query}”.` : 'No images here yet.'}
</div>
) : (
<ul className="media-grid">
{images.map(image => (
<li key={image.id} className={`media-card${editing === image.id ? ' is-editing' : ''}`}>
<div className="media-thumb">
<img src={uploadUrl(image.path)} alt={image.alt_text || image.title || ''} loading="lazy" />
{/* The id is what you type into a question, so it has to be
readable without opening anything. */}
<span className="media-id">#{image.id}</span>
</div>
<div className="media-meta">
<strong>{image.title || 'Untitled'}</strong>
{image.caption ? <p>{image.caption}</p>
: <p className="media-undescribed">No caption this image will be hard to find.</p>}
{(image.tags || []).length > 0 && (
<div className="media-tags">
{image.tags.map(tag => <span key={tag} className="media-tag">{tag}</span>)}
</div>
)}
<div className="media-actions">
<button className="btn btn-secondary btn-sm"
aria-label={`Edit image ${image.id}`} onClick={() => startEdit(image)}>Edit</button>
{user?.is_moderator && (
<button className="btn btn-secondary btn-sm"
aria-label={`Delete image ${image.id}`}
onClick={() => { setEditing(null); setConfirmDelete(image.id) }}>Delete</button>
)}
</div>
</div>
{editing === image.id && draft && (
<div className="media-edit">
<label>Title<input value={draft.title} aria-label={`Title for image ${image.id}`}
onChange={e => setDraft(d => ({ ...d, title: e.target.value }))} /></label>
<label>Caption<textarea rows={2} value={draft.caption} aria-label={`Caption for image ${image.id}`}
onChange={e => setDraft(d => ({ ...d, caption: e.target.value }))} /></label>
<label>Alt text<input value={draft.alt_text} aria-label={`Alt text for image ${image.id}`}
onChange={e => setDraft(d => ({ ...d, alt_text: e.target.value }))} /></label>
<label>Library<select value={draft.library_id} aria-label={`Library for image ${image.id}`}
onChange={e => setDraft(d => ({ ...d, library_id: e.target.value }))}>
<option value="">No library</option>
{libraries.map(lib => <option key={lib.id} value={lib.id}>{lib.name}</option>)}
</select></label>
<label>Tags<input value={draft.tags} placeholder="comma, separated"
aria-label={`Tags for image ${image.id}`}
onChange={e => setDraft(d => ({ ...d, tags: e.target.value }))} /></label>
<div className="media-edit-actions">
<button className="btn btn-primary btn-sm" disabled={busy} onClick={() => save(image)}>Save</button>
<button className="btn btn-secondary btn-sm" onClick={() => setEditing(null)}>Cancel</button>
</div>
</div>
)}
{confirmDelete === image.id && (
<div className="media-confirm" role="alert">
<span>Delete image #{image.id}? Questions using it will lose their picture.</span>
<button className="btn btn-danger btn-sm" disabled={busy} onClick={() => remove(image)}>Delete</button>
<button className="btn btn-secondary btn-sm" onClick={() => setConfirmDelete(null)}>Cancel</button>
</div>
)}
</li>
))}
</ul>
)}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import MediaPage from './MediaPage'
import api from '../api/client'
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() } }))
let currentUser = { id: 1, name: 'Mod', is_moderator: true }
vi.mock('../context/AuthContext', () => ({ useAuth: () => ({ user: currentUser }) }))
const images = [
{ id: 11, path: 'media/a.png', title: 'Chest film', caption: 'Lobar consolidation', alt_text: 'CXR', library_id: 1, tags: ['pneumonia'] },
{ id: 12, path: 'media/b.png', title: null, caption: null, alt_text: null, library_id: null, tags: [] },
]
const libraries = [{ id: 1, name: 'Radiology', description: null, image_count: 1 }]
const mockApi = (rows = images) => api.get.mockImplementation(url => {
if (url === '/media/libraries') return Promise.resolve({ data: libraries })
if (url === '/media/') return Promise.resolve({ data: { total: rows.length, images: rows } })
return Promise.resolve({ data: {} })
})
const mount = () => render(<MemoryRouter><MediaPage /></MemoryRouter>)
describe('image bank', () => {
beforeEach(() => {
vi.clearAllMocks()
currentUser = { id: 1, name: 'Mod', is_moderator: true }
mockApi()
})
it('shows the id on every image, because that is what a question refers to', async () => {
mount()
expect(await screen.findByText('#11')).toBeInTheDocument()
expect(screen.getByText('#12')).toBeInTheDocument()
})
it('calls out an image nobody described, since search is built from captions', async () => {
mount()
await screen.findByText('#12')
const undescribed = screen.getByText('#12').closest('.media-card')
expect(within(undescribed).getByText(/hard to find/)).toBeInTheDocument()
const described = screen.getByText('#11').closest('.media-card')
expect(within(described).getByText('Lobar consolidation')).toBeInTheDocument()
expect(within(described).getByText('pneumonia')).toBeInTheDocument()
})
it('saves a description, a library and tags together', async () => {
mount()
await screen.findByText('#11')
api.patch.mockResolvedValue({ data: {} })
await userEvent.click(screen.getByRole('button', { name: 'Edit image 11' }))
await userEvent.clear(screen.getByLabelText('Caption for image 11'))
await userEvent.type(screen.getByLabelText('Caption for image 11'), 'Right lower lobe')
await userEvent.clear(screen.getByLabelText('Tags for image 11'))
await userEvent.type(screen.getByLabelText('Tags for image 11'), 'pneumonia, consolidation')
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/media/11', {
title: 'Chest film', caption: 'Right lower lobe', alt_text: 'CXR',
library_id: 1, tags: ['pneumonia', 'consolidation'],
}))
})
it('filters by library without asking for everything again', async () => {
mount()
await screen.findByText('#11')
await userEvent.click(screen.getByRole('button', { name: /Radiology/ }))
await waitFor(() => expect(api.get).toHaveBeenCalledWith('/media/', { params: { library_id: 1 } }))
})
it('warns what a deletion costs before doing it', async () => {
mount()
await screen.findByText('#11')
api.delete.mockResolvedValue({})
await userEvent.click(screen.getByRole('button', { name: 'Delete image 11' }))
expect(screen.getByRole('alert')).toHaveTextContent('Questions using it will lose their picture')
await userEvent.click(screen.getByRole('button', { name: 'Delete' }))
await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/media/11'))
})
it('keeps library creation and deletion to moderators', async () => {
currentUser = { id: 2, name: 'Learner', is_moderator: false }
mount()
await screen.findByText('#11')
expect(screen.queryByRole('button', { name: '+ New library' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Delete image 11' })).not.toBeInTheDocument()
// Describing an image is not a moderator job anyone with access may.
expect(screen.getByRole('button', { name: 'Edit image 11' })).toBeInTheDocument()
})
it('sends an upload into the library being browsed', async () => {
mount()
await screen.findByText('#11')
api.post.mockResolvedValue({ data: { id: 20, path: 'media/c.png', tags: [] } })
await userEvent.click(screen.getByRole('button', { name: /Radiology/ }))
const file = new File(['x'], 'scan.png', { type: 'image/png' })
await userEvent.upload(screen.getByLabelText('Image file'), file)
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/media/upload', expect.any(FormData)))
expect(api.post.mock.calls[0][1].get('library_id')).toBe('1')
expect(await screen.findByRole('status')).toHaveTextContent('image #20')
})
})

View file

@ -110,3 +110,7 @@
.qe-version-when { font-size: 0.72rem; color: var(--text-subtle); }
.qe-version-text { font-size: 0.82rem; overflow-wrap: anywhere; }
.qe-versions li .btn { align-self: flex-start; }
/* Image fields: preview, then the controls that change it. */
.qe-image-field { margin-bottom: 16px; }
.qe-image-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }

View file

@ -2,6 +2,8 @@ import { useState, useEffect, useCallback, useMemo } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../api/client'
import CategoryDrilldown from '../components/CategoryDrilldown'
import ImagePicker from '../components/ImagePicker'
import { uploadUrl } from '../utils/uploads'
import './QuestionEditPage.css'
const LETTERS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
@ -36,6 +38,8 @@ export default function QuestionEditPage({ mode = 'edit' }) {
const [copying, setCopying] = useState(false)
const [versions, setVersions] = useState([])
const [showVersions, setShowVersions] = useState(false)
// Which image field the picker is filling, or null when it is closed.
const [picking, setPicking] = useState(null)
useEffect(() => {
api.get('/question-categories/').then(res => setCategories(res.data || [])).catch(() => setCategories([]))
@ -355,25 +359,36 @@ export default function QuestionEditPage({ mode = 'edit' }) {
<section className="qe-card">
<h2>Images</h2>
<div className="qe-card-body">
<label className="qe-field">
<span>Question image</span>
<input value={form.image_path} placeholder="Image ID or filename"
aria-label="Question image" onChange={e => setField('image_path', e.target.value)} />
</label>
{form.image_path && (
<div className="qe-image">
<img src={`/uploads/${form.image_path}`} alt="Question"
title={form.image_path}
onError={e => { e.currentTarget.style.display = 'none' }} />
<span className="qe-image-meta">{form.image_path}</span>
{/* Typing a filename from memory meant keeping a second tab open;
the picker searches the bank by what an image shows. */}
{[['image_path', 'Question image'], ['explanation_image_path', 'Explanation image']].map(([field, label]) => (
<div className="qe-image-field" key={field}>
<label className="qe-field">
<span>{label}</span>
<input value={form[field]} placeholder="Image ID or filename"
aria-label={label} onChange={e => setField(field, e.target.value)} />
</label>
{form[field] && (
<div className="qe-image">
<img src={uploadUrl(form[field])} alt={label}
title={form[field]}
onError={e => { e.currentTarget.style.display = 'none' }} />
<span className="qe-image-meta">{form[field]}</span>
</div>
)}
<div className="qe-image-actions">
<button type="button" className="btn btn-secondary btn-sm"
onClick={() => setPicking(field)}>
{form[field] ? 'Change image' : 'Choose image'}
</button>
{form[field] && (
<button type="button" className="btn btn-secondary btn-sm"
onClick={() => setField(field, '')}>Remove</button>
)}
</div>
</div>
)}
<label className="qe-field" style={{ marginTop: 12 }}>
<span>Explanation image</span>
<input value={form.explanation_image_path} placeholder="Image ID or filename"
aria-label="Explanation image" onChange={e => setField('explanation_image_path', e.target.value)} />
</label>
<Link className="btn btn-secondary btn-sm" to="/images">Browse image bank</Link>
))}
<Link className="btn btn-secondary btn-sm" to="/media">Manage the image bank</Link>
</div>
</section>
</aside>
@ -391,6 +406,11 @@ export default function QuestionEditPage({ mode = 'edit' }) {
</div>
{error && <p className="qe-error" role="alert">{error}</p>}
</div>
<ImagePicker open={picking !== null}
title={picking === 'explanation_image_path' ? 'Choose an explanation image' : 'Choose a question image'}
onClose={() => setPicking(null)}
onPick={(path) => { setField(picking, path); setPicking(null) }} />
</div>
)
}