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:
parent
885e8be417
commit
50ddcafd82
11 changed files with 738 additions and 23 deletions
14
docs/TODO.md
14
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
55
frontend/src/components/ImagePicker.css
Normal file
55
frontend/src/components/ImagePicker.css
Normal 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)); }
|
||||
}
|
||||
111
frontend/src/components/ImagePicker.jsx
Normal file
111
frontend/src/components/ImagePicker.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
82
frontend/src/components/ImagePicker.test.jsx
Normal file
82
frontend/src/components/ImagePicker.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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' },
|
||||
|
|
|
|||
79
frontend/src/pages/MediaPage.css
Normal file
79
frontend/src/pages/MediaPage.css
Normal 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; }
|
||||
}
|
||||
248
frontend/src/pages/MediaPage.jsx
Normal file
248
frontend/src/pages/MediaPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
107
frontend/src/pages/MediaPage.test.jsx
Normal file
107
frontend/src/pages/MediaPage.test.jsx
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue