fix: edit an image over the grid, drop the role from figure titles, no iOS zoom
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
f723a2fea4
commit
6abe46d1ea
5 changed files with 169 additions and 24 deletions
86
backend/scripts/retitle_figures.py
Normal file
86
backend/scripts/retitle_figures.py
Normal file
|
|
@ -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())
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -207,29 +207,6 @@ export default function MediaPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{editing === image.id && draft && (
|
||||
<div className="media-edit">
|
||||
<label>Title<input value={draft.title} aria-label={`Title for image ${image.id}`}
|
||||
onChange={e => setDraft(d => ({ ...d, title: e.target.value }))} /></label>
|
||||
<label>Caption<textarea rows={2} value={draft.caption} aria-label={`Caption for image ${image.id}`}
|
||||
onChange={e => setDraft(d => ({ ...d, caption: e.target.value }))} /></label>
|
||||
<label>Alt text<input value={draft.alt_text} aria-label={`Alt text for image ${image.id}`}
|
||||
onChange={e => setDraft(d => ({ ...d, alt_text: e.target.value }))} /></label>
|
||||
<label>Library<select value={draft.library_id} aria-label={`Library for image ${image.id}`}
|
||||
onChange={e => setDraft(d => ({ ...d, library_id: e.target.value }))}>
|
||||
<option value="">No library</option>
|
||||
{libraries.map(lib => <option key={lib.id} value={lib.id}>{lib.name}</option>)}
|
||||
</select></label>
|
||||
<label>Tags<input value={draft.tags} placeholder="comma, separated"
|
||||
aria-label={`Tags for image ${image.id}`}
|
||||
onChange={e => setDraft(d => ({ ...d, tags: e.target.value }))} /></label>
|
||||
<div className="media-edit-actions">
|
||||
<button className="btn btn-primary btn-sm" disabled={busy} onClick={() => save(image)}>Save</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setEditing(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmDelete === image.id && (
|
||||
<div className="media-confirm" role="alert">
|
||||
<span>Delete image #{image.id}? Questions using it will lose their picture.</span>
|
||||
|
|
@ -243,6 +220,48 @@ export default function MediaPage() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* A form the size of a card does not belong inside one: expanding in
|
||||
place stretched its column and shoved every neighbour out of line. */}
|
||||
{editing != null && draft && (() => {
|
||||
const image = images.find(row => row.id === editing)
|
||||
if (!image) return null
|
||||
return (
|
||||
<div className="media-modal" role="dialog" aria-modal="true"
|
||||
aria-label={`Edit image ${image.id}`}
|
||||
onMouseDown={e => e.target === e.currentTarget && setEditing(null)}>
|
||||
<div className="media-modal-panel">
|
||||
<div className="media-modal-head">
|
||||
<h2>Image #{image.id}</h2>
|
||||
<button type="button" onClick={() => setEditing(null)} aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div className="media-modal-body">
|
||||
<img src={uploadUrl(image.path)} alt={image.alt_text || image.title || ''} />
|
||||
<div className="media-edit">
|
||||
<label>Title<input value={draft.title} aria-label={`Title for image ${image.id}`}
|
||||
onChange={e => setDraft(d => ({ ...d, title: e.target.value }))} /></label>
|
||||
<label>Caption<textarea rows={3} value={draft.caption} aria-label={`Caption for image ${image.id}`}
|
||||
onChange={e => setDraft(d => ({ ...d, caption: e.target.value }))} /></label>
|
||||
<label>Alt text<input value={draft.alt_text} aria-label={`Alt text for image ${image.id}`}
|
||||
onChange={e => setDraft(d => ({ ...d, alt_text: e.target.value }))} /></label>
|
||||
<label>Library<select value={draft.library_id} aria-label={`Library for image ${image.id}`}
|
||||
onChange={e => setDraft(d => ({ ...d, library_id: e.target.value }))}>
|
||||
<option value="">No library</option>
|
||||
{libraries.map(lib => <option key={lib.id} value={lib.id}>{lib.name}</option>)}
|
||||
</select></label>
|
||||
<label>Tags<input value={draft.tags} placeholder="comma, separated"
|
||||
aria-label={`Tags for image ${image.id}`}
|
||||
onChange={e => setDraft(d => ({ ...d, tags: e.target.value }))} /></label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="media-modal-foot">
|
||||
<button className="btn btn-secondary" onClick={() => setEditing(null)}>Cancel</button>
|
||||
<button className="btn btn-primary" disabled={busy} onClick={() => save(image)}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ describe('image bank', () => {
|
|||
api.patch.mockResolvedValue({ data: {} })
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Edit image 11' }))
|
||||
// Over the grid, not inside one cell of it: expanding a card in place
|
||||
// stretched its column and shoved every neighbour out of line.
|
||||
expect(screen.getByRole('dialog', { name: 'Edit image 11' })).toBeInTheDocument()
|
||||
await userEvent.clear(screen.getByLabelText('Caption for image 11'))
|
||||
await userEvent.type(screen.getByLabelText('Caption for image 11'), 'Right lower lobe')
|
||||
await userEvent.clear(screen.getByLabelText('Tags for image 11'))
|
||||
|
|
|
|||
Loading…
Reference in a new issue