Photo detail page: each photo gets a real page, not a bare image

/photos/view/{name} shows the photo large in the shell, every title
read from it with status chips and BGG matches, its reshoot tickets
with working dismiss, prev/next navigation with arrow keys, position
in the gallery, and a link to the raw full-size file. Gallery and
catalog photo links point here now (review's shots keep linking to the
raw image — zooming spine text is their whole purpose). The status
chip renderer moves to app.js so the catalog and photo pages can't
drift; render_page learns an `active` override so a detail page keeps
its nav section lit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-02 18:37:31 -04:00
parent 08b741671d
commit 33cbfffbd6
7 changed files with 153 additions and 14 deletions
+5
View File
@@ -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); }
+9
View File
@@ -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 `<span class="chip wait">awaiting BGG</span>`;
if (c.status === "auto" || c.status === "approved") return `<span class="chip ok">${esc(c.status)}</span>`;
if (c.status === "rejected") return `<span class="chip no">rejected</span>`;
if (c.status === "merged") return `<span class="chip merged" title="merged into ${esc(c.merged_into)}">merged → ${esc(c.merged_into)}</span>`;
return `<span class="chip open">${esc(c.status)}</span>`;
}
+2 -10
View File
@@ -8,14 +8,6 @@
"use strict";
let CATALOG = [];
function chip(c) {
if (c.status === "awaiting_resolve") return `<span class="chip wait">awaiting BGG</span>`;
if (c.status === "auto" || c.status === "approved") return `<span class="chip ok">${esc(c.status)}</span>`;
if (c.status === "rejected") return `<span class="chip no">rejected</span>`;
if (c.status === "merged") return `<span class="chip merged" title="merged into ${esc(c.merged_into)}">merged → ${esc(c.merged_into)}</span>`;
return `<span class="chip open">${esc(c.status)}</span>`;
}
function render() {
const q = document.getElementById("catsearch").value.trim().toLowerCase();
const rows = q
@@ -27,11 +19,11 @@ function render() {
? `<div class="catalog"><table>` + rows.map(c => `
<tr>
<td class="t">${esc(c.title_raw)}</td>
<td>${chip(c)}</td>
<td>${statusChip(c)}</td>
<td class="meta">${c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : ""}
${c.version_name ? " · " + esc(c.version_name) : ""}</td>
<td class="meta">${c.photos.map(p =>
`<a href="/photos/${encodeURIComponent(p)}" target="_blank">${esc(p)}</a>`
`<a href="/photos/view/${encodeURIComponent(p)}">${esc(p)}</a>`
).join(", ")}</td>
</tr>`).join("") + `</table></div>`
: `<p class="empty">${CATALOG.length
+112
View File
@@ -0,0 +1,112 @@
<h1 id="photoname">Photo</h1>
<div class="pagebar">
<a href="/photos">← all photos</a>
<span id="position"></span>
<a id="prevlink" hidden>previous</a>
<a id="nextlink" hidden>next</a>
<a id="rawlink" target="_blank">open full size</a>
<span class="keyhelp"><kbd></kbd>/<kbd></kbd> move between photos</span>
</div>
<div id="photobody"><p class="empty">Loading…</p></div>
<script>
"use strict";
const NAME = decodeURIComponent(location.pathname.split("/").pop());
document.getElementById("photoname").textContent = NAME;
document.getElementById("rawlink").href = `/photos/${encodeURIComponent(NAME)}`;
document.title = `bggpipe — ${NAME}`;
function ticket(s) {
return `
<section class="ticket"
data-photo="${esc(s.photo)}" data-location="${esc(s.location)}"
data-partial="${esc(s.partial_text)}" data-art="${esc(s.art_notes)}">
<span class="stencil">reshoot</span>
<div>
<div class="loc">${esc(s.location) || "somewhere in this photo"}</div>
${s.partial_text ? `<div class="partial">text visible: ${esc(s.partial_text)}</div>` : ""}
${s.art_notes ? `<div class="notes">${esc(s.art_notes)}</div>` : ""}
<button class="dismiss">dismiss — found it / not a game</button>
</div>
</section>`;
}
function render(state, photos) {
const info = photos.find(p => p.name === NAME);
const body = document.getElementById("photobody");
if (!info) {
body.innerHTML = `<p class="empty">No photo named <b>${esc(NAME)}</b> is on file —
back to <a href="/photos">all photos</a>.</p>`;
return;
}
const ix = photos.findIndex(p => p.name === NAME);
document.getElementById("position").innerHTML =
`<b>${ix + 1}</b> of <b>${photos.length}</b>`;
const prev = photos[ix - 1], next = photos[ix + 1];
const prevEl = document.getElementById("prevlink");
const nextEl = document.getElementById("nextlink");
prevEl.hidden = !prev;
nextEl.hidden = !next;
if (prev) prevEl.href = `/photos/view/${encodeURIComponent(prev.name)}`;
if (next) nextEl.href = `/photos/view/${encodeURIComponent(next.name)}`;
const titles = state.catalog.filter(c => c.photos.includes(NAME));
const tickets = state.unidentified.filter(s => s.photo === NAME);
let html = `
<a href="/photos/${encodeURIComponent(NAME)}" target="_blank">
<img class="fullshot" src="/photos/${encodeURIComponent(NAME)}"
alt="shelf photo ${esc(NAME)}"></a>`;
html += `<h2>Titles read from this photo <span class="count">— ${titles.length}</span></h2>`;
html += titles.length
? `<div class="catalog"><table>` + titles.map(c => `
<tr>
<td class="t">${esc(c.title_raw)}</td>
<td>${statusChip(c)}</td>
<td class="meta">${c.bgg_name ? esc(c.bgg_name) + (c.bgg_id ? " · " + esc(c.bgg_id) : "") : ""}
${c.version_name ? " · " + esc(c.version_name) : ""}</td>
</tr>`).join("") + `</table></div>`
: `<p class="empty">${info.extracted
? "No titles were read from this photo."
: "Not extracted yet — run <b>extract</b> from the <a href='/'>pipeline</a>."}</p>`;
if (tickets.length) {
html += `<h2>Reshoot tickets <span class="count">— boxes seen here but not identified</span></h2>`;
html += tickets.map(ticket).join("");
}
body.innerHTML = html;
body.querySelectorAll(".dismiss").forEach(b => b.addEventListener("click", async () => {
const t = b.closest(".ticket");
const res = await apiPost("/api/dismiss", {
photo: t.dataset.photo, location: t.dataset.location,
partial_text: t.dataset.partial, art_notes: t.dataset.art,
});
if (res) refresh().catch(() => {});
}));
}
let LAST = null;
async function refresh() {
const [state, photos] = await Promise.all([
fetchJSON("/api/state"),
fetchJSON("/api/photos-list"),
]);
const payload = JSON.stringify([state.catalog, state.unidentified, photos]);
if (payload === LAST) return;
LAST = payload;
render(state, photos);
}
document.addEventListener("keydown", e => {
if (e.target.tagName === "INPUT") return;
if (e.key === "ArrowLeft" && !document.getElementById("prevlink").hidden)
location.href = document.getElementById("prevlink").href;
if (e.key === "ArrowRight" && !document.getElementById("nextlink").hidden)
location.href = document.getElementById("nextlink").href;
});
refresh().catch(err => errorBanner(err.message || err));
pollLoop(refresh, 5000, () => showBanner(""));
</script>
+1 -1
View File
@@ -49,7 +49,7 @@ function render(state, photos) {
document.getElementById("gallerycount").textContent = `${photos.length} on file`;
document.getElementById("shots").innerHTML = photos.map(p => `
<figure class="shot">
<a href="/photos/${encodeURIComponent(p.name)}" target="_blank" aria-label="open ${esc(p.name)} full size">
<a href="/photos/view/${encodeURIComponent(p.name)}" aria-label="open ${esc(p.name)} details">
<img src="/photos/${encodeURIComponent(p.name)}" alt="shelf photo ${esc(p.name)}" loading="lazy"></a>
<figcaption class="meta">${esc(p.name)}<br>
${p.extracted
+12 -3
View File
@@ -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' <a href="{href}"'
+ (' aria-current="page"' if page == name else "")
+ (' aria-current="page"' if page == active else "")
+ f">{label}"
+ (f'<span class="navbadge" data-badge="{badge}"></span>' if badge else "")
+ "</a>"
@@ -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")
+12
View File
@@ -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'<a href="([^"]+)" aria-current="page"', html)
assert current == "/photos"
assert 'src="/static/app.js"' in html