Remove last Jinja fragment templates, use sx_components directly

Events fragment routes now call render_fragment_container_cards(),
render_fragment_account_tickets(), and render_fragment_account_bookings()
from sx_components instead of render_template(). Account sx_components
handles both SxExpr (text/sx) and HTML (text/html) fragment responses.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 01:07:02 +00:00
parent 5578923242
commit 8445c36270
6 changed files with 51 additions and 170 deletions

View File

@@ -294,26 +294,49 @@ async def render_newsletters_oob(ctx: dict, newsletter_list: list) -> str:
# Public API: Fragment pages # Public API: Fragment pages
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def render_fragment_page(ctx: dict, page_fragment_html: str) -> str: async def render_fragment_page(ctx: dict, page_fragment: str) -> str:
"""Full page: fragment-provided content.""" """Full page: fragment-provided content.
*page_fragment* may be sx source (from text/sx fragments wrapped in
SxExpr) or HTML (from text/html fragments). Sx source is embedded
directly; HTML is wrapped in ``~rich-text``.
"""
from shared.sx.parser import SxExpr
hdr = root_header_sx(ctx) hdr = root_header_sx(ctx)
hdr_child = header_child_sx(_auth_header_sx(ctx)) hdr_child = header_child_sx(_auth_header_sx(ctx))
header_rows = "(<> " + hdr + " " + hdr_child + ")" header_rows = "(<> " + hdr + " " + hdr_child + ")"
content = _fragment_content(page_fragment)
return full_page_sx(ctx, header_rows=header_rows, return full_page_sx(ctx, header_rows=header_rows,
content=f'(~rich-text :html "{_sx_escape(page_fragment_html)}")', content=content,
menu=_auth_nav_mobile_sx(ctx)) menu=_auth_nav_mobile_sx(ctx))
async def render_fragment_oob(ctx: dict, page_fragment_html: str) -> str: async def render_fragment_oob(ctx: dict, page_fragment: str) -> str:
"""OOB response for fragment pages.""" """OOB response for fragment pages."""
oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")" oobs = "(<> " + _auth_header_sx(ctx, oob=True) + " " + root_header_sx(ctx, oob=True) + ")"
content = _fragment_content(page_fragment)
return oob_page_sx(oobs=oobs, return oob_page_sx(oobs=oobs,
content=f'(~rich-text :html "{_sx_escape(page_fragment_html)}")', content=content,
menu=_auth_nav_mobile_sx(ctx)) menu=_auth_nav_mobile_sx(ctx))
def _fragment_content(frag: object) -> str:
"""Convert a fragment response to sx content string.
SxExpr (from text/sx responses) is embedded as-is; plain strings
(from text/html) are wrapped in ``~rich-text``.
"""
from shared.sx.parser import SxExpr
if isinstance(frag, SxExpr):
return frag.source
s = str(frag) if frag else ""
if not s:
return ""
return f'(~rich-text :html "{_sx_escape(s)}")'
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Public API: Auth pages (login, device) # Public API: Auth pages (login, device)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -3,16 +3,17 @@
Exposes sx fragments at ``/internal/fragments/<type>`` for consumption Exposes sx fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client. by other coop apps via the fragment client.
Most handlers are defined declaratively in .sx files under All handlers are defined declaratively in .sx files under
``events/sx/handlers/`` and dispatched via the sx handler registry. ``events/sx/handlers/`` and dispatched via the sx handler registry.
Jinja HTML handlers (container-cards, account-page) remain as Python container-cards and account-page remain as Python handlers because they
because they return ``text/html`` templates, not sx source. call domain service methods and return batched/conditional content, but
they use sx_call() for rendering (no Jinja templates).
""" """
from __future__ import annotations from __future__ import annotations
from quart import Blueprint, Response, g, render_template, request from quart import Blueprint, Response, g, request
from shared.infrastructure.fragments import FRAGMENT_HEADER from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.services.registry import services from shared.services.registry import services
@@ -24,8 +25,8 @@ def register():
_handlers: dict[str, object] = {} _handlers: dict[str, object] = {}
# Fragment types that return HTML (Jinja templates) # Fragment types that return HTML (comment-delimited batch)
_html_types = {"container-cards", "account-page"} _html_types = {"container-cards"}
@bp.before_request @bp.before_request
async def _require_fragment_header(): async def _require_fragment_header():
@@ -34,7 +35,7 @@ def register():
@bp.get("/<fragment_type>") @bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str): async def get_fragment(fragment_type: str):
# 1. Check Python handlers first (Jinja HTML types) # 1. Check Python handlers first
handler = _handlers.get(fragment_type) handler = _handlers.get(fragment_type)
if handler is not None: if handler is not None:
result = await handler() result = await handler()
@@ -51,9 +52,13 @@ def register():
return Response("", status=200, content_type="text/sx") return Response("", status=200, content_type="text/sx")
# --- container-cards fragment: entries for blog listing cards (Jinja HTML) -- # --- container-cards fragment: entries for blog listing cards -----------
# Returns text/html with <!-- card-widget:POST_ID --> comment markers
# so the blog consumer can split per-post fragments.
async def _container_cards_handler(): async def _container_cards_handler():
from sx.sx_components import render_fragment_container_cards
post_ids_raw = request.args.get("post_ids", "") post_ids_raw = request.args.get("post_ids", "")
post_slugs_raw = request.args.get("post_slugs", "") post_slugs_raw = request.args.get("post_slugs", "")
post_ids = [int(x) for x in post_ids_raw.split(",") if x.strip()] post_ids = [int(x) for x in post_ids_raw.split(",") if x.strip()]
@@ -66,16 +71,19 @@ def register():
slug_map[pid] = post_slugs[i] if i < len(post_slugs) else "" slug_map[pid] = post_slugs[i] if i < len(post_slugs) else ""
batch = await services.calendar.confirmed_entries_for_posts(g.s, post_ids) batch = await services.calendar.confirmed_entries_for_posts(g.s, post_ids)
return await render_template( return render_fragment_container_cards(batch, post_ids, slug_map)
"fragments/container_cards_entries.html",
batch=batch, post_ids=post_ids, slug_map=slug_map,
)
_handlers["container-cards"] = _container_cards_handler _handlers["container-cards"] = _container_cards_handler
# --- account-page fragment: tickets or bookings panel (Jinja HTML) ------ # --- account-page fragment: tickets or bookings panel ------------------
# Returns text/sx — the account app embeds this as sx source.
async def _account_page_handler(): async def _account_page_handler():
from sx.sx_components import (
render_fragment_account_tickets,
render_fragment_account_bookings,
)
slug = request.args.get("slug", "") slug = request.args.get("slug", "")
user_id = request.args.get("user_id", type=int) user_id = request.args.get("user_id", type=int)
if not user_id: if not user_id:
@@ -83,16 +91,10 @@ def register():
if slug == "tickets": if slug == "tickets":
tickets = await services.calendar.user_tickets(g.s, user_id=user_id) tickets = await services.calendar.user_tickets(g.s, user_id=user_id)
return await render_template( return render_fragment_account_tickets(tickets)
"fragments/account_page_tickets.html",
tickets=tickets,
)
elif slug == "bookings": elif slug == "bookings":
bookings = await services.calendar.user_bookings(g.s, user_id=user_id) bookings = await services.calendar.user_bookings(g.s, user_id=user_id)
return await render_template( return render_fragment_account_bookings(bookings)
"fragments/account_page_bookings.html",
bookings=bookings,
)
return "" return ""
_handlers["account-page"] = _account_page_handler _handlers["account-page"] = _account_page_handler

View File

@@ -1,23 +0,0 @@
{# Account nav items: tickets + bookings links for the account dashboard #}
<div class="relative nav-group">
<a href="{{ account_url('/tickets/') }}"
sx-get="{{ account_url('/tickets/') }}"
sx-target="#main-panel"
sx-select="{{ hx_select_search }}"
sx-swap="outerHTML"
sx-push-url="true"
class="{{styles.nav_button}}">
tickets
</a>
</div>
<div class="relative nav-group">
<a href="{{ account_url('/bookings/') }}"
sx-get="{{ account_url('/bookings/') }}"
sx-target="#main-panel"
sx-select="{{ hx_select_search }}"
sx-swap="outerHTML"
sx-push-url="true"
class="{{styles.nav_button}}">
bookings
</a>
</div>

View File

@@ -1,44 +0,0 @@
<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">Bookings</h1>
{% if bookings %}
<div class="divide-y divide-stone-100">
{% for booking in bookings %}
<div class="py-4 first:pt-0 last:pb-0">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-stone-800">{{ booking.name }}</p>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
<span>{{ booking.start_at.strftime('%d %b %Y, %H:%M') }}</span>
{% if booking.end_at %}
<span>&ndash; {{ booking.end_at.strftime('%H:%M') }}</span>
{% endif %}
{% if booking.calendar_name %}
<span>&middot; {{ booking.calendar_name }}</span>
{% endif %}
{% if booking.cost %}
<span>&middot; &pound;{{ booking.cost }}</span>
{% endif %}
</div>
</div>
<div class="flex-shrink-0">
{% if booking.state == 'confirmed' %}
<span class="inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700">confirmed</span>
{% elif booking.state == 'provisional' %}
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700">provisional</span>
{% else %}
<span class="inline-flex items-center rounded-full bg-stone-50 border border-stone-200 px-2.5 py-0.5 text-xs font-medium text-stone-600">{{ booking.state }}</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-stone-500">No bookings yet.</p>
{% endif %}
</div>
</div>

View File

@@ -1,44 +0,0 @@
<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">Tickets</h1>
{% if tickets %}
<div class="divide-y divide-stone-100">
{% for ticket in tickets %}
<div class="py-4 first:pt-0 last:pb-0">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<a href="{{ events_url('/tickets/' ~ ticket.code ~ '/') }}"
class="text-sm font-medium text-stone-800 hover:text-emerald-700 transition">
{{ ticket.entry_name }}
</a>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
<span>{{ ticket.entry_start_at.strftime('%d %b %Y, %H:%M') }}</span>
{% if ticket.calendar_name %}
<span>&middot; {{ ticket.calendar_name }}</span>
{% endif %}
{% if ticket.ticket_type_name %}
<span>&middot; {{ ticket.ticket_type_name }}</span>
{% endif %}
</div>
</div>
<div class="flex-shrink-0">
{% if ticket.state == 'checked_in' %}
<span class="inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-2.5 py-0.5 text-xs font-medium text-blue-700">checked in</span>
{% elif ticket.state == 'confirmed' %}
<span class="inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700">confirmed</span>
{% else %}
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700">{{ ticket.state }}</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-stone-500">No tickets yet.</p>
{% endif %}
</div>
</div>

View File

@@ -1,33 +0,0 @@
{# Calendar entries for blog listing cards — served as fragment from events app.
Each post's entries are delimited by comment markers so the consumer can
extract per-post HTML via simple string splitting. #}
{% for post_id in post_ids %}
<!-- card-widget:{{ post_id }} -->
{% set widget_entries = batch.get(post_id, []) %}
{% if widget_entries %}
<div class="mt-4 mb-2">
<h3 class="text-sm font-semibold text-stone-700 mb-2 px-2">Events:</h3>
<div class="overflow-x-auto scrollbar-hide" style="scroll-behavior: smooth;">
<div class="flex gap-2 px-2">
{% for entry in widget_entries %}
{% set _post_slug = slug_map.get(post_id, '') %}
{% set _entry_path = '/' + _post_slug + '/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
<a
href="{{ events_url(_entry_path) }}"
class="flex flex-col gap-1 px-3 py-2 bg-stone-50 hover:bg-stone-100 rounded border border-stone-200 transition text-sm whitespace-nowrap flex-shrink-0 min-w-[180px]">
<div class="font-medium text-stone-900 truncate">{{ entry.name }}</div>
<div class="text-xs text-stone-600">
{{ entry.start_at.strftime('%a, %b %d') }}
</div>
<div class="text-xs text-stone-500">
{{ entry.start_at.strftime('%H:%M') }}
{% if entry.end_at %} {{ entry.end_at.strftime('%H:%M') }}{% endif %}
</div>
</a>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- /card-widget:{{ post_id }} -->
{% endfor %}