Compare commits
18 Commits
0456b3d25c
...
64aa417d63
| Author | SHA1 | Date | |
|---|---|---|---|
| 64aa417d63 | |||
| 2a04aaad5e | |||
| 1d59023571 | |||
| 877e776977 | |||
| 1560207097 | |||
| aed4c03537 | |||
| dfccd113fc | |||
| b15025befd | |||
| 0144220427 | |||
| c71ca6754d | |||
| e81d77437e | |||
| 36a0bd8577 | |||
| 4298d5be16 | |||
| 1077fae815 | |||
| 57a31a3b83 | |||
| 1db52472e3 | |||
| 278ae3e8f6 | |||
| ad75798ab7 |
@@ -69,7 +69,7 @@ def register(url_prefix="/"):
|
||||
return sx_response(sx_call(
|
||||
"account-newsletter-toggle",
|
||||
id=f"nl-{nid}", url=toggle_url,
|
||||
hdrs=f'{{"X-CSRFToken": "{csrf}"}}',
|
||||
hdrs={"X-CSRFToken": csrf},
|
||||
target=f"#nl-{nid}",
|
||||
cls=f"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}",
|
||||
checked=checked,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
;; Account auth-menu fragment handler
|
||||
;; returns: sx
|
||||
;;
|
||||
;; Renders the desktop + mobile auth menu (sign-in or user link).
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
:toggle (~account-newsletter-toggle
|
||||
:id (str "nl-" nid)
|
||||
:url toggle-url
|
||||
:hdrs (str "{\"X-CSRFToken\": \"" csrf "\"}")
|
||||
:hdrs {:X-CSRFToken csrf}
|
||||
:target (str "#nl-" nid)
|
||||
:cls (str "relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 " bg)
|
||||
:checked checked
|
||||
|
||||
@@ -144,7 +144,7 @@ def _render_page_search_results(pages, query, page, has_more) -> str:
|
||||
items_sx = "(<> " + " ".join(items) + ")"
|
||||
return sx_call("page-search-results",
|
||||
items=SxExpr(items_sx),
|
||||
sentinel=SxExpr(sentinel) if sentinel else None)
|
||||
sentinel=sentinel or None)
|
||||
|
||||
|
||||
def _render_menu_items_nav_oob(menu_items) -> str:
|
||||
@@ -191,12 +191,12 @@ def _render_menu_items_nav_oob(menu_items) -> str:
|
||||
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,
|
||||
nav_cls=nav_button_cls, img=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,
|
||||
img=img_sx, label=label,
|
||||
))
|
||||
|
||||
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""
|
||||
|
||||
@@ -138,7 +138,7 @@ def _render_calendar_view(
|
||||
e_id = getattr(e, "id", None)
|
||||
e_name = esc(getattr(e, "name", ""))
|
||||
t_url = toggle_url_fn(e_id)
|
||||
hx_hdrs = f'{{"X-CSRFToken": "{csrf}"}}'
|
||||
hx_hdrs = '{:X-CSRFToken "' + csrf + '"}'
|
||||
|
||||
if e_id in associated_entry_ids:
|
||||
entry_btns.append(
|
||||
@@ -190,54 +190,14 @@ def _render_calendar_view(
|
||||
def _render_associated_entries(all_calendars, associated_entry_ids, post_slug: str) -> str:
|
||||
"""Render the associated entries panel."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for as qurl
|
||||
from sxc.pages.helpers import _extract_associated_entries_data
|
||||
|
||||
csrf = generate_csrf_token()
|
||||
entry_data = _extract_associated_entries_data(
|
||||
all_calendars, associated_entry_ids, post_slug)
|
||||
|
||||
has_entries = False
|
||||
entry_items: list[str] = []
|
||||
for calendar in all_calendars:
|
||||
entries = getattr(calendar, "entries", []) or []
|
||||
cal_name = getattr(calendar, "name", "")
|
||||
cal_post = getattr(calendar, "post", None)
|
||||
cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None
|
||||
cal_title = getattr(cal_post, "title", "") if cal_post else ""
|
||||
|
||||
for entry in entries:
|
||||
e_id = getattr(entry, "id", None)
|
||||
if e_id not in associated_entry_ids:
|
||||
continue
|
||||
if getattr(entry, "deleted_at", None) is not None:
|
||||
continue
|
||||
has_entries = True
|
||||
e_name = getattr(entry, "name", "")
|
||||
e_start = getattr(entry, "start_at", None)
|
||||
e_end = getattr(entry, "end_at", None)
|
||||
|
||||
toggle_url = host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=e_id))
|
||||
|
||||
img_sx = sx_call("blog-entry-image", src=cal_fi, title=cal_title)
|
||||
|
||||
date_str = e_start.strftime("%A, %B %d, %Y at %H:%M") if e_start else ""
|
||||
if e_end:
|
||||
date_str += f" \u2013 {e_end.strftime('%H:%M')}"
|
||||
|
||||
entry_items.append(sx_call("blog-associated-entry",
|
||||
confirm_text=f"This will remove {e_name} from this post",
|
||||
toggle_url=toggle_url,
|
||||
hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
|
||||
img=SxExpr(img_sx), name=e_name,
|
||||
date_str=f"{cal_name} \u2022 {date_str}",
|
||||
))
|
||||
|
||||
if has_entries:
|
||||
content_sx = sx_call("blog-associated-entries-content",
|
||||
items=SxExpr("(<> " + " ".join(entry_items) + ")"),
|
||||
)
|
||||
else:
|
||||
content_sx = sx_call("blog-associated-entries-empty")
|
||||
|
||||
return sx_call("blog-associated-entries-panel", content=SxExpr(content_sx))
|
||||
return sx_call("blog-associated-entries-from-data",
|
||||
entries=entry_data, csrf=csrf)
|
||||
|
||||
|
||||
def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> str:
|
||||
|
||||
@@ -156,14 +156,10 @@ def register():
|
||||
csrf = generate_csrf_token()
|
||||
|
||||
def _like_btn(liked):
|
||||
if liked:
|
||||
colour, icon, label = "text-red-600", "fa-solid fa-heart", "Unlike this post"
|
||||
else:
|
||||
colour, icon, label = "text-stone-300", "fa-regular fa-heart", "Like this post"
|
||||
return sx_call("market-like-toggle-button",
|
||||
colour=colour, action=like_url,
|
||||
hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
|
||||
label=label, icon_cls=icon)
|
||||
return sx_call("blog-like-toggle",
|
||||
like_url=like_url,
|
||||
hx_headers={"X-CSRFToken": csrf},
|
||||
heart="\u2764\ufe0f" if liked else "\U0001f90d")
|
||||
|
||||
if not g.user:
|
||||
return sx_response(_like_btn(False), status=403)
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
"""Blog page data service — provides serialized dicts for .sx defpages."""
|
||||
from __future__ import annotations
|
||||
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
|
||||
def _sx_content_expr(raw: str) -> SxExpr | None:
|
||||
"""Wrap non-empty sx_content as SxExpr so it serializes unquoted."""
|
||||
return SxExpr(raw) if raw else None
|
||||
|
||||
|
||||
class BlogPageService:
|
||||
"""Service for blog page data, callable via (service "blog-page" ...)."""
|
||||
@@ -424,7 +431,7 @@ class BlogPageService:
|
||||
"authors": authors,
|
||||
"feature_image": post.get("feature_image"),
|
||||
"html_content": post.get("html", ""),
|
||||
"sx_content": post.get("sx_content", ""),
|
||||
"sx_content": _sx_content_expr(post.get("sx_content", "")),
|
||||
}
|
||||
|
||||
async def preview_data(self, session, *, slug=None, **kw):
|
||||
|
||||
126
blog/sx/admin.sx
126
blog/sx/admin.sx
@@ -206,7 +206,7 @@
|
||||
(when is-admin
|
||||
(~blog-snippet-visibility-select
|
||||
:patch-url (get s "patch_url")
|
||||
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
|
||||
:hx-headers {:X-CSRFToken csrf}
|
||||
:options (<>
|
||||
(~blog-snippet-option :value "private" :selected (= vis "private") :label "private")
|
||||
(~blog-snippet-option :value "shared" :selected (= vis "shared") :label "shared")
|
||||
@@ -217,7 +217,7 @@
|
||||
:trigger-target "#snippets-list"
|
||||
:title "Delete snippet?"
|
||||
:text (str "Delete \u201c" name "\u201d?")
|
||||
:sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
|
||||
:sx-headers {:X-CSRFToken csrf}
|
||||
:cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0"))))))
|
||||
(or snippets (list)))))))
|
||||
|
||||
@@ -240,7 +240,7 @@
|
||||
:edit-url (get mi "edit_url")
|
||||
:delete-url (get mi "delete_url")
|
||||
:confirm-text (str "Remove " (get mi "label") " from the menu?")
|
||||
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")))
|
||||
:hx-headers {:X-CSRFToken csrf}))
|
||||
(or menu-items (list)))))))
|
||||
|
||||
;; Tag Groups — receives serialized tag group data from service
|
||||
@@ -292,3 +292,123 @@
|
||||
:name (get t "name")))
|
||||
(or all-tags (list))))
|
||||
:delete-form (~blog-tag-group-delete-form :delete-url delete-url :csrf csrf)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Preview content composition — replaces _h_post_preview_content
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~blog-preview-content (&key sx-pretty json-pretty sx-rendered lex-rendered)
|
||||
(let* ((sections (list)))
|
||||
(if (and (not sx-pretty) (not json-pretty) (not sx-rendered) (not lex-rendered))
|
||||
(~blog-preview-empty)
|
||||
(~blog-preview-panel :sections
|
||||
(<>
|
||||
(when sx-pretty
|
||||
(~blog-preview-section :title "S-Expression Source" :content sx-pretty))
|
||||
(when json-pretty
|
||||
(~blog-preview-section :title "Lexical JSON" :content json-pretty))
|
||||
(when sx-rendered
|
||||
(~blog-preview-section :title "SX Rendered"
|
||||
:content (~blog-preview-rendered :html sx-rendered)))
|
||||
(when lex-rendered
|
||||
(~blog-preview-section :title "Lexical Rendered"
|
||||
:content (~blog-preview-rendered :html lex-rendered))))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Data introspection composition — replaces _h_post_data_content
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~blog-data-value-cell (&key value value-type)
|
||||
(if (= value-type "nil")
|
||||
(span :class "text-neutral-400" "\u2014")
|
||||
(pre :class "whitespace-pre-wrap break-words break-all text-xs"
|
||||
(if (or (= value-type "date") (= value-type "other"))
|
||||
(code value)
|
||||
value))))
|
||||
|
||||
(defcomp ~blog-data-scalar-table (&key columns)
|
||||
(div :class "w-full overflow-x-auto sm:overflow-visible"
|
||||
(table :class "w-full table-fixed text-sm border border-neutral-200 rounded-xl overflow-hidden"
|
||||
(thead :class "bg-neutral-50/70"
|
||||
(tr (th :class "px-3 py-2 text-left font-medium w-40 sm:w-56" "Field")
|
||||
(th :class "px-3 py-2 text-left font-medium" "Value")))
|
||||
(tbody
|
||||
(map (lambda (col)
|
||||
(tr :class "border-t border-neutral-200 align-top"
|
||||
(td :class "px-3 py-2 whitespace-nowrap text-neutral-600 align-top" (get col "key"))
|
||||
(td :class "px-3 py-2 align-top"
|
||||
(~blog-data-value-cell :value (get col "value") :value-type (get col "type")))))
|
||||
(or columns (list)))))))
|
||||
|
||||
(defcomp ~blog-data-relationship-item (&key index summary children)
|
||||
(tr :class "border-t border-neutral-200 align-top"
|
||||
(td :class "px-2 py-1 whitespace-nowrap align-top" (str index))
|
||||
(td :class "px-2 py-1 align-top"
|
||||
(pre :class "whitespace-pre-wrap break-words break-all text-xs"
|
||||
(code summary))
|
||||
(when children
|
||||
(div :class "mt-2 pl-3 border-l border-neutral-200"
|
||||
(~blog-data-model-content
|
||||
:columns (get children "columns")
|
||||
:relationships (get children "relationships")))))))
|
||||
|
||||
(defcomp ~blog-data-relationship (&key name cardinality class-name loaded value)
|
||||
(div :class "rounded-xl border border-neutral-200"
|
||||
(div :class "px-3 py-2 bg-neutral-50/70 text-sm font-medium"
|
||||
"Relationship: " (span :class "font-semibold" name)
|
||||
(span :class "ml-2 text-xs text-neutral-500"
|
||||
cardinality " \u2192 " class-name
|
||||
(when (not loaded) " \u2022 " (em "not loaded"))))
|
||||
(div :class "p-3 text-sm"
|
||||
(if (not value)
|
||||
(span :class "text-neutral-400" "\u2014")
|
||||
(if (get value "is_list")
|
||||
(<>
|
||||
(div :class "text-neutral-500 mb-2"
|
||||
(str (get value "count") " item" (if (= (get value "count") 1) "" "s")))
|
||||
(when (get value "items")
|
||||
(div :class "w-full overflow-x-auto sm:overflow-visible"
|
||||
(table :class "w-full table-fixed text-sm border border-neutral-200 rounded-lg overflow-hidden"
|
||||
(thead :class "bg-neutral-50/70"
|
||||
(tr (th :class "px-2 py-1 text-left w-10" "#")
|
||||
(th :class "px-2 py-1 text-left" "Summary")))
|
||||
(tbody
|
||||
(map (lambda (item)
|
||||
(~blog-data-relationship-item
|
||||
:index (get item "index")
|
||||
:summary (get item "summary")
|
||||
:children (get item "children")))
|
||||
(get value "items")))))))
|
||||
;; Single value
|
||||
(<>
|
||||
(pre :class "whitespace-pre-wrap break-words break-all text-xs mb-2"
|
||||
(code (get value "summary")))
|
||||
(when (get value "children")
|
||||
(div :class "pl-3 border-l border-neutral-200"
|
||||
(~blog-data-model-content
|
||||
:columns (get (get value "children") "columns")
|
||||
:relationships (get (get value "children") "relationships"))))))))))
|
||||
|
||||
(defcomp ~blog-data-model-content (&key columns relationships)
|
||||
(div :class "space-y-4"
|
||||
(~blog-data-scalar-table :columns columns)
|
||||
(when (not (empty? (or relationships (list))))
|
||||
(div :class "space-y-3"
|
||||
(map (lambda (rel)
|
||||
(~blog-data-relationship
|
||||
:name (get rel "name")
|
||||
:cardinality (get rel "cardinality")
|
||||
:class-name (get rel "class_name")
|
||||
:loaded (get rel "loaded")
|
||||
:value (get rel "value")))
|
||||
relationships)))))
|
||||
|
||||
(defcomp ~blog-data-table-content (&key tablename model-data)
|
||||
(if (not model-data)
|
||||
(div :class "px-4 py-8 text-stone-400" "No post data available.")
|
||||
(div :class "px-4 py-8"
|
||||
(div :class "mb-6 text-sm text-neutral-500"
|
||||
"Model: " (code "Post") " \u2022 Table: " (code tablename))
|
||||
(~blog-data-model-content
|
||||
:columns (get model-data "columns")
|
||||
:relationships (get model-data "relationships")))))
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
(defcomp ~blog-like-button (&key like-url hx-headers heart)
|
||||
(div :class "absolute top-20 right-2 z-10 text-6xl md:text-4xl"
|
||||
(button :sx-post like-url :sx-swap "outerHTML"
|
||||
:sx-headers hx-headers :class "cursor-pointer" heart)))
|
||||
(~blog-like-toggle :like-url like-url :hx-headers hx-headers :heart heart)))
|
||||
|
||||
(defcomp ~blog-draft-status (&key publish-requested timestamp)
|
||||
(<> (div :class "flex justify-center gap-2 mt-1"
|
||||
@@ -56,7 +55,7 @@
|
||||
(when has-like
|
||||
(~blog-like-button
|
||||
:like-url like-url
|
||||
:sx-headers (str "{\"X-CSRFToken\": \"" csrf-token "\"}")
|
||||
:hx-headers {:X-CSRFToken csrf-token}
|
||||
:heart (if liked "❤️" "🤍")))
|
||||
(a :href href :sx-get href :sx-target "#main-panel"
|
||||
:sx-select (or hx-select "#main-panel") :sx-swap "outerHTML" :sx-push-url "true"
|
||||
|
||||
@@ -12,10 +12,13 @@
|
||||
(when publish-requested (span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-800" "Publish requested"))
|
||||
edit))
|
||||
|
||||
(defcomp ~blog-like-toggle (&key like-url hx-headers heart)
|
||||
(button :sx-post like-url :sx-swap "outerHTML"
|
||||
:sx-headers hx-headers :class "cursor-pointer" heart))
|
||||
|
||||
(defcomp ~blog-detail-like (&key like-url hx-headers heart)
|
||||
(div :class "absolute top-2 right-2 z-10 text-8xl md:text-6xl"
|
||||
(button :sx-post like-url :sx-swap "outerHTML"
|
||||
:sx-headers hx-headers :class "cursor-pointer" heart)))
|
||||
(~blog-like-toggle :like-url like-url :hx-headers hx-headers :heart heart)))
|
||||
|
||||
(defcomp ~blog-detail-excerpt (&key excerpt)
|
||||
(div :class "w-full text-center italic text-3xl p-2" excerpt))
|
||||
@@ -55,8 +58,8 @@
|
||||
:like (when has-user
|
||||
(~blog-detail-like
|
||||
:like-url like-url
|
||||
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
|
||||
:heart (if liked "\u2764\ufe0f" "\U0001f90d")))
|
||||
:hx-headers {:X-CSRFToken csrf}
|
||||
:heart (if liked "❤️" "🤍")))
|
||||
:excerpt (when (not (= custom-excerpt ""))
|
||||
(~blog-detail-excerpt :excerpt custom-excerpt))
|
||||
:at-bar (~blog-at-bar :tags tags :authors authors)))))
|
||||
|
||||
@@ -303,3 +303,48 @@
|
||||
|
||||
;; Drag over editor
|
||||
".sx-drag-over { outline: 2px dashed #3b82f6; outline-offset: -2px; border-radius: 4px; }"))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Editor panel composition — replaces render_editor_panel (new post/page)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~blog-editor-content (&key csrf title-placeholder create-label
|
||||
css-href js-src sx-editor-js-src init-js
|
||||
save-error)
|
||||
(~blog-editor-panel :parts
|
||||
(<>
|
||||
(when save-error (~blog-editor-error :error save-error))
|
||||
(~blog-editor-form :csrf csrf :title-placeholder title-placeholder
|
||||
:create-label create-label)
|
||||
(~blog-editor-styles :css-href css-href)
|
||||
(~sx-editor-styles)
|
||||
(~blog-editor-scripts :js-src js-src :sx-editor-js-src sx-editor-js-src
|
||||
:init-js init-js))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Edit content composition — replaces _h_post_edit_content (existing post)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~blog-edit-content (&key csrf updated-at title-val excerpt-val
|
||||
feature-image feature-image-caption
|
||||
sx-content-val lexical-json has-sx
|
||||
title-placeholder status already-emailed
|
||||
newsletter-options footer-extra
|
||||
css-href js-src sx-editor-js-src init-js
|
||||
save-error)
|
||||
(~blog-editor-panel :parts
|
||||
(<>
|
||||
(when save-error (~blog-editor-error :error save-error))
|
||||
(~blog-editor-edit-form
|
||||
:csrf csrf :updated-at updated-at
|
||||
:title-val title-val :excerpt-val excerpt-val
|
||||
:feature-image feature-image :feature-image-caption feature-image-caption
|
||||
:sx-content-val sx-content-val :lexical-json lexical-json
|
||||
:has-sx has-sx :title-placeholder title-placeholder
|
||||
:status status :already-emailed already-emailed
|
||||
:newsletter-options newsletter-options :footer-extra footer-extra)
|
||||
(~blog-editor-publish-js :already-emailed already-emailed)
|
||||
(~blog-editor-styles :css-href css-href)
|
||||
(~sx-editor-styles)
|
||||
(~blog-editor-scripts :js-src js-src :sx-editor-js-src sx-editor-js-src
|
||||
:init-js init-js))))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
;; Blog link-card fragment handler
|
||||
;; returns: sx
|
||||
;;
|
||||
;; Renders link-card(s) for blog posts by slug.
|
||||
;; Supports single mode (?slug=x) and batch mode (?keys=x,y,z).
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
;; Blog nav-tree fragment handler
|
||||
;; returns: sx
|
||||
;;
|
||||
;; Renders the full scrollable navigation menu bar with app icons.
|
||||
;; Uses nav-tree I/O primitive to fetch menu nodes from the blog DB.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
;; Blog layout defcomps — fully self-contained via IO primitives.
|
||||
;; Registered via register_sx_layout in __init__.py.
|
||||
|
||||
;; --- Blog header (invisible row for blog-header-child swap target) ---
|
||||
|
||||
@@ -7,28 +8,161 @@
|
||||
:link-label-content (div)
|
||||
:child-id "blog-header-child" :oob oob))
|
||||
|
||||
;; --- Blog layout (root + blog header) ---
|
||||
;; --- Auto-fetching settings header macro ---
|
||||
|
||||
(defmacro ~blog-settings-header-auto (oob)
|
||||
(quasiquote
|
||||
(~menu-row-sx :id "root-settings-row" :level 1
|
||||
:link-href (url-for "settings.defpage_settings_home")
|
||||
:link-label-content (~blog-admin-label)
|
||||
:nav (~blog-settings-nav)
|
||||
:child-id "root-settings-header-child"
|
||||
:oob (unquote oob))))
|
||||
|
||||
;; --- Auto-fetching sub-settings header macro ---
|
||||
|
||||
(defmacro ~blog-sub-settings-header-auto (row-id child-id endpoint icon label oob)
|
||||
(quasiquote
|
||||
(~menu-row-sx :id (unquote row-id) :level 2
|
||||
:link-href (url-for (unquote endpoint))
|
||||
:link-label-content (~blog-sub-settings-label
|
||||
:icon (str "fa fa-" (unquote icon))
|
||||
:label (unquote label))
|
||||
:child-id (unquote child-id)
|
||||
:oob (unquote oob))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Blog layout (root + blog header)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~blog-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~blog-header)))
|
||||
|
||||
;; --- Settings layout (root + settings header) ---
|
||||
(defcomp ~blog-layout-oob ()
|
||||
(<> (~blog-header :oob true)
|
||||
(~clear-oob-div :id "blog-header-child")
|
||||
(~root-header-auto true)))
|
||||
|
||||
(defcomp ~settings-layout-full (&key settings-header)
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Settings layout (root + settings header)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~blog-settings-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
settings-header))
|
||||
(~blog-settings-header-auto)))
|
||||
|
||||
;; --- Sub-settings layout (root + settings + sub row) ---
|
||||
(defcomp ~blog-settings-layout-oob ()
|
||||
(<> (~blog-settings-header-auto true)
|
||||
(~clear-oob-div :id "root-settings-header-child")
|
||||
(~root-header-auto true)))
|
||||
|
||||
(defcomp ~sub-settings-layout-full (&key settings-header sub-header)
|
||||
(defcomp ~blog-settings-layout-mobile ()
|
||||
(~blog-settings-nav))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Cache layout (root + settings + cache sub-header)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~blog-cache-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
settings-header sub-header))
|
||||
(~blog-settings-header-auto)
|
||||
(~blog-sub-settings-header-auto
|
||||
"cache-row" "cache-header-child"
|
||||
"settings.defpage_cache_page" "refresh" "Cache")))
|
||||
|
||||
(defcomp ~sub-settings-layout-oob (&key settings-header-oob sub-header-oob)
|
||||
(<> settings-header-oob sub-header-oob))
|
||||
(defcomp ~blog-cache-layout-oob ()
|
||||
(<> (~blog-sub-settings-header-auto
|
||||
"cache-row" "cache-header-child"
|
||||
"settings.defpage_cache_page" "refresh" "Cache" true)
|
||||
(~clear-oob-div :id "cache-header-child")
|
||||
(~blog-settings-header-auto true)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; --- Settings nav links — uses (select-colours) IO primitive ---
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Snippets layout (root + settings + snippets sub-header)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~blog-snippets-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~blog-settings-header-auto)
|
||||
(~blog-sub-settings-header-auto
|
||||
"snippets-row" "snippets-header-child"
|
||||
"snippets.defpage_snippets_page" "puzzle-piece" "Snippets")))
|
||||
|
||||
(defcomp ~blog-snippets-layout-oob ()
|
||||
(<> (~blog-sub-settings-header-auto
|
||||
"snippets-row" "snippets-header-child"
|
||||
"snippets.defpage_snippets_page" "puzzle-piece" "Snippets" true)
|
||||
(~clear-oob-div :id "snippets-header-child")
|
||||
(~blog-settings-header-auto true)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Menu Items layout (root + settings + menu-items sub-header)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~blog-menu-items-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~blog-settings-header-auto)
|
||||
(~blog-sub-settings-header-auto
|
||||
"menu_items-row" "menu_items-header-child"
|
||||
"menu_items.defpage_menu_items_page" "bars" "Menu Items")))
|
||||
|
||||
(defcomp ~blog-menu-items-layout-oob ()
|
||||
(<> (~blog-sub-settings-header-auto
|
||||
"menu_items-row" "menu_items-header-child"
|
||||
"menu_items.defpage_menu_items_page" "bars" "Menu Items" true)
|
||||
(~clear-oob-div :id "menu_items-header-child")
|
||||
(~blog-settings-header-auto true)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Tag Groups layout (root + settings + tag-groups sub-header)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~blog-tag-groups-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~blog-settings-header-auto)
|
||||
(~blog-sub-settings-header-auto
|
||||
"tag-groups-row" "tag-groups-header-child"
|
||||
"blog.tag_groups_admin.defpage_tag_groups_page" "tags" "Tag Groups")))
|
||||
|
||||
(defcomp ~blog-tag-groups-layout-oob ()
|
||||
(<> (~blog-sub-settings-header-auto
|
||||
"tag-groups-row" "tag-groups-header-child"
|
||||
"blog.tag_groups_admin.defpage_tag_groups_page" "tags" "Tag Groups" true)
|
||||
(~clear-oob-div :id "tag-groups-header-child")
|
||||
(~blog-settings-header-auto true)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Tag Group Edit layout (root + settings + tag-groups sub-header with id)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~blog-tag-group-edit-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~blog-settings-header-auto)
|
||||
(~menu-row-sx :id "tag-groups-row" :level 2
|
||||
:link-href (url-for "blog.tag_groups_admin.defpage_tag_group_edit"
|
||||
:id (request-view-args "id"))
|
||||
:link-label-content (~blog-sub-settings-label
|
||||
:icon "fa fa-tags" :label "Tag Groups")
|
||||
:child-id "tag-groups-header-child")))
|
||||
|
||||
(defcomp ~blog-tag-group-edit-layout-oob ()
|
||||
(<> (~menu-row-sx :id "tag-groups-row" :level 2
|
||||
:link-href (url-for "blog.tag_groups_admin.defpage_tag_group_edit"
|
||||
:id (request-view-args "id"))
|
||||
:link-label-content (~blog-sub-settings-label
|
||||
:icon "fa fa-tags" :label "Tag Groups")
|
||||
:child-id "tag-groups-header-child"
|
||||
:oob true)
|
||||
(~clear-oob-div :id "tag-groups-header-child")
|
||||
(~blog-settings-header-auto true)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; --- Settings nav links — uses IO primitives ---
|
||||
|
||||
(defcomp ~blog-settings-nav ()
|
||||
(let* ((sc (select-colours))
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
(defcomp ~blog-features-form (&key features-url calendar-checked market-checked hs-trigger)
|
||||
(form :sx-put features-url :sx-target "#features-panel" :sx-swap "outerHTML"
|
||||
:sx-headers "{\"Content-Type\": \"application/json\"}" :sx-encoding "json" :class "space-y-3"
|
||||
:sx-headers {:Content-Type "application/json"} :sx-encoding "json" :class "space-y-3"
|
||||
(label :class "flex items-center gap-3 cursor-pointer"
|
||||
(input :type "checkbox" :name "calendar" :value "true" :checked calendar-checked
|
||||
:class "h-5 w-5 rounded border-stone-300 text-blue-600 focus:ring-blue-500"
|
||||
@@ -126,3 +126,167 @@
|
||||
(div :id "associated-entries-list" :class "border rounded-lg p-4 bg-white"
|
||||
(h3 :class "text-lg font-semibold mb-4" "Associated Entries")
|
||||
content))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Associated entries composition — replaces _render_associated_entries
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~blog-associated-entries-from-data (&key entries csrf)
|
||||
(~blog-associated-entries-panel
|
||||
:content (if (empty? (or entries (list)))
|
||||
(~blog-associated-entries-empty)
|
||||
(~blog-associated-entries-content
|
||||
:items (map (lambda (e)
|
||||
(~blog-associated-entry
|
||||
:confirm-text (get e "confirm_text")
|
||||
:toggle-url (get e "toggle_url")
|
||||
:hx-headers {:X-CSRFToken csrf}
|
||||
:img (~blog-entry-image :src (get e "cal_image") :title (get e "cal_title"))
|
||||
:name (get e "name")
|
||||
:date-str (get e "date_str")))
|
||||
(or entries (list)))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Entries browser composition — replaces _h_post_entries_content
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~blog-calendar-browser-item (&key name title image view-url)
|
||||
(details :class "border rounded-lg bg-white" :data-toggle-group "calendar-browser"
|
||||
(summary :class "p-4 cursor-pointer hover:bg-stone-50 flex items-center gap-3"
|
||||
(if image
|
||||
(img :src image :alt title :class "w-12 h-12 rounded object-cover flex-shrink-0")
|
||||
(div :class "w-12 h-12 rounded bg-stone-200 flex-shrink-0"))
|
||||
(div :class "flex-1"
|
||||
(div :class "font-semibold flex items-center gap-2"
|
||||
(i :class "fa fa-calendar text-stone-500") " " name)
|
||||
(div :class "text-sm text-stone-600" title)))
|
||||
(div :class "p-4 border-t" :sx-get view-url :sx-trigger "intersect once" :sx-swap "innerHTML"
|
||||
(div :class "text-sm text-stone-400" "Loading calendar..."))))
|
||||
|
||||
(defcomp ~blog-entries-browser-content (&key entries-panel calendars)
|
||||
(div :id "post-entries-content" :class "space-y-6 p-4"
|
||||
entries-panel
|
||||
(div :class "space-y-3"
|
||||
(h3 :class "text-lg font-semibold" "Browse Calendars")
|
||||
(if (empty? (or calendars (list)))
|
||||
(div :class "text-sm text-stone-400" "No calendars found.")
|
||||
(map (lambda (cal)
|
||||
(~blog-calendar-browser-item
|
||||
:name (get cal "name")
|
||||
:title (get cal "title")
|
||||
:image (get cal "image")
|
||||
:view-url (get cal "view_url")))
|
||||
(or calendars (list)))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Post settings form composition — replaces _h_post_settings_content
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~blog-settings-field-label (&key text field-for)
|
||||
(label :for field-for
|
||||
:class "block text-[13px] font-medium text-stone-500 mb-[4px]" text))
|
||||
|
||||
(defcomp ~blog-settings-section (&key title content is-open)
|
||||
(details :class "border border-stone-200 rounded-[8px] overflow-hidden" :open is-open
|
||||
(summary :class "px-[16px] py-[10px] bg-stone-50 text-[14px] font-medium text-stone-600 cursor-pointer select-none hover:bg-stone-100 transition-colors"
|
||||
title)
|
||||
(div :class "px-[16px] py-[12px] space-y-[12px]" content)))
|
||||
|
||||
(defcomp ~blog-settings-form-content (&key csrf updated-at is-page save-success
|
||||
slug published-at featured visibility email-only
|
||||
tags feature-image-alt
|
||||
meta-title meta-description canonical-url
|
||||
og-title og-description og-image
|
||||
twitter-title twitter-description twitter-image
|
||||
custom-template)
|
||||
(let* ((input-cls "w-full text-[14px] rounded-[6px] border border-stone-200 px-[10px] py-[7px] bg-white text-stone-700 placeholder:text-stone-300 focus:outline-none focus:border-stone-400 focus:ring-1 focus:ring-stone-300")
|
||||
(textarea-cls (str input-cls " resize-y"))
|
||||
(slug-placeholder (if is-page "page-slug" "post-slug"))
|
||||
(tmpl-placeholder (if is-page "custom-page.hbs" "custom-post.hbs"))
|
||||
(featured-label (if is-page "Featured page" "Featured post")))
|
||||
(form :method "post" :class "max-w-[640px] mx-auto pb-[48px] px-[16px]"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(input :type "hidden" :name "updated_at" :value (or updated-at ""))
|
||||
(div :class "space-y-[12px] mt-[16px]"
|
||||
;; General
|
||||
(~blog-settings-section :title "General" :is-open true :content
|
||||
(<>
|
||||
(div (~blog-settings-field-label :text "Slug" :field-for "settings-slug")
|
||||
(input :type "text" :name "slug" :id "settings-slug" :value (or slug "")
|
||||
:placeholder slug-placeholder :class input-cls))
|
||||
(div (~blog-settings-field-label :text "Published at" :field-for "settings-published_at")
|
||||
(input :type "datetime-local" :name "published_at" :id "settings-published_at"
|
||||
:value (or published-at "") :class input-cls))
|
||||
(div (label :class "inline-flex items-center gap-[8px] cursor-pointer"
|
||||
(input :type "checkbox" :name "featured" :id "settings-featured" :checked featured
|
||||
:class "rounded border-stone-300 text-stone-600 focus:ring-stone-300")
|
||||
(span :class "text-[14px] text-stone-600" featured-label)))
|
||||
(div (~blog-settings-field-label :text "Visibility" :field-for "settings-visibility")
|
||||
(select :name "visibility" :id "settings-visibility" :class input-cls
|
||||
(option :value "public" :selected (= visibility "public") "Public")
|
||||
(option :value "members" :selected (= visibility "members") "Members")
|
||||
(option :value "paid" :selected (= visibility "paid") "Paid")))
|
||||
(div (label :class "inline-flex items-center gap-[8px] cursor-pointer"
|
||||
(input :type "checkbox" :name "email_only" :id "settings-email_only" :checked email-only
|
||||
:class "rounded border-stone-300 text-stone-600 focus:ring-stone-300")
|
||||
(span :class "text-[14px] text-stone-600" "Email only")))))
|
||||
;; Tags
|
||||
(~blog-settings-section :title "Tags" :content
|
||||
(div (~blog-settings-field-label :text "Tags (comma-separated)" :field-for "settings-tags")
|
||||
(input :type "text" :name "tags" :id "settings-tags" :value (or tags "")
|
||||
:placeholder "news, updates, featured" :class input-cls)
|
||||
(p :class "text-[12px] text-stone-400 mt-[4px]" "Unknown tags will be created automatically.")))
|
||||
;; Feature Image
|
||||
(~blog-settings-section :title "Feature Image" :content
|
||||
(div (~blog-settings-field-label :text "Alt text" :field-for "settings-feature_image_alt")
|
||||
(input :type "text" :name "feature_image_alt" :id "settings-feature_image_alt"
|
||||
:value (or feature-image-alt "") :placeholder "Describe the feature image" :class input-cls)))
|
||||
;; SEO / Meta
|
||||
(~blog-settings-section :title "SEO / Meta" :content
|
||||
(<>
|
||||
(div (~blog-settings-field-label :text "Meta title" :field-for "settings-meta_title")
|
||||
(input :type "text" :name "meta_title" :id "settings-meta_title" :value (or meta-title "")
|
||||
:placeholder "SEO title" :maxlength "300" :class input-cls)
|
||||
(p :class "text-[12px] text-stone-400 mt-[2px]" "Recommended: 70 characters. Max: 300."))
|
||||
(div (~blog-settings-field-label :text "Meta description" :field-for "settings-meta_description")
|
||||
(textarea :name "meta_description" :id "settings-meta_description" :rows "2"
|
||||
:placeholder "SEO description" :maxlength "500" :class textarea-cls
|
||||
(or meta-description ""))
|
||||
(p :class "text-[12px] text-stone-400 mt-[2px]" "Recommended: 156 characters."))
|
||||
(div (~blog-settings-field-label :text "Canonical URL" :field-for "settings-canonical_url")
|
||||
(input :type "url" :name "canonical_url" :id "settings-canonical_url"
|
||||
:value (or canonical-url "") :placeholder "https://example.com/original-post" :class input-cls))))
|
||||
;; Facebook / OpenGraph
|
||||
(~blog-settings-section :title "Facebook / OpenGraph" :content
|
||||
(<>
|
||||
(div (~blog-settings-field-label :text "OG title" :field-for "settings-og_title")
|
||||
(input :type "text" :name "og_title" :id "settings-og_title" :value (or og-title "") :class input-cls))
|
||||
(div (~blog-settings-field-label :text "OG description" :field-for "settings-og_description")
|
||||
(textarea :name "og_description" :id "settings-og_description" :rows "2" :class textarea-cls
|
||||
(or og-description "")))
|
||||
(div (~blog-settings-field-label :text "OG image URL" :field-for "settings-og_image")
|
||||
(input :type "url" :name "og_image" :id "settings-og_image" :value (or og-image "")
|
||||
:placeholder "https://..." :class input-cls))))
|
||||
;; X / Twitter
|
||||
(~blog-settings-section :title "X / Twitter" :content
|
||||
(<>
|
||||
(div (~blog-settings-field-label :text "Twitter title" :field-for "settings-twitter_title")
|
||||
(input :type "text" :name "twitter_title" :id "settings-twitter_title"
|
||||
:value (or twitter-title "") :class input-cls))
|
||||
(div (~blog-settings-field-label :text "Twitter description" :field-for "settings-twitter_description")
|
||||
(textarea :name "twitter_description" :id "settings-twitter_description" :rows "2" :class textarea-cls
|
||||
(or twitter-description "")))
|
||||
(div (~blog-settings-field-label :text "Twitter image URL" :field-for "settings-twitter_image")
|
||||
(input :type "url" :name "twitter_image" :id "settings-twitter_image"
|
||||
:value (or twitter-image "") :placeholder "https://..." :class input-cls))))
|
||||
;; Advanced
|
||||
(~blog-settings-section :title "Advanced" :content
|
||||
(div (~blog-settings-field-label :text "Custom template" :field-for "settings-custom_template")
|
||||
(input :type "text" :name "custom_template" :id "settings-custom_template"
|
||||
:value (or custom-template "") :placeholder tmpl-placeholder :class input-cls))))
|
||||
(div :class "flex items-center gap-[16px] mt-[24px] pt-[16px] border-t border-stone-200"
|
||||
(button :type "submit"
|
||||
:class "px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px] hover:bg-stone-800 transition-colors cursor-pointer"
|
||||
"Save settings")
|
||||
(when save-success
|
||||
(span :class "text-[14px] text-green-600" "Saved."))))))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
; Blog app defpage declarations
|
||||
; Pages kept as Python: home, index, post-detail (cache_page / complex branching)
|
||||
; All helpers return data dicts — markup composition in SX.
|
||||
|
||||
; --- New post/page editors ---
|
||||
|
||||
@@ -7,13 +8,23 @@
|
||||
:path "/new/"
|
||||
:auth :admin
|
||||
:layout :blog
|
||||
:content (editor-content))
|
||||
:data (editor-data)
|
||||
:content (~blog-editor-content
|
||||
:csrf csrf :title-placeholder title-placeholder
|
||||
:create-label create-label :css-href css-href
|
||||
:js-src js-src :sx-editor-js-src sx-editor-js-src
|
||||
:init-js init-js))
|
||||
|
||||
(defpage new-page
|
||||
:path "/new-page/"
|
||||
:auth :admin
|
||||
:layout :blog
|
||||
:content (editor-page-content))
|
||||
:data (editor-page-data)
|
||||
:content (~blog-editor-content
|
||||
:csrf csrf :title-placeholder title-placeholder
|
||||
:create-label create-label :css-href css-href
|
||||
:js-src js-src :sx-editor-js-src sx-editor-js-src
|
||||
:init-js init-js))
|
||||
|
||||
; --- Post admin pages (absolute paths under /<slug>/admin/) ---
|
||||
|
||||
@@ -21,37 +32,71 @@
|
||||
:path "/<slug>/admin/"
|
||||
:auth :admin
|
||||
:layout (:post-admin :selected "admin")
|
||||
:content (post-admin-content slug))
|
||||
:data (post-admin-data slug)
|
||||
:content (~blog-admin-placeholder))
|
||||
|
||||
(defpage post-data
|
||||
:path "/<slug>/admin/data/"
|
||||
:auth :admin
|
||||
:layout (:post-admin :selected "data")
|
||||
:content (post-data-content slug))
|
||||
:data (post-data-data slug)
|
||||
:content (~blog-data-table-content :tablename tablename :model-data model-data))
|
||||
|
||||
(defpage post-preview
|
||||
:path "/<slug>/admin/preview/"
|
||||
:auth :admin
|
||||
:layout (:post-admin :selected "preview")
|
||||
:content (post-preview-content slug))
|
||||
:data (post-preview-data slug)
|
||||
:content (~blog-preview-content
|
||||
:sx-pretty sx-pretty :json-pretty json-pretty
|
||||
:sx-rendered sx-rendered :lex-rendered lex-rendered))
|
||||
|
||||
(defpage post-entries
|
||||
:path "/<slug>/admin/entries/"
|
||||
:auth :admin
|
||||
:layout (:post-admin :selected "entries")
|
||||
:content (post-entries-content slug))
|
||||
:data (post-entries-data slug)
|
||||
:content (~blog-entries-browser-content
|
||||
:entries-panel (~blog-associated-entries-from-data :entries entries :csrf csrf)
|
||||
:calendars calendars))
|
||||
|
||||
(defpage post-settings
|
||||
:path "/<slug>/admin/settings/"
|
||||
:auth :post_author
|
||||
:layout (:post-admin :selected "settings")
|
||||
:content (post-settings-content slug))
|
||||
:data (post-settings-data slug)
|
||||
:content (~blog-settings-form-content
|
||||
:csrf csrf :updated-at updated-at :is-page is-page
|
||||
:save-success save-success :slug settings-slug
|
||||
:published-at published-at :featured featured
|
||||
:visibility visibility :email-only email-only
|
||||
:tags tags :feature-image-alt feature-image-alt
|
||||
:meta-title meta-title :meta-description meta-description
|
||||
:canonical-url canonical-url :og-title og-title
|
||||
:og-description og-description :og-image og-image
|
||||
:twitter-title twitter-title :twitter-description twitter-description
|
||||
:twitter-image twitter-image :custom-template custom-template))
|
||||
|
||||
(defpage post-edit
|
||||
:path "/<slug>/admin/edit/"
|
||||
:auth :post_author
|
||||
:layout (:post-admin :selected "edit")
|
||||
:content (post-edit-content slug))
|
||||
:data (post-edit-data slug)
|
||||
:content (~blog-edit-content
|
||||
:csrf csrf :updated-at updated-at
|
||||
:title-val title-val :excerpt-val excerpt-val
|
||||
:feature-image feature-image :feature-image-caption feature-image-caption
|
||||
:sx-content-val sx-content-val :lexical-json lexical-json
|
||||
:has-sx has-sx :title-placeholder title-placeholder
|
||||
:status status :already-emailed already-emailed
|
||||
:newsletter-options (<>
|
||||
(option :value "" "Select newsletter\u2026")
|
||||
(map (fn (nl) (option :value (get nl "slug") (get nl "name"))) newsletters))
|
||||
:footer-extra (when badges
|
||||
(<> (map (fn (b) (span :class (get b "cls") (get b "text"))) badges)))
|
||||
:css-href css-href :js-src js-src
|
||||
:sx-editor-js-src sx-editor-js-src
|
||||
:init-js init-js :save-error save-error))
|
||||
|
||||
; --- Settings pages (absolute paths) ---
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,203 +1,19 @@
|
||||
"""Blog layout functions for defpage rendering."""
|
||||
"""Blog layout registration — all layouts delegate to .sx defcomps."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Header helpers (moved from sx_components — thin sx_call wrappers)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _settings_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
from shared.sx.helpers import sx_call
|
||||
from shared.sx.parser import SxExpr
|
||||
from quart import url_for as qurl
|
||||
|
||||
settings_href = qurl("settings.defpage_settings_home")
|
||||
label_sx = sx_call("blog-admin-label")
|
||||
nav_sx = _settings_nav_sx(ctx)
|
||||
|
||||
return sx_call("menu-row-sx",
|
||||
id="root-settings-row", level=1,
|
||||
link_href=settings_href,
|
||||
link_label_content=SxExpr(label_sx),
|
||||
nav=SxExpr(nav_sx) if nav_sx else None,
|
||||
child_id="root-settings-header-child", oob=oob)
|
||||
|
||||
|
||||
def _settings_nav_sx(ctx: dict) -> str:
|
||||
from shared.sx.helpers import sx_call
|
||||
return sx_call("blog-settings-nav")
|
||||
|
||||
|
||||
def _sub_settings_header_sx(row_id: str, child_id: str, href: str,
|
||||
icon: str, label: str, ctx: dict,
|
||||
*, oob: bool = False, nav_sx: str = "") -> str:
|
||||
from shared.sx.helpers import sx_call
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
label_sx = sx_call("blog-sub-settings-label",
|
||||
icon=f"fa fa-{icon}", label=label)
|
||||
return sx_call("menu-row-sx",
|
||||
id=row_id, level=2,
|
||||
link_href=href,
|
||||
link_label_content=SxExpr(label_sx),
|
||||
nav=SxExpr(nav_sx) if nav_sx else None,
|
||||
child_id=child_id, oob=oob)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layouts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _register_blog_layouts() -> None:
|
||||
from shared.sx.layouts import register_custom_layout
|
||||
register_custom_layout("blog", _blog_full, _blog_oob)
|
||||
register_custom_layout("blog-settings", _settings_full, _settings_oob,
|
||||
mobile_fn=_settings_mobile)
|
||||
register_custom_layout("blog-cache", _cache_full, _cache_oob)
|
||||
register_custom_layout("blog-snippets", _snippets_full, _snippets_oob)
|
||||
register_custom_layout("blog-menu-items", _menu_items_full, _menu_items_oob)
|
||||
register_custom_layout("blog-tag-groups", _tag_groups_full, _tag_groups_oob)
|
||||
register_custom_layout("blog-tag-group-edit",
|
||||
_tag_group_edit_full, _tag_group_edit_oob)
|
||||
|
||||
|
||||
# --- Blog layout (root + blog header) ---
|
||||
|
||||
async def _blog_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env
|
||||
return await render_to_sx_with_env("blog-layout-full", {})
|
||||
|
||||
|
||||
async def _blog_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env, oob_header_sx
|
||||
rows = await render_to_sx_with_env("blog-layout-full", {})
|
||||
return await oob_header_sx("root-header-child", "blog-header-child", rows)
|
||||
|
||||
|
||||
# --- Settings layout (root + settings header) ---
|
||||
|
||||
async def _settings_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env
|
||||
from shared.sx.parser import SxExpr
|
||||
return await render_to_sx_with_env("settings-layout-full", {},
|
||||
settings_header=SxExpr(_settings_header_sx(ctx)))
|
||||
|
||||
|
||||
async def _settings_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env, oob_header_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
rows = await render_to_sx_with_env("settings-layout-full", {},
|
||||
settings_header=SxExpr(_settings_header_sx(ctx)))
|
||||
return await oob_header_sx("root-header-child", "root-settings-header-child", rows)
|
||||
|
||||
|
||||
def _settings_mobile(ctx: dict, **kw: Any) -> str:
|
||||
return _settings_nav_sx(ctx)
|
||||
|
||||
|
||||
# --- Sub-settings helpers ---
|
||||
|
||||
async def _sub_settings_full(ctx: dict, row_id: str, child_id: str,
|
||||
endpoint: str, icon: str, label: str) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env
|
||||
from shared.sx.parser import SxExpr
|
||||
from quart import url_for as qurl
|
||||
return await render_to_sx_with_env("sub-settings-layout-full", {},
|
||||
settings_header=SxExpr(_settings_header_sx(ctx)),
|
||||
sub_header=SxExpr(_sub_settings_header_sx(
|
||||
row_id, child_id, qurl(endpoint), icon, label, ctx)))
|
||||
|
||||
|
||||
async def _sub_settings_oob(ctx: dict, row_id: str, child_id: str,
|
||||
endpoint: str, icon: str, label: str) -> str:
|
||||
from shared.sx.helpers import oob_header_sx, sx_call
|
||||
from shared.sx.parser import SxExpr
|
||||
from quart import url_for as qurl
|
||||
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
|
||||
sub_hdr = _sub_settings_header_sx(
|
||||
row_id, child_id, qurl(endpoint), icon, label, ctx)
|
||||
sub_oob = await oob_header_sx("root-settings-header-child", child_id, sub_hdr)
|
||||
return sx_call("sub-settings-layout-oob",
|
||||
settings_header_oob=SxExpr(settings_hdr_oob),
|
||||
sub_header_oob=SxExpr(sub_oob))
|
||||
|
||||
|
||||
# --- Cache ---
|
||||
|
||||
async def _cache_full(ctx: dict, **kw: Any) -> str:
|
||||
return await _sub_settings_full(ctx, "cache-row", "cache-header-child",
|
||||
"defpage_cache_page", "refresh", "Cache")
|
||||
|
||||
|
||||
async def _cache_oob(ctx: dict, **kw: Any) -> str:
|
||||
return await _sub_settings_oob(ctx, "cache-row", "cache-header-child",
|
||||
"defpage_cache_page", "refresh", "Cache")
|
||||
|
||||
|
||||
# --- Snippets ---
|
||||
|
||||
async def _snippets_full(ctx: dict, **kw: Any) -> str:
|
||||
return await _sub_settings_full(ctx, "snippets-row", "snippets-header-child",
|
||||
"defpage_snippets_page", "puzzle-piece", "Snippets")
|
||||
|
||||
|
||||
async def _snippets_oob(ctx: dict, **kw: Any) -> str:
|
||||
return await _sub_settings_oob(ctx, "snippets-row", "snippets-header-child",
|
||||
"defpage_snippets_page", "puzzle-piece", "Snippets")
|
||||
|
||||
|
||||
# --- Menu Items ---
|
||||
|
||||
async def _menu_items_full(ctx: dict, **kw: Any) -> str:
|
||||
return await _sub_settings_full(ctx, "menu_items-row", "menu_items-header-child",
|
||||
"defpage_menu_items_page", "bars", "Menu Items")
|
||||
|
||||
|
||||
async def _menu_items_oob(ctx: dict, **kw: Any) -> str:
|
||||
return await _sub_settings_oob(ctx, "menu_items-row", "menu_items-header-child",
|
||||
"defpage_menu_items_page", "bars", "Menu Items")
|
||||
|
||||
|
||||
# --- Tag Groups ---
|
||||
|
||||
async def _tag_groups_full(ctx: dict, **kw: Any) -> str:
|
||||
return await _sub_settings_full(ctx, "tag-groups-row", "tag-groups-header-child",
|
||||
"defpage_tag_groups_page", "tags", "Tag Groups")
|
||||
|
||||
|
||||
async def _tag_groups_oob(ctx: dict, **kw: Any) -> str:
|
||||
return await _sub_settings_oob(ctx, "tag-groups-row", "tag-groups-header-child",
|
||||
"defpage_tag_groups_page", "tags", "Tag Groups")
|
||||
|
||||
|
||||
# --- Tag Group Edit ---
|
||||
|
||||
async def _tag_group_edit_full(ctx: dict, **kw: Any) -> str:
|
||||
from quart import request, url_for as qurl
|
||||
from shared.sx.helpers import render_to_sx_with_env
|
||||
from shared.sx.parser import SxExpr
|
||||
g_id = (request.view_args or {}).get("id")
|
||||
return await render_to_sx_with_env("sub-settings-layout-full", {},
|
||||
settings_header=SxExpr(_settings_header_sx(ctx)),
|
||||
sub_header=SxExpr(_sub_settings_header_sx(
|
||||
"tag-groups-row", "tag-groups-header-child",
|
||||
qurl("defpage_tag_group_edit", id=g_id),
|
||||
"tags", "Tag Groups", ctx)))
|
||||
|
||||
|
||||
async def _tag_group_edit_oob(ctx: dict, **kw: Any) -> str:
|
||||
from quart import request, url_for as qurl
|
||||
from shared.sx.helpers import oob_header_sx, sx_call
|
||||
from shared.sx.parser import SxExpr
|
||||
g_id = (request.view_args or {}).get("id")
|
||||
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
|
||||
sub_hdr = _sub_settings_header_sx(
|
||||
"tag-groups-row", "tag-groups-header-child",
|
||||
qurl("defpage_tag_group_edit", id=g_id),
|
||||
"tags", "Tag Groups", ctx)
|
||||
sub_oob = await oob_header_sx("root-settings-header-child", "tag-groups-header-child", sub_hdr)
|
||||
return sx_call("sub-settings-layout-oob",
|
||||
settings_header_oob=SxExpr(settings_hdr_oob),
|
||||
sub_header_oob=SxExpr(sub_oob))
|
||||
from shared.sx.layouts import register_sx_layout
|
||||
register_sx_layout("blog", "blog-layout-full", "blog-layout-oob")
|
||||
register_sx_layout("blog-settings", "blog-settings-layout-full",
|
||||
"blog-settings-layout-oob", "blog-settings-layout-mobile")
|
||||
register_sx_layout("blog-cache", "blog-cache-layout-full",
|
||||
"blog-cache-layout-oob")
|
||||
register_sx_layout("blog-snippets", "blog-snippets-layout-full",
|
||||
"blog-snippets-layout-oob")
|
||||
register_sx_layout("blog-menu-items", "blog-menu-items-layout-full",
|
||||
"blog-menu-items-layout-oob")
|
||||
register_sx_layout("blog-tag-groups", "blog-tag-groups-layout-full",
|
||||
"blog-tag-groups-layout-oob")
|
||||
register_sx_layout("blog-tag-group-edit", "blog-tag-group-edit-layout-full",
|
||||
"blog-tag-group-edit-layout-oob")
|
||||
|
||||
@@ -3,175 +3,23 @@ from __future__ import annotations
|
||||
|
||||
|
||||
def render_editor_panel(save_error: str | None = None, is_page: bool = False) -> str:
|
||||
"""Build the WYSIWYG editor panel HTML for new post/page creation."""
|
||||
import os
|
||||
from quart import url_for as qurl, current_app
|
||||
"""Build the WYSIWYG editor panel for new post/page creation."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from shared.sx.helpers import sx_call
|
||||
from .helpers import _editor_urls, _editor_init_js
|
||||
|
||||
urls = _editor_urls()
|
||||
csrf = generate_csrf_token()
|
||||
asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "")
|
||||
editor_css = asset_url_fn("scripts/editor.css")
|
||||
editor_js = asset_url_fn("scripts/editor.js")
|
||||
sx_editor_js = asset_url_fn("scripts/sx-editor.js")
|
||||
|
||||
upload_image_url = qurl("blog.editor_api.upload_image")
|
||||
upload_media_url = qurl("blog.editor_api.upload_media")
|
||||
upload_file_url = qurl("blog.editor_api.upload_file")
|
||||
oembed_url = qurl("blog.editor_api.oembed_proxy")
|
||||
snippets_url = qurl("blog.editor_api.list_snippets")
|
||||
unsplash_key = os.environ.get("UNSPLASH_ACCESS_KEY", "")
|
||||
|
||||
title_placeholder = "Page title..." if is_page else "Post title..."
|
||||
create_label = "Create Page" if is_page else "Create Post"
|
||||
init_js = _editor_init_js(urls, form_id="post-new-form", has_initial_json=False)
|
||||
|
||||
parts: list[str] = []
|
||||
|
||||
if save_error:
|
||||
parts.append(sx_call("blog-editor-error", error=str(save_error)))
|
||||
|
||||
parts.append(sx_call("blog-editor-form",
|
||||
csrf=csrf, title_placeholder=title_placeholder,
|
||||
return sx_call("blog-editor-content",
|
||||
csrf=csrf,
|
||||
title_placeholder=title_placeholder,
|
||||
create_label=create_label,
|
||||
))
|
||||
|
||||
parts.append(sx_call("blog-editor-styles", css_href=editor_css))
|
||||
parts.append(sx_call("sx-editor-styles"))
|
||||
|
||||
init_js = (
|
||||
"console.log('[EDITOR-DEBUG] init script running');\n"
|
||||
"(function() {\n"
|
||||
" console.log('[EDITOR-DEBUG] IIFE entered, mountEditor=', typeof window.mountEditor);\n"
|
||||
" function init() {\n"
|
||||
" var csrfToken = document.querySelector('input[name=\"csrf_token\"]').value;\n"
|
||||
f" var uploadUrl = '{upload_image_url}';\n"
|
||||
" var uploadUrls = {\n"
|
||||
" image: uploadUrl,\n"
|
||||
f" media: '{upload_media_url}',\n"
|
||||
f" file: '{upload_file_url}',\n"
|
||||
" };\n"
|
||||
"\n"
|
||||
" var fileInput = document.getElementById('feature-image-file');\n"
|
||||
" var addBtn = document.getElementById('feature-image-add-btn');\n"
|
||||
" var deleteBtn = document.getElementById('feature-image-delete-btn');\n"
|
||||
" var preview = document.getElementById('feature-image-preview');\n"
|
||||
" var emptyState = document.getElementById('feature-image-empty');\n"
|
||||
" var filledState = document.getElementById('feature-image-filled');\n"
|
||||
" var hiddenUrl = document.getElementById('feature-image-input');\n"
|
||||
" var hiddenCaption = document.getElementById('feature-image-caption-input');\n"
|
||||
" var captionInput = document.getElementById('feature-image-caption');\n"
|
||||
" var uploading = document.getElementById('feature-image-uploading');\n"
|
||||
"\n"
|
||||
" function showFilled(url) {\n"
|
||||
" preview.src = url;\n"
|
||||
" hiddenUrl.value = url;\n"
|
||||
" emptyState.classList.add('hidden');\n"
|
||||
" filledState.classList.remove('hidden');\n"
|
||||
" uploading.classList.add('hidden');\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" function showEmpty() {\n"
|
||||
" preview.src = '';\n"
|
||||
" hiddenUrl.value = '';\n"
|
||||
" hiddenCaption.value = '';\n"
|
||||
" captionInput.value = '';\n"
|
||||
" emptyState.classList.remove('hidden');\n"
|
||||
" filledState.classList.add('hidden');\n"
|
||||
" uploading.classList.add('hidden');\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" function uploadFile(file) {\n"
|
||||
" emptyState.classList.add('hidden');\n"
|
||||
" uploading.classList.remove('hidden');\n"
|
||||
" var fd = new FormData();\n"
|
||||
" fd.append('file', file);\n"
|
||||
" fetch(uploadUrl, {\n"
|
||||
" method: 'POST',\n"
|
||||
" body: fd,\n"
|
||||
" headers: { 'X-CSRFToken': csrfToken },\n"
|
||||
" })\n"
|
||||
" .then(function(r) {\n"
|
||||
" if (!r.ok) throw new Error('Upload failed (' + r.status + ')');\n"
|
||||
" return r.json();\n"
|
||||
" })\n"
|
||||
" .then(function(data) {\n"
|
||||
" var url = data.images && data.images[0] && data.images[0].url;\n"
|
||||
" if (url) showFilled(url);\n"
|
||||
" else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }\n"
|
||||
" })\n"
|
||||
" .catch(function(e) {\n"
|
||||
" showEmpty();\n"
|
||||
" alert(e.message);\n"
|
||||
" });\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" addBtn.addEventListener('click', function() { fileInput.click(); });\n"
|
||||
" preview.addEventListener('click', function() { fileInput.click(); });\n"
|
||||
" deleteBtn.addEventListener('click', function(e) {\n"
|
||||
" e.stopPropagation();\n"
|
||||
" showEmpty();\n"
|
||||
" });\n"
|
||||
" fileInput.addEventListener('change', function() {\n"
|
||||
" if (fileInput.files && fileInput.files[0]) {\n"
|
||||
" uploadFile(fileInput.files[0]);\n"
|
||||
" fileInput.value = '';\n"
|
||||
" }\n"
|
||||
" });\n"
|
||||
" captionInput.addEventListener('input', function() {\n"
|
||||
" hiddenCaption.value = captionInput.value;\n"
|
||||
" });\n"
|
||||
"\n"
|
||||
" var excerpt = document.querySelector('textarea[name=\"custom_excerpt\"]');\n"
|
||||
" function autoResize() {\n"
|
||||
" excerpt.style.height = 'auto';\n"
|
||||
" excerpt.style.height = excerpt.scrollHeight + 'px';\n"
|
||||
" }\n"
|
||||
" excerpt.addEventListener('input', autoResize);\n"
|
||||
" autoResize();\n"
|
||||
"\n"
|
||||
" window.mountEditor('lexical-editor', {\n"
|
||||
" initialJson: null,\n"
|
||||
" csrfToken: csrfToken,\n"
|
||||
" uploadUrls: uploadUrls,\n"
|
||||
f" oembedUrl: '{oembed_url}',\n"
|
||||
f" unsplashApiKey: '{unsplash_key}',\n"
|
||||
f" snippetsUrl: '{snippets_url}',\n"
|
||||
" });\n"
|
||||
"\n"
|
||||
" if (typeof SxEditor !== 'undefined') {\n"
|
||||
" SxEditor.mount('sx-editor', {\n"
|
||||
" initialSx: (document.getElementById('sx-content-input') || {}).value || null,\n"
|
||||
" csrfToken: csrfToken,\n"
|
||||
" uploadUrls: uploadUrls,\n"
|
||||
f" oembedUrl: '{oembed_url}',\n"
|
||||
" onChange: function(sx) {\n"
|
||||
" document.getElementById('sx-content-input').value = sx;\n"
|
||||
" }\n"
|
||||
" });\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" document.addEventListener('keydown', function(e) {\n"
|
||||
" if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n"
|
||||
" e.preventDefault();\n"
|
||||
" document.getElementById('post-new-form').requestSubmit();\n"
|
||||
" }\n"
|
||||
" });\n"
|
||||
" }\n"
|
||||
"\n"
|
||||
" if (typeof window.mountEditor === 'function') {\n"
|
||||
" init();\n"
|
||||
" } else {\n"
|
||||
" var _t = setInterval(function() {\n"
|
||||
" if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }\n"
|
||||
" }, 50);\n"
|
||||
" }\n"
|
||||
"})();\n"
|
||||
)
|
||||
parts.append(sx_call("blog-editor-scripts",
|
||||
js_src=editor_js,
|
||||
sx_editor_js_src=sx_editor_js,
|
||||
init_js=init_js))
|
||||
|
||||
from shared.sx.parser import SxExpr
|
||||
return sx_call("blog-editor-panel",
|
||||
parts=SxExpr("(<> " + " ".join(parts) + ")")) if parts else ""
|
||||
css_href=urls["css_href"],
|
||||
js_src=urls["js_src"],
|
||||
sx_editor_js_src=urls["sx_editor_js_src"],
|
||||
init_js=init_js,
|
||||
save_error=save_error or None)
|
||||
|
||||
@@ -172,6 +172,45 @@ class CartPageService:
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
async def admin_data(self, session, **kw):
|
||||
"""Populate post context for cart-admin layout headers."""
|
||||
from quart import g
|
||||
from shared.infrastructure.fragments import fetch_fragments
|
||||
|
||||
post = g.page_post
|
||||
slug = post.slug if post else ""
|
||||
post_id = post.id if post else None
|
||||
|
||||
# Fetch container_nav for post header
|
||||
container_nav = ""
|
||||
if post_id:
|
||||
nav_params = {
|
||||
"container_type": "page",
|
||||
"container_id": str(post_id),
|
||||
"post_slug": slug,
|
||||
}
|
||||
events_nav, market_nav = await fetch_fragments([
|
||||
("events", "container-nav", nav_params),
|
||||
("market", "container-nav", nav_params),
|
||||
], required=False)
|
||||
container_nav = events_nav + market_nav
|
||||
|
||||
return {
|
||||
"post": {
|
||||
"id": post_id,
|
||||
"slug": slug,
|
||||
"title": (post.title if post else "")[:160],
|
||||
"feature_image": getattr(post, "feature_image", None),
|
||||
},
|
||||
"container_nav": container_nav,
|
||||
}
|
||||
|
||||
async def payments_admin_data(self, session, **kw):
|
||||
"""Admin data + payments data combined for cart-payments page."""
|
||||
admin = await self.admin_data(session)
|
||||
payments = await self.payments_data(session)
|
||||
return {**admin, **payments}
|
||||
|
||||
async def payments_data(self, session, **kw):
|
||||
from shared.sx.page import get_template_context
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
;; Cart account-nav-item fragment handler
|
||||
;; returns: sx
|
||||
;;
|
||||
;; Renders the "orders" link for the account dashboard nav.
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
;; Cart cart-mini fragment handler
|
||||
;; returns: sx
|
||||
;;
|
||||
;; Renders the cart icon with badge (or logo when empty).
|
||||
|
||||
|
||||
@@ -1,25 +1,78 @@
|
||||
;; Cart layout defcomps — fully self-contained via IO primitives.
|
||||
;; Registered via register_sx_layout in __init__.py.
|
||||
|
||||
;; --- cart-page layout: root + cart row + page-cart row ---
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Auto-fetching cart page header macros
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~cart-page-layout-full (&key cart-row page-cart-row)
|
||||
(defmacro ~cart-page-header-auto (oob)
|
||||
"Cart page header: cart-row + page-cart-row using (cart-page-ctx)."
|
||||
(quasiquote
|
||||
(let ((__cpctx (cart-page-ctx)))
|
||||
(<>
|
||||
(~menu-row-sx :id "cart-row" :level 1 :colour "sky"
|
||||
:link-href (get __cpctx "cart-url")
|
||||
:link-label "cart" :icon "fa fa-shopping-cart"
|
||||
:child-id "cart-header-child")
|
||||
(~header-child-sx :id "cart-header-child"
|
||||
:inner (~menu-row-sx :id "page-cart-row" :level 2 :colour "sky"
|
||||
:link-href (get __cpctx "page-cart-url")
|
||||
:link-label-content (~cart-page-label
|
||||
:feature-image (get __cpctx "feature-image")
|
||||
:title (get __cpctx "title"))
|
||||
:nav (~cart-all-carts-link :href (get __cpctx "cart-url"))
|
||||
:oob (unquote oob)))))))
|
||||
|
||||
(defmacro ~cart-page-header-oob ()
|
||||
"Cart page OOB: individual oob rows."
|
||||
(quasiquote
|
||||
(let ((__cpctx (cart-page-ctx)))
|
||||
(<>
|
||||
(~menu-row-sx :id "page-cart-row" :level 2 :colour "sky"
|
||||
:link-href (get __cpctx "page-cart-url")
|
||||
:link-label-content (~cart-page-label
|
||||
:feature-image (get __cpctx "feature-image")
|
||||
:title (get __cpctx "title"))
|
||||
:nav (~cart-all-carts-link :href (get __cpctx "cart-url"))
|
||||
:oob true)
|
||||
(~menu-row-sx :id "cart-row" :level 1 :colour "sky"
|
||||
:link-href (get __cpctx "cart-url")
|
||||
:link-label "cart" :icon "fa fa-shopping-cart"
|
||||
:child-id "cart-header-child"
|
||||
:oob true)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; cart-page layout: root + cart row + page-cart row
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~cart-page-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~header-child-sx
|
||||
:inner (<> cart-row
|
||||
(~header-child-sx :id "cart-header-child" :inner page-cart-row)))))
|
||||
:inner (~cart-page-header-auto))))
|
||||
|
||||
(defcomp ~cart-page-layout-oob (&key root-header-oob cart-row-oob page-cart-row)
|
||||
(<> (~oob-header-sx :parent-id "cart-header-child" :row page-cart-row)
|
||||
cart-row-oob
|
||||
root-header-oob))
|
||||
(defcomp ~cart-page-layout-oob ()
|
||||
(<> (~cart-page-header-oob)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; --- cart-admin layout: root + post header + admin header ---
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; cart-admin layout: root + post header + admin header
|
||||
;; Uses (post-header-ctx) — requires :data handler to populate g._defpage_ctx
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~cart-admin-layout-full (&key post-header admin-header)
|
||||
(defcomp ~cart-admin-layout-full (&key selected)
|
||||
(<> (~root-header-auto)
|
||||
post-header admin-header))
|
||||
(~header-child-sx
|
||||
:inner (~post-header-auto nil))))
|
||||
|
||||
;; --- orders-within-cart: root + auth-simple + orders ---
|
||||
(defcomp ~cart-admin-layout-oob (&key selected)
|
||||
(<> (~post-header-auto true)
|
||||
(~oob-header-sx :parent-id "post-header-child"
|
||||
:row (~post-admin-header-auto nil selected))
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; orders-within-cart: root + auth-simple + orders
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~cart-orders-layout-full (&key list-url)
|
||||
(<> (~root-header-auto)
|
||||
@@ -35,7 +88,9 @@
|
||||
:row (~orders-header-row :list-url list-url))
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; --- order-detail-within-cart: root + auth-simple + orders + order ---
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; order-detail-within-cart: root + auth-simple + orders + order
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~cart-order-detail-layout-full (&key list-url detail-url order-label)
|
||||
(<> (~root-header-auto)
|
||||
@@ -61,3 +116,21 @@
|
||||
|
||||
(defcomp ~cart-orders-rows (&key rows next-scroll)
|
||||
(<> rows next-scroll))
|
||||
|
||||
;; Composition defcomp — replaces Python loop in render_orders_rows
|
||||
(defcomp ~cart-orders-rows-content (&key orders detail-url-prefix page total-pages next-url)
|
||||
(~cart-orders-rows
|
||||
:rows (map (lambda (od)
|
||||
(~order-row-pair :order od :detail-url-prefix detail-url-prefix))
|
||||
(or orders (list)))
|
||||
:next-scroll (if (< page total-pages)
|
||||
(~infinite-scroll :url next-url :page page
|
||||
:total-pages total-pages :id-prefix "orders" :colspan 5)
|
||||
(~order-end-row))))
|
||||
|
||||
;; Composition defcomp — replaces conditional composition in render_checkout_error_page
|
||||
(defcomp ~cart-checkout-error-from-data (&key msg order-id back-url)
|
||||
(~checkout-error-content
|
||||
:msg msg
|
||||
:order (when order-id (~checkout-error-order-id :oid (str "#" order-id)))
|
||||
:back-url back-url))
|
||||
|
||||
@@ -32,12 +32,13 @@
|
||||
:path "/<page_slug>/admin/"
|
||||
:auth :admin
|
||||
:layout :cart-admin
|
||||
:data (service "cart-page" "admin-data")
|
||||
:content (~cart-admin-content))
|
||||
|
||||
(defpage cart-payments
|
||||
:path "/<page_slug>/admin/payments/"
|
||||
:auth :admin
|
||||
:layout (:cart-admin :selected "payments")
|
||||
:data (service "cart-page" "payments-data")
|
||||
:data (service "cart-page" "payments-admin-data")
|
||||
:content (~cart-payments-content
|
||||
:page-config page-config))
|
||||
|
||||
@@ -1,135 +1,8 @@
|
||||
"""Cart layout registration and header builders."""
|
||||
"""Cart layout registration — all layouts delegate to .sx defcomps."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
|
||||
def _register_cart_layouts() -> None:
|
||||
from shared.sx.layouts import register_custom_layout
|
||||
register_custom_layout("cart-page", _cart_page_full, _cart_page_oob)
|
||||
register_custom_layout("cart-admin", _cart_admin_full, _cart_admin_oob)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Header helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ensure_post_ctx(ctx: dict, page_post: Any) -> dict:
|
||||
"""Ensure ctx has a 'post' dict from page_post DTO."""
|
||||
if ctx.get("post") or not page_post:
|
||||
return ctx
|
||||
return {**ctx, "post": {
|
||||
"id": getattr(page_post, "id", None),
|
||||
"slug": getattr(page_post, "slug", ""),
|
||||
"title": getattr(page_post, "title", ""),
|
||||
"feature_image": getattr(page_post, "feature_image", None),
|
||||
}}
|
||||
|
||||
|
||||
async def _ensure_container_nav(ctx: dict) -> dict:
|
||||
"""Fetch container_nav if not already present."""
|
||||
if ctx.get("container_nav"):
|
||||
return ctx
|
||||
post = ctx.get("post") or {}
|
||||
post_id = post.get("id")
|
||||
if not post_id:
|
||||
return ctx
|
||||
slug = post.get("slug", "")
|
||||
from shared.infrastructure.fragments import fetch_fragments
|
||||
nav_params = {
|
||||
"container_type": "page",
|
||||
"container_id": str(post_id),
|
||||
"post_slug": slug,
|
||||
}
|
||||
events_nav, market_nav = await fetch_fragments([
|
||||
("events", "container-nav", nav_params),
|
||||
("market", "container-nav", nav_params),
|
||||
], required=False)
|
||||
return {**ctx, "container_nav": events_nav + market_nav}
|
||||
|
||||
|
||||
async def _post_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
|
||||
from shared.sx.helpers import post_header_sx as _shared_post_header_sx
|
||||
ctx = _ensure_post_ctx(ctx, page_post)
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
return await _shared_post_header_sx(ctx, oob=oob)
|
||||
|
||||
|
||||
def _cart_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
from shared.sx.helpers import sx_call, call_url
|
||||
return sx_call(
|
||||
"menu-row-sx",
|
||||
id="cart-row", level=1, colour="sky",
|
||||
link_href=call_url(ctx, "cart_url", "/"),
|
||||
link_label="cart", icon="fa fa-shopping-cart",
|
||||
child_id="cart-header-child", oob=oob,
|
||||
)
|
||||
|
||||
|
||||
def _page_cart_header_sx(ctx: dict, page_post: Any, *, oob: bool = False) -> str:
|
||||
from shared.sx.helpers import sx_call, call_url
|
||||
slug = page_post.slug if page_post else ""
|
||||
title = ((page_post.title if page_post else None) or "")[:160]
|
||||
label_sx = sx_call("cart-page-label",
|
||||
feature_image=page_post.feature_image if page_post else None,
|
||||
title=title)
|
||||
nav_sx = sx_call("cart-all-carts-link", href=call_url(ctx, "cart_url", "/"))
|
||||
return sx_call(
|
||||
"menu-row-sx",
|
||||
id="page-cart-row", level=2, colour="sky",
|
||||
link_href=call_url(ctx, "cart_url", f"/{slug}/"),
|
||||
link_label_content=SxExpr(label_sx),
|
||||
nav=SxExpr(nav_sx), oob=oob,
|
||||
)
|
||||
|
||||
|
||||
async def _cart_page_admin_header_sx(ctx: dict, page_post: Any, *, oob: bool = False,
|
||||
selected: str = "") -> str:
|
||||
from shared.sx.helpers import post_admin_header_sx
|
||||
slug = page_post.slug if page_post else ""
|
||||
ctx = _ensure_post_ctx(ctx, page_post)
|
||||
return await post_admin_header_sx(ctx, slug, oob=oob, selected=selected)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layout functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _cart_page_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env
|
||||
page_post = ctx.get("page_post")
|
||||
env = {}
|
||||
return await render_to_sx_with_env("cart-page-layout-full", env,
|
||||
cart_row=SxExpr(_cart_header_sx(ctx)),
|
||||
page_cart_row=SxExpr(_page_cart_header_sx(ctx, page_post)),
|
||||
)
|
||||
|
||||
|
||||
async def _cart_page_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env, root_header_sx
|
||||
page_post = ctx.get("page_post")
|
||||
env = {}
|
||||
return await render_to_sx_with_env("cart-page-layout-oob", env,
|
||||
root_header_oob=SxExpr(await root_header_sx(ctx, oob=True)),
|
||||
cart_row_oob=SxExpr(_cart_header_sx(ctx, oob=True)),
|
||||
page_cart_row=SxExpr(_page_cart_header_sx(ctx, page_post)),
|
||||
)
|
||||
|
||||
|
||||
async def _cart_admin_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env
|
||||
page_post = ctx.get("page_post")
|
||||
selected = kw.get("selected", "")
|
||||
env = {}
|
||||
return await render_to_sx_with_env("cart-admin-layout-full", env,
|
||||
post_header=SxExpr(await _post_header_sx(ctx, page_post)),
|
||||
admin_header=SxExpr(await _cart_page_admin_header_sx(ctx, page_post, selected=selected)),
|
||||
)
|
||||
|
||||
|
||||
async def _cart_admin_oob(ctx: dict, **kw: Any) -> str:
|
||||
page_post = ctx.get("page_post")
|
||||
selected = kw.get("selected", "")
|
||||
return await _cart_page_admin_header_sx(ctx, page_post, oob=True, selected=selected)
|
||||
from shared.sx.layouts import register_sx_layout
|
||||
register_sx_layout("cart-page", "cart-page-layout-full", "cart-page-layout-oob")
|
||||
register_sx_layout("cart-admin", "cart-admin-layout-full", "cart-admin-layout-oob")
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Cart render functions — called from bp routes."""
|
||||
from __future__ import annotations
|
||||
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
from .utils import _serialize_order, _serialize_calendar_entry
|
||||
|
||||
|
||||
@@ -20,7 +18,7 @@ async def render_orders_page(ctx, orders, page, total_pages, search, search_coun
|
||||
header_rows = await render_to_sx_with_env("cart-orders-layout-full", {},
|
||||
list_url=list_url,
|
||||
)
|
||||
filt = sx_call("order-list-header", search_mobile=SxExpr(await search_mobile_sx(ctx)))
|
||||
filt = sx_call("order-list-header", search_mobile=await search_mobile_sx(ctx))
|
||||
return await full_page_sx(ctx, header_rows=header_rows, filter=filt,
|
||||
aside=await search_desktop_sx(ctx), content=content)
|
||||
|
||||
@@ -32,20 +30,10 @@ def render_orders_rows(ctx, orders, page, total_pages, url_for_fn, qs_fn):
|
||||
list_url = pfx + url_for_fn("orders.list_orders")
|
||||
detail_url_prefix = pfx + url_for_fn("orders.order.order_detail", order_id=0).rsplit("0/", 1)[0]
|
||||
order_dicts = [_serialize_order(o) for o in orders]
|
||||
parts = []
|
||||
for od in order_dicts:
|
||||
parts.append(sx_call("order-row-pair", order=od, detail_url_prefix=detail_url_prefix))
|
||||
next_scroll = ""
|
||||
if page < total_pages:
|
||||
next_url = list_url + qs_fn(page=page + 1)
|
||||
next_scroll = sx_call("infinite-scroll", url=next_url, page=page,
|
||||
total_pages=total_pages, id_prefix="orders", colspan=5)
|
||||
else:
|
||||
next_scroll = sx_call("order-end-row")
|
||||
return sx_call("cart-orders-rows",
|
||||
rows=SxExpr("(<> " + " ".join(parts) + ")"),
|
||||
next_scroll=SxExpr(next_scroll),
|
||||
)
|
||||
next_url = list_url + qs_fn(page=page + 1) if page < total_pages else ""
|
||||
return sx_call("cart-orders-rows-content",
|
||||
orders=order_dicts, detail_url_prefix=detail_url_prefix,
|
||||
page=page, total_pages=total_pages, next_url=next_url)
|
||||
|
||||
|
||||
async def render_orders_oob(ctx, orders, page, total_pages, search, search_count, url_for_fn, qs_fn):
|
||||
@@ -62,7 +50,7 @@ async def render_orders_oob(ctx, orders, page, total_pages, search, search_count
|
||||
oobs = await render_to_sx_with_env("cart-orders-layout-oob", {},
|
||||
list_url=list_url,
|
||||
)
|
||||
filt = sx_call("order-list-header", search_mobile=SxExpr(await search_mobile_sx(ctx)))
|
||||
filt = sx_call("order-list-header", search_mobile=await search_mobile_sx(ctx))
|
||||
return await oob_page_sx(oobs=oobs, filter=filt, aside=await search_desktop_sx(ctx), content=content)
|
||||
|
||||
|
||||
@@ -112,11 +100,11 @@ async def render_checkout_error_page(ctx, error=None, order=None):
|
||||
from shared.sx.helpers import sx_call, render_to_sx_with_env, full_page_sx
|
||||
from shared.infrastructure.urls import cart_url
|
||||
err_msg = error or "Unexpected error while creating the hosted checkout session."
|
||||
order_sx = sx_call("checkout-error-order-id", oid=f"#{order.id}") if order else None
|
||||
hdr = await render_to_sx_with_env("layout-root-full", {})
|
||||
filt = sx_call("checkout-error-header")
|
||||
content = sx_call("checkout-error-content", msg=err_msg,
|
||||
order=SxExpr(order_sx) if order_sx else None, back_url=cart_url("/"))
|
||||
content = sx_call("cart-checkout-error-from-data",
|
||||
msg=err_msg, order_id=order.id if order else None,
|
||||
back_url=cart_url("/"))
|
||||
return await full_page_sx(ctx, header_rows=hdr, filter=filt, content=content)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
;; Events account-nav-item fragment handler
|
||||
;; returns: sx
|
||||
;;
|
||||
;; Renders tickets + bookings links for the account dashboard nav.
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
;; Account-page fragment handler
|
||||
;; returns: sx
|
||||
;;
|
||||
;; Renders tickets or bookings panel for the account dashboard.
|
||||
;; slug=tickets → ticket list; slug=bookings → booking list.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
;; Container-cards fragment handler
|
||||
;; returns: sx
|
||||
;;
|
||||
;; Returns HTML with <!-- card-widget:ID --> comment markers so the
|
||||
;; blog consumer can split per-post fragments. Each post section
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
;; Events container-nav fragment handler
|
||||
;; returns: sx
|
||||
;;
|
||||
;; Renders calendar entry nav items + calendar link nav items
|
||||
;; for the scrollable navigation panel on blog post pages.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
;; Events link-card fragment handler
|
||||
;; returns: sx
|
||||
;;
|
||||
;; Renders event page preview card(s) by slug.
|
||||
;; Supports single mode (?slug=x) and batch mode (?keys=x,y,z).
|
||||
|
||||
@@ -1,96 +1,405 @@
|
||||
;; Events layout defcomps — root header via ~root-header-auto,
|
||||
;; events-specific headers passed as &key params.
|
||||
;; Events layout defcomps — fully self-contained via IO primitives.
|
||||
;; Registered via register_sx_layout in helpers.py.
|
||||
|
||||
;; --- Calendar admin layout: root + post + child(admin + cal + cal-admin) ---
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Auto-fetching header macros — calendar, day, entry, slot, tickets
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~events-cal-admin-layout-full (&key post-header admin-header
|
||||
calendar-header calendar-admin-header)
|
||||
(defmacro ~events-calendar-header-auto (oob)
|
||||
"Calendar header row using (events-calendar-ctx)."
|
||||
(quasiquote
|
||||
(let ((__cal (events-calendar-ctx))
|
||||
(__sc (select-colours)))
|
||||
(when (get __cal "slug")
|
||||
(~menu-row-sx :id "calendar-row" :level 3
|
||||
:link-href (url-for "calendar.get"
|
||||
:calendar-slug (get __cal "slug"))
|
||||
:link-label-content (~events-calendar-label
|
||||
:name (get __cal "name")
|
||||
:description (get __cal "description"))
|
||||
:nav (<>
|
||||
(~nav-link :href (url-for "defpage_slots_listing"
|
||||
:calendar-slug (get __cal "slug"))
|
||||
:icon "fa fa-clock" :label "Slots"
|
||||
:select-colours __sc)
|
||||
(let ((__rights (app-rights)))
|
||||
(when (get __rights "admin")
|
||||
(~nav-link :href (url-for "defpage_calendar_admin"
|
||||
:calendar-slug (get __cal "slug"))
|
||||
:icon "fa fa-cog"
|
||||
:select-colours __sc))))
|
||||
:child-id "calendar-header-child"
|
||||
:oob (unquote oob))))))
|
||||
|
||||
(defmacro ~events-calendar-admin-header-auto (oob)
|
||||
"Calendar admin header row."
|
||||
(quasiquote
|
||||
(let ((__cal (events-calendar-ctx))
|
||||
(__sc (select-colours)))
|
||||
(when (get __cal "slug")
|
||||
(~menu-row-sx :id "calendar-admin-row" :level 4
|
||||
:link-label "admin" :icon "fa fa-cog"
|
||||
:nav (<>
|
||||
(~nav-link :href (url-for "defpage_slots_listing"
|
||||
:calendar-slug (get __cal "slug"))
|
||||
:label "slots" :select-colours __sc)
|
||||
(~nav-link :href (url-for "calendar.admin.calendar_description_edit"
|
||||
:calendar-slug (get __cal "slug"))
|
||||
:label "description" :select-colours __sc))
|
||||
:child-id "calendar-admin-header-child"
|
||||
:oob (unquote oob))))))
|
||||
|
||||
(defmacro ~events-day-header-auto (oob)
|
||||
"Day header row using (events-day-ctx)."
|
||||
(quasiquote
|
||||
(let ((__day (events-day-ctx))
|
||||
(__cal (events-calendar-ctx)))
|
||||
(when (get __day "date-str")
|
||||
(~menu-row-sx :id "day-row" :level 4
|
||||
:link-href (url-for "calendar.day.show_day"
|
||||
:calendar-slug (get __cal "slug")
|
||||
:year (get __day "year")
|
||||
:month (get __day "month")
|
||||
:day (get __day "day"))
|
||||
:link-label-content (~events-day-label
|
||||
:date-str (get __day "date-str"))
|
||||
:nav (get __day "nav")
|
||||
:child-id "day-header-child"
|
||||
:oob (unquote oob))))))
|
||||
|
||||
(defmacro ~events-day-admin-header-auto (oob)
|
||||
"Day admin header row."
|
||||
(quasiquote
|
||||
(let ((__day (events-day-ctx))
|
||||
(__cal (events-calendar-ctx)))
|
||||
(when (get __day "date-str")
|
||||
(~menu-row-sx :id "day-admin-row" :level 5
|
||||
:link-href (url-for "defpage_day_admin"
|
||||
:calendar-slug (get __cal "slug")
|
||||
:year (get __day "year")
|
||||
:month (get __day "month")
|
||||
:day (get __day "day"))
|
||||
:link-label "admin" :icon "fa fa-cog"
|
||||
:child-id "day-admin-header-child"
|
||||
:oob (unquote oob))))))
|
||||
|
||||
(defmacro ~events-entry-header-auto (oob)
|
||||
"Entry header row using (events-entry-ctx)."
|
||||
(quasiquote
|
||||
(let ((__ectx (events-entry-ctx)))
|
||||
(when (get __ectx "id")
|
||||
(~menu-row-sx :id "entry-row" :level 5
|
||||
:link-href (get __ectx "link-href")
|
||||
:link-label-content (~events-entry-label
|
||||
:entry-id (get __ectx "id")
|
||||
:title (~events-entry-title :name (get __ectx "name"))
|
||||
:times (~events-entry-times :time-str (get __ectx "time-str")))
|
||||
:nav (get __ectx "nav")
|
||||
:child-id "entry-header-child"
|
||||
:oob (unquote oob))))))
|
||||
|
||||
(defmacro ~events-entry-admin-header-auto (oob)
|
||||
"Entry admin header row."
|
||||
(quasiquote
|
||||
(let ((__ectx (events-entry-ctx)))
|
||||
(when (get __ectx "id")
|
||||
(~menu-row-sx :id "entry-admin-row" :level 6
|
||||
:link-href (get __ectx "admin-href")
|
||||
:link-label "admin" :icon "fa fa-cog"
|
||||
:nav (when (get __ectx "is-admin")
|
||||
(~nav-link :href (get __ectx "ticket-types-href")
|
||||
:label "ticket_types"
|
||||
:select-colours (get __ectx "select-colours")))
|
||||
:child-id "entry-admin-header-child"
|
||||
:oob (unquote oob))))))
|
||||
|
||||
(defmacro ~events-slot-header-auto (oob)
|
||||
"Slot detail header row using (events-slot-ctx)."
|
||||
(quasiquote
|
||||
(let ((__slot (events-slot-ctx)))
|
||||
(when (get __slot "name")
|
||||
(~menu-row-sx :id "slot-row" :level 5
|
||||
:link-label-content (~events-slot-label
|
||||
:name (get __slot "name")
|
||||
:description (get __slot "description"))
|
||||
:child-id "slot-header-child"
|
||||
:oob (unquote oob))))))
|
||||
|
||||
(defmacro ~events-ticket-types-header-auto (oob)
|
||||
"Ticket types header row."
|
||||
(quasiquote
|
||||
(let ((__ectx (events-entry-ctx))
|
||||
(__cal (events-calendar-ctx)))
|
||||
(when (get __ectx "id")
|
||||
(~menu-row-sx :id "ticket_types-row" :level 7
|
||||
:link-href (get __ectx "ticket-types-href")
|
||||
:link-label-content (<>
|
||||
(i :class "fa fa-ticket")
|
||||
(div :class "shrink-0" "ticket types"))
|
||||
:nav (~events-admin-placeholder-nav)
|
||||
:child-id "ticket_type-header-child"
|
||||
:oob (unquote oob))))))
|
||||
|
||||
(defmacro ~events-ticket-type-header-auto (oob)
|
||||
"Single ticket type header row using (events-ticket-type-ctx)."
|
||||
(quasiquote
|
||||
(let ((__tt (events-ticket-type-ctx)))
|
||||
(when (get __tt "id")
|
||||
(~menu-row-sx :id "ticket_type-row" :level 8
|
||||
:link-href (get __tt "link-href")
|
||||
:link-label-content (div :class "flex flex-col md:flex-row md:gap-2 items-center"
|
||||
(div :class "flex flex-row items-center gap-2"
|
||||
(i :class "fa fa-ticket")
|
||||
(div :class "shrink-0" (get __tt "name"))))
|
||||
:nav (~events-admin-placeholder-nav)
|
||||
:child-id "ticket_type-header-child-inner"
|
||||
:oob (unquote oob))))))
|
||||
|
||||
(defmacro ~events-markets-header-auto (oob)
|
||||
"Markets section header row."
|
||||
(quasiquote
|
||||
(~menu-row-sx :id "markets-row" :level 3
|
||||
:link-href (url-for "defpage_events_markets")
|
||||
:link-label-content (~events-markets-label)
|
||||
:child-id "markets-header-child"
|
||||
:oob (unquote oob))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; OOB clear helpers — clear deeper header rows not present at this level
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~events-clear-oob-cal-admin ()
|
||||
"Clear OOB divs for cal-admin level (keeps down to calendar-admin)."
|
||||
(<>
|
||||
(~clear-oob-div :id "entry-admin-row")
|
||||
(~clear-oob-div :id "entry-admin-header-child")
|
||||
(~clear-oob-div :id "entry-row")
|
||||
(~clear-oob-div :id "entry-header-child")
|
||||
(~clear-oob-div :id "day-admin-row")
|
||||
(~clear-oob-div :id "day-admin-header-child")
|
||||
(~clear-oob-div :id "day-row")
|
||||
(~clear-oob-div :id "day-header-child")
|
||||
(~clear-oob-div :id "calendars-row")
|
||||
(~clear-oob-div :id "calendars-header-child")))
|
||||
|
||||
(defcomp ~events-clear-oob-slot ()
|
||||
"Clear OOB divs for slot level."
|
||||
(<>
|
||||
(~clear-oob-div :id "entry-admin-row")
|
||||
(~clear-oob-div :id "entry-admin-header-child")
|
||||
(~clear-oob-div :id "entry-row")
|
||||
(~clear-oob-div :id "entry-header-child")
|
||||
(~clear-oob-div :id "day-admin-row")
|
||||
(~clear-oob-div :id "day-admin-header-child")
|
||||
(~clear-oob-div :id "day-row")
|
||||
(~clear-oob-div :id "day-header-child")
|
||||
(~clear-oob-div :id "calendars-row")
|
||||
(~clear-oob-div :id "calendars-header-child")))
|
||||
|
||||
(defcomp ~events-clear-oob-day-admin ()
|
||||
"Clear OOB divs for day-admin level."
|
||||
(<>
|
||||
(~clear-oob-div :id "entry-admin-row")
|
||||
(~clear-oob-div :id "entry-admin-header-child")
|
||||
(~clear-oob-div :id "entry-row")
|
||||
(~clear-oob-div :id "entry-header-child")
|
||||
(~clear-oob-div :id "calendars-row")
|
||||
(~clear-oob-div :id "calendars-header-child")))
|
||||
|
||||
(defcomp ~events-clear-oob-entry ()
|
||||
"Clear OOB divs for entry level (public, no admin rows)."
|
||||
(<>
|
||||
(~clear-oob-div :id "entry-admin-row")
|
||||
(~clear-oob-div :id "entry-admin-header-child")
|
||||
(~clear-oob-div :id "day-admin-row")
|
||||
(~clear-oob-div :id "day-admin-header-child")
|
||||
(~clear-oob-div :id "calendar-admin-row")
|
||||
(~clear-oob-div :id "calendar-admin-header-child")
|
||||
(~clear-oob-div :id "calendars-row")
|
||||
(~clear-oob-div :id "calendars-header-child")
|
||||
(~clear-oob-div :id "post-admin-row")
|
||||
(~clear-oob-div :id "post-admin-header-child")))
|
||||
|
||||
(defcomp ~events-clear-oob-entry-admin ()
|
||||
"Clear OOB divs for entry-admin level."
|
||||
(<>
|
||||
(~clear-oob-div :id "calendars-row")
|
||||
(~clear-oob-div :id "calendars-header-child")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Calendar admin layout: root + post + child(post-admin + cal + cal-admin)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~events-cal-admin-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
post-header
|
||||
(~header-child-sx :inner (<> admin-header calendar-header calendar-admin-header))))
|
||||
(~header-child-sx
|
||||
:inner (<> (~post-header-auto nil)
|
||||
(~post-admin-header-auto nil "calendars")
|
||||
(~events-calendar-header-auto nil)
|
||||
(~events-calendar-admin-header-auto nil)))))
|
||||
|
||||
(defcomp ~events-cal-admin-layout-oob (&key admin-oob cal-oob cal-admin-oob-wrap clear-oob)
|
||||
(<> admin-oob cal-oob cal-admin-oob-wrap clear-oob))
|
||||
(defcomp ~events-cal-admin-layout-oob ()
|
||||
(<> (~post-admin-header-auto true "calendars")
|
||||
(~events-calendar-header-auto true)
|
||||
(~oob-header-sx :parent-id "calendar-header-child"
|
||||
:row (~events-calendar-admin-header-auto nil))
|
||||
(~events-clear-oob-cal-admin)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; --- Slots layout: same full as cal-admin ---
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Slots layout: same full as cal-admin
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~events-slots-layout-oob (&key admin-oob cal-admin-oob clear-oob)
|
||||
(<> admin-oob cal-admin-oob clear-oob))
|
||||
|
||||
;; --- Slot detail layout: root + post + child(admin + cal + cal-admin + slot) ---
|
||||
|
||||
(defcomp ~events-slot-layout-full (&key post-header admin-header
|
||||
calendar-header calendar-admin-header slot-header)
|
||||
(defcomp ~events-slots-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
post-header
|
||||
(~header-child-sx :inner (<> admin-header calendar-header calendar-admin-header slot-header))))
|
||||
(~header-child-sx
|
||||
:inner (<> (~post-header-auto nil)
|
||||
(~post-admin-header-auto nil "calendars")
|
||||
(~events-calendar-header-auto nil)
|
||||
(~events-calendar-admin-header-auto nil)))))
|
||||
|
||||
(defcomp ~events-slot-layout-oob (&key admin-oob cal-admin-oob slot-oob-wrap clear-oob)
|
||||
(<> admin-oob cal-admin-oob slot-oob-wrap clear-oob))
|
||||
(defcomp ~events-slots-layout-oob ()
|
||||
(<> (~post-admin-header-auto true "calendars")
|
||||
(~events-calendar-admin-header-auto true)
|
||||
(~events-clear-oob-cal-admin)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; --- Day admin layout: root + post + child(admin + cal + day + day-admin) ---
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Slot detail layout: root + post + child(admin + cal + cal-admin + slot)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~events-day-admin-layout-full (&key post-header admin-header
|
||||
calendar-header day-header day-admin-header)
|
||||
(defcomp ~events-slot-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
post-header
|
||||
(~header-child-sx :inner (<> admin-header calendar-header day-header day-admin-header))))
|
||||
(~header-child-sx
|
||||
:inner (<> (~post-header-auto nil)
|
||||
(~post-admin-header-auto nil "calendars")
|
||||
(~events-calendar-header-auto nil)
|
||||
(~events-calendar-admin-header-auto nil)
|
||||
(~events-slot-header-auto nil)))))
|
||||
|
||||
(defcomp ~events-day-admin-layout-oob (&key admin-oob cal-oob day-admin-oob-wrap clear-oob)
|
||||
(<> admin-oob cal-oob day-admin-oob-wrap clear-oob))
|
||||
(defcomp ~events-slot-layout-oob ()
|
||||
(<> (~post-admin-header-auto true "calendars")
|
||||
(~events-calendar-admin-header-auto true)
|
||||
(~oob-header-sx :parent-id "calendar-admin-header-child"
|
||||
:row (~events-slot-header-auto nil))
|
||||
(~events-clear-oob-slot)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; --- Entry layout: root + child(post + cal + day + entry) ---
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Day admin layout: root + post + child(admin + cal + day + day-admin)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~events-entry-layout-full (&key post-header calendar-header day-header entry-header)
|
||||
(defcomp ~events-day-admin-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~header-child-sx :inner (<> post-header calendar-header day-header entry-header))))
|
||||
(~header-child-sx
|
||||
:inner (<> (~post-header-auto nil)
|
||||
(~post-admin-header-auto nil "calendars")
|
||||
(~events-calendar-header-auto nil)
|
||||
(~events-day-header-auto nil)
|
||||
(~events-day-admin-header-auto nil)))))
|
||||
|
||||
(defcomp ~events-entry-layout-oob (&key day-oob entry-oob-wrap clear-oob)
|
||||
(<> day-oob entry-oob-wrap clear-oob))
|
||||
(defcomp ~events-day-admin-layout-oob ()
|
||||
(<> (~post-admin-header-auto true "calendars")
|
||||
(~events-calendar-header-auto true)
|
||||
(~oob-header-sx :parent-id "day-header-child"
|
||||
:row (~events-day-admin-header-auto nil))
|
||||
(~events-clear-oob-day-admin)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; --- Entry admin layout: root + post + child(admin + cal + day + entry + entry-admin) ---
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Entry layout: root + child(post + cal + day + entry) — public, no admin
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~events-entry-admin-layout-full (&key post-header admin-header
|
||||
calendar-header day-header
|
||||
entry-header entry-admin-header)
|
||||
(defcomp ~events-entry-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
post-header
|
||||
(~header-child-sx :inner (<> admin-header calendar-header day-header
|
||||
entry-header entry-admin-header))))
|
||||
(~header-child-sx
|
||||
:inner (<> (~post-header-auto nil)
|
||||
(~events-calendar-header-auto nil)
|
||||
(~events-day-header-auto nil)
|
||||
(~events-entry-header-auto nil)))))
|
||||
|
||||
(defcomp ~events-entry-admin-layout-oob (&key admin-oob entry-oob entry-admin-oob-wrap clear-oob)
|
||||
(<> admin-oob entry-oob entry-admin-oob-wrap clear-oob))
|
||||
(defcomp ~events-entry-layout-oob ()
|
||||
(<> (~events-day-header-auto true)
|
||||
(~oob-header-sx :parent-id "day-header-child"
|
||||
:row (~events-entry-header-auto nil))
|
||||
(~events-clear-oob-entry)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; --- Ticket types layout: root + child(post + cal + day + entry + entry-admin + ticket-types) ---
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Entry admin layout: root + post + child(admin + cal + day + entry + entry-admin)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~events-ticket-types-layout-full (&key post-header calendar-header day-header
|
||||
entry-header entry-admin-header
|
||||
ticket-types-header)
|
||||
(defcomp ~events-entry-admin-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~header-child-sx :inner (<> post-header calendar-header day-header
|
||||
entry-header entry-admin-header ticket-types-header))))
|
||||
(~header-child-sx
|
||||
:inner (<> (~post-header-auto nil)
|
||||
(~post-admin-header-auto nil "calendars")
|
||||
(~events-calendar-header-auto nil)
|
||||
(~events-day-header-auto nil)
|
||||
(~events-entry-header-auto nil)
|
||||
(~events-entry-admin-header-auto nil)))))
|
||||
|
||||
(defcomp ~events-ticket-types-layout-oob (&key entry-admin-oob ticket-types-oob-wrap)
|
||||
(<> entry-admin-oob ticket-types-oob-wrap))
|
||||
(defcomp ~events-entry-admin-layout-oob ()
|
||||
(<> (~post-admin-header-auto true "calendars")
|
||||
(~events-entry-header-auto true)
|
||||
(~oob-header-sx :parent-id "entry-header-child"
|
||||
:row (~events-entry-admin-header-auto nil))
|
||||
(~events-clear-oob-entry-admin)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; --- Ticket type detail layout: root + child(post + cal + day + entry + entry-admin + types + type) ---
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Ticket types layout: root + child(post + cal + day + entry + entry-admin + ticket-types)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~events-ticket-type-layout-full (&key post-header calendar-header day-header
|
||||
entry-header entry-admin-header
|
||||
ticket-types-header ticket-type-header)
|
||||
(defcomp ~events-ticket-types-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~header-child-sx :inner (<> post-header calendar-header day-header
|
||||
entry-header entry-admin-header
|
||||
ticket-types-header ticket-type-header))))
|
||||
(~header-child-sx
|
||||
:inner (<> (~post-header-auto nil)
|
||||
(~events-calendar-header-auto nil)
|
||||
(~events-day-header-auto nil)
|
||||
(~events-entry-header-auto nil)
|
||||
(~events-entry-admin-header-auto nil)
|
||||
(~events-ticket-types-header-auto nil)))))
|
||||
|
||||
(defcomp ~events-ticket-type-layout-oob (&key ticket-types-oob ticket-type-oob-wrap)
|
||||
(<> ticket-types-oob ticket-type-oob-wrap))
|
||||
(defcomp ~events-ticket-types-layout-oob ()
|
||||
(<> (~events-entry-admin-header-auto true)
|
||||
(~oob-header-sx :parent-id "entry-admin-header-child"
|
||||
:row (~events-ticket-types-header-auto nil))
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; --- Markets layout: root + child(post + markets) ---
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Ticket type layout: all headers down to ticket-type
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~events-markets-layout-full (&key post-header markets-header)
|
||||
(defcomp ~events-ticket-type-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~header-child-sx :inner (<> post-header markets-header))))
|
||||
(~header-child-sx
|
||||
:inner (<> (~post-header-auto nil)
|
||||
(~events-calendar-header-auto nil)
|
||||
(~events-day-header-auto nil)
|
||||
(~events-entry-header-auto nil)
|
||||
(~events-entry-admin-header-auto nil)
|
||||
(~events-ticket-types-header-auto nil)
|
||||
(~events-ticket-type-header-auto nil)))))
|
||||
|
||||
(defcomp ~events-markets-layout-oob (&key post-oob markets-oob-wrap)
|
||||
(<> post-oob markets-oob-wrap))
|
||||
(defcomp ~events-ticket-type-layout-oob ()
|
||||
(<> (~events-ticket-types-header-auto true)
|
||||
(~oob-header-sx :parent-id "ticket_types-header-child"
|
||||
:row (~events-ticket-type-header-auto nil))
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Markets layout: root + child(post + markets)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~events-markets-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~header-child-sx
|
||||
:inner (<> (~post-header-auto nil)
|
||||
(~events-markets-header-auto nil)))))
|
||||
|
||||
(defcomp ~events-markets-layout-oob ()
|
||||
(<> (~post-header-auto true)
|
||||
(~oob-header-sx :parent-id "post-header-child"
|
||||
:row (~events-markets-header-auto nil))
|
||||
(~root-header-auto true)))
|
||||
|
||||
@@ -78,7 +78,7 @@ def _calendars_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
link_href = url_for("calendars.home")
|
||||
return sx_call("menu-row-sx", id="calendars-row", level=3,
|
||||
link_href=link_href,
|
||||
link_label_content=SxExpr(sx_call("events-calendars-label")),
|
||||
link_label_content=sx_call("events-calendars-label"),
|
||||
child_id="calendars-header-child", oob=oob)
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ def _calendar_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
nav_html = _calendar_nav_sx(ctx)
|
||||
|
||||
return sx_call("menu-row-sx", id="calendar-row", level=3,
|
||||
link_href=link_href, link_label_content=SxExpr(label_html),
|
||||
link_href=link_href, link_label_content=label_html,
|
||||
nav=SxExpr(nav_html) if nav_html else None, child_id="calendar-header-child", oob=oob)
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ def _day_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
nav_html = _day_nav_sx(ctx)
|
||||
|
||||
return sx_call("menu-row-sx", id="day-row", level=4,
|
||||
link_href=link_href, link_label_content=SxExpr(label_html),
|
||||
link_href=link_href, link_label_content=label_html,
|
||||
nav=SxExpr(nav_html) if nav_html else None, child_id="day-header-child", oob=oob)
|
||||
|
||||
|
||||
@@ -180,7 +180,7 @@ def _day_nav_sx(ctx: dict) -> str:
|
||||
entry_links = []
|
||||
for entry in confirmed_entries:
|
||||
href = url_for(
|
||||
"calendar.day.calendar_entries.calendar_entry.get",
|
||||
"defpage_entry_detail",
|
||||
calendar_slug=cal_slug,
|
||||
year=day_date.year,
|
||||
month=day_date.month,
|
||||
@@ -197,7 +197,7 @@ def _day_nav_sx(ctx: dict) -> str:
|
||||
|
||||
if is_admin and day_date:
|
||||
admin_href = url_for(
|
||||
"calendar.day.admin.admin",
|
||||
"defpage_day_admin",
|
||||
calendar_slug=cal_slug,
|
||||
year=day_date.year,
|
||||
month=day_date.month,
|
||||
@@ -223,7 +223,7 @@ def _day_admin_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
return ""
|
||||
|
||||
link_href = url_for(
|
||||
"calendar.day.admin.admin",
|
||||
"defpage_day_admin",
|
||||
calendar_slug=cal_slug,
|
||||
year=day_date.year,
|
||||
month=day_date.month,
|
||||
@@ -271,7 +271,7 @@ def _markets_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
link_href = url_for("defpage_events_markets")
|
||||
return sx_call("menu-row-sx", id="markets-row", level=3,
|
||||
link_href=link_href,
|
||||
link_label_content=SxExpr(sx_call("events-markets-label")),
|
||||
link_label_content=sx_call("events-markets-label"),
|
||||
child_id="markets-header-child", oob=oob)
|
||||
|
||||
|
||||
@@ -323,7 +323,7 @@ def _calendars_list_sx(ctx: dict, calendars: list) -> str:
|
||||
cal_name = getattr(cal, "name", "")
|
||||
href = prefix + url_for("calendar.get", calendar_slug=cal_slug)
|
||||
del_url = url_for("calendar.delete", calendar_slug=cal_slug)
|
||||
csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}'
|
||||
csrf_hdr = {"X-CSRFToken": csrf}
|
||||
parts.append(sx_call("crud-item",
|
||||
href=href, name=cal_name, slug=cal_slug,
|
||||
del_url=del_url, csrf_hdr=csrf_hdr,
|
||||
@@ -518,7 +518,7 @@ def _day_row_html(ctx: dict, entry) -> str:
|
||||
tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
|
||||
|
||||
entry_href = url_for(
|
||||
"calendar.day.calendar_entries.calendar_entry.get",
|
||||
"defpage_entry_detail",
|
||||
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=entry.id,
|
||||
)
|
||||
|
||||
@@ -544,7 +544,7 @@ def _day_row_html(ctx: dict, entry) -> str:
|
||||
state = getattr(entry, "state", "pending") or "pending"
|
||||
state_badge = _entry_state_badge_html(state)
|
||||
state_td = sx_call("events-day-row-state",
|
||||
state_id=f"entry-state-{entry.id}", badge=SxExpr(state_badge))
|
||||
state_id=f"entry-state-{entry.id}", badge=state_badge)
|
||||
|
||||
# Cost
|
||||
cost = getattr(entry, "cost", None)
|
||||
@@ -564,9 +564,9 @@ def _day_row_html(ctx: dict, entry) -> str:
|
||||
actions_td = sx_call("events-day-row-actions")
|
||||
|
||||
return sx_call("events-day-row",
|
||||
tr_cls=tr_cls, name=SxExpr(name_html), slot=SxExpr(slot_html),
|
||||
state=SxExpr(state_td), cost=SxExpr(cost_td),
|
||||
tickets=SxExpr(tickets_td), actions=SxExpr(actions_td))
|
||||
tr_cls=tr_cls, name=name_html, slot=slot_html,
|
||||
state=state_td, cost=cost_td,
|
||||
tickets=tickets_td, actions=actions_td)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -598,7 +598,7 @@ def _calendar_admin_main_panel_html(ctx: dict) -> str:
|
||||
description_html = _calendar_description_display_html(calendar, desc_edit_url)
|
||||
|
||||
return sx_call("events-calendar-admin-panel",
|
||||
description_content=SxExpr(description_html), csrf=csrf,
|
||||
description_content=description_html, csrf=csrf,
|
||||
description=desc)
|
||||
|
||||
|
||||
@@ -656,7 +656,7 @@ def _markets_list_html(ctx: dict, markets: list) -> str:
|
||||
m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "")
|
||||
market_href = call_url(ctx, "market_url", f"/{slug}/{m_slug}/")
|
||||
del_url = url_for("markets.delete_market", market_slug=m_slug)
|
||||
csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}'
|
||||
csrf_hdr = {"X-CSRFToken": csrf}
|
||||
parts.append(sx_call("crud-item",
|
||||
href=market_href, name=m_name,
|
||||
slug=m_slug, del_url=del_url,
|
||||
|
||||
@@ -73,10 +73,10 @@ def _entry_card_html(entry, page_info: dict, pending_tickets: dict,
|
||||
if tp is not None:
|
||||
qty = pending_tickets.get(entry.id, 0)
|
||||
widget_html = sx_call("events-entry-widget-wrapper",
|
||||
widget=SxExpr(_ticket_widget_html(entry, qty, ticket_url, ctx={})))
|
||||
widget=_ticket_widget_html(entry, qty, ticket_url, ctx={}))
|
||||
|
||||
return sx_call("events-entry-card",
|
||||
title=SxExpr(title_html), badges=SxExpr(badges_html),
|
||||
title=title_html, badges=SxExpr(badges_html),
|
||||
time_parts=SxExpr(time_parts), cost=SxExpr(cost_html),
|
||||
widget=SxExpr(widget_html))
|
||||
|
||||
@@ -137,10 +137,10 @@ def _entry_card_tile_html(entry, page_info: dict, pending_tickets: dict,
|
||||
if tp is not None:
|
||||
qty = pending_tickets.get(entry.id, 0)
|
||||
widget_html = sx_call("events-entry-tile-widget-wrapper",
|
||||
widget=SxExpr(_ticket_widget_html(entry, qty, ticket_url, ctx={})))
|
||||
widget=_ticket_widget_html(entry, qty, ticket_url, ctx={}))
|
||||
|
||||
return sx_call("events-entry-card-tile",
|
||||
title=SxExpr(title_html), badges=SxExpr(badges_html),
|
||||
title=title_html, badges=SxExpr(badges_html),
|
||||
time=SxExpr(time_html), cost=SxExpr(cost_html),
|
||||
widget=SxExpr(widget_html))
|
||||
|
||||
@@ -199,7 +199,7 @@ def _events_main_panel_html(ctx: dict, entries, has_more, pending_tickets, page_
|
||||
cls="px-3 py-12 text-center text-stone-400")
|
||||
|
||||
return sx_call("events-main-panel-body",
|
||||
toggle=SxExpr(toggle), body=SxExpr(body))
|
||||
toggle=toggle, body=body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -253,7 +253,7 @@ def _entry_main_panel_html(ctx: dict) -> str:
|
||||
# State
|
||||
state_html = _field("State", sx_call("events-entry-state-field",
|
||||
entry_id=str(eid),
|
||||
badge=SxExpr(_entry_state_badge_html(state))))
|
||||
badge=_entry_state_badge_html(state)))
|
||||
|
||||
# Cost
|
||||
cost = getattr(entry, "cost", None)
|
||||
@@ -284,7 +284,7 @@ def _entry_main_panel_html(ctx: dict) -> str:
|
||||
entry_posts = ctx.get("entry_posts") or []
|
||||
posts_html = _field("Associated Posts", sx_call("events-entry-posts-field",
|
||||
entry_id=str(eid),
|
||||
posts_panel=SxExpr(render_entry_posts_panel(entry_posts, entry, calendar, day, month, year))))
|
||||
posts_panel=render_entry_posts_panel(entry_posts, entry, calendar, day, month, year)))
|
||||
|
||||
# Options and Edit Button
|
||||
edit_url = url_for(
|
||||
@@ -295,12 +295,12 @@ def _entry_main_panel_html(ctx: dict) -> str:
|
||||
|
||||
return sx_call("events-entry-panel",
|
||||
entry_id=str(eid), list_container=list_container,
|
||||
name=SxExpr(name_html), slot=SxExpr(slot_html),
|
||||
time=SxExpr(time_html), state=SxExpr(state_html),
|
||||
cost=SxExpr(cost_html), tickets=SxExpr(tickets_html),
|
||||
buy=SxExpr(buy_html), date=SxExpr(date_html),
|
||||
posts=SxExpr(posts_html),
|
||||
options=SxExpr(_entry_options_html(entry, calendar, day, month, year)),
|
||||
name=name_html, slot=slot_html,
|
||||
time=time_html, state=state_html,
|
||||
cost=cost_html, tickets=tickets_html,
|
||||
buy=SxExpr(buy_html), date=date_html,
|
||||
posts=posts_html,
|
||||
options=_entry_options_html(entry, calendar, day, month, year),
|
||||
pre_action=pre_action, edit_url=edit_url)
|
||||
|
||||
|
||||
@@ -324,20 +324,20 @@ def _entry_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
year = ctx.get("year")
|
||||
|
||||
link_href = url_for(
|
||||
"calendar.day.calendar_entries.calendar_entry.get",
|
||||
"defpage_entry_detail",
|
||||
calendar_slug=cal_slug,
|
||||
year=year, month=month, day=day,
|
||||
entry_id=entry.id,
|
||||
)
|
||||
label_html = sx_call("events-entry-label",
|
||||
entry_id=str(entry.id),
|
||||
title=SxExpr(_entry_title_html(entry)),
|
||||
title=_entry_title_html(entry),
|
||||
times=SxExpr(_entry_times_html(entry)))
|
||||
|
||||
nav_html = _entry_nav_html(ctx)
|
||||
|
||||
return sx_call("menu-row-sx", id="entry-row", level=5,
|
||||
link_href=link_href, link_label_content=SxExpr(label_html),
|
||||
link_href=link_href, link_label_content=label_html,
|
||||
nav=SxExpr(nav_html) if nav_html else None, child_id="entry-header-child", oob=oob)
|
||||
|
||||
|
||||
@@ -391,14 +391,14 @@ def _entry_nav_html(ctx: dict) -> str:
|
||||
else:
|
||||
img_html = sx_call("events-post-img-placeholder")
|
||||
post_links += sx_call("events-entry-nav-post-link",
|
||||
href=href, img=SxExpr(img_html), title=title)
|
||||
href=href, img=img_html, title=title)
|
||||
parts.append((sx_call("events-entry-posts-nav-oob",
|
||||
items=SxExpr(post_links))).replace(' :hx-swap-oob "true"', ''))
|
||||
|
||||
# Admin link
|
||||
if is_admin:
|
||||
admin_url = url_for(
|
||||
"calendar.day.calendar_entries.calendar_entry.admin.admin",
|
||||
"defpage_entry_admin",
|
||||
calendar_slug=cal_slug,
|
||||
day=day, month=month, year=year,
|
||||
entry_id=entry.id,
|
||||
@@ -420,7 +420,7 @@ def render_entry_optioned(entry, calendar, day, month, year) -> str:
|
||||
|
||||
return options + sx_call("events-entry-optioned-oob",
|
||||
entry_id=str(entry.id),
|
||||
title=SxExpr(title), state=SxExpr(state))
|
||||
title=title, state=state)
|
||||
|
||||
|
||||
def _entry_title_html(entry) -> str:
|
||||
@@ -428,7 +428,7 @@ def _entry_title_html(entry) -> str:
|
||||
state = getattr(entry, "state", "pending") or "pending"
|
||||
return sx_call("events-entry-title",
|
||||
name=entry.name,
|
||||
badge=SxExpr(_entry_state_badge_html(state)))
|
||||
badge=_entry_state_badge_html(state))
|
||||
|
||||
|
||||
def _entry_options_html(entry, calendar, day, month, year) -> str:
|
||||
@@ -550,9 +550,9 @@ def render_entry_posts_panel(entry_posts, entry, calendar, day, month, year) ->
|
||||
entry_id=eid, post_id=ep_id,
|
||||
)
|
||||
items += sx_call("events-entry-post-item",
|
||||
img=SxExpr(img_html), title=ep_title,
|
||||
img=img_html, title=ep_title,
|
||||
del_url=del_url, entry_id=eid_s,
|
||||
csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}')
|
||||
csrf_hdr={"X-CSRFToken": csrf})
|
||||
posts_html = sx_call("events-entry-posts-list", items=SxExpr(items))
|
||||
else:
|
||||
posts_html = sx_call("events-entry-posts-none")
|
||||
@@ -563,7 +563,7 @@ def render_entry_posts_panel(entry_posts, entry, calendar, day, month, year) ->
|
||||
)
|
||||
|
||||
return sx_call("events-entry-posts-panel",
|
||||
posts=SxExpr(posts_html), search_url=search_url,
|
||||
posts=posts_html, search_url=search_url,
|
||||
entry_id=eid_s)
|
||||
|
||||
|
||||
@@ -591,7 +591,7 @@ def render_entry_posts_nav_oob(entry_posts) -> str:
|
||||
if feat else sx_call("events-post-img-placeholder"))
|
||||
items += sx_call("events-entry-nav-post",
|
||||
href=href, nav_btn=nav_btn,
|
||||
img=SxExpr(img_html), title=title)
|
||||
img=img_html, title=title)
|
||||
|
||||
return sx_call("events-entry-posts-nav-oob", items=SxExpr(items))
|
||||
|
||||
@@ -614,7 +614,7 @@ def render_day_entries_nav_oob(confirmed_entries, calendar, day_date) -> str:
|
||||
items = ""
|
||||
for entry in confirmed_entries:
|
||||
href = url_for(
|
||||
"calendar.day.calendar_entries.calendar_entry.get",
|
||||
"defpage_entry_detail",
|
||||
calendar_slug=cal_slug,
|
||||
year=day_date.year, month=day_date.month, day=day_date.day,
|
||||
entry_id=entry.id,
|
||||
@@ -735,7 +735,7 @@ def _entry_admin_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
year = ctx.get("year")
|
||||
|
||||
link_href = url_for(
|
||||
"calendar.day.calendar_entries.calendar_entry.admin.admin",
|
||||
"defpage_entry_admin",
|
||||
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=entry.id,
|
||||
)
|
||||
# Nav: ticket_types link
|
||||
@@ -743,7 +743,7 @@ def _entry_admin_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
|
||||
return sx_call("menu-row-sx", id="entry-admin-row", level=6,
|
||||
link_href=link_href, link_label="admin", icon="fa fa-cog",
|
||||
nav=SxExpr(nav_html) if nav_html else None, child_id="entry-admin-header-child", oob=oob)
|
||||
nav=nav_html or None, child_id="entry-admin-header-child", oob=oob)
|
||||
|
||||
|
||||
def _entry_admin_nav_html(ctx: dict) -> str:
|
||||
@@ -822,7 +822,7 @@ def render_post_search_results(search_posts, search_query, page, total_pages,
|
||||
|
||||
parts.append(sx_call("events-post-search-item",
|
||||
post_url=post_url, entry_id=str(eid), csrf=csrf,
|
||||
post_id=str(sp.id), img=SxExpr(img_html), title=title))
|
||||
post_id=str(sp.id), img=img_html, title=title))
|
||||
|
||||
result = "".join(parts)
|
||||
|
||||
@@ -858,7 +858,7 @@ def render_entry_edit_form(entry, calendar, day, month, year, day_slots) -> str:
|
||||
|
||||
put_url = url_for("calendar.day.calendar_entries.calendar_entry.put",
|
||||
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid)
|
||||
cancel_url = url_for("calendar.day.calendar_entries.calendar_entry.get",
|
||||
cancel_url = url_for("defpage_entry_detail",
|
||||
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid)
|
||||
|
||||
# Slot picker
|
||||
@@ -882,7 +882,7 @@ def render_entry_edit_form(entry, calendar, day, month, year, day_slots) -> str:
|
||||
html = sx_call("events-entry-edit-form",
|
||||
entry_id=str(eid), list_container=list_container,
|
||||
put_url=put_url, cancel_url=cancel_url, csrf=csrf,
|
||||
name_val=entry.name or "", slot_picker=SxExpr(slot_picker_html),
|
||||
name_val=entry.name or "", slot_picker=slot_picker_html,
|
||||
start_val=start_val, end_val=end_val, cost_display=cost_display,
|
||||
ticket_price_val=tp_val, ticket_count_val=tc_val,
|
||||
action_btn=action_btn, cancel_btn=cancel_btn)
|
||||
@@ -920,7 +920,7 @@ def render_entry_add_form(calendar, day, month, year, day_slots) -> str:
|
||||
|
||||
html = sx_call("events-entry-add-form",
|
||||
post_url=post_url, csrf=csrf,
|
||||
slot_picker=SxExpr(slot_picker_html),
|
||||
slot_picker=slot_picker_html,
|
||||
action_btn=action_btn, cancel_btn=cancel_btn,
|
||||
cancel_url=cancel_url)
|
||||
return html + _SLOT_PICKER_JS
|
||||
@@ -998,13 +998,13 @@ def render_fragment_account_tickets(tickets) -> str:
|
||||
items_html += sx_call("events-frag-ticket-item",
|
||||
href=href, entry_name=ticket.entry_name,
|
||||
date_str=date_str, calendar_name=cal_name,
|
||||
type_name=type_name, badge=SxExpr(badge_html))
|
||||
type_name=type_name, badge=badge_html)
|
||||
body = sx_call("events-frag-tickets-list", items=SxExpr(items_html))
|
||||
else:
|
||||
body = sx_call("empty-state", message="No tickets yet.",
|
||||
cls="text-sm text-stone-500")
|
||||
|
||||
return sx_call("events-frag-tickets-panel", items=SxExpr(body))
|
||||
return sx_call("events-frag-tickets-panel", items=body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1033,10 +1033,10 @@ def render_fragment_account_bookings(bookings) -> str:
|
||||
name=booking.name,
|
||||
date_str=date_str + date_str_extra,
|
||||
calendar_name=cal_name, cost_str=cost_str,
|
||||
badge=SxExpr(badge_html))
|
||||
badge=badge_html)
|
||||
body = sx_call("events-frag-bookings-list", items=SxExpr(items_html))
|
||||
else:
|
||||
body = sx_call("empty-state", message="No bookings yet.",
|
||||
cls="text-sm text-stone-500")
|
||||
|
||||
return sx_call("events-frag-bookings-panel", items=SxExpr(body))
|
||||
return sx_call("events-frag-bookings-panel", items=body)
|
||||
|
||||
@@ -1,89 +1,235 @@
|
||||
;; Events pages — auto-mounted with absolute paths
|
||||
;; All helpers return data dicts — markup composition in SX.
|
||||
|
||||
;; Calendar admin
|
||||
(defpage calendar-admin
|
||||
:path "/<slug>/<calendar_slug>/admin/"
|
||||
:auth :admin
|
||||
:layout :events-calendar-admin
|
||||
:content (calendar-admin-content calendar-slug))
|
||||
:data (calendar-admin-data calendar-slug)
|
||||
:content (~events-calendar-admin-panel
|
||||
:description-content (~events-calendar-description-display
|
||||
:description cal-description :edit-url desc-edit-url)
|
||||
:csrf csrf :description cal-description))
|
||||
|
||||
;; Day admin
|
||||
(defpage day-admin
|
||||
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/admin/"
|
||||
:auth :admin
|
||||
:layout :events-day-admin
|
||||
:content (day-admin-content calendar-slug year month day))
|
||||
:data (day-admin-data calendar-slug year month day)
|
||||
:content (~events-day-admin-panel))
|
||||
|
||||
;; Slots listing
|
||||
(defpage slots-listing
|
||||
:path "/<slug>/<calendar_slug>/slots/"
|
||||
:auth :public
|
||||
:layout :events-slots
|
||||
:content (slots-content calendar-slug))
|
||||
:data (slots-data calendar-slug)
|
||||
:content (~events-slots-table
|
||||
:list-container list-container
|
||||
:rows (if has-slots
|
||||
(<> (map (fn (s)
|
||||
(~events-slots-row
|
||||
:tr-cls tr-cls :slot-href (get s "slot-href")
|
||||
:pill-cls pill-cls :hx-select hx-select
|
||||
:slot-name (get s "name") :description (get s "description")
|
||||
:flexible (get s "flexible")
|
||||
:days (if (get s "has-days")
|
||||
(~events-slot-days-pills :days-inner
|
||||
(<> (map (fn (d) (~events-slot-day-pill :day d)) (get s "day-list"))))
|
||||
(~events-slot-no-days))
|
||||
:time-str (get s "time-str")
|
||||
:cost-str (get s "cost-str") :action-btn action-btn
|
||||
:del-url (get s "del-url")
|
||||
:csrf-hdr csrf-hdr))
|
||||
slots-list))
|
||||
(~events-slots-empty-row))
|
||||
:pre-action pre-action :add-url add-url))
|
||||
|
||||
;; Slot detail
|
||||
(defpage slot-detail
|
||||
:path "/<slug>/<calendar_slug>/slots/<int:slot_id>/"
|
||||
:auth :admin
|
||||
:layout :events-slot
|
||||
:content (slot-content calendar-slug slot-id))
|
||||
:data (slot-data calendar-slug slot-id)
|
||||
:content (~events-slot-panel
|
||||
:slot-id slot-id-str
|
||||
:list-container list-container
|
||||
:days (if has-days
|
||||
(~events-slot-days-pills :days-inner
|
||||
(<> (map (fn (d) (~events-slot-day-pill :day d)) day-list)))
|
||||
(~events-slot-no-days))
|
||||
:flexible flexible
|
||||
:time-str time-str :cost-str cost-str
|
||||
:pre-action pre-action :edit-url edit-url))
|
||||
|
||||
;; Entry detail
|
||||
(defpage entry-detail
|
||||
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/"
|
||||
:auth :admin
|
||||
:layout :events-entry
|
||||
:content (entry-content calendar-slug entry-id)
|
||||
:menu (entry-menu calendar-slug entry-id))
|
||||
:data (entry-data calendar-slug entry-id)
|
||||
:content (~events-entry-panel
|
||||
:entry-id entry-id-str :list-container list-container
|
||||
:name (~events-entry-field :label "Name"
|
||||
:content (~events-entry-name-field :name entry-name))
|
||||
:slot (~events-entry-field :label "Slot"
|
||||
:content (if has-slot
|
||||
(~events-entry-slot-assigned :slot-name slot-name :flex-label flex-label)
|
||||
(~events-entry-slot-none)))
|
||||
:time (~events-entry-field :label "Time Period"
|
||||
:content (~events-entry-time-field :time-str time-str))
|
||||
:state (~events-entry-field :label "State"
|
||||
:content (~events-entry-state-field :entry-id entry-id-str
|
||||
:badge (~badge :cls state-badge-cls :label state-badge-label)))
|
||||
:cost (~events-entry-field :label "Cost"
|
||||
:content (~events-entry-cost-field :cost cost-str))
|
||||
:tickets (~events-entry-field :label "Tickets"
|
||||
:content (~events-entry-tickets-field :entry-id entry-id-str
|
||||
:tickets-config tickets-config))
|
||||
:buy buy-form
|
||||
:date (~events-entry-field :label "Date"
|
||||
:content (~events-entry-date-field :date-str date-str))
|
||||
:posts (~events-entry-field :label "Associated Posts"
|
||||
:content (~events-entry-posts-field :entry-id entry-id-str
|
||||
:posts-panel posts-panel))
|
||||
:options options-html
|
||||
:pre-action pre-action :edit-url edit-url)
|
||||
:menu entry-menu)
|
||||
|
||||
;; Entry admin
|
||||
(defpage entry-admin
|
||||
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/admin/"
|
||||
:auth :admin
|
||||
:layout :events-entry-admin
|
||||
:content (entry-admin-content calendar-slug entry-id)
|
||||
:menu (admin-menu))
|
||||
:data (entry-admin-data calendar-slug entry-id year month day)
|
||||
:content (~nav-link :href ticket-types-href :label "ticket_types"
|
||||
:select-colours select-colours :aclass nav-btn :is-selected false)
|
||||
:menu (~events-admin-placeholder-nav))
|
||||
|
||||
;; Ticket types listing
|
||||
(defpage ticket-types-listing
|
||||
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/ticket-types/"
|
||||
:auth :public
|
||||
:layout :events-ticket-types
|
||||
:content (ticket-types-content calendar-slug entry-id year month day)
|
||||
:menu (admin-menu))
|
||||
:data (ticket-types-data calendar-slug entry-id year month day)
|
||||
:content (~events-ticket-types-table
|
||||
:list-container list-container
|
||||
:rows (if has-types
|
||||
(<> (map (fn (tt)
|
||||
(~events-ticket-types-row
|
||||
:tr-cls tr-cls :tt-href (get tt "tt-href")
|
||||
:pill-cls pill-cls :hx-select hx-select
|
||||
:tt-name (get tt "tt-name") :cost-str (get tt "cost-str")
|
||||
:count (get tt "count") :action-btn action-btn
|
||||
:del-url (get tt "del-url")
|
||||
:csrf-hdr csrf-hdr))
|
||||
types-list))
|
||||
(~events-ticket-types-empty-row))
|
||||
:action-btn action-btn :add-url add-url)
|
||||
:menu (~events-admin-placeholder-nav))
|
||||
|
||||
;; Ticket type detail
|
||||
(defpage ticket-type-detail
|
||||
:path "/<slug>/<calendar_slug>/day/<int:year>/<int:month>/<int:day>/entries/<int:entry_id>/ticket-types/<int:ticket_type_id>/"
|
||||
:auth :admin
|
||||
:layout :events-ticket-type
|
||||
:content (ticket-type-content calendar-slug entry-id ticket-type-id year month day)
|
||||
:menu (admin-menu))
|
||||
:data (ticket-type-data calendar-slug entry-id ticket-type-id year month day)
|
||||
:content (~events-ticket-type-panel
|
||||
:ticket-id ticket-id :list-container list-container
|
||||
:c1 (~events-ticket-type-col :label "Name" :value tt-name)
|
||||
:c2 (~events-ticket-type-col :label "Cost" :value cost-str)
|
||||
:c3 (~events-ticket-type-col :label "Count" :value count-str)
|
||||
:pre-action pre-action :edit-url edit-url)
|
||||
:menu (~events-admin-placeholder-nav))
|
||||
|
||||
;; My tickets
|
||||
(defpage my-tickets
|
||||
:path "/tickets/"
|
||||
:auth :public
|
||||
:layout :root
|
||||
:content (tickets-content))
|
||||
:data (tickets-data)
|
||||
:content (~events-tickets-panel
|
||||
:list-container list-container
|
||||
:has-tickets has-tickets
|
||||
:cards (when has-tickets
|
||||
(<> (map (fn (t)
|
||||
(~events-ticket-card
|
||||
:href (get t "href") :entry-name (get t "entry-name")
|
||||
:type-name (get t "type-name") :time-str (get t "time-str")
|
||||
:cal-name (get t "cal-name")
|
||||
:badge (~badge :cls (get t "badge-cls") :label (get t "badge-label"))
|
||||
:code-prefix (get t "code-prefix")))
|
||||
tickets-list)))))
|
||||
|
||||
;; Ticket detail
|
||||
(defpage ticket-detail
|
||||
:path "/tickets/<code>/"
|
||||
:auth :public
|
||||
:layout :root
|
||||
:content (ticket-detail-content code))
|
||||
:data (ticket-detail-data code)
|
||||
:content (~events-ticket-detail
|
||||
:list-container list-container :back-href back-href
|
||||
:header-bg header-bg :entry-name entry-name
|
||||
:badge (span :class (str "inline-flex items-center rounded-full px-3 py-1 text-sm font-medium " badge-cls)
|
||||
badge-label)
|
||||
:type-name type-name :code ticket-code
|
||||
:time-date time-date :time-range time-range
|
||||
:cal-name cal-name :type-desc type-desc :checkin-str checkin-str
|
||||
:qr-script qr-script))
|
||||
|
||||
;; Ticket admin dashboard
|
||||
(defpage ticket-admin
|
||||
:path "/admin/tickets/"
|
||||
:auth :admin
|
||||
:layout :root
|
||||
:content (ticket-admin-content))
|
||||
:data (ticket-admin-data)
|
||||
:content (~events-ticket-admin-panel
|
||||
:list-container list-container
|
||||
:stats (<> (map (fn (s)
|
||||
(~events-ticket-admin-stat
|
||||
:border (get s "border") :bg (get s "bg")
|
||||
:text-cls (get s "text-cls") :label-cls (get s "label-cls")
|
||||
:value (get s "value") :label (get s "label")))
|
||||
admin-stats))
|
||||
:lookup-url lookup-url :has-tickets has-tickets
|
||||
:rows (when has-tickets
|
||||
(<> (map (fn (t)
|
||||
(~events-ticket-admin-row
|
||||
:code (get t "code") :code-short (get t "code-short")
|
||||
:entry-name (get t "entry-name")
|
||||
:date (when (get t "date-str")
|
||||
(~events-ticket-admin-date :date-str (get t "date-str")))
|
||||
:type-name (get t "type-name")
|
||||
:badge (~badge :cls (get t "badge-cls") :label (get t "badge-label"))
|
||||
:action (if (get t "can-checkin")
|
||||
(~events-ticket-admin-checkin-form
|
||||
:checkin-url (get t "checkin-url") :code (get t "code") :csrf csrf)
|
||||
(when (get t "is-checked-in")
|
||||
(~events-ticket-admin-checked-in :time-str (get t "checkin-time"))))))
|
||||
admin-tickets)))))
|
||||
|
||||
;; Markets
|
||||
(defpage events-markets
|
||||
:path "/<slug>/markets/"
|
||||
:auth :public
|
||||
:layout :events-markets
|
||||
:content (markets-content))
|
||||
:data (markets-data)
|
||||
:content (~crud-panel
|
||||
:list-id "markets-list"
|
||||
:form (when can-create
|
||||
(~crud-create-form :create-url create-url :csrf csrf
|
||||
:errors-id "market-create-errors" :list-id "markets-list"
|
||||
:placeholder "e.g. Farm Shop, Bakery" :btn-label "Add market"))
|
||||
:list (if markets-list
|
||||
(<> (map (fn (m)
|
||||
(~crud-item :href (get m "href") :name (get m "name")
|
||||
:slug (get m "slug") :del-url (get m "del-url")
|
||||
:csrf-hdr (get m "csrf-hdr")
|
||||
:list-id "markets-list"
|
||||
:confirm-title "Delete market?"
|
||||
:confirm-text "Products will be hidden (soft delete)"))
|
||||
markets-list))
|
||||
(~empty-state :message "No markets yet. Create one above."
|
||||
:cls "text-gray-500 mt-4"))))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,288 +0,0 @@
|
||||
"""Layout registration + header builders."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
from .utils import _clear_deeper_oob, _ensure_container_nav
|
||||
from .calendar import (
|
||||
_post_header_sx, _calendar_header_sx, _calendar_admin_header_sx,
|
||||
_day_header_sx, _day_admin_header_sx, _markets_header_sx,
|
||||
)
|
||||
from .entries import _entry_header_html, _entry_admin_header_html
|
||||
from .slots import _slot_header_html
|
||||
from .tickets import _ticket_types_header_html, _ticket_type_header_html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layouts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _register_events_layouts() -> None:
|
||||
from shared.sx.layouts import register_custom_layout
|
||||
register_custom_layout("events-calendar-admin", _cal_admin_full, _cal_admin_oob)
|
||||
register_custom_layout("events-slots", _slots_full, _slots_oob)
|
||||
register_custom_layout("events-slot", _slot_full, _slot_oob)
|
||||
register_custom_layout("events-day-admin", _day_admin_full, _day_admin_oob)
|
||||
register_custom_layout("events-entry", _entry_full, _entry_oob)
|
||||
register_custom_layout("events-entry-admin", _entry_admin_full, _entry_admin_oob)
|
||||
register_custom_layout("events-ticket-types", _ticket_types_full, _ticket_types_oob)
|
||||
register_custom_layout("events-ticket-type", _ticket_type_full, _ticket_type_oob)
|
||||
register_custom_layout("events-markets", _markets_full, _markets_oob)
|
||||
|
||||
|
||||
# --- Calendar admin layout (root + post + child(post-admin + calendar + cal-admin)) ---
|
||||
|
||||
async def _cal_admin_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
return await render_to_sx_with_env("events-cal-admin-layout-full", {},
|
||||
post_header=SxExpr(await _post_header_sx(ctx)),
|
||||
admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")),
|
||||
calendar_header=SxExpr(_calendar_header_sx(ctx)),
|
||||
calendar_admin_header=SxExpr(_calendar_admin_header_sx(ctx)),
|
||||
)
|
||||
|
||||
|
||||
async def _cal_admin_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx, oob_header_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
return await render_to_sx_with_env("events-cal-admin-layout-oob", {},
|
||||
admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")),
|
||||
cal_oob=SxExpr(_calendar_header_sx(ctx, oob=True)),
|
||||
cal_admin_oob_wrap=SxExpr(await oob_header_sx("calendar-header-child",
|
||||
"calendar-admin-header-child", _calendar_admin_header_sx(ctx))),
|
||||
clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child",
|
||||
"post-admin-row", "post-admin-header-child",
|
||||
"calendar-row", "calendar-header-child",
|
||||
"calendar-admin-row", "calendar-admin-header-child")),
|
||||
)
|
||||
|
||||
|
||||
# --- Slots layout (same full as cal-admin but different OOB) ---
|
||||
|
||||
async def _slots_full(ctx: dict, **kw: Any) -> str:
|
||||
return await _cal_admin_full({**ctx, "is_admin_section": True}, **kw)
|
||||
|
||||
|
||||
async def _slots_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
ctx = await _ensure_container_nav({**ctx, "is_admin_section": True})
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
return await render_to_sx_with_env("events-slots-layout-oob", {},
|
||||
admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")),
|
||||
cal_admin_oob=SxExpr(_calendar_admin_header_sx(ctx, oob=True)),
|
||||
clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child",
|
||||
"post-admin-row", "post-admin-header-child",
|
||||
"calendar-row", "calendar-header-child",
|
||||
"calendar-admin-row", "calendar-admin-header-child")),
|
||||
)
|
||||
|
||||
|
||||
# --- Slot detail layout (extends cal-admin with slot header) ---
|
||||
|
||||
async def _slot_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
ctx = await _ensure_container_nav({**ctx, "is_admin_section": True})
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
return await render_to_sx_with_env("events-slot-layout-full", {},
|
||||
post_header=SxExpr(await _post_header_sx(ctx)),
|
||||
admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")),
|
||||
calendar_header=SxExpr(_calendar_header_sx(ctx)),
|
||||
calendar_admin_header=SxExpr(_calendar_admin_header_sx(ctx)),
|
||||
slot_header=SxExpr(_slot_header_html(ctx)),
|
||||
)
|
||||
|
||||
|
||||
async def _slot_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx, oob_header_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
ctx = await _ensure_container_nav({**ctx, "is_admin_section": True})
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
return await render_to_sx_with_env("events-slot-layout-oob", {},
|
||||
admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")),
|
||||
cal_admin_oob=SxExpr(_calendar_admin_header_sx(ctx, oob=True)),
|
||||
slot_oob_wrap=SxExpr(await oob_header_sx("calendar-admin-header-child",
|
||||
"slot-header-child", _slot_header_html(ctx))),
|
||||
clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child",
|
||||
"post-admin-row", "post-admin-header-child",
|
||||
"calendar-row", "calendar-header-child",
|
||||
"calendar-admin-row", "calendar-admin-header-child",
|
||||
"slot-row", "slot-header-child")),
|
||||
)
|
||||
|
||||
|
||||
# --- Day admin layout (root + post + post-admin + child(cal + day + day-admin)) ---
|
||||
|
||||
async def _day_admin_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
return await render_to_sx_with_env("events-day-admin-layout-full", {},
|
||||
post_header=SxExpr(await _post_header_sx(ctx)),
|
||||
admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")),
|
||||
calendar_header=SxExpr(_calendar_header_sx(ctx)),
|
||||
day_header=SxExpr(_day_header_sx(ctx)),
|
||||
day_admin_header=SxExpr(_day_admin_header_sx(ctx)),
|
||||
)
|
||||
|
||||
|
||||
async def _day_admin_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx, oob_header_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
return await render_to_sx_with_env("events-day-admin-layout-oob", {},
|
||||
admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")),
|
||||
cal_oob=SxExpr(_calendar_header_sx(ctx, oob=True)),
|
||||
day_admin_oob_wrap=SxExpr(await oob_header_sx("day-header-child",
|
||||
"day-admin-header-child", _day_admin_header_sx(ctx))),
|
||||
clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child",
|
||||
"post-admin-row", "post-admin-header-child",
|
||||
"calendar-row", "calendar-header-child",
|
||||
"day-row", "day-header-child",
|
||||
"day-admin-row", "day-admin-header-child")),
|
||||
)
|
||||
|
||||
|
||||
# --- Entry layout (root + child(post + cal + day + entry), + menu) ---
|
||||
|
||||
async def _entry_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env
|
||||
from shared.sx.parser import SxExpr
|
||||
return await render_to_sx_with_env("events-entry-layout-full", {},
|
||||
post_header=SxExpr(await _post_header_sx(ctx)),
|
||||
calendar_header=SxExpr(_calendar_header_sx(ctx)),
|
||||
day_header=SxExpr(_day_header_sx(ctx)),
|
||||
entry_header=SxExpr(_entry_header_html(ctx)),
|
||||
)
|
||||
|
||||
|
||||
async def _entry_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env, oob_header_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
return await render_to_sx_with_env("events-entry-layout-oob", {},
|
||||
day_oob=SxExpr(_day_header_sx(ctx, oob=True)),
|
||||
entry_oob_wrap=SxExpr(await oob_header_sx("day-header-child",
|
||||
"entry-header-child", _entry_header_html(ctx))),
|
||||
clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child",
|
||||
"calendar-row", "calendar-header-child",
|
||||
"day-row", "day-header-child",
|
||||
"entry-row", "entry-header-child")),
|
||||
)
|
||||
|
||||
|
||||
# --- Entry admin layout (root + post + child(post-admin + cal + day + entry + entry-admin), + menu) ---
|
||||
|
||||
async def _entry_admin_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
return await render_to_sx_with_env("events-entry-admin-layout-full", {},
|
||||
post_header=SxExpr(await _post_header_sx(ctx)),
|
||||
admin_header=SxExpr(await post_admin_header_sx(ctx, slug, selected="calendars")),
|
||||
calendar_header=SxExpr(_calendar_header_sx(ctx)),
|
||||
day_header=SxExpr(_day_header_sx(ctx)),
|
||||
entry_header=SxExpr(_entry_header_html(ctx)),
|
||||
entry_admin_header=SxExpr(_entry_admin_header_html(ctx)),
|
||||
)
|
||||
|
||||
|
||||
async def _entry_admin_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env, post_admin_header_sx, oob_header_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
ctx = await _ensure_container_nav(ctx)
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
return await render_to_sx_with_env("events-entry-admin-layout-oob", {},
|
||||
admin_oob=SxExpr(await post_admin_header_sx(ctx, slug, oob=True, selected="calendars")),
|
||||
entry_oob=SxExpr(_entry_header_html(ctx, oob=True)),
|
||||
entry_admin_oob_wrap=SxExpr(await oob_header_sx("entry-header-child",
|
||||
"entry-admin-header-child", _entry_admin_header_html(ctx))),
|
||||
clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child",
|
||||
"post-admin-row", "post-admin-header-child",
|
||||
"calendar-row", "calendar-header-child",
|
||||
"day-row", "day-header-child",
|
||||
"entry-row", "entry-header-child",
|
||||
"entry-admin-row", "entry-admin-header-child")),
|
||||
)
|
||||
|
||||
|
||||
# --- Ticket types layout (extends entry admin with ticket-types header, + menu) ---
|
||||
|
||||
async def _ticket_types_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env
|
||||
from shared.sx.parser import SxExpr
|
||||
return await render_to_sx_with_env("events-ticket-types-layout-full", {},
|
||||
post_header=SxExpr(await _post_header_sx(ctx)),
|
||||
calendar_header=SxExpr(_calendar_header_sx(ctx)),
|
||||
day_header=SxExpr(_day_header_sx(ctx)),
|
||||
entry_header=SxExpr(_entry_header_html(ctx)),
|
||||
entry_admin_header=SxExpr(_entry_admin_header_html(ctx)),
|
||||
ticket_types_header=SxExpr(_ticket_types_header_html(ctx)),
|
||||
)
|
||||
|
||||
|
||||
async def _ticket_types_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env, oob_header_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
return await render_to_sx_with_env("events-ticket-types-layout-oob", {},
|
||||
entry_admin_oob=SxExpr(_entry_admin_header_html(ctx, oob=True)),
|
||||
ticket_types_oob_wrap=SxExpr(await oob_header_sx("entry-admin-header-child",
|
||||
"ticket_types-header-child", _ticket_types_header_html(ctx))),
|
||||
)
|
||||
|
||||
|
||||
# --- Ticket type detail layout (extends ticket types with ticket-type header, + menu) ---
|
||||
|
||||
async def _ticket_type_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env
|
||||
from shared.sx.parser import SxExpr
|
||||
return await render_to_sx_with_env("events-ticket-type-layout-full", {},
|
||||
post_header=SxExpr(await _post_header_sx(ctx)),
|
||||
calendar_header=SxExpr(_calendar_header_sx(ctx)),
|
||||
day_header=SxExpr(_day_header_sx(ctx)),
|
||||
entry_header=SxExpr(_entry_header_html(ctx)),
|
||||
entry_admin_header=SxExpr(_entry_admin_header_html(ctx)),
|
||||
ticket_types_header=SxExpr(_ticket_types_header_html(ctx)),
|
||||
ticket_type_header=SxExpr(_ticket_type_header_html(ctx)),
|
||||
)
|
||||
|
||||
|
||||
async def _ticket_type_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env, oob_header_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
return await render_to_sx_with_env("events-ticket-type-layout-oob", {},
|
||||
ticket_types_oob=SxExpr(_ticket_types_header_html(ctx, oob=True)),
|
||||
ticket_type_oob_wrap=SxExpr(await oob_header_sx("ticket_types-header-child",
|
||||
"ticket_type-header-child", _ticket_type_header_html(ctx))),
|
||||
)
|
||||
|
||||
|
||||
# --- Markets layout (root + child(post + markets)) ---
|
||||
|
||||
async def _markets_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env
|
||||
from shared.sx.parser import SxExpr
|
||||
return await render_to_sx_with_env("events-markets-layout-full", {},
|
||||
post_header=SxExpr(await _post_header_sx(ctx)),
|
||||
markets_header=SxExpr(_markets_header_sx(ctx)),
|
||||
)
|
||||
|
||||
|
||||
async def _markets_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env, oob_header_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
return await render_to_sx_with_env("events-markets-layout-oob", {},
|
||||
post_oob=SxExpr(await _post_header_sx(ctx, oob=True)),
|
||||
markets_oob_wrap=SxExpr(await oob_header_sx("post-header-child",
|
||||
"markets-header-child", _markets_header_sx(ctx))),
|
||||
)
|
||||
@@ -160,7 +160,7 @@ def _slot_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
name=slot.name, description=desc)
|
||||
|
||||
return sx_call("menu-row-sx", id="slot-row", level=5,
|
||||
link_label_content=SxExpr(label_sx),
|
||||
link_label_content=label_sx,
|
||||
child_id="slot-header-child", oob=oob)
|
||||
|
||||
|
||||
@@ -201,7 +201,7 @@ def render_slot_main_panel(slot, calendar, *, oob: bool = False) -> str:
|
||||
|
||||
result = sx_call("events-slot-panel",
|
||||
slot_id=sid, list_container=list_container,
|
||||
days=SxExpr(days_html),
|
||||
days=days_html,
|
||||
flexible="yes" if flexible else "no",
|
||||
time_str=f"{time_start} \u2014 {time_end}",
|
||||
cost_str=cost_str,
|
||||
@@ -259,11 +259,11 @@ def render_slots_table(slots, calendar) -> str:
|
||||
pill_cls=pill_cls, hx_select=hx_select,
|
||||
slot_name=s.name, description=desc,
|
||||
flexible="yes" if s.flexible else "no",
|
||||
days=SxExpr(days_html),
|
||||
days=days_html,
|
||||
time_str=f"{time_start} - {time_end}",
|
||||
cost_str=cost_str, action_btn=action_btn,
|
||||
del_url=del_url,
|
||||
csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}')
|
||||
csrf_hdr={"X-CSRFToken": csrf})
|
||||
else:
|
||||
rows_html = sx_call("events-slots-empty-row")
|
||||
|
||||
@@ -343,7 +343,7 @@ def render_slot_add_form(calendar) -> str:
|
||||
|
||||
post_url = url_for("calendar.slots.post", calendar_slug=cal_slug)
|
||||
cancel_url = url_for("calendar.slots.add_button", calendar_slug=cal_slug)
|
||||
csrf_hdr = f'{{"X-CSRFToken": "{csrf}"}}'
|
||||
csrf_hdr = {"X-CSRFToken": csrf}
|
||||
|
||||
# Days checkboxes (all unchecked for add)
|
||||
day_keys = [("mon", "Mon"), ("tue", "Tue"), ("wed", "Wed"), ("thu", "Thu"),
|
||||
|
||||
@@ -36,7 +36,7 @@ def _ticket_widget_html(entry, qty: int, ticket_url: str, *, ctx: dict) -> str:
|
||||
return sx_call("events-tw-form",
|
||||
ticket_url=ticket_url, target=tgt,
|
||||
csrf=csrf_token_val, entry_id=str(eid),
|
||||
count_val=str(count_val), btn=SxExpr(btn_html))
|
||||
count_val=str(count_val), btn=btn_html)
|
||||
|
||||
if qty == 0:
|
||||
inner = _tw_form(1, sx_call("events-tw-cart-plus"))
|
||||
@@ -80,7 +80,7 @@ def _tickets_main_panel_html(ctx: dict, tickets: list) -> str:
|
||||
type_name=tt.name if tt else None,
|
||||
time_str=time_str or None,
|
||||
cal_name=cal.name if cal else None,
|
||||
badge=SxExpr(_ticket_state_badge_html(state)),
|
||||
badge=_ticket_state_badge_html(state),
|
||||
code_prefix=ticket.code[:8]))
|
||||
|
||||
cards_html = "".join(ticket_cards)
|
||||
@@ -193,7 +193,7 @@ def _ticket_admin_main_panel_html(ctx: dict, tickets: list, stats: dict) -> str:
|
||||
entry_name=entry.name if entry else "\u2014",
|
||||
date=SxExpr(date_html),
|
||||
type_name=tt.name if tt else "\u2014",
|
||||
badge=SxExpr(_ticket_state_badge_html(state)),
|
||||
badge=_ticket_state_badge_html(state),
|
||||
action=SxExpr(action_html))
|
||||
|
||||
return sx_call("events-ticket-admin-panel",
|
||||
@@ -238,7 +238,7 @@ def render_checkin_result(success: bool, error: str | None, ticket) -> str:
|
||||
entry_name=entry.name if entry else "\u2014",
|
||||
date=SxExpr(date_html),
|
||||
type_name=tt.name if tt else "\u2014",
|
||||
badge=SxExpr(_ticket_state_badge_html("checked_in")),
|
||||
badge=_ticket_state_badge_html("checked_in"),
|
||||
time_str=time_str)
|
||||
|
||||
|
||||
@@ -275,7 +275,7 @@ def render_lookup_result(ticket, error: str | None) -> str:
|
||||
if cal:
|
||||
info_html += sx_call("events-lookup-cal", cal_name=cal.name)
|
||||
info_html += sx_call("events-lookup-status",
|
||||
badge=SxExpr(_ticket_state_badge_html(state)), code=code)
|
||||
badge=_ticket_state_badge_html(state), code=code)
|
||||
if checked_in_at:
|
||||
info_html += sx_call("events-lookup-checkin-time",
|
||||
date_str=checked_in_at.strftime("%B %d, %Y at %H:%M"))
|
||||
@@ -328,7 +328,7 @@ def render_entry_tickets_admin(entry, tickets: list) -> str:
|
||||
rows_html += sx_call("events-entry-tickets-admin-row",
|
||||
code=code, code_short=code[:12] + "...",
|
||||
type_name=tt.name if tt else "\u2014",
|
||||
badge=SxExpr(_ticket_state_badge_html(state)),
|
||||
badge=_ticket_state_badge_html(state),
|
||||
action=SxExpr(action_html))
|
||||
|
||||
if tickets:
|
||||
@@ -340,7 +340,7 @@ def render_entry_tickets_admin(entry, tickets: list) -> str:
|
||||
return sx_call("events-entry-tickets-admin-panel",
|
||||
entry_name=entry.name,
|
||||
count_label=f"{count} ticket{suffix}",
|
||||
body=SxExpr(body_html))
|
||||
body=body_html)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -419,7 +419,7 @@ def render_ticket_types_table(ticket_types, entry, calendar, day, month, year) -
|
||||
tt_name=tt.name, cost_str=cost_str,
|
||||
count=str(tt.count), action_btn=action_btn,
|
||||
del_url=del_url,
|
||||
csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}')
|
||||
csrf_hdr={"X-CSRFToken": csrf})
|
||||
else:
|
||||
rows_html = sx_call("events-ticket-types-empty-row")
|
||||
|
||||
@@ -519,16 +519,16 @@ def render_buy_form(entry, ticket_remaining, ticket_sold_count,
|
||||
cost_str = f"\u00a3{tt.cost:.2f}" if tt.cost is not None else "\u00a30.00"
|
||||
type_items += sx_call("events-buy-type-item",
|
||||
type_name=tt.name, cost_str=cost_str,
|
||||
adjust_controls=SxExpr(_ticket_adjust_controls(csrf, adjust_url, target, eid, type_count, ticket_type_id=tt.id)))
|
||||
adjust_controls=_ticket_adjust_controls(csrf, adjust_url, target, eid, type_count, ticket_type_id=tt.id))
|
||||
body_html = sx_call("events-buy-types-wrapper", items=SxExpr(type_items))
|
||||
else:
|
||||
qty = user_ticket_count or 0
|
||||
body_html = sx_call("events-buy-default",
|
||||
price_str=f"\u00a3{tp:.2f}",
|
||||
adjust_controls=SxExpr(_ticket_adjust_controls(csrf, adjust_url, target, eid, qty)))
|
||||
adjust_controls=_ticket_adjust_controls(csrf, adjust_url, target, eid, qty))
|
||||
|
||||
return sx_call("events-buy-panel",
|
||||
entry_id=eid_s, info=SxExpr(info_html), body=SxExpr(body_html))
|
||||
entry_id=eid_s, info=SxExpr(info_html), body=body_html)
|
||||
|
||||
|
||||
def _ticket_adjust_controls(csrf, adjust_url, target, entry_id, count, *, ticket_type_id=None):
|
||||
@@ -543,8 +543,8 @@ def _ticket_adjust_controls(csrf, adjust_url, target, entry_id, count, *, ticket
|
||||
return sx_call("events-adjust-form",
|
||||
adjust_url=adjust_url, target=target,
|
||||
extra_cls=extra_cls, csrf=csrf,
|
||||
entry_id=eid_s, tt=SxExpr(tt_html) if tt_html else None,
|
||||
count_val=str(count_val), btn=SxExpr(btn_html))
|
||||
entry_id=eid_s, tt=tt_html or None,
|
||||
count_val=str(count_val), btn=btn_html)
|
||||
|
||||
if count == 0:
|
||||
return _adj_form(1, sx_call("events-adjust-cart-plus"),
|
||||
@@ -557,7 +557,7 @@ def _ticket_adjust_controls(csrf, adjust_url, target, entry_id, count, *, ticket
|
||||
plus = _adj_form(count + 1, sx_call("events-adjust-plus"))
|
||||
|
||||
return sx_call("events-adjust-controls",
|
||||
minus=SxExpr(minus), cart_icon=SxExpr(cart_icon), plus=SxExpr(plus))
|
||||
minus=minus, cart_icon=cart_icon, plus=plus)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -603,7 +603,7 @@ def _ticket_types_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
|
||||
return sx_call("menu-row-sx", id="ticket_types-row", level=7,
|
||||
link_href=link_href, link_label_content=SxExpr(label_html),
|
||||
nav=SxExpr(nav_html) if nav_html else None, child_id="ticket_type-header-child", oob=oob)
|
||||
nav=nav_html or None, child_id="ticket_type-header-child", oob=oob)
|
||||
|
||||
|
||||
|
||||
@@ -639,7 +639,7 @@ def _ticket_type_header_html(ctx: dict, *, oob: bool = False) -> str:
|
||||
|
||||
return sx_call("menu-row-sx", id="ticket_type-row", level=8,
|
||||
link_href=link_href, link_label_content=SxExpr(label_html),
|
||||
nav=SxExpr(nav_html) if nav_html else None, child_id="ticket_type-header-child-inner", oob=oob)
|
||||
nav=nav_html or None, child_id="ticket_type-header-child-inner", oob=oob)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -699,7 +699,7 @@ def render_ticket_type_add_form(entry, calendar, day, month, year) -> str:
|
||||
cancel_url = url_for("calendar.day.calendar_entries.calendar_entry.ticket_types.add_button",
|
||||
calendar_slug=cal_slug, entry_id=entry.id,
|
||||
year=year, month=month, day=day)
|
||||
csrf_hdr = f'{{"X-CSRFToken": "{csrf}"}}'
|
||||
csrf_hdr = {"X-CSRFToken": csrf}
|
||||
|
||||
return sx_call("events-ticket-type-add-form",
|
||||
post_url=post_url, csrf=csrf_hdr,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from shared.sx.helpers import sx_call
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -146,7 +145,7 @@ def _view_toggle_html(ctx: dict, view: str) -> str:
|
||||
list_href=list_href, tile_href=tile_href,
|
||||
hx_select=hx_select, list_cls=list_active,
|
||||
tile_cls=tile_active, storage_key="events_view",
|
||||
list_svg=SxExpr(_get_list_svg()), tile_svg=SxExpr(_get_tile_svg()))
|
||||
list_svg=_get_list_svg(), tile_svg=_get_tile_svg())
|
||||
|
||||
|
||||
def _cart_icon_oob(count: int) -> str:
|
||||
|
||||
@@ -31,7 +31,6 @@ async def _render_choose_username(*, actor=None, error="", username=""):
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from shared.config import config
|
||||
from shared.sx.helpers import sx_call
|
||||
from shared.sx.parser import SxExpr
|
||||
from shared.sx.page import get_template_context
|
||||
from sxc.pages.utils import _social_page
|
||||
from markupsafe import escape
|
||||
@@ -45,7 +44,7 @@ async def _render_choose_username(*, actor=None, error="", username=""):
|
||||
content = sx_call(
|
||||
"federation-choose-username",
|
||||
domain=str(escape(ap_domain)),
|
||||
error=SxExpr(error_sx) if error_sx else None,
|
||||
error=error_sx or None,
|
||||
csrf=csrf, username=str(escape(username)),
|
||||
check_url=check_url,
|
||||
)
|
||||
|
||||
@@ -212,7 +212,6 @@ def register(url_prefix="/social"):
|
||||
"""Re-render interaction buttons after a like/boost action."""
|
||||
from shared.models.federation import APInteraction
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from shared.sx.parser import SxExpr
|
||||
from sqlalchemy import select
|
||||
|
||||
svc = services.federation
|
||||
@@ -290,9 +289,9 @@ def register(url_prefix="/social"):
|
||||
count=str(boost_count))
|
||||
|
||||
return sx_response(sx_call("federation-interaction-buttons",
|
||||
like=SxExpr(like_form),
|
||||
boost=SxExpr(boost_form),
|
||||
reply=SxExpr(reply_sx) if reply_sx else None))
|
||||
like=like_form,
|
||||
boost=boost_form,
|
||||
reply=reply_sx or None))
|
||||
|
||||
# -- Following / Followers pagination --------------------------------------
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
;; Federation link-card fragment handler
|
||||
;; returns: sx
|
||||
;;
|
||||
;; Renders actor profile preview card(s) by username.
|
||||
;; Supports single mode (?slug=x or ?username=x) and batch mode (?keys=x,y,z).
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
;; Federation layout defcomps — read ctx values from env free variables.
|
||||
;; `actor` is injected into env by the layout registration in __init__.py.
|
||||
;; Federation layout defcomps — fully self-contained via IO primitives.
|
||||
;; Registered via register_sx_layout("social", ...) in __init__.py.
|
||||
|
||||
;; Full page: root header + social header in header-child
|
||||
(defcomp ~social-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~header-child-sx
|
||||
:inner (~federation-social-header
|
||||
:nav (~federation-social-nav :actor actor)))))
|
||||
:nav (~federation-social-nav :actor (federation-actor-ctx))))))
|
||||
|
||||
;; OOB (HTMX): social header oob + root header oob
|
||||
(defcomp ~social-layout-oob ()
|
||||
(<> (~oob-header-sx
|
||||
:parent-id "root-header-child"
|
||||
:row (~federation-social-header
|
||||
:nav (~federation-social-nav :actor actor)))
|
||||
:nav (~federation-social-nav :actor (federation-actor-ctx))))
|
||||
(~root-header-auto true)))
|
||||
|
||||
@@ -15,6 +15,5 @@ def _load_federation_page_files() -> None:
|
||||
|
||||
|
||||
def _register_federation_layouts() -> None:
|
||||
from shared.sx.layouts import register_custom_layout
|
||||
from .utils import _social_full, _social_oob
|
||||
register_custom_layout("social", _social_full, _social_oob)
|
||||
from shared.sx.layouts import register_sx_layout
|
||||
register_sx_layout("social", "social-layout-full", "social-layout-oob")
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Federation page utilities — serializers, actor helpers, social page builder."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _serialize_actor(actor) -> dict | None:
|
||||
"""Serialize an actor profile to a dict for sx defcomps."""
|
||||
@@ -24,7 +22,7 @@ def _serialize_remote_actor(a) -> dict:
|
||||
|
||||
async def _social_page(ctx: dict, actor, *, content: str,
|
||||
title: str = "Rose Ash", meta_html: str = "") -> str:
|
||||
"""Build a full social page with social header."""
|
||||
"""Build a full social page with social header (non-defpage routes)."""
|
||||
from shared.sx.helpers import render_to_sx_with_env, full_page_sx
|
||||
from markupsafe import escape
|
||||
|
||||
@@ -47,22 +45,3 @@ def _require_actor():
|
||||
if not actor:
|
||||
abort(403, "You need to choose a federation username first")
|
||||
return actor
|
||||
|
||||
|
||||
def _actor_data(ctx: dict) -> dict | None:
|
||||
actor = ctx.get("actor")
|
||||
if not actor:
|
||||
return None
|
||||
return _serialize_actor(actor)
|
||||
|
||||
|
||||
async def _social_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env
|
||||
env = {"actor": kw.get("actor") or _actor_data(ctx)}
|
||||
return await render_to_sx_with_env("social-layout-full", env)
|
||||
|
||||
|
||||
async def _social_oob(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env
|
||||
env = {"actor": kw.get("actor") or _actor_data(ctx)}
|
||||
return await render_to_sx_with_env("social-layout-oob", env)
|
||||
|
||||
@@ -228,6 +228,11 @@ def create_app() -> "Quart":
|
||||
("market", "container-nav", nav_params),
|
||||
], required=False)
|
||||
ctx["container_nav"] = events_nav + market_nav
|
||||
# Populate g._defpage_ctx for layout IO primitives
|
||||
if not hasattr(g, '_defpage_ctx'):
|
||||
g._defpage_ctx = {}
|
||||
g._defpage_ctx.setdefault("post", post_data.get("post"))
|
||||
g._defpage_ctx.setdefault("container_nav", ctx["container_nav"])
|
||||
return ctx
|
||||
|
||||
# --- oEmbed endpoint ---
|
||||
|
||||
@@ -95,3 +95,63 @@
|
||||
(if desc-content desc-content (when desc desc)))
|
||||
(if badge-content badge-content (when badge badge))))
|
||||
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Composition defcomps — receive data lists, compose component trees
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; Product cards grid with infinite scroll sentinels
|
||||
(defcomp ~market-product-cards-content (&key products page total-pages next-url
|
||||
mobile-sentinel-hs desktop-sentinel-hs)
|
||||
(<>
|
||||
(map (lambda (p)
|
||||
(~market-product-card
|
||||
:href (get p "href") :hx-select (get p "hx-select") :slug (get p "slug")
|
||||
:image (get p "image") :brand (get p "brand") :brand-highlight (get p "brand-highlight")
|
||||
:special-price (get p "special-price") :regular-price (get p "regular-price")
|
||||
:cart-action (get p "cart-action") :quantity (get p "quantity")
|
||||
:cart-href (get p "cart-href") :csrf (get p "csrf")
|
||||
:title (get p "title") :has-like (get p "has-like")
|
||||
:liked (get p "liked") :like-action (get p "like-action")
|
||||
:labels (get p "labels") :stickers (get p "stickers")
|
||||
:has-highlight (get p "has-highlight")
|
||||
:search-pre (get p "search-pre") :search-mid (get p "search-mid")
|
||||
:search-post (get p "search-post")))
|
||||
products)
|
||||
(if (< page total-pages)
|
||||
(<> (~sentinel-mobile :id (str "sentinel-" page "-m") :next-url next-url
|
||||
:hyperscript mobile-sentinel-hs)
|
||||
(~sentinel-desktop :id (str "sentinel-" page "-d") :next-url next-url
|
||||
:hyperscript desktop-sentinel-hs))
|
||||
(~end-of-results))))
|
||||
|
||||
;; Single market card from data (handles conditional title/desc/badge)
|
||||
(defcomp ~market-card-from-data (&key name description href show-badge badge-href badge-title)
|
||||
(~market-market-card
|
||||
:title-content (if href
|
||||
(~market-market-card-title-link :href href :name name)
|
||||
(~market-market-card-title :name name))
|
||||
:desc-content (when description
|
||||
(~market-market-card-desc :description description))
|
||||
:badge-content (when (and show-badge badge-title)
|
||||
(~market-market-card-badge :href badge-href :title badge-title))))
|
||||
|
||||
;; Market cards list with infinite scroll sentinel
|
||||
(defcomp ~market-cards-content (&key markets page has-more next-url)
|
||||
(<>
|
||||
(map (lambda (m)
|
||||
(~market-card-from-data
|
||||
:name (get m "name") :description (get m "description")
|
||||
:href (get m "href") :show-badge (get m "show-badge")
|
||||
:badge-href (get m "badge-href") :badge-title (get m "badge-title")))
|
||||
markets)
|
||||
(when has-more
|
||||
(~sentinel-simple :id (str "sentinel-" page) :next-url next-url))))
|
||||
|
||||
;; Market landing page content from data
|
||||
(defcomp ~market-landing-from-data (&key excerpt feature-image html)
|
||||
(~market-landing-content :inner
|
||||
(<> (when excerpt (~market-landing-excerpt :text excerpt))
|
||||
(when feature-image (~market-landing-image :src feature-image))
|
||||
(when html (~market-landing-html :html html)))))
|
||||
|
||||
|
||||
@@ -92,3 +92,71 @@
|
||||
|
||||
(defcomp ~market-landing-content (&key inner)
|
||||
(<> (article :class "relative w-full" inner) (div :class "pb-8")))
|
||||
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Composition: product detail page from data
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; Gallery section from pre-computed data
|
||||
(defcomp ~market-detail-gallery-from-data (&key images labels brand like-data has-nav-buttons thumbs)
|
||||
(let ((like-sx (when like-data
|
||||
(~market-like-button
|
||||
:form-id (get like-data "form-id") :action (get like-data "action")
|
||||
:slug (get like-data "slug") :csrf (get like-data "csrf")
|
||||
:icon-cls (get like-data "icon-cls")))))
|
||||
(if images
|
||||
(<>
|
||||
(~market-detail-gallery
|
||||
:inner (~market-detail-gallery-inner
|
||||
:like like-sx
|
||||
:image (get (first images) "src") :alt (get (first images) "alt")
|
||||
:labels (when labels
|
||||
(<> (map (lambda (src) (~market-label-overlay :src src)) labels)))
|
||||
:brand brand)
|
||||
:nav (when has-nav-buttons (~market-detail-nav-buttons)))
|
||||
(when thumbs
|
||||
(~market-detail-thumbs :thumbs
|
||||
(<> (map (lambda (t)
|
||||
(~market-detail-thumb
|
||||
:title (get t "title") :src (get t "src") :alt (get t "alt")))
|
||||
thumbs)))))
|
||||
(~market-detail-no-image :like like-sx))))
|
||||
|
||||
;; Right column details from data
|
||||
(defcomp ~market-detail-info-from-data (&key extras desc-short desc-html sections)
|
||||
(~market-detail-right-col :inner
|
||||
(<>
|
||||
(when extras
|
||||
(~market-detail-extras :inner
|
||||
(<> (map (lambda (e)
|
||||
(if (= (get e "type") "unit-price")
|
||||
(~market-detail-unit-price :price (get e "value"))
|
||||
(~market-detail-case-size :size (get e "value"))))
|
||||
extras))))
|
||||
(when (or desc-short desc-html)
|
||||
(~market-detail-desc-wrapper :inner
|
||||
(<> (when desc-short (~market-detail-desc-short :text desc-short))
|
||||
(when desc-html (~market-detail-desc-html :html desc-html)))))
|
||||
(when sections
|
||||
(~market-detail-sections :items
|
||||
(<> (map (lambda (s)
|
||||
(~market-detail-section :title (get s "title") :html (get s "html")))
|
||||
sections)))))))
|
||||
|
||||
;; Full product detail layout from data
|
||||
(defcomp ~market-product-detail-from-data (&key images labels brand like-data
|
||||
has-nav-buttons thumbs sticker-items
|
||||
extras desc-short desc-html sections)
|
||||
(~market-detail-layout
|
||||
:gallery (~market-detail-gallery-from-data
|
||||
:images images :labels labels :brand brand :like-data like-data
|
||||
:has-nav-buttons has-nav-buttons :thumbs thumbs)
|
||||
:stickers (when sticker-items
|
||||
(~market-detail-stickers :items
|
||||
(<> (map (lambda (s)
|
||||
(~market-detail-sticker :src (get s "src") :name (get s "name")))
|
||||
sticker-items))))
|
||||
:details (~market-detail-info-from-data
|
||||
:extras extras :desc-short desc-short :desc-html desc-html
|
||||
:sections sections)))
|
||||
|
||||
@@ -122,3 +122,152 @@
|
||||
|
||||
(defcomp ~market-mobile-chip-brand-list (&key items)
|
||||
(ul items))
|
||||
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Composition defcomps — receive data, compose filter component trees
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; Sort option stickers from data
|
||||
(defcomp ~market-filter-sort-from-data (&key items)
|
||||
(~market-filter-sort-row :items
|
||||
(<> (map (lambda (s)
|
||||
(~market-filter-sort-item
|
||||
:href (get s "href") :hx-select (get s "hx-select")
|
||||
:ring-cls (get s "ring-cls") :src (get s "src") :label (get s "label")))
|
||||
items))))
|
||||
|
||||
;; Like filter from data
|
||||
(defcomp ~market-filter-like-from-data (&key href hx-select liked mobile)
|
||||
(~market-filter-like
|
||||
:href href :hx-select hx-select
|
||||
:icon-cls (if liked "fa-solid fa-heart text-red-500" "fa-regular fa-heart text-stone-400")
|
||||
:size-cls (if mobile "text-[40px]" "text-2xl")))
|
||||
|
||||
;; Label filter items from data
|
||||
(defcomp ~market-filter-labels-from-data (&key items hx-select)
|
||||
(<> (map (lambda (lb)
|
||||
(~market-filter-label-item
|
||||
:href (get lb "href") :hx-select hx-select
|
||||
:ring-cls (get lb "ring-cls") :src (get lb "src") :name (get lb "name")))
|
||||
items)))
|
||||
|
||||
;; Sticker filter items from data
|
||||
(defcomp ~market-filter-stickers-from-data (&key items hx-select)
|
||||
(~market-filter-stickers-row :items
|
||||
(<> (map (lambda (st)
|
||||
(~market-filter-sticker-item
|
||||
:href (get st "href") :hx-select hx-select
|
||||
:ring-cls (get st "ring-cls") :src (get st "src") :name (get st "name")
|
||||
:count-cls (get st "count-cls") :count (get st "count")))
|
||||
items))))
|
||||
|
||||
;; Brand filter items from data
|
||||
(defcomp ~market-filter-brands-from-data (&key items hx-select)
|
||||
(~market-filter-brands-panel :items
|
||||
(<> (map (lambda (br)
|
||||
(~market-filter-brand-item
|
||||
:href (get br "href") :hx-select hx-select
|
||||
:bg-cls (get br "bg-cls") :name-cls (get br "name-cls")
|
||||
:name (get br "name") :count (get br "count")))
|
||||
items))))
|
||||
|
||||
;; Subcategory selector from data
|
||||
(defcomp ~market-filter-subcategories-from-data (&key items hx-select all-href current-sub)
|
||||
(~market-filter-subcategory-panel :items
|
||||
(<>
|
||||
(~market-filter-subcategory-item
|
||||
:href all-href :hx-select hx-select
|
||||
:active-cls (if (not current-sub) " bg-stone-200 font-medium" "")
|
||||
:name "All")
|
||||
(map (lambda (sub)
|
||||
(~market-filter-subcategory-item
|
||||
:href (get sub "href") :hx-select hx-select
|
||||
:active-cls (get sub "active-cls") :name (get sub "name")))
|
||||
items))))
|
||||
|
||||
;; Desktop filter panel from data
|
||||
(defcomp ~market-desktop-filter-from-data (&key search-sx category-label
|
||||
sort-data like-data label-data
|
||||
sticker-data brand-data sub-data hx-select)
|
||||
(<>
|
||||
search-sx
|
||||
(~market-desktop-category-summary :inner
|
||||
(<>
|
||||
(~market-filter-category-label :label category-label)
|
||||
(when sort-data (~market-filter-sort-from-data :items sort-data))
|
||||
(~market-filter-like-labels-nav :inner
|
||||
(<>
|
||||
(~market-filter-like-from-data
|
||||
:href (get like-data "href") :hx-select hx-select
|
||||
:liked (get like-data "liked") :mobile false)
|
||||
(when label-data
|
||||
(~market-filter-labels-from-data :items label-data :hx-select hx-select))))
|
||||
(when sticker-data
|
||||
(~market-filter-stickers-from-data :items sticker-data :hx-select hx-select))
|
||||
(when sub-data
|
||||
(~market-filter-subcategories-from-data
|
||||
:items (get sub-data "items") :hx-select hx-select
|
||||
:all-href (get sub-data "all-href")
|
||||
:current-sub (get sub-data "current-sub")))))
|
||||
(~market-desktop-brand-summary
|
||||
:inner (when brand-data
|
||||
(~market-filter-brands-from-data :items brand-data :hx-select hx-select)))))
|
||||
|
||||
;; Mobile filter chips from active filter data
|
||||
(defcomp ~market-mobile-chips-from-data (&key sort-chip liked-chip label-chips sticker-chips brand-chips)
|
||||
(~market-mobile-chips-row :inner
|
||||
(<>
|
||||
(when sort-chip
|
||||
(~market-mobile-chip-sort :src (get sort-chip "src") :label (get sort-chip "label")))
|
||||
(when liked-chip
|
||||
(~market-mobile-chip-liked :inner
|
||||
(<>
|
||||
(~market-mobile-chip-liked-icon)
|
||||
(when (get liked-chip "count")
|
||||
(~market-mobile-chip-count
|
||||
:cls (get liked-chip "count-cls") :count (get liked-chip "count"))))))
|
||||
(when label-chips
|
||||
(~market-mobile-chip-list :items
|
||||
(<> (map (lambda (lc)
|
||||
(~market-mobile-chip-item :inner
|
||||
(<>
|
||||
(~market-mobile-chip-image :src (get lc "src") :name (get lc "name"))
|
||||
(when (get lc "count")
|
||||
(~market-mobile-chip-count :cls (get lc "count-cls") :count (get lc "count"))))))
|
||||
label-chips))))
|
||||
(when sticker-chips
|
||||
(~market-mobile-chip-list :items
|
||||
(<> (map (lambda (sc)
|
||||
(~market-mobile-chip-item :inner
|
||||
(<>
|
||||
(~market-mobile-chip-image :src (get sc "src") :name (get sc "name"))
|
||||
(when (get sc "count")
|
||||
(~market-mobile-chip-count :cls (get sc "count-cls") :count (get sc "count"))))))
|
||||
sticker-chips))))
|
||||
(when brand-chips
|
||||
(~market-mobile-chip-brand-list :items
|
||||
(<> (map (lambda (bc)
|
||||
(if (get bc "has-count")
|
||||
(~market-mobile-chip-brand :name (get bc "name") :count (get bc "count"))
|
||||
(~market-mobile-chip-brand-zero :name (get bc "name"))))
|
||||
brand-chips)))))))
|
||||
|
||||
;; Mobile filter content (expanded panel) from data
|
||||
(defcomp ~market-mobile-filter-content-from-data (&key sort-data like-data label-data
|
||||
sticker-data brand-data clear-href hx-select)
|
||||
(<>
|
||||
(when sort-data (~market-filter-sort-from-data :items sort-data))
|
||||
(when clear-href
|
||||
(~market-mobile-clear-filters :href clear-href :hx-select hx-select))
|
||||
(~market-mobile-like-labels-row :inner
|
||||
(<>
|
||||
(~market-filter-like-from-data
|
||||
:href (get like-data "href") :hx-select hx-select
|
||||
:liked (get like-data "liked") :mobile true)
|
||||
(when label-data
|
||||
(~market-filter-labels-from-data :items label-data :hx-select hx-select))))
|
||||
(when sticker-data
|
||||
(~market-filter-stickers-from-data :items sticker-data :hx-select hx-select))
|
||||
(when brand-data
|
||||
(~market-filter-brands-from-data :items brand-data :hx-select hx-select))))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
;; Market container-nav fragment handler
|
||||
;; returns: sx
|
||||
;;
|
||||
;; Renders marketplace link nav items for blog post pages.
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
;; Market link-card fragment handler
|
||||
;; returns: sx
|
||||
;;
|
||||
;; Renders product preview card(s) by slug.
|
||||
;; Supports single mode (?slug=x) and batch mode (?keys=x,y,z).
|
||||
|
||||
@@ -15,3 +15,64 @@
|
||||
:class "px-2 py-1 text-stone-500 hover:text-stone-700"
|
||||
(i :class "fa fa-cog" :aria-hidden "true")))
|
||||
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Composition defcomps — receive data, compose component trees
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; Desktop category nav from pre-computed category data
|
||||
(defcomp ~market-desktop-nav-from-data (&key categories hx-select select-colours
|
||||
all-href all-active admin-href)
|
||||
(~market-desktop-category-nav
|
||||
:links (<>
|
||||
(~market-category-link :href all-href :hx-select hx-select
|
||||
:active all-active :select-colours select-colours :label "All")
|
||||
(map (lambda (cat)
|
||||
(~market-category-link
|
||||
:href (get cat "href") :hx-select hx-select
|
||||
:active (get cat "active") :select-colours select-colours
|
||||
:label (get cat "label"))) categories))
|
||||
:admin (when admin-href
|
||||
(~market-admin-link :href admin-href :hx-select hx-select))))
|
||||
|
||||
;; Market-level header row from data
|
||||
(defcomp ~market-header-from-data (&key market-title top-slug sub-slug link-href
|
||||
categories hx-select select-colours
|
||||
all-href all-active admin-href oob)
|
||||
(~menu-row-sx :id "market-row" :level 2
|
||||
:link-href link-href
|
||||
:link-label-content (~market-shop-label
|
||||
:title market-title :top-slug (or top-slug "") :sub-div sub-slug)
|
||||
:nav (~market-desktop-nav-from-data
|
||||
:categories categories :hx-select hx-select :select-colours select-colours
|
||||
:all-href all-href :all-active all-active :admin-href admin-href)
|
||||
:child-id "market-header-child"
|
||||
:oob oob))
|
||||
|
||||
;; Product-level header row from data
|
||||
(defcomp ~market-product-header-from-data (&key title link-href hx-select
|
||||
price-data admin-href oob)
|
||||
(~menu-row-sx :id "product-row" :level 3
|
||||
:link-href link-href
|
||||
:link-label-content (~market-product-label :title title)
|
||||
:nav (<>
|
||||
(~market-prices-header-from-data
|
||||
:cart-id (get price-data "cart-id")
|
||||
:cart-action (get price-data "cart-action")
|
||||
:csrf (get price-data "csrf")
|
||||
:quantity (get price-data "quantity")
|
||||
:cart-href (get price-data "cart-href")
|
||||
:sp-val (get price-data "sp-val") :sp-str (get price-data "sp-str")
|
||||
:rp-val (get price-data "rp-val") :rp-str (get price-data "rp-str")
|
||||
:rrp-str (get price-data "rrp-str"))
|
||||
(when admin-href
|
||||
(~market-admin-link :href admin-href :hx-select hx-select)))
|
||||
:child-id "product-header-child"
|
||||
:oob oob))
|
||||
|
||||
;; Product admin header row from data
|
||||
(defcomp ~market-product-admin-header-from-data (&key link-href oob)
|
||||
(~menu-row-sx :id "product-admin-row" :level 4
|
||||
:link-href link-href :link-label "admin!!" :icon "fa fa-cog"
|
||||
:child-id "product-admin-header-child" :oob oob))
|
||||
|
||||
|
||||
@@ -1,42 +1,111 @@
|
||||
;; Market layout defcomps — root header via ~root-header-auto,
|
||||
;; market-specific headers passed as &key params.
|
||||
;; Market layout defcomps — fully self-contained via IO primitives.
|
||||
;; Registered via register_sx_layout in layouts.py.
|
||||
|
||||
;; --- Browse layout: root + post header + market header ---
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Auto-fetching market header macro
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~market-browse-layout-full (&key post-header market-header)
|
||||
(defmacro ~market-header-auto (oob)
|
||||
"Market header row using (market-header-ctx)."
|
||||
(quasiquote
|
||||
(let ((__mctx (market-header-ctx)))
|
||||
(~menu-row-sx :id "market-row" :level 2
|
||||
:link-href (get __mctx "link-href")
|
||||
:link-label-content (~market-shop-label
|
||||
:title (get __mctx "market-title")
|
||||
:top-slug (get __mctx "top-slug")
|
||||
:sub-div (get __mctx "sub-slug"))
|
||||
:nav (~market-desktop-nav-from-data
|
||||
:categories (get __mctx "categories")
|
||||
:hx-select (get __mctx "hx-select")
|
||||
:select-colours (get __mctx "select-colours")
|
||||
:all-href (get __mctx "all-href")
|
||||
:all-active (get __mctx "all-active")
|
||||
:admin-href (get __mctx "admin-href"))
|
||||
:child-id "market-header-child"
|
||||
:oob (unquote oob)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; OOB clear helpers
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~market-clear-oob ()
|
||||
"Clear OOB divs for browse level."
|
||||
(<>
|
||||
(~clear-oob-div :id "product-admin-row")
|
||||
(~clear-oob-div :id "product-admin-header-child")
|
||||
(~clear-oob-div :id "product-row")
|
||||
(~clear-oob-div :id "product-header-child")
|
||||
(~clear-oob-div :id "market-admin-row")
|
||||
(~clear-oob-div :id "market-admin-header-child")
|
||||
(~clear-oob-div :id "post-admin-row")
|
||||
(~clear-oob-div :id "post-admin-header-child")))
|
||||
|
||||
(defcomp ~market-clear-oob-admin ()
|
||||
"Clear OOB divs for admin level."
|
||||
(<>
|
||||
(~clear-oob-div :id "product-admin-row")
|
||||
(~clear-oob-div :id "product-admin-header-child")
|
||||
(~clear-oob-div :id "product-row")
|
||||
(~clear-oob-div :id "product-header-child")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Browse layout: root + post + market (self-contained)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~market-browse-layout-full ()
|
||||
(<> (~root-header-auto)
|
||||
(~header-child-sx :inner (<> post-header market-header))))
|
||||
(~header-child-sx
|
||||
:inner (<> (~post-header-auto nil)
|
||||
(~market-header-auto nil)))))
|
||||
|
||||
(defcomp ~market-browse-layout-oob (&key oob-header post-header-oob clear-oob)
|
||||
(<> oob-header post-header-oob clear-oob))
|
||||
(defcomp ~market-browse-layout-oob ()
|
||||
(<> (~post-header-auto true)
|
||||
(~oob-header-sx :parent-id "post-header-child"
|
||||
:row (~market-header-auto nil))
|
||||
(~market-clear-oob)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; --- Product layout: root + post + market + product ---
|
||||
(defcomp ~market-browse-layout-mobile ()
|
||||
(let ((__mctx (market-header-ctx)))
|
||||
(get __mctx "mobile-nav")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Market admin layout: root + post + market + post-admin (self-contained)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~market-admin-layout-full (&key selected)
|
||||
(<> (~root-header-auto)
|
||||
(~header-child-sx
|
||||
:inner (<> (~post-header-auto nil)
|
||||
(~market-header-auto nil)
|
||||
(~post-admin-header-auto nil selected)))))
|
||||
|
||||
(defcomp ~market-admin-layout-oob (&key selected)
|
||||
(<> (~market-header-auto true)
|
||||
(~oob-header-sx :parent-id "market-header-child"
|
||||
:row (~post-admin-header-auto nil selected))
|
||||
(~market-clear-oob-admin)
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Parameterized defcomps — used by renders.py (non-defpage routes)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; Product layout: root + post + market + product
|
||||
(defcomp ~market-product-layout-full (&key post-header market-header product-header)
|
||||
(<> (~root-header-auto)
|
||||
(~header-child-sx :inner (<> post-header market-header product-header))))
|
||||
|
||||
;; --- Product admin layout: root + post + market + product + admin ---
|
||||
|
||||
;; Product admin layout: root + post + market + product + admin
|
||||
(defcomp ~market-product-admin-layout-full (&key post-header market-header product-header admin-header)
|
||||
(<> (~root-header-auto)
|
||||
(~header-child-sx :inner (<> post-header market-header product-header admin-header))))
|
||||
|
||||
;; --- Market admin layout: root + post + market + market-admin ---
|
||||
|
||||
(defcomp ~market-admin-layout-full (&key post-header market-header admin-header)
|
||||
(<> (~root-header-auto)
|
||||
(~header-child-sx :inner (<> post-header market-header admin-header))))
|
||||
|
||||
(defcomp ~market-admin-layout-oob (&key market-header-oob admin-oob-header clear-oob)
|
||||
(<> market-header-oob admin-oob-header clear-oob))
|
||||
|
||||
;; --- OOB wrappers ---
|
||||
|
||||
;; OOB wrappers
|
||||
(defcomp ~market-oob-wrap (&key parts)
|
||||
(<> parts))
|
||||
|
||||
;; --- Content wrappers ---
|
||||
|
||||
;; Content wrappers
|
||||
(defcomp ~market-content-padded (&key content)
|
||||
(<> content (div :class "pb-8")))
|
||||
|
||||
@@ -17,3 +17,35 @@
|
||||
|
||||
(defcomp ~market-meta-jsonld (&key json)
|
||||
(script :type "application/ld+json" (~rich-text :html json)))
|
||||
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Composition: all product meta tags from data
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~market-product-meta-from-data (&key title description canonical image-url
|
||||
site-title brand price price-currency
|
||||
jsonld-json)
|
||||
(<>
|
||||
(~market-meta-title :title title)
|
||||
(~market-meta-description :description description)
|
||||
(when canonical (~market-meta-canonical :href canonical))
|
||||
;; OpenGraph
|
||||
(~market-meta-og :property "og:site_name" :content site-title)
|
||||
(~market-meta-og :property "og:type" :content "product")
|
||||
(~market-meta-og :property "og:title" :content title)
|
||||
(~market-meta-og :property "og:description" :content description)
|
||||
(when canonical (~market-meta-og :property "og:url" :content canonical))
|
||||
(when image-url (~market-meta-og :property "og:image" :content image-url))
|
||||
(when (and price price-currency)
|
||||
(<> (~market-meta-og :property "product:price:amount" :content price)
|
||||
(~market-meta-og :property "product:price:currency" :content price-currency)))
|
||||
(when brand (~market-meta-og :property "product:brand" :content brand))
|
||||
;; Twitter
|
||||
(~market-meta-twitter :name "twitter:card"
|
||||
:content (if image-url "summary_large_image" "summary"))
|
||||
(~market-meta-twitter :name "twitter:title" :content title)
|
||||
(~market-meta-twitter :name "twitter:description" :content description)
|
||||
(when image-url (~market-meta-twitter :name "twitter:image" :content image-url))
|
||||
;; JSON-LD
|
||||
(~market-meta-jsonld :json jsonld-json)))
|
||||
|
||||
@@ -61,3 +61,37 @@
|
||||
(defcomp ~market-mobile-cat-details (&key open summary subs)
|
||||
(details :class "group/cat py-1" :open open
|
||||
summary subs))
|
||||
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Composition: mobile nav panel from pre-computed category data
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~market-mobile-nav-from-data (&key categories all-href all-active hx-select select-colours)
|
||||
(~market-mobile-nav-wrapper :items
|
||||
(<>
|
||||
(~market-mobile-all-link :href all-href :hx-select hx-select
|
||||
:active all-active :select-colours select-colours)
|
||||
(map (lambda (cat)
|
||||
(~market-mobile-cat-details
|
||||
:open (get cat "active")
|
||||
:summary (~market-mobile-cat-summary
|
||||
:bg-cls (if (get cat "active") " bg-stone-900 text-white hover:bg-stone-900" "")
|
||||
:href (get cat "href") :hx-select hx-select
|
||||
:select-colours select-colours :cat-name (get cat "name")
|
||||
:count-label (str (get cat "count") " products")
|
||||
:count-str (str (get cat "count"))
|
||||
:chevron (~market-mobile-chevron))
|
||||
:subs (if (get cat "subs")
|
||||
(~market-mobile-subs-panel :links
|
||||
(<> (map (lambda (sub)
|
||||
(~market-mobile-sub-link
|
||||
:select-colours select-colours
|
||||
:active (get sub "active")
|
||||
:href (get sub "href") :hx-select hx-select
|
||||
:label (get sub "label")
|
||||
:count-label (str (get sub "count") " products")
|
||||
:count-str (str (get sub "count"))))
|
||||
(get cat "subs"))))
|
||||
(~market-mobile-view-all :href (get cat "href") :hx-select hx-select))))
|
||||
categories))))
|
||||
|
||||
@@ -32,3 +32,36 @@
|
||||
|
||||
(defcomp ~market-prices-row (&key inner)
|
||||
(div :class "flex flex-row items-center justify-between md:gap-2 md:px-2" inner))
|
||||
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Composition: prices header + cart button from data
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~market-prices-header-from-data (&key cart-id cart-action csrf quantity cart-href
|
||||
sp-val sp-str rp-val rp-str rrp-str)
|
||||
(~market-prices-row :inner
|
||||
(<>
|
||||
(if quantity
|
||||
(~market-cart-add-quantity :cart-id cart-id :action cart-action :csrf csrf
|
||||
:minus-val (str (- quantity 1)) :plus-val (str (+ quantity 1))
|
||||
:quantity (str quantity) :cart-href cart-href)
|
||||
(~market-cart-add-empty :cart-id cart-id :action cart-action :csrf csrf))
|
||||
(when sp-val
|
||||
(<> (~market-header-price-special-label)
|
||||
(~market-header-price-special :price sp-str)
|
||||
(when rp-val (~market-header-price-strike :price rp-str))))
|
||||
(when (and (not sp-val) rp-val)
|
||||
(<> (~market-header-price-regular-label)
|
||||
(~market-header-price-regular :price rp-str)))
|
||||
(when rrp-str (~market-header-rrp :rrp rrp-str)))))
|
||||
|
||||
;; Card price line from data (used in product cards)
|
||||
(defcomp ~market-card-price-from-data (&key sp-val sp-str rp-val rp-str)
|
||||
(~market-price-line :inner
|
||||
(<>
|
||||
(when sp-val
|
||||
(<> (~market-price-special :price sp-str)
|
||||
(when rp-val (~market-price-regular-strike :price rp-str))))
|
||||
(when (and (not sp-val) rp-val)
|
||||
(~market-price-regular :price rp-str)))))
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Product/market card builders."""
|
||||
"""Product/market card data builders."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from shared.sx.parser import SxExpr
|
||||
from shared.sx.helpers import sx_call
|
||||
|
||||
from .utils import _set_prices, _price_str
|
||||
@@ -11,11 +10,11 @@ from .filters import _MOBILE_SENTINEL_HS, _DESKTOP_SENTINEL_HS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Product card (browse grid item)
|
||||
# Product card data extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _product_card_sx(p: dict, ctx: dict) -> str:
|
||||
"""Build a single product card for browse grid as sx call."""
|
||||
def _product_card_data(p: dict, ctx: dict) -> dict:
|
||||
"""Extract data for a single product card."""
|
||||
from quart import url_for
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from shared.utils import route_prefix
|
||||
@@ -49,7 +48,8 @@ def _product_card_sx(p: dict, ctx: dict) -> str:
|
||||
if raw_stickers and callable(asset_url_fn):
|
||||
for s in raw_stickers:
|
||||
ring = " ring-2 ring-emerald-500 rounded" if s in selected_stickers else ""
|
||||
sticker_data.append({"src": asset_url_fn(f"stickers/{s}.svg"), "name": s, "ring-cls": ring})
|
||||
sticker_data.append({"src": asset_url_fn(f"stickers/{s}.svg"),
|
||||
"name": s, "ring-cls": ring})
|
||||
|
||||
# Title highlighting
|
||||
title = p.get("title", "")
|
||||
@@ -71,38 +71,37 @@ def _product_card_sx(p: dict, ctx: dict) -> str:
|
||||
brand = p.get("brand", "")
|
||||
brand_highlight = " bg-yellow-200" if brand in selected_brands else ""
|
||||
|
||||
kwargs = dict(
|
||||
href=item_href, hx_select=hx_select, slug=slug,
|
||||
image=p.get("image", ""), brand=brand, brand_highlight=brand_highlight,
|
||||
special_price=sp_str, regular_price=rp_str,
|
||||
cart_action=cart_action, quantity=quantity, cart_href=cart_href, csrf=csrf,
|
||||
title=title,
|
||||
has_like=bool(user),
|
||||
)
|
||||
d: dict[str, Any] = {
|
||||
"href": item_href, "hx-select": hx_select, "slug": slug,
|
||||
"image": p.get("image", ""), "brand": brand, "brand-highlight": brand_highlight,
|
||||
"special-price": sp_str, "regular-price": rp_str,
|
||||
"cart-action": cart_action, "quantity": quantity, "cart-href": cart_href, "csrf": csrf,
|
||||
"title": title, "has-like": bool(user),
|
||||
}
|
||||
|
||||
if label_srcs:
|
||||
kwargs["labels"] = label_srcs
|
||||
d["labels"] = label_srcs
|
||||
elif labels:
|
||||
kwargs["labels"] = labels
|
||||
d["labels"] = labels
|
||||
|
||||
if user:
|
||||
kwargs["liked"] = p.get("is_liked", False)
|
||||
kwargs["like_action"] = url_for("market.browse.product.like_toggle", product_slug=slug)
|
||||
d["liked"] = p.get("is_liked", False)
|
||||
d["like-action"] = url_for("market.browse.product.like_toggle", product_slug=slug)
|
||||
|
||||
if sticker_data:
|
||||
kwargs["stickers"] = sticker_data
|
||||
d["stickers"] = sticker_data
|
||||
|
||||
if has_highlight:
|
||||
kwargs["has_highlight"] = True
|
||||
kwargs["search_pre"] = search_pre
|
||||
kwargs["search_mid"] = search_mid
|
||||
kwargs["search_post"] = search_post
|
||||
d["has-highlight"] = True
|
||||
d["search-pre"] = search_pre
|
||||
d["search-mid"] = search_mid
|
||||
d["search-post"] = search_post
|
||||
|
||||
return sx_call("market-product-card", **kwargs)
|
||||
return d
|
||||
|
||||
|
||||
def _product_cards_sx(ctx: dict) -> str:
|
||||
"""S-expression wire format for product cards (client renders)."""
|
||||
"""S-expression wire format for product cards — delegates to .sx defcomp."""
|
||||
from shared.utils import route_prefix
|
||||
|
||||
prefix = route_prefix()
|
||||
@@ -112,48 +111,46 @@ def _product_cards_sx(ctx: dict) -> str:
|
||||
current_local_href = ctx.get("current_local_href", "/")
|
||||
qs_fn = ctx.get("qs_filter")
|
||||
|
||||
parts = []
|
||||
for p in products:
|
||||
parts.append(_product_card_sx(p, ctx))
|
||||
product_data = [_product_card_data(p, ctx) for p in products]
|
||||
|
||||
next_url = ""
|
||||
if page < total_pages:
|
||||
if callable(qs_fn):
|
||||
next_qs = qs_fn({"page": page + 1})
|
||||
else:
|
||||
next_qs = f"?page={page + 1}"
|
||||
next_url = prefix + current_local_href + next_qs
|
||||
parts.append(sx_call("sentinel-mobile",
|
||||
id=f"sentinel-{page}-m", next_url=next_url,
|
||||
hyperscript=_MOBILE_SENTINEL_HS))
|
||||
parts.append(sx_call("sentinel-desktop",
|
||||
id=f"sentinel-{page}-d", next_url=next_url,
|
||||
hyperscript=_DESKTOP_SENTINEL_HS))
|
||||
else:
|
||||
parts.append(sx_call("end-of-results"))
|
||||
|
||||
return "(<> " + " ".join(parts) + ")"
|
||||
return sx_call("market-product-cards-content",
|
||||
products=product_data,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
next_url=next_url,
|
||||
mobile_sentinel_hs=_MOBILE_SENTINEL_HS,
|
||||
desktop_sentinel_hs=_DESKTOP_SENTINEL_HS)
|
||||
|
||||
|
||||
def _like_button_sx(slug: str, liked: bool, csrf: str, ctx: dict) -> str:
|
||||
"""Build the like/unlike heart button overlay as sx."""
|
||||
def _like_button_data(slug: str, liked: bool, csrf: str, ctx: dict) -> dict:
|
||||
"""Extract like button data."""
|
||||
from quart import url_for
|
||||
|
||||
action = url_for("market.browse.product.like_toggle", product_slug=slug)
|
||||
icon_cls = "fa-solid fa-heart text-red-500" if liked else "fa-regular fa-heart text-stone-400"
|
||||
return sx_call(
|
||||
"market-like-button",
|
||||
form_id=f"like-{slug}", action=action, slug=slug,
|
||||
csrf=csrf, icon_cls=icon_cls,
|
||||
)
|
||||
return {
|
||||
"form-id": f"like-{slug}",
|
||||
"action": action,
|
||||
"slug": slug,
|
||||
"csrf": csrf,
|
||||
"icon-cls": icon_cls,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Market cards (all markets / page markets)
|
||||
# Market cards data extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _market_card_sx(market: Any, page_info: dict, *, show_page_badge: bool = True,
|
||||
post_slug: str = "") -> str:
|
||||
"""Build a single market card as sx."""
|
||||
def _market_card_data(market: Any, page_info: dict, *, show_page_badge: bool = True,
|
||||
post_slug: str = "") -> dict:
|
||||
"""Extract data for a single market card."""
|
||||
from shared.infrastructure.urls import market_url
|
||||
|
||||
name = getattr(market, "name", "")
|
||||
@@ -161,78 +158,60 @@ def _market_card_sx(market: Any, page_info: dict, *, show_page_badge: bool = Tru
|
||||
slug = getattr(market, "slug", "")
|
||||
container_id = getattr(market, "container_id", None)
|
||||
|
||||
href = ""
|
||||
badge_href = ""
|
||||
badge_title = ""
|
||||
|
||||
if show_page_badge and page_info:
|
||||
pi = page_info.get(container_id, {})
|
||||
p_slug = pi.get("slug", "")
|
||||
p_title = pi.get("title", "")
|
||||
market_href = market_url(f"/{p_slug}/{slug}/") if p_slug else ""
|
||||
href = market_url(f"/{p_slug}/{slug}/") if p_slug else ""
|
||||
if p_title:
|
||||
badge_href = market_url(f"/{p_slug}/")
|
||||
badge_title = p_title
|
||||
else:
|
||||
p_slug = post_slug
|
||||
p_title = ""
|
||||
market_href = market_url(f"/{post_slug}/{slug}/") if post_slug else ""
|
||||
href = market_url(f"/{post_slug}/{slug}/") if post_slug else ""
|
||||
|
||||
title_sx = ""
|
||||
if market_href:
|
||||
title_sx = sx_call("market-market-card-title-link", href=market_href, name=name)
|
||||
else:
|
||||
title_sx = sx_call("market-market-card-title", name=name)
|
||||
|
||||
desc_sx = ""
|
||||
if description:
|
||||
desc_sx = sx_call("market-market-card-desc", description=description)
|
||||
|
||||
badge_sx = ""
|
||||
if show_page_badge and p_title:
|
||||
badge_href = market_url(f"/{p_slug}/")
|
||||
badge_sx = sx_call("market-market-card-badge", href=badge_href, title=p_title)
|
||||
|
||||
return sx_call(
|
||||
"market-market-card",
|
||||
title_content=SxExpr(title_sx) if title_sx else None,
|
||||
desc_content=SxExpr(desc_sx) if desc_sx else None,
|
||||
badge_content=SxExpr(badge_sx) if badge_sx else None,
|
||||
)
|
||||
return {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"href": href,
|
||||
"show-badge": show_page_badge,
|
||||
"badge-href": badge_href,
|
||||
"badge-title": badge_title,
|
||||
}
|
||||
|
||||
|
||||
def _market_cards_sx(markets: list, page_info: dict, page: int, has_more: bool,
|
||||
next_url: str, *, show_page_badge: bool = True,
|
||||
post_slug: str = "") -> str:
|
||||
"""Build market cards with infinite scroll sentinel as sx."""
|
||||
parts = []
|
||||
for m in markets:
|
||||
parts.append(_market_card_sx(m, page_info, show_page_badge=show_page_badge,
|
||||
post_slug=post_slug))
|
||||
if has_more:
|
||||
parts.append(sx_call(
|
||||
"sentinel-simple",
|
||||
id=f"sentinel-{page}", next_url=next_url,
|
||||
))
|
||||
return "(<> " + " ".join(parts) + ")"
|
||||
next_url: str, *, show_page_badge: bool = True,
|
||||
post_slug: str = "") -> str:
|
||||
"""Build market cards as sx — delegates to .sx defcomp."""
|
||||
market_data = [_market_card_data(m, page_info, show_page_badge=show_page_badge,
|
||||
post_slug=post_slug) for m in markets]
|
||||
return sx_call("market-cards-content",
|
||||
markets=market_data,
|
||||
page=page,
|
||||
has_more=has_more,
|
||||
next_url=next_url)
|
||||
|
||||
|
||||
def _markets_grid(cards_sx: str) -> str:
|
||||
"""Wrap market cards in a grid as sx."""
|
||||
from shared.sx.parser import SxExpr
|
||||
return sx_call("market-markets-grid", cards=SxExpr(cards_sx))
|
||||
|
||||
|
||||
def _no_markets_sx(message: str = "No markets available") -> str:
|
||||
"""Empty state for markets as sx."""
|
||||
return sx_call("empty-state", icon="fa fa-store", message=message,
|
||||
cls="px-3 py-12 text-center text-stone-400")
|
||||
cls="px-3 py-12 text-center text-stone-400")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Market landing page
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _market_landing_content_sx(post: dict) -> str:
|
||||
"""Build market landing page content as sx."""
|
||||
parts: list[str] = []
|
||||
if post.get("custom_excerpt"):
|
||||
parts.append(sx_call("market-landing-excerpt", text=post["custom_excerpt"]))
|
||||
if post.get("feature_image"):
|
||||
parts.append(sx_call("market-landing-image", src=post["feature_image"]))
|
||||
if post.get("html"):
|
||||
parts.append(sx_call("market-landing-html", html=post["html"]))
|
||||
inner = "(<> " + " ".join(parts) + ")" if parts else "(<>)"
|
||||
return sx_call("market-landing-content", inner=SxExpr(inner))
|
||||
"""Build market landing page content — delegates to .sx defcomp."""
|
||||
return sx_call("market-landing-from-data",
|
||||
excerpt=post.get("custom_excerpt") or None,
|
||||
feature_image=post.get("feature_image") or None,
|
||||
html=post.get("html") or None)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Filter panel functions (mobile + desktop)."""
|
||||
"""Filter panel data extraction (mobile + desktop)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from shared.sx.parser import SxExpr
|
||||
@@ -77,365 +77,308 @@ _DESKTOP_SENTINEL_HS = (
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Browse filter panels (mobile + desktop)
|
||||
# Filter data extraction helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _desktop_filter_sx(ctx: dict) -> str:
|
||||
"""Build the desktop aside filter panel as sx."""
|
||||
category_label = ctx.get("category_label", "")
|
||||
sort_options = ctx.get("sort_options", [])
|
||||
sort = ctx.get("sort", "")
|
||||
labels = ctx.get("labels", [])
|
||||
selected_labels = ctx.get("selected_labels", [])
|
||||
stickers = ctx.get("stickers", [])
|
||||
selected_stickers = ctx.get("selected_stickers", [])
|
||||
brands = ctx.get("brands", [])
|
||||
selected_brands = ctx.get("selected_brands", [])
|
||||
liked = ctx.get("liked", False)
|
||||
liked_count = ctx.get("liked_count", 0)
|
||||
subs_local = ctx.get("subs_local", [])
|
||||
top_local_href = ctx.get("top_local_href", "")
|
||||
sub_slug = ctx.get("sub_slug", "")
|
||||
|
||||
# Search
|
||||
search_sx = await search_desktop_sx(ctx)
|
||||
|
||||
# Category summary + sort + like + labels + stickers
|
||||
cat_parts = [sx_call("market-filter-category-label", label=category_label)]
|
||||
|
||||
if sort_options:
|
||||
cat_parts.append(_sort_stickers_sx(sort_options, sort, ctx))
|
||||
|
||||
like_label_parts = [_like_filter_sx(liked, liked_count, ctx)]
|
||||
if labels:
|
||||
like_label_parts.append(_labels_filter_sx(labels, selected_labels, ctx, prefix="nav-labels"))
|
||||
like_labels_sx = "(<> " + " ".join(like_label_parts) + ")"
|
||||
cat_parts.append(sx_call("market-filter-like-labels-nav", inner=SxExpr(like_labels_sx)))
|
||||
|
||||
if stickers:
|
||||
cat_parts.append(_stickers_filter_sx(stickers, selected_stickers, ctx))
|
||||
|
||||
if subs_local and top_local_href:
|
||||
cat_parts.append(_subcategory_selector_sx(subs_local, top_local_href, sub_slug, ctx))
|
||||
|
||||
cat_inner_sx = "(<> " + " ".join(cat_parts) + ")"
|
||||
cat_summary = sx_call("market-desktop-category-summary", inner=SxExpr(cat_inner_sx))
|
||||
|
||||
# Brand filter
|
||||
brand_inner = ""
|
||||
if brands:
|
||||
brand_inner = _brand_filter_sx(brands, selected_brands, ctx)
|
||||
brand_summary = sx_call("market-desktop-brand-summary",
|
||||
inner=SxExpr(brand_inner) if brand_inner else None)
|
||||
|
||||
return "(<> " + " ".join([search_sx, cat_summary, brand_summary]) + ")"
|
||||
|
||||
|
||||
async def _mobile_filter_summary_sx(ctx: dict) -> str:
|
||||
"""Build mobile filter summary as sx."""
|
||||
asset_url_fn = ctx.get("asset_url")
|
||||
sort = ctx.get("sort", "")
|
||||
sort_options = ctx.get("sort_options", [])
|
||||
liked = ctx.get("liked", False)
|
||||
liked_count = ctx.get("liked_count", 0)
|
||||
selected_labels = ctx.get("selected_labels", [])
|
||||
selected_stickers = ctx.get("selected_stickers", [])
|
||||
selected_brands = ctx.get("selected_brands", [])
|
||||
labels = ctx.get("labels", [])
|
||||
stickers = ctx.get("stickers", [])
|
||||
brands = ctx.get("brands", [])
|
||||
|
||||
# Search bar
|
||||
search_bar = await search_mobile_sx(ctx)
|
||||
|
||||
# Summary chips showing active filters
|
||||
chip_parts: list[str] = []
|
||||
|
||||
if sort and sort_options:
|
||||
for k, l, i in sort_options:
|
||||
if k == sort and callable(asset_url_fn):
|
||||
chip_parts.append(sx_call("market-mobile-chip-sort", src=asset_url_fn(i), label=l))
|
||||
if liked:
|
||||
liked_parts = [sx_call("market-mobile-chip-liked-icon")]
|
||||
if liked_count is not None:
|
||||
cls = "text-[10px] text-stone-500" if liked_count != 0 else "text-md text-red-500 font-bold"
|
||||
liked_parts.append(sx_call("market-mobile-chip-count", cls=cls, count=str(liked_count)))
|
||||
liked_inner = "(<> " + " ".join(liked_parts) + ")"
|
||||
chip_parts.append(sx_call("market-mobile-chip-liked", inner=SxExpr(liked_inner)))
|
||||
|
||||
# Selected labels
|
||||
if selected_labels:
|
||||
label_item_parts = []
|
||||
for sl in selected_labels:
|
||||
for lb in labels:
|
||||
if lb.get("name") == sl and callable(asset_url_fn):
|
||||
li_parts = [sx_call(
|
||||
"market-mobile-chip-image",
|
||||
src=asset_url_fn("nav-labels/" + sl + ".svg"), name=sl,
|
||||
)]
|
||||
if lb.get("count") is not None:
|
||||
cls = "text-[10px] text-stone-500" if lb["count"] != 0 else "text-md text-red-500 font-bold"
|
||||
li_parts.append(sx_call("market-mobile-chip-count", cls=cls, count=str(lb["count"])))
|
||||
li_inner = "(<> " + " ".join(li_parts) + ")"
|
||||
label_item_parts.append(sx_call("market-mobile-chip-item", inner=SxExpr(li_inner)))
|
||||
if label_item_parts:
|
||||
label_items = "(<> " + " ".join(label_item_parts) + ")"
|
||||
chip_parts.append(sx_call("market-mobile-chip-list", items=SxExpr(label_items)))
|
||||
|
||||
# Selected stickers
|
||||
if selected_stickers:
|
||||
sticker_item_parts = []
|
||||
for ss in selected_stickers:
|
||||
for st in stickers:
|
||||
if st.get("name") == ss and callable(asset_url_fn):
|
||||
si_parts = [sx_call(
|
||||
"market-mobile-chip-image",
|
||||
src=asset_url_fn("stickers/" + ss + ".svg"), name=ss,
|
||||
)]
|
||||
if st.get("count") is not None:
|
||||
cls = "text-[10px] text-stone-500" if st["count"] != 0 else "text-md text-red-500 font-bold"
|
||||
si_parts.append(sx_call("market-mobile-chip-count", cls=cls, count=str(st["count"])))
|
||||
si_inner = "(<> " + " ".join(si_parts) + ")"
|
||||
sticker_item_parts.append(sx_call("market-mobile-chip-item", inner=SxExpr(si_inner)))
|
||||
if sticker_item_parts:
|
||||
sticker_items = "(<> " + " ".join(sticker_item_parts) + ")"
|
||||
chip_parts.append(sx_call("market-mobile-chip-list", items=SxExpr(sticker_items)))
|
||||
|
||||
# Selected brands
|
||||
if selected_brands:
|
||||
brand_item_parts = []
|
||||
for b in selected_brands:
|
||||
count = 0
|
||||
for br in brands:
|
||||
if br.get("name") == b:
|
||||
count = br.get("count", 0)
|
||||
if count:
|
||||
brand_item_parts.append(sx_call("market-mobile-chip-brand", name=b, count=str(count)))
|
||||
else:
|
||||
brand_item_parts.append(sx_call("market-mobile-chip-brand-zero", name=b))
|
||||
brand_items = "(<> " + " ".join(brand_item_parts) + ")"
|
||||
chip_parts.append(sx_call("market-mobile-chip-brand-list", items=SxExpr(brand_items)))
|
||||
|
||||
chips_sx = "(<> " + " ".join(chip_parts) + ")" if chip_parts else '(<>)'
|
||||
chips_row = sx_call("market-mobile-chips-row", inner=SxExpr(chips_sx))
|
||||
|
||||
# Full mobile filter details
|
||||
def _filter_common(ctx: dict) -> tuple:
|
||||
"""Extract common filter params from context."""
|
||||
from shared.utils import route_prefix
|
||||
prefix = route_prefix()
|
||||
mobile_filter = _mobile_filter_content_sx(ctx, prefix)
|
||||
|
||||
return sx_call(
|
||||
"market-mobile-filter-summary",
|
||||
search_bar=SxExpr(search_bar),
|
||||
chips=SxExpr(chips_row),
|
||||
filter=SxExpr(mobile_filter),
|
||||
)
|
||||
|
||||
|
||||
def _mobile_filter_content_sx(ctx: dict, prefix: str) -> str:
|
||||
"""Build the expanded mobile filter panel contents as sx."""
|
||||
selected_labels = ctx.get("selected_labels", [])
|
||||
selected_stickers = ctx.get("selected_stickers", [])
|
||||
selected_brands = ctx.get("selected_brands", [])
|
||||
current_local_href = ctx.get("current_local_href", "/")
|
||||
hx_select = ctx.get("hx_select_search", "#main-panel")
|
||||
sort_options = ctx.get("sort_options", [])
|
||||
sort = ctx.get("sort", "")
|
||||
liked = ctx.get("liked", False)
|
||||
liked_count = ctx.get("liked_count", 0)
|
||||
labels = ctx.get("labels", [])
|
||||
stickers = ctx.get("stickers", [])
|
||||
brands = ctx.get("brands", [])
|
||||
search = ctx.get("search", "")
|
||||
qs_fn = ctx.get("qs_filter")
|
||||
|
||||
parts: list[str] = []
|
||||
|
||||
# Sort options
|
||||
if sort_options:
|
||||
parts.append(_sort_stickers_sx(sort_options, sort, ctx, mobile=True))
|
||||
|
||||
# Clear filters button
|
||||
has_filters = search or selected_labels or selected_stickers or selected_brands
|
||||
if has_filters and callable(qs_fn):
|
||||
clear_url = prefix + current_local_href + qs_fn({"clear_filters": True})
|
||||
parts.append(sx_call("market-mobile-clear-filters", href=clear_url, hx_select=hx_select))
|
||||
|
||||
# Like + labels row
|
||||
like_label_parts = [_like_filter_sx(liked, liked_count, ctx, mobile=True)]
|
||||
if labels:
|
||||
like_label_parts.append(_labels_filter_sx(labels, selected_labels, ctx, prefix="nav-labels", mobile=True))
|
||||
like_labels_sx = "(<> " + " ".join(like_label_parts) + ")"
|
||||
parts.append(sx_call("market-mobile-like-labels-row", inner=SxExpr(like_labels_sx)))
|
||||
|
||||
# Stickers
|
||||
if stickers:
|
||||
parts.append(_stickers_filter_sx(stickers, selected_stickers, ctx, mobile=True))
|
||||
|
||||
# Brands
|
||||
if brands:
|
||||
parts.append(_brand_filter_sx(brands, selected_brands, ctx, mobile=True))
|
||||
|
||||
return "(<> " + " ".join(parts) + ")" if parts else "(<>)"
|
||||
|
||||
|
||||
def _sort_stickers_sx(sort_options: list, current_sort: str, ctx: dict, mobile: bool = False) -> str:
|
||||
"""Build sort option stickers as sx."""
|
||||
asset_url_fn = ctx.get("asset_url")
|
||||
current_local_href = ctx.get("current_local_href", "/")
|
||||
hx_select = ctx.get("hx_select_search", "#main-panel")
|
||||
qs_fn = ctx.get("qs_filter")
|
||||
from shared.utils import route_prefix
|
||||
prefix = route_prefix()
|
||||
asset_url_fn = ctx.get("asset_url")
|
||||
return prefix, current_local_href, hx_select, qs_fn, asset_url_fn
|
||||
|
||||
item_parts: list[str] = []
|
||||
|
||||
def _sort_data(sort_options: list, current_sort: str, ctx: dict) -> list:
|
||||
"""Extract sort option data for .sx composition."""
|
||||
prefix, current_local_href, hx_select, qs_fn, asset_url_fn = _filter_common(ctx)
|
||||
items = []
|
||||
for k, label, icon in sort_options:
|
||||
if callable(qs_fn):
|
||||
href = prefix + current_local_href + qs_fn({"sort": k})
|
||||
else:
|
||||
href = "#"
|
||||
active = (k == current_sort)
|
||||
href = prefix + current_local_href + qs_fn({"sort": k}) if callable(qs_fn) else "#"
|
||||
active = k == current_sort
|
||||
ring = " ring-2 ring-emerald-500 rounded" if active else ""
|
||||
src = asset_url_fn(icon) if callable(asset_url_fn) else icon
|
||||
item_parts.append(sx_call(
|
||||
"market-filter-sort-item",
|
||||
href=href, hx_select=hx_select, ring_cls=ring, src=src, label=label,
|
||||
))
|
||||
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)"
|
||||
return sx_call("market-filter-sort-row", items=SxExpr(items_sx))
|
||||
items.append({"href": href, "hx-select": hx_select,
|
||||
"ring-cls": ring, "src": src, "label": label})
|
||||
return items
|
||||
|
||||
|
||||
def _like_filter_sx(liked: bool, liked_count: int, ctx: dict, mobile: bool = False) -> str:
|
||||
"""Build the like filter toggle as sx."""
|
||||
current_local_href = ctx.get("current_local_href", "/")
|
||||
hx_select = ctx.get("hx_select_search", "#main-panel")
|
||||
qs_fn = ctx.get("qs_filter")
|
||||
from shared.utils import route_prefix
|
||||
prefix = route_prefix()
|
||||
|
||||
if callable(qs_fn):
|
||||
href = prefix + current_local_href + qs_fn({"liked": not liked})
|
||||
else:
|
||||
href = "#"
|
||||
|
||||
icon_cls = "fa-solid fa-heart text-red-500" if liked else "fa-regular fa-heart text-stone-400"
|
||||
size = "text-[40px]" if mobile else "text-2xl"
|
||||
return sx_call(
|
||||
"market-filter-like",
|
||||
href=href, hx_select=hx_select, icon_cls=icon_cls, size_cls=size,
|
||||
)
|
||||
def _like_data(liked: bool, liked_count: int, ctx: dict) -> dict:
|
||||
"""Extract like filter data for .sx composition."""
|
||||
prefix, current_local_href, hx_select, qs_fn, _ = _filter_common(ctx)
|
||||
href = prefix + current_local_href + qs_fn({"liked": not liked}) if callable(qs_fn) else "#"
|
||||
return {"href": href, "hx-select": hx_select, "liked": liked}
|
||||
|
||||
|
||||
def _labels_filter_sx(labels: list, selected: list, ctx: dict, *,
|
||||
prefix: str = "nav-labels", mobile: bool = False) -> str:
|
||||
"""Build label filter buttons as sx."""
|
||||
asset_url_fn = ctx.get("asset_url")
|
||||
current_local_href = ctx.get("current_local_href", "/")
|
||||
hx_select = ctx.get("hx_select_search", "#main-panel")
|
||||
qs_fn = ctx.get("qs_filter")
|
||||
from shared.utils import route_prefix
|
||||
rp = route_prefix()
|
||||
|
||||
item_parts: list[str] = []
|
||||
def _label_data(labels: list, selected: list, ctx: dict, *, img_prefix: str = "nav-labels") -> list:
|
||||
"""Extract label filter data for .sx composition."""
|
||||
prefix, current_local_href, hx_select, qs_fn, asset_url_fn = _filter_common(ctx)
|
||||
items = []
|
||||
for lb in labels:
|
||||
name = lb.get("name", "")
|
||||
is_sel = name in selected
|
||||
if callable(qs_fn):
|
||||
new_sel = [s for s in selected if s != name] if is_sel else selected + [name]
|
||||
href = rp + current_local_href + qs_fn({"labels": new_sel})
|
||||
href = prefix + current_local_href + qs_fn({"labels": new_sel})
|
||||
else:
|
||||
href = "#"
|
||||
ring = " ring-2 ring-emerald-500 rounded" if is_sel else ""
|
||||
src = asset_url_fn(f"{prefix}/{name}.svg") if callable(asset_url_fn) else ""
|
||||
item_parts.append(sx_call(
|
||||
"market-filter-label-item",
|
||||
href=href, hx_select=hx_select, ring_cls=ring, src=src, name=name,
|
||||
))
|
||||
return "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)"
|
||||
src = asset_url_fn(f"{img_prefix}/{name}.svg") if callable(asset_url_fn) else ""
|
||||
items.append({"href": href, "hx-select": hx_select,
|
||||
"ring-cls": ring, "src": src, "name": name})
|
||||
return items
|
||||
|
||||
|
||||
def _stickers_filter_sx(stickers: list, selected: list, ctx: dict, mobile: bool = False) -> str:
|
||||
"""Build sticker filter grid as sx."""
|
||||
asset_url_fn = ctx.get("asset_url")
|
||||
current_local_href = ctx.get("current_local_href", "/")
|
||||
hx_select = ctx.get("hx_select_search", "#main-panel")
|
||||
qs_fn = ctx.get("qs_filter")
|
||||
from shared.utils import route_prefix
|
||||
rp = route_prefix()
|
||||
|
||||
item_parts: list[str] = []
|
||||
def _sticker_data(stickers: list, selected: list, ctx: dict) -> list:
|
||||
"""Extract sticker filter data for .sx composition."""
|
||||
prefix, current_local_href, hx_select, qs_fn, asset_url_fn = _filter_common(ctx)
|
||||
items = []
|
||||
for st in stickers:
|
||||
name = st.get("name", "")
|
||||
count = st.get("count", 0)
|
||||
is_sel = name in selected
|
||||
if callable(qs_fn):
|
||||
new_sel = [s for s in selected if s != name] if is_sel else selected + [name]
|
||||
href = rp + current_local_href + qs_fn({"stickers": new_sel})
|
||||
href = prefix + current_local_href + qs_fn({"stickers": new_sel})
|
||||
else:
|
||||
href = "#"
|
||||
ring = " ring-2 ring-emerald-500 rounded" if is_sel else ""
|
||||
src = asset_url_fn(f"stickers/{name}.svg") if callable(asset_url_fn) else ""
|
||||
cls = "text-[10px] text-stone-500" if count != 0 else "text-md text-red-500 font-bold"
|
||||
item_parts.append(sx_call(
|
||||
"market-filter-sticker-item",
|
||||
href=href, hx_select=hx_select, ring_cls=ring,
|
||||
src=src, name=name, count_cls=cls, count=str(count),
|
||||
))
|
||||
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)"
|
||||
return sx_call("market-filter-stickers-row", items=SxExpr(items_sx))
|
||||
items.append({"href": href, "hx-select": hx_select, "ring-cls": ring,
|
||||
"src": src, "name": name, "count-cls": cls, "count": str(count)})
|
||||
return items
|
||||
|
||||
|
||||
def _brand_filter_sx(brands: list, selected: list, ctx: dict, mobile: bool = False) -> str:
|
||||
"""Build brand filter checkboxes as sx."""
|
||||
current_local_href = ctx.get("current_local_href", "/")
|
||||
hx_select = ctx.get("hx_select_search", "#main-panel")
|
||||
qs_fn = ctx.get("qs_filter")
|
||||
from shared.utils import route_prefix
|
||||
rp = route_prefix()
|
||||
|
||||
item_parts: list[str] = []
|
||||
def _brand_data(brands: list, selected: list, ctx: dict) -> list:
|
||||
"""Extract brand filter data for .sx composition."""
|
||||
prefix, current_local_href, hx_select, qs_fn, _ = _filter_common(ctx)
|
||||
items = []
|
||||
for br in brands:
|
||||
name = br.get("name", "")
|
||||
count = br.get("count", 0)
|
||||
is_sel = name in selected
|
||||
if callable(qs_fn):
|
||||
new_sel = [s for s in selected if s != name] if is_sel else selected + [name]
|
||||
href = rp + current_local_href + qs_fn({"brands": new_sel})
|
||||
href = prefix + current_local_href + qs_fn({"brands": new_sel})
|
||||
else:
|
||||
href = "#"
|
||||
bg = " bg-yellow-200" if is_sel else ""
|
||||
cls = "text-md" if count else "text-md text-red-500"
|
||||
item_parts.append(sx_call(
|
||||
"market-filter-brand-item",
|
||||
href=href, hx_select=hx_select, bg_cls=bg,
|
||||
name_cls=cls, name=name, count=str(count),
|
||||
))
|
||||
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else "(<>)"
|
||||
return sx_call("market-filter-brands-panel", items=SxExpr(items_sx))
|
||||
items.append({"href": href, "hx-select": hx_select, "bg-cls": bg,
|
||||
"name-cls": cls, "name": name, "count": str(count)})
|
||||
return items
|
||||
|
||||
|
||||
def _subcategory_selector_sx(subs: list, top_href: str, current_sub: str, ctx: dict) -> str:
|
||||
"""Build subcategory vertical nav as sx."""
|
||||
hx_select = ctx.get("hx_select_search", "#main-panel")
|
||||
def _subcategory_data(subs: list, top_href: str, current_sub: str, ctx: dict) -> dict:
|
||||
"""Extract subcategory filter data for .sx composition."""
|
||||
from shared.utils import route_prefix
|
||||
rp = route_prefix()
|
||||
hx_select = ctx.get("hx_select_search", "#main-panel")
|
||||
|
||||
all_cls = " bg-stone-200 font-medium" if not current_sub else ""
|
||||
all_full_href = rp + top_href
|
||||
item_parts = [sx_call(
|
||||
"market-filter-subcategory-item",
|
||||
href=all_full_href, hx_select=hx_select, active_cls=all_cls, name="All",
|
||||
)]
|
||||
items = []
|
||||
for sub in subs:
|
||||
slug = sub.get("slug", "")
|
||||
name = sub.get("name", "")
|
||||
href = sub.get("href", "")
|
||||
active = (slug == current_sub)
|
||||
active = slug == current_sub
|
||||
active_cls = " bg-stone-200 font-medium" if active else ""
|
||||
full_href = rp + href
|
||||
item_parts.append(sx_call(
|
||||
"market-filter-subcategory-item",
|
||||
href=full_href, hx_select=hx_select, active_cls=active_cls, name=name,
|
||||
))
|
||||
items_sx = "(<> " + " ".join(item_parts) + ")"
|
||||
return sx_call("market-filter-subcategory-panel", items=SxExpr(items_sx))
|
||||
items.append({"href": rp + sub.get("href", ""), "hx-select": hx_select,
|
||||
"active-cls": active_cls, "name": sub.get("name", "")})
|
||||
return {"items": items, "all-href": rp + top_href, "current-sub": current_sub}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Desktop filter panel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _desktop_filter_sx(ctx: dict) -> str:
|
||||
"""Build the desktop aside filter panel — delegates to .sx defcomp."""
|
||||
hx_select = ctx.get("hx_select_search", "#main-panel")
|
||||
|
||||
# Search (still uses render_to_sx for shared component)
|
||||
search_sx = await search_desktop_sx(ctx)
|
||||
|
||||
# Sort data
|
||||
sort_options = ctx.get("sort_options", [])
|
||||
sort = ctx.get("sort", "")
|
||||
sd = _sort_data(sort_options, sort, ctx) if sort_options else None
|
||||
|
||||
# Like data
|
||||
ld = _like_data(ctx.get("liked", False), ctx.get("liked_count", 0), ctx)
|
||||
|
||||
# Labels data
|
||||
labels = ctx.get("labels", [])
|
||||
selected_labels = ctx.get("selected_labels", [])
|
||||
lab_d = _label_data(labels, selected_labels, ctx) if labels else None
|
||||
|
||||
# Stickers data
|
||||
stickers = ctx.get("stickers", [])
|
||||
selected_stickers = ctx.get("selected_stickers", [])
|
||||
st_d = _sticker_data(stickers, selected_stickers, ctx) if stickers else None
|
||||
|
||||
# Brands data
|
||||
brands = ctx.get("brands", [])
|
||||
selected_brands = ctx.get("selected_brands", [])
|
||||
br_d = _brand_data(brands, selected_brands, ctx) if brands else None
|
||||
|
||||
# Subcategory data
|
||||
subs_local = ctx.get("subs_local", [])
|
||||
top_local_href = ctx.get("top_local_href", "")
|
||||
sub_slug = ctx.get("sub_slug", "")
|
||||
sub_d = (_subcategory_data(subs_local, top_local_href, sub_slug, ctx)
|
||||
if subs_local and top_local_href else None)
|
||||
|
||||
return sx_call("market-desktop-filter-from-data",
|
||||
search_sx=SxExpr(search_sx),
|
||||
category_label=ctx.get("category_label", ""),
|
||||
sort_data=sd,
|
||||
like_data=ld,
|
||||
label_data=lab_d,
|
||||
sticker_data=st_d,
|
||||
brand_data=br_d,
|
||||
sub_data=sub_d,
|
||||
hx_select=hx_select)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mobile filter summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _mobile_chips_data(ctx: dict) -> dict:
|
||||
"""Extract mobile filter chip data for .sx composition."""
|
||||
asset_url_fn = ctx.get("asset_url")
|
||||
sort = ctx.get("sort", "")
|
||||
sort_options = ctx.get("sort_options", [])
|
||||
liked = ctx.get("liked", False)
|
||||
liked_count = ctx.get("liked_count", 0)
|
||||
selected_labels = ctx.get("selected_labels", [])
|
||||
selected_stickers = ctx.get("selected_stickers", [])
|
||||
selected_brands = ctx.get("selected_brands", [])
|
||||
labels = ctx.get("labels", [])
|
||||
stickers = ctx.get("stickers", [])
|
||||
brands = ctx.get("brands", [])
|
||||
|
||||
# Sort chip
|
||||
sort_chip = None
|
||||
if sort and sort_options:
|
||||
for k, l, i in sort_options:
|
||||
if k == sort and callable(asset_url_fn):
|
||||
sort_chip = {"src": asset_url_fn(i), "label": l}
|
||||
|
||||
# Liked chip
|
||||
liked_chip = None
|
||||
if liked:
|
||||
count_cls = ("text-[10px] text-stone-500" if liked_count != 0
|
||||
else "text-md text-red-500 font-bold")
|
||||
liked_chip = {"count": str(liked_count) if liked_count is not None else None,
|
||||
"count-cls": count_cls}
|
||||
|
||||
# Label chips
|
||||
label_chips = []
|
||||
if selected_labels:
|
||||
for sl in selected_labels:
|
||||
for lb in labels:
|
||||
if lb.get("name") == sl and callable(asset_url_fn):
|
||||
chip: dict = {"src": asset_url_fn("nav-labels/" + sl + ".svg"), "name": sl}
|
||||
if lb.get("count") is not None:
|
||||
cls = ("text-[10px] text-stone-500" if lb["count"] != 0
|
||||
else "text-md text-red-500 font-bold")
|
||||
chip["count"] = str(lb["count"])
|
||||
chip["count-cls"] = cls
|
||||
label_chips.append(chip)
|
||||
|
||||
# Sticker chips
|
||||
sticker_chips = []
|
||||
if selected_stickers:
|
||||
for ss in selected_stickers:
|
||||
for st in stickers:
|
||||
if st.get("name") == ss and callable(asset_url_fn):
|
||||
chip = {"src": asset_url_fn("stickers/" + ss + ".svg"), "name": ss}
|
||||
if st.get("count") is not None:
|
||||
cls = ("text-[10px] text-stone-500" if st["count"] != 0
|
||||
else "text-md text-red-500 font-bold")
|
||||
chip["count"] = str(st["count"])
|
||||
chip["count-cls"] = cls
|
||||
sticker_chips.append(chip)
|
||||
|
||||
# Brand chips
|
||||
brand_chips = []
|
||||
if selected_brands:
|
||||
for b in selected_brands:
|
||||
count = 0
|
||||
for br in brands:
|
||||
if br.get("name") == b:
|
||||
count = br.get("count", 0)
|
||||
brand_chips.append({"name": b, "count": str(count), "has-count": bool(count)})
|
||||
|
||||
return {
|
||||
"sort-chip": sort_chip,
|
||||
"liked-chip": liked_chip,
|
||||
"label-chips": label_chips or None,
|
||||
"sticker-chips": sticker_chips or None,
|
||||
"brand-chips": brand_chips or None,
|
||||
}
|
||||
|
||||
|
||||
def _mobile_filter_content_data(ctx: dict) -> dict:
|
||||
"""Extract mobile filter expanded panel data."""
|
||||
from shared.utils import route_prefix
|
||||
prefix = route_prefix()
|
||||
hx_select = ctx.get("hx_select_search", "#main-panel")
|
||||
|
||||
sort_options = ctx.get("sort_options", [])
|
||||
sort = ctx.get("sort", "")
|
||||
sd = _sort_data(sort_options, sort, ctx) if sort_options else None
|
||||
|
||||
ld = _like_data(ctx.get("liked", False), ctx.get("liked_count", 0), ctx)
|
||||
|
||||
labels = ctx.get("labels", [])
|
||||
selected_labels = ctx.get("selected_labels", [])
|
||||
lab_d = _label_data(labels, selected_labels, ctx) if labels else None
|
||||
|
||||
stickers = ctx.get("stickers", [])
|
||||
selected_stickers = ctx.get("selected_stickers", [])
|
||||
st_d = _sticker_data(stickers, selected_stickers, ctx) if stickers else None
|
||||
|
||||
brands = ctx.get("brands", [])
|
||||
selected_brands = ctx.get("selected_brands", [])
|
||||
br_d = _brand_data(brands, selected_brands, ctx) if brands else None
|
||||
|
||||
# Clear filters URL
|
||||
clear_href = None
|
||||
search = ctx.get("search", "")
|
||||
has_filters = search or selected_labels or selected_stickers or selected_brands
|
||||
qs_fn = ctx.get("qs_filter")
|
||||
if has_filters and callable(qs_fn):
|
||||
current_local_href = ctx.get("current_local_href", "/")
|
||||
clear_href = prefix + current_local_href + qs_fn({"clear_filters": True})
|
||||
|
||||
return {
|
||||
"sort-data": sd,
|
||||
"like-data": ld,
|
||||
"label-data": lab_d,
|
||||
"sticker-data": st_d,
|
||||
"brand-data": br_d,
|
||||
"clear-href": clear_href,
|
||||
"hx-select": hx_select,
|
||||
}
|
||||
|
||||
|
||||
async def _mobile_filter_summary_sx(ctx: dict) -> str:
|
||||
"""Build mobile filter summary — delegates to .sx defcomps."""
|
||||
# Search bar (still uses render_to_sx for shared component)
|
||||
search_bar = await search_mobile_sx(ctx)
|
||||
|
||||
# Chips data
|
||||
chips_data = _mobile_chips_data(ctx)
|
||||
chips = sx_call("market-mobile-chips-from-data", **chips_data)
|
||||
|
||||
# Expanded filter content data
|
||||
filter_data = _mobile_filter_content_data(ctx)
|
||||
filter_content = sx_call("market-mobile-filter-content-from-data", **filter_data)
|
||||
|
||||
return sx_call("market-mobile-filter-summary",
|
||||
search_bar=SxExpr(search_bar),
|
||||
chips=SxExpr(chips),
|
||||
filter=SxExpr(filter_content))
|
||||
|
||||
@@ -1,98 +1,33 @@
|
||||
"""Page helpers for market defpage system."""
|
||||
"""Market page helpers — data-only.
|
||||
|
||||
All helpers return data values (dicts, lists) — no sx_call().
|
||||
Markup composition lives entirely in .sx defpage and .sx defcomp files.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .cards import (
|
||||
_market_cards_sx, _markets_grid, _no_markets_sx,
|
||||
_market_landing_content_sx,
|
||||
)
|
||||
from .cards import _market_card_data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page admin panel (used by _h_page_admin_content)
|
||||
# Registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _markets_admin_panel_sx(ctx: dict) -> str:
|
||||
"""Render the markets list + create form panel."""
|
||||
from quart import g, url_for
|
||||
from shared.services.registry import services
|
||||
from shared.sx.helpers import sx_call
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
rights = ctx.get("rights") or {}
|
||||
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
|
||||
has_access = ctx.get("has_access")
|
||||
can_create = has_access("page_admin.create_market") if callable(has_access) else is_admin
|
||||
csrf_token = ctx.get("csrf_token")
|
||||
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
|
||||
|
||||
post = ctx.get("post") or {}
|
||||
post_id = post.get("id")
|
||||
markets = await services.market.marketplaces_for_container(g.s, "page", post_id) if post_id else []
|
||||
|
||||
form_html = ""
|
||||
if can_create:
|
||||
create_url = url_for("page_admin.create_market")
|
||||
form_html = sx_call("crud-create-form",
|
||||
create_url=create_url, csrf=csrf,
|
||||
errors_id="market-create-errors",
|
||||
list_id="markets-list",
|
||||
placeholder="e.g. Suma, Craft Fair",
|
||||
btn_label="Add market")
|
||||
|
||||
list_html = _markets_admin_list_sx(ctx, markets)
|
||||
return sx_call("crud-panel",
|
||||
form=SxExpr(form_html), list=SxExpr(list_html),
|
||||
list_id="markets-list")
|
||||
|
||||
|
||||
def _markets_admin_list_sx(ctx: dict, markets: list) -> str:
|
||||
"""Render the markets list items."""
|
||||
from quart import url_for
|
||||
from shared.utils import route_prefix
|
||||
from shared.sx.helpers import sx_call
|
||||
csrf_token = ctx.get("csrf_token")
|
||||
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
|
||||
prefix = route_prefix()
|
||||
|
||||
if not markets:
|
||||
return sx_call("empty-state",
|
||||
message="No markets yet. Create one above.",
|
||||
cls="text-gray-500 mt-4")
|
||||
|
||||
parts = []
|
||||
for m in markets:
|
||||
m_slug = getattr(m, "slug", "") or (m.get("slug", "") if isinstance(m, dict) else "")
|
||||
m_name = getattr(m, "name", "") or (m.get("name", "") if isinstance(m, dict) else "")
|
||||
post_slug = (ctx.get("post") or {}).get("slug", "")
|
||||
href = prefix + f"/{post_slug}/{m_slug}/"
|
||||
del_url = url_for("page_admin.delete_market", market_slug=m_slug)
|
||||
csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}'
|
||||
parts.append(sx_call("crud-item",
|
||||
href=href, name=m_name, slug=m_slug,
|
||||
del_url=del_url, csrf_hdr=csrf_hdr,
|
||||
list_id="markets-list",
|
||||
confirm_title="Delete market?",
|
||||
confirm_text="Products will be hidden (soft delete)"))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Page helpers
|
||||
# ===========================================================================
|
||||
|
||||
def _register_market_helpers() -> None:
|
||||
from shared.sx.pages import register_page_helpers
|
||||
|
||||
register_page_helpers("market", {
|
||||
"all-markets-content": _h_all_markets_content,
|
||||
"page-markets-content": _h_page_markets_content,
|
||||
"page-admin-content": _h_page_admin_content,
|
||||
"market-home-content": _h_market_home_content,
|
||||
"market-admin-content": _h_market_admin_content,
|
||||
"all-markets-data": _h_all_markets_data,
|
||||
"page-markets-data": _h_page_markets_data,
|
||||
"page-admin-data": _h_page_admin_data,
|
||||
"market-home-data": _h_market_home_data,
|
||||
})
|
||||
|
||||
|
||||
async def _h_all_markets_content(**kw):
|
||||
# ---------------------------------------------------------------------------
|
||||
# All markets (global view)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _h_all_markets_data(**kw) -> dict:
|
||||
from quart import g, url_for, request
|
||||
from shared.utils import route_prefix
|
||||
from shared.services.registry import services
|
||||
@@ -116,16 +51,26 @@ async def _h_all_markets_content(**kw):
|
||||
page_info[p.id] = {"title": p.title, "slug": p.slug}
|
||||
|
||||
if not markets:
|
||||
return _no_markets_sx()
|
||||
return {"no-markets": True}
|
||||
|
||||
prefix = route_prefix()
|
||||
next_url = prefix + url_for("all_markets.markets_fragment", page=page + 1)
|
||||
market_data = [_market_card_data(m, page_info) for m in markets]
|
||||
|
||||
cards = _market_cards_sx(markets, page_info, page, has_more, next_url)
|
||||
return _markets_grid(cards)
|
||||
return {
|
||||
"no-markets": False,
|
||||
"market-data": market_data,
|
||||
"market-page": page,
|
||||
"has-more": has_more,
|
||||
"next-url": next_url,
|
||||
}
|
||||
|
||||
|
||||
async def _h_page_markets_content(slug=None, **kw):
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page markets (markets for a single page)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _h_page_markets_data(slug=None, **kw) -> dict:
|
||||
from quart import g, url_for, request
|
||||
from shared.utils import route_prefix
|
||||
from shared.services.registry import services
|
||||
@@ -138,31 +83,76 @@ async def _h_page_markets_content(slug=None, **kw):
|
||||
post_slug = post.get("slug", "")
|
||||
|
||||
if not markets:
|
||||
return _no_markets_sx("No markets for this page")
|
||||
return {"no-markets": True}
|
||||
|
||||
prefix = route_prefix()
|
||||
next_url = prefix + url_for("page_markets.markets_fragment", page=page + 1)
|
||||
market_data = [_market_card_data(m, {}, show_page_badge=False,
|
||||
post_slug=post_slug) for m in markets]
|
||||
|
||||
cards = _market_cards_sx(markets, {}, page, has_more, next_url,
|
||||
show_page_badge=False, post_slug=post_slug)
|
||||
return _markets_grid(cards)
|
||||
return {
|
||||
"no-markets": False,
|
||||
"market-data": market_data,
|
||||
"market-page": page,
|
||||
"has-more": has_more,
|
||||
"next-url": next_url,
|
||||
}
|
||||
|
||||
|
||||
async def _h_page_admin_content(slug=None, **kw):
|
||||
# ---------------------------------------------------------------------------
|
||||
# Page admin (CRUD panel for markets under a page)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _h_page_admin_data(slug=None, **kw) -> dict:
|
||||
from quart import g, url_for
|
||||
from shared.sx.page import get_template_context
|
||||
from shared.sx.helpers import sx_call
|
||||
from shared.sx.parser import SxExpr
|
||||
from shared.services.registry import services
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from shared.utils import route_prefix
|
||||
|
||||
ctx = await get_template_context()
|
||||
content = await _markets_admin_panel_sx(ctx)
|
||||
return sx_call("market-admin-content-wrap", inner=SxExpr(content))
|
||||
rights = ctx.get("rights") or {}
|
||||
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
|
||||
has_access = ctx.get("has_access")
|
||||
can_create = has_access("page_admin.create_market") if callable(has_access) else is_admin
|
||||
csrf = generate_csrf_token()
|
||||
|
||||
post = ctx.get("post") or {}
|
||||
post_id = post.get("id")
|
||||
post_slug = post.get("slug", "")
|
||||
markets_raw = await services.market.marketplaces_for_container(g.s, "page", post_id) if post_id else []
|
||||
|
||||
prefix = route_prefix()
|
||||
markets = []
|
||||
for m in markets_raw:
|
||||
m_slug = getattr(m, "slug", "") or (m.get("slug", "") if isinstance(m, dict) else "")
|
||||
m_name = getattr(m, "name", "") or (m.get("name", "") if isinstance(m, dict) else "")
|
||||
href = prefix + f"/{post_slug}/{m_slug}/"
|
||||
del_url = url_for("page_admin.delete_market", market_slug=m_slug)
|
||||
csrf_hdr = {"X-CSRFToken": csrf}
|
||||
markets.append({
|
||||
"href": href, "name": m_name, "slug": m_slug,
|
||||
"del-url": del_url, "csrf-hdr": csrf_hdr,
|
||||
})
|
||||
|
||||
return {
|
||||
"can-create": can_create,
|
||||
"create-url": url_for("page_admin.create_market") if can_create else None,
|
||||
"csrf": csrf,
|
||||
"admin-markets": markets,
|
||||
}
|
||||
|
||||
|
||||
def _h_market_home_content(page_slug=None, market_slug=None, **kw):
|
||||
# ---------------------------------------------------------------------------
|
||||
# Market landing page
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _h_market_home_data(page_slug=None, market_slug=None, **kw) -> dict:
|
||||
from quart import g
|
||||
post_data = getattr(g, "post_data", {})
|
||||
post = post_data.get("post", {})
|
||||
return _market_landing_content_sx(post)
|
||||
|
||||
|
||||
def _h_market_admin_content(page_slug=None, market_slug=None, **kw):
|
||||
return '"market admin"'
|
||||
return {
|
||||
"excerpt": post.get("custom_excerpt") or None,
|
||||
"feature-image": post.get("feature_image") or None,
|
||||
"html": post.get("html") or None,
|
||||
}
|
||||
|
||||
@@ -1,333 +1,202 @@
|
||||
"""Layout registration + header builders."""
|
||||
"""Layout registration + header data builders."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from shared.sx.helpers import sx_call
|
||||
|
||||
from shared.sx.parser import SxExpr
|
||||
from shared.sx.helpers import (
|
||||
sx_call,
|
||||
post_header_sx as _post_header_sx,
|
||||
post_admin_header_sx,
|
||||
oob_header_sx as _oob_header_sx,
|
||||
header_child_sx,
|
||||
)
|
||||
|
||||
from .utils import _set_prices, _price_str, _clear_deeper_oob
|
||||
from .utils import _set_prices, _price_str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Header helpers
|
||||
# Header data extraction — pure data, no component references
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _market_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build the market-level header row as sx call string."""
|
||||
from quart import url_for
|
||||
|
||||
market_title = ctx.get("market_title", "")
|
||||
top_slug = ctx.get("top_slug", "")
|
||||
sub_slug = ctx.get("sub_slug", "")
|
||||
hx_select_search = ctx.get("hx_select_search", "#main-panel")
|
||||
|
||||
label_sx = sx_call(
|
||||
"market-shop-label",
|
||||
title=market_title, top_slug=top_slug or "",
|
||||
sub_div=sub_slug or None,
|
||||
)
|
||||
|
||||
link_href = url_for("defpage_market_home")
|
||||
|
||||
# Build desktop nav from categories
|
||||
categories = ctx.get("categories", {})
|
||||
qs = ctx.get("qs", "")
|
||||
nav_sx = _desktop_category_nav_sx(ctx, categories, qs, hx_select_search)
|
||||
|
||||
return sx_call(
|
||||
"menu-row-sx",
|
||||
id="market-row", level=2,
|
||||
link_href=link_href, link_label_content=SxExpr(label_sx),
|
||||
nav=SxExpr(nav_sx) if nav_sx else None,
|
||||
child_id="market-header-child", oob=oob,
|
||||
)
|
||||
|
||||
|
||||
def _desktop_category_nav_sx(ctx: dict, categories: dict, qs: str,
|
||||
hx_select: str) -> str:
|
||||
"""Build desktop category navigation links as sx."""
|
||||
def _market_header_data(ctx: dict) -> dict:
|
||||
"""Extract market header data for .sx composition."""
|
||||
from quart import url_for
|
||||
from shared.utils import route_prefix
|
||||
|
||||
prefix = route_prefix()
|
||||
category_label = ctx.get("category_label", "")
|
||||
hx_select = ctx.get("hx_select_search", "#main-panel")
|
||||
select_colours = ctx.get("select_colours", "")
|
||||
rights = ctx.get("rights", {})
|
||||
qs = ctx.get("qs", "")
|
||||
categories = ctx.get("categories", {})
|
||||
|
||||
all_href = prefix + url_for("market.browse.browse_all") + qs
|
||||
all_active = (category_label == "All Products")
|
||||
link_parts = [sx_call(
|
||||
"market-category-link",
|
||||
href=all_href, hx_select=hx_select, active=all_active,
|
||||
select_colours=select_colours, label="All",
|
||||
)]
|
||||
|
||||
cat_data = []
|
||||
for cat, data in categories.items():
|
||||
cat_href = prefix + url_for("market.browse.browse_top", top_slug=data["slug"]) + qs
|
||||
cat_active = (cat == category_label)
|
||||
link_parts.append(sx_call(
|
||||
"market-category-link",
|
||||
href=cat_href, hx_select=hx_select, active=cat_active,
|
||||
select_colours=select_colours, label=cat,
|
||||
))
|
||||
cat_data.append({
|
||||
"href": cat_href,
|
||||
"active": cat == ctx.get("category_label", ""),
|
||||
"label": cat,
|
||||
})
|
||||
|
||||
links_sx = "(<> " + " ".join(link_parts) + ")"
|
||||
|
||||
admin_sx = ""
|
||||
admin_href = ""
|
||||
if rights and rights.get("admin"):
|
||||
admin_href = prefix + url_for("defpage_market_admin")
|
||||
admin_sx = sx_call("market-admin-link", href=admin_href, hx_select=hx_select)
|
||||
|
||||
return sx_call("market-desktop-category-nav",
|
||||
links=SxExpr(links_sx),
|
||||
admin=SxExpr(admin_sx) if admin_sx else None)
|
||||
return {
|
||||
"market-title": ctx.get("market_title", ""),
|
||||
"top-slug": ctx.get("top_slug", ""),
|
||||
"sub-slug": ctx.get("sub_slug", ""),
|
||||
"link-href": url_for("defpage_market_home"),
|
||||
"categories": cat_data,
|
||||
"hx-select": hx_select,
|
||||
"select-colours": select_colours,
|
||||
"all-href": all_href,
|
||||
"all-active": ctx.get("category_label", "") == "All Products",
|
||||
"admin-href": admin_href,
|
||||
}
|
||||
|
||||
|
||||
def _product_header_sx(ctx: dict, d: dict, *, oob: bool = False) -> str:
|
||||
"""Build the product-level header row as sx call string."""
|
||||
def _market_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
"""Build market header as sx — delegates to .sx defcomp."""
|
||||
data = _market_header_data(ctx)
|
||||
return sx_call("market-header-from-data", oob=oob, **data)
|
||||
|
||||
|
||||
def _product_header_data(ctx: dict, d: dict) -> dict:
|
||||
"""Extract product header data for .sx composition."""
|
||||
from quart import url_for
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
slug = d.get("slug", "")
|
||||
title = d.get("title", "")
|
||||
hx_select_search = ctx.get("hx_select_search", "#main-panel")
|
||||
link_href = url_for("market.browse.product.product_detail", product_slug=slug)
|
||||
|
||||
label_sx = sx_call("market-product-label", title=title)
|
||||
|
||||
# Prices in nav area
|
||||
pr = _set_prices(d)
|
||||
hx_select = ctx.get("hx_select_search", "#main-panel")
|
||||
cart = ctx.get("cart", [])
|
||||
prices_nav = _prices_header_sx(d, pr, cart, slug, ctx)
|
||||
|
||||
rights = ctx.get("rights", {})
|
||||
nav_parts = [prices_nav]
|
||||
if rights and rights.get("admin"):
|
||||
admin_href = url_for("market.browse.product.admin", product_slug=slug)
|
||||
nav_parts.append(sx_call("market-admin-link", href=admin_href, hx_select=hx_select_search))
|
||||
nav_sx = "(<> " + " ".join(nav_parts) + ")"
|
||||
|
||||
return sx_call(
|
||||
"menu-row-sx",
|
||||
id="product-row", level=3,
|
||||
link_href=link_href, link_label_content=SxExpr(label_sx),
|
||||
nav=SxExpr(nav_sx), child_id="product-header-child", oob=oob,
|
||||
)
|
||||
|
||||
|
||||
def _prices_header_sx(d: dict, pr: dict, cart: list, slug: str, ctx: dict) -> str:
|
||||
"""Build prices + add-to-cart for product header row as sx."""
|
||||
from quart import url_for
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
|
||||
csrf = generate_csrf_token()
|
||||
cart_action = url_for("market.browse.product.cart", product_slug=slug)
|
||||
cart_url_fn = ctx.get("cart_url")
|
||||
|
||||
# Add-to-cart button
|
||||
quantity = sum(ci.quantity for ci in cart if ci.product.slug == slug) if cart else 0
|
||||
add_sx = _cart_add_sx(slug, quantity, cart_action, csrf, cart_url_fn)
|
||||
|
||||
parts = [add_sx]
|
||||
sp_val, rp_val = pr.get("sp_val"), pr.get("rp_val")
|
||||
if sp_val:
|
||||
parts.append(sx_call("market-header-price-special-label"))
|
||||
parts.append(sx_call("market-header-price-special",
|
||||
price=_price_str(sp_val, pr["sp_raw"], pr["sp_cur"])))
|
||||
if rp_val:
|
||||
parts.append(sx_call("market-header-price-strike",
|
||||
price=_price_str(rp_val, pr["rp_raw"], pr["rp_cur"])))
|
||||
elif rp_val:
|
||||
parts.append(sx_call("market-header-price-regular-label"))
|
||||
parts.append(sx_call("market-header-price-regular",
|
||||
price=_price_str(rp_val, pr["rp_raw"], pr["rp_cur"])))
|
||||
# Price data
|
||||
pr = _set_prices(d)
|
||||
sp_str = _price_str(pr["sp_val"], pr["sp_raw"], pr["sp_cur"]) if pr["sp_val"] else ""
|
||||
rp_str = _price_str(pr["rp_val"], pr["rp_raw"], pr["rp_cur"]) if pr["rp_val"] else ""
|
||||
|
||||
# RRP
|
||||
rrp_str = ""
|
||||
rrp_raw = d.get("rrp_raw")
|
||||
rrp_val = d.get("rrp")
|
||||
case_size = d.get("case_size_count") or 1
|
||||
if rrp_raw and rrp_val:
|
||||
rrp_str = f"{rrp_raw[0]}{rrp_val * case_size:.2f}"
|
||||
parts.append(sx_call("market-header-rrp", rrp=rrp_str))
|
||||
|
||||
inner_sx = "(<> " + " ".join(parts) + ")"
|
||||
return sx_call("market-prices-row", inner=SxExpr(inner_sx))
|
||||
|
||||
|
||||
def _cart_add_sx(slug: str, quantity: int, action: str, csrf: str,
|
||||
cart_url_fn: Any = None) -> str:
|
||||
"""Build add-to-cart button or quantity controls as sx."""
|
||||
if not quantity:
|
||||
return sx_call(
|
||||
"market-cart-add-empty",
|
||||
cart_id=f"cart-{slug}", action=action, csrf=csrf,
|
||||
)
|
||||
|
||||
# Cart state
|
||||
csrf = generate_csrf_token()
|
||||
cart_action = url_for("market.browse.product.cart", product_slug=slug)
|
||||
quantity = sum(ci.quantity for ci in cart if ci.product.slug == slug) if cart else 0
|
||||
cart_url_fn = ctx.get("cart_url")
|
||||
cart_href = cart_url_fn("/") if callable(cart_url_fn) else "/"
|
||||
return sx_call(
|
||||
"market-cart-add-quantity",
|
||||
cart_id=f"cart-{slug}", action=action, csrf=csrf,
|
||||
minus_val=str(quantity - 1), plus_val=str(quantity + 1),
|
||||
quantity=str(quantity), cart_href=cart_href,
|
||||
)
|
||||
|
||||
admin_href = ""
|
||||
if rights and rights.get("admin"):
|
||||
admin_href = url_for("market.browse.product.admin", product_slug=slug)
|
||||
|
||||
return {
|
||||
"title": d.get("title", ""),
|
||||
"link-href": url_for("market.browse.product.product_detail", product_slug=slug),
|
||||
"hx-select": hx_select,
|
||||
"price-data": {
|
||||
"cart-id": f"cart-{slug}",
|
||||
"cart-action": cart_action,
|
||||
"csrf": csrf,
|
||||
"quantity": quantity,
|
||||
"cart-href": cart_href,
|
||||
"sp-val": pr["sp_val"] or "",
|
||||
"sp-str": sp_str,
|
||||
"rp-val": pr["rp_val"] or "",
|
||||
"rp-str": rp_str,
|
||||
"rrp-str": rrp_str,
|
||||
},
|
||||
"admin-href": admin_href,
|
||||
}
|
||||
|
||||
|
||||
def _product_header_sx(ctx: dict, d: dict, *, oob: bool = False) -> str:
|
||||
"""Build product header as sx — delegates to .sx defcomp."""
|
||||
data = _product_header_data(ctx, d)
|
||||
return sx_call("market-product-header-from-data", oob=oob, **data)
|
||||
|
||||
|
||||
def _product_admin_header_sx(ctx: dict, d: dict, *, oob: bool = False) -> str:
|
||||
"""Build product admin header as sx — delegates to .sx defcomp."""
|
||||
from quart import url_for
|
||||
slug = d.get("slug", "")
|
||||
link_href = url_for("market.browse.product.admin", product_slug=slug)
|
||||
return sx_call("market-product-admin-header-from-data",
|
||||
link_href=link_href, oob=oob)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mobile nav panel
|
||||
# Mobile nav panel data extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _mobile_nav_panel_sx(ctx: dict) -> str:
|
||||
"""Build mobile nav panel with category accordion as sx."""
|
||||
def _mobile_nav_data(ctx: dict) -> dict:
|
||||
"""Extract mobile nav panel data for .sx composition."""
|
||||
from quart import url_for
|
||||
from shared.utils import route_prefix
|
||||
|
||||
prefix = route_prefix()
|
||||
categories = ctx.get("categories", {})
|
||||
qs = ctx.get("qs", "")
|
||||
category_label = ctx.get("category_label", "")
|
||||
top_slug = ctx.get("top_slug", "")
|
||||
sub_slug = ctx.get("sub_slug", "")
|
||||
hx_select = ctx.get("hx_select_search", "#main-panel")
|
||||
select_colours = ctx.get("select_colours", "")
|
||||
|
||||
all_href = prefix + url_for("market.browse.browse_all") + qs
|
||||
all_active = (category_label == "All Products")
|
||||
item_parts = [sx_call(
|
||||
"market-mobile-all-link",
|
||||
href=all_href, hx_select=hx_select, active=all_active,
|
||||
select_colours=select_colours,
|
||||
)]
|
||||
|
||||
cat_data = []
|
||||
for cat, data in categories.items():
|
||||
cat_slug = data.get("slug", "")
|
||||
cat_active = (top_slug == cat_slug.lower() if top_slug else False)
|
||||
cat_active = top_slug == cat_slug.lower() if top_slug else False
|
||||
cat_href = prefix + url_for("market.browse.browse_top", top_slug=cat_slug) + qs
|
||||
bg_cls = " bg-stone-900 text-white hover:bg-stone-900" if cat_active else ""
|
||||
|
||||
chevron_sx = sx_call("market-mobile-chevron")
|
||||
|
||||
cat_count = data.get("count", 0)
|
||||
summary_sx = sx_call(
|
||||
"market-mobile-cat-summary",
|
||||
bg_cls=bg_cls, href=cat_href, hx_select=hx_select,
|
||||
select_colours=select_colours, cat_name=cat,
|
||||
count_label=f"{cat_count} products", count_str=str(cat_count),
|
||||
chevron=SxExpr(chevron_sx),
|
||||
)
|
||||
|
||||
subs = data.get("subs", [])
|
||||
subs_sx = ""
|
||||
sub_data = []
|
||||
if subs:
|
||||
sub_link_parts = []
|
||||
for sub in subs:
|
||||
sub_href = prefix + url_for("market.browse.browse_sub", top_slug=cat_slug, sub_slug=sub["slug"]) + qs
|
||||
sub_active = (cat_active and sub_slug == sub.get("slug"))
|
||||
sub_href = prefix + url_for("market.browse.browse_sub",
|
||||
top_slug=cat_slug, sub_slug=sub["slug"]) + qs
|
||||
sub_active = cat_active and sub_slug == sub.get("slug")
|
||||
sub_label = sub.get("html_label") or sub.get("name", "")
|
||||
sub_count = sub.get("count", 0)
|
||||
sub_link_parts.append(sx_call(
|
||||
"market-mobile-sub-link",
|
||||
select_colours=select_colours, active=sub_active,
|
||||
href=sub_href, hx_select=hx_select, label=sub_label,
|
||||
count_label=f"{sub_count} products", count_str=str(sub_count),
|
||||
))
|
||||
sub_links_sx = "(<> " + " ".join(sub_link_parts) + ")"
|
||||
subs_sx = sx_call("market-mobile-subs-panel", links=SxExpr(sub_links_sx))
|
||||
else:
|
||||
view_href = prefix + url_for("market.browse.browse_top", top_slug=cat_slug) + qs
|
||||
subs_sx = sx_call("market-mobile-view-all", href=view_href, hx_select=hx_select)
|
||||
sub_data.append({
|
||||
"href": sub_href,
|
||||
"active": sub_active,
|
||||
"label": sub_label,
|
||||
"count": sub.get("count", 0),
|
||||
})
|
||||
|
||||
item_parts.append(sx_call(
|
||||
"market-mobile-cat-details",
|
||||
open=cat_active or None,
|
||||
summary=SxExpr(summary_sx),
|
||||
subs=SxExpr(subs_sx),
|
||||
))
|
||||
cat_data.append({
|
||||
"name": cat,
|
||||
"href": cat_href,
|
||||
"active": cat_active,
|
||||
"count": data.get("count", 0),
|
||||
"subs": sub_data if sub_data else None,
|
||||
})
|
||||
|
||||
items_sx = "(<> " + " ".join(item_parts) + ")"
|
||||
return sx_call("market-mobile-nav-wrapper", items=SxExpr(items_sx))
|
||||
return {
|
||||
"categories": cat_data,
|
||||
"all-href": all_href,
|
||||
"all-active": ctx.get("category_label", "") == "All Products",
|
||||
"hx-select": hx_select,
|
||||
"select-colours": select_colours,
|
||||
}
|
||||
|
||||
|
||||
def _mobile_nav_panel_sx(ctx: dict) -> str:
|
||||
"""Build mobile nav panel as sx — delegates to .sx defcomp."""
|
||||
data = _mobile_nav_data(ctx)
|
||||
return sx_call("market-mobile-nav-from-data", **data)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Product admin header
|
||||
# Layout registration — all layouts delegate to .sx defcomps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _product_admin_header_sx(ctx: dict, d: dict, *, oob: bool = False) -> str:
|
||||
"""Build product admin header row as sx."""
|
||||
from quart import url_for
|
||||
|
||||
slug = d.get("slug", "")
|
||||
link_href = url_for("market.browse.product.admin", product_slug=slug)
|
||||
return sx_call(
|
||||
"menu-row-sx",
|
||||
id="product-admin-row", level=4,
|
||||
link_href=link_href, link_label="admin!!", icon="fa fa-cog",
|
||||
child_id="product-admin-header-child", oob=oob,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Market admin header
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _market_admin_header_sx(ctx: dict, *, oob: bool = False, selected: str = "") -> str:
|
||||
"""Build market admin header row -- delegates to shared helper."""
|
||||
slug = (ctx.get("post") or {}).get("slug", "")
|
||||
return await post_admin_header_sx(ctx, slug, oob=oob, selected=selected)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Layout registration
|
||||
# ===========================================================================
|
||||
|
||||
def _register_market_layouts() -> None:
|
||||
from shared.sx.layouts import register_custom_layout
|
||||
register_custom_layout("market", _market_full, _market_oob, _market_mobile)
|
||||
register_custom_layout("market-admin", _market_admin_full, _market_admin_oob)
|
||||
|
||||
|
||||
async def _market_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env
|
||||
return await render_to_sx_with_env("market-browse-layout-full", {},
|
||||
post_header=SxExpr(await _post_header_sx(ctx)),
|
||||
market_header=SxExpr(_market_header_sx(ctx)))
|
||||
|
||||
|
||||
async def _market_oob(ctx: dict, **kw: Any) -> str:
|
||||
oob_hdr = await _oob_header_sx("post-header-child", "market-header-child",
|
||||
_market_header_sx(ctx))
|
||||
return sx_call("market-browse-layout-oob",
|
||||
oob_header=SxExpr(oob_hdr),
|
||||
post_header_oob=SxExpr(await _post_header_sx(ctx, oob=True)),
|
||||
clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child",
|
||||
"market-row", "market-header-child")))
|
||||
|
||||
|
||||
def _market_mobile(ctx: dict, **kw: Any) -> str:
|
||||
return _mobile_nav_panel_sx(ctx)
|
||||
|
||||
|
||||
async def _market_admin_full(ctx: dict, **kw: Any) -> str:
|
||||
from shared.sx.helpers import render_to_sx_with_env
|
||||
selected = kw.get("selected", "")
|
||||
return await render_to_sx_with_env("market-admin-layout-full", {},
|
||||
post_header=SxExpr(await _post_header_sx(ctx)),
|
||||
market_header=SxExpr(_market_header_sx(ctx)),
|
||||
admin_header=SxExpr(await _market_admin_header_sx(ctx, selected=selected)))
|
||||
|
||||
|
||||
async def _market_admin_oob(ctx: dict, **kw: Any) -> str:
|
||||
selected = kw.get("selected", "")
|
||||
return sx_call("market-admin-layout-oob",
|
||||
market_header_oob=SxExpr(_market_header_sx(ctx, oob=True)),
|
||||
admin_oob_header=SxExpr(await _oob_header_sx("market-header-child", "market-admin-header-child",
|
||||
await _market_admin_header_sx(ctx, selected=selected))),
|
||||
clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child",
|
||||
"market-row", "market-header-child",
|
||||
"market-admin-row", "market-admin-header-child")))
|
||||
from shared.sx.layouts import register_sx_layout
|
||||
register_sx_layout("market",
|
||||
"market-browse-layout-full", "market-browse-layout-oob",
|
||||
"market-browse-layout-mobile")
|
||||
register_sx_layout("market-admin",
|
||||
"market-admin-layout-full", "market-admin-layout-oob")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
;; Market app defpage declarations.
|
||||
;; All helpers return data dicts — markup composition in SX.
|
||||
;;
|
||||
;; all-markets-index: / — global view across all pages
|
||||
;; page-markets-index: /<slug>/ — markets for a single page
|
||||
@@ -10,28 +11,64 @@
|
||||
:path "/"
|
||||
:auth :public
|
||||
:layout :root
|
||||
:content (all-markets-content))
|
||||
:data (all-markets-data)
|
||||
:content (if no-markets
|
||||
(~empty-state :icon "fa fa-store" :message "No markets available"
|
||||
:cls "px-3 py-12 text-center text-stone-400")
|
||||
(~market-markets-grid
|
||||
:cards (~market-cards-content
|
||||
:markets market-data :page market-page
|
||||
:has-more has-more :next-url next-url))))
|
||||
|
||||
(defpage page-markets-index
|
||||
:path "/<slug>/"
|
||||
:auth :public
|
||||
:layout :post
|
||||
:content (page-markets-content))
|
||||
:data (page-markets-data)
|
||||
:content (if no-markets
|
||||
(~empty-state :message "No markets for this page"
|
||||
:cls "px-3 py-12 text-center text-stone-400")
|
||||
(~market-markets-grid
|
||||
:cards (~market-cards-content
|
||||
:markets market-data :page market-page
|
||||
:has-more has-more :next-url next-url))))
|
||||
|
||||
(defpage page-admin
|
||||
:path "/<slug>/admin/"
|
||||
:auth :admin
|
||||
:layout (:post-admin :selected "markets")
|
||||
:content (page-admin-content))
|
||||
:data (page-admin-data)
|
||||
:content (~market-admin-content-wrap
|
||||
:inner (~crud-panel
|
||||
:list-id "markets-list"
|
||||
:form (when can-create
|
||||
(~crud-create-form
|
||||
:create-url create-url :csrf csrf
|
||||
:errors-id "market-create-errors" :list-id "markets-list"
|
||||
:placeholder "e.g. Suma, Craft Fair" :btn-label "Add market"))
|
||||
:list (if admin-markets
|
||||
(<> (map (fn (m)
|
||||
(~crud-item
|
||||
:href (get m "href") :name (get m "name") :slug (get m "slug")
|
||||
:del-url (get m "del-url") :csrf-hdr (get m "csrf-hdr")
|
||||
:list-id "markets-list"
|
||||
:confirm-title "Delete market?"
|
||||
:confirm-text "Products will be hidden (soft delete)"))
|
||||
admin-markets))
|
||||
(~empty-state
|
||||
:message "No markets yet. Create one above."
|
||||
:cls "text-gray-500 mt-4")))))
|
||||
|
||||
(defpage market-home
|
||||
:path "/<page_slug>/<market_slug>/"
|
||||
:auth :public
|
||||
:layout :market
|
||||
:content (market-home-content))
|
||||
:data (market-home-data)
|
||||
:content (~market-landing-from-data
|
||||
:excerpt excerpt :feature-image feature-image :html html))
|
||||
|
||||
(defpage market-admin
|
||||
:path "/<page_slug>/<market_slug>/admin/"
|
||||
:auth :admin
|
||||
:layout (:market-admin :selected "markets")
|
||||
:content (market-admin-content))
|
||||
:content "market admin")
|
||||
|
||||
@@ -11,7 +11,7 @@ from shared.sx.helpers import (
|
||||
full_page_sx, oob_page_sx,
|
||||
)
|
||||
|
||||
from .utils import _set_prices, _price_str, _clear_deeper_oob, _product_detail_sx, _product_meta_sx
|
||||
from .utils import _clear_deeper_oob, _product_detail_sx, _product_meta_sx
|
||||
from .cards import _product_cards_sx, _market_cards_sx
|
||||
from .filters import _desktop_filter_sx, _mobile_filter_summary_sx
|
||||
from .layouts import (
|
||||
@@ -36,9 +36,7 @@ async def render_browse_page(ctx: dict) -> str:
|
||||
content = _product_grid(cards)
|
||||
|
||||
from shared.sx.helpers import render_to_sx_with_env
|
||||
hdr = await render_to_sx_with_env("market-browse-layout-full", {},
|
||||
post_header=SxExpr(await _post_header_sx(ctx)),
|
||||
market_header=SxExpr(_market_header_sx(ctx)))
|
||||
hdr = await render_to_sx_with_env("market-browse-layout-full", {})
|
||||
menu = _mobile_nav_panel_sx(ctx)
|
||||
filter_sx = await _mobile_filter_summary_sx(ctx)
|
||||
aside_sx = await _desktop_filter_sx(ctx)
|
||||
@@ -52,13 +50,8 @@ async def render_browse_oob(ctx: dict) -> str:
|
||||
cards = _product_cards_sx(ctx)
|
||||
content = _product_grid(cards)
|
||||
|
||||
oob_hdr = await _oob_header_sx("post-header-child", "market-header-child",
|
||||
_market_header_sx(ctx))
|
||||
oobs = sx_call("market-browse-layout-oob",
|
||||
oob_header=SxExpr(oob_hdr),
|
||||
post_header_oob=SxExpr(await _post_header_sx(ctx, oob=True)),
|
||||
clear_oob=SxExpr(_clear_deeper_oob("post-row", "post-header-child",
|
||||
"market-row", "market-header-child")))
|
||||
# Layout handles all OOB headers via auto-fetch macros
|
||||
oobs = sx_call("market-browse-layout-oob")
|
||||
menu = _mobile_nav_panel_sx(ctx)
|
||||
filter_sx = await _mobile_filter_summary_sx(ctx)
|
||||
aside_sx = await _desktop_filter_sx(ctx)
|
||||
@@ -83,9 +76,9 @@ async def render_product_page(ctx: dict, d: dict) -> str:
|
||||
|
||||
from shared.sx.helpers import render_to_sx_with_env
|
||||
hdr = await render_to_sx_with_env("market-product-layout-full", {},
|
||||
post_header=SxExpr(await _post_header_sx(ctx)),
|
||||
market_header=SxExpr(_market_header_sx(ctx)),
|
||||
product_header=SxExpr(_product_header_sx(ctx, d)))
|
||||
post_header=await _post_header_sx(ctx),
|
||||
market_header=_market_header_sx(ctx),
|
||||
product_header=_product_header_sx(ctx, d))
|
||||
return await full_page_sx(ctx, header_rows=hdr, content=content, meta=meta)
|
||||
|
||||
|
||||
@@ -114,10 +107,10 @@ async def render_product_admin_page(ctx: dict, d: dict) -> str:
|
||||
|
||||
from shared.sx.helpers import render_to_sx_with_env
|
||||
hdr = await render_to_sx_with_env("market-product-admin-layout-full", {},
|
||||
post_header=SxExpr(await _post_header_sx(ctx)),
|
||||
market_header=SxExpr(_market_header_sx(ctx)),
|
||||
product_header=SxExpr(_product_header_sx(ctx, d)),
|
||||
admin_header=SxExpr(_product_admin_header_sx(ctx, d)))
|
||||
post_header=await _post_header_sx(ctx),
|
||||
market_header=_market_header_sx(ctx),
|
||||
product_header=_product_header_sx(ctx, d),
|
||||
admin_header=_product_admin_header_sx(ctx, d))
|
||||
return await full_page_sx(ctx, header_rows=hdr, content=content)
|
||||
|
||||
|
||||
@@ -196,7 +189,7 @@ def render_like_toggle_button(slug: str, liked: bool, *,
|
||||
return sx_call(
|
||||
"market-like-toggle-button",
|
||||
colour=colour, action=like_url,
|
||||
hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
|
||||
hx_headers={"X-CSRFToken": csrf},
|
||||
label=label, icon_cls=icon,
|
||||
)
|
||||
|
||||
@@ -207,7 +200,7 @@ def render_cart_added_response(cart: list, item: Any, d: dict) -> str:
|
||||
Returns OOB fragments: cart-mini icon + product add/remove buttons + cart item row.
|
||||
"""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from quart import url_for, g
|
||||
from quart import url_for
|
||||
from shared.infrastructure.urls import cart_url as _cart_url
|
||||
|
||||
csrf = generate_csrf_token()
|
||||
@@ -243,7 +236,7 @@ def render_cart_added_response(cart: list, item: Any, d: dict) -> str:
|
||||
add_sx = sx_call(
|
||||
"market-cart-add-oob",
|
||||
id=f"cart-add-{slug}",
|
||||
inner=SxExpr(cart_add),
|
||||
inner=cart_add,
|
||||
)
|
||||
|
||||
return "(<> " + cart_mini + " " + add_sx + ")"
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
"""Price helpers, OOB helpers, product detail/meta builders."""
|
||||
"""Price helpers, OOB helpers, product detail/meta data builders."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from shared.sx.parser import SxExpr
|
||||
from shared.sx.helpers import sx_call
|
||||
|
||||
|
||||
@@ -54,31 +51,14 @@ def _set_prices(item: dict) -> dict:
|
||||
rp_val=rp_val, rp_raw=rp_raw, rp_cur=rp_cur)
|
||||
|
||||
|
||||
def _card_price_sx(p: dict) -> str:
|
||||
"""Build price line for product card as sx call."""
|
||||
pr = _set_prices(p)
|
||||
sp_str = _price_str(pr["sp_val"], pr["sp_raw"], pr["sp_cur"])
|
||||
rp_str = _price_str(pr["rp_val"], pr["rp_raw"], pr["rp_cur"])
|
||||
parts: list[str] = []
|
||||
if pr["sp_val"]:
|
||||
parts.append(sx_call("market-price-special", price=sp_str))
|
||||
if pr["rp_val"]:
|
||||
parts.append(sx_call("market-price-regular-strike", price=rp_str))
|
||||
elif pr["rp_val"]:
|
||||
parts.append(sx_call("market-price-regular", price=rp_str))
|
||||
inner = "(<> " + " ".join(parts) + ")" if parts else None
|
||||
return sx_call("market-price-line", inner=SxExpr(inner) if inner else None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Product detail page content
|
||||
# Product detail data extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _product_detail_sx(d: dict, ctx: dict) -> str:
|
||||
"""Build product detail main panel content as sx."""
|
||||
from quart import url_for
|
||||
def _product_detail_data(d: dict, ctx: dict) -> dict:
|
||||
"""Extract product detail page data for .sx composition."""
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
from .cards import _like_button_sx
|
||||
from .cards import _like_button_data
|
||||
|
||||
asset_url_fn = ctx.get("asset_url")
|
||||
user = ctx.get("user")
|
||||
@@ -91,132 +71,70 @@ def _product_detail_sx(d: dict, ctx: dict) -> str:
|
||||
brand = d.get("brand", "")
|
||||
slug = d.get("slug", "")
|
||||
|
||||
# Gallery
|
||||
if images:
|
||||
# Like button
|
||||
like_sx = ""
|
||||
if user:
|
||||
like_sx = _like_button_sx(slug, liked_by_current_user, csrf, ctx)
|
||||
# Like button data
|
||||
like_data = None
|
||||
if user:
|
||||
like_data = _like_button_data(slug, liked_by_current_user, csrf, ctx)
|
||||
|
||||
# Main image + labels
|
||||
label_parts: list[str] = []
|
||||
if callable(asset_url_fn):
|
||||
for l in labels:
|
||||
label_parts.append(sx_call(
|
||||
"market-label-overlay",
|
||||
src=asset_url_fn("labels/" + l + ".svg"),
|
||||
))
|
||||
labels_sx = "(<> " + " ".join(label_parts) + ")" if label_parts else None
|
||||
# Label overlay URLs
|
||||
label_urls = []
|
||||
if callable(asset_url_fn):
|
||||
label_urls = [asset_url_fn("labels/" + l + ".svg") for l in labels]
|
||||
|
||||
gallery_inner = sx_call(
|
||||
"market-detail-gallery-inner",
|
||||
like=SxExpr(like_sx) if like_sx else None,
|
||||
image=images[0], alt=d.get("title", ""),
|
||||
labels=SxExpr(labels_sx) if labels_sx else None,
|
||||
brand=brand,
|
||||
)
|
||||
# Image data
|
||||
image_data = [{"src": u, "alt": d.get("title", "")} for u in images] if images else []
|
||||
|
||||
# Prev/next buttons
|
||||
nav_buttons = ""
|
||||
if len(images) > 1:
|
||||
nav_buttons = sx_call("market-detail-nav-buttons")
|
||||
# Thumbnail data
|
||||
thumb_data = []
|
||||
if len(images) > 1:
|
||||
for i, u in enumerate(images):
|
||||
thumb_data.append({"title": f"Image {i+1}", "src": u, "alt": f"thumb {i+1}"})
|
||||
|
||||
gallery_sx = sx_call(
|
||||
"market-detail-gallery",
|
||||
inner=SxExpr(gallery_inner),
|
||||
nav=SxExpr(nav_buttons) if nav_buttons else None,
|
||||
)
|
||||
|
||||
# Thumbnails
|
||||
gallery_parts = [gallery_sx]
|
||||
if len(images) > 1:
|
||||
thumb_parts = []
|
||||
for i, u in enumerate(images):
|
||||
thumb_parts.append(sx_call(
|
||||
"market-detail-thumb",
|
||||
title=f"Image {i+1}", src=u, alt=f"thumb {i+1}",
|
||||
))
|
||||
thumbs_sx = "(<> " + " ".join(thumb_parts) + ")"
|
||||
gallery_parts.append(sx_call("market-detail-thumbs", thumbs=SxExpr(thumbs_sx)))
|
||||
gallery_final = "(<> " + " ".join(gallery_parts) + ")"
|
||||
else:
|
||||
like_sx = ""
|
||||
if user:
|
||||
like_sx = _like_button_sx(slug, liked_by_current_user, csrf, ctx)
|
||||
gallery_final = sx_call("market-detail-no-image",
|
||||
like=SxExpr(like_sx) if like_sx else None)
|
||||
|
||||
# Stickers below gallery
|
||||
stickers_sx = ""
|
||||
# Sticker items
|
||||
sticker_items = []
|
||||
if stickers and callable(asset_url_fn):
|
||||
sticker_parts = []
|
||||
for s in stickers:
|
||||
sticker_parts.append(sx_call(
|
||||
"market-detail-sticker",
|
||||
src=asset_url_fn("stickers/" + s + ".svg"), name=s,
|
||||
))
|
||||
sticker_items_sx = "(<> " + " ".join(sticker_parts) + ")"
|
||||
stickers_sx = sx_call("market-detail-stickers", items=SxExpr(sticker_items_sx))
|
||||
sticker_items.append({"src": asset_url_fn("stickers/" + s + ".svg"), "name": s})
|
||||
|
||||
# Right column: prices, description, sections
|
||||
pr = _set_prices(d)
|
||||
detail_parts: list[str] = []
|
||||
|
||||
# Unit price / case size extras
|
||||
extra_parts: list[str] = []
|
||||
# Extras (unit price, case size)
|
||||
extras = []
|
||||
ppu = d.get("price_per_unit") or d.get("price_per_unit_raw")
|
||||
if ppu:
|
||||
extra_parts.append(sx_call(
|
||||
"market-detail-unit-price",
|
||||
price=_price_str(d.get("price_per_unit"), d.get("price_per_unit_raw"), d.get("price_per_unit_currency")),
|
||||
))
|
||||
extras.append({
|
||||
"type": "unit-price",
|
||||
"value": _price_str(d.get("price_per_unit"), d.get("price_per_unit_raw"),
|
||||
d.get("price_per_unit_currency")),
|
||||
})
|
||||
if d.get("case_size_raw"):
|
||||
extra_parts.append(sx_call("market-detail-case-size", size=d["case_size_raw"]))
|
||||
if extra_parts:
|
||||
extras_sx = "(<> " + " ".join(extra_parts) + ")"
|
||||
detail_parts.append(sx_call("market-detail-extras", inner=SxExpr(extras_sx)))
|
||||
extras.append({"type": "case-size", "value": d["case_size_raw"]})
|
||||
|
||||
# Description
|
||||
desc_short = d.get("description_short")
|
||||
desc_html_val = d.get("description_html")
|
||||
if desc_short or desc_html_val:
|
||||
desc_parts: list[str] = []
|
||||
if desc_short:
|
||||
desc_parts.append(sx_call("market-detail-desc-short", text=desc_short))
|
||||
if desc_html_val:
|
||||
desc_parts.append(sx_call("market-detail-desc-html", html=desc_html_val))
|
||||
desc_inner = "(<> " + " ".join(desc_parts) + ")"
|
||||
detail_parts.append(sx_call("market-detail-desc-wrapper", inner=SxExpr(desc_inner)))
|
||||
return {
|
||||
"images": image_data or None,
|
||||
"labels": label_urls or None,
|
||||
"brand": brand,
|
||||
"like-data": like_data,
|
||||
"has-nav-buttons": len(images) > 1,
|
||||
"thumbs": thumb_data or None,
|
||||
"sticker-items": sticker_items or None,
|
||||
"extras": extras or None,
|
||||
"desc-short": d.get("description_short") or None,
|
||||
"desc-html": d.get("description_html") or None,
|
||||
"sections": d.get("sections") or None,
|
||||
}
|
||||
|
||||
# Sections (expandable)
|
||||
sections = d.get("sections", [])
|
||||
if sections:
|
||||
sec_parts = []
|
||||
for sec in sections:
|
||||
sec_parts.append(sx_call(
|
||||
"market-detail-section",
|
||||
title=sec.get("title", ""), html=sec.get("html", ""),
|
||||
))
|
||||
sec_items_sx = "(<> " + " ".join(sec_parts) + ")"
|
||||
detail_parts.append(sx_call("market-detail-sections", items=SxExpr(sec_items_sx)))
|
||||
|
||||
details_inner_sx = "(<> " + " ".join(detail_parts) + ")" if detail_parts else "(<>)"
|
||||
details_sx = sx_call("market-detail-right-col", inner=SxExpr(details_inner_sx))
|
||||
|
||||
return sx_call(
|
||||
"market-detail-layout",
|
||||
gallery=SxExpr(gallery_final),
|
||||
stickers=SxExpr(stickers_sx) if stickers_sx else None,
|
||||
details=SxExpr(details_sx),
|
||||
)
|
||||
def _product_detail_sx(d: dict, ctx: dict) -> str:
|
||||
"""Build product detail content — delegates to .sx defcomp."""
|
||||
data = _product_detail_data(d, ctx)
|
||||
return sx_call("market-product-detail-from-data", **data)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Product meta (OpenGraph, JSON-LD)
|
||||
# Product meta data extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _product_meta_sx(d: dict, ctx: dict) -> str:
|
||||
"""Build product meta tags as sx (auto-hoisted to <head> by sx.js)."""
|
||||
def _product_meta_data(d: dict, ctx: dict) -> dict:
|
||||
"""Extract product meta/SEO data for .sx composition."""
|
||||
import json
|
||||
from quart import request
|
||||
|
||||
@@ -231,36 +149,8 @@ def _product_meta_sx(d: dict, ctx: dict) -> str:
|
||||
brand = d.get("brand", "")
|
||||
sku = d.get("sku", "")
|
||||
price = d.get("special_price") or d.get("regular_price") or d.get("rrp")
|
||||
price_currency = d.get("special_price_currency") or d.get("regular_price_currency") or d.get("rrp_currency")
|
||||
|
||||
parts = [sx_call("market-meta-title", title=title)]
|
||||
parts.append(sx_call("market-meta-description", description=description))
|
||||
if canonical:
|
||||
parts.append(sx_call("market-meta-canonical", href=canonical))
|
||||
|
||||
# OpenGraph
|
||||
site_title = ctx.get("base_title", "")
|
||||
parts.append(sx_call("market-meta-og", property="og:site_name", content=site_title))
|
||||
parts.append(sx_call("market-meta-og", property="og:type", content="product"))
|
||||
parts.append(sx_call("market-meta-og", property="og:title", content=title))
|
||||
parts.append(sx_call("market-meta-og", property="og:description", content=description))
|
||||
if canonical:
|
||||
parts.append(sx_call("market-meta-og", property="og:url", content=canonical))
|
||||
if image_url:
|
||||
parts.append(sx_call("market-meta-og", property="og:image", content=image_url))
|
||||
if price and price_currency:
|
||||
parts.append(sx_call("market-meta-og", property="product:price:amount", content=f"{price:.2f}"))
|
||||
parts.append(sx_call("market-meta-og", property="product:price:currency", content=price_currency))
|
||||
if brand:
|
||||
parts.append(sx_call("market-meta-og", property="product:brand", content=brand))
|
||||
|
||||
# Twitter
|
||||
card_type = "summary_large_image" if image_url else "summary"
|
||||
parts.append(sx_call("market-meta-twitter", name="twitter:card", content=card_type))
|
||||
parts.append(sx_call("market-meta-twitter", name="twitter:title", content=title))
|
||||
parts.append(sx_call("market-meta-twitter", name="twitter:description", content=description))
|
||||
if image_url:
|
||||
parts.append(sx_call("market-meta-twitter", name="twitter:image", content=image_url))
|
||||
price_currency = (d.get("special_price_currency") or d.get("regular_price_currency")
|
||||
or d.get("rrp_currency"))
|
||||
|
||||
# JSON-LD
|
||||
jsonld = {
|
||||
@@ -282,6 +172,21 @@ def _product_meta_sx(d: dict, ctx: dict) -> str:
|
||||
"url": canonical,
|
||||
"availability": "https://schema.org/InStock",
|
||||
}
|
||||
parts.append(sx_call("market-meta-jsonld", json=json.dumps(jsonld)))
|
||||
|
||||
return "(<> " + " ".join(parts) + ")"
|
||||
return {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"canonical": canonical or None,
|
||||
"image-url": image_url or None,
|
||||
"site-title": ctx.get("base_title", ""),
|
||||
"brand": brand or None,
|
||||
"price": f"{price:.2f}" if price and price_currency else None,
|
||||
"price-currency": price_currency if price else None,
|
||||
"jsonld-json": json.dumps(jsonld),
|
||||
}
|
||||
|
||||
|
||||
def _product_meta_sx(d: dict, ctx: dict) -> str:
|
||||
"""Build product meta tags — delegates to .sx defcomp."""
|
||||
data = _product_meta_data(d, ctx)
|
||||
return sx_call("market-product-meta-from-data", **data)
|
||||
|
||||
@@ -45,7 +45,7 @@ async def _render_checkout_return(ctx: dict, order=None, status: str = "",
|
||||
else:
|
||||
img = sx_call("order-item-no-image")
|
||||
item_parts.append(sx_call("order-item-row",
|
||||
href=product_url, img=SxExpr(img),
|
||||
href=product_url, img=img,
|
||||
title=item.product_title or "Unknown product",
|
||||
pid=f"Product ID: {item.product_id}",
|
||||
qty=f"Qty: {item.quantity}",
|
||||
@@ -109,11 +109,11 @@ async def _render_checkout_return(ctx: dict, order=None, status: str = "",
|
||||
status_msg = sx_call("checkout-return-paid")
|
||||
|
||||
content = sx_call("checkout-return-content",
|
||||
summary=SxExpr(summary),
|
||||
items=SxExpr(items) if items else None,
|
||||
calendar=SxExpr(calendar) if calendar else None,
|
||||
tickets=SxExpr(tickets) if tickets else None,
|
||||
status_message=SxExpr(status_msg) if status_msg else None,
|
||||
summary=summary,
|
||||
items=items or None,
|
||||
calendar=calendar or None,
|
||||
tickets=tickets or None,
|
||||
status_message=status_msg or None,
|
||||
)
|
||||
|
||||
account_url = call_url(ctx, "account_url", "")
|
||||
|
||||
@@ -71,7 +71,6 @@ def register() -> Blueprint:
|
||||
if not hosted_url:
|
||||
from shared.sx.page import get_template_context
|
||||
from shared.sx.helpers import sx_call, root_header_sx, header_child_sx, full_page_sx, call_url
|
||||
from shared.sx.parser import SxExpr
|
||||
from shared.infrastructure.urls import cart_url
|
||||
tctx = await get_template_context()
|
||||
account_url = call_url(tctx, "account_url", "")
|
||||
@@ -82,7 +81,7 @@ def register() -> Blueprint:
|
||||
content = sx_call(
|
||||
"checkout-error-content",
|
||||
msg="No hosted checkout URL returned from SumUp when trying to reopen payment.",
|
||||
order=SxExpr(order_sx),
|
||||
order=order_sx,
|
||||
back_url=cart_url("/"),
|
||||
)
|
||||
html = await full_page_sx(tctx, header_rows=hdr, filter=filt, content=content)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
;; Orders account-nav-item fragment handler
|
||||
;; returns: sx
|
||||
;;
|
||||
;; Renders the "orders" link for the account dashboard nav.
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
;; Relations container-nav fragment handler
|
||||
;; returns: sx
|
||||
;;
|
||||
;; Generic navigation fragment driven by the relation registry.
|
||||
;; Renders nav items for all related entities of a container.
|
||||
|
||||
@@ -251,6 +251,18 @@
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Serialize a JS object as SX dict {:key "val" ...} for attribute values. */
|
||||
function _serializeDict(obj) {
|
||||
var parts = [];
|
||||
for (var k in obj) {
|
||||
if (!obj.hasOwnProperty(k)) continue;
|
||||
var v = obj[k];
|
||||
var vs = typeof v === "string" ? '"' + v.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"' : String(v);
|
||||
parts.push(":" + k + " " + vs);
|
||||
}
|
||||
return "{" + parts.join(" ") + "}";
|
||||
}
|
||||
|
||||
// --- Primitives ---
|
||||
|
||||
var PRIMITIVES = {};
|
||||
@@ -1420,7 +1432,7 @@
|
||||
} else if (attrVal === true) {
|
||||
el.setAttribute(attrName, "");
|
||||
} else {
|
||||
el.setAttribute(attrName, String(attrVal));
|
||||
el.setAttribute(attrName, typeof attrVal === "object" && attrVal !== null && !Array.isArray(attrVal) ? _serializeDict(attrVal) : String(attrVal));
|
||||
}
|
||||
} else {
|
||||
// Child
|
||||
@@ -1851,7 +1863,7 @@
|
||||
cancelButtonText: "Cancel"
|
||||
}).then(function (result) {
|
||||
if (!result.isConfirmed) return;
|
||||
return _doFetch(el, method, url, extraParams);
|
||||
return _doFetch(el, verbInfo, method, url, extraParams);
|
||||
});
|
||||
}
|
||||
if (!window.confirm(confirmMsg)) return Promise.resolve();
|
||||
@@ -1866,10 +1878,10 @@
|
||||
extraParams.promptValue = promptVal;
|
||||
}
|
||||
|
||||
return _doFetch(el, method, url, extraParams);
|
||||
return _doFetch(el, verbInfo, method, url, extraParams);
|
||||
}
|
||||
|
||||
function _doFetch(el, method, url, extraParams) {
|
||||
function _doFetch(el, verbInfo, method, url, extraParams) {
|
||||
// sx-sync: abort previous
|
||||
var sync = el.getAttribute("sx-sync");
|
||||
if (sync && sync.indexOf("replace") >= 0) abortPrevious(el);
|
||||
@@ -1895,12 +1907,12 @@
|
||||
var cssHeader = _getSxCssHeader();
|
||||
if (cssHeader) headers["SX-Css"] = cssHeader;
|
||||
|
||||
// Extra headers from sx-headers
|
||||
// Extra headers from sx-headers (SX dict {:key "val"} or JSON)
|
||||
var extraH = el.getAttribute("sx-headers");
|
||||
if (extraH) {
|
||||
try {
|
||||
var parsed = JSON.parse(extraH);
|
||||
for (var k in parsed) headers[k] = parsed[k];
|
||||
var parsed = extraH.charAt(0) === "{" && extraH.charAt(1) === ":" ? parse(extraH) : JSON.parse(extraH);
|
||||
for (var k in parsed) headers[k] = String(parsed[k]);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -1974,7 +1986,7 @@
|
||||
var valsAttr = el.getAttribute("sx-vals");
|
||||
if (valsAttr) {
|
||||
try {
|
||||
var vals = JSON.parse(valsAttr);
|
||||
var vals = valsAttr.charAt(0) === "{" && valsAttr.charAt(1) === ":" ? parse(valsAttr) : JSON.parse(valsAttr);
|
||||
if (method === "GET") {
|
||||
for (var vk in vals) {
|
||||
url += (url.indexOf("?") >= 0 ? "&" : "?") + encodeURIComponent(vk) + "=" + encodeURIComponent(vals[vk]);
|
||||
|
||||
@@ -1010,10 +1010,37 @@ async def async_eval_to_sx(
|
||||
ctx = RequestContext()
|
||||
result = await _aser(expr, env, ctx)
|
||||
if isinstance(result, SxExpr):
|
||||
return result.source
|
||||
return result
|
||||
if result is None or result is NIL:
|
||||
return ""
|
||||
return serialize(result)
|
||||
return SxExpr("")
|
||||
if isinstance(result, str):
|
||||
return SxExpr(result)
|
||||
return SxExpr(serialize(result))
|
||||
|
||||
|
||||
async def _maybe_expand_component_result(
|
||||
result: Any,
|
||||
env: dict[str, Any],
|
||||
ctx: RequestContext,
|
||||
) -> Any:
|
||||
"""If *result* is a component call (SxExpr or string starting with
|
||||
``(~``), re-parse and expand it server-side.
|
||||
|
||||
This ensures Python-only helpers (e.g. ``highlight``) inside the
|
||||
component body are evaluated on the server rather than being
|
||||
serialized for the client where they don't exist.
|
||||
"""
|
||||
raw = None
|
||||
if isinstance(result, SxExpr):
|
||||
raw = str(result).strip()
|
||||
elif isinstance(result, str):
|
||||
raw = result.strip()
|
||||
if raw and raw.startswith("(~"):
|
||||
from .parser import parse_all
|
||||
parsed = parse_all(raw)
|
||||
if parsed:
|
||||
return await async_eval_slot_to_sx(parsed[0], env, ctx)
|
||||
return result
|
||||
|
||||
|
||||
async def async_eval_slot_to_sx(
|
||||
@@ -1039,10 +1066,12 @@ async def async_eval_slot_to_sx(
|
||||
if isinstance(comp, Component):
|
||||
result = await _aser_component(comp, expr[1:], env, ctx)
|
||||
if isinstance(result, SxExpr):
|
||||
return result.source
|
||||
return result
|
||||
if result is None or result is NIL:
|
||||
return ""
|
||||
return serialize(result)
|
||||
return SxExpr("")
|
||||
if isinstance(result, str):
|
||||
return SxExpr(result)
|
||||
return SxExpr(serialize(result))
|
||||
else:
|
||||
import logging
|
||||
logging.getLogger("sx.eval").error(
|
||||
@@ -1055,15 +1084,18 @@ async def async_eval_slot_to_sx(
|
||||
)
|
||||
# Fall back to normal async_eval_to_sx
|
||||
result = await _aser(expr, env, ctx)
|
||||
# If the result is a component call (from case/if/let branches or
|
||||
# page helpers returning strings), re-parse and expand it server-side
|
||||
# so that Python-only helpers like ``highlight`` in the component body
|
||||
# get evaluated here, not on the client.
|
||||
result = await _maybe_expand_component_result(result, env, ctx)
|
||||
if isinstance(result, SxExpr):
|
||||
return result.source
|
||||
if result is None or result is NIL:
|
||||
return ""
|
||||
# Page helpers return SX source strings from render_to_sx() —
|
||||
# pass through directly instead of quoting via serialize().
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
return serialize(result)
|
||||
if result is None or result is NIL:
|
||||
return SxExpr("")
|
||||
if isinstance(result, str):
|
||||
return SxExpr(result)
|
||||
return SxExpr(serialize(result))
|
||||
|
||||
|
||||
async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
|
||||
@@ -1071,10 +1103,10 @@ async def _aser(expr: Any, env: dict[str, Any], ctx: RequestContext) -> Any:
|
||||
for everything else."""
|
||||
if isinstance(expr, (int, float, bool)):
|
||||
return expr
|
||||
if isinstance(expr, str):
|
||||
return expr
|
||||
if isinstance(expr, SxExpr):
|
||||
return expr
|
||||
if isinstance(expr, str):
|
||||
return expr
|
||||
if expr is None or expr is NIL:
|
||||
return NIL
|
||||
|
||||
|
||||
@@ -111,16 +111,19 @@ async def execute_handler(
|
||||
service_name: str,
|
||||
args: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Execute a declarative handler and return rendered sx/HTML string.
|
||||
"""Execute a declarative handler and return SX wire format (``SxExpr``).
|
||||
|
||||
Uses the async evaluator+renderer so I/O primitives (``query``,
|
||||
``service``, ``request-arg``, etc.) are awaited inline within
|
||||
control flow — no collect-then-substitute limitations.
|
||||
Uses the async evaluator so I/O primitives (``query``, ``service``,
|
||||
``request-arg``, etc.) are awaited inline within control flow.
|
||||
|
||||
Returns ``SxExpr`` — pre-built sx source. Callers like
|
||||
``fetch_fragment`` check ``content-type: text/sx`` and wrap the
|
||||
response in ``SxExpr`` when consuming cross-service fragments.
|
||||
|
||||
1. Build env from component env + handler closure
|
||||
2. Bind handler params from args (typically request.args)
|
||||
3. Evaluate + render via async_render (handles I/O inline)
|
||||
4. Return rendered string
|
||||
3. Evaluate via ``async_eval_to_sx`` (I/O inline, components serialized)
|
||||
4. Return ``SxExpr`` wire format
|
||||
"""
|
||||
from .jinja_bridge import get_component_env, _get_request_context
|
||||
from .async_eval import async_eval_to_sx
|
||||
|
||||
@@ -74,10 +74,10 @@ async def root_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
)
|
||||
|
||||
|
||||
def mobile_menu_sx(*sections: str) -> str:
|
||||
def mobile_menu_sx(*sections: str) -> SxExpr:
|
||||
"""Assemble mobile menu from pre-built sections (deepest first)."""
|
||||
parts = [s for s in sections if s]
|
||||
return "(<> " + " ".join(parts) + ")" if parts else ""
|
||||
return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
|
||||
|
||||
|
||||
async def mobile_root_nav_sx(ctx: dict) -> str:
|
||||
@@ -96,13 +96,13 @@ async def mobile_root_nav_sx(ctx: dict) -> str:
|
||||
# Shared nav-item builders — used by BOTH desktop headers and mobile menus
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _post_nav_items_sx(ctx: dict) -> str:
|
||||
async def _post_nav_items_sx(ctx: dict) -> SxExpr:
|
||||
"""Build post-level nav items (container_nav + admin cog). Shared by
|
||||
``post_header_sx`` (desktop) and ``post_mobile_nav_sx`` (mobile)."""
|
||||
post = ctx.get("post") or {}
|
||||
slug = post.get("slug", "")
|
||||
if not slug:
|
||||
return ""
|
||||
return SxExpr("")
|
||||
parts: list[str] = []
|
||||
page_cart_count = ctx.get("page_cart_count", 0)
|
||||
if page_cart_count and page_cart_count > 0:
|
||||
@@ -130,11 +130,11 @@ async def _post_nav_items_sx(ctx: dict) -> str:
|
||||
is_admin_page=is_admin_page or None)
|
||||
if admin_nav:
|
||||
parts.append(admin_nav)
|
||||
return "(<> " + " ".join(parts) + ")" if parts else ""
|
||||
return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
|
||||
|
||||
|
||||
async def _post_admin_nav_items_sx(ctx: dict, slug: str,
|
||||
selected: str = "") -> str:
|
||||
selected: str = "") -> SxExpr:
|
||||
"""Build post-admin nav items (calendars, markets, etc.). Shared by
|
||||
``post_admin_header_sx`` (desktop) and mobile menu."""
|
||||
select_colours = ctx.get("select_colours", "")
|
||||
@@ -158,7 +158,7 @@ async def _post_admin_nav_items_sx(ctx: dict, slug: str,
|
||||
parts.append(await _render_to_sx("nav-link", href=href, label=label,
|
||||
select_colours=select_colours,
|
||||
is_selected=is_sel or None))
|
||||
return "(<> " + " ".join(parts) + ")" if parts else ""
|
||||
return SxExpr("(<> " + " ".join(parts) + ")") if parts else SxExpr("")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -177,7 +177,7 @@ async def post_mobile_nav_sx(ctx: dict) -> str:
|
||||
label=title,
|
||||
href=call_url(ctx, "blog_url", f"/{slug}/"),
|
||||
level=1,
|
||||
items=SxExpr(nav),
|
||||
items=nav,
|
||||
)
|
||||
|
||||
|
||||
@@ -220,8 +220,8 @@ async def post_header_sx(ctx: dict, *, oob: bool = False, child: str = "") -> st
|
||||
return await _render_to_sx("menu-row-sx",
|
||||
id="post-row", level=1,
|
||||
link_href=link_href,
|
||||
link_label_content=SxExpr(label_sx),
|
||||
nav=SxExpr(nav_sx) if nav_sx else None,
|
||||
link_label_content=label_sx,
|
||||
nav=nav_sx,
|
||||
child_id="post-header-child",
|
||||
child=SxExpr(child) if child else None,
|
||||
oob=oob, external=True,
|
||||
@@ -244,8 +244,8 @@ async def post_admin_header_sx(ctx: dict, slug: str, *, oob: bool = False,
|
||||
return await _render_to_sx("menu-row-sx",
|
||||
id="post-admin-row", level=2,
|
||||
link_href=admin_href,
|
||||
link_label_content=SxExpr(label_sx),
|
||||
nav=SxExpr(nav_sx) if nav_sx else None,
|
||||
link_label_content=label_sx,
|
||||
nav=nav_sx,
|
||||
child_id="post-admin-header-child", oob=oob,
|
||||
)
|
||||
|
||||
@@ -347,12 +347,23 @@ async def _render_to_sx_with_env(__name: str, extra_env: dict, **kwargs: Any) ->
|
||||
"""
|
||||
from .jinja_bridge import get_component_env, _get_request_context
|
||||
from .async_eval import async_eval_slot_to_sx
|
||||
from .types import Symbol, Keyword, NIL as _NIL
|
||||
|
||||
# Build AST with extra_env entries as keyword args so _aser_component
|
||||
# binds them as params (otherwise it defaults all params to NIL).
|
||||
comp_sym = Symbol(__name if __name.startswith("~") else f"~{__name}")
|
||||
ast: list = [comp_sym]
|
||||
for k, v in extra_env.items():
|
||||
ast.append(Keyword(k))
|
||||
ast.append(v if v is not None else _NIL)
|
||||
for k, v in kwargs.items():
|
||||
ast.append(Keyword(k.replace("_", "-")))
|
||||
ast.append(v if v is not None else _NIL)
|
||||
|
||||
ast = _build_component_ast(__name, **kwargs)
|
||||
env = dict(get_component_env())
|
||||
env.update(extra_env)
|
||||
ctx = _get_request_context()
|
||||
return await async_eval_slot_to_sx(ast, env, ctx)
|
||||
return SxExpr(await async_eval_slot_to_sx(ast, env, ctx))
|
||||
|
||||
|
||||
async def _render_to_sx(__name: str, **kwargs: Any) -> str:
|
||||
@@ -371,7 +382,7 @@ async def _render_to_sx(__name: str, **kwargs: Any) -> str:
|
||||
ast = _build_component_ast(__name, **kwargs)
|
||||
env = dict(get_component_env())
|
||||
ctx = _get_request_context()
|
||||
return await async_eval_to_sx(ast, env, ctx)
|
||||
return SxExpr(await async_eval_to_sx(ast, env, ctx))
|
||||
|
||||
|
||||
# Backwards-compat alias — layout infrastructure still imports this.
|
||||
@@ -420,7 +431,7 @@ def sx_call(component_name: str, **kwargs: Any) -> str:
|
||||
parts.append("(list " + " ".join(items) + ")")
|
||||
else:
|
||||
parts.append(serialize(val))
|
||||
return "(" + " ".join(parts) + ")"
|
||||
return SxExpr("(" + " ".join(parts) + ")")
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ def _render(expr: Any, env: dict[str, Any]) -> str:
|
||||
return ""
|
||||
return _render_list(expr, env)
|
||||
|
||||
# --- dict → skip (data, not renderable) -------------------------------
|
||||
# --- dict → skip (data, not renderable as HTML content) -----------------
|
||||
if isinstance(expr, dict):
|
||||
return ""
|
||||
|
||||
@@ -540,6 +540,9 @@ def _render_element(tag: str, args: list, env: dict[str, Any]) -> str:
|
||||
parts.append(f" {attr_name}")
|
||||
elif attr_val is True:
|
||||
parts.append(f" {attr_name}")
|
||||
elif isinstance(attr_val, dict):
|
||||
from .parser import serialize as _sx_serialize
|
||||
parts.append(f' {attr_name}="{escape_attr(_sx_serialize(attr_val))}"')
|
||||
else:
|
||||
parts.append(f' {attr_name}="{escape_attr(str(attr_val))}"')
|
||||
parts.append(">")
|
||||
|
||||
@@ -30,8 +30,8 @@ from typing import Any
|
||||
|
||||
from .jinja_bridge import sx
|
||||
|
||||
SEARCH_HEADERS_MOBILE = '{"X-Origin":"search-mobile","X-Search":"true"}'
|
||||
SEARCH_HEADERS_DESKTOP = '{"X-Origin":"search-desktop","X-Search":"true"}'
|
||||
SEARCH_HEADERS_MOBILE = {"X-Origin": "search-mobile", "X-Search": "true"}
|
||||
SEARCH_HEADERS_DESKTOP = {"X-Origin": "search-desktop", "X-Search": "true"}
|
||||
|
||||
|
||||
def render_page(source: str, **kwargs: Any) -> str:
|
||||
|
||||
@@ -25,31 +25,37 @@ from .types import Keyword, Symbol, NIL
|
||||
# SxExpr — pre-built sx source marker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SxExpr:
|
||||
class SxExpr(str):
|
||||
"""Pre-built sx source that serialize() outputs unquoted.
|
||||
|
||||
``SxExpr`` is a ``str`` subclass, so it works everywhere a plain
|
||||
string does (join, startswith, f-strings, isinstance checks). The
|
||||
only difference: ``serialize()`` emits it unquoted instead of
|
||||
wrapping it in double-quotes.
|
||||
|
||||
Use this to nest sx call strings inside other sx_call() invocations
|
||||
without them being quoted as strings::
|
||||
|
||||
sx_call("parent", child=SxExpr(sx_call("child", x=1)))
|
||||
sx_call("parent", child=sx_call("child", x=1))
|
||||
# => (~parent :child (~child :x 1))
|
||||
"""
|
||||
__slots__ = ("source",)
|
||||
|
||||
def __init__(self, source: str):
|
||||
self.source = source
|
||||
def __new__(cls, source: str = "") -> "SxExpr":
|
||||
return str.__new__(cls, source)
|
||||
|
||||
@property
|
||||
def source(self) -> str:
|
||||
"""The raw SX source string (backward compat)."""
|
||||
return str.__str__(self)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"SxExpr({self.source!r})"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.source
|
||||
return f"SxExpr({str.__repr__(self)})"
|
||||
|
||||
def __add__(self, other: object) -> "SxExpr":
|
||||
return SxExpr(self.source + str(other))
|
||||
return SxExpr(str.__add__(self, str(other)))
|
||||
|
||||
def __radd__(self, other: object) -> "SxExpr":
|
||||
return SxExpr(str(other) + self.source)
|
||||
return SxExpr(str.__add__(str(other), self))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -283,7 +289,26 @@ def _parse_map(tok: Tokenizer) -> dict[str, Any]:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str:
|
||||
"""Serialize a value back to s-expression text."""
|
||||
"""Serialize a value back to s-expression text.
|
||||
|
||||
Type dispatch order (first match wins):
|
||||
|
||||
- ``SxExpr`` → emitted unquoted (pre-built sx source)
|
||||
- ``list`` → ``(head ...)`` (s-expression list)
|
||||
- ``Symbol`` → bare name
|
||||
- ``Keyword`` → ``:name``
|
||||
- ``str`` → ``"quoted"`` (with escapes)
|
||||
- ``bool`` → ``true`` / ``false``
|
||||
- ``int/float`` → numeric literal
|
||||
- ``None/NIL`` → ``nil``
|
||||
- ``dict`` → ``{:key val ...}``
|
||||
|
||||
List serialization conventions (for ``sx_call`` kwargs):
|
||||
|
||||
- ``(list ...)`` — data array: client gets iterable for map/filter
|
||||
- ``(<> ...)`` — rendered content: client treats as DocumentFragment
|
||||
- ``(head ...)`` — AST: head is called as function (never use for data)
|
||||
"""
|
||||
if isinstance(expr, SxExpr):
|
||||
return expr.source
|
||||
|
||||
|
||||
@@ -50,6 +50,15 @@ IO_PRIMITIVES: frozenset[str] = frozenset({
|
||||
"select-colours",
|
||||
"account-nav-ctx",
|
||||
"app-rights",
|
||||
"federation-actor-ctx",
|
||||
"request-view-args",
|
||||
"cart-page-ctx",
|
||||
"events-calendar-ctx",
|
||||
"events-day-ctx",
|
||||
"events-entry-ctx",
|
||||
"events-slot-ctx",
|
||||
"events-ticket-type-ctx",
|
||||
"market-header-ctx",
|
||||
})
|
||||
|
||||
|
||||
@@ -557,6 +566,376 @@ async def _io_post_header_ctx(
|
||||
return result
|
||||
|
||||
|
||||
async def _io_cart_page_ctx(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> dict[str, Any]:
|
||||
"""``(cart-page-ctx)`` → dict with cart page header values.
|
||||
|
||||
Reads ``g.page_post`` (set by cart's before_request) and returns
|
||||
slug, title, feature-image, and cart-url for the page cart header.
|
||||
"""
|
||||
from quart import g
|
||||
from .types import NIL
|
||||
from shared.infrastructure.urls import app_url
|
||||
|
||||
page_post = getattr(g, "page_post", None)
|
||||
if not page_post:
|
||||
return {"slug": "", "title": "", "feature-image": NIL, "cart-url": "/"}
|
||||
|
||||
slug = getattr(page_post, "slug", "") or ""
|
||||
title = (getattr(page_post, "title", "") or "")[:160]
|
||||
feature_image = getattr(page_post, "feature_image", None) or NIL
|
||||
|
||||
return {
|
||||
"slug": slug,
|
||||
"title": title,
|
||||
"feature-image": feature_image,
|
||||
"page-cart-url": app_url("cart", f"/{slug}/"),
|
||||
"cart-url": app_url("cart", "/"),
|
||||
}
|
||||
|
||||
|
||||
async def _io_federation_actor_ctx(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> dict[str, Any] | None:
|
||||
"""``(federation-actor-ctx)`` → serialized actor dict or None.
|
||||
|
||||
Reads ``g._social_actor`` (set by federation social blueprint's
|
||||
before_request hook) and serializes to a dict for .sx components.
|
||||
"""
|
||||
from quart import g
|
||||
actor = getattr(g, "_social_actor", None)
|
||||
if not actor:
|
||||
return None
|
||||
return {
|
||||
"id": actor.id,
|
||||
"preferred_username": actor.preferred_username,
|
||||
"display_name": getattr(actor, "display_name", None),
|
||||
"icon_url": getattr(actor, "icon_url", None),
|
||||
"actor_url": getattr(actor, "actor_url", ""),
|
||||
}
|
||||
|
||||
|
||||
async def _io_request_view_args(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> Any:
|
||||
"""``(request-view-args "key")`` → request.view_args[key]."""
|
||||
if not args:
|
||||
raise ValueError("request-view-args requires a key")
|
||||
from quart import request
|
||||
key = str(args[0])
|
||||
return (request.view_args or {}).get(key)
|
||||
|
||||
|
||||
async def _io_events_calendar_ctx(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> dict[str, Any]:
|
||||
"""``(events-calendar-ctx)`` → dict with events calendar header values.
|
||||
|
||||
Reads ``g.calendar`` or ``g._defpage_ctx["calendar"]`` and returns
|
||||
slug, name, description for the calendar header row.
|
||||
"""
|
||||
from quart import g
|
||||
cal = getattr(g, "calendar", None)
|
||||
if not cal:
|
||||
dctx = getattr(g, "_defpage_ctx", None) or {}
|
||||
cal = dctx.get("calendar")
|
||||
if not cal:
|
||||
return {"slug": ""}
|
||||
return {
|
||||
"slug": getattr(cal, "slug", "") or "",
|
||||
"name": getattr(cal, "name", "") or "",
|
||||
"description": getattr(cal, "description", "") or "",
|
||||
}
|
||||
|
||||
|
||||
async def _io_events_day_ctx(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> dict[str, Any]:
|
||||
"""``(events-day-ctx)`` → dict with events day header values.
|
||||
|
||||
Reads ``g.day_date``, ``g.calendar``, confirmed entries from
|
||||
``g._defpage_ctx``. Pre-builds the confirmed entries nav as SxExpr.
|
||||
"""
|
||||
from quart import g, url_for
|
||||
from .types import NIL
|
||||
from .parser import SxExpr
|
||||
|
||||
dctx = getattr(g, "_defpage_ctx", None) or {}
|
||||
cal = getattr(g, "calendar", None) or dctx.get("calendar")
|
||||
day_date = dctx.get("day_date") or getattr(g, "day_date", None)
|
||||
if not cal or not day_date:
|
||||
return {"date-str": ""}
|
||||
|
||||
cal_slug = getattr(cal, "slug", "") or ""
|
||||
|
||||
# Build confirmed entries nav
|
||||
confirmed = dctx.get("confirmed_entries") or []
|
||||
rights = getattr(g, "rights", None) or {}
|
||||
is_admin = (
|
||||
rights.get("admin", False)
|
||||
if isinstance(rights, dict)
|
||||
else getattr(rights, "admin", False)
|
||||
)
|
||||
|
||||
from .helpers import sx_call
|
||||
nav_parts: list[str] = []
|
||||
if confirmed:
|
||||
entry_links = []
|
||||
for entry in confirmed:
|
||||
href = url_for(
|
||||
"calendar.day.calendar_entries.calendar_entry.get",
|
||||
calendar_slug=cal_slug,
|
||||
year=day_date.year, month=day_date.month, day=day_date.day,
|
||||
entry_id=entry.id,
|
||||
)
|
||||
start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
|
||||
end = (
|
||||
f" \u2013 {entry.end_at.strftime('%H:%M')}"
|
||||
if entry.end_at else ""
|
||||
)
|
||||
entry_links.append(sx_call(
|
||||
"events-day-entry-link",
|
||||
href=href, name=entry.name, time_str=f"{start}{end}",
|
||||
))
|
||||
inner = "".join(entry_links)
|
||||
nav_parts.append(sx_call(
|
||||
"events-day-entries-nav", inner=SxExpr(inner),
|
||||
))
|
||||
|
||||
if is_admin and day_date:
|
||||
admin_href = url_for(
|
||||
"defpage_day_admin", calendar_slug=cal_slug,
|
||||
year=day_date.year, month=day_date.month, day=day_date.day,
|
||||
)
|
||||
nav_parts.append(sx_call("nav-link", href=admin_href, icon="fa fa-cog"))
|
||||
|
||||
return {
|
||||
"date-str": day_date.strftime("%A %d %B %Y"),
|
||||
"year": day_date.year,
|
||||
"month": day_date.month,
|
||||
"day": day_date.day,
|
||||
"nav": SxExpr("".join(nav_parts)) if nav_parts else NIL,
|
||||
}
|
||||
|
||||
|
||||
async def _io_events_entry_ctx(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> dict[str, Any]:
|
||||
"""``(events-entry-ctx)`` → dict with events entry header values.
|
||||
|
||||
Reads ``g.entry``, ``g.calendar``, and entry_posts from
|
||||
``g._defpage_ctx``. Pre-builds entry nav (posts + admin link) as SxExpr.
|
||||
"""
|
||||
from quart import g, url_for
|
||||
from .types import NIL
|
||||
from .parser import SxExpr
|
||||
|
||||
dctx = getattr(g, "_defpage_ctx", None) or {}
|
||||
cal = getattr(g, "calendar", None) or dctx.get("calendar")
|
||||
entry = getattr(g, "entry", None) or dctx.get("entry")
|
||||
if not cal or not entry:
|
||||
return {"id": ""}
|
||||
|
||||
cal_slug = getattr(cal, "slug", "") or ""
|
||||
day = dctx.get("day")
|
||||
month = dctx.get("month")
|
||||
year = dctx.get("year")
|
||||
|
||||
# Times
|
||||
start = entry.start_at
|
||||
end = entry.end_at
|
||||
time_str = ""
|
||||
if start:
|
||||
time_str = start.strftime("%H:%M")
|
||||
if end:
|
||||
time_str += f" \u2192 {end.strftime('%H:%M')}"
|
||||
|
||||
link_href = url_for(
|
||||
"calendar.day.calendar_entries.calendar_entry.get",
|
||||
calendar_slug=cal_slug,
|
||||
year=year, month=month, day=day, entry_id=entry.id,
|
||||
)
|
||||
|
||||
# Build nav: associated posts + admin link
|
||||
entry_posts = dctx.get("entry_posts") or []
|
||||
rights = getattr(g, "rights", None) or {}
|
||||
is_admin = (
|
||||
rights.get("admin", False)
|
||||
if isinstance(rights, dict)
|
||||
else getattr(rights, "admin", False)
|
||||
)
|
||||
|
||||
from .helpers import sx_call
|
||||
from shared.infrastructure.urls import app_url
|
||||
|
||||
nav_parts: list[str] = []
|
||||
if entry_posts:
|
||||
post_links = ""
|
||||
for ep in entry_posts:
|
||||
ep_slug = getattr(ep, "slug", "")
|
||||
ep_title = getattr(ep, "title", "")
|
||||
feat = getattr(ep, "feature_image", None)
|
||||
href = app_url("blog", f"/{ep_slug}/")
|
||||
if feat:
|
||||
img_html = sx_call("events-post-img", src=feat, alt=ep_title)
|
||||
else:
|
||||
img_html = sx_call("events-post-img-placeholder")
|
||||
post_links += sx_call(
|
||||
"events-entry-nav-post-link",
|
||||
href=href, img=SxExpr(img_html), title=ep_title,
|
||||
)
|
||||
nav_parts.append(
|
||||
sx_call("events-entry-posts-nav-oob", items=SxExpr(post_links))
|
||||
.replace(' :hx-swap-oob "true"', '')
|
||||
)
|
||||
|
||||
if is_admin:
|
||||
admin_url = url_for(
|
||||
"calendar.day.calendar_entries.calendar_entry.admin.admin",
|
||||
calendar_slug=cal_slug,
|
||||
day=day, month=month, year=year, entry_id=entry.id,
|
||||
)
|
||||
nav_parts.append(sx_call("events-entry-admin-link", href=admin_url))
|
||||
|
||||
# Entry admin nav (ticket_types link)
|
||||
admin_href = url_for(
|
||||
"calendar.day.calendar_entries.calendar_entry.admin.admin",
|
||||
calendar_slug=cal_slug,
|
||||
day=day, month=month, year=year, entry_id=entry.id,
|
||||
) if is_admin else ""
|
||||
|
||||
ticket_types_href = url_for(
|
||||
"calendar.day.calendar_entries.calendar_entry.ticket_types.get",
|
||||
calendar_slug=cal_slug, entry_id=entry.id,
|
||||
year=year, month=month, day=day,
|
||||
)
|
||||
|
||||
from quart import current_app
|
||||
select_colours = current_app.jinja_env.globals.get("select_colours", "")
|
||||
|
||||
return {
|
||||
"id": str(entry.id),
|
||||
"name": entry.name or "",
|
||||
"time-str": time_str,
|
||||
"link-href": link_href,
|
||||
"nav": SxExpr("".join(nav_parts)) if nav_parts else NIL,
|
||||
"admin-href": admin_href,
|
||||
"ticket-types-href": ticket_types_href,
|
||||
"is-admin": is_admin,
|
||||
"select-colours": select_colours,
|
||||
}
|
||||
|
||||
|
||||
async def _io_events_slot_ctx(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> dict[str, Any]:
|
||||
"""``(events-slot-ctx)`` → dict with events slot header values."""
|
||||
from quart import g
|
||||
dctx = getattr(g, "_defpage_ctx", None) or {}
|
||||
slot = getattr(g, "slot", None) or dctx.get("slot")
|
||||
if not slot:
|
||||
return {"name": ""}
|
||||
return {
|
||||
"name": getattr(slot, "name", "") or "",
|
||||
"description": getattr(slot, "description", "") or "",
|
||||
}
|
||||
|
||||
|
||||
async def _io_events_ticket_type_ctx(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> dict[str, Any]:
|
||||
"""``(events-ticket-type-ctx)`` → dict with ticket type header values."""
|
||||
from quart import g, url_for
|
||||
|
||||
dctx = getattr(g, "_defpage_ctx", None) or {}
|
||||
cal = getattr(g, "calendar", None) or dctx.get("calendar")
|
||||
entry = getattr(g, "entry", None) or dctx.get("entry")
|
||||
ticket_type = getattr(g, "ticket_type", None) or dctx.get("ticket_type")
|
||||
if not cal or not entry or not ticket_type:
|
||||
return {"id": ""}
|
||||
|
||||
cal_slug = getattr(cal, "slug", "") or ""
|
||||
day = dctx.get("day")
|
||||
month = dctx.get("month")
|
||||
year = dctx.get("year")
|
||||
|
||||
link_href = url_for(
|
||||
"calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get",
|
||||
calendar_slug=cal_slug, year=year, month=month, day=day,
|
||||
entry_id=entry.id, ticket_type_id=ticket_type.id,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": str(ticket_type.id),
|
||||
"name": getattr(ticket_type, "name", "") or "",
|
||||
"link-href": link_href,
|
||||
}
|
||||
|
||||
|
||||
async def _io_market_header_ctx(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> dict[str, Any]:
|
||||
"""``(market-header-ctx)`` → dict with market header data.
|
||||
|
||||
Returns plain data (categories list, hrefs, flags) for the
|
||||
~market-header-auto macro. Mobile nav is pre-built as SxExpr.
|
||||
"""
|
||||
from quart import g, url_for
|
||||
from shared.config import config as get_config
|
||||
from .parser import SxExpr
|
||||
|
||||
cfg = get_config()
|
||||
market_title = cfg.get("market_title", "")
|
||||
link_href = url_for("defpage_market_home")
|
||||
|
||||
# Get categories if market is loaded
|
||||
market = getattr(g, "market", None)
|
||||
categories = {}
|
||||
if market:
|
||||
from bp.browse.services.nav import get_nav
|
||||
nav_data = await get_nav(g.s, market_id=market.id)
|
||||
categories = nav_data.get("cats", {})
|
||||
|
||||
# Build minimal ctx for existing helper functions
|
||||
select_colours = getattr(g, "select_colours", "")
|
||||
if not select_colours:
|
||||
from quart import current_app
|
||||
select_colours = current_app.jinja_env.globals.get("select_colours", "")
|
||||
rights = getattr(g, "rights", None) or {}
|
||||
|
||||
mini_ctx: dict[str, Any] = {
|
||||
"market_title": market_title,
|
||||
"top_slug": "",
|
||||
"sub_slug": "",
|
||||
"categories": categories,
|
||||
"qs": "",
|
||||
"hx_select_search": "#main-panel",
|
||||
"select_colours": select_colours,
|
||||
"rights": rights,
|
||||
"category_label": "",
|
||||
}
|
||||
|
||||
# Build header + mobile nav data via new data-driven helpers
|
||||
from sxc.pages.layouts import _market_header_data, _mobile_nav_panel_sx
|
||||
header_data = _market_header_data(mini_ctx)
|
||||
mobile_nav = _mobile_nav_panel_sx(mini_ctx)
|
||||
|
||||
return {
|
||||
"market-title": market_title,
|
||||
"link-href": link_href,
|
||||
"top-slug": "",
|
||||
"sub-slug": "",
|
||||
"categories": header_data.get("categories", []),
|
||||
"hx-select": header_data.get("hx-select", "#main-panel"),
|
||||
"select-colours": header_data.get("select-colours", ""),
|
||||
"all-href": header_data.get("all-href", ""),
|
||||
"all-active": header_data.get("all-active", False),
|
||||
"admin-href": header_data.get("admin-href", ""),
|
||||
"mobile-nav": SxExpr(mobile_nav) if mobile_nav else "",
|
||||
}
|
||||
|
||||
|
||||
_IO_HANDLERS: dict[str, Any] = {
|
||||
"frag": _io_frag,
|
||||
"query": _io_query,
|
||||
@@ -578,4 +957,13 @@ _IO_HANDLERS: dict[str, Any] = {
|
||||
"select-colours": _io_select_colours,
|
||||
"account-nav-ctx": _io_account_nav_ctx,
|
||||
"app-rights": _io_app_rights,
|
||||
"federation-actor-ctx": _io_federation_actor_ctx,
|
||||
"request-view-args": _io_request_view_args,
|
||||
"cart-page-ctx": _io_cart_page_ctx,
|
||||
"events-calendar-ctx": _io_events_calendar_ctx,
|
||||
"events-day-ctx": _io_events_day_ctx,
|
||||
"events-entry-ctx": _io_events_entry_ctx,
|
||||
"events-slot-ctx": _io_events_slot_ctx,
|
||||
"events-ticket-type-ctx": _io_events_ticket_type_ctx,
|
||||
"market-header-ctx": _io_market_header_ctx,
|
||||
}
|
||||
|
||||
115
sx/sx/docs-content.sx
Normal file
115
sx/sx/docs-content.sx
Normal file
@@ -0,0 +1,115 @@
|
||||
;; Docs page content — fully self-contained, no Python intermediaries
|
||||
|
||||
(defcomp ~sx-home-content ()
|
||||
(div :id "main-content"
|
||||
(~sx-hero (highlight "(div :class \"p-4 bg-white rounded shadow\"\n (h1 :class \"text-2xl font-bold\" \"Hello\")\n (button :sx-get \"/api/data\"\n :sx-target \"#result\"\n \"Load data\"))" "lisp"))
|
||||
(~sx-philosophy)
|
||||
(~sx-how-it-works)
|
||||
(~sx-credits)))
|
||||
|
||||
(defcomp ~docs-introduction-content ()
|
||||
(~doc-page :title "Introduction"
|
||||
(~doc-section :title "What is sx?" :id "what"
|
||||
(p :class "text-stone-600"
|
||||
"sx is an s-expression language for building web UIs. It combines htmx's server-first hypermedia approach with React's component model. The server sends s-expression source code over the wire. The client parses, evaluates, and renders it to DOM.")
|
||||
(p :class "text-stone-600"
|
||||
"The same evaluator runs on both server (Python) and client (JavaScript). Components defined once render identically in both environments."))
|
||||
(~doc-section :title "Design decisions" :id "design"
|
||||
(p :class "text-stone-600"
|
||||
"HTML elements are first-class: (div :class \"card\" (p \"hello\")) renders exactly what you'd expect. Components use defcomp with keyword parameters and optional children. The evaluator supports let bindings, conditionals, lambda, map/filter/reduce, and ~80 primitives.")
|
||||
(p :class "text-stone-600"
|
||||
"sx replaces the pattern of shipping a JS framework + build step + client-side router + state management library just to render some server data. For most applications, sx eliminates the need for JavaScript entirely — htmx attributes handle interactivity, hyperscript handles small behaviours, and the server handles everything else."))
|
||||
(~doc-section :title "What sx is not" :id "not"
|
||||
(ul :class "space-y-2 text-stone-600"
|
||||
(li "Not a general-purpose programming language — it's a UI rendering language")
|
||||
(li "Not a full Lisp — it has macros and TCO, but no continuations or call/cc")
|
||||
(li "Not production-hardened at scale — it runs one website")))))
|
||||
|
||||
(defcomp ~docs-getting-started-content ()
|
||||
(~doc-page :title "Getting Started"
|
||||
(~doc-section :title "Minimal example" :id "minimal"
|
||||
(p :class "text-stone-600"
|
||||
"An sx response is s-expression source code with content type text/sx:")
|
||||
(~doc-code :code (highlight "(div :class \"p-4 bg-white rounded\"\n (h1 :class \"text-2xl font-bold\" \"Hello, world!\")\n (p \"This is rendered from an s-expression.\"))" "lisp"))
|
||||
(p :class "text-stone-600"
|
||||
"Add sx-get to any element to make it fetch and render sx:"))
|
||||
(~doc-section :title "Hypermedia attributes" :id "attrs"
|
||||
(p :class "text-stone-600"
|
||||
"Like htmx, sx adds attributes to HTML elements to trigger HTTP requests:")
|
||||
(~doc-code :code (highlight "(button\n :sx-get \"/api/data\"\n :sx-target \"#result\"\n :sx-swap \"innerHTML\"\n \"Load data\")" "lisp"))
|
||||
(p :class "text-stone-600"
|
||||
"sx-get, sx-post, sx-put, sx-delete, sx-patch — all work the same way. The response is parsed as sx and rendered into the target element."))))
|
||||
|
||||
(defcomp ~docs-components-content ()
|
||||
(~doc-page :title "Components"
|
||||
(~doc-section :title "defcomp" :id "defcomp"
|
||||
(p :class "text-stone-600"
|
||||
"Components are defined with defcomp. They take keyword parameters and optional children:")
|
||||
(~doc-code :code (highlight "(defcomp ~card (&key title subtitle &rest children)\n (div :class \"border rounded p-4\"\n (h2 :class \"font-bold\" title)\n (when subtitle (p :class \"text-stone-500\" subtitle))\n (div :class \"mt-3\" children)))" "lisp"))
|
||||
(p :class "text-stone-600"
|
||||
"Use components with the ~ prefix:")
|
||||
(~doc-code :code (highlight "(~card :title \"My Card\" :subtitle \"A description\"\n (p \"First child\")\n (p \"Second child\"))" "lisp")))
|
||||
(~doc-section :title "Component caching" :id "caching"
|
||||
(p :class "text-stone-600"
|
||||
"Component definitions are sent in a <script type=\"text/sx\" data-components> block. The client caches them in localStorage keyed by a content hash. On subsequent page loads, the client sends an SX-Components header listing what it has. The server only sends definitions the client is missing.")
|
||||
(p :class "text-stone-600"
|
||||
"This means the first page load sends all component definitions (~5-15KB). Subsequent navigations send zero component bytes — just the page content."))
|
||||
(~doc-section :title "Parameters" :id "params"
|
||||
(p :class "text-stone-600"
|
||||
"&key declares keyword parameters. &rest children captures remaining positional arguments. Missing parameters evaluate to nil. Components always receive all declared parameters — use (when param ...) or (if param ... ...) to handle optional values."))))
|
||||
|
||||
(defcomp ~docs-evaluator-content ()
|
||||
(~doc-page :title "Evaluator"
|
||||
(~doc-section :title "Special forms" :id "special"
|
||||
(p :class "text-stone-600"
|
||||
"Special forms have lazy evaluation — arguments are not evaluated before the form runs:")
|
||||
(~doc-code :code (highlight ";; Conditionals\n(if condition then-expr else-expr)\n(when condition body...)\n(cond (test1 body1) (test2 body2) (else default))\n\n;; Bindings\n(let ((name value) (name2 value2)) body...)\n(define name value)\n\n;; Functions\n(lambda (x y) (+ x y))\n(fn (x) (* x x))\n\n;; Sequencing\n(do expr1 expr2 expr3)\n(begin expr1 expr2)\n\n;; Threading\n(-> value (fn1 arg) (fn2 arg))" "lisp")))
|
||||
(~doc-section :title "Higher-order forms" :id "higher"
|
||||
(p :class "text-stone-600"
|
||||
"These operate on collections with function arguments:")
|
||||
(~doc-code :code (highlight "(map (fn (x) (* x 2)) (list 1 2 3)) ;; => (2 4 6)\n(filter (fn (x) (> x 2)) (list 1 2 3 4 5)) ;; => (3 4 5)\n(reduce (fn (acc x) (+ acc x)) 0 (list 1 2 3)) ;; => 6\n(some (fn (x) (> x 3)) (list 1 2 3 4)) ;; => true\n(every? (fn (x) (> x 0)) (list 1 2 3)) ;; => true" "lisp")))))
|
||||
|
||||
(defcomp ~docs-primitives-content (&key prims)
|
||||
(~doc-page :title "Primitives"
|
||||
(~doc-section :title "Built-in functions" :id "builtins"
|
||||
(p :class "text-stone-600"
|
||||
"sx provides ~80 built-in pure functions. They work identically on server (Python) and client (JavaScript).")
|
||||
(div :class "space-y-6" prims))))
|
||||
|
||||
(defcomp ~docs-css-content ()
|
||||
(~doc-page :title "On-Demand CSS"
|
||||
(~doc-section :title "How it works" :id "how"
|
||||
(p :class "text-stone-600"
|
||||
"sx scans every response for CSS class names used in :class attributes. It looks up only those classes in a pre-parsed Tailwind CSS registry and ships just the rules that are needed. No build step. No purging. No unused CSS.")
|
||||
(p :class "text-stone-600"
|
||||
"On the first page load, the full set of used classes is embedded in a <style> block. A hash of the class set is stored. On subsequent navigations, the client sends the hash in the SX-Css header. The server computes the diff and sends only new rules via SX-Css-Add and a <style data-sx-css> block."))
|
||||
(~doc-section :title "The protocol" :id "protocol"
|
||||
(~doc-code :code (highlight "# First page load:\nGET / HTTP/1.1\n\nHTTP/1.1 200 OK\nContent-Type: text/html\n# Full CSS in <style id=\"sx-css\"> + hash in <meta name=\"sx-css-classes\">\n\n# Subsequent navigation:\nGET /about HTTP/1.1\nSX-Css: a1b2c3d4\n\nHTTP/1.1 200 OK\nContent-Type: text/sx\nSX-Css-Hash: e5f6g7h8\nSX-Css-Add: bg-blue-500,text-white,rounded-lg\n# Only new rules in <style data-sx-css>" "bash")))
|
||||
(~doc-section :title "Advantages" :id "advantages"
|
||||
(ul :class "space-y-2 text-stone-600"
|
||||
(li "Zero build step — no Tailwind CLI, no PostCSS, no purging")
|
||||
(li "Exact CSS — never ships a rule that isn't used on the page")
|
||||
(li "Incremental — subsequent navigations only ship new rules")
|
||||
(li "Component-aware — pre-scans component definitions at registration time")))
|
||||
(~doc-section :title "Disadvantages" :id "disadvantages"
|
||||
(ul :class "space-y-2 text-stone-600"
|
||||
(li "Requires the full Tailwind CSS file loaded in memory at startup (~4MB parsed)")
|
||||
(li "Regex-based class scanning — can miss dynamically constructed class names")
|
||||
(li "No @apply support — classes must be used directly")
|
||||
(li "Tied to Tailwind's utility class naming conventions")))))
|
||||
|
||||
(defcomp ~docs-server-rendering-content ()
|
||||
(~doc-page :title "Server Rendering"
|
||||
(~doc-section :title "Python API" :id "python"
|
||||
(p :class "text-stone-600"
|
||||
"The server-side sx library provides several entry points for rendering:")
|
||||
(~doc-code :code (highlight "from shared.sx.helpers import sx_page, sx_response, sx_call\nfrom shared.sx.parser import SxExpr\n\n# Build a component call from Python kwargs\nsx_call(\"card\", title=\"Hello\", subtitle=\"World\")\n\n# Return an sx wire-format response\nreturn sx_response(sx_call(\"card\", title=\"Hello\"))\n\n# Return a full HTML page shell with sx boot\nreturn sx_page(ctx, page_sx)" "python")))
|
||||
(~doc-section :title "sx_call" :id "sx-call"
|
||||
(p :class "text-stone-600"
|
||||
"sx_call converts Python kwargs to an s-expression component call. Snake_case becomes kebab-case. SxExpr values are inlined without quoting. None becomes nil. Bools become true/false."))
|
||||
(~doc-section :title "sx_response" :id "sx-response"
|
||||
(p :class "text-stone-600"
|
||||
"sx_response returns a Quart Response with content type text/sx. It prepends missing component definitions, scans for CSS classes, and sets SX-Css-Hash and SX-Css-Add headers."))
|
||||
(~doc-section :title "sx_page" :id "sx-page"
|
||||
(p :class "text-stone-600"
|
||||
"sx_page returns a minimal HTML document that boots the page from sx source. The browser loads component definitions and page sx from inline <script> tags, then sx.js renders everything client-side. CSS rules are pre-scanned and injected."))))
|
||||
@@ -63,3 +63,49 @@
|
||||
(button :onclick "localStorage.removeItem('sx-components-hash');localStorage.removeItem('sx-components-src');var e=Sx.getEnv();Object.keys(e).forEach(function(k){if(k.charAt(0)==='~')delete e[k]});var b=this;b.textContent='Cleared!';setTimeout(function(){b.textContent='Clear component cache'},2000)"
|
||||
:class "text-xs text-stone-400 hover:text-stone-600 border border-stone-200 rounded px-2 py-1 transition-colors"
|
||||
"Clear component cache"))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Data-driven table builders — replace Python sx_call() composition
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
;; Build attr table from a list of {name, desc, exists, href} dicts.
|
||||
;; Replaces _attr_table_sx() in utils.py.
|
||||
(defcomp ~doc-attr-table-from-data (&key title attrs)
|
||||
(~doc-attr-table :title title
|
||||
:rows (<> (map (fn (a)
|
||||
(~doc-attr-row
|
||||
:attr (get a "name")
|
||||
:description (get a "desc")
|
||||
:exists (get a "exists")
|
||||
:href (get a "href")))
|
||||
attrs))))
|
||||
|
||||
;; Build headers table from a list of {name, value, desc} dicts.
|
||||
;; Replaces _headers_table_sx() in utils.py.
|
||||
(defcomp ~doc-headers-table-from-data (&key title headers)
|
||||
(~doc-headers-table :title title
|
||||
:rows (<> (map (fn (h)
|
||||
(~doc-headers-row
|
||||
:name (get h "name")
|
||||
:value (get h "value")
|
||||
:description (get h "desc")))
|
||||
headers))))
|
||||
|
||||
;; Build two-col table from a list of {name, desc} dicts.
|
||||
;; Replaces the _reference_events_sx / _reference_js_api_sx builders.
|
||||
(defcomp ~doc-two-col-table-from-data (&key title intro col1 col2 items)
|
||||
(~doc-two-col-table :title title :intro intro :col1 col1 :col2 col2
|
||||
:rows (<> (map (fn (item)
|
||||
(~doc-two-col-row
|
||||
:name (get item "name")
|
||||
:description (get item "desc")))
|
||||
items))))
|
||||
|
||||
;; Build all primitives category tables from a {category: [prim, ...]} dict.
|
||||
;; Replaces _primitives_section_sx() in utils.py.
|
||||
(defcomp ~doc-primitives-tables (&key primitives)
|
||||
(<> (map (fn (cat)
|
||||
(~doc-primitives-table
|
||||
:category cat
|
||||
:primitives (get primitives cat)))
|
||||
(keys primitives))))
|
||||
|
||||
28
sx/sx/essays.sx
Normal file
28
sx/sx/essays.sx
Normal file
File diff suppressed because one or more lines are too long
316
sx/sx/examples-content.sx
Normal file
316
sx/sx/examples-content.sx
Normal file
@@ -0,0 +1,316 @@
|
||||
;; Example page defcomps — one per example.
|
||||
;; Each calls ~example-page-content with static string data.
|
||||
;; Replaces all _example_*_sx() builders in essays.py.
|
||||
|
||||
(defcomp ~example-click-to-load ()
|
||||
(~example-page-content
|
||||
:title "Click to Load"
|
||||
:description "The simplest sx interaction: click a button, fetch content from the server, swap it in."
|
||||
:demo-description "Click the button to load server-rendered content."
|
||||
:demo (~click-to-load-demo)
|
||||
:sx-code "(button\n :sx-get \"/examples/api/click\"\n :sx-target \"#click-result\"\n :sx-swap \"innerHTML\"\n \"Load content\")"
|
||||
:handler-code "@bp.get(\"/examples/api/click\")\nasync def api_click():\n now = datetime.now().strftime(...)\n return sx_response(\n f'(~click-result :time \"{now}\")')"
|
||||
:comp-placeholder-id "click-comp"
|
||||
:wire-placeholder-id "click-wire"
|
||||
:wire-note "The server responds with content-type text/sx. New CSS rules are prepended as a style tag. Clear the component cache to see component definitions included in the wire response."))
|
||||
|
||||
(defcomp ~example-form-submission ()
|
||||
(~example-page-content
|
||||
:title "Form Submission"
|
||||
:description "Forms with sx-post submit via AJAX and swap the response into a target."
|
||||
:demo-description "Enter a name and submit."
|
||||
:demo (~form-demo)
|
||||
:sx-code "(form\n :sx-post \"/examples/api/form\"\n :sx-target \"#form-result\"\n :sx-swap \"innerHTML\"\n (input :type \"text\" :name \"name\")\n (button :type \"submit\" \"Submit\"))"
|
||||
:handler-code "@bp.post(\"/examples/api/form\")\nasync def api_form():\n form = await request.form\n name = form.get(\"name\", \"\")\n return sx_response(\n f'(~form-result :name \"{name}\")')"
|
||||
:comp-placeholder-id "form-comp"
|
||||
:wire-placeholder-id "form-wire"))
|
||||
|
||||
(defcomp ~example-polling ()
|
||||
(~example-page-content
|
||||
:title "Polling"
|
||||
:description "Use sx-trigger with \"every\" to poll the server at regular intervals."
|
||||
:demo-description "This div polls the server every 2 seconds."
|
||||
:demo (~polling-demo)
|
||||
:sx-code "(div\n :sx-get \"/examples/api/poll\"\n :sx-trigger \"load, every 2s\"\n :sx-swap \"innerHTML\"\n \"Loading...\")"
|
||||
:handler-code "@bp.get(\"/examples/api/poll\")\nasync def api_poll():\n poll_count[\"n\"] += 1\n now = datetime.now().strftime(\"%H:%M:%S\")\n count = min(poll_count[\"n\"], 10)\n return sx_response(\n f'(~poll-result :time \"{now}\" :count {count})')"
|
||||
:comp-placeholder-id "poll-comp"
|
||||
:wire-placeholder-id "poll-wire"
|
||||
:wire-note "Updates every 2 seconds — watch the time and count change."))
|
||||
|
||||
(defcomp ~example-delete-row ()
|
||||
(~example-page-content
|
||||
:title "Delete Row"
|
||||
:description "sx-delete with sx-swap \"outerHTML\" and an empty response removes the row from the DOM."
|
||||
:demo-description "Click delete to remove a row. Uses sx-confirm for confirmation."
|
||||
:demo (~delete-demo :items (list
|
||||
(list "1" "Implement dark mode")
|
||||
(list "2" "Fix login bug")
|
||||
(list "3" "Write documentation")
|
||||
(list "4" "Deploy to production")
|
||||
(list "5" "Add unit tests")))
|
||||
:sx-code "(button\n :sx-delete \"/api/delete/1\"\n :sx-target \"#row-1\"\n :sx-swap \"outerHTML\"\n :sx-confirm \"Delete this item?\"\n \"delete\")"
|
||||
:handler-code "@bp.delete(\"/examples/api/delete/<item_id>\")\nasync def api_delete(item_id: str):\n # Empty response — outerHTML swap removes the row\n return Response(\"\", status=200,\n content_type=\"text/sx\")"
|
||||
:comp-placeholder-id "delete-comp"
|
||||
:wire-placeholder-id "delete-wire"
|
||||
:wire-note "Empty body — outerHTML swap replaces the target element with nothing."))
|
||||
|
||||
(defcomp ~example-inline-edit ()
|
||||
(~example-page-content
|
||||
:title "Inline Edit"
|
||||
:description "Click edit to swap a display view for an edit form. Save swaps back."
|
||||
:demo-description "Click edit, modify the text, save or cancel."
|
||||
:demo (~inline-edit-demo)
|
||||
:sx-code ";; View mode — shows text + edit button\n(~inline-view :value \"some text\")\n\n;; Edit mode — returned by server on click\n(~inline-edit-form :value \"some text\")"
|
||||
:handler-code "@bp.get(\"/examples/api/edit\")\nasync def api_edit_form():\n value = request.args.get(\"value\", \"\")\n return sx_response(\n f'(~inline-edit-form :value \"{value}\")')\n\n@bp.post(\"/examples/api/edit\")\nasync def api_edit_save():\n form = await request.form\n value = form.get(\"value\", \"\")\n return sx_response(\n f'(~inline-view :value \"{value}\")')"
|
||||
:comp-placeholder-id "edit-comp"
|
||||
:comp-heading "Components"
|
||||
:handler-heading "Server handlers"
|
||||
:wire-placeholder-id "edit-wire"))
|
||||
|
||||
(defcomp ~example-oob-swaps ()
|
||||
(~example-page-content
|
||||
:title "Out-of-Band Swaps"
|
||||
:description "sx-swap-oob lets a single response update multiple elements anywhere in the DOM."
|
||||
:demo-description "One request updates both Box A (via sx-target) and Box B (via sx-swap-oob)."
|
||||
:demo (~oob-demo)
|
||||
:sx-code ";; Button targets Box A\n(button\n :sx-get \"/examples/api/oob\"\n :sx-target \"#oob-box-a\"\n :sx-swap \"innerHTML\"\n \"Update both boxes\")"
|
||||
:handler-code "@bp.get(\"/examples/api/oob\")\nasync def api_oob():\n now = datetime.now().strftime(\"%H:%M:%S\")\n return sx_response(\n f'(<>'\n f' (p \"Box A updated at {now}\")'\n f' (div :id \"oob-box-b\"'\n f' :sx-swap-oob \"innerHTML\"'\n f' (p \"Box B updated at {now}\")))')"
|
||||
:wire-placeholder-id "oob-wire"
|
||||
:wire-note "The fragment contains both the main content and an OOB element. sx.js splits them: main content goes to sx-target, OOB elements find their targets by ID."))
|
||||
|
||||
(defcomp ~example-lazy-loading ()
|
||||
(~example-page-content
|
||||
:title "Lazy Loading"
|
||||
:description "Use sx-trigger=\"load\" to fetch content as soon as the element enters the DOM. Great for deferring expensive content below the fold."
|
||||
:demo-description "Content loads automatically when the page renders."
|
||||
:demo (~lazy-loading-demo)
|
||||
:sx-code "(div\n :sx-get \"/examples/api/lazy\"\n :sx-trigger \"load\"\n :sx-swap \"innerHTML\"\n (div :class \"animate-pulse\" \"Loading...\"))"
|
||||
:handler-code "@bp.get(\"/examples/api/lazy\")\nasync def api_lazy():\n now = datetime.now().strftime(...)\n return sx_response(\n f'(~lazy-result :time \"{now}\")')"
|
||||
:comp-placeholder-id "lazy-comp"
|
||||
:wire-placeholder-id "lazy-wire"))
|
||||
|
||||
(defcomp ~example-infinite-scroll ()
|
||||
(~example-page-content
|
||||
:title "Infinite Scroll"
|
||||
:description "A sentinel element at the bottom uses sx-trigger=\"intersect once\" to load the next page when scrolled into view. Each response appends items and a new sentinel."
|
||||
:demo-description "Scroll down in the container to load more items (5 pages total)."
|
||||
:demo (~infinite-scroll-demo)
|
||||
:sx-code "(div :id \"scroll-sentinel\"\n :sx-get \"/examples/api/scroll?page=2\"\n :sx-trigger \"intersect once\"\n :sx-target \"#scroll-items\"\n :sx-swap \"beforeend\"\n \"Loading more...\")"
|
||||
:handler-code "@bp.get(\"/examples/api/scroll\")\nasync def api_scroll():\n page = int(request.args.get(\"page\", 2))\n items = [f\"Item {i}\" for i in range(...)]\n # Include next sentinel if more pages\n return sx_response(items_sx + sentinel_sx)"
|
||||
:comp-placeholder-id "scroll-comp"
|
||||
:wire-placeholder-id "scroll-wire"))
|
||||
|
||||
(defcomp ~example-progress-bar ()
|
||||
(~example-page-content
|
||||
:title "Progress Bar"
|
||||
:description "Start a server-side job, then poll for progress using sx-trigger=\"load delay:500ms\" on each response. The bar fills up and stops when complete."
|
||||
:demo-description "Click start to begin a simulated job."
|
||||
:demo (~progress-bar-demo)
|
||||
:sx-code ";; Start the job\n(button\n :sx-post \"/examples/api/progress/start\"\n :sx-target \"#progress-target\"\n :sx-swap \"innerHTML\")\n\n;; Each response re-polls via sx-trigger=\"load\"\n(div :sx-get \"/api/progress/status?job=ID\"\n :sx-trigger \"load delay:500ms\"\n :sx-target \"#progress-target\"\n :sx-swap \"innerHTML\")"
|
||||
:handler-code "@bp.post(\"/examples/api/progress/start\")\nasync def api_progress_start():\n job_id = str(uuid4())[:8]\n _jobs[job_id] = 0\n return sx_response(\n f'(~progress-status :percent 0 :job-id \"{job_id}\")')"
|
||||
:comp-placeholder-id "progress-comp"
|
||||
:wire-placeholder-id "progress-wire"))
|
||||
|
||||
(defcomp ~example-active-search ()
|
||||
(~example-page-content
|
||||
:title "Active Search"
|
||||
:description "An input with sx-trigger=\"keyup delay:300ms changed\" debounces keystrokes and only fires when the value changes. The server filters a list of programming languages."
|
||||
:demo-description "Type to search through 20 programming languages."
|
||||
:demo (~active-search-demo)
|
||||
:sx-code "(input :type \"text\" :name \"q\"\n :sx-get \"/examples/api/search\"\n :sx-trigger \"keyup delay:300ms changed\"\n :sx-target \"#search-results\"\n :sx-swap \"innerHTML\"\n :placeholder \"Search...\")"
|
||||
:handler-code "@bp.get(\"/examples/api/search\")\nasync def api_search():\n q = request.args.get(\"q\", \"\").lower()\n results = [l for l in LANGUAGES if q in l.lower()]\n return sx_response(\n f'(~search-results :items (...) :query \"{q}\")')"
|
||||
:comp-placeholder-id "search-comp"
|
||||
:wire-placeholder-id "search-wire"))
|
||||
|
||||
(defcomp ~example-inline-validation ()
|
||||
(~example-page-content
|
||||
:title "Inline Validation"
|
||||
:description "Validate an email field on blur. The server checks format and whether it is taken, returning green or red feedback inline."
|
||||
:demo-description "Enter an email and click away (blur) to validate."
|
||||
:demo (~inline-validation-demo)
|
||||
:sx-code "(input :type \"text\" :name \"email\"\n :sx-get \"/examples/api/validate\"\n :sx-trigger \"blur\"\n :sx-target \"#email-feedback\"\n :sx-swap \"innerHTML\"\n :placeholder \"user@example.com\")"
|
||||
:handler-code "@bp.get(\"/examples/api/validate\")\nasync def api_validate():\n email = request.args.get(\"email\", \"\")\n if \"@\" not in email:\n return sx_response('(~validation-error ...)')\n return sx_response('(~validation-ok ...)')"
|
||||
:comp-placeholder-id "validate-comp"
|
||||
:wire-placeholder-id "validate-wire"))
|
||||
|
||||
(defcomp ~example-value-select ()
|
||||
(~example-page-content
|
||||
:title "Value Select"
|
||||
:description "Two linked selects: pick a category and the second select updates with matching items via sx-get."
|
||||
:demo-description "Select a category to populate the item dropdown."
|
||||
:demo (~value-select-demo)
|
||||
:sx-code "(select :name \"category\"\n :sx-get \"/examples/api/values\"\n :sx-trigger \"change\"\n :sx-target \"#value-items\"\n :sx-swap \"innerHTML\"\n (option \"Languages\")\n (option \"Frameworks\")\n (option \"Databases\"))"
|
||||
:handler-code "@bp.get(\"/examples/api/values\")\nasync def api_values():\n cat = request.args.get(\"category\", \"\")\n items = VALUE_SELECT_DATA.get(cat, [])\n return sx_response(\n f'(~value-options :items (list ...))')"
|
||||
:comp-placeholder-id "values-comp"
|
||||
:wire-placeholder-id "values-wire"))
|
||||
|
||||
(defcomp ~example-reset-on-submit ()
|
||||
(~example-page-content
|
||||
:title "Reset on Submit"
|
||||
:description "Use sx-on:afterSwap=\"this.reset()\" to clear form inputs after a successful submission."
|
||||
:demo-description "Submit a message — the input resets after each send."
|
||||
:demo (~reset-on-submit-demo)
|
||||
:sx-code "(form :id \"reset-form\"\n :sx-post \"/examples/api/reset-submit\"\n :sx-target \"#reset-result\"\n :sx-swap \"innerHTML\"\n :sx-on:afterSwap \"this.reset()\"\n (input :type \"text\" :name \"message\")\n (button :type \"submit\" \"Send\"))"
|
||||
:handler-code "@bp.post(\"/examples/api/reset-submit\")\nasync def api_reset_submit():\n form = await request.form\n msg = form.get(\"message\", \"\")\n return sx_response(\n f'(~reset-message :message \"{msg}\" :time \"...\")')"
|
||||
:comp-placeholder-id "reset-comp"
|
||||
:wire-placeholder-id "reset-wire"))
|
||||
|
||||
(defcomp ~example-edit-row ()
|
||||
(~example-page-content
|
||||
:title "Edit Row"
|
||||
:description "Click edit to replace a table row with input fields. Save or cancel swaps back the display row. Uses sx-include to gather form values from the row."
|
||||
:demo-description "Click edit on any row to modify it inline."
|
||||
:demo (~edit-row-demo :rows (list
|
||||
(list "1" "Widget A" "19.99" "142")
|
||||
(list "2" "Widget B" "24.50" "89")
|
||||
(list "3" "Widget C" "12.00" "305")
|
||||
(list "4" "Widget D" "45.00" "67")))
|
||||
:sx-code "(button\n :sx-get \"/examples/api/editrow/1\"\n :sx-target \"#erow-1\"\n :sx-swap \"outerHTML\"\n \"edit\")\n\n;; Save sends form data via POST\n(button\n :sx-post \"/examples/api/editrow/1\"\n :sx-target \"#erow-1\"\n :sx-swap \"outerHTML\"\n :sx-include \"#erow-1\"\n \"save\")"
|
||||
:handler-code "@bp.get(\"/examples/api/editrow/<id>\")\nasync def api_editrow_form(id):\n row = EDIT_ROW_DATA[id]\n return sx_response(\n f'(~edit-row-form :id ... :name ...)')\n\n@bp.post(\"/examples/api/editrow/<id>\")\nasync def api_editrow_save(id):\n form = await request.form\n return sx_response(\n f'(~edit-row-view :id ... :name ...)')"
|
||||
:comp-placeholder-id "editrow-comp"
|
||||
:wire-placeholder-id "editrow-wire"))
|
||||
|
||||
(defcomp ~example-bulk-update ()
|
||||
(~example-page-content
|
||||
:title "Bulk Update"
|
||||
:description "Select rows with checkboxes and use Activate/Deactivate buttons. sx-include gathers checkbox values from the form."
|
||||
:demo-description "Check some rows, then click Activate or Deactivate."
|
||||
:demo (~bulk-update-demo :users (list
|
||||
(list "1" "Alice Chen" "alice@example.com" "active")
|
||||
(list "2" "Bob Rivera" "bob@example.com" "inactive")
|
||||
(list "3" "Carol Zhang" "carol@example.com" "active")
|
||||
(list "4" "Dan Okafor" "dan@example.com" "inactive")
|
||||
(list "5" "Eve Larsson" "eve@example.com" "active")))
|
||||
:sx-code "(button\n :sx-post \"/examples/api/bulk?action=activate\"\n :sx-target \"#bulk-table\"\n :sx-swap \"innerHTML\"\n :sx-include \"#bulk-form\"\n \"Activate\")"
|
||||
:handler-code "@bp.post(\"/examples/api/bulk\")\nasync def api_bulk():\n action = request.args.get(\"action\")\n form = await request.form\n ids = form.getlist(\"ids\")\n # Update matching users\n return sx_response(updated_rows)"
|
||||
:comp-placeholder-id "bulk-comp"
|
||||
:wire-placeholder-id "bulk-wire"))
|
||||
|
||||
(defcomp ~example-swap-positions ()
|
||||
(~example-page-content
|
||||
:title "Swap Positions"
|
||||
:description "Demonstrates different swap modes: beforeend appends, afterbegin prepends, and none skips the main swap while still processing OOB updates."
|
||||
:demo-description "Try each button to see different swap behaviours."
|
||||
:demo (~swap-positions-demo)
|
||||
:sx-code ";; Append to end\n(button :sx-post \"/api/swap-log?mode=beforeend\"\n :sx-target \"#swap-log\" :sx-swap \"beforeend\"\n \"Add to End\")\n\n;; Prepend to start\n(button :sx-post \"/api/swap-log?mode=afterbegin\"\n :sx-target \"#swap-log\" :sx-swap \"afterbegin\"\n \"Add to Start\")\n\n;; No swap — OOB counter update only\n(button :sx-post \"/api/swap-log?mode=none\"\n :sx-target \"#swap-log\" :sx-swap \"none\"\n \"Silent Ping\")"
|
||||
:handler-code "@bp.post(\"/examples/api/swap-log\")\nasync def api_swap_log():\n mode = request.args.get(\"mode\")\n # OOB counter updates on every request\n oob = f'(span :id \"swap-counter\" :sx-swap-oob \"innerHTML\" \"Count: {n}\")'\n return sx_response(entry + oob)"
|
||||
:wire-placeholder-id "swap-wire"))
|
||||
|
||||
(defcomp ~example-select-filter ()
|
||||
(~example-page-content
|
||||
:title "Select Filter"
|
||||
:description "sx-select lets the client pick a specific section from the server response by CSS selector. The server always returns the full dashboard — the client filters."
|
||||
:demo-description "Different buttons select different parts of the same server response."
|
||||
:demo (~select-filter-demo)
|
||||
:sx-code ";; Pick just the stats section from the response\n(button\n :sx-get \"/examples/api/dashboard\"\n :sx-target \"#filter-target\"\n :sx-swap \"innerHTML\"\n :sx-select \"#dash-stats\"\n \"Stats Only\")\n\n;; No sx-select — get the full response\n(button\n :sx-get \"/examples/api/dashboard\"\n :sx-target \"#filter-target\"\n :sx-swap \"innerHTML\"\n \"Full Dashboard\")"
|
||||
:handler-code "@bp.get(\"/examples/api/dashboard\")\nasync def api_dashboard():\n # Returns header + stats + footer\n # Client uses sx-select to pick sections\n return sx_response(\n '(<> (div :id \"dash-header\" ...) '\n ' (div :id \"dash-stats\" ...) '\n ' (div :id \"dash-footer\" ...))')"
|
||||
:wire-placeholder-id "filter-wire"))
|
||||
|
||||
(defcomp ~example-tabs ()
|
||||
(~example-page-content
|
||||
:title "Tabs"
|
||||
:description "Tab navigation using sx-push-url to update the browser URL. Back/forward buttons navigate between previously visited tabs."
|
||||
:demo-description "Click tabs to switch content. Watch the browser URL change."
|
||||
:demo (~tabs-demo)
|
||||
:sx-code "(button\n :sx-get \"/examples/api/tabs/tab1\"\n :sx-target \"#tab-content\"\n :sx-swap \"innerHTML\"\n :sx-push-url \"/examples/tabs?tab=tab1\"\n \"Overview\")"
|
||||
:handler-code "@bp.get(\"/examples/api/tabs/<tab>\")\nasync def api_tabs(tab: str):\n content = TAB_CONTENT[tab]\n return sx_response(content)"
|
||||
:wire-placeholder-id "tabs-wire"))
|
||||
|
||||
(defcomp ~example-animations ()
|
||||
(~example-page-content
|
||||
:title "Animations"
|
||||
:description "CSS animations play on swap. The component injects a style tag with a keyframe animation and applies the class. Each click picks a random background colour."
|
||||
:demo-description "Click to swap in content with a fade-in animation."
|
||||
:demo (~animations-demo)
|
||||
:sx-code "(button\n :sx-get \"/examples/api/animate\"\n :sx-target \"#anim-target\"\n :sx-swap \"innerHTML\"\n \"Load with animation\")\n\n;; Component uses CSS animation class\n(defcomp ~anim-result (&key color time)\n (div :class \"sx-fade-in ...\"\n (style \".sx-fade-in { animation: sxFadeIn 0.5s }\")\n (p \"Faded in!\")))"
|
||||
:handler-code "@bp.get(\"/examples/api/animate\")\nasync def api_animate():\n colors = [\"bg-violet-100\", \"bg-emerald-100\", ...]\n color = random.choice(colors)\n return sx_response(\n f'(~anim-result :color \"{color}\" :time \"{now}\")')"
|
||||
:comp-placeholder-id "anim-comp"
|
||||
:wire-placeholder-id "anim-wire"))
|
||||
|
||||
(defcomp ~example-dialogs ()
|
||||
(~example-page-content
|
||||
:title "Dialogs"
|
||||
:description "Open a modal dialog by swapping in the dialog component. Close by swapping in empty content. Pure sx — no JavaScript library needed."
|
||||
:demo-description "Click to open a modal dialog."
|
||||
:demo (~dialogs-demo)
|
||||
:sx-code "(button\n :sx-get \"/examples/api/dialog\"\n :sx-target \"#dialog-container\"\n :sx-swap \"innerHTML\"\n \"Open Dialog\")\n\n;; Dialog closes by swapping empty content\n(button\n :sx-get \"/examples/api/dialog/close\"\n :sx-target \"#dialog-container\"\n :sx-swap \"innerHTML\"\n \"Close\")"
|
||||
:handler-code "@bp.get(\"/examples/api/dialog\")\nasync def api_dialog():\n return sx_response(\n '(~dialog-modal :title \"Confirm\"'\n ' :message \"Are you sure?\")')\n\n@bp.get(\"/examples/api/dialog/close\")\nasync def api_dialog_close():\n return sx_response(\"\")"
|
||||
:comp-placeholder-id "dialog-comp"
|
||||
:wire-placeholder-id "dialog-wire"))
|
||||
|
||||
(defcomp ~example-keyboard-shortcuts ()
|
||||
(~example-page-content
|
||||
:title "Keyboard Shortcuts"
|
||||
:description "Use sx-trigger with keyup event filters and from:body to listen for global keyboard shortcuts. The filter prevents firing when typing in inputs."
|
||||
:demo-description "Press s, n, or h on your keyboard."
|
||||
:demo (~keyboard-shortcuts-demo)
|
||||
:sx-code "(div :id \"kbd-target\"\n :sx-get \"/examples/api/keyboard?key=s\"\n :sx-trigger \"keyup[key=='s'&&!event.target.matches('input,textarea')] from:body\"\n :sx-swap \"innerHTML\"\n \"Press a shortcut key...\")"
|
||||
:handler-code "@bp.get(\"/examples/api/keyboard\")\nasync def api_keyboard():\n key = request.args.get(\"key\", \"\")\n actions = {\"s\": \"Search\", \"n\": \"New item\", \"h\": \"Help\"}\n return sx_response(\n f'(~kbd-result :key \"{key}\" :action \"{actions[key]}\")')"
|
||||
:comp-placeholder-id "kbd-comp"
|
||||
:wire-placeholder-id "kbd-wire"))
|
||||
|
||||
(defcomp ~example-put-patch ()
|
||||
(~example-page-content
|
||||
:title "PUT / PATCH"
|
||||
:description "sx-put replaces the entire resource. This example shows a profile card with an Edit All button that sends a PUT with all fields."
|
||||
:demo-description "Click Edit All to replace the full profile via PUT."
|
||||
:demo (~put-patch-demo :name "Ada Lovelace" :email "ada@example.com" :role "Engineer")
|
||||
:sx-code ";; Replace entire resource\n(form :sx-put \"/examples/api/putpatch\"\n :sx-target \"#pp-target\" :sx-swap \"innerHTML\"\n (input :name \"name\") (input :name \"email\")\n (button \"Save All (PUT)\"))"
|
||||
:handler-code "@bp.put(\"/examples/api/putpatch\")\nasync def api_put():\n form = await request.form\n # Full replacement\n return sx_response('(~pp-view ...)')"
|
||||
:comp-placeholder-id "pp-comp"
|
||||
:wire-placeholder-id "pp-wire"))
|
||||
|
||||
(defcomp ~example-json-encoding ()
|
||||
(~example-page-content
|
||||
:title "JSON Encoding"
|
||||
:description "Use sx-encoding=\"json\" to send form data as a JSON body instead of URL-encoded form data. The server echoes back what it received."
|
||||
:demo-description "Submit the form and see the JSON body the server received."
|
||||
:demo (~json-encoding-demo)
|
||||
:sx-code "(form\n :sx-post \"/examples/api/json-echo\"\n :sx-target \"#json-result\"\n :sx-swap \"innerHTML\"\n :sx-encoding \"json\"\n (input :name \"name\" :value \"Ada\")\n (input :type \"number\" :name \"age\" :value \"36\")\n (button \"Submit as JSON\"))"
|
||||
:handler-code "@bp.post(\"/examples/api/json-echo\")\nasync def api_json_echo():\n data = await request.get_json()\n body = json.dumps(data, indent=2)\n ct = request.content_type\n return sx_response(\n f'(~json-result :body \"{body}\" :content-type \"{ct}\")')"
|
||||
:comp-placeholder-id "json-comp"
|
||||
:wire-placeholder-id "json-wire"))
|
||||
|
||||
(defcomp ~example-vals-and-headers ()
|
||||
(~example-page-content
|
||||
:title "Vals & Headers"
|
||||
:description "sx-vals adds extra key/value pairs to the request parameters. sx-headers adds custom HTTP headers. The server echoes back what it received."
|
||||
:demo-description "Click each button to see what the server receives."
|
||||
:demo (~vals-headers-demo)
|
||||
:sx-code ";; Send extra values with the request\n(button\n :sx-get \"/examples/api/echo-vals\"\n :sx-vals \"{\\\"source\\\": \\\"button\\\"}\"\n \"Send with vals\")\n\n;; Send custom headers\n(button\n :sx-get \"/examples/api/echo-headers\"\n :sx-headers {:X-Custom-Token \"abc123\"}\n \"Send with headers\")"
|
||||
:handler-code "@bp.get(\"/examples/api/echo-vals\")\nasync def api_echo_vals():\n vals = dict(request.args)\n return sx_response(\n f'(~echo-result :label \"values\" :items (...))')\n\n@bp.get(\"/examples/api/echo-headers\")\nasync def api_echo_headers():\n custom = {k: v for k, v in request.headers\n if k.startswith(\"X-\")}\n return sx_response(\n f'(~echo-result :label \"headers\" :items (...))')"
|
||||
:comp-placeholder-id "vals-comp"
|
||||
:wire-placeholder-id "vals-wire"))
|
||||
|
||||
(defcomp ~example-loading-states ()
|
||||
(~example-page-content
|
||||
:title "Loading States"
|
||||
:description "sx.js adds the .sx-request CSS class to any element that has an active request. Use pure CSS to show spinners, disable buttons, or change opacity during loading."
|
||||
:demo-description "Click the button — it shows a spinner during the 2-second request."
|
||||
:demo (~loading-states-demo)
|
||||
:sx-code ";; .sx-request class added during request\n(style \".sx-loading-btn.sx-request {\n opacity: 0.7; pointer-events: none; }\n.sx-loading-btn.sx-request .sx-spinner {\n display: inline-block; }\n.sx-loading-btn .sx-spinner {\n display: none; }\")\n\n(button :class \"sx-loading-btn\"\n :sx-get \"/examples/api/slow\"\n :sx-target \"#loading-result\"\n (span :class \"sx-spinner animate-spin\" \"...\")\n \"Load slow endpoint\")"
|
||||
:handler-code "@bp.get(\"/examples/api/slow\")\nasync def api_slow():\n await asyncio.sleep(2)\n return sx_response(\n f'(~loading-result :time \"{now}\")')"
|
||||
:comp-placeholder-id "loading-comp"
|
||||
:wire-placeholder-id "loading-wire"))
|
||||
|
||||
(defcomp ~example-sync-replace ()
|
||||
(~example-page-content
|
||||
:title "Request Abort"
|
||||
:description "sx-sync=\"replace\" aborts any in-flight request before sending a new one. This prevents stale responses from overwriting newer ones, even with random server delays."
|
||||
:demo-description "Type quickly — only the latest result appears despite random 0.5-2s server delays."
|
||||
:demo (~sync-replace-demo)
|
||||
:sx-code "(input :type \"text\" :name \"q\"\n :sx-get \"/examples/api/slow-search\"\n :sx-trigger \"keyup delay:200ms changed\"\n :sx-target \"#sync-result\"\n :sx-swap \"innerHTML\"\n :sx-sync \"replace\"\n \"Type to search...\")"
|
||||
:handler-code "@bp.get(\"/examples/api/slow-search\")\nasync def api_slow_search():\n delay = random.uniform(0.5, 2.0)\n await asyncio.sleep(delay)\n q = request.args.get(\"q\", \"\")\n return sx_response(\n f'(~sync-result :query \"{q}\" :delay \"{delay_ms}\")')"
|
||||
:comp-placeholder-id "sync-comp"
|
||||
:wire-placeholder-id "sync-wire"))
|
||||
|
||||
(defcomp ~example-retry ()
|
||||
(~example-page-content
|
||||
:title "Retry"
|
||||
:description "sx-retry=\"exponential:1000:8000\" retries failed requests with exponential backoff starting at 1s up to 8s. The endpoint fails the first 2 attempts and succeeds on the 3rd."
|
||||
:demo-description "Click the button — watch it retry automatically after failures."
|
||||
:demo (~retry-demo)
|
||||
:sx-code "(button\n :sx-get \"/examples/api/flaky\"\n :sx-target \"#retry-result\"\n :sx-swap \"innerHTML\"\n :sx-retry \"exponential:1000:8000\"\n \"Call flaky endpoint\")"
|
||||
:handler-code "@bp.get(\"/examples/api/flaky\")\nasync def api_flaky():\n _flaky[\"n\"] += 1\n if _flaky[\"n\"] % 3 != 0:\n return Response(\"\", status=503)\n return sx_response(\n f'(~retry-result :attempt {n} ...)')"
|
||||
:comp-placeholder-id "retry-comp"
|
||||
:wire-placeholder-id "retry-wire"))
|
||||
58
sx/sx/examples.sx
Normal file
58
sx/sx/examples.sx
Normal file
@@ -0,0 +1,58 @@
|
||||
;; Example page template and reference index
|
||||
;; Template receives data values (code strings, titles), calls highlight internally.
|
||||
|
||||
(defcomp ~example-page-content (&key title description demo-description demo
|
||||
sx-code sx-lang handler-code handler-lang
|
||||
comp-placeholder-id wire-placeholder-id wire-note
|
||||
comp-heading handler-heading)
|
||||
(~doc-page :title title
|
||||
(p :class "text-stone-600 mb-6" description)
|
||||
(~example-card :title "Demo" :description demo-description
|
||||
(~example-demo demo))
|
||||
(h3 :class "text-lg font-semibold text-stone-700 mt-6" "S-expression")
|
||||
(~example-source :code (highlight sx-code (if sx-lang sx-lang "lisp")))
|
||||
(when comp-placeholder-id
|
||||
(<>
|
||||
(h3 :class "text-lg font-semibold text-stone-700 mt-6"
|
||||
(if comp-heading comp-heading "Component"))
|
||||
(~doc-placeholder :id comp-placeholder-id)))
|
||||
(h3 :class "text-lg font-semibold text-stone-700 mt-6"
|
||||
(if handler-heading handler-heading "Server handler"))
|
||||
(~example-source :code (highlight handler-code (if handler-lang handler-lang "python")))
|
||||
(div :class "flex items-center justify-between mt-6"
|
||||
(h3 :class "text-lg font-semibold text-stone-700" "Wire response")
|
||||
(~doc-clear-cache-btn))
|
||||
(when wire-note
|
||||
(p :class "text-stone-500 text-sm mb-2" wire-note))
|
||||
(when wire-placeholder-id
|
||||
(~doc-placeholder :id wire-placeholder-id))))
|
||||
|
||||
(defcomp ~reference-index-content ()
|
||||
(~doc-page :title "Reference"
|
||||
(p :class "text-stone-600 mb-6"
|
||||
"Complete reference for the sx client library.")
|
||||
(div :class "grid gap-4 sm:grid-cols-2"
|
||||
(a :href "/reference/attributes"
|
||||
:sx-get "/reference/attributes" :sx-target "#main-panel" :sx-select "#main-panel"
|
||||
:sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class "block p-5 rounded-lg border border-stone-200 hover:border-violet-300 hover:shadow-sm transition-all no-underline"
|
||||
(h3 :class "text-lg font-semibold text-violet-700 mb-1" "Attributes")
|
||||
(p :class "text-stone-600 text-sm" "All sx attributes — request verbs, behavior modifiers, and sx-unique features."))
|
||||
(a :href "/reference/headers"
|
||||
:sx-get "/reference/headers" :sx-target "#main-panel" :sx-select "#main-panel"
|
||||
:sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class "block p-5 rounded-lg border border-stone-200 hover:border-violet-300 hover:shadow-sm transition-all no-underline"
|
||||
(h3 :class "text-lg font-semibold text-violet-700 mb-1" "Headers")
|
||||
(p :class "text-stone-600 text-sm" "Custom HTTP headers used to coordinate between the sx client and server."))
|
||||
(a :href "/reference/events"
|
||||
:sx-get "/reference/events" :sx-target "#main-panel" :sx-select "#main-panel"
|
||||
:sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class "block p-5 rounded-lg border border-stone-200 hover:border-violet-300 hover:shadow-sm transition-all no-underline"
|
||||
(h3 :class "text-lg font-semibold text-violet-700 mb-1" "Events")
|
||||
(p :class "text-stone-600 text-sm" "DOM events fired during the sx request lifecycle."))
|
||||
(a :href "/reference/js-api"
|
||||
:sx-get "/reference/js-api" :sx-target "#main-panel" :sx-select "#main-panel"
|
||||
:sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class "block p-5 rounded-lg border border-stone-200 hover:border-violet-300 hover:shadow-sm transition-all no-underline"
|
||||
(h3 :class "text-lg font-semibold text-violet-700 mb-1" "JS API")
|
||||
(p :class "text-stone-600 text-sm" "JavaScript functions for parsing, evaluating, and rendering s-expressions.")))))
|
||||
@@ -1,17 +1,93 @@
|
||||
;; SX docs layout defcomps — root header via ~root-header-auto,
|
||||
;; sx-specific headers passed as &key params.
|
||||
;; SX docs layout defcomps — fully self-contained via IO primitives.
|
||||
;; Registered via register_sx_layout in __init__.py.
|
||||
|
||||
;; --- SX home layout: root + sx menu row ---
|
||||
;; --- Main nav defcomp: static nav items from MAIN_NAV ---
|
||||
;; @css aria-selected:bg-violet-200 aria-selected:text-violet-900
|
||||
|
||||
(defcomp ~sx-layout-full (&key sx-row)
|
||||
(defcomp ~sx-main-nav (&key section)
|
||||
(let* ((sc "aria-selected:bg-violet-200 aria-selected:text-violet-900")
|
||||
(items (list
|
||||
(dict :label "Docs" :href "/docs/introduction")
|
||||
(dict :label "Reference" :href "/reference/")
|
||||
(dict :label "Protocols" :href "/protocols/wire-format")
|
||||
(dict :label "Examples" :href "/examples/click-to-load")
|
||||
(dict :label "Essays" :href "/essays/sx-sucks"))))
|
||||
(<> (map (lambda (item)
|
||||
(~nav-link
|
||||
:href (get item "href")
|
||||
:label (get item "label")
|
||||
:is-selected (when (= (get item "label") section) "true")
|
||||
:select-colours sc))
|
||||
items))))
|
||||
|
||||
;; --- SX header row ---
|
||||
|
||||
(defcomp ~sx-header-row (&key nav child oob)
|
||||
(~menu-row-sx :id "sx-row" :level 1 :colour "violet"
|
||||
:link-href "/" :link-label "sx"
|
||||
:link-label-content (~sx-docs-label)
|
||||
:nav nav
|
||||
:child-id "sx-header-child"
|
||||
:child child
|
||||
:oob oob))
|
||||
|
||||
;; --- Sub-row for section pages ---
|
||||
|
||||
(defcomp ~sx-sub-row (&key sub-label sub-href sub-nav selected oob)
|
||||
(~menu-row-sx :id "sx-sub-row" :level 2 :colour "violet"
|
||||
:link-href sub-href :link-label sub-label
|
||||
:selected selected
|
||||
:nav sub-nav
|
||||
:oob oob))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; SX home layout (root + sx header)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~sx-layout-full (&key section)
|
||||
(<> (~root-header-auto)
|
||||
sx-row))
|
||||
(~sx-header-row :nav (~sx-main-nav :section section))))
|
||||
|
||||
(defcomp ~sx-layout-oob (&key root-header sx-row)
|
||||
(<> root-header sx-row))
|
||||
(defcomp ~sx-layout-oob (&key section)
|
||||
(<> (~sx-header-row
|
||||
:nav (~sx-main-nav :section section)
|
||||
:oob true)
|
||||
(~clear-oob-div :id "sx-header-child")
|
||||
(~root-header-auto true)))
|
||||
|
||||
;; --- SX section layout: root + sx row (with child sub-row) ---
|
||||
(defcomp ~sx-layout-mobile (&key section)
|
||||
(<> (~mobile-menu-section
|
||||
:label "sx" :href "/" :level 1 :colour "violet"
|
||||
:items (~sx-main-nav :section section))
|
||||
(~root-mobile-auto)))
|
||||
|
||||
(defcomp ~sx-section-layout-full (&key sx-row)
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; SX section layout (root + sx header + sub-row)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~sx-section-layout-full (&key section sub-label sub-href sub-nav selected)
|
||||
(<> (~root-header-auto)
|
||||
sx-row))
|
||||
(~sx-header-row
|
||||
:nav (~sx-main-nav :section section)
|
||||
:child (~sx-sub-row :sub-label sub-label :sub-href sub-href
|
||||
:sub-nav sub-nav :selected selected))))
|
||||
|
||||
(defcomp ~sx-section-layout-oob (&key section sub-label sub-href sub-nav selected)
|
||||
(<> (~oob-header-sx :parent-id "sx-header-child"
|
||||
:row (~sx-sub-row :sub-label sub-label :sub-href sub-href
|
||||
:sub-nav sub-nav :selected selected))
|
||||
(~sx-header-row
|
||||
:nav (~sx-main-nav :section section)
|
||||
:oob true)
|
||||
(~root-header-auto true)))
|
||||
|
||||
(defcomp ~sx-section-layout-mobile (&key section sub-label sub-href sub-nav)
|
||||
(<>
|
||||
(when sub-nav
|
||||
(~mobile-menu-section
|
||||
:label (or sub-label section) :href sub-href :level 2 :colour "violet"
|
||||
:items sub-nav))
|
||||
(~mobile-menu-section
|
||||
:label "sx" :href "/" :level 1 :colour "violet"
|
||||
:items (~sx-main-nav :section section))
|
||||
(~root-mobile-auto)))
|
||||
|
||||
87
sx/sx/nav-data.sx
Normal file
87
sx/sx/nav-data.sx
Normal file
@@ -0,0 +1,87 @@
|
||||
;; Navigation data and section-nav component for sx docs.
|
||||
;; Replaces Python nav tuples from content/pages.py and _nav_items_sx() from utils.py.
|
||||
;; @css aria-selected:bg-violet-200 aria-selected:text-violet-900
|
||||
|
||||
(define docs-nav-items (list
|
||||
(dict :label "Introduction" :href "/docs/introduction")
|
||||
(dict :label "Getting Started" :href "/docs/getting-started")
|
||||
(dict :label "Components" :href "/docs/components")
|
||||
(dict :label "Evaluator" :href "/docs/evaluator")
|
||||
(dict :label "Primitives" :href "/docs/primitives")
|
||||
(dict :label "CSS" :href "/docs/css")
|
||||
(dict :label "Server Rendering" :href "/docs/server-rendering")))
|
||||
|
||||
(define reference-nav-items (list
|
||||
(dict :label "Attributes" :href "/reference/attributes")
|
||||
(dict :label "Headers" :href "/reference/headers")
|
||||
(dict :label "Events" :href "/reference/events")
|
||||
(dict :label "JS API" :href "/reference/js-api")))
|
||||
|
||||
(define protocols-nav-items (list
|
||||
(dict :label "Wire Format" :href "/protocols/wire-format")
|
||||
(dict :label "Fragments" :href "/protocols/fragments")
|
||||
(dict :label "Resolver I/O" :href "/protocols/resolver-io")
|
||||
(dict :label "Internal Services" :href "/protocols/internal-services")
|
||||
(dict :label "ActivityPub" :href "/protocols/activitypub")
|
||||
(dict :label "Future" :href "/protocols/future")))
|
||||
|
||||
(define examples-nav-items (list
|
||||
(dict :label "Click to Load" :href "/examples/click-to-load")
|
||||
(dict :label "Form Submission" :href "/examples/form-submission")
|
||||
(dict :label "Polling" :href "/examples/polling")
|
||||
(dict :label "Delete Row" :href "/examples/delete-row")
|
||||
(dict :label "Inline Edit" :href "/examples/inline-edit")
|
||||
(dict :label "OOB Swaps" :href "/examples/oob-swaps")
|
||||
(dict :label "Lazy Loading" :href "/examples/lazy-loading")
|
||||
(dict :label "Infinite Scroll" :href "/examples/infinite-scroll")
|
||||
(dict :label "Progress Bar" :href "/examples/progress-bar")
|
||||
(dict :label "Active Search" :href "/examples/active-search")
|
||||
(dict :label "Inline Validation" :href "/examples/inline-validation")
|
||||
(dict :label "Value Select" :href "/examples/value-select")
|
||||
(dict :label "Reset on Submit" :href "/examples/reset-on-submit")
|
||||
(dict :label "Edit Row" :href "/examples/edit-row")
|
||||
(dict :label "Bulk Update" :href "/examples/bulk-update")
|
||||
(dict :label "Swap Positions" :href "/examples/swap-positions")
|
||||
(dict :label "Select Filter" :href "/examples/select-filter")
|
||||
(dict :label "Tabs" :href "/examples/tabs")
|
||||
(dict :label "Animations" :href "/examples/animations")
|
||||
(dict :label "Dialogs" :href "/examples/dialogs")
|
||||
(dict :label "Keyboard Shortcuts" :href "/examples/keyboard-shortcuts")
|
||||
(dict :label "PUT / PATCH" :href "/examples/put-patch")
|
||||
(dict :label "JSON Encoding" :href "/examples/json-encoding")
|
||||
(dict :label "Vals & Headers" :href "/examples/vals-and-headers")
|
||||
(dict :label "Loading States" :href "/examples/loading-states")
|
||||
(dict :label "Request Abort" :href "/examples/sync-replace")
|
||||
(dict :label "Retry" :href "/examples/retry")))
|
||||
|
||||
(define essays-nav-items (list
|
||||
(dict :label "sx sucks" :href "/essays/sx-sucks")
|
||||
(dict :label "Why S-Expressions" :href "/essays/why-sexps")
|
||||
(dict :label "The htmx/React Hybrid" :href "/essays/htmx-react-hybrid")
|
||||
(dict :label "On-Demand CSS" :href "/essays/on-demand-css")
|
||||
(dict :label "Client Reactivity" :href "/essays/client-reactivity")
|
||||
(dict :label "SX Native" :href "/essays/sx-native")
|
||||
(dict :label "The SX Manifesto" :href "/essays/sx-manifesto")
|
||||
(dict :label "Tail-Call Optimization" :href "/essays/tail-call-optimization")
|
||||
(dict :label "Continuations" :href "/essays/continuations")))
|
||||
|
||||
;; Find the current nav label for a slug by matching href suffix.
|
||||
;; Returns the label string or nil if no match.
|
||||
(define find-current
|
||||
(fn (items slug)
|
||||
(when slug
|
||||
(some (fn (item)
|
||||
(when (ends-with? (get item "href") slug)
|
||||
(get item "label")))
|
||||
items))))
|
||||
|
||||
;; Generic section nav — builds nav links from a list of items.
|
||||
;; Replaces _nav_items_sx() and all section-specific nav builders in utils.py.
|
||||
(defcomp ~section-nav (&key items current)
|
||||
(<> (map (fn (item)
|
||||
(~nav-link
|
||||
:href (get item "href")
|
||||
:label (get item "label")
|
||||
:is-selected (when (= (get item "label") current) "true")
|
||||
:select-colours "aria-selected:bg-violet-200 aria-selected:text-violet-900"))
|
||||
items)))
|
||||
98
sx/sx/protocols.sx
Normal file
98
sx/sx/protocols.sx
Normal file
@@ -0,0 +1,98 @@
|
||||
;; Protocol documentation pages — fully self-contained
|
||||
|
||||
(defcomp ~protocol-wire-format-content ()
|
||||
(~doc-page :title "Wire Format"
|
||||
(~doc-section :title "The text/sx content type" :id "content-type"
|
||||
(p :class "text-stone-600"
|
||||
"sx responses use content type text/sx. The body is s-expression source code. The client parses and evaluates it, then renders the result into the DOM.")
|
||||
(~doc-code :code (highlight "HTTP/1.1 200 OK\nContent-Type: text/sx\nSX-Css-Hash: a1b2c3d4\n\n(div :class \"p-4\"\n (~card :title \"Hello\"))" "bash")))
|
||||
(~doc-section :title "Request lifecycle" :id "lifecycle"
|
||||
(p :class "text-stone-600"
|
||||
"1. User interacts with an element that has sx-get/sx-post/etc.")
|
||||
(p :class "text-stone-600"
|
||||
"2. sx.js fires sx:beforeRequest, then sends the HTTP request with SX-Request: true header.")
|
||||
(p :class "text-stone-600"
|
||||
"3. Server builds s-expression tree, scans CSS classes, prepends missing component definitions.")
|
||||
(p :class "text-stone-600"
|
||||
"4. Client receives text/sx response, parses it, evaluates it, renders to DOM.")
|
||||
(p :class "text-stone-600"
|
||||
"5. sx.js fires sx:afterSwap and sx:afterSettle.")
|
||||
(p :class "text-stone-600"
|
||||
"6. Any sx-swap-oob elements are swapped into their targets elsewhere in the DOM."))
|
||||
(~doc-section :title "Component definitions" :id "components"
|
||||
(p :class "text-stone-600"
|
||||
"On full page loads, component definitions are in <script type=\"text/sx\" data-components>. On subsequent sx requests, missing definitions are prepended to the response body. The client caches definitions in localStorage keyed by a content hash."))))
|
||||
|
||||
(defcomp ~protocol-fragments-content ()
|
||||
(~doc-page :title "Cross-Service Fragments"
|
||||
(~doc-section :title "Fragment protocol" :id "protocol"
|
||||
(p :class "text-stone-600"
|
||||
"Rose Ash runs as independent microservices. Each service can expose HTML or sx fragments that other services compose into their pages. Fragment endpoints return text/sx or text/html.")
|
||||
(p :class "text-stone-600"
|
||||
"The frag resolver is an I/O primitive in the render tree:")
|
||||
(~doc-code :code (highlight "(frag \"blog\" \"link-card\" :slug \"hello\")" "lisp")))
|
||||
(~doc-section :title "SxExpr wrapping" :id "wrapping"
|
||||
(p :class "text-stone-600"
|
||||
"When a fragment returns text/sx, the response is wrapped in an SxExpr and embedded directly in the render tree. When it returns text/html, it's wrapped in a ~rich-text component that inserts the HTML via raw!. This allows transparent composition across service boundaries."))
|
||||
(~doc-section :title "fetch_fragments()" :id "fetch"
|
||||
(p :class "text-stone-600"
|
||||
"The Python helper fetch_fragments() fetches multiple fragments in parallel via asyncio.gather(). Fragments are cached in Redis with short TTLs. Each fragment request is HMAC-signed for authentication."))))
|
||||
|
||||
(defcomp ~protocol-resolver-io-content ()
|
||||
(~doc-page :title "Resolver I/O"
|
||||
(~doc-section :title "Async I/O primitives" :id "primitives"
|
||||
(p :class "text-stone-600"
|
||||
"The sx resolver identifies I/O nodes in the render tree, groups them, executes them in parallel via asyncio.gather(), and substitutes results back in.")
|
||||
(p :class "text-stone-600"
|
||||
"I/O primitives:")
|
||||
(ul :class "space-y-2 text-stone-600"
|
||||
(li (span :class "font-mono text-violet-700" "frag") " — fetch a cross-service fragment")
|
||||
(li (span :class "font-mono text-violet-700" "query") " — read data from another service")
|
||||
(li (span :class "font-mono text-violet-700" "action") " — execute a write on another service")
|
||||
(li (span :class "font-mono text-violet-700" "current-user") " — resolve the current authenticated user")))
|
||||
(~doc-section :title "Execution model" :id "execution"
|
||||
(p :class "text-stone-600"
|
||||
"The render tree is walked to find I/O nodes. All nodes at the same depth are gathered and executed in parallel. Results replace the I/O nodes in the tree. The walk continues until no more I/O nodes are found. This typically completes in 1-2 passes."))))
|
||||
|
||||
(defcomp ~protocol-internal-services-content ()
|
||||
(~doc-page :title "Internal Services"
|
||||
(~doc-note
|
||||
(p "Honest note: the internal service protocol is JSON, not sx. Sx is the composition layer on top. The protocols below are the plumbing underneath."))
|
||||
(~doc-section :title "HMAC-signed HTTP" :id "hmac"
|
||||
(p :class "text-stone-600"
|
||||
"Services communicate via HMAC-signed HTTP requests with short timeouts:")
|
||||
(ul :class "space-y-2 text-stone-600 font-mono text-sm"
|
||||
(li "GET /internal/data/{query} — read data (3s timeout)")
|
||||
(li "POST /internal/actions/{action} — execute write (5s timeout)")
|
||||
(li "POST /internal/inbox — ActivityPub-shaped event delivery")))
|
||||
(~doc-section :title "fetch_data / call_action" :id "fetch"
|
||||
(p :class "text-stone-600"
|
||||
"Python helpers fetch_data() and call_action() handle HMAC signing, serialization, and error handling. They resolve service URLs from environment variables (INTERNAL_URL_BLOG, etc) and fall back to public URLs in development."))))
|
||||
|
||||
(defcomp ~protocol-activitypub-content ()
|
||||
(~doc-page :title "ActivityPub"
|
||||
(~doc-note
|
||||
(p "Honest note: ActivityPub wire format is JSON-LD, not sx. This documents how AP integrates with the sx rendering layer."))
|
||||
(~doc-section :title "AP activities" :id "activities"
|
||||
(p :class "text-stone-600"
|
||||
"Rose Ash services communicate cross-domain writes via ActivityPub-shaped activities. Each service has a virtual actor. Activities are JSON-LD objects sent to /internal/inbox endpoints. RSA signatures authenticate the sender."))
|
||||
(~doc-section :title "Event bus" :id "bus"
|
||||
(p :class "text-stone-600"
|
||||
"The event bus dispatches activities to registered handlers. Handlers are async functions that process the activity and may trigger side effects. The bus runs as a background processor in each service."))))
|
||||
|
||||
(defcomp ~protocol-future-content ()
|
||||
(~doc-page :title "Future Possibilities"
|
||||
(~doc-note
|
||||
(p "This page is speculative. Nothing here is implemented. It documents ideas that may or may not happen."))
|
||||
(~doc-section :title "Custom protocol schemes" :id "schemes"
|
||||
(p :class "text-stone-600"
|
||||
"sx:// and sxs:// as custom URI schemes for content addressing or deep linking. An sx:// URI could resolve to an sx expression from a federated registry. This is technically feasible but practically unnecessary for a single-site deployment."))
|
||||
(~doc-section :title "Sx as AP serialization" :id "ap-sx"
|
||||
(p :class "text-stone-600"
|
||||
"ActivityPub objects could be serialized as s-expressions instead of JSON-LD. S-expressions are more compact and easier to parse. The practical barrier: the entire AP ecosystem expects JSON-LD."))
|
||||
(~doc-section :title "Sx-native federation" :id "federation"
|
||||
(p :class "text-stone-600"
|
||||
"Federated services could exchange sx fragments directly — render a remote user's profile card by fetching its sx source from their server. This requires trust and standardization that doesn't exist yet."))
|
||||
(~doc-section :title "Realistic assessment" :id "realistic"
|
||||
(p :class "text-stone-600"
|
||||
"The most likely near-term improvement is sx:// deep linking for client-side component resolution. Everything else requires ecosystem adoption that one project can't drive alone."))))
|
||||
51
sx/sx/reference.sx
Normal file
51
sx/sx/reference.sx
Normal file
@@ -0,0 +1,51 @@
|
||||
;; Reference page layouts — receive data from Python primitives
|
||||
|
||||
(defcomp ~reference-attrs-content (&key req-table beh-table uniq-table)
|
||||
(~doc-page :title "Attribute Reference"
|
||||
(p :class "text-stone-600 mb-6"
|
||||
"sx attributes mirror htmx where possible. This table shows all available attributes and their status.")
|
||||
(div :class "space-y-8"
|
||||
req-table
|
||||
beh-table
|
||||
uniq-table)))
|
||||
|
||||
(defcomp ~reference-headers-content (&key req-table resp-table)
|
||||
(~doc-page :title "Headers"
|
||||
(p :class "text-stone-600 mb-6"
|
||||
"sx uses custom HTTP headers to coordinate between client and server.")
|
||||
(div :class "space-y-8"
|
||||
req-table
|
||||
resp-table)))
|
||||
|
||||
(defcomp ~reference-events-content (&key table)
|
||||
(~doc-page :title "Events"
|
||||
table))
|
||||
|
||||
(defcomp ~reference-js-api-content (&key table)
|
||||
(~doc-page :title "JavaScript API"
|
||||
table))
|
||||
|
||||
(defcomp ~reference-attr-detail-content (&key title description demo
|
||||
example-code handler-code wire-placeholder-id)
|
||||
(~doc-page :title title
|
||||
(p :class "text-stone-600 mb-6" description)
|
||||
(when demo
|
||||
(~example-card :title "Demo"
|
||||
(~example-demo demo)))
|
||||
(h3 :class "text-lg font-semibold text-stone-700 mt-6" "S-expression")
|
||||
(~example-source :code (highlight example-code "lisp"))
|
||||
(when handler-code
|
||||
(<>
|
||||
(h3 :class "text-lg font-semibold text-stone-700 mt-6" "Server handler")
|
||||
(~example-source :code (highlight handler-code "lisp"))))
|
||||
(when wire-placeholder-id
|
||||
(<>
|
||||
(h3 :class "text-lg font-semibold text-stone-700 mt-6" "Wire response")
|
||||
(p :class "text-stone-500 text-sm mb-2"
|
||||
"Trigger the demo to see the raw response the server sends.")
|
||||
(~doc-placeholder :id wire-placeholder-id)))))
|
||||
|
||||
(defcomp ~reference-attr-not-found (&key slug)
|
||||
(~doc-page :title "Not Found"
|
||||
(p :class "text-stone-600"
|
||||
(str "No documentation found for \"" slug "\"."))))
|
||||
@@ -705,7 +705,7 @@
|
||||
:sx-get "/examples/api/echo-headers"
|
||||
:sx-target "#headers-result"
|
||||
:sx-swap "innerHTML"
|
||||
:sx-headers "{\"X-Custom-Token\": \"abc123\", \"X-Request-Source\": \"demo\"}"
|
||||
:sx-headers {:X-Custom-Token "abc123" :X-Request-Source "demo"}
|
||||
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
||||
"Send with headers")
|
||||
(div :id "headers-result" :class "p-3 rounded bg-stone-50 text-sm text-stone-400"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
;; SX docs app — declarative page definitions
|
||||
;; These replace the GET route handlers in routes.py
|
||||
;; All content dispatched via case + direct component references.
|
||||
;; Navigation built from SX data (nav-data.sx), no Python intermediaries.
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Home page
|
||||
@@ -9,7 +10,7 @@
|
||||
:path "/"
|
||||
:auth :public
|
||||
:layout :sx
|
||||
:content (home-content))
|
||||
:content (~sx-home-content))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Docs section
|
||||
@@ -22,9 +23,9 @@
|
||||
:section "Docs"
|
||||
:sub-label "Docs"
|
||||
:sub-href "/docs/introduction"
|
||||
:sub-nav (docs-nav "Introduction")
|
||||
:sub-nav (~section-nav :items docs-nav-items :current "Introduction")
|
||||
:selected "Introduction")
|
||||
:content (docs-content "introduction"))
|
||||
:content (~docs-introduction-content))
|
||||
|
||||
(defpage docs-page
|
||||
:path "/docs/<slug>"
|
||||
@@ -33,9 +34,19 @@
|
||||
:section "Docs"
|
||||
:sub-label "Docs"
|
||||
:sub-href "/docs/introduction"
|
||||
:sub-nav (docs-nav (find-current DOCS_NAV slug))
|
||||
:selected (or (find-current DOCS_NAV slug) ""))
|
||||
:content (docs-content slug))
|
||||
:sub-nav (~section-nav :items docs-nav-items
|
||||
:current (find-current docs-nav-items slug))
|
||||
:selected (or (find-current docs-nav-items slug) ""))
|
||||
:content (case slug
|
||||
"introduction" (~docs-introduction-content)
|
||||
"getting-started" (~docs-getting-started-content)
|
||||
"components" (~docs-components-content)
|
||||
"evaluator" (~docs-evaluator-content)
|
||||
"primitives" (~docs-primitives-content
|
||||
:prims (~doc-primitives-tables :primitives (primitives-data)))
|
||||
"css" (~docs-css-content)
|
||||
"server-rendering" (~docs-server-rendering-content)
|
||||
:else (~docs-introduction-content)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Reference section
|
||||
@@ -48,9 +59,9 @@
|
||||
:section "Reference"
|
||||
:sub-label "Reference"
|
||||
:sub-href "/reference/"
|
||||
:sub-nav (reference-nav "")
|
||||
:sub-nav (~section-nav :items reference-nav-items :current "")
|
||||
:selected "")
|
||||
:content (reference-index-content))
|
||||
:content (~reference-index-content))
|
||||
|
||||
(defpage reference-page
|
||||
:path "/reference/<slug>"
|
||||
@@ -59,9 +70,30 @@
|
||||
:section "Reference"
|
||||
:sub-label "Reference"
|
||||
:sub-href "/reference/"
|
||||
:sub-nav (reference-nav (find-current REFERENCE_NAV slug))
|
||||
:selected (or (find-current REFERENCE_NAV slug) ""))
|
||||
:content (reference-content slug))
|
||||
:sub-nav (~section-nav :items reference-nav-items
|
||||
:current (find-current reference-nav-items slug))
|
||||
:selected (or (find-current reference-nav-items slug) ""))
|
||||
:data (reference-data slug)
|
||||
:content (case slug
|
||||
"attributes" (~reference-attrs-content
|
||||
:req-table (~doc-attr-table-from-data :title "Request Attributes" :attrs req-attrs)
|
||||
:beh-table (~doc-attr-table-from-data :title "Behavior Attributes" :attrs beh-attrs)
|
||||
:uniq-table (~doc-attr-table-from-data :title "Unique to sx" :attrs uniq-attrs))
|
||||
"headers" (~reference-headers-content
|
||||
:req-table (~doc-headers-table-from-data :title "Request Headers" :headers req-headers)
|
||||
:resp-table (~doc-headers-table-from-data :title "Response Headers" :headers resp-headers))
|
||||
"events" (~reference-events-content
|
||||
:table (~doc-two-col-table-from-data
|
||||
:intro "sx fires custom DOM events at various points in the request lifecycle."
|
||||
:col1 "Event" :col2 "Description" :items events-list))
|
||||
"js-api" (~reference-js-api-content
|
||||
:table (~doc-two-col-table-from-data
|
||||
:intro "The client-side sx.js library exposes a public API for programmatic use."
|
||||
:col1 "Method" :col2 "Description" :items js-api-list))
|
||||
:else (~reference-attrs-content
|
||||
:req-table (~doc-attr-table-from-data :title "Request Attributes" :attrs req-attrs)
|
||||
:beh-table (~doc-attr-table-from-data :title "Behavior Attributes" :attrs beh-attrs)
|
||||
:uniq-table (~doc-attr-table-from-data :title "Unique to sx" :attrs uniq-attrs))))
|
||||
|
||||
(defpage reference-attr-detail
|
||||
:path "/reference/attributes/<slug>"
|
||||
@@ -70,9 +102,18 @@
|
||||
:section "Reference"
|
||||
:sub-label "Reference"
|
||||
:sub-href "/reference/"
|
||||
:sub-nav (reference-nav "Attributes")
|
||||
:sub-nav (~section-nav :items reference-nav-items :current "Attributes")
|
||||
:selected "Attributes")
|
||||
:content (reference-attr-detail slug))
|
||||
:data (attr-detail-data slug)
|
||||
:content (if attr-not-found
|
||||
(~reference-attr-not-found :slug slug)
|
||||
(~reference-attr-detail-content
|
||||
:title attr-title
|
||||
:description attr-description
|
||||
:demo attr-demo
|
||||
:example-code attr-example
|
||||
:handler-code attr-handler
|
||||
:wire-placeholder-id attr-wire-id)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Protocols section
|
||||
@@ -85,9 +126,9 @@
|
||||
:section "Protocols"
|
||||
:sub-label "Protocols"
|
||||
:sub-href "/protocols/wire-format"
|
||||
:sub-nav (protocols-nav "Wire Format")
|
||||
:sub-nav (~section-nav :items protocols-nav-items :current "Wire Format")
|
||||
:selected "Wire Format")
|
||||
:content (protocol-content "wire-format"))
|
||||
:content (~protocol-wire-format-content))
|
||||
|
||||
(defpage protocol-page
|
||||
:path "/protocols/<slug>"
|
||||
@@ -96,9 +137,17 @@
|
||||
:section "Protocols"
|
||||
:sub-label "Protocols"
|
||||
:sub-href "/protocols/wire-format"
|
||||
:sub-nav (protocols-nav (find-current PROTOCOLS_NAV slug))
|
||||
:selected (or (find-current PROTOCOLS_NAV slug) ""))
|
||||
:content (protocol-content slug))
|
||||
:sub-nav (~section-nav :items protocols-nav-items
|
||||
:current (find-current protocols-nav-items slug))
|
||||
:selected (or (find-current protocols-nav-items slug) ""))
|
||||
:content (case slug
|
||||
"wire-format" (~protocol-wire-format-content)
|
||||
"fragments" (~protocol-fragments-content)
|
||||
"resolver-io" (~protocol-resolver-io-content)
|
||||
"internal-services" (~protocol-internal-services-content)
|
||||
"activitypub" (~protocol-activitypub-content)
|
||||
"future" (~protocol-future-content)
|
||||
:else (~protocol-wire-format-content)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Examples section
|
||||
@@ -111,9 +160,9 @@
|
||||
:section "Examples"
|
||||
:sub-label "Examples"
|
||||
:sub-href "/examples/click-to-load"
|
||||
:sub-nav (examples-nav "Click to Load")
|
||||
:sub-nav (~section-nav :items examples-nav-items :current "Click to Load")
|
||||
:selected "Click to Load")
|
||||
:content (examples-content "click-to-load"))
|
||||
:content (~example-click-to-load))
|
||||
|
||||
(defpage examples-page
|
||||
:path "/examples/<slug>"
|
||||
@@ -122,9 +171,38 @@
|
||||
:section "Examples"
|
||||
:sub-label "Examples"
|
||||
:sub-href "/examples/click-to-load"
|
||||
:sub-nav (examples-nav (find-current EXAMPLES_NAV slug))
|
||||
:selected (or (find-current EXAMPLES_NAV slug) ""))
|
||||
:content (examples-content slug))
|
||||
:sub-nav (~section-nav :items examples-nav-items
|
||||
:current (find-current examples-nav-items slug))
|
||||
:selected (or (find-current examples-nav-items slug) ""))
|
||||
:content (case slug
|
||||
"click-to-load" (~example-click-to-load)
|
||||
"form-submission" (~example-form-submission)
|
||||
"polling" (~example-polling)
|
||||
"delete-row" (~example-delete-row)
|
||||
"inline-edit" (~example-inline-edit)
|
||||
"oob-swaps" (~example-oob-swaps)
|
||||
"lazy-loading" (~example-lazy-loading)
|
||||
"infinite-scroll" (~example-infinite-scroll)
|
||||
"progress-bar" (~example-progress-bar)
|
||||
"active-search" (~example-active-search)
|
||||
"inline-validation" (~example-inline-validation)
|
||||
"value-select" (~example-value-select)
|
||||
"reset-on-submit" (~example-reset-on-submit)
|
||||
"edit-row" (~example-edit-row)
|
||||
"bulk-update" (~example-bulk-update)
|
||||
"swap-positions" (~example-swap-positions)
|
||||
"select-filter" (~example-select-filter)
|
||||
"tabs" (~example-tabs)
|
||||
"animations" (~example-animations)
|
||||
"dialogs" (~example-dialogs)
|
||||
"keyboard-shortcuts" (~example-keyboard-shortcuts)
|
||||
"put-patch" (~example-put-patch)
|
||||
"json-encoding" (~example-json-encoding)
|
||||
"vals-and-headers" (~example-vals-and-headers)
|
||||
"loading-states" (~example-loading-states)
|
||||
"sync-replace" (~example-sync-replace)
|
||||
"retry" (~example-retry)
|
||||
:else (~example-click-to-load)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Essays section
|
||||
@@ -137,9 +215,9 @@
|
||||
:section "Essays"
|
||||
:sub-label "Essays"
|
||||
:sub-href "/essays/sx-sucks"
|
||||
:sub-nav (essays-nav "sx sucks")
|
||||
:sub-nav (~section-nav :items essays-nav-items :current "sx sucks")
|
||||
:selected "sx sucks")
|
||||
:content (essay-content "sx-sucks"))
|
||||
:content (~essay-sx-sucks))
|
||||
|
||||
(defpage essay-page
|
||||
:path "/essays/<slug>"
|
||||
@@ -148,6 +226,17 @@
|
||||
:section "Essays"
|
||||
:sub-label "Essays"
|
||||
:sub-href "/essays/sx-sucks"
|
||||
:sub-nav (essays-nav (find-current ESSAYS_NAV slug))
|
||||
:selected (or (find-current ESSAYS_NAV slug) ""))
|
||||
:content (essay-content slug))
|
||||
:sub-nav (~section-nav :items essays-nav-items
|
||||
:current (find-current essays-nav-items slug))
|
||||
:selected (or (find-current essays-nav-items slug) ""))
|
||||
:content (case slug
|
||||
"sx-sucks" (~essay-sx-sucks)
|
||||
"why-sexps" (~essay-why-sexps)
|
||||
"htmx-react-hybrid" (~essay-htmx-react-hybrid)
|
||||
"on-demand-css" (~essay-on-demand-css)
|
||||
"client-reactivity" (~essay-client-reactivity)
|
||||
"sx-native" (~essay-sx-native)
|
||||
"sx-manifesto" (~essay-sx-manifesto)
|
||||
"tail-call-optimization" (~essay-tail-call-optimization)
|
||||
"continuations" (~essay-continuations)
|
||||
:else (~essay-sx-sucks)))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,136 +1,135 @@
|
||||
"""Public partials and page helper registration for sx docs."""
|
||||
"""Page helper registration for sx docs.
|
||||
|
||||
All helpers return data values (dicts, lists) — no sx_call(), no SxExpr.
|
||||
Markup composition lives entirely in .sx files.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .essays import (
|
||||
_docs_content_sx, _reference_content_sx, _protocol_content_sx,
|
||||
_examples_content_sx, _essay_content_sx,
|
||||
_reference_index_sx, _reference_attr_detail_sx,
|
||||
)
|
||||
from .utils import _docs_nav_sx, _reference_nav_sx, _protocols_nav_sx, _examples_nav_sx, _essays_nav_sx
|
||||
from content.highlight import highlight
|
||||
|
||||
|
||||
def home_content_sx() -> str:
|
||||
"""Home page content as sx wire format."""
|
||||
hero_code = highlight('(div :class "p-4 bg-white rounded shadow"\n'
|
||||
' (h1 :class "text-2xl font-bold" "Hello")\n'
|
||||
' (button :sx-get "/api/data"\n'
|
||||
' :sx-target "#result"\n'
|
||||
' "Load data"))', "lisp")
|
||||
return (
|
||||
f'(section :id "main-panel"'
|
||||
f' :class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"'
|
||||
f' (div :id "main-content"'
|
||||
f' (~sx-hero {hero_code})'
|
||||
f' (~sx-philosophy)'
|
||||
f' (~sx-how-it-works)'
|
||||
f' (~sx-credits)))'
|
||||
)
|
||||
|
||||
|
||||
async def docs_content_partial_sx(slug: str) -> str:
|
||||
"""Docs content as sx wire format."""
|
||||
inner = await _docs_content_sx(slug)
|
||||
return (
|
||||
f'(section :id "main-panel"'
|
||||
f' :class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"'
|
||||
f' {inner})'
|
||||
)
|
||||
|
||||
|
||||
async def reference_content_partial_sx(slug: str) -> str:
|
||||
inner = await _reference_content_sx(slug)
|
||||
return (
|
||||
f'(section :id "main-panel"'
|
||||
f' :class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"'
|
||||
f' {inner})'
|
||||
)
|
||||
|
||||
|
||||
async def protocol_content_partial_sx(slug: str) -> str:
|
||||
inner = await _protocol_content_sx(slug)
|
||||
return (
|
||||
f'(section :id "main-panel"'
|
||||
f' :class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"'
|
||||
f' {inner})'
|
||||
)
|
||||
|
||||
|
||||
async def examples_content_partial_sx(slug: str) -> str:
|
||||
inner = await _examples_content_sx(slug)
|
||||
return (
|
||||
f'(section :id "main-panel"'
|
||||
f' :class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"'
|
||||
f' {inner})'
|
||||
)
|
||||
|
||||
|
||||
async def essay_content_partial_sx(slug: str) -> str:
|
||||
inner = await _essay_content_sx(slug)
|
||||
return (
|
||||
f'(section :id "main-panel"'
|
||||
f' :class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport"'
|
||||
f' {inner})'
|
||||
)
|
||||
|
||||
|
||||
def _register_sx_helpers() -> None:
|
||||
"""Register Python content builder functions as page helpers."""
|
||||
"""Register Python data helpers as page helpers."""
|
||||
from shared.sx.pages import register_page_helpers
|
||||
from content.highlight import highlight as _highlight
|
||||
from content.pages import (
|
||||
DOCS_NAV, REFERENCE_NAV, PROTOCOLS_NAV,
|
||||
EXAMPLES_NAV, ESSAYS_NAV,
|
||||
)
|
||||
|
||||
def _find_current(nav_list, slug, match_fn=None):
|
||||
"""Find the current nav label for a slug."""
|
||||
if match_fn:
|
||||
return match_fn(nav_list, slug)
|
||||
for label, href in nav_list:
|
||||
if href.endswith(slug):
|
||||
return label
|
||||
return None
|
||||
|
||||
def _home_content():
|
||||
"""Build home page content (uses highlight for hero code block)."""
|
||||
hero_code = _highlight(
|
||||
'(div :class "p-4 bg-white rounded shadow"\n'
|
||||
' (h1 :class "text-2xl font-bold" "Hello")\n'
|
||||
' (button :sx-get "/api/data"\n'
|
||||
' :sx-target "#result"\n'
|
||||
' "Load data"))', "lisp")
|
||||
return (
|
||||
f'(div :id "main-content"'
|
||||
f' (~sx-hero {hero_code})'
|
||||
f' (~sx-philosophy)'
|
||||
f' (~sx-how-it-works)'
|
||||
f' (~sx-credits))'
|
||||
)
|
||||
|
||||
register_page_helpers("sx", {
|
||||
# Content builders
|
||||
"home-content": _home_content,
|
||||
"docs-content": _docs_content_sx,
|
||||
"reference-content": _reference_content_sx,
|
||||
"reference-index-content": _reference_index_sx,
|
||||
"reference-attr-detail": _reference_attr_detail_sx,
|
||||
"protocol-content": _protocol_content_sx,
|
||||
"examples-content": _examples_content_sx,
|
||||
"essay-content": _essay_content_sx,
|
||||
"highlight": _highlight,
|
||||
# Nav builders
|
||||
"docs-nav": _docs_nav_sx,
|
||||
"reference-nav": _reference_nav_sx,
|
||||
"protocols-nav": _protocols_nav_sx,
|
||||
"examples-nav": _examples_nav_sx,
|
||||
"essays-nav": _essays_nav_sx,
|
||||
# Nav data (for current label lookup)
|
||||
"DOCS_NAV": DOCS_NAV,
|
||||
"REFERENCE_NAV": REFERENCE_NAV,
|
||||
"PROTOCOLS_NAV": PROTOCOLS_NAV,
|
||||
"EXAMPLES_NAV": EXAMPLES_NAV,
|
||||
"ESSAYS_NAV": ESSAYS_NAV,
|
||||
# Utility
|
||||
"find-current": _find_current,
|
||||
"primitives-data": _primitives_data,
|
||||
"reference-data": _reference_data,
|
||||
"attr-detail-data": _attr_detail_data,
|
||||
})
|
||||
|
||||
|
||||
def _primitives_data() -> dict:
|
||||
"""Return the PRIMITIVES dict for the primitives docs page."""
|
||||
from content.pages import PRIMITIVES
|
||||
return PRIMITIVES
|
||||
|
||||
|
||||
def _reference_data(slug: str) -> dict:
|
||||
"""Return reference table data for a given slug.
|
||||
|
||||
Returns a dict whose keys become SX env bindings:
|
||||
- attributes: req-attrs, beh-attrs, uniq-attrs
|
||||
- headers: req-headers, resp-headers
|
||||
- events: events-list
|
||||
- js-api: js-api-list
|
||||
"""
|
||||
from content.pages import (
|
||||
REQUEST_ATTRS, BEHAVIOR_ATTRS, SX_UNIQUE_ATTRS,
|
||||
REQUEST_HEADERS, RESPONSE_HEADERS,
|
||||
EVENTS, JS_API, ATTR_DETAILS,
|
||||
)
|
||||
|
||||
if slug == "attributes":
|
||||
return {
|
||||
"req-attrs": [
|
||||
{"name": a, "desc": d, "exists": e,
|
||||
"href": f"/reference/attributes/{a}" if e and a in ATTR_DETAILS else None}
|
||||
for a, d, e in REQUEST_ATTRS
|
||||
],
|
||||
"beh-attrs": [
|
||||
{"name": a, "desc": d, "exists": e,
|
||||
"href": f"/reference/attributes/{a}" if e and a in ATTR_DETAILS else None}
|
||||
for a, d, e in BEHAVIOR_ATTRS
|
||||
],
|
||||
"uniq-attrs": [
|
||||
{"name": a, "desc": d, "exists": e,
|
||||
"href": f"/reference/attributes/{a}" if e and a in ATTR_DETAILS else None}
|
||||
for a, d, e in SX_UNIQUE_ATTRS
|
||||
],
|
||||
}
|
||||
elif slug == "headers":
|
||||
return {
|
||||
"req-headers": [
|
||||
{"name": n, "value": v, "desc": d}
|
||||
for n, v, d in REQUEST_HEADERS
|
||||
],
|
||||
"resp-headers": [
|
||||
{"name": n, "value": v, "desc": d}
|
||||
for n, v, d in RESPONSE_HEADERS
|
||||
],
|
||||
}
|
||||
elif slug == "events":
|
||||
return {
|
||||
"events-list": [
|
||||
{"name": n, "desc": d}
|
||||
for n, d in EVENTS
|
||||
],
|
||||
}
|
||||
elif slug == "js-api":
|
||||
return {
|
||||
"js-api-list": [
|
||||
{"name": n, "desc": d}
|
||||
for n, d in JS_API
|
||||
],
|
||||
}
|
||||
# Default — return attrs data for fallback
|
||||
return {
|
||||
"req-attrs": [
|
||||
{"name": a, "desc": d, "exists": e,
|
||||
"href": f"/reference/attributes/{a}" if e and a in ATTR_DETAILS else None}
|
||||
for a, d, e in REQUEST_ATTRS
|
||||
],
|
||||
"beh-attrs": [
|
||||
{"name": a, "desc": d, "exists": e,
|
||||
"href": f"/reference/attributes/{a}" if e and a in ATTR_DETAILS else None}
|
||||
for a, d, e in BEHAVIOR_ATTRS
|
||||
],
|
||||
"uniq-attrs": [
|
||||
{"name": a, "desc": d, "exists": e,
|
||||
"href": f"/reference/attributes/{a}" if e and a in ATTR_DETAILS else None}
|
||||
for a, d, e in SX_UNIQUE_ATTRS
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _attr_detail_data(slug: str) -> dict:
|
||||
"""Return attribute detail data for a specific attribute slug.
|
||||
|
||||
Returns a dict whose keys become SX env bindings:
|
||||
- attr-title, attr-description, attr-example, attr-handler
|
||||
- attr-demo (component call or None)
|
||||
- attr-wire-id (wire placeholder id or None)
|
||||
- attr-not-found (truthy if not found)
|
||||
"""
|
||||
from content.pages import ATTR_DETAILS
|
||||
from shared.sx.helpers import SxExpr
|
||||
|
||||
detail = ATTR_DETAILS.get(slug)
|
||||
if not detail:
|
||||
return {"attr-not-found": True}
|
||||
|
||||
demo_name = detail.get("demo")
|
||||
wire_id = None
|
||||
if "handler" in detail:
|
||||
wire_id = f"ref-wire-{slug.replace(':', '-').replace('*', 'star')}"
|
||||
|
||||
return {
|
||||
"attr-not-found": None,
|
||||
"attr-title": slug,
|
||||
"attr-description": detail["description"],
|
||||
"attr-example": detail["example"],
|
||||
"attr-handler": detail.get("handler"),
|
||||
"attr-demo": SxExpr(f"(~{demo_name})") if demo_name else None,
|
||||
"attr-wire-id": wire_id,
|
||||
}
|
||||
|
||||
@@ -1,112 +1,11 @@
|
||||
"""Layout registration and header/mobile functions for sx docs."""
|
||||
"""SX docs layout registration — all layouts delegate to .sx defcomps."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .utils import _main_nav_sx, _sx_header_sx, _sub_row_sx
|
||||
|
||||
def _register_sx_layouts() -> None:
|
||||
"""Register the sx docs layout presets."""
|
||||
from shared.sx.layouts import register_custom_layout
|
||||
from shared.sx.layouts import register_sx_layout
|
||||
|
||||
register_custom_layout("sx", _sx_full_headers, _sx_oob_headers, _sx_mobile)
|
||||
register_custom_layout("sx-section", _sx_section_full_headers, _sx_section_oob_headers, _sx_section_mobile)
|
||||
|
||||
|
||||
async def _sx_full_headers(ctx: dict, **kw: Any) -> str:
|
||||
"""Full headers for sx home page: root + sx menu row."""
|
||||
from shared.sx.helpers import render_to_sx_with_env
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
main_nav = _main_nav_sx(kw.get("section"))
|
||||
sx_row = _sx_header_sx(main_nav)
|
||||
return await render_to_sx_with_env("sx-layout-full", {},
|
||||
sx_row=SxExpr(sx_row))
|
||||
|
||||
|
||||
async def _sx_oob_headers(ctx: dict, **kw: Any) -> str:
|
||||
"""OOB headers for sx home page."""
|
||||
from shared.sx.helpers import render_to_sx_with_env, oob_header_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
main_nav = _main_nav_sx(kw.get("section"))
|
||||
sx_row = _sx_header_sx(main_nav)
|
||||
rows = await render_to_sx_with_env("sx-layout-full", {},
|
||||
sx_row=SxExpr(sx_row))
|
||||
return await oob_header_sx("root-header-child", "sx-header-child", rows)
|
||||
|
||||
|
||||
async def _sx_section_full_headers(ctx: dict, **kw: Any) -> str:
|
||||
"""Full headers for sx section pages: root + sx row + sub row."""
|
||||
from shared.sx.helpers import render_to_sx_with_env
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
section = kw.get("section", "")
|
||||
sub_label = kw.get("sub_label", section)
|
||||
sub_href = kw.get("sub_href", "/")
|
||||
sub_nav = kw.get("sub_nav", "")
|
||||
selected = kw.get("selected", "")
|
||||
|
||||
main_nav = _main_nav_sx(section)
|
||||
sub_row = _sub_row_sx(sub_label, sub_href, sub_nav, selected)
|
||||
sx_row = _sx_header_sx(main_nav, child=sub_row)
|
||||
return await render_to_sx_with_env("sx-section-layout-full", {},
|
||||
sx_row=SxExpr(sx_row))
|
||||
|
||||
|
||||
async def _sx_section_oob_headers(ctx: dict, **kw: Any) -> str:
|
||||
"""OOB headers for sx section pages."""
|
||||
from shared.sx.helpers import render_to_sx_with_env, oob_header_sx
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
section = kw.get("section", "")
|
||||
sub_label = kw.get("sub_label", section)
|
||||
sub_href = kw.get("sub_href", "/")
|
||||
sub_nav = kw.get("sub_nav", "")
|
||||
selected = kw.get("selected", "")
|
||||
|
||||
main_nav = _main_nav_sx(section)
|
||||
sub_row = _sub_row_sx(sub_label, sub_href, sub_nav, selected)
|
||||
sx_row = _sx_header_sx(main_nav, child=sub_row)
|
||||
rows = await render_to_sx_with_env("sx-section-layout-full", {},
|
||||
sx_row=SxExpr(sx_row))
|
||||
return await oob_header_sx("root-header-child", "sx-header-child", rows)
|
||||
|
||||
|
||||
async def _sx_mobile(ctx: dict, **kw: Any) -> str:
|
||||
"""Mobile menu for sx home page: main nav + root."""
|
||||
from shared.sx.helpers import (
|
||||
mobile_menu_sx, mobile_root_nav_sx, sx_call, SxExpr,
|
||||
)
|
||||
|
||||
main_nav = _main_nav_sx(kw.get("section"))
|
||||
return mobile_menu_sx(
|
||||
sx_call("mobile-menu-section",
|
||||
label="sx", href="/", level=1, colour="violet",
|
||||
items=SxExpr(main_nav)),
|
||||
await mobile_root_nav_sx(ctx),
|
||||
)
|
||||
|
||||
|
||||
async def _sx_section_mobile(ctx: dict, **kw: Any) -> str:
|
||||
"""Mobile menu for sx section pages: sub nav + main nav + root."""
|
||||
from shared.sx.helpers import (
|
||||
mobile_menu_sx, mobile_root_nav_sx, sx_call, SxExpr,
|
||||
)
|
||||
|
||||
section = kw.get("section", "")
|
||||
sub_label = kw.get("sub_label", section)
|
||||
sub_href = kw.get("sub_href", "/")
|
||||
sub_nav = kw.get("sub_nav", "")
|
||||
main_nav = _main_nav_sx(section)
|
||||
|
||||
parts = []
|
||||
if sub_nav:
|
||||
parts.append(sx_call("mobile-menu-section",
|
||||
label=sub_label, href=sub_href, level=2, colour="violet",
|
||||
items=SxExpr(sub_nav)))
|
||||
parts.append(sx_call("mobile-menu-section",
|
||||
label="sx", href="/", level=1, colour="violet",
|
||||
items=SxExpr(main_nav)))
|
||||
parts.append(await mobile_root_nav_sx(ctx))
|
||||
return mobile_menu_sx(*parts)
|
||||
register_sx_layout("sx", "sx-layout-full", "sx-layout-oob", "sx-layout-mobile")
|
||||
register_sx_layout("sx-section", "sx-section-layout-full",
|
||||
"sx-section-layout-oob", "sx-section-layout-mobile")
|
||||
|
||||
@@ -1,26 +1,6 @@
|
||||
"""Public render/utility functions called from bp routes."""
|
||||
from __future__ import annotations
|
||||
|
||||
from content.highlight import highlight
|
||||
|
||||
|
||||
def _code(code: str, language: str = "lisp") -> str:
|
||||
"""Build a ~doc-code component with highlighted content."""
|
||||
highlighted = highlight(code, language)
|
||||
return f'(~doc-code :code {highlighted})'
|
||||
|
||||
|
||||
def _example_code(code: str, language: str = "lisp") -> str:
|
||||
"""Build an ~example-source component with highlighted content."""
|
||||
highlighted = highlight(code, language)
|
||||
return f'(~example-source :code {highlighted})'
|
||||
|
||||
|
||||
def _placeholder(div_id: str) -> str:
|
||||
"""Empty placeholder that will be filled by OOB swap on interaction."""
|
||||
from shared.sx.helpers import sx_call
|
||||
return sx_call("doc-placeholder", id=div_id)
|
||||
|
||||
|
||||
def _component_source_text(*names: str) -> str:
|
||||
"""Get defcomp source text for named components."""
|
||||
@@ -47,12 +27,6 @@ def _oob_code(target_id: str, text: str) -> str:
|
||||
return sx_call("doc-oob-code", target_id=target_id, text=text)
|
||||
|
||||
|
||||
def _clear_components_btn() -> str:
|
||||
"""Button that clears the client-side component cache (localStorage + in-memory)."""
|
||||
from shared.sx.helpers import sx_call
|
||||
return sx_call("doc-clear-cache-btn")
|
||||
|
||||
|
||||
def _full_wire_text(sx_src: str, *comp_names: str) -> str:
|
||||
"""Build the full wire response text showing component defs + CSS note + sx source.
|
||||
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
"""Shared utility functions for sx docs pages."""
|
||||
from __future__ import annotations
|
||||
|
||||
from shared.sx.helpers import (
|
||||
sx_call, SxExpr,
|
||||
)
|
||||
|
||||
|
||||
def _nav_items_sx(items: list[tuple[str, str]], current: str | None = None) -> str:
|
||||
"""Build nav link items as sx."""
|
||||
parts = []
|
||||
for label, href in items:
|
||||
parts.append(sx_call("nav-link",
|
||||
href=href, label=label,
|
||||
is_selected="true" if current == label else None,
|
||||
select_colours="aria-selected:bg-violet-200 aria-selected:text-violet-900",
|
||||
))
|
||||
return "(<> " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
def _doc_nav_sx(items: list[tuple[str, str]], current: str) -> str:
|
||||
"""Build the in-page doc navigation pills."""
|
||||
items_sx = " ".join(
|
||||
f'(list "{label}" "{href}")'
|
||||
for label, href in items
|
||||
)
|
||||
return sx_call("doc-nav", items=SxExpr(f"(list {items_sx})"), current=current)
|
||||
|
||||
|
||||
def _attr_table_sx(title: str, attrs: list[tuple[str, str, bool]]) -> str:
|
||||
"""Build an attribute reference table."""
|
||||
from content.pages import ATTR_DETAILS
|
||||
rows = []
|
||||
for attr, desc, exists in attrs:
|
||||
href = f"/reference/attributes/{attr}" if exists and attr in ATTR_DETAILS else None
|
||||
rows.append(sx_call("doc-attr-row", attr=attr, description=desc,
|
||||
exists="true" if exists else None,
|
||||
href=href))
|
||||
rows_sx = "(<> " + " ".join(rows) + ")"
|
||||
return sx_call("doc-attr-table", title=title, rows=SxExpr(rows_sx))
|
||||
|
||||
|
||||
def _headers_table_sx(title: str, headers: list[tuple[str, str, str]]) -> str:
|
||||
"""Build a headers reference table."""
|
||||
rows = []
|
||||
for name, value, desc in headers:
|
||||
rows.append(sx_call("doc-headers-row",
|
||||
name=name, value=value, description=desc))
|
||||
rows_sx = "(<> " + " ".join(rows) + ")"
|
||||
return sx_call("doc-headers-table", title=title, rows=SxExpr(rows_sx))
|
||||
|
||||
|
||||
def _primitives_section_sx() -> str:
|
||||
"""Build the primitives section."""
|
||||
from content.pages import PRIMITIVES
|
||||
parts = []
|
||||
for category, prims in PRIMITIVES.items():
|
||||
prims_sx = " ".join(f'"{p}"' for p in prims)
|
||||
parts.append(sx_call("doc-primitives-table",
|
||||
category=category,
|
||||
primitives=SxExpr(f"(list {prims_sx})")))
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _sx_header_sx(nav: str | None = None, *, child: str | None = None) -> str:
|
||||
"""Build the sx docs menu-row."""
|
||||
label_sx = sx_call("sx-docs-label")
|
||||
return sx_call("menu-row-sx",
|
||||
id="sx-row", level=1, colour="violet",
|
||||
link_href="/", link_label="sx",
|
||||
link_label_content=SxExpr(label_sx),
|
||||
nav=SxExpr(nav) if nav else None,
|
||||
child_id="sx-header-child",
|
||||
child=SxExpr(child) if child else None,
|
||||
)
|
||||
|
||||
|
||||
def _docs_nav_sx(current: str | None = None) -> str:
|
||||
from content.pages import DOCS_NAV
|
||||
return _nav_items_sx(DOCS_NAV, current)
|
||||
|
||||
|
||||
def _reference_nav_sx(current: str | None = None) -> str:
|
||||
from content.pages import REFERENCE_NAV
|
||||
return _nav_items_sx(REFERENCE_NAV, current)
|
||||
|
||||
|
||||
def _protocols_nav_sx(current: str | None = None) -> str:
|
||||
from content.pages import PROTOCOLS_NAV
|
||||
return _nav_items_sx(PROTOCOLS_NAV, current)
|
||||
|
||||
|
||||
def _examples_nav_sx(current: str | None = None) -> str:
|
||||
from content.pages import EXAMPLES_NAV
|
||||
return _nav_items_sx(EXAMPLES_NAV, current)
|
||||
|
||||
|
||||
def _essays_nav_sx(current: str | None = None) -> str:
|
||||
from content.pages import ESSAYS_NAV
|
||||
return _nav_items_sx(ESSAYS_NAV, current)
|
||||
|
||||
|
||||
def _main_nav_sx(current_section: str | None = None) -> str:
|
||||
from content.pages import MAIN_NAV
|
||||
return _nav_items_sx(MAIN_NAV, current_section)
|
||||
|
||||
|
||||
def _sub_row_sx(sub_label: str, sub_href: str, sub_nav: str,
|
||||
selected: str = "") -> str:
|
||||
"""Build the level-2 sub-section menu-row."""
|
||||
return sx_call("menu-row-sx",
|
||||
id="sx-sub-row", level=2, colour="violet",
|
||||
link_href=sub_href, link_label=sub_label,
|
||||
selected=selected or None,
|
||||
nav=SxExpr(sub_nav),
|
||||
)
|
||||
@@ -272,7 +272,7 @@
|
||||
(defcomp ~ref-headers-demo ()
|
||||
(div :class "space-y-3"
|
||||
(button :sx-get "/reference/api/echo-headers"
|
||||
:sx-headers "{\"X-Custom-Token\": \"abc123\", \"X-Request-Source\": \"demo\"}"
|
||||
:sx-headers {:X-Custom-Token "abc123" :X-Request-Source "demo"}
|
||||
:sx-target "#ref-headers-result"
|
||||
:sx-swap "innerHTML"
|
||||
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
|
||||
|
||||
@@ -5,7 +5,7 @@ import os
|
||||
from datetime import datetime
|
||||
|
||||
from shared.sx.jinja_bridge import load_service_components
|
||||
from shared.sx.helpers import sx_call, SxExpr, render_to_sx_with_env, full_page_sx
|
||||
from shared.sx.helpers import sx_call, render_to_sx_with_env, full_page_sx
|
||||
|
||||
# Load test-specific .sx components at import time
|
||||
load_service_components(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
||||
@@ -98,7 +98,7 @@ async def render_dashboard_page_sx(ctx: dict, result: dict | None,
|
||||
|
||||
inner = sx_call("test-results-partial",
|
||||
summary_data=summary_data, sections=sections, has_failures=has_failures)
|
||||
content = sx_call("test-results-wrap", running=running, inner=SxExpr(inner))
|
||||
content = sx_call("test-results-wrap", running=running, inner=inner)
|
||||
hdr = await render_to_sx_with_env("test-layout-full", {},
|
||||
services=_service_list(),
|
||||
active_service=active_service,
|
||||
@@ -126,7 +126,7 @@ async def render_results_partial_sx(result: dict | None, running: bool,
|
||||
|
||||
inner = sx_call("test-results-partial",
|
||||
summary_data=summary_data, sections=sections, has_failures=has_failures)
|
||||
return sx_call("test-results-wrap", running=running, inner=SxExpr(inner))
|
||||
return sx_call("test-results-wrap", running=running, inner=inner)
|
||||
|
||||
|
||||
async def render_test_detail_page_sx(ctx: dict, test: dict) -> str:
|
||||
|
||||
Reference in New Issue
Block a user