diff --git a/frontend/src/routes/art/+page.svelte b/frontend/src/routes/art/+page.svelte index 600edbb..dca7519 100644 --- a/frontend/src/routes/art/+page.svelte +++ b/frontend/src/routes/art/+page.svelte @@ -2,11 +2,28 @@ import { onMount } from 'svelte'; import { getJSON } from '$lib/api.js'; + // Virtual frames the viewer can switch between — remembered locally, no account needed. + const FRAMES = [ + { id: 'walnut', label: 'Walnut' }, + { id: 'gold', label: 'Gold' }, + { id: 'silver', label: 'Silver' }, + { id: 'none', label: 'No frame' }, + ]; + let art = $state(null); let state = $state('loading'); // loading | ready | empty let zoom = $state(false); + let frame = $state('walnut'); + + let who = $derived( + art ? [art.artist || 'Unknown artist', art.date_text].filter(Boolean) : [] + ); onMount(async () => { + try { + const saved = localStorage.getItem('ub_art_frame'); + if (saved && FRAMES.some((f) => f.id === saved)) frame = saved; + } catch { /* private mode — default frame is fine */ } try { art = await getJSON('/api/art/today'); state = art?.image_url ? 'ready' : 'empty'; @@ -14,6 +31,11 @@ state = 'empty'; } }); + + function setFrame(id) { + frame = id; + try { localStorage.setItem('ub_art_frame', id); } catch { /* ignore */ } + } @@ -23,12 +45,20 @@
- upbeatBytes + + upbeatBytes +
@@ -40,17 +70,39 @@ {#if state === 'ready'}
- +

{art.title}

-

{art.artist || 'Unknown artist'}{#if art.date_text} · {art.date_text}{/if}

+

+ {#each who as part, i}{#if i > 0}·{/if}{part}{/each} +

{#if art.medium}

{art.medium}

{/if}

- from {art.museum}{#if art.license} · {art.license}{/if} + from {art.museum}{#if art.license}·{art.license}{/if} {#if art.source_url}View at {art.museum} →{/if}

+ +
+ Frame + {#each FRAMES as f} + + {/each} +
{:else if state === 'empty'} @@ -65,8 +117,8 @@ {#if zoom && art} {/if} @@ -90,19 +142,20 @@ .bar { display: flex; align-items: center; justify-content: space-between; - padding: 18px clamp(16px, 5vw, 56px); + padding: 14px clamp(16px, 5vw, 56px); max-width: 1100px; width: 100%; margin: 0 auto; box-sizing: border-box; } - .brand { font-weight: 800; font-size: 1.25rem; letter-spacing: -0.02em; color: var(--ink); text-decoration: none; } - .brand span { color: var(--accent); } + .brand { display: block; line-height: 0; } + .brand img { height: 34px; width: auto; display: block; } .nav { display: flex; align-items: center; gap: clamp(12px, 2.5vw, 26px); } .nav a { color: var(--muted); text-decoration: none; font-weight: 600; font-size: 0.95rem; } .nav a:hover { color: var(--ink); } .nav a[aria-current="page"] { color: var(--accent); } - .pill { - font-size: 0.76rem; font-weight: 600; color: var(--accent-deep); - background: #e7f4f9; border: 1px solid #cdeaf3; border-radius: 999px; padding: 5px 12px; + .acct { + display: inline-flex; align-items: center; justify-content: center; + width: 38px; height: 38px; border-radius: 50%; color: var(--muted); } + .acct:hover { color: var(--accent); background: #eef6f9; } .gallery { flex: 1; width: 100%; max-width: 1100px; margin: 0 auto; @@ -116,17 +169,65 @@ } .intro p { color: var(--muted); margin: 10px 0 0; font-size: 1.05rem; } - .piece { margin: 0; display: flex; flex-direction: column; align-items: center; max-width: 820px; } - .frame { - border: none; padding: 0; background: var(--surface); cursor: zoom-in; - border-radius: 18px; overflow: hidden; line-height: 0; - box-shadow: 0 18px 50px rgba(20, 30, 45, 0.14), 0 2px 8px rgba(20, 30, 45, 0.06); - transition: transform 0.25s ease, box-shadow 0.25s ease; - } - .frame:hover { transform: translateY(-3px); box-shadow: 0 26px 64px rgba(20, 30, 45, 0.18); } - .frame img { display: block; width: 100%; height: auto; max-height: 74vh; object-fit: contain; } + .piece { margin: 0; display: flex; flex-direction: column; align-items: center; max-width: 860px; } - .placard { text-align: center; margin-top: clamp(20px, 4vw, 34px); max-width: 640px; } + /* The frame: a beveled moulding (wood/metal) around a cream mat around the art. */ + .frame { + border: none; cursor: zoom-in; padding: 0; background: none; + position: relative; line-height: 0; display: inline-block; max-width: 100%; + border-radius: 5px; + box-shadow: 0 24px 58px rgba(20, 30, 45, 0.20), 0 3px 10px rgba(20, 30, 45, 0.10); + } + .frame--walnut, .frame--gold, .frame--silver { padding: clamp(11px, 2.3vw, 22px); border-radius: 4px; } + .frame--walnut { + background: linear-gradient(135deg, #5c3d26, #87592f 38%, #4d3220 68%, #76512f); + box-shadow: 0 24px 58px rgba(20, 30, 45, 0.24), + inset 0 0 0 1px rgba(255, 255, 255, 0.10), + inset 0 2px 3px rgba(255, 226, 190, 0.30), + inset 0 -3px 9px rgba(0, 0, 0, 0.48); + } + .frame--gold { + background: linear-gradient(135deg, #b88c3d, #ecd293 42%, #a9772f 60%, #dcbd71); + box-shadow: 0 24px 58px rgba(20, 30, 45, 0.20), + inset 0 0 0 1px rgba(255, 255, 255, 0.30), + inset 0 2px 3px rgba(255, 249, 226, 0.60), + inset 0 -3px 9px rgba(92, 62, 12, 0.48); + } + .frame--silver { + background: linear-gradient(135deg, #a9b0ba, #edf0f3 45%, #98a0ab 62%, #d2d7de); + box-shadow: 0 24px 58px rgba(20, 30, 45, 0.18), + inset 0 0 0 1px rgba(255, 255, 255, 0.50), + inset 0 2px 3px rgba(255, 255, 255, 0.75), + inset 0 -3px 9px rgba(60, 70, 85, 0.42); + } + + .mat { + display: block; position: relative; background: #fbf8f1; border-radius: 1px; + } + .frame--walnut .mat, .frame--gold .mat, .frame--silver .mat { + padding: clamp(10px, 2.4vw, 22px); + box-shadow: inset 0 2px 10px rgba(0, 0, 0, 0.14), inset 0 0 0 1px rgba(0, 0, 0, 0.05); + } + .frame img { display: block; width: 100%; height: auto; max-height: 72vh; object-fit: contain; border-radius: 1px; } + + /* Plain mode = the clean soft-shadowed card, no moulding. */ + .frame--none { background: var(--surface); border-radius: 14px; overflow: hidden; } + .frame--none .mat { background: none; } + .frame--none img { border-radius: 14px; } + + /* A quiet, always-there affordance — no hover wobble. */ + .hint { + position: absolute; right: 12px; bottom: 12px; + display: inline-flex; align-items: center; gap: 6px; + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 0.76rem; font-weight: 600; line-height: 1; color: #fff; + background: rgba(20, 26, 33, 0.50); border-radius: 999px; padding: 7px 11px; + backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); + pointer-events: none; transition: background 0.2s ease; + } + .frame:hover .hint, .frame:focus-visible .hint { background: rgba(20, 26, 33, 0.72); } + + .placard { text-align: center; margin-top: clamp(22px, 4vw, 36px); max-width: 640px; } .title { font-family: Georgia, "Iowan Old Style", "Times New Roman", serif; font-size: clamp(1.4rem, 3.5vw, 2rem); margin: 0; line-height: 1.2; @@ -134,9 +235,23 @@ .who { margin: 8px 0 0; font-size: 1.05rem; color: var(--ink); } .medium { margin: 4px 0 0; color: var(--muted); font-size: 0.95rem; font-style: italic; } .credit { margin: 16px 0 0; color: var(--muted); font-size: 0.88rem; } + .sep { display: inline-block; margin: 0 0.5em; color: var(--muted); } .more { display: inline-block; margin-left: 8px; color: var(--accent); font-weight: 600; text-decoration: none; } .more:hover { color: var(--accent-deep); } + .frames { display: flex; align-items: center; justify-content: center; gap: 9px; margin-top: 22px; } + .frames-label { font-size: 0.74rem; font-weight: 600; color: var(--muted); margin-right: 4px; text-transform: uppercase; letter-spacing: 0.06em; } + .swatch { + width: 26px; height: 26px; border-radius: 50%; border: 2px solid transparent; + cursor: pointer; padding: 0; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); + } + .swatch.on { box-shadow: 0 0 0 2px var(--canvas), 0 0 0 4px var(--accent); } + .swatch--walnut { background: linear-gradient(135deg, #5c3d26, #87592f); } + .swatch--gold { background: linear-gradient(135deg, #b88c3d, #ecd293); } + .swatch--silver { background: linear-gradient(135deg, #a9b0ba, #edf0f3); } + .swatch--none { background: var(--surface); border: 2px solid var(--line); box-shadow: inset 0 0 0 1px rgba(0,0,0,0.03); } + .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } + .note { color: var(--muted); font-size: 1.05rem; margin-top: 40px; } .foot { @@ -146,13 +261,13 @@ .lightbox { position: fixed; inset: 0; z-index: 50; border: none; cursor: zoom-out; - background: rgba(18, 22, 28, 0.92); display: flex; flex-direction: column; - align-items: center; justify-content: center; gap: 14px; padding: 4vmin; + background: rgba(18, 22, 28, 0.94); display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: 14px; padding: 3vmin; + } + .lightbox img { + max-width: 97vw; max-height: 92vh; width: auto; height: auto; + object-fit: contain; border-radius: 4px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); } - .lightbox img { max-width: 96vw; max-height: 88vh; border-radius: 8px; box-shadow: 0 20px 60px rgba(0,0,0,0.5); } .lb-cap { color: #e9e2d6; font-size: 0.9rem; } - - @media (max-width: 560px) { - .pill { display: none; } - } + .lb-cap .sep { color: #9aa0a6; } diff --git a/goodnews/api.py b/goodnews/api.py index dc3ca06..df16b04 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -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. diff --git a/goodnews/art.py b/goodnews/art.py index a73f611..d1047b4 100644 --- a/goodnews/art.py +++ b/goodnews/art.py @@ -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]: diff --git a/tests/test_art.py b/tests/test_art.py index 9e77768..8108aa4 100644 --- a/tests/test_art.py +++ b/tests/test_art.py @@ -54,6 +54,16 @@ def test_pick_caches_image_metadata_and_marks_shown(conn): assert not list(art.cache_dir().glob("*.tmp")) # atomic write left no temp file +def test_pick_caches_full_res_for_lightbox(conn): + conn.execute("INSERT INTO art_pool (source, object_id) VALUES ('met', 1)") # has distinct primaryImage + conn.commit() + a = art.pick_daily(conn, art_date="2026-06-21") + assert a and a["object_id"] == 1 + assert list(art.cache_dir().glob("1.*")) # web-large display copy + assert list(art.cache_dir().glob("1-full.*")) # hi-res copy for the zoom + assert not list(art.cache_dir().glob("*.tmp")) + + def test_blocked_pieces_are_never_picked(conn): art.harvest_pool(conn) conn.execute("UPDATE art_pool SET blocked=1 WHERE object_id=1") # block the good one