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:
+188
-78
@@ -1,52 +1,136 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ file }} · {{ name }} · The Booth{% endblock %}
|
||||
{# THE REVIEW (R2 C6). One media item at full size — image, video or audio —
|
||||
with the judgment on screen beside it, the whole set as a filmstrip below and
|
||||
the tape above. Docs keep doc.html. Everything a mark can change is a
|
||||
`data-region` the in-place script swaps (the rail, the filmstrip, the tape);
|
||||
THE STAGE NEVER IS — swapping it would restart a playing track. #}
|
||||
{% macro num(n) -%}#{{ "%0*d"|format(ord_width, n) }}{%- endmacro %}
|
||||
{% block content %}
|
||||
<div class="viewer">
|
||||
<div class="viewer review">
|
||||
<div class="vbar">
|
||||
<a class="vbtn vx" href="/b/{{ name_url }}/" title="back to gallery (Esc)">✕</a>
|
||||
<span class="vname">{{ file }}</span>
|
||||
<a class="vbtn vx" href="{{ back_url }}" title="back to the grid (Esc)">✕</a>
|
||||
<span class="vname"><span class="ord">{{ num(ordinal) }}</span> {{ file }}</span>
|
||||
<span class="vspacer"></span>
|
||||
{% if kind == 'image' %}
|
||||
{# A JS-only VIEWING convenience (INV-3): hidden until the script shows it,
|
||||
and only ever rendered for a picture. With scripts off the image shows at
|
||||
fit size and no judgment depends on this. #}
|
||||
<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>
|
||||
{% endif %}
|
||||
<a class="vbtn" href="{{ file_url }}" download title="download {{ file }}">⬇</a>
|
||||
</div>
|
||||
{% if prev_url %}<a class="vnav vprev" href="?f={{ prev_url }}" title="previous (←)" aria-label="previous image">‹</a>{% endif %}
|
||||
{% if next_url %}<a class="vnav vnext" href="?f={{ next_url }}" title="next (→)" aria-label="next image">›</a>{% endif %}
|
||||
<div class="vstage fit" id="vstage"><img id="vimg" src="{{ file_url }}" alt="{{ file }}"></div>
|
||||
{# THE ANNOTATION, at full size. It was never rendered here before U1 — not
|
||||
because the template dropped it, but because the route never resolved it.
|
||||
A caption is most useful at the size where you are actually judging the
|
||||
thing, so it belongs here at least as much as in the grid. #}
|
||||
{% if caption %}<div class="vcap">{{ caption }}</div>{% endif %}
|
||||
{# INV-3: the JUDGMENT travels to full size too, not just the caption. This is
|
||||
the size at which the operator is actually deciding, so the flag toggle and
|
||||
the notes belong here at least as much as on the tile. #}
|
||||
<div class="vmarks">
|
||||
<form class="vflag" method="post" action="/b/{{ name_url }}/flag">
|
||||
<input type="hidden" name="target" value="{{ file }}">
|
||||
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
|
||||
<button class="vbtn{% if flagged %} is-flagged{% endif %}"
|
||||
title="{{ 'un-flag this item' if flagged else 'flag this one' }}"
|
||||
>{{ '✔ flagged' if flagged else '○ flag' }}</button>
|
||||
</form>
|
||||
{% for m in marks if m.shape == 'note' %}
|
||||
<div class="vnote"><pre>{{ m.text }}</pre>
|
||||
<form method="post" action="/b/{{ name_url }}/unmark">
|
||||
<input type="hidden" name="mark" value="{{ m.id }}">
|
||||
<button class="mark-x" title="withdraw this note">×</button>
|
||||
|
||||
{# THE TAPE (B's device): one segment per item in the review ring — seen,
|
||||
flagged, current — so how far through the set you are is always in view. #}
|
||||
<div class="tape" data-region="tape" aria-label="{{ seen_n }} of {{ ring_m }} seen">
|
||||
<div class="tape-segs">
|
||||
{% for x in film %}
|
||||
<a class="tape-s{% if x.current %} is-current{% elif x.flagged %} is-flagged{% elif x.seen %} is-seen{% endif %}"
|
||||
href="?f={{ x.url }}" title="{{ num(x.ordinal) }} {{ x.name }}"></a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<span class="tape-count">{{ seen_n }} of {{ ring_m }} seen</span>
|
||||
</div>
|
||||
|
||||
<div class="review-body">
|
||||
{% if prev_url %}<a class="vnav vprev" href="?f={{ prev_url }}" title="previous (←)" aria-label="previous">‹</a>{% endif %}
|
||||
<div class="vstage fit{% if blurred %} is-blurred{% endif %}" id="vstage">
|
||||
{% if kind == 'image' %}<img id="vimg" src="{{ file_url }}" alt="{{ file }}">
|
||||
{% elif kind == 'video' %}<video id="vmedia" controls preload="metadata" src="{{ file_url }}"></video>
|
||||
{% else %}<audio id="vmedia" controls preload="metadata" src="{{ file_url }}"></audio>
|
||||
{% endif %}
|
||||
{% if blurred %}<button type="button" class="reveal" id="vreveal" aria-label="reveal {{ file }}">👁 reveal — blur is cosmetic</button>{% endif %}
|
||||
</div>
|
||||
{% if next_url %}<a class="vnav vnext" href="?f={{ next_url }}" title="next (→)" aria-label="next">›</a>{% endif %}
|
||||
|
||||
<aside class="vrail" id="rail" data-region="rail" aria-label="your judgment">
|
||||
<div class="vr-sec">
|
||||
<div class="vr-where"><span class="ord">{{ num(ordinal) }}</span> · {{ ring_k }} of {{ ring_m }}
|
||||
{%- if group %} · {{ group.k }} of {{ group.n }} in {{ group.key }}{% endif %}</div>
|
||||
{# THE ANNOTATION, at full size — the size where it is most readable. #}
|
||||
{% if caption %}<div class="vcap">{{ caption }}</div>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="vr-sec vr-judge">
|
||||
<form class="vflag" method="post" action="/b/{{ name_url }}/flag" data-inplace>
|
||||
<input type="hidden" name="target" value="{{ file }}">
|
||||
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
|
||||
<input type="hidden" name="back" value="view">
|
||||
<input type="hidden" name="f" value="{{ file }}">
|
||||
<button class="vbtn vflag-btn{% if flagged %} is-flagged{% endif %}" id="vflag-btn"
|
||||
title="{{ 'un-flag this item' if flagged else 'flag this one' }} (F)"
|
||||
>{{ '✔ flagged' if flagged else '○ flag' }} <kbd>F</kbd></button>
|
||||
</form>
|
||||
{% for m in marks if m.shape == 'note' %}
|
||||
<div class="vnote"><pre>{{ m.text }}</pre>
|
||||
<form method="post" action="/b/{{ name_url }}/unmark" data-inplace>
|
||||
<input type="hidden" name="mark" value="{{ m.id }}">
|
||||
<input type="hidden" name="back" value="view">
|
||||
<input type="hidden" name="f" value="{{ file }}">
|
||||
<button class="mark-x" title="withdraw this note">×</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<form class="vaddnote" method="post" action="/b/{{ name_url }}/note" data-inplace>
|
||||
<input type="hidden" name="target" value="{{ file }}">
|
||||
<input type="hidden" name="back" value="view">
|
||||
<input type="hidden" name="f" value="{{ file }}">
|
||||
<textarea name="text" id="vnote-text" rows="2" placeholder="a note on this item (N)"></textarea>
|
||||
<button type="submit">Add note</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<form class="vaddnote" method="post" action="/b/{{ name_url }}/note">
|
||||
<input type="hidden" name="target" value="{{ file }}">
|
||||
<textarea name="text" rows="2" placeholder="a note on this item"></textarea>
|
||||
<button type="submit">Add note</button>
|
||||
</form>
|
||||
|
||||
{# A question ABOUT this item is answerable here. #}
|
||||
{% if item_picks %}
|
||||
<div class="vr-sec">
|
||||
{% with marks=item_picks, picks_only=true, back_view=file, marks_page=false %}{% include "_marks.html" %}{% endwith %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_last %}
|
||||
{# THE END OF THE SET — not a separate page: on the last item the rail
|
||||
adds the summary and every question still open on the booth. #}
|
||||
<div class="vr-sec vr-end">
|
||||
<p class="vr-end-head">End of the set · {{ seen_n }} of {{ ring_m }} seen · {{ tray|length }} flagged</p>
|
||||
{% if tray %}
|
||||
<div class="tray">
|
||||
{% for x in tray %}
|
||||
<a class="tray-item{% if x.blurred %} is-blurred{% endif %}" href="?f={{ x.url }}" title="{{ x.name }}">
|
||||
{%- if x.kind == 'image' %}<img loading="lazy" src="{{ x.url }}" alt="">{% else %}<span class="tray-kind">{{ x.kind }}</span>{% endif -%}
|
||||
<span class="tray-ord">{{ num(x.ordinal) }}</span></a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if other_picks %}
|
||||
{% with marks=other_picks, picks_only=true, back_view=file, marks_page=false %}{% include "_marks.html" %}{% endwith %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif other_picks %}
|
||||
<div class="vr-sec vr-more">
|
||||
<a href="/b/{{ name_url }}/">{{ other_picks|length }} more open question{{ '' if other_picks|length == 1 else 's' }} on this booth →</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="vr-keys"><kbd>←</kbd> <kbd>→</kbd> <kbd>Space</kbd> move · <kbd>F</kbd> flag · <kbd>N</kbd> note · <kbd>Esc</kbd> grid</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{# THE FILMSTRIP: the review ring in set order, numbered like the tiles,
|
||||
flagged frames underlined, the current one in the reticle. #}
|
||||
<nav class="film" data-region="film" aria-label="the set">
|
||||
{% for x in film %}
|
||||
<a class="film-f{% if x.flagged %} is-flagged{% endif %}{% if x.current %} is-current{% endif %}{% if x.blurred %} is-blurred{% endif %}"
|
||||
href="?f={{ x.url }}" title="{{ x.name }}"{% if x.current %} aria-current="true"{% endif %}>
|
||||
{%- if x.kind == 'image' %}<img loading="lazy" src="{{ x.url }}" alt="">{% else %}<span class="film-kind">{{ '♪' if x.kind == 'audio' else '▶' }}</span>{% endif -%}
|
||||
<span class="film-ord">{{ num(x.ordinal) }}</span></a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
</div>
|
||||
<style>
|
||||
.vnav{position:fixed;top:50%;transform:translateY(-50%);z-index:40;display:flex;
|
||||
.vnav{position:absolute;top:50%;transform:translateY(-50%);z-index:4;display:flex;
|
||||
align-items:center;justify-content:center;width:40px;height:56px;font-size:28px;
|
||||
line-height:1;text-decoration:none;color:oklch(0.91 0.008 216);background:oklch(0.17 0.01 250 / .6);
|
||||
border:1px solid rgb(255 255 255 / .12);border-radius:var(--radius-lg);margin:0 8px;user-select:none;
|
||||
@@ -54,67 +138,93 @@
|
||||
transition:background var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out)}
|
||||
.vnav:hover{background:oklch(0.21 0.01 248 / .92);border-color:rgb(255 255 255 / .3);text-decoration:none;
|
||||
color:oklch(0.91 0.008 216)}
|
||||
.vprev{left:0}.vnext{right:0}
|
||||
/* Bottom bar rather than the top chrome: a caption can run to CAPTION_MAX
|
||||
(800 chars), which would shove the filename and the Fit/1:1 toggle around. */
|
||||
.vcap{flex:0 0 auto;max-height:22vh;overflow-y:auto;padding:10px clamp(12px,3vw,20px);
|
||||
font-size:var(--size-body);line-height:var(--leading-body);color:var(--text-body);background:var(--surface-base);
|
||||
border-top:1px solid var(--border-subtle);white-space:pre-wrap}
|
||||
@media print{.vcap{max-height:none;overflow:visible}}
|
||||
@media print{.vnav{display:none}}
|
||||
.vprev{left:0}.vnext{right:360px}
|
||||
.vcap{margin-top:10px;max-height:30vh;overflow-y:auto;font-size:var(--size-sm);line-height:var(--leading-body);
|
||||
color:var(--text-body);white-space:pre-wrap}
|
||||
@media print{.vcap{max-height:none;overflow:visible}.vnav{display:none}}
|
||||
</style>
|
||||
<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 }};
|
||||
var BACK = {{ back_url|tojson }};
|
||||
var PREV = {{ (('?f=' ~ prev_url) if prev_url else '')|tojson }};
|
||||
var NEXT = {{ (('?f=' ~ next_url) if next_url else '')|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);
|
||||
/* Fit / 1:1 — bound only when the stage is a picture (R2 C6). */
|
||||
var img = document.getElementById('vimg');
|
||||
if (img) {
|
||||
var stage = document.getElementById('vstage');
|
||||
var toggle = document.getElementById('vtoggle');
|
||||
var bFit = document.getElementById('btn-fit');
|
||||
var bOne = document.getElementById('btn-one');
|
||||
var setMode = function (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 — then the toggle is hidden. */
|
||||
var evaluate = function () {
|
||||
if (!img.naturalWidth) return;
|
||||
if (img.naturalWidth <= stage.clientWidth && img.naturalHeight <= stage.clientHeight) {
|
||||
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();
|
||||
}
|
||||
// "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();
|
||||
|
||||
/* An arrow key inside the note field is a CARET move, not a navigation.
|
||||
The handler is on `document` and the note textarea shipped into this same
|
||||
page, so typing a note and reaching for ← threw the draft away; Escape
|
||||
did it in one keystroke. Anything editable keeps its own keys. */
|
||||
/* Keep the current frame in view on the filmstrip — on load, and after an
|
||||
in-place save swaps the strip for a fresh one. Additive: without it the
|
||||
strip is still a row of links. */
|
||||
function centreFilm() {
|
||||
var cur = document.querySelector('.film-f.is-current');
|
||||
var film = document.querySelector('.film');
|
||||
if (cur && film) film.scrollLeft = cur.offsetLeft - (film.clientWidth - cur.offsetWidth) / 2;
|
||||
}
|
||||
centreFilm();
|
||||
document.addEventListener('booth:swapped', centreFilm);
|
||||
|
||||
/* Blur reveal on the stage — per-viewer, never persisted. Cosmetic, and
|
||||
the button says so. */
|
||||
var rv = document.getElementById('vreveal');
|
||||
if (rv) rv.addEventListener('click', function () {
|
||||
var on = document.getElementById('vstage').classList.toggle('revealed');
|
||||
rv.textContent = on ? '🙈 hide' : '👁 reveal — blur is cosmetic';
|
||||
});
|
||||
|
||||
/* EVERY key here, new and old, is ignored while focus is in something
|
||||
editable: an arrow key in the note field is a caret move, and F typed
|
||||
into a note is a letter, not a flag. */
|
||||
function isEditable(el) {
|
||||
return !!(el && (el.isContentEditable ||
|
||||
/^(input|textarea|select)$/i.test(el.tagName || '')));
|
||||
}
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (isEditable(e.target)) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
/* Space moves only when the stage is not a player that wants it. */
|
||||
var player = document.getElementById('vmedia');
|
||||
if (e.key === 'Escape') window.location.href = BACK;
|
||||
else if (e.key === 'ArrowLeft' && PREV) window.location.href = PREV;
|
||||
else if (e.key === 'ArrowRight' && NEXT) window.location.href = NEXT;
|
||||
else if (e.key === ' ' && NEXT && e.target !== player) { e.preventDefault(); window.location.href = e.shiftKey && PREV ? PREV : NEXT; }
|
||||
else if (e.key === 'f' || e.key === 'F') {
|
||||
var b = document.getElementById('vflag-btn'); /* re-read: the rail may have been swapped */
|
||||
if (b) { e.preventDefault(); b.click(); }
|
||||
}
|
||||
else if (e.key === 'n' || e.key === 'N') {
|
||||
var t = document.getElementById('vnote-text');
|
||||
if (t) { e.preventDefault(); t.focus(); }
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user