From f9d9697c6756ec5746739ae1c20d2ab37b0a44d4 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 28 Feb 2026 16:14:58 +0000 Subject: [PATCH] Externalize sexp to .sexpr files + render() API Replace all 676 inline sexp() string calls across 7 services with render(component_name, **kwargs) calls backed by 46 external .sexpr component definition files (587 defcomps total). - Add render() function to shared/sexp/jinja_bridge.py - Add load_service_components() helper and update load_sexp_dir() for *.sexpr - Update parser keyword regex to support HTMX hx-on::event syntax - Convert remaining inline HTML in route files to render() calls - Add shared/sexp/templates/misc.sexp for cross-service utility components Co-Authored-By: Claude Opus 4.6 --- account/sexp/auth.sexpr | 58 + account/sexp/dashboard.sexpr | 48 + account/sexp/newsletters.sexpr | 37 + account/sexp/sexp_components.py | 227 ++-- blog/bp/admin/routes.py | 3 +- blog/sexp/admin.sexpr | 178 +++ blog/sexp/cards.sexpr | 89 ++ blog/sexp/detail.sexpr | 57 + blog/sexp/editor.sexpr | 54 + blog/sexp/filters.sexpr | 65 + blog/sexp/header.sexpr | 34 + blog/sexp/index.sexpr | 72 ++ blog/sexp/nav.sexpr | 67 + blog/sexp/settings.sexpr | 113 ++ blog/sexp/sexp_components.py | 1144 +++++------------ cart/sexp/calendar.sexpr | 12 + cart/sexp/checkout.sexpr | 20 + cart/sexp/header.sexpr | 44 + cart/sexp/items.sexpr | 66 + cart/sexp/order_detail.sexpr | 53 + cart/sexp/orders.sexpr | 51 + cart/sexp/overview.sexpr | 52 + cart/sexp/sexp_components.py | 587 +++------ cart/sexp/summary.sexpr | 26 + cart/sexp/tickets.sexpr | 42 + events/bp/calendars/routes.py | 3 +- events/bp/fragments/routes.py | 51 +- events/bp/markets/routes.py | 3 +- events/sexp/admin.sexpr | 96 ++ events/sexp/calendar.sexpr | 102 ++ events/sexp/day.sexpr | 84 ++ events/sexp/entries.sexpr | 103 ++ events/sexp/header.sexpr | 46 + events/sexp/page.sexpr | 386 ++++++ events/sexp/payments.sexpr | 59 + events/sexp/sexp_components.py | 1790 ++++++++------------------- events/sexp/tickets.sexpr | 206 +++ federation/bp/social/routes.py | 3 +- federation/sexp/auth.sexpr | 52 + federation/sexp/notifications.sexpr | 25 + federation/sexp/profile.sexpr | 55 + federation/sexp/search.sexpr | 61 + federation/sexp/sexp_components.py | 548 +++----- federation/sexp/social.sexpr | 121 ++ market/bp/fragments/routes.py | 18 +- market/sexp/cards.sexpr | 105 ++ market/sexp/cart.sexpr | 44 + market/sexp/detail.sexpr | 94 ++ market/sexp/filters.sexpr | 120 ++ market/sexp/grids.sexpr | 22 + market/sexp/headers.sexpr | 38 + market/sexp/meta.sexpr | 19 + market/sexp/navigation.sexpr | 63 + market/sexp/prices.sexpr | 34 + market/sexp/sexp_components.py | 943 ++++---------- orders/sexp/checkout.sexpr | 38 + orders/sexp/detail.sexpr | 54 + orders/sexp/list.sexpr | 60 + orders/sexp/sexp_components.py | 270 ++-- shared/browser/app/errors.py | 15 +- shared/sexp/helpers.py | 89 +- shared/sexp/jinja_bridge.py | 41 +- shared/sexp/parser.py | 2 +- shared/sexp/templates/misc.sexp | 30 + 64 files changed, 5041 insertions(+), 4051 deletions(-) create mode 100644 account/sexp/auth.sexpr create mode 100644 account/sexp/dashboard.sexpr create mode 100644 account/sexp/newsletters.sexpr create mode 100644 blog/sexp/admin.sexpr create mode 100644 blog/sexp/cards.sexpr create mode 100644 blog/sexp/detail.sexpr create mode 100644 blog/sexp/editor.sexpr create mode 100644 blog/sexp/filters.sexpr create mode 100644 blog/sexp/header.sexpr create mode 100644 blog/sexp/index.sexpr create mode 100644 blog/sexp/nav.sexpr create mode 100644 blog/sexp/settings.sexpr create mode 100644 cart/sexp/calendar.sexpr create mode 100644 cart/sexp/checkout.sexpr create mode 100644 cart/sexp/header.sexpr create mode 100644 cart/sexp/items.sexpr create mode 100644 cart/sexp/order_detail.sexpr create mode 100644 cart/sexp/orders.sexpr create mode 100644 cart/sexp/overview.sexpr create mode 100644 cart/sexp/summary.sexpr create mode 100644 cart/sexp/tickets.sexpr create mode 100644 events/sexp/admin.sexpr create mode 100644 events/sexp/calendar.sexpr create mode 100644 events/sexp/day.sexpr create mode 100644 events/sexp/entries.sexpr create mode 100644 events/sexp/header.sexpr create mode 100644 events/sexp/page.sexpr create mode 100644 events/sexp/payments.sexpr create mode 100644 events/sexp/tickets.sexpr create mode 100644 federation/sexp/auth.sexpr create mode 100644 federation/sexp/notifications.sexpr create mode 100644 federation/sexp/profile.sexpr create mode 100644 federation/sexp/search.sexpr create mode 100644 federation/sexp/social.sexpr create mode 100644 market/sexp/cards.sexpr create mode 100644 market/sexp/cart.sexpr create mode 100644 market/sexp/detail.sexpr create mode 100644 market/sexp/filters.sexpr create mode 100644 market/sexp/grids.sexpr create mode 100644 market/sexp/headers.sexpr create mode 100644 market/sexp/meta.sexpr create mode 100644 market/sexp/navigation.sexpr create mode 100644 market/sexp/prices.sexpr create mode 100644 orders/sexp/checkout.sexpr create mode 100644 orders/sexp/detail.sexpr create mode 100644 orders/sexp/list.sexpr create mode 100644 shared/sexp/templates/misc.sexp diff --git a/account/sexp/auth.sexpr b/account/sexp/auth.sexpr new file mode 100644 index 0000000..530fc0a --- /dev/null +++ b/account/sexp/auth.sexpr @@ -0,0 +1,58 @@ +;; Auth page components (login, device, check email) + +(defcomp ~account-login-error (&key error) + (when error + (div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" + (raw! error)))) + +(defcomp ~account-login-form (&key error-html action csrf-token email) + (div :class "py-8 max-w-md mx-auto" + (h1 :class "text-2xl font-bold mb-6" "Sign in") + (raw! error-html) + (form :method "post" :action action :class "space-y-4" + (input :type "hidden" :name "csrf_token" :value csrf-token) + (div + (label :for "email" :class "block text-sm font-medium mb-1" "Email address") + (input :type "email" :name "email" :id "email" :value email :required true :autofocus true + :class "w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500")) + (button :type "submit" + :class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition" + "Send magic link")))) + +(defcomp ~account-device-error (&key error) + (when error + (div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" + (raw! error)))) + +(defcomp ~account-device-form (&key error-html action csrf-token code) + (div :class "py-8 max-w-md mx-auto" + (h1 :class "text-2xl font-bold mb-6" "Authorize device") + (p :class "text-stone-600 mb-4" "Enter the code shown in your terminal to sign in.") + (raw! error-html) + (form :method "post" :action action :class "space-y-4" + (input :type "hidden" :name "csrf_token" :value csrf-token) + (div + (label :for "code" :class "block text-sm font-medium mb-1" "Device code") + (input :type "text" :name "code" :id "code" :value code :placeholder "XXXX-XXXX" + :required true :autofocus true :maxlength "9" :autocomplete "off" :spellcheck "false" + :class "w-full border border-stone-300 rounded px-3 py-3 text-center text-2xl tracking-widest font-mono uppercase focus:outline-none focus:ring-2 focus:ring-stone-500")) + (button :type "submit" + :class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition" + "Authorize")))) + +(defcomp ~account-device-approved () + (div :class "py-8 max-w-md mx-auto text-center" + (h1 :class "text-2xl font-bold mb-4" "Device authorized") + (p :class "text-stone-600" "You can close this window and return to your terminal."))) + +(defcomp ~account-check-email-error (&key error) + (when error + (div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4" + (raw! error)))) + +(defcomp ~account-check-email (&key email error-html) + (div :class "py-8 max-w-md mx-auto text-center" + (h1 :class "text-2xl font-bold mb-4" "Check your email") + (p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong (raw! email)) ".") + (p :class "text-stone-500 text-sm" "Click the link in the email to sign in. The link expires in 15 minutes.") + (raw! error-html))) diff --git a/account/sexp/dashboard.sexpr b/account/sexp/dashboard.sexpr new file mode 100644 index 0000000..4f2fc7a --- /dev/null +++ b/account/sexp/dashboard.sexpr @@ -0,0 +1,48 @@ +;; Account dashboard components + +(defcomp ~account-error-banner (&key error) + (when error + (div :class "rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm" + (raw! error)))) + +(defcomp ~account-user-email (&key email) + (when email + (p :class "text-sm text-stone-500 mt-1" (raw! email)))) + +(defcomp ~account-user-name (&key name) + (when name + (p :class "text-sm text-stone-600" (raw! name)))) + +(defcomp ~account-logout-form (&key csrf-token) + (form :action "/auth/logout/" :method "post" + (input :type "hidden" :name "csrf_token" :value csrf-token) + (button :type "submit" + :class "inline-flex items-center gap-2 rounded-full border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50 transition" + (i :class "fa-solid fa-right-from-bracket text-xs") " Sign out"))) + +(defcomp ~account-label-item (&key name) + (span :class "inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60" + (raw! name))) + +(defcomp ~account-labels-section (&key items-html) + (when items-html + (div + (h2 :class "text-base font-semibold tracking-tight mb-3" "Labels") + (div :class "flex flex-wrap gap-2" (raw! items-html))))) + +(defcomp ~account-main-panel (&key error-html email-html name-html logout-html labels-html) + (div :class "w-full max-w-3xl mx-auto px-4 py-6" + (div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-8" + (raw! error-html) + (div :class "flex items-center justify-between" + (div + (h1 :class "text-xl font-semibold tracking-tight" "Account") + (raw! email-html) + (raw! name-html)) + (raw! logout-html)) + (raw! labels-html)))) + +;; Header child wrapper +(defcomp ~account-header-child (&key inner-html) + (div :id "root-header-child" :class "flex flex-col w-full items-center" + (raw! inner-html))) diff --git a/account/sexp/newsletters.sexpr b/account/sexp/newsletters.sexpr new file mode 100644 index 0000000..4896598 --- /dev/null +++ b/account/sexp/newsletters.sexpr @@ -0,0 +1,37 @@ +;; Newsletter management components + +(defcomp ~account-newsletter-desc (&key description) + (when description + (p :class "text-xs text-stone-500 mt-0.5 truncate" (raw! description)))) + +(defcomp ~account-newsletter-toggle (&key id url hdrs target cls checked knob-cls) + (div :id id :class "flex items-center" + (button :hx-post url :hx-headers hdrs :hx-target target :hx-swap "outerHTML" + :class cls :role "switch" :aria-checked checked + (span :class knob-cls)))) + +(defcomp ~account-newsletter-toggle-off (&key id url hdrs target) + (div :id id :class "flex items-center" + (button :hx-post url :hx-headers hdrs :hx-target target :hx-swap "outerHTML" + :class "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-stone-300" + :role "switch" :aria-checked "false" + (span :class "inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1")))) + +(defcomp ~account-newsletter-item (&key name desc-html toggle-html) + (div :class "flex items-center justify-between py-4 first:pt-0 last:pb-0" + (div :class "min-w-0 flex-1" + (p :class "text-sm font-medium text-stone-800" (raw! name)) + (raw! desc-html)) + (div :class "ml-4 flex-shrink-0" (raw! toggle-html)))) + +(defcomp ~account-newsletter-list (&key items-html) + (div :class "divide-y divide-stone-100" (raw! items-html))) + +(defcomp ~account-newsletter-empty () + (p :class "text-sm text-stone-500" "No newsletters available.")) + +(defcomp ~account-newsletters-panel (&key list-html) + (div :class "w-full max-w-3xl mx-auto px-4 py-6" + (div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6" + (h1 :class "text-xl font-semibold tracking-tight" "Newsletters") + (raw! list-html)))) diff --git a/account/sexp/sexp_components.py b/account/sexp/sexp_components.py index 846a683..422d0eb 100644 --- a/account/sexp/sexp_components.py +++ b/account/sexp/sexp_components.py @@ -6,14 +6,18 @@ auth pages. Called from route handlers in place of ``render_template()``. """ from __future__ import annotations +import os from typing import Any -from shared.sexp.jinja_bridge import sexp +from shared.sexp.jinja_bridge import render, load_service_components from shared.sexp.helpers import ( call_url, root_header_html, search_desktop_html, search_mobile_html, full_page, oob_page, ) +# Load account-specific .sexpr components at import time +load_service_components(os.path.dirname(os.path.dirname(__file__))) + # --------------------------------------------------------------------------- # Header helpers @@ -21,10 +25,11 @@ from shared.sexp.helpers import ( def _auth_nav_html(ctx: dict) -> str: """Auth section desktop nav items.""" - html = sexp( - '(~nav-link :href h :label "newsletters" :select-colours sc)', - h=call_url(ctx, "account_url", "/newsletters/"), - sc=ctx.get("select_colours", ""), + html = render( + "nav-link", + href=call_url(ctx, "account_url", "/newsletters/"), + label="newsletters", + select_colours=ctx.get("select_colours", ""), ) account_nav_html = ctx.get("account_nav_html", "") if account_nav_html: @@ -34,22 +39,23 @@ def _auth_nav_html(ctx: dict) -> str: def _auth_header_html(ctx: dict, *, oob: bool = False) -> str: """Build the account section header row.""" - return sexp( - '(~menu-row :id "auth-row" :level 1 :colour "sky"' - ' :link-href lh :link-label "account" :icon "fa-solid fa-user"' - ' :nav-html nh :child-id "auth-header-child" :oob oob)', - lh=call_url(ctx, "account_url", "/"), - nh=_auth_nav_html(ctx), - oob=oob, + return render( + "menu-row", + id="auth-row", level=1, colour="sky", + link_href=call_url(ctx, "account_url", "/"), + link_label="account", icon="fa-solid fa-user", + nav_html=_auth_nav_html(ctx), + child_id="auth-header-child", oob=oob, ) def _auth_nav_mobile_html(ctx: dict) -> str: """Mobile nav menu for auth section.""" - html = sexp( - '(~nav-link :href h :label "newsletters" :select-colours sc)', - h=call_url(ctx, "account_url", "/newsletters/"), - sc=ctx.get("select_colours", ""), + html = render( + "nav-link", + href=call_url(ctx, "account_url", "/newsletters/"), + label="newsletters", + select_colours=ctx.get("select_colours", ""), ) account_nav_html = ctx.get("account_nav_html", "") if account_nav_html: @@ -69,58 +75,30 @@ def _account_main_panel_html(ctx: dict) -> str: user = getattr(g, "user", None) error = ctx.get("error", "") - error_html = sexp( - '(div :class "rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm" (raw! e))', - e=error, - ) if error else "" + error_html = render("account-error-banner", error=error) if error else "" user_email_html = "" user_name_html = "" if user: - user_email_html = sexp( - '(p :class "text-sm text-stone-500 mt-1" (raw! e))', - e=user.email, - ) + user_email_html = render("account-user-email", email=user.email) if user.name: - user_name_html = sexp( - '(p :class "text-sm text-stone-600" (raw! n))', - n=user.name, - ) + user_name_html = render("account-user-name", name=user.name) - logout_html = sexp( - '(form :action "/auth/logout/" :method "post"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (button :type "submit"' - ' :class "inline-flex items-center gap-2 rounded-full border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50 transition"' - ' (i :class "fa-solid fa-right-from-bracket text-xs") " Sign out"))', - csrf=generate_csrf_token(), - ) + logout_html = render("account-logout-form", csrf_token=generate_csrf_token()) labels_html = "" if user and hasattr(user, "labels") and user.labels: label_items = "".join( - sexp( - '(span :class "inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60" (raw! n))', - n=label.name, - ) + render("account-label-item", name=label.name) for label in user.labels ) - labels_html = sexp( - '(div (h2 :class "text-base font-semibold tracking-tight mb-3" "Labels")' - ' (div :class "flex flex-wrap gap-2" (raw! items)))', - items=label_items, - ) + labels_html = render("account-labels-section", items_html=label_items) - return sexp( - '(div :class "w-full max-w-3xl mx-auto px-4 py-6"' - ' (div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-8"' - ' (raw! err)' - ' (div :class "flex items-center justify-between"' - ' (div (h1 :class "text-xl font-semibold tracking-tight" "Account") (raw! email) (raw! name))' - ' (raw! logout))' - ' (raw! labels)))', - err=error_html, email=user_email_html, name=user_name_html, - logout=logout_html, labels=labels_html, + return render( + "account-main-panel", + error_html=error_html, email_html=user_email_html, + name_html=user_name_html, logout_html=logout_html, + labels_html=labels_html, ) @@ -140,31 +118,24 @@ def _newsletter_toggle_html(un: Any, account_url_fn: Any, csrf_token: str) -> st bg = "bg-stone-300" translate = "translate-x-1" checked = "false" - return sexp( - '(div :id id :class "flex items-center"' - ' (button :hx-post url :hx-headers hdrs :hx-target tgt :hx-swap "outerHTML"' - ' :class cls :role "switch" :aria-checked checked' - ' (span :class knob)))', + return render( + "account-newsletter-toggle", id=f"nl-{nid}", url=toggle_url, hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}', - tgt=f"#nl-{nid}", + 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, - knob=f"inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}", + knob_cls=f"inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}", ) def _newsletter_toggle_off_html(nid: int, toggle_url: str, csrf_token: str) -> str: """Render an unsubscribed newsletter toggle (no subscription record yet).""" - return sexp( - '(div :id id :class "flex items-center"' - ' (button :hx-post url :hx-headers hdrs :hx-target tgt :hx-swap "outerHTML"' - ' :class "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-stone-300"' - ' :role "switch" :aria-checked "false"' - ' (span :class "inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1")))', + return render( + "account-newsletter-toggle-off", id=f"nl-{nid}", url=toggle_url, hdrs=f'{{"X-CSRFToken": "{csrf_token}"}}', - tgt=f"#nl-{nid}", + target=f"#nl-{nid}", ) @@ -181,9 +152,8 @@ def _newsletters_panel_html(ctx: dict, newsletter_list: list) -> str: nl = item["newsletter"] un = item.get("un") - desc_html = sexp( - '(p :class "text-xs text-stone-500 mt-0.5 truncate" (raw! d))', - d=nl.description, + desc_html = render( + "account-newsletter-desc", description=nl.description ) if nl.description else "" if un: @@ -192,28 +162,18 @@ def _newsletters_panel_html(ctx: dict, newsletter_list: list) -> str: toggle_url = account_url_fn(f"/newsletter/{nl.id}/toggle/") toggle = _newsletter_toggle_off_html(nl.id, toggle_url, csrf) - items.append(sexp( - '(div :class "flex items-center justify-between py-4 first:pt-0 last:pb-0"' - ' (div :class "min-w-0 flex-1"' - ' (p :class "text-sm font-medium text-stone-800" (raw! name))' - ' (raw! desc))' - ' (div :class "ml-4 flex-shrink-0" (raw! toggle)))', - name=nl.name, desc=desc_html, toggle=toggle, + items.append(render( + "account-newsletter-item", + name=nl.name, desc_html=desc_html, toggle_html=toggle, )) - list_html = sexp( - '(div :class "divide-y divide-stone-100" (raw! items))', - items="".join(items), + list_html = render( + "account-newsletter-list", + items_html="".join(items), ) else: - list_html = sexp('(p :class "text-sm text-stone-500" "No newsletters available.")') + list_html = render("account-newsletter-empty") - return sexp( - '(div :class "w-full max-w-3xl mx-auto px-4 py-6"' - ' (div :class "bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6"' - ' (h1 :class "text-xl font-semibold tracking-tight" "Newsletters")' - ' (raw! list)))', - list=list_html, - ) + return render("account-newsletters-panel", list_html=list_html) # --------------------------------------------------------------------------- @@ -229,26 +189,12 @@ def _login_page_content(ctx: dict) -> str: email = ctx.get("email", "") action = url_for("auth.start_login") - error_html = sexp( - '(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" (raw! e))', - e=error, - ) if error else "" + error_html = render("account-login-error", error=error) if error else "" - return sexp( - '(div :class "py-8 max-w-md mx-auto"' - ' (h1 :class "text-2xl font-bold mb-6" "Sign in")' - ' (raw! err)' - ' (form :method "post" :action action :class "space-y-4"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (div' - ' (label :for "email" :class "block text-sm font-medium mb-1" "Email address")' - ' (input :type "email" :name "email" :id "email" :value email :required true :autofocus true' - ' :class "w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"))' - ' (button :type "submit"' - ' :class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"' - ' "Send magic link")))', - err=error_html, action=action, - csrf=generate_csrf_token(), email=email, + return render( + "account-login-form", + error_html=error_html, action=action, + csrf_token=generate_csrf_token(), email=email, ) @@ -261,38 +207,18 @@ def _device_page_content(ctx: dict) -> str: code = ctx.get("code", "") action = url_for("auth.device_submit") - error_html = sexp( - '(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" (raw! e))', - e=error, - ) if error else "" + error_html = render("account-device-error", error=error) if error else "" - return sexp( - '(div :class "py-8 max-w-md mx-auto"' - ' (h1 :class "text-2xl font-bold mb-6" "Authorize device")' - ' (p :class "text-stone-600 mb-4" "Enter the code shown in your terminal to sign in.")' - ' (raw! err)' - ' (form :method "post" :action action :class "space-y-4"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (div' - ' (label :for "code" :class "block text-sm font-medium mb-1" "Device code")' - ' (input :type "text" :name "code" :id "code" :value code :placeholder "XXXX-XXXX"' - ' :required true :autofocus true :maxlength "9" :autocomplete "off" :spellcheck "false"' - ' :class "w-full border border-stone-300 rounded px-3 py-3 text-center text-2xl tracking-widest font-mono uppercase focus:outline-none focus:ring-2 focus:ring-stone-500"))' - ' (button :type "submit"' - ' :class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"' - ' "Authorize")))', - err=error_html, action=action, - csrf=generate_csrf_token(), code=code, + return render( + "account-device-form", + error_html=error_html, action=action, + csrf_token=generate_csrf_token(), code=code, ) def _device_approved_content() -> str: """Device approved success content.""" - return sexp( - '(div :class "py-8 max-w-md mx-auto text-center"' - ' (h1 :class "text-2xl font-bold mb-4" "Device authorized")' - ' (p :class "text-stone-600" "You can close this window and return to your terminal."))', - ) + return render("account-device-approved") # --------------------------------------------------------------------------- @@ -304,10 +230,7 @@ async def render_account_page(ctx: dict) -> str: main = _account_main_panel_html(ctx) hdr = root_header_html(ctx) - hdr += sexp( - '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a))', - a=_auth_header_html(ctx), - ) + hdr += render("account-header-child", inner_html=_auth_header_html(ctx)) return full_page(ctx, header_rows_html=hdr, content_html=main, @@ -337,10 +260,7 @@ async def render_newsletters_page(ctx: dict, newsletter_list: list) -> str: main = _newsletters_panel_html(ctx, newsletter_list) hdr = root_header_html(ctx) - hdr += sexp( - '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a))', - a=_auth_header_html(ctx), - ) + hdr += render("account-header-child", inner_html=_auth_header_html(ctx)) return full_page(ctx, header_rows_html=hdr, content_html=main, @@ -368,10 +288,7 @@ async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str: async def render_fragment_page(ctx: dict, page_fragment_html: str) -> str: """Full page: fragment-provided content.""" hdr = root_header_html(ctx) - hdr += sexp( - '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a))', - a=_auth_header_html(ctx), - ) + hdr += render("account-header-child", inner_html=_auth_header_html(ctx)) return full_page(ctx, header_rows_html=hdr, content_html=page_fragment_html, @@ -426,18 +343,13 @@ def _check_email_content(email: str, email_error: str | None = None) -> str: """Check email confirmation content.""" from markupsafe import escape - error_html = sexp( - '(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4" (raw! e))', - e=str(escape(email_error)), + error_html = render( + "account-check-email-error", error=str(escape(email_error)) ) if email_error else "" - return sexp( - '(div :class "py-8 max-w-md mx-auto text-center"' - ' (h1 :class "text-2xl font-bold mb-4" "Check your email")' - ' (p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong (raw! email)) ".")' - ' (p :class "text-stone-500 text-sm" "Click the link in the email to sign in. The link expires in 15 minutes.")' - ' (raw! err))', - email=str(escape(email)), err=error_html, + return render( + "account-check-email", + email=str(escape(email)), error_html=error_html, ) @@ -468,7 +380,6 @@ def render_newsletter_toggle(un) -> str: from quart import g account_url_fn = getattr(g, "_account_url", None) if account_url_fn is None: - # Fallback: construct URL directly from shared.infrastructure.urls import account_url account_url_fn = account_url return _newsletter_toggle_html(un, account_url_fn, generate_csrf_token()) diff --git a/blog/bp/admin/routes.py b/blog/bp/admin/routes.py index b7137e0..6189538 100644 --- a/blog/bp/admin/routes.py +++ b/blog/bp/admin/routes.py @@ -59,7 +59,8 @@ def register(url_prefix): await clear_all_cache() if is_htmx_request(): now = datetime.now() - html = f'Cache cleared at {now.strftime("%H:%M:%S")}' + from shared.sexp.jinja_bridge import render as render_comp + html = render_comp("cache-cleared", time_str=now.strftime("%H:%M:%S")) return html return redirect(url_for("settings.cache")) diff --git a/blog/sexp/admin.sexpr b/blog/sexp/admin.sexpr new file mode 100644 index 0000000..6521296 --- /dev/null +++ b/blog/sexp/admin.sexpr @@ -0,0 +1,178 @@ +;; Blog admin panel components + +(defcomp ~blog-cache-panel (&key clear-url csrf) + (div :class "max-w-2xl mx-auto px-4 py-6 space-y-6" + (div :class "flex flex-col md:flex-row gap-3 items-start" + (form :hx-post clear-url :hx-trigger "submit" :hx-target "#cache-status" :hx-swap "innerHTML" + (input :type "hidden" :name "csrf_token" :value csrf) + (button :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" :type "submit" "Clear cache")) + (div :id "cache-status" :class "py-2")))) + +(defcomp ~blog-snippets-panel (&key list-html) + (div :class "max-w-4xl mx-auto p-6" + (div :class "mb-6 flex justify-between items-center" + (h1 :class "text-3xl font-bold" "Snippets")) + (div :id "snippets-list" (raw! list-html)))) + +(defcomp ~blog-snippets-empty () + (div :class "bg-white rounded-lg shadow" + (div :class "p-8 text-center text-stone-400" + (i :class "fa fa-puzzle-piece text-4xl mb-2") + (p "No snippets yet. Create one from the blog editor.")))) + +(defcomp ~blog-snippet-visibility-select (&key patch-url hx-headers options-html cls) + (select :name "visibility" :hx-patch patch-url :hx-target "#snippets-list" :hx-swap "innerHTML" + :hx-headers hx-headers :class "text-sm border border-stone-300 rounded px-2 py-1" + (raw! options-html))) + +(defcomp ~blog-snippet-option (&key value selected label) + (option :value value :selected selected label)) + +(defcomp ~blog-snippet-delete-button (&key confirm-text delete-url hx-headers) + (button :type "button" :data-confirm "" :data-confirm-title "Delete snippet?" + :data-confirm-text confirm-text :data-confirm-icon "warning" + :data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel" + :data-confirm-event "confirmed" + :hx-delete delete-url :hx-trigger "confirmed" :hx-target "#snippets-list" :hx-swap "innerHTML" + :hx-headers hx-headers + :class "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0" + (i :class "fa fa-trash") " Delete")) + +(defcomp ~blog-snippet-row (&key name owner badge-cls visibility extra-html) + (div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition" + (div :class "flex-1 min-w-0" + (div :class "font-medium truncate" name) + (div :class "text-xs text-stone-500" owner)) + (span :class (str "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium " badge-cls) visibility) + (raw! extra-html))) + +(defcomp ~blog-snippets-list (&key rows-html) + (div :class "bg-white rounded-lg shadow" (div :class "divide-y" (raw! rows-html)))) + +(defcomp ~blog-menu-items-panel (&key new-url list-html) + (div :class "max-w-4xl mx-auto p-6" + (div :class "mb-6 flex justify-end items-center" + (button :type "button" :hx-get new-url :hx-target "#menu-item-form" :hx-swap "innerHTML" + :class "px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700" + (i :class "fa fa-plus") " Add Menu Item")) + (div :id "menu-item-form" :class "mb-6") + (div :id "menu-items-list" (raw! list-html)))) + +(defcomp ~blog-menu-items-empty () + (div :class "bg-white rounded-lg shadow" + (div :class "p-8 text-center text-stone-400" + (i :class "fa fa-inbox text-4xl mb-2") + (p "No menu items yet. Add one to get started!")))) + +(defcomp ~blog-menu-item-image (&key src label) + (if src (img :src src :alt label :class "w-12 h-12 rounded-full object-cover flex-shrink-0") + (div :class "w-12 h-12 rounded-full bg-stone-200 flex-shrink-0"))) + +(defcomp ~blog-menu-item-row (&key img-html label slug sort-order edit-url delete-url confirm-text hx-headers) + (div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition" + (div :class "text-stone-400 cursor-move" (i :class "fa fa-grip-vertical")) + (raw! img-html) + (div :class "flex-1 min-w-0" + (div :class "font-medium truncate" label) + (div :class "text-xs text-stone-500 truncate" slug)) + (div :class "text-sm text-stone-500" (str "Order: " sort-order)) + (div :class "flex gap-2 flex-shrink-0" + (button :type "button" :hx-get edit-url :hx-target "#menu-item-form" :hx-swap "innerHTML" + :class "px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded" + (i :class "fa fa-edit") " Edit") + (button :type "button" :data-confirm "" :data-confirm-title "Delete menu item?" + :data-confirm-text confirm-text :data-confirm-icon "warning" + :data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel" + :data-confirm-event "confirmed" + :hx-delete delete-url :hx-trigger "confirmed" :hx-target "#menu-items-list" :hx-swap "innerHTML" + :hx-headers hx-headers + :class "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800" + (i :class "fa fa-trash") " Delete")))) + +(defcomp ~blog-menu-items-list (&key rows-html) + (div :class "bg-white rounded-lg shadow" (div :class "divide-y" (raw! rows-html)))) + +;; Tag groups admin + +(defcomp ~blog-tag-groups-create-form (&key create-url csrf) + (form :method "post" :action create-url :class "border rounded p-4 bg-white space-y-3" + (input :type "hidden" :name "csrf_token" :value csrf) + (h3 :class "text-sm font-semibold text-stone-700" "New Group") + (div :class "flex flex-col sm:flex-row gap-3" + (input :type "text" :name "name" :placeholder "Group name" :required "" :class "flex-1 border rounded px-3 py-2 text-sm") + (input :type "text" :name "colour" :placeholder "#colour" :class "w-28 border rounded px-3 py-2 text-sm") + (input :type "number" :name "sort_order" :placeholder "Order" :value "0" :class "w-20 border rounded px-3 py-2 text-sm")) + (input :type "text" :name "feature_image" :placeholder "Image URL (optional)" :class "w-full border rounded px-3 py-2 text-sm") + (button :type "submit" :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" "Create"))) + +(defcomp ~blog-tag-group-icon-image (&key src name) + (img :src src :alt name :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0")) + +(defcomp ~blog-tag-group-icon-color (&key style initial) + (div :class "h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0" + :style style initial)) + +(defcomp ~blog-tag-group-li (&key icon-html edit-href name slug sort-order) + (li :class "border rounded p-3 bg-white flex items-center gap-3" + (raw! icon-html) + (div :class "flex-1" + (a :href edit-href :class "font-medium text-stone-800 hover:underline" name) + (span :class "text-xs text-stone-500 ml-2" slug)) + (span :class "text-xs text-stone-500" (str "order: " sort-order)))) + +(defcomp ~blog-tag-groups-list (&key items-html) + (ul :class "space-y-2" (raw! items-html))) + +(defcomp ~blog-tag-groups-empty () + (p :class "text-stone-500 text-sm" "No tag groups yet.")) + +(defcomp ~blog-unassigned-tag (&key name) + (span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200 rounded" name)) + +(defcomp ~blog-unassigned-tags (&key heading spans-html) + (div :class "border-t pt-4" + (h3 :class "text-sm font-semibold text-stone-700 mb-2" heading) + (div :class "flex flex-wrap gap-2" (raw! spans-html)))) + +(defcomp ~blog-tag-groups-main (&key form-html groups-html unassigned-html) + (div :class "max-w-2xl mx-auto px-4 py-6 space-y-8" + (raw! form-html) (raw! groups-html) (raw! unassigned-html))) + +;; Tag group edit + +(defcomp ~blog-tag-checkbox (&key tag-id checked img-html name) + (label :class "flex items-center gap-2 px-2 py-1 hover:bg-stone-50 rounded text-sm cursor-pointer" + (input :type "checkbox" :name "tag_ids" :value tag-id :checked checked :class "rounded border-stone-300") + (raw! img-html) (span name))) + +(defcomp ~blog-tag-checkbox-image (&key src) + (img :src src :alt "" :class "h-4 w-4 rounded-full object-cover")) + +(defcomp ~blog-tag-group-edit-form (&key save-url csrf name colour sort-order feature-image tags-html) + (form :method "post" :action save-url :class "border rounded p-4 bg-white space-y-4" + (input :type "hidden" :name "csrf_token" :value csrf) + (div :class "space-y-3" + (div (label :class "block text-xs font-medium text-stone-600 mb-1" "Name") + (input :type "text" :name "name" :value name :required "" :class "w-full border rounded px-3 py-2 text-sm")) + (div :class "flex gap-3" + (div :class "flex-1" (label :class "block text-xs font-medium text-stone-600 mb-1" "Colour") + (input :type "text" :name "colour" :value colour :placeholder "#hex" :class "w-full border rounded px-3 py-2 text-sm")) + (div :class "w-24" (label :class "block text-xs font-medium text-stone-600 mb-1" "Order") + (input :type "number" :name "sort_order" :value sort-order :class "w-full border rounded px-3 py-2 text-sm"))) + (div (label :class "block text-xs font-medium text-stone-600 mb-1" "Feature Image URL") + (input :type "text" :name "feature_image" :value feature-image :placeholder "https://..." :class "w-full border rounded px-3 py-2 text-sm"))) + (div (label :class "block text-xs font-medium text-stone-600 mb-2" "Assign Tags") + (div :class "grid grid-cols-1 sm:grid-cols-2 gap-1 max-h-64 overflow-y-auto border rounded p-2" + (raw! tags-html))) + (div :class "flex gap-3" + (button :type "submit" :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" "Save")))) + +(defcomp ~blog-tag-group-delete-form (&key delete-url csrf) + (form :method "post" :action delete-url :class "border-t pt-4" + :onsubmit "return confirm('Delete this tag group? Tags will not be deleted.')" + (input :type "hidden" :name "csrf_token" :value csrf) + (button :type "submit" :class "border rounded px-4 py-2 bg-red-600 text-white text-sm" "Delete Group"))) + +(defcomp ~blog-tag-group-edit-main (&key edit-form-html delete-form-html) + (div :class "max-w-2xl mx-auto px-4 py-6 space-y-6" + (raw! edit-form-html) (raw! delete-form-html))) diff --git a/blog/sexp/cards.sexpr b/blog/sexp/cards.sexpr new file mode 100644 index 0000000..0a12c3a --- /dev/null +++ b/blog/sexp/cards.sexpr @@ -0,0 +1,89 @@ +;; Blog card components + +(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 :hx-post like-url :hx-swap "outerHTML" + :hx-headers hx-headers :class "cursor-pointer" heart))) + +(defcomp ~blog-draft-status (&key publish-requested timestamp) + (<> (div :class "flex justify-center gap-2 mt-1" + (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800" "Draft") + (when publish-requested (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested"))) + (when timestamp (p :class "text-sm text-stone-500" (str "Updated: " timestamp))))) + +(defcomp ~blog-published-status (&key timestamp) + (p :class "text-sm text-stone-500" (str "Published: " timestamp))) + +(defcomp ~blog-card (&key like-html href hx-select title status-html feature-image excerpt widget-html at-bar-html) + (article :class "border-b pb-6 last:border-b-0 relative" + (raw! like-html) + (a :href href :hx-get href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" + :class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden" + (header :class "mb-2 text-center" + (h2 :class "text-4xl font-bold text-stone-900" title) + (raw! status-html)) + (when feature-image (div :class "mb-4" (img :src feature-image :alt "" :class "rounded-lg w-full object-cover"))) + (when excerpt (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt))) + (when widget-html (raw! widget-html)) + (raw! at-bar-html))) + +(defcomp ~blog-card-tile (&key href hx-select feature-image title status-html excerpt at-bar-html) + (article :class "relative" + (a :href href :hx-get href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" + :class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden" + (when feature-image (div (img :src feature-image :alt "" :class "w-full aspect-video object-cover"))) + (div :class "p-3 text-center" + (h2 :class "text-lg font-bold text-stone-900" title) + (raw! status-html) + (when excerpt (p :class "text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1" excerpt)))) + (raw! at-bar-html))) + +(defcomp ~blog-tag-icon-image (&key src name) + (img :src src :alt name :class "h-4 w-4 rounded-full object-cover border border-stone-300 flex-shrink-0")) + +(defcomp ~blog-tag-icon-initial (&key initial) + (div :class "h-4 w-4 rounded-full text-[8px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0 bg-stone-200 text-stone-600" initial)) + +(defcomp ~blog-tag-li (&key icon-html name) + (li (a :class "flex items-center gap-1" (raw! icon-html) + (span :class "inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200" name)))) + +(defcomp ~blog-tag-bar (&key items-html) + (div :class "mt-4 flex items-center gap-2" (div "in") + (ul :class "flex flex-wrap gap-2 text-sm" (raw! items-html)))) + +(defcomp ~blog-author-with-image (&key image name) + (li :class "flex items-center gap-1" + (img :src image :alt name :class "h-5 w-5 rounded-full object-cover") + (span :class "text-stone-700" name))) + +(defcomp ~blog-author-text (&key name) + (li :class "text-stone-700" name)) + +(defcomp ~blog-author-bar (&key items-html) + (div :class "mt-4 flex items-center gap-2" (div "by") + (ul :class "flex flex-wrap gap-2 text-sm" (raw! items-html)))) + +(defcomp ~blog-at-bar (&key tag-items-html author-items-html) + (div :class "flex flex-row justify-center gap-3" + (raw! tag-items-html) (div) (raw! author-items-html))) + +(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" + (i :class "fa fa-calendar mr-1") "Calendar")) + (when has-market (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800" + (i :class "fa fa-shopping-bag mr-1") "Market")))) + +(defcomp ~blog-page-card (&key href hx-select title badges-html pub-html feature-image excerpt) + (article :class "border-b pb-6 last:border-b-0 relative" + (a :href href :hx-get href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" + :class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden" + (header :class "mb-2 text-center" + (h2 :class "text-4xl font-bold text-stone-900" title) + (raw! badges-html) (raw! pub-html)) + (when feature-image (div :class "mb-4" (img :src feature-image :alt "" :class "rounded-lg w-full object-cover"))) + (when excerpt (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt))))) diff --git a/blog/sexp/detail.sexpr b/blog/sexp/detail.sexpr new file mode 100644 index 0000000..3f351f7 --- /dev/null +++ b/blog/sexp/detail.sexpr @@ -0,0 +1,57 @@ +;; Blog post detail components + +(defcomp ~blog-detail-edit-link (&key href hx-select) + (a :href href :hx-get href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" + :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-stone-700 text-white hover:bg-stone-800 transition-colors" + (i :class "fa fa-pencil mr-1") " Edit")) + +(defcomp ~blog-detail-draft (&key publish-requested edit-html) + (div :class "flex items-center justify-center gap-2 mb-3" + (span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-amber-100 text-amber-800" "Draft") + (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")) + (raw! edit-html))) + +(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 :hx-post like-url :hx-swap "outerHTML" + :hx-headers hx-headers :class "cursor-pointer" heart))) + +(defcomp ~blog-detail-excerpt (&key excerpt) + (div :class "w-full text-center italic text-3xl p-2" excerpt)) + +(defcomp ~blog-detail-chrome (&key like-html excerpt-html at-bar-html) + (<> (raw! like-html) (raw! excerpt-html) (div :class "hidden md:block" (raw! at-bar-html)))) + +(defcomp ~blog-detail-main (&key draft-html chrome-html feature-image html-content) + (<> (article :class "relative" + (raw! draft-html) (raw! chrome-html) + (when feature-image (div :class "mb-3 flex justify-center" + (img :src feature-image :alt "" :class "rounded-lg w-full md:w-3/4 object-cover"))) + (when html-content (div :class "blog-content p-2" (raw! html-content)))) + (div :class "pb-8"))) + +(defcomp ~blog-meta (&key robots base-title desc canonical og-type og-title image twitter-card twitter-title) + (<> + (meta :name "robots" :content robots) + (title base-title) + (meta :name "description" :content desc) + (when canonical (link :rel "canonical" :href canonical)) + (meta :property "og:type" :content og-type) + (meta :property "og:title" :content og-title) + (meta :property "og:description" :content desc) + (when canonical (meta :property "og:url" :content canonical)) + (when image (meta :property "og:image" :content image)) + (meta :name "twitter:card" :content twitter-card) + (meta :name "twitter:title" :content twitter-title) + (meta :name "twitter:description" :content desc) + (when image (meta :name "twitter:image" :content image)))) + +(defcomp ~blog-home-main (&key html-content) + (article :class "relative" (div :class "blog-content p-2" (raw! html-content)))) + +(defcomp ~blog-admin-empty () + (div :class "pb-8")) + +(defcomp ~blog-settings-empty () + (div :class "max-w-2xl mx-auto px-4 py-6")) diff --git a/blog/sexp/editor.sexpr b/blog/sexp/editor.sexpr new file mode 100644 index 0000000..979cac5 --- /dev/null +++ b/blog/sexp/editor.sexpr @@ -0,0 +1,54 @@ +;; Blog editor components + +(defcomp ~blog-editor-error (&key error) + (div :class "max-w-[768px] mx-auto mt-[16px] rounded-[8px] border border-red-300 bg-red-50 px-[16px] py-[12px] text-[14px] text-red-700" + (strong "Save failed:") " " error)) + +(defcomp ~blog-editor-form (&key csrf title-placeholder create-label) + (form :id "post-new-form" :method "post" :class "max-w-[768px] mx-auto pb-[48px]" + (input :type "hidden" :name "csrf_token" :value csrf) + (input :type "hidden" :id "lexical-json-input" :name "lexical" :value "") + (input :type "hidden" :id "feature-image-input" :name "feature_image" :value "") + (input :type "hidden" :id "feature-image-caption-input" :name "feature_image_caption" :value "") + (div :id "feature-image-container" :class "relative mt-[16px] mb-[24px] group" + (div :id "feature-image-empty" + (button :type "button" :id "feature-image-add-btn" + :class "text-[14px] text-stone-400 hover:text-stone-600 transition-colors cursor-pointer" + "+ Add feature image")) + (div :id "feature-image-filled" :class "relative hidden" + (img :id "feature-image-preview" :src "" :alt "" + :class "w-full max-h-[448px] object-cover rounded-[8px] cursor-pointer") + (button :type "button" :id "feature-image-delete-btn" + :class "absolute top-[8px] right-[8px] w-[32px] h-[32px] rounded-full bg-black/50 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/70 cursor-pointer text-[14px]" + :title "Remove feature image" + (i :class "fa-solid fa-trash-can")) + (input :type "text" :id "feature-image-caption" :value "" + :placeholder "Add a caption..." + :class "mt-[8px] w-full text-[14px] text-stone-500 bg-transparent border-none outline-none placeholder:text-stone-300 focus:text-stone-700")) + (div :id "feature-image-uploading" + :class "hidden flex items-center gap-[8px] mt-[8px] text-[14px] text-stone-400" + (i :class "fa-solid fa-spinner fa-spin") " Uploading...") + (input :type "file" :id "feature-image-file" + :accept "image/jpeg,image/png,image/gif,image/webp,image/svg+xml" :class "hidden")) + (input :type "text" :name "title" :value "" :placeholder title-placeholder + :class "w-full text-[36px] font-bold bg-transparent border-none outline-none placeholder:text-stone-300 mb-[8px] leading-tight") + (textarea :name "custom_excerpt" :rows "1" :placeholder "Add an excerpt..." + :class "w-full text-[18px] text-stone-500 bg-transparent border-none outline-none placeholder:text-stone-300 resize-none mb-[24px] leading-relaxed") + (div :id "lexical-editor" :class "relative w-full bg-transparent") + (div :class "flex items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200" + (select :name "status" + :class "text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600" + (option :value "draft" :selected t "Draft") + (option :value "published" "Published")) + (button :type "submit" + :class "px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px] hover:bg-stone-800 transition-colors cursor-pointer" create-label)))) + +(defcomp ~blog-editor-styles (&key css-href) + (<> (link :rel "stylesheet" :href css-href) + (style + "#lexical-editor { display: flow-root; }" + "#lexical-editor [data-kg-card=\"html\"] * { float: none !important; }" + "#lexical-editor [data-kg-card=\"html\"] table { width: 100% !important; }"))) + +(defcomp ~blog-editor-scripts (&key js-src init-js) + (<> (script :src js-src) (script (raw! init-js)))) diff --git a/blog/sexp/filters.sexpr b/blog/sexp/filters.sexpr new file mode 100644 index 0000000..d853499 --- /dev/null +++ b/blog/sexp/filters.sexpr @@ -0,0 +1,65 @@ +;; Blog filter components + +(defcomp ~blog-action-button (&key href hx-select btn-class title icon-class label) + (a :href href :hx-get href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" + :class btn-class :title title (i :class icon-class) label)) + +(defcomp ~blog-drafts-button (&key href hx-select btn-class title label draft-count) + (a :href href :hx-get href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" + :class btn-class :title title (i :class "fa fa-file-text-o mr-1") " Drafts " + (span :class "inline-block bg-stone-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1" draft-count))) + +(defcomp ~blog-drafts-button-amber (&key href hx-select btn-class title label draft-count) + (a :href href :hx-get href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" + :class btn-class :title title (i :class "fa fa-file-text-o mr-1") " Drafts " + (span :class "inline-block bg-amber-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1" draft-count))) + +(defcomp ~blog-action-buttons-wrapper (&key inner-html) + (div :class "flex flex-wrap gap-2 px-4 py-3" (raw! inner-html))) + +(defcomp ~blog-filter-any-topic (&key cls hx-select) + (li (a :class (str "px-3 py-1 rounded border " cls) + :hx-get "?page=1" :hx-target "#main-panel" :hx-select hx-select + :hx-swap "outerHTML" :hx-push-url "true" "Any Topic"))) + +(defcomp ~blog-filter-group-icon-image (&key src name) + (img :src src :alt name :class "h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0")) + +(defcomp ~blog-filter-group-icon-color (&key style initial) + (div :class "h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0" :style style initial)) + +(defcomp ~blog-filter-group-li (&key cls hx-get hx-select icon-html name count) + (li (a :class (str "flex items-center gap-2 px-3 py-1 rounded border " cls) + :hx-get hx-get :hx-target "#main-panel" :hx-select hx-select + :hx-swap "outerHTML" :hx-push-url "true" + (raw! icon-html) + (span :class "inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200" name) + (span :class "flex-1") + (span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200" count)))) + +(defcomp ~blog-filter-nav (&key items-html) + (nav :class "max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm" + (ul :class "divide-y flex flex-col gap-3" (raw! items-html)))) + +(defcomp ~blog-filter-any-author (&key cls hx-select) + (li (a :class (str "px-3 py-1 rounded " cls) + :hx-get "?page=1" :hx-target "#main-panel" :hx-select hx-select + :hx-swap "outerHTML" :hx-push-url "true" "Any author"))) + +(defcomp ~blog-filter-author-icon (&key src name) + (img :src src :alt name :class "h-5 w-5 rounded-full object-cover")) + +(defcomp ~blog-filter-author-li (&key cls hx-get hx-select icon-html name count) + (li (a :class (str "flex items-center gap-2 px-3 py-1 rounded " cls) + :hx-get hx-get :hx-target "#main-panel" :hx-select hx-select + :hx-swap "outerHTML" :hx-push-url "true" + (raw! icon-html) + (span :class "text-stone-700" name) + (span :class "flex-1") + (span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200" count)))) + +(defcomp ~blog-filter-summary (&key text) + (span :class "text-sm text-stone-600" text)) diff --git a/blog/sexp/header.sexpr b/blog/sexp/header.sexpr new file mode 100644 index 0000000..3ab3654 --- /dev/null +++ b/blog/sexp/header.sexpr @@ -0,0 +1,34 @@ +;; Blog header components + +(defcomp ~blog-oob-header (&key parent-id child-id row-html) + (div :id parent-id :hx-swap-oob "outerHTML" :class "w-full" + (div :class "w-full" (raw! row-html) + (div :id child-id)))) + +(defcomp ~blog-header-label () + (div)) + +(defcomp ~blog-post-label (&key feature-image title) + (<> (when feature-image (img :src feature-image :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0")) + (span title))) + +(defcomp ~blog-post-cart-link (&key href count) + (a :href href :class "relative inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 transition" + (i :class "fa fa-shopping-cart" :aria-hidden "true") + (span count))) + +(defcomp ~blog-container-nav (&key container-nav-html) + (div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl" + :id "entries-calendars-nav-wrapper" (raw! container-nav-html))) + +(defcomp ~blog-admin-label () + (<> (i :class "fa fa-shield-halved" :aria-hidden "true") " admin")) + +(defcomp ~blog-admin-nav-item (&key href nav-btn-class label) + (div :class "relative nav-group" (a :href href :class nav-btn-class label))) + +(defcomp ~blog-sub-settings-label (&key icon label) + (<> (i :class icon :aria-hidden "true") " " label)) + +(defcomp ~blog-sub-admin-label (&key icon label) + (<> (i :class icon :aria-hidden "true") (div label))) diff --git a/blog/sexp/index.sexpr b/blog/sexp/index.sexpr new file mode 100644 index 0000000..120832c --- /dev/null +++ b/blog/sexp/index.sexpr @@ -0,0 +1,72 @@ +;; Blog index components + +(defcomp ~blog-end-of-results () + (div :class "col-span-full mt-4 text-center text-xs text-stone-400" "End of results")) + +(defcomp ~blog-sentinel-mobile (&key id next-url hyperscript) + (div :id id :class "block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel" + :hx-get next-url :hx-trigger "intersect once delay:250ms, sentinelmobile:retry" + :hx-swap "outerHTML" :_ hyperscript + :role "status" :aria-live "polite" :aria-hidden "true" + (div :class "js-loading hidden flex justify-center py-8" + (div :class "animate-spin h-8 w-8 border-4 border-stone-300 border-t-stone-600 rounded-full")) + (div :class "js-neterr hidden text-center py-8 text-stone-400" + (i :class "fa fa-exclamation-triangle text-2xl") + (p :class "mt-2" "Loading failed \u2014 retrying\u2026")))) + +(defcomp ~blog-sentinel-desktop (&key id next-url hyperscript) + (div :id id :class "hidden md:block h-4 opacity-0 pointer-events-none" + :hx-get next-url :hx-trigger "intersect once delay:250ms, sentinel:retry" + :hx-swap "outerHTML" :_ hyperscript + :role "status" :aria-live "polite" :aria-hidden "true" + (div :class "js-loading hidden flex justify-center py-2" + (div :class "animate-spin h-6 w-6 border-2 border-stone-300 border-t-stone-600 rounded-full")) + (div :class "js-neterr hidden text-center py-2 text-stone-400 text-sm" "Retry\u2026"))) + +(defcomp ~blog-page-sentinel (&key id next-url) + (div :id id :class "h-4 opacity-0 pointer-events-none" + :hx-get next-url :hx-trigger "intersect once delay:250ms" :hx-swap "outerHTML")) + +(defcomp ~blog-no-pages () + (div :class "col-span-full mt-8 text-center text-stone-500" "No pages found.")) + +(defcomp ~blog-list-svg () + (svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" :viewBox "0 0 24 24" + :stroke "currentColor" :stroke-width "2" + (path :stroke-linecap "round" :stroke-linejoin "round" :d "M4 6h16M4 12h16M4 18h16"))) + +(defcomp ~blog-tile-svg () + (svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" :viewBox "0 0 24 24" + :stroke "currentColor" :stroke-width "2" + (path :stroke-linecap "round" :stroke-linejoin "round" + :d "M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"))) + +(defcomp ~blog-view-toggle (&key list-href tile-href hx-select list-cls tile-cls list-svg-html tile-svg-html) + (div :class "hidden md:flex justify-end px-3 pt-3 gap-1" + (a :href list-href :hx-get list-href :hx-target "#main-panel" :hx-select hx-select + :hx-swap "outerHTML" :hx-push-url "true" :class (str "p-1.5 rounded " list-cls) :title "List view" + :_ "on click js localStorage.removeItem('blog_view') end" (raw! list-svg-html)) + (a :href tile-href :hx-get tile-href :hx-target "#main-panel" :hx-select hx-select + :hx-swap "outerHTML" :hx-push-url "true" :class (str "p-1.5 rounded " tile-cls) :title "Tile view" + :_ "on click js localStorage.setItem('blog_view','tile') end" (raw! tile-svg-html)))) + +(defcomp ~blog-content-type-tabs (&key posts-href pages-href hx-select posts-cls pages-cls) + (div :class "flex justify-center gap-1 px-3 pt-3" + (a :href posts-href :hx-get posts-href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" + :class (str "px-4 py-1.5 rounded-t text-sm font-medium transition-colors " posts-cls) "Posts") + (a :href pages-href :hx-get pages-href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" + :class (str "px-4 py-1.5 rounded-t text-sm font-medium transition-colors " pages-cls) "Pages"))) + +(defcomp ~blog-main-panel-pages (&key tabs-html cards-html) + (<> (raw! tabs-html) (div :class "max-w-full px-3 py-3 space-y-3" (raw! cards-html)) (div :class "pb-8"))) + +(defcomp ~blog-main-panel-posts (&key tabs-html toggle-html grid-cls cards-html) + (<> (raw! tabs-html) (raw! toggle-html) (div :class grid-cls (raw! cards-html)) (div :class "pb-8"))) + +(defcomp ~blog-aside (&key search-html action-buttons-html tag-groups-filter-html authors-filter-html) + (<> (raw! search-html) (raw! action-buttons-html) + (div :id "category-summary-desktop" :hxx-swap-oob "outerHTML" + (raw! tag-groups-filter-html) (raw! authors-filter-html)) + (div :id "filter-summary-desktop" :hxx-swap-oob "outerHTML"))) diff --git a/blog/sexp/nav.sexpr b/blog/sexp/nav.sexpr new file mode 100644 index 0000000..5406fd2 --- /dev/null +++ b/blog/sexp/nav.sexpr @@ -0,0 +1,67 @@ +;; Blog navigation components + +(defcomp ~blog-nav-empty (&key wrapper-id) + (div :id wrapper-id :hx-swap-oob "outerHTML")) + +(defcomp ~blog-nav-item-image (&key src label) + (if src (img :src src :alt label :class "w-8 h-8 rounded-full object-cover flex-shrink-0") + (div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"))) + +(defcomp ~blog-nav-item-link (&key href hx-get selected nav-cls img-html label) + (div (a :href href :hx-get hx-get :hx-target "#main-panel" + :hx-swap "outerHTML" :hx-push-url "true" + :aria-selected selected :class nav-cls + (raw! img-html) (span label)))) + +(defcomp ~blog-nav-item-plain (&key href selected nav-cls img-html label) + (div (a :href href :aria-selected selected :class nav-cls + (raw! img-html) (span label)))) + +(defcomp ~blog-nav-wrapper (&key arrow-cls container-id left-hs scroll-hs right-hs items-html) + (div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl" + :id "menu-items-nav-wrapper" :hx-swap-oob "outerHTML" + (button :class (str arrow-cls " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded") + :aria-label "Scroll left" + :_ left-hs (i :class "fa fa-chevron-left")) + (div :id container-id + :class "overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none" + :style "scroll-behavior: smooth;" :_ scroll-hs + (div :class "flex flex-col sm:flex-row gap-1" (raw! items-html))) + (style ".scrollbar-hide::-webkit-scrollbar { display: none; } .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }") + (button :class (str arrow-cls " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded") + :aria-label "Scroll right" + :_ right-hs (i :class "fa fa-chevron-right")))) + +;; Nav entries + +(defcomp ~blog-nav-entries-empty () + (div :id "entries-calendars-nav-wrapper" :hx-swap-oob "true")) + +(defcomp ~blog-nav-entry-item (&key href nav-cls name date-str) + (a :href href :class nav-cls + (div :class "w-8 h-8 rounded bg-stone-200 flex-shrink-0") + (div :class "flex-1 min-w-0" + (div :class "font-medium truncate" name) + (div :class "text-xs text-stone-600 truncate" date-str)))) + +(defcomp ~blog-nav-calendar-item (&key href nav-cls name) + (a :href href :class nav-cls + (i :class "fa fa-calendar" :aria-hidden "true") + (div name))) + +(defcomp ~blog-nav-entries-wrapper (&key scroll-hs items-html) + (div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl" + :id "entries-calendars-nav-wrapper" :hx-swap-oob "true" + (button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded" + :aria-label "Scroll left" + :_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200" + (i :class "fa fa-chevron-left")) + (div :id "associated-items-container" + :class "overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none" + :style "scroll-behavior: smooth;" :_ scroll-hs + (div :class "flex flex-col sm:flex-row gap-1" (raw! items-html))) + (style ".scrollbar-hide::-webkit-scrollbar { display: none; } .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }") + (button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded" + :aria-label "Scroll right" + :_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200" + (i :class "fa fa-chevron-right")))) diff --git a/blog/sexp/settings.sexpr b/blog/sexp/settings.sexpr new file mode 100644 index 0000000..da3fad2 --- /dev/null +++ b/blog/sexp/settings.sexpr @@ -0,0 +1,113 @@ +;; Blog settings panel components (features, markets, associated entries) + +(defcomp ~blog-features-form (&key features-url calendar-checked market-checked hs-trigger) + (form :hx-put features-url :hx-target "#features-panel" :hx-swap "outerHTML" + :hx-headers "{\"Content-Type\": \"application/json\"}" :hx-ext "json-enc" :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" + :_ hs-trigger) + (span :class "text-sm text-stone-700" + (i :class "fa fa-calendar text-blue-600 mr-1") + " Calendar \u2014 enable event booking on this page")) + (label :class "flex items-center gap-3 cursor-pointer" + (input :type "checkbox" :name "market" :value "true" :checked market-checked + :class "h-5 w-5 rounded border-stone-300 text-green-600 focus:ring-green-500" + :_ hs-trigger) + (span :class "text-sm text-stone-700" + (i :class "fa fa-shopping-bag text-green-600 mr-1") + " Market \u2014 enable product catalog on this page")))) + +(defcomp ~blog-sumup-connected () + (span :class "ml-2 text-xs text-green-600" (i :class "fa fa-check-circle") " Connected")) + +(defcomp ~blog-sumup-key-hint () + (p :class "text-xs text-stone-400 mt-0.5" "Key is set. Leave blank to keep current key.")) + +(defcomp ~blog-sumup-form (&key sumup-url merchant-code placeholder key-hint-html checkout-prefix connected-html) + (div :class "mt-4 pt-4 border-t border-stone-100" + (h4 :class "text-sm font-medium text-stone-700" + (i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment") + (p :class "text-xs text-stone-400 mt-1 mb-3" + "Configure per-page SumUp credentials. Leave blank to use the global merchant account.") + (form :hx-put sumup-url :hx-target "#features-panel" :hx-swap "outerHTML" :class "space-y-3" + (div (label :class "block text-xs font-medium text-stone-600 mb-1" "Merchant Code") + (input :type "text" :name "merchant_code" :value merchant-code :placeholder "e.g. ME4J6100" + :class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500")) + (div (label :class "block text-xs font-medium text-stone-600 mb-1" "API Key") + (input :type "password" :name "api_key" :value "" :placeholder placeholder + :class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500") + (raw! key-hint-html)) + (div (label :class "block text-xs font-medium text-stone-600 mb-1" "Checkout Reference Prefix") + (input :type "text" :name "checkout_prefix" :value checkout-prefix :placeholder "e.g. ROSE-" + :class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500")) + (button :type "submit" + :class "px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500" + "Save SumUp Settings") + (raw! connected-html)))) + +(defcomp ~blog-features-panel (&key form-html sumup-html) + (div :id "features-panel" :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200" + (h3 :class "text-lg font-semibold text-stone-800" "Page Features") + (raw! form-html) (raw! sumup-html))) + +;; Markets panel + +(defcomp ~blog-market-item (&key name slug delete-url confirm-text) + (li :class "flex items-center justify-between p-3 bg-stone-50 rounded" + (div (span :class "font-medium" name) + (span :class "text-stone-400 text-sm ml-2" (str "/" slug "/"))) + (button :hx-delete delete-url :hx-target "#markets-panel" :hx-swap "outerHTML" + :hx-confirm confirm-text :class "text-red-600 hover:text-red-800 text-sm" "Delete"))) + +(defcomp ~blog-markets-list (&key items-html) + (ul :class "space-y-2 mb-4" (raw! items-html))) + +(defcomp ~blog-markets-empty () + (p :class "text-stone-500 mb-4 text-sm" "No markets yet.")) + +(defcomp ~blog-markets-panel (&key list-html create-url) + (div :id "markets-panel" + (h3 :class "text-lg font-semibold mb-3" "Markets") + (raw! list-html) + (form :hx-post create-url :hx-target "#markets-panel" :hx-swap "outerHTML" :class "flex gap-2" + (input :type "text" :name "name" :placeholder "Market name" :required "" + :class "flex-1 border border-stone-300 rounded px-3 py-1.5 text-sm") + (button :type "submit" + :class "bg-stone-800 text-white px-4 py-1.5 rounded text-sm hover:bg-stone-700" "Create")))) + +;; Associated entries + +(defcomp ~blog-entry-image (&key src title) + (if src (img :src src :alt title :class "w-8 h-8 rounded-full object-cover flex-shrink-0") + (div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"))) + +(defcomp ~blog-associated-entry (&key confirm-text toggle-url hx-headers img-html name date-str) + (button :type "button" + :class "w-full text-left p-3 rounded border bg-green-50 border-green-300 transition hover:bg-green-100" + :data-confirm "" :data-confirm-title "Remove entry?" + :data-confirm-text confirm-text :data-confirm-icon "warning" + :data-confirm-confirm-text "Yes, remove it" + :data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed" + :hx-post toggle-url :hx-trigger "confirmed" + :hx-target "#associated-entries-list" :hx-swap "outerHTML" + :hx-headers hx-headers + :_ "on htmx:afterRequest trigger entryToggled on body" + (div :class "flex items-center justify-between gap-3" + (raw! img-html) + (div :class "flex-1" + (div :class "font-medium text-sm" name) + (div :class "text-xs text-stone-600 mt-1" date-str)) + (i :class "fa fa-times-circle text-green-600 text-lg flex-shrink-0")))) + +(defcomp ~blog-associated-entries-content (&key items-html) + (div :class "space-y-1" (raw! items-html))) + +(defcomp ~blog-associated-entries-empty () + (div :class "text-sm text-stone-400" + "No entries associated yet. Browse calendars below to add entries.")) + +(defcomp ~blog-associated-entries-panel (&key content-html) + (div :id "associated-entries-list" :class "border rounded-lg p-4 bg-white" + (h3 :class "text-lg font-semibold mb-4" "Associated Entries") + (raw! content-html))) diff --git a/blog/sexp/sexp_components.py b/blog/sexp/sexp_components.py index 83ac832..6c28784 100644 --- a/blog/sexp/sexp_components.py +++ b/blog/sexp/sexp_components.py @@ -8,16 +8,20 @@ Called from route handlers in place of ``render_template()``. """ from __future__ import annotations +import os from typing import Any from markupsafe import escape -from shared.sexp.jinja_bridge import sexp +from shared.sexp.jinja_bridge import render, load_service_components from shared.sexp.helpers import ( call_url, get_asset_url, root_header_html, search_mobile_html, search_desktop_html, full_page, oob_page, ) +# Load blog service .sexpr component definitions +load_service_components(os.path.dirname(os.path.dirname(__file__))) + # --------------------------------------------------------------------------- # OOB header helper @@ -25,11 +29,8 @@ from shared.sexp.helpers import ( def _oob_header_html(parent_id: str, child_id: str, row_html: str) -> str: """Wrap a header row in OOB div with child placeholder.""" - return sexp( - '(div :id pid :hx-swap-oob "outerHTML" :class "w-full"' - ' (div :class "w-full" (raw! rh)' - ' (div :id cid)))', - pid=parent_id, rh=row_html, cid=child_id, + return render("blog-oob-header", + parent_id=parent_id, child_id=child_id, row_html=row_html, ) @@ -39,12 +40,10 @@ def _oob_header_html(parent_id: str, child_id: str, row_html: str) -> str: def _blog_header_html(ctx: dict, *, oob: bool = False) -> str: """Blog header row — empty child of root.""" - return sexp( - '(~menu-row :id "blog-row" :level 1' - ' :link-label-html llh' - ' :child-id "blog-header-child" :oob oob)', - llh=sexp('(div)'), - oob=oob, + return render("menu-row", + id="blog-row", level=1, + link_label_html=render("blog-header-label"), + child_id="blog-header-child", oob=oob, ) @@ -59,31 +58,23 @@ def _post_header_html(ctx: dict, *, oob: bool = False) -> str: title = (post.get("title") or "")[:160] feature_image = post.get("feature_image") - label_html = sexp( - '(<> (when fi (img :src fi :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"))' - ' (span t))', - fi=feature_image, t=title, + label_html = render("blog-post-label", + feature_image=feature_image, title=title, ) nav_parts = [] page_cart_count = ctx.get("page_cart_count", 0) if page_cart_count and page_cart_count > 0: cart_href = call_url(ctx, "cart_url", f"/{slug}/") - nav_parts.append(sexp( - '(a :href h :class "relative inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full' - ' border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 transition"' - ' (i :class "fa fa-shopping-cart" :aria-hidden "true")' - ' (span c))', - h=cart_href, c=str(page_cart_count), + nav_parts.append(render("blog-post-cart-link", + href=cart_href, count=str(page_cart_count), )) # Container nav fragments (calendars, markets) container_nav = ctx.get("container_nav_html", "") if container_nav: - nav_parts.append(sexp( - '(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"' - ' :id "entries-calendars-nav-wrapper" (raw! cn))', - cn=container_nav, + nav_parts.append(render("blog-container-nav", + container_nav_html=container_nav, )) # Admin link @@ -96,26 +87,19 @@ def _post_header_html(ctx: dict, *, oob: bool = False) -> str: nav_btn = styles.get("nav_button", "") if isinstance(styles, dict) else getattr(styles, "nav_button", "") admin_href = qurl("blog.post.admin.admin", slug=slug) is_admin_page = "/admin" in request.path - nav_parts.append(sexp( - '(~nav-link :href h :hx-select "#main-panel" :icon "fa fa-cog"' - ' :aclass ac :select-colours sc :is-selected sel)', - h=admin_href, - ac=f"{nav_btn} {select_colours}", - sc=select_colours, - sel=is_admin_page, + nav_parts.append(render("nav-link", + href=admin_href, hx_select="#main-panel", icon="fa fa-cog", + aclass=f"{nav_btn} {select_colours}", + select_colours=select_colours, is_selected=is_admin_page, )) nav_html = "".join(nav_parts) link_href = call_url(ctx, "blog_url", f"/{slug}/") - return sexp( - '(~menu-row :id "post-row" :level 1' - ' :link-href lh :link-label-html llh' - ' :nav-html nh :child-id "post-header-child" :oob oob)', - lh=link_href, - llh=label_html, - nh=nav_html, - oob=oob, + return render("menu-row", + id="post-row", level=1, + link_href=link_href, link_label_html=label_html, + nav_html=nav_html, child_id="post-header-child", oob=oob, ) @@ -135,20 +119,14 @@ def _post_admin_header_html(ctx: dict, *, oob: bool = False) -> str: nav_btn = styles.get("nav_button", "") if isinstance(styles, dict) else getattr(styles, "nav_button", "") admin_href = qurl("blog.post.admin.admin", slug=slug) - label_html = sexp( - '(<> (i :class "fa fa-shield-halved" :aria-hidden "true") " admin")', - ) + label_html = render("blog-admin-label") nav_html = _post_admin_nav_html(ctx) - return sexp( - '(~menu-row :id "post-admin-row" :level 2' - ' :link-href lh :link-label-html llh' - ' :nav-html nh :child-id "post-admin-header-child" :oob oob)', - lh=admin_href, - llh=label_html, - nh=nav_html, - oob=oob, + return render("menu-row", + id="post-admin-row", level=2, + link_href=admin_href, link_label_html=label_html, + nav_html=nav_html, child_id="post-admin-header-child", oob=oob, ) @@ -177,9 +155,8 @@ def _post_admin_nav_html(ctx: dict) -> str: if not callable(url_fn): continue href = url_fn(path) - parts.append(sexp( - '(div :class "relative nav-group" (a :href h :class c l))', - h=href, c=nav_btn, l=label, + parts.append(render("blog-admin-nav-item", + href=href, nav_btn_class=nav_btn, label=label, )) # HTMX links @@ -190,9 +167,8 @@ def _post_admin_nav_html(ctx: dict) -> str: ("blog.post.admin.settings", "settings"), ]: href = qurl(endpoint, slug=slug) - parts.append(sexp( - '(~nav-link :href h :label l :select-colours sc)', - h=href, l=label, sc=select_colours, + parts.append(render("nav-link", + href=href, label=label, select_colours=select_colours, )) return "".join(parts) @@ -208,20 +184,14 @@ def _settings_header_html(ctx: dict, *, oob: bool = False) -> str: hx_select = ctx.get("hx_select_search", "#main-panel") settings_href = qurl("settings.home") - label_html = sexp( - '(<> (i :class "fa fa-shield-halved" :aria-hidden "true") " admin")', - ) + label_html = render("blog-admin-label") nav_html = _settings_nav_html(ctx) - return sexp( - '(~menu-row :id "root-settings-row" :level 1' - ' :link-href lh :link-label-html llh' - ' :nav-html nh :child-id "root-settings-header-child" :oob oob)', - lh=settings_href, - llh=label_html, - nh=nav_html, - oob=oob, + return render("menu-row", + id="root-settings-row", level=1, + link_href=settings_href, link_label_html=label_html, + nav_html=nav_html, child_id="root-settings-header-child", oob=oob, ) @@ -239,9 +209,9 @@ def _settings_nav_html(ctx: dict) -> str: ("settings.cache", "refresh", "Cache"), ]: href = qurl(endpoint) - parts.append(sexp( - '(~nav-link :href h :icon ic :label l :select-colours sc)', - h=href, ic=f"fa fa-{icon}", l=label, sc=select_colours, + parts.append(render("nav-link", + href=href, icon=f"fa fa-{icon}", label=label, + select_colours=select_colours, )) return "".join(parts) @@ -256,21 +226,14 @@ def _sub_settings_header_html(row_id: str, child_id: str, href: str, *, oob: bool = False, nav_html: str = "") -> str: """Generic sub-settings header row (menu_items, snippets, tag_groups, cache).""" select_colours = ctx.get("select_colours", "") - label_html = sexp( - '(<> (i :class ic :aria-hidden "true") " " l)', - ic=f"fa fa-{icon}", l=label, + label_html = render("blog-sub-settings-label", + icon=f"fa fa-{icon}", label=label, ) - return sexp( - '(~menu-row :id rid :level 2' - ' :link-href lh :link-label-html llh' - ' :nav-html nh :child-id cid :oob oob)', - rid=row_id, - lh=href, - llh=label_html, - nh=nav_html, - cid=child_id, - oob=oob, + return render("menu-row", + id=row_id, level=2, + link_href=href, link_label_html=label_html, + nav_html=nav_html, child_id=child_id, oob=oob, ) @@ -278,21 +241,14 @@ def _post_sub_admin_header_html(row_id: str, child_id: str, href: str, icon: str, label: str, ctx: dict, *, oob: bool = False, nav_html: str = "") -> str: """Generic post sub-admin header row (data, edit, entries, settings).""" - label_html = sexp( - '(<> (i :class ic :aria-hidden "true") (div l))', - ic=f"fa fa-{icon}", l=label, + label_html = render("blog-sub-admin-label", + icon=f"fa fa-{icon}", label=label, ) - return sexp( - '(~menu-row :id rid :level 3' - ' :link-href lh :link-label-html llh' - ' :nav-html nh :child-id cid :oob oob)', - rid=row_id, - lh=href, - llh=label_html, - nh=nav_html, - cid=child_id, - oob=oob, + return render("menu-row", + id=row_id, level=3, + link_href=href, link_label_html=label_html, + nav_html=nav_html, child_id=child_id, oob=oob, ) @@ -327,12 +283,9 @@ def _blog_card_html(post: dict, ctx: dict) -> str: if user: liked = post.get("is_liked", False) like_url = call_url(ctx, "blog_url", f"/{slug}/like/toggle/") - like_html = sexp( - '(div :class "absolute top-20 right-2 z-10 text-6xl md:text-4xl"' - ' (button :hx-post lu :hx-swap "outerHTML"' - ' :hx-headers hh :class "cursor-pointer" heart))', - lu=like_url, - hh=f'{{"X-CSRFToken": "{ctx.get("csrf_token", "")}"}}', + like_html = render("blog-like-button", + like_url=like_url, + hx_headers=f'{{"X-CSRFToken": "{ctx.get("csrf_token", "")}"}}', heart="\u2764\ufe0f" if liked else "\U0001f90d", ) @@ -344,21 +297,14 @@ def _blog_card_html(post: dict, ctx: dict) -> str: ts = "" if updated: ts = updated.strftime("%-d %b %Y at %H:%M") if hasattr(updated, "strftime") else str(updated) - status_html = sexp( - '(<> (div :class "flex justify-center gap-2 mt-1"' - ' (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800" "Draft")' - ' (when pr (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")))' - ' (when ts (p :class "text-sm text-stone-500" (str "Updated: " ts))))', - pr=pub_req, ts=ts, + status_html = render("blog-draft-status", + publish_requested=pub_req, timestamp=ts, ) else: pub = post.get("published_at") if pub: ts = pub.strftime("%-d %b %Y at %H:%M") if hasattr(pub, "strftime") else str(pub) - status_html = sexp( - '(p :class "text-sm text-stone-500" (str "Published: " ts))', - ts=ts, - ) + status_html = render("blog-published-status", timestamp=ts) fi = post.get("feature_image") excerpt = post.get("custom_excerpt") or post.get("excerpt", "") @@ -366,22 +312,11 @@ def _blog_card_html(post: dict, ctx: dict) -> str: widget = card_widgets.get(str(post.get("id", "")), "") at_bar = _at_bar_html(post, ctx) - return sexp( - '(article :class "border-b pb-6 last:border-b-0 relative"' - ' (raw! like_html)' - ' (a :href h :hx-get h :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' :class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"' - ' (header :class "mb-2 text-center"' - ' (h2 :class "text-4xl font-bold text-stone-900" t)' - ' (raw! sh))' - ' (when fi (div :class "mb-4" (img :src fi :alt "" :class "rounded-lg w-full object-cover")))' - ' (when ex (p :class "text-stone-700 text-lg leading-relaxed text-center" ex)))' - ' (when wid (raw! wid))' - ' (raw! ab))', - like_html=like_html, h=href, hs=hx_select, - t=post.get("title", ""), sh=status_html, - fi=fi, ex=excerpt, wid=widget, ab=at_bar, + return render("blog-card", + like_html=like_html, href=href, hx_select=hx_select, + title=post.get("title", ""), status_html=status_html, + feature_image=fi, excerpt=excerpt, widget_html=widget, + at_bar_html=at_bar, ) @@ -400,36 +335,22 @@ def _blog_card_tile_html(post: dict, ctx: dict) -> str: ts = "" if updated: ts = updated.strftime("%-d %b %Y at %H:%M") if hasattr(updated, "strftime") else str(updated) - status_html = sexp( - '(<> (div :class "flex justify-center gap-1 mt-1"' - ' (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800" "Draft")' - ' (when pr (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")))' - ' (when ts (p :class "text-sm text-stone-500" (str "Updated: " ts))))', - pr=post.get("publish_requested"), ts=ts, + status_html = render("blog-draft-status", + publish_requested=post.get("publish_requested"), timestamp=ts, ) else: pub = post.get("published_at") if pub: ts = pub.strftime("%-d %b %Y at %H:%M") if hasattr(pub, "strftime") else str(pub) - status_html = sexp('(p :class "text-sm text-stone-500" (str "Published: " ts))', ts=ts) + status_html = render("blog-published-status", timestamp=ts) excerpt = post.get("custom_excerpt") or post.get("excerpt", "") at_bar = _at_bar_html(post, ctx) - return sexp( - '(article :class "relative"' - ' (a :href h :hx-get h :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' :class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"' - ' (when fi (div (img :src fi :alt "" :class "w-full aspect-video object-cover")))' - ' (div :class "p-3 text-center"' - ' (h2 :class "text-lg font-bold text-stone-900" t)' - ' (raw! sh)' - ' (when ex (p :class "text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1" ex))))' - ' (raw! ab))', - h=href, hs=hx_select, fi=fi, - t=post.get("title", ""), sh=status_html, - ex=excerpt, ab=at_bar, + return render("blog-card-tile", + href=href, hx_select=hx_select, feature_image=fi, + title=post.get("title", ""), status_html=status_html, + excerpt=excerpt, at_bar_html=at_bar, ) @@ -447,28 +368,14 @@ def _at_bar_html(post: dict, ctx: dict) -> str: t_name = t.get("name") or getattr(t, "name", "") t_fi = t.get("feature_image") or getattr(t, "feature_image", None) if t_fi: - icon = sexp( - '(img :src fi :alt n :class "h-4 w-4 rounded-full object-cover border border-stone-300 flex-shrink-0")', - fi=t_fi, n=t_name, - ) + icon = render("blog-tag-icon-image", src=t_fi, name=t_name) else: init = (t_name[:1]) if t_name else "" - icon = sexp( - '(div :class "h-4 w-4 rounded-full text-[8px] font-semibold flex items-center justify-center' - ' border border-stone-300 flex-shrink-0 bg-stone-200 text-stone-600" i)', - i=init, - ) - tag_li.append(sexp( - '(li (a :class "flex items-center gap-1" (raw! ic)' - ' (span :class "inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium' - ' border border-stone-200" n)))', - ic=icon, n=t_name, + icon = render("blog-tag-icon-initial", initial=init) + tag_li.append(render("blog-tag-li", + icon_html=icon, name=t_name, )) - tag_items = sexp( - '(div :class "mt-4 flex items-center gap-2" (div "in")' - ' (ul :class "flex flex-wrap gap-2 text-sm" (raw! items)))', - items="".join(tag_li), - ) + tag_items = render("blog-tag-bar", items_html="".join(tag_li)) author_items = "" if authors: @@ -477,26 +384,15 @@ def _at_bar_html(post: dict, ctx: dict) -> str: a_name = a.get("name") or getattr(a, "name", "") a_img = a.get("profile_image") or getattr(a, "profile_image", None) if a_img: - author_li.append(sexp( - '(li :class "flex items-center gap-1"' - ' (img :src ai :alt n :class "h-5 w-5 rounded-full object-cover")' - ' (span :class "text-stone-700" n))', - ai=a_img, n=a_name, + author_li.append(render("blog-author-with-image", + image=a_img, name=a_name, )) else: - author_li.append(sexp( - '(li :class "text-stone-700" n)', n=a_name, - )) - author_items = sexp( - '(div :class "mt-4 flex items-center gap-2" (div "by")' - ' (ul :class "flex flex-wrap gap-2 text-sm" (raw! items)))', - items="".join(author_li), - ) + author_li.append(render("blog-author-text", name=a_name)) + author_items = render("blog-author-bar", items_html="".join(author_li)) - return sexp( - '(div :class "flex flex-row justify-center gap-3"' - ' (raw! ti) (div) (raw! ai))', - ti=tag_items, ai=author_items, + return render("blog-at-bar", + tag_items_html=tag_items, author_items_html=author_items, ) @@ -508,7 +404,7 @@ def _blog_sentinel_html(ctx: dict) -> str: total_pages = int(total_pages) if page >= total_pages: - return sexp('(div :class "col-span-full mt-4 text-center text-xs text-stone-400" "End of results")') + return render("blog-end-of-results") current_local_href = ctx.get("current_local_href", "/index") next_url = f"{current_local_href}?page={page + 1}" @@ -538,28 +434,12 @@ def _blog_sentinel_html(ctx: dict) -> str: " on htmx:sendError call backoff() on htmx:responseError call backoff() on htmx:timeout call backoff()" ) - mobile = sexp( - '(div :id mid :class "block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"' - ' :hx-get nu :hx-trigger "intersect once delay:250ms, sentinelmobile:retry"' - ' :hx-swap "outerHTML" :_ mhs' - ' :role "status" :aria-live "polite" :aria-hidden "true"' - ' (div :class "js-loading hidden flex justify-center py-8"' - ' (div :class "animate-spin h-8 w-8 border-4 border-stone-300 border-t-stone-600 rounded-full"))' - ' (div :class "js-neterr hidden text-center py-8 text-stone-400"' - ' (i :class "fa fa-exclamation-triangle text-2xl")' - ' (p :class "mt-2" "Loading failed \u2014 retrying\u2026")))', - mid=f"sentinel-{page}-m", nu=next_url, mhs=mobile_hs, + mobile = render("blog-sentinel-mobile", + id=f"sentinel-{page}-m", next_url=next_url, hyperscript=mobile_hs, ) - desktop = sexp( - '(div :id did :class "hidden md:block h-4 opacity-0 pointer-events-none"' - ' :hx-get nu :hx-trigger "intersect once delay:250ms, sentinel:retry"' - ' :hx-swap "outerHTML" :_ dhs' - ' :role "status" :aria-live "polite" :aria-hidden "true"' - ' (div :class "js-loading hidden flex justify-center py-2"' - ' (div :class "animate-spin h-6 w-6 border-2 border-stone-300 border-t-stone-600 rounded-full"))' - ' (div :class "js-neterr hidden text-center py-2 text-stone-400 text-sm" "Retry\u2026"))', - did=f"sentinel-{page}-d", nu=next_url, dhs=desktop_hs, + desktop = render("blog-sentinel-desktop", + id=f"sentinel-{page}-d", next_url=next_url, hyperscript=desktop_hs, ) return mobile + desktop @@ -581,15 +461,13 @@ def _page_cards_html(ctx: dict) -> str: if page_num < total_pages: current_local_href = ctx.get("current_local_href", "/index?type=pages") next_url = f"{current_local_href}&page={page_num + 1}" if "?" in current_local_href else f"{current_local_href}?page={page_num + 1}" - parts.append(sexp( - '(div :id sid :class "h-4 opacity-0 pointer-events-none"' - ' :hx-get nu :hx-trigger "intersect once delay:250ms" :hx-swap "outerHTML")', - sid=f"sentinel-{page_num}-d", nu=next_url, + parts.append(render("blog-page-sentinel", + id=f"sentinel-{page_num}-d", next_url=next_url, )) elif pages: - parts.append(sexp('(div :class "col-span-full mt-4 text-center text-xs text-stone-400" "End of results")')) + parts.append(render("blog-end-of-results")) else: - parts.append(sexp('(div :class "col-span-full mt-8 text-center text-stone-500" "No pages found.")')) + parts.append(render("blog-no-pages")) return "".join(parts) @@ -603,36 +481,23 @@ def _page_card_html(page: dict, ctx: dict) -> str: features = page.get("features") or {} badges_html = "" if features: - badges_html = sexp( - '(div :class "flex justify-center gap-2 mt-2"' - ' (when cal (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800"' - ' (i :class "fa fa-calendar mr-1") "Calendar"))' - ' (when mkt (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800"' - ' (i :class "fa fa-shopping-bag mr-1") "Market")))', - cal=features.get("calendar"), mkt=features.get("market"), + badges_html = render("blog-page-badges", + has_calendar=features.get("calendar"), has_market=features.get("market"), ) pub = page.get("published_at") pub_html = "" if pub: ts = pub.strftime("%-d %b %Y at %H:%M") if hasattr(pub, "strftime") else str(pub) - pub_html = sexp('(p :class "text-sm text-stone-500" (str "Published: " ts))', ts=ts) + pub_html = render("blog-published-status", timestamp=ts) fi = page.get("feature_image") excerpt = page.get("custom_excerpt") or page.get("excerpt", "") - return sexp( - '(article :class "border-b pb-6 last:border-b-0 relative"' - ' (a :href h :hx-get h :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' :class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"' - ' (header :class "mb-2 text-center"' - ' (h2 :class "text-4xl font-bold text-stone-900" t)' - ' (raw! bh) (raw! ph))' - ' (when fi (div :class "mb-4" (img :src fi :alt "" :class "rounded-lg w-full object-cover")))' - ' (when ex (p :class "text-stone-700 text-lg leading-relaxed text-center" ex))))', - h=href, hs=hx_select, t=page.get("title", ""), - bh=badges_html, ph=pub_html, fi=fi, ex=excerpt, + return render("blog-page-card", + href=href, hx_select=hx_select, title=page.get("title", ""), + badges_html=badges_html, pub_html=pub_html, feature_image=fi, + excerpt=excerpt, ) @@ -648,28 +513,13 @@ def _view_toggle_html(ctx: dict) -> str: list_href = f"{current_local_href}" tile_href = f"{current_local_href}{'&' if '?' in current_local_href else '?'}view=tile" - list_svg = sexp( - '(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" :viewBox "0 0 24 24"' - ' :stroke "currentColor" :stroke-width "2"' - ' (path :stroke-linecap "round" :stroke-linejoin "round" :d "M4 6h16M4 12h16M4 18h16"))', - ) - tile_svg = sexp( - '(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" :viewBox "0 0 24 24"' - ' :stroke "currentColor" :stroke-width "2"' - ' (path :stroke-linecap "round" :stroke-linejoin "round"' - ' :d "M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"))', - ) + list_svg = render("blog-list-svg") + tile_svg = render("blog-tile-svg") - return sexp( - '(div :class "hidden md:flex justify-end px-3 pt-3 gap-1"' - ' (a :href lh :hx-get lh :hx-target "#main-panel" :hx-select hs' - ' :hx-swap "outerHTML" :hx-push-url "true" :class (str "p-1.5 rounded " lc) :title "List view"' - ' :_ "on click js localStorage.removeItem(\'blog_view\') end" (raw! ls))' - ' (a :href th :hx-get th :hx-target "#main-panel" :hx-select hs' - ' :hx-swap "outerHTML" :hx-push-url "true" :class (str "p-1.5 rounded " tc) :title "Tile view"' - ' :_ "on click js localStorage.setItem(\'blog_view\',\'tile\') end" (raw! ts)))', - lh=list_href, th=tile_href, hs=hx_select, - lc=list_cls, tc=tile_cls, ls=list_svg, ts=tile_svg, + return render("blog-view-toggle", + list_href=list_href, tile_href=tile_href, hx_select=hx_select, + list_cls=list_cls, tile_cls=tile_cls, + list_svg_html=list_svg, tile_svg_html=tile_svg, ) @@ -686,16 +536,9 @@ def _content_type_tabs_html(ctx: dict) -> str: posts_cls = "bg-stone-700 text-white" if content_type != "pages" else "bg-stone-100 text-stone-600 hover:bg-stone-200" pages_cls = "bg-stone-700 text-white" if content_type == "pages" else "bg-stone-100 text-stone-600 hover:bg-stone-200" - return sexp( - '(div :class "flex justify-center gap-1 px-3 pt-3"' - ' (a :href ph :hx-get ph :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' :class (str "px-4 py-1.5 rounded-t text-sm font-medium transition-colors " pc) "Posts")' - ' (a :href pgh :hx-get pgh :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' :class (str "px-4 py-1.5 rounded-t text-sm font-medium transition-colors " pgc) "Pages"))', - ph=posts_href, pgh=pages_href, hs=hx_select, - pc=posts_cls, pgc=pages_cls, + return render("blog-content-type-tabs", + posts_href=posts_href, pages_href=pages_href, hx_select=hx_select, + posts_cls=posts_cls, pages_cls=pages_cls, ) @@ -708,17 +551,16 @@ def _blog_main_panel_html(ctx: dict) -> str: if content_type == "pages": cards = _page_cards_html(ctx) - return sexp( - '(<> (raw! tabs) (div :class "max-w-full px-3 py-3 space-y-3" (raw! cards)) (div :class "pb-8"))', - tabs=tabs, cards=cards, + return render("blog-main-panel-pages", + tabs_html=tabs, cards_html=cards, ) else: toggle = _view_toggle_html(ctx) 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" cards = _blog_cards_html(ctx) - return sexp( - '(<> (raw! tabs) (raw! toggle) (div :class gc (raw! cards)) (div :class "pb-8"))', - tabs=tabs, toggle=toggle, gc=grid_cls, cards=cards, + return render("blog-main-panel-posts", + tabs_html=tabs, toggle_html=toggle, grid_cls=grid_cls, + cards_html=cards, ) @@ -732,12 +574,9 @@ def _blog_aside_html(ctx: dict) -> str: ab = _action_buttons_html(ctx) tgf = _tag_groups_filter_html(ctx) af = _authors_filter_html(ctx) - return sexp( - '(<> (raw! sd) (raw! ab)' - ' (div :id "category-summary-desktop" :hxx-swap-oob "outerHTML"' - ' (raw! tgf) (raw! af))' - ' (div :id "filter-summary-desktop" :hxx-swap-oob "outerHTML"))', - sd=sd, ab=ab, tgf=tgf, af=af, + return render("blog-aside", + search_html=sd, action_buttons_html=ab, + tag_groups_filter_html=tgf, authors_filter_html=af, ) @@ -758,12 +597,10 @@ def _blog_filter_html(ctx: dict) -> str: action_buttons = _action_buttons_html(ctx) filter_details = _tag_groups_filter_html(ctx) + _authors_filter_html(ctx) - return sexp( - '(~mobile-filter :filter-summary-html fsh :action-buttons-html abh' - ' :filter-details-html fdh)', - fsh=filter_content, - abh=action_buttons, - fdh=filter_details, + return render("mobile-filter", + filter_summary_html=filter_content, + action_buttons_html=action_buttons, + filter_details_html=filter_details, ) @@ -783,46 +620,36 @@ def _action_buttons_html(ctx: dict) -> str: if has_admin: new_href = call_url(ctx, "blog_url", "/new/") - parts.append(sexp( - '(a :href h :hx-get h :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' :class "px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"' - ' :title "New Post" (i :class "fa fa-plus mr-1") " New Post")', - h=new_href, hs=hx_select, + parts.append(render("blog-action-button", + href=new_href, hx_select=hx_select, + btn_class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors", + title="New Post", icon_class="fa fa-plus mr-1", label=" New Post", )) new_page_href = call_url(ctx, "blog_url", "/new-page/") - parts.append(sexp( - '(a :href h :hx-get h :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' :class "px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors"' - ' :title "New Page" (i :class "fa fa-plus mr-1") " New Page")', - h=new_page_href, hs=hx_select, + parts.append(render("blog-action-button", + href=new_page_href, hx_select=hx_select, + btn_class="px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors", + title="New Page", icon_class="fa fa-plus mr-1", label=" New Page", )) if user and (draft_count or drafts): if drafts: off_href = f"{current_local_href}" - parts.append(sexp( - '(a :href h :hx-get h :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' :class "px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"' - ' :title "Hide Drafts" (i :class "fa fa-file-text-o mr-1") " Drafts "' - ' (span :class "inline-block bg-stone-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1" dc))', - h=off_href, hs=hx_select, dc=str(draft_count), + parts.append(render("blog-drafts-button", + href=off_href, hx_select=hx_select, + btn_class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors", + title="Hide Drafts", label=" Drafts ", draft_count=str(draft_count), )) else: on_href = f"{current_local_href}{'&' if '?' in current_local_href else '?'}drafts=1" - parts.append(sexp( - '(a :href h :hx-get h :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' :class "px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors"' - ' :title "Show Drafts" (i :class "fa fa-file-text-o mr-1") " Drafts "' - ' (span :class "inline-block bg-amber-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1" dc))', - h=on_href, hs=hx_select, dc=str(draft_count), + parts.append(render("blog-drafts-button-amber", + href=on_href, hx_select=hx_select, + btn_class="px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors", + title="Show Drafts", label=" Drafts ", draft_count=str(draft_count), )) inner = "".join(parts) - return sexp('(div :class "flex flex-wrap gap-2 px-4 py-3" (raw! inner))', inner=inner) + return render("blog-action-buttons-wrapper", inner_html=inner) def _tag_groups_filter_html(ctx: dict) -> str: @@ -835,12 +662,7 @@ def _tag_groups_filter_html(ctx: dict) -> str: is_any = len(selected_groups) == 0 and len(selected_tags) == 0 any_cls = "bg-stone-900 text-white border-stone-900" if is_any else "bg-white text-stone-600 border-stone-300 hover:bg-stone-50" - li_parts = [sexp( - '(li (a :class (str "px-3 py-1 rounded border " ac)' - ' :hx-get "?page=1" :hx-target "#main-panel" :hx-select hs' - ' :hx-swap "outerHTML" :hx-push-url "true" "Any Topic"))', - ac=any_cls, hs=hx_select, - )] + li_parts = [render("blog-filter-any-topic", cls=any_cls, hx_select=hx_select)] for group in tag_groups: g_slug = getattr(group, "slug", "") if hasattr(group, "slug") else group.get("slug", "") @@ -856,36 +678,18 @@ def _tag_groups_filter_html(ctx: dict) -> str: cls = "bg-stone-900 text-white border-stone-900" if is_on else "bg-white text-stone-600 border-stone-300 hover:bg-stone-50" if g_fi: - icon = sexp( - '(img :src fi :alt n :class "h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0")', - fi=g_fi, n=g_name, - ) + icon = render("blog-filter-group-icon-image", src=g_fi, name=g_name) else: style = f"background-color: {g_colour}; color: white;" if g_colour else "background-color: #e7e5e4; color: #57534e;" - icon = sexp( - '(div :class "h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center' - ' border border-stone-300 flex-shrink-0" :style st i)', - st=style, i=g_name[:1], - ) + icon = render("blog-filter-group-icon-color", style=style, initial=g_name[:1]) - li_parts.append(sexp( - '(li (a :class (str "flex items-center gap-2 px-3 py-1 rounded border " c)' - ' :hx-get hg :hx-target "#main-panel" :hx-select hs' - ' :hx-swap "outerHTML" :hx-push-url "true"' - ' (raw! ic)' - ' (span :class "inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200" n)' - ' (span :class "flex-1")' - ' (span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200" gc)))', - c=cls, hg=f"?group={g_slug}&page=1", hs=hx_select, - ic=icon, n=g_name, gc=str(g_count), + li_parts.append(render("blog-filter-group-li", + cls=cls, hx_get=f"?group={g_slug}&page=1", hx_select=hx_select, + icon_html=icon, name=g_name, count=str(g_count), )) items = "".join(li_parts) - return sexp( - '(nav :class "max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm"' - ' (ul :class "divide-y flex flex-col gap-3" (raw! items)))', - items=items, - ) + return render("blog-filter-nav", items_html=items) def _authors_filter_html(ctx: dict) -> str: @@ -897,12 +701,7 @@ def _authors_filter_html(ctx: dict) -> str: is_any = len(selected_authors) == 0 any_cls = "bg-stone-900 text-white border-stone-900" if is_any else "bg-white text-stone-600 border-stone-300 hover:bg-stone-50" - li_parts = [sexp( - '(li (a :class (str "px-3 py-1 rounded " ac)' - ' :hx-get "?page=1" :hx-target "#main-panel" :hx-select hs' - ' :hx-swap "outerHTML" :hx-push-url "true" "Any author"))', - ac=any_cls, hs=hx_select, - )] + li_parts = [render("blog-filter-any-author", cls=any_cls, hx_select=hx_select)] for author in authors: a_slug = getattr(author, "slug", "") if hasattr(author, "slug") else author.get("slug", "") @@ -915,29 +714,15 @@ def _authors_filter_html(ctx: dict) -> str: icon = "" if a_img: - icon = sexp( - '(img :src ai :alt n :class "h-5 w-5 rounded-full object-cover")', - ai=a_img, n=a_name, - ) + icon = render("blog-filter-author-icon", src=a_img, name=a_name) - li_parts.append(sexp( - '(li (a :class (str "flex items-center gap-2 px-3 py-1 rounded " c)' - ' :hx-get hg :hx-target "#main-panel" :hx-select hs' - ' :hx-swap "outerHTML" :hx-push-url "true"' - ' (raw! ic)' - ' (span :class "text-stone-700" n)' - ' (span :class "flex-1")' - ' (span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200" ac)))', - c=cls, hg=f"?author={a_slug}&page=1", hs=hx_select, - ic=icon, n=a_name, ac=str(a_count), + li_parts.append(render("blog-filter-author-li", + cls=cls, hx_get=f"?author={a_slug}&page=1", hx_select=hx_select, + icon_html=icon, name=a_name, count=str(a_count), )) items = "".join(li_parts) - return sexp( - '(nav :class "max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm"' - ' (ul :class "divide-y flex flex-col gap-3" (raw! items)))', - items=items, - ) + return render("blog-filter-nav", items_html=items) def _tag_groups_filter_summary_html(ctx: dict) -> str: @@ -954,7 +739,7 @@ def _tag_groups_filter_summary_html(ctx: dict) -> str: names.append(g_name) if not names: return "" - return sexp('(span :class "text-sm text-stone-600" t)', t=", ".join(names)) + return render("blog-filter-summary", text=", ".join(names)) def _authors_filter_summary_html(ctx: dict) -> str: @@ -971,7 +756,7 @@ def _authors_filter_summary_html(ctx: dict) -> str: names.append(a_name) if not names: return "" - return sexp('(span :class "text-sm text-stone-600" t)', t=", ".join(names)) + return render("blog-filter-summary", text=", ".join(names)) # --------------------------------------------------------------------------- @@ -995,19 +780,11 @@ def _post_main_panel_html(ctx: dict) -> str: edit_html = "" if is_admin or (user and post.get("user_id") == getattr(user, "id", None)): edit_href = qurl("blog.post.admin.edit", slug=slug) - edit_html = sexp( - '(a :href eh :hx-get eh :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-stone-700 text-white hover:bg-stone-800 transition-colors"' - ' (i :class "fa fa-pencil mr-1") " Edit")', - eh=edit_href, hs=hx_select, + edit_html = render("blog-detail-edit-link", + href=edit_href, hx_select=hx_select, ) - draft_html = sexp( - '(div :class "flex items-center justify-center gap-2 mb-3"' - ' (span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-amber-100 text-amber-800" "Draft")' - ' (when pr (span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-800" "Publish requested"))' - ' (raw! eh))', - pr=post.get("publish_requested"), eh=edit_html, + draft_html = render("blog-detail-draft", + publish_requested=post.get("publish_requested"), edit_html=edit_html, ) # Blog post chrome (not for pages) @@ -1017,40 +794,29 @@ def _post_main_panel_html(ctx: dict) -> str: if user: liked = post.get("is_liked", False) like_url = call_url(ctx, "blog_url", f"/{slug}/like/toggle/") - like_html = sexp( - '(div :class "absolute top-2 right-2 z-10 text-8xl md:text-6xl"' - ' (button :hx-post lu :hx-swap "outerHTML"' - ' :hx-headers hh :class "cursor-pointer" heart))', - lu=like_url, - hh=f'{{"X-CSRFToken": "{ctx.get("csrf_token", "")}"}}', + like_html = render("blog-detail-like", + like_url=like_url, + hx_headers=f'{{"X-CSRFToken": "{ctx.get("csrf_token", "")}"}}', heart="\u2764\ufe0f" if liked else "\U0001f90d", ) excerpt_html = "" if post.get("custom_excerpt"): - excerpt_html = sexp( - '(div :class "w-full text-center italic text-3xl p-2" ex)', - ex=post["custom_excerpt"], + excerpt_html = render("blog-detail-excerpt", + excerpt=post["custom_excerpt"], ) at_bar = _at_bar_html(post, ctx) - chrome_html = sexp( - '(<> (raw! lh) (raw! exh) (div :class "hidden md:block" (raw! ab)))', - lh=like_html, exh=excerpt_html, ab=at_bar, + chrome_html = render("blog-detail-chrome", + like_html=like_html, excerpt_html=excerpt_html, at_bar_html=at_bar, ) fi = post.get("feature_image") html_content = post.get("html", "") - return sexp( - '(<> (article :class "relative"' - ' (raw! dh) (raw! ch)' - ' (when fi (div :class "mb-3 flex justify-center"' - ' (img :src fi :alt "" :class "rounded-lg w-full md:w-3/4 object-cover")))' - ' (when hc (div :class "blog-content p-2" (raw! hc))))' - ' (div :class "pb-8"))', - dh=draft_html, ch=chrome_html, - fi=fi, hc=html_content, + return render("blog-detail-main", + draft_html=draft_html, chrome_html=chrome_html, + feature_image=fi, html_content=html_content, ) @@ -1084,26 +850,12 @@ def _post_meta_html(ctx: dict) -> str: tw_title = post.get("twitter_title") or base_title is_article = not post.get("is_page") - return sexp( - '(<>' - ' (meta :name "robots" :content robots)' - ' (title bt)' - ' (meta :name "description" :content desc)' - ' (when canon (link :rel "canonical" :href canon))' - ' (meta :property "og:type" :content ogt)' - ' (meta :property "og:title" :content og_title)' - ' (meta :property "og:description" :content desc)' - ' (when canon (meta :property "og:url" :content canon))' - ' (when image (meta :property "og:image" :content image))' - ' (meta :name "twitter:card" :content twc)' - ' (meta :name "twitter:title" :content tw_title)' - ' (meta :name "twitter:description" :content desc)' - ' (when image (meta :name "twitter:image" :content image)))', - robots=robots, bt=base_title, desc=desc, canon=canonical, - ogt="article" if is_article else "website", + return render("blog-meta", + robots=robots, base_title=base_title, desc=desc, canonical=canonical, + og_type="article" if is_article else "website", og_title=og_title, image=image, - twc="summary_large_image" if image else "summary", - tw_title=tw_title, + twitter_card="summary_large_image" if image else "summary", + twitter_title=tw_title, ) @@ -1115,7 +867,7 @@ def _home_main_panel_html(ctx: dict) -> str: """Home page content — renders the Ghost page HTML.""" post = ctx.get("post") or {} html = post.get("html", "") - return sexp('(article :class "relative" (div :class "blog-content p-2" (raw! h)))', h=html) + return render("blog-home-main", html_content=html) # --------------------------------------------------------------------------- @@ -1123,7 +875,7 @@ def _home_main_panel_html(ctx: dict) -> str: # --------------------------------------------------------------------------- def _post_admin_main_panel_html(ctx: dict) -> str: - return sexp('(div :class "pb-8")') + return render("blog-admin-empty") # --------------------------------------------------------------------------- @@ -1131,7 +883,7 @@ def _post_admin_main_panel_html(ctx: dict) -> str: # --------------------------------------------------------------------------- def _settings_main_panel_html(ctx: dict) -> str: - return sexp('(div :class "max-w-2xl mx-auto px-4 py-6")') + return render("blog-settings-empty") def _cache_main_panel_html(ctx: dict) -> str: @@ -1139,15 +891,7 @@ def _cache_main_panel_html(ctx: dict) -> str: csrf = ctx.get("csrf_token", "") clear_url = qurl("settings.cache_clear") - return sexp( - '(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"' - ' (div :class "flex flex-col md:flex-row gap-3 items-start"' - ' (form :hx-post cu :hx-trigger "submit" :hx-target "#cache-status" :hx-swap "innerHTML"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (button :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" :type "submit" "Clear cache"))' - ' (div :id "cache-status" :class "py-2")))', - cu=clear_url, csrf=csrf, - ) + return render("blog-cache-panel", clear_url=clear_url, csrf=csrf) # --------------------------------------------------------------------------- @@ -1156,13 +900,7 @@ def _cache_main_panel_html(ctx: dict) -> str: def _snippets_main_panel_html(ctx: dict) -> str: sl = _snippets_list_html(ctx) - return sexp( - '(div :class "max-w-4xl mx-auto p-6"' - ' (div :class "mb-6 flex justify-between items-center"' - ' (h1 :class "text-3xl font-bold" "Snippets"))' - ' (div :id "snippets-list" (raw! sl)))', - sl=sl, - ) + return render("blog-snippets-panel", list_html=sl) def _snippets_list_html(ctx: dict) -> str: @@ -1176,12 +914,7 @@ def _snippets_list_html(ctx: dict) -> str: user_id = getattr(user, "id", None) if not snippets: - return sexp( - '(div :class "bg-white rounded-lg shadow"' - ' (div :class "p-8 text-center text-stone-400"' - ' (i :class "fa fa-puzzle-piece text-4xl mb-2")' - ' (p "No snippets yet. Create one from the blog editor.")))', - ) + return render("blog-snippets-empty") badge_colours = { "private": "bg-stone-200 text-stone-700", @@ -1204,47 +937,31 @@ def _snippets_list_html(ctx: dict) -> str: patch_url = qurl("snippets.patch_visibility", snippet_id=s_id) opts = "" for v in ["private", "shared", "admin"]: - opts += sexp( - '(option :value v :selected sel v)', - v=v, sel=(s_vis == v), + opts += render("blog-snippet-option", + value=v, selected=(s_vis == v), label=v, ) - extra += sexp( - '(select :name "visibility" :hx-patch pu :hx-target "#snippets-list" :hx-swap "innerHTML"' - ' :hx-headers hh :class "text-sm border border-stone-300 rounded px-2 py-1"' - ' (raw! opts))', - pu=patch_url, hh=f'{{"X-CSRFToken": "{csrf}"}}', opts=opts, + extra += render("blog-snippet-visibility-select", + patch_url=patch_url, + hx_headers=f'{{"X-CSRFToken": "{csrf}"}}', + options_html=opts, + cls="text-sm border border-stone-300 rounded px-2 py-1", ) if s_uid == user_id or is_admin: del_url = qurl("snippets.delete_snippet", snippet_id=s_id) - extra += sexp( - '(button :type "button" :data-confirm "" :data-confirm-title "Delete snippet?"' - ' :data-confirm-text ct :data-confirm-icon "warning"' - ' :data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel"' - ' :data-confirm-event "confirmed"' - ' :hx-delete du :hx-trigger "confirmed" :hx-target "#snippets-list" :hx-swap "innerHTML"' - ' :hx-headers hh' - ' :class "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0"' - ' (i :class "fa fa-trash") " Delete")', - ct=f'Delete \u201c{s_name}\u201d?', - du=del_url, hh=f'{{"X-CSRFToken": "{csrf}"}}', + extra += render("blog-snippet-delete-button", + confirm_text=f'Delete \u201c{s_name}\u201d?', + delete_url=del_url, + hx_headers=f'{{"X-CSRFToken": "{csrf}"}}', ) - row_parts.append(sexp( - '(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"' - ' (div :class "flex-1 min-w-0"' - ' (div :class "font-medium truncate" sn)' - ' (div :class "text-xs text-stone-500" ow))' - ' (span :class (str "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium " bc) sv)' - ' (raw! ex))', - sn=s_name, ow=owner, bc=badge_cls, sv=s_vis, ex=extra, + row_parts.append(render("blog-snippet-row", + name=s_name, owner=owner, badge_cls=badge_cls, + visibility=s_vis, extra_html=extra, )) rows = "".join(row_parts) - return sexp( - '(div :class "bg-white rounded-lg shadow" (div :class "divide-y" (raw! rows)))', - rows=rows, - ) + return render("blog-snippets-list", rows_html=rows) # --------------------------------------------------------------------------- @@ -1256,16 +973,7 @@ def _menu_items_main_panel_html(ctx: dict) -> str: new_url = qurl("menu_items.new_menu_item") ml = _menu_items_list_html(ctx) - return sexp( - '(div :class "max-w-4xl mx-auto p-6"' - ' (div :class "mb-6 flex justify-end items-center"' - ' (button :type "button" :hx-get nu :hx-target "#menu-item-form" :hx-swap "innerHTML"' - ' :class "px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"' - ' (i :class "fa fa-plus") " Add Menu Item"))' - ' (div :id "menu-item-form" :class "mb-6")' - ' (div :id "menu-items-list" (raw! ml)))', - nu=new_url, ml=ml, - ) + return render("blog-menu-items-panel", new_url=new_url, list_html=ml) def _menu_items_list_html(ctx: dict) -> str: @@ -1275,12 +983,7 @@ def _menu_items_list_html(ctx: dict) -> str: csrf = ctx.get("csrf_token", "") if not menu_items: - return sexp( - '(div :class "bg-white rounded-lg shadow"' - ' (div :class "p-8 text-center text-stone-400"' - ' (i :class "fa fa-inbox text-4xl mb-2")' - ' (p "No menu items yet. Add one to get started!")))', - ) + return render("blog-menu-items-empty") row_parts = [] for item in menu_items: @@ -1293,43 +996,17 @@ def _menu_items_list_html(ctx: dict) -> str: edit_url = qurl("menu_items.edit_menu_item", item_id=i_id) del_url = qurl("menu_items.delete_menu_item_route", item_id=i_id) - img_html = sexp( - '(if fi (img :src fi :alt lb :class "w-12 h-12 rounded-full object-cover flex-shrink-0")' - ' (div :class "w-12 h-12 rounded-full bg-stone-200 flex-shrink-0"))', - fi=fi, lb=label, - ) + img_html = render("blog-menu-item-image", src=fi, label=label) - row_parts.append(sexp( - '(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"' - ' (div :class "text-stone-400 cursor-move" (i :class "fa fa-grip-vertical"))' - ' (raw! img)' - ' (div :class "flex-1 min-w-0"' - ' (div :class "font-medium truncate" lb)' - ' (div :class "text-xs text-stone-500 truncate" sl))' - ' (div :class "text-sm text-stone-500" (str "Order: " so))' - ' (div :class "flex gap-2 flex-shrink-0"' - ' (button :type "button" :hx-get eu :hx-target "#menu-item-form" :hx-swap "innerHTML"' - ' :class "px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded"' - ' (i :class "fa fa-edit") " Edit")' - ' (button :type "button" :data-confirm "" :data-confirm-title "Delete menu item?"' - ' :data-confirm-text ct :data-confirm-icon "warning"' - ' :data-confirm-confirm-text "Yes, delete" :data-confirm-cancel-text "Cancel"' - ' :data-confirm-event "confirmed"' - ' :hx-delete du :hx-trigger "confirmed" :hx-target "#menu-items-list" :hx-swap "innerHTML"' - ' :hx-headers hh' - ' :class "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800"' - ' (i :class "fa fa-trash") " Delete")))', - img=img_html, lb=label, sl=slug, so=str(sort), - eu=edit_url, du=del_url, - ct=f"Remove {label} from the menu?", - hh=f'{{"X-CSRFToken": "{csrf}"}}', + row_parts.append(render("blog-menu-item-row", + img_html=img_html, label=label, slug=slug, + sort_order=str(sort), edit_url=edit_url, delete_url=del_url, + confirm_text=f"Remove {label} from the menu?", + hx_headers=f'{{"X-CSRFToken": "{csrf}"}}', )) rows = "".join(row_parts) - return sexp( - '(div :class "bg-white rounded-lg shadow" (div :class "divide-y" (raw! rows)))', - rows=rows, - ) + return render("blog-menu-items-list", rows_html=rows) # --------------------------------------------------------------------------- @@ -1344,17 +1021,8 @@ def _tag_groups_main_panel_html(ctx: dict) -> str: csrf = ctx.get("csrf_token", "") create_url = qurl("blog.tag_groups_admin.create") - form_html = sexp( - '(form :method "post" :action cu :class "border rounded p-4 bg-white space-y-3"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (h3 :class "text-sm font-semibold text-stone-700" "New Group")' - ' (div :class "flex flex-col sm:flex-row gap-3"' - ' (input :type "text" :name "name" :placeholder "Group name" :required "" :class "flex-1 border rounded px-3 py-2 text-sm")' - ' (input :type "text" :name "colour" :placeholder "#colour" :class "w-28 border rounded px-3 py-2 text-sm")' - ' (input :type "number" :name "sort_order" :placeholder "Order" :value "0" :class "w-20 border rounded px-3 py-2 text-sm"))' - ' (input :type "text" :name "feature_image" :placeholder "Image URL (optional)" :class "w-full border rounded px-3 py-2 text-sm")' - ' (button :type "submit" :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" "Create"))', - cu=create_url, csrf=csrf, + form_html = render("blog-tag-groups-create-form", + create_url=create_url, csrf=csrf, ) # Groups list @@ -1372,33 +1040,18 @@ def _tag_groups_main_panel_html(ctx: dict) -> str: edit_href = qurl("blog.tag_groups_admin.edit", id=g_id) if g_fi: - icon = sexp( - '(img :src fi :alt n :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0")', - fi=g_fi, n=g_name, - ) + icon = render("blog-tag-group-icon-image", src=g_fi, name=g_name) else: style = f"background-color: {g_colour}; color: white;" if g_colour else "background-color: #e7e5e4; color: #57534e;" - icon = sexp( - '(div :class "h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"' - ' :style st i)', - st=style, i=g_name[:1], - ) + icon = render("blog-tag-group-icon-color", style=style, initial=g_name[:1]) - li_parts.append(sexp( - '(li :class "border rounded p-3 bg-white flex items-center gap-3"' - ' (raw! ic)' - ' (div :class "flex-1"' - ' (a :href eh :class "font-medium text-stone-800 hover:underline" gn)' - ' (span :class "text-xs text-stone-500 ml-2" gs))' - ' (span :class "text-xs text-stone-500" (str "order: " so)))', - ic=icon, eh=edit_href, gn=g_name, gs=g_slug, so=str(g_sort), + li_parts.append(render("blog-tag-group-li", + icon_html=icon, edit_href=edit_href, name=g_name, + slug=g_slug, sort_order=str(g_sort), )) - groups_html = sexp( - '(ul :class "space-y-2" (raw! items))', - items="".join(li_parts), - ) + groups_html = render("blog-tag-groups-list", items_html="".join(li_parts)) else: - groups_html = sexp('(p :class "text-stone-500 text-sm" "No tag groups yet.")') + groups_html = render("blog-tag-groups-empty") # Unassigned tags unassigned_html = "" @@ -1406,22 +1059,15 @@ def _tag_groups_main_panel_html(ctx: dict) -> str: tag_spans = [] for tag in unassigned_tags: t_name = getattr(tag, "name", "") if hasattr(tag, "name") else tag.get("name", "") - tag_spans.append(sexp( - '(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200 rounded" tn)', - tn=t_name, - )) - unassigned_html = sexp( - '(div :class "border-t pt-4"' - ' (h3 :class "text-sm font-semibold text-stone-700 mb-2" hd)' - ' (div :class "flex flex-wrap gap-2" (raw! spans)))', - hd=f"Unassigned Tags ({len(unassigned_tags)})", - spans="".join(tag_spans), + tag_spans.append(render("blog-unassigned-tag", name=t_name)) + unassigned_html = render("blog-unassigned-tags", + heading=f"Unassigned Tags ({len(unassigned_tags)})", + spans_html="".join(tag_spans), ) - return sexp( - '(div :class "max-w-2xl mx-auto px-4 py-6 space-y-8"' - ' (raw! fh) (raw! gh) (raw! uh))', - fh=form_html, gh=groups_html, uh=unassigned_html, + return render("blog-tag-groups-main", + form_html=form_html, groups_html=groups_html, + unassigned_html=unassigned_html, ) @@ -1449,52 +1095,23 @@ def _tag_groups_edit_main_panel_html(ctx: dict) -> str: t_name = getattr(tag, "name", "") if hasattr(tag, "name") else tag.get("name", "") t_fi = getattr(tag, "feature_image", None) if hasattr(tag, "feature_image") else tag.get("feature_image") checked = t_id in assigned_tag_ids - img = sexp( - '(img :src fi :alt "" :class "h-4 w-4 rounded-full object-cover")', - fi=t_fi, - ) if t_fi else "" - tag_items.append(sexp( - '(label :class "flex items-center gap-2 px-2 py-1 hover:bg-stone-50 rounded text-sm cursor-pointer"' - ' (input :type "checkbox" :name "tag_ids" :value tid :checked ch :class "rounded border-stone-300")' - ' (raw! im) (span tn))', - tid=str(t_id), ch=checked, im=img, tn=t_name, + img = render("blog-tag-checkbox-image", src=t_fi) if t_fi else "" + tag_items.append(render("blog-tag-checkbox", + tag_id=str(t_id), checked=checked, img_html=img, name=t_name, )) - edit_form = sexp( - '(form :method "post" :action su :class "border rounded p-4 bg-white space-y-4"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (div :class "space-y-3"' - ' (div (label :class "block text-xs font-medium text-stone-600 mb-1" "Name")' - ' (input :type "text" :name "name" :value gn :required "" :class "w-full border rounded px-3 py-2 text-sm"))' - ' (div :class "flex gap-3"' - ' (div :class "flex-1" (label :class "block text-xs font-medium text-stone-600 mb-1" "Colour")' - ' (input :type "text" :name "colour" :value gc :placeholder "#hex" :class "w-full border rounded px-3 py-2 text-sm"))' - ' (div :class "w-24" (label :class "block text-xs font-medium text-stone-600 mb-1" "Order")' - ' (input :type "number" :name "sort_order" :value gs :class "w-full border rounded px-3 py-2 text-sm")))' - ' (div (label :class "block text-xs font-medium text-stone-600 mb-1" "Feature Image URL")' - ' (input :type "text" :name "feature_image" :value gfi :placeholder "https://..." :class "w-full border rounded px-3 py-2 text-sm")))' - ' (div (label :class "block text-xs font-medium text-stone-600 mb-2" "Assign Tags")' - ' (div :class "grid grid-cols-1 sm:grid-cols-2 gap-1 max-h-64 overflow-y-auto border rounded p-2"' - ' (raw! tags)))' - ' (div :class "flex gap-3"' - ' (button :type "submit" :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" "Save")))', - su=save_url, csrf=csrf, - gn=g_name, gc=g_colour or "", gs=str(g_sort), gfi=g_fi or "", - tags="".join(tag_items), + edit_form = render("blog-tag-group-edit-form", + save_url=save_url, csrf=csrf, + name=g_name, colour=g_colour or "", sort_order=str(g_sort), + feature_image=g_fi or "", tags_html="".join(tag_items), ) - del_form = sexp( - '(form :method "post" :action du :class "border-t pt-4"' - ' :onsubmit "return confirm(\'Delete this tag group? Tags will not be deleted.\')"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (button :type "submit" :class "border rounded px-4 py-2 bg-red-600 text-white text-sm" "Delete Group"))', - du=del_url, csrf=csrf, + del_form = render("blog-tag-group-delete-form", + delete_url=del_url, csrf=csrf, ) - return sexp( - '(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"' - ' (raw! ef) (raw! df))', - ef=edit_form, df=del_form, + return render("blog-tag-group-edit-main", + edit_form_html=edit_form, delete_form_html=del_form, ) @@ -1600,71 +1217,17 @@ def render_editor_panel(save_error: str | None = None, is_page: bool = False) -> # Error banner if save_error: - parts.append(sexp( - '(div :class "max-w-[768px] mx-auto mt-[16px] rounded-[8px] border border-red-300' - ' bg-red-50 px-[16px] py-[12px] text-[14px] text-red-700"' - ' (strong "Save failed:") " " err)', - err=str(save_error), - )) + parts.append(render("blog-editor-error", error=str(save_error))) # Form structure - form_html = sexp( - '(form :id "post-new-form" :method "post" :class "max-w-[768px] mx-auto pb-[48px]"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (input :type "hidden" :id "lexical-json-input" :name "lexical" :value "")' - ' (input :type "hidden" :id "feature-image-input" :name "feature_image" :value "")' - ' (input :type "hidden" :id "feature-image-caption-input" :name "feature_image_caption" :value "")' - ' (div :id "feature-image-container" :class "relative mt-[16px] mb-[24px] group"' - ' (div :id "feature-image-empty"' - ' (button :type "button" :id "feature-image-add-btn"' - ' :class "text-[14px] text-stone-400 hover:text-stone-600 transition-colors cursor-pointer"' - ' "+ Add feature image"))' - ' (div :id "feature-image-filled" :class "relative hidden"' - ' (img :id "feature-image-preview" :src "" :alt ""' - ' :class "w-full max-h-[448px] object-cover rounded-[8px] cursor-pointer")' - ' (button :type "button" :id "feature-image-delete-btn"' - ' :class "absolute top-[8px] right-[8px] w-[32px] h-[32px] rounded-full bg-black/50 text-white' - ' flex items-center justify-center opacity-0 group-hover:opacity-100' - ' transition-opacity hover:bg-black/70 cursor-pointer text-[14px]"' - ' :title "Remove feature image"' - ' (i :class "fa-solid fa-trash-can"))' - ' (input :type "text" :id "feature-image-caption" :value ""' - ' :placeholder "Add a caption..."' - ' :class "mt-[8px] w-full text-[14px] text-stone-500 bg-transparent border-none' - ' outline-none placeholder:text-stone-300 focus:text-stone-700"))' - ' (div :id "feature-image-uploading"' - ' :class "hidden flex items-center gap-[8px] mt-[8px] text-[14px] text-stone-400"' - ' (i :class "fa-solid fa-spinner fa-spin") " Uploading...")' - ' (input :type "file" :id "feature-image-file"' - ' :accept "image/jpeg,image/png,image/gif,image/webp,image/svg+xml" :class "hidden"))' - ' (input :type "text" :name "title" :value "" :placeholder tp' - ' :class "w-full text-[36px] font-bold bg-transparent border-none outline-none' - ' placeholder:text-stone-300 mb-[8px] leading-tight")' - ' (textarea :name "custom_excerpt" :rows "1" :placeholder "Add an excerpt..."' - ' :class "w-full text-[18px] text-stone-500 bg-transparent border-none outline-none' - ' placeholder:text-stone-300 resize-none mb-[24px] leading-relaxed")' - ' (div :id "lexical-editor" :class "relative w-full bg-transparent")' - ' (div :class "flex items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200"' - ' (select :name "status"' - ' :class "text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600"' - ' (option :value "draft" :selected t "Draft")' - ' (option :value "published" "Published"))' - ' (button :type "submit"' - ' :class "px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px]' - ' hover:bg-stone-800 transition-colors cursor-pointer" cl)))', - csrf=csrf, tp=title_placeholder, cl=create_label, t=True, + form_html = render("blog-editor-form", + csrf=csrf, title_placeholder=title_placeholder, + create_label=create_label, ) parts.append(form_html) # Editor CSS + inline styles - parts.append(sexp( - '(<> (link :rel "stylesheet" :href ecss)' - ' (style' - ' "#lexical-editor { display: flow-root; }"' - ' "#lexical-editor [data-kg-card=\\"html\\"] * { float: none !important; }"' - ' "#lexical-editor [data-kg-card=\\"html\\"] table { width: 100% !important; }"))', - ecss=editor_css, - )) + parts.append(render("blog-editor-styles", css_href=editor_css)) # Editor JS + init script init_js = ( @@ -1798,10 +1361,7 @@ def render_editor_panel(save_error: str | None = None, is_page: bool = False) -> " }\n" "})();\n" ) - parts.append(sexp( - '(<> (script :src ejs) (script (raw! js)))', - ejs=editor_js, js=init_js, - )) + parts.append(render("blog-editor-scripts", js_src=editor_js, init_js=init_js)) return "".join(parts) @@ -2217,7 +1777,7 @@ def render_menu_items_nav_oob(menu_items, ctx: dict | None = None) -> str: from quart import request as qrequest if not menu_items: - return sexp('(div :id "menu-items-nav-wrapper" :hx-swap-oob "outerHTML")') + return render("blog-nav-empty", wrapper_id="menu-items-nav-wrapper") # Resolve URL helpers from context or fall back to template globals if ctx is None: @@ -2265,51 +1825,27 @@ def render_menu_items_nav_oob(menu_items, ctx: dict | None = None) -> str: selected = "true" if (item_slug == first_seg or item_slug == app_name) else "false" - img_html = sexp( - '(if fi (img :src fi :alt lb :class "w-8 h-8 rounded-full object-cover flex-shrink-0")' - ' (div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"))', - fi=fi, lb=label, - ) + img_html = render("blog-nav-item-image", src=fi, label=label) if item_slug != "cart": - item_parts.append(sexp( - '(div (a :href h :hx-get hg :hx-target "#main-panel"' - ' :hx-swap "outerHTML" :hx-push-url "true"' - ' :aria-selected sel :class nc' - ' (raw! im) (span lb)))', - h=href, hg=f"/{item_slug}/", sel=selected, nc=nav_button_cls, - im=img_html, lb=label, + item_parts.append(render("blog-nav-item-link", + href=href, hx_get=f"/{item_slug}/", selected=selected, + nav_cls=nav_button_cls, img_html=img_html, label=label, )) else: - item_parts.append(sexp( - '(div (a :href h :aria-selected sel :class nc' - ' (raw! im) (span lb)))', - h=href, sel=selected, nc=nav_button_cls, - im=img_html, lb=label, + item_parts.append(render("blog-nav-item-plain", + href=href, selected=selected, nav_cls=nav_button_cls, + img_html=img_html, label=label, )) items_html = "".join(item_parts) - return sexp( - '(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"' - ' :id "menu-items-nav-wrapper" :hx-swap-oob "outerHTML"' - ' (button :class (str ac " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")' - ' :aria-label "Scroll left"' - ' :_ lhs (i :class "fa fa-chevron-left"))' - ' (div :id cid' - ' :class "overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"' - ' :style "scroll-behavior: smooth;" :_ shs' - ' (div :class "flex flex-col sm:flex-row gap-1" (raw! items)))' - ' (style ".scrollbar-hide::-webkit-scrollbar { display: none; }' - ' .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")' - ' (button :class (str ac " hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded")' - ' :aria-label "Scroll right"' - ' :_ rhs (i :class "fa fa-chevron-right")))', - ac=arrow_cls, cid=container_id, - lhs=f"on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft - 200", - shs=scroll_hs, - rhs=f"on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft + 200", - items=items_html, + return render("blog-nav-wrapper", + arrow_cls=arrow_cls, container_id=container_id, + left_hs=f"on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft - 200", + scroll_hs=scroll_hs, + right_hs=f"on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft + 200", + items_html=items_html, ) @@ -2329,67 +1865,27 @@ def render_features_panel(features: dict, post: dict, hs_trigger = "on change trigger submit on closest
" - form_html = sexp( - '(form :hx-put fu :hx-target "#features-panel" :hx-swap "outerHTML"' - ' :hx-headers "{\\\"Content-Type\\\": \\\"application/json\\\"}" :hx-ext "json-enc" :class "space-y-3"' - ' (label :class "flex items-center gap-3 cursor-pointer"' - ' (input :type "checkbox" :name "calendar" :value "true" :checked cc' - ' :class "h-5 w-5 rounded border-stone-300 text-blue-600 focus:ring-blue-500"' - ' :_ ht)' - ' (span :class "text-sm text-stone-700"' - ' (i :class "fa fa-calendar text-blue-600 mr-1")' - ' " Calendar \u2014 enable event booking on this page"))' - ' (label :class "flex items-center gap-3 cursor-pointer"' - ' (input :type "checkbox" :name "market" :value "true" :checked mc' - ' :class "h-5 w-5 rounded border-stone-300 text-green-600 focus:ring-green-500"' - ' :_ ht)' - ' (span :class "text-sm text-stone-700"' - ' (i :class "fa fa-shopping-bag text-green-600 mr-1")' - ' " Market \u2014 enable product catalog on this page")))', - fu=features_url, cc=bool(features.get("calendar")), mc=bool(features.get("market")), - ht=hs_trigger, + form_html = render("blog-features-form", + features_url=features_url, + calendar_checked=bool(features.get("calendar")), + market_checked=bool(features.get("market")), + hs_trigger=hs_trigger, ) sumup_html = "" if features.get("calendar") or features.get("market"): placeholder = "\u2022" * 8 if sumup_configured else "sup_sk_..." - connected = sexp( - '(span :class "ml-2 text-xs text-green-600" (i :class "fa fa-check-circle") " Connected")', - ) if sumup_configured else "" - key_hint = sexp( - '(p :class "text-xs text-stone-400 mt-0.5" "Key is set. Leave blank to keep current key.")', - ) if sumup_configured else "" + connected = render("blog-sumup-connected") if sumup_configured else "" + key_hint = render("blog-sumup-key-hint") if sumup_configured else "" - sumup_html = sexp( - '(div :class "mt-4 pt-4 border-t border-stone-100"' - ' (h4 :class "text-sm font-medium text-stone-700"' - ' (i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment")' - ' (p :class "text-xs text-stone-400 mt-1 mb-3"' - ' "Configure per-page SumUp credentials. Leave blank to use the global merchant account.")' - ' (form :hx-put su :hx-target "#features-panel" :hx-swap "outerHTML" :class "space-y-3"' - ' (div (label :class "block text-xs font-medium text-stone-600 mb-1" "Merchant Code")' - ' (input :type "text" :name "merchant_code" :value smc :placeholder "e.g. ME4J6100"' - ' :class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))' - ' (div (label :class "block text-xs font-medium text-stone-600 mb-1" "API Key")' - ' (input :type "password" :name "api_key" :value "" :placeholder ph' - ' :class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500")' - ' (raw! kh))' - ' (div (label :class "block text-xs font-medium text-stone-600 mb-1" "Checkout Reference Prefix")' - ' (input :type "text" :name "checkout_prefix" :value scp :placeholder "e.g. ROSE-"' - ' :class "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"))' - ' (button :type "submit"' - ' :class "px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500"' - ' "Save SumUp Settings")' - ' (raw! cn)))', - su=sumup_url, smc=sumup_merchant_code, ph=placeholder, - kh=key_hint, scp=sumup_checkout_prefix, cn=connected, + sumup_html = render("blog-sumup-form", + sumup_url=sumup_url, merchant_code=sumup_merchant_code, + placeholder=placeholder, key_hint_html=key_hint, + checkout_prefix=sumup_checkout_prefix, connected_html=connected, ) - return sexp( - '(div :id "features-panel" :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200"' - ' (h3 :class "text-lg font-semibold text-stone-800" "Page Features")' - ' (raw! fh) (raw! sh))', - fh=form_html, sh=sumup_html, + return render("blog-features-panel", + form_html=form_html, sumup_html=sumup_html, ) @@ -2410,32 +1906,16 @@ def render_markets_panel(markets, post: dict) -> str: m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "") m_slug = getattr(m, "slug", "") if hasattr(m, "slug") else m.get("slug", "") del_url = host_url(qurl("blog.post.admin.delete_market", slug=slug, market_slug=m_slug)) - li_parts.append(sexp( - '(li :class "flex items-center justify-between p-3 bg-stone-50 rounded"' - ' (div (span :class "font-medium" mn)' - ' (span :class "text-stone-400 text-sm ml-2" (str "/" ms "/")))' - ' (button :hx-delete du :hx-target "#markets-panel" :hx-swap "outerHTML"' - ' :hx-confirm cf :class "text-red-600 hover:text-red-800 text-sm" "Delete"))', - mn=m_name, ms=m_slug, du=del_url, - cf=f"Delete market '{m_name}'?", + li_parts.append(render("blog-market-item", + name=m_name, slug=m_slug, delete_url=del_url, + confirm_text=f"Delete market '{m_name}'?", )) - list_html = sexp( - '(ul :class "space-y-2 mb-4" (raw! items))', - items="".join(li_parts), - ) + list_html = render("blog-markets-list", items_html="".join(li_parts)) else: - list_html = sexp('(p :class "text-stone-500 mb-4 text-sm" "No markets yet.")') + list_html = render("blog-markets-empty") - return sexp( - '(div :id "markets-panel"' - ' (h3 :class "text-lg font-semibold mb-3" "Markets")' - ' (raw! lh)' - ' (form :hx-post cu :hx-target "#markets-panel" :hx-swap "outerHTML" :class "flex gap-2"' - ' (input :type "text" :name "name" :placeholder "Market name" :required ""' - ' :class "flex-1 border border-stone-300 rounded px-3 py-1.5 text-sm")' - ' (button :type "submit"' - ' :class "bg-stone-800 text-white px-4 py-1.5 rounded text-sm hover:bg-stone-700" "Create")))', - lh=list_html, cu=create_url, + return render("blog-markets-panel", + list_html=list_html, create_url=create_url, ) @@ -2471,56 +1951,28 @@ def render_associated_entries(all_calendars, associated_entry_ids, post_slug: st toggle_url = host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=e_id)) - img_html = sexp( - '(if fi (img :src fi :alt ct :class "w-8 h-8 rounded-full object-cover flex-shrink-0")' - ' (div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"))', - fi=cal_fi, ct=cal_title, - ) + img_html = render("blog-entry-image", src=cal_fi, title=cal_title) date_str = e_start.strftime("%A, %B %d, %Y at %H:%M") if e_start else "" if e_end: date_str += f" \u2013 {e_end.strftime('%H:%M')}" - entry_items.append(sexp( - '(button :type "button"' - ' :class "w-full text-left p-3 rounded border bg-green-50 border-green-300 transition hover:bg-green-100"' - ' :data-confirm "" :data-confirm-title "Remove entry?"' - ' :data-confirm-text ct :data-confirm-icon "warning"' - ' :data-confirm-confirm-text "Yes, remove it"' - ' :data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"' - ' :hx-post tu :hx-trigger "confirmed"' - ' :hx-target "#associated-entries-list" :hx-swap "outerHTML"' - ' :hx-headers hh' - ' :_ "on htmx:afterRequest trigger entryToggled on body"' - ' (div :class "flex items-center justify-between gap-3"' - ' (raw! im)' - ' (div :class "flex-1"' - ' (div :class "font-medium text-sm" en)' - ' (div :class "text-xs text-stone-600 mt-1" ds))' - ' (i :class "fa fa-times-circle text-green-600 text-lg flex-shrink-0")))', - ct=f"This will remove {e_name} from this post", - tu=toggle_url, hh=f'{{"X-CSRFToken": "{csrf}"}}', - im=img_html, en=e_name, - ds=f"{cal_name} \u2022 {date_str}", + entry_items.append(render("blog-associated-entry", + confirm_text=f"This will remove {e_name} from this post", + toggle_url=toggle_url, + hx_headers=f'{{"X-CSRFToken": "{csrf}"}}', + img_html=img_html, name=e_name, + date_str=f"{cal_name} \u2022 {date_str}", )) if has_entries: - content_html = sexp( - '(div :class "space-y-1" (raw! items))', - items="".join(entry_items), + content_html = render("blog-associated-entries-content", + items_html="".join(entry_items), ) else: - content_html = sexp( - '(div :class "text-sm text-stone-400"' - ' "No entries associated yet. Browse calendars below to add entries.")', - ) + content_html = render("blog-associated-entries-empty") - return sexp( - '(div :id "associated-entries-list" :class "border rounded-lg p-4 bg-white"' - ' (h3 :class "text-lg font-semibold mb-4" "Associated Entries")' - ' (raw! ch))', - ch=content_html, - ) + return render("blog-associated-entries-panel", content_html=content_html) # ---- Nav entries OOB ---- @@ -2541,7 +1993,7 @@ def render_nav_entries_oob(associated_entries, calendars, post: dict, ctx: dict has_items = bool(entries_list or calendars) if not has_items: - return sexp('(div :id "entries-calendars-nav-wrapper" :hx-swap-oob "true")') + return render("blog-nav-entries-empty") events_url_fn = ctx.get("events_url") @@ -2589,13 +2041,8 @@ def render_nav_entries_oob(associated_entries, calendars, post: dict, ctx: dict href = events_url_fn(entry_path) if events_url_fn else entry_path - item_parts.append(sexp( - '(a :href h :class nc' - ' (div :class "w-8 h-8 rounded bg-stone-200 flex-shrink-0")' - ' (div :class "flex-1 min-w-0"' - ' (div :class "font-medium truncate" en)' - ' (div :class "text-xs text-stone-600 truncate" ds)))', - h=href, nc=nav_cls, en=e_name, ds=date_str, + item_parts.append(render("blog-nav-entry-item", + href=href, nav_cls=nav_cls, name=e_name, date_str=date_str, )) # Calendar links @@ -2605,31 +2052,12 @@ def render_nav_entries_oob(associated_entries, calendars, post: dict, ctx: dict cal_path = f"/{post_slug}/{cal_slug}/" href = events_url_fn(cal_path) if events_url_fn else cal_path - item_parts.append(sexp( - '(a :href h :class nc' - ' (i :class "fa fa-calendar" :aria-hidden "true")' - ' (div cn))', - h=href, nc=nav_cls, cn=cal_name, + item_parts.append(render("blog-nav-calendar-item", + href=href, nav_cls=nav_cls, name=cal_name, )) items_html = "".join(item_parts) - return sexp( - '(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"' - ' :id "entries-calendars-nav-wrapper" :hx-swap-oob "true"' - ' (button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"' - ' :aria-label "Scroll left"' - ' :_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200"' - ' (i :class "fa fa-chevron-left"))' - ' (div :id "associated-items-container"' - ' :class "overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"' - ' :style "scroll-behavior: smooth;" :_ shs' - ' (div :class "flex flex-col sm:flex-row gap-1" (raw! items)))' - ' (style ".scrollbar-hide::-webkit-scrollbar { display: none; }' - ' .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")' - ' (button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"' - ' :aria-label "Scroll right"' - ' :_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200"' - ' (i :class "fa fa-chevron-right")))', - shs=scroll_hs, items=items_html, + return render("blog-nav-entries-wrapper", + scroll_hs=scroll_hs, items_html=items_html, ) diff --git a/cart/sexp/calendar.sexpr b/cart/sexp/calendar.sexpr new file mode 100644 index 0000000..450e710 --- /dev/null +++ b/cart/sexp/calendar.sexpr @@ -0,0 +1,12 @@ +;; Cart calendar entry components + +(defcomp ~cart-cal-entry (&key name date-str cost) + (li :class "flex items-start justify-between text-sm" + (div (div :class "font-medium" (raw! name)) + (div :class "text-xs text-stone-500" (raw! date-str))) + (div :class "ml-4 font-medium" (raw! cost)))) + +(defcomp ~cart-cal-section (&key items-html) + (div :class "mt-6 border-t border-stone-200 pt-4" + (h2 :class "text-base font-semibold mb-2" "Calendar bookings") + (ul :class "space-y-2" (raw! items-html)))) diff --git a/cart/sexp/checkout.sexpr b/cart/sexp/checkout.sexpr new file mode 100644 index 0000000..3d792d9 --- /dev/null +++ b/cart/sexp/checkout.sexpr @@ -0,0 +1,20 @@ +;; Cart checkout error components + +(defcomp ~cart-checkout-error-filter () + (header :class "mb-6 sm:mb-8" + (h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Checkout error") + (p :class "text-xs sm:text-sm text-stone-600" + "We tried to start your payment with SumUp but hit a problem."))) + +(defcomp ~cart-checkout-error-order-id (&key order-id) + (p :class "text-xs text-rose-800/80" + "Order ID: " (span :class "font-mono" (raw! order-id)))) + +(defcomp ~cart-checkout-error-content (&key error-msg order-html back-url) + (div :class "max-w-full px-3 py-3 space-y-4" + (div :class "rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2" + (p :class "font-medium" "Something went wrong.") + (p (raw! error-msg)) + (raw! order-html)) + (div (a :href back-url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition" + (i :class "fa fa-shopping-cart mr-2" :aria-hidden "true") "Back to cart")))) diff --git a/cart/sexp/header.sexpr b/cart/sexp/header.sexpr new file mode 100644 index 0000000..1d845f5 --- /dev/null +++ b/cart/sexp/header.sexpr @@ -0,0 +1,44 @@ +;; Cart header components + +(defcomp ~cart-page-label-img (&key src) + (img :src src :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0")) + +(defcomp ~cart-all-carts-link (&key href) + (a :href href :class "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition" + (i :class "fa fa-arrow-left text-xs" :aria-hidden "true") "All carts")) + +(defcomp ~cart-header-child (&key inner-html) + (div :id "root-header-child" :class "flex flex-col w-full items-center" + (raw! inner-html))) + +(defcomp ~cart-header-child-nested (&key outer-html inner-html) + (div :id "root-header-child" :class "flex flex-col w-full items-center" + (raw! outer-html) + (div :id "cart-header-child" :class "flex flex-col w-full items-center" + (raw! inner-html)))) + +(defcomp ~cart-header-child-oob (&key inner-html) + (div :id "cart-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center" + (raw! inner-html))) + +(defcomp ~cart-auth-header-child (&key auth-html orders-html) + (div :id "root-header-child" :class "flex flex-col w-full items-center" + (raw! auth-html) + (div :id "auth-header-child" :class "flex flex-col w-full items-center" + (raw! orders-html)))) + +(defcomp ~cart-auth-header-child-oob (&key inner-html) + (div :id "auth-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center" + (raw! inner-html))) + +(defcomp ~cart-order-header-child (&key auth-html orders-html order-html) + (div :id "root-header-child" :class "flex flex-col w-full items-center" + (raw! auth-html) + (div :id "auth-header-child" :class "flex flex-col w-full items-center" + (raw! orders-html) + (div :id "orders-header-child" :class "flex flex-col w-full items-center" + (raw! order-html))))) + +(defcomp ~cart-orders-header-child-oob (&key inner-html) + (div :id "orders-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center" + (raw! inner-html))) diff --git a/cart/sexp/items.sexpr b/cart/sexp/items.sexpr new file mode 100644 index 0000000..32dbc4f --- /dev/null +++ b/cart/sexp/items.sexpr @@ -0,0 +1,66 @@ +;; Cart item components + +(defcomp ~cart-item-img (&key src alt) + (img :src src :alt alt :class "w-24 h-24 sm:w-32 sm:h-28 object-cover rounded-xl border border-stone-100" :loading "lazy")) + +(defcomp ~cart-item-no-img () + (div :class "w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300 flex items-center justify-center text-xs text-stone-400" + "No image")) + +(defcomp ~cart-item-price (&key text) + (p :class "text-sm sm:text-base font-semibold text-stone-900" (raw! text))) + +(defcomp ~cart-item-price-was (&key text) + (p :class "text-xs text-stone-400 line-through" (raw! text))) + +(defcomp ~cart-item-no-price () + (p :class "text-xs text-stone-500" "No price")) + +(defcomp ~cart-item-deleted () + (p :class "mt-2 inline-flex items-center gap-1 text-[0.65rem] sm:text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5" + (i :class "fa-solid fa-triangle-exclamation text-[0.6rem]" :aria-hidden "true") + " This item is no longer available or price has changed")) + +(defcomp ~cart-item-brand (&key brand) + (p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" (raw! brand))) + +(defcomp ~cart-item-line-total (&key text) + (p :class "text-sm sm:text-base font-semibold text-stone-900" (raw! text))) + +(defcomp ~cart-item (&key id img-html prod-url title brand-html deleted-html price-html qty-url csrf minus qty plus line-total-html) + (article :id id :class "flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4 md:p-5" + (div :class "w-full sm:w-32 shrink-0 flex justify-center sm:block" (raw! img-html)) + (div :class "flex-1 min-w-0" + (div :class "flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3" + (div :class "min-w-0" + (h2 :class "text-sm sm:text-base md:text-lg font-semibold text-stone-900" + (a :href prod-url :class "hover:text-emerald-700" (raw! title))) + (raw! brand-html) (raw! deleted-html)) + (div :class "text-left sm:text-right" (raw! price-html))) + (div :class "mt-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4" + (div :class "flex items-center gap-2 text-xs sm:text-sm text-stone-700" + (span :class "text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500" "Quantity") + (form :action qty-url :method "post" :hx-post qty-url :hx-swap "none" + (input :type "hidden" :name "csrf_token" :value csrf) + (input :type "hidden" :name "count" :value minus) + (button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "-")) + (span :class "inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium" (raw! qty)) + (form :action qty-url :method "post" :hx-post qty-url :hx-swap "none" + (input :type "hidden" :name "csrf_token" :value csrf) + (input :type "hidden" :name "count" :value plus) + (button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "+"))) + (div :class "flex items-center justify-between sm:justify-end gap-3" (raw! line-total-html)))))) + +(defcomp ~cart-page-empty () + (div :class "max-w-full px-3 py-3 space-y-3" + (div :id "cart" + (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center" + (div :class "inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3" + (i :class "fa fa-shopping-cart text-stone-500 text-sm sm:text-base" :aria-hidden "true")) + (p :class "text-base sm:text-lg font-medium text-stone-800" "Your cart is empty"))))) + +(defcomp ~cart-page-panel (&key items-html cal-html tickets-html summary-html) + (div :class "max-w-full px-3 py-3 space-y-3" + (div :id "cart" + (div (section :class "space-y-3 sm:space-y-4" (raw! items-html) (raw! cal-html) (raw! tickets-html)) + (raw! summary-html))))) diff --git a/cart/sexp/order_detail.sexpr b/cart/sexp/order_detail.sexpr new file mode 100644 index 0000000..134f324 --- /dev/null +++ b/cart/sexp/order_detail.sexpr @@ -0,0 +1,53 @@ +;; Cart single order detail components + +(defcomp ~cart-order-item-img (&key src alt) + (img :src src :alt alt :class "w-full h-full object-contain object-center" :loading "lazy" :decoding "async")) + +(defcomp ~cart-order-item-no-img () + (div :class "w-full h-full flex items-center justify-center text-[9px] text-stone-400" "No image")) + +(defcomp ~cart-order-item (&key prod-url img-html title product-id qty price) + (li (a :class "w-full py-2 flex gap-3" :href prod-url + (div :class "w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden" (raw! img-html)) + (div :class "flex-1 flex justify-between gap-3" + (div (p :class "font-medium" (raw! title)) + (p :class "text-[11px] text-stone-500" (raw! product-id))) + (div :class "text-right whitespace-nowrap" + (p (raw! qty)) (p (raw! price))))))) + +(defcomp ~cart-order-items-panel (&key items-html) + (div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6" + (h2 :class "text-sm sm:text-base font-semibold mb-3" "Items") + (ul :class "divide-y divide-stone-100 text-xs sm:text-sm" (raw! items-html)))) + +(defcomp ~cart-order-cal-entry (&key name pill status date-str cost) + (li :class "px-4 py-3 flex items-start justify-between text-sm" + (div (div :class "font-medium flex items-center gap-2" + (raw! name) (span :class pill (raw! status))) + (div :class "text-xs text-stone-500" (raw! date-str))) + (div :class "ml-4 font-medium" (raw! cost)))) + +(defcomp ~cart-order-cal-section (&key items-html) + (section :class "mt-6 space-y-3" + (h2 :class "text-base sm:text-lg font-semibold" "Calendar bookings in this order") + (ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" (raw! items-html)))) + +(defcomp ~cart-order-main (&key summary-html items-html cal-html) + (div :class "max-w-full px-3 py-3 space-y-4" (raw! summary-html) (raw! items-html) (raw! cal-html))) + +(defcomp ~cart-order-pay-btn (&key url) + (a :href url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition" + (i :class "fa fa-credit-card mr-2" :aria-hidden "true") "Open payment page")) + +(defcomp ~cart-order-filter (&key info list-url recheck-url csrf pay-html) + (header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4" + (div :class "space-y-1" + (p :class "text-xs sm:text-sm text-stone-600" (raw! info))) + (div :class "flex w-full sm:w-auto justify-start sm:justify-end gap-2" + (a :href list-url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition" + (i :class "fa-solid fa-list mr-2" :aria-hidden "true") "All orders") + (form :method "post" :action recheck-url :class "inline" + (input :type "hidden" :name "csrf_token" :value csrf) + (button :type "submit" :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition" + (i :class "fa-solid fa-rotate mr-2" :aria-hidden "true") "Re-check status")) + (raw! pay-html)))) diff --git a/cart/sexp/orders.sexpr b/cart/sexp/orders.sexpr new file mode 100644 index 0000000..728aacc --- /dev/null +++ b/cart/sexp/orders.sexpr @@ -0,0 +1,51 @@ +;; Cart orders list components + +(defcomp ~cart-order-row-desktop (&key order-id created desc total pill status detail-url) + (tr :class "hidden sm:table-row border-t border-stone-100 hover:bg-stone-50/60" + (td :class "px-3 py-2 align-top" (span :class "font-mono text-[11px] sm:text-xs" (raw! order-id))) + (td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" (raw! created)) + (td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" (raw! desc)) + (td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" (raw! total)) + (td :class "px-3 py-2 align-top" (span :class pill (raw! status))) + (td :class "px-3 py-0.5 align-top text-right" + (a :href detail-url :class "inline-flex items-center px-3 py-1.5 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition" "View")))) + +(defcomp ~cart-order-row-mobile (&key order-id pill status created total detail-url) + (tr :class "sm:hidden border-t border-stone-100" + (td :colspan "5" :class "px-3 py-3" + (div :class "flex flex-col gap-2 text-xs" + (div :class "flex items-center justify-between gap-2" + (span :class "font-mono text-[11px] text-stone-700" (raw! order-id)) + (span :class pill (raw! status))) + (div :class "text-[11px] text-stone-500 break-words" (raw! created)) + (div :class "flex items-center justify-between gap-2" + (div :class "font-medium text-stone-800" (raw! total)) + (a :href detail-url :class "inline-flex items-center px-2 py-1 text-[11px] rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition shrink-0" "View")))))) + +(defcomp ~cart-orders-end () + (tr (td :colspan "5" :class "px-3 py-4 text-center text-xs text-stone-400" "End of results"))) + +(defcomp ~cart-orders-empty () + (div :class "max-w-full px-3 py-3 space-y-3" + (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-4 sm:p-6 text-sm text-stone-700" + "No orders yet."))) + +(defcomp ~cart-orders-table (&key rows-html) + (div :class "max-w-full px-3 py-3 space-y-3" + (div :class "overflow-x-auto rounded-2xl border border-stone-200 bg-white/80" + (table :class "min-w-full text-xs sm:text-sm" + (thead :class "bg-stone-50 border-b border-stone-200 text-stone-600" + (tr + (th :class "px-3 py-2 text-left font-medium" "Order") + (th :class "px-3 py-2 text-left font-medium" "Created") + (th :class "px-3 py-2 text-left font-medium" "Description") + (th :class "px-3 py-2 text-left font-medium" "Total") + (th :class "px-3 py-2 text-left font-medium" "Status") + (th :class "px-3 py-2 text-left font-medium"))) + (tbody (raw! rows-html)))))) + +(defcomp ~cart-orders-filter (&key search-mobile-html) + (header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4" + (div :class "space-y-1" + (p :class "text-xs sm:text-sm text-stone-600" "Recent orders placed via the checkout.")) + (div :class "md:hidden" (raw! search-mobile-html)))) diff --git a/cart/sexp/overview.sexpr b/cart/sexp/overview.sexpr new file mode 100644 index 0000000..0a9e54b --- /dev/null +++ b/cart/sexp/overview.sexpr @@ -0,0 +1,52 @@ +;; Cart overview components + +(defcomp ~cart-badge (&key icon text) + (span :class "inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100" + (i :class icon :aria-hidden "true") (raw! text))) + +(defcomp ~cart-badges-wrap (&key badges-html) + (div :class "mt-1 flex flex-wrap gap-2 text-xs text-stone-600" + (raw! badges-html))) + +(defcomp ~cart-group-card-img (&key src alt) + (img :src src :alt alt :class "h-16 w-16 rounded-xl object-cover border border-stone-200 flex-shrink-0")) + +(defcomp ~cart-group-card-placeholder () + (div :class "h-16 w-16 rounded-xl bg-stone-100 flex items-center justify-center flex-shrink-0" + (i :class "fa fa-store text-stone-400 text-xl" :aria-hidden "true"))) + +(defcomp ~cart-mp-subtitle (&key title) + (p :class "text-xs text-stone-500 truncate" (raw! title))) + +(defcomp ~cart-group-card (&key href img-html display-title subtitle-html badges-html total) + (a :href href :class "block rounded-2xl border border-stone-200 bg-white shadow-sm hover:shadow-md hover:border-stone-300 transition p-4 sm:p-5" + (div :class "flex items-start gap-4" + (raw! img-html) + (div :class "flex-1 min-w-0" + (h3 :class "text-base sm:text-lg font-semibold text-stone-900 truncate" (raw! display-title)) + (raw! subtitle-html) (raw! badges-html)) + (div :class "text-right flex-shrink-0" + (div :class "text-lg font-bold text-stone-900" (raw! total)) + (div :class "mt-1 text-xs text-emerald-700 font-medium" "View cart \u2192"))))) + +(defcomp ~cart-orphan-card (&key badges-html total) + (div :class "rounded-2xl border border-dashed border-amber-300 bg-amber-50/60 p-4 sm:p-5" + (div :class "flex items-start gap-4" + (div :class "h-16 w-16 rounded-xl bg-amber-100 flex items-center justify-center flex-shrink-0" + (i :class "fa fa-shopping-cart text-amber-500 text-xl" :aria-hidden "true")) + (div :class "flex-1 min-w-0" + (h3 :class "text-base sm:text-lg font-semibold text-stone-900" "Other items") + (raw! badges-html)) + (div :class "text-right flex-shrink-0" + (div :class "text-lg font-bold text-stone-900" (raw! total)))))) + +(defcomp ~cart-empty () + (div :class "max-w-full px-3 py-3 space-y-3" + (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center" + (div :class "inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3" + (i :class "fa fa-shopping-cart text-stone-500 text-sm sm:text-base" :aria-hidden "true")) + (p :class "text-base sm:text-lg font-medium text-stone-800" "Your cart is empty")))) + +(defcomp ~cart-overview-panel (&key cards-html) + (div :class "max-w-full px-3 py-3 space-y-3" + (div :class "space-y-4" (raw! cards-html)))) diff --git a/cart/sexp/sexp_components.py b/cart/sexp/sexp_components.py index 6c51691..74ce5e1 100644 --- a/cart/sexp/sexp_components.py +++ b/cart/sexp/sexp_components.py @@ -6,15 +6,19 @@ Called from route handlers in place of ``render_template()``. """ from __future__ import annotations +import os from typing import Any -from shared.sexp.jinja_bridge import sexp +from shared.sexp.jinja_bridge import render, load_service_components from shared.sexp.helpers import ( call_url, root_header_html, search_desktop_html, search_mobile_html, full_page, oob_page, ) from shared.infrastructure.urls import market_product_url, cart_url +# Load cart-specific .sexpr components at import time +load_service_components(os.path.dirname(os.path.dirname(__file__))) + # --------------------------------------------------------------------------- # Header helpers @@ -22,12 +26,12 @@ from shared.infrastructure.urls import market_product_url, cart_url def _cart_header_html(ctx: dict, *, oob: bool = False) -> str: """Build the cart section header row.""" - return sexp( - '(~menu-row :id "cart-row" :level 1 :colour "sky"' - ' :link-href lh :link-label "cart" :icon "fa fa-shopping-cart"' - ' :child-id "cart-header-child" :oob oob)', - lh=call_url(ctx, "cart_url", "/"), - oob=oob, + return render( + "menu-row", + id="cart-row", level=1, colour="sky", + link_href=call_url(ctx, "cart_url", "/"), + link_label="cart", icon="fa fa-shopping-cart", + child_id="cart-header-child", oob=oob, ) @@ -37,44 +41,35 @@ def _page_cart_header_html(ctx: dict, page_post: Any, *, oob: bool = False) -> s title = ((page_post.title if page_post else None) or "")[:160] label_html = "" if page_post and page_post.feature_image: - label_html += sexp( - '(img :src fi :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0")', - fi=page_post.feature_image, - ) - label_html += sexp('(span t)', t=title) - nav_html = sexp( - '(a :href h :class "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"' - ' (i :class "fa fa-arrow-left text-xs" :aria-hidden "true") "All carts")', - h=call_url(ctx, "cart_url", "/"), - ) - return sexp( - '(~menu-row :id "page-cart-row" :level 2 :colour "sky"' - ' :link-href lh :link-label-html llh :nav-html nh :oob oob)', - lh=call_url(ctx, "cart_url", f"/{slug}/"), - llh=label_html, - nh=nav_html, - oob=oob, + label_html += render("cart-page-label-img", src=page_post.feature_image) + label_html += f"{title}" + nav_html = render("cart-all-carts-link", href=call_url(ctx, "cart_url", "/")) + return render( + "menu-row", + id="page-cart-row", level=2, colour="sky", + link_href=call_url(ctx, "cart_url", f"/{slug}/"), + link_label_html=label_html, nav_html=nav_html, oob=oob, ) def _auth_header_html(ctx: dict, *, oob: bool = False) -> str: """Build the account section header row (for orders).""" - return sexp( - '(~menu-row :id "auth-row" :level 1 :colour "sky"' - ' :link-href lh :link-label "account" :icon "fa-solid fa-user"' - ' :child-id "auth-header-child" :oob oob)', - lh=call_url(ctx, "account_url", "/"), - oob=oob, + return render( + "menu-row", + id="auth-row", level=1, colour="sky", + link_href=call_url(ctx, "account_url", "/"), + link_label="account", icon="fa-solid fa-user", + child_id="auth-header-child", oob=oob, ) def _orders_header_html(ctx: dict, list_url: str) -> str: """Build the orders section header row.""" - return sexp( - '(~menu-row :id "orders-row" :level 2 :colour "sky"' - ' :link-href lh :link-label "Orders" :icon "fa fa-gbp"' - ' :child-id "orders-header-child")', - lh=list_url, + return render( + "menu-row", + id="orders-row", level=2, colour="sky", + link_href=list_url, link_label="Orders", icon="fa fa-gbp", + child_id="orders-header-child", ) @@ -85,11 +80,7 @@ def _orders_header_html(ctx: dict, list_url: str) -> str: def _badge_html(icon: str, count: int, label: str) -> str: """Render a count badge.""" s = "s" if count != 1 else "" - return sexp( - '(span :class "inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100"' - ' (i :class ic :aria-hidden "true") txt)', - ic=icon, txt=f"{count} {label}{s}", - ) + return render("cart-badge", icon=icon, text=f"{count} {label}{s}") def _page_group_card_html(grp: Any, ctx: dict) -> str: @@ -115,10 +106,7 @@ def _page_group_card_html(grp: Any, ctx: dict) -> str: badges += _badge_html("fa fa-calendar", calendar_count, "booking") if ticket_count > 0: badges += _badge_html("fa fa-ticket", ticket_count, "ticket") - badges_html = sexp( - '(div :class "mt-1 flex flex-wrap gap-2 text-xs text-stone-600" (raw! b))', - b=badges, - ) + badges_html = render("cart-badges-wrap", badges_html=badges) if post: slug = post.slug if hasattr(post, "slug") else post.get("slug", "") @@ -127,66 +115,38 @@ def _page_group_card_html(grp: Any, ctx: dict) -> str: cart_href = call_url(ctx, "cart_url", f"/{slug}/") if feature_image: - img = sexp( - '(img :src fi :alt t :class "h-16 w-16 rounded-xl object-cover border border-stone-200 flex-shrink-0")', - fi=feature_image, t=title, - ) + img = render("cart-group-card-img", src=feature_image, alt=title) else: - img = sexp( - '(div :class "h-16 w-16 rounded-xl bg-stone-100 flex items-center justify-center flex-shrink-0"' - ' (i :class "fa fa-store text-stone-400 text-xl" :aria-hidden "true"))', - ) + img = render("cart-group-card-placeholder") - mp_name = "" mp_sub = "" if market_place: mp_name = market_place.name if hasattr(market_place, "name") else market_place.get("name", "") - mp_sub = sexp('(p :class "text-xs text-stone-500 truncate" t)', t=title) + mp_sub = render("cart-mp-subtitle", title=title) + else: + mp_name = "" display_title = mp_name or title - return sexp( - '(a :href ch :class "block rounded-2xl border border-stone-200 bg-white shadow-sm hover:shadow-md hover:border-stone-300 transition p-4 sm:p-5"' - ' (div :class "flex items-start gap-4"' - ' (raw! img)' - ' (div :class "flex-1 min-w-0"' - ' (h3 :class "text-base sm:text-lg font-semibold text-stone-900 truncate" dt)' - ' (raw! ms) (raw! bh))' - ' (div :class "text-right flex-shrink-0"' - ' (div :class "text-lg font-bold text-stone-900" tt)' - ' (div :class "mt-1 text-xs text-emerald-700 font-medium" "View cart \u2192"))))', - ch=cart_href, img=img, dt=display_title, ms=mp_sub, bh=badges_html, - tt=f"\u00a3{total:.2f}", + return render( + "cart-group-card", + href=cart_href, img_html=img, display_title=display_title, + subtitle_html=mp_sub, badges_html=badges_html, + total=f"\u00a3{total:.2f}", ) else: # Orphan items — use amber badges badges_amber = badges.replace("bg-stone-100", "bg-amber-100") - badges_html_amber = sexp( - '(div :class "mt-1 flex flex-wrap gap-2 text-xs text-stone-600" (raw! b))', - b=badges_amber, - ) - return sexp( - '(div :class "rounded-2xl border border-dashed border-amber-300 bg-amber-50/60 p-4 sm:p-5"' - ' (div :class "flex items-start gap-4"' - ' (div :class "h-16 w-16 rounded-xl bg-amber-100 flex items-center justify-center flex-shrink-0"' - ' (i :class "fa fa-shopping-cart text-amber-500 text-xl" :aria-hidden "true"))' - ' (div :class "flex-1 min-w-0"' - ' (h3 :class "text-base sm:text-lg font-semibold text-stone-900" "Other items")' - ' (raw! bh))' - ' (div :class "text-right flex-shrink-0"' - ' (div :class "text-lg font-bold text-stone-900" tt))))', - bh=badges_html_amber, tt=f"\u00a3{total:.2f}", + badges_html_amber = render("cart-badges-wrap", badges_html=badges_amber) + return render( + "cart-orphan-card", + badges_html=badges_html_amber, + total=f"\u00a3{total:.2f}", ) def _empty_cart_html() -> str: """Empty cart state.""" - return sexp( - '(div :class "max-w-full px-3 py-3 space-y-3"' - ' (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"' - ' (div :class "inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3"' - ' (i :class "fa fa-shopping-cart text-stone-500 text-sm sm:text-base" :aria-hidden "true"))' - ' (p :class "text-base sm:text-lg font-medium text-stone-800" "Your cart is empty")))', - ) + return render("cart-empty") def _overview_main_panel_html(page_groups: list, ctx: dict) -> str: @@ -199,11 +159,7 @@ def _overview_main_panel_html(page_groups: list, ctx: dict) -> str: if not has_items: return _empty_cart_html() - return sexp( - '(div :class "max-w-full px-3 py-3 space-y-3"' - ' (div :class "space-y-4" (raw! c)))', - c="".join(cards), - ) + return render("cart-overview-panel", cards_html="".join(cards)) # --------------------------------------------------------------------------- @@ -225,78 +181,38 @@ def _cart_item_html(item: Any, ctx: dict) -> str: prod_url = market_product_url(slug) if p.image: - img = sexp( - '(img :src im :alt t :class "w-24 h-24 sm:w-32 sm:h-28 object-cover rounded-xl border border-stone-100" :loading "lazy")', - im=p.image, t=p.title, - ) + img = render("cart-item-img", src=p.image, alt=p.title) else: - img = sexp( - '(div :class "w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300 flex items-center justify-center text-xs text-stone-400"' - ' "No image")', - ) + img = render("cart-item-no-img") price_html = "" if unit_price: - price_html = sexp( - '(p :class "text-sm sm:text-base font-semibold text-stone-900" ps)', - ps=f"{symbol}{unit_price:.2f}", - ) + price_html = render("cart-item-price", text=f"{symbol}{unit_price:.2f}") if p.special_price and p.special_price != p.regular_price: - price_html += sexp( - '(p :class "text-xs text-stone-400 line-through" ps)', - ps=f"{symbol}{p.regular_price:.2f}", - ) + price_html += render("cart-item-price-was", text=f"{symbol}{p.regular_price:.2f}") else: - price_html = sexp('(p :class "text-xs text-stone-500" "No price")') + price_html = render("cart-item-no-price") deleted_html = "" if getattr(item, "is_deleted", False): - deleted_html = sexp( - '(p :class "mt-2 inline-flex items-center gap-1 text-[0.65rem] sm:text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5"' - ' (i :class "fa-solid fa-triangle-exclamation text-[0.6rem]" :aria-hidden "true")' - ' " This item is no longer available or price has changed")', - ) + deleted_html = render("cart-item-deleted") brand_html = "" if getattr(p, "brand", None): - brand_html = sexp('(p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" br)', br=p.brand) + brand_html = render("cart-item-brand", brand=p.brand) line_total_html = "" if unit_price: lt = unit_price * item.quantity - line_total_html = sexp( - '(p :class "text-sm sm:text-base font-semibold text-stone-900" lt)', - lt=f"Line total: {symbol}{lt:.2f}", - ) + line_total_html = render("cart-item-line-total", text=f"Line total: {symbol}{lt:.2f}") - return sexp( - '(article :id aid :class "flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4 md:p-5"' - ' (div :class "w-full sm:w-32 shrink-0 flex justify-center sm:block" (raw! img))' - ' (div :class "flex-1 min-w-0"' - ' (div :class "flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3"' - ' (div :class "min-w-0"' - ' (h2 :class "text-sm sm:text-base md:text-lg font-semibold text-stone-900"' - ' (a :href pu :class "hover:text-emerald-700" pt))' - ' (raw! brh) (raw! dh))' - ' (div :class "text-left sm:text-right" (raw! ph)))' - ' (div :class "mt-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4"' - ' (div :class "flex items-center gap-2 text-xs sm:text-sm text-stone-700"' - ' (span :class "text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500" "Quantity")' - ' (form :action qu :method "post" :hx-post qu :hx-swap "none"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (input :type "hidden" :name "count" :value minus)' - ' (button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "-"))' - ' (span :class "inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium" qty)' - ' (form :action qu :method "post" :hx-post qu :hx-swap "none"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (input :type "hidden" :name "count" :value plus)' - ' (button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "+")))' - ' (div :class "flex items-center justify-between sm:justify-end gap-3" (raw! lth)))))', - aid=f"cart-item-{slug}", img=img, pu=prod_url, pt=p.title, - brh=brand_html, dh=deleted_html, ph=price_html, - qu=qty_url, csrf=csrf, minus=str(item.quantity - 1), + return render( + "cart-item", + id=f"cart-item-{slug}", img_html=img, prod_url=prod_url, title=p.title, + brand_html=brand_html, deleted_html=deleted_html, price_html=price_html, + qty_url=qty_url, csrf=csrf, minus=str(item.quantity - 1), qty=str(item.quantity), plus=str(item.quantity + 1), - lth=line_total_html, + line_total_html=line_total_html, ) @@ -311,19 +227,11 @@ def _calendar_entries_html(entries: list) -> str: end = getattr(e, "end_at", None) cost = getattr(e, "cost", 0) or 0 end_str = f" \u2013 {end}" if end else "" - items += sexp( - '(li :class "flex items-start justify-between text-sm"' - ' (div (div :class "font-medium" nm)' - ' (div :class "text-xs text-stone-500" ds))' - ' (div :class "ml-4 font-medium" cs))', - nm=name, ds=f"{start}{end_str}", cs=f"\u00a3{cost:.2f}", + items += render( + "cart-cal-entry", + name=name, date_str=f"{start}{end_str}", cost=f"\u00a3{cost:.2f}", ) - return sexp( - '(div :class "mt-6 border-t border-stone-200 pt-4"' - ' (h2 :class "text-base font-semibold mb-2" "Calendar bookings")' - ' (ul :class "space-y-2" (raw! items)))', - items=items, - ) + return render("cart-cal-section", items_html=items) def _ticket_groups_html(ticket_groups: list, ctx: dict) -> str: @@ -352,51 +260,19 @@ def _ticket_groups_html(ticket_groups: list, ctx: dict) -> str: if end_at: date_str += f" \u2013 {end_at.strftime('%-d %b %Y, %H:%M')}" - tt_name_html = sexp('(p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" tn)', tn=tt_name) if tt_name else "" - tt_hidden = sexp('(input :type "hidden" :name "ticket_type_id" :value tid)', tid=str(tt_id)) if tt_id else "" + tt_name_html = render("cart-ticket-type-name", name=tt_name) if tt_name else "" + tt_hidden = render("cart-ticket-type-hidden", value=str(tt_id)) if tt_id else "" - items += sexp( - '(article :class "flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4"' - ' (div :class "flex-1 min-w-0"' - ' (div :class "flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3"' - ' (div :class "min-w-0"' - ' (h3 :class "text-sm sm:text-base font-semibold text-stone-900" nm)' - ' (raw! tnh)' - ' (p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" ds))' - ' (div :class "text-left sm:text-right"' - ' (p :class "text-sm sm:text-base font-semibold text-stone-900" ps)))' - ' (div :class "mt-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4"' - ' (div :class "flex items-center gap-2 text-xs sm:text-sm text-stone-700"' - ' (span :class "text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500" "Quantity")' - ' (form :action qu :method "post" :hx-post qu :hx-swap "none"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (input :type "hidden" :name "entry_id" :value eid)' - ' (raw! tth)' - ' (input :type "hidden" :name "count" :value minus)' - ' (button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "-"))' - ' (span :class "inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium" qty)' - ' (form :action qu :method "post" :hx-post qu :hx-swap "none"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (input :type "hidden" :name "entry_id" :value eid)' - ' (raw! tth)' - ' (input :type "hidden" :name "count" :value plus)' - ' (button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "+")))' - ' (div :class "flex items-center justify-between sm:justify-end gap-3"' - ' (p :class "text-sm sm:text-base font-semibold text-stone-900" lt)))))', - nm=name, tnh=tt_name_html, ds=date_str, - ps=f"\u00a3{price or 0:.2f}", qu=qty_url, csrf=csrf, - eid=str(entry_id), tth=tt_hidden, + items += render( + "cart-ticket-article", + name=name, type_name_html=tt_name_html, date_str=date_str, + price=f"\u00a3{price or 0:.2f}", qty_url=qty_url, csrf=csrf, + entry_id=str(entry_id), type_hidden_html=tt_hidden, minus=str(max(quantity - 1, 0)), qty=str(quantity), - plus=str(quantity + 1), lt=f"Line total: \u00a3{line_total:.2f}", + plus=str(quantity + 1), line_total=f"Line total: \u00a3{line_total:.2f}", ) - return sexp( - '(div :class "mt-6 border-t border-stone-200 pt-4"' - ' (h2 :class "text-base font-semibold mb-2"' - ' (i :class "fa fa-ticket mr-1" :aria-hidden "true") " Event tickets")' - ' (div :class "space-y-3" (raw! items)))', - items=items, - ) + return render("cart-tickets-section", items_html=items) def _cart_summary_html(ctx: dict, cart: list, cal_entries: list, tickets: list, @@ -431,36 +307,18 @@ def _cart_summary_html(ctx: dict, cart: list, cal_entries: list, tickets: list, action = url_for("cart_global.checkout") from shared.utils import route_prefix action = route_prefix() + action - checkout_html = sexp( - '(form :method "post" :action act :class "w-full"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (button :type "submit" :class "w-full inline-flex items-center justify-center px-4 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition"' - ' (i :class "fa-solid fa-credit-card mr-2" :aria-hidden "true") lbl))', - act=action, csrf=csrf, lbl=f" Checkout as {user.email}", + checkout_html = render( + "cart-checkout-form", + action=action, csrf=csrf, label=f" Checkout as {user.email}", ) else: href = login_url(request.url) - checkout_html = sexp( - '(div :class "w-full flex"' - ' (a :href h :class "w-full cursor-pointer flex flex-row items-center justify-center p-3 gap-2 rounded bg-stone-200 text-black hover:bg-stone-300 transition"' - ' (i :class "fa-solid fa-key") (span "sign in or register to checkout")))', - h=href, - ) + checkout_html = render("cart-checkout-signin", href=href) - return sexp( - '(aside :id "cart-summary" :class "lg:pl-2"' - ' (div :class "rounded-2xl bg-white shadow-sm border border-stone-200 p-4 sm:p-5"' - ' (h2 :class "text-sm sm:text-base font-semibold text-stone-900 mb-3 sm:mb-4" "Order summary")' - ' (dl :class "space-y-2 text-xs sm:text-sm"' - ' (div :class "flex items-center justify-between"' - ' (dt :class "text-stone-600" "Items") (dd :class "text-stone-900" ic))' - ' (div :class "flex items-center justify-between"' - ' (dt :class "text-stone-600" "Subtotal") (dd :class "text-stone-900" st)))' - ' (div :class "flex flex-col items-center w-full"' - ' (h1 :class "text-5xl mt-2" "This is a test - it will not take actual money")' - ' (div "use dummy card number: 5555 5555 5555 4444"))' - ' (div :class "mt-4 sm:mt-5" (raw! ch))))', - ic=str(item_count), st=f"{symbol}{grand:.2f}", ch=checkout_html, + return render( + "cart-summary-panel", + item_count=str(item_count), subtotal=f"{symbol}{grand:.2f}", + checkout_html=checkout_html, ) @@ -470,26 +328,17 @@ def _page_cart_main_panel_html(ctx: dict, cart: list, cal_entries: list, ticket_total_fn: Any) -> str: """Page cart main panel.""" if not cart and not cal_entries and not tickets: - return sexp( - '(div :class "max-w-full px-3 py-3 space-y-3"' - ' (div :id "cart"' - ' (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center"' - ' (div :class "inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3"' - ' (i :class "fa fa-shopping-cart text-stone-500 text-sm sm:text-base" :aria-hidden "true"))' - ' (p :class "text-base sm:text-lg font-medium text-stone-800" "Your cart is empty"))))', - ) + return render("cart-page-empty") items_html = "".join(_cart_item_html(item, ctx) for item in cart) cal_html = _calendar_entries_html(cal_entries) tickets_html = _ticket_groups_html(ticket_groups, ctx) summary_html = _cart_summary_html(ctx, cart, cal_entries, tickets, total_fn, cal_total_fn, ticket_total_fn) - return sexp( - '(div :class "max-w-full px-3 py-3 space-y-3"' - ' (div :id "cart"' - ' (div (section :class "space-y-3 sm:space-y-4" (raw! ih) (raw! ch) (raw! th))' - ' (raw! sh))))', - ih=items_html, ch=cal_html, th=tickets_html, sh=summary_html, + return render( + "cart-page-panel", + items_html=items_html, cal_html=cal_html, + tickets_html=tickets_html, summary_html=summary_html, ) @@ -506,35 +355,21 @@ def _order_row_html(order: Any, detail_url: str) -> str: else "border-rose-300 bg-rose-50 text-rose-700" if sl in ("failed", "cancelled") else "border-stone-300 bg-stone-50 text-stone-700" ) + pill_cls = f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs {pill}" created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014" total = f"{order.currency or 'GBP'} {order.total_amount or 0:.2f}" - desktop = sexp( - '(tr :class "hidden sm:table-row border-t border-stone-100 hover:bg-stone-50/60"' - ' (td :class "px-3 py-2 align-top" (span :class "font-mono text-[11px] sm:text-xs" oid))' - ' (td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" cr)' - ' (td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" desc)' - ' (td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" tot)' - ' (td :class "px-3 py-2 align-top" (span :class (str "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs " pill) st))' - ' (td :class "px-3 py-0.5 align-top text-right"' - ' (a :href du :class "inline-flex items-center px-3 py-1.5 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition" "View")))', - oid=f"#{order.id}", cr=created, desc=order.description or "", - tot=total, pill=pill, st=status, du=detail_url, + desktop = render( + "cart-order-row-desktop", + order_id=f"#{order.id}", created=created, desc=order.description or "", + total=total, pill=pill_cls, status=status, detail_url=detail_url, ) - mobile = sexp( - '(tr :class "sm:hidden border-t border-stone-100"' - ' (td :colspan "5" :class "px-3 py-3"' - ' (div :class "flex flex-col gap-2 text-xs"' - ' (div :class "flex items-center justify-between gap-2"' - ' (span :class "font-mono text-[11px] text-stone-700" oid)' - ' (span :class (str "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] " pill) st))' - ' (div :class "text-[11px] text-stone-500 break-words" cr)' - ' (div :class "flex items-center justify-between gap-2"' - ' (div :class "font-medium text-stone-800" tot)' - ' (a :href du :class "inline-flex items-center px-2 py-1 text-[11px] rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition shrink-0" "View")))))', - oid=f"#{order.id}", pill=pill, st=status, cr=created, - tot=total, du=detail_url, + mobile_pill = f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] {pill}" + mobile = render( + "cart-order-row-mobile", + order_id=f"#{order.id}", pill=mobile_pill, status=status, + created=created, total=total, detail_url=detail_url, ) return desktop + mobile @@ -553,14 +388,13 @@ def _orders_rows_html(orders: list, page: int, total_pages: int, if page < total_pages: next_url = pfx + url_for_fn("orders.list_orders") + qs_fn(page=page + 1) - parts.append(sexp( - '(~infinite-scroll :url u :page p :total-pages tp :id-prefix "orders" :colspan 5)', - u=next_url, p=page, **{"total-pages": total_pages}, + parts.append(render( + "infinite-scroll", + url=next_url, page=page, total_pages=total_pages, + id_prefix="orders", colspan=5, )) else: - parts.append(sexp( - '(tr (td :colspan "5" :class "px-3 py-4 text-center text-xs text-stone-400" "End of results"))', - )) + parts.append(render("cart-orders-end")) return "".join(parts) @@ -568,37 +402,13 @@ def _orders_rows_html(orders: list, page: int, total_pages: int, def _orders_main_panel_html(orders: list, rows_html: str) -> str: """Main panel for orders list.""" if not orders: - return sexp( - '(div :class "max-w-full px-3 py-3 space-y-3"' - ' (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-4 sm:p-6 text-sm text-stone-700"' - ' "No orders yet."))', - ) - return sexp( - '(div :class "max-w-full px-3 py-3 space-y-3"' - ' (div :class "overflow-x-auto rounded-2xl border border-stone-200 bg-white/80"' - ' (table :class "min-w-full text-xs sm:text-sm"' - ' (thead :class "bg-stone-50 border-b border-stone-200 text-stone-600"' - ' (tr' - ' (th :class "px-3 py-2 text-left font-medium" "Order")' - ' (th :class "px-3 py-2 text-left font-medium" "Created")' - ' (th :class "px-3 py-2 text-left font-medium" "Description")' - ' (th :class "px-3 py-2 text-left font-medium" "Total")' - ' (th :class "px-3 py-2 text-left font-medium" "Status")' - ' (th :class "px-3 py-2 text-left font-medium")))' - ' (tbody (raw! rh)))))', - rh=rows_html, - ) + return render("cart-orders-empty") + return render("cart-orders-table", rows_html=rows_html) def _orders_summary_html(ctx: dict) -> str: """Filter section for orders list.""" - return sexp( - '(header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"' - ' (div :class "space-y-1"' - ' (p :class "text-xs sm:text-sm text-stone-600" "Recent orders placed via the checkout."))' - ' (div :class "md:hidden" (raw! sm)))', - sm=search_mobile_html(ctx), - ) + return render("cart-orders-filter", search_mobile_html=search_mobile_html(ctx)) # --------------------------------------------------------------------------- @@ -613,43 +423,31 @@ def _order_items_html(order: Any) -> str: for item in order.items: prod_url = market_product_url(item.product_slug) if item.product_image: - img = sexp( - '(img :src pi :alt pt :class "w-full h-full object-contain object-center" :loading "lazy" :decoding "async")', - pi=item.product_image, pt=item.product_title or "Product image", + img = render( + "cart-order-item-img", + src=item.product_image, alt=item.product_title or "Product image", ) else: - img = sexp( - '(div :class "w-full h-full flex items-center justify-center text-[9px] text-stone-400" "No image")', - ) - items += sexp( - '(li (a :class "w-full py-2 flex gap-3" :href pu' - ' (div :class "w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden" (raw! img))' - ' (div :class "flex-1 flex justify-between gap-3"' - ' (div (p :class "font-medium" pt)' - ' (p :class "text-[11px] text-stone-500" pid))' - ' (div :class "text-right whitespace-nowrap"' - ' (p qty) (p pr)))))', - pu=prod_url, img=img, pt=item.product_title or "Unknown product", - pid=f"Product ID: {item.product_id}", + img = render("cart-order-item-no-img") + items += render( + "cart-order-item", + prod_url=prod_url, img_html=img, + title=item.product_title or "Unknown product", + product_id=f"Product ID: {item.product_id}", qty=f"Qty: {item.quantity}", - pr=f"{item.currency or order.currency or 'GBP'} {item.unit_price or 0:.2f}", + price=f"{item.currency or order.currency or 'GBP'} {item.unit_price or 0:.2f}", ) - return sexp( - '(div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6"' - ' (h2 :class "text-sm sm:text-base font-semibold mb-3" "Items")' - ' (ul :class "divide-y divide-stone-100 text-xs sm:text-sm" (raw! items)))', - items=items, - ) + return render("cart-order-items-panel", items_html=items) def _order_summary_html(order: Any) -> str: """Order summary card.""" - return sexp( - '(~order-summary-card :order-id oid :created-at ca :description d :status s :currency c :total-amount ta)', - oid=order.id, - ca=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None, - d=order.description, s=order.status, c=order.currency, - ta=f"{order.total_amount:.2f}" if order.total_amount else None, + return render( + "order-summary-card", + order_id=order.id, + created_at=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None, + description=order.description, status=order.status, currency=order.currency, + total_amount=f"{order.total_amount:.2f}" if order.total_amount else None, ) @@ -666,31 +464,25 @@ def _order_calendar_items_html(calendar_entries: list | None) -> str: else "bg-blue-100 text-blue-800" if st == "ordered" else "bg-stone-100 text-stone-700" ) + pill_cls = f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}" ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else "" if e.end_at: ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}" - items += sexp( - '(li :class "px-4 py-3 flex items-start justify-between text-sm"' - ' (div (div :class "font-medium flex items-center gap-2"' - ' nm (span :class (str "inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium " pill) sc))' - ' (div :class "text-xs text-stone-500" ds))' - ' (div :class "ml-4 font-medium" cs))', - nm=e.name, pill=pill, sc=st.capitalize(), ds=ds, cs=f"\u00a3{e.cost or 0:.2f}", + items += render( + "cart-order-cal-entry", + name=e.name, pill=pill_cls, status=st.capitalize(), + date_str=ds, cost=f"\u00a3{e.cost or 0:.2f}", ) - return sexp( - '(section :class "mt-6 space-y-3"' - ' (h2 :class "text-base sm:text-lg font-semibold" "Calendar bookings in this order")' - ' (ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" (raw! items)))', - items=items, - ) + return render("cart-order-cal-section", items_html=items) def _order_main_html(order: Any, calendar_entries: list | None) -> str: """Main panel for single order detail.""" summary = _order_summary_html(order) - return sexp( - '(div :class "max-w-full px-3 py-3 space-y-4" (raw! s) (raw! oi) (raw! ci))', - s=summary, oi=_order_items_html(order), ci=_order_calendar_items_html(calendar_entries), + return render( + "cart-order-main", + summary_html=summary, items_html=_order_items_html(order), + cal_html=_order_calendar_items_html(calendar_entries), ) @@ -702,26 +494,12 @@ def _order_filter_html(order: Any, list_url: str, recheck_url: str, pay = "" if status != "paid": - pay = sexp( - '(a :href pu :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition"' - ' (i :class "fa fa-credit-card mr-2" :aria-hidden "true") "Open payment page")', - pu=pay_url, - ) + pay = render("cart-order-pay-btn", url=pay_url) - return sexp( - '(header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"' - ' (div :class "space-y-1"' - ' (p :class "text-xs sm:text-sm text-stone-600" info))' - ' (div :class "flex w-full sm:w-auto justify-start sm:justify-end gap-2"' - ' (a :href lu :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"' - ' (i :class "fa-solid fa-list mr-2" :aria-hidden "true") "All orders")' - ' (form :method "post" :action ru :class "inline"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (button :type "submit" :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"' - ' (i :class "fa-solid fa-rotate mr-2" :aria-hidden "true") "Re-check status"))' - ' (raw! pay)))', + return render( + "cart-order-filter", info=f"Placed {created} \u00b7 Status: {status}", - lu=list_url, ru=recheck_url, csrf=csrf_token, pay=pay, + list_url=list_url, recheck_url=recheck_url, csrf=csrf_token, pay_html=pay, ) @@ -733,10 +511,7 @@ async def render_overview_page(ctx: dict, page_groups: list) -> str: """Full page: cart overview.""" main = _overview_main_panel_html(page_groups, ctx) hdr = root_header_html(ctx) - hdr += sexp( - '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! c))', - c=_cart_header_html(ctx), - ) + hdr += render("cart-header-child", inner_html=_cart_header_html(ctx)) return full_page(ctx, header_rows_html=hdr, content_html=main) @@ -764,10 +539,9 @@ async def render_page_cart_page(ctx: dict, page_post: Any, hdr = root_header_html(ctx) child = _cart_header_html(ctx) page_hdr = _page_cart_header_html(ctx, page_post) - hdr += sexp( - '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! c)' - ' (div :id "cart-header-child" :class "flex flex-col w-full items-center" (raw! p)))', - c=child, p=page_hdr, + hdr += render( + "cart-header-child-nested", + outer_html=child, inner_html=page_hdr, ) return full_page(ctx, header_rows_html=hdr, content_html=main) @@ -780,8 +554,7 @@ async def render_page_cart_oob(ctx: dict, page_post: Any, main = _page_cart_main_panel_html(ctx, cart, cal_entries, tickets, ticket_groups, total_fn, cal_total_fn, ticket_total_fn) oobs = ( - sexp('(div :id "cart-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center" (raw! p))', - p=_page_cart_header_html(ctx, page_post)) + render("cart-header-child-oob", inner_html=_page_cart_header_html(ctx, page_post)) + _cart_header_html(ctx, oob=True) + root_header_html(ctx, oob=True) ) @@ -807,10 +580,10 @@ async def render_orders_page(ctx: dict, orders: list, page: int, main = _orders_main_panel_html(orders, rows) hdr = root_header_html(ctx) - hdr += sexp( - '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a)' - ' (div :id "auth-header-child" :class "flex flex-col w-full items-center" (raw! o)))', - a=_auth_header_html(ctx), o=_orders_header_html(ctx, list_url), + hdr += render( + "cart-auth-header-child", + auth_html=_auth_header_html(ctx), + orders_html=_orders_header_html(ctx, list_url), ) return full_page(ctx, header_rows_html=hdr, @@ -842,10 +615,9 @@ async def render_orders_oob(ctx: dict, orders: list, page: int, oobs = ( _auth_header_html(ctx, oob=True) - + sexp( - '(div :id "auth-header-child" :hx-swap-oob "outerHTML"' - ' :class "flex flex-col w-full items-center" (raw! o))', - o=_orders_header_html(ctx, list_url), + + render( + "cart-auth-header-child-oob", + inner_html=_orders_header_html(ctx, list_url), ) + root_header_html(ctx, oob=True) ) @@ -877,17 +649,16 @@ async def render_order_page(ctx: dict, order: Any, filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token()) hdr = root_header_html(ctx) - order_row = sexp( - '(~menu-row :id "order-row" :level 3 :colour "sky" :link-href lh :link-label ll :icon "fa fa-gbp")', - lh=detail_url, ll=f"Order {order.id}", + order_row = render( + "menu-row", + id="order-row", level=3, colour="sky", + link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp", ) - hdr += sexp( - '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a)' - ' (div :id "auth-header-child" :class "flex flex-col w-full items-center" (raw! b)' - ' (div :id "orders-header-child" :class "flex flex-col w-full items-center" (raw! c))))', - a=_auth_header_html(ctx), - b=_orders_header_html(ctx, list_url), - c=order_row, + hdr += render( + "cart-order-header-child", + auth_html=_auth_header_html(ctx), + orders_html=_orders_header_html(ctx, list_url), + order_html=order_row, ) return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=main) @@ -909,12 +680,14 @@ async def render_order_oob(ctx: dict, order: Any, main = _order_main_html(order, calendar_entries) filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token()) - order_row_oob = sexp( - '(~menu-row :id "order-row" :level 3 :colour "sky" :link-href lh :link-label ll :icon "fa fa-gbp" :oob true)', - lh=detail_url, ll=f"Order {order.id}", + order_row_oob = render( + "menu-row", + id="order-row", level=3, colour="sky", + link_href=detail_url, link_label=f"Order {order.id}", icon="fa fa-gbp", + oob=True, ) oobs = ( - sexp('(div :id "orders-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center" (raw! o))', o=order_row_oob) + render("cart-orders-header-child-oob", inner_html=order_row_oob) + root_header_html(ctx, oob=True) ) @@ -926,43 +699,25 @@ async def render_order_oob(ctx: dict, order: Any, # --------------------------------------------------------------------------- def _checkout_error_filter_html() -> str: - return sexp( - '(header :class "mb-6 sm:mb-8"' - ' (h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Checkout error")' - ' (p :class "text-xs sm:text-sm text-stone-600"' - ' "We tried to start your payment with SumUp but hit a problem."))', - ) + return render("cart-checkout-error-filter") def _checkout_error_content_html(error: str | None, order: Any | None) -> str: err_msg = error or "Unexpected error while creating the hosted checkout session." order_html = "" if order: - order_html = sexp( - '(p :class "text-xs text-rose-800/80"' - ' "Order ID: " (span :class "font-mono" oid))', - oid=f"#{order.id}", - ) + order_html = render("cart-checkout-error-order-id", order_id=f"#{order.id}") back_url = cart_url("/") - return sexp( - '(div :class "max-w-full px-3 py-3 space-y-4"' - ' (div :class "rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2"' - ' (p :class "font-medium" "Something went wrong.")' - ' (p em)' - ' (raw! oh))' - ' (div (a :href bu :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"' - ' (i :class "fa fa-shopping-cart mr-2" :aria-hidden "true") "Back to cart")))', - em=err_msg, oh=order_html, bu=back_url, + return render( + "cart-checkout-error-content", + error_msg=err_msg, order_html=order_html, back_url=back_url, ) async def render_checkout_error_page(ctx: dict, error: str | None = None, order: Any | None = None) -> str: """Full page: checkout error.""" hdr = root_header_html(ctx) - hdr += sexp( - '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! c))', - c=_cart_header_html(ctx), - ) + hdr += render("cart-header-child", inner_html=_cart_header_html(ctx)) filt = _checkout_error_filter_html() content = _checkout_error_content_html(error, order) return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=content) diff --git a/cart/sexp/summary.sexpr b/cart/sexp/summary.sexpr new file mode 100644 index 0000000..e3b7fb6 --- /dev/null +++ b/cart/sexp/summary.sexpr @@ -0,0 +1,26 @@ +;; Cart summary / checkout components + +(defcomp ~cart-checkout-form (&key action csrf label) + (form :method "post" :action action :class "w-full" + (input :type "hidden" :name "csrf_token" :value csrf) + (button :type "submit" :class "w-full inline-flex items-center justify-center px-4 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition" + (i :class "fa-solid fa-credit-card mr-2" :aria-hidden "true") (raw! label)))) + +(defcomp ~cart-checkout-signin (&key href) + (div :class "w-full flex" + (a :href href :class "w-full cursor-pointer flex flex-row items-center justify-center p-3 gap-2 rounded bg-stone-200 text-black hover:bg-stone-300 transition" + (i :class "fa-solid fa-key") (span "sign in or register to checkout")))) + +(defcomp ~cart-summary-panel (&key item-count subtotal checkout-html) + (aside :id "cart-summary" :class "lg:pl-2" + (div :class "rounded-2xl bg-white shadow-sm border border-stone-200 p-4 sm:p-5" + (h2 :class "text-sm sm:text-base font-semibold text-stone-900 mb-3 sm:mb-4" "Order summary") + (dl :class "space-y-2 text-xs sm:text-sm" + (div :class "flex items-center justify-between" + (dt :class "text-stone-600" "Items") (dd :class "text-stone-900" (raw! item-count))) + (div :class "flex items-center justify-between" + (dt :class "text-stone-600" "Subtotal") (dd :class "text-stone-900" (raw! subtotal)))) + (div :class "flex flex-col items-center w-full" + (h1 :class "text-5xl mt-2" "This is a test - it will not take actual money") + (div "use dummy card number: 5555 5555 5555 4444")) + (div :class "mt-4 sm:mt-5" (raw! checkout-html))))) diff --git a/cart/sexp/tickets.sexpr b/cart/sexp/tickets.sexpr new file mode 100644 index 0000000..dcbc907 --- /dev/null +++ b/cart/sexp/tickets.sexpr @@ -0,0 +1,42 @@ +;; Cart ticket components + +(defcomp ~cart-ticket-type-name (&key name) + (p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" (raw! name))) + +(defcomp ~cart-ticket-type-hidden (&key value) + (input :type "hidden" :name "ticket_type_id" :value value)) + +(defcomp ~cart-ticket-article (&key name type-name-html date-str price qty-url csrf entry-id type-hidden-html minus qty plus line-total) + (article :class "flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4" + (div :class "flex-1 min-w-0" + (div :class "flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3" + (div :class "min-w-0" + (h3 :class "text-sm sm:text-base font-semibold text-stone-900" (raw! name)) + (raw! type-name-html) + (p :class "mt-0.5 text-[0.7rem] sm:text-xs text-stone-500" (raw! date-str))) + (div :class "text-left sm:text-right" + (p :class "text-sm sm:text-base font-semibold text-stone-900" (raw! price)))) + (div :class "mt-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4" + (div :class "flex items-center gap-2 text-xs sm:text-sm text-stone-700" + (span :class "text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500" "Quantity") + (form :action qty-url :method "post" :hx-post qty-url :hx-swap "none" + (input :type "hidden" :name "csrf_token" :value csrf) + (input :type "hidden" :name "entry_id" :value entry-id) + (raw! type-hidden-html) + (input :type "hidden" :name "count" :value minus) + (button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "-")) + (span :class "inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium" (raw! qty)) + (form :action qty-url :method "post" :hx-post qty-url :hx-swap "none" + (input :type "hidden" :name "csrf_token" :value csrf) + (input :type "hidden" :name "entry_id" :value entry-id) + (raw! type-hidden-html) + (input :type "hidden" :name "count" :value plus) + (button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "+"))) + (div :class "flex items-center justify-between sm:justify-end gap-3" + (p :class "text-sm sm:text-base font-semibold text-stone-900" (raw! line-total))))))) + +(defcomp ~cart-tickets-section (&key items-html) + (div :class "mt-6 border-t border-stone-200 pt-4" + (h2 :class "text-base font-semibold mb-2" + (i :class "fa fa-ticket mr-1" :aria-hidden "true") " Event tickets") + (div :class "space-y-3" (raw! items-html)))) diff --git a/events/bp/calendars/routes.py b/events/bp/calendars/routes.py index d46e2b5..e4e26ab 100644 --- a/events/bp/calendars/routes.py +++ b/events/bp/calendars/routes.py @@ -66,7 +66,8 @@ def register(): try: await svc_create_calendar(g.s, post_id, name) except Exception as e: - return await make_response(f'
{e}
', 422) + from shared.sexp.jinja_bridge import render as render_comp + return await make_response(render_comp("error-inline", message=str(e)), 422) from shared.sexp.page import get_template_context from sexp.sexp_components import render_calendars_list_panel diff --git a/events/bp/fragments/routes.py b/events/bp/fragments/routes.py index 43be5a5..35caab7 100644 --- a/events/bp/fragments/routes.py +++ b/events/bp/fragments/routes.py @@ -37,7 +37,7 @@ def register(): async def _container_nav_handler(): from quart import current_app from shared.infrastructure.urls import events_url - from shared.sexp.jinja_bridge import sexp as render_sexp + from shared.sexp.jinja_bridge import render as render_comp container_type = request.args.get("container_type", "page") container_id = int(request.args.get("container_id", 0)) @@ -65,21 +65,21 @@ def register(): date_str = entry.start_at.strftime("%b %d, %Y at %H:%M") if entry.end_at: date_str += f" – {entry.end_at.strftime('%H:%M')}" - html_parts.append(render_sexp( - '(~calendar-entry-nav :href href :name name :date-str date-str :nav-class nav-class)', + html_parts.append(render_comp( + "calendar-entry-nav", href=events_url(entry_path), name=entry.name, - **{"date-str": date_str, "nav-class": nav_class}, + date_str=date_str, nav_class=nav_class, )) # Infinite scroll sentinel (kept as raw HTML — HTMX-specific) if has_more and paginate_url_base: - html_parts.append( - f'
' - ) + html_parts.append(render_comp( + "htmx-sentinel", + id=f"entries-load-sentinel-{page}", + hx_get=f"{paginate_url_base}?page={page + 1}", + hx_trigger="intersect once", + hx_swap="beforebegin", + **{"class": "flex-shrink-0 w-1"}, + )) # Calendar links nav if not any(e.startswith("calendar") for e in excludes): @@ -88,9 +88,9 @@ def register(): ) for cal in calendars: href = events_url(f"/{post_slug}/{cal.slug}/") - html_parts.append(render_sexp( - '(~calendar-link-nav :href href :name name :nav-class nav-class)', - href=href, name=cal.name, **{"nav-class": nav_class}, + html_parts.append(render_comp( + "calendar-link-nav", + href=href, name=cal.name, nav_class=nav_class, )) return "\n".join(html_parts) @@ -125,6 +125,7 @@ def register(): async def _account_nav_item_handler(): from quart import current_app from shared.infrastructure.urls import account_url + from shared.sexp.jinja_bridge import render as render_comp styles = current_app.jinja_env.globals.get("styles", {}) nav_class = styles.get("nav_button", "") @@ -138,12 +139,10 @@ def register(): # hx-* attributes that don't map neatly to a reusable component. parts = [] for href, label in [(tickets_url, "tickets"), (bookings_url, "bookings")]: - parts.append( - f'' - ) + parts.append(render_comp( + "nav-group-link", + href=href, hx_select=hx_select, nav_class=nav_class, label=label, + )) return "\n".join(parts) _handlers["account-nav-item"] = _account_nav_item_handler @@ -176,7 +175,7 @@ def register(): async def _link_card_handler(): from shared.infrastructure.urls import events_url - from shared.sexp.jinja_bridge import sexp as render_sexp + from shared.sexp.jinja_bridge import render as render_comp slug = request.args.get("slug", "") keys_raw = request.args.get("keys", "") @@ -194,8 +193,8 @@ def register(): g.s, "page", post.id, ) cal_names = ", ".join(c.name for c in calendars) if calendars else "" - parts.append(render_sexp( - '(~link-card :title title :image image :subtitle subtitle :link link)', + parts.append(render_comp( + "link-card", title=post.title, image=post.feature_image, subtitle=cal_names, link=events_url(f"/{post.slug}"), )) @@ -212,8 +211,8 @@ def register(): g.s, "page", post.id, ) cal_names = ", ".join(c.name for c in calendars) if calendars else "" - return render_sexp( - '(~link-card :title title :image image :subtitle subtitle :link link)', + return render_comp( + "link-card", title=post.title, image=post.feature_image, subtitle=cal_names, link=events_url(f"/{post.slug}"), ) diff --git a/events/bp/markets/routes.py b/events/bp/markets/routes.py index 7c1b8f7..0b02cef 100644 --- a/events/bp/markets/routes.py +++ b/events/bp/markets/routes.py @@ -50,7 +50,8 @@ def register(): try: await svc_create_market(g.s, post_id, name) except Exception as e: - return await make_response(f'
{e}
', 422) + from shared.sexp.jinja_bridge import render as render_comp + return await make_response(render_comp("error-inline", message=str(e)), 422) from shared.sexp.page import get_template_context from sexp.sexp_components import render_markets_list_panel diff --git a/events/sexp/admin.sexpr b/events/sexp/admin.sexpr new file mode 100644 index 0000000..111c453 --- /dev/null +++ b/events/sexp/admin.sexpr @@ -0,0 +1,96 @@ +;; Events admin components + +(defcomp ~events-calendar-admin-panel (&key description-html csrf description) + (section :class "max-w-3xl mx-auto p-4 space-y-10" + (div + (h2 :class "text-xl font-semibold" "Calendar configuration") + (div :id "cal-put-errors" :class "mt-2 text-sm text-red-600") + (div (label :class "block text-sm font-medium text-stone-700" "Description") + (raw! description-html)) + (form :id "calendar-form" :method "post" :hx-target "#main-panel" :hx-select "#main-panel" + :hx-on::before-request "document.querySelector('#cal-put-errors').textContent='';" + :hx-on::response-error "document.querySelector('#cal-put-errors').innerHTML = event.detail.xhr.responseText;" + :hx-on::after-request "if (event.detail.successful) this.reset()" + :class "hidden space-y-4 mt-4" :autocomplete "off" + (input :type "hidden" :name "csrf_token" :value csrf) + (div (label :class "block text-sm font-medium text-stone-700" "Description") + (div description) + (textarea :name "description" :autocomplete "off" :rows "4" :class "w-full p-2 border rounded" description)) + (div (button :class "px-3 py-2 rounded bg-stone-800 text-white" "Save")))) + (hr :class "border-stone-200"))) + +(defcomp ~events-entry-admin-link (&key href) + (a :href href :class "inline-flex items-center gap-1 px-2 py-1 text-xs text-stone-500 hover:text-stone-700 hover:bg-stone-100 rounded" + (i :class "fa fa-cog" :aria-hidden "true") " Admin")) + +(defcomp ~events-entry-field (&key label content-html) + (div :class "flex flex-col mb-4" + (div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" label) + (raw! content-html))) + +(defcomp ~events-entry-name-field (&key name) + (div :class "mt-1 text-lg font-medium" name)) + +(defcomp ~events-entry-slot-assigned (&key slot-name flex-label) + (div :class "mt-1" + (span :class "px-2 py-1 rounded text-sm bg-blue-100 text-blue-700" slot-name) + (span :class "ml-2 text-xs text-stone-500" flex-label))) + +(defcomp ~events-entry-slot-none () + (div :class "mt-1" (span :class "text-sm text-stone-400" "No slot assigned"))) + +(defcomp ~events-entry-time-field (&key time-str) + (div :class "mt-1" time-str)) + +(defcomp ~events-entry-state-field (&key entry-id badge-html) + (div :class "mt-1" (div :id (str "entry-state-" entry-id) (raw! badge-html)))) + +(defcomp ~events-entry-cost-field (&key cost-html) + (div :class "mt-1" (span :class "font-medium text-green-600" (raw! cost-html)))) + +(defcomp ~events-entry-tickets-field (&key entry-id tickets-config-html) + (div :class "mt-1" :id (str "entry-tickets-" entry-id) (raw! tickets-config-html))) + +(defcomp ~events-entry-date-field (&key date-str) + (div :class "mt-1" date-str)) + +(defcomp ~events-entry-posts-field (&key entry-id posts-panel-html) + (div :class "mt-1" :id (str "entry-posts-" entry-id) (raw! posts-panel-html))) + +(defcomp ~events-entry-panel (&key entry-id list-container name-html slot-html time-html state-html cost-html + tickets-html buy-html date-html posts-html options-html pre-action edit-url) + (section :id (str "entry-" entry-id) :class list-container + (raw! name-html) (raw! slot-html) (raw! time-html) (raw! state-html) (raw! cost-html) + (raw! tickets-html) (raw! buy-html) (raw! date-html) (raw! posts-html) + (div :class "flex gap-2 mt-6" + (raw! options-html) + (button :type "button" :class pre-action + :hx-get edit-url :hx-target (str "#entry-" entry-id) :hx-swap "outerHTML" + "Edit")))) + +(defcomp ~events-entry-title (&key name badge-html) + (<> (i :class "fa fa-clock") " " name " " (raw! badge-html))) + +(defcomp ~events-entry-times (&key time-str) + (div :class "text-sm text-gray-600" time-str)) + +(defcomp ~events-entry-optioned-oob (&key entry-id title-html state-html) + (<> (div :id (str "entry-title-" entry-id) :hx-swap-oob "innerHTML" (raw! title-html)) + (div :id (str "entry-state-" entry-id) :hx-swap-oob "innerHTML" (raw! state-html)))) + +(defcomp ~events-entry-options (&key entry-id buttons-html) + (div :id (str "calendar_entry_options_" entry-id) :class "flex flex-col md:flex-row gap-1" + (raw! buttons-html))) + +(defcomp ~events-entry-option-button (&key url target csrf btn-type action-btn confirm-title confirm-text + label is-btn) + (form :hx-post url :hx-select target :hx-target target :hx-swap "outerHTML" + :hx-trigger (if is-btn "confirmed" nil) + (input :type "hidden" :name "csrf_token" :value csrf) + (button :type btn-type :class action-btn + :data-confirm "true" :data-confirm-title confirm-title + :data-confirm-text confirm-text :data-confirm-icon "question" + :data-confirm-confirm-text (str "Yes, " label " it") + :data-confirm-cancel-text "Cancel" + :data-confirm-event (if is-btn "confirmed" nil) + (i :class "fa-solid fa-rotate mr-2" :aria-hidden "true") label))) diff --git a/events/sexp/calendar.sexpr b/events/sexp/calendar.sexpr new file mode 100644 index 0000000..2adb926 --- /dev/null +++ b/events/sexp/calendar.sexpr @@ -0,0 +1,102 @@ +;; Events calendar components + +(defcomp ~events-calendar-nav-arrow (&key pill-cls href label) + (a :class (str pill-cls " text-xl") :href href + :hx-get href :hx-target "#main-panel" :hx-select "#main-panel" :hx-swap "outerHTML" :hx-push-url "true" label)) + +(defcomp ~events-calendar-month-label (&key month-name year) + (div :class "px-3 font-medium" (str month-name " " year))) + +(defcomp ~events-calendar-weekday (&key name) + (div :class "py-1" name)) + +(defcomp ~events-calendar-day-short (&key day-str) + (span :class "sm:hidden text-[16px] text-stone-500" day-str)) + +(defcomp ~events-calendar-day-num (&key pill-cls href num) + (a :class pill-cls :href href :hx-get href :hx-target "#main-panel" :hx-select "#main-panel" + :hx-swap "outerHTML" :hx-push-url "true" num)) + +(defcomp ~events-calendar-entry-badge (&key bg-cls name state-label) + (div :class (str "flex items-center justify-between gap-1 text-[11px] rounded px-1 py-0.5 " bg-cls) + (span :class "truncate" name) + (span :class "shrink-0 text-[10px] font-semibold uppercase tracking-tight" state-label))) + +(defcomp ~events-calendar-cell (&key cell-cls day-short-html day-num-html badges-html) + (div :class cell-cls + (div :class "flex justify-between items-center" + (div :class "flex flex-col" (raw! day-short-html) (raw! day-num-html))) + (div :class "mt-1 space-y-0.5" (raw! badges-html)))) + +(defcomp ~events-calendar-grid (&key arrows-html weekdays-html cells-html) + (section :class "bg-orange-100" + (header :class "flex items-center justify-center mt-2" + (nav :class "flex items-center gap-2 text-2xl" (raw! arrows-html))) + (div :class "rounded-2xl border border-stone-200 bg-white/80 p-4" + (div :class "hidden sm:grid grid-cols-7 text-center text-md font-semibold text-stone-700 mb-2" (raw! weekdays-html)) + (div :class "grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200 rounded-xl overflow-hidden" (raw! cells-html))))) + +(defcomp ~events-calendars-create-form (&key create-url csrf) + (<> + (div :id "cal-create-errors" :class "mt-2 text-sm text-red-600") + (form :class "mt-4 flex gap-2 items-end" :hx-post create-url + :hx-target "#calendars-list" :hx-select "#calendars-list" :hx-swap "outerHTML" + :hx-on::before-request "document.querySelector('#cal-create-errors').textContent='';" + :hx-on::response-error "document.querySelector('#cal-create-errors').innerHTML = event.detail.xhr.responseText;" + (input :type "hidden" :name "csrf_token" :value csrf) + (div :class "flex-1" + (label :class "block text-sm text-gray-600" "Name") + (input :name "name" :type "text" :required true :class "w-full border rounded px-3 py-2" + :placeholder "e.g. Events, Gigs, Meetings")) + (button :type "submit" :class "border rounded px-3 py-2" "Add calendar")))) + +(defcomp ~events-calendars-panel (&key form-html list-html) + (section :class "p-4" + (raw! form-html) + (div :id "calendars-list" :class "mt-6" (raw! list-html)))) + +(defcomp ~events-calendars-empty () + (p :class "text-gray-500 mt-4" "No calendars yet. Create one above.")) + +(defcomp ~events-calendars-item (&key href cal-name cal-slug del-url csrf-hdr) + (div :class "mt-6 border rounded-lg p-4" + (div :class "flex items-center justify-between gap-3" + (a :class "flex items-baseline gap-3" :href href + :hx-get href :hx-target "#main-panel" :hx-select "#main-panel" :hx-swap "outerHTML" :hx-push-url "true" + (h3 :class "font-semibold" cal-name) + (h4 :class "text-gray-500" (str "/" cal-slug "/"))) + (button :class "text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400" + :data-confirm true :data-confirm-title "Delete calendar?" + :data-confirm-text "Entries will be hidden (soft delete)" + :data-confirm-icon "warning" :data-confirm-confirm-text "Yes, delete it" + :data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed" + :hx-delete del-url :hx-trigger "confirmed" + :hx-target "#calendars-list" :hx-select "#calendars-list" :hx-swap "outerHTML" + :hx-headers csrf-hdr + (i :class "fa-solid fa-trash"))))) + +(defcomp ~events-calendar-description-display (&key description edit-url) + (div :id "calendar-description" + (if description + (p :class "text-stone-700 whitespace-pre-line break-all" description) + (p :class "text-stone-400 italic" "No description yet.")) + (button :type "button" :class "mt-2 text-xs underline" + :hx-get edit-url :hx-target "#calendar-description" :hx-swap "outerHTML" + (i :class "fas fa-edit")))) + +(defcomp ~events-calendar-description-title-oob (&key description) + (div :id "calendar-description-title" :hx-swap-oob "outerHTML" + :class "text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block" + description)) + +(defcomp ~events-calendar-description-edit-form (&key save-url cancel-url csrf description) + (div :id "calendar-description" + (form :hx-post save-url :hx-target "#calendar-description" :hx-swap "outerHTML" + (input :type "hidden" :name "csrf_token" :value csrf) + (textarea :name "description" :autocomplete "off" :rows "4" + :class "w-full p-2 border rounded" description) + (div :class "mt-2 flex gap-2 text-xs" + (button :type "submit" :class "px-3 py-1 rounded bg-stone-800 text-white" "Save") + (button :type "button" :class "px-3 py-1 rounded border" + :hx-get cancel-url :hx-target "#calendar-description" :hx-swap "outerHTML" + "Cancel"))))) diff --git a/events/sexp/day.sexpr b/events/sexp/day.sexpr new file mode 100644 index 0000000..3c65cdb --- /dev/null +++ b/events/sexp/day.sexpr @@ -0,0 +1,84 @@ +;; Events day components + +(defcomp ~events-day-entry-link (&key href name time-str) + (a :href href :class "flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0" + (div :class "flex-1 min-w-0" + (div :class "font-medium truncate" name) + (div :class "text-xs text-stone-600 truncate" time-str)))) + +(defcomp ~events-day-entries-nav (&key inner-html) + (div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl" + :id "day-entries-nav-wrapper" + (div :class "flex overflow-x-auto gap-1 scrollbar-thin" + (raw! inner-html)))) + +(defcomp ~events-day-table (&key list-container rows-html pre-action add-url) + (section :id "day-entries" :class list-container + (table :class "w-full text-sm border table-fixed" + (thead :class "bg-stone-100" + (tr + (th :class "p-2 text-left w-2/6" "Name") + (th :class "text-left p-2 w-1/6" "Slot/Time") + (th :class "text-left p-2 w-1/6" "State") + (th :class "text-left p-2 w-1/6" "Cost") + (th :class "text-left p-2 w-1/6" "Tickets") + (th :class "text-left p-2 w-1/6" "Actions"))) + (tbody (raw! rows-html))) + (div :id "entry-add-container" :class "mt-4" + (button :type "button" :class pre-action + :hx-get add-url :hx-target "#entry-add-container" :hx-swap "innerHTML" + "+ Add entry")))) + +(defcomp ~events-day-empty-row () + (tr (td :colspan "6" :class "p-3 text-stone-500" "No entries yet."))) + +(defcomp ~events-day-row-name (&key href pill-cls name) + (td :class "p-2 align-top w-2/6" (div :class "font-medium" + (a :href href :class pill-cls :hx-get href :hx-target "#main-panel" :hx-select "#main-panel" + :hx-swap "outerHTML" :hx-push-url "true" name)))) + +(defcomp ~events-day-row-slot (&key href pill-cls slot-name time-str) + (td :class "p-2 align-top w-1/6" (div :class "text-xs font-medium" + (a :href href :class pill-cls :hx-get href :hx-target "#main-panel" :hx-select "#main-panel" + :hx-swap "outerHTML" :hx-push-url "true" slot-name) + (span :class "text-stone-600 font-normal" (raw! time-str))))) + +(defcomp ~events-day-row-time (&key start end) + (td :class "p-2 align-top w-1/6" (div :class "text-xs text-stone-600" (str start end)))) + +(defcomp ~events-day-row-state (&key state-id badge-html) + (td :class "p-2 align-top w-1/6" (div :id state-id (raw! badge-html)))) + +(defcomp ~events-day-row-cost (&key cost-str) + (td :class "p-2 align-top w-1/6" (span :class "font-medium text-green-600" cost-str))) + +(defcomp ~events-day-row-tickets (&key price-str count-str) + (td :class "p-2 align-top w-1/6" (div :class "text-xs space-y-1" + (div :class "font-medium text-green-600" price-str) + (div :class "text-stone-600" count-str)))) + +(defcomp ~events-day-row-no-tickets () + (td :class "p-2 align-top w-1/6" (span :class "text-xs text-stone-400" "No tickets"))) + +(defcomp ~events-day-row-actions () + (td :class "p-2 align-top w-1/6")) + +(defcomp ~events-day-row (&key tr-cls name-html slot-html state-html cost-html tickets-html actions-html) + (tr :class tr-cls (raw! name-html) (raw! slot-html) (raw! state-html) (raw! cost-html) (raw! tickets-html) (raw! actions-html))) + +(defcomp ~events-day-admin-panel () + (div :class "p-4 text-sm text-stone-500" "Admin options")) + +(defcomp ~events-day-entries-nav-oob-empty () + (div :id "day-entries-nav-wrapper" :hx-swap-oob "true")) + +(defcomp ~events-day-entries-nav-oob (&key items-html) + (div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl" + :id "day-entries-nav-wrapper" :hx-swap-oob "true" + (div :class "flex overflow-x-auto gap-1 scrollbar-thin" (raw! items-html)))) + +(defcomp ~events-day-nav-entry (&key href nav-btn name time-str) + (a :href href :class nav-btn + (div :class "flex-1 min-w-0" + (div :class "font-medium truncate" name) + (div :class "text-xs text-stone-600 truncate" time-str)))) diff --git a/events/sexp/entries.sexpr b/events/sexp/entries.sexpr new file mode 100644 index 0000000..f91a518 --- /dev/null +++ b/events/sexp/entries.sexpr @@ -0,0 +1,103 @@ +;; Events entry card components (all events / page summary) + +(defcomp ~events-state-badge (&key cls label) + (span :class (str "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium " cls) label)) + +(defcomp ~events-entry-title-linked (&key href name) + (a :href href :class "hover:text-emerald-700" + (h2 :class "text-lg font-semibold text-stone-900" name))) + +(defcomp ~events-entry-title-plain (&key name) + (h2 :class "text-lg font-semibold text-stone-900" name)) + +(defcomp ~events-entry-title-tile-linked (&key href name) + (a :href href :class "hover:text-emerald-700" + (h2 :class "text-base font-semibold text-stone-900 line-clamp-2" name))) + +(defcomp ~events-entry-title-tile-plain (&key name) + (h2 :class "text-base font-semibold text-stone-900 line-clamp-2" name)) + +(defcomp ~events-entry-page-badge (&key href title) + (a :href href :class "inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200" title)) + +(defcomp ~events-entry-cal-badge (&key name) + (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-700" name)) + +(defcomp ~events-entry-time-linked (&key href date-str) + (<> (a :href href :class "hover:text-stone-700" date-str) (raw! " · "))) + +(defcomp ~events-entry-time-plain (&key date-str) + (<> (span date-str) (raw! " · "))) + +(defcomp ~events-entry-cost (&key cost-html) + (div :class "mt-1 text-sm font-medium text-green-600" (raw! cost-html))) + +(defcomp ~events-entry-card (&key title-html badges-html time-parts cost-html widget-html) + (article :class "rounded-xl bg-white shadow-sm border border-stone-200 p-4" + (div :class "flex flex-col sm:flex-row sm:items-start justify-between gap-3" + (div :class "flex-1 min-w-0" + (raw! title-html) + (div :class "flex flex-wrap items-center gap-1.5 mt-1" (raw! badges-html)) + (div :class "mt-1 text-sm text-stone-500" (raw! time-parts)) + (raw! cost-html)) + (raw! widget-html)))) + +(defcomp ~events-entry-card-tile (&key title-html badges-html time-html cost-html widget-html) + (article :class "rounded-xl bg-white shadow-sm border border-stone-200 overflow-hidden" + (div :class "p-3" + (raw! title-html) + (div :class "flex flex-wrap items-center gap-1 mt-1" (raw! badges-html)) + (div :class "mt-1 text-xs text-stone-500" (raw! time-html)) + (raw! cost-html)) + (raw! widget-html))) + +(defcomp ~events-entry-tile-widget-wrapper (&key widget-html) + (div :class "border-t border-stone-100 px-3 py-2" (raw! widget-html))) + +(defcomp ~events-entry-widget-wrapper (&key widget-html) + (div :class "shrink-0" (raw! widget-html))) + +(defcomp ~events-date-separator (&key date-str) + (div :class "pt-2 pb-1" + (h3 :class "text-sm font-semibold text-stone-500 uppercase tracking-wide" date-str))) + +(defcomp ~events-sentinel (&key page next-url) + (div :id (str "sentinel-" page) :class "h-4 opacity-0 pointer-events-none" + :hx-get next-url :hx-trigger "intersect once delay:250ms" :hx-swap "outerHTML" + :role "status" :aria-hidden "true" + (div :class "text-center text-xs text-stone-400" "loading..."))) + +(defcomp ~events-list-svg () + (svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" + :viewBox "0 0 24 24" :stroke "currentColor" :stroke-width "2" + (path :stroke-linecap "round" :stroke-linejoin "round" :d "M4 6h16M4 12h16M4 18h16"))) + +(defcomp ~events-tile-svg () + (svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none" + :viewBox "0 0 24 24" :stroke "currentColor" :stroke-width "2" + (path :stroke-linecap "round" :stroke-linejoin "round" + :d "M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"))) + +(defcomp ~events-view-toggle (&key list-href tile-href hx-select list-active tile-active list-svg tile-svg) + (div :class "hidden md:flex justify-end px-3 pt-3 gap-1" + (a :href list-href :hx-get list-href :hx-target "#main-panel" :hx-select hx-select + :hx-swap "outerHTML" :hx-push-url "true" + :class (str "p-1.5 rounded " list-active) :title "List view" + :_ "on click js localStorage.removeItem('events_view') end" + (raw! list-svg)) + (a :href tile-href :hx-get tile-href :hx-target "#main-panel" :hx-select hx-select + :hx-swap "outerHTML" :hx-push-url "true" + :class (str "p-1.5 rounded " tile-active) :title "Tile view" + :_ "on click js localStorage.setItem('events_view','tile') end" + (raw! tile-svg)))) + +(defcomp ~events-grid (&key grid-cls cards-html) + (div :class grid-cls (raw! cards-html))) + +(defcomp ~events-empty () + (div :class "px-3 py-12 text-center text-stone-400" + (i :class "fa fa-calendar-xmark text-4xl mb-3" :aria-hidden "true") + (p :class "text-lg" "No upcoming events"))) + +(defcomp ~events-main-panel-body (&key toggle-html body-html) + (<> (raw! toggle-html) (raw! body-html) (div :class "pb-8"))) diff --git a/events/sexp/header.sexpr b/events/sexp/header.sexpr new file mode 100644 index 0000000..03c7872 --- /dev/null +++ b/events/sexp/header.sexpr @@ -0,0 +1,46 @@ +;; Events header components + +(defcomp ~events-oob-header (&key parent-id child-id row-html) + (div :id parent-id :hx-swap-oob "outerHTML" :class "w-full" + (div :class "w-full" + (raw! row-html) + (div :id child-id)))) + +(defcomp ~events-post-label (&key feature-image title) + (<> (when feature-image (img :src feature-image :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0")) + (span title))) + +(defcomp ~events-post-cart-link (&key href count) + (a :href href :class "relative inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 transition" + (i :class "fa fa-shopping-cart" :aria-hidden "true") + (span count))) + +(defcomp ~events-calendars-label () + (<> (i :class "fa fa-calendar" :aria-hidden "true") (div "Calendars"))) + +(defcomp ~events-markets-label () + (<> (i :class "fa fa-shopping-bag" :aria-hidden "true") (div "Markets"))) + +(defcomp ~events-payments-label () + (<> (i :class "fa fa-credit-card" :aria-hidden "true") (div "Payments"))) + +(defcomp ~events-calendar-label (&key name description) + (div :class "flex flex-col md:flex-row md:gap-2 items-center min-w-0" + (div :class "flex flex-row items-center gap-2" + (i :class "fa fa-calendar") + (div :class "shrink-0" name)) + (div :id "calendar-description-title" + :class "text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block" + description))) + +(defcomp ~events-day-label (&key date-str) + (div :class "flex gap-1 items-center" + (i :class "fa fa-calendar-day") + (span date-str))) + +(defcomp ~events-entry-label (&key entry-id title-html times-html) + (div :id (str "entry-title-" entry-id) :class "flex gap-1 items-center" + (raw! title-html) (raw! times-html))) + +(defcomp ~events-header-child (&key inner-html) + (div :id "root-header-child" :class "w-full" (raw! inner-html))) diff --git a/events/sexp/page.sexpr b/events/sexp/page.sexpr new file mode 100644 index 0000000..a0ba5a1 --- /dev/null +++ b/events/sexp/page.sexpr @@ -0,0 +1,386 @@ +;; Events page-level components (slots, ticket types, buy form, cart, posts nav) + +(defcomp ~events-slot-days-pills (&key days-inner-html) + (div :class "flex flex-wrap gap-1" (raw! days-inner-html))) + +(defcomp ~events-slot-day-pill (&key day) + (span :class "px-2 py-0.5 rounded-full text-xs bg-slate-200" day)) + +(defcomp ~events-slot-no-days () + (span :class "text-xs text-slate-400" "No days")) + +(defcomp ~events-slot-panel (&key slot-id list-container days-html flexible time-str cost-str pre-action edit-url) + (section :id (str "slot-" slot-id) :class list-container + (div :class "flex flex-col" + (div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" "Days") + (div :class "mt-1" (raw! days-html))) + (div :class "flex flex-col" + (div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" "Flexible") + (div :class "mt-1" flexible)) + (div :class "grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm" + (div :class "flex flex-col" + (div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" "Time") + (div :class "mt-1" time-str)) + (div :class "flex flex-col" + (div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" "Cost") + (div :class "mt-1" cost-str))) + (button :type "button" :class pre-action :hx-get edit-url + :hx-target (str "#slot-" slot-id) :hx-swap "outerHTML" "Edit"))) + +(defcomp ~events-slot-description-oob (&key description) + (div :id "slot-description-title" :hx-swap-oob "outerHTML" + :class "text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block" + description)) + +(defcomp ~events-slots-empty-row () + (tr (td :colspan "5" :class "p-3 text-stone-500" "No slots yet."))) + +(defcomp ~events-slots-row (&key tr-cls slot-href pill-cls hx-select slot-name description + flexible days-html time-str cost-str action-btn del-url csrf-hdr) + (tr :class tr-cls + (td :class "p-2 align-top w-1/6" + (div :class "font-medium" + (a :href slot-href :class pill-cls :hx-get slot-href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" slot-name)) + (p :class "text-stone-500 whitespace-pre-line break-all w-full" description)) + (td :class "p-2 align-top w-1/6" flexible) + (td :class "p-2 align-top w-1/6" (raw! days-html)) + (td :class "p-2 align-top w-1/6" time-str) + (td :class "p-2 align-top w-1/6" cost-str) + (td :class "p-2 align-top w-1/6" + (button :class action-btn :type "button" + :data-confirm "true" :data-confirm-title "Delete slot?" + :data-confirm-text "This action cannot be undone." + :data-confirm-icon "warning" :data-confirm-confirm-text "Yes, delete it" + :data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed" + :hx-delete del-url :hx-target "#slots-table" :hx-select "#slots-table" + :hx-swap "outerHTML" :hx-headers csrf-hdr :hx-trigger "confirmed" + (i :class "fa-solid fa-trash"))))) + +(defcomp ~events-slots-table (&key list-container rows-html pre-action add-url) + (section :id "slots-table" :class list-container + (table :class "w-full text-sm border table-fixed" + (thead :class "bg-stone-100" + (tr (th :class "p-2 text-left w-1/6" "Name") + (th :class "p-2 text-left w-1/6" "Flexible") + (th :class "text-left p-2 w-1/6" "Days") + (th :class "text-left p-2 w-1/6" "Time") + (th :class "text-left p-2 w-1/6" "Cost") + (th :class "text-left p-2 w-1/6" "Actions"))) + (tbody (raw! rows-html))) + (div :id "slot-add-container" :class "mt-4" + (button :type "button" :class pre-action + :hx-get add-url :hx-target "#slot-add-container" :hx-swap "innerHTML" + "+ Add slot")))) + +(defcomp ~events-ticket-type-col (&key label value) + (div :class "flex flex-col" + (div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" label) + (div :class "mt-1" value))) + +(defcomp ~events-ticket-type-panel (&key ticket-id list-container c1 c2 c3 pre-action edit-url) + (section :id (str "ticket-" ticket-id) :class list-container + (div :class "grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm" + (raw! c1) (raw! c2) (raw! c3)) + (button :type "button" :class pre-action :hx-get edit-url + :hx-target (str "#ticket-" ticket-id) :hx-swap "outerHTML" "Edit"))) + +(defcomp ~events-ticket-types-empty-row () + (tr (td :colspan "4" :class "p-3 text-stone-500" "No ticket types yet."))) + +(defcomp ~events-ticket-types-row (&key tr-cls tt-href pill-cls hx-select tt-name cost-str count + action-btn del-url csrf-hdr) + (tr :class tr-cls + (td :class "p-2 align-top w-1/3" + (div :class "font-medium" + (a :href tt-href :class pill-cls :hx-get tt-href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" tt-name))) + (td :class "p-2 align-top w-1/4" cost-str) + (td :class "p-2 align-top w-1/4" count) + (td :class "p-2 align-top w-1/6" + (button :class action-btn :type "button" + :data-confirm "true" :data-confirm-title "Delete ticket type?" + :data-confirm-text "This action cannot be undone." + :data-confirm-icon "warning" :data-confirm-confirm-text "Yes, delete it" + :data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed" + :hx-delete del-url :hx-target "#tickets-table" :hx-select "#tickets-table" + :hx-swap "outerHTML" :hx-headers csrf-hdr :hx-trigger "confirmed" + (i :class "fa-solid fa-trash"))))) + +(defcomp ~events-ticket-types-table (&key list-container rows-html action-btn add-url) + (section :id "tickets-table" :class list-container + (table :class "w-full text-sm border table-fixed" + (thead :class "bg-stone-100" + (tr (th :class "p-2 text-left w-1/3" "Name") + (th :class "text-left p-2 w-1/4" "Cost") + (th :class "text-left p-2 w-1/4" "Count") + (th :class "text-left p-2 w-1/6" "Actions"))) + (tbody (raw! rows-html))) + (div :id "ticket-add-container" :class "mt-4" + (button :class action-btn :hx-get add-url :hx-target "#ticket-add-container" :hx-swap "innerHTML" + (i :class "fa fa-plus") " Add ticket type")))) + +(defcomp ~events-ticket-config-display (&key price-str count-str show-js) + (div :class "space-y-2" + (div :class "flex items-center gap-2" + (span :class "text-sm font-medium text-stone-700" "Price:") + (span :class "font-medium text-green-600" (raw! price-str))) + (div :class "flex items-center gap-2" + (span :class "text-sm font-medium text-stone-700" "Available:") + (span :class "font-medium text-blue-600" count-str)) + (button :type "button" :class "text-xs text-blue-600 hover:text-blue-800 underline" + :onclick show-js "Edit ticket config"))) + +(defcomp ~events-ticket-config-none (&key show-js) + (div :class "space-y-2" + (span :class "text-sm text-stone-400" "No tickets configured") + (button :type "button" :class "block text-xs text-blue-600 hover:text-blue-800 underline" + :onclick show-js "Configure tickets"))) + +(defcomp ~events-ticket-config-form (&key entry-id hidden-cls update-url csrf price-val count-val hide-js) + (form :id (str "ticket-form-" entry-id) :class (str hidden-cls " space-y-3 mt-2 p-3 border rounded bg-stone-50") + :hx-post update-url :hx-target (str "#entry-tickets-" entry-id) :hx-swap "innerHTML" + (input :type "hidden" :name "csrf_token" :value csrf) + (div (label :for (str "ticket-price-" entry-id) :class "block text-sm font-medium text-stone-700 mb-1" + (raw! "Ticket Price (£)")) + (input :type "number" :id (str "ticket-price-" entry-id) :name "ticket_price" + :step "0.01" :min "0" :value price-val + :class "w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + :placeholder "e.g., 5.00")) + (div (label :for (str "ticket-count-" entry-id) :class "block text-sm font-medium text-stone-700 mb-1" + "Total Tickets") + (input :type "number" :id (str "ticket-count-" entry-id) :name "ticket_count" + :min "0" :value count-val + :class "w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + :placeholder "Leave empty for unlimited")) + (div :class "flex gap-2" + (button :type "submit" :class "px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm" "Save") + (button :type "button" :class "px-4 py-2 bg-stone-200 text-stone-700 rounded hover:bg-stone-300 text-sm" + :onclick hide-js "Cancel")))) + +(defcomp ~events-buy-not-confirmed (&key entry-id) + (div :id (str "ticket-buy-" entry-id) :class "rounded-xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-500" + (i :class "fa fa-ticket mr-1" :aria-hidden "true") + "Tickets available once this event is confirmed.")) + +(defcomp ~events-buy-info-sold (&key count) + (span (str count " sold"))) + +(defcomp ~events-buy-info-remaining (&key count) + (span (str count " remaining"))) + +(defcomp ~events-buy-info-basket (&key count) + (span :class "text-emerald-600 font-medium" + (i :class "fa fa-shopping-cart text-[0.6rem]" :aria-hidden "true") + (str " " count " in basket"))) + +(defcomp ~events-buy-info-bar (&key items-html) + (div :class "flex items-center gap-3 mb-3 text-xs text-stone-500" (raw! items-html))) + +(defcomp ~events-buy-type-item (&key type-name cost-str adjust-controls-html) + (div :class "flex items-center justify-between p-3 rounded-lg bg-stone-50 border border-stone-100" + (div (div :class "font-medium text-sm" type-name) + (div :class "text-xs text-stone-500" cost-str)) + (raw! adjust-controls-html))) + +(defcomp ~events-buy-types-wrapper (&key items-html) + (div :class "space-y-2" (raw! items-html))) + +(defcomp ~events-buy-default (&key price-str adjust-controls-html) + (<> (div :class "flex items-center justify-between mb-4" + (div (span :class "font-medium text-green-600" price-str) + (span :class "text-sm text-stone-500 ml-2" "per ticket"))) + (raw! adjust-controls-html))) + +(defcomp ~events-buy-panel (&key entry-id info-html body-html) + (div :id (str "ticket-buy-" entry-id) :class "rounded-xl border border-stone-200 bg-white p-4" + (h3 :class "text-sm font-semibold text-stone-700 mb-3" + (i :class "fa fa-ticket mr-1" :aria-hidden "true") "Tickets") + (raw! info-html) (raw! body-html))) + +(defcomp ~events-adjust-form (&key adjust-url target extra-cls csrf entry-id tt-html count-val btn-html) + (form :hx-post adjust-url :hx-target target :hx-swap "outerHTML" :class extra-cls + (input :type "hidden" :name "csrf_token" :value csrf) + (input :type "hidden" :name "entry_id" :value entry-id) + (raw! tt-html) + (input :type "hidden" :name "count" :value count-val) + (raw! btn-html))) + +(defcomp ~events-adjust-tt-hidden (&key ticket-type-id) + (input :type "hidden" :name "ticket_type_id" :value ticket-type-id)) + +(defcomp ~events-adjust-cart-plus () + (button :type "submit" + :class "relative inline-flex items-center justify-center text-sm font-medium text-stone-500 hover:bg-emerald-50 rounded p-1" + (i :class "fa fa-cart-plus text-2xl" :aria-hidden "true"))) + +(defcomp ~events-adjust-minus () + (button :type "submit" + :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" + "-")) + +(defcomp ~events-adjust-plus () + (button :type "submit" + :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" + "+")) + +(defcomp ~events-adjust-cart-icon (&key href count) + (a :class "relative inline-flex items-center justify-center text-emerald-700" :href href + (span :class "relative inline-flex items-center justify-center" + (i :class "fa-solid fa-shopping-cart text-2xl" :aria-hidden "true") + (span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none" + (span :class "flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold" count))))) + +(defcomp ~events-adjust-controls (&key minus-html cart-icon-html plus-html) + (div :class "flex items-center gap-2" (raw! minus-html) (raw! cart-icon-html) (raw! plus-html))) + +(defcomp ~events-buy-result (&key entry-id count-label tickets-html remaining-html my-tickets-href) + (div :id (str "ticket-buy-" entry-id) :class "rounded-xl border border-emerald-200 bg-emerald-50 p-4" + (div :class "flex items-center gap-2 mb-3" + (i :class "fa fa-check-circle text-emerald-600" :aria-hidden "true") + (span :class "font-semibold text-emerald-800" count-label)) + (div :class "space-y-2 mb-4" (raw! tickets-html)) + (raw! remaining-html) + (div :class "mt-3 flex gap-2" + (a :href my-tickets-href :class "text-sm text-emerald-700 hover:text-emerald-900 underline" + "View all my tickets")))) + +(defcomp ~events-buy-result-ticket (&key href code-short) + (a :href href :class "flex items-center justify-between p-2 rounded-lg bg-white border border-emerald-100 hover:border-emerald-300 transition text-sm" + (div :class "flex items-center gap-2" + (i :class "fa fa-ticket text-emerald-500" :aria-hidden "true") + (span :class "font-mono text-xs text-stone-500" code-short)) + (span :class "text-xs text-emerald-600 font-medium" "View ticket"))) + +(defcomp ~events-buy-result-remaining (&key text) + (p :class "text-xs text-stone-500" text)) + +(defcomp ~events-cart-icon-logo (&key blog-href logo) + (div :id "cart-mini" :hx-swap-oob "true" + (div :class "h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0" + (a :href blog-href :class "h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1" + (img :src logo :class "h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0"))))) + +(defcomp ~events-cart-icon-badge (&key cart-href count) + (div :id "cart-mini" :hx-swap-oob "true" + (a :href cart-href :class "relative inline-flex items-center justify-center text-stone-700 hover:text-emerald-700" + (i :class "fa fa-shopping-cart text-5xl" :aria-hidden "true") + (span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-full bg-emerald-600 text-white text-sm w-5 h-5" + count)))) + +;; Inline ticket widget (for all-events/page-summary cards) +(defcomp ~events-tw-form (&key ticket-url target csrf entry-id count-val btn-html) + (form :action ticket-url :method "post" :hx-post ticket-url :hx-target target :hx-swap "outerHTML" + (input :type "hidden" :name "csrf_token" :value csrf) + (input :type "hidden" :name "entry_id" :value entry-id) + (input :type "hidden" :name "count" :value count-val) + (raw! btn-html))) + +(defcomp ~events-tw-cart-plus () + (button :type "submit" :class "relative inline-flex items-center justify-center text-stone-500 hover:bg-emerald-50 rounded p-1" + (i :class "fa fa-cart-plus text-2xl" :aria-hidden "true"))) + +(defcomp ~events-tw-minus () + (button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "-")) + +(defcomp ~events-tw-plus () + (button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "+")) + +(defcomp ~events-tw-cart-icon (&key qty) + (span :class "relative inline-flex items-center justify-center text-emerald-700" + (span :class "relative inline-flex items-center justify-center" + (i :class "fa-solid fa-shopping-cart text-xl" :aria-hidden "true") + (span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none" + (span :class "flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold" qty))))) + +(defcomp ~events-tw-widget (&key entry-id price-html inner-html) + (div :id (str "page-ticket-" entry-id) :class "flex items-center gap-2" + (span :class "text-green-600 font-medium text-sm" (raw! price-html)) + (raw! inner-html))) + +;; Entry posts panel +(defcomp ~events-entry-posts-panel (&key posts-html search-url entry-id) + (div :class "space-y-2" + (raw! posts-html) + (div :class "mt-3 pt-3 border-t" + (label :class "block text-xs font-medium text-stone-700 mb-1" "Add Post") + (input :type "text" :placeholder "Search posts..." + :class "w-full px-3 py-2 border rounded text-sm" + :hx-get search-url :hx-trigger "keyup changed delay:300ms, load" + :hx-target (str "#post-search-results-" entry-id) :hx-swap "innerHTML" :name "q") + (div :id (str "post-search-results-" entry-id) :class "mt-2 max-h-96 overflow-y-auto border rounded")))) + +(defcomp ~events-entry-posts-list (&key items-html) + (div :class "space-y-2" (raw! items-html))) + +(defcomp ~events-entry-posts-none () + (p :class "text-sm text-stone-400" "No posts associated")) + +(defcomp ~events-entry-post-item (&key img-html title del-url entry-id csrf-hdr) + (div :class "flex items-center justify-between gap-3 p-2 bg-stone-50 rounded border" + (raw! img-html) (span :class "text-sm flex-1" title) + (button :type "button" :class "text-xs text-red-600 hover:text-red-800 flex-shrink-0" + :data-confirm "true" :data-confirm-title "Remove post?" + :data-confirm-text (str "This will remove " title " from this entry") + :data-confirm-icon "warning" :data-confirm-confirm-text "Yes, remove it" + :data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed" + :hx-delete del-url :hx-trigger "confirmed" + :hx-target (str "#entry-posts-" entry-id) :hx-swap "innerHTML" + :hx-headers csrf-hdr + (i :class "fa fa-times") " Remove"))) + +(defcomp ~events-post-img (&key src alt) + (img :src src :alt alt :class "w-8 h-8 rounded-full object-cover flex-shrink-0")) + +(defcomp ~events-post-img-placeholder () + (div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")) + +;; Entry posts nav OOB +(defcomp ~events-entry-posts-nav-oob-empty () + (div :id "entry-posts-nav-wrapper" :hx-swap-oob "true")) + +(defcomp ~events-entry-posts-nav-oob (&key items-html) + (div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl" + :id "entry-posts-nav-wrapper" :hx-swap-oob "true" + (div :class "flex overflow-x-auto gap-1 scrollbar-thin" (raw! items-html)))) + +(defcomp ~events-entry-nav-post (&key href nav-btn img-html title) + (a :href href :class nav-btn (raw! img-html) (div :class "flex-1 min-w-0" (div :class "font-medium truncate" title)))) + +;; Post nav entries OOB +(defcomp ~events-post-nav-oob-empty () + (div :id "entries-calendars-nav-wrapper" :hx-swap-oob "true")) + +(defcomp ~events-post-nav-entry (&key href nav-btn name time-str) + (a :href href :class nav-btn + (div :class "w-8 h-8 rounded bg-stone-200 flex-shrink-0") + (div :class "flex-1 min-w-0" + (div :class "font-medium truncate" name) + (div :class "text-xs text-stone-600 truncate" time-str)))) + +(defcomp ~events-post-nav-calendar (&key href nav-btn name) + (a :href href :class nav-btn + (i :class "fa fa-calendar" :aria-hidden "true") + (div name))) + +(defcomp ~events-post-nav-wrapper (&key items-html hyperscript) + (div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl" + :id "entries-calendars-nav-wrapper" :hx-swap-oob "true" + (button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded" + :aria-label "Scroll left" + :_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200" + (i :class "fa fa-chevron-left")) + (div :id "associated-items-container" + :class "overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none" + :style "scroll-behavior: smooth;" :_ hyperscript + (div :class "flex flex-col sm:flex-row gap-1" (raw! items-html))) + (style ".scrollbar-hide::-webkit-scrollbar { display: none; } .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }") + (button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded" + :aria-label "Scroll right" + :_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200" + (i :class "fa fa-chevron-right")))) + +;; Entry nav post link (with image) +(defcomp ~events-entry-nav-post-link (&key href img-html title) + (a :href href :class "flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0" + (raw! img-html) (div :class "flex-1 min-w-0" (div :class "font-medium truncate" title)))) diff --git a/events/sexp/payments.sexpr b/events/sexp/payments.sexpr new file mode 100644 index 0000000..494ac00 --- /dev/null +++ b/events/sexp/payments.sexpr @@ -0,0 +1,59 @@ +;; Events payments components + +(defcomp ~events-payments-panel (&key update-url csrf merchant-code placeholder input-cls sumup-configured checkout-prefix) + (section :class "p-4 max-w-lg mx-auto" + (div :id "payments-panel" :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200" + (h3 :class "text-lg font-semibold text-stone-800" + (i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment") + (p :class "text-xs text-stone-400" "Configure per-page SumUp credentials. Leave blank to use the global merchant account.") + (form :hx-put update-url :hx-target "#payments-panel" :hx-swap "outerHTML" :hx-select "#payments-panel" :class "space-y-3" + (input :type "hidden" :name "csrf_token" :value csrf) + (div (label :class "block text-xs font-medium text-stone-600 mb-1" "Merchant Code") + (input :type "text" :name "merchant_code" :value merchant-code :placeholder "e.g. ME4J6100" :class input-cls)) + (div (label :class "block text-xs font-medium text-stone-600 mb-1" "API Key") + (input :type "password" :name "api_key" :value "" :placeholder placeholder :class input-cls) + (when sumup-configured (p :class "text-xs text-stone-400 mt-0.5" "Key is set. Leave blank to keep current key."))) + (div (label :class "block text-xs font-medium text-stone-600 mb-1" "Checkout Reference Prefix") + (input :type "text" :name "checkout_prefix" :value checkout-prefix :placeholder "e.g. ROSE-" :class input-cls)) + (button :type "submit" :class "px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500" + "Save SumUp Settings") + (when sumup-configured (span :class "ml-2 text-xs text-green-600" + (i :class "fa fa-check-circle") " Connected")))))) + +(defcomp ~events-markets-create-form (&key create-url csrf) + (<> + (div :id "market-create-errors" :class "mt-2 text-sm text-red-600") + (form :class "mt-4 flex gap-2 items-end" :hx-post create-url + :hx-target "#markets-list" :hx-select "#markets-list" :hx-swap "outerHTML" + :hx-on::before-request "document.querySelector('#market-create-errors').textContent='';" + :hx-on::response-error "document.querySelector('#market-create-errors').innerHTML = event.detail.xhr.responseText;" + (input :type "hidden" :name "csrf_token" :value csrf) + (div :class "flex-1" + (label :class "block text-sm text-gray-600" "Name") + (input :name "name" :type "text" :required true :class "w-full border rounded px-3 py-2" + :placeholder "e.g. Farm Shop, Bakery")) + (button :type "submit" :class "border rounded px-3 py-2" "Add market")))) + +(defcomp ~events-markets-panel (&key form-html list-html) + (section :class "p-4" + (raw! form-html) + (div :id "markets-list" :class "mt-6" (raw! list-html)))) + +(defcomp ~events-markets-empty () + (p :class "text-gray-500 mt-4" "No markets yet. Create one above.")) + +(defcomp ~events-markets-item (&key href market-name market-slug del-url csrf-hdr) + (div :class "mt-6 border rounded-lg p-4" + (div :class "flex items-center justify-between gap-3" + (a :class "flex items-baseline gap-3" :href href + (h3 :class "font-semibold" market-name) + (h4 :class "text-gray-500" (str "/" market-slug "/"))) + (button :class "text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400" + :data-confirm true :data-confirm-title "Delete market?" + :data-confirm-text "Products will be hidden (soft delete)" + :data-confirm-icon "warning" :data-confirm-confirm-text "Yes, delete it" + :data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed" + :hx-delete del-url :hx-trigger "confirmed" + :hx-target "#markets-list" :hx-select "#markets-list" :hx-swap "outerHTML" + :hx-headers csrf-hdr + (i :class "fa-solid fa-trash"))))) diff --git a/events/sexp/sexp_components.py b/events/sexp/sexp_components.py index 5f10a5e..f8eb1d0 100644 --- a/events/sexp/sexp_components.py +++ b/events/sexp/sexp_components.py @@ -7,16 +7,20 @@ Called from route handlers in place of ``render_template()``. """ from __future__ import annotations +import os from typing import Any from markupsafe import escape -from shared.sexp.jinja_bridge import sexp +from shared.sexp.jinja_bridge import render, load_service_components from shared.sexp.helpers import ( call_url, get_asset_url, root_header_html, search_mobile_html, search_desktop_html, full_page, oob_page, ) +# Load events-specific .sexpr components at import time +load_service_components(os.path.dirname(os.path.dirname(__file__))) + # --------------------------------------------------------------------------- # OOB header helper (same pattern as market) @@ -24,13 +28,8 @@ from shared.sexp.helpers import ( def _oob_header_html(parent_id: str, child_id: str, row_html: str) -> str: """Wrap a header row in OOB div with child placeholder.""" - return sexp( - '(div :id pid :hx-swap-oob "outerHTML" :class "w-full"' - ' (div :class "w-full"' - ' (raw! rh)' - ' (div :id cid)))', - pid=parent_id, cid=child_id, rh=row_html, - ) + return render("events-oob-header", + parent_id=parent_id, child_id=child_id, row_html=row_html) # --------------------------------------------------------------------------- @@ -44,23 +43,15 @@ def _post_header_html(ctx: dict, *, oob: bool = False) -> str: title = (post.get("title") or "")[:160] feature_image = post.get("feature_image") - label_html = sexp( - '(<> (when fi (img :src fi :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"))' - ' (span t))', - fi=feature_image, t=title, - ) + label_html = render("events-post-label", + feature_image=feature_image, title=title) nav_parts = [] page_cart_count = ctx.get("page_cart_count", 0) if page_cart_count and page_cart_count > 0: cart_href = call_url(ctx, "cart_url", f"/{slug}/") - nav_parts.append(sexp( - '(a :href ch :class "relative inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full' - ' border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 transition"' - ' (i :class "fa fa-shopping-cart" :aria-hidden "true")' - ' (span cnt))', - ch=cart_href, cnt=str(page_cart_count), - )) + nav_parts.append(render("events-post-cart-link", + href=cart_href, count=str(page_cart_count))) # Post nav: calendar links + admin nav_parts.append(_post_nav_html(ctx)) @@ -68,15 +59,9 @@ def _post_header_html(ctx: dict, *, oob: bool = False) -> str: nav_html = "".join(nav_parts) link_href = call_url(ctx, "blog_url", f"/{slug}/") - return sexp( - '(~menu-row :id "post-row" :level 1' - ' :link-href lh :link-label-html llh' - ' :nav-html nh :child-id "post-header-child" :oob oob)', - lh=link_href, - llh=label_html, - nh=nav_html, - oob=oob, - ) + return render("menu-row", id="post-row", level=1, + link_href=link_href, link_label_html=label_html, + nav_html=nav_html, child_id="post-header-child", oob=oob) def _post_nav_html(ctx: dict) -> str: @@ -93,13 +78,9 @@ def _post_nav_html(ctx: dict) -> str: cal_name = getattr(cal, "name", "") if hasattr(cal, "name") else cal.get("name", "") href = url_for("calendars.calendar.get", calendar_slug=cal_slug) is_sel = (cal_slug == current_cal_slug) - parts.append(sexp( - '(~nav-link :href h :icon "fa fa-calendar" :label l :select-colours sc :is-selected sel)', - h=href, - l=cal_name, - sc=select_colours, - sel=is_sel, - )) + parts.append(render("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_html", "") if container_nav: @@ -120,14 +101,10 @@ def _calendars_header_html(ctx: dict, *, oob: bool = False) -> str: """Build the calendars section header row.""" from quart import url_for link_href = url_for("calendars.home") - return sexp( - '(~menu-row :id "calendars-row" :level 3' - ' :link-href lh :link-label-html llh' - ' :child-id "calendars-header-child" :oob oob)', - lh=link_href, - llh=sexp('(<> (i :class "fa fa-calendar" :aria-hidden "true") (div "Calendars"))'), - oob=oob, - ) + return render("menu-row", id="calendars-row", level=3, + link_href=link_href, + link_label_html=render("events-calendars-label"), + child_id="calendars-header-child", oob=oob) # --------------------------------------------------------------------------- @@ -145,29 +122,15 @@ def _calendar_header_html(ctx: dict, *, oob: bool = False) -> str: cal_desc = getattr(calendar, "description", "") or "" link_href = url_for("calendars.calendar.get", calendar_slug=cal_slug) - label_html = sexp( - '(div :class "flex flex-col md:flex-row md:gap-2 items-center min-w-0"' - ' (div :class "flex flex-row items-center gap-2"' - ' (i :class "fa fa-calendar")' - ' (div :class "shrink-0" n))' - ' (div :id "calendar-description-title"' - ' :class "text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block"' - ' d))', - n=cal_name, d=cal_desc, - ) + label_html = render("events-calendar-label", + name=cal_name, description=cal_desc) # Desktop nav: slots + admin nav_html = _calendar_nav_html(ctx) - return sexp( - '(~menu-row :id "calendar-row" :level 3' - ' :link-href lh :link-label-html llh' - ' :nav-html nh :child-id "calendar-header-child" :oob oob)', - lh=link_href, - llh=label_html, - nh=nav_html, - oob=oob, - ) + return render("menu-row", id="calendar-row", level=3, + link_href=link_href, link_label_html=label_html, + nav_html=nav_html, child_id="calendar-header-child", oob=oob) def _calendar_nav_html(ctx: dict) -> str: @@ -183,17 +146,12 @@ def _calendar_nav_html(ctx: dict) -> str: parts = [] slots_href = url_for("calendars.calendar.slots.get", calendar_slug=cal_slug) - parts.append(sexp( - '(~nav-link :href h :icon "fa fa-clock" :label "Slots" :select-colours sc)', - h=slots_href, - sc=select_colours, - )) + parts.append(render("nav-link", href=slots_href, icon="fa fa-clock", + label="Slots", select_colours=select_colours)) if is_admin: admin_href = url_for("calendars.calendar.admin.admin", calendar_slug=cal_slug) - parts.append(sexp( - '(~nav-link :href h :icon "fa fa-cog" :select-colours sc)', - h=admin_href, sc=select_colours, - )) + parts.append(render("nav-link", href=admin_href, icon="fa fa-cog", + select_colours=select_colours)) return "".join(parts) @@ -219,24 +177,14 @@ def _day_header_html(ctx: dict, *, oob: bool = False) -> str: month=day_date.month, day=day_date.day, ) - label_html = sexp( - '(div :class "flex gap-1 items-center"' - ' (i :class "fa fa-calendar-day")' - ' (span d))', - d=day_date.strftime("%A %d %B %Y"), - ) + label_html = render("events-day-label", + date_str=day_date.strftime("%A %d %B %Y")) nav_html = _day_nav_html(ctx) - return sexp( - '(~menu-row :id "day-row" :level 4' - ' :link-href lh :link-label-html llh' - ' :nav-html nh :child-id "day-header-child" :oob oob)', - lh=link_href, - llh=label_html, - nh=nav_html, - oob=oob, - ) + return render("menu-row", id="day-row", level=4, + link_href=link_href, link_label_html=label_html, + nav_html=nav_html, child_id="day-header-child", oob=oob) def _day_nav_html(ctx: dict) -> str: @@ -265,22 +213,12 @@ def _day_nav_html(ctx: dict) -> str: entry_id=entry.id, ) start = entry.start_at.strftime("%H:%M") if entry.start_at else "" - end = f" – {entry.end_at.strftime('%H:%M')}" if entry.end_at else "" - entry_links.append(sexp( - '(a :href h :class "flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0"' - ' (div :class "flex-1 min-w-0"' - ' (div :class "font-medium truncate" n)' - ' (div :class "text-xs text-stone-600 truncate" t)))', - h=href, n=entry.name, t=f"{start}{end}", - )) + end = f" \u2013 {entry.end_at.strftime('%H:%M')}" if entry.end_at else "" + entry_links.append(render("events-day-entry-link", + href=href, name=entry.name, + time_str=f"{start}{end}")) inner = "".join(entry_links) - parts.append(sexp( - '(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"' - ' :id "day-entries-nav-wrapper"' - ' (div :class "flex overflow-x-auto gap-1 scrollbar-thin"' - ' (raw! inner)))', - inner=inner, - )) + parts.append(render("events-day-entries-nav", inner_html=inner)) if is_admin and day_date: admin_href = url_for( @@ -290,10 +228,7 @@ def _day_nav_html(ctx: dict) -> str: month=day_date.month, day=day_date.day, ) - parts.append(sexp( - '(~nav-link :href h :icon "fa fa-cog")', - h=admin_href, - )) + parts.append(render("nav-link", href=admin_href, icon="fa fa-cog")) return "".join(parts) @@ -319,13 +254,9 @@ def _day_admin_header_html(ctx: dict, *, oob: bool = False) -> str: month=day_date.month, day=day_date.day, ) - return sexp( - '(~menu-row :id "day-admin-row" :level 5' - ' :link-href lh :link-label "admin" :icon "fa fa-cog"' - ' :child-id "day-admin-header-child" :oob oob)', - lh=link_href, - oob=oob, - ) + return render("menu-row", id="day-admin-row", level=5, + link_href=link_href, link_label="admin", icon="fa fa-cog", + child_id="day-admin-header-child", oob=oob) # --------------------------------------------------------------------------- @@ -346,19 +277,13 @@ def _calendar_admin_header_html(ctx: dict, *, oob: bool = False) -> str: ("calendars.calendar.admin.calendar_description_edit", "description"), ]: href = url_for(endpoint, calendar_slug=cal_slug) - nav_parts.append(sexp( - '(~nav-link :href h :label l :select-colours sc)', - h=href, l=label, sc=select_colours, - )) + nav_parts.append(render("nav-link", href=href, label=label, + select_colours=select_colours)) nav_html = "".join(nav_parts) - return sexp( - '(~menu-row :id "calendar-admin-row" :level 4' - ' :link-label "admin" :icon "fa fa-cog"' - ' :nav-html nh :child-id "calendar-admin-header-child" :oob oob)', - nh=nav_html, - oob=oob, - ) + return render("menu-row", id="calendar-admin-row", level=4, + link_label="admin", icon="fa fa-cog", + nav_html=nav_html, child_id="calendar-admin-header-child", oob=oob) # --------------------------------------------------------------------------- @@ -369,14 +294,10 @@ def _markets_header_html(ctx: dict, *, oob: bool = False) -> str: """Build the markets section header row.""" from quart import url_for link_href = url_for("markets.home") - return sexp( - '(~menu-row :id "markets-row" :level 3' - ' :link-href lh :link-label-html llh' - ' :child-id "markets-header-child" :oob oob)', - lh=link_href, - llh=sexp('(<> (i :class "fa fa-shopping-bag" :aria-hidden "true") (div "Markets"))'), - oob=oob, - ) + return render("menu-row", id="markets-row", level=3, + link_href=link_href, + link_label_html=render("events-markets-label"), + child_id="markets-header-child", oob=oob) # --------------------------------------------------------------------------- @@ -387,14 +308,10 @@ def _payments_header_html(ctx: dict, *, oob: bool = False) -> str: """Build the payments section header row.""" from quart import url_for link_href = url_for("payments.home") - return sexp( - '(~menu-row :id "payments-row" :level 3' - ' :link-href lh :link-label-html llh' - ' :child-id "payments-header-child" :oob oob)', - lh=link_href, - llh=sexp('(<> (i :class "fa fa-credit-card" :aria-hidden "true") (div "Payments"))'), - oob=oob, - ) + return render("menu-row", id="payments-row", level=3, + link_href=link_href, + link_label_html=render("events-payments-label"), + child_id="payments-header-child", oob=oob) # --------------------------------------------------------------------------- @@ -416,29 +333,12 @@ def _calendars_main_panel_html(ctx: dict) -> str: form_html = "" if can_create: create_url = url_for("calendars.create_calendar") - form_html = sexp( - '(<>' - ' (div :id "cal-create-errors" :class "mt-2 text-sm text-red-600")' - ' (form :class "mt-4 flex gap-2 items-end" :hx-post cu' - ' :hx-target "#calendars-list" :hx-select "#calendars-list" :hx-swap "outerHTML"' - """ :hx-on::before-request "document.querySelector('#cal-create-errors').textContent='';" """ - """ :hx-on::response-error "document.querySelector('#cal-create-errors').innerHTML = event.detail.xhr.responseText;" """ - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (div :class "flex-1"' - ' (label :class "block text-sm text-gray-600" "Name")' - ' (input :name "name" :type "text" :required true :class "w-full border rounded px-3 py-2"' - ' :placeholder "e.g. Events, Gigs, Meetings"))' - ' (button :type "submit" :class "border rounded px-3 py-2" "Add calendar")))', - cu=create_url, csrf=csrf, - ) + form_html = render("events-calendars-create-form", + create_url=create_url, csrf=csrf) list_html = _calendars_list_html(ctx, calendars) - return sexp( - '(section :class "p-4"' - ' (raw! fh)' - ' (div :id "calendars-list" :class "mt-6" (raw! lh)))', - fh=form_html, lh=list_html, - ) + return render("events-calendars-panel", + form_html=form_html, list_html=list_html) def _calendars_list_html(ctx: dict, calendars: list) -> str: @@ -450,7 +350,7 @@ def _calendars_list_html(ctx: dict, calendars: list) -> str: prefix = route_prefix() if not calendars: - return sexp('(p :class "text-gray-500 mt-4" "No calendars yet. Create one above.")') + return render("events-calendars-empty") parts = [] for cal in calendars: @@ -459,24 +359,9 @@ def _calendars_list_html(ctx: dict, calendars: list) -> str: href = prefix + url_for("calendars.calendar.get", calendar_slug=cal_slug) del_url = url_for("calendars.calendar.delete", calendar_slug=cal_slug) csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}' - parts.append(sexp( - '(div :class "mt-6 border rounded-lg p-4"' - ' (div :class "flex items-center justify-between gap-3"' - ' (a :class "flex items-baseline gap-3" :href h' - ' :hx-get h :hx-target "#main-panel" :hx-select "#main-panel" :hx-swap "outerHTML" :hx-push-url "true"' - ' (h3 :class "font-semibold" cn)' - ' (h4 :class "text-gray-500" (str "/" cs "/")))' - ' (button :class "text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400"' - ' :data-confirm true :data-confirm-title "Delete calendar?"' - ' :data-confirm-text "Entries will be hidden (soft delete)"' - ' :data-confirm-icon "warning" :data-confirm-confirm-text "Yes, delete it"' - ' :data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"' - ' :hx-delete du :hx-trigger "confirmed"' - ' :hx-target "#calendars-list" :hx-select "#calendars-list" :hx-swap "outerHTML"' - ' :hx-headers ch' - ' (i :class "fa-solid fa-trash"))))', - h=href, cn=cal_name, cs=cal_slug, du=del_url, ch=csrf_hdr, - )) + parts.append(render("events-calendars-item", + href=href, cal_name=cal_name, cal_slug=cal_slug, + del_url=del_url, csrf_hdr=csrf_hdr)) return "".join(parts) @@ -522,27 +407,22 @@ def _calendar_main_panel_html(ctx: dict) -> str: ("\u2039", prev_month_year, prev_month), ]: href = nav_link(yr, mn) - nav_arrows.append(sexp( - '(a :class (str pc " text-xl") :href h' - ' :hx-get h :hx-target "#main-panel" :hx-select "#main-panel" :hx-swap "outerHTML" :hx-push-url "true" l)', - pc=pill_cls, h=href, l=label, - )) + nav_arrows.append(render("events-calendar-nav-arrow", + pill_cls=pill_cls, href=href, label=label)) - nav_arrows.append(sexp('(div :class "px-3 font-medium" (str mn " " yr))', mn=month_name, yr=str(year))) + nav_arrows.append(render("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(sexp( - '(a :class (str pc " text-xl") :href h' - ' :hx-get h :hx-target "#main-panel" :hx-select "#main-panel" :hx-swap "outerHTML" :hx-push-url "true" l)', - pc=pill_cls, h=href, l=label, - )) + nav_arrows.append(render("events-calendar-nav-arrow", + pill_cls=pill_cls, href=href, label=label)) # Weekday headers - wd_html = "".join(sexp('(div :class "py-1" w)', w=wd) for wd in weekday_names) + wd_html = "".join(render("events-calendar-weekday", name=wd) for wd in weekday_names) # Day cells cells = [] @@ -572,15 +452,11 @@ def _calendar_main_panel_html(ctx: dict) -> str: calendar_slug=cal_slug, year=day_date.year, month=day_date.month, day=day_date.day, ) - day_short_html = sexp( - '(span :class "sm:hidden text-[16px] text-stone-500" d)', - d=day_date.strftime("%a"), - ) - day_num_html = sexp( - '(a :class pc :href h :hx-get h :hx-target "#main-panel" :hx-select "#main-panel"' - ' :hx-swap "outerHTML" :hx-push-url "true" n)', - pc=pill_cls, h=day_href, n=str(day_date.day), - ) + day_short_html = render("events-calendar-day-short", + day_str=day_date.strftime("%a")) + day_num_html = render("events-calendar-day-num", + pill_cls=pill_cls, href=day_href, + num=str(day_date.day)) # Entry badges for this day entry_badges = [] @@ -596,33 +472,20 @@ def _calendar_main_panel_html(ctx: dict) -> str: else: bg_cls = "bg-sky-100 text-sky-800" if is_mine else "bg-stone-100 text-stone-700" state_label = (e.state or "pending").replace("_", " ") - entry_badges.append(sexp( - '(div :class (str "flex items-center justify-between gap-1 text-[11px] rounded px-1 py-0.5 " bc)' - ' (span :class "truncate" n)' - ' (span :class "shrink-0 text-[10px] font-semibold uppercase tracking-tight" sl))', - bc=bg_cls, n=e.name, sl=state_label, - )) + entry_badges.append(render("events-calendar-entry-badge", + bg_cls=bg_cls, name=e.name, + state_label=state_label)) badges_html = "".join(entry_badges) - cells.append(sexp( - '(div :class cc' - ' (div :class "flex justify-between items-center"' - ' (div :class "flex flex-col" (raw! dsh) (raw! dnh)))' - ' (div :class "mt-1 space-y-0.5" (raw! bh)))', - cc=cell_cls, dsh=day_short_html, dnh=day_num_html, bh=badges_html, - )) + cells.append(render("events-calendar-cell", + cell_cls=cell_cls, day_short_html=day_short_html, + day_num_html=day_num_html, badges_html=badges_html)) cells_html = "".join(cells) arrows_html = "".join(nav_arrows) - return sexp( - '(section :class "bg-orange-100"' - ' (header :class "flex items-center justify-center mt-2"' - ' (nav :class "flex items-center gap-2 text-2xl" (raw! ah)))' - ' (div :class "rounded-2xl border border-stone-200 bg-white/80 p-4"' - ' (div :class "hidden sm:grid grid-cols-7 text-center text-md font-semibold text-stone-700 mb-2" (raw! wh))' - ' (div :class "grid grid-cols-1 sm:grid-cols-7 gap-px bg-stone-200 rounded-xl overflow-hidden" (raw! ch))))', - ah=arrows_html, wh=wd_html, ch=cells_html, - ) + return render("events-calendar-grid", + arrows_html=arrows_html, weekdays_html=wd_html, + cells_html=cells_html) # --------------------------------------------------------------------------- @@ -652,7 +515,7 @@ def _day_main_panel_html(ctx: dict) -> str: if day_entries: rows_html = "".join(_day_row_html(ctx, entry) for entry in day_entries) else: - rows_html = sexp('(tr (td :colspan "6" :class "p-3 text-stone-500" "No entries yet."))') + rows_html = render("events-day-empty-row") add_url = url_for( "calendars.calendar.day.calendar_entries.add_form", @@ -660,24 +523,9 @@ def _day_main_panel_html(ctx: dict) -> str: day=day, month=month, year=year, ) - return sexp( - '(section :id "day-entries" :class lc' - ' (table :class "w-full text-sm border table-fixed"' - ' (thead :class "bg-stone-100"' - ' (tr' - ' (th :class "p-2 text-left w-2/6" "Name")' - ' (th :class "text-left p-2 w-1/6" "Slot/Time")' - ' (th :class "text-left p-2 w-1/6" "State")' - ' (th :class "text-left p-2 w-1/6" "Cost")' - ' (th :class "text-left p-2 w-1/6" "Tickets")' - ' (th :class "text-left p-2 w-1/6" "Actions")))' - ' (tbody (raw! rh)))' - ' (div :id "entry-add-container" :class "mt-4"' - ' (button :type "button" :class pa' - ' :hx-get au :hx-target "#entry-add-container" :hx-swap "innerHTML"' - ' "+ Add entry")))', - lc=list_container, rh=rows_html, pa=pre_action, au=add_url, - ) + return render("events-day-table", + list_container=list_container, rows_html=rows_html, + pre_action=pre_action, add_url=add_url) def _day_row_html(ctx: dict, entry) -> str: @@ -699,12 +547,8 @@ def _day_row_html(ctx: dict, entry) -> str: ) # Name - name_html = sexp( - '(td :class "p-2 align-top w-2/6" (div :class "font-medium"' - ' (a :href h :class pc :hx-get h :hx-target "#main-panel" :hx-select "#main-panel"' - ' :hx-swap "outerHTML" :hx-push-url "true" n)))', - h=entry_href, pc=pill_cls, n=entry.name, - ) + name_html = render("events-day-row-name", + href=entry_href, pill_cls=pill_cls, name=entry.name) # Slot/Time slot = getattr(entry, "slot", None) @@ -712,55 +556,41 @@ def _day_row_html(ctx: dict, entry) -> str: slot_href = url_for("calendars.calendar.slots.slot.get", 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 = sexp( - '(td :class "p-2 align-top w-1/6" (div :class "text-xs font-medium"' - ' (a :href h :class pc :hx-get h :hx-target "#main-panel" :hx-select "#main-panel"' - ' :hx-swap "outerHTML" :hx-push-url "true" sn)' - ' (span :class "text-stone-600 font-normal" (raw! time-str))))', - h=slot_href, pc=pill_cls, sn=slot.name, - **{"time-str": f"({time_start}{time_end})"}, - ) + slot_html = render("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 = sexp( - '(td :class "p-2 align-top w-1/6" (div :class "text-xs text-stone-600" (str s e)))', - s=start, e=end, - ) + slot_html = render("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 = sexp( - '(td :class "p-2 align-top w-1/6" (div :id sid (raw! sb)))', - sid=f"entry-state-{entry.id}", sb=state_badge, - ) + state_td = render("events-day-row-state", + state_id=f"entry-state-{entry.id}", badge_html=state_badge) # Cost cost = getattr(entry, "cost", None) cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00" - cost_td = sexp('(td :class "p-2 align-top w-1/6" (span :class "font-medium text-green-600" c))', c=cost_str) + cost_td = render("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 = sexp( - '(td :class "p-2 align-top w-1/6" (div :class "text-xs space-y-1"' - ' (div :class "font-medium text-green-600" tp)' - ' (div :class "text-stone-600" tc)))', - tp=f"\u00a3{tp:.2f}", tc=tc_str, - ) + tickets_td = render("events-day-row-tickets", + price_str=f"\u00a3{tp:.2f}", count_str=tc_str) else: - tickets_td = sexp('(td :class "p-2 align-top w-1/6" (span :class "text-xs text-stone-400" "No tickets"))') + tickets_td = render("events-day-row-no-tickets") - actions_td = sexp('(td :class "p-2 align-top w-1/6")') + actions_td = render("events-day-row-actions") - return sexp( - '(tr :class tc (raw! nh) (raw! sh) (raw! std) (raw! ctd) (raw! ttd) (raw! atd))', - tc=tr_cls, nh=name_html, sh=slot_html, std=state_td, ctd=cost_td, ttd=tickets_td, atd=actions_td, - ) + return render("events-day-row", + tr_cls=tr_cls, name_html=name_html, slot_html=slot_html, + state_html=state_td, cost_html=cost_td, + tickets_html=tickets_td, actions_html=actions_td) def _entry_state_badge_html(state: str) -> str: @@ -774,10 +604,7 @@ def _entry_state_badge_html(state: str) -> str: } cls = state_classes.get(state, "bg-stone-100 text-stone-700") label = state.replace("_", " ").capitalize() - return sexp( - '(span :class (str "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium " c) l)', - c=cls, l=label, - ) + return render("events-state-badge", cls=cls, label=label) # --------------------------------------------------------------------------- @@ -786,7 +613,7 @@ def _entry_state_badge_html(state: str) -> str: def _day_admin_main_panel_html(ctx: dict) -> str: """Render day admin panel (placeholder nav).""" - return sexp('(div :class "p-4 text-sm text-stone-500" "Admin options")') + return render("events-day-admin-panel") # --------------------------------------------------------------------------- @@ -808,41 +635,16 @@ def _calendar_admin_main_panel_html(ctx: dict) -> str: desc_edit_url = url_for("calendars.calendar.admin.calendar_description_edit", calendar_slug=cal_slug) description_html = _calendar_description_display_html(calendar, desc_edit_url) - return sexp( - '(section :class "max-w-3xl mx-auto p-4 space-y-10"' - ' (div' - ' (h2 :class "text-xl font-semibold" "Calendar configuration")' - ' (div :id "cal-put-errors" :class "mt-2 text-sm text-red-600")' - ' (div (label :class "block text-sm font-medium text-stone-700" "Description")' - ' (raw! dh))' - ' (form :id "calendar-form" :method "post" :hx-target "#main-panel" :hx-select "#main-panel"' - """ :hx-on::before-request "document.querySelector('#cal-put-errors').textContent='';" """ - """ :hx-on::response-error "document.querySelector('#cal-put-errors').innerHTML = event.detail.xhr.responseText;" """ - """ :hx-on::after-request "if (event.detail.successful) this.reset()" """ - ' :class "hidden space-y-4 mt-4" :autocomplete "off"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (div (label :class "block text-sm font-medium text-stone-700" "Description")' - ' (div d)' - ' (textarea :name "description" :autocomplete "off" :rows "4" :class "w-full p-2 border rounded" d))' - ' (div (button :class "px-3 py-2 rounded bg-stone-800 text-white" "Save"))))' - ' (hr :class "border-stone-200"))', - dh=description_html, csrf=csrf, d=desc, - ) + return render("events-calendar-admin-panel", + description_html=description_html, csrf=csrf, + description=desc) def _calendar_description_display_html(calendar, edit_url: str) -> str: """Render calendar description display with edit button.""" desc = getattr(calendar, "description", "") or "" - return sexp( - '(div :id "calendar-description"' - ' (if d' - ' (p :class "text-stone-700 whitespace-pre-line break-all" d)' - ' (p :class "text-stone-400 italic" "No description yet."))' - ' (button :type "button" :class "mt-2 text-xs underline"' - ' :hx-get eu :hx-target "#calendar-description" :hx-swap "outerHTML"' - ' (i :class "fas fa-edit")))', - d=desc, eu=edit_url, - ) + return render("events-calendar-description-display", + description=desc, edit_url=edit_url) # --------------------------------------------------------------------------- @@ -863,29 +665,12 @@ def _markets_main_panel_html(ctx: dict) -> str: form_html = "" if can_create: create_url = url_for("markets.create_market") - form_html = sexp( - '(<>' - ' (div :id "market-create-errors" :class "mt-2 text-sm text-red-600")' - ' (form :class "mt-4 flex gap-2 items-end" :hx-post cu' - ' :hx-target "#markets-list" :hx-select "#markets-list" :hx-swap "outerHTML"' - """ :hx-on::before-request "document.querySelector('#market-create-errors').textContent='';" """ - """ :hx-on::response-error "document.querySelector('#market-create-errors').innerHTML = event.detail.xhr.responseText;" """ - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (div :class "flex-1"' - ' (label :class "block text-sm text-gray-600" "Name")' - ' (input :name "name" :type "text" :required true :class "w-full border rounded px-3 py-2"' - ' :placeholder "e.g. Farm Shop, Bakery"))' - ' (button :type "submit" :class "border rounded px-3 py-2" "Add market")))', - cu=create_url, csrf=csrf, - ) + form_html = render("events-markets-create-form", + create_url=create_url, csrf=csrf) list_html = _markets_list_html(ctx, markets) - return sexp( - '(section :class "p-4"' - ' (raw! fh)' - ' (div :id "markets-list" :class "mt-6" (raw! lh)))', - fh=form_html, lh=list_html, - ) + return render("events-markets-panel", + form_html=form_html, list_html=list_html) def _markets_list_html(ctx: dict, markets: list) -> str: @@ -897,7 +682,7 @@ def _markets_list_html(ctx: dict, markets: list) -> str: slug = post.get("slug", "") if not markets: - return sexp('(p :class "text-gray-500 mt-4" "No markets yet. Create one above.")') + return render("events-markets-empty") parts = [] for m in markets: @@ -906,23 +691,10 @@ def _markets_list_html(ctx: dict, markets: list) -> str: market_href = call_url(ctx, "market_url", f"/{slug}/{m_slug}/") del_url = url_for("markets.delete_market", market_slug=m_slug) csrf_hdr = f'{{"X-CSRFToken":"{csrf}"}}' - parts.append(sexp( - '(div :class "mt-6 border rounded-lg p-4"' - ' (div :class "flex items-center justify-between gap-3"' - ' (a :class "flex items-baseline gap-3" :href h' - ' (h3 :class "font-semibold" mn)' - ' (h4 :class "text-gray-500" (str "/" ms "/")))' - ' (button :class "text-sm border rounded px-3 py-1 hover:bg-red-50 hover:border-red-400"' - ' :data-confirm true :data-confirm-title "Delete market?"' - ' :data-confirm-text "Products will be hidden (soft delete)"' - ' :data-confirm-icon "warning" :data-confirm-confirm-text "Yes, delete it"' - ' :data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"' - ' :hx-delete du :hx-trigger "confirmed"' - ' :hx-target "#markets-list" :hx-select "#markets-list" :hx-swap "outerHTML"' - ' :hx-headers ch' - ' (i :class "fa-solid fa-trash"))))', - h=market_href, mn=m_name, ms=m_slug, du=del_url, ch=csrf_hdr, - )) + parts.append(render("events-markets-item", + href=market_href, market_name=m_name, + market_slug=m_slug, del_url=del_url, + csrf_hdr=csrf_hdr)) return "".join(parts) @@ -943,28 +715,11 @@ def _payments_main_panel_html(ctx: dict) -> str: placeholder = "--------" if sumup_configured else "sup_sk_..." input_cls = "w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500" - return sexp( - '(section :class "p-4 max-w-lg mx-auto"' - ' (div :id "payments-panel" :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200"' - ' (h3 :class "text-lg font-semibold text-stone-800"' - ' (i :class "fa fa-credit-card text-purple-600 mr-1") " SumUp Payment")' - ' (p :class "text-xs text-stone-400" "Configure per-page SumUp credentials. Leave blank to use the global merchant account.")' - ' (form :hx-put uu :hx-target "#payments-panel" :hx-swap "outerHTML" :hx-select "#payments-panel" :class "space-y-3"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (div (label :class "block text-xs font-medium text-stone-600 mb-1" "Merchant Code")' - ' (input :type "text" :name "merchant_code" :value mc :placeholder "e.g. ME4J6100" :class ic))' - ' (div (label :class "block text-xs font-medium text-stone-600 mb-1" "API Key")' - ' (input :type "password" :name "api_key" :value "" :placeholder ph :class ic)' - ' (when sc (p :class "text-xs text-stone-400 mt-0.5" "Key is set. Leave blank to keep current key.")))' - ' (div (label :class "block text-xs font-medium text-stone-600 mb-1" "Checkout Reference Prefix")' - ' (input :type "text" :name "checkout_prefix" :value cp :placeholder "e.g. ROSE-" :class ic))' - ' (button :type "submit" :class "px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500"' - ' "Save SumUp Settings")' - ' (when sc (span :class "ml-2 text-xs text-green-600"' - ' (i :class "fa fa-check-circle") " Connected")))))', - uu=update_url, csrf=csrf, mc=merchant_code, ph=placeholder, - ic=input_cls, sc=sumup_configured, cp=checkout_prefix, - ) + return render("events-payments-panel", + update_url=update_url, csrf=csrf, + merchant_code=merchant_code, placeholder=placeholder, + input_cls=input_cls, sumup_configured=sumup_configured, + checkout_prefix=checkout_prefix) # --------------------------------------------------------------------------- @@ -981,10 +736,7 @@ def _ticket_state_badge_html(state: str) -> str: } cls = cls_map.get(state, "bg-stone-100 text-stone-700") label = (state or "").replace("_", " ").capitalize() - return sexp( - '(span :class (str "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium " c) l)', - c=cls, l=label, - ) + return render("events-state-badge", cls=cls, label=label) # --------------------------------------------------------------------------- @@ -1011,37 +763,18 @@ def _tickets_main_panel_html(ctx: dict, tickets: list) -> str: if entry.end_at: time_str += f" \u2013 {entry.end_at.strftime('%H:%M')}" - ticket_cards.append(sexp( - '(a :href h :class "block rounded-xl border border-stone-200 bg-white p-4 hover:shadow-md transition"' - ' (div :class "flex items-start justify-between gap-4"' - ' (div :class "flex-1 min-w-0"' - ' (div :class "font-semibold text-lg truncate" en)' - ' (when tn (div :class "text-sm text-stone-600 mt-0.5" tn))' - ' (when ts (div :class "text-sm text-stone-500 mt-1" ts))' - ' (when cn (div :class "text-xs text-stone-400 mt-0.5" cn)))' - ' (div :class "flex flex-col items-end gap-1 flex-shrink-0"' - ' (raw! sb)' - ' (span :class "text-xs text-stone-400 font-mono" (str cc "...")))))', - h=href, en=entry_name, - tn=tt.name if tt else None, - ts=time_str or None, - cn=cal.name if cal else None, - sb=_ticket_state_badge_html(state), - cc=ticket.code[:8], - )) + ticket_cards.append(render("events-ticket-card", + 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_html=_ticket_state_badge_html(state), + code_prefix=ticket.code[:8])) cards_html = "".join(ticket_cards) - return sexp( - '(section :id "tickets-list" :class lc' - ' (h1 :class "text-2xl font-bold mb-6" "My Tickets")' - ' (if has' - ' (div :class "space-y-4" (raw! ch))' - ' (div :class "text-center py-12 text-stone-500"' - ' (i :class "fa fa-ticket text-4xl mb-4 block" :aria-hidden "true")' - ' (p :class "text-lg" "No tickets yet")' - ' (p :class "text-sm mt-1" "Tickets will appear here after you purchase them."))))', - lc=_list_container(ctx), has=bool(tickets), ch=cards_html, - ) + return render("events-tickets-panel", + list_container=_list_container(ctx), + has_tickets=bool(tickets), cards_html=cards_html) # --------------------------------------------------------------------------- @@ -1084,42 +817,14 @@ def _ticket_detail_panel_html(ctx: dict, ticket) -> str: "}})()" ) - return sexp( - '(section :id "ticket-detail" :class (str lc " max-w-lg mx-auto")' - ' (a :href bh :class "inline-flex items-center gap-1 text-sm text-stone-500 hover:text-stone-700 mb-4"' - ' (i :class "fa fa-arrow-left" :aria-hidden "true") " Back to my tickets")' - ' (div :class "rounded-2xl border border-stone-200 bg-white overflow-hidden"' - ' (div :class (str "px-6 py-4 border-b border-stone-100 " hbg)' - ' (div :class "flex items-center justify-between"' - ' (h1 :class "text-xl font-bold" en)' - ' (raw! bdg))' - ' (when tn (div :class "text-sm text-stone-600 mt-1" tn)))' - ' (div :class "px-6 py-8 flex flex-col items-center border-b border-stone-100"' - ' (div :id (str "ticket-qr-" cd) :class "bg-white p-4 rounded-lg border border-stone-200")' - ' (p :class "text-xs text-stone-400 mt-3 font-mono select-all" cd))' - ' (div :class "px-6 py-4 space-y-3"' - ' (when td (div :class "flex items-start gap-3"' - ' (i :class "fa fa-calendar text-stone-400 mt-0.5" :aria-hidden "true")' - ' (div (div :class "text-sm font-medium" td)' - ' (div :class "text-sm text-stone-500" tr))))' - ' (when cn (div :class "flex items-start gap-3"' - ' (i :class "fa fa-map-pin text-stone-400 mt-0.5" :aria-hidden "true")' - ' (div :class "text-sm" cn)))' - ' (when ttd (div :class "flex items-start gap-3"' - ' (i :class "fa fa-tag text-stone-400 mt-0.5" :aria-hidden "true")' - ' (div :class "text-sm" ttd)))' - ' (when cs (div :class "flex items-start gap-3"' - ' (i :class "fa fa-check-circle text-blue-500 mt-0.5" :aria-hidden "true")' - ' (div :class "text-sm text-blue-700" cs)))))' - ' (script :src "https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js")' - ' (script qs))', - lc=_list_container(ctx), bh=back_href, hbg=header_bg, - en=entry_name, bdg=badge, - tn=tt.name if tt else None, - cd=code, td=time_date, tr=time_range, - cn=cal.name if cal else None, - ttd=tt_desc, cs=checkin_str, qs=qr_script, - ) + return render("events-ticket-detail", + list_container=_list_container(ctx), back_href=back_href, + header_bg=header_bg, entry_name=entry_name, + badge_html=badge, type_name=tt.name if tt else None, + code=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) # --------------------------------------------------------------------------- @@ -1143,12 +848,9 @@ def _ticket_admin_main_panel_html(ctx: dict, tickets: list, stats: dict) -> str: ]: val = stats.get(key, 0) lbl_cls = text_cls.replace("700", "600").replace("900", "500") if "stone" not in text_cls else "text-stone-500" - stats_html += sexp( - '(div :class (str "rounded-xl border " b " " bg " p-4 text-center")' - ' (div :class (str "text-2xl font-bold " tc) v)' - ' (div :class (str "text-xs " lc " uppercase tracking-wide") l))', - b=border, bg=bg, tc=text_cls, v=str(val), lc=lbl_cls, l=label, - ) + stats_html += render("events-ticket-admin-stat", + border=border, bg=bg, text_cls=text_cls, + label_cls=lbl_cls, value=str(val), label=label) # Ticket rows rows_html = "" @@ -1160,78 +862,32 @@ def _ticket_admin_main_panel_html(ctx: dict, tickets: list, stats: dict) -> str: date_html = "" if entry and entry.start_at: - date_html = sexp( - '(div :class "text-xs text-stone-500" d)', - d=entry.start_at.strftime("%d %b %Y, %H:%M"), - ) + date_html = render("events-ticket-admin-date", + date_str=entry.start_at.strftime("%d %b %Y, %H:%M")) action_html = "" if state in ("confirmed", "reserved"): checkin_url = url_for("ticket_admin.do_checkin", code=code) - action_html = sexp( - '(form :hx-post cu :hx-target (str "#ticket-row-" c) :hx-swap "outerHTML"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (button :type "submit" :class "px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition"' - ' (i :class "fa fa-check mr-1" :aria-hidden "true") "Check in"))', - cu=checkin_url, c=code, csrf=csrf, - ) + action_html = render("events-ticket-admin-checkin-form", + checkin_url=checkin_url, code=code, csrf=csrf) elif state == "checked_in": checked_in_at = getattr(ticket, "checked_in_at", None) t_str = checked_in_at.strftime("%H:%M") if checked_in_at else "" - action_html = sexp( - '(span :class "text-xs text-blue-600"' - ' (i :class "fa fa-check-circle" :aria-hidden "true") (str " " ts))', - ts=t_str, - ) + action_html = render("events-ticket-admin-checked-in", + time_str=t_str) - rows_html += sexp( - '(tr :class "hover:bg-stone-50 transition" :id (str "ticket-row-" c)' - ' (td :class "px-4 py-3" (span :class "font-mono text-xs" cs))' - ' (td :class "px-4 py-3" (div :class "font-medium" en) (raw! dh))' - ' (td :class "px-4 py-3 text-sm" tn)' - ' (td :class "px-4 py-3" (raw! sb))' - ' (td :class "px-4 py-3" (raw! ah)))', - c=code, cs=code[:12] + "...", - en=entry.name if entry else "—", - dh=date_html, tn=tt.name if tt else "—", - sb=_ticket_state_badge_html(state), ah=action_html, - ) + rows_html += render("events-ticket-admin-row", + code=code, code_short=code[:12] + "...", + entry_name=entry.name if entry else "\u2014", + date_html=date_html, + type_name=tt.name if tt else "\u2014", + badge_html=_ticket_state_badge_html(state), + action_html=action_html) - return sexp( - '(section :id "ticket-admin" :class lc' - ' (h1 :class "text-2xl font-bold mb-6" "Ticket Admin")' - ' (div :class "grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8" (raw! sh))' - ' (div :class "rounded-xl border border-stone-200 bg-white p-6 mb-8"' - ' (h2 :class "text-lg font-semibold mb-4"' - ' (i :class "fa fa-qrcode mr-2" :aria-hidden "true") "Scan / Look Up Ticket")' - ' (div :class "flex gap-3 mb-4"' - ' (input :type "text" :id "ticket-code-input" :name "code"' - ' :placeholder "Enter or scan ticket code..."' - ' :class "flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"' - ' :hx-get lu :hx-trigger "keyup changed delay:300ms"' - ' :hx-target "#lookup-result" :hx-include "this" :autofocus "true")' - ' (button :type "button"' - ' :class "px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"' - """ :onclick "document.getElementById('ticket-code-input').dispatchEvent(new Event('keyup'))" """ - ' (i :class "fa fa-search" :aria-hidden "true")))' - ' (div :id "lookup-result"' - ' (div :class "text-sm text-stone-400 text-center py-4" "Enter a ticket code to look it up")))' - ' (div :class "rounded-xl border border-stone-200 bg-white overflow-hidden"' - ' (h2 :class "text-lg font-semibold px-6 py-4 border-b border-stone-100" "Recent Tickets")' - ' (if has-tickets' - ' (div :class "overflow-x-auto"' - ' (table :class "w-full text-sm"' - ' (thead :class "bg-stone-50"' - ' (tr (th :class "px-4 py-3 text-left font-medium text-stone-600" "Code")' - ' (th :class "px-4 py-3 text-left font-medium text-stone-600" "Event")' - ' (th :class "px-4 py-3 text-left font-medium text-stone-600" "Type")' - ' (th :class "px-4 py-3 text-left font-medium text-stone-600" "State")' - ' (th :class "px-4 py-3 text-left font-medium text-stone-600" "Actions")))' - ' (tbody :class "divide-y divide-stone-100" (raw! rh))))' - ' (div :class "px-6 py-8 text-center text-stone-500" "No tickets yet"))))', - lc=_list_container(ctx), sh=stats_html, lu=lookup_url, - **{"has-tickets": bool(tickets)}, rh=rows_html, - ) + return render("events-ticket-admin-panel", + list_container=_list_container(ctx), stats_html=stats_html, + lookup_url=lookup_url, has_tickets=bool(tickets), + rows_html=rows_html) # --------------------------------------------------------------------------- @@ -1255,69 +911,51 @@ def _entry_card_html(entry, page_info: dict, pending_tickets: dict, entry_href = f"{day_href}entries/{entry.id}/" if day_href else "" # Title (linked or plain) - title_html = sexp( - '(if eh (a :href eh :class "hover:text-emerald-700"' - ' (h2 :class "text-lg font-semibold text-stone-900" n))' - ' (h2 :class "text-lg font-semibold text-stone-900" n))', - eh=entry_href or False, n=entry.name, - ) + if entry_href: + title_html = render("events-entry-title-linked", + href=entry_href, name=entry.name) + else: + title_html = render("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_href = events_url_fn(f"/{page_slug}/") - badges_html += sexp( - '(a :href ph :class "inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200" pt)', - ph=page_href, pt=page_title, - ) + badges_html += render("events-entry-page-badge", + href=page_href, title=page_title) cal_name = getattr(entry, "calendar_name", "") if cal_name: - badges_html += sexp( - '(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-700" cn)', - cn=cal_name, - ) + badges_html += render("events-entry-cal-badge", name=cal_name) # Time line time_parts = "" if day_href and not is_page_scoped: - time_parts += sexp( - '(<> (a :href dh :class "hover:text-stone-700" ds) (raw! " · "))', - dh=day_href, ds=entry.start_at.strftime("%a %-d %b"), - ) + time_parts += render("events-entry-time-linked", + href=day_href, + date_str=entry.start_at.strftime("%a %-d %b")) elif not is_page_scoped: - time_parts += sexp( - '(<> (span ds) (raw! " · "))', - ds=entry.start_at.strftime("%a %-d %b"), - ) + time_parts += render("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_html = sexp('(div :class "mt-1 text-sm font-medium text-green-600" (raw! c))', - c=f"£{cost:.2f}") if cost else "" + cost_html = render("events-entry-cost", + cost_html=f"£{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 = sexp( - '(div :class "shrink-0" (raw! w))', - w=_ticket_widget_html(entry, qty, ticket_url, ctx={}), - ) + widget_html = render("events-entry-widget-wrapper", + widget_html=_ticket_widget_html(entry, qty, ticket_url, ctx={})) - return sexp( - '(article :class "rounded-xl bg-white shadow-sm border border-stone-200 p-4"' - ' (div :class "flex flex-col sm:flex-row sm:items-start justify-between gap-3"' - ' (div :class "flex-1 min-w-0"' - ' (raw! th)' - ' (div :class "flex flex-wrap items-center gap-1.5 mt-1" (raw! bh))' - ' (div :class "mt-1 text-sm text-stone-500" (raw! tp))' - ' (raw! ch))' - ' (raw! wh)))', - th=title_html, bh=badges_html, tp=time_parts, ch=cost_html, wh=widget_html, - ) + return render("events-entry-card", + title_html=title_html, badges_html=badges_html, + time_parts=time_parts, cost_html=cost_html, + widget_html=widget_html) def _entry_card_tile_html(entry, page_info: dict, pending_tickets: dict, @@ -1337,32 +975,28 @@ def _entry_card_tile_html(entry, page_info: dict, pending_tickets: dict, entry_href = f"{day_href}entries/{entry.id}/" if day_href else "" # Title - title_html = sexp( - '(if eh (a :href eh :class "hover:text-emerald-700"' - ' (h2 :class "text-base font-semibold text-stone-900 line-clamp-2" n))' - ' (h2 :class "text-base font-semibold text-stone-900 line-clamp-2" n))', - eh=entry_href or False, n=entry.name, - ) + if entry_href: + title_html = render("events-entry-title-tile-linked", + href=entry_href, name=entry.name) + else: + title_html = render("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 += sexp( - '(a :href ph :class "inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200" pt)', - ph=page_href, pt=page_title, - ) + badges_html += render("events-entry-page-badge", + href=page_href, title=page_title) cal_name = getattr(entry, "calendar_name", "") if cal_name: - badges_html += sexp( - '(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-700" cn)', - cn=cal_name, - ) + badges_html += render("events-entry-cal-badge", name=cal_name) # Time time_html = "" if day_href: - time_html += sexp('(a :href dh :class "hover:text-stone-700" ds)', dh=day_href, ds=entry.start_at.strftime("%a %-d %b")) + time_html += render("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")}' @@ -1370,29 +1004,21 @@ def _entry_card_tile_html(entry, page_info: dict, pending_tickets: dict, time_html += f' \u2013 {entry.end_at.strftime("%H:%M")}' cost = getattr(entry, "cost", None) - cost_html = sexp('(div :class "mt-1 text-sm font-medium text-green-600" (raw! c))', - c=f"£{cost:.2f}") if cost else "" + cost_html = render("events-entry-cost", + cost_html=f"£{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 = sexp( - '(div :class "border-t border-stone-100 px-3 py-2" (raw! w))', - w=_ticket_widget_html(entry, qty, ticket_url, ctx={}), - ) + widget_html = render("events-entry-tile-widget-wrapper", + widget_html=_ticket_widget_html(entry, qty, ticket_url, ctx={})) - return sexp( - '(article :class "rounded-xl bg-white shadow-sm border border-stone-200 overflow-hidden"' - ' (div :class "p-3"' - ' (raw! th)' - ' (div :class "flex flex-wrap items-center gap-1 mt-1" (raw! bh))' - ' (div :class "mt-1 text-xs text-stone-500" (raw! tm))' - ' (raw! ch))' - ' (raw! wh))', - th=title_html, bh=badges_html, tm=time_html, ch=cost_html, wh=widget_html, - ) + return render("events-entry-card-tile", + title_html=title_html, badges_html=badges_html, + time_html=time_html, cost_html=cost_html, + widget_html=widget_html) def _ticket_widget_html(entry, qty: int, ticket_url: str, *, ctx: dict) -> str: @@ -1413,44 +1039,22 @@ def _ticket_widget_html(entry, qty: int, ticket_url: str, *, ctx: dict) -> str: tgt = f"#page-ticket-{eid}" def _tw_form(count_val, btn_html): - return sexp( - '(form :action tu :method "post" :hx-post tu :hx-target tgt :hx-swap "outerHTML"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (input :type "hidden" :name "entry_id" :value eid)' - ' (input :type "hidden" :name "count" :value cv)' - ' (raw! bh))', - tu=ticket_url, tgt=tgt, csrf=csrf_token_val, - eid=str(eid), cv=str(count_val), bh=btn_html, - ) + return render("events-tw-form", + ticket_url=ticket_url, target=tgt, + csrf=csrf_token_val, entry_id=str(eid), + count_val=str(count_val), btn_html=btn_html) if qty == 0: - inner = _tw_form(1, sexp( - '(button :type "submit" :class "relative inline-flex items-center justify-center text-stone-500 hover:bg-emerald-50 rounded p-1"' - ' (i :class "fa fa-cart-plus text-2xl" :aria-hidden "true"))', - )) + inner = _tw_form(1, render("events-tw-cart-plus")) else: - minus = _tw_form(qty - 1, sexp( - '(button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "-")', - )) - cart_icon = sexp( - '(span :class "relative inline-flex items-center justify-center text-emerald-700"' - ' (span :class "relative inline-flex items-center justify-center"' - ' (i :class "fa-solid fa-shopping-cart text-xl" :aria-hidden "true")' - ' (span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"' - ' (span :class "flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold" q))))', - q=str(qty), - ) - plus = _tw_form(qty + 1, sexp( - '(button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "+")', - )) + minus = _tw_form(qty - 1, render("events-tw-minus")) + cart_icon = render("events-tw-cart-icon", qty=str(qty)) + plus = _tw_form(qty + 1, render("events-tw-plus")) inner = minus + cart_icon + plus - return sexp( - '(div :id (str "page-ticket-" eid) :class "flex items-center gap-2"' - ' (span :class "text-green-600 font-medium text-sm" (raw! pr))' - ' (raw! inner))', - eid=str(eid), pr=f"£{tp:.2f}", inner=inner, - ) + return render("events-tw-widget", + entry_id=str(eid), price_html=f"£{tp:.2f}", + inner_html=inner) def _entry_cards_html(entries, page_info, pending_tickets, ticket_url, @@ -1468,11 +1072,8 @@ def _entry_cards_html(entries, page_info, pending_tickets, ticket_url, else: entry_date = entry.start_at.strftime("%A %-d %B %Y") if entry.start_at else "" if entry_date != last_date: - parts.append(sexp( - '(div :class "pt-2 pb-1"' - ' (h3 :class "text-sm font-semibold text-stone-500 uppercase tracking-wide" d))', - d=entry_date, - )) + parts.append(render("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, @@ -1480,13 +1081,8 @@ def _entry_cards_html(entries, page_info, pending_tickets, ticket_url, )) if has_more: - parts.append(sexp( - '(div :id (str "sentinel-" p) :class "h-4 opacity-0 pointer-events-none"' - ' :hx-get nu :hx-trigger "intersect once delay:250ms" :hx-swap "outerHTML"' - ' :role "status" :aria-hidden "true"' - ' (div :class "text-center text-xs text-stone-400" "loading..."))', - p=str(page), nu=next_url, - )) + parts.append(render("events-sentinel", + page=str(page), next_url=next_url)) return "".join(parts) @@ -1494,17 +1090,22 @@ def _entry_cards_html(entries, page_info, pending_tickets, ticket_url, # All events / page summary main panels # --------------------------------------------------------------------------- -_LIST_SVG = sexp( - '(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none"' - ' :viewBox "0 0 24 24" :stroke "currentColor" :stroke-width "2"' - ' (path :stroke-linecap "round" :stroke-linejoin "round" :d "M4 6h16M4 12h16M4 18h16"))', -) -_TILE_SVG = sexp( - '(svg :xmlns "http://www.w3.org/2000/svg" :class "h-5 w-5" :fill "none"' - ' :viewBox "0 0 24 24" :stroke "currentColor" :stroke-width "2"' - ' (path :stroke-linecap "round" :stroke-linejoin "round"' - ' :d "M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"))', -) +_LIST_SVG = None +_TILE_SVG = None + + +def _get_list_svg(): + global _LIST_SVG + if _LIST_SVG is None: + _LIST_SVG = render("events-list-svg") + return _LIST_SVG + + +def _get_tile_svg(): + global _TILE_SVG + if _TILE_SVG is None: + _TILE_SVG = render("events-tile-svg") + return _TILE_SVG def _view_toggle_html(ctx: dict, view: str) -> str: @@ -1526,22 +1127,11 @@ def _view_toggle_html(ctx: dict, view: str) -> str: list_active = 'bg-stone-200 text-stone-800' if view != 'tile' else 'text-stone-400 hover:text-stone-600' tile_active = 'bg-stone-200 text-stone-800' if view == 'tile' else 'text-stone-400 hover:text-stone-600' - return sexp( - '(div :class "hidden md:flex justify-end px-3 pt-3 gap-1"' - ' (a :href lh :hx-get lh :hx-target "#main-panel" :hx-select hs' - ' :hx-swap "outerHTML" :hx-push-url "true"' - ' :class (str "p-1.5 rounded " la) :title "List view"' - """ :_ "on click js localStorage.removeItem('events_view') end" """ - ' (raw! ls))' - ' (a :href th :hx-get th :hx-target "#main-panel" :hx-select hs' - ' :hx-swap "outerHTML" :hx-push-url "true"' - ' :class (str "p-1.5 rounded " ta) :title "Tile view"' - """ :_ "on click js localStorage.setItem('events_view','tile') end" """ - ' (raw! ts)))', - lh=list_href, th=tile_href, hs=hx_select, - la=list_active, ta=tile_active, - ls=_LIST_SVG, ts=_TILE_SVG, - ) + return render("events-view-toggle", + list_href=list_href, tile_href=tile_href, + hx_select=hx_select, list_active=list_active, + tile_active=tile_active, list_svg=_get_list_svg(), + tile_svg=_get_tile_svg()) def _events_main_panel_html(ctx: dict, entries, has_more, pending_tickets, page_info, @@ -1558,18 +1148,12 @@ def _events_main_panel_html(ctx: dict, entries, has_more, pending_tickets, page_ ) 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 = sexp('(div :class gc (raw! c))', gc=grid_cls, c=cards) + body = render("events-grid", grid_cls=grid_cls, cards_html=cards) else: - body = sexp( - '(div :class "px-3 py-12 text-center text-stone-400"' - ' (i :class "fa fa-calendar-xmark text-4xl mb-3" :aria-hidden "true")' - ' (p :class "text-lg" "No upcoming events"))', - ) + body = render("events-empty") - return sexp( - '(<> (raw! tg) (raw! bd) (div :class "pb-8"))', - tg=toggle, bd=body, - ) + return render("events-main-panel-body", + toggle_html=toggle, body_html=body) # --------------------------------------------------------------------------- @@ -1668,10 +1252,8 @@ async def render_page_summary_page(ctx: dict, entries, has_more, pending_tickets ) hdr = root_header_html(ctx) - hdr += sexp( - '(div :id "root-header-child" :class "w-full" (raw! ph))', - ph=_post_header_html(ctx), - ) + hdr += render("events-header-child", + inner_html=_post_header_html(ctx)) return full_page(ctx, header_rows_html=hdr, content_html=content) @@ -1724,7 +1306,7 @@ async def render_calendars_page(ctx: dict) -> str: content = _calendars_main_panel_html(ctx) hdr = root_header_html(ctx) child = _post_header_html(ctx) + _calendars_header_html(ctx) - hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) + hdr += render("events-header-child", inner_html=child) return full_page(ctx, header_rows_html=hdr, content_html=content) @@ -1746,7 +1328,7 @@ async def render_calendar_page(ctx: dict) -> str: content = _calendar_main_panel_html(ctx) hdr = root_header_html(ctx) child = _post_header_html(ctx) + _calendar_header_html(ctx) - hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) + hdr += render("events-header-child", inner_html=child) return full_page(ctx, header_rows_html=hdr, content_html=content) @@ -1769,7 +1351,7 @@ async def render_day_page(ctx: dict) -> str: hdr = root_header_html(ctx) child = (_post_header_html(ctx) + _calendar_header_html(ctx) + _day_header_html(ctx)) - hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) + hdr += render("events-header-child", inner_html=child) return full_page(ctx, header_rows_html=hdr, content_html=content) @@ -1793,7 +1375,7 @@ async def render_day_admin_page(ctx: dict) -> str: child = (_post_header_html(ctx) + _calendar_header_html(ctx) + _day_header_html(ctx) + _day_admin_header_html(ctx)) - hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) + hdr += render("events-header-child", inner_html=child) return full_page(ctx, header_rows_html=hdr, content_html=content) @@ -1816,7 +1398,7 @@ async def render_calendar_admin_page(ctx: dict) -> str: hdr = root_header_html(ctx) child = (_post_header_html(ctx) + _calendar_header_html(ctx) + _calendar_admin_header_html(ctx)) - hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) + hdr += render("events-header-child", inner_html=child) return full_page(ctx, header_rows_html=hdr, content_html=content) @@ -1842,7 +1424,7 @@ async def render_slots_page(ctx: dict) -> str: hdr = root_header_html(ctx) child = (_post_header_html(ctx) + _calendar_header_html(ctx) + _calendar_admin_header_html(ctx)) - hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) + hdr += render("events-header-child", inner_html=child) return full_page(ctx, header_rows_html=hdr, content_html=content) @@ -1911,7 +1493,7 @@ async def render_markets_page(ctx: dict) -> str: content = _markets_main_panel_html(ctx) hdr = root_header_html(ctx) child = _post_header_html(ctx) + _markets_header_html(ctx) - hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) + hdr += render("events-header-child", inner_html=child) return full_page(ctx, header_rows_html=hdr, content_html=content) @@ -1933,7 +1515,7 @@ async def render_payments_page(ctx: dict) -> str: content = _payments_main_panel_html(ctx) hdr = root_header_html(ctx) child = _post_header_html(ctx) + _payments_header_html(ctx) - hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) + hdr += render("events-header-child", inner_html=child) return full_page(ctx, header_rows_html=hdr, content_html=content) @@ -1967,11 +1549,8 @@ def render_ticket_widget(entry, qty: int, ticket_url: str) -> str: def render_checkin_result(success: bool, error: str | None, ticket) -> str: """Render checkin result: table row on success, error div on failure.""" if not success: - return sexp( - '(div :class "rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800"' - ' (i :class "fa fa-exclamation-circle mr-2" :aria-hidden "true") em)', - em=error or "Check-in failed", - ) + return render("events-checkin-error", + message=error or "Check-in failed") if not ticket: return "" code = ticket.code @@ -1982,23 +1561,16 @@ def render_checkin_result(success: bool, error: str | None, ticket) -> str: date_html = "" if entry and entry.start_at: - date_html = sexp('(div :class "text-xs text-stone-500" d)', - d=entry.start_at.strftime("%d %b %Y, %H:%M")) + date_html = render("events-ticket-admin-date", + date_str=entry.start_at.strftime("%d %b %Y, %H:%M")) - return sexp( - '(tr :class "bg-blue-50" :id (str "ticket-row-" c)' - ' (td :class "px-4 py-3" (span :class "font-mono text-xs" cs))' - ' (td :class "px-4 py-3" (div :class "font-medium" en) (raw! dh))' - ' (td :class "px-4 py-3 text-sm" tn)' - ' (td :class "px-4 py-3" (raw! sb))' - ' (td :class "px-4 py-3"' - ' (span :class "text-xs text-blue-600"' - ' (i :class "fa fa-check-circle" :aria-hidden "true") (str " " ts))))', - c=code, cs=code[:12] + "...", - en=entry.name if entry else "\u2014", - dh=date_html, tn=tt.name if tt else "\u2014", - sb=_ticket_state_badge_html("checked_in"), ts=time_str, - ) + return render("events-checkin-success-row", + code=code, code_short=code[:12] + "...", + entry_name=entry.name if entry else "\u2014", + date_html=date_html, + type_name=tt.name if tt else "\u2014", + badge_html=_ticket_state_badge_html("checked_in"), + time_str=time_str) # --------------------------------------------------------------------------- @@ -2011,11 +1583,7 @@ def render_lookup_result(ticket, error: str | None) -> str: from shared.browser.app.csrf import generate_csrf_token if error: - return sexp( - '(div :class "rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800"' - ' (i :class "fa fa-exclamation-circle mr-2" :aria-hidden "true") em)', - em=error, - ) + return render("events-lookup-error", message=error) if not ticket: return "" @@ -2027,56 +1595,35 @@ def render_lookup_result(ticket, error: str | None) -> str: csrf = generate_csrf_token() # Info section - info_html = sexp('(div :class "font-semibold text-lg" en)', - en=entry.name if entry else "Unknown event") + info_html = render("events-lookup-info", + entry_name=entry.name if entry else "Unknown event") if tt: - info_html += sexp('(div :class "text-sm text-stone-600" tn)', tn=tt.name) + info_html += render("events-lookup-type", type_name=tt.name) if entry and entry.start_at: - info_html += sexp('(div :class "text-sm text-stone-500 mt-1" d)', - d=entry.start_at.strftime("%A, %B %d, %Y at %H:%M")) + info_html += render("events-lookup-date", + date_str=entry.start_at.strftime("%A, %B %d, %Y at %H:%M")) cal = getattr(entry, "calendar", None) if entry else None if cal: - info_html += sexp('(div :class "text-xs text-stone-400 mt-0.5" cn)', cn=cal.name) - info_html += sexp( - '(div :class "mt-2" (raw! sb) (span :class "text-xs text-stone-400 ml-2 font-mono" c))', - sb=_ticket_state_badge_html(state), c=code, - ) + info_html += render("events-lookup-cal", cal_name=cal.name) + info_html += render("events-lookup-status", + badge_html=_ticket_state_badge_html(state), code=code) if checked_in_at: - info_html += sexp('(div :class "text-xs text-blue-600 mt-1" (str "Checked in: " d))', - d=checked_in_at.strftime("%B %d, %Y at %H:%M")) + info_html += render("events-lookup-checkin-time", + date_str=checked_in_at.strftime("%B %d, %Y at %H:%M")) # Action area action_html = "" if state in ("confirmed", "reserved"): checkin_url = url_for("ticket_admin.do_checkin", code=code) - action_html = sexp( - '(form :hx-post cu :hx-target (str "#checkin-action-" c) :hx-swap "innerHTML"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (button :type "submit"' - ' :class "px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition font-semibold text-lg"' - ' (i :class "fa fa-check mr-2" :aria-hidden "true") "Check In"))', - cu=checkin_url, c=code, csrf=csrf, - ) + action_html = render("events-lookup-checkin-btn", + checkin_url=checkin_url, code=code, csrf=csrf) elif state == "checked_in": - action_html = sexp( - '(div :class "text-blue-600 text-center"' - ' (i :class "fa fa-check-circle text-3xl" :aria-hidden "true")' - ' (div :class "text-sm font-medium mt-1" "Checked In"))', - ) + action_html = render("events-lookup-checked-in") elif state == "cancelled": - action_html = sexp( - '(div :class "text-red-600 text-center"' - ' (i :class "fa fa-times-circle text-3xl" :aria-hidden "true")' - ' (div :class "text-sm font-medium mt-1" "Cancelled"))', - ) + action_html = render("events-lookup-cancelled") - return sexp( - '(div :class "rounded-lg border border-stone-200 bg-stone-50 p-4"' - ' (div :class "flex items-start justify-between gap-4"' - ' (div :class "flex-1" (raw! ih))' - ' (div :id (str "checkin-action-" c) (raw! ah))))', - ih=info_html, c=code, ah=action_html, - ) + return render("events-lookup-card", + info_html=info_html, code=code, action_html=action_html) # --------------------------------------------------------------------------- @@ -2102,59 +1649,33 @@ def render_entry_tickets_admin(entry, tickets: list) -> str: action_html = "" if state in ("confirmed", "reserved"): checkin_url = url_for("ticket_admin.do_checkin", code=code) - action_html = sexp( - '(form :hx-post cu :hx-target (str "#entry-ticket-row-" c) :hx-swap "outerHTML"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (button :type "submit" :class "px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700"' - ' "Check in"))', - cu=checkin_url, c=code, csrf=csrf, - ) + action_html = render("events-entry-tickets-admin-checkin", + checkin_url=checkin_url, code=code, csrf=csrf) elif state == "checked_in": t_str = checked_in_at.strftime("%H:%M") if checked_in_at else "" - action_html = sexp( - '(span :class "text-xs text-blue-600"' - ' (i :class "fa fa-check-circle" :aria-hidden "true") (str " " ts))', - ts=t_str, - ) + action_html = render("events-ticket-admin-checked-in", + time_str=t_str) - rows_html += sexp( - '(tr :class "hover:bg-stone-50" :id (str "entry-ticket-row-" c)' - ' (td :class "px-4 py-2 font-mono text-xs" cs)' - ' (td :class "px-4 py-2" tn)' - ' (td :class "px-4 py-2" (raw! sb))' - ' (td :class "px-4 py-2" (raw! ah)))', - c=code, cs=code[:12] + "...", - tn=tt.name if tt else "\u2014", - sb=_ticket_state_badge_html(state), ah=action_html, - ) + rows_html += render("events-entry-tickets-admin-row", + code=code, code_short=code[:12] + "...", + type_name=tt.name if tt else "\u2014", + badge_html=_ticket_state_badge_html(state), + action_html=action_html) if tickets: - body_html = sexp( - '(div :class "overflow-x-auto rounded-xl border border-stone-200"' - ' (table :class "w-full text-sm"' - ' (thead :class "bg-stone-50"' - ' (tr (th :class "px-4 py-2 text-left font-medium text-stone-600" "Code")' - ' (th :class "px-4 py-2 text-left font-medium text-stone-600" "Type")' - ' (th :class "px-4 py-2 text-left font-medium text-stone-600" "State")' - ' (th :class "px-4 py-2 text-left font-medium text-stone-600" "Actions")))' - ' (tbody :class "divide-y divide-stone-100" (raw! rh))))', - rh=rows_html, - ) + body_html = render("events-entry-tickets-admin-table", + rows_html=rows_html) else: - body_html = sexp('(div :class "text-center py-6 text-stone-500 text-sm" "No tickets for this entry")') + body_html = render("events-entry-tickets-admin-empty") - return sexp( - '(div :class "space-y-4"' - ' (div :class "flex items-center justify-between"' - ' (h3 :class "text-lg font-semibold" (str "Tickets for: " en))' - ' (span :class "text-sm text-stone-500" cl))' - ' (raw! bh))', - en=entry.name, cl=f"{count} ticket{suffix}", bh=body_html, - ) + return render("events-entry-tickets-admin-panel", + entry_name=entry.name, + count_label=f"{count} ticket{suffix}", + body_html=body_html) # --------------------------------------------------------------------------- -# Day main panel — public API +# Day main panel -- public API # --------------------------------------------------------------------------- def render_day_main_panel(ctx: dict) -> str: @@ -2188,54 +1709,42 @@ def _entry_main_panel_html(ctx: dict) -> str: state = getattr(entry, "state", "pending") or "pending" def _field(label, content_html): - return sexp( - '(div :class "flex flex-col mb-4"' - ' (div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" l)' - ' (raw! ch))', - l=label, ch=content_html, - ) + return render("events-entry-field", label=label, content_html=content_html) # Name - name_html = _field("Name", sexp('(div :class "mt-1 text-lg font-medium" n)', n=entry.name)) + name_html = _field("Name", render("events-entry-name-field", name=entry.name)) # Slot slot = getattr(entry, "slot", None) if slot: flex_label = "(flexible)" if getattr(slot, "flexible", False) else "(fixed)" - slot_inner = sexp( - '(div :class "mt-1"' - ' (span :class "px-2 py-1 rounded text-sm bg-blue-100 text-blue-700" sn)' - ' (span :class "ml-2 text-xs text-stone-500" fl))', - sn=slot.name, fl=flex_label, - ) + slot_inner = render("events-entry-slot-assigned", + slot_name=slot.name, flex_label=flex_label) else: - slot_inner = sexp('(div :class "mt-1" (span :class "text-sm text-stone-400" "No slot assigned"))') + slot_inner = render("events-entry-slot-none") slot_html = _field("Slot", slot_inner) # Time Period 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" - time_html = _field("Time Period", sexp('(div :class "mt-1" t)', t=start_str + end_str)) + time_html = _field("Time Period", render("events-entry-time-field", + time_str=start_str + end_str)) # State - state_html = _field("State", sexp( - '(div :class "mt-1" (div :id (str "entry-state-" eid) (raw! sb)))', - eid=str(eid), sb=_entry_state_badge_html(state), - )) + state_html = _field("State", render("events-entry-state-field", + entry_id=str(eid), + badge_html=_entry_state_badge_html(state))) # Cost cost = getattr(entry, "cost", None) cost_str = f"{cost:.2f}" if cost is not None else "0.00" - cost_html = _field("Cost", sexp( - '(div :class "mt-1" (span :class "font-medium text-green-600" (raw! cs)))', - cs=f"£{cost_str}", - )) + cost_html = _field("Cost", render("events-entry-cost-field", + cost_html=f"£{cost_str}")) # Ticket Configuration (admin) - tickets_html = _field("Tickets", sexp( - '(div :class "mt-1" :id (str "entry-tickets-" eid) (raw! tc))', - eid=str(eid), tc=render_entry_tickets_config(entry, calendar, day, month, year), - )) + tickets_html = _field("Tickets", render("events-entry-tickets-field", + entry_id=str(eid), + tickets_config_html=render_entry_tickets_config(entry, calendar, day, month, year))) # Buy Tickets (public-facing) ticket_remaining = ctx.get("ticket_remaining") @@ -2249,14 +1758,13 @@ def _entry_main_panel_html(ctx: dict) -> str: # Date date_str = entry.start_at.strftime("%A, %B %d, %Y") if entry.start_at else "" - date_html = _field("Date", sexp('(div :class "mt-1" d)', d=date_str)) + date_html = _field("Date", render("events-entry-date-field", date_str=date_str)) # Associated Posts entry_posts = ctx.get("entry_posts") or [] - posts_html = _field("Associated Posts", sexp( - '(div :class "mt-1" :id (str "entry-posts-" eid) (raw! ph))', - eid=str(eid), ph=render_entry_posts_panel(entry_posts, entry, calendar, day, month, year), - )) + posts_html = _field("Associated Posts", render("events-entry-posts-field", + entry_id=str(eid), + posts_panel_html=render_entry_posts_panel(entry_posts, entry, calendar, day, month, year))) # Options and Edit Button edit_url = url_for( @@ -2265,22 +1773,15 @@ def _entry_main_panel_html(ctx: dict) -> str: day=day, month=month, year=year, ) - return sexp( - '(section :id (str "entry-" eid) :class lc' - ' (raw! nh) (raw! slh) (raw! tmh) (raw! sth) (raw! cth)' - ' (raw! tkh) (raw! buyh) (raw! dth) (raw! psh)' - ' (div :class "flex gap-2 mt-6"' - ' (raw! opts)' - ' (button :type "button" :class pa' - ' :hx-get eu :hx-target (str "#entry-" eid) :hx-swap "outerHTML"' - ' "Edit")))', - eid=str(eid), lc=list_container, - nh=name_html, slh=slot_html, tmh=time_html, sth=state_html, - cth=cost_html, tkh=tickets_html, buyh=buy_html, - dth=date_html, psh=posts_html, - opts=_entry_options_html(entry, calendar, day, month, year), - pa=pre_action, eu=edit_url, - ) + return render("events-entry-panel", + entry_id=str(eid), list_container=list_container, + name_html=name_html, slot_html=slot_html, + time_html=time_html, state_html=state_html, + cost_html=cost_html, tickets_html=tickets_html, + buy_html=buy_html, date_html=date_html, + posts_html=posts_html, + options_html=_entry_options_html(entry, calendar, day, month, year), + pre_action=pre_action, edit_url=edit_url) # --------------------------------------------------------------------------- @@ -2308,23 +1809,16 @@ def _entry_header_html(ctx: dict, *, oob: bool = False) -> str: year=year, month=month, day=day, entry_id=entry.id, ) - label_html = sexp( - '(div :id (str "entry-title-" eid) :class "flex gap-1 items-center"' - ' (raw! th) (raw! tmh))', - eid=str(entry.id), th=_entry_title_html(entry), tmh=_entry_times_html(entry), - ) + label_html = render("events-entry-label", + entry_id=str(entry.id), + title_html=_entry_title_html(entry), + times_html=_entry_times_html(entry)) nav_html = _entry_nav_html(ctx) - return sexp( - '(~menu-row :id "entry-row" :level 5' - ' :link-href lh :link-label-html llh' - ' :nav-html nh :child-id "entry-header-child" :oob oob)', - lh=link_href, - llh=label_html, - nh=nav_html, - oob=oob, - ) + return render("menu-row", id="entry-row", level=5, + link_href=link_href, link_label_html=label_html, + nav_html=nav_html, child_id="entry-header-child", oob=oob) def _entry_times_html(entry) -> str: @@ -2335,7 +1829,7 @@ def _entry_times_html(entry) -> str: return "" start_str = start.strftime("%H:%M") end_str = f" \u2192 {end.strftime('%H:%M')}" if end else "" - return sexp('(div :class "text-sm text-gray-600" t)', t=start_str + end_str) + return render("events-entry-times", time_str=start_str + end_str) # --------------------------------------------------------------------------- @@ -2373,23 +1867,13 @@ def _entry_nav_html(ctx: dict) -> str: feat = getattr(ep, "feature_image", None) href = blog_url_fn(f"/{slug}/") if blog_url_fn else f"/{slug}/" if feat: - img_html = sexp( - '(img :src f :alt t :class "w-8 h-8 rounded-full object-cover flex-shrink-0")', - f=feat, t=title, - ) + img_html = render("events-post-img", src=feat, alt=title) else: - img_html = sexp('(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")') - post_links += sexp( - '(a :href h :class "flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0"' - ' (raw! ih) (div :class "flex-1 min-w-0" (div :class "font-medium truncate" t)))', - h=href, ih=img_html, t=title, - ) - parts.append(sexp( - '(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"' - ' :id "entry-posts-nav-wrapper"' - ' (div :class "flex overflow-x-auto gap-1 scrollbar-thin" (raw! pl)))', - pl=post_links, - )) + img_html = render("events-post-img-placeholder") + post_links += render("events-entry-nav-post-link", + href=href, img_html=img_html, title=title) + parts.append(render("events-entry-posts-nav-oob", + items_html=post_links).replace(' :hx-swap-oob "true"', '')) # Admin link if is_admin: @@ -2399,11 +1883,7 @@ def _entry_nav_html(ctx: dict) -> str: day=day, month=month, year=year, entry_id=entry.id, ) - parts.append(sexp( - '(a :href au :class "inline-flex items-center gap-1 px-2 py-1 text-xs text-stone-500 hover:text-stone-700 hover:bg-stone-100 rounded"' - ' (i :class "fa fa-cog" :aria-hidden "true") " Admin")', - au=admin_url, - )) + parts.append(render("events-entry-admin-link", href=admin_url)) return "".join(parts) @@ -2419,7 +1899,7 @@ async def render_entry_page(ctx: dict) -> str: child = (_post_header_html(ctx) + _calendar_header_html(ctx) + _day_header_html(ctx) + _entry_header_html(ctx)) - hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) + hdr += render("events-header-child", inner_html=child) nav_html = _entry_nav_html(ctx) return full_page(ctx, header_rows_html=hdr, content_html=content, menu_html=nav_html) @@ -2444,20 +1924,17 @@ def render_entry_optioned(entry, calendar, day, month, year) -> str: title = _entry_title_html(entry) state = _entry_state_badge_html(getattr(entry, "state", "pending") or "pending") - return options + sexp( - '(<> (div :id (str "entry-title-" eid) :hx-swap-oob "innerHTML" (raw! th))' - ' (div :id (str "entry-state-" eid) :hx-swap-oob "innerHTML" (raw! sh)))', - eid=str(entry.id), th=title, sh=state, - ) + return options + render("events-entry-optioned-oob", + entry_id=str(entry.id), + title_html=title, state_html=state) def _entry_title_html(entry) -> str: """Render entry title (icon + name + state badge).""" state = getattr(entry, "state", "pending") or "pending" - return sexp( - '(<> (i :class "fa fa-clock") " " n " " (raw! sb))', - n=entry.name, sb=_entry_state_badge_html(state), - ) + return render("events-entry-title", + name=entry.name, + badge_html=_entry_state_badge_html(state)) def _entry_options_html(entry, calendar, day, month, year) -> str: @@ -2480,21 +1957,11 @@ def _entry_options_html(entry, calendar, day, month, year) -> str: calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid, ) btn_type = "button" if trigger_type == "button" else "submit" - return sexp( - '(form :hx-post u :hx-select tgt :hx-target tgt :hx-swap "outerHTML"' - ' :hx-trigger (if is-btn "confirmed" nil)' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (button :type bt :class ab' - ' :data-confirm "true" :data-confirm-title ct' - ' :data-confirm-text cx :data-confirm-icon "question"' - ' :data-confirm-confirm-text (str "Yes, " l " it")' - ' :data-confirm-cancel-text "Cancel"' - ' :data-confirm-event (if is-btn "confirmed" nil)' - ' (i :class "fa-solid fa-rotate mr-2" :aria-hidden "true") l))', - u=url, tgt=target, csrf=csrf, bt=btn_type, - ab=action_btn, ct=confirm_title, cx=confirm_text, - l=label, **{"is-btn": trigger_type == "button"}, - ) + return render("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_html = "" if state == "provisional": @@ -2513,11 +1980,8 @@ def _entry_options_html(entry, calendar, day, month, year) -> str: trigger_type="button", ) - return sexp( - '(div :id (str "calendar_entry_options_" eid) :class "flex flex-col md:flex-row gap-1"' - ' (raw! bh))', - eid=str(eid), bh=buttons_html, - ) + return render("events-entry-options", + entry_id=str(eid), buttons_html=buttons_html) # --------------------------------------------------------------------------- @@ -2541,26 +2005,11 @@ def render_entry_tickets_config(entry, calendar, day, month, year) -> str: if tp is not None: tc_str = f"{tc} tickets" if tc is not None else "Unlimited" - display_html = sexp( - '(div :class "space-y-2"' - ' (div :class "flex items-center gap-2"' - ' (span :class "text-sm font-medium text-stone-700" "Price:")' - ' (span :class "font-medium text-green-600" (raw! ps)))' - ' (div :class "flex items-center gap-2"' - ' (span :class "text-sm font-medium text-stone-700" "Available:")' - ' (span :class "font-medium text-blue-600" ts))' - ' (button :type "button" :class "text-xs text-blue-600 hover:text-blue-800 underline"' - ' :onclick sj "Edit ticket config"))', - ps=f"£{tp:.2f}", ts=tc_str, sj=show_js, - ) + display_html = render("events-ticket-config-display", + price_str=f"£{tp:.2f}", + count_str=tc_str, show_js=show_js) else: - display_html = sexp( - '(div :class "space-y-2"' - ' (span :class "text-sm text-stone-400" "No tickets configured")' - ' (button :type "button" :class "block text-xs text-blue-600 hover:text-blue-800 underline"' - ' :onclick sj "Configure tickets"))', - sj=show_js, - ) + display_html = render("events-ticket-config-none", show_js=show_js) update_url = url_for( "calendars.calendar.day.calendar_entries.calendar_entry.update_tickets", @@ -2570,29 +2019,10 @@ def render_entry_tickets_config(entry, calendar, day, month, year) -> str: tp_val = f"{tp:.2f}" if tp is not None else "" tc_val = str(tc) if tc is not None else "" - form_html = sexp( - '(form :id (str "ticket-form-" eid) :class (str hc " space-y-3 mt-2 p-3 border rounded bg-stone-50")' - ' :hx-post uu :hx-target (str "#entry-tickets-" eid) :hx-swap "innerHTML"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (div (label :for (str "ticket-price-" eid) :class "block text-sm font-medium text-stone-700 mb-1"' - ' (raw! "Ticket Price (£)"))' - ' (input :type "number" :id (str "ticket-price-" eid) :name "ticket_price"' - ' :step "0.01" :min "0" :value tpv' - ' :class "w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"' - ' :placeholder "e.g., 5.00"))' - ' (div (label :for (str "ticket-count-" eid) :class "block text-sm font-medium text-stone-700 mb-1"' - ' "Total Tickets")' - ' (input :type "number" :id (str "ticket-count-" eid) :name "ticket_count"' - ' :min "0" :value tcv' - ' :class "w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"' - ' :placeholder "Leave empty for unlimited"))' - ' (div :class "flex gap-2"' - ' (button :type "submit" :class "px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm" "Save")' - ' (button :type "button" :class "px-4 py-2 bg-stone-200 text-stone-700 rounded hover:bg-stone-300 text-sm"' - ' :onclick hj "Cancel")))', - eid=eid_s, hc=hidden_cls, uu=update_url, csrf=csrf, - tpv=tp_val, tcv=tc_val, hj=hide_js, - ) + form_html = render("events-ticket-config-form", + entry_id=eid_s, hidden_cls=hidden_cls, + update_url=update_url, csrf=csrf, + price_val=tp_val, count_val=tc_val, hide_js=hide_js) return display_html + form_html @@ -2617,50 +2047,30 @@ def render_entry_posts_panel(entry_posts, entry, calendar, day, month, year) -> ep_title = getattr(ep, "title", "") ep_id = getattr(ep, "id", 0) feat = getattr(ep, "feature_image", None) - img_html = (sexp('(img :src f :alt t :class "w-8 h-8 rounded-full object-cover flex-shrink-0")', f=feat, t=ep_title) - if feat else sexp('(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")')) + img_html = (render("events-post-img", src=feat, alt=ep_title) + if feat else render("events-post-img-placeholder")) del_url = url_for( "calendars.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 += sexp( - '(div :class "flex items-center justify-between gap-3 p-2 bg-stone-50 rounded border"' - ' (raw! ih) (span :class "text-sm flex-1" t)' - ' (button :type "button" :class "text-xs text-red-600 hover:text-red-800 flex-shrink-0"' - ' :data-confirm "true" :data-confirm-title "Remove post?"' - ' :data-confirm-text (str "This will remove " t " from this entry")' - ' :data-confirm-icon "warning" :data-confirm-confirm-text "Yes, remove it"' - ' :data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"' - ' :hx-delete du :hx-trigger "confirmed"' - ' :hx-target (str "#entry-posts-" eid) :hx-swap "innerHTML"' - ' :hx-headers hd' - ' (i :class "fa fa-times") " Remove"))', - ih=img_html, t=ep_title, du=del_url, - eid=eid_s, hd=f'{{"X-CSRFToken": "{csrf}"}}', - ) - posts_html = sexp('(div :class "space-y-2" (raw! it))', it=items) + items += render("events-entry-post-item", + img_html=img_html, title=ep_title, + del_url=del_url, entry_id=eid_s, + csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}') + posts_html = render("events-entry-posts-list", items_html=items) else: - posts_html = sexp('(p :class "text-sm text-stone-400" "No posts associated")') + posts_html = render("events-entry-posts-none") search_url = url_for( "calendars.calendar.day.calendar_entries.calendar_entry.search_posts", calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid, ) - return sexp( - '(div :class "space-y-2"' - ' (raw! ph)' - ' (div :class "mt-3 pt-3 border-t"' - ' (label :class "block text-xs font-medium text-stone-700 mb-1" "Add Post")' - ' (input :type "text" :placeholder "Search posts..."' - ' :class "w-full px-3 py-2 border rounded text-sm"' - ' :hx-get su :hx-trigger "keyup changed delay:300ms, load"' - ' :hx-target (str "#post-search-results-" eid) :hx-swap "innerHTML" :name "q")' - ' (div :id (str "post-search-results-" eid) :class "mt-2 max-h-96 overflow-y-auto border rounded")))', - ph=posts_html, su=search_url, eid=eid_s, - ) + return render("events-entry-posts-panel", + posts_html=posts_html, search_url=search_url, + entry_id=eid_s) # --------------------------------------------------------------------------- @@ -2675,7 +2085,7 @@ def render_entry_posts_nav_oob(entry_posts) -> str: blog_url_fn = getattr(g, "blog_url", None) if not entry_posts: - return sexp('(div :id "entry-posts-nav-wrapper" :hx-swap-oob "true")') + return render("events-entry-posts-nav-oob-empty") items = "" for ep in entry_posts: @@ -2683,19 +2093,13 @@ def render_entry_posts_nav_oob(entry_posts) -> str: 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 = (sexp('(img :src f :alt t :class "w-8 h-8 rounded-full object-cover flex-shrink-0")', f=feat, t=title) - if feat else sexp('(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")')) - items += sexp( - '(a :href h :class nb (raw! ih) (div :class "flex-1 min-w-0" (div :class "font-medium truncate" t)))', - h=href, nb=nav_btn, ih=img_html, t=title, - ) + img_html = (render("events-post-img", src=feat, alt=title) + if feat else render("events-post-img-placeholder")) + items += render("events-entry-nav-post", + href=href, nav_btn=nav_btn, + img_html=img_html, title=title) - return sexp( - '(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"' - ' :id "entry-posts-nav-wrapper" :hx-swap-oob "true"' - ' (div :class "flex overflow-x-auto gap-1 scrollbar-thin" (raw! it)))', - it=items, - ) + return render("events-entry-posts-nav-oob", items_html=items) # --------------------------------------------------------------------------- @@ -2711,7 +2115,7 @@ def render_day_entries_nav_oob(confirmed_entries, calendar, day_date) -> str: cal_slug = getattr(calendar, "slug", "") if not confirmed_entries: - return sexp('(div :id "day-entries-nav-wrapper" :hx-swap-oob "true")') + return render("events-day-entries-nav-oob-empty") items = "" for entry in confirmed_entries: @@ -2723,20 +2127,11 @@ def render_day_entries_nav_oob(confirmed_entries, calendar, day_date) -> str: ) 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 += sexp( - '(a :href h :class nb' - ' (div :class "flex-1 min-w-0"' - ' (div :class "font-medium truncate" n)' - ' (div :class "text-xs text-stone-600 truncate" t)))', - h=href, nb=nav_btn, n=entry.name, t=start + end, - ) + items += render("events-day-nav-entry", + href=href, nav_btn=nav_btn, + name=entry.name, time_str=start + end) - return sexp( - '(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"' - ' :id "day-entries-nav-wrapper" :hx-swap-oob "true"' - ' (div :class "flex overflow-x-auto gap-1 scrollbar-thin" (raw! it)))', - it=items, - ) + return render("events-day-entries-nav-oob", items_html=items) # --------------------------------------------------------------------------- @@ -2755,7 +2150,7 @@ def render_post_nav_entries_oob(associated_entries, calendars, post) -> str: has_items = has_entries or calendars if not has_items: - return sexp('(div :id "entries-calendars-nav-wrapper" :hx-swap-oob "true")') + return render("events-post-nav-oob-empty") slug = post.get("slug", "") if isinstance(post, dict) else getattr(post, "slug", "") @@ -2770,50 +2165,24 @@ def render_post_nav_entries_oob(associated_entries, calendars, post) -> str: 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 "" - items += sexp( - '(a :href h :class nb' - ' (div :class "w-8 h-8 rounded bg-stone-200 flex-shrink-0")' - ' (div :class "flex-1 min-w-0"' - ' (div :class "font-medium truncate" n)' - ' (div :class "text-xs text-stone-600 truncate" t)))', - h=href, nb=nav_btn, n=entry.name, t=time_str + end_str, - ) + items += render("events-post-nav-entry", + href=href, nav_btn=nav_btn, + name=entry.name, time_str=time_str + end_str) if calendars: for cal in calendars: cs = getattr(cal, "slug", "") local_href = events_url(f"/{slug}/{cs}/") - items += sexp( - '(a :href lh :class nb' - ' (i :class "fa fa-calendar" :aria-hidden "true")' - ' (div cn))', - lh=local_href, nb=nav_btn, cn=cal.name, - ) + items += render("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 sexp( - '(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"' - ' :id "entries-calendars-nav-wrapper" :hx-swap-oob "true"' - ' (button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"' - ' :aria-label "Scroll left"' - ' :_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200"' - ' (i :class "fa fa-chevron-left"))' - ' (div :id "associated-items-container"' - ' :class "overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"' - ' :style "scroll-behavior: smooth;" :_ hs' - ' (div :class "flex flex-col sm:flex-row gap-1" (raw! it)))' - ' (style ".scrollbar-hide::-webkit-scrollbar { display: none; }' - ' .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }")' - ' (button :class "entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"' - ' :aria-label "Scroll right"' - ' :_ "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200"' - ' (i :class "fa fa-chevron-right")))', - it=items, hs=hs, - ) + return render("events-post-nav-wrapper", + items_html=items, hyperscript=hs) # --------------------------------------------------------------------------- @@ -2830,12 +2199,8 @@ def render_calendar_description(calendar, *, oob: bool = False) -> str: if oob: desc = getattr(calendar, "description", "") or "" - html += sexp( - '(div :id "calendar-description-title" :hx-swap-oob "outerHTML"' - ' :class "text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block"' - ' d)', - d=desc, - ) + html += render("events-calendar-description-title-oob", + description=desc) return html @@ -2850,19 +2215,9 @@ def render_calendar_description_edit(calendar) -> str: save_url = url_for("calendars.calendar.admin.calendar_description_save", calendar_slug=cal_slug) cancel_url = url_for("calendars.calendar.admin.calendar_description_view", calendar_slug=cal_slug) - return sexp( - '(div :id "calendar-description"' - ' (form :hx-post su :hx-target "#calendar-description" :hx-swap "outerHTML"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (textarea :name "description" :autocomplete "off" :rows "4"' - ' :class "w-full p-2 border rounded" d)' - ' (div :class "mt-2 flex gap-2 text-xs"' - ' (button :type "submit" :class "px-3 py-1 rounded bg-stone-800 text-white" "Save")' - ' (button :type "button" :class "px-3 py-1 rounded border"' - ' :hx-get cu :hx-target "#calendar-description" :hx-swap "outerHTML"' - ' "Cancel"))))', - su=save_url, csrf=csrf, d=desc, cu=cancel_url, - ) + return render("events-calendar-description-edit-form", + save_url=save_url, cancel_url=cancel_url, + csrf=csrf, description=desc) # --------------------------------------------------------------------------- @@ -2919,44 +2274,24 @@ def render_slot_main_panel(slot, calendar, *, oob: bool = False) -> str: # Days pills if days and days[0] != "\u2014": days_inner = "".join( - sexp('(span :class "px-2 py-0.5 rounded-full text-xs bg-slate-200" d)', d=d) for d in days + render("events-slot-day-pill", day=d) for d in days ) - days_html = sexp('(div :class "flex flex-wrap gap-1" (raw! di))', di=days_inner) + days_html = render("events-slot-days-pills", days_inner_html=days_inner) else: - days_html = sexp('(span :class "text-xs text-slate-400" "No days")') + days_html = render("events-slot-no-days") sid = str(slot.id) - result = sexp( - '(section :id (str "slot-" sid) :class lc' - ' (div :class "flex flex-col"' - ' (div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" "Days")' - ' (div :class "mt-1" (raw! dh)))' - ' (div :class "flex flex-col"' - ' (div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" "Flexible")' - ' (div :class "mt-1" fl))' - ' (div :class "grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm"' - ' (div :class "flex flex-col"' - ' (div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" "Time")' - ' (div :class "mt-1" tm))' - ' (div :class "flex flex-col"' - ' (div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" "Cost")' - ' (div :class "mt-1" cs)))' - ' (button :type "button" :class pa :hx-get eu' - ' :hx-target (str "#slot-" sid) :hx-swap "outerHTML" "Edit"))', - sid=sid, lc=list_container, dh=days_html, - fl="yes" if flexible else "no", - tm=f"{time_start} \u2014 {time_end}", cs=cost_str, - pa=pre_action, eu=edit_url, - ) + result = render("events-slot-panel", + slot_id=sid, list_container=list_container, + days_html=days_html, + flexible="yes" if flexible else "no", + time_str=f"{time_start} \u2014 {time_end}", + cost_str=cost_str, + pre_action=pre_action, edit_url=edit_url) if oob: - result += sexp( - '(div :id "slot-description-title" :hx-swap-oob "outerHTML"' - ' :class "text-base font-normal break-words whitespace-normal min-w-0 break-all w-full text-center block"' - ' d)', - d=desc, - ) + result += render("events-slot-description-oob", description=desc) return result @@ -2991,64 +2326,35 @@ def render_slots_table(slots, calendar) -> str: day_list = days_display.split(", ") if day_list and day_list[0] != "\u2014": days_inner = "".join( - sexp('(span :class "px-2 py-0.5 rounded-full text-xs bg-slate-200" d)', d=d) for d in day_list + render("events-slot-day-pill", day=d) for d in day_list ) - days_html = sexp('(div :class "flex flex-wrap gap-1" (raw! di))', di=days_inner) + days_html = render("events-slot-days-pills", days_inner_html=days_inner) else: - days_html = sexp('(span :class "text-xs text-slate-400" "No days")') + days_html = render("events-slot-no-days") 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 "" - rows_html += sexp( - '(tr :class tc' - ' (td :class "p-2 align-top w-1/6"' - ' (div :class "font-medium"' - ' (a :href sh :class pc :hx-get sh :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true" sn))' - ' (p :class "text-stone-500 whitespace-pre-line break-all w-full" ds))' - ' (td :class "p-2 align-top w-1/6" fl)' - ' (td :class "p-2 align-top w-1/6" (raw! dh))' - ' (td :class "p-2 align-top w-1/6" tm)' - ' (td :class "p-2 align-top w-1/6" cs)' - ' (td :class "p-2 align-top w-1/6"' - ' (button :class ab :type "button"' - ' :data-confirm "true" :data-confirm-title "Delete slot?"' - ' :data-confirm-text "This action cannot be undone."' - ' :data-confirm-icon "warning" :data-confirm-confirm-text "Yes, delete it"' - ' :data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"' - ' :hx-delete du :hx-target "#slots-table" :hx-select "#slots-table"' - ' :hx-swap "outerHTML" :hx-headers hd :hx-trigger "confirmed"' - ' (i :class "fa-solid fa-trash"))))', - tc=tr_cls, sh=slot_href, pc=pill_cls, hs=hx_select, - sn=s.name, ds=desc, fl="yes" if s.flexible else "no", - dh=days_html, tm=f"{time_start} - {time_end}", cs=cost_str, - ab=action_btn, du=del_url, hd=f'{{"X-CSRFToken": "{csrf}"}}', - ) + rows_html += render("events-slots-row", + tr_cls=tr_cls, slot_href=slot_href, + pill_cls=pill_cls, hx_select=hx_select, + slot_name=s.name, description=desc, + flexible="yes" if s.flexible else "no", + days_html=days_html, + time_str=f"{time_start} - {time_end}", + cost_str=cost_str, action_btn=action_btn, + del_url=del_url, + csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}') else: - rows_html = sexp('(tr (td :colspan "5" :class "p-3 text-stone-500" "No slots yet."))') + rows_html = render("events-slots-empty-row") add_url = url_for("calendars.calendar.slots.add_form", calendar_slug=cal_slug) - return sexp( - '(section :id "slots-table" :class lc' - ' (table :class "w-full text-sm border table-fixed"' - ' (thead :class "bg-stone-100"' - ' (tr (th :class "p-2 text-left w-1/6" "Name")' - ' (th :class "p-2 text-left w-1/6" "Flexible")' - ' (th :class "text-left p-2 w-1/6" "Days")' - ' (th :class "text-left p-2 w-1/6" "Time")' - ' (th :class "text-left p-2 w-1/6" "Cost")' - ' (th :class "text-left p-2 w-1/6" "Actions")))' - ' (tbody (raw! rh)))' - ' (div :id "slot-add-container" :class "mt-4"' - ' (button :type "button" :class pa' - ' :hx-get au :hx-target "#slot-add-container" :hx-swap "innerHTML"' - ' "+ Add slot")))', - lc=list_container, rh=rows_html, pa=pre_action, au=add_url, - ) + return render("events-slots-table", + list_container=list_container, rows_html=rows_html, + pre_action=pre_action, add_url=add_url) # --------------------------------------------------------------------------- @@ -3076,25 +2382,14 @@ def render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year ) def _col(label, val): - return sexp( - '(div :class "flex flex-col"' - ' (div :class "text-xs font-semibold uppercase tracking-wide text-stone-500" l)' - ' (div :class "mt-1" v))', - l=label, v=val, - ) + return render("events-ticket-type-col", label=label, value=val) - return sexp( - '(section :id (str "ticket-" tid) :class lc' - ' (div :class "grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm"' - ' (raw! c1) (raw! c2) (raw! c3))' - ' (button :type "button" :class pa :hx-get eu' - ' :hx-target (str "#ticket-" tid) :hx-swap "outerHTML" "Edit"))', - tid=tid, lc=list_container, - c1=_col("Name", ticket_type.name), - c2=_col("Cost", cost_str), - c3=_col("Count", str(count)), - pa=pre_action, eu=edit_url, - ) + return render("events-ticket-type-panel", + ticket_id=tid, list_container=list_container, + c1=_col("Name", ticket_type.name), + c2=_col("Cost", cost_str), + c3=_col("Count", str(count)), + pre_action=pre_action, edit_url=edit_url) # --------------------------------------------------------------------------- @@ -3132,49 +2427,24 @@ def render_ticket_types_table(ticket_types, entry, calendar, day, month, year) - cost = getattr(tt, "cost", None) cost_str = f"\u00a3{cost:.2f}" if cost is not None else "\u00a30.00" - rows_html += sexp( - '(tr :class tc' - ' (td :class "p-2 align-top w-1/3"' - ' (div :class "font-medium"' - ' (a :href th :class pc :hx-get th :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true" tn)))' - ' (td :class "p-2 align-top w-1/4" cs)' - ' (td :class "p-2 align-top w-1/4" cnt)' - ' (td :class "p-2 align-top w-1/6"' - ' (button :class ab :type "button"' - ' :data-confirm "true" :data-confirm-title "Delete ticket type?"' - ' :data-confirm-text "This action cannot be undone."' - ' :data-confirm-icon "warning" :data-confirm-confirm-text "Yes, delete it"' - ' :data-confirm-cancel-text "Cancel" :data-confirm-event "confirmed"' - ' :hx-delete du :hx-target "#tickets-table" :hx-select "#tickets-table"' - ' :hx-swap "outerHTML" :hx-headers hd :hx-trigger "confirmed"' - ' (i :class "fa-solid fa-trash"))))', - tc=tr_cls, th=tt_href, pc=pill_cls, hs=hx_select, - tn=tt.name, cs=cost_str, cnt=str(tt.count), - ab=action_btn, du=del_url, hd=f'{{"X-CSRFToken": "{csrf}"}}', - ) + rows_html += render("events-ticket-types-row", + tr_cls=tr_cls, tt_href=tt_href, + pill_cls=pill_cls, hx_select=hx_select, + tt_name=tt.name, cost_str=cost_str, + count=str(tt.count), action_btn=action_btn, + del_url=del_url, + csrf_hdr=f'{{"X-CSRFToken": "{csrf}"}}') else: - rows_html = sexp('(tr (td :colspan "4" :class "p-3 text-stone-500" "No ticket types yet."))') + rows_html = render("events-ticket-types-empty-row") add_url = url_for( "calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.add_form", calendar_slug=cal_slug, entry_id=eid, year=year, month=month, day=day, ) - return sexp( - '(section :id "tickets-table" :class lc' - ' (table :class "w-full text-sm border table-fixed"' - ' (thead :class "bg-stone-100"' - ' (tr (th :class "p-2 text-left w-1/3" "Name")' - ' (th :class "text-left p-2 w-1/4" "Cost")' - ' (th :class "text-left p-2 w-1/4" "Count")' - ' (th :class "text-left p-2 w-1/6" "Actions")))' - ' (tbody (raw! rh)))' - ' (div :id "ticket-add-container" :class "mt-4"' - ' (button :class ab :hx-get au :hx-target "#ticket-add-container" :hx-swap "innerHTML"' - ' (i :class "fa fa-plus") " Add ticket type")))', - lc=list_container, rh=rows_html, ab=action_btn, au=add_url, - ) + return render("events-ticket-types-table", + list_container=list_container, rows_html=rows_html, + action_btn=action_btn, add_url=add_url) # --------------------------------------------------------------------------- @@ -3193,36 +2463,23 @@ def render_buy_result(entry, created_tickets, remaining, cart_count) -> str: tickets_html = "" for ticket in created_tickets: href = url_for("tickets.ticket_detail", code=ticket.code) - tickets_html += sexp( - '(a :href h :class "flex items-center justify-between p-2 rounded-lg bg-white border border-emerald-100 hover:border-emerald-300 transition text-sm"' - ' (div :class "flex items-center gap-2"' - ' (i :class "fa fa-ticket text-emerald-500" :aria-hidden "true")' - ' (span :class "font-mono text-xs text-stone-500" cs))' - ' (span :class "text-xs text-emerald-600 font-medium" "View ticket"))', - h=href, cs=ticket.code[:12] + "...", - ) + tickets_html += render("events-buy-result-ticket", + href=href, code_short=ticket.code[:12] + "...") remaining_html = "" if remaining is not None: r_suffix = "s" if remaining != 1 else "" - remaining_html = sexp('(p :class "text-xs text-stone-500" r)', - r=f"{remaining} ticket{r_suffix} remaining") + remaining_html = render("events-buy-result-remaining", + text=f"{remaining} ticket{r_suffix} remaining") my_href = url_for("tickets.my_tickets") - return cart_html + sexp( - '(div :id (str "ticket-buy-" eid) :class "rounded-xl border border-emerald-200 bg-emerald-50 p-4"' - ' (div :class "flex items-center gap-2 mb-3"' - ' (i :class "fa fa-check-circle text-emerald-600" :aria-hidden "true")' - ' (span :class "font-semibold text-emerald-800" cl))' - ' (div :class "space-y-2 mb-4" (raw! th))' - ' (raw! rh)' - ' (div :class "mt-3 flex gap-2"' - ' (a :href mh :class "text-sm text-emerald-700 hover:text-emerald-900 underline"' - ' "View all my tickets")))', - eid=str(entry.id), cl=f"{count} ticket{suffix} reserved", - th=tickets_html, rh=remaining_html, mh=my_href, - ) + return cart_html + render("events-buy-result", + entry_id=str(entry.id), + count_label=f"{count} ticket{suffix} reserved", + tickets_html=tickets_html, + remaining_html=remaining_html, + my_tickets_href=my_href) # --------------------------------------------------------------------------- @@ -3246,12 +2503,7 @@ def render_buy_form(entry, ticket_remaining, ticket_sold_count, return "" if state != "confirmed": - return sexp( - '(div :id (str "ticket-buy-" eid) :class "rounded-xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-500"' - ' (i :class "fa fa-ticket mr-1" :aria-hidden "true")' - ' "Tickets available once this event is confirmed.")', - eid=eid_s, - ) + return render("events-buy-not-confirmed", entry_id=eid_s) adjust_url = url_for("tickets.adjust_quantity") target = f"#ticket-buy-{eid}" @@ -3260,18 +2512,16 @@ def render_buy_form(entry, ticket_remaining, ticket_sold_count, info_html = "" info_items = "" if ticket_sold_count: - info_items += sexp('(span (str sc " sold"))', sc=str(ticket_sold_count)) + info_items += render("events-buy-info-sold", + count=str(ticket_sold_count)) if ticket_remaining is not None: - info_items += sexp('(span (str tr " remaining"))', tr=str(ticket_remaining)) + info_items += render("events-buy-info-remaining", + count=str(ticket_remaining)) if user_ticket_count: - info_items += sexp( - '(span :class "text-emerald-600 font-medium"' - ' (i :class "fa fa-shopping-cart text-[0.6rem]" :aria-hidden "true")' - ' (str " " uc " in basket"))', - uc=str(user_ticket_count), - ) + info_items += render("events-buy-info-basket", + count=str(user_ticket_count)) if info_items: - info_html = sexp('(div :class "flex items-center gap-3 mb-3 text-xs text-stone-500" (raw! ii))', ii=info_items) + info_html = render("events-buy-info-bar", items_html=info_items) active_types = [tt for tt in ticket_types if getattr(tt, "deleted_at", None) is None] @@ -3281,86 +2531,47 @@ def render_buy_form(entry, ticket_remaining, ticket_sold_count, for tt in active_types: type_count = user_ticket_counts_by_type.get(tt.id, 0) if user_ticket_counts_by_type else 0 cost_str = f"\u00a3{tt.cost:.2f}" if tt.cost is not None else "\u00a30.00" - type_items += sexp( - '(div :class "flex items-center justify-between p-3 rounded-lg bg-stone-50 border border-stone-100"' - ' (div (div :class "font-medium text-sm" tn)' - ' (div :class "text-xs text-stone-500" cs))' - ' (raw! ac))', - tn=tt.name, cs=cost_str, - ac=_ticket_adjust_controls(csrf, adjust_url, target, eid, type_count, ticket_type_id=tt.id), - ) - body_html = sexp('(div :class "space-y-2" (raw! ti))', ti=type_items) + type_items += render("events-buy-type-item", + type_name=tt.name, cost_str=cost_str, + adjust_controls_html=_ticket_adjust_controls(csrf, adjust_url, target, eid, type_count, ticket_type_id=tt.id)) + body_html = render("events-buy-types-wrapper", items_html=type_items) else: qty = user_ticket_count or 0 - body_html = sexp( - '(<> (div :class "flex items-center justify-between mb-4"' - ' (div (span :class "font-medium text-green-600" ps)' - ' (span :class "text-sm text-stone-500 ml-2" "per ticket")))' - ' (raw! ac))', - ps=f"\u00a3{tp:.2f}", - ac=_ticket_adjust_controls(csrf, adjust_url, target, eid, qty), - ) + body_html = render("events-buy-default", + price_str=f"\u00a3{tp:.2f}", + adjust_controls_html=_ticket_adjust_controls(csrf, adjust_url, target, eid, qty)) - return sexp( - '(div :id (str "ticket-buy-" eid) :class "rounded-xl border border-stone-200 bg-white p-4"' - ' (h3 :class "text-sm font-semibold text-stone-700 mb-3"' - ' (i :class "fa fa-ticket mr-1" :aria-hidden "true") "Tickets")' - ' (raw! ih) (raw! bh))', - eid=eid_s, ih=info_html, bh=body_html, - ) + return render("events-buy-panel", + entry_id=eid_s, info_html=info_html, body_html=body_html) def _ticket_adjust_controls(csrf, adjust_url, target, entry_id, count, *, ticket_type_id=None): """Render +/- ticket controls for buy form.""" from quart import url_for - tt_html = sexp('(input :type "hidden" :name "ticket_type_id" :value tti)', - tti=str(ticket_type_id)) if ticket_type_id else "" + tt_html = render("events-adjust-tt-hidden", + ticket_type_id=str(ticket_type_id)) if ticket_type_id else "" eid_s = str(entry_id) def _adj_form(count_val, btn_html, *, extra_cls=""): - return sexp( - '(form :hx-post au :hx-target tgt :hx-swap "outerHTML" :class fc' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (input :type "hidden" :name "entry_id" :value eid)' - ' (raw! tth)' - ' (input :type "hidden" :name "count" :value cv)' - ' (raw! bh))', - au=adjust_url, tgt=target, fc=extra_cls, csrf=csrf, - eid=eid_s, tth=tt_html, cv=str(count_val), bh=btn_html, - ) + return render("events-adjust-form", + adjust_url=adjust_url, target=target, + extra_cls=extra_cls, csrf=csrf, + entry_id=eid_s, tt_html=tt_html, + count_val=str(count_val), btn_html=btn_html) if count == 0: - return _adj_form(1, sexp( - '(button :type "submit"' - ' :class "relative inline-flex items-center justify-center text-sm font-medium text-stone-500 hover:bg-emerald-50 rounded p-1"' - ' (i :class "fa fa-cart-plus text-2xl" :aria-hidden "true"))', - ), extra_cls="flex items-center") + return _adj_form(1, render("events-adjust-cart-plus"), + extra_cls="flex items-center") my_tickets_href = url_for("tickets.my_tickets") - minus = _adj_form(count - 1, sexp( - '(button :type "submit"' - ' :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"' - ' "-")', - )) - cart_icon = sexp( - '(a :class "relative inline-flex items-center justify-center text-emerald-700" :href mth' - ' (span :class "relative inline-flex items-center justify-center"' - ' (i :class "fa-solid fa-shopping-cart text-2xl" :aria-hidden "true")' - ' (span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"' - ' (span :class "flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold" c))))', - mth=my_tickets_href, c=str(count), - ) - plus = _adj_form(count + 1, sexp( - '(button :type "submit"' - ' :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"' - ' "+")', - )) + minus = _adj_form(count - 1, render("events-adjust-minus")) + cart_icon = render("events-adjust-cart-icon", + href=my_tickets_href, count=str(count)) + plus = _adj_form(count + 1, render("events-adjust-plus")) - return sexp( - '(div :class "flex items-center gap-2" (raw! m) (raw! ci) (raw! p))', - m=minus, ci=cart_icon, p=plus, - ) + return render("events-adjust-controls", + minus_html=minus, cart_icon_html=cart_icon, plus_html=plus) # --------------------------------------------------------------------------- @@ -3393,20 +2604,9 @@ def _cart_icon_oob(count: int) -> str: if count == 0: blog_href = blog_url_fn("/") if blog_url_fn else "/" - return sexp( - '(div :id "cart-mini" :hx-swap-oob "true"' - ' (div :class "h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0"' - ' (a :href bh :class "h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1"' - ' (img :src lg :class "h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0"))))', - bh=blog_href, lg=logo, - ) + return render("events-cart-icon-logo", + blog_href=blog_href, logo=logo) cart_href = cart_url_fn("/") if cart_url_fn else "/" - return sexp( - '(div :id "cart-mini" :hx-swap-oob "true"' - ' (a :href ch :class "relative inline-flex items-center justify-center text-stone-700 hover:text-emerald-700"' - ' (i :class "fa fa-shopping-cart text-5xl" :aria-hidden "true")' - ' (span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-full bg-emerald-600 text-white text-sm w-5 h-5"' - ' c)))', - ch=cart_href, c=str(count), - ) + return render("events-cart-icon-badge", + cart_href=cart_href, count=str(count)) diff --git a/events/sexp/tickets.sexpr b/events/sexp/tickets.sexpr new file mode 100644 index 0000000..64eaea3 --- /dev/null +++ b/events/sexp/tickets.sexpr @@ -0,0 +1,206 @@ +;; Events ticket components + +(defcomp ~events-ticket-card (&key href entry-name type-name time-str cal-name badge-html code-prefix) + (a :href href :class "block rounded-xl border border-stone-200 bg-white p-4 hover:shadow-md transition" + (div :class "flex items-start justify-between gap-4" + (div :class "flex-1 min-w-0" + (div :class "font-semibold text-lg truncate" entry-name) + (when type-name (div :class "text-sm text-stone-600 mt-0.5" type-name)) + (when time-str (div :class "text-sm text-stone-500 mt-1" time-str)) + (when cal-name (div :class "text-xs text-stone-400 mt-0.5" cal-name))) + (div :class "flex flex-col items-end gap-1 flex-shrink-0" + (raw! badge-html) + (span :class "text-xs text-stone-400 font-mono" (str code-prefix "...")))))) + +(defcomp ~events-tickets-panel (&key list-container has-tickets cards-html) + (section :id "tickets-list" :class list-container + (h1 :class "text-2xl font-bold mb-6" "My Tickets") + (if has-tickets + (div :class "space-y-4" (raw! cards-html)) + (div :class "text-center py-12 text-stone-500" + (i :class "fa fa-ticket text-4xl mb-4 block" :aria-hidden "true") + (p :class "text-lg" "No tickets yet") + (p :class "text-sm mt-1" "Tickets will appear here after you purchase them."))))) + +(defcomp ~events-ticket-detail (&key list-container back-href header-bg entry-name badge-html + type-name code time-date time-range cal-name + type-desc checkin-str qr-script) + (section :id "ticket-detail" :class (str list-container " max-w-lg mx-auto") + (a :href back-href :class "inline-flex items-center gap-1 text-sm text-stone-500 hover:text-stone-700 mb-4" + (i :class "fa fa-arrow-left" :aria-hidden "true") " Back to my tickets") + (div :class "rounded-2xl border border-stone-200 bg-white overflow-hidden" + (div :class (str "px-6 py-4 border-b border-stone-100 " header-bg) + (div :class "flex items-center justify-between" + (h1 :class "text-xl font-bold" entry-name) + (raw! badge-html)) + (when type-name (div :class "text-sm text-stone-600 mt-1" type-name))) + (div :class "px-6 py-8 flex flex-col items-center border-b border-stone-100" + (div :id (str "ticket-qr-" code) :class "bg-white p-4 rounded-lg border border-stone-200") + (p :class "text-xs text-stone-400 mt-3 font-mono select-all" code)) + (div :class "px-6 py-4 space-y-3" + (when time-date (div :class "flex items-start gap-3" + (i :class "fa fa-calendar text-stone-400 mt-0.5" :aria-hidden "true") + (div (div :class "text-sm font-medium" time-date) + (div :class "text-sm text-stone-500" time-range)))) + (when cal-name (div :class "flex items-start gap-3" + (i :class "fa fa-map-pin text-stone-400 mt-0.5" :aria-hidden "true") + (div :class "text-sm" cal-name))) + (when type-desc (div :class "flex items-start gap-3" + (i :class "fa fa-tag text-stone-400 mt-0.5" :aria-hidden "true") + (div :class "text-sm" type-desc))) + (when checkin-str (div :class "flex items-start gap-3" + (i :class "fa fa-check-circle text-blue-500 mt-0.5" :aria-hidden "true") + (div :class "text-sm text-blue-700" checkin-str))))) + (script :src "https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js") + (script qr-script))) + +(defcomp ~events-ticket-admin-stat (&key border bg text-cls label-cls value label) + (div :class (str "rounded-xl border " border " " bg " p-4 text-center") + (div :class (str "text-2xl font-bold " text-cls) value) + (div :class (str "text-xs " label-cls " uppercase tracking-wide") label))) + +(defcomp ~events-ticket-admin-date (&key date-str) + (div :class "text-xs text-stone-500" date-str)) + +(defcomp ~events-ticket-admin-checkin-form (&key checkin-url code csrf) + (form :hx-post checkin-url :hx-target (str "#ticket-row-" code) :hx-swap "outerHTML" + (input :type "hidden" :name "csrf_token" :value csrf) + (button :type "submit" :class "px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition" + (i :class "fa fa-check mr-1" :aria-hidden "true") "Check in"))) + +(defcomp ~events-ticket-admin-checked-in (&key time-str) + (span :class "text-xs text-blue-600" + (i :class "fa fa-check-circle" :aria-hidden "true") (str " " time-str))) + +(defcomp ~events-ticket-admin-row (&key code code-short entry-name date-html type-name badge-html action-html) + (tr :class "hover:bg-stone-50 transition" :id (str "ticket-row-" code) + (td :class "px-4 py-3" (span :class "font-mono text-xs" code-short)) + (td :class "px-4 py-3" (div :class "font-medium" entry-name) (raw! date-html)) + (td :class "px-4 py-3 text-sm" type-name) + (td :class "px-4 py-3" (raw! badge-html)) + (td :class "px-4 py-3" (raw! action-html)))) + +(defcomp ~events-ticket-admin-panel (&key list-container stats-html lookup-url has-tickets rows-html) + (section :id "ticket-admin" :class list-container + (h1 :class "text-2xl font-bold mb-6" "Ticket Admin") + (div :class "grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8" (raw! stats-html)) + (div :class "rounded-xl border border-stone-200 bg-white p-6 mb-8" + (h2 :class "text-lg font-semibold mb-4" + (i :class "fa fa-qrcode mr-2" :aria-hidden "true") "Scan / Look Up Ticket") + (div :class "flex gap-3 mb-4" + (input :type "text" :id "ticket-code-input" :name "code" + :placeholder "Enter or scan ticket code..." + :class "flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + :hx-get lookup-url :hx-trigger "keyup changed delay:300ms" + :hx-target "#lookup-result" :hx-include "this" :autofocus "true") + (button :type "button" + :class "px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition" + :onclick "document.getElementById('ticket-code-input').dispatchEvent(new Event('keyup'))" + (i :class "fa fa-search" :aria-hidden "true"))) + (div :id "lookup-result" + (div :class "text-sm text-stone-400 text-center py-4" "Enter a ticket code to look it up"))) + (div :class "rounded-xl border border-stone-200 bg-white overflow-hidden" + (h2 :class "text-lg font-semibold px-6 py-4 border-b border-stone-100" "Recent Tickets") + (if has-tickets + (div :class "overflow-x-auto" + (table :class "w-full text-sm" + (thead :class "bg-stone-50" + (tr (th :class "px-4 py-3 text-left font-medium text-stone-600" "Code") + (th :class "px-4 py-3 text-left font-medium text-stone-600" "Event") + (th :class "px-4 py-3 text-left font-medium text-stone-600" "Type") + (th :class "px-4 py-3 text-left font-medium text-stone-600" "State") + (th :class "px-4 py-3 text-left font-medium text-stone-600" "Actions"))) + (tbody :class "divide-y divide-stone-100" (raw! rows-html)))) + (div :class "px-6 py-8 text-center text-stone-500" "No tickets yet"))))) + +(defcomp ~events-checkin-error (&key message) + (div :class "rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800" + (i :class "fa fa-exclamation-circle mr-2" :aria-hidden "true") message)) + +(defcomp ~events-checkin-success-row (&key code code-short entry-name date-html type-name badge-html time-str) + (tr :class "bg-blue-50" :id (str "ticket-row-" code) + (td :class "px-4 py-3" (span :class "font-mono text-xs" code-short)) + (td :class "px-4 py-3" (div :class "font-medium" entry-name) (raw! date-html)) + (td :class "px-4 py-3 text-sm" type-name) + (td :class "px-4 py-3" (raw! badge-html)) + (td :class "px-4 py-3" + (span :class "text-xs text-blue-600" + (i :class "fa fa-check-circle" :aria-hidden "true") (str " " time-str))))) + +(defcomp ~events-lookup-error (&key message) + (div :class "rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800" + (i :class "fa fa-exclamation-circle mr-2" :aria-hidden "true") message)) + +(defcomp ~events-lookup-info (&key entry-name) + (div :class "font-semibold text-lg" entry-name)) + +(defcomp ~events-lookup-type (&key type-name) + (div :class "text-sm text-stone-600" type-name)) + +(defcomp ~events-lookup-date (&key date-str) + (div :class "text-sm text-stone-500 mt-1" date-str)) + +(defcomp ~events-lookup-cal (&key cal-name) + (div :class "text-xs text-stone-400 mt-0.5" cal-name)) + +(defcomp ~events-lookup-status (&key badge-html code) + (div :class "mt-2" (raw! badge-html) (span :class "text-xs text-stone-400 ml-2 font-mono" code))) + +(defcomp ~events-lookup-checkin-time (&key date-str) + (div :class "text-xs text-blue-600 mt-1" (str "Checked in: " date-str))) + +(defcomp ~events-lookup-checkin-btn (&key checkin-url code csrf) + (form :hx-post checkin-url :hx-target (str "#checkin-action-" code) :hx-swap "innerHTML" + (input :type "hidden" :name "csrf_token" :value csrf) + (button :type "submit" + :class "px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition font-semibold text-lg" + (i :class "fa fa-check mr-2" :aria-hidden "true") "Check In"))) + +(defcomp ~events-lookup-checked-in () + (div :class "text-blue-600 text-center" + (i :class "fa fa-check-circle text-3xl" :aria-hidden "true") + (div :class "text-sm font-medium mt-1" "Checked In"))) + +(defcomp ~events-lookup-cancelled () + (div :class "text-red-600 text-center" + (i :class "fa fa-times-circle text-3xl" :aria-hidden "true") + (div :class "text-sm font-medium mt-1" "Cancelled"))) + +(defcomp ~events-lookup-card (&key info-html code action-html) + (div :class "rounded-lg border border-stone-200 bg-stone-50 p-4" + (div :class "flex items-start justify-between gap-4" + (div :class "flex-1" (raw! info-html)) + (div :id (str "checkin-action-" code) (raw! action-html))))) + +(defcomp ~events-entry-tickets-admin-row (&key code code-short type-name badge-html action-html) + (tr :class "hover:bg-stone-50" :id (str "entry-ticket-row-" code) + (td :class "px-4 py-2 font-mono text-xs" code-short) + (td :class "px-4 py-2" type-name) + (td :class "px-4 py-2" (raw! badge-html)) + (td :class "px-4 py-2" (raw! action-html)))) + +(defcomp ~events-entry-tickets-admin-checkin (&key checkin-url code csrf) + (form :hx-post checkin-url :hx-target (str "#entry-ticket-row-" code) :hx-swap "outerHTML" + (input :type "hidden" :name "csrf_token" :value csrf) + (button :type "submit" :class "px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700" + "Check in"))) + +(defcomp ~events-entry-tickets-admin-table (&key rows-html) + (div :class "overflow-x-auto rounded-xl border border-stone-200" + (table :class "w-full text-sm" + (thead :class "bg-stone-50" + (tr (th :class "px-4 py-2 text-left font-medium text-stone-600" "Code") + (th :class "px-4 py-2 text-left font-medium text-stone-600" "Type") + (th :class "px-4 py-2 text-left font-medium text-stone-600" "State") + (th :class "px-4 py-2 text-left font-medium text-stone-600" "Actions"))) + (tbody :class "divide-y divide-stone-100" (raw! rows-html))))) + +(defcomp ~events-entry-tickets-admin-empty () + (div :class "text-center py-6 text-stone-500 text-sm" "No tickets for this entry")) + +(defcomp ~events-entry-tickets-admin-panel (&key entry-name count-label body-html) + (div :class "space-y-4" + (div :class "flex items-center justify-between" + (h3 :class "text-lg font-semibold" (str "Tickets for: " entry-name)) + (span :class "text-sm text-stone-500" count-label)) + (raw! body-html))) diff --git a/federation/bp/social/routes.py b/federation/bp/social/routes.py index 06cb2bf..6aa0712 100644 --- a/federation/bp/social/routes.py +++ b/federation/bp/social/routes.py @@ -422,8 +422,9 @@ def register(url_prefix="/social"): return Response("0", content_type="text/plain") count = await services.federation.unread_notification_count(g.s, actor.id) if count > 0: + from shared.sexp.jinja_bridge import render as render_comp return Response( - f'{count}', + render_comp("notification-badge", count=str(count)), content_type="text/html", ) return Response("", content_type="text/html") diff --git a/federation/sexp/auth.sexpr b/federation/sexp/auth.sexpr new file mode 100644 index 0000000..d392b38 --- /dev/null +++ b/federation/sexp/auth.sexpr @@ -0,0 +1,52 @@ +;; Auth components (login, check email, choose username) + +(defcomp ~federation-error-banner (&key error) + (div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" (raw! error))) + +(defcomp ~federation-login-form (&key error-html action csrf email) + (div :class "py-8 max-w-md mx-auto" + (h1 :class "text-2xl font-bold mb-6" "Sign in") + (raw! error-html) + (form :method "post" :action action :class "space-y-4" + (input :type "hidden" :name "csrf_token" :value csrf) + (div + (label :for "email" :class "block text-sm font-medium mb-1" "Email address") + (input :type "email" :name "email" :id "email" :value email :required true :autofocus true + :class "w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500")) + (button :type "submit" + :class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition" + "Send magic link")))) + +(defcomp ~federation-check-email-error (&key error) + (div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4" (raw! error))) + +(defcomp ~federation-check-email (&key email error-html) + (div :class "py-8 max-w-md mx-auto text-center" + (h1 :class "text-2xl font-bold mb-4" "Check your email") + (p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong (raw! email)) ".") + (p :class "text-stone-500 text-sm" "Click the link in the email to sign in. The link expires in 15 minutes.") + (raw! error-html))) + +(defcomp ~federation-choose-username (&key domain error-html csrf username check-url) + (div :class "py-8 max-w-md mx-auto" + (h1 :class "text-2xl font-bold mb-2" "Choose your username") + (p :class "text-stone-600 mb-6" "This will be your identity on the fediverse: " + (strong "@username@" (raw! domain))) + (raw! error-html) + (form :method "post" :class "space-y-4" + (input :type "hidden" :name "csrf_token" :value csrf) + (div + (label :for "username" :class "block text-sm font-medium mb-1" "Username") + (div :class "flex items-center" + (span :class "text-stone-400 mr-1" "@") + (input :type "text" :name "username" :id "username" :value username + :pattern "[a-z][a-z0-9_]{2,31}" :minlength "3" :maxlength "32" + :required true :autocomplete "off" + :class "flex-1 border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500" + :hx-get check-url :hx-trigger "keyup changed delay:300ms" :hx-target "#username-status" + :hx-include "[name='username']")) + (div :id "username-status" :class "text-sm mt-1") + (p :class "text-xs text-stone-400 mt-1" "3-32 characters. Lowercase letters, numbers, underscores. Must start with a letter.")) + (button :type "submit" + :class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition" + "Claim username")))) diff --git a/federation/sexp/notifications.sexpr b/federation/sexp/notifications.sexpr new file mode 100644 index 0000000..cb3a7bd --- /dev/null +++ b/federation/sexp/notifications.sexpr @@ -0,0 +1,25 @@ +;; Notification components + +(defcomp ~federation-notification-preview (&key preview) + (div :class "text-sm text-stone-500 mt-1 truncate" (raw! preview))) + +(defcomp ~federation-notification-card (&key cls avatar-html from-name from-username from-domain action-text preview-html time-html) + (div :class cls + (div :class "flex items-start gap-3" + (raw! avatar-html) + (div :class "flex-1" + (div :class "text-sm" + (span :class "font-semibold" (raw! from-name)) + " " (span :class "text-stone-500" "@" (raw! from-username) (raw! from-domain)) + " " (span :class "text-stone-600" (raw! action-text))) + (raw! preview-html) + (div :class "text-xs text-stone-400 mt-1" (raw! time-html)))))) + +(defcomp ~federation-notifications-empty () + (p :class "text-stone-500" "No notifications yet.")) + +(defcomp ~federation-notifications-list (&key items-html) + (div :class "space-y-2" (raw! items-html))) + +(defcomp ~federation-notifications-page (&key notifs-html) + (h1 :class "text-2xl font-bold mb-6" "Notifications") (raw! notifs-html)) diff --git a/federation/sexp/profile.sexpr b/federation/sexp/profile.sexpr new file mode 100644 index 0000000..58836ad --- /dev/null +++ b/federation/sexp/profile.sexpr @@ -0,0 +1,55 @@ +;; Profile and actor timeline components + +(defcomp ~federation-actor-profile-header (&key avatar-html display-name username domain summary-html follow-html) + (div :class "bg-white rounded-lg shadow-sm border border-stone-200 p-6 mb-6" + (div :class "flex items-center gap-4" + (raw! avatar-html) + (div :class "flex-1" + (h1 :class "text-xl font-bold" (raw! display-name)) + (div :class "text-stone-500" "@" (raw! username) "@" (raw! domain)) + (raw! summary-html)) + (raw! follow-html)))) + +(defcomp ~federation-actor-timeline-layout (&key header-html timeline-html) + (raw! header-html) + (div :id "timeline" (raw! timeline-html))) + +(defcomp ~federation-follow-form (&key action csrf actor-url label cls) + (div :class "flex-shrink-0" + (form :method "post" :action action + (input :type "hidden" :name "csrf_token" :value csrf) + (input :type "hidden" :name "actor_url" :value actor-url) + (button :type "submit" :class cls (raw! label))))) + +(defcomp ~federation-profile-summary (&key summary) + (div :class "text-sm text-stone-600 mt-2" (raw! summary))) + +;; Public profile page + +(defcomp ~federation-activity-obj-type (&key obj-type) + (span :class "text-sm text-stone-500" (raw! obj-type))) + +(defcomp ~federation-activity-card (&key activity-type published obj-type-html) + (div :class "bg-white rounded-lg shadow p-4" + (div :class "flex justify-between items-start" + (span :class "font-medium" (raw! activity-type)) + (span :class "text-sm text-stone-400" (raw! published))) + (raw! obj-type-html))) + +(defcomp ~federation-activities-list (&key items-html) + (div :class "space-y-4" (raw! items-html))) + +(defcomp ~federation-activities-empty () + (p :class "text-stone-500" "No activities yet.")) + +(defcomp ~federation-profile-page (&key display-name username domain summary-html activities-heading activities-html) + (div :class "py-8" + (div :class "bg-white rounded-lg shadow p-6 mb-6" + (h1 :class "text-2xl font-bold" (raw! display-name)) + (p :class "text-stone-500" "@" (raw! username) "@" (raw! domain)) + (raw! summary-html)) + (h2 :class "text-xl font-bold mb-4" (raw! activities-heading)) + (raw! activities-html))) + +(defcomp ~federation-profile-summary-text (&key text) + (p :class "mt-2" (raw! text))) diff --git a/federation/sexp/search.sexpr b/federation/sexp/search.sexpr new file mode 100644 index 0000000..d1877ed --- /dev/null +++ b/federation/sexp/search.sexpr @@ -0,0 +1,61 @@ +;; Search and actor card components + +(defcomp ~federation-actor-avatar-img (&key src cls) + (img :src src :alt "" :class cls)) + +(defcomp ~federation-actor-avatar-placeholder (&key cls initial) + (div :class cls (raw! initial))) + +(defcomp ~federation-actor-name-link (&key href name) + (a :href href :class "font-semibold text-stone-900 hover:underline" (raw! name))) + +(defcomp ~federation-actor-name-link-external (&key href name) + (a :href href :target "_blank" :rel "noopener" + :class "font-semibold text-stone-900 hover:underline" (raw! name))) + +(defcomp ~federation-actor-summary (&key summary) + (div :class "text-sm text-stone-600 mt-1 truncate" (raw! summary))) + +(defcomp ~federation-unfollow-button (&key action csrf actor-url) + (div :class "flex-shrink-0" + (form :method "post" :action action :hx-post action :hx-target "closest article" :hx-swap "outerHTML" + (input :type "hidden" :name "csrf_token" :value csrf) + (input :type "hidden" :name "actor_url" :value actor-url) + (button :type "submit" :class "text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100" "Unfollow")))) + +(defcomp ~federation-follow-button (&key action csrf actor-url label) + (div :class "flex-shrink-0" + (form :method "post" :action action :hx-post action :hx-target "closest article" :hx-swap "outerHTML" + (input :type "hidden" :name "csrf_token" :value csrf) + (input :type "hidden" :name "actor_url" :value actor-url) + (button :type "submit" :class "text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700" (raw! label))))) + +(defcomp ~federation-actor-card (&key cls id avatar-html name-html username domain summary-html button-html) + (article :class cls :id id + (raw! avatar-html) + (div :class "flex-1 min-w-0" + (raw! name-html) + (div :class "text-sm text-stone-500" "@" (raw! username) "@" (raw! domain)) + (raw! summary-html)) + (raw! button-html))) + +(defcomp ~federation-search-info (&key cls text) + (p :class cls (raw! text))) + +(defcomp ~federation-search-page (&key search-url search-page-url query info-html results-html) + (h1 :class "text-2xl font-bold mb-6" "Search") + (form :method "get" :action search-url :class "mb-6" + :hx-get search-page-url :hx-target "#search-results" :hx-push-url search-url + (div :class "flex gap-2" + (input :type "text" :name "q" :value query + :class "flex-1 border border-stone-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500" + :placeholder "Search users or @user@instance.tld") + (button :type "submit" :class "bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700" "Search"))) + (raw! info-html) + (div :id "search-results" (raw! results-html))) + +;; Following / Followers list page +(defcomp ~federation-actor-list-page (&key title count-str items-html) + (h1 :class "text-2xl font-bold mb-6" (raw! title) " " + (span :class "text-stone-400 font-normal" (raw! count-str))) + (div :id "actor-list" (raw! items-html))) diff --git a/federation/sexp/sexp_components.py b/federation/sexp/sexp_components.py index 7907648..91791cf 100644 --- a/federation/sexp/sexp_components.py +++ b/federation/sexp/sexp_components.py @@ -6,12 +6,16 @@ actor profiles, login, and username selection pages. """ from __future__ import annotations +import os from typing import Any from markupsafe import escape -from shared.sexp.jinja_bridge import sexp +from shared.sexp.jinja_bridge import render, load_service_components from shared.sexp.helpers import root_header_html, full_page +# Load federation-specific .sexpr components at import time +load_service_components(os.path.dirname(os.path.dirname(__file__))) + # --------------------------------------------------------------------------- # Social header nav @@ -23,11 +27,7 @@ def _social_nav_html(actor: Any) -> str: if not actor: choose_url = url_for("identity.choose_username_form") - return sexp( - '(nav :class "flex gap-3 text-sm items-center"' - ' (a :href url :class "px-2 py-1 rounded hover:bg-stone-200 font-bold" "Choose username"))', - url=choose_url, - ) + return render("federation-nav-choose-username", url=choose_url) links = [ ("social.home_timeline", "Timeline"), @@ -42,8 +42,8 @@ def _social_nav_html(actor: Any) -> str: for endpoint, label in links: href = url_for(endpoint) bold = " font-bold" if request.path == href else "" - parts.append(sexp( - '(a :href href :class cls (raw! label))', + parts.append(render( + "federation-nav-link", href=href, cls=f"px-2 py-1 rounded hover:bg-stone-200{bold}", label=label, @@ -53,47 +53,38 @@ def _social_nav_html(actor: Any) -> str: notif_url = url_for("social.notifications") notif_count_url = url_for("social.notification_count") notif_bold = " font-bold" if request.path == notif_url else "" - parts.append(sexp( - '(a :href href :class cls "Notifications"' - ' (span :hx-get count-url :hx-trigger "load, every 30s" :hx-swap "innerHTML"' - ' :class "absolute -top-2 -right-3 text-xs bg-red-500 text-white rounded-full px-1 empty:hidden"))', - href=notif_url, cls=f"px-2 py-1 rounded hover:bg-stone-200 relative{notif_bold}", - **{"count-url": notif_count_url}, + parts.append(render( + "federation-nav-notification-link", + href=notif_url, + cls=f"px-2 py-1 rounded hover:bg-stone-200 relative{notif_bold}", + count_url=notif_count_url, )) # Profile link profile_url = url_for("activitypub.actor_profile", username=actor.preferred_username) - parts.append(sexp( - '(a :href href :class "px-2 py-1 rounded hover:bg-stone-200" (raw! label))', - href=profile_url, label=f"@{actor.preferred_username}", + parts.append(render( + "federation-nav-link", + href=profile_url, + cls="px-2 py-1 rounded hover:bg-stone-200", + label=f"@{actor.preferred_username}", )) - return sexp( - '(nav :class "flex gap-3 text-sm items-center flex-wrap" (raw! items))', - items="".join(parts), - ) + return render("federation-nav-bar", items_html="".join(parts)) def _social_header_html(actor: Any) -> str: """Build the social section header row.""" nav_html = _social_nav_html(actor) - return sexp( - '(div :id "social-row" :class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-400"' - ' (div :class "w-full flex flex-row items-center gap-2 flex-wrap" (raw! nh)))', - nh=nav_html, - ) + return render("federation-social-header", nav_html=nav_html) def _social_page(ctx: dict, actor: Any, *, content_html: str, title: str = "Rose Ash", meta_html: str = "") -> str: """Render a social page with header and content.""" hdr = root_header_html(ctx) - hdr += sexp( - '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! sh))', - sh=_social_header_html(actor), - ) + hdr += render("federation-header-child", inner_html=_social_header_html(actor)) return full_page(ctx, header_rows_html=hdr, content_html=content_html, - meta_html=meta_html or sexp('(title (raw! t))', t=escape(title))) + meta_html=meta_html or f'{escape(title)}') # --------------------------------------------------------------------------- @@ -133,37 +124,25 @@ def _interaction_buttons_html(item: Any, actor: Any) -> str: boost_cls = "hover:text-green-600" reply_url = url_for("social.compose_form", reply_to=oid) if oid else "" - reply_html = sexp( - '(a :href url :class "hover:text-stone-700" "Reply")', - url=reply_url, - ) if reply_url else "" + reply_html = render("federation-reply-link", url=reply_url) if reply_url else "" - like_form = sexp( - '(form :hx-post action :hx-target target :hx-swap "innerHTML"' - ' (input :type "hidden" :name "object_id" :value oid)' - ' (input :type "hidden" :name "author_inbox" :value ainbox)' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (button :type "submit" :class cls (span (raw! icon)) " " (raw! count)))', + like_form = render( + "federation-like-form", action=like_action, target=target, oid=oid, ainbox=ainbox, csrf=csrf, cls=f"flex items-center gap-1 {like_cls}", icon=like_icon, count=str(lcount), ) - boost_form = sexp( - '(form :hx-post action :hx-target target :hx-swap "innerHTML"' - ' (input :type "hidden" :name "object_id" :value oid)' - ' (input :type "hidden" :name "author_inbox" :value ainbox)' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (button :type "submit" :class cls (span "\u21bb") " " (raw! count)))', + boost_form = render( + "federation-boost-form", action=boost_action, target=target, oid=oid, ainbox=ainbox, csrf=csrf, cls=f"flex items-center gap-1 {boost_cls}", count=str(bcount), ) - return sexp( - '(div :class "flex items-center gap-4 mt-3 text-sm text-stone-500"' - ' (raw! like) (raw! boost) (raw! reply))', - like=like_form, boost=boost_form, reply=reply_html, + return render( + "federation-interaction-buttons", + like_html=like_form, boost_html=boost_form, reply_html=reply_html, ) @@ -180,74 +159,53 @@ def _post_card_html(item: Any, actor: Any) -> str: url = getattr(item, "url", None) post_type = getattr(item, "post_type", "") - boost_html = sexp( - '(div :class "text-sm text-stone-500 mb-2" "Boosted by " (raw! name))', - name=str(escape(boosted_by)), + boost_html = render( + "federation-boost-label", name=str(escape(boosted_by)), ) if boosted_by else "" if actor_icon: - avatar = sexp( - '(img :src src :alt "" :class "w-10 h-10 rounded-full")', - src=actor_icon, - ) + avatar = render("federation-avatar-img", src=actor_icon, cls="w-10 h-10 rounded-full") else: initial = actor_name[0].upper() if actor_name else "?" - avatar = sexp( - '(div :class "w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm" (raw! i))', - i=initial, + avatar = render( + "federation-avatar-placeholder", + cls="w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm", + initial=initial, ) domain_html = f"@{escape(actor_domain)}" if actor_domain else "" time_html = published.strftime("%b %d, %H:%M") if published else "" if summary: - content_html = sexp( - '(details :class "mt-2"' - ' (summary :class "text-stone-500 cursor-pointer" "CW: " (raw! s))' - ' (div :class "mt-2 prose prose-sm prose-stone max-w-none" (raw! c)))', - s=str(escape(summary)), c=content, + content_html = render( + "federation-content-cw", + summary=str(escape(summary)), content=content, ) else: - content_html = sexp( - '(div :class "mt-2 prose prose-sm prose-stone max-w-none" (raw! c))', - c=content, - ) + content_html = render("federation-content-plain", content=content) original_html = "" if url and post_type == "remote": - original_html = sexp( - '(a :href url :target "_blank" :rel "noopener"' - ' :class "text-sm text-stone-400 hover:underline mt-1 inline-block" "original")', - url=url, - ) + original_html = render("federation-original-link", url=url) interactions_html = "" if actor: oid = getattr(item, "object_id", "") or "" safe_id = oid.replace("/", "_").replace(":", "_") - interactions_html = sexp( - '(div :id id (raw! buttons))', + interactions_html = render( + "federation-interactions-wrap", id=f"interactions-{safe_id}", - buttons=_interaction_buttons_html(item, actor), + buttons_html=_interaction_buttons_html(item, actor), ) - return sexp( - '(article :class "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4"' - ' (raw! boost)' - ' (div :class "flex items-start gap-3"' - ' (raw! avatar)' - ' (div :class "flex-1 min-w-0"' - ' (div :class "flex items-baseline gap-2"' - ' (span :class "font-semibold text-stone-900" (raw! aname))' - ' (span :class "text-sm text-stone-500" "@" (raw! ausername) (raw! domain))' - ' (span :class "text-sm text-stone-400 ml-auto" (raw! time)))' - ' (raw! content) (raw! original) (raw! interactions))))', - boost=boost_html, avatar=avatar, - aname=str(escape(actor_name)), - ausername=str(escape(actor_username)), - domain=domain_html, time=time_html, - content=content_html, original=original_html, - interactions=interactions_html, + return render( + "federation-post-card", + boost_html=boost_html, avatar_html=avatar, + actor_name=str(escape(actor_name)), + actor_username=str(escape(actor_username)), + domain_html=domain_html, time_html=time_html, + content_html=content_html, original_html=original_html, + interactions_html=interactions_html, ) @@ -269,10 +227,7 @@ def _timeline_items_html(items: list, timeline_type: str, actor: Any, next_url = url_for("social.actor_timeline_page", id=actor_id, before=before) else: next_url = url_for(f"social.{timeline_type}_timeline_page", before=before) - parts.append(sexp( - '(div :hx-get url :hx-trigger "revealed" :hx-swap "outerHTML")', - url=next_url, - )) + parts.append(render("federation-scroll-sentinel", url=next_url)) return "".join(parts) @@ -299,75 +254,54 @@ def _actor_card_html(a: Any, actor: Any, followed_urls: set, safe_id = actor_url.replace("/", "_").replace(":", "_") if icon_url: - avatar = sexp( - '(img :src src :alt "" :class "w-12 h-12 rounded-full")', - src=icon_url, - ) + avatar = render("federation-actor-avatar-img", src=icon_url, cls="w-12 h-12 rounded-full") else: initial = (display_name or username)[0].upper() if (display_name or username) else "?" - avatar = sexp( - '(div :class "w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold" (raw! i))', - i=initial, + avatar = render( + "federation-actor-avatar-placeholder", + cls="w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold", + initial=initial, ) # Name link if (list_type in ("following", "search")) and aid: - name_html = sexp( - '(a :href href :class "font-semibold text-stone-900 hover:underline" (raw! name))', + name_html = render( + "federation-actor-name-link", href=url_for("social.actor_timeline", id=aid), name=str(escape(display_name)), ) else: - name_html = sexp( - '(a :href href :target "_blank" :rel "noopener"' - ' :class "font-semibold text-stone-900 hover:underline" (raw! name))', + name_html = render( + "federation-actor-name-link-external", href=f"https://{domain}/@{username}", name=str(escape(display_name)), ) - summary_html = sexp( - '(div :class "text-sm text-stone-600 mt-1 truncate" (raw! s))', - s=summary, - ) if summary else "" + summary_html = render("federation-actor-summary", summary=summary) if summary else "" # Follow/unfollow button button_html = "" if actor: is_followed = actor_url in (followed_urls or set()) if list_type == "following" or is_followed: - button_html = sexp( - '(div :class "flex-shrink-0"' - ' (form :method "post" :action action :hx-post action :hx-target "closest article" :hx-swap "outerHTML"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (input :type "hidden" :name "actor_url" :value aurl)' - ' (button :type "submit" :class "text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100" "Unfollow")))', - action=url_for("social.unfollow"), csrf=csrf, aurl=actor_url, + button_html = render( + "federation-unfollow-button", + action=url_for("social.unfollow"), csrf=csrf, actor_url=actor_url, ) else: label = "Follow Back" if list_type == "followers" else "Follow" - button_html = sexp( - '(div :class "flex-shrink-0"' - ' (form :method "post" :action action :hx-post action :hx-target "closest article" :hx-swap "outerHTML"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (input :type "hidden" :name "actor_url" :value aurl)' - ' (button :type "submit" :class "text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700" (raw! label))))', - action=url_for("social.follow"), csrf=csrf, aurl=actor_url, label=label, + button_html = render( + "federation-follow-button", + action=url_for("social.follow"), csrf=csrf, actor_url=actor_url, label=label, ) - return sexp( - '(article :class cls :id id' - ' (raw! avatar)' - ' (div :class "flex-1 min-w-0"' - ' (raw! name-link)' - ' (div :class "text-sm text-stone-500" "@" (raw! username) "@" (raw! domain))' - ' (raw! summary))' - ' (raw! button))', + return render( + "federation-actor-card", cls="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4", id=f"actor-{safe_id}", - avatar=avatar, - **{"name-link": name_html}, + avatar_html=avatar, name_html=name_html, username=str(escape(username)), domain=str(escape(domain)), - summary=summary_html, button=button_html, + summary_html=summary_html, button_html=button_html, ) @@ -379,10 +313,7 @@ def _search_results_html(actors: list, query: str, page: int, parts = [_actor_card_html(a, actor, followed_urls, list_type="search") for a in actors] if len(actors) >= 20: next_url = url_for("social.search_page", q=query, page=page + 1) - parts.append(sexp( - '(div :hx-get url :hx-trigger "revealed" :hx-swap "outerHTML")', - url=next_url, - )) + parts.append(render("federation-scroll-sentinel", url=next_url)) return "".join(parts) @@ -394,10 +325,7 @@ def _actor_list_items_html(actors: list, page: int, list_type: str, parts = [_actor_card_html(a, actor, followed_urls, list_type=list_type) for a in actors] if len(actors) >= 20: next_url = url_for(f"social.{list_type}_list_page", page=page + 1) - parts.append(sexp( - '(div :hx-get url :hx-trigger "revealed" :hx-swap "outerHTML")', - url=next_url, - )) + parts.append(render("federation-scroll-sentinel", url=next_url)) return "".join(parts) @@ -420,15 +348,13 @@ def _notification_html(notif: Any) -> str: border = " border-l-4 border-l-stone-400" if not read else "" if from_icon: - avatar = sexp( - '(img :src src :alt "" :class "w-8 h-8 rounded-full")', - src=from_icon, - ) + avatar = render("federation-avatar-img", src=from_icon, cls="w-8 h-8 rounded-full") else: initial = from_name[0].upper() if from_name else "?" - avatar = sexp( - '(div :class "w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs" (raw! i))', - i=initial, + avatar = render( + "federation-avatar-placeholder", + cls="w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs", + initial=initial, ) domain_html = f"@{escape(from_domain)}" if from_domain else "" @@ -444,29 +370,19 @@ def _notification_html(notif: Any) -> str: if ntype == "follow" and app_domain and app_domain != "federation": action += f" on {escape(app_domain)}" - preview_html = sexp( - '(div :class "text-sm text-stone-500 mt-1 truncate" (raw! p))', - p=str(escape(preview)), + preview_html = render( + "federation-notification-preview", preview=str(escape(preview)), ) if preview else "" time_html = created.strftime("%b %d, %H:%M") if created else "" - return sexp( - '(div :class cls' - ' (div :class "flex items-start gap-3"' - ' (raw! avatar)' - ' (div :class "flex-1"' - ' (div :class "text-sm"' - ' (span :class "font-semibold" (raw! fname))' - ' " " (span :class "text-stone-500" "@" (raw! fusername) (raw! fdomain))' - ' " " (span :class "text-stone-600" (raw! action)))' - ' (raw! preview)' - ' (div :class "text-xs text-stone-400 mt-1" (raw! time)))))', + return render( + "federation-notification-card", cls=f"bg-white rounded-lg shadow-sm border border-stone-200 p-4{border}", - avatar=avatar, - fname=str(escape(from_name)), - fusername=str(escape(from_username)), - fdomain=domain_html, action=action, - preview=preview_html, time=time_html, + avatar_html=avatar, + from_name=str(escape(from_name)), + from_username=str(escape(from_username)), + from_domain=domain_html, action_text=action, + preview_html=preview_html, time_html=time_html, ) @@ -494,25 +410,11 @@ async def render_login_page(ctx: dict) -> str: action = url_for("auth.start_login") csrf = generate_csrf_token() - error_html = sexp( - '(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" (raw! e))', - e=error, - ) if error else "" + error_html = render("federation-error-banner", error=error) if error else "" - content = sexp( - '(div :class "py-8 max-w-md mx-auto"' - ' (h1 :class "text-2xl font-bold mb-6" "Sign in")' - ' (raw! err)' - ' (form :method "post" :action action :class "space-y-4"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (div' - ' (label :for "email" :class "block text-sm font-medium mb-1" "Email address")' - ' (input :type "email" :name "email" :id "email" :value email :required true :autofocus true' - ' :class "w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"))' - ' (button :type "submit"' - ' :class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"' - ' "Send magic link")))', - err=error_html, action=action, csrf=csrf, + content = render( + "federation-login-form", + error_html=error_html, action=action, csrf=csrf, email=str(escape(email)), ) @@ -526,18 +428,13 @@ async def render_check_email_page(ctx: dict) -> str: email = ctx.get("email", "") email_error = ctx.get("email_error") - error_html = sexp( - '(div :class "bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4" (raw! e))', - e=str(escape(email_error)), + error_html = render( + "federation-check-email-error", error=str(escape(email_error)), ) if email_error else "" - content = sexp( - '(div :class "py-8 max-w-md mx-auto text-center"' - ' (h1 :class "text-2xl font-bold mb-4" "Check your email")' - ' (p :class "text-stone-600 mb-2" "We sent a sign-in link to " (strong (raw! email)) ".")' - ' (p :class "text-stone-500 text-sm" "Click the link in the email to sign in. The link expires in 15 minutes.")' - ' (raw! err))', - email=str(escape(email)), err=error_html, + content = render( + "federation-check-email", + email=str(escape(email)), error_html=error_html, ) hdr = root_header_html(ctx) @@ -558,19 +455,13 @@ async def render_timeline_page(ctx: dict, items: list, timeline_type: str, compose_html = "" if actor: compose_url = url_for("social.compose_form") - compose_html = sexp( - '(a :href url :class "bg-stone-800 text-white px-4 py-2 rounded hover:bg-stone-700" "Compose")', - url=compose_url, - ) + compose_html = render("federation-compose-button", url=compose_url) timeline_html = _timeline_items_html(items, timeline_type, actor) - content = sexp( - '(div :class "flex items-center justify-between mb-6"' - ' (h1 :class "text-2xl font-bold" (raw! label) " Timeline")' - ' (raw! compose))' - '(div :id "timeline" (raw! tl))', - label=label, compose=compose_html, tl=timeline_html, + content = render( + "federation-timeline-page", + label=label, compose_html=compose_html, timeline_html=timeline_html, ) return _social_page(ctx, actor, content_html=content, @@ -597,27 +488,14 @@ async def render_compose_page(ctx: dict, actor: Any, reply_to: str | None) -> st reply_html = "" if reply_to: - reply_html = sexp( - '(input :type "hidden" :name "in_reply_to" :value val)' - '(div :class "text-sm text-stone-500" "Replying to " (span :class "font-mono" (raw! rt)))', - val=str(escape(reply_to)), rt=str(escape(reply_to)), + reply_html = render( + "federation-compose-reply", + reply_to=str(escape(reply_to)), ) - content = sexp( - '(h1 :class "text-2xl font-bold mb-6" "Compose")' - '(form :method "post" :action action :class "space-y-4"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (raw! reply)' - ' (textarea :name "content" :rows "6" :maxlength "5000" :required true' - ' :class "w-full border border-stone-300 rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-stone-500"' - ' :placeholder "What\'s on your mind?")' - ' (div :class "flex items-center justify-between"' - ' (select :name "visibility" :class "border border-stone-300 rounded px-3 py-1.5 text-sm"' - ' (option :value "public" "Public")' - ' (option :value "unlisted" "Unlisted")' - ' (option :value "followers" "Followers only"))' - ' (button :type "submit" :class "bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700" "Publish")))', - action=action, csrf=csrf, reply=reply_html, + content = render( + "federation-compose-form", + action=action, csrf=csrf, reply_html=reply_html, ) return _social_page(ctx, actor, content_html=content, @@ -641,30 +519,23 @@ async def render_search_page(ctx: dict, query: str, actors: list, total: int, info_html = "" if query and total: s = "s" if total != 1 else "" - info_html = sexp( - '(p :class "text-sm text-stone-500 mb-4" (raw! t))', - t=f"{total} result{s} for {escape(query)}", + info_html = render( + "federation-search-info", + cls="text-sm text-stone-500 mb-4", + text=f"{total} result{s} for {escape(query)}", ) elif query: - info_html = sexp( - '(p :class "text-stone-500 mb-4" (raw! t))', - t=f"No results found for {escape(query)}", + info_html = render( + "federation-search-info", + cls="text-stone-500 mb-4", + text=f"No results found for {escape(query)}", ) - content = sexp( - '(h1 :class "text-2xl font-bold mb-6" "Search")' - '(form :method "get" :action search-url :class "mb-6"' - ' :hx-get search-page-url :hx-target "#search-results" :hx-push-url search-url' - ' (div :class "flex gap-2"' - ' (input :type "text" :name "q" :value query' - ' :class "flex-1 border border-stone-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"' - ' :placeholder "Search users or @user@instance.tld")' - ' (button :type "submit" :class "bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700" "Search")))' - '(raw! info)' - '(div :id "search-results" (raw! results))', - **{"search-url": search_url, "search-page-url": search_page_url}, + content = render( + "federation-search-page", + search_url=search_url, search_page_url=search_page_url, query=str(escape(query)), - info=info_html, results=results_html, + info_html=info_html, results_html=results_html, ) return _social_page(ctx, actor, content_html=content, @@ -685,11 +556,9 @@ async def render_following_page(ctx: dict, actors: list, total: int, actor: Any) -> str: """Full page: following list.""" items_html = _actor_list_items_html(actors, 1, "following", set(), actor) - content = sexp( - '(h1 :class "text-2xl font-bold mb-6" "Following "' - ' (span :class "text-stone-400 font-normal" (raw! count-str)))' - '(div :id "actor-list" (raw! items))', - **{"count-str": f"({total})"}, items=items_html, + content = render( + "federation-actor-list-page", + title="Following", count_str=f"({total})", items_html=items_html, ) return _social_page(ctx, actor, content_html=content, title="Following \u2014 Rose Ash") @@ -704,11 +573,9 @@ async def render_followers_page(ctx: dict, actors: list, total: int, followed_urls: set, actor: Any) -> str: """Full page: followers list.""" items_html = _actor_list_items_html(actors, 1, "followers", followed_urls, actor) - content = sexp( - '(h1 :class "text-2xl font-bold mb-6" "Followers "' - ' (span :class "text-stone-400 font-normal" (raw! count-str)))' - '(div :id "actor-list" (raw! items))', - **{"count-str": f"({total})"}, items=items_html, + content = render( + "federation-actor-list-page", + title="Followers", count_str=f"({total})", items_html=items_html, ) return _social_page(ctx, actor, content_html=content, title="Followers \u2014 Rose Ash") @@ -737,61 +604,48 @@ async def render_actor_timeline_page(ctx: dict, remote_actor: Any, items: list, actor_url = getattr(remote_actor, "actor_url", "") if icon_url: - avatar = sexp( - '(img :src src :alt "" :class "w-16 h-16 rounded-full")', - src=icon_url, - ) + avatar = render("federation-avatar-img", src=icon_url, cls="w-16 h-16 rounded-full") else: initial = display_name[0].upper() if display_name else "?" - avatar = sexp( - '(div :class "w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl" (raw! i))', - i=initial, + avatar = render( + "federation-avatar-placeholder", + cls="w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl", + initial=initial, ) - summary_html = sexp( - '(div :class "text-sm text-stone-600 mt-2" (raw! s))', - s=summary, - ) if summary else "" + summary_html = render("federation-profile-summary", summary=summary) if summary else "" follow_html = "" if actor: if is_following: - follow_html = sexp( - '(div :class "flex-shrink-0"' - ' (form :method "post" :action action' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (input :type "hidden" :name "actor_url" :value aurl)' - ' (button :type "submit" :class "border border-stone-300 rounded px-4 py-2 hover:bg-stone-100" "Unfollow")))', - action=url_for("social.unfollow"), csrf=csrf, aurl=actor_url, + follow_html = render( + "federation-follow-form", + action=url_for("social.unfollow"), csrf=csrf, actor_url=actor_url, + label="Unfollow", + cls="border border-stone-300 rounded px-4 py-2 hover:bg-stone-100", ) else: - follow_html = sexp( - '(div :class "flex-shrink-0"' - ' (form :method "post" :action action' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (input :type "hidden" :name "actor_url" :value aurl)' - ' (button :type "submit" :class "bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700" "Follow")))', - action=url_for("social.follow"), csrf=csrf, aurl=actor_url, + follow_html = render( + "federation-follow-form", + action=url_for("social.follow"), csrf=csrf, actor_url=actor_url, + label="Follow", + cls="bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700", ) timeline_html = _timeline_items_html(items, "actor", actor, remote_actor.id) - content = sexp( - '(div :class "bg-white rounded-lg shadow-sm border border-stone-200 p-6 mb-6"' - ' (div :class "flex items-center gap-4"' - ' (raw! avatar)' - ' (div :class "flex-1"' - ' (h1 :class "text-xl font-bold" (raw! dname))' - ' (div :class "text-stone-500" "@" (raw! username) "@" (raw! domain))' - ' (raw! summary))' - ' (raw! follow)))' - '(div :id "timeline" (raw! tl))', - avatar=avatar, - dname=str(escape(display_name)), + header_html = render( + "federation-actor-profile-header", + avatar_html=avatar, + display_name=str(escape(display_name)), username=str(escape(remote_actor.preferred_username)), domain=str(escape(remote_actor.domain)), - summary=summary_html, follow=follow_html, - tl=timeline_html, + summary_html=summary_html, follow_html=follow_html, + ) + + content = render( + "federation-actor-timeline-layout", + header_html=header_html, timeline_html=timeline_html, ) return _social_page(ctx, actor, content_html=content, @@ -812,17 +666,14 @@ async def render_notifications_page(ctx: dict, notifications: list, actor: Any) -> str: """Full page: notifications.""" if not notifications: - notif_html = sexp('(p :class "text-stone-500" "No notifications yet.")') + notif_html = render("federation-notifications-empty") else: - notif_html = sexp( - '(div :class "space-y-2" (raw! items))', - items="".join(_notification_html(n) for n in notifications), + notif_html = render( + "federation-notifications-list", + items_html="".join(_notification_html(n) for n in notifications), ) - content = sexp( - '(h1 :class "text-2xl font-bold mb-6" "Notifications") (raw! notifs)', - notifs=notif_html, - ) + content = render("federation-notifications-page", notifs_html=notif_html) return _social_page(ctx, actor, content_html=content, title="Notifications \u2014 Rose Ash") @@ -844,37 +695,13 @@ async def render_choose_username_page(ctx: dict) -> str: check_url = url_for("identity.check_username") actor = ctx.get("actor") - error_html = sexp( - '(div :class "bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4" (raw! e))', - e=error, - ) if error else "" + error_html = render("federation-error-banner", error=error) if error else "" - content = sexp( - '(div :class "py-8 max-w-md mx-auto"' - ' (h1 :class "text-2xl font-bold mb-2" "Choose your username")' - ' (p :class "text-stone-600 mb-6" "This will be your identity on the fediverse: "' - ' (strong "@username@" (raw! domain)))' - ' (raw! err)' - ' (form :method "post" :class "space-y-4"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (div' - ' (label :for "username" :class "block text-sm font-medium mb-1" "Username")' - ' (div :class "flex items-center"' - ' (span :class "text-stone-400 mr-1" "@")' - ' (input :type "text" :name "username" :id "username" :value uname' - ' :pattern "[a-z][a-z0-9_]{2,31}" :minlength "3" :maxlength "32"' - ' :required true :autocomplete "off"' - ' :class "flex-1 border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"' - ' :hx-get check-url :hx-trigger "keyup changed delay:300ms" :hx-target "#username-status"' - ' :hx-include "[name=\'username\']"))' - ' (div :id "username-status" :class "text-sm mt-1")' - ' (p :class "text-xs text-stone-400 mt-1" "3-32 characters. Lowercase letters, numbers, underscores. Must start with a letter."))' - ' (button :type "submit"' - ' :class "w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"' - ' "Claim username")))', - domain=str(escape(ap_domain)), err=error_html, - csrf=csrf, uname=str(escape(username)), - **{"check-url": check_url}, + content = render( + "federation-choose-username", + domain=str(escape(ap_domain)), error_html=error_html, + csrf=csrf, username=str(escape(username)), + check_url=check_url, ) return _social_page(ctx, actor, content_html=content, @@ -892,9 +719,8 @@ async def render_profile_page(ctx: dict, actor: Any, activities: list, ap_domain = config().get("ap_domain", "rose-ash.com") display_name = actor.display_name or actor.preferred_username - summary_html = sexp( - '(p :class "mt-2" (raw! s))', - s=str(escape(actor.summary)), + summary_html = render( + "federation-profile-summary-text", text=str(escape(actor.summary)), ) if actor.summary else "" activities_html = "" @@ -902,40 +728,26 @@ async def render_profile_page(ctx: dict, actor: Any, activities: list, parts = [] for a in activities: published = a.published.strftime("%Y-%m-%d %H:%M") if a.published else "" - obj_type_html = sexp( - '(span :class "text-sm text-stone-500" (raw! t))', - t=a.object_type, + obj_type_html = render( + "federation-activity-obj-type", obj_type=a.object_type, ) if a.object_type else "" - parts.append(sexp( - '(div :class "bg-white rounded-lg shadow p-4"' - ' (div :class "flex justify-between items-start"' - ' (span :class "font-medium" (raw! atype))' - ' (span :class "text-sm text-stone-400" (raw! pub)))' - ' (raw! otype))', - atype=a.activity_type, pub=published, - otype=obj_type_html, + parts.append(render( + "federation-activity-card", + activity_type=a.activity_type, published=published, + obj_type_html=obj_type_html, )) - activities_html = sexp( - '(div :class "space-y-4" (raw! items))', - items="".join(parts), - ) + activities_html = render("federation-activities-list", items_html="".join(parts)) else: - activities_html = sexp('(p :class "text-stone-500" "No activities yet.")') + activities_html = render("federation-activities-empty") - content = sexp( - '(div :class "py-8"' - ' (div :class "bg-white rounded-lg shadow p-6 mb-6"' - ' (h1 :class "text-2xl font-bold" (raw! dname))' - ' (p :class "text-stone-500" "@" (raw! username) "@" (raw! domain))' - ' (raw! summary))' - ' (h2 :class "text-xl font-bold mb-4" (raw! activities-heading))' - ' (raw! activities))', - dname=str(escape(display_name)), + content = render( + "federation-profile-page", + display_name=str(escape(display_name)), username=str(escape(actor.preferred_username)), domain=str(escape(ap_domain)), - summary=summary_html, - **{"activities-heading": f"Activities ({total})"}, - activities=activities_html, + summary_html=summary_html, + activities_heading=f"Activities ({total})", + activities_html=activities_html, ) return _social_page(ctx, actor, content_html=content, diff --git a/federation/sexp/social.sexpr b/federation/sexp/social.sexpr new file mode 100644 index 0000000..85f686a --- /dev/null +++ b/federation/sexp/social.sexpr @@ -0,0 +1,121 @@ +;; Social navigation, header, post cards, timeline, compose + +;; --- Navigation --- + +(defcomp ~federation-nav-choose-username (&key url) + (nav :class "flex gap-3 text-sm items-center" + (a :href url :class "px-2 py-1 rounded hover:bg-stone-200 font-bold" "Choose username"))) + +(defcomp ~federation-nav-link (&key href cls label) + (a :href href :class cls (raw! label))) + +(defcomp ~federation-nav-notification-link (&key href cls count-url) + (a :href href :class cls "Notifications" + (span :hx-get count-url :hx-trigger "load, every 30s" :hx-swap "innerHTML" + :class "absolute -top-2 -right-3 text-xs bg-red-500 text-white rounded-full px-1 empty:hidden"))) + +(defcomp ~federation-nav-bar (&key items-html) + (nav :class "flex gap-3 text-sm items-center flex-wrap" (raw! items-html))) + +(defcomp ~federation-social-header (&key nav-html) + (div :id "social-row" :class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-400" + (div :class "w-full flex flex-row items-center gap-2 flex-wrap" (raw! nav-html)))) + +(defcomp ~federation-header-child (&key inner-html) + (div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! inner-html))) + +;; --- Post card --- + +(defcomp ~federation-boost-label (&key name) + (div :class "text-sm text-stone-500 mb-2" "Boosted by " (raw! name))) + +(defcomp ~federation-avatar-img (&key src cls) + (img :src src :alt "" :class cls)) + +(defcomp ~federation-avatar-placeholder (&key cls initial) + (div :class cls (raw! initial))) + +(defcomp ~federation-content-cw (&key summary content) + (details :class "mt-2" + (summary :class "text-stone-500 cursor-pointer" "CW: " (raw! summary)) + (div :class "mt-2 prose prose-sm prose-stone max-w-none" (raw! content)))) + +(defcomp ~federation-content-plain (&key content) + (div :class "mt-2 prose prose-sm prose-stone max-w-none" (raw! content))) + +(defcomp ~federation-original-link (&key url) + (a :href url :target "_blank" :rel "noopener" + :class "text-sm text-stone-400 hover:underline mt-1 inline-block" "original")) + +(defcomp ~federation-interactions-wrap (&key id buttons-html) + (div :id id (raw! buttons-html))) + +(defcomp ~federation-post-card (&key boost-html avatar-html actor-name actor-username domain-html time-html content-html original-html interactions-html) + (article :class "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4" + (raw! boost-html) + (div :class "flex items-start gap-3" + (raw! avatar-html) + (div :class "flex-1 min-w-0" + (div :class "flex items-baseline gap-2" + (span :class "font-semibold text-stone-900" (raw! actor-name)) + (span :class "text-sm text-stone-500" "@" (raw! actor-username) (raw! domain-html)) + (span :class "text-sm text-stone-400 ml-auto" (raw! time-html))) + (raw! content-html) (raw! original-html) (raw! interactions-html))))) + +;; --- Interaction buttons --- + +(defcomp ~federation-reply-link (&key url) + (a :href url :class "hover:text-stone-700" "Reply")) + +(defcomp ~federation-like-form (&key action target oid ainbox csrf cls icon count) + (form :hx-post action :hx-target target :hx-swap "innerHTML" + (input :type "hidden" :name "object_id" :value oid) + (input :type "hidden" :name "author_inbox" :value ainbox) + (input :type "hidden" :name "csrf_token" :value csrf) + (button :type "submit" :class cls (span (raw! icon)) " " (raw! count)))) + +(defcomp ~federation-boost-form (&key action target oid ainbox csrf cls count) + (form :hx-post action :hx-target target :hx-swap "innerHTML" + (input :type "hidden" :name "object_id" :value oid) + (input :type "hidden" :name "author_inbox" :value ainbox) + (input :type "hidden" :name "csrf_token" :value csrf) + (button :type "submit" :class cls (span "\u21bb") " " (raw! count)))) + +(defcomp ~federation-interaction-buttons (&key like-html boost-html reply-html) + (div :class "flex items-center gap-4 mt-3 text-sm text-stone-500" + (raw! like-html) (raw! boost-html) (raw! reply-html))) + +;; --- Timeline --- + +(defcomp ~federation-scroll-sentinel (&key url) + (div :hx-get url :hx-trigger "revealed" :hx-swap "outerHTML")) + +(defcomp ~federation-compose-button (&key url) + (a :href url :class "bg-stone-800 text-white px-4 py-2 rounded hover:bg-stone-700" "Compose")) + +(defcomp ~federation-timeline-page (&key label compose-html timeline-html) + (div :class "flex items-center justify-between mb-6" + (h1 :class "text-2xl font-bold" (raw! label) " Timeline") + (raw! compose-html)) + (div :id "timeline" (raw! timeline-html))) + +;; --- Compose --- + +(defcomp ~federation-compose-reply (&key reply-to) + (input :type "hidden" :name "in_reply_to" :value reply-to) + (div :class "text-sm text-stone-500" "Replying to " (span :class "font-mono" (raw! reply-to)))) + +(defcomp ~federation-compose-form (&key action csrf reply-html) + (h1 :class "text-2xl font-bold mb-6" "Compose") + (form :method "post" :action action :class "space-y-4" + (input :type "hidden" :name "csrf_token" :value csrf) + (raw! reply-html) + (textarea :name "content" :rows "6" :maxlength "5000" :required true + :class "w-full border border-stone-300 rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-stone-500" + :placeholder "What's on your mind?") + (div :class "flex items-center justify-between" + (select :name "visibility" :class "border border-stone-300 rounded px-3 py-1.5 text-sm" + (option :value "public" "Public") + (option :value "unlisted" "Unlisted") + (option :value "followers" "Followers only")) + (button :type "submit" :class "bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700" "Publish")))) diff --git a/market/bp/fragments/routes.py b/market/bp/fragments/routes.py index a3dce41..85e532f 100644 --- a/market/bp/fragments/routes.py +++ b/market/bp/fragments/routes.py @@ -35,7 +35,7 @@ def register(): async def _container_nav_handler(): from quart import current_app from shared.infrastructure.urls import market_url - from shared.sexp.jinja_bridge import sexp as render_sexp + from shared.sexp.jinja_bridge import render as render_comp container_type = request.args.get("container_type", "page") container_id = int(request.args.get("container_id", 0)) @@ -51,9 +51,9 @@ def register(): parts = [] for m in markets: href = market_url(f"/{post_slug}/{m.slug}/") - parts.append(render_sexp( - '(~market-link-nav :href href :name name :nav-class nav-class)', - href=href, name=m.name, **{"nav-class": nav_class}, + parts.append(render_comp( + "market-link-nav", + href=href, name=m.name, nav_class=nav_class, )) return "\n".join(parts) @@ -65,7 +65,7 @@ def register(): from sqlalchemy import select from shared.models.market import Product from shared.infrastructure.urls import market_url - from shared.sexp.jinja_bridge import sexp as render_sexp + from shared.sexp.jinja_bridge import render as render_comp slug = request.args.get("slug", "") keys_raw = request.args.get("keys", "") @@ -86,8 +86,8 @@ def register(): detail = f"{product.regular_price} {product.special_price}" elif product.regular_price: detail = str(product.regular_price) - parts.append(render_sexp( - '(~link-card :title title :image image :subtitle subtitle :detail detail :link link)', + parts.append(render_comp( + "link-card", title=product.title, image=product.image, subtitle=subtitle, detail=detail, link=market_url(f"/product/{product.slug}/"), @@ -108,8 +108,8 @@ def register(): detail = f"{product.regular_price} {product.special_price}" elif product.regular_price: detail = str(product.regular_price) - return render_sexp( - '(~link-card :title title :image image :subtitle subtitle :detail detail :link link)', + return render_comp( + "link-card", title=product.title, image=product.image, subtitle=subtitle, detail=detail, link=market_url(f"/product/{product.slug}/"), diff --git a/market/sexp/cards.sexpr b/market/sexp/cards.sexpr new file mode 100644 index 0000000..5e9c895 --- /dev/null +++ b/market/sexp/cards.sexpr @@ -0,0 +1,105 @@ +;; Market card components + +(defcomp ~market-label-overlay (&key src) + (img :src src :alt "" + :class "pointer-events-none absolute inset-0 w-full h-full object-contain object-top")) + +(defcomp ~market-card-image (&key image labels-html brand-highlight brand) + (div :class "w-full aspect-square bg-stone-100 relative" + (figure :class "inline-block w-full h-full" + (div :class "relative w-full h-full" + (img :src image :alt "no image" :class "absolute inset-0 w-full h-full object-contain object-top" :loading "lazy" :decoding "async" :fetchpriority "low") + (raw! labels-html)) + (figcaption :class (str "mt-2 text-sm text-center" brand-highlight " text-stone-600") brand)))) + +(defcomp ~market-card-no-image (&key labels-html brand) + (div :class "w-full aspect-square bg-stone-100 relative" + (div :class "p-2 flex flex-col items-center justify-center gap-2 text-red-500 h-full relative" + (div :class "text-stone-400 text-xs" "No image") + (ul :class "flex flex-row gap-1" (raw! labels-html)) + (div :class "text-stone-900 text-center line-clamp-3 break-words [overflow-wrap:anywhere]" brand)))) + +(defcomp ~market-card-label-item (&key label) + (li label)) + +(defcomp ~market-card-sticker (&key src name ring-cls) + (img :src src :alt name :class (str "w-6 h-6" ring-cls))) + +(defcomp ~market-card-stickers (&key items-html) + (div :class "flex flex-row justify-center gap-2 p-2" (raw! items-html))) + +(defcomp ~market-card-highlight (&key pre mid post) + (<> pre (mark mid) post)) + +(defcomp ~market-card-text (&key text) + (<> text)) + +(defcomp ~market-product-card (&key like-html href hx-select image-html price-html add-html stickers-html title-html) + (div :class "flex flex-col rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden relative" + (raw! like-html) + (a :href href :hx-get href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" + (raw! image-html) (raw! price-html)) + (div :class "flex justify-center" (raw! add-html)) + (a :href href :hx-get href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" + (raw! stickers-html) + (div :class "text-sm font-medium text-stone-800 text-center line-clamp-3 break-words [overflow-wrap:anywhere]" + (raw! title-html))))) + +(defcomp ~market-like-button (&key form-id action slug csrf icon-cls) + (div :class "absolute top-2 right-2 z-10 text-6xl md:text-xl" + (form :id form-id :action action :method "post" + :hx-post action :hx-target (str "#like-" slug) :hx-swap "outerHTML" + (input :type "hidden" :name "csrf_token" :value csrf) + (button :type "submit" :class "cursor-pointer" + (i :class icon-cls :aria-hidden "true"))))) + +(defcomp ~market-market-card-title-link (&key href name) + (a :href href :class "hover:text-emerald-700" + (h2 :class "text-lg font-semibold text-stone-900" name))) + +(defcomp ~market-market-card-title (&key name) + (h2 :class "text-lg font-semibold text-stone-900" name)) + +(defcomp ~market-market-card-desc (&key description) + (p :class "text-sm text-stone-600 mt-1 line-clamp-2" description)) + +(defcomp ~market-market-card-badge (&key href title) + (div :class "flex flex-wrap items-center gap-1.5 mt-3" + (a :href href :class "inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200" + title))) + +(defcomp ~market-market-card (&key title-html desc-html badge-html) + (article :class "rounded-xl bg-white shadow-sm border border-stone-200 p-5 flex flex-col justify-between hover:border-stone-400 transition-colors" + (div (raw! title-html) (raw! desc-html)) + (raw! badge-html))) + +(defcomp ~market-sentinel-mobile (&key id next-url hyperscript) + (div :id id + :class "block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel" + :hx-get next-url :hx-trigger "intersect once delay:250ms, sentinelmobile:retry" + :hx-swap "outerHTML" + :_ hyperscript + :role "status" :aria-live "polite" :aria-hidden "true" + (div :class "js-loading text-center text-xs text-stone-400" "loading...") + (div :class "js-neterr hidden text-center text-xs text-stone-400" "Retrying..."))) + +(defcomp ~market-sentinel-desktop (&key id next-url hyperscript) + (div :id id + :class "hidden md:block h-4 opacity-0 pointer-events-none" + :hx-get next-url :hx-trigger "intersect once delay:250ms, sentinel:retry" + :hx-swap "outerHTML" + :_ hyperscript + :role "status" :aria-live "polite" :aria-hidden "true" + (div :class "js-loading text-center text-xs text-stone-400" "loading...") + (div :class "js-neterr hidden text-center text-xs text-stone-400" "Retrying..."))) + +(defcomp ~market-sentinel-end () + (div :class "col-span-full mt-4 text-center text-xs text-stone-400" "End of results")) + +(defcomp ~market-market-sentinel (&key id next-url) + (div :id id :class "h-4 opacity-0 pointer-events-none" + :hx-get next-url :hx-trigger "intersect once delay:250ms" + :hx-swap "outerHTML" :role "status" :aria-hidden "true" + (div :class "text-center text-xs text-stone-400" "loading..."))) diff --git a/market/sexp/cart.sexpr b/market/sexp/cart.sexpr new file mode 100644 index 0000000..26c4aa9 --- /dev/null +++ b/market/sexp/cart.sexpr @@ -0,0 +1,44 @@ +;; Market cart components + +(defcomp ~market-cart-add-empty (&key cart-id action csrf) + (div :id cart-id + (form :action action :method "post" :hx-post action :hx-target "#cart-mini" :hx-swap "outerHTML" :class "rounded flex items-center" + (input :type "hidden" :name "csrf_token" :value csrf) + (input :type "hidden" :name "count" :value "1") + (button :type "submit" :class "relative inline-flex items-center justify-center text-sm font-medium text-stone-500 hover:bg-emerald-50" + (span :class "relative inline-flex items-center justify-center" + (i :class "fa fa-cart-plus text-4xl" :aria-hidden "true")))))) + +(defcomp ~market-cart-add-quantity (&key cart-id action csrf minus-val plus-val quantity cart-href) + (div :id cart-id + (div :class "rounded flex items-center gap-2" + (form :action action :method "post" :hx-post action :hx-target "#cart-mini" :hx-swap "outerHTML" + (input :type "hidden" :name "csrf_token" :value csrf) + (input :type "hidden" :name "count" :value minus-val) + (button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "-")) + (a :class "relative inline-flex items-center justify-center text-emerald-700" :href cart-href + (span :class "relative inline-flex items-center justify-center" + (i :class "fa-solid fa-shopping-cart text-2xl" :aria-hidden "true") + (span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none" + (span :class "flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold" quantity)))) + (form :action action :method "post" :hx-post action :hx-target "#cart-mini" :hx-swap "outerHTML" + (input :type "hidden" :name "csrf_token" :value csrf) + (input :type "hidden" :name "count" :value plus-val) + (button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "+"))))) + +(defcomp ~market-cart-mini-count (&key href count) + (div :id "cart-mini" :hx-swap-oob "outerHTML" + (a :href href :class "relative inline-flex items-center justify-center" + (span :class "relative inline-flex items-center justify-center" + (i :class "fa-solid fa-shopping-cart text-xl" :aria-hidden "true") + (span :class "absolute -top-1.5 -right-2 pointer-events-none" + (span :class "flex items-center justify-center bg-emerald-500 text-white rounded-full min-w-[1.25rem] h-5 text-xs font-bold px-1" + count)))))) + +(defcomp ~market-cart-mini-empty (&key href logo) + (div :id "cart-mini" :hx-swap-oob "outerHTML" + (a :href href :class "relative inline-flex items-center justify-center" + (img :src logo :class "h-8 w-8 rounded-full object-cover border border-stone-300" :alt "")))) + +(defcomp ~market-cart-add-oob (&key id inner-html) + (div :id id :hx-swap-oob "outerHTML" (raw! inner-html))) diff --git a/market/sexp/detail.sexpr b/market/sexp/detail.sexpr new file mode 100644 index 0000000..a80c66d --- /dev/null +++ b/market/sexp/detail.sexpr @@ -0,0 +1,94 @@ +;; Market product detail components + +(defcomp ~market-detail-gallery-inner (&key like-html image alt labels-html brand) + (<> (raw! like-html) + (figure :class "inline-block" + (div :class "relative w-full aspect-square" + (img :data-main-img "" :src image :alt alt + :class "w-full h-full object-contain object-top" :loading "eager" :decoding "async") + (raw! labels-html)) + (figcaption :class "mt-2 text-sm text-stone-600 text-center" brand)))) + +(defcomp ~market-detail-nav-buttons () + (<> + (button :type "button" :data-prev "" + :class "absolute left-2 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-12 h-12 md:w-14 md:h-14 rounded-full bg-white/90 hover:bg-white shadow-lg text-3xl md:text-4xl" + :title "Previous" "\u2039") + (button :type "button" :data-next "" + :class "absolute right-2 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-12 h-12 md:w-14 md:h-14 rounded-full bg-white/90 hover:bg-white shadow-lg text-3xl md:text-4xl" + :title "Next" "\u203a"))) + +(defcomp ~market-detail-gallery (&key inner-html nav-html) + (div :class "relative rounded-xl overflow-hidden bg-stone-100" + (raw! inner-html) (raw! nav-html))) + +(defcomp ~market-detail-thumb (&key title src alt) + (<> (button :type "button" :data-thumb "" + :class "shrink-0 rounded-lg overflow-hidden bg-stone-100 hover:opacity-90 ring-offset-2" + :title title + (img :src src :class "h-16 w-16 object-contain" :alt alt :loading "lazy" :decoding "async")) + (span :data-image-src src :class "hidden"))) + +(defcomp ~market-detail-thumbs (&key thumbs-html) + (div :class "flex flex-row justify-center" + (div :class "mt-3 flex gap-2 overflow-x-auto no-scrollbar" (raw! thumbs-html)))) + +(defcomp ~market-detail-no-image (&key like-html) + (div :class "relative aspect-square bg-stone-100 rounded-xl flex items-center justify-center text-stone-400" + (raw! like-html) "No image")) + +(defcomp ~market-detail-sticker (&key src name) + (img :src src :alt name :class "w-10 h-10")) + +(defcomp ~market-detail-stickers (&key items-html) + (div :class "p-2 flex flex-row justify-center gap-2" (raw! items-html))) + +(defcomp ~market-detail-unit-price (&key price) + (div (str "Unit price: " price))) + +(defcomp ~market-detail-case-size (&key size) + (div (str "Case size: " size))) + +(defcomp ~market-detail-extras (&key inner-html) + (div :class "mt-2 space-y-1 text-sm text-stone-600" (raw! inner-html))) + +(defcomp ~market-detail-desc-short (&key text) + (p :class "leading-relaxed text-lg" text)) + +(defcomp ~market-detail-desc-html (&key html) + (div :class "max-w-none text-sm leading-relaxed" (raw! html))) + +(defcomp ~market-detail-desc-wrapper (&key inner-html) + (div :class "mt-4 text-stone-800 space-y-3" (raw! inner-html))) + +(defcomp ~market-detail-section (&key title html) + (details :class "group rounded-xl border bg-white shadow-sm open:shadow p-0" + (summary :class "cursor-pointer select-none px-4 py-3 flex items-center justify-between" + (span :class "font-medium" title) + (span :class "ml-2 text-xl transition-transform group-open:rotate-180" "\u2304")) + (div :class "px-4 pb-4 max-w-none text-sm leading-relaxed" (raw! html)))) + +(defcomp ~market-detail-sections (&key items-html) + (div :class "mt-8 space-y-3" (raw! items-html))) + +(defcomp ~market-detail-right-col (&key inner-html) + (div :class "md:col-span-3" (raw! inner-html))) + +(defcomp ~market-detail-layout (&key gallery-html stickers-html details-html) + (<> (div :class "mt-3 grid grid-cols-1 md:grid-cols-5 gap-6" :data-gallery-root "" + (div :class "md:col-span-2" (raw! gallery-html) (raw! stickers-html)) + (raw! details-html)) + (div :class "pb-8"))) + +(defcomp ~market-landing-excerpt (&key text) + (div :class "w-full text-center italic text-3xl p-2" text)) + +(defcomp ~market-landing-image (&key src) + (div :class "mb-3 flex justify-center" + (img :src src :alt "" :class "rounded-lg w-full md:w-3/4 object-cover"))) + +(defcomp ~market-landing-html (&key html) + (div :class "blog-content p-2" (raw! html))) + +(defcomp ~market-landing-content (&key inner-html) + (<> (article :class "relative w-full" (raw! inner-html)) (div :class "pb-8"))) diff --git a/market/sexp/filters.sexpr b/market/sexp/filters.sexpr new file mode 100644 index 0000000..31d8a67 --- /dev/null +++ b/market/sexp/filters.sexpr @@ -0,0 +1,120 @@ +;; Market filter components + +(defcomp ~market-filter-sort-item (&key href hx-select ring-cls src label) + (a :href href :hx-get href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" + :class (str "flex flex-col items-center gap-1 p-1 cursor-pointer" ring-cls) + (img :src src :alt label :class "w-10 h-10") + (span :class "text-xs" label))) + +(defcomp ~market-filter-sort-row (&key items-html) + (div :class "flex flex-row gap-2 justify-center p-1" (raw! items-html))) + +(defcomp ~market-filter-like (&key href hx-select icon-cls size-cls) + (a :href href :hx-get href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" + :class "flex flex-col items-center gap-1 p-1 cursor-pointer" + (i :aria-hidden "true" :class (str icon-cls " " size-cls " leading-none")))) + +(defcomp ~market-filter-label-item (&key href hx-select ring-cls src name) + (a :href href :hx-get href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" + :class (str "flex flex-col items-center gap-1 p-1 cursor-pointer" ring-cls) + (img :src src :alt name :class "w-10 h-10"))) + +(defcomp ~market-filter-sticker-item (&key href hx-select ring-cls src name count-cls count) + (a :href href :hx-get href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" + :class (str "flex flex-col items-center gap-1 p-1 cursor-pointer" ring-cls) + (img :src src :alt name :class "w-6 h-6") + (span :class count-cls count))) + +(defcomp ~market-filter-stickers-row (&key items-html) + (div :class "flex flex-wrap gap-2 justify-center p-1" (raw! items-html))) + +(defcomp ~market-filter-brand-item (&key href hx-select bg-cls name-cls name count) + (a :href href :hx-get href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" + :class (str "flex flex-row items-center gap-2 px-2 py-1 rounded hover:bg-stone-100" bg-cls) + (div :class name-cls name) (div :class name-cls count))) + +(defcomp ~market-filter-brands-panel (&key items-html) + (div :class "space-y-1 p-2" (raw! items-html))) + +(defcomp ~market-filter-category-label (&key label) + (div :class "mb-4" (div :class "text-2xl uppercase tracking-wide text-black-500" label))) + +(defcomp ~market-filter-like-labels-nav (&key inner-html) + (nav :aria-label "like" :class "flex flex-row justify-center w-full p-0 m-0 border bg-white shadow-sm rounded-xl gap-1" + (raw! inner-html))) + +(defcomp ~market-desktop-category-summary (&key inner-html) + (div :id "category-summary-desktop" :hxx-swap-oob "outerHTML" (raw! inner-html))) + +(defcomp ~market-desktop-brand-summary (&key inner-html) + (div :id "filter-summary-desktop" :hxx-swap-oob "outerHTML" (raw! inner-html))) + +(defcomp ~market-filter-subcategory-item (&key href hx-select active-cls name) + (a :href href :hx-get href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" + :class (str "block px-2 py-1 rounded hover:bg-stone-100" active-cls) + name)) + +(defcomp ~market-filter-subcategory-panel (&key items-html) + (div :class "mt-4 space-y-1" (raw! items-html))) + +(defcomp ~market-mobile-clear-filters (&key href hx-select) + (div :class "flex flex-row justify-center" + (a :href href :hx-get href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" + :role "button" :title "clear filters" :aria-label "clear filters" + :class "flex flex-col items-center justify-start p-1 rounded bg-stone-200 text-black cursor-pointer" + (span :class "mt-1 leading-none tabular-nums" "clear filters")))) + +(defcomp ~market-mobile-like-labels-row (&key inner-html) + (div :class "flex flex-row gap-2 justify-center items-center" (raw! inner-html))) + +(defcomp ~market-mobile-filter-summary (&key search-bar chips-html filter-html) + (details :class "md:hidden group" :id "/filter" + (summary :class "cursor-pointer select-none" :id "filter-summary-mobile" + (raw! search-bar) + (div :class "col-span-12 min-w-0 grid grid-cols-1 gap-1 bg-gray-100 px-2" :role "list" + (raw! chips-html))) + (div :id "filter-details-mobile" :style "display:contents" + (raw! filter-html)))) + +(defcomp ~market-mobile-chips-row (&key inner-html) + (div :class "flex flex-row items-start gap-2" (raw! inner-html))) + +(defcomp ~market-mobile-chip-sort (&key src label) + (ul :class "relative inline-flex items-center justify-center gap-2" + (li :role "listitem" (img :src src :alt label :class "w-10 h-10")))) + +(defcomp ~market-mobile-chip-liked-icon () + (i :aria-hidden "true" :class "fa-solid fa-heart text-red-500 text-[40px] leading-none")) + +(defcomp ~market-mobile-chip-count (&key cls count) + (div :class (str cls " mt-1 leading-none tabular-nums") count)) + +(defcomp ~market-mobile-chip-liked (&key inner-html) + (div :class "flex flex-col items-center gap-1 pb-1" (raw! inner-html))) + +(defcomp ~market-mobile-chip-image (&key src name) + (img :src src :alt name :class "w-10 h-10")) + +(defcomp ~market-mobile-chip-item (&key inner-html) + (li :role "listitem" :class "flex flex-col items-center gap-1 pb-1" (raw! inner-html))) + +(defcomp ~market-mobile-chip-list (&key items-html) + (ul :class "relative inline-flex items-center justify-center gap-2" (raw! items-html))) + +(defcomp ~market-mobile-chip-brand (&key name count) + (li :role "listitem" :class "flex flex-row items-center gap-2" + (div :class "text-md" name) (div :class "text-md" count))) + +(defcomp ~market-mobile-chip-brand-zero (&key name) + (li :role "listitem" :class "flex flex-row items-center gap-2" + (div :class "text-md text-red-500" name) (div :class "text-xl text-red-500" "0"))) + +(defcomp ~market-mobile-chip-brand-list (&key items-html) + (ul (raw! items-html))) diff --git a/market/sexp/grids.sexpr b/market/sexp/grids.sexpr new file mode 100644 index 0000000..b7d23e4 --- /dev/null +++ b/market/sexp/grids.sexpr @@ -0,0 +1,22 @@ +;; Market grid and layout components + +(defcomp ~market-markets-grid (&key cards-html) + (div :class "max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4" (raw! cards-html))) + +(defcomp ~market-no-markets (&key message) + (div :class "px-3 py-12 text-center text-stone-400" + (i :class "fa fa-store text-4xl mb-3" :aria-hidden "true") + (p :class "text-lg" message))) + +(defcomp ~market-product-grid (&key cards-html) + (<> (div :class "grid grid-cols-1 sm:grid-cols-3 md:grid-cols-6 gap-3" (raw! cards-html)) (div :class "pb-8"))) + +(defcomp ~market-bottom-spacer () + (div :class "pb-8")) + +(defcomp ~market-like-toggle-button (&key colour action hx-headers label icon-cls) + (button :class (str "flex items-center gap-1 " colour " hover:text-red-600 transition-colors w-[1em] h-[1em]") + :hx-post action :hx-target "this" :hx-swap "outerHTML" :hx-push-url "false" + :hx-headers hx-headers + :hx-swap-settle "0ms" :aria-label label + (i :aria-hidden "true" :class icon-cls))) diff --git a/market/sexp/headers.sexpr b/market/sexp/headers.sexpr new file mode 100644 index 0000000..1bbfe7c --- /dev/null +++ b/market/sexp/headers.sexpr @@ -0,0 +1,38 @@ +;; Market header components + +(defcomp ~market-post-label-image (&key src) + (img :src src :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0")) + +(defcomp ~market-post-label-title (&key title) + (span title)) + +(defcomp ~market-post-cart-badge (&key href count) + (a :href href :class "relative inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 transition" + (i :class "fa fa-shopping-cart" :aria-hidden "true") + (span count))) + +(defcomp ~market-shop-label (&key title top-slug sub-div-html) + (div :class "font-bold text-xl flex-shrink-0 flex gap-2 items-center" + (div (i :class "fa fa-shop") " " title) + (div :class "flex flex-col md:flex-row md:gap-2 text-xs" + (div top-slug) (raw! sub-div-html)))) + +(defcomp ~market-sub-slug (&key sub) + (div sub)) + +(defcomp ~market-product-label (&key title) + (<> (i :class "fa fa-shopping-bag" :aria-hidden "true") (div title))) + +(defcomp ~market-admin-link (&key href hx-select) + (a :href href :hx-get href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" + :class "px-2 py-1 text-stone-500 hover:text-stone-700" + (i :class "fa fa-cog" :aria-hidden "true"))) + +(defcomp ~market-oob-header (&key parent-id child-id row-html) + (div :id parent-id :hx-swap-oob "outerHTML" :class "w-full" + (div :class "w-full" (raw! row-html) + (div :id child-id)))) + +(defcomp ~market-header-child (&key inner-html) + (div :id "root-header-child" :class "w-full" (raw! inner-html))) diff --git a/market/sexp/meta.sexpr b/market/sexp/meta.sexpr new file mode 100644 index 0000000..2953452 --- /dev/null +++ b/market/sexp/meta.sexpr @@ -0,0 +1,19 @@ +;; Market meta/SEO components + +(defcomp ~market-meta-title (&key title) + (title title)) + +(defcomp ~market-meta-description (&key description) + (meta :name "description" :content description)) + +(defcomp ~market-meta-canonical (&key href) + (link :rel "canonical" :href href)) + +(defcomp ~market-meta-og (&key property content) + (meta :property property :content content)) + +(defcomp ~market-meta-twitter (&key name content) + (meta :name name :content content)) + +(defcomp ~market-meta-jsonld (&key json) + (script :type "application/ld+json" (raw! json))) diff --git a/market/sexp/navigation.sexpr b/market/sexp/navigation.sexpr new file mode 100644 index 0000000..511b1ef --- /dev/null +++ b/market/sexp/navigation.sexpr @@ -0,0 +1,63 @@ +;; Market navigation components + +(defcomp ~market-category-link (&key href hx-select active select-colours label) + (div :class "relative nav-group" + (a :href href :hx-get href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" + :aria-selected (if active "true" "false") + :class (str "block px-2 py-1 rounded text-center whitespace-normal break-words leading-snug bg-stone-200 text-black " select-colours) + label))) + +(defcomp ~market-desktop-category-nav (&key links-html admin-html) + (nav :class "hidden md:flex gap-4 text-sm ml-2 w-full justify-end items-center" + (raw! links-html) (raw! admin-html))) + +(defcomp ~market-mobile-nav-wrapper (&key items-html) + (div :class "px-4 py-2" (div :class "divide-y" (raw! items-html)))) + +(defcomp ~market-mobile-all-link (&key href hx-select active select-colours) + (a :role "option" :href href :hx-get href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" + :aria-selected (if active "true" "false") + :class (str "block rounded-lg px-3 py-3 text-base hover:bg-stone-50 " select-colours) + (div :class "prose prose-stone max-w-none" "All"))) + +(defcomp ~market-mobile-chevron () + (svg :class "w-4 h-4 shrink-0 transition-transform group-open/cat:rotate-180" + :viewBox "0 0 20 20" :fill "currentColor" + (path :fill-rule "evenodd" :clip-rule "evenodd" + :d "M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"))) + +(defcomp ~market-mobile-cat-summary (&key bg-cls href hx-select select-colours cat-name count-label count-str chevron-html) + (summary :class (str "flex items-center justify-between cursor-pointer select-none block rounded-lg px-3 py-3 text-base hover:bg-stone-50" bg-cls) + (a :href href :hx-get href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" + :class (str "font-medium " select-colours " flex flex-row gap-2") + (div cat-name) + (div :aria-label count-label count-str)) + (raw! chevron-html))) + +(defcomp ~market-mobile-sub-link (&key select-colours active href hx-select label count-label count-str) + (a :class (str "snap-start px-2 py-3 rounded " select-colours " flex flex-row gap-2") + :aria-selected (if active "true" "false") + :href href :hx-get href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" + (div label) + (div :aria-label count-label count-str))) + +(defcomp ~market-mobile-subs-panel (&key links-html) + (div :class "pb-3 pl-2" + (div :data-peek-viewport "" :data-peek-size-px "18" :data-peek-edge "bottom" :data-peek-mask "true" :class "m-2 bg-stone-100" + (div :data-peek-inner "" :class "grid grid-cols-1 gap-1 snap-y snap-mandatory pr-1" :aria-label "Subcategories" + (raw! links-html))))) + +(defcomp ~market-mobile-view-all (&key href hx-select) + (div :class "pb-3 pl-2" + (a :class "px-2 py-1 rounded hover:bg-stone-100 block" + :href href :hx-get href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" :hx-push-url "true" + "View all"))) + +(defcomp ~market-mobile-cat-details (&key open summary-html subs-html) + (details :class "group/cat py-1" :open open + (raw! summary-html) (raw! subs-html))) diff --git a/market/sexp/prices.sexpr b/market/sexp/prices.sexpr new file mode 100644 index 0000000..0bc85ab --- /dev/null +++ b/market/sexp/prices.sexpr @@ -0,0 +1,34 @@ +;; Market price display components + +(defcomp ~market-price-special (&key price) + (div :class "text-lg font-semibold text-emerald-700" price)) + +(defcomp ~market-price-regular-strike (&key price) + (div :class "text-sm line-through text-stone-500" price)) + +(defcomp ~market-price-regular (&key price) + (div :class "mt-1 text-lg font-semibold" price)) + +(defcomp ~market-price-line (&key inner-html) + (div :class "mt-1 flex items-baseline gap-2 justify-center" (raw! inner-html))) + +(defcomp ~market-header-price-special-label () + (div :class "text-md font-bold text-emerald-700" "Special price")) + +(defcomp ~market-header-price-special (&key price) + (div :class "text-xl font-semibold text-emerald-700" price)) + +(defcomp ~market-header-price-strike (&key price) + (div :class "text-base text-md line-through text-stone-500" price)) + +(defcomp ~market-header-price-regular-label () + (div :class "hidden md:block text-xl font-bold" "Our price")) + +(defcomp ~market-header-price-regular (&key price) + (div :class "text-xl font-semibold" price)) + +(defcomp ~market-header-rrp (&key rrp) + (div :class "text-base text-stone-400" (span "rrp:") " " (span rrp))) + +(defcomp ~market-prices-row (&key inner-html) + (div :class "flex flex-row items-center justify-between md:gap-2 md:px-2" (raw! inner-html))) diff --git a/market/sexp/sexp_components.py b/market/sexp/sexp_components.py index e174983..5eddb30 100644 --- a/market/sexp/sexp_components.py +++ b/market/sexp/sexp_components.py @@ -7,16 +7,20 @@ Called from route handlers in place of ``render_template()``. """ from __future__ import annotations +import os from typing import Any from markupsafe import escape -from shared.sexp.jinja_bridge import sexp +from shared.sexp.jinja_bridge import render, load_service_components from shared.sexp.helpers import ( call_url, get_asset_url, root_header_html, search_mobile_html, search_desktop_html, full_page, oob_page, ) +# Load market-specific .sexpr components at import time +load_service_components(os.path.dirname(os.path.dirname(__file__))) + # --------------------------------------------------------------------------- # Price helpers @@ -53,15 +57,12 @@ def _card_price_html(p: dict) -> str: rp_str = _price_str(pr["rp_val"], pr["rp_raw"], pr["rp_cur"]) inner = "" if pr["sp_val"]: - inner += sexp('(div :class "text-lg font-semibold text-emerald-700" sp)', sp=sp_str) + inner += render("market-price-special", price=sp_str) if pr["rp_val"]: - inner += sexp('(div :class "text-sm line-through text-stone-500" rp)', rp=rp_str) + inner += render("market-price-regular-strike", price=rp_str) elif pr["rp_val"]: - inner += sexp('(div :class "mt-1 text-lg font-semibold" rp)', rp=rp_str) - return sexp( - '(div :class "mt-1 flex items-baseline gap-2 justify-center" (raw! inner))', - inner=inner, - ) + inner += render("market-price-regular", price=rp_str) + return render("market-price-line", inner_html=inner) # --------------------------------------------------------------------------- @@ -77,23 +78,14 @@ def _post_header_html(ctx: dict, *, oob: bool = False) -> str: label_html = "" if feature_image: - label_html += sexp( - '(img :src fi :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0")', - fi=feature_image, - ) - label_html += sexp('(span t)', t=title) + label_html += render("market-post-label-image", src=feature_image) + label_html += render("market-post-label-title", title=title) nav_html = "" page_cart_count = ctx.get("page_cart_count", 0) if page_cart_count and page_cart_count > 0: cart_href = call_url(ctx, "cart_url", f"/{slug}/") - nav_html += sexp( - '(a :href ch :class "relative inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full' - ' border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 transition"' - ' (i :class "fa fa-shopping-cart" :aria-hidden "true")' - ' (span pcc))', - ch=cart_href, pcc=str(page_cart_count), - ) + nav_html += render("market-post-cart-badge", href=cart_href, count=str(page_cart_count)) # Container nav container_nav = ctx.get("container_nav_html", "") @@ -102,14 +94,11 @@ def _post_header_html(ctx: dict, *, oob: bool = False) -> str: link_href = call_url(ctx, "blog_url", f"/{slug}/") - return sexp( - '(~menu-row :id "post-row" :level 1' - ' :link-href lh :link-label-html llh' - ' :nav-html nh :child-id "post-header-child" :oob oob)', - lh=link_href, - llh=label_html, - nh=nav_html, - oob=oob, + return render( + "menu-row", + id="post-row", level=1, + link_href=link_href, link_label_html=label_html, + nav_html=nav_html, child_id="post-header-child", oob=oob, ) @@ -122,13 +111,10 @@ def _market_header_html(ctx: dict, *, oob: bool = False) -> str: sub_slug = ctx.get("sub_slug", "") hx_select_search = ctx.get("hx_select_search", "#main-panel") - sub_div = sexp('(div sub)', sub=sub_slug) if sub_slug else "" - label_html = sexp( - '(div :class "font-bold text-xl flex-shrink-0 flex gap-2 items-center"' - ' (div (i :class "fa fa-shop") " " mt)' - ' (div :class "flex flex-col md:flex-row md:gap-2 text-xs"' - ' (div ts) (raw! sd)))', - mt=market_title, ts=top_slug or "", sd=sub_div, + sub_div = render("market-sub-slug", sub=sub_slug) if sub_slug else "" + label_html = render( + "market-shop-label", + title=market_title, top_slug=top_slug or "", sub_div_html=sub_div, ) link_href = url_for("market.browse.home") @@ -138,14 +124,11 @@ def _market_header_html(ctx: dict, *, oob: bool = False) -> str: qs = ctx.get("qs", "") nav_html = _desktop_category_nav_html(ctx, categories, qs, hx_select_search) - return sexp( - '(~menu-row :id "market-row" :level 2' - ' :link-href lh :link-label-html llh' - ' :nav-html nh :child-id "market-header-child" :oob oob)', - lh=link_href, - llh=label_html, - nh=nav_html, - oob=oob, + return render( + "menu-row", + id="market-row", level=2, + link_href=link_href, link_label_html=label_html, + nav_html=nav_html, child_id="market-header-child", oob=oob, ) @@ -162,45 +145,27 @@ def _desktop_category_nav_html(ctx: dict, categories: dict, qs: str, all_href = prefix + url_for("market.browse.browse_all") + qs all_active = (category_label == "All Products") - links = sexp( - '(div :class "relative nav-group"' - ' (a :href ah :hx-get ah :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' :aria-selected (if aa "true" "false")' - ' :class (str "block px-2 py-1 rounded text-center whitespace-normal break-words leading-snug bg-stone-200 text-black " sc)' - ' "All"))', - ah=all_href, hs=hx_select, aa=all_active, sc=select_colours, + links = render( + "market-category-link", + href=all_href, hx_select=hx_select, active=all_active, + select_colours=select_colours, label="All", ) for cat, data in categories.items(): cat_href = prefix + url_for("market.browse.browse_top", top_slug=data["slug"]) + qs cat_active = (cat == category_label) - links += sexp( - '(div :class "relative nav-group"' - ' (a :href ch :hx-get ch :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' :aria-selected (if ca "true" "false")' - ' :class (str "block px-2 py-1 rounded text-center whitespace-normal break-words leading-snug bg-stone-200 text-black " sc)' - ' cn))', - ch=cat_href, hs=hx_select, ca=cat_active, sc=select_colours, cn=cat, + links += render( + "market-category-link", + href=cat_href, hx_select=hx_select, active=cat_active, + select_colours=select_colours, label=cat, ) admin_link = "" if rights and rights.get("admin"): admin_href = prefix + url_for("market.admin.admin") - admin_link = sexp( - '(a :href ah :hx-get ah :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' :class "px-2 py-1 text-stone-500 hover:text-stone-700"' - ' (i :class "fa fa-cog" :aria-hidden "true"))', - ah=admin_href, hs=hx_select, - ) + admin_link = render("market-admin-link", href=admin_href, hx_select=hx_select) - return sexp( - '(nav :class "hidden md:flex gap-4 text-sm ml-2 w-full justify-end items-center"' - ' (raw! links) (raw! al))', - links=links, al=admin_link, - ) + return render("market-desktop-category-nav", links_html=links, admin_html=admin_link) def _product_header_html(ctx: dict, d: dict, *, oob: bool = False) -> str: @@ -213,10 +178,7 @@ def _product_header_html(ctx: dict, d: dict, *, oob: bool = False) -> str: hx_select_search = ctx.get("hx_select_search", "#main-panel") link_href = url_for("market.browse.product.product_detail", product_slug=slug) - label_html = sexp( - '(<> (i :class "fa fa-shopping-bag" :aria-hidden "true") (div t))', - t=title, - ) + label_html = render("market-product-label", title=title) # Prices in nav area pr = _set_prices(d) @@ -227,23 +189,14 @@ def _product_header_html(ctx: dict, d: dict, *, oob: bool = False) -> str: admin_html = "" if rights and rights.get("admin"): admin_href = url_for("market.browse.product.admin", product_slug=slug) - admin_html = sexp( - '(a :href ah :hx-get ah :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' :class "px-2 py-1 text-stone-500 hover:text-stone-700"' - ' (i :class "fa fa-cog" :aria-hidden "true"))', - ah=admin_href, hs=hx_select_search, - ) + admin_html = render("market-admin-link", href=admin_href, hx_select=hx_select_search) nav_html = prices_nav + admin_html - return sexp( - '(~menu-row :id "product-row" :level 3' - ' :link-href lh :link-label-html llh' - ' :nav-html nh :child-id "product-header-child" :oob oob)', - lh=link_href, - llh=label_html, - nh=nav_html, - oob=oob, + return render( + "menu-row", + id="product-row", level=3, + link_href=link_href, link_label_html=label_html, + nav_html=nav_html, child_id="product-header-child", oob=oob, ) @@ -263,22 +216,16 @@ def _prices_header_html(d: dict, pr: dict, cart: list, slug: str, ctx: dict) -> inner = add_html sp_val, rp_val = pr.get("sp_val"), pr.get("rp_val") if sp_val: - inner += sexp('(div :class "text-md font-bold text-emerald-700" "Special price")') - inner += sexp( - '(div :class "text-xl font-semibold text-emerald-700" ps)', - ps=_price_str(sp_val, pr["sp_raw"], pr["sp_cur"]), - ) + inner += render("market-header-price-special-label") + inner += render("market-header-price-special", + price=_price_str(sp_val, pr["sp_raw"], pr["sp_cur"])) if rp_val: - inner += sexp( - '(div :class "text-base text-md line-through text-stone-500" ps)', - ps=_price_str(rp_val, pr["rp_raw"], pr["rp_cur"]), - ) + inner += render("market-header-price-strike", + price=_price_str(rp_val, pr["rp_raw"], pr["rp_cur"])) elif rp_val: - inner += sexp('(div :class "hidden md:block text-xl font-bold" "Our price")') - inner += sexp( - '(div :class "text-xl font-semibold" ps)', - ps=_price_str(rp_val, pr["rp_raw"], pr["rp_cur"]), - ) + inner += render("market-header-price-regular-label") + inner += render("market-header-price-regular", + price=_price_str(rp_val, pr["rp_raw"], pr["rp_cur"])) # RRP rrp_raw = d.get("rrp_raw") @@ -286,51 +233,26 @@ def _prices_header_html(d: dict, pr: dict, cart: list, slug: str, ctx: dict) -> case_size = d.get("case_size_count") or 1 if rrp_raw and rrp_val: rrp_str = f"{rrp_raw[0]}{rrp_val * case_size:.2f}" - inner += sexp( - '(div :class "text-base text-stone-400" (span "rrp:") " " (span rs))', - rs=rrp_str, - ) + inner += render("market-header-rrp", rrp=rrp_str) - return sexp( - '(div :class "flex flex-row items-center justify-between md:gap-2 md:px-2" (raw! inner))', - inner=inner, - ) + return render("market-prices-row", inner_html=inner) def _cart_add_html(slug: str, quantity: int, action: str, csrf: str, cart_url_fn: Any = None) -> str: """Render add-to-cart button or quantity controls.""" if not quantity: - return sexp( - '(div :id cid' - ' (form :action act :method "post" :hx-post act :hx-target "#cart-mini" :hx-swap "outerHTML" :class "rounded flex items-center"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (input :type "hidden" :name "count" :value "1")' - ' (button :type "submit" :class "relative inline-flex items-center justify-center text-sm font-medium text-stone-500 hover:bg-emerald-50"' - ' (span :class "relative inline-flex items-center justify-center"' - ' (i :class "fa fa-cart-plus text-4xl" :aria-hidden "true")))))', - cid=f"cart-{slug}", act=action, csrf=csrf, + return render( + "market-cart-add-empty", + cart_id=f"cart-{slug}", action=action, csrf=csrf, ) cart_href = cart_url_fn("/") if callable(cart_url_fn) else "/" - return sexp( - '(div :id cid' - ' (div :class "rounded flex items-center gap-2"' - ' (form :action act :method "post" :hx-post act :hx-target "#cart-mini" :hx-swap "outerHTML"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (input :type "hidden" :name "count" :value minus)' - ' (button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "-"))' - ' (a :class "relative inline-flex items-center justify-center text-emerald-700" :href ch' - ' (span :class "relative inline-flex items-center justify-center"' - ' (i :class "fa-solid fa-shopping-cart text-2xl" :aria-hidden "true")' - ' (span :class "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"' - ' (span :class "flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold" qty))))' - ' (form :action act :method "post" :hx-post act :hx-target "#cart-mini" :hx-swap "outerHTML"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (input :type "hidden" :name "count" :value plus)' - ' (button :type "submit" :class "inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl" "+"))))', - cid=f"cart-{slug}", act=action, csrf=csrf, ch=cart_href, - minus=str(quantity - 1), plus=str(quantity + 1), qty=str(quantity), + return render( + "market-cart-add-quantity", + cart_id=f"cart-{slug}", action=action, csrf=csrf, + minus_val=str(quantity - 1), plus_val=str(quantity + 1), + quantity=str(quantity), cart_href=cart_href, ) @@ -354,13 +276,10 @@ def _mobile_nav_panel_html(ctx: dict) -> str: all_href = prefix + url_for("market.browse.browse_all") + qs all_active = (category_label == "All Products") - items = sexp( - '(a :role "option" :href ah :hx-get ah :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' :aria-selected (if aa "true" "false")' - ' :class (str "block rounded-lg px-3 py-3 text-base hover:bg-stone-50 " sc)' - ' (div :class "prose prose-stone max-w-none" "All"))', - ah=all_href, hs=hx_select, aa=all_active, sc=select_colours, + items = render( + "market-mobile-all-link", + href=all_href, hx_select=hx_select, active=all_active, + select_colours=select_colours, ) for cat, data in categories.items(): @@ -369,24 +288,15 @@ def _mobile_nav_panel_html(ctx: dict) -> str: cat_href = prefix + url_for("market.browse.browse_top", top_slug=cat_slug) + qs bg_cls = " bg-stone-900 text-white hover:bg-stone-900" if cat_active else "" - chevron = sexp( - '(svg :class "w-4 h-4 shrink-0 transition-transform group-open/cat:rotate-180"' - ' :viewBox "0 0 20 20" :fill "currentColor"' - ' (path :fill-rule "evenodd" :clip-rule "evenodd"' - ' :d "M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"))', - ) + chevron = render("market-mobile-chevron") cat_count = data.get("count", 0) - summary_html = sexp( - '(summary :class (str "flex items-center justify-between cursor-pointer select-none block rounded-lg px-3 py-3 text-base hover:bg-stone-50" bg)' - ' (a :href ch :hx-get ch :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' :class (str "font-medium " sc " flex flex-row gap-2")' - ' (div cn)' - ' (div :aria-label cl cn2))' - ' (raw! chev))', - bg=bg_cls, ch=cat_href, hs=hx_select, sc=select_colours, - cn=cat, cl=f"{cat_count} products", cn2=str(cat_count), chev=chevron, + summary_html = render( + "market-mobile-cat-summary", + bg_cls=bg_cls, href=cat_href, hx_select=hx_select, + select_colours=select_colours, cat_name=cat, + count_label=f"{cat_count} products", count_str=str(cat_count), + chevron_html=chevron, ) subs = data.get("subs", []) @@ -398,44 +308,23 @@ def _mobile_nav_panel_html(ctx: dict) -> str: sub_active = (cat_active and sub_slug == sub.get("slug")) sub_label = sub.get("html_label") or sub.get("name", "") sub_count = sub.get("count", 0) - sub_links += sexp( - '(a :class (str "snap-start px-2 py-3 rounded " sc " flex flex-row gap-2")' - ' :aria-selected (if sa "true" "false")' - ' :href sh :hx-get sh :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' (div sl)' - ' (div :aria-label scl sct))', - sc=select_colours, sa=sub_active, sh=sub_href, hs=hx_select, - sl=sub_label, scl=f"{sub_count} products", sct=str(sub_count), + sub_links += render( + "market-mobile-sub-link", + select_colours=select_colours, active=sub_active, + href=sub_href, hx_select=hx_select, label=sub_label, + count_label=f"{sub_count} products", count_str=str(sub_count), ) - subs_html = sexp( - '(div :class "pb-3 pl-2"' - ' (div :data-peek-viewport "" :data-peek-size-px "18" :data-peek-edge "bottom" :data-peek-mask "true" :class "m-2 bg-stone-100"' - ' (div :data-peek-inner "" :class "grid grid-cols-1 gap-1 snap-y snap-mandatory pr-1" :aria-label "Subcategories"' - ' (raw! sl))))', - sl=sub_links, - ) + subs_html = render("market-mobile-subs-panel", links_html=sub_links) else: view_href = prefix + url_for("market.browse.browse_top", top_slug=cat_slug) + qs - subs_html = sexp( - '(div :class "pb-3 pl-2"' - ' (a :class "px-2 py-1 rounded hover:bg-stone-100 block"' - ' :href vh :hx-get vh :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' "View all"))', - vh=view_href, hs=hx_select, - ) + subs_html = render("market-mobile-view-all", href=view_href, hx_select=hx_select) - items += sexp( - '(details :class "group/cat py-1" :open op' - ' (raw! sh) (raw! subh))', - op=cat_active or None, sh=summary_html, subh=subs_html, + items += render( + "market-mobile-cat-details", + open=cat_active or None, summary_html=summary_html, subs_html=subs_html, ) - return sexp( - '(div :class "px-4 py-2" (div :class "divide-y" (raw! items)))', - items=items, - ) + return render("market-mobile-nav-wrapper", items_html=items) # --------------------------------------------------------------------------- @@ -477,31 +366,22 @@ def _product_card_html(p: dict, ctx: dict) -> str: labels_html = "" if callable(asset_url_fn): for l in labels: - labels_html += sexp( - '(img :src src :alt ""' - ' :class "pointer-events-none absolute inset-0 w-full h-full object-contain object-top")', + labels_html += render( + "market-label-overlay", src=asset_url_fn("labels/" + l + ".svg"), ) - img_html = sexp( - '(div :class "w-full aspect-square bg-stone-100 relative"' - ' (figure :class "inline-block w-full h-full"' - ' (div :class "relative w-full h-full"' - ' (img :src im :alt "no image" :class "absolute inset-0 w-full h-full object-contain object-top" :loading "lazy" :decoding "async" :fetchpriority "low")' - ' (raw! lh))' - ' (figcaption :class (str "mt-2 text-sm text-center" bh " text-stone-600") br)))', - im=image, lh=labels_html, bh=brand_highlight, br=brand, + img_html = render( + "market-card-image", + image=image, labels_html=labels_html, + brand_highlight=brand_highlight, brand=brand, ) else: labels_list = "" for l in labels: - labels_list += sexp('(li l)', l=l) - img_html = sexp( - '(div :class "w-full aspect-square bg-stone-100 relative"' - ' (div :class "p-2 flex flex-col items-center justify-center gap-2 text-red-500 h-full relative"' - ' (div :class "text-stone-400 text-xs" "No image")' - ' (ul :class "flex flex-row gap-1" (raw! ll))' - ' (div :class "text-stone-900 text-center line-clamp-3 break-words [overflow-wrap:anywhere]" br)))', - ll=labels_list, br=brand, + labels_list += render("market-card-label-item", label=l) + img_html = render( + "market-card-no-image", + labels_html=labels_list, brand=brand, ) price_html = _card_price_html(p) @@ -520,41 +400,25 @@ def _product_card_html(p: dict, ctx: dict) -> str: found = s in selected_stickers src = asset_url_fn(f"stickers/{s}.svg") ring = " ring-2 ring-emerald-500 rounded" if found else "" - sticker_items += sexp( - '(img :src src :alt sn :class (str "w-6 h-6" ring))', - src=src, sn=s, ring=ring, - ) - stickers_html = sexp( - '(div :class "flex flex-row justify-center gap-2 p-2" (raw! si))', - si=sticker_items, - ) + sticker_items += render("market-card-sticker", src=src, name=s, ring_cls=ring) + stickers_html = render("market-card-stickers", items_html=sticker_items) # Title with search highlight title = p.get("title", "") if search and search.lower() in title.lower(): idx = title.lower().index(search.lower()) - highlighted = sexp( - '(<> pre (mark mid) post)', + highlighted = render( + "market-card-highlight", pre=title[:idx], mid=title[idx:idx+len(search)], post=title[idx+len(search):], ) else: - highlighted = sexp('(<> t)', t=title) + highlighted = render("market-card-text", text=title) - return sexp( - '(div :class "flex flex-col rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden relative"' - ' (raw! lk)' - ' (a :href ih :hx-get ih :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' (raw! imh) (raw! ph))' - ' (div :class "flex justify-center" (raw! ah))' - ' (a :href ih :hx-get ih :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' (raw! sth)' - ' (div :class "text-sm font-medium text-stone-800 text-center line-clamp-3 break-words [overflow-wrap:anywhere]"' - ' (raw! hl))))', - lk=like_html, ih=item_href, hs=hx_select, - imh=img_html, ph=price_html, ah=add_html, - sth=stickers_html, hl=highlighted, + return render( + "market-product-card", + like_html=like_html, href=item_href, hx_select=hx_select, + image_html=img_html, price_html=price_html, add_html=add_html, + stickers_html=stickers_html, title_html=highlighted, ) @@ -564,14 +428,10 @@ def _like_button_html(slug: str, liked: bool, csrf: str, ctx: dict) -> str: action = url_for("market.browse.product.like_toggle", product_slug=slug) icon_cls = "fa-solid fa-heart text-red-500" if liked else "fa-regular fa-heart text-stone-400" - return sexp( - '(div :class "absolute top-2 right-2 z-10 text-6xl md:text-xl"' - ' (form :id fid :action act :method "post"' - ' :hx-post act :hx-target (str "#like-" slug) :hx-swap "outerHTML"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (button :type "submit" :class "cursor-pointer"' - ' (i :class ic :aria-hidden "true"))))', - fid=f"like-{slug}", act=action, slug=slug, csrf=csrf, ic=icon_cls, + return render( + "market-like-button", + form_id=f"like-{slug}", action=action, slug=slug, + csrf=csrf, icon_cls=icon_cls, ) @@ -665,34 +525,18 @@ def _product_cards_html(ctx: dict) -> str: next_url = prefix + current_local_href + next_qs # Mobile sentinel - parts.append(sexp( - '(div :id mid' - ' :class "block md:hidden h-[60vh] opacity-0 pointer-events-none js-mobile-sentinel"' - ' :hx-get nu :hx-trigger "intersect once delay:250ms, sentinelmobile:retry"' - ' :hx-swap "outerHTML"' - ' :_ mhs' - ' :role "status" :aria-live "polite" :aria-hidden "true"' - ' (div :class "js-loading text-center text-xs text-stone-400" "loading...")' - ' (div :class "js-neterr hidden text-center text-xs text-stone-400" "Retrying..."))', - mid=f"sentinel-{page}-m", nu=next_url, mhs=_MOBILE_SENTINEL_HS, + parts.append(render( + "market-sentinel-mobile", + id=f"sentinel-{page}-m", next_url=next_url, hyperscript=_MOBILE_SENTINEL_HS, )) # Desktop sentinel - parts.append(sexp( - '(div :id did' - ' :class "hidden md:block h-4 opacity-0 pointer-events-none"' - ' :hx-get nu :hx-trigger "intersect once delay:250ms, sentinel:retry"' - ' :hx-swap "outerHTML"' - ' :_ dhs' - ' :role "status" :aria-live "polite" :aria-hidden "true"' - ' (div :class "js-loading text-center text-xs text-stone-400" "loading...")' - ' (div :class "js-neterr hidden text-center text-xs text-stone-400" "Retrying..."))', - did=f"sentinel-{page}-d", nu=next_url, dhs=_DESKTOP_SENTINEL_HS, + parts.append(render( + "market-sentinel-desktop", + id=f"sentinel-{page}-d", next_url=next_url, hyperscript=_DESKTOP_SENTINEL_HS, )) else: - parts.append(sexp( - '(div :class "col-span-full mt-4 text-center text-xs text-stone-400" "End of results")', - )) + parts.append(render("market-sentinel-end")) return "".join(parts) @@ -726,10 +570,7 @@ def _desktop_filter_html(ctx: dict) -> str: search_html = search_desktop_html(ctx) # Category summary + sort + like + labels + stickers - cat_inner = sexp( - '(div :class "mb-4" (div :class "text-2xl uppercase tracking-wide text-black-500" cl))', - cl=category_label, - ) + cat_inner = render("market-filter-category-label", label=category_label) if sort_options: cat_inner += _sort_stickers_html(sort_options, sort, ctx) @@ -737,11 +578,7 @@ def _desktop_filter_html(ctx: dict) -> str: like_labels = _like_filter_html(liked, liked_count, ctx) if labels: like_labels += _labels_filter_html(labels, selected_labels, ctx, prefix="nav-labels") - cat_inner += sexp( - '(nav :aria-label "like" :class "flex flex-row justify-center w-full p-0 m-0 border bg-white shadow-sm rounded-xl gap-1"' - ' (raw! ll))', - ll=like_labels, - ) + cat_inner += render("market-filter-like-labels-nav", inner_html=like_labels) if stickers: cat_inner += _stickers_filter_html(stickers, selected_stickers, ctx) @@ -749,19 +586,13 @@ def _desktop_filter_html(ctx: dict) -> str: if subs_local and top_local_href: cat_inner += _subcategory_selector_html(subs_local, top_local_href, sub_slug, ctx) - cat_summary = sexp( - '(div :id "category-summary-desktop" :hxx-swap-oob "outerHTML" (raw! ci))', - ci=cat_inner, - ) + cat_summary = render("market-desktop-category-summary", inner_html=cat_inner) # Brand filter brand_inner = "" if brands: brand_inner = _brand_filter_html(brands, selected_brands, ctx) - brand_summary = sexp( - '(div :id "filter-summary-desktop" :hxx-swap-oob "outerHTML" (raw! bi))', - bi=brand_inner, - ) + brand_summary = render("market-desktop-brand-summary", inner_html=brand_inner) return search_html + cat_summary + brand_summary @@ -789,25 +620,13 @@ def _mobile_filter_summary_html(ctx: dict) -> str: if sort and sort_options: for k, l, i in sort_options: if k == sort and callable(asset_url_fn): - chips += sexp( - '(ul :class "relative inline-flex items-center justify-center gap-2"' - ' (li :role "listitem" (img :src src :alt lb :class "w-10 h-10")))', - src=asset_url_fn(i), lb=l, - ) + chips += render("market-mobile-chip-sort", src=asset_url_fn(i), label=l) if liked: - liked_inner = sexp( - '(i :aria-hidden "true" :class "fa-solid fa-heart text-red-500 text-[40px] leading-none")', - ) + liked_inner = render("market-mobile-chip-liked-icon") if liked_count is not None: cls = "text-[10px] text-stone-500" if liked_count != 0 else "text-md text-red-500 font-bold" - liked_inner += sexp( - '(div :class (str cls " mt-1 leading-none tabular-nums") lc)', - cls=cls, lc=str(liked_count), - ) - chips += sexp( - '(div :class "flex flex-col items-center gap-1 pb-1" (raw! li))', - li=liked_inner, - ) + liked_inner += render("market-mobile-chip-count", cls=cls, count=str(liked_count)) + chips += render("market-mobile-chip-liked", inner_html=liked_inner) # Selected labels if selected_labels: @@ -815,24 +634,15 @@ def _mobile_filter_summary_html(ctx: dict) -> str: for sl in selected_labels: for lb in labels: if lb.get("name") == sl and callable(asset_url_fn): - li_inner = sexp( - '(img :src src :alt sn :class "w-10 h-10")', - src=asset_url_fn("nav-labels/" + sl + ".svg"), sn=sl, + li_inner = render( + "market-mobile-chip-image", + src=asset_url_fn("nav-labels/" + sl + ".svg"), name=sl, ) if lb.get("count") is not None: cls = "text-[10px] text-stone-500" if lb["count"] != 0 else "text-md text-red-500 font-bold" - li_inner += sexp( - '(div :class (str cls " mt-1 leading-none tabular-nums") ct)', - cls=cls, ct=str(lb["count"]), - ) - label_items += sexp( - '(li :role "listitem" :class "flex flex-col items-center gap-1 pb-1" (raw! li))', - li=li_inner, - ) - chips += sexp( - '(ul :class "relative inline-flex items-center justify-center gap-2" (raw! li))', - li=label_items, - ) + li_inner += render("market-mobile-chip-count", cls=cls, count=str(lb["count"])) + label_items += render("market-mobile-chip-item", inner_html=li_inner) + chips += render("market-mobile-chip-list", items_html=label_items) # Selected stickers if selected_stickers: @@ -840,24 +650,15 @@ def _mobile_filter_summary_html(ctx: dict) -> str: for ss in selected_stickers: for st in stickers: if st.get("name") == ss and callable(asset_url_fn): - si_inner = sexp( - '(img :src src :alt sn :class "w-10 h-10")', - src=asset_url_fn("stickers/" + ss + ".svg"), sn=ss, + si_inner = render( + "market-mobile-chip-image", + src=asset_url_fn("stickers/" + ss + ".svg"), name=ss, ) if st.get("count") is not None: cls = "text-[10px] text-stone-500" if st["count"] != 0 else "text-md text-red-500 font-bold" - si_inner += sexp( - '(div :class (str cls " mt-1 leading-none tabular-nums") ct)', - cls=cls, ct=str(st["count"]), - ) - sticker_items += sexp( - '(li :role "listitem" :class "flex flex-col items-center gap-1 pb-1" (raw! si))', - si=si_inner, - ) - chips += sexp( - '(ul :class "relative inline-flex items-center justify-center gap-2" (raw! si))', - si=sticker_items, - ) + si_inner += render("market-mobile-chip-count", cls=cls, count=str(st["count"])) + sticker_items += render("market-mobile-chip-item", inner_html=si_inner) + chips += render("market-mobile-chip-list", items_html=sticker_items) # Selected brands if selected_brands: @@ -868,38 +669,21 @@ def _mobile_filter_summary_html(ctx: dict) -> str: if br.get("name") == b: count = br.get("count", 0) if count: - brand_items += sexp( - '(li :role "listitem" :class "flex flex-row items-center gap-2"' - ' (div :class "text-md" bn) (div :class "text-md" ct))', - bn=b, ct=str(count), - ) + brand_items += render("market-mobile-chip-brand", name=b, count=str(count)) else: - brand_items += sexp( - '(li :role "listitem" :class "flex flex-row items-center gap-2"' - ' (div :class "text-md text-red-500" bn) (div :class "text-xl text-red-500" "0"))', - bn=b, - ) - chips += sexp('(ul (raw! bi))', bi=brand_items) + brand_items += render("market-mobile-chip-brand-zero", name=b) + chips += render("market-mobile-chip-brand-list", items_html=brand_items) - chips_html = sexp( - '(div :class "flex flex-row items-start gap-2" (raw! ch))', - ch=chips, - ) + chips_html = render("market-mobile-chips-row", inner_html=chips) # Full mobile filter details from shared.utils import route_prefix prefix = route_prefix() mobile_filter = _mobile_filter_content_html(ctx, prefix) - return sexp( - '(details :class "md:hidden group" :id "/filter"' - ' (summary :class "cursor-pointer select-none" :id "filter-summary-mobile"' - ' (raw! sb)' - ' (div :class "col-span-12 min-w-0 grid grid-cols-1 gap-1 bg-gray-100 px-2" :role "list"' - ' (raw! ch)))' - ' (div :id "filter-details-mobile" :style "display:contents"' - ' (raw! mf)))', - sb=search_bar, ch=chips_html, mf=mobile_filter, + return render( + "market-mobile-filter-summary", + search_bar=search_bar, chips_html=chips_html, filter_html=mobile_filter, ) @@ -930,24 +714,13 @@ def _mobile_filter_content_html(ctx: dict, prefix: str) -> str: has_filters = search or selected_labels or selected_stickers or selected_brands if has_filters and callable(qs_fn): clear_url = prefix + current_local_href + qs_fn({"clear_filters": True}) - parts.append(sexp( - '(div :class "flex flex-row justify-center"' - ' (a :href cu :hx-get cu :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' :role "button" :title "clear filters" :aria-label "clear filters"' - ' :class "flex flex-col items-center justify-start p-1 rounded bg-stone-200 text-black cursor-pointer"' - ' (span :class "mt-1 leading-none tabular-nums" "clear filters")))', - cu=clear_url, hs=hx_select, - )) + parts.append(render("market-mobile-clear-filters", href=clear_url, hx_select=hx_select)) # Like + labels row like_labels = _like_filter_html(liked, liked_count, ctx, mobile=True) if labels: like_labels += _labels_filter_html(labels, selected_labels, ctx, prefix="nav-labels", mobile=True) - parts.append(sexp( - '(div :class "flex flex-row gap-2 justify-center items-center" (raw! ll))', - ll=like_labels, - )) + parts.append(render("market-mobile-like-labels-row", inner_html=like_labels)) # Stickers if stickers: @@ -978,18 +751,11 @@ def _sort_stickers_html(sort_options: list, current_sort: str, ctx: dict, mobile active = (k == current_sort) ring = " ring-2 ring-emerald-500 rounded" if active else "" src = asset_url_fn(icon) if callable(asset_url_fn) else icon - items += sexp( - '(a :href h :hx-get h :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' :class (str "flex flex-col items-center gap-1 p-1 cursor-pointer" ring)' - ' (img :src src :alt lb :class "w-10 h-10")' - ' (span :class "text-xs" lb))', - h=href, hs=hx_select, ring=ring, src=src, lb=label, + items += render( + "market-filter-sort-item", + href=href, hx_select=hx_select, ring_cls=ring, src=src, label=label, ) - return sexp( - '(div :class "flex flex-row gap-2 justify-center p-1" (raw! items))', - items=items, - ) + return render("market-filter-sort-row", items_html=items) def _like_filter_html(liked: bool, liked_count: int, ctx: dict, mobile: bool = False) -> str: @@ -1007,12 +773,9 @@ def _like_filter_html(liked: bool, liked_count: int, ctx: dict, mobile: bool = F icon_cls = "fa-solid fa-heart text-red-500" if liked else "fa-regular fa-heart text-stone-400" size = "text-[40px]" if mobile else "text-2xl" - return sexp( - '(a :href h :hx-get h :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' :class "flex flex-col items-center gap-1 p-1 cursor-pointer"' - ' (i :aria-hidden "true" :class (str ic " " sz " leading-none")))', - h=href, hs=hx_select, ic=icon_cls, sz=size, + return render( + "market-filter-like", + href=href, hx_select=hx_select, icon_cls=icon_cls, size_cls=size, ) @@ -1037,12 +800,9 @@ def _labels_filter_html(labels: list, selected: list, ctx: dict, *, href = "#" ring = " ring-2 ring-emerald-500 rounded" if is_sel else "" src = asset_url_fn(f"{prefix}/{name}.svg") if callable(asset_url_fn) else "" - items += sexp( - '(a :href h :hx-get h :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' :class (str "flex flex-col items-center gap-1 p-1 cursor-pointer" ring)' - ' (img :src src :alt nm :class "w-10 h-10"))', - h=href, hs=hx_select, ring=ring, src=src, nm=name, + items += render( + "market-filter-label-item", + href=href, hx_select=hx_select, ring_cls=ring, src=src, name=name, ) return items @@ -1069,18 +829,12 @@ def _stickers_filter_html(stickers: list, selected: list, ctx: dict, mobile: boo ring = " ring-2 ring-emerald-500 rounded" if is_sel else "" src = asset_url_fn(f"stickers/{name}.svg") if callable(asset_url_fn) else "" cls = "text-[10px] text-stone-500" if count != 0 else "text-md text-red-500 font-bold" - items += sexp( - '(a :href h :hx-get h :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' :class (str "flex flex-col items-center gap-1 p-1 cursor-pointer" ring)' - ' (img :src src :alt nm :class "w-6 h-6")' - ' (span :class cls ct))', - h=href, hs=hx_select, ring=ring, src=src, nm=name, cls=cls, ct=str(count), + items += render( + "market-filter-sticker-item", + href=href, hx_select=hx_select, ring_cls=ring, + src=src, name=name, count_cls=cls, count=str(count), ) - return sexp( - '(div :class "flex flex-wrap gap-2 justify-center p-1" (raw! items))', - items=items, - ) + return render("market-filter-stickers-row", items_html=items) def _brand_filter_html(brands: list, selected: list, ctx: dict, mobile: bool = False) -> str: @@ -1103,17 +857,12 @@ def _brand_filter_html(brands: list, selected: list, ctx: dict, mobile: bool = F href = "#" bg = " bg-yellow-200" if is_sel else "" cls = "text-md" if count else "text-md text-red-500" - items += sexp( - '(a :href h :hx-get h :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' :class (str "flex flex-row items-center gap-2 px-2 py-1 rounded hover:bg-stone-100" bg)' - ' (div :class cls nm) (div :class cls ct))', - h=href, hs=hx_select, bg=bg, cls=cls, nm=name, ct=str(count), + items += render( + "market-filter-brand-item", + href=href, hx_select=hx_select, bg_cls=bg, + name_cls=cls, name=name, count=str(count), ) - return sexp( - '(div :class "space-y-1 p-2" (raw! items))', - items=items, - ) + return render("market-filter-brands-panel", items_html=items) def _subcategory_selector_html(subs: list, top_href: str, current_sub: str, ctx: dict) -> str: @@ -1124,12 +873,9 @@ def _subcategory_selector_html(subs: list, top_href: str, current_sub: str, ctx: all_cls = " bg-stone-200 font-medium" if not current_sub else "" all_full_href = rp + top_href - items = sexp( - '(a :href ah :hx-get ah :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' :class (str "block px-2 py-1 rounded hover:bg-stone-100" ac)' - ' "All")', - ah=all_full_href, hs=hx_select, ac=all_cls, + items = render( + "market-filter-subcategory-item", + href=all_full_href, hx_select=hx_select, active_cls=all_cls, name="All", ) for sub in subs: slug = sub.get("slug", "") @@ -1138,17 +884,11 @@ def _subcategory_selector_html(subs: list, top_href: str, current_sub: str, ctx: active = (slug == current_sub) active_cls = " bg-stone-200 font-medium" if active else "" full_href = rp + href - items += sexp( - '(a :href fh :hx-get fh :hx-target "#main-panel"' - ' :hx-select hs :hx-swap "outerHTML" :hx-push-url "true"' - ' :class (str "block px-2 py-1 rounded hover:bg-stone-100" ac)' - ' nm)', - fh=full_href, hs=hx_select, ac=active_cls, nm=name, + items += render( + "market-filter-subcategory-item", + href=full_href, hx_select=hx_select, active_cls=active_cls, name=name, ) - return sexp( - '(div :class "mt-4 space-y-1" (raw! items))', - items=items, - ) + return render("market-filter-subcategory-panel", items_html=items) # --------------------------------------------------------------------------- @@ -1182,83 +922,52 @@ def _product_detail_html(d: dict, ctx: dict) -> str: labels_overlay = "" if callable(asset_url_fn): for l in labels: - labels_overlay += sexp( - '(img :src src :alt ""' - ' :class "pointer-events-none absolute inset-0 w-full h-full object-contain object-top")', + labels_overlay += render( + "market-label-overlay", src=asset_url_fn("labels/" + l + ".svg"), ) - gallery_inner = sexp( - '(<> (raw! lk)' - ' (figure :class "inline-block"' - ' (div :class "relative w-full aspect-square"' - ' (img :data-main-img "" :src im :alt alt' - ' :class "w-full h-full object-contain object-top" :loading "eager" :decoding "async")' - ' (raw! lo))' - ' (figcaption :class "mt-2 text-sm text-stone-600 text-center" br)))', - lk=like_html, im=images[0], alt=d.get("title", ""), - lo=labels_overlay, br=brand, + gallery_inner = render( + "market-detail-gallery-inner", + like_html=like_html, image=images[0], alt=d.get("title", ""), + labels_html=labels_overlay, brand=brand, ) # Prev/next buttons nav_buttons = "" if len(images) > 1: - nav_buttons = sexp( - '(<>' - ' (button :type "button" :data-prev ""' - ' :class "absolute left-2 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-12 h-12 md:w-14 md:h-14 rounded-full bg-white/90 hover:bg-white shadow-lg text-3xl md:text-4xl"' - ' :title "Previous" "\u2039")' - ' (button :type "button" :data-next ""' - ' :class "absolute right-2 top-1/2 -translate-y-1/2 z-10 grid place-items-center w-12 h-12 md:w-14 md:h-14 rounded-full bg-white/90 hover:bg-white shadow-lg text-3xl md:text-4xl"' - ' :title "Next" "\u203a"))', - ) + nav_buttons = render("market-detail-nav-buttons") - gallery_html = sexp( - '(div :class "relative rounded-xl overflow-hidden bg-stone-100"' - ' (raw! gi) (raw! nb))', - gi=gallery_inner, nb=nav_buttons, + gallery_html = render( + "market-detail-gallery", + inner_html=gallery_inner, nav_html=nav_buttons, ) # Thumbnails if len(images) > 1: thumbs = "" for i, u in enumerate(images): - thumbs += sexp( - '(<> (button :type "button" :data-thumb ""' - ' :class "shrink-0 rounded-lg overflow-hidden bg-stone-100 hover:opacity-90 ring-offset-2"' - ' :title ti' - ' (img :src u :class "h-16 w-16 object-contain" :alt ai :loading "lazy" :decoding "async"))' - ' (span :data-image-src u :class "hidden"))', - ti=f"Image {i+1}", u=u, ai=f"thumb {i+1}", + thumbs += render( + "market-detail-thumb", + title=f"Image {i+1}", src=u, alt=f"thumb {i+1}", ) - gallery_html += sexp( - '(div :class "flex flex-row justify-center"' - ' (div :class "mt-3 flex gap-2 overflow-x-auto no-scrollbar" (raw! th)))', - th=thumbs, - ) + gallery_html += render("market-detail-thumbs", thumbs_html=thumbs) else: like_html = "" if user: like_html = _like_button_html(slug, liked_by_current_user, csrf, ctx) - gallery_html = sexp( - '(div :class "relative aspect-square bg-stone-100 rounded-xl flex items-center justify-center text-stone-400"' - ' (raw! lk) "No image")', - lk=like_html, - ) + gallery_html = render("market-detail-no-image", like_html=like_html) # Stickers below gallery stickers_html = "" if stickers and callable(asset_url_fn): sticker_items = "" for s in stickers: - sticker_items += sexp( - '(img :src src :alt sn :class "w-10 h-10")', - src=asset_url_fn("stickers/" + s + ".svg"), sn=s, + sticker_items += render( + "market-detail-sticker", + src=asset_url_fn("stickers/" + s + ".svg"), name=s, ) - stickers_html = sexp( - '(div :class "p-2 flex flex-row justify-center gap-2" (raw! si))', - si=sticker_items, - ) + stickers_html = render("market-detail-stickers", items_html=sticker_items) # Right column: prices, description, sections pr = _set_prices(d) @@ -1268,17 +977,14 @@ def _product_detail_html(d: dict, ctx: dict) -> str: extras = "" ppu = d.get("price_per_unit") or d.get("price_per_unit_raw") if ppu: - extras += sexp( - '(div (str "Unit price: " ps))', - ps=_price_str(d.get("price_per_unit"), d.get("price_per_unit_raw"), d.get("price_per_unit_currency")), + extras += render( + "market-detail-unit-price", + price=_price_str(d.get("price_per_unit"), d.get("price_per_unit_raw"), d.get("price_per_unit_currency")), ) if d.get("case_size_raw"): - extras += sexp('(div (str "Case size: " cs))', cs=d["case_size_raw"]) + extras += render("market-detail-case-size", size=d["case_size_raw"]) if extras: - details_inner += sexp( - '(div :class "mt-2 space-y-1 text-sm text-stone-600" (raw! ex))', - ex=extras, - ) + details_inner += render("market-detail-extras", inner_html=extras) # Description desc_short = d.get("description_short") @@ -1286,43 +992,27 @@ def _product_detail_html(d: dict, ctx: dict) -> str: if desc_short or desc_html: desc_inner = "" if desc_short: - desc_inner += sexp('(p :class "leading-relaxed text-lg" ds)', ds=desc_short) + desc_inner += render("market-detail-desc-short", text=desc_short) if desc_html: - desc_inner += sexp( - '(div :class "max-w-none text-sm leading-relaxed" (raw! dh))', - dh=desc_html, - ) - details_inner += sexp( - '(div :class "mt-4 text-stone-800 space-y-3" (raw! di))', - di=desc_inner, - ) + desc_inner += render("market-detail-desc-html", html=desc_html) + details_inner += render("market-detail-desc-wrapper", inner_html=desc_inner) # Sections (expandable) sections = d.get("sections", []) if sections: sec_items = "" for sec in sections: - sec_items += sexp( - '(details :class "group rounded-xl border bg-white shadow-sm open:shadow p-0"' - ' (summary :class "cursor-pointer select-none px-4 py-3 flex items-center justify-between"' - ' (span :class "font-medium" st)' - ' (span :class "ml-2 text-xl transition-transform group-open:rotate-180" "\u2304"))' - ' (div :class "px-4 pb-4 max-w-none text-sm leading-relaxed" (raw! sh)))', - st=sec.get("title", ""), sh=sec.get("html", ""), + sec_items += render( + "market-detail-section", + title=sec.get("title", ""), html=sec.get("html", ""), ) - details_inner += sexp( - '(div :class "mt-8 space-y-3" (raw! si))', - si=sec_items, - ) + details_inner += render("market-detail-sections", items_html=sec_items) - details_html = sexp('(div :class "md:col-span-3" (raw! di))', di=details_inner) + details_html = render("market-detail-right-col", inner_html=details_inner) - return sexp( - '(<> (div :class "mt-3 grid grid-cols-1 md:grid-cols-5 gap-6" :data-gallery-root ""' - ' (div :class "md:col-span-2" (raw! gh) (raw! sh))' - ' (raw! dh))' - ' (div :class "pb-8"))', - gh=gallery_html, sh=stickers_html, dh=details_html, + return render( + "market-detail-layout", + gallery_html=gallery_html, stickers_html=stickers_html, details_html=details_html, ) @@ -1348,34 +1038,34 @@ def _product_meta_html(d: dict, ctx: dict) -> str: price = d.get("special_price") or d.get("regular_price") or d.get("rrp") price_currency = d.get("special_price_currency") or d.get("regular_price_currency") or d.get("rrp_currency") - parts = sexp('(title t)', t=title) - parts += sexp('(meta :name "description" :content desc)', desc=description) + parts = render("market-meta-title", title=title) + parts += render("market-meta-description", description=description) if canonical: - parts += sexp('(link :rel "canonical" :href can)', can=canonical) + parts += render("market-meta-canonical", href=canonical) # OpenGraph site_title = ctx.get("base_title", "") - parts += sexp('(meta :property "og:site_name" :content st)', st=site_title) - parts += sexp('(meta :property "og:type" :content "product")') - parts += sexp('(meta :property "og:title" :content t)', t=title) - parts += sexp('(meta :property "og:description" :content desc)', desc=description) + parts += render("market-meta-og", property="og:site_name", content=site_title) + parts += render("market-meta-og", property="og:type", content="product") + parts += render("market-meta-og", property="og:title", content=title) + parts += render("market-meta-og", property="og:description", content=description) if canonical: - parts += sexp('(meta :property "og:url" :content can)', can=canonical) + parts += render("market-meta-og", property="og:url", content=canonical) if image_url: - parts += sexp('(meta :property "og:image" :content iu)', iu=image_url) + parts += render("market-meta-og", property="og:image", content=image_url) if price and price_currency: - parts += sexp('(meta :property "product:price:amount" :content pa)', pa=f"{price:.2f}") - parts += sexp('(meta :property "product:price:currency" :content pc)', pc=price_currency) + parts += render("market-meta-og", property="product:price:amount", content=f"{price:.2f}") + parts += render("market-meta-og", property="product:price:currency", content=price_currency) if brand: - parts += sexp('(meta :property "product:brand" :content br)', br=brand) + parts += render("market-meta-og", property="product:brand", content=brand) # Twitter card_type = "summary_large_image" if image_url else "summary" - parts += sexp('(meta :name "twitter:card" :content ct)', ct=card_type) - parts += sexp('(meta :name "twitter:title" :content t)', t=title) - parts += sexp('(meta :name "twitter:description" :content desc)', desc=description) + parts += render("market-meta-twitter", name="twitter:card", content=card_type) + parts += render("market-meta-twitter", name="twitter:title", content=title) + parts += render("market-meta-twitter", name="twitter:description", content=description) if image_url: - parts += sexp('(meta :name "twitter:image" :content iu)', iu=image_url) + parts += render("market-meta-twitter", name="twitter:image", content=image_url) # JSON-LD jsonld = { @@ -1397,10 +1087,7 @@ def _product_meta_html(d: dict, ctx: dict) -> str: "url": canonical, "availability": "https://schema.org/InStock", } - parts += sexp( - '(script :type "application/ld+json" (raw! jl))', - jl=json.dumps(jsonld), - ) + parts += render("market-meta-jsonld", json=json.dumps(jsonld)) return parts @@ -1431,39 +1118,22 @@ def _market_card_html(market: Any, page_info: dict, *, show_page_badge: bool = T title_html = "" if market_href: - title_html = sexp( - '(a :href mh :class "hover:text-emerald-700"' - ' (h2 :class "text-lg font-semibold text-stone-900" nm))', - mh=market_href, nm=name, - ) + title_html = render("market-market-card-title-link", href=market_href, name=name) else: - title_html = sexp( - '(h2 :class "text-lg font-semibold text-stone-900" nm)', - nm=name, - ) + title_html = render("market-market-card-title", name=name) desc_html = "" if description: - desc_html = sexp( - '(p :class "text-sm text-stone-600 mt-1 line-clamp-2" d)', - d=description, - ) + desc_html = render("market-market-card-desc", description=description) badge_html = "" if show_page_badge and p_title: badge_href = market_url(f"/{p_slug}/") - badge_html = sexp( - '(div :class "flex flex-wrap items-center gap-1.5 mt-3"' - ' (a :href bh :class "inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200"' - ' pt))', - bh=badge_href, pt=p_title, - ) + badge_html = render("market-market-card-badge", href=badge_href, title=p_title) - return sexp( - '(article :class "rounded-xl bg-white shadow-sm border border-stone-200 p-5 flex flex-col justify-between hover:border-stone-400 transition-colors"' - ' (div (raw! th) (raw! dh))' - ' (raw! bh))', - th=title_html, dh=desc_html, bh=badge_html, + return render( + "market-market-card", + title_html=title_html, desc_html=desc_html, badge_html=badge_html, ) @@ -1474,12 +1144,9 @@ def _market_cards_html(markets: list, page_info: dict, page: int, has_more: bool parts = [_market_card_html(m, page_info, show_page_badge=show_page_badge, post_slug=post_slug) for m in markets] if has_more: - parts.append(sexp( - '(div :id sid :class "h-4 opacity-0 pointer-events-none"' - ' :hx-get nu :hx-trigger "intersect once delay:250ms"' - ' :hx-swap "outerHTML" :role "status" :aria-hidden "true"' - ' (div :class "text-center text-xs text-stone-400" "loading..."))', - sid=f"sentinel-{page}", nu=next_url, + parts.append(render( + "market-market-sentinel", + id=f"sentinel-{page}", next_url=next_url, )) return "".join(parts) @@ -1490,11 +1157,9 @@ def _market_cards_html(markets: list, page_info: dict, page: int, has_more: bool def _oob_header_html(parent_id: str, child_id: str, row_html: str) -> str: """Wrap a header row in OOB div with child placeholder.""" - return sexp( - '(div :id pid :hx-swap-oob "outerHTML" :class "w-full"' - ' (div :class "w-full" (raw! rh)' - ' (div :id cid)))', - pid=parent_id, cid=child_id, rh=row_html, + return render( + "market-oob-header", + parent_id=parent_id, child_id=child_id, row_html=row_html, ) @@ -1509,20 +1174,12 @@ def _oob_header_html(parent_id: str, child_id: str, row_html: str) -> str: def _markets_grid(cards: str) -> str: """Wrap market cards in a grid.""" - return sexp( - '(div :class "max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4" (raw! c))', - c=cards, - ) + return render("market-markets-grid", cards_html=cards) def _no_markets_html(message: str = "No markets available") -> str: """Empty state for markets.""" - return sexp( - '(div :class "px-3 py-12 text-center text-stone-400"' - ' (i :class "fa fa-store text-4xl mb-3" :aria-hidden "true")' - ' (p :class "text-lg" msg))', - msg=message, - ) + return render("market-no-markets", message=message) async def render_all_markets_page(ctx: dict, markets: list, has_more: bool, @@ -1539,7 +1196,7 @@ async def render_all_markets_page(ctx: dict, markets: list, has_more: bool, content = _markets_grid(cards) else: content = _no_markets_html() - content += sexp('(div :class "pb-8")') + content += render("market-bottom-spacer") hdr = root_header_html(ctx) return full_page(ctx, header_rows_html=hdr, content_html=content) @@ -1559,7 +1216,7 @@ async def render_all_markets_oob(ctx: dict, markets: list, has_more: bool, content = _markets_grid(cards) else: content = _no_markets_html() - content += sexp('(div :class "pb-8")') + content += render("market-bottom-spacer") oobs = root_header_html(ctx, oob=True) return oob_page(ctx, oobs_html=oobs, content_html=content) @@ -1597,13 +1254,10 @@ async def render_page_markets_page(ctx: dict, markets: list, has_more: bool, content = _markets_grid(cards) else: content = _no_markets_html("No markets for this page") - content += sexp('(div :class "pb-8")') + content += render("market-bottom-spacer") hdr = root_header_html(ctx) - hdr += sexp( - '(div :id "root-header-child" :class "w-full" (raw! ph))', - ph=_post_header_html(ctx), - ) + hdr += render("market-header-child", inner_html=_post_header_html(ctx)) return full_page(ctx, header_rows_html=hdr, content_html=content) @@ -1624,7 +1278,7 @@ async def render_page_markets_oob(ctx: dict, markets: list, has_more: bool, content = _markets_grid(cards) else: content = _no_markets_html("No markets for this page") - content += sexp('(div :class "pb-8")') + content += render("market-bottom-spacer") oobs = _oob_header_html("post-header-child", "market-header-child", "") oobs += _post_header_html(ctx, oob=True) @@ -1654,10 +1308,7 @@ async def render_market_home_page(ctx: dict) -> str: hdr = root_header_html(ctx) child = _post_header_html(ctx) + _market_header_html(ctx) - hdr += sexp( - '(div :id "root-header-child" :class "w-full" (raw! h))', - h=child, - ) + hdr += render("market-header-child", inner_html=child) menu = _mobile_nav_panel_html(ctx) return full_page(ctx, header_rows_html=hdr, content_html=content, menu_html=menu) @@ -1678,25 +1329,12 @@ def _market_landing_content(post: dict) -> str: """Build market landing page content (excerpt + feature image + html).""" inner = "" if post.get("custom_excerpt"): - inner += sexp( - '(div :class "w-full text-center italic text-3xl p-2" ce)', - ce=post["custom_excerpt"], - ) + inner += render("market-landing-excerpt", text=post["custom_excerpt"]) if post.get("feature_image"): - inner += sexp( - '(div :class "mb-3 flex justify-center"' - ' (img :src fi :alt "" :class "rounded-lg w-full md:w-3/4 object-cover"))', - fi=post["feature_image"], - ) + inner += render("market-landing-image", src=post["feature_image"]) if post.get("html"): - inner += sexp( - '(div :class "blog-content p-2" (raw! h))', - h=post["html"], - ) - return sexp( - '(<> (article :class "relative w-full" (raw! inner)) (div :class "pb-8"))', - inner=inner, - ) + inner += render("market-landing-html", html=post["html"]) + return render("market-landing-content", inner_html=inner) # --------------------------------------------------------------------------- @@ -1705,10 +1343,7 @@ def _market_landing_content(post: dict) -> str: def _product_grid(cards_html: str) -> str: """Wrap product cards in a grid.""" - return sexp( - '(<> (div :class "grid grid-cols-1 sm:grid-cols-3 md:grid-cols-6 gap-3" (raw! c)) (div :class "pb-8"))', - c=cards_html, - ) + return render("market-product-grid", cards_html=cards_html) async def render_browse_page(ctx: dict) -> str: @@ -1718,10 +1353,7 @@ async def render_browse_page(ctx: dict) -> str: hdr = root_header_html(ctx) child = _post_header_html(ctx) + _market_header_html(ctx) - hdr += sexp( - '(div :id "root-header-child" :class "w-full" (raw! h))', - h=child, - ) + hdr += render("market-header-child", inner_html=child) menu = _mobile_nav_panel_html(ctx) filter_html = _mobile_filter_summary_html(ctx) aside_html = _desktop_filter_html(ctx) @@ -1762,10 +1394,7 @@ async def render_product_page(ctx: dict, d: dict) -> str: hdr = root_header_html(ctx) child = _post_header_html(ctx) + _market_header_html(ctx) + _product_header_html(ctx, d) - hdr += sexp( - '(div :id "root-header-child" :class "w-full" (raw! h))', - h=child, - ) + hdr += render("market-header-child", inner_html=child) return full_page(ctx, header_rows_html=hdr, content_html=content, meta_html=meta) @@ -1791,10 +1420,7 @@ async def render_product_admin_page(ctx: dict, d: dict) -> str: hdr = root_header_html(ctx) child = (_post_header_html(ctx) + _market_header_html(ctx) + _product_header_html(ctx, d) + _product_admin_header_html(ctx, d)) - hdr += sexp( - '(div :id "root-header-child" :class "w-full" (raw! h))', - h=child, - ) + hdr += render("market-header-child", inner_html=child) return full_page(ctx, header_rows_html=hdr, content_html=content) @@ -1814,12 +1440,11 @@ def _product_admin_header_html(ctx: dict, d: dict, *, oob: bool = False) -> str: slug = d.get("slug", "") link_href = url_for("market.browse.product.admin", product_slug=slug) - return sexp( - '(~menu-row :id "product-admin-row" :level 4' - ' :link-href lh :link-label "admin!!" :icon "fa fa-cog"' - ' :child-id "product-admin-header-child" :oob oob)', - lh=link_href, - oob=oob, + return render( + "menu-row", + id="product-admin-row", level=4, + link_href=link_href, link_label="admin!!", icon="fa fa-cog", + child_id="product-admin-header-child", oob=oob, ) @@ -1833,10 +1458,7 @@ async def render_market_admin_page(ctx: dict) -> str: hdr = root_header_html(ctx) child = _post_header_html(ctx) + _market_header_html(ctx) + _market_admin_header_html(ctx) - hdr += sexp( - '(div :id "root-header-child" :class "w-full" (raw! h))', - h=child, - ) + hdr += render("market-header-child", inner_html=child) return full_page(ctx, header_rows_html=hdr, content_html=content) @@ -1855,12 +1477,11 @@ def _market_admin_header_html(ctx: dict, *, oob: bool = False) -> str: from quart import url_for link_href = url_for("market.admin.admin") - return sexp( - '(~menu-row :id "market-admin-row" :level 3' - ' :link-href lh :link-label "admin" :icon "fa fa-cog"' - ' :child-id "market-admin-header-child" :oob oob)', - lh=link_href, - oob=oob, + return render( + "menu-row", + id="market-admin-row", level=3, + link_href=link_href, link_label="admin", icon="fa fa-cog", + child_id="market-admin-header-child", oob=oob, ) @@ -1892,15 +1513,11 @@ def render_like_toggle_button(slug: str, liked: bool, *, icon = "fa-regular fa-heart" label = f"Like this {item_type}" - return sexp( - '(button :class (str "flex items-center gap-1 " colour " hover:text-red-600 transition-colors w-[1em] h-[1em]")' - ' :hx-post lu :hx-target "this" :hx-swap "outerHTML" :hx-push-url "false"' - ' :hx-headers hh' - ' :hx-swap-settle "0ms" :aria-label lb' - ' (i :aria-hidden "true" :class ic))', - colour=colour, lu=like_url, - hh=f'{{"X-CSRFToken": "{csrf}"}}', - lb=label, ic=icon, + return render( + "market-like-toggle-button", + colour=colour, action=like_url, + hx_headers=f'{{"X-CSRFToken": "{csrf}"}}', + label=label, icon_cls=icon, ) @@ -1920,34 +1537,20 @@ def render_cart_added_response(cart: list, item: Any, d: dict) -> str: # 1. Cart mini icon OOB if count > 0: cart_href = _cart_url("/") - cart_mini = sexp( - '(div :id "cart-mini" :hx-swap-oob "outerHTML"' - ' (a :href ch :class "relative inline-flex items-center justify-center"' - ' (span :class "relative inline-flex items-center justify-center"' - ' (i :class "fa-solid fa-shopping-cart text-xl" :aria-hidden "true")' - ' (span :class "absolute -top-1.5 -right-2 pointer-events-none"' - ' (span :class "flex items-center justify-center bg-emerald-500 text-white rounded-full min-w-[1.25rem] h-5 text-xs font-bold px-1"' - ' ct)))))', - ch=cart_href, ct=str(count), - ) + cart_mini = render("market-cart-mini-count", href=cart_href, count=str(count)) else: from shared.config import config blog_href = config().get("blog_url", "/") logo = config().get("logo", "") - cart_mini = sexp( - '(div :id "cart-mini" :hx-swap-oob "outerHTML"' - ' (a :href bh :class "relative inline-flex items-center justify-center"' - ' (img :src lg :class "h-8 w-8 rounded-full object-cover border border-stone-300" :alt "")))', - bh=blog_href, lg=logo, - ) + cart_mini = render("market-cart-mini-empty", href=blog_href, logo=logo) # 2. Add/remove buttons OOB action = url_for("market.browse.product.cart", product_slug=slug) quantity = getattr(item, "quantity", 0) if item else 0 - add_html = sexp( - '(div :id aid :hx-swap-oob "outerHTML" (raw! ah))', - aid=f"cart-add-{slug}", - ah=_cart_add_html(slug, quantity, action, csrf, cart_url_fn=_cart_url), + add_html = render( + "market-cart-add-oob", + id=f"cart-add-{slug}", + inner_html=_cart_add_html(slug, quantity, action, csrf, cart_url_fn=_cart_url), ) return cart_mini + add_html diff --git a/orders/sexp/checkout.sexpr b/orders/sexp/checkout.sexpr new file mode 100644 index 0000000..c842e9e --- /dev/null +++ b/orders/sexp/checkout.sexpr @@ -0,0 +1,38 @@ +;; Checkout error components + +(defcomp ~orders-checkout-error-header () + (header :class "mb-6 sm:mb-8" + (h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Checkout error") + (p :class "text-xs sm:text-sm text-stone-600" "We tried to start your payment with SumUp but hit a problem."))) + +(defcomp ~orders-checkout-error-order-id (&key oid) + (p :class "text-xs text-rose-800/80" "Order ID: " (span :class "font-mono" (raw! oid)))) + +(defcomp ~orders-checkout-error-pay-btn (&key url) + (a :href url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition" + (i :class "fa fa-credit-card mr-2" :aria-hidden "true") "Open payment page")) + +(defcomp ~orders-checkout-error-content (&key msg order-html back-url) + (div :class "max-w-full px-3 py-3 space-y-4" + (div :class "rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2" + (p :class "font-medium" "Something went wrong.") + (p (raw! msg)) + (raw! order-html)) + (div + (a :href back-url + :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition" + (i :class "fa fa-shopping-cart mr-2" :aria-hidden "true") "Back to cart")))) + +(defcomp ~orders-detail-filter (&key created status list-url recheck-url csrf pay-html) + (header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4" + (div :class "space-y-1" + (p :class "text-xs sm:text-sm text-stone-600" "Placed " (raw! created) " \u00b7 Status: " (raw! status))) + (div :class "flex w-full sm:w-auto justify-start sm:justify-end gap-2" + (a :href list-url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition" + (i :class "fa-solid fa-list mr-2" :aria-hidden "true") "All orders") + (form :method "post" :action recheck-url :class "inline" + (input :type "hidden" :name "csrf_token" :value csrf) + (button :type "submit" + :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition" + (i :class "fa-solid fa-rotate mr-2" :aria-hidden "true") "Re-check status")) + (raw! pay-html)))) diff --git a/orders/sexp/detail.sexpr b/orders/sexp/detail.sexpr new file mode 100644 index 0000000..9e838be --- /dev/null +++ b/orders/sexp/detail.sexpr @@ -0,0 +1,54 @@ +;; Order detail components + +(defcomp ~orders-item-image (&key src alt) + (img :src src :alt alt :class "w-full h-full object-contain object-center" :loading "lazy" :decoding "async")) + +(defcomp ~orders-item-no-image () + (div :class "w-full h-full flex items-center justify-center text-[9px] text-stone-400" "No image")) + +(defcomp ~orders-item-row (&key href img-html title pid qty price) + (li (a :class "w-full py-2 flex gap-3" :href href + (div :class "w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden" (raw! img-html)) + (div :class "flex-1 flex justify-between gap-3" + (div + (p :class "font-medium" (raw! title)) + (p :class "text-[11px] text-stone-500" "Product ID: " (raw! pid))) + (div :class "text-right whitespace-nowrap" + (p "Qty: " (raw! qty)) + (p (raw! price))))))) + +(defcomp ~orders-items-section (&key items-html) + (div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6" + (h2 :class "text-sm sm:text-base font-semibold mb-3" "Items") + (ul :class "divide-y divide-stone-100 text-xs sm:text-sm" (raw! items-html)))) + +(defcomp ~orders-calendar-item (&key name pill state ds cost) + (li :class "px-4 py-3 flex items-start justify-between text-sm" + (div + (div :class "font-medium flex items-center gap-2" + (raw! name) + (span :class pill (raw! state))) + (div :class "text-xs text-stone-500" (raw! ds))) + (div :class "ml-4 font-medium" (raw! cost)))) + +(defcomp ~orders-calendar-section (&key items-html) + (section :class "mt-6 space-y-3" + (h2 :class "text-base sm:text-lg font-semibold" "Calendar bookings in this order") + (ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" (raw! items-html)))) + +(defcomp ~orders-detail-panel (&key summary-html items-html calendar-html) + (div :class "max-w-full px-3 py-3 space-y-4" + (raw! summary-html) (raw! items-html) (raw! calendar-html))) + +(defcomp ~orders-detail-header-stack (&key auth-html orders-html order-html) + (div :id "root-header-child" :class "flex flex-col w-full items-center" + (raw! auth-html) + (div :id "auth-header-child" :class "flex flex-col w-full items-center" + (raw! orders-html) + (div :id "orders-header-child" :class "flex flex-col w-full items-center" + (raw! order-html))))) + +(defcomp ~orders-header-child-oob (&key inner-html) + (div :id "orders-header-child" :hx-swap-oob "outerHTML" + :class "flex flex-col w-full items-center" + (raw! inner-html))) diff --git a/orders/sexp/list.sexpr b/orders/sexp/list.sexpr new file mode 100644 index 0000000..b6f6f52 --- /dev/null +++ b/orders/sexp/list.sexpr @@ -0,0 +1,60 @@ +;; Orders list components + +(defcomp ~orders-row-desktop (&key oid created desc total pill status url) + (tr :class "hidden sm:table-row border-t border-stone-100 hover:bg-stone-50/60" + (td :class "px-3 py-2 align-top" (span :class "font-mono text-[11px] sm:text-xs" (raw! oid))) + (td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" (raw! created)) + (td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" (raw! desc)) + (td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" (raw! total)) + (td :class "px-3 py-2 align-top" (span :class pill (raw! status))) + (td :class "px-3 py-0.5 align-top text-right" + (a :href url :class "inline-flex items-center px-3 py-1.5 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition" "View")))) + +(defcomp ~orders-row-mobile (&key oid created total pill status url) + (tr :class "sm:hidden border-t border-stone-100" + (td :colspan "5" :class "px-3 py-3" + (div :class "flex flex-col gap-2 text-xs" + (div :class "flex items-center justify-between gap-2" + (span :class "font-mono text-[11px] text-stone-700" (raw! oid)) + (span :class pill (raw! status))) + (div :class "text-[11px] text-stone-500 break-words" (raw! created)) + (div :class "flex items-center justify-between gap-2" + (div :class "font-medium text-stone-800" (raw! total)) + (a :href url :class "inline-flex items-center px-2 py-1 text-[11px] rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition shrink-0" "View")))))) + +(defcomp ~orders-end-row () + (tr (td :colspan "5" :class "px-3 py-4 text-center text-xs text-stone-400" "End of results"))) + +(defcomp ~orders-empty-state () + (div :class "max-w-full px-3 py-3 space-y-3" + (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-4 sm:p-6 text-sm text-stone-700" + "No orders yet."))) + +(defcomp ~orders-table (&key rows-html) + (div :class "max-w-full px-3 py-3 space-y-3" + (div :class "overflow-x-auto rounded-2xl border border-stone-200 bg-white/80" + (table :class "min-w-full text-xs sm:text-sm" + (thead :class "bg-stone-50 border-b border-stone-200 text-stone-600" + (tr + (th :class "px-3 py-2 text-left font-medium" "Order") + (th :class "px-3 py-2 text-left font-medium" "Created") + (th :class "px-3 py-2 text-left font-medium" "Description") + (th :class "px-3 py-2 text-left font-medium" "Total") + (th :class "px-3 py-2 text-left font-medium" "Status") + (th :class "px-3 py-2 text-left font-medium" ""))) + (tbody (raw! rows-html)))))) + +(defcomp ~orders-summary (&key search-mobile-html) + (header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4" + (div :class "space-y-1" (p :class "text-xs sm:text-sm text-stone-600" "Recent orders placed via the checkout.")) + (div :class "md:hidden" (raw! search-mobile-html)))) + +;; Header child wrapper +(defcomp ~orders-header-child (&key inner-html) + (div :id "root-header-child" :class "flex flex-col w-full items-center" + (raw! inner-html))) + +(defcomp ~orders-auth-header-child-oob (&key inner-html) + (div :id "auth-header-child" :hx-swap-oob "outerHTML" + :class "flex flex-col w-full items-center" + (raw! inner-html))) diff --git a/orders/sexp/sexp_components.py b/orders/sexp/sexp_components.py index d0e00eb..ddf2134 100644 --- a/orders/sexp/sexp_components.py +++ b/orders/sexp/sexp_components.py @@ -7,23 +7,18 @@ of ``render_template()``. """ from __future__ import annotations +import os from typing import Any -from shared.sexp.jinja_bridge import sexp, register_components +from shared.sexp.jinja_bridge import render, load_service_components from shared.sexp.helpers import ( call_url, get_asset_url, root_header_html, search_mobile_html, search_desktop_html, full_page, oob_page, ) from shared.infrastructure.urls import market_product_url, cart_url - -# --------------------------------------------------------------------------- -# Service-specific component definitions -# --------------------------------------------------------------------------- - -def load_orders_components() -> None: - """Register orders-specific s-expression components (placeholder for future).""" - pass +# Load orders-specific .sexpr components at import time +load_service_components(os.path.dirname(os.path.dirname(__file__))) # --------------------------------------------------------------------------- @@ -32,10 +27,11 @@ def load_orders_components() -> None: def _auth_nav_html(ctx: dict) -> str: """Auth section desktop nav items.""" - html = sexp( - '(~nav-link :href h :label "newsletters" :select-colours sc)', - h=call_url(ctx, "account_url", "/newsletters/"), - sc=ctx.get("select_colours", ""), + html = render( + "nav-link", + href=call_url(ctx, "account_url", "/newsletters/"), + label="newsletters", + select_colours=ctx.get("select_colours", ""), ) account_nav_html = ctx.get("account_nav_html", "") if account_nav_html: @@ -45,23 +41,23 @@ def _auth_nav_html(ctx: dict) -> str: def _auth_header_html(ctx: dict, *, oob: bool = False) -> str: """Build the account section header row.""" - return sexp( - '(~menu-row :id "auth-row" :level 1 :colour "sky"' - ' :link-href lh :link-label "account" :icon "fa-solid fa-user"' - ' :nav-html nh :child-id "auth-header-child" :oob oob)', - lh=call_url(ctx, "account_url", "/"), - nh=_auth_nav_html(ctx), - oob=oob, + return render( + "menu-row", + id="auth-row", level=1, colour="sky", + link_href=call_url(ctx, "account_url", "/"), + link_label="account", icon="fa-solid fa-user", + nav_html=_auth_nav_html(ctx), + child_id="auth-header-child", oob=oob, ) def _orders_header_html(ctx: dict, list_url: str) -> str: """Build the orders section header row.""" - return sexp( - '(~menu-row :id "orders-row" :level 2 :colour "sky"' - ' :link-href lh :link-label "Orders" :icon "fa fa-gbp"' - ' :child-id "orders-header-child")', - lh=list_url, + return render( + "menu-row", + id="orders-row", level=2, colour="sky", + link_href=list_url, link_label="Orders", icon="fa fa-gbp", + child_id="orders-header-child", ) @@ -86,32 +82,16 @@ def _order_row_html(order: Any, detail_url: str) -> str: created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014" total = f"{order.currency or 'GBP'} {order.total_amount or 0:.2f}" - desktop = sexp( - '(tr :class "hidden sm:table-row border-t border-stone-100 hover:bg-stone-50/60"' - ' (td :class "px-3 py-2 align-top" (span :class "font-mono text-[11px] sm:text-xs" (raw! oid)))' - ' (td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" (raw! created))' - ' (td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" (raw! desc))' - ' (td :class "px-3 py-2 align-top text-stone-700 text-xs sm:text-sm" (raw! total))' - ' (td :class "px-3 py-2 align-top" (span :class pill (raw! status)))' - ' (td :class "px-3 py-0.5 align-top text-right"' - ' (a :href url :class "inline-flex items-center px-3 py-1.5 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition" "View")))', + desktop = render( + "orders-row-desktop", oid=f"#{order.id}", created=created, desc=order.description or "", total=total, pill=f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs {pill}", status=status, url=detail_url, ) - mobile = sexp( - '(tr :class "sm:hidden border-t border-stone-100"' - ' (td :colspan "5" :class "px-3 py-3"' - ' (div :class "flex flex-col gap-2 text-xs"' - ' (div :class "flex items-center justify-between gap-2"' - ' (span :class "font-mono text-[11px] text-stone-700" (raw! oid))' - ' (span :class pill (raw! status)))' - ' (div :class "text-[11px] text-stone-500 break-words" (raw! created))' - ' (div :class "flex items-center justify-between gap-2"' - ' (div :class "font-medium text-stone-800" (raw! total))' - ' (a :href url :class "inline-flex items-center px-2 py-1 text-[11px] rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition shrink-0" "View")))))', + mobile = render( + "orders-row-mobile", oid=f"#{order.id}", created=created, total=total, pill=f"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] {pill}", status=status, url=detail_url, @@ -133,14 +113,12 @@ def _orders_rows_html(orders: list, page: int, total_pages: int, if page < total_pages: next_url = pfx + url_for_fn("orders.list_orders") + qs_fn(page=page + 1) - parts.append(sexp( - '(~infinite-scroll :url u :page p :total-pages tp :id-prefix "orders" :colspan 5)', - u=next_url, p=page, **{"total-pages": total_pages}, + parts.append(render( + "infinite-scroll", + url=next_url, page=page, total_pages=total_pages, id_prefix="orders", colspan=5, )) else: - parts.append(sexp( - '(tr (td :colspan "5" :class "px-3 py-4 text-center text-xs text-stone-400" "End of results"))', - )) + parts.append(render("orders-end-row")) return "".join(parts) @@ -148,36 +126,13 @@ def _orders_rows_html(orders: list, page: int, total_pages: int, def _orders_main_panel_html(orders: list, rows_html: str) -> str: """Main panel with table or empty state.""" if not orders: - return sexp( - '(div :class "max-w-full px-3 py-3 space-y-3"' - ' (div :class "rounded-2xl border border-dashed border-stone-300 bg-white/80 p-4 sm:p-6 text-sm text-stone-700"' - ' "No orders yet."))', - ) - return sexp( - '(div :class "max-w-full px-3 py-3 space-y-3"' - ' (div :class "overflow-x-auto rounded-2xl border border-stone-200 bg-white/80"' - ' (table :class "min-w-full text-xs sm:text-sm"' - ' (thead :class "bg-stone-50 border-b border-stone-200 text-stone-600"' - ' (tr' - ' (th :class "px-3 py-2 text-left font-medium" "Order")' - ' (th :class "px-3 py-2 text-left font-medium" "Created")' - ' (th :class "px-3 py-2 text-left font-medium" "Description")' - ' (th :class "px-3 py-2 text-left font-medium" "Total")' - ' (th :class "px-3 py-2 text-left font-medium" "Status")' - ' (th :class "px-3 py-2 text-left font-medium" "")))' - ' (tbody (raw! rows)))))', - rows=rows_html, - ) + return render("orders-empty-state") + return render("orders-table", rows_html=rows_html) def _orders_summary_html(ctx: dict) -> str: """Filter section for orders list.""" - return sexp( - '(header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"' - ' (div :class "space-y-1" (p :class "text-xs sm:text-sm text-stone-600" "Recent orders placed via the checkout."))' - ' (div :class "md:hidden" (raw! sm)))', - sm=search_mobile_html(ctx), - ) + return render("orders-summary", search_mobile_html=search_mobile_html(ctx)) # --------------------------------------------------------------------------- @@ -199,9 +154,9 @@ async def render_orders_page(ctx: dict, orders: list, page: int, main = _orders_main_panel_html(orders, rows) hdr = root_header_html(ctx) - hdr += sexp( - '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a) (raw! o))', - a=_auth_header_html(ctx), o=_orders_header_html(ctx, list_url), + hdr += render( + "orders-header-child", + inner_html=_auth_header_html(ctx) + _orders_header_html(ctx, list_url), ) return full_page(ctx, header_rows_html=hdr, @@ -233,10 +188,9 @@ async def render_orders_oob(ctx: dict, orders: list, page: int, oobs = ( _auth_header_html(ctx, oob=True) - + sexp( - '(div :id "auth-header-child" :hx-swap-oob "outerHTML"' - ' :class "flex flex-col w-full items-center" (raw! o))', - o=_orders_header_html(ctx, list_url), + + render( + "orders-auth-header-child-oob", + inner_html=_orders_header_html(ctx, list_url), ) + root_header_html(ctx, oob=True) ) @@ -259,36 +213,23 @@ def _order_items_html(order: Any) -> str: for item in order.items: prod_url = market_product_url(item.product_slug) if item.product_image: - img = sexp( - '(img :src src :alt alt :class "w-full h-full object-contain object-center" :loading "lazy" :decoding "async")', + img = render( + "orders-item-image", src=item.product_image, alt=item.product_title or "Product image", ) else: - img = sexp('(div :class "w-full h-full flex items-center justify-center text-[9px] text-stone-400" "No image")') + img = render("orders-item-no-image") - items.append(sexp( - '(li (a :class "w-full py-2 flex gap-3" :href href' - ' (div :class "w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden" (raw! img))' - ' (div :class "flex-1 flex justify-between gap-3"' - ' (div' - ' (p :class "font-medium" (raw! title))' - ' (p :class "text-[11px] text-stone-500" "Product ID: " (raw! pid)))' - ' (div :class "text-right whitespace-nowrap"' - ' (p "Qty: " (raw! qty))' - ' (p (raw! price))))))', - href=prod_url, img=img, + items.append(render( + "orders-item-row", + href=prod_url, img_html=img, title=item.product_title or "Unknown product", pid=str(item.product_id), qty=str(item.quantity), price=f"{item.currency or order.currency or 'GBP'} {item.unit_price or 0:.2f}", )) - return sexp( - '(div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6"' - ' (h2 :class "text-sm sm:text-base font-semibold mb-3" "Items")' - ' (ul :class "divide-y divide-stone-100 text-xs sm:text-sm" (raw! items)))', - items="".join(items), - ) + return render("orders-items-section", items_html="".join(items)) def _calendar_items_html(calendar_entries: list | None) -> str: @@ -307,41 +248,30 @@ def _calendar_items_html(calendar_entries: list | None) -> str: ds = e.start_at.strftime("%-d %b %Y, %H:%M") if e.start_at else "" if e.end_at: ds += f" \u2013 {e.end_at.strftime('%-d %b %Y, %H:%M')}" - items.append(sexp( - '(li :class "px-4 py-3 flex items-start justify-between text-sm"' - ' (div' - ' (div :class "font-medium flex items-center gap-2"' - ' (raw! name)' - ' (span :class pill (raw! state)))' - ' (div :class "text-xs text-stone-500" (raw! ds)))' - ' (div :class "ml-4 font-medium" (raw! cost)))', + items.append(render( + "orders-calendar-item", name=e.name, pill=f"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}", state=st.capitalize(), ds=ds, cost=f"\u00a3{e.cost or 0:.2f}", )) - return sexp( - '(section :class "mt-6 space-y-3"' - ' (h2 :class "text-base sm:text-lg font-semibold" "Calendar bookings in this order")' - ' (ul :class "divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80" (raw! items)))', - items="".join(items), - ) + return render("orders-calendar-section", items_html="".join(items)) def _order_main_html(order: Any, calendar_entries: list | None) -> str: """Main panel for single order detail.""" - summary = sexp( - '(~order-summary-card :order-id oid :created-at ca :description d :status s :currency c :total-amount ta)', - oid=order.id, - ca=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None, - d=order.description, s=order.status, c=order.currency, - ta=f"{order.total_amount:.2f}" if order.total_amount else None, + summary = render( + "order-summary-card", + order_id=order.id, + created_at=order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else None, + description=order.description, status=order.status, currency=order.currency, + total_amount=f"{order.total_amount:.2f}" if order.total_amount else None, ) - return sexp( - '(div :class "max-w-full px-3 py-3 space-y-4" (raw! summary) (raw! items) (raw! cal))', - summary=summary, items=_order_items_html(order), - cal=_calendar_items_html(calendar_entries), + return render( + "orders-detail-panel", + summary_html=summary, items_html=_order_items_html(order), + calendar_html=_calendar_items_html(calendar_entries), ) @@ -353,28 +283,13 @@ def _order_filter_html(order: Any, list_url: str, recheck_url: str, pay_html = "" if status != "paid": - pay_html = sexp( - '(a :href url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition"' - ' (i :class "fa fa-credit-card mr-2" :aria-hidden "true") "Open payment page")', - url=pay_url, - ) + pay_html = render("orders-checkout-error-pay-btn", url=pay_url) - return sexp( - '(header :class "mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4"' - ' (div :class "space-y-1"' - ' (p :class "text-xs sm:text-sm text-stone-600" "Placed " (raw! created) " \u00b7 Status: " (raw! status)))' - ' (div :class "flex w-full sm:w-auto justify-start sm:justify-end gap-2"' - ' (a :href list-url :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"' - ' (i :class "fa-solid fa-list mr-2" :aria-hidden "true") "All orders")' - ' (form :method "post" :action recheck-url :class "inline"' - ' (input :type "hidden" :name "csrf_token" :value csrf)' - ' (button :type "submit"' - ' :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"' - ' (i :class "fa-solid fa-rotate mr-2" :aria-hidden "true") "Re-check status"))' - ' (raw! pay)))', + return render( + "orders-detail-filter", created=created, status=status, - **{"list-url": list_url, "recheck-url": recheck_url}, - csrf=csrf_token, pay=pay_html, + list_url=list_url, recheck_url=recheck_url, + csrf=csrf_token, pay_html=pay_html, ) @@ -396,17 +311,16 @@ async def render_order_page(ctx: dict, order: Any, # Header stack: root -> auth -> orders -> order hdr = root_header_html(ctx) - order_row = sexp( - '(~menu-row :id "order-row" :level 3 :colour "sky" :link-href lh :link-label "Order" :icon "fa fa-gbp")', - lh=detail_url, + order_row = render( + "menu-row", + id="order-row", level=3, colour="sky", link_href=detail_url, + link_label="Order", icon="fa fa-gbp", ) - hdr += sexp( - '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! a)' - ' (div :id "auth-header-child" :class "flex flex-col w-full items-center" (raw! b)' - ' (div :id "orders-header-child" :class "flex flex-col w-full items-center" (raw! c))))', - a=_auth_header_html(ctx), - b=_orders_header_html(ctx, list_url), - c=order_row, + hdr += render( + "orders-detail-header-stack", + auth_html=_auth_header_html(ctx), + orders_html=_orders_header_html(ctx, list_url), + order_html=order_row, ) return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=main) @@ -428,12 +342,13 @@ async def render_order_oob(ctx: dict, order: Any, main = _order_main_html(order, calendar_entries) filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token()) - order_row_oob = sexp( - '(~menu-row :id "order-row" :level 3 :colour "sky" :link-href lh :link-label "Order" :icon "fa fa-gbp" :oob true)', - lh=detail_url, + order_row_oob = render( + "menu-row", + id="order-row", level=3, colour="sky", link_href=detail_url, + link_label="Order", icon="fa fa-gbp", oob=True, ) oobs = ( - sexp('(div :id "orders-header-child" :hx-swap-oob "outerHTML" :class "flex flex-col w-full items-center" (raw! o))', o=order_row_oob) + render("orders-header-child-oob", inner_html=order_row_oob) + root_header_html(ctx, oob=True) ) @@ -445,42 +360,27 @@ async def render_order_oob(ctx: dict, order: Any, # --------------------------------------------------------------------------- def _checkout_error_filter_html() -> str: - return sexp( - '(header :class "mb-6 sm:mb-8"' - ' (h1 :class "text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight" "Checkout error")' - ' (p :class "text-xs sm:text-sm text-stone-600" "We tried to start your payment with SumUp but hit a problem."))', - ) + return render("orders-checkout-error-header") def _checkout_error_content_html(error: str | None, order: Any | None) -> str: err_msg = error or "Unexpected error while creating the hosted checkout session." order_html = "" if order: - order_html = sexp( - '(p :class "text-xs text-rose-800/80" "Order ID: " (span :class "font-mono" (raw! oid)))', - oid=f"#{order.id}", - ) + order_html = render("orders-checkout-error-order-id", oid=f"#{order.id}") back_url = cart_url("/") - return sexp( - '(div :class "max-w-full px-3 py-3 space-y-4"' - ' (div :class "rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2"' - ' (p :class "font-medium" "Something went wrong.")' - ' (p (raw! msg))' - ' (raw! order-html))' - ' (div' - ' (a :href back-url' - ' :class "inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"' - ' (i :class "fa fa-shopping-cart mr-2" :aria-hidden "true") "Back to cart")))', - msg=err_msg, **{"order-html": order_html, "back-url": back_url}, + return render( + "orders-checkout-error-content", + msg=err_msg, order_html=order_html, back_url=back_url, ) async def render_checkout_error_page(ctx: dict, error: str | None = None, order: Any | None = None) -> str: """Full page: checkout error.""" hdr = root_header_html(ctx) - hdr += sexp( - '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! c))', - c=_auth_header_html(ctx), + hdr += render( + "orders-header-child", + inner_html=_auth_header_html(ctx), ) filt = _checkout_error_filter_html() content = _checkout_error_content_html(error, order) diff --git a/shared/browser/app/errors.py b/shared/browser/app/errors.py index 9d3b0c6..71bc020 100644 --- a/shared/browser/app/errors.py +++ b/shared/browser/app/errors.py @@ -138,16 +138,12 @@ def errors(app): messages = getattr(e, "messages", [str(e)]) if request.headers.get("HX-Request") == "true": - # Build a little styled
  • ...
snippet - lis = "".join( - f"
  • {escape(m)}
  • " + from shared.sexp.jinja_bridge import render as render_comp + items = "".join( + render_comp("error-list-item", message=str(escape(m))) for m in messages if m ) - html = ( - "
      " - f"{lis}" - "
    " - ) + html = render_comp("error-list", items_html=items) return await make_response(html, status) # Non-HTMX: show a nicer page with error messages @@ -164,8 +160,9 @@ def errors(app): # Extract service name from "Fragment account/auth-menu failed: ..." service = msg.split("/")[0].replace("Fragment ", "") if "/" in msg else "unknown" if request.headers.get("HX-Request") == "true": + from shared.sexp.jinja_bridge import render as render_comp return await make_response( - f"

    Service {escape(service)} is unavailable.

    ", + render_comp("fragment-error", service=str(escape(service))), 503, ) # Raw HTML — cannot use render_template here because the context diff --git a/shared/sexp/helpers.py b/shared/sexp/helpers.py index 257a500..9533692 100644 --- a/shared/sexp/helpers.py +++ b/shared/sexp/helpers.py @@ -8,7 +8,7 @@ from __future__ import annotations from typing import Any -from .jinja_bridge import sexp +from .jinja_bridge import render from .page import SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP @@ -31,43 +31,39 @@ def get_asset_url(ctx: dict) -> str: def root_header_html(ctx: dict, *, oob: bool = False) -> str: """Build the root header row HTML.""" - return sexp( - '(~header-row :cart-mini-html cmi :blog-url bu :site-title st' - ' :nav-tree-html nth :auth-menu-html amh :nav-panel-html nph' - ' :oob oob)', - cmi=ctx.get("cart_mini_html", ""), - bu=call_url(ctx, "blog_url", ""), - st=ctx.get("base_title", ""), - nth=ctx.get("nav_tree_html", ""), - amh=ctx.get("auth_menu_html", ""), - nph=ctx.get("nav_panel_html", ""), + return render( + "header-row", + cart_mini_html=ctx.get("cart_mini_html", ""), + blog_url=call_url(ctx, "blog_url", ""), + site_title=ctx.get("base_title", ""), + nav_tree_html=ctx.get("nav_tree_html", ""), + auth_menu_html=ctx.get("auth_menu_html", ""), + nav_panel_html=ctx.get("nav_panel_html", ""), oob=oob, ) def search_mobile_html(ctx: dict) -> str: """Build mobile search input HTML.""" - return sexp( - '(~search-mobile :current-local-href clh :search s :search-count sc' - ' :hx-select hs :search-headers-mobile shm)', - clh=ctx.get("current_local_href", "/"), - s=ctx.get("search", ""), - sc=ctx.get("search_count", ""), - hs=ctx.get("hx_select", "#main-panel"), - shm=SEARCH_HEADERS_MOBILE, + return render( + "search-mobile", + current_local_href=ctx.get("current_local_href", "/"), + search=ctx.get("search", ""), + search_count=ctx.get("search_count", ""), + hx_select=ctx.get("hx_select", "#main-panel"), + search_headers_mobile=SEARCH_HEADERS_MOBILE, ) def search_desktop_html(ctx: dict) -> str: """Build desktop search input HTML.""" - return sexp( - '(~search-desktop :current-local-href clh :search s :search-count sc' - ' :hx-select hs :search-headers-desktop shd)', - clh=ctx.get("current_local_href", "/"), - s=ctx.get("search", ""), - sc=ctx.get("search_count", ""), - hs=ctx.get("hx_select", "#main-panel"), - shd=SEARCH_HEADERS_DESKTOP, + return render( + "search-desktop", + current_local_href=ctx.get("current_local_href", "/"), + search=ctx.get("search", ""), + search_count=ctx.get("search_count", ""), + hx_select=ctx.get("hx_select", "#main-panel"), + search_headers_desktop=SEARCH_HEADERS_DESKTOP, ) @@ -76,19 +72,17 @@ def full_page(ctx: dict, *, header_rows_html: str, content_html: str = "", menu_html: str = "", body_end_html: str = "", meta_html: str = "") -> str: """Render a full app page with the standard layout.""" - return sexp( - '(~app-layout :title t :asset-url au :meta-html mh' - ' :header-rows-html hrh :menu-html muh :filter-html fh' - ' :aside-html ash :content-html ch :body-end-html beh)', - t=ctx.get("base_title", "Rose Ash"), - au=get_asset_url(ctx), - mh=meta_html, - hrh=header_rows_html, - muh=menu_html, - fh=filter_html, - ash=aside_html, - ch=content_html, - beh=body_end_html, + return render( + "app-layout", + title=ctx.get("base_title", "Rose Ash"), + asset_url=get_asset_url(ctx), + meta_html=meta_html, + header_rows_html=header_rows_html, + menu_html=menu_html, + filter_html=filter_html, + aside_html=aside_html, + content_html=content_html, + body_end_html=body_end_html, ) @@ -96,12 +90,11 @@ def oob_page(ctx: dict, *, oobs_html: str = "", filter_html: str = "", aside_html: str = "", content_html: str = "", menu_html: str = "") -> str: """Render an OOB response with standard swap targets.""" - return sexp( - '(~oob-response :oobs-html oh :filter-html fh :aside-html ash' - ' :menu-html mh :content-html ch)', - oh=oobs_html, - fh=filter_html, - ash=aside_html, - mh=menu_html, - ch=content_html, + return render( + "oob-response", + oobs_html=oobs_html, + filter_html=filter_html, + aside_html=aside_html, + menu_html=menu_html, + content_html=content_html, ) diff --git a/shared/sexp/jinja_bridge.py b/shared/sexp/jinja_bridge.py index 4160583..646d3a7 100644 --- a/shared/sexp/jinja_bridge.py +++ b/shared/sexp/jinja_bridge.py @@ -24,9 +24,9 @@ import glob import os from typing import Any -from .types import NIL, Symbol +from .types import NIL, Component, Keyword, Symbol from .parser import parse -from .html import render as html_render +from .html import render as html_render, _render_component # --------------------------------------------------------------------------- @@ -44,12 +44,22 @@ def get_component_env() -> dict[str, Any]: def load_sexp_dir(directory: str) -> None: - """Load all .sexp files from a directory and register components.""" - for filepath in sorted(glob.glob(os.path.join(directory, "*.sexp"))): + """Load all .sexp and .sexpr files from a directory and register components.""" + for filepath in sorted( + glob.glob(os.path.join(directory, "*.sexp")) + + glob.glob(os.path.join(directory, "*.sexpr")) + ): with open(filepath, encoding="utf-8") as f: register_components(f.read()) +def load_service_components(service_dir: str) -> None: + """Load service-specific s-expression components from {service_dir}/sexp/.""" + sexp_dir = os.path.join(service_dir, "sexp") + if os.path.isdir(sexp_dir): + load_sexp_dir(sexp_dir) + + def register_components(sexp_source: str) -> None: """Parse and evaluate s-expression component definitions into the shared environment. @@ -96,6 +106,28 @@ def sexp(source: str, **kwargs: Any) -> str: return html_render(expr, env) +def render(component_name: str, **kwargs: Any) -> str: + """Call a registered component by name with Python kwargs. + + Automatically converts Python snake_case to sexp kebab-case. + No sexp strings needed — just a function call. + """ + name = component_name if component_name.startswith("~") else f"~{component_name}" + comp = _COMPONENT_ENV.get(name) + if not isinstance(comp, Component): + raise ValueError(f"Unknown component: {name}") + + env = dict(_COMPONENT_ENV) + args: list[Any] = [] + for key, val in kwargs.items(): + kw_name = key.replace("_", "-") + args.append(Keyword(kw_name)) + args.append(val) + env[kw_name] = val + + return _render_component(comp, args, env) + + async def sexp_async(source: str, **kwargs: Any) -> str: """Async version of ``sexp()`` — resolves I/O primitives (frag, query) before rendering. @@ -144,4 +176,5 @@ def setup_sexp_bridge(app: Any) -> None: - ``sexp_async(source, **kwargs)`` — async render (with I/O resolution) """ app.jinja_env.globals["sexp"] = sexp + app.jinja_env.globals["render"] = render app.jinja_env.globals["sexp_async"] = sexp_async diff --git a/shared/sexp/parser.py b/shared/sexp/parser.py index 8da6103..51bd7f1 100644 --- a/shared/sexp/parser.py +++ b/shared/sexp/parser.py @@ -46,7 +46,7 @@ class Tokenizer: COMMENT = re.compile(r";[^\n]*") STRING = re.compile(r'"(?:[^"\\]|\\.)*"') NUMBER = re.compile(r"-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?") - KEYWORD = re.compile(r":[a-zA-Z_][a-zA-Z0-9_>-]*") + KEYWORD = re.compile(r":[a-zA-Z_][a-zA-Z0-9_>:-]*") # Symbols may start with alpha, _, or common operator chars, plus ~ for components, # <> for the fragment symbol, and & for &key/&rest. SYMBOL = re.compile(r"[a-zA-Z_~*+\-><=/!?&][a-zA-Z0-9_~*+\-><=/!?.:&]*") diff --git a/shared/sexp/templates/misc.sexp b/shared/sexp/templates/misc.sexp new file mode 100644 index 0000000..6c2d5f4 --- /dev/null +++ b/shared/sexp/templates/misc.sexp @@ -0,0 +1,30 @@ +;; Miscellaneous shared components for Phase 3 conversion + +(defcomp ~error-inline (&key message) + (div :class "text-red-600 text-sm" (raw! message))) + +(defcomp ~notification-badge (&key count) + (span :class "bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5" (raw! count))) + +(defcomp ~cache-cleared (&key time-str) + (span :class "text-green-600 font-bold" "Cache cleared at " (raw! time-str))) + +(defcomp ~error-list (&key items-html) + (ul :class "list-disc pl-5 space-y-1 text-sm text-red-600" + (raw! items-html))) + +(defcomp ~error-list-item (&key message) + (li (raw! message))) + +(defcomp ~fragment-error (&key service) + (p :class "text-sm text-red-600" "Service " (b (raw! service)) " is unavailable.")) + +(defcomp ~htmx-sentinel (&key id hx-get hx-trigger hx-swap class extra-attrs) + (div :id id :hx-get hx-get :hx-trigger hx-trigger :hx-swap hx-swap :class class)) + +(defcomp ~nav-group-link (&key href hx-select nav-class label) + (div :class "relative nav-group" + (a :href href :hx-get href :hx-target "#main-panel" + :hx-select hx-select :hx-swap "outerHTML" + :hx-push-url "true" :class nav-class + (raw! label))))