From e75c8d16d18cd0f95614085966f7a024bdccbeb1 Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 4 Mar 2026 09:14:23 +0000 Subject: [PATCH] Move blog index rendering from Python to .sx composition defcomps BlogPageService.index_data() assembles all data (cards, filters, actions) and 7 new .sx defcomps handle rendering: main content, aside, filter, actions, tag groups filter, authors filter, and sentinel. Co-Authored-By: Claude Opus 4.6 --- blog/bp/blog/routes.py | 97 +++++----------- blog/services/blog_page.py | 159 ++++++++++++++++++++++++++ blog/sx/index.sx | 221 +++++++++++++++++++++++++++++++++++++ 3 files changed, 406 insertions(+), 71 deletions(-) diff --git a/blog/bp/blog/routes.py b/blog/bp/blog/routes.py index b51ee40..7d25001 100644 --- a/blog/bp/blog/routes.py +++ b/blog/bp/blog/routes.py @@ -133,85 +133,40 @@ def register(url_prefix, title): @blogs_bp.get("/index/") async def index(): """Blog listing — moved from / to /index.""" - - q = decode() - content_type = request.args.get("type", "posts") - - if content_type == "pages": - data = await pages_data(g.s, q.page, q.search) - context = { - **data, - "content_type": "pages", - "search": q.search, - "selected_tags": (), - "selected_authors": (), - "selected_groups": (), - "sort": None, - "view": None, - "drafts": None, - "draft_count": 0, - "tags": [], - "authors": [], - "tag_groups": [], - "posts": data.get("pages", []), - } - from shared.sx.page import get_template_context - from sx.sx_components import render_blog_page, render_blog_oob, render_blog_page_cards - - tctx = await get_template_context() - tctx.update(context) - if not is_htmx_request(): - html = await render_blog_page(tctx) - return await make_response(html) - elif q.page > 1: - sx_src = await render_blog_page_cards(tctx) - return sx_response(sx_src) - else: - sx_src = await render_blog_oob(tctx) - return sx_response(sx_src) - - # Default: posts listing - # Drafts filter requires login; ignore if not logged in - show_drafts = bool(q.drafts and g.user) - is_admin = bool((g.get("rights") or {}).get("admin")) - drafts_user_id = None if (not show_drafts or is_admin) else g.user.id - - # For the draft count badge: admin sees all drafts, non-admin sees own - count_drafts_uid = None if (g.user and is_admin) else (g.user.id if g.user else False) - - data = await posts_data( - g.s, q.page, q.search, q.sort, q.selected_tags, q.selected_authors, q.liked, - drafts=show_drafts, drafts_user_id=drafts_user_id, - count_drafts_for_user_id=count_drafts_uid, - selected_groups=q.selected_groups, + from shared.services.registry import services + from shared.sx.helpers import ( + render_to_sx, root_header_sx, full_page_sx, oob_page_sx, ) + from sx.sx_components import _blog_header_sx, _oob_header_sx + from shared.sx.parser import SxExpr - context = { - **data, - "content_type": "posts", - "selected_tags": q.selected_tags, - "selected_authors": q.selected_authors, - "selected_groups": q.selected_groups, - "sort": q.sort, - "search": q.search, - "view": q.view, - "drafts": q.drafts if show_drafts else None, - } + data = await services.get("blog_page").index_data(g.s) + + # Render content, aside, and filter via .sx defcomps + content = await render_to_sx("blog-index-main-content", **data) + aside = await render_to_sx("blog-index-aside-content", **data) + filter_sx = await render_to_sx("blog-index-filter-content", **data) from shared.sx.page import get_template_context - from sx.sx_components import render_blog_page, render_blog_oob, render_blog_cards - tctx = await get_template_context() - tctx.update(context) + if not is_htmx_request(): - html = await render_blog_page(tctx) + root_hdr = await root_header_sx(tctx) + blog_hdr = await _blog_header_sx(tctx) + header_rows = "(<> " + root_hdr + " " + blog_hdr + ")" + html = await full_page_sx(tctx, header_rows=header_rows, + content=content, aside=aside, filter=filter_sx) return await make_response(html) - elif q.page > 1: - # Sx wire format — client renders blog cards - sx_src = await render_blog_cards(tctx) - return sx_response(sx_src) + elif data.get("page", 1) > 1: + # Pagination — return just the cards + return sx_response(content) else: - sx_src = await render_blog_oob(tctx) + root_hdr = await root_header_sx(tctx) + blog_hdr = await _blog_header_sx(tctx) + rows = "(<> " + root_hdr + " " + blog_hdr + ")" + header_oob = await _oob_header_sx("root-header-child", "blog-header-child", rows) + sx_src = await oob_page_sx(oobs=header_oob, content=content, + aside=aside, filter=filter_sx) return sx_response(sx_src) @blogs_bp.post("/new/") diff --git a/blog/services/blog_page.py b/blog/services/blog_page.py index da57df2..247c6ee 100644 --- a/blog/services/blog_page.py +++ b/blog/services/blog_page.py @@ -171,6 +171,165 @@ class BlogPageService: "csrf": generate_csrf_token(), } + async def index_data(self, session, **kw): + """Blog index page data — posts or pages listing with filters.""" + from quart import g, request, url_for as qurl + from bp.blog.services.posts_data import posts_data + from bp.blog.services.pages_data import pages_data + from bp.blog.filters.qs import decode + from shared.utils import host_url + from shared.browser.app.csrf import generate_csrf_token + + q = decode() + content_type = request.args.get("type", "posts") + is_admin = bool((g.get("rights") or {}).get("admin")) + user = getattr(g, "user", None) + csrf = generate_csrf_token() + + blog_url_base = host_url(qurl("blog.index")).rstrip("/index").rstrip("/") + + if content_type == "pages": + data = await pages_data(session, q.page, q.search) + posts_list = data.get("pages", []) + tag_groups_raw = [] + authors_raw = [] + draft_count = 0 + selected_tags = () + selected_authors = () + selected_groups = () + else: + show_drafts = bool(q.drafts and user) + drafts_user_id = None if (not show_drafts or is_admin) else user.id + count_drafts_uid = None if (user and is_admin) else (user.id if user else False) + data = await posts_data( + session, q.page, q.search, q.sort, q.selected_tags, + q.selected_authors, q.liked, + drafts=show_drafts, drafts_user_id=drafts_user_id, + count_drafts_for_user_id=count_drafts_uid, + selected_groups=q.selected_groups, + ) + posts_list = data.get("posts", []) + tag_groups_raw = data.get("tag_groups", []) + authors_raw = data.get("authors", []) + draft_count = data.get("draft_count", 0) + selected_tags = q.selected_tags + selected_authors = q.selected_authors + selected_groups = q.selected_groups + + page_num = data.get("page", q.page) + total_pages = data.get("total_pages", 1) + card_widgets = data.get("card_widgets_html", {}) + + current_local_href = f"{blog_url_base}/index" + if content_type == "pages": + current_local_href += "?type=pages" + hx_select = "#main-panel" + + # Serialize posts for cards + def _format_ts(dt): + if not dt: + return "" + return dt.strftime("%-d %b %Y at %H:%M") if hasattr(dt, "strftime") else str(dt) + + cards = [] + for p in posts_list: + slug = p.get("slug", "") + href = f"{blog_url_base}/{slug}/" + status = p.get("status", "published") + is_draft = status == "draft" + ts = _format_ts(p.get("updated_at") if is_draft else p.get("published_at")) + tags = [] + for t in (p.get("tags") or []): + name = t.get("name") or getattr(t, "name", "") + fi = t.get("feature_image") or getattr(t, "feature_image", None) + tags.append({"name": name, "src": fi or "", "initial": name[:1] if name else ""}) + authors = [] + for a in (p.get("authors") or []): + name = a.get("name") or getattr(a, "name", "") + img = a.get("profile_image") or getattr(a, "profile_image", None) + authors.append({"name": name, "image": img or ""}) + card = { + "slug": slug, "href": href, "hx_select": hx_select, + "title": p.get("title", ""), "feature_image": p.get("feature_image"), + "excerpt": p.get("custom_excerpt") or p.get("excerpt", ""), + "is_draft": is_draft, + "publish_requested": p.get("publish_requested", False) if is_draft else False, + "status_timestamp": ts, + "tags": tags, "authors": authors, + "has_like": bool(user), + } + if user: + card["liked"] = p.get("is_liked", False) + card["like_url"] = f"{blog_url_base}/{slug}/like/toggle/" + card["csrf_token"] = csrf + widget = card_widgets.get(str(p.get("id", "")), "") + if widget: + card["widget"] = widget + # Page-specific fields + features = p.get("features") or {} + if content_type == "pages": + card["has_calendar"] = features.get("calendar", False) + card["has_market"] = features.get("market", False) + card["pub_timestamp"] = ts + cards.append(card) + + # Serialize tag groups for filter + tag_groups = [] + for grp in tag_groups_raw: + g_slug = grp.get("slug", "") if isinstance(grp, dict) else getattr(grp, "slug", "") + g_name = grp.get("name", "") if isinstance(grp, dict) else getattr(grp, "name", "") + g_fi = grp.get("feature_image") if isinstance(grp, dict) else getattr(grp, "feature_image", None) + g_colour = grp.get("colour") if isinstance(grp, dict) else getattr(grp, "colour", None) + g_count = grp.get("post_count", 0) if isinstance(grp, dict) else getattr(grp, "post_count", 0) + if g_count <= 0 and g_slug not in selected_groups: + continue + tag_groups.append({ + "slug": g_slug, "name": g_name, "feature_image": g_fi, + "colour": g_colour, "post_count": g_count, + "is_selected": g_slug in selected_groups, + }) + + # Serialize authors for filter + authors_list = [] + for a in authors_raw: + a_slug = a.get("slug", "") if isinstance(a, dict) else getattr(a, "slug", "") + a_name = a.get("name", "") if isinstance(a, dict) else getattr(a, "name", "") + a_img = a.get("profile_image") if isinstance(a, dict) else getattr(a, "profile_image", None) + a_count = a.get("published_post_count", 0) if isinstance(a, dict) else getattr(a, "published_post_count", 0) + authors_list.append({ + "slug": a_slug, "name": a_name, "profile_image": a_img, + "published_post_count": a_count, + "is_selected": a_slug in selected_authors, + }) + + # Filter summary names + tg_summary_names = [grp["name"] for grp in tag_groups if grp["is_selected"]] + au_summary_names = [a["name"] for a in authors_list if a["is_selected"]] + + return { + "content_type": content_type, + "view": q.view, + "cards": cards, + "page": page_num, + "total_pages": total_pages, + "current_local_href": current_local_href, + "hx_select": hx_select, + "is_admin": is_admin, + "has_user": bool(user), + "draft_count": draft_count, + "drafts": bool(q.drafts) if user else False, + "new_post_href": f"{blog_url_base}/new/", + "new_page_href": f"{blog_url_base}/new-page/", + "tag_groups": tag_groups, + "authors": authors_list, + "is_any_group": len(selected_groups) == 0 and len(selected_tags) == 0, + "is_any_author": len(selected_authors) == 0, + "tg_summary": ", ".join(tg_summary_names) if tg_summary_names else "", + "au_summary": ", ".join(au_summary_names) if au_summary_names else "", + "blog_url_base": blog_url_base, + "csrf": csrf, + } + async def post_admin_data(self, session, *, slug=None, **kw): """Post admin panel — just needs post loaded into context.""" from quart import g diff --git a/blog/sx/index.sx b/blog/sx/index.sx index b4feb85..978c2ae 100644 --- a/blog/sx/index.sx +++ b/blog/sx/index.sx @@ -30,3 +30,224 @@ tag-groups-filter authors-filter) (div :id "filter-summary-desktop" :hxx-swap-oob "outerHTML"))) + +;; --------------------------------------------------------------------------- +;; Data-driven composition defcomps — replace Python sx_components functions +;; --------------------------------------------------------------------------- + +;; Helper: CSS class for filter item based on selection state +(defcomp ~blog-filter-cls (&key is-on) + ;; Returns nothing — use inline (if is-on ...) instead + nil) + +;; Blog index main content — replaces _blog_main_panel_sx +(defcomp ~blog-index-main-content (&key content-type view cards page total-pages + current-local-href hx-select blog-url-base) + (let* ((posts-href (str blog-url-base "/index")) + (pages-href (str posts-href "?type=pages")) + (posts-cls (if (not (= content-type "pages")) + "bg-stone-700 text-white" "bg-stone-100 text-stone-600 hover:bg-stone-200")) + (pages-cls (if (= content-type "pages") + "bg-stone-700 text-white" "bg-stone-100 text-stone-600 hover:bg-stone-200"))) + (if (= content-type "pages") + ;; Pages listing + (~blog-main-panel-pages + :tabs (~blog-content-type-tabs + :posts-href posts-href :pages-href pages-href + :hx-select hx-select :posts-cls posts-cls :pages-cls pages-cls) + :cards (<> + (map (lambda (card) + (~blog-page-card + :href (get card "href") :hx-select hx-select + :title (get card "title") + :has-calendar (get card "has_calendar") + :has-market (get card "has_market") + :pub-timestamp (get card "pub_timestamp") + :feature-image (get card "feature_image") + :excerpt (get card "excerpt"))) + (or cards (list))) + (if (< page total-pages) + (~sentinel-simple + :id (str "sentinel-" page "-d") + :next-url (str current-local-href + (if (contains? current-local-href "?") "&" "?") + "page=" (+ page 1))) + (if (not (empty? (or cards (list)))) + (~end-of-results) + (~blog-no-pages))))) + ;; Posts listing + (let* ((grid-cls (if (= view "tile") + "max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4" + "max-w-full px-3 py-3 space-y-3")) + (list-href current-local-href) + (tile-href (str current-local-href + (if (contains? current-local-href "?") "&" "?") "view=tile")) + (list-cls (if (not (= view "tile")) + "bg-stone-200 text-stone-800" + "text-stone-400 hover:text-stone-600")) + (tile-cls (if (= view "tile") + "bg-stone-200 text-stone-800" + "text-stone-400 hover:text-stone-600"))) + (~blog-main-panel-posts + :tabs (~blog-content-type-tabs + :posts-href posts-href :pages-href pages-href + :hx-select hx-select :posts-cls posts-cls :pages-cls pages-cls) + :toggle (~view-toggle + :list-href list-href :tile-href tile-href :hx-select hx-select + :list-cls list-cls :tile-cls tile-cls :storage-key "blog_view" + :list-svg (~list-svg) :tile-svg (~tile-svg)) + :grid-cls grid-cls + :cards (<> + (map (lambda (card) + (if (= view "tile") + (~blog-card-tile + :href (get card "href") :hx-select hx-select + :feature-image (get card "feature_image") + :title (get card "title") :is-draft (get card "is_draft") + :publish-requested (get card "publish_requested") + :status-timestamp (get card "status_timestamp") + :excerpt (get card "excerpt") + :tags (get card "tags") :authors (get card "authors")) + (~blog-card + :slug (get card "slug") :href (get card "href") :hx-select hx-select + :title (get card "title") :feature-image (get card "feature_image") + :excerpt (get card "excerpt") :is-draft (get card "is_draft") + :publish-requested (get card "publish_requested") + :status-timestamp (get card "status_timestamp") + :has-like (get card "has_like") :liked (get card "liked") + :like-url (get card "like_url") :csrf-token (get card "csrf_token") + :tags (get card "tags") :authors (get card "authors") + :widget (get card "widget")))) + (or cards (list))) + (~blog-index-sentinel + :page page :total-pages total-pages + :current-local-href current-local-href))))))) + +;; Sentinel for blog index infinite scroll +(defcomp ~blog-index-sentinel (&key page total-pages current-local-href) + (when (< page total-pages) + (let* ((next-url (str current-local-href "?page=" (+ page 1)))) + (~sentinel-desktop + :id (str "sentinel-" page "-d") + :next-url next-url + :hyperscript "init if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end on htmx:beforeRequest(event) add .hidden to .js-neterr in me remove .hidden from .js-loading in me remove .opacity-100 from me add .opacity-0 to me set trig to null if event.detail and event.detail.triggeringEvent then set trig to event.detail.triggeringEvent end if trig and trig.type is 'intersect' set scroller to the closest .js-grid-viewport if scroller is null then halt end if scroller.scrollTop < 20 then halt end end def backoff() set ms to me.dataset.retryMs if ms > 30000 then set ms to 30000 end add .hidden to .js-loading in me remove .hidden from .js-neterr in me remove .opacity-0 from me add .opacity-100 to me wait ms ms trigger sentinel:retry set ms to ms * 2 if ms > 30000 then set ms to 30000 end set me.dataset.retryMs to ms end on htmx:sendError call backoff() on htmx:responseError call backoff() on htmx:timeout call backoff()")))) + +;; Blog index action buttons — replaces _action_buttons_sx +(defcomp ~blog-index-actions (&key is-admin has-user hx-select draft-count drafts + new-post-href new-page-href current-local-href) + (~blog-action-buttons-wrapper + :inner (<> + (when is-admin + (<> + (~blog-action-button + :href new-post-href :hx-select hx-select + :btn-class "px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors" + :title "New Post" :icon-class "fa fa-plus mr-1" :label " New Post") + (~blog-action-button + :href new-page-href :hx-select hx-select + :btn-class "px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors" + :title "New Page" :icon-class "fa fa-plus mr-1" :label " New Page"))) + (when (and has-user (or draft-count drafts)) + (if drafts + (~blog-drafts-button + :href current-local-href :hx-select hx-select + :btn-class "px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors" + :title "Hide Drafts" :label " Drafts " :draft-count (str draft-count)) + (let* ((on-href (str current-local-href + (if (contains? current-local-href "?") "&" "?") "drafts=1"))) + (~blog-drafts-button-amber + :href on-href :hx-select hx-select + :btn-class "px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors" + :title "Show Drafts" :label " Drafts " :draft-count (str draft-count)))))))) + +;; Tag groups filter — replaces _tag_groups_filter_sx +(defcomp ~blog-index-tag-groups-filter (&key tag-groups is-any-group hx-select) + (~blog-filter-nav + :items (<> + (~blog-filter-any-topic + :cls (if is-any-group + "bg-stone-900 text-white border-stone-900" + "bg-white text-stone-600 border-stone-300 hover:bg-stone-50") + :hx-select hx-select) + (map (lambda (grp) + (let* ((is-on (get grp "is_selected")) + (cls (if is-on + "bg-stone-900 text-white border-stone-900" + "bg-white text-stone-600 border-stone-300 hover:bg-stone-50")) + (fi (get grp "feature_image")) + (colour (get grp "colour")) + (name (get grp "name")) + (icon (if fi + (~blog-filter-group-icon-image :src fi :name name) + (~blog-filter-group-icon-color + :style (if colour + (str "background-color: " colour "; color: white;") + "background-color: #e7e5e4; color: #57534e;") + :initial (slice (or name "?") 0 1))))) + (~blog-filter-group-li + :cls cls :hx-get (str "?group=" (get grp "slug") "&page=1") + :hx-select hx-select :icon icon + :name name :count (str (get grp "post_count"))))) + (or tag-groups (list)))))) + +;; Authors filter — replaces _authors_filter_sx +(defcomp ~blog-index-authors-filter (&key authors is-any-author hx-select) + (~blog-filter-nav + :items (<> + (~blog-filter-any-author + :cls (if is-any-author + "bg-stone-900 text-white border-stone-900" + "bg-white text-stone-600 border-stone-300 hover:bg-stone-50") + :hx-select hx-select) + (map (lambda (a) + (let* ((is-on (get a "is_selected")) + (cls (if is-on + "bg-stone-900 text-white border-stone-900" + "bg-white text-stone-600 border-stone-300 hover:bg-stone-50")) + (img (get a "profile_image"))) + (~blog-filter-author-li + :cls cls :hx-get (str "?author=" (get a "slug") "&page=1") + :hx-select hx-select + :icon (when img (~blog-filter-author-icon :src img :name (get a "name"))) + :name (get a "name") + :count (str (get a "published_post_count"))))) + (or authors (list)))))) + +;; Blog index aside — replaces _blog_aside_sx +(defcomp ~blog-index-aside-content (&key is-admin has-user hx-select draft-count drafts + new-post-href new-page-href current-local-href + tag-groups authors is-any-group is-any-author) + (~blog-aside + :search (~search-desktop) + :action-buttons (~blog-index-actions + :is-admin is-admin :has-user has-user :hx-select hx-select + :draft-count draft-count :drafts drafts + :new-post-href new-post-href :new-page-href new-page-href + :current-local-href current-local-href) + :tag-groups-filter (~blog-index-tag-groups-filter + :tag-groups tag-groups :is-any-group is-any-group :hx-select hx-select) + :authors-filter (~blog-index-authors-filter + :authors authors :is-any-author is-any-author :hx-select hx-select))) + +;; Blog index mobile filter — replaces _blog_filter_sx +(defcomp ~blog-index-filter-content (&key is-admin has-user hx-select draft-count drafts + new-post-href new-page-href current-local-href + tag-groups authors is-any-group is-any-author + tg-summary au-summary) + (~mobile-filter + :filter-summary (<> + (~search-mobile) + (when (not (= tg-summary "")) + (~blog-filter-summary :text tg-summary)) + (when (not (= au-summary "")) + (~blog-filter-summary :text au-summary))) + :action-buttons (~blog-index-actions + :is-admin is-admin :has-user has-user :hx-select hx-select + :draft-count draft-count :drafts drafts + :new-post-href new-post-href :new-page-href new-page-href + :current-local-href current-local-href) + :filter-details (<> + (~blog-index-tag-groups-filter + :tag-groups tag-groups :is-any-group is-any-group :hx-select hx-select) + (~blog-index-authors-filter + :authors authors :is-any-author is-any-author :hx-select hx-select))))