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:
@@ -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); }
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user