feat(r2): C6 the review, and C7

- The zoom route becomes the review for image, video AND audio: the native
  player on the stage for sound and video, the Fit/1:1 toggle for pictures
  only. The judgment rail, the tape and the filmstrip are each a data-region.
  The stage never is, so a playing track survives an in-place save.
- The rail shows the whole-set number, K of M in the review ring and the
  position in the group; then the caption, and the flag and notes, landing
  back here (back=view). A pick targeting this item is answerable in place.
  On the last item the end-of-set block lists what was seen, the flags, and
  every other open question.
- The keys are ← → Space F N Esc. Every one is ignored in an editable field,
  and Esc returns to the grid at the tile you were on.
- _marks.html gains picks_only/back_view, so a pick form has one renderer
  wherever it sits.
- In-place swaps now carry an unsaved draft across. A half-typed note
  survives a flag, except in the form that was just sent.
- The filmstrip keeps the current frame in view.
- C7: no emblem in the chrome, pinned.

Browser tests cover: F typed into the note stays a letter and does not
flag; F outside the note flags in place and the draft survives; Space
moves; Esc lands on the grid tile. 706 passed.
This commit is contained in:
vh
2026-09-23 09:06:15 -07:00
parent 50f88a3e5e
commit f8cb1b29af
6 changed files with 442 additions and 94 deletions
+48 -11
View File
@@ -1706,19 +1706,56 @@ def create_app(
"flagged": any(m.shape == "flag" for m in item_marks),
}
if item is not None and item.kind == "image":
# prev/next ring (wraps; only when there is more than one item in
# it). R2 C2: the ring is `review_chain` — the item order filtered to
# MEDIA — so a set that mixes pictures and sound steps through both.
names = review_chain(items)
if item is not None and item.kind in REVIEW_KINDS:
# THE REVIEW (R2 C6): images, video and audio at full size with the
# judgment on screen. The ring is `review_chain` — the item order
# filtered to MEDIA — and the prev/next, the filmstrip and the tape
# all read that ONE list, so they cannot disagree about "next".
ring = review_chain(items)
by_rel = {it.rel: it for it in items}
pos = ring.index(f)
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="/")
if len(ring) > 1:
prev_url = quote(ring[(pos - 1) % len(ring)], safe="/")
next_url = quote(ring[(pos + 1) % len(ring)], safe="/")
flagged_rels = {m.target for m in marks
if m.shape == "flag" and m.error is None}
# recorded above, before this read: the current item counts as seen
seen = read_seen(booth) & set(ring)
film = [{"name": r, "url": by_rel[r].url, "ordinal": by_rel[r].ordinal,
"kind": by_rel[r].kind, "blurred": by_rel[r].blurred,
"flagged": r in flagged_rels, "seen": r in seen,
"current": r == f} for r in ring]
# Position within the group, only when there IS grouping: two or
# more groups among the ring. One group for everything says nothing.
group = None
ring_groups = {by_rel[r].group for r in ring if by_rel[r].group}
if item.group and len(ring_groups) > 1:
members = [r for r in ring if by_rel[r].group == item.group]
group = {"key": item.group, "k": members.index(f) + 1, "n": len(members)}
open_now = open_marks(marks)
return templates.TemplateResponse(
request, "view.html", {**common, "prev_url": prev_url, "next_url": next_url}
)
request, "view.html", {
**common,
"kind": item.kind,
"ordinal": item.ordinal,
"ord_width": len(str(len(items))),
"ring_k": pos + 1,
"ring_m": len(ring),
"prev_url": prev_url,
"next_url": next_url,
"film": film,
"seen_n": len(seen),
"group": group,
# a question ABOUT this item is answerable here; the rest are
# a count and a link — until the last item, where the end of
# the set offers them all
"item_picks": [m for m in open_now if m.target == f],
"other_picks": [m for m in open_now if m.target != f],
"is_last": pos == len(ring) - 1,
"tray": [x for x in film if x["flagged"]],
"back_url": f"/b/{quote(name, safe='')}/#item-{item.url}",
})
# .md renders, .txt/.log show as text — viewable in-booth, no download
if item is not None: