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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user