Phase 6: Replace render_template() with s-expression rendering in all GET routes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m15s

Migrate ~52 GET route handlers across all 7 services from Jinja
render_template() to s-expression component rendering. Each service
gets a sexp_components.py with page/oob/cards render functions.

- Add per-service sexp_components.py (account, blog, cart, events,
  federation, market, orders) with full page, OOB, and pagination
  card rendering
- Add shared/sexp/helpers.py with call_url, root_header_html,
  full_page, oob_page utilities
- Update all GET routes to use get_template_context() + render fns
- Fix get_template_context() to inject Jinja globals (URL helpers)
- Add qs_filter to base_context for sexp filter URL building
- Mount sexp_components.py in docker-compose.dev.yml for all services
- Import sexp_components in app.py for Hypercorn --reload watching
- Fix route_prefix import (shared.utils not shared.infrastructure.urls)
- Fix federation choose-username missing actor in context
- Fix market page_markets missing post in context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 23:19:33 +00:00
parent 8013317b41
commit d53b9648a9
53 changed files with 8690 additions and 463 deletions

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path 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 pathlib import Path
from quart import g, request from quart import g, request

View File

@@ -47,14 +47,17 @@ def register(url_prefix="/"):
@account_bp.get("/") @account_bp.get("/")
async def account(): async def account():
from shared.browser.app.utils.htmx import is_htmx_request 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"): if not g.get("user"):
return redirect(login_url("/")) return redirect(login_url("/"))
ctx = await get_template_context()
if not is_htmx_request(): if not is_htmx_request():
html = await render_template("_types/auth/index.html") html = await render_account_page(ctx)
else: else:
html = await render_template("_types/auth/_oob_elements.html") html = await render_account_oob(ctx)
return await make_response(html) return await make_response(html)
@@ -86,20 +89,14 @@ def register(url_prefix="/"):
"subscribed": un.subscribed if un else False, "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(): if not is_htmx_request():
html = await render_template( html = await render_newsletters_page(ctx, newsletter_list)
"_types/auth/index.html",
oob=nl_oob,
newsletter_list=newsletter_list,
)
else: else:
html = await render_template( html = await render_newsletters_oob(ctx, newsletter_list)
"_types/auth/_oob_elements.html",
oob=nl_oob,
newsletter_list=newsletter_list,
)
return await make_response(html) return await make_response(html)
@@ -149,20 +146,14 @@ def register(url_prefix="/"):
if not fragment_html: if not fragment_html:
abort(404) 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(): if not is_htmx_request():
html = await render_template( html = await render_fragment_page(ctx, fragment_html)
"_types/auth/index.html",
oob=w_oob,
page_fragment_html=fragment_html,
)
else: else:
html = await render_template( html = await render_fragment_oob(ctx, fragment_html)
"_types/auth/_oob_elements.html",
oob=w_oob,
page_fragment_html=fragment_html,
)
return await make_response(html) return await make_response(html)

View File

@@ -275,7 +275,11 @@ def register(url_prefix="/auth"):
if g.get("user"): if g.get("user"):
redirect_url = pop_login_redirect_target() redirect_url = pop_login_redirect_target()
return redirect(redirect_url) 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( @rate_limit(
key_func=lambda: request.headers.get("X-Forwarded-For", request.remote_addr), 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/") @auth_bp.get("/device/")
async def device_form(): async def device_form():
"""Browser form where user enters the code displayed in terminal.""" """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", "") 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")
@auth_bp.post("/device/") @auth_bp.post("/device/")
@@ -739,6 +746,9 @@ def register(url_prefix="/auth"):
@auth_bp.get("/device/complete/") @auth_bp.get("/device/complete/")
async def device_complete(): async def device_complete():
"""Post-login redirect — completes approval after magic link auth.""" """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", "") device_code = request.args.get("code", "")
if not device_code: if not device_code:
@@ -750,11 +760,12 @@ def register(url_prefix="/auth"):
ok = await _approve_device(device_code, g.user) ok = await _approve_device(device_code, g.user)
if not ok: if not ok:
return await render_template( ctx = await get_template_context(
"auth/device.html",
error="Code expired or already used. Please start the login process again in your terminal.", 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 return auth_bp

379
account/sexp_components.py Normal file
View File

@@ -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 = ['<div class="w-full max-w-3xl mx-auto px-4 py-6">',
'<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-8">']
if error:
parts.append(
f'<div class="rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm">{error}</div>'
)
# Account header with logout
parts.append('<div class="flex items-center justify-between"><div>')
parts.append('<h1 class="text-xl font-semibold tracking-tight">Account</h1>')
if user:
parts.append(f'<p class="text-sm text-stone-500 mt-1">{user.email}</p>')
if user.name:
parts.append(f'<p class="text-sm text-stone-600">{user.name}</p>')
parts.append('</div>')
parts.append(
f'<form action="/auth/logout/" method="post">'
f'<input type="hidden" name="csrf_token" value="{generate_csrf_token()}">'
f'<button type="submit" class="inline-flex items-center gap-2 rounded-full border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50 transition">'
f'<i class="fa-solid fa-right-from-bracket text-xs"></i> Sign out</button></form>'
)
parts.append('</div>')
# Labels
if user and hasattr(user, "labels") and user.labels:
parts.append('<div><h2 class="text-base font-semibold tracking-tight mb-3">Labels</h2>')
parts.append('<div class="flex flex-wrap gap-2">')
for label in user.labels:
parts.append(
f'<span class="inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60">'
f'{label.name}</span>'
)
parts.append('</div></div>')
parts.append('</div></div>')
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'<div id="nl-{nid}" class="flex items-center">'
f'<button hx-post="{toggle_url}"'
f' hx-headers=\'{{"X-CSRFToken": "{csrf_token}"}}\''
f' hx-target="#nl-{nid}" hx-swap="outerHTML"'
f' class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors'
f' focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 {bg}"'
f' role="switch" aria-checked="{checked}">'
f'<span class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform {translate}"></span>'
f'</button></div>'
)
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 = ['<div class="w-full max-w-3xl mx-auto px-4 py-6">',
'<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">',
'<h1 class="text-xl font-semibold tracking-tight">Newsletters</h1>']
if newsletter_list:
parts.append('<div class="divide-y divide-stone-100">')
for item in newsletter_list:
nl = item["newsletter"]
un = item.get("un")
parts.append('<div class="flex items-center justify-between py-4 first:pt-0 last:pb-0">')
parts.append(f'<div class="min-w-0 flex-1"><p class="text-sm font-medium text-stone-800">{nl.name}</p>')
if nl.description:
parts.append(f'<p class="text-xs text-stone-500 mt-0.5 truncate">{nl.description}</p>')
parts.append('</div><div class="ml-4 flex-shrink-0">')
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'<div id="nl-{nl.id}" class="flex items-center">'
f'<button hx-post="{toggle_url}"'
f' hx-headers=\'{{"X-CSRFToken": "{csrf}"}}\''
f' hx-target="#nl-{nl.id}" hx-swap="outerHTML"'
f' class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors'
f' focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 bg-stone-300"'
f' role="switch" aria-checked="false">'
f'<span class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1"></span>'
f'</button></div>'
)
parts.append('</div></div>')
parts.append('</div>')
else:
parts.append('<p class="text-sm text-stone-500">No newsletters available.</p>')
parts.append('</div></div>')
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 = ['<div class="py-8 max-w-md mx-auto">',
'<h1 class="text-2xl font-bold mb-6">Sign in</h1>']
if error:
parts.append(
f'<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">{error}</div>'
)
action = url_for("auth.start_login")
parts.append(
f'<form method="post" action="{action}" class="space-y-4">'
f'<input type="hidden" name="csrf_token" value="{generate_csrf_token()}">'
f'<div><label for="email" class="block text-sm font-medium mb-1">Email address</label>'
f'<input type="email" name="email" id="email" value="{email}" required autofocus'
f' class="w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"></div>'
f'<button type="submit" class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition">'
f'Send magic link</button></form>'
)
parts.append('</div>')
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 = ['<div class="py-8 max-w-md mx-auto">',
'<h1 class="text-2xl font-bold mb-6">Authorize device</h1>',
'<p class="text-stone-600 mb-4">Enter the code shown in your terminal to sign in.</p>']
if error:
parts.append(
f'<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">{error}</div>'
)
action = url_for("auth.device_submit")
parts.append(
f'<form method="post" action="{action}" class="space-y-4">'
f'<input type="hidden" name="csrf_token" value="{generate_csrf_token()}">'
f'<div><label for="code" class="block text-sm font-medium mb-1">Device code</label>'
f'<input type="text" name="code" id="code" value="{code}" placeholder="XXXX-XXXX"'
f' required autofocus maxlength="9" autocomplete="off" spellcheck="false"'
f' class="w-full border border-stone-300 rounded px-3 py-3 text-center text-2xl tracking-widest font-mono uppercase focus:outline-none focus:ring-2 focus:ring-stone-500"></div>'
f'<button type="submit" class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition">'
f'Authorize</button></form>'
)
parts.append('</div>')
return "".join(parts)
def _device_approved_content() -> str:
"""Device approved success content."""
return (
'<div class="py-8 max-w-md mx-auto text-center">'
'<h1 class="text-2xl font-bold mb-4">Device authorized</h1>'
'<p class="text-stone-600">You can close this window and return to your terminal.</p>'
'</div>'
)
# ---------------------------------------------------------------------------
# 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='<title>Login \u2014 Rose Ash</title>')
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='<title>Authorize Device \u2014 Rose Ash</title>')
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='<title>Device Authorized \u2014 Rose Ash</title>')

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path 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 pathlib import Path
from quart import g, request from quart import g, request

View File

@@ -29,27 +29,28 @@ def register(url_prefix):
@bp.get("/") @bp.get("/")
@require_admin @require_admin
async def home(): 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(): if not is_htmx_request():
# Normal browser request: full page with layout html = await render_settings_page(tctx)
html = await render_template(
"_types/root/settings/index.html",
)
else: else:
html = await render_template("_types/root/settings/_oob_elements.html") html = await render_settings_oob(tctx)
return await make_response(html) return await make_response(html)
@bp.get("/cache/") @bp.get("/cache/")
@require_admin @require_admin
async def cache(): 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(): if not is_htmx_request():
html = await render_template("_types/root/settings/cache/index.html") html = await render_cache_page(tctx)
else: else:
html = await render_template("_types/root/settings/cache/_oob_elements.html") html = await render_cache_oob(tctx)
return await make_response(html) return await make_response(html)
@bp.post("/cache_clear/") @bp.post("/cache_clear/")

View File

@@ -57,10 +57,15 @@ def register():
ctx = {"groups": groups, "unassigned_tags": unassigned} 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(): 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: 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("/") @bp.post("/")
@require_admin @require_admin
@@ -117,10 +122,15 @@ def register():
"assigned_tag_ids": assigned_tag_ids, "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(): 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: 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("/<int:id>/") @bp.post("/<int:id>/")
@require_admin @require_admin

View File

@@ -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_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) 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(): if not is_htmx_request():
html = await render_template("_types/home/index.html", **ctx) html = await render_home_page(tctx)
else: else:
html = await render_template("_types/home/_oob_elements.html", **ctx) html = await render_home_oob(tctx)
return await make_response(html) return await make_response(html)
@blogs_bp.get("/index") @blogs_bp.get("/index")
@@ -185,12 +190,17 @@ def register(url_prefix, title):
"tag_groups": [], "tag_groups": [],
"posts": data.get("pages", []), "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(): if not is_htmx_request():
html = await render_template("_types/blog/index.html", **context) html = await render_blog_page(tctx)
elif q.page > 1: elif q.page > 1:
html = await render_template("_types/blog/_page_cards.html", **context) html = await render_blog_page_cards(tctx)
else: else:
html = await render_template("_types/blog/_oob_elements.html", **context) html = await render_blog_oob(tctx)
return await make_response(html) return await make_response(html)
# Default: posts listing # Default: posts listing
@@ -221,28 +231,33 @@ def register(url_prefix, title):
"drafts": q.drafts if show_drafts else None, "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(): if not is_htmx_request():
# Normal browser request: full page with layout html = await render_blog_page(tctx)
html = await render_template("_types/blog/index.html", **context)
elif q.page > 1: elif q.page > 1:
# HTMX pagination: just blog cards + sentinel html = await render_blog_cards(tctx)
html = await render_template("_types/blog/_cards.html", **context)
else: else:
# HTMX navigation (page 1): main panel + OOB elements html = await render_blog_oob(tctx)
#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
return await make_response(html) return await make_response(html)
@blogs_bp.get("/new/") @blogs_bp.get("/new/")
@require_admin @require_admin
async def new_post(): 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(): if not is_htmx_request():
html = await render_template("_types/blog_new/index.html") html = await render_new_post_page(tctx)
else: else:
html = await render_template("_types/blog_new/_oob_elements.html") html = await render_new_post_oob(tctx)
return await make_response(html) return await make_response(html)
@blogs_bp.post("/new/") @blogs_bp.post("/new/")
@@ -312,10 +327,17 @@ def register(url_prefix, title):
@blogs_bp.get("/new-page/") @blogs_bp.get("/new-page/")
@require_admin @require_admin
async def new_page(): 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(): 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: 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) return await make_response(html)
@blogs_bp.post("/new-page/") @blogs_bp.post("/new-page/")

View File

@@ -34,20 +34,15 @@ def register():
menu_items = await get_all_menu_items(g.s) menu_items = await get_all_menu_items(g.s)
if not is_htmx_request(): from shared.sexp.page import get_template_context
# Normal browser request: full page with layout from sexp_components import render_menu_items_page, render_menu_items_oob
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")
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) return await make_response(html)

View File

@@ -51,13 +51,15 @@ def register():
"sumup_checkout_prefix": sumup_checkout_prefix, "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(): if not is_htmx_request():
# Normal browser request: full page with layout html = await render_post_admin_page(tctx)
html = await render_template("_types/post/admin/index.html", **ctx)
else: else:
# HTMX request: main panel + OOB elements html = await render_post_admin_oob(tctx)
html = await render_template("_types/post/admin/_oob_elements.html", **ctx)
return await make_response(html) return await make_response(html)
@@ -149,14 +151,16 @@ def register():
@bp.get("/data/") @bp.get("/data/")
@require_admin @require_admin
async def data(slug: str): 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(): if not is_htmx_request():
html = await render_template( html = await render_post_data_page(tctx)
"_types/post_data/index.html",
)
else: else:
html = await render_template( html = await render_post_data_oob(tctx)
"_types/post_data/_oob_elements.html",
)
return await make_response(html) return await make_response(html)
@@ -266,18 +270,20 @@ def register():
# Load entries and post for each calendar # Load entries and post for each calendar
for calendar in all_calendars: for calendar in all_calendars:
await g.s.refresh(calendar, ["entries", "post"]) 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(): if not is_htmx_request():
html = await render_template( html = await render_post_entries_page(tctx)
"_types/post_entries/index.html",
all_calendars=all_calendars,
associated_entry_ids=associated_entry_ids,
)
else: else:
html = await render_template( html = await render_post_entries_oob(tctx)
"_types/post_entries/_oob_elements.html",
all_calendars=all_calendars,
associated_entry_ids=associated_entry_ids,
)
return await make_response(html) return await make_response(html)
@@ -350,18 +356,20 @@ def register():
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page) ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
save_success = request.args.get("saved") == "1" 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(): if not is_htmx_request():
html = await render_template( html = await render_post_settings_page(tctx)
"_types/post_settings/index.html",
ghost_post=ghost_post,
save_success=save_success,
)
else: else:
html = await render_template( html = await render_post_settings_oob(tctx)
"_types/post_settings/_oob_elements.html",
ghost_post=ghost_post,
save_success=save_success,
)
return await make_response(html) return await make_response(html)
@@ -451,20 +459,21 @@ def register():
from types import SimpleNamespace from types import SimpleNamespace
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters] 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(): if not is_htmx_request():
html = await render_template( html = await render_post_edit_page(tctx)
"_types/post_edit/index.html",
ghost_post=ghost_post,
save_success=save_success,
newsletters=newsletters,
)
else: else:
html = await render_template( html = await render_post_edit_oob(tctx)
"_types/post_edit/_oob_elements.html",
ghost_post=ghost_post,
save_success=save_success,
newsletters=newsletters,
)
return await make_response(html) return await make_response(html)

View File

@@ -114,13 +114,14 @@ def register():
@bp.get("/") @bp.get("/")
@cache_page(tag="post.post_detail") @cache_page(tag="post.post_detail")
async def post_detail(slug: str): 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(): if not is_htmx_request():
# Normal browser request: full page with layout html = await render_post_page(tctx)
html = await render_template("_types/post/index.html")
else: else:
# HTMX request: main panel + OOB elements html = await render_post_oob(tctx)
html = await render_template("_types/post/_oob_elements.html")
return await make_response(html) return await make_response(html)

View File

@@ -38,18 +38,16 @@ def register():
snippets = await _visible_snippets(g.s) snippets = await _visible_snippets(g.s)
is_admin = g.rights.get("admin") 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(): if not is_htmx_request():
html = await render_template( html = await render_snippets_page(tctx)
"_types/snippets/index.html",
snippets=snippets,
is_admin=is_admin,
)
else: else:
html = await render_template( html = await render_snippets_oob(tctx)
"_types/snippets/_oob_elements.html",
snippets=snippets,
is_admin=is_admin,
)
return await make_response(html) return await make_response(html)

1805
blog/sexp_components.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path 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 decimal import Decimal
from pathlib import Path from pathlib import Path

View File

@@ -14,18 +14,16 @@ def register(url_prefix: str) -> Blueprint:
@bp.get("/") @bp.get("/")
async def overview(): async def overview():
from quart import g 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) page_groups = await get_cart_grouped_by_page(g.s)
ctx = await get_template_context()
if not is_htmx_request(): if not is_htmx_request():
html = await render_template( html = await render_overview_page(ctx, page_groups)
"_types/cart/overview/index.html",
page_groups=page_groups,
)
else: else:
html = await render_template( html = await render_overview_oob(ctx, page_groups)
"_types/cart/overview/_oob_elements.html",
page_groups=page_groups,
)
return await make_response(html) return await make_response(html)
return bp return bp

View File

@@ -40,10 +40,20 @@ def register(url_prefix: str) -> Blueprint:
ticket_total=ticket_total, 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(): 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: 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) return await make_response(html)
@bp.post("/checkout/") @bp.post("/checkout/")

View File

@@ -55,12 +55,16 @@ def register() -> Blueprint:
order = result.scalar_one_or_none() order = result.scalar_one_or_none()
if not order: if not order:
return await make_response("Order not found", 404) 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(): if not is_htmx_request():
# Normal browser request: full page with layout html = await render_order_page(ctx, order, calendar_entries, url_for)
html = await render_template("_types/order/index.html", order=order,)
else: else:
# HTMX navigation (page 1): main panel + OOB elements html = await render_order_oob(ctx, order, calendar_entries, url_for)
html = await render_template("_types/order/_oob_elements.html", order=order,)
return await make_response(html) return await make_response(html)

View File

@@ -136,24 +136,30 @@ def register(url_prefix: str) -> Blueprint:
result = await g.s.execute(stmt) result = await g.s.execute(stmt)
orders = result.scalars().all() orders = result.scalars().all()
context = { from shared.sexp.page import get_template_context
"orders": orders, from sexp_components import (
"page": page, render_orders_page,
"total_pages": total_pages, render_orders_rows,
"search": search, render_orders_oob,
"search_count": total_count, # For search display )
}
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(): if not is_htmx_request():
# Normal browser request: full page with layout html = await render_orders_page(
html = await render_template("_types/orders/index.html", **context) ctx, orders, page, total_pages, search, total_count,
url_for, qs_fn,
)
elif page > 1: elif page > 1:
# HTMX pagination: just table rows + sentinel html = await render_orders_rows(
html = await render_template("_types/orders/_rows.html", **context) ctx, orders, page, total_pages, url_for, qs_fn,
)
else: else:
# HTMX navigation (page 1): main panel + OOB elements html = await render_orders_oob(
html = await render_template("_types/orders/_oob_elements.html", **context) ctx, orders, page, total_pages, search, total_count,
url_for, qs_fn,
)
resp = await make_response(html) resp = await make_response(html)
resp.headers["Hx-Push-Url"] = _current_url_without_page() resp.headers["Hx-Push-Url"] = _current_url_without_page()

805
cart/sexp_components.py Normal file
View File

@@ -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'<img src="{page_post.feature_image}"'
f' class="h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0">'
)
label_html = f'{img_html}<span>{title}</span>'
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='<i class="fa fa-arrow-left text-xs" aria-hidden="true"></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'<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100">'
f'<i class="fa fa-box-open" aria-hidden="true"></i> {product_count} item{s}</span>'
)
if calendar_count > 0:
s = "s" if calendar_count != 1 else ""
badges.append(
f'<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100">'
f'<i class="fa fa-calendar" aria-hidden="true"></i> {calendar_count} booking{s}</span>'
)
if ticket_count > 0:
s = "s" if ticket_count != 1 else ""
badges.append(
f'<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100">'
f'<i class="fa fa-ticket" aria-hidden="true"></i> {ticket_count} ticket{s}</span>'
)
badges_html = '<div class="mt-1 flex flex-wrap gap-2 text-xs text-stone-600">' + "".join(badges) + '</div>'
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'<img src="{feature_image}" alt="{title}" class="h-16 w-16 rounded-xl object-cover border border-stone-200 flex-shrink-0">'
else:
img = '<div class="h-16 w-16 rounded-xl bg-stone-100 flex items-center justify-center flex-shrink-0"><i class="fa fa-store text-stone-400 text-xl" aria-hidden="true"></i></div>'
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'<p class="text-xs text-stone-500 truncate">{title}</p>'
display_title = mp_name or title
return (
f'<a href="{cart_url}" class="block rounded-2xl border border-stone-200 bg-white shadow-sm hover:shadow-md hover:border-stone-300 transition p-4 sm:p-5">'
f'<div class="flex items-start gap-4">{img}'
f'<div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-semibold text-stone-900 truncate">{display_title}</h3>{mp_sub}{badges_html}</div>'
f'<div class="text-right flex-shrink-0"><div class="text-lg font-bold text-stone-900">&pound;{total:.2f}</div>'
f'<div class="mt-1 text-xs text-emerald-700 font-medium">View cart &rarr;</div></div></div></a>'
)
else:
# Orphan items
badges_html_amber = badges_html.replace("bg-stone-100", "bg-amber-100")
return (
f'<div class="rounded-2xl border border-dashed border-amber-300 bg-amber-50/60 p-4 sm:p-5">'
f'<div class="flex items-start gap-4">'
f'<div class="h-16 w-16 rounded-xl bg-amber-100 flex items-center justify-center flex-shrink-0">'
f'<i class="fa fa-shopping-cart text-amber-500 text-xl" aria-hidden="true"></i></div>'
f'<div class="flex-1 min-w-0"><h3 class="text-base sm:text-lg font-semibold text-stone-900">Other items</h3>{badges_html_amber}</div>'
f'<div class="text-right flex-shrink-0"><div class="text-lg font-bold text-stone-900">&pound;{total:.2f}</div></div></div></div>'
)
def _overview_main_panel_html(page_groups: list, ctx: dict) -> str:
"""Cart overview main panel."""
if not page_groups:
return (
'<div class="max-w-full px-3 py-3 space-y-3">'
'<div class="rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center">'
'<div class="inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3">'
'<i class="fa fa-shopping-cart text-stone-500 text-sm sm:text-base" aria-hidden="true"></i></div>'
'<p class="text-base sm:text-lg font-medium text-stone-800">Your cart is empty</p></div></div>'
)
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 (
'<div class="max-w-full px-3 py-3 space-y-3">'
'<div class="rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center">'
'<div class="inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3">'
'<i class="fa fa-shopping-cart text-stone-500 text-sm sm:text-base" aria-hidden="true"></i></div>'
'<p class="text-base sm:text-lg font-medium text-stone-800">Your cart is empty</p></div></div>'
)
return '<div class="max-w-full px-3 py-3 space-y-3"><div class="space-y-4">' + "".join(cards) + '</div></div>'
# ---------------------------------------------------------------------------
# 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'<img src="{p.image}" alt="{p.title}" class="w-24 h-24 sm:w-32 sm:h-28 object-cover rounded-xl border border-stone-100" loading="lazy">'
else:
img = '<div class="w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300 flex items-center justify-center text-xs text-stone-400">No image</div>'
price_html = ""
if unit_price:
price_html = f'<p class="text-sm sm:text-base font-semibold text-stone-900">{symbol}{unit_price:.2f}</p>'
if p.special_price and p.special_price != p.regular_price:
price_html += f'<p class="text-xs text-stone-400 line-through">{symbol}{p.regular_price:.2f}</p>'
else:
price_html = '<p class="text-xs text-stone-500">No price</p>'
deleted_html = ""
if getattr(item, "is_deleted", False):
deleted_html = (
'<p class="mt-2 inline-flex items-center gap-1 text-[0.65rem] sm:text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5">'
'<i class="fa-solid fa-triangle-exclamation text-[0.6rem]" aria-hidden="true"></i>'
' This item is no longer available or price has changed</p>'
)
brand_html = f'<p class="mt-0.5 text-[0.7rem] sm:text-xs text-stone-500">{p.brand}</p>' if getattr(p, "brand", None) else ""
line_total_html = ""
if unit_price:
lt = unit_price * item.quantity
line_total_html = f'<p class="text-sm sm:text-base font-semibold text-stone-900">Line total: {symbol}{lt:.2f}</p>'
return (
f'<article id="cart-item-{slug}" class="flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4 md:p-5">'
f'<div class="w-full sm:w-32 shrink-0 flex justify-center sm:block">{img}</div>'
f'<div class="flex-1 min-w-0">'
f'<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3">'
f'<div class="min-w-0"><h2 class="text-sm sm:text-base md:text-lg font-semibold text-stone-900">'
f'<a href="{prod_url}" class="hover:text-emerald-700">{p.title}</a></h2>{brand_html}{deleted_html}</div>'
f'<div class="text-left sm:text-right">{price_html}</div></div>'
f'<div class="mt-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4">'
f'<div class="flex items-center gap-2 text-xs sm:text-sm text-stone-700">'
f'<span class="text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500">Quantity</span>'
f'<form action="{qty_url}" method="post" hx-post="{qty_url}" hx-swap="none">'
f'<input type="hidden" name="csrf_token" value="{csrf}"><input type="hidden" name="count" value="{item.quantity - 1}">'
f'<button type="submit" class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl">-</button></form>'
f'<span class="inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium">{item.quantity}</span>'
f'<form action="{qty_url}" method="post" hx-post="{qty_url}" hx-swap="none">'
f'<input type="hidden" name="csrf_token" value="{csrf}"><input type="hidden" name="count" value="{item.quantity + 1}">'
f'<button type="submit" class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl">+</button></form></div>'
f'<div class="flex items-center justify-between sm:justify-end gap-3">{line_total_html}</div></div></div></article>'
)
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'<li class="flex items-start justify-between text-sm">'
f'<div><div class="font-medium">{name}</div>'
f'<div class="text-xs text-stone-500">{start}{end_html}</div></div>'
f'<div class="ml-4 font-medium">\u00a3{cost:.2f}</div></li>'
)
return (
'<div class="mt-6 border-t border-stone-200 pt-4">'
'<h2 class="text-base font-semibold mb-2">Calendar bookings</h2>'
f'<ul class="space-y-2">{"".join(items)}</ul></div>'
)
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 = ['<div class="mt-6 border-t border-stone-200 pt-4">',
'<h2 class="text-base font-semibold mb-2"><i class="fa fa-ticket mr-1" aria-hidden="true"></i> Event tickets</h2>',
'<div class="space-y-3">']
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'<p class="mt-0.5 text-[0.7rem] sm:text-xs text-stone-500">{tt_name}</p>' if tt_name else ""
tt_hidden = f'<input type="hidden" name="ticket_type_id" value="{tt_id}">' if tt_id else ""
parts.append(
f'<article class="flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4">'
f'<div class="flex-1 min-w-0">'
f'<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3">'
f'<div class="min-w-0"><h3 class="text-sm sm:text-base font-semibold text-stone-900">{name}</h3>{tt_name_html}'
f'<p class="mt-0.5 text-[0.7rem] sm:text-xs text-stone-500">{date_str}</p></div>'
f'<div class="text-left sm:text-right"><p class="text-sm sm:text-base font-semibold text-stone-900">\u00a3{price or 0:.2f}</p></div></div>'
f'<div class="mt-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4">'
f'<div class="flex items-center gap-2 text-xs sm:text-sm text-stone-700">'
f'<span class="text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500">Quantity</span>'
f'<form action="{qty_url}" method="post" hx-post="{qty_url}" hx-swap="none">'
f'<input type="hidden" name="csrf_token" value="{csrf}"><input type="hidden" name="entry_id" value="{entry_id}">{tt_hidden}'
f'<input type="hidden" name="count" value="{max(quantity - 1, 0)}">'
f'<button type="submit" class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl">-</button></form>'
f'<span class="inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium">{quantity}</span>'
f'<form action="{qty_url}" method="post" hx-post="{qty_url}" hx-swap="none">'
f'<input type="hidden" name="csrf_token" value="{csrf}"><input type="hidden" name="entry_id" value="{entry_id}">{tt_hidden}'
f'<input type="hidden" name="count" value="{quantity + 1}">'
f'<button type="submit" class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl">+</button></form></div>'
f'<div class="flex items-center justify-between sm:justify-end gap-3">'
f'<p class="text-sm sm:text-base font-semibold text-stone-900">Line total: \u00a3{line_total:.2f}</p></div></div></div></article>'
)
parts.append('</div></div>')
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'<form method="post" action="{action}" class="w-full">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<button type="submit" class="w-full inline-flex items-center justify-center px-4 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition">'
f'<i class="fa-solid fa-credit-card mr-2" aria-hidden="true"></i> Checkout as {user.email}</button></form>'
)
else:
href = login_url(request.url)
checkout_html = (
f'<div class="w-full flex"><a href="{href}" class="w-full cursor-pointer flex flex-row items-center justify-center p-3 gap-2 rounded bg-stone-200 text-black hover:bg-stone-300 transition">'
f'<i class="fa-solid fa-key"></i><span>sign in or register to checkout</span></a></div>'
)
return (
f'<aside id="cart-summary" class="lg:pl-2"><div class="rounded-2xl bg-white shadow-sm border border-stone-200 p-4 sm:p-5">'
f'<h2 class="text-sm sm:text-base font-semibold text-stone-900 mb-3 sm:mb-4">Order summary</h2>'
f'<dl class="space-y-2 text-xs sm:text-sm">'
f'<div class="flex items-center justify-between"><dt class="text-stone-600">Items</dt><dd class="text-stone-900">{item_count}</dd></div>'
f'<div class="flex items-center justify-between"><dt class="text-stone-600">Subtotal</dt><dd class="text-stone-900">{symbol}{grand:.2f}</dd></div></dl>'
f'<div class="flex flex-col items-center w-full"><h1 class="text-5xl mt-2">This is a test - it will not take actual money</h1>'
f'<div>use dummy card number: 5555 5555 5555 4444</div></div>'
f'<div class="mt-4 sm:mt-5">{checkout_html}</div></div></aside>'
)
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 (
'<div class="max-w-full px-3 py-3 space-y-3">'
'<div id="cart"><div class="rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center">'
'<div class="inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3">'
'<i class="fa fa-shopping-cart text-stone-500 text-sm sm:text-base" aria-hidden="true"></i></div>'
'<p class="text-base sm:text-lg font-medium text-stone-800">Your cart is empty</p></div></div></div>'
)
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'<div class="max-w-full px-3 py-3 space-y-3"><div id="cart">'
f'<div><section class="space-y-3 sm:space-y-4">{items_html}{cal_html}{tickets_html}</section>'
f'{summary_html}</div></div></div>'
)
# ---------------------------------------------------------------------------
# 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'<tr class="hidden sm:table-row border-t border-stone-100 hover:bg-stone-50/60">'
f'<td class="px-3 py-2 align-top"><span class="font-mono text-[11px] sm:text-xs">#{order.id}</span></td>'
f'<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">{created}</td>'
f'<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">{order.description or ""}</td>'
f'<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">{total}</td>'
f'<td class="px-3 py-2 align-top"><span class="inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs {pill}">{status}</span></td>'
f'<td class="px-3 py-0.5 align-top text-right"><a href="{detail_url}" class="inline-flex items-center px-3 py-1.5 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition">View</a></td></tr>'
f'<tr class="sm:hidden border-t border-stone-100"><td colspan="5" class="px-3 py-3"><div class="flex flex-col gap-2 text-xs">'
f'<div class="flex items-center justify-between gap-2"><span class="font-mono text-[11px] text-stone-700">#{order.id}</span>'
f'<span class="inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] {pill}">{status}</span></div>'
f'<div class="text-[11px] text-stone-500 break-words">{created}</div>'
f'<div class="flex items-center justify-between gap-2"><div class="font-medium text-stone-800">{total}</div>'
f'<a href="{detail_url}" class="inline-flex items-center px-2 py-1 text-[11px] rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition shrink-0">View</a></div></div></td></tr>'
)
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('<tr><td colspan="5" class="px-3 py-4 text-center text-xs text-stone-400">End of results</td></tr>')
return "".join(parts)
def _orders_main_panel_html(orders: list, rows_html: str) -> str:
"""Main panel for orders list."""
if not orders:
return (
'<div class="max-w-full px-3 py-3 space-y-3">'
'<div class="rounded-2xl border border-dashed border-stone-300 bg-white/80 p-4 sm:p-6 text-sm text-stone-700">'
'No orders yet.</div></div>'
)
return (
'<div class="max-w-full px-3 py-3 space-y-3">'
'<div class="overflow-x-auto rounded-2xl border border-stone-200 bg-white/80">'
'<table class="min-w-full text-xs sm:text-sm">'
'<thead class="bg-stone-50 border-b border-stone-200 text-stone-600"><tr>'
'<th class="px-3 py-2 text-left font-medium">Order</th>'
'<th class="px-3 py-2 text-left font-medium">Created</th>'
'<th class="px-3 py-2 text-left font-medium">Description</th>'
'<th class="px-3 py-2 text-left font-medium">Total</th>'
'<th class="px-3 py-2 text-left font-medium">Status</th>'
'<th class="px-3 py-2 text-left font-medium"></th>'
f'</tr></thead><tbody>{rows_html}</tbody></table></div></div>'
)
def _orders_summary_html(ctx: dict) -> str:
"""Filter section for orders list."""
return (
'<header class="mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4">'
'<div class="space-y-1"><p class="text-xs sm:text-sm text-stone-600">Recent orders placed via the checkout.</p></div>'
f'<div class="md:hidden">{search_mobile_html(ctx)}</div>'
'</header>'
)
# ---------------------------------------------------------------------------
# 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'<img src="{item.product_image}" alt="{item.product_title or "Product image"}"'
f' class="w-full h-full object-contain object-center" loading="lazy" decoding="async">'
if item.product_image else
'<div class="w-full h-full flex items-center justify-center text-[9px] text-stone-400">No image</div>'
)
items.append(
f'<li><a class="w-full py-2 flex gap-3" href="{prod_url}">'
f'<div class="w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden">{img}</div>'
f'<div class="flex-1 flex justify-between gap-3">'
f'<div><p class="font-medium">{item.product_title or "Unknown product"}</p>'
f'<p class="text-[11px] text-stone-500">Product ID: {item.product_id}</p></div>'
f'<div class="text-right whitespace-nowrap"><p>Qty: {item.quantity}</p>'
f'<p>{item.currency or order.currency or "GBP"} {item.unit_price or 0:.2f}</p>'
f'</div></div></a></li>'
)
return (
'<div class="rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6">'
'<h2 class="text-sm sm:text-base font-semibold mb-3">Items</h2>'
f'<ul class="divide-y divide-stone-100 text-xs sm:text-sm">{"".join(items)}</ul></div>'
)
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'<li class="px-4 py-3 flex items-start justify-between text-sm">'
f'<div><div class="font-medium flex items-center gap-2">{e.name}'
f'<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}">'
f'{st.capitalize()}</span></div>'
f'<div class="text-xs text-stone-500">{ds}</div></div>'
f'<div class="ml-4 font-medium">\u00a3{e.cost or 0:.2f}</div></li>'
)
return (
'<section class="mt-6 space-y-3">'
'<h2 class="text-base sm:text-lg font-semibold">Calendar bookings in this order</h2>'
f'<ul class="divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80">{"".join(items)}</ul></section>'
)
def _order_main_html(order: Any, calendar_entries: list | None) -> str:
"""Main panel for single order detail."""
summary = _order_summary_html(order)
return f'<div class="max-w-full px-3 py-3 space-y-4">{summary}{_order_items_html(order)}{_order_calendar_items_html(calendar_entries)}</div>'
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'<a href="{pay_url}" class="inline-flex items-center px-3 py-2 text-xs sm:text-sm '
f'rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition">'
f'<i class="fa fa-credit-card mr-2" aria-hidden="true"></i>Open payment page</a>'
) if status != "paid" else ""
return (
'<header class="mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4">'
f'<div class="space-y-1"><p class="text-xs sm:text-sm text-stone-600">Placed {created} &middot; Status: {status}</p></div>'
'<div class="flex w-full sm:w-auto justify-start sm:justify-end gap-2">'
f'<a href="{list_url}" class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"><i class="fa-solid fa-list mr-2" aria-hidden="true"></i>All orders</a>'
f'<form method="post" action="{recheck_url}" class="inline"><input type="hidden" name="csrf_token" value="{csrf_token}">'
f'<button type="submit" class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"><i class="fa-solid fa-rotate mr-2" aria-hidden="true"></i>Re-check status</button></form>'
f'{pay}</div></header>'
)
# ---------------------------------------------------------------------------
# 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)

View File

@@ -45,6 +45,7 @@ services:
- ./blog/alembic.ini:/app/blog/alembic.ini:ro - ./blog/alembic.ini:/app/blog/alembic.ini:ro
- ./blog/alembic:/app/blog/alembic:ro - ./blog/alembic:/app/blog/alembic:ro
- ./blog/app.py:/app/app.py - ./blog/app.py:/app/app.py
- ./blog/sexp_components.py:/app/sexp_components.py
- ./blog/bp:/app/bp - ./blog/bp:/app/bp
- ./blog/services:/app/services - ./blog/services:/app/services
- ./blog/templates:/app/templates - ./blog/templates:/app/templates
@@ -82,6 +83,7 @@ services:
- ./market/alembic.ini:/app/market/alembic.ini:ro - ./market/alembic.ini:/app/market/alembic.ini:ro
- ./market/alembic:/app/market/alembic:ro - ./market/alembic:/app/market/alembic:ro
- ./market/app.py:/app/app.py - ./market/app.py:/app/app.py
- ./market/sexp_components.py:/app/sexp_components.py
- ./market/bp:/app/bp - ./market/bp:/app/bp
- ./market/services:/app/services - ./market/services:/app/services
- ./market/templates:/app/templates - ./market/templates:/app/templates
@@ -118,6 +120,7 @@ services:
- ./cart/alembic.ini:/app/cart/alembic.ini:ro - ./cart/alembic.ini:/app/cart/alembic.ini:ro
- ./cart/alembic:/app/cart/alembic:ro - ./cart/alembic:/app/cart/alembic:ro
- ./cart/app.py:/app/app.py - ./cart/app.py:/app/app.py
- ./cart/sexp_components.py:/app/sexp_components.py
- ./cart/bp:/app/bp - ./cart/bp:/app/bp
- ./cart/services:/app/services - ./cart/services:/app/services
- ./cart/templates:/app/templates - ./cart/templates:/app/templates
@@ -154,6 +157,7 @@ services:
- ./events/alembic.ini:/app/events/alembic.ini:ro - ./events/alembic.ini:/app/events/alembic.ini:ro
- ./events/alembic:/app/events/alembic:ro - ./events/alembic:/app/events/alembic:ro
- ./events/app.py:/app/app.py - ./events/app.py:/app/app.py
- ./events/sexp_components.py:/app/sexp_components.py
- ./events/bp:/app/bp - ./events/bp:/app/bp
- ./events/services:/app/services - ./events/services:/app/services
- ./events/templates:/app/templates - ./events/templates:/app/templates
@@ -190,6 +194,7 @@ services:
- ./federation/alembic.ini:/app/federation/alembic.ini:ro - ./federation/alembic.ini:/app/federation/alembic.ini:ro
- ./federation/alembic:/app/federation/alembic:ro - ./federation/alembic:/app/federation/alembic:ro
- ./federation/app.py:/app/app.py - ./federation/app.py:/app/app.py
- ./federation/sexp_components.py:/app/sexp_components.py
- ./federation/bp:/app/bp - ./federation/bp:/app/bp
- ./federation/services:/app/services - ./federation/services:/app/services
- ./federation/templates:/app/templates - ./federation/templates:/app/templates
@@ -226,6 +231,7 @@ services:
- ./account/alembic.ini:/app/account/alembic.ini:ro - ./account/alembic.ini:/app/account/alembic.ini:ro
- ./account/alembic:/app/account/alembic:ro - ./account/alembic:/app/account/alembic:ro
- ./account/app.py:/app/app.py - ./account/app.py:/app/app.py
- ./account/sexp_components.py:/app/sexp_components.py
- ./account/bp:/app/bp - ./account/bp:/app/bp
- ./account/services:/app/services - ./account/services:/app/services
- ./account/templates:/app/templates - ./account/templates:/app/templates
@@ -324,6 +330,7 @@ services:
- ./orders/alembic.ini:/app/orders/alembic.ini:ro - ./orders/alembic.ini:/app/orders/alembic.ini:ro
- ./orders/alembic:/app/orders/alembic:ro - ./orders/alembic:/app/orders/alembic:ro
- ./orders/app.py:/app/app.py - ./orders/app.py:/app/app.py
- ./orders/sexp_components.py:/app/sexp_components.py
- ./orders/bp:/app/bp - ./orders/bp:/app/bp
- ./orders/services:/app/services - ./orders/services:/app/services
- ./orders/templates:/app/templates - ./orders/templates:/app/templates

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path 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 pathlib import Path
from quart import g, abort, request from quart import g, abort, request

View File

@@ -65,19 +65,14 @@ def register() -> Blueprint:
entries, has_more, pending_tickets, page_info = await _load_entries(page) entries, has_more, pending_tickets, page_info = await _load_entries(page)
ctx = dict( from shared.sexp.page import get_template_context
entries=entries, from sexp_components import render_all_events_page, render_all_events_oob
has_more=has_more,
pending_tickets=pending_tickets,
page_info=page_info,
page=page,
view=view,
)
ctx = await get_template_context()
if is_htmx_request(): 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: 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) return await make_response(html, 200)
@@ -88,15 +83,8 @@ def register() -> Blueprint:
entries, has_more, pending_tickets, page_info = await _load_entries(page) entries, has_more, pending_tickets, page_info = await _load_entries(page)
html = await render_template( from sexp_components import render_all_events_cards
"_types/all_events/_cards.html", html = await render_all_events_cards(entries, has_more, pending_tickets, page_info, page, view)
entries=entries,
has_more=has_more,
pending_tickets=pending_tickets,
page_info=page_info,
page=page,
view=view,
)
return await make_response(html, 200) return await make_response(html, 200)
@bp.post("/all-tickets/adjust") @bp.post("/all-tickets/adjust")

View File

@@ -19,13 +19,14 @@ def register():
async def admin(calendar_slug: str, **kwargs): async def admin(calendar_slug: str, **kwargs):
from shared.browser.app.utils.htmx import is_htmx_request 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(): if not is_htmx_request():
# Normal browser request: full page with layout html = await render_calendar_admin_page(tctx)
html = await render_template("_types/calendar/admin/index.html")
else: else:
# HTMX request: main panel + OOB elements html = await render_calendar_admin_oob(tctx)
html = await render_template("_types/calendar/admin/_oob_elements.html")
return await make_response(html) return await make_response(html)

View File

@@ -142,47 +142,25 @@ def register():
user_entries = visible.user_entries user_entries = visible.user_entries
confirmed_entries = visible.confirmed_entries confirmed_entries = visible.confirmed_entries
if not is_htmx_request(): from shared.sexp.page import get_template_context
# Normal browser request: full page with layout from sexp_components import render_calendar_page, render_calendar_oob
html = await render_template(
"_types/calendar/index.html", tctx = await get_template_context()
tctx.update(dict(
qsession=qsession, qsession=qsession,
year=year, year=year, month=month, month_name=month_name,
month=month, weekday_names=weekday_names, weeks=weeks,
month_name=month_name, prev_month=prev_month, prev_month_year=prev_month_year,
weekday_names=weekday_names, next_month=next_month, next_month_year=next_month_year,
weeks=weeks, prev_year=prev_year, next_year=next_year,
prev_month=prev_month, user_entries=user_entries, confirmed_entries=confirmed_entries,
prev_month_year=prev_month_year, month_entries=month_entries,
next_month=next_month, ))
next_month_year=next_month_year, if not is_htmx_request():
prev_year=prev_year, html = await render_calendar_page(tctx)
next_year=next_year,
user_entries=user_entries,
confirmed_entries=confirmed_entries,
month_entries=month_entries,
)
else: else:
html = await render_calendar_oob(tctx)
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,
)
return await make_response(html) return await make_response(html)

View File

@@ -35,14 +35,14 @@ def register():
@bp.get("/") @bp.get("/")
@cache_page(tag="calendars") @cache_page(tag="calendars")
async def home(**kwargs): 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(): if not is_htmx_request():
html = await render_template( html = await render_calendars_page(ctx)
"_types/calendars/index.html",
)
else: else:
html = await render_template( html = await render_calendars_oob(ctx)
"_types/calendars/_oob_elements.html",
)
return await make_response(html) return await make_response(html)

View File

@@ -17,12 +17,14 @@ def register():
async def admin(year: int, month: int, day: int, **kwargs): async def admin(year: int, month: int, day: int, **kwargs):
from shared.browser.app.utils.htmx import is_htmx_request 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(): if not is_htmx_request():
# Normal browser request: full page with layout html = await render_day_admin_page(tctx)
html = await render_template("_types/day/admin/index.html")
else: else:
html = await render_template("_types/day/admin/_oob_elements.html") html = await render_day_admin_oob(tctx)
return await make_response(html) return await make_response(html)
return bp return bp

View File

@@ -120,16 +120,14 @@ def register():
- all confirmed + provisional + ordered entries for that day (all users) - all confirmed + provisional + ordered entries for that day (all users)
- pending only for current user/session - 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(): if not is_htmx_request():
# Normal browser request: full page with layout html = await render_day_page(tctx)
html = await render_template(
"_types/day/index.html",
)
else: else:
html = await render_day_oob(tctx)
html = await render_template(
"_types/day/_oob_elements.html",
)
return await make_response(html) return await make_response(html)
@bp.get("/w/<widget_domain>/") @bp.get("/w/<widget_domain>/")

View File

@@ -23,10 +23,14 @@ def register():
@bp.get("/") @bp.get("/")
async def home(**kwargs): 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(): if not is_htmx_request():
html = await render_template("_types/markets/index.html") html = await render_markets_page(ctx)
else: else:
html = await render_template("_types/markets/_oob_elements.html") html = await render_markets_oob(ctx)
return await make_response(html) return await make_response(html)
@bp.post("/new/") @bp.post("/new/")

View File

@@ -45,19 +45,14 @@ def register() -> Blueprint:
entries, has_more, pending_tickets = await _load_entries(post["id"], page) entries, has_more, pending_tickets = await _load_entries(post["id"], page)
ctx = dict( from shared.sexp.page import get_template_context
entries=entries, from sexp_components import render_page_summary_page, render_page_summary_oob
has_more=has_more,
pending_tickets=pending_tickets,
page_info={},
page=page,
view=view,
)
ctx = await get_template_context()
if is_htmx_request(): 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: 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) return await make_response(html, 200)
@@ -69,15 +64,8 @@ def register() -> Blueprint:
entries, has_more, pending_tickets = await _load_entries(post["id"], page) entries, has_more, pending_tickets = await _load_entries(post["id"], page)
html = await render_template( from sexp_components import render_page_summary_cards
"_types/page_summary/_cards.html", html = await render_page_summary_cards(entries, has_more, pending_tickets, {}, page, view, post)
entries=entries,
has_more=has_more,
pending_tickets=pending_tickets,
page_info={},
page=page,
view=view,
)
return await make_response(html, 200) return await make_response(html, 200)
@bp.post("/tickets/adjust") @bp.post("/tickets/adjust")

View File

@@ -42,11 +42,17 @@ def register():
@bp.get("/") @bp.get("/")
@require_admin @require_admin
async def home(**kwargs): 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(): if not is_htmx_request():
html = await render_template("_types/payments/index.html", **ctx) html = await render_payments_page(ctx)
else: else:
html = await render_template("_types/payments/_oob_elements.html", **ctx) html = await render_payments_oob(ctx)
return await make_response(html) return await make_response(html)
@bp.put("/") @bp.put("/")

View File

@@ -70,18 +70,14 @@ def register() -> Blueprint:
"reserved": reserved or 0, "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(): if not is_htmx_request():
html = await render_template( html = await render_ticket_admin_page(ctx, tickets, stats)
"_types/ticket_admin/index.html",
tickets=tickets,
stats=stats,
)
else: else:
html = await render_template( html = await render_ticket_admin_oob(ctx, tickets, stats)
"_types/ticket_admin/_main_panel.html",
tickets=tickets,
stats=stats,
)
return await make_response(html, 200) return await make_response(html, 200)

View File

@@ -50,16 +50,14 @@ def register() -> Blueprint:
session_id=ident["session_id"], 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(): if not is_htmx_request():
html = await render_template( html = await render_tickets_page(ctx, tickets)
"_types/tickets/index.html",
tickets=tickets,
)
else: else:
html = await render_template( html = await render_tickets_oob(ctx, tickets)
"_types/tickets/_main_panel.html",
tickets=tickets,
)
return await make_response(html, 200) return await make_response(html, 200)
@@ -83,16 +81,14 @@ def register() -> Blueprint:
else: else:
return await make_response("Ticket not found", 404) 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(): if not is_htmx_request():
html = await render_template( html = await render_ticket_detail_page(ctx, ticket)
"_types/tickets/detail.html",
ticket=ticket,
)
else: else:
html = await render_template( html = await render_ticket_detail_oob(ctx, ticket)
"_types/tickets/_detail_panel.html",
ticket=ticket,
)
return await make_response(html, 200) return await make_response(html, 200)

1872
events/sexp_components.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path 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 pathlib import Path
from quart import g, request from quart import g, request
@@ -93,8 +94,13 @@ def create_app() -> "Quart":
# --- home page --- # --- home page ---
@app.get("/") @app.get("/")
async def home(): async def home():
from quart import render_template from quart import make_response
return await render_template("_types/federation/index.html") 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 return app

View File

@@ -100,7 +100,10 @@ def register(url_prefix="/auth"):
# If there's a pending redirect (e.g. OAuth authorize), follow it # If there's a pending redirect (e.g. OAuth authorize), follow it
redirect_url = pop_login_redirect_target() redirect_url = pop_login_redirect_target()
return redirect(redirect_url) 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/") @auth_bp.post("/start/")
async def start_login(): async def start_login():

View File

@@ -39,7 +39,11 @@ def register(url_prefix="/identity"):
if actor: if actor:
return redirect(url_for("activitypub.actor_profile", username=actor.preferred_username)) 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") @bp.post("/choose-username")
async def choose_username(): async def choose_username():

View File

@@ -39,12 +39,10 @@ def register(url_prefix="/social"):
return redirect(url_for("auth.login_form")) return redirect(url_for("auth.login_form"))
actor = _require_actor() actor = _require_actor()
items = await services.federation.get_home_timeline(g.s, actor.id) items = await services.federation.get_home_timeline(g.s, actor.id)
return await render_template( from shared.sexp.page import get_template_context
"federation/timeline.html", from sexp_components import render_timeline_page
items=items, ctx = await get_template_context()
timeline_type="home", return await render_timeline_page(ctx, items, "home", actor)
actor=actor,
)
@bp.get("/timeline") @bp.get("/timeline")
async def home_timeline_page(): async def home_timeline_page():
@@ -59,23 +57,17 @@ def register(url_prefix="/social"):
items = await services.federation.get_home_timeline( items = await services.federation.get_home_timeline(
g.s, actor.id, before=before, g.s, actor.id, before=before,
) )
return await render_template( from sexp_components import render_timeline_items
"federation/_timeline_items.html", return await render_timeline_items(items, "home", actor)
items=items,
timeline_type="home",
actor=actor,
)
@bp.get("/public") @bp.get("/public")
async def public_timeline(): async def public_timeline():
items = await services.federation.get_public_timeline(g.s) items = await services.federation.get_public_timeline(g.s)
actor = getattr(g, "_social_actor", None) actor = getattr(g, "_social_actor", None)
return await render_template( from shared.sexp.page import get_template_context
"federation/timeline.html", from sexp_components import render_timeline_page
items=items, ctx = await get_template_context()
timeline_type="public", return await render_timeline_page(ctx, items, "public", actor)
actor=actor,
)
@bp.get("/public/timeline") @bp.get("/public/timeline")
async def public_timeline_page(): async def public_timeline_page():
@@ -88,12 +80,8 @@ def register(url_prefix="/social"):
pass pass
items = await services.federation.get_public_timeline(g.s, before=before) items = await services.federation.get_public_timeline(g.s, before=before)
actor = getattr(g, "_social_actor", None) actor = getattr(g, "_social_actor", None)
return await render_template( from sexp_components import render_timeline_items
"federation/_timeline_items.html", return await render_timeline_items(items, "public", actor)
items=items,
timeline_type="public",
actor=actor,
)
# -- Compose -------------------------------------------------------------- # -- Compose --------------------------------------------------------------
@@ -101,11 +89,10 @@ def register(url_prefix="/social"):
async def compose_form(): async def compose_form():
actor = _require_actor() actor = _require_actor()
reply_to = request.args.get("reply_to") reply_to = request.args.get("reply_to")
return await render_template( from shared.sexp.page import get_template_context
"federation/compose.html", from sexp_components import render_compose_page
actor=actor, ctx = await get_template_context()
reply_to=reply_to, return await render_compose_page(ctx, actor, reply_to)
)
@bp.post("/compose") @bp.post("/compose")
async def compose_submit(): async def compose_submit():
@@ -148,15 +135,10 @@ def register(url_prefix="/social"):
g.s, actor.preferred_username, page=1, per_page=1000, g.s, actor.preferred_username, page=1, per_page=1000,
) )
followed_urls = {a.actor_url for a in following} followed_urls = {a.actor_url for a in following}
return await render_template( from shared.sexp.page import get_template_context
"federation/search.html", from sexp_components import render_search_page
query=query, ctx = await get_template_context()
actors=actors, return await render_search_page(ctx, query, actors, total, 1, followed_urls, actor)
total=total,
page=1,
followed_urls=followed_urls,
actor=actor,
)
@bp.get("/search/page") @bp.get("/search/page")
async def 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, g.s, actor.preferred_username, page=1, per_page=1000,
) )
followed_urls = {a.actor_url for a in following} followed_urls = {a.actor_url for a in following}
return await render_template( from sexp_components import render_search_results
"federation/_search_results.html", return await render_search_results(actors, query, page, followed_urls, actor)
actors=actors,
total=total,
page=page,
query=query,
followed_urls=followed_urls,
actor=actor,
)
@bp.post("/follow") @bp.post("/follow")
async def follow(): async def follow():
@@ -340,13 +315,10 @@ def register(url_prefix="/social"):
actors, total = await services.federation.get_following( actors, total = await services.federation.get_following(
g.s, actor.preferred_username, g.s, actor.preferred_username,
) )
return await render_template( from shared.sexp.page import get_template_context
"federation/following.html", from sexp_components import render_following_page
actors=actors, ctx = await get_template_context()
total=total, return await render_following_page(ctx, actors, total, actor)
page=1,
actor=actor,
)
@bp.get("/following/page") @bp.get("/following/page")
async def following_list_page(): async def following_list_page():
@@ -355,15 +327,8 @@ def register(url_prefix="/social"):
actors, total = await services.federation.get_following( actors, total = await services.federation.get_following(
g.s, actor.preferred_username, page=page, g.s, actor.preferred_username, page=page,
) )
return await render_template( from sexp_components import render_following_items
"federation/_actor_list_items.html", return await render_following_items(actors, page, actor)
actors=actors,
total=total,
page=page,
list_type="following",
followed_urls=set(),
actor=actor,
)
@bp.get("/followers") @bp.get("/followers")
async def followers_list(): async def followers_list():
@@ -376,14 +341,10 @@ def register(url_prefix="/social"):
g.s, actor.preferred_username, page=1, per_page=1000, g.s, actor.preferred_username, page=1, per_page=1000,
) )
followed_urls = {a.actor_url for a in following} followed_urls = {a.actor_url for a in following}
return await render_template( from shared.sexp.page import get_template_context
"federation/followers.html", from sexp_components import render_followers_page
actors=actors, ctx = await get_template_context()
total=total, return await render_followers_page(ctx, actors, total, followed_urls, actor)
page=1,
followed_urls=followed_urls,
actor=actor,
)
@bp.get("/followers/page") @bp.get("/followers/page")
async def followers_list_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, g.s, actor.preferred_username, page=1, per_page=1000,
) )
followed_urls = {a.actor_url for a in following} followed_urls = {a.actor_url for a in following}
return await render_template( from sexp_components import render_followers_items
"federation/_actor_list_items.html", return await render_followers_items(actors, page, followed_urls, actor)
actors=actors,
total=total,
page=page,
list_type="followers",
followed_urls=followed_urls,
actor=actor,
)
@bp.get("/actor/<int:id>") @bp.get("/actor/<int:id>")
async def actor_timeline(id: int): async def actor_timeline(id: int):
@@ -435,13 +389,10 @@ def register(url_prefix="/social"):
) )
).scalar_one_or_none() ).scalar_one_or_none()
is_following = existing is not None is_following = existing is not None
return await render_template( from shared.sexp.page import get_template_context
"federation/actor_timeline.html", from sexp_components import render_actor_timeline_page
remote_actor=remote_dto, ctx = await get_template_context()
items=items, return await render_actor_timeline_page(ctx, remote_dto, items, is_following, actor)
is_following=is_following,
actor=actor,
)
@bp.get("/actor/<int:id>/timeline") @bp.get("/actor/<int:id>/timeline")
async def actor_timeline_page(id: int): async def actor_timeline_page(id: int):
@@ -456,13 +407,8 @@ def register(url_prefix="/social"):
items = await services.federation.get_actor_timeline( items = await services.federation.get_actor_timeline(
g.s, id, before=before, g.s, id, before=before,
) )
return await render_template( from sexp_components import render_actor_timeline_items
"federation/_timeline_items.html", return await render_actor_timeline_items(items, id, actor)
items=items,
timeline_type="actor",
actor_id=id,
actor=actor,
)
# -- Notifications -------------------------------------------------------- # -- Notifications --------------------------------------------------------
@@ -471,11 +417,10 @@ def register(url_prefix="/social"):
actor = _require_actor() actor = _require_actor()
items = await services.federation.get_notifications(g.s, actor.id) items = await services.federation.get_notifications(g.s, actor.id)
await services.federation.mark_notifications_read(g.s, actor.id) await services.federation.mark_notifications_read(g.s, actor.id)
return await render_template( from shared.sexp.page import get_template_context
"federation/notifications.html", from sexp_components import render_notifications_page
notifications=items, ctx = await get_template_context()
actor=actor, return await render_notifications_page(ctx, items, actor)
)
@bp.get("/notifications/count") @bp.get("/notifications/count")
async def notification_count(): async def notification_count():

View File

@@ -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 (
'<nav class="flex gap-3 text-sm items-center">'
f'<a href="{choose_url}" class="px-2 py-1 rounded hover:bg-stone-200 font-bold">Choose username</a>'
'</nav>'
)
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 = ['<nav class="flex gap-3 text-sm items-center flex-wrap">']
for endpoint, label in links:
href = url_for(endpoint)
bold = " font-bold" if request.path == href else ""
parts.append(f'<a href="{href}" class="px-2 py-1 rounded hover:bg-stone-200{bold}">{label}</a>')
# Notifications with live badge
notif_url = url_for("social.notifications")
notif_count_url = url_for("social.notification_count")
notif_bold = " font-bold" if request.path == notif_url else ""
parts.append(
f'<a href="{notif_url}" class="px-2 py-1 rounded hover:bg-stone-200 relative{notif_bold}">Notifications'
f'<span hx-get="{notif_count_url}" hx-trigger="load, every 30s" hx-swap="innerHTML"'
f' class="absolute -top-2 -right-3 text-xs bg-red-500 text-white rounded-full px-1 empty:hidden"></span></a>'
)
# Profile link
profile_url = url_for("activitypub.actor_profile", username=actor.preferred_username)
parts.append(f'<a href="{profile_url}" class="px-2 py-1 rounded hover:bg-stone-200">@{actor.preferred_username}</a>')
parts.append('</nav>')
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'<title>{escape(title)}</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 = "&#x2665;"
else:
like_action = url_for("social.like")
like_cls = "hover:text-red-500"
like_icon = "&#x2661;"
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'<a href="{reply_url}" class="hover:text-stone-700">Reply</a>' if reply_url else ""
return (
f'<div class="flex items-center gap-4 mt-3 text-sm text-stone-500">'
f'<form hx-post="{like_action}" hx-target="{target}" hx-swap="innerHTML">'
f'<input type="hidden" name="object_id" value="{oid}">'
f'<input type="hidden" name="author_inbox" value="{ainbox}">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<button type="submit" class="flex items-center gap-1 {like_cls}"><span>{like_icon}</span> {lcount}</button></form>'
f'<form hx-post="{boost_action}" hx-target="{target}" hx-swap="innerHTML">'
f'<input type="hidden" name="object_id" value="{oid}">'
f'<input type="hidden" name="author_inbox" value="{ainbox}">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<button type="submit" class="flex items-center gap-1 {boost_cls}"><span>&#x21BB;</span> {bcount}</button></form>'
f'{reply_html}</div>'
)
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'<div class="text-sm text-stone-500 mb-2">Boosted by {escape(boosted_by)}</div>' if boosted_by else ""
if actor_icon:
avatar = f'<img src="{actor_icon}" alt="" class="w-10 h-10 rounded-full">'
else:
initial = actor_name[0].upper() if actor_name else "?"
avatar = f'<div class="w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm">{initial}</div>'
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'<details class="mt-2"><summary class="text-stone-500 cursor-pointer">CW: {escape(summary)}</summary>'
f'<div class="mt-2 prose prose-sm prose-stone max-w-none">{content}</div></details>'
)
else:
content_html = f'<div class="mt-2 prose prose-sm prose-stone max-w-none">{content}</div>'
original_html = ""
if url and post_type == "remote":
original_html = f'<a href="{url}" target="_blank" rel="noopener" class="text-sm text-stone-400 hover:underline mt-1 inline-block">original</a>'
interactions_html = ""
if actor:
oid = getattr(item, "object_id", "") or ""
safe_id = oid.replace("/", "_").replace(":", "_")
interactions_html = f'<div id="interactions-{safe_id}">{_interaction_buttons_html(item, actor)}</div>'
return (
f'<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4">'
f'{boost_html}'
f'<div class="flex items-start gap-3">{avatar}'
f'<div class="flex-1 min-w-0">'
f'<div class="flex items-baseline gap-2">'
f'<span class="font-semibold text-stone-900">{escape(actor_name)}</span>'
f'<span class="text-sm text-stone-500">@{escape(actor_username)}{domain_html}</span>'
f'<span class="text-sm text-stone-400 ml-auto">{time_html}</span></div>'
f'{content_html}{original_html}{interactions_html}</div></div></article>'
)
# ---------------------------------------------------------------------------
# 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'<div hx-get="{next_url}" hx-trigger="revealed" hx-swap="outerHTML"></div>')
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'<img src="{icon_url}" alt="" class="w-12 h-12 rounded-full">'
else:
initial = (display_name or username)[0].upper() if (display_name or username) else "?"
avatar = f'<div class="w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold">{initial}</div>'
# Name link
if list_type == "following" and aid:
name_html = f'<a href="{url_for("social.actor_timeline", id=aid)}" class="font-semibold text-stone-900 hover:underline">{escape(display_name)}</a>'
elif list_type == "search" and aid:
name_html = f'<a href="{url_for("social.actor_timeline", id=aid)}" class="font-semibold text-stone-900 hover:underline">{escape(display_name)}</a>'
else:
name_html = f'<a href="https://{domain}/@{username}" target="_blank" rel="noopener" class="font-semibold text-stone-900 hover:underline">{escape(display_name)}</a>'
summary_html = f'<div class="text-sm text-stone-600 mt-1 truncate">{summary}</div>' 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'<div class="flex-shrink-0"><form method="post" action="{url_for("social.unfollow")}"'
f' hx-post="{url_for("social.unfollow")}" hx-target="closest article" hx-swap="outerHTML">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<input type="hidden" name="actor_url" value="{actor_url}">'
f'<button type="submit" class="text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100">Unfollow</button></form></div>'
)
else:
label = "Follow Back" if list_type == "followers" else "Follow"
button_html = (
f'<div class="flex-shrink-0"><form method="post" action="{url_for("social.follow")}"'
f' hx-post="{url_for("social.follow")}" hx-target="closest article" hx-swap="outerHTML">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<input type="hidden" name="actor_url" value="{actor_url}">'
f'<button type="submit" class="text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700">{label}</button></form></div>'
)
return (
f'<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4" id="actor-{safe_id}">'
f'{avatar}<div class="flex-1 min-w-0">{name_html}'
f'<div class="text-sm text-stone-500">@{escape(username)}@{escape(domain)}</div>'
f'{summary_html}</div>{button_html}</article>'
)
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'<div hx-get="{next_url}" hx-trigger="revealed" hx-swap="outerHTML"></div>')
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'<div hx-get="{next_url}" hx-trigger="revealed" hx-swap="outerHTML"></div>')
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'<img src="{from_icon}" alt="" class="w-8 h-8 rounded-full">'
else:
initial = from_name[0].upper() if from_name else "?"
avatar = f'<div class="w-8 h-8 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xs">{initial}</div>'
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'<div class="text-sm text-stone-500 mt-1 truncate">{escape(preview)}</div>' if preview else ""
time_html = created.strftime("%b %d, %H:%M") if created else ""
return (
f'<div class="bg-white rounded-lg shadow-sm border border-stone-200 p-4{border}">'
f'<div class="flex items-start gap-3">{avatar}<div class="flex-1">'
f'<div class="text-sm"><span class="font-semibold">{escape(from_name)}</span>'
f' <span class="text-stone-500">@{escape(from_username)}{domain_html}</span>'
f' <span class="text-stone-600">{action}</span></div>'
f'{preview_html}<div class="text-xs text-stone-400 mt-1">{time_html}</div></div></div></div>'
)
# ---------------------------------------------------------------------------
# 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'<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">{error}</div>' if error else ""
content = (
f'<div class="py-8 max-w-md mx-auto"><h1 class="text-2xl font-bold mb-6">Sign in</h1>{error_html}'
f'<form method="post" action="{action}" class="space-y-4">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<div><label for="email" class="block text-sm font-medium mb-1">Email address</label>'
f'<input type="email" name="email" id="email" value="{escape(email)}" required autofocus'
f' class="w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"></div>'
f'<button type="submit" class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition">'
f'Send magic link</button></form></div>'
)
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr, content_html=content,
meta_html="<title>Login \u2014 Rose Ash</title>")
# ---------------------------------------------------------------------------
# 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'<a href="{compose_url}" class="bg-stone-800 text-white px-4 py-2 rounded hover:bg-stone-700">Compose</a>'
timeline_html = _timeline_items_html(items, timeline_type, actor)
content = (
f'<div class="flex items-center justify-between mb-6">'
f'<h1 class="text-2xl font-bold">{label} Timeline</h1>{compose_html}</div>'
f'<div id="timeline">{timeline_html}</div>'
)
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'<input type="hidden" name="in_reply_to" value="{escape(reply_to)}">'
f'<div class="text-sm text-stone-500">Replying to <span class="font-mono">{escape(reply_to)}</span></div>'
)
content = (
f'<h1 class="text-2xl font-bold mb-6">Compose</h1>'
f'<form method="post" action="{action}" class="space-y-4">'
f'<input type="hidden" name="csrf_token" value="{csrf}">{reply_html}'
f'<textarea name="content" rows="6" maxlength="5000" required'
f' class="w-full border border-stone-300 rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-stone-500"'
f' placeholder="What\'s on your mind?"></textarea>'
f'<div class="flex items-center justify-between">'
f'<select name="visibility" class="border border-stone-300 rounded px-3 py-1.5 text-sm">'
f'<option value="public">Public</option><option value="unlisted">Unlisted</option>'
f'<option value="followers">Followers only</option></select>'
f'<button type="submit" class="bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700">Publish</button></div></form>'
)
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'<p class="text-sm text-stone-500 mb-4">{total} result{s} for <strong>{escape(query)}</strong></p>'
elif query:
info_html = f'<p class="text-stone-500 mb-4">No results found for <strong>{escape(query)}</strong></p>'
content = (
f'<h1 class="text-2xl font-bold mb-6">Search</h1>'
f'<form method="get" action="{search_url}" class="mb-6"'
f' hx-get="{search_page_url}" hx-target="#search-results" hx-push-url="{search_url}">'
f'<div class="flex gap-2"><input type="text" name="q" value="{escape(query)}"'
f' class="flex-1 border border-stone-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"'
f' placeholder="Search users or @user@instance.tld">'
f'<button type="submit" class="bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700">Search</button></div></form>'
f'{info_html}<div id="search-results">{results_html}</div>'
)
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'<h1 class="text-2xl font-bold mb-6">Following <span class="text-stone-400 font-normal">({total})</span></h1>'
f'<div id="actor-list">{items_html}</div>'
)
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'<h1 class="text-2xl font-bold mb-6">Followers <span class="text-stone-400 font-normal">({total})</span></h1>'
f'<div id="actor-list">{items_html}</div>'
)
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'<img src="{icon_url}" alt="" class="w-16 h-16 rounded-full">'
else:
initial = display_name[0].upper() if display_name else "?"
avatar = f'<div class="w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl">{initial}</div>'
summary_html = f'<div class="text-sm text-stone-600 mt-2">{summary}</div>' if summary else ""
follow_html = ""
if actor:
if is_following:
follow_html = (
f'<div class="flex-shrink-0"><form method="post" action="{url_for("social.unfollow")}">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<input type="hidden" name="actor_url" value="{actor_url}">'
f'<button type="submit" class="border border-stone-300 rounded px-4 py-2 hover:bg-stone-100">Unfollow</button></form></div>'
)
else:
follow_html = (
f'<div class="flex-shrink-0"><form method="post" action="{url_for("social.follow")}">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<input type="hidden" name="actor_url" value="{actor_url}">'
f'<button type="submit" class="bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700">Follow</button></form></div>'
)
timeline_html = _timeline_items_html(items, "actor", actor, remote_actor.id)
content = (
f'<div class="bg-white rounded-lg shadow-sm border border-stone-200 p-6 mb-6">'
f'<div class="flex items-center gap-4">{avatar}'
f'<div class="flex-1"><h1 class="text-xl font-bold">{escape(display_name)}</h1>'
f'<div class="text-stone-500">@{escape(remote_actor.preferred_username)}@{escape(remote_actor.domain)}</div>'
f'{summary_html}</div>{follow_html}</div></div>'
f'<div id="timeline">{timeline_html}</div>'
)
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 = '<p class="text-stone-500">No notifications yet.</p>'
else:
notif_html = '<div class="space-y-2">' + "".join(_notification_html(n) for n in notifications) + '</div>'
content = f'<h1 class="text-2xl font-bold mb-6">Notifications</h1>{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'<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">{error}</div>' if error else ""
content = (
f'<div class="py-8 max-w-md mx-auto">'
f'<h1 class="text-2xl font-bold mb-2">Choose your username</h1>'
f'<p class="text-stone-600 mb-6">This will be your identity on the fediverse: '
f'<strong>@username@{escape(ap_domain)}</strong></p>'
f'{error_html}'
f'<form method="post" class="space-y-4">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
f'<div><label for="username" class="block text-sm font-medium mb-1">Username</label>'
f'<div class="flex items-center"><span class="text-stone-400 mr-1">@</span>'
f'<input type="text" name="username" id="username" value="{escape(username)}"'
f' pattern="[a-z][a-z0-9_]{{2,31}}" minlength="3" maxlength="32" required autocomplete="off"'
f' class="flex-1 border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"'
f' hx-get="{check_url}" hx-trigger="keyup changed delay:300ms" hx-target="#username-status" hx-include="[name=\'username\']">'
f'</div><div id="username-status" class="text-sm mt-1"></div>'
f'<p class="text-xs text-stone-400 mt-1">3-32 characters. Lowercase letters, numbers, underscores. Must start with a letter.</p></div>'
f'<button type="submit" class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition">Claim username</button></form></div>'
)
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'<p class="mt-2">{escape(actor.summary)}</p>' 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'<span class="text-sm text-stone-500">{a.object_type}</span>' if a.object_type else ""
parts.append(
f'<div class="bg-white rounded-lg shadow p-4"><div class="flex justify-between items-start">'
f'<span class="font-medium">{a.activity_type}</span>'
f'<span class="text-sm text-stone-400">{published}</span></div>{obj_type}</div>'
)
activities_html = '<div class="space-y-4">' + "".join(parts) + '</div>'
else:
activities_html = '<p class="text-stone-500">No activities yet.</p>'
content = (
f'<div class="py-8"><div class="bg-white rounded-lg shadow p-6 mb-6">'
f'<h1 class="text-2xl font-bold">{escape(display_name)}</h1>'
f'<p class="text-stone-500">@{escape(actor.preferred_username)}@{escape(ap_domain)}</p>'
f'{summary_html}</div>'
f'<h2 class="text-xl font-bold mb-4">Activities ({total})</h2>{activities_html}</div>'
)
return _social_page(ctx, actor, content_html=content,
title=f"@{actor.preferred_username} \u2014 Rose Ash")

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path 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 pathlib import Path

View File

@@ -55,10 +55,14 @@ def register() -> Blueprint:
page=page, 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(): 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: 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) return await make_response(html, 200)
@@ -67,13 +71,8 @@ def register() -> Blueprint:
page = int(request.args.get("page", 1)) page = int(request.args.get("page", 1))
markets, has_more, page_info = await _load_markets(page) markets, has_more, page_info = await _load_markets(page)
html = await render_template( from sexp_components import render_all_markets_cards
"_types/all_markets/_cards.html", html = await render_all_markets_cards(markets, has_more, page_info, page)
markets=markets,
has_more=has_more,
page_info=page_info,
page=page,
)
return await make_response(html, 200) return await make_response(html, 200)
return bp return bp

View File

@@ -42,12 +42,15 @@ def register():
p_data = getattr(g, "post_data", None) or {} p_data = getattr(g, "post_data", None) or {}
# Determine which template to use based on request type # 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(): if not is_htmx_request():
# Normal browser request: full page with layout html = await render_market_home_page(ctx)
html = await render_template("_types/market/index.html", **p_data)
else: else:
# HTMX request: main panel + OOB elements html = await render_market_home_oob(ctx)
html = await render_template("_types/market/_oob_elements.html", **p_data)
return await make_response(html) return await make_response(html)
@@ -70,16 +73,18 @@ def register():
product_info = await _productInfo() product_info = await _productInfo()
full_context = {**product_info, **ctx} 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(): if not is_htmx_request():
# Normal browser request: full page with layout html = await render_browse_page(tctx)
html = await render_template("_types/browse/index.html", **full_context)
elif product_info["page"] > 1: elif product_info["page"] > 1:
# HTMX pagination: just product cards + sentinel tctx.update(product_info)
html = await render_template("_types/browse/_product_cards.html", **product_info) html = await render_browse_cards(tctx)
else: else:
# HTMX navigation (page 1): main panel + OOB elements html = await render_browse_oob(tctx)
html = await render_template("_types/browse/_oob_elements.html", **full_context)
resp = await make_response(html) resp = await make_response(html)
resp.headers["Hx-Push-Url"] = _current_url_without_page() resp.headers["Hx-Push-Url"] = _current_url_without_page()
@@ -107,15 +112,18 @@ def register():
product_info = await _productInfo(top_slug) product_info = await _productInfo(top_slug)
full_context = {**product_info, **ctx} 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(): if not is_htmx_request():
# Normal browser request: full page with layout html = await render_browse_page(tctx)
html = await render_template("_types/browse/index.html", **full_context)
elif product_info["page"] > 1: elif product_info["page"] > 1:
# HTMX pagination: just product cards + sentinel tctx.update(product_info)
html = await render_template("_types/browse/_product_cards.html", **product_info) html = await render_browse_cards(tctx)
else: else:
html = await render_template("_types/browse/_oob_elements.html", **full_context) html = await render_browse_oob(tctx)
resp = await make_response(html) resp = await make_response(html)
resp.headers["Hx-Push-Url"] = _current_url_without_page() resp.headers["Hx-Push-Url"] = _current_url_without_page()
@@ -143,16 +151,18 @@ def register():
product_info = await _productInfo(top_slug, sub_slug) product_info = await _productInfo(top_slug, sub_slug)
full_context = {**product_info, **ctx} 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(): if not is_htmx_request():
# Normal browser request: full page with layout html = await render_browse_page(tctx)
html = await render_template("_types/browse/index.html", **full_context)
elif product_info["page"] > 1: elif product_info["page"] > 1:
# HTMX pagination: just product cards + sentinel tctx.update(product_info)
html = await render_template("_types/browse/_product_cards.html", **product_info) html = await render_browse_cards(tctx)
else: else:
# HTMX navigation (page 1): main panel + OOB elements html = await render_browse_oob(tctx)
html = await render_template("_types/browse/_oob_elements.html", **full_context)
resp = await make_response(html) resp = await make_response(html)
resp.headers["Hx-Push-Url"] = _current_url_without_page() resp.headers["Hx-Push-Url"] = _current_url_without_page()

View File

@@ -17,12 +17,14 @@ def register():
async def admin(): async def admin():
from shared.browser.app.utils.htmx import is_htmx_request 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(): if not is_htmx_request():
# Normal browser request: full page with layout html = await render_market_admin_page(tctx)
html = await render_template("_types/market/admin/index.html")
else: else:
html = await render_template("_types/market/admin/_oob_elements.html") html = await render_market_admin_oob(tctx)
return await make_response(html) return await make_response(html)
return bp return bp

View File

@@ -39,10 +39,15 @@ def register() -> Blueprint:
page=page, 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(): 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: 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) return await make_response(html, 200)
@@ -53,13 +58,9 @@ def register() -> Blueprint:
markets, has_more = await _load_markets(post["id"], page) markets, has_more = await _load_markets(post["id"], page)
html = await render_template( from sexp_components import render_page_markets_cards
"_types/page_markets/_cards.html", post_slug = post.get("slug", "")
markets=markets, html = await render_page_markets_cards(markets, has_more, page, post_slug)
has_more=has_more,
page_info={},
page=page,
)
return await make_response(html, 200) return await make_response(html, 200)
return bp return bp

View File

@@ -107,13 +107,17 @@ def register():
async def product_detail(): async def product_detail():
from shared.browser.app.utils.htmx import is_htmx_request 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(): if not is_htmx_request():
# Normal browser request: full page with layout html = await render_product_page(tctx, d)
html = await render_template("_types/product/index.html")
else: else:
# HTMX request: main panel + OOB elements html = await render_product_oob(tctx, d)
html = await render_template("_types/product/_oob_elements.html")
return html return html
@@ -151,12 +155,17 @@ def register():
async def admin(): async def admin():
from shared.browser.app.utils.htmx import is_htmx_request 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(): if not is_htmx_request():
# Normal browser request: full page with layout html = await render_product_admin_page(tctx, d)
html = await render_template("_types/product/admin/index.html")
else: else:
# HTMX request: main panel + OOB elements html = await render_product_admin_oob(tctx, d)
html = await render_template("_types/product/admin/_oob_elements.html")
return await make_response(html) return await make_response(html)

1584
market/sexp_components.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path 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 pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
@@ -69,6 +70,10 @@ def create_app() -> "Quart":
app.jinja_loader, 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_fragments())
app.register_blueprint(register_actions()) app.register_blueprint(register_actions())
app.register_blueprint(register_data()) app.register_blueprint(register_data())

View File

@@ -9,6 +9,7 @@ from shared.browser.app.payments.sumup import create_checkout as sumup_create_ch
from shared.config import config from shared.config import config
from shared.infrastructure.cart_identity import current_cart_identity 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 services.check_sumup_status import check_sumup_status
from shared.browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
@@ -46,10 +47,16 @@ def register() -> Blueprint:
order = result.scalar_one_or_none() order = result.scalar_one_or_none()
if not order: if not order:
return await make_response("Order not found", 404) 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(): 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: 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) return await make_response(html)
@bp.get("/pay/") @bp.get("/pay/")

View File

@@ -116,20 +116,30 @@ def register(url_prefix: str) -> Blueprint:
result = await g.s.execute(stmt) result = await g.s.execute(stmt)
orders = result.scalars().all() orders = result.scalars().all()
context = { from shared.sexp.page import get_template_context
"orders": orders, from sexp_components import (
"page": page, render_orders_page,
"total_pages": total_pages, render_orders_rows,
"search": search, render_orders_oob,
"search_count": total_count, )
}
ctx = await get_template_context()
qs_fn = makeqs_factory()
if not is_htmx_request(): 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: 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: 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 = await make_response(html)
resp.headers["Hx-Push-Url"] = _current_url_without_page() resp.headers["Hx-Push-Url"] = _current_url_without_page()

385
orders/sexp_components.py Normal file
View File

@@ -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'<tr class="hidden sm:table-row border-t border-stone-100 hover:bg-stone-50/60">'
f'<td class="px-3 py-2 align-top"><span class="font-mono text-[11px] sm:text-xs">#{order.id}</span></td>'
f'<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">{created}</td>'
f'<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">{order.description or ""}</td>'
f'<td class="px-3 py-2 align-top text-stone-700 text-xs sm:text-sm">{total}</td>'
f'<td class="px-3 py-2 align-top"><span class="inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] sm:text-xs {pill}">{status}</span></td>'
f'<td class="px-3 py-0.5 align-top text-right"><a href="{detail_url}" class="inline-flex items-center px-3 py-1.5 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition">View</a></td></tr>'
# Mobile row
f'<tr class="sm:hidden border-t border-stone-100"><td colspan="5" class="px-3 py-3"><div class="flex flex-col gap-2 text-xs">'
f'<div class="flex items-center justify-between gap-2"><span class="font-mono text-[11px] text-stone-700">#{order.id}</span>'
f'<span class="inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] {pill}">{status}</span></div>'
f'<div class="text-[11px] text-stone-500 break-words">{created}</div>'
f'<div class="flex items-center justify-between gap-2"><div class="font-medium text-stone-800">{total}</div>'
f'<a href="{detail_url}" class="inline-flex items-center px-2 py-1 text-[11px] rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition shrink-0">View</a></div></div></td></tr>'
)
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('<tr><td colspan="5" class="px-3 py-4 text-center text-xs text-stone-400">End of results</td></tr>')
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 (
'<div class="max-w-full px-3 py-3 space-y-3">'
'<div class="rounded-2xl border border-dashed border-stone-300 bg-white/80 p-4 sm:p-6 text-sm text-stone-700">'
'No orders yet.</div></div>'
)
return (
'<div class="max-w-full px-3 py-3 space-y-3">'
'<div class="overflow-x-auto rounded-2xl border border-stone-200 bg-white/80">'
'<table class="min-w-full text-xs sm:text-sm">'
'<thead class="bg-stone-50 border-b border-stone-200 text-stone-600"><tr>'
'<th class="px-3 py-2 text-left font-medium">Order</th>'
'<th class="px-3 py-2 text-left font-medium">Created</th>'
'<th class="px-3 py-2 text-left font-medium">Description</th>'
'<th class="px-3 py-2 text-left font-medium">Total</th>'
'<th class="px-3 py-2 text-left font-medium">Status</th>'
'<th class="px-3 py-2 text-left font-medium"></th>'
f'</tr></thead><tbody>{rows_html}</tbody></table></div></div>'
)
def _orders_summary_html(ctx: dict) -> str:
"""Filter section for orders list."""
return (
'<header class="mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4">'
'<div class="space-y-1"><p class="text-xs sm:text-sm text-stone-600">Recent orders placed via the checkout.</p></div>'
f'<div class="md:hidden">{search_mobile_html(ctx)}</div>'
'</header>'
)
# ---------------------------------------------------------------------------
# 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'<img src="{item.product_image}" alt="{item.product_title or "Product image"}"'
f' class="w-full h-full object-contain object-center" loading="lazy" decoding="async">'
if item.product_image else
'<div class="w-full h-full flex items-center justify-center text-[9px] text-stone-400">No image</div>'
)
items.append(
f'<li><a class="w-full py-2 flex gap-3" href="{prod_url}">'
f'<div class="w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden">{img}</div>'
f'<div class="flex-1 flex justify-between gap-3">'
f'<div><p class="font-medium">{item.product_title or "Unknown product"}</p>'
f'<p class="text-[11px] text-stone-500">Product ID: {item.product_id}</p></div>'
f'<div class="text-right whitespace-nowrap"><p>Qty: {item.quantity}</p>'
f'<p>{item.currency or order.currency or "GBP"} {item.unit_price or 0:.2f}</p>'
f'</div></div></a></li>'
)
return (
'<div class="rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6">'
'<h2 class="text-sm sm:text-base font-semibold mb-3">Items</h2>'
f'<ul class="divide-y divide-stone-100 text-xs sm:text-sm">{"".join(items)}</ul></div>'
)
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'<li class="px-4 py-3 flex items-start justify-between text-sm">'
f'<div><div class="font-medium flex items-center gap-2">{e.name}'
f'<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium {pill}">'
f'{st.capitalize()}</span></div>'
f'<div class="text-xs text-stone-500">{ds}</div></div>'
f'<div class="ml-4 font-medium">\u00a3{e.cost or 0:.2f}</div></li>'
)
return (
'<section class="mt-6 space-y-3">'
'<h2 class="text-base sm:text-lg font-semibold">Calendar bookings in this order</h2>'
f'<ul class="divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80">{"".join(items)}</ul></section>'
)
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'<div class="max-w-full px-3 py-3 space-y-4">{summary}{_order_items_html(order)}{_calendar_items_html(calendar_entries)}</div>'
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'<a href="{pay_url}" class="inline-flex items-center px-3 py-2 text-xs sm:text-sm '
f'rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition">'
f'<i class="fa fa-credit-card mr-2" aria-hidden="true"></i>Open payment page</a>'
) if status != "paid" else ""
return (
'<header class="mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4">'
f'<div class="space-y-1"><p class="text-xs sm:text-sm text-stone-600">Placed {created} &middot; Status: {status}</p></div>'
'<div class="flex w-full sm:w-auto justify-start sm:justify-end gap-2">'
f'<a href="{list_url}" class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"><i class="fa-solid fa-list mr-2" aria-hidden="true"></i>All orders</a>'
f'<form method="post" action="{recheck_url}" class="inline"><input type="hidden" name="csrf_token" value="{csrf_token}">'
f'<button type="submit" class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"><i class="fa-solid fa-rotate mr-2" aria-hidden="true"></i>Re-check status</button></form>'
f'{pay}</div></header>'
)
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)

View File

@@ -16,6 +16,51 @@ from shared.utils import host_url
from shared.browser.app.utils import current_route_relative_path 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: async def base_context() -> dict:
""" """
Common template variables available in every app. 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"), ("price-desc", "\u00a3 high\u2192low", "order/h-l.svg"),
], ],
"zap_filter": zap_filter, "zap_filter": zap_filter,
"qs_filter": _qs_filter_fn(),
"print": print, "print": print,
"base_url": base_url, "base_url": base_url,
"base_title": config()["title"], "base_title": config()["title"],

View File

@@ -23,6 +23,18 @@ def load_shared_components() -> None:
register_components(_POST_CARD) register_components(_POST_CARD)
register_components(_BASE_SHELL) register_components(_BASE_SHELL)
register_components(_ERROR_PAGE) 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" (div :class "flex justify-center"
(img :src image :width "300" :height "300")))))) (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 <html><head>...<body> 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! "<!doctype html>")
(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 <details>/<summary> 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
"<div class=\"max-w-screen-2xl mx-auto py-1 px-1\">"
"<div class=\"w-full\">"
"<details class=\"group/root p-2\" data-toggle-group=\"mobile-panels\">"
"<summary>"
"<header class=\"z-50\">"
"<div id=\"root-header-summary\" class=\"flex items-start gap-2 p-1 bg-" colour "-500\">"
"<div class=\"flex flex-col w-full items-center\">"
header-rows-html
"</div>"
"</div>"
"</header>"
"</summary>"
"<div id=\"root-menu\" hx-swap-oob=\"outerHTML\" class=\"md:hidden\">"
(or menu-html "")
"</div>"
"</details>"
"</div>"
"<div id=\"filter\">"
(or filter-html "")
"</div>"
"<main id=\"root-panel\" class=\"max-w-full\">"
"<div class=\"md:min-h-0\">"
"<div class=\"flex flex-row md:h-full md:min-h-0\">"
"<aside id=\"aside\" class=\"hidden md:flex md:flex-col max-w-xs md:h-full md:min-h-0 mr-3\">"
(or aside-html "")
"</aside>"
"<section id=\"main-panel\" class=\"flex-1 md:h-full md:min-h-0 overflow-y-auto overscroll-contain js-grid-viewport\">"
(or content-html "")
"<div class=\"pb-8\"></div>"
"</section>"
"</div>"
"</div>"
"</main>"
"</div>"))))
'''
# ---------------------------------------------------------------------------
# ~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 <tr> 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
"<tr id=\"" id-prefix "-sentinel-" page "\""
" hx-get=\"" url "\""
" hx-trigger=\"intersect once delay:250ms, sentinel:retry\""
" hx-swap=\"outerHTML\""
" _=\""
"init "
"if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end "
"on sentinel:retry "
"remove .hidden from .js-loading in me "
"add .hidden to .js-neterr in me "
"set me.style.pointerEvents to 'none' "
"set me.style.opacity to '0' "
"trigger htmx:consume on me "
"call htmx.trigger(me, 'intersect') "
"end "
"def backoff() "
"add .hidden to .js-loading in me "
"remove .hidden from .js-neterr in me "
"set myMs to Number(me.dataset.retryMs) "
"if myMs < 10000 then set me.dataset.retryMs to myMs * 2 end "
"js setTimeout(() => 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\">"
"<td colspan=\"" colspan "\" class=\"px-3 py-4\">"
"<div class=\"block md:hidden h-[60vh] js-mobile-sentinel\">"
"<div class=\"js-loading text-center text-xs text-stone-400\">loading… " page " / " total-pages "</div>"
"<div class=\"js-neterr hidden flex h-full items-center justify-center\"></div>"
"</div>"
"<div class=\"hidden md:block h-[30vh] js-desktop-sentinel\">"
"<div class=\"js-loading text-center text-xs text-stone-400\">loading… " page " / " total-pages "</div>"
"<div class=\"js-neterr hidden inset-0 grid place-items-center p-4\"></div>"
"</div>"
"</td></tr>"))
(raw! (str
"<tr><td colspan=\"" colspan "\" class=\"px-3 py-4 text-center text-xs text-stone-400\">End of results</td></tr>"))))
'''
# ---------------------------------------------------------------------------
# ~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"))))
'''

108
shared/sexp/helpers.py Normal file
View File

@@ -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,
)

View File

@@ -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 s-expression, bypassing Jinja entirely. Used by error handlers and
(eventually) by route handlers for fully-migrated pages. (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:: 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( html = render_page(
'(~error-page :title "Not Found" :message "NOT FOUND" :image img :asset-url aurl)', '(~error-page :title "Not Found" :message "NOT FOUND" :image img :asset-url aurl)',
image="/static/errors/404.gif", image="/static/errors/404.gif",
asset_url="/static", 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 from __future__ import annotations
@@ -22,6 +30,22 @@ from typing import Any
from .jinja_bridge import sexp 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 = (
'<div class="md:hidden bg-stone-200 rounded">'
'<svg class="h-12 w-12 transition-transform group-open/root:hidden block self-start"'
' viewBox="0 0 24 24" fill="none" stroke="currentColor">'
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"'
' d="M4 6h16M4 12h16M4 18h16"/></svg>'
'<svg aria-hidden="true" viewBox="0 0 24 24"'
' class="w-12 h-12 rotate-180 transition-transform group-open/root:block hidden self-start">'
'<path d="M6 9l6 6 6-6" fill="currentColor"/></svg></div>'
)
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: def render_page(source: str, **kwargs: Any) -> str:
"""Render a full HTML page from an s-expression string. """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). intent explicit in call sites (rendering a whole page, not a fragment).
""" """
return sexp(source, **kwargs) 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)