host: logged-in "add related" hint + filterable infinite-scroll relate picker

Make relating discoverable and pleasant: a hint on posts with no relations, and
a real candidate picker on the edit page.

- post page: when a post has no relations AND the viewer is logged in, show a
  subtle "No related posts yet — add some" hint linking to the edit page;
  anonymous viewers still see nothing.
- GET /<slug>/relate-options?q=&offset= — SX endpoint returning one page of
  candidate rows (HTML <li> fragment): every post except itself and ones already
  related, narrowed by q (case-insensitive title/slug substring), title-sorted,
  paginated by host/blog--picker-limit. Public read; the relate POST stays
  guarded.
- GET /relate-picker.js — small vanilla glue (debounced live filter +
  scroll-to-load-more) served from a route. The host serves static HTML (no SX
  island hydration), so the interactive layer is a cached script, not an island;
  data-slug on the input carries the post to it.
- edit page: the plain "slug to relate" box becomes a filter input + scrollable
  results list (#relate-filter/#relate-results) populated by the script; each row
  is a one-click relate form.

8 tests: endpoint lists/excludes-self/filters-by-q/excludes-already-related, JS
route content-type + glue, hint shown logged-in / hidden anonymous. 238/238.
Verified live: hint (logged-in only), candidate rows, q=filter, JS route
(node --check OK), edit picker UI with data-slug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 10:53:54 +00:00
parent ccbee8c1be
commit 04aa537c7b
2 changed files with 124 additions and 20 deletions

View File

@@ -249,6 +249,34 @@
(contains? (host/blog-related "my-first-post") "another-one"))
false)
;; -- relate picker (filterable candidate endpoint + glue + hint) --
(host/blog-put! "alpha-post" "Alpha Post" "(p \"a\")" "published")
(host/blog-put! "beta-post" "Beta Post" "(p \"b\")" "published")
(host/blog-put! "gamma-post" "Gamma Post" "(p \"g\")" "published")
(host-bl-test "relate-options lists other posts"
(contains? (dream-resp-body (host-bl-app (host-bl-req "/alpha-post/relate-options"))) "Beta Post") true)
(host-bl-test "relate-options excludes the post itself"
(contains? (dream-resp-body (host-bl-app (host-bl-req "/alpha-post/relate-options"))) ">Alpha Post<") false)
(host-bl-test "relate-options filters by q (title substring)"
(let ((body (dream-resp-body (host-bl-app (host-bl-req "/alpha-post/relate-options?q=beta")))))
(list (contains? body "Beta Post") (contains? body "Gamma Post")))
(list true false))
(host-bl-test "relate-options excludes already-related candidates"
(begin
(host/blog-relate! "alpha-post" "beta-post")
(contains? (dream-resp-body (host-bl-app (host-bl-req "/alpha-post/relate-options"))) "Beta Post"))
false)
(host/blog-unrelate! "alpha-post" "beta-post")
(host-bl-test "relate-picker.js served as javascript"
(dream-resp-header (host-bl-app (host-bl-req "/relate-picker.js")) "content-type")
"application/javascript; charset=utf-8")
(host-bl-test "relate-picker.js carries the fetch glue"
(contains? (dream-resp-body (host-bl-app (host-bl-req "/relate-picker.js"))) "relate-options") true)
(host-bl-test "related block: hint when logged-in + no relations"
(contains? (str (host/blog--related-block "gamma-post" true)) "add some") true)
(host-bl-test "related block: empty when anonymous + no relations"
(= (host/blog--related-block "gamma-post" false) "") true)
;; -- experimental unguarded create-only route (POST /new, no auth) --
(define host-bl-oapp (host/make-app (list host/blog-open-create-routes host/blog-routes)))
(host/blog-use-store! (persist/open))