Kept boards had no delete path in the UI at all. The kept lane deliberately omits the wipe control -- a one-click wipe next to the durable stuff is a footgun -- but "deliberate" had been implemented as "impossible": the only routes out were ssh or a hand-written API call. Now it is two deliberate acts. A `release` control on kept cards drops the sentinel, the board moves to the ephemeral lane, and the existing x wipes it from there. Release is reversible -- POST /b/<name>/keep pins it again. POST /b/<name>/unkeep release the pin POST /b/<name>/keep pin it (round-trip, so release is not a one-way door) FOUND WHILE TESTING, and it invalidates the previously-documented workaround: removing the sentinel BUMPS the booth directory's mtime, and booth age is the newest mtime in the tree -- so a released board's clock RESETS from 10,000s to 0s and it survives another full TTL. The old comment said "remove the sentinel first (it rejoins the sweep)", which is true but means the board lives another 24h, not that it gets reaped. Unkeep-and-wait is a delay, not a delete. test_releasing_a_board_RESETS_its_ttl_clock pins that behaviour deliberately so nobody re-derives the workaround. Release is what unlocks the x; the x is what deletes. CLI: `booth rm` already worked on kept boards but said nothing about it. It now announces "(was KEPT -- durable board)" so wiping something durable can never look identical to wiping run output. Not a block -- a CLI user naming a booth is being explicit. 5 new tests (67 pass). Verified live on nh3-dev: release renders on all four kept boards, the ephemeral lane keeps its x, and the links board is untouched with its sentinel intact.
129 lines
5.6 KiB
HTML
129 lines
5.6 KiB
HTML
{% extends "base.html" %}
|
||
{% block content %}
|
||
<form class="uploader" method="post" action="/upload" enctype="multipart/form-data">
|
||
<label class="drop" for="booth-files">
|
||
<span class="drop-icon">⬆</span>
|
||
<span class="drop-main">Upload files for pickup</span>
|
||
<span class="drop-sub" id="drop-sub">drop here, or click to choose · one pickup id, wiped in {{ ttl_hours }}h</span>
|
||
<input id="booth-files" name="files" type="file" multiple>
|
||
</label>
|
||
<button class="up-go" type="submit">Get pickup id →</button>
|
||
</form>
|
||
|
||
{% if kept %}
|
||
{# Kept boards render FIRST and look different on purpose: they are durable
|
||
operator-facing things (the agent link board, standing reports) and the
|
||
point of the lane is that they cannot be lost in a feed that turns over
|
||
every day. No countdown — they have no expiry to advertise. #}
|
||
<h2 class="lane-head">Kept <span class="lane-note">· no expiry · <code>{{ keep_marker }}</code></span></h2>
|
||
<div class="grid kept-grid">
|
||
{% for b in kept %}
|
||
<article class="card card-kept">
|
||
<a class="thumb" href="/b/{{ b.name_url }}/">
|
||
{% if b.thumb_url %}
|
||
<img loading="lazy" src="/b/{{ b.name_url }}/{{ b.thumb_url }}" alt="">
|
||
{% elif b.has_index %}
|
||
<div class="ph">▦ page</div>
|
||
{% elif b.kinds.video %}
|
||
<div class="ph">▶ video</div>
|
||
{% elif b.kinds.audio %}
|
||
<div class="ph">♪ audio</div>
|
||
{% else %}
|
||
<div class="ph">◆ files</div>
|
||
{% endif %}
|
||
<span class="badge badge-kept">★ kept</span>
|
||
</a>
|
||
<div class="meta">
|
||
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
||
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · kept · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
|
||
</div>
|
||
{# Still no × here — a one-click wipe next to the durable stuff is a
|
||
footgun. But "deliberate" must not mean "impossible from the UI",
|
||
which is what it meant before: the only routes out were ssh or a
|
||
hand-written API call. Release drops the sentinel and the board moves
|
||
to the ephemeral lane, where the × already lives. Two deliberate
|
||
acts, both reachable, and the first one is reversible.
|
||
|
||
The confirm says "wipe it from there" rather than "let it expire" on
|
||
purpose: releasing BUMPS the directory mtime, so the board's age
|
||
resets and it survives another full TTL. Unkeep-and-wait is a 24h
|
||
delay, not a delete. #}
|
||
<form class="release" method="post" action="/b/{{ b.name_url }}/unkeep"
|
||
onsubmit="return confirm('Release \u201c{{ b.name }}\u201d?\n\nIt moves to the ephemeral lane so you can wipe it from there. Nothing is deleted by this step.')">
|
||
<button title="release this board so it can be wiped">release</button>
|
||
</form>
|
||
</article>
|
||
{% endfor %}
|
||
</div>
|
||
{% if booths %}<h2 class="lane-head">Ephemeral <span class="lane-note">· wiped {{ ttl_hours }}h after last activity</span></h2>{% endif %}
|
||
{% endif %}
|
||
|
||
{% if not booths %}
|
||
{% if not kept %}
|
||
<div class="empty">
|
||
No booths yet. Upload files above, or drop a folder into <code>{{ data_dir }}</code>.
|
||
</div>
|
||
{% endif %}
|
||
{% else %}
|
||
<div class="grid">
|
||
{% for b in booths %}
|
||
<article class="card">
|
||
<a class="thumb" href="/b/{{ b.name_url }}/">
|
||
{% if b.thumb_url %}
|
||
<img loading="lazy" src="/b/{{ b.name_url }}/{{ b.thumb_url }}" alt="">
|
||
{% elif b.has_index %}
|
||
<div class="ph">▦ page</div>
|
||
{% elif b.kinds.video %}
|
||
<div class="ph">▶ video</div>
|
||
{% elif b.kinds.audio %}
|
||
<div class="ph">♪ audio</div>
|
||
{% else %}
|
||
<div class="ph">◆ files</div>
|
||
{% endif %}
|
||
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
|
||
</a>
|
||
<div class="meta">
|
||
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
||
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · expires in {{ b.expires_in|dur }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
|
||
</div>
|
||
<form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
|
||
onsubmit="return confirm('Wipe booth “{{ b.name }}”?')">
|
||
<button title="wipe now" aria-label="wipe booth">×</button>
|
||
</form>
|
||
</article>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
|
||
<script>
|
||
/* progressive enhancement: reflect chosen files + drag-drop onto the panel.
|
||
With JS off, the native file input + submit still works. */
|
||
(function () {
|
||
var input = document.getElementById('booth-files');
|
||
var sub = document.getElementById('drop-sub');
|
||
var drop = document.querySelector('.drop');
|
||
if (!input) return;
|
||
function show() {
|
||
var n = input.files ? input.files.length : 0;
|
||
if (n) {
|
||
sub.textContent = n + ' file' + (n > 1 ? 's' : '') + ' ready — hit “Get pickup id”';
|
||
drop.classList.add('has');
|
||
}
|
||
}
|
||
input.addEventListener('change', show);
|
||
['dragover', 'dragenter'].forEach(function (e) {
|
||
drop.addEventListener(e, function (ev) { ev.preventDefault(); drop.classList.add('over'); });
|
||
});
|
||
['dragleave', 'drop'].forEach(function (e) {
|
||
drop.addEventListener(e, function (ev) { ev.preventDefault(); drop.classList.remove('over'); });
|
||
});
|
||
drop.addEventListener('drop', function (ev) {
|
||
if (ev.dataTransfer && ev.dataTransfer.files.length) {
|
||
try { input.files = ev.dataTransfer.files; } catch (_) {}
|
||
show();
|
||
}
|
||
});
|
||
})();
|
||
</script>
|
||
{% endblock %}
|