Art page round 2: virtual frames, real logo, hi-res zoom, spacing/affordance polish

- Virtual frames (Walnut/Gold/Silver/None), selectable + remembered in localStorage,
  built as a beveled moulding around a cream museum mat.
- Header uses the real /logo.svg wordmark; the "No ads" pill is replaced by an
  account icon (the pill doesn't need to follow every page).
- Lightbox now opens a full-resolution copy that fills the screen: art._download_image
  caches a hi-res {id}-full copy alongside the web-large display copy, served via
  /api/art/image/{id}?size=full (image_url_large in /api/art/today).
- Centered the placard bullet separators (explicit .sep spans, equal margins).
- Image no longer shifts on hover; a quiet "Click to expand" affordance sits on the art.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-21 16:25:31 -04:00
parent 9bfec573e2
commit 27788ba2a8
4 changed files with 198 additions and 58 deletions
+6 -2
View File
@@ -2276,11 +2276,15 @@ def create_app() -> FastAPI:
"is_public_domain": bool(a["is_public_domain"]),
"license": "Public Domain (CC0)" if a["is_public_domain"] else None,
"image_url": f"/api/art/image/{a['object_id']}",
"image_url_large": f"/api/art/image/{a['object_id']}?size=full",
}
@app.get("/api/art/image/{object_id}")
def art_image(object_id: int) -> FileResponse:
matches = sorted(art.cache_dir().glob(f"{object_id}.*"))
def art_image(object_id: int, size: str = Query("")) -> FileResponse:
cdir = art.cache_dir()
matches = sorted(cdir.glob(f"{object_id}-full.*")) if size == "full" else []
if not matches: # fall back to the web-large copy
matches = sorted(cdir.glob(f"{object_id}.*"))
if not matches:
raise HTTPException(status_code=404, detail="Not cached.")
# Cached museum image: immutable for a given object id.
+36 -25
View File
@@ -91,34 +91,45 @@ def _object(object_id: int) -> dict:
return _http_json(f"{MET_BASE}/objects/{object_id}")
def _download_image(obj: dict, object_id: int) -> str | None:
"""Download the web-large (then full) image to our cache; return the filename or None.
def _fetch_to_cache(url: str | None, stem: str) -> str | None:
"""Download one image URL to cache as {stem}.{ext}; return the filename or None.
Writes to a temp file then atomically renames, so a reader never sees a half-file."""
for key in ("primaryImageSmall", "primaryImage"):
url = obj.get(key)
if not url:
continue
if not url:
return None
try:
data, ctype = _http_bytes(url)
except Exception: # noqa: BLE001
return None
if not ctype.startswith("image/") or len(data) < _MIN_IMAGE_BYTES:
return None
ext = ".png" if "png" in ctype else ".jpg"
fname = f"{stem}{ext}"
cdir = cache_dir()
tmp = cdir / f".{stem}.tmp"
try:
tmp.write_bytes(data)
os.replace(tmp, cdir / fname) # atomic
except OSError:
try:
data, ctype = _http_bytes(url)
except Exception: # noqa: BLE001
continue
if not ctype.startswith("image/") or len(data) < _MIN_IMAGE_BYTES:
continue
ext = ".png" if "png" in ctype else ".jpg"
fname = f"{object_id}{ext}"
cdir = cache_dir()
tmp = cdir / f".{object_id}.tmp"
try:
tmp.write_bytes(data)
os.replace(tmp, cdir / fname) # atomic
tmp.unlink()
except OSError:
try:
tmp.unlink()
except OSError:
pass
return None
return fname
return None
pass
return None
return fname
def _download_image(obj: dict, object_id: int) -> str | None:
"""Cache the day's images to our origin. Stores the web-large display copy as
{id}.{ext} (what the page shows) and, when available, the full-resolution copy as
{id}-full.{ext} (what the lightbox opens, so zoom fills the screen). Returns the
display filename or None if even the display copy couldn't be fetched."""
display = _fetch_to_cache(obj.get("primaryImageSmall") or obj.get("primaryImage"), str(object_id))
if not display:
return None
full_url = obj.get("primaryImage")
if full_url and full_url != obj.get("primaryImageSmall"):
_fetch_to_cache(full_url, f"{object_id}-full") # best-effort hi-res for zoom
return display
def _candidates(conn: sqlite3.Connection, art_date: str, source: str) -> list[int]: