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:
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
0
account/sexp/__init__.py
Normal 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())
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>/")
|
||||
|
||||
@@ -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
0
blog/sexp/__init__.py
Normal 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 <form/>">'
|
||||
'<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 <form/>">'
|
||||
'<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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
0
cart/sexp/__init__.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
0
events/sexp/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
0
federation/sexp/__init__.py
Normal file
0
federation/sexp/__init__.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
0
market/sexp/__init__.py
Normal 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
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
0
orders/sexp/__init__.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user