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'' + + 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'
' + f'
{day_date.day}
{entries_html}
' + ) + + 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