feat(booth): pin/favorite, multi-select delete, newest-first link board
The standing link board grew from a flat oldest-first list with a per-row × into a manageable board: newest links lead, favorites stay on top, and several dead links can go in one pass. - Ordering: order_for_display() renders pinned rows first, then newest-first within each group (the board is an append log, so newest = most recently posted — the row you usually came to grab). - Pin/favorite: a per-row ★ toggles pinned state via POST /b/<name>/pin. State lives in a .pins sidecar dotfile (one content id per line), NOT inline in links.md — so links.md stays a pure atomic-append log (many sessions post concurrently) and a row's content id never changes just because it was pinned. remove_link_entry drops a removed row's pin; orphaned pins are inert (renderer only stars a live id). - Multi-select delete: checkboxes feed POST /b/<name>/unlink-many (repeated 'sel' content ids), with a select-all box and a live count. The per-row × stays for single removal. - One <form> with formaction buttons, so checkboxes, ×, ★, and bulk delete coexist without nested forms AND all work with JS off; JS only adds select-all and the live count. Per-row × confirm reads desc/url from data-* attrs, so an arbitrary posted description can't break into the JS. - Every action is keyed by content id, never row position — same race-safety the existing × has, extended to the bulk path. - Fixed pre-existing undefined --fg/--bg CSS refs in the board styles. Tests: +19 (pins round-trip, ordering, orphan-inert, remove-unpins, /pin and /unlink-many endpoints, board render + order). Full suite 102 passing. Deployed to nh3-dev booth.service; verified live (newest-first, pin round-trip, bulk delete) against the real 31-row board with no data loss.
This commit is contained in:
+35
-4
@@ -78,9 +78,13 @@ KEEP_MARKER = ".forever"
|
||||
from booth.links import ( # noqa: E402
|
||||
LINK_LOCK,
|
||||
LINKS_FILE,
|
||||
PINS_FILE,
|
||||
link_entry_id,
|
||||
order_for_display,
|
||||
parse_link_entries,
|
||||
read_pins,
|
||||
remove_link_entry,
|
||||
toggle_pin,
|
||||
)
|
||||
|
||||
|
||||
@@ -604,11 +608,16 @@ def create_app(
|
||||
if not ((booth / LINKS_FILE).is_file() and it["name"] == LINKS_FILE)
|
||||
],
|
||||
# A booth carrying links.md is the standing link board: render
|
||||
# its rows as real UI (link, provenance, per-row remove) instead
|
||||
# of a markdown blob you can only edit by hand. Empty list for
|
||||
# every other booth, so the template branch simply does not fire.
|
||||
# its rows as real UI (link, provenance, pin, per-row + bulk
|
||||
# remove) instead of a markdown blob you can only edit by hand.
|
||||
# Ordered pinned-first then newest-first, each row stamped with a
|
||||
# `pinned` flag. Empty list for every other booth, so the template
|
||||
# branch simply does not fire.
|
||||
"board": (
|
||||
parse_link_entries((booth / LINKS_FILE).read_text())
|
||||
order_for_display(
|
||||
parse_link_entries((booth / LINKS_FILE).read_text()),
|
||||
read_pins(booth),
|
||||
)
|
||||
if (booth / LINKS_FILE).is_file() else []
|
||||
),
|
||||
"uploaded": (booth / UPLOAD_MARKER).exists(),
|
||||
@@ -736,6 +745,28 @@ def create_app(
|
||||
pass
|
||||
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
|
||||
|
||||
@app.post("/b/{name}/unlink-many")
|
||||
def board_unlink_many(name: str, sel: list[str] = Form(default=[])):
|
||||
"""Remove SEVERAL rows in one go — the multi-select delete.
|
||||
|
||||
Each `sel` is a content id (same identity the per-row × uses), so the same
|
||||
race-safety holds: an id either matches the row the operator selected or
|
||||
matches nothing, never a neighbour that another session appended in the
|
||||
meantime. An empty selection is a no-op, not an error.
|
||||
"""
|
||||
board = resolve_booth(name)
|
||||
for entry_id in sel:
|
||||
remove_link_entry(board, entry_id)
|
||||
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
|
||||
|
||||
@app.post("/b/{name}/pin")
|
||||
def board_pin(name: str, entry: str = Form(...)):
|
||||
"""Toggle a row's pinned (favorite) state, by content id. Pinned rows
|
||||
float to the top of the board; toggling again unpins. Reversible, so no
|
||||
confirmation — unlike removal."""
|
||||
toggle_pin(resolve_booth(name), entry)
|
||||
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
|
||||
|
||||
@app.post("/b/{name}/keep")
|
||||
def booth_keep(name: str):
|
||||
(resolve_booth(name) / KEEP_MARKER).touch()
|
||||
|
||||
@@ -32,6 +32,20 @@ from pathlib import Path
|
||||
LINKS_FILE = "links.md"
|
||||
LINK_LOCK = ".links.lock"
|
||||
|
||||
# Pin state lives in a sidecar dotfile — one content id per line — NOT inline in
|
||||
# links.md. Three reasons this is the right seam:
|
||||
# * links.md stays a pure append log: `booth link` remains a single atomic
|
||||
# O_APPEND write, which is what lets many fleet sessions post concurrently
|
||||
# without a lock on the common path.
|
||||
# * pinning never rewrites a row, so a row's content id (its identity for
|
||||
# removal) never changes just because it was pinned.
|
||||
# * it mirrors the `.forever` sentinel already in play — a dotfile the listing
|
||||
# code skips, so it costs nothing in item counts or galleries.
|
||||
# Orphaned ids (a row hand-edited so its id drifts, or removed) are inert: the
|
||||
# renderer only marks a row pinned when a live row still carries that id, and
|
||||
# remove_link_entry drops the id as it deletes the row.
|
||||
PINS_FILE = ".pins"
|
||||
|
||||
# - [description](url) <sub>· who · when</sub>
|
||||
_LINK_RE = re.compile(
|
||||
r"^- \[(?P<desc>.*?)\]\((?P<url>[^)]*)\)"
|
||||
@@ -102,6 +116,81 @@ def remove_link_entry(board: Path, entry_id: str) -> dict | None:
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text("".join(kept))
|
||||
os.replace(tmp, path)
|
||||
# The row is gone; drop any pin that referenced it so .pins does not
|
||||
# accumulate dead ids. Same critical section, so a concurrent pin
|
||||
# toggle cannot race this rewrite.
|
||||
pins = _read_pins_unlocked(board)
|
||||
if entry_id in pins:
|
||||
pins.discard(entry_id)
|
||||
_write_pins_unlocked(board, pins)
|
||||
return removed
|
||||
finally:
|
||||
fcntl.flock(lf, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
# ---- pins: favorite a row so it floats to the top --------------------------
|
||||
|
||||
|
||||
def _read_pins_unlocked(board: Path) -> set[str]:
|
||||
path = board / PINS_FILE
|
||||
if not path.exists():
|
||||
return set()
|
||||
try:
|
||||
return {ln.strip() for ln in path.read_text().splitlines() if ln.strip()}
|
||||
except OSError:
|
||||
return set()
|
||||
|
||||
|
||||
def _write_pins_unlocked(board: Path, ids: set[str]) -> None:
|
||||
"""Atomic replace of the pins file. Caller must hold the board lock."""
|
||||
path = board / PINS_FILE
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text("".join(f"{i}\n" for i in sorted(ids)))
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def read_pins(board: Path) -> set[str]:
|
||||
"""Pinned entry ids for a board. Missing file → empty set. Lock-free: a set
|
||||
read of a dotfile the sweeper never touches, safe to call on the render path."""
|
||||
return _read_pins_unlocked(Path(board))
|
||||
|
||||
|
||||
def toggle_pin(board: Path, entry_id: str) -> bool:
|
||||
"""Flip one row's pinned state. Returns the NEW state (True = now pinned).
|
||||
|
||||
Held under the same sidecar flock as append and remove, so a toggle cannot
|
||||
interleave with a board rewrite. Pure add/remove of the id — orphan pruning
|
||||
is the remover's job (remove_link_entry) and the renderer's (a pin with no
|
||||
live row is simply not shown as pinned)."""
|
||||
board = Path(board)
|
||||
lock = board / LINK_LOCK
|
||||
lock.touch(exist_ok=True)
|
||||
with lock.open("r+") as lf:
|
||||
fcntl.flock(lf, fcntl.LOCK_EX)
|
||||
try:
|
||||
pins = _read_pins_unlocked(board)
|
||||
if entry_id in pins:
|
||||
pins.discard(entry_id)
|
||||
new_state = False
|
||||
else:
|
||||
pins.add(entry_id)
|
||||
new_state = True
|
||||
_write_pins_unlocked(board, pins)
|
||||
return new_state
|
||||
finally:
|
||||
fcntl.flock(lf, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def order_for_display(entries: list[dict], pinned: set[str]) -> list[dict]:
|
||||
"""Board rows for the web view: pinned first, then newest-first in each group.
|
||||
|
||||
`entries` arrive from parse_link_entries in file order (oldest first). Each
|
||||
returned row is a copy stamped with a `pinned` bool (the input dicts are left
|
||||
untouched, so parse output stays a faithful file-order view for callers that
|
||||
want it — e.g. the CLI). Within both the pinned and the unpinned group the
|
||||
most recently appended row leads, which is what "newest on top" means for an
|
||||
append log.
|
||||
"""
|
||||
stamped = [{**e, "pinned": e["id"] in pinned} for e in entries]
|
||||
stamped.reverse() # newest first
|
||||
return [e for e in stamped if e["pinned"]] + [e for e in stamped if not e["pinned"]]
|
||||
|
||||
+39
-15
@@ -138,32 +138,56 @@
|
||||
attention with the thing you came to read. */
|
||||
.board{border:1px solid var(--border-subtle);border-radius:.5rem;overflow:hidden;
|
||||
background:var(--rk-panel);margin:.6rem 0 1rem}
|
||||
.board-head{display:flex;align-items:baseline;gap:.6rem;padding:.5rem .75rem;
|
||||
border-bottom:1px solid var(--border-subtle);background:var(--bg)}
|
||||
.board-head{display:flex;align-items:center;gap:.6rem;padding:.45rem .75rem;
|
||||
border-bottom:1px solid var(--border-subtle);background:var(--rk-well)}
|
||||
.board-selall{display:inline-flex;align-items:center;cursor:pointer;flex:0 0 auto}
|
||||
.board-title{font-weight:600;font-size:.85rem}
|
||||
.board-note{font-size:.72rem;opacity:.55}
|
||||
.board-note{font-size:.72rem;color:var(--fg-3)}
|
||||
.board-spacer{flex:1 1 auto}
|
||||
/* Bulk delete: quiet until something is selected. Red-outline danger word,
|
||||
matching the .wipe-lg language — fills on hover, greys out when disabled. */
|
||||
.board-del-sel{flex:0 0 auto;cursor:pointer;font-family:var(--font-mono);font-size:.7rem;
|
||||
letter-spacing:.06em;padding:.28rem .6rem;border-radius:var(--radius-sm);
|
||||
background:transparent;border:1px solid var(--aus-red);color:var(--aus-bright-red);
|
||||
transition:.14s var(--ease-out)}
|
||||
.board-del-sel:hover:not(:disabled){background:var(--aus-red);border-color:var(--aus-red);color:#fff}
|
||||
.board-del-sel:disabled{opacity:.4;cursor:default;border-color:var(--border-default);color:var(--fg-3)}
|
||||
|
||||
.board-row{display:flex;align-items:center;gap:.6rem;padding:.45rem .75rem;
|
||||
border-bottom:1px solid var(--border-subtle);transition:background .1s}
|
||||
.board-row:last-child{border-bottom:0}
|
||||
.board-row:hover{background:var(--bg)}
|
||||
.board-row:hover{background:var(--rk-well)}
|
||||
/* Pinned rows: a subtle cyan wash + a filled star. NOT a coloured left border
|
||||
(Australis forbids it); the tint is enough to group them, and the ★ carries
|
||||
the state at the row level. */
|
||||
.board-row.is-pinned{background:rgba(66,220,209,.05)}
|
||||
.board-row.is-pinned:hover{background:rgba(66,220,209,.09)}
|
||||
.board-check{flex:0 0 auto;width:1rem;height:1rem;cursor:pointer;accent-color:var(--aus-bright-cyan)}
|
||||
.board-selall input{width:1rem;height:1rem;cursor:pointer;accent-color:var(--aus-bright-cyan)}
|
||||
/* ★ favorite toggle. Always visible (unlike ×) because it carries state you
|
||||
need to see; dim-hollow when off, warm-gold and filled when pinned. */
|
||||
.board-pin{flex:0 0 auto;border:0;background:none;cursor:pointer;line-height:1;
|
||||
font-size:1.02rem;padding:.05rem .2rem;border-radius:.25rem;color:var(--fg-3);
|
||||
transition:color .12s,transform .12s var(--ease-out)}
|
||||
.board-pin:hover{color:var(--aus-bright-yellow);transform:scale(1.15)}
|
||||
.board-pin.on{color:var(--aus-bright-yellow)}
|
||||
.board-main{flex:1 1 auto;min-width:0}
|
||||
.board-link{font-size:.9rem;text-decoration:none;font-weight:500}
|
||||
.board-link:hover{text-decoration:underline}
|
||||
.board-url{font-size:.7rem;opacity:.45;overflow:hidden;text-overflow:ellipsis;
|
||||
white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
||||
.board-url{font-size:.7rem;color:var(--fg-3);opacity:.8;overflow:hidden;text-overflow:ellipsis;
|
||||
white-space:nowrap;font-family:var(--font-mono)}
|
||||
.board-meta{flex:0 0 auto;display:flex;flex-direction:column;align-items:flex-end;
|
||||
gap:.05rem;font-size:.68rem;opacity:.5;white-space:nowrap}
|
||||
gap:.05rem;font-size:.68rem;color:var(--fg-3);white-space:nowrap}
|
||||
.board-who{font-weight:600}
|
||||
.board-copy,.board-rm button{opacity:0;transition:opacity .12s;flex:0 0 auto}
|
||||
.board-row:hover .board-copy,.board-row:hover .board-rm button,
|
||||
.board-copy:focus,.board-rm button:focus{opacity:1}
|
||||
.board-rm{flex:0 0 auto;margin:0}
|
||||
.board-rm button{font:inherit;font-size:1rem;line-height:1;padding:.1rem .35rem;
|
||||
border:0;background:none;cursor:pointer;color:var(--fg);border-radius:.25rem}
|
||||
.board-rm button:hover{background:#c0392b;color:#fff}
|
||||
.board-copy,.board-rm-btn{opacity:0;transition:opacity .12s;flex:0 0 auto}
|
||||
.board-row:hover .board-copy,.board-row:hover .board-rm-btn,
|
||||
.board-copy:focus,.board-rm-btn:focus{opacity:1}
|
||||
.board-rm-btn{font:inherit;font-size:1rem;line-height:1;padding:.1rem .35rem;
|
||||
border:0;background:none;cursor:pointer;color:var(--fg-2);border-radius:.25rem}
|
||||
.board-rm-btn:hover{background:var(--aus-red);color:#fff}
|
||||
@media (max-width:600px){
|
||||
/* No hover on touch — controls must be permanently visible or unreachable. */
|
||||
.board-copy,.board-rm button{opacity:1}
|
||||
.board-copy,.board-rm-btn{opacity:1}
|
||||
.board-meta{display:none}
|
||||
}
|
||||
|
||||
|
||||
+85
-13
@@ -33,16 +33,33 @@
|
||||
it, when) is readable at a glance, which is the whole reason a bare URL
|
||||
three days old is useless.
|
||||
|
||||
Removal posts a CONTENT ID, never a row number: another session can append
|
||||
between this page rendering and the × being clicked, and an index would
|
||||
then delete a neighbour. #}
|
||||
<div class="board">
|
||||
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">
|
||||
<span class="board-title">{{ board|length }} link{{ '' if board|length == 1 else 's' }}</span>
|
||||
<span class="board-note">newest last · appended by any session · × removes one row</span>
|
||||
<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">
|
||||
<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>
|
||||
@@ -52,14 +69,12 @@
|
||||
{% 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>
|
||||
<form class="board-rm" method="post" action="/b/{{ name_url }}/unlink"
|
||||
onsubmit="return confirm('Remove this link?\n\n{{ e.desc }}\n{{ e.url }}\n\nThe rest of the board is untouched.')">
|
||||
<input type="hidden" name="entry" value="{{ e.id }}">
|
||||
<button title="remove this link">×</button>
|
||||
</form>
|
||||
<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 %}
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if not items and not board %}
|
||||
@@ -171,5 +186,62 @@
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
/* 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 %}
|
||||
|
||||
Reference in New Issue
Block a user