feat(booth): image viewer page with Fit/1:1, download, Esc-back (v0.1.2)
Clicking a gallery image now opens a dedicated viewer instead of dumping you on the raw file. - GET /b/<name>/view?f=<img> — full-viewport viewer (registered before the file catch-all so /view wins; non-image f 307-redirects to the raw file, traversal and missing f 404). - Fit (downscale-only) / 1:1 (natural pixels, scroll-to-pan) toggle that only appears when the image is larger than the viewport — when it already fits, Fit ≡ 1:1 so the toggle is hidden. Re-evaluates on resize. - Download button + ✕/Esc back to the gallery. Australis-themed, progressive JS (degrades to fit-only, no-JS still shows the image + download + back). - 5 new tests (34 total, all green); verified Fit/1:1/hidden-toggle states in a real browser.
This commit is contained in:
@@ -66,7 +66,9 @@ to a safe basename (no path traversal).
|
||||
`chart.png`, `report.css` — resolve out of the same folder). Build whatever
|
||||
page you want.
|
||||
- **No `index.html`?** → **auto-gallery** of the folder's media:
|
||||
- images (`png jpg jpeg gif webp avif svg bmp`) → `<img>`
|
||||
- images (`png jpg jpeg gif webp avif svg bmp`) → `<img>` (click → full-screen
|
||||
viewer with **Fit** / **1:1** — the toggle only appears when the image is
|
||||
larger than the viewport — plus download and ✕/Esc back to the gallery)
|
||||
- video (`webm mp4 ogv m4v mov`) → `<video controls>`
|
||||
- audio (`mp3 wav ogg flac m4a opus aac`) → `<audio controls>`
|
||||
- anything else → a download link
|
||||
|
||||
@@ -349,6 +349,31 @@ def create_app(
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/b/{name}/view", response_class=HTMLResponse)
|
||||
def booth_image_view(request: Request, name: str, f: str):
|
||||
booth = resolve_booth(name)
|
||||
try:
|
||||
target = (booth / f).resolve()
|
||||
except OSError:
|
||||
raise HTTPException(status_code=404, detail="no such file")
|
||||
if not str(target).startswith(str(booth) + os.sep) or not target.is_file():
|
||||
raise HTTPException(status_code=404, detail="no such file")
|
||||
file_url = quote(f, safe="/")
|
||||
if classify(target.name) != "image":
|
||||
# nothing to zoom on a non-image — hand back the raw file
|
||||
return RedirectResponse(url=f"/b/{quote(name, safe='')}/{file_url}", status_code=307)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"view.html",
|
||||
{
|
||||
**base_ctx,
|
||||
"name": name,
|
||||
"name_url": quote(name, safe=""),
|
||||
"file": f,
|
||||
"file_url": file_url,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/b/{name}/{filepath:path}")
|
||||
def booth_file(name: str, filepath: str):
|
||||
booth = resolve_booth(name)
|
||||
|
||||
@@ -115,6 +115,31 @@
|
||||
.dl-link:hover{color:var(--aus-cyan)}
|
||||
.cap-text{color:var(--fg-2)}
|
||||
|
||||
/* ---- image viewer (fixed full-viewport overlay) ---- */
|
||||
.viewer{position:fixed;inset:0;z-index:50;background:var(--rk-canvas);display:flex;flex-direction:column}
|
||||
.vbar{display:flex;align-items:center;gap:.7rem;padding:.5rem .8rem;
|
||||
border-bottom:1px solid var(--border-subtle);background:var(--rk-panel)}
|
||||
.vname{font-family:var(--font-mono);font-size:.8rem;color:var(--fg-2);
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:50vw}
|
||||
.vspacer{flex:1}
|
||||
.vbtn{display:inline-flex;align-items:center;justify-content:center;min-width:2rem;height:2rem;
|
||||
padding:0 .55rem;border:1px solid var(--border-default);border-radius:var(--radius-md);
|
||||
color:var(--fg-1);background:transparent;font-family:var(--font-mono);font-size:.9rem;
|
||||
transition:.14s var(--ease-out)}
|
||||
.vbtn:hover{border-color:var(--aus-cyan);color:var(--aus-bright-cyan);text-decoration:none}
|
||||
.vx:hover{border-color:var(--aus-red);color:#fff;background:var(--aus-red)}
|
||||
.vtoggle{border:1px solid var(--border-default);border-radius:var(--radius-md);overflow:hidden}
|
||||
.vseg{cursor:pointer;border:0;background:transparent;color:var(--fg-3);font-family:var(--font-mono);
|
||||
font-size:.72rem;letter-spacing:.06em;padding:.4rem .75rem;transition:.14s var(--ease-out)}
|
||||
.vseg+.vseg{border-left:1px solid var(--border-default)}
|
||||
.vseg:hover{color:var(--fg-1)}
|
||||
.vseg.on{background:var(--aus-bright-cyan);color:var(--fg-on-accent)}
|
||||
.vstage{flex:1;min-height:0;background:var(--rk-deep)}
|
||||
.vstage.fit{display:flex;align-items:center;justify-content:center;overflow:hidden;padding:1rem}
|
||||
.vstage.fit img{max-width:100%;max-height:100%;width:auto;height:auto;box-shadow:var(--shadow-3)}
|
||||
.vstage.one{overflow:auto;text-align:center}
|
||||
.vstage.one img{max-width:none;max-height:none;margin:auto}
|
||||
|
||||
.foot{border-top:1px solid var(--border-subtle);color:var(--fg-muted);
|
||||
font-size:.72rem;font-family:var(--font-mono);letter-spacing:.04em;padding:1rem 1.5rem;text-align:center}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
{% for it in items %}
|
||||
<figure class="item item-{{ it.kind }}">
|
||||
{% if it.kind == 'image' %}
|
||||
<a href="{{ it.url }}" target="_blank" rel="noopener"><img loading="lazy" src="{{ it.url }}" alt="{{ it.name }}"></a>
|
||||
<a href="view?f={{ it.url }}"><img loading="lazy" src="{{ it.url }}" alt="{{ it.name }}"></a>
|
||||
{% elif it.kind == 'video' %}
|
||||
<video controls preload="metadata" src="{{ it.url }}"></video>
|
||||
{% elif it.kind == 'audio' %}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ file }} · {{ name }} · The Booth{% endblock %}
|
||||
{% block content %}
|
||||
<div class="viewer">
|
||||
<div class="vbar">
|
||||
<a class="vbtn vx" href="/b/{{ name_url }}/" title="back to gallery (Esc)">✕</a>
|
||||
<span class="vname">{{ file }}</span>
|
||||
<span class="vspacer"></span>
|
||||
<span class="vtoggle" id="vtoggle" style="display:none">
|
||||
<button type="button" class="vseg on" id="btn-fit">Fit</button><button type="button" class="vseg" id="btn-one">1:1</button>
|
||||
</span>
|
||||
<a class="vbtn" href="{{ file_url }}" download title="download {{ file }}">⬇</a>
|
||||
</div>
|
||||
<div class="vstage fit" id="vstage"><img id="vimg" src="{{ file_url }}" alt="{{ file }}"></div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var img = document.getElementById('vimg');
|
||||
var stage = document.getElementById('vstage');
|
||||
var toggle = document.getElementById('vtoggle');
|
||||
var bFit = document.getElementById('btn-fit');
|
||||
var bOne = document.getElementById('btn-one');
|
||||
var BACK = {{ ('/b/' ~ name_url ~ '/')|tojson }};
|
||||
|
||||
function setMode(mode) {
|
||||
var fit = mode === 'fit';
|
||||
stage.classList.toggle('fit', fit);
|
||||
stage.classList.toggle('one', !fit);
|
||||
bFit.classList.toggle('on', fit);
|
||||
bOne.classList.toggle('on', !fit);
|
||||
}
|
||||
// "fits" == the image at natural size already sits inside the stage, so Fit
|
||||
// and 1:1 would render identically — in that case we hide the toggle entirely.
|
||||
function fits() {
|
||||
return img.naturalWidth <= stage.clientWidth && img.naturalHeight <= stage.clientHeight;
|
||||
}
|
||||
function evaluate() {
|
||||
if (!img.naturalWidth) return;
|
||||
if (fits()) {
|
||||
toggle.style.display = 'none';
|
||||
setMode('fit');
|
||||
} else {
|
||||
toggle.style.display = 'inline-flex';
|
||||
if (!stage.classList.contains('one')) setMode('fit');
|
||||
}
|
||||
}
|
||||
bFit.addEventListener('click', function () { setMode('fit'); });
|
||||
bOne.addEventListener('click', function () { setMode('one'); });
|
||||
img.addEventListener('load', evaluate);
|
||||
window.addEventListener('resize', evaluate);
|
||||
if (img.complete) evaluate();
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') window.location.href = BACK;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "booth"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
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 = [
|
||||
|
||||
@@ -286,3 +286,43 @@ def test_upload_dedupes_repeated_names(client):
|
||||
pid = r.headers["location"].split("/b/")[1].rstrip("/")
|
||||
names = sorted(p.name for p in (data / pid).iterdir() if not p.name.startswith("."))
|
||||
assert names == ["shot-1.png", "shot.png"]
|
||||
|
||||
|
||||
# ---- image viewer -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_image_view_renders(client):
|
||||
c, data = client
|
||||
_touch(data / "run1" / "shot.png")
|
||||
r = c.get("/b/run1/view", params={"f": "shot.png"})
|
||||
assert r.status_code == 200
|
||||
assert 'id="vstage"' in r.text # the viewer stage
|
||||
assert ">Fit<" in r.text and ">1:1<" in r.text
|
||||
assert "shot.png" in r.text
|
||||
|
||||
|
||||
def test_gallery_image_links_to_viewer(client):
|
||||
c, data = client
|
||||
_touch(data / "run1" / "shot.png")
|
||||
page = c.get("/b/run1/")
|
||||
assert "view?f=shot.png" in page.text # gallery routes images to the viewer, not the raw file
|
||||
|
||||
|
||||
def test_image_view_missing_404(client):
|
||||
c, data = client
|
||||
(data / "run1").mkdir()
|
||||
assert c.get("/b/run1/view", params={"f": "nope.png"}).status_code == 404
|
||||
|
||||
|
||||
def test_image_view_traversal_404(client):
|
||||
c, data = client
|
||||
(data / "run1").mkdir()
|
||||
assert c.get("/b/run1/view", params={"f": "../../etc/passwd"}).status_code == 404
|
||||
|
||||
|
||||
def test_image_view_nonimage_redirects_to_raw(client):
|
||||
c, data = client
|
||||
_touch(data / "run1" / "notes.txt")
|
||||
r = c.get("/b/run1/view", params={"f": "notes.txt"}, follow_redirects=False)
|
||||
assert r.status_code == 307
|
||||
assert r.headers["location"] == "/b/run1/notes.txt"
|
||||
|
||||
Reference in New Issue
Block a user