From c37a425276091e648dbe440f3c61cf7e91f21e3d Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Wed, 5 Aug 2026 01:30:30 -0700 Subject: [PATCH] feat(booth): prev/next arrows in the image viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zooming an image now shows ‹ / › arrows at the left/right edges that step to the previous/next image in the booth (gallery sorted-rel order), wrapping around, plus keyboard ←/→. Arrows are hidden when a booth has a single image. booth_view_file computes neighbors via a new booth_image_names() helper and passes prev_url/next_url to view.html. 3 new tests, suite 47 passing; deployed + verified live on nh3-dev :8090. --- services/booth/booth/app.py | 20 +++++++++++++- services/booth/booth/templates/view.html | 16 +++++++++++ services/booth/pyproject.toml | 2 +- services/booth/tests/test_booth.py | 35 ++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/services/booth/booth/app.py b/services/booth/booth/app.py index 526548e..d450387 100644 --- a/services/booth/booth/app.py +++ b/services/booth/booth/app.py @@ -77,6 +77,15 @@ def render_doc(text: str, kind: str) -> tuple[str, bool]: return text, False +def booth_image_names(child: Path) -> list[str]: + """Image files in a booth, in gallery (sorted-rel) order — for viewer prev/next.""" + return sorted( + p.relative_to(child).as_posix() + for p in child.rglob("*") + if p.is_file() and not p.name.startswith(".") and classify(p.name) == "image" + ) + + def classify(name: str) -> str: """image | video | audio | other, by extension.""" ext = Path(name).suffix.lower() @@ -525,7 +534,16 @@ def create_app( file_url = quote(f, safe="/") common = {**base_ctx, "name": name, "name_url": quote(name, safe=""), "file": f, "file_url": file_url} if classify(target.name) == "image": - return templates.TemplateResponse(request, "view.html", common) + # prev/next image nav (wraps around; only when >1 image in the booth) + names = booth_image_names(booth) + prev_url = next_url = None + if f in names and len(names) > 1: + i = names.index(f) + prev_url = quote(names[(i - 1) % len(names)], safe="/") + next_url = quote(names[(i + 1) % len(names)], safe="/") + return templates.TemplateResponse( + request, "view.html", {**common, "prev_url": prev_url, "next_url": next_url} + ) # .md renders, .txt/.log show as text — viewable in-booth, no download dk = doc_kind(target.name) if dk: diff --git a/services/booth/booth/templates/view.html b/services/booth/booth/templates/view.html index 28fe915..ed09ea0 100644 --- a/services/booth/booth/templates/view.html +++ b/services/booth/booth/templates/view.html @@ -11,8 +11,20 @@ + {% if prev_url %}{% endif %} + {% if next_url %}{% endif %}
{{ file }}
+ diff --git a/services/booth/pyproject.toml b/services/booth/pyproject.toml index d87e35d..e8f40eb 100644 --- a/services/booth/pyproject.toml +++ b/services/booth/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "booth" -version = "0.1.6" +version = "0.1.7" description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). Also accepts browser/curl uploads for pickup under a human-readable id. 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator." requires-python = ">=3.11" dependencies = [ diff --git a/services/booth/tests/test_booth.py b/services/booth/tests/test_booth.py index 6fd15b6..e544f1b 100644 --- a/services/booth/tests/test_booth.py +++ b/services/booth/tests/test_booth.py @@ -493,3 +493,38 @@ def test_gallery_links_docs_to_view(client): assert "view?f=readme.md" in page.text # md -> viewer assert "view?f=notes.txt" in page.text # txt -> viewer assert 'href="readme.md" download' not in page.text # not a forced download + + +# ---- image viewer prev/next nav --------------------------------------------- + + +def test_view_image_prev_next_nav(client): + c, data = client + d = data / "run1"; d.mkdir() + for n in ("a.png", "b.png", "c.png"): + _touch(d / n) + r = c.get("/b/run1/view", params={"f": "b.png"}) # middle -> prev=a, next=c + assert r.status_code == 200 + assert 'class="vnav vprev" href="?f=a.png"' in r.text + assert 'class="vnav vnext" href="?f=c.png"' in r.text + + +def test_view_image_nav_wraps(client): + c, data = client + d = data / "run1"; d.mkdir() + for n in ("a.png", "b.png", "c.png"): + _touch(d / n) + first = c.get("/b/run1/view", params={"f": "a.png"}).text + assert 'vprev" href="?f=c.png"' in first and 'vnext" href="?f=b.png"' in first # first wraps prev->last + last = c.get("/b/run1/view", params={"f": "c.png"}).text + assert 'vnext" href="?f=a.png"' in last and 'vprev" href="?f=b.png"' in last # last wraps next->first + + +def test_view_single_image_no_nav(client): + c, data = client + d = data / "run1"; d.mkdir() + _touch(d / "only.png") + r = c.get("/b/run1/view", params={"f": "only.png"}) + assert r.status_code == 200 + # no arrow anchors with a single image (the .vnav CSS rule is always present) + assert 'class="vnav vprev"' not in r.text and 'class="vnav vnext"' not in r.text