Refactor SX templates: shared components, Python migration, cleanup

- Extract shared components (empty-state, delete-btn, sentinel, crud-*,
  view-toggle, img-or-placeholder, avatar, sumup-settings-form, auth
  forms, order tables/detail/checkout)
- Migrate all Python sx_call() callers to use shared components directly
- Remove 55+ thin wrapper defcomps from domain .sx files
- Remove trivial passthrough wrappers (blog-header-label, market-card-text, etc)
- Unify duplicate auth flows (account + federation) into shared/sx/templates/auth.sx
- Unify duplicate order views (cart + orders) into shared/sx/templates/orders.sx
- Disable static file caching in dev (SEND_FILE_MAX_AGE_DEFAULT=0)
- Add SX response validation and debug headers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 20:34:34 +00:00
parent 755313bd29
commit c0d369eb8e
58 changed files with 3473 additions and 1210 deletions

View File

@@ -59,9 +59,10 @@ def register():
href = app_slugs.get(item.slug, blog_url(f"/{item.slug}/"))
selected = "true" if (item.slug == first_seg
or item.slug == app_name) else "false"
img = sx_call("blog-nav-item-image",
img = sx_call("img-or-placeholder",
src=getattr(item, "feature_image", None),
label=getattr(item, "label", item.slug))
alt=getattr(item, "label", item.slug),
size_cls="w-8 h-8 rounded-full object-cover flex-shrink-0")
item_sxs.append(sx_call(
"blog-nav-item-link",
href=href, hx_get=href, selected=selected, nav_cls=nav_cls,
@@ -72,7 +73,8 @@ def register():
href = artdag_url("/")
selected = "true" if ("artdag" == first_seg
or "artdag" == app_name) else "false"
img = sx_call("blog-nav-item-image", src=None, label="art-dag")
img = sx_call("img-or-placeholder", src=None, alt="art-dag",
size_cls="w-8 h-8 rounded-full object-cover flex-shrink-0")
item_sxs.append(sx_call(
"blog-nav-item-link",
href=href, hx_get=href, selected=selected, nav_cls=nav_cls,
@@ -101,13 +103,15 @@ def register():
right_hs = ("on click set #" + container_id
+ ".scrollLeft to #" + container_id + ".scrollLeft + 200")
return sx_call("blog-nav-wrapper",
arrow_cls=arrow_cls,
return sx_call("scroll-nav-wrapper",
wrapper_id="menu-items-nav-wrapper",
container_id=container_id,
arrow_cls=arrow_cls,
left_hs=left_hs,
scroll_hs=scroll_hs,
right_hs=right_hs,
items=SxExpr(items_frag))
items=SxExpr(items_frag),
oob=True)
_handlers["nav-tree"] = _nav_tree_handler

View File

@@ -14,12 +14,6 @@
(h1 :class "text-3xl font-bold" "Snippets"))
(div :id "snippets-list" list)))
(defcomp ~blog-snippets-empty ()
(div :class "bg-white rounded-lg shadow"
(div :class "p-8 text-center text-stone-400"
(i :class "fa fa-puzzle-piece text-4xl mb-2")
(p "No snippets yet. Create one from the blog editor."))))
(defcomp ~blog-snippet-visibility-select (&key patch-url hx-headers options cls)
(select :name "visibility" :sx-patch patch-url :sx-target "#snippets-list" :sx-swap "innerHTML"
:sx-headers hx-headers :class "text-sm border border-stone-300 rounded px-2 py-1"
@@ -28,16 +22,6 @@
(defcomp ~blog-snippet-option (&key value selected label)
(option :value value :selected selected label))
(defcomp ~blog-snippet-delete-button (&key confirm-text delete-url hx-headers)
(button :type "button" :data-confirm "" :data-confirm-title "Delete snippet?"
:data-confirm-text confirm-text :data-confirm-icon "warning"
:data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel"
:data-confirm-event "confirmed"
:sx-delete delete-url :sx-trigger "confirmed" :sx-target "#snippets-list" :sx-swap "innerHTML"
:sx-headers hx-headers
:class "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0"
(i :class "fa fa-trash") " Delete"))
(defcomp ~blog-snippet-row (&key name owner badge-cls visibility extra)
(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"
(div :class "flex-1 min-w-0"
@@ -58,16 +42,6 @@
(div :id "menu-item-form" :class "mb-6")
(div :id "menu-items-list" list)))
(defcomp ~blog-menu-items-empty ()
(div :class "bg-white rounded-lg shadow"
(div :class "p-8 text-center text-stone-400"
(i :class "fa fa-inbox text-4xl mb-2")
(p "No menu items yet. Add one to get started!"))))
(defcomp ~blog-menu-item-image (&key src label)
(if src (img :src src :alt label :class "w-12 h-12 rounded-full object-cover flex-shrink-0")
(div :class "w-12 h-12 rounded-full bg-stone-200 flex-shrink-0")))
(defcomp ~blog-menu-item-row (&key img label slug sort-order edit-url delete-url confirm-text hx-headers)
(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"
(div :class "text-stone-400 cursor-move" (i :class "fa fa-grip-vertical"))
@@ -80,14 +54,9 @@
(button :type "button" :sx-get edit-url :sx-target "#menu-item-form" :sx-swap "innerHTML"
:class "px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded"
(i :class "fa fa-edit") " Edit")
(button :type "button" :data-confirm "" :data-confirm-title "Delete menu item?"
:data-confirm-text confirm-text :data-confirm-icon "warning"
:data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel"
:data-confirm-event "confirmed"
:sx-delete delete-url :sx-trigger "confirmed" :sx-target "#menu-items-list" :sx-swap "innerHTML"
:sx-headers hx-headers
:class "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800"
(i :class "fa fa-trash") " Delete"))))
(~delete-btn :url delete-url :trigger-target "#menu-items-list"
:title "Delete menu item?" :text confirm-text
:sx-headers hx-headers))))
(defcomp ~blog-menu-items-list (&key rows)
(div :class "bg-white rounded-lg shadow" (div :class "divide-y" rows)))
@@ -123,9 +92,6 @@
(defcomp ~blog-tag-groups-list (&key items)
(ul :class "space-y-2" items))
(defcomp ~blog-tag-groups-empty ()
(p :class "text-stone-500 text-sm" "No tag groups yet."))
(defcomp ~blog-unassigned-tag (&key name)
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200 rounded" name))

View File

@@ -52,9 +52,3 @@
(defcomp ~blog-home-main (&key html-content)
(article :class "relative" (div :class "blog-content p-2" (~rich-text :html html-content))))
(defcomp ~blog-admin-empty ()
(div :class "pb-8"))
(defcomp ~blog-settings-empty ()
(div :class "max-w-2xl mx-auto px-4 py-6"))

View File

@@ -1,8 +1,5 @@
;; Blog header components
(defcomp ~blog-header-label ()
(div))
(defcomp ~blog-container-nav (&key container-nav)
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
:id "entries-calendars-nav-wrapper" container-nav))

View File

@@ -1,55 +1,8 @@
;; Blog index components
(defcomp ~blog-end-of-results ()
(div :class "col-span-full mt-4 text-center text-xs text-stone-400" "End of results"))
(defcomp ~blog-sentinel-mobile (&key id next-url hyperscript)
(div :id id :class "block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"
:sx-get next-url :sx-trigger "intersect once delay:250ms, sentinelmobile:retry"
:sx-swap "outerHTML" :_ hyperscript
:role "status" :aria-live "polite" :aria-hidden "true"
(div :class "js-loading hidden flex justify-center py-8"
(div :class "animate-spin h-8 w-8 border-4 border-stone-300 border-t-stone-600 rounded-full"))
(div :class "js-neterr hidden text-center py-8 text-stone-400"
(i :class "fa fa-exclamation-triangle text-2xl")
(p :class "mt-2" "Loading failed \u2014 retrying\u2026"))))
(defcomp ~blog-sentinel-desktop (&key id next-url hyperscript)
(div :id id :class "hidden md:block h-4 opacity-0 pointer-events-none"
:sx-get next-url :sx-trigger "intersect once delay:250ms, sentinel:retry"
:sx-swap "outerHTML" :_ hyperscript
:role "status" :aria-live "polite" :aria-hidden "true"
(div :class "js-loading hidden flex justify-center py-2"
(div :class "animate-spin h-6 w-6 border-2 border-stone-300 border-t-stone-600 rounded-full"))
(div :class "js-neterr hidden text-center py-2 text-stone-400 text-sm" "Retry\u2026")))
(defcomp ~blog-page-sentinel (&key id next-url)
(div :id id :class "h-4 opacity-0 pointer-events-none"
:sx-get next-url :sx-trigger "intersect once delay:250ms" :sx-swap "outerHTML"))
(defcomp ~blog-no-pages ()
(div :class "col-span-full mt-8 text-center text-stone-500" "No pages found."))
(defcomp ~blog-list-svg ()
(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" :viewBox "0 0 24 24"
:stroke "currentColor" :stroke-width "2"
(path :stroke-linecap "round" :stroke-linejoin "round" :d "M4 6h16M4 12h16M4 18h16")))
(defcomp ~blog-tile-svg ()
(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" :viewBox "0 0 24 24"
:stroke "currentColor" :stroke-width "2"
(path :stroke-linecap "round" :stroke-linejoin "round"
:d "M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z")))
(defcomp ~blog-view-toggle (&key list-href tile-href hx-select list-cls tile-cls list-svg tile-svg)
(div :class "hidden md:flex justify-end px-3 pt-3 gap-1"
(a :href list-href :sx-get list-href :sx-target "#main-panel" :sx-select hx-select
:sx-swap "outerHTML" :sx-push-url "true" :class (str "p-1.5 rounded " list-cls) :title "List view"
:_ "on click js localStorage.removeItem('blog_view') end" list-svg)
(a :href tile-href :sx-get tile-href :sx-target "#main-panel" :sx-select hx-select
:sx-swap "outerHTML" :sx-push-url "true" :class (str "p-1.5 rounded " tile-cls) :title "Tile view"
:_ "on click js localStorage.setItem('blog_view','tile') end" tile-svg)))
(defcomp ~blog-content-type-tabs (&key posts-href pages-href hx-select posts-cls pages-cls)
(div :class "flex justify-center gap-1 px-3 pt-3"
(a :href posts-href :sx-get posts-href :sx-target "#main-panel"

View File

@@ -18,33 +18,11 @@
(i :class "fa fa-shopping-bag text-green-600 mr-1")
" Market \u2014 enable product catalog on this page"))))
(defcomp ~blog-sumup-connected ()
(span :class "ml-2 text-xs text-green-600" (i :class "fa fa-check-circle") " Connected"))
(defcomp ~blog-sumup-key-hint ()
(p :class "text-xs text-stone-400 mt-0.5" "Key is set. Leave blank to keep current key."))
(defcomp ~blog-sumup-form (&key sumup-url merchant-code placeholder key-hint checkout-prefix connected)
(defcomp ~blog-sumup-form (&key sumup-url merchant-code placeholder sumup-configured checkout-prefix)
(div :class "mt-4 pt-4 border-t border-stone-100"
(h4 :class "text-sm font-medium text-stone-700"
(i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment")
(p :class "text-xs text-stone-400 mt-1 mb-3"
"Configure per-page SumUp credentials. Leave blank to use the global merchant account.")
(form :sx-put sumup-url :sx-target "#features-panel" :sx-swap "outerHTML" :class "space-y-3"
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Merchant Code")
(input :type "text" :name "merchant_code" :value merchant-code :placeholder "e.g. ME4J6100"
:class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "API Key")
(input :type "password" :name "api_key" :value "" :placeholder placeholder
:class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500")
key-hint)
(div (label :class "block text-xs font-medium text-stone-600 mb-1" "Checkout Reference Prefix")
(input :type "text" :name "checkout_prefix" :value checkout-prefix :placeholder "e.g. ROSE-"
:class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))
(button :type "submit"
:class "px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500"
"Save SumUp Settings")
connected)))
(~sumup-settings-form :update-url sumup-url :merchant-code merchant-code
:placeholder placeholder :sumup-configured sumup-configured
:checkout-prefix checkout-prefix :panel-id "features-panel")))
(defcomp ~blog-features-panel (&key form sumup)
(div :id "features-panel" :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200"

View File

@@ -51,10 +51,9 @@ _oob_header_sx = oob_header_sx
def _blog_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Blog header row — empty child of root."""
label_sx = sx_call("blog-header-label")
return sx_call("menu-row-sx",
id="blog-row", level=1,
link_label_content=SxExpr(label_sx),
link_label_content=SxExpr("(div)"),
child_id="blog-header-child", oob=oob,
)
@@ -160,7 +159,7 @@ def _blog_sentinel_sx(ctx: dict) -> str:
total_pages = int(total_pages)
if page >= total_pages:
return sx_call("blog-end-of-results")
return sx_call("end-of-results")
current_local_href = ctx.get("current_local_href", "/index")
next_url = f"{current_local_href}?page={page + 1}"
@@ -190,9 +189,9 @@ def _blog_sentinel_sx(ctx: dict) -> str:
)
return (
sx_call("blog-sentinel-mobile", id=f"sentinel-{page}-m", next_url=next_url, hyperscript=mobile_hs)
sx_call("sentinel-mobile", id=f"sentinel-{page}-m", next_url=next_url, hyperscript=mobile_hs)
+ " "
+ sx_call("blog-sentinel-desktop", id=f"sentinel-{page}-d", next_url=next_url, hyperscript=desktop_hs)
+ sx_call("sentinel-desktop", id=f"sentinel-{page}-d", next_url=next_url, hyperscript=desktop_hs)
)
@@ -363,11 +362,11 @@ def _page_cards_sx(ctx: dict) -> str:
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("blog-page-sentinel",
parts.append(sx_call("sentinel-simple",
id=f"sentinel-{page_num}-d", next_url=next_url,
))
elif pages:
parts.append(sx_call("blog-end-of-results"))
parts.append(sx_call("end-of-results"))
else:
parts.append(sx_call("blog-no-pages"))
@@ -407,12 +406,12 @@ def _view_toggle_sx(ctx: dict) -> str:
list_href = f"{current_local_href}"
tile_href = f"{current_local_href}{'&' if '?' in current_local_href else '?'}view=tile"
list_svg_sx = sx_call("blog-list-svg")
tile_svg_sx = sx_call("blog-tile-svg")
list_svg_sx = sx_call("list-svg")
tile_svg_sx = sx_call("tile-svg")
return sx_call("blog-view-toggle",
return sx_call("view-toggle",
list_href=list_href, tile_href=tile_href, hx_select=hx_select,
list_cls=list_cls, tile_cls=tile_cls,
list_cls=list_cls, tile_cls=tile_cls, storage_key="blog_view",
list_svg=SxExpr(list_svg_sx), tile_svg=SxExpr(tile_svg_sx),
)
@@ -782,7 +781,7 @@ def _home_main_panel_sx(ctx: dict) -> str:
# ---------------------------------------------------------------------------
def _post_admin_main_panel_sx(ctx: dict) -> str:
return sx_call("blog-admin-empty")
return '(div :class "pb-8")'
# ---------------------------------------------------------------------------
@@ -790,7 +789,7 @@ def _post_admin_main_panel_sx(ctx: dict) -> str:
# ---------------------------------------------------------------------------
def _settings_main_panel_sx(ctx: dict) -> str:
return sx_call("blog-settings-empty")
return '(div :class "max-w-2xl mx-auto px-4 py-6")'
def _cache_main_panel_sx(ctx: dict) -> str:
@@ -821,7 +820,7 @@ def _snippets_list_sx(ctx: dict) -> str:
user_id = getattr(user, "id", None)
if not snippets:
return sx_call("blog-snippets-empty")
return sx_call("empty-state", icon="fa fa-puzzle-piece", message="No snippets yet. Create one from the blog editor.")
badge_colours = {
"private": "bg-stone-200 text-stone-700",
@@ -856,10 +855,12 @@ def _snippets_list_sx(ctx: dict) -> str:
if s_uid == user_id or is_admin:
del_url = qurl("snippets.delete_snippet", snippet_id=s_id)
extra += sx_call("blog-snippet-delete-button",
confirm_text=f'Delete \u201c{s_name}\u201d?',
delete_url=del_url,
hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
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",
@@ -890,7 +891,7 @@ def _menu_items_list_sx(ctx: dict) -> str:
csrf = _ctx_csrf(ctx)
if not menu_items:
return sx_call("blog-menu-items-empty")
return sx_call("empty-state", icon="fa fa-inbox", message="No menu items yet. Add one to get started!")
row_parts = []
for item in menu_items:
@@ -903,7 +904,8 @@ def _menu_items_list_sx(ctx: dict) -> str:
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("blog-menu-item-image", src=fi, label=label)
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,
@@ -958,7 +960,7 @@ def _tag_groups_main_panel_sx(ctx: dict) -> str:
))
groups_sx = sx_call("blog-tag-groups-list", items=SxExpr("(<> " + " ".join(li_parts) + ")"))
else:
groups_sx = sx_call("blog-tag-groups-empty")
groups_sx = sx_call("empty-state", message="No tag groups yet.", cls="text-stone-500 text-sm")
# Unassigned tags
unassigned_sx = ""
@@ -1687,7 +1689,8 @@ def render_menu_items_nav_oob(menu_items, ctx: dict | None = None) -> str:
selected = "true" if (item_slug == first_seg or item_slug == app_name) else "false"
img_sx = sx_call("blog-nav-item-image", src=fi, label=label)
img_sx = sx_call("img-or-placeholder", src=fi, alt=label,
size_cls="w-8 h-8 rounded-full object-cover flex-shrink-0")
if item_slug != "cart":
item_parts.append(sx_call("blog-nav-item-link",
@@ -1702,12 +1705,13 @@ def render_menu_items_nav_oob(menu_items, ctx: dict | None = None) -> str:
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""
return sx_call("blog-nav-wrapper",
arrow_cls=arrow_cls, container_id=container_id,
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,
items=SxExpr(items_sx) if items_sx else None, oob=True,
)
@@ -1737,15 +1741,12 @@ def render_features_panel(features: dict, post: dict,
sumup_sx = ""
if features.get("calendar") or features.get("market"):
placeholder = "\u2022" * 8 if sumup_configured else "sup_sk_..."
connected = sx_call("blog-sumup-connected") if sumup_configured else ""
key_hint = sx_call("blog-sumup-key-hint") if sumup_configured else ""
sumup_sx = sx_call("blog-sumup-form",
sumup_url=sumup_url, merchant_code=sumup_merchant_code,
placeholder=placeholder,
key_hint=SxExpr(key_hint) if key_hint else None,
sumup_configured=sumup_configured,
checkout_prefix=sumup_checkout_prefix,
connected=SxExpr(connected) if connected else None,
)
return sx_call("blog-features-panel",
@@ -1906,8 +1907,8 @@ def render_nav_entries_oob(associated_entries, calendars, post: dict, ctx: dict
href = events_url_fn(entry_path) if events_url_fn else entry_path
item_parts.append(sx_call("blog-nav-entry-item",
href=href, nav_cls=nav_cls, name=e_name, date_str=date_str,
item_parts.append(sx_call("calendar-entry-nav",
href=href, nav_class=nav_cls, name=e_name, date_str=date_str,
))
# Calendar links
@@ -1923,6 +1924,11 @@ def render_nav_entries_oob(associated_entries, calendars, post: dict, ctx: dict
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""
return sx_call("blog-nav-entries-wrapper",
scroll_hs=scroll_hs, items=SxExpr(items_sx) if items_sx else None,
return sx_call("scroll-nav-wrapper",
wrapper_id="entries-calendars-nav-wrapper", container_id="associated-items-container",
arrow_cls="entries-nav-arrow",
left_hs="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200",
scroll_hs=scroll_hs,
right_hs="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200",
items=SxExpr(items_sx) if items_sx else None, oob=True,
)