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 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 { padding: .4rem .6rem; font-size: .78rem; color: var(--ink-soft); }
|
||||||
.shot .meta b { color: var(--ink); }
|
.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 --------------------------------------------------- */
|
/* -- queue + library --------------------------------------------------- */
|
||||||
.ledger { background: var(--board); border: var(--line); border-radius: var(--radius-lg); padding: .4rem 1rem; box-shadow: var(--shadow-card); }
|
.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(() => {});
|
refreshBadges().catch(() => {});
|
||||||
setInterval(() => refreshBadges().catch(() => {}), 5000);
|
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";
|
"use strict";
|
||||||
let CATALOG = [];
|
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() {
|
function render() {
|
||||||
const q = document.getElementById("catsearch").value.trim().toLowerCase();
|
const q = document.getElementById("catsearch").value.trim().toLowerCase();
|
||||||
const rows = q
|
const rows = q
|
||||||
@@ -27,11 +19,11 @@ function render() {
|
|||||||
? `<div class="catalog"><table>` + rows.map(c => `
|
? `<div class="catalog"><table>` + rows.map(c => `
|
||||||
<tr>
|
<tr>
|
||||||
<td class="t">${esc(c.title_raw)}</td>
|
<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) : "") : ""}
|
<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>
|
${c.version_name ? " · " + esc(c.version_name) : ""}</td>
|
||||||
<td class="meta">${c.photos.map(p =>
|
<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>
|
).join(", ")}</td>
|
||||||
</tr>`).join("") + `</table></div>`
|
</tr>`).join("") + `</table></div>`
|
||||||
: `<p class="empty">${CATALOG.length
|
: `<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("gallerycount").textContent = `— ${photos.length} on file`;
|
||||||
document.getElementById("shots").innerHTML = photos.map(p => `
|
document.getElementById("shots").innerHTML = photos.map(p => `
|
||||||
<figure class="shot">
|
<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>
|
<img src="/photos/${encodeURIComponent(p.name)}" alt="shelf photo ${esc(p.name)}" loading="lazy"></a>
|
||||||
<figcaption class="meta">${esc(p.name)}<br>
|
<figcaption class="meta">${esc(p.name)}<br>
|
||||||
${p.extracted
|
${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
|
"""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"
|
templates = resources.files("bggpipe") / "templates"
|
||||||
shell = (templates / "shell.html").read_text()
|
shell = (templates / "shell.html").read_text()
|
||||||
fragment = (templates / "pages" / f"{name}.html").read_text()
|
fragment = (templates / "pages" / f"{name}.html").read_text()
|
||||||
nav = "\n".join(
|
nav = "\n".join(
|
||||||
f' <a href="{href}"'
|
f' <a href="{href}"'
|
||||||
+ (' aria-current="page"' if page == name else "")
|
+ (' aria-current="page"' if page == active else "")
|
||||||
+ f">{label}"
|
+ f">{label}"
|
||||||
+ (f'<span class="navbadge" data-badge="{badge}"></span>' if badge else "")
|
+ (f'<span class="navbadge" data-badge="{badge}"></span>' if badge else "")
|
||||||
+ "</a>"
|
+ "</a>"
|
||||||
@@ -443,6 +446,12 @@ def create_app(
|
|||||||
def photos_page() -> str:
|
def photos_page() -> str:
|
||||||
return render_page("photos")
|
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)
|
@app.get("/review", response_class=HTMLResponse)
|
||||||
def review_page() -> str:
|
def review_page() -> str:
|
||||||
return render_page("review")
|
return render_page("review")
|
||||||
|
|||||||
@@ -621,3 +621,15 @@ def test_pipeline_reports_badge_fields(tmp_path):
|
|||||||
p = _app(cfg).get("/api/pipeline").json()
|
p = _app(cfg).get("/api/pipeline").json()
|
||||||
assert p["pending_review"] == 2 # one match + one edition decision
|
assert p["pending_review"] == 2 # one match + one edition decision
|
||||||
assert p["to_add"] == 2 # header excluded
|
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