diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 4d38535..1eec7f5 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -84,13 +84,25 @@ jobs:
fi
done
- # Deploy swarm stack only on main branch
+ # Deploy swarm stacks only on main branch
if [ '${{ github.ref_name }}' = 'main' ]; then
source .env
docker stack deploy -c docker-compose.yml rose-ash
echo 'Waiting for swarm services to update...'
sleep 10
docker stack services rose-ash
+
+ # Deploy sx-web standalone stack (sx-web.org)
+ SX_REBUILT=false
+ if [ \"\$REBUILD_ALL\" = true ] || echo \"\$CHANGED\" | grep -q '^sx/'; then
+ SX_REBUILT=true
+ fi
+ if [ \"\$SX_REBUILT\" = true ]; then
+ echo 'Deploying sx-web stack (sx-web.org)...'
+ docker stack deploy -c /root/sx-web/docker-compose.yml sx-web
+ sleep 5
+ docker stack services sx-web
+ fi
else
echo 'Skipping swarm deploy (branch: ${{ github.ref_name }})'
fi
diff --git a/CLAUDE.md b/CLAUDE.md
index 7fb92ec..297672f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -52,6 +52,65 @@ artdag/
test/ # Integration & e2e tests
```
+## SX Language — Canonical Reference
+
+The SX language is defined by a self-hosting specification in `shared/sx/ref/`. **Read these files for authoritative SX semantics** — they supersede any implementation detail in `sx.js` or Python evaluators.
+
+### Specification files
+
+- **`shared/sx/ref/eval.sx`** — Core evaluator: types, trampoline (TCO), `eval-expr` dispatch, special forms (`if`, `when`, `cond`, `case`, `let`, `and`, `or`, `lambda`, `define`, `defcomp`, `defmacro`, `quasiquote`), higher-order forms (`map`, `filter`, `reduce`, `some`, `every?`, `for-each`), macro expansion, function/lambda/component calling.
+- **`shared/sx/ref/parser.sx`** — Tokenizer and parser: grammar, string escapes, dict literals `{:key val}`, quote sugar (`` ` ``, `,`, `,@`), serializer.
+- **`shared/sx/ref/primitives.sx`** — All ~80 built-in pure functions: arithmetic, comparison, predicates, string ops, collection ops, dict ops, format helpers, CSSX style primitives.
+- **`shared/sx/ref/render.sx`** — Three rendering modes: `render-to-html` (server HTML), `render-to-sx`/`aser` (SX wire format for client), `render-to-dom` (browser). HTML tag registry, void elements, boolean attrs.
+- **`shared/sx/ref/bootstrap_js.py`** — Transpiler: reads the `.sx` spec files and emits `sx-ref.js`.
+
+### Type system
+
+```
+number, string, boolean, nil, symbol, keyword, list, dict,
+lambda, component, macro, thunk (TCO deferred eval)
+```
+
+### Evaluation rules (from eval.sx)
+
+1. **Literals** (number, string, boolean, nil) — pass through
+2. **Symbols** — look up in env, then primitives, then `true`/`false`/`nil`, else error
+3. **Keywords** — evaluate to their string name
+4. **Dicts** — evaluate all values recursively
+5. **Lists** — dispatch on head:
+ - Special forms (`if`, `when`, `cond`, `case`, `let`, `lambda`, `define`, `defcomp`, `defmacro`, `quote`, `quasiquote`, `begin`/`do`, `set!`, `->`)
+ - Higher-order forms (`map`, `filter`, `reduce`, `some`, `every?`, `for-each`, `map-indexed`)
+ - Macros — expand then re-evaluate
+ - Function calls — evaluate head and args, then: native callable → `apply`, lambda → bind params + TCO thunk, component → parse keyword args + bind params + TCO thunk
+
+### Component calling convention
+
+```lisp
+(defcomp ~card (&key title subtitle &rest children)
+ (div :class "card"
+ (h2 title)
+ (when subtitle (p subtitle))
+ children))
+```
+
+- `&key` params are keyword arguments: `(~card :title "Hi" :subtitle "Sub")`
+- `&rest children` captures positional args as `children`
+- Component body evaluated in merged env: `closure + caller-env + bound-params`
+
+### Rendering modes (from render.sx)
+
+| Mode | Function | Expands components? | Output |
+|------|----------|-------------------|--------|
+| HTML | `render-to-html` | Yes (recursive) | HTML string |
+| SX wire | `aser` | No — serializes `(~name ...)` | SX source text |
+| DOM | `render-to-dom` | Yes (recursive) | DOM nodes |
+
+The `aser` (async-serialize) mode evaluates control flow and function calls but serializes HTML tags and component calls as SX source — the client renders them. This is the wire format for HTMX-like responses.
+
+### Platform interface
+
+Each target (JS, Python) must provide: type inspection (`type-of`), constructors (`make-lambda`, `make-component`, `make-macro`, `make-thunk`), accessors, environment operations (`env-has?`, `env-get`, `env-set!`, `env-extend`, `env-merge`), and DOM/HTML rendering primitives.
+
## Tech Stack
**Web platform:** Python 3.11+, Quart (async Flask), SQLAlchemy (asyncpg), Jinja2, HTMX, PostgreSQL, Redis, Docker Swarm, Hypercorn.
@@ -110,11 +169,11 @@ cd artdag/l1 && mypy app/types.py app/routers/recipes.py tests/
### SX Rendering Pipeline
-The SX system renders component trees defined in s-expressions. The same AST can be evaluated in different modes depending on where the server/client rendering boundary is drawn:
+The SX system renders component trees defined in s-expressions. Canonical semantics are in `shared/sx/ref/` (see "SX Language" section above). The same AST can be evaluated in different modes depending on where the server/client rendering boundary is drawn:
-- `render_to_html(name, **kw)` — server-side, produces HTML. Used by route handlers returning full HTML.
-- `render_to_sx(name, **kw)` — server-side, produces SX wire format. Component calls stay **unexpanded** (serialized for client-side rendering by sx.js).
-- `render_to_sx_with_env(name, env, **kw)` — server-side, **expands the top-level component** then serializes children as SX wire format. Used by layout components that need Python context (auth state, fragments, URLs) resolved server-side.
+- `render_to_html(name, **kw)` — server-side, produces HTML. Maps to `render-to-html` in the spec.
+- `render_to_sx(name, **kw)` — server-side, produces SX wire format. Maps to `aser` in the spec. Component calls stay **unexpanded**.
+- `render_to_sx_with_env(name, env, **kw)` — server-side, **expands known components** then serializes as SX wire format. Used by layout components that need Python context.
- `sx_page(ctx, page_sx)` — produces the full HTML shell (`...`) with component definitions, CSS, and page SX inlined for client-side boot.
See the docstring in `shared/sx/async_eval.py` for the full evaluation modes table.
diff --git a/account/bp/account/routes.py b/account/bp/account/routes.py
index 9129c3c..f300d9e 100644
--- a/account/bp/account/routes.py
+++ b/account/bp/account/routes.py
@@ -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,
diff --git a/account/sx/newsletters.sx b/account/sx/newsletters.sx
index 4b0626b..b051d2a 100644
--- a/account/sx/newsletters.sx
+++ b/account/sx/newsletters.sx
@@ -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
diff --git a/blog/bp/post/admin/routes.py b/blog/bp/post/admin/routes.py
index 5ae0db0..4cbaf70 100644
--- a/blog/bp/post/admin/routes.py
+++ b/blog/bp/post/admin/routes.py
@@ -10,11 +10,18 @@ from quart import (
url_for,
)
from shared.browser.app.authz import require_admin, require_post_author
+from markupsafe import escape
from shared.sx.helpers import sx_response, sx_call
from shared.sx.parser import SxExpr, serialize as sx_serialize
from shared.utils import host_url
+def _raw_html_sx(html: str) -> str:
+ """Wrap raw HTML in (raw! "...") so it's valid inside sx source."""
+ if not html:
+ return ""
+ return "(raw! " + sx_serialize(html) + ")"
+
def _post_to_edit_dict(post) -> dict:
"""Convert an ORM Post to a dict matching the shape templates expect.
@@ -89,58 +96,95 @@ def _render_calendar_view(
prev_year, next_year, month_entries, associated_entry_ids,
post_slug: str,
) -> str:
- """Build calendar month grid via ~blog-calendar-view defcomp."""
+ """Build calendar month grid HTML."""
from quart import url_for as qurl
from shared.browser.app.csrf import generate_csrf_token
+ esc = escape
- cal_id = calendar.id
csrf = generate_csrf_token()
+ cal_id = calendar.id
def cal_url(y, m):
- return host_url(qurl("blog.post.admin.calendar_view",
- slug=post_slug, calendar_id=cal_id, year=y, month=m))
+ return esc(host_url(qurl("blog.post.admin.calendar_view", slug=post_slug, calendar_id=cal_id, year=y, month=m)))
- # Flatten weeks into day dicts with pre-computed entries per day
- days = []
+ cur_url = cal_url(year, month)
+ toggle_url_fn = lambda eid: esc(host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=eid)))
+
+ nav = (
+ f''
+ f''
+ )
+
+ wd_cells = "".join(f'
{esc(wd)}
' for wd in weekday_names)
+ wd_row = f'{wd_cells}
'
+
+ cells: list[str] = []
for week in weeks:
for day in week:
+ extra_cls = " bg-stone-50 text-stone-400" if not day.in_month else ""
day_date = day.date
- day_entries = []
+
+ entry_btns: list[str] = []
for e in month_entries:
e_start = getattr(e, "start_at", None)
if not e_start or e_start.date() != day_date:
continue
e_id = getattr(e, "id", None)
- toggle_url = host_url(qurl("blog.post.admin.toggle_entry",
- slug=post_slug, entry_id=e_id))
- day_entries.append({
- "name": str(getattr(e, "name", "")),
- "toggle_url": toggle_url,
- "is_associated": e_id in associated_entry_ids,
- })
- # Wrap nested entries list as SxExpr so it serializes as (list ...)
- entries_sx = SxExpr("(list " + " ".join(
- sx_serialize(e) for e in day_entries
- ) + ")") if day_entries else None
- days.append({
- "day": day_date.day,
- "in_month": day.in_month,
- "entries": entries_sx,
- })
+ e_name = esc(getattr(e, "name", ""))
+ t_url = toggle_url_fn(e_id)
+ hx_hdrs = '{:X-CSRFToken "' + csrf + '"}'
- return sx_call("blog-calendar-view",
- cal_id=str(cal_id),
- year=str(year),
- month_name=month_name,
- current_url=cal_url(year, month),
- prev_month_url=cal_url(prev_month_year, prev_month),
- prev_year_url=cal_url(prev_year, month),
- next_month_url=cal_url(next_month_year, next_month),
- next_year_url=cal_url(next_year, month),
- weekday_names=list(weekday_names),
- days=days,
- csrf=csrf,
+ if e_id in associated_entry_ids:
+ entry_btns.append(
+ f''
+ f'{e_name}'
+ f'
'
+ )
+ else:
+ entry_btns.append(
+ f''
+ )
+
+ entries_html = '' + "".join(entry_btns) + '
' if entry_btns else ''
+ cells.append(
+ f''
+ )
+
+ grid = f'{"".join(cells)}
'
+
+ html = (
+ f''
+ f'{nav}'
+ f'
{wd_row}{grid}
'
+ f'
'
)
+ return _raw_html_sx(html)
def _render_associated_entries(all_calendars, associated_entry_ids, post_slug: str) -> str:
@@ -157,15 +201,37 @@ def _render_associated_entries(all_calendars, associated_entry_ids, post_slug: s
def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> str:
- """Render the OOB nav entries swap via ~blog-nav-entries-oob defcomp."""
+ """Render the OOB nav entries swap."""
entries_list = []
if associated_entries and hasattr(associated_entries, "entries"):
entries_list = associated_entries.entries or []
+ has_items = bool(entries_list or calendars)
+
+ if not has_items:
+ return sx_call("blog-nav-entries-empty")
+
+ select_colours = (
+ "[.hover-capable_&]:hover:bg-yellow-300"
+ " aria-selected:bg-stone-500 aria-selected:text-white"
+ " [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500"
+ )
+ nav_cls = (
+ f"justify-center cursor-pointer flex flex-row items-center gap-2"
+ f" rounded bg-stone-200 text-black {select_colours} p-2"
+ )
+
post_slug = post.get("slug", "")
- # Extract entry data as list of dicts
- entry_data = []
+ scroll_hs = (
+ "on load or scroll"
+ " if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth"
+ " remove .hidden from .entries-nav-arrow add .flex to .entries-nav-arrow"
+ " else add .hidden to .entries-nav-arrow remove .flex from .entries-nav-arrow end"
+ )
+
+ item_parts = []
+
for entry in entries_list:
e_name = getattr(entry, "name", "")
e_start = getattr(entry, "start_at", None)
@@ -173,7 +239,7 @@ def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> str:
cal_slug = getattr(entry, "calendar_slug", "")
if e_start:
- href = (
+ entry_path = (
f"/{post_slug}/{cal_slug}/"
f"{e_start.year}/{e_start.month}/{e_start.day}"
f"/entries/{getattr(entry, 'id', '')}/"
@@ -182,19 +248,32 @@ def _render_nav_entries_oob(associated_entries, calendars, post: dict) -> str:
if e_end:
date_str += f" \u2013 {e_end.strftime('%H:%M')}"
else:
- href = f"/{post_slug}/{cal_slug}/"
+ entry_path = f"/{post_slug}/{cal_slug}/"
date_str = ""
- entry_data.append({"name": e_name, "href": href, "date_str": date_str})
+ item_parts.append(sx_call("calendar-entry-nav",
+ href=entry_path, nav_class=nav_cls, name=e_name, date_str=date_str,
+ ))
- # Extract calendar data as list of dicts
- cal_data = []
for calendar in (calendars or []):
cal_name = getattr(calendar, "name", "")
cal_slug = getattr(calendar, "slug", "")
- cal_data.append({"name": cal_name, "href": f"/{post_slug}/{cal_slug}/"})
+ cal_path = f"/{post_slug}/{cal_slug}/"
- return sx_call("blog-nav-entries-oob", entries=entry_data, calendars=cal_data)
+ item_parts.append(sx_call("blog-nav-calendar-item",
+ href=cal_path, nav_cls=nav_cls, name=cal_name,
+ ))
+
+ items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""
+
+ return sx_call("scroll-nav-wrapper",
+ wrapper_id="entries-calendars-nav-wrapper", container_id="associated-items-container",
+ arrow_cls="entries-nav-arrow",
+ left_hs="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200",
+ scroll_hs=scroll_hs,
+ right_hs="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200",
+ items=SxExpr(items_sx) if items_sx else None, oob=True,
+ )
def register():
diff --git a/blog/bp/post/routes.py b/blog/bp/post/routes.py
index c5808af..70cf5c4 100644
--- a/blog/bp/post/routes.py
+++ b/blog/bp/post/routes.py
@@ -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)
diff --git a/blog/services/blog_page.py b/blog/services/blog_page.py
index 7595a17..0a42fe5 100644
--- a/blog/services/blog_page.py
+++ b/blog/services/blog_page.py
@@ -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):
diff --git a/blog/sx/admin.sx b/blog/sx/admin.sx
index dc39c46..0ac2aa4 100644
--- a/blog/sx/admin.sx
+++ b/blog/sx/admin.sx
@@ -143,6 +143,80 @@
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
edit-form delete-form))
+;; Data-driven snippets list (replaces Python _snippets_sx loop)
+(defcomp ~blog-snippets-from-data (&key snippets user-id is-admin csrf badge-colours)
+ (~blog-snippets-list
+ :rows (<> (map (lambda (s)
+ (let* ((s-id (get s "id"))
+ (s-name (get s "name"))
+ (s-uid (get s "user_id"))
+ (s-vis (get s "visibility"))
+ (owner (if (= s-uid user-id) "You" (str "User #" s-uid)))
+ (badge-cls (or (get badge-colours s-vis) "bg-stone-200 text-stone-700"))
+ (extra (<>
+ (when is-admin
+ (~blog-snippet-visibility-select
+ :patch-url (get s "patch_url")
+ :hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
+ :options (<>
+ (~blog-snippet-option :value "private" :selected (= s-vis "private") :label "private")
+ (~blog-snippet-option :value "shared" :selected (= s-vis "shared") :label "shared")
+ (~blog-snippet-option :value "admin" :selected (= s-vis "admin") :label "admin"))
+ :cls "text-sm border border-stone-300 rounded px-2 py-1"))
+ (when (or (= s-uid user-id) is-admin)
+ (~delete-btn :url (get s "delete_url") :trigger-target "#snippets-list"
+ :title "Delete snippet?"
+ :text (str "Delete \u201c" s-name "\u201d?")
+ :sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
+ :cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0")))))
+ (~blog-snippet-row :name s-name :owner owner :badge-cls badge-cls
+ :visibility s-vis :extra extra)))
+ (or snippets (list))))))
+
+;; Data-driven menu items list (replaces Python _menu_items_list_sx loop)
+(defcomp ~blog-menu-items-from-data (&key items csrf)
+ (~blog-menu-items-list
+ :rows (<> (map (lambda (item)
+ (let* ((img (~img-or-placeholder :src (get item "feature_image") :alt (get item "label")
+ :size-cls "w-12 h-12 rounded-full object-cover flex-shrink-0")))
+ (~blog-menu-item-row
+ :img img :label (get item "label") :slug (get item "slug")
+ :sort-order (get item "sort_order") :edit-url (get item "edit_url")
+ :delete-url (get item "delete_url")
+ :confirm-text (str "Remove " (get item "label") " from the menu?")
+ :hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}"))))
+ (or items (list))))))
+
+;; Data-driven tag groups main (replaces Python _tag_groups_main_panel_sx loops)
+(defcomp ~blog-tag-groups-from-data (&key groups unassigned-tags csrf create-url)
+ (~blog-tag-groups-main
+ :form (~blog-tag-groups-create-form :create-url create-url :csrf csrf)
+ :groups (if (empty? (or groups (list)))
+ (~empty-state :message "No tag groups yet." :cls "text-stone-500 text-sm")
+ (~blog-tag-groups-list
+ :items (<> (map (lambda (g)
+ (let* ((icon (if (get g "feature_image")
+ (~blog-tag-group-icon-image :src (get g "feature_image") :name (get g "name"))
+ (~blog-tag-group-icon-color :style (get g "style") :initial (get g "initial")))))
+ (~blog-tag-group-li :icon icon :edit-href (get g "edit_href")
+ :name (get g "name") :slug (get g "slug") :sort-order (get g "sort_order"))))
+ groups))))
+ :unassigned (when (not (empty? (or unassigned-tags (list))))
+ (~blog-unassigned-tags
+ :heading (str "Unassigned Tags (" (len unassigned-tags) ")")
+ :spans (<> (map (lambda (t)
+ (~blog-unassigned-tag :name (get t "name")))
+ unassigned-tags))))))
+
+;; Data-driven tag group edit (replaces Python _tag_groups_edit_main_panel_sx loop)
+(defcomp ~blog-tag-checkboxes-from-data (&key tags)
+ (<> (map (lambda (t)
+ (~blog-tag-checkbox
+ :tag-id (get t "tag_id") :checked (get t "checked")
+ :img (when (get t "feature_image") (~blog-tag-checkbox-image :src (get t "feature_image")))
+ :name (get t "name")))
+ (or tags (list)))))
+
;; Preview panel components
(defcomp ~blog-preview-panel (&key sections)
@@ -206,7 +280,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 +291,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 +314,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
diff --git a/blog/sx/cards.sx b/blog/sx/cards.sx
index cf4f8fa..53460b3 100644
--- a/blog/sx/cards.sx
+++ b/blog/sx/cards.sx
@@ -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"
@@ -107,6 +106,43 @@
(ul :class "flex flex-wrap gap-2 text-sm"
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors))))))))
+;; Data-driven blog cards list (replaces Python _blog_cards_sx loop)
+(defcomp ~blog-cards-from-data (&key posts view sentinel)
+ (<>
+ (map (lambda (p)
+ (if (= view "tile")
+ (~blog-card-tile
+ :href (get p "href") :hx-select (get p "hx_select")
+ :feature-image (get p "feature_image") :title (get p "title")
+ :is-draft (get p "is_draft") :publish-requested (get p "publish_requested")
+ :status-timestamp (get p "status_timestamp")
+ :excerpt (get p "excerpt") :tags (get p "tags") :authors (get p "authors"))
+ (~blog-card
+ :slug (get p "slug") :href (get p "href") :hx-select (get p "hx_select")
+ :title (get p "title") :feature-image (get p "feature_image")
+ :excerpt (get p "excerpt") :is-draft (get p "is_draft")
+ :publish-requested (get p "publish_requested")
+ :status-timestamp (get p "status_timestamp")
+ :has-like (get p "has_like") :liked (get p "liked")
+ :like-url (get p "like_url") :csrf-token (get p "csrf_token")
+ :tags (get p "tags") :authors (get p "authors")
+ :widget (when (get p "widget") (~rich-text :html (get p "widget"))))))
+ (or posts (list)))
+ sentinel))
+
+;; Data-driven page cards list (replaces Python _page_cards_sx loop)
+(defcomp ~page-cards-from-data (&key pages sentinel)
+ (<>
+ (map (lambda (pg)
+ (~blog-page-card
+ :href (get pg "href") :hx-select (get pg "hx_select")
+ :title (get pg "title")
+ :has-calendar (get pg "has_calendar") :has-market (get pg "has_market")
+ :pub-timestamp (get pg "pub_timestamp")
+ :feature-image (get pg "feature_image") :excerpt (get pg "excerpt")))
+ (or pages (list)))
+ sentinel))
+
(defcomp ~blog-page-badges (&key has-calendar has-market)
(div :class "flex justify-center gap-2 mt-2"
(when has-calendar (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800"
diff --git a/blog/sx/detail.sx b/blog/sx/detail.sx
index c7ab534..c00ac6e 100644
--- a/blog/sx/detail.sx
+++ b/blog/sx/detail.sx
@@ -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)))))
diff --git a/blog/sx/filters.sx b/blog/sx/filters.sx
index 2198389..4332ea3 100644
--- a/blog/sx/filters.sx
+++ b/blog/sx/filters.sx
@@ -63,3 +63,39 @@
(defcomp ~blog-filter-summary (&key text)
(span :class "text-sm text-stone-600" text))
+
+;; Data-driven tag groups filter (replaces Python _tag_groups_filter_sx loop)
+(defcomp ~blog-tag-groups-filter-from-data (&key groups selected-groups hx-select)
+ (let* ((is-any (empty? (or selected-groups (list))))
+ (any-cls (if is-any "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50")))
+ (~blog-filter-nav
+ :items (<>
+ (~blog-filter-any-topic :cls any-cls :hx-select hx-select)
+ (map (lambda (g)
+ (let* ((slug (get g "slug"))
+ (name (get g "name"))
+ (is-on (contains? selected-groups slug))
+ (cls (if is-on "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
+ (icon (if (get g "feature_image")
+ (~blog-filter-group-icon-image :src (get g "feature_image") :name name)
+ (~blog-filter-group-icon-color :style (get g "style") :initial (get g "initial")))))
+ (~blog-filter-group-li :cls cls :hx-get (str "?group=" slug "&page=1") :hx-select hx-select
+ :icon icon :name name :count (get g "count"))))
+ (or groups (list)))))))
+
+;; Data-driven authors filter (replaces Python _authors_filter_sx loop)
+(defcomp ~blog-authors-filter-from-data (&key authors selected-authors hx-select)
+ (let* ((is-any (empty? (or selected-authors (list))))
+ (any-cls (if is-any "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50")))
+ (~blog-filter-nav
+ :items (<>
+ (~blog-filter-any-author :cls any-cls :hx-select hx-select)
+ (map (lambda (a)
+ (let* ((slug (get a "slug"))
+ (is-on (contains? selected-authors slug))
+ (cls (if is-on "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
+ (icon (when (get a "profile_image")
+ (~blog-filter-author-icon :src (get a "profile_image") :name (get a "name")))))
+ (~blog-filter-author-li :cls cls :hx-get (str "?author=" slug "&page=1") :hx-select hx-select
+ :icon icon :name (get a "name") :count (get a "count"))))
+ (or authors (list)))))))
diff --git a/blog/sx/menu_items.sx b/blog/sx/menu_items.sx
index d9fb57d..2cbc6f8 100644
--- a/blog/sx/menu_items.sx
+++ b/blog/sx/menu_items.sx
@@ -24,3 +24,37 @@
(defcomp ~page-search-empty (&key query)
(div :class "p-3 text-center text-stone-400 border border-stone-200 rounded-md"
(str "No pages found matching \"" query "\"")))
+
+;; Data-driven page search results (replaces Python render_page_search_results loop)
+(defcomp ~page-search-results-from-data (&key pages query has-more search-url next-page)
+ (if (and (not pages) query)
+ (~page-search-empty :query query)
+ (when pages
+ (~page-search-results
+ :items (<> (map (lambda (p)
+ (~page-search-item
+ :id (get p "id") :title (get p "title")
+ :slug (get p "slug") :feature-image (get p "feature_image")))
+ pages))
+ :sentinel (when has-more
+ (~page-search-sentinel :url search-url :query query :next-page next-page))))))
+
+;; Data-driven menu nav items (replaces Python render_menu_items_nav_oob loop)
+(defcomp ~blog-menu-nav-from-data (&key items nav-cls container-id arrow-cls scroll-hs)
+ (if (not items)
+ (~blog-nav-empty :wrapper-id "menu-items-nav-wrapper")
+ (~scroll-nav-wrapper :wrapper-id "menu-items-nav-wrapper" :container-id container-id
+ :arrow-cls arrow-cls
+ :left-hs (str "on click set #" container-id ".scrollLeft to #" container-id ".scrollLeft - 200")
+ :scroll-hs scroll-hs
+ :right-hs (str "on click set #" container-id ".scrollLeft to #" container-id ".scrollLeft + 200")
+ :items (<> (map (lambda (item)
+ (let* ((img (~img-or-placeholder :src (get item "feature_image") :alt (get item "label")
+ :size-cls "w-8 h-8 rounded-full object-cover flex-shrink-0")))
+ (if (= (get item "slug") "cart")
+ (~blog-nav-item-plain :href (get item "href") :selected (get item "selected")
+ :nav-cls nav-cls :img img :label (get item "label"))
+ (~blog-nav-item-link :href (get item "href") :hx-get (get item "hx_get")
+ :selected (get item "selected") :nav-cls nav-cls :img img :label (get item "label")))))
+ items))
+ :oob true)))
diff --git a/blog/sx/settings.sx b/blog/sx/settings.sx
index c72f04a..b9a8978 100644
--- a/blog/sx/settings.sx
+++ b/blog/sx/settings.sx
@@ -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"
@@ -140,7 +140,7 @@
(~blog-associated-entry
:confirm-text (get e "confirm_text")
:toggle-url (get e "toggle_url")
- :hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
+ :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")))
diff --git a/events/sxc/pages/calendar.py b/events/sxc/pages/calendar.py
index 193d448..24471e3 100644
--- a/events/sxc/pages/calendar.py
+++ b/events/sxc/pages/calendar.py
@@ -8,8 +8,8 @@ from shared.sx.helpers import (
from shared.sx.parser import SxExpr
from .utils import (
- _ensure_container_nav,
- _list_container,
+ _clear_deeper_oob, _ensure_container_nav,
+ _entry_state_badge_html, _list_container,
)
@@ -27,41 +27,45 @@ def _post_nav_sx(ctx: dict) -> str:
"""Post desktop nav: calendar links + container nav (markets, etc.)."""
from quart import url_for, g
- calendars_orm = ctx.get("calendars") or []
+ calendars = ctx.get("calendars") or []
select_colours = ctx.get("select_colours", "")
current_cal_slug = getattr(g, "calendar_slug", None)
- calendars_data = []
- for cal in calendars_orm:
+ parts = []
+ for cal in calendars:
cal_slug = getattr(cal, "slug", "") if hasattr(cal, "slug") else cal.get("slug", "")
cal_name = getattr(cal, "name", "") if hasattr(cal, "name") else cal.get("name", "")
href = url_for("calendar.get", calendar_slug=cal_slug)
- calendars_data.append({
- "href": href, "name": cal_name,
- "is-selected": True if cal_slug == current_cal_slug else None,
- })
-
- container_nav = ctx.get("container_nav", "") or None
+ is_sel = (cal_slug == current_cal_slug)
+ parts.append(sx_call("nav-link", href=href, icon="fa fa-calendar",
+ label=cal_name, select_colours=select_colours,
+ is_selected=is_sel))
+ # Container nav fragments (markets, etc.)
+ container_nav = ctx.get("container_nav", "")
+ if container_nav:
+ parts.append(container_nav)
+ # Admin cog → blog admin for this post (cross-domain, no HTMX)
rights = ctx.get("rights") or {}
has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
- admin_href = None
- aclass = None
if has_admin:
post = ctx.get("post") or {}
slug = post.get("slug", "")
styles = ctx.get("styles") or {}
nav_btn = styles.get("nav_button", "") if isinstance(styles, dict) else getattr(styles, "nav_button", "")
+ select_colours = ctx.get("select_colours", "")
admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/")
aclass = f"{nav_btn} {select_colours}".strip() or (
"justify-center cursor-pointer flex flex-row items-center gap-2 "
"rounded bg-stone-200 text-black p-3"
)
+ parts.append(
+ f''
+ )
- return sx_call("events-post-nav-from-data",
- calendars=calendars_data or None, container_nav=container_nav,
- select_colours=select_colours,
- has_admin=has_admin or None, admin_href=admin_href, aclass=aclass)
+ return "".join(parts)
# ---------------------------------------------------------------------------
@@ -115,13 +119,15 @@ def _calendar_nav_sx(ctx: dict) -> str:
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
select_colours = ctx.get("select_colours", "")
+ parts = []
slots_href = url_for("defpage_slots_listing", calendar_slug=cal_slug)
- admin_href = url_for("defpage_calendar_admin", calendar_slug=cal_slug) if is_admin else None
-
- return sx_call("events-calendar-nav-from-data",
- slots_href=slots_href, admin_href=admin_href,
- select_colours=select_colours,
- is_admin=is_admin or None)
+ parts.append(sx_call("nav-link", href=slots_href, icon="fa fa-clock",
+ label="Slots", select_colours=select_colours))
+ if is_admin:
+ admin_href = url_for("defpage_calendar_admin", calendar_slug=cal_slug)
+ parts.append(sx_call("nav-link", href=admin_href, icon="fa fa-cog",
+ select_colours=select_colours))
+ return "(<> " + " ".join(parts) + ")" if parts else ""
# ---------------------------------------------------------------------------
@@ -168,21 +174,27 @@ def _day_nav_sx(ctx: dict) -> str:
rights = ctx.get("rights") or {}
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
- entries_data = []
- for entry in confirmed_entries:
- href = url_for(
- "defpage_entry_detail",
- 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 ""
- entries_data.append({"href": href, "name": entry.name, "time-str": f"{start}{end}"})
+ parts = []
+ # Confirmed entries nav (scrolling menu)
+ if confirmed_entries:
+ entry_links = []
+ for entry in confirmed_entries:
+ href = url_for(
+ "defpage_entry_detail",
+ 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)
+ parts.append(sx_call("events-day-entries-nav", inner=SxExpr(inner)))
- admin_href = None
if is_admin and day_date:
admin_href = url_for(
"defpage_day_admin",
@@ -191,10 +203,8 @@ def _day_nav_sx(ctx: dict) -> str:
month=day_date.month,
day=day_date.day,
)
-
- return sx_call("events-day-nav-from-data",
- entries=entries_data or None,
- is_admin=is_admin or None, admin_href=admin_href)
+ parts.append(sx_call("nav-link", href=admin_href, icon="fa fa-cog"))
+ return "".join(parts)
# ---------------------------------------------------------------------------
@@ -235,16 +245,17 @@ def _calendar_admin_header_sx(ctx: dict, *, oob: bool = False) -> str:
cal_slug = getattr(calendar, "slug", "") if calendar else ""
select_colours = ctx.get("select_colours", "")
- links_data = []
+ nav_parts = []
if cal_slug:
for endpoint, label in [
("defpage_slots_listing", "slots"),
("calendar.admin.calendar_description_edit", "description"),
]:
- links_data.append({"href": url_for(endpoint, calendar_slug=cal_slug), "label": label})
+ href = url_for(endpoint, calendar_slug=cal_slug)
+ nav_parts.append(sx_call("nav-link", href=href, label=label,
+ select_colours=select_colours))
- nav_html = sx_call("events-calendar-admin-nav-from-data",
- links=links_data or None, select_colours=select_colours) if links_data else ""
+ nav_html = "".join(nav_parts)
return sx_call("menu-row-sx", id="calendar-admin-row", level=4,
link_label="admin", icon="fa fa-cog",
nav=SxExpr(nav_html) if nav_html else None, child_id="calendar-admin-header-child", oob=oob)
@@ -271,36 +282,55 @@ def _markets_header_sx(ctx: dict, *, oob: bool = False) -> str:
def _calendars_main_panel_sx(ctx: dict) -> str:
"""Render the calendars list + create form panel."""
from quart import url_for
- from shared.utils import route_prefix
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("calendars.create_calendar") if callable(has_access) else is_admin
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
- prefix = route_prefix()
calendars = ctx.get("calendars") or []
- items_data = []
+
+ form_html = ""
+ if can_create:
+ create_url = url_for("calendars.create_calendar")
+ form_html = sx_call("crud-create-form",
+ create_url=create_url, csrf=csrf,
+ errors_id="cal-create-errors", list_id="calendars-list",
+ placeholder="e.g. Events, Gigs, Meetings", btn_label="Add calendar")
+
+ list_html = _calendars_list_sx(ctx, calendars)
+ return sx_call("crud-panel",
+ form=SxExpr(form_html), list=SxExpr(list_html),
+ list_id="calendars-list")
+
+
+def _calendars_list_sx(ctx: dict, calendars: list) -> str:
+ """Render the calendars list items."""
+ from quart import url_for
+ from shared.utils import route_prefix
+ csrf_token = ctx.get("csrf_token")
+ csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
+ prefix = route_prefix()
+
+ if not calendars:
+ return sx_call("empty-state", message="No calendars yet. Create one above.",
+ cls="text-gray-500 mt-4")
+
+ parts = []
for cal in calendars:
cal_slug = getattr(cal, "slug", "")
cal_name = getattr(cal, "name", "")
- items_data.append({
- "href": prefix + url_for("calendar.get", calendar_slug=cal_slug),
- "name": cal_name, "slug": cal_slug,
- "del-url": url_for("calendar.delete", calendar_slug=cal_slug),
- "csrf-hdr": f'{{"X-CSRFToken":"{csrf}"}}',
- "confirm-title": "Delete calendar?",
- "confirm-text": "Entries will be hidden (soft delete)",
- })
-
- return sx_call("events-crud-panel-from-data",
- can_create=can_create or None,
- create_url=url_for("calendars.create_calendar") if can_create else None,
- csrf=csrf, errors_id="cal-create-errors", list_id="calendars-list",
- placeholder="e.g. Events, Gigs, Meetings", btn_label="Add calendar",
- items=items_data or None,
- empty_msg="No calendars yet. Create one above.")
+ href = prefix + url_for("calendar.get", calendar_slug=cal_slug)
+ del_url = url_for("calendar.delete", calendar_slug=cal_slug)
+ 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,
+ list_id="calendars-list",
+ confirm_title="Delete calendar?",
+ confirm_text="Entries will be hidden (soft delete)"))
+ return "".join(parts)
# ---------------------------------------------------------------------------
@@ -308,7 +338,7 @@ def _calendars_main_panel_sx(ctx: dict) -> str:
# ---------------------------------------------------------------------------
def _calendar_main_panel_html(ctx: dict) -> str:
- """Render the calendar month grid via data extraction + sx defcomp."""
+ """Render the calendar month grid."""
from quart import url_for
from quart import session as qsession
@@ -316,6 +346,7 @@ def _calendar_main_panel_html(ctx: dict) -> str:
if not calendar:
return ""
cal_slug = getattr(calendar, "slug", "")
+ hx_select = ctx.get("hx_select_search", "#main-panel")
styles = ctx.get("styles") or {}
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
@@ -337,8 +368,35 @@ def _calendar_main_panel_html(ctx: dict) -> str:
def nav_link(y, m):
return url_for("calendar.get", calendar_slug=cal_slug, year=y, month=m)
- # Day cells data
- cells_data = []
+ # Month navigation arrows
+ nav_arrows = []
+ for label, yr, mn in [
+ ("\u00ab", prev_year, month),
+ ("\u2039", prev_month_year, prev_month),
+ ]:
+ href = nav_link(yr, mn)
+ nav_arrows.append(sx_call("events-calendar-nav-arrow",
+ pill_cls=pill_cls, href=href, label=label))
+
+ nav_arrows.append(sx_call("events-calendar-month-label",
+ month_name=month_name, year=str(year)))
+
+ for label, yr, mn in [
+ ("\u203a", next_month_year, next_month),
+ ("\u00bb", next_year, month),
+ ]:
+ href = nav_link(yr, mn)
+ nav_arrows.append(sx_call("events-calendar-nav-arrow",
+ pill_cls=pill_cls, href=href, label=label))
+
+ # Weekday headers
+ wd_parts = []
+ for wd in weekday_names:
+ wd_parts.append(sx_call("events-calendar-weekday", name=wd))
+ wd_html = "".join(wd_parts)
+
+ # Day cells
+ cells = []
for week in weeks:
for day_cell in week:
if isinstance(day_cell, dict):
@@ -356,18 +414,24 @@ def _calendar_main_panel_html(ctx: dict) -> str:
if is_today:
cell_cls += " ring-2 ring-blue-500 z-10 relative"
- cell = {"cell-cls": cell_cls}
+ # Day number link
+ day_num_html = ""
+ day_short_html = ""
if day_date:
- cell["day-str"] = day_date.strftime("%a")
- cell["day-href"] = url_for(
+ day_href = url_for(
"calendar.day.show_day",
calendar_slug=cal_slug,
year=day_date.year, month=day_date.month, day=day_date.day,
)
- cell["day-num"] = str(day_date.day)
+ day_short_html = sx_call("events-calendar-day-short",
+ day_str=day_date.strftime("%a"))
+ day_num_html = sx_call("events-calendar-day-num",
+ pill_cls=pill_cls, href=day_href,
+ num=str(day_date.day))
- # Entry badges for this day
- badges = []
+ # Entry badges for this day
+ entry_badges = []
+ if day_date:
for e in month_entries:
if e.start_at and e.start_at.date() == day_date:
is_mine = (
@@ -378,23 +442,23 @@ def _calendar_main_panel_html(ctx: dict) -> str:
bg_cls = "bg-emerald-200 text-emerald-900" if is_mine else "bg-emerald-100 text-emerald-800"
else:
bg_cls = "bg-sky-100 text-sky-800" if is_mine else "bg-stone-100 text-stone-700"
- badges.append({
- "bg-cls": bg_cls, "name": e.name,
- "state-label": (e.state or "pending").replace("_", " "),
- })
- if badges:
- cell["badges"] = badges
+ state_label = (e.state or "pending").replace("_", " ")
+ entry_badges.append(sx_call("events-calendar-entry-badge",
+ bg_cls=bg_cls, name=e.name,
+ state_label=state_label))
- cells_data.append(cell)
+ badges_html = "(<> " + "".join(entry_badges) + ")" if entry_badges else ""
+ cells.append(sx_call("events-calendar-cell",
+ cell_cls=cell_cls, day_short=SxExpr(day_short_html),
+ day_num=SxExpr(day_num_html),
+ badges=SxExpr(badges_html) if badges_html else None))
- return sx_call("events-calendar-grid-from-data",
- pill_cls=pill_cls, month_name=month_name, year=str(year),
- prev_year_href=nav_link(prev_year, month),
- prev_month_href=nav_link(prev_month_year, prev_month),
- next_month_href=nav_link(next_month_year, next_month),
- next_year_href=nav_link(next_year, month),
- weekday_names=weekday_names or None,
- cells=cells_data or None)
+ cells_html = "(<> " + "".join(cells) + ")"
+ arrows_html = "(<> " + "".join(nav_arrows) + ")"
+ wd_html = "(<> " + wd_html + ")"
+ return sx_call("events-calendar-grid",
+ arrows=SxExpr(arrows_html), weekdays=SxExpr(wd_html),
+ cells=SxExpr(cells_html))
# ---------------------------------------------------------------------------
@@ -402,7 +466,7 @@ def _calendar_main_panel_html(ctx: dict) -> str:
# ---------------------------------------------------------------------------
def _day_main_panel_html(ctx: dict) -> str:
- """Render the day entries table via data extraction + sx defcomp."""
+ """Render the day entries table + add button."""
from quart import url_for
calendar = ctx.get("calendar")
@@ -413,49 +477,21 @@ def _day_main_panel_html(ctx: dict) -> str:
day = ctx.get("day")
month = ctx.get("month")
year = ctx.get("year")
+ hx_select = ctx.get("hx_select_search", "#main-panel")
styles = ctx.get("styles") or {}
list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "")
- rows_data = []
- for entry in day_entries:
- entry_href = url_for(
- "defpage_entry_detail",
- calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=entry.id,
- )
- row = {
- "href": entry_href, "name": entry.name,
- "state-id": f"entry-state-{entry.id}",
- "state": getattr(entry, "state", "pending") or "pending",
- }
-
- # Slot/Time
- slot = getattr(entry, "slot", None)
- if slot:
- row["slot-name"] = slot.name
- row["slot-href"] = url_for("defpage_slot_detail", calendar_slug=cal_slug, slot_id=slot.id)
- time_start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
- time_end = f" \u2192 {slot.time_end.strftime('%H:%M')}" if slot.time_end else ""
- row["slot-time"] = f"({time_start}{time_end})"
- else:
- row["start"] = entry.start_at.strftime("%H:%M") if entry.start_at else ""
- row["end"] = f" \u2192 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
-
- # Cost
- cost = getattr(entry, "cost", None)
- row["cost-str"] = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
-
- # Tickets
- tp = getattr(entry, "ticket_price", None)
- if tp is not None:
- tc = getattr(entry, "ticket_count", None)
- row["has-tickets"] = True
- row["price-str"] = f"\u00a3{tp:.2f}"
- row["count-str"] = f"{tc} tickets" if tc is not None else "Unlimited"
-
- rows_data.append(row)
+ rows_html = ""
+ if day_entries:
+ row_parts = []
+ for entry in day_entries:
+ row_parts.append(_day_row_html(ctx, entry))
+ rows_html = "".join(row_parts)
+ else:
+ rows_html = sx_call("events-day-empty-row")
add_url = url_for(
"calendar.day.calendar_entries.add_form",
@@ -463,10 +499,74 @@ def _day_main_panel_html(ctx: dict) -> str:
day=day, month=month, year=year,
)
- return sx_call("events-day-table-from-data",
- list_container=list_container, pre_action=pre_action,
- add_url=add_url, tr_cls=tr_cls, pill_cls=pill_cls,
- rows=rows_data or None)
+ return sx_call("events-day-table",
+ list_container=list_container, rows=SxExpr(rows_html),
+ pre_action=pre_action, add_url=add_url)
+
+
+def _day_row_html(ctx: dict, entry) -> str:
+ """Render a single day table row."""
+ from quart import url_for
+ calendar = ctx.get("calendar")
+ cal_slug = getattr(calendar, "slug", "")
+ day = ctx.get("day")
+ month = ctx.get("month")
+ year = ctx.get("year")
+ hx_select = ctx.get("hx_select_search", "#main-panel")
+ styles = ctx.get("styles") or {}
+ pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "")
+ tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "")
+
+ entry_href = url_for(
+ "defpage_entry_detail",
+ calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=entry.id,
+ )
+
+ # Name
+ name_html = sx_call("events-day-row-name",
+ href=entry_href, pill_cls=pill_cls, name=entry.name)
+
+ # Slot/Time
+ slot = getattr(entry, "slot", None)
+ if slot:
+ slot_href = url_for("defpage_slot_detail", calendar_slug=cal_slug, slot_id=slot.id)
+ time_start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
+ time_end = f" \u2192 {slot.time_end.strftime('%H:%M')}" if slot.time_end else ""
+ slot_html = sx_call("events-day-row-slot",
+ href=slot_href, pill_cls=pill_cls, slot_name=slot.name,
+ time_str=f"({time_start}{time_end})")
+ else:
+ start = entry.start_at.strftime("%H:%M") if entry.start_at else ""
+ end = f" \u2192 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
+ slot_html = sx_call("events-day-row-time", start=start, end=end)
+
+ # State
+ 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=state_badge)
+
+ # Cost
+ cost = getattr(entry, "cost", None)
+ cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
+ cost_td = sx_call("events-day-row-cost", cost_str=cost_str)
+
+ # Tickets
+ tp = getattr(entry, "ticket_price", None)
+ if tp is not None:
+ tc = getattr(entry, "ticket_count", None)
+ tc_str = f"{tc} tickets" if tc is not None else "Unlimited"
+ tickets_td = sx_call("events-day-row-tickets",
+ price_str=f"\u00a3{tp:.2f}", count_str=tc_str)
+ else:
+ tickets_td = sx_call("events-day-row-no-tickets")
+
+ actions_td = sx_call("events-day-row-actions")
+
+ return sx_call("events-day-row",
+ tr_cls=tr_cls, name=name_html, slot=slot_html,
+ state=state_td, cost=cost_td,
+ tickets=tickets_td, actions=actions_td)
# ---------------------------------------------------------------------------
@@ -514,7 +614,7 @@ def _calendar_description_display_html(calendar, edit_url: str) -> str:
# ---------------------------------------------------------------------------
def _markets_main_panel_html(ctx: dict) -> str:
- """Render markets list + create form panel via data extraction + sx defcomp."""
+ """Render markets list + create form panel."""
from quart import url_for
rights = ctx.get("rights") or {}
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
@@ -523,29 +623,48 @@ def _markets_main_panel_html(ctx: dict) -> str:
csrf_token = ctx.get("csrf_token")
csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
markets = ctx.get("markets") or []
+
+ form_html = ""
+ if can_create:
+ create_url = url_for("markets.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. Farm Shop, Bakery", btn_label="Add market")
+
+ list_html = _markets_list_html(ctx, markets)
+ return sx_call("crud-panel",
+ form=SxExpr(form_html), list=SxExpr(list_html),
+ list_id="markets-list")
+
+
+def _markets_list_html(ctx: dict, markets: list) -> str:
+ """Render markets list items."""
+ from quart import url_for
+ csrf_token = ctx.get("csrf_token")
+ csrf = csrf_token() if callable(csrf_token) else (csrf_token or "")
post = ctx.get("post") or {}
slug = post.get("slug", "")
- items_data = []
+ 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", "") if hasattr(m, "slug") else m.get("slug", "")
m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "")
- items_data.append({
- "href": call_url(ctx, "market_url", f"/{slug}/{m_slug}/"),
- "name": m_name, "slug": m_slug,
- "del-url": url_for("markets.delete_market", market_slug=m_slug),
- "csrf-hdr": f'{{"X-CSRFToken":"{csrf}"}}',
- "confirm-title": "Delete market?",
- "confirm-text": "Products will be hidden (soft delete)",
- })
-
- return sx_call("events-crud-panel-from-data",
- can_create=can_create or None,
- create_url=url_for("markets.create_market") if can_create else None,
- csrf=csrf, errors_id="market-create-errors", list_id="markets-list",
- placeholder="e.g. Farm Shop, Bakery", btn_label="Add market",
- items=items_data or None,
- empty_msg="No markets yet. Create one above.")
+ market_href = call_url(ctx, "market_url", f"/{slug}/{m_slug}/")
+ del_url = url_for("markets.delete_market", market_slug=m_slug)
+ csrf_hdr = {"X-CSRFToken": csrf}
+ parts.append(sx_call("crud-item",
+ href=market_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)
# ---------------------------------------------------------------------------
diff --git a/events/sxc/pages/entries.py b/events/sxc/pages/entries.py
index efdaf44..506483e 100644
--- a/events/sxc/pages/entries.py
+++ b/events/sxc/pages/entries.py
@@ -1,23 +1,26 @@
"""Entry panels, cards, forms, edit/add."""
from __future__ import annotations
+from markupsafe import escape
+
from shared.sx.helpers import sx_call
from shared.sx.parser import SxExpr
from .utils import (
- _entry_state_badge_html,
+ _entry_state_badge_html, _ticket_state_badge_html,
_list_container, _view_toggle_html,
)
# ---------------------------------------------------------------------------
-# All events / page summary entry cards — data extraction
+# All events / page summary entry cards
# ---------------------------------------------------------------------------
-def _entry_card_data(entry, page_info: dict, pending_tickets: dict,
+def _entry_card_html(entry, page_info: dict, pending_tickets: dict,
ticket_url: str, events_url_fn, *, is_page_scoped: bool = False,
- post: dict | None = None) -> dict:
- """Extract data for a single entry card (list or tile)."""
+ post: dict | None = None) -> str:
+ """Render a list card for one event entry."""
+ from .tickets import _ticket_widget_html
pi = page_info.get(getattr(entry, "calendar_container_id", 0), {})
if is_page_scoped and post:
page_slug = pi.get("slug", post.get("slug", ""))
@@ -30,103 +33,145 @@ def _entry_card_data(entry, page_info: dict, pending_tickets: dict,
day_href = events_url_fn(f"/{page_slug}/{entry.calendar_slug}/day/{entry.start_at.strftime('%Y/%-m/%-d')}/")
entry_href = f"{day_href}entries/{entry.id}/" if day_href else ""
- # Page badge (only show if different from current page title)
- page_badge_href = ""
- page_badge_title = ""
+ # Title (linked or plain)
+ if entry_href:
+ title_html = sx_call("events-entry-title-linked",
+ href=entry_href, name=entry.name)
+ else:
+ title_html = sx_call("events-entry-title-plain", name=entry.name)
+
+ # Badges
+ badges_html = ""
if page_title and (not is_page_scoped or page_title != (post or {}).get("title")):
- page_badge_href = events_url_fn(f"/{page_slug}/")
- page_badge_title = page_title
+ page_href = events_url_fn(f"/{page_slug}/")
+ badges_html += sx_call("events-entry-page-badge",
+ href=page_href, title=page_title)
+ cal_name = getattr(entry, "calendar_name", "")
+ if cal_name:
+ badges_html += sx_call("events-entry-cal-badge", name=cal_name)
- cal_name = getattr(entry, "calendar_name", "") or ""
-
- # Time parts
- date_str = entry.start_at.strftime("%a %-d %b") if entry.start_at else ""
- start_time = entry.start_at.strftime("%H:%M") if entry.start_at else ""
- end_time = entry.end_at.strftime("%H:%M") if entry.end_at else ""
-
- # Tile time string (combined)
- time_str_parts = []
- if date_str:
- time_str_parts.append(date_str)
- if start_time:
- time_str_parts.append(start_time)
- time_str = " \u00b7 ".join(time_str_parts)
- if end_time:
- time_str += f" \u2013 {end_time}"
+ # Time line
+ time_parts = ""
+ if day_href and not is_page_scoped:
+ time_parts += sx_call("events-entry-time-linked",
+ href=day_href,
+ date_str=entry.start_at.strftime("%a %-d %b"))
+ elif not is_page_scoped:
+ time_parts += sx_call("events-entry-time-plain",
+ date_str=entry.start_at.strftime("%a %-d %b"))
+ time_parts += entry.start_at.strftime("%H:%M")
+ if entry.end_at:
+ time_parts += f' \u2013 {entry.end_at.strftime("%H:%M")}'
cost = getattr(entry, "cost", None)
- cost_str = f"\u00a3{cost:.2f}" if cost else None
+ cost_html = sx_call("events-entry-cost",
+ cost=f"\u00a3{cost:.2f}") if cost else ""
- # Ticket widget data
+ # Ticket widget
tp = getattr(entry, "ticket_price", None)
- has_ticket = tp is not None
- ticket_data = None
- if has_ticket:
+ widget_html = ""
+ if tp is not None:
qty = pending_tickets.get(entry.id, 0)
- ticket_data = {
- "entry-id": str(entry.id),
- "price": f"\u00a3{tp:.2f}",
- "qty": qty,
- "ticket-url": ticket_url,
- "csrf": _get_csrf(),
- }
+ widget_html = sx_call("events-entry-widget-wrapper",
+ widget=_ticket_widget_html(entry, qty, ticket_url, ctx={}))
- return {
- "entry-href": entry_href or None,
- "name": entry.name,
- "day-href": day_href or None,
- "page-badge-href": page_badge_href or None,
- "page-badge-title": page_badge_title or None,
- "cal-name": cal_name or None,
- "date-str": date_str,
- "start-time": start_time,
- "end-time": end_time or None,
- "time-str": time_str,
- "is-page-scoped": is_page_scoped or None,
- "cost": cost_str,
- "has-ticket": has_ticket or None,
- "ticket-data": ticket_data,
- }
+ return sx_call("events-entry-card",
+ title=title_html, badges=SxExpr(badges_html),
+ time_parts=SxExpr(time_parts), cost=SxExpr(cost_html),
+ widget=SxExpr(widget_html))
-def _get_csrf() -> str:
- """Get CSRF token (lazy import)."""
- try:
- from flask_wtf.csrf import generate_csrf
- return generate_csrf()
- except Exception:
- return ""
+def _entry_card_tile_html(entry, page_info: dict, pending_tickets: dict,
+ ticket_url: str, events_url_fn, *, is_page_scoped: bool = False,
+ post: dict | None = None) -> str:
+ """Render a tile card for one event entry."""
+ from .tickets import _ticket_widget_html
+ pi = page_info.get(getattr(entry, "calendar_container_id", 0), {})
+ if is_page_scoped and post:
+ page_slug = pi.get("slug", post.get("slug", ""))
+ else:
+ page_slug = pi.get("slug", "")
+ page_title = pi.get("title")
+ day_href = ""
+ if page_slug and entry.start_at:
+ day_href = events_url_fn(f"/{page_slug}/{entry.calendar_slug}/day/{entry.start_at.strftime('%Y/%-m/%-d')}/")
+ entry_href = f"{day_href}entries/{entry.id}/" if day_href else ""
-def _entry_cards_data(entries, page_info, pending_tickets, ticket_url,
- events_url_fn, view, *, is_page_scoped=False, post=None) -> list:
- """Extract data list for entry cards with date separators."""
- items = []
- last_date = None
- for entry in entries:
- if view != "tile":
- entry_date = entry.start_at.strftime("%A %-d %B %Y") if entry.start_at else ""
- if entry_date != last_date:
- items.append({"is-separator": True, "date-str": entry_date})
- last_date = entry_date
- items.append(_entry_card_data(
- entry, page_info, pending_tickets, ticket_url, events_url_fn,
- is_page_scoped=is_page_scoped, post=post,
- ))
- return items
+ # Title
+ if entry_href:
+ title_html = sx_call("events-entry-title-tile-linked",
+ href=entry_href, name=entry.name)
+ else:
+ title_html = sx_call("events-entry-title-tile-plain", name=entry.name)
+
+ # Badges
+ badges_html = ""
+ if page_title and (not is_page_scoped or page_title != (post or {}).get("title")):
+ page_href = events_url_fn(f"/{page_slug}/")
+ badges_html += sx_call("events-entry-page-badge",
+ href=page_href, title=page_title)
+ cal_name = getattr(entry, "calendar_name", "")
+ if cal_name:
+ badges_html += sx_call("events-entry-cal-badge", name=cal_name)
+
+ # Time
+ time_html = ""
+ if day_href:
+ time_html += (sx_call("events-entry-time-linked",
+ href=day_href,
+ date_str=entry.start_at.strftime("%a %-d %b"))).replace(" · ", "")
+ else:
+ time_html += entry.start_at.strftime("%a %-d %b")
+ time_html += f' \u00b7 {entry.start_at.strftime("%H:%M")}'
+ if entry.end_at:
+ time_html += f' \u2013 {entry.end_at.strftime("%H:%M")}'
+
+ cost = getattr(entry, "cost", None)
+ cost_html = sx_call("events-entry-cost",
+ cost=f"\u00a3{cost:.2f}") if cost else ""
+
+ # Ticket widget
+ tp = getattr(entry, "ticket_price", None)
+ widget_html = ""
+ if tp is not None:
+ qty = pending_tickets.get(entry.id, 0)
+ widget_html = sx_call("events-entry-tile-widget-wrapper",
+ widget=_ticket_widget_html(entry, qty, ticket_url, ctx={}))
+
+ return sx_call("events-entry-card-tile",
+ title=title_html, badges=SxExpr(badges_html),
+ time=SxExpr(time_html), cost=SxExpr(cost_html),
+ widget=SxExpr(widget_html))
def _entry_cards_html(entries, page_info, pending_tickets, ticket_url,
events_url_fn, view, page, has_more, next_url,
*, is_page_scoped=False, post=None) -> str:
- """Render entry cards via sx defcomp with data extraction."""
- items = _entry_cards_data(
- entries, page_info, pending_tickets, ticket_url, events_url_fn,
- view, is_page_scoped=is_page_scoped, post=post,
- )
- return sx_call("events-entry-cards-from-data",
- items=items, view=view, page=page,
- has_more=has_more, next_url=next_url)
+ """Render entry cards (list or tile) with sentinel."""
+ parts = []
+ last_date = None
+ for entry in entries:
+ if view == "tile":
+ parts.append(_entry_card_tile_html(
+ entry, page_info, pending_tickets, ticket_url, events_url_fn,
+ is_page_scoped=is_page_scoped, post=post,
+ ))
+ else:
+ entry_date = entry.start_at.strftime("%A %-d %B %Y") if entry.start_at else ""
+ if entry_date != last_date:
+ parts.append(sx_call("events-date-separator",
+ date_str=entry_date))
+ last_date = entry_date
+ parts.append(_entry_card_html(
+ entry, page_info, pending_tickets, ticket_url, events_url_fn,
+ is_page_scoped=is_page_scoped, post=post,
+ ))
+
+ if has_more:
+ parts.append(sx_call("sentinel-simple",
+ id=f"sentinel-{page}", next_url=next_url))
+ return "".join(parts)
# ---------------------------------------------------------------------------
@@ -138,15 +183,23 @@ def _events_main_panel_html(ctx: dict, entries, has_more, pending_tickets, page_
*, is_page_scoped=False, post=None) -> str:
"""Render the events main panel with view toggle + cards."""
toggle = _view_toggle_html(ctx, view)
- items = None
+
if entries:
- items = _entry_cards_data(
+ cards = _entry_cards_html(
entries, page_info, pending_tickets, ticket_url, events_url_fn,
- view, is_page_scoped=is_page_scoped, post=post,
+ view, page, has_more, next_url,
+ is_page_scoped=is_page_scoped, post=post,
)
- return sx_call("events-main-panel-from-data",
- toggle=toggle, items=items, view=view, page=page,
- has_more=has_more, next_url=next_url)
+ grid_cls = ("max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
+ if view == "tile" else "max-w-full px-3 py-3 space-y-3")
+ body = sx_call("events-grid", grid_cls=grid_cls, cards=SxExpr(cards))
+ else:
+ body = sx_call("empty-state", icon="fa fa-calendar-xmark",
+ message="No upcoming events",
+ cls="px-3 py-12 text-center text-stone-400")
+
+ return sx_call("events-main-panel-body",
+ toggle=toggle, body=body)
# ---------------------------------------------------------------------------
@@ -320,16 +373,27 @@ def _entry_nav_html(ctx: dict) -> str:
entry_posts = ctx.get("entry_posts") or []
rights = ctx.get("rights") or {}
is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False)
+
blog_url_fn = ctx.get("blog_url")
parts = []
- # Associated Posts scrolling menu (strip OOB attr for inline embedding)
+ # Associated Posts scrolling menu
if entry_posts:
- posts_data = _entry_posts_nav_data(entry_posts, blog_url_fn)
- nav_html = sx_call("events-entry-posts-nav-inner-from-data", posts=posts_data or None)
- if nav_html:
- parts.append(nav_html.replace(' :hx-swap-oob "true"', ''))
+ post_links = ""
+ for ep in entry_posts:
+ slug = getattr(ep, "slug", "")
+ title = getattr(ep, "title", "")
+ feat = getattr(ep, "feature_image", None)
+ href = blog_url_fn(f"/{slug}/") if blog_url_fn else f"/{slug}/"
+ if feat:
+ img_html = sx_call("events-post-img", src=feat, alt=title)
+ else:
+ img_html = sx_call("events-post-img-placeholder")
+ post_links += sx_call("events-entry-nav-post-link",
+ 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:
@@ -368,7 +432,7 @@ def _entry_title_html(entry) -> str:
def _entry_options_html(entry, calendar, day, month, year) -> str:
- """Render confirm/decline/provisional buttons via data extraction + sx defcomp."""
+ """Render confirm/decline/provisional buttons based on entry state."""
from quart import url_for, g
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
@@ -379,30 +443,39 @@ def _entry_options_html(entry, calendar, day, month, year) -> str:
cal_slug = getattr(calendar, "slug", "")
eid = entry.id
state = getattr(entry, "state", "pending") or "pending"
+ target = f"#calendar_entry_options_{eid}"
- def _btn_data(action_name, label, confirm_title, confirm_text, *, trigger_type="submit"):
- return {
- "url": url_for(f"calendar.day.calendar_entries.calendar_entry.{action_name}",
- calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid),
- "csrf": csrf, "btn-type": "button" if trigger_type == "button" else "submit",
- "action-btn": action_btn, "confirm-title": confirm_title,
- "confirm-text": confirm_text, "label": label,
- "is-btn": True if trigger_type == "button" else None,
- }
+ def _make_button(action_name, label, confirm_title, confirm_text, *, trigger_type="submit"):
+ url = url_for(
+ f"calendar.day.calendar_entries.calendar_entry.{action_name}",
+ calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid,
+ )
+ btn_type = "button" if trigger_type == "button" else "submit"
+ return sx_call("events-entry-option-button",
+ url=url, target=target, csrf=csrf, btn_type=btn_type,
+ action_btn=action_btn, confirm_title=confirm_title,
+ confirm_text=confirm_text, label=label,
+ is_btn=trigger_type == "button")
- buttons = []
+ buttons_html = ""
if state == "provisional":
- buttons.append(_btn_data("confirm_entry", "confirm",
- "Confirm entry?", "Are you sure you want to confirm this entry?"))
- buttons.append(_btn_data("decline_entry", "decline",
- "Decline entry?", "Are you sure you want to decline this entry?"))
+ buttons_html += _make_button(
+ "confirm_entry", "confirm",
+ "Confirm entry?", "Are you sure you want to confirm this entry?",
+ )
+ buttons_html += _make_button(
+ "decline_entry", "decline",
+ "Decline entry?", "Are you sure you want to decline this entry?",
+ )
elif state == "confirmed":
- buttons.append(_btn_data("provisional_entry", "provisional",
- "Provisional entry?", "Are you sure you want to provisional this entry?",
- trigger_type="button"))
+ buttons_html += _make_button(
+ "provisional_entry", "provisional",
+ "Provisional entry?", "Are you sure you want to provisional this entry?",
+ trigger_type="button",
+ )
- return sx_call("events-entry-options-from-data",
- entry_id=str(eid), buttons=buttons or None)
+ return sx_call("events-entry-options",
+ entry_id=str(eid), buttons=SxExpr(buttons_html))
# ---------------------------------------------------------------------------
@@ -452,7 +525,7 @@ def render_entry_tickets_config(entry, calendar, day, month, year) -> str:
# ---------------------------------------------------------------------------
def render_entry_posts_panel(entry_posts, entry, calendar, day, month, year) -> str:
- """Render associated posts list via data extraction + sx defcomp."""
+ """Render associated posts list with remove buttons and search input."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
@@ -460,46 +533,38 @@ def render_entry_posts_panel(entry_posts, entry, calendar, day, month, year) ->
cal_slug = getattr(calendar, "slug", "")
eid = entry.id
eid_s = str(eid)
- csrf_hdr = f'{{"X-CSRFToken": "{csrf}"}}'
- posts_data = []
+ posts_html = ""
if entry_posts:
+ items = ""
for ep in entry_posts:
- posts_data.append({
- "title": getattr(ep, "title", ""),
- "img": getattr(ep, "feature_image", None),
- "del-url": url_for(
- "calendar.day.calendar_entries.calendar_entry.remove_post",
- calendar_slug=cal_slug, day=day, month=month, year=year,
- entry_id=eid, post_id=getattr(ep, "id", 0),
- ),
- "csrf-hdr": csrf_hdr,
- })
+ ep_title = getattr(ep, "title", "")
+ ep_id = getattr(ep, "id", 0)
+ feat = getattr(ep, "feature_image", None)
+ img_html = (sx_call("events-post-img", src=feat, alt=ep_title)
+ if feat else sx_call("events-post-img-placeholder"))
+
+ del_url = url_for(
+ "calendar.day.calendar_entries.calendar_entry.remove_post",
+ calendar_slug=cal_slug, day=day, month=month, year=year,
+ entry_id=eid, post_id=ep_id,
+ )
+ items += sx_call("events-entry-post-item",
+ img=img_html, title=ep_title,
+ del_url=del_url, entry_id=eid_s,
+ 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")
search_url = url_for(
"calendar.day.calendar_entries.calendar_entry.search_posts",
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid,
)
- return sx_call("events-entry-posts-panel-from-data",
- entry_id=eid_s, posts=posts_data or None,
- search_url=search_url)
-
-
-# ---------------------------------------------------------------------------
-# Entry posts nav data helper (shared by nav OOB + entry nav)
-# ---------------------------------------------------------------------------
-
-def _entry_posts_nav_data(entry_posts, blog_url_fn) -> list:
- """Extract post nav data from ORM entry posts."""
- if not entry_posts:
- return []
- return [
- {"href": blog_url_fn(f"/{getattr(ep, 'slug', '')}/") if blog_url_fn else f"/{getattr(ep, 'slug', '')}/",
- "title": getattr(ep, "title", ""),
- "img": getattr(ep, "feature_image", None)}
- for ep in entry_posts
- ]
+ return sx_call("events-entry-posts-panel",
+ posts=posts_html, search_url=search_url,
+ entry_id=eid_s)
# ---------------------------------------------------------------------------
@@ -507,15 +572,28 @@ def _entry_posts_nav_data(entry_posts, blog_url_fn) -> list:
# ---------------------------------------------------------------------------
def render_entry_posts_nav_oob(entry_posts) -> str:
- """Render OOB nav for entry posts via data extraction + sx defcomp."""
+ """Render OOB nav for entry posts (scrolling menu)."""
from quart import g
styles = getattr(g, "styles", None) or {}
nav_btn = getattr(styles, "nav_button", "") if hasattr(styles, "nav_button") else styles.get("nav_button", "")
blog_url_fn = getattr(g, "blog_url", None)
- posts_data = _entry_posts_nav_data(entry_posts, blog_url_fn)
- return sx_call("events-entry-posts-nav-oob-from-data",
- nav_btn=nav_btn, posts=posts_data or None)
+ if not entry_posts:
+ return sx_call("events-entry-posts-nav-oob-empty")
+
+ items = ""
+ for ep in entry_posts:
+ slug = getattr(ep, "slug", "")
+ title = getattr(ep, "title", "")
+ feat = getattr(ep, "feature_image", None)
+ href = blog_url_fn(f"/{slug}/") if blog_url_fn else f"/{slug}/"
+ img_html = (sx_call("events-post-img", src=feat, alt=title)
+ if feat else sx_call("events-post-img-placeholder"))
+ items += sx_call("events-entry-nav-post",
+ href=href, nav_btn=nav_btn,
+ img=img_html, title=title)
+
+ return sx_call("events-entry-posts-nav-oob", items=SxExpr(items))
# ---------------------------------------------------------------------------
@@ -523,28 +601,31 @@ def render_entry_posts_nav_oob(entry_posts) -> str:
# ---------------------------------------------------------------------------
def render_day_entries_nav_oob(confirmed_entries, calendar, day_date) -> str:
- """Render OOB nav for confirmed entries via data extraction + sx defcomp."""
+ """Render OOB nav for confirmed entries in a day."""
from quart import url_for, g
styles = getattr(g, "styles", None) or {}
nav_btn = getattr(styles, "nav_button", "") if hasattr(styles, "nav_button") else styles.get("nav_button", "")
cal_slug = getattr(calendar, "slug", "")
- entries_data = []
- if confirmed_entries:
- for entry in confirmed_entries:
- 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 ""
- entries_data.append({
- "href": url_for("defpage_entry_detail", calendar_slug=cal_slug,
- year=day_date.year, month=day_date.month, day=day_date.day,
- entry_id=entry.id),
- "name": entry.name,
- "time-str": start + end,
- })
+ if not confirmed_entries:
+ return sx_call("events-day-entries-nav-oob-empty")
- return sx_call("events-day-entries-nav-oob-from-data",
- nav_btn=nav_btn, entries=entries_data or None)
+ items = ""
+ for entry in confirmed_entries:
+ href = url_for(
+ "defpage_entry_detail",
+ 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 ""
+ items += sx_call("events-day-nav-entry",
+ href=href, nav_btn=nav_btn,
+ name=entry.name, time_str=start + end)
+
+ return sx_call("events-day-entries-nav-oob", items=SxExpr(items))
# ---------------------------------------------------------------------------
@@ -552,7 +633,7 @@ def render_day_entries_nav_oob(confirmed_entries, calendar, day_date) -> str:
# ---------------------------------------------------------------------------
def render_post_nav_entries_oob(associated_entries, calendars, post) -> str:
- """Render OOB nav for associated entries and calendars via data + sx defcomp."""
+ """Render OOB nav for associated entries and calendars of a post."""
from quart import g
from shared.infrastructure.urls import events_url
@@ -560,9 +641,14 @@ def render_post_nav_entries_oob(associated_entries, calendars, post) -> str:
nav_btn = getattr(styles, "nav_button_less_pad", "") if hasattr(styles, "nav_button_less_pad") else styles.get("nav_button_less_pad", "")
has_entries = associated_entries and getattr(associated_entries, "entries", None)
+ has_items = has_entries or calendars
+
+ if not has_items:
+ return sx_call("events-post-nav-oob-empty")
+
slug = post.get("slug", "") if isinstance(post, dict) else getattr(post, "slug", "")
- entries_data = []
+ items = ""
if has_entries:
for entry in associated_entries.entries:
entry_path = (
@@ -570,31 +656,27 @@ def render_post_nav_entries_oob(associated_entries, calendars, post) -> str:
f"{entry.start_at.year}/{entry.start_at.month}/{entry.start_at.day}/"
f"entries/{entry.id}/"
)
+ href = events_url(entry_path)
time_str = entry.start_at.strftime("%b %d, %Y at %H:%M")
end_str = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else ""
- entries_data.append({
- "href": events_url(entry_path),
- "name": entry.name,
- "time-str": time_str + end_str,
- })
+ items += sx_call("events-post-nav-entry",
+ href=href, nav_btn=nav_btn,
+ name=entry.name, time_str=time_str + end_str)
- calendars_data = []
if calendars:
for cal in calendars:
cs = getattr(cal, "slug", "")
- calendars_data.append({
- "href": events_url(f"/{slug}/{cs}/"),
- "name": cal.name,
- })
+ local_href = events_url(f"/{slug}/{cs}/")
+ items += sx_call("events-post-nav-calendar",
+ href=local_href, nav_btn=nav_btn, name=cal.name)
hs = ("on load or scroll "
"if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth "
"remove .hidden from .entries-nav-arrow add .flex to .entries-nav-arrow "
"else add .hidden to .entries-nav-arrow remove .flex from .entries-nav-arrow end")
- return sx_call("events-post-nav-wrapper-from-data",
- nav_btn=nav_btn, entries=entries_data or None,
- calendars=calendars_data or None, hyperscript=hs)
+ return sx_call("events-post-nav-wrapper",
+ items=SxExpr(items), hyperscript=hs)
# ---------------------------------------------------------------------------
@@ -718,36 +800,42 @@ def _entry_admin_main_panel_html(ctx: dict) -> str:
def render_post_search_results(search_posts, search_query, page, total_pages,
entry, calendar, day, month, year) -> str:
- """Render post search results via data extraction + sx defcomp."""
+ """Render post search results (replaces _types/entry/_post_search_results.html)."""
from quart import url_for
from shared.browser.app.csrf import generate_csrf_token
csrf = generate_csrf_token()
cal_slug = getattr(calendar, "slug", "")
eid = entry.id
- post_url = url_for("calendar.day.calendar_entries.calendar_entry.add_post",
- calendar_slug=cal_slug, day=day, month=month, year=year,
- entry_id=eid)
- items_data = []
+ parts = []
for sp in search_posts:
- items_data.append({
- "post-url": post_url, "entry-id": str(eid),
- "csrf": csrf, "post-id": str(sp.id),
- "img": getattr(sp, "feature_image", None),
- "title": getattr(sp, "title", ""),
- })
+ post_url = url_for("calendar.day.calendar_entries.calendar_entry.add_post",
+ calendar_slug=cal_slug, day=day, month=month, year=year,
+ entry_id=eid)
+ feat = getattr(sp, "feature_image", None)
+ title = getattr(sp, "title", "")
+ if feat:
+ img_html = sx_call("events-post-img", src=feat, alt=title)
+ else:
+ img_html = sx_call("events-post-img-placeholder")
- has_more = page < int(total_pages)
- next_url = None
- if has_more:
+ parts.append(sx_call("events-post-search-item",
+ post_url=post_url, entry_id=str(eid), csrf=csrf,
+ post_id=str(sp.id), img=img_html, title=title))
+
+ result = "".join(parts)
+
+ if page < int(total_pages):
next_url = url_for("calendar.day.calendar_entries.calendar_entry.search_posts",
calendar_slug=cal_slug, day=day, month=month, year=year,
entry_id=eid, q=search_query, page=page + 1)
+ result += sx_call("events-post-search-sentinel",
+ page=str(page), next_url=next_url)
+ elif search_posts:
+ result += sx_call("events-post-search-end")
- return sx_call("events-post-search-results-from-data",
- items=items_data or None, page=str(page),
- next_url=next_url, has_more=has_more or None)
+ return result
# ---------------------------------------------------------------------------
@@ -758,7 +846,7 @@ def render_entry_edit_form(entry, calendar, day, month, year, day_slots) -> str:
"""Render entry edit form (replaces _types/entry/_edit.html)."""
from quart import url_for, g
from shared.browser.app.csrf import generate_csrf_token
- from .slots import _slot_options_data, _SLOT_PICKER_JS
+ from .slots import _slot_options_html, _SLOT_PICKER_JS
csrf = generate_csrf_token()
styles = getattr(g, "styles", None) or {}
@@ -774,9 +862,12 @@ def render_entry_edit_form(entry, calendar, day, month, year, day_slots) -> str:
calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid)
# Slot picker
- slots_data = _slot_options_data(day_slots, selected_slot_id=getattr(entry, "slot_id", None)) if day_slots else []
- slot_picker_html = sx_call("events-slot-picker-from-data",
- id=f"entry-slot-{eid}", slots=slots_data or None)
+ if day_slots:
+ options_html = _slot_options_html(day_slots, selected_slot_id=getattr(entry, "slot_id", None))
+ slot_picker_html = sx_call("events-slot-picker",
+ id=f"entry-slot-{eid}", options=SxExpr(options_html))
+ else:
+ slot_picker_html = sx_call("events-no-slots")
# Values
start_val = entry.start_at.strftime("%H:%M") if entry.start_at else ""
@@ -806,7 +897,7 @@ def render_entry_add_form(calendar, day, month, year, day_slots) -> str:
"""Render entry add form (replaces _types/day/_add.html)."""
from quart import url_for, g
from shared.browser.app.csrf import generate_csrf_token
- from .slots import _slot_options_data, _SLOT_PICKER_JS
+ from .slots import _slot_options_html, _SLOT_PICKER_JS
csrf = generate_csrf_token()
styles = getattr(g, "styles", None) or {}
@@ -820,9 +911,12 @@ def render_entry_add_form(calendar, day, month, year, day_slots) -> str:
calendar_slug=cal_slug, day=day, month=month, year=year)
# Slot picker
- slots_data = _slot_options_data(day_slots) if day_slots else []
- slot_picker_html = sx_call("events-slot-picker-from-data",
- id="entry-slot-new", slots=slots_data or None)
+ if day_slots:
+ options_html = _slot_options_html(day_slots)
+ slot_picker_html = sx_call("events-slot-picker",
+ id="entry-slot-new", options=SxExpr(options_html))
+ else:
+ slot_picker_html = sx_call("events-no-slots")
html = sx_call("events-entry-add-form",
post_url=post_url, csrf=csrf,
@@ -850,33 +944,34 @@ def render_entry_add_button(calendar, day, month, year) -> str:
# ---------------------------------------------------------------------------
def render_fragment_container_cards(batch, post_ids, slug_map) -> str:
- """Render container cards entries via data extraction + sx defcomp."""
+ """Render container cards entries (replaces fragments/container_cards_entries.html)."""
from shared.infrastructure.urls import events_url
- widgets_data = []
+ parts = []
for post_id in post_ids:
+ parts.append(f"")
widget_entries = batch.get(post_id, [])
- entries_data = []
- for entry in widget_entries:
- _post_slug = slug_map.get(post_id, "")
- _entry_path = (
- f"/{_post_slug}/{entry.calendar_slug}/"
- f"{entry.start_at.year}/{entry.start_at.month}/"
- f"{entry.start_at.day}/entries/{entry.id}/"
- )
- time_str = entry.start_at.strftime("%H:%M")
- if entry.end_at:
- time_str += f" \u2013 {entry.end_at.strftime('%H:%M')}"
- entries_data.append({
- "href": events_url(_entry_path),
- "name": entry.name,
- "date-str": entry.start_at.strftime("%a, %b %d"),
- "time-str": time_str,
- })
- widgets_data.append({"entries": entries_data or None})
+ if widget_entries:
+ cards_html = ""
+ for entry in widget_entries:
+ _post_slug = slug_map.get(post_id, "")
+ _entry_path = (
+ f"/{_post_slug}/{entry.calendar_slug}/"
+ f"{entry.start_at.year}/{entry.start_at.month}/"
+ f"{entry.start_at.day}/entries/{entry.id}/"
+ )
+ time_str = entry.start_at.strftime("%H:%M")
+ if entry.end_at:
+ time_str += f" \u2013 {entry.end_at.strftime('%H:%M')}"
+ cards_html += sx_call("events-frag-entry-card",
+ href=events_url(_entry_path),
+ name=entry.name,
+ date_str=entry.start_at.strftime("%a, %b %d"),
+ time_str=time_str)
+ parts.append(sx_call("events-frag-entries-widget", cards=SxExpr(cards_html)))
+ parts.append(f"")
- return sx_call("events-frag-container-cards-from-data",
- widgets=widgets_data or None)
+ return "\n".join(parts)
# ---------------------------------------------------------------------------
@@ -884,23 +979,32 @@ def render_fragment_container_cards(batch, post_ids, slug_map) -> str:
# ---------------------------------------------------------------------------
def render_fragment_account_tickets(tickets) -> str:
- """Render account page tickets via data extraction + sx defcomp."""
+ """Render account page tickets (replaces fragments/account_page_tickets.html)."""
from shared.infrastructure.urls import events_url
- tickets_data = []
if tickets:
+ items_html = ""
for ticket in tickets:
- tickets_data.append({
- "href": events_url(f"/tickets/{ticket.code}/"),
- "entry-name": ticket.entry_name,
- "date-str": ticket.entry_start_at.strftime("%d %b %Y, %H:%M"),
- "calendar-name": getattr(ticket, "calendar_name", None) or None,
- "type-name": getattr(ticket, "ticket_type_name", None) or None,
- "state": getattr(ticket, "state", ""),
- })
+ href = events_url(f"/tickets/{ticket.code}/")
+ date_str = ticket.entry_start_at.strftime("%d %b %Y, %H:%M")
+ cal_name = ""
+ if getattr(ticket, "calendar_name", None):
+ cal_name = f'· {escape(ticket.calendar_name)}'
+ type_name = ""
+ if getattr(ticket, "ticket_type_name", None):
+ type_name = f'· {escape(ticket.ticket_type_name)}'
+ badge_html = sx_call("status-pill",
+ status=getattr(ticket, "state", ""))
+ 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=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-from-data",
- tickets=tickets_data or None)
+ return sx_call("events-frag-tickets-panel", items=body)
# ---------------------------------------------------------------------------
@@ -908,18 +1012,31 @@ def render_fragment_account_tickets(tickets) -> str:
# ---------------------------------------------------------------------------
def render_fragment_account_bookings(bookings) -> str:
- """Render account page bookings via data extraction + sx defcomp."""
- bookings_data = []
+ """Render account page bookings (replaces fragments/account_page_bookings.html)."""
if bookings:
+ items_html = ""
for booking in bookings:
- bookings_data.append({
- "name": booking.name,
- "date-str": booking.start_at.strftime("%d %b %Y, %H:%M"),
- "end-time": booking.end_at.strftime("%H:%M") if getattr(booking, "end_at", None) else None,
- "calendar-name": getattr(booking, "calendar_name", None) or None,
- "cost-str": str(booking.cost) if getattr(booking, "cost", None) else None,
- "state": getattr(booking, "state", ""),
- })
+ date_str = booking.start_at.strftime("%d %b %Y, %H:%M")
+ if getattr(booking, "end_at", None):
+ date_str_extra = f'– {escape(booking.end_at.strftime("%H:%M"))}'
+ else:
+ date_str_extra = ""
+ cal_name = ""
+ if getattr(booking, "calendar_name", None):
+ cal_name = f'· {escape(booking.calendar_name)}'
+ cost_str = ""
+ if getattr(booking, "cost", None):
+ cost_str = f'· £{escape(str(booking.cost))}'
+ badge_html = sx_call("status-pill",
+ status=getattr(booking, "state", ""))
+ items_html += sx_call("events-frag-booking-item",
+ name=booking.name,
+ date_str=date_str + date_str_extra,
+ calendar_name=cal_name, cost_str=cost_str,
+ 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-from-data",
- bookings=bookings_data or None)
+ return sx_call("events-frag-bookings-panel", items=body)
diff --git a/events/sxc/pages/events.sx b/events/sxc/pages/events.sx
index 6ad48b0..130e693 100644
--- a/events/sxc/pages/events.sx
+++ b/events/sxc/pages/events.sx
@@ -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 "///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 "///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 "///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 "///slots//"
: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 "///day////entries//"
: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 "///day////entries//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 "///day////entries//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 "///day////entries//ticket-types//"
: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//"
: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 "//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"))))
diff --git a/events/sxc/pages/helpers.py b/events/sxc/pages/helpers.py
index 2925ca8..b005fbb 100644
--- a/events/sxc/pages/helpers.py
+++ b/events/sxc/pages/helpers.py
@@ -1,26 +1,13 @@
-"""Layout registrations, page helpers, and shared hydration helpers."""
+"""Layout registrations, page helpers, and shared hydration helpers.
+
+All helpers return data dicts — no sx_call().
+Markup composition lives entirely in .sx defpage and .sx defcomp files.
+"""
from __future__ import annotations
from typing import Any
-from shared.sx.helpers import sx_call
-
-from .calendar import (
- _calendar_admin_main_panel_html,
- _day_admin_main_panel_html,
- _markets_main_panel_html,
-)
-from .entries import (
- _entry_main_panel_html,
- _entry_nav_html,
- _entry_admin_main_panel_html,
-)
-from .tickets import (
- _tickets_main_panel_html, _ticket_detail_panel_html,
- _ticket_admin_main_panel_html,
- render_ticket_type_main_panel, render_ticket_types_table,
-)
-from .slots import render_slot_main_panel, render_slots_table
+from shared.sx.parser import SxExpr
# ---------------------------------------------------------------------------
@@ -261,6 +248,60 @@ def _register_events_layouts() -> None:
"events-markets-layout-full", "events-markets-layout-oob")
+# ---------------------------------------------------------------------------
+# Badge data helpers
+# ---------------------------------------------------------------------------
+
+_ENTRY_STATE_CLASSES = {
+ "confirmed": "bg-emerald-100 text-emerald-800",
+ "provisional": "bg-amber-100 text-amber-800",
+ "ordered": "bg-sky-100 text-sky-800",
+ "pending": "bg-stone-100 text-stone-700",
+ "declined": "bg-red-100 text-red-800",
+}
+
+_TICKET_STATE_CLASSES = {
+ "confirmed": "bg-emerald-100 text-emerald-800",
+ "checked_in": "bg-blue-100 text-blue-800",
+ "reserved": "bg-amber-100 text-amber-800",
+ "cancelled": "bg-red-100 text-red-800",
+}
+
+
+def _entry_badge_data(state: str) -> dict:
+ cls = _ENTRY_STATE_CLASSES.get(state, "bg-stone-100 text-stone-700")
+ label = state.replace("_", " ").capitalize()
+ return {"cls": cls, "label": label}
+
+
+def _ticket_badge_data(state: str) -> dict:
+ cls = _TICKET_STATE_CLASSES.get(state, "bg-stone-100 text-stone-700")
+ label = (state or "").replace("_", " ").capitalize()
+ return {"cls": cls, "label": label}
+
+
+# ---------------------------------------------------------------------------
+# Styles helper
+# ---------------------------------------------------------------------------
+
+def _styles_data() -> dict:
+ """Extract common style classes from g.styles."""
+ from quart import g
+ styles = getattr(g, "styles", None) or {}
+
+ def _gs(attr):
+ return getattr(styles, attr, "") if hasattr(styles, attr) else styles.get(attr, "")
+
+ return {
+ "list-container": _gs("list_container"),
+ "pre-action": _gs("pre_action_button"),
+ "action-btn": _gs("action_button"),
+ "tr-cls": _gs("tr"),
+ "pill-cls": _gs("pill"),
+ "nav-btn": _gs("nav_button"),
+ }
+
+
# ---------------------------------------------------------------------------
# Page helpers
# ---------------------------------------------------------------------------
@@ -269,141 +310,468 @@ def _register_events_helpers() -> None:
from shared.sx.pages import register_page_helpers
register_page_helpers("events", {
- "calendar-admin-content": _h_calendar_admin_content,
- "day-admin-content": _h_day_admin_content,
- "slots-content": _h_slots_content,
- "slot-content": _h_slot_content,
- "entry-content": _h_entry_content,
- "entry-menu": _h_entry_menu,
- "entry-admin-content": _h_entry_admin_content,
- "admin-menu": _h_admin_menu,
- "ticket-types-content": _h_ticket_types_content,
- "ticket-type-content": _h_ticket_type_content,
- "tickets-content": _h_tickets_content,
- "ticket-detail-content": _h_ticket_detail_content,
- "ticket-admin-content": _h_ticket_admin_content,
- "markets-content": _h_markets_content,
+ "calendar-admin-data": _h_calendar_admin_data,
+ "day-admin-data": _h_day_admin_data,
+ "slots-data": _h_slots_data,
+ "slot-data": _h_slot_data,
+ "entry-data": _h_entry_data,
+ "entry-admin-data": _h_entry_admin_data,
+ "ticket-types-data": _h_ticket_types_data,
+ "ticket-type-data": _h_ticket_type_data,
+ "tickets-data": _h_tickets_data,
+ "ticket-detail-data": _h_ticket_detail_data,
+ "ticket-admin-data": _h_ticket_admin_data,
+ "markets-data": _h_markets_data,
})
-async def _h_calendar_admin_content(calendar_slug=None, **kw):
+# ---------------------------------------------------------------------------
+# Calendar admin
+# ---------------------------------------------------------------------------
+
+async def _h_calendar_admin_data(calendar_slug=None, **kw) -> dict:
+ from quart import url_for
+ from shared.browser.app.csrf import generate_csrf_token
+
await _ensure_calendar(calendar_slug)
await _ensure_container_nav_defpage_ctx()
- from shared.sx.page import get_template_context
- ctx = await get_template_context()
- return _calendar_admin_main_panel_html(ctx)
+
+ from quart import g
+ calendar = getattr(g, "calendar", None)
+ if not calendar:
+ return {}
+
+ csrf = generate_csrf_token()
+ cal_slug = getattr(calendar, "slug", "")
+ desc = getattr(calendar, "description", "") or ""
+ desc_edit_url = url_for("calendar.admin.calendar_description_edit",
+ calendar_slug=cal_slug)
+
+ return {
+ "cal-description": desc,
+ "csrf": csrf,
+ "desc-edit-url": desc_edit_url,
+ }
-async def _h_day_admin_content(calendar_slug=None, year=None, month=None, day=None, **kw):
+# ---------------------------------------------------------------------------
+# Day admin
+# ---------------------------------------------------------------------------
+
+async def _h_day_admin_data(calendar_slug=None, year=None, month=None,
+ day=None, **kw) -> dict:
await _ensure_calendar(calendar_slug)
await _ensure_container_nav_defpage_ctx()
if year is not None:
await _ensure_day_data(int(year), int(month), int(day))
- return _day_admin_main_panel_html({})
+ return {}
-async def _h_slots_content(calendar_slug=None, **kw):
- from quart import g
+# ---------------------------------------------------------------------------
+# Slots listing
+# ---------------------------------------------------------------------------
+
+async def _h_slots_data(calendar_slug=None, **kw) -> dict:
+ from quart import g, url_for
+ from shared.browser.app.csrf import generate_csrf_token
+ from bp.slots.services.slots import list_slots as svc_list_slots
+
await _ensure_calendar(calendar_slug)
await _ensure_container_nav_defpage_ctx()
+
calendar = getattr(g, "calendar", None)
- from bp.slots.services.slots import list_slots as svc_list_slots
slots = await svc_list_slots(g.s, calendar.id) if calendar else []
_add_to_defpage_ctx(slots=slots)
- return render_slots_table(slots, calendar)
+
+ styles = _styles_data()
+ csrf = generate_csrf_token()
+ cal_slug = getattr(calendar, "slug", "")
+ hx_select = getattr(g, "hx_select_search", "#main-panel")
+ csrf_hdr = {"X-CSRFToken": csrf}
+ add_url = url_for("calendar.slots.add_form", calendar_slug=cal_slug)
+
+ slots_list = []
+ for s in slots:
+ slot_href = url_for("defpage_slot_detail",
+ calendar_slug=cal_slug, slot_id=s.id)
+ del_url = url_for("calendar.slots.slot.slot_delete",
+ calendar_slug=cal_slug, slot_id=s.id)
+ desc = getattr(s, "description", "") or ""
+ days_display = getattr(s, "days_display", "\u2014")
+ day_list = days_display.split(", ")
+ has_days = bool(day_list and day_list[0] != "\u2014")
+ time_start = s.time_start.strftime("%H:%M") if s.time_start else ""
+ time_end = s.time_end.strftime("%H:%M") if s.time_end else ""
+ cost = getattr(s, "cost", None)
+ cost_str = f"{cost:.2f}" if cost is not None else ""
+
+ slots_list.append({
+ "name": s.name,
+ "description": desc,
+ "day-list": day_list if has_days else [],
+ "has-days": has_days,
+ "flexible": "yes" if s.flexible else "no",
+ "time-str": f"{time_start} - {time_end}",
+ "cost-str": cost_str,
+ "slot-href": slot_href,
+ "del-url": del_url,
+ })
+
+ return {
+ "has-slots": bool(slots),
+ "slots-list": slots_list,
+ "add-url": add_url,
+ "csrf-hdr": csrf_hdr,
+ "hx-select": hx_select,
+ **styles,
+ }
-async def _h_slot_content(calendar_slug=None, slot_id=None, **kw):
- from quart import g, abort
+# ---------------------------------------------------------------------------
+# Slot detail
+# ---------------------------------------------------------------------------
+
+async def _h_slot_data(calendar_slug=None, slot_id=None, **kw) -> dict:
+ from quart import g, abort, url_for
+
await _ensure_calendar(calendar_slug)
await _ensure_container_nav_defpage_ctx()
+
from bp.slot.services.slot import get_slot as svc_get_slot
slot = await svc_get_slot(g.s, slot_id) if slot_id else None
if not slot:
abort(404)
g.slot = slot
_add_to_defpage_ctx(slot=slot)
+
calendar = getattr(g, "calendar", None)
- return render_slot_main_panel(slot, calendar)
+ styles = _styles_data()
+ cal_slug = getattr(calendar, "slug", "")
+
+ days_display = getattr(slot, "days_display", "\u2014")
+ day_list = days_display.split(", ")
+ has_days = bool(day_list and day_list[0] != "\u2014")
+ time_start = slot.time_start.strftime("%H:%M") if slot.time_start else ""
+ time_end = slot.time_end.strftime("%H:%M") if slot.time_end else ""
+ cost = getattr(slot, "cost", None)
+ cost_str = f"{cost:.2f}" if cost is not None else ""
+ edit_url = url_for("calendar.slots.slot.get_edit",
+ slot_id=slot.id, calendar_slug=cal_slug)
+
+ return {
+ "slot-id-str": str(slot.id),
+ "day-list": day_list if has_days else [],
+ "has-days": has_days,
+ "flexible": "yes" if getattr(slot, "flexible", False) else "no",
+ "time-str": f"{time_start} \u2014 {time_end}",
+ "cost-str": cost_str,
+ "edit-url": edit_url,
+ **styles,
+ }
-async def _h_entry_content(calendar_slug=None, entry_id=None, **kw):
+# ---------------------------------------------------------------------------
+# Entry detail (complex — sub-panels returned as SxExpr)
+# ---------------------------------------------------------------------------
+
+async def _h_entry_data(calendar_slug=None, entry_id=None, **kw) -> dict:
+ from quart import url_for, g
+ from .entries import (
+ _entry_nav_html,
+ _entry_options_html,
+ render_entry_tickets_config,
+ render_entry_posts_panel,
+ )
+ from .tickets import render_buy_form
+
await _ensure_calendar(calendar_slug)
await _ensure_entry_context(entry_id)
+
from shared.sx.page import get_template_context
ctx = await get_template_context()
- return _entry_main_panel_html(ctx)
+
+ entry = ctx.get("entry")
+ if not entry:
+ return {}
+
+ calendar = ctx.get("calendar")
+ cal_slug = getattr(calendar, "slug", "") if calendar else ""
+ day = ctx.get("day")
+ month = ctx.get("month")
+ year = ctx.get("year")
+
+ styles = _styles_data()
+ eid = entry.id
+ state = getattr(entry, "state", "pending") or "pending"
+
+ # Simple field data
+ slot = getattr(entry, "slot", None)
+ has_slot = slot is not None
+ slot_name = slot.name if slot else ""
+ flex_label = "(flexible)" if slot and getattr(slot, "flexible", False) else "(fixed)"
+ start_str = entry.start_at.strftime("%H:%M") if entry.start_at else ""
+ end_str = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else " \u2013 open-ended"
+ cost = getattr(entry, "cost", None)
+ cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
+ date_str = entry.start_at.strftime("%A, %B %d, %Y") if entry.start_at else ""
+ badge = _entry_badge_data(state)
+
+ edit_url = url_for(
+ "calendar.day.calendar_entries.calendar_entry.get_edit",
+ entry_id=eid, calendar_slug=cal_slug,
+ day=day, month=month, year=year,
+ )
+
+ # Complex sub-panels (pre-composed as SxExpr)
+ ticket_remaining = ctx.get("ticket_remaining")
+ ticket_sold_count = ctx.get("ticket_sold_count", 0)
+ user_ticket_count = ctx.get("user_ticket_count", 0)
+ user_ticket_counts_by_type = ctx.get("user_ticket_counts_by_type") or {}
+ entry_posts = ctx.get("entry_posts") or []
+
+ tickets_config = render_entry_tickets_config(entry, calendar, day, month, year)
+ buy_form = render_buy_form(
+ entry, ticket_remaining, ticket_sold_count,
+ user_ticket_count, user_ticket_counts_by_type,
+ )
+ posts_panel = render_entry_posts_panel(
+ entry_posts, entry, calendar, day, month, year,
+ )
+ options_html = _entry_options_html(entry, calendar, day, month, year)
+
+ # Entry menu (pre-composed for :menu slot)
+ entry_menu = _entry_nav_html(ctx)
+
+ return {
+ "entry-id-str": str(eid),
+ "entry-name": entry.name,
+ "has-slot": has_slot,
+ "slot-name": slot_name,
+ "flex-label": flex_label,
+ "time-str": start_str + end_str,
+ "state-badge-cls": badge["cls"],
+ "state-badge-label": badge["label"],
+ "cost-str": cost_str,
+ "date-str": date_str,
+ "edit-url": edit_url,
+ "tickets-config": SxExpr(tickets_config),
+ "buy-form": SxExpr(buy_form) if buy_form else None,
+ "posts-panel": SxExpr(posts_panel),
+ "options-html": SxExpr(options_html),
+ "entry-menu": SxExpr(entry_menu) if entry_menu else None,
+ **styles,
+ }
-async def _h_entry_menu(calendar_slug=None, entry_id=None, **kw):
- await _ensure_calendar(calendar_slug)
- await _ensure_entry_context(entry_id)
- from shared.sx.page import get_template_context
- ctx = await get_template_context()
- return _entry_nav_html(ctx)
+# ---------------------------------------------------------------------------
+# Entry admin
+# ---------------------------------------------------------------------------
+async def _h_entry_admin_data(calendar_slug=None, entry_id=None,
+ year=None, month=None, day=None, **kw) -> dict:
+ from quart import url_for, g
-async def _h_entry_admin_content(calendar_slug=None, entry_id=None, **kw):
await _ensure_calendar(calendar_slug)
await _ensure_container_nav_defpage_ctx()
await _ensure_entry_context(entry_id)
+
+ calendar = getattr(g, "calendar", None)
+ entry = getattr(g, "entry", None)
+ if not calendar or not entry:
+ return {}
+
+ cal_slug = getattr(calendar, "slug", "")
+ styles = _styles_data()
+
from shared.sx.page import get_template_context
ctx = await get_template_context()
- return _entry_admin_main_panel_html(ctx)
+ select_colours = ctx.get("select_colours", "")
+
+ 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,
+ )
+
+ return {
+ "ticket-types-href": ticket_types_href,
+ "select-colours": select_colours,
+ **styles,
+ }
-def _h_admin_menu():
- return sx_call("events-admin-placeholder-nav")
+# ---------------------------------------------------------------------------
+# Ticket types listing
+# ---------------------------------------------------------------------------
+async def _h_ticket_types_data(calendar_slug=None, entry_id=None,
+ year=None, month=None, day=None, **kw) -> dict:
+ from quart import g, url_for
+ from shared.browser.app.csrf import generate_csrf_token
-async def _h_ticket_types_content(calendar_slug=None, entry_id=None,
- year=None, month=None, day=None, **kw):
- from quart import g
await _ensure_calendar(calendar_slug)
await _ensure_entry(entry_id)
+
entry = getattr(g, "entry", None)
calendar = getattr(g, "calendar", None)
+
from bp.ticket_types.services.tickets import list_ticket_types as svc_list_ticket_types
ticket_types = await svc_list_ticket_types(g.s, entry.id) if entry else []
_add_to_defpage_ctx(ticket_types=ticket_types)
- return render_ticket_types_table(ticket_types, entry, calendar, day, month, year)
+
+ styles = _styles_data()
+ csrf = generate_csrf_token()
+ cal_slug = getattr(calendar, "slug", "")
+ hx_select = getattr(g, "hx_select_search", "#main-panel")
+ eid = entry.id if entry else 0
+ csrf_hdr = {"X-CSRFToken": csrf}
+
+ types_list = []
+ for tt in (ticket_types or []):
+ tt_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=eid, ticket_type_id=tt.id,
+ )
+ del_url = url_for(
+ "calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.delete",
+ calendar_slug=cal_slug, year=year, month=month, day=day,
+ entry_id=eid, ticket_type_id=tt.id,
+ )
+ cost = getattr(tt, "cost", None)
+ cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
+
+ types_list.append({
+ "tt-href": tt_href,
+ "tt-name": tt.name,
+ "cost-str": cost_str,
+ "count": str(tt.count),
+ "del-url": del_url,
+ })
+
+ add_url = url_for(
+ "calendar.day.calendar_entries.calendar_entry.ticket_types.add_form",
+ calendar_slug=cal_slug, entry_id=eid, year=year, month=month, day=day,
+ )
+
+ return {
+ "has-types": bool(ticket_types),
+ "types-list": types_list,
+ "add-url": add_url,
+ "csrf-hdr": csrf_hdr,
+ "hx-select": hx_select,
+ **styles,
+ }
-async def _h_ticket_type_content(calendar_slug=None, entry_id=None,
- ticket_type_id=None, year=None, month=None, day=None, **kw):
- from quart import g, abort
+# ---------------------------------------------------------------------------
+# Ticket type detail
+# ---------------------------------------------------------------------------
+
+async def _h_ticket_type_data(calendar_slug=None, entry_id=None,
+ ticket_type_id=None,
+ year=None, month=None, day=None, **kw) -> dict:
+ from quart import g, abort, url_for
+
await _ensure_calendar(calendar_slug)
await _ensure_entry(entry_id)
+
from bp.ticket_type.services.ticket import get_ticket_type as svc_get_ticket_type
ticket_type = await svc_get_ticket_type(g.s, ticket_type_id) if ticket_type_id else None
if not ticket_type:
abort(404)
g.ticket_type = ticket_type
_add_to_defpage_ctx(ticket_type=ticket_type)
+
entry = getattr(g, "entry", None)
calendar = getattr(g, "calendar", None)
- return render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year)
+ styles = _styles_data()
+ cal_slug = getattr(calendar, "slug", "")
+ cost = getattr(ticket_type, "cost", None)
+ cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00"
+ count = getattr(ticket_type, "count", 0)
+
+ edit_url = url_for(
+ "calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get_edit",
+ ticket_type_id=ticket_type.id, calendar_slug=cal_slug,
+ year=year, month=month, day=day,
+ entry_id=entry.id if entry else 0,
+ )
+
+ return {
+ "ticket-id": str(ticket_type.id),
+ "tt-name": ticket_type.name,
+ "cost-str": cost_str,
+ "count-str": str(count),
+ "edit-url": edit_url,
+ **styles,
+ }
-async def _h_tickets_content(**kw):
- from quart import g
+# ---------------------------------------------------------------------------
+# My tickets
+# ---------------------------------------------------------------------------
+
+async def _h_tickets_data(**kw) -> dict:
+ from quart import g, url_for
from shared.infrastructure.cart_identity import current_cart_identity
from bp.tickets.services.tickets import get_user_tickets
+
ident = current_cart_identity()
tickets = await get_user_tickets(
g.s,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
+
from shared.sx.page import get_template_context
ctx = await get_template_context()
- return _tickets_main_panel_html(ctx, tickets)
+ styles = ctx.get("styles") or {}
+ list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
+
+ tickets_list = []
+ for ticket in (tickets or []):
+ href = url_for("defpage_ticket_detail", code=ticket.code)
+ entry = getattr(ticket, "entry", None)
+ entry_name = entry.name if entry else "Unknown event"
+ tt = getattr(ticket, "ticket_type", None)
+ state = getattr(ticket, "state", "")
+ cal = getattr(entry, "calendar", None) if entry else None
+
+ time_str = ""
+ if entry and entry.start_at:
+ time_str = entry.start_at.strftime("%A, %B %d, %Y at %H:%M")
+ if entry.end_at:
+ time_str += f" \u2013 {entry.end_at.strftime('%H:%M')}"
+
+ badge = _ticket_badge_data(state)
+ tickets_list.append({
+ "href": href,
+ "entry-name": entry_name,
+ "type-name": tt.name if tt else None,
+ "time-str": time_str or None,
+ "cal-name": cal.name if cal else None,
+ "badge-cls": badge["cls"],
+ "badge-label": badge["label"],
+ "code-prefix": ticket.code[:8],
+ })
+
+ return {
+ "has-tickets": bool(tickets),
+ "tickets-list": tickets_list,
+ "list-container": list_container,
+ }
-async def _h_ticket_detail_content(code=None, **kw):
- from quart import g, abort
+# ---------------------------------------------------------------------------
+# Ticket detail
+# ---------------------------------------------------------------------------
+
+async def _h_ticket_detail_data(code=None, **kw) -> dict:
+ from quart import g, abort, url_for
from shared.infrastructure.cart_identity import current_cart_identity
from bp.tickets.services.tickets import get_ticket_by_code
+
ticket = await get_ticket_by_code(g.s, code) if code else None
if not ticket:
abort(404)
@@ -417,16 +785,71 @@ async def _h_ticket_detail_content(code=None, **kw):
abort(404)
else:
abort(404)
+
from shared.sx.page import get_template_context
ctx = await get_template_context()
- return _ticket_detail_panel_html(ctx, ticket)
+ styles = ctx.get("styles") or {}
+ list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
+
+ entry = getattr(ticket, "entry", None)
+ tt = getattr(ticket, "ticket_type", None)
+ state = getattr(ticket, "state", "")
+ ticket_code = ticket.code
+ cal = getattr(entry, "calendar", None) if entry else None
+ checked_in_at = getattr(ticket, "checked_in_at", None)
+
+ bg_map = {"confirmed": "bg-emerald-50", "checked_in": "bg-blue-50",
+ "reserved": "bg-amber-50"}
+ header_bg = bg_map.get(state, "bg-stone-50")
+ entry_name = entry.name if entry else "Ticket"
+ back_href = url_for("defpage_my_tickets")
+
+ badge = _ticket_badge_data(state)
+
+ time_date = entry.start_at.strftime("%A, %B %d, %Y") if entry and entry.start_at else None
+ time_range = entry.start_at.strftime("%H:%M") if entry and entry.start_at else None
+ if time_range and entry.end_at:
+ time_range += f" \u2013 {entry.end_at.strftime('%H:%M')}"
+
+ tt_desc = f"{tt.name} \u2014 \u00a3{tt.cost:.2f}" if tt and getattr(tt, "cost", None) else None
+ checkin_str = checked_in_at.strftime("Checked in: %B %d, %Y at %H:%M") if checked_in_at else None
+
+ qr_script = (
+ f"(function(){{var c=document.getElementById('ticket-qr-{ticket_code}');"
+ "if(c&&typeof QRCode!=='undefined'){"
+ "var cv=document.createElement('canvas');"
+ f"QRCode.toCanvas(cv,'{ticket_code}',{{width:200,margin:2,color:{{dark:'#1c1917',light:'#ffffff'}}}},function(e){{if(!e)c.appendChild(cv)}});"
+ "}})()"
+ )
+
+ return {
+ "list-container": list_container,
+ "back-href": back_href,
+ "header-bg": header_bg,
+ "entry-name": entry_name,
+ "badge-cls": badge["cls"],
+ "badge-label": badge["label"],
+ "type-name": tt.name if tt else None,
+ "ticket-code": ticket_code,
+ "time-date": time_date,
+ "time-range": time_range,
+ "cal-name": cal.name if cal else None,
+ "type-desc": tt_desc,
+ "checkin-str": checkin_str,
+ "qr-script": qr_script,
+ }
-async def _h_ticket_admin_content(**kw):
- from quart import g
+# ---------------------------------------------------------------------------
+# Ticket admin dashboard
+# ---------------------------------------------------------------------------
+
+async def _h_ticket_admin_data(**kw) -> dict:
+ from quart import g, url_for
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry, Ticket
+ from shared.browser.app.csrf import generate_csrf_token
result = await g.s.execute(
select(Ticket)
@@ -449,20 +872,118 @@ async def _h_ticket_admin_content(**kw):
reserved = await g.s.scalar(
select(func.count(Ticket.id)).where(Ticket.state == "reserved")
)
- stats = {
- "total": total or 0,
- "confirmed": confirmed or 0,
- "checked_in": checked_in or 0,
- "reserved": reserved or 0,
+
+ csrf = generate_csrf_token()
+ lookup_url = url_for("ticket_admin.lookup")
+
+ from shared.sx.page import get_template_context
+ ctx = await get_template_context()
+ styles = ctx.get("styles") or {}
+ list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "")
+
+ # Stats cards data
+ admin_stats = []
+ for label, key, border, bg, text_cls in [
+ ("Total", "total", "border-stone-200", "", "text-stone-900"),
+ ("Confirmed", "confirmed", "border-emerald-200", "bg-emerald-50", "text-emerald-700"),
+ ("Checked In", "checked_in", "border-blue-200", "bg-blue-50", "text-blue-700"),
+ ("Reserved", "reserved", "border-amber-200", "bg-amber-50", "text-amber-700"),
+ ]:
+ val_map = {"total": total, "confirmed": confirmed,
+ "checked_in": checked_in, "reserved": reserved}
+ val = val_map.get(key, 0) or 0
+ lbl_cls = text_cls.replace("700", "600").replace("900", "500") if "stone" not in text_cls else "text-stone-500"
+ admin_stats.append({
+ "border": border, "bg": bg, "text-cls": text_cls,
+ "label-cls": lbl_cls, "value": str(val), "label": label,
+ })
+
+ # Ticket rows data
+ admin_tickets = []
+ for ticket in tickets:
+ entry = getattr(ticket, "entry", None)
+ tt = getattr(ticket, "ticket_type", None)
+ state = getattr(ticket, "state", "")
+ tcode = ticket.code
+ checked_in_at = getattr(ticket, "checked_in_at", None)
+
+ date_str = None
+ if entry and entry.start_at:
+ date_str = entry.start_at.strftime("%d %b %Y, %H:%M")
+
+ badge = _ticket_badge_data(state)
+ can_checkin = state in ("confirmed", "reserved")
+ is_checked_in = state == "checked_in"
+ checkin_url = url_for("ticket_admin.do_checkin", code=tcode) if can_checkin else None
+ checkin_time = checked_in_at.strftime("%H:%M") if checked_in_at else ""
+
+ admin_tickets.append({
+ "code": tcode,
+ "code-short": tcode[:12] + "...",
+ "entry-name": entry.name if entry else "\u2014",
+ "date-str": date_str,
+ "type-name": tt.name if tt else "\u2014",
+ "badge-cls": badge["cls"],
+ "badge-label": badge["label"],
+ "can-checkin": can_checkin,
+ "is-checked-in": is_checked_in,
+ "checkin-url": checkin_url,
+ "checkin-time": checkin_time,
+ })
+
+ return {
+ "admin-stats": admin_stats,
+ "admin-tickets": admin_tickets,
+ "list-container": list_container,
+ "lookup-url": lookup_url,
+ "csrf": csrf,
+ "has-tickets": bool(tickets),
}
- from shared.sx.page import get_template_context
- ctx = await get_template_context()
- return _ticket_admin_main_panel_html(ctx, tickets, stats)
+# ---------------------------------------------------------------------------
+# Markets
+# ---------------------------------------------------------------------------
+
+async def _h_markets_data(**kw) -> dict:
+ from quart import url_for
+ from shared.browser.app.csrf import generate_csrf_token
+ from shared.sx.helpers import call_url
-async def _h_markets_content(**kw):
_ensure_post_defpage_ctx()
+
from shared.sx.page import get_template_context
ctx = await get_template_context()
- return _markets_main_panel_html(ctx)
+
+ 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("markets.create_market") if callable(has_access) else is_admin
+ csrf = generate_csrf_token()
+ markets_raw = ctx.get("markets") or []
+
+ post = ctx.get("post") or {}
+ slug = post.get("slug", "")
+ csrf_hdr = {"X-CSRFToken": csrf}
+
+ markets_list = []
+ for m in markets_raw:
+ m_slug = getattr(m, "slug", "") if hasattr(m, "slug") else m.get("slug", "")
+ 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)
+
+ markets_list.append({
+ "href": market_href,
+ "name": m_name,
+ "slug": m_slug,
+ "del-url": del_url,
+ "csrf-hdr": csrf_hdr,
+ })
+
+ return {
+ "can-create": can_create,
+ "create-url": url_for("markets.create_market") if can_create else None,
+ "csrf": csrf,
+ "markets-list": markets_list,
+ }
diff --git a/events/sxc/pages/slots.py b/events/sxc/pages/slots.py
index 4916590..bd95562 100644
--- a/events/sxc/pages/slots.py
+++ b/events/sxc/pages/slots.py
@@ -3,6 +3,7 @@ from __future__ import annotations
from shared.sx.helpers import sx_call
+from shared.sx.parser import SxExpr
# ===========================================================================
@@ -110,9 +111,9 @@ _SLOT_PICKER_JS = """\
# Slot options (shared by entry edit + add forms)
# ---------------------------------------------------------------------------
-def _slot_options_data(day_slots, selected_slot_id=None) -> list:
- """Extract slot option data for sx composition."""
- result = []
+def _slot_options_html(day_slots, selected_slot_id=None) -> str:
+ """Build slot