Art post-audit polish (Codex): image HEAD, texture immutable cache, lightbox a11y, spacing
- /api/art/image/{id} now answers HEAD as well as GET (was 404 on HEAD) — mirrors the
/a/{id} fix. Added tests/test_art_api.py (GET+HEAD+size=full fallback + today payload).
- /textures/* served immutable (long cache) instead of no-cache; excluded from the
revalidate matcher. Live Caddyfile + repo snapshot both updated.
- Lightbox: Escape closes it, and focus moves to it on open (keyboard-friendly).
- Trimmed the gallery's top padding so "Daily Art" sits closer to the bar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,11 @@ upbeatbytes.com {
|
|||||||
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
|
||||||
# Static texture assets (frame wood grain, etc.) — large and unchanging. Cache
|
# Static texture assets (frame wood grain, etc.) — large and unchanging. Cache
|
||||||
|
# them forever like immutable assets; rename the file if the texture ever changes.
|
||||||
|
@textures path /textures/*
|
||||||
|
header @textures Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
|
||||||
|
# The SPA shell: "/" and extensionless client routes (try_files → index.html).
|
||||||
# Briefly cacheable at the CDN edge (s-maxage) so a first paint never depends
|
# Briefly cacheable at the CDN edge (s-maxage) so a first paint never depends
|
||||||
# on this origin's uplink; browsers still revalidate every visit (max-age=0).
|
# on this origin's uplink; browsers still revalidate every visit (max-age=0).
|
||||||
# A deploy propagates within ≤2min and old immutable chunks are kept for a
|
# A deploy propagates within ≤2min and old immutable chunks are kept for a
|
||||||
@@ -80,6 +85,7 @@ upbeatbytes.com {
|
|||||||
@revalidate {
|
@revalidate {
|
||||||
not path /_app/immutable/*
|
not path /_app/immutable/*
|
||||||
not path /textures/*
|
not path /textures/*
|
||||||
|
path *.*
|
||||||
}
|
}
|
||||||
header @revalidate Cache-Control "no-cache"
|
header @revalidate Cache-Control "no-cache"
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,13 @@
|
|||||||
let zoom = $state(false);
|
let zoom = $state(false);
|
||||||
let frame = $state('walnut');
|
let frame = $state('walnut');
|
||||||
let thickness = $state(1); // frame-scale multiplier, 0.7–1.9 (mat caps at 1.5)
|
let thickness = $state(1); // frame-scale multiplier, 0.7–1.9 (mat caps at 1.5)
|
||||||
|
let lightboxEl = $state(null);
|
||||||
|
|
||||||
|
function onKey(e) {
|
||||||
|
if (e.key === 'Escape' && zoom) zoom = false;
|
||||||
|
}
|
||||||
|
// Move focus to the lightbox when it opens, so Escape/Enter work and focus is trapped sanely.
|
||||||
|
$effect(() => { if (zoom && lightboxEl) lightboxEl.focus(); });
|
||||||
|
|
||||||
let who = $derived(
|
let who = $derived(
|
||||||
art ? [art.artist || 'Unknown artist', art.date_text].filter(Boolean) : []
|
art ? [art.artist || 'Unknown artist', art.date_text].filter(Boolean) : []
|
||||||
@@ -50,6 +57,8 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKey} />
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Daily Art · upbeatBytes</title>
|
<title>Daily Art · upbeatBytes</title>
|
||||||
<meta name="description" content="A masterwork a day from the world's open museum collections — one piece, beautifully framed, on upbeatBytes." />
|
<meta name="description" content="A masterwork a day from the world's open museum collections — one piece, beautifully framed, on upbeatBytes." />
|
||||||
@@ -145,7 +154,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if zoom && art}
|
{#if zoom && art}
|
||||||
<button class="lightbox" onclick={() => (zoom = false)} aria-label="Close artwork">
|
<button class="lightbox" bind:this={lightboxEl} onclick={() => (zoom = false)} aria-label="Close artwork">
|
||||||
<span class="frame frame--{frame} lb-frame" style="--frame-scale:{thickness}">
|
<span class="frame frame--{frame} lb-frame" style="--frame-scale:{thickness}">
|
||||||
{#if isWood}{@render woodRails()}{/if}
|
{#if isWood}{@render woodRails()}{/if}
|
||||||
<span class="mat"><img src={art.image_url_large || art.image_url} alt={art.title} /></span>
|
<span class="mat"><img src={art.image_url_large || art.image_url} alt={art.title} /></span>
|
||||||
@@ -191,7 +200,8 @@
|
|||||||
|
|
||||||
.gallery {
|
.gallery {
|
||||||
flex: 1; width: 100%; max-width: 1100px; margin: 0 auto;
|
flex: 1; width: 100%; max-width: 1100px; margin: 0 auto;
|
||||||
padding: clamp(20px, 5vw, 56px); box-sizing: border-box;
|
padding: clamp(6px, 1.5vw, 16px) clamp(20px, 5vw, 56px) clamp(20px, 5vw, 56px);
|
||||||
|
box-sizing: border-box;
|
||||||
display: flex; flex-direction: column; align-items: center;
|
display: flex; flex-direction: column; align-items: center;
|
||||||
}
|
}
|
||||||
.intro { text-align: center; margin-bottom: clamp(18px, 3.5vw, 30px); }
|
.intro { text-align: center; margin-bottom: clamp(18px, 3.5vw, 30px); }
|
||||||
|
|||||||
+1
-1
@@ -2279,7 +2279,7 @@ def create_app() -> FastAPI:
|
|||||||
"image_url_large": f"/api/art/image/{a['object_id']}?size=full",
|
"image_url_large": f"/api/art/image/{a['object_id']}?size=full",
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/api/art/image/{object_id}")
|
@app.api_route("/api/art/image/{object_id}", methods=["GET", "HEAD"])
|
||||||
def art_image(object_id: int, size: str = Query("")) -> FileResponse:
|
def art_image(object_id: int, size: str = Query("")) -> FileResponse:
|
||||||
cdir = art.cache_dir()
|
cdir = art.cache_dir()
|
||||||
matches = sorted(cdir.glob(f"{object_id}-full.*")) if size == "full" else []
|
matches = sorted(cdir.glob(f"{object_id}-full.*")) if size == "full" else []
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""/api/art image + today endpoints: GET and HEAD both work (HEAD must not 404 — the
|
||||||
|
share /a/{id} HEAD gap bit us once), and the today payload carries the lightbox's
|
||||||
|
full-res URL."""
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path, monkeypatch):
|
||||||
|
db = tmp_path / "t.sqlite3"
|
||||||
|
cache = tmp_path / "art_cache"
|
||||||
|
monkeypatch.setenv("GOODNEWS_DB", str(db))
|
||||||
|
monkeypatch.setenv("GOODNEWS_ART_CACHE", str(cache))
|
||||||
|
monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "https://upbeatbytes.com")
|
||||||
|
import importlib
|
||||||
|
import goodnews.api as api
|
||||||
|
importlib.reload(api)
|
||||||
|
from goodnews.db import connect, init_db
|
||||||
|
c = connect(str(db)); init_db(c)
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO daily_art (art_date, source, object_id, title, artist, date_text, "
|
||||||
|
"medium, department, credit, source_url, image_file, image_url_full, is_public_domain) "
|
||||||
|
"VALUES ('2026-06-21','met',10154,'Lander''s Peak','Bierstadt','1863','Oil','Paintings',"
|
||||||
|
"'Gift','https://met/10154','10154.jpg','https://met/full.jpg',1)"
|
||||||
|
)
|
||||||
|
c.commit(); c.close()
|
||||||
|
cache.mkdir(parents=True, exist_ok=True)
|
||||||
|
(cache / "10154.jpg").write_bytes(b"\xff\xd8\xff" + b"x" * 5000) # web-large display copy
|
||||||
|
return api.create_app()
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_get_and_head(client):
|
||||||
|
tc = TestClient(client)
|
||||||
|
assert tc.get("/api/art/image/10154").status_code == 200
|
||||||
|
assert tc.head("/api/art/image/10154").status_code == 200 # HEAD must not 404
|
||||||
|
# ?size=full falls back to the display copy when no -full is cached
|
||||||
|
assert tc.get("/api/art/image/10154?size=full").status_code == 200
|
||||||
|
assert tc.get("/api/art/image/9999").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_today_exposes_full_res_url(client):
|
||||||
|
a = TestClient(client).get("/api/art/today").json()
|
||||||
|
assert a["image_url"] == "/api/art/image/10154"
|
||||||
|
assert a["image_url_large"] == "/api/art/image/10154?size=full"
|
||||||
|
assert a["license"] == "Public Domain (CC0)" and a["museum"] == "The Met"
|
||||||
Reference in New Issue
Block a user