diff --git a/account/app.py b/account/app.py index a6348ce..dfbe274 100644 --- a/account/app.py +++ b/account/app.py @@ -1,5 +1,6 @@ from __future__ import annotations import path_setup # noqa: F401 # adds shared/ to sys.path +import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file from pathlib import Path from quart import g, request diff --git a/account/bp/account/routes.py b/account/bp/account/routes.py index 59fe868..71c0010 100644 --- a/account/bp/account/routes.py +++ b/account/bp/account/routes.py @@ -47,14 +47,17 @@ def register(url_prefix="/"): @account_bp.get("/") async def account(): from shared.browser.app.utils.htmx import is_htmx_request + from shared.sexp.page import get_template_context + from sexp_components import render_account_page, render_account_oob if not g.get("user"): return redirect(login_url("/")) + ctx = await get_template_context() if not is_htmx_request(): - html = await render_template("_types/auth/index.html") + html = await render_account_page(ctx) else: - html = await render_template("_types/auth/_oob_elements.html") + html = await render_account_oob(ctx) return await make_response(html) @@ -86,20 +89,14 @@ def register(url_prefix="/"): "subscribed": un.subscribed if un else False, }) - nl_oob = {**oob, "main": "_types/auth/_newsletters_panel.html"} + from shared.sexp.page import get_template_context + from sexp_components import render_newsletters_page, render_newsletters_oob + ctx = await get_template_context() if not is_htmx_request(): - html = await render_template( - "_types/auth/index.html", - oob=nl_oob, - newsletter_list=newsletter_list, - ) + html = await render_newsletters_page(ctx, newsletter_list) else: - html = await render_template( - "_types/auth/_oob_elements.html", - oob=nl_oob, - newsletter_list=newsletter_list, - ) + html = await render_newsletters_oob(ctx, newsletter_list) return await make_response(html) @@ -149,20 +146,14 @@ def register(url_prefix="/"): if not fragment_html: abort(404) - w_oob = {**oob, "main": "_types/auth/_fragment_panel.html"} + from shared.sexp.page import get_template_context + from sexp_components import render_fragment_page, render_fragment_oob + ctx = await get_template_context() if not is_htmx_request(): - html = await render_template( - "_types/auth/index.html", - oob=w_oob, - page_fragment_html=fragment_html, - ) + html = await render_fragment_page(ctx, fragment_html) else: - html = await render_template( - "_types/auth/_oob_elements.html", - oob=w_oob, - page_fragment_html=fragment_html, - ) + html = await render_fragment_oob(ctx, fragment_html) return await make_response(html) diff --git a/account/bp/auth/routes.py b/account/bp/auth/routes.py index ab6551c..e101dca 100644 --- a/account/bp/auth/routes.py +++ b/account/bp/auth/routes.py @@ -275,7 +275,11 @@ def register(url_prefix="/auth"): if g.get("user"): redirect_url = pop_login_redirect_target() return redirect(redirect_url) - return await render_template("auth/login.html") + + from shared.sexp.page import get_template_context + from sexp_components import render_login_page + ctx = await get_template_context() + return await render_login_page(ctx) @rate_limit( key_func=lambda: request.headers.get("X-Forwarded-For", request.remote_addr), @@ -688,8 +692,11 @@ def register(url_prefix="/auth"): @auth_bp.get("/device/") async def device_form(): """Browser form where user enters the code displayed in terminal.""" + from shared.sexp.page import get_template_context + from sexp_components import render_device_page code = request.args.get("code", "") - return await render_template("auth/device.html", code=code) + ctx = await get_template_context(code=code) + return await render_device_page(ctx) @auth_bp.post("/device") @auth_bp.post("/device/") @@ -739,6 +746,9 @@ def register(url_prefix="/auth"): @auth_bp.get("/device/complete/") async def device_complete(): """Post-login redirect — completes approval after magic link auth.""" + from shared.sexp.page import get_template_context + from sexp_components import render_device_page, render_device_approved_page + device_code = request.args.get("code", "") if not device_code: @@ -750,11 +760,12 @@ def register(url_prefix="/auth"): ok = await _approve_device(device_code, g.user) if not ok: - return await render_template( - "auth/device.html", + ctx = await get_template_context( error="Code expired or already used. Please start the login process again in your terminal.", - ), 400 + ) + return await render_device_page(ctx), 400 - return await render_template("auth/device_approved.html") + ctx = await get_template_context() + return await render_device_approved_page(ctx) return auth_bp diff --git a/account/sexp_components.py b/account/sexp_components.py new file mode 100644 index 0000000..410ece0 --- /dev/null +++ b/account/sexp_components.py @@ -0,0 +1,379 @@ +""" +Account service s-expression page components. + +Renders account dashboard, newsletters, fragment pages, login, and device +auth pages. Called from route handlers in place of ``render_template()``. +""" +from __future__ import annotations + +from typing import Any + +from shared.sexp.jinja_bridge import sexp +from shared.sexp.helpers import ( + call_url, root_header_html, search_desktop_html, + search_mobile_html, full_page, oob_page, +) + + +# --------------------------------------------------------------------------- +# Header helpers +# --------------------------------------------------------------------------- + +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", ""), + ) + account_nav_html = ctx.get("account_nav_html", "") + if account_nav_html: + html += account_nav_html + return html + + +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, + ) + + +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", ""), + ) + account_nav_html = ctx.get("account_nav_html", "") + if account_nav_html: + html += account_nav_html + return html + + +# --------------------------------------------------------------------------- +# Account dashboard (GET /) +# --------------------------------------------------------------------------- + +def _account_main_panel_html(ctx: dict) -> str: + """Account info panel with user details and logout.""" + from quart import g + from shared.browser.app.csrf import generate_csrf_token + + user = getattr(g, "user", None) + error = ctx.get("error", "") + + parts = ['
', + '
'] + + if error: + parts.append( + f'
{error}
' + ) + + # Account header with logout + parts.append('
') + parts.append('

Account

') + if user: + parts.append(f'

{user.email}

') + if user.name: + parts.append(f'

{user.name}

') + parts.append('
') + parts.append( + f'
' + f'' + f'
' + ) + parts.append('
') + + # Labels + if user and hasattr(user, "labels") and user.labels: + parts.append('

Labels

') + parts.append('
') + for label in user.labels: + parts.append( + f'' + f'{label.name}' + ) + parts.append('
') + + parts.append('
') + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Newsletters (GET /newsletters/) +# --------------------------------------------------------------------------- + +def _newsletter_toggle_html(un: Any, account_url_fn: Any, csrf_token: str) -> str: + """Render a single newsletter toggle switch.""" + nid = un.newsletter_id + toggle_url = account_url_fn(f"/newsletter/{nid}/toggle/") + if un.subscribed: + bg = "bg-emerald-500" + translate = "translate-x-6" + checked = "true" + else: + bg = "bg-stone-300" + translate = "translate-x-1" + checked = "false" + return ( + f'
' + f'
' + ) + + +def _newsletters_panel_html(ctx: dict, newsletter_list: list) -> str: + """Newsletters management panel.""" + from shared.browser.app.csrf import generate_csrf_token + + account_url_fn = ctx.get("account_url") + csrf = generate_csrf_token() + + parts = ['
', + '
', + '

Newsletters

'] + + if newsletter_list: + parts.append('
') + for item in newsletter_list: + nl = item["newsletter"] + un = item.get("un") + parts.append('
') + parts.append(f'

{nl.name}

') + if nl.description: + parts.append(f'

{nl.description}

') + parts.append('
') + + if un: + parts.append(_newsletter_toggle_html(un, account_url_fn, csrf)) + else: + # No subscription yet — show off toggle + toggle_url = account_url_fn(f"/newsletter/{nl.id}/toggle/") + parts.append( + f'
' + f'
' + ) + parts.append('
') + parts.append('
') + else: + parts.append('

No newsletters available.

') + + parts.append('
') + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Auth pages (login, device, check_email) +# --------------------------------------------------------------------------- + +def _login_page_content(ctx: dict) -> str: + """Login form content.""" + from shared.browser.app.csrf import generate_csrf_token + from quart import url_for + + error = ctx.get("error", "") + email = ctx.get("email", "") + + parts = ['
', + '

Sign in

'] + if error: + parts.append( + f'
{error}
' + ) + action = url_for("auth.start_login") + parts.append( + f'
' + f'' + f'
' + f'
' + f'
' + ) + parts.append('
') + return "".join(parts) + + +def _device_page_content(ctx: dict) -> str: + """Device authorization form content.""" + from shared.browser.app.csrf import generate_csrf_token + from quart import url_for + + error = ctx.get("error", "") + code = ctx.get("code", "") + + parts = ['
', + '

Authorize device

', + '

Enter the code shown in your terminal to sign in.

'] + if error: + parts.append( + f'
{error}
' + ) + action = url_for("auth.device_submit") + parts.append( + f'
' + f'' + f'
' + f'
' + f'
' + ) + parts.append('
') + return "".join(parts) + + +def _device_approved_content() -> str: + """Device approved success content.""" + return ( + '
' + '

Device authorized

' + '

You can close this window and return to your terminal.

' + '
' + ) + + +# --------------------------------------------------------------------------- +# Public API: Account dashboard +# --------------------------------------------------------------------------- + +async def render_account_page(ctx: dict) -> str: + """Full page: account dashboard.""" + 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), + ) + + return full_page(ctx, header_rows_html=hdr, + content_html=main, + menu_html=_auth_nav_mobile_html(ctx)) + + +async def render_account_oob(ctx: dict) -> str: + """OOB response for account dashboard.""" + main = _account_main_panel_html(ctx) + + oobs = ( + _auth_header_html(ctx, oob=True) + + root_header_html(ctx, oob=True) + ) + + return oob_page(ctx, oobs_html=oobs, + content_html=main, + menu_html=_auth_nav_mobile_html(ctx)) + + +# --------------------------------------------------------------------------- +# Public API: Newsletters +# --------------------------------------------------------------------------- + +async def render_newsletters_page(ctx: dict, newsletter_list: list) -> str: + """Full page: newsletters.""" + 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), + ) + + return full_page(ctx, header_rows_html=hdr, + content_html=main, + menu_html=_auth_nav_mobile_html(ctx)) + + +async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str: + """OOB response for newsletters.""" + main = _newsletters_panel_html(ctx, newsletter_list) + + oobs = ( + _auth_header_html(ctx, oob=True) + + root_header_html(ctx, oob=True) + ) + + return oob_page(ctx, oobs_html=oobs, + content_html=main, + menu_html=_auth_nav_mobile_html(ctx)) + + +# --------------------------------------------------------------------------- +# Public API: Fragment pages +# --------------------------------------------------------------------------- + +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), + ) + + return full_page(ctx, header_rows_html=hdr, + content_html=page_fragment_html, + menu_html=_auth_nav_mobile_html(ctx)) + + +async def render_fragment_oob(ctx: dict, page_fragment_html: str) -> str: + """OOB response for fragment pages.""" + oobs = ( + _auth_header_html(ctx, oob=True) + + root_header_html(ctx, oob=True) + ) + + return oob_page(ctx, oobs_html=oobs, + content_html=page_fragment_html, + menu_html=_auth_nav_mobile_html(ctx)) + + +# --------------------------------------------------------------------------- +# Public API: Auth pages (login, device) +# --------------------------------------------------------------------------- + +async def render_login_page(ctx: dict) -> str: + """Full page: login form.""" + hdr = root_header_html(ctx) + return full_page(ctx, header_rows_html=hdr, + content_html=_login_page_content(ctx), + meta_html='Login \u2014 Rose Ash') + + +async def render_device_page(ctx: dict) -> str: + """Full page: device authorization form.""" + hdr = root_header_html(ctx) + return full_page(ctx, header_rows_html=hdr, + content_html=_device_page_content(ctx), + meta_html='Authorize Device \u2014 Rose Ash') + + +async def render_device_approved_page(ctx: dict) -> str: + """Full page: device approved.""" + hdr = root_header_html(ctx) + return full_page(ctx, header_rows_html=hdr, + content_html=_device_approved_content(), + meta_html='Device Authorized \u2014 Rose Ash') diff --git a/blog/app.py b/blog/app.py index 48b9941..61dbc81 100644 --- a/blog/app.py +++ b/blog/app.py @@ -1,5 +1,6 @@ from __future__ import annotations import path_setup # noqa: F401 # adds shared/ to sys.path +import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file from pathlib import Path from quart import g, request diff --git a/blog/bp/admin/routes.py b/blog/bp/admin/routes.py index e387c17..52b73c9 100644 --- a/blog/bp/admin/routes.py +++ b/blog/bp/admin/routes.py @@ -29,27 +29,28 @@ def register(url_prefix): @bp.get("/") @require_admin async def home(): + from shared.sexp.page import get_template_context + from sexp_components import render_settings_page, render_settings_oob - # Determine which template to use based on request type and pagination + tctx = await get_template_context() if not is_htmx_request(): - # Normal browser request: full page with layout - html = await render_template( - "_types/root/settings/index.html", - ) - + html = await render_settings_page(tctx) else: - html = await render_template("_types/root/settings/_oob_elements.html") - + html = await render_settings_oob(tctx) return await make_response(html) @bp.get("/cache/") @require_admin async def cache(): + from shared.sexp.page import get_template_context + from sexp_components import render_cache_page, render_cache_oob + + tctx = await get_template_context() if not is_htmx_request(): - html = await render_template("_types/root/settings/cache/index.html") + html = await render_cache_page(tctx) else: - html = await render_template("_types/root/settings/cache/_oob_elements.html") + html = await render_cache_oob(tctx) return await make_response(html) @bp.post("/cache_clear/") diff --git a/blog/bp/blog/admin/routes.py b/blog/bp/blog/admin/routes.py index 4bf8139..0350996 100644 --- a/blog/bp/blog/admin/routes.py +++ b/blog/bp/blog/admin/routes.py @@ -57,10 +57,15 @@ def register(): ctx = {"groups": groups, "unassigned_tags": unassigned} + from shared.sexp.page import get_template_context + from sexp_components import render_tag_groups_page, render_tag_groups_oob + + tctx = await get_template_context() + tctx.update(ctx) if not is_htmx_request(): - return await render_template("_types/blog/admin/tag_groups/index.html", **ctx) + return await make_response(await render_tag_groups_page(tctx)) else: - return await render_template("_types/blog/admin/tag_groups/_oob_elements.html", **ctx) + return await make_response(await render_tag_groups_oob(tctx)) @bp.post("/") @require_admin @@ -117,10 +122,15 @@ def register(): "assigned_tag_ids": assigned_tag_ids, } + from shared.sexp.page import get_template_context + from sexp_components import render_tag_group_edit_page, render_tag_group_edit_oob + + tctx = await get_template_context() + tctx.update(ctx) if not is_htmx_request(): - return await render_template("_types/blog/admin/tag_groups/edit.html", **ctx) + return await make_response(await render_tag_group_edit_page(tctx)) else: - return await render_template("_types/blog/admin/tag_groups/_edit_oob.html", **ctx) + return await make_response(await render_tag_group_edit_oob(tctx)) @bp.post("//") @require_admin diff --git a/blog/bp/blog/routes.py b/blog/bp/blog/routes.py index b14d75a..8593fa8 100644 --- a/blog/bp/blog/routes.py +++ b/blog/bp/blog/routes.py @@ -153,10 +153,15 @@ def register(url_prefix, title): ctx["page_cart_count"] = page_summary.count + page_summary.calendar_count + page_summary.ticket_count ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total + page_summary.ticket_total) + from shared.sexp.page import get_template_context + from sexp_components import render_home_page, render_home_oob + + tctx = await get_template_context() + tctx.update(ctx) if not is_htmx_request(): - html = await render_template("_types/home/index.html", **ctx) + html = await render_home_page(tctx) else: - html = await render_template("_types/home/_oob_elements.html", **ctx) + html = await render_home_oob(tctx) return await make_response(html) @blogs_bp.get("/index") @@ -185,12 +190,17 @@ def register(url_prefix, title): "tag_groups": [], "posts": data.get("pages", []), } + from shared.sexp.page import get_template_context + from sexp_components import render_blog_page, render_blog_oob, render_blog_page_cards + + tctx = await get_template_context() + tctx.update(context) if not is_htmx_request(): - html = await render_template("_types/blog/index.html", **context) + html = await render_blog_page(tctx) elif q.page > 1: - html = await render_template("_types/blog/_page_cards.html", **context) + html = await render_blog_page_cards(tctx) else: - html = await render_template("_types/blog/_oob_elements.html", **context) + html = await render_blog_oob(tctx) return await make_response(html) # Default: posts listing @@ -221,28 +231,33 @@ def register(url_prefix, title): "drafts": q.drafts if show_drafts else None, } - # Determine which template to use based on request type and pagination + from shared.sexp.page import get_template_context + from sexp_components import render_blog_page, render_blog_oob, render_blog_cards + + tctx = await get_template_context() + tctx.update(context) if not is_htmx_request(): - # Normal browser request: full page with layout - html = await render_template("_types/blog/index.html", **context) + html = await render_blog_page(tctx) elif q.page > 1: - # HTMX pagination: just blog cards + sentinel - html = await render_template("_types/blog/_cards.html", **context) + html = await render_blog_cards(tctx) else: - # HTMX navigation (page 1): main panel + OOB elements - #main_panel = await render_template("_types/blog/_main_panel.html", **context) - html = await render_template("_types/blog/_oob_elements.html", **context) - #html = oob_elements + main_panel + html = await render_blog_oob(tctx) return await make_response(html) @blogs_bp.get("/new/") @require_admin async def new_post(): + from shared.sexp.page import get_template_context + from sexp_components import render_new_post_page, render_new_post_oob + + editor_html = await render_template("_types/blog_new/_main_panel.html") + tctx = await get_template_context() + tctx["editor_html"] = editor_html if not is_htmx_request(): - html = await render_template("_types/blog_new/index.html") + html = await render_new_post_page(tctx) else: - html = await render_template("_types/blog_new/_oob_elements.html") + html = await render_new_post_oob(tctx) return await make_response(html) @blogs_bp.post("/new/") @@ -312,10 +327,17 @@ def register(url_prefix, title): @blogs_bp.get("/new-page/") @require_admin async def new_page(): + from shared.sexp.page import get_template_context + from sexp_components import render_new_post_page, render_new_post_oob + + editor_html = await render_template("_types/blog_new/_main_panel.html", is_page=True) + tctx = await get_template_context() + tctx["editor_html"] = editor_html + tctx["is_page"] = True if not is_htmx_request(): - html = await render_template("_types/blog_new/index.html", is_page=True) + html = await render_new_post_page(tctx) else: - html = await render_template("_types/blog_new/_oob_elements.html", is_page=True) + html = await render_new_post_oob(tctx) return await make_response(html) @blogs_bp.post("/new-page/") diff --git a/blog/bp/menu_items/routes.py b/blog/bp/menu_items/routes.py index 26ac745..2645f34 100644 --- a/blog/bp/menu_items/routes.py +++ b/blog/bp/menu_items/routes.py @@ -34,20 +34,15 @@ def register(): menu_items = await get_all_menu_items(g.s) - if not is_htmx_request(): - # Normal browser request: full page with layout - html = await render_template( - "_types/menu_items/index.html", - menu_items=menu_items, - ) - else: - - html = await render_template( - "_types/menu_items/_oob_elements.html", - menu_items=menu_items, - ) - #html = await render_template("_types/root/settings/_oob_elements.html") + from shared.sexp.page import get_template_context + from sexp_components import render_menu_items_page, render_menu_items_oob + tctx = await get_template_context() + tctx["menu_items"] = menu_items + if not is_htmx_request(): + html = await render_menu_items_page(tctx) + else: + html = await render_menu_items_oob(tctx) return await make_response(html) diff --git a/blog/bp/post/admin/routes.py b/blog/bp/post/admin/routes.py index ac85830..60df515 100644 --- a/blog/bp/post/admin/routes.py +++ b/blog/bp/post/admin/routes.py @@ -51,13 +51,15 @@ def register(): "sumup_checkout_prefix": sumup_checkout_prefix, } - # Determine which template to use based on request type + from shared.sexp.page import get_template_context + from sexp_components import render_post_admin_page, render_post_admin_oob + + tctx = await get_template_context() + tctx.update(ctx) if not is_htmx_request(): - # Normal browser request: full page with layout - html = await render_template("_types/post/admin/index.html", **ctx) + html = await render_post_admin_page(tctx) else: - # HTMX request: main panel + OOB elements - html = await render_template("_types/post/admin/_oob_elements.html", **ctx) + html = await render_post_admin_oob(tctx) return await make_response(html) @@ -149,14 +151,16 @@ def register(): @bp.get("/data/") @require_admin async def data(slug: str): + from shared.sexp.page import get_template_context + from sexp_components import render_post_data_page, render_post_data_oob + + data_html = await render_template("_types/post_data/_main_panel.html") + tctx = await get_template_context() + tctx["data_html"] = data_html if not is_htmx_request(): - html = await render_template( - "_types/post_data/index.html", - ) + html = await render_post_data_page(tctx) else: - html = await render_template( - "_types/post_data/_oob_elements.html", - ) + html = await render_post_data_oob(tctx) return await make_response(html) @@ -266,18 +270,20 @@ def register(): # Load entries and post for each calendar for calendar in all_calendars: await g.s.refresh(calendar, ["entries", "post"]) + from shared.sexp.page import get_template_context + from sexp_components import render_post_entries_page, render_post_entries_oob + + entries_html = await render_template( + "_types/post_entries/_main_panel.html", + all_calendars=all_calendars, + associated_entry_ids=associated_entry_ids, + ) + tctx = await get_template_context() + tctx["entries_html"] = entries_html if not is_htmx_request(): - html = await render_template( - "_types/post_entries/index.html", - all_calendars=all_calendars, - associated_entry_ids=associated_entry_ids, - ) + html = await render_post_entries_page(tctx) else: - html = await render_template( - "_types/post_entries/_oob_elements.html", - all_calendars=all_calendars, - associated_entry_ids=associated_entry_ids, - ) + html = await render_post_entries_oob(tctx) return await make_response(html) @@ -350,18 +356,20 @@ def register(): ghost_post = await get_post_for_edit(ghost_id, is_page=is_page) save_success = request.args.get("saved") == "1" + from shared.sexp.page import get_template_context + from sexp_components import render_post_settings_page, render_post_settings_oob + + settings_html = await render_template( + "_types/post_settings/_main_panel.html", + ghost_post=ghost_post, + save_success=save_success, + ) + tctx = await get_template_context() + tctx["settings_html"] = settings_html if not is_htmx_request(): - html = await render_template( - "_types/post_settings/index.html", - ghost_post=ghost_post, - save_success=save_success, - ) + html = await render_post_settings_page(tctx) else: - html = await render_template( - "_types/post_settings/_oob_elements.html", - ghost_post=ghost_post, - save_success=save_success, - ) + html = await render_post_settings_oob(tctx) return await make_response(html) @@ -451,20 +459,21 @@ def register(): from types import SimpleNamespace newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters] + from shared.sexp.page import get_template_context + from sexp_components import render_post_edit_page, render_post_edit_oob + + edit_html = await render_template( + "_types/post_edit/_main_panel.html", + ghost_post=ghost_post, + save_success=save_success, + newsletters=newsletters, + ) + tctx = await get_template_context() + tctx["edit_html"] = edit_html if not is_htmx_request(): - html = await render_template( - "_types/post_edit/index.html", - ghost_post=ghost_post, - save_success=save_success, - newsletters=newsletters, - ) + html = await render_post_edit_page(tctx) else: - html = await render_template( - "_types/post_edit/_oob_elements.html", - ghost_post=ghost_post, - save_success=save_success, - newsletters=newsletters, - ) + html = await render_post_edit_oob(tctx) return await make_response(html) diff --git a/blog/bp/post/routes.py b/blog/bp/post/routes.py index ba19ad5..973c3b9 100644 --- a/blog/bp/post/routes.py +++ b/blog/bp/post/routes.py @@ -114,13 +114,14 @@ def register(): @bp.get("/") @cache_page(tag="post.post_detail") async def post_detail(slug: str): - # Determine which template to use based on request type + from shared.sexp.page import get_template_context + from sexp_components import render_post_page, render_post_oob + + tctx = await get_template_context() if not is_htmx_request(): - # Normal browser request: full page with layout - html = await render_template("_types/post/index.html") + html = await render_post_page(tctx) else: - # HTMX request: main panel + OOB elements - html = await render_template("_types/post/_oob_elements.html") + html = await render_post_oob(tctx) return await make_response(html) diff --git a/blog/bp/snippets/routes.py b/blog/bp/snippets/routes.py index 8f4778a..2ed79fd 100644 --- a/blog/bp/snippets/routes.py +++ b/blog/bp/snippets/routes.py @@ -38,18 +38,16 @@ def register(): snippets = await _visible_snippets(g.s) is_admin = g.rights.get("admin") + from shared.sexp.page import get_template_context + from sexp_components import render_snippets_page, render_snippets_oob + + tctx = await get_template_context() + tctx["snippets"] = snippets + tctx["is_admin"] = is_admin if not is_htmx_request(): - html = await render_template( - "_types/snippets/index.html", - snippets=snippets, - is_admin=is_admin, - ) + html = await render_snippets_page(tctx) else: - html = await render_template( - "_types/snippets/_oob_elements.html", - snippets=snippets, - is_admin=is_admin, - ) + html = await render_snippets_oob(tctx) return await make_response(html) diff --git a/blog/sexp_components.py b/blog/sexp_components.py new file mode 100644 index 0000000..7a6aea8 --- /dev/null +++ b/blog/sexp_components.py @@ -0,0 +1,1805 @@ +""" +Blog service s-expression page components. + +Renders home, blog index (posts/pages), new post/page, post detail, +post admin, post data, post entries, post edit, post settings, +settings home, cache, snippets, menu items, and tag groups pages. +Called from route handlers in place of ``render_template()``. +""" +from __future__ import annotations + +from typing import Any +from markupsafe import escape + +from shared.sexp.jinja_bridge import sexp +from shared.sexp.helpers import ( + call_url, get_asset_url, root_header_html, + search_mobile_html, search_desktop_html, + full_page, oob_page, +) + + +# --------------------------------------------------------------------------- +# OOB header helper +# --------------------------------------------------------------------------- + +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 ( + f'
' + f'
{row_html}' + f'
' + ) + + +# --------------------------------------------------------------------------- +# Blog header (root-header-child -> blog-header-child) +# --------------------------------------------------------------------------- + +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="
", + oob=oob, + ) + + +# --------------------------------------------------------------------------- +# Post header helpers +# --------------------------------------------------------------------------- + +def _post_header_html(ctx: dict, *, oob: bool = False) -> str: + """Build the post-level header row.""" + post = ctx.get("post") or {} + slug = post.get("slug", "") + title = (post.get("title") or "")[:160] + feature_image = post.get("feature_image") + + label_parts = [] + if feature_image: + label_parts.append( + f'' + ) + label_parts.append(f"{escape(title)}") + label_html = "".join(label_parts) + + 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( + f'' + f'' + f'{page_cart_count}' + ) + + # Container nav fragments (calendars, markets) + container_nav = ctx.get("container_nav_html", "") + if container_nav: + nav_parts.append( + f'
{container_nav}
' + ) + + # Admin link + from quart import url_for as qurl, g + rights = ctx.get("rights") or {} + has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False) + if has_admin: + hx_select = ctx.get("hx_select_search", "#main-panel") + select_colours = ctx.get("select_colours", "") + styles = ctx.get("styles") or {} + nav_btn = styles.get("nav_button", "") if isinstance(styles, dict) else getattr(styles, "nav_button", "") + admin_href = qurl("blog.post.admin.admin", slug=slug) + nav_parts.append( + f'' + ) + + 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, + ) + + +# --------------------------------------------------------------------------- +# Post admin header +# --------------------------------------------------------------------------- + +def _post_admin_header_html(ctx: dict, *, oob: bool = False) -> str: + """Post admin header row with admin icon and nav links.""" + from quart import url_for as qurl + + post = ctx.get("post") or {} + slug = post.get("slug", "") + hx_select = ctx.get("hx_select_search", "#main-panel") + select_colours = ctx.get("select_colours", "") + styles = ctx.get("styles") or {} + 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 = ' admin' + + 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, + ) + + +def _post_admin_nav_html(ctx: dict) -> str: + """Post admin desktop nav: calendars, markets, payments, entries, data, edit, settings.""" + from quart import url_for as qurl + + post = ctx.get("post") or {} + slug = post.get("slug", "") + hx_select = ctx.get("hx_select_search", "#main-panel") + select_colours = ctx.get("select_colours", "") + styles = ctx.get("styles") or {} + nav_btn = styles.get("nav_button", "") if isinstance(styles, dict) else getattr(styles, "nav_button", "") + + parts = [] + + # External links to events service + events_url_fn = ctx.get("events_url") + if callable(events_url_fn): + for path, label in [ + (f"/{slug}/calendars/", "calendars"), + (f"/{slug}/markets/", "markets"), + (f"/{slug}/payments/", "payments"), + ]: + href = events_url_fn(path) + parts.append( + f'' + ) + + # HTMX links + for endpoint, label in [ + ("blog.post.admin.entries", "entries"), + ("blog.post.admin.data", "data"), + ("blog.post.admin.edit", "edit"), + ("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, + )) + + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Settings header (root-header-child -> root-settings-header-child) +# --------------------------------------------------------------------------- + +def _settings_header_html(ctx: dict, *, oob: bool = False) -> str: + """Settings header row with admin icon and nav links.""" + from quart import url_for as qurl + + hx_select = ctx.get("hx_select_search", "#main-panel") + settings_href = qurl("settings.home") + label_html = ' admin' + + 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, + ) + + +def _settings_nav_html(ctx: dict) -> str: + """Settings desktop nav: menu items, snippets, tag groups, cache.""" + from quart import url_for as qurl + + select_colours = ctx.get("select_colours", "") + parts = [] + + for endpoint, icon, label in [ + ("menu_items.list_menu_items", "bars", "Menu Items"), + ("snippets.list_snippets", "puzzle-piece", "Snippets"), + ("blog.tag_groups_admin.index", "tags", "Tag Groups"), + ("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, + )) + + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Sub-settings headers (root-settings-header-child -> X-header-child) +# --------------------------------------------------------------------------- + +def _sub_settings_header_html(row_id: str, child_id: str, href: str, + icon: str, label: str, ctx: dict, + *, 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 = f' {escape(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, + ) + + +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 = f'
{escape(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, + ) + + +# --------------------------------------------------------------------------- +# Blog index main panel helpers +# --------------------------------------------------------------------------- + +def _blog_cards_html(ctx: dict) -> str: + """Render blog post cards (list or tile).""" + posts = ctx.get("posts") or [] + view = ctx.get("view") + parts = [] + for p in posts: + if view == "tile": + parts.append(_blog_card_tile_html(p, ctx)) + else: + parts.append(_blog_card_html(p, ctx)) + parts.append(_blog_sentinel_html(ctx)) + return "".join(parts) + + +def _blog_card_html(post: dict, ctx: dict) -> str: + """Single blog post card (list view).""" + from quart import url_for as qurl, g + + slug = post.get("slug", "") + href = call_url(ctx, "blog_url", f"/{slug}/") + hx_select = ctx.get("hx_select_search", "#main-panel") + user = getattr(g, "user", None) + + parts = ['
'] + + # Like button + if user: + liked = post.get("is_liked", False) + like_url = call_url(ctx, "blog_url", f"/{slug}/like/toggle/") + parts.append( + f'
' + f'
' + ) + + parts.append( + f'' + ) + + # Header + parts.append(f'

{escape(post.get("title", ""))}

') + + status = post.get("status", "published") + if status == "draft": + parts.append('
') + parts.append('Draft') + if post.get("publish_requested"): + parts.append('Publish requested') + parts.append('
') + updated = post.get("updated_at") + if updated: + ts = updated.strftime("%-d %b %Y at %H:%M") if hasattr(updated, "strftime") else str(updated) + parts.append(f'

Updated: {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) + parts.append(f'

Published: {ts}

') + + parts.append('
') + + # Feature image + fi = post.get("feature_image") + if fi: + parts.append(f'
') + + # Excerpt + excerpt = post.get("custom_excerpt") or post.get("excerpt", "") + if excerpt: + parts.append(f'

{escape(excerpt)}

') + + parts.append('
') + + # Card widgets (fragments) + card_widgets = ctx.get("card_widgets_html") or {} + widget = card_widgets.get(str(post.get("id", "")), "") + if widget: + parts.append(widget) + + # Tags + authors bar + parts.append(_at_bar_html(post, ctx)) + parts.append('
') + return "".join(parts) + + +def _blog_card_tile_html(post: dict, ctx: dict) -> str: + """Single blog post card (tile view).""" + slug = post.get("slug", "") + href = call_url(ctx, "blog_url", f"/{slug}/") + hx_select = ctx.get("hx_select_search", "#main-panel") + + parts = ['
'] + parts.append( + f'' + ) + + fi = post.get("feature_image") + if fi: + parts.append(f'
') + + parts.append('
') + parts.append(f'

{escape(post.get("title", ""))}

') + + status = post.get("status", "published") + if status == "draft": + parts.append('
') + parts.append('Draft') + if post.get("publish_requested"): + parts.append('Publish requested') + parts.append('
') + updated = post.get("updated_at") + if updated: + ts = updated.strftime("%-d %b %Y at %H:%M") if hasattr(updated, "strftime") else str(updated) + parts.append(f'

Updated: {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) + parts.append(f'

Published: {ts}

') + + excerpt = post.get("custom_excerpt") or post.get("excerpt", "") + if excerpt: + parts.append(f'

{escape(excerpt)}

') + + parts.append('
') + parts.append(_at_bar_html(post, ctx)) + parts.append('
') + return "".join(parts) + + +def _at_bar_html(post: dict, ctx: dict) -> str: + """Tags + authors bar below a card.""" + tags = post.get("tags") or [] + authors = post.get("authors") or [] + if not tags and not authors: + return "" + + all_tags = ctx.get("tags") or [] + tag_slugs = {t.get("slug") or getattr(t, "slug", "") for t in all_tags} if all_tags else set() + + parts = ['
'] + + if tags: + parts.append('
in
    ') + for t in tags: + t_slug = t.get("slug") or getattr(t, "slug", "") + 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 = f'{escape(t_name)}' + else: + init = escape(t_name[:1]) if t_name else "" + icon = ( + f'
    {init}
    ' + ) + parts.append( + f'
  • {icon}' + f'{escape(t_name)}
  • ' + ) + parts.append('
') + + parts.append('
') + + if authors: + parts.append('
by
    ') + for a in authors: + a_name = a.get("name") or getattr(a, "name", "") + a_img = a.get("profile_image") or getattr(a, "profile_image", None) + if a_img: + parts.append( + f'
  • ' + f'{escape(a_name)}' + f'{escape(a_name)}
  • ' + ) + else: + parts.append(f'
  • {escape(a_name)}
  • ') + parts.append('
') + + parts.append('
') + return "".join(parts) + + +def _blog_sentinel_html(ctx: dict) -> str: + """Infinite scroll sentinels for blog post list.""" + page = ctx.get("page", 1) + total_pages = ctx.get("total_pages", 1) + if isinstance(total_pages, str): + total_pages = int(total_pages) + + if page >= total_pages: + return '
End of results
' + + current_local_href = ctx.get("current_local_href", "/index") + qs_fn = ctx.get("qs") + # Build next page URL + next_url = f"{current_local_href}?page={page + 1}" + + parts = [] + # Mobile sentinel + parts.append( + f'' + ) + # Desktop sentinel + parts.append( + f'' + ) + return "".join(parts) + + +def _page_cards_html(ctx: dict) -> str: + """Render page cards with sentinel.""" + pages = ctx.get("pages") or ctx.get("posts") or [] + page_num = ctx.get("page", 1) + total_pages = ctx.get("total_pages", 1) + if isinstance(total_pages, str): + total_pages = int(total_pages) + hx_select = ctx.get("hx_select_search", "#main-panel") + + parts = [] + for pg in pages: + parts.append(_page_card_html(pg, ctx)) + + 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( + f'
' + ) + elif pages: + parts.append('
End of results
') + else: + parts.append('
No pages found.
') + + return "".join(parts) + + +def _page_card_html(page: dict, ctx: dict) -> str: + """Single page card.""" + slug = page.get("slug", "") + href = call_url(ctx, "blog_url", f"/{slug}/") + hx_select = ctx.get("hx_select_search", "#main-panel") + + parts = ['
'] + parts.append( + f'' + ) + parts.append(f'

{escape(page.get("title", ""))}

') + + # Feature badges + features = page.get("features") or {} + if features: + parts.append('
') + if features.get("calendar"): + parts.append('Calendar') + if features.get("market"): + parts.append('Market') + parts.append('
') + + pub = page.get("published_at") + if pub: + ts = pub.strftime("%-d %b %Y at %H:%M") if hasattr(pub, "strftime") else str(pub) + parts.append(f'

Published: {ts}

') + + parts.append('
') + + fi = page.get("feature_image") + if fi: + parts.append(f'
') + + excerpt = page.get("custom_excerpt") or page.get("excerpt", "") + if excerpt: + parts.append(f'

{escape(excerpt)}

') + + parts.append('
') + return "".join(parts) + + +def _view_toggle_html(ctx: dict) -> str: + """View toggle bar (list/tile) for desktop.""" + view = ctx.get("view") + current_local_href = ctx.get("current_local_href", "/index") + hx_select = ctx.get("hx_select_search", "#main-panel") + + list_cls = "bg-stone-200 text-stone-800" if view != "tile" else "text-stone-400 hover:text-stone-600" + tile_cls = "bg-stone-200 text-stone-800" if view == "tile" else "text-stone-400 hover:text-stone-600" + + list_href = f"{current_local_href}" + tile_href = f"{current_local_href}{'&' if '?' in current_local_href else '?'}view=tile" + + list_svg = '' + tile_svg = '' + + return ( + f'' + ) + + +def _content_type_tabs_html(ctx: dict) -> str: + """Posts/Pages tabs.""" + from quart import url_for as qurl + + content_type = ctx.get("content_type", "posts") + hx_select = ctx.get("hx_select_search", "#main-panel") + + posts_href = call_url(ctx, "blog_url", "/index") + pages_href = f"{posts_href}?type=pages" + + 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 ( + f'
' + f'Posts' + f'Pages' + f'
' + ) + + +def _blog_main_panel_html(ctx: dict) -> str: + """Blog index main panel with tabs, toggle, and cards.""" + content_type = ctx.get("content_type", "posts") + view = ctx.get("view") + + parts = [_content_type_tabs_html(ctx)] + + if content_type == "pages": + parts.append('
') + parts.append(_page_cards_html(ctx)) + parts.append('
') + else: + parts.append(_view_toggle_html(ctx)) + if view == "tile": + parts.append('
') + else: + parts.append('
') + parts.append(_blog_cards_html(ctx)) + parts.append('
') + + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Desktop aside (filter sidebar) +# --------------------------------------------------------------------------- + +def _blog_aside_html(ctx: dict) -> str: + """Desktop aside with search, action buttons, and filters.""" + parts = [] + parts.append(search_desktop_html(ctx)) + parts.append(_action_buttons_html(ctx)) + parts.append(f'
') + parts.append(_tag_groups_filter_html(ctx)) + parts.append(_authors_filter_html(ctx)) + parts.append('
') + parts.append('
') + return "".join(parts) + + +def _blog_filter_html(ctx: dict) -> str: + """Mobile filter (details/summary).""" + current_local_href = ctx.get("current_local_href", "/index") + search = ctx.get("search", "") + search_count = ctx.get("search_count", "") + hx_select = ctx.get("hx_select", "#main-panel") + + # Mobile filter summary tags + summary_parts = [] + summary_parts.append(_tag_groups_filter_summary_html(ctx)) + summary_parts.append(_authors_filter_summary_html(ctx)) + summary_html = "".join(summary_parts) + + filter_content = search_mobile_html(ctx) + summary_html + 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, + ) + + +def _action_buttons_html(ctx: dict) -> str: + """New Post/Page + Drafts toggle buttons.""" + from quart import g + + rights = ctx.get("rights") or {} + has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False) + user = getattr(g, "user", None) + hx_select = ctx.get("hx_select_search", "#main-panel") + drafts = ctx.get("drafts") + draft_count = ctx.get("draft_count", 0) + current_local_href = ctx.get("current_local_href", "/index") + + parts = ['
'] + + if has_admin: + new_href = call_url(ctx, "blog_url", "/new/") + parts.append( + f' New Post' + ) + new_page_href = call_url(ctx, "blog_url", "/new-page/") + parts.append( + f' New Page' + ) + + if user and (draft_count or drafts): + if drafts: + off_href = f"{current_local_href}" + parts.append( + f' Drafts' + f' {draft_count}' + ) + else: + on_href = f"{current_local_href}{'&' if '?' in current_local_href else '?'}drafts=1" + parts.append( + f' Drafts' + f' {draft_count}' + ) + + parts.append('
') + return "".join(parts) + + +def _tag_groups_filter_html(ctx: dict) -> str: + """Tag group filter bar for desktop/mobile.""" + tag_groups = ctx.get("tag_groups") or [] + selected_groups = ctx.get("selected_groups") or () + selected_tags = ctx.get("selected_tags") or () + hx_select = ctx.get("hx_select_search", "#main-panel") + + parts = ['') + return "".join(parts) + + +def _authors_filter_html(ctx: dict) -> str: + """Author filter bar for desktop/mobile.""" + authors = ctx.get("authors") or [] + selected_authors = ctx.get("selected_authors") or () + hx_select = ctx.get("hx_select_search", "#main-panel") + + parts = ['') + return "".join(parts) + + +def _tag_groups_filter_summary_html(ctx: dict) -> str: + """Mobile filter summary for tag groups.""" + selected_groups = ctx.get("selected_groups") or () + tag_groups = ctx.get("tag_groups") or [] + if not selected_groups: + return "" + names = [] + for g in tag_groups: + g_slug = getattr(g, "slug", "") if hasattr(g, "slug") else g.get("slug", "") + g_name = getattr(g, "name", "") if hasattr(g, "name") else g.get("name", "") + if g_slug in selected_groups: + names.append(g_name) + if not names: + return "" + return f'{escape(", ".join(names))}' + + +def _authors_filter_summary_html(ctx: dict) -> str: + """Mobile filter summary for authors.""" + selected_authors = ctx.get("selected_authors") or () + authors = ctx.get("authors") or [] + if not selected_authors: + return "" + names = [] + for a in authors: + a_slug = getattr(a, "slug", "") if hasattr(a, "slug") else a.get("slug", "") + a_name = getattr(a, "name", "") if hasattr(a, "name") else a.get("name", "") + if a_slug in selected_authors: + names.append(a_name) + if not names: + return "" + return f'{escape(", ".join(names))}' + + +# --------------------------------------------------------------------------- +# Post detail main panel +# --------------------------------------------------------------------------- + +def _post_main_panel_html(ctx: dict) -> str: + """Post/page article content.""" + from quart import g, url_for as qurl + + post = ctx.get("post") or {} + slug = post.get("slug", "") + user = getattr(g, "user", None) + rights = ctx.get("rights") or {} + is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False) + hx_select = ctx.get("hx_select_search", "#main-panel") + + parts = ['
'] + + # Draft indicator + if post.get("status") == "draft": + parts.append('
') + parts.append('Draft') + if post.get("publish_requested"): + parts.append('Publish requested') + if is_admin or (user and post.get("user_id") == getattr(user, "id", None)): + edit_href = qurl("blog.post.admin.edit", slug=slug) + parts.append( + f'' + f' Edit' + ) + parts.append('
') + + # Blog post chrome (not for pages) + if not post.get("is_page"): + if user: + liked = post.get("is_liked", False) + like_url = call_url(ctx, "blog_url", f"/{slug}/like/toggle/") + parts.append( + f'
' + f'
' + ) + + if post.get("custom_excerpt"): + parts.append(f'
{post["custom_excerpt"]}
') + + # Desktop at_bar + parts.append(f'') + + # Feature image + fi = post.get("feature_image") + if fi: + parts.append(f'
') + + # Post HTML content + html_content = post.get("html", "") + if html_content: + parts.append(f'
{html_content}
') + + parts.append('
') + return "".join(parts) + + +def _post_meta_html(ctx: dict) -> str: + """Post SEO meta tags (Open Graph, Twitter, JSON-LD).""" + post = ctx.get("post") or {} + base_title = ctx.get("base_title", "") + + is_public = post.get("visibility") == "public" + is_published = post.get("status") == "published" + email_only = post.get("email_only", False) + robots = "index,follow" if (is_public and is_published and not email_only) else "noindex,nofollow" + + # Description + desc = (post.get("meta_description") or post.get("og_description") or + post.get("twitter_description") or post.get("custom_excerpt") or + post.get("excerpt") or "") + if not desc and post.get("html"): + import re + desc = re.sub(r'<[^>]+>', '', post["html"]) + desc = desc.replace("\n", " ").replace("\r", " ").strip()[:160] + + # Image + image = (post.get("og_image") or post.get("twitter_image") or post.get("feature_image") or "") + + # Canonical + from quart import request as req + canonical = post.get("canonical_url") or (req.url if req else "") + + og_title = post.get("og_title") or base_title + tw_title = post.get("twitter_title") or base_title + is_article = not post.get("is_page") + + parts = [f''] + parts.append(f'{escape(base_title)}') + parts.append(f'') + if canonical: + parts.append(f'') + + parts.append(f'') + parts.append(f'') + parts.append(f'') + if canonical: + parts.append(f'') + if image: + parts.append(f'') + + parts.append(f'') + parts.append(f'') + parts.append(f'') + if image: + parts.append(f'') + + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Home page (Ghost "home" page) +# --------------------------------------------------------------------------- + +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 f'
{html}
' + + +# --------------------------------------------------------------------------- +# Post admin - empty main panel +# --------------------------------------------------------------------------- + +def _post_admin_main_panel_html(ctx: dict) -> str: + return '
' + + +# --------------------------------------------------------------------------- +# Settings main panels +# --------------------------------------------------------------------------- + +def _settings_main_panel_html(ctx: dict) -> str: + return '
' + + +def _cache_main_panel_html(ctx: dict) -> str: + from quart import url_for as qurl + + csrf = ctx.get("csrf_token", "") + clear_url = qurl("settings.cache_clear") + return ( + f'
' + f'
' + f'
' + f'' + f'' + f'
' + f'
' + f'
' + ) + + +# --------------------------------------------------------------------------- +# Snippets main panel +# --------------------------------------------------------------------------- + +def _snippets_main_panel_html(ctx: dict) -> str: + return ( + f'
' + f'
' + f'

Snippets

' + f'
{_snippets_list_html(ctx)}
' + ) + + +def _snippets_list_html(ctx: dict) -> str: + """Snippets list with visibility badges and delete buttons.""" + from quart import url_for as qurl, g + + snippets = ctx.get("snippets") or [] + is_admin = ctx.get("is_admin", False) + csrf = ctx.get("csrf_token", "") + user = getattr(g, "user", None) + user_id = getattr(user, "id", None) + + if not snippets: + return ( + '
' + '
' + '' + '

No snippets yet. Create one from the blog editor.

' + ) + + badge_colours = { + "private": "bg-stone-200 text-stone-700", + "shared": "bg-blue-100 text-blue-700", + "admin": "bg-amber-100 text-amber-700", + } + + parts = ['
'] + for s in snippets: + s_id = getattr(s, "id", None) or s.get("id") + s_name = getattr(s, "name", "") if hasattr(s, "name") else s.get("name", "") + s_uid = getattr(s, "user_id", None) if hasattr(s, "user_id") else s.get("user_id") + s_vis = getattr(s, "visibility", "private") if hasattr(s, "visibility") else s.get("visibility", "private") + + owner = "You" if s_uid == user_id else f"User #{s_uid}" + badge_cls = badge_colours.get(s_vis, "bg-stone-200 text-stone-700") + + parts.append( + f'
' + f'
{escape(s_name)}
' + f'
{owner}
' + f'{s_vis}' + ) + + if is_admin: + patch_url = qurl("snippets.patch_visibility", snippet_id=s_id) + parts.append( + f'') + + if s_uid == user_id or is_admin: + del_url = qurl("snippets.delete_snippet", snippet_id=s_id) + parts.append( + f'' + ) + + parts.append('
') + + parts.append('
') + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Menu items main panel +# --------------------------------------------------------------------------- + +def _menu_items_main_panel_html(ctx: dict) -> str: + from quart import url_for as qurl + + new_url = qurl("menu_items.new_menu_item") + return ( + f'
' + f'
' + f'
' + f'' + f'
' + ) + + +def _menu_items_list_html(ctx: dict) -> str: + from quart import url_for as qurl + + menu_items = ctx.get("menu_items") or [] + csrf = ctx.get("csrf_token", "") + + if not menu_items: + return ( + '
' + '
' + '' + '

No menu items yet. Add one to get started!

' + ) + + parts = ['
'] + for item in menu_items: + i_id = getattr(item, "id", None) or item.get("id") + label = getattr(item, "label", "") if hasattr(item, "label") else item.get("label", "") + slug = getattr(item, "slug", "") if hasattr(item, "slug") else item.get("slug", "") + fi = getattr(item, "feature_image", None) if hasattr(item, "feature_image") else item.get("feature_image") + sort = getattr(item, "sort_order", 0) if hasattr(item, "sort_order") else item.get("sort_order", 0) + + 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 = (f'{escape(label)}' + if fi else '
') + + parts.append( + f'
' + f'
' + f'{img}' + f'
{escape(label)}
' + f'
{escape(slug)}
' + f'
Order: {sort}
' + f'
' + f'' + f'
' + ) + + parts.append('
') + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Tag groups main panel +# --------------------------------------------------------------------------- + +def _tag_groups_main_panel_html(ctx: dict) -> str: + from quart import url_for as qurl + + groups = ctx.get("groups") or [] + unassigned_tags = ctx.get("unassigned_tags") or [] + csrf = ctx.get("csrf_token", "") + + parts = ['
'] + + # Create form + create_url = qurl("blog.tag_groups_admin.create") + parts.append( + f'
' + f'' + f'

New Group

' + f'
' + f'' + f'' + f'' + f'
' + f'' + f'' + f'
' + ) + + # Groups list + if groups: + parts.append('
    ') + for group in groups: + g_id = getattr(group, "id", None) or group.get("id") + g_name = getattr(group, "name", "") if hasattr(group, "name") else group.get("name", "") + g_slug = getattr(group, "slug", "") if hasattr(group, "slug") else group.get("slug", "") + g_fi = getattr(group, "feature_image", None) if hasattr(group, "feature_image") else group.get("feature_image") + g_colour = getattr(group, "colour", None) if hasattr(group, "colour") else group.get("colour") + g_sort = getattr(group, "sort_order", 0) if hasattr(group, "sort_order") else group.get("sort_order", 0) + + edit_href = qurl("blog.tag_groups_admin.edit", id=g_id) + + if g_fi: + icon = f'{escape(g_name)}' + else: + style = f"background-color: {g_colour}; color: white;" if g_colour else "background-color: #e7e5e4; color: #57534e;" + icon = ( + f'
    {escape(g_name[:1])}
    ' + ) + + parts.append( + f'
  • {icon}' + f'
    {escape(g_name)}' + f'{escape(g_slug)}
    ' + f'order: {g_sort}
  • ' + ) + parts.append('
') + else: + parts.append('

No tag groups yet.

') + + # Unassigned tags + if unassigned_tags: + parts.append(f'

Unassigned Tags ({len(unassigned_tags)})

') + parts.append('
') + for tag in unassigned_tags: + t_name = getattr(tag, "name", "") if hasattr(tag, "name") else tag.get("name", "") + parts.append( + f'' + f'{escape(t_name)}' + ) + parts.append('
') + + parts.append('
') + return "".join(parts) + + +def _tag_groups_edit_main_panel_html(ctx: dict) -> str: + from quart import url_for as qurl + + group = ctx.get("group") + all_tags = ctx.get("all_tags") or [] + assigned_tag_ids = ctx.get("assigned_tag_ids") or set() + csrf = ctx.get("csrf_token", "") + + g_id = getattr(group, "id", None) or group.get("id") if group else None + g_name = getattr(group, "name", "") if hasattr(group, "name") else (group.get("name", "") if group else "") + g_colour = getattr(group, "colour", "") if hasattr(group, "colour") else (group.get("colour", "") if group else "") + g_sort = getattr(group, "sort_order", 0) if hasattr(group, "sort_order") else (group.get("sort_order", 0) if group else 0) + g_fi = getattr(group, "feature_image", "") if hasattr(group, "feature_image") else (group.get("feature_image", "") if group else "") + + save_url = qurl("blog.tag_groups_admin.save", id=g_id) + del_url = qurl("blog.tag_groups_admin.delete_group", id=g_id) + + parts = [f'
'] + + # Edit form + parts.append( + f'
' + f'' + f'
' + f'
' + f'
' + f'
' + f'
' + f'
' + f'
' + f'
' + f'
' + f'
' + ) + + # Tag checkboxes + parts.append( + '
' + '
' + ) + for tag in all_tags: + t_id = getattr(tag, "id", None) or tag.get("id") + 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 = " checked" if t_id in assigned_tag_ids else "" + img = f'' if t_fi else "" + parts.append( + f'' + ) + parts.append('
') + + parts.append( + '
' + '
' + ) + + # Delete form + parts.append( + f'
' + f'' + f'
' + ) + + parts.append('
') + return "".join(parts) + + +# --------------------------------------------------------------------------- +# New post/page main panel — left as render_template (uses Koenig editor JS) +# Post edit main panel — left as render_template (uses Koenig editor JS) +# Post settings main panel — left as render_template (complex form macros) +# Post entries main panel — left as render_template (calendar browser lazy-loads) +# Post data main panel — left as render_template (uses ORM introspection macros) +# --------------------------------------------------------------------------- + + +# =========================================================================== +# PUBLIC API — called from route handlers +# =========================================================================== + +# ---- Home page ---- + +async def render_home_page(ctx: dict) -> str: + root_hdr = root_header_html(ctx) + post_hdr = _post_header_html(ctx) + header_rows = root_hdr + post_hdr + content = _home_main_panel_html(ctx) + meta = _post_meta_html(ctx) + menu_html = ctx.get("nav_html", "") or "" + return full_page(ctx, header_rows_html=header_rows, content_html=content, + meta_html=meta, menu_html=menu_html) + + +async def render_home_oob(ctx: dict) -> str: + root_hdr = root_header_html(ctx, oob=True) + post_oob = _oob_header_html("root-header-child", "post-header-child", + _post_header_html(ctx)) + content = _home_main_panel_html(ctx) + return oob_page(ctx, oobs_html=root_hdr + post_oob, content_html=content) + + +# ---- Blog index ---- + +async def render_blog_page(ctx: dict) -> str: + root_hdr = root_header_html(ctx) + blog_hdr = _blog_header_html(ctx) + header_rows = root_hdr + blog_hdr + content = _blog_main_panel_html(ctx) + aside = _blog_aside_html(ctx) + filter_html = _blog_filter_html(ctx) + return full_page(ctx, header_rows_html=header_rows, content_html=content, + aside_html=aside, filter_html=filter_html) + + +async def render_blog_oob(ctx: dict) -> str: + root_hdr = root_header_html(ctx, oob=True) + blog_oob = _oob_header_html("root-header-child", "blog-header-child", + _blog_header_html(ctx)) + content = _blog_main_panel_html(ctx) + aside = _blog_aside_html(ctx) + filter_html = _blog_filter_html(ctx) + nav_html = ctx.get("nav_html", "") or "" + return oob_page(ctx, oobs_html=root_hdr + blog_oob, + content_html=content, aside_html=aside, + filter_html=filter_html, menu_html=nav_html) + + +async def render_blog_cards(ctx: dict) -> str: + """Pagination-only response (page > 1).""" + return _blog_cards_html(ctx) + + +async def render_blog_page_cards(ctx: dict) -> str: + """Page cards pagination response.""" + return _page_cards_html(ctx) + + +# ---- New post/page ---- + +async def render_new_post_page(ctx: dict) -> str: + root_hdr = root_header_html(ctx) + blog_hdr = _blog_header_html(ctx) + header_rows = root_hdr + blog_hdr + # Content comes from Jinja (editor template) + content = ctx.get("editor_html", "") + return full_page(ctx, header_rows_html=header_rows, content_html=content) + + +async def render_new_post_oob(ctx: dict) -> str: + root_hdr = root_header_html(ctx, oob=True) + blog_oob = _blog_header_html(ctx, oob=True) + content = ctx.get("editor_html", "") + return oob_page(ctx, oobs_html=root_hdr + blog_oob, content_html=content) + + +# ---- Post detail ---- + +async def render_post_page(ctx: dict) -> str: + root_hdr = root_header_html(ctx) + post_hdr = _post_header_html(ctx) + header_rows = root_hdr + post_hdr + content = _post_main_panel_html(ctx) + meta = _post_meta_html(ctx) + menu_html = ctx.get("nav_html", "") or "" + return full_page(ctx, header_rows_html=header_rows, content_html=content, + meta_html=meta, menu_html=menu_html) + + +async def render_post_oob(ctx: dict) -> str: + root_hdr = root_header_html(ctx, oob=True) + post_oob = _oob_header_html("root-header-child", "post-header-child", + _post_header_html(ctx)) + content = _post_main_panel_html(ctx) + menu_html = ctx.get("nav_html", "") or "" + return oob_page(ctx, oobs_html=root_hdr + post_oob, + content_html=content, menu_html=menu_html) + + +# ---- Post admin ---- + +async def render_post_admin_page(ctx: dict) -> str: + root_hdr = root_header_html(ctx) + post_hdr = _post_header_html(ctx) + admin_hdr = _post_admin_header_html(ctx) + header_rows = root_hdr + post_hdr + admin_hdr + content = _post_admin_main_panel_html(ctx) + menu_html = ctx.get("nav_html", "") or "" + return full_page(ctx, header_rows_html=header_rows, content_html=content, + menu_html=menu_html) + + +async def render_post_admin_oob(ctx: dict) -> str: + post_hdr_oob = _post_header_html(ctx, oob=True) + admin_oob = _oob_header_html("post-header-child", "post-admin-header-child", + _post_admin_header_html(ctx)) + content = _post_admin_main_panel_html(ctx) + menu_html = ctx.get("nav_html", "") or "" + return oob_page(ctx, oobs_html=post_hdr_oob + admin_oob, + content_html=content, menu_html=menu_html) + + +# ---- Post data ---- + +async def render_post_data_page(ctx: dict) -> str: + root_hdr = root_header_html(ctx) + post_hdr = _post_header_html(ctx) + admin_hdr = _post_admin_header_html(ctx) + from quart import url_for as qurl + slug = (ctx.get("post") or {}).get("slug", "") + data_hdr = _post_sub_admin_header_html( + "post_data-row", "post_data-header-child", + qurl("blog.post.admin.data", slug=slug), + "database", "data", ctx, + ) + header_rows = root_hdr + post_hdr + admin_hdr + data_hdr + content = ctx.get("data_html", "") + return full_page(ctx, header_rows_html=header_rows, content_html=content) + + +async def render_post_data_oob(ctx: dict) -> str: + admin_hdr_oob = _post_admin_header_html(ctx, oob=True) + from quart import url_for as qurl + slug = (ctx.get("post") or {}).get("slug", "") + data_hdr = _post_sub_admin_header_html( + "post_data-row", "post_data-header-child", + qurl("blog.post.admin.data", slug=slug), + "database", "data", ctx, + ) + data_oob = _oob_header_html("post-admin-header-child", "post_data-header-child", + data_hdr) + content = ctx.get("data_html", "") + return oob_page(ctx, oobs_html=admin_hdr_oob + data_oob, content_html=content) + + +# ---- Post entries ---- + +async def render_post_entries_page(ctx: dict) -> str: + root_hdr = root_header_html(ctx) + post_hdr = _post_header_html(ctx) + admin_hdr = _post_admin_header_html(ctx) + from quart import url_for as qurl + slug = (ctx.get("post") or {}).get("slug", "") + entries_hdr = _post_sub_admin_header_html( + "post_entries-row", "post_entries-header-child", + qurl("blog.post.admin.entries", slug=slug), + "clock", "entries", ctx, + ) + header_rows = root_hdr + post_hdr + admin_hdr + entries_hdr + content = ctx.get("entries_html", "") + return full_page(ctx, header_rows_html=header_rows, content_html=content) + + +async def render_post_entries_oob(ctx: dict) -> str: + admin_hdr_oob = _post_admin_header_html(ctx, oob=True) + from quart import url_for as qurl + slug = (ctx.get("post") or {}).get("slug", "") + entries_hdr = _post_sub_admin_header_html( + "post_entries-row", "post_entries-header-child", + qurl("blog.post.admin.entries", slug=slug), + "clock", "entries", ctx, + ) + entries_oob = _oob_header_html("post-admin-header-child", "post_entries-header-child", + entries_hdr) + content = ctx.get("entries_html", "") + return oob_page(ctx, oobs_html=admin_hdr_oob + entries_oob, content_html=content) + + +# ---- Post edit ---- + +async def render_post_edit_page(ctx: dict) -> str: + root_hdr = root_header_html(ctx) + post_hdr = _post_header_html(ctx) + admin_hdr = _post_admin_header_html(ctx) + from quart import url_for as qurl + slug = (ctx.get("post") or {}).get("slug", "") + edit_hdr = _post_sub_admin_header_html( + "post_edit-row", "post_edit-header-child", + qurl("blog.post.admin.edit", slug=slug), + "pen-to-square", "edit", ctx, + ) + header_rows = root_hdr + post_hdr + admin_hdr + edit_hdr + content = ctx.get("edit_html", "") + body_end = ctx.get("body_end_html", "") + return full_page(ctx, header_rows_html=header_rows, content_html=content, + body_end_html=body_end) + + +async def render_post_edit_oob(ctx: dict) -> str: + admin_hdr_oob = _post_admin_header_html(ctx, oob=True) + from quart import url_for as qurl + slug = (ctx.get("post") or {}).get("slug", "") + edit_hdr = _post_sub_admin_header_html( + "post_edit-row", "post_edit-header-child", + qurl("blog.post.admin.edit", slug=slug), + "pen-to-square", "edit", ctx, + ) + edit_oob = _oob_header_html("post-admin-header-child", "post_edit-header-child", + edit_hdr) + content = ctx.get("edit_html", "") + return oob_page(ctx, oobs_html=admin_hdr_oob + edit_oob, content_html=content) + + +# ---- Post settings ---- + +async def render_post_settings_page(ctx: dict) -> str: + root_hdr = root_header_html(ctx) + post_hdr = _post_header_html(ctx) + admin_hdr = _post_admin_header_html(ctx) + from quart import url_for as qurl + slug = (ctx.get("post") or {}).get("slug", "") + settings_hdr = _post_sub_admin_header_html( + "post_settings-row", "post_settings-header-child", + qurl("blog.post.admin.settings", slug=slug), + "cog", "settings", ctx, + ) + header_rows = root_hdr + post_hdr + admin_hdr + settings_hdr + content = ctx.get("settings_html", "") + return full_page(ctx, header_rows_html=header_rows, content_html=content) + + +async def render_post_settings_oob(ctx: dict) -> str: + admin_hdr_oob = _post_admin_header_html(ctx, oob=True) + from quart import url_for as qurl + slug = (ctx.get("post") or {}).get("slug", "") + settings_hdr = _post_sub_admin_header_html( + "post_settings-row", "post_settings-header-child", + qurl("blog.post.admin.settings", slug=slug), + "cog", "settings", ctx, + ) + settings_oob = _oob_header_html("post-admin-header-child", "post_settings-header-child", + settings_hdr) + content = ctx.get("settings_html", "") + return oob_page(ctx, oobs_html=admin_hdr_oob + settings_oob, content_html=content) + + +# ---- Settings home ---- + +async def render_settings_page(ctx: dict) -> str: + root_hdr = root_header_html(ctx) + settings_hdr = _settings_header_html(ctx) + header_rows = root_hdr + settings_hdr + content = _settings_main_panel_html(ctx) + menu_html = _settings_nav_html(ctx) + return full_page(ctx, header_rows_html=header_rows, content_html=content, + menu_html=menu_html) + + +async def render_settings_oob(ctx: dict) -> str: + root_hdr = root_header_html(ctx, oob=True) + settings_oob = _oob_header_html("root-header-child", "root-settings-header-child", + _settings_header_html(ctx)) + content = _settings_main_panel_html(ctx) + menu_html = _settings_nav_html(ctx) + return oob_page(ctx, oobs_html=root_hdr + settings_oob, + content_html=content, menu_html=menu_html) + + +# ---- Cache ---- + +async def render_cache_page(ctx: dict) -> str: + root_hdr = root_header_html(ctx) + settings_hdr = _settings_header_html(ctx) + from quart import url_for as qurl + cache_hdr = _sub_settings_header_html( + "cache-row", "cache-header-child", + qurl("settings.cache"), "refresh", "Cache", ctx, + ) + header_rows = root_hdr + settings_hdr + cache_hdr + content = _cache_main_panel_html(ctx) + return full_page(ctx, header_rows_html=header_rows, content_html=content) + + +async def render_cache_oob(ctx: dict) -> str: + settings_hdr_oob = _settings_header_html(ctx, oob=True) + from quart import url_for as qurl + cache_hdr = _sub_settings_header_html( + "cache-row", "cache-header-child", + qurl("settings.cache"), "refresh", "Cache", ctx, + ) + cache_oob = _oob_header_html("root-settings-header-child", "cache-header-child", + cache_hdr) + content = _cache_main_panel_html(ctx) + return oob_page(ctx, oobs_html=settings_hdr_oob + cache_oob, content_html=content) + + +# ---- Snippets ---- + +async def render_snippets_page(ctx: dict) -> str: + root_hdr = root_header_html(ctx) + settings_hdr = _settings_header_html(ctx) + from quart import url_for as qurl + snippets_hdr = _sub_settings_header_html( + "snippets-row", "snippets-header-child", + qurl("snippets.list_snippets"), "puzzle-piece", "Snippets", ctx, + ) + header_rows = root_hdr + settings_hdr + snippets_hdr + content = _snippets_main_panel_html(ctx) + return full_page(ctx, header_rows_html=header_rows, content_html=content) + + +async def render_snippets_oob(ctx: dict) -> str: + settings_hdr_oob = _settings_header_html(ctx, oob=True) + from quart import url_for as qurl + snippets_hdr = _sub_settings_header_html( + "snippets-row", "snippets-header-child", + qurl("snippets.list_snippets"), "puzzle-piece", "Snippets", ctx, + ) + snippets_oob = _oob_header_html("root-settings-header-child", "snippets-header-child", + snippets_hdr) + content = _snippets_main_panel_html(ctx) + return oob_page(ctx, oobs_html=settings_hdr_oob + snippets_oob, content_html=content) + + +# ---- Menu items ---- + +async def render_menu_items_page(ctx: dict) -> str: + root_hdr = root_header_html(ctx) + settings_hdr = _settings_header_html(ctx) + from quart import url_for as qurl + mi_hdr = _sub_settings_header_html( + "menu_items-row", "menu_items-header-child", + qurl("menu_items.list_menu_items"), "bars", "Menu Items", ctx, + ) + header_rows = root_hdr + settings_hdr + mi_hdr + content = _menu_items_main_panel_html(ctx) + return full_page(ctx, header_rows_html=header_rows, content_html=content) + + +async def render_menu_items_oob(ctx: dict) -> str: + settings_hdr_oob = _settings_header_html(ctx, oob=True) + from quart import url_for as qurl + mi_hdr = _sub_settings_header_html( + "menu_items-row", "menu_items-header-child", + qurl("menu_items.list_menu_items"), "bars", "Menu Items", ctx, + ) + mi_oob = _oob_header_html("root-settings-header-child", "menu_items-header-child", + mi_hdr) + content = _menu_items_main_panel_html(ctx) + return oob_page(ctx, oobs_html=settings_hdr_oob + mi_oob, content_html=content) + + +# ---- Tag groups ---- + +async def render_tag_groups_page(ctx: dict) -> str: + root_hdr = root_header_html(ctx) + settings_hdr = _settings_header_html(ctx) + from quart import url_for as qurl + tg_hdr = _sub_settings_header_html( + "tag-groups-row", "tag-groups-header-child", + qurl("blog.tag_groups_admin.index"), "tags", "Tag Groups", ctx, + ) + header_rows = root_hdr + settings_hdr + tg_hdr + content = _tag_groups_main_panel_html(ctx) + return full_page(ctx, header_rows_html=header_rows, content_html=content) + + +async def render_tag_groups_oob(ctx: dict) -> str: + settings_hdr_oob = _settings_header_html(ctx, oob=True) + from quart import url_for as qurl + tg_hdr = _sub_settings_header_html( + "tag-groups-row", "tag-groups-header-child", + qurl("blog.tag_groups_admin.index"), "tags", "Tag Groups", ctx, + ) + tg_oob = _oob_header_html("root-settings-header-child", "tag-groups-header-child", + tg_hdr) + content = _tag_groups_main_panel_html(ctx) + return oob_page(ctx, oobs_html=settings_hdr_oob + tg_oob, content_html=content) + + +# ---- Tag group edit ---- + +async def render_tag_group_edit_page(ctx: dict) -> str: + root_hdr = root_header_html(ctx) + settings_hdr = _settings_header_html(ctx) + from quart import url_for as qurl + g_id = (ctx.get("group") or {}).get("id") or getattr(ctx.get("group"), "id", None) + tg_hdr = _sub_settings_header_html( + "tag-groups-row", "tag-groups-header-child", + qurl("blog.tag_groups_admin.edit", id=g_id), "tags", "Tag Groups", ctx, + ) + header_rows = root_hdr + settings_hdr + tg_hdr + content = _tag_groups_edit_main_panel_html(ctx) + return full_page(ctx, header_rows_html=header_rows, content_html=content) + + +async def render_tag_group_edit_oob(ctx: dict) -> str: + settings_hdr_oob = _settings_header_html(ctx, oob=True) + from quart import url_for as qurl + g_id = (ctx.get("group") or {}).get("id") or getattr(ctx.get("group"), "id", None) + tg_hdr = _sub_settings_header_html( + "tag-groups-row", "tag-groups-header-child", + qurl("blog.tag_groups_admin.edit", id=g_id), "tags", "Tag Groups", ctx, + ) + tg_oob = _oob_header_html("root-settings-header-child", "tag-groups-header-child", + tg_hdr) + content = _tag_groups_edit_main_panel_html(ctx) + return oob_page(ctx, oobs_html=settings_hdr_oob + tg_oob, content_html=content) diff --git a/cart/app.py b/cart/app.py index 0eefe90..255c605 100644 --- a/cart/app.py +++ b/cart/app.py @@ -1,5 +1,6 @@ from __future__ import annotations import path_setup # noqa: F401 # adds shared/ to sys.path +import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file from decimal import Decimal from pathlib import Path diff --git a/cart/bp/cart/overview_routes.py b/cart/bp/cart/overview_routes.py index 15f9eb6..8ac65c4 100644 --- a/cart/bp/cart/overview_routes.py +++ b/cart/bp/cart/overview_routes.py @@ -14,18 +14,16 @@ def register(url_prefix: str) -> Blueprint: @bp.get("/") async def overview(): from quart import g + from shared.sexp.page import get_template_context + from sexp_components import render_overview_page, render_overview_oob + page_groups = await get_cart_grouped_by_page(g.s) + ctx = await get_template_context() if not is_htmx_request(): - html = await render_template( - "_types/cart/overview/index.html", - page_groups=page_groups, - ) + html = await render_overview_page(ctx, page_groups) else: - html = await render_template( - "_types/cart/overview/_oob_elements.html", - page_groups=page_groups, - ) + html = await render_overview_oob(ctx, page_groups) return await make_response(html) return bp diff --git a/cart/bp/cart/page_routes.py b/cart/bp/cart/page_routes.py index 729fc57..59749e6 100644 --- a/cart/bp/cart/page_routes.py +++ b/cart/bp/cart/page_routes.py @@ -40,10 +40,20 @@ def register(url_prefix: str) -> Blueprint: ticket_total=ticket_total, ) + from shared.sexp.page import get_template_context + from sexp_components import render_page_cart_page, render_page_cart_oob + + ctx = await get_template_context() if not is_htmx_request(): - html = await render_template("_types/cart/page/index.html", **tpl_ctx) + html = await render_page_cart_page( + ctx, post, cart, cal_entries, page_tickets, + ticket_groups, total, calendar_total, ticket_total, + ) else: - html = await render_template("_types/cart/page/_oob_elements.html", **tpl_ctx) + html = await render_page_cart_oob( + ctx, post, cart, cal_entries, page_tickets, + ticket_groups, total, calendar_total, ticket_total, + ) return await make_response(html) @bp.post("/checkout/") diff --git a/cart/bp/order/routes.py b/cart/bp/order/routes.py index 2452679..bb869e4 100644 --- a/cart/bp/order/routes.py +++ b/cart/bp/order/routes.py @@ -55,12 +55,16 @@ def register() -> Blueprint: order = result.scalar_one_or_none() if not order: return await make_response("Order not found", 404) + from shared.sexp.page import get_template_context + from sexp_components import render_order_page, render_order_oob + + ctx = await get_template_context() + calendar_entries = ctx.get("calendar_entries") + if not is_htmx_request(): - # Normal browser request: full page with layout - html = await render_template("_types/order/index.html", order=order,) + html = await render_order_page(ctx, order, calendar_entries, url_for) else: - # HTMX navigation (page 1): main panel + OOB elements - html = await render_template("_types/order/_oob_elements.html", order=order,) + html = await render_order_oob(ctx, order, calendar_entries, url_for) return await make_response(html) diff --git a/cart/bp/orders/routes.py b/cart/bp/orders/routes.py index a6fbd8a..cdbf717 100644 --- a/cart/bp/orders/routes.py +++ b/cart/bp/orders/routes.py @@ -136,24 +136,30 @@ def register(url_prefix: str) -> Blueprint: result = await g.s.execute(stmt) orders = result.scalars().all() - context = { - "orders": orders, - "page": page, - "total_pages": total_pages, - "search": search, - "search_count": total_count, # For search display - } + from shared.sexp.page import get_template_context + from sexp_components import ( + render_orders_page, + render_orders_rows, + render_orders_oob, + ) + + ctx = await get_template_context() + qs_fn = makeqs_factory() - # Determine which template to use based on request type and pagination if not is_htmx_request(): - # Normal browser request: full page with layout - html = await render_template("_types/orders/index.html", **context) + html = await render_orders_page( + ctx, orders, page, total_pages, search, total_count, + url_for, qs_fn, + ) elif page > 1: - # HTMX pagination: just table rows + sentinel - html = await render_template("_types/orders/_rows.html", **context) + html = await render_orders_rows( + ctx, orders, page, total_pages, url_for, qs_fn, + ) else: - # HTMX navigation (page 1): main panel + OOB elements - html = await render_template("_types/orders/_oob_elements.html", **context) + html = await render_orders_oob( + ctx, orders, page, total_pages, search, total_count, + url_for, qs_fn, + ) resp = await make_response(html) resp.headers["Hx-Push-Url"] = _current_url_without_page() diff --git a/cart/sexp_components.py b/cart/sexp_components.py new file mode 100644 index 0000000..b769cda --- /dev/null +++ b/cart/sexp_components.py @@ -0,0 +1,805 @@ +""" +Cart service s-expression page components. + +Renders cart overview, page cart, orders list, and single order detail. +Called from route handlers in place of ``render_template()``. +""" +from __future__ import annotations + +from typing import Any + +from shared.sexp.jinja_bridge import sexp +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 + + +# --------------------------------------------------------------------------- +# Header helpers +# --------------------------------------------------------------------------- + +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, + ) + + +def _page_cart_header_html(ctx: dict, page_post: Any, *, oob: bool = False) -> str: + """Build the per-page cart header row.""" + slug = page_post.slug if page_post else "" + title = (page_post.title or "")[:160] + img_html = "" + if page_post and page_post.feature_image: + img_html = ( + f'' + ) + label_html = f'{img_html}{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"' + ' (raw! i) "All carts")', + h=call_url(ctx, "cart_url", "/"), + i='', + ) + 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, + ) + + +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, + ) + + +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, + ) + + +# --------------------------------------------------------------------------- +# Cart overview +# --------------------------------------------------------------------------- + +def _page_group_card_html(grp: Any, ctx: dict) -> str: + """Render a single page group card for cart overview.""" + post = grp.get("post") if isinstance(grp, dict) else getattr(grp, "post", None) + cart_items = grp.get("cart_items", []) if isinstance(grp, dict) else getattr(grp, "cart_items", []) + cal_entries = grp.get("calendar_entries", []) if isinstance(grp, dict) else getattr(grp, "calendar_entries", []) + tickets = grp.get("tickets", []) if isinstance(grp, dict) else getattr(grp, "tickets", []) + product_count = grp.get("product_count", 0) if isinstance(grp, dict) else getattr(grp, "product_count", 0) + calendar_count = grp.get("calendar_count", 0) if isinstance(grp, dict) else getattr(grp, "calendar_count", 0) + ticket_count = grp.get("ticket_count", 0) if isinstance(grp, dict) else getattr(grp, "ticket_count", 0) + total = grp.get("total", 0) if isinstance(grp, dict) else getattr(grp, "total", 0) + market_place = grp.get("market_place") if isinstance(grp, dict) else getattr(grp, "market_place", None) + + if not cart_items and not cal_entries and not tickets: + return "" + + # Count badges + badges = [] + if product_count > 0: + s = "s" if product_count != 1 else "" + badges.append( + f'' + f' {product_count} item{s}' + ) + if calendar_count > 0: + s = "s" if calendar_count != 1 else "" + badges.append( + f'' + f' {calendar_count} booking{s}' + ) + if ticket_count > 0: + s = "s" if ticket_count != 1 else "" + badges.append( + f'' + f' {ticket_count} ticket{s}' + ) + badges_html = '
' + "".join(badges) + '
' + + if post: + slug = post.slug if hasattr(post, "slug") else post.get("slug", "") + title = post.title if hasattr(post, "title") else post.get("title", "") + feature_image = post.feature_image if hasattr(post, "feature_image") else post.get("feature_image") + cart_url = call_url(ctx, "cart_url", f"/{slug}/") + + if feature_image: + img = f'{title}' + else: + img = '
' + + mp_name = "" + mp_sub = "" + if market_place: + mp_name = market_place.name if hasattr(market_place, "name") else market_place.get("name", "") + mp_sub = f'

{title}

' + display_title = mp_name or title + + return ( + f'' + f'
{img}' + f'

{display_title}

{mp_sub}{badges_html}
' + f'
£{total:.2f}
' + f'
View cart →
' + ) + else: + # Orphan items + badges_html_amber = badges_html.replace("bg-stone-100", "bg-amber-100") + return ( + f'
' + f'
' + f'
' + f'
' + f'

Other items

{badges_html_amber}
' + f'
£{total:.2f}
' + ) + + +def _overview_main_panel_html(page_groups: list, ctx: dict) -> str: + """Cart overview main panel.""" + if not page_groups: + return ( + '
' + '
' + '
' + '
' + '

Your cart is empty

' + ) + + cards = [_page_group_card_html(grp, ctx) for grp in page_groups] + has_items = any(c for c in cards) + if not has_items: + return ( + '
' + '
' + '
' + '
' + '

Your cart is empty

' + ) + + return '
' + "".join(cards) + '
' + + +# --------------------------------------------------------------------------- +# Page cart +# --------------------------------------------------------------------------- + +def _cart_item_html(item: Any, ctx: dict) -> str: + """Render a single product cart item.""" + from shared.browser.app.csrf import generate_csrf_token + from quart import url_for + + p = item.product if hasattr(item, "product") else item + slug = p.slug if hasattr(p, "slug") else "" + unit_price = getattr(p, "special_price", None) or getattr(p, "regular_price", None) + currency = getattr(p, "regular_price_currency", "GBP") or "GBP" + symbol = "\u00a3" if currency == "GBP" else currency + csrf = generate_csrf_token() + qty_url = url_for("cart_global.update_quantity", product_id=p.id) + prod_url = market_product_url(slug) + + if p.image: + img = f'{p.title}' + else: + img = '
No image
' + + price_html = "" + if unit_price: + price_html = f'

{symbol}{unit_price:.2f}

' + if p.special_price and p.special_price != p.regular_price: + price_html += f'

{symbol}{p.regular_price:.2f}

' + else: + price_html = '

No price

' + + deleted_html = "" + if getattr(item, "is_deleted", False): + deleted_html = ( + '

' + '' + ' This item is no longer available or price has changed

' + ) + + brand_html = f'

{p.brand}

' if getattr(p, "brand", None) else "" + + line_total_html = "" + if unit_price: + lt = unit_price * item.quantity + line_total_html = f'

Line total: {symbol}{lt:.2f}

' + + return ( + f'
' + f'
{img}
' + f'
' + f'
' + f'

' + f'{p.title}

{brand_html}{deleted_html}
' + f'
{price_html}
' + f'
' + f'
' + f'Quantity' + f'
' + f'' + f'
' + f'{item.quantity}' + f'
' + f'' + f'
' + f'
{line_total_html}
' + ) + + +def _calendar_entries_html(entries: list) -> str: + """Render calendar booking entries in cart.""" + if not entries: + return "" + items = [] + for e in entries: + name = getattr(e, "name", None) or getattr(e, "calendar_name", "") + start = e.start_at if hasattr(e, "start_at") else "" + end = getattr(e, "end_at", None) + cost = getattr(e, "cost", 0) or 0 + end_html = f" \u2013 {end}" if end else "" + items.append( + f'
  • ' + f'
    {name}
    ' + f'
    {start}{end_html}
    ' + f'
    \u00a3{cost:.2f}
  • ' + ) + return ( + '
    ' + '

    Calendar bookings

    ' + f'
      {"".join(items)}
    ' + ) + + +def _ticket_groups_html(ticket_groups: list, ctx: dict) -> str: + """Render ticket groups in cart.""" + if not ticket_groups: + return "" + from shared.browser.app.csrf import generate_csrf_token + from quart import url_for + + csrf = generate_csrf_token() + qty_url = url_for("cart_global.update_ticket_quantity") + parts = ['
    ', + '

    Event tickets

    ', + '
    '] + + for tg in ticket_groups: + name = tg.entry_name if hasattr(tg, "entry_name") else tg.get("entry_name", "") + tt_name = tg.ticket_type_name if hasattr(tg, "ticket_type_name") else tg.get("ticket_type_name", "") + price = tg.price if hasattr(tg, "price") else tg.get("price", 0) + quantity = tg.quantity if hasattr(tg, "quantity") else tg.get("quantity", 0) + line_total = tg.line_total if hasattr(tg, "line_total") else tg.get("line_total", 0) + entry_id = tg.entry_id if hasattr(tg, "entry_id") else tg.get("entry_id", "") + tt_id = tg.ticket_type_id if hasattr(tg, "ticket_type_id") else tg.get("ticket_type_id", "") + start_at = tg.entry_start_at if hasattr(tg, "entry_start_at") else tg.get("entry_start_at") + end_at = tg.entry_end_at if hasattr(tg, "entry_end_at") else tg.get("entry_end_at") + + date_str = start_at.strftime("%-d %b %Y, %H:%M") if start_at else "" + if end_at: + date_str += f" \u2013 {end_at.strftime('%-d %b %Y, %H:%M')}" + + tt_name_html = f'

    {tt_name}

    ' if tt_name else "" + tt_hidden = f'' if tt_id else "" + + parts.append( + f'
    ' + f'
    ' + f'
    ' + f'

    {name}

    {tt_name_html}' + f'

    {date_str}

    ' + f'

    \u00a3{price or 0:.2f}

    ' + f'
    ' + f'
    ' + f'Quantity' + f'
    ' + f'{tt_hidden}' + f'' + f'
    ' + f'{quantity}' + f'
    ' + f'{tt_hidden}' + f'' + f'
    ' + f'
    ' + f'

    Line total: \u00a3{line_total:.2f}

    ' + ) + + parts.append('
    ') + return "".join(parts) + + +def _cart_summary_html(ctx: dict, cart: list, cal_entries: list, tickets: list, + total_fn: Any, cal_total_fn: Any, ticket_total_fn: Any) -> str: + """Render the order summary sidebar.""" + from shared.browser.app.csrf import generate_csrf_token + from quart import g, url_for, request + from shared.infrastructure.urls import login_url + + csrf = generate_csrf_token() + product_qty = sum(ci.quantity for ci in cart) if cart else 0 + ticket_qty = len(tickets) if tickets else 0 + item_count = product_qty + ticket_qty + + product_total = total_fn(cart) or 0 + cal_total = cal_total_fn(cal_entries) or 0 + tk_total = ticket_total_fn(tickets) or 0 + grand = float(product_total) + float(cal_total) + float(tk_total) + + symbol = "\u00a3" + if cart and hasattr(cart[0], "product") and getattr(cart[0].product, "regular_price_currency", None): + cur = cart[0].product.regular_price_currency + symbol = "\u00a3" if cur == "GBP" else cur + + user = getattr(g, "user", None) + page_post = ctx.get("page_post") + + if user: + if page_post: + action = url_for("page_cart.page_checkout") + else: + action = url_for("cart_global.checkout") + from shared.utils import route_prefix + action = route_prefix() + action + checkout_html = ( + f'
    ' + f'' + f'
    ' + ) + else: + href = login_url(request.url) + checkout_html = ( + f'' + ) + + return ( + f'' + ) + + +def _page_cart_main_panel_html(ctx: dict, cart: list, cal_entries: list, + tickets: list, ticket_groups: list, + total_fn: Any, cal_total_fn: Any, + ticket_total_fn: Any) -> str: + """Page cart main panel.""" + if not cart and not cal_entries and not tickets: + return ( + '
    ' + '
    ' + '
    ' + '
    ' + '

    Your cart is 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 ( + f'
    ' + f'
    {items_html}{cal_html}{tickets_html}
    ' + f'{summary_html}
    ' + ) + + +# --------------------------------------------------------------------------- +# Orders list (same pattern as orders service) +# --------------------------------------------------------------------------- + +def _order_row_html(order: Any, detail_url: str) -> str: + """Render a single order as desktop table row + mobile card.""" + status = order.status or "pending" + sl = status.lower() + pill = ( + "border-emerald-300 bg-emerald-50 text-emerald-700" if sl == "paid" + 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" + ) + 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}" + + return ( + f'' + f'#{order.id}' + f'{created}' + f'{order.description or ""}' + f'{total}' + f'{status}' + f'View' + f'
    ' + f'
    #{order.id}' + f'{status}
    ' + f'
    {created}
    ' + f'
    {total}
    ' + f'View
    ' + ) + + +def _orders_rows_html(orders: list, page: int, total_pages: int, + url_for_fn: Any, qs_fn: Any) -> str: + """Render order rows + infinite scroll sentinel.""" + from shared.utils import route_prefix + pfx = route_prefix() + + parts = [ + _order_row_html(o, pfx + url_for_fn("orders.order.order_detail", order_id=o.id)) + for o in orders + ] + + 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}, + )) + else: + parts.append('End of results') + + return "".join(parts) + + +def _orders_main_panel_html(orders: list, rows_html: str) -> str: + """Main panel for orders list.""" + if not orders: + return ( + '
    ' + '
    ' + 'No orders yet.
    ' + ) + return ( + '
    ' + '
    ' + '' + '' + '' + '' + '' + '' + '' + '' + f'{rows_html}
    OrderCreatedDescriptionTotalStatus
    ' + ) + + +def _orders_summary_html(ctx: dict) -> str: + """Filter section for orders list.""" + return ( + '
    ' + '

    Recent orders placed via the checkout.

    ' + f'
    {search_mobile_html(ctx)}
    ' + '
    ' + ) + + +# --------------------------------------------------------------------------- +# Single order detail +# --------------------------------------------------------------------------- + +def _order_items_html(order: Any) -> str: + """Render order items list.""" + if not order or not order.items: + return "" + items = [] + for item in order.items: + prod_url = market_product_url(item.product_slug) + img = ( + f'{item.product_title or ' + if item.product_image else + '
    No image
    ' + ) + items.append( + f'
  • ' + f'
    {img}
    ' + f'
    ' + f'

    {item.product_title or "Unknown product"}

    ' + f'

    Product ID: {item.product_id}

    ' + f'

    Qty: {item.quantity}

    ' + f'

    {item.currency or order.currency or "GBP"} {item.unit_price or 0:.2f}

    ' + f'
  • ' + ) + return ( + '
    ' + '

    Items

    ' + f'
      {"".join(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, + ) + + +def _order_calendar_items_html(calendar_entries: list | None) -> str: + """Render calendar bookings for an order.""" + if not calendar_entries: + return "" + items = [] + for e in calendar_entries: + st = e.state or "" + pill = ( + "bg-emerald-100 text-emerald-800" if st == "confirmed" + else "bg-amber-100 text-amber-800" if st == "provisional" + else "bg-blue-100 text-blue-800" if st == "ordered" + else "bg-stone-100 text-stone-700" + ) + 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( + f'
  • ' + f'
    {e.name}' + f'' + f'{st.capitalize()}
    ' + f'
    {ds}
    ' + f'
    \u00a3{e.cost or 0:.2f}
  • ' + ) + return ( + '
    ' + '

    Calendar bookings in this order

    ' + f'
      {"".join(items)}
    ' + ) + + +def _order_main_html(order: Any, calendar_entries: list | None) -> str: + """Main panel for single order detail.""" + summary = _order_summary_html(order) + return f'
    {summary}{_order_items_html(order)}{_order_calendar_items_html(calendar_entries)}
    ' + + +def _order_filter_html(order: Any, list_url: str, recheck_url: str, + pay_url: str, csrf_token: str) -> str: + """Filter section for single order detail.""" + created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014" + status = order.status or "pending" + pay = ( + f'' + f'Open payment page' + ) if status != "paid" else "" + + return ( + '
    ' + f'

    Placed {created} · Status: {status}

    ' + '
    ' + f'All orders' + f'
    ' + f'
    ' + f'{pay}
    ' + ) + + +# --------------------------------------------------------------------------- +# Public API: Cart overview +# --------------------------------------------------------------------------- + +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), + ) + return full_page(ctx, header_rows_html=hdr, content_html=main) + + +async def render_overview_oob(ctx: dict, page_groups: list) -> str: + """OOB response for cart overview.""" + main = _overview_main_panel_html(page_groups, ctx) + oobs = ( + _cart_header_html(ctx, oob=True) + + root_header_html(ctx, oob=True) + ) + return oob_page(ctx, oobs_html=oobs, content_html=main) + + +# --------------------------------------------------------------------------- +# Public API: Page cart +# --------------------------------------------------------------------------- + +async def render_page_cart_page(ctx: dict, page_post: Any, + cart: list, cal_entries: list, tickets: list, + ticket_groups: list, total_fn: Any, + cal_total_fn: Any, ticket_total_fn: Any) -> str: + """Full page: page-specific cart.""" + main = _page_cart_main_panel_html(ctx, cart, cal_entries, tickets, ticket_groups, + total_fn, cal_total_fn, ticket_total_fn) + 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, + ) + return full_page(ctx, header_rows_html=hdr, content_html=main) + + +async def render_page_cart_oob(ctx: dict, page_post: Any, + cart: list, cal_entries: list, tickets: list, + ticket_groups: list, total_fn: Any, + cal_total_fn: Any, ticket_total_fn: Any) -> str: + """OOB response for page cart.""" + 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)) + + _cart_header_html(ctx, oob=True) + + root_header_html(ctx, oob=True) + ) + return oob_page(ctx, oobs_html=oobs, content_html=main) + + +# --------------------------------------------------------------------------- +# Public API: Orders list +# --------------------------------------------------------------------------- + +async def render_orders_page(ctx: dict, orders: list, page: int, + total_pages: int, search: str | None, + search_count: int, url_for_fn: Any, + qs_fn: Any) -> str: + """Full page: orders list.""" + from shared.utils import route_prefix + + ctx["search"] = search + ctx["search_count"] = search_count + list_url = route_prefix() + url_for_fn("orders.list_orders") + + rows = _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn) + 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), + ) + + return full_page(ctx, header_rows_html=hdr, + filter_html=_orders_summary_html(ctx), + aside_html=search_desktop_html(ctx), + content_html=main) + + +async def render_orders_rows(ctx: dict, orders: list, page: int, + total_pages: int, url_for_fn: Any, + qs_fn: Any) -> str: + """Pagination: just the table rows.""" + return _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn) + + +async def render_orders_oob(ctx: dict, orders: list, page: int, + total_pages: int, search: str | None, + search_count: int, url_for_fn: Any, + qs_fn: Any) -> str: + """OOB response for orders list.""" + from shared.utils import route_prefix + + ctx["search"] = search + ctx["search_count"] = search_count + list_url = route_prefix() + url_for_fn("orders.list_orders") + + rows = _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn) + main = _orders_main_panel_html(orders, rows) + + 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), + ) + + root_header_html(ctx, oob=True) + ) + + return oob_page(ctx, oobs_html=oobs, + filter_html=_orders_summary_html(ctx), + aside_html=search_desktop_html(ctx), + content_html=main) + + +# --------------------------------------------------------------------------- +# Public API: Single order detail +# --------------------------------------------------------------------------- + +async def render_order_page(ctx: dict, order: Any, + calendar_entries: list | None, + url_for_fn: Any) -> str: + """Full page: single order detail.""" + from shared.utils import route_prefix + from shared.browser.app.csrf import generate_csrf_token + + pfx = route_prefix() + detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id) + list_url = pfx + url_for_fn("orders.list_orders") + recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id) + pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id) + + main = _order_main_html(order, calendar_entries) + 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}", + ) + 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, + ) + + return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=main) + + +async def render_order_oob(ctx: dict, order: Any, + calendar_entries: list | None, + url_for_fn: Any) -> str: + """OOB response for single order detail.""" + from shared.utils import route_prefix + from shared.browser.app.csrf import generate_csrf_token + + pfx = route_prefix() + detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id) + list_url = pfx + url_for_fn("orders.list_orders") + recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id) + pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id) + + 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}", + ) + 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) + + root_header_html(ctx, oob=True) + ) + + return oob_page(ctx, oobs_html=oobs, filter_html=filt, content_html=main) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 5596647..0bd15e4 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -45,6 +45,7 @@ services: - ./blog/alembic.ini:/app/blog/alembic.ini:ro - ./blog/alembic:/app/blog/alembic:ro - ./blog/app.py:/app/app.py + - ./blog/sexp_components.py:/app/sexp_components.py - ./blog/bp:/app/bp - ./blog/services:/app/services - ./blog/templates:/app/templates @@ -82,6 +83,7 @@ services: - ./market/alembic.ini:/app/market/alembic.ini:ro - ./market/alembic:/app/market/alembic:ro - ./market/app.py:/app/app.py + - ./market/sexp_components.py:/app/sexp_components.py - ./market/bp:/app/bp - ./market/services:/app/services - ./market/templates:/app/templates @@ -118,6 +120,7 @@ services: - ./cart/alembic.ini:/app/cart/alembic.ini:ro - ./cart/alembic:/app/cart/alembic:ro - ./cart/app.py:/app/app.py + - ./cart/sexp_components.py:/app/sexp_components.py - ./cart/bp:/app/bp - ./cart/services:/app/services - ./cart/templates:/app/templates @@ -154,6 +157,7 @@ services: - ./events/alembic.ini:/app/events/alembic.ini:ro - ./events/alembic:/app/events/alembic:ro - ./events/app.py:/app/app.py + - ./events/sexp_components.py:/app/sexp_components.py - ./events/bp:/app/bp - ./events/services:/app/services - ./events/templates:/app/templates @@ -190,6 +194,7 @@ services: - ./federation/alembic.ini:/app/federation/alembic.ini:ro - ./federation/alembic:/app/federation/alembic:ro - ./federation/app.py:/app/app.py + - ./federation/sexp_components.py:/app/sexp_components.py - ./federation/bp:/app/bp - ./federation/services:/app/services - ./federation/templates:/app/templates @@ -226,6 +231,7 @@ services: - ./account/alembic.ini:/app/account/alembic.ini:ro - ./account/alembic:/app/account/alembic:ro - ./account/app.py:/app/app.py + - ./account/sexp_components.py:/app/sexp_components.py - ./account/bp:/app/bp - ./account/services:/app/services - ./account/templates:/app/templates @@ -324,6 +330,7 @@ services: - ./orders/alembic.ini:/app/orders/alembic.ini:ro - ./orders/alembic:/app/orders/alembic:ro - ./orders/app.py:/app/app.py + - ./orders/sexp_components.py:/app/sexp_components.py - ./orders/bp:/app/bp - ./orders/services:/app/services - ./orders/templates:/app/templates diff --git a/events/app.py b/events/app.py index d450a67..1f51300 100644 --- a/events/app.py +++ b/events/app.py @@ -1,6 +1,7 @@ from __future__ import annotations import path_setup # noqa: F401 # adds shared/ to sys.path +import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file from pathlib import Path from quart import g, abort, request diff --git a/events/bp/all_events/routes.py b/events/bp/all_events/routes.py index b8cc697..c3429e0 100644 --- a/events/bp/all_events/routes.py +++ b/events/bp/all_events/routes.py @@ -65,19 +65,14 @@ def register() -> Blueprint: entries, has_more, pending_tickets, page_info = await _load_entries(page) - ctx = dict( - entries=entries, - has_more=has_more, - pending_tickets=pending_tickets, - page_info=page_info, - page=page, - view=view, - ) + from shared.sexp.page import get_template_context + from sexp_components import render_all_events_page, render_all_events_oob + ctx = await get_template_context() if is_htmx_request(): - html = await render_template("_types/all_events/_main_panel.html", **ctx) + html = await render_all_events_oob(ctx, entries, has_more, pending_tickets, page_info, page, view) else: - html = await render_template("_types/all_events/index.html", **ctx) + html = await render_all_events_page(ctx, entries, has_more, pending_tickets, page_info, page, view) return await make_response(html, 200) @@ -88,15 +83,8 @@ def register() -> Blueprint: entries, has_more, pending_tickets, page_info = await _load_entries(page) - html = await render_template( - "_types/all_events/_cards.html", - entries=entries, - has_more=has_more, - pending_tickets=pending_tickets, - page_info=page_info, - page=page, - view=view, - ) + from sexp_components import render_all_events_cards + html = await render_all_events_cards(entries, has_more, pending_tickets, page_info, page, view) return await make_response(html, 200) @bp.post("/all-tickets/adjust") diff --git a/events/bp/calendar/admin/routes.py b/events/bp/calendar/admin/routes.py index 3d042ff..68d92aa 100644 --- a/events/bp/calendar/admin/routes.py +++ b/events/bp/calendar/admin/routes.py @@ -19,13 +19,14 @@ def register(): async def admin(calendar_slug: str, **kwargs): from shared.browser.app.utils.htmx import is_htmx_request - # Determine which template to use based on request type + from shared.sexp.page import get_template_context + from sexp_components import render_calendar_admin_page, render_calendar_admin_oob + + tctx = await get_template_context() if not is_htmx_request(): - # Normal browser request: full page with layout - html = await render_template("_types/calendar/admin/index.html") + html = await render_calendar_admin_page(tctx) else: - # HTMX request: main panel + OOB elements - html = await render_template("_types/calendar/admin/_oob_elements.html") + html = await render_calendar_admin_oob(tctx) return await make_response(html) diff --git a/events/bp/calendar/routes.py b/events/bp/calendar/routes.py index a07ff69..84f1b8c 100644 --- a/events/bp/calendar/routes.py +++ b/events/bp/calendar/routes.py @@ -142,47 +142,25 @@ def register(): user_entries = visible.user_entries confirmed_entries = visible.confirmed_entries - if not is_htmx_request(): - # Normal browser request: full page with layout - html = await render_template( - "_types/calendar/index.html", + from shared.sexp.page import get_template_context + from sexp_components import render_calendar_page, render_calendar_oob + + tctx = await get_template_context() + tctx.update(dict( qsession=qsession, - year=year, - month=month, - month_name=month_name, - weekday_names=weekday_names, - weeks=weeks, - prev_month=prev_month, - prev_month_year=prev_month_year, - next_month=next_month, - next_month_year=next_month_year, - prev_year=prev_year, - next_year=next_year, - user_entries=user_entries, - confirmed_entries=confirmed_entries, - month_entries=month_entries, - ) + year=year, month=month, month_name=month_name, + weekday_names=weekday_names, weeks=weeks, + prev_month=prev_month, prev_month_year=prev_month_year, + next_month=next_month, next_month_year=next_month_year, + prev_year=prev_year, next_year=next_year, + user_entries=user_entries, confirmed_entries=confirmed_entries, + month_entries=month_entries, + )) + if not is_htmx_request(): + html = await render_calendar_page(tctx) else: - - html = await render_template( - "_types/calendar/_oob_elements.html", - qsession=qsession, - year=year, - month=month, - month_name=month_name, - weekday_names=weekday_names, - weeks=weeks, - prev_month=prev_month, - prev_month_year=prev_month_year, - next_month=next_month, - next_month_year=next_month_year, - prev_year=prev_year, - next_year=next_year, - user_entries=user_entries, - confirmed_entries=confirmed_entries, - month_entries=month_entries, - ) - + html = await render_calendar_oob(tctx) + return await make_response(html) diff --git a/events/bp/calendars/routes.py b/events/bp/calendars/routes.py index f723811..989be3e 100644 --- a/events/bp/calendars/routes.py +++ b/events/bp/calendars/routes.py @@ -35,14 +35,14 @@ def register(): @bp.get("/") @cache_page(tag="calendars") async def home(**kwargs): + from shared.sexp.page import get_template_context + from sexp_components import render_calendars_page, render_calendars_oob + + ctx = await get_template_context() if not is_htmx_request(): - html = await render_template( - "_types/calendars/index.html", - ) + html = await render_calendars_page(ctx) else: - html = await render_template( - "_types/calendars/_oob_elements.html", - ) + html = await render_calendars_oob(ctx) return await make_response(html) diff --git a/events/bp/day/admin/routes.py b/events/bp/day/admin/routes.py index b14cfe7..a895f7b 100644 --- a/events/bp/day/admin/routes.py +++ b/events/bp/day/admin/routes.py @@ -17,12 +17,14 @@ def register(): async def admin(year: int, month: int, day: int, **kwargs): from shared.browser.app.utils.htmx import is_htmx_request - # Determine which template to use based on request type + from shared.sexp.page import get_template_context + from sexp_components import render_day_admin_page, render_day_admin_oob + + tctx = await get_template_context() if not is_htmx_request(): - # Normal browser request: full page with layout - html = await render_template("_types/day/admin/index.html") + html = await render_day_admin_page(tctx) else: - html = await render_template("_types/day/admin/_oob_elements.html") + html = await render_day_admin_oob(tctx) return await make_response(html) return bp diff --git a/events/bp/day/routes.py b/events/bp/day/routes.py index 7fbe550..6e74fe7 100644 --- a/events/bp/day/routes.py +++ b/events/bp/day/routes.py @@ -120,16 +120,14 @@ def register(): - all confirmed + provisional + ordered entries for that day (all users) - pending only for current user/session """ + from shared.sexp.page import get_template_context + from sexp_components import render_day_page, render_day_oob + + tctx = await get_template_context() if not is_htmx_request(): - # Normal browser request: full page with layout - html = await render_template( - "_types/day/index.html", - ) + html = await render_day_page(tctx) else: - - html = await render_template( - "_types/day/_oob_elements.html", - ) + html = await render_day_oob(tctx) return await make_response(html) @bp.get("/w//") diff --git a/events/bp/markets/routes.py b/events/bp/markets/routes.py index bac523f..385a5b6 100644 --- a/events/bp/markets/routes.py +++ b/events/bp/markets/routes.py @@ -23,10 +23,14 @@ def register(): @bp.get("/") async def home(**kwargs): + from shared.sexp.page import get_template_context + from sexp_components import render_markets_page, render_markets_oob + + ctx = await get_template_context() if not is_htmx_request(): - html = await render_template("_types/markets/index.html") + html = await render_markets_page(ctx) else: - html = await render_template("_types/markets/_oob_elements.html") + html = await render_markets_oob(ctx) return await make_response(html) @bp.post("/new/") diff --git a/events/bp/page/routes.py b/events/bp/page/routes.py index 7944df3..f711801 100644 --- a/events/bp/page/routes.py +++ b/events/bp/page/routes.py @@ -45,19 +45,14 @@ def register() -> Blueprint: entries, has_more, pending_tickets = await _load_entries(post["id"], page) - ctx = dict( - entries=entries, - has_more=has_more, - pending_tickets=pending_tickets, - page_info={}, - page=page, - view=view, - ) + from shared.sexp.page import get_template_context + from sexp_components import render_page_summary_page, render_page_summary_oob + ctx = await get_template_context() if is_htmx_request(): - html = await render_template("_types/page_summary/_main_panel.html", **ctx) + html = await render_page_summary_oob(ctx, entries, has_more, pending_tickets, {}, page, view) else: - html = await render_template("_types/page_summary/index.html", **ctx) + html = await render_page_summary_page(ctx, entries, has_more, pending_tickets, {}, page, view) return await make_response(html, 200) @@ -69,15 +64,8 @@ def register() -> Blueprint: entries, has_more, pending_tickets = await _load_entries(post["id"], page) - html = await render_template( - "_types/page_summary/_cards.html", - entries=entries, - has_more=has_more, - pending_tickets=pending_tickets, - page_info={}, - page=page, - view=view, - ) + from sexp_components import render_page_summary_cards + html = await render_page_summary_cards(entries, has_more, pending_tickets, {}, page, view, post) return await make_response(html, 200) @bp.post("/tickets/adjust") diff --git a/events/bp/payments/routes.py b/events/bp/payments/routes.py index 5957cef..5fb6e09 100644 --- a/events/bp/payments/routes.py +++ b/events/bp/payments/routes.py @@ -42,11 +42,17 @@ def register(): @bp.get("/") @require_admin async def home(**kwargs): - ctx = await _load_payment_ctx() + pay_ctx = await _load_payment_ctx() + + from shared.sexp.page import get_template_context + from sexp_components import render_payments_page, render_payments_oob + + ctx = await get_template_context() + ctx.update(pay_ctx) if not is_htmx_request(): - html = await render_template("_types/payments/index.html", **ctx) + html = await render_payments_page(ctx) else: - html = await render_template("_types/payments/_oob_elements.html", **ctx) + html = await render_payments_oob(ctx) return await make_response(html) @bp.put("/") diff --git a/events/bp/ticket_admin/routes.py b/events/bp/ticket_admin/routes.py index 3168a29..945ea6c 100644 --- a/events/bp/ticket_admin/routes.py +++ b/events/bp/ticket_admin/routes.py @@ -70,18 +70,14 @@ def register() -> Blueprint: "reserved": reserved or 0, } + from shared.sexp.page import get_template_context + from sexp_components import render_ticket_admin_page, render_ticket_admin_oob + + ctx = await get_template_context() if not is_htmx_request(): - html = await render_template( - "_types/ticket_admin/index.html", - tickets=tickets, - stats=stats, - ) + html = await render_ticket_admin_page(ctx, tickets, stats) else: - html = await render_template( - "_types/ticket_admin/_main_panel.html", - tickets=tickets, - stats=stats, - ) + html = await render_ticket_admin_oob(ctx, tickets, stats) return await make_response(html, 200) diff --git a/events/bp/tickets/routes.py b/events/bp/tickets/routes.py index a7497b9..2f1a9ce 100644 --- a/events/bp/tickets/routes.py +++ b/events/bp/tickets/routes.py @@ -50,16 +50,14 @@ def register() -> Blueprint: session_id=ident["session_id"], ) + from shared.sexp.page import get_template_context + from sexp_components import render_tickets_page, render_tickets_oob + + ctx = await get_template_context() if not is_htmx_request(): - html = await render_template( - "_types/tickets/index.html", - tickets=tickets, - ) + html = await render_tickets_page(ctx, tickets) else: - html = await render_template( - "_types/tickets/_main_panel.html", - tickets=tickets, - ) + html = await render_tickets_oob(ctx, tickets) return await make_response(html, 200) @@ -83,16 +81,14 @@ def register() -> Blueprint: else: return await make_response("Ticket not found", 404) + from shared.sexp.page import get_template_context + from sexp_components import render_ticket_detail_page, render_ticket_detail_oob + + ctx = await get_template_context() if not is_htmx_request(): - html = await render_template( - "_types/tickets/detail.html", - ticket=ticket, - ) + html = await render_ticket_detail_page(ctx, ticket) else: - html = await render_template( - "_types/tickets/_detail_panel.html", - ticket=ticket, - ) + html = await render_ticket_detail_oob(ctx, ticket) return await make_response(html, 200) diff --git a/events/sexp_components.py b/events/sexp_components.py new file mode 100644 index 0000000..9a0105c --- /dev/null +++ b/events/sexp_components.py @@ -0,0 +1,1872 @@ +""" +Events service s-expression page components. + +Renders all events, page summary, calendars, calendar month, day, day admin, +calendar admin, tickets, ticket admin, markets, and payments pages. +Called from route handlers in place of ``render_template()``. +""" +from __future__ import annotations + +from typing import Any +from markupsafe import escape + +from shared.sexp.jinja_bridge import sexp +from shared.sexp.helpers import ( + call_url, get_asset_url, root_header_html, + search_mobile_html, search_desktop_html, + full_page, oob_page, +) + + +# --------------------------------------------------------------------------- +# OOB header helper (same pattern as market) +# --------------------------------------------------------------------------- + +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 ( + f'
    ' + f'
    {row_html}' + f'
    ' + ) + + +# --------------------------------------------------------------------------- +# Post header helpers (mirrors events/templates/_types/post/header/_header.html) +# --------------------------------------------------------------------------- + +def _post_header_html(ctx: dict, *, oob: bool = False) -> str: + """Build the post-level header row.""" + post = ctx.get("post") or {} + slug = post.get("slug", "") + title = (post.get("title") or "")[:160] + feature_image = post.get("feature_image") + + label_parts = [] + if feature_image: + label_parts.append( + f'' + ) + label_parts.append(f"{escape(title)}") + label_html = "".join(label_parts) + + 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( + f'' + f'' + f'{page_cart_count}' + ) + + # Post nav: calendar links + admin + nav_parts.append(_post_nav_html(ctx)) + + 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, + ) + + +def _post_nav_html(ctx: dict) -> str: + """Post desktop nav: calendar links + admin gear.""" + from quart import url_for + + calendars = ctx.get("calendars") or [] + rights = ctx.get("rights") or {} + is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False) + hx_select = ctx.get("hx_select_search", "#main-panel") + select_colours = ctx.get("select_colours", "") + post = ctx.get("post") or {} + slug = post.get("slug", "") + + parts = [] + for cal in calendars: + cal_slug = getattr(cal, "slug", "") if hasattr(cal, "slug") else cal.get("slug", "") + cal_name = getattr(cal, "name", "") if hasattr(cal, "name") else cal.get("name", "") + href = url_for("calendars.calendar.get", calendar_slug=cal_slug) + parts.append(sexp( + '(~nav-link :href h :icon "fa fa-calendar" :label l :select-colours sc)', + h=href, + l=cal_name, + sc=select_colours, + )) + if is_admin: + admin_href = call_url(ctx, "blog_url", f"/{slug}/admin/") + parts.append( + f'' + f'' + ) + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Post admin header +# --------------------------------------------------------------------------- + +def _post_admin_header_html(ctx: dict, *, oob: bool = False) -> str: + """Build the post-admin-level header row.""" + post = ctx.get("post") or {} + slug = post.get("slug", "") + link_href = call_url(ctx, "blog_url", f"/{slug}/admin/") + + return sexp( + '(~menu-row :id "post-admin-row" :level 2' + ' :link-href lh :link-label "admin" :icon "fa fa-cog"' + ' :child-id "post-admin-header-child" :oob oob)', + lh=link_href, + oob=oob, + ) + + +# --------------------------------------------------------------------------- +# Calendars header +# --------------------------------------------------------------------------- + +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='
    Calendars
    ', + oob=oob, + ) + + +# --------------------------------------------------------------------------- +# Calendar header +# --------------------------------------------------------------------------- + +def _calendar_header_html(ctx: dict, *, oob: bool = False) -> str: + """Build a single calendar's header row.""" + from quart import url_for + calendar = ctx.get("calendar") + if not calendar: + return "" + cal_slug = getattr(calendar, "slug", "") + cal_name = getattr(calendar, "name", "") + cal_desc = getattr(calendar, "description", "") or "" + + link_href = url_for("calendars.calendar.get", calendar_slug=cal_slug) + label_html = ( + '
    ' + '
    ' + f'' + f'
    {escape(cal_name)}
    ' + '
    ' + f'
    {escape(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, + ) + + +def _calendar_nav_html(ctx: dict) -> str: + """Calendar desktop nav: Slots + admin link.""" + from quart import url_for + calendar = ctx.get("calendar") + if not calendar: + return "" + cal_slug = getattr(calendar, "slug", "") + rights = ctx.get("rights") or {} + is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False) + select_colours = ctx.get("select_colours", "") + + parts = [] + slots_href = url_for("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, + )) + if is_admin: + admin_href = url_for("calendars.calendar.admin.admin", calendar_slug=cal_slug) + parts.append( + f'' + f'' + ) + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Day header +# --------------------------------------------------------------------------- + +def _day_header_html(ctx: dict, *, oob: bool = False) -> str: + """Build day detail header row.""" + from quart import url_for + calendar = ctx.get("calendar") + if not calendar: + return "" + cal_slug = getattr(calendar, "slug", "") + day_date = ctx.get("day_date") + if not day_date: + return "" + + link_href = url_for( + "calendars.calendar.day.show_day", + calendar_slug=cal_slug, + year=day_date.year, + month=day_date.month, + day=day_date.day, + ) + label_html = ( + '
    ' + f'' + f' {escape(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, + ) + + +def _day_nav_html(ctx: dict) -> str: + """Day desktop nav: confirmed entries scrolling menu + admin link.""" + from quart import url_for + calendar = ctx.get("calendar") + if not calendar: + return "" + cal_slug = getattr(calendar, "slug", "") + day_date = ctx.get("day_date") + confirmed_entries = ctx.get("confirmed_entries") or [] + rights = ctx.get("rights") or {} + is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False) + + parts = [] + # Confirmed entries nav (scrolling menu) + if confirmed_entries: + parts.append( + '
    ' + '
    ' + ) + for entry in confirmed_entries: + href = url_for( + "calendars.calendar.day.calendar_entries.calendar_entry.get", + calendar_slug=cal_slug, + year=day_date.year, + month=day_date.month, + day=day_date.day, + entry_id=entry.id, + ) + name = escape(entry.name) + 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 "" + parts.append( + f'' + f'
    ' + f'
    {name}
    ' + f'
    {start}{end}
    ' + f'
    ' + ) + parts.append('
    ') + + if is_admin and day_date: + admin_href = url_for( + "calendars.calendar.day.admin.admin", + calendar_slug=cal_slug, + year=day_date.year, + month=day_date.month, + day=day_date.day, + ) + parts.append( + f'' + f'' + ) + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Day admin header +# --------------------------------------------------------------------------- + +def _day_admin_header_html(ctx: dict, *, oob: bool = False) -> str: + """Build day admin header row.""" + from quart import url_for + calendar = ctx.get("calendar") + if not calendar: + return "" + cal_slug = getattr(calendar, "slug", "") + day_date = ctx.get("day_date") + if not day_date: + return "" + + link_href = url_for( + "calendars.calendar.day.admin.admin", + calendar_slug=cal_slug, + year=day_date.year, + 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, + ) + + +# --------------------------------------------------------------------------- +# Calendar admin header +# --------------------------------------------------------------------------- + +def _calendar_admin_header_html(ctx: dict, *, oob: bool = False) -> str: + """Build calendar admin header row.""" + return sexp( + '(~menu-row :id "calendar-admin-row" :level 4' + ' :link-label "admin" :icon "fa fa-cog"' + ' :child-id "calendar-admin-header-child" :oob oob)', + oob=oob, + ) + + +# --------------------------------------------------------------------------- +# Markets header +# --------------------------------------------------------------------------- + +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='
    Markets
    ', + oob=oob, + ) + + +# --------------------------------------------------------------------------- +# Payments header +# --------------------------------------------------------------------------- + +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='
    Payments
    ', + oob=oob, + ) + + +# --------------------------------------------------------------------------- +# Calendars main panel +# --------------------------------------------------------------------------- + +def _calendars_main_panel_html(ctx: dict) -> str: + """Render the calendars list + create form panel.""" + from quart import url_for + rights = ctx.get("rights") or {} + is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False) + has_access = ctx.get("has_access") + can_create = has_access("calendars.create_calendar") if callable(has_access) else is_admin + csrf_token = ctx.get("csrf_token") + csrf = csrf_token() if callable(csrf_token) else (csrf_token or "") + + calendars = ctx.get("calendars") or [] + hx_select = ctx.get("hx_select_search", "#main-panel") + + parts = ['
    '] + if can_create: + create_url = url_for("calendars.create_calendar") + parts.append( + '
    ' + f'
    """ + f'' + '
    ' + '
    ' + '
    ' + ) + + parts.append('
    ') + parts.append(_calendars_list_html(ctx, calendars)) + parts.append('
    ') + return "".join(parts) + + +def _calendars_list_html(ctx: dict, calendars: list) -> str: + """Render the calendars list items.""" + from quart import url_for + from shared.utils import route_prefix + hx_select = ctx.get("hx_select_search", "#main-panel") + csrf_token = ctx.get("csrf_token") + csrf = csrf_token() if callable(csrf_token) else (csrf_token or "") + prefix = route_prefix() + + if not calendars: + return '

    No calendars yet. Create one above.

    ' + + parts = [] + for cal in calendars: + cal_slug = getattr(cal, "slug", "") + cal_name = getattr(cal, "name", "") + href = prefix + url_for("calendars.calendar.get", calendar_slug=cal_slug) + del_url = url_for("calendars.calendar.delete", calendar_slug=cal_slug) + parts.append( + f'' + ) + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Calendar month grid +# --------------------------------------------------------------------------- + +def _calendar_main_panel_html(ctx: dict) -> str: + """Render the calendar month grid.""" + from quart import url_for + from quart import session as qsession + + calendar = ctx.get("calendar") + if not calendar: + return "" + cal_slug = getattr(calendar, "slug", "") + hx_select = ctx.get("hx_select_search", "#main-panel") + styles = ctx.get("styles") or {} + pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "") + + year = ctx.get("year", 2024) + month = ctx.get("month", 1) + month_name = ctx.get("month_name", "") + weekday_names = ctx.get("weekday_names", []) + weeks = ctx.get("weeks", []) + prev_month = ctx.get("prev_month", 1) + prev_month_year = ctx.get("prev_month_year", year) + next_month = ctx.get("next_month", 1) + next_month_year = ctx.get("next_month_year", year) + prev_year = ctx.get("prev_year", year - 1) + next_year = ctx.get("next_year", year + 1) + month_entries = ctx.get("month_entries") or [] + user = ctx.get("user") + qs = qsession if "qsession" not in ctx else ctx["qsession"] + + def nav_link(y, m): + href = url_for("calendars.calendar.get", calendar_slug=cal_slug, year=y, month=m) + return href + + # Month navigation header + parts = ['
    '] + parts.append('
    ') + parts.append('
    ') + + # Calendar grid + parts.append('
    ') + # Weekday headers + parts.append('') + + # Weeks grid + parts.append('
    ') + for week in weeks: + for day_cell in week: + in_month = getattr(day_cell, "in_month", True) + is_today = getattr(day_cell, "is_today", False) + day_date = getattr(day_cell, "date", None) + + cell_cls = "min-h-20 sm:min-h-24 bg-white px-3 py-2 text-xs" + if not in_month: + cell_cls += " bg-stone-50 text-stone-400" + if is_today: + cell_cls += " ring-2 ring-blue-500 z-10 relative" + + parts.append(f'
    ') + parts.append('
    ') + parts.append(f'{day_date.strftime("%a")}') + + # Clickable day number + if day_date: + day_href = url_for( + "calendars.calendar.day.show_day", + calendar_slug=cal_slug, + year=day_date.year, + month=day_date.month, + day=day_date.day, + ) + parts.append( + f'{day_date.day}' + ) + parts.append('
    ') + + # Entries for this day + parts.append('
    ') + if day_date: + for e in month_entries: + if e.start_at.date() == day_date: + is_mine = ( + (user and e.user_id == user.id) + or (not user and e.session_id == qs.get("calendar_sid")) + ) + if e.state == "confirmed": + bg_cls = "bg-emerald-200 text-emerald-900" if is_mine else "bg-emerald-100 text-emerald-800" + else: + bg_cls = "bg-sky-100 text-sky-800" if is_mine else "bg-stone-100 text-stone-700" + + state_label = (e.state or "pending").replace("_", " ") + parts.append( + f'
    ' + f'{escape(e.name)}' + f'{state_label}' + f'
    ' + ) + parts.append('
    ') + + parts.append('
    ') + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Day main panel +# --------------------------------------------------------------------------- + +def _day_main_panel_html(ctx: dict) -> str: + """Render the day entries table + add button.""" + from quart import url_for + + calendar = ctx.get("calendar") + if not calendar: + return "" + cal_slug = getattr(calendar, "slug", "") + day_entries = ctx.get("day_entries") or [] + day = ctx.get("day") + month = ctx.get("month") + year = ctx.get("year") + hx_select = ctx.get("hx_select_search", "#main-panel") + styles = ctx.get("styles") or {} + list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "") + pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "") + tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "") + pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "") + + parts = [f'
    '] + parts.append( + '' + '' + '' + '' + '' + '' + '' + '' + ) + + if day_entries: + for entry in day_entries: + parts.append(_day_row_html(ctx, entry)) + else: + parts.append('') + + parts.append('
    NameSlot/TimeStateCostTicketsActions
    No entries yet.
    ') + + # Add entry button + add_url = url_for( + "calendars.calendar.day.calendar_entries.add_form", + calendar_slug=cal_slug, + day=day, month=month, year=year, + ) + parts.append( + f'
    ' + f'
    ' + ) + parts.append('
    ') + return "".join(parts) + + +def _day_row_html(ctx: dict, entry) -> str: + """Render a single day table row.""" + from quart import url_for + calendar = ctx.get("calendar") + cal_slug = getattr(calendar, "slug", "") + day = ctx.get("day") + month = ctx.get("month") + year = ctx.get("year") + hx_select = ctx.get("hx_select_search", "#main-panel") + styles = ctx.get("styles") or {} + pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "") + tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "") + + entry_href = url_for( + "calendars.calendar.day.calendar_entries.calendar_entry.get", + calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=entry.id, + ) + + # Name + name_html = ( + f'' + ) + + # Slot/Time + slot = getattr(entry, "slot", None) + if slot: + 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" → {slot.time_end.strftime('%H:%M')}" if slot.time_end else "" + slot_html = ( + f'
    ' + f'' + f'{escape(slot.name)}' + f'({time_start}{time_end})' + f'
    ' + ) + else: + 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 "" + slot_html = f'
    {start}{end}
    ' + + # State + state = getattr(entry, "state", "pending") or "pending" + state_html = _entry_state_badge_html(state) + state_td = f'
    {state_html}
    ' + + # Cost + cost = getattr(entry, "cost", None) + cost_str = f"£{cost:.2f}" if cost is not None else "£0.00" + cost_td = f'{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 = ( + f'
    ' + f'
    £{tp:.2f}
    ' + f'
    {tc_str}
    ' + ) + else: + tickets_td = 'No tickets' + + # Actions (entry options) - keep simple, just link to entry + actions_td = f'' + + return f'{name_html}{slot_html}{state_td}{cost_td}{tickets_td}{actions_td}' + + +def _entry_state_badge_html(state: str) -> str: + """Render an entry state badge.""" + state_classes = { + "confirmed": "bg-emerald-100 text-emerald-800", + "provisional": "bg-amber-100 text-amber-800", + "ordered": "bg-sky-100 text-sky-800", + "pending": "bg-stone-100 text-stone-700", + "declined": "bg-red-100 text-red-800", + } + cls = state_classes.get(state, "bg-stone-100 text-stone-700") + label = state.replace("_", " ").capitalize() + return f'{label}' + + +# --------------------------------------------------------------------------- +# Day admin main panel +# --------------------------------------------------------------------------- + +def _day_admin_main_panel_html(ctx: dict) -> str: + """Render day admin panel (placeholder nav).""" + return '
    Admin options
    ' + + +# --------------------------------------------------------------------------- +# Calendar admin main panel +# --------------------------------------------------------------------------- + +def _calendar_admin_main_panel_html(ctx: dict) -> str: + """Render calendar admin config panel with description editor.""" + from quart import url_for + calendar = ctx.get("calendar") + if not calendar: + return "" + csrf_token = ctx.get("csrf_token") + csrf = csrf_token() if callable(csrf_token) else (csrf_token or "") + cal_slug = getattr(calendar, "slug", "") + desc = getattr(calendar, "description", "") or "" + hx_select = ctx.get("hx_select_search", "#main-panel") + + 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) + + parts = ['
    '] + parts.append('

    Calendar configuration

    ') + parts.append('
    ') + parts.append(f'
    ') + parts.append(description_html) + parts.append('
    ') + + # Hidden form for direct PUT + parts.append( + f'
    ' + f'' + '
    ' + f'
    {escape(desc)}
    ' + f'' + '
    ' + ) + parts.append('

    ') + return "".join(parts) + + +def _calendar_description_display_html(calendar, edit_url: str) -> str: + """Render calendar description display with edit button.""" + desc = getattr(calendar, "description", "") or "" + if desc: + desc_html = f'

    {escape(desc)}

    ' + else: + desc_html = '

    No description yet.

    ' + return ( + f'
    {desc_html}' + f'
    ' + ) + + +# --------------------------------------------------------------------------- +# Markets main panel +# --------------------------------------------------------------------------- + +def _markets_main_panel_html(ctx: dict) -> str: + """Render markets list + create form panel.""" + from quart import url_for + rights = ctx.get("rights") or {} + is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False) + has_access = ctx.get("has_access") + can_create = has_access("markets.create_market") if callable(has_access) else is_admin + csrf_token = ctx.get("csrf_token") + csrf = csrf_token() if callable(csrf_token) else (csrf_token or "") + markets = ctx.get("markets") or [] + post = ctx.get("post") or {} + + parts = ['
    '] + if can_create: + create_url = url_for("markets.create_market") + parts.append( + '
    ' + f'
    """ + f'' + '
    ' + '
    ' + '
    ' + ) + parts.append('
    ') + parts.append(_markets_list_html(ctx, markets)) + parts.append('
    ') + return "".join(parts) + + +def _markets_list_html(ctx: dict, markets: list) -> str: + """Render markets list items.""" + from quart import url_for + csrf_token = ctx.get("csrf_token") + csrf = csrf_token() if callable(csrf_token) else (csrf_token or "") + post = ctx.get("post") or {} + slug = post.get("slug", "") + + if not markets: + return '

    No markets yet. Create one above.

    ' + + parts = [] + for m in markets: + m_slug = getattr(m, "slug", "") if hasattr(m, "slug") else m.get("slug", "") + m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "") + market_href = call_url(ctx, "market_url", f"/{slug}/{m_slug}/") + del_url = url_for("markets.delete_market", market_slug=m_slug) + parts.append( + f'' + ) + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Payments main panel +# --------------------------------------------------------------------------- + +def _payments_main_panel_html(ctx: dict) -> str: + """Render SumUp payment config form.""" + from quart import url_for + csrf_token = ctx.get("csrf_token") + csrf = csrf_token() if callable(csrf_token) else (csrf_token or "") + sumup_configured = ctx.get("sumup_configured", False) + merchant_code = ctx.get("sumup_merchant_code", "") + checkout_prefix = ctx.get("sumup_checkout_prefix", "") + update_url = url_for("payments.update_sumup") + + placeholder = "--------" if sumup_configured else "sup_sk_..." + key_note = '

    Key is set. Leave blank to keep current key.

    ' if sumup_configured else "" + connected = ('' + ' Connected') if sumup_configured else "" + + return ( + '
    ' + '
    ' + '

    ' + ' SumUp Payment

    ' + '

    Configure per-page SumUp credentials. Leave blank to use the global merchant account.

    ' + f'
    ' + f'' + '
    ' + f'
    ' + '
    ' + f'' + f'{key_note}
    ' + '
    ' + f'
    ' + '{connected}
    ' + ) + + +# --------------------------------------------------------------------------- +# Ticket state badge helper +# --------------------------------------------------------------------------- + +def _ticket_state_badge_html(state: str) -> str: + """Render a ticket state badge.""" + cls_map = { + "confirmed": "bg-emerald-100 text-emerald-800", + "checked_in": "bg-blue-100 text-blue-800", + "reserved": "bg-amber-100 text-amber-800", + "cancelled": "bg-red-100 text-red-800", + } + cls = cls_map.get(state, "bg-stone-100 text-stone-700") + label = (state or "").replace("_", " ").capitalize() + return f'{label}' + + +# --------------------------------------------------------------------------- +# Tickets main panel (my tickets) +# --------------------------------------------------------------------------- + +def _tickets_main_panel_html(ctx: dict, tickets: list) -> str: + """Render my tickets list.""" + from quart import url_for + + parts = [f'
    '] + parts.append('

    My Tickets

    ') + + if tickets: + parts.append('') + else: + parts.append( + '
    ' + '' + '

    No tickets yet

    ' + '

    Tickets will appear here after you purchase them.

    ' + ) + parts.append('
    ') + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Ticket detail panel +# --------------------------------------------------------------------------- + +def _ticket_detail_panel_html(ctx: dict, ticket) -> str: + """Render a single ticket detail with QR code.""" + from quart import url_for + + entry = getattr(ticket, "entry", None) + tt = getattr(ticket, "ticket_type", None) + state = getattr(ticket, "state", "") + code = ticket.code + + # Background color for header + bg_map = {"confirmed": "bg-emerald-50", "checked_in": "bg-blue-50", "reserved": "bg-amber-50"} + header_bg = bg_map.get(state, "bg-stone-50") + + entry_name = entry.name if entry else "Ticket" + back_href = url_for("tickets.my_tickets") + + parts = [f'
    '] + parts.append( + f'' + ' Back to my tickets' + ) + + parts.append('
    ') + # Header + parts.append(f'
    ') + parts.append(f'

    {escape(entry_name)}

    ') + parts.append(_ticket_state_badge_html(state).replace('px-2 py-0.5 text-xs', 'px-3 py-1 text-sm')) + parts.append('
    ') + if tt: + parts.append(f'
    {escape(tt.name)}
    ') + parts.append('
    ') + + # QR code + parts.append( + f'
    ' + f'
    ' + f'

    {code}

    ' + ) + + # Event details + parts.append('
    ') + if entry: + parts.append( + '
    ' + f'
    {entry.start_at.strftime("%A, %B %d, %Y")}
    ' + f'
    {entry.start_at.strftime("%H:%M")}' + ) + if entry.end_at: + parts.append(f' – {entry.end_at.strftime("%H:%M")}') + parts.append('
    ') + + cal = getattr(entry, "calendar", None) + if cal: + parts.append( + '
    ' + f'
    {escape(cal.name)}
    ' + ) + + if tt and getattr(tt, "cost", None): + parts.append( + '
    ' + f'
    {escape(tt.name)} — £{tt.cost:.2f}
    ' + ) + + checked_in_at = getattr(ticket, "checked_in_at", None) + if checked_in_at: + parts.append( + '
    ' + f'
    Checked in: {checked_in_at.strftime("%B %d, %Y at %H:%M")}
    ' + ) + parts.append('
    ') + + # QR code script + parts.append( + '' + '' + ) + parts.append('
    ') + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Ticket admin main panel +# --------------------------------------------------------------------------- + +def _ticket_admin_main_panel_html(ctx: dict, tickets: list, stats: dict) -> str: + """Render ticket admin dashboard.""" + from quart import url_for + csrf_token = ctx.get("csrf_token") + csrf = csrf_token() if callable(csrf_token) else (csrf_token or "") + lookup_url = url_for("ticket_admin.lookup") + + parts = [f'
    '] + parts.append('

    Ticket Admin

    ') + + # Stats + parts.append('
    ') + for label, key, border, bg, text_cls in [ + ("Total", "total", "border-stone-200", "", "text-stone-900"), + ("Confirmed", "confirmed", "border-emerald-200", "bg-emerald-50", "text-emerald-700"), + ("Checked In", "checked_in", "border-blue-200", "bg-blue-50", "text-blue-700"), + ("Reserved", "reserved", "border-amber-200", "bg-amber-50", "text-amber-700"), + ]: + val = stats.get(key, 0) + lbl_cls = text_cls.replace("700", "600").replace("900", "500") if "stone" not in text_cls else "text-stone-500" + parts.append( + f'
    ' + f'
    {val}
    ' + f'
    {label}
    ' + ) + parts.append('
    ') + + # Scanner + parts.append( + '
    ' + '

    Scan / Look Up Ticket

    ' + '
    ' + f'' + '
    ' + '
    ' + 'Enter a ticket code to look it up
    ' + ) + + # Recent tickets table + parts.append('
    ') + parts.append('

    Recent Tickets

    ') + + if tickets: + parts.append('
    ') + for col in ["Code", "Event", "Type", "State", "Actions"]: + parts.append(f'') + parts.append('') + + for ticket in tickets: + entry = getattr(ticket, "entry", None) + tt = getattr(ticket, "ticket_type", None) + state = getattr(ticket, "state", "") + code = ticket.code + + parts.append(f'') + parts.append(f'') + parts.append(f'') + parts.append(f'') + parts.append(f'') + + # Actions + parts.append('') + + parts.append('
    {col}
    {code[:12]}...
    {escape(entry.name) if entry else "—"}
    ') + if entry and entry.start_at: + parts.append(f'
    {entry.start_at.strftime("%d %b %Y, %H:%M")}
    ') + parts.append('
    {escape(tt.name) if tt else "—"}{_ticket_state_badge_html(state)}') + if state in ("confirmed", "reserved"): + checkin_url = url_for("ticket_admin.do_checkin", code=code) + parts.append( + f'
    ' + f'' + '
    ' + ) + 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 "" + parts.append(f' {t_str}') + parts.append('
    ') + else: + parts.append('
    No tickets yet
    ') + + parts.append('
    ') + return "".join(parts) + + +# --------------------------------------------------------------------------- +# All events / page summary entry cards +# --------------------------------------------------------------------------- + +def _entry_card_html(entry, page_info: dict, pending_tickets: dict, + ticket_url: str, events_url_fn, *, is_page_scoped: bool = False, + post: dict | None = None) -> str: + """Render a list card for one event entry.""" + pi = page_info.get(getattr(entry, "calendar_container_id", 0), {}) + if is_page_scoped and post: + page_slug = pi.get("slug", post.get("slug", "")) + else: + page_slug = pi.get("slug", "") + page_title = pi.get("title") + + day_href = "" + if page_slug: + day_href = events_url_fn(f"/{page_slug}/calendars/{entry.calendar_slug}/day/{entry.start_at.strftime('%Y/%-m/%-d')}/") + entry_href = f"{day_href}entries/{entry.id}/" if day_href else "" + + parts = ['
    '] + parts.append('
    ') + parts.append('
    ') + + if entry_href: + parts.append(f'') + parts.append(f'

    {escape(entry.name)}

    ') + if entry_href: + parts.append('
    ') + + # Badges + parts.append('
    ') + if page_title and (not is_page_scoped or page_title != (post or {}).get("title")): + page_href = events_url_fn(f"/{page_slug}/") + parts.append( + f'' + f'{escape(page_title)}' + ) + cal_name = getattr(entry, "calendar_name", "") + if cal_name: + parts.append(f'{escape(cal_name)}') + parts.append('
    ') + + # Time + parts.append('
    ') + if day_href and not is_page_scoped: + parts.append(f'{entry.start_at.strftime("%a %-d %b")} · ') + elif not is_page_scoped: + parts.append(f'{entry.start_at.strftime("%a %-d %b")} · ') + parts.append(entry.start_at.strftime("%H:%M")) + if entry.end_at: + parts.append(f' – {entry.end_at.strftime("%H:%M")}') + parts.append('
    ') + + cost = getattr(entry, "cost", None) + if cost: + parts.append(f'
    £{cost:.2f}
    ') + parts.append('
    ') + + # Ticket widget + tp = getattr(entry, "ticket_price", None) + if tp is not None: + qty = pending_tickets.get(entry.id, 0) + parts.append('
    ') + parts.append(_ticket_widget_html(entry, qty, ticket_url, ctx={})) + parts.append('
    ') + parts.append('
    ') + return "".join(parts) + + +def _entry_card_tile_html(entry, page_info: dict, pending_tickets: dict, + ticket_url: str, events_url_fn, *, is_page_scoped: bool = False, + post: dict | None = None) -> str: + """Render a tile card for one event entry.""" + pi = page_info.get(getattr(entry, "calendar_container_id", 0), {}) + if is_page_scoped and post: + page_slug = pi.get("slug", post.get("slug", "")) + else: + page_slug = pi.get("slug", "") + page_title = pi.get("title") + + day_href = "" + if page_slug: + day_href = events_url_fn(f"/{page_slug}/calendars/{entry.calendar_slug}/day/{entry.start_at.strftime('%Y/%-m/%-d')}/") + entry_href = f"{day_href}entries/{entry.id}/" if day_href else "" + + parts = ['
    '] + if entry_href: + parts.append(f'') + parts.append(f'

    {escape(entry.name)}

    ') + if entry_href: + parts.append('
    ') + + parts.append('
    ') + if page_title and (not is_page_scoped or page_title != (post or {}).get("title")): + page_href = events_url_fn(f"/{page_slug}/") + parts.append( + f'' + f'{escape(page_title)}' + ) + cal_name = getattr(entry, "calendar_name", "") + if cal_name: + parts.append(f'{escape(cal_name)}') + parts.append('
    ') + + parts.append('
    ') + if day_href: + parts.append(f'{entry.start_at.strftime("%a %-d %b")}') + else: + parts.append(entry.start_at.strftime("%a %-d %b")) + parts.append(f' · {entry.start_at.strftime("%H:%M")}') + if entry.end_at: + parts.append(f' – {entry.end_at.strftime("%H:%M")}') + parts.append('
    ') + + cost = getattr(entry, "cost", None) + if cost: + parts.append(f'
    £{cost:.2f}
    ') + parts.append('
    ') + + tp = getattr(entry, "ticket_price", None) + if tp is not None: + qty = pending_tickets.get(entry.id, 0) + parts.append('
    ') + parts.append(_ticket_widget_html(entry, qty, ticket_url, ctx={})) + parts.append('
    ') + parts.append('
    ') + return "".join(parts) + + +def _ticket_widget_html(entry, qty: int, ticket_url: str, *, ctx: dict) -> str: + """Render the inline +/- ticket widget.""" + csrf_token_val = "" + if ctx: + ct = ctx.get("csrf_token") + csrf_token_val = ct() if callable(ct) else (ct or "") + else: + from quart import g as _g + ct = getattr(_g, "_csrf_token", None) + try: + from quart import current_app + with current_app.app_context(): + pass + except Exception: + pass + # Use a deferred approach - get CSRF from template context + csrf_token_val = "" + + # For the ticket widget, we need to get csrf token from the app + try: + from flask_wtf.csrf import generate_csrf + csrf_token_val = generate_csrf() + except Exception: + pass + + if not csrf_token_val: + try: + from quart import current_app + csrf_token_val = current_app.config.get("WTF_CSRF_SECRET_KEY", "") + except Exception: + pass + + eid = entry.id + tp = getattr(entry, "ticket_price", 0) or 0 + cart_url_fn = None + + parts = [f'
    '] + parts.append(f'£{tp:.2f}') + + if qty == 0: + parts.append( + f'
    ' + f'' + f'' + '' + '
    ' + ) + else: + # Minus button + parts.append( + f'
    ' + f'' + f'' + f'' + '
    ' + ) + # Cart icon with count + parts.append( + '' + '' + '' + '' + f'{qty}' + '' + ) + # Plus button + parts.append( + f'
    ' + f'' + f'' + f'' + '
    ' + ) + parts.append('
    ') + return "".join(parts) + + +def _entry_cards_html(entries, page_info, pending_tickets, ticket_url, + events_url_fn, view, page, has_more, next_url, + *, is_page_scoped=False, post=None) -> str: + """Render entry cards (list or tile) with sentinel.""" + parts = [] + last_date = None + for entry in entries: + if view == "tile": + parts.append(_entry_card_tile_html( + entry, page_info, pending_tickets, ticket_url, events_url_fn, + is_page_scoped=is_page_scoped, post=post, + )) + else: + entry_date = entry.start_at.strftime("%A %-d %B %Y") + if entry_date != last_date: + parts.append( + f'

    ' + f'{entry_date}

    ' + ) + last_date = entry_date + parts.append(_entry_card_html( + entry, page_info, pending_tickets, ticket_url, events_url_fn, + is_page_scoped=is_page_scoped, post=post, + )) + + if has_more: + parts.append( + f'' + ) + return "".join(parts) + + +# --------------------------------------------------------------------------- +# All events / page summary main panels +# --------------------------------------------------------------------------- + +_LIST_SVG = '' +_TILE_SVG = '' + + +def _view_toggle_html(ctx: dict, view: str) -> str: + """Render the list/tile view toggle bar.""" + from shared.utils import route_prefix + prefix = route_prefix() + clh = ctx.get("current_local_href", "/") + qs_fn = ctx.get("qs") + hx_select = ctx.get("hx_select_search", "#main-panel") + + # Build hrefs - list removes view param, tile sets view=tile + list_href = prefix + str(clh) + tile_href = prefix + str(clh) + # Use simple query parameter manipulation + if "?" in list_href: + list_href = list_href.split("?")[0] + if "?" in tile_href: + tile_href = tile_href.split("?")[0] + "?view=tile" + else: + tile_href = tile_href + "?view=tile" + + 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 ( + '""" + ) + + +def _events_main_panel_html(ctx: dict, entries, has_more, pending_tickets, page_info, + page, view, ticket_url, next_url, events_url_fn, + *, is_page_scoped=False, post=None) -> str: + """Render the events main panel with view toggle + cards.""" + parts = [_view_toggle_html(ctx, view)] + + if entries: + cards = _entry_cards_html( + entries, page_info, pending_tickets, ticket_url, events_url_fn, + view, page, has_more, next_url, + is_page_scoped=is_page_scoped, post=post, + ) + if view == "tile": + parts.append(f'
    {cards}
    ') + else: + parts.append(f'
    {cards}
    ') + else: + parts.append( + '
    ' + '' + '

    No upcoming events

    ' + ) + parts.append('
    ') + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Utility +# --------------------------------------------------------------------------- + +def _list_container(ctx: dict) -> str: + styles = ctx.get("styles") or {} + return getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "") + + +# =========================================================================== +# PUBLIC API +# =========================================================================== + + +# --------------------------------------------------------------------------- +# All events +# --------------------------------------------------------------------------- + +async def render_all_events_page(ctx: dict, entries, has_more, pending_tickets, + page_info, page, view) -> str: + """Full page: all events listing.""" + from quart import url_for + from shared.utils import route_prefix, events_url + + prefix = route_prefix() + view_param = f"&view={view}" if view != "list" else "" + ticket_url = url_for("all_events.adjust_ticket") + next_url = prefix + url_for("all_events.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "") + + content = _events_main_panel_html( + ctx, entries, has_more, pending_tickets, page_info, + page, view, ticket_url, next_url, events_url, + ) + hdr = root_header_html(ctx) + return full_page(ctx, header_rows_html=hdr, content_html=content) + + +async def render_all_events_oob(ctx: dict, entries, has_more, pending_tickets, + page_info, page, view) -> str: + """OOB response: all events listing (htmx nav).""" + from quart import url_for + from shared.utils import route_prefix, events_url + + prefix = route_prefix() + ticket_url = url_for("all_events.adjust_ticket") + next_url = prefix + url_for("all_events.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "") + + content = _events_main_panel_html( + ctx, entries, has_more, pending_tickets, page_info, + page, view, ticket_url, next_url, events_url, + ) + return oob_page(ctx, content_html=content) + + +async def render_all_events_cards(entries, has_more, pending_tickets, + page_info, page, view) -> str: + """Pagination fragment: all events cards only.""" + from quart import url_for + from shared.utils import route_prefix, events_url + + prefix = route_prefix() + ticket_url = url_for("all_events.adjust_ticket") + next_url = prefix + url_for("all_events.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "") + + return _entry_cards_html( + entries, page_info, pending_tickets, ticket_url, events_url, + view, page, has_more, next_url, + ) + + +# --------------------------------------------------------------------------- +# Page summary +# --------------------------------------------------------------------------- + +async def render_page_summary_page(ctx: dict, entries, has_more, pending_tickets, + page_info, page, view) -> str: + """Full page: page-scoped events listing.""" + from quart import url_for + from shared.utils import route_prefix, events_url + + prefix = route_prefix() + post = ctx.get("post") or {} + ticket_url = url_for("page_summary.adjust_ticket") + next_url = prefix + url_for("page_summary.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "") + + content = _events_main_panel_html( + ctx, entries, has_more, pending_tickets, page_info, + page, view, ticket_url, next_url, events_url, + is_page_scoped=True, post=post, + ) + + hdr = root_header_html(ctx) + hdr += sexp( + '(div :id "root-header-child" :class "w-full" (raw! ph))', + ph=_post_header_html(ctx), + ) + return full_page(ctx, header_rows_html=hdr, content_html=content) + + +async def render_page_summary_oob(ctx: dict, entries, has_more, pending_tickets, + page_info, page, view) -> str: + """OOB response: page-scoped events (htmx nav).""" + from quart import url_for + from shared.utils import route_prefix, events_url + + prefix = route_prefix() + post = ctx.get("post") or {} + ticket_url = url_for("page_summary.adjust_ticket") + next_url = prefix + url_for("page_summary.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "") + + content = _events_main_panel_html( + ctx, entries, has_more, pending_tickets, page_info, + page, view, ticket_url, next_url, events_url, + is_page_scoped=True, post=post, + ) + + oobs = _post_header_html(ctx, oob=True) + return oob_page(ctx, oobs_html=oobs, content_html=content) + + +async def render_page_summary_cards(entries, has_more, pending_tickets, + page_info, page, view, post) -> str: + """Pagination fragment: page-scoped events cards only.""" + from quart import url_for + from shared.utils import route_prefix, events_url + + prefix = route_prefix() + ticket_url = url_for("page_summary.adjust_ticket") + next_url = prefix + url_for("page_summary.entries_fragment", page=page + 1) + (f"?view={view}" if view != "list" else "") + + return _entry_cards_html( + entries, page_info, pending_tickets, ticket_url, events_url, + view, page, has_more, next_url, + is_page_scoped=True, post=post, + ) + + +# --------------------------------------------------------------------------- +# Calendars home +# --------------------------------------------------------------------------- + +async def render_calendars_page(ctx: dict) -> str: + """Full page: calendars listing.""" + content = _calendars_main_panel_html(ctx) + hdr = root_header_html(ctx) + hdr += sexp( + '(div :id "root-header-child" :class "w-full" (raw! ph (raw! pah (raw! ch))))', + ph=_post_header_html(ctx), + pah=_post_admin_header_html(ctx), + ch=_calendars_header_html(ctx), + ) + return full_page(ctx, header_rows_html=hdr, content_html=content) + + +async def render_calendars_oob(ctx: dict) -> str: + """OOB response: calendars listing.""" + content = _calendars_main_panel_html(ctx) + oobs = _post_admin_header_html(ctx, oob=True) + oobs += _oob_header_html("post-admin-header-child", "calendars-header-child", + _calendars_header_html(ctx)) + return oob_page(ctx, oobs_html=oobs, content_html=content) + + +# --------------------------------------------------------------------------- +# Calendar month view +# --------------------------------------------------------------------------- + +async def render_calendar_page(ctx: dict) -> str: + """Full page: calendar month view.""" + content = _calendar_main_panel_html(ctx) + hdr = root_header_html(ctx) + hdr += sexp( + '(div :id "root-header-child" :class "w-full" (raw! ph (raw! pah (raw! ch))))', + ph=_post_header_html(ctx), + pah=_post_admin_header_html(ctx), + ch=_calendar_header_html(ctx), + ) + return full_page(ctx, header_rows_html=hdr, content_html=content) + + +async def render_calendar_oob(ctx: dict) -> str: + """OOB response: calendar month view.""" + content = _calendar_main_panel_html(ctx) + oobs = _post_header_html(ctx, oob=True) + oobs += _oob_header_html("post-header-child", "calendar-header-child", + _calendar_header_html(ctx)) + return oob_page(ctx, oobs_html=oobs, content_html=content) + + +# --------------------------------------------------------------------------- +# Day detail +# --------------------------------------------------------------------------- + +async def render_day_page(ctx: dict) -> str: + """Full page: day detail.""" + content = _day_main_panel_html(ctx) + hdr = root_header_html(ctx) + hdr += sexp( + '(div :id "root-header-child" :class "w-full" (raw! ph (raw! pah (raw! ch (raw! dh)))))', + ph=_post_header_html(ctx), + pah=_post_admin_header_html(ctx), + ch=_calendar_header_html(ctx), + dh=_day_header_html(ctx), + ) + return full_page(ctx, header_rows_html=hdr, content_html=content) + + +async def render_day_oob(ctx: dict) -> str: + """OOB response: day detail.""" + content = _day_main_panel_html(ctx) + oobs = _calendar_header_html(ctx, oob=True) + oobs += _oob_header_html("calendar-header-child", "day-header-child", + _day_header_html(ctx)) + return oob_page(ctx, oobs_html=oobs, content_html=content) + + +# --------------------------------------------------------------------------- +# Day admin +# --------------------------------------------------------------------------- + +async def render_day_admin_page(ctx: dict) -> str: + """Full page: day admin.""" + content = _day_admin_main_panel_html(ctx) + hdr = root_header_html(ctx) + hdr += sexp( + '(div :id "root-header-child" :class "w-full" (raw! ph (raw! pah (raw! ch (raw! dh (raw! dah))))))', + ph=_post_header_html(ctx), + pah=_post_admin_header_html(ctx), + ch=_calendar_header_html(ctx), + dh=_day_header_html(ctx), + dah=_day_admin_header_html(ctx), + ) + return full_page(ctx, header_rows_html=hdr, content_html=content) + + +async def render_day_admin_oob(ctx: dict) -> str: + """OOB response: day admin.""" + content = _day_admin_main_panel_html(ctx) + oobs = _calendar_header_html(ctx, oob=True) + oobs += _oob_header_html("day-header-child", "day-admin-header-child", + _day_admin_header_html(ctx)) + return oob_page(ctx, oobs_html=oobs, content_html=content) + + +# --------------------------------------------------------------------------- +# Calendar admin +# --------------------------------------------------------------------------- + +async def render_calendar_admin_page(ctx: dict) -> str: + """Full page: calendar admin.""" + content = _calendar_admin_main_panel_html(ctx) + hdr = root_header_html(ctx) + hdr += sexp( + '(div :id "root-header-child" :class "w-full" (raw! ph (raw! pah (raw! ch (raw! cah)))))', + ph=_post_header_html(ctx), + pah=_post_admin_header_html(ctx), + ch=_calendar_header_html(ctx), + cah=_calendar_admin_header_html(ctx), + ) + return full_page(ctx, header_rows_html=hdr, content_html=content) + + +async def render_calendar_admin_oob(ctx: dict) -> str: + """OOB response: calendar admin.""" + content = _calendar_admin_main_panel_html(ctx) + oobs = _calendar_header_html(ctx, oob=True) + oobs += _oob_header_html("calendar-header-child", "calendar-admin-header-child", + _calendar_admin_header_html(ctx)) + return oob_page(ctx, oobs_html=oobs, content_html=content) + + +# --------------------------------------------------------------------------- +# Tickets +# --------------------------------------------------------------------------- + +async def render_tickets_page(ctx: dict, tickets: list) -> str: + """Full page: my tickets.""" + content = _tickets_main_panel_html(ctx, tickets) + hdr = root_header_html(ctx) + return full_page(ctx, header_rows_html=hdr, content_html=content) + + +async def render_tickets_oob(ctx: dict, tickets: list) -> str: + """OOB response: my tickets.""" + content = _tickets_main_panel_html(ctx, tickets) + return oob_page(ctx, content_html=content) + + +async def render_ticket_detail_page(ctx: dict, ticket) -> str: + """Full page: ticket detail with QR.""" + content = _ticket_detail_panel_html(ctx, ticket) + hdr = root_header_html(ctx) + return full_page(ctx, header_rows_html=hdr, content_html=content) + + +async def render_ticket_detail_oob(ctx: dict, ticket) -> str: + """OOB response: ticket detail.""" + content = _ticket_detail_panel_html(ctx, ticket) + return oob_page(ctx, content_html=content) + + +# --------------------------------------------------------------------------- +# Ticket admin +# --------------------------------------------------------------------------- + +async def render_ticket_admin_page(ctx: dict, tickets: list, stats: dict) -> str: + """Full page: ticket admin dashboard.""" + content = _ticket_admin_main_panel_html(ctx, tickets, stats) + hdr = root_header_html(ctx) + return full_page(ctx, header_rows_html=hdr, content_html=content) + + +async def render_ticket_admin_oob(ctx: dict, tickets: list, stats: dict) -> str: + """OOB response: ticket admin dashboard.""" + content = _ticket_admin_main_panel_html(ctx, tickets, stats) + return oob_page(ctx, content_html=content) + + +# --------------------------------------------------------------------------- +# Markets +# --------------------------------------------------------------------------- + +async def render_markets_page(ctx: dict) -> str: + """Full page: markets listing.""" + content = _markets_main_panel_html(ctx) + hdr = root_header_html(ctx) + hdr += sexp( + '(div :id "root-header-child" :class "w-full" (raw! ph (raw! pah (raw! mh))))', + ph=_post_header_html(ctx), + pah=_post_admin_header_html(ctx), + mh=_markets_header_html(ctx), + ) + return full_page(ctx, header_rows_html=hdr, content_html=content) + + +async def render_markets_oob(ctx: dict) -> str: + """OOB response: markets listing.""" + content = _markets_main_panel_html(ctx) + oobs = _post_admin_header_html(ctx, oob=True) + oobs += _oob_header_html("post-admin-header-child", "markets-header-child", + _markets_header_html(ctx)) + return oob_page(ctx, oobs_html=oobs, content_html=content) + + +# --------------------------------------------------------------------------- +# Payments +# --------------------------------------------------------------------------- + +async def render_payments_page(ctx: dict) -> str: + """Full page: payments admin.""" + content = _payments_main_panel_html(ctx) + hdr = root_header_html(ctx) + hdr += sexp( + '(div :id "root-header-child" :class "w-full" (raw! ph (raw! pah (raw! pyh))))', + ph=_post_header_html(ctx), + pah=_post_admin_header_html(ctx), + pyh=_payments_header_html(ctx), + ) + return full_page(ctx, header_rows_html=hdr, content_html=content) + + +async def render_payments_oob(ctx: dict) -> str: + """OOB response: payments admin.""" + content = _payments_main_panel_html(ctx) + oobs = _post_admin_header_html(ctx, oob=True) + oobs += _oob_header_html("post-admin-header-child", "payments-header-child", + _payments_header_html(ctx)) + return oob_page(ctx, oobs_html=oobs, content_html=content) diff --git a/federation/app.py b/federation/app.py index 393b643..55f6519 100644 --- a/federation/app.py +++ b/federation/app.py @@ -1,5 +1,6 @@ from __future__ import annotations import path_setup # noqa: F401 # adds shared/ to sys.path +import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file from pathlib import Path from quart import g, request @@ -93,8 +94,13 @@ def create_app() -> "Quart": # --- home page --- @app.get("/") async def home(): - from quart import render_template - return await render_template("_types/federation/index.html") + from quart import make_response + from shared.sexp.page import get_template_context + from sexp_components import render_federation_home + + ctx = await get_template_context() + html = await render_federation_home(ctx) + return await make_response(html) return app diff --git a/federation/bp/auth/routes.py b/federation/bp/auth/routes.py index 6b33175..4aa3a8e 100644 --- a/federation/bp/auth/routes.py +++ b/federation/bp/auth/routes.py @@ -100,7 +100,10 @@ def register(url_prefix="/auth"): # If there's a pending redirect (e.g. OAuth authorize), follow it redirect_url = pop_login_redirect_target() return redirect(redirect_url) - return await render_template("auth/login.html") + from shared.sexp.page import get_template_context + from sexp_components import render_login_page + ctx = await get_template_context() + return await render_login_page(ctx) @auth_bp.post("/start/") async def start_login(): diff --git a/federation/bp/identity/routes.py b/federation/bp/identity/routes.py index b445eda..b18461c 100644 --- a/federation/bp/identity/routes.py +++ b/federation/bp/identity/routes.py @@ -39,7 +39,11 @@ def register(url_prefix="/identity"): if actor: return redirect(url_for("activitypub.actor_profile", username=actor.preferred_username)) - return await render_template("federation/choose_username.html") + from shared.sexp.page import get_template_context + from sexp_components import render_choose_username_page + ctx = await get_template_context() + ctx["actor"] = actor + return await render_choose_username_page(ctx) @bp.post("/choose-username") async def choose_username(): diff --git a/federation/bp/social/routes.py b/federation/bp/social/routes.py index 7878156..9ba7d37 100644 --- a/federation/bp/social/routes.py +++ b/federation/bp/social/routes.py @@ -39,12 +39,10 @@ def register(url_prefix="/social"): return redirect(url_for("auth.login_form")) actor = _require_actor() items = await services.federation.get_home_timeline(g.s, actor.id) - return await render_template( - "federation/timeline.html", - items=items, - timeline_type="home", - actor=actor, - ) + from shared.sexp.page import get_template_context + from sexp_components import render_timeline_page + ctx = await get_template_context() + return await render_timeline_page(ctx, items, "home", actor) @bp.get("/timeline") async def home_timeline_page(): @@ -59,23 +57,17 @@ def register(url_prefix="/social"): items = await services.federation.get_home_timeline( g.s, actor.id, before=before, ) - return await render_template( - "federation/_timeline_items.html", - items=items, - timeline_type="home", - actor=actor, - ) + from sexp_components import render_timeline_items + return await render_timeline_items(items, "home", actor) @bp.get("/public") async def public_timeline(): items = await services.federation.get_public_timeline(g.s) actor = getattr(g, "_social_actor", None) - return await render_template( - "federation/timeline.html", - items=items, - timeline_type="public", - actor=actor, - ) + from shared.sexp.page import get_template_context + from sexp_components import render_timeline_page + ctx = await get_template_context() + return await render_timeline_page(ctx, items, "public", actor) @bp.get("/public/timeline") async def public_timeline_page(): @@ -88,12 +80,8 @@ def register(url_prefix="/social"): pass items = await services.federation.get_public_timeline(g.s, before=before) actor = getattr(g, "_social_actor", None) - return await render_template( - "federation/_timeline_items.html", - items=items, - timeline_type="public", - actor=actor, - ) + from sexp_components import render_timeline_items + return await render_timeline_items(items, "public", actor) # -- Compose -------------------------------------------------------------- @@ -101,11 +89,10 @@ def register(url_prefix="/social"): async def compose_form(): actor = _require_actor() reply_to = request.args.get("reply_to") - return await render_template( - "federation/compose.html", - actor=actor, - reply_to=reply_to, - ) + from shared.sexp.page import get_template_context + from sexp_components import render_compose_page + ctx = await get_template_context() + return await render_compose_page(ctx, actor, reply_to) @bp.post("/compose") async def compose_submit(): @@ -148,15 +135,10 @@ def register(url_prefix="/social"): g.s, actor.preferred_username, page=1, per_page=1000, ) followed_urls = {a.actor_url for a in following} - return await render_template( - "federation/search.html", - query=query, - actors=actors, - total=total, - page=1, - followed_urls=followed_urls, - actor=actor, - ) + from shared.sexp.page import get_template_context + from sexp_components import render_search_page + ctx = await get_template_context() + return await render_search_page(ctx, query, actors, total, 1, followed_urls, actor) @bp.get("/search/page") async def search_page(): @@ -175,15 +157,8 @@ def register(url_prefix="/social"): g.s, actor.preferred_username, page=1, per_page=1000, ) followed_urls = {a.actor_url for a in following} - return await render_template( - "federation/_search_results.html", - actors=actors, - total=total, - page=page, - query=query, - followed_urls=followed_urls, - actor=actor, - ) + from sexp_components import render_search_results + return await render_search_results(actors, query, page, followed_urls, actor) @bp.post("/follow") async def follow(): @@ -340,13 +315,10 @@ def register(url_prefix="/social"): actors, total = await services.federation.get_following( g.s, actor.preferred_username, ) - return await render_template( - "federation/following.html", - actors=actors, - total=total, - page=1, - actor=actor, - ) + from shared.sexp.page import get_template_context + from sexp_components import render_following_page + ctx = await get_template_context() + return await render_following_page(ctx, actors, total, actor) @bp.get("/following/page") async def following_list_page(): @@ -355,15 +327,8 @@ def register(url_prefix="/social"): actors, total = await services.federation.get_following( g.s, actor.preferred_username, page=page, ) - return await render_template( - "federation/_actor_list_items.html", - actors=actors, - total=total, - page=page, - list_type="following", - followed_urls=set(), - actor=actor, - ) + from sexp_components import render_following_items + return await render_following_items(actors, page, actor) @bp.get("/followers") async def followers_list(): @@ -376,14 +341,10 @@ def register(url_prefix="/social"): g.s, actor.preferred_username, page=1, per_page=1000, ) followed_urls = {a.actor_url for a in following} - return await render_template( - "federation/followers.html", - actors=actors, - total=total, - page=1, - followed_urls=followed_urls, - actor=actor, - ) + from shared.sexp.page import get_template_context + from sexp_components import render_followers_page + ctx = await get_template_context() + return await render_followers_page(ctx, actors, total, followed_urls, actor) @bp.get("/followers/page") async def followers_list_page(): @@ -396,15 +357,8 @@ def register(url_prefix="/social"): g.s, actor.preferred_username, page=1, per_page=1000, ) followed_urls = {a.actor_url for a in following} - return await render_template( - "federation/_actor_list_items.html", - actors=actors, - total=total, - page=page, - list_type="followers", - followed_urls=followed_urls, - actor=actor, - ) + from sexp_components import render_followers_items + return await render_followers_items(actors, page, followed_urls, actor) @bp.get("/actor/") async def actor_timeline(id: int): @@ -435,13 +389,10 @@ def register(url_prefix="/social"): ) ).scalar_one_or_none() is_following = existing is not None - return await render_template( - "federation/actor_timeline.html", - remote_actor=remote_dto, - items=items, - is_following=is_following, - actor=actor, - ) + from shared.sexp.page import get_template_context + from sexp_components import render_actor_timeline_page + ctx = await get_template_context() + return await render_actor_timeline_page(ctx, remote_dto, items, is_following, actor) @bp.get("/actor//timeline") async def actor_timeline_page(id: int): @@ -456,13 +407,8 @@ def register(url_prefix="/social"): items = await services.federation.get_actor_timeline( g.s, id, before=before, ) - return await render_template( - "federation/_timeline_items.html", - items=items, - timeline_type="actor", - actor_id=id, - actor=actor, - ) + from sexp_components import render_actor_timeline_items + return await render_actor_timeline_items(items, id, actor) # -- Notifications -------------------------------------------------------- @@ -471,11 +417,10 @@ def register(url_prefix="/social"): actor = _require_actor() items = await services.federation.get_notifications(g.s, actor.id) await services.federation.mark_notifications_read(g.s, actor.id) - return await render_template( - "federation/notifications.html", - notifications=items, - actor=actor, - ) + from shared.sexp.page import get_template_context + from sexp_components import render_notifications_page + ctx = await get_template_context() + return await render_notifications_page(ctx, items, actor) @bp.get("/notifications/count") async def notification_count(): diff --git a/federation/sexp_components.py b/federation/sexp_components.py new file mode 100644 index 0000000..f27b0e4 --- /dev/null +++ b/federation/sexp_components.py @@ -0,0 +1,710 @@ +""" +Federation service s-expression page components. + +Renders social timeline, compose, search, following/followers, notifications, +actor profiles, login, and username selection pages. +""" +from __future__ import annotations + +from typing import Any +from markupsafe import escape + +from shared.sexp.jinja_bridge import sexp +from shared.sexp.helpers import root_header_html, full_page + + +# --------------------------------------------------------------------------- +# Social header nav +# --------------------------------------------------------------------------- + +def _social_nav_html(actor: Any) -> str: + """Build the social header nav bar content.""" + from quart import url_for, request + + if not actor: + choose_url = url_for("identity.choose_username_form") + return ( + '' + ) + + links = [ + ("social.home_timeline", "Timeline"), + ("social.public_timeline", "Public"), + ("social.compose_form", "Compose"), + ("social.following_list", "Following"), + ("social.followers_list", "Followers"), + ("social.search", "Search"), + ] + + parts = ['') + return "".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, + ) + + +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), + ) + return full_page(ctx, header_rows_html=hdr, content_html=content_html, + meta_html=meta_html or f'{escape(title)}') + + +# --------------------------------------------------------------------------- +# Post card +# --------------------------------------------------------------------------- + +def _interaction_buttons_html(item: Any, actor: Any) -> str: + """Render like/boost/reply buttons for a post.""" + from shared.browser.app.csrf import generate_csrf_token + from quart import url_for + + oid = getattr(item, "object_id", "") or "" + ainbox = getattr(item, "author_inbox", "") or "" + lcount = getattr(item, "like_count", 0) or 0 + bcount = getattr(item, "boost_count", 0) or 0 + liked = getattr(item, "liked_by_me", False) + boosted = getattr(item, "boosted_by_me", False) + csrf = generate_csrf_token() + + safe_id = oid.replace("/", "_").replace(":", "_") + target = f"#interactions-{safe_id}" + + if liked: + like_action = url_for("social.unlike") + like_cls = "text-red-500 hover:text-red-600" + like_icon = "♥" + else: + like_action = url_for("social.like") + like_cls = "hover:text-red-500" + like_icon = "♡" + + if boosted: + boost_action = url_for("social.unboost") + boost_cls = "text-green-600 hover:text-green-700" + else: + boost_action = url_for("social.boost") + boost_cls = "hover:text-green-600" + + reply_url = url_for("social.compose_form", reply_to=oid) if oid else "" + reply_html = f'Reply' if reply_url else "" + + return ( + f'
    ' + f'
    ' + f'' + f'' + f'' + f'
    ' + f'
    ' + f'' + f'' + f'' + f'
    ' + f'{reply_html}
    ' + ) + + +def _post_card_html(item: Any, actor: Any) -> str: + """Render a single timeline post card.""" + boosted_by = getattr(item, "boosted_by", None) + actor_icon = getattr(item, "actor_icon", None) + actor_name = getattr(item, "actor_name", "?") + actor_username = getattr(item, "actor_username", "") + actor_domain = getattr(item, "actor_domain", "") + content = getattr(item, "content", "") + summary = getattr(item, "summary", None) + published = getattr(item, "published", None) + url = getattr(item, "url", None) + post_type = getattr(item, "post_type", "") + + boost_html = f'
    Boosted by {escape(boosted_by)}
    ' if boosted_by else "" + + if actor_icon: + avatar = f'' + else: + initial = actor_name[0].upper() if actor_name else "?" + avatar = f'
    {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 = ( + f'
    CW: {escape(summary)}' + f'
    {content}
    ' + ) + else: + content_html = f'
    {content}
    ' + + original_html = "" + if url and post_type == "remote": + original_html = f'original' + + interactions_html = "" + if actor: + oid = getattr(item, "object_id", "") or "" + safe_id = oid.replace("/", "_").replace(":", "_") + interactions_html = f'
    {_interaction_buttons_html(item, actor)}
    ' + + return ( + f'
    ' + f'{boost_html}' + f'
    {avatar}' + f'
    ' + f'
    ' + f'{escape(actor_name)}' + f'@{escape(actor_username)}{domain_html}' + f'{time_html}
    ' + f'{content_html}{original_html}{interactions_html}
    ' + ) + + +# --------------------------------------------------------------------------- +# Timeline items (pagination fragment) +# --------------------------------------------------------------------------- + +def _timeline_items_html(items: list, timeline_type: str, actor: Any, + actor_id: int | None = None) -> str: + """Render timeline items with infinite scroll sentinel.""" + from quart import url_for + + parts = [_post_card_html(item, actor) for item in items] + + if items: + last = items[-1] + before = last.published.isoformat() if last.published else "" + if timeline_type == "actor" and actor_id is not None: + 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(f'
    ') + + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Search results (pagination fragment) +# --------------------------------------------------------------------------- + +def _actor_card_html(a: Any, actor: Any, followed_urls: set, + *, list_type: str = "search") -> str: + """Render a single actor card with follow/unfollow button.""" + from shared.browser.app.csrf import generate_csrf_token + from quart import url_for + + csrf = generate_csrf_token() + display_name = getattr(a, "display_name", None) or getattr(a, "preferred_username", "") + username = getattr(a, "preferred_username", "") + domain = getattr(a, "domain", "") + icon_url = getattr(a, "icon_url", None) + actor_url = getattr(a, "actor_url", "") + summary = getattr(a, "summary", None) + aid = getattr(a, "id", None) + + safe_id = actor_url.replace("/", "_").replace(":", "_") + + if icon_url: + avatar = f'' + else: + initial = (display_name or username)[0].upper() if (display_name or username) else "?" + avatar = f'
    {initial}
    ' + + # Name link + if list_type == "following" and aid: + name_html = f'{escape(display_name)}' + elif list_type == "search" and aid: + name_html = f'{escape(display_name)}' + else: + name_html = f'{escape(display_name)}' + + summary_html = f'
    {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 = ( + f'
    ' + f'' + f'' + f'
    ' + ) + else: + label = "Follow Back" if list_type == "followers" else "Follow" + button_html = ( + f'
    ' + f'' + f'' + f'
    ' + ) + + return ( + f'
    ' + f'{avatar}
    {name_html}' + f'
    @{escape(username)}@{escape(domain)}
    ' + f'{summary_html}
    {button_html}
    ' + ) + + +def _search_results_html(actors: list, query: str, page: int, + followed_urls: set, actor: Any) -> str: + """Render search results with pagination sentinel.""" + from quart import url_for + + 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(f'
    ') + return "".join(parts) + + +def _actor_list_items_html(actors: list, page: int, list_type: str, + followed_urls: set, actor: Any) -> str: + """Render actor list items (following/followers) with pagination sentinel.""" + from quart import url_for + + 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(f'
    ') + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Notification card +# --------------------------------------------------------------------------- + +def _notification_html(notif: Any) -> str: + """Render a single notification.""" + from_name = getattr(notif, "from_actor_name", "?") + from_username = getattr(notif, "from_actor_username", "") + from_domain = getattr(notif, "from_actor_domain", "") + from_icon = getattr(notif, "from_actor_icon", None) + ntype = getattr(notif, "notification_type", "") + preview = getattr(notif, "target_content_preview", None) + created = getattr(notif, "created_at", None) + read = getattr(notif, "read", True) + app_domain = getattr(notif, "app_domain", "") + + border = " border-l-4 border-l-stone-400" if not read else "" + + if from_icon: + avatar = f'' + else: + initial = from_name[0].upper() if from_name else "?" + avatar = f'
    {initial}
    ' + + domain_html = f"@{escape(from_domain)}" if from_domain else "" + + type_map = { + "follow": "followed you", + "like": "liked your post", + "boost": "boosted your post", + "mention": "mentioned you", + "reply": "replied to your post", + } + action = type_map.get(ntype, "") + if ntype == "follow" and app_domain and app_domain != "federation": + action += f" on {escape(app_domain)}" + + preview_html = f'
    {escape(preview)}
    ' if preview else "" + time_html = created.strftime("%b %d, %H:%M") if created else "" + + return ( + f'
    ' + f'
    {avatar}
    ' + f'
    {escape(from_name)}' + f' @{escape(from_username)}{domain_html}' + f' {action}
    ' + f'{preview_html}
    {time_html}
    ' + ) + + +# --------------------------------------------------------------------------- +# Public API: Home page +# --------------------------------------------------------------------------- + +async def render_federation_home(ctx: dict) -> str: + """Full page: federation home (minimal).""" + hdr = root_header_html(ctx) + return full_page(ctx, header_rows_html=hdr) + + +# --------------------------------------------------------------------------- +# Public API: Login +# --------------------------------------------------------------------------- + +async def render_login_page(ctx: dict) -> str: + """Full page: federation login form.""" + from shared.browser.app.csrf import generate_csrf_token + from quart import url_for + + error = ctx.get("error", "") + email = ctx.get("email", "") + action = url_for("auth.start_login") + csrf = generate_csrf_token() + + error_html = f'
    {error}
    ' if error else "" + content = ( + f'

    Sign in

    {error_html}' + f'
    ' + f'' + f'
    ' + f'
    ' + f'
    ' + ) + + hdr = root_header_html(ctx) + return full_page(ctx, header_rows_html=hdr, content_html=content, + meta_html="Login \u2014 Rose Ash") + + +# --------------------------------------------------------------------------- +# Public API: Timeline +# --------------------------------------------------------------------------- + +async def render_timeline_page(ctx: dict, items: list, timeline_type: str, + actor: Any) -> str: + """Full page: timeline (home or public).""" + from quart import url_for + + label = "Home" if timeline_type == "home" else "Public" + compose_html = "" + if actor: + compose_url = url_for("social.compose_form") + compose_html = f'Compose' + + timeline_html = _timeline_items_html(items, timeline_type, actor) + + content = ( + f'
    ' + f'

    {label} Timeline

    {compose_html}
    ' + f'
    {timeline_html}
    ' + ) + + return _social_page(ctx, actor, content_html=content, + title=f"{label} Timeline \u2014 Rose Ash") + + +async def render_timeline_items(items: list, timeline_type: str, + actor: Any, actor_id: int | None = None) -> str: + """Pagination fragment: timeline items.""" + return _timeline_items_html(items, timeline_type, actor, actor_id) + + +# --------------------------------------------------------------------------- +# Public API: Compose +# --------------------------------------------------------------------------- + +async def render_compose_page(ctx: dict, actor: Any, reply_to: str | None) -> str: + """Full page: compose form.""" + from shared.browser.app.csrf import generate_csrf_token + from quart import url_for + + csrf = generate_csrf_token() + action = url_for("social.compose_submit") + + reply_html = "" + if reply_to: + reply_html = ( + f'' + f'
    Replying to {escape(reply_to)}
    ' + ) + + content = ( + f'

    Compose

    ' + f'
    ' + f'{reply_html}' + f'' + f'
    ' + f'' + f'
    ' + ) + + return _social_page(ctx, actor, content_html=content, + title="Compose \u2014 Rose Ash") + + +# --------------------------------------------------------------------------- +# Public API: Search +# --------------------------------------------------------------------------- + +async def render_search_page(ctx: dict, query: str, actors: list, total: int, + page: int, followed_urls: set, actor: Any) -> str: + """Full page: search.""" + from quart import url_for + + search_url = url_for("social.search") + search_page_url = url_for("social.search_page") + + results_html = _search_results_html(actors, query, page, followed_urls, actor) + + info_html = "" + if query and total: + s = "s" if total != 1 else "" + info_html = f'

    {total} result{s} for {escape(query)}

    ' + elif query: + info_html = f'

    No results found for {escape(query)}

    ' + + content = ( + f'

    Search

    ' + f'
    ' + f'
    ' + f'
    ' + f'{info_html}
    {results_html}
    ' + ) + + return _social_page(ctx, actor, content_html=content, + title="Search \u2014 Rose Ash") + + +async def render_search_results(actors: list, query: str, page: int, + followed_urls: set, actor: Any) -> str: + """Pagination fragment: search results.""" + return _search_results_html(actors, query, page, followed_urls, actor) + + +# --------------------------------------------------------------------------- +# Public API: Following / Followers +# --------------------------------------------------------------------------- + +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 = ( + f'

    Following ({total})

    ' + f'
    {items_html}
    ' + ) + return _social_page(ctx, actor, content_html=content, + title="Following \u2014 Rose Ash") + + +async def render_following_items(actors: list, page: int, actor: Any) -> str: + """Pagination fragment: following items.""" + return _actor_list_items_html(actors, page, "following", set(), actor) + + +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 = ( + f'

    Followers ({total})

    ' + f'
    {items_html}
    ' + ) + return _social_page(ctx, actor, content_html=content, + title="Followers \u2014 Rose Ash") + + +async def render_followers_items(actors: list, page: int, + followed_urls: set, actor: Any) -> str: + """Pagination fragment: followers items.""" + return _actor_list_items_html(actors, page, "followers", followed_urls, actor) + + +# --------------------------------------------------------------------------- +# Public API: Actor timeline +# --------------------------------------------------------------------------- + +async def render_actor_timeline_page(ctx: dict, remote_actor: Any, items: list, + is_following: bool, actor: Any) -> str: + """Full page: remote actor timeline.""" + from shared.browser.app.csrf import generate_csrf_token + from quart import url_for + + csrf = generate_csrf_token() + display_name = remote_actor.display_name or remote_actor.preferred_username + icon_url = getattr(remote_actor, "icon_url", None) + summary = getattr(remote_actor, "summary", None) + actor_url = getattr(remote_actor, "actor_url", "") + + if icon_url: + avatar = f'' + else: + initial = display_name[0].upper() if display_name else "?" + avatar = f'
    {initial}
    ' + + summary_html = f'
    {summary}
    ' if summary else "" + + follow_html = "" + if actor: + if is_following: + follow_html = ( + f'
    ' + f'' + f'' + f'
    ' + ) + else: + follow_html = ( + f'
    ' + f'' + f'' + f'
    ' + ) + + timeline_html = _timeline_items_html(items, "actor", actor, remote_actor.id) + + content = ( + f'
    ' + f'
    {avatar}' + f'

    {escape(display_name)}

    ' + f'
    @{escape(remote_actor.preferred_username)}@{escape(remote_actor.domain)}
    ' + f'{summary_html}
    {follow_html}
    ' + f'
    {timeline_html}
    ' + ) + + return _social_page(ctx, actor, content_html=content, + title=f"{display_name} \u2014 Rose Ash") + + +async def render_actor_timeline_items(items: list, actor_id: int, + actor: Any) -> str: + """Pagination fragment: actor timeline items.""" + return _timeline_items_html(items, "actor", actor, actor_id) + + +# --------------------------------------------------------------------------- +# Public API: Notifications +# --------------------------------------------------------------------------- + +async def render_notifications_page(ctx: dict, notifications: list, + actor: Any) -> str: + """Full page: notifications.""" + if not notifications: + notif_html = '

    No notifications yet.

    ' + else: + notif_html = '
    ' + "".join(_notification_html(n) for n in notifications) + '
    ' + + content = f'

    Notifications

    {notif_html}' + return _social_page(ctx, actor, content_html=content, + title="Notifications \u2014 Rose Ash") + + +# --------------------------------------------------------------------------- +# Public API: Choose username +# --------------------------------------------------------------------------- + +async def render_choose_username_page(ctx: dict) -> str: + """Full page: choose username form.""" + from shared.browser.app.csrf import generate_csrf_token + from quart import url_for + from shared.config import config + + csrf = generate_csrf_token() + error = ctx.get("error", "") + username = ctx.get("username", "") + ap_domain = config().get("ap_domain", "rose-ash.com") + check_url = url_for("identity.check_username") + actor = ctx.get("actor") + + error_html = f'
    {error}
    ' if error else "" + + content = ( + f'
    ' + f'

    Choose your username

    ' + f'

    This will be your identity on the fediverse: ' + f'@username@{escape(ap_domain)}

    ' + f'{error_html}' + f'
    ' + f'' + f'
    ' + f'
    @' + f'' + f'
    ' + f'

    3-32 characters. Lowercase letters, numbers, underscores. Must start with a letter.

    ' + f'
    ' + ) + + return _social_page(ctx, actor, content_html=content, + title="Choose Username \u2014 Rose Ash") + + +# --------------------------------------------------------------------------- +# Public API: Actor profile +# --------------------------------------------------------------------------- + +async def render_profile_page(ctx: dict, actor: Any, activities: list, + total: int) -> str: + """Full page: actor profile.""" + from shared.config import config + + ap_domain = config().get("ap_domain", "rose-ash.com") + display_name = actor.display_name or actor.preferred_username + summary_html = f'

    {escape(actor.summary)}

    ' if actor.summary else "" + + activities_html = "" + if activities: + parts = [] + for a in activities: + published = a.published.strftime("%Y-%m-%d %H:%M") if a.published else "" + obj_type = f'{a.object_type}' if a.object_type else "" + parts.append( + f'
    ' + f'{a.activity_type}' + f'{published}
    {obj_type}
    ' + ) + activities_html = '
    ' + "".join(parts) + '
    ' + else: + activities_html = '

    No activities yet.

    ' + + content = ( + f'
    ' + f'

    {escape(display_name)}

    ' + f'

    @{escape(actor.preferred_username)}@{escape(ap_domain)}

    ' + f'{summary_html}
    ' + f'

    Activities ({total})

    {activities_html}
    ' + ) + + return _social_page(ctx, actor, content_html=content, + title=f"@{actor.preferred_username} \u2014 Rose Ash") diff --git a/market/app.py b/market/app.py index ed73144..e318c04 100644 --- a/market/app.py +++ b/market/app.py @@ -1,5 +1,6 @@ from __future__ import annotations import path_setup # noqa: F401 # adds shared/ to sys.path +import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file from pathlib import Path diff --git a/market/bp/all_markets/routes.py b/market/bp/all_markets/routes.py index 66506a2..d79f0bf 100644 --- a/market/bp/all_markets/routes.py +++ b/market/bp/all_markets/routes.py @@ -55,10 +55,14 @@ def register() -> Blueprint: page=page, ) + from shared.sexp.page import get_template_context + from sexp_components import render_all_markets_page, render_all_markets_oob + + tctx = await get_template_context() if is_htmx_request(): - html = await render_template("_types/all_markets/_main_panel.html", **ctx) + html = await render_all_markets_oob(tctx, markets, has_more, page_info, page) else: - html = await render_template("_types/all_markets/index.html", **ctx) + html = await render_all_markets_page(tctx, markets, has_more, page_info, page) return await make_response(html, 200) @@ -67,13 +71,8 @@ def register() -> Blueprint: page = int(request.args.get("page", 1)) markets, has_more, page_info = await _load_markets(page) - html = await render_template( - "_types/all_markets/_cards.html", - markets=markets, - has_more=has_more, - page_info=page_info, - page=page, - ) + from sexp_components import render_all_markets_cards + html = await render_all_markets_cards(markets, has_more, page_info, page) return await make_response(html, 200) return bp diff --git a/market/bp/browse/routes.py b/market/bp/browse/routes.py index 750b816..b0e5bf4 100644 --- a/market/bp/browse/routes.py +++ b/market/bp/browse/routes.py @@ -42,12 +42,15 @@ def register(): p_data = getattr(g, "post_data", None) or {} # Determine which template to use based on request type + from shared.sexp.page import get_template_context + from sexp_components import render_market_home_page, render_market_home_oob + + ctx = await get_template_context() + ctx.update(p_data) if not is_htmx_request(): - # Normal browser request: full page with layout - html = await render_template("_types/market/index.html", **p_data) + html = await render_market_home_page(ctx) else: - # HTMX request: main panel + OOB elements - html = await render_template("_types/market/_oob_elements.html", **p_data) + html = await render_market_home_oob(ctx) return await make_response(html) @@ -70,16 +73,18 @@ def register(): product_info = await _productInfo() full_context = {**product_info, **ctx} - # Determine which template to use based on request type and pagination + from shared.sexp.page import get_template_context + from sexp_components import render_browse_page, render_browse_oob, render_browse_cards + + tctx = await get_template_context() + tctx.update(full_context) if not is_htmx_request(): - # Normal browser request: full page with layout - html = await render_template("_types/browse/index.html", **full_context) + html = await render_browse_page(tctx) elif product_info["page"] > 1: - # HTMX pagination: just product cards + sentinel - html = await render_template("_types/browse/_product_cards.html", **product_info) + tctx.update(product_info) + html = await render_browse_cards(tctx) else: - # HTMX navigation (page 1): main panel + OOB elements - html = await render_template("_types/browse/_oob_elements.html", **full_context) + html = await render_browse_oob(tctx) resp = await make_response(html) resp.headers["Hx-Push-Url"] = _current_url_without_page() @@ -107,15 +112,18 @@ def register(): product_info = await _productInfo(top_slug) full_context = {**product_info, **ctx} - # Determine which template to use based on request type and pagination + from shared.sexp.page import get_template_context + from sexp_components import render_browse_page, render_browse_oob, render_browse_cards + + tctx = await get_template_context() + tctx.update(full_context) if not is_htmx_request(): - # Normal browser request: full page with layout - html = await render_template("_types/browse/index.html", **full_context) + html = await render_browse_page(tctx) elif product_info["page"] > 1: - # HTMX pagination: just product cards + sentinel - html = await render_template("_types/browse/_product_cards.html", **product_info) + tctx.update(product_info) + html = await render_browse_cards(tctx) else: - html = await render_template("_types/browse/_oob_elements.html", **full_context) + html = await render_browse_oob(tctx) resp = await make_response(html) resp.headers["Hx-Push-Url"] = _current_url_without_page() @@ -143,16 +151,18 @@ def register(): product_info = await _productInfo(top_slug, sub_slug) full_context = {**product_info, **ctx} - # Determine which template to use based on request type and pagination + from shared.sexp.page import get_template_context + from sexp_components import render_browse_page, render_browse_oob, render_browse_cards + + tctx = await get_template_context() + tctx.update(full_context) if not is_htmx_request(): - # Normal browser request: full page with layout - html = await render_template("_types/browse/index.html", **full_context) + html = await render_browse_page(tctx) elif product_info["page"] > 1: - # HTMX pagination: just product cards + sentinel - html = await render_template("_types/browse/_product_cards.html", **product_info) + tctx.update(product_info) + html = await render_browse_cards(tctx) else: - # HTMX navigation (page 1): main panel + OOB elements - html = await render_template("_types/browse/_oob_elements.html", **full_context) + html = await render_browse_oob(tctx) resp = await make_response(html) resp.headers["Hx-Push-Url"] = _current_url_without_page() diff --git a/market/bp/market/admin/routes.py b/market/bp/market/admin/routes.py index 0b8478a..598aa64 100644 --- a/market/bp/market/admin/routes.py +++ b/market/bp/market/admin/routes.py @@ -17,12 +17,14 @@ def register(): async def admin(): from shared.browser.app.utils.htmx import is_htmx_request - # Determine which template to use based on request type + from shared.sexp.page import get_template_context + from sexp_components import render_market_admin_page, render_market_admin_oob + + tctx = await get_template_context() if not is_htmx_request(): - # Normal browser request: full page with layout - html = await render_template("_types/market/admin/index.html") + html = await render_market_admin_page(tctx) else: - html = await render_template("_types/market/admin/_oob_elements.html") + html = await render_market_admin_oob(tctx) return await make_response(html) return bp diff --git a/market/bp/page_markets/routes.py b/market/bp/page_markets/routes.py index e18a616..e7c92d6 100644 --- a/market/bp/page_markets/routes.py +++ b/market/bp/page_markets/routes.py @@ -39,10 +39,15 @@ def register() -> Blueprint: page=page, ) + from shared.sexp.page import get_template_context + from sexp_components import render_page_markets_page, render_page_markets_oob + + tctx = await get_template_context() + tctx["post"] = post if is_htmx_request(): - html = await render_template("_types/page_markets/_main_panel.html", **ctx) + html = await render_page_markets_oob(tctx, markets, has_more, page) else: - html = await render_template("_types/page_markets/index.html", **ctx) + html = await render_page_markets_page(tctx, markets, has_more, page) return await make_response(html, 200) @@ -53,13 +58,9 @@ def register() -> Blueprint: markets, has_more = await _load_markets(post["id"], page) - html = await render_template( - "_types/page_markets/_cards.html", - markets=markets, - has_more=has_more, - page_info={}, - page=page, - ) + from sexp_components import render_page_markets_cards + post_slug = post.get("slug", "") + html = await render_page_markets_cards(markets, has_more, page, post_slug) return await make_response(html, 200) return bp diff --git a/market/bp/product/routes.py b/market/bp/product/routes.py index bd92a56..c4fb591 100644 --- a/market/bp/product/routes.py +++ b/market/bp/product/routes.py @@ -107,13 +107,17 @@ def register(): async def product_detail(): from shared.browser.app.utils.htmx import is_htmx_request - # Determine which template to use based on request type + from shared.sexp.page import get_template_context + from sexp_components import render_product_page, render_product_oob + + tctx = await get_template_context() + item_data = getattr(g, "item_data", {}) + d = item_data.get("d", {}) + tctx["liked_by_current_user"] = item_data.get("liked", False) if not is_htmx_request(): - # Normal browser request: full page with layout - html = await render_template("_types/product/index.html") + html = await render_product_page(tctx, d) else: - # HTMX request: main panel + OOB elements - html = await render_template("_types/product/_oob_elements.html") + html = await render_product_oob(tctx, d) return html @@ -151,12 +155,17 @@ def register(): async def admin(): from shared.browser.app.utils.htmx import is_htmx_request + from shared.sexp.page import get_template_context + from sexp_components import render_product_admin_page, render_product_admin_oob + + tctx = await get_template_context() + item_data = getattr(g, "item_data", {}) + d = item_data.get("d", {}) + tctx["liked_by_current_user"] = item_data.get("liked", False) if not is_htmx_request(): - # Normal browser request: full page with layout - html = await render_template("_types/product/admin/index.html") + html = await render_product_admin_page(tctx, d) else: - # HTMX request: main panel + OOB elements - html = await render_template("_types/product/admin/_oob_elements.html") + html = await render_product_admin_oob(tctx, d) return await make_response(html) diff --git a/market/sexp_components.py b/market/sexp_components.py new file mode 100644 index 0000000..242a309 --- /dev/null +++ b/market/sexp_components.py @@ -0,0 +1,1584 @@ +""" +Market service s-expression page components. + +Renders market landing, browse (category/subcategory), product detail, +product admin, market admin, page markets, and all markets pages. +Called from route handlers in place of ``render_template()``. +""" +from __future__ import annotations + +from typing import Any +from markupsafe import escape + +from shared.sexp.jinja_bridge import sexp +from shared.sexp.helpers import ( + call_url, get_asset_url, root_header_html, + search_mobile_html, search_desktop_html, + full_page, oob_page, +) + + +# --------------------------------------------------------------------------- +# Price helpers +# --------------------------------------------------------------------------- + +_SYM = {"GBP": "£", "EUR": "€", "USD": "$"} + + +def _price_str(val, raw, cur) -> str: + if raw: + return str(raw) + if isinstance(val, (int, float)): + return f"{_SYM.get(cur, '')}{val:.2f}" + return str(val or "") + + +def _set_prices(item: dict) -> dict: + """Extract price values from product dict (mirrors prices.html set_prices macro).""" + oe = item.get("oe_list_price") or {} + sp_val = item.get("special_price") or (oe.get("special") if oe else None) + sp_raw = item.get("special_price_raw") or (oe.get("special_raw") if oe else None) + sp_cur = item.get("special_price_currency") or (oe.get("special_currency") if oe else None) + rp_val = item.get("regular_price") or item.get("rrp") or (oe.get("rrp") if oe else None) + rp_raw = item.get("regular_price_raw") or item.get("rrp_raw") or (oe.get("rrp_raw") if oe else None) + rp_cur = item.get("regular_price_currency") or item.get("rrp_currency") or (oe.get("rrp_currency") if oe else None) + return dict(sp_val=sp_val, sp_raw=sp_raw, sp_cur=sp_cur, + rp_val=rp_val, rp_raw=rp_raw, rp_cur=rp_cur) + + +def _card_price_html(p: dict) -> str: + """Render price line for product card (mirrors prices.html card_price macro).""" + pr = _set_prices(p) + sp_str = _price_str(pr["sp_val"], pr["sp_raw"], pr["sp_cur"]) + rp_str = _price_str(pr["rp_val"], pr["rp_raw"], pr["rp_cur"]) + parts = ['
    '] + if pr["sp_val"]: + parts.append(f'
    {sp_str}
    ') + if pr["rp_val"]: + parts.append(f'
    {rp_str}
    ') + elif pr["rp_val"]: + parts.append(f'
    {rp_str}
    ') + parts.append("
    ") + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Header helpers +# --------------------------------------------------------------------------- + +def _post_header_html(ctx: dict, *, oob: bool = False) -> str: + """Build the post-level header row (feature image + title + page cart count).""" + post = ctx.get("post") or {} + slug = post.get("slug", "") + title = (post.get("title") or "")[:160] + feature_image = post.get("feature_image") + + label_parts = [] + if feature_image: + label_parts.append( + f'' + ) + label_parts.append(f"{escape(title)}") + label_html = "".join(label_parts) + + 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( + f'' + f'' + f'{page_cart_count}' + ) + + # Container nav + container_nav = ctx.get("container_nav_html", "") + if container_nav: + nav_parts.append(container_nav) + + 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, + ) + + +def _market_header_html(ctx: dict, *, oob: bool = False) -> str: + """Build the market-level header row (shop icon + market title + category slugs + nav).""" + from quart import url_for + + market_title = ctx.get("market_title", "") + top_slug = ctx.get("top_slug", "") + sub_slug = ctx.get("sub_slug", "") + hx_select_search = ctx.get("hx_select_search", "#main-panel") + + label_parts = [ + '
    ', + f'
    {escape(market_title)}
    ', + '
    ', + f"
    {escape(top_slug or '')}
    ", + ] + if sub_slug: + label_parts.append(f"
    {escape(sub_slug)}
    ") + label_parts.append("
    ") + label_html = "".join(label_parts) + + link_href = url_for("market.browse.home") + + # Build desktop nav from categories + categories = ctx.get("categories", {}) + 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, + ) + + +def _desktop_category_nav_html(ctx: dict, categories: dict, qs: str, + hx_select: str) -> str: + """Build desktop category navigation links.""" + from quart import url_for + from shared.utils import route_prefix + + prefix = route_prefix() + category_label = ctx.get("category_label", "") + select_colours = ctx.get("select_colours", "") + rights = ctx.get("rights", {}) + + parts = ['") + return "".join(parts) + + +def _product_header_html(ctx: dict, d: dict, *, oob: bool = False) -> str: + """Build the product-level header row (bag icon + title + prices + admin).""" + from quart import url_for + from shared.browser.app.csrf import generate_csrf_token + + slug = d.get("slug", "") + title = d.get("title", "") + hx_select_search = ctx.get("hx_select_search", "#main-panel") + link_href = url_for("market.browse.product.product_detail", product_slug=slug) + + label_html = f'
    {escape(title)}
    ' + + # Prices in nav area + pr = _set_prices(d) + cart = ctx.get("cart", []) + prices_nav = _prices_header_html(d, pr, cart, slug, ctx) + + rights = ctx.get("rights", {}) + admin_html = "" + if rights and rights.get("admin"): + admin_href = url_for("market.browse.product.admin", product_slug=slug) + admin_html = ( + f'' + f'' + ) + 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, + ) + + +def _prices_header_html(d: dict, pr: dict, cart: list, slug: str, ctx: dict) -> str: + """Build prices + add-to-cart for product header row.""" + from quart import url_for + from shared.browser.app.csrf import generate_csrf_token + + csrf = generate_csrf_token() + cart_action = url_for("market.browse.product.cart", product_slug=slug) + cart_url_fn = ctx.get("cart_url") + + # Add-to-cart button + quantity = sum(ci.quantity for ci in cart if ci.product.slug == slug) if cart else 0 + add_html = _cart_add_html(slug, quantity, cart_action, csrf, cart_url_fn) + + parts = ['
    '] + parts.append(add_html) + + sp_val, rp_val = pr.get("sp_val"), pr.get("rp_val") + if sp_val: + parts.append(f'
    Special price
    ') + parts.append(f'
    {_price_str(sp_val, pr["sp_raw"], pr["sp_cur"])}
    ') + if rp_val: + parts.append(f'
    {_price_str(rp_val, pr["rp_raw"], pr["rp_cur"])}
    ') + elif rp_val: + parts.append(f'') + parts.append(f'
    {_price_str(rp_val, pr["rp_raw"], pr["rp_cur"])}
    ') + + # RRP + rrp_raw = d.get("rrp_raw") + rrp_val = d.get("rrp") + case_size = d.get("case_size_count") or 1 + if rrp_raw and rrp_val: + rrp_str = f"{rrp_raw[0]}{rrp_val * case_size:.2f}" + parts.append(f'
    rrp: {rrp_str}
    ') + + parts.append("
    ") + return "".join(parts) + + +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 ( + f'
    ' + f'
    ' + f'' + f'' + f'
    ' + ) + + cart_href = cart_url_fn("/") if callable(cart_url_fn) else "/" + return ( + f'
    ' + f'
    ' + f'' + f'' + f'
    ' + f'' + f'' + f'' + f'{quantity}' + f'
    ' + f'' + f'' + f'
    ' + f'
    ' + ) + + +# --------------------------------------------------------------------------- +# Mobile nav panel +# --------------------------------------------------------------------------- + +def _mobile_nav_panel_html(ctx: dict) -> str: + """Build mobile nav panel with category accordion.""" + from quart import url_for + from shared.utils import route_prefix + + prefix = route_prefix() + categories = ctx.get("categories", {}) + qs = ctx.get("qs", "") + category_label = ctx.get("category_label", "") + top_slug = ctx.get("top_slug", "") + sub_slug = ctx.get("sub_slug", "") + hx_select = ctx.get("hx_select_search", "#main-panel") + select_colours = ctx.get("select_colours", "") + + parts = ['
    '] + + all_href = prefix + url_for("market.browse.browse_all") + qs + all_active = (category_label == "All Products") + parts.append( + f'' + f'
    All
    ' + ) + + for cat, data in categories.items(): + cat_slug = data.get("slug", "") + cat_active = (top_slug == cat_slug.lower() if top_slug else False) + open_attr = " open" if cat_active else "" + 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 "" + + parts.append(f'
    ') + parts.append( + f'' + f'' + f'
    {escape(cat)}
    ' + f'
    {data.get("count", 0)}
    ' + f'' + f'
    ' + ) + + subs = data.get("subs", []) + if subs: + parts.append('
    ') + parts.append('
    ') + for sub in subs: + sub_href = prefix + url_for("market.browse.browse_sub", top_slug=cat_slug, sub_slug=sub["slug"]) + qs + sub_active = (cat_active and sub_slug == sub.get("slug")) + parts.append( + f'' + f'
    {escape(sub.get("html_label") or sub.get("name", ""))}
    ' + f'
    {sub.get("count", 0)}
    ' + ) + parts.append("
    ") + else: + view_href = prefix + url_for("market.browse.browse_top", top_slug=cat_slug) + qs + parts.append( + f'' + ) + parts.append("
    ") + + parts.append("
    ") + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Product card (browse grid item) +# --------------------------------------------------------------------------- + +def _product_card_html(p: dict, ctx: dict) -> str: + """Render a single product card for browse grid.""" + from quart import url_for + from shared.browser.app.csrf import generate_csrf_token + from shared.utils import route_prefix + + prefix = route_prefix() + slug = p.get("slug", "") + item_href = prefix + url_for("market.browse.product.product_detail", product_slug=slug) + hx_select = ctx.get("hx_select_search", "#main-panel") + asset_url_fn = ctx.get("asset_url") + cart = ctx.get("cart", []) + selected_brands = ctx.get("selected_brands", []) + selected_stickers = ctx.get("selected_stickers", []) + search = ctx.get("search", "") + user = ctx.get("user") + csrf = generate_csrf_token() + cart_action = url_for("market.browse.product.cart", product_slug=slug) + + # Like button overlay + like_html = "" + if user: + liked = p.get("is_liked", False) + like_html = _like_button_html(slug, liked, csrf, ctx) + + # Image + image = p.get("image") + labels = p.get("labels", []) + brand = p.get("brand", "") + brand_highlight = " bg-yellow-200" if brand in selected_brands else "" + + if image: + labels_html = "".join( + f'' + for l in labels + ) if callable(asset_url_fn) else "" + img_html = ( + f'
    ' + f'
    ' + f'no image' + f'{labels_html}
    ' + f'
    {escape(brand)}
    ' + f'
    ' + ) + else: + labels_list = "".join(f"
  • {l}
  • " for l in labels) + img_html = ( + f'
    ' + f'
    ' + f'
    No image
    ' + f'
      {labels_list}
    ' + f'
    {escape(brand)}
    ' + f'
    ' + ) + + price_html = _card_price_html(p) + + # Cart button + quantity = sum(ci.quantity for ci in cart if ci.product.slug == slug) if cart else 0 + cart_url_fn = ctx.get("cart_url") + add_html = _cart_add_html(slug, quantity, cart_action, csrf, cart_url_fn) + + # Stickers + stickers = p.get("stickers", []) + stickers_html = "" + if stickers and callable(asset_url_fn): + sticker_parts = [] + for s in stickers: + found = s in selected_stickers + src = asset_url_fn(f"stickers/{s}.svg") + sticker_parts.append( + f'{escape(s)}' + ) + stickers_html = '
    ' + "".join(sticker_parts) + "
    " + + # Title with search highlight + title = p.get("title", "") + if search and search.lower() in title.lower(): + idx = title.lower().index(search.lower()) + highlighted = f"{escape(title[:idx])}{escape(title[idx:idx+len(search)])}{escape(title[idx+len(search):])}" + else: + highlighted = escape(title) + + return ( + f'' + ) + + +def _like_button_html(slug: str, liked: bool, csrf: str, ctx: dict) -> str: + """Render the like/unlike heart button overlay.""" + from quart import url_for + + action = url_for("market.browse.product.like_toggle", product_slug=slug) + icon_cls = "fa-solid fa-heart text-red-500" if liked else "fa-regular fa-heart text-stone-400" + return ( + f'
    ' + f'
    ' + f'' + f'
    ' + ) + + +# --------------------------------------------------------------------------- +# Product cards (pagination fragment) +# --------------------------------------------------------------------------- + +def _product_cards_html(ctx: dict) -> str: + """Render product cards with infinite scroll sentinels.""" + from shared.utils import route_prefix + + prefix = route_prefix() + products = ctx.get("products", []) + page = ctx.get("page", 1) + total_pages = ctx.get("total_pages", 1) + current_local_href = ctx.get("current_local_href", "/") + qs_fn = ctx.get("qs_filter") + + parts = [_product_card_html(p, ctx) for p in products] + + if page < total_pages: + # Build next page URL + if callable(qs_fn): + next_qs = qs_fn({"page": page + 1}) + else: + next_qs = f"?page={page + 1}" + next_url = prefix + current_local_href + next_qs + + # Mobile sentinel + parts.append( + f'' + ) + + # Desktop sentinel + parts.append( + f'' + ) + else: + parts.append('
    End of results
    ') + + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Browse filter panels (mobile + desktop) +# --------------------------------------------------------------------------- + +def _desktop_filter_html(ctx: dict) -> str: + """Build the desktop aside filter panel (search, category, sort, like, labels, stickers, brands).""" + from quart import url_for + from shared.utils import route_prefix + + prefix = route_prefix() + category_label = ctx.get("category_label", "") + search = ctx.get("search", "") + search_count = ctx.get("search_count", "") + current_local_href = ctx.get("current_local_href", "/") + hx_select = ctx.get("hx_select", "#main-panel") + sort_options = ctx.get("sort_options", []) + sort = ctx.get("sort", "") + labels = ctx.get("labels", []) + selected_labels = ctx.get("selected_labels", []) + stickers = ctx.get("stickers", []) + selected_stickers = ctx.get("selected_stickers", []) + brands = ctx.get("brands", []) + selected_brands = ctx.get("selected_brands", []) + liked = ctx.get("liked", False) + liked_count = ctx.get("liked_count", 0) + subs_local = ctx.get("subs_local", []) + top_local_href = ctx.get("top_local_href", "") + sub_slug = ctx.get("sub_slug", "") + asset_url_fn = ctx.get("asset_url") + + # Search + search_html = search_desktop_html(ctx) + + # Category summary + sort + like + labels + stickers + parts = [search_html] + parts.append(f'
    ') + parts.append(f'
    {escape(category_label)}
    ') + + # Sort stickers + if sort_options: + parts.append(_sort_stickers_html(sort_options, sort, ctx)) + + # Like + labels row + parts.append('") + + # Stickers + if stickers: + parts.append(_stickers_filter_html(stickers, selected_stickers, ctx)) + + # Subcategory selector + if subs_local and top_local_href: + parts.append(_subcategory_selector_html(subs_local, top_local_href, sub_slug, ctx)) + + parts.append("
    ") + + # Brand filter + parts.append(f'
    ') + if brands: + parts.append(_brand_filter_html(brands, selected_brands, ctx)) + parts.append("
    ") + + return "".join(parts) + + +def _mobile_filter_summary_html(ctx: dict) -> str: + """Build mobile filter summary (collapsible bar showing active filters).""" + # Simplified version — just the filter details/summary wrapper + asset_url_fn = ctx.get("asset_url") + search = ctx.get("search", "") + search_count = ctx.get("search_count", "") + current_local_href = ctx.get("current_local_href", "/") + hx_select = ctx.get("hx_select", "#main-panel") + sort = ctx.get("sort", "") + sort_options = ctx.get("sort_options", []) + liked = ctx.get("liked", False) + liked_count = ctx.get("liked_count", 0) + selected_labels = ctx.get("selected_labels", []) + selected_stickers = ctx.get("selected_stickers", []) + selected_brands = ctx.get("selected_brands", []) + labels = ctx.get("labels", []) + stickers = ctx.get("stickers", []) + brands = ctx.get("brands", []) + + # Search bar + search_bar = search_mobile_html(ctx) + + # Summary chips showing active filters + chip_parts = ['
    '] + + if sort and sort_options: + for k, l, i in sort_options: + if k == sort and callable(asset_url_fn): + chip_parts.append(f'
      ' + f'
    • {escape(l)}
    ') + if liked: + chip_parts.append('
    ' + f'') + if liked_count is not None: + cls = "text-[10px] text-stone-500" if liked_count != 0 else "text-md text-red-500 font-bold" + chip_parts.append(f'
    {liked_count}
    ') + chip_parts.append("
    ") + + # Selected labels + if selected_labels: + chip_parts.append('
      ') + for sl in selected_labels: + for lb in labels: + if lb.get("name") == sl and callable(asset_url_fn): + chip_parts.append(f'
    • ' + f'{escape(sl)}') + if lb.get("count") is not None: + cls = "text-[10px] text-stone-500" if lb["count"] != 0 else "text-md text-red-500 font-bold" + chip_parts.append(f'
      {lb["count"]}
      ') + chip_parts.append("
    • ") + chip_parts.append("
    ") + + # Selected stickers + if selected_stickers: + chip_parts.append('
      ') + for ss in selected_stickers: + for st in stickers: + if st.get("name") == ss and callable(asset_url_fn): + chip_parts.append(f'
    • ' + f'{escape(ss)}') + if st.get("count") is not None: + cls = "text-[10px] text-stone-500" if st["count"] != 0 else "text-md text-red-500 font-bold" + chip_parts.append(f'
      {st["count"]}
      ') + chip_parts.append("
    • ") + chip_parts.append("
    ") + + # Selected brands + if selected_brands: + chip_parts.append('
      ') + for b in selected_brands: + count = 0 + for br in brands: + if br.get("name") == b: + count = br.get("count", 0) + if count: + chip_parts.append(f'
    • {escape(b)}
      {count}
    • ') + else: + chip_parts.append(f'
    • {escape(b)}
      0
    • ') + chip_parts.append("
    ") + + chip_parts.append("
    ") + chips_html = "".join(chip_parts) + + # Full mobile filter details + from shared.utils import route_prefix + prefix = route_prefix() + mobile_filter = _mobile_filter_content_html(ctx, prefix) + + return ( + f'
    ' + f'' + f'{search_bar}' + f'
    ' + f'{chips_html}' + f'
    ' + f'
    ' + f'{mobile_filter}' + f'
    ' + ) + + +def _mobile_filter_content_html(ctx: dict, prefix: str) -> str: + """Build the expanded mobile filter panel contents.""" + from shared.utils import route_prefix + + search = ctx.get("search", "") + selected_labels = ctx.get("selected_labels", []) + selected_stickers = ctx.get("selected_stickers", []) + selected_brands = ctx.get("selected_brands", []) + current_local_href = ctx.get("current_local_href", "/") + hx_select = ctx.get("hx_select_search", "#main-panel") + sort_options = ctx.get("sort_options", []) + sort = ctx.get("sort", "") + liked = ctx.get("liked", False) + liked_count = ctx.get("liked_count", 0) + labels = ctx.get("labels", []) + stickers = ctx.get("stickers", []) + brands = ctx.get("brands", []) + asset_url_fn = ctx.get("asset_url") + qs_fn = ctx.get("qs_filter") + + parts = [] + + # Sort options + if sort_options: + parts.append(_sort_stickers_html(sort_options, sort, ctx, mobile=True)) + + # Clear filters button + has_filters = search or selected_labels or selected_stickers or selected_brands + if has_filters and callable(qs_fn): + clear_url = prefix + current_local_href + qs_fn({"clear_filters": True}) + parts.append( + f'' + ) + + # Like + labels row + parts.append('
    ') + parts.append(_like_filter_html(liked, liked_count, ctx, mobile=True)) + if labels: + parts.append(_labels_filter_html(labels, selected_labels, ctx, prefix="nav-labels", mobile=True)) + parts.append("
    ") + + # Stickers + if stickers: + parts.append(_stickers_filter_html(stickers, selected_stickers, ctx, mobile=True)) + + # Brands + if brands: + parts.append(_brand_filter_html(brands, selected_brands, ctx, mobile=True)) + + return "".join(parts) + + +def _sort_stickers_html(sort_options: list, current_sort: str, ctx: dict, mobile: bool = False) -> str: + """Render sort option stickers.""" + asset_url_fn = ctx.get("asset_url") + current_local_href = ctx.get("current_local_href", "/") + hx_select = ctx.get("hx_select_search", "#main-panel") + qs_fn = ctx.get("qs_filter") + from shared.utils import route_prefix + prefix = route_prefix() + + parts = ['
    '] + for k, label, icon in sort_options: + if callable(qs_fn): + href = prefix + current_local_href + qs_fn({"sort": k}) + else: + href = "#" + active = (k == current_sort) + ring = " ring-2 ring-emerald-500 rounded" if active else "" + src = asset_url_fn(icon) if callable(asset_url_fn) else icon + parts.append( + f'' + f'{escape(label)}' + f'{escape(label)}' + ) + parts.append("
    ") + return "".join(parts) + + +def _like_filter_html(liked: bool, liked_count: int, ctx: dict, mobile: bool = False) -> str: + """Render the like filter toggle.""" + current_local_href = ctx.get("current_local_href", "/") + hx_select = ctx.get("hx_select_search", "#main-panel") + qs_fn = ctx.get("qs_filter") + from shared.utils import route_prefix + prefix = route_prefix() + + if callable(qs_fn): + href = prefix + current_local_href + qs_fn({"liked": not liked}) + else: + href = "#" + + icon_cls = "fa-solid fa-heart text-red-500" if liked else "fa-regular fa-heart text-stone-400" + size = "text-[40px]" if mobile else "text-2xl" + return ( + f'' + f'' + ) + + +def _labels_filter_html(labels: list, selected: list, ctx: dict, *, + prefix: str = "nav-labels", mobile: bool = False) -> str: + """Render label filter buttons.""" + asset_url_fn = ctx.get("asset_url") + current_local_href = ctx.get("current_local_href", "/") + hx_select = ctx.get("hx_select_search", "#main-panel") + qs_fn = ctx.get("qs_filter") + from shared.utils import route_prefix + rp = route_prefix() + + parts = [] + for lb in labels: + name = lb.get("name", "") + is_sel = name in selected + if callable(qs_fn): + new_sel = [s for s in selected if s != name] if is_sel else selected + [name] + href = rp + current_local_href + qs_fn({"labels": new_sel}) + else: + href = "#" + ring = " ring-2 ring-emerald-500 rounded" if is_sel else "" + src = asset_url_fn(f"{prefix}/{name}.svg") if callable(asset_url_fn) else "" + parts.append( + f'' + f'{escape(name)}' + ) + return "".join(parts) + + +def _stickers_filter_html(stickers: list, selected: list, ctx: dict, mobile: bool = False) -> str: + """Render sticker filter grid.""" + asset_url_fn = ctx.get("asset_url") + current_local_href = ctx.get("current_local_href", "/") + hx_select = ctx.get("hx_select_search", "#main-panel") + qs_fn = ctx.get("qs_filter") + from shared.utils import route_prefix + rp = route_prefix() + + parts = ['
    '] + for st in stickers: + name = st.get("name", "") + count = st.get("count", 0) + is_sel = name in selected + if callable(qs_fn): + new_sel = [s for s in selected if s != name] if is_sel else selected + [name] + href = rp + current_local_href + qs_fn({"stickers": new_sel}) + else: + href = "#" + ring = " ring-2 ring-emerald-500 rounded" if is_sel else "" + src = asset_url_fn(f"stickers/{name}.svg") if callable(asset_url_fn) else "" + cls = "text-[10px] text-stone-500" if count != 0 else "text-md text-red-500 font-bold" + parts.append( + f'' + f'{escape(name)}' + f'{count}' + ) + parts.append("
    ") + return "".join(parts) + + +def _brand_filter_html(brands: list, selected: list, ctx: dict, mobile: bool = False) -> str: + """Render brand filter checkboxes.""" + current_local_href = ctx.get("current_local_href", "/") + hx_select = ctx.get("hx_select_search", "#main-panel") + qs_fn = ctx.get("qs_filter") + from shared.utils import route_prefix + rp = route_prefix() + + parts = ['
    '] + for br in brands: + name = br.get("name", "") + count = br.get("count", 0) + is_sel = name in selected + if callable(qs_fn): + new_sel = [s for s in selected if s != name] if is_sel else selected + [name] + href = rp + current_local_href + qs_fn({"brands": new_sel}) + else: + href = "#" + bg = " bg-yellow-200" if is_sel else "" + cls = "text-md" if count else "text-md text-red-500" + parts.append( + f'' + f'
    {escape(name)}
    ' + f'
    {count}
    ' + ) + parts.append("
    ") + return "".join(parts) + + +def _subcategory_selector_html(subs: list, top_href: str, current_sub: str, ctx: dict) -> str: + """Render subcategory vertical nav.""" + hx_select = ctx.get("hx_select_search", "#main-panel") + from shared.utils import route_prefix + rp = route_prefix() + + parts = ['
    '] + # "All" link + parts.append( + f'All' + ) + for sub in subs: + slug = sub.get("slug", "") + name = sub.get("name", "") + href = sub.get("href", "") + active = (slug == current_sub) + parts.append( + f'{escape(name)}' + ) + parts.append("
    ") + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Product detail page content +# --------------------------------------------------------------------------- + +def _product_detail_html(d: dict, ctx: dict) -> str: + """Build product detail main panel content.""" + from quart import url_for + from shared.browser.app.csrf import generate_csrf_token + + asset_url_fn = ctx.get("asset_url") + user = ctx.get("user") + liked_by_current_user = ctx.get("liked_by_current_user", False) + csrf = generate_csrf_token() + + images = d.get("images", []) + labels = d.get("labels", []) + stickers = d.get("stickers", []) + brand = d.get("brand", "") + slug = d.get("slug", "") + + # Gallery + if images: + # Like button + like_html = "" + if user: + like_html = _like_button_html(slug, liked_by_current_user, csrf, ctx) + + # Main image + labels + labels_overlay = "".join( + f'' + for l in labels + ) if callable(asset_url_fn) else "" + + gallery_html = ( + f'
    ' + f'{like_html}' + f'
    ' + f'{escape(d.get(' + f'{labels_overlay}
    ' + f'
    {escape(brand)}
    ' + ) + + # Prev/next buttons + if len(images) > 1: + gallery_html += ( + '' + '' + ) + + gallery_html += "
    " + + # Thumbnails + if len(images) > 1: + thumbs = "".join( + f'' + f'' + for i, u in enumerate(images) + ) + gallery_html += f'
    {thumbs}
    ' + else: + like_html = "" + if user: + like_html = _like_button_html(slug, liked_by_current_user, csrf, ctx) + gallery_html = ( + f'
    ' + f'{like_html}No image
    ' + ) + + # Stickers below gallery + stickers_html = "" + if stickers and callable(asset_url_fn): + sticker_parts = "".join( + f'{escape(s)}' + for s in stickers + ) + stickers_html = f'
    {sticker_parts}
    ' + + # Right column: prices, description, sections + pr = _set_prices(d) + details_parts = ['
    '] + + # Unit price / case size extras + extras = [] + ppu = d.get("price_per_unit") or d.get("price_per_unit_raw") + if ppu: + extras.append(f'
    Unit 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.append(f'
    Case size: {d["case_size_raw"]}
    ') + if extras: + details_parts.append('
    ' + "".join(extras) + "
    ") + + # Description + desc_short = d.get("description_short") + desc_html = d.get("description_html") + if desc_short or desc_html: + details_parts.append('
    ') + if desc_short: + details_parts.append(f'

    {escape(desc_short)}

    ') + if desc_html: + details_parts.append(f'
    {desc_html}
    ') + details_parts.append("
    ") + + # Sections (expandable) + sections = d.get("sections", []) + if sections: + details_parts.append('
    ') + for sec in sections: + details_parts.append( + f'
    ' + f'' + f'{escape(sec.get("title", ""))}' + f'' + f'
    {sec.get("html", "")}
    ' + ) + details_parts.append("
    ") + + details_parts.append("
    ") + + return ( + f'
    ' + f'
    {gallery_html}{stickers_html}
    ' + f'{"".join(details_parts)}
    ' + ) + + +# --------------------------------------------------------------------------- +# Product meta (OpenGraph, JSON-LD) +# --------------------------------------------------------------------------- + +def _product_meta_html(d: dict, ctx: dict) -> str: + """Build product meta tags for .""" + import json + from quart import request + + title = d.get("title", "") + desc_source = d.get("description_short") or "" + if not desc_source and d.get("description_html"): + # Strip HTML tags (simple approach) + import re + desc_source = re.sub(r"<[^>]+>", "", d.get("description_html", "")) + description = desc_source.strip().replace("\n", " ")[:160] + image_url = d.get("image") or (d.get("images", [None])[0] if d.get("images") else None) + canonical = request.url if request else "" + brand = d.get("brand", "") + sku = d.get("sku", "") + price = d.get("special_price") or d.get("regular_price") or d.get("rrp") + price_currency = d.get("special_price_currency") or d.get("regular_price_currency") or d.get("rrp_currency") + + parts = [f"{escape(title)}"] + parts.append(f'') + if canonical: + parts.append(f'') + + # OpenGraph + site_title = ctx.get("base_title", "") + parts.append(f'') + parts.append('') + parts.append(f'') + parts.append(f'') + if canonical: + parts.append(f'') + if image_url: + parts.append(f'') + if price and price_currency: + parts.append(f'') + parts.append(f'') + if brand: + parts.append(f'') + + # Twitter + card_type = "summary_large_image" if image_url else "summary" + parts.append(f'') + parts.append(f'') + parts.append(f'') + if image_url: + parts.append(f'') + + # JSON-LD + jsonld = { + "@context": "https://schema.org", + "@type": "Product", + "name": title, + "image": image_url, + "description": description, + "sku": sku, + "url": canonical, + } + if brand: + jsonld["brand"] = {"@type": "Brand", "name": brand} + if price and price_currency: + jsonld["offers"] = { + "@type": "Offer", + "price": price, + "priceCurrency": price_currency, + "url": canonical, + "availability": "https://schema.org/InStock", + } + parts.append(f'') + + return "\n".join(parts) + + +# --------------------------------------------------------------------------- +# Market cards (all markets / page markets) +# --------------------------------------------------------------------------- + +def _market_card_html(market: Any, page_info: dict, *, show_page_badge: bool = True, + post_slug: str = "") -> str: + """Render a single market card.""" + from shared.infrastructure.urls import market_url + + name = getattr(market, "name", "") + description = getattr(market, "description", "") + slug = getattr(market, "slug", "") + container_id = getattr(market, "container_id", None) + + if show_page_badge and page_info: + pi = page_info.get(container_id, {}) + p_slug = pi.get("slug", "") + p_title = pi.get("title", "") + market_href = market_url(f"/{p_slug}/{slug}/") if p_slug else "" + else: + p_slug = post_slug + p_title = "" + market_href = market_url(f"/{post_slug}/{slug}/") if post_slug else "" + + parts = ['
    '] + parts.append("
    ") + if market_href: + parts.append(f'

    {escape(name)}

    ') + else: + parts.append(f'

    {escape(name)}

    ') + if description: + parts.append(f'

    {escape(description)}

    ') + parts.append("
    ") + + if show_page_badge and p_title: + badge_href = market_url(f"/{p_slug}/") + parts.append( + f'' + ) + + parts.append("
    ") + return "".join(parts) + + +def _market_cards_html(markets: list, page_info: dict, page: int, has_more: bool, + next_url: str, *, show_page_badge: bool = True, + post_slug: str = "") -> str: + """Render market cards with infinite scroll sentinel.""" + 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( + f'' + ) + return "".join(parts) + + +# --------------------------------------------------------------------------- +# OOB header helpers +# --------------------------------------------------------------------------- + +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 ( + f'
    ' + f'
    {row_html}' + f'
    ' + ) + + +# =========================================================================== +# PUBLIC API +# =========================================================================== + + +# --------------------------------------------------------------------------- +# All markets +# --------------------------------------------------------------------------- + +async def render_all_markets_page(ctx: dict, markets: list, has_more: bool, + page_info: dict, page: int) -> str: + """Full page: all markets listing.""" + from quart import url_for + from shared.utils import route_prefix + + prefix = route_prefix() + next_url = prefix + url_for("all_markets.markets_fragment", page=page + 1) + + if markets: + cards = _market_cards_html(markets, page_info, page, has_more, next_url) + content = f'
    {cards}
    ' + else: + content = ('
    ' + '' + '

    No markets available

    ') + content += '
    ' + + hdr = root_header_html(ctx) + return full_page(ctx, header_rows_html=hdr, content_html=content) + + +async def render_all_markets_oob(ctx: dict, markets: list, has_more: bool, + page_info: dict, page: int) -> str: + """OOB response: all markets listing.""" + from quart import url_for + from shared.utils import route_prefix + + prefix = route_prefix() + next_url = prefix + url_for("all_markets.markets_fragment", page=page + 1) + + if markets: + cards = _market_cards_html(markets, page_info, page, has_more, next_url) + content = f'
    {cards}
    ' + else: + content = ('
    ' + '' + '

    No markets available

    ') + content += '
    ' + + oobs = root_header_html(ctx, oob=True) + return oob_page(ctx, oobs_html=oobs, content_html=content) + + +async def render_all_markets_cards(markets: list, has_more: bool, + page_info: dict, page: int) -> str: + """Pagination fragment: all markets cards.""" + from quart import url_for + from shared.utils import route_prefix + + prefix = route_prefix() + next_url = prefix + url_for("all_markets.markets_fragment", page=page + 1) + return _market_cards_html(markets, page_info, page, has_more, next_url) + + +# --------------------------------------------------------------------------- +# Page markets +# --------------------------------------------------------------------------- + +async def render_page_markets_page(ctx: dict, markets: list, has_more: bool, + page: int) -> str: + """Full page: page-scoped markets listing.""" + from quart import url_for + from shared.utils import route_prefix + + prefix = route_prefix() + post = ctx.get("post", {}) + post_slug = post.get("slug", "") + next_url = prefix + url_for("page_markets.markets_fragment", page=page + 1) + + if markets: + cards = _market_cards_html(markets, {}, page, has_more, next_url, + show_page_badge=False, post_slug=post_slug) + content = f'
    {cards}
    ' + else: + content = ('
    ' + '' + '

    No markets for this page

    ') + content += '
    ' + + hdr = root_header_html(ctx) + hdr += sexp( + '(div :id "root-header-child" :class "w-full" (raw! ph))', + ph=_post_header_html(ctx), + ) + return full_page(ctx, header_rows_html=hdr, content_html=content) + + +async def render_page_markets_oob(ctx: dict, markets: list, has_more: bool, + page: int) -> str: + """OOB response: page-scoped markets.""" + from quart import url_for + from shared.utils import route_prefix + + prefix = route_prefix() + post = ctx.get("post", {}) + post_slug = post.get("slug", "") + next_url = prefix + url_for("page_markets.markets_fragment", page=page + 1) + + if markets: + cards = _market_cards_html(markets, {}, page, has_more, next_url, + show_page_badge=False, post_slug=post_slug) + content = f'
    {cards}
    ' + else: + content = ('
    ' + '' + '

    No markets for this page

    ') + content += '
    ' + + oobs = _oob_header_html("post-header-child", "market-header-child", "") + oobs += _post_header_html(ctx, oob=True) + return oob_page(ctx, oobs_html=oobs, content_html=content) + + +async def render_page_markets_cards(markets: list, has_more: bool, + page: int, post_slug: str) -> str: + """Pagination fragment: page-scoped markets cards.""" + from quart import url_for + from shared.utils import route_prefix + + prefix = route_prefix() + next_url = prefix + url_for("page_markets.markets_fragment", page=page + 1) + return _market_cards_html(markets, {}, page, has_more, next_url, + show_page_badge=False, post_slug=post_slug) + + +# --------------------------------------------------------------------------- +# Market landing page +# --------------------------------------------------------------------------- + +async def render_market_home_page(ctx: dict) -> str: + """Full page: market landing page (post content).""" + post = ctx.get("post") or {} + content = _market_landing_content(post) + + hdr = root_header_html(ctx) + hdr += sexp( + '(div :id "root-header-child" :class "w-full" (raw! ph (raw! mh)))', + ph=_post_header_html(ctx), + mh=_market_header_html(ctx), + ) + menu = _mobile_nav_panel_html(ctx) + return full_page(ctx, header_rows_html=hdr, content_html=content, menu_html=menu) + + +async def render_market_home_oob(ctx: dict) -> str: + """OOB response: market landing page.""" + post = ctx.get("post") or {} + content = _market_landing_content(post) + + oobs = _oob_header_html("post-header-child", "market-header-child", + _market_header_html(ctx)) + oobs += _post_header_html(ctx, oob=True) + menu = _mobile_nav_panel_html(ctx) + return oob_page(ctx, oobs_html=oobs, content_html=content, menu_html=menu) + + +def _market_landing_content(post: dict) -> str: + """Build market landing page content (excerpt + feature image + html).""" + parts = ['
    '] + if post.get("custom_excerpt"): + parts.append(f'
    {post["custom_excerpt"]}
    ') + if post.get("feature_image"): + parts.append( + f'
    ' + f'
    ' + ) + if post.get("html"): + parts.append(f'
    {post["html"]}
    ') + parts.append('
    ') + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Browse page +# --------------------------------------------------------------------------- + +async def render_browse_page(ctx: dict) -> str: + """Full page: product browse with filters.""" + cards_html = _product_cards_html(ctx) + content = f'
    {cards_html}
    ' + + hdr = root_header_html(ctx) + hdr += sexp( + '(div :id "root-header-child" :class "w-full" (raw! ph (raw! mh)))', + ph=_post_header_html(ctx), + mh=_market_header_html(ctx), + ) + menu = _mobile_nav_panel_html(ctx) + filter_html = _mobile_filter_summary_html(ctx) + aside_html = _desktop_filter_html(ctx) + + return full_page(ctx, header_rows_html=hdr, content_html=content, + menu_html=menu, filter_html=filter_html, aside_html=aside_html) + + +async def render_browse_oob(ctx: dict) -> str: + """OOB response: product browse.""" + cards_html = _product_cards_html(ctx) + content = f'
    {cards_html}
    ' + + oobs = _oob_header_html("post-header-child", "market-header-child", + _market_header_html(ctx)) + oobs += _post_header_html(ctx, oob=True) + menu = _mobile_nav_panel_html(ctx) + filter_html = _mobile_filter_summary_html(ctx) + aside_html = _desktop_filter_html(ctx) + + return oob_page(ctx, oobs_html=oobs, content_html=content, + menu_html=menu, filter_html=filter_html, aside_html=aside_html) + + +async def render_browse_cards(ctx: dict) -> str: + """Pagination fragment: product cards only.""" + return _product_cards_html(ctx) + + +# --------------------------------------------------------------------------- +# Product detail +# --------------------------------------------------------------------------- + +async def render_product_page(ctx: dict, d: dict) -> str: + """Full page: product detail.""" + content = _product_detail_html(d, ctx) + meta = _product_meta_html(d, ctx) + + hdr = root_header_html(ctx) + hdr += sexp( + '(div :id "root-header-child" :class "w-full" (raw! ph (raw! mh (raw! prh))))', + ph=_post_header_html(ctx), + mh=_market_header_html(ctx), + prh=_product_header_html(ctx, d), + ) + return full_page(ctx, header_rows_html=hdr, content_html=content, meta_html=meta) + + +async def render_product_oob(ctx: dict, d: dict) -> str: + """OOB response: product detail.""" + content = _product_detail_html(d, ctx) + + oobs = _market_header_html(ctx, oob=True) + oobs += _oob_header_html("market-header-child", "product-header-child", + _product_header_html(ctx, d)) + menu = _mobile_nav_panel_html(ctx) + return oob_page(ctx, oobs_html=oobs, content_html=content, menu_html=menu) + + +# --------------------------------------------------------------------------- +# Product admin +# --------------------------------------------------------------------------- + +async def render_product_admin_page(ctx: dict, d: dict) -> str: + """Full page: product admin.""" + content = _product_detail_html(d, ctx) + + hdr = root_header_html(ctx) + hdr += sexp( + '(div :id "root-header-child" :class "w-full" (raw! ph (raw! mh (raw! prh (raw! pah)))))', + ph=_post_header_html(ctx), + mh=_market_header_html(ctx), + prh=_product_header_html(ctx, d), + pah=_product_admin_header_html(ctx, d), + ) + return full_page(ctx, header_rows_html=hdr, content_html=content) + + +async def render_product_admin_oob(ctx: dict, d: dict) -> str: + """OOB response: product admin.""" + content = _product_detail_html(d, ctx) + + oobs = _product_header_html(ctx, d, oob=True) + oobs += _oob_header_html("product-header-child", "product-admin-header-child", + _product_admin_header_html(ctx, d)) + return oob_page(ctx, oobs_html=oobs, content_html=content) + + +def _product_admin_header_html(ctx: dict, d: dict, *, oob: bool = False) -> str: + """Build product admin header row.""" + from quart import url_for + + 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, + ) + + +# --------------------------------------------------------------------------- +# Market admin +# --------------------------------------------------------------------------- + +async def render_market_admin_page(ctx: dict) -> str: + """Full page: market admin.""" + content = "market admin" + + hdr = root_header_html(ctx) + hdr += sexp( + '(div :id "root-header-child" :class "w-full" (raw! ph (raw! mh (raw! mah))))', + ph=_post_header_html(ctx), + mh=_market_header_html(ctx), + mah=_market_admin_header_html(ctx), + ) + return full_page(ctx, header_rows_html=hdr, content_html=content) + + +async def render_market_admin_oob(ctx: dict) -> str: + """OOB response: market admin.""" + content = "market admin" + + oobs = _market_header_html(ctx, oob=True) + oobs += _oob_header_html("market-header-child", "market-admin-header-child", + _market_admin_header_html(ctx)) + return oob_page(ctx, oobs_html=oobs, content_html=content) + + +def _market_admin_header_html(ctx: dict, *, oob: bool = False) -> str: + """Build market admin header row.""" + 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, + ) diff --git a/orders/app.py b/orders/app.py index 875255c..b68da41 100644 --- a/orders/app.py +++ b/orders/app.py @@ -1,5 +1,6 @@ from __future__ import annotations import path_setup # noqa: F401 # adds shared/ to sys.path +import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file from pathlib import Path from types import SimpleNamespace @@ -69,6 +70,10 @@ def create_app() -> "Quart": app.jinja_loader, ]) + # Load orders-specific s-expression components + from sexp_components import load_orders_components + load_orders_components() + app.register_blueprint(register_fragments()) app.register_blueprint(register_actions()) app.register_blueprint(register_data()) diff --git a/orders/bp/order/routes.py b/orders/bp/order/routes.py index c84f85f..56e2a9a 100644 --- a/orders/bp/order/routes.py +++ b/orders/bp/order/routes.py @@ -9,6 +9,7 @@ from shared.browser.app.payments.sumup import create_checkout as sumup_create_ch from shared.config import config from shared.infrastructure.cart_identity import current_cart_identity +from shared.sexp.page import get_template_context from services.check_sumup_status import check_sumup_status from shared.browser.app.utils.htmx import is_htmx_request @@ -46,10 +47,16 @@ def register() -> Blueprint: order = result.scalar_one_or_none() if not order: return await make_response("Order not found", 404) + + from sexp_components import render_order_page, render_order_oob + + ctx = await get_template_context() + calendar_entries = ctx.get("calendar_entries") + if not is_htmx_request(): - html = await render_template("_types/order/index.html", order=order) + html = await render_order_page(ctx, order, calendar_entries, url_for) else: - html = await render_template("_types/order/_oob_elements.html", order=order) + html = await render_order_oob(ctx, order, calendar_entries, url_for) return await make_response(html) @bp.get("/pay/") diff --git a/orders/bp/orders/routes.py b/orders/bp/orders/routes.py index 5cc33e0..7841975 100644 --- a/orders/bp/orders/routes.py +++ b/orders/bp/orders/routes.py @@ -116,20 +116,30 @@ def register(url_prefix: str) -> Blueprint: result = await g.s.execute(stmt) orders = result.scalars().all() - context = { - "orders": orders, - "page": page, - "total_pages": total_pages, - "search": search, - "search_count": total_count, - } + from shared.sexp.page import get_template_context + from sexp_components import ( + render_orders_page, + render_orders_rows, + render_orders_oob, + ) + + ctx = await get_template_context() + qs_fn = makeqs_factory() if not is_htmx_request(): - html = await render_template("_types/orders/index.html", **context) + html = await render_orders_page( + ctx, orders, page, total_pages, search, total_count, + url_for, qs_fn, + ) elif page > 1: - html = await render_template("_types/orders/_rows.html", **context) + html = await render_orders_rows( + ctx, orders, page, total_pages, url_for, qs_fn, + ) else: - html = await render_template("_types/orders/_oob_elements.html", **context) + html = await render_orders_oob( + ctx, orders, page, total_pages, search, total_count, + url_for, qs_fn, + ) resp = await make_response(html) resp.headers["Hx-Push-Url"] = _current_url_without_page() diff --git a/orders/sexp_components.py b/orders/sexp_components.py new file mode 100644 index 0000000..212bd34 --- /dev/null +++ b/orders/sexp_components.py @@ -0,0 +1,385 @@ +""" +Orders service s-expression page components. + +Each function renders a complete page section (full page, OOB, or pagination) +using shared s-expression components. Called from route handlers in place +of ``render_template()``. +""" +from __future__ import annotations + +from typing import Any + +from shared.sexp.jinja_bridge import sexp, register_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.sexp.page import HAMBURGER_HTML +from shared.infrastructure.urls import market_product_url + + +# --------------------------------------------------------------------------- +# Service-specific component definitions +# --------------------------------------------------------------------------- + +def load_orders_components() -> None: + """Register orders-specific s-expression components (placeholder for future).""" + pass + + +# --------------------------------------------------------------------------- +# Header helpers (shared auth + orders-specific) +# --------------------------------------------------------------------------- + +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", ""), + ) + account_nav_html = ctx.get("account_nav_html", "") + if account_nav_html: + html += account_nav_html + return html + + +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, + ) + + +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, + ) + + +# --------------------------------------------------------------------------- +# Orders list rendering +# --------------------------------------------------------------------------- + +def _order_row_html(order: Any, detail_url: str) -> str: + """Render a single order as desktop table row + mobile card.""" + status = order.status or "pending" + sl = status.lower() + pill = ( + "border-emerald-300 bg-emerald-50 text-emerald-700" if sl == "paid" + 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" + ) + 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}" + + return ( + # Desktop row + f'' + f'#{order.id}' + f'{created}' + f'{order.description or ""}' + f'{total}' + f'{status}' + f'View' + # Mobile row + f'
    ' + f'
    #{order.id}' + f'{status}
    ' + f'
    {created}
    ' + f'
    {total}
    ' + f'View
    ' + ) + + +def _orders_rows_html(orders: list, page: int, total_pages: int, + url_for_fn: Any, qs_fn: Any) -> str: + """Render order rows + infinite scroll sentinel.""" + from shared.utils import route_prefix + pfx = route_prefix() + + parts = [ + _order_row_html(o, pfx + url_for_fn("orders.order.order_detail", order_id=o.id)) + for o in orders + ] + + 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}, + )) + else: + parts.append('End of results') + + return "".join(parts) + + +def _orders_main_panel_html(orders: list, rows_html: str) -> str: + """Main panel with table or empty state.""" + if not orders: + return ( + '
    ' + '
    ' + 'No orders yet.
    ' + ) + return ( + '
    ' + '
    ' + '' + '' + '' + '' + '' + '' + '' + '' + f'{rows_html}
    OrderCreatedDescriptionTotalStatus
    ' + ) + + +def _orders_summary_html(ctx: dict) -> str: + """Filter section for orders list.""" + return ( + '
    ' + '

    Recent orders placed via the checkout.

    ' + f'
    {search_mobile_html(ctx)}
    ' + '
    ' + ) + + +# --------------------------------------------------------------------------- +# Public API: orders list +# --------------------------------------------------------------------------- + +async def render_orders_page(ctx: dict, orders: list, page: int, + total_pages: int, search: str | None, + search_count: int, url_for_fn: Any, + qs_fn: Any) -> str: + """Full page: orders list.""" + from shared.utils import route_prefix + + ctx["search"] = search + ctx["search_count"] = search_count + list_url = route_prefix() + url_for_fn("orders.list_orders") + + rows = _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn) + 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), + ) + + return full_page(ctx, header_rows_html=hdr, + filter_html=_orders_summary_html(ctx), + aside_html=search_desktop_html(ctx), + content_html=main) + + +async def render_orders_rows(ctx: dict, orders: list, page: int, + total_pages: int, url_for_fn: Any, + qs_fn: Any) -> str: + """Pagination: just the table rows.""" + return _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn) + + +async def render_orders_oob(ctx: dict, orders: list, page: int, + total_pages: int, search: str | None, + search_count: int, url_for_fn: Any, + qs_fn: Any) -> str: + """OOB response for HTMX navigation to orders list.""" + from shared.utils import route_prefix + + ctx["search"] = search + ctx["search_count"] = search_count + list_url = route_prefix() + url_for_fn("orders.list_orders") + + rows = _orders_rows_html(orders, page, total_pages, url_for_fn, qs_fn) + main = _orders_main_panel_html(orders, rows) + + 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), + ) + + root_header_html(ctx, oob=True) + ) + + return oob_page(ctx, oobs_html=oobs, + filter_html=_orders_summary_html(ctx), + aside_html=search_desktop_html(ctx), + content_html=main) + + +# --------------------------------------------------------------------------- +# Single order detail +# --------------------------------------------------------------------------- + +def _order_items_html(order: Any) -> str: + """Render order items list.""" + if not order or not order.items: + return "" + items = [] + for item in order.items: + prod_url = market_product_url(item.product_slug) + img = ( + f'{item.product_title or ' + if item.product_image else + '
    No image
    ' + ) + items.append( + f'
  • ' + f'
    {img}
    ' + f'
    ' + f'

    {item.product_title or "Unknown product"}

    ' + f'

    Product ID: {item.product_id}

    ' + f'

    Qty: {item.quantity}

    ' + f'

    {item.currency or order.currency or "GBP"} {item.unit_price or 0:.2f}

    ' + f'
  • ' + ) + return ( + '
    ' + '

    Items

    ' + f'
      {"".join(items)}
    ' + ) + + +def _calendar_items_html(calendar_entries: list | None) -> str: + """Render calendar bookings for an order.""" + if not calendar_entries: + return "" + items = [] + for e in calendar_entries: + st = e.state or "" + pill = ( + "bg-emerald-100 text-emerald-800" if st == "confirmed" + else "bg-amber-100 text-amber-800" if st == "provisional" + else "bg-blue-100 text-blue-800" if st == "ordered" + else "bg-stone-100 text-stone-700" + ) + 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( + f'
  • ' + f'
    {e.name}' + f'' + f'{st.capitalize()}
    ' + f'
    {ds}
    ' + f'
    \u00a3{e.cost or 0:.2f}
  • ' + ) + return ( + '
    ' + '

    Calendar bookings in this order

    ' + f'
      {"".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, + ) + return f'
    {summary}{_order_items_html(order)}{_calendar_items_html(calendar_entries)}
    ' + + +def _order_filter_html(order: Any, list_url: str, recheck_url: str, + pay_url: str, csrf_token: str) -> str: + """Filter section for single order detail.""" + created = order.created_at.strftime("%-d %b %Y, %H:%M") if order.created_at else "\u2014" + status = order.status or "pending" + pay = ( + f'' + f'Open payment page' + ) if status != "paid" else "" + + return ( + '
    ' + f'

    Placed {created} · Status: {status}

    ' + '
    ' + f'All orders' + f'
    ' + f'
    ' + f'{pay}
    ' + ) + + +async def render_order_page(ctx: dict, order: Any, + calendar_entries: list | None, + url_for_fn: Any) -> str: + """Full page: single order detail.""" + from shared.utils import route_prefix + from shared.browser.app.csrf import generate_csrf_token + + pfx = route_prefix() + detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id) + list_url = pfx + url_for_fn("orders.list_orders") + recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id) + pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id) + + main = _order_main_html(order, calendar_entries) + filt = _order_filter_html(order, list_url, recheck_url, pay_url, generate_csrf_token()) + + # 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, + ) + 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, + ) + + return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=main) + + +async def render_order_oob(ctx: dict, order: Any, + calendar_entries: list | None, + url_for_fn: Any) -> str: + """OOB response for single order detail.""" + from shared.utils import route_prefix + from shared.browser.app.csrf import generate_csrf_token + + pfx = route_prefix() + detail_url = pfx + url_for_fn("orders.order.order_detail", order_id=order.id) + list_url = pfx + url_for_fn("orders.list_orders") + recheck_url = pfx + url_for_fn("orders.order.order_recheck", order_id=order.id) + pay_url = pfx + url_for_fn("orders.order.order_pay", order_id=order.id) + + 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, + ) + 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) + + root_header_html(ctx, oob=True) + ) + + return oob_page(ctx, oobs_html=oobs, filter_html=filt, content_html=main) diff --git a/shared/infrastructure/context.py b/shared/infrastructure/context.py index a98227c..09f268b 100644 --- a/shared/infrastructure/context.py +++ b/shared/infrastructure/context.py @@ -16,6 +16,51 @@ from shared.utils import host_url from shared.browser.app.utils import current_route_relative_path +def _qs_filter_fn(): + """Build a qs_filter(dict) wrapper for sexp components, or None. + + Sexp components call ``qs_fn({"page": 2})``, ``qs_fn({"sort": "az"})``, + ``qs_fn({"labels": ["organic", "local"]})``, etc. + + Simple keys (page, sort, search, liked, clear_filters) are forwarded + to ``makeqs(**kwargs)``. List-valued keys (labels, stickers, brands) + represent *replacement* sets, so we rebuild the querystring from the + current base with those overridden. + """ + factory = getattr(g, "makeqs_factory", None) + if not factory: + return None + makeqs = factory() + + def _qs(d: dict) -> str: + from shared.browser.app.filters.qs_base import build_qs + + # Collect list-valued overrides + list_overrides = {} + for plural, singular in (("labels", "label"), ("stickers", "sticker"), ("brands", "brand")): + if plural in d: + list_overrides[singular] = list(d[plural] or []) + + simple = {k: v for k, v in d.items() + if k in ("page", "sort", "search", "liked", "clear_filters")} + + if not list_overrides: + return makeqs(**simple) + + # For list overrides: get the base qs, parse out the overridden keys, + # then rebuild with the new values. + base_qs = makeqs(**simple) + from urllib.parse import parse_qsl, urlencode + params = [(k, v) for k, v in parse_qsl(base_qs.lstrip("?")) + if k not in list_overrides] + for singular, vals in list_overrides.items(): + for v in vals: + params.append((singular, v)) + return ("?" + urlencode(params)) if params else "" + + return _qs + + async def base_context() -> dict: """ Common template variables available in every app. @@ -50,6 +95,7 @@ async def base_context() -> dict: ("price-desc", "\u00a3 high\u2192low", "order/h-l.svg"), ], "zap_filter": zap_filter, + "qs_filter": _qs_filter_fn(), "print": print, "base_url": base_url, "base_title": config()["title"], diff --git a/shared/sexp/components.py b/shared/sexp/components.py index 7513f49..92ac65e 100644 --- a/shared/sexp/components.py +++ b/shared/sexp/components.py @@ -23,6 +23,18 @@ def load_shared_components() -> None: register_components(_POST_CARD) register_components(_BASE_SHELL) register_components(_ERROR_PAGE) + # Phase 6: layout infrastructure + register_components(_APP_SHELL) + register_components(_APP_LAYOUT) + register_components(_OOB_RESPONSE) + register_components(_HEADER_ROW) + register_components(_MENU_ROW) + register_components(_NAV_LINK) + register_components(_INFINITE_SCROLL) + register_components(_STATUS_PILL) + register_components(_SEARCH_MOBILE) + register_components(_SEARCH_DESKTOP) + register_components(_ORDER_SUMMARY_CARD) # --------------------------------------------------------------------------- @@ -298,3 +310,425 @@ _ERROR_PAGE = ''' (div :class "flex justify-center" (img :src image :width "300" :height "300")))))) ''' + + +# =================================================================== +# Phase 6: Layout infrastructure components +# =================================================================== + +# --------------------------------------------------------------------------- +# ~app-shell — full HTML document with all required CSS/JS assets +# --------------------------------------------------------------------------- +# Replaces: _types/root/index.html ... shell +# +# This includes htmx, hyperscript, tailwind, fontawesome, prism, and +# all shared CSS/JS. ``~base-shell`` remains the lightweight error-page +# shell; ``~app-shell`` is for real app pages. +# +# Usage: +# sexp('(~app-shell :title t :asset-url a :meta-html m :body-html b)', **ctx) +# --------------------------------------------------------------------------- + +_APP_SHELL = r''' +(defcomp ~app-shell (&key title asset-url meta-html body-html body-end-html) + (<> + (raw! "") + (html :lang "en" + (head + (meta :charset "utf-8") + (meta :name "viewport" :content "width=device-width, initial-scale=1") + (meta :name "robots" :content "index,follow") + (meta :name "theme-color" :content "#ffffff") + (title title) + (when meta-html (raw! meta-html)) + (style "@media (min-width: 768px) { .js-mobile-sentinel { display:none !important; } }") + (link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/basics.css")) + (link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/cards.css")) + (link :rel "stylesheet" :type "text/css" :href (str asset-url "/styles/blog-content.css")) + (script :src "https://unpkg.com/htmx.org@2.0.8") + (script :src "https://unpkg.com/hyperscript.org@0.9.12") + (script :src "https://cdn.tailwindcss.com") + (link :rel "stylesheet" :href (str asset-url "/fontawesome/css/all.min.css")) + (link :rel "stylesheet" :href (str asset-url "/fontawesome/css/v4-shims.min.css")) + (link :href "https://unpkg.com/prismjs/themes/prism.css" :rel "stylesheet") + (script :src "https://unpkg.com/prismjs/prism.js") + (script :src "https://unpkg.com/prismjs/components/prism-javascript.min.js") + (script :src "https://unpkg.com/prismjs/components/prism-python.min.js") + (script :src "https://unpkg.com/prismjs/components/prism-bash.min.js") + (script :src "https://cdn.jsdelivr.net/npm/sweetalert2@11") + (script "if(matchMedia('(hover:hover) and (pointer:fine)').matches){document.documentElement.classList.add('hover-capable')}") + (script "document.addEventListener('click',function(e){var t=e.target.closest('[data-close-details]');if(!t)return;var d=t.closest('details');if(d)d.removeAttribute('open')})") + (style + "details[data-toggle-group=\"mobile-panels\"]>summary{list-style:none}" + "details[data-toggle-group=\"mobile-panels\"]>summary::-webkit-details-marker{display:none}" + "@media(min-width:768px){.nav-group:focus-within .submenu,.nav-group:hover .submenu{display:block}}" + "img{max-width:100%;height:auto}" + ".clamp-2{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}" + ".clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}" + ".no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}" + "details.group{overflow:hidden}details.group>summary{list-style:none}details.group>summary::-webkit-details-marker{display:none}" + ".htmx-indicator{display:none}.htmx-request .htmx-indicator{display:inline-flex}" + ".js-wrap.open .js-pop{display:block}.js-wrap.open .js-backdrop{display:block}")) + (body :class "bg-stone-50 text-stone-900" + (raw! body-html) + (when body-end-html (raw! body-end-html)) + (script :src (str asset-url "/scripts/body.js")))))) +''' + + +# --------------------------------------------------------------------------- +# ~app-layout — page body layout (header + filter + aside + main-panel) +# --------------------------------------------------------------------------- +# Replaces: _types/root/index.html body structure +# +# The header uses a
    / pattern for mobile menu toggle. +# All content sections are passed as pre-rendered HTML strings. +# +# Usage: +# sexp('(~app-layout :title t :asset-url a :header-rows-html h +# :menu-html m :filter-html f :aside-html a :content-html c)', **ctx) +# --------------------------------------------------------------------------- + +_APP_LAYOUT = r''' +(defcomp ~app-layout (&key title asset-url meta-html menu-colour + header-rows-html menu-html + filter-html aside-html content-html + body-end-html) + (let* ((colour (or menu-colour "sky"))) + (~app-shell :title (or title "Rose Ash") :asset-url asset-url + :meta-html meta-html :body-end-html body-end-html + :body-html (str + "
    " + "
    " + "
    " + "" + "
    " + "
    " + "
    " + header-rows-html + "
    " + "
    " + "
    " + "
    " + "
    " + (or menu-html "") + "
    " + "
    " + "
    " + "
    " + (or filter-html "") + "
    " + "
    " + "
    " + "
    " + "" + "
    " + (or content-html "") + "
    " + "
    " + "
    " + "
    " + "
    " + "
    ")))) +''' + + +# --------------------------------------------------------------------------- +# ~oob-response — HTMX OOB multi-target swap wrapper +# --------------------------------------------------------------------------- +# Replaces: oob_elements.html base template +# +# Each named region gets hx-swap-oob="outerHTML" on its wrapper div. +# The oobs-html param contains any extra OOB elements (header row swaps). +# +# Usage: +# sexp('(~oob-response :oobs-html oh :filter-html fh :aside-html ah +# :menu-html mh :content-html ch)', **ctx) +# --------------------------------------------------------------------------- + +_OOB_RESPONSE = ''' +(defcomp ~oob-response (&key oobs-html filter-html aside-html menu-html content-html) + (<> + (when oobs-html (raw! oobs-html)) + (div :id "filter" :hx-swap-oob "outerHTML" + (when filter-html (raw! filter-html))) + (aside :id "aside" :hx-swap-oob "outerHTML" + :class "hidden md:flex md:flex-col max-w-xs md:h-full md:min-h-0 mr-3" + (when aside-html (raw! aside-html))) + (div :id "root-menu" :hx-swap-oob "outerHTML" :class "md:hidden" + (when menu-html (raw! menu-html))) + (section :id "main-panel" + :class "flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport" + (when content-html (raw! content-html))))) +''' + + +# --------------------------------------------------------------------------- +# ~header-row — root header bar (cart-mini, title, nav-tree, auth-menu) +# --------------------------------------------------------------------------- +# Replaces: _types/root/header/_header.html header_row macro +# +# Usage: +# sexp('(~header-row :cart-mini-html cm :blog-url bu :site-title st +# :nav-tree-html nh :auth-menu-html ah :nav-panel-html np +# :settings-url su :is-admin ia)', **ctx) +# --------------------------------------------------------------------------- + +_HEADER_ROW = ''' +(defcomp ~header-row (&key cart-mini-html blog-url site-title + nav-tree-html auth-menu-html nav-panel-html + settings-url is-admin oob hamburger-html) + (<> + (div :id "root-row" + :hx-swap-oob (if oob "outerHTML" nil) + :class "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-sky-500" + (div :class "w-full flex flex-row items-top" + (when cart-mini-html (raw! cart-mini-html)) + (div :class "font-bold text-5xl flex-1" + (a :href (str (or blog-url "") "/") :class "flex justify-center md:justify-start" + (h1 (or site-title "")))) + (nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0" + (when nav-tree-html (raw! nav-tree-html)) + (when auth-menu-html (raw! auth-menu-html)) + (when nav-panel-html (raw! nav-panel-html)) + (when (and is-admin settings-url) + (a :href settings-url :class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3" + (i :class "fa fa-cog" :aria-hidden "true")))) + (when hamburger-html (raw! hamburger-html)))) + (div :class "block md:hidden text-md font-bold" + (when auth-menu-html (raw! auth-menu-html))))) +''' + + +# --------------------------------------------------------------------------- +# ~menu-row — section header row (wraps in colored bar) +# --------------------------------------------------------------------------- +# Replaces: macros/links.html menu_row macro +# +# Each nested header row gets a progressively lighter background. +# The route handler passes the level (0-based depth after root). +# +# Usage: +# sexp('(~menu-row :id "auth-row" :level 1 :colour "sky" +# :link-href url :link-label "account" :icon "fa-solid fa-user" +# :nav-html nh :child-id "auth-header-child" :child-html ch)', **ctx) +# --------------------------------------------------------------------------- + +_MENU_ROW = ''' +(defcomp ~menu-row (&key id level colour link-href link-label link-label-html icon + hx-select nav-html child-id child-html oob) + (let* ((c (or colour "sky")) + (lv (or level 1)) + (shade (str (- 500 (* lv 100))))) + (<> + (div :id id + :hx-swap-oob (if oob "outerHTML" nil) + :class (str "flex flex-col items-center md:flex-row justify-center md:justify-between w-full p-1 bg-" c "-" shade) + (div :class "relative nav-group" + (a :href link-href + :hx-get link-href + :hx-target "#main-panel" + :hx-select (or hx-select "#main-panel") + :hx-swap "outerHTML" + :hx-push-url "true" + :class "w-full whitespace-normal flex items-center gap-2 font-bold text-2xl px-3 py-2" + (when icon (i :class icon :aria-hidden "true")) + (if link-label-html (raw! link-label-html) + (when link-label (div link-label))))) + (when nav-html + (nav :class "hidden md:flex gap-4 text-sm ml-2 justify-end items-center flex-0" + (raw! nav-html)))) + (when child-id + (div :id child-id :class "flex flex-col w-full items-center" + (when child-html (raw! child-html))))))) +''' + + +# --------------------------------------------------------------------------- +# ~nav-link — HTMX navigation link (replaces macros/links.html link macro) +# --------------------------------------------------------------------------- + +_NAV_LINK = ''' +(defcomp ~nav-link (&key href hx-select label icon aclass select-colours) + (div :class "relative nav-group" + (a :href href + :hx-get href + :hx-target "#main-panel" + :hx-select (or hx-select "#main-panel") + :hx-swap "outerHTML" + :hx-push-url "true" + :class (or aclass + (str "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3 " + (or select-colours ""))) + (when icon (i :class icon :aria-hidden "true")) + (when label (span label))))) +''' + + +# --------------------------------------------------------------------------- +# ~infinite-scroll — pagination sentinel for table-based lists +# --------------------------------------------------------------------------- +# Replaces: sentinel pattern in _rows.html templates +# +# For table rows (orders, etc.): renders with intersection observer. +# Uses hyperscript for retry with exponential backoff. +# +# Usage: +# sexp('(~infinite-scroll :url next-url :page p :total-pages tp +# :id-prefix "orders" :colspan 5)', **ctx) +# --------------------------------------------------------------------------- + +_INFINITE_SCROLL = r''' +(defcomp ~infinite-scroll (&key url page total-pages id-prefix colspan) + (if (< page total-pages) + (raw! (str + " htmx.trigger(me, 'sentinel:retry'), myMs) " + "end " + "on htmx:beforeRequest " + "set me.style.pointerEvents to 'none' " + "set me.style.opacity to '0' " + "end " + "on htmx:afterSwap set me.dataset.retryMs to 1000 end " + "on htmx:sendError call backoff() " + "on htmx:responseError call backoff() " + "on htmx:timeout call backoff()" + "\"" + " role=\"status\" aria-live=\"polite\" aria-hidden=\"true\">" + "" + "
    " + "
    loading… " page " / " total-pages "
    " + "" + "
    " + "
    " + "
    loading… " page " / " total-pages "
    " + "" + "
    " + "")) + (raw! (str + "End of results")))) +''' + + +# --------------------------------------------------------------------------- +# ~status-pill — colored status indicator +# --------------------------------------------------------------------------- +# Replaces: inline Jinja status pill patterns across templates +# +# Usage: +# sexp('(~status-pill :status s :size "sm")', status="paid") +# --------------------------------------------------------------------------- + +_STATUS_PILL = ''' +(defcomp ~status-pill (&key status size) + (let* ((s (or status "pending")) + (lower (lower s)) + (sz (or size "xs")) + (colours (cond + (= lower "paid") "border-emerald-300 bg-emerald-50 text-emerald-700" + (= lower "confirmed") "border-emerald-300 bg-emerald-50 text-emerald-700" + (= lower "checked_in") "border-blue-300 bg-blue-50 text-blue-700" + (or (= lower "failed") (= lower "cancelled")) "border-rose-300 bg-rose-50 text-rose-700" + (= lower "provisional") "border-amber-300 bg-amber-50 text-amber-700" + (= lower "ordered") "border-blue-300 bg-blue-50 text-blue-700" + true "border-stone-300 bg-stone-50 text-stone-700"))) + (span :class (str "inline-flex items-center rounded-full border px-2 py-0.5 text-" sz " font-medium " colours) + s))) +''' + + +# --------------------------------------------------------------------------- +# ~search-mobile — mobile search input with htmx +# --------------------------------------------------------------------------- + +_SEARCH_MOBILE = ''' +(defcomp ~search-mobile (&key current-local-href search search-count hx-select search-headers-mobile) + (div :id "search-mobile-wrapper" + :class "flex flex-row gap-2 items-center flex-1 min-w-0 pr-2" + (input :id "search-mobile" + :type "text" :name "search" :aria-label "search" + :class "text-base md:text-sm col-span-5 rounded-md px-3 py-2 mb-2 w-full min-w-0 max-w-full border-2 border-stone-200 placeholder-shown:border-stone-200 [&:not(:placeholder-shown)]:border-yellow-200" + :hx-preserve true + :value (or search "") + :placeholder "search" + :hx-trigger "input changed delay:300ms" + :hx-target "#main-panel" + :hx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper") + :hx-get current-local-href + :hx-swap "outerHTML" + :hx-push-url "true" + :hx-headers search-headers-mobile + :hx-sync "this:replace" + :autocomplete "off") + (div :id "search-count-mobile" :aria-label "search count" + :class (if (not search-count) "text-xl text-red-500" "") + (when search (raw! (str search-count)))))) +''' + + +# --------------------------------------------------------------------------- +# ~search-desktop — desktop search input with htmx +# --------------------------------------------------------------------------- + +_SEARCH_DESKTOP = ''' +(defcomp ~search-desktop (&key current-local-href search search-count hx-select search-headers-desktop) + (div :id "search-desktop-wrapper" + :class "flex flex-row gap-2 items-center" + (input :id "search-desktop" + :type "text" :name "search" :aria-label "search" + :class "w-full mx-1 my-3 px-3 py-2 text-md rounded-xl border-2 shadow-sm border-white placeholder-shown:border-white [&:not(:placeholder-shown)]:border-yellow-200" + :hx-preserve true + :value (or search "") + :placeholder "search" + :hx-trigger "input changed delay:300ms" + :hx-target "#main-panel" + :hx-select (str (or hx-select "#main-panel") ", #search-mobile-wrapper, #search-desktop-wrapper") + :hx-get current-local-href + :hx-swap "outerHTML" + :hx-push-url "true" + :hx-headers search-headers-desktop + :hx-sync "this:replace" + :autocomplete "off") + (div :id "search-count-desktop" :aria-label "search count" + :class (if (not search-count) "text-xl text-red-500" "") + (when search (raw! (str search-count)))))) +''' + + +# --------------------------------------------------------------------------- +# ~order-summary-card — reusable order summary card +# --------------------------------------------------------------------------- +_ORDER_SUMMARY_CARD = r''' +(defcomp ~order-summary-card (&key order-id created-at description status currency total-amount) + (div :class "rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6 space-y-2 text-xs sm:text-sm text-stone-800" + (p (span :class "font-medium" "Order ID:") " " (span :class "font-mono" (str "#" order-id))) + (p (span :class "font-medium" "Created:") " " (or created-at "\u2014")) + (p (span :class "font-medium" "Description:") " " (or description "\u2013")) + (p (span :class "font-medium" "Status:") " " (~status-pill :status (or status "pending") :size "[11px]")) + (p (span :class "font-medium" "Currency:") " " (or currency "GBP")) + (p (span :class "font-medium" "Total:") " " + (if total-amount + (str (or currency "GBP") " " total-amount) + "\u2013")))) +''' diff --git a/shared/sexp/helpers.py b/shared/sexp/helpers.py new file mode 100644 index 0000000..66b9216 --- /dev/null +++ b/shared/sexp/helpers.py @@ -0,0 +1,108 @@ +""" +Shared helper functions for s-expression page rendering. + +These are used by per-service sexp_components.py files to build common +page elements (headers, search, etc.) from template context. +""" +from __future__ import annotations + +from typing import Any + +from .jinja_bridge import sexp +from .page import HAMBURGER_HTML, SEARCH_HEADERS_MOBILE, SEARCH_HEADERS_DESKTOP + + +def call_url(ctx: dict, key: str, path: str = "/") -> str: + """Call a URL helper from context (e.g., blog_url, account_url).""" + fn = ctx.get(key) + if callable(fn): + return fn(path) + return str(fn or "") + path + + +def get_asset_url(ctx: dict) -> str: + """Extract the asset URL base from context.""" + au = ctx.get("asset_url") + if callable(au): + result = au("") + return result.rsplit("/", 1)[0] if "/" in result else result + return au or "" + + +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' + ' :hamburger-html hh :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", ""), + hh=HAMBURGER_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, + ) + + +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, + ) + + +def full_page(ctx: dict, *, header_rows_html: str, + filter_html: str = "", aside_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, + ) + + +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, + ) diff --git a/shared/sexp/page.py b/shared/sexp/page.py index 29b038c..a0c05e5 100644 --- a/shared/sexp/page.py +++ b/shared/sexp/page.py @@ -5,15 +5,23 @@ Provides ``render_page()`` for rendering a complete HTML page from an s-expression, bypassing Jinja entirely. Used by error handlers and (eventually) by route handlers for fully-migrated pages. +``render_sexp_response()`` is the main entry point for GET route handlers: +it calls the app's context processor, merges in route-specific kwargs, +renders the s-expression to HTML, and returns a Quart ``Response``. + Usage:: - from shared.sexp.page import render_page + from shared.sexp.page import render_page, render_sexp_response + # Error pages (no context needed) html = render_page( '(~error-page :title "Not Found" :message "NOT FOUND" :image img :asset-url aurl)', image="/static/errors/404.gif", asset_url="/static", ) + + # GET route handlers (auto-injects app context) + resp = await render_sexp_response('(~orders-page :orders orders)', orders=orders) """ from __future__ import annotations @@ -22,6 +30,22 @@ from typing import Any from .jinja_bridge import sexp +# HTML constants used by layout components — kept here to avoid +# s-expression parser issues with embedded quotes in SVG. +HAMBURGER_HTML = ( + '
    ' + '' + '' + '
    ' +) + +SEARCH_HEADERS_MOBILE = '{"X-Origin":"search-mobile","X-Search":"true"}' +SEARCH_HEADERS_DESKTOP = '{"X-Origin":"search-desktop","X-Search":"true"}' + def render_page(source: str, **kwargs: Any) -> str: """Render a full HTML page from an s-expression string. @@ -30,3 +54,52 @@ def render_page(source: str, **kwargs: Any) -> str: intent explicit in call sites (rendering a whole page, not a fragment). """ return sexp(source, **kwargs) + + +async def get_template_context(**kwargs: Any) -> dict[str, Any]: + """Gather the full template context from all registered context processors. + + Returns a dict with all context variables that would normally be + available in a Jinja template, merged with any extra kwargs. + """ + import asyncio + from quart import current_app, request + + ctx: dict[str, Any] = {} + + # App-level context processors + for proc in current_app.template_context_processors.get(None, []): + rv = proc() + if asyncio.iscoroutine(rv): + rv = await rv + ctx.update(rv) + + # Blueprint-scoped context processors + for bp_name in (request.blueprints or []): + for proc in current_app.template_context_processors.get(bp_name, []): + rv = proc() + if asyncio.iscoroutine(rv): + rv = await rv + ctx.update(rv) + + # Inject Jinja globals that s-expression components need (URL helpers, + # asset_url, site, etc.) — these aren't provided by context processors. + for key, val in current_app.jinja_env.globals.items(): + if key not in ctx and callable(val): + ctx[key] = val + + ctx.update(kwargs) + return ctx + + +async def render_sexp_response(source: str, **kwargs: Any) -> str: + """Render an s-expression with the full app template context. + + Calls the app's registered context processors (which provide + cart_mini_html, auth_menu_html, nav_tree_html, asset_url, etc.) + and merges them with the caller's kwargs before rendering. + + Returns the rendered HTML string (caller wraps in Response as needed). + """ + ctx = await get_template_context(**kwargs) + return sexp(source, **ctx)