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:
+19
-1
@@ -77,6 +77,15 @@ def render_doc(text: str, kind: str) -> tuple[str, bool]:
|
|||||||
return text, False
|
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:
|
def classify(name: str) -> str:
|
||||||
"""image | video | audio | other, by extension."""
|
"""image | video | audio | other, by extension."""
|
||||||
ext = Path(name).suffix.lower()
|
ext = Path(name).suffix.lower()
|
||||||
@@ -525,7 +534,16 @@ def create_app(
|
|||||||
file_url = quote(f, safe="/")
|
file_url = quote(f, safe="/")
|
||||||
common = {**base_ctx, "name": name, "name_url": quote(name, safe=""), "file": f, "file_url": file_url}
|
common = {**base_ctx, "name": name, "name_url": quote(name, safe=""), "file": f, "file_url": file_url}
|
||||||
if classify(target.name) == "image":
|
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
|
# .md renders, .txt/.log show as text — viewable in-booth, no download
|
||||||
dk = doc_kind(target.name)
|
dk = doc_kind(target.name)
|
||||||
if dk:
|
if dk:
|
||||||
|
|||||||
@@ -11,8 +11,20 @@
|
|||||||
</span>
|
</span>
|
||||||
<a class="vbtn" href="{{ file_url }}" download title="download {{ file }}">⬇</a>
|
<a class="vbtn" href="{{ file_url }}" download title="download {{ file }}">⬇</a>
|
||||||
</div>
|
</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 class="vstage fit" id="vstage"><img id="vimg" src="{{ file_url }}" alt="{{ file }}"></div>
|
||||||
</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>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var img = document.getElementById('vimg');
|
var img = document.getElementById('vimg');
|
||||||
@@ -21,6 +33,8 @@
|
|||||||
var bFit = document.getElementById('btn-fit');
|
var bFit = document.getElementById('btn-fit');
|
||||||
var bOne = document.getElementById('btn-one');
|
var bOne = document.getElementById('btn-one');
|
||||||
var BACK = {{ ('/b/' ~ name_url ~ '/')|tojson }};
|
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) {
|
function setMode(mode) {
|
||||||
var fit = mode === 'fit';
|
var fit = mode === 'fit';
|
||||||
@@ -51,6 +65,8 @@
|
|||||||
if (img.complete) evaluate();
|
if (img.complete) evaluate();
|
||||||
document.addEventListener('keydown', function (e) {
|
document.addEventListener('keydown', function (e) {
|
||||||
if (e.key === 'Escape') window.location.href = BACK;
|
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>
|
</script>
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "booth"
|
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."
|
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"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
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=readme.md" in page.text # md -> viewer
|
||||||
assert "view?f=notes.txt" in page.text # txt -> viewer
|
assert "view?f=notes.txt" in page.text # txt -> viewer
|
||||||
assert 'href="readme.md" download' not in page.text # not a forced download
|
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