diff --git a/docs/TODO.md b/docs/TODO.md
index 9c1dfce..28fefbd 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -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.
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index bfe0424..11b5f2e 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -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() {
} />
} />
} />
+ } />
} />
} />
{/* Cross-references in article prose address a topic by slug, which
diff --git a/frontend/src/components/ImagePicker.css b/frontend/src/components/ImagePicker.css
new file mode 100644
index 0000000..b086a63
--- /dev/null
+++ b/frontend/src/components/ImagePicker.css
@@ -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)); }
+}
diff --git a/frontend/src/components/ImagePicker.jsx b/frontend/src/components/ImagePicker.jsx
new file mode 100644
index 0000000..4d0f485
--- /dev/null
+++ b/frontend/src/components/ImagePicker.jsx
@@ -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 (
+
{ if (e.target === e.currentTarget) onClose() }}>
+
+
+
{title}
+ ✕
+
+
+
+ setQuery(e.target.value)}
+ placeholder="Search by what the image shows" aria-label="Search images" />
+
+ fileInput.current?.click()}>Upload new
+
+
+ {error &&
{error}
}
+
+ {loading ?
+ : images.length === 0 ? (
+
{query ? `Nothing matches “${query}”.` : 'The image bank is empty.'}
+ ) : (
+
+ {images.map(image => (
+
+ onPick(image.path, image)}>
+
+
+ #{image.id}
+
+ {image.title || 'Untitled'}
+ {image.caption && {image.caption} }
+ {(image.tags || []).length > 0 && (
+ {image.tags.slice(0, 3).map(t => (
+ {t}
+ ))}
+ )}
+
+
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/components/ImagePicker.test.jsx b/frontend/src/components/ImagePicker.test.jsx
new file mode 100644
index 0000000..99f0d06
--- /dev/null
+++ b/frontend/src/components/ImagePicker.test.jsx
@@ -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( )
+ return { onPick, onClose }
+}
+
+describe('image picker', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ api.get.mockResolvedValue({ data: { images } })
+ })
+
+ it('draws nothing at all when closed', () => {
+ render( {}} 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()
+ })
+})
diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx
index 71c1195..482308c 100644
--- a/frontend/src/components/Navbar.jsx
+++ b/frontend/src/components/Navbar.jsx
@@ -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' },
diff --git a/frontend/src/pages/MediaPage.css b/frontend/src/pages/MediaPage.css
new file mode 100644
index 0000000..c3e6d3c
--- /dev/null
+++ b/frontend/src/pages/MediaPage.css
@@ -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; }
+}
diff --git a/frontend/src/pages/MediaPage.jsx b/frontend/src/pages/MediaPage.jsx
new file mode 100644
index 0000000..15c88ff
--- /dev/null
+++ b/frontend/src/pages/MediaPage.jsx
@@ -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 (
+
+
+
+
Images
+
Images are found by what they show. A caption is not decoration — it is the index.
+
+
+
+ fileInput.current?.click()}>Upload image
+
+
+
+
+
+
+
+
setQuery(e.target.value)}
+ placeholder="Search captions, alt text and titles" aria-label="Search images" />
+ {error &&
{error}
}
+ {notice &&
{notice}
}
+
+ {loading ?
+ : images.length === 0 ? (
+
+ {query ? `Nothing matches “${query}”.` : 'No images here yet.'}
+
+ ) : (
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/pages/MediaPage.test.jsx b/frontend/src/pages/MediaPage.test.jsx
new file mode 100644
index 0000000..7739537
--- /dev/null
+++ b/frontend/src/pages/MediaPage.test.jsx
@@ -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( )
+
+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')
+ })
+})
diff --git a/frontend/src/pages/QuestionEditPage.css b/frontend/src/pages/QuestionEditPage.css
index 87d0963..4b8d2f4 100644
--- a/frontend/src/pages/QuestionEditPage.css
+++ b/frontend/src/pages/QuestionEditPage.css
@@ -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; }
diff --git a/frontend/src/pages/QuestionEditPage.jsx b/frontend/src/pages/QuestionEditPage.jsx
index a063d26..8ac927c 100644
--- a/frontend/src/pages/QuestionEditPage.jsx
+++ b/frontend/src/pages/QuestionEditPage.jsx
@@ -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' }) {
Images
-
- Question image
- setField('image_path', e.target.value)} />
-
- {form.image_path && (
-
-
{ e.currentTarget.style.display = 'none' }} />
-
{form.image_path}
+ {/* 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]) => (
+
+
+ {label}
+ setField(field, e.target.value)} />
+
+ {form[field] && (
+
+
{ e.currentTarget.style.display = 'none' }} />
+
{form[field]}
+
+ )}
+
+ setPicking(field)}>
+ {form[field] ? 'Change image' : 'Choose image'}
+
+ {form[field] && (
+ setField(field, '')}>Remove
+ )}
+
- )}
-
- Explanation image
- setField('explanation_image_path', e.target.value)} />
-
-
Browse image bank
+ ))}
+
Manage the image bank
@@ -391,6 +406,11 @@ export default function QuestionEditPage({ mode = 'edit' }) {
{error && {error}
}
+
+ setPicking(null)}
+ onPick={(path) => { setField(picking, path); setPicking(null) }} />
)
}