Files
esh-pfi-infrastructure/services/booth/booth/templates/booth.html
T
vh a56743ade3 fix(booth): asks were invisible in a booth serving its own index.html
A custom index.html is returned verbatim, so booth.html's asks panel never
rendered there — a valid ask (emmie-anchor/anchor.ask.json) was listed by the
CLI and shown nowhere, with nothing to say so.

- panel extracted to _asks.html; new GET /b/<name>/asks standalone page
- verbatim pages get an amber '? N open asks' chip beside the back chip
- POST /answer honours back=asks so answering returns to that page
- single-question asks now keep an optional 'title' (was silently dropped)
- README + routes table; 8 regression tests; v0.1.12
2026-09-09 10:39:18 -07:00

252 lines
12 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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" data-name="{{ it.name }}">
<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.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>
</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 %}