feat(booth): prev/next arrows in the image viewer
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.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -11,8 +11,20 @@
|
||||
</span>
|
||||
<a class="vbtn" href="{{ file_url }}" download title="download {{ file }}">⬇</a>
|
||||
</div>
|
||||
{% if prev_url %}<a class="vnav vprev" href="?f={{ prev_url }}" title="previous (←)" aria-label="previous image">‹</a>{% endif %}
|
||||
{% if next_url %}<a class="vnav vnext" href="?f={{ next_url }}" title="next (→)" aria-label="next image">›</a>{% endif %}
|
||||
<div class="vstage fit" id="vstage"><img id="vimg" src="{{ file_url }}" alt="{{ file }}"></div>
|
||||
</div>
|
||||
<style>
|
||||
.vnav{position:fixed;top:50%;transform:translateY(-50%);z-index:40;display:flex;
|
||||
align-items:center;justify-content:center;width:2.6rem;height:3.4rem;font-size:2rem;
|
||||
line-height:1;text-decoration:none;color:var(--fg-1);background:rgba(20,23,32,.55);
|
||||
border:1px solid rgba(255,255,255,.10);border-radius:10px;margin:0 .5rem;user-select:none;
|
||||
-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);transition:background .15s,border-color .15s}
|
||||
.vnav:hover{background:rgba(28,33,46,.92);border-color:var(--aus-bright-cyan,#42dcd1)}
|
||||
.vprev{left:0}.vnext{right:0}
|
||||
@media print{.vnav{display:none}}
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var img = document.getElementById('vimg');
|
||||
@@ -21,6 +33,8 @@
|
||||
var bFit = document.getElementById('btn-fit');
|
||||
var bOne = document.getElementById('btn-one');
|
||||
var BACK = {{ ('/b/' ~ name_url ~ '/')|tojson }};
|
||||
var PREV = {{ (('?f=' ~ prev_url) if prev_url else '')|tojson }};
|
||||
var NEXT = {{ (('?f=' ~ next_url) if next_url else '')|tojson }};
|
||||
|
||||
function setMode(mode) {
|
||||
var fit = mode === 'fit';
|
||||
@@ -51,6 +65,8 @@
|
||||
if (img.complete) evaluate();
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') window.location.href = BACK;
|
||||
else if (e.key === 'ArrowLeft' && PREV) window.location.href = PREV;
|
||||
else if (e.key === 'ArrowRight' && NEXT) window.location.href = NEXT;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user