diff --git a/README.md b/README.md
index 810a906..048bc30 100644
--- a/README.md
+++ b/README.md
@@ -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`) → ` `
+ - images (`png jpg jpeg gif webp avif svg bmp`) → ` ` (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`) → ``
- audio (`mp3 wav ogg flac m4a opus aac`) → ``
- anything else → a download link
diff --git a/booth/app.py b/booth/app.py
index df9575f..066f709 100644
--- a/booth/app.py
+++ b/booth/app.py
@@ -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)
diff --git a/booth/templates/base.html b/booth/templates/base.html
index 82cbc9f..15b842c 100644
--- a/booth/templates/base.html
+++ b/booth/templates/base.html
@@ -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}
diff --git a/booth/templates/booth.html b/booth/templates/booth.html
index cdb2866..d6184ad 100644
--- a/booth/templates/booth.html
+++ b/booth/templates/booth.html
@@ -24,7 +24,7 @@
{% for it in items %}
{% if it.kind == 'image' %}
-
+
{% elif it.kind == 'video' %}
{% elif it.kind == 'audio' %}
diff --git a/booth/templates/view.html b/booth/templates/view.html
new file mode 100644
index 0000000..28fe915
--- /dev/null
+++ b/booth/templates/view.html
@@ -0,0 +1,57 @@
+{% extends "base.html" %}
+{% block title %}{{ file }} · {{ name }} · The Booth{% endblock %}
+{% block content %}
+
+
+
✕
+
{{ file }}
+
+
+ Fit 1:1
+
+
⬇
+
+
+
+
+{% endblock %}
diff --git a/pyproject.toml b/pyproject.toml
index ce3375d..d7771de 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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 = [
diff --git a/tests/test_booth.py b/tests/test_booth.py
index 658b2dd..0aa9e6a 100644
--- a/tests/test_booth.py
+++ b/tests/test_booth.py
@@ -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"