Files
mono/blog/sx/sx_components.py
giles c0d369eb8e Refactor SX templates: shared components, Python migration, cleanup
- Extract shared components (empty-state, delete-btn, sentinel, crud-*,
  view-toggle, img-or-placeholder, avatar, sumup-settings-form, auth
  forms, order tables/detail/checkout)
- Migrate all Python sx_call() callers to use shared components directly
- Remove 55+ thin wrapper defcomps from domain .sx files
- Remove trivial passthrough wrappers (blog-header-label, market-card-text, etc)
- Unify duplicate auth flows (account + federation) into shared/sx/templates/auth.sx
- Unify duplicate order views (cart + orders) into shared/sx/templates/orders.sx
- Disable static file caching in dev (SEND_FILE_MAX_AGE_DEFAULT=0)
- Add SX response validation and debug headers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 20:34:34 +00:00

1935 lines
74 KiB
Python

"""
Blog service s-expression page components.
Renders home, blog index (posts/pages), new post/page, post detail,
post admin, post data, post entries, post edit, post settings,
settings home, cache, snippets, menu items, and tag groups pages.
Called from route handlers in place of ``render_template()``.
"""
from __future__ import annotations
import os
from typing import Any
from markupsafe import escape
from shared.sx.jinja_bridge import load_service_components
from shared.sx.parser import serialize as sx_serialize
from shared.sx.helpers import (
SxExpr, sx_call,
call_url, get_asset_url,
root_header_sx,
post_header_sx,
post_admin_header_sx,
header_child_sx,
oob_header_sx,
oob_page_sx,
search_mobile_sx,
search_desktop_sx,
full_page_sx,
)
# Load blog service .sx component definitions
load_service_components(os.path.dirname(os.path.dirname(__file__)))
def _ctx_csrf(ctx: dict) -> str:
"""Get CSRF token from context, handling Jinja callable globals."""
val = ctx.get("csrf_token", "")
return val() if callable(val) else val
# ---------------------------------------------------------------------------
# OOB header helper — delegates to shared
# ---------------------------------------------------------------------------
_oob_header_sx = oob_header_sx
# ---------------------------------------------------------------------------
# Blog header (root-header-child -> blog-header-child)
# ---------------------------------------------------------------------------
def _blog_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Blog header row — empty child of root."""
return sx_call("menu-row-sx",
id="blog-row", level=1,
link_label_content=SxExpr("(div)"),
child_id="blog-header-child", oob=oob,
)
# ---------------------------------------------------------------------------
# Post header helpers — thin wrapper over shared post_header_sx
# ---------------------------------------------------------------------------
def _post_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Build the post-level header row as sx — delegates to shared helper."""
return post_header_sx(ctx, oob=oob)
# ---------------------------------------------------------------------------
# Post admin header
# ---------------------------------------------------------------------------
def _post_admin_header_sx(ctx: dict, *, oob: bool = False, selected: str = "") -> str:
"""Post admin header row as sx — delegates to shared helper."""
slug = (ctx.get("post") or {}).get("slug", "")
return post_admin_header_sx(ctx, slug, oob=oob, selected=selected)
# ---------------------------------------------------------------------------
# Settings header (root-header-child -> root-settings-header-child)
# ---------------------------------------------------------------------------
def _settings_header_sx(ctx: dict, *, oob: bool = False) -> str:
"""Settings header row with admin icon and nav links (sx)."""
from quart import url_for as qurl
settings_href = qurl("settings.home")
label_sx = sx_call("blog-admin-label")
nav_sx = _settings_nav_sx(ctx)
return sx_call("menu-row-sx",
id="root-settings-row", level=1,
link_href=settings_href,
link_label_content=SxExpr(label_sx),
nav=SxExpr(nav_sx) if nav_sx else None,
child_id="root-settings-header-child", oob=oob,
)
def _settings_nav_sx(ctx: dict) -> str:
"""Settings desktop nav as sx."""
from quart import url_for as qurl
select_colours = ctx.get("select_colours", "")
parts = []
for endpoint, icon, label in [
("menu_items.list_menu_items", "bars", "Menu Items"),
("snippets.list_snippets", "puzzle-piece", "Snippets"),
("blog.tag_groups_admin.index", "tags", "Tag Groups"),
("settings.cache", "refresh", "Cache"),
]:
href = qurl(endpoint)
parts.append(sx_call("nav-link",
href=href, icon=f"fa fa-{icon}", label=label,
select_colours=select_colours,
))
return "(<> " + " ".join(parts) + ")" if parts else ""
# ---------------------------------------------------------------------------
# Sub-settings headers (root-settings-header-child -> X-header-child)
# ---------------------------------------------------------------------------
def _sub_settings_header_sx(row_id: str, child_id: str, href: str,
icon: str, label: str, ctx: dict,
*, oob: bool = False, nav_sx: str = "") -> str:
"""Generic sub-settings header row as sx."""
label_sx = sx_call("blog-sub-settings-label",
icon=f"fa fa-{icon}", label=label,
)
return sx_call("menu-row-sx",
id=row_id, level=2,
link_href=href,
link_label_content=SxExpr(label_sx),
nav=SxExpr(nav_sx) if nav_sx else None,
child_id=child_id, oob=oob,
)
# ---------------------------------------------------------------------------
# Blog index main panel helpers
# ---------------------------------------------------------------------------
def _blog_sentinel_sx(ctx: dict) -> str:
"""Infinite scroll sentinels as sx calls (for wire format)."""
from shared.sx.helpers import sx_call
page = ctx.get("page", 1)
total_pages = ctx.get("total_pages", 1)
if isinstance(total_pages, str):
total_pages = int(total_pages)
if page >= total_pages:
return sx_call("end-of-results")
current_local_href = ctx.get("current_local_href", "/index")
next_url = f"{current_local_href}?page={page + 1}"
mobile_hs = (
"init if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end"
" if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' end"
" on resize from window if window.matchMedia('(min-width: 768px)').matches then set @hx-disabled to '' else remove @hx-disabled end"
" on htmx:beforeRequest if window.matchMedia('(min-width: 768px)').matches then halt end"
" add .hidden to .js-neterr in me remove .hidden from .js-loading in me remove .opacity-100 from me add .opacity-0 to me"
" def backoff() set ms to me.dataset.retryMs if ms > 30000 then set ms to 30000 end"
" add .hidden to .js-loading in me remove .hidden from .js-neterr in me remove .opacity-0 from me add .opacity-100 to me"
" wait ms ms trigger sentinelmobile:retry set ms to ms * 2 if ms > 30000 then set ms to 30000 end set me.dataset.retryMs to ms end"
" on htmx:sendError call backoff() on htmx:responseError call backoff() on htmx:timeout call backoff()"
)
desktop_hs = (
"init if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end"
" on htmx:beforeRequest(event) add .hidden to .js-neterr in me remove .hidden from .js-loading in me"
" remove .opacity-100 from me add .opacity-0 to me"
" set trig to null if event.detail and event.detail.triggeringEvent then set trig to event.detail.triggeringEvent end"
" if trig and trig.type is 'intersect' set scroller to the closest .js-grid-viewport"
" if scroller is null then halt end if scroller.scrollTop < 20 then halt end end"
" def backoff() set ms to me.dataset.retryMs if ms > 30000 then set ms to 30000 end"
" add .hidden to .js-loading in me remove .hidden from .js-neterr in me remove .opacity-0 from me add .opacity-100 to me"
" wait ms ms trigger sentinel:retry set ms to ms * 2 if ms > 30000 then set ms to 30000 end set me.dataset.retryMs to ms end"
" on htmx:sendError call backoff() on htmx:responseError call backoff() on htmx:timeout call backoff()"
)
return (
sx_call("sentinel-mobile", id=f"sentinel-{page}-m", next_url=next_url, hyperscript=mobile_hs)
+ " "
+ sx_call("sentinel-desktop", id=f"sentinel-{page}-d", next_url=next_url, hyperscript=desktop_hs)
)
def _blog_cards_sx(ctx: dict) -> str:
"""S-expression wire format for blog cards (client renders)."""
posts = ctx.get("posts") or []
view = ctx.get("view")
parts = []
for p in posts:
if view == "tile":
parts.append(_blog_card_tile_sx(p, ctx))
else:
parts.append(_blog_card_sx(p, ctx))
parts.append(_blog_sentinel_sx(ctx))
return "(<> " + " ".join(parts) + ")"
def _format_ts(dt) -> str:
if not dt:
return ""
return dt.strftime("%-d %b %Y at %H:%M") if hasattr(dt, "strftime") else str(dt)
def _tag_data(tags: list) -> list[dict]:
"""Extract pure data for tags."""
result = []
for t in tags:
name = t.get("name") or getattr(t, "name", "")
fi = t.get("feature_image") or getattr(t, "feature_image", None)
initial = (name[:1]) if name else ""
result.append({"name": name, "src": fi or "", "initial": initial})
return result
def _author_data(authors: list) -> list[dict]:
"""Extract pure data for authors."""
result = []
for a in authors:
name = a.get("name") or getattr(a, "name", "")
img = a.get("profile_image") or getattr(a, "profile_image", None)
result.append({"name": name, "image": img or ""})
return result
def _blog_card_sx(post: dict, ctx: dict) -> str:
"""Single blog card as sx call (wire format) — pure data, no HTML."""
from quart import g
slug = post.get("slug", "")
href = call_url(ctx, "blog_url", f"/{slug}/")
hx_select = ctx.get("hx_select_search", "#main-panel")
user = getattr(g, "user", None)
status = post.get("status", "published")
is_draft = status == "draft"
if is_draft:
status_timestamp = _format_ts(post.get("updated_at"))
else:
status_timestamp = _format_ts(post.get("published_at"))
fi = post.get("feature_image")
excerpt = post.get("custom_excerpt") or post.get("excerpt", "")
card_widgets = ctx.get("card_widgets_html") or {}
widget = card_widgets.get(str(post.get("id", "")), "")
tags = _tag_data(post.get("tags") or [])
authors = _author_data(post.get("authors") or [])
kwargs = dict(
slug=slug, href=href, hx_select=hx_select,
title=post.get("title", ""),
feature_image=fi, excerpt=excerpt,
is_draft=is_draft,
publish_requested=post.get("publish_requested", False) if is_draft else False,
status_timestamp=status_timestamp,
has_like=bool(user),
)
if user:
kwargs["liked"] = post.get("is_liked", False)
kwargs["like_url"] = call_url(ctx, "blog_url", f"/{slug}/like/toggle/")
kwargs["csrf_token"] = _ctx_csrf(ctx)
if tags:
kwargs["tags"] = tags
if authors:
kwargs["authors"] = authors
if widget:
kwargs["widget"] = SxExpr(widget) if widget else None
return sx_call("blog-card", **kwargs)
def _blog_card_tile_sx(post: dict, ctx: dict) -> str:
"""Single blog card tile as sx call (wire format) — pure data."""
slug = post.get("slug", "")
href = call_url(ctx, "blog_url", f"/{slug}/")
hx_select = ctx.get("hx_select_search", "#main-panel")
fi = post.get("feature_image")
status = post.get("status", "published")
is_draft = status == "draft"
if is_draft:
status_timestamp = _format_ts(post.get("updated_at"))
else:
status_timestamp = _format_ts(post.get("published_at"))
excerpt = post.get("custom_excerpt") or post.get("excerpt", "")
tags = _tag_data(post.get("tags") or [])
authors = _author_data(post.get("authors") or [])
kwargs = dict(
href=href, hx_select=hx_select, feature_image=fi,
title=post.get("title", ""),
is_draft=is_draft,
publish_requested=post.get("publish_requested", False) if is_draft else False,
status_timestamp=status_timestamp,
excerpt=excerpt,
)
if tags:
kwargs["tags"] = tags
if authors:
kwargs["authors"] = authors
return sx_call("blog-card-tile", **kwargs)
def _at_bar_sx(post: dict, ctx: dict) -> str:
"""Tags + authors bar below a card as sx."""
tags = post.get("tags") or []
authors = post.get("authors") or []
if not tags and not authors:
return ""
tag_data = [
{"src": t.get("feature_image") or getattr(t, "feature_image", None),
"name": t.get("name") or getattr(t, "name", ""),
"initial": (t.get("name") or getattr(t, "name", ""))[:1]}
for t in tags
] if tags else []
author_data = [
{"image": a.get("profile_image") or getattr(a, "profile_image", None),
"name": a.get("name") or getattr(a, "name", "")}
for a in authors
] if authors else []
return sx_call("blog-at-bar", tags=tag_data, authors=author_data)
def _page_cards_sx(ctx: dict) -> str:
"""Render page cards with sentinel (sx)."""
pages = ctx.get("pages") or ctx.get("posts") or []
page_num = ctx.get("page", 1)
total_pages = ctx.get("total_pages", 1)
if isinstance(total_pages, str):
total_pages = int(total_pages)
hx_select = ctx.get("hx_select_search", "#main-panel")
parts = []
for pg in pages:
parts.append(_page_card_sx(pg, ctx))
if page_num < total_pages:
current_local_href = ctx.get("current_local_href", "/index?type=pages")
next_url = f"{current_local_href}&page={page_num + 1}" if "?" in current_local_href else f"{current_local_href}?page={page_num + 1}"
parts.append(sx_call("sentinel-simple",
id=f"sentinel-{page_num}-d", next_url=next_url,
))
elif pages:
parts.append(sx_call("end-of-results"))
else:
parts.append(sx_call("blog-no-pages"))
return "(<> " + " ".join(parts) + ")" if parts else ""
def _page_card_sx(page: dict, ctx: dict) -> str:
"""Single page card as sx."""
slug = page.get("slug", "")
href = call_url(ctx, "blog_url", f"/{slug}/")
hx_select = ctx.get("hx_select_search", "#main-panel")
features = page.get("features") or {}
pub_timestamp = _format_ts(page.get("published_at"))
fi = page.get("feature_image")
excerpt = page.get("custom_excerpt") or page.get("excerpt", "")
return sx_call("blog-page-card",
href=href, hx_select=hx_select, title=page.get("title", ""),
has_calendar=features.get("calendar", False),
has_market=features.get("market", False),
pub_timestamp=pub_timestamp, feature_image=fi,
excerpt=excerpt,
)
def _view_toggle_sx(ctx: dict) -> str:
"""View toggle bar (list/tile) for desktop."""
view = ctx.get("view")
current_local_href = ctx.get("current_local_href", "/index")
hx_select = ctx.get("hx_select_search", "#main-panel")
list_cls = "bg-stone-200 text-stone-800" if view != "tile" else "text-stone-400 hover:text-stone-600"
tile_cls = "bg-stone-200 text-stone-800" if view == "tile" else "text-stone-400 hover:text-stone-600"
list_href = f"{current_local_href}"
tile_href = f"{current_local_href}{'&' if '?' in current_local_href else '?'}view=tile"
list_svg_sx = sx_call("list-svg")
tile_svg_sx = sx_call("tile-svg")
return sx_call("view-toggle",
list_href=list_href, tile_href=tile_href, hx_select=hx_select,
list_cls=list_cls, tile_cls=tile_cls, storage_key="blog_view",
list_svg=SxExpr(list_svg_sx), tile_svg=SxExpr(tile_svg_sx),
)
def _content_type_tabs_sx(ctx: dict) -> str:
"""Posts/Pages tabs."""
content_type = ctx.get("content_type", "posts")
hx_select = ctx.get("hx_select_search", "#main-panel")
posts_href = call_url(ctx, "blog_url", "/index")
pages_href = f"{posts_href}?type=pages"
posts_cls = "bg-stone-700 text-white" if content_type != "pages" else "bg-stone-100 text-stone-600 hover:bg-stone-200"
pages_cls = "bg-stone-700 text-white" if content_type == "pages" else "bg-stone-100 text-stone-600 hover:bg-stone-200"
return sx_call("blog-content-type-tabs",
posts_href=posts_href, pages_href=pages_href, hx_select=hx_select,
posts_cls=posts_cls, pages_cls=pages_cls,
)
def _blog_main_panel_sx(ctx: dict) -> str:
"""Blog index main panel with tabs, toggle, and cards."""
content_type = ctx.get("content_type", "posts")
view = ctx.get("view")
tabs = _content_type_tabs_sx(ctx)
if content_type == "pages":
cards = _page_cards_sx(ctx)
return sx_call("blog-main-panel-pages",
tabs=SxExpr(tabs), cards=SxExpr(cards),
)
else:
toggle = _view_toggle_sx(ctx)
grid_cls = "max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4" if view == "tile" else "max-w-full px-3 py-3 space-y-3"
cards = _blog_cards_sx(ctx)
return sx_call("blog-main-panel-posts",
tabs=SxExpr(tabs), toggle=SxExpr(toggle), grid_cls=grid_cls,
cards=SxExpr(cards),
)
# ---------------------------------------------------------------------------
# Desktop aside (filter sidebar)
# ---------------------------------------------------------------------------
def _blog_aside_sx(ctx: dict) -> str:
"""Desktop aside with search, action buttons, and filters."""
sd = search_desktop_sx(ctx)
ab = _action_buttons_sx(ctx)
tgf = _tag_groups_filter_sx(ctx)
af = _authors_filter_sx(ctx)
return sx_call("blog-aside",
search=SxExpr(sd), action_buttons=SxExpr(ab),
tag_groups_filter=SxExpr(tgf), authors_filter=SxExpr(af),
)
def _blog_filter_sx(ctx: dict) -> str:
"""Mobile filter (details/summary)."""
# Mobile filter summary tags
summary_parts = []
tg_summary = _tag_groups_filter_summary_sx(ctx)
au_summary = _authors_filter_summary_sx(ctx)
if tg_summary:
summary_parts.append(tg_summary)
if au_summary:
summary_parts.append(au_summary)
search_sx = search_mobile_sx(ctx)
if summary_parts:
filter_content = "(<> " + search_sx + " " + " ".join(summary_parts) + ")"
else:
filter_content = search_sx
action_buttons = _action_buttons_sx(ctx)
tgf = _tag_groups_filter_sx(ctx)
af = _authors_filter_sx(ctx)
filter_details = "(<> " + tgf + " " + af + ")"
return sx_call("mobile-filter",
filter_summary=SxExpr(filter_content),
action_buttons=SxExpr(action_buttons),
filter_details=SxExpr(filter_details),
)
def _action_buttons_sx(ctx: dict) -> str:
"""New Post/Page + Drafts toggle buttons (sx)."""
from quart import g
rights = ctx.get("rights") or {}
has_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
user = getattr(g, "user", None)
hx_select = ctx.get("hx_select_search", "#main-panel")
drafts = ctx.get("drafts")
draft_count = ctx.get("draft_count", 0)
current_local_href = ctx.get("current_local_href", "/index")
parts = []
if has_admin:
new_href = call_url(ctx, "blog_url", "/new/")
parts.append(sx_call("blog-action-button",
href=new_href, hx_select=hx_select,
btn_class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors",
title="New Post", icon_class="fa fa-plus mr-1", label=" New Post",
))
new_page_href = call_url(ctx, "blog_url", "/new-page/")
parts.append(sx_call("blog-action-button",
href=new_page_href, hx_select=hx_select,
btn_class="px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors",
title="New Page", icon_class="fa fa-plus mr-1", label=" New Page",
))
if user and (draft_count or drafts):
if drafts:
off_href = f"{current_local_href}"
parts.append(sx_call("blog-drafts-button",
href=off_href, hx_select=hx_select,
btn_class="px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors",
title="Hide Drafts", label=" Drafts ", draft_count=str(draft_count),
))
else:
on_href = f"{current_local_href}{'&' if '?' in current_local_href else '?'}drafts=1"
parts.append(sx_call("blog-drafts-button-amber",
href=on_href, hx_select=hx_select,
btn_class="px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors",
title="Show Drafts", label=" Drafts ", draft_count=str(draft_count),
))
inner = "(<> " + " ".join(parts) + ")" if parts else ""
return sx_call("blog-action-buttons-wrapper",
inner=SxExpr(inner) if inner else None,
)
def _tag_groups_filter_sx(ctx: dict) -> str:
"""Tag group filter bar as sx."""
tag_groups = ctx.get("tag_groups") or []
selected_groups = ctx.get("selected_groups") or ()
selected_tags = ctx.get("selected_tags") or ()
hx_select = ctx.get("hx_select_search", "#main-panel")
is_any = len(selected_groups) == 0 and len(selected_tags) == 0
any_cls = "bg-stone-900 text-white border-stone-900" if is_any else "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"
li_parts = [sx_call("blog-filter-any-topic", cls=any_cls, hx_select=hx_select)]
for group in tag_groups:
g_slug = getattr(group, "slug", "") if hasattr(group, "slug") else group.get("slug", "")
g_name = getattr(group, "name", "") if hasattr(group, "name") else group.get("name", "")
g_fi = getattr(group, "feature_image", None) if hasattr(group, "feature_image") else group.get("feature_image")
g_colour = getattr(group, "colour", None) if hasattr(group, "colour") else group.get("colour")
g_count = getattr(group, "post_count", 0) if hasattr(group, "post_count") else group.get("post_count", 0)
if g_count <= 0 and g_slug not in selected_groups:
continue
is_on = g_slug in selected_groups
cls = "bg-stone-900 text-white border-stone-900" if is_on else "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"
if g_fi:
icon = sx_call("blog-filter-group-icon-image", src=g_fi, name=g_name)
else:
style = f"background-color: {g_colour}; color: white;" if g_colour else "background-color: #e7e5e4; color: #57534e;"
icon = sx_call("blog-filter-group-icon-color", style=style, initial=g_name[:1])
li_parts.append(sx_call("blog-filter-group-li",
cls=cls, hx_get=f"?group={g_slug}&page=1", hx_select=hx_select,
icon=SxExpr(icon), name=g_name, count=str(g_count),
))
items = "(<> " + " ".join(li_parts) + ")"
return sx_call("blog-filter-nav", items=SxExpr(items))
def _authors_filter_sx(ctx: dict) -> str:
"""Author filter bar as sx."""
authors = ctx.get("authors") or []
selected_authors = ctx.get("selected_authors") or ()
hx_select = ctx.get("hx_select_search", "#main-panel")
is_any = len(selected_authors) == 0
any_cls = "bg-stone-900 text-white border-stone-900" if is_any else "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"
li_parts = [sx_call("blog-filter-any-author", cls=any_cls, hx_select=hx_select)]
for author in authors:
a_slug = getattr(author, "slug", "") if hasattr(author, "slug") else author.get("slug", "")
a_name = getattr(author, "name", "") if hasattr(author, "name") else author.get("name", "")
a_img = getattr(author, "profile_image", None) if hasattr(author, "profile_image") else author.get("profile_image")
a_count = getattr(author, "published_post_count", 0) if hasattr(author, "published_post_count") else author.get("published_post_count", 0)
is_on = a_slug in selected_authors
cls = "bg-stone-900 text-white border-stone-900" if is_on else "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"
icon_sx = None
if a_img:
icon_sx = sx_call("blog-filter-author-icon", src=a_img, name=a_name)
li_parts.append(sx_call("blog-filter-author-li",
cls=cls, hx_get=f"?author={a_slug}&page=1", hx_select=hx_select,
icon=SxExpr(icon_sx) if icon_sx else None, name=a_name, count=str(a_count),
))
items = "(<> " + " ".join(li_parts) + ")"
return sx_call("blog-filter-nav", items=SxExpr(items))
def _tag_groups_filter_summary_sx(ctx: dict) -> str:
"""Mobile filter summary for tag groups (sx)."""
selected_groups = ctx.get("selected_groups") or ()
tag_groups = ctx.get("tag_groups") or []
if not selected_groups:
return ""
names = []
for g in tag_groups:
g_slug = getattr(g, "slug", "") if hasattr(g, "slug") else g.get("slug", "")
g_name = getattr(g, "name", "") if hasattr(g, "name") else g.get("name", "")
if g_slug in selected_groups:
names.append(g_name)
if not names:
return ""
return sx_call("blog-filter-summary", text=", ".join(names))
def _authors_filter_summary_sx(ctx: dict) -> str:
"""Mobile filter summary for authors (sx)."""
selected_authors = ctx.get("selected_authors") or ()
authors = ctx.get("authors") or []
if not selected_authors:
return ""
names = []
for a in authors:
a_slug = getattr(a, "slug", "") if hasattr(a, "slug") else a.get("slug", "")
a_name = getattr(a, "name", "") if hasattr(a, "name") else a.get("name", "")
if a_slug in selected_authors:
names.append(a_name)
if not names:
return ""
return sx_call("blog-filter-summary", text=", ".join(names))
# ---------------------------------------------------------------------------
# Post detail main panel
# ---------------------------------------------------------------------------
def _post_main_panel_sx(ctx: dict) -> str:
"""Post/page article content."""
from quart import g, url_for as qurl
post = ctx.get("post") or {}
slug = post.get("slug", "")
user = getattr(g, "user", None)
rights = ctx.get("rights") or {}
is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
hx_select = ctx.get("hx_select_search", "#main-panel")
# Draft indicator
draft_sx = ""
if post.get("status") == "draft":
edit_sx = ""
if is_admin or (user and post.get("user_id") == getattr(user, "id", None)):
edit_href = qurl("blog.post.admin.edit", slug=slug)
edit_sx = sx_call("blog-detail-edit-link",
href=edit_href, hx_select=hx_select,
)
draft_sx = sx_call("blog-detail-draft",
publish_requested=post.get("publish_requested"),
edit=SxExpr(edit_sx) if edit_sx else None,
)
# Blog post chrome (not for pages)
chrome_sx = ""
if not post.get("is_page"):
like_sx = ""
if user:
liked = post.get("is_liked", False)
like_url = call_url(ctx, "blog_url", f"/{slug}/like/toggle/")
like_sx = sx_call("blog-detail-like",
like_url=like_url,
hx_headers=f'{{"X-CSRFToken": "{_ctx_csrf(ctx)}"}}',
heart="\u2764\ufe0f" if liked else "\U0001f90d",
)
excerpt_sx = ""
if post.get("custom_excerpt"):
excerpt_sx = sx_call("blog-detail-excerpt",
excerpt=post["custom_excerpt"],
)
at_bar = _at_bar_sx(post, ctx)
chrome_sx = sx_call("blog-detail-chrome",
like=SxExpr(like_sx) if like_sx else None,
excerpt=SxExpr(excerpt_sx) if excerpt_sx else None,
at_bar=SxExpr(at_bar) if at_bar else None,
)
fi = post.get("feature_image")
html_content = post.get("html", "")
return sx_call("blog-detail-main",
draft=SxExpr(draft_sx) if draft_sx else None,
chrome=SxExpr(chrome_sx) if chrome_sx else None,
feature_image=fi, html_content=html_content,
)
def _post_meta_sx(ctx: dict) -> str:
"""Post SEO meta tags as sx (auto-hoisted to <head> by sx.js)."""
post = ctx.get("post") or {}
base_title = ctx.get("base_title", "")
is_public = post.get("visibility") == "public"
is_published = post.get("status") == "published"
email_only = post.get("email_only", False)
robots = "index,follow" if (is_public and is_published and not email_only) else "noindex,nofollow"
# Description
desc = (post.get("meta_description") or post.get("og_description") or
post.get("twitter_description") or post.get("custom_excerpt") or
post.get("excerpt") or "")
if not desc and post.get("html"):
import re
desc = re.sub(r'<[^>]+>', '', post["html"])
desc = desc.replace("\n", " ").replace("\r", " ").strip()[:160]
# Image
image = (post.get("og_image") or post.get("twitter_image") or post.get("feature_image") or "")
# Canonical
from quart import request as req
canonical = post.get("canonical_url") or (req.url if req else "")
post_title = post.get("meta_title") or post.get("title") or ""
page_title = f"{post_title} \u2014 {base_title}" if post_title else base_title
og_title = post.get("og_title") or page_title
tw_title = post.get("twitter_title") or page_title
is_article = not post.get("is_page")
return sx_call("blog-meta",
robots=robots, page_title=page_title, desc=desc, canonical=canonical,
og_type="article" if is_article else "website",
og_title=og_title, image=image,
twitter_card="summary_large_image" if image else "summary",
twitter_title=tw_title,
)
# ---------------------------------------------------------------------------
# Home page (Ghost "home" page)
# ---------------------------------------------------------------------------
def _home_main_panel_sx(ctx: dict) -> str:
"""Home page content — renders the Ghost page HTML."""
post = ctx.get("post") or {}
html = post.get("html", "")
return sx_call("blog-home-main", html_content=html)
# ---------------------------------------------------------------------------
# Post admin - empty main panel
# ---------------------------------------------------------------------------
def _post_admin_main_panel_sx(ctx: dict) -> str:
return '(div :class "pb-8")'
# ---------------------------------------------------------------------------
# Settings main panels
# ---------------------------------------------------------------------------
def _settings_main_panel_sx(ctx: dict) -> str:
return '(div :class "max-w-2xl mx-auto px-4 py-6")'
def _cache_main_panel_sx(ctx: dict) -> str:
from quart import url_for as qurl
csrf = _ctx_csrf(ctx)
clear_url = qurl("settings.cache_clear")
return sx_call("blog-cache-panel", clear_url=clear_url, csrf=csrf)
# ---------------------------------------------------------------------------
# Snippets main panel
# ---------------------------------------------------------------------------
def _snippets_main_panel_sx(ctx: dict) -> str:
sl = _snippets_list_sx(ctx)
return sx_call("blog-snippets-panel", list=SxExpr(sl))
def _snippets_list_sx(ctx: dict) -> str:
"""Snippets list with visibility badges and delete buttons."""
from quart import url_for as qurl, g
snippets = ctx.get("snippets") or []
is_admin = ctx.get("is_admin", False)
csrf = _ctx_csrf(ctx)
user = getattr(g, "user", None)
user_id = getattr(user, "id", None)
if not snippets:
return sx_call("empty-state", icon="fa fa-puzzle-piece", message="No snippets yet. Create one from the blog editor.")
badge_colours = {
"private": "bg-stone-200 text-stone-700",
"shared": "bg-blue-100 text-blue-700",
"admin": "bg-amber-100 text-amber-700",
}
row_parts = []
for s in snippets:
s_id = getattr(s, "id", None) or s.get("id")
s_name = getattr(s, "name", "") if hasattr(s, "name") else s.get("name", "")
s_uid = getattr(s, "user_id", None) if hasattr(s, "user_id") else s.get("user_id")
s_vis = getattr(s, "visibility", "private") if hasattr(s, "visibility") else s.get("visibility", "private")
owner = "You" if s_uid == user_id else f"User #{s_uid}"
badge_cls = badge_colours.get(s_vis, "bg-stone-200 text-stone-700")
extra = ""
if is_admin:
patch_url = qurl("snippets.patch_visibility", snippet_id=s_id)
opts = ""
for v in ["private", "shared", "admin"]:
opts += sx_call("blog-snippet-option",
value=v, selected=(s_vis == v), label=v,
)
extra += sx_call("blog-snippet-visibility-select",
patch_url=patch_url,
hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
options=SxExpr("(<> " + opts + ")") if opts else None,
cls="text-sm border border-stone-300 rounded px-2 py-1",
)
if s_uid == user_id or is_admin:
del_url = qurl("snippets.delete_snippet", snippet_id=s_id)
extra += sx_call("delete-btn",
url=del_url, trigger_target="#snippets-list",
title="Delete snippet?",
text=f'Delete \u201c{s_name}\u201d?',
sx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
cls="px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0",
)
row_parts.append(sx_call("blog-snippet-row",
name=s_name, owner=owner, badge_cls=badge_cls,
visibility=s_vis, extra=SxExpr("(<> " + extra + ")") if extra else None,
))
rows = "(<> " + " ".join(row_parts) + ")"
return sx_call("blog-snippets-list", rows=SxExpr(rows))
# ---------------------------------------------------------------------------
# Menu items main panel
# ---------------------------------------------------------------------------
def _menu_items_main_panel_sx(ctx: dict) -> str:
from quart import url_for as qurl
new_url = qurl("menu_items.new_menu_item")
ml = _menu_items_list_sx(ctx)
return sx_call("blog-menu-items-panel", new_url=new_url, list=SxExpr(ml))
def _menu_items_list_sx(ctx: dict) -> str:
from quart import url_for as qurl
menu_items = ctx.get("menu_items") or []
csrf = _ctx_csrf(ctx)
if not menu_items:
return sx_call("empty-state", icon="fa fa-inbox", message="No menu items yet. Add one to get started!")
row_parts = []
for item in menu_items:
i_id = getattr(item, "id", None) or item.get("id")
label = getattr(item, "label", "") if hasattr(item, "label") else item.get("label", "")
slug = getattr(item, "slug", "") if hasattr(item, "slug") else item.get("slug", "")
fi = getattr(item, "feature_image", None) if hasattr(item, "feature_image") else item.get("feature_image")
sort = getattr(item, "sort_order", 0) if hasattr(item, "sort_order") else item.get("sort_order", 0)
edit_url = qurl("menu_items.edit_menu_item", item_id=i_id)
del_url = qurl("menu_items.delete_menu_item_route", item_id=i_id)
img_sx = sx_call("img-or-placeholder", src=fi, alt=label,
size_cls="w-12 h-12 rounded-full object-cover flex-shrink-0")
row_parts.append(sx_call("blog-menu-item-row",
img=SxExpr(img_sx), label=label, slug=slug,
sort_order=str(sort), edit_url=edit_url, delete_url=del_url,
confirm_text=f"Remove {label} from the menu?",
hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
))
rows = "(<> " + " ".join(row_parts) + ")"
return sx_call("blog-menu-items-list", rows=SxExpr(rows))
# ---------------------------------------------------------------------------
# Tag groups main panel
# ---------------------------------------------------------------------------
def _tag_groups_main_panel_sx(ctx: dict) -> str:
from quart import url_for as qurl
groups = ctx.get("groups") or []
unassigned_tags = ctx.get("unassigned_tags") or []
csrf = _ctx_csrf(ctx)
create_url = qurl("blog.tag_groups_admin.create")
form_sx = sx_call("blog-tag-groups-create-form",
create_url=create_url, csrf=csrf,
)
# Groups list
groups_html = ""
if groups:
li_parts = []
for group in groups:
g_id = getattr(group, "id", None) or group.get("id")
g_name = getattr(group, "name", "") if hasattr(group, "name") else group.get("name", "")
g_slug = getattr(group, "slug", "") if hasattr(group, "slug") else group.get("slug", "")
g_fi = getattr(group, "feature_image", None) if hasattr(group, "feature_image") else group.get("feature_image")
g_colour = getattr(group, "colour", None) if hasattr(group, "colour") else group.get("colour")
g_sort = getattr(group, "sort_order", 0) if hasattr(group, "sort_order") else group.get("sort_order", 0)
edit_href = qurl("blog.tag_groups_admin.edit", id=g_id)
if g_fi:
icon = sx_call("blog-tag-group-icon-image", src=g_fi, name=g_name)
else:
style = f"background-color: {g_colour}; color: white;" if g_colour else "background-color: #e7e5e4; color: #57534e;"
icon = sx_call("blog-tag-group-icon-color", style=style, initial=g_name[:1])
li_parts.append(sx_call("blog-tag-group-li",
icon=SxExpr(icon), edit_href=edit_href, name=g_name,
slug=g_slug, sort_order=str(g_sort),
))
groups_sx = sx_call("blog-tag-groups-list", items=SxExpr("(<> " + " ".join(li_parts) + ")"))
else:
groups_sx = sx_call("empty-state", message="No tag groups yet.", cls="text-stone-500 text-sm")
# Unassigned tags
unassigned_sx = ""
if unassigned_tags:
tag_spans = []
for tag in unassigned_tags:
t_name = getattr(tag, "name", "") if hasattr(tag, "name") else tag.get("name", "")
tag_spans.append(sx_call("blog-unassigned-tag", name=t_name))
unassigned_sx = sx_call("blog-unassigned-tags",
heading=f"Unassigned Tags ({len(unassigned_tags)})",
spans=SxExpr("(<> " + " ".join(tag_spans) + ")"),
)
return sx_call("blog-tag-groups-main",
form=SxExpr(form_sx),
groups=SxExpr(groups_sx),
unassigned=SxExpr(unassigned_sx) if unassigned_sx else None,
)
def _tag_groups_edit_main_panel_sx(ctx: dict) -> str:
from quart import url_for as qurl
group = ctx.get("group")
all_tags = ctx.get("all_tags") or []
assigned_tag_ids = ctx.get("assigned_tag_ids") or set()
csrf = _ctx_csrf(ctx)
g_id = getattr(group, "id", None) or group.get("id") if group else None
g_name = getattr(group, "name", "") if hasattr(group, "name") else (group.get("name", "") if group else "")
g_colour = getattr(group, "colour", "") if hasattr(group, "colour") else (group.get("colour", "") if group else "")
g_sort = getattr(group, "sort_order", 0) if hasattr(group, "sort_order") else (group.get("sort_order", 0) if group else 0)
g_fi = getattr(group, "feature_image", "") if hasattr(group, "feature_image") else (group.get("feature_image", "") if group else "")
save_url = qurl("blog.tag_groups_admin.save", id=g_id)
del_url = qurl("blog.tag_groups_admin.delete_group", id=g_id)
# Tag checkboxes
tag_items = []
for tag in all_tags:
t_id = getattr(tag, "id", None) or tag.get("id")
t_name = getattr(tag, "name", "") if hasattr(tag, "name") else tag.get("name", "")
t_fi = getattr(tag, "feature_image", None) if hasattr(tag, "feature_image") else tag.get("feature_image")
checked = t_id in assigned_tag_ids
img = sx_call("blog-tag-checkbox-image", src=t_fi) if t_fi else ""
tag_items.append(sx_call("blog-tag-checkbox",
tag_id=str(t_id), checked=checked,
img=SxExpr(img) if img else None, name=t_name,
))
edit_form = sx_call("blog-tag-group-edit-form",
save_url=save_url, csrf=csrf,
name=g_name, colour=g_colour or "", sort_order=str(g_sort),
feature_image=g_fi or "",
tags=SxExpr("(<> " + " ".join(tag_items) + ")"),
)
del_form = sx_call("blog-tag-group-delete-form",
delete_url=del_url, csrf=csrf,
)
return sx_call("blog-tag-group-edit-main",
edit_form=SxExpr(edit_form), delete_form=SxExpr(del_form),
)
# ---------------------------------------------------------------------------
# New post/page main panel — left as render_template (uses Koenig editor JS)
# Post edit main panel — left as render_template (uses Koenig editor JS)
# Post settings main panel — left as render_template (complex form macros)
# Post entries main panel — left as render_template (calendar browser lazy-loads)
# Post data main panel — left as render_template (uses ORM introspection macros)
# ---------------------------------------------------------------------------
# ===========================================================================
# PUBLIC API — called from route handlers
# ===========================================================================
# ---- Home page ----
async def render_home_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
header_rows = "(<> " + root_hdr + " " + post_hdr + ")"
content = _home_main_panel_sx(ctx)
meta = _post_meta_sx(ctx)
menu = ctx.get("nav_sx", "") or ""
return full_page_sx(ctx, header_rows=header_rows, content=content,
meta=meta, menu=menu)
async def render_home_oob(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
rows = "(<> " + root_hdr + " " + post_hdr + ")"
header_oob = _oob_header_sx("root-header-child", "post-header-child", rows)
content = _home_main_panel_sx(ctx)
return oob_page_sx(oobs=header_oob, content=content)
# ---- Blog index ----
async def render_blog_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
blog_hdr = _blog_header_sx(ctx)
header_rows = "(<> " + root_hdr + " " + blog_hdr + ")"
content = _blog_main_panel_sx(ctx)
aside = _blog_aside_sx(ctx)
filter_sx = _blog_filter_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content,
aside=aside, filter=filter_sx)
async def render_blog_oob(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
blog_hdr = _blog_header_sx(ctx)
rows = "(<> " + root_hdr + " " + blog_hdr + ")"
header_oob = _oob_header_sx("root-header-child", "blog-header-child", rows)
content = _blog_main_panel_sx(ctx)
aside = _blog_aside_sx(ctx)
filter_sx = _blog_filter_sx(ctx)
nav = ctx.get("nav_sx", "") or ""
return oob_page_sx(oobs=header_oob, content=content, aside=aside,
filter=filter_sx, menu=nav)
async def render_blog_cards(ctx: dict) -> str:
"""Pagination-only response (page > 1) — sx wire format."""
return _blog_cards_sx(ctx)
async def render_blog_page_cards(ctx: dict) -> str:
"""Page cards pagination response."""
return _page_cards_sx(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(sx_call("blog-editor-error", error=str(save_error)))
# Form structure
form_html = sx_call("blog-editor-form",
csrf=csrf, title_placeholder=title_placeholder,
create_label=create_label,
)
parts.append(form_html)
# Editor CSS + inline styles
parts.append(sx_call("blog-editor-styles", css_href=editor_css))
# Editor JS + init script
init_js = (
"console.log('[EDITOR-DEBUG] init script running');\n"
"(function() {\n"
" console.log('[EDITOR-DEBUG] IIFE entered, mountEditor=', typeof window.mountEditor);\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"
)
parts.append(sx_call("blog-editor-scripts", js_src=editor_js, init_js=init_js))
return "(<> " + " ".join(parts) + ")" if parts else ""
# ---- New post/page ----
async def render_new_post_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
blog_hdr = _blog_header_sx(ctx)
header_rows = "(<> " + root_hdr + " " + blog_hdr + ")"
content = ctx.get("editor_html", "")
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_new_post_oob(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
blog_hdr = _blog_header_sx(ctx)
rows = "(<> " + root_hdr + " " + blog_hdr + ")"
header_oob = _oob_header_sx("root-header-child", "blog-header-child", rows)
content = ctx.get("editor_html", "")
return oob_page_sx(oobs=header_oob, content=content)
# ---- Post detail ----
async def render_post_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
header_rows = "(<> " + root_hdr + " " + post_hdr + ")"
content = _post_main_panel_sx(ctx)
meta = _post_meta_sx(ctx)
menu = ctx.get("nav_sx", "") or ""
return full_page_sx(ctx, header_rows=header_rows, content=content,
meta=meta, menu=menu)
async def render_post_oob(ctx: dict) -> str:
root_hdr = root_header_sx(ctx) # non-OOB (nested inside root-header-child)
post_hdr = _post_header_sx(ctx)
rows = "(<> " + root_hdr + " " + post_hdr + ")"
post_oob = _oob_header_sx("root-header-child", "post-header-child", rows)
content = _post_main_panel_sx(ctx)
menu = ctx.get("nav_sx", "") or ""
oobs = post_oob
return oob_page_sx(oobs=oobs, content=content, menu=menu)
# ---- Post admin ----
async def render_post_admin_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = _post_admin_header_sx(ctx)
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
content = _post_admin_main_panel_sx(ctx)
menu = ctx.get("nav_sx", "") or ""
return full_page_sx(ctx, header_rows=header_rows, content=content,
menu=menu)
async def render_post_admin_oob(ctx: dict) -> str:
post_hdr_oob = _post_header_sx(ctx, oob=True)
admin_oob = _oob_header_sx("post-header-child", "post-admin-header-child",
_post_admin_header_sx(ctx))
content = _post_admin_main_panel_sx(ctx)
menu = ctx.get("nav_sx", "") or ""
oobs = "(<> " + post_hdr_oob + " " + admin_oob + ")"
return oob_page_sx(oobs=oobs, content=content, menu=menu)
# ---- Post data ----
async def render_post_data_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = _post_admin_header_sx(ctx, selected="data")
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
content = _raw_html_sx(ctx.get("data_html", ""))
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_post_data_oob(ctx: dict) -> str:
admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="data")
content = _raw_html_sx(ctx.get("data_html", ""))
return oob_page_sx(oobs=admin_hdr_oob, content=content)
# ---- Post entries ----
async def render_post_entries_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = _post_admin_header_sx(ctx, selected="entries")
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
content = _raw_html_sx(ctx.get("entries_html", ""))
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_post_entries_oob(ctx: dict) -> str:
admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="entries")
content = _raw_html_sx(ctx.get("entries_html", ""))
return oob_page_sx(oobs=admin_hdr_oob, content=content)
# ---- Post edit ----
def _raw_html_sx(html: str) -> str:
"""Wrap raw HTML in (raw! "...") so it's valid inside sx source."""
if not html:
return ""
return "(raw! " + sx_serialize(html) + ")"
async def render_post_edit_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = _post_admin_header_sx(ctx, selected="edit")
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
content = _raw_html_sx(ctx.get("edit_html", ""))
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_post_edit_oob(ctx: dict) -> str:
admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="edit")
content = _raw_html_sx(ctx.get("edit_html", ""))
return oob_page_sx(oobs=admin_hdr_oob, content=content)
# ---- Post settings ----
async def render_post_settings_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
post_hdr = _post_header_sx(ctx)
admin_hdr = _post_admin_header_sx(ctx, selected="settings")
header_rows = "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
content = _raw_html_sx(ctx.get("settings_html", ""))
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_post_settings_oob(ctx: dict) -> str:
admin_hdr_oob = _post_admin_header_sx(ctx, oob=True, selected="settings")
content = _raw_html_sx(ctx.get("settings_html", ""))
return oob_page_sx(oobs=admin_hdr_oob, content=content)
# ---- Settings home ----
async def render_settings_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
header_rows = "(<> " + root_hdr + " " + settings_hdr + ")"
content = _settings_main_panel_sx(ctx)
menu = _settings_nav_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content,
menu=menu)
async def render_settings_oob(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
rows = "(<> " + root_hdr + " " + settings_hdr + ")"
header_oob = _oob_header_sx("root-header-child", "root-settings-header-child", rows)
content = _settings_main_panel_sx(ctx)
menu = _settings_nav_sx(ctx)
return oob_page_sx(oobs=header_oob, content=content, menu=menu)
# ---- Cache ----
async def render_cache_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
from quart import url_for as qurl
cache_hdr = _sub_settings_header_sx(
"cache-row", "cache-header-child",
qurl("settings.cache"), "refresh", "Cache", ctx,
)
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + cache_hdr + ")"
content = _cache_main_panel_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_cache_oob(ctx: dict) -> str:
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
from quart import url_for as qurl
cache_hdr = _sub_settings_header_sx(
"cache-row", "cache-header-child",
qurl("settings.cache"), "refresh", "Cache", ctx,
)
cache_oob = _oob_header_sx("root-settings-header-child", "cache-header-child",
cache_hdr)
content = _cache_main_panel_sx(ctx)
oobs = "(<> " + settings_hdr_oob + " " + cache_oob + ")"
return oob_page_sx(oobs=oobs, content=content)
# ---- Snippets ----
async def render_snippets_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
from quart import url_for as qurl
snippets_hdr = _sub_settings_header_sx(
"snippets-row", "snippets-header-child",
qurl("snippets.list_snippets"), "puzzle-piece", "Snippets", ctx,
)
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + snippets_hdr + ")"
content = _snippets_main_panel_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_snippets_oob(ctx: dict) -> str:
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
from quart import url_for as qurl
snippets_hdr = _sub_settings_header_sx(
"snippets-row", "snippets-header-child",
qurl("snippets.list_snippets"), "puzzle-piece", "Snippets", ctx,
)
snippets_oob = _oob_header_sx("root-settings-header-child", "snippets-header-child",
snippets_hdr)
content = _snippets_main_panel_sx(ctx)
oobs = "(<> " + settings_hdr_oob + " " + snippets_oob + ")"
return oob_page_sx(oobs=oobs, content=content)
# ---- Menu items ----
async def render_menu_items_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
from quart import url_for as qurl
mi_hdr = _sub_settings_header_sx(
"menu_items-row", "menu_items-header-child",
qurl("menu_items.list_menu_items"), "bars", "Menu Items", ctx,
)
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + mi_hdr + ")"
content = _menu_items_main_panel_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_menu_items_oob(ctx: dict) -> str:
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
from quart import url_for as qurl
mi_hdr = _sub_settings_header_sx(
"menu_items-row", "menu_items-header-child",
qurl("menu_items.list_menu_items"), "bars", "Menu Items", ctx,
)
mi_oob = _oob_header_sx("root-settings-header-child", "menu_items-header-child",
mi_hdr)
content = _menu_items_main_panel_sx(ctx)
oobs = "(<> " + settings_hdr_oob + " " + mi_oob + ")"
return oob_page_sx(oobs=oobs, content=content)
# ---- Tag groups ----
async def render_tag_groups_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
from quart import url_for as qurl
tg_hdr = _sub_settings_header_sx(
"tag-groups-row", "tag-groups-header-child",
qurl("blog.tag_groups_admin.index"), "tags", "Tag Groups", ctx,
)
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + tg_hdr + ")"
content = _tag_groups_main_panel_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_tag_groups_oob(ctx: dict) -> str:
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
from quart import url_for as qurl
tg_hdr = _sub_settings_header_sx(
"tag-groups-row", "tag-groups-header-child",
qurl("blog.tag_groups_admin.index"), "tags", "Tag Groups", ctx,
)
tg_oob = _oob_header_sx("root-settings-header-child", "tag-groups-header-child",
tg_hdr)
content = _tag_groups_main_panel_sx(ctx)
oobs = "(<> " + settings_hdr_oob + " " + tg_oob + ")"
return oob_page_sx(oobs=oobs, content=content)
# ---- Tag group edit ----
async def render_tag_group_edit_page(ctx: dict) -> str:
root_hdr = root_header_sx(ctx)
settings_hdr = _settings_header_sx(ctx)
from quart import url_for as qurl
g_id = (ctx.get("group") or {}).get("id") or getattr(ctx.get("group"), "id", None)
tg_hdr = _sub_settings_header_sx(
"tag-groups-row", "tag-groups-header-child",
qurl("blog.tag_groups_admin.edit", id=g_id), "tags", "Tag Groups", ctx,
)
header_rows = "(<> " + root_hdr + " " + settings_hdr + " " + tg_hdr + ")"
content = _tag_groups_edit_main_panel_sx(ctx)
return full_page_sx(ctx, header_rows=header_rows, content=content)
async def render_tag_group_edit_oob(ctx: dict) -> str:
settings_hdr_oob = _settings_header_sx(ctx, oob=True)
from quart import url_for as qurl
g_id = (ctx.get("group") or {}).get("id") or getattr(ctx.get("group"), "id", None)
tg_hdr = _sub_settings_header_sx(
"tag-groups-row", "tag-groups-header-child",
qurl("blog.tag_groups_admin.edit", id=g_id), "tags", "Tag Groups", ctx,
)
tg_oob = _oob_header_sx("root-settings-header-child", "tag-groups-header-child",
tg_hdr)
content = _tag_groups_edit_main_panel_sx(ctx)
oobs = "(<> " + settings_hdr_oob + " " + tg_oob + ")"
return oob_page_sx(oobs=oobs, content=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.sx.sx_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_sx(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_sx(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 sx_call("blog-nav-empty", wrapper_id="menu-items-nav-wrapper")
# 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}"
scroll_hs = (
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"
)
blog_url_fn = ctx.get("blog_url")
cart_url_fn = ctx.get("cart_url")
app_name = ctx.get("app_name", "")
item_parts = []
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")
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"
img_sx = sx_call("img-or-placeholder", src=fi, alt=label,
size_cls="w-8 h-8 rounded-full object-cover flex-shrink-0")
if item_slug != "cart":
item_parts.append(sx_call("blog-nav-item-link",
href=href, hx_get=f"/{item_slug}/", selected=selected,
nav_cls=nav_button_cls, img=SxExpr(img_sx), label=label,
))
else:
item_parts.append(sx_call("blog-nav-item-plain",
href=href, selected=selected, nav_cls=nav_button_cls,
img=SxExpr(img_sx), label=label,
))
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""
return sx_call("scroll-nav-wrapper",
wrapper_id="menu-items-nav-wrapper", container_id=container_id,
arrow_cls=arrow_cls,
left_hs=f"on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft - 200",
scroll_hs=scroll_hs,
right_hs=f"on click set #{container_id}.scrollLeft to #{container_id}.scrollLeft + 200",
items=SxExpr(items_sx) if items_sx else None, oob=True,
)
# ---- 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))
hs_trigger = "on change trigger submit on closest <form/>"
form_sx = sx_call("blog-features-form",
features_url=features_url,
calendar_checked=bool(features.get("calendar")),
market_checked=bool(features.get("market")),
hs_trigger=hs_trigger,
)
sumup_sx = ""
if features.get("calendar") or features.get("market"):
placeholder = "\u2022" * 8 if sumup_configured else "sup_sk_..."
sumup_sx = sx_call("blog-sumup-form",
sumup_url=sumup_url, merchant_code=sumup_merchant_code,
placeholder=placeholder,
sumup_configured=sumup_configured,
checkout_prefix=sumup_checkout_prefix,
)
return sx_call("blog-features-panel",
form=SxExpr(form_sx),
sumup=SxExpr(sumup_sx) if sumup_sx else None,
)
# ---- 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))
list_sx = ""
if markets:
li_parts = []
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))
li_parts.append(sx_call("blog-market-item",
name=m_name, slug=m_slug, delete_url=del_url,
confirm_text=f"Delete market '{m_name}'?",
))
list_sx = sx_call("blog-markets-list", items=SxExpr("(<> " + " ".join(li_parts) + ")"))
else:
list_sx = sx_call("blog-markets-empty")
return sx_call("blog-markets-panel",
list=SxExpr(list_sx), create_url=create_url,
)
# ---- 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()
has_entries = False
entry_items: 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))
img_sx = sx_call("blog-entry-image", src=cal_fi, title=cal_title)
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_items.append(sx_call("blog-associated-entry",
confirm_text=f"This will remove {e_name} from this post",
toggle_url=toggle_url,
hx_headers=f'{{"X-CSRFToken": "{csrf}"}}',
img=SxExpr(img_sx), name=e_name,
date_str=f"{cal_name} \u2022 {date_str}",
))
if has_entries:
content_sx = sx_call("blog-associated-entries-content",
items=SxExpr("(<> " + " ".join(entry_items) + ")"),
)
else:
content_sx = sx_call("blog-associated-entries-empty")
return sx_call("blog-associated-entries-panel", content=SxExpr(content_sx))
# ---- 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 sx_call("blog-nav-entries-empty")
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", "")
scroll_hs = (
"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"
)
item_parts = []
# 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}/{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}/{cal_slug}/"
date_str = ""
href = events_url_fn(entry_path) if events_url_fn else entry_path
item_parts.append(sx_call("calendar-entry-nav",
href=href, nav_class=nav_cls, name=e_name, date_str=date_str,
))
# Calendar links
for calendar in (calendars or []):
cal_name = getattr(calendar, "name", "")
cal_slug = getattr(calendar, "slug", "")
cal_path = f"/{post_slug}/{cal_slug}/"
href = events_url_fn(cal_path) if events_url_fn else cal_path
item_parts.append(sx_call("blog-nav-calendar-item",
href=href, nav_cls=nav_cls, name=cal_name,
))
items_sx = "(<> " + " ".join(item_parts) + ")" if item_parts else ""
return sx_call("scroll-nav-wrapper",
wrapper_id="entries-calendars-nav-wrapper", container_id="associated-items-container",
arrow_cls="entries-nav-arrow",
left_hs="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft - 200",
scroll_hs=scroll_hs,
right_hs="on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200",
items=SxExpr(items_sx) if items_sx else None, oob=True,
)