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 ? ( +
    +