Two operator requests.
KEEP, BOTH DIRECTIONS. The kept lane could already release a booth back to
ephemeral, but an ephemeral booth could only be promoted from a shell -- so the
round trip was closed only if you had ssh. The /keep route and the `booth keep`
verb both already existed; only the button was missing. Adds ★ to the ephemeral
card, mirroring × on the other shoulder.
BLUR. Per-item cosmetic censoring: `booth blur <name> <file>...`, a ◌/◉ toggle
in each caption row, and 👁 click-to-reveal. State is `.blurred` in the booth
dir, one booth-relative path per line -- the same filesystem-is-the-state idiom
as .pins and .forever. An empty set deletes the marker rather than leaving a
zero-byte file, so `ls -a` tells the truth.
⚠ BLUR IS NOT ACCESS CONTROL, and the code, the docs and a test all say so on
purpose. A blurred item is still served at its own URL, still in the zip, still
on disk. The Booth has no auth by design. test_blur_is_cosmetic_the_file_is_
still_served asserts the 200 deliberately: if someone later "hardens" this into
a 403 that test fails, and it should, because half-implemented access control is
more dangerous than none.
Reveal is per-viewer and never persisted; a reload re-hides. With JS off an item
stays blurred, which is the safe direction to fail in.
Two things the first pass got wrong, both caught by checking rather than
assuming:
* The cover thumb. index.html has IDENTICAL markup in the kept and ephemeral
lanes, so a single-occurrence replace patched only the kept one and the
ephemeral front page happily displayed the thing someone had hidden. The
test that caught it was itself wrong first -- it matched the bare string
"blurred-thumb", which is in base.html's stylesheet on every page and so
passed in both states. It now asserts the attribute.
* Inline docs render through their own <figure> branch and were left
unblurred -- the branch that puts readable text straight on the page, so it
needed blur more than images do. The suite passed; a live curl caught it.
165 tests pass (154 pre-existing, unchanged).
283 lines
14 KiB
HTML
283 lines
14 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}{{ name }} · The Booth{% endblock %}
|
||
{% block content %}
|
||
<div class="boothhead">
|
||
<a class="back" href="/">‹ all booths</a>
|
||
<h1>{{ name }}</h1>
|
||
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %}{% else %}{% set open_asks = asks|selectattr('answer', 'none')|rejectattr('error')|list|length %}{% if open_asks %}<span class="badge badge-ask">{{ open_asks }} open ask{{ '' if open_asks == 1 else 's' }}</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %}</span>
|
||
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %}
|
||
{# A durable multi-writer board gets no one-click wipe — same rule as the
|
||
kept lane on the index. Remove rows with the per-row ×, or release the
|
||
board from the index and wipe it from there. #}
|
||
{% if not board %}
|
||
<form class="wipe wipe-lg" method="post" action="/b/{{ name_url }}/delete"
|
||
onsubmit="return confirm('Wipe this booth now?')">
|
||
<button>Wipe now</button>
|
||
</form>
|
||
{% endif %}
|
||
</div>
|
||
|
||
{% if uploaded %}
|
||
<div class="pickup-note">
|
||
📦 Pickup <code>{{ name }}</code>
|
||
<button type="button" class="copy-btn" data-copy="{{ name }}" title="copy id to clipboard">⧉ copy</button>
|
||
— download files below, or on nh3-dev grab <code>~/booth-data/{{ name }}/</code>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if asks %}
|
||
{% include "_asks.html" %}
|
||
{% endif %}
|
||
|
||
{% if board %}
|
||
{# THE STANDING LINK BOARD. Every agent session on the fleet appends here, so
|
||
this is the one booth where the useful granularity is the ROW, not the
|
||
folder. Rendered as real UI rather than a markdown blob so a dead link can
|
||
be removed without hand-editing the file — and so provenance (who posted
|
||
it, when) is readable at a glance, which is the whole reason a bare URL
|
||
three days old is useless.
|
||
|
||
ORDER: pinned rows first, then newest-first (order_for_display). Pin a row
|
||
with the ★ so the ones you care about stop scrolling off the bottom.
|
||
|
||
ONE <form>, not one-per-row: checkboxes drive the bulk delete, while the
|
||
per-row × and ★ are submit buttons with their own `formaction`. That keeps
|
||
all three actions in a single form (nested forms are invalid HTML) AND lets
|
||
every one work with JS off — JS only adds select-all and the live count.
|
||
|
||
Every action posts a CONTENT ID, never a row number: another session can
|
||
append between this page rendering and a click, and an index would then hit
|
||
a neighbour. An id matches the row the operator saw, or nothing. #}
|
||
{% set pinned_n = board | selectattr('pinned') | list | length %}
|
||
<form class="board" method="post" action="/b/{{ name_url }}/unlink-many" id="boardform">
|
||
<div class="board-head">
|
||
<label class="board-selall" title="select all"><input type="checkbox" id="board-selall"></label>
|
||
<span class="board-title">{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if pinned_n %} · {{ pinned_n }} pinned{% endif %}</span>
|
||
<span class="board-note">pinned first · newest on top · ★ pins a row · tick rows to delete</span>
|
||
<span class="board-spacer"></span>
|
||
<button type="submit" class="board-del-sel" id="board-del-sel"
|
||
formaction="/b/{{ name_url }}/unlink-many">🗑 delete <span id="board-selcount">0</span></button>
|
||
</div>
|
||
{% for e in board %}
|
||
<div class="board-row{% if e.pinned %} is-pinned{% endif %}">
|
||
<input class="board-check" type="checkbox" name="sel" value="{{ e.id }}" aria-label="select {{ e.desc }}">
|
||
<button type="submit" class="board-pin{% if e.pinned %} on{% endif %}" formaction="/b/{{ name_url }}/pin"
|
||
name="entry" value="{{ e.id }}" aria-pressed="{{ 'true' if e.pinned else 'false' }}"
|
||
title="{{ 'unpin' if e.pinned else 'pin to top' }}">{{ '★' if e.pinned else '☆' }}</button>
|
||
<div class="board-main">
|
||
<a class="board-link" href="{{ e.url }}" target="_blank" rel="noopener">{{ e.desc }}</a>
|
||
<div class="board-url">{{ e.url }}</div>
|
||
</div>
|
||
<div class="board-meta">
|
||
{% if e.who %}<span class="board-who">{{ e.who }}</span>{% endif %}
|
||
{% if e.when %}<span class="board-when">{{ e.when }}</span>{% endif %}
|
||
</div>
|
||
<button type="button" class="copy-btn board-copy" data-copy="{{ e.url }}" title="copy URL">⧉</button>
|
||
<button type="submit" class="board-rm-btn" formaction="/b/{{ name_url }}/unlink"
|
||
name="entry" value="{{ e.id }}" title="remove this link"
|
||
data-desc="{{ e.desc }}" data-url="{{ e.url }}">×</button>
|
||
</div>
|
||
{% endfor %}
|
||
</form>
|
||
{% endif %}
|
||
|
||
{% if not items and not board and not asks %}
|
||
<div class="empty">This booth is empty.</div>
|
||
{% elif items %}
|
||
{# `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. #}
|
||
<div class="gallery">
|
||
{% for it in items %}
|
||
{% 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 }}">
|
||
{% 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
|
||
the doc branch leaves the most readable content unblurred. Missed on
|
||
the first pass; caught by a live check, not by the suite. #}
|
||
<button type="button" class="reveal" aria-label="reveal {{ it.name }}">👁 reveal</button>
|
||
{% endif %}
|
||
<details class="doc-inline" open>
|
||
<summary class="doc-bar">
|
||
<span class="doc-chevron" aria-hidden="true">▸</span>
|
||
<span class="doc-name">{{ it.name }}</span>
|
||
<span class="doc-spacer"></span>
|
||
<a class="doc-act" href="view?f={{ it.url }}" title="open full page">⤢</a>
|
||
<a class="doc-act" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
|
||
<button type="button" class="doc-act doc-close" title="close (hide for now)" aria-label="close">✕</button>
|
||
</summary>
|
||
{% if it.rendered_html %}
|
||
<article class="markdown-body doc-body">{{ it.rendered|safe }}</article>
|
||
{% else %}
|
||
<pre class="textview doc-body">{{ it.rendered }}</pre>
|
||
{% endif %}
|
||
</details>
|
||
</figure>
|
||
{% else %}
|
||
<figure class="item item-{{ it.kind }}{% if it.blurred %} blurred{% endif %}" data-item="{{ it.name }}">
|
||
{% if it.blurred %}
|
||
{# Click-to-reveal is per-viewer and client-side: nothing is persisted, so
|
||
a reload re-hides it. No-JS degrades to STAYS BLURRED, which is the
|
||
safe direction to fail in. #}
|
||
<button type="button" class="reveal" aria-label="reveal {{ it.name }}">👁 reveal</button>
|
||
{% endif %}
|
||
{% if it.kind == 'image' %}
|
||
<a href="view?f={{ it.url }}"><img loading="lazy" src="{{ it.url }}" alt="{{ it.name }}"></a>
|
||
{% elif it.kind == 'video' %}
|
||
{# preload="none": a booth of a dozen webms was fetching them
|
||
all at page load ("metadata" still pulls real ranges per
|
||
file); nothing loads until the viewer hits play #}
|
||
<video controls preload="none" src="{{ it.url }}"></video>
|
||
{% elif it.kind == 'audio' %}
|
||
<audio controls preload="none" src="{{ it.url }}"></audio>
|
||
{% elif it.doc %}
|
||
{# a doc too large to inline (over DOC_MAX_BYTES) still links out #}
|
||
<a class="dl doc" href="view?f={{ it.url }}" title="view {{ it.name }}">📄 {{ it.name }}</a>
|
||
{% else %}
|
||
<a class="dl" href="{{ it.url }}" download>⬇ {{ it.name }}</a>
|
||
{% endif %}
|
||
{% if it.kind == 'other' %}
|
||
{% if it.caption %}<figcaption><span class="cap-text">{{ it.caption }}</span></figcaption>{% endif %}
|
||
{% else %}
|
||
<figcaption>
|
||
<a class="dl-link" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
|
||
<span class="cap-text">{{ it.caption or it.name }}</span>
|
||
<form class="blurtoggle" method="post" action="/b/{{ name_url }}/blur">
|
||
<input type="hidden" name="f" value="{{ it.name }}">
|
||
<input type="hidden" name="on" value="{{ '0' if it.blurred else '1' }}">
|
||
<button title="{{ 'un-blur this item' if it.blurred else 'blur this item (cosmetic only — the file is still served)' }}"
|
||
aria-label="{{ 'un-blur' if it.blurred else 'blur' }} {{ it.name }}">{{ '◉' if it.blurred else '◌' }}</button>
|
||
</form>
|
||
</figcaption>
|
||
{% endif %}
|
||
</figure>
|
||
{% endif %}
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
|
||
<script>
|
||
/* Copy-to-clipboard for any .copy-btn[data-copy]. The Booth serves over plain
|
||
HTTP on a LAN IP, where navigator.clipboard is undefined (secure-context
|
||
only) — so fall back to a hidden-textarea execCommand('copy'). */
|
||
(function () {
|
||
function copyText(t) {
|
||
if (navigator.clipboard && window.isSecureContext) {
|
||
return navigator.clipboard.writeText(t);
|
||
}
|
||
var ta = document.createElement('textarea');
|
||
ta.value = t;
|
||
ta.setAttribute('readonly', '');
|
||
ta.style.position = 'fixed';
|
||
ta.style.top = '-1000px';
|
||
ta.style.opacity = '0';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
try { document.execCommand('copy'); } catch (e) {}
|
||
document.body.removeChild(ta);
|
||
return Promise.resolve();
|
||
}
|
||
document.querySelectorAll('.copy-btn').forEach(function (btn) {
|
||
var label = btn.textContent;
|
||
btn.addEventListener('click', function () {
|
||
copyText(btn.getAttribute('data-copy')).then(function () {
|
||
btn.classList.add('copied');
|
||
btn.textContent = '✓ copied';
|
||
setTimeout(function () { btn.classList.remove('copied'); btn.textContent = label; }, 1300);
|
||
});
|
||
});
|
||
});
|
||
})();
|
||
|
||
/* 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 () {
|
||
document.querySelectorAll('.doc-close').forEach(function (btn) {
|
||
btn.addEventListener('click', function (ev) {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
var item = btn.closest('.item-doc');
|
||
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();
|
||
})();
|
||
</script>
|
||
{% endblock %}
|
||
|
||
<script>
|
||
/* Progressive enhancement only: without JS a blurred item stays blurred.
|
||
Reveal is per-viewer and never persisted — reloading re-hides. */
|
||
document.querySelectorAll('.item.blurred .reveal').forEach(function (btn) {
|
||
btn.addEventListener('click', function () {
|
||
var fig = btn.closest('.item');
|
||
var on = fig.classList.toggle('revealed');
|
||
btn.textContent = on ? '🙈 hide' : '👁 reveal';
|
||
});
|
||
});
|
||
</script>
|