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()
|
||||
|
||||
Reference in New Issue
Block a user