From 6abe46d1ea85f48917883fe883b2b3167d2a3934 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 11 Sep 2026 03:28:09 +0200 Subject: [PATCH] fix: edit an image over the grid, drop the role from figure titles, no iOS zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing expanded the card in place, which stretched its column to the height of a form and shoved every neighbour out of line. It opens over the grid now, with the image beside the fields so you can see what you are describing. Figure titles read "Stem figure — Occult Fracture". The role is already obvious from where the figure sits, and the link is the `question_media` row rather than the words in the title, so the title is now just the subject. All 440 are named: 346 lost the prefix, and 94 that were still filenames took the subject of the question they came from — including detached ones, whose caption records which question they came off. No link was touched; the id is the link, and all 346 remain. iOS Safari zooms the page when a focused field's text is under 16px and does not zoom back out, which leaves a reader stuck at 1.4x with no way back. Every control is 16px on a coarse pointer — one rule, applied once, rather than remembered per component. 249 frontend tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/scripts/retitle_figures.py | 86 +++++++++++++++++++++++++++ frontend/src/index.css | 12 ++++ frontend/src/pages/MediaPage.css | 27 ++++++++- frontend/src/pages/MediaPage.jsx | 65 +++++++++++++------- frontend/src/pages/MediaPage.test.jsx | 3 + 5 files changed, 169 insertions(+), 24 deletions(-) create mode 100644 backend/scripts/retitle_figures.py diff --git a/backend/scripts/retitle_figures.py b/backend/scripts/retitle_figures.py new file mode 100644 index 0000000..d90ef5c --- /dev/null +++ b/backend/scripts/retitle_figures.py @@ -0,0 +1,86 @@ +"""Drop the role from figure titles; the badge and the link already say it. + +Titles read "Stem figure — Occult Fracture". The role is already visible from +where the figure sits on the question, and the link itself is the +`question_media` row, not the words in the title. What is useful in a bank of +440 is the subject, so that is all the title should be. + +The id stays the identifier it always was, and no link is touched. + + docker compose exec backend python -m scripts.retitle_figures + docker compose exec backend python -m scripts.retitle_figures --apply +""" +import re +import sys + +from sqlalchemy import text as sa_text + +from app.database import SessionLocal +from app.models.media import MediaAsset + +PREFIX = re.compile(r"^(stem|explanation) figure\s*—\s*", re.I) + + +def main(): + apply_changes = "--apply" in sys.argv + db = SessionLocal() + try: + # Where a figure has no subject of its own, the question's category is + # the best name available; the filename is not a name at all. + subjects = {} + for media_id, name in db.execute(sa_text(""" + SELECT qm.media_id, c.name + FROM question_media qm + JOIN questions q ON q.id = qm.question_id + LEFT JOIN question_categories c ON c.id = q.question_category_id + """)).fetchall(): + if name and media_id not in subjects: + subjects[media_id] = name + + # A detached figure has no link to take a subject from, but its caption + # records which question it came off. That is still the best name. + detached = {} + for asset in db.query(MediaAsset).all(): + match = re.search(r"question #(\d+)", asset.caption or "") + if match and asset.id not in subjects: + detached[asset.id] = int(match.group(1)) + if detached: + names = dict(db.execute(sa_text(""" + SELECT q.id, c.name FROM questions q + LEFT JOIN question_categories c ON c.id = q.question_category_id + WHERE q.id = ANY(:ids) + """), {"ids": list(detached.values())}).fetchall()) + for media_id, question_id in detached.items(): + if names.get(question_id): + subjects[media_id] = names[question_id] + + changed = [] + for asset in db.query(MediaAsset).all(): + title = asset.title or "" + if PREFIX.search(title): + new = PREFIX.sub("", title).strip() + elif re.match(r"^page_\d+_img", title): + new = subjects.get(asset.id) or title + else: + continue + if new and new != title: + changed.append((asset, title, new)) + + print(f" titles to change: {len(changed)}") + for _asset, old, new in changed[:6]: + print(f" {old:<44} -> {new}") + if not apply_changes: + print("\n Re-run with --apply.") + return 0 + + for asset, _old, new in changed: + asset.title = new[:300] + db.commit() + print(f"\n retitled: {len(changed)} (no link changed; the id is the link)") + finally: + db.close() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/frontend/src/index.css b/frontend/src/index.css index dafe150..3ebb2dd 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -829,3 +829,15 @@ body { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } + +/* iOS Safari zooms the page when a focused field's text is under 16px, and it + does not zoom back out. Every control is at least 16px on a touch pointer — + the visual size is what the design wanted, but not at the cost of trapping a + reader at 1.4x with no way back. */ +@media (pointer: coarse) { + input, select, textarea, + input[type='text'], input[type='search'], input[type='email'], + input[type='password'], input[type='number'], input[type='url'] { + font-size: 16px; + } +} diff --git a/frontend/src/pages/MediaPage.css b/frontend/src/pages/MediaPage.css index c3e6d3c..c78170e 100644 --- a/frontend/src/pages/MediaPage.css +++ b/frontend/src/pages/MediaPage.css @@ -59,7 +59,7 @@ .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 { display: flex; flex-direction: column; gap: 10px; } .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; @@ -77,3 +77,28 @@ .media-header-actions { width: 100%; } .media-header-actions .btn { flex: 1; } } + +/* Editing happens over the grid, not inside one cell of it. */ +.media-modal { + position: fixed; inset: 0; z-index: 1100; padding: 20px; + background: rgba(15, 23, 42, 0.45); + display: flex; align-items: center; justify-content: center; +} +.media-modal-panel { + background: var(--card-bg); border: 1px solid var(--border); border-radius: 14px; + width: min(760px, 100%); max-height: 88vh; display: flex; flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); overflow: hidden; +} +.media-modal-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--border); } +.media-modal-head h2 { margin: 0; font-size: 1rem; } +.media-modal-head button { background: none; border: 0; font-size: 1.05rem; color: var(--text-muted); cursor: pointer; padding: 6px 8px; } +.media-modal-body { display: grid; grid-template-columns: 240px 1fr; gap: 18px; padding: 18px; overflow-y: auto; } +.media-modal-body > img { width: 100%; border-radius: 10px; border: 1px solid var(--border); background: var(--bg); object-fit: contain; max-height: 260px; } +.media-modal-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 18px; border-top: 1px solid var(--border); } + +@media (max-width: 640px) { + .media-modal { padding: 0; align-items: flex-end; } + .media-modal-panel { max-height: 92vh; border-radius: 14px 14px 0 0; } + .media-modal-body { grid-template-columns: 1fr; } + .media-modal-foot .btn { flex: 1; } +} diff --git a/frontend/src/pages/MediaPage.jsx b/frontend/src/pages/MediaPage.jsx index 15c88ff..0b60fc9 100644 --- a/frontend/src/pages/MediaPage.jsx +++ b/frontend/src/pages/MediaPage.jsx @@ -207,29 +207,6 @@ export default function MediaPage() { - {editing === image.id && draft && ( -
- -