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 && ( -
- -