diff --git a/account/app.py b/account/app.py index dfbe274..e328b48 100644 --- a/account/app.py +++ b/account/app.py @@ -1,6 +1,6 @@ from __future__ import annotations import path_setup # noqa: F401 # adds shared/ to sys.path -import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file +import sexp.sexp_components as sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file from pathlib import Path from quart import g, request diff --git a/account/bp/account/routes.py b/account/bp/account/routes.py index 71c0010..aeb1b12 100644 --- a/account/bp/account/routes.py +++ b/account/bp/account/routes.py @@ -8,7 +8,6 @@ from __future__ import annotations from quart import ( Blueprint, request, - render_template, make_response, redirect, g, @@ -48,7 +47,7 @@ def register(url_prefix="/"): async def account(): from shared.browser.app.utils.htmx import is_htmx_request from shared.sexp.page import get_template_context - from sexp_components import render_account_page, render_account_oob + from sexp.sexp_components import render_account_page, render_account_oob if not g.get("user"): return redirect(login_url("/")) @@ -90,7 +89,7 @@ def register(url_prefix="/"): }) from shared.sexp.page import get_template_context - from sexp_components import render_newsletters_page, render_newsletters_oob + from sexp.sexp_components import render_newsletters_page, render_newsletters_oob ctx = await get_template_context() if not is_htmx_request(): @@ -125,10 +124,8 @@ def register(url_prefix="/"): await g.s.flush() - return await render_template( - "_types/auth/_newsletter_toggle.html", - un=un, - ) + from sexp.sexp_components import render_newsletter_toggle + return render_newsletter_toggle(un) # Catch-all for fragment-provided pages — must be last @account_bp.get("//") @@ -147,7 +144,7 @@ def register(url_prefix="/"): abort(404) from shared.sexp.page import get_template_context - from sexp_components import render_fragment_page, render_fragment_oob + from sexp.sexp_components import render_fragment_page, render_fragment_oob ctx = await get_template_context() if not is_htmx_request(): diff --git a/account/bp/auth/routes.py b/account/bp/auth/routes.py index e101dca..c0b73e6 100644 --- a/account/bp/auth/routes.py +++ b/account/bp/auth/routes.py @@ -12,7 +12,6 @@ from datetime import datetime, timezone, timedelta from quart import ( Blueprint, request, - render_template, redirect, url_for, session as qsession, @@ -277,7 +276,7 @@ def register(url_prefix="/auth"): return redirect(redirect_url) from shared.sexp.page import get_template_context - from sexp_components import render_login_page + from sexp.sexp_components import render_login_page ctx = await get_template_context() return await render_login_page(ctx) @@ -292,28 +291,20 @@ def register(url_prefix="/auth"): is_valid, email = validate_email(email_input) if not is_valid: - return ( - await render_template( - "auth/login.html", - error="Please enter a valid email address.", - email=email_input, - ), - 400, - ) + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_login_page + ctx = await get_template_context(error="Please enter a valid email address.", email=email_input) + return await render_login_page(ctx), 400 # Per-email rate limit: 5 magic links per 15 minutes from shared.infrastructure.rate_limit import _check_rate_limit try: allowed, _ = await _check_rate_limit(f"magic_email:{email}", 5, 900) if not allowed: - return ( - await render_template( - "auth/check_email.html", - email=email, - email_error=None, - ), - 200, - ) + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_check_email_page + ctx = await get_template_context(email=email, email_error=None) + return await render_check_email_page(ctx), 200 except Exception: pass # Redis down — allow the request @@ -333,11 +324,10 @@ def register(url_prefix="/auth"): "Please try again in a moment." ) - return await render_template( - "auth/check_email.html", - email=email, - email_error=email_error, - ) + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_check_email_page + ctx = await get_template_context(email=email, email_error=email_error) + return await render_check_email_page(ctx) @auth_bp.get("/magic//") async def magic(token: str): @@ -350,20 +340,17 @@ def register(url_prefix="/auth"): user, error = await validate_magic_link(s, token) if error: - return ( - await render_template("auth/login.html", error=error), - 400, - ) + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_login_page + ctx = await get_template_context(error=error) + return await render_login_page(ctx), 400 user_id = user.id except Exception: - return ( - await render_template( - "auth/login.html", - error="Could not sign you in right now. Please try again.", - ), - 502, - ) + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_login_page + ctx = await get_template_context(error="Could not sign you in right now. Please try again.") + return await render_login_page(ctx), 502 assert user_id is not None @@ -693,7 +680,7 @@ def register(url_prefix="/auth"): async def device_form(): """Browser form where user enters the code displayed in terminal.""" from shared.sexp.page import get_template_context - from sexp_components import render_device_page + from sexp.sexp_components import render_device_page code = request.args.get("code", "") ctx = await get_template_context(code=code) return await render_device_page(ctx) @@ -706,22 +693,20 @@ def register(url_prefix="/auth"): user_code = (form.get("code") or "").strip().replace("-", "").upper() if not user_code or len(user_code) != 8: - return await render_template( - "auth/device.html", - error="Please enter a valid 8-character code.", - code=form.get("code", ""), - ), 400 + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_device_page + ctx = await get_template_context(error="Please enter a valid 8-character code.", code=form.get("code", "")) + return await render_device_page(ctx), 400 from shared.infrastructure.auth_redis import get_auth_redis r = await get_auth_redis() device_code = await r.get(f"devflow_uc:{user_code}") if not device_code: - return await render_template( - "auth/device.html", - error="Code not found or expired. Please try again.", - code=form.get("code", ""), - ), 400 + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_device_page + ctx = await get_template_context(error="Code not found or expired. Please try again.", code=form.get("code", "")) + return await render_device_page(ctx), 400 if isinstance(device_code, bytes): device_code = device_code.decode() @@ -735,19 +720,22 @@ def register(url_prefix="/auth"): # Logged in — approve immediately ok = await _approve_device(device_code, g.user) if not ok: - return await render_template( - "auth/device.html", - error="Code expired or already used.", - ), 400 + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_device_page + ctx = await get_template_context(error="Code expired or already used.") + return await render_device_page(ctx), 400 - return await render_template("auth/device_approved.html") + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_device_approved_page + ctx = await get_template_context() + return await render_device_approved_page(ctx) @auth_bp.get("/device/complete") @auth_bp.get("/device/complete/") async def device_complete(): """Post-login redirect — completes approval after magic link auth.""" from shared.sexp.page import get_template_context - from sexp_components import render_device_page, render_device_approved_page + from sexp.sexp_components import render_device_page, render_device_approved_page device_code = request.args.get("code", "") diff --git a/account/sexp/__init__.py b/account/sexp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/account/sexp_components.py b/account/sexp/sexp_components.py similarity index 85% rename from account/sexp_components.py rename to account/sexp/sexp_components.py index 410ece0..53a3ae8 100644 --- a/account/sexp_components.py +++ b/account/sexp/sexp_components.py @@ -141,7 +141,7 @@ 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") + account_url_fn = ctx.get("account_url") or (lambda p: p) csrf = generate_csrf_token() parts = ['
', @@ -377,3 +377,59 @@ async def render_device_approved_page(ctx: dict) -> str: return full_page(ctx, header_rows_html=hdr, content_html=_device_approved_content(), meta_html='Device Authorized \u2014 Rose Ash') + + +# --------------------------------------------------------------------------- +# Public API: Check email page (POST /start/ success) +# --------------------------------------------------------------------------- + +def _check_email_content(email: str, email_error: str | None = None) -> str: + """Check email confirmation content.""" + from markupsafe import escape + + error_html = "" + if email_error: + error_html = ( + f'
' + f'{escape(email_error)}
' + ) + return ( + '
' + '

Check your email

' + f'

We sent a sign-in link to {escape(email)}.

' + '

Click the link in the email to sign in. The link expires in 15 minutes.

' + f'{error_html}
' + ) + + +async def render_check_email_page(ctx: dict) -> str: + """Full page: check email after magic link sent.""" + email = ctx.get("email", "") + email_error = ctx.get("email_error") + hdr = root_header_html(ctx) + return full_page(ctx, header_rows_html=hdr, + content_html=_check_email_content(email, email_error), + meta_html='Check your email \u2014 Rose Ash') + + +# --------------------------------------------------------------------------- +# Public API: Fragment renderers for POST handlers +# --------------------------------------------------------------------------- + +def render_newsletter_toggle_html(un) -> str: + """Render a newsletter toggle switch for POST response.""" + from shared.browser.app.csrf import generate_csrf_token + return _newsletter_toggle_html(un, lambda p: f"/newsletter/{un.newsletter_id}/toggle/" if "/toggle/" in p else p, + generate_csrf_token()) + + +def render_newsletter_toggle(un) -> str: + """Render a newsletter toggle switch for POST response (uses account_url).""" + from shared.browser.app.csrf import generate_csrf_token + from quart import g + account_url_fn = getattr(g, "_account_url", None) + if account_url_fn is None: + # Fallback: construct URL directly + from shared.infrastructure.urls import account_url + account_url_fn = account_url + return _newsletter_toggle_html(un, account_url_fn, generate_csrf_token()) diff --git a/blog/app.py b/blog/app.py index 61dbc81..dadd0f6 100644 --- a/blog/app.py +++ b/blog/app.py @@ -1,6 +1,6 @@ from __future__ import annotations import path_setup # noqa: F401 # adds shared/ to sys.path -import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file +import sexp.sexp_components as sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file from pathlib import Path from quart import g, request diff --git a/blog/bp/admin/routes.py b/blog/bp/admin/routes.py index 52b73c9..b7137e0 100644 --- a/blog/bp/admin/routes.py +++ b/blog/bp/admin/routes.py @@ -30,7 +30,7 @@ def register(url_prefix): @require_admin async def home(): from shared.sexp.page import get_template_context - from sexp_components import render_settings_page, render_settings_oob + from sexp.sexp_components import render_settings_page, render_settings_oob tctx = await get_template_context() if not is_htmx_request(): @@ -44,7 +44,7 @@ def register(url_prefix): @require_admin async def cache(): from shared.sexp.page import get_template_context - from sexp_components import render_cache_page, render_cache_oob + from sexp.sexp_components import render_cache_page, render_cache_oob tctx = await get_template_context() if not is_htmx_request(): diff --git a/blog/bp/blog/admin/routes.py b/blog/bp/blog/admin/routes.py index 0350996..03fe680 100644 --- a/blog/bp/blog/admin/routes.py +++ b/blog/bp/blog/admin/routes.py @@ -58,7 +58,7 @@ def register(): ctx = {"groups": groups, "unassigned_tags": unassigned} from shared.sexp.page import get_template_context - from sexp_components import render_tag_groups_page, render_tag_groups_oob + from sexp.sexp_components import render_tag_groups_page, render_tag_groups_oob tctx = await get_template_context() tctx.update(ctx) @@ -123,7 +123,7 @@ def register(): } from shared.sexp.page import get_template_context - from sexp_components import render_tag_group_edit_page, render_tag_group_edit_oob + from sexp.sexp_components import render_tag_group_edit_page, render_tag_group_edit_oob tctx = await get_template_context() tctx.update(ctx) diff --git a/blog/bp/blog/routes.py b/blog/bp/blog/routes.py index 8593fa8..a8a8230 100644 --- a/blog/bp/blog/routes.py +++ b/blog/bp/blog/routes.py @@ -7,7 +7,6 @@ import os from quart import ( request, - render_template, make_response, g, Blueprint, @@ -154,7 +153,7 @@ def register(url_prefix, title): 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 + from sexp.sexp_components import render_home_page, render_home_oob tctx = await get_template_context() tctx.update(ctx) @@ -191,7 +190,7 @@ def register(url_prefix, title): "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 + from sexp.sexp_components import render_blog_page, render_blog_oob, render_blog_page_cards tctx = await get_template_context() tctx.update(context) @@ -232,7 +231,7 @@ def register(url_prefix, title): } from shared.sexp.page import get_template_context - from sexp_components import render_blog_page, render_blog_oob, render_blog_cards + from sexp.sexp_components import render_blog_page, render_blog_oob, render_blog_cards tctx = await get_template_context() tctx.update(context) @@ -249,11 +248,10 @@ def register(url_prefix, title): @require_admin async def new_post(): from shared.sexp.page import get_template_context - from sexp_components import render_new_post_page, render_new_post_oob + from sexp.sexp_components import render_new_post_page, render_new_post_oob, render_editor_panel - editor_html = await render_template("_types/blog_new/_main_panel.html") tctx = await get_template_context() - tctx["editor_html"] = editor_html + tctx["editor_html"] = render_editor_panel() if not is_htmx_request(): html = await render_new_post_page(tctx) else: @@ -279,18 +277,20 @@ def register(url_prefix, title): try: lexical_doc = json.loads(lexical_raw) except (json.JSONDecodeError, TypeError): - html = await render_template( - "_types/blog_new/index.html", - save_error="Invalid JSON in editor content.", - ) + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_new_post_page, render_editor_panel + tctx = await get_template_context() + tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.") + html = await render_new_post_page(tctx) return await make_response(html, 400) ok, reason = validate_lexical(lexical_doc) if not ok: - html = await render_template( - "_types/blog_new/index.html", - save_error=reason, - ) + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_new_post_page, render_editor_panel + tctx = await get_template_context() + tctx["editor_html"] = render_editor_panel(save_error=reason) + html = await render_new_post_page(tctx) return await make_response(html, 400) # Create in Ghost @@ -328,11 +328,10 @@ def register(url_prefix, title): @require_admin async def new_page(): from shared.sexp.page import get_template_context - from sexp_components import render_new_post_page, render_new_post_oob + from sexp.sexp_components import render_new_post_page, render_new_post_oob, render_editor_panel - 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["editor_html"] = render_editor_panel(is_page=True) tctx["is_page"] = True if not is_htmx_request(): html = await render_new_post_page(tctx) @@ -359,20 +358,22 @@ def register(url_prefix, title): try: lexical_doc = json.loads(lexical_raw) except (json.JSONDecodeError, TypeError): - html = await render_template( - "_types/blog_new/index.html", - save_error="Invalid JSON in editor content.", - is_page=True, - ) + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_new_post_page, render_editor_panel + tctx = await get_template_context() + tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.", is_page=True) + tctx["is_page"] = True + html = await render_new_post_page(tctx) return await make_response(html, 400) ok, reason = validate_lexical(lexical_doc) if not ok: - html = await render_template( - "_types/blog_new/index.html", - save_error=reason, - is_page=True, - ) + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_new_post_page, render_editor_panel + tctx = await get_template_context() + tctx["editor_html"] = render_editor_panel(save_error=reason, is_page=True) + tctx["is_page"] = True + html = await render_new_post_page(tctx) return await make_response(html, 400) # Create in Ghost (as page) diff --git a/blog/bp/menu_items/routes.py b/blog/bp/menu_items/routes.py index 2645f34..05868f5 100644 --- a/blog/bp/menu_items/routes.py +++ b/blog/bp/menu_items/routes.py @@ -17,15 +17,10 @@ from shared.browser.app.utils.htmx import is_htmx_request def register(): bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items') - async def get_menu_items_nav_oob(): + def get_menu_items_nav_oob_sync(menu_items): """Helper to generate OOB update for root nav menu items""" - menu_items = await get_all_menu_items(g.s) - - nav_oob = await render_template( - "_types/menu_items/_nav_oob.html", - menu_items=menu_items, - ) - return nav_oob + from sexp.sexp_components import render_menu_items_nav_oob + return render_menu_items_nav_oob(menu_items) @bp.get("/") @require_admin @@ -35,7 +30,7 @@ def register(): from shared.sexp.page import get_template_context - from sexp_components import render_menu_items_page, render_menu_items_oob + from sexp.sexp_components import render_menu_items_page, render_menu_items_oob tctx = await get_template_context() tctx["menu_items"] = menu_items @@ -77,12 +72,9 @@ def register(): # Get updated list and nav OOB menu_items = await get_all_menu_items(g.s) - nav_oob = await get_menu_items_nav_oob() - - html = await render_template( - "_types/menu_items/_list.html", - menu_items=menu_items, - ) + from sexp.sexp_components import render_menu_items_list + html = render_menu_items_list(menu_items) + nav_oob = get_menu_items_nav_oob_sync(menu_items) return await make_response(html + nav_oob, 200) except MenuItemError as e: @@ -123,12 +115,9 @@ def register(): # Get updated list and nav OOB menu_items = await get_all_menu_items(g.s) - nav_oob = await get_menu_items_nav_oob() - - html = await render_template( - "_types/menu_items/_list.html", - menu_items=menu_items, - ) + from sexp.sexp_components import render_menu_items_list + html = render_menu_items_list(menu_items) + nav_oob = get_menu_items_nav_oob_sync(menu_items) return await make_response(html + nav_oob, 200) except MenuItemError as e: @@ -147,12 +136,9 @@ def register(): # Get updated list and nav OOB menu_items = await get_all_menu_items(g.s) - nav_oob = await get_menu_items_nav_oob() - - html = await render_template( - "_types/menu_items/_list.html", - menu_items=menu_items, - ) + from sexp.sexp_components import render_menu_items_list + html = render_menu_items_list(menu_items) + nav_oob = get_menu_items_nav_oob_sync(menu_items) return await make_response(html + nav_oob, 200) @bp.get("/pages/search/") @@ -197,12 +183,9 @@ def register(): # Get updated list and nav OOB menu_items = await get_all_menu_items(g.s) - nav_oob = await get_menu_items_nav_oob() - - html = await render_template( - "_types/menu_items/_list.html", - menu_items=menu_items, - ) + from sexp.sexp_components import render_menu_items_list + html = render_menu_items_list(menu_items) + nav_oob = get_menu_items_nav_oob_sync(menu_items) return await make_response(html + nav_oob, 200) return bp diff --git a/blog/bp/post/admin/routes.py b/blog/bp/post/admin/routes.py index 60df515..41a422e 100644 --- a/blog/bp/post/admin/routes.py +++ b/blog/bp/post/admin/routes.py @@ -52,7 +52,7 @@ def register(): } from shared.sexp.page import get_template_context - from sexp_components import render_post_admin_page, render_post_admin_oob + from sexp.sexp_components import render_post_admin_page, render_post_admin_oob tctx = await get_template_context() tctx.update(ctx) @@ -98,10 +98,9 @@ def register(): features = result.get("features", {}) - html = await render_template( - "_types/post/admin/_features_panel.html", - features=features, - post=post, + from sexp.sexp_components import render_features_panel + html = render_features_panel( + features, post, sumup_configured=result.get("sumup_configured", False), sumup_merchant_code=result.get("sumup_merchant_code") or "", sumup_checkout_prefix=result.get("sumup_checkout_prefix") or "", @@ -138,10 +137,9 @@ def register(): result = await call_action("blog", "update-page-config", payload=payload) features = result.get("features", {}) - html = await render_template( - "_types/post/admin/_features_panel.html", - features=features, - post=post, + from sexp.sexp_components import render_features_panel + html = render_features_panel( + features, post, sumup_configured=result.get("sumup_configured", False), sumup_merchant_code=result.get("sumup_merchant_code") or "", sumup_checkout_prefix=result.get("sumup_checkout_prefix") or "", @@ -152,7 +150,7 @@ def register(): @require_admin async def data(slug: str): from shared.sexp.page import get_template_context - from sexp_components import render_post_data_page, render_post_data_oob + from sexp.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() @@ -271,7 +269,7 @@ def register(): for calendar in all_calendars: await g.s.refresh(calendar, ["entries", "post"]) from shared.sexp.page import get_template_context - from sexp_components import render_post_entries_page, render_post_entries_oob + from sexp.sexp_components import render_post_entries_page, render_post_entries_oob entries_html = await render_template( "_types/post_entries/_main_panel.html", @@ -331,20 +329,13 @@ def register(): ).scalars().all() # Return the associated entries admin list + OOB update for nav entries - admin_list = await render_template( - "_types/post/admin/_associated_entries.html", - all_calendars=all_calendars, - associated_entry_ids=associated_entry_ids, - ) + from sexp.sexp_components import render_associated_entries, render_nav_entries_oob - nav_entries_oob = await render_template( - "_types/post/admin/_nav_entries_oob.html", - associated_entries=associated_entries, - calendars=calendars, - post=g.post_data["post"], - ) + post = g.post_data["post"] + admin_list = render_associated_entries(all_calendars, associated_entry_ids, post["slug"]) + nav_entries_html = render_nav_entries_oob(associated_entries, calendars, post) - return await make_response(admin_list + nav_entries_oob) + return await make_response(admin_list + nav_entries_html) @bp.get("/settings/") @require_post_author @@ -357,7 +348,7 @@ def register(): 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 + from sexp.sexp_components import render_post_settings_page, render_post_settings_oob settings_html = await render_template( "_types/post_settings/_main_panel.html", @@ -452,6 +443,7 @@ def register(): is_page = bool(g.post_data["post"].get("is_page")) ghost_post = await get_post_for_edit(ghost_id, is_page=is_page) save_success = request.args.get("saved") == "1" + save_error = request.args.get("error", "") # Newsletters live in db_account — fetch via HTTP raw_newsletters = await fetch_data("account", "newsletters", required=False) or [] @@ -460,12 +452,13 @@ def register(): 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 + from sexp.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, + save_error=save_error, newsletters=newsletters, ) tctx = await get_template_context() @@ -500,28 +493,15 @@ def register(): feature_image_caption = form.get("feature_image_caption", "").strip() # Validate the lexical JSON + from urllib.parse import quote try: lexical_doc = json.loads(lexical_raw) except (json.JSONDecodeError, TypeError): - from ...blog.ghost.ghost_posts import get_post_for_edit - ghost_post = await get_post_for_edit(ghost_id, is_page=is_page) - html = await render_template( - "_types/post_edit/index.html", - ghost_post=ghost_post, - save_error="Invalid JSON in editor content.", - ) - return await make_response(html, 400) + return redirect(host_url(url_for("blog.post.admin.edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content.")) ok, reason = validate_lexical(lexical_doc) if not ok: - from ...blog.ghost.ghost_posts import get_post_for_edit - ghost_post = await get_post_for_edit(ghost_id, is_page=is_page) - html = await render_template( - "_types/post_edit/index.html", - ghost_post=ghost_post, - save_error=reason, - ) - return await make_response(html, 400) + return redirect(host_url(url_for("blog.post.admin.edit", slug=slug)) + "?error=" + quote(reason)) # Update in Ghost (content save — no status change yet) ghost_post = await update_post( @@ -617,11 +597,8 @@ def register(): page_markets = await _fetch_page_markets(post_id) - html = await render_template( - "_types/post/admin/_markets_panel.html", - markets=page_markets, - post=post, - ) + from sexp.sexp_components import render_markets_panel + html = render_markets_panel(page_markets, post) return await make_response(html) @bp.post("/markets/new/") @@ -647,11 +624,8 @@ def register(): # Return updated markets list page_markets = await _fetch_page_markets(post_id) - html = await render_template( - "_types/post/admin/_markets_panel.html", - markets=page_markets, - post=post, - ) + from sexp.sexp_components import render_markets_panel + html = render_markets_panel(page_markets, post) return await make_response(html) @bp.delete("/markets//") @@ -671,11 +645,8 @@ def register(): # Return updated markets list page_markets = await _fetch_page_markets(post_id) - html = await render_template( - "_types/post/admin/_markets_panel.html", - markets=page_markets, - post=post, - ) + from sexp.sexp_components import render_markets_panel + html = render_markets_panel(page_markets, post) return await make_response(html) return bp diff --git a/blog/bp/post/routes.py b/blog/bp/post/routes.py index 973c3b9..d7516f2 100644 --- a/blog/bp/post/routes.py +++ b/blog/bp/post/routes.py @@ -2,7 +2,6 @@ from __future__ import annotations from quart import ( - render_template, make_response, g, Blueprint, @@ -115,7 +114,7 @@ def register(): @cache_page(tag="post.post_detail") async def post_detail(slug: str): from shared.sexp.page import get_template_context - from sexp_components import render_post_page, render_post_oob + from sexp.sexp_components import render_post_page, render_post_oob tctx = await get_template_context() if not is_htmx_request(): @@ -129,16 +128,13 @@ def register(): @clear_cache(tag="post.post_detail", tag_scope="user") async def like_toggle(slug: str): from shared.utils import host_url + from sexp.sexp_components import render_like_toggle_button + + like_url = host_url(url_for('blog.post.like_toggle', slug=slug)) # Get post_id from g.post_data if not g.user: - html = await render_template( - "_types/browse/like/button.html", - slug=slug, - liked=False, - like_url=host_url(url_for('blog.post.like_toggle', slug=slug)), - item_type='post', - ) + html = render_like_toggle_button(slug, False, like_url) resp = make_response(html, 403) return resp @@ -150,13 +146,7 @@ def register(): }) liked = result["liked"] - html = await render_template( - "_types/browse/like/button.html", - slug=slug, - liked=liked, - like_url=host_url(url_for('blog.post.like_toggle', slug=slug)), - item_type='post', - ) + html = render_like_toggle_button(slug, liked, like_url) return html @bp.get("/w//") diff --git a/blog/bp/snippets/routes.py b/blog/bp/snippets/routes.py index 2ed79fd..3d2f48f 100644 --- a/blog/bp/snippets/routes.py +++ b/blog/bp/snippets/routes.py @@ -1,6 +1,6 @@ from __future__ import annotations -from quart import Blueprint, render_template, make_response, request, g, abort +from quart import Blueprint, make_response, request, g, abort from sqlalchemy import select, or_ from sqlalchemy.orm import selectinload @@ -39,7 +39,7 @@ def register(): is_admin = g.rights.get("admin") from shared.sexp.page import get_template_context - from sexp_components import render_snippets_page, render_snippets_oob + from sexp.sexp_components import render_snippets_page, render_snippets_oob tctx = await get_template_context() tctx["snippets"] = snippets @@ -67,11 +67,8 @@ def register(): await g.s.flush() snippets = await _visible_snippets(g.s) - html = await render_template( - "_types/snippets/_list.html", - snippets=snippets, - is_admin=is_admin, - ) + from sexp.sexp_components import render_snippets_list + html = render_snippets_list(snippets, is_admin) return await make_response(html) @bp.patch("//visibility/") @@ -95,11 +92,8 @@ def register(): await g.s.flush() snippets = await _visible_snippets(g.s) - html = await render_template( - "_types/snippets/_list.html", - snippets=snippets, - is_admin=True, - ) + from sexp.sexp_components import render_snippets_list + html = render_snippets_list(snippets, True) return await make_response(html) return bp diff --git a/blog/sexp/__init__.py b/blog/sexp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/blog/sexp_components.py b/blog/sexp/sexp_components.py similarity index 71% rename from blog/sexp_components.py rename to blog/sexp/sexp_components.py index 7a6aea8..2a85485 100644 --- a/blog/sexp_components.py +++ b/blog/sexp/sexp_components.py @@ -1443,13 +1443,272 @@ async def render_blog_page_cards(ctx: dict) -> str: return _page_cards_html(ctx) +# ---- New post/page editor panel ---- + +def render_editor_panel(save_error: str | None = None, is_page: bool = False) -> str: + """Build the WYSIWYG editor panel HTML (replaces _main_panel.html template). + + This is synchronous — it just assembles an HTML string from the current + request context (url_for, CSRF token, asset URLs, config). + """ + import os + from quart import url_for as qurl, current_app + from shared.browser.app.csrf import generate_csrf_token + from markupsafe import escape as esc + + csrf = generate_csrf_token() + asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "") + editor_css = asset_url_fn("scripts/editor.css") + editor_js = asset_url_fn("scripts/editor.js") + + upload_image_url = qurl("blog.editor_api.upload_image") + upload_media_url = qurl("blog.editor_api.upload_media") + upload_file_url = qurl("blog.editor_api.upload_file") + oembed_url = qurl("blog.editor_api.oembed_proxy") + snippets_url = qurl("blog.editor_api.list_snippets") + unsplash_key = os.environ.get("UNSPLASH_ACCESS_KEY", "") + + title_placeholder = "Page title..." if is_page else "Post title..." + create_label = "Create Page" if is_page else "Create Post" + + parts: list[str] = [] + + # Error banner + if save_error: + parts.append( + '
' + f'Save failed: {esc(save_error)}
' + ) + + # Form + parts.append( + '
' + f'' + '' + '' + '' + ) + + # Feature image section + parts.append( + '
' + # Empty state + '
' + '
' + # Filled state + '' + # Upload spinner + '' + # Hidden file input + '' + '
' + ) + + # Title + parts.append( + f'' + ) + + # Excerpt + parts.append( + '' + ) + + # Editor mount point + parts.append('
') + + # Status + Save footer + parts.append( + '
' + '' + '' + '
' + ) + + # Editor CSS + inline styles + parts.append( + f'' + '' + ) + + # Editor JS + init script + # NOTE: JavaScript string literals use single quotes; Python f-string injects URLs. + parts.append( + f'' + "" + ) + + return "".join(parts) + + # ---- New post/page ---- async def render_new_post_page(ctx: dict) -> str: root_hdr = root_header_html(ctx) blog_hdr = _blog_header_html(ctx) header_rows = root_hdr + blog_hdr - # Content comes from Jinja (editor template) content = ctx.get("editor_html", "") return full_page(ctx, header_rows_html=header_rows, content_html=content) @@ -1803,3 +2062,472 @@ async def render_tag_group_edit_oob(ctx: dict) -> str: tg_hdr) content = _tag_groups_edit_main_panel_html(ctx) return oob_page(ctx, oobs_html=settings_hdr_oob + tg_oob, content_html=content) + + +# =========================================================================== +# PUBLIC API — HTMX fragment renderers for POST/PUT/DELETE handlers +# =========================================================================== + +# ---- Like toggle button (delegates to market impl) ---- + +def render_like_toggle_button(slug: str, liked: bool, like_url: str) -> str: + """Render a like toggle button for HTMX POST response.""" + from market.sexp.sexp_components import render_like_toggle_button as _market_like + return _market_like(slug, liked, like_url=like_url, item_type="post") + + +# ---- Snippets list ---- + +def render_snippets_list(snippets, is_admin: bool) -> str: + """Render the snippets list fragment for HTMX DELETE/PATCH responses.""" + from shared.browser.app.csrf import generate_csrf_token + from quart import g + + ctx = { + "snippets": snippets, + "is_admin": is_admin, + "csrf_token": generate_csrf_token(), + } + return _snippets_list_html(ctx) + + +# ---- Menu items list + nav OOB ---- + +def render_menu_items_list(menu_items) -> str: + """Render the menu items list fragment for HTMX responses.""" + from shared.browser.app.csrf import generate_csrf_token + + ctx = { + "menu_items": menu_items, + "csrf_token": generate_csrf_token(), + } + return _menu_items_list_html(ctx) + + +def render_menu_items_nav_oob(menu_items, ctx: dict | None = None) -> str: + """Render the OOB nav update for menu items. + + Produces the same DOM structure as ``_types/menu_items/_nav_oob.html``: + a scrolling nav wrapper with ``id="menu-items-nav-wrapper"`` and + ``hx-swap-oob="outerHTML"``. + """ + from quart import request as qrequest + + if not menu_items: + return '' + + # Resolve URL helpers from context or fall back to template globals + if ctx is None: + ctx = {} + + first_seg = qrequest.path.strip("/").split("/")[0] if qrequest else "" + + # nav_button style (matches shared/infrastructure/jinja_setup.py) + select_colours = ( + "[.hover-capable_&]:hover:bg-yellow-300" + " aria-selected:bg-stone-500 aria-selected:text-white" + " [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500" + ) + nav_button_cls = ( + f"justify-center cursor-pointer flex flex-row items-center gap-2" + f" rounded bg-stone-200 text-black {select_colours} p-3" + ) + + container_id = "menu-items-container" + arrow_cls = f"scrolling-menu-arrow-{container_id}" + + parts = [ + '') # close wrapper + return "".join(parts) + + +# ---- Features panel ---- + +def render_features_panel(features: dict, post: dict, + sumup_configured: bool, + sumup_merchant_code: str, + sumup_checkout_prefix: str) -> str: + """Render the features panel fragment for HTMX PUT responses.""" + from shared.utils import host_url + from quart import url_for as qurl + + slug = post.get("slug", "") + features_url = host_url(qurl("blog.post.admin.update_features", slug=slug)) + sumup_url = host_url(qurl("blog.post.admin.update_sumup", slug=slug)) + + cal_checked = " checked" if features.get("calendar") else "" + mkt_checked = " checked" if features.get("market") else "" + + parts = [ + '
', + '

Page Features

', + f'
', + # Calendar checkbox + '', + # Market checkbox + '', + '
', + ] + + # SumUp section — shown when calendar or market is enabled + if features.get("calendar") or features.get("market"): + placeholder = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" if sumup_configured else "sup_sk_..." + connected = ( + '' + ' Connected' + ) if sumup_configured else "" + key_hint = ( + '

Key is set. Leave blank to keep current key.

' + ) if sumup_configured else "" + + parts.append( + '
' + '

' + ' SumUp Payment

' + '

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

' + f'
' + '
' + f'
' + '
' + f'' + f'{key_hint}
' + '
' + f'
' + '' + f'{connected}
' + ) + + parts.append('
') + return "".join(parts) + + +# ---- Markets panel ---- + +def render_markets_panel(markets, post: dict) -> str: + """Render the markets panel fragment for HTMX responses.""" + from shared.utils import host_url + from quart import url_for as qurl + + slug = post.get("slug", "") + create_url = host_url(qurl("blog.post.admin.create_market", slug=slug)) + + parts = ['
', + '

Markets

'] + + if markets: + parts.append('
    ') + for m in markets: + m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "") + m_slug = getattr(m, "slug", "") if hasattr(m, "slug") else m.get("slug", "") + del_url = host_url(qurl("blog.post.admin.delete_market", slug=slug, market_slug=m_slug)) + parts.append( + f'
  • ' + f'
    {escape(m_name)}' + f'/{escape(m_slug)}/
    ' + f'
  • ' + ) + parts.append('
') + else: + parts.append('

No markets yet.

') + + parts.append( + f'
' + '' + '' + '
' + ) + return "".join(parts) + + +# ---- Associated entries ---- + +def render_associated_entries(all_calendars, associated_entry_ids, post_slug: str) -> str: + """Render the associated entries panel for HTMX POST responses.""" + from shared.browser.app.csrf import generate_csrf_token + from quart import url_for as qurl + from shared.utils import host_url + + csrf = generate_csrf_token() + + parts = ['
', + '

Associated Entries

'] + + has_entries = False + entry_parts: list[str] = [] + for calendar in all_calendars: + entries = getattr(calendar, "entries", []) or [] + cal_name = getattr(calendar, "name", "") + cal_post = getattr(calendar, "post", None) + cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None + cal_title = getattr(cal_post, "title", "") if cal_post else "" + + for entry in entries: + e_id = getattr(entry, "id", None) + if e_id not in associated_entry_ids: + continue + if getattr(entry, "deleted_at", None) is not None: + continue + has_entries = True + e_name = getattr(entry, "name", "") + e_start = getattr(entry, "start_at", None) + e_end = getattr(entry, "end_at", None) + + toggle_url = host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=e_id)) + + if cal_fi: + img = f'{escape(cal_title)}' + else: + img = '
' + + date_str = e_start.strftime("%A, %B %d, %Y at %H:%M") if e_start else "" + if e_end: + date_str += f" \u2013 {e_end.strftime('%H:%M')}" + + entry_parts.append( + f'' + ) + + if has_entries: + parts.append('
') + parts.extend(entry_parts) + parts.append('
') + else: + parts.append( + '
No entries associated yet.' + ' Browse calendars below to add entries.
' + ) + + parts.append('
') + return "".join(parts) + + +# ---- Nav entries OOB ---- + +def render_nav_entries_oob(associated_entries, calendars, post: dict, ctx: dict | None = None) -> str: + """Render the OOB nav entries swap. + + Produces the ``entries-calendars-nav-wrapper`` OOB element with links + to associated entries and calendars. + """ + if ctx is None: + ctx = {} + + entries_list = [] + if associated_entries and hasattr(associated_entries, "entries"): + entries_list = associated_entries.entries or [] + + has_items = bool(entries_list or calendars) + + if not has_items: + return '
' + + events_url_fn = ctx.get("events_url") + + # nav_button_less_pad style + select_colours = ( + "[.hover-capable_&]:hover:bg-yellow-300" + " aria-selected:bg-stone-500 aria-selected:text-white" + " [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500" + ) + nav_cls = ( + f"justify-center cursor-pointer flex flex-row items-center gap-2" + f" rounded bg-stone-200 text-black {select_colours} p-2" + ) + + post_slug = post.get("slug", "") + + parts = [ + '
', + # Left arrow + '', + # Container + '
', + '
', + ] + + # Entry links + for entry in entries_list: + e_name = getattr(entry, "name", "") + e_start = getattr(entry, "start_at", None) + e_end = getattr(entry, "end_at", None) + cal_slug = getattr(entry, "calendar_slug", "") + + if e_start: + entry_path = ( + f"/{post_slug}/calendars/{cal_slug}/" + f"{e_start.year}/{e_start.month}/{e_start.day}" + f"/entries/{getattr(entry, 'id', '')}/" + ) + date_str = e_start.strftime("%b %d, %Y at %H:%M") + if e_end: + date_str += f" \u2013 {e_end.strftime('%H:%M')}" + else: + entry_path = f"/{post_slug}/calendars/{cal_slug}/" + date_str = "" + + href = events_url_fn(entry_path) if events_url_fn else entry_path + + parts.append( + f'' + f'
' + f'
' + f'
{escape(e_name)}
' + f'
{date_str}
' + f'
' + ) + + # Calendar links + for calendar in (calendars or []): + cal_name = getattr(calendar, "name", "") + cal_slug = getattr(calendar, "slug", "") + cal_path = f"/{post_slug}/calendars/{cal_slug}/" + href = events_url_fn(cal_path) if events_url_fn else cal_path + + parts.append( + f'' + f'' + f'
{escape(cal_name)}
' + ) + + parts.append('
') # close flex + container + + # Scrollbar style + parts.append( + '' + ) + + # Right arrow + parts.append( + '' + ) + + parts.append('
') + return "".join(parts) diff --git a/cart/app.py b/cart/app.py index 255c605..f361e3f 100644 --- a/cart/app.py +++ b/cart/app.py @@ -1,6 +1,6 @@ from __future__ import annotations import path_setup # noqa: F401 # adds shared/ to sys.path -import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file +import sexp.sexp_components as sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file from decimal import Decimal from pathlib import Path diff --git a/cart/bp/cart/global_routes.py b/cart/bp/cart/global_routes.py index 33e8897..7d0f5a6 100644 --- a/cart/bp/cart/global_routes.py +++ b/cart/bp/cart/global_routes.py @@ -2,7 +2,7 @@ from __future__ import annotations -from quart import Blueprint, g, request, render_template, redirect, url_for, make_response +from quart import Blueprint, g, request, redirect, url_for, make_response from sqlalchemy import select from shared.models.market import CartItem @@ -150,11 +150,10 @@ def register(url_prefix: str) -> Blueprint: try: page_config = await resolve_page_config(g.s, cart, calendar_entries, tickets) except ValueError as e: - html = await render_template( - "_types/cart/checkout_error.html", - order=None, - error=str(e), - ) + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_checkout_error_page + tctx = await get_template_context() + html = await render_checkout_error_page(tctx, error=str(e)) return await make_response(html, 400) ident = current_cart_identity() @@ -208,11 +207,10 @@ def register(url_prefix: str) -> Blueprint: hosted_url = result.get("sumup_hosted_url") if not hosted_url: - html = await render_template( - "_types/cart/checkout_error.html", - order=None, - error="No hosted checkout URL returned from SumUp.", - ) + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_checkout_error_page + tctx = await get_template_context() + html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp.") return await make_response(html, 500) return redirect(hosted_url) diff --git a/cart/bp/cart/overview_routes.py b/cart/bp/cart/overview_routes.py index 8ac65c4..47ed086 100644 --- a/cart/bp/cart/overview_routes.py +++ b/cart/bp/cart/overview_routes.py @@ -15,7 +15,7 @@ def register(url_prefix: str) -> Blueprint: async def overview(): from quart import g from shared.sexp.page import get_template_context - from sexp_components import render_overview_page, render_overview_oob + from sexp.sexp_components import render_overview_page, render_overview_oob page_groups = await get_cart_grouped_by_page(g.s) ctx = await get_template_context() diff --git a/cart/bp/cart/page_routes.py b/cart/bp/cart/page_routes.py index 59749e6..4bc3f78 100644 --- a/cart/bp/cart/page_routes.py +++ b/cart/bp/cart/page_routes.py @@ -2,7 +2,7 @@ from __future__ import annotations -from quart import Blueprint, g, render_template, redirect, make_response, url_for +from quart import Blueprint, g, redirect, make_response, url_for from shared.browser.app.utils.htmx import is_htmx_request from shared.infrastructure.actions import call_action @@ -41,7 +41,7 @@ def register(url_prefix: str) -> Blueprint: ) from shared.sexp.page import get_template_context - from sexp_components import render_page_cart_page, render_page_cart_oob + from sexp.sexp_components import render_page_cart_page, render_page_cart_oob ctx = await get_template_context() if not is_htmx_request(): @@ -109,11 +109,10 @@ def register(url_prefix: str) -> Blueprint: hosted_url = result.get("sumup_hosted_url") if not hosted_url: - html = await render_template( - "_types/cart/checkout_error.html", - order=None, - error="No hosted checkout URL returned from SumUp.", - ) + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_checkout_error_page + tctx = await get_template_context() + html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp.") return await make_response(html, 500) return redirect(hosted_url) diff --git a/cart/bp/order/routes.py b/cart/bp/order/routes.py index bb869e4..76c4f91 100644 --- a/cart/bp/order/routes.py +++ b/cart/bp/order/routes.py @@ -1,6 +1,6 @@ from __future__ import annotations -from quart import Blueprint, g, render_template, redirect, url_for, make_response +from quart import Blueprint, g, redirect, url_for, make_response from sqlalchemy import select, func, or_, cast, String, exists from sqlalchemy.orm import selectinload @@ -56,7 +56,7 @@ def register() -> Blueprint: if not order: return await make_response("Order not found", 404) from shared.sexp.page import get_template_context - from sexp_components import render_order_page, render_order_oob + from sexp.sexp_components import render_order_page, render_order_oob ctx = await get_template_context() calendar_entries = ctx.get("calendar_entries") @@ -120,11 +120,10 @@ def register() -> Blueprint: await g.s.flush() if not hosted_url: - html = await render_template( - "_types/cart/checkout_error.html", - order=order, - error="No hosted checkout URL returned from SumUp when trying to reopen payment.", - ) + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_checkout_error_page + tctx = await get_template_context() + html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp when trying to reopen payment.", order=order) return await make_response(html, 500) return redirect(hosted_url) diff --git a/cart/bp/orders/routes.py b/cart/bp/orders/routes.py index cdbf717..08e647d 100644 --- a/cart/bp/orders/routes.py +++ b/cart/bp/orders/routes.py @@ -137,7 +137,7 @@ def register(url_prefix: str) -> Blueprint: orders = result.scalars().all() from shared.sexp.page import get_template_context - from sexp_components import ( + from sexp.sexp_components import ( render_orders_page, render_orders_rows, render_orders_oob, diff --git a/cart/sexp/__init__.py b/cart/sexp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cart/sexp_components.py b/cart/sexp/sexp_components.py similarity index 94% rename from cart/sexp_components.py rename to cart/sexp/sexp_components.py index b769cda..2748181 100644 --- a/cart/sexp_components.py +++ b/cart/sexp/sexp_components.py @@ -13,7 +13,7 @@ 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 +from shared.infrastructure.urls import market_product_url, cart_url # --------------------------------------------------------------------------- @@ -34,7 +34,7 @@ def _cart_header_html(ctx: dict, *, oob: bool = False) -> str: 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] + title = ((page_post.title if page_post else None) or "")[:160] img_html = "" if page_post and page_post.feature_image: img_html = ( @@ -803,3 +803,56 @@ async def render_order_oob(ctx: dict, order: Any, ) return oob_page(ctx, oobs_html=oobs, filter_html=filt, content_html=main) + + +# --------------------------------------------------------------------------- +# Public API: Checkout error +# --------------------------------------------------------------------------- + +def _checkout_error_filter_html() -> str: + return ( + '
' + '

' + 'Checkout error

' + '

' + 'We tried to start your payment with SumUp but hit a problem.

' + '
' + ) + + +def _checkout_error_content_html(error: str | None, order: Any | None) -> str: + err_msg = error or "Unexpected error while creating the hosted checkout session." + order_html = "" + if order: + order_html = ( + f'

' + f'Order ID: #{order.id}

' + ) + back_url = cart_url("/") + return ( + '
' + '
' + f'

Something went wrong.

' + f'

{err_msg}

' + f'{order_html}' + '
' + '' + '
' + ) + + +async def render_checkout_error_page(ctx: dict, error: str | None = None, order: Any | None = None) -> str: + """Full page: checkout error.""" + hdr = root_header_html(ctx) + hdr += sexp( + '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! c))', + c=_cart_header_html(ctx), + ) + filt = _checkout_error_filter_html() + content = _checkout_error_content_html(error, order) + return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=content) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 0bd15e4..cda3740 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -45,7 +45,7 @@ services: - ./blog/alembic.ini:/app/blog/alembic.ini:ro - ./blog/alembic:/app/blog/alembic:ro - ./blog/app.py:/app/app.py - - ./blog/sexp_components.py:/app/sexp_components.py + - ./blog/sexp:/app/sexp - ./blog/bp:/app/bp - ./blog/services:/app/services - ./blog/templates:/app/templates @@ -83,7 +83,7 @@ services: - ./market/alembic.ini:/app/market/alembic.ini:ro - ./market/alembic:/app/market/alembic:ro - ./market/app.py:/app/app.py - - ./market/sexp_components.py:/app/sexp_components.py + - ./market/sexp:/app/sexp - ./market/bp:/app/bp - ./market/services:/app/services - ./market/templates:/app/templates @@ -120,7 +120,7 @@ services: - ./cart/alembic.ini:/app/cart/alembic.ini:ro - ./cart/alembic:/app/cart/alembic:ro - ./cart/app.py:/app/app.py - - ./cart/sexp_components.py:/app/sexp_components.py + - ./cart/sexp:/app/sexp - ./cart/bp:/app/bp - ./cart/services:/app/services - ./cart/templates:/app/templates @@ -157,7 +157,7 @@ services: - ./events/alembic.ini:/app/events/alembic.ini:ro - ./events/alembic:/app/events/alembic:ro - ./events/app.py:/app/app.py - - ./events/sexp_components.py:/app/sexp_components.py + - ./events/sexp:/app/sexp - ./events/bp:/app/bp - ./events/services:/app/services - ./events/templates:/app/templates @@ -194,7 +194,7 @@ services: - ./federation/alembic.ini:/app/federation/alembic.ini:ro - ./federation/alembic:/app/federation/alembic:ro - ./federation/app.py:/app/app.py - - ./federation/sexp_components.py:/app/sexp_components.py + - ./federation/sexp:/app/sexp - ./federation/bp:/app/bp - ./federation/services:/app/services - ./federation/templates:/app/templates @@ -231,7 +231,7 @@ services: - ./account/alembic.ini:/app/account/alembic.ini:ro - ./account/alembic:/app/account/alembic:ro - ./account/app.py:/app/app.py - - ./account/sexp_components.py:/app/sexp_components.py + - ./account/sexp:/app/sexp - ./account/bp:/app/bp - ./account/services:/app/services - ./account/templates:/app/templates @@ -330,7 +330,7 @@ services: - ./orders/alembic.ini:/app/orders/alembic.ini:ro - ./orders/alembic:/app/orders/alembic:ro - ./orders/app.py:/app/app.py - - ./orders/sexp_components.py:/app/sexp_components.py + - ./orders/sexp:/app/sexp - ./orders/bp:/app/bp - ./orders/services:/app/services - ./orders/templates:/app/templates diff --git a/events/app.py b/events/app.py index 1f51300..52c1031 100644 --- a/events/app.py +++ b/events/app.py @@ -1,7 +1,7 @@ from __future__ import annotations import path_setup # noqa: F401 # adds shared/ to sys.path -import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file +import sexp.sexp_components as sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file from pathlib import Path from quart import g, abort, request diff --git a/events/bp/all_events/routes.py b/events/bp/all_events/routes.py index c3429e0..fca7d69 100644 --- a/events/bp/all_events/routes.py +++ b/events/bp/all_events/routes.py @@ -66,7 +66,7 @@ def register() -> Blueprint: entries, has_more, pending_tickets, page_info = await _load_entries(page) from shared.sexp.page import get_template_context - from sexp_components import render_all_events_page, render_all_events_oob + from sexp.sexp_components import render_all_events_page, render_all_events_oob ctx = await get_template_context() if is_htmx_request(): @@ -83,7 +83,7 @@ def register() -> Blueprint: entries, has_more, pending_tickets, page_info = await _load_entries(page) - from sexp_components import render_all_events_cards + from sexp.sexp_components import render_all_events_cards html = await render_all_events_cards(entries, has_more, pending_tickets, page_info, page, view) return await make_response(html, 200) @@ -124,12 +124,8 @@ def register() -> Blueprint: if ident["session_id"] is not None: frag_params["session_id"] = ident["session_id"] - widget_html = await render_template( - "_types/page_summary/_ticket_widget.html", - entry=entry, - qty=qty, - ticket_url="/all-tickets/adjust", - ) + from sexp.sexp_components import render_ticket_widget + widget_html = render_ticket_widget(entry, qty, "/all-tickets/adjust") mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False) return await make_response(widget_html + mini_html, 200) diff --git a/events/bp/calendar/admin/routes.py b/events/bp/calendar/admin/routes.py index 68d92aa..f7be8a1 100644 --- a/events/bp/calendar/admin/routes.py +++ b/events/bp/calendar/admin/routes.py @@ -20,7 +20,7 @@ def register(): from shared.browser.app.utils.htmx import is_htmx_request from shared.sexp.page import get_template_context - from sexp_components import render_calendar_admin_page, render_calendar_admin_oob + from sexp.sexp_components import render_calendar_admin_page, render_calendar_admin_oob tctx = await get_template_context() if not is_htmx_request(): @@ -34,12 +34,8 @@ def register(): @bp.get("/description/") @require_admin async def calendar_description_edit(calendar_slug: str, **kwargs): - # g.post and g.calendar should already be set by the parent calendar bp - html = await render_template( - "_types/calendar/admin/_description_edit.html", - post=g.post_data['post'], - calendar=g.calendar, - ) + from sexp.sexp_components import render_calendar_description_edit + html = render_calendar_description_edit(g.calendar) return await make_response(html) @@ -54,24 +50,16 @@ def register(): g.calendar.description = description await g.s.flush() - html = await render_template( - "_types/calendar/admin/_description.html", - post=g.post_data['post'], - calendar=g.calendar, - oob=True - ) + from sexp.sexp_components import render_calendar_description + html = render_calendar_description(g.calendar, oob=True) return await make_response(html) @bp.get("/description/view/") @require_admin async def calendar_description_view(calendar_slug: str, **kwargs): - # just render the display version without touching the DB (used by Cancel) - html = await render_template( - "_types/calendar/admin/_description.html", - post=g.post_data['post'], - calendar=g.calendar, - ) + from sexp.sexp_components import render_calendar_description + html = render_calendar_description(g.calendar) return await make_response(html) return bp diff --git a/events/bp/calendar/routes.py b/events/bp/calendar/routes.py index 84f1b8c..d686ec2 100644 --- a/events/bp/calendar/routes.py +++ b/events/bp/calendar/routes.py @@ -143,7 +143,7 @@ def register(): confirmed_entries = visible.confirmed_entries from shared.sexp.page import get_template_context - from sexp_components import render_calendar_page, render_calendar_oob + from sexp.sexp_components import render_calendar_page, render_calendar_oob tctx = await get_template_context() tctx.update(dict( @@ -183,7 +183,10 @@ def register(): description = (form.get("description") or "").strip() await update_calendar_description(g.calendar, description) - html = await render_template("_types/calendar/admin/index.html") + from shared.sexp.page import get_template_context + from sexp.sexp_components import _calendar_admin_main_panel_html + ctx = await get_template_context() + html = _calendar_admin_main_panel_html(ctx) return await make_response(html, 200) @@ -199,10 +202,14 @@ def register(): # If we have post context (blog-embedded mode), update nav post_data = getattr(g, "post_data", None) - html = await render_template("_types/calendars/index.html") + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_calendars_list_panel + ctx = await get_template_context() + html = render_calendars_list_panel(ctx) if post_data: from shared.services.entry_associations import get_associated_entries + from sexp.sexp_components import render_post_nav_entries_oob post_id = (post_data.get("post") or {}).get("id") cals = ( @@ -214,13 +221,7 @@ def register(): ).scalars().all() associated_entries = await get_associated_entries(g.s, post_id) - - nav_oob = await render_template( - "_types/post/admin/_nav_entries_oob.html", - associated_entries=associated_entries, - calendars=cals, - post=post_data["post"], - ) + nav_oob = render_post_nav_entries_oob(associated_entries, cals, post_data["post"]) html = html + nav_oob return await make_response(html, 200) diff --git a/events/bp/calendar_entries/routes.py b/events/bp/calendar_entries/routes.py index af1c2ee..bb8c1e1 100644 --- a/events/bp/calendar_entries/routes.py +++ b/events/bp/calendar_entries/routes.py @@ -216,7 +216,49 @@ def register(): if ident["session_id"] is not None: frag_params["session_id"] = ident["session_id"] - html = await render_template("_types/day/_main_panel.html") + # Re-query day entries for the sexp component + from datetime import date as date_cls, timedelta + from bp.calendar.services import get_visible_entries_for_period + from quart import session as qsession + + period_start = datetime(year, month, day, tzinfo=timezone.utc) + period_end = period_start + timedelta(days=1) + user = getattr(g, "user", None) + session_id = qsession.get("calendar_sid") + + visible = await get_visible_entries_for_period( + sess=g.s, + calendar_id=g.calendar.id, + period_start=period_start, + period_end=period_end, + user=user, + session_id=session_id, + ) + + # Query day slots for this weekday + day_date = date_cls(year, month, day) + weekday_attr = ["mon","tue","wed","thu","fri","sat","sun"][day_date.weekday()] + stmt = select(CalendarSlot).where( + CalendarSlot.calendar_id == g.calendar.id, + getattr(CalendarSlot, weekday_attr) == True, + CalendarSlot.deleted_at.is_(None), + ).order_by(CalendarSlot.time_start.asc(), CalendarSlot.id.asc()) + result = await g.s.execute(stmt) + day_slots = list(result.scalars()) + + styles = getattr(g, "styles", None) or {} + ctx = { + "calendar": g.calendar, + "day_entries": visible.merged_entries, + "day": day, + "month": month, + "year": year, + "hx_select_search": "#main-panel", + "styles": styles, + } + + from sexp.sexp_components import render_day_main_panel + html = render_day_main_panel(ctx) mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False) return await make_response(html + mini_html, 200) diff --git a/events/bp/calendar_entry/routes.py b/events/bp/calendar_entry/routes.py index c44e94c..a8bf501 100644 --- a/events/bp/calendar_entry/routes.py +++ b/events/bp/calendar_entry/routes.py @@ -109,15 +109,9 @@ def register(): session_id=session_id, ) - # Render OOB template - nav_oob = await render_template( - "_types/day/admin/_nav_entries_oob.html", - confirmed_entries=visible.confirmed_entries, - post=g.post_data["post"], - calendar=calendar, - day_date=day_date, - ) - return nav_oob + # Render OOB nav + from sexp.sexp_components import render_day_entries_nav_oob + return render_day_entries_nav_oob(visible.confirmed_entries, calendar, day_date) async def get_post_nav_oob(entry_id: int): """Helper to generate OOB update for post entries nav when entry state changes""" @@ -152,13 +146,9 @@ def register(): ) ).scalars().all() - # Render OOB template for this post's nav - nav_oob = await render_template( - "_types/post/admin/_nav_entries_oob.html", - associated_entries=associated_entries, - calendars=calendars, - post=post, - ) + # Render OOB nav for this post + from sexp.sexp_components import render_post_nav_entries_oob + nav_oob = render_post_nav_entries_oob(associated_entries, calendars, post) nav_oobs.append(nav_oob) return "".join(nav_oobs) @@ -250,19 +240,15 @@ def register(): @require_admin async def get(entry_id: int, **rest): from shared.browser.app.utils.htmx import is_htmx_request + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_entry_page, render_entry_oob - # Full template for both HTMX and normal requests + tctx = await get_template_context() if not is_htmx_request(): - # Normal browser request: full page with layout - html = await render_template( - "_types/entry/index.html", - ) + html = await render_entry_page(tctx) else: - - html = await render_template( - "_types/entry/_oob_elements.html", - ) - + html = await render_entry_oob(tctx) + return await make_response(html, 200) @bp.get("/edit/") @@ -431,10 +417,11 @@ def register(): # Get nav OOB update nav_oob = await get_day_nav_oob(year, month, day) - html = await render_template( - "_types/entry/index.html", - #entry=entry, - ) + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_entry_page + + tctx = await get_template_context() + html = await render_entry_page(tctx) return await make_response(html + nav_oob, 200) @@ -457,8 +444,10 @@ def register(): day_nav_oob = await get_day_nav_oob(year, month, day) post_nav_oob = await get_post_nav_oob(entry_id) - # redirect back to calendar admin or order page as you prefer - html = await render_template("_types/entry/_optioned.html") + # Re-read entry to get updated state + await g.s.refresh(g.entry) + from sexp.sexp_components import render_entry_optioned + html = render_entry_optioned(g.entry, g.calendar, day, month, year) return await make_response(html + day_nav_oob + post_nav_oob, 200) @bp.post("/decline/") @@ -480,8 +469,10 @@ def register(): day_nav_oob = await get_day_nav_oob(year, month, day) post_nav_oob = await get_post_nav_oob(entry_id) - # redirect back to calendar admin or order page as you prefer - html = await render_template("_types/entry/_optioned.html") + # Re-read entry to get updated state + await g.s.refresh(g.entry) + from sexp.sexp_components import render_entry_optioned + html = render_entry_optioned(g.entry, g.calendar, day, month, year) return await make_response(html + day_nav_oob + post_nav_oob, 200) @bp.post("/provisional/") @@ -503,8 +494,10 @@ def register(): day_nav_oob = await get_day_nav_oob(year, month, day) post_nav_oob = await get_post_nav_oob(entry_id) - # redirect back to calendar admin or order page as you prefer - html = await render_template("_types/entry/_optioned.html") + # Re-read entry to get updated state + await g.s.refresh(g.entry) + from sexp.sexp_components import render_entry_optioned + html = render_entry_optioned(g.entry, g.calendar, day, month, year) return await make_response(html + day_nav_oob + post_nav_oob, 200) @bp.post("/tickets/") @@ -546,7 +539,9 @@ def register(): await g.s.flush() # Return just the tickets fragment (targeted by hx-target="#entry-tickets-...") - html = await render_template("_types/entry/_tickets.html") + await g.s.refresh(g.entry) + from sexp.sexp_components import render_entry_tickets_config + html = render_entry_tickets_config(g.entry, g.calendar, request.view_args.get("day"), request.view_args.get("month"), request.view_args.get("year")) return await make_response(html, 200) @bp.get("/posts/search/") @@ -596,11 +591,10 @@ def register(): entry_posts = await get_entry_posts(g.s, entry_id) # Return updated posts list + OOB nav update - html = await render_template("_types/entry/_posts.html") - nav_oob = await render_template( - "_types/entry/admin/_nav_posts_oob.html", - entry_posts=entry_posts, - ) + from sexp.sexp_components import render_entry_posts_panel, render_entry_posts_nav_oob + va = request.view_args or {} + html = render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year")) + nav_oob = render_entry_posts_nav_oob(entry_posts) return await make_response(html + nav_oob, 200) @bp.delete("/posts//") @@ -619,11 +613,10 @@ def register(): entry_posts = await get_entry_posts(g.s, entry_id) # Return updated posts list + OOB nav update - html = await render_template("_types/entry/_posts.html") - nav_oob = await render_template( - "_types/entry/admin/_nav_posts_oob.html", - entry_posts=entry_posts, - ) + from sexp.sexp_components import render_entry_posts_panel, render_entry_posts_nav_oob + va = request.view_args or {} + html = render_entry_posts_panel(entry_posts, g.entry, g.calendar, va.get("day"), va.get("month"), va.get("year")) + nav_oob = render_entry_posts_nav_oob(entry_posts) return await make_response(html + nav_oob, 200) return bp diff --git a/events/bp/calendars/routes.py b/events/bp/calendars/routes.py index 989be3e..2bdd9e1 100644 --- a/events/bp/calendars/routes.py +++ b/events/bp/calendars/routes.py @@ -36,7 +36,7 @@ def register(): @cache_page(tag="calendars") async def home(**kwargs): from shared.sexp.page import get_template_context - from sexp_components import render_calendars_page, render_calendars_oob + from sexp.sexp_components import render_calendars_page, render_calendars_oob ctx = await get_template_context() if not is_htmx_request(): @@ -68,13 +68,15 @@ def register(): except Exception as e: return await make_response(f'
{e}
', 422) - html = await render_template( - "_types/calendars/index.html", - ) + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_calendars_list_panel + ctx = await get_template_context() + html = render_calendars_list_panel(ctx) # Blog-embedded mode: also update post nav if post_data: from shared.services.entry_associations import get_associated_entries + from sexp.sexp_components import render_post_nav_entries_oob cals = ( await g.s.execute( @@ -85,14 +87,7 @@ def register(): ).scalars().all() associated_entries = await get_associated_entries(g.s, post_id) - - nav_oob = await render_template( - "_types/post/admin/_nav_entries_oob.html", - associated_entries=associated_entries, - calendars=cals, - post=post_data["post"], - ) - + nav_oob = render_post_nav_entries_oob(associated_entries, cals, post_data["post"]) html = html + nav_oob return await make_response(html) diff --git a/events/bp/day/admin/routes.py b/events/bp/day/admin/routes.py index a895f7b..03e3539 100644 --- a/events/bp/day/admin/routes.py +++ b/events/bp/day/admin/routes.py @@ -18,7 +18,7 @@ def register(): from shared.browser.app.utils.htmx import is_htmx_request from shared.sexp.page import get_template_context - from sexp_components import render_day_admin_page, render_day_admin_oob + from sexp.sexp_components import render_day_admin_page, render_day_admin_oob tctx = await get_template_context() if not is_htmx_request(): diff --git a/events/bp/day/routes.py b/events/bp/day/routes.py index 6e74fe7..fac7265 100644 --- a/events/bp/day/routes.py +++ b/events/bp/day/routes.py @@ -121,7 +121,7 @@ def register(): - pending only for current user/session """ from shared.sexp.page import get_template_context - from sexp_components import render_day_page, render_day_oob + from sexp.sexp_components import render_day_page, render_day_oob tctx = await get_template_context() if not is_htmx_request(): diff --git a/events/bp/markets/routes.py b/events/bp/markets/routes.py index 385a5b6..7c1b8f7 100644 --- a/events/bp/markets/routes.py +++ b/events/bp/markets/routes.py @@ -24,7 +24,7 @@ def register(): @bp.get("/") async def home(**kwargs): from shared.sexp.page import get_template_context - from sexp_components import render_markets_page, render_markets_oob + from sexp.sexp_components import render_markets_page, render_markets_oob ctx = await get_template_context() if not is_htmx_request(): @@ -52,7 +52,10 @@ def register(): except Exception as e: return await make_response(f'
{e}
', 422) - html = await render_template("_types/markets/index.html") + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_markets_list_panel + ctx = await get_template_context() + html = render_markets_list_panel(ctx) return await make_response(html) @bp.delete("//") @@ -63,7 +66,10 @@ def register(): if not deleted: return await make_response("Market not found", 404) - html = await render_template("_types/markets/index.html") + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_markets_list_panel + ctx = await get_template_context() + html = render_markets_list_panel(ctx) return await make_response(html) return bp diff --git a/events/bp/page/routes.py b/events/bp/page/routes.py index f711801..7f793ca 100644 --- a/events/bp/page/routes.py +++ b/events/bp/page/routes.py @@ -46,7 +46,7 @@ def register() -> Blueprint: entries, has_more, pending_tickets = await _load_entries(post["id"], page) from shared.sexp.page import get_template_context - from sexp_components import render_page_summary_page, render_page_summary_oob + from sexp.sexp_components import render_page_summary_page, render_page_summary_oob ctx = await get_template_context() if is_htmx_request(): @@ -64,7 +64,7 @@ def register() -> Blueprint: entries, has_more, pending_tickets = await _load_entries(post["id"], page) - from sexp_components import render_page_summary_cards + from sexp.sexp_components import render_page_summary_cards html = await render_page_summary_cards(entries, has_more, pending_tickets, {}, page, view, post) return await make_response(html, 200) @@ -105,12 +105,8 @@ def register() -> Blueprint: if ident["session_id"] is not None: frag_params["session_id"] = ident["session_id"] - widget_html = await render_template( - "_types/page_summary/_ticket_widget.html", - entry=entry, - qty=qty, - ticket_url=f"/{g.post_slug}/tickets/adjust", - ) + from sexp.sexp_components import render_ticket_widget + widget_html = render_ticket_widget(entry, qty, f"/{g.post_slug}/tickets/adjust") mini_html = await fetch_fragment("cart", "cart-mini", params=frag_params, required=False) return await make_response(widget_html + mini_html, 200) diff --git a/events/bp/payments/routes.py b/events/bp/payments/routes.py index 5fb6e09..8e4da9a 100644 --- a/events/bp/payments/routes.py +++ b/events/bp/payments/routes.py @@ -45,7 +45,7 @@ def register(): pay_ctx = await _load_payment_ctx() from shared.sexp.page import get_template_context - from sexp_components import render_payments_page, render_payments_oob + from sexp.sexp_components import render_payments_page, render_payments_oob ctx = await get_template_context() ctx.update(pay_ctx) @@ -80,8 +80,11 @@ def register(): await call_action("blog", "update-page-config", payload=payload) - ctx = await _load_payment_ctx() - html = await render_template("_types/payments/_main_panel.html", **ctx) + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_payments_panel + ctx = await get_template_context() + ctx.update(await _load_payment_ctx()) + html = render_payments_panel(ctx) return await make_response(html) return bp diff --git a/events/bp/slot/routes.py b/events/bp/slot/routes.py index d3011fd..cc73768 100644 --- a/events/bp/slot/routes.py +++ b/events/bp/slot/routes.py @@ -74,12 +74,8 @@ def register(): slot = await svc_get_slot(g.s, slot_id) if not slot: return await make_response("Not found", 404) - html = await render_template( - "_types/slot/_main_panel.html", - slot=slot, - #post=g.post_data['post'], - #calendar=g.calendar, - ) + from sexp.sexp_components import render_slot_main_panel + html = render_slot_main_panel(slot, g.calendar) return await make_response(html) @bp.delete("/") @@ -88,7 +84,8 @@ def register(): async def slot_delete(slot_id: int, **kwargs): await svc_delete_slot(g.s, slot_id) slots = await svc_list_slots(g.s, g.calendar.id) - html = await render_template("_types/slots/_man_panel.html", calendar=g.calendar, slots=slots) + from sexp.sexp_components import render_slots_table + html = render_slots_table(slots, g.calendar) return await make_response(html) @bp.put("/") @@ -170,11 +167,8 @@ def register(): } ), 422 - html = await render_template( - "_types/slot/_main_panel.html", - slot=slot, - oob=True, - ) + from sexp.sexp_components import render_slot_main_panel + html = render_slot_main_panel(slot, g.calendar, oob=True) return await make_response(html) diff --git a/events/bp/slots/routes.py b/events/bp/slots/routes.py index cd655cb..3c64958 100644 --- a/events/bp/slots/routes.py +++ b/events/bp/slots/routes.py @@ -128,7 +128,9 @@ def register(): }), 422 # Success → re-render the slots table - html = await render_template("_types/slots/_main_panel.html") + slots = await svc_list_slots(g.s, g.calendar.id) + from sexp.sexp_components import render_slots_table + html = render_slots_table(slots, g.calendar) return await make_response(html) diff --git a/events/bp/ticket_admin/routes.py b/events/bp/ticket_admin/routes.py index 945ea6c..83cf12c 100644 --- a/events/bp/ticket_admin/routes.py +++ b/events/bp/ticket_admin/routes.py @@ -71,7 +71,7 @@ def register() -> Blueprint: } from shared.sexp.page import get_template_context - from sexp_components import render_ticket_admin_page, render_ticket_admin_oob + from sexp.sexp_components import render_ticket_admin_page, render_ticket_admin_oob ctx = await get_template_context() if not is_htmx_request(): @@ -100,11 +100,8 @@ def register() -> Blueprint: tickets = await get_tickets_for_entry(g.s, entry_id) - html = await render_template( - "_types/ticket_admin/_entry_tickets.html", - entry=entry, - tickets=tickets, - ) + from sexp.sexp_components import render_entry_tickets_admin + html = render_entry_tickets_admin(entry, tickets) return await make_response(html, 200) @bp.get("/lookup/") @@ -119,19 +116,12 @@ def register() -> Blueprint: ) ticket = await get_ticket_by_code(g.s, code) + from sexp.sexp_components import render_lookup_result if not ticket: - html = await render_template( - "_types/ticket_admin/_lookup_result.html", - ticket=None, - error="Ticket not found", - ) + html = render_lookup_result(None, "Ticket not found") return await make_response(html, 200) - html = await render_template( - "_types/ticket_admin/_lookup_result.html", - ticket=ticket, - error=None, - ) + html = render_lookup_result(ticket, None) return await make_response(html, 200) @bp.post("//checkin/") @@ -141,22 +131,13 @@ def register() -> Blueprint: """Check in a ticket by its code.""" success, error = await checkin_ticket(g.s, code) + from sexp.sexp_components import render_checkin_result if not success: - html = await render_template( - "_types/ticket_admin/_checkin_result.html", - success=False, - error=error, - ticket=None, - ) + html = render_checkin_result(False, error, None) return await make_response(html, 200) ticket = await get_ticket_by_code(g.s, code) - html = await render_template( - "_types/ticket_admin/_checkin_result.html", - success=True, - error=None, - ticket=ticket, - ) + html = render_checkin_result(True, None, ticket) return await make_response(html, 200) return bp diff --git a/events/bp/ticket_type/routes.py b/events/bp/ticket_type/routes.py index 8f807b3..24b51dc 100644 --- a/events/bp/ticket_type/routes.py +++ b/events/bp/ticket_type/routes.py @@ -66,9 +66,11 @@ def register(): if not ticket_type: return await make_response("Not found", 404) - html = await render_template( - "_types/ticket_type/_main_panel.html", - ticket_type=ticket_type, + from sexp.sexp_components import render_ticket_type_main_panel + va = request.view_args or {} + html = render_ticket_type_main_panel( + ticket_type, g.entry, g.calendar, + va.get("day"), va.get("month"), va.get("year"), ) return await make_response(html) @@ -132,9 +134,11 @@ def register(): return await make_response("Not found", 404) # Return updated view with OOB flag - html = await render_template( - "_types/ticket_type/_main_panel.html", - ticket_type=ticket_type, + from sexp.sexp_components import render_ticket_type_main_panel + va = request.view_args or {} + html = render_ticket_type_main_panel( + ticket_type, g.entry, g.calendar, + va.get("day"), va.get("month"), va.get("year"), oob=True, ) return await make_response(html) @@ -150,9 +154,11 @@ def register(): # Re-render the ticket types list ticket_types = await svc_list_ticket_types(g.s, g.entry.id) - html = await render_template( - "_types/ticket_types/_main_panel.html", - ticket_types=ticket_types + from sexp.sexp_components import render_ticket_types_table + va = request.view_args or {} + html = render_ticket_types_table( + ticket_types, g.entry, g.calendar, + va.get("day"), va.get("month"), va.get("year"), ) return await make_response(html) diff --git a/events/bp/ticket_types/routes.py b/events/bp/ticket_types/routes.py index 0041eb1..3a33d11 100644 --- a/events/bp/ticket_types/routes.py +++ b/events/bp/ticket_types/routes.py @@ -108,7 +108,13 @@ def register(): ) # Success → re-render the ticket types table - html = await render_template("_types/ticket_types/_main_panel.html") + ticket_types = await svc_list_ticket_types(g.s, g.entry.id) + from sexp.sexp_components import render_ticket_types_table + va = request.view_args or {} + html = render_ticket_types_table( + ticket_types, g.entry, g.calendar, + va.get("day"), va.get("month"), va.get("year"), + ) return await make_response(html) @bp.get("/add") diff --git a/events/bp/tickets/routes.py b/events/bp/tickets/routes.py index 2f1a9ce..e0965f0 100644 --- a/events/bp/tickets/routes.py +++ b/events/bp/tickets/routes.py @@ -51,7 +51,7 @@ def register() -> Blueprint: ) from shared.sexp.page import get_template_context - from sexp_components import render_tickets_page, render_tickets_oob + from sexp.sexp_components import render_tickets_page, render_tickets_oob ctx = await get_template_context() if not is_htmx_request(): @@ -82,7 +82,7 @@ def register() -> Blueprint: 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 + from sexp.sexp_components import render_ticket_detail_page, render_ticket_detail_oob ctx = await get_template_context() if not is_htmx_request(): @@ -169,13 +169,20 @@ def register() -> Blueprint: remaining = await get_available_ticket_count(g.s, entry_id) all_tickets = await get_tickets_for_entry(g.s, entry_id) - html = await render_template( - "_types/tickets/_buy_result.html", - entry=entry, - created_tickets=created, - remaining=remaining, - all_tickets=all_tickets, - ) + # Compute cart count for OOB mini-cart update + from shared.infrastructure.data_client import fetch_data + from shared.contracts.dtos import CartSummaryDTO, dto_from_dict + summary_params = {} + if ident["user_id"] is not None: + summary_params["user_id"] = ident["user_id"] + if ident["session_id"] is not None: + summary_params["session_id"] = ident["session_id"] + raw_summary = await fetch_data("cart", "cart-summary", params=summary_params, required=False) + summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO() + cart_count = summary.count + summary.calendar_count + summary.ticket_count + + from sexp.sexp_components import render_buy_result + html = render_buy_result(entry, created, remaining, cart_count) return await make_response(html, 200) @bp.post("/adjust/") @@ -298,14 +305,10 @@ def register() -> Blueprint: summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO() cart_count = summary.count + summary.calendar_count + summary.ticket_count - html = await render_template( - "_types/tickets/_adjust_response.html", - entry=entry, - ticket_remaining=ticket_remaining, - ticket_sold_count=ticket_sold_count, - user_ticket_count=user_ticket_count, - user_ticket_counts_by_type=user_ticket_counts_by_type, - cart_count=cart_count, + from sexp.sexp_components import render_adjust_response + html = render_adjust_response( + entry, ticket_remaining, ticket_sold_count, + user_ticket_count, user_ticket_counts_by_type, cart_count, ) return await make_response(html, 200) diff --git a/events/sexp/__init__.py b/events/sexp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/events/sexp_components.py b/events/sexp/sexp_components.py similarity index 55% rename from events/sexp_components.py rename to events/sexp/sexp_components.py index c14fad8..d1e1ac2 100644 --- a/events/sexp_components.py +++ b/events/sexp/sexp_components.py @@ -547,9 +547,14 @@ def _calendar_main_panel_html(ctx: dict) -> str: parts.append('
') for week in weeks: for day_cell in week: - in_month = getattr(day_cell, "in_month", True) - is_today = getattr(day_cell, "is_today", False) - day_date = getattr(day_cell, "date", None) + if isinstance(day_cell, dict): + in_month = day_cell.get("in_month", True) + is_today = day_cell.get("is_today", False) + day_date = day_cell.get("date") + else: + in_month = getattr(day_cell, "in_month", True) + is_today = getattr(day_cell, "is_today", False) + day_date = getattr(day_cell, "date", None) cell_cls = "min-h-20 sm:min-h-24 bg-white px-3 py-2 text-xs" if not in_month: @@ -582,7 +587,7 @@ def _calendar_main_panel_html(ctx: dict) -> str: parts.append('
') if day_date: for e in month_entries: - if e.start_at.date() == day_date: + if e.start_at and e.start_at.date() == day_date: is_mine = ( (user and e.user_id == user.id) or (not user and e.session_id == qs.get("calendar_sid")) @@ -977,7 +982,7 @@ def _tickets_main_panel_html(ctx: dict, tickets: list) -> str: ) if tt: parts.append(f'
{escape(tt.name)}
') - if entry: + if entry and entry.start_at: parts.append( '
' f'{entry.start_at.strftime("%A, %B %d, %Y at %H:%M")}' @@ -1050,7 +1055,7 @@ def _ticket_detail_panel_html(ctx: dict, ticket) -> str: # Event details parts.append('
') - if entry: + if entry and entry.start_at: parts.append( '
' f'
{entry.start_at.strftime("%A, %B %d, %Y")}
' @@ -1206,7 +1211,7 @@ def _entry_card_html(entry, page_info: dict, pending_tickets: dict, page_title = pi.get("title") day_href = "" - if page_slug: + if page_slug and entry.start_at: day_href = events_url_fn(f"/{page_slug}/calendars/{entry.calendar_slug}/day/{entry.start_at.strftime('%Y/%-m/%-d')}/") entry_href = f"{day_href}entries/{entry.id}/" if day_href else "" @@ -1272,7 +1277,7 @@ def _entry_card_tile_html(entry, page_info: dict, pending_tickets: dict, page_title = pi.get("title") day_href = "" - if page_slug: + if page_slug and entry.start_at: day_href = events_url_fn(f"/{page_slug}/calendars/{entry.calendar_slug}/day/{entry.start_at.strftime('%Y/%-m/%-d')}/") entry_href = f"{day_href}entries/{entry.id}/" if day_href else "" @@ -1414,7 +1419,7 @@ def _entry_cards_html(entries, page_info, pending_tickets, ticket_url, is_page_scoped=is_page_scoped, post=post, )) else: - entry_date = entry.start_at.strftime("%A %-d %B %Y") + entry_date = entry.start_at.strftime("%A %-d %B %Y") if entry.start_at else "" if entry_date != last_date: parts.append( f'

' @@ -1843,3 +1848,1441 @@ async def render_payments_oob(ctx: dict) -> str: oobs += _oob_header_html("post-admin-header-child", "payments-header-child", _payments_header_html(ctx)) return oob_page(ctx, oobs_html=oobs, content_html=content) + + +# =========================================================================== +# POST / PUT / DELETE response components +# =========================================================================== + + +# --------------------------------------------------------------------------- +# Ticket widget (public wrapper for _ticket_widget_html) +# --------------------------------------------------------------------------- + +def render_ticket_widget(entry, qty: int, ticket_url: str) -> str: + """Render the +/- ticket widget for page_summary / all_events adjust_ticket.""" + return _ticket_widget_html(entry, qty, ticket_url, ctx={}) + + +# --------------------------------------------------------------------------- +# Ticket admin: checkin result +# --------------------------------------------------------------------------- + +def render_checkin_result(success: bool, error: str | None, ticket) -> str: + """Render checkin result: table row on success, error div on failure.""" + if not success: + err_msg = escape(error or "Check-in failed") + return ( + '
' + f'{err_msg}' + '
' + ) + if not ticket: + return "" + code = ticket.code + entry = getattr(ticket, "entry", None) + tt = getattr(ticket, "ticket_type", None) + checked_in_at = getattr(ticket, "checked_in_at", None) + time_str = checked_in_at.strftime("%H:%M") if checked_in_at else "Just now" + + entry_name = escape(entry.name) if entry else "—" + date_html = "" + if entry and entry.start_at: + date_html = f'
{entry.start_at.strftime("%d %b %Y, %H:%M")}
' + + tt_name = escape(tt.name) if tt else "—" + + return ( + f'' + f'{code[:12]}...' + f'
{entry_name}
{date_html}' + f'{tt_name}' + f'{_ticket_state_badge_html("checked_in")}' + f'' + f' {time_str}' + '' + ) + + +# --------------------------------------------------------------------------- +# Ticket admin: lookup result +# --------------------------------------------------------------------------- + +def render_lookup_result(ticket, error: str | None) -> str: + """Render ticket lookup result: error div or ticket info card.""" + from quart import url_for + from shared.browser.app.csrf import generate_csrf_token + + if error: + return ( + '
' + f'{escape(error)}' + '
' + ) + if not ticket: + return "" + + entry = getattr(ticket, "entry", None) + tt = getattr(ticket, "ticket_type", None) + state = getattr(ticket, "state", "") + code = ticket.code + checked_in_at = getattr(ticket, "checked_in_at", None) + csrf = generate_csrf_token() + + entry_name = escape(entry.name) if entry else "Unknown event" + parts = ['
'] + parts.append('
') + parts.append(f'
{entry_name}
') + if tt: + parts.append(f'
{escape(tt.name)}
') + if entry and entry.start_at: + parts.append(f'
{entry.start_at.strftime("%A, %B %d, %Y at %H:%M")}
') + cal = getattr(entry, "calendar", None) if entry else None + if cal: + parts.append(f'
{escape(cal.name)}
') + + parts.append('
') + parts.append(_ticket_state_badge_html(state)) + parts.append(f'{code}') + parts.append('
') + + if checked_in_at: + parts.append(f'
Checked in: {checked_in_at.strftime("%B %d, %Y at %H:%M")}
') + + parts.append('
') + + # Action area + parts.append(f'
') + if state in ("confirmed", "reserved"): + checkin_url = url_for("ticket_admin.do_checkin", code=code) + parts.append( + f'
' + f'' + '
' + ) + elif state == "checked_in": + parts.append( + '
' + '' + '
Checked In
' + ) + elif state == "cancelled": + parts.append( + '
' + '' + '
Cancelled
' + ) + parts.append('
') + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Ticket admin: entry tickets table +# --------------------------------------------------------------------------- + +def render_entry_tickets_admin(entry, tickets: list) -> str: + """Render admin ticket table for a specific entry.""" + from quart import url_for + from shared.browser.app.csrf import generate_csrf_token + csrf = generate_csrf_token() + + count = len(tickets) + suffix = "s" if count != 1 else "" + parts = ['
'] + parts.append( + '
' + f'

Tickets for: {escape(entry.name)}

' + f'{count} ticket{suffix}' + '
' + ) + + if tickets: + parts.append('
') + parts.append( + '' + '' + '' + '' + '' + '' + ) + for ticket in tickets: + tt = getattr(ticket, "ticket_type", None) + state = getattr(ticket, "state", "") + code = ticket.code + checked_in_at = getattr(ticket, "checked_in_at", None) + + parts.append(f'') + parts.append(f'') + parts.append(f'') + parts.append(f'') + parts.append('') + + parts.append('
CodeTypeStateActions
{code[:12]}...{escape(tt.name) if tt else "—"}{_ticket_state_badge_html(state)}') + if state in ("confirmed", "reserved"): + checkin_url = url_for("ticket_admin.do_checkin", code=code) + parts.append( + f'
' + f'' + '
' + ) + elif state == "checked_in": + t_str = checked_in_at.strftime("%H:%M") if checked_in_at else "" + parts.append(f' {t_str}') + parts.append('
') + else: + parts.append('
No tickets for this entry
') + + parts.append('
') + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Day main panel — public API +# --------------------------------------------------------------------------- + +def render_day_main_panel(ctx: dict) -> str: + """Public wrapper for day main panel rendering.""" + return _day_main_panel_html(ctx) + + +# --------------------------------------------------------------------------- +# Entry main panel +# --------------------------------------------------------------------------- + +def _entry_main_panel_html(ctx: dict) -> str: + """Render the entry detail panel (name, slot, time, state, cost, tickets, + buy form, date, posts, options + edit button).""" + from quart import url_for + from shared.browser.app.csrf import generate_csrf_token + + entry = ctx.get("entry") + if not entry: + return "" + + calendar = ctx.get("calendar") + cal_slug = getattr(calendar, "slug", "") if calendar else "" + day = ctx.get("day") + month = ctx.get("month") + year = ctx.get("year") + styles = ctx.get("styles") or {} + list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "") + pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "") + + eid = entry.id + state = getattr(entry, "state", "pending") or "pending" + + parts = [f'
'] + + # Name + parts.append( + '
' + '
Name
' + f'
{escape(entry.name)}
' + '
' + ) + + # Slot + slot = getattr(entry, "slot", None) + parts.append( + '
' + '
Slot
' + '
' + ) + if slot: + flex_label = "(flexible)" if getattr(slot, "flexible", False) else "(fixed)" + parts.append( + f'{escape(slot.name)}' + f'{flex_label}' + ) + else: + parts.append('No slot assigned') + parts.append('
') + + # Time Period + start_str = entry.start_at.strftime("%H:%M") if entry.start_at else "" + end_str = f" – {entry.end_at.strftime('%H:%M')}" if entry.end_at else " – open-ended" + parts.append( + '
' + '
Time Period
' + f'
{start_str}{end_str}
' + '
' + ) + + # State + state_badge = _entry_state_badge_html(state) + parts.append( + '
' + '
State
' + f'
{state_badge}
' + '
' + ) + + # Cost + cost = getattr(entry, "cost", None) + cost_str = f"{cost:.2f}" if cost is not None else "0.00" + parts.append( + '
' + '
Cost
' + f'
£{cost_str}
' + '
' + ) + + # Ticket Configuration (admin) + parts.append( + '
' + '
Tickets
' + f'
' + ) + parts.append(render_entry_tickets_config(entry, calendar, day, month, year)) + parts.append('
') + + # Buy Tickets (public-facing) + ticket_remaining = ctx.get("ticket_remaining") + ticket_sold_count = ctx.get("ticket_sold_count", 0) + user_ticket_count = ctx.get("user_ticket_count", 0) + user_ticket_counts_by_type = ctx.get("user_ticket_counts_by_type") or {} + parts.append(render_buy_form( + entry, ticket_remaining, ticket_sold_count, + user_ticket_count, user_ticket_counts_by_type, + )) + + # Date + date_str = entry.start_at.strftime("%A, %B %d, %Y") if entry.start_at else "" + parts.append( + '
' + '
Date
' + f'
{date_str}
' + '
' + ) + + # Associated Posts + entry_posts = ctx.get("entry_posts") or [] + parts.append( + '
' + '
Associated Posts
' + f'
' + ) + parts.append(render_entry_posts_panel(entry_posts, entry, calendar, day, month, year)) + parts.append('
') + + # Options and Edit Button + parts.append('
') + parts.append(_entry_options_html(entry, calendar, day, month, year)) + + edit_url = url_for( + "calendars.calendar.day.calendar_entries.calendar_entry.get_edit", + entry_id=eid, calendar_slug=cal_slug, + day=day, month=month, year=year, + ) + parts.append( + f'' + ) + parts.append('
') + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Entry header row +# --------------------------------------------------------------------------- + +def _entry_header_html(ctx: dict, *, oob: bool = False) -> str: + """Build entry detail header row.""" + from quart import url_for + + calendar = ctx.get("calendar") + if not calendar: + return "" + cal_slug = getattr(calendar, "slug", "") + entry = ctx.get("entry") + if not entry: + return "" + day = ctx.get("day") + month = ctx.get("month") + year = ctx.get("year") + + link_href = url_for( + "calendars.calendar.day.calendar_entries.calendar_entry.get", + calendar_slug=cal_slug, + year=year, month=month, day=day, + entry_id=entry.id, + ) + label_html = ( + f'
' + + _entry_title_html(entry) + + _entry_times_html(entry) + + '
' + ) + + nav_html = _entry_nav_html(ctx) + + return sexp( + '(~menu-row :id "entry-row" :level 5' + ' :link-href lh :link-label-html llh' + ' :nav-html nh :child-id "entry-header-child" :oob oob)', + lh=link_href, + llh=label_html, + nh=nav_html, + oob=oob, + ) + + +def _entry_times_html(entry) -> str: + """Render entry times label.""" + start = entry.start_at + end = entry.end_at + if not start: + return "" + start_str = start.strftime("%H:%M") + end_str = f" → {end.strftime('%H:%M')}" if end else "" + return f'
{start_str}{end_str}
' + + +# --------------------------------------------------------------------------- +# Entry nav (desktop + admin link) +# --------------------------------------------------------------------------- + +def _entry_nav_html(ctx: dict) -> str: + """Entry desktop nav: associated posts scrolling menu + admin link.""" + from quart import url_for + + calendar = ctx.get("calendar") + if not calendar: + return "" + cal_slug = getattr(calendar, "slug", "") + entry = ctx.get("entry") + if not entry: + return "" + day = ctx.get("day") + month = ctx.get("month") + year = ctx.get("year") + entry_posts = ctx.get("entry_posts") or [] + rights = ctx.get("rights") or {} + is_admin = getattr(rights, "admin", False) if hasattr(rights, "admin") else rights.get("admin", False) + + blog_url_fn = ctx.get("blog_url") + + parts = [] + + # Associated Posts scrolling menu + if entry_posts: + parts.append( + '
' + '
' + ) + for ep in entry_posts: + slug = getattr(ep, "slug", "") + title = escape(getattr(ep, "title", "")) + feat = getattr(ep, "feature_image", None) + href = blog_url_fn(f"/{slug}/") if blog_url_fn else f"/{slug}/" + if feat: + img = f'{title}' + else: + img = '
' + parts.append( + f'' + f'{img}
{title}
' + ) + parts.append('
') + + # Admin link + if is_admin: + admin_url = url_for( + "calendars.calendar.day.calendar_entries.calendar_entry.admin.admin", + calendar_slug=cal_slug, + day=day, month=month, year=year, + entry_id=entry.id, + ) + parts.append( + f'' + ' Admin' + ) + + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Entry page / OOB rendering +# --------------------------------------------------------------------------- + +async def render_entry_page(ctx: dict) -> str: + """Full page: entry detail.""" + content = _entry_main_panel_html(ctx) + hdr = root_header_html(ctx) + child = (_post_header_html(ctx) + _post_admin_header_html(ctx) + + _calendar_header_html(ctx) + _day_header_html(ctx) + + _entry_header_html(ctx)) + hdr += sexp('(div :id "root-header-child" :class "w-full" (raw! h))', h=child) + nav_html = _entry_nav_html(ctx) + return full_page(ctx, header_rows_html=hdr, content_html=content, menu_html=nav_html) + + +async def render_entry_oob(ctx: dict) -> str: + """OOB response: entry detail.""" + content = _entry_main_panel_html(ctx) + oobs = _day_header_html(ctx, oob=True) + oobs += _oob_header_html("day-header-child", "entry-header-child", + _entry_header_html(ctx)) + nav_html = _entry_nav_html(ctx) + return oob_page(ctx, oobs_html=oobs, content_html=content, menu_html=nav_html) + + +# --------------------------------------------------------------------------- +# Entry optioned (confirm/decline/provisional response) +# --------------------------------------------------------------------------- + +def render_entry_optioned(entry, calendar, day, month, year) -> str: + """Render entry options buttons + OOB title & state swaps.""" + options = _entry_options_html(entry, calendar, day, month, year) + title = _entry_title_html(entry) + state = _entry_state_badge_html(getattr(entry, "state", "pending") or "pending") + + return ( + options + + f'
{title}
' + + f'
{state}
' + ) + + +def _entry_title_html(entry) -> str: + """Render entry title (icon + name + state badge).""" + state = getattr(entry, "state", "pending") or "pending" + return ( + f' {escape(entry.name)} ' + + _entry_state_badge_html(state) + ) + + +def _entry_options_html(entry, calendar, day, month, year) -> str: + """Render confirm/decline/provisional buttons based on entry state.""" + from quart import url_for, g + from shared.browser.app.csrf import generate_csrf_token + csrf = generate_csrf_token() + + styles = getattr(g, "styles", None) or {} + action_btn = getattr(styles, "action_button", "") if hasattr(styles, "action_button") else styles.get("action_button", "") + + cal_slug = getattr(calendar, "slug", "") + eid = entry.id + state = getattr(entry, "state", "pending") or "pending" + target = f"#calendar_entry_options_{eid}" + + parts = [f'
'] + + def _make_button(action_name, label, confirm_title, confirm_text, *, trigger_type="submit"): + url = url_for( + f"calendars.calendar.day.calendar_entries.calendar_entry.{action_name}", + calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid, + ) + btn_type = "button" if trigger_type == "button" else "submit" + trigger_attr = ' hx-trigger="confirmed"' if trigger_type == "button" else "" + return ( + f'
' + f'' + f'
' + ) + + if state == "provisional": + parts.append(_make_button( + "confirm_entry", "confirm", + "Confirm entry?", "Are you sure you want to confirm this entry?", + )) + parts.append(_make_button( + "decline_entry", "decline", + "Decline entry?", "Are you sure you want to decline this entry?", + )) + elif state == "confirmed": + parts.append(_make_button( + "provisional_entry", "provisional", + "Provisional entry?", "Are you sure you want to provisional this entry?", + trigger_type="button", + )) + + parts.append("
") + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Entry tickets config (display + form) +# --------------------------------------------------------------------------- + +def render_entry_tickets_config(entry, calendar, day, month, year) -> str: + """Render ticket config display + edit form for admin entry view.""" + from quart import url_for + from shared.browser.app.csrf import generate_csrf_token + csrf = generate_csrf_token() + + cal_slug = getattr(calendar, "slug", "") + eid = entry.id + tp = getattr(entry, "ticket_price", None) + tc = getattr(entry, "ticket_count", None) + + parts = [] + + if tp is not None: + parts.append('
') + parts.append(f'
Price:') + parts.append(f'£{tp:.2f}
') + parts.append(f'
Available:') + tc_str = f"{tc} tickets" if tc is not None else "Unlimited" + parts.append(f'{tc_str}
') + parts.append( + f'
' + ) + else: + parts.append('
') + parts.append('No tickets configured') + parts.append( + f'
' + ) + + # Form + update_url = url_for( + "calendars.calendar.day.calendar_entries.calendar_entry.update_tickets", + entry_id=eid, calendar_slug=cal_slug, day=day, month=month, year=year, + ) + hidden_cls = "" if tp is None else "hidden" + tp_val = f"{tp:.2f}" if tp is not None else "" + tc_val = str(tc) if tc is not None else "" + + parts.append( + f'
' + f'' + f'
' + f'
' + f'
' + f'
' + '
' + '' + f'
' + ) + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Entry posts panel +# --------------------------------------------------------------------------- + +def render_entry_posts_panel(entry_posts, entry, calendar, day, month, year) -> str: + """Render associated posts list with remove buttons and search input.""" + from quart import url_for + from shared.browser.app.csrf import generate_csrf_token + csrf = generate_csrf_token() + + cal_slug = getattr(calendar, "slug", "") + eid = entry.id + + parts = ['
'] + if entry_posts: + parts.append('
') + for ep in entry_posts: + ep_title = escape(getattr(ep, "title", "")) + ep_id = getattr(ep, "id", 0) + feat = getattr(ep, "feature_image", None) + if feat: + img = f'{ep_title}' + else: + img = '
' + + del_url = url_for( + "calendars.calendar.day.calendar_entries.calendar_entry.remove_post", + calendar_slug=cal_slug, day=day, month=month, year=year, + entry_id=eid, post_id=ep_id, + ) + parts.append( + f'
' + f'{img}{ep_title}' + f'
' + ) + parts.append('
') + else: + parts.append('

No posts associated

') + + # Search to add + search_url = url_for( + "calendars.calendar.day.calendar_entries.calendar_entry.search_posts", + calendar_slug=cal_slug, day=day, month=month, year=year, entry_id=eid, + ) + parts.append( + '
' + '' + f'' + f'
' + ) + parts.append('
') + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Entry posts nav OOB +# --------------------------------------------------------------------------- + +def render_entry_posts_nav_oob(entry_posts) -> str: + """Render OOB nav for entry posts (scrolling menu).""" + from quart import g + styles = getattr(g, "styles", None) or {} + nav_btn = getattr(styles, "nav_button", "") if hasattr(styles, "nav_button") else styles.get("nav_button", "") + blog_url_fn = getattr(g, "blog_url", None) + + if not entry_posts: + return '
' + + parts = [ + '
' + '
' + ] + for ep in entry_posts: + slug = getattr(ep, "slug", "") + title = escape(getattr(ep, "title", "")) + feat = getattr(ep, "feature_image", None) + if blog_url_fn: + href = blog_url_fn(f"/{slug}/") + else: + href = f"/{slug}/" + + if feat: + img = f'{title}' + else: + img = '
' + + parts.append( + f'' + f'{img}
{title}
' + ) + parts.append('
') + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Day entries nav OOB +# --------------------------------------------------------------------------- + +def render_day_entries_nav_oob(confirmed_entries, calendar, day_date) -> str: + """Render OOB nav for confirmed entries in a day.""" + from quart import url_for, g + + styles = getattr(g, "styles", None) or {} + nav_btn = getattr(styles, "nav_button", "") if hasattr(styles, "nav_button") else styles.get("nav_button", "") + cal_slug = getattr(calendar, "slug", "") + + if not confirmed_entries: + return '
' + + parts = [ + '
' + '
' + ] + for entry in confirmed_entries: + href = url_for( + "calendars.calendar.day.calendar_entries.calendar_entry.get", + calendar_slug=cal_slug, + year=day_date.year, month=day_date.month, day=day_date.day, + entry_id=entry.id, + ) + name = escape(entry.name) + start = entry.start_at.strftime("%H:%M") if entry.start_at else "" + end = f" – {entry.end_at.strftime('%H:%M')}" if entry.end_at else "" + parts.append( + f'' + '
' + f'
{name}
' + f'
{start}{end}
' + '
' + ) + parts.append('
') + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Post nav entries OOB +# --------------------------------------------------------------------------- + +def render_post_nav_entries_oob(associated_entries, calendars, post) -> str: + """Render OOB nav for associated entries and calendars of a post.""" + from quart import g + from shared.utils import events_url + + styles = getattr(g, "styles", None) or {} + nav_btn = getattr(styles, "nav_button_less_pad", "") if hasattr(styles, "nav_button_less_pad") else styles.get("nav_button_less_pad", "") + + has_entries = associated_entries and getattr(associated_entries, "entries", None) + has_items = has_entries or calendars + + if not has_items: + return '
' + + slug = post.get("slug", "") if isinstance(post, dict) else getattr(post, "slug", "") + + parts = [ + '
' + '' + '
' + '
' + ] + + if has_entries: + for entry in associated_entries.entries: + entry_path = ( + f"/{slug}/calendars/{entry.calendar_slug}/" + f"{entry.start_at.year}/{entry.start_at.month}/{entry.start_at.day}/" + f"entries/{entry.id}/" + ) + href = events_url(entry_path) + name = escape(entry.name) + time_str = entry.start_at.strftime("%b %d, %Y at %H:%M") + end_str = f" – {entry.end_at.strftime('%H:%M')}" if entry.end_at else "" + parts.append( + f'' + '
' + '
' + f'
{name}
' + f'
{time_str}{end_str}
' + '
' + ) + + if calendars: + for cal in calendars: + cal_slug = getattr(cal, "slug", "") + cal_name = escape(getattr(cal, "name", "")) + local_href = events_url(f"/{slug}/calendars/{cal_slug}/") + parts.append( + f'' + f'' + f'
{cal_name}
' + ) + + parts.append('
') + parts.append( + '' + ) + parts.append( + '' + ) + parts.append('
') + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Calendar description display + edit form +# --------------------------------------------------------------------------- + +def render_calendar_description(calendar, *, oob: bool = False) -> str: + """Render calendar description display with edit button, optionally with OOB title.""" + from quart import url_for + + cal_slug = getattr(calendar, "slug", "") + edit_url = url_for("calendars.calendar.admin.calendar_description_edit", calendar_slug=cal_slug) + html = _calendar_description_display_html(calendar, edit_url) + + if oob: + desc = getattr(calendar, "description", "") or "" + html += ( + '
' + f'{escape(desc)}
' + ) + return html + + +def render_calendar_description_edit(calendar) -> str: + """Render calendar description edit form.""" + from quart import url_for + from shared.browser.app.csrf import generate_csrf_token + csrf = generate_csrf_token() + cal_slug = getattr(calendar, "slug", "") + desc = getattr(calendar, "description", "") or "" + + save_url = url_for("calendars.calendar.admin.calendar_description_save", calendar_slug=cal_slug) + cancel_url = url_for("calendars.calendar.admin.calendar_description_view", calendar_slug=cal_slug) + + return ( + '
' + f'
' + f'' + f'' + '
' + '' + f'' + '
' + ) + + +# --------------------------------------------------------------------------- +# Payments panel (public wrapper) +# --------------------------------------------------------------------------- + +def render_payments_panel(ctx: dict) -> str: + """Render the payments config panel for PUT response.""" + return _payments_main_panel_html(ctx) + + +# --------------------------------------------------------------------------- +# Calendars list panel (for POST create / DELETE) +# --------------------------------------------------------------------------- + +def render_calendars_list_panel(ctx: dict) -> str: + """Render the calendars main panel HTML for POST/DELETE response.""" + return _calendars_main_panel_html(ctx) + + +# --------------------------------------------------------------------------- +# Markets list panel (for POST create / DELETE) +# --------------------------------------------------------------------------- + +def render_markets_list_panel(ctx: dict) -> str: + """Render the markets main panel HTML for POST/DELETE response.""" + return _markets_main_panel_html(ctx) + + +# --------------------------------------------------------------------------- +# Slot main panel +# --------------------------------------------------------------------------- + +def render_slot_main_panel(slot, calendar, *, oob: bool = False) -> str: + """Render slot detail view.""" + from quart import url_for, g + + styles = getattr(g, "styles", None) or {} + list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "") + pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "") + cal_slug = getattr(calendar, "slug", "") + + days_display = getattr(slot, "days_display", "—") + days = days_display.split(", ") + flexible = getattr(slot, "flexible", False) + time_start = slot.time_start.strftime("%H:%M") if slot.time_start else "" + time_end = slot.time_end.strftime("%H:%M") if slot.time_end else "" + cost = getattr(slot, "cost", None) + cost_str = f"{cost:.2f}" if cost is not None else "" + desc = getattr(slot, "description", "") or "" + + edit_url = url_for("calendars.calendar.slots.slot.get_edit", slot_id=slot.id, calendar_slug=cal_slug) + + parts = [f'
'] + + # Days + parts.append( + '
' + '
Days
' + '
' + ) + if days and days[0] != "—": + parts.append('
') + for d in days: + parts.append(f'{escape(d)}') + parts.append('
') + else: + parts.append('No days') + parts.append('
') + + # Flexible + parts.append( + '
' + '
Flexible
' + f'
{"yes" if flexible else "no"}
' + ) + + # Time & Cost + parts.append( + '
' + '
' + '
Time
' + f'
{time_start} — {time_end}
' + '
' + '
Cost
' + f'
{cost_str}
' + ) + + # Edit button + parts.append( + f'' + ) + parts.append('
') + + if oob: + parts.append( + f'
' + f'{escape(desc)}
' + ) + + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Slots table +# --------------------------------------------------------------------------- + +def render_slots_table(slots, calendar) -> str: + """Render slots table with rows and add button.""" + from quart import url_for, g + from shared.browser.app.csrf import generate_csrf_token + csrf = generate_csrf_token() + + styles = getattr(g, "styles", None) or {} + list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "") + tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "") + pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "") + action_btn = getattr(styles, "action_button", "") if hasattr(styles, "action_button") else styles.get("action_button", "") + pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "") + hx_select = getattr(g, "hx_select_search", "#main-panel") + cal_slug = getattr(calendar, "slug", "") + + parts = [f'
'] + parts.append( + '' + '' + '' + '' + '' + '' + '' + '' + ) + + if slots: + for s in slots: + slot_href = url_for("calendars.calendar.slots.slot.get", calendar_slug=cal_slug, slot_id=s.id) + del_url = url_for("calendars.calendar.slots.slot.slot_delete", calendar_slug=cal_slug, slot_id=s.id) + desc = getattr(s, "description", "") or "" + desc_html = f'

{escape(desc)}

' if desc else '

' + + days_display = getattr(s, "days_display", "—") + day_list = days_display.split(", ") + if day_list and day_list[0] != "—": + days_html = '
' + "".join( + f'{escape(d)}' for d in day_list + ) + '
' + else: + days_html = 'No days' + + time_start = s.time_start.strftime("%H:%M") if s.time_start else "" + time_end = s.time_end.strftime("%H:%M") if s.time_end else "" + cost = getattr(s, "cost", None) + cost_str = f"{cost:.2f}" if cost is not None else "" + + parts.append( + f'' + f'' + f'' + f'' + f'' + f'' + f'' + ) + else: + parts.append('') + + parts.append('
NameFlexibleDaysTimeCostActions
{desc_html}{"yes" if s.flexible else "no"}{days_html}{time_start} - {time_end}{cost_str}' + f'
No slots yet.
') + + # Add button + add_url = url_for("calendars.calendar.slots.add_form", calendar_slug=cal_slug) + parts.append( + f'
' + f'
' + ) + parts.append('
') + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Ticket type main panel +# --------------------------------------------------------------------------- + +def render_ticket_type_main_panel(ticket_type, entry, calendar, day, month, year, *, oob: bool = False) -> str: + """Render ticket type detail view.""" + from quart import url_for, g + + styles = getattr(g, "styles", None) or {} + list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "") + pre_action = getattr(styles, "pre_action_button", "") if hasattr(styles, "pre_action_button") else styles.get("pre_action_button", "") + cal_slug = getattr(calendar, "slug", "") + + name = escape(getattr(ticket_type, "name", "")) + cost = getattr(ticket_type, "cost", None) + cost_str = f"£{cost:.2f}" if cost is not None else "£0.00" + count = getattr(ticket_type, "count", 0) + + edit_url = url_for( + "calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get_edit", + ticket_type_id=ticket_type.id, calendar_slug=cal_slug, + year=year, month=month, day=day, entry_id=entry.id, + ) + + return ( + f'
' + '
' + '
' + '
Name
' + f'
{name}
' + '
' + '
Cost
' + f'
{cost_str}
' + '
' + '
Count
' + f'
{count}
' + f'' + '
' + ) + + +# --------------------------------------------------------------------------- +# Ticket types table +# --------------------------------------------------------------------------- + +def render_ticket_types_table(ticket_types, entry, calendar, day, month, year) -> str: + """Render ticket types table with rows and add button.""" + from quart import url_for, g + from shared.browser.app.csrf import generate_csrf_token + csrf = generate_csrf_token() + + styles = getattr(g, "styles", None) or {} + list_container = getattr(styles, "list_container", "") if hasattr(styles, "list_container") else styles.get("list_container", "") + tr_cls = getattr(styles, "tr", "") if hasattr(styles, "tr") else styles.get("tr", "") + pill_cls = getattr(styles, "pill", "") if hasattr(styles, "pill") else styles.get("pill", "") + action_btn = getattr(styles, "action_button", "") if hasattr(styles, "action_button") else styles.get("action_button", "") + hx_select = getattr(g, "hx_select_search", "#main-panel") + cal_slug = getattr(calendar, "slug", "") + eid = entry.id + + parts = [f'
'] + parts.append( + '' + '' + '' + '' + '' + '' + ) + + if ticket_types: + for tt in ticket_types: + tt_href = url_for( + "calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.get", + calendar_slug=cal_slug, year=year, month=month, day=day, + entry_id=eid, ticket_type_id=tt.id, + ) + del_url = url_for( + "calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.ticket_type.delete", + calendar_slug=cal_slug, year=year, month=month, day=day, + entry_id=eid, ticket_type_id=tt.id, + ) + cost = getattr(tt, "cost", None) + cost_str = f"£{cost:.2f}" if cost is not None else "£0.00" + + parts.append( + f'' + f'' + f'' + f'' + f'' + ) + else: + parts.append('') + + parts.append('
NameCostCountActions
{cost_str}{tt.count}' + f'
No ticket types yet.
') + + # Add button + add_url = url_for( + "calendars.calendar.day.calendar_entries.calendar_entry.ticket_types.add_form", + calendar_slug=cal_slug, entry_id=eid, year=year, month=month, day=day, + ) + parts.append( + f'
' + f'
' + ) + parts.append('
') + return "".join(parts) + + +# --------------------------------------------------------------------------- +# Buy result (ticket purchase confirmation) +# --------------------------------------------------------------------------- + +def render_buy_result(entry, created_tickets, remaining, cart_count) -> str: + """Render buy result card with created tickets + OOB cart icon.""" + from quart import url_for + + # OOB cart icon + html = _cart_icon_oob(cart_count) + + count = len(created_tickets) + suffix = "s" if count != 1 else "" + parts = [f'
'] + parts.append( + '
' + '' + f'{count} ticket{suffix} reserved
' + ) + + parts.append('
') + for ticket in created_tickets: + href = url_for("tickets.ticket_detail", code=ticket.code) + parts.append( + f'' + '
' + '' + f'{ticket.code[:12]}...
' + 'View ticket
' + ) + parts.append('
') + + if remaining is not None: + r_suffix = "s" if remaining != 1 else "" + parts.append(f'

{remaining} ticket{r_suffix} remaining

') + + my_href = url_for("tickets.my_tickets") + parts.append( + '' + ) + parts.append('
') + return html + "".join(parts) + + +# --------------------------------------------------------------------------- +# Buy form (ticket +/- controls) +# --------------------------------------------------------------------------- + +def render_buy_form(entry, ticket_remaining, ticket_sold_count, + user_ticket_count, user_ticket_counts_by_type) -> str: + """Render the ticket buy/adjust form with +/- controls.""" + from quart import url_for + from shared.browser.app.csrf import generate_csrf_token + csrf = generate_csrf_token() + + eid = entry.id + tp = getattr(entry, "ticket_price", None) + state = getattr(entry, "state", "") + ticket_types = getattr(entry, "ticket_types", None) or [] + + if tp is None: + return "" + + if tp is not None and state != "confirmed": + return ( + f'
' + '' + 'Tickets available once this event is confirmed.
' + ) + + adjust_url = url_for("tickets.adjust_quantity") + target = f"#ticket-buy-{eid}" + + parts = [f'
'] + parts.append( + '

' + 'Tickets

' + ) + + # Info line + info_parts = [] + if ticket_sold_count: + info_parts.append(f'{ticket_sold_count} sold') + if ticket_remaining is not None: + info_parts.append(f'{ticket_remaining} remaining') + if user_ticket_count: + info_parts.append( + '' + f' {user_ticket_count} in basket' + ) + if info_parts: + parts.append(f'
{"".join(info_parts)}
') + + active_types = [tt for tt in ticket_types if getattr(tt, "deleted_at", None) is None] + + if active_types: + # Multiple ticket types + parts.append('
') + for tt in active_types: + type_count = user_ticket_counts_by_type.get(tt.id, 0) if user_ticket_counts_by_type else 0 + cost_str = f"£{tt.cost:.2f}" if tt.cost is not None else "£0.00" + + parts.append( + '
' + f'
{escape(tt.name)}
' + f'
{cost_str}
' + ) + parts.append(_ticket_adjust_controls(csrf, adjust_url, target, eid, type_count, ticket_type_id=tt.id)) + parts.append('
') + parts.append('
') + else: + # Simple ticket + parts.append( + '
' + f'£{tp:.2f}' + 'per ticket
' + ) + qty = user_ticket_count or 0 + parts.append(_ticket_adjust_controls(csrf, adjust_url, target, eid, qty)) + + parts.append('
') + return "".join(parts) + + +def _ticket_adjust_controls(csrf, adjust_url, target, entry_id, count, *, ticket_type_id=None): + """Render +/- ticket controls for buy form.""" + from quart import url_for + + tt_hidden = f'' if ticket_type_id else "" + my_tickets_href = url_for("tickets.my_tickets") + + if count == 0: + return ( + f'
' + f'' + f'' + f'{tt_hidden}' + '' + '
' + ) + + return ( + '
' + f'
' + f'' + f'' + f'{tt_hidden}' + f'' + '
' + f'' + '' + '' + '' + f'{count}' + '' + f'
' + f'' + f'' + f'{tt_hidden}' + f'' + '
' + '
' + ) + + +# --------------------------------------------------------------------------- +# Adjust response (OOB cart icon + buy form) +# --------------------------------------------------------------------------- + +def render_adjust_response(entry, ticket_remaining, ticket_sold_count, + user_ticket_count, user_ticket_counts_by_type, + cart_count) -> str: + """Render ticket adjust response: OOB cart icon + buy form.""" + cart_html = _cart_icon_oob(cart_count) + form_html = render_buy_form( + entry, ticket_remaining, ticket_sold_count, + user_ticket_count, user_ticket_counts_by_type, + ) + return cart_html + form_html + + +def _cart_icon_oob(count: int) -> str: + """Render the OOB cart icon/badge swap.""" + from quart import g + + blog_url_fn = getattr(g, "blog_url", None) + cart_url_fn = getattr(g, "cart_url", None) + site_fn = getattr(g, "site", None) + logo = "" + if site_fn: + site_obj = site_fn() if callable(site_fn) else site_fn + logo = getattr(site_obj, "logo", "") if site_obj else "" + + if count == 0: + blog_href = blog_url_fn("/") if blog_url_fn else "/" + return ( + '
' + '
' + f'' + f'' + '
' + ) + + cart_href = cart_url_fn("/") if cart_url_fn else "/" + return ( + '' + ) diff --git a/federation/app.py b/federation/app.py index 55f6519..6eadefa 100644 --- a/federation/app.py +++ b/federation/app.py @@ -1,6 +1,6 @@ from __future__ import annotations import path_setup # noqa: F401 # adds shared/ to sys.path -import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file +import sexp.sexp_components as sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file from pathlib import Path from quart import g, request @@ -96,7 +96,7 @@ def create_app() -> "Quart": async def home(): from quart import make_response from shared.sexp.page import get_template_context - from sexp_components import render_federation_home + from sexp.sexp_components import render_federation_home ctx = await get_template_context() html = await render_federation_home(ctx) diff --git a/federation/bp/auth/routes.py b/federation/bp/auth/routes.py index 4aa3a8e..21cc67b 100644 --- a/federation/bp/auth/routes.py +++ b/federation/bp/auth/routes.py @@ -11,7 +11,6 @@ from datetime import datetime, timezone, timedelta from quart import ( Blueprint, request, - render_template, redirect, url_for, session as qsession, @@ -101,7 +100,7 @@ def register(url_prefix="/auth"): redirect_url = pop_login_redirect_target() return redirect(redirect_url) from shared.sexp.page import get_template_context - from sexp_components import render_login_page + from sexp.sexp_components import render_login_page ctx = await get_template_context() return await render_login_page(ctx) @@ -112,14 +111,10 @@ def register(url_prefix="/auth"): is_valid, email = validate_email(email_input) if not is_valid: - return ( - await render_template( - "auth/login.html", - error="Please enter a valid email address.", - email=email_input, - ), - 400, - ) + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_login_page + ctx = await get_template_context(error="Please enter a valid email address.", email=email_input) + return await render_login_page(ctx), 400 user = await find_or_create_user(g.s, email) token, expires = await create_magic_link(g.s, user.id) @@ -137,11 +132,10 @@ def register(url_prefix="/auth"): "Please try again in a moment." ) - return await render_template( - "auth/check_email.html", - email=email, - email_error=email_error, - ) + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_check_email_page + ctx = await get_template_context(email=email, email_error=email_error) + return await render_check_email_page(ctx) @auth_bp.get("/magic//") async def magic(token: str): @@ -154,20 +148,17 @@ def register(url_prefix="/auth"): user, error = await validate_magic_link(s, token) if error: - return ( - await render_template("auth/login.html", error=error), - 400, - ) + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_login_page + ctx = await get_template_context(error=error) + return await render_login_page(ctx), 400 user_id = user.id except Exception: - return ( - await render_template( - "auth/login.html", - error="Could not sign you in right now. Please try again.", - ), - 502, - ) + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_login_page + ctx = await get_template_context(error="Could not sign you in right now. Please try again.") + return await render_login_page(ctx), 502 assert user_id is not None diff --git a/federation/bp/identity/routes.py b/federation/bp/identity/routes.py index b18461c..56b3550 100644 --- a/federation/bp/identity/routes.py +++ b/federation/bp/identity/routes.py @@ -8,7 +8,7 @@ from __future__ import annotations import re from quart import ( - Blueprint, request, render_template, redirect, url_for, g, abort, + Blueprint, request, redirect, url_for, g, abort, ) from shared.services.registry import services @@ -40,7 +40,7 @@ def register(url_prefix="/identity"): return redirect(url_for("activitypub.actor_profile", username=actor.preferred_username)) from shared.sexp.page import get_template_context - from sexp_components import render_choose_username_page + from sexp.sexp_components import render_choose_username_page ctx = await get_template_context() ctx["actor"] = actor return await render_choose_username_page(ctx) @@ -71,11 +71,11 @@ def register(url_prefix="/identity"): error = "This username is already taken." if error: - return await render_template( - "federation/choose_username.html", - error=error, - username=username, - ), 400 + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_choose_username_page + ctx = await get_template_context(error=error, username=username) + ctx["actor"] = None + return await render_choose_username_page(ctx), 400 # Create ActorProfile with RSA keys display_name = g.user.name or username diff --git a/federation/bp/social/routes.py b/federation/bp/social/routes.py index 9ba7d37..06cb2bf 100644 --- a/federation/bp/social/routes.py +++ b/federation/bp/social/routes.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from datetime import datetime -from quart import Blueprint, request, g, redirect, url_for, abort, render_template, Response +from quart import Blueprint, request, g, redirect, url_for, abort, Response from shared.services.registry import services @@ -40,7 +40,7 @@ def register(url_prefix="/social"): actor = _require_actor() items = await services.federation.get_home_timeline(g.s, actor.id) from shared.sexp.page import get_template_context - from sexp_components import render_timeline_page + from sexp.sexp_components import render_timeline_page ctx = await get_template_context() return await render_timeline_page(ctx, items, "home", actor) @@ -57,7 +57,7 @@ def register(url_prefix="/social"): items = await services.federation.get_home_timeline( g.s, actor.id, before=before, ) - from sexp_components import render_timeline_items + from sexp.sexp_components import render_timeline_items return await render_timeline_items(items, "home", actor) @bp.get("/public") @@ -65,7 +65,7 @@ def register(url_prefix="/social"): items = await services.federation.get_public_timeline(g.s) actor = getattr(g, "_social_actor", None) from shared.sexp.page import get_template_context - from sexp_components import render_timeline_page + from sexp.sexp_components import render_timeline_page ctx = await get_template_context() return await render_timeline_page(ctx, items, "public", actor) @@ -80,7 +80,7 @@ def register(url_prefix="/social"): pass items = await services.federation.get_public_timeline(g.s, before=before) actor = getattr(g, "_social_actor", None) - from sexp_components import render_timeline_items + from sexp.sexp_components import render_timeline_items return await render_timeline_items(items, "public", actor) # -- Compose -------------------------------------------------------------- @@ -90,7 +90,7 @@ def register(url_prefix="/social"): actor = _require_actor() reply_to = request.args.get("reply_to") from shared.sexp.page import get_template_context - from sexp_components import render_compose_page + from sexp.sexp_components import render_compose_page ctx = await get_template_context() return await render_compose_page(ctx, actor, reply_to) @@ -136,7 +136,7 @@ def register(url_prefix="/social"): ) followed_urls = {a.actor_url for a in following} from shared.sexp.page import get_template_context - from sexp_components import render_search_page + from sexp.sexp_components import render_search_page ctx = await get_template_context() return await render_search_page(ctx, query, actors, total, 1, followed_urls, actor) @@ -157,7 +157,7 @@ def register(url_prefix="/social"): g.s, actor.preferred_username, page=1, per_page=1000, ) followed_urls = {a.actor_url for a in following} - from sexp_components import render_search_results + from sexp.sexp_components import render_search_results return await render_search_results(actors, query, page, followed_urls, actor) @bp.post("/follow") @@ -200,15 +200,8 @@ def register(url_prefix="/social"): list_type = "followers" else: list_type = "following" - return await render_template( - "federation/_actor_list_items.html", - actors=[remote_dto], - total=0, - page=1, - list_type=list_type, - followed_urls=followed_urls, - actor=actor, - ) + from sexp.sexp_components import render_actor_card + return render_actor_card(remote_dto, actor, followed_urls, list_type=list_type) # -- Interactions --------------------------------------------------------- @@ -296,10 +289,10 @@ def register(url_prefix="/social"): ).limit(1) )).scalar()) - return await render_template( - "federation/_interaction_buttons.html", - item_object_id=object_id, - item_author_inbox=author_inbox, + from sexp.sexp_components import render_interaction_buttons + return render_interaction_buttons( + object_id=object_id, + author_inbox=author_inbox, like_count=like_count, boost_count=boost_count, liked_by_me=liked_by_me, @@ -316,7 +309,7 @@ def register(url_prefix="/social"): g.s, actor.preferred_username, ) from shared.sexp.page import get_template_context - from sexp_components import render_following_page + from sexp.sexp_components import render_following_page ctx = await get_template_context() return await render_following_page(ctx, actors, total, actor) @@ -327,7 +320,7 @@ def register(url_prefix="/social"): actors, total = await services.federation.get_following( g.s, actor.preferred_username, page=page, ) - from sexp_components import render_following_items + from sexp.sexp_components import render_following_items return await render_following_items(actors, page, actor) @bp.get("/followers") @@ -342,7 +335,7 @@ def register(url_prefix="/social"): ) followed_urls = {a.actor_url for a in following} from shared.sexp.page import get_template_context - from sexp_components import render_followers_page + from sexp.sexp_components import render_followers_page ctx = await get_template_context() return await render_followers_page(ctx, actors, total, followed_urls, actor) @@ -357,7 +350,7 @@ def register(url_prefix="/social"): g.s, actor.preferred_username, page=1, per_page=1000, ) followed_urls = {a.actor_url for a in following} - from sexp_components import render_followers_items + from sexp.sexp_components import render_followers_items return await render_followers_items(actors, page, followed_urls, actor) @bp.get("/actor/") @@ -390,7 +383,7 @@ def register(url_prefix="/social"): ).scalar_one_or_none() is_following = existing is not None from shared.sexp.page import get_template_context - from sexp_components import render_actor_timeline_page + from sexp.sexp_components import render_actor_timeline_page ctx = await get_template_context() return await render_actor_timeline_page(ctx, remote_dto, items, is_following, actor) @@ -407,7 +400,7 @@ def register(url_prefix="/social"): items = await services.federation.get_actor_timeline( g.s, id, before=before, ) - from sexp_components import render_actor_timeline_items + from sexp.sexp_components import render_actor_timeline_items return await render_actor_timeline_items(items, id, actor) # -- Notifications -------------------------------------------------------- @@ -418,7 +411,7 @@ def register(url_prefix="/social"): items = await services.federation.get_notifications(g.s, actor.id) await services.federation.mark_notifications_read(g.s, actor.id) from shared.sexp.page import get_template_context - from sexp_components import render_notifications_page + from sexp.sexp_components import render_notifications_page ctx = await get_template_context() return await render_notifications_page(ctx, items, actor) diff --git a/federation/sexp/__init__.py b/federation/sexp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/federation/sexp_components.py b/federation/sexp/sexp_components.py similarity index 94% rename from federation/sexp_components.py rename to federation/sexp/sexp_components.py index f27b0e4..8057ca6 100644 --- a/federation/sexp_components.py +++ b/federation/sexp/sexp_components.py @@ -398,6 +398,30 @@ async def render_login_page(ctx: dict) -> str: meta_html="Login \u2014 Rose Ash") +async def render_check_email_page(ctx: dict) -> str: + """Full page: check email after magic link sent.""" + email = ctx.get("email", "") + email_error = ctx.get("email_error") + + error_html = "" + if email_error: + error_html = ( + f'
' + f'{escape(email_error)}
' + ) + content = ( + '
' + '

Check your email

' + f'

We sent a sign-in link to {escape(email)}.

' + '

Click the link in the email to sign in. The link expires in 15 minutes.

' + f'{error_html}
' + ) + + hdr = root_header_html(ctx) + return full_page(ctx, header_rows_html=hdr, content_html=content, + meta_html='Check your email \u2014 Rose Ash') + + # --------------------------------------------------------------------------- # Public API: Timeline # --------------------------------------------------------------------------- @@ -708,3 +732,30 @@ async def render_profile_page(ctx: dict, actor: Any, activities: list, return _social_page(ctx, actor, content_html=content, title=f"@{actor.preferred_username} \u2014 Rose Ash") + + +# --------------------------------------------------------------------------- +# Public API: POST handler fragment renderers +# --------------------------------------------------------------------------- + +def render_interaction_buttons(object_id: str, author_inbox: str, + like_count: int, boost_count: int, + liked_by_me: bool, boosted_by_me: bool, + actor: Any) -> str: + """Render interaction buttons fragment for HTMX POST response.""" + from types import SimpleNamespace + item = SimpleNamespace( + object_id=object_id, + author_inbox=author_inbox, + like_count=like_count, + boost_count=boost_count, + liked_by_me=liked_by_me, + boosted_by_me=boosted_by_me, + ) + return _interaction_buttons_html(item, actor) + + +def render_actor_card(actor_dto: Any, actor: Any, followed_urls: set, + *, list_type: str = "following") -> str: + """Render a single actor card fragment for HTMX POST response.""" + return _actor_card_html(actor_dto, actor, followed_urls, list_type=list_type) diff --git a/market/app.py b/market/app.py index a83104e..3e196c2 100644 --- a/market/app.py +++ b/market/app.py @@ -1,6 +1,6 @@ from __future__ import annotations import path_setup # noqa: F401 # adds shared/ to sys.path -import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file +import sexp.sexp_components as sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file from pathlib import Path diff --git a/market/bp/all_markets/routes.py b/market/bp/all_markets/routes.py index d79f0bf..c6ecb3f 100644 --- a/market/bp/all_markets/routes.py +++ b/market/bp/all_markets/routes.py @@ -56,7 +56,7 @@ def register() -> Blueprint: ) from shared.sexp.page import get_template_context - from sexp_components import render_all_markets_page, render_all_markets_oob + from sexp.sexp_components import render_all_markets_page, render_all_markets_oob tctx = await get_template_context() if is_htmx_request(): @@ -71,7 +71,7 @@ def register() -> Blueprint: page = int(request.args.get("page", 1)) markets, has_more, page_info = await _load_markets(page) - from sexp_components import render_all_markets_cards + from sexp.sexp_components import render_all_markets_cards html = await render_all_markets_cards(markets, has_more, page_info, page) return await make_response(html, 200) diff --git a/market/bp/browse/routes.py b/market/bp/browse/routes.py index b0e5bf4..3432071 100644 --- a/market/bp/browse/routes.py +++ b/market/bp/browse/routes.py @@ -43,7 +43,7 @@ def register(): # 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 + from sexp.sexp_components import render_market_home_page, render_market_home_oob ctx = await get_template_context() ctx.update(p_data) @@ -74,7 +74,7 @@ def register(): full_context = {**product_info, **ctx} from shared.sexp.page import get_template_context - from sexp_components import render_browse_page, render_browse_oob, render_browse_cards + from sexp.sexp_components import render_browse_page, render_browse_oob, render_browse_cards tctx = await get_template_context() tctx.update(full_context) @@ -113,7 +113,7 @@ def register(): full_context = {**product_info, **ctx} from shared.sexp.page import get_template_context - from sexp_components import render_browse_page, render_browse_oob, render_browse_cards + from sexp.sexp_components import render_browse_page, render_browse_oob, render_browse_cards tctx = await get_template_context() tctx.update(full_context) @@ -152,7 +152,7 @@ def register(): full_context = {**product_info, **ctx} from shared.sexp.page import get_template_context - from sexp_components import render_browse_page, render_browse_oob, render_browse_cards + from sexp.sexp_components import render_browse_page, render_browse_oob, render_browse_cards tctx = await get_template_context() tctx.update(full_context) diff --git a/market/bp/market/admin/routes.py b/market/bp/market/admin/routes.py index 598aa64..b48eeb5 100644 --- a/market/bp/market/admin/routes.py +++ b/market/bp/market/admin/routes.py @@ -18,7 +18,7 @@ def register(): from shared.browser.app.utils.htmx import is_htmx_request from shared.sexp.page import get_template_context - from sexp_components import render_market_admin_page, render_market_admin_oob + from sexp.sexp_components import render_market_admin_page, render_market_admin_oob tctx = await get_template_context() if not is_htmx_request(): diff --git a/market/bp/page_markets/routes.py b/market/bp/page_markets/routes.py index e7c92d6..2356d53 100644 --- a/market/bp/page_markets/routes.py +++ b/market/bp/page_markets/routes.py @@ -40,7 +40,7 @@ def register() -> Blueprint: ) from shared.sexp.page import get_template_context - from sexp_components import render_page_markets_page, render_page_markets_oob + from sexp.sexp_components import render_page_markets_page, render_page_markets_oob tctx = await get_template_context() tctx["post"] = post @@ -58,7 +58,7 @@ def register() -> Blueprint: markets, has_more = await _load_markets(post["id"], page) - from sexp_components import render_page_markets_cards + from sexp.sexp_components import render_page_markets_cards post_slug = post.get("slug", "") html = await render_page_markets_cards(markets, has_more, page, post_slug) return await make_response(html, 200) diff --git a/market/bp/product/routes.py b/market/bp/product/routes.py index c4fb591..712d55d 100644 --- a/market/bp/product/routes.py +++ b/market/bp/product/routes.py @@ -5,7 +5,6 @@ from quart import ( Blueprint, abort, redirect, - render_template, make_response, ) from sqlalchemy import select, func, update @@ -108,7 +107,7 @@ def register(): from shared.browser.app.utils.htmx import is_htmx_request from shared.sexp.page import get_template_context - from sexp_components import render_product_page, render_product_oob + from sexp.sexp_components import render_product_page, render_product_oob tctx = await get_template_context() item_data = getattr(g, "item_data", {}) @@ -126,12 +125,10 @@ def register(): async def like_toggle(): product_slug = g.product_slug + from sexp.sexp_components import render_like_toggle_button + if not g.user: - html = await render_template( - "_types/browse/like/button.html", - slug=product_slug, - liked=False, - ) + html = render_like_toggle_button(product_slug, False) resp = make_response(html, 403) return resp @@ -142,12 +139,7 @@ def register(): }) liked = result["liked"] - html = await render_template( - "_types/browse/like/button.html", - slug=product_slug, - liked=liked, - ) - return html + return render_like_toggle_button(product_slug, liked) @@ -156,7 +148,7 @@ def register(): 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 + from sexp.sexp_components import render_product_admin_page, render_product_admin_oob tctx = await get_template_context() item_data = getattr(g, "item_data", {}) @@ -263,11 +255,10 @@ def register(): # htmx response: OOB-swap mini cart + product buttons if request.headers.get("HX-Request") == "true": - return await render_template( - "_types/product/_added.html", - cart=g.cart, - item=ci_ns, - ) + from sexp.sexp_components import render_cart_added_response + item_data = getattr(g, "item_data", {}) + d = item_data.get("d", {}) + return render_cart_added_response(g.cart, ci_ns, d) # normal POST: go to cart page from shared.infrastructure.urls import cart_url diff --git a/market/sexp/__init__.py b/market/sexp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/market/sexp_components.py b/market/sexp/sexp_components.py similarity index 95% rename from market/sexp_components.py rename to market/sexp/sexp_components.py index f0f90e7..a60eb8c 100644 --- a/market/sexp_components.py +++ b/market/sexp/sexp_components.py @@ -1579,3 +1579,88 @@ def _market_admin_header_html(ctx: dict, *, oob: bool = False) -> str: lh=link_href, oob=oob, ) + + +# --------------------------------------------------------------------------- +# Public API: POST handler fragment renderers +# --------------------------------------------------------------------------- + +def render_like_toggle_button(slug: str, liked: bool, *, + like_url: str | None = None, + item_type: str = "product") -> str: + """Render a standalone like toggle button for HTMX POST response. + + Used by both market and blog like_toggle handlers. + """ + from shared.browser.app.csrf import generate_csrf_token + from quart import url_for + from shared.utils import host_url + + csrf = generate_csrf_token() + if not like_url: + like_url = host_url(url_for("market.browse.product.like_toggle", product_slug=slug)) + + if liked: + colour = "text-red-600" + icon = "fa-solid fa-heart" + label = f"Unlike this {item_type}" + else: + colour = "text-stone-300" + icon = "fa-regular fa-heart" + label = f"Like this {item_type}" + + return ( + f'' + ) + + +def render_cart_added_response(cart: list, item: Any, d: dict) -> str: + """Render the HTMX response after add-to-cart. + + Returns OOB fragments: cart-mini icon + product add/remove buttons + cart item row. + """ + from shared.browser.app.csrf import generate_csrf_token + from quart import url_for, g + from shared.infrastructure.urls import cart_url as _cart_url + + csrf = generate_csrf_token() + slug = d.get("slug", "") + count = sum(getattr(ci, "quantity", 0) for ci in cart) + + # 1. Cart mini icon OOB + if count > 0: + cart_href = _cart_url("/") + cart_mini = ( + f'' + ) + else: + from shared.config import config + blog_href = config().get("blog_url", "/") + logo = config().get("logo", "") + cart_mini = ( + f'' + ) + + # 2. Add/remove buttons OOB + action = url_for("market.browse.product.cart", product_slug=slug) + quantity = getattr(item, "quantity", 0) if item else 0 + add_html = ( + f'
' + + _cart_add_html(slug, quantity, action, csrf, cart_url_fn=_cart_url) + + '
' + ) + + return cart_mini + add_html diff --git a/orders/app.py b/orders/app.py index b68da41..827d78c 100644 --- a/orders/app.py +++ b/orders/app.py @@ -1,6 +1,6 @@ from __future__ import annotations import path_setup # noqa: F401 # adds shared/ to sys.path -import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file +import sexp.sexp_components as sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file from pathlib import Path from types import SimpleNamespace @@ -71,7 +71,7 @@ def create_app() -> "Quart": ]) # Load orders-specific s-expression components - from sexp_components import load_orders_components + from sexp.sexp_components import load_orders_components load_orders_components() app.register_blueprint(register_fragments()) diff --git a/orders/bp/order/routes.py b/orders/bp/order/routes.py index 56e2a9a..6b4cfa8 100644 --- a/orders/bp/order/routes.py +++ b/orders/bp/order/routes.py @@ -1,6 +1,6 @@ from __future__ import annotations -from quart import Blueprint, g, render_template, redirect, url_for, make_response +from quart import Blueprint, g, redirect, url_for, make_response from sqlalchemy import select from sqlalchemy.orm import selectinload @@ -48,7 +48,7 @@ def register() -> Blueprint: if not order: return await make_response("Order not found", 404) - from sexp_components import render_order_page, render_order_oob + from sexp.sexp_components import render_order_page, render_order_oob ctx = await get_template_context() calendar_entries = ctx.get("calendar_entries") @@ -98,11 +98,10 @@ def register() -> Blueprint: await g.s.flush() if not hosted_url: - html = await render_template( - "_types/cart/checkout_error.html", - order=order, - error="No hosted checkout URL returned from SumUp when trying to reopen payment.", - ) + from shared.sexp.page import get_template_context + from sexp.sexp_components import render_checkout_error_page + tctx = await get_template_context() + html = await render_checkout_error_page(tctx, error="No hosted checkout URL returned from SumUp when trying to reopen payment.", order=order) return await make_response(html, 500) return redirect(hosted_url) diff --git a/orders/bp/orders/routes.py b/orders/bp/orders/routes.py index 7841975..e05a986 100644 --- a/orders/bp/orders/routes.py +++ b/orders/bp/orders/routes.py @@ -117,7 +117,7 @@ def register(url_prefix: str) -> Blueprint: orders = result.scalars().all() from shared.sexp.page import get_template_context - from sexp_components import ( + from sexp.sexp_components import ( render_orders_page, render_orders_rows, render_orders_oob, diff --git a/orders/sexp/__init__.py b/orders/sexp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orders/sexp_components.py b/orders/sexp/sexp_components.py similarity index 89% rename from orders/sexp_components.py rename to orders/sexp/sexp_components.py index 212bd34..fc66526 100644 --- a/orders/sexp_components.py +++ b/orders/sexp/sexp_components.py @@ -15,7 +15,7 @@ from shared.sexp.helpers import ( 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 +from shared.infrastructure.urls import market_product_url, cart_url # --------------------------------------------------------------------------- @@ -383,3 +383,56 @@ async def render_order_oob(ctx: dict, order: Any, ) return oob_page(ctx, oobs_html=oobs, filter_html=filt, content_html=main) + + +# --------------------------------------------------------------------------- +# Public API: Checkout error +# --------------------------------------------------------------------------- + +def _checkout_error_filter_html() -> str: + return ( + '
' + '

' + 'Checkout error

' + '

' + 'We tried to start your payment with SumUp but hit a problem.

' + '
' + ) + + +def _checkout_error_content_html(error: str | None, order: Any | None) -> str: + err_msg = error or "Unexpected error while creating the hosted checkout session." + order_html = "" + if order: + order_html = ( + f'

' + f'Order ID: #{order.id}

' + ) + back_url = cart_url("/") + return ( + '
' + '
' + f'

Something went wrong.

' + f'

{err_msg}

' + f'{order_html}' + '
' + '' + '
' + ) + + +async def render_checkout_error_page(ctx: dict, error: str | None = None, order: Any | None = None) -> str: + """Full page: checkout error.""" + hdr = root_header_html(ctx) + hdr += sexp( + '(div :id "root-header-child" :class "flex flex-col w-full items-center" (raw! c))', + c=_auth_header_html(ctx), + ) + filt = _checkout_error_filter_html() + content = _checkout_error_content_html(error, order) + return full_page(ctx, header_rows_html=hdr, filter_html=filt, content_html=content) diff --git a/shared/sexp/page.py b/shared/sexp/page.py index a0c05e5..fafddfa 100644 --- a/shared/sexp/page.py +++ b/shared/sexp/page.py @@ -83,9 +83,9 @@ async def get_template_context(**kwargs: Any) -> dict[str, Any]: ctx.update(rv) # Inject Jinja globals that s-expression components need (URL helpers, - # asset_url, site, etc.) — these aren't provided by context processors. + # asset_url, styles, 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): + if key not in ctx: ctx[key] = val ctx.update(kwargs)