feat(r2): C4 the Desk — the index triaged by what needs the operator
- list_booths gains open_since (parsed, never compared as text), flags, landed_at (content only; a new, differently named clock, INV-5), viewed_at, and a four-image preview that keeps blur. - The index renders needs you / new since you looked / everything else, always in that order. Needs you includes unreadable marks, so a damaged judgment file cannot hide. Everything else keeps list_booths' order rather than stating a second rule. An empty section renders nothing. - The side column holds live benches (a damaged registry says so), bookmarks from BOOTH_LINKS_BOARD with booth URLs left out (capped at 8), and the pickup form. - test_booth's kept-lane test is rewritten as the contract declared: kept is a fact on each row, not a lane. Two of the new tests were VACUOUS on their first draft, and mutation- checking caught both. The clocks test used a future t0, so a hand-set marker outranked every real write. The look-then-judge test followed the flag's 303, and the resulting GET recorded a fresh look. Both are fixed and now go red under their mutation.
This commit is contained in:
+111
-7
@@ -41,11 +41,13 @@ import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
from urllib.parse import quote, unquote
|
||||
@@ -254,6 +256,56 @@ def _newest_mtime(path: Path) -> float:
|
||||
return newest
|
||||
|
||||
|
||||
def _content_mtime(path: Path) -> float:
|
||||
"""`landed_at` (R2 C4): the newest mtime among the booth's CONTENT — regular
|
||||
files with no dot-component in their path. Deliberately NOT `_newest_mtime`
|
||||
(INV-5 of r2): a mark, a view, a blur or a keep is activity, never new
|
||||
content, so none of them may make a booth read as newly landed.
|
||||
|
||||
Files only, never directories: creating `.viewed` bumps the booth
|
||||
directory's own mtime, and counting that would make the first look at a
|
||||
booth look like a delivery. An empty booth landed at 0.0. Unknowable reads
|
||||
as NOW, the posture `_newest_mtime` takes and for a milder reason here: a
|
||||
booth we cannot read is shown as new rather than hidden as old.
|
||||
"""
|
||||
newest = 0.0
|
||||
try:
|
||||
for p in path.rglob("*"):
|
||||
rel = p.relative_to(path)
|
||||
if any(part.startswith(".") for part in rel.parts):
|
||||
continue
|
||||
try:
|
||||
st = p.stat()
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if stat.S_ISREG(st.st_mode) and st.st_mtime > newest:
|
||||
newest = st.st_mtime
|
||||
except OSError:
|
||||
return time.time()
|
||||
return newest
|
||||
|
||||
|
||||
def _viewed_at(path: Path) -> float | None:
|
||||
"""The mtime of the booth's `.viewed` marker (U4), or None if it has never
|
||||
been looked at. `lstat`, like `is_kept`: a planted symlink is read as the
|
||||
marker it claims to be, never followed."""
|
||||
try:
|
||||
return os.lstat(path / VIEW_MARKER).st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _stamp(created: str) -> datetime | None:
|
||||
"""A mark's `created` as an aware datetime, or None when it will not parse.
|
||||
Strings are never compared: two ISO stamps with different offsets sort
|
||||
wrong as text. A naive stamp is read as UTC."""
|
||||
try:
|
||||
dt = datetime.fromisoformat(created)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def booth_age_seconds(path: Path, now: float | None = None) -> float:
|
||||
now = time.time() if now is None else now
|
||||
return now - _newest_mtime(path)
|
||||
@@ -384,6 +436,9 @@ def wants_json(accept: str | None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# The Desk shows this many bookmarks and links to the board for the rest.
|
||||
BOOKMARKS_SHOWN = 8
|
||||
|
||||
HOLD_UNREADABLE = "unreadable"
|
||||
HOLD_OPEN = "open"
|
||||
|
||||
@@ -529,6 +584,10 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
|
||||
# rather than re-opening .blurred here.
|
||||
thumb_blurred = it.blurred
|
||||
mtime = _newest_mtime(child)
|
||||
# R2 C4: the oldest question still owed an answer, PARSED. Unparseable
|
||||
# stamps are left out, so a booth whose every open pick is unparseable
|
||||
# has no `open_since` and sorts after every booth that has one.
|
||||
stamps = [st for st in (_stamp(m.created) for m in open_marks(marks)) if st]
|
||||
booths.append(
|
||||
{
|
||||
"name": child.name,
|
||||
@@ -555,6 +614,17 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
|
||||
"hold": hold,
|
||||
"expires_in": max(0.0, ttl_seconds - (now - mtime)),
|
||||
"mtime": mtime,
|
||||
# ---- R2 C4, the Desk. All from the pass above; no second read.
|
||||
"open_since": min(stamps) if stamps else None,
|
||||
"flags": sum(1 for m in marks if m.shape == "flag"),
|
||||
# Two clocks, named apart (INV-5): `mtime` is activity,
|
||||
# `landed_at` is content. "New since you looked" reads only the
|
||||
# second, so a flag or a view never makes a booth look new.
|
||||
"landed_at": _content_mtime(child),
|
||||
"viewed_at": _viewed_at(child),
|
||||
# The first four images in item order, as the originals shown
|
||||
# small. Blurred ones stay blurred, the cover's rule.
|
||||
"preview": [(it.url, it.blurred) for it in items if it.kind == "image"][:4],
|
||||
}
|
||||
)
|
||||
# Newest first, NAME as the tie-break. Sorting on mtime alone left equal-mtime
|
||||
@@ -779,6 +849,7 @@ def create_app(
|
||||
sweep_interval_s: int = 900,
|
||||
max_upload_mb: float = 1024.0,
|
||||
max_files: int = 50,
|
||||
links_board: str = "links",
|
||||
) -> FastAPI:
|
||||
data_dir = Path(data_dir).expanduser().resolve()
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -889,19 +960,49 @@ def create_app(
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request):
|
||||
# Two lanes, split here rather than in the template: kept boards are a
|
||||
# different KIND of thing from the ephemeral churn — durable, deliberate,
|
||||
# operator-facing — and burying them in a feed that turns over daily is
|
||||
# exactly how they would get lost, which is the problem they exist to
|
||||
# solve. Kept renders first.
|
||||
"""THE DESK (R2 C4) — the index triaged by what needs the operator.
|
||||
|
||||
Three sections, ALWAYS in this order, each booth in exactly one:
|
||||
needs — an open pick, or marks that cannot be read (somebody has to
|
||||
fix those, so they must not hide further down). Oldest open
|
||||
question first; a booth with no parseable stamp after every
|
||||
booth that has one; name breaks ties.
|
||||
new — content landed since the booth was last looked at, or never
|
||||
looked at. Newest content first; name breaks ties.
|
||||
rest — everything else, in `list_booths`' own order (last activity
|
||||
first, name as the tie-break). No second rule is stated.
|
||||
The kept/ephemeral lanes are gone: 23 of 24 live booths were kept, so
|
||||
the lanes sorted nothing. Kept status still shows on every row.
|
||||
"""
|
||||
everything = list_booths(data_dir, ttl_seconds)
|
||||
needs = [b for b in everything
|
||||
if b["marks_open"] > 0 or b["hold"] == HOLD_UNREADABLE]
|
||||
needs.sort(key=lambda b: ((0, b["open_since"].timestamp())
|
||||
if b["open_since"] else (1, 0.0), b["name"]))
|
||||
in_needs = {b["name"] for b in needs}
|
||||
new = [b for b in everything if b["name"] not in in_needs
|
||||
and (b["viewed_at"] is None or b["landed_at"] > b["viewed_at"])]
|
||||
new.sort(key=lambda b: (-b["landed_at"], b["name"]))
|
||||
in_new = {b["name"] for b in new}
|
||||
rest = [b for b in everything
|
||||
if b["name"] not in in_needs and b["name"] not in in_new]
|
||||
benches, benches_error = read_benches(data_dir)
|
||||
board = data_dir / links_board
|
||||
bookmarks = [row for row in _board_rows(board)
|
||||
if booth_target(row["url"]) is None] if board.is_dir() else []
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"index.html",
|
||||
{
|
||||
**base_ctx,
|
||||
"kept": [b for b in everything if b["kept"]],
|
||||
"booths": [b for b in everything if not b["kept"]],
|
||||
"needs": needs,
|
||||
"new": new,
|
||||
"rest": rest,
|
||||
"benches": [b for b in benches if b.state != "retired"],
|
||||
"benches_error": benches_error,
|
||||
"bookmarks": bookmarks[:BOOKMARKS_SHOWN],
|
||||
"bookmarks_total": len(bookmarks),
|
||||
"board_url": f"/b/{quote(links_board, safe='')}/",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1875,6 +1976,9 @@ def _from_env() -> FastAPI:
|
||||
sweep_interval_s=interval,
|
||||
max_upload_mb=max_mb,
|
||||
max_files=max_n,
|
||||
# The board the CLI's `booth link` writes (scripts/booth reads the same
|
||||
# variable), so the Desk's bookmarks come from where they are written.
|
||||
links_board=os.environ.get("BOOTH_LINKS_BOARD", "links"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -157,6 +157,87 @@
|
||||
box-shadow:var(--shadow-sm)}
|
||||
.thumb .badge+.badge-mark{top:36px}
|
||||
|
||||
/* ---- THE DESK (R2 C4) -------------------------------------------------
|
||||
The index as triage. Sections stack in a fixed order; a row is a booth.
|
||||
"Needs you" rows carry an amber inner edge — the one thing on the page
|
||||
that asks to be looked at. */
|
||||
.desk{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:var(--space-6);align-items:start}
|
||||
@media (max-width:1000px){.desk{grid-template-columns:1fr}}
|
||||
.desk-sec{margin:0 0 var(--space-6)}
|
||||
.desk-head{display:flex;align-items:center;gap:10px;margin:0 0 10px;font-family:var(--font-mono);
|
||||
font-size:var(--size-micro);font-weight:500;letter-spacing:var(--tracking-caps);text-transform:uppercase;
|
||||
color:var(--text-muted)}
|
||||
.desk-head::after{content:"";flex:1;height:1px;background:var(--border-subtle)}
|
||||
.desk-head-needs{color:var(--warning-text)}
|
||||
.desk-head-new{color:var(--intel-text)}
|
||||
.desk-rule{font-weight:400;letter-spacing:0;text-transform:none;color:var(--text-muted)}
|
||||
.desk-row{display:grid;grid-template-columns:210px minmax(0,1fr) auto;gap:var(--space-4);align-items:center;
|
||||
padding:12px;margin-bottom:8px;border:1px solid var(--border-default);border-radius:var(--radius-xl);
|
||||
background:var(--surface-card);box-shadow:var(--shadow-sm);
|
||||
transition:border-color var(--dur-2) var(--ease-out)}
|
||||
.desk-row:hover,.desk-row:focus-within{border-color:var(--border-strong)}
|
||||
.desk-row.is-needs{box-shadow:inset 3px 0 0 var(--warning),var(--shadow-sm)}
|
||||
.desk-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:3px;height:58px;border-radius:var(--radius-md);
|
||||
overflow:hidden;background:var(--surface-sunken)}
|
||||
.desk-strip img{width:100%;height:100%;object-fit:cover;display:block}
|
||||
.desk-strip .ph{grid-column:1/-1;display:flex;align-items:center;justify-content:center;font-family:var(--font-mono);
|
||||
font-size:var(--size-caption);letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-muted)}
|
||||
.desk-strip:hover{text-decoration:none}
|
||||
.desk-main{min-width:0}
|
||||
.desk-title{display:block;font-weight:600;font-size:var(--size-h3);line-height:1.3;color:var(--text-heading);
|
||||
overflow-wrap:anywhere}
|
||||
.desk-title:hover{color:var(--text-link);text-decoration:none}
|
||||
.desk-slug{font-family:var(--font-mono);font-size:var(--size-caption);font-weight:400;color:var(--text-muted);margin-left:6px}
|
||||
.desk-main .prov{margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.desk-facts{margin-top:5px;font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-muted)}
|
||||
.desk-flags{color:var(--success-text)}
|
||||
.desk-side{display:flex;flex-direction:column;align-items:flex-end;gap:8px}
|
||||
.badge-new{color:var(--intel-text);background:var(--intel-soft)}
|
||||
.badge-broken{color:var(--danger-text);background:var(--danger-soft);border-color:color-mix(in oklab,var(--danger) 40%,transparent)}
|
||||
/* The row's keep / release / × — quiet until the row is hovered or focused,
|
||||
because a destructive control must not compete with the thing you came to
|
||||
read. Static here: on the old cards they floated over a thumbnail. */
|
||||
.desk-acts{display:flex;gap:6px;opacity:0;transition:opacity var(--dur-1)}
|
||||
.desk-row:hover .desk-acts,.desk-acts:focus-within{opacity:1}
|
||||
.desk-acts form{position:static;opacity:1;margin:0}
|
||||
.desk-acts button{height:28px;min-width:28px;padding:0 8px;font-size:var(--size-sm);border-radius:var(--radius-md);
|
||||
border:1px solid var(--border-strong);background:var(--surface-raised);color:var(--text-body);
|
||||
-webkit-backdrop-filter:none;backdrop-filter:none;cursor:pointer}
|
||||
.desk-acts .release button{font-family:var(--font-mono);font-size:var(--size-caption)}
|
||||
.desk-acts .wipe button:hover{background:var(--danger);border-color:var(--danger);color:var(--danger-contrast)}
|
||||
.desk-acts .keepit button:hover,.desk-acts .release button:hover{background:var(--surface-overlay)}
|
||||
@media (max-width:700px){
|
||||
.desk-row{grid-template-columns:1fr}
|
||||
.desk-side{flex-direction:row;align-items:center;justify-content:space-between}
|
||||
.desk-acts{opacity:1}
|
||||
}
|
||||
/* the side column */
|
||||
.desk-panel{border:1px solid var(--border-default);border-radius:var(--radius-xl);background:var(--surface-card);
|
||||
overflow:hidden;margin-bottom:var(--space-4);box-shadow:var(--shadow-sm)}
|
||||
.desk-panel-head{display:flex;align-items:baseline;gap:8px;margin:0;padding:10px 14px;background:var(--surface-raised);
|
||||
border-bottom:1px solid var(--border-subtle);font-family:var(--font-mono);font-size:var(--size-micro);font-weight:500;
|
||||
letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-heading)}
|
||||
.desk-panel-head .desk-rule{margin-left:auto}
|
||||
.desk-bench,.desk-mark{display:flex;align-items:center;gap:10px;padding:9px 14px;border-bottom:1px solid var(--border-subtle);
|
||||
font-size:var(--size-sm);color:var(--text-heading)}
|
||||
.desk-bench:last-child,.desk-mark:last-of-type{border-bottom:0}
|
||||
.desk-bench:hover,.desk-mark:hover{background:var(--surface-sunken);text-decoration:none}
|
||||
.desk-mark{display:block;color:var(--text-link)}
|
||||
.desk-mark.is-pinned{background:var(--surface-overlay)}
|
||||
.desk-bench-dot{flex:0 0 auto;width:6px;height:6px;border-radius:var(--radius-pill);background:var(--text-muted)}
|
||||
/* Device 3: a live bench is a running thing — the one row that glows. */
|
||||
.desk-bench.is-live .desk-bench-dot{background:var(--accent);box-shadow:var(--glow-armed)}
|
||||
.desk-bench.is-promoted .desk-bench-dot{background:var(--intel)}
|
||||
.desk-bench-main{display:flex;flex-direction:column;min-width:0}
|
||||
.desk-bench-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.desk-bench-sub{display:block;font-family:var(--font-mono);font-size:var(--size-micro);color:var(--text-muted)}
|
||||
.desk-more{display:block;padding:9px 14px;border-top:1px solid var(--border-subtle);font-family:var(--font-mono);
|
||||
font-size:var(--size-caption)}
|
||||
.desk-panel .uploader{flex-direction:column;margin:0;padding:12px}
|
||||
.desk-panel .drop{flex:1 1 auto;padding:14px 12px}
|
||||
.desk-panel .up-go{padding:10px 14px;border-radius:var(--radius-lg)}
|
||||
.desk-panel .drop.has+.up-go{padding:10px 14px;border-radius:var(--radius-lg)}
|
||||
|
||||
/* ---- index: lanes and cards ------------------------------------------ */
|
||||
.lane-head{margin:28px 0 12px;font-family:var(--font-mono);font-size:var(--size-micro);font-weight:500;
|
||||
letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-muted);
|
||||
|
||||
+153
-142
@@ -1,136 +1,155 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_provenance.html" import provenance %}
|
||||
{% from "_lifetime.html" import lifetime %}
|
||||
{% block content %}
|
||||
<form class="uploader" method="post" action="/upload" enctype="multipart/form-data">
|
||||
<label class="drop" for="booth-files">
|
||||
<span class="drop-icon">⬆</span>
|
||||
<span class="drop-main">Upload files for pickup</span>
|
||||
<span class="drop-sub" id="drop-sub">drop here, or click to choose · one pickup id, wiped in {{ ttl_hours }}h</span>
|
||||
<input id="booth-files" name="files" type="file" multiple>
|
||||
</label>
|
||||
<button class="up-go" type="submit">Get pickup id →</button>
|
||||
</form>
|
||||
{# THE DESK (R2 C4). The index triaged by what needs the operator: needs you,
|
||||
then new since you looked, then everything else — always in that order, and
|
||||
the ORDER WITHIN each is decided in app.index, never here. A section with no
|
||||
booths renders nothing at all: no heading, no empty box (the negative half
|
||||
of the kept-lane pair this replaces). #}
|
||||
|
||||
{% if kept %}
|
||||
{# Kept boards render FIRST and look different on purpose: they are durable
|
||||
operator-facing things (the agent link board, standing reports) and the
|
||||
point of the lane is that they cannot be lost in a feed that turns over
|
||||
every day. No countdown — they have no expiry to advertise. #}
|
||||
<h2 class="lane-head">Kept <span class="lane-note">· no expiry · <code>{{ keep_marker }}</code></span></h2>
|
||||
<div class="grid kept-grid">
|
||||
{% for b in kept %}
|
||||
<article class="card card-kept">
|
||||
<a class="thumb" href="/b/{{ b.name_url }}/">
|
||||
{% if b.thumb_url %}
|
||||
{# A cover blurred inside the booth must be blurred here too, or the
|
||||
front page undoes the censoring the booth page applied. #}
|
||||
<img class="{{ 'blurred-thumb' if b.thumb_blurred }}" loading="lazy"
|
||||
src="/b/{{ b.name_url }}/{{ b.thumb_url }}" alt="">
|
||||
{% elif b.has_index %}
|
||||
<div class="ph">▦ page</div>
|
||||
{% elif b.kinds.video %}
|
||||
<div class="ph">▶ video</div>
|
||||
{% elif b.kinds.audio %}
|
||||
<div class="ph">♪ audio</div>
|
||||
{% else %}
|
||||
<div class="ph">◆ files</div>
|
||||
{% endif %}
|
||||
<span class="badge badge-kept">★ kept</span>
|
||||
</a>
|
||||
<div class="meta">
|
||||
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
||||
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · {{ lifetime(true, b.hold, b.expires_in) }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
|
||||
{{ provenance(b.manifest) }}
|
||||
</div>
|
||||
{# There IS a × here now (operator, 2026-09-21). The old rule was
|
||||
release-then-find-it-in-the-other-lane, on the theory that two
|
||||
deliberate acts protect durable boards. In practice it protects
|
||||
nothing and costs a hunt: the board you just released is loose in a
|
||||
feed that turns over, and you have to go find it to finish the job
|
||||
you had already decided on.
|
||||
|
||||
The protection now lives in the CONFIRMATION, not in the number of
|
||||
lanes you must traverse — this one names the booth and says the word
|
||||
KEPT, where the ephemeral × just asks. A deliberate act, one click,
|
||||
reachable.
|
||||
|
||||
Release still exists and is still the reversible option. Note it
|
||||
BUMPS the directory mtime, so the board's age resets and it survives
|
||||
another full TTL — unkeep-and-wait is a 24h delay, not a delete,
|
||||
which is exactly why a direct × was worth adding. #}
|
||||
{# ⚠ BOTH OF THESE WERE position:absolute ON THE SAME CORNER, and `release`
|
||||
is the later sibling, so it painted over the × completely: measured
|
||||
30x22 px of overlap on a 30px button, and elementFromPoint at the ×'s
|
||||
centre returned the release form. The × was unclickable from the day
|
||||
it shipped.
|
||||
|
||||
One flex row, positioned once, instead of two independently guessed
|
||||
offsets — so neither control can drift back on top of the other when
|
||||
a label changes width. #}
|
||||
<div class="kept-actions">
|
||||
<form class="release" method="post" action="/b/{{ b.name_url }}/unkeep"
|
||||
data-booth="{{ b.name }}" data-confirm="release">
|
||||
<button title="release this board so it can be wiped">release</button>
|
||||
</form>
|
||||
<form class="wipe wipe-kept" method="post" action="/b/{{ b.name_url }}/delete"
|
||||
data-booth="{{ b.name }}" data-confirm="wipe-kept">
|
||||
<button title="wipe this KEPT booth now" aria-label="wipe kept booth">×</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{# The first four images, the originals shown small. A blurred one stays
|
||||
blurred (`blurred-thumb`, the cover's rule). A booth with no images shows the
|
||||
kind placeholder the cards used to. #}
|
||||
{% macro preview(b) -%}
|
||||
<a class="desk-strip" href="/b/{{ b.name_url }}/" tabindex="-1" aria-hidden="true">
|
||||
{% if b.preview %}
|
||||
{% for url, blurred in b.preview %}
|
||||
<img class="{{ 'blurred-thumb' if blurred }}" loading="lazy" src="/b/{{ b.name_url }}/{{ url }}" alt="">
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if booths %}<h2 class="lane-head">Ephemeral <span class="lane-note">· wiped {{ ttl_hours }}h after last activity</span></h2>{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if not booths %}
|
||||
{% if not kept %}
|
||||
<div class="empty">
|
||||
No booths yet. Upload files above, or drop a folder into <code>{{ data_dir }}</code>.
|
||||
</div>
|
||||
{% elif b.has_index %}<span class="ph">▦ page</span>
|
||||
{% elif b.kinds.video %}<span class="ph">▶ video</span>
|
||||
{% elif b.kinds.audio %}<span class="ph">♪ audio</span>
|
||||
{% else %}<span class="ph">◆ files</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="grid">
|
||||
{% for b in booths %}
|
||||
<article class="card">
|
||||
<a class="thumb" href="/b/{{ b.name_url }}/">
|
||||
{% if b.thumb_url %}
|
||||
<img class="{{ 'blurred-thumb' if b.thumb_blurred }}" loading="lazy"
|
||||
src="/b/{{ b.name_url }}/{{ b.thumb_url }}" alt="">
|
||||
{% elif b.has_index %}
|
||||
<div class="ph">▦ page</div>
|
||||
{% elif b.kinds.video %}
|
||||
<div class="ph">▶ video</div>
|
||||
{% elif b.kinds.audio %}
|
||||
<div class="ph">♪ audio</div>
|
||||
{% else %}
|
||||
<div class="ph">◆ files</div>
|
||||
{% endif %}
|
||||
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
|
||||
{% if b.marks_open %}<span class="badge badge-mark">? {{ b.marks_open }} open</span>{% endif %}
|
||||
</a>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro row(b, section) -%}
|
||||
<article class="desk-row{% if section == 'needs' %} is-needs{% endif %}" data-booth="{{ b.name }}" data-kept="{{ '1' if b.kept else '0' }}">
|
||||
{{ preview(b) }}
|
||||
<div class="desk-main">
|
||||
{# The manifest title leads when there is one; the directory name stays
|
||||
beside it because it is what the URL says. #}
|
||||
<a class="desk-title" href="/b/{{ b.name_url }}/">
|
||||
{%- if b.manifest and not b.manifest.error and b.manifest.title and b.manifest.title != b.name -%}
|
||||
{{ b.manifest.title }} <span class="desk-slug">{{ b.name }}</span>
|
||||
{%- else -%}{{ b.name }}{%- endif -%}
|
||||
</a>
|
||||
<div class="meta">
|
||||
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
||||
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · {{ lifetime(false, b.hold, b.expires_in) }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
|
||||
{{ provenance(b.manifest) }}
|
||||
{{ provenance(b.manifest) }}
|
||||
<div class="desk-facts">
|
||||
{{ b.count }} item{{ '' if b.count == 1 else 's' }}
|
||||
{% if b.flags %} · <span class="desk-flags">{{ b.flags }} flagged</span>{% endif %}
|
||||
· {{ lifetime(b.kept, b.hold, b.expires_in) }}
|
||||
· <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>
|
||||
</div>
|
||||
{# Promote to the kept lane. The /keep route and the `booth keep` CLI verb
|
||||
both predate this button; until 2026-09-19 the UI could only RELEASE a
|
||||
kept booth, never keep an ephemeral one, so the round trip was only
|
||||
closed if you had a shell. Reversible, so no confirmation — the × next
|
||||
to it is the destructive one and keeps its prompt. #}
|
||||
<form class="keepit" method="post" action="/b/{{ b.name_url }}/keep">
|
||||
<button title="keep — exempt from the {{ ttl_hours }}h sweep" aria-label="keep booth">★</button>
|
||||
</form>
|
||||
<form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
|
||||
data-booth="{{ b.name }}" data-confirm="wipe">
|
||||
<button title="wipe now" aria-label="wipe booth">×</button>
|
||||
</form>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="desk-side">
|
||||
{% if b.marks_open %}<span class="badge badge-mark">? {{ b.marks_open }} open</span>
|
||||
{% elif b.hold == "unreadable" %}<span class="badge badge-broken">marks unreadable</span>
|
||||
{% elif section == 'new' %}<span class="badge badge-new">new</span>{% endif %}
|
||||
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
|
||||
<div class="desk-acts">
|
||||
{# Keep / release and the ×. The confirmation text is DATA-DRIVEN: the
|
||||
booth name travels as a data attribute and never reaches a JS string
|
||||
(see the script below). Release is reversible, so it has no prompt of
|
||||
its own beyond the wording. #}
|
||||
{% if b.kept %}
|
||||
<form class="release" method="post" action="/b/{{ b.name_url }}/unkeep"
|
||||
data-booth="{{ b.name }}" data-confirm="release">
|
||||
<button title="release this booth so it can be wiped">release</button>
|
||||
</form>
|
||||
<form class="wipe wipe-kept" method="post" action="/b/{{ b.name_url }}/delete"
|
||||
data-booth="{{ b.name }}" data-confirm="wipe-kept">
|
||||
<button title="wipe this KEPT booth now" aria-label="wipe kept booth">×</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form class="keepit" method="post" action="/b/{{ b.name_url }}/keep">
|
||||
<button title="keep — exempt from the {{ ttl_hours }}h sweep" aria-label="keep booth">★</button>
|
||||
</form>
|
||||
<form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
|
||||
data-booth="{{ b.name }}" data-confirm="wipe">
|
||||
<button title="wipe now" aria-label="wipe booth">×</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{%- endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<div class="desk">
|
||||
<div class="desk-list">
|
||||
{% if needs %}
|
||||
<section class="desk-sec" data-section="needs">
|
||||
<h2 class="desk-head desk-head-needs">Needs you <span class="desk-rule">oldest question first</span></h2>
|
||||
{% for b in needs %}{{ row(b, 'needs') }}{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if new %}
|
||||
<section class="desk-sec" data-section="new">
|
||||
<h2 class="desk-head desk-head-new">New since you looked <span class="desk-rule">newest first</span></h2>
|
||||
{% for b in new %}{{ row(b, 'new') }}{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if rest %}
|
||||
<section class="desk-sec" data-section="rest">
|
||||
<h2 class="desk-head">Everything else <span class="desk-rule">last activity first</span></h2>
|
||||
{% for b in rest %}{{ row(b, 'rest') }}{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if not needs and not new and not rest %}
|
||||
<div class="empty">
|
||||
No booths yet. Drop a folder into <code>{{ data_dir }}</code>, or upload files for pickup.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<aside class="desk-aside">
|
||||
{# Benches: running things. DAMAGED AND ABSENT MUST NOT RENDER THE SAME —
|
||||
an unreadable registry says so; an empty one renders no panel. #}
|
||||
{% if benches_error %}
|
||||
<section class="desk-panel" data-panel="benches">
|
||||
<h2 class="desk-panel-head">Benches</h2>
|
||||
<div class="bench-err">the bench registry could not be read: {{ benches_error }}</div>
|
||||
</section>
|
||||
{% elif benches %}
|
||||
<section class="desk-panel" data-panel="benches">
|
||||
<h2 class="desk-panel-head">Benches <span class="desk-rule">running things</span></h2>
|
||||
{% for b in benches %}
|
||||
<a class="desk-bench is-{{ b.state }}" href="{{ b.url }}" target="_blank" rel="noopener">
|
||||
<span class="desk-bench-dot" aria-hidden="true"></span>
|
||||
<span class="desk-bench-main"><span class="desk-bench-name">{{ b.name or b.url }}</span>
|
||||
<span class="desk-bench-sub">{% if b.owner %}{{ b.owner }} · {% endif %}{{ b.state }}</span></span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if bookmarks %}
|
||||
<section class="desk-panel" data-panel="bookmarks">
|
||||
<h2 class="desk-panel-head">Bookmarks <span class="desk-rule">pinned first</span></h2>
|
||||
{% for e in bookmarks %}
|
||||
<a class="desk-mark{% if e.pinned %} is-pinned{% endif %}" href="{{ e.url }}" target="_blank" rel="noopener">
|
||||
{{ e.desc }}{% if e.who %}<span class="desk-bench-sub">{{ e.who }}</span>{% endif %}</a>
|
||||
{% endfor %}
|
||||
<a class="desk-more" href="{{ board_url }}">all {{ bookmarks_total }} on the board →</a>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="desk-panel" data-panel="pickup">
|
||||
<h2 class="desk-panel-head">Pickup</h2>
|
||||
<form class="uploader" method="post" action="/upload" enctype="multipart/form-data">
|
||||
<label class="drop" for="booth-files">
|
||||
<span class="drop-icon">⬆</span>
|
||||
<span class="drop-main">Upload files for pickup</span>
|
||||
<span class="drop-sub" id="drop-sub">drop here, or click · wiped in {{ ttl_hours }}h</span>
|
||||
<input id="booth-files" name="files" type="file" multiple>
|
||||
</label>
|
||||
<button class="up-go" type="submit">Get pickup id →</button>
|
||||
</form>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* progressive enhancement: reflect chosen files + drag-drop onto the panel.
|
||||
@@ -162,31 +181,23 @@
|
||||
});
|
||||
})();
|
||||
|
||||
/* Destructive-action confirmation, delegated and DATA-DRIVEN.
|
||||
These were an inline onsubmit calling confirm() with the booth NAME
|
||||
interpolated straight into the JS string literal. Jinja's autoescape is
|
||||
HTML-attribute escaping, not JS-string escaping: the browser decodes the
|
||||
entity back to a quote before the JS parser ever sees it, so a booth name
|
||||
crafted to close that string executed on submit. Booth names are
|
||||
agent-authored — making a folder under the data dir is the whole API — so
|
||||
that is a live path, not a theoretical one.
|
||||
|
||||
The name now travels as a DATA ATTRIBUTE, where escaping is escaping, and
|
||||
never reaches a JS string literal. Same pattern the board controls already
|
||||
use. With JS off the form submits without a prompt, which is what every
|
||||
no-JS browser here already did. */
|
||||
/* Destructive-action confirmation, delegated and DATA-DRIVEN. The booth name
|
||||
travels as a data attribute, where escaping is escaping, and never reaches
|
||||
a JS string literal: a booth name is agent-authored, and an inline handler
|
||||
carrying one was a live injection path. With JS off the form submits
|
||||
without a prompt. */
|
||||
(function () {
|
||||
var WORDS = {
|
||||
release: function (n) {
|
||||
return 'Release \u201c' + n + '\u201d?\n\nIt moves to the ephemeral lane so you '
|
||||
+ 'can wipe it from there. Nothing is deleted by this step.';
|
||||
return 'Release “' + n + '”?\n\nIt rejoins the sweep: it will be wiped '
|
||||
+ '{{ ttl_hours|int }}h after its last activity. Nothing is deleted by this step.';
|
||||
},
|
||||
'wipe-kept': function (n) {
|
||||
return 'WIPE the KEPT booth \u201c' + n + '\u201d?\n\nThis deletes it and its files '
|
||||
return 'WIPE the KEPT booth “' + n + '”?\n\nThis deletes it and its files '
|
||||
+ 'immediately. Kept booths are the ones nothing else will clean up, so nobody '
|
||||
+ 'else is going to do this for you \u2014 and nothing brings it back.';
|
||||
+ 'else is going to do this for you — and nothing brings it back.';
|
||||
},
|
||||
wipe: function (n) { return 'Wipe booth \u201c' + n + '\u201d?'; }
|
||||
wipe: function (n) { return 'Wipe booth “' + n + '”?'; }
|
||||
};
|
||||
document.addEventListener('submit', function (ev) {
|
||||
var form = ev.target.closest ? ev.target.closest('form[data-confirm]') : null;
|
||||
|
||||
Reference in New Issue
Block a user