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" /> + + +
+ + {error &&

{error}

} + + {loading ?
+ : images.length === 0 ? ( +

{query ? `Nothing matches “${query}”.` : 'The image bank is empty.'}

+ ) : ( +
    + {images.map(image => ( +
  • + +
  • + ))} +
+ )} +
+
+ ) +} 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.

+
+
+ + +
+
+ +
+ + +
+ 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.'} +
+ ) : ( +
    + {images.map(image => ( +
  • +
    + {image.alt_text + {/* The id is what you type into a question, so it has to be + readable without opening anything. */} + #{image.id} +
    +
    + {image.title || 'Untitled'} + {image.caption ?

    {image.caption}

    + :

    No caption — this image will be hard to find.

    } + {(image.tags || []).length > 0 && ( +
    + {image.tags.map(tag => {tag})} +
    + )} +
    + + {user?.is_moderator && ( + + )} +
    +
    + + {editing === image.id && draft && ( +
    + +