Phase 7: Replace render_template() with s-expression rendering in all POST/PUT/DELETE routes

Eliminates all render_template() calls from POST/PUT/DELETE handlers across
all 7 services. Moves sexp_components.py into sexp/ packages per service.

- Blog: like toggle, snippets, cache clear, features/sumup/entry panels,
  create/delete market, WYSIWYG editor panel (render_editor_panel)
- Federation: like/unlike/boost/unboost, follow/unfollow, actor card,
  interaction buttons
- Events: ticket widget, checkin, confirm/decline/provisional, tickets
  config, posts CRUD, description edit/save, calendar/slot/ticket_type
  CRUD, payments, buy tickets, day main panel, entry page
- Market: like toggle, cart add response
- Account: newsletter toggle
- Cart: checkout error pages (3 handlers)
- Orders: checkout error page (1 handler)

Remaining render_template() calls are exclusively in GET handlers and
internal services (email templates, fragment endpoints).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 01:15:29 +00:00
parent e65232761b
commit 838ec982eb
64 changed files with 2920 additions and 545 deletions

View File

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

View File

@@ -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("/<slug>/")
@@ -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():

View File

@@ -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/<token>/")
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", "")

0
account/sexp/__init__.py Normal file
View File

View File

@@ -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 = ['<div class="w-full max-w-3xl mx-auto px-4 py-6">',
@@ -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='<title>Device Authorized \u2014 Rose Ash</title>')
# ---------------------------------------------------------------------------
# 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'<div class="bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4">'
f'{escape(email_error)}</div>'
)
return (
'<div class="py-8 max-w-md mx-auto text-center">'
'<h1 class="text-2xl font-bold mb-4">Check your email</h1>'
f'<p class="text-stone-600 mb-2">We sent a sign-in link to <strong>{escape(email)}</strong>.</p>'
'<p class="text-stone-500 text-sm">Click the link in the email to sign in. The link expires in 15 minutes.</p>'
f'{error_html}</div>'
)
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='<title>Check your email \u2014 Rose Ash</title>')
# ---------------------------------------------------------------------------
# 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())

View File

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

View File

@@ -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():

View File

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

View File

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

View File

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

View File

@@ -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/<market_slug>/")
@@ -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

View File

@@ -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/<widget_domain>/")

View File

@@ -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("/<int:snippet_id>/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

0
blog/sexp/__init__.py Normal file
View File

View File

@@ -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(
'<div class="max-w-[768px] mx-auto mt-[16px] rounded-[8px] border border-red-300'
' bg-red-50 px-[16px] py-[12px] text-[14px] text-red-700">'
f'<strong>Save failed:</strong> {esc(save_error)}</div>'
)
# Form
parts.append(
'<form id="post-new-form" method="post" class="max-w-[768px] mx-auto pb-[48px]">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
'<input type="hidden" id="lexical-json-input" name="lexical" value="">'
'<input type="hidden" id="feature-image-input" name="feature_image" value="">'
'<input type="hidden" id="feature-image-caption-input" name="feature_image_caption" value="">'
)
# Feature image section
parts.append(
'<div id="feature-image-container" class="relative mt-[16px] mb-[24px] group">'
# Empty state
'<div id="feature-image-empty">'
'<button type="button" id="feature-image-add-btn"'
' class="text-[14px] text-stone-400 hover:text-stone-600 transition-colors cursor-pointer"'
'>+ Add feature image</button></div>'
# Filled state
'<div id="feature-image-filled" class="relative hidden">'
'<img id="feature-image-preview" src="" alt=""'
' class="w-full max-h-[448px] object-cover rounded-[8px] cursor-pointer">'
'<button type="button" id="feature-image-delete-btn"'
' class="absolute top-[8px] right-[8px] w-[32px] h-[32px] rounded-full bg-black/50 text-white'
' flex items-center justify-center opacity-0 group-hover:opacity-100'
' transition-opacity hover:bg-black/70 cursor-pointer text-[14px]"'
' title="Remove feature image">'
'<i class="fa-solid fa-trash-can"></i></button>'
'<input type="text" id="feature-image-caption" value=""'
' placeholder="Add a caption..."'
' class="mt-[8px] w-full text-[14px] text-stone-500 bg-transparent border-none'
' outline-none placeholder:text-stone-300 focus:text-stone-700">'
'</div>'
# Upload spinner
'<div id="feature-image-uploading"'
' class="hidden flex items-center gap-[8px] mt-[8px] text-[14px] text-stone-400">'
'<i class="fa-solid fa-spinner fa-spin"></i> Uploading...</div>'
# Hidden file input
'<input type="file" id="feature-image-file"'
' accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml" class="hidden">'
'</div>'
)
# Title
parts.append(
f'<input type="text" name="title" value="" placeholder="{title_placeholder}"'
' class="w-full text-[36px] font-bold bg-transparent border-none outline-none'
' placeholder:text-stone-300 mb-[8px] leading-tight">'
)
# Excerpt
parts.append(
'<textarea name="custom_excerpt" rows="1" placeholder="Add an excerpt..."'
' class="w-full text-[18px] text-stone-500 bg-transparent border-none outline-none'
' placeholder:text-stone-300 resize-none mb-[24px] leading-relaxed"></textarea>'
)
# Editor mount point
parts.append('<div id="lexical-editor" class="relative w-full bg-transparent"></div>')
# Status + Save footer
parts.append(
'<div class="flex items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200">'
'<select name="status"'
' class="text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600">'
'<option value="draft" selected>Draft</option>'
'<option value="published">Published</option></select>'
'<button type="submit"'
' class="px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px]'
f' hover:bg-stone-800 transition-colors cursor-pointer">{create_label}</button>'
'</div></form>'
)
# Editor CSS + inline styles
parts.append(
f'<link rel="stylesheet" href="{editor_css}">'
'<style>'
'#lexical-editor { display: flow-root; }'
'#lexical-editor [data-kg-card="html"] * { float: none !important; }'
'#lexical-editor [data-kg-card="html"] table { width: 100% !important; }'
'</style>'
)
# Editor JS + init script
# NOTE: JavaScript string literals use single quotes; Python f-string injects URLs.
parts.append(
f'<script src="{editor_js}"></script>'
"<script>\n"
"(function() {\n"
" function applyEditorFontSize() {\n"
" document.documentElement.style.fontSize = '62.5%';\n"
" document.body.style.fontSize = '1.6rem';\n"
" }\n"
" function restoreDefaultFontSize() {\n"
" document.documentElement.style.fontSize = '';\n"
" document.body.style.fontSize = '';\n"
" }\n"
" applyEditorFontSize();\n"
" document.body.addEventListener('htmx:beforeSwap', function cleanup(e) {\n"
" if (e.detail.target && e.detail.target.id === 'main-panel') {\n"
" restoreDefaultFontSize();\n"
" document.body.removeEventListener('htmx:beforeSwap', cleanup);\n"
" }\n"
" });\n"
"\n"
" function init() {\n"
" var csrfToken = document.querySelector('input[name=\"csrf_token\"]').value;\n"
f" var uploadUrl = '{upload_image_url}';\n"
" var uploadUrls = {\n"
" image: uploadUrl,\n"
f" media: '{upload_media_url}',\n"
f" file: '{upload_file_url}',\n"
" };\n"
"\n"
" var fileInput = document.getElementById('feature-image-file');\n"
" var addBtn = document.getElementById('feature-image-add-btn');\n"
" var deleteBtn = document.getElementById('feature-image-delete-btn');\n"
" var preview = document.getElementById('feature-image-preview');\n"
" var emptyState = document.getElementById('feature-image-empty');\n"
" var filledState = document.getElementById('feature-image-filled');\n"
" var hiddenUrl = document.getElementById('feature-image-input');\n"
" var hiddenCaption = document.getElementById('feature-image-caption-input');\n"
" var captionInput = document.getElementById('feature-image-caption');\n"
" var uploading = document.getElementById('feature-image-uploading');\n"
"\n"
" function showFilled(url) {\n"
" preview.src = url;\n"
" hiddenUrl.value = url;\n"
" emptyState.classList.add('hidden');\n"
" filledState.classList.remove('hidden');\n"
" uploading.classList.add('hidden');\n"
" }\n"
"\n"
" function showEmpty() {\n"
" preview.src = '';\n"
" hiddenUrl.value = '';\n"
" hiddenCaption.value = '';\n"
" captionInput.value = '';\n"
" emptyState.classList.remove('hidden');\n"
" filledState.classList.add('hidden');\n"
" uploading.classList.add('hidden');\n"
" }\n"
"\n"
" function uploadFile(file) {\n"
" emptyState.classList.add('hidden');\n"
" uploading.classList.remove('hidden');\n"
" var fd = new FormData();\n"
" fd.append('file', file);\n"
" fetch(uploadUrl, {\n"
" method: 'POST',\n"
" body: fd,\n"
" headers: { 'X-CSRFToken': csrfToken },\n"
" })\n"
" .then(function(r) {\n"
" if (!r.ok) throw new Error('Upload failed (' + r.status + ')');\n"
" return r.json();\n"
" })\n"
" .then(function(data) {\n"
" var url = data.images && data.images[0] && data.images[0].url;\n"
" if (url) showFilled(url);\n"
" else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }\n"
" })\n"
" .catch(function(e) {\n"
" showEmpty();\n"
" alert(e.message);\n"
" });\n"
" }\n"
"\n"
" addBtn.addEventListener('click', function() { fileInput.click(); });\n"
" preview.addEventListener('click', function() { fileInput.click(); });\n"
" deleteBtn.addEventListener('click', function(e) {\n"
" e.stopPropagation();\n"
" showEmpty();\n"
" });\n"
" fileInput.addEventListener('change', function() {\n"
" if (fileInput.files && fileInput.files[0]) {\n"
" uploadFile(fileInput.files[0]);\n"
" fileInput.value = '';\n"
" }\n"
" });\n"
" captionInput.addEventListener('input', function() {\n"
" hiddenCaption.value = captionInput.value;\n"
" });\n"
"\n"
" var excerpt = document.querySelector('textarea[name=\"custom_excerpt\"]');\n"
" function autoResize() {\n"
" excerpt.style.height = 'auto';\n"
" excerpt.style.height = excerpt.scrollHeight + 'px';\n"
" }\n"
" excerpt.addEventListener('input', autoResize);\n"
" autoResize();\n"
"\n"
" window.mountEditor('lexical-editor', {\n"
" initialJson: null,\n"
" csrfToken: csrfToken,\n"
" uploadUrls: uploadUrls,\n"
f" oembedUrl: '{oembed_url}',\n"
f" unsplashApiKey: '{unsplash_key}',\n"
f" snippetsUrl: '{snippets_url}',\n"
" });\n"
"\n"
" document.addEventListener('keydown', function(e) {\n"
" if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n"
" e.preventDefault();\n"
" document.getElementById('post-new-form').requestSubmit();\n"
" }\n"
" });\n"
" }\n"
"\n"
" if (typeof window.mountEditor === 'function') {\n"
" init();\n"
" } else {\n"
" var _t = setInterval(function() {\n"
" if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }\n"
" }, 50);\n"
" }\n"
"})();\n"
"</script>"
)
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 '<div id="menu-items-nav-wrapper" hx-swap-oob="outerHTML"></div>'
# 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 = [
'<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"'
' id="menu-items-nav-wrapper" hx-swap-oob="outerHTML">',
# Left arrow
f'<button class="{arrow_cls} hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"'
f' aria-label="Scroll left"'
f' _="on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft - 200">'
f'<i class="fa fa-chevron-left"></i></button>',
# Scrollable container
f'<div id="{container_id}"'
f' class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"'
f' style="scroll-behavior: smooth;"'
f' _="on load or scroll'
f' if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth'
f' remove .hidden from .{arrow_cls} add .flex to .{arrow_cls}'
f' else add .hidden to .{arrow_cls} remove .flex from .{arrow_cls} end">',
'<div class="flex flex-col sm:flex-row gap-1">',
]
blog_url_fn = ctx.get("blog_url")
cart_url_fn = ctx.get("cart_url")
app_name = ctx.get("app_name", "")
for item in menu_items:
item_slug = getattr(item, "slug", "") if hasattr(item, "slug") else item.get("slug", "")
label = getattr(item, "label", "") if hasattr(item, "label") else item.get("label", "")
fi = getattr(item, "feature_image", None) if hasattr(item, "feature_image") else item.get("feature_image")
# Determine href — cart slug maps to cart_url, others to blog_url
if item_slug == "cart" and cart_url_fn:
href = cart_url_fn("/")
elif blog_url_fn:
href = blog_url_fn(f"/{item_slug}/")
else:
href = f"/{item_slug}/"
selected = "true" if (item_slug == first_seg or item_slug == app_name) else "false"
if fi:
img = f'<img src="{fi}" alt="{escape(label)}" class="w-8 h-8 rounded-full object-cover flex-shrink-0" />'
else:
img = '<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>'
# Items that are not special app slugs get htmx attributes
htmx_attrs = ""
if item_slug != "cart":
htmx_attrs = (
f' hx-get="/{item_slug}/" hx-target="#main-panel"'
f' hx-swap="outerHTML" hx-push-url="true"'
)
parts.append(
f'<div><a href="{href}"{htmx_attrs}'
f' aria-selected="{selected}" class="{nav_button_cls}">'
f'{img}<span>{escape(label)}</span></a></div>'
)
parts.append('</div></div>') # close flex-col + scroll container
# scrollbar-hide style
parts.append(
'<style>.scrollbar-hide::-webkit-scrollbar { display: none; }'
' .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }</style>'
)
# Right arrow
parts.append(
f'<button class="{arrow_cls} hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"'
f' aria-label="Scroll right"'
f' _="on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft + 200">'
f'<i class="fa fa-chevron-right"></i></button>'
)
parts.append('</div>') # 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 = [
'<div id="features-panel" class="space-y-4 p-4 bg-white rounded-lg border border-stone-200">',
'<h3 class="text-lg font-semibold text-stone-800">Page Features</h3>',
f'<form hx-put="{features_url}" hx-target="#features-panel" hx-swap="outerHTML"'
f' hx-headers=\'{{\"Content-Type\": \"application/json\"}}\' hx-ext="json-enc" class="space-y-3">',
# Calendar checkbox
'<label class="flex items-center gap-3 cursor-pointer">'
f'<input type="checkbox" name="calendar" value="true"{cal_checked}'
' class="h-5 w-5 rounded border-stone-300 text-blue-600 focus:ring-blue-500"'
' _="on change trigger submit on closest &lt;form/&gt;">'
'<span class="text-sm text-stone-700">'
'<i class="fa fa-calendar text-blue-600 mr-1"></i>'
' Calendar \u2014 enable event booking on this page</span></label>',
# Market checkbox
'<label class="flex items-center gap-3 cursor-pointer">'
f'<input type="checkbox" name="market" value="true"{mkt_checked}'
' class="h-5 w-5 rounded border-stone-300 text-green-600 focus:ring-green-500"'
' _="on change trigger submit on closest &lt;form/&gt;">'
'<span class="text-sm text-stone-700">'
'<i class="fa fa-shopping-bag text-green-600 mr-1"></i>'
' Market \u2014 enable product catalog on this page</span></label>',
'</form>',
]
# 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 = (
'<span class="ml-2 text-xs text-green-600">'
'<i class="fa fa-check-circle"></i> Connected</span>'
) if sumup_configured else ""
key_hint = (
'<p class="text-xs text-stone-400 mt-0.5">Key is set. Leave blank to keep current key.</p>'
) if sumup_configured else ""
parts.append(
'<div class="mt-4 pt-4 border-t border-stone-100">'
'<h4 class="text-sm font-medium text-stone-700">'
'<i class="fa fa-credit-card text-purple-600 mr-1"></i> SumUp Payment</h4>'
'<p class="text-xs text-stone-400 mt-1 mb-3">'
'Configure per-page SumUp credentials. Leave blank to use the global merchant account.</p>'
f'<form hx-put="{sumup_url}" hx-target="#features-panel" hx-swap="outerHTML" class="space-y-3">'
'<div><label class="block text-xs font-medium text-stone-600 mb-1">Merchant Code</label>'
f'<input type="text" name="merchant_code" value="{escape(sumup_merchant_code)}"'
' placeholder="e.g. ME4J6100"'
' class="w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"></div>'
'<div><label class="block text-xs font-medium text-stone-600 mb-1">API Key</label>'
f'<input type="password" name="api_key" value="" placeholder="{placeholder}"'
' class="w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500">'
f'{key_hint}</div>'
'<div><label class="block text-xs font-medium text-stone-600 mb-1">Checkout Reference Prefix</label>'
f'<input type="text" name="checkout_prefix" value="{escape(sumup_checkout_prefix)}"'
' placeholder="e.g. ROSE-"'
' class="w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"></div>'
'<button type="submit"'
' class="px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500">'
'Save SumUp Settings</button>'
f'{connected}</form></div>'
)
parts.append('</div>')
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 = ['<div id="markets-panel">',
'<h3 class="text-lg font-semibold mb-3">Markets</h3>']
if markets:
parts.append('<ul class="space-y-2 mb-4">')
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'<li class="flex items-center justify-between p-3 bg-stone-50 rounded">'
f'<div><span class="font-medium">{escape(m_name)}</span>'
f'<span class="text-stone-400 text-sm ml-2">/{escape(m_slug)}/</span></div>'
f'<button hx-delete="{del_url}" hx-target="#markets-panel" hx-swap="outerHTML"'
f' hx-confirm="Delete market \'{escape(m_name)}\'?"'
f' class="text-red-600 hover:text-red-800 text-sm">Delete</button></li>'
)
parts.append('</ul>')
else:
parts.append('<p class="text-stone-500 mb-4 text-sm">No markets yet.</p>')
parts.append(
f'<form hx-post="{create_url}" hx-target="#markets-panel" hx-swap="outerHTML" class="flex gap-2">'
'<input type="text" name="name" placeholder="Market name" required'
' class="flex-1 border border-stone-300 rounded px-3 py-1.5 text-sm" />'
'<button type="submit"'
' class="bg-stone-800 text-white px-4 py-1.5 rounded text-sm hover:bg-stone-700">Create</button>'
'</form></div>'
)
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 = ['<div id="associated-entries-list" class="border rounded-lg p-4 bg-white">',
'<h3 class="text-lg font-semibold mb-4">Associated Entries</h3>']
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'<img src="{cal_fi}" alt="{escape(cal_title)}" class="w-8 h-8 rounded-full object-cover flex-shrink-0" />'
else:
img = '<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>'
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'<button type="button"'
f' class="w-full text-left p-3 rounded border bg-green-50 border-green-300 transition hover:bg-green-100"'
f' data-confirm data-confirm-title="Remove entry?"'
f' data-confirm-text="This will remove {escape(e_name)} from this post"'
f' data-confirm-icon="warning" data-confirm-confirm-text="Yes, remove it"'
f' data-confirm-cancel-text="Cancel" data-confirm-event="confirmed"'
f' hx-post="{toggle_url}" hx-trigger="confirmed"'
f' hx-target="#associated-entries-list" hx-swap="outerHTML"'
f' hx-headers=\'{{\"X-CSRFToken\": \"{csrf}\"}}\''
f' _="on htmx:afterRequest trigger entryToggled on body">'
f'<div class="flex items-center justify-between gap-3">'
f'{img}'
f'<div class="flex-1">'
f'<div class="font-medium text-sm">{escape(e_name)}</div>'
f'<div class="text-xs text-stone-600 mt-1">{escape(cal_name)} \u2022 {date_str}</div>'
f'</div>'
f'<i class="fa fa-times-circle text-green-600 text-lg flex-shrink-0"></i>'
f'</div></button>'
)
if has_entries:
parts.append('<div class="space-y-1">')
parts.extend(entry_parts)
parts.append('</div>')
else:
parts.append(
'<div class="text-sm text-stone-400">No entries associated yet.'
' Browse calendars below to add entries.</div>'
)
parts.append('</div>')
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 '<div id="entries-calendars-nav-wrapper" hx-swap-oob="true"></div>'
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 = [
'<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"'
' id="entries-calendars-nav-wrapper" hx-swap-oob="true">',
# Left arrow
'<button class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"'
' aria-label="Scroll left"'
' _="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200">'
'<i class="fa fa-chevron-left"></i></button>',
# Container
'<div id="associated-items-container"'
' class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"'
' style="scroll-behavior: smooth;"'
' _="on load or scroll'
' if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth'
' remove .hidden from .entries-nav-arrow add .flex to .entries-nav-arrow'
' else add .hidden to .entries-nav-arrow remove .flex from .entries-nav-arrow end">',
'<div class="flex flex-col sm:flex-row gap-1">',
]
# 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'<a href="{href}" class="{nav_cls}">'
f'<div class="w-8 h-8 rounded bg-stone-200 flex-shrink-0"></div>'
f'<div class="flex-1 min-w-0">'
f'<div class="font-medium truncate">{escape(e_name)}</div>'
f'<div class="text-xs text-stone-600 truncate">{date_str}</div>'
f'</div></a>'
)
# 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'<a href="{href}" class="{nav_cls}">'
f'<i class="fa fa-calendar" aria-hidden="true"></i>'
f'<div>{escape(cal_name)}</div></a>'
)
parts.append('</div></div>') # close flex + container
# Scrollbar style
parts.append(
'<style>.scrollbar-hide::-webkit-scrollbar { display: none; }'
' .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }</style>'
)
# Right arrow
parts.append(
'<button class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"'
' aria-label="Scroll right"'
' _="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200">'
'<i class="fa fa-chevron-right"></i></button>'
)
parts.append('</div>')
return "".join(parts)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

0
cart/sexp/__init__.py Normal file
View File

View File

@@ -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 (
'<header class="mb-6 sm:mb-8">'
'<h1 class="text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight">'
'Checkout error</h1>'
'<p class="text-xs sm:text-sm text-stone-600">'
'We tried to start your payment with SumUp but hit a problem.</p>'
'</header>'
)
def _checkout_error_content_html(error: str | None, order: Any | None) -> str:
err_msg = error or "Unexpected error while creating the hosted checkout session."
order_html = ""
if order:
order_html = (
f'<p class="text-xs text-rose-800/80">'
f'Order ID: <span class="font-mono">#{order.id}</span></p>'
)
back_url = cart_url("/")
return (
'<div class="max-w-full px-3 py-3 space-y-4">'
'<div class="rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2">'
f'<p class="font-medium">Something went wrong.</p>'
f'<p>{err_msg}</p>'
f'{order_html}'
'</div>'
'<div>'
f'<a href="{back_url}"'
' class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition">'
'<i class="fa fa-shopping-cart mr-2" aria-hidden="true"></i>'
'Back to cart</a>'
'</div>'
'</div>'
)
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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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/<int:post_id>/")
@@ -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

View File

@@ -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'<div class="text-red-600 text-sm">{e}</div>', 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)

View File

@@ -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():

View File

@@ -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():

View File

@@ -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'<div class="text-red-600 text-sm">{e}</div>', 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("/<market_slug>/")
@@ -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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("/<code>/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

View File

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

View File

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

View File

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

0
events/sexp/__init__.py Normal file
View File

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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/<token>/")
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

View File

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

View File

@@ -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/<int:id>")
@@ -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)

View File

View File

@@ -398,6 +398,30 @@ async def render_login_page(ctx: dict) -> str:
meta_html="<title>Login \u2014 Rose Ash</title>")
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'<div class="bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4">'
f'{escape(email_error)}</div>'
)
content = (
'<div class="py-8 max-w-md mx-auto text-center">'
'<h1 class="text-2xl font-bold mb-4">Check your email</h1>'
f'<p class="text-stone-600 mb-2">We sent a sign-in link to <strong>{escape(email)}</strong>.</p>'
'<p class="text-stone-500 text-sm">Click the link in the email to sign in. The link expires in 15 minutes.</p>'
f'{error_html}</div>'
)
hdr = root_header_html(ctx)
return full_page(ctx, header_rows_html=hdr, content_html=content,
meta_html='<title>Check your email \u2014 Rose Ash</title>')
# ---------------------------------------------------------------------------
# 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)

View File

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

View File

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

View File

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

View File

@@ -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():

View File

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

View File

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

0
market/sexp/__init__.py Normal file
View File

View File

@@ -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'<button class="flex items-center gap-1 {colour} hover:text-red-600 transition-colors w-[1em] h-[1em]"'
f' hx-post="{like_url}" hx-target="this" hx-swap="outerHTML" hx-push-url="false"'
f' hx-headers=\'{{\"X-CSRFToken\": \"{csrf}\"}}\''
f' hx-swap-settle="0ms" aria-label="{label}">'
f'<i aria-hidden="true" class="{icon}"></i></button>'
)
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'<div id="cart-mini" hx-swap-oob="outerHTML">'
f'<a href="{cart_href}" class="relative inline-flex items-center justify-center">'
f'<span class="relative inline-flex items-center justify-center">'
f'<i class="fa-solid fa-shopping-cart text-xl" aria-hidden="true"></i>'
f'<span class="absolute -top-1.5 -right-2 pointer-events-none">'
f'<span class="flex items-center justify-center bg-emerald-500 text-white rounded-full min-w-[1.25rem] h-5 text-xs font-bold px-1">'
f'{count}</span></span></span></a></div>'
)
else:
from shared.config import config
blog_href = config().get("blog_url", "/")
logo = config().get("logo", "")
cart_mini = (
f'<div id="cart-mini" hx-swap-oob="outerHTML">'
f'<a href="{blog_href}" class="relative inline-flex items-center justify-center">'
f'<img src="{logo}" class="h-8 w-8 rounded-full object-cover border border-stone-300" alt="">'
f'</a></div>'
)
# 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'<div id="cart-add-{slug}" hx-swap-oob="outerHTML">'
+ _cart_add_html(slug, quantity, action, csrf, cart_url_fn=_cart_url)
+ '</div>'
)
return cart_mini + add_html

View File

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

View File

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

View File

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

0
orders/sexp/__init__.py Normal file
View File

View File

@@ -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 (
'<header class="mb-6 sm:mb-8">'
'<h1 class="text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight">'
'Checkout error</h1>'
'<p class="text-xs sm:text-sm text-stone-600">'
'We tried to start your payment with SumUp but hit a problem.</p>'
'</header>'
)
def _checkout_error_content_html(error: str | None, order: Any | None) -> str:
err_msg = error or "Unexpected error while creating the hosted checkout session."
order_html = ""
if order:
order_html = (
f'<p class="text-xs text-rose-800/80">'
f'Order ID: <span class="font-mono">#{order.id}</span></p>'
)
back_url = cart_url("/")
return (
'<div class="max-w-full px-3 py-3 space-y-4">'
'<div class="rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2">'
f'<p class="font-medium">Something went wrong.</p>'
f'<p>{err_msg}</p>'
f'{order_html}'
'</div>'
'<div>'
f'<a href="{back_url}"'
' class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition">'
'<i class="fa fa-shopping-cart mr-2" aria-hidden="true"></i>'
'Back to cart</a>'
'</div>'
'</div>'
)
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)

View File

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