diff --git a/blog/sx/admin.sx b/blog/sx/admin.sx index 85d2f98..72e787c 100644 --- a/blog/sx/admin.sx +++ b/blog/sx/admin.sx @@ -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) diff --git a/blog/sx/cards.sx b/blog/sx/cards.sx index cf4f8fa..d9f67d1 100644 --- a/blog/sx/cards.sx +++ b/blog/sx/cards.sx @@ -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" diff --git a/blog/sx/filters.sx b/blog/sx/filters.sx index 2198389..4332ea3 100644 --- a/blog/sx/filters.sx +++ b/blog/sx/filters.sx @@ -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))))))) diff --git a/blog/sx/menu_items.sx b/blog/sx/menu_items.sx index d9fb57d..2cbc6f8 100644 --- a/blog/sx/menu_items.sx +++ b/blog/sx/menu_items.sx @@ -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))) diff --git a/blog/sx/sx_components.py b/blog/sx/sx_components.py index 9f383c1..d23735d 100644 --- a/blog/sx/sx_components.py +++ b/blog/sx/sx_components.py @@ -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 ---- diff --git a/federation/sx/profile.sx b/federation/sx/profile.sx index 9ce33e6..7587f03 100644 --- a/federation/sx/profile.sx +++ b/federation/sx/profile.sx @@ -53,3 +53,16 @@ (defcomp ~federation-profile-summary-text (&key 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))))) diff --git a/federation/sx/search.sx b/federation/sx/search.sx index ad8d2c8..4ede0bb 100644 --- a/federation/sx/search.sx +++ b/federation/sx/search.sx @@ -40,6 +40,47 @@ summary) 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) (p :class cls text)) diff --git a/federation/sx/social.sx b/federation/sx/social.sx index b7b59ae..22b2a41 100644 --- a/federation/sx/social.sx +++ b/federation/sx/social.sx @@ -90,6 +90,65 @@ compose) (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 --- (defcomp ~federation-compose-reply (&key reply-to) diff --git a/federation/sx/sx_components.py b/federation/sx/sx_components.py index 4435c3d..c6f3655 100644 --- a/federation/sx/sx_components.py +++ b/federation/sx/sx_components.py @@ -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) actor_icon = getattr(item, "actor_icon", None) actor_name = getattr(item, "actor_name", "?") actor_username = getattr(item, "actor_username", "") actor_domain = getattr(item, "actor_domain", "") - content = getattr(item, "content", "") - summary = getattr(item, "summary", None) - published = getattr(item, "published", None) + oid = getattr(item, "object_id", "") or "" + safe_id = oid.replace("/", "_").replace(":", "_") + initial = actor_name[0].upper() if (not actor_icon and actor_name) else "?" url = getattr(item, "url", None) post_type = getattr(item, "post_type", "") + published = getattr(item, "published", None) + summary = getattr(item, "summary", None) - boost_sx = sx_call( - "federation-boost-label", name=str(escape(boosted_by)), - ) if boosted_by else "" - - 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), + d: dict[str, Any] = dict( + boosted_by=str(escape(boosted_by)) if boosted_by else None, + actor_icon=actor_icon or None, actor_name=str(escape(actor_name)), actor_username=str(escape(actor_username)), - domain=domain_str, time=time_str, - content=SxExpr(content_sx), - original=SxExpr(original_sx) if original_sx else None, - interactions=SxExpr(interactions_sx) if interactions_sx else None, + domain=f"@{escape(actor_domain)}" if actor_domain else "", + time=published.strftime("%b %d, %H:%M") if published else "", + content=getattr(item, "content", ""), + 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: """Render timeline items with infinite scroll sentinel.""" 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: last = items[-1] 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) else: 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) # --------------------------------------------------------------------------- -def _actor_card_sx(a: Any, actor: Any, followed_urls: set, - *, list_type: str = "search") -> str: - """Render a single actor card with follow/unfollow button.""" - from shared.browser.app.csrf import generate_csrf_token +def _actor_card_data(a: Any, followed_urls: set, + *, list_type: str = "search") -> dict: + """Serialize an actor to a display-data dict for sx rendering.""" from quart import url_for - csrf = generate_csrf_token() display_name = getattr(a, "display_name", None) or getattr(a, "preferred_username", "") username = getattr(a, "preferred_username", "") domain = getattr(a, "domain", "") @@ -248,55 +243,43 @@ def _actor_card_sx(a: Any, actor: Any, followed_urls: set, aid = getattr(a, "id", None) safe_id = actor_url.replace("/", "_").replace(":", "_") - initial = (display_name or username)[0].upper() if (not icon_url and (display_name or username)) else "?" - avatar = sx_call( - "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", - initial=None if icon_url else initial, + is_followed = actor_url in (followed_urls or set()) + + d: dict[str, Any] = dict( + 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: - name_sx = sx_call( - "federation-actor-name-link", - href=url_for("social.defpage_actor_timeline", id=aid), - name=str(escape(display_name)), - ) + d["name_href"] = url_for("social.defpage_actor_timeline", id=aid) else: - name_sx = sx_call( - "federation-actor-name-link-external", - href=f"https://{domain}/@{username}", - name=str(escape(display_name)), - ) + d["name_href"] = f"https://{domain}/@{username}" + d["external_link"] = True + return d - summary_sx = sx_call("federation-actor-summary", summary=summary) if summary else "" - # Follow/unfollow button - button_sx = "" - if actor: - is_followed = actor_url in (followed_urls or set()) - if list_type == "following" or is_followed: - 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, - ) +def _actor_card_sx(a: Any, actor: Any, followed_urls: set, + *, list_type: str = "search") -> str: + """Render a single actor card (used by follow/unfollow re-render).""" + from shared.browser.app.csrf import generate_csrf_token + from quart import url_for + d = _actor_card_data(a, followed_urls, list_type=list_type) return sx_call( - "federation-actor-card", - cls="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4", - id=f"actor-{safe_id}", - avatar=SxExpr(avatar), - name=SxExpr(name_sx), - username=str(escape(username)), domain=str(escape(domain)), - summary=SxExpr(summary_sx) if summary_sx else None, - button=SxExpr(button_sx) if button_sx else None, + "federation-actor-card-from-data", + d=d, + 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, + list_type=list_type, ) @@ -304,24 +287,38 @@ def _search_results_sx(actors: list, query: str, page: int, followed_urls: set, actor: Any) -> str: """Render search results with pagination sentinel.""" 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] - if len(actors) >= 20: - next_url = url_for("social.search_page", q=query, page=page + 1) - parts.append(sx_call("federation-scroll-sentinel", url=next_url)) - return "(<> " + " ".join(parts) + ")" if parts else "" + actor_dicts = [_actor_card_data(a, followed_urls, list_type="search") for a in actors] + next_url = url_for("social.search_page", q=query, page=page + 1) if len(actors) >= 20 else None + + return sx_call( + "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, followed_urls: set, actor: Any) -> str: """Render actor list items (following/followers) with pagination sentinel.""" 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] - if len(actors) >= 20: - next_url = url_for(f"social.{list_type}_list_page", page=page + 1) - parts.append(sx_call("federation-scroll-sentinel", url=next_url)) - return "(<> " + " ".join(parts) + ")" if parts else "" + actor_dicts = [_actor_card_data(a, followed_urls, list_type=list_type) for a in actors] + next_url = url_for(f"social.{list_type}_list_page", page=page + 1) if len(actors) >= 20 else None + + return sx_call( + "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)), ) if actor.summary else "" - activities_sx = "" - if activities: - parts = [] - for a in activities: - published = a.published.strftime("%Y-%m-%d %H:%M") if a.published else "" - obj_type_sx = sx_call( - "federation-activity-obj-type", obj_type=a.object_type, - ) if a.object_type else "" - parts.append(sx_call( - "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") + activity_dicts = [ + dict( + activity_type=a.activity_type, + published=a.published.strftime("%Y-%m-%d %H:%M") if a.published else "", + object_type=a.object_type, + ) + for a in activities + ] + activities_sx = sx_call("federation-activities-from-data", activities=activity_dicts) content = sx_call( "federation-profile-page", diff --git a/orders/sx/checkout.sx b/orders/sx/checkout.sx index 7c6eeeb..8dfa2d9 100644 --- a/orders/sx/checkout.sx +++ b/orders/sx/checkout.sx @@ -47,6 +47,17 @@ (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))) +;; 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) (div :class "max-w-full px-1 py-1" (when summary diff --git a/orders/sx/sx_components.py b/orders/sx/sx_components.py index d3615eb..87a1dc8 100644 --- a/orders/sx/sx_components.py +++ b/orders/sx/sx_components.py @@ -99,29 +99,14 @@ def _orders_rows_sx(orders: list, page: int, total_pages: int, from shared.utils import route_prefix pfx = route_prefix() - parts = [] - for o in orders: - d = _order_row_data(o, pfx + url_for_fn("orders.defpage_order_detail", order_id=o.id)) - parts.append(sx_call("order-row-desktop", - oid=d["oid"], created=d["created"], - desc=d["desc"], total=d["total"], - pill=d["pill_desktop"], status=d["status"], - url=d["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) + ")" + order_dicts = [ + _order_row_data(o, pfx + url_for_fn("orders.defpage_order_detail", order_id=o.id)) + for o in orders + ] + next_url = (pfx + url_for_fn("orders.orders_rows") + qs_fn(page=page + 1)) if page < total_pages else None + return sx_call("order-rows-from-data", + orders=order_dicts, page=page, total_pages=total_pages, + next_url=next_url) 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.""" if not order or not order.items: return "" - items = [] - for item in order.items: - prod_url = market_product_url(item.product_slug) - if item.product_image: - img = sx_call( - "order-item-image", - src=item.product_image, alt=item.product_title or "Product image", - ) - 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}", + item_dicts = [ + dict( + href=market_product_url(item.product_slug), + product_image=item.product_image, + product_title=item.product_title or "Unknown product", + product_id=str(item.product_id), + quantity=str(item.quantity), price=f"{item.currency or order.currency or 'GBP'} {item.unit_price or 0:.2f}", - )) - - items_sx = "(<> " + " ".join(items) + ")" - return sx_call("order-items-panel", items=SxExpr(items_sx)) + ) + for item in order.items + ] + return sx_call("order-items-from-data", items=item_dicts) def _calendar_items_sx(calendar_entries: list | None) -> str: """Render calendar bookings for an order as sx.""" if not calendar_entries: return "" - items = [] + entry_dicts = [] for e in calendar_entries: st = e.state or "" 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 "" if e.end_at: ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}" - items.append(sx_call( - "order-calendar-entry", + entry_dicts.append(dict( name=e.name, pill=f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}", status=st.capitalize(), date_str=ds, cost=f"\u00a3{e.cost or 0:.2f}", )) - - items_sx = "(<> " + " ".join(items) + ")" - return sx_call("order-calendar-section", items=SxExpr(items_sx)) + return sx_call("order-calendar-from-data", entries=entry_dicts) 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.""" if not order_tickets: return "" - items = [] + ticket_dicts = [] for tk in order_tickets: st = tk.state or "" 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-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 "" if tk.entry_end_at: ds += f" – {tk.entry_end_at.strftime('%-d %b %Y, %H:%M')}" - items.append(sx_call( - "checkout-return-ticket", + ticket_dicts.append(dict( 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(), type_name=tk.ticket_type_name or None, date_str=ds, code=tk.code, price=f"£{tk.price or 0:.2f}", )) - items_sx = "(<> " + " ".join(items) + ")" - return sx_call("checkout-return-tickets", items=SxExpr(items_sx)) + return sx_call("checkout-return-tickets-from-data", tickets=ticket_dicts) async def render_checkout_return_page(ctx: dict, order: Any | None, diff --git a/shared/sx/templates/orders.sx b/shared/sx/templates/orders.sx index 3c43794..03022a5 100644 --- a/shared/sx/templates/orders.sx +++ b/shared/sx/templates/orders.sx @@ -120,6 +120,57 @@ (<> auth (~header-child-sx :id "auth-header-child" :inner (<> 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 ;; ---------------------------------------------------------------------------