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:
@@ -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 */ }
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -23,12 +45,20 @@
|
||||
|
||||
<div class="room">
|
||||
<header class="bar">
|
||||
<a class="brand" href="/">upbeat<span>Bytes</span></a>
|
||||
<a class="brand" href="/" aria-label="upbeatBytes home">
|
||||
<img src="/logo.svg" alt="upbeatBytes" width="586" height="196" />
|
||||
</a>
|
||||
<nav class="nav">
|
||||
<a href="/">News</a>
|
||||
<a href="/play">Games</a>
|
||||
<a href="/art" aria-current="page">Art</a>
|
||||
<span class="pill">No ads · No paywalls</span>
|
||||
<a class="acct" href="/account" aria-label="Your account">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor"
|
||||
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="8" r="3.3" />
|
||||
<path d="M5.5 19.2a6.5 6.5 0 0 1 13 0" />
|
||||
</svg>
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -40,17 +70,39 @@
|
||||
|
||||
{#if state === 'ready'}
|
||||
<figure class="piece">
|
||||
<button class="frame" onclick={() => (zoom = true)} aria-label="View larger">
|
||||
<img src={art.image_url} alt={art.title} />
|
||||
<button class="frame frame--{frame}" onclick={() => (zoom = true)} aria-label="Expand artwork">
|
||||
<span class="mat">
|
||||
<img src={art.image_url} alt={art.title} />
|
||||
<span class="hint">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5" />
|
||||
</svg>
|
||||
Click to expand
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<figcaption class="placard">
|
||||
<h2 class="title">{art.title}</h2>
|
||||
<p class="who">{art.artist || 'Unknown artist'}{#if art.date_text} · {art.date_text}{/if}</p>
|
||||
<p class="who">
|
||||
{#each who as part, i}{#if i > 0}<span class="sep">·</span>{/if}{part}{/each}
|
||||
</p>
|
||||
{#if art.medium}<p class="medium">{art.medium}</p>{/if}
|
||||
<p class="credit">
|
||||
from {art.museum}{#if art.license} · {art.license}{/if}
|
||||
from {art.museum}{#if art.license}<span class="sep">·</span>{art.license}{/if}
|
||||
{#if art.source_url}<a class="more" href={art.source_url} target="_blank" rel="noopener">View at {art.museum} →</a>{/if}
|
||||
</p>
|
||||
|
||||
<div class="frames">
|
||||
<span class="frames-label">Frame</span>
|
||||
{#each FRAMES as f}
|
||||
<button class="swatch swatch--{f.id}" class:on={frame === f.id}
|
||||
onclick={() => setFrame(f.id)} aria-pressed={frame === f.id} title={f.label}>
|
||||
<span class="sr">{f.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</figcaption>
|
||||
</figure>
|
||||
{:else if state === 'empty'}
|
||||
@@ -65,8 +117,8 @@
|
||||
|
||||
{#if zoom && art}
|
||||
<button class="lightbox" onclick={() => (zoom = false)} aria-label="Close">
|
||||
<img src={art.image_url} alt={art.title} />
|
||||
<span class="lb-cap">{art.title} · {art.artist || ''}</span>
|
||||
<img src={art.image_url_large || art.image_url} alt={art.title} />
|
||||
<span class="lb-cap">{art.title}{#if art.artist}<span class="sep">·</span>{art.artist}{/if}</span>
|
||||
</button>
|
||||
{/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; }
|
||||
</style>
|
||||
|
||||
+6
-2
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user