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" (div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
edit-form delete-form)) 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 ;; Preview panel components
(defcomp ~blog-preview-panel (&key sections) (defcomp ~blog-preview-panel (&key sections)

View File

@@ -107,6 +107,43 @@
(ul :class "flex flex-wrap gap-2 text-sm" (ul :class "flex flex-wrap gap-2 text-sm"
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors)))))))) (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) (defcomp ~blog-page-badges (&key has-calendar has-market)
(div :class "flex justify-center gap-2 mt-2" (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" (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) (defcomp ~blog-filter-summary (&key text)
(span :class "text-sm text-stone-600" 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) (defcomp ~page-search-empty (&key query)
(div :class "p-3 text-center text-stone-400 border border-stone-200 rounded-md" (div :class "p-3 text-center text-stone-400 border border-stone-200 rounded-md"
(str "No pages found matching \"" query "\""))) (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).""" """S-expression wire format for blog cards (client renders)."""
posts = ctx.get("posts") or [] posts = ctx.get("posts") or []
view = ctx.get("view") view = ctx.get("view")
parts = [] post_dicts = [_blog_card_data(p, ctx) for p in posts]
for p in posts: sentinel = SxExpr(_blog_sentinel_sx(ctx))
if view == "tile": return sx_call("blog-cards-from-data", posts=post_dicts, view=view, sentinel=sentinel)
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) + ")"
def _format_ts(dt) -> str: def _format_ts(dt) -> str:
@@ -249,8 +244,8 @@ def _author_data(authors: list) -> list[dict]:
return result return result
def _blog_card_sx(post: dict, ctx: dict) -> str: def _blog_card_data(post: dict, ctx: dict) -> dict:
"""Single blog card as sx call (wire format) — pure data, no HTML.""" """Serialize a blog post to a display-data dict for sx rendering."""
from quart import g from quart import g
slug = post.get("slug", "") slug = post.get("slug", "")
@@ -274,7 +269,7 @@ def _blog_card_sx(post: dict, ctx: dict) -> str:
tags = _tag_data(post.get("tags") or []) tags = _tag_data(post.get("tags") or [])
authors = _author_data(post.get("authors") or []) authors = _author_data(post.get("authors") or [])
kwargs = dict( d: dict[str, Any] = dict(
slug=slug, href=href, hx_select=hx_select, slug=slug, href=href, hx_select=hx_select,
title=post.get("title", ""), title=post.get("title", ""),
feature_image=fi, excerpt=excerpt, feature_image=fi, excerpt=excerpt,
@@ -285,54 +280,18 @@ def _blog_card_sx(post: dict, ctx: dict) -> str:
) )
if user: if user:
kwargs["liked"] = post.get("is_liked", False) d["liked"] = post.get("is_liked", False)
kwargs["like_url"] = call_url(ctx, "blog_url", f"/{slug}/like/toggle/") d["like_url"] = call_url(ctx, "blog_url", f"/{slug}/like/toggle/")
kwargs["csrf_token"] = _ctx_csrf(ctx) d["csrf_token"] = _ctx_csrf(ctx)
if tags: if tags:
kwargs["tags"] = tags d["tags"] = tags
if authors: if authors:
kwargs["authors"] = authors d["authors"] = authors
if widget: if widget:
kwargs["widget"] = SxExpr(widget) if widget else None d["widget"] = widget
return sx_call("blog-card", **kwargs) return d
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)
def _at_bar_sx(post: dict, ctx: dict) -> str: 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: def _page_cards_sx(ctx: dict) -> str:
"""Render page cards with sentinel (sx).""" """Render page cards with sentinel (sx)."""
pages = ctx.get("pages") or ctx.get("posts") or [] 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) total_pages = ctx.get("total_pages", 1)
if isinstance(total_pages, str): if isinstance(total_pages, str):
total_pages = int(total_pages) total_pages = int(total_pages)
hx_select = ctx.get("hx_select_search", "#main-panel")
parts = [] page_dicts = [_page_card_data(pg, ctx) for pg in pages]
for pg in pages:
parts.append(_page_card_sx(pg, ctx))
if page_num < total_pages: if page_num < total_pages:
current_local_href = ctx.get("current_local_href", "/index?type=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}" 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", sentinel = SxExpr(sx_call("sentinel-simple",
id=f"sentinel-{page_num}-d", next_url=next_url, id=f"sentinel-{page_num}-d", next_url=next_url))
))
elif pages: 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: else:
parts.append(sx_call("blog-no-pages")) sentinel = None
return "(<> " + " ".join(parts) + ")" if parts else "" return sx_call("page-cards-from-data", pages=page_dicts, sentinel=sentinel)
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,
)
def _view_toggle_sx(ctx: dict) -> str: 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: def _tag_groups_filter_sx(ctx: dict) -> str:
"""Tag group filter bar as sx.""" """Tag group filter bar as sx."""
tag_groups = ctx.get("tag_groups") or [] tag_groups = ctx.get("tag_groups") or []
selected_groups = ctx.get("selected_groups") or () selected_groups = list(ctx.get("selected_groups") or ())
selected_tags = ctx.get("selected_tags") or ()
hx_select = ctx.get("hx_select_search", "#main-panel") hx_select = ctx.get("hx_select_search", "#main-panel")
is_any = len(selected_groups) == 0 and len(selected_tags) == 0 group_dicts = []
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)]
for group in tag_groups: for group in tag_groups:
g_slug = getattr(group, "slug", "") if hasattr(group, "slug") else group.get("slug", "") 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", "") 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: if g_count <= 0 and g_slug not in selected_groups:
continue continue
is_on = g_slug in selected_groups style = (f"background-color: {g_colour}; color: white;" if g_colour
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" else "background-color: #e7e5e4; color: #57534e;") if not g_fi else None
group_dicts.append(dict(
if g_fi: slug=g_slug, name=g_name, feature_image=g_fi,
icon = sx_call("blog-filter-group-icon-image", src=g_fi, name=g_name) style=style, initial=g_name[:1], count=str(g_count),
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),
)) ))
items = "(<> " + " ".join(li_parts) + ")" return sx_call("blog-tag-groups-filter-from-data",
return sx_call("blog-filter-nav", items=SxExpr(items)) groups=group_dicts, selected_groups=selected_groups, hx_select=hx_select)
def _authors_filter_sx(ctx: dict) -> str: def _authors_filter_sx(ctx: dict) -> str:
"""Author filter bar as sx.""" """Author filter bar as sx."""
authors = ctx.get("authors") or [] 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") hx_select = ctx.get("hx_select_search", "#main-panel")
is_any = len(selected_authors) == 0 author_dicts = []
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)]
for author in authors: for author in authors:
a_slug = getattr(author, "slug", "") if hasattr(author, "slug") else author.get("slug", "") 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_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_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) a_count = getattr(author, "published_post_count", 0) if hasattr(author, "published_post_count") else author.get("published_post_count", 0)
author_dicts.append(dict(
is_on = a_slug in selected_authors slug=a_slug, name=a_name, profile_image=a_img, count=str(a_count),
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),
)) ))
items = "(<> " + " ".join(li_parts) + ")" return sx_call("blog-authors-filter-from-data",
return sx_call("blog-filter-nav", items=SxExpr(items)) authors=author_dicts, selected_authors=selected_authors, hx_select=hx_select)
def _tag_groups_filter_summary_sx(ctx: dict) -> str: 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", "admin": "bg-amber-100 text-amber-700",
} }
row_parts = [] snippet_dicts = []
for s in snippets: for s in snippets:
s_id = getattr(s, "id", None) or s.get("id") s_id = getattr(s, "id", None) or s.get("id")
s_name = getattr(s, "name", "") if hasattr(s, "name") else s.get("name", "") 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_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") s_vis = getattr(s, "visibility", "private") if hasattr(s, "visibility") else s.get("visibility", "private")
snippet_dicts.append(dict(
owner = "You" if s_uid == user_id else f"User #{s_uid}" id=s_id, name=s_name, user_id=s_uid, visibility=s_vis,
badge_cls = badge_colours.get(s_vis, "bg-stone-200 text-stone-700") patch_url=qurl("snippets.patch_visibility", snippet_id=s_id),
delete_url=qurl("snippets.delete_snippet", snippet_id=s_id),
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,
)) ))
rows = "(<> " + " ".join(row_parts) + ")" return sx_call("blog-snippets-from-data",
return sx_call("blog-snippets-list", rows=SxExpr(rows)) 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: if not menu_items:
return sx_call("empty-state", icon="fa fa-inbox", message="No menu items yet. Add one to get started!") 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: for item in menu_items:
i_id = getattr(item, "id", None) or item.get("id") i_id = getattr(item, "id", None) or item.get("id")
label = getattr(item, "label", "") if hasattr(item, "label") else item.get("label", "") label = getattr(item, "label", "") if hasattr(item, "label") else item.get("label", "")
slug = getattr(item, "slug", "") if hasattr(item, "slug") else item.get("slug", "") 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") 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) sort = getattr(item, "sort_order", 0) if hasattr(item, "sort_order") else item.get("sort_order", 0)
item_dicts.append(dict(
edit_url = qurl("menu_items.edit_menu_item", item_id=i_id) label=label, slug=slug, feature_image=fi,
del_url = qurl("menu_items.delete_menu_item_route", item_id=i_id) sort_order=str(sort),
edit_url=qurl("menu_items.edit_menu_item", item_id=i_id),
img_sx = sx_call("img-or-placeholder", src=fi, alt=label, delete_url=qurl("menu_items.delete_menu_item_route", item_id=i_id),
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}"}}',
)) ))
rows = "(<> " + " ".join(row_parts) + ")" return sx_call("blog-menu-items-from-data", items=item_dicts, csrf=csrf)
return sx_call("blog-menu-items-list", rows=SxExpr(rows))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -946,57 +838,32 @@ def _tag_groups_main_panel_sx(ctx: dict) -> str:
groups = ctx.get("groups") or [] groups = ctx.get("groups") or []
unassigned_tags = ctx.get("unassigned_tags") or [] unassigned_tags = ctx.get("unassigned_tags") or []
csrf = _ctx_csrf(ctx) csrf = _ctx_csrf(ctx)
create_url = qurl("blog.tag_groups_admin.create") 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 group_dicts = []
groups_html = "" for group in groups:
if groups: g_id = getattr(group, "id", None) or group.get("id")
li_parts = [] g_name = getattr(group, "name", "") if hasattr(group, "name") else group.get("name", "")
for group in groups: g_slug = getattr(group, "slug", "") if hasattr(group, "slug") else group.get("slug", "")
g_id = getattr(group, "id", None) or group.get("id") g_fi = getattr(group, "feature_image", None) if hasattr(group, "feature_image") else group.get("feature_image")
g_name = getattr(group, "name", "") if hasattr(group, "name") else group.get("name", "") g_colour = getattr(group, "colour", None) if hasattr(group, "colour") else group.get("colour")
g_slug = getattr(group, "slug", "") if hasattr(group, "slug") else group.get("slug", "") g_sort = getattr(group, "sort_order", 0) if hasattr(group, "sort_order") else group.get("sort_order", 0)
g_fi = getattr(group, "feature_image", None) if hasattr(group, "feature_image") else group.get("feature_image") style = (f"background-color: {g_colour}; color: white;" if g_colour
g_colour = getattr(group, "colour", None) if hasattr(group, "colour") else group.get("colour") else "background-color: #e7e5e4; color: #57534e;") if not g_fi else None
g_sort = getattr(group, "sort_order", 0) if hasattr(group, "sort_order") else group.get("sort_order", 0) 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: return sx_call("blog-tag-groups-from-data",
icon = sx_call("blog-tag-group-icon-image", src=g_fi, name=g_name) groups=group_dicts, unassigned_tags=unassigned_dicts,
else: csrf=csrf, create_url=create_url)
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,
)
def _tag_groups_edit_main_panel_sx(ctx: dict) -> str: 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) save_url = qurl("blog.tag_groups_admin.save", id=g_id)
del_url = qurl("blog.tag_groups_admin.delete_group", id=g_id) del_url = qurl("blog.tag_groups_admin.delete_group", id=g_id)
# Tag checkboxes # Tag checkboxes — pass data dicts to defcomp
tag_items = [] tag_dicts = []
for tag in all_tags: for tag in all_tags:
t_id = getattr(tag, "id", None) or tag.get("id") t_id = getattr(tag, "id", None) or tag.get("id")
t_name = getattr(tag, "name", "") if hasattr(tag, "name") else tag.get("name", "") 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") t_fi = getattr(tag, "feature_image", None) if hasattr(tag, "feature_image") else tag.get("feature_image")
checked = t_id in assigned_tag_ids tag_dicts.append(dict(
img = sx_call("blog-tag-checkbox-image", src=t_fi) if t_fi else "" tag_id=str(t_id), name=t_name, feature_image=t_fi,
tag_items.append(sx_call("blog-tag-checkbox", checked=t_id in assigned_tag_ids,
tag_id=str(t_id), checked=checked,
img=SxExpr(img) if img else None, name=t_name,
)) ))
tags_sx = sx_call("blog-tag-checkboxes-from-data", tags=tag_dicts)
edit_form = sx_call("blog-tag-group-edit-form", edit_form = sx_call("blog-tag-group-edit-form",
save_url=save_url, csrf=csrf, save_url=save_url, csrf=csrf,
name=g_name, colour=g_colour or "", sort_order=str(g_sort), name=g_name, colour=g_colour or "", sort_order=str(g_sort),
feature_image=g_fi or "", feature_image=g_fi or "",
tags=SxExpr("(<> " + " ".join(tag_items) + ")"), tags=SxExpr(tags_sx),
) )
del_form = sx_call("blog-tag-group-delete-form", 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: if not pages:
return "" return ""
items = [] page_dicts = [
for post in pages: dict(id=post.id, title=post.title, slug=post.slug,
items.append(sx_call("page-search-item", feature_image=post.feature_image or None)
id=post.id, title=post.title, for post in pages
slug=post.slug, ]
feature_image=post.feature_image or None))
sentinel = "" search_url = qurl("menu_items.search_pages_route") if has_more else None
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)
items_sx = "(<> " + " ".join(items) + ")" return sx_call("page-search-results-from-data",
return sx_call("page-search-results", pages=page_dicts, query=query, has_more=has_more,
items=SxExpr(items_sx), search_url=search_url, next_page=page + 1)
sentinel=SxExpr(sentinel) if sentinel else None)
def render_menu_items_nav_oob(menu_items, ctx: dict | None = None) -> str: 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: if not menu_items:
return sx_call("blog-nav-empty", wrapper_id="menu-items-nav-wrapper") 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: if ctx is None:
ctx = {} ctx = {}
first_seg = qrequest.path.strip("/").split("/")[0] if qrequest else "" first_seg = qrequest.path.strip("/").split("/")[0] if qrequest else ""
# nav_button style (matches shared/infrastructure/jinja_setup.py)
select_colours = ( select_colours = (
"[.hover-capable_&]:hover:bg-yellow-300" "[.hover-capable_&]:hover:bg-yellow-300"
" aria-selected:bg-stone-500 aria-selected:text-white" " aria-selected:bg-stone-500 aria-selected:text-white"
" [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500" " [.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"justify-center cursor-pointer flex flex-row items-center gap-2"
f" rounded bg-stone-200 text-black {select_colours} p-3" f" rounded bg-stone-200 text-black {select_colours} p-3"
) )
container_id = "menu-items-container" container_id = "menu-items-container"
arrow_cls = f"scrolling-menu-arrow-{container_id}" arrow_cls = f"scrolling-menu-arrow-{container_id}"
scroll_hs = ( scroll_hs = (
f"on load or scroll" f"on load or scroll"
f" if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth" 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") cart_url_fn = ctx.get("cart_url")
app_name = ctx.get("app_name", "") app_name = ctx.get("app_name", "")
item_parts = [] item_dicts = []
for item in menu_items: for item in menu_items:
item_slug = getattr(item, "slug", "") if hasattr(item, "slug") else item.get("slug", "") 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", "") 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}/" href = f"/{item_slug}/"
selected = "true" if (item_slug == first_seg or item_slug == app_name) else "false" 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, item_dicts.append(dict(
size_cls="w-8 h-8 rounded-full object-cover flex-shrink-0") slug=item_slug, label=label, feature_image=fi,
href=href, hx_get=hx_get, selected=selected,
))
if item_slug != "cart": return sx_call("blog-menu-nav-from-data",
item_parts.append(sx_call("blog-nav-item-link", items=item_dicts, nav_cls=nav_cls, container_id=container_id,
href=href, hx_get=f"/{item_slug}/", selected=selected, arrow_cls=arrow_cls, scroll_hs=scroll_hs)
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,
)
# ---- Features panel ---- # ---- Features panel ----

View File

@@ -53,3 +53,16 @@
(defcomp ~federation-profile-summary-text (&key text) (defcomp ~federation-profile-summary-text (&key text)
(p :class "mt-2" text)) (p :class "mt-2" text))
;; Data-driven activities list (replaces Python loop in render_profile_page)
(defcomp ~federation-activities-from-data (&key activities)
(if (empty? (or activities (list)))
(~federation-activities-empty)
(~federation-activities-list
:items (<> (map (lambda (a)
(~federation-activity-card
:activity-type (get a "activity_type")
:published (get a "published")
:obj-type (when (get a "object_type")
(~federation-activity-obj-type :obj-type (get a "object_type")))))
activities)))))

View File

@@ -40,6 +40,47 @@
summary) summary)
button)) button))
;; Data-driven actor card (replaces Python _actor_card_sx loop)
(defcomp ~federation-actor-card-from-data (&key d has-actor csrf follow-url unfollow-url list-type)
(let* ((icon-url (get d "icon_url"))
(display-name (get d "display_name"))
(username (get d "username"))
(domain (get d "domain"))
(actor-url (get d "actor_url"))
(safe-id (get d "safe_id"))
(initial (or (get d "initial") "?"))
(avatar (~avatar
:src icon-url
:cls (if icon-url "w-12 h-12 rounded-full"
"w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold")
:initial (when (not icon-url) initial)))
(name-sx (if (get d "external_link")
(~federation-actor-name-link-external :href (get d "name_href") :name display-name)
(~federation-actor-name-link :href (get d "name_href") :name display-name)))
(summary-sx (when (get d "summary")
(~federation-actor-summary :summary (get d "summary"))))
(is-followed (get d "is_followed"))
(button (when has-actor
(if (or (= list-type "following") is-followed)
(~federation-unfollow-button :action unfollow-url :csrf csrf :actor-url actor-url)
(~federation-follow-button :action follow-url :csrf csrf :actor-url actor-url
:label (if (= list-type "followers") "Follow Back" "Follow"))))))
(~federation-actor-card
:cls "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4"
:id (str "actor-" safe-id)
:avatar avatar :name name-sx :username username :domain domain
:summary summary-sx :button button)))
;; Data-driven actor list (replaces Python _search_results_sx / _actor_list_items_sx loops)
(defcomp ~federation-actor-list-from-data (&key actors next-url has-actor csrf
follow-url unfollow-url list-type)
(<>
(map (lambda (d)
(~federation-actor-card-from-data :d d :has-actor has-actor :csrf csrf
:follow-url follow-url :unfollow-url unfollow-url :list-type list-type))
(or actors (list)))
(when next-url (~federation-scroll-sentinel :url next-url))))
(defcomp ~federation-search-info (&key cls text) (defcomp ~federation-search-info (&key cls text)
(p :class cls text)) (p :class cls text))

View File

@@ -90,6 +90,65 @@
compose) compose)
(div :id "timeline" timeline)) (div :id "timeline" timeline))
;; --- Data-driven post card (replaces Python _post_card_sx loop) ---
(defcomp ~federation-post-card-from-data (&key d has-actor csrf
like-url unlike-url
boost-url unboost-url)
(let* ((boosted-by (get d "boosted_by"))
(actor-icon (get d "actor_icon"))
(actor-name (get d "actor_name"))
(initial (or (get d "initial") "?"))
(avatar (~avatar
:src actor-icon
:cls (if actor-icon "w-10 h-10 rounded-full"
"w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm")
:initial (when (not actor-icon) initial)))
(boost (when boosted-by (~federation-boost-label :name boosted-by)))
(content-sx (if (get d "summary")
(~federation-content :content (get d "content") :summary (get d "summary"))
(~federation-content :content (get d "content"))))
(original (when (get d "original_url")
(~federation-original-link :url (get d "original_url"))))
(safe-id (get d "safe_id"))
(interactions (when has-actor
(let* ((oid (get d "object_id"))
(ainbox (get d "author_inbox"))
(target (str "#interactions-" safe-id))
(liked (get d "liked_by_me"))
(boosted-me (get d "boosted_by_me"))
(l-action (if liked unlike-url like-url))
(l-cls (str "flex items-center gap-1 " (if liked "text-red-500 hover:text-red-600" "hover:text-red-500")))
(l-icon (if liked "\u2665" "\u2661"))
(b-action (if boosted-me unboost-url boost-url))
(b-cls (str "flex items-center gap-1 " (if boosted-me "text-green-600 hover:text-green-700" "hover:text-green-600")))
(reply-url (get d "reply_url"))
(reply (when reply-url (~federation-reply-link :url reply-url)))
(like-form (~federation-like-form
:action l-action :target target :oid oid :ainbox ainbox
:csrf csrf :cls l-cls :icon l-icon :count (get d "like_count")))
(boost-form (~federation-boost-form
:action b-action :target target :oid oid :ainbox ainbox
:csrf csrf :cls b-cls :count (get d "boost_count"))))
(div :id (str "interactions-" safe-id)
(~federation-interaction-buttons :like like-form :boost boost-form :reply reply))))))
(~federation-post-card
:boost boost :avatar avatar
:actor-name actor-name :actor-username (get d "actor_username")
:domain (get d "domain") :time (get d "time")
:content content-sx :original original
:interactions interactions)))
;; Data-driven timeline items (replaces Python _timeline_items_sx loop)
(defcomp ~federation-timeline-items-from-data (&key items next-url has-actor csrf
like-url unlike-url boost-url unboost-url)
(<>
(map (lambda (d)
(~federation-post-card-from-data :d d :has-actor has-actor :csrf csrf
:like-url like-url :unlike-url unlike-url :boost-url boost-url :unboost-url unboost-url))
(or items (list)))
(when next-url (~federation-scroll-sentinel :url next-url))))
;; --- Compose --- ;; --- Compose ---
(defcomp ~federation-compose-reply (&key reply-to) (defcomp ~federation-compose-reply (&key reply-to)

View File

@@ -147,62 +147,49 @@ def _interaction_buttons_sx(item: Any, actor: Any) -> str:
) )
def _post_card_sx(item: Any, actor: Any) -> str: # ---------------------------------------------------------------------------
"""Render a single timeline post card.""" # Post card data serializer
# ---------------------------------------------------------------------------
def _post_card_data(item: Any, actor: Any) -> dict:
"""Serialize a timeline item to a display-data dict for sx rendering."""
from quart import url_for
boosted_by = getattr(item, "boosted_by", None) boosted_by = getattr(item, "boosted_by", None)
actor_icon = getattr(item, "actor_icon", None) actor_icon = getattr(item, "actor_icon", None)
actor_name = getattr(item, "actor_name", "?") actor_name = getattr(item, "actor_name", "?")
actor_username = getattr(item, "actor_username", "") actor_username = getattr(item, "actor_username", "")
actor_domain = getattr(item, "actor_domain", "") actor_domain = getattr(item, "actor_domain", "")
content = getattr(item, "content", "") oid = getattr(item, "object_id", "") or ""
summary = getattr(item, "summary", None) safe_id = oid.replace("/", "_").replace(":", "_")
published = getattr(item, "published", None) initial = actor_name[0].upper() if (not actor_icon and actor_name) else "?"
url = getattr(item, "url", None) url = getattr(item, "url", None)
post_type = getattr(item, "post_type", "") post_type = getattr(item, "post_type", "")
published = getattr(item, "published", None)
summary = getattr(item, "summary", None)
boost_sx = sx_call( d: dict[str, Any] = dict(
"federation-boost-label", name=str(escape(boosted_by)), boosted_by=str(escape(boosted_by)) if boosted_by else None,
) if boosted_by else "" actor_icon=actor_icon or None,
initial = actor_name[0].upper() if (not actor_icon and actor_name) else "?"
avatar = sx_call(
"avatar", src=actor_icon or None,
cls="w-10 h-10 rounded-full" if actor_icon else "w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm",
initial=None if actor_icon else initial,
)
domain_str = f"@{escape(actor_domain)}" if actor_domain else ""
time_str = published.strftime("%b %d, %H:%M") if published else ""
if summary:
content_sx = sx_call(
"federation-content",
content=content, summary=str(escape(summary)),
)
else:
content_sx = sx_call("federation-content", content=content)
original_sx = ""
if url and post_type == "remote":
original_sx = sx_call("federation-original-link", url=url)
interactions_sx = ""
if actor:
oid = getattr(item, "object_id", "") or ""
safe_id = oid.replace("/", "_").replace(":", "_")
interactions_sx = f'(div :id {serialize(f"interactions-{safe_id}")} {_interaction_buttons_sx(item, actor)})'
return sx_call(
"federation-post-card",
boost=SxExpr(boost_sx) if boost_sx else None,
avatar=SxExpr(avatar),
actor_name=str(escape(actor_name)), actor_name=str(escape(actor_name)),
actor_username=str(escape(actor_username)), actor_username=str(escape(actor_username)),
domain=domain_str, time=time_str, domain=f"@{escape(actor_domain)}" if actor_domain else "",
content=SxExpr(content_sx), time=published.strftime("%b %d, %H:%M") if published else "",
original=SxExpr(original_sx) if original_sx else None, content=getattr(item, "content", ""),
interactions=SxExpr(interactions_sx) if interactions_sx else None, summary=str(escape(summary)) if summary else None,
original_url=url if url and post_type == "remote" else None,
object_id=oid,
author_inbox=getattr(item, "author_inbox", "") or "",
safe_id=safe_id,
initial=initial,
like_count=str(getattr(item, "like_count", 0) or 0),
boost_count=str(getattr(item, "boost_count", 0) or 0),
liked_by_me=bool(getattr(item, "liked_by_me", False)),
boosted_by_me=bool(getattr(item, "boosted_by_me", False)),
) )
if actor and oid:
d["reply_url"] = url_for("social.defpage_compose_form", reply_to=oid)
return d
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -213,9 +200,11 @@ def _timeline_items_sx(items: list, timeline_type: str, actor: Any,
actor_id: int | None = None) -> str: actor_id: int | None = None) -> str:
"""Render timeline items with infinite scroll sentinel.""" """Render timeline items with infinite scroll sentinel."""
from quart import url_for from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
parts = [_post_card_sx(item, actor) for item in items] item_dicts = [_post_card_data(item, actor) for item in items]
next_url = None
if items: if items:
last = items[-1] last = items[-1]
before = last.published.isoformat() if last.published else "" before = last.published.isoformat() if last.published else ""
@@ -223,22 +212,28 @@ def _timeline_items_sx(items: list, timeline_type: str, actor: Any,
next_url = url_for("social.actor_timeline_page", id=actor_id, before=before) next_url = url_for("social.actor_timeline_page", id=actor_id, before=before)
else: else:
next_url = url_for(f"social.{timeline_type}_timeline_page", before=before) next_url = url_for(f"social.{timeline_type}_timeline_page", before=before)
parts.append(sx_call("federation-scroll-sentinel", url=next_url))
return "(<> " + " ".join(parts) + ")" if parts else "" kwargs: dict[str, Any] = dict(items=item_dicts, next_url=next_url)
if actor:
kwargs["has_actor"] = True
kwargs["csrf"] = generate_csrf_token()
kwargs["like_url"] = url_for("social.like")
kwargs["unlike_url"] = url_for("social.unlike")
kwargs["boost_url"] = url_for("social.boost")
kwargs["unboost_url"] = url_for("social.unboost")
return sx_call("federation-timeline-items-from-data", **kwargs)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Search results (pagination fragment) # Search results (pagination fragment)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _actor_card_sx(a: Any, actor: Any, followed_urls: set, def _actor_card_data(a: Any, followed_urls: set,
*, list_type: str = "search") -> str: *, list_type: str = "search") -> dict:
"""Render a single actor card with follow/unfollow button.""" """Serialize an actor to a display-data dict for sx rendering."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for from quart import url_for
csrf = generate_csrf_token()
display_name = getattr(a, "display_name", None) or getattr(a, "preferred_username", "") display_name = getattr(a, "display_name", None) or getattr(a, "preferred_username", "")
username = getattr(a, "preferred_username", "") username = getattr(a, "preferred_username", "")
domain = getattr(a, "domain", "") domain = getattr(a, "domain", "")
@@ -248,55 +243,43 @@ def _actor_card_sx(a: Any, actor: Any, followed_urls: set,
aid = getattr(a, "id", None) aid = getattr(a, "id", None)
safe_id = actor_url.replace("/", "_").replace(":", "_") safe_id = actor_url.replace("/", "_").replace(":", "_")
initial = (display_name or username)[0].upper() if (not icon_url and (display_name or username)) else "?" initial = (display_name or username)[0].upper() if (not icon_url and (display_name or username)) else "?"
avatar = sx_call( is_followed = actor_url in (followed_urls or set())
"avatar", src=icon_url or None,
cls="w-12 h-12 rounded-full" if icon_url else "w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold", d: dict[str, Any] = dict(
initial=None if icon_url else initial, display_name=str(escape(display_name)),
username=str(escape(username)),
domain=str(escape(domain)),
icon_url=icon_url or None,
actor_url=actor_url,
summary=summary,
safe_id=safe_id,
initial=initial,
is_followed=is_followed,
) )
# Name link
if (list_type in ("following", "search")) and aid: if (list_type in ("following", "search")) and aid:
name_sx = sx_call( d["name_href"] = url_for("social.defpage_actor_timeline", id=aid)
"federation-actor-name-link",
href=url_for("social.defpage_actor_timeline", id=aid),
name=str(escape(display_name)),
)
else: else:
name_sx = sx_call( d["name_href"] = f"https://{domain}/@{username}"
"federation-actor-name-link-external", d["external_link"] = True
href=f"https://{domain}/@{username}", return d
name=str(escape(display_name)),
)
summary_sx = sx_call("federation-actor-summary", summary=summary) if summary else ""
# Follow/unfollow button def _actor_card_sx(a: Any, actor: Any, followed_urls: set,
button_sx = "" *, list_type: str = "search") -> str:
if actor: """Render a single actor card (used by follow/unfollow re-render)."""
is_followed = actor_url in (followed_urls or set()) from shared.browser.app.csrf import generate_csrf_token
if list_type == "following" or is_followed: from quart import url_for
button_sx = sx_call(
"federation-unfollow-button",
action=url_for("social.unfollow"), csrf=csrf, actor_url=actor_url,
)
else:
label = "Follow Back" if list_type == "followers" else "Follow"
button_sx = sx_call(
"federation-follow-button",
action=url_for("social.follow"), csrf=csrf, actor_url=actor_url, label=label,
)
d = _actor_card_data(a, followed_urls, list_type=list_type)
return sx_call( return sx_call(
"federation-actor-card", "federation-actor-card-from-data",
cls="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4", d=d,
id=f"actor-{safe_id}", has_actor=actor is not None,
avatar=SxExpr(avatar), csrf=generate_csrf_token() if actor else None,
name=SxExpr(name_sx), follow_url=url_for("social.follow") if actor else None,
username=str(escape(username)), domain=str(escape(domain)), unfollow_url=url_for("social.unfollow") if actor else None,
summary=SxExpr(summary_sx) if summary_sx else None, list_type=list_type,
button=SxExpr(button_sx) if button_sx else None,
) )
@@ -304,24 +287,38 @@ def _search_results_sx(actors: list, query: str, page: int,
followed_urls: set, actor: Any) -> str: followed_urls: set, actor: Any) -> str:
"""Render search results with pagination sentinel.""" """Render search results with pagination sentinel."""
from quart import url_for from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
parts = [_actor_card_sx(a, actor, followed_urls, list_type="search") for a in actors] actor_dicts = [_actor_card_data(a, followed_urls, list_type="search") for a in actors]
if len(actors) >= 20: next_url = url_for("social.search_page", q=query, page=page + 1) if len(actors) >= 20 else None
next_url = url_for("social.search_page", q=query, page=page + 1)
parts.append(sx_call("federation-scroll-sentinel", url=next_url)) return sx_call(
return "(<> " + " ".join(parts) + ")" if parts else "" "federation-actor-list-from-data",
actors=actor_dicts, next_url=next_url, list_type="search",
has_actor=actor is not None,
csrf=generate_csrf_token() if actor else None,
follow_url=url_for("social.follow") if actor else None,
unfollow_url=url_for("social.unfollow") if actor else None,
)
def _actor_list_items_sx(actors: list, page: int, list_type: str, def _actor_list_items_sx(actors: list, page: int, list_type: str,
followed_urls: set, actor: Any) -> str: followed_urls: set, actor: Any) -> str:
"""Render actor list items (following/followers) with pagination sentinel.""" """Render actor list items (following/followers) with pagination sentinel."""
from quart import url_for from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
parts = [_actor_card_sx(a, actor, followed_urls, list_type=list_type) for a in actors] actor_dicts = [_actor_card_data(a, followed_urls, list_type=list_type) for a in actors]
if len(actors) >= 20: next_url = url_for(f"social.{list_type}_list_page", page=page + 1) if len(actors) >= 20 else None
next_url = url_for(f"social.{list_type}_list_page", page=page + 1)
parts.append(sx_call("federation-scroll-sentinel", url=next_url)) return sx_call(
return "(<> " + " ".join(parts) + ")" if parts else "" "federation-actor-list-from-data",
actors=actor_dicts, next_url=next_url, list_type=list_type,
has_actor=actor is not None,
csrf=generate_csrf_token() if actor else None,
follow_url=url_for("social.follow") if actor else None,
unfollow_url=url_for("social.unfollow") if actor else None,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -683,23 +680,15 @@ async def render_profile_page(ctx: dict, actor: Any, activities: list,
"federation-profile-summary-text", text=str(escape(actor.summary)), "federation-profile-summary-text", text=str(escape(actor.summary)),
) if actor.summary else "" ) if actor.summary else ""
activities_sx = "" activity_dicts = [
if activities: dict(
parts = [] activity_type=a.activity_type,
for a in activities: published=a.published.strftime("%Y-%m-%d %H:%M") if a.published else "",
published = a.published.strftime("%Y-%m-%d %H:%M") if a.published else "" object_type=a.object_type,
obj_type_sx = sx_call( )
"federation-activity-obj-type", obj_type=a.object_type, for a in activities
) if a.object_type else "" ]
parts.append(sx_call( activities_sx = sx_call("federation-activities-from-data", activities=activity_dicts)
"federation-activity-card",
activity_type=a.activity_type, published=published,
obj_type=SxExpr(obj_type_sx) if obj_type_sx else None,
))
items_sx = "(<> " + " ".join(parts) + ")"
activities_sx = sx_call("federation-activities-list", items=SxExpr(items_sx))
else:
activities_sx = sx_call("federation-activities-empty")
content = sx_call( content = sx_call(
"federation-profile-page", "federation-profile-page",

View File

@@ -47,6 +47,17 @@
(h2 :class "text-base sm:text-lg font-semibold" "Event tickets in this order") (h2 :class "text-base sm:text-lg font-semibold" "Event tickets in this order")
(ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" items))) (ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" items)))
;; Data-driven ticket items (replaces Python loop)
(defcomp ~checkout-return-tickets-from-data (&key tickets)
(~checkout-return-tickets
:items (<> (map (lambda (tk)
(~checkout-return-ticket
:name (get tk "name") :pill (get tk "pill")
:state (get tk "state") :type-name (get tk "type_name")
:date-str (get tk "date_str") :code (get tk "code")
:price (get tk "price")))
(or tickets (list))))))
(defcomp ~checkout-return-content (&key summary items calendar tickets status-message) (defcomp ~checkout-return-content (&key summary items calendar tickets status-message)
(div :class "max-w-full px-1 py-1" (div :class "max-w-full px-1 py-1"
(when summary (when summary

View File

@@ -99,29 +99,14 @@ def _orders_rows_sx(orders: list, page: int, total_pages: int,
from shared.utils import route_prefix from shared.utils import route_prefix
pfx = route_prefix() pfx = route_prefix()
parts = [] order_dicts = [
for o in orders: _order_row_data(o, pfx + url_for_fn("orders.defpage_order_detail", order_id=o.id))
d = _order_row_data(o, pfx + url_for_fn("orders.defpage_order_detail", order_id=o.id)) for o in orders
parts.append(sx_call("order-row-desktop", ]
oid=d["oid"], created=d["created"], next_url = (pfx + url_for_fn("orders.orders_rows") + qs_fn(page=page + 1)) if page < total_pages else None
desc=d["desc"], total=d["total"], return sx_call("order-rows-from-data",
pill=d["pill_desktop"], status=d["status"], orders=order_dicts, page=page, total_pages=total_pages,
url=d["url"])) next_url=next_url)
parts.append(sx_call("order-row-mobile",
oid=d["oid"], created=d["created"],
total=d["total"], pill=d["pill_mobile"],
status=d["status"], url=d["url"]))
if page < total_pages:
next_url = pfx + url_for_fn("orders.orders_rows") + qs_fn(page=page + 1)
parts.append(sx_call("infinite-scroll",
url=next_url, page=page,
total_pages=total_pages,
id_prefix="orders", colspan=5))
else:
parts.append(sx_call("order-end-row"))
return "(<> " + " ".join(parts) + ")"
def _orders_main_panel_sx(orders: list, rows_sx: str) -> str: def _orders_main_panel_sx(orders: list, rows_sx: str) -> str:
@@ -153,35 +138,25 @@ def _order_items_sx(order: Any) -> str:
"""Render order items list as sx.""" """Render order items list as sx."""
if not order or not order.items: if not order or not order.items:
return "" return ""
items = [] item_dicts = [
for item in order.items: dict(
prod_url = market_product_url(item.product_slug) href=market_product_url(item.product_slug),
if item.product_image: product_image=item.product_image,
img = sx_call( product_title=item.product_title or "Unknown product",
"order-item-image", product_id=str(item.product_id),
src=item.product_image, alt=item.product_title or "Product image", quantity=str(item.quantity),
)
else:
img = sx_call("order-item-no-image")
items.append(sx_call(
"order-item-row",
href=prod_url, img=SxExpr(img),
title=item.product_title or "Unknown product",
pid=f"Product ID: {item.product_id}",
qty=f"Qty: {item.quantity}",
price=f"{item.currency or order.currency or 'GBP'} {item.unit_price or 0:.2f}", price=f"{item.currency or order.currency or 'GBP'} {item.unit_price or 0:.2f}",
)) )
for item in order.items
items_sx = "(<> " + " ".join(items) + ")" ]
return sx_call("order-items-panel", items=SxExpr(items_sx)) return sx_call("order-items-from-data", items=item_dicts)
def _calendar_items_sx(calendar_entries: list | None) -> str: def _calendar_items_sx(calendar_entries: list | None) -> str:
"""Render calendar bookings for an order as sx.""" """Render calendar bookings for an order as sx."""
if not calendar_entries: if not calendar_entries:
return "" return ""
items = [] entry_dicts = []
for e in calendar_entries: for e in calendar_entries:
st = e.state or "" st = e.state or ""
pill = ( pill = (
@@ -193,16 +168,13 @@ def _calendar_items_sx(calendar_entries: list | None) -> str:
ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else "" ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else ""
if e.end_at: if e.end_at:
ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}" ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}"
items.append(sx_call( entry_dicts.append(dict(
"order-calendar-entry",
name=e.name, name=e.name,
pill=f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}", pill=f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}",
status=st.capitalize(), date_str=ds, status=st.capitalize(), date_str=ds,
cost=f"\u00a3{e.cost or 0:.2f}", cost=f"\u00a3{e.cost or 0:.2f}",
)) ))
return sx_call("order-calendar-from-data", entries=entry_dicts)
items_sx = "(<> " + " ".join(items) + ")"
return sx_call("order-calendar-section", items=SxExpr(items_sx))
def _order_main_sx(order: Any, calendar_entries: list | None) -> str: def _order_main_sx(order: Any, calendar_entries: list | None) -> str:
@@ -287,7 +259,7 @@ def _ticket_items_sx(order_tickets: list | None) -> str:
"""Render ticket items for an order as sx.""" """Render ticket items for an order as sx."""
if not order_tickets: if not order_tickets:
return "" return ""
items = [] ticket_dicts = []
for tk in order_tickets: for tk in order_tickets:
st = tk.state or "" st = tk.state or ""
pill = ( pill = (
@@ -296,22 +268,19 @@ def _ticket_items_sx(order_tickets: list | None) -> str:
else "bg-blue-100 text-blue-800" if st == "checked_in" else "bg-blue-100 text-blue-800" if st == "checked_in"
else "bg-stone-100 text-stone-700" else "bg-stone-100 text-stone-700"
) )
pill_cls = f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}"
ds = tk.entry_start_at.strftime("%-d %b %Y, %H:%M") if tk.entry_start_at else "" ds = tk.entry_start_at.strftime("%-d %b %Y, %H:%M") if tk.entry_start_at else ""
if tk.entry_end_at: if tk.entry_end_at:
ds += f" {tk.entry_end_at.strftime('%-d %b %Y, %H:%M')}" ds += f" {tk.entry_end_at.strftime('%-d %b %Y, %H:%M')}"
items.append(sx_call( ticket_dicts.append(dict(
"checkout-return-ticket",
name=tk.entry_name, name=tk.entry_name,
pill=pill_cls, pill=f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}",
state=st.replace("_", " ").capitalize(), state=st.replace("_", " ").capitalize(),
type_name=tk.ticket_type_name or None, type_name=tk.ticket_type_name or None,
date_str=ds, date_str=ds,
code=tk.code, code=tk.code,
price=f"£{tk.price or 0:.2f}", price=f"£{tk.price or 0:.2f}",
)) ))
items_sx = "(<> " + " ".join(items) + ")" return sx_call("checkout-return-tickets-from-data", tickets=ticket_dicts)
return sx_call("checkout-return-tickets", items=SxExpr(items_sx))
async def render_checkout_return_page(ctx: dict, order: Any | None, async def render_checkout_return_page(ctx: dict, order: Any | None,

View File

@@ -120,6 +120,57 @@
(<> auth (~header-child-sx :id "auth-header-child" :inner (<> auth (~header-child-sx :id "auth-header-child" :inner
(<> orders (~header-child-sx :id "orders-header-child" :inner order)))))) (<> orders (~header-child-sx :id "orders-header-child" :inner order))))))
;; ---------------------------------------------------------------------------
;; Data-driven order rows (replaces Python loop)
;; ---------------------------------------------------------------------------
(defcomp ~order-rows-from-data (&key orders page total-pages next-url)
(<>
(map (lambda (o)
(<>
(~order-row-desktop :oid (get o "oid") :created (get o "created")
:desc (get o "desc") :total (get o "total")
:pill (get o "pill_desktop") :status (get o "status") :url (get o "url"))
(~order-row-mobile :oid (get o "oid") :created (get o "created")
:total (get o "total") :pill (get o "pill_mobile")
:status (get o "status") :url (get o "url"))))
(or orders (list)))
(if next-url
(~infinite-scroll :url next-url :page page :total-pages total-pages
:id-prefix "orders" :colspan 5)
(~order-end-row))))
;; ---------------------------------------------------------------------------
;; Data-driven order items (replaces Python loop)
;; ---------------------------------------------------------------------------
(defcomp ~order-items-from-data (&key items)
(~order-items-panel
:items (<> (map (lambda (item)
(let* ((img (if (get item "product_image")
(~order-item-image :src (get item "product_image") :alt (or (get item "product_title") "Product image"))
(~order-item-no-image))))
(~order-item-row
:href (get item "href") :img img
:title (or (get item "product_title") "Unknown product")
:pid (str "Product ID: " (get item "product_id"))
:qty (str "Qty: " (get item "quantity"))
:price (get item "price"))))
(or items (list))))))
;; ---------------------------------------------------------------------------
;; Data-driven calendar entries (replaces Python loop)
;; ---------------------------------------------------------------------------
(defcomp ~order-calendar-from-data (&key entries)
(~order-calendar-section
:items (<> (map (lambda (e)
(~order-calendar-entry
:name (get e "name") :pill (get e "pill")
:status (get e "status") :date-str (get e "date_str")
:cost (get e "cost")))
(or entries (list))))))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; Checkout error screens ;; Checkout error screens
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------