diff --git a/src/bggpipe/static/app.css b/src/bggpipe/static/app.css index 7ad6db1..62343d4 100644 --- a/src/bggpipe/static/app.css +++ b/src/bggpipe/static/app.css @@ -391,6 +391,11 @@ button.danger { color: var(--stop-ink); border-color: var(--stop); background: # .shot img { width: 100%; aspect-ratio: 4/3; object-fit: cover; display: block; } .shot .meta { padding: .4rem .6rem; font-size: .78rem; color: var(--ink-soft); } .shot .meta b { color: var(--ink); } +.fullshot { + width: 100%; max-height: 60vh; object-fit: contain; + background: var(--navy); border: var(--line); border-radius: var(--radius-lg); + display: block; margin-bottom: 1rem; +} /* -- queue + library --------------------------------------------------- */ .ledger { background: var(--board); border: var(--line); border-radius: var(--radius-lg); padding: .4rem 1rem; box-shadow: var(--shadow-card); } diff --git a/src/bggpipe/static/app.js b/src/bggpipe/static/app.js index 78e33b0..d8eae8f 100644 --- a/src/bggpipe/static/app.js +++ b/src/bggpipe/static/app.js @@ -80,3 +80,12 @@ async function refreshBadges() { } refreshBadges().catch(() => {}); setInterval(() => refreshBadges().catch(() => {}), 5000); + +/* Status chip for a catalog entry — shared by the catalog and photo pages. */ +function statusChip(c) { + if (c.status === "awaiting_resolve") return `awaiting BGG`; + if (c.status === "auto" || c.status === "approved") return `${esc(c.status)}`; + if (c.status === "rejected") return `rejected`; + if (c.status === "merged") return `merged → ${esc(c.merged_into)}`; + return `${esc(c.status)}`; +} diff --git a/src/bggpipe/templates/pages/catalog.html b/src/bggpipe/templates/pages/catalog.html index 686b945..7e1f1ca 100644 --- a/src/bggpipe/templates/pages/catalog.html +++ b/src/bggpipe/templates/pages/catalog.html @@ -8,14 +8,6 @@ "use strict"; let CATALOG = []; -function chip(c) { - if (c.status === "awaiting_resolve") return `awaiting BGG`; - if (c.status === "auto" || c.status === "approved") return `${esc(c.status)}`; - if (c.status === "rejected") return `rejected`; - if (c.status === "merged") return `merged → ${esc(c.merged_into)}`; - return `${esc(c.status)}`; -} - function render() { const q = document.getElementById("catsearch").value.trim().toLowerCase(); const rows = q @@ -27,11 +19,11 @@ function render() { ? `
` + rows.map(c => ` - + `).join("") + `
${esc(c.title_raw)}${chip(c)}${statusChip(c)} ${c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : ""} ${c.version_name ? " · " + esc(c.version_name) : ""} ${c.photos.map(p => - `${esc(p)}` + `${esc(p)}` ).join(", ")}
` : `

${CATALOG.length diff --git a/src/bggpipe/templates/pages/photo.html b/src/bggpipe/templates/pages/photo.html new file mode 100644 index 0000000..31a6e00 --- /dev/null +++ b/src/bggpipe/templates/pages/photo.html @@ -0,0 +1,112 @@ +

Photo

+
+ ← all photos + + + + open full size + / move between photos +
+

Loading…

+ diff --git a/src/bggpipe/templates/pages/photos.html b/src/bggpipe/templates/pages/photos.html index 9a7cc33..d80352f 100644 --- a/src/bggpipe/templates/pages/photos.html +++ b/src/bggpipe/templates/pages/photos.html @@ -49,7 +49,7 @@ function render(state, photos) { document.getElementById("gallerycount").textContent = `— ${photos.length} on file`; document.getElementById("shots").innerHTML = photos.map(p => `
- + shelf photo ${esc(p.name)}
${esc(p.name)}
${p.extracted diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py index 78ac201..02657eb 100644 --- a/src/bggpipe/webreview.py +++ b/src/bggpipe/webreview.py @@ -415,15 +415,18 @@ def create_app( }, } - def render_page(name: str) -> str: + def render_page(name: str, active: str | None = None) -> str: """Server-side shell: shared sidebar + nav with aria-current, page - fragment substituted in. No template engine — three placeholders.""" + fragment substituted in. No template engine — three placeholders. + `active` highlights a nav entry other than the fragment's own name + (a detail page keeps its section lit).""" + active = active or name templates = resources.files("bggpipe") / "templates" shell = (templates / "shell.html").read_text() fragment = (templates / "pages" / f"{name}.html").read_text() nav = "\n".join( f' {label}" + (f'' if badge else "") + "" @@ -443,6 +446,12 @@ def create_app( def photos_page() -> str: return render_page("photos") + @app.get("/photos/view/{name}", response_class=HTMLResponse) + def photo_detail_page(name: str) -> str: + # the fragment reads the photo name from its own URL; the server + # never interpolates it (nothing to escape here) + return render_page("photo", active="photos") + @app.get("/review", response_class=HTMLResponse) def review_page() -> str: return render_page("review") diff --git a/tests/test_web_dashboard.py b/tests/test_web_dashboard.py index 48a3e5f..cd26108 100644 --- a/tests/test_web_dashboard.py +++ b/tests/test_web_dashboard.py @@ -621,3 +621,15 @@ def test_pipeline_reports_badge_fields(tmp_path): p = _app(cfg).get("/api/pipeline").json() assert p["pending_review"] == 2 # one match + one edition decision assert p["to_add"] == 2 # header excluded + + +def test_photo_detail_page_serves_with_photos_nav_active(tmp_path): + web = _app(_cfg(tmp_path)) + html = web.get("/photos/view/shelf.jpg").text + assert "all photos" in html + # the Photos nav entry stays highlighted on the detail page + import re + + (current,) = re.findall(r'