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

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

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

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

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

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
import sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
import sexp.sexp_components as sexp_components # noqa: F401 # ensure Hypercorn --reload watches this file
from pathlib import Path
from quart import g, request

View File

@@ -30,7 +30,7 @@ def register(url_prefix):
@require_admin
async def home():
from shared.sexp.page import get_template_context
from sexp_components import render_settings_page, render_settings_oob
from sexp.sexp_components import render_settings_page, render_settings_oob
tctx = await get_template_context()
if not is_htmx_request():
@@ -44,7 +44,7 @@ def register(url_prefix):
@require_admin
async def cache():
from shared.sexp.page import get_template_context
from sexp_components import render_cache_page, render_cache_oob
from sexp.sexp_components import render_cache_page, render_cache_oob
tctx = await get_template_context()
if not is_htmx_request():

View File

@@ -58,7 +58,7 @@ def register():
ctx = {"groups": groups, "unassigned_tags": unassigned}
from shared.sexp.page import get_template_context
from sexp_components import render_tag_groups_page, render_tag_groups_oob
from sexp.sexp_components import render_tag_groups_page, render_tag_groups_oob
tctx = await get_template_context()
tctx.update(ctx)
@@ -123,7 +123,7 @@ def register():
}
from shared.sexp.page import get_template_context
from sexp_components import render_tag_group_edit_page, render_tag_group_edit_oob
from sexp.sexp_components import render_tag_group_edit_page, render_tag_group_edit_oob
tctx = await get_template_context()
tctx.update(ctx)

View File

@@ -7,7 +7,6 @@ import os
from quart import (
request,
render_template,
make_response,
g,
Blueprint,
@@ -154,7 +153,7 @@ def register(url_prefix, title):
ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total + page_summary.ticket_total)
from shared.sexp.page import get_template_context
from sexp_components import render_home_page, render_home_oob
from sexp.sexp_components import render_home_page, render_home_oob
tctx = await get_template_context()
tctx.update(ctx)
@@ -191,7 +190,7 @@ def register(url_prefix, title):
"posts": data.get("pages", []),
}
from shared.sexp.page import get_template_context
from sexp_components import render_blog_page, render_blog_oob, render_blog_page_cards
from sexp.sexp_components import render_blog_page, render_blog_oob, render_blog_page_cards
tctx = await get_template_context()
tctx.update(context)
@@ -232,7 +231,7 @@ def register(url_prefix, title):
}
from shared.sexp.page import get_template_context
from sexp_components import render_blog_page, render_blog_oob, render_blog_cards
from sexp.sexp_components import render_blog_page, render_blog_oob, render_blog_cards
tctx = await get_template_context()
tctx.update(context)
@@ -249,11 +248,10 @@ def register(url_prefix, title):
@require_admin
async def new_post():
from shared.sexp.page import get_template_context
from sexp_components import render_new_post_page, render_new_post_oob
from sexp.sexp_components import render_new_post_page, render_new_post_oob, render_editor_panel
editor_html = await render_template("_types/blog_new/_main_panel.html")
tctx = await get_template_context()
tctx["editor_html"] = editor_html
tctx["editor_html"] = render_editor_panel()
if not is_htmx_request():
html = await render_new_post_page(tctx)
else:
@@ -279,18 +277,20 @@ def register(url_prefix, title):
try:
lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError):
html = await render_template(
"_types/blog_new/index.html",
save_error="Invalid JSON in editor content.",
)
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_new_post_page, render_editor_panel
tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.")
html = await render_new_post_page(tctx)
return await make_response(html, 400)
ok, reason = validate_lexical(lexical_doc)
if not ok:
html = await render_template(
"_types/blog_new/index.html",
save_error=reason,
)
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_new_post_page, render_editor_panel
tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error=reason)
html = await render_new_post_page(tctx)
return await make_response(html, 400)
# Create in Ghost
@@ -328,11 +328,10 @@ def register(url_prefix, title):
@require_admin
async def new_page():
from shared.sexp.page import get_template_context
from sexp_components import render_new_post_page, render_new_post_oob
from sexp.sexp_components import render_new_post_page, render_new_post_oob, render_editor_panel
editor_html = await render_template("_types/blog_new/_main_panel.html", is_page=True)
tctx = await get_template_context()
tctx["editor_html"] = editor_html
tctx["editor_html"] = render_editor_panel(is_page=True)
tctx["is_page"] = True
if not is_htmx_request():
html = await render_new_post_page(tctx)
@@ -359,20 +358,22 @@ def register(url_prefix, title):
try:
lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError):
html = await render_template(
"_types/blog_new/index.html",
save_error="Invalid JSON in editor content.",
is_page=True,
)
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_new_post_page, render_editor_panel
tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error="Invalid JSON in editor content.", is_page=True)
tctx["is_page"] = True
html = await render_new_post_page(tctx)
return await make_response(html, 400)
ok, reason = validate_lexical(lexical_doc)
if not ok:
html = await render_template(
"_types/blog_new/index.html",
save_error=reason,
is_page=True,
)
from shared.sexp.page import get_template_context
from sexp.sexp_components import render_new_post_page, render_editor_panel
tctx = await get_template_context()
tctx["editor_html"] = render_editor_panel(save_error=reason, is_page=True)
tctx["is_page"] = True
html = await render_new_post_page(tctx)
return await make_response(html, 400)
# Create in Ghost (as page)

View File

@@ -17,15 +17,10 @@ from shared.browser.app.utils.htmx import is_htmx_request
def register():
bp = Blueprint("menu_items", __name__, url_prefix='/settings/menu_items')
async def get_menu_items_nav_oob():
def get_menu_items_nav_oob_sync(menu_items):
"""Helper to generate OOB update for root nav menu items"""
menu_items = await get_all_menu_items(g.s)
nav_oob = await render_template(
"_types/menu_items/_nav_oob.html",
menu_items=menu_items,
)
return nav_oob
from sexp.sexp_components import render_menu_items_nav_oob
return render_menu_items_nav_oob(menu_items)
@bp.get("/")
@require_admin
@@ -35,7 +30,7 @@ def register():
from shared.sexp.page import get_template_context
from sexp_components import render_menu_items_page, render_menu_items_oob
from sexp.sexp_components import render_menu_items_page, render_menu_items_oob
tctx = await get_template_context()
tctx["menu_items"] = menu_items
@@ -77,12 +72,9 @@ def register():
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
nav_oob = await get_menu_items_nav_oob()
html = await render_template(
"_types/menu_items/_list.html",
menu_items=menu_items,
)
from sexp.sexp_components import render_menu_items_list
html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items)
return await make_response(html + nav_oob, 200)
except MenuItemError as e:
@@ -123,12 +115,9 @@ def register():
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
nav_oob = await get_menu_items_nav_oob()
html = await render_template(
"_types/menu_items/_list.html",
menu_items=menu_items,
)
from sexp.sexp_components import render_menu_items_list
html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items)
return await make_response(html + nav_oob, 200)
except MenuItemError as e:
@@ -147,12 +136,9 @@ def register():
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
nav_oob = await get_menu_items_nav_oob()
html = await render_template(
"_types/menu_items/_list.html",
menu_items=menu_items,
)
from sexp.sexp_components import render_menu_items_list
html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items)
return await make_response(html + nav_oob, 200)
@bp.get("/pages/search/")
@@ -197,12 +183,9 @@ def register():
# Get updated list and nav OOB
menu_items = await get_all_menu_items(g.s)
nav_oob = await get_menu_items_nav_oob()
html = await render_template(
"_types/menu_items/_list.html",
menu_items=menu_items,
)
from sexp.sexp_components import render_menu_items_list
html = render_menu_items_list(menu_items)
nav_oob = get_menu_items_nav_oob_sync(menu_items)
return await make_response(html + nav_oob, 200)
return bp

View File

@@ -52,7 +52,7 @@ def register():
}
from shared.sexp.page import get_template_context
from sexp_components import render_post_admin_page, render_post_admin_oob
from sexp.sexp_components import render_post_admin_page, render_post_admin_oob
tctx = await get_template_context()
tctx.update(ctx)
@@ -98,10 +98,9 @@ def register():
features = result.get("features", {})
html = await render_template(
"_types/post/admin/_features_panel.html",
features=features,
post=post,
from sexp.sexp_components import render_features_panel
html = render_features_panel(
features, post,
sumup_configured=result.get("sumup_configured", False),
sumup_merchant_code=result.get("sumup_merchant_code") or "",
sumup_checkout_prefix=result.get("sumup_checkout_prefix") or "",
@@ -138,10 +137,9 @@ def register():
result = await call_action("blog", "update-page-config", payload=payload)
features = result.get("features", {})
html = await render_template(
"_types/post/admin/_features_panel.html",
features=features,
post=post,
from sexp.sexp_components import render_features_panel
html = render_features_panel(
features, post,
sumup_configured=result.get("sumup_configured", False),
sumup_merchant_code=result.get("sumup_merchant_code") or "",
sumup_checkout_prefix=result.get("sumup_checkout_prefix") or "",
@@ -152,7 +150,7 @@ def register():
@require_admin
async def data(slug: str):
from shared.sexp.page import get_template_context
from sexp_components import render_post_data_page, render_post_data_oob
from sexp.sexp_components import render_post_data_page, render_post_data_oob
data_html = await render_template("_types/post_data/_main_panel.html")
tctx = await get_template_context()
@@ -271,7 +269,7 @@ def register():
for calendar in all_calendars:
await g.s.refresh(calendar, ["entries", "post"])
from shared.sexp.page import get_template_context
from sexp_components import render_post_entries_page, render_post_entries_oob
from sexp.sexp_components import render_post_entries_page, render_post_entries_oob
entries_html = await render_template(
"_types/post_entries/_main_panel.html",
@@ -331,20 +329,13 @@ def register():
).scalars().all()
# Return the associated entries admin list + OOB update for nav entries
admin_list = await render_template(
"_types/post/admin/_associated_entries.html",
all_calendars=all_calendars,
associated_entry_ids=associated_entry_ids,
)
from sexp.sexp_components import render_associated_entries, render_nav_entries_oob
nav_entries_oob = await render_template(
"_types/post/admin/_nav_entries_oob.html",
associated_entries=associated_entries,
calendars=calendars,
post=g.post_data["post"],
)
post = g.post_data["post"]
admin_list = render_associated_entries(all_calendars, associated_entry_ids, post["slug"])
nav_entries_html = render_nav_entries_oob(associated_entries, calendars, post)
return await make_response(admin_list + nav_entries_oob)
return await make_response(admin_list + nav_entries_html)
@bp.get("/settings/")
@require_post_author
@@ -357,7 +348,7 @@ def register():
save_success = request.args.get("saved") == "1"
from shared.sexp.page import get_template_context
from sexp_components import render_post_settings_page, render_post_settings_oob
from sexp.sexp_components import render_post_settings_page, render_post_settings_oob
settings_html = await render_template(
"_types/post_settings/_main_panel.html",
@@ -452,6 +443,7 @@ def register():
is_page = bool(g.post_data["post"].get("is_page"))
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
save_success = request.args.get("saved") == "1"
save_error = request.args.get("error", "")
# Newsletters live in db_account — fetch via HTTP
raw_newsletters = await fetch_data("account", "newsletters", required=False) or []
@@ -460,12 +452,13 @@ def register():
newsletters = [SimpleNamespace(**nl) for nl in raw_newsletters]
from shared.sexp.page import get_template_context
from sexp_components import render_post_edit_page, render_post_edit_oob
from sexp.sexp_components import render_post_edit_page, render_post_edit_oob
edit_html = await render_template(
"_types/post_edit/_main_panel.html",
ghost_post=ghost_post,
save_success=save_success,
save_error=save_error,
newsletters=newsletters,
)
tctx = await get_template_context()
@@ -500,28 +493,15 @@ def register():
feature_image_caption = form.get("feature_image_caption", "").strip()
# Validate the lexical JSON
from urllib.parse import quote
try:
lexical_doc = json.loads(lexical_raw)
except (json.JSONDecodeError, TypeError):
from ...blog.ghost.ghost_posts import get_post_for_edit
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
html = await render_template(
"_types/post_edit/index.html",
ghost_post=ghost_post,
save_error="Invalid JSON in editor content.",
)
return await make_response(html, 400)
return redirect(host_url(url_for("blog.post.admin.edit", slug=slug)) + "?error=" + quote("Invalid JSON in editor content."))
ok, reason = validate_lexical(lexical_doc)
if not ok:
from ...blog.ghost.ghost_posts import get_post_for_edit
ghost_post = await get_post_for_edit(ghost_id, is_page=is_page)
html = await render_template(
"_types/post_edit/index.html",
ghost_post=ghost_post,
save_error=reason,
)
return await make_response(html, 400)
return redirect(host_url(url_for("blog.post.admin.edit", slug=slug)) + "?error=" + quote(reason))
# Update in Ghost (content save — no status change yet)
ghost_post = await update_post(
@@ -617,11 +597,8 @@ def register():
page_markets = await _fetch_page_markets(post_id)
html = await render_template(
"_types/post/admin/_markets_panel.html",
markets=page_markets,
post=post,
)
from sexp.sexp_components import render_markets_panel
html = render_markets_panel(page_markets, post)
return await make_response(html)
@bp.post("/markets/new/")
@@ -647,11 +624,8 @@ def register():
# Return updated markets list
page_markets = await _fetch_page_markets(post_id)
html = await render_template(
"_types/post/admin/_markets_panel.html",
markets=page_markets,
post=post,
)
from sexp.sexp_components import render_markets_panel
html = render_markets_panel(page_markets, post)
return await make_response(html)
@bp.delete("/markets/<market_slug>/")
@@ -671,11 +645,8 @@ def register():
# Return updated markets list
page_markets = await _fetch_page_markets(post_id)
html = await render_template(
"_types/post/admin/_markets_panel.html",
markets=page_markets,
post=post,
)
from sexp.sexp_components import render_markets_panel
html = render_markets_panel(page_markets, post)
return await make_response(html)
return bp

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
from quart import (
render_template,
make_response,
g,
Blueprint,
@@ -115,7 +114,7 @@ def register():
@cache_page(tag="post.post_detail")
async def post_detail(slug: str):
from shared.sexp.page import get_template_context
from sexp_components import render_post_page, render_post_oob
from sexp.sexp_components import render_post_page, render_post_oob
tctx = await get_template_context()
if not is_htmx_request():
@@ -129,16 +128,13 @@ def register():
@clear_cache(tag="post.post_detail", tag_scope="user")
async def like_toggle(slug: str):
from shared.utils import host_url
from sexp.sexp_components import render_like_toggle_button
like_url = host_url(url_for('blog.post.like_toggle', slug=slug))
# Get post_id from g.post_data
if not g.user:
html = await render_template(
"_types/browse/like/button.html",
slug=slug,
liked=False,
like_url=host_url(url_for('blog.post.like_toggle', slug=slug)),
item_type='post',
)
html = render_like_toggle_button(slug, False, like_url)
resp = make_response(html, 403)
return resp
@@ -150,13 +146,7 @@ def register():
})
liked = result["liked"]
html = await render_template(
"_types/browse/like/button.html",
slug=slug,
liked=liked,
like_url=host_url(url_for('blog.post.like_toggle', slug=slug)),
item_type='post',
)
html = render_like_toggle_button(slug, liked, like_url)
return html
@bp.get("/w/<widget_domain>/")

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from quart import Blueprint, render_template, make_response, request, g, abort
from quart import Blueprint, make_response, request, g, abort
from sqlalchemy import select, or_
from sqlalchemy.orm import selectinload
@@ -39,7 +39,7 @@ def register():
is_admin = g.rights.get("admin")
from shared.sexp.page import get_template_context
from sexp_components import render_snippets_page, render_snippets_oob
from sexp.sexp_components import render_snippets_page, render_snippets_oob
tctx = await get_template_context()
tctx["snippets"] = snippets
@@ -67,11 +67,8 @@ def register():
await g.s.flush()
snippets = await _visible_snippets(g.s)
html = await render_template(
"_types/snippets/_list.html",
snippets=snippets,
is_admin=is_admin,
)
from sexp.sexp_components import render_snippets_list
html = render_snippets_list(snippets, is_admin)
return await make_response(html)
@bp.patch("/<int:snippet_id>/visibility/")
@@ -95,11 +92,8 @@ def register():
await g.s.flush()
snippets = await _visible_snippets(g.s)
html = await render_template(
"_types/snippets/_list.html",
snippets=snippets,
is_admin=True,
)
from sexp.sexp_components import render_snippets_list
html = render_snippets_list(snippets, True)
return await make_response(html)
return bp

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

View File

@@ -1443,13 +1443,272 @@ async def render_blog_page_cards(ctx: dict) -> str:
return _page_cards_html(ctx)
# ---- New post/page editor panel ----
def render_editor_panel(save_error: str | None = None, is_page: bool = False) -> str:
"""Build the WYSIWYG editor panel HTML (replaces _main_panel.html template).
This is synchronous it just assembles an HTML string from the current
request context (url_for, CSRF token, asset URLs, config).
"""
import os
from quart import url_for as qurl, current_app
from shared.browser.app.csrf import generate_csrf_token
from markupsafe import escape as esc
csrf = generate_csrf_token()
asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "")
editor_css = asset_url_fn("scripts/editor.css")
editor_js = asset_url_fn("scripts/editor.js")
upload_image_url = qurl("blog.editor_api.upload_image")
upload_media_url = qurl("blog.editor_api.upload_media")
upload_file_url = qurl("blog.editor_api.upload_file")
oembed_url = qurl("blog.editor_api.oembed_proxy")
snippets_url = qurl("blog.editor_api.list_snippets")
unsplash_key = os.environ.get("UNSPLASH_ACCESS_KEY", "")
title_placeholder = "Page title..." if is_page else "Post title..."
create_label = "Create Page" if is_page else "Create Post"
parts: list[str] = []
# Error banner
if save_error:
parts.append(
'<div class="max-w-[768px] mx-auto mt-[16px] rounded-[8px] border border-red-300'
' bg-red-50 px-[16px] py-[12px] text-[14px] text-red-700">'
f'<strong>Save failed:</strong> {esc(save_error)}</div>'
)
# Form
parts.append(
'<form id="post-new-form" method="post" class="max-w-[768px] mx-auto pb-[48px]">'
f'<input type="hidden" name="csrf_token" value="{csrf}">'
'<input type="hidden" id="lexical-json-input" name="lexical" value="">'
'<input type="hidden" id="feature-image-input" name="feature_image" value="">'
'<input type="hidden" id="feature-image-caption-input" name="feature_image_caption" value="">'
)
# Feature image section
parts.append(
'<div id="feature-image-container" class="relative mt-[16px] mb-[24px] group">'
# Empty state
'<div id="feature-image-empty">'
'<button type="button" id="feature-image-add-btn"'
' class="text-[14px] text-stone-400 hover:text-stone-600 transition-colors cursor-pointer"'
'>+ Add feature image</button></div>'
# Filled state
'<div id="feature-image-filled" class="relative hidden">'
'<img id="feature-image-preview" src="" alt=""'
' class="w-full max-h-[448px] object-cover rounded-[8px] cursor-pointer">'
'<button type="button" id="feature-image-delete-btn"'
' class="absolute top-[8px] right-[8px] w-[32px] h-[32px] rounded-full bg-black/50 text-white'
' flex items-center justify-center opacity-0 group-hover:opacity-100'
' transition-opacity hover:bg-black/70 cursor-pointer text-[14px]"'
' title="Remove feature image">'
'<i class="fa-solid fa-trash-can"></i></button>'
'<input type="text" id="feature-image-caption" value=""'
' placeholder="Add a caption..."'
' class="mt-[8px] w-full text-[14px] text-stone-500 bg-transparent border-none'
' outline-none placeholder:text-stone-300 focus:text-stone-700">'
'</div>'
# Upload spinner
'<div id="feature-image-uploading"'
' class="hidden flex items-center gap-[8px] mt-[8px] text-[14px] text-stone-400">'
'<i class="fa-solid fa-spinner fa-spin"></i> Uploading...</div>'
# Hidden file input
'<input type="file" id="feature-image-file"'
' accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml" class="hidden">'
'</div>'
)
# Title
parts.append(
f'<input type="text" name="title" value="" placeholder="{title_placeholder}"'
' class="w-full text-[36px] font-bold bg-transparent border-none outline-none'
' placeholder:text-stone-300 mb-[8px] leading-tight">'
)
# Excerpt
parts.append(
'<textarea name="custom_excerpt" rows="1" placeholder="Add an excerpt..."'
' class="w-full text-[18px] text-stone-500 bg-transparent border-none outline-none'
' placeholder:text-stone-300 resize-none mb-[24px] leading-relaxed"></textarea>'
)
# Editor mount point
parts.append('<div id="lexical-editor" class="relative w-full bg-transparent"></div>')
# Status + Save footer
parts.append(
'<div class="flex items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200">'
'<select name="status"'
' class="text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600">'
'<option value="draft" selected>Draft</option>'
'<option value="published">Published</option></select>'
'<button type="submit"'
' class="px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px]'
f' hover:bg-stone-800 transition-colors cursor-pointer">{create_label}</button>'
'</div></form>'
)
# Editor CSS + inline styles
parts.append(
f'<link rel="stylesheet" href="{editor_css}">'
'<style>'
'#lexical-editor { display: flow-root; }'
'#lexical-editor [data-kg-card="html"] * { float: none !important; }'
'#lexical-editor [data-kg-card="html"] table { width: 100% !important; }'
'</style>'
)
# Editor JS + init script
# NOTE: JavaScript string literals use single quotes; Python f-string injects URLs.
parts.append(
f'<script src="{editor_js}"></script>'
"<script>\n"
"(function() {\n"
" function applyEditorFontSize() {\n"
" document.documentElement.style.fontSize = '62.5%';\n"
" document.body.style.fontSize = '1.6rem';\n"
" }\n"
" function restoreDefaultFontSize() {\n"
" document.documentElement.style.fontSize = '';\n"
" document.body.style.fontSize = '';\n"
" }\n"
" applyEditorFontSize();\n"
" document.body.addEventListener('htmx:beforeSwap', function cleanup(e) {\n"
" if (e.detail.target && e.detail.target.id === 'main-panel') {\n"
" restoreDefaultFontSize();\n"
" document.body.removeEventListener('htmx:beforeSwap', cleanup);\n"
" }\n"
" });\n"
"\n"
" function init() {\n"
" var csrfToken = document.querySelector('input[name=\"csrf_token\"]').value;\n"
f" var uploadUrl = '{upload_image_url}';\n"
" var uploadUrls = {\n"
" image: uploadUrl,\n"
f" media: '{upload_media_url}',\n"
f" file: '{upload_file_url}',\n"
" };\n"
"\n"
" var fileInput = document.getElementById('feature-image-file');\n"
" var addBtn = document.getElementById('feature-image-add-btn');\n"
" var deleteBtn = document.getElementById('feature-image-delete-btn');\n"
" var preview = document.getElementById('feature-image-preview');\n"
" var emptyState = document.getElementById('feature-image-empty');\n"
" var filledState = document.getElementById('feature-image-filled');\n"
" var hiddenUrl = document.getElementById('feature-image-input');\n"
" var hiddenCaption = document.getElementById('feature-image-caption-input');\n"
" var captionInput = document.getElementById('feature-image-caption');\n"
" var uploading = document.getElementById('feature-image-uploading');\n"
"\n"
" function showFilled(url) {\n"
" preview.src = url;\n"
" hiddenUrl.value = url;\n"
" emptyState.classList.add('hidden');\n"
" filledState.classList.remove('hidden');\n"
" uploading.classList.add('hidden');\n"
" }\n"
"\n"
" function showEmpty() {\n"
" preview.src = '';\n"
" hiddenUrl.value = '';\n"
" hiddenCaption.value = '';\n"
" captionInput.value = '';\n"
" emptyState.classList.remove('hidden');\n"
" filledState.classList.add('hidden');\n"
" uploading.classList.add('hidden');\n"
" }\n"
"\n"
" function uploadFile(file) {\n"
" emptyState.classList.add('hidden');\n"
" uploading.classList.remove('hidden');\n"
" var fd = new FormData();\n"
" fd.append('file', file);\n"
" fetch(uploadUrl, {\n"
" method: 'POST',\n"
" body: fd,\n"
" headers: { 'X-CSRFToken': csrfToken },\n"
" })\n"
" .then(function(r) {\n"
" if (!r.ok) throw new Error('Upload failed (' + r.status + ')');\n"
" return r.json();\n"
" })\n"
" .then(function(data) {\n"
" var url = data.images && data.images[0] && data.images[0].url;\n"
" if (url) showFilled(url);\n"
" else { showEmpty(); alert('Upload succeeded but no image URL returned.'); }\n"
" })\n"
" .catch(function(e) {\n"
" showEmpty();\n"
" alert(e.message);\n"
" });\n"
" }\n"
"\n"
" addBtn.addEventListener('click', function() { fileInput.click(); });\n"
" preview.addEventListener('click', function() { fileInput.click(); });\n"
" deleteBtn.addEventListener('click', function(e) {\n"
" e.stopPropagation();\n"
" showEmpty();\n"
" });\n"
" fileInput.addEventListener('change', function() {\n"
" if (fileInput.files && fileInput.files[0]) {\n"
" uploadFile(fileInput.files[0]);\n"
" fileInput.value = '';\n"
" }\n"
" });\n"
" captionInput.addEventListener('input', function() {\n"
" hiddenCaption.value = captionInput.value;\n"
" });\n"
"\n"
" var excerpt = document.querySelector('textarea[name=\"custom_excerpt\"]');\n"
" function autoResize() {\n"
" excerpt.style.height = 'auto';\n"
" excerpt.style.height = excerpt.scrollHeight + 'px';\n"
" }\n"
" excerpt.addEventListener('input', autoResize);\n"
" autoResize();\n"
"\n"
" window.mountEditor('lexical-editor', {\n"
" initialJson: null,\n"
" csrfToken: csrfToken,\n"
" uploadUrls: uploadUrls,\n"
f" oembedUrl: '{oembed_url}',\n"
f" unsplashApiKey: '{unsplash_key}',\n"
f" snippetsUrl: '{snippets_url}',\n"
" });\n"
"\n"
" document.addEventListener('keydown', function(e) {\n"
" if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n"
" e.preventDefault();\n"
" document.getElementById('post-new-form').requestSubmit();\n"
" }\n"
" });\n"
" }\n"
"\n"
" if (typeof window.mountEditor === 'function') {\n"
" init();\n"
" } else {\n"
" var _t = setInterval(function() {\n"
" if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }\n"
" }, 50);\n"
" }\n"
"})();\n"
"</script>"
)
return "".join(parts)
# ---- New post/page ----
async def render_new_post_page(ctx: dict) -> str:
root_hdr = root_header_html(ctx)
blog_hdr = _blog_header_html(ctx)
header_rows = root_hdr + blog_hdr
# Content comes from Jinja (editor template)
content = ctx.get("editor_html", "")
return full_page(ctx, header_rows_html=header_rows, content_html=content)
@@ -1803,3 +2062,472 @@ async def render_tag_group_edit_oob(ctx: dict) -> str:
tg_hdr)
content = _tag_groups_edit_main_panel_html(ctx)
return oob_page(ctx, oobs_html=settings_hdr_oob + tg_oob, content_html=content)
# ===========================================================================
# PUBLIC API — HTMX fragment renderers for POST/PUT/DELETE handlers
# ===========================================================================
# ---- Like toggle button (delegates to market impl) ----
def render_like_toggle_button(slug: str, liked: bool, like_url: str) -> str:
"""Render a like toggle button for HTMX POST response."""
from market.sexp.sexp_components import render_like_toggle_button as _market_like
return _market_like(slug, liked, like_url=like_url, item_type="post")
# ---- Snippets list ----
def render_snippets_list(snippets, is_admin: bool) -> str:
"""Render the snippets list fragment for HTMX DELETE/PATCH responses."""
from shared.browser.app.csrf import generate_csrf_token
from quart import g
ctx = {
"snippets": snippets,
"is_admin": is_admin,
"csrf_token": generate_csrf_token(),
}
return _snippets_list_html(ctx)
# ---- Menu items list + nav OOB ----
def render_menu_items_list(menu_items) -> str:
"""Render the menu items list fragment for HTMX responses."""
from shared.browser.app.csrf import generate_csrf_token
ctx = {
"menu_items": menu_items,
"csrf_token": generate_csrf_token(),
}
return _menu_items_list_html(ctx)
def render_menu_items_nav_oob(menu_items, ctx: dict | None = None) -> str:
"""Render the OOB nav update for menu items.
Produces the same DOM structure as ``_types/menu_items/_nav_oob.html``:
a scrolling nav wrapper with ``id="menu-items-nav-wrapper"`` and
``hx-swap-oob="outerHTML"``.
"""
from quart import request as qrequest
if not menu_items:
return '<div id="menu-items-nav-wrapper" hx-swap-oob="outerHTML"></div>'
# Resolve URL helpers from context or fall back to template globals
if ctx is None:
ctx = {}
first_seg = qrequest.path.strip("/").split("/")[0] if qrequest else ""
# nav_button style (matches shared/infrastructure/jinja_setup.py)
select_colours = (
"[.hover-capable_&]:hover:bg-yellow-300"
" aria-selected:bg-stone-500 aria-selected:text-white"
" [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500"
)
nav_button_cls = (
f"justify-center cursor-pointer flex flex-row items-center gap-2"
f" rounded bg-stone-200 text-black {select_colours} p-3"
)
container_id = "menu-items-container"
arrow_cls = f"scrolling-menu-arrow-{container_id}"
parts = [
'<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"'
' id="menu-items-nav-wrapper" hx-swap-oob="outerHTML">',
# Left arrow
f'<button class="{arrow_cls} hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"'
f' aria-label="Scroll left"'
f' _="on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft - 200">'
f'<i class="fa fa-chevron-left"></i></button>',
# Scrollable container
f'<div id="{container_id}"'
f' class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"'
f' style="scroll-behavior: smooth;"'
f' _="on load or scroll'
f' if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth'
f' remove .hidden from .{arrow_cls} add .flex to .{arrow_cls}'
f' else add .hidden to .{arrow_cls} remove .flex from .{arrow_cls} end">',
'<div class="flex flex-col sm:flex-row gap-1">',
]
blog_url_fn = ctx.get("blog_url")
cart_url_fn = ctx.get("cart_url")
app_name = ctx.get("app_name", "")
for item in menu_items:
item_slug = getattr(item, "slug", "") if hasattr(item, "slug") else item.get("slug", "")
label = getattr(item, "label", "") if hasattr(item, "label") else item.get("label", "")
fi = getattr(item, "feature_image", None) if hasattr(item, "feature_image") else item.get("feature_image")
# Determine href — cart slug maps to cart_url, others to blog_url
if item_slug == "cart" and cart_url_fn:
href = cart_url_fn("/")
elif blog_url_fn:
href = blog_url_fn(f"/{item_slug}/")
else:
href = f"/{item_slug}/"
selected = "true" if (item_slug == first_seg or item_slug == app_name) else "false"
if fi:
img = f'<img src="{fi}" alt="{escape(label)}" class="w-8 h-8 rounded-full object-cover flex-shrink-0" />'
else:
img = '<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>'
# Items that are not special app slugs get htmx attributes
htmx_attrs = ""
if item_slug != "cart":
htmx_attrs = (
f' hx-get="/{item_slug}/" hx-target="#main-panel"'
f' hx-swap="outerHTML" hx-push-url="true"'
)
parts.append(
f'<div><a href="{href}"{htmx_attrs}'
f' aria-selected="{selected}" class="{nav_button_cls}">'
f'{img}<span>{escape(label)}</span></a></div>'
)
parts.append('</div></div>') # close flex-col + scroll container
# scrollbar-hide style
parts.append(
'<style>.scrollbar-hide::-webkit-scrollbar { display: none; }'
' .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }</style>'
)
# Right arrow
parts.append(
f'<button class="{arrow_cls} hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"'
f' aria-label="Scroll right"'
f' _="on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft + 200">'
f'<i class="fa fa-chevron-right"></i></button>'
)
parts.append('</div>') # close wrapper
return "".join(parts)
# ---- Features panel ----
def render_features_panel(features: dict, post: dict,
sumup_configured: bool,
sumup_merchant_code: str,
sumup_checkout_prefix: str) -> str:
"""Render the features panel fragment for HTMX PUT responses."""
from shared.utils import host_url
from quart import url_for as qurl
slug = post.get("slug", "")
features_url = host_url(qurl("blog.post.admin.update_features", slug=slug))
sumup_url = host_url(qurl("blog.post.admin.update_sumup", slug=slug))
cal_checked = " checked" if features.get("calendar") else ""
mkt_checked = " checked" if features.get("market") else ""
parts = [
'<div id="features-panel" class="space-y-4 p-4 bg-white rounded-lg border border-stone-200">',
'<h3 class="text-lg font-semibold text-stone-800">Page Features</h3>',
f'<form hx-put="{features_url}" hx-target="#features-panel" hx-swap="outerHTML"'
f' hx-headers=\'{{\"Content-Type\": \"application/json\"}}\' hx-ext="json-enc" class="space-y-3">',
# Calendar checkbox
'<label class="flex items-center gap-3 cursor-pointer">'
f'<input type="checkbox" name="calendar" value="true"{cal_checked}'
' class="h-5 w-5 rounded border-stone-300 text-blue-600 focus:ring-blue-500"'
' _="on change trigger submit on closest &lt;form/&gt;">'
'<span class="text-sm text-stone-700">'
'<i class="fa fa-calendar text-blue-600 mr-1"></i>'
' Calendar \u2014 enable event booking on this page</span></label>',
# Market checkbox
'<label class="flex items-center gap-3 cursor-pointer">'
f'<input type="checkbox" name="market" value="true"{mkt_checked}'
' class="h-5 w-5 rounded border-stone-300 text-green-600 focus:ring-green-500"'
' _="on change trigger submit on closest &lt;form/&gt;">'
'<span class="text-sm text-stone-700">'
'<i class="fa fa-shopping-bag text-green-600 mr-1"></i>'
' Market \u2014 enable product catalog on this page</span></label>',
'</form>',
]
# SumUp section — shown when calendar or market is enabled
if features.get("calendar") or features.get("market"):
placeholder = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" if sumup_configured else "sup_sk_..."
connected = (
'<span class="ml-2 text-xs text-green-600">'
'<i class="fa fa-check-circle"></i> Connected</span>'
) if sumup_configured else ""
key_hint = (
'<p class="text-xs text-stone-400 mt-0.5">Key is set. Leave blank to keep current key.</p>'
) if sumup_configured else ""
parts.append(
'<div class="mt-4 pt-4 border-t border-stone-100">'
'<h4 class="text-sm font-medium text-stone-700">'
'<i class="fa fa-credit-card text-purple-600 mr-1"></i> SumUp Payment</h4>'
'<p class="text-xs text-stone-400 mt-1 mb-3">'
'Configure per-page SumUp credentials. Leave blank to use the global merchant account.</p>'
f'<form hx-put="{sumup_url}" hx-target="#features-panel" hx-swap="outerHTML" class="space-y-3">'
'<div><label class="block text-xs font-medium text-stone-600 mb-1">Merchant Code</label>'
f'<input type="text" name="merchant_code" value="{escape(sumup_merchant_code)}"'
' placeholder="e.g. ME4J6100"'
' class="w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"></div>'
'<div><label class="block text-xs font-medium text-stone-600 mb-1">API Key</label>'
f'<input type="password" name="api_key" value="" placeholder="{placeholder}"'
' class="w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500">'
f'{key_hint}</div>'
'<div><label class="block text-xs font-medium text-stone-600 mb-1">Checkout Reference Prefix</label>'
f'<input type="text" name="checkout_prefix" value="{escape(sumup_checkout_prefix)}"'
' placeholder="e.g. ROSE-"'
' class="w-full px-3 py-1.5 text-sm border border-stone-300 rounded focus:ring-purple-500 focus:border-purple-500"></div>'
'<button type="submit"'
' class="px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded hover:bg-purple-700 focus:ring-2 focus:ring-purple-500">'
'Save SumUp Settings</button>'
f'{connected}</form></div>'
)
parts.append('</div>')
return "".join(parts)
# ---- Markets panel ----
def render_markets_panel(markets, post: dict) -> str:
"""Render the markets panel fragment for HTMX responses."""
from shared.utils import host_url
from quart import url_for as qurl
slug = post.get("slug", "")
create_url = host_url(qurl("blog.post.admin.create_market", slug=slug))
parts = ['<div id="markets-panel">',
'<h3 class="text-lg font-semibold mb-3">Markets</h3>']
if markets:
parts.append('<ul class="space-y-2 mb-4">')
for m in markets:
m_name = getattr(m, "name", "") if hasattr(m, "name") else m.get("name", "")
m_slug = getattr(m, "slug", "") if hasattr(m, "slug") else m.get("slug", "")
del_url = host_url(qurl("blog.post.admin.delete_market", slug=slug, market_slug=m_slug))
parts.append(
f'<li class="flex items-center justify-between p-3 bg-stone-50 rounded">'
f'<div><span class="font-medium">{escape(m_name)}</span>'
f'<span class="text-stone-400 text-sm ml-2">/{escape(m_slug)}/</span></div>'
f'<button hx-delete="{del_url}" hx-target="#markets-panel" hx-swap="outerHTML"'
f' hx-confirm="Delete market \'{escape(m_name)}\'?"'
f' class="text-red-600 hover:text-red-800 text-sm">Delete</button></li>'
)
parts.append('</ul>')
else:
parts.append('<p class="text-stone-500 mb-4 text-sm">No markets yet.</p>')
parts.append(
f'<form hx-post="{create_url}" hx-target="#markets-panel" hx-swap="outerHTML" class="flex gap-2">'
'<input type="text" name="name" placeholder="Market name" required'
' class="flex-1 border border-stone-300 rounded px-3 py-1.5 text-sm" />'
'<button type="submit"'
' class="bg-stone-800 text-white px-4 py-1.5 rounded text-sm hover:bg-stone-700">Create</button>'
'</form></div>'
)
return "".join(parts)
# ---- Associated entries ----
def render_associated_entries(all_calendars, associated_entry_ids, post_slug: str) -> str:
"""Render the associated entries panel for HTMX POST responses."""
from shared.browser.app.csrf import generate_csrf_token
from quart import url_for as qurl
from shared.utils import host_url
csrf = generate_csrf_token()
parts = ['<div id="associated-entries-list" class="border rounded-lg p-4 bg-white">',
'<h3 class="text-lg font-semibold mb-4">Associated Entries</h3>']
has_entries = False
entry_parts: list[str] = []
for calendar in all_calendars:
entries = getattr(calendar, "entries", []) or []
cal_name = getattr(calendar, "name", "")
cal_post = getattr(calendar, "post", None)
cal_fi = getattr(cal_post, "feature_image", None) if cal_post else None
cal_title = getattr(cal_post, "title", "") if cal_post else ""
for entry in entries:
e_id = getattr(entry, "id", None)
if e_id not in associated_entry_ids:
continue
if getattr(entry, "deleted_at", None) is not None:
continue
has_entries = True
e_name = getattr(entry, "name", "")
e_start = getattr(entry, "start_at", None)
e_end = getattr(entry, "end_at", None)
toggle_url = host_url(qurl("blog.post.admin.toggle_entry", slug=post_slug, entry_id=e_id))
if cal_fi:
img = f'<img src="{cal_fi}" alt="{escape(cal_title)}" class="w-8 h-8 rounded-full object-cover flex-shrink-0" />'
else:
img = '<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>'
date_str = e_start.strftime("%A, %B %d, %Y at %H:%M") if e_start else ""
if e_end:
date_str += f" \u2013 {e_end.strftime('%H:%M')}"
entry_parts.append(
f'<button type="button"'
f' class="w-full text-left p-3 rounded border bg-green-50 border-green-300 transition hover:bg-green-100"'
f' data-confirm data-confirm-title="Remove entry?"'
f' data-confirm-text="This will remove {escape(e_name)} from this post"'
f' data-confirm-icon="warning" data-confirm-confirm-text="Yes, remove it"'
f' data-confirm-cancel-text="Cancel" data-confirm-event="confirmed"'
f' hx-post="{toggle_url}" hx-trigger="confirmed"'
f' hx-target="#associated-entries-list" hx-swap="outerHTML"'
f' hx-headers=\'{{\"X-CSRFToken\": \"{csrf}\"}}\''
f' _="on htmx:afterRequest trigger entryToggled on body">'
f'<div class="flex items-center justify-between gap-3">'
f'{img}'
f'<div class="flex-1">'
f'<div class="font-medium text-sm">{escape(e_name)}</div>'
f'<div class="text-xs text-stone-600 mt-1">{escape(cal_name)} \u2022 {date_str}</div>'
f'</div>'
f'<i class="fa fa-times-circle text-green-600 text-lg flex-shrink-0"></i>'
f'</div></button>'
)
if has_entries:
parts.append('<div class="space-y-1">')
parts.extend(entry_parts)
parts.append('</div>')
else:
parts.append(
'<div class="text-sm text-stone-400">No entries associated yet.'
' Browse calendars below to add entries.</div>'
)
parts.append('</div>')
return "".join(parts)
# ---- Nav entries OOB ----
def render_nav_entries_oob(associated_entries, calendars, post: dict, ctx: dict | None = None) -> str:
"""Render the OOB nav entries swap.
Produces the ``entries-calendars-nav-wrapper`` OOB element with links
to associated entries and calendars.
"""
if ctx is None:
ctx = {}
entries_list = []
if associated_entries and hasattr(associated_entries, "entries"):
entries_list = associated_entries.entries or []
has_items = bool(entries_list or calendars)
if not has_items:
return '<div id="entries-calendars-nav-wrapper" hx-swap-oob="true"></div>'
events_url_fn = ctx.get("events_url")
# nav_button_less_pad style
select_colours = (
"[.hover-capable_&]:hover:bg-yellow-300"
" aria-selected:bg-stone-500 aria-selected:text-white"
" [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500"
)
nav_cls = (
f"justify-center cursor-pointer flex flex-row items-center gap-2"
f" rounded bg-stone-200 text-black {select_colours} p-2"
)
post_slug = post.get("slug", "")
parts = [
'<div class="flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"'
' id="entries-calendars-nav-wrapper" hx-swap-oob="true">',
# Left arrow
'<button class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"'
' aria-label="Scroll left"'
' _="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200">'
'<i class="fa fa-chevron-left"></i></button>',
# Container
'<div id="associated-items-container"'
' class="overflow-y-auto sm:overflow-x-auto sm:overflow-y-visible scrollbar-hide max-h-[50vh] sm:max-h-none"'
' style="scroll-behavior: smooth;"'
' _="on load or scroll'
' if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth'
' remove .hidden from .entries-nav-arrow add .flex to .entries-nav-arrow'
' else add .hidden to .entries-nav-arrow remove .flex from .entries-nav-arrow end">',
'<div class="flex flex-col sm:flex-row gap-1">',
]
# Entry links
for entry in entries_list:
e_name = getattr(entry, "name", "")
e_start = getattr(entry, "start_at", None)
e_end = getattr(entry, "end_at", None)
cal_slug = getattr(entry, "calendar_slug", "")
if e_start:
entry_path = (
f"/{post_slug}/calendars/{cal_slug}/"
f"{e_start.year}/{e_start.month}/{e_start.day}"
f"/entries/{getattr(entry, 'id', '')}/"
)
date_str = e_start.strftime("%b %d, %Y at %H:%M")
if e_end:
date_str += f" \u2013 {e_end.strftime('%H:%M')}"
else:
entry_path = f"/{post_slug}/calendars/{cal_slug}/"
date_str = ""
href = events_url_fn(entry_path) if events_url_fn else entry_path
parts.append(
f'<a href="{href}" class="{nav_cls}">'
f'<div class="w-8 h-8 rounded bg-stone-200 flex-shrink-0"></div>'
f'<div class="flex-1 min-w-0">'
f'<div class="font-medium truncate">{escape(e_name)}</div>'
f'<div class="text-xs text-stone-600 truncate">{date_str}</div>'
f'</div></a>'
)
# Calendar links
for calendar in (calendars or []):
cal_name = getattr(calendar, "name", "")
cal_slug = getattr(calendar, "slug", "")
cal_path = f"/{post_slug}/calendars/{cal_slug}/"
href = events_url_fn(cal_path) if events_url_fn else cal_path
parts.append(
f'<a href="{href}" class="{nav_cls}">'
f'<i class="fa fa-calendar" aria-hidden="true"></i>'
f'<div>{escape(cal_name)}</div></a>'
)
parts.append('</div></div>') # close flex + container
# Scrollbar style
parts.append(
'<style>.scrollbar-hide::-webkit-scrollbar { display: none; }'
' .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }</style>'
)
# Right arrow
parts.append(
'<button class="entries-nav-arrow hidden flex-shrink-0 p-2 hover:bg-stone-200 rounded"'
' aria-label="Scroll right"'
' _="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200">'
'<i class="fa fa-chevron-right"></i></button>'
)
parts.append('</div>')
return "".join(parts)