diff --git a/docs/TODO.md b/docs/TODO.md
index 1fd7704..4f7391f 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -236,9 +236,7 @@ Captured so nothing is lost while the article writing runs.
- [x] **Boxed quiz player** — done 2026-09-11. A box the height of the window:
the rail and the bottom bar stay put, the question scrolls in its own
column, and the footer is out of the way while a session is being sat.
-- [ ] **Share dialog** — a proper one: session title, first question as a
- preview, the link with Copy, and a few share targets. Currently a copy
- button in the more-menu.
+- [x] **Share dialog** — done 2026-09-11.
- [x] **Sharing off site-wide** — done 2026-09-11. Settings → Site policy.
Stops new links; one already handed to somebody keeps working.
- [ ] **Remove per-question share/unshare** — `Question.is_shared` and
@@ -251,17 +249,19 @@ Captured so nothing is lost while the article writing runs.
codes an administrator issues, with a note of who each is for and who it
let in. The form asks for one only when the site needs it, and never says
whether a code is valid before the account is made.
-- [ ] **Settings, properly** — the section list was a restructure, not the
- revamp asked for. Wants: what belongs there decided first, then the
- sign-up policy and invite codes, the site-wide sharing switch, and the
- admin dashboard's contents folded in rather than linked out to.
-- [ ] **Comments backend still mounted** — the UI is gone and nothing calls
- `/api/comments`. The router, model and table are still there; removing
- them destroys whatever was written, so it is a deliberate decision.
-
-## Loose ends
-
-- [ ] **Image thumbnails and caching** — you mentioned a tool from the ped-ai
+- [x] **Settings, properly** — done 2026-09-11: People and AI models rebuilt natively, AdminPage deleted.
+- [x] **Comments backend removed** — done 2026-09-11: router, model and table dropped.
+- [ ] **Image thumbnails.** The ped-ai design, read 2026-09-11: allow-listed
+ widths only (256 and 640 — any other `?w=` is refused, so the endpoint
+ cannot be turned into a resize-on-demand CPU sink), EXIF rotate, resize
+ without enlarging, WebP q82, derivatives stored in the same bucket under
+ a `thumbs/{id}/{width}` prefix so credentials, lifecycle and backup are
+ unchanged, generated best-effort so a failed preview never fails the
+ upload. **Pillow, not sharp** — sharp is Node and this backend is Python;
+ Pillow 12.3 is already installed and does the same three things. **No
+ Caddy caching**: these are behind auth, ped-ai serves its own
+ `private, no-store` for that reason, and the win is the 256px WebP.
+- [ ] ~~Image thumbnails and caching~~ — you mentioned a tool from the ped-ai
work that generates thumbnails and caches through Caddy so images load
fast, click to open full size, same bucket, and no straightforward
download of the original. Not started: I need the name of that tool or a
@@ -356,6 +356,24 @@ Analysis**, which has three tabs.
clinical tool is app.pedshub.com. AI Mode answers about the corpus a
learner is studying, and nothing here is for use at a bedside.
+## Tools, as asked 2026-09-11 (late)
+
+The whole pipeline from a PDF to a question in the bank lives on one page,
+and nothing reaches the bank until an administrator has read it.
+
+- [ ] **Move the PDFs into Tools.** Upload, the document list and extraction
+ all move off their own pages and onto the Tools page, because they are
+ one job and were three places.
+- [ ] **Nextcloud is the administrator's alone.** Nobody else connects an
+ account; it is an import path for whoever loads the corpus, not a
+ per-learner integration. It moves out of everyone's Settings.
+- [ ] **Review and promote, on the same page.** A run lands as a batch of
+ drafts — already built, `draft_batches` / `draft_questions`, with their
+ own sequence so nothing takes a question id early. What is missing is
+ the screen: read them, fix them, reject the rubbish, then select the
+ ones worth keeping and move them into the bank. Acceptance is the only
+ moment a `Question` is created.
+
## Collections, as shown 2026-09-11 (evening)
- [ ] **A collections page.** Favorites and the question libraries in one
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 4a88f9d..b5b4595 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -38,6 +38,7 @@ const MediaPage = lazyPage(() => import('./pages/MediaPage'))
const EditorialPage = lazyPage(() => import('./pages/EditorialPage'))
const AccessPage = lazyPage(() => import('./pages/AccessPage'))
const HandbookPage = lazyPage(() => import('./pages/HandbookPage'))
+const ToolsPage = lazyPage(() => import('./pages/ToolsPage'))
const StudyPlansPage = lazyPage(() => import('./pages/StudyPlansPage'))
const StudyPlanPage = lazyPage(() => import('./pages/StudyPlanPage'))
const StudyPlanBlockPage = lazyPage(() => import('./pages/StudyPlanBlockPage'))
@@ -172,6 +173,9 @@ function AppRoutes() {
{/* Moderator-only */}
}>
}>
+ {/* The whole pipeline in one place: a PDF in, drafts read, the
+ ones worth keeping moved into the bank. */}
+ } />
} />
} />
} />
diff --git a/frontend/src/components/DraftBatch.css b/frontend/src/components/DraftBatch.css
new file mode 100644
index 0000000..6fefdd4
--- /dev/null
+++ b/frontend/src/components/DraftBatch.css
@@ -0,0 +1,59 @@
+.db { border-top: 1px solid var(--border); padding: 12px 14px 16px; }
+.db-error { margin: 0 0 10px; font-size: 0.84rem; color: var(--wrong-fg); }
+
+.db-bar {
+ display: flex; align-items: center; justify-content: space-between;
+ gap: 12px; flex-wrap: wrap; margin-bottom: 10px;
+}
+.db-all { display: inline-flex; align-items: center; gap: 9px; font-size: 0.85rem; }
+.db-bar-actions { display: inline-flex; gap: 8px; flex-wrap: wrap; }
+
+.db-list { list-style: none; margin: 0; padding: 0; max-height: 60vh; overflow-y: auto; }
+.db-list > li { border-top: 1px solid var(--border); padding: 12px 0; }
+.db-list > li.is-accepted { opacity: 0.6; }
+.db-list > li.is-rejected { opacity: 0.45; }
+
+.db-row { display: flex; align-items: flex-start; gap: 11px; }
+.db-row > input[type="checkbox"] { margin-top: 3px; flex: none; }
+.db-mark { flex: none; width: 18px; text-align: center; font-weight: 700; }
+.db-mark.is-accepted { color: var(--correct-fg); }
+.db-mark.is-rejected { color: var(--wrong-fg); }
+.db-body { flex: 1; min-width: 0; }
+
+.db-stem { margin: 0; font-size: 0.88rem; line-height: 1.55; }
+.db-num { color: var(--text-subtle); margin-right: 6px; font-variant-numeric: tabular-nums; }
+.db-options { list-style: none; margin: 8px 0 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
+.db-options li {
+ font-size: 0.82rem; padding: 4px 9px; border-radius: 6px;
+ background: var(--bg); color: var(--text-muted);
+}
+.db-options li.is-right {
+ background: var(--correct-bg); color: var(--correct-fg); font-weight: 600;
+}
+.db-problems { margin: 8px 0 0; font-size: 0.8rem; color: var(--wrong-fg); }
+.db-became { margin: 8px 0 0; font-size: 0.8rem; color: var(--text-subtle); }
+
+.db-row-actions { display: inline-flex; gap: 6px; flex: none; }
+.db-row-actions button {
+ padding: 4px 10px; font: inherit; font-size: 0.76rem; font-weight: 600;
+ cursor: pointer; border-radius: 999px;
+ border: 1px solid var(--border); background: var(--card-bg); color: var(--text-muted);
+}
+.db-row-actions button:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); }
+
+/* Fixing one in place is the whole point of a staging table. */
+.db-edit { display: flex; flex-direction: column; gap: 10px; }
+.db-edit label { display: flex; flex-direction: column; gap: 4px; }
+.db-edit label span {
+ font-size: 0.66rem; font-weight: 700; letter-spacing: 0.07em;
+ text-transform: uppercase; color: var(--text-subtle);
+}
+.db-edit textarea, .db-edit input {
+ width: 100%; padding: 8px 10px;
+ /* 16px on touch: iOS zooms in on anything smaller and never zooms back. */
+ font-size: 16px; font-family: inherit; line-height: 1.5;
+ border: 1px solid var(--border); border-radius: 8px;
+ background: var(--input-bg); color: var(--text); resize: vertical;
+}
+@media (min-width: 700px) { .db-edit textarea, .db-edit input { font-size: 0.86rem; } }
+.db-edit-actions { display: flex; gap: 8px; }
diff --git a/frontend/src/components/DraftBatch.jsx b/frontend/src/components/DraftBatch.jsx
new file mode 100644
index 0000000..5e1cb88
--- /dev/null
+++ b/frontend/src/components/DraftBatch.jsx
@@ -0,0 +1,204 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import api from '../api/client'
+import './DraftBatch.css'
+
+const detail = (err, fallback) => {
+ const value = err?.response?.data?.detail
+ return typeof value === 'string' ? value : fallback
+}
+
+/**
+ * Reading a run of extracted questions, and deciding about them.
+ *
+ * The point of the staging table is that a draft can be wrong and be corrected
+ * without anything being in the bank. So this is an editor as much as a list:
+ * what is not ready says why, it can be fixed in place, and only then does
+ * anything cross over — which is the single moment a question id is taken.
+ */
+export default function DraftBatch({ batchId, onChanged }) {
+ const [batch, setBatch] = useState(null)
+ const [error, setError] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [picked, setPicked] = useState(() => new Set())
+ const [editing, setEditing] = useState(null)
+ const [draftEdit, setDraftEdit] = useState(null)
+
+ const load = useCallback(() => {
+ api.get(`/drafts/batches/${batchId}`)
+ .then(res => setBatch(res.data))
+ .catch(() => setError('Could not load that batch'))
+ }, [batchId])
+
+ useEffect(() => { load() }, [load])
+
+ const pending = useMemo(
+ () => (batch?.drafts || []).filter(d => d.status === 'pending'), [batch])
+ const ready = useMemo(() => pending.filter(d => d.problems.length === 0), [pending])
+
+ const act = async (run, failure, message) => {
+ setBusy(true); setError('')
+ try {
+ await run()
+ setPicked(new Set())
+ load()
+ onChanged?.()
+ if (message) setError('')
+ } catch (err) { setError(detail(err, failure)) }
+ finally { setBusy(false) }
+ }
+
+ const accept = (ids) => act(
+ () => api.post('/drafts/accept', { ids }),
+ 'Could not move those across')
+
+ const reject = (ids) => act(
+ () => api.post('/drafts/reject', { ids }),
+ 'Could not drop those')
+
+ const saveEdit = () => act(
+ async () => {
+ await api.patch(`/drafts/${editing}`, draftEdit)
+ setEditing(null)
+ setDraftEdit(null)
+ },
+ 'Could not save that')
+
+ const startEdit = (draft) => {
+ setEditing(draft.id)
+ setDraftEdit({
+ question_text: draft.question_text || '',
+ options: draft.options || [],
+ correct_answer: draft.correct_answer || '',
+ explanation: draft.explanation || '',
+ })
+ }
+
+ const toggle = (id) => setPicked(prev => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+
+ if (!batch) return
+ )}
+ {draft.problems.length > 0 && (
+ // Said before anyone opens it, so what needs work is
+ // visible from the list rather than on a failed attempt.
+
+ A document goes in, a model proposes questions, you read them, and the
+ ones worth keeping move into the bank. Nothing is in the bank until
+ you put it there.
+
+
+
+ {error &&
{error}
}
+
+ {pending > 0 && (
+
+ {pending} draft{pending === 1 ? '' : 's'} waiting to be read,
+ across {waiting.length} batch{waiting.length === 1 ? '' : 'es'}.
+
+ )}
+
+
+
+
Documents
+ Upload a PDF
+
+ {documents.length === 0 ? (
+
+ Nothing uploaded yet. A PDF is where a batch of questions comes from.
+
+ {/* One person loads the corpus. It was in everyone's settings as
+ though each learner had a cloud to connect, which none of them
+ has and none of them needs. */}
+ Nextcloud is an import path for whoever loads the corpus, not a
+ per-learner integration. Connect it.
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/ToolsPage.test.jsx b/frontend/src/pages/ToolsPage.test.jsx
new file mode 100644
index 0000000..359ea30
--- /dev/null
+++ b/frontend/src/pages/ToolsPage.test.jsx
@@ -0,0 +1,107 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router-dom'
+import ToolsPage from './ToolsPage'
+import api from '../api/client'
+
+vi.mock('../api/client', () => ({
+ default: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() },
+}))
+vi.mock('../context/AuthContext', () => ({
+ useAuth: () => ({ user: { id: 1, role: 'admin' } }),
+}))
+
+const BATCHES = [
+ { id: 1, title: 'Nelson ch. 12', status: 'open', created_at: '2026-09-11',
+ model_id: 'claude-opus-5', extraction_mode: 'standard',
+ counts: { pending: 2, accepted: 1 } },
+ { id: 2, title: 'Old run', status: 'closed', created_at: '2026-09-01', counts: { accepted: 5 } },
+]
+
+const BATCH = {
+ id: 1, title: 'Nelson ch. 12', status: 'open', counts: { pending: 2, accepted: 1 },
+ drafts: [
+ { id: 11, position: 0, question_text: 'A neonate with cyanosis…', options: ['TGA', 'ASD'],
+ correct_answer: 'TGA', explanation: 'Because.', status: 'pending', problems: [], question_id: null },
+ { id: 12, position: 1, question_text: 'Broken one', options: ['only'],
+ correct_answer: null, status: 'pending', problems: ['fewer than two options'], question_id: null },
+ { id: 13, position: 2, question_text: 'Already kept', options: ['a', 'b'],
+ correct_answer: 'a', status: 'accepted', problems: [], question_id: 900 },
+ ],
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ api.get.mockImplementation(url => {
+ if (url === '/drafts/batches') return Promise.resolve({ data: BATCHES })
+ if (url === '/drafts/batches/1') return Promise.resolve({ data: BATCH })
+ if (url === '/documents/') return Promise.resolve({ data: [
+ { id: 3, original_filename: 'nelson.pdf', total_pages: 400, status: 'ready', uploaded_at: '2026-09-10' },
+ ] })
+ return Promise.resolve({ data: [] })
+ })
+ api.post.mockResolvedValue({ data: { accepted: 1, questions: [] } })
+ api.patch.mockResolvedValue({ data: {} })
+})
+
+const mount = () => render()
+
+describe('the question workbench', () => {
+ it('says what is waiting to be read before anything else', async () => {
+ mount()
+ // The count is its own element, so ask about the sentence around it.
+ const waiting = await screen.findByRole('status')
+ expect(waiting).toHaveTextContent('2 drafts waiting to be read')
+ expect(waiting).toHaveTextContent('across 1 batch')
+ })
+
+ it('keeps documents and extracted batches on the one page', async () => {
+ mount()
+ expect(await screen.findByText('nelson.pdf')).toBeInTheDocument()
+ expect(screen.getByText('Nelson ch. 12')).toBeInTheDocument()
+ })
+
+ it('hides finished batches until asked', async () => {
+ mount()
+ await screen.findByText('Nelson ch. 12')
+ expect(screen.queryByText('Old run')).toBeNull()
+ await userEvent.click(screen.getByRole('button', { name: 'Show finished' }))
+ expect(screen.getByText('Old run')).toBeInTheDocument()
+ })
+
+ it('will not let a draft that is not ready be moved across', async () => {
+ mount()
+ await userEvent.click(await screen.findByRole('button', { name: /Nelson ch\. 12/ }))
+ // Said on the row rather than on a failed attempt to accept it.
+ expect(await screen.findByText(/fewer than two options/)).toBeInTheDocument()
+ expect(screen.getByRole('checkbox', { name: 'Select draft 2' })).toBeDisabled()
+ })
+
+ it('moves only the chosen, ready drafts into the bank', async () => {
+ mount()
+ await userEvent.click(await screen.findByRole('button', { name: /Nelson ch\. 12/ }))
+ await userEvent.click(await screen.findByRole('checkbox', { name: 'Select draft 1' }))
+ await userEvent.click(screen.getByRole('button', { name: /Move 1 into the bank/ }))
+ // The only moment a question id is taken.
+ await waitFor(() => expect(api.post).toHaveBeenCalledWith('/drafts/accept', { ids: [11] }))
+ })
+
+ it('says what an accepted draft became', async () => {
+ mount()
+ await userEvent.click(await screen.findByRole('button', { name: /Nelson ch\. 12/ }))
+ expect(await screen.findByText(/Now question #900/)).toBeInTheDocument()
+ })
+
+ it('fixes a draft in place rather than in the bank', async () => {
+ mount()
+ await userEvent.click(await screen.findByRole('button', { name: /Nelson ch\. 12/ }))
+ const rows = await screen.findAllByRole('button', { name: 'Edit' })
+ await userEvent.click(rows[1])
+ const answer = screen.getByLabelText(/Correct answer/)
+ await userEvent.type(answer, 'two')
+ await userEvent.click(screen.getByRole('button', { name: 'Save' }))
+ await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/drafts/12',
+ expect.objectContaining({ correct_answer: 'two' })))
+ })
+})