feat(r2): C5 the lightbox, and the in-place client
- On a gallery booth the marks panel moves into a sticky verdict aside beside the set. The aside comes first in the document, so a narrow screen stacks the question above the work; grid areas place it on the right when wide. Nothing in an ordered collection moves. Boards are unchanged. - The flag tray lists flagged items by tile number: the declared change from the panel list's (created, id). The standalone marks page keeps the list. - Inline group headers are divs, never figure.item. - Every mark-dependent element is a data-region: the verdict, each tile, the rail's filter counts. There is also a server-rendered status line. - The in-place script (base.html) POSTs with an explicit JSON Accept, then on 204 swaps every region from a fresh GET. Live media and per-viewer view state are carried across the swap, so there is no layout jolt and no stopped track. It never re-POSTs: on failure it says so and reloads. Tile controls re-bind after a swap, and the grid cursor survives it. - The `n` key opens the tile's closed note disclosure before focusing it. - test_embed_browser's keyboard-flag test expected a navigation, which is the defect R2 removes. It is updated as declared in the contract, and tightened: a window marker must survive, proving no reload. Browser tests: flag in place, with no reload and no scroll jump, and the tile, tray and rail count all updated; and a failed save that reloads without re-POSTing. Two mutations turn them red (no carry, no rail region). 700 passed.
This commit is contained in:
@@ -1139,6 +1139,12 @@ def create_app(
|
||||
it["name"]: marks_for_target(marks, it["name"]) for it in gallery
|
||||
},
|
||||
"booth_marks": marks_for_target(marks, None),
|
||||
# R2 C5: the flag tray — the flagged items in SET order, i.e.
|
||||
# by ordinal, over the full gallery (a filter hides tiles, not
|
||||
# judgments). Order is total with no tie-break: rels are unique.
|
||||
"tray": [it for it in gallery if it["name"] in {
|
||||
m.target for m in marks if m.shape == "flag" and m.error is None}],
|
||||
"ord_width": len(str(len(gallery))),
|
||||
"uploaded": (booth / UPLOAD_MARKER).exists(),
|
||||
# The same provenance line the index card carries. Deliberate:
|
||||
# a booth URL handed to the operator lands HERE, never on the
|
||||
|
||||
+39
-19
@@ -32,7 +32,7 @@
|
||||
<span class="mark-state">⚠ broken</span>
|
||||
<span class="mark-id"><code>{{ a.id }}</code></span>
|
||||
<span class="board-spacer"></span>
|
||||
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark">
|
||||
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark" data-inplace>
|
||||
<input type="hidden" name="mark" value="{{ a.id }}">
|
||||
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
|
||||
<button type="submit" class="mark-x" title="withdraw this mark">×</button>
|
||||
@@ -75,7 +75,7 @@
|
||||
{% endif %}
|
||||
<details class="mark-formwrap"{% if not a.answer %} open{% endif %}>
|
||||
<summary class="mark-change">{% if a.answer %}change answer{% else %}answer{% endif %}</summary>
|
||||
<form class="mark-form" method="post" action="/b/{{ name_url }}/answer">
|
||||
<form class="mark-form" method="post" action="/b/{{ name_url }}/answer" data-inplace>
|
||||
{# The field is still `ask`: inline fragments in reports the operator
|
||||
has already published POST that name, and breaking every landed
|
||||
verbatim report to tidy a form field is not a trade worth making. #}
|
||||
@@ -117,25 +117,26 @@
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
{% for a in notes %}
|
||||
<article class="mark mark-note" id="mark-{{ a.id }}">
|
||||
{# FLAGS come right after the picks. On the lightbox (`tray` defined) they
|
||||
render as the TRAY: the flagged items in SET order — by tile number, the
|
||||
declared R2 change from the click order below — each the original shown
|
||||
small, blurred if the item is. The standalone marks page has no item
|
||||
records, so it keeps the list, in `(created, id)` order. #}
|
||||
{% if tray is defined and tray %}
|
||||
<article class="mark mark-flags" id="mark-flags">
|
||||
<header class="mark-head">
|
||||
<span class="mark-state mark-state-note">note</span>
|
||||
{% if a.target %}<span class="mark-target">on <a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></span>
|
||||
{% else %}<span class="mark-target">on this booth</span>{% endif %}
|
||||
<span class="board-spacer"></span>
|
||||
<span class="mark-when">{{ a.created }}{% if a.by %} · {{ a.by }}{% endif %}</span>
|
||||
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark">
|
||||
<input type="hidden" name="mark" value="{{ a.id }}">
|
||||
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
|
||||
<button type="submit" class="mark-x" title="withdraw this note">×</button>
|
||||
</form>
|
||||
<span class="mark-state mark-state-flag">✔ flagged</span>
|
||||
<span class="mark-id">{{ tray|length }} item{{ '' if tray|length == 1 else 's' }} · in set order</span>
|
||||
</header>
|
||||
<pre class="mark-text">{{ a.text }}</pre>
|
||||
<div class="tray">
|
||||
{% for it in tray %}
|
||||
<a class="tray-item{% if it.blurred %} is-blurred{% endif %}" href="view?f={{ it.url }}" title="{{ it.name }}">
|
||||
{%- if it.kind == 'image' %}<img loading="lazy" src="{{ it.url }}" alt="">{% else %}<span class="tray-kind">{{ it.kind }}</span>{% endif -%}
|
||||
<span class="tray-ord">#{{ "%0*d"|format(ord_width, it.ordinal) }}</span></a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
{% if flags %}
|
||||
{% elif tray is not defined and flags %}
|
||||
<article class="mark mark-flags" id="mark-flags">
|
||||
<header class="mark-head">
|
||||
<span class="mark-state mark-state-flag">✔ flagged</span>
|
||||
@@ -149,9 +150,28 @@
|
||||
</article>
|
||||
{% endif %}
|
||||
|
||||
{% for a in notes %}
|
||||
<article class="mark mark-note" id="mark-{{ a.id }}">
|
||||
<header class="mark-head">
|
||||
<span class="mark-state mark-state-note">note</span>
|
||||
{% if a.target %}<span class="mark-target">on <a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></span>
|
||||
{% else %}<span class="mark-target">on this booth</span>{% endif %}
|
||||
<span class="board-spacer"></span>
|
||||
<span class="mark-when">{{ a.created }}{% if a.by %} · {{ a.by }}{% endif %}</span>
|
||||
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark" data-inplace>
|
||||
<input type="hidden" name="mark" value="{{ a.id }}">
|
||||
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
|
||||
<button type="submit" class="mark-x" title="withdraw this note">×</button>
|
||||
</form>
|
||||
</header>
|
||||
<pre class="mark-text">{{ a.text }}</pre>
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
|
||||
{# The operator volunteering a remark, which before marks had no mechanism at
|
||||
all — this is the direction that was running through chat. #}
|
||||
<form class="mark-add" method="post" action="/b/{{ name_url }}/note">
|
||||
<form class="mark-add" method="post" action="/b/{{ name_url }}/note" data-inplace>
|
||||
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
|
||||
<textarea name="text" rows="2" placeholder="a note on this booth, for the session that posted it"></textarea>
|
||||
<button type="submit">Add note</button>
|
||||
|
||||
+129
-1
@@ -419,6 +419,52 @@
|
||||
.mark-add textarea,.vaddnote textarea,.item-addnote textarea{flex:1;min-width:0}
|
||||
.mark-add{margin-top:2px}
|
||||
|
||||
/* ---- THE LIGHTBOX (R2 C5) ----------------------------------------------
|
||||
The set on the left, the verdict on the right — placed by grid AREAS, not
|
||||
by `order`: the aside is FIRST in the document so a narrow screen stacks
|
||||
the question above the work without anything being reordered. The aside
|
||||
is sticky and scrolls on its own, so the question stays beside tile 40. */
|
||||
main:has(.lightbox){max-width:1560px}
|
||||
.lightbox{display:grid;grid-template-columns:minmax(0,1fr) 360px;grid-template-areas:"set verdict";
|
||||
gap:var(--space-6);align-items:start}
|
||||
.lb-set{grid-area:set;min-width:0}
|
||||
.verdict{grid-area:verdict;position:sticky;top:12px;max-height:calc(100vh - 24px);overflow:auto;
|
||||
border:1px solid var(--border-default);border-radius:var(--radius-xl);background:var(--surface-base);
|
||||
padding:12px;scrollbar-width:thin}
|
||||
.verdict .marks{margin:0}
|
||||
.verdict .mark-prompt{font-size:var(--size-body)}
|
||||
.verdict .mark-add{flex-direction:column;align-items:stretch}
|
||||
.verdict .mark-add button{align-self:flex-end}
|
||||
@media (max-width:1000px){
|
||||
.lightbox{grid-template-columns:1fr;grid-template-areas:"verdict" "set"}
|
||||
.verdict{position:static;max-height:none}
|
||||
}
|
||||
/* the flag tray: the flagged items in set order, the originals shown small */
|
||||
.tray{display:grid;grid-template-columns:repeat(auto-fill,minmax(64px,1fr));gap:6px;padding:12px 14px}
|
||||
.tray-item{position:relative;display:block;aspect-ratio:1;border-radius:var(--radius-md);overflow:hidden;
|
||||
border:1px solid var(--success);background:var(--surface-sunken)}
|
||||
.tray-item img{width:100%;height:100%;object-fit:cover;display:block}
|
||||
.tray-item.is-blurred img{filter:blur(8px)}
|
||||
.tray-kind{display:flex;align-items:center;justify-content:center;height:100%;font-family:var(--font-mono);
|
||||
font-size:var(--size-micro);text-transform:uppercase;color:var(--text-muted)}
|
||||
.tray-ord{position:absolute;left:3px;bottom:3px;padding:1px 4px;border-radius:var(--radius-sm);
|
||||
font:600 10px/1.2 var(--font-mono);background:oklch(0.17 0.01 250 / .85);color:oklch(0.91 0.008 216)}
|
||||
.tray-item:hover{text-decoration:none;box-shadow:0 0 0 1px var(--success)}
|
||||
/* ordinals: the item's number in the whole set (C1) */
|
||||
.item > .ord{position:absolute;top:10px;left:10px;z-index:2;padding:3px 6px;border-radius:var(--radius-sm);
|
||||
font:600 11px/1.2 var(--font-mono);background:oklch(0.17 0.01 250 / .82);color:oklch(0.91 0.008 216);
|
||||
pointer-events:none}
|
||||
.item.blurred > .ord{top:44px}
|
||||
.doc-bar .ord{font:600 11px/1 var(--font-mono);color:var(--text-muted)}
|
||||
/* the inline group header: spans the grid, is never a tile */
|
||||
.grp-head{grid-column:1 / -1;display:flex;align-items:center;gap:8px;margin:8px 0 -4px;
|
||||
font-family:var(--font-mono);font-size:var(--size-micro);letter-spacing:var(--tracking-caps);text-transform:uppercase}
|
||||
.grp-head::after{content:"";flex:1;height:1px;background:var(--border-subtle)}
|
||||
.grp-key{color:var(--text-heading);font-weight:600}
|
||||
.grp-n{color:var(--text-muted)}
|
||||
.status{margin:0 0 12px;padding:8px 12px;border-radius:var(--radius-md);background:var(--warning-soft);
|
||||
color:var(--warning-text);font-size:var(--size-sm)}
|
||||
|
||||
/* ---- the gallery ------------------------------------------------------ */
|
||||
/* THE RAIL. Sticky, opaque, and counted in mono because the counts are
|
||||
records. The active filter is a place you are, not an armed thing, so it
|
||||
@@ -726,7 +772,89 @@
|
||||
<a class="brand" href="/"><span class="dot"></span><span class="name">The Booth</span></a>
|
||||
<span class="tagline">ephemeral media · auto-wipes {{ ttl_hours }}h · kept boards don't</span>
|
||||
</header>
|
||||
<main>{% block content %}{% endblock %}</main>
|
||||
<main>
|
||||
{# R2 C3: where the in-place script says it could not save in place. Server
|
||||
rendered and empty, so the script only ever sets its text — it builds no
|
||||
markup (INV-6). #}
|
||||
<p class="status" data-region="status" role="status" aria-live="polite" hidden></p>
|
||||
{% block content %}{% endblock %}</main>
|
||||
<script>
|
||||
/* IN-PLACE JUDGMENT (R2 C3). A form marked `data-inplace` is POSTed in the
|
||||
background with `Accept: application/json`; on 204 the page is re-fetched
|
||||
and every server-rendered `data-region` is swapped for its fresh copy. The
|
||||
SERVER RENDERS EVERY STATE; this only places it — no markup is built here
|
||||
(INV-6), for U3's reason: a second renderer in JavaScript would be the same
|
||||
bug in a new language.
|
||||
|
||||
It NEVER RE-POSTS. After a lost response the write may already have
|
||||
landed, and a retry would duplicate a note or re-date an answer. On any
|
||||
failure it says so and reloads with a GET, so what shows is the server's
|
||||
truth. With scripts off, every one of these is a plain form and a 303. */
|
||||
(function () {
|
||||
if (!window.fetch || !window.DOMParser || !window.URLSearchParams) return;
|
||||
function say(text) {
|
||||
var st = document.querySelector('[data-region="status"]');
|
||||
if (st) { st.textContent = text; st.hidden = false; }
|
||||
}
|
||||
/* What the server cannot render, carried from the old node to the new:
|
||||
live MEDIA elements whose src did not change (a playing track keeps
|
||||
playing, a decoded image does not collapse to zero height and jolt the
|
||||
page), and the per-viewer view state a reload would have reset anyway
|
||||
but an in-place save must not — a revealed blur, a closed doc. */
|
||||
function carry(oldEl, newEl) {
|
||||
var olds = [].slice.call(oldEl.querySelectorAll('img[src], video[src], audio[src]'));
|
||||
newEl.querySelectorAll('img[src], video[src], audio[src]').forEach(function (m) {
|
||||
for (var i = 0; i < olds.length; i++) {
|
||||
var o = olds[i];
|
||||
if (o && o.tagName === m.tagName && o.getAttribute('src') === m.getAttribute('src')) {
|
||||
o.className = m.className; /* the SERVER decides blur/state classes */
|
||||
m.replaceWith(o); olds[i] = null; break;
|
||||
}
|
||||
}
|
||||
});
|
||||
['revealed', 'is-closed'].forEach(function (c) {
|
||||
if (oldEl.classList.contains(c)) newEl.classList.add(c);
|
||||
});
|
||||
}
|
||||
function swap(html) {
|
||||
var fresh = new DOMParser().parseFromString(html, 'text/html');
|
||||
document.querySelectorAll('[data-region]').forEach(function (el) {
|
||||
var id = el.getAttribute('data-region');
|
||||
if (id === 'status') return;
|
||||
var next = null;
|
||||
fresh.querySelectorAll('[data-region]').forEach(function (cand) {
|
||||
if (!next && cand.getAttribute('data-region') === id) next = cand;
|
||||
});
|
||||
if (next) {
|
||||
var node = document.importNode(next, true);
|
||||
carry(el, node);
|
||||
el.replaceWith(node);
|
||||
}
|
||||
});
|
||||
document.dispatchEvent(new CustomEvent('booth:swapped'));
|
||||
}
|
||||
document.addEventListener('submit', function (ev) {
|
||||
var form = ev.target;
|
||||
if (!form.matches || !form.matches('form[data-inplace]') || ev.defaultPrevented) return;
|
||||
ev.preventDefault();
|
||||
var data = new FormData(form);
|
||||
if (ev.submitter && ev.submitter.name) data.append(ev.submitter.name, ev.submitter.value);
|
||||
fetch(form.action, {
|
||||
method: 'POST', body: new URLSearchParams(data),
|
||||
headers: {'Accept': 'application/json'}, credentials: 'same-origin'
|
||||
}).then(function (r) {
|
||||
if (r.status !== 204) throw new Error('status ' + r.status);
|
||||
return fetch(window.location.href, {headers: {'Accept': 'text/html'}, credentials: 'same-origin'});
|
||||
}).then(function (r) {
|
||||
if (!r.ok) throw new Error('status ' + r.status);
|
||||
return r.text();
|
||||
}).then(swap).catch(function () {
|
||||
say('Could not save in place — reloading to show what was saved.');
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
/* The sticky rail's real height, published as --rail-h for .item's
|
||||
scroll-margin. ADDITIVE: with JS off the static fallback applies, and a
|
||||
|
||||
+73
-85
@@ -20,7 +20,7 @@
|
||||
one definition. `marks` here is THIS item's marks, from item_marks. #}
|
||||
{% macro markcontrols(name_url, it, marks, cls='') -%}
|
||||
{% set flagged = marks | selectattr('shape', 'equalto', 'flag') | list | length > 0 %}
|
||||
<form class="flagtoggle {{ cls }}" method="post" action="/b/{{ name_url }}/flag">
|
||||
<form class="flagtoggle {{ cls }}" method="post" action="/b/{{ name_url }}/flag" data-inplace>
|
||||
<input type="hidden" name="target" value="{{ it.name }}">
|
||||
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
|
||||
<button title="{{ 'un-flag this item' if flagged else 'flag this one — the session that posted it can read the selection' }}"
|
||||
@@ -37,7 +37,7 @@
|
||||
{% for m in marks if m.shape == 'note' %}
|
||||
<div class="item-note" id="mark-{{ m.id }}">
|
||||
<pre>{{ m.text }}</pre>
|
||||
<form method="post" action="/b/{{ name_url }}/unmark">
|
||||
<form method="post" action="/b/{{ name_url }}/unmark" data-inplace>
|
||||
<input type="hidden" name="mark" value="{{ m.id }}">
|
||||
<button class="mark-x" title="withdraw this note">×</button>
|
||||
</form>
|
||||
@@ -45,7 +45,7 @@
|
||||
{% endfor %}
|
||||
<details class="item-addnote">
|
||||
<summary>+ note</summary>
|
||||
<form method="post" action="/b/{{ name_url }}/note">
|
||||
<form method="post" action="/b/{{ name_url }}/note" data-inplace>
|
||||
<input type="hidden" name="target" value="{{ it.name }}">
|
||||
<textarea name="text" rows="2" placeholder="a note on this item"></textarea>
|
||||
<button type="submit">Add</button>
|
||||
@@ -116,7 +116,11 @@
|
||||
a gallery, and the add-note control would be noise on it — but the
|
||||
suppression was unconditional, so a pick declared on a booth that happens to
|
||||
carry a links.md had no form to answer it and nothing said so. #}
|
||||
{% if marks or not board %}
|
||||
{# R2 C5: on a GALLERY booth the panel moves into the verdict aside beside the
|
||||
set (below). It renders up here only where there is no set to sit beside —
|
||||
a board, or a booth with marks and nothing to show. #}
|
||||
{% set lightbox = all_items and not board %}
|
||||
{% if (marks or not board) and not lightbox %}
|
||||
{% include "_marks.html" %}
|
||||
{% endif %}
|
||||
|
||||
@@ -257,6 +261,16 @@
|
||||
{% if not all_items and not board and not marks %}
|
||||
<div class="empty">This booth is empty.</div>
|
||||
{% elif all_items %}
|
||||
{# THE LIGHTBOX (R2 C5). The verdict aside comes FIRST in the document and the
|
||||
set second: on a narrow screen that is the stacking the contract wants
|
||||
(the question above the work), and on a wide one the grid areas in
|
||||
base.html put the aside on the right. Placement, not order — nothing in an
|
||||
ordered collection moves. #}
|
||||
<div class="lightbox">
|
||||
<aside class="verdict" data-region="verdict" aria-label="your verdict">
|
||||
{% include "_marks.html" %}
|
||||
</aside>
|
||||
<div class="lb-set">
|
||||
{# `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 <div class="gallery"> under the board. #}
|
||||
@@ -273,7 +287,7 @@
|
||||
is therefore the whole guard; the two degenerate cases (one group for
|
||||
everything, one group per item) are decided in Python, where they can be
|
||||
measured, rather than by a count in a template. #}
|
||||
<div class="rail">
|
||||
<div class="rail" data-region="filters">
|
||||
<span class="rail-total">{{ rail.total }} item{{ '' if rail.total == 1 else 's' }}</span>
|
||||
{% for f in rail.counts %}
|
||||
<a class="rail-f{% if f.key == filter %} on{% endif %}"
|
||||
@@ -299,14 +313,21 @@
|
||||
<div class="empty">No items match the <b>{{ filter }}</b> filter.
|
||||
<a href="/b/{{ name_url }}/">show all {{ rail.total }}</a></div>
|
||||
{% endif %}
|
||||
{% set group_n = {} %}{% for g in rail.groups %}{% set _ = group_n.update({g.key: g.n}) %}{% endfor %}
|
||||
<div class="gallery" id="grid" tabindex="-1">
|
||||
{% for it in items %}
|
||||
{# R2 C5: an inline header before each group's FIRST tile, only when the
|
||||
rail thinks grouping is informative. A <div> spanning the grid, never a
|
||||
figure.item, so the keyboard and the order check are blind to it. #}
|
||||
{% if rail.groups and it.group and (loop.first or loop.previtem.group != it.group) %}
|
||||
<div class="grp-head" aria-hidden="true"><span class="grp-key">{{ it.group }}</span> <span class="grp-n">{{ group_n.get(it.group, '') }}</span></div>
|
||||
{% endif %}
|
||||
{% if it.doc and it.rendered is not none %}
|
||||
{# Docs render INLINE, collapsible, and closable — not a link to a
|
||||
separate page. <details open> is native collapse (works with JS off);
|
||||
the ✕ hides the item for the session (JS, progressive enhancement).
|
||||
The item spans the full grid width so prose has room to read. #}
|
||||
<figure class="item item-doc{% if it.blurred %} blurred{% endif %}" data-name="{{ it.name }}" data-item="{{ it.name }}" id="item-{{ it.url }}">
|
||||
<figure class="item item-doc{% if it.blurred %} blurred{% endif %}" data-name="{{ it.name }}" data-item="{{ it.name }}" id="item-{{ it.url }}" data-region="item-{{ it.url }}">
|
||||
{% if it.blurred %}
|
||||
{# Inline docs need this MORE than images, not less: a rendered doc puts
|
||||
its text straight on the page, so "blur the picture" logic that skips
|
||||
@@ -340,7 +361,7 @@
|
||||
</details>
|
||||
</figure>
|
||||
{% else %}
|
||||
<figure class="item item-{{ it.kind }}{% if it.blurred %} blurred{% endif %}{% if item_marks.get(it.name, []) | selectattr('shape', 'equalto', 'flag') | list %} is-flagged{% endif %}" data-item="{{ it.name }}" id="item-{{ it.url }}">
|
||||
<figure class="item item-{{ it.kind }}{% if it.blurred %} blurred{% endif %}{% if item_marks.get(it.name, []) | selectattr('shape', 'equalto', 'flag') | list %} is-flagged{% endif %}" data-item="{{ it.name }}" id="item-{{ it.url }}" data-region="item-{{ it.url }}">
|
||||
{{ ordinal(it) }}
|
||||
{% if it.blurred %}
|
||||
{# Click-to-reveal is per-viewer and client-side: nothing is persisted, so
|
||||
@@ -385,6 +406,8 @@
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>{# .lb-set #}
|
||||
</div>{# .lightbox #}
|
||||
{% endif %}
|
||||
|
||||
{% if items %}
|
||||
@@ -455,6 +478,11 @@
|
||||
Found by the heid bug-hunt panel (hulda), 2026-09-22. */
|
||||
case 'f': click('.flagtoggle button'); e.preventDefault(); break;
|
||||
case 'n': var el = current();
|
||||
/* The add-note field lives in a closed <details> (270 tiles
|
||||
must not each carry an open textarea); a closed one cannot
|
||||
take focus, so open it first. */
|
||||
var d = el && el.querySelector('details.item-addnote');
|
||||
if (d) d.open = true;
|
||||
if (el) { var f = el.querySelector('input[type=text], textarea');
|
||||
if (f) { f.focus(); e.preventDefault(); } }
|
||||
break;
|
||||
@@ -464,6 +492,13 @@
|
||||
at = -1; break;
|
||||
}
|
||||
});
|
||||
/* R2 C3: an in-place save swaps the tiles for fresh server-rendered ones,
|
||||
and the cursor is client state the server cannot render. Put it back on
|
||||
the same position — the order did not change, only the judgment. */
|
||||
document.addEventListener('booth:swapped', function () {
|
||||
if (at < 0) return;
|
||||
tiles().forEach(function (el, j) { el.classList.toggle('is-cursor', j === at); });
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
@@ -500,17 +535,30 @@
|
||||
});
|
||||
})();
|
||||
|
||||
/* Inline-doc ✕ closes (hides) a rendered doc for the session. The button sits
|
||||
/* TILE CONTROLS, bound per node and RE-BOUND after an in-place swap (R2 C3):
|
||||
the swap puts fresh server-rendered tiles in the page, and a handler bound
|
||||
to the node it replaced goes with that node. `__bound` keeps a node from
|
||||
being bound twice.
|
||||
|
||||
Inline-doc ✕ closes (hides) a rendered doc for the session. The button sits
|
||||
inside <summary>, so without this its click would just toggle the <details>
|
||||
open/closed — stopPropagation + preventDefault make ✕ mean "close", not
|
||||
"collapse". Collapse stays available via the rest of the summary bar. With
|
||||
JS off the button is inert and collapse via <details> still works. */
|
||||
(function () {
|
||||
JS off the button is inert and collapse via <details> still works.
|
||||
|
||||
Blur reveal. WARNING: this handler previously sat after the content block's
|
||||
closing tag, which in a child template Jinja DISCARDS — the button rendered
|
||||
and did nothing, and two commits plus a README claimed click-to-reveal
|
||||
worked. Anything that must reach the page belongs inside the content
|
||||
block. Per-viewer and never persisted: a reload re-hides. */
|
||||
function bindTiles() {
|
||||
function once(el) { if (el.__bound) return false; el.__bound = true; return true; }
|
||||
/* A form inside <summary> would otherwise collapse the doc on submit. */
|
||||
document.querySelectorAll('.doc-bar .blurtoggle').forEach(function (f) {
|
||||
f.addEventListener('click', function (ev) { ev.stopPropagation(); });
|
||||
document.querySelectorAll('.doc-bar .blurtoggle, .doc-bar .flagtoggle').forEach(function (f) {
|
||||
if (once(f)) f.addEventListener('click', function (ev) { ev.stopPropagation(); });
|
||||
});
|
||||
document.querySelectorAll('.doc-close').forEach(function (btn) {
|
||||
if (!once(btn)) return;
|
||||
btn.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
@@ -518,80 +566,20 @@
|
||||
if (item) item.classList.add('is-closed');
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
/* Link-board multi-select. PROGRESSIVE ENHANCEMENT: the checkboxes, the per-row
|
||||
× / ★, and the bulk 🗑 all submit as plain form POSTs with JS off — this only
|
||||
adds select-all, a live count, and disabling 🗑 when nothing is ticked. The
|
||||
per-row × confirm reads desc/url from data-* attributes rather than being
|
||||
interpolated into an inline handler, so an arbitrary agent-posted description
|
||||
(quotes, newlines) can never break out into the page's JS. */
|
||||
(function () {
|
||||
var form = document.getElementById('boardform');
|
||||
if (!form) return;
|
||||
var boxes = Array.prototype.slice.call(form.querySelectorAll('.board-check'));
|
||||
var selall = document.getElementById('board-selall');
|
||||
var delBtn = document.getElementById('board-del-sel');
|
||||
var countEl = document.getElementById('board-selcount');
|
||||
|
||||
function selected() { return boxes.filter(function (b) { return b.checked; }); }
|
||||
function refresh() {
|
||||
var n = selected().length;
|
||||
if (countEl) countEl.textContent = n;
|
||||
if (delBtn) delBtn.disabled = n === 0;
|
||||
if (selall) {
|
||||
selall.checked = n > 0 && n === boxes.length;
|
||||
selall.indeterminate = n > 0 && n < boxes.length;
|
||||
}
|
||||
}
|
||||
if (selall) {
|
||||
selall.addEventListener('change', function () {
|
||||
boxes.forEach(function (b) { b.checked = selall.checked; });
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
boxes.forEach(function (b) { b.addEventListener('change', refresh); });
|
||||
|
||||
// Bulk delete: confirm with the count. Attached to the button (not the form's
|
||||
// submit) so the per-row × / ★ submits — which share this form — are unaffected.
|
||||
if (delBtn) {
|
||||
delBtn.addEventListener('click', function (ev) {
|
||||
var n = selected().length;
|
||||
if (n === 0) { ev.preventDefault(); return; }
|
||||
if (!confirm('Delete ' + n + ' selected link' + (n === 1 ? '' : 's') + '?\n\nThe rest of the board is untouched.')) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
form.querySelectorAll('.board-rm-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (ev) {
|
||||
var d = btn.getAttribute('data-desc') || '';
|
||||
var u = btn.getAttribute('data-url') || '';
|
||||
if (!confirm('Remove this link?\n\n' + d + '\n' + u + '\n\nThe rest of the board is untouched.')) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
refresh();
|
||||
})();
|
||||
|
||||
/* Blur reveal. WARNING: this handler previously sat after the content
|
||||
block's closing tag, which in a
|
||||
child template Jinja DISCARDS — the button rendered and did nothing, and
|
||||
two commits plus a README claimed click-to-reveal worked. Anything that
|
||||
must reach the page belongs inside the content block. Verified now by
|
||||
grepping the SERVED html for this function, not the template for the text.
|
||||
Per-viewer and never persisted: a reload re-hides. */
|
||||
document.querySelectorAll('.item.blurred .reveal').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
document.querySelectorAll('.item.blurred .reveal').forEach(function (btn) {
|
||||
var fig = btn.closest('.item');
|
||||
var on = fig.classList.toggle('revealed');
|
||||
btn.textContent = on ? '🙈 hide' : '👁 reveal';
|
||||
/* a swap carries `revealed` across (base.html); the label follows it */
|
||||
btn.textContent = fig.classList.contains('revealed') ? '🙈 hide' : '👁 reveal';
|
||||
if (!once(btn)) return;
|
||||
btn.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
var on = fig.classList.toggle('revealed');
|
||||
btn.textContent = on ? '🙈 hide' : '👁 reveal';
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
bindTiles();
|
||||
document.addEventListener('booth:swapped', bindTiles);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user