Replace Python sx_call loops with data-driven SX defcomps using map

Move rendering logic from Python for-loops building sx_call strings into
SX defcomp components that use map/lambda over data dicts. Python now
serializes display data into plain dicts and passes them via a single
sx_call; the SX layer handles iteration and conditional rendering.

Covers orders (rows, items, calendar, tickets), federation (timeline,
search, actors, profile activities), and blog (cards, pages, filters,
snippets, menu items, tag groups, page search, nav OOB).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 16:03:29 +00:00
parent 0c9dbd6657
commit c1ad6fd8d4
12 changed files with 608 additions and 452 deletions

View File

@@ -143,6 +143,80 @@
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
edit-form delete-form))
;; Data-driven snippets list (replaces Python _snippets_sx loop)
(defcomp ~blog-snippets-from-data (&key snippets user-id is-admin csrf badge-colours)
(~blog-snippets-list
:rows (<> (map (lambda (s)
(let* ((s-id (get s "id"))
(s-name (get s "name"))
(s-uid (get s "user_id"))
(s-vis (get s "visibility"))
(owner (if (= s-uid user-id) "You" (str "User #" s-uid)))
(badge-cls (or (get badge-colours s-vis) "bg-stone-200 text-stone-700"))
(extra (<>
(when is-admin
(~blog-snippet-visibility-select
:patch-url (get s "patch_url")
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
:options (<>
(~blog-snippet-option :value "private" :selected (= s-vis "private") :label "private")
(~blog-snippet-option :value "shared" :selected (= s-vis "shared") :label "shared")
(~blog-snippet-option :value "admin" :selected (= s-vis "admin") :label "admin"))
:cls "text-sm border border-stone-300 rounded px-2 py-1"))
(when (or (= s-uid user-id) is-admin)
(~delete-btn :url (get s "delete_url") :trigger-target "#snippets-list"
:title "Delete snippet?"
:text (str "Delete \u201c" s-name "\u201d?")
:sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
:cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0")))))
(~blog-snippet-row :name s-name :owner owner :badge-cls badge-cls
:visibility s-vis :extra extra)))
(or snippets (list))))))
;; Data-driven menu items list (replaces Python _menu_items_list_sx loop)
(defcomp ~blog-menu-items-from-data (&key items csrf)
(~blog-menu-items-list
:rows (<> (map (lambda (item)
(let* ((img (~img-or-placeholder :src (get item "feature_image") :alt (get item "label")
:size-cls "w-12 h-12 rounded-full object-cover flex-shrink-0")))
(~blog-menu-item-row
:img img :label (get item "label") :slug (get item "slug")
:sort-order (get item "sort_order") :edit-url (get item "edit_url")
:delete-url (get item "delete_url")
:confirm-text (str "Remove " (get item "label") " from the menu?")
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}"))))
(or items (list))))))
;; Data-driven tag groups main (replaces Python _tag_groups_main_panel_sx loops)
(defcomp ~blog-tag-groups-from-data (&key groups unassigned-tags csrf create-url)
(~blog-tag-groups-main
:form (~blog-tag-groups-create-form :create-url create-url :csrf csrf)
:groups (if (empty? (or groups (list)))
(~empty-state :message "No tag groups yet." :cls "text-stone-500 text-sm")
(~blog-tag-groups-list
:items (<> (map (lambda (g)
(let* ((icon (if (get g "feature_image")
(~blog-tag-group-icon-image :src (get g "feature_image") :name (get g "name"))
(~blog-tag-group-icon-color :style (get g "style") :initial (get g "initial")))))
(~blog-tag-group-li :icon icon :edit-href (get g "edit_href")
:name (get g "name") :slug (get g "slug") :sort-order (get g "sort_order"))))
groups))))
:unassigned (when (not (empty? (or unassigned-tags (list))))
(~blog-unassigned-tags
:heading (str "Unassigned Tags (" (len unassigned-tags) ")")
:spans (<> (map (lambda (t)
(~blog-unassigned-tag :name (get t "name")))
unassigned-tags))))))
;; Data-driven tag group edit (replaces Python _tag_groups_edit_main_panel_sx loop)
(defcomp ~blog-tag-checkboxes-from-data (&key tags)
(<> (map (lambda (t)
(~blog-tag-checkbox
:tag-id (get t "tag_id") :checked (get t "checked")
:img (when (get t "feature_image") (~blog-tag-checkbox-image :src (get t "feature_image")))
:name (get t "name")))
(or tags (list)))))
;; Preview panel components
(defcomp ~blog-preview-panel (&key sections)

View File

@@ -107,6 +107,43 @@
(ul :class "flex flex-wrap gap-2 text-sm"
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors))))))))
;; Data-driven blog cards list (replaces Python _blog_cards_sx loop)
(defcomp ~blog-cards-from-data (&key posts view sentinel)
(<>
(map (lambda (p)
(if (= view "tile")
(~blog-card-tile
:href (get p "href") :hx-select (get p "hx_select")
:feature-image (get p "feature_image") :title (get p "title")
:is-draft (get p "is_draft") :publish-requested (get p "publish_requested")
:status-timestamp (get p "status_timestamp")
:excerpt (get p "excerpt") :tags (get p "tags") :authors (get p "authors"))
(~blog-card
:slug (get p "slug") :href (get p "href") :hx-select (get p "hx_select")
:title (get p "title") :feature-image (get p "feature_image")
:excerpt (get p "excerpt") :is-draft (get p "is_draft")
:publish-requested (get p "publish_requested")
:status-timestamp (get p "status_timestamp")
:has-like (get p "has_like") :liked (get p "liked")
:like-url (get p "like_url") :csrf-token (get p "csrf_token")
:tags (get p "tags") :authors (get p "authors")
:widget (when (get p "widget") (~rich-text :html (get p "widget"))))))
(or posts (list)))
sentinel))
;; Data-driven page cards list (replaces Python _page_cards_sx loop)
(defcomp ~page-cards-from-data (&key pages sentinel)
(<>
(map (lambda (pg)
(~blog-page-card
:href (get pg "href") :hx-select (get pg "hx_select")
:title (get pg "title")
:has-calendar (get pg "has_calendar") :has-market (get pg "has_market")
:pub-timestamp (get pg "pub_timestamp")
:feature-image (get pg "feature_image") :excerpt (get pg "excerpt")))
(or pages (list)))
sentinel))
(defcomp ~blog-page-badges (&key has-calendar has-market)
(div :class "flex justify-center gap-2 mt-2"
(when has-calendar (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800"

View File

@@ -63,3 +63,39 @@
(defcomp ~blog-filter-summary (&key text)
(span :class "text-sm text-stone-600" text))
;; Data-driven tag groups filter (replaces Python _tag_groups_filter_sx loop)
(defcomp ~blog-tag-groups-filter-from-data (&key groups selected-groups hx-select)
(let* ((is-any (empty? (or selected-groups (list))))
(any-cls (if is-any "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50")))
(~blog-filter-nav
:items (<>
(~blog-filter-any-topic :cls any-cls :hx-select hx-select)
(map (lambda (g)
(let* ((slug (get g "slug"))
(name (get g "name"))
(is-on (contains? selected-groups slug))
(cls (if is-on "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
(icon (if (get g "feature_image")
(~blog-filter-group-icon-image :src (get g "feature_image") :name name)
(~blog-filter-group-icon-color :style (get g "style") :initial (get g "initial")))))
(~blog-filter-group-li :cls cls :hx-get (str "?group=" slug "&page=1") :hx-select hx-select
:icon icon :name name :count (get g "count"))))
(or groups (list)))))))
;; Data-driven authors filter (replaces Python _authors_filter_sx loop)
(defcomp ~blog-authors-filter-from-data (&key authors selected-authors hx-select)
(let* ((is-any (empty? (or selected-authors (list))))
(any-cls (if is-any "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50")))
(~blog-filter-nav
:items (<>
(~blog-filter-any-author :cls any-cls :hx-select hx-select)
(map (lambda (a)
(let* ((slug (get a "slug"))
(is-on (contains? selected-authors slug))
(cls (if is-on "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
(icon (when (get a "profile_image")
(~blog-filter-author-icon :src (get a "profile_image") :name (get a "name")))))
(~blog-filter-author-li :cls cls :hx-get (str "?author=" slug "&page=1") :hx-select hx-select
:icon icon :name (get a "name") :count (get a "count"))))
(or authors (list)))))))

View File

@@ -24,3 +24,37 @@
(defcomp ~page-search-empty (&key query)
(div :class "p-3 text-center text-stone-400 border border-stone-200 rounded-md"
(str "No pages found matching \"" query "\"")))
;; Data-driven page search results (replaces Python render_page_search_results loop)
(defcomp ~page-search-results-from-data (&key pages query has-more search-url next-page)
(if (and (not pages) query)
(~page-search-empty :query query)
(when pages
(~page-search-results
:items (<> (map (lambda (p)
(~page-search-item
:id (get p "id") :title (get p "title")
:slug (get p "slug") :feature-image (get p "feature_image")))
pages))
:sentinel (when has-more
(~page-search-sentinel :url search-url :query query :next-page next-page))))))
;; Data-driven menu nav items (replaces Python render_menu_items_nav_oob loop)
(defcomp ~blog-menu-nav-from-data (&key items nav-cls container-id arrow-cls scroll-hs)
(if (not items)
(~blog-nav-empty :wrapper-id "menu-items-nav-wrapper")
(~scroll-nav-wrapper :wrapper-id "menu-items-nav-wrapper" :container-id container-id
:arrow-cls arrow-cls
:left-hs (str "on click set #" container-id ".scrollLeft to #" container-id ".scrollLeft - 200")
:scroll-hs scroll-hs
:right-hs (str "on click set #" container-id ".scrollLeft to #" container-id ".scrollLeft + 200")
:items (<> (map (lambda (item)
(let* ((img (~img-or-placeholder :src (get item "feature_image") :alt (get item "label")
:size-cls "w-8 h-8 rounded-full object-cover flex-shrink-0")))
(if (= (get item "slug") "cart")
(~blog-nav-item-plain :href (get item "href") :selected (get item "selected")
:nav-cls nav-cls :img img :label (get item "label"))
(~blog-nav-item-link :href (get item "href") :hx-get (get item "hx_get")
:selected (get item "selected") :nav-cls nav-cls :img img :label (get item "label")))))
items))
:oob true)))

View File

@@ -212,14 +212,9 @@ def _blog_cards_sx(ctx: dict) -> str:
"""S-expression wire format for blog cards (client renders)."""
posts = ctx.get("posts") or []
view = ctx.get("view")
parts = []
for p in posts:
if view == "tile":
parts.append(_blog_card_tile_sx(p, ctx))
else:
parts.append(_blog_card_sx(p, ctx))
parts.append(_blog_sentinel_sx(ctx))
return "(<> " + " ".join(parts) + ")"
post_dicts = [_blog_card_data(p, ctx) for p in posts]
sentinel = SxExpr(_blog_sentinel_sx(ctx))
return sx_call("blog-cards-from-data", posts=post_dicts, view=view, sentinel=sentinel)
def _format_ts(dt) -> str:
@@ -249,8 +244,8 @@ def _author_data(authors: list) -> list[dict]:
return result
def _blog_card_sx(post: dict, ctx: dict) -> str:
"""Single blog card as sx call (wire format) — pure data, no HTML."""
def _blog_card_data(post: dict, ctx: dict) -> dict:
"""Serialize a blog post to a display-data dict for sx rendering."""
from quart import g
slug = post.get("slug", "")
@@ -274,7 +269,7 @@ def _blog_card_sx(post: dict, ctx: dict) -> str:
tags = _tag_data(post.get("tags") or [])
authors = _author_data(post.get("authors") or [])
kwargs = dict(
d: dict[str, Any] = dict(
slug=slug, href=href, hx_select=hx_select,
title=post.get("title", ""),
feature_image=fi, excerpt=excerpt,
@@ -285,54 +280,18 @@ def _blog_card_sx(post: dict, ctx: dict) -> str:
)
if user:
kwargs["liked"] = post.get("is_liked", False)
kwargs["like_url"] = call_url(ctx, "blog_url", f"/{slug}/like/toggle/")
kwargs["csrf_token"] = _ctx_csrf(ctx)
d["liked"] = post.get("is_liked", False)
d["like_url"] = call_url(ctx, "blog_url", f"/{slug}/like/toggle/")
d["csrf_token"] = _ctx_csrf(ctx)
if tags:
kwargs["tags"] = tags
d["tags"] = tags
if authors:
kwargs["authors"] = authors
d["authors"] = authors
if widget:
kwargs["widget"] = SxExpr(widget) if widget else None
d["widget"] = widget
return sx_call("blog-card", **kwargs)
def _blog_card_tile_sx(post: dict, ctx: dict) -> str:
"""Single blog card tile as sx call (wire format) — pure data."""
slug = post.get("slug", "")
href = call_url(ctx, "blog_url", f"/{slug}/")
hx_select = ctx.get("hx_select_search", "#main-panel")
fi = post.get("feature_image")
status = post.get("status", "published")
is_draft = status == "draft"
if is_draft:
status_timestamp = _format_ts(post.get("updated_at"))
else:
status_timestamp = _format_ts(post.get("published_at"))
excerpt = post.get("custom_excerpt") or post.get("excerpt", "")
tags = _tag_data(post.get("tags") or [])
authors = _author_data(post.get("authors") or [])
kwargs = dict(
href=href, hx_select=hx_select, feature_image=fi,
title=post.get("title", ""),
is_draft=is_draft,
publish_requested=post.get("publish_requested", False) if is_draft else False,
status_timestamp=status_timestamp,
excerpt=excerpt,
)
if tags:
kwargs["tags"] = tags
if authors:
kwargs["authors"] = authors
return sx_call("blog-card-tile", **kwargs)
return d
def _at_bar_sx(post: dict, ctx: dict) -> str:
@@ -359,6 +318,22 @@ def _at_bar_sx(post: dict, ctx: dict) -> str:
def _page_card_data(page: dict, ctx: dict) -> dict:
"""Serialize a page to a display-data dict for sx rendering."""
slug = page.get("slug", "")
href = call_url(ctx, "blog_url", f"/{slug}/")
hx_select = ctx.get("hx_select_search", "#main-panel")
features = page.get("features") or {}
return dict(
href=href, hx_select=hx_select, title=page.get("title", ""),
has_calendar=features.get("calendar", False),
has_market=features.get("market", False),
pub_timestamp=_format_ts(page.get("published_at")),
feature_image=page.get("feature_image"),
excerpt=page.get("custom_excerpt") or page.get("excerpt", ""),
)
def _page_cards_sx(ctx: dict) -> str:
"""Render page cards with sentinel (sx)."""
pages = ctx.get("pages") or ctx.get("posts") or []
@@ -366,45 +341,22 @@ def _page_cards_sx(ctx: dict) -> str:
total_pages = ctx.get("total_pages", 1)
if isinstance(total_pages, str):
total_pages = int(total_pages)
hx_select = ctx.get("hx_select_search", "#main-panel")
parts = []
for pg in pages:
parts.append(_page_card_sx(pg, ctx))
page_dicts = [_page_card_data(pg, ctx) for pg in pages]
if page_num < total_pages:
current_local_href = ctx.get("current_local_href", "/index?type=pages")
next_url = f"{current_local_href}&page={page_num + 1}" if "?" in current_local_href else f"{current_local_href}?page={page_num + 1}"
parts.append(sx_call("sentinel-simple",
id=f"sentinel-{page_num}-d", next_url=next_url,
))
sentinel = SxExpr(sx_call("sentinel-simple",
id=f"sentinel-{page_num}-d", next_url=next_url))
elif pages:
parts.append(sx_call("end-of-results"))
sentinel = SxExpr(sx_call("end-of-results"))
elif not pages:
sentinel = SxExpr(sx_call("blog-no-pages"))
else:
parts.append(sx_call("blog-no-pages"))
sentinel = None
return "(<> " + " ".join(parts) + ")" if parts else ""
def _page_card_sx(page: dict, ctx: dict) -> str:
"""Single page card as sx."""
slug = page.get("slug", "")
href = call_url(ctx, "blog_url", f"/{slug}/")
hx_select = ctx.get("hx_select_search", "#main-panel")
features = page.get("features") or {}
pub_timestamp = _format_ts(page.get("published_at"))
fi = page.get("feature_image")
excerpt = page.get("custom_excerpt") or page.get("excerpt", "")
return sx_call("blog-page-card",
href=href, hx_select=hx_select, title=page.get("title", ""),
has_calendar=features.get("calendar", False),
has_market=features.get("market", False),
pub_timestamp=pub_timestamp, feature_image=fi,
excerpt=excerpt,
)
return sx_call("page-cards-from-data", pages=page_dicts, sentinel=sentinel)
def _view_toggle_sx(ctx: dict) -> str:
@@ -566,15 +518,10 @@ def _action_buttons_sx(ctx: dict) -> str:
def _tag_groups_filter_sx(ctx: dict) -> str:
"""Tag group filter bar as sx."""
tag_groups = ctx.get("tag_groups") or []
selected_groups = ctx.get("selected_groups") or ()
selected_tags = ctx.get("selected_tags") or ()
selected_groups = list(ctx.get("selected_groups") or ())
hx_select = ctx.get("hx_select_search", "#main-panel")
is_any = len(selected_groups) == 0 and len(selected_tags) == 0
any_cls = "bg-stone-900 text-white border-stone-900" if is_any else "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"
li_parts = [sx_call("blog-filter-any-topic", cls=any_cls, hx_select=hx_select)]
group_dicts = []
for group in tag_groups:
g_slug = getattr(group, "slug", "") if hasattr(group, "slug") else group.get("slug", "")
g_name = getattr(group, "name", "") if hasattr(group, "name") else group.get("name", "")
@@ -585,55 +532,35 @@ def _tag_groups_filter_sx(ctx: dict) -> str:
if g_count <= 0 and g_slug not in selected_groups:
continue
is_on = g_slug in selected_groups
cls = "bg-stone-900 text-white border-stone-900" if is_on else "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"
if g_fi:
icon = sx_call("blog-filter-group-icon-image", src=g_fi, name=g_name)
else:
style = f"background-color: {g_colour}; color: white;" if g_colour else "background-color: #e7e5e4; color: #57534e;"
icon = sx_call("blog-filter-group-icon-color", style=style, initial=g_name[:1])
li_parts.append(sx_call("blog-filter-group-li",
cls=cls, hx_get=f"?group={g_slug}&page=1", hx_select=hx_select,
icon=SxExpr(icon), name=g_name, count=str(g_count),
style = (f"background-color: {g_colour}; color: white;" if g_colour
else "background-color: #e7e5e4; color: #57534e;") if not g_fi else None
group_dicts.append(dict(
slug=g_slug, name=g_name, feature_image=g_fi,
style=style, initial=g_name[:1], count=str(g_count),
))
items = "(<> " + " ".join(li_parts) + ")"
return sx_call("blog-filter-nav", items=SxExpr(items))
return sx_call("blog-tag-groups-filter-from-data",
groups=group_dicts, selected_groups=selected_groups, hx_select=hx_select)
def _authors_filter_sx(ctx: dict) -> str:
"""Author filter bar as sx."""
authors = ctx.get("authors") or []
selected_authors = ctx.get("selected_authors") or ()
selected_authors = list(ctx.get("selected_authors") or ())
hx_select = ctx.get("hx_select_search", "#main-panel")
is_any = len(selected_authors) == 0
any_cls = "bg-stone-900 text-white border-stone-900" if is_any else "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"
li_parts = [sx_call("blog-filter-any-author", cls=any_cls, hx_select=hx_select)]
author_dicts = []
for author in authors:
a_slug = getattr(author, "slug", "") if hasattr(author, "slug") else author.get("slug", "")
a_name = getattr(author, "name", "") if hasattr(author, "name") else author.get("name", "")
a_img = getattr(author, "profile_image", None) if hasattr(author, "profile_image") else author.get("profile_image")
a_count = getattr(author, "published_post_count", 0) if hasattr(author, "published_post_count") else author.get("published_post_count", 0)
is_on = a_slug in selected_authors
cls = "bg-stone-900 text-white border-stone-900" if is_on else "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"
icon_sx = None
if a_img:
icon_sx = sx_call("blog-filter-author-icon", src=a_img, name=a_name)
li_parts.append(sx_call("blog-filter-author-li",
cls=cls, hx_get=f"?author={a_slug}&page=1", hx_select=hx_select,
icon=SxExpr(icon_sx) if icon_sx else None, name=a_name, count=str(a_count),
author_dicts.append(dict(
slug=a_slug, name=a_name, profile_image=a_img, count=str(a_count),
))
items = "(<> " + " ".join(li_parts) + ")"
return sx_call("blog-filter-nav", items=SxExpr(items))
return sx_call("blog-authors-filter-from-data",
authors=author_dicts, selected_authors=selected_authors, hx_select=hx_select)
def _tag_groups_filter_summary_sx(ctx: dict) -> str:
@@ -846,48 +773,21 @@ def _snippets_list_sx(ctx: dict) -> str:
"admin": "bg-amber-100 text-amber-700",
}
row_parts = []
snippet_dicts = []
for s in snippets:
s_id = getattr(s, "id", None) or s.get("id")
s_name = getattr(s, "name", "") if hasattr(s, "name") else s.get("name", "")
s_uid = getattr(s, "user_id", None) if hasattr(s, "user_id") else s.get("user_id")
s_vis = getattr(s, "visibility", "private") if hasattr(s, "visibility") else s.get("visibility", "private")
owner = "You" if s_uid == user_id else f"User #{s_uid}"
badge_cls = badge_colours.get(s_vis, "bg-stone-200 text-stone-700")
extra = ""
if is_admin:
patch_url = qurl("snippets.patch_visibility", snippet_id=s_id)
opts = ""
for v in ["private", "shared", "admin"]:
opts += sx_call("blog-snippet-option",
value=v, selected=(s_vis == v), label=v,
)
extra += sx_call("blog-snippet-visibility-select",
patch_url=patch_url,
hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
options=SxExpr("(<> " + opts + ")") if opts else None,
cls="text-sm border border-stone-300 rounded px-2 py-1",
)
if s_uid == user_id or is_admin:
del_url = qurl("snippets.delete_snippet", snippet_id=s_id)
extra += sx_call("delete-btn",
url=del_url, trigger_target="#snippets-list",
title="Delete snippet?",
text=f'Delete \u201c{s_name}\u201d?',
sx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
cls="px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0",
)
row_parts.append(sx_call("blog-snippet-row",
name=s_name, owner=owner, badge_cls=badge_cls,
visibility=s_vis, extra=SxExpr("(<> " + extra + ")") if extra else None,
snippet_dicts.append(dict(
id=s_id, name=s_name, user_id=s_uid, visibility=s_vis,
patch_url=qurl("snippets.patch_visibility", snippet_id=s_id),
delete_url=qurl("snippets.delete_snippet", snippet_id=s_id),
))
rows = "(<> " + " ".join(row_parts) + ")"
return sx_call("blog-snippets-list", rows=SxExpr(rows))
return sx_call("blog-snippets-from-data",
snippets=snippet_dicts, user_id=user_id, is_admin=is_admin,
csrf=csrf, badge_colours=badge_colours)
# ---------------------------------------------------------------------------
@@ -911,29 +811,21 @@ def _menu_items_list_sx(ctx: dict) -> str:
if not menu_items:
return sx_call("empty-state", icon="fa fa-inbox", message="No menu items yet. Add one to get started!")
row_parts = []
item_dicts = []
for item in menu_items:
i_id = getattr(item, "id", None) or item.get("id")
label = getattr(item, "label", "") if hasattr(item, "label") else item.get("label", "")
slug = getattr(item, "slug", "") if hasattr(item, "slug") else item.get("slug", "")
fi = getattr(item, "feature_image", None) if hasattr(item, "feature_image") else item.get("feature_image")
sort = getattr(item, "sort_order", 0) if hasattr(item, "sort_order") else item.get("sort_order", 0)
edit_url = qurl("menu_items.edit_menu_item", item_id=i_id)
del_url = qurl("menu_items.delete_menu_item_route", item_id=i_id)
img_sx = sx_call("img-or-placeholder", src=fi, alt=label,
size_cls="w-12 h-12 rounded-full object-cover flex-shrink-0")
row_parts.append(sx_call("blog-menu-item-row",
img=SxExpr(img_sx), label=label, slug=slug,
sort_order=str(sort), edit_url=edit_url, delete_url=del_url,
confirm_text=f"Remove {label} from the menu?",
hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
item_dicts.append(dict(
label=label, slug=slug, feature_image=fi,
sort_order=str(sort),
edit_url=qurl("menu_items.edit_menu_item", item_id=i_id),
delete_url=qurl("menu_items.delete_menu_item_route", item_id=i_id),
))
rows = "(<> " + " ".join(row_parts) + ")"
return sx_call("blog-menu-items-list", rows=SxExpr(rows))
return sx_call("blog-menu-items-from-data", items=item_dicts, csrf=csrf)
# ---------------------------------------------------------------------------
@@ -946,57 +838,32 @@ def _tag_groups_main_panel_sx(ctx: dict) -> str:
groups = ctx.get("groups") or []
unassigned_tags = ctx.get("unassigned_tags") or []
csrf = _ctx_csrf(ctx)
create_url = qurl("blog.tag_groups_admin.create")
form_sx = sx_call("blog-tag-groups-create-form",
create_url=create_url, csrf=csrf,
)
# Groups list
groups_html = ""
if groups:
li_parts = []
for group in groups:
g_id = getattr(group, "id", None) or group.get("id")
g_name = getattr(group, "name", "") if hasattr(group, "name") else group.get("name", "")
g_slug = getattr(group, "slug", "") if hasattr(group, "slug") else group.get("slug", "")
g_fi = getattr(group, "feature_image", None) if hasattr(group, "feature_image") else group.get("feature_image")
g_colour = getattr(group, "colour", None) if hasattr(group, "colour") else group.get("colour")
g_sort = getattr(group, "sort_order", 0) if hasattr(group, "sort_order") else group.get("sort_order", 0)
group_dicts = []
for group in groups:
g_id = getattr(group, "id", None) or group.get("id")
g_name = getattr(group, "name", "") if hasattr(group, "name") else group.get("name", "")
g_slug = getattr(group, "slug", "") if hasattr(group, "slug") else group.get("slug", "")
g_fi = getattr(group, "feature_image", None) if hasattr(group, "feature_image") else group.get("feature_image")
g_colour = getattr(group, "colour", None) if hasattr(group, "colour") else group.get("colour")
g_sort = getattr(group, "sort_order", 0) if hasattr(group, "sort_order") else group.get("sort_order", 0)
style = (f"background-color: {g_colour}; color: white;" if g_colour
else "background-color: #e7e5e4; color: #57534e;") if not g_fi else None
group_dicts.append(dict(
name=g_name, slug=g_slug, feature_image=g_fi,
style=style, initial=g_name[:1], sort_order=str(g_sort),
edit_href=qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id),
))
edit_href = qurl("blog.tag_groups_admin.defpage_tag_group_edit", id=g_id)
unassigned_dicts = []
for tag in unassigned_tags:
t_name = getattr(tag, "name", "") if hasattr(tag, "name") else tag.get("name", "")
unassigned_dicts.append(dict(name=t_name))
if g_fi:
icon = sx_call("blog-tag-group-icon-image", src=g_fi, name=g_name)
else:
style = f"background-color: {g_colour}; color: white;" if g_colour else "background-color: #e7e5e4; color: #57534e;"
icon = sx_call("blog-tag-group-icon-color", style=style, initial=g_name[:1])
li_parts.append(sx_call("blog-tag-group-li",
icon=SxExpr(icon), edit_href=edit_href, name=g_name,
slug=g_slug, sort_order=str(g_sort),
))
groups_sx = sx_call("blog-tag-groups-list", items=SxExpr("(<> " + " ".join(li_parts) + ")"))
else:
groups_sx = sx_call("empty-state", message="No tag groups yet.", cls="text-stone-500 text-sm")
# Unassigned tags
unassigned_sx = ""
if unassigned_tags:
tag_spans = []
for tag in unassigned_tags:
t_name = getattr(tag, "name", "") if hasattr(tag, "name") else tag.get("name", "")
tag_spans.append(sx_call("blog-unassigned-tag", name=t_name))
unassigned_sx = sx_call("blog-unassigned-tags",
heading=f"Unassigned Tags ({len(unassigned_tags)})",
spans=SxExpr("(<> " + " ".join(tag_spans) + ")"),
)
return sx_call("blog-tag-groups-main",
form=SxExpr(form_sx),
groups=SxExpr(groups_sx),
unassigned=SxExpr(unassigned_sx) if unassigned_sx else None,
)
return sx_call("blog-tag-groups-from-data",
groups=group_dicts, unassigned_tags=unassigned_dicts,
csrf=csrf, create_url=create_url)
def _tag_groups_edit_main_panel_sx(ctx: dict) -> str:
@@ -1016,24 +883,24 @@ def _tag_groups_edit_main_panel_sx(ctx: dict) -> str:
save_url = qurl("blog.tag_groups_admin.save", id=g_id)
del_url = qurl("blog.tag_groups_admin.delete_group", id=g_id)
# Tag checkboxes
tag_items = []
# Tag checkboxes — pass data dicts to defcomp
tag_dicts = []
for tag in all_tags:
t_id = getattr(tag, "id", None) or tag.get("id")
t_name = getattr(tag, "name", "") if hasattr(tag, "name") else tag.get("name", "")
t_fi = getattr(tag, "feature_image", None) if hasattr(tag, "feature_image") else tag.get("feature_image")
checked = t_id in assigned_tag_ids
img = sx_call("blog-tag-checkbox-image", src=t_fi) if t_fi else ""
tag_items.append(sx_call("blog-tag-checkbox",
tag_id=str(t_id), checked=checked,
img=SxExpr(img) if img else None, name=t_name,
tag_dicts.append(dict(
tag_id=str(t_id), name=t_name, feature_image=t_fi,
checked=t_id in assigned_tag_ids,
))
tags_sx = sx_call("blog-tag-checkboxes-from-data", tags=tag_dicts)
edit_form = sx_call("blog-tag-group-edit-form",
save_url=save_url, csrf=csrf,
name=g_name, colour=g_colour or "", sort_order=str(g_sort),
feature_image=g_fi or "",
tags=SxExpr("(<> " + " ".join(tag_items) + ")"),
tags=SxExpr(tags_sx),
)
del_form = sx_call("blog-tag-group-delete-form",
@@ -2171,24 +2038,17 @@ def render_page_search_results(pages, query, page, has_more) -> str:
if not pages:
return ""
items = []
for post in pages:
items.append(sx_call("page-search-item",
id=post.id, title=post.title,
slug=post.slug,
feature_image=post.feature_image or None))
page_dicts = [
dict(id=post.id, title=post.title, slug=post.slug,
feature_image=post.feature_image or None)
for post in pages
]
sentinel = ""
if has_more:
search_url = qurl("menu_items.search_pages_route")
sentinel = sx_call("page-search-sentinel",
url=search_url, query=query,
next_page=page + 1)
search_url = qurl("menu_items.search_pages_route") if has_more else None
items_sx = "(<> " + " ".join(items) + ")"
return sx_call("page-search-results",
items=SxExpr(items_sx),
sentinel=SxExpr(sentinel) if sentinel else None)
return sx_call("page-search-results-from-data",
pages=page_dicts, query=query, has_more=has_more,
search_url=search_url, next_page=page + 1)
def render_menu_items_nav_oob(menu_items, ctx: dict | None = None) -> str:
@@ -2203,26 +2063,23 @@ def render_menu_items_nav_oob(menu_items, ctx: dict | None = None) -> str:
if not menu_items:
return sx_call("blog-nav-empty", wrapper_id="menu-items-nav-wrapper")
# Resolve URL helpers from context or fall back to template globals
if ctx is None:
ctx = {}
first_seg = qrequest.path.strip("/").split("/")[0] if qrequest else ""
# nav_button style (matches shared/infrastructure/jinja_setup.py)
select_colours = (
"[.hover-capable_&]:hover:bg-yellow-300"
" aria-selected:bg-stone-500 aria-selected:text-white"
" [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500"
)
nav_button_cls = (
nav_cls = (
f"justify-center cursor-pointer flex flex-row items-center gap-2"
f" rounded bg-stone-200 text-black {select_colours} p-3"
)
container_id = "menu-items-container"
arrow_cls = f"scrolling-menu-arrow-{container_id}"
scroll_hs = (
f"on load or scroll"
f" if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth"
@@ -2234,7 +2091,7 @@ def render_menu_items_nav_oob(menu_items, ctx: dict | None = None) -> str:
cart_url_fn = ctx.get("cart_url")
app_name = ctx.get("app_name", "")
item_parts = []
item_dicts = []
for item in menu_items:
item_slug = getattr(item, "slug", "") if hasattr(item, "slug") else item.get("slug", "")
label = getattr(item, "label", "") if hasattr(item, "label") else item.get("label", "")
@@ -2248,31 +2105,16 @@ def render_menu_items_nav_oob(menu_items, ctx: dict | None = None) -> str:
href = f"/{item_slug}/"
selected = "true" if (item_slug == first_seg or item_slug == app_name) else "false"
hx_get = f"/{item_slug}/" if item_slug != "cart" else None
img_sx = sx_call("img-or-placeholder", src=fi, alt=label,
size_cls="w-8 h-8 rounded-full object-cover flex-shrink-0")
item_dicts.append(dict(
slug=item_slug, label=label, feature_image=fi,
href=href, hx_get=hx_get, selected=selected,
))
if item_slug != "cart":
item_parts.append(sx_call("blog-nav-item-link",
href=href, hx_get=f"/{item_slug}/", selected=selected,
nav_cls=nav_button_cls, img=SxExpr(img_sx), label=label,
))
else:
item_parts.append(sx_call("blog-nav-item-plain",
href=href, selected=selected, nav_cls=nav_button_cls,
img=SxExpr(img_sx), label=label,
))
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""
return sx_call("scroll-nav-wrapper",
wrapper_id="menu-items-nav-wrapper", container_id=container_id,
arrow_cls=arrow_cls,
left_hs=f"on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft - 200",
scroll_hs=scroll_hs,
right_hs=f"on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft + 200",
items=SxExpr(items_sx) if items_sx else None, oob=True,
)
return sx_call("blog-menu-nav-from-data",
items=item_dicts, nav_cls=nav_cls, container_id=container_id,
arrow_cls=arrow_cls, scroll_hs=scroll_hs)
# ---- Features panel ----