diff --git a/backend/app/services/question_figures.py b/backend/app/services/question_figures.py index d54950d..18a2e18 100644 --- a/backend/app/services/question_figures.py +++ b/backend/app/services/question_figures.py @@ -42,6 +42,28 @@ def figure_json(link: QuestionMedia, asset: MediaAsset) -> dict: "caption": link.caption or None, "path": getattr(asset, "path", None), "position": link.position, + # What the library knows, for an explanation figure only. + # + # Once the answer is in, the catalogue entry is worth having: the + # title, the description written when the image was filed, where it + # came from, and any marks an educator drew on it. Before the answer + # it is a giveaway, which is why the caption fell out of the figure + # itself — so it rides only on the rows that are already withheld + # until answers are revealed. Nothing here is sent with a stem. + "library": _library_json(asset) if link.role == "explanation" else None, + } + + +def _library_json(asset: MediaAsset) -> dict | None: + if asset is None: + return None + return { + "title": asset.title or None, + "caption": asset.caption or None, + "alt_text": asset.alt_text or None, + "source": asset.source or None, + "source_url": asset.source_url or None, + "overlay": asset.overlay or None, } diff --git a/backend/tests/test_question_figures.py b/backend/tests/test_question_figures.py index a57ef87..2f3bc61 100644 --- a/backend/tests/test_question_figures.py +++ b/backend/tests/test_question_figures.py @@ -39,6 +39,24 @@ class FigureCaptionTests(unittest.TestCase): self.assertNotIn("title", figure) self.assertEqual(figure["path"], "uploads/hip.png") + def test_the_library_record_rides_only_on_an_explanation_figure(self): + """Once the answer is in, the catalogue entry is worth having. + + The server already withholds explanation figures until answers are + revealed, so hanging the library record on those rows — and only those + — puts it in front of a reader who has finished the question and + nowhere near one who has not. + """ + self.asset.source = "Teaching file" + self.asset.source_url = "https://example.org/x" + stem = QuestionMedia(id=3, question_id=9, media_id=5, role="stem", position=0) + back = QuestionMedia(id=4, question_id=9, media_id=5, role="explanation", position=0) + self.assertIsNone(figure_json(stem, self.asset)["library"]) + shelf = figure_json(back, self.asset)["library"] + self.assertEqual(shelf["title"], "Right SCFE, AP pelvis") + self.assertIn("right hip joint", shelf["caption"]) + self.assertEqual(shelf["source"], "Teaching file") + def test_what_the_question_itself_says_is_shown(self): link = QuestionMedia(id=2, question_id=9, media_id=5, role="stem", label="Figure 1", caption="AP pelvis at presentation.", diff --git a/frontend/src/components/FigureStrip.css b/frontend/src/components/FigureStrip.css index e162f08..8cb560d 100644 --- a/frontend/src/components/FigureStrip.css +++ b/frontend/src/components/FigureStrip.css @@ -48,6 +48,29 @@ } .fs-overlay img { display: block; max-width: 100%; max-height: 70vh; margin: 0 auto; border-radius: 8px; } .fs-overlay-cap { margin: 10px 0 0; font-size: 0.85rem; line-height: 1.6; color: var(--text-muted); } + +/* The image and anything drawn on it, in one box: the overlay is positioned + against this, so it has to be the size of the picture rather than the + panel around it. */ +.fs-overlay-frame { position: relative; display: block; width: fit-content; margin: 0 auto; } +.fs-overlay-mark { + margin-left: auto; padding: 4px 10px; font-size: .78rem; font-weight: 600; + border: 1px solid var(--border); border-radius: 999px; + background: none; color: var(--text-muted); cursor: pointer; +} +.fs-overlay-mark.is-on { border-color: var(--primary); color: var(--primary); } + +/* What the library holds, under what the question said. Set apart, because + they are two different voices: one was written for this question, the other + catalogues the image wherever it turns up. */ +.fs-overlay-shelf { + margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); + font-size: .82rem; line-height: 1.6; color: var(--text-muted); +} +.fs-overlay-shelf strong { display: block; color: var(--text); font-size: .88rem; } +.fs-overlay-shelf p { margin: 4px 0 0; } +.fs-overlay-source { font-size: .78rem; } +.fs-overlay-source a { color: var(--primary); } .fs-overlay-nav { display: flex; align-items: center; justify-content: center; gap: 16px; margin-top: 12px; font-size: 0.82rem; color: var(--text-muted); } .fs-overlay-nav button { min-height: 38px; padding: 8px 14px; font: inherit; font-size: 0.82rem; diff --git a/frontend/src/components/FigureStrip.jsx b/frontend/src/components/FigureStrip.jsx index 8724004..ea38536 100644 --- a/frontend/src/components/FigureStrip.jsx +++ b/frontend/src/components/FigureStrip.jsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import { uploadUrl } from '../utils/uploads' +import ImageOverlay from './ImageOverlay' import './FigureStrip.css' /** @@ -16,6 +17,12 @@ import './FigureStrip.css' */ export default function FigureStrip({ figures, attemptId, size = 'full', label }) { const [open, setOpen] = useState(null) + //: Whether the educator's marks are drawn. Off until asked for, like every + //: other overlay: marks shown before somebody has looked answer the question + //: for them. + const [marked, setMarked] = useState(false) + + useEffect(() => { setMarked(false) }, [open]) useEffect(() => { if (open === null) return undefined @@ -26,6 +33,13 @@ export default function FigureStrip({ figures, attemptId, size = 'full', label } if (!figures?.length) return null const shown = figures[open] + // What the library knows about this image. Sent on explanation figures only, + // so it appears after the answer and never beside a stem. The thumbnail is + // unaffected either way: in the explanation it shows what the *question* + // says about the figure, which is what an educator wrote for this question. + const shelf = shown?.library || null + const shapes = shelf?.overlay?.shapes + const hasShapes = Array.isArray(shapes) && shapes.length > 0 return ( <> @@ -59,11 +73,35 @@ export default function FigureStrip({ figures, attemptId, size = 'full', label } onClick={e => e.target === e.currentTarget && setOpen(null)}>
{shown.caption}
} + {/* Everything the library holds, once the answer is in: what it was + filed as, the description written for it, and where it came + from. The thumbnail keeps the question's own words. */} + {shelf && (shelf.title || shelf.caption || shelf.source) && ( +{shelf.caption}
} + {shelf.source && ( ++ Source: {shelf.source_url + ? {shelf.source} + : shelf.source} +
+ )} +