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 = (
- ""
- )
+ 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))))