From 50ddcafd821909fbbc571954acc6519324ec78c7 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 10 Sep 2026 12:04:06 +0200 Subject: [PATCH] feat: an image bank you can actually use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01XeFQJXJTfHKTfbfsdxv57Z --- docs/TODO.md | 14 +- frontend/src/App.jsx | 2 + frontend/src/components/ImagePicker.css | 55 ++++ frontend/src/components/ImagePicker.jsx | 111 +++++++++ frontend/src/components/ImagePicker.test.jsx | 82 ++++++ frontend/src/components/Navbar.jsx | 3 +- frontend/src/pages/MediaPage.css | 79 ++++++ frontend/src/pages/MediaPage.jsx | 248 +++++++++++++++++++ frontend/src/pages/MediaPage.test.jsx | 107 ++++++++ frontend/src/pages/QuestionEditPage.css | 4 + frontend/src/pages/QuestionEditPage.jsx | 56 +++-- 11 files changed, 738 insertions(+), 23 deletions(-) create mode 100644 frontend/src/components/ImagePicker.css create mode 100644 frontend/src/components/ImagePicker.jsx create mode 100644 frontend/src/components/ImagePicker.test.jsx create mode 100644 frontend/src/pages/MediaPage.css create mode 100644 frontend/src/pages/MediaPage.jsx create mode 100644 frontend/src/pages/MediaPage.test.jsx 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 && ( +
    + +