From 202979f7c0c39e935105c5c6b87f0679e35f06f9 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 00:02:02 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20a=20workbench=20=E2=80=94=20PDFs=20in,?= =?UTF-8?q?=20drafts=20read,=20questions=20out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uploading, extracting and reviewing were three places and the middle one wrote straight into the bank. They are one job, so they are one page: the documents, the batches a run produced, and the drafts themselves, with the counts of what is still to be read said before anything else. The review is an editor, because that is what the staging table is for. A draft that is not ready says why on its own row rather than on a failed attempt to accept it, and cannot be selected until it is fixed. Fixing happens in place, with nothing in the bank. Only the chosen, ready ones cross over, and that is the single moment a question id is taken — an accepted draft then says which question it became. Nextcloud is the administrator's. It was in every learner's settings as though each of them had a cloud to connect; it is an import path for whoever loads the corpus. The image work is restated in the TODO now that the ped-ai design has actually been read rather than remembered: allow-listed widths only, EXIF rotate, resize without enlarging, WebP q82, derivatives in the same bucket under a thumbs/ prefix, generated best-effort. Pillow rather than sharp — sharp is Node and this backend is Python — and no Caddy caching, because these are behind auth and ped-ai serves its own private, no-store for that reason. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- docs/TODO.md | 46 ++++-- frontend/src/App.jsx | 4 + frontend/src/components/DraftBatch.css | 59 +++++++ frontend/src/components/DraftBatch.jsx | 204 +++++++++++++++++++++++++ frontend/src/pages/SettingsPage.jsx | 11 +- frontend/src/pages/ToolsPage.css | 54 +++++++ frontend/src/pages/ToolsPage.jsx | 166 ++++++++++++++++++++ frontend/src/pages/ToolsPage.test.jsx | 107 +++++++++++++ 8 files changed, 635 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/DraftBatch.css create mode 100644 frontend/src/components/DraftBatch.jsx create mode 100644 frontend/src/pages/ToolsPage.css create mode 100644 frontend/src/pages/ToolsPage.jsx create mode 100644 frontend/src/pages/ToolsPage.test.jsx 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
+ + const chosen = [...picked] + const chosenReady = chosen.filter(id => { + const draft = batch.drafts.find(d => d.id === id) + return draft && draft.problems.length === 0 && draft.status === 'pending' + }) + + return ( +
+ {error &&

{error}

} + +
+ + + + {/* The only moment a question id is taken. */} + + +
+ +
    + {batch.drafts.map(draft => ( +
  • +
    + {draft.status === 'pending' ? ( + 0} + aria-label={`Select draft ${draft.position + 1}`} + onChange={() => toggle(draft.id)} /> + ) : ( + + {draft.status === 'accepted' ? '✓' : '✕'} + + )} + +
    + {editing === draft.id ? ( +
    +