diff --git a/booth/app.py b/booth/app.py index 3c9d8b7..60340a2 100644 --- a/booth/app.py +++ b/booth/app.py @@ -859,7 +859,7 @@ def create_app( return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=307) @app.get("/b/{name}/", response_class=HTMLResponse) - def booth_view(request: Request, name: str, download: int = 0): + def booth_view(request: Request, name: str, download: int = 0, filter: str = "all"): booth = resolve_booth(name) # U4: viewing is activity. ABOVE both early returns — the zip download # and the verbatim-index.html branch are looks at this booth too, and a @@ -902,6 +902,7 @@ def create_app( held_marks, read_err = hold_read(booth) # ONE read; see list_booths hold = hold_reason(held_marks, read_err) marks = held_marks if read_err is None else marks_for(booth) + rail, shown = _rail(gallery, marks, filter) return templates.TemplateResponse( request, "booth.html", @@ -912,7 +913,16 @@ def create_app( # The page could not previously tell keep from release, so it # offered neither and you had to go back to the index. "kept": is_kept(booth), - "items": gallery, + # THE GRID RENDERS `shown`; everything else reads `gallery`. + # Filtering is a VIEW: `shown` is `gallery` with non-matching + # items removed and NOTHING re-sorted, so "the third one" means + # the same thing with a filter on as with it off. Sorting by + # anything filter-derived would look right and silently misfile + # the operator's judgment — CLAUDE.md invariant 6. + "items": shown, + "all_items": gallery, + "rail": rail, + "filter": rail["active"], # A booth carrying links.md is the standing link board: render # its rows as real UI (link, provenance, pin, per-row + bulk # remove) instead of a markdown blob you can only edit by hand. @@ -951,7 +961,9 @@ def create_app( "marks": marks, "marks_open": len(open_marks(marks)), # Per-item marks, keyed by rel, so a tile reads its own judgment - # without every tile re-filtering the whole list. + # without every tile re-filtering the whole list. Keyed off the + # FULL gallery, not the filtered one, so a tile hidden by the + # current filter still has its marks if the filter changes. "item_marks": { it["name"]: marks_for_target(marks, it["name"]) for it in gallery }, @@ -969,6 +981,41 @@ def create_app( }, ) + FILTERS = ("all", "flagged", "annotated", "unanswered") + + def _rail(gallery: list[dict], marks, requested: str) -> tuple[dict, list[dict]]: + """Per-filter counts, and the items the grid should render. + + `requested` ARRIVES FROM A URL, which is operator-editable and + link-shared, so an unknown value falls back to `all` rather than + indexing a dict by it. A filter nobody can mistype into a 500. + + `unanswered` means HAS AN OPEN PICK — the U4 hold predicate, which + already exists and already has a home. The other reading ("has no mark + at all") is a genuinely different question and is an open question on + the U7 contract, not something to guess at here. + """ + active = requested if requested in FILTERS else "all" + open_ids = {m.id for m in open_marks(marks)} + buckets: dict[str, list[dict]] = {f: [] for f in FILTERS} + for it in gallery: + mine = marks_for_target(marks, it["name"]) + buckets["all"].append(it) + if any(m.shape == "flag" and m.flagged for m in mine): + buckets["flagged"].append(it) + if any(m.shape == "note" for m in mine): + buckets["annotated"].append(it) + if any(m.id in open_ids for m in mine): + buckets["unanswered"].append(it) + rail = { + "active": active, + # ORDER: the declaration order of FILTERS. Stated because a rail is + # an ordered collection and invariant 6 binds to it like any other. + "counts": [{"key": f, "n": len(buckets[f])} for f in FILTERS], + "total": len(gallery), + } + return rail, buckets[active] + def _board_rows(booth: Path) -> list[dict]: """The link board's rows, or [] for a board that cannot be read. diff --git a/booth/templates/base.html b/booth/templates/base.html index 6a1c448..823d403 100644 --- a/booth/templates/base.html +++ b/booth/templates/base.html @@ -520,6 +520,16 @@ /* A board row whose booth has been swept. Marked, never auto-removed. */ .board-row.board-dead{opacity:.45} .board-dead-tag{font-size:.9em;color:#f2b8b5;opacity:.9} + /* U7 — the rail, and the grid cursor. */ + .rail{position:sticky;top:0;z-index:5;display:flex;gap:.5rem;align-items:baseline; + padding:.4rem .6rem;margin:.6rem 0;background:var(--bg,#111); + border-bottom:1px solid var(--line,#2a2a2a);flex-wrap:wrap} + .rail-total{font-weight:600} + .rail-f{font-size:.85em;padding:.1rem .45rem;border-radius:3px;text-decoration:none; + opacity:.65;border:1px solid transparent} + .rail-f:hover{opacity:1} + .rail-f.on{opacity:1;border-color:var(--line,#2a2a2a);background:rgba(255,255,255,.06)} + figure.item.is-cursor{outline:2px solid #7aa2f7;outline-offset:2px} diff --git a/booth/templates/booth.html b/booth/templates/booth.html index 9713a51..78656d6 100644 --- a/booth/templates/booth.html +++ b/booth/templates/booth.html @@ -243,7 +243,29 @@ {# `elif items` and not a bare `else`: a board booth has NO gallery items (its links.md is rendered as the board above and filtered out), so a plain else would emit an empty